admin_asset_service.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. package service
  2. import (
  3. "context"
  4. "mime"
  5. "net/http"
  6. "path"
  7. "path/filepath"
  8. "strings"
  9. "cmr-backend/internal/apperr"
  10. "cmr-backend/internal/platform/assets"
  11. "cmr-backend/internal/platform/security"
  12. "cmr-backend/internal/store/postgres"
  13. )
  14. type AdminAssetService struct {
  15. store *postgres.Store
  16. assetBaseURL string
  17. assetPublisher *assets.OSSUtilPublisher
  18. }
  19. type ManagedAssetSummary struct {
  20. ID string `json:"id"`
  21. AssetType string `json:"assetType"`
  22. AssetCode string `json:"assetCode"`
  23. Version string `json:"version"`
  24. Title *string `json:"title,omitempty"`
  25. SourceMode string `json:"sourceMode"`
  26. StorageProvider string `json:"storageProvider"`
  27. ObjectKey *string `json:"objectKey,omitempty"`
  28. PublicURL string `json:"publicUrl"`
  29. FileName *string `json:"fileName,omitempty"`
  30. ContentType *string `json:"contentType,omitempty"`
  31. FileSizeBytes *int64 `json:"fileSizeBytes,omitempty"`
  32. ChecksumSHA256 *string `json:"checksumSha256,omitempty"`
  33. Status string `json:"status"`
  34. Metadata map[string]any `json:"metadata,omitempty"`
  35. }
  36. type RegisterLinkAssetInput struct {
  37. AssetType string `json:"assetType"`
  38. AssetCode string `json:"assetCode"`
  39. Version string `json:"version"`
  40. Title *string `json:"title,omitempty"`
  41. PublicURL string `json:"publicUrl"`
  42. FileName *string `json:"fileName,omitempty"`
  43. ContentType *string `json:"contentType,omitempty"`
  44. Status string `json:"status"`
  45. Metadata map[string]any `json:"metadata,omitempty"`
  46. }
  47. type UploadAssetFileInput struct {
  48. AssetType string
  49. AssetCode string
  50. Version string
  51. Title *string
  52. ObjectDir *string
  53. FileName string
  54. ContentType string
  55. FileSize int64
  56. Checksum string
  57. TempPath string
  58. Status string
  59. Metadata map[string]any
  60. }
  61. func NewAdminAssetService(store *postgres.Store, assetBaseURL string, assetPublisher *assets.OSSUtilPublisher) *AdminAssetService {
  62. return &AdminAssetService{
  63. store: store,
  64. assetBaseURL: strings.TrimRight(strings.TrimSpace(assetBaseURL), "/"),
  65. assetPublisher: assetPublisher,
  66. }
  67. }
  68. func (s *AdminAssetService) ListManagedAssets(ctx context.Context, limit int) ([]ManagedAssetSummary, error) {
  69. items, err := s.store.ListManagedAssets(ctx, limit)
  70. if err != nil {
  71. return nil, err
  72. }
  73. result := make([]ManagedAssetSummary, 0, len(items))
  74. for _, item := range items {
  75. result = append(result, buildManagedAssetSummary(item))
  76. }
  77. return result, nil
  78. }
  79. func (s *AdminAssetService) GetManagedAsset(ctx context.Context, assetPublicID string) (*ManagedAssetSummary, error) {
  80. record, err := s.store.GetManagedAssetByPublicID(ctx, strings.TrimSpace(assetPublicID))
  81. if err != nil {
  82. return nil, err
  83. }
  84. if record == nil {
  85. return nil, apperr.New(http.StatusNotFound, "asset_not_found", "asset not found")
  86. }
  87. summary := buildManagedAssetSummary(*record)
  88. return &summary, nil
  89. }
  90. func (s *AdminAssetService) RegisterExternalLink(ctx context.Context, input RegisterLinkAssetInput) (*ManagedAssetSummary, error) {
  91. if err := validateManagedAssetInput(input.AssetType, input.AssetCode, input.Version); err != nil {
  92. return nil, err
  93. }
  94. publicURL := strings.TrimSpace(input.PublicURL)
  95. if publicURL == "" {
  96. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "publicUrl is required")
  97. }
  98. publicID, err := security.GeneratePublicID("asset")
  99. if err != nil {
  100. return nil, err
  101. }
  102. tx, err := s.store.Begin(ctx)
  103. if err != nil {
  104. return nil, err
  105. }
  106. defer tx.Rollback(ctx)
  107. record, err := s.store.CreateManagedAsset(ctx, tx, postgres.CreateManagedAssetParams{
  108. PublicID: publicID,
  109. AssetType: normalizeCode(input.AssetType),
  110. AssetCode: normalizeCode(input.AssetCode),
  111. Version: strings.TrimSpace(input.Version),
  112. Title: assetTrimStringPtr(input.Title),
  113. SourceMode: "external_link",
  114. StorageProvider: "external",
  115. ObjectKey: nil,
  116. PublicURL: publicURL,
  117. FileName: assetTrimStringPtr(input.FileName),
  118. ContentType: assetTrimStringPtr(input.ContentType),
  119. FileSizeBytes: nil,
  120. ChecksumSHA256: nil,
  121. Status: normalizeManagedAssetStatus(input.Status),
  122. MetadataJSONB: normalizeJSONMap(input.Metadata),
  123. })
  124. if err != nil {
  125. return nil, err
  126. }
  127. if err := tx.Commit(ctx); err != nil {
  128. return nil, err
  129. }
  130. summary := buildManagedAssetSummary(*record)
  131. return &summary, nil
  132. }
  133. func (s *AdminAssetService) UploadAssetFile(ctx context.Context, input UploadAssetFileInput) (*ManagedAssetSummary, error) {
  134. if err := validateManagedAssetInput(input.AssetType, input.AssetCode, input.Version); err != nil {
  135. return nil, err
  136. }
  137. if !s.assetPublisher.Enabled() {
  138. return nil, apperr.New(http.StatusFailedDependency, "asset_publisher_not_configured", "asset publisher is not configured")
  139. }
  140. if strings.TrimSpace(input.TempPath) == "" || strings.TrimSpace(input.FileName) == "" {
  141. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "upload file is required")
  142. }
  143. objectDir := s.defaultObjectDir(input.AssetType, input.AssetCode, input.Version, input.ObjectDir)
  144. publicURL := s.assetBaseURL + "/" + strings.TrimLeft(path.Join(objectDir, sanitizeFileName(input.FileName)), "/")
  145. if err := s.assetPublisher.UploadFile(ctx, publicURL, input.TempPath); err != nil {
  146. return nil, err
  147. }
  148. publicID, err := security.GeneratePublicID("asset")
  149. if err != nil {
  150. return nil, err
  151. }
  152. objectKey := strings.TrimPrefix(strings.TrimPrefix(publicURL, s.assetBaseURL), "/")
  153. fileName := sanitizeFileName(input.FileName)
  154. contentType := detectContentType(fileName, input.ContentType)
  155. checksum := strings.TrimSpace(input.Checksum)
  156. tx, err := s.store.Begin(ctx)
  157. if err != nil {
  158. return nil, err
  159. }
  160. defer tx.Rollback(ctx)
  161. record, err := s.store.CreateManagedAsset(ctx, tx, postgres.CreateManagedAssetParams{
  162. PublicID: publicID,
  163. AssetType: normalizeCode(input.AssetType),
  164. AssetCode: normalizeCode(input.AssetCode),
  165. Version: strings.TrimSpace(input.Version),
  166. Title: assetTrimStringPtr(input.Title),
  167. SourceMode: "uploaded",
  168. StorageProvider: "oss",
  169. ObjectKey: stringPtr(objectKey),
  170. PublicURL: publicURL,
  171. FileName: stringPtr(fileName),
  172. ContentType: stringPtr(contentType),
  173. FileSizeBytes: &input.FileSize,
  174. ChecksumSHA256: stringPtr(checksum),
  175. Status: normalizeManagedAssetStatus(input.Status),
  176. MetadataJSONB: normalizeJSONMap(input.Metadata),
  177. })
  178. if err != nil {
  179. return nil, err
  180. }
  181. if err := tx.Commit(ctx); err != nil {
  182. return nil, err
  183. }
  184. summary := buildManagedAssetSummary(*record)
  185. return &summary, nil
  186. }
  187. func (s *AdminAssetService) defaultObjectDir(assetType, assetCode, version string, preferred *string) string {
  188. if preferred != nil && strings.TrimSpace(*preferred) != "" {
  189. return strings.Trim(strings.ReplaceAll(strings.TrimSpace(*preferred), "\\", "/"), "/")
  190. }
  191. return path.Join("uploads", normalizeCode(assetType), normalizeCode(assetCode), strings.TrimSpace(version))
  192. }
  193. func buildManagedAssetSummary(record postgres.ManagedAssetRecord) ManagedAssetSummary {
  194. return ManagedAssetSummary{
  195. ID: record.PublicID,
  196. AssetType: record.AssetType,
  197. AssetCode: record.AssetCode,
  198. Version: record.Version,
  199. Title: record.Title,
  200. SourceMode: record.SourceMode,
  201. StorageProvider: record.StorageProvider,
  202. ObjectKey: record.ObjectKey,
  203. PublicURL: record.PublicURL,
  204. FileName: record.FileName,
  205. ContentType: record.ContentType,
  206. FileSizeBytes: record.FileSizeBytes,
  207. ChecksumSHA256: record.ChecksumSHA256,
  208. Status: record.Status,
  209. Metadata: normalizeJSONMap(record.MetadataJSONB),
  210. }
  211. }
  212. func validateManagedAssetInput(assetType, assetCode, version string) error {
  213. if normalizeCode(assetType) == "" || normalizeCode(assetCode) == "" || strings.TrimSpace(version) == "" {
  214. return apperr.New(http.StatusBadRequest, "invalid_params", "assetType, assetCode and version are required")
  215. }
  216. return nil
  217. }
  218. func normalizeManagedAssetStatus(value string) string {
  219. switch strings.ToLower(strings.TrimSpace(value)) {
  220. case "", "active":
  221. return "active"
  222. case "draft", "disabled", "archived":
  223. return strings.ToLower(strings.TrimSpace(value))
  224. default:
  225. return "active"
  226. }
  227. }
  228. func normalizeCode(value string) string {
  229. value = strings.TrimSpace(strings.ToLower(value))
  230. value = strings.ReplaceAll(value, " ", "-")
  231. return value
  232. }
  233. func sanitizeFileName(name string) string {
  234. name = filepath.Base(strings.TrimSpace(name))
  235. name = strings.ReplaceAll(name, " ", "-")
  236. return name
  237. }
  238. func detectContentType(fileName, provided string) string {
  239. if strings.TrimSpace(provided) != "" {
  240. return strings.TrimSpace(provided)
  241. }
  242. if ext := filepath.Ext(fileName); ext != "" {
  243. if guessed := mime.TypeByExtension(ext); guessed != "" {
  244. return guessed
  245. }
  246. }
  247. return "application/octet-stream"
  248. }
  249. func stringPtr(value string) *string {
  250. value = strings.TrimSpace(value)
  251. if value == "" {
  252. return nil
  253. }
  254. return &value
  255. }
  256. func assetTrimStringPtr(value *string) *string {
  257. if value == nil {
  258. return nil
  259. }
  260. trimmed := strings.TrimSpace(*value)
  261. if trimmed == "" {
  262. return nil
  263. }
  264. return &trimmed
  265. }
  266. func normalizeJSONMap(value map[string]any) map[string]any {
  267. if value == nil {
  268. return map[string]any{}
  269. }
  270. return value
  271. }