fix: enforce content visibility and tenant login
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user