fix: enforce content visibility and tenant login

This commit is contained in:
2026-01-13 09:28:45 +08:00
parent ca7c799344
commit 342987334a
4 changed files with 231 additions and 27 deletions

View File

@@ -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 {
return nil
}
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("内容未发布")
return errorx.ErrForbidden.WithMsg("内容仅限本店铺成员访问")
}
if item.UserID == userID {
return nil
}
exists, err := models.TenantUserQuery.WithContext(ctx).
Where(models.TenantUserQuery.TenantID.Eq(item.TenantID),
models.TenantUserQuery.UserID.Eq(userID),
models.TenantUserQuery.Role.Contains(types.Array[consts.TenantUserRole]{consts.TenantUserRoleTenantAdmin})).
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 !exists {
return errorx.ErrForbidden.WithMsg("内容未发布")
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(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(tenantID),
models.TenantUserQuery.UserID.Eq(userID),
models.TenantUserQuery.Role.Contains(types.Array[consts.TenantUserRole]{consts.TenantUserRoleTenantAdmin})).
Exists()
if err != nil {
return false, errorx.ErrDatabaseError.WithCause(err)
}
return exists, nil
}
func (s *content) isTenantMember(ctx context.Context, tenantID, userID int64) (bool, error) {
if tenantID == 0 || userID == 0 {
return false, 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 {

View File

@@ -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() {

View File

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

View File

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