feat: implement tenant-side creator audit feature and update related tests and documentation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -186,6 +186,36 @@ func (c *Creator) ListOrders(ctx fiber.Ctx, filter *dto.CreatorOrderListFilter)
|
||||
return services.Creator.ListOrders(ctx, tenantID, userID, filter)
|
||||
}
|
||||
|
||||
// List creator audit logs
|
||||
//
|
||||
// @Router /v1/t/:tenantCode/creator/audit-logs [get]
|
||||
// @Summary List creator audit logs
|
||||
// @Description 查询当前租户创作者侧审计日志(仅管理员可见)
|
||||
// @Tags CreatorCenter
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param page query int false "Page"
|
||||
// @Param limit query int false "Limit"
|
||||
// @Param operator_id query int false "Operator ID"
|
||||
// @Param operator_name query string false "Operator name"
|
||||
// @Param action query string false "Action"
|
||||
// @Param target_id query string false "Target ID"
|
||||
// @Param keyword query string false "Keyword"
|
||||
// @Param created_at_from query string false "Created at from (RFC3339)"
|
||||
// @Param created_at_to query string false "Created at to (RFC3339)"
|
||||
// @Success 200 {object} requests.Pager{items=[]dto.CreatorAuditLogItem}
|
||||
// @Bind filter query
|
||||
func (c *Creator) ListAuditLogs(ctx fiber.Ctx, filter *dto.CreatorAuditLogListFilter) (*requests.Pager, error) {
|
||||
if filter == nil {
|
||||
filter = &dto.CreatorAuditLogListFilter{}
|
||||
}
|
||||
|
||||
tenantID := getTenantID(ctx)
|
||||
userID := getUserID(ctx)
|
||||
|
||||
return services.Creator.ListAuditLogs(ctx, tenantID, userID, filter)
|
||||
}
|
||||
|
||||
// Process order refund
|
||||
//
|
||||
// @Router /v1/t/:tenantCode/creator/orders/:id<int>/refund [post]
|
||||
|
||||
45
backend/app/http/v1/dto/creator_audit.go
Normal file
45
backend/app/http/v1/dto/creator_audit.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package dto
|
||||
|
||||
import "quyun/v2/app/requests"
|
||||
|
||||
// CreatorAuditLogListFilter 创作者侧审计日志列表过滤条件。
|
||||
type CreatorAuditLogListFilter struct {
|
||||
// Pagination 分页参数(page/limit)。
|
||||
requests.Pagination
|
||||
// OperatorID 操作者用户ID,精确匹配。
|
||||
OperatorID *int64 `query:"operator_id"`
|
||||
// OperatorName 操作者用户名/昵称,模糊匹配。
|
||||
OperatorName *string `query:"operator_name"`
|
||||
// Action 动作标识,精确匹配。
|
||||
Action *string `query:"action"`
|
||||
// TargetID 目标ID,精确匹配。
|
||||
TargetID *string `query:"target_id"`
|
||||
// Keyword 详情关键词,模糊匹配。
|
||||
Keyword *string `query:"keyword"`
|
||||
// CreatedAtFrom 创建时间起始(RFC3339/2006-01-02)。
|
||||
CreatedAtFrom *string `query:"created_at_from"`
|
||||
// CreatedAtTo 创建时间结束(RFC3339/2006-01-02)。
|
||||
CreatedAtTo *string `query:"created_at_to"`
|
||||
// Asc 升序字段(id/created_at)。
|
||||
Asc *string `query:"asc"`
|
||||
// Desc 降序字段(id/created_at)。
|
||||
Desc *string `query:"desc"`
|
||||
}
|
||||
|
||||
// CreatorAuditLogItem 创作者侧审计日志条目。
|
||||
type CreatorAuditLogItem struct {
|
||||
// ID 审计日志ID。
|
||||
ID int64 `json:"id"`
|
||||
// OperatorID 操作者用户ID。
|
||||
OperatorID int64 `json:"operator_id"`
|
||||
// OperatorName 操作者用户名/昵称。
|
||||
OperatorName string `json:"operator_name"`
|
||||
// Action 动作标识。
|
||||
Action string `json:"action"`
|
||||
// TargetID 目标ID。
|
||||
TargetID string `json:"target_id"`
|
||||
// Detail 操作详情。
|
||||
Detail string `json:"detail"`
|
||||
// CreatedAt 创建时间(RFC3339)。
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
@@ -171,6 +171,11 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
r.creator.RemovePayoutAccount,
|
||||
QueryParam[int64]("id"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /v1/t/:tenantCode/creator/audit-logs -> creator.ListAuditLogs")
|
||||
router.Get("/v1/t/:tenantCode/creator/audit-logs"[len(r.Path()):], DataFunc1(
|
||||
r.creator.ListAuditLogs,
|
||||
Query[dto.CreatorAuditLogListFilter]("filter"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /v1/t/:tenantCode/creator/contents -> creator.ListContents")
|
||||
router.Get("/v1/t/:tenantCode/creator/contents"[len(r.Path()):], DataFunc1(
|
||||
r.creator.ListContents,
|
||||
|
||||
@@ -2,9 +2,11 @@ package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"quyun/v2/app/commands/testx"
|
||||
"quyun/v2/app/errorx"
|
||||
order_dto "quyun/v2/app/http/v1/dto"
|
||||
"quyun/v2/database"
|
||||
"quyun/v2/database/models"
|
||||
@@ -196,3 +198,137 @@ func (s *CouponTestSuite) Test_ListAvailable() {
|
||||
So(list[0].CouponID, ShouldEqual, cp.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CouponTestSuite) Test_Validate_DenyCrossTenantCoupon() {
|
||||
Convey("Validate should deny cross-tenant coupon", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(
|
||||
ctx,
|
||||
s.DB,
|
||||
models.TableNameCoupon,
|
||||
models.TableNameUserCoupon,
|
||||
models.TableNameUser,
|
||||
)
|
||||
|
||||
user := &models.User{Username: "coupon_cross_validate", Phone: "13800000011"}
|
||||
So(models.UserQuery.WithContext(ctx).Create(user), ShouldBeNil)
|
||||
|
||||
tenantA := int64(11)
|
||||
tenantB := int64(22)
|
||||
coupon := &models.Coupon{
|
||||
TenantID: tenantA,
|
||||
Title: "Tenant A Coupon",
|
||||
Type: consts.CouponTypeFixAmount,
|
||||
Value: 200,
|
||||
MinOrderAmount: 0,
|
||||
}
|
||||
So(models.CouponQuery.WithContext(ctx).Create(coupon), ShouldBeNil)
|
||||
|
||||
userCoupon := &models.UserCoupon{
|
||||
UserID: user.ID,
|
||||
CouponID: coupon.ID,
|
||||
Status: consts.UserCouponStatusUnused,
|
||||
}
|
||||
So(models.UserCouponQuery.WithContext(ctx).Create(userCoupon), ShouldBeNil)
|
||||
|
||||
_, err := Coupon.Validate(ctx, tenantB, user.ID, userCoupon.ID, 1000)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
var appErr *errorx.AppError
|
||||
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||
So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CouponTestSuite) Test_MarkUsed_DenyCrossTenantCoupon() {
|
||||
Convey("MarkUsed should deny cross-tenant coupon", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(
|
||||
ctx,
|
||||
s.DB,
|
||||
models.TableNameCoupon,
|
||||
models.TableNameUserCoupon,
|
||||
models.TableNameOrder,
|
||||
models.TableNameUser,
|
||||
)
|
||||
|
||||
user := &models.User{Username: "coupon_cross_mark", Phone: "13800000012"}
|
||||
So(models.UserQuery.WithContext(ctx).Create(user), ShouldBeNil)
|
||||
|
||||
tenantA := int64(33)
|
||||
tenantB := int64(44)
|
||||
coupon := &models.Coupon{
|
||||
TenantID: tenantA,
|
||||
Title: "Tenant A Coupon",
|
||||
Type: consts.CouponTypeFixAmount,
|
||||
Value: 200,
|
||||
MinOrderAmount: 0,
|
||||
}
|
||||
So(models.CouponQuery.WithContext(ctx).Create(coupon), ShouldBeNil)
|
||||
|
||||
userCoupon := &models.UserCoupon{
|
||||
UserID: user.ID,
|
||||
CouponID: coupon.ID,
|
||||
Status: consts.UserCouponStatusUnused,
|
||||
}
|
||||
So(models.UserCouponQuery.WithContext(ctx).Create(userCoupon), ShouldBeNil)
|
||||
|
||||
order := &models.Order{
|
||||
TenantID: tenantA,
|
||||
UserID: user.ID,
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusCreated,
|
||||
}
|
||||
So(models.OrderQuery.WithContext(ctx).Create(order), ShouldBeNil)
|
||||
|
||||
err := models.Q.Transaction(func(tx *models.Query) error {
|
||||
return Coupon.MarkUsed(ctx, tx, tenantB, userCoupon.ID, order.ID)
|
||||
})
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
var appErr *errorx.AppError
|
||||
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||
So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CouponTestSuite) Test_Grant_DenyCrossTenantCoupon() {
|
||||
Convey("Grant should reject coupon from another tenant", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(
|
||||
ctx,
|
||||
s.DB,
|
||||
models.TableNameCoupon,
|
||||
models.TableNameUserCoupon,
|
||||
models.TableNameUser,
|
||||
)
|
||||
|
||||
user := &models.User{Username: "coupon_cross_grant", Phone: "13800000013"}
|
||||
So(models.UserQuery.WithContext(ctx).Create(user), ShouldBeNil)
|
||||
|
||||
tenantA := int64(55)
|
||||
tenantB := int64(66)
|
||||
coupon := &models.Coupon{
|
||||
TenantID: tenantA,
|
||||
Title: "Tenant A Coupon",
|
||||
Type: consts.CouponTypeFixAmount,
|
||||
Value: 200,
|
||||
MinOrderAmount: 0,
|
||||
}
|
||||
So(models.CouponQuery.WithContext(ctx).Create(coupon), ShouldBeNil)
|
||||
|
||||
granted, err := Coupon.Grant(ctx, tenantB, coupon.ID, []int64{user.ID})
|
||||
So(err, ShouldNotBeNil)
|
||||
So(granted, ShouldEqual, 0)
|
||||
|
||||
var appErr *errorx.AppError
|
||||
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||
So(appErr.Code, ShouldEqual, errorx.ErrRecordNotFound.Code)
|
||||
|
||||
exists, err := models.UserCouponQuery.WithContext(ctx).
|
||||
Where(models.UserCouponQuery.UserID.Eq(user.ID), models.UserCouponQuery.CouponID.Eq(coupon.ID)).
|
||||
Exists()
|
||||
So(err, ShouldBeNil)
|
||||
So(exists, ShouldBeFalse)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"quyun/v2/pkg/consts"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.ipao.vip/gen/field"
|
||||
"go.ipao.vip/gen/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -717,6 +718,158 @@ func (s *creator) ListOrders(
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *creator) ListAuditLogs(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
filter *creator_dto.CreatorAuditLogListFilter,
|
||||
) (*requests.Pager, error) {
|
||||
if filter == nil {
|
||||
filter = &creator_dto.CreatorAuditLogListFilter{}
|
||||
}
|
||||
if tenantID == 0 {
|
||||
return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在")
|
||||
}
|
||||
|
||||
if _, err := Tenant.ensureTenantAdmin(ctx, tenantID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tbl, q := models.AuditLogQuery.QueryContext(ctx)
|
||||
q = q.Where(tbl.TenantID.Eq(tenantID))
|
||||
|
||||
if filter.OperatorID != nil && *filter.OperatorID > 0 {
|
||||
q = q.Where(tbl.OperatorID.Eq(*filter.OperatorID))
|
||||
}
|
||||
if filter.Action != nil && strings.TrimSpace(*filter.Action) != "" {
|
||||
q = q.Where(tbl.Action.Eq(strings.TrimSpace(*filter.Action)))
|
||||
}
|
||||
if filter.TargetID != nil && strings.TrimSpace(*filter.TargetID) != "" {
|
||||
q = q.Where(tbl.TargetID.Eq(strings.TrimSpace(*filter.TargetID)))
|
||||
}
|
||||
if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" {
|
||||
keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%"
|
||||
q = q.Where(field.Or(tbl.Detail.Like(keyword), tbl.Action.Like(keyword), tbl.TargetID.Like(keyword)))
|
||||
}
|
||||
|
||||
operatorIDs, operatorFilter, err := Tenant.lookupUserIDs(ctx, filter.OperatorName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if operatorFilter {
|
||||
if len(operatorIDs) == 0 {
|
||||
q = q.Where(tbl.ID.Eq(-1))
|
||||
} else {
|
||||
q = q.Where(tbl.OperatorID.In(operatorIDs...))
|
||||
}
|
||||
}
|
||||
|
||||
if filter.CreatedAtFrom != nil {
|
||||
from, err := Super.parseFilterTime(filter.CreatedAtFrom)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if from != nil {
|
||||
q = q.Where(tbl.CreatedAt.Gte(*from))
|
||||
}
|
||||
}
|
||||
if filter.CreatedAtTo != nil {
|
||||
to, err := Super.parseFilterTime(filter.CreatedAtTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if to != nil {
|
||||
q = q.Where(tbl.CreatedAt.Lte(*to))
|
||||
}
|
||||
}
|
||||
|
||||
orderApplied := false
|
||||
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
|
||||
switch strings.TrimSpace(*filter.Desc) {
|
||||
case "id":
|
||||
q = q.Order(tbl.ID.Desc())
|
||||
case "created_at":
|
||||
q = q.Order(tbl.CreatedAt.Desc())
|
||||
}
|
||||
orderApplied = true
|
||||
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
|
||||
switch strings.TrimSpace(*filter.Asc) {
|
||||
case "id":
|
||||
q = q.Order(tbl.ID)
|
||||
case "created_at":
|
||||
q = q.Order(tbl.CreatedAt)
|
||||
}
|
||||
orderApplied = true
|
||||
}
|
||||
if !orderApplied {
|
||||
q = q.Order(tbl.CreatedAt.Desc())
|
||||
}
|
||||
|
||||
filter.Pagination.Format()
|
||||
total, err := q.Count()
|
||||
if err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
|
||||
if err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return &requests.Pager{
|
||||
Pagination: filter.Pagination,
|
||||
Total: total,
|
||||
Items: []creator_dto.CreatorAuditLogItem{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
operatorSet := make(map[int64]struct{}, len(list))
|
||||
for _, log := range list {
|
||||
if log.OperatorID > 0 {
|
||||
operatorSet[log.OperatorID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
operatorMap := make(map[int64]*models.User, len(operatorSet))
|
||||
if len(operatorSet) > 0 {
|
||||
ids := make([]int64, 0, len(operatorSet))
|
||||
for id := range operatorSet {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
userTbl, userQuery := models.UserQuery.QueryContext(ctx)
|
||||
users, err := userQuery.Where(userTbl.ID.In(ids...)).Find()
|
||||
if err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
for _, user := range users {
|
||||
operatorMap[user.ID] = user
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]creator_dto.CreatorAuditLogItem, 0, len(list))
|
||||
for _, log := range list {
|
||||
item := creator_dto.CreatorAuditLogItem{
|
||||
ID: log.ID,
|
||||
OperatorID: log.OperatorID,
|
||||
Action: log.Action,
|
||||
TargetID: log.TargetID,
|
||||
Detail: log.Detail,
|
||||
CreatedAt: s.formatTime(log.CreatedAt),
|
||||
}
|
||||
if operator := operatorMap[log.OperatorID]; operator != nil {
|
||||
item.OperatorName = operator.Username
|
||||
} else if log.OperatorID > 0 {
|
||||
item.OperatorName = "ID:" + strconv.FormatInt(log.OperatorID, 10)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return &requests.Pager{
|
||||
Pagination: filter.Pagination,
|
||||
Total: total,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *creator) ProcessRefund(ctx context.Context, tenantID, userID, id int64, form *creator_dto.RefundForm) error {
|
||||
tid, err := s.getTenantID(ctx, tenantID, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -592,3 +592,86 @@ func (s *CreatorTestSuite) Test_ExportReport() {
|
||||
So(resp.Content, ShouldContainSubstring, "date,paid_orders,paid_amount,refund_orders,refund_amount,withdrawal_apply_orders,withdrawal_apply_amount,withdrawal_paid_orders,withdrawal_paid_amount,withdrawal_failed_orders,withdrawal_failed_amount,content_created,like_actions,favorite_actions,comment_count")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CreatorTestSuite) Test_ListAuditLogs() {
|
||||
Convey("ListAuditLogs", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(ctx, s.DB,
|
||||
models.TableNameAuditLog,
|
||||
models.TableNameTenant,
|
||||
models.TableNameUser,
|
||||
)
|
||||
|
||||
owner := &models.User{Username: "owner_audit", Phone: "13900001013"}
|
||||
operator := &models.User{Username: "operator_audit", Phone: "13900001014"}
|
||||
outsider := &models.User{Username: "outsider_audit", Phone: "13900001015"}
|
||||
models.UserQuery.WithContext(ctx).Create(owner, operator, outsider)
|
||||
|
||||
tenantA := &models.Tenant{
|
||||
Name: "Tenant Audit A",
|
||||
Code: "tenant_audit_a",
|
||||
UserID: owner.ID,
|
||||
Status: consts.TenantStatusVerified,
|
||||
}
|
||||
tenantB := &models.Tenant{
|
||||
Name: "Tenant Audit B",
|
||||
Code: "tenant_audit_b",
|
||||
UserID: operator.ID,
|
||||
Status: consts.TenantStatusVerified,
|
||||
}
|
||||
models.TenantQuery.WithContext(ctx).Create(tenantA, tenantB)
|
||||
|
||||
now := time.Now()
|
||||
models.AuditLogQuery.WithContext(ctx).Create(
|
||||
&models.AuditLog{
|
||||
TenantID: tenantA.ID,
|
||||
OperatorID: owner.ID,
|
||||
Action: "update_settings",
|
||||
TargetID: "setting_1",
|
||||
Detail: "更新频道配置",
|
||||
CreatedAt: now.Add(-1 * time.Hour),
|
||||
},
|
||||
&models.AuditLog{
|
||||
TenantID: tenantA.ID,
|
||||
OperatorID: operator.ID,
|
||||
Action: "invite_member",
|
||||
TargetID: "member_1",
|
||||
Detail: "邀请成员",
|
||||
CreatedAt: now,
|
||||
},
|
||||
&models.AuditLog{
|
||||
TenantID: tenantB.ID,
|
||||
OperatorID: operator.ID,
|
||||
Action: "update_settings",
|
||||
TargetID: "setting_999",
|
||||
Detail: "跨租户数据",
|
||||
CreatedAt: now,
|
||||
},
|
||||
)
|
||||
|
||||
pager, err := Creator.ListAuditLogs(ctx, tenantA.ID, owner.ID, &creator_dto.CreatorAuditLogListFilter{})
|
||||
So(err, ShouldBeNil)
|
||||
So(pager.Total, ShouldEqual, 2)
|
||||
|
||||
items, ok := pager.Items.([]creator_dto.CreatorAuditLogItem)
|
||||
So(ok, ShouldBeTrue)
|
||||
So(len(items), ShouldEqual, 2)
|
||||
So(items[0].TargetID, ShouldEqual, "member_1")
|
||||
So(items[1].TargetID, ShouldEqual, "setting_1")
|
||||
|
||||
operatorName := "operator_audit"
|
||||
filtered, err := Creator.ListAuditLogs(ctx, tenantA.ID, owner.ID, &creator_dto.CreatorAuditLogListFilter{
|
||||
OperatorName: &operatorName,
|
||||
})
|
||||
So(err, ShouldBeNil)
|
||||
So(filtered.Total, ShouldEqual, 1)
|
||||
filteredItems, ok := filtered.Items.([]creator_dto.CreatorAuditLogItem)
|
||||
So(ok, ShouldBeTrue)
|
||||
So(len(filteredItems), ShouldEqual, 1)
|
||||
So(filteredItems[0].Action, ShouldEqual, "invite_member")
|
||||
So(filteredItems[0].OperatorName, ShouldEqual, "operator_audit")
|
||||
|
||||
_, err = Creator.ListAuditLogs(ctx, tenantA.ID, outsider.ID, &creator_dto.CreatorAuditLogListFilter{})
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"quyun/v2/app/commands/testx"
|
||||
"quyun/v2/app/errorx"
|
||||
order_dto "quyun/v2/app/http/v1/dto"
|
||||
"quyun/v2/database"
|
||||
"quyun/v2/database/models"
|
||||
@@ -168,3 +170,74 @@ func (s *OrderTestSuite) Test_PlatformCommission() {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OrderTestSuite) Test_Pay_DenyCrossTenantOrder() {
|
||||
Convey("Pay should deny cross-tenant order", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(
|
||||
ctx,
|
||||
s.DB,
|
||||
models.TableNameUser,
|
||||
models.TableNameTenant,
|
||||
models.TableNameOrder,
|
||||
)
|
||||
|
||||
buyer := &models.User{Username: "buyer_cross_tenant", Balance: 5000}
|
||||
So(models.UserQuery.WithContext(ctx).Create(buyer), ShouldBeNil)
|
||||
|
||||
tenantA := &models.Tenant{UserID: buyer.ID, Code: "order_pay_cross_a", Name: "Tenant A", Status: consts.TenantStatusVerified}
|
||||
tenantB := &models.Tenant{UserID: buyer.ID, Code: "order_pay_cross_b", Name: "Tenant B", Status: consts.TenantStatusVerified}
|
||||
So(models.TenantQuery.WithContext(ctx).Create(tenantA, tenantB), ShouldBeNil)
|
||||
|
||||
order := &models.Order{
|
||||
TenantID: tenantA.ID,
|
||||
UserID: buyer.ID,
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusCreated,
|
||||
AmountPaid: 1000,
|
||||
}
|
||||
So(models.OrderQuery.WithContext(ctx).Create(order), ShouldBeNil)
|
||||
|
||||
_, err := Order.Pay(ctx, tenantB.ID, buyer.ID, order.ID, &order_dto.OrderPayForm{Method: "balance"})
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
var appErr *errorx.AppError
|
||||
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||
So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OrderTestSuite) Test_Status_DenyCrossTenantOrder() {
|
||||
Convey("Status should deny cross-tenant order", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(
|
||||
ctx,
|
||||
s.DB,
|
||||
models.TableNameUser,
|
||||
models.TableNameTenant,
|
||||
models.TableNameOrder,
|
||||
)
|
||||
|
||||
buyer := &models.User{Username: "buyer_status_cross", Balance: 5000}
|
||||
So(models.UserQuery.WithContext(ctx).Create(buyer), ShouldBeNil)
|
||||
|
||||
tenantA := &models.Tenant{UserID: buyer.ID, Code: "order_status_cross_a", Name: "Tenant A", Status: consts.TenantStatusVerified}
|
||||
tenantB := &models.Tenant{UserID: buyer.ID, Code: "order_status_cross_b", Name: "Tenant B", Status: consts.TenantStatusVerified}
|
||||
So(models.TenantQuery.WithContext(ctx).Create(tenantA, tenantB), ShouldBeNil)
|
||||
|
||||
order := &models.Order{
|
||||
TenantID: tenantA.ID,
|
||||
UserID: buyer.ID,
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusCreated,
|
||||
}
|
||||
So(models.OrderQuery.WithContext(ctx).Create(order), ShouldBeNil)
|
||||
|
||||
_, err := Order.Status(ctx, tenantB.ID, buyer.ID, order.ID)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
var appErr *errorx.AppError
|
||||
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||
So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,6 +216,27 @@ func (s *TenantTestSuite) Test_ReviewJoin() {
|
||||
So(err, ShouldBeNil)
|
||||
So(req.Status, ShouldEqual, string(consts.TenantJoinRequestStatusRejected))
|
||||
})
|
||||
|
||||
Convey("should deny review when request belongs to another tenant", func() {
|
||||
tenantA, _, _, reqA := setup()
|
||||
|
||||
ownerB := &models.User{Username: "owner_review_cross_b", Phone: "13900009991"}
|
||||
So(models.UserQuery.WithContext(ctx).Create(ownerB), ShouldBeNil)
|
||||
tenantB := &models.Tenant{Name: "Tenant Review B", Code: "tenant_review_cross_b", UserID: ownerB.ID, Status: consts.TenantStatusVerified}
|
||||
So(models.TenantQuery.WithContext(ctx).Create(tenantB), ShouldBeNil)
|
||||
|
||||
err := Tenant.ReviewJoin(ctx, tenantB.ID, ownerB.ID, reqA.ID, &tenant_dto.TenantJoinReviewForm{Action: "approve"})
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
var appErr *errorx.AppError
|
||||
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||
So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code)
|
||||
|
||||
reqReload, err := models.TenantJoinRequestQuery.WithContext(ctx).Where(models.TenantJoinRequestQuery.ID.Eq(reqA.ID)).First()
|
||||
So(err, ShouldBeNil)
|
||||
So(reqReload.TenantID, ShouldEqual, tenantA.ID)
|
||||
So(reqReload.Status, ShouldEqual, string(consts.TenantJoinRequestStatusPending))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -352,54 +373,132 @@ func (s *TenantTestSuite) Test_ListMembersAndRemove() {
|
||||
So(err, ShouldBeNil)
|
||||
So(exists, ShouldBeFalse)
|
||||
})
|
||||
|
||||
Convey("should deny removing member from another tenant", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(ctx, s.DB,
|
||||
models.TableNameTenantUser,
|
||||
models.TableNameTenant,
|
||||
models.TableNameUser,
|
||||
)
|
||||
|
||||
ownerA := &models.User{Username: "owner_remove_a", Phone: "13900007771"}
|
||||
ownerB := &models.User{Username: "owner_remove_b", Phone: "13900007772"}
|
||||
memberA := &models.User{Username: "member_remove_a", Phone: "13900007773"}
|
||||
So(models.UserQuery.WithContext(ctx).Create(ownerA, ownerB, memberA), ShouldBeNil)
|
||||
|
||||
tenantA := &models.Tenant{Name: "Tenant Remove A", Code: "tenant_remove_cross_a", UserID: ownerA.ID, Status: consts.TenantStatusVerified}
|
||||
tenantB := &models.Tenant{Name: "Tenant Remove B", Code: "tenant_remove_cross_b", UserID: ownerB.ID, Status: consts.TenantStatusVerified}
|
||||
So(models.TenantQuery.WithContext(ctx).Create(tenantA, tenantB), ShouldBeNil)
|
||||
|
||||
memberLinkA := &models.TenantUser{
|
||||
TenantID: tenantA.ID,
|
||||
UserID: memberA.ID,
|
||||
Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember},
|
||||
Status: consts.UserStatusVerified,
|
||||
}
|
||||
So(models.TenantUserQuery.WithContext(ctx).Create(memberLinkA), ShouldBeNil)
|
||||
|
||||
err := Tenant.RemoveMember(ctx, tenantB.ID, ownerB.ID, memberLinkA.ID)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
var appErr *errorx.AppError
|
||||
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||
So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code)
|
||||
|
||||
exists, err := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.ID.Eq(memberLinkA.ID)).Exists()
|
||||
So(err, ShouldBeNil)
|
||||
So(exists, ShouldBeTrue)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TenantTestSuite) Test_ListInvitesAndDisable() {
|
||||
Convey("ListInvites and DisableInvite", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
|
||||
database.Truncate(ctx, s.DB,
|
||||
models.TableNameTenantInvite,
|
||||
models.TableNameTenant,
|
||||
models.TableNameUser,
|
||||
)
|
||||
Convey("should list and disable invite in same tenant", func() {
|
||||
database.Truncate(ctx, s.DB,
|
||||
models.TableNameTenantInvite,
|
||||
models.TableNameTenant,
|
||||
models.TableNameUser,
|
||||
)
|
||||
|
||||
owner := &models.User{Username: "owner_invite", Phone: "13900002003"}
|
||||
_ = models.UserQuery.WithContext(ctx).Create(owner)
|
||||
owner := &models.User{Username: "owner_invite", Phone: "13900002003"}
|
||||
_ = models.UserQuery.WithContext(ctx).Create(owner)
|
||||
|
||||
tenant := &models.Tenant{
|
||||
Name: "Tenant Invite",
|
||||
UserID: owner.ID,
|
||||
Status: consts.TenantStatusVerified,
|
||||
}
|
||||
_ = models.TenantQuery.WithContext(ctx).Create(tenant)
|
||||
tenant := &models.Tenant{
|
||||
Name: "Tenant Invite",
|
||||
UserID: owner.ID,
|
||||
Status: consts.TenantStatusVerified,
|
||||
}
|
||||
_ = models.TenantQuery.WithContext(ctx).Create(tenant)
|
||||
|
||||
invite := &models.TenantInvite{
|
||||
TenantID: tenant.ID,
|
||||
UserID: owner.ID,
|
||||
Code: "invite_list",
|
||||
Status: string(consts.TenantInviteStatusActive),
|
||||
MaxUses: 2,
|
||||
UsedCount: 0,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
Remark: "测试邀请",
|
||||
}
|
||||
_ = models.TenantInviteQuery.WithContext(ctx).Create(invite)
|
||||
invite := &models.TenantInvite{
|
||||
TenantID: tenant.ID,
|
||||
UserID: owner.ID,
|
||||
Code: "invite_list",
|
||||
Status: string(consts.TenantInviteStatusActive),
|
||||
MaxUses: 2,
|
||||
UsedCount: 0,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
Remark: "测试邀请",
|
||||
}
|
||||
_ = models.TenantInviteQuery.WithContext(ctx).Create(invite)
|
||||
|
||||
res, err := Tenant.ListInvites(ctx, tenant.ID, owner.ID, &tenant_dto.TenantInviteListFilter{
|
||||
Pagination: requests.Pagination{Page: 1, Limit: 10},
|
||||
res, err := Tenant.ListInvites(ctx, tenant.ID, owner.ID, &tenant_dto.TenantInviteListFilter{
|
||||
Pagination: requests.Pagination{Page: 1, Limit: 10},
|
||||
})
|
||||
So(err, ShouldBeNil)
|
||||
So(res.Total, ShouldEqual, 1)
|
||||
|
||||
err = Tenant.DisableInvite(ctx, tenant.ID, owner.ID, invite.ID)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
updated, err := models.TenantInviteQuery.WithContext(ctx).
|
||||
Where(models.TenantInviteQuery.ID.Eq(invite.ID)).
|
||||
First()
|
||||
So(err, ShouldBeNil)
|
||||
So(updated.Status, ShouldEqual, string(consts.TenantInviteStatusDisabled))
|
||||
})
|
||||
So(err, ShouldBeNil)
|
||||
So(res.Total, ShouldEqual, 1)
|
||||
|
||||
err = Tenant.DisableInvite(ctx, tenant.ID, owner.ID, invite.ID)
|
||||
So(err, ShouldBeNil)
|
||||
Convey("should deny disabling invite from another tenant", func() {
|
||||
database.Truncate(ctx, s.DB,
|
||||
models.TableNameTenantInvite,
|
||||
models.TableNameTenant,
|
||||
models.TableNameUser,
|
||||
)
|
||||
|
||||
updated, err := models.TenantInviteQuery.WithContext(ctx).
|
||||
Where(models.TenantInviteQuery.ID.Eq(invite.ID)).
|
||||
First()
|
||||
So(err, ShouldBeNil)
|
||||
So(updated.Status, ShouldEqual, string(consts.TenantInviteStatusDisabled))
|
||||
ownerA := &models.User{Username: "owner_invite_a", Phone: "13900008881"}
|
||||
ownerB := &models.User{Username: "owner_invite_b", Phone: "13900008882"}
|
||||
So(models.UserQuery.WithContext(ctx).Create(ownerA, ownerB), ShouldBeNil)
|
||||
|
||||
tenantA := &models.Tenant{Name: "Tenant Invite A", Code: "tenant_invite_cross_a", UserID: ownerA.ID, Status: consts.TenantStatusVerified}
|
||||
tenantB := &models.Tenant{Name: "Tenant Invite B", Code: "tenant_invite_cross_b", UserID: ownerB.ID, Status: consts.TenantStatusVerified}
|
||||
So(models.TenantQuery.WithContext(ctx).Create(tenantA, tenantB), ShouldBeNil)
|
||||
|
||||
inviteA := &models.TenantInvite{
|
||||
TenantID: tenantA.ID,
|
||||
UserID: ownerA.ID,
|
||||
Code: "invite_cross_disable",
|
||||
Status: string(consts.TenantInviteStatusActive),
|
||||
MaxUses: 2,
|
||||
UsedCount: 0,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
Remark: "跨租户禁用测试",
|
||||
}
|
||||
So(models.TenantInviteQuery.WithContext(ctx).Create(inviteA), ShouldBeNil)
|
||||
|
||||
err := Tenant.DisableInvite(ctx, tenantB.ID, ownerB.ID, inviteA.ID)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
var appErr *errorx.AppError
|
||||
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||
So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code)
|
||||
|
||||
reloaded, err := models.TenantInviteQuery.WithContext(ctx).Where(models.TenantInviteQuery.ID.Eq(inviteA.ID)).First()
|
||||
So(err, ShouldBeNil)
|
||||
So(reloaded.Status, ShouldEqual, string(consts.TenantInviteStatusActive))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user