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:
2026-02-09 06:54:04 +08:00
parent 3126ed5e64
commit 05a0d07dbb
23 changed files with 7205 additions and 112 deletions

View File

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

View 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"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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