Files
cmr-mini/backend/internal/service/admin_asset_service.go

304 lines
9.4 KiB
Go

package service
import (
"context"
"mime"
"net/http"
"path"
"path/filepath"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/assets"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type AdminAssetService struct {
store *postgres.Store
assetBaseURL string
assetPublisher *assets.OSSUtilPublisher
}
type ManagedAssetSummary struct {
ID string `json:"id"`
AssetType string `json:"assetType"`
AssetCode string `json:"assetCode"`
Version string `json:"version"`
Title *string `json:"title,omitempty"`
SourceMode string `json:"sourceMode"`
StorageProvider string `json:"storageProvider"`
ObjectKey *string `json:"objectKey,omitempty"`
PublicURL string `json:"publicUrl"`
FileName *string `json:"fileName,omitempty"`
ContentType *string `json:"contentType,omitempty"`
FileSizeBytes *int64 `json:"fileSizeBytes,omitempty"`
ChecksumSHA256 *string `json:"checksumSha256,omitempty"`
Status string `json:"status"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type RegisterLinkAssetInput struct {
AssetType string `json:"assetType"`
AssetCode string `json:"assetCode"`
Version string `json:"version"`
Title *string `json:"title,omitempty"`
PublicURL string `json:"publicUrl"`
FileName *string `json:"fileName,omitempty"`
ContentType *string `json:"contentType,omitempty"`
Status string `json:"status"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type UploadAssetFileInput struct {
AssetType string
AssetCode string
Version string
Title *string
ObjectDir *string
FileName string
ContentType string
FileSize int64
Checksum string
TempPath string
Status string
Metadata map[string]any
}
func NewAdminAssetService(store *postgres.Store, assetBaseURL string, assetPublisher *assets.OSSUtilPublisher) *AdminAssetService {
return &AdminAssetService{
store: store,
assetBaseURL: strings.TrimRight(strings.TrimSpace(assetBaseURL), "/"),
assetPublisher: assetPublisher,
}
}
func (s *AdminAssetService) ListManagedAssets(ctx context.Context, limit int) ([]ManagedAssetSummary, error) {
items, err := s.store.ListManagedAssets(ctx, limit)
if err != nil {
return nil, err
}
result := make([]ManagedAssetSummary, 0, len(items))
for _, item := range items {
result = append(result, buildManagedAssetSummary(item))
}
return result, nil
}
func (s *AdminAssetService) GetManagedAsset(ctx context.Context, assetPublicID string) (*ManagedAssetSummary, error) {
record, err := s.store.GetManagedAssetByPublicID(ctx, strings.TrimSpace(assetPublicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, apperr.New(http.StatusNotFound, "asset_not_found", "asset not found")
}
summary := buildManagedAssetSummary(*record)
return &summary, nil
}
func (s *AdminAssetService) RegisterExternalLink(ctx context.Context, input RegisterLinkAssetInput) (*ManagedAssetSummary, error) {
if err := validateManagedAssetInput(input.AssetType, input.AssetCode, input.Version); err != nil {
return nil, err
}
publicURL := strings.TrimSpace(input.PublicURL)
if publicURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "publicUrl is required")
}
publicID, err := security.GeneratePublicID("asset")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.CreateManagedAsset(ctx, tx, postgres.CreateManagedAssetParams{
PublicID: publicID,
AssetType: normalizeCode(input.AssetType),
AssetCode: normalizeCode(input.AssetCode),
Version: strings.TrimSpace(input.Version),
Title: assetTrimStringPtr(input.Title),
SourceMode: "external_link",
StorageProvider: "external",
ObjectKey: nil,
PublicURL: publicURL,
FileName: assetTrimStringPtr(input.FileName),
ContentType: assetTrimStringPtr(input.ContentType),
FileSizeBytes: nil,
ChecksumSHA256: nil,
Status: normalizeManagedAssetStatus(input.Status),
MetadataJSONB: normalizeJSONMap(input.Metadata),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
summary := buildManagedAssetSummary(*record)
return &summary, nil
}
func (s *AdminAssetService) UploadAssetFile(ctx context.Context, input UploadAssetFileInput) (*ManagedAssetSummary, error) {
if err := validateManagedAssetInput(input.AssetType, input.AssetCode, input.Version); err != nil {
return nil, err
}
if !s.assetPublisher.Enabled() {
return nil, apperr.New(http.StatusFailedDependency, "asset_publisher_not_configured", "asset publisher is not configured")
}
if strings.TrimSpace(input.TempPath) == "" || strings.TrimSpace(input.FileName) == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "upload file is required")
}
objectDir := s.defaultObjectDir(input.AssetType, input.AssetCode, input.Version, input.ObjectDir)
publicURL := s.assetBaseURL + "/" + strings.TrimLeft(path.Join(objectDir, sanitizeFileName(input.FileName)), "/")
if err := s.assetPublisher.UploadFile(ctx, publicURL, input.TempPath); err != nil {
return nil, err
}
publicID, err := security.GeneratePublicID("asset")
if err != nil {
return nil, err
}
objectKey := strings.TrimPrefix(strings.TrimPrefix(publicURL, s.assetBaseURL), "/")
fileName := sanitizeFileName(input.FileName)
contentType := detectContentType(fileName, input.ContentType)
checksum := strings.TrimSpace(input.Checksum)
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.CreateManagedAsset(ctx, tx, postgres.CreateManagedAssetParams{
PublicID: publicID,
AssetType: normalizeCode(input.AssetType),
AssetCode: normalizeCode(input.AssetCode),
Version: strings.TrimSpace(input.Version),
Title: assetTrimStringPtr(input.Title),
SourceMode: "uploaded",
StorageProvider: "oss",
ObjectKey: stringPtr(objectKey),
PublicURL: publicURL,
FileName: stringPtr(fileName),
ContentType: stringPtr(contentType),
FileSizeBytes: &input.FileSize,
ChecksumSHA256: stringPtr(checksum),
Status: normalizeManagedAssetStatus(input.Status),
MetadataJSONB: normalizeJSONMap(input.Metadata),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
summary := buildManagedAssetSummary(*record)
return &summary, nil
}
func (s *AdminAssetService) defaultObjectDir(assetType, assetCode, version string, preferred *string) string {
if preferred != nil && strings.TrimSpace(*preferred) != "" {
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(*preferred), "\\", "/"), "/")
}
return path.Join("uploads", normalizeCode(assetType), normalizeCode(assetCode), strings.TrimSpace(version))
}
func buildManagedAssetSummary(record postgres.ManagedAssetRecord) ManagedAssetSummary {
return ManagedAssetSummary{
ID: record.PublicID,
AssetType: record.AssetType,
AssetCode: record.AssetCode,
Version: record.Version,
Title: record.Title,
SourceMode: record.SourceMode,
StorageProvider: record.StorageProvider,
ObjectKey: record.ObjectKey,
PublicURL: record.PublicURL,
FileName: record.FileName,
ContentType: record.ContentType,
FileSizeBytes: record.FileSizeBytes,
ChecksumSHA256: record.ChecksumSHA256,
Status: record.Status,
Metadata: normalizeJSONMap(record.MetadataJSONB),
}
}
func validateManagedAssetInput(assetType, assetCode, version string) error {
if normalizeCode(assetType) == "" || normalizeCode(assetCode) == "" || strings.TrimSpace(version) == "" {
return apperr.New(http.StatusBadRequest, "invalid_params", "assetType, assetCode and version are required")
}
return nil
}
func normalizeManagedAssetStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "active":
return "active"
case "draft", "disabled", "archived":
return strings.ToLower(strings.TrimSpace(value))
default:
return "active"
}
}
func normalizeCode(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
value = strings.ReplaceAll(value, " ", "-")
return value
}
func sanitizeFileName(name string) string {
name = filepath.Base(strings.TrimSpace(name))
name = strings.ReplaceAll(name, " ", "-")
return name
}
func detectContentType(fileName, provided string) string {
if strings.TrimSpace(provided) != "" {
return strings.TrimSpace(provided)
}
if ext := filepath.Ext(fileName); ext != "" {
if guessed := mime.TypeByExtension(ext); guessed != "" {
return guessed
}
}
return "application/octet-stream"
}
func stringPtr(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func assetTrimStringPtr(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func normalizeJSONMap(value map[string]any) map[string]any {
if value == nil {
return map[string]any{}
}
return value
}