| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- package user
- import (
- "context"
- "errors"
- "sync"
- "sync/atomic"
- "testing"
- deptLogic "perms-system-server/internal/logic/dept"
- "perms-system-server/internal/response"
- "perms-system-server/internal/svc"
- "perms-system-server/internal/testutil"
- "perms-system-server/internal/testutil/ctxhelper"
- "perms-system-server/internal/types"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
- // ---------------------------------------------------------------------------
- // 覆盖目标:审计 M-R11-3 —— UpdateUser 把 deptId 调入 targetDept 与 DeleteDept 并发执行时,
- // 绝不能同时成功(否则会出现 "sys_user.deptId 指向已被删除的部门行" 的 orphan 数据)。
- //
- // 修复前的 write skew:
- // T1 DeleteDept 事务:SELECT id FROM sys_dept WHERE id=X FOR UPDATE
- // → SELECT id FROM sys_user WHERE deptId=X FOR SHARE —— 空集
- // → DELETE sys_dept[X]
- // T2 UpdateUser :UPDATE sys_user SET deptId=X WHERE id=U —— 无锁同时进行
- // 两边各自提交后,U 的 deptId 指向了已被删除的 X。
- //
- // 修复后(M-R11-3):
- // UpdateUser 在事务内 `SELECT ... LOCK IN SHARE MODE` sys_dept[X],
- // DeleteDept 在事务内 `SELECT ... FOR UPDATE` sys_dept[X] —— S vs X 互斥,
- // 后到者必被前者阻塞。先到者提交后,DeleteDept 重读 sys_user WHERE deptId=X FOR SHARE
- // 能看到 UpdateUser 提交的新成员,转为 400;先到的是 DeleteDept,则 UpdateUser 的
- // S 锁获取会卡住等待 DeleteDept 的 X 锁,DeleteDept 提交后 sys_dept[X] 消失,
- // UpdateUser 的 FindOneForShareTx 返回 ErrNotFound → 400 "部门不存在"。
- //
- // 这里跑真实 MySQL 事务,对"不可能出现 orphan"做闭环断言。
- // ---------------------------------------------------------------------------
- // TC-1049: M-R11-3 —— UpdateUser 调入 X 与 DeleteDept(X) 并发:两者互斥,结果必须自洽
- // - 要么 UpdateUser 成功 + DeleteDept 收到 400「该部门下仍有关联用户」(dept 仍在、user.deptId 指向 dept);
- // - 要么 DeleteDept 成功 + UpdateUser 收到 400「部门不存在」(dept 已删、user.deptId 未被改到已删 dept)。
- // 严禁同时成功,也严禁同时失败。DB 终态必须自洽。
- func TestUpdateUser_DeptIdSwitch_VsDeleteDept_NoWriteSkew(t *testing.T) {
- bootstrap := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- // 构造:用户 U 在 deptA,新候选部门 deptX 空(无子部门 + 无关联用户,满足 DeleteDept 可删条件)。
- deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptA", "/3100/")
- deptXId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptX", "/3200/")
- targetId := insertTestUserWithDept(t, bootstrap, "m113_user", deptAId)
- mId := insertTestMember(t, svcCtx, "test_product", targetId)
- t.Cleanup(func() {
- testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
- testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
- testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId, deptXId)
- })
- // 超管身份用于 UpdateUser / DeleteDept。
- superCtx := ctxhelper.SuperAdminCtx()
- // 两个 goroutine 并发:
- // G1: UpdateUser targetId.deptId = deptXId
- // G2: DeleteDept deptXId
- var (
- wg sync.WaitGroup
- upErr atomic.Value
- upOK atomic.Bool
- delErr atomic.Value
- delOK atomic.Bool
- unexpected atomic.Value
- )
- start := make(chan struct{})
- wg.Add(2)
- go func() {
- defer wg.Done()
- <-start
- err := NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
- Id: targetId,
- DeptId: &deptXId,
- })
- if err == nil {
- upOK.Store(true)
- } else {
- upErr.Store(err)
- }
- }()
- go func() {
- defer wg.Done()
- <-start
- err := deptLogic.NewDeleteDeptLogic(superCtx, svcCtx).DeleteDept(&types.DeleteDeptReq{
- Id: deptXId,
- })
- if err == nil {
- delOK.Store(true)
- } else {
- delErr.Store(err)
- }
- }()
- close(start)
- wg.Wait()
- // 允许的结果共两种:
- // A) upOK && !delOK:user.deptId==deptX,sys_dept[deptX] 仍在,DeleteDept 收 400
- // B) !upOK && delOK:user.deptId==deptA(未动),sys_dept[deptX] 已删,UpdateUser 收 400
- // 绝不能同时成功(write skew)。DB 终态须自洽。
- u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
- require.NoError(t, err)
- // dept X 存在性:**绕过 go-zero 的 WithCache**,直接从 MySQL 查真相,避免 UpdateUser
- // 在 FindOne 时预热的缓存把 DeleteDept 的真实删除"遮住"。
- var deptCount int64
- require.NoError(t,
- conn.QueryRowCtx(context.Background(), &deptCount,
- "SELECT COUNT(*) FROM `sys_dept` WHERE `id` = ?", deptXId))
- deptStillThere := deptCount > 0
- switch {
- case upOK.Load() && !delOK.Load():
- assert.Equal(t, deptXId, u.DeptId,
- "M-R11-3:UpdateUser 胜出,user.deptId 必须为 deptX")
- assert.True(t, deptStillThere,
- "M-R11-3:UpdateUser 胜出后 deptX 必须仍存在,否则存在 orphan 引用")
- var ce *response.CodeError
- require.NotNil(t, delErr.Load(), "DeleteDept 应返回 400 解释失败原因")
- if errors.As(delErr.Load().(error), &ce) {
- assert.Equal(t, 400, ce.Code(),
- "M-R11-3:DeleteDept 看到新 user 后必须 400'该部门下仍有关联用户'")
- assert.Contains(t, ce.Error(), "关联用户")
- }
- case !upOK.Load() && delOK.Load():
- assert.Equal(t, deptAId, u.DeptId,
- "M-R11-3:DeleteDept 胜出,user.deptId 必须保持为 deptA(UpdateUser 被拒绝,不得写入)")
- assert.False(t, deptStillThere,
- "M-R11-3:DeleteDept 胜出后 deptX 必须已被删除")
- var ce *response.CodeError
- require.NotNil(t, upErr.Load(), "UpdateUser 应返回 400 解释失败原因")
- if errors.As(upErr.Load().(error), &ce) {
- assert.Equal(t, 400, ce.Code(),
- "M-R11-3:UpdateUser 发现目标 dept 已消失必须 400'部门不存在'")
- assert.Contains(t, ce.Error(), "部门不存在")
- }
- case upOK.Load() && delOK.Load():
- t.Fatalf("M-R11-3 回归:UpdateUser + DeleteDept **同时成功** —— write skew 未被闭合。" +
- "DB 现在持有 user.deptId 指向已被删 dept 的 orphan 数据。")
- case !upOK.Load() && !delOK.Load():
- unexpected.Store(struct{ up, del error }{upErr.Load().(error), delErr.Load().(error)})
- t.Fatalf("M-R11-3:两端都失败是不期望的调度:upErr=%v delErr=%v", upErr.Load(), delErr.Load())
- }
- }
- // TC-1050: M-R11-3 —— UpdateUser 只改 deptId 之外的字段(或 deptId=0)时不进事务(性能与锁范围约束)
- // 这是修复的**对偶契约**:避免 DEV 未来不分 case 把所有 UpdateProfile 都塞进事务 / 或反之。
- // 用"目标部门不存在但仅改 Nickname"的 case 证明:非 deptId 变更路径不需要 FindOneForShareTx,
- // 且走的是 UpdateProfile(非事务)。该契约只能以"只调昵称也能成功"的正向场景间接证实:
- func TestUpdateUser_OnlyNicknameUpdate_DoesNotRequireDeptShareLock(t *testing.T) {
- bootstrap := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_onlyNick", "/3300/")
- targetId := insertTestUserWithDept(t, bootstrap, "m113_onlyNick", deptAId)
- mId := insertTestMember(t, svcCtx, "test_product", targetId)
- t.Cleanup(func() {
- testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
- testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
- testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId)
- })
- newNick := "only_nick_mutate"
- superCtx := ctxhelper.SuperAdminCtx()
- require.NoError(t,
- NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
- Id: targetId, Nickname: &newNick,
- }),
- "M-R11-3 对偶:只改昵称不应走事务路径(若走事务会无谓扩大锁范围)")
- u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
- require.NoError(t, err)
- assert.Equal(t, newNick, u.Nickname)
- assert.Equal(t, deptAId, u.DeptId, "deptId 未变")
- }
- // 备注:原本想写一条"deptId 从 A 改到 A 不走事务路径"的对偶用例,但 MySQL 对"所有字段都
- // 不变"的 UPDATE 返回 RowsAffected=0,UpdateProfile 会把它升格为 ErrUpdateConflict → 409。
- // 这是底层驱动/引擎层的 side-effect,非 M-R11-3 关心的契约。若要验证该对偶,请同时改一个
- // 真实字段(参见上面的 Nickname 用例)。
|