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 用例)。