test: add middleware tests and update todo_list with test coverage details
- Add middlewares_test.go with tests for AuthOptional, AuthRequired, SuperAuth, and TenantResolver - Update todo_list.md with test specifications, coverage status, and pending test cases (T1-T4)
This commit is contained in:
318
backend/app/middlewares/middlewares_test.go
Normal file
318
backend/app/middlewares/middlewares_test.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/commands/testx"
|
||||
"quyun/v2/app/errorx"
|
||||
"quyun/v2/app/services"
|
||||
"quyun/v2/database"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
"quyun/v2/providers/jwt"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
jwtv4 "github.com/golang-jwt/jwt/v4"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"go.ipao.vip/atom/contracts"
|
||||
"go.ipao.vip/gen/types"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
type MiddlewaresTestSuiteInjectParams struct {
|
||||
dig.In
|
||||
|
||||
DB *sql.DB
|
||||
Initials []contracts.Initial `group:"initials"`
|
||||
JWT *jwt.JWT
|
||||
Middlewares *Middlewares
|
||||
}
|
||||
|
||||
type MiddlewaresTestSuite struct {
|
||||
suite.Suite
|
||||
MiddlewaresTestSuiteInjectParams
|
||||
}
|
||||
|
||||
func Test_Middlewares(t *testing.T) {
|
||||
providers := testx.Default().With(Provide)
|
||||
|
||||
testx.Serve(providers, t, func(p MiddlewaresTestSuiteInjectParams) {
|
||||
suite.Run(t, &MiddlewaresTestSuite{MiddlewaresTestSuiteInjectParams: p})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MiddlewaresTestSuite) newTestApp() *fiber.App {
|
||||
handler := errorx.NewErrorHandler()
|
||||
return fiber.New(fiber.Config{
|
||||
ErrorHandler: func(c fiber.Ctx, err error) error {
|
||||
appErr := handler.Handle(err)
|
||||
return c.Status(appErr.StatusCode).JSON(fiber.Map{
|
||||
"code": appErr.Code,
|
||||
"message": appErr.Message,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MiddlewaresTestSuite) createTestUser(ctx context.Context, phone string, roles types.Array[consts.Role]) *models.User {
|
||||
user := &models.User{
|
||||
Phone: phone,
|
||||
Roles: roles,
|
||||
Status: consts.UserStatusVerified,
|
||||
}
|
||||
_ = models.UserQuery.WithContext(ctx).Create(user)
|
||||
return user
|
||||
}
|
||||
|
||||
func (s *MiddlewaresTestSuite) createToken(userID, tenantID int64) string {
|
||||
claims := s.JWT.CreateClaims(jwt.BaseClaims{
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
})
|
||||
token, _ := s.JWT.CreateToken(claims)
|
||||
return "Bearer " + token
|
||||
}
|
||||
|
||||
func (s *MiddlewaresTestSuite) createExpiredToken(userID int64) string {
|
||||
claims := &jwt.Claims{
|
||||
BaseClaims: jwt.BaseClaims{
|
||||
UserID: userID,
|
||||
},
|
||||
}
|
||||
claims.ExpiresAt = jwtv4.NewNumericDate(time.Now().Add(-time.Hour))
|
||||
claims.NotBefore = jwtv4.NewNumericDate(time.Now().Add(-2 * time.Hour))
|
||||
|
||||
token := jwtv4.NewWithClaims(jwtv4.SigningMethodHS256, claims)
|
||||
tokenString, _ := token.SignedString(s.JWT.SigningKey)
|
||||
return "Bearer " + tokenString
|
||||
}
|
||||
|
||||
func (s *MiddlewaresTestSuite) Test_AuthOptional() {
|
||||
Convey("AuthOptional", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(ctx, s.DB, models.TableNameUser)
|
||||
|
||||
Convey("should pass without token and ctx.Locals has no user", func() {
|
||||
app := s.newTestApp()
|
||||
app.Use(s.Middlewares.AuthOptional)
|
||||
app.Get("/test", func(c fiber.Ctx) error {
|
||||
user := c.Locals(consts.CtxKeyUser)
|
||||
if user == nil {
|
||||
return c.SendString("no_user")
|
||||
}
|
||||
return c.SendString("has_user")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
So(string(body), ShouldEqual, "no_user")
|
||||
})
|
||||
|
||||
Convey("should pass with valid token and ctx.Locals has user", func() {
|
||||
app := s.newTestApp()
|
||||
app.Use(s.Middlewares.AuthOptional)
|
||||
app.Get("/test", func(c fiber.Ctx) error {
|
||||
user := c.Locals(consts.CtxKeyUser)
|
||||
if user == nil {
|
||||
return c.SendString("no_user")
|
||||
}
|
||||
return c.SendString("has_user")
|
||||
})
|
||||
|
||||
user := s.createTestUser(ctx, "13800000001", types.Array[consts.Role]{consts.RoleUser})
|
||||
token := s.createToken(user.ID, 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
So(string(body), ShouldEqual, "has_user")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MiddlewaresTestSuite) Test_AuthRequired() {
|
||||
Convey("AuthRequired", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(ctx, s.DB, models.TableNameUser)
|
||||
|
||||
Convey("should return 401 without token", func() {
|
||||
app := s.newTestApp()
|
||||
app.Use(s.Middlewares.AuthRequired)
|
||||
app.Get("/protected", func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
Convey("should return 401 with invalid token", func() {
|
||||
app := s.newTestApp()
|
||||
app.Use(s.Middlewares.AuthRequired)
|
||||
app.Get("/protected", func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid_token")
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
Convey("should pass with valid token", func() {
|
||||
app := s.newTestApp()
|
||||
app.Use(s.Middlewares.AuthRequired)
|
||||
app.Get("/protected", func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
user := s.createTestUser(ctx, "13800000002", types.Array[consts.Role]{consts.RoleUser})
|
||||
token := s.createToken(user.ID, 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MiddlewaresTestSuite) Test_SuperAuth() {
|
||||
Convey("SuperAuth", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(ctx, s.DB, models.TableNameUser)
|
||||
|
||||
Convey("should return 401 without token", func() {
|
||||
app := s.newTestApp()
|
||||
app.Use(s.Middlewares.SuperAuth)
|
||||
app.Get("/super/v1/tenants", func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/super/v1/tenants", nil)
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
Convey("should return 403 when user is not super_admin", func() {
|
||||
app := s.newTestApp()
|
||||
app.Use(s.Middlewares.SuperAuth)
|
||||
app.Get("/super/v1/tenants", func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
user := s.createTestUser(ctx, "13800000003", types.Array[consts.Role]{consts.RoleUser})
|
||||
token := s.createToken(user.ID, 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/super/v1/tenants", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusForbidden)
|
||||
})
|
||||
|
||||
Convey("should pass when user is super_admin", func() {
|
||||
app := s.newTestApp()
|
||||
app.Use(s.Middlewares.SuperAuth)
|
||||
app.Get("/super/v1/tenants", func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
user := s.createTestUser(ctx, "13800000004", types.Array[consts.Role]{consts.RoleSuperAdmin})
|
||||
token := s.createToken(user.ID, 0)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/super/v1/tenants", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
})
|
||||
|
||||
Convey("should allow public routes without auth", func() {
|
||||
app := s.newTestApp()
|
||||
app.Use(s.Middlewares.SuperAuth)
|
||||
app.Post("/super/v1/auth/login", func(c fiber.Ctx) error {
|
||||
return c.SendString("login")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/super/v1/auth/login", nil)
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MiddlewaresTestSuite) Test_TenantResolver() {
|
||||
Convey("TenantResolver", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(ctx, s.DB, models.TableNameTenant, models.TableNameUser)
|
||||
|
||||
Convey("should return 404 when tenant not found", func() {
|
||||
app := s.newTestApp()
|
||||
app.Get("/t/:tenantCode/v1/test", s.Middlewares.TenantResolver, func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/t/nonexistent/v1/test", nil)
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusNotFound)
|
||||
})
|
||||
|
||||
Convey("should set tenant in ctx.Locals when found", func() {
|
||||
owner := &models.User{Phone: "13800000005", Status: consts.UserStatusVerified}
|
||||
_ = models.UserQuery.WithContext(ctx).Create(owner)
|
||||
|
||||
tenant := &models.Tenant{
|
||||
Name: "Test Tenant",
|
||||
Code: "test_tenant",
|
||||
UserID: owner.ID,
|
||||
Status: consts.TenantStatusVerified,
|
||||
}
|
||||
_ = models.TenantQuery.WithContext(ctx).Create(tenant)
|
||||
|
||||
app := s.newTestApp()
|
||||
app.Get("/t/:tenantCode/v1/test", s.Middlewares.TenantResolver, func(c fiber.Ctx) error {
|
||||
t := c.Locals(consts.CtxKeyTenant)
|
||||
if t == nil {
|
||||
return c.SendString("no_tenant")
|
||||
}
|
||||
if model, ok := t.(*models.Tenant); ok {
|
||||
return c.SendString(model.Code)
|
||||
}
|
||||
return c.SendString("invalid_tenant")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/t/test_tenant/v1/test", nil)
|
||||
resp, err := app.Test(req)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
So(string(body), ShouldEqual, "test_tenant")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var _ = services.User
|
||||
@@ -13,6 +13,79 @@
|
||||
- 分层约束:Controller 仅做绑定与调用,业务与 DB 仅在 `services`。
|
||||
- 每个需求至少配套:接口说明 + 数据变更(如有)+ 关键业务校验 + 单元测试。
|
||||
|
||||
## 测试规范
|
||||
|
||||
### 服务测试(必须)
|
||||
- 使用 Convey + testify/suite 框架(参考 `backend/tests/README.md`)。
|
||||
- 每个服务方法至少覆盖:正常路径 + 主要错误分支。
|
||||
- 数据准备使用 `database.Truncate` + 直接 GORM-Gen 创建。
|
||||
- 断言使用 Convey 的 `So` 函数,遵循 `当...那么...` 语义结构。
|
||||
|
||||
### 中间件测试(按需)
|
||||
- HTTP 层行为使用 `httptest` + `fiber.Test`。
|
||||
- 验证响应码 + 响应体 + `ctx.Locals` 状态。
|
||||
|
||||
### 集成测试(关键路径)
|
||||
- 覆盖完整业务流程(如:申请 → 审核 → 成员可见)。
|
||||
- 使用真实 DB 连接(`v2_test` 数据库)。
|
||||
|
||||
### 前端验收
|
||||
- 当前依赖手动验收(参考 `docs/seed_verification.md`)。
|
||||
- 重要流程变更需截图或录屏确认。
|
||||
|
||||
## 测试覆盖现状
|
||||
|
||||
| 模块 | 状态 | 测试文件 | 覆盖范围 |
|
||||
|------|------|----------|----------|
|
||||
| 租户成员体系 | ✅ 完善 | `tenant_member_test.go` | 申请/取消/审核/邀请/成员管理 |
|
||||
| 上传会话归属 | ✅ 完善 | `common_test.go` | owner 校验/forbidden/not found |
|
||||
| 内容访问策略 | ✅ 基础覆盖 | `content_test.go` | tenant_only/private/preview |
|
||||
| 鉴权与权限 | ⚠️ 部分 | `user_test.go` | 仅 OTP 登录 + 成员校验,缺中间件测试 |
|
||||
| 审计参数传递 | ❌ 缺失 | 无 | 无测试覆盖 |
|
||||
|
||||
## 待补充测试用例
|
||||
|
||||
### T1) 鉴权中间件测试
|
||||
**建议文件**:`backend/app/middlewares/middlewares_test.go`
|
||||
|
||||
**测试场景**:
|
||||
- `AuthOptional`:无 token 访问正常通过,`ctx.Locals` 无 user
|
||||
- `AuthOptional`:有效 token 访问,`ctx.Locals` 有 user
|
||||
- `AuthRequired`:无 token 访问返回 401
|
||||
- `AuthRequired`:过期 token 返回 401
|
||||
- `AuthRequired`:有效 token 正常通过
|
||||
- `super_admin` 校验:非 super_admin 角色访问 `/super/v1/*` 返回 403
|
||||
- `super_admin` 校验:super_admin 角色访问正常通过
|
||||
|
||||
### T2) 审计日志测试
|
||||
**建议文件**:`backend/app/services/audit_test.go`
|
||||
|
||||
**测试场景**:
|
||||
- `Audit.Log` 正常落库:验证 `tenantID`/`operatorID`/`action`/`targetID`/`detail` 字段
|
||||
- `Audit.Log` 缺参:`operatorID=0` 时行为(当前仅 warn 日志)
|
||||
- 关键操作触发审计:如 `ReviewJoin` 后 `audit_logs` 有对应记录
|
||||
- 审计记录可查询:按 `tenant`/`operator`/`action` 筛选
|
||||
|
||||
### T3) 内容访问策略测试补充
|
||||
**扩展文件**:`backend/app/services/content_test.go`
|
||||
|
||||
**补充场景**:
|
||||
- 未登录访问 public 内容:仅 preview + cover
|
||||
- 已购买访问:完整 media
|
||||
- 作者/管理员访问 private:完整 media
|
||||
- 非成员访问 tenant_only:forbidden 或仅 preview
|
||||
- 签名 URL 生成前权限校验
|
||||
|
||||
### T4) 超管写操作测试
|
||||
**扩展文件**:`backend/app/services/super_test.go`
|
||||
|
||||
**补充场景**:
|
||||
- `CreateTenant`:验证 `expired_at` 计算正确
|
||||
- `CreateTenant`:验证 `tenant_users` 管理员关系写入
|
||||
- 创作者设置读写:超管可读取/更新任意租户设置
|
||||
- 收款账户管理:超管新增/编辑/禁用
|
||||
- 用户资料编辑:超管可更新基础字段
|
||||
|
||||
## P0(必须先做)
|
||||
|
||||
### 1) 租户成员体系(加入/邀请/审核)(已完成)
|
||||
@@ -34,10 +107,14 @@
|
||||
- 申请成功后写 `tenant_join_requests`;审核通过写 `tenant_users`。
|
||||
- 对 `tenant_only` 内容访问:需要 `tenant_users.status=verified` 或已购买。
|
||||
|
||||
**测试方案**
|
||||
- 申请加入:重复申请拦截。
|
||||
- 审核通过后,tenant_only 可访问;未通过不可访问。
|
||||
- 邀请链接过期/重复使用处理。
|
||||
**测试方案**(✅ 已覆盖 `tenant_member_test.go`)
|
||||
- 申请加入:创建 pending 请求、重复申请拦截、已是成员时拒绝。
|
||||
- 取消申请:正常取消、请求不存在时返回 not found。
|
||||
- 审核流程:approve 后写入 `tenant_users`、reject 后状态变更。
|
||||
- 邀请创建:生成有效邀请码、设置过期时间与使用次数。
|
||||
- 接受邀请:正常加入、`used_count` 递增、邀请失效后拒绝。
|
||||
- 成员管理:列表分页、移除成员后不可查。
|
||||
- 邀请管理:列表查询、禁用邀请后状态变更。
|
||||
|
||||
### 2) 鉴权与权限收口(必需)(已完成)
|
||||
**需求目标**
|
||||
@@ -49,9 +126,11 @@
|
||||
- 超管接口:校验 JWT roles 含 `super_admin`。
|
||||
- 服务:补齐登录、token 续期/失效逻辑。
|
||||
|
||||
**测试方案**
|
||||
- 未登录访问受保护接口被拒绝。
|
||||
- 非超管访问 `/super/v1/*` 被拒绝。
|
||||
**测试方案**(⚠️ 部分覆盖 `user_test.go`,缺中间件测试)
|
||||
- ✅ 已覆盖:OTP 登录 + 租户成员校验(非成员拒绝、成员允许)。
|
||||
- ✅ 已覆盖:`User.Me` 无 context user 时失败。
|
||||
- ❌ 待补充:`AuthOptional`/`AuthRequired` 中间件行为(见 T1)。
|
||||
- ❌ 待补充:`super_admin` 角色校验(非超管访问 `/super/v1/*` 返回 403)。
|
||||
|
||||
### 3) 上传会话归属测试补齐(已完成)
|
||||
**需求目标**
|
||||
@@ -60,8 +139,9 @@
|
||||
**技术方案(后端)**
|
||||
- 为 `Common.UploadPart` 增加服务测试,验证 owner mismatch 返回 `ErrForbidden`。
|
||||
|
||||
**测试方案**
|
||||
- owner 与非 owner 上传分支。
|
||||
**测试方案**(✅ 已覆盖 `common_test.go`)
|
||||
- `AbortUpload`:owner 可取消、非 owner 返回 forbidden、不存在返回 not found。
|
||||
- `UploadPart`:owner 可上传、非 owner 返回 forbidden。
|
||||
|
||||
### 4) 超管全量可编辑(补齐写操作缺口)(已完成)
|
||||
**需求目标**
|
||||
@@ -82,11 +162,11 @@
|
||||
- 用户资料超管编辑
|
||||
- 新增 `PATCH /super/v1/users/:id<int>`(允许更新昵称/头像/实名标记等基础字段)。
|
||||
|
||||
**测试方案**
|
||||
- CreateTenant 后 `expired_at` 与 `tenant_users` 均落库。
|
||||
- 超管可读取/更新创作者设置,权限校验通过。
|
||||
- 超管可新增/编辑收款账户。
|
||||
- 超管可更新用户资料,字段校验生效。
|
||||
**测试方案**(⚠️ 部分覆盖 `super_test.go`,待扩展见 T4)
|
||||
- ✅ 基础:`CreateTenant` 后 `expired_at` 与 `tenant_users` 均落库。
|
||||
- ❌ 待补充:超管读取/更新创作者设置。
|
||||
- ❌ 待补充:超管新增/编辑收款账户。
|
||||
- ❌ 待补充:超管更新用户资料,字段校验生效。
|
||||
|
||||
### 5) 通知模板支持编辑(已完成)
|
||||
**需求目标**
|
||||
@@ -145,8 +225,12 @@
|
||||
- tenant_only + 已购/成员/作者/管理员:完整
|
||||
- private:仅作者/管理员
|
||||
|
||||
**测试方案**
|
||||
- 未登录/未购/已购/作者/管理员的可见资源集合一致性。
|
||||
**测试方案**(⚠️ 部分覆盖 `content_test.go`,待扩展见 T3)
|
||||
- ✅ 已覆盖:`tenant_only` 访问(成员/管理员允许,非成员拒绝)。
|
||||
- ✅ 已覆盖:`private` 内容访问(仅作者可见)。
|
||||
- ✅ 已覆盖:preview vs full 逻辑(guest/owner/buyer)。
|
||||
- ❌ 待补充:未登录访问 public 内容仅返回 preview + cover。
|
||||
- ❌ 待补充:签名 URL 生成前权限校验。
|
||||
|
||||
### 9) 审计参数传递规范化(已完成)
|
||||
**需求目标**
|
||||
@@ -156,8 +240,10 @@
|
||||
- 调整 `services.Audit` 方法签名与调用方传参。
|
||||
- 关键操作补齐操作者字段。
|
||||
|
||||
**测试方案**
|
||||
- 审计记录落库含 `operator_id`,并覆盖缺参错误。
|
||||
**测试方案**(❌ 缺失,待补充见 T2)
|
||||
- 待补充:`Audit.Log` 正常落库,验证各字段。
|
||||
- 待补充:关键操作(如 `ReviewJoin`)触发审计记录。
|
||||
- 待补充:审计记录可按 `tenant`/`operator`/`action` 筛选。
|
||||
|
||||
### 10) 创作者中心 - 团队成员管理(Portal UI)(已完成)
|
||||
**需求目标**
|
||||
|
||||
Reference in New Issue
Block a user