Compare commits

..

2 Commits

Author SHA1 Message Date
05a0d07dbb 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>
2026-02-09 06:54:04 +08:00
3126ed5e64 chore: refine production-readiness execution plan
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-08 19:47:28 +08:00
24 changed files with 7434 additions and 191 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))
})
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,64 +1,98 @@
# Implementation Plan: v1 Creator 路由恢复与闭环验证
# Implementation Plan: 生产部署能力补齐(数据源统一、隔离强化、双侧审计)
**Branch**: `[current-working-branch]` | **Date**: 2026-02-07 | **Spec**: 会话需求(修复 `/v1/t/:tenantCode/creator/*` 404
**Input**: 用户需求:开始修复 Creator 路由缺失问题,恢复 Portal 创作者中心 API 可用性并完成回归验证。
**Branch**: `[prod-readiness-hardening]` | **Date**: 2026-02-08 | **Spec**: 会话需求(评估并补齐生产部署能力
**Input**: 用户要求按 4 项标准补齐生产部署能力:
1) 前端数据来源后端接口/渲染;
2) 用户/租户数据隔离完备;
3) 超级管理员后台可审计;
4) 租户管理侧可审计。
## Summary
当前 `backend/app/http/v1/creator.go` 仅保留 `GrantCoupon` 方法,但注释块包含大量与该方法签名不匹配的 `@Router` 声明,导致 `atomctl gen route` 未生成任何 `/v1/t/:tenantCode/creator/*` 路由。计划将按“前端真实调用路径 -> 后端服务能力 -> 控制器显式方法”逐项恢复最小改动修复随后通过路由生成、Go 测试与关键 API 冒烟验证闭环。
当前评估结果为:
- #1 前端数据来源:未完全达标(存在硬编码业务数据页面);
- #2 用户/租户隔离:基础机制已具备,但依赖服务层手工加租户条件,仍有遗漏风险;
- #3 超管审计已具备audit_logs 表 + 超管查询页面/API
- #4 租户侧审计:未达标(仅有 audit 类通知,不等同审计日志查询能力)。
本计划目标是在最小风险下将以上 4 项全部提升为可上线状态:
- 去除生产路径硬编码业务数据;
- 强化多租户隔离“可证明性”(代码约束 + 负向测试);
- 保持并补强超管审计覆盖;
- 增加租户管理侧可审计能力(租户范围审计日志查询 API + 页面);
- 建立可审签发布证据链route/swagger/test/UI flow/evidence/archive
## Technical Context
**Language/Version**:
- Backend: Go (Fiber + GORM-Gen)
- Frontend reference: Vue 3仅用于接口映射不改前端
- Backend: GoFiber + GORM-Gen
- Frontend: Vue 3 + Viteportal/superadmin
**Primary Dependencies**:
- `atomctl gen route`(路由生成)
- `backend/app/services`Creator/Tenant/Coupon 服务)
- `backend/app/http/v1/dto`Creator 与 TenantMember DTO
- Backend: `backend/app/http/v1`, `backend/app/http/super/v1`, `backend/app/services/*`, `backend/database/models/*`
- Frontend: `frontend/portal/src/views/*`, `frontend/portal/src/api/*`, `frontend/superadmin/src/views/superadmin/*`, `frontend/superadmin/src/service/*`
- Generators: `atomctl gen route`, `atomctl swag init`
**Storage**: PostgreSQL使用现有 schema 与 seed 数据)
**Storage**:
- PostgreSQL`audit_logs` 已存在,必要时补索引/查询优化)
**Testing**:
- `cd backend && env GOCACHE=$PWD/.gocache GOTMPDIR=$PWD/.gotmp go test ./...`
- API 冒烟:`/v1/t/:tenantCode/creator/orders` 等关键路径不再 404
- Backend: `cd backend && env GOCACHE=$PWD/.gocache GOTMPDIR=$PWD/.gotmp go test ./...`
- Frontend Build/Lint:
- `npm -C frontend/portal run build`
- `npm -C frontend/portal run lint`
- `npm -C frontend/superadmin run build`
- `npm -C frontend/superadmin run lint`
- Frontend功能验收页面流:
- 超管审计日志查询流
- 租户管理侧审计日志查询流
- 用户“我的点赞/收藏/订单”等后端数据回填流
**Target Platform**: Linux 本地环境backend: `127.0.0.1:18080`
**Target Platform**:
- Linux 部署环境Web + API
**Project Type**: Web backend API
**Project Type**:
- Web applicationfrontend + backend
**Performance Goals**:
- 本次以功能恢复为目标,不新增性能指标
- 审计日志列表接口在常规分页20条下 p95 <= 300ms预发基准
- 不引入明显回归(核心列表接口不劣化)
**Performance Measurement Protocol**:
- 测量接口:`/super/v1/audit-logs` 与新增租户审计列表接口。
- 测量条件:`page=1&limit=20`,默认排序(`created_at desc`),不带 keyword。
- 样本规则:预热 10 次后连续采样 50 次,统计 p95。
- 证据输出:写入 `docs/release-evidence/<date>.md` 的“性能基线”小节。
**Constraints**:
- 不手改任何 `*.gen.go`
- 控制器保持薄层(参数绑定 -> services.* -> 返回
- 路由参数使用 `camelCase`,数值 path 参数使用 `:id<int>`
- 仅做最小修复,不做与问题无关重构
- 禁止手改 `*.gen.go`(路由/文档生成文件仅通过工具生成)
- 控制器保持薄层(bind -> services -> return
- 使用 `as any` / `@ts-ignore` / `@ts-expect-error`
- 缺陷修复最小化,不做无关重构
**Scale/Scope**:
- 仅修复 `backend/app/http/v1/creator.go` 路由缺失问题
- 影响生成文件:`backend/app/http/v1/routes.gen.go`(通过生成器更新
- 更新回归记录:`docs/test-matrix.md`
- 覆盖 portal + superadmin + backend v1/super v1 审计与隔离相关模块
- 覆盖上线阻塞项P0与并行优化项P1
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
-控制器薄层:方法将仅负责绑定和调用 service
- ✅ 生成文件规范:仅通过 `atomctl gen route` 更新路由生成文件。
-事务与数据访问边界:不在 controller 做任何 DB 操作。
-验证要求:包含 `go test ./...` + 关键页面流 API 冒烟证据。
-符合后端分层Controller 不直接做 DAO CRUD统一走 `services.*`
-符合生成文件约束:路由与 Swagger 仅通过 `atomctl` 生成
-符合多租户前缀规范:`/v1/t/:tenantCode/*`
-覆盖前后端联调验收要求:前端页面流 + `go test ./...`
- ✅ 计划包含任务拆解、依赖、验收标准、风险项、责任归属与里程碑
## Project Structure
### Documentation (this feature)
### Documentation (this phase)
```text
docs/
├── plan.md # 当前计划(本文件)
── test-matrix.md # 回归记录更新
├── plan.md # 当前执行计划(本文件)
── release-evidence/<date>.md # 验收证据模板与执行结果
└── plans/<date>.md # 阶段完成归档
```
### Source Code (repository root)
@@ -66,79 +100,195 @@ docs/
```text
backend/
├── app/http/v1/
│ ├── creator.go # 本次主要修复文件
── routes.gen.go # 由 atomctl 生成
├── app/http/v1/dto/
│ ├── routes.manual.go
── routes.gen.go # 生成文件(勿手改)
│ ├── user.go
│ ├── creator.go
── creator_report.go
│ └── tenant_member.go
└── app/services/
├── creator.go
── creator_report.go
├── tenant_member.go
── coupon.go
── (new) audit_logs.go # 若新增租户侧审计接口
├── app/http/super/v1/
│ ├── audit_logs.go
├── routes.manual.go
── routes.gen.go # 生成文件(勿手改)
├── app/services/
── audit.go
│ ├── order.go
│ ├── content.go
│ ├── coupon.go
│ ├── tenant*.go
│ └── super.go
└── docs/
├── docs.go # 生成文件(勿手改)
├── swagger.yaml # 生成文件(勿手改)
└── swagger.json # 生成文件(勿手改)
frontend/portal/
── src/api/creator.js # 仅用于接口映射核对
frontend/
── portal/
│ └── src/
│ ├── api/
│ ├── router/index.js
│ └── views/
└── superadmin/
└── src/
├── router/index.js
├── service/
└── views/superadmin/
```
**Structure Decision**: 使用现有后端 v1 模块结构,控制器集中恢复,服务层复用现有能力,不新增新模块
**Structure Decision**: 在现有目录内增量修复,不新增独立子项目;优先复用现有 service 与 DTO确保变更可控
## Plan Phases
### Phase 1 — 路由映射与控制器设计
- 依据 `frontend/portal/src/api/creator.js` 提取全部 `/creator/*` 调用
- 将调用映射到现有 service 方法Creator/Tenant/Coupon
- 为每个 endpoint 设计独立 controller 方法与准确 `@Router/@Bind`
### Phase 1 — 前端数据源统一P0
目标:满足“前端业务数据必须来自后端接口/渲染”
- 基于 router 生成“生产页面 -> 数据来源(API/mock/static)”清单
- 替换生产路径中的硬编码业务数据示例Portal 用户侧业务列表)
- 处理 Superadmin demo 数据风险:默认禁入生产菜单/路由(必要时移出生产构建入口)。
### Phase 2 — 控制器实现与路由生成
- 重写 `backend/app/http/v1/creator.go`:一条路由一个方法,去除“多路由堆在单方法注释”反模式
- 执行 `atomctl gen route`,确认 `routes.gen.go` 产出完整 `/v1/t/:tenantCode/creator/*` 注册项
### Phase 2 — 用户/租户隔离强化P0
目标:把“依赖人工自觉加条件”改为“可验证、可回归”
- 梳理关键服务order/content/coupon/tenant/wallet过滤点
- 对高风险路径补足 query-time tenant/user 约束。
- 增加跨租户越权负向测试并形成隔离矩阵证据。
### Phase 3 — 回归验证与文档更新
- 运行 `go test ./...`
- 用 token 对至少以下接口冒烟:
- `GET /v1/t/:tenantCode/creator/orders`
- `GET /v1/t/:tenantCode/creator/contents`
- `GET /v1/t/:tenantCode/creator/settings`
- 将修复结果与证据补充到 `docs/test-matrix.md`
### Phase 3 — 审计能力补齐(超管 + 租户P0
目标:满足 #3/#4 审计要求
- 超管侧:盘点并补齐关键操作 `Audit.Log` 覆盖。
- 租户侧:新增租户审计查询 API + 页面,附角色权限控制。
- 完成 route 生成与 Swagger 生成,确保接口可见且可调试。
### Phase 4 — 验证、证据与发布门禁P0
目标:形成可审签的上线证据。
- 执行 backend 全量测试与双前端 build/lint。
- 执行前端页面流验收并按模板记录证据。
- 输出发布门禁检查单Pass/Fail + 证据路径)。
- 阶段通过后归档 `docs/plan.md``docs/plans/<date>.md`,并清空 `docs/plan.md`
## Tasks
- [ ] T1 `frontend/portal/src/api/creator.js` 提取 endpoint 清单并建立 service 映射
- [ ] T2 `backend/app/http/v1/creator.go` 实现 creator 核心接口apply/dashboard/contents/orders/settings/payout/withdraw
- [ ] T3 `backend/app/http/v1/creator.go` 实现成员与邀请相关接口members/invites/join-requests/review
- [ ] T4 `backend/app/http/v1/creator.go` 实现优惠券相关接口list/get/create/update/grant
- [ ] T5 `backend/app/http/v1/creator.go` 实现报表相关接口reports/overview 与 reports/export
- [ ] T6 执行 `atomctl gen route` 并确认 `/creator/*` 路由已注册
- [ ] T7 执行 `go test ./...`,修复由本次改动引入的问题
- [ ] T8 执行 Creator 关键接口冒烟并记录结果
- [ ] T9 更新 `docs/test-matrix.md` 记录 Creator 修复闭环结果
- [ ] T1 建立“生产部署能力差距台账”(含 router->页面->数据来源盘点)
- [ ] T2 替换 Portal 生产路径中的硬编码业务数据来源
- [ ] T3 下线或隔离 Superadmin demo 数据路由(防止进入生产流量路径)
- [ ] T4 建立后端隔离审计清单order/content/coupon/tenant/wallet
- [ ] T5 对高风险服务补充 tenant/user query-time 约束(最小改动)
- [ ] T6 增加跨租户越权测试用例并纳入回归
- [ ] T7 盘点超管关键操作审计埋点,补齐缺失 `Audit.Log` 调用
- [ ] T8 设计并实现租户侧审计日志查询 API租户范围、权限受控
- [ ] T9 执行 `cd backend && atomctl gen route` 并校验新增路由注册
- [ ] T10 实现租户管理侧审计页面(筛选、分页、时间范围、动作类型)。
- [ ] T11 补充审计相关 API 注释与字段定义operator/action/target/detail
- [ ] T12 执行 `cd backend && atomctl swag init` 并校验 Swagger 产物更新。
- [ ] T13 执行 backend 全量测试 `go test ./...`
- [ ] T14 执行 portal/superadmin build + lint。
- [ ] T15 创建并冻结验收证据模板:`docs/release-evidence/<date>.md`
- [ ] T16 执行前端页面流验收并填充证据模板(含截图/日志/结论)。
- [ ] T17 汇总发布门禁清单并形成 Go/No-Go 结论。
- [ ] T18 阶段完成后归档 `docs/plan.md``docs/plans/<date>.md`,并清空 `docs/plan.md`
## Dependencies
- T1 -> T2/T3/T4/T5(先映射后编码
- T2/T3/T4/T5 -> T6代码落地后生成路由
- T6 -> T7/T8先确认路由注册再跑测试与冒烟
- T7/T8 -> T9测试证据写入文档
- T1 -> T2/T3/T4/T7(先冻结缺口,再实施
- T4 -> T5 -> T6 -> T13隔离设计先行测试跟进
- T7 -> T8 -> T9 -> T10先有后端能力与路由再接前端
- T8 -> T11 -> T12接口定义后再生成 Swagger
- T2/T3/T10 -> T14/T16前端改造完成后再构建与页面验收
- T15 -> T16 -> T17证据模板先行
- T9/T12/T13/T14/T16 -> T17发布结论依赖完整证据链
- T17(Go) -> T18仅 Go 才归档No-Go 不归档)
## Owner Roles (RACI-lite)
> 说明:每个任务必须有唯一 DRIDirectly Responsible Individual与一个 Approver。
| Role | 主要职责 |
|------|----------|
| Tech Lead (Overall) | 统筹范围、顺序、风险与里程碑审签 |
| Backend Lead | 隔离强化、审计后端能力、生成流程与后端验证 |
| Frontend Portal Owner | Portal/租户侧页面改造与联调 |
| Frontend Superadmin Owner | Superadmin 生产路径治理与联调 |
| QA Owner | 回归用例、页面流验收、证据完整性审查 |
| Release Owner | 发布门禁、Go/No-Go 会议与归档执行 |
## Task Accountability (DRI / Approver)
| Task | DRI | Approver |
|------|-----|----------|
| T1 | Tech Lead | QA Owner |
| T2 | Frontend Portal Owner | Tech Lead |
| T3 | Frontend Superadmin Owner | Tech Lead |
| T4 | Backend Lead | Tech Lead |
| T5 | Backend Lead | QA Owner |
| T6 | QA Owner | Backend Lead |
| T7 | Backend Lead | Tech Lead |
| T8 | Backend Lead | Tech Lead |
| T9 | Backend Lead | Tech Lead |
| T10 | Frontend Portal Owner | QA Owner |
| T11 | Backend Lead | Tech Lead |
| T12 | Backend Lead | Tech Lead |
| T13 | Backend Lead | QA Owner |
| T14 | Frontend Superadmin Owner | QA Owner |
| T15 | QA Owner | Release Owner |
| T16 | QA Owner | Release Owner |
| T17 | Release Owner | Tech Lead |
| T18 | Release Owner | Tech Lead |
## Execution Schedule (10 Working Days)
> 计划周期10个工作日。发生阻塞时顺延不压缩验证与证据阶段。
| Day | Focus | 任务 | Primary Owner | 协作方 | 当日退出标准 |
|-----|-------|------|---------------|--------|--------------|
| D1 | 基线与证据机制冻结 | T1, T4(启动), T15 | Tech Lead, Backend Lead, QA Owner | Release Owner | 差距台账冻结;隔离清单初版;证据模板落盘 |
| D2 | 前端数据源整改Portal | T2(Portal核心页面) | Frontend Portal Owner | Backend Lead | 生产路径硬编码业务数据改为 API 拉取 |
| D3 | 前端生产路径治理Superadmin | T2(收尾), T3 | Frontend Superadmin Owner | Tech Lead | demo 路由完成隔离且不进入生产入口 |
| D4 | 隔离约束补强(一) | T5(order/content) | Backend Lead | QA Owner | 关键查询改为 query-time 约束,完成自检 |
| D5 | 隔离约束补强(二)+ 负向测试 | T5(coupon/tenant/wallet), T6 | Backend Lead, QA Owner | Tech Lead | 跨租户负向测试覆盖核心场景并通过 |
| D6 | 审计覆盖补漏 + 租户审计API开发 | T7, T8(启动) | Backend Lead | Frontend Portal Owner | 审计补漏清单清零;租户审计 API 代码完成 |
| D7 | 路由生成与后端可调用性 | T9, T8(联调收口) | Backend Lead | Tech Lead | `atomctl gen route` 完成且新增路由注册可见 |
| D8 | 租户审计前端与接口文档定义 | T10, T11 | Frontend Portal Owner | Backend Lead, QA Owner | 租户审计页面接通后端;注释字段定义完成 |
| D9 | 文档生成 + 构建测试 | T12, T13, T14 | Backend Lead, Frontend Superadmin Owner | QA Owner | Swagger 产物更新go test 与双前端 build/lint 可复现 |
| D10 | 页面流验收与发布评审 | T16, T17, T18(条件触发) | QA Owner, Release Owner | 全员 | 页面流证据齐全Go/No-Go 结论明确Go 时完成归档与清空 |
## Milestones
- **M1 (D3 End): 前端生产数据源统一完成**
Exit Criteria: 生产路径无硬编码业务记录数据demo 路由已隔离。
- **M2 (D5 End): 多租户隔离强化完成**
Exit Criteria: 高风险服务完成 query-time 约束;跨租户负向测试通过。
- **M3 (D9 End): 双侧审计能力与生成链路完成**
Exit Criteria: 超管审计补漏完成;租户审计 API + 页面可用route/swagger 生成成功。
- **M4 (D10 End): 发布门禁与归档完成**
Exit Criteria: 测试、构建、页面流证据与门禁结论完整Go 时归档并清空活动 plan。
## Acceptance Criteria
1. `backend/app/http/v1/routes.gen.go` 中存在并注册 `/v1/t/:tenantCode/creator/*` 对应路由
2. `GET /v1/t/:tenantCode/creator/orders` 不再返回 404认证通过前提下
3. `go test ./...` 通过(若有历史失败,需明确标注非本次引入)
4. `docs/test-matrix.md` 新增 Creator 路由修复结果与可复现命令
1. 前端生产路径不再存在硬编码业务数据列表(允许 UI 常量,不允许业务记录数据常量)
2. 用户/租户隔离具备可回归证据:跨租户访问负向测试通过,关键接口均有 tenant/user 约束
3. 超级管理员后台审计可用:可按租户、操作者、动作、时间筛查审计日志
4. 租户管理侧审计可用:租户管理员可查询本租户审计日志,且无法查看其他租户数据
5. 新增/调整审计 API 后,必须完成 `atomctl gen route` 且路由注册可验证。
6. 新增/调整审计 API 后,必须完成 `atomctl swag init` 且 swagger 产物与接口一致。
7. 若涉及前端改动,必须完成页面流验收;同时完成 backend `go test ./...`
8. 发布门禁清单全部通过后方可标记“具备生产部署能力”;且需执行归档到 `docs/plans/<date>.md` 并清空 `docs/plan.md`
## Risks
- **DTO 不匹配风险**:部分接口需复用 `tenant_member` DTO可能出现绑定字段不一致。
- 缓解:按现有 service 签名逐项对齐 `@Bind`
- **风险1前端替换数据源引发页面空态/交互回归**
- 缓解:逐页替换 + 空态兜底 + 页面流测试
- **路由冲突风险**:新增 `/creator/*` 可能与其他路径发生顺序/匹配冲突。
- 缓解:依赖生成器产出并通过启动日志核对注册项
- **风险2隔离强化引发历史“宽查询”行为变化**
- 缓解:先补负向测试,再做最小修复,避免大规模重构
- **权限语义偏差风险**Controller 恢复后可能暴露 service 内已有“仅租户主”限制,导致与前端预期不一致。
- 缓解:先恢复 404 问题;权限语义差异单独记录为后续优化项
- **风险3审计日志增长导致查询性能下降**
- 缓解:按测量协议验证 p95必要时补复合索引并复测
- **风险4租户侧审计权限定义不清**
- 缓解实现前冻结角色矩阵owner/admin/member验收时执行越权测试。
- **风险5证据缺失导致 Go/No-Go 争议**
- 缓解:统一证据模板、固定路径、固定 DRI/Approver 审核链。
## Complexity Tracking

View File

@@ -0,0 +1,299 @@
# Release Evidence — 2026-02-08
## Scope
D1 基线执行(对应 `docs/plan.md`T1 / T4 / T15
- 生产部署能力差距台账4 项标准)
- 前端生产路由数据来源盘点Portal + Superadmin
- 后端隔离基线盘点order/content/coupon/tenant/wallet
## Environment
- Repo: `/home/rogee/Projects/quyun_v2`
- Branch: `main`
- Plan commit: `3126ed5` (`chore: refine production-readiness execution plan`)
## Evidence A — 4项标准差距台账Baseline)
| 标准 | 当前状态 | 结论 | 关键证据 |
|------|----------|------|----------|
| 1) 前端所有数据来源后端接口/渲染 | Portal/Superadmin 主业务页大多为 API存在硬编码业务数据页面与 demo mock 数据入口 | **未达标** | `frontend/portal/src/views/user/LikesView.vue:12-35`(硬编码 items`frontend/superadmin/src/views/uikit/TableDoc.vue:2,85-91` + `frontend/superadmin/src/service/CustomerService.js:2-39`mock数据源 |
| 2) 用户/租户数据隔离完备 | Controller 与 Service 多数传递 tenantID/userID 并做 where 约束;仍存在“依赖人工维护”的模式,需继续补负向测试 | **部分达标** | `backend/app/http/v1/helpers.go`tenant/user 上下文);`backend/app/services/order.go:31-39,64-73`; `backend/app/services/content.go:31-47`; `backend/app/services/coupon.go:162-175,237-239`; `backend/app/services/tenant_member.go:138-154`; `backend/app/services/wallet.go:35-43` |
| 3) 超级管理员后台可审计 | 超管审计链路已存在表、服务、API、页面 | **达标** | `backend/database/migrations/20260115103830_create_audit_logs_and_system_configs.sql:3-25`; `backend/app/http/super/v1/audit_logs.go:16-27`; `frontend/superadmin/src/views/superadmin/AuditLogs.vue:46-67` |
| 4) 租户管理对租户数据可审计 | 目前未见租户侧独立审计日志查询 API/页面(仅通知不等同审计日志) | **未达标** | `backend/app/http/v1` 未发现租户 audit-log 列表入口Portal 仅有通知页 `frontend/portal/src/views/user/NotificationsView.vue` |
## Evidence B — Portal 路由数据来源盘点(生产相关)
来源:`frontend/portal/src/router/index.js`
### B1. API 驱动为主(带少量 UI 常量)
- `/`, `/t/:tenantCode` -> `HomeView.vue`API + fallback 常量)
- `/t/:tenantCode/channel` -> `tenant/HomeView.vue`API + tab 常量)
- `/t/:tenantCode/contents/:id` -> `content/DetailView.vue`API
- `/t/:tenantCode/me/orders``/wallet``/coupons``/library``/favorites``/notifications``/profile`(以 `userApi` 为主)
- `/t/:tenantCode/creator/*`(以 `creatorApi` 为主)
- `/t/:tenantCode/checkout``/payment/:id``contentApi/orderApi`
### B2. 高风险(业务硬编码)
- `/t/:tenantCode/me/likes` -> `user/LikesView.vue`
- 证据:`items` 直接硬编码业务内容 `LikesView.vue:12-35`
### B3. 静态/演示型(非关键业务)
- `/creator/apply` -> `creator/ApplyView.vue`
- 证据:`setTimeout` 模拟提交,未接后端 `ApplyView.vue:42-47`
- `/t/:tenantCode/me/security` -> `user/SecurityView.vue`
- 证据:页面展示固定手机号/验证流程占位,未接后端 `SecurityView.vue:64,113,29`
## Evidence C — Superadmin 路由数据来源盘点
来源:`frontend/superadmin/src/router/index.js`
### C1. 生产业务路由(/superadmin/*
- 租户、用户、订单、内容、创作者、优惠券、财务、报表、资产、通知、审计日志、系统配置等页面均存在。
- 这些页面普遍是“API 查询 + 本地选项常量(筛选项)”混合模式。
### C2. 明确 demo/mock 数据入口(应隔离)
- `/uikit/table` -> `views/uikit/TableDoc.vue`
- 证据:`CustomerService.getCustomers*` `TableDoc.vue:85-91`
- 数据源:`CustomerService.js` 内置对象数组 `CustomerService.js:2-39`(后续大量同类对象)
## Evidence D — 后端隔离基线矩阵T4
| 模块 | 主要隔离实现 | 风险备注 | 证据 |
|------|--------------|----------|------|
| order | query-time 使用 tenantID/userID 条件;读取明细时 tenant + user 双条件 | Recharge 类型用 OR 放行(设计允许同用户跨租户查看充值记录),需确认业务预期 | `order.go:31-39,64-73,66-70` |
| content | 列表/详情普遍 tenant 过滤filter tenant mismatch 直接 forbidden | `UnderlyingDB` + preload 路径需持续关注遗漏风险 | `content.go:31-47,148-159,172-176` |
| coupon | Receive/Create/Update/Get/Validate/MarkUsed 多处 tenant 校验 | 存在 tenantID==0 分支(平台视角),需在接口层严格限定调用入口 | `coupon.go:162-175,237-239,306-308,657-658,720-721` |
| tenant (member) | 管理操作先 `ensureTenantAdmin`,再检查 request/invite 的 tenant 一致性 | 需用负向用例覆盖“跨租户 requestID/inviteID”场景 | `tenant_member.go:138-154,227-237,291-295,320-330` |
| wallet | 钱包交易列表使用 tenant+user 查询;支持充值订单特例 | 与订单同样存在 recharge 例外,需文档化并测试 | `wallet.go:35-43` |
## Performance Baseline Protocol (for later execution)
按计划定义,后续性能验证需满足:
- 目标接口:`/super/v1/audit-logs` + 新增租户审计列表
- 条件:`page=1&limit=20`,默认排序
- 样本预热10次 + 采样50次统计 p95
- 结果写入本文件后续“性能结果”小节
## D1 Exit Check
- [x] T1 差距台账已建立并含证据路径
- [x] T4 隔离基线矩阵已建立order/content/coupon/tenant/wallet
- [x] T15 证据模板文件已创建并写入 D1 结果
## Evidence E — T2 执行结果LikesView API化
### E1. 变更内容
- 文件:`frontend/portal/src/views/user/LikesView.vue`
- 改动要点:
- 删除硬编码 `items` 列表(原本本地静态业务记录)。
- 接入 `userApi.getLikes()` 拉取后端数据。
- 接入 `userApi.removeLike(id)` 处理取消点赞并同步本地列表。
- 复用与 Favorites/Library 一致的数据字段渲染:`title/cover/type/author_name/author_avatar/created_at`
### E2. 核验结果
- LSP目标文件: `lsp_diagnostics frontend/portal/src/views/user/LikesView.vue severity=error` -> **No diagnostics found**
- Portal lint: `npm -C frontend/portal run lint` -> **pass**
- Portal build: `npm -C frontend/portal run build` -> **pass**(包含 `LikesView-*.js` 构建产物)。
### E3. 状态更新
- 标准 #1(前端数据来源后端接口/渲染):
- `LikesView` 已从“未达标子项”移除。
- 剩余风险主要在 demo/doc 路由隔离(`/uikit/table`)与静态占位页策略。
## Evidence F — T3 执行结果Superadmin demo 路由隔离)
### F1. 变更内容
- 文件:`frontend/superadmin/src/router/index.js`
- 改动要点:
- 新增 `isDemoOnlyRoute(path)` 判定,覆盖:
- `/uikit/*`
- `/blocks`
- `/pages/empty`
- `/pages/crud`
- `/documentation`
- `/landing`
- 在全局 `beforeEach` 中加入生产环境拦截:
- `if (!import.meta.env.DEV && isDemoOnlyRoute(to.path)) return { name: 'dashboard' }`
- 效果:开发环境保留 demo 调试能力;非开发环境禁止通过 URL 直接进入 demo 页面。
### F2. 核验结果
- LSP目标文件: `frontend/superadmin/src/router/index.js` -> **No diagnostics found**
- Superadmin lint: `npm -C frontend/superadmin run lint` -> **pass**(首次执行出现一次临时文件 ENOENT重试通过
- Superadmin build: `npm -C frontend/superadmin run build` -> **pass**
### F3. 状态更新
- 标准 #1(前端生产数据来源约束)继续收敛:
- demo/mock 路由已从“可直接访问”改为“生产环境拦截”。
- 仍建议后续把 demo 路由按配置化开关进一步显式隔离(可选增强项)。
## Evidence G — T6 执行结果(跨租户负向测试补强)
### G1. 新增测试覆盖
#### Order
- 文件:`backend/app/services/order_test.go`
- 新增用例:
- `Test_Pay_DenyCrossTenantOrder`
- `Test_Status_DenyCrossTenantOrder`
- 断言目标:同一用户持有 A 租户订单时,用 B 租户上下文调用 `Pay/Status` 必须返回 `ErrForbidden`
#### Coupon
- 文件:`backend/app/services/coupon_test.go`
- 新增用例:
- `Test_Validate_DenyCrossTenantCoupon`
- `Test_MarkUsed_DenyCrossTenantCoupon`
- `Test_Grant_DenyCrossTenantCoupon`
- 断言目标:
- `Validate/MarkUsed` 跨租户必须拒绝(`ErrForbidden`
- `Grant` 使用非所属租户发放时必须失败且不产生 `user_coupons` 记录。
#### Tenant Member
- 文件:`backend/app/services/tenant_member_test.go`
- 新增用例:
- `Test_ReviewJoin` 中补充跨租户 review 拒绝场景
- `Test_ListMembersAndRemove` 中补充跨租户 remove 拒绝场景
- `Test_ListInvitesAndDisable` 中补充跨租户 disable 邀请拒绝场景
- 断言目标:跨租户操作返回 `ErrForbidden`,目标记录状态不应被修改。
### G2. 测试执行结果
- 命令(聚合):
- `cd backend && env GOCACHE=$PWD/.gocache GOTMPDIR=$PWD/.gotmp go test ./app/services -run 'Test_Order/(Test_Pay_DenyCrossTenantOrder|Test_Status_DenyCrossTenantOrder)|Test_Coupon/(Test_Validate_DenyCrossTenantCoupon|Test_MarkUsed_DenyCrossTenantCoupon|Test_Grant_DenyCrossTenantCoupon)|Test_Tenant/(Test_ReviewJoin|Test_ListMembersAndRemove|Test_ListInvitesAndDisable)'`
- 结果:**PASS**`ok quyun/v2/app/services`
### G3. 过程说明
- 首轮执行暴露新增测试数据问题(`tenants_code_key` 唯一约束冲突),已通过为新增租户测试数据设置唯一 `code` 修复。
- 修复后目标测试集稳定通过。
## Evidence H — T8 执行结果(租户侧审计日志 API
### H1. 变更内容
- 新增 DTO`backend/app/http/v1/dto/creator_audit.go`
- `CreatorAuditLogListFilter`:分页 + `operator_id/operator_name/action/target_id/keyword/created_at_from/created_at_to/asc/desc`
- `CreatorAuditLogItem``id/operator_id/operator_name/action/target_id/detail/created_at`
- 新增服务方法:`backend/app/services/creator.go`
- `Creator.ListAuditLogs(ctx, tenantID, userID, filter)`
- 强制租户范围:`audit_logs.tenant_id = currentTenantID`
- 权限校验:复用 `Tenant.ensureTenantAdmin`,仅租户主账号/tenant_admin 可查看
- 过滤/排序/分页:对齐 super audit 风格(支持 `id/created_at` 排序)
- 操作者名补齐:批量查询 user 表回填 `operator_name`
- DB 错误统一 `errorx.ErrDatabaseError.WithCause(err)` 包装
- 新增控制器接口:`backend/app/http/v1/creator.go`
- `GET /v1/t/:tenantCode/creator/audit-logs`
- 控制器仅做 bind + tenant/user 上下文提取 + service 调用
- 路由/文档生成:
- `atomctl gen route`
- `atomctl swag init`
- 生成结果包含:
- `backend/app/http/v1/routes.gen.go` 新路由注册
- `backend/docs/swagger.yaml|swagger.json|docs.go` 新接口与模型
### H2. 测试与核验
- 新增测试:`backend/app/services/creator_test.go`
- `Test_ListAuditLogs`
- 覆盖点:
- 仅返回当前租户日志(跨租户数据不泄露)
- `operator_name` 过滤生效
- 非管理员访问拒绝
- 执行结果:
- `go test ./app/services -run 'Test_Creator/(Test_ListAuditLogs|Test_ReportOverview|Test_ExportReport)$'` -> **PASS**
- `go test ./app/http/v1 ./app/services` -> **PASS**
- `go test ./...`backend-> **PASS**
- LSP本次变更文件: **No diagnostics found**
## Evidence I — T9 执行结果Portal 创作者审计页面)
### I1. 变更内容
- API 封装:`frontend/portal/src/api/creator.js`
- 新增 `listAuditLogs(params)` -> `/creator/audit-logs`
- 新增页面:`frontend/portal/src/views/creator/AuditView.vue`
- 筛选:`operator_id/operator_name/action/target_id/keyword/created_at_from/created_at_to`
- 排序:`created_at|id` + 升降序
- 列表展示日志ID、操作者、动作、目标ID、详情、创建时间
- 分页PrimeVue `Paginator`
- 路由注册:`frontend/portal/src/router/index.js`
- 新增 `creator-audit`,路径 `creator/audit`
- 侧边菜单:`frontend/portal/src/layout/LayoutCreator.vue`
- 新增“操作审计”入口,链接 `tenantRoute('/creator/audit')`
### I2. 核验结果
- Portal lint: `npm -C frontend/portal run lint` -> **pass**
- Portal build: `npm -C frontend/portal run build` -> **pass**(产物含 `AuditView-*.js`
- LSP本次前端变更文件: **No diagnostics found**
## Status Update
- 标准 #4(租户管理侧可审计):
- 已具备租户侧审计查询 API + Portal 页面入口与展示能力
- 当前状态:**达标(待后续前后端联调回归统一验收)**
## Evidence J — T16 执行结果 (Tenant Creator Audit Flow Acceptance)
### J1. 测试执行摘要
| 测试用例 | 状态 | 观察结果 | 证据 |
|-----------|--------|-------------|----------|
| **Admin Login & Navigation** | PASS | Admin `13800000001` 成功登录并导航至 `/t/meipai_765/creator/audit`。页面标题“操作审计”验证通过。 | `admin-audit-page-loaded.png` |
| **Data Rendering** | PASS | 审计日志列表渲染多行数据 (IDs 13, 12, etc.)。 | `admin-audit-page-loaded.png` |
| **Action Filter** | PASS | 筛选动作 "seed" 后列表缩减为单条匹配记录 (ID 3)。 | `admin-filter-result.png` |
| **Pagination** | PASS | 切换至第 2 页显示了不同的记录 (IDs 3, 2, 1)。 | `admin-page-2.png` |
| **Permission Control** | PASS | 普通成员 `13800138000` 访问页面显示“暂无审计记录”,验证了数据权限控制。 | `member-denied-state.png` |
### J2. 截图证据
#### 1. Admin: Audit Page Loaded
![Admin Audit Page](2026-02-08/admin-audit-page-loaded.png)
*完整审计日志列表展示*
#### 2. Admin: Filter Result ("seed")
![Admin Filter Result](2026-02-08/admin-filter-result.png)
*筛选 action='seed' 结果*
#### 3. Admin: Pagination (Page 2)
![Admin Page 2](2026-02-08/admin-page-2.png)
*第 2 页旧数据展示*
#### 4. Member: Access Denied / No Data
![Member Denied](2026-02-08/member-denied-state.png)
*非管理员用户访问无数据展示*
### J3. 结论
**PASS**. 新增的租户创作者审计流程对管理员功能正常对普通成员具备权限控制。T16 验收通过。
## Evidence K — T17 发布门禁汇总与 Go/No-Go
### K1. 门禁清单结果
| 门禁项 | 结果 | 证据 |
|---|---|---|
| T13 Backend 全量测试 | PASS | `go test ./...`backend通过 |
| T14 Frontend build/lint | PASS | `npm -C frontend/portal run lint && npm -C frontend/portal run build` 通过;`npm -C frontend/superadmin run lint && npm -C frontend/superadmin run build` 通过 |
| T16 前端页面流验收(本次受影响流) | PASS | Evidence J + 截图 `docs/release-evidence/2026-02-08/*.png` |
| 租户审计 API 权限与数据面验证 | PASS | 成员调用 `/v1/t/meipai_765/creator/audit-logs` 返回 `code=1206 无权限操作该租户`;管理员调用返回 `total=13` 且可筛选/分页 |
### K2. 四项标准最终判定(本轮)
| 标准 | 判定 | 说明 |
|---|---|---|
| 1) 前端业务数据来自后端接口/渲染 | PASS本轮范围 | LikesView 已 API 化superadmin demo 路由已生产拦截 |
| 2) 用户/租户数据隔离完备 | PASS本轮范围 | 跨租户负向测试补强通过Evidence G |
| 3) 超管后台可审计 | PASS | 既有超管审计链路 + 构建验证通过 |
| 4) 租户管理侧可审计 | PASS | 新增 creator audit API + Portal 页面 + 页面流验收通过 |
### K3. Go/No-Go 结论
**Go可进入生产发布候选**
依据T13/T14/T16/T17 门禁均通过,且四项标准在本轮改造范围内均达标。
### K4. 发布前剩余建议(非阻塞)
1. 按计划补充审计接口性能基线p95记录目前文档仅有测量协议尚缺执行数据
2. 将 superadmin demo 路由从“运行时拦截”进一步提升为“构建期裁剪”(可选增强)。
3. 按计划完成归档动作:若确认本阶段收口,执行 T18归档 `docs/plan.md` -> `docs/plans/<date>.md` 并清空活动 plan
## Next Actions (D2+)
1. 已完成执行计划门禁与联调验收项T13/T14/T16/T17见 Evidence K。
2. 已完成进行前端页面流验收creator audit 查询/筛选/分页)并补充录屏或截图证据(见 Evidence J
3. (可选增强)将 superadmin demo 路由按构建开关完全剔除,而非仅运行时拦截。

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -18,6 +18,10 @@ export const creatorApi = {
const qs = new URLSearchParams(params).toString();
return request(`/creator/orders?${qs}`);
},
listAuditLogs: (params) => {
const qs = new URLSearchParams(params).toString();
return request(`/creator/audit-logs?${qs}`);
},
refundOrder: (id, data) =>
request(`/creator/orders/${id}/refund`, { method: "POST", body: data }),
listCoupons: (params) => {

View File

@@ -91,6 +91,16 @@ const isFullWidth = computed(() => {
></i>
<span class="font-medium">订单管理</span>
</router-link>
<router-link
:to="tenantRoute('/creator/audit')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-surface-highlight hover:text-content transition-all group"
>
<i
class="pi pi-shield text-lg group-hover:scale-110 transition-transform"
></i>
<span class="font-medium">操作审计</span>
</router-link>
<router-link
:to="tenantRoute('/creator/coupons')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"

View File

@@ -175,6 +175,11 @@ const router = createRouter({
name: "creator-orders",
component: () => import("../views/creator/OrdersView.vue"),
},
{
path: "audit",
name: "creator-audit",
component: () => import("../views/creator/AuditView.vue"),
},
{
path: "members",
name: "creator-members",

View File

@@ -0,0 +1,347 @@
<script setup>
import Paginator from "primevue/paginator";
import Toast from "primevue/toast";
import { useToast } from "primevue/usetoast";
import { computed, onMounted, ref } from "vue";
import { creatorApi } from "../../api/creator";
const toast = useToast();
const logs = ref([]);
const loading = ref(false);
const totalRecords = ref(0);
const rows = ref(10);
const first = ref(0);
const operatorID = ref("");
const operatorName = ref("");
const action = ref("");
const targetID = ref("");
const keyword = ref("");
const createdAtFrom = ref("");
const createdAtTo = ref("");
const sortField = ref("created_at");
const sortOrder = ref("desc");
const page = computed(() => Math.floor(first.value / rows.value) + 1);
const toISO = (value) => {
if (!value) return undefined;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const formatDate = (value) => {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const actionTagClass = (value) => {
if (!value) return "bg-slate-100 text-slate-500";
if (value.includes("create") || value.includes("approve")) {
return "bg-emerald-50 text-emerald-700";
}
if (value.includes("update") || value.includes("review")) {
return "bg-blue-50 text-blue-700";
}
if (
value.includes("delete") ||
value.includes("reject") ||
value.includes("disable")
) {
return "bg-rose-50 text-rose-700";
}
return "bg-slate-100 text-slate-600";
};
const buildParams = () => {
const params = {
page: page.value,
limit: rows.value,
};
if (operatorID.value) {
const parsed = Number(operatorID.value);
if (Number.isFinite(parsed) && parsed > 0) {
params.operator_id = parsed;
}
}
if (operatorName.value.trim())
params.operator_name = operatorName.value.trim();
if (action.value.trim()) params.action = action.value.trim();
if (targetID.value.trim()) params.target_id = targetID.value.trim();
if (keyword.value.trim()) params.keyword = keyword.value.trim();
const fromISO = toISO(createdAtFrom.value);
if (fromISO) params.created_at_from = fromISO;
const toISOValue = toISO(createdAtTo.value);
if (toISOValue) params.created_at_to = toISOValue;
if (sortOrder.value === "asc") {
params.asc = sortField.value;
} else {
params.desc = sortField.value;
}
return params;
};
const fetchLogs = async () => {
loading.value = true;
try {
const res = await creatorApi.listAuditLogs(buildParams());
logs.value = res?.items || [];
totalRecords.value = res?.total || 0;
} catch (error) {
toast.add({
severity: "error",
summary: "加载失败",
detail: error?.message || "审计日志加载失败",
life: 3000,
});
} finally {
loading.value = false;
}
};
const onSearch = () => {
first.value = 0;
fetchLogs();
};
const onReset = () => {
operatorID.value = "";
operatorName.value = "";
action.value = "";
targetID.value = "";
keyword.value = "";
createdAtFrom.value = "";
createdAtTo.value = "";
sortField.value = "created_at";
sortOrder.value = "desc";
first.value = 0;
rows.value = 10;
fetchLogs();
};
const onPage = (event) => {
first.value = event.first;
rows.value = event.rows;
fetchLogs();
};
onMounted(fetchLogs);
</script>
<template>
<div>
<Toast />
<div class="flex items-center justify-between mb-8">
<h1 class="text-2xl font-bold text-slate-900">操作审计</h1>
<div class="text-sm text-slate-500">仅展示当前租户审计记录</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<div>
<label class="text-xs font-bold text-slate-500 block mb-1"
>操作者ID</label
>
<input
v-model="operatorID"
type="number"
min="1"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
placeholder="精确匹配"
@keyup.enter="onSearch"
/>
</div>
<div>
<label class="text-xs font-bold text-slate-500 block mb-1"
>操作者</label
>
<input
v-model="operatorName"
type="text"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
placeholder="用户名/昵称"
@keyup.enter="onSearch"
/>
</div>
<div>
<label class="text-xs font-bold text-slate-500 block mb-1"
>动作</label
>
<input
v-model="action"
type="text"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
placeholder="如 update_settings"
@keyup.enter="onSearch"
/>
</div>
<div>
<label class="text-xs font-bold text-slate-500 block mb-1"
>目标ID</label
>
<input
v-model="targetID"
type="text"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
placeholder="精确匹配"
@keyup.enter="onSearch"
/>
</div>
<div>
<label class="text-xs font-bold text-slate-500 block mb-1"
>关键词</label
>
<input
v-model="keyword"
type="text"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
placeholder="详情关键词"
@keyup.enter="onSearch"
/>
</div>
<div>
<label class="text-xs font-bold text-slate-500 block mb-1"
>创建时间 From</label
>
<input
v-model="createdAtFrom"
type="datetime-local"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
<div>
<label class="text-xs font-bold text-slate-500 block mb-1"
>创建时间 To</label
>
<input
v-model="createdAtTo"
type="datetime-local"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
<div>
<label class="text-xs font-bold text-slate-500 block mb-1"
>排序</label
>
<div class="flex gap-2">
<select
v-model="sortField"
class="flex-1 h-9 px-2 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
>
<option value="created_at">created_at</option>
<option value="id">id</option>
</select>
<select
v-model="sortOrder"
class="w-24 h-9 px-2 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
>
<option value="desc">降序</option>
<option value="asc">升序</option>
</select>
</div>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
@click="onReset"
class="px-4 h-9 border border-slate-200 rounded text-sm text-slate-600 hover:bg-slate-50"
>
重置
</button>
<button
@click="onSearch"
class="px-4 h-9 bg-slate-900 text-white rounded text-sm hover:bg-slate-800"
>
查询
</button>
</div>
</div>
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"
>
<div v-if="loading" class="px-6 py-10 text-center text-slate-400">
加载中...
</div>
<template v-else>
<table class="w-full text-left text-sm">
<thead
class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200"
>
<tr>
<th class="px-6 py-4 whitespace-nowrap">日志ID</th>
<th class="px-6 py-4 whitespace-nowrap">操作者</th>
<th class="px-6 py-4 whitespace-nowrap">动作</th>
<th class="px-6 py-4 whitespace-nowrap">目标ID</th>
<th class="px-6 py-4">详情</th>
<th class="px-6 py-4 whitespace-nowrap">创建时间</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr
v-for="item in logs"
:key="item.id"
class="hover:bg-slate-50 transition-colors"
>
<td class="px-6 py-4 font-mono text-slate-600">{{ item.id }}</td>
<td class="px-6 py-4">
<div class="font-medium text-slate-900">
{{ item.operator_name || "-" }}
</div>
<div class="text-xs text-slate-500">
ID: {{ item.operator_id || "-" }}
</div>
</td>
<td class="px-6 py-4">
<span
class="inline-block px-2.5 py-1 rounded text-xs font-bold"
:class="actionTagClass(item.action)"
>
{{ item.action || "-" }}
</span>
</td>
<td class="px-6 py-4 font-mono text-slate-600">
{{ item.target_id || "-" }}
</td>
<td class="px-6 py-4 text-slate-700">
<span
class="block max-w-[520px] truncate"
:title="item.detail"
>{{ item.detail || "-" }}</span
>
</td>
<td class="px-6 py-4 text-slate-500 whitespace-nowrap">
{{ formatDate(item.created_at) }}
</td>
</tr>
</tbody>
</table>
<div v-if="logs.length === 0" class="text-center py-12 text-slate-400">
暂无审计记录
</div>
</template>
</div>
<div class="mt-6 flex justify-end" v-if="totalRecords > rows">
<Paginator
:rows="rows"
:first="first"
:totalRecords="totalRecords"
@page="onPage"
template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown"
:rowsPerPageOptions="[10, 20, 50]"
/>
</div>
</div>
</template>

View File

@@ -1,42 +1,61 @@
<script setup>
import { ref } from "vue";
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import Toast from "primevue/toast";
import { useToast } from "primevue/usetoast";
import { userApi } from "../../api/user";
import { tenantPath } from "../../utils/tenant";
const toast = useToast();
const route = useRoute();
const tenantRoute = (path) => tenantPath(path, route);
const items = ref([
{
id: 4,
title: "《霸王别姬》全本实录珍藏版",
cover:
"https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60",
author: "梅派传人小林",
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Master1",
type: "video",
duration: "120:00",
time: "昨天点赞",
},
{
id: 5,
title: "京剧打击乐基础教程",
cover:
"https://images.unsplash.com/photo-1576014131795-d44019d02374?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60",
author: "戏曲学院官方",
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=School",
type: "video",
duration: "45:00",
time: "3天前点赞",
},
]);
const items = ref([]);
const loading = ref(true);
const removeItem = (id) => {
items.value = items.value.filter((i) => i.id !== id);
toast.add({ severity: "success", summary: "已取消点赞", life: 2000 });
const fetchLikes = async () => {
try {
const res = await userApi.getLikes();
items.value = res || [];
} catch (e) {
toast.add({
severity: "error",
summary: "加载失败",
detail: e.message,
life: 3000,
});
} finally {
loading.value = false;
}
};
onMounted(fetchLikes);
const removeItem = async (id) => {
try {
await userApi.removeLike(id);
items.value = items.value.filter((i) => i.id !== id);
toast.add({ severity: "success", summary: "已取消点赞", life: 2000 });
} catch (e) {
toast.add({
severity: "error",
summary: "操作失败",
detail: e.message,
life: 3000,
});
}
};
const getTypeIcon = (type) => {
if (type === "video") return "pi-play-circle";
if (type === "audio") return "pi-volume-up";
return "pi-book";
};
const getTypeLabel = (type) => {
if (type === "video") return "视频";
if (type === "audio") return "音频";
return "文章";
};
</script>
@@ -65,11 +84,8 @@ const removeItem = (id) => {
<div
class="absolute bottom-2 left-2 px-1.5 py-0.5 bg-black/60 text-white text-xs rounded flex items-center gap-1"
>
<i
class="pi"
:class="item.type === 'video' ? 'pi-play-circle' : 'pi-book'"
></i>
<span>{{ item.duration || "文章" }}</span>
<i class="pi" :class="getTypeIcon(item.type)"></i>
<span>{{ getTypeLabel(item.type) }}</span>
</div>
</div>
@@ -81,13 +97,19 @@ const removeItem = (id) => {
{{ item.title }}
</h3>
<div class="flex items-center gap-2 text-xs text-slate-500 mb-3">
<img :src="item.authorAvatar" class="w-5 h-5 rounded-full" />
<span>{{ item.author }}</span>
<img
:src="
item.author_avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${item.author_id}`
"
class="w-5 h-5 rounded-full"
/>
<span>{{ item.author_name }}</span>
</div>
<div
class="flex items-center justify-between text-xs text-slate-400 border-t border-slate-50 pt-3"
>
<span>{{ item.time }}</span>
<span>{{ item.created_at }}</span>
<button
@click.stop="removeItem(item.id)"
class="hover:text-primary-600 flex items-center gap-1 transition-colors"
@@ -100,7 +122,7 @@ const removeItem = (id) => {
</div>
<!-- Empty State -->
<div v-if="items.length === 0" class="text-center py-20">
<div v-if="!loading && items.length === 0" class="text-center py-20">
<div
class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-50 mb-4"
>

View File

@@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sakai Vue</title>
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet">
<script type="module" crossorigin src="./assets/index-CsH8eBi3.js"></script>
<script type="module" crossorigin src="./assets/index-DRIu3C4l.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CLNNtsXI.css">
</head>

View File

@@ -225,10 +225,18 @@ const router = createRouter({
]
});
const isDemoOnlyRoute = (path) => {
return path.startsWith('/uikit/') || path === '/blocks' || path === '/pages/empty' || path === '/pages/crud' || path === '/documentation' || path === '/landing';
};
let tokenValidated = false;
let tokenValidationPromise = null;
router.beforeEach(async (to) => {
if (!import.meta.env.DEV && isDemoOnlyRoute(to.path)) {
return { name: 'dashboard' };
}
if (to.meta?.requiresAuth !== true) return true;
const isAuthed = hasSuperAuthToken();