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 }