From 342987334a725a1b9f1575553dfe96da4926f16d Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 13 Jan 2026 09:28:45 +0800 Subject: [PATCH] fix: enforce content visibility and tenant login --- backend/app/services/content.go | 89 +++++++++++++++++++++++++--- backend/app/services/content_test.go | 76 +++++++++++++++++++++++- backend/app/services/user.go | 38 +++++++++++- backend/app/services/user_test.go | 55 ++++++++++++----- 4 files changed, 231 insertions(+), 27 deletions(-) diff --git a/backend/app/services/content.go b/backend/app/services/content.go index 9b18dae..497a665 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -31,6 +31,8 @@ func (s *content) List(ctx context.Context, tenantID int64, filter *content_dto. if tenantID > 0 { q = q.Where(tbl.TenantID.Eq(tenantID)) } + // 私密内容不参与公开列表展示。 + q = q.Where(tbl.Visibility.Neq(consts.ContentVisibilityPrivate)) if filter.AuthorID != nil && *filter.AuthorID > 0 { q = q.Where(tbl.UserID.Eq(*filter.AuthorID)) } @@ -622,28 +624,99 @@ func (s *content) toContentItemDTO(item *models.Content, price float64, authorIs } func (s *content) ensureContentReadable(ctx context.Context, userID int64, item *models.Content) error { - if item.Status == consts.ContentStatusPublished { + if item.Status != consts.ContentStatusPublished { + // 未发布内容仅允许作者或租户管理员查看。 + return s.ensureContentOwnerOrAdmin(ctx, userID, item, "内容未发布") + } + + switch item.Visibility { + case consts.ContentVisibilityPublic, "": + return nil + case consts.ContentVisibilityPrivate: + // 私密内容仅允许作者或租户管理员查看。 + return s.ensureContentOwnerOrAdmin(ctx, userID, item, "内容不可见") + case consts.ContentVisibilityTenantOnly: + // 店铺内可见内容需要登录并满足成员/购买条件。 + if userID == 0 { + return errorx.ErrForbidden.WithMsg("内容仅限本店铺成员访问") + } + if item.UserID == userID { + return nil + } + isAdmin, err := s.isTenantAdmin(ctx, item.TenantID, userID) + if err != nil { + return err + } + if isAdmin { + return nil + } + // 已购买用户允许访问。 + hasAccess, err := models.ContentAccessQuery.WithContext(ctx). + Where(models.ContentAccessQuery.UserID.Eq(userID), + models.ContentAccessQuery.ContentID.Eq(item.ID), + models.ContentAccessQuery.Status.Eq(consts.ContentAccessStatusActive)). + Exists() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if hasAccess { + return nil + } + isMember, err := s.isTenantMember(ctx, item.TenantID, userID) + if err != nil { + return err + } + if !isMember { + return errorx.ErrForbidden.WithMsg("内容仅限本店铺成员访问") + } + return nil + default: return nil } - // 未发布内容仅允许作者或租户管理员查看。 +} + +func (s *content) ensureContentOwnerOrAdmin(ctx context.Context, userID int64, item *models.Content, msg string) error { if userID == 0 { - return errorx.ErrForbidden.WithMsg("内容未发布") + return errorx.ErrForbidden.WithMsg(msg) } if item.UserID == userID { return nil } + isAdmin, err := s.isTenantAdmin(ctx, item.TenantID, userID) + if err != nil { + return err + } + if !isAdmin { + return errorx.ErrForbidden.WithMsg(msg) + } + return nil +} + +func (s *content) isTenantAdmin(ctx context.Context, tenantID, userID int64) (bool, error) { + if tenantID == 0 || userID == 0 { + return false, nil + } exists, err := models.TenantUserQuery.WithContext(ctx). - Where(models.TenantUserQuery.TenantID.Eq(item.TenantID), + Where(models.TenantUserQuery.TenantID.Eq(tenantID), models.TenantUserQuery.UserID.Eq(userID), models.TenantUserQuery.Role.Contains(types.Array[consts.TenantUserRole]{consts.TenantUserRoleTenantAdmin})). Exists() if err != nil { - return errorx.ErrDatabaseError.WithCause(err) + return false, errorx.ErrDatabaseError.WithCause(err) } - if !exists { - return errorx.ErrForbidden.WithMsg("内容未发布") + return exists, nil +} + +func (s *content) isTenantMember(ctx context.Context, tenantID, userID int64) (bool, error) { + if tenantID == 0 || userID == 0 { + return false, nil } - return nil + tbl, q := models.TenantUserQuery.QueryContext(ctx) + exists, err := q.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID), tbl.Status.Eq(consts.UserStatusVerified)).Exists() + if err != nil { + return false, errorx.ErrDatabaseError.WithCause(err) + } + return exists, nil } func (s *content) toMediaURLs(assets []*models.ContentAsset) []content_dto.MediaURL { diff --git a/backend/app/services/content_test.go b/backend/app/services/content_test.go index e34235b..237b349 100644 --- a/backend/app/services/content_test.go +++ b/backend/app/services/content_test.go @@ -3,9 +3,11 @@ package services import ( "context" "database/sql" + "errors" "testing" "quyun/v2/app/commands/testx" + "quyun/v2/app/errorx" content_dto "quyun/v2/app/http/v1/dto" "quyun/v2/app/requests" "quyun/v2/database" @@ -15,6 +17,7 @@ import ( . "github.com/smartystreets/goconvey/convey" "github.com/stretchr/testify/suite" "go.ipao.vip/atom/contracts" + "go.ipao.vip/gen/types" "go.uber.org/dig" ) @@ -88,7 +91,7 @@ func (s *ContentTestSuite) Test_Get() { Convey("Get", s.T(), func() { ctx := s.T().Context() tenantID := int64(1) - database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameMediaAsset, models.TableNameContentAsset, models.TableNameUser) + database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameMediaAsset, models.TableNameContentAsset, models.TableNameTenantUser, models.TableNameUser) // Author author := &models.User{Nickname: "Author1", Username: "author1", Phone: "13800000002"} @@ -112,6 +115,32 @@ func (s *ContentTestSuite) Test_Get() { } models.ContentQuery.WithContext(ctx).Create(content) + member := &models.User{Nickname: "Member", Username: "member1", Phone: "13800000003"} + guest := &models.User{Nickname: "Guest", Username: "guest1", Phone: "13800000004"} + models.UserQuery.WithContext(ctx).Create(member, guest) + models.TenantUserQuery.WithContext(ctx).Create(&models.TenantUser{ + TenantID: 1, + UserID: member.ID, + Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}, + Status: consts.UserStatusVerified, + }) + + tenantOnly := &models.Content{ + TenantID: 1, + UserID: author.ID, + Title: "Member Only", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityTenantOnly, + } + privateContent := &models.Content{ + TenantID: 1, + UserID: author.ID, + Title: "Private Content", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityPrivate, + } + models.ContentQuery.WithContext(ctx).Create(tenantOnly, privateContent) + // Link Asset ca := &models.ContentAsset{ TenantID: 1, @@ -134,6 +163,34 @@ func (s *ContentTestSuite) Test_Get() { So(len(detail.MediaUrls), ShouldEqual, 1) So(detail.MediaUrls[0].URL, ShouldContainSubstring, "test.mp4") }) + + Convey("should allow tenant_only content for member", func() { + detail, err := Content.Get(ctx, tenantID, member.ID, tenantOnly.ID) + So(err, ShouldBeNil) + So(detail.Title, ShouldEqual, "Member Only") + }) + + Convey("should reject tenant_only content for non-member", func() { + _, err := Content.Get(ctx, tenantID, guest.ID, tenantOnly.ID) + So(err, ShouldNotBeNil) + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code) + }) + + Convey("should reject private content for non-owner", func() { + _, err := Content.Get(ctx, tenantID, member.ID, privateContent.ID) + So(err, ShouldNotBeNil) + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code) + }) + + Convey("should allow private content for author", func() { + detail, err := Content.Get(ctx, tenantID, author.ID, privateContent.ID) + So(err, ShouldBeNil) + So(detail.Title, ShouldEqual, "Private Content") + }) }) } @@ -333,7 +390,13 @@ func (s *ContentTestSuite) Test_PreviewLogic() { author := &models.User{Username: "author_p", Phone: "13900000006"} models.UserQuery.WithContext(ctx).Create(author) - c := &models.Content{TenantID: 1, UserID: author.ID, Title: "Premium", Status: consts.ContentStatusPublished} + c := &models.Content{ + TenantID: 1, + UserID: author.ID, + Title: "Premium", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityPublic, + } models.ContentQuery.WithContext(ctx).Create(c) assetMain := &models.MediaAsset{ObjectKey: "main.mp4", Type: consts.MediaAssetTypeVideo} @@ -391,7 +454,14 @@ func (s *ContentTestSuite) Test_ViewCounting() { author := &models.User{Username: "author_v", Phone: "13900000009"} models.UserQuery.WithContext(ctx).Create(author) - c := &models.Content{TenantID: 1, UserID: author.ID, Title: "View Me", Views: 0, Status: consts.ContentStatusPublished} + c := &models.Content{ + TenantID: 1, + UserID: author.ID, + Title: "View Me", + Views: 0, + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityPublic, + } models.ContentQuery.WithContext(ctx).Create(c) Convey("should increment views", func() { diff --git a/backend/app/services/user.go b/backend/app/services/user.go index 74a4934..787c985 100644 --- a/backend/app/services/user.go +++ b/backend/app/services/user.go @@ -64,8 +64,12 @@ func (s *user) LoginWithOTP(ctx context.Context, tenantID int64, phone, otp stri if u.Status == consts.UserStatusBanned { return nil, errorx.ErrAccountDisabled } + // 4. 校验租户成员关系(租户上下文下仅允许成员登录)。 + if err := s.ensureTenantMember(ctx, tenantID, u.ID); err != nil { + return nil, err + } - // 4. 生成 Token + // 5. 生成 Token token, err := s.jwt.CreateToken(s.jwt.CreateClaims(jwt.BaseClaims{ UserID: u.ID, TenantID: tenantID, @@ -80,6 +84,38 @@ func (s *user) LoginWithOTP(ctx context.Context, tenantID int64, phone, otp stri }, nil } +func (s *user) ensureTenantMember(ctx context.Context, tenantID, userID int64) error { + if tenantID <= 0 { + return nil + } + // 校验租户存在,避免非法租户ID绕过校验。 + tblTenant, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenant, err := tenantQuery.Where(tblTenant.ID.Eq(tenantID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithCause(err).WithMsg("租户不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if tenant.UserID == userID { + return nil + } + + tbl, q := models.TenantUserQuery.QueryContext(ctx) + exists, err := q.Where( + tbl.TenantID.Eq(tenantID), + tbl.UserID.Eq(userID), + tbl.Status.Eq(consts.UserStatusVerified), + ).Exists() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if !exists { + return errorx.ErrForbidden.WithMsg("未加入该租户") + } + return nil +} + // GetModelByID 获取指定 ID 的用户model func (s *user) GetModelByID(ctx context.Context, userID int64) (*models.User, error) { tbl, query := models.UserQuery.QueryContext(ctx) diff --git a/backend/app/services/user_test.go b/backend/app/services/user_test.go index ec4e3be..cf7cc2d 100644 --- a/backend/app/services/user_test.go +++ b/backend/app/services/user_test.go @@ -3,9 +3,11 @@ package services import ( "context" "database/sql" + "errors" "testing" "quyun/v2/app/commands/testx" + "quyun/v2/app/errorx" user_dto "quyun/v2/app/http/v1/dto" "quyun/v2/database" "quyun/v2/database/models" @@ -14,6 +16,7 @@ import ( . "github.com/smartystreets/goconvey/convey" "github.com/stretchr/testify/suite" "go.ipao.vip/atom/contracts" + "go.ipao.vip/gen/types" "go.uber.org/dig" ) @@ -40,12 +43,19 @@ func Test_User(t *testing.T) { func (s *UserTestSuite) Test_LoginWithOTP() { Convey("LoginWithOTP", s.T(), func() { ctx := s.T().Context() - tenantID := int64(1) - database.Truncate(ctx, s.DB, models.TableNameUser) + database.Truncate(ctx, s.DB, models.TableNameTenantUser, models.TableNameTenant, models.TableNameUser) - Convey("should create user and login success with correct OTP", func() { + tenant := &models.Tenant{ + UserID: 1000, + Name: "Tenant A", + Code: "tenant_a", + Status: consts.TenantStatusVerified, + } + models.TenantQuery.WithContext(ctx).Create(tenant) + + Convey("should create user and login success without tenant", func() { phone := "13800138000" - resp, err := User.LoginWithOTP(ctx, tenantID, phone, "1234") + resp, err := User.LoginWithOTP(ctx, 0, phone, "1234") So(err, ShouldBeNil) So(resp, ShouldNotBeNil) So(resp.Token, ShouldNotBeEmpty) @@ -53,20 +63,35 @@ func (s *UserTestSuite) Test_LoginWithOTP() { So(resp.User.Nickname, ShouldStartWith, "User_") }) - Convey("should login existing user", func() { + Convey("should reject login when not tenant member", func() { phone := "13800138001" - // Pre-create user - _, err := User.LoginWithOTP(ctx, tenantID, phone, "1234") + _, err := User.LoginWithOTP(ctx, tenant.ID, phone, "1234") + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code) + }) + + Convey("should login existing tenant member", func() { + phone := "13800138002" + resp, err := User.LoginWithOTP(ctx, 0, phone, "1234") So(err, ShouldBeNil) - // Login again - resp, err := User.LoginWithOTP(ctx, tenantID, phone, "1234") + models.TenantUserQuery.WithContext(ctx).Create(&models.TenantUser{ + TenantID: tenant.ID, + UserID: resp.User.ID, + Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}, + Status: consts.UserStatusVerified, + }) + + resp2, err := User.LoginWithOTP(ctx, tenant.ID, phone, "1234") So(err, ShouldBeNil) - So(resp.User.Phone, ShouldEqual, phone) + So(resp2.User.Phone, ShouldEqual, phone) }) Convey("should fail with incorrect OTP", func() { - resp, err := User.LoginWithOTP(ctx, tenantID, "13800138002", "000000") + resp, err := User.LoginWithOTP(ctx, 0, "13800138003", "000000") So(err, ShouldNotBeNil) So(resp, ShouldBeNil) }) @@ -76,7 +101,7 @@ func (s *UserTestSuite) Test_LoginWithOTP() { func (s *UserTestSuite) Test_Me() { Convey("Me", s.T(), func() { ctx := s.T().Context() - tenantID := int64(1) + tenantID := int64(0) database.Truncate(ctx, s.DB, models.TableNameUser) // Create user @@ -106,7 +131,7 @@ func (s *UserTestSuite) Test_Me() { func (s *UserTestSuite) Test_Update() { Convey("Update", s.T(), func() { ctx := s.T().Context() - tenantID := int64(1) + tenantID := int64(0) database.Truncate(ctx, s.DB, models.TableNameUser) phone := "13800138004" @@ -135,7 +160,7 @@ func (s *UserTestSuite) Test_Update() { func (s *UserTestSuite) Test_RealName() { Convey("RealName", s.T(), func() { ctx := s.T().Context() - tenantID := int64(1) + tenantID := int64(0) database.Truncate(ctx, s.DB, models.TableNameUser) phone := "13800138005" @@ -161,7 +186,7 @@ func (s *UserTestSuite) Test_RealName() { func (s *UserTestSuite) Test_GetNotifications() { Convey("GetNotifications", s.T(), func() { ctx := s.T().Context() - tenantID := int64(1) + tenantID := int64(0) database.Truncate(ctx, s.DB, models.TableNameUser, models.TableNameNotification) phone := "13800138006"