diff --git a/backend/app/commands/http/http.go b/backend/app/commands/http/http.go index f235401..ace0555 100644 --- a/backend/app/commands/http/http.go +++ b/backend/app/commands/http/http.go @@ -8,6 +8,8 @@ import ( "quyun/v2/app/errorx" "quyun/v2/app/http/super" "quyun/v2/app/http/tenant" + "quyun/v2/app/http/tenant_join" + "quyun/v2/app/http/tenant_public" "quyun/v2/app/jobs" "quyun/v2/app/middlewares" "quyun/v2/app/services" @@ -53,6 +55,8 @@ func Command() atom.Option { middlewares.Provide, super.Provide, tenant.Provide, + tenant_join.Provide, + tenant_public.Provide, // {Provider: api.Provide}, // {Provider: web.Provide}, ), diff --git a/backend/app/http/tenantjoin/dto/join.go b/backend/app/http/tenant_join/dto/join.go similarity index 100% rename from backend/app/http/tenantjoin/dto/join.go rename to backend/app/http/tenant_join/dto/join.go diff --git a/backend/app/http/tenantjoin/join.go b/backend/app/http/tenant_join/join.go similarity index 95% rename from backend/app/http/tenantjoin/join.go rename to backend/app/http/tenant_join/join.go index 8621f56..27c98b4 100644 --- a/backend/app/http/tenantjoin/join.go +++ b/backend/app/http/tenant_join/join.go @@ -1,8 +1,8 @@ -package tenantjoin +package tenant_join import ( "quyun/v2/app/errorx" - "quyun/v2/app/http/tenantjoin/dto" + "quyun/v2/app/http/tenant_join/dto" "quyun/v2/app/services" "quyun/v2/database/models" "quyun/v2/providers/jwt" @@ -81,7 +81,7 @@ func (*join) createJoinRequest( log.WithFields(log.Fields{ "tenant_id": tenant.ID, "user_id": claims.UserID, - }).Info("tenantjoin.create_join_request") + }).Info("tenant_join.create_join_request") return services.Tenant.CreateJoinRequest(ctx.Context(), tenant.ID, claims.UserID, form) } diff --git a/backend/app/http/tenantjoin/provider.gen.go b/backend/app/http/tenant_join/provider.gen.go similarity index 97% rename from backend/app/http/tenantjoin/provider.gen.go rename to backend/app/http/tenant_join/provider.gen.go index e5231ea..0f3c8ec 100755 --- a/backend/app/http/tenantjoin/provider.gen.go +++ b/backend/app/http/tenant_join/provider.gen.go @@ -1,4 +1,4 @@ -package tenantjoin +package tenant_join import ( "quyun/v2/app/middlewares" diff --git a/backend/app/http/tenantjoin/routes.gen.go b/backend/app/http/tenant_join/routes.gen.go similarity index 87% rename from backend/app/http/tenantjoin/routes.gen.go rename to backend/app/http/tenant_join/routes.gen.go index b81a93f..45e6396 100644 --- a/backend/app/http/tenantjoin/routes.gen.go +++ b/backend/app/http/tenant_join/routes.gen.go @@ -1,11 +1,11 @@ // Code generated by atomctl. DO NOT EDIT. -// Package tenantjoin provides HTTP route definitions and registration +// Package tenant_join provides HTTP route definitions and registration // for the quyun/v2 application. -package tenantjoin +package tenant_join import ( - "quyun/v2/app/http/tenantjoin/dto" + "quyun/v2/app/http/tenant_join/dto" "quyun/v2/app/middlewares" "quyun/v2/database/models" "quyun/v2/providers/jwt" @@ -18,7 +18,7 @@ import ( ) // Routes implements the HttpRoute contract and provides route registration -// for all controllers in the tenantjoin module. +// for all controllers in the tenant_join module. // // @provider contracts.HttpRoute atom.GroupRoutes type Routes struct { @@ -30,14 +30,14 @@ type Routes struct { // Prepare initializes the routes provider with logging configuration. func (r *Routes) Prepare() error { - r.log = log.WithField("module", "routes.tenantjoin") + r.log = log.WithField("module", "routes.tenant_join") r.log.Info("Initializing routes module") return nil } // Name returns the unique identifier for this routes provider. func (r *Routes) Name() string { - return "tenantjoin" + return "tenant_join" } // Register registers all HTTP routes with the provided fiber router. diff --git a/backend/app/http/tenantjoin/routes.manual.go b/backend/app/http/tenant_join/routes.manual.go similarity index 90% rename from backend/app/http/tenantjoin/routes.manual.go rename to backend/app/http/tenant_join/routes.manual.go index 13ef92b..1bab786 100644 --- a/backend/app/http/tenantjoin/routes.manual.go +++ b/backend/app/http/tenant_join/routes.manual.go @@ -1,4 +1,4 @@ -package tenantjoin +package tenant_join func (r *Routes) Path() string { return "/t/:tenantCode/v1" diff --git a/backend/app/http/tenant_public/content.go b/backend/app/http/tenant_public/content.go new file mode 100644 index 0000000..ea94342 --- /dev/null +++ b/backend/app/http/tenant_public/content.go @@ -0,0 +1,181 @@ +package tenant_public + +import ( + "quyun/v2/app/errorx" + tenant_dto "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + "quyun/v2/providers/jwt" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" +) + +// content 提供“租户维度的公开只读接口”(不要求租户成员)。 +// +// @provider +type content struct{} + +func viewerUserID(ctx fiber.Ctx) int64 { + claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) + if !ok || claims == nil { + return 0 + } + return claims.UserID +} + +// list +// +// @Summary 公开内容列表(已发布 + public) +// @Tags TenantPublic +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param filter query tenant_dto.ContentListFilter true "Filter" +// @Success 200 {object} requests.Pager{items=tenant_dto.ContentItem} +// +// @Router /t/:tenantCode/v1/public/contents [get] +// @Bind tenant local key(tenant) +// @Bind filter query +func (*content) list( + ctx fiber.Ctx, + tenant *models.Tenant, + filter *tenant_dto.ContentListFilter, +) (*requests.Pager, error) { + uid := viewerUserID(ctx) + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": uid, + }).Info("tenant_public.contents.list") + + if filter == nil { + filter = &tenant_dto.ContentListFilter{} + } + filter.Pagination.Format() + return services.Content.ListPublicPublished(ctx, tenant.ID, uid, filter) +} + +// show +// +// @Summary 公开内容详情(已发布 + public) +// @Tags TenantPublic +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param contentID path int64 true "ContentID" +// @Success 200 {object} tenant_dto.ContentDetail +// +// @Router /t/:tenantCode/v1/public/contents/:contentID [get] +// @Bind tenant local key(tenant) +// @Bind contentID path +func (*content) show(ctx fiber.Ctx, tenant *models.Tenant, contentID int64) (*tenant_dto.ContentDetail, error) { + uid := viewerUserID(ctx) + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": uid, + "content_id": contentID, + }).Info("tenant_public.contents.show") + + item, err := services.Content.PublicDetail(ctx, tenant.ID, uid, contentID) + if err != nil { + return nil, err + } + return &tenant_dto.ContentDetail{ + Content: item.Content, + Price: item.Price, + HasAccess: item.HasAccess, + }, nil +} + +// previewAssets +// +// @Summary 获取公开试看资源(preview role) +// @Tags TenantPublic +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param contentID path int64 true "ContentID" +// @Success 200 {object} tenant_dto.ContentAssetsResponse +// +// @Router /t/:tenantCode/v1/public/contents/:contentID/preview [get] +// @Bind tenant local key(tenant) +// @Bind contentID path +func (*content) previewAssets( + ctx fiber.Ctx, + tenant *models.Tenant, + contentID int64, +) (*tenant_dto.ContentAssetsResponse, error) { + uid := viewerUserID(ctx) + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": uid, + "content_id": contentID, + }).Info("tenant_public.contents.preview_assets") + + detail, err := services.Content.PublicDetail(ctx, tenant.ID, uid, contentID) + if err != nil { + return nil, err + } + + assets, err := services.Content.AssetsByRole(ctx, tenant.ID, contentID, consts.ContentAssetRolePreview) + if err != nil { + return nil, err + } + + previewSeconds := int32(detail.Content.PreviewSeconds) + if previewSeconds <= 0 { + previewSeconds = consts.DefaultContentPreviewSeconds + } + + return &tenant_dto.ContentAssetsResponse{ + Content: detail.Content, + Assets: assets, + PreviewSeconds: previewSeconds, + }, nil +} + +// mainAssets +// +// @Summary 获取公开正片资源(main role;免费/作者/已购) +// @Tags TenantPublic +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param contentID path int64 true "ContentID" +// @Success 200 {object} tenant_dto.ContentAssetsResponse +// +// @Router /t/:tenantCode/v1/public/contents/:contentID/assets [get] +// @Bind tenant local key(tenant) +// @Bind contentID path +func (*content) mainAssets( + ctx fiber.Ctx, + tenant *models.Tenant, + contentID int64, +) (*tenant_dto.ContentAssetsResponse, error) { + uid := viewerUserID(ctx) + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": uid, + "content_id": contentID, + }).Info("tenantpublic.contents.main_assets") + + detail, err := services.Content.PublicDetail(ctx, tenant.ID, uid, contentID) + if err != nil { + return nil, err + } + if !detail.HasAccess { + return nil, errorx.ErrPermissionDenied.WithMsg("未购买或无权限访问") + } + + assets, err := services.Content.AssetsByRole(ctx, tenant.ID, contentID, consts.ContentAssetRoleMain) + if err != nil { + return nil, err + } + + return &tenant_dto.ContentAssetsResponse{ + Content: detail.Content, + Assets: assets, + }, nil +} diff --git a/backend/app/http/tenant_public/provider.gen.go b/backend/app/http/tenant_public/provider.gen.go new file mode 100755 index 0000000..e18db87 --- /dev/null +++ b/backend/app/http/tenant_public/provider.gen.go @@ -0,0 +1,37 @@ +package tenant_public + +import ( + "quyun/v2/app/middlewares" + + "go.ipao.vip/atom" + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/contracts" + "go.ipao.vip/atom/opt" +) + +func Provide(opts ...opt.Option) error { + if err := container.Container.Provide(func() (*content, error) { + obj := &content{} + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func( + content *content, + middlewares *middlewares.Middlewares, + ) (contracts.HttpRoute, error) { + obj := &Routes{ + content: content, + middlewares: middlewares, + } + if err := obj.Prepare(); err != nil { + return nil, err + } + + return obj, nil + }, atom.GroupRoutes); err != nil { + return err + } + return nil +} diff --git a/backend/app/http/tenant_public/routes.gen.go b/backend/app/http/tenant_public/routes.gen.go new file mode 100644 index 0000000..6f869d4 --- /dev/null +++ b/backend/app/http/tenant_public/routes.gen.go @@ -0,0 +1,72 @@ +// Code generated by atomctl. DO NOT EDIT. + +// Package tenant_public provides HTTP route definitions and registration +// for the quyun/v2 application. +package tenant_public + +import ( + tenant_dto "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/middlewares" + "quyun/v2/database/models" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + _ "go.ipao.vip/atom/contracts" + . "go.ipao.vip/atom/fen" +) + +// Routes implements the HttpRoute contract and provides route registration +// for all controllers in the tenant_public module. +// +// @provider contracts.HttpRoute atom.GroupRoutes +type Routes struct { + log *log.Entry `inject:"false"` + middlewares *middlewares.Middlewares + // Controller instances + content *content +} + +// Prepare initializes the routes provider with logging configuration. +func (r *Routes) Prepare() error { + r.log = log.WithField("module", "routes.tenant_public") + r.log.Info("Initializing routes module") + return nil +} + +// Name returns the unique identifier for this routes provider. +func (r *Routes) Name() string { + return "tenant_public" +} + +// Register registers all HTTP routes with the provided fiber router. +// 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/:tenantCode/v1/public/contents -> content.list") + router.Get("/t/:tenantCode/v1/public/contents"[len(r.Path()):], DataFunc2( + r.content.list, + Local[*models.Tenant]("tenant"), + Query[tenant_dto.ContentListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/public/contents/:contentID -> content.show") + router.Get("/t/:tenantCode/v1/public/contents/:contentID"[len(r.Path()):], DataFunc2( + r.content.show, + Local[*models.Tenant]("tenant"), + PathParam[int64]("contentID"), + )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/public/contents/:contentID/assets -> content.mainAssets") + router.Get("/t/:tenantCode/v1/public/contents/:contentID/assets"[len(r.Path()):], DataFunc2( + r.content.mainAssets, + Local[*models.Tenant]("tenant"), + PathParam[int64]("contentID"), + )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/public/contents/:contentID/preview -> content.previewAssets") + router.Get("/t/:tenantCode/v1/public/contents/:contentID/preview"[len(r.Path()):], DataFunc2( + r.content.previewAssets, + Local[*models.Tenant]("tenant"), + PathParam[int64]("contentID"), + )) + + r.log.Info("Successfully registered all routes") +} diff --git a/backend/app/http/tenant_public/routes.manual.go b/backend/app/http/tenant_public/routes.manual.go new file mode 100644 index 0000000..d3d3647 --- /dev/null +++ b/backend/app/http/tenant_public/routes.manual.go @@ -0,0 +1,12 @@ +package tenant_public + +func (r *Routes) Path() string { + return "/t/:tenantCode/v1" +} + +func (r *Routes) Middlewares() []any { + return []any{ + r.middlewares.TenantResolve, + r.middlewares.TenantOptionalAuth, + } +} diff --git a/backend/app/middlewares/tenant.go b/backend/app/middlewares/tenant.go index d9de749..dde4c1b 100644 --- a/backend/app/middlewares/tenant.go +++ b/backend/app/middlewares/tenant.go @@ -63,6 +63,40 @@ func (f *Middlewares) TenantAuth(c fiber.Ctx) error { return c.Next() } +// TenantOptionalAuth 在 token 存在时解析并写入 claims,但允许无 token 的请求继续。 +// 用于“公开只读”类接口:可匿名访问,但若携带 token 则可以得到更准确的 has_access 等判断。 +func (f *Middlewares) TenantOptionalAuth(c fiber.Ctx) error { + authHeader := c.Get(jwt.HttpHeader) + if authHeader == "" { + f.log.Debug("middlewares.tenant.optional_auth.no_token") + return c.Next() + } + + claims, err := f.jwt.Parse(authHeader) + if err != nil { + f.log.WithError(err).Warn("middlewares.tenant.optional_auth.invalid_token") + switch err { + case jwt.TokenExpired: + return errorx.ErrTokenExpired + case jwt.TokenMalformed, jwt.TokenNotValidYet, jwt.TokenInvalid: + return errorx.ErrTokenInvalid + default: + return errorx.ErrTokenInvalid + } + } + if claims.UserID == 0 { + f.log.Warn("middlewares.tenant.optional_auth.missing_user_id") + return errorx.ErrTokenInvalid + } + + f.log.WithFields(map[string]any{ + "user_id": claims.UserID, + }).Debug("middlewares.tenant.optional_auth.ok") + + c.Locals(consts.CtxKeyClaims, claims) + return c.Next() +} + func (f *Middlewares) TenantRequireMember(c fiber.Ctx) error { tenantModel, ok := c.Locals(consts.CtxKeyTenant).(*models.Tenant) if !ok || tenantModel == nil { diff --git a/backend/app/services/content.go b/backend/app/services/content.go index 109fe7e..1497603 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -5,6 +5,7 @@ import ( "errors" "time" + "quyun/v2/app/errorx" "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" "quyun/v2/database" @@ -257,6 +258,117 @@ func (s *content) ListPublished(ctx context.Context, tenantID, userID int64, fil }, nil } +// ListPublicPublished 返回“公开可见”的已发布内容列表(给游客/非成员使用)。 +// 规则:仅返回 published + visibility=public;tenant_only/private 永不通过公开接口暴露。 +func (s *content) ListPublicPublished(ctx context.Context, tenantID, viewerUserID int64, filter *dto.ContentListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &dto.ContentListFilter{} + } + + log.WithFields(log.Fields{ + "tenant_id": tenantID, + "user_id": viewerUserID, + "page": filter.Page, + "limit": filter.Limit, + }).Info("services.content.list_public_published") + + tbl, query := models.ContentQuery.QueryContext(ctx) + + conds := []gen.Condition{ + tbl.TenantID.Eq(tenantID), + tbl.Status.Eq(consts.ContentStatusPublished), + tbl.Visibility.Eq(consts.ContentVisibilityPublic), + tbl.DeletedAt.IsNull(), + } + if filter.Keyword != nil && *filter.Keyword != "" { + conds = append(conds, tbl.Title.Like(database.WrapLike(*filter.Keyword))) + } + + filter.Pagination.Format() + items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + contentIDs := lo.Map(items, func(item *models.Content, _ int) int64 { return item.ID }) + priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs) + if err != nil { + return nil, err + } + + accessSet := map[int64]bool{} + if viewerUserID > 0 { + m, err := s.accessSet(ctx, tenantID, viewerUserID, contentIDs) + if err != nil { + return nil, err + } + accessSet = m + } + + respItems := lo.Map(items, func(model *models.Content, _ int) *dto.ContentItem { + price := priceByContent[model.ID] + free := price == nil || price.PriceAmount == 0 + has := free || accessSet[model.ID] || model.UserID == viewerUserID + return &dto.ContentItem{ + Content: model, + Price: price, + HasAccess: has, + } + }) + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: respItems, + }, nil +} + +// PublicDetail 返回“公开可见”的内容详情(给游客/非成员使用)。 +// 规则:仅允许 published + visibility=public;否则统一返回 not found,避免信息泄露。 +func (s *content) PublicDetail(ctx context.Context, tenantID, viewerUserID, contentID int64) (*ContentDetailResult, error) { + log.WithFields(log.Fields{ + "tenant_id": tenantID, + "user_id": viewerUserID, + "content_id": contentID, + }).Info("services.content.public_detail") + + tbl, query := models.ContentQuery.QueryContext(ctx) + model, err := query.Where( + tbl.TenantID.Eq(tenantID), + tbl.ID.Eq(contentID), + tbl.DeletedAt.IsNull(), + ).First() + if err != nil { + return nil, errorx.ErrRecordNotFound.WithMsg("content not found") + } + + // Public endpoints only expose published + public contents. + if model.Status != consts.ContentStatusPublished || model.Visibility != consts.ContentVisibilityPublic { + return nil, errorx.ErrRecordNotFound.WithMsg("content not found") + } + + price, err := s.contentPrice(ctx, tenantID, contentID) + if err != nil { + return nil, err + } + free := price == nil || price.PriceAmount == 0 + + hasAccess := model.UserID == viewerUserID || free + if !hasAccess && viewerUserID > 0 { + ok, err := s.HasAccess(ctx, tenantID, viewerUserID, contentID) + if err != nil { + return nil, err + } + hasAccess = ok + } + + return &ContentDetailResult{ + Content: model, + Price: price, + HasAccess: hasAccess, + }, nil +} + func (s *content) Detail(ctx context.Context, tenantID, userID, contentID int64) (*ContentDetailResult, error) { log.WithFields(log.Fields{ "tenant_id": tenantID, diff --git a/backend/app/services/content_test.go b/backend/app/services/content_test.go index 427ed0e..cbd3fc3 100644 --- a/backend/app/services/content_test.go +++ b/backend/app/services/content_test.go @@ -2,11 +2,14 @@ package services import ( "database/sql" + "errors" "testing" "time" "quyun/v2/app/commands/testx" + "quyun/v2/app/errorx" "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" "quyun/v2/database" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -18,6 +21,7 @@ import ( "go.ipao.vip/atom/contracts" "go.ipao.vip/gen/types" "go.uber.org/dig" + "gorm.io/gorm" ) type ContentTestSuiteInjectParams struct { @@ -241,3 +245,247 @@ func (s *ContentTestSuite) Test_HasAccess() { }) }) } + +func (s *ContentTestSuite) Test_ListPublicPublished() { + Convey("Content.ListPublicPublished", s.T(), func() { + ctx := s.T().Context() + now := time.Now().UTC() + tenantID := int64(1) + ownerID := int64(2) + + database.Truncate(ctx, s.DB, + models.TableNameContentAccess, + models.TableNameContentPrice, + models.TableNameContent, + ) + + publicPaid := &models.Content{ + TenantID: tenantID, + UserID: ownerID, + Title: "public_paid", + Description: "d", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityPublic, + PreviewSeconds: consts.DefaultContentPreviewSeconds, + PreviewDownloadable: false, + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(publicPaid.Create(ctx), ShouldBeNil) + So((&models.ContentPrice{ + TenantID: tenantID, + UserID: ownerID, + ContentID: publicPaid.ID, + Currency: consts.CurrencyCNY, + PriceAmount: 100, + CreatedAt: now, + UpdatedAt: now, + }).Create(ctx), ShouldBeNil) + + publicFree := &models.Content{ + TenantID: tenantID, + UserID: ownerID, + Title: "public_free", + Description: "d", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityPublic, + PreviewSeconds: consts.DefaultContentPreviewSeconds, + PreviewDownloadable: false, + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(publicFree.Create(ctx), ShouldBeNil) + + tenantOnly := &models.Content{ + TenantID: tenantID, + UserID: ownerID, + Title: "tenant_only", + Description: "d", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityTenantOnly, + PreviewSeconds: consts.DefaultContentPreviewSeconds, + PreviewDownloadable: false, + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(tenantOnly.Create(ctx), ShouldBeNil) + + privateContent := &models.Content{ + TenantID: tenantID, + UserID: ownerID, + Title: "private", + Description: "d", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityPrivate, + PreviewSeconds: consts.DefaultContentPreviewSeconds, + PreviewDownloadable: false, + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(privateContent.Create(ctx), ShouldBeNil) + + draftPublic := &models.Content{ + TenantID: tenantID, + UserID: ownerID, + Title: "draft_public", + Description: "d", + Status: consts.ContentStatusDraft, + Visibility: consts.ContentVisibilityPublic, + PreviewSeconds: consts.DefaultContentPreviewSeconds, + PreviewDownloadable: false, + CreatedAt: now, + UpdatedAt: now, + } + So(draftPublic.Create(ctx), ShouldBeNil) + + deletedPublic := &models.Content{ + TenantID: tenantID, + UserID: ownerID, + Title: "deleted_public", + Description: "d", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityPublic, + PreviewSeconds: consts.DefaultContentPreviewSeconds, + PreviewDownloadable: false, + PublishedAt: now, + DeletedAt: gorm.DeletedAt{Time: now, Valid: true}, + CreatedAt: now, + UpdatedAt: now, + } + So(deletedPublic.Create(ctx), ShouldBeNil) + + Convey("游客仅能看到 public+published,且免费内容 has_access=true", func() { + pager, err := Content.ListPublicPublished(ctx, tenantID, 0, &dto.ContentListFilter{Pagination: requests.Pagination{Page: 1, Limit: 20}}) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 2) + + items := pager.Items.([]*dto.ContentItem) + So(len(items), ShouldEqual, 2) + + got := map[int64]*dto.ContentItem{} + for _, it := range items { + got[it.Content.ID] = it + } + So(got[publicPaid.ID], ShouldNotBeNil) + So(got[publicFree.ID], ShouldNotBeNil) + So(got[publicPaid.ID].HasAccess, ShouldBeFalse) + So(got[publicFree.ID].HasAccess, ShouldBeTrue) + }) + + Convey("已登录用户若有权益则 has_access=true", func() { + viewerID := int64(99) + access := &models.ContentAccess{ + TenantID: tenantID, + UserID: viewerID, + ContentID: publicPaid.ID, + OrderID: 123, + Status: consts.ContentAccessStatusActive, + CreatedAt: now, + UpdatedAt: now, + } + So(access.Create(ctx), ShouldBeNil) + + pager, err := Content.ListPublicPublished(ctx, tenantID, viewerID, &dto.ContentListFilter{Pagination: requests.Pagination{Page: 1, Limit: 20}}) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 2) + + items := pager.Items.([]*dto.ContentItem) + got := map[int64]*dto.ContentItem{} + for _, it := range items { + got[it.Content.ID] = it + } + So(got[publicPaid.ID].HasAccess, ShouldBeTrue) + }) + }) +} + +func (s *ContentTestSuite) Test_PublicDetail() { + Convey("Content.PublicDetail", s.T(), func() { + ctx := s.T().Context() + now := time.Now().UTC() + tenantID := int64(1) + ownerID := int64(2) + + database.Truncate(ctx, s.DB, + models.TableNameContentAccess, + models.TableNameContentPrice, + models.TableNameContent, + ) + + publicPaid := &models.Content{ + TenantID: tenantID, + UserID: ownerID, + Title: "public_paid", + Description: "d", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityPublic, + PreviewSeconds: consts.DefaultContentPreviewSeconds, + PreviewDownloadable: false, + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(publicPaid.Create(ctx), ShouldBeNil) + So((&models.ContentPrice{ + TenantID: tenantID, + UserID: ownerID, + ContentID: publicPaid.ID, + Currency: consts.CurrencyCNY, + PriceAmount: 100, + CreatedAt: now, + UpdatedAt: now, + }).Create(ctx), ShouldBeNil) + + tenantOnly := &models.Content{ + TenantID: tenantID, + UserID: ownerID, + Title: "tenant_only", + Description: "d", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityTenantOnly, + PreviewSeconds: consts.DefaultContentPreviewSeconds, + PreviewDownloadable: false, + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(tenantOnly.Create(ctx), ShouldBeNil) + + Convey("游客访问 public+paid:可见但无正片权限", func() { + out, err := Content.PublicDetail(ctx, tenantID, 0, publicPaid.ID) + So(err, ShouldBeNil) + So(out, ShouldNotBeNil) + So(out.HasAccess, ShouldBeFalse) + }) + + Convey("tenant_only 在 public detail 下应表现为 not found", func() { + _, err := Content.PublicDetail(ctx, tenantID, 0, tenantOnly.ID) + So(err, ShouldNotBeNil) + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.ErrRecordNotFound.Code) + }) + + Convey("有权益的已登录用户访问 public+paid:has_access=true", func() { + viewerID := int64(99) + access := &models.ContentAccess{ + TenantID: tenantID, + UserID: viewerID, + ContentID: publicPaid.ID, + OrderID: 123, + Status: consts.ContentAccessStatusActive, + CreatedAt: now, + UpdatedAt: now, + } + So(access.Create(ctx), ShouldBeNil) + + out, err := Content.PublicDetail(ctx, tenantID, viewerID, publicPaid.ID) + So(err, ShouldBeNil) + So(out.HasAccess, ShouldBeTrue) + }) + }) +} diff --git a/backend/app/services/tenant_join.go b/backend/app/services/tenant_join.go index 7418619..0d25ef4 100644 --- a/backend/app/services/tenant_join.go +++ b/backend/app/services/tenant_join.go @@ -9,7 +9,7 @@ import ( "quyun/v2/app/errorx" "quyun/v2/app/http/tenant/dto" - tenantjoindto "quyun/v2/app/http/tenantjoin/dto" + tenant_join_dto "quyun/v2/app/http/tenant_join/dto" "quyun/v2/app/requests" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -297,7 +297,7 @@ func (t *tenant) JoinByInvite(ctx context.Context, tenantID, userID int64, invit } // CreateJoinRequest 用户提交加入租户申请(无邀请码场景)。 -func (t *tenant) CreateJoinRequest(ctx context.Context, tenantID, userID int64, form *tenantjoindto.JoinRequestCreateForm) (*models.TenantJoinRequest, error) { +func (t *tenant) CreateJoinRequest(ctx context.Context, tenantID, userID int64, form *tenant_join_dto.JoinRequestCreateForm) (*models.TenantJoinRequest, error) { if tenantID <= 0 || userID <= 0 { return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/user_id") } diff --git a/backend/app/services/tenant_join_test.go b/backend/app/services/tenant_join_test.go index 7e758b1..affee0d 100644 --- a/backend/app/services/tenant_join_test.go +++ b/backend/app/services/tenant_join_test.go @@ -10,7 +10,7 @@ import ( "quyun/v2/app/commands/testx" "quyun/v2/app/errorx" tenantdto "quyun/v2/app/http/tenant/dto" - tenantjoindto "quyun/v2/app/http/tenantjoin/dto" + tenant_join_dto "quyun/v2/app/http/tenant_join/dto" "quyun/v2/database" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -262,7 +262,7 @@ func (s *TenantJoinTestSuite) Test_CreateJoinRequest() { Status: consts.UserStatusVerified, }).Error, ShouldBeNil) - _, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenantjoindto.JoinRequestCreateForm{Reason: "x"}) + _, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenant_join_dto.JoinRequestCreateForm{Reason: "x"}) So(err, ShouldNotBeNil) var appErr *errorx.AppError @@ -273,11 +273,11 @@ func (s *TenantJoinTestSuite) Test_CreateJoinRequest() { Convey("重复提交应返回同一个 pending 申请(幂等)", func() { s.truncateAll(ctx) - out1, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenantjoindto.JoinRequestCreateForm{Reason: "a"}) + out1, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenant_join_dto.JoinRequestCreateForm{Reason: "a"}) So(err, ShouldBeNil) So(out1, ShouldNotBeNil) - out2, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenantjoindto.JoinRequestCreateForm{Reason: "b"}) + out2, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenant_join_dto.JoinRequestCreateForm{Reason: "b"}) So(err, ShouldBeNil) So(out2, ShouldNotBeNil) So(out2.ID, ShouldEqual, out1.ID) diff --git a/backend/specs/spec01-backlog.md b/backend/specs/spec01-backlog.md new file mode 100644 index 0000000..5244dce --- /dev/null +++ b/backend/specs/spec01-backlog.md @@ -0,0 +1,178 @@ +# Spec01 可执行 Backlog(按接口/表/状态机/验收用例) + +本文从 `backend/specs/spec01-gap-analysis.md` 的“差异点/未实现项”拆解为可落地的 backlog。每条尽量可独立开发、可验收、可回滚。 + +## 0. 约定(用于所有条目) + +- **P0/P1/P2**:优先级从高到低;P0=阻塞核心目标,P1=重要增强,P2=可延后。 +- **验收方式**:默认用“service 测试 + http 层冒烟”覆盖;涉及对象存储/转码的条目允许先用 mock/本地 minio 方案。 +- **租户隔离硬约束**:任何带 `id` 的资源访问都必须校验 `tenant_id` 边界。 + +## Epic A:公开内容/游客访问(当前未落地) + +当前 `/t/:tenantCode/v1/*` 默认强制“登录 + 必须是租户成员”,不满足 spec01 的“游客可浏览公开内容(若允许)”。 + +### A1(P0, Middleware/API)增加“公开读接口路由组” + +- **新增路由组**(建议二选一): + 1) `GET /t/:tenantCode/v1/public/contents`(公开列表) + 2) `GET /t/:tenantCode/v1/public/contents/:contentID`(公开详情) + 3) `GET /t/:tenantCode/v1/public/contents/:contentID/preview`(公开试看资源) +- **中间件策略**: + - 仅 `TenantResolve`; + - 可选 `TenantOptionalAuth`:有 token 则解析写 ctx,无 token 允许继续(用于展示“已购/作者/已登录”差异)。 +- **响应语义**: + - 仅返回 `visibility=public` 且 `status=published` 的内容; + - `HasAccess` 在公开接口里定义为:`free || owner || purchased`(若无登录则恒为 false,除非 free=真)。 +- **验收用例**: + - 未登录可拉取公开内容列表/详情/preview; + - 未登录访问 `visibility=tenant_only/private` 返回权限错误或 404(按统一策略定); + - 已登录但非成员:公开内容可读;非公开内容不可读;购买/余额接口不可用。 + +### A2(P1, State/Rule)明确 `visibility` 与主资源访问关系 + +- **规则固化**(写入接口文档 + tests): + - `visibility` 只控制“内容详情是否可见”;主资源(main role)仍需 `free/owner/purchased`; + - `public + free` 是否允许游客看正片:需要业务明确(建议允许,减少“公开但看不了”的困惑)。 +- **验收用例**: + - `public + price=0`:游客能拿到 main assets; + - `public + price>0`:游客不能拿到 main assets,但能拿到 preview assets。 + +## Epic B:MediaAsset 上传/处理全链路(当前缺失) + +已有 `media_assets`、`content_assets` 表,但缺少“上传→处理→对外下发”的闭环接口与状态机。 + +### B1(P0, API)上传初始化:申请上传凭证/直传参数 + +- **新增接口**(tenant_admin): + - `POST /t/:tenantCode/v1/admin/media_assets/upload_init` +- **请求字段**: + - `type`(video/audio/image) + - `content_type`(mime,可选) + - `file_size`(可选,用于限额) + - `sha256`(可选,用于去重/审计) +- **返回字段**(按存储 provider 定): + - `asset_id` + - `upload_url` / `form_fields`(S3 POST policy)/ `headers` + - `expires_at` +- **DB**: + - 创建 `media_assets(status=uploaded, provider/bucket/object_key/meta)`; + - `object_key` 由后端生成,避免客户端指定路径。 +- **验收用例**: + - tenant_admin 调用成功返回可用上传信息; + - member/非 admin 调用被拒绝; + - asset 必须绑定正确 `tenant_id/user_id`。 + +### B2(P0, API/State)上传完成回调:触发处理并进入 processing + +- **新增接口**(tenant_admin 或 system): + - `POST /t/:tenantCode/v1/admin/media_assets/:assetID/upload_complete` +- **行为**: + - 校验 `asset.status=uploaded`; + - 写入必要 meta(duration/width/height 可后置); + - 状态迁移:`uploaded -> processing`; + - 触发异步处理(先允许 stub:写入任务表或发消息)。 +- **验收用例**: + - 重复调用幂等(第二次返回同一结果,不重复触发任务); + - 非法状态迁移返回明确错误码(status conflict)。 + +### B3(P1, API)查询资源:详情与列表 + +- **新增接口**(tenant_admin): + - `GET /t/:tenantCode/v1/admin/media_assets` + - `GET /t/:tenantCode/v1/admin/media_assets/:assetID` +- **查询字段**: + - `status/type/created_at` 过滤;分页。 +- **验收用例**: + - 只能查本租户资源; + - deleted_at 过滤策略一致(默认不返回已删除)。 + +### B4(P1, State Machine)固化 media_assets 状态机与允许迁移 + +- **状态集合**:`uploaded/processing/ready/failed/deleted`。 +- **允许迁移**: + - uploaded → processing + - processing → ready | failed + - ready/failed → deleted(软删) +- **验收用例**: + - 任意越权迁移失败; + - ready 才允许绑定到 `content_assets`(见 Epic C)。 + +## Epic C:资源下发与防直链(当前为“返回对象信息”,未形成安全下发) + +目前内容资源接口返回 `models.MediaAsset`,可能包含 `bucket/object_key` 等内部定位信息;spec01 希望通过“短时效播放凭证/签名 URL/token”下发,并且 preview 与 main 资源彻底区分。 + +### C1(P0, API/DTO)资源下发改为“签名 URL/Token”响应 + +- **调整接口返回 DTO**: + - `GET /t/:tenantCode/v1/contents/:contentID/preview` + - `GET /t/:tenantCode/v1/contents/:contentID/assets` +- **响应字段建议**: + - `asset_id` + - `type` + - `play_url`(短时效) + - `expires_at` + - `meta`(可展示字段的白名单,如 duration/width/height) +- **实现要点**: + - 后端对 provider(minio/s3/oss)生成签名 URL; + - 绝不返回可长期复用的直链或裸 object_key(除非配置允许且仅内网)。 +- **验收用例**: + - 返回的 URL 具备过期时间; + - 无权限时不返回任何可用播放地址; + - 日志/审计中记录 tenant_id/content_id/user_id/role/asset_id。 + +### C2(P1, Rule/Validation)校验 preview 必须独立产物 + +- **约束**(二选一落库方式): + 1) `media_assets.meta.variant=preview/main`(或 `is_preview`) + 2) 新增列 `media_assets.variant`(枚举) +- **绑定校验**: + - `content_assets.role=preview` 只能绑定 `variant=preview`; + - `role=main` 只能绑定 `variant=main`。 +- **验收用例**: + - 用 main 资源绑定 preview 被拒绝; + - preview 秒数只对 preview 下发生效。 + +## Epic D:异步退款/风控预留(当前 `refunding` 未使用) + +### D1(P2, State Machine)引入 `refunding` 并定义状态迁移 + +- **订单状态机补齐**: + - paid → refunding → refunded | failed +- **接口语义**: + - `POST refund` 返回 `refunding`; + - 单独的 job/worker 完成 `credit_refund + revoke access + status->refunded`。 +- **验收用例**: + - 重复退款请求幂等; + - refunding 期间不得重复扣款/重复回收权益; + - 失败可重试(明确重试幂等键策略)。 + +## Epic E:审计字段结构化(当前充值操作者更多在 snapshot/remark) + +### E1(P1, DB/API)tenant_ledgers 增加操作者字段与业务引用字段 + +- **DB 变更**(建议): + - `tenant_ledgers.operator_user_id bigint NULL` + - `tenant_ledgers.biz_ref_type varchar(32) NULL`(order/refund/topup/etc) + - `tenant_ledgers.biz_ref_id bigint NULL` + - 对 `(tenant_id, biz_ref_type, biz_ref_id, type)` 做唯一约束(或与 idempotency_key 二选一作为主幂等源)。 +- **验收用例**: + - 充值/退款/购买相关 ledger 必须写入 operator_user_id(admin/buyer/system); + - 后台可按 operator_user_id 检索敏感操作流水。 + +### E2(P1, DB/Order)topup 结构化操作者字段(可选) + +- **DB 变更**(二选一): + 1) 在 `orders` 增加 `operator_user_id`(对 topup 更直观) + 2) 保持在 snapshot,但保证 ledger/operator 字段可追溯 +- **验收用例**: + - 导出订单时能明确区分“充值发起人”和“充值受益人”。 + +## 1. 建议交付顺序(最小闭环) + +1) A1 → A2(先把公开读能力与语义定死) +2) B1 → B2 → B4(上传/处理状态机闭环;任务系统可先 stub) +3) C1 → C2(把资源下发安全化,再强制 preview 独立产物) +4) E1(审计增强,避免后续追溯成本) +5) D1(如确需异步退款/风控,再引入) + diff --git a/backend/specs/spec01-gap-analysis.md b/backend/specs/spec01-gap-analysis.md new file mode 100644 index 0000000..0c0372a --- /dev/null +++ b/backend/specs/spec01-gap-analysis.md @@ -0,0 +1,115 @@ +# Spec01 vs 当前实现:功能对比与后续需求规则 + +本文基于 `backend/specs/spec01.md`,对照当前后端实现(数据表 / service / HTTP 路由),用于: +- 快速确认“已实现/部分实现/未实现”的范围边界; +- 固化后续需求补充时需要遵循的规则与约束,避免在多租户与资金链路上走偏。 + +## 1. 已实现(与 spec01 对齐) + +### 1.1 多租户隔离与租户成员 +- **租户上下文解析**:所有租户 API 按 `tenantCode` 解析租户并写入 ctx(middleware)。 +- **必须为租户成员**:`/t/:tenantCode/v1/*` 默认强制登录 + 必须属于租户(middleware),不属于租户会直接拒绝。 +- **角色模型**:`tenant_users.role`(member/tenant_admin)存在,租户管理接口有 role 校验。 +- **加入租户**:支持邀请码加入与申请加入(tenantjoin 模块)。 + +### 1.2 余额体系(可用 + 冻结)与账本流水 +- **账户维度**:`tenant_users(tenant_id,user_id)`;字段包含 `balance`、`balance_frozen`。 +- **账本流水**:`tenant_ledgers` 记录每次余额变更,含: + - `type`(credit_topup / freeze / unfreeze / debit_purchase / credit_refund 等); + - `balance_before/after`、`frozen_before/after` 快照; + - `idempotency_key` 唯一约束(tenant+user 维度)用于幂等落账。 +- **一致性**:账本落地实现包含行锁与“余额/冻结余额不得为负”的不变量校验。 + +### 1.3 内容、定价与权益 +- **内容模型**:`contents`(status/visibility/preview_seconds 等)。 +- **内容定价**:`content_prices`(price_amount + discount_* 时间窗)。 +- **订单快照**:购买时将价格/折扣/内容信息写入 `orders.snapshot`,避免改价影响历史订单。 +- **权益模型**:`content_access(tenant_id,user_id,content_id)`;购买授予 `active`,退款置为 `revoked`。 +- **试看**:区分 preview/main 资源角色;`/preview` 不要求购买,`/assets` 要求已购/免费/作者。 + +### 1.4 订单、购买、充值与退款 +- **订单与明细**:`orders` + `order_items`;支持 type=content_purchase/topup 与状态流转。 +- **购买(余额支付)**:支持冻结→扣款(消耗冻结)→授予权益;并发靠行锁+冻结方案防止透支。 +- **购买幂等**:`idempotency_key` 支持“至多一次”购买语义;失败会写回滚标记并稳定返回“失败+已回滚”。 +- **充值**:租户管理员可为租户成员单笔充值 + 批量充值;写 topup 订单 + credit_topup 账本。 +- **退款**:租户管理员可对已支付订单退款;默认时间窗(paid_at + 24h),可 force 绕过;退款入账 + 回收权益。 +- **后台订单查询**:支持管理员按条件分页查询与导出(CSV)。 + +## 2. 部分实现 / 需要明确的差异点 + +### 2.1 “游客/公开内容”未落地 +spec01 允许“游客/未加入租户用户浏览公开内容(若允许)”。当前实现中,`/t/:tenantCode/v1/*` 默认要求登录且必须是租户成员。 + +若要支持“公开内容给非成员/未登录用户访问”,需要单独的路由与中间件策略(至少绕过 `TenantRequireMember`,并重新定义 `visibility=public` 的含义)。 + +### 2.2 订单状态 `refunding` 未使用 +spec01 给出 `refunding` 中间态建议;当前退款实现通常直接落到 `refunded`(事务内完成退款账本+权益回收+订单更新)。 + +若未来需要异步退款(例如接第三方支付、风控审核),应补齐 `refunding` 状态的状态机与重试/幂等规则。 + +### 2.3 操作者审计字段不完全结构化 +spec01 建议在订单侧保留 `operator_user_id` 等结构化字段。当前实现: +- 退款操作者落在 `orders.refund_operator_user_id`; +- 充值操作者主要在 `orders.snapshot`/ledger remark 中体现(结构化程度较弱)。 + +若后续需要强审计/报表,应明确哪些操作必须“结构化字段 + 快照”双写。 + +## 3. 未实现(spec01 提到但系统暂缺) + +### 3.1 MediaAsset 上传/处理全链路 +已存在 `media_assets` 表及内容关联 `content_assets`,但目前缺少: +- 上传/回调/转码/处理状态流转接口; +- 存储签名 URL/防盗链/短时 token 下发机制; +- “preview 资源必须是独立转码产物”的生产链路约束与校验。 + +## 4. 后续需求“规则”(建议强制遵循) + +### 4.1 多租户规则(硬约束) +- 所有新增业务表必须具备 `tenant_id`,或能从主实体可推导且在查询/写入时强校验租户边界。 +- 每个 HTTP API 必须明确:是否需要登录、是否需要租户成员、是否需要 tenant_admin、是否允许跨租户访问(默认禁止)。 + +### 4.2 资金/余额规则(硬约束) +- 任何会改变余额/冻结余额的行为,都必须: + - 走账本(tenant_ledgers)并记录 before/after; + - 定义唯一幂等键策略(稳定、可重放、可查证); + - 明确事务边界与失败补偿(尤其是“冻结成功但后续失败”的回滚路径)。 +- 余额不允许为负;冻结余额不允许为负;这是系统级不变量,需求不得破坏。 + +### 4.3 订单规则(硬约束) +- 订单必须有快照(至少包含:内容标题、定价、折扣、成交价、时间、请求 idempotency_key)。 +- 任何“可重试”的下单/退款/充值动作必须给出幂等语义:重复请求返回同一结果,不重复扣款/入账。 +- 需求必须明确:失败时是否保留订单、订单处于何种终态、以及客户端应如何重试。 + +### 4.4 权益与资源访问规则(硬约束) +- “是否可看正片”只取决于:免费/作者/权益(content_access=active);客户端表现不构成安全措施。 +- 试看资源必须与正片资源隔离(不同 asset role + 不同存储对象),需求不得允许复用正片资源做试看。 +- 退款后权益必须立即失效(revoked),并且该规则优先级高于缓存/前端展示。 + +### 4.5 状态机与审计规则(建议) +- 对所有引入状态的实体(content/order/media_asset/tenant_user),需求必须附带: + - 允许的状态集合; + - 允许的状态迁移; + - 幂等行为(重复迁移是否允许、返回什么)。 +- 对敏感操作(充值/退款/调账/封禁),需求必须明确: + - 操作者字段(operator_user_id)是否结构化落库; + - 原因字段是否必填; + - 审计可检索性(按租户/用户/时间/单据维度)。 + +## 5. 参考实现位置(便于后续对齐) + +- 数据库迁移: + - `backend/database/migrations/20251216011456_tenant_users.sql` + - `backend/database/migrations/20251217223000_media_contents.sql` + - `backend/database/migrations/20251218120000_orders_ledgers.sql` +- 中间件(租户上下文/成员校验): + - `backend/app/middlewares/tenant.go` + - `backend/app/http/tenant/routes.manual.go` + - `backend/app/http/tenant_join/routes.manual.go` +- 业务服务(核心资金与订单链路): + - `backend/app/services/ledger.go` + - `backend/app/services/order.go` + - `backend/app/services/content.go` +- HTTP 路由(对外能力清单): + - `backend/app/http/tenant/*.go` + - `backend/app/http/tenant_join/*.go` + - `backend/app/http/super/*.go`(可选:平台侧)