Compare commits

..

5 Commits

Author SHA1 Message Date
f27e9c5d30 fix: always show publish in topbar 2025-12-25 15:17:21 +08:00
83ff116ea5 feat: show publish action for tenant admins 2025-12-25 15:05:05 +08:00
4bf23e1f4a feat: tenant management api paths 2025-12-25 14:55:02 +08:00
20f9b3cf1f refactor: portal admin routes to management 2025-12-25 14:33:23 +08:00
6542c71ec0 feat: tenant content publish 2025-12-25 14:29:16 +08:00
24 changed files with 5693 additions and 4564 deletions

View File

@@ -39,7 +39,7 @@ func requireTenantAdmin(tenantUser *models.TenantUser) error {
// @Param filter query dto.AdminContentListFilter true "Filter" // @Param filter query dto.AdminContentListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=dto.AdminContentItem} // @Success 200 {object} requests.Pager{items=dto.AdminContentItem}
// //
// @Router /t/:tenantCode/v1/admin/contents [get] // @Router /t/:tenantCode/v1/management/contents [get]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind filter query // @Bind filter query
@@ -78,7 +78,7 @@ func (*contentAdmin) list(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *mode
// @Param form body dto.ContentCreateForm true "Form" // @Param form body dto.ContentCreateForm true "Form"
// @Success 200 {object} models.Content // @Success 200 {object} models.Content
// //
// @Router /t/:tenantCode/v1/admin/contents [post] // @Router /t/:tenantCode/v1/management/contents [post]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind form body // @Bind form body
@@ -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) 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/management/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 // update
// //
// @Summary 更新内容(标题/描述/状态等) // @Summary 更新内容(标题/描述/状态等)
@@ -106,7 +144,7 @@ func (*contentAdmin) create(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *mo
// @Param form body dto.ContentUpdateForm true "Form" // @Param form body dto.ContentUpdateForm true "Form"
// @Success 200 {object} models.Content // @Success 200 {object} models.Content
// //
// @Router /t/:tenantCode/v1/admin/contents/:contentID [patch] // @Router /t/:tenantCode/v1/management/contents/:contentID [patch]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind contentID path // @Bind contentID path
@@ -136,7 +174,7 @@ func (*contentAdmin) update(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *mo
// @Param form body dto.ContentPriceUpsertForm true "Form" // @Param form body dto.ContentPriceUpsertForm true "Form"
// @Success 200 {object} models.ContentPrice // @Success 200 {object} models.ContentPrice
// //
// @Router /t/:tenantCode/v1/admin/contents/:contentID/price [put] // @Router /t/:tenantCode/v1/management/contents/:contentID/price [put]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind contentID path // @Bind contentID path
@@ -166,7 +204,7 @@ func (*contentAdmin) upsertPrice(ctx fiber.Ctx, tenant *models.Tenant, tenantUse
// @Param form body dto.ContentAssetAttachForm true "Form" // @Param form body dto.ContentAssetAttachForm true "Form"
// @Success 200 {object} models.ContentAsset // @Success 200 {object} models.ContentAsset
// //
// @Router /t/:tenantCode/v1/admin/contents/:contentID/assets [post] // @Router /t/:tenantCode/v1/management/contents/:contentID/assets [post]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind contentID path // @Bind contentID path

View File

@@ -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"`
}

View File

@@ -25,7 +25,7 @@ type ledgerAdmin struct{}
// @Param filter query dto.AdminLedgerListFilter true "Filter" // @Param filter query dto.AdminLedgerListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=dto.AdminLedgerItem} // @Success 200 {object} requests.Pager{items=dto.AdminLedgerItem}
// //
// @Router /t/:tenantCode/v1/admin/ledgers [get] // @Router /t/:tenantCode/v1/management/ledgers [get]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind filter query // @Bind filter query

View File

@@ -28,7 +28,7 @@ type mediaAssetAdmin struct{}
// @Param filter query dto.AdminMediaAssetListFilter true "Filter" // @Param filter query dto.AdminMediaAssetListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=models.MediaAsset} // @Success 200 {object} requests.Pager{items=models.MediaAsset}
// //
// @Router /t/:tenantCode/v1/admin/media_assets [get] // @Router /t/:tenantCode/v1/management/media_assets [get]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind filter query // @Bind filter query
@@ -65,7 +65,7 @@ func (*mediaAssetAdmin) adminList(
// @Param assetID path int64 true "AssetID" // @Param assetID path int64 true "AssetID"
// @Success 200 {object} models.MediaAsset // @Success 200 {object} models.MediaAsset
// //
// @Router /t/:tenantCode/v1/admin/media_assets/:assetID [get] // @Router /t/:tenantCode/v1/management/media_assets/:assetID [get]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind assetID path // @Bind assetID path
@@ -98,7 +98,7 @@ func (*mediaAssetAdmin) adminDetail(
// @Param form body dto.AdminMediaAssetUploadInitForm true "Form" // @Param form body dto.AdminMediaAssetUploadInitForm true "Form"
// @Success 200 {object} dto.AdminMediaAssetUploadInitResponse // @Success 200 {object} dto.AdminMediaAssetUploadInitResponse
// //
// @Router /t/:tenantCode/v1/admin/media_assets/upload_init [post] // @Router /t/:tenantCode/v1/management/media_assets/upload_init [post]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind form body // @Bind form body
@@ -135,7 +135,7 @@ func (*mediaAssetAdmin) uploadInit(
// @Param form body dto.AdminMediaAssetUploadCompleteForm false "Form" // @Param form body dto.AdminMediaAssetUploadCompleteForm false "Form"
// @Success 200 {object} models.MediaAsset // @Success 200 {object} models.MediaAsset
// //
// @Router /t/:tenantCode/v1/admin/media_assets/:assetID/upload_complete [post] // @Router /t/:tenantCode/v1/management/media_assets/:assetID/upload_complete [post]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind assetID path // @Bind assetID path
@@ -170,7 +170,7 @@ func (*mediaAssetAdmin) uploadComplete(
// @Param assetID path int64 true "AssetID" // @Param assetID path int64 true "AssetID"
// @Success 200 {object} models.MediaAsset // @Success 200 {object} models.MediaAsset
// //
// @Router /t/:tenantCode/v1/admin/media_assets/:assetID [delete] // @Router /t/:tenantCode/v1/management/media_assets/:assetID [delete]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind assetID path // @Bind assetID path

View File

@@ -28,7 +28,7 @@ type orderAdmin struct{}
// @Param filter query dto.AdminOrderListFilter true "Filter" // @Param filter query dto.AdminOrderListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=models.Order} // @Success 200 {object} requests.Pager{items=models.Order}
// //
// @Router /t/:tenantCode/v1/admin/orders [get] // @Router /t/:tenantCode/v1/management/orders [get]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind filter query // @Bind filter query
@@ -73,7 +73,7 @@ func (*orderAdmin) adminOrderList(
// @Param filter query dto.AdminOrderListFilter true "Filter" // @Param filter query dto.AdminOrderListFilter true "Filter"
// @Success 200 {object} dto.AdminOrderExportResponse // @Success 200 {object} dto.AdminOrderExportResponse
// //
// @Router /t/:tenantCode/v1/admin/orders/export [get] // @Router /t/:tenantCode/v1/management/orders/export [get]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind filter query // @Bind filter query
@@ -108,7 +108,7 @@ func (*orderAdmin) adminOrderExport(
// @Param orderID path int64 true "OrderID" // @Param orderID path int64 true "OrderID"
// @Success 200 {object} dto.AdminOrderDetail // @Success 200 {object} dto.AdminOrderDetail
// //
// @Router /t/:tenantCode/v1/admin/orders/:orderID [get] // @Router /t/:tenantCode/v1/management/orders/:orderID [get]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind orderID path // @Bind orderID path
@@ -148,7 +148,7 @@ func (*orderAdmin) adminOrderDetail(
// @Param form body dto.AdminOrderRefundForm true "Form" // @Param form body dto.AdminOrderRefundForm true "Form"
// @Success 200 {object} models.Order // @Success 200 {object} models.Order
// //
// @Router /t/:tenantCode/v1/admin/orders/:orderID/refund [post] // @Router /t/:tenantCode/v1/management/orders/:orderID/refund [post]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind orderID path // @Bind orderID path

View File

@@ -82,38 +82,45 @@ func (r *Routes) Register(router fiber.Router) {
PathParam[int64]("contentID"), PathParam[int64]("contentID"),
)) ))
// Register routes for controller: contentAdmin // Register routes for controller: contentAdmin
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/contents -> contentAdmin.list") r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/contents -> contentAdmin.list")
router.Get("/t/:tenantCode/v1/admin/contents"[len(r.Path()):], DataFunc3( router.Get("/t/:tenantCode/v1/management/contents"[len(r.Path()):], DataFunc3(
r.contentAdmin.list, r.contentAdmin.list,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
Query[dto.AdminContentListFilter]("filter"), Query[dto.AdminContentListFilter]("filter"),
)) ))
r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/admin/contents/:contentID -> contentAdmin.update") r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/management/contents/:contentID -> contentAdmin.update")
router.Patch("/t/:tenantCode/v1/admin/contents/:contentID"[len(r.Path()):], DataFunc4( router.Patch("/t/:tenantCode/v1/management/contents/:contentID"[len(r.Path()):], DataFunc4(
r.contentAdmin.update, r.contentAdmin.update,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("contentID"), PathParam[int64]("contentID"),
Body[dto.ContentUpdateForm]("form"), Body[dto.ContentUpdateForm]("form"),
)) ))
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/contents -> contentAdmin.create") r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/contents -> contentAdmin.create")
router.Post("/t/:tenantCode/v1/admin/contents"[len(r.Path()):], DataFunc3( router.Post("/t/:tenantCode/v1/management/contents"[len(r.Path()):], DataFunc3(
r.contentAdmin.create, r.contentAdmin.create,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
Body[dto.ContentCreateForm]("form"), Body[dto.ContentCreateForm]("form"),
)) ))
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/contents/:contentID/assets -> contentAdmin.attachAsset") r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/contents/:contentID/assets -> contentAdmin.attachAsset")
router.Post("/t/:tenantCode/v1/admin/contents/:contentID/assets"[len(r.Path()):], DataFunc4( router.Post("/t/:tenantCode/v1/management/contents/:contentID/assets"[len(r.Path()):], DataFunc4(
r.contentAdmin.attachAsset, r.contentAdmin.attachAsset,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("contentID"), PathParam[int64]("contentID"),
Body[dto.ContentAssetAttachForm]("form"), Body[dto.ContentAssetAttachForm]("form"),
)) ))
r.log.Debugf("Registering route: Put /t/:tenantCode/v1/admin/contents/:contentID/price -> contentAdmin.upsertPrice") r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/contents/publish -> contentAdmin.publish")
router.Put("/t/:tenantCode/v1/admin/contents/:contentID/price"[len(r.Path()):], DataFunc4( router.Post("/t/:tenantCode/v1/management/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/management/contents/:contentID/price -> contentAdmin.upsertPrice")
router.Put("/t/:tenantCode/v1/management/contents/:contentID/price"[len(r.Path()):], DataFunc4(
r.contentAdmin.upsertPrice, r.contentAdmin.upsertPrice,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
@@ -121,8 +128,8 @@ func (r *Routes) Register(router fiber.Router) {
Body[dto.ContentPriceUpsertForm]("form"), Body[dto.ContentPriceUpsertForm]("form"),
)) ))
// Register routes for controller: ledgerAdmin // Register routes for controller: ledgerAdmin
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/ledgers -> ledgerAdmin.adminLedgers") r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/ledgers -> ledgerAdmin.adminLedgers")
router.Get("/t/:tenantCode/v1/admin/ledgers"[len(r.Path()):], DataFunc3( router.Get("/t/:tenantCode/v1/management/ledgers"[len(r.Path()):], DataFunc3(
r.ledgerAdmin.adminLedgers, r.ledgerAdmin.adminLedgers,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
@@ -150,37 +157,37 @@ func (r *Routes) Register(router fiber.Router) {
Query[dto.MyLedgerListFilter]("filter"), Query[dto.MyLedgerListFilter]("filter"),
)) ))
// Register routes for controller: mediaAssetAdmin // Register routes for controller: mediaAssetAdmin
r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/admin/media_assets/:assetID -> mediaAssetAdmin.adminDelete") r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/management/media_assets/:assetID -> mediaAssetAdmin.adminDelete")
router.Delete("/t/:tenantCode/v1/admin/media_assets/:assetID"[len(r.Path()):], DataFunc3( router.Delete("/t/:tenantCode/v1/management/media_assets/:assetID"[len(r.Path()):], DataFunc3(
r.mediaAssetAdmin.adminDelete, r.mediaAssetAdmin.adminDelete,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("assetID"), PathParam[int64]("assetID"),
)) ))
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/media_assets -> mediaAssetAdmin.adminList") r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/media_assets -> mediaAssetAdmin.adminList")
router.Get("/t/:tenantCode/v1/admin/media_assets"[len(r.Path()):], DataFunc3( router.Get("/t/:tenantCode/v1/management/media_assets"[len(r.Path()):], DataFunc3(
r.mediaAssetAdmin.adminList, r.mediaAssetAdmin.adminList,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
Query[dto.AdminMediaAssetListFilter]("filter"), Query[dto.AdminMediaAssetListFilter]("filter"),
)) ))
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/media_assets/:assetID -> mediaAssetAdmin.adminDetail") r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/media_assets/:assetID -> mediaAssetAdmin.adminDetail")
router.Get("/t/:tenantCode/v1/admin/media_assets/:assetID"[len(r.Path()):], DataFunc3( router.Get("/t/:tenantCode/v1/management/media_assets/:assetID"[len(r.Path()):], DataFunc3(
r.mediaAssetAdmin.adminDetail, r.mediaAssetAdmin.adminDetail,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("assetID"), PathParam[int64]("assetID"),
)) ))
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/media_assets/:assetID/upload_complete -> mediaAssetAdmin.uploadComplete") r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/media_assets/:assetID/upload_complete -> mediaAssetAdmin.uploadComplete")
router.Post("/t/:tenantCode/v1/admin/media_assets/:assetID/upload_complete"[len(r.Path()):], DataFunc4( router.Post("/t/:tenantCode/v1/management/media_assets/:assetID/upload_complete"[len(r.Path()):], DataFunc4(
r.mediaAssetAdmin.uploadComplete, r.mediaAssetAdmin.uploadComplete,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("assetID"), PathParam[int64]("assetID"),
Body[dto.AdminMediaAssetUploadCompleteForm]("form"), Body[dto.AdminMediaAssetUploadCompleteForm]("form"),
)) ))
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/media_assets/upload_init -> mediaAssetAdmin.uploadInit") r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/media_assets/upload_init -> mediaAssetAdmin.uploadInit")
router.Post("/t/:tenantCode/v1/admin/media_assets/upload_init"[len(r.Path()):], DataFunc3( router.Post("/t/:tenantCode/v1/management/media_assets/upload_init"[len(r.Path()):], DataFunc3(
r.mediaAssetAdmin.uploadInit, r.mediaAssetAdmin.uploadInit,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
@@ -196,29 +203,29 @@ func (r *Routes) Register(router fiber.Router) {
Body[dto.PurchaseContentForm]("form"), Body[dto.PurchaseContentForm]("form"),
)) ))
// Register routes for controller: orderAdmin // Register routes for controller: orderAdmin
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/orders -> orderAdmin.adminOrderList") r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/orders -> orderAdmin.adminOrderList")
router.Get("/t/:tenantCode/v1/admin/orders"[len(r.Path()):], DataFunc3( router.Get("/t/:tenantCode/v1/management/orders"[len(r.Path()):], DataFunc3(
r.orderAdmin.adminOrderList, r.orderAdmin.adminOrderList,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
Query[dto.AdminOrderListFilter]("filter"), Query[dto.AdminOrderListFilter]("filter"),
)) ))
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/orders/:orderID -> orderAdmin.adminOrderDetail") r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/orders/:orderID -> orderAdmin.adminOrderDetail")
router.Get("/t/:tenantCode/v1/admin/orders/:orderID"[len(r.Path()):], DataFunc3( router.Get("/t/:tenantCode/v1/management/orders/:orderID"[len(r.Path()):], DataFunc3(
r.orderAdmin.adminOrderDetail, r.orderAdmin.adminOrderDetail,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("orderID"), PathParam[int64]("orderID"),
)) ))
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/orders/export -> orderAdmin.adminOrderExport") r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/orders/export -> orderAdmin.adminOrderExport")
router.Get("/t/:tenantCode/v1/admin/orders/export"[len(r.Path()):], DataFunc3( router.Get("/t/:tenantCode/v1/management/orders/export"[len(r.Path()):], DataFunc3(
r.orderAdmin.adminOrderExport, r.orderAdmin.adminOrderExport,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
Query[dto.AdminOrderListFilter]("filter"), Query[dto.AdminOrderListFilter]("filter"),
)) ))
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/orders/:orderID/refund -> orderAdmin.adminRefund") r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/orders/:orderID/refund -> orderAdmin.adminRefund")
router.Post("/t/:tenantCode/v1/admin/orders/:orderID/refund"[len(r.Path()):], DataFunc4( router.Post("/t/:tenantCode/v1/management/orders/:orderID/refund"[len(r.Path()):], DataFunc4(
r.orderAdmin.adminRefund, r.orderAdmin.adminRefund,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
@@ -241,46 +248,46 @@ func (r *Routes) Register(router fiber.Router) {
PathParam[int64]("orderID"), PathParam[int64]("orderID"),
)) ))
// Register routes for controller: tenantInviteAdmin // Register routes for controller: tenantInviteAdmin
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/invites -> tenantInviteAdmin.adminInviteList") r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/invites -> tenantInviteAdmin.adminInviteList")
router.Get("/t/:tenantCode/v1/admin/invites"[len(r.Path()):], DataFunc3( router.Get("/t/:tenantCode/v1/management/invites"[len(r.Path()):], DataFunc3(
r.tenantInviteAdmin.adminInviteList, r.tenantInviteAdmin.adminInviteList,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
Query[dto.AdminTenantInviteListFilter]("filter"), Query[dto.AdminTenantInviteListFilter]("filter"),
)) ))
r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/admin/invites/:inviteID/disable -> tenantInviteAdmin.adminDisableInvite") r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/management/invites/:inviteID/disable -> tenantInviteAdmin.adminDisableInvite")
router.Patch("/t/:tenantCode/v1/admin/invites/:inviteID/disable"[len(r.Path()):], DataFunc4( router.Patch("/t/:tenantCode/v1/management/invites/:inviteID/disable"[len(r.Path()):], DataFunc4(
r.tenantInviteAdmin.adminDisableInvite, r.tenantInviteAdmin.adminDisableInvite,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("inviteID"), PathParam[int64]("inviteID"),
Body[dto.AdminTenantInviteDisableForm]("form"), Body[dto.AdminTenantInviteDisableForm]("form"),
)) ))
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/invites -> tenantInviteAdmin.adminCreateInvite") r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/invites -> tenantInviteAdmin.adminCreateInvite")
router.Post("/t/:tenantCode/v1/admin/invites"[len(r.Path()):], DataFunc3( router.Post("/t/:tenantCode/v1/management/invites"[len(r.Path()):], DataFunc3(
r.tenantInviteAdmin.adminCreateInvite, r.tenantInviteAdmin.adminCreateInvite,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
Body[dto.AdminTenantInviteCreateForm]("form"), Body[dto.AdminTenantInviteCreateForm]("form"),
)) ))
// Register routes for controller: tenantJoinAdmin // Register routes for controller: tenantJoinAdmin
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/join-requests -> tenantJoinAdmin.adminJoinRequests") r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/join-requests -> tenantJoinAdmin.adminJoinRequests")
router.Get("/t/:tenantCode/v1/admin/join-requests"[len(r.Path()):], DataFunc3( router.Get("/t/:tenantCode/v1/management/join-requests"[len(r.Path()):], DataFunc3(
r.tenantJoinAdmin.adminJoinRequests, r.tenantJoinAdmin.adminJoinRequests,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
Query[dto.AdminTenantJoinRequestListFilter]("filter"), Query[dto.AdminTenantJoinRequestListFilter]("filter"),
)) ))
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/join-requests/:requestID/approve -> tenantJoinAdmin.adminApproveJoinRequest") r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/join-requests/:requestID/approve -> tenantJoinAdmin.adminApproveJoinRequest")
router.Post("/t/:tenantCode/v1/admin/join-requests/:requestID/approve"[len(r.Path()):], DataFunc4( router.Post("/t/:tenantCode/v1/management/join-requests/:requestID/approve"[len(r.Path()):], DataFunc4(
r.tenantJoinAdmin.adminApproveJoinRequest, r.tenantJoinAdmin.adminApproveJoinRequest,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("requestID"), PathParam[int64]("requestID"),
Body[dto.AdminTenantJoinRequestDecideForm]("form"), Body[dto.AdminTenantJoinRequestDecideForm]("form"),
)) ))
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/join-requests/:requestID/reject -> tenantJoinAdmin.adminRejectJoinRequest") r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/join-requests/:requestID/reject -> tenantJoinAdmin.adminRejectJoinRequest")
router.Post("/t/:tenantCode/v1/admin/join-requests/:requestID/reject"[len(r.Path()):], DataFunc4( router.Post("/t/:tenantCode/v1/management/join-requests/:requestID/reject"[len(r.Path()):], DataFunc4(
r.tenantJoinAdmin.adminRejectJoinRequest, r.tenantJoinAdmin.adminRejectJoinRequest,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
@@ -288,30 +295,30 @@ func (r *Routes) Register(router fiber.Router) {
Body[dto.AdminTenantJoinRequestDecideForm]("form"), Body[dto.AdminTenantJoinRequestDecideForm]("form"),
)) ))
// Register routes for controller: tenantUserAdmin // Register routes for controller: tenantUserAdmin
r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/admin/users/:userID -> tenantUserAdmin.adminRemoveUser") r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/management/users/:userID -> tenantUserAdmin.adminRemoveUser")
router.Delete("/t/:tenantCode/v1/admin/users/:userID"[len(r.Path()):], Func3( router.Delete("/t/:tenantCode/v1/management/users/:userID"[len(r.Path()):], Func3(
r.tenantUserAdmin.adminRemoveUser, r.tenantUserAdmin.adminRemoveUser,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("userID"), PathParam[int64]("userID"),
)) ))
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/users -> tenantUserAdmin.adminTenantUsers") r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/users -> tenantUserAdmin.adminTenantUsers")
router.Get("/t/:tenantCode/v1/admin/users"[len(r.Path()):], DataFunc3( router.Get("/t/:tenantCode/v1/management/users"[len(r.Path()):], DataFunc3(
r.tenantUserAdmin.adminTenantUsers, r.tenantUserAdmin.adminTenantUsers,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
Query[dto.AdminTenantUserListFilter]("filter"), Query[dto.AdminTenantUserListFilter]("filter"),
)) ))
r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/admin/users/:userID/role -> tenantUserAdmin.adminSetUserRole") r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/management/users/:userID/role -> tenantUserAdmin.adminSetUserRole")
router.Patch("/t/:tenantCode/v1/admin/users/:userID/role"[len(r.Path()):], DataFunc4( router.Patch("/t/:tenantCode/v1/management/users/:userID/role"[len(r.Path()):], DataFunc4(
r.tenantUserAdmin.adminSetUserRole, r.tenantUserAdmin.adminSetUserRole,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("userID"), PathParam[int64]("userID"),
Body[dto.AdminTenantUserRoleUpdateForm]("form"), Body[dto.AdminTenantUserRoleUpdateForm]("form"),
)) ))
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/users/:userID/join -> tenantUserAdmin.adminJoinUser") r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/users/:userID/join -> tenantUserAdmin.adminJoinUser")
router.Post("/t/:tenantCode/v1/admin/users/:userID/join"[len(r.Path()):], DataFunc3( router.Post("/t/:tenantCode/v1/management/users/:userID/join"[len(r.Path()):], DataFunc3(
r.tenantUserAdmin.adminJoinUser, r.tenantUserAdmin.adminJoinUser,
Local[*models.Tenant]("tenant"), Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"), Local[*models.TenantUser]("tenant_user"),

View File

@@ -28,7 +28,7 @@ type tenantInviteAdmin struct{}
// @Param form body dto.AdminTenantInviteCreateForm true "Form" // @Param form body dto.AdminTenantInviteCreateForm true "Form"
// @Success 200 {object} models.TenantInvite // @Success 200 {object} models.TenantInvite
// //
// @Router /t/:tenantCode/v1/admin/invites [post] // @Router /t/:tenantCode/v1/management/invites [post]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind form body // @Bind form body
@@ -63,7 +63,7 @@ func (*tenantInviteAdmin) adminCreateInvite(
// @Param filter query dto.AdminTenantInviteListFilter true "Filter" // @Param filter query dto.AdminTenantInviteListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=models.TenantInvite} // @Success 200 {object} requests.Pager{items=models.TenantInvite}
// //
// @Router /t/:tenantCode/v1/admin/invites [get] // @Router /t/:tenantCode/v1/management/invites [get]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind filter query // @Bind filter query
@@ -101,7 +101,7 @@ func (*tenantInviteAdmin) adminInviteList(
// @Param form body dto.AdminTenantInviteDisableForm true "Form" // @Param form body dto.AdminTenantInviteDisableForm true "Form"
// @Success 200 {object} models.TenantInvite // @Success 200 {object} models.TenantInvite
// //
// @Router /t/:tenantCode/v1/admin/invites/:inviteID/disable [patch] // @Router /t/:tenantCode/v1/management/invites/:inviteID/disable [patch]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind inviteID path // @Bind inviteID path

View File

@@ -28,7 +28,7 @@ type tenantJoinAdmin struct{}
// @Param filter query dto.AdminTenantJoinRequestListFilter true "Filter" // @Param filter query dto.AdminTenantJoinRequestListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=models.TenantJoinRequest} // @Success 200 {object} requests.Pager{items=models.TenantJoinRequest}
// //
// @Router /t/:tenantCode/v1/admin/join-requests [get] // @Router /t/:tenantCode/v1/management/join-requests [get]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind filter query // @Bind filter query
@@ -66,7 +66,7 @@ func (*tenantJoinAdmin) adminJoinRequests(
// @Param form body dto.AdminTenantJoinRequestDecideForm true "Form" // @Param form body dto.AdminTenantJoinRequestDecideForm true "Form"
// @Success 200 {object} models.TenantJoinRequest // @Success 200 {object} models.TenantJoinRequest
// //
// @Router /t/:tenantCode/v1/admin/join-requests/:requestID/approve [post] // @Router /t/:tenantCode/v1/management/join-requests/:requestID/approve [post]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind requestID path // @Bind requestID path
@@ -109,7 +109,7 @@ func (*tenantJoinAdmin) adminApproveJoinRequest(
// @Param form body dto.AdminTenantJoinRequestDecideForm true "Form" // @Param form body dto.AdminTenantJoinRequestDecideForm true "Form"
// @Success 200 {object} models.TenantJoinRequest // @Success 200 {object} models.TenantJoinRequest
// //
// @Router /t/:tenantCode/v1/admin/join-requests/:requestID/reject [post] // @Router /t/:tenantCode/v1/management/join-requests/:requestID/reject [post]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind requestID path // @Bind requestID path

View File

@@ -29,7 +29,7 @@ type tenantUserAdmin struct{}
// @Param userID path int64 true "UserID" // @Param userID path int64 true "UserID"
// @Success 200 {object} requests.Pager // @Success 200 {object} requests.Pager
// //
// @Router /t/:tenantCode/v1/admin/users/:userID [delete] // @Router /t/:tenantCode/v1/management/users/:userID [delete]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind userID path // @Bind userID path
@@ -67,7 +67,7 @@ func (*tenantUserAdmin) adminRemoveUser(
// @Param userID path int64 true "UserID" // @Param userID path int64 true "UserID"
// @Success 200 {object} dto.AdminTenantUserJoinResponse // @Success 200 {object} dto.AdminTenantUserJoinResponse
// //
// @Router /t/:tenantCode/v1/admin/users/:userID/join [post] // @Router /t/:tenantCode/v1/management/users/:userID/join [post]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind userID path // @Bind userID path
@@ -114,7 +114,7 @@ func (*tenantUserAdmin) adminJoinUser(
// @Param form body dto.AdminTenantUserRoleUpdateForm true "Form" // @Param form body dto.AdminTenantUserRoleUpdateForm true "Form"
// @Success 200 {object} dto.AdminTenantUserJoinResponse // @Success 200 {object} dto.AdminTenantUserJoinResponse
// //
// @Router /t/:tenantCode/v1/admin/users/:userID/role [patch] // @Router /t/:tenantCode/v1/management/users/:userID/role [patch]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind userID path // @Bind userID path
@@ -176,7 +176,7 @@ func (*tenantUserAdmin) adminSetUserRole(
// @Param filter query dto.AdminTenantUserListFilter true "Filter" // @Param filter query dto.AdminTenantUserListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=dto.AdminTenantUserItem} // @Success 200 {object} requests.Pager{items=dto.AdminTenantUserItem}
// //
// @Router /t/:tenantCode/v1/admin/users [get] // @Router /t/:tenantCode/v1/management/users [get]
// @Bind tenant local key(tenant) // @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user) // @Bind tenantUser local key(tenant_user)
// @Bind filter query // @Bind filter query

View File

@@ -2,7 +2,9 @@ package services
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"strings"
"time" "time"
"quyun/v2/app/errorx" "quyun/v2/app/errorx"
@@ -16,6 +18,7 @@ import (
"github.com/samber/lo" "github.com/samber/lo"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"go.ipao.vip/gen" "go.ipao.vip/gen"
"go.ipao.vip/gen/types"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -34,6 +37,20 @@ type ContentDetailResult struct {
HasAccess bool 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 { func requiredMediaAssetVariantForRole(role consts.ContentAssetRole) consts.MediaAssetVariant {
switch role { switch role {
case consts.ContentAssetRolePreview: case consts.ContentAssetRolePreview:
@@ -78,6 +95,272 @@ func (s *content) Create(ctx context.Context, tenantID, userID int64, form *dto.
return m, nil 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) { func (s *content) Update(ctx context.Context, tenantID, userID, contentID int64, form *dto.ContentUpdateForm) (*models.Content, error) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"tenant_id": tenantID, "tenant_id": tenantID,

View File

@@ -27,6 +27,7 @@ field_type:
contents: contents:
status: consts.ContentStatus status: consts.ContentStatus
visibility: consts.ContentVisibility visibility: consts.ContentVisibility
tags: types.JSON
content_assets: content_assets:
role: consts.ContentAssetRole role: consts.ContentAssetRole
content_prices: content_prices:

View File

@@ -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

View File

@@ -11,6 +11,7 @@ import (
"quyun/v2/pkg/consts" "quyun/v2/pkg/consts"
"go.ipao.vip/gen" "go.ipao.vip/gen"
"go.ipao.vip/gen/types"
"gorm.io/gorm" "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"` // 软删除时间:非空表示已删除;对外接口需过滤 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();用于审计与排序 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();编辑内容时写入 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 // Quick operations without importing query package

View File

@@ -38,6 +38,8 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery {
_contentQuery.DeletedAt = field.NewField(tableName, "deleted_at") _contentQuery.DeletedAt = field.NewField(tableName, "deleted_at")
_contentQuery.CreatedAt = field.NewTime(tableName, "created_at") _contentQuery.CreatedAt = field.NewTime(tableName, "created_at")
_contentQuery.UpdatedAt = field.NewTime(tableName, "updated_at") _contentQuery.UpdatedAt = field.NewTime(tableName, "updated_at")
_contentQuery.Summary = field.NewString(tableName, "summary")
_contentQuery.Tags = field.NewJSONB(tableName, "tags")
_contentQuery.fillFieldMap() _contentQuery.fillFieldMap()
@@ -61,6 +63,8 @@ type contentQuery struct {
DeletedAt field.Field // 软删除时间:非空表示已删除;对外接口需过滤 DeletedAt field.Field // 软删除时间:非空表示已删除;对外接口需过滤
CreatedAt field.Time // 创建时间:默认 now();用于审计与排序 CreatedAt field.Time // 创建时间:默认 now();用于审计与排序
UpdatedAt field.Time // 更新时间:默认 now();编辑内容时写入 UpdatedAt field.Time // 更新时间:默认 now();编辑内容时写入
Summary field.String // 简介:用于列表/卡片展示的短文本;建议 <= 256 字符(由业务校验)
Tags field.JSONB // 标签JSON 数组(字符串列表);用于分类/检索与聚合展示
fieldMap map[string]field.Expr fieldMap map[string]field.Expr
} }
@@ -90,6 +94,8 @@ func (c *contentQuery) updateTableName(table string) *contentQuery {
c.DeletedAt = field.NewField(table, "deleted_at") c.DeletedAt = field.NewField(table, "deleted_at")
c.CreatedAt = field.NewTime(table, "created_at") c.CreatedAt = field.NewTime(table, "created_at")
c.UpdatedAt = field.NewTime(table, "updated_at") c.UpdatedAt = field.NewTime(table, "updated_at")
c.Summary = field.NewString(table, "summary")
c.Tags = field.NewJSONB(table, "tags")
c.fillFieldMap() c.fillFieldMap()
@@ -122,7 +128,7 @@ func (c *contentQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool)
} }
func (c *contentQuery) fillFieldMap() { 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["id"] = c.ID
c.fieldMap["tenant_id"] = c.TenantID c.fieldMap["tenant_id"] = c.TenantID
c.fieldMap["user_id"] = c.UserID c.fieldMap["user_id"] = c.UserID
@@ -136,6 +142,8 @@ func (c *contentQuery) fillFieldMap() {
c.fieldMap["deleted_at"] = c.DeletedAt c.fieldMap["deleted_at"] = c.DeletedAt
c.fieldMap["created_at"] = c.CreatedAt c.fieldMap["created_at"] = c.CreatedAt
c.fieldMap["updated_at"] = c.UpdatedAt c.fieldMap["updated_at"] = c.UpdatedAt
c.fieldMap["summary"] = c.Summary
c.fieldMap["tags"] = c.Tags
} }
func (c contentQuery) clone(db *gorm.DB) contentQuery { func (c contentQuery) clone(db *gorm.DB) contentQuery {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,5 +3,4 @@
## API Access Constraints ## API Access Constraints
- `frontend/portal` 业务禁止调用任何 `/super/v1/*` 接口(包括本地开发的 Vite 代理)。 - `frontend/portal` 业务禁止调用任何 `/super/v1/*` 接口(包括本地开发的 Vite 代理)。
- Portal 仅允许使用面向用户/租户公开的接口前缀(例如 `/v1/*`,具体以后端实际路由为准)。 - Portal 仅允许使用面向用户/租户公开的接口前缀(例如 `/v1/*``/t/*`,具体以后端实际路由为准)。

View File

@@ -40,11 +40,11 @@ const model = ref([
{ {
label: '租户管理', label: '租户管理',
items: [ items: [
{ label: '概览', icon: 'pi pi-fw pi-chart-line', to: '/admin' }, { label: '概览', icon: 'pi pi-fw pi-chart-line', to: '/management' },
{ label: '内容管理', icon: 'pi pi-fw pi-file', to: '/admin/contents' }, { label: '内容管理', icon: 'pi pi-fw pi-file', to: '/management/contents' },
{ label: '订单管理', icon: 'pi pi-fw pi-list-check', to: '/admin/orders' }, { label: '订单管理', icon: 'pi pi-fw pi-list-check', to: '/management/orders' },
{ label: '租户设置', icon: 'pi pi-fw pi-cog', to: '/admin/settings' }, { label: '租户设置', icon: 'pi pi-fw pi-cog', to: '/management/settings' },
{ label: '团队与权限', icon: 'pi pi-fw pi-users', to: '/admin/team' } { label: '团队与权限', icon: 'pi pi-fw pi-users', to: '/management/team' }
] ]
}, },
{ {

View File

@@ -11,7 +11,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
const toast = useToast(); const toast = useToast();
const router = useRouter(); const router = useRouter();
const { state: sessionState, isLoggedIn, username, isTenantApproved } = useSession(); const { state: sessionState, isLoggedIn, username, isTenantApproved, isTenantAdmin, firstAdminTenantCode } = useSession();
const userMenuRef = ref(); const userMenuRef = ref();
@@ -38,6 +38,14 @@ const tenantApplyAction = computed(() => {
return { label: '申请创作者', to: '/tenant/apply', icon: 'pi pi-star' }; return { label: '申请创作者', to: '/tenant/apply', icon: 'pi pi-star' };
}); });
const publishTo = computed(() => {
const code = firstAdminTenantCode.value;
if (code) return { path: '/management/contents/new', query: { tenantCode: code } };
return '/management/contents/new';
});
const canPublish = computed(() => isTenantAdmin.value || isTenantApproved.value);
const userMenuItems = computed(() => [ const userMenuItems = computed(() => [
{ label: '个人中心', icon: 'pi pi-user', command: () => router.push('/me') }, { label: '个人中心', icon: 'pi pi-user', command: () => router.push('/me') },
{ separator: true }, { separator: true },
@@ -91,6 +99,10 @@ onMounted(() => {
<div class="layout-topbar-actions"> <div class="layout-topbar-actions">
<div class="layout-config-menu"> <div class="layout-config-menu">
<router-link v-if="canPublish" :to="publishTo" class="layout-topbar-action layout-topbar-action-text">
<i class="pi pi-plus"></i>
<span>发布</span>
</router-link>
<button type="button" class="layout-topbar-action" @click="toggleDarkMode"> <button type="button" class="layout-topbar-action" @click="toggleDarkMode">
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i> <i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
</button> </button>

View File

@@ -46,18 +46,18 @@ const router = createRouter({
{ path: 'tenant/apply/status', name: 'tenantApplyStatus', component: () => import('@/views/tenant/TenantApply.vue'), meta: { title: '创作者申请状态' } }, { path: 'tenant/apply/status', name: 'tenantApplyStatus', component: () => import('@/views/tenant/TenantApply.vue'), meta: { title: '创作者申请状态' } },
{ path: 'tenant/switch', name: 'tenantSwitch', component: TitlePage, meta: { title: '租户切换' } }, { path: 'tenant/switch', name: 'tenantSwitch', component: TitlePage, meta: { title: '租户切换' } },
{ path: 'admin', name: 'adminDashboard', component: TitlePage, meta: { title: '管理概览(仪表盘)' } }, { path: 'management', name: 'managementDashboard', component: TitlePage, meta: { title: '管理概览(仪表盘)' } },
{ path: 'admin/contents', name: 'adminContents', component: TitlePage, meta: { title: '内容列表(管理)' } }, { path: 'management/contents', name: 'managementContents', component: TitlePage, meta: { title: '内容列表(管理)' } },
{ path: 'admin/contents/new', name: 'adminContentNew', component: TitlePage, meta: { title: '内容发布' } }, { path: 'management/contents/new', name: 'managementContentNew', component: () => import('@/views/management/ContentPublish.vue'), meta: { title: '内容发布' } },
{ path: 'admin/contents/:contentId/edit', name: 'adminContentEdit', component: TitlePage, meta: { title: '内容编辑' } }, { path: 'management/contents/:contentId/edit', name: 'managementContentEdit', component: TitlePage, meta: { title: '内容编辑' } },
{ path: 'admin/assets', name: 'adminAssets', component: TitlePage, meta: { title: '素材库' } }, { path: 'management/assets', name: 'managementAssets', component: TitlePage, meta: { title: '素材库' } },
{ path: 'admin/orders', name: 'adminOrders', component: TitlePage, meta: { title: '订单列表(管理)' } }, { path: 'management/orders', name: 'managementOrders', component: TitlePage, meta: { title: '订单列表(管理)' } },
{ path: 'admin/orders/:orderId', name: 'adminOrderDetail', component: TitlePage, meta: { title: '订单详情(管理)' } }, { path: 'management/orders/:orderId', name: 'managementOrderDetail', component: TitlePage, meta: { title: '订单详情(管理)' } },
{ path: 'admin/tenants', name: 'adminTenants', component: TitlePage, meta: { title: '租户列表(管理)' } }, { path: 'management/tenants', name: 'managementTenants', component: TitlePage, meta: { title: '租户列表(管理)' } },
{ path: 'admin/settings', name: 'adminSettings', component: TitlePage, meta: { title: '租户设置' } }, { path: 'management/settings', name: 'managementSettings', component: TitlePage, meta: { title: '租户设置' } },
{ path: 'admin/team', name: 'adminTeam', component: TitlePage, meta: { title: '团队成员与权限' } }, { path: 'management/team', name: 'managementTeam', component: TitlePage, meta: { title: '团队成员与权限' } },
{ path: 'admin/audit-logs', name: 'adminAuditLogs', component: TitlePage, meta: { title: '操作日志' } }, { path: 'management/audit-logs', name: 'managementAuditLogs', component: TitlePage, meta: { title: '操作日志' } },
{ path: 'admin/finance', name: 'adminFinance', component: TitlePage, meta: { title: '财务结算/提现' } } { path: 'management/finance', name: 'managementFinance', component: TitlePage, meta: { title: '财务结算/提现' } }
] ]
}, },

View File

@@ -7,7 +7,9 @@ const state = reactive({
me: null, me: null,
loadingMe: false, loadingMe: false,
tenantApplication: null, tenantApplication: null,
loadingTenantApplication: false loadingTenantApplication: false,
myTenants: null,
loadingMyTenants: false
}); });
let initPromise = null; let initPromise = null;
@@ -19,12 +21,23 @@ export function useSession() {
return String(raw || '').trim(); return String(raw || '').trim();
}); });
const isTenantApproved = computed(() => state.tenantApplication?.hasApplication && state.tenantApplication?.status === 'verified'); const isTenantApproved = computed(() => state.tenantApplication?.hasApplication && state.tenantApplication?.status === 'verified');
const isTenantAdmin = computed(() => {
const items = Array.isArray(state.myTenants) ? state.myTenants : [];
return items.some((item) => item?.is_owner || (Array.isArray(item?.member_roles) && item.member_roles.includes('tenant_admin')));
});
const firstAdminTenantCode = computed(() => {
const items = Array.isArray(state.myTenants) ? state.myTenants : [];
const hit = items.find((item) => item?.is_owner || (Array.isArray(item?.member_roles) && item.member_roles.includes('tenant_admin')));
return String(hit?.tenant_code || '').trim();
});
return { return {
state, state,
isLoggedIn, isLoggedIn,
username, username,
isTenantApproved isTenantApproved,
isTenantAdmin,
firstAdminTenantCode
}; };
} }
@@ -60,6 +73,22 @@ export async function fetchTenantApplication() {
} }
} }
export async function fetchMyTenants() {
if (!state.token) {
state.myTenants = null;
return null;
}
state.loadingMyTenants = true;
try {
const data = await requestJson('/v1/me/tenants', { auth: true });
state.myTenants = Array.isArray(data) ? data : [];
return state.myTenants;
} finally {
state.loadingMyTenants = false;
}
}
export function setToken(token) { export function setToken(token) {
const normalized = String(token || '').trim(); const normalized = String(token || '').trim();
state.token = normalized; state.token = normalized;
@@ -70,6 +99,7 @@ export async function setTokenAndLoadMe(token) {
setToken(token); setToken(token);
await fetchMe(); await fetchMe();
await fetchTenantApplication(); await fetchTenantApplication();
await fetchMyTenants();
return state.me; return state.me;
} }
@@ -77,6 +107,7 @@ export function logout() {
setToken(''); setToken('');
state.me = null; state.me = null;
state.tenantApplication = null; state.tenantApplication = null;
state.myTenants = null;
} }
export async function initSession() { export async function initSession() {
@@ -88,6 +119,7 @@ export async function initSession() {
try { try {
await fetchMe(); await fetchMe();
await fetchTenantApplication(); await fetchTenantApplication();
await fetchMyTenants();
} catch { } catch {
// token 可能过期或无效:清理并让 UI 回到未登录态 // token 可能过期或无效:清理并让 UI 回到未登录态
logout(); logout();

View File

@@ -0,0 +1,216 @@
<script setup>
import { requestJson } from '@/service/apiClient';
import { useSession } from '@/service/session';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const toast = useToast();
const router = useRouter();
const route = useRoute();
const { isLoggedIn } = useSession();
const submitting = ref(false);
const tenantCode = ref('');
const title = ref('');
const summary = ref('');
const detail = ref('');
const tags = ref([]);
const coverAssetIDsInput = ref('');
const audioAssetIDsInput = ref('');
const videoAssetIDsInput = ref('');
const imageAssetIDsInput = ref('');
const priceAmount = ref(0);
const hasText = computed(() => String(detail.value || '').trim().length > 0);
const hasAnyMedia = computed(() => {
return (
String(audioAssetIDsInput.value || '').trim() ||
String(videoAssetIDsInput.value || '').trim() ||
String(imageAssetIDsInput.value || '').trim()
);
});
function parseIDList(input) {
const raw = String(input || '')
.split(/[,\n\r\t ]+/)
.map((v) => v.trim())
.filter(Boolean);
const ids = raw.map((v) => Number.parseInt(v, 10)).filter((v) => Number.isFinite(v) && v > 0);
const uniq = Array.from(new Set(ids));
return uniq;
}
async function submit() {
if (submitting.value) return;
const tenant = String(tenantCode.value || '').trim();
if (!tenant) {
toast.add({ severity: 'warn', summary: '请填写租户 ID', detail: '例如abcdedf', life: 2500 });
return;
}
const t = String(title.value || '').trim();
if (!t) {
toast.add({ severity: 'warn', summary: '请填写标题', detail: '标题不能为空', life: 2500 });
return;
}
const coverAssetIDs = parseIDList(coverAssetIDsInput.value);
if (coverAssetIDs.length < 1 || coverAssetIDs.length > 3) {
toast.add({ severity: 'warn', summary: '展示图数量不正确', detail: '展示图需为 1-3 张(填图片资源 ID', life: 2800 });
return;
}
const audioAssetIDs = parseIDList(audioAssetIDsInput.value);
const videoAssetIDs = parseIDList(videoAssetIDsInput.value);
const imageAssetIDs = parseIDList(imageAssetIDsInput.value);
if (!hasText.value && audioAssetIDs.length === 0 && videoAssetIDs.length === 0 && imageAssetIDs.length === 0) {
toast.add({ severity: 'warn', summary: '内容为空', detail: '请至少提供一种内容类型(文字/音频/视频/多图)', life: 2800 });
return;
}
const amount = Number(priceAmount.value || 0);
if (!Number.isFinite(amount) || amount < 0) {
toast.add({ severity: 'warn', summary: '价格不正确', detail: '价格需为 0 或正整数(单位:分)', life: 2500 });
return;
}
try {
submitting.value = true;
const payload = await requestJson(`/t/${encodeURIComponent(tenant)}/v1/management/contents/publish`, {
method: 'POST',
auth: true,
body: {
title: t,
summary: String(summary.value || '').trim(),
detail: String(detail.value || '').trim(),
tags: Array.isArray(tags.value) ? tags.value : [],
cover_asset_ids: coverAssetIDs,
audio_asset_ids: audioAssetIDs,
video_asset_ids: videoAssetIDs,
image_asset_ids: imageAssetIDs,
price_amount: amount,
currency: 'CNY'
}
});
toast.add({
severity: 'success',
summary: '提交成功',
detail: `内容已进入审核ID: ${payload?.content?.id || '-'})`,
life: 2500
});
await router.push('/management/contents');
} catch (err) {
const status = err?.status;
const msg = String(err?.payload?.message || err?.payload?.error || err?.message || '').trim();
if (status === 401) {
toast.add({ severity: 'warn', summary: '请先登录', detail: '登录后再提交发布', life: 2500 });
const redirect = typeof route.fullPath === 'string' ? route.fullPath : '/management/contents/new';
await router.push(`/auth/login?redirect=${encodeURIComponent(redirect)}`);
return;
}
toast.add({ severity: 'error', summary: '提交失败', detail: msg || '请检查资源是否已处理完成ready然后重试', life: 3500 });
} finally {
submitting.value = false;
}
}
onMounted(async () => {
if (!isLoggedIn.value) {
const redirect = typeof route.fullPath === 'string' ? route.fullPath : '/management/contents/new';
await router.push(`/auth/login?redirect=${encodeURIComponent(redirect)}`);
return;
}
if (!String(tenantCode.value || '').trim()) {
const q = route.query?.tenantCode;
if (typeof q === 'string' && q.trim()) {
tenantCode.value = q.trim();
}
}
});
</script>
<template>
<div class="card max-w-3xl mx-auto">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">内容发布</h1>
<div class="text-muted-color mt-2">支持文字/音频/视频/多图组合展示图需 1-3 价格单位为分</div>
</div>
<Button label="提交" icon="pi pi-send" size="large" :loading="submitting" @click="submit" />
</div>
<Divider class="my-6" />
<div class="flex flex-col gap-5">
<div>
<label for="tenantCode" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">租户 ID</label>
<InputText id="tenantCode" v-model="tenantCode" size="large" class="w-full text-xl py-3" placeholder="例如 abcdedf" autocomplete="off" />
<small class="text-muted-color">当前 Portal 暂不支持自动选择租户这里先手动填写</small>
</div>
<div>
<label for="title" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">标题</label>
<InputText id="title" v-model="title" size="large" class="w-full text-xl py-3" placeholder="请输入标题" autocomplete="off" />
</div>
<div>
<label for="summary" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">简介</label>
<InputText id="summary" v-model="summary" size="large" class="w-full text-xl py-3" placeholder="用于列表展示(建议 ≤ 256 字符)" autocomplete="off" />
</div>
<div>
<label for="detail" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">详细文字内容</label>
<Textarea id="detail" v-model="detail" autoResize rows="6" class="w-full text-lg" placeholder="可选:填写后视为包含文字内容类型" />
</div>
<div>
<label class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">标签</label>
<Chips v-model="tags" class="w-full" placeholder="回车添加标签(最多建议 20 个)" />
</div>
<div>
<label for="coverAssets" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">展示图图片资源 ID</label>
<InputText
id="coverAssets"
v-model="coverAssetIDsInput"
size="large"
class="w-full text-xl py-3"
placeholder="1-3 个图片资源 ID使用逗号分隔例如12,13,14"
autocomplete="off"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="videoAssets" class="block text-surface-900 dark:text-surface-0 font-medium mb-1">视频资源 ID</label>
<InputText id="videoAssets" v-model="videoAssetIDsInput" size="large" class="w-full" placeholder="例如21,22" autocomplete="off" />
</div>
<div>
<label for="audioAssets" class="block text-surface-900 dark:text-surface-0 font-medium mb-1">音频资源 ID</label>
<InputText id="audioAssets" v-model="audioAssetIDsInput" size="large" class="w-full" placeholder="例如31,32" autocomplete="off" />
</div>
<div>
<label for="imageAssets" class="block text-surface-900 dark:text-surface-0 font-medium mb-1">多图资源 ID</label>
<InputText id="imageAssets" v-model="imageAssetIDsInput" size="large" class="w-full" placeholder="例如41,42,43" autocomplete="off" />
</div>
</div>
<div>
<label for="priceAmount" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">价格</label>
<InputNumber id="priceAmount" v-model="priceAmount" :min="0" size="large" class="w-full" placeholder="0 表示免费" />
</div>
<div class="text-sm text-muted-color">
提示如提交失败且提示资源未处理完成请先确保对应资源已变为 ready媒体转码/处理完成
</div>
</div>
</div>
</template>

View File

@@ -19,6 +19,10 @@ export default defineConfig({
'/v1': { '/v1': {
target: 'http://localhost:8080', target: 'http://localhost:8080',
changeOrigin: true changeOrigin: true
},
'/t': {
target: 'http://localhost:8080',
changeOrigin: true
} }
} }
}, },