diff --git a/backend/app/http/tenant/dto/media_asset_admin_list.go b/backend/app/http/tenant/dto/media_asset_admin_list.go new file mode 100644 index 0000000..7033e84 --- /dev/null +++ b/backend/app/http/tenant/dto/media_asset_admin_list.go @@ -0,0 +1,29 @@ +package dto + +import ( + "time" + + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) + +// AdminMediaAssetListFilter defines tenant-admin list query filters for media assets. +type AdminMediaAssetListFilter struct { + // Pagination defines page/limit; page is 1-based, limit uses the global whitelist. + requests.Pagination `json:",inline" query:",inline"` + + // SortQueryFilter defines asc/desc ordering; service layer applies a whitelist. + requests.SortQueryFilter `json:",inline" query:",inline"` + + // Type filters by media type (video/audio/image); optional. + Type *consts.MediaAssetType `json:"type,omitempty" query:"type"` + + // Status filters by processing status (uploaded/processing/ready/failed/deleted); optional. + Status *consts.MediaAssetStatus `json:"status,omitempty" query:"status"` + + // CreatedAtFrom filters assets by created_at >= this time; optional. + CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` + + // CreatedAtTo filters assets by created_at <= this time; optional. + CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` +} diff --git a/backend/app/http/tenant/media_asset_admin.go b/backend/app/http/tenant/media_asset_admin.go index 5c5da6b..141a950 100644 --- a/backend/app/http/tenant/media_asset_admin.go +++ b/backend/app/http/tenant/media_asset_admin.go @@ -5,6 +5,7 @@ import ( "quyun/v2/app/errorx" "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" "quyun/v2/app/services" "quyun/v2/database/models" @@ -17,6 +18,76 @@ import ( // @provider type mediaAssetAdmin struct{} +// adminList +// +// @Summary 媒体资源列表(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param filter query dto.AdminMediaAssetListFilter true "Filter" +// @Success 200 {object} requests.Pager{items=models.MediaAsset} +// +// @Router /t/:tenantCode/v1/admin/media_assets [get] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind filter query +func (*mediaAssetAdmin) adminList( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + filter *dto.AdminMediaAssetListFilter, +) (*requests.Pager, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if filter == nil { + filter = &dto.AdminMediaAssetListFilter{} + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "type": filter.Type, + "status": filter.Status, + }).Info("tenant.admin.media_assets.list") + + return services.MediaAsset.AdminPage(ctx.Context(), tenant.ID, filter) +} + +// adminDetail +// +// @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 [get] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind assetID path +func (*mediaAssetAdmin) adminDetail( + 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.detail") + + return services.MediaAsset.AdminDetail(ctx.Context(), tenant.ID, assetID) +} + // uploadInit // // @Summary 初始化媒体资源上传(租户管理) diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index 661c8c1..8dc6980 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -134,6 +134,20 @@ func (r *Routes) Register(router fiber.Router) { Query[dto.MyLedgerListFilter]("filter"), )) // Register routes for controller: mediaAssetAdmin + 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, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + Query[dto.AdminMediaAssetListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/media_assets/:assetID -> mediaAssetAdmin.adminDetail") + router.Get("/t/:tenantCode/v1/admin/media_assets/:assetID"[len(r.Path()):], DataFunc3( + r.mediaAssetAdmin.adminDetail, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + PathParam[int64]("assetID"), + )) r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/media_assets/:assetID/upload_complete -> mediaAssetAdmin.uploadComplete") router.Post("/t/:tenantCode/v1/admin/media_assets/:assetID/upload_complete"[len(r.Path()):], DataFunc4( r.mediaAssetAdmin.uploadComplete, diff --git a/backend/app/services/media_asset.go b/backend/app/services/media_asset.go index 491ba8b..bf6b7ad 100644 --- a/backend/app/services/media_asset.go +++ b/backend/app/services/media_asset.go @@ -11,12 +11,16 @@ import ( "time" "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" + tenant_dto "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" "quyun/v2/database/models" "quyun/v2/pkg/consts" pkgerrors "github.com/pkg/errors" + "github.com/samber/lo" "github.com/sirupsen/logrus" + "go.ipao.vip/gen" + "go.ipao.vip/gen/field" "go.ipao.vip/gen/types" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -44,7 +48,7 @@ func newObjectKey(tenantID, userID int64, assetType consts.MediaAssetType, now t // AdminUploadInit creates a MediaAsset record and returns upload parameters. // 当前版本为“stub 上传初始化”:只负责生成 asset 与 object_key,不对接外部存储签名。 -func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUserID int64, form *dto.AdminMediaAssetUploadInitForm, now time.Time) (*dto.AdminMediaAssetUploadInitResponse, error) { +func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUserID int64, form *tenant_dto.AdminMediaAssetUploadInitForm, now time.Time) (*tenant_dto.AdminMediaAssetUploadInitResponse, error) { if tenantID <= 0 || operatorUserID <= 0 { return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id must be > 0") } @@ -105,7 +109,7 @@ func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUser }).Info("services.media_asset.admin.upload_init") // 约定:upload_url 先返回空或内部占位;后续接入真实存储签名后再补齐。 - return &dto.AdminMediaAssetUploadInitResponse{ + return &tenant_dto.AdminMediaAssetUploadInitResponse{ AssetID: m.ID, Provider: m.Provider, Bucket: m.Bucket, @@ -124,7 +128,7 @@ func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUser func (s *mediaAsset) AdminUploadComplete( ctx context.Context, tenantID, operatorUserID, assetID int64, - form *dto.AdminMediaAssetUploadCompleteForm, + form *tenant_dto.AdminMediaAssetUploadCompleteForm, now time.Time, ) (*models.MediaAsset, error) { if tenantID <= 0 || operatorUserID <= 0 || assetID <= 0 { @@ -226,3 +230,117 @@ func (s *mediaAsset) AdminUploadComplete( } return &out, nil } + +// AdminPage 分页查询租户内媒体资源(租户管理)。 +func (s *mediaAsset) AdminPage(ctx context.Context, tenantID int64, filter *tenant_dto.AdminMediaAssetListFilter) (*requests.Pager, error) { + if tenantID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") + } + if filter == nil { + filter = &tenant_dto.AdminMediaAssetListFilter{} + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "type": lo.FromPtr(filter.Type), + "status": lo.FromPtr(filter.Status), + "created_at_from": filter.CreatedAtFrom, + "created_at_to": filter.CreatedAtTo, + "sort_asc_fields": filter.AscFields(), + "sort_desc_fields": filter.DescFields(), + }).Info("services.media_asset.admin.page") + + filter.Pagination.Format() + + tbl, query := models.MediaAssetQuery.QueryContext(ctx) + + conds := []gen.Condition{ + tbl.TenantID.Eq(tenantID), + tbl.DeletedAt.IsNull(), + } + if filter.Type != nil { + conds = append(conds, tbl.Type.Eq(*filter.Type)) + } + if filter.Status != nil { + conds = append(conds, tbl.Status.Eq(*filter.Status)) + } + if filter.CreatedAtFrom != nil { + conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) + } + if filter.CreatedAtTo != nil { + conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) + } + + // 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。 + orderBys := make([]field.Expr, 0, 4) + allowedAsc := map[string]field.Expr{ + "id": tbl.ID.Asc(), + "created_at": tbl.CreatedAt.Asc(), + "updated_at": tbl.UpdatedAt.Asc(), + } + allowedDesc := map[string]field.Expr{ + "id": tbl.ID.Desc(), + "created_at": tbl.CreatedAt.Desc(), + "updated_at": tbl.UpdatedAt.Desc(), + } + for _, f := range filter.AscFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedAsc[f]; ok { + orderBys = append(orderBys, ob) + } + } + for _, f := range filter.DescFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedDesc[f]; ok { + orderBys = append(orderBys, ob) + } + } + if len(orderBys) == 0 { + orderBys = append(orderBys, tbl.ID.Desc()) + } else { + orderBys = append(orderBys, tbl.ID.Desc()) + } + + items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +// AdminDetail 查询租户内媒体资源详情(租户管理)。 +func (s *mediaAsset) AdminDetail(ctx context.Context, tenantID, assetID int64) (*models.MediaAsset, error) { + if tenantID <= 0 || assetID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/asset_id must be > 0") + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "asset_id": assetID, + }).Info("services.media_asset.admin.detail") + + tbl, query := models.MediaAssetQuery.QueryContext(ctx) + m, err := query.Where( + tbl.TenantID.Eq(tenantID), + tbl.ID.Eq(assetID), + tbl.DeletedAt.IsNull(), + ).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("media asset not found") + } + return nil, err + } + return m, nil +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 2c63696..12d4fe4 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -939,6 +939,121 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/admin/media_assets": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "媒体资源列表(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", + "name": "asc", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtFrom filters assets by created_at \u003e= this time; optional.", + "name": "created_at_from", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtTo filters assets by created_at \u003c= this time; optional.", + "name": "created_at_to", + "in": "query" + }, + { + "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", + "name": "desc", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "uploaded", + "processing", + "ready", + "failed", + "deleted" + ], + "type": "string", + "x-enum-varnames": [ + "MediaAssetStatusUploaded", + "MediaAssetStatusProcessing", + "MediaAssetStatusReady", + "MediaAssetStatusFailed", + "MediaAssetStatusDeleted" + ], + "description": "Status filters by processing status (uploaded/processing/ready/failed/deleted); optional.", + "name": "status", + "in": "query" + }, + { + "enum": [ + "video", + "audio", + "image" + ], + "type": "string", + "x-enum-varnames": [ + "MediaAssetTypeVideo", + "MediaAssetTypeAudio", + "MediaAssetTypeImage" + ], + "description": "Type filters by media type (video/audio/image); optional.", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/models.MediaAsset" + } + } + } + ] + } + } + } + } + }, "/t/{tenantCode}/v1/admin/media_assets/upload_init": { "post": { "consumes": [ @@ -979,6 +1094,45 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/admin/media_assets/{assetID}": { + "get": { + "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": { "post": { "consumes": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index b2d47e9..769d7c2 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -933,6 +933,121 @@ } } }, + "/t/{tenantCode}/v1/admin/media_assets": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "媒体资源列表(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", + "name": "asc", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtFrom filters assets by created_at \u003e= this time; optional.", + "name": "created_at_from", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtTo filters assets by created_at \u003c= this time; optional.", + "name": "created_at_to", + "in": "query" + }, + { + "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", + "name": "desc", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "uploaded", + "processing", + "ready", + "failed", + "deleted" + ], + "type": "string", + "x-enum-varnames": [ + "MediaAssetStatusUploaded", + "MediaAssetStatusProcessing", + "MediaAssetStatusReady", + "MediaAssetStatusFailed", + "MediaAssetStatusDeleted" + ], + "description": "Status filters by processing status (uploaded/processing/ready/failed/deleted); optional.", + "name": "status", + "in": "query" + }, + { + "enum": [ + "video", + "audio", + "image" + ], + "type": "string", + "x-enum-varnames": [ + "MediaAssetTypeVideo", + "MediaAssetTypeAudio", + "MediaAssetTypeImage" + ], + "description": "Type filters by media type (video/audio/image); optional.", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/models.MediaAsset" + } + } + } + ] + } + } + } + } + }, "/t/{tenantCode}/v1/admin/media_assets/upload_init": { "post": { "consumes": [ @@ -973,6 +1088,45 @@ } } }, + "/t/{tenantCode}/v1/admin/media_assets/{assetID}": { + "get": { + "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": { "post": { "consumes": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 36c0ae4..e592f40 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1921,6 +1921,112 @@ paths: summary: 拒绝加入申请(租户管理) tags: - Tenant + /t/{tenantCode}/v1/admin/media_assets: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Asc specifies comma-separated field names to sort ascending by. + in: query + name: asc + type: string + - description: CreatedAtFrom filters assets by created_at >= this time; optional. + in: query + name: created_at_from + type: string + - description: CreatedAtTo filters assets by created_at <= this time; optional. + in: query + name: created_at_to + type: string + - description: Desc specifies comma-separated field names to sort descending + by. + in: query + name: desc + type: string + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query + name: limit + type: integer + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query + name: page + type: integer + - description: Status filters by processing status (uploaded/processing/ready/failed/deleted); + optional. + enum: + - uploaded + - processing + - ready + - failed + - deleted + in: query + name: status + type: string + x-enum-varnames: + - MediaAssetStatusUploaded + - MediaAssetStatusProcessing + - MediaAssetStatusReady + - MediaAssetStatusFailed + - MediaAssetStatusDeleted + - description: Type filters by media type (video/audio/image); optional. + enum: + - video + - audio + - image + in: query + name: type + type: string + x-enum-varnames: + - MediaAssetTypeVideo + - MediaAssetTypeAudio + - MediaAssetTypeImage + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + $ref: '#/definitions/models.MediaAsset' + type: object + summary: 媒体资源列表(租户管理) + tags: + - Tenant + /t/{tenantCode}/v1/admin/media_assets/{assetID}: + get: + 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 /t/{tenantCode}/v1/admin/media_assets/{assetID}/upload_complete: post: consumes: diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 568127e..19f1178 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -144,6 +144,21 @@ Authorization: Bearer {{ token }} "sha256": "" } +### Tenant Admin - MediaAssets list (paged) +GET {{ host }}/t/{{ tenantCode }}/v1/admin/media_assets?page=1&limit=20 +Content-Type: application/json +Authorization: Bearer {{ token }} + +### Tenant Admin - MediaAssets list (filter + sort) +GET {{ host }}/t/{{ tenantCode }}/v1/admin/media_assets?page=1&limit=20&type=video&status=processing&created_at_from=2025-01-01T00:00:00Z&created_at_to=2026-01-01T00:00:00Z&asc=created_at&desc=updated_at +Content-Type: application/json +Authorization: Bearer {{ token }} + +### Tenant Admin - MediaAsset detail +GET {{ 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