diff --git a/backend/app/http/tenant/content_admin.go b/backend/app/http/tenant/content_admin.go index 9f40e17..e3471e0 100644 --- a/backend/app/http/tenant/content_admin.go +++ b/backend/app/http/tenant/content_admin.go @@ -95,6 +95,44 @@ func (*contentAdmin) create(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *mo return services.Content.Create(ctx, tenant.ID, tenantUser.UserID, form) } +// publish +// +// @Summary 内容发布(创建+绑定资源+定价) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param form body dto.ContentPublishForm true "Form" +// @Success 200 {object} dto.ContentPublishResponse +// +// @Router /t/:tenantCode/v1/admin/contents/publish [post] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind form body +func (*contentAdmin) publish(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, form *dto.ContentPublishForm) (*dto.ContentPublishResponse, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + }).Info("tenant.admin.contents.publish") + + res, err := services.Content.Publish(ctx.Context(), tenant.ID, tenantUser.UserID, form) + if err != nil { + return nil, err + } + + return &dto.ContentPublishResponse{ + Content: res.Content, + Price: res.Price, + CoverAssets: res.CoverAssets, + MainAssets: res.MainAssets, + ContentTypes: res.ContentTypes, + }, nil +} + // update // // @Summary 更新内容(标题/描述/状态等) diff --git a/backend/app/http/tenant/dto/content_admin_publish.go b/backend/app/http/tenant/dto/content_admin_publish.go new file mode 100644 index 0000000..e71b09a --- /dev/null +++ b/backend/app/http/tenant/dto/content_admin_publish.go @@ -0,0 +1,55 @@ +package dto + +import ( + "quyun/v2/database/models" + "quyun/v2/pkg/consts" +) + +// ContentPublishForm 租户管理员提交“内容发布”表单(创建内容 + 绑定资源 + 定价)。 +// 说明: +// - 内容类型支持组合:文字/音频/视频/多图可同时存在; +// - 文字内容通过 Detail 是否为空来判断; +// - 音频/视频/多图通过对应资源列表是否为空来判断(资源需为 ready 且属于当前租户)。 +type ContentPublishForm struct { + // Title 标题:用于列表展示与搜索;必填。 + Title string `json:"title,omitempty"` + // Summary 简介:用于列表/卡片展示的短文本;可选,建议 <= 256 字符。 + Summary string `json:"summary,omitempty"` + // Detail 详细:用于详情页的长文本;可选;当非空时视为“文字内容”类型存在。 + Detail string `json:"detail,omitempty"` + // Tags 标签:用于分类/检索;字符串数组;会做 trim/去重;可为空。 + Tags []string `json:"tags,omitempty"` + + // CoverAssetIDs 展示图(封面图)资源 ID 列表:1-3 张;每个资源必须为 image/main/ready。 + CoverAssetIDs []int64 `json:"cover_asset_ids,omitempty"` + // AudioAssetIDs 音频资源 ID 列表:可为空;每个资源必须为 audio/main/ready。 + AudioAssetIDs []int64 `json:"audio_asset_ids,omitempty"` + // VideoAssetIDs 视频资源 ID 列表:可为空;每个资源必须为 video/main/ready。 + VideoAssetIDs []int64 `json:"video_asset_ids,omitempty"` + // ImageAssetIDs 多图内容资源 ID 列表:可为空;每个资源必须为 image/main/ready;数量 >= 2 时视为“多图内容”类型存在。 + ImageAssetIDs []int64 `json:"image_asset_ids,omitempty"` + + // PriceAmount 价格:单位为分;0 表示免费;必填(前端可默认填 0)。 + PriceAmount int64 `json:"price_amount,omitempty"` + // Currency 币种:当前固定为 CNY;可不传(后端默认 CNY)。 + Currency consts.Currency `json:"currency,omitempty"` + + // Visibility 可见性:控制“详情页”可见范围;默认 tenant_only。 + Visibility consts.ContentVisibility `json:"visibility,omitempty"` + // PreviewSeconds 试看秒数:仅对 preview 资源生效;默认 60;必须为正整数。 + PreviewSeconds *int32 `json:"preview_seconds,omitempty"` +} + +// ContentPublishResponse 内容发布结果(便于前端一次性拿到核心信息)。 +type ContentPublishResponse struct { + // Content 内容主体(包含标题/简介/详细/状态等)。 + Content *models.Content `json:"content"` + // Price 定价信息(单位分)。 + Price *models.ContentPrice `json:"price"` + // CoverAssets 封面图绑定结果(role=cover)。 + CoverAssets []*models.ContentAsset `json:"cover_assets,omitempty"` + // MainAssets 主资源绑定结果(role=main;可能包含音频/视频/图片)。 + MainAssets []*models.ContentAsset `json:"main_assets,omitempty"` + // ContentTypes 内容类型列表:text/audio/video/image/multi_image(用于前端展示)。 + ContentTypes []string `json:"content_types,omitempty"` +} diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index 224210a..1e72956 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -112,6 +112,13 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("contentID"), Body[dto.ContentAssetAttachForm]("form"), )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/contents/publish -> contentAdmin.publish") + router.Post("/t/:tenantCode/v1/admin/contents/publish"[len(r.Path()):], DataFunc3( + r.contentAdmin.publish, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + Body[dto.ContentPublishForm]("form"), + )) r.log.Debugf("Registering route: Put /t/:tenantCode/v1/admin/contents/:contentID/price -> contentAdmin.upsertPrice") router.Put("/t/:tenantCode/v1/admin/contents/:contentID/price"[len(r.Path()):], DataFunc4( r.contentAdmin.upsertPrice, diff --git a/backend/app/services/content.go b/backend/app/services/content.go index 3f6097d..b9fcd8b 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -2,7 +2,9 @@ package services import ( "context" + "encoding/json" "errors" + "strings" "time" "quyun/v2/app/errorx" @@ -16,6 +18,7 @@ import ( "github.com/samber/lo" log "github.com/sirupsen/logrus" "go.ipao.vip/gen" + "go.ipao.vip/gen/types" "gorm.io/gorm" ) @@ -34,6 +37,20 @@ type ContentDetailResult struct { HasAccess bool } +// ContentPublishResult 为“内容发布(创建+绑定资源+定价)”的内部结果。 +type ContentPublishResult struct { + // Content 内容主体。 + Content *models.Content + // Price 定价信息。 + Price *models.ContentPrice + // CoverAssets 封面图绑定结果(role=cover)。 + CoverAssets []*models.ContentAsset + // MainAssets 主资源绑定结果(role=main)。 + MainAssets []*models.ContentAsset + // ContentTypes 内容类型列表:text/audio/video/image/multi_image(用于前端展示)。 + ContentTypes []string +} + func requiredMediaAssetVariantForRole(role consts.ContentAssetRole) consts.MediaAssetVariant { switch role { case consts.ContentAssetRolePreview: @@ -78,6 +95,272 @@ func (s *content) Create(ctx context.Context, tenantID, userID int64, form *dto. return m, nil } +// Publish 租户管理员发布内容(创建内容 + 绑定封面/主资源 + 定价)。 +// 说明:此接口面向“创作者/租户管理员”的内容发布场景,支持多种内容类型组合存在。 +func (s *content) Publish(ctx context.Context, tenantID, userID int64, form *dto.ContentPublishForm) (*ContentPublishResult, error) { + if tenantID <= 0 || userID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0") + } + if form == nil { + return nil, errorx.ErrMissingParameter.WithMsg("form is required") + } + + title := strings.TrimSpace(form.Title) + if title == "" { + return nil, errorx.ErrMissingParameter.WithMsg("请填写标题") + } + summary := strings.TrimSpace(form.Summary) + if len([]rune(summary)) > 256 { + return nil, errorx.ErrInvalidParameter.WithMsg("简介过长(建议不超过 256 字符)") + } + detail := strings.TrimSpace(form.Detail) + + if len(form.CoverAssetIDs) < 1 || len(form.CoverAssetIDs) > 3 { + return nil, errorx.ErrInvalidParameter.WithMsg("展示图需为 1-3 张") + } + + hasText := detail != "" + hasAudio := len(form.AudioAssetIDs) > 0 + hasVideo := len(form.VideoAssetIDs) > 0 + hasImage := len(form.ImageAssetIDs) > 0 + if !hasText && !hasAudio && !hasVideo && !hasImage { + return nil, errorx.ErrInvalidParameter.WithMsg("请至少提供一种内容类型(文字/音频/视频/多图)") + } + + visibility := form.Visibility + if visibility == "" { + visibility = consts.ContentVisibilityTenantOnly + } + + previewSeconds := consts.DefaultContentPreviewSeconds + if form.PreviewSeconds != nil && *form.PreviewSeconds > 0 { + previewSeconds = *form.PreviewSeconds + } + + currency := form.Currency + if currency == "" { + currency = consts.CurrencyCNY + } + if form.PriceAmount < 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("价格不合法(需为 0 或正整数)") + } + + // 标签:trim + 去重;限制数量与长度,避免滥用导致索引/存储膨胀。 + tags := make([]string, 0, len(form.Tags)) + seenTag := map[string]struct{}{} + for _, raw := range form.Tags { + v := strings.TrimSpace(raw) + if v == "" { + continue + } + if len([]rune(v)) > 20 { + return nil, errorx.ErrInvalidParameter.WithMsg("标签过长(单个标签建议不超过 20 字符)") + } + if _, ok := seenTag[v]; ok { + continue + } + seenTag[v] = struct{}{} + tags = append(tags, v) + if len(tags) >= 20 { + return nil, errorx.ErrInvalidParameter.WithMsg("标签数量过多(建议不超过 20 个)") + } + } + tagBytes, _ := json.Marshal(tags) + if len(tagBytes) == 0 { + tagBytes = []byte("[]") + } + + // 资源去重与批量拉取。 + allAssetIDs := make([]int64, 0, len(form.CoverAssetIDs)+len(form.AudioAssetIDs)+len(form.VideoAssetIDs)+len(form.ImageAssetIDs)) + assetSeen := map[int64]struct{}{} + addIDs := func(ids []int64) error { + for _, id := range ids { + if id <= 0 { + return errorx.ErrInvalidParameter.WithMsg("资源ID不合法") + } + if _, ok := assetSeen[id]; ok { + return errorx.ErrInvalidParameter.WithMsg("同一资源不可重复绑定(封面/主资源之间也不可重复)") + } + assetSeen[id] = struct{}{} + allAssetIDs = append(allAssetIDs, id) + } + return nil + } + if err := addIDs(form.CoverAssetIDs); err != nil { + return nil, err + } + if err := addIDs(form.AudioAssetIDs); err != nil { + return nil, err + } + if err := addIDs(form.VideoAssetIDs); err != nil { + return nil, err + } + if err := addIDs(form.ImageAssetIDs); err != nil { + return nil, err + } + + out := &ContentPublishResult{} + + log.WithFields(log.Fields{ + "tenant_id": tenantID, + "user_id": userID, + "title": title, + "price": form.PriceAmount, + }).Info("services.content.publish") + + err := models.Q.Transaction(func(tx *models.Query) error { + // 1) 校验资源(必须属于租户、未删除、ready、variant=main) + assetTbl, assetQuery := tx.MediaAsset.QueryContext(ctx) + assets, err := assetQuery.Where( + assetTbl.TenantID.Eq(tenantID), + assetTbl.ID.In(allAssetIDs...), + assetTbl.DeletedAt.IsNull(), + ).Find() + if err != nil { + return err + } + assetMap := make(map[int64]*models.MediaAsset, len(assets)) + for _, a := range assets { + if a == nil { + continue + } + assetMap[a.ID] = a + } + for _, id := range allAssetIDs { + a := assetMap[id] + if a == nil { + return errorx.ErrRecordNotFound.WithMsg("资源不存在或无权限访问") + } + if a.Status != consts.MediaAssetStatusReady { + return errorx.ErrPreconditionFailed.WithMsg("存在未处理完成的资源,请稍后再试") + } + if a.Variant != consts.MediaAssetVariantMain { + return errorx.ErrInvalidParameter.WithMsg("资源产物类型不正确(需为正片 main)") + } + } + + // 2) 创建内容(默认进入审核中) + content := &models.Content{ + TenantID: tenantID, + UserID: userID, + Title: title, + Summary: summary, + Description: detail, + Tags: types.JSON(tagBytes), + Status: consts.ContentStatusReviewing, + Visibility: visibility, + PreviewSeconds: previewSeconds, + PreviewDownloadable: false, + } + if err := tx.Content.WithContext(ctx).Create(content); err != nil { + return err + } + + // 3) 创建定价(固定 CNY,折扣默认 none) + price := &models.ContentPrice{ + TenantID: tenantID, + UserID: userID, + ContentID: content.ID, + Currency: currency, + PriceAmount: form.PriceAmount, + DiscountType: consts.DiscountTypeNone, + DiscountValue: 0, + DiscountStartAt: time.Time{}, + DiscountEndAt: time.Time{}, + } + if err := tx.ContentPrice.WithContext(ctx).Create(price); err != nil { + return err + } + + // 4) 绑定封面图(role=cover) + coverAssets := make([]*models.ContentAsset, 0, len(form.CoverAssetIDs)) + for i, id := range form.CoverAssetIDs { + a := assetMap[id] + if a.Type != consts.MediaAssetTypeImage { + return errorx.ErrInvalidParameter.WithMsg("展示图必须为图片资源") + } + ca := &models.ContentAsset{ + TenantID: tenantID, + UserID: userID, + ContentID: content.ID, + AssetID: id, + Role: consts.ContentAssetRoleCover, + Sort: int32(i), + } + if err := tx.ContentAsset.WithContext(ctx).Create(ca); err != nil { + return err + } + coverAssets = append(coverAssets, ca) + } + + // 5) 绑定主资源(role=main;支持音频/视频/多图组合) + mainAssets := make([]*models.ContentAsset, 0, len(form.AudioAssetIDs)+len(form.VideoAssetIDs)+len(form.ImageAssetIDs)) + sort := int32(0) + attachMain := func(ids []int64, wantType consts.MediaAssetType) error { + for _, id := range ids { + a := assetMap[id] + if a.Type != wantType { + return errorx.ErrInvalidParameter.WithMsg("主资源类型与选择不匹配") + } + ca := &models.ContentAsset{ + TenantID: tenantID, + UserID: userID, + ContentID: content.ID, + AssetID: id, + Role: consts.ContentAssetRoleMain, + Sort: sort, + } + if err := tx.ContentAsset.WithContext(ctx).Create(ca); err != nil { + return err + } + mainAssets = append(mainAssets, ca) + sort++ + } + return nil + } + // 顺序:视频 -> 音频 -> 图片(多图) + if err := attachMain(form.VideoAssetIDs, consts.MediaAssetTypeVideo); err != nil { + return err + } + if err := attachMain(form.AudioAssetIDs, consts.MediaAssetTypeAudio); err != nil { + return err + } + if err := attachMain(form.ImageAssetIDs, consts.MediaAssetTypeImage); err != nil { + return err + } + + typesOut := make([]string, 0, 4) + if hasText { + typesOut = append(typesOut, "text") + } + if hasAudio { + typesOut = append(typesOut, "audio") + } + if hasVideo { + typesOut = append(typesOut, "video") + } + if hasImage { + if len(form.ImageAssetIDs) >= 2 { + typesOut = append(typesOut, "multi_image") + } else { + typesOut = append(typesOut, "image") + } + } + + out.Content = content + out.Price = price + out.CoverAssets = coverAssets + out.MainAssets = mainAssets + out.ContentTypes = typesOut + return nil + }) + if err != nil { + return nil, err + } + + return out, nil +} + func (s *content) Update(ctx context.Context, tenantID, userID, contentID int64, form *dto.ContentUpdateForm) (*models.Content, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, diff --git a/backend/database/.transform.yaml b/backend/database/.transform.yaml index f29e059..c4cfbf5 100644 --- a/backend/database/.transform.yaml +++ b/backend/database/.transform.yaml @@ -27,6 +27,7 @@ field_type: contents: status: consts.ContentStatus visibility: consts.ContentVisibility + tags: types.JSON content_assets: role: consts.ContentAssetRole content_prices: diff --git a/backend/database/migrations/20251225123000_contents_summary_tags.sql b/backend/database/migrations/20251225123000_contents_summary_tags.sql new file mode 100644 index 0000000..47223e5 --- /dev/null +++ b/backend/database/migrations/20251225123000_contents_summary_tags.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- +goose StatementBegin +-- contents:补齐“简介/标签”字段,用于内容发布与列表展示 +ALTER TABLE contents + ADD COLUMN IF NOT EXISTS summary varchar(256) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS tags jsonb NOT NULL DEFAULT '[]'::jsonb; + +COMMENT ON COLUMN contents.summary IS '简介:用于列表/卡片展示的短文本;建议 <= 256 字符(由业务校验)'; +COMMENT ON COLUMN contents.tags IS '标签:JSON 数组(字符串列表);用于分类/检索与聚合展示'; + +CREATE INDEX IF NOT EXISTS ix_contents_tenant_tags ON contents(tenant_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS ix_contents_tenant_tags; +ALTER TABLE contents + DROP COLUMN IF EXISTS tags, + DROP COLUMN IF EXISTS summary; +-- +goose StatementEnd + diff --git a/backend/database/models/contents.gen.go b/backend/database/models/contents.gen.go index 892b4b7..cc28fed 100644 --- a/backend/database/models/contents.gen.go +++ b/backend/database/models/contents.gen.go @@ -11,6 +11,7 @@ import ( "quyun/v2/pkg/consts" "go.ipao.vip/gen" + "go.ipao.vip/gen/types" "gorm.io/gorm" ) @@ -31,6 +32,8 @@ type Content struct { DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone;comment:软删除时间:非空表示已删除;对外接口需过滤" json:"deleted_at"` // 软删除时间:非空表示已删除;对外接口需过滤 CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计与排序" json:"created_at"` // 创建时间:默认 now();用于审计与排序 UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();编辑内容时写入" json:"updated_at"` // 更新时间:默认 now();编辑内容时写入 + Summary string `gorm:"column:summary;type:character varying(256);not null;comment:简介:用于列表/卡片展示的短文本;建议 <= 256 字符(由业务校验)" json:"summary"` // 简介:用于列表/卡片展示的短文本;建议 <= 256 字符(由业务校验) + Tags types.JSON `gorm:"column:tags;type:jsonb;not null;default:[];comment:标签:JSON 数组(字符串列表);用于分类/检索与聚合展示" json:"tags"` // 标签:JSON 数组(字符串列表);用于分类/检索与聚合展示 } // Quick operations without importing query package diff --git a/backend/database/models/contents.query.gen.go b/backend/database/models/contents.query.gen.go index c967c65..fca7622 100644 --- a/backend/database/models/contents.query.gen.go +++ b/backend/database/models/contents.query.gen.go @@ -38,6 +38,8 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { _contentQuery.DeletedAt = field.NewField(tableName, "deleted_at") _contentQuery.CreatedAt = field.NewTime(tableName, "created_at") _contentQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + _contentQuery.Summary = field.NewString(tableName, "summary") + _contentQuery.Tags = field.NewJSONB(tableName, "tags") _contentQuery.fillFieldMap() @@ -61,6 +63,8 @@ type contentQuery struct { DeletedAt field.Field // 软删除时间:非空表示已删除;对外接口需过滤 CreatedAt field.Time // 创建时间:默认 now();用于审计与排序 UpdatedAt field.Time // 更新时间:默认 now();编辑内容时写入 + Summary field.String // 简介:用于列表/卡片展示的短文本;建议 <= 256 字符(由业务校验) + Tags field.JSONB // 标签:JSON 数组(字符串列表);用于分类/检索与聚合展示 fieldMap map[string]field.Expr } @@ -90,6 +94,8 @@ func (c *contentQuery) updateTableName(table string) *contentQuery { c.DeletedAt = field.NewField(table, "deleted_at") c.CreatedAt = field.NewTime(table, "created_at") c.UpdatedAt = field.NewTime(table, "updated_at") + c.Summary = field.NewString(table, "summary") + c.Tags = field.NewJSONB(table, "tags") c.fillFieldMap() @@ -122,7 +128,7 @@ func (c *contentQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) } func (c *contentQuery) fillFieldMap() { - c.fieldMap = make(map[string]field.Expr, 13) + c.fieldMap = make(map[string]field.Expr, 15) c.fieldMap["id"] = c.ID c.fieldMap["tenant_id"] = c.TenantID c.fieldMap["user_id"] = c.UserID @@ -136,6 +142,8 @@ func (c *contentQuery) fillFieldMap() { c.fieldMap["deleted_at"] = c.DeletedAt c.fieldMap["created_at"] = c.CreatedAt c.fieldMap["updated_at"] = c.UpdatedAt + c.fieldMap["summary"] = c.Summary + c.fieldMap["tags"] = c.Tags } func (c contentQuery) clone(db *gorm.DB) contentQuery { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index ecdcfbf..24525da 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1602,6 +1602,46 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/admin/contents/publish": { + "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.ContentPublishForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ContentPublishResponse" + } + } + } + } + }, "/t/{tenantCode}/v1/admin/contents/{contentID}": { "patch": { "consumes": [ @@ -4847,6 +4887,124 @@ const docTemplate = `{ } } }, + "dto.ContentPublishForm": { + "type": "object", + "properties": { + "audio_asset_ids": { + "description": "AudioAssetIDs 音频资源 ID 列表:可为空;每个资源必须为 audio/main/ready。", + "type": "array", + "items": { + "type": "integer" + } + }, + "cover_asset_ids": { + "description": "CoverAssetIDs 展示图(封面图)资源 ID 列表:1-3 张;每个资源必须为 image/main/ready。", + "type": "array", + "items": { + "type": "integer" + } + }, + "currency": { + "description": "Currency 币种:当前固定为 CNY;可不传(后端默认 CNY)。", + "allOf": [ + { + "$ref": "#/definitions/consts.Currency" + } + ] + }, + "detail": { + "description": "Detail 详细:用于详情页的长文本;可选;当非空时视为“文字内容”类型存在。", + "type": "string" + }, + "image_asset_ids": { + "description": "ImageAssetIDs 多图内容资源 ID 列表:可为空;每个资源必须为 image/main/ready;数量 \u003e= 2 时视为“多图内容”类型存在。", + "type": "array", + "items": { + "type": "integer" + } + }, + "preview_seconds": { + "description": "PreviewSeconds 试看秒数:仅对 preview 资源生效;默认 60;必须为正整数。", + "type": "integer" + }, + "price_amount": { + "description": "PriceAmount 价格:单位为分;0 表示免费;必填(前端可默认填 0)。", + "type": "integer" + }, + "summary": { + "description": "Summary 简介:用于列表/卡片展示的短文本;可选,建议 \u003c= 256 字符。", + "type": "string" + }, + "tags": { + "description": "Tags 标签:用于分类/检索;字符串数组;会做 trim/去重;可为空。", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "Title 标题:用于列表展示与搜索;必填。", + "type": "string" + }, + "video_asset_ids": { + "description": "VideoAssetIDs 视频资源 ID 列表:可为空;每个资源必须为 video/main/ready。", + "type": "array", + "items": { + "type": "integer" + } + }, + "visibility": { + "description": "Visibility 可见性:控制“详情页”可见范围;默认 tenant_only。", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentVisibility" + } + ] + } + } + }, + "dto.ContentPublishResponse": { + "type": "object", + "properties": { + "content": { + "description": "Content 内容主体(包含标题/简介/详细/状态等)。", + "allOf": [ + { + "$ref": "#/definitions/models.Content" + } + ] + }, + "content_types": { + "description": "ContentTypes 内容类型列表:text/audio/video/image/multi_image(用于前端展示)。", + "type": "array", + "items": { + "type": "string" + } + }, + "cover_assets": { + "description": "CoverAssets 封面图绑定结果(role=cover)。", + "type": "array", + "items": { + "$ref": "#/definitions/models.ContentAsset" + } + }, + "main_assets": { + "description": "MainAssets 主资源绑定结果(role=main;可能包含音频/视频/图片)。", + "type": "array", + "items": { + "$ref": "#/definitions/models.ContentAsset" + } + }, + "price": { + "description": "Price 定价信息(单位分)。", + "allOf": [ + { + "$ref": "#/definitions/models.ContentPrice" + } + ] + } + } + }, "dto.ContentUpdateForm": { "type": "object", "properties": { @@ -5775,6 +5933,17 @@ const docTemplate = `{ } ] }, + "summary": { + "description": "简介:用于列表/卡片展示的短文本;建议 \u003c= 256 字符(由业务校验)", + "type": "string" + }, + "tags": { + "description": "标签:JSON 数组(字符串列表);用于分类/检索与聚合展示", + "type": "array", + "items": { + "type": "integer" + } + }, "tenant_id": { "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", "type": "integer" diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index e977ca5..39b5063 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1596,6 +1596,46 @@ } } }, + "/t/{tenantCode}/v1/admin/contents/publish": { + "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.ContentPublishForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ContentPublishResponse" + } + } + } + } + }, "/t/{tenantCode}/v1/admin/contents/{contentID}": { "patch": { "consumes": [ @@ -4841,6 +4881,124 @@ } } }, + "dto.ContentPublishForm": { + "type": "object", + "properties": { + "audio_asset_ids": { + "description": "AudioAssetIDs 音频资源 ID 列表:可为空;每个资源必须为 audio/main/ready。", + "type": "array", + "items": { + "type": "integer" + } + }, + "cover_asset_ids": { + "description": "CoverAssetIDs 展示图(封面图)资源 ID 列表:1-3 张;每个资源必须为 image/main/ready。", + "type": "array", + "items": { + "type": "integer" + } + }, + "currency": { + "description": "Currency 币种:当前固定为 CNY;可不传(后端默认 CNY)。", + "allOf": [ + { + "$ref": "#/definitions/consts.Currency" + } + ] + }, + "detail": { + "description": "Detail 详细:用于详情页的长文本;可选;当非空时视为“文字内容”类型存在。", + "type": "string" + }, + "image_asset_ids": { + "description": "ImageAssetIDs 多图内容资源 ID 列表:可为空;每个资源必须为 image/main/ready;数量 \u003e= 2 时视为“多图内容”类型存在。", + "type": "array", + "items": { + "type": "integer" + } + }, + "preview_seconds": { + "description": "PreviewSeconds 试看秒数:仅对 preview 资源生效;默认 60;必须为正整数。", + "type": "integer" + }, + "price_amount": { + "description": "PriceAmount 价格:单位为分;0 表示免费;必填(前端可默认填 0)。", + "type": "integer" + }, + "summary": { + "description": "Summary 简介:用于列表/卡片展示的短文本;可选,建议 \u003c= 256 字符。", + "type": "string" + }, + "tags": { + "description": "Tags 标签:用于分类/检索;字符串数组;会做 trim/去重;可为空。", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "Title 标题:用于列表展示与搜索;必填。", + "type": "string" + }, + "video_asset_ids": { + "description": "VideoAssetIDs 视频资源 ID 列表:可为空;每个资源必须为 video/main/ready。", + "type": "array", + "items": { + "type": "integer" + } + }, + "visibility": { + "description": "Visibility 可见性:控制“详情页”可见范围;默认 tenant_only。", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentVisibility" + } + ] + } + } + }, + "dto.ContentPublishResponse": { + "type": "object", + "properties": { + "content": { + "description": "Content 内容主体(包含标题/简介/详细/状态等)。", + "allOf": [ + { + "$ref": "#/definitions/models.Content" + } + ] + }, + "content_types": { + "description": "ContentTypes 内容类型列表:text/audio/video/image/multi_image(用于前端展示)。", + "type": "array", + "items": { + "type": "string" + } + }, + "cover_assets": { + "description": "CoverAssets 封面图绑定结果(role=cover)。", + "type": "array", + "items": { + "$ref": "#/definitions/models.ContentAsset" + } + }, + "main_assets": { + "description": "MainAssets 主资源绑定结果(role=main;可能包含音频/视频/图片)。", + "type": "array", + "items": { + "$ref": "#/definitions/models.ContentAsset" + } + }, + "price": { + "description": "Price 定价信息(单位分)。", + "allOf": [ + { + "$ref": "#/definitions/models.ContentPrice" + } + ] + } + } + }, "dto.ContentUpdateForm": { "type": "object", "properties": { @@ -5769,6 +5927,17 @@ } ] }, + "summary": { + "description": "简介:用于列表/卡片展示的短文本;建议 \u003c= 256 字符(由业务校验)", + "type": "string" + }, + "tags": { + "description": "标签:JSON 数组(字符串列表);用于分类/检索与聚合展示", + "type": "array", + "items": { + "type": "integer" + } + }, "tenant_id": { "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", "type": "integer" diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index ae9ac97..9e2c022 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -524,6 +524,84 @@ definitions: description: PriceAmount is the base price in cents (CNY 分). type: integer type: object + dto.ContentPublishForm: + properties: + audio_asset_ids: + description: AudioAssetIDs 音频资源 ID 列表:可为空;每个资源必须为 audio/main/ready。 + items: + type: integer + type: array + cover_asset_ids: + description: CoverAssetIDs 展示图(封面图)资源 ID 列表:1-3 张;每个资源必须为 image/main/ready。 + items: + type: integer + type: array + currency: + allOf: + - $ref: '#/definitions/consts.Currency' + description: Currency 币种:当前固定为 CNY;可不传(后端默认 CNY)。 + detail: + description: Detail 详细:用于详情页的长文本;可选;当非空时视为“文字内容”类型存在。 + type: string + image_asset_ids: + description: ImageAssetIDs 多图内容资源 ID 列表:可为空;每个资源必须为 image/main/ready;数量 >= + 2 时视为“多图内容”类型存在。 + items: + type: integer + type: array + preview_seconds: + description: PreviewSeconds 试看秒数:仅对 preview 资源生效;默认 60;必须为正整数。 + type: integer + price_amount: + description: PriceAmount 价格:单位为分;0 表示免费;必填(前端可默认填 0)。 + type: integer + summary: + description: Summary 简介:用于列表/卡片展示的短文本;可选,建议 <= 256 字符。 + type: string + tags: + description: Tags 标签:用于分类/检索;字符串数组;会做 trim/去重;可为空。 + items: + type: string + type: array + title: + description: Title 标题:用于列表展示与搜索;必填。 + type: string + video_asset_ids: + description: VideoAssetIDs 视频资源 ID 列表:可为空;每个资源必须为 video/main/ready。 + items: + type: integer + type: array + visibility: + allOf: + - $ref: '#/definitions/consts.ContentVisibility' + description: Visibility 可见性:控制“详情页”可见范围;默认 tenant_only。 + type: object + dto.ContentPublishResponse: + properties: + content: + allOf: + - $ref: '#/definitions/models.Content' + description: Content 内容主体(包含标题/简介/详细/状态等)。 + content_types: + description: ContentTypes 内容类型列表:text/audio/video/image/multi_image(用于前端展示)。 + items: + type: string + type: array + cover_assets: + description: CoverAssets 封面图绑定结果(role=cover)。 + items: + $ref: '#/definitions/models.ContentAsset' + type: array + main_assets: + description: MainAssets 主资源绑定结果(role=main;可能包含音频/视频/图片)。 + items: + $ref: '#/definitions/models.ContentAsset' + type: array + price: + allOf: + - $ref: '#/definitions/models.ContentPrice' + description: Price 定价信息(单位分)。 + type: object dto.ContentUpdateForm: properties: description: @@ -1139,6 +1217,14 @@ definitions: allOf: - $ref: '#/definitions/consts.ContentStatus' description: 状态:draft/reviewing/published/unpublished/blocked;published 才对外展示 + summary: + description: 简介:用于列表/卡片展示的短文本;建议 <= 256 字符(由业务校验) + type: string + tags: + description: 标签:JSON 数组(字符串列表);用于分类/检索与聚合展示 + items: + type: integer + type: array tenant_id: description: 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id type: integer @@ -2876,6 +2962,32 @@ paths: summary: 设置内容价格与折扣 tags: - Tenant + /t/{tenantCode}/v1/admin/contents/publish: + 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.ContentPublishForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.ContentPublishResponse' + summary: 内容发布(创建+绑定资源+定价) + tags: + - Tenant /t/{tenantCode}/v1/admin/invites: get: consumes: diff --git a/frontend/portal/AGENTS.md b/frontend/portal/AGENTS.md index 27ca2b4..422ec07 100644 --- a/frontend/portal/AGENTS.md +++ b/frontend/portal/AGENTS.md @@ -3,5 +3,4 @@ ## API Access Constraints - `frontend/portal` 业务禁止调用任何 `/super/v1/*` 接口(包括本地开发的 Vite 代理)。 -- Portal 仅允许使用面向用户/租户公开的接口前缀(例如 `/v1/*`,具体以后端实际路由为准)。 - +- Portal 仅允许使用面向用户/租户公开的接口前缀(例如 `/v1/*`、`/t/*`,具体以后端实际路由为准)。 diff --git a/frontend/portal/src/router/index.js b/frontend/portal/src/router/index.js index d3265e1..5c9d2f9 100644 --- a/frontend/portal/src/router/index.js +++ b/frontend/portal/src/router/index.js @@ -48,7 +48,7 @@ const router = createRouter({ { path: 'admin', name: 'adminDashboard', component: TitlePage, meta: { title: '管理概览(仪表盘)' } }, { path: 'admin/contents', name: 'adminContents', component: TitlePage, meta: { title: '内容列表(管理)' } }, - { path: 'admin/contents/new', name: 'adminContentNew', component: TitlePage, meta: { title: '内容发布' } }, + { path: 'admin/contents/new', name: 'adminContentNew', component: () => import('@/views/admin/ContentPublish.vue'), meta: { title: '内容发布' } }, { path: 'admin/contents/:contentId/edit', name: 'adminContentEdit', component: TitlePage, meta: { title: '内容编辑' } }, { path: 'admin/assets', name: 'adminAssets', component: TitlePage, meta: { title: '素材库' } }, { path: 'admin/orders', name: 'adminOrders', component: TitlePage, meta: { title: '订单列表(管理)' } }, diff --git a/frontend/portal/src/views/admin/ContentPublish.vue b/frontend/portal/src/views/admin/ContentPublish.vue new file mode 100644 index 0000000..282da48 --- /dev/null +++ b/frontend/portal/src/views/admin/ContentPublish.vue @@ -0,0 +1,209 @@ + + +