From 674c562831fe3c42e693034098c1e3c40c5aca38 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 18 Dec 2025 10:27:40 +0800 Subject: [PATCH] feat: follow llm.txt --- backend/app/http/super/auth.go | 2 +- backend/app/http/super/routes.gen.go | 2 +- backend/app/http/super/routes.manual.go | 2 +- backend/app/http/super/tenant.go | 4 +- backend/app/http/super/user.go | 8 +- backend/app/http/tenant/content.go | 20 +- backend/app/http/tenant/content_admin.go | 24 +- backend/app/http/tenant/dto/content.go | 1 + backend/app/http/tenant/dto/content_admin.go | 1 + backend/app/http/tenant/dto/me.go | 2 +- backend/app/http/tenant/me.go | 6 +- backend/app/http/tenant/routes.gen.go | 38 +- backend/app/http/tenant/routes.manual.go | 3 +- backend/app/middlewares/tenant.go | 18 +- backend/app/requests/label.go | 7 +- backend/app/requests/pagination.go | 13 +- backend/app/requests/sort.go | 5 +- backend/app/services/content.go | 6 +- .../20251217223000_media_contents.sql | 71 +- backend/docs/docs.go | 976 +++++++++++++++++- backend/docs/swagger.json | 976 +++++++++++++++++- backend/docs/swagger.yaml | 683 +++++++++++- backend/go.mod | 3 +- backend/main.go | 4 +- backend/pkg/consts/consts.go | 6 + 25 files changed, 2775 insertions(+), 106 deletions(-) diff --git a/backend/app/http/super/auth.go b/backend/app/http/super/auth.go index b572c88..dd63c8a 100644 --- a/backend/app/http/super/auth.go +++ b/backend/app/http/super/auth.go @@ -56,7 +56,7 @@ func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse, // @Tags Super // @Accept json // @Produce json -// @Success 200 {object} dto.LoginResponse "成功" +// @Success 200 {object} dto.LoginResponse "成功" // // @Router /super/v1/auth/token [get] func (ctl *auth) token(ctx fiber.Ctx) (*dto.LoginResponse, error) { diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go index a42ed14..ba692a9 100644 --- a/backend/app/http/super/routes.gen.go +++ b/backend/app/http/super/routes.gen.go @@ -18,7 +18,7 @@ import ( // Routes implements the HttpRoute contract and provides route registration // for all controllers in the super module. // -// @provider contracts.HttpRoute atom.GroupRoutes +// @provider contracts.HttpRoute atom.GroupRoutes type Routes struct { log *log.Entry `inject:"false"` middlewares *middlewares.Middlewares diff --git a/backend/app/http/super/routes.manual.go b/backend/app/http/super/routes.manual.go index 7692daa..581130a 100644 --- a/backend/app/http/super/routes.manual.go +++ b/backend/app/http/super/routes.manual.go @@ -1,7 +1,7 @@ package super func (r *Routes) Path() string { - return "/super" + return "/super/v1" } func (r *Routes) Middlewares() []any { diff --git a/backend/app/http/super/tenant.go b/backend/app/http/super/tenant.go index 3504788..6f9c999 100644 --- a/backend/app/http/super/tenant.go +++ b/backend/app/http/super/tenant.go @@ -20,7 +20,7 @@ type tenant struct{} // @Tags Super // @Accept json // @Produce json -// @Param filter query dto.TenantFilter true "Filter" +// @Param filter query dto.TenantFilter true "Filter" // @Success 200 {object} requests.Pager{items=dto.TenantItem} // // @Router /super/v1/tenants [get] @@ -72,7 +72,7 @@ func (*tenant) updateStatus(ctx fiber.Ctx, tenantID int64, form *dto.TenantStatu // @Tags Super // @Accept json // @Produce json -// @Success 200 {array} requests.KV +// @Success 200 {array} requests.KV // // @Router /super/v1/tenants/statuses [get] // @Bind userID path diff --git a/backend/app/http/super/user.go b/backend/app/http/super/user.go index c25ce7d..6018a96 100644 --- a/backend/app/http/super/user.go +++ b/backend/app/http/super/user.go @@ -20,7 +20,7 @@ type user struct{} // @Tags Super // @Accept json // @Produce json -// @Param filter query dto.UserPageFilter true "Filter" +// @Param filter query dto.UserPageFilter true "Filter" // @Success 200 {object} requests.Pager{items=dto.UserItem} // // @Router /super/v1/users [get] @@ -36,7 +36,7 @@ func (*user) list(ctx fiber.Ctx, filter *dto.UserPageFilter) (*requests.Pager, e // @Accept json // @Produce json // @Param userID path int64 true "UserID" -// @Param form body dto.UserStatusUpdateForm true "Form" +// @Param form body dto.UserStatusUpdateForm true "Form" // // @Router /super/v1/users/:userID/status [patch] // @Bind userID path @@ -51,7 +51,7 @@ func (*user) updateStatus(ctx fiber.Ctx, userID int64, form *dto.UserStatusUpdat // @Tags Super // @Accept json // @Produce json -// @Success 200 {array} requests.KV +// @Success 200 {array} requests.KV // // @Router /super/v1/users/statuses [get] // @Bind userID path @@ -68,7 +68,7 @@ func (*user) statusList(ctx fiber.Ctx) ([]requests.KV, error) { // @Tags Super // @Accept json // @Produce json -// @Success 200 {array} dto.UserStatistics +// @Success 200 {array} dto.UserStatistics // // @Router /super/v1/users/statistics [get] // @Bind userID path diff --git a/backend/app/http/tenant/content.go b/backend/app/http/tenant/content.go index 41afc6f..7bc949b 100644 --- a/backend/app/http/tenant/content.go +++ b/backend/app/http/tenant/content.go @@ -14,7 +14,7 @@ import ( // content provides tenant-side read-only content endpoints. // -// @provider +// @provider type content struct{} // list @@ -23,11 +23,11 @@ type content struct{} // @Tags Tenant // @Accept json // @Produce json -// @Param tenant_code path string true "Tenant Code" +// @Param tenantCode path string true "Tenant Code" // @Param filter query dto.ContentListFilter true "Filter" // @Success 200 {object} requests.Pager{items=dto.ContentItem} // -// @Router /t/:tenant_code/v1/contents [get] +// @Router /t/:tenantCode/v1/contents [get] // @Bind tenant local key(tenant) // @Bind user local key(user) // @Bind filter query @@ -47,11 +47,11 @@ func (*content) list(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, fi // @Tags Tenant // @Accept json // @Produce json -// @Param tenant_code path string true "Tenant Code" +// @Param tenantCode path string true "Tenant Code" // @Param contentID path int64 true "ContentID" // @Success 200 {object} dto.ContentDetail // -// @Router /t/:tenant_code/v1/contents/:contentID [get] +// @Router /t/:tenantCode/v1/contents/:contentID [get] // @Bind tenant local key(tenant) // @Bind user local key(user) // @Bind contentID path @@ -79,11 +79,11 @@ func (*content) show(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, co // @Tags Tenant // @Accept json // @Produce json -// @Param tenant_code path string true "Tenant Code" +// @Param tenantCode path string true "Tenant Code" // @Param contentID path int64 true "ContentID" // @Success 200 {object} dto.ContentAssetsResponse // -// @Router /t/:tenant_code/v1/contents/:contentID/preview [get] +// @Router /t/:tenantCode/v1/contents/:contentID/preview [get] // @Bind tenant local key(tenant) // @Bind user local key(user) // @Bind contentID path @@ -106,7 +106,7 @@ func (*content) previewAssets(ctx fiber.Ctx, tenant *models.Tenant, user *models previewSeconds := int32(detail.Content.PreviewSeconds) if previewSeconds <= 0 { - previewSeconds = 60 + previewSeconds = consts.DefaultContentPreviewSeconds } return &dto.ContentAssetsResponse{ Content: detail.Content, @@ -121,11 +121,11 @@ func (*content) previewAssets(ctx fiber.Ctx, tenant *models.Tenant, user *models // @Tags Tenant // @Accept json // @Produce json -// @Param tenant_code path string true "Tenant Code" +// @Param tenantCode path string true "Tenant Code" // @Param contentID path int64 true "ContentID" // @Success 200 {object} dto.ContentAssetsResponse // -// @Router /t/:tenant_code/v1/contents/:contentID/assets [get] +// @Router /t/:tenantCode/v1/contents/:contentID/assets [get] // @Bind tenant local key(tenant) // @Bind user local key(user) // @Bind contentID path diff --git a/backend/app/http/tenant/content_admin.go b/backend/app/http/tenant/content_admin.go index e8d89a6..9dbb99c 100644 --- a/backend/app/http/tenant/content_admin.go +++ b/backend/app/http/tenant/content_admin.go @@ -15,7 +15,7 @@ import ( // contentAdmin provides tenant-admin content management endpoints. // -// @provider +// @provider type contentAdmin struct{} func requireTenantAdmin(tenantUser *models.TenantUser) error { @@ -34,11 +34,11 @@ func requireTenantAdmin(tenantUser *models.TenantUser) error { // @Tags Tenant // @Accept json // @Produce json -// @Param tenant_code path string true "Tenant Code" +// @Param tenantCode path string true "Tenant Code" // @Param form body dto.ContentCreateForm true "Form" // @Success 200 {object} models.Content // -// @Router /t/:tenant_code/v1/admin/contents [post] +// @Router /t/:tenantCode/v1/admin/contents [post] // @Bind tenant local key(tenant) // @Bind tenantUser local key(tenant_user) // @Bind form body @@ -61,12 +61,12 @@ func (*contentAdmin) create(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *mo // @Tags Tenant // @Accept json // @Produce json -// @Param tenant_code path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" +// @Param tenantCode path string true "Tenant Code" +// @Param contentID path int64 true "ContentID" // @Param form body dto.ContentUpdateForm true "Form" // @Success 200 {object} models.Content // -// @Router /t/:tenant_code/v1/admin/contents/:contentID [patch] +// @Router /t/:tenantCode/v1/admin/contents/:contentID [patch] // @Bind tenant local key(tenant) // @Bind tenantUser local key(tenant_user) // @Bind contentID path @@ -91,12 +91,12 @@ func (*contentAdmin) update(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *mo // @Tags Tenant // @Accept json // @Produce json -// @Param tenant_code path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" +// @Param tenantCode path string true "Tenant Code" +// @Param contentID path int64 true "ContentID" // @Param form body dto.ContentPriceUpsertForm true "Form" // @Success 200 {object} models.ContentPrice // -// @Router /t/:tenant_code/v1/admin/contents/:contentID/price [put] +// @Router /t/:tenantCode/v1/admin/contents/:contentID/price [put] // @Bind tenant local key(tenant) // @Bind tenantUser local key(tenant_user) // @Bind contentID path @@ -121,12 +121,12 @@ func (*contentAdmin) upsertPrice(ctx fiber.Ctx, tenant *models.Tenant, tenantUse // @Tags Tenant // @Accept json // @Produce json -// @Param tenant_code path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" +// @Param tenantCode path string true "Tenant Code" +// @Param contentID path int64 true "ContentID" // @Param form body dto.ContentAssetAttachForm true "Form" // @Success 200 {object} models.ContentAsset // -// @Router /t/:tenant_code/v1/admin/contents/:contentID/assets [post] +// @Router /t/:tenantCode/v1/admin/contents/:contentID/assets [post] // @Bind tenant local key(tenant) // @Bind tenantUser local key(tenant_user) // @Bind contentID path diff --git a/backend/app/http/tenant/dto/content.go b/backend/app/http/tenant/dto/content.go index 8f76370..1fb184d 100644 --- a/backend/app/http/tenant/dto/content.go +++ b/backend/app/http/tenant/dto/content.go @@ -5,6 +5,7 @@ import ( "quyun/v2/database/models" ) +// ContentListFilter defines list query filters for published contents within a tenant. type ContentListFilter struct { // Pagination controls paging parameters (page/limit). requests.Pagination `json:",inline" query:",inline"` diff --git a/backend/app/http/tenant/dto/content_admin.go b/backend/app/http/tenant/dto/content_admin.go index c32463e..10793d7 100644 --- a/backend/app/http/tenant/dto/content_admin.go +++ b/backend/app/http/tenant/dto/content_admin.go @@ -6,6 +6,7 @@ import ( "quyun/v2/pkg/consts" ) +// ContentCreateForm defines payload for tenant-admin to create a new content draft. type ContentCreateForm struct { // Title is the content title. Title string `json:"title,omitempty"` diff --git a/backend/app/http/tenant/dto/me.go b/backend/app/http/tenant/dto/me.go index fed5c0e..b849a97 100644 --- a/backend/app/http/tenant/dto/me.go +++ b/backend/app/http/tenant/dto/me.go @@ -4,7 +4,7 @@ import "quyun/v2/database/models" // MeResponse returns the resolved tenant context for the current request. type MeResponse struct { - // Tenant is the resolved tenant by `tenant_code`. + // Tenant is the resolved tenant by `tenantCode`. Tenant *models.Tenant `json:"tenant,omitempty"` // User is the authenticated user derived from JWT `user_id`. User *models.User `json:"user,omitempty"` diff --git a/backend/app/http/tenant/me.go b/backend/app/http/tenant/me.go index 6584798..c5132ff 100644 --- a/backend/app/http/tenant/me.go +++ b/backend/app/http/tenant/me.go @@ -9,7 +9,7 @@ import ( // me provides tenant context introspection endpoints (current tenant/user/tenant_user). // -// @provider +// @provider type me struct{} // get @@ -18,10 +18,10 @@ type me struct{} // @Tags Tenant // @Accept json // @Produce json -// @Param tenant_code path string true "Tenant Code" +// @Param tenantCode path string true "Tenant Code" // @Success 200 {object} dto.MeResponse // -// @Router /t/:tenant_code/v1/me [get] +// @Router /t/:tenantCode/v1/me [get] // @Bind tenant local key(tenant) // @Bind user local key(user) // @Bind tenantUser local key(tenant_user) diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index 8705f95..f8b161e 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -19,7 +19,7 @@ import ( // Routes implements the HttpRoute contract and provides route registration // for all controllers in the tenant module. // -// @provider contracts.HttpRoute atom.GroupRoutes +// @provider contracts.HttpRoute atom.GroupRoutes type Routes struct { log *log.Entry `inject:"false"` middlewares *middlewares.Middlewares @@ -45,60 +45,60 @@ func (r *Routes) Name() string { // Each route is registered with its corresponding controller action and parameter bindings. func (r *Routes) Register(router fiber.Router) { // Register routes for controller: content - r.log.Debugf("Registering route: Get /t/:tenant_code/v1/contents -> content.list") - router.Get("/t/:tenant_code/v1/contents"[len(r.Path()):], DataFunc3( + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/contents -> content.list") + router.Get("/t/:tenantCode/v1/contents"[len(r.Path()):], DataFunc3( r.content.list, Local[*models.Tenant]("tenant"), Local[*models.User]("user"), Query[dto.ContentListFilter]("filter"), )) - r.log.Debugf("Registering route: Get /t/:tenant_code/v1/contents/:contentID -> content.show") - router.Get("/t/:tenant_code/v1/contents/:contentID"[len(r.Path()):], DataFunc3( + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/contents/:contentID -> content.show") + router.Get("/t/:tenantCode/v1/contents/:contentID"[len(r.Path()):], DataFunc3( r.content.show, Local[*models.Tenant]("tenant"), Local[*models.User]("user"), PathParam[int64]("contentID"), )) - r.log.Debugf("Registering route: Get /t/:tenant_code/v1/contents/:contentID/assets -> content.mainAssets") - router.Get("/t/:tenant_code/v1/contents/:contentID/assets"[len(r.Path()):], DataFunc3( + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/contents/:contentID/assets -> content.mainAssets") + router.Get("/t/:tenantCode/v1/contents/:contentID/assets"[len(r.Path()):], DataFunc3( r.content.mainAssets, Local[*models.Tenant]("tenant"), Local[*models.User]("user"), PathParam[int64]("contentID"), )) - r.log.Debugf("Registering route: Get /t/:tenant_code/v1/contents/:contentID/preview -> content.previewAssets") - router.Get("/t/:tenant_code/v1/contents/:contentID/preview"[len(r.Path()):], DataFunc3( + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/contents/:contentID/preview -> content.previewAssets") + router.Get("/t/:tenantCode/v1/contents/:contentID/preview"[len(r.Path()):], DataFunc3( r.content.previewAssets, Local[*models.Tenant]("tenant"), Local[*models.User]("user"), PathParam[int64]("contentID"), )) // Register routes for controller: contentAdmin - r.log.Debugf("Registering route: Patch /t/:tenant_code/v1/admin/contents/:contentID -> contentAdmin.update") - router.Patch("/t/:tenant_code/v1/admin/contents/:contentID"[len(r.Path()):], DataFunc4( + r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/admin/contents/:contentID -> contentAdmin.update") + router.Patch("/t/:tenantCode/v1/admin/contents/:contentID"[len(r.Path()):], DataFunc4( r.contentAdmin.update, Local[*models.Tenant]("tenant"), Local[*models.TenantUser]("tenant_user"), PathParam[int64]("contentID"), Body[dto.ContentUpdateForm]("form"), )) - r.log.Debugf("Registering route: Post /t/:tenant_code/v1/admin/contents -> contentAdmin.create") - router.Post("/t/:tenant_code/v1/admin/contents"[len(r.Path()):], DataFunc3( + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/contents -> contentAdmin.create") + router.Post("/t/:tenantCode/v1/admin/contents"[len(r.Path()):], DataFunc3( r.contentAdmin.create, Local[*models.Tenant]("tenant"), Local[*models.TenantUser]("tenant_user"), Body[dto.ContentCreateForm]("form"), )) - r.log.Debugf("Registering route: Post /t/:tenant_code/v1/admin/contents/:contentID/assets -> contentAdmin.attachAsset") - router.Post("/t/:tenant_code/v1/admin/contents/:contentID/assets"[len(r.Path()):], DataFunc4( + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/contents/:contentID/assets -> contentAdmin.attachAsset") + router.Post("/t/:tenantCode/v1/admin/contents/:contentID/assets"[len(r.Path()):], DataFunc4( r.contentAdmin.attachAsset, Local[*models.Tenant]("tenant"), Local[*models.TenantUser]("tenant_user"), PathParam[int64]("contentID"), Body[dto.ContentAssetAttachForm]("form"), )) - r.log.Debugf("Registering route: Put /t/:tenant_code/v1/admin/contents/:contentID/price -> contentAdmin.upsertPrice") - router.Put("/t/:tenant_code/v1/admin/contents/:contentID/price"[len(r.Path()):], DataFunc4( + 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, Local[*models.Tenant]("tenant"), Local[*models.TenantUser]("tenant_user"), @@ -106,8 +106,8 @@ func (r *Routes) Register(router fiber.Router) { Body[dto.ContentPriceUpsertForm]("form"), )) // Register routes for controller: me - r.log.Debugf("Registering route: Get /t/:tenant_code/v1/me -> me.get") - router.Get("/t/:tenant_code/v1/me"[len(r.Path()):], DataFunc3( + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me -> me.get") + router.Get("/t/:tenantCode/v1/me"[len(r.Path()):], DataFunc3( r.me.get, Local[*models.Tenant]("tenant"), Local[*models.User]("user"), diff --git a/backend/app/http/tenant/routes.manual.go b/backend/app/http/tenant/routes.manual.go index 1043b51..15e5979 100644 --- a/backend/app/http/tenant/routes.manual.go +++ b/backend/app/http/tenant/routes.manual.go @@ -1,12 +1,11 @@ package tenant func (r *Routes) Path() string { - return "/t/:tenant_code/v1" + return "/t/:tenantCode/v1" } func (r *Routes) Middlewares() []any { return []any{ - r.middlewares.DebugMode, r.middlewares.TenantResolve, r.middlewares.TenantAuth, r.middlewares.TenantRequireMember, diff --git a/backend/app/middlewares/tenant.go b/backend/app/middlewares/tenant.go index 06d5611..d9de749 100644 --- a/backend/app/middlewares/tenant.go +++ b/backend/app/middlewares/tenant.go @@ -1,7 +1,6 @@ package middlewares import ( - "go.ipao.vip/atom/container" "quyun/v2/app/errorx" "quyun/v2/app/services" "quyun/v2/database/models" @@ -12,9 +11,9 @@ import ( ) func (f *Middlewares) TenantResolve(c fiber.Ctx) error { - tenantCode := c.Params("tenant_code") + tenantCode := c.Params("tenantCode") if tenantCode == "" { - return errorx.ErrMissingParameter.WithMsg("缺少 tenant_code") + return errorx.ErrMissingParameter.WithMsg("缺少 tenantCode") } tenantModel, err := services.Tenant.FindByCode(c, tenantCode) @@ -39,18 +38,7 @@ func (f *Middlewares) TenantAuth(c fiber.Ctx) error { return errorx.ErrTokenMissing } - jwtProvider := f.jwt - if jwtProvider == nil { - if err := container.Container.Invoke(func(j *jwt.JWT) { - jwtProvider = j - f.jwt = j - }); err != nil { - f.log.WithError(err).Error("middlewares.tenant.auth.jwt_provider_missing") - return errorx.ErrInternalError.WithMsg("jwt provider missing") - } - } - - claims, err := jwtProvider.Parse(authHeader) + claims, err := f.jwt.Parse(authHeader) if err != nil { f.log.WithError(err).Warn("middlewares.tenant.auth.invalid_token") switch err { diff --git a/backend/app/requests/label.go b/backend/app/requests/label.go index 0390afa..6831504 100644 --- a/backend/app/requests/label.go +++ b/backend/app/requests/label.go @@ -1,8 +1,11 @@ package requests +// KV is a generic key-value response item, commonly used for label/value enums. type KV struct { - Key string `json:"key,omitempty"` - Value any `json:"value,omitempty"` + // Key is a machine-readable value, usually an enum string. + Key string `json:"key,omitempty"` + // Value is the label payload, often a human-readable string. + Value any `json:"value,omitempty"` } func NewKV(k string, v any) KV { diff --git a/backend/app/requests/pagination.go b/backend/app/requests/pagination.go index c739bc7..898f53c 100644 --- a/backend/app/requests/pagination.go +++ b/backend/app/requests/pagination.go @@ -2,14 +2,21 @@ package requests import "github.com/samber/lo" +// Pager is a common paginated API response envelope. type Pager struct { + // Pagination contains paging inputs (page/limit) echoed back in the response. Pagination ` json:",inline"` - Total int64 `json:"total"` - Items any `json:"items"` + // Total is the total number of items matching current filter (before paging). + Total int64 `json:"total"` + // Items is the paged result list; concrete type depends on endpoint. + Items any `json:"items"` } +// Pagination defines common paging query parameters used by list endpoints. type Pagination struct { - Page int64 `json:"page" form:"page" query:"page"` + // Page is 1-based page index; values <= 0 are normalized to 1. + Page int64 `json:"page" form:"page" query:"page"` + // Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10). Limit int64 `json:"limit" form:"limit" query:"limit"` } diff --git a/backend/app/requests/sort.go b/backend/app/requests/sort.go index 517b419..9166503 100644 --- a/backend/app/requests/sort.go +++ b/backend/app/requests/sort.go @@ -6,8 +6,11 @@ import ( "github.com/samber/lo" ) +// SortQueryFilter defines common query sorting parameters used by list endpoints. type SortQueryFilter struct { - Asc *string `json:"asc" form:"asc"` + // Asc specifies comma-separated field names to sort ascending by. + Asc *string `json:"asc" form:"asc"` + // Desc specifies comma-separated field names to sort descending by. Desc *string `json:"desc" form:"desc"` } diff --git a/backend/app/services/content.go b/backend/app/services/content.go index 1c0ede2..a06b552 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -26,9 +26,9 @@ type content struct{} // ContentDetailResult is the internal detail result used by controllers. type ContentDetailResult struct { // Content is the content entity. - Content *models.Content + Content *models.Content // Price is the price settings (may be nil). - Price *models.ContentPrice + Price *models.ContentPrice // HasAccess indicates whether the user can access main assets. HasAccess bool } @@ -44,7 +44,7 @@ func (s *content) Create(ctx context.Context, tenantID, userID int64, form *dto. visibility = consts.ContentVisibilityTenantOnly } - previewSeconds := int32(60) + previewSeconds := consts.DefaultContentPreviewSeconds if form.PreviewSeconds != nil && *form.PreviewSeconds > 0 { previewSeconds = *form.PreviewSeconds } diff --git a/backend/database/migrations/20251217223000_media_contents.sql b/backend/database/migrations/20251217223000_media_contents.sql index fe1e327..08c8fe4 100644 --- a/backend/database/migrations/20251217223000_media_contents.sql +++ b/backend/database/migrations/20251217223000_media_contents.sql @@ -15,6 +15,21 @@ CREATE TABLE IF NOT EXISTS media_assets( updated_at timestamptz NOT NULL DEFAULT NOW() ); +-- media_assets:媒体资源表(视频/音频/图片),用于承载实际文件对象及其处理状态 +COMMENT ON TABLE media_assets IS '媒体资源:存储对象的抽象(video/audio/image),关联租户与上传用户,记录处理状态与元数据'; +COMMENT ON COLUMN media_assets.id IS '主键ID:自增;仅用于内部关联'; +COMMENT ON COLUMN media_assets.tenant_id IS '租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id'; +COMMENT ON COLUMN media_assets.user_id IS '用户ID:资源上传者;用于审计与权限控制'; +COMMENT ON COLUMN media_assets.type IS '资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等)'; +COMMENT ON COLUMN media_assets.status IS '处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供'; +COMMENT ON COLUMN media_assets.provider IS '存储提供方:例如 s3/minio/oss;便于多存储扩展'; +COMMENT ON COLUMN media_assets.bucket IS '存储桶:对象所在 bucket;与 provider 组合确定存储定位'; +COMMENT ON COLUMN media_assets.object_key IS '对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发)'; +COMMENT ON COLUMN media_assets.meta IS '元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控'; +COMMENT ON COLUMN media_assets.deleted_at IS '软删除时间:非空表示已删除;对外接口需过滤'; +COMMENT ON COLUMN media_assets.created_at IS '创建时间:默认 now();用于审计与排序'; +COMMENT ON COLUMN media_assets.updated_at IS '更新时间:默认 now();更新状态/元数据时写入'; + CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_id ON media_assets(tenant_id); CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_user_id ON media_assets(tenant_id, user_id); CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_status ON media_assets(tenant_id, status); @@ -35,6 +50,22 @@ CREATE TABLE IF NOT EXISTS contents( updated_at timestamptz NOT NULL DEFAULT NOW() ); +-- contents:内容表(可发布/可售卖/可试看) +COMMENT ON TABLE contents IS '内容:可发布的媒体内容实体,承载标题描述、可见性、试看配置、发布状态等'; +COMMENT ON COLUMN contents.id IS '主键ID:自增;用于内容引用'; +COMMENT ON COLUMN contents.tenant_id IS '租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id'; +COMMENT ON COLUMN contents.user_id IS '用户ID:内容创建者/发布者;用于权限与审计(例如私有内容仅作者可见)'; +COMMENT ON COLUMN contents.title IS '标题:用于列表展示与搜索;建议限制长度(由业务校验)'; +COMMENT ON COLUMN contents.description IS '描述:用于详情页展示;可为空字符串'; +COMMENT ON COLUMN contents.status IS '状态:draft/reviewing/published/unpublished/blocked;published 才对外展示'; +COMMENT ON COLUMN contents.visibility IS '可见性:public/tenant_only/private;仅控制详情可见,正片资源仍需按价格/权益校验'; +COMMENT ON COLUMN contents.preview_seconds IS '试看秒数:默认 60;只对 preview 资源生效;必须为正整数'; +COMMENT ON COLUMN contents.preview_downloadable IS '试看是否允许下载:默认 false;当前策略固定为不允许下载(仅 streaming)'; +COMMENT ON COLUMN contents.published_at IS '发布时间:首次发布时写入;用于时间窗与排序'; +COMMENT ON COLUMN contents.deleted_at IS '软删除时间:非空表示已删除;对外接口需过滤'; +COMMENT ON COLUMN contents.created_at IS '创建时间:默认 now();用于审计与排序'; +COMMENT ON COLUMN contents.updated_at IS '更新时间:默认 now();编辑内容时写入'; + CREATE INDEX IF NOT EXISTS ix_contents_tenant_id ON contents(tenant_id); CREATE INDEX IF NOT EXISTS ix_contents_tenant_user_id ON contents(tenant_id, user_id); CREATE INDEX IF NOT EXISTS ix_contents_tenant_status ON contents(tenant_id, status); @@ -53,6 +84,18 @@ CREATE TABLE IF NOT EXISTS content_assets( UNIQUE (tenant_id, content_id, asset_id) ); +-- content_assets:内容与媒体资源的关联(区分 main/cover/preview) +COMMENT ON TABLE content_assets IS '内容-资源关联:将 media_assets 以角色(main/cover/preview)绑定到 contents,支持排序'; +COMMENT ON COLUMN content_assets.id IS '主键ID:自增'; +COMMENT ON COLUMN content_assets.tenant_id IS '租户ID:多租户隔离;必须与 content_id、asset_id 所属租户一致'; +COMMENT ON COLUMN content_assets.user_id IS '用户ID:操作人/绑定人;用于审计(通常为租户管理员或作者)'; +COMMENT ON COLUMN content_assets.content_id IS '内容ID:关联 contents.id;用于查询内容下资源列表'; +COMMENT ON COLUMN content_assets.asset_id IS '资源ID:关联 media_assets.id;用于查询资源归属内容'; +COMMENT ON COLUMN content_assets.role IS '资源角色:main/cover/preview;preview 必须为独立资源以满足禁下载与防绕过'; +COMMENT ON COLUMN content_assets.sort IS '排序:同一 role 下的展示顺序,数值越小越靠前'; +COMMENT ON COLUMN content_assets.created_at IS '创建时间:默认 now();用于审计'; +COMMENT ON COLUMN content_assets.updated_at IS '更新时间:默认 now();更新 sort/role 时写入'; + CREATE INDEX IF NOT EXISTS ix_content_assets_tenant_content ON content_assets(tenant_id, content_id); CREATE INDEX IF NOT EXISTS ix_content_assets_tenant_asset ON content_assets(tenant_id, asset_id); CREATE INDEX IF NOT EXISTS ix_content_assets_tenant_role ON content_assets(tenant_id, content_id, role); @@ -73,6 +116,21 @@ CREATE TABLE IF NOT EXISTS content_prices( UNIQUE (tenant_id, content_id) ); +-- content_prices:内容定价与折扣(仅 CNY 分) +COMMENT ON TABLE content_prices IS '内容定价:为内容设置价格与折扣(订单需记录成交快照,避免改价影响历史)'; +COMMENT ON COLUMN content_prices.id IS '主键ID:自增'; +COMMENT ON COLUMN content_prices.tenant_id IS '租户ID:多租户隔离;与内容归属一致'; +COMMENT ON COLUMN content_prices.user_id IS '用户ID:设置/更新价格的操作人(通常为 tenant_admin);用于审计'; +COMMENT ON COLUMN content_prices.content_id IS '内容ID:唯一约束 (tenant_id, content_id);一个内容在一个租户内仅一份定价'; +COMMENT ON COLUMN content_prices.currency IS '币种:当前固定 CNY;金额单位为分'; +COMMENT ON COLUMN content_prices.price_amount IS '基础价格:分;0 表示免费(可直接访问正片资源)'; +COMMENT ON COLUMN content_prices.discount_type IS '折扣类型:none/percent/amount;仅影响下单时成交价,需写入订单快照'; +COMMENT ON COLUMN content_prices.discount_value IS '折扣值:percent=0-100(按业务校验);amount=分;none 时忽略'; +COMMENT ON COLUMN content_prices.discount_start_at IS '折扣开始时间:可为空;为空表示立即生效(由业务逻辑解释)'; +COMMENT ON COLUMN content_prices.discount_end_at IS '折扣结束时间:可为空;为空表示长期有效(由业务逻辑解释)'; +COMMENT ON COLUMN content_prices.created_at IS '创建时间:默认 now();用于审计'; +COMMENT ON COLUMN content_prices.updated_at IS '更新时间:默认 now();更新价格/折扣时写入'; + CREATE INDEX IF NOT EXISTS ix_content_prices_tenant_id ON content_prices(tenant_id); CREATE TABLE IF NOT EXISTS content_access( @@ -88,6 +146,18 @@ CREATE TABLE IF NOT EXISTS content_access( UNIQUE (tenant_id, user_id, content_id) ); +-- content_access:购买权益/访问权限(退款后撤销) +COMMENT ON TABLE content_access IS '内容权益:记录用户在租户内对内容的访问资格;退款后应立即 revoked'; +COMMENT ON COLUMN content_access.id IS '主键ID:自增'; +COMMENT ON COLUMN content_access.tenant_id IS '租户ID:多租户隔离;与内容、用户归属一致'; +COMMENT ON COLUMN content_access.user_id IS '用户ID:权益所属用户;用于访问校验'; +COMMENT ON COLUMN content_access.content_id IS '内容ID:权益对应内容;唯一约束 (tenant_id, user_id, content_id)'; +COMMENT ON COLUMN content_access.order_id IS '订单ID:产生该权益的订单;可为空(例如后台补发/迁移)'; +COMMENT ON COLUMN content_access.status IS '权益状态:active/revoked/expired;revoked 表示立即失效(例如退款/违规)'; +COMMENT ON COLUMN content_access.revoked_at IS '撤销时间:当 status=revoked 时写入;用于审计与追责'; +COMMENT ON COLUMN content_access.created_at IS '创建时间:默认 now();用于审计'; +COMMENT ON COLUMN content_access.updated_at IS '更新时间:默认 now();更新 status 时写入'; + CREATE INDEX IF NOT EXISTS ix_content_access_tenant_user ON content_access(tenant_id, user_id); CREATE INDEX IF NOT EXISTS ix_content_access_tenant_content ON content_access(tenant_id, content_id); @@ -118,4 +188,3 @@ DROP INDEX IF EXISTS ix_media_assets_tenant_id; DROP TABLE IF EXISTS media_assets; -- +goose StatementEnd - diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 149544b..e3f541c 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -56,6 +56,27 @@ const docTemplate = `{ } } }, + "/super/v1/auth/token": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Super" + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/dto.LoginResponse" + } + } + } + } + }, "/super/v1/tenants": { "get": { "consumes": [ @@ -71,16 +92,19 @@ const docTemplate = `{ "parameters": [ { "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", "name": "asc", "in": "query" }, { "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", "name": "desc", "in": "query" }, { "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", "name": "limit", "in": "query" }, @@ -91,11 +115,22 @@ const docTemplate = `{ }, { "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", "name": "page", "in": "query" }, { + "enum": [ + "pending_verify", + "verified", + "banned" + ], "type": "string", + "x-enum-varnames": [ + "TenantStatusPendingVerify", + "TenantStatusVerified", + "TenantStatusBanned" + ], "name": "status", "in": "query" } @@ -230,26 +265,40 @@ const docTemplate = `{ "parameters": [ { "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", "name": "asc", "in": "query" }, { "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", "name": "desc", "in": "query" }, { "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", "name": "limit", "in": "query" }, { "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", "name": "page", "in": "query" }, { + "enum": [ + "pending_verify", + "verified", + "banned" + ], "type": "string", + "x-enum-varnames": [ + "UserStatusPendingVerify", + "UserStatusVerified", + "UserStatusBanned" + ], "name": "status", "in": "query" }, @@ -369,9 +418,497 @@ const docTemplate = `{ ], "responses": {} } + }, + "/t/{tenantCode}/v1/admin/contents": { + "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.ContentCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Content" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/contents/{contentID}": { + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "更新内容(标题/描述/状态等)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContentUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Content" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/contents/{contentID}/assets": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "绑定媒体资源到内容(main/cover/preview)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContentAssetAttachForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.ContentAsset" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/contents/{contentID}/price": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "设置内容价格与折扣", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContentPriceUpsertForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.ContentPrice" + } + } + } + } + }, + "/t/{tenantCode}/v1/contents": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "内容列表(已发布)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Keyword filters by title keyword (LIKE).", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/dto.ContentItem" + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/contents/{contentID}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "内容详情(可见性+权益校验)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ContentDetail" + } + } + } + } + }, + "/t/{tenantCode}/v1/contents/{contentID}/assets": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "获取正片资源(main role,需要已购或免费)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ContentAssetsResponse" + } + } + } + } + }, + "/t/{tenantCode}/v1/contents/{contentID}/preview": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "获取试看资源(preview role)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ContentAssetsResponse" + } + } + } + } + }, + "/t/{tenantCode}/v1/me": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "当前租户上下文信息", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.MeResponse" + } + } + } + } } }, "definitions": { + "consts.ContentAssetRole": { + "type": "string", + "enum": [ + "main", + "cover", + "preview" + ], + "x-enum-varnames": [ + "ContentAssetRoleMain", + "ContentAssetRoleCover", + "ContentAssetRolePreview" + ] + }, + "consts.ContentStatus": { + "type": "string", + "enum": [ + "draft", + "reviewing", + "published", + "unpublished", + "blocked" + ], + "x-enum-varnames": [ + "ContentStatusDraft", + "ContentStatusReviewing", + "ContentStatusPublished", + "ContentStatusUnpublished", + "ContentStatusBlocked" + ] + }, + "consts.ContentVisibility": { + "type": "string", + "enum": [ + "public", + "tenant_only", + "private" + ], + "x-enum-varnames": [ + "ContentVisibilityPublic", + "ContentVisibilityTenantOnly", + "ContentVisibilityPrivate" + ] + }, + "consts.Currency": { + "type": "string", + "enum": [ + "CNY" + ], + "x-enum-varnames": [ + "CurrencyCNY" + ] + }, + "consts.DiscountType": { + "type": "string", + "enum": [ + "none", + "percent", + "amount" + ], + "x-enum-varnames": [ + "DiscountTypeNone", + "DiscountTypePercent", + "DiscountTypeAmount" + ] + }, + "consts.MediaAssetStatus": { + "type": "string", + "enum": [ + "uploaded", + "processing", + "ready", + "failed", + "deleted" + ], + "x-enum-varnames": [ + "MediaAssetStatusUploaded", + "MediaAssetStatusProcessing", + "MediaAssetStatusReady", + "MediaAssetStatusFailed", + "MediaAssetStatusDeleted" + ] + }, + "consts.MediaAssetType": { + "type": "string", + "enum": [ + "video", + "audio", + "image" + ], + "x-enum-varnames": [ + "MediaAssetTypeVideo", + "MediaAssetTypeAudio", + "MediaAssetTypeImage" + ] + }, "consts.Role": { "type": "string", "enum": [ @@ -396,6 +933,17 @@ const docTemplate = `{ "TenantStatusBanned" ] }, + "consts.TenantUserRole": { + "type": "string", + "enum": [ + "member", + "tenant_admin" + ], + "x-enum-varnames": [ + "TenantUserRoleMember", + "TenantUserRoleTenantAdmin" + ] + }, "consts.UserStatus": { "type": "string", "enum": [ @@ -409,6 +957,196 @@ const docTemplate = `{ "UserStatusBanned" ] }, + "dto.ContentAssetAttachForm": { + "type": "object", + "properties": { + "asset_id": { + "description": "AssetID is the media asset id to attach.", + "type": "integer" + }, + "role": { + "description": "Role indicates how this asset is used (main/cover/preview).", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentAssetRole" + } + ] + }, + "sort": { + "description": "Sort controls ordering under the same role.", + "type": "integer" + } + } + }, + "dto.ContentAssetsResponse": { + "type": "object", + "properties": { + "assets": { + "description": "Assets is the list of media assets for the requested role (preview/main).", + "type": "array", + "items": { + "$ref": "#/definitions/models.MediaAsset" + } + }, + "content": { + "description": "Content is the content entity.", + "allOf": [ + { + "$ref": "#/definitions/models.Content" + } + ] + }, + "preview_seconds": { + "description": "PreviewSeconds indicates the max preview duration (only meaningful for preview response).", + "type": "integer" + } + } + }, + "dto.ContentCreateForm": { + "type": "object", + "properties": { + "description": { + "description": "Description is the content description.", + "type": "string" + }, + "preview_seconds": { + "description": "PreviewSeconds controls preview duration (defaults to 60 when omitted).", + "type": "integer" + }, + "title": { + "description": "Title is the content title.", + "type": "string" + }, + "visibility": { + "description": "Visibility controls who can view the content detail (main assets still require free/purchase).", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentVisibility" + } + ] + } + } + }, + "dto.ContentDetail": { + "type": "object", + "properties": { + "content": { + "description": "Content is the content entity.", + "allOf": [ + { + "$ref": "#/definitions/models.Content" + } + ] + }, + "has_access": { + "description": "HasAccess indicates whether current user can access main assets (free/owner/purchased).", + "type": "boolean" + }, + "price": { + "description": "Price is the current price settings for the content (may be nil if not set).", + "allOf": [ + { + "$ref": "#/definitions/models.ContentPrice" + } + ] + } + } + }, + "dto.ContentItem": { + "type": "object", + "properties": { + "content": { + "description": "Content is the content entity.", + "allOf": [ + { + "$ref": "#/definitions/models.Content" + } + ] + }, + "has_access": { + "description": "HasAccess indicates whether current user can access main assets (free/owner/purchased).", + "type": "boolean" + }, + "price": { + "description": "Price is the current price settings for the content (may be nil if not set).", + "allOf": [ + { + "$ref": "#/definitions/models.ContentPrice" + } + ] + } + } + }, + "dto.ContentPriceUpsertForm": { + "type": "object", + "properties": { + "currency": { + "description": "Currency is fixed to CNY for now.", + "allOf": [ + { + "$ref": "#/definitions/consts.Currency" + } + ] + }, + "discount_end_at": { + "description": "DiscountEndAt disables discount after this time (optional).", + "type": "string" + }, + "discount_start_at": { + "description": "DiscountStartAt enables discount from this time (optional).", + "type": "string" + }, + "discount_type": { + "description": "DiscountType defines the discount algorithm (none/percent/amount).", + "allOf": [ + { + "$ref": "#/definitions/consts.DiscountType" + } + ] + }, + "discount_value": { + "description": "DiscountValue is interpreted based on DiscountType.", + "type": "integer" + }, + "price_amount": { + "description": "PriceAmount is the base price in cents (CNY 分).", + "type": "integer" + } + } + }, + "dto.ContentUpdateForm": { + "type": "object", + "properties": { + "description": { + "description": "Description updates the description when provided.", + "type": "string" + }, + "preview_seconds": { + "description": "PreviewSeconds updates preview duration when provided (must be \u003e 0).", + "type": "integer" + }, + "status": { + "description": "Status updates the content status when provided (e.g. publish/unpublish).", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentStatus" + } + ] + }, + "title": { + "description": "Title updates the title when provided.", + "type": "string" + }, + "visibility": { + "description": "Visibility updates the visibility when provided.", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentVisibility" + } + ] + } + } + }, "dto.LoginForm": { "type": "object", "properties": { @@ -428,6 +1166,35 @@ const docTemplate = `{ } } }, + "dto.MeResponse": { + "type": "object", + "properties": { + "tenant": { + "description": "Tenant is the resolved tenant by ` + "`" + `tenantCode` + "`" + `.", + "allOf": [ + { + "$ref": "#/definitions/models.Tenant" + } + ] + }, + "tenant_user": { + "description": "TenantUser is the membership record of the authenticated user within the tenant.", + "allOf": [ + { + "$ref": "#/definitions/models.TenantUser" + } + ] + }, + "user": { + "description": "User is the authenticated user derived from JWT ` + "`" + `user_id` + "`" + `.", + "allOf": [ + { + "$ref": "#/definitions/models.User" + } + ] + } + } + }, "dto.TenantExpireUpdateForm": { "type": "object", "required": [ @@ -616,6 +1383,167 @@ const docTemplate = `{ } } }, + "models.Content": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "deleted_at": { + "$ref": "#/definitions/gorm.DeletedAt" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "preview_downloadable": { + "type": "boolean" + }, + "preview_seconds": { + "type": "integer" + }, + "published_at": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/consts.ContentStatus" + }, + "tenant_id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "visibility": { + "$ref": "#/definitions/consts.ContentVisibility" + } + } + }, + "models.ContentAsset": { + "type": "object", + "properties": { + "asset_id": { + "type": "integer" + }, + "content_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "role": { + "$ref": "#/definitions/consts.ContentAssetRole" + }, + "sort": { + "type": "integer" + }, + "tenant_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.ContentPrice": { + "type": "object", + "properties": { + "content_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "currency": { + "$ref": "#/definitions/consts.Currency" + }, + "discount_end_at": { + "type": "string" + }, + "discount_start_at": { + "type": "string" + }, + "discount_type": { + "$ref": "#/definitions/consts.DiscountType" + }, + "discount_value": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "price_amount": { + "type": "integer" + }, + "tenant_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.MediaAsset": { + "type": "object", + "properties": { + "bucket": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deleted_at": { + "$ref": "#/definitions/gorm.DeletedAt" + }, + "id": { + "type": "integer" + }, + "meta": { + "type": "array", + "items": { + "type": "integer" + } + }, + "object_key": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/consts.MediaAssetStatus" + }, + "tenant_id": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/consts.MediaAssetType" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "models.Tenant": { "type": "object", "properties": { @@ -660,6 +1588,38 @@ const docTemplate = `{ } } }, + "models.TenantUser": { + "type": "object", + "properties": { + "balance": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "role": { + "type": "array", + "items": { + "$ref": "#/definitions/consts.TenantUserRole" + } + }, + "status": { + "$ref": "#/definitions/consts.UserStatus" + }, + "tenant_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "models.User": { "type": "object", "properties": { @@ -714,22 +1674,30 @@ const docTemplate = `{ "type": "object", "properties": { "key": { + "description": "Key is a machine-readable value, usually an enum string.", "type": "string" }, - "value": {} + "value": { + "description": "Value is the label payload, often a human-readable string." + } } }, "requests.Pager": { "type": "object", "properties": { - "items": {}, + "items": { + "description": "Items is the paged result list; concrete type depends on endpoint." + }, "limit": { + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", "type": "integer" }, "page": { + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", "type": "integer" }, "total": { + "description": "Total is the total number of items matching current filter (before paging).", "type": "integer" } } @@ -750,10 +1718,10 @@ const docTemplate = `{ var SwaggerInfo = &swag.Spec{ Version: "1.0", Host: "localhost:8080", - BasePath: "/t/{tenant_code}/v1", + BasePath: "/t/{tenantCode}/v1", Schemes: []string{}, Title: "ApiDoc", - Description: "This is a sample server celler server.", + Description: "Multi-tenant media platform backend API.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index b029048..916d800 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1,7 +1,7 @@ { "swagger": "2.0", "info": { - "description": "This is a sample server celler server.", + "description": "Multi-tenant media platform backend API.", "title": "ApiDoc", "termsOfService": "http://swagger.io/terms/", "contact": { @@ -16,7 +16,7 @@ "version": "1.0" }, "host": "localhost:8080", - "basePath": "/t/{tenant_code}/v1", + "basePath": "/t/{tenantCode}/v1", "paths": { "/super/v1/auth/login": { "post": { @@ -50,6 +50,27 @@ } } }, + "/super/v1/auth/token": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Super" + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/dto.LoginResponse" + } + } + } + } + }, "/super/v1/tenants": { "get": { "consumes": [ @@ -65,16 +86,19 @@ "parameters": [ { "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", "name": "asc", "in": "query" }, { "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", "name": "desc", "in": "query" }, { "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", "name": "limit", "in": "query" }, @@ -85,11 +109,22 @@ }, { "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", "name": "page", "in": "query" }, { + "enum": [ + "pending_verify", + "verified", + "banned" + ], "type": "string", + "x-enum-varnames": [ + "TenantStatusPendingVerify", + "TenantStatusVerified", + "TenantStatusBanned" + ], "name": "status", "in": "query" } @@ -224,26 +259,40 @@ "parameters": [ { "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", "name": "asc", "in": "query" }, { "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", "name": "desc", "in": "query" }, { "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", "name": "limit", "in": "query" }, { "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", "name": "page", "in": "query" }, { + "enum": [ + "pending_verify", + "verified", + "banned" + ], "type": "string", + "x-enum-varnames": [ + "UserStatusPendingVerify", + "UserStatusVerified", + "UserStatusBanned" + ], "name": "status", "in": "query" }, @@ -363,9 +412,497 @@ ], "responses": {} } + }, + "/t/{tenantCode}/v1/admin/contents": { + "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.ContentCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Content" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/contents/{contentID}": { + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "更新内容(标题/描述/状态等)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContentUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Content" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/contents/{contentID}/assets": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "绑定媒体资源到内容(main/cover/preview)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContentAssetAttachForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.ContentAsset" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/contents/{contentID}/price": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "设置内容价格与折扣", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContentPriceUpsertForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.ContentPrice" + } + } + } + } + }, + "/t/{tenantCode}/v1/contents": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "内容列表(已发布)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Keyword filters by title keyword (LIKE).", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/dto.ContentItem" + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/contents/{contentID}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "内容详情(可见性+权益校验)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ContentDetail" + } + } + } + } + }, + "/t/{tenantCode}/v1/contents/{contentID}/assets": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "获取正片资源(main role,需要已购或免费)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ContentAssetsResponse" + } + } + } + } + }, + "/t/{tenantCode}/v1/contents/{contentID}/preview": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "获取试看资源(preview role)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ContentAssetsResponse" + } + } + } + } + }, + "/t/{tenantCode}/v1/me": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "当前租户上下文信息", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.MeResponse" + } + } + } + } } }, "definitions": { + "consts.ContentAssetRole": { + "type": "string", + "enum": [ + "main", + "cover", + "preview" + ], + "x-enum-varnames": [ + "ContentAssetRoleMain", + "ContentAssetRoleCover", + "ContentAssetRolePreview" + ] + }, + "consts.ContentStatus": { + "type": "string", + "enum": [ + "draft", + "reviewing", + "published", + "unpublished", + "blocked" + ], + "x-enum-varnames": [ + "ContentStatusDraft", + "ContentStatusReviewing", + "ContentStatusPublished", + "ContentStatusUnpublished", + "ContentStatusBlocked" + ] + }, + "consts.ContentVisibility": { + "type": "string", + "enum": [ + "public", + "tenant_only", + "private" + ], + "x-enum-varnames": [ + "ContentVisibilityPublic", + "ContentVisibilityTenantOnly", + "ContentVisibilityPrivate" + ] + }, + "consts.Currency": { + "type": "string", + "enum": [ + "CNY" + ], + "x-enum-varnames": [ + "CurrencyCNY" + ] + }, + "consts.DiscountType": { + "type": "string", + "enum": [ + "none", + "percent", + "amount" + ], + "x-enum-varnames": [ + "DiscountTypeNone", + "DiscountTypePercent", + "DiscountTypeAmount" + ] + }, + "consts.MediaAssetStatus": { + "type": "string", + "enum": [ + "uploaded", + "processing", + "ready", + "failed", + "deleted" + ], + "x-enum-varnames": [ + "MediaAssetStatusUploaded", + "MediaAssetStatusProcessing", + "MediaAssetStatusReady", + "MediaAssetStatusFailed", + "MediaAssetStatusDeleted" + ] + }, + "consts.MediaAssetType": { + "type": "string", + "enum": [ + "video", + "audio", + "image" + ], + "x-enum-varnames": [ + "MediaAssetTypeVideo", + "MediaAssetTypeAudio", + "MediaAssetTypeImage" + ] + }, "consts.Role": { "type": "string", "enum": [ @@ -390,6 +927,17 @@ "TenantStatusBanned" ] }, + "consts.TenantUserRole": { + "type": "string", + "enum": [ + "member", + "tenant_admin" + ], + "x-enum-varnames": [ + "TenantUserRoleMember", + "TenantUserRoleTenantAdmin" + ] + }, "consts.UserStatus": { "type": "string", "enum": [ @@ -403,6 +951,196 @@ "UserStatusBanned" ] }, + "dto.ContentAssetAttachForm": { + "type": "object", + "properties": { + "asset_id": { + "description": "AssetID is the media asset id to attach.", + "type": "integer" + }, + "role": { + "description": "Role indicates how this asset is used (main/cover/preview).", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentAssetRole" + } + ] + }, + "sort": { + "description": "Sort controls ordering under the same role.", + "type": "integer" + } + } + }, + "dto.ContentAssetsResponse": { + "type": "object", + "properties": { + "assets": { + "description": "Assets is the list of media assets for the requested role (preview/main).", + "type": "array", + "items": { + "$ref": "#/definitions/models.MediaAsset" + } + }, + "content": { + "description": "Content is the content entity.", + "allOf": [ + { + "$ref": "#/definitions/models.Content" + } + ] + }, + "preview_seconds": { + "description": "PreviewSeconds indicates the max preview duration (only meaningful for preview response).", + "type": "integer" + } + } + }, + "dto.ContentCreateForm": { + "type": "object", + "properties": { + "description": { + "description": "Description is the content description.", + "type": "string" + }, + "preview_seconds": { + "description": "PreviewSeconds controls preview duration (defaults to 60 when omitted).", + "type": "integer" + }, + "title": { + "description": "Title is the content title.", + "type": "string" + }, + "visibility": { + "description": "Visibility controls who can view the content detail (main assets still require free/purchase).", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentVisibility" + } + ] + } + } + }, + "dto.ContentDetail": { + "type": "object", + "properties": { + "content": { + "description": "Content is the content entity.", + "allOf": [ + { + "$ref": "#/definitions/models.Content" + } + ] + }, + "has_access": { + "description": "HasAccess indicates whether current user can access main assets (free/owner/purchased).", + "type": "boolean" + }, + "price": { + "description": "Price is the current price settings for the content (may be nil if not set).", + "allOf": [ + { + "$ref": "#/definitions/models.ContentPrice" + } + ] + } + } + }, + "dto.ContentItem": { + "type": "object", + "properties": { + "content": { + "description": "Content is the content entity.", + "allOf": [ + { + "$ref": "#/definitions/models.Content" + } + ] + }, + "has_access": { + "description": "HasAccess indicates whether current user can access main assets (free/owner/purchased).", + "type": "boolean" + }, + "price": { + "description": "Price is the current price settings for the content (may be nil if not set).", + "allOf": [ + { + "$ref": "#/definitions/models.ContentPrice" + } + ] + } + } + }, + "dto.ContentPriceUpsertForm": { + "type": "object", + "properties": { + "currency": { + "description": "Currency is fixed to CNY for now.", + "allOf": [ + { + "$ref": "#/definitions/consts.Currency" + } + ] + }, + "discount_end_at": { + "description": "DiscountEndAt disables discount after this time (optional).", + "type": "string" + }, + "discount_start_at": { + "description": "DiscountStartAt enables discount from this time (optional).", + "type": "string" + }, + "discount_type": { + "description": "DiscountType defines the discount algorithm (none/percent/amount).", + "allOf": [ + { + "$ref": "#/definitions/consts.DiscountType" + } + ] + }, + "discount_value": { + "description": "DiscountValue is interpreted based on DiscountType.", + "type": "integer" + }, + "price_amount": { + "description": "PriceAmount is the base price in cents (CNY 分).", + "type": "integer" + } + } + }, + "dto.ContentUpdateForm": { + "type": "object", + "properties": { + "description": { + "description": "Description updates the description when provided.", + "type": "string" + }, + "preview_seconds": { + "description": "PreviewSeconds updates preview duration when provided (must be \u003e 0).", + "type": "integer" + }, + "status": { + "description": "Status updates the content status when provided (e.g. publish/unpublish).", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentStatus" + } + ] + }, + "title": { + "description": "Title updates the title when provided.", + "type": "string" + }, + "visibility": { + "description": "Visibility updates the visibility when provided.", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentVisibility" + } + ] + } + } + }, "dto.LoginForm": { "type": "object", "properties": { @@ -422,6 +1160,35 @@ } } }, + "dto.MeResponse": { + "type": "object", + "properties": { + "tenant": { + "description": "Tenant is the resolved tenant by `tenantCode`.", + "allOf": [ + { + "$ref": "#/definitions/models.Tenant" + } + ] + }, + "tenant_user": { + "description": "TenantUser is the membership record of the authenticated user within the tenant.", + "allOf": [ + { + "$ref": "#/definitions/models.TenantUser" + } + ] + }, + "user": { + "description": "User is the authenticated user derived from JWT `user_id`.", + "allOf": [ + { + "$ref": "#/definitions/models.User" + } + ] + } + } + }, "dto.TenantExpireUpdateForm": { "type": "object", "required": [ @@ -610,6 +1377,167 @@ } } }, + "models.Content": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "deleted_at": { + "$ref": "#/definitions/gorm.DeletedAt" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "preview_downloadable": { + "type": "boolean" + }, + "preview_seconds": { + "type": "integer" + }, + "published_at": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/consts.ContentStatus" + }, + "tenant_id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "visibility": { + "$ref": "#/definitions/consts.ContentVisibility" + } + } + }, + "models.ContentAsset": { + "type": "object", + "properties": { + "asset_id": { + "type": "integer" + }, + "content_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "role": { + "$ref": "#/definitions/consts.ContentAssetRole" + }, + "sort": { + "type": "integer" + }, + "tenant_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.ContentPrice": { + "type": "object", + "properties": { + "content_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "currency": { + "$ref": "#/definitions/consts.Currency" + }, + "discount_end_at": { + "type": "string" + }, + "discount_start_at": { + "type": "string" + }, + "discount_type": { + "$ref": "#/definitions/consts.DiscountType" + }, + "discount_value": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "price_amount": { + "type": "integer" + }, + "tenant_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.MediaAsset": { + "type": "object", + "properties": { + "bucket": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deleted_at": { + "$ref": "#/definitions/gorm.DeletedAt" + }, + "id": { + "type": "integer" + }, + "meta": { + "type": "array", + "items": { + "type": "integer" + } + }, + "object_key": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/consts.MediaAssetStatus" + }, + "tenant_id": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/consts.MediaAssetType" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "models.Tenant": { "type": "object", "properties": { @@ -654,6 +1582,38 @@ } } }, + "models.TenantUser": { + "type": "object", + "properties": { + "balance": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "role": { + "type": "array", + "items": { + "$ref": "#/definitions/consts.TenantUserRole" + } + }, + "status": { + "$ref": "#/definitions/consts.UserStatus" + }, + "tenant_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "models.User": { "type": "object", "properties": { @@ -708,22 +1668,30 @@ "type": "object", "properties": { "key": { + "description": "Key is a machine-readable value, usually an enum string.", "type": "string" }, - "value": {} + "value": { + "description": "Value is the label payload, often a human-readable string." + } } }, "requests.Pager": { "type": "object", "properties": { - "items": {}, + "items": { + "description": "Items is the paged result list; concrete type depends on endpoint." + }, "limit": { + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", "type": "integer" }, "page": { + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", "type": "integer" }, "total": { + "description": "Total is the total number of items matching current filter (before paging).", "type": "integer" } } diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 739ff03..7cbbbdd 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,5 +1,79 @@ -basePath: /t/{tenant_code}/v1 +basePath: /t/{tenantCode}/v1 definitions: + consts.ContentAssetRole: + enum: + - main + - cover + - preview + type: string + x-enum-varnames: + - ContentAssetRoleMain + - ContentAssetRoleCover + - ContentAssetRolePreview + consts.ContentStatus: + enum: + - draft + - reviewing + - published + - unpublished + - blocked + type: string + x-enum-varnames: + - ContentStatusDraft + - ContentStatusReviewing + - ContentStatusPublished + - ContentStatusUnpublished + - ContentStatusBlocked + consts.ContentVisibility: + enum: + - public + - tenant_only + - private + type: string + x-enum-varnames: + - ContentVisibilityPublic + - ContentVisibilityTenantOnly + - ContentVisibilityPrivate + consts.Currency: + enum: + - CNY + type: string + x-enum-varnames: + - CurrencyCNY + consts.DiscountType: + enum: + - none + - percent + - amount + type: string + x-enum-varnames: + - DiscountTypeNone + - DiscountTypePercent + - DiscountTypeAmount + consts.MediaAssetStatus: + enum: + - uploaded + - processing + - ready + - failed + - deleted + type: string + x-enum-varnames: + - MediaAssetStatusUploaded + - MediaAssetStatusProcessing + - MediaAssetStatusReady + - MediaAssetStatusFailed + - MediaAssetStatusDeleted + consts.MediaAssetType: + enum: + - video + - audio + - image + type: string + x-enum-varnames: + - MediaAssetTypeVideo + - MediaAssetTypeAudio + - MediaAssetTypeImage consts.Role: enum: - user @@ -18,6 +92,14 @@ definitions: - TenantStatusPendingVerify - TenantStatusVerified - TenantStatusBanned + consts.TenantUserRole: + enum: + - member + - tenant_admin + type: string + x-enum-varnames: + - TenantUserRoleMember + - TenantUserRoleTenantAdmin consts.UserStatus: enum: - pending_verify @@ -28,6 +110,129 @@ definitions: - UserStatusPendingVerify - UserStatusVerified - UserStatusBanned + dto.ContentAssetAttachForm: + properties: + asset_id: + description: AssetID is the media asset id to attach. + type: integer + role: + allOf: + - $ref: '#/definitions/consts.ContentAssetRole' + description: Role indicates how this asset is used (main/cover/preview). + sort: + description: Sort controls ordering under the same role. + type: integer + type: object + dto.ContentAssetsResponse: + properties: + assets: + description: Assets is the list of media assets for the requested role (preview/main). + items: + $ref: '#/definitions/models.MediaAsset' + type: array + content: + allOf: + - $ref: '#/definitions/models.Content' + description: Content is the content entity. + preview_seconds: + description: PreviewSeconds indicates the max preview duration (only meaningful + for preview response). + type: integer + type: object + dto.ContentCreateForm: + properties: + description: + description: Description is the content description. + type: string + preview_seconds: + description: PreviewSeconds controls preview duration (defaults to 60 when + omitted). + type: integer + title: + description: Title is the content title. + type: string + visibility: + allOf: + - $ref: '#/definitions/consts.ContentVisibility' + description: Visibility controls who can view the content detail (main assets + still require free/purchase). + type: object + dto.ContentDetail: + properties: + content: + allOf: + - $ref: '#/definitions/models.Content' + description: Content is the content entity. + has_access: + description: HasAccess indicates whether current user can access main assets + (free/owner/purchased). + type: boolean + price: + allOf: + - $ref: '#/definitions/models.ContentPrice' + description: Price is the current price settings for the content (may be nil + if not set). + type: object + dto.ContentItem: + properties: + content: + allOf: + - $ref: '#/definitions/models.Content' + description: Content is the content entity. + has_access: + description: HasAccess indicates whether current user can access main assets + (free/owner/purchased). + type: boolean + price: + allOf: + - $ref: '#/definitions/models.ContentPrice' + description: Price is the current price settings for the content (may be nil + if not set). + type: object + dto.ContentPriceUpsertForm: + properties: + currency: + allOf: + - $ref: '#/definitions/consts.Currency' + description: Currency is fixed to CNY for now. + discount_end_at: + description: DiscountEndAt disables discount after this time (optional). + type: string + discount_start_at: + description: DiscountStartAt enables discount from this time (optional). + type: string + discount_type: + allOf: + - $ref: '#/definitions/consts.DiscountType' + description: DiscountType defines the discount algorithm (none/percent/amount). + discount_value: + description: DiscountValue is interpreted based on DiscountType. + type: integer + price_amount: + description: PriceAmount is the base price in cents (CNY 分). + type: integer + type: object + dto.ContentUpdateForm: + properties: + description: + description: Description updates the description when provided. + type: string + preview_seconds: + description: PreviewSeconds updates preview duration when provided (must be + > 0). + type: integer + status: + allOf: + - $ref: '#/definitions/consts.ContentStatus' + description: Status updates the content status when provided (e.g. publish/unpublish). + title: + description: Title updates the title when provided. + type: string + visibility: + allOf: + - $ref: '#/definitions/consts.ContentVisibility' + description: Visibility updates the visibility when provided. + type: object dto.LoginForm: properties: password: @@ -40,6 +245,22 @@ definitions: token: type: string type: object + dto.MeResponse: + properties: + tenant: + allOf: + - $ref: '#/definitions/models.Tenant' + description: Tenant is the resolved tenant by `tenantCode`. + tenant_user: + allOf: + - $ref: '#/definitions/models.TenantUser' + description: TenantUser is the membership record of the authenticated user + within the tenant. + user: + allOf: + - $ref: '#/definitions/models.User' + description: User is the authenticated user derived from JWT `user_id`. + type: object dto.TenantExpireUpdateForm: properties: duration: @@ -162,6 +383,112 @@ definitions: description: Valid is true if Time is not NULL type: boolean type: object + models.Content: + properties: + created_at: + type: string + deleted_at: + $ref: '#/definitions/gorm.DeletedAt' + description: + type: string + id: + type: integer + preview_downloadable: + type: boolean + preview_seconds: + type: integer + published_at: + type: string + status: + $ref: '#/definitions/consts.ContentStatus' + tenant_id: + type: integer + title: + type: string + updated_at: + type: string + user_id: + type: integer + visibility: + $ref: '#/definitions/consts.ContentVisibility' + type: object + models.ContentAsset: + properties: + asset_id: + type: integer + content_id: + type: integer + created_at: + type: string + id: + type: integer + role: + $ref: '#/definitions/consts.ContentAssetRole' + sort: + type: integer + tenant_id: + type: integer + updated_at: + type: string + user_id: + type: integer + type: object + models.ContentPrice: + properties: + content_id: + type: integer + created_at: + type: string + currency: + $ref: '#/definitions/consts.Currency' + discount_end_at: + type: string + discount_start_at: + type: string + discount_type: + $ref: '#/definitions/consts.DiscountType' + discount_value: + type: integer + id: + type: integer + price_amount: + type: integer + tenant_id: + type: integer + updated_at: + type: string + user_id: + type: integer + type: object + models.MediaAsset: + properties: + bucket: + type: string + created_at: + type: string + deleted_at: + $ref: '#/definitions/gorm.DeletedAt' + id: + type: integer + meta: + items: + type: integer + type: array + object_key: + type: string + provider: + type: string + status: + $ref: '#/definitions/consts.MediaAssetStatus' + tenant_id: + type: integer + type: + $ref: '#/definitions/consts.MediaAssetType' + updated_at: + type: string + user_id: + type: integer + type: object models.Tenant: properties: code: @@ -191,6 +518,27 @@ definitions: uuid: type: string type: object + models.TenantUser: + properties: + balance: + type: integer + created_at: + type: string + id: + type: integer + role: + items: + $ref: '#/definitions/consts.TenantUserRole' + type: array + status: + $ref: '#/definitions/consts.UserStatus' + tenant_id: + type: integer + updated_at: + type: string + user_id: + type: integer + type: object models.User: properties: created_at: @@ -227,17 +575,25 @@ definitions: requests.KV: properties: key: + description: Key is a machine-readable value, usually an enum string. type: string - value: {} + value: + description: Value is the label payload, often a human-readable string. type: object requests.Pager: properties: - items: {} + items: + description: Items is the paged result list; concrete type depends on endpoint. limit: + description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). type: integer page: + description: Page is 1-based page index; values <= 0 are normalized to 1. type: integer total: + description: Total is the total number of items matching current filter (before + paging). type: integer type: object externalDocs: @@ -249,7 +605,7 @@ info: email: support@swagger.io name: UserName url: http://www.swagger.io/support - description: This is a sample server celler server. + description: Multi-tenant media platform backend API. license: name: Apache 2.0 url: http://www.apache.org/licenses/LICENSE-2.0.html @@ -277,29 +633,56 @@ paths: $ref: '#/definitions/dto.LoginResponse' tags: - Super + /super/v1/auth/token: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/dto.LoginResponse' + tags: + - Super /super/v1/tenants: get: consumes: - application/json parameters: - - in: query + - description: Asc specifies comma-separated field names to sort ascending by. + in: query name: asc type: string - - in: query + - description: Desc specifies comma-separated field names to sort descending + by. + in: query name: desc type: string - - in: query + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query name: limit type: integer - in: query name: name type: string - - in: query + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query name: page type: integer - - in: query + - enum: + - pending_verify + - verified + - banned + in: query name: status type: string + x-enum-varnames: + - TenantStatusPendingVerify + - TenantStatusVerified + - TenantStatusBanned produces: - application/json responses: @@ -382,21 +765,35 @@ paths: consumes: - application/json parameters: - - in: query + - description: Asc specifies comma-separated field names to sort ascending by. + in: query name: asc type: string - - in: query + - description: Desc specifies comma-separated field names to sort descending + by. + in: query name: desc type: string - - in: query + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query name: limit type: integer - - in: query + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query name: page type: integer - - in: query + - enum: + - pending_verify + - verified + - banned + in: query name: status type: string + x-enum-varnames: + - UserStatusPendingVerify + - UserStatusVerified + - UserStatusBanned - in: query name: tenantID type: integer @@ -473,6 +870,264 @@ paths: summary: 用户状态列表 tags: - Super + /t/{tenantCode}/v1/admin/contents: + 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.ContentCreateForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Content' + summary: 创建内容(草稿) + tags: + - Tenant + /t/{tenantCode}/v1/admin/contents/{contentID}: + patch: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: ContentID + format: int64 + in: path + name: contentID + required: true + type: integer + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.ContentUpdateForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Content' + summary: 更新内容(标题/描述/状态等) + tags: + - Tenant + /t/{tenantCode}/v1/admin/contents/{contentID}/assets: + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: ContentID + format: int64 + in: path + name: contentID + required: true + type: integer + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.ContentAssetAttachForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.ContentAsset' + summary: 绑定媒体资源到内容(main/cover/preview) + tags: + - Tenant + /t/{tenantCode}/v1/admin/contents/{contentID}/price: + put: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: ContentID + format: int64 + in: path + name: contentID + required: true + type: integer + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.ContentPriceUpsertForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.ContentPrice' + summary: 设置内容价格与折扣 + tags: + - Tenant + /t/{tenantCode}/v1/contents: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Keyword filters by title keyword (LIKE). + in: query + name: keyword + type: string + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query + name: limit + type: integer + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query + name: page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + $ref: '#/definitions/dto.ContentItem' + type: object + summary: 内容列表(已发布) + tags: + - Tenant + /t/{tenantCode}/v1/contents/{contentID}: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: ContentID + format: int64 + in: path + name: contentID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.ContentDetail' + summary: 内容详情(可见性+权益校验) + tags: + - Tenant + /t/{tenantCode}/v1/contents/{contentID}/assets: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: ContentID + format: int64 + in: path + name: contentID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.ContentAssetsResponse' + summary: 获取正片资源(main role,需要已购或免费) + tags: + - Tenant + /t/{tenantCode}/v1/contents/{contentID}/preview: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: ContentID + format: int64 + in: path + name: contentID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.ContentAssetsResponse' + summary: 获取试看资源(preview role) + tags: + - Tenant + /t/{tenantCode}/v1/me: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.MeResponse' + summary: 当前租户上下文信息 + tags: + - Tenant securityDefinitions: BasicAuth: type: basic diff --git a/backend/go.mod b/backend/go.mod index f18ddad..ffba6d1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -39,6 +39,8 @@ require ( gorm.io/plugin/dbresolver v1.6.2 ) +replace google.golang.org/genproto/googleapis/rpc => google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 + require ( github.com/IBM/sarama v1.46.3 // indirect github.com/KyleBanks/depth v1.2.1 // indirect @@ -128,7 +130,6 @@ require ( golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.40.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/hints v1.1.0 // indirect ) diff --git a/backend/main.go b/backend/main.go index ac835d1..5583468 100644 --- a/backend/main.go +++ b/backend/main.go @@ -11,7 +11,7 @@ import ( // @title ApiDoc // @version 1.0 -// @description This is a sample server celler server. +// @description Multi-tenant media platform backend API. // @termsOfService http://swagger.io/terms/ // @contact.name UserName // @contact.url http://www.swagger.io/support @@ -19,7 +19,7 @@ import ( // @license.name Apache 2.0 // @license.url http://www.apache.org/licenses/LICENSE-2.0.html // @host localhost:8080 -// @BasePath /t/{tenant_code}/v1 +// @BasePath / // @securityDefinitions.basic BasicAuth // @externalDocs.description OpenAPI // @externalDocs.url https://swagger.io/resources/open-api/ diff --git a/backend/pkg/consts/consts.go b/backend/pkg/consts/consts.go index 6f50269..7ef226c 100644 --- a/backend/pkg/consts/consts.go +++ b/backend/pkg/consts/consts.go @@ -77,6 +77,12 @@ type ContentVisibility string // ENUM( main, cover, preview ) type ContentAssetRole string +const ( + // DefaultContentPreviewSeconds is the default preview duration in seconds when content.preview_seconds is unset/invalid. + // 默认试看时长(秒):当未配置或传入非法值时使用。 + DefaultContentPreviewSeconds int32 = 60 +) + // content_prices // swagger:enum DiscountType