diff --git a/backend/app/http/tenant/media_asset_admin.go b/backend/app/http/tenant/media_asset_admin.go index 141a950..de68554 100644 --- a/backend/app/http/tenant/media_asset_admin.go +++ b/backend/app/http/tenant/media_asset_admin.go @@ -159,3 +159,36 @@ func (*mediaAssetAdmin) uploadComplete( return services.MediaAsset.AdminUploadComplete(ctx.Context(), tenant.ID, tenantUser.UserID, assetID, form, time.Now()) } + +// adminDelete +// +// @Summary 删除媒体资源(租户管理,软删) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param assetID path int64 true "AssetID" +// @Success 200 {object} models.MediaAsset +// +// @Router /t/:tenantCode/v1/admin/media_assets/:assetID [delete] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind assetID path +func (*mediaAssetAdmin) adminDelete( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + assetID int64, +) (*models.MediaAsset, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "asset_id": assetID, + }).Info("tenant.admin.media_assets.delete") + + return services.MediaAsset.AdminDelete(ctx.Context(), tenant.ID, tenantUser.UserID, assetID, time.Now()) +} diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index 8dc6980..c4412a4 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -134,6 +134,13 @@ func (r *Routes) Register(router fiber.Router) { Query[dto.MyLedgerListFilter]("filter"), )) // Register routes for controller: mediaAssetAdmin + r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/admin/media_assets/:assetID -> mediaAssetAdmin.adminDelete") + router.Delete("/t/:tenantCode/v1/admin/media_assets/:assetID"[len(r.Path()):], DataFunc3( + r.mediaAssetAdmin.adminDelete, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + PathParam[int64]("assetID"), + )) r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/media_assets -> mediaAssetAdmin.adminList") router.Get("/t/:tenantCode/v1/admin/media_assets"[len(r.Path()):], DataFunc3( r.mediaAssetAdmin.adminList, diff --git a/backend/app/services/content.go b/backend/app/services/content.go index 1497603..e8f9e0a 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -187,6 +187,34 @@ func (s *content) AttachAsset(ctx context.Context, tenantID, userID, contentID, "sort": sort, }).Info("services.content.attach_asset") + // 约束:只能绑定本租户内、且已处理完成(ready)的资源;避免未完成处理的资源对外可见。 + tblContent, queryContent := models.ContentQuery.QueryContext(ctx) + if _, err := queryContent.Where( + tblContent.TenantID.Eq(tenantID), + tblContent.ID.Eq(contentID), + ).First(); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("content not found") + } + return nil, err + } + + tblAsset, queryAsset := models.MediaAssetQuery.QueryContext(ctx) + asset, err := queryAsset.Where( + tblAsset.TenantID.Eq(tenantID), + tblAsset.ID.Eq(assetID), + tblAsset.DeletedAt.IsNull(), + ).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("media asset not found") + } + return nil, err + } + if asset.Status != consts.MediaAssetStatusReady { + return nil, errorx.ErrPreconditionFailed.WithMsg("media asset not ready") + } + m := &models.ContentAsset{ TenantID: tenantID, UserID: userID, diff --git a/backend/app/services/media_asset.go b/backend/app/services/media_asset.go index bf6b7ad..a3003df 100644 --- a/backend/app/services/media_asset.go +++ b/backend/app/services/media_asset.go @@ -31,6 +31,19 @@ import ( // @provider type mediaAsset struct{} +func mediaAssetTransitionAllowed(from, to consts.MediaAssetStatus) bool { + switch from { + case consts.MediaAssetStatusUploaded: + return to == consts.MediaAssetStatusProcessing + case consts.MediaAssetStatusProcessing: + return to == consts.MediaAssetStatusReady || to == consts.MediaAssetStatusFailed + case consts.MediaAssetStatusReady, consts.MediaAssetStatusFailed: + return to == consts.MediaAssetStatusDeleted + default: + return false + } +} + func newObjectKey(tenantID, userID int64, assetType consts.MediaAssetType, now time.Time) (string, error) { // object_key 作为存储定位的关键字段:必须由服务端生成,避免客户端路径注入与越权覆盖。 buf := make([]byte, 16) // 128-bit @@ -200,6 +213,9 @@ func (s *mediaAsset) AdminUploadComplete( } // 状态迁移:uploaded -> processing + if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusProcessing) { + return errorx.ErrStatusConflict.WithMsg("invalid media asset status transition") + } if err := tx.Model(&models.MediaAsset{}). Where("id = ?", m.ID). Updates(map[string]any{ @@ -231,6 +247,215 @@ func (s *mediaAsset) AdminUploadComplete( return &out, nil } +// ProcessSuccess marks a processing asset as ready. +// 用于异步处理链路(worker/job)回写处理结果;当前不暴露 HTTP 接口。 +func (s *mediaAsset) ProcessSuccess( + ctx context.Context, + tenantID, assetID int64, + metaPatch map[string]any, + now time.Time, +) (*models.MediaAsset, error) { + if tenantID <= 0 || assetID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/asset_id must be > 0") + } + if now.IsZero() { + now = time.Now() + } + + var out models.MediaAsset + err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var m models.MediaAsset + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("tenant_id = ? AND id = ?", tenantID, assetID). + First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("media asset not found") + } + return err + } + if m.DeletedAt.Valid || m.Status == consts.MediaAssetStatusDeleted { + return errorx.ErrPreconditionFailed.WithMsg("media asset deleted") + } + if m.Status == consts.MediaAssetStatusReady { + out = m + return nil + } + if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusReady) { + return errorx.ErrStatusConflict.WithMsg("invalid media asset status transition") + } + + meta := map[string]any{} + if len(m.Meta) > 0 { + _ = json.Unmarshal(m.Meta, &meta) + } + for k, v := range metaPatch { + if strings.TrimSpace(k) == "" { + continue + } + meta[k] = v + } + meta["processed_at"] = now.UTC().Format(time.RFC3339Nano) + metaBytes, _ := json.Marshal(meta) + if len(metaBytes) == 0 { + metaBytes = []byte("{}") + } + + if err := tx.Model(&models.MediaAsset{}). + Where("id = ?", m.ID). + Updates(map[string]any{ + "status": consts.MediaAssetStatusReady, + "meta": types.JSON(metaBytes), + "updated_at": now, + }).Error; err != nil { + return err + } + m.Status = consts.MediaAssetStatusReady + m.Meta = types.JSON(metaBytes) + m.UpdatedAt = now + out = m + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +// ProcessFailed marks a processing asset as failed. +// 用于异步处理链路(worker/job)回写处理结果;当前不暴露 HTTP 接口。 +func (s *mediaAsset) ProcessFailed( + ctx context.Context, + tenantID, assetID int64, + reason string, + now time.Time, +) (*models.MediaAsset, error) { + if tenantID <= 0 || assetID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/asset_id must be > 0") + } + if now.IsZero() { + now = time.Now() + } + + var out models.MediaAsset + err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var m models.MediaAsset + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("tenant_id = ? AND id = ?", tenantID, assetID). + First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("media asset not found") + } + return err + } + if m.DeletedAt.Valid || m.Status == consts.MediaAssetStatusDeleted { + return errorx.ErrPreconditionFailed.WithMsg("media asset deleted") + } + if m.Status == consts.MediaAssetStatusFailed { + out = m + return nil + } + if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusFailed) { + return errorx.ErrStatusConflict.WithMsg("invalid media asset status transition") + } + + meta := map[string]any{} + if len(m.Meta) > 0 { + _ = json.Unmarshal(m.Meta, &meta) + } + if strings.TrimSpace(reason) != "" { + meta["failed_reason"] = strings.TrimSpace(reason) + } + meta["failed_at"] = now.UTC().Format(time.RFC3339Nano) + metaBytes, _ := json.Marshal(meta) + if len(metaBytes) == 0 { + metaBytes = []byte("{}") + } + + if err := tx.Model(&models.MediaAsset{}). + Where("id = ?", m.ID). + Updates(map[string]any{ + "status": consts.MediaAssetStatusFailed, + "meta": types.JSON(metaBytes), + "updated_at": now, + }).Error; err != nil { + return err + } + m.Status = consts.MediaAssetStatusFailed + m.Meta = types.JSON(metaBytes) + m.UpdatedAt = now + out = m + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +// AdminDelete soft-deletes a media asset (ready/failed -> deleted). +func (s *mediaAsset) AdminDelete(ctx context.Context, tenantID, operatorUserID, assetID int64, now time.Time) (*models.MediaAsset, error) { + if tenantID <= 0 || operatorUserID <= 0 || assetID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/asset_id must be > 0") + } + if now.IsZero() { + now = time.Now() + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": operatorUserID, + "asset_id": assetID, + }).Info("services.media_asset.admin.delete") + + var out models.MediaAsset + err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var m models.MediaAsset + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("tenant_id = ? AND id = ?", tenantID, assetID). + First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("media asset not found") + } + return err + } + + // 幂等:已删除直接返回。 + if m.DeletedAt.Valid || m.Status == consts.MediaAssetStatusDeleted { + out = m + return nil + } + + if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusDeleted) { + return errorx.ErrStatusConflict.WithMsg("invalid media asset status transition") + } + + if err := tx.Model(&models.MediaAsset{}). + Where("id = ?", m.ID). + Updates(map[string]any{ + "status": consts.MediaAssetStatusDeleted, + "updated_at": now, + }).Error; err != nil { + return err + } + + if err := tx.Delete(&m).Error; err != nil { + return err + } + + m.Status = consts.MediaAssetStatusDeleted + m.UpdatedAt = now + out = m + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + // AdminPage 分页查询租户内媒体资源(租户管理)。 func (s *mediaAsset) AdminPage(ctx context.Context, tenantID int64, filter *tenant_dto.AdminMediaAssetListFilter) (*requests.Pager, error) { if tenantID <= 0 { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 12d4fe4..b0644ed 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1131,6 +1131,43 @@ const docTemplate = `{ } } } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "删除媒体资源(租户管理,软删)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "AssetID", + "name": "assetID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.MediaAsset" + } + } + } } }, "/t/{tenantCode}/v1/admin/media_assets/{assetID}/upload_complete": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 769d7c2..18efe2d 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1125,6 +1125,43 @@ } } } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "删除媒体资源(租户管理,软删)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "AssetID", + "name": "assetID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.MediaAsset" + } + } + } } }, "/t/{tenantCode}/v1/admin/media_assets/{assetID}/upload_complete": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index e592f40..2259ffe 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -2002,6 +2002,31 @@ paths: tags: - Tenant /t/{tenantCode}/v1/admin/media_assets/{assetID}: + delete: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: AssetID + format: int64 + in: path + name: assetID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.MediaAsset' + summary: 删除媒体资源(租户管理,软删) + tags: + - Tenant get: consumes: - application/json diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 19f1178..9ae8162 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -159,6 +159,11 @@ GET {{ host }}/t/{{ tenantCode }}/v1/admin/media_assets/{{ assetID }} Content-Type: application/json Authorization: Bearer {{ token }} +### Tenant Admin - MediaAsset delete (soft delete; ready/failed only) +DELETE {{ host }}/t/{{ tenantCode }}/v1/admin/media_assets/{{ assetID }} +Content-Type: application/json +Authorization: Bearer {{ token }} + ### Tenant Admin - Attach asset to content (main/cover/preview) @assetID = 1 POST {{ host }}/t/{{ tenantCode }}/v1/admin/contents/{{ contentID }}/assets