diff --git a/backend/app/http/tenant/dto/media_asset_admin.go b/backend/app/http/tenant/dto/media_asset_admin.go new file mode 100644 index 0000000..b4bd9e6 --- /dev/null +++ b/backend/app/http/tenant/dto/media_asset_admin.go @@ -0,0 +1,57 @@ +package dto + +import "time" + +// AdminMediaAssetUploadInitForm defines payload for tenant-admin to initialize a media asset upload. +type AdminMediaAssetUploadInitForm struct { + // Type is the media asset type (video/audio/image). + // Used to decide processing pipeline and validation rules; required. + Type string `json:"type,omitempty"` + // ContentType is the MIME type reported by the client (e.g. video/mp4); optional. + // Server should not fully trust it, but can use it as a hint for validation/logging. + ContentType string `json:"content_type,omitempty"` + // FileSize is the expected file size in bytes; optional. + // Used for quota/limit checks and audit; client may omit when unknown. + FileSize int64 `json:"file_size,omitempty"` + // SHA256 is the hex-encoded sha256 of the file; optional. + // Used for deduplication/audit; server may validate it later during upload-complete. + SHA256 string `json:"sha256,omitempty"` +} + +// AdminMediaAssetUploadInitResponse returns server-generated upload parameters and the created asset id. +type AdminMediaAssetUploadInitResponse struct { + // AssetID is the created media asset id. + AssetID int64 `json:"asset_id"` + // Provider is the storage provider identifier (e.g. s3/minio/oss/local); for debugging/audit. + Provider string `json:"provider,omitempty"` + // Bucket is the target bucket/container; for debugging/audit (may be empty in stub mode). + Bucket string `json:"bucket,omitempty"` + // ObjectKey is the server-generated object key/path; client must NOT choose it. + ObjectKey string `json:"object_key,omitempty"` + + // UploadURL is the URL the client should upload to (signed URL or service endpoint). + UploadURL string `json:"upload_url,omitempty"` + // Headers are additional headers required for upload (e.g. signed headers); optional. + Headers map[string]string `json:"headers,omitempty"` + // FormFields are form fields required for multipart form upload (S3 POST policy); optional. + FormFields map[string]string `json:"form_fields,omitempty"` + // ExpiresAt indicates when UploadURL/FormFields expire; optional. + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// AdminMediaAssetUploadCompleteForm defines payload for tenant-admin to mark a media upload as completed. +// This endpoint is expected to be called after the client finishes uploading the object to storage. +type AdminMediaAssetUploadCompleteForm struct { + // ETag is the storage returned ETag (or similar checksum); optional. + // Used for audit/debugging and later integrity verification. + ETag string `json:"etag,omitempty"` + // ContentType is the MIME type observed during upload; optional. + // Server may record it for audit and later processing decisions. + ContentType string `json:"content_type,omitempty"` + // FileSize is the uploaded object size in bytes; optional. + // Server records it for quota/audit and later validation. + FileSize int64 `json:"file_size,omitempty"` + // SHA256 is the hex-encoded sha256 of the uploaded object; optional. + // Server records it for integrity checks/deduplication. + SHA256 string `json:"sha256,omitempty"` +} diff --git a/backend/app/http/tenant/media_asset_admin.go b/backend/app/http/tenant/media_asset_admin.go new file mode 100644 index 0000000..5c5da6b --- /dev/null +++ b/backend/app/http/tenant/media_asset_admin.go @@ -0,0 +1,90 @@ +package tenant + +import ( + "time" + + "quyun/v2/app/errorx" + "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/services" + "quyun/v2/database/models" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" +) + +// mediaAssetAdmin provides tenant-admin media asset endpoints. +// +// @provider +type mediaAssetAdmin struct{} + +// uploadInit +// +// @Summary 初始化媒体资源上传(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param form body dto.AdminMediaAssetUploadInitForm true "Form" +// @Success 200 {object} dto.AdminMediaAssetUploadInitResponse +// +// @Router /t/:tenantCode/v1/admin/media_assets/upload_init [post] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind form body +func (*mediaAssetAdmin) uploadInit( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + form *dto.AdminMediaAssetUploadInitForm, +) (*dto.AdminMediaAssetUploadInitResponse, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if form == nil { + return nil, errorx.ErrInvalidParameter + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "type": form.Type, + }).Info("tenant.admin.media_assets.upload_init") + + return services.MediaAsset.AdminUploadInit(ctx.Context(), tenant.ID, tenantUser.UserID, form, time.Now()) +} + +// uploadComplete +// +// @Summary 确认上传完成并进入处理(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param assetID path int64 true "AssetID" +// @Param form body dto.AdminMediaAssetUploadCompleteForm false "Form" +// @Success 200 {object} models.MediaAsset +// +// @Router /t/:tenantCode/v1/admin/media_assets/:assetID/upload_complete [post] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind assetID path +// @Bind form body +func (*mediaAssetAdmin) uploadComplete( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + assetID int64, + form *dto.AdminMediaAssetUploadCompleteForm, +) (*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.upload_complete") + + return services.MediaAsset.AdminUploadComplete(ctx.Context(), tenant.ID, tenantUser.UserID, assetID, form, time.Now()) +} diff --git a/backend/app/http/tenant/provider.gen.go b/backend/app/http/tenant/provider.gen.go index 19fbd7a..83e23ef 100755 --- a/backend/app/http/tenant/provider.gen.go +++ b/backend/app/http/tenant/provider.gen.go @@ -31,6 +31,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*mediaAssetAdmin, error) { + obj := &mediaAssetAdmin{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*order, error) { obj := &order{} @@ -56,6 +63,7 @@ func Provide(opts ...opt.Option) error { content *content, contentAdmin *contentAdmin, me *me, + mediaAssetAdmin *mediaAssetAdmin, middlewares *middlewares.Middlewares, order *order, orderAdmin *orderAdmin, @@ -68,6 +76,7 @@ func Provide(opts ...opt.Option) error { content: content, contentAdmin: contentAdmin, me: me, + mediaAssetAdmin: mediaAssetAdmin, middlewares: middlewares, order: order, orderAdmin: orderAdmin, diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index 2d498c3..661c8c1 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -27,6 +27,7 @@ type Routes struct { content *content contentAdmin *contentAdmin me *me + mediaAssetAdmin *mediaAssetAdmin order *order orderAdmin *orderAdmin orderMe *orderMe @@ -132,6 +133,22 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("user"), Query[dto.MyLedgerListFilter]("filter"), )) + // Register routes for controller: mediaAssetAdmin + 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, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + PathParam[int64]("assetID"), + Body[dto.AdminMediaAssetUploadCompleteForm]("form"), + )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/media_assets/upload_init -> mediaAssetAdmin.uploadInit") + router.Post("/t/:tenantCode/v1/admin/media_assets/upload_init"[len(r.Path()):], DataFunc3( + r.mediaAssetAdmin.uploadInit, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + Body[dto.AdminMediaAssetUploadInitForm]("form"), + )) // Register routes for controller: order r.log.Debugf("Registering route: Post /t/:tenantCode/v1/contents/:contentID/purchase -> order.purchaseContent") router.Post("/t/:tenantCode/v1/contents/:contentID/purchase"[len(r.Path()):], DataFunc4( diff --git a/backend/app/services/media_asset.go b/backend/app/services/media_asset.go new file mode 100644 index 0000000..491ba8b --- /dev/null +++ b/backend/app/services/media_asset.go @@ -0,0 +1,228 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/base32" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "quyun/v2/app/errorx" + "quyun/v2/app/http/tenant/dto" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + pkgerrors "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "go.ipao.vip/gen/types" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// mediaAsset 提供媒体资源上传初始化等能力(上传/处理链路会在后续里程碑补齐)。 +// +// @provider +type mediaAsset struct{} + +func newObjectKey(tenantID, userID int64, assetType consts.MediaAssetType, now time.Time) (string, error) { + // object_key 作为存储定位的关键字段:必须由服务端生成,避免客户端路径注入与越权覆盖。 + buf := make([]byte, 16) // 128-bit + if _, err := rand.Read(buf); err != nil { + return "", err + } + token := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)) + date := now.UTC().Format("20060102") + return "tenants/" + strconv.FormatInt(tenantID, 10) + + "/users/" + strconv.FormatInt(userID, 10) + + "/" + string(assetType) + + "/" + date + + "/" + token, nil +} + +// 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) { + if tenantID <= 0 || operatorUserID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id must be > 0") + } + if form == nil { + return nil, errorx.ErrInvalidParameter.WithMsg("form is nil") + } + if now.IsZero() { + now = time.Now() + } + + typ := consts.MediaAssetType(strings.TrimSpace(form.Type)) + if typ == "" || !typ.IsValid() { + return nil, errorx.ErrInvalidParameter.WithMsg("invalid type") + } + + objectKey, err := newObjectKey(tenantID, operatorUserID, typ, now) + if err != nil { + return nil, pkgerrors.Wrap(err, "generate object_key failed") + } + + metaMap := map[string]any{} + if form.ContentType != "" { + metaMap["content_type"] = strings.TrimSpace(form.ContentType) + } + if form.FileSize > 0 { + metaMap["file_size"] = form.FileSize + } + if form.SHA256 != "" { + metaMap["sha256"] = strings.ToLower(strings.TrimSpace(form.SHA256)) + } + metaBytes, _ := json.Marshal(metaMap) + if len(metaBytes) == 0 { + metaBytes = []byte("{}") + } + + m := &models.MediaAsset{ + TenantID: tenantID, + UserID: operatorUserID, + Type: typ, + Status: consts.MediaAssetStatusUploaded, + Provider: "stub", + Bucket: "", + ObjectKey: objectKey, + Meta: types.JSON(metaBytes), + CreatedAt: now, + UpdatedAt: now, + } + if err := m.Create(ctx); err != nil { + return nil, pkgerrors.Wrap(err, "create media asset failed") + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": operatorUserID, + "asset_id": m.ID, + "type": typ, + "object_key": objectKey, + }).Info("services.media_asset.admin.upload_init") + + // 约定:upload_url 先返回空或内部占位;后续接入真实存储签名后再补齐。 + return &dto.AdminMediaAssetUploadInitResponse{ + AssetID: m.ID, + Provider: m.Provider, + Bucket: m.Bucket, + ObjectKey: m.ObjectKey, + UploadURL: "", + Headers: map[string]string{}, + FormFields: map[string]string{}, + ExpiresAt: nil, + }, nil +} + +// AdminUploadComplete marks the asset upload as completed and transitions status uploaded -> processing. +// 幂等语义: +// - 若当前已是 processing/ready/failed,则直接返回当前资源,不重复触发处理。 +// - 仅允许 uploaded 状态进入 processing;其他状态返回状态冲突/前置条件失败。 +func (s *mediaAsset) AdminUploadComplete( + ctx context.Context, + tenantID, operatorUserID, assetID int64, + form *dto.AdminMediaAssetUploadCompleteForm, + 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.upload_complete") + + 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 { + return errorx.ErrPreconditionFailed.WithMsg("media asset deleted") + } + + // 幂等:重复 upload_complete 时返回现态。 + switch m.Status { + case consts.MediaAssetStatusProcessing, consts.MediaAssetStatusReady, consts.MediaAssetStatusFailed: + out = m + return nil + case consts.MediaAssetStatusUploaded: + // allowed + default: + return errorx.ErrStatusConflict.WithMsg("invalid media asset status") + } + + // 合并 meta(尽量不覆盖已有字段)。 + meta := map[string]any{} + if len(m.Meta) > 0 { + _ = json.Unmarshal(m.Meta, &meta) + } + meta["upload_complete_at"] = now.UTC().Format(time.RFC3339Nano) + if form != nil { + if strings.TrimSpace(form.ETag) != "" { + meta["etag"] = strings.TrimSpace(form.ETag) + } + if strings.TrimSpace(form.ContentType) != "" { + meta["content_type"] = strings.TrimSpace(form.ContentType) + } + if form.FileSize > 0 { + meta["file_size"] = form.FileSize + } + if strings.TrimSpace(form.SHA256) != "" { + meta["sha256"] = strings.ToLower(strings.TrimSpace(form.SHA256)) + } + } + metaBytes, _ := json.Marshal(meta) + if len(metaBytes) == 0 { + metaBytes = []byte("{}") + } + + // 状态迁移:uploaded -> processing + if err := tx.Model(&models.MediaAsset{}). + Where("id = ?", m.ID). + Updates(map[string]any{ + "status": consts.MediaAssetStatusProcessing, + "meta": types.JSON(metaBytes), + "updated_at": now, + }).Error; err != nil { + return err + } + + m.Status = consts.MediaAssetStatusProcessing + m.Meta = types.JSON(metaBytes) + m.UpdatedAt = now + out = m + + // 触发异步处理(当前为 stub):后续接入队列/任务系统时在此处落任务并保持幂等。 + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": operatorUserID, + "asset_id": assetID, + "status": m.Status, + }).Info("services.media_asset.process.triggered") + + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} diff --git a/backend/app/services/provider.gen.go b/backend/app/services/provider.gen.go index 4f3ba2e..42f9eb8 100755 --- a/backend/app/services/provider.gen.go +++ b/backend/app/services/provider.gen.go @@ -27,6 +27,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*mediaAsset, error) { + obj := &mediaAsset{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func( db *gorm.DB, ledger *ledger, @@ -44,19 +51,23 @@ func Provide(opts ...opt.Option) error { content *content, db *gorm.DB, ledger *ledger, + mediaAsset *mediaAsset, order *order, tenant *tenant, + tenantJoin *tenantJoin, test *test, user *user, ) (contracts.Initial, error) { obj := &services{ - content: content, - db: db, - ledger: ledger, - order: order, - tenant: tenant, - test: test, - user: user, + content: content, + db: db, + ledger: ledger, + mediaAsset: mediaAsset, + order: order, + tenant: tenant, + tenantJoin: tenantJoin, + test: test, + user: user, } if err := obj.Prepare(); err != nil { return nil, err @@ -73,6 +84,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*tenantJoin, error) { + obj := &tenantJoin{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*test, error) { obj := &test{} diff --git a/backend/app/services/services.gen.go b/backend/app/services/services.gen.go index 3cfef1b..bbeff1c 100644 --- a/backend/app/services/services.gen.go +++ b/backend/app/services/services.gen.go @@ -8,24 +8,28 @@ var _db *gorm.DB // exported CamelCase Services var ( - Content *content - Ledger *ledger - Order *order - Tenant *tenant - Test *test - User *user + Content *content + Ledger *ledger + MediaAsset *mediaAsset + Order *order + Tenant *tenant + TenantJoin *tenantJoin + Test *test + User *user ) // @provider(model) type services struct { db *gorm.DB // define Services - content *content - ledger *ledger - order *order - tenant *tenant - test *test - user *user + content *content + ledger *ledger + mediaAsset *mediaAsset + order *order + tenant *tenant + tenantJoin *tenantJoin + test *test + user *user } func (svc *services) Prepare() error { @@ -34,8 +38,10 @@ func (svc *services) Prepare() error { // set exported Services here Content = svc.content Ledger = svc.ledger + MediaAsset = svc.mediaAsset Order = svc.order Tenant = svc.tenant + TenantJoin = svc.tenantJoin Test = svc.test User = svc.user diff --git a/backend/app/services/tenant_join.go b/backend/app/services/tenant_join.go index 0d25ef4..8f5d709 100644 --- a/backend/app/services/tenant_join.go +++ b/backend/app/services/tenant_join.go @@ -23,6 +23,12 @@ import ( "gorm.io/gorm/clause" ) +// tenantJoin 提供“加入租户”域相关能力(占位服务)。 +// 当前 join 相关实现复用在 `tenant` service 上,以保持对外 API 不变;此处仅用于服务汇总/注入。 +// +// @provider +type tenantJoin struct{} + func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 5aae9ee..2c63696 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -939,6 +939,93 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/admin/media_assets/upload_init": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "初始化媒体资源上传(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminMediaAssetUploadInitForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.AdminMediaAssetUploadInitResponse" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/media_assets/{assetID}/upload_complete": { + "post": { + "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 + }, + { + "description": "Form", + "name": "form", + "in": "body", + "schema": { + "$ref": "#/definitions/dto.AdminMediaAssetUploadCompleteForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.MediaAsset" + } + } + } + } + }, "/t/{tenantCode}/v1/admin/orders": { "get": { "consumes": [ @@ -2701,6 +2788,91 @@ const docTemplate = `{ } } }, + "dto.AdminMediaAssetUploadCompleteForm": { + "type": "object", + "properties": { + "content_type": { + "description": "ContentType is the MIME type observed during upload; optional.\nServer may record it for audit and later processing decisions.", + "type": "string" + }, + "etag": { + "description": "ETag is the storage returned ETag (or similar checksum); optional.\nUsed for audit/debugging and later integrity verification.", + "type": "string" + }, + "file_size": { + "description": "FileSize is the uploaded object size in bytes; optional.\nServer records it for quota/audit and later validation.", + "type": "integer" + }, + "sha256": { + "description": "SHA256 is the hex-encoded sha256 of the uploaded object; optional.\nServer records it for integrity checks/deduplication.", + "type": "string" + } + } + }, + "dto.AdminMediaAssetUploadInitForm": { + "type": "object", + "properties": { + "content_type": { + "description": "ContentType is the MIME type reported by the client (e.g. video/mp4); optional.\nServer should not fully trust it, but can use it as a hint for validation/logging.", + "type": "string" + }, + "file_size": { + "description": "FileSize is the expected file size in bytes; optional.\nUsed for quota/limit checks and audit; client may omit when unknown.", + "type": "integer" + }, + "sha256": { + "description": "SHA256 is the hex-encoded sha256 of the file; optional.\nUsed for deduplication/audit; server may validate it later during upload-complete.", + "type": "string" + }, + "type": { + "description": "Type is the media asset type (video/audio/image).\nUsed to decide processing pipeline and validation rules; required.", + "type": "string" + } + } + }, + "dto.AdminMediaAssetUploadInitResponse": { + "type": "object", + "properties": { + "asset_id": { + "description": "AssetID is the created media asset id.", + "type": "integer" + }, + "bucket": { + "description": "Bucket is the target bucket/container; for debugging/audit (may be empty in stub mode).", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt indicates when UploadURL/FormFields expire; optional.", + "type": "string" + }, + "form_fields": { + "description": "FormFields are form fields required for multipart form upload (S3 POST policy); optional.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "headers": { + "description": "Headers are additional headers required for upload (e.g. signed headers); optional.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "object_key": { + "description": "ObjectKey is the server-generated object key/path; client must NOT choose it.", + "type": "string" + }, + "provider": { + "description": "Provider is the storage provider identifier (e.g. s3/minio/oss/local); for debugging/audit.", + "type": "string" + }, + "upload_url": { + "description": "UploadURL is the URL the client should upload to (signed URL or service endpoint).", + "type": "string" + } + } + }, "dto.AdminOrderDetail": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index be85193..b2d47e9 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -933,6 +933,93 @@ } } }, + "/t/{tenantCode}/v1/admin/media_assets/upload_init": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "初始化媒体资源上传(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminMediaAssetUploadInitForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.AdminMediaAssetUploadInitResponse" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/media_assets/{assetID}/upload_complete": { + "post": { + "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 + }, + { + "description": "Form", + "name": "form", + "in": "body", + "schema": { + "$ref": "#/definitions/dto.AdminMediaAssetUploadCompleteForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.MediaAsset" + } + } + } + } + }, "/t/{tenantCode}/v1/admin/orders": { "get": { "consumes": [ @@ -2695,6 +2782,91 @@ } } }, + "dto.AdminMediaAssetUploadCompleteForm": { + "type": "object", + "properties": { + "content_type": { + "description": "ContentType is the MIME type observed during upload; optional.\nServer may record it for audit and later processing decisions.", + "type": "string" + }, + "etag": { + "description": "ETag is the storage returned ETag (or similar checksum); optional.\nUsed for audit/debugging and later integrity verification.", + "type": "string" + }, + "file_size": { + "description": "FileSize is the uploaded object size in bytes; optional.\nServer records it for quota/audit and later validation.", + "type": "integer" + }, + "sha256": { + "description": "SHA256 is the hex-encoded sha256 of the uploaded object; optional.\nServer records it for integrity checks/deduplication.", + "type": "string" + } + } + }, + "dto.AdminMediaAssetUploadInitForm": { + "type": "object", + "properties": { + "content_type": { + "description": "ContentType is the MIME type reported by the client (e.g. video/mp4); optional.\nServer should not fully trust it, but can use it as a hint for validation/logging.", + "type": "string" + }, + "file_size": { + "description": "FileSize is the expected file size in bytes; optional.\nUsed for quota/limit checks and audit; client may omit when unknown.", + "type": "integer" + }, + "sha256": { + "description": "SHA256 is the hex-encoded sha256 of the file; optional.\nUsed for deduplication/audit; server may validate it later during upload-complete.", + "type": "string" + }, + "type": { + "description": "Type is the media asset type (video/audio/image).\nUsed to decide processing pipeline and validation rules; required.", + "type": "string" + } + } + }, + "dto.AdminMediaAssetUploadInitResponse": { + "type": "object", + "properties": { + "asset_id": { + "description": "AssetID is the created media asset id.", + "type": "integer" + }, + "bucket": { + "description": "Bucket is the target bucket/container; for debugging/audit (may be empty in stub mode).", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt indicates when UploadURL/FormFields expire; optional.", + "type": "string" + }, + "form_fields": { + "description": "FormFields are form fields required for multipart form upload (S3 POST policy); optional.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "headers": { + "description": "Headers are additional headers required for upload (e.g. signed headers); optional.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "object_key": { + "description": "ObjectKey is the server-generated object key/path; client must NOT choose it.", + "type": "string" + }, + "provider": { + "description": "Provider is the storage provider identifier (e.g. s3/minio/oss/local); for debugging/audit.", + "type": "string" + }, + "upload_url": { + "description": "UploadURL is the URL the client should upload to (signed URL or service endpoint).", + "type": "string" + } + } + }, "dto.AdminOrderDetail": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 2755e01..36c0ae4 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -249,6 +249,89 @@ definitions: description: UserID 目标用户ID。 type: integer type: object + dto.AdminMediaAssetUploadCompleteForm: + properties: + content_type: + description: |- + ContentType is the MIME type observed during upload; optional. + Server may record it for audit and later processing decisions. + type: string + etag: + description: |- + ETag is the storage returned ETag (or similar checksum); optional. + Used for audit/debugging and later integrity verification. + type: string + file_size: + description: |- + FileSize is the uploaded object size in bytes; optional. + Server records it for quota/audit and later validation. + type: integer + sha256: + description: |- + SHA256 is the hex-encoded sha256 of the uploaded object; optional. + Server records it for integrity checks/deduplication. + type: string + type: object + dto.AdminMediaAssetUploadInitForm: + properties: + content_type: + description: |- + ContentType is the MIME type reported by the client (e.g. video/mp4); optional. + Server should not fully trust it, but can use it as a hint for validation/logging. + type: string + file_size: + description: |- + FileSize is the expected file size in bytes; optional. + Used for quota/limit checks and audit; client may omit when unknown. + type: integer + sha256: + description: |- + SHA256 is the hex-encoded sha256 of the file; optional. + Used for deduplication/audit; server may validate it later during upload-complete. + type: string + type: + description: |- + Type is the media asset type (video/audio/image). + Used to decide processing pipeline and validation rules; required. + type: string + type: object + dto.AdminMediaAssetUploadInitResponse: + properties: + asset_id: + description: AssetID is the created media asset id. + type: integer + bucket: + description: Bucket is the target bucket/container; for debugging/audit (may + be empty in stub mode). + type: string + expires_at: + description: ExpiresAt indicates when UploadURL/FormFields expire; optional. + type: string + form_fields: + additionalProperties: + type: string + description: FormFields are form fields required for multipart form upload + (S3 POST policy); optional. + type: object + headers: + additionalProperties: + type: string + description: Headers are additional headers required for upload (e.g. signed + headers); optional. + type: object + object_key: + description: ObjectKey is the server-generated object key/path; client must + NOT choose it. + type: string + provider: + description: Provider is the storage provider identifier (e.g. s3/minio/oss/local); + for debugging/audit. + type: string + upload_url: + description: UploadURL is the URL the client should upload to (signed URL + or service endpoint). + type: string + type: object dto.AdminOrderDetail: properties: order: @@ -1838,6 +1921,63 @@ paths: summary: 拒绝加入申请(租户管理) tags: - Tenant + /t/{tenantCode}/v1/admin/media_assets/{assetID}/upload_complete: + post: + 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 + - description: Form + in: body + name: form + schema: + $ref: '#/definitions/dto.AdminMediaAssetUploadCompleteForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.MediaAsset' + summary: 确认上传完成并进入处理(租户管理) + tags: + - Tenant + /t/{tenantCode}/v1/admin/media_assets/upload_init: + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.AdminMediaAssetUploadInitForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.AdminMediaAssetUploadInitResponse' + summary: 初始化媒体资源上传(租户管理) + tags: + - Tenant /t/{tenantCode}/v1/admin/orders: get: consumes: diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 820032e..568127e 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -119,6 +119,31 @@ Authorization: Bearer {{ token }} "discount_value": 0 } +### Tenant Admin - MediaAsset upload init (create asset + upload params) +POST {{ host }}/t/{{ tenantCode }}/v1/admin/media_assets/upload_init +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "type": "video", + "content_type": "video/mp4", + "file_size": 12345678, + "sha256": "" +} + +### Tenant Admin - MediaAsset upload complete (uploaded -> processing) +@assetID = 1 +POST {{ host }}/t/{{ tenantCode }}/v1/admin/media_assets/{{ assetID }}/upload_complete +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "etag": "", + "content_type": "video/mp4", + "file_size": 12345678, + "sha256": "" +} + ### Tenant Admin - Attach asset to content (main/cover/preview) @assetID = 1 POST {{ host }}/t/{{ tenantCode }}/v1/admin/contents/{{ contentID }}/assets