package user import ( "context" "database/sql" "errors" "fmt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" "math" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" deptLogic "perms-system-server/internal/logic/dept" "perms-system-server/internal/middleware" deptModel "perms-system-server/internal/model/dept" userModel "perms-system-server/internal/model/user" "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" "sync" "sync/atomic" "testing" "time" ) func insertTestDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ Name: testutil.UniqueId(), ParentId: 0, Path: "/", Sort: 0, DeptType: "NORMAL", Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } // TC-0156: 正常更新 func TestUpdateUser_Success(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) deptId := insertTestDept(t, ctx, svcCtx) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Nickname: strPtr("新昵称"), Email: strPtr("new@example.com"), Phone: strPtr("13900139000"), Remark: strPtr("更新备注"), DeptId: int64Ptr(deptId), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "新昵称", user.Nickname) assert.Equal(t, "new@example.com", user.Email) assert.Equal(t, "13900139000", user.Phone) assert.Equal(t, "更新备注", user.Remark) assert.Equal(t, deptId, user.DeptId) } // TC-0157: 不存在 func TestUpdateUser_NotFound(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: 999999999, Nickname: strPtr("ghost"), }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 404, codeErr.Code()) assert.Equal(t, "用户不存在", codeErr.Error()) } // TC-0158: 仅传id func TestUpdateUser_OnlyId_NothingChanges(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) // 真实使用场景: 用户打开编辑页面后再提交, 插入与更新之间存在时间间隔, // updateTime 粒度为秒级, 因此在 INSERT 之后等待 1 秒再发起 UPDATE, 避免同秒 no-op SQL 被 MySQL 视为 0 affected rows. time.Sleep(1100 * time.Millisecond) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{Id: userId}) require.NoError(t, err) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, before.Nickname, after.Nickname) assert.Equal(t, before.Email, after.Email) assert.Equal(t, before.Phone, after.Phone) assert.Equal(t, before.DeptId, after.DeptId) } // TC-0159: 清空nickname func TestUpdateUser_ClearNickname(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Nickname: strPtr(""), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "", user.Nickname) } // TC-0160: 清空email func TestUpdateUser_ClearEmail(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Email: strPtr(""), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "", user.Email) } // TC-0162: 非法email格式 func TestUpdateUser_InvalidEmail(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Email: strPtr("bad-email"), }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "邮箱格式不正确", codeErr.Error()) } // TC-0166: DeptId设为0(取消部门) func TestUpdateUser_DeptIdZero_Clear(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) deptId := insertTestDept(t, ctx, svcCtx) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, DeptId: int64Ptr(deptId), }) require.NoError(t, err) err = logic.UpdateUser(&types.UpdateUserReq{ Id: userId, DeptId: int64Ptr(0), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(0), user.DeptId) } // TC-0167: DeptId设为正值 func TestUpdateUser_DeptIdSet(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) deptId := insertTestDept(t, ctx, svcCtx) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, DeptId: int64Ptr(deptId), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, deptId, user.DeptId) } // TC-0168: DeptId不传(nil) func TestUpdateUser_NilDeptId_Unchanged(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) deptId := insertTestDept(t, ctx, svcCtx) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, DeptId: int64Ptr(deptId), }) require.NoError(t, err) err = logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Nickname: strPtr("changed"), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, deptId, user.DeptId) assert.Equal(t, "changed", user.Nickname) } // TC-0161: 清空remark func TestUpdateUser_ClearRemark(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Remark: strPtr("some remark"), }) require.NoError(t, err) err = logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Remark: strPtr(""), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "", user.Remark) } // TC-0164: 合法phone func TestUpdateUser_ValidInternationalPhone(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Phone: strPtr("+8613800138000"), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "+8613800138000", user.Phone) } // TC-0163: 非法phone格式 func TestUpdateUser_InvalidPhone(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Phone: strPtr("12345"), }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "手机号格式不正确", codeErr.Error()) } // TC-0165: 不传email(nil) func TestUpdateUser_NilEmail_Unchanged(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) originalEmail := before.Email logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Nickname: strPtr("changed-nick"), }) require.NoError(t, err) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, originalEmail, after.Email) assert.Equal(t, "changed-nick", after.Nickname) } // TC-0542: MEMBER用户尝试修改其他用户被CheckManageAccess拒绝 func TestUpdateUser_MemberCannotManageOtherUser(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() targetName := testutil.UniqueId() targetId := insertTestUser(t, superCtx, targetName, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_user`", targetId) }) ctx := ctxhelper.MemberCtx("test_product") logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: targetId, Nickname: strPtr("hacked")}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) } // TC-0170: 产品管理员可以修改其管理范围内的用户信息 func TestUpdateUser_ProductAdminCanManageUser(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() deptId := insertTestDept(t, superCtx, svcCtx) t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_dept`", deptId) }) targetName := testutil.UniqueId() targetId := insertTestUser(t, superCtx, targetName, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_user`", targetId) }) productCode := "test_product" mId := insertTestMember(t, svcCtx, productCode, targetId) t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) }) ctx := ctxhelper.AdminCtx(productCode) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: targetId, Nickname: strPtr("new-nick")}) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(superCtx, targetId) require.NoError(t, err) assert.Equal(t, "new-nick", user.Nickname) } // TC-0171: UpdateUser 昵称超过64字符被拒绝 func TestUpdateUser_NicknameTooLong(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) longNick := string(make([]byte, 65)) for i := range longNick { longNick = longNick[:i] + "a" + longNick[i+1:] } logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: userId, Nickname: &longNick}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Contains(t, ce.Error(), "昵称长度不能超过64个字符") } // TC-0172: UpdateUser 部门不存在被拒绝 func TestUpdateUser_DeptNotExists(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: userId, DeptId: int64Ptr(999999999)}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Contains(t, ce.Error(), "部门不存在") } // TC-0543: updateUser自己修改DeptId被拒绝 func TestUpdateUser_SelfEditDeptIdRejected(t *testing.T) { ctx := ctxhelper.CustomCtx(&loaders.UserDetails{ UserId: 100, Username: "self_user", Status: consts.StatusEnabled, }) svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: 100, DeptId: int64Ptr(5)}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Equal(t, "不允许修改自己的部门和状态", ce.Error()) } // TC-0544: updateUser自己修改Status被拒绝 func TestUpdateUser_SelfEditStatusRejected(t *testing.T) { ctx := ctxhelper.CustomCtx(&loaders.UserDetails{ UserId: 100, Username: "self_user", Status: consts.StatusEnabled, }) svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: 100, Status: 2}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Equal(t, "不允许修改自己的部门和状态", ce.Error()) } // TC-0545: updateUser未登录被拒绝 func TestUpdateUser_NotLoggedInRejected(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: 1, Nickname: strPtr("hacked")}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code()) assert.Equal(t, "未登录", ce.Error()) } // TC-0169: 超管A通过updateUser修改超管B的状态被拒绝(修复验证) func TestUpdateUser_SuperAdminCannotFreezeOtherSuperAdmin(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: testutil.UniqueId(), Password: testutil.HashPassword("pass"), Nickname: "super_b", IsSuperAdmin: consts.IsSuperAdminYes, MustChangePassword: 2, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) superBId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", superBId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{ Id: superBId, Status: consts.StatusDisabled, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) // 最新重构:Status 校验统一走 authHelper.ValidateStatusChange,文案为"不能修改超级管理员的状态" assert.Equal(t, "不能修改超级管理员的状态", ce.Error()) user, err := svcCtx.SysUserModel.FindOne(ctx, superBId) require.NoError(t, err) assert.Equal(t, int64(consts.StatusEnabled), user.Status, "超管B的状态不应被修改") } // TC-0173: updateUser 修改状态时会递增 tokenVersion(修复验证) func TestUpdateUser_StatusChange_IncrementsTokenVersion(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) origTv := before.TokenVersion logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{Id: userId, Status: consts.StatusDisabled}) require.NoError(t, err) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(consts.StatusDisabled), after.Status) assert.Equal(t, origTv+1, after.TokenVersion, "状态变化应递增 tokenVersion") } // TC-0174: updateUser 只改 profile 不会递增 tokenVersion func TestUpdateUser_ProfileOnly_NoTokenVersionChange(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Nickname: strPtr("新名字"), Email: strPtr("new@example.com"), }) require.NoError(t, err) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "新名字", after.Nickname) assert.Equal(t, before.TokenVersion, after.TokenVersion, "不改状态时 tokenVersion 不应变化") } // TC-0175: updateUser 乐观锁冲突 -> 409 // 乐观锁依赖秒级 updateTime, 需在两次更新之间保证 >= 1 秒的间隔, 否则 MySQL 看到的新/旧 updateTime 相同无法生效. func TestUpdateUser_OptimisticLockConflict_Returns409(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) orig, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) time.Sleep(1100 * time.Millisecond) logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{Id: userId, Nickname: strPtr("first")}) require.NoError(t, err) err = svcCtx.SysUserModel.UpdateProfile(ctx, userId, orig.Username, "second", orig.Email, orig.Phone, orig.Remark, orig.DeptId, orig.Status, false, orig.UpdateTime) require.ErrorIs(t, err, userModel.ErrUpdateConflict, "基于旧 updateTime 的更新应失败") } func insertTestDeptForScope(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, tag, path string) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ ParentId: 0, Name: tag + "_" + testutil.UniqueId(), Path: path, Sort: 0, DeptType: "NORMAL", Remark: "", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } func insertTestUserWithDept(t *testing.T, ctx context.Context, tag string, deptId int64) int64 { t.Helper() now := time.Now().Unix() return insertTestUserFull(t, ctx, &userModel.SysUser{ Username: "ddu_" + tag + "_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"), Nickname: "n", Avatar: sql.NullString{}, Email: "x@t.com", Phone: "13800000000", DeptId: deptId, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) } // TC-0746: -F 修复回归 —— DEVELOPER 调用者不得将目标用户的 deptId 调到 // 自己 DeptPath 子树之外的部门。UpdateUser 必须在 req.DeptId 变更时做 Path 前缀校验。 func TestUpdateUser_DeveloperCannotMoveTargetOutsideSubtree(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "caller", "/100/") targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "target", "/100/200/") outsideDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "outside", "/999/") targetId := insertTestUserWithDept(t, bootstrap, "lf_out", targetDeptId) 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`", callerDeptId, targetDeptId, outsideDeptId) }) devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 55555, Username: "lf_dev", IsSuperAdmin: false, MemberType: consts.MemberTypeDeveloper, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: callerDeptId, DeptPath: "/100/", MinPermsLevel: math.MaxInt64, }) newDept := outsideDeptId err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }) require.Error(t, err, "调入外部部门应被拒绝") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Contains(t, ce.Error(), "无权将用户调入") user, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, targetDeptId, user.DeptId, "被拒绝的请求必须不改动 DB") } // TC-0747: -F 正向回归 —— DEVELOPER 将目标用户调入自己子树下的部门应允许。 func TestUpdateUser_DeveloperCanMoveTargetWithinSubtree(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "caller_in", "/200/") srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "src_in", "/200/1/") dstDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dst_in", "/200/2/") targetId := insertTestUserWithDept(t, bootstrap, "lf_in", srcDeptId) 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`", callerDeptId, srcDeptId, dstDeptId) }) devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 66666, Username: "lf_dev_ok", IsSuperAdmin: false, MemberType: consts.MemberTypeDeveloper, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: callerDeptId, DeptPath: "/200/", MinPermsLevel: math.MaxInt64, }) newDept := dstDeptId require.NoError(t, NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }), "caller DeptPath 的前缀子部门必须允许调入") user, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, dstDeptId, user.DeptId) } // TC-0748: -F —— 产品 ADMIN 调用者被豁免 DeptPath 前缀校验(可跨部门转移)。 func TestUpdateUser_ProductAdminExemptFromSubtreeCheck(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "admin_home", "/300/") targetHomeDept := insertTestDeptForScope(t, bootstrap, svcCtx, "target_home", "/400/") anywhereDept := insertTestDeptForScope(t, bootstrap, svcCtx, "anywhere", "/500/") targetId := insertTestUserWithDept(t, bootstrap, "lf_admin", targetHomeDept) 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`", adminDeptId, targetHomeDept, anywhereDept) }) adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 77777, Username: "lf_admin", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: adminDeptId, DeptPath: "/300/", MinPermsLevel: math.MaxInt64, }) newDept := anywhereDept require.NoError(t, NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }), "产品 ADMIN 在 UpdateUser 的 DeptPath 前缀校验中被豁免") } func TestUpdateUser_DeveloperCannotMoveTargetOutOfDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_caller_dev", "/700/") targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_target_dev", "/700/1/") targetId := insertTestUserWithDept(t, bootstrap, "h4_dev", targetDeptId) 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`", callerDeptId, targetDeptId) }) devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 88881, Username: "h4_dev", IsSuperAdmin: false, MemberType: consts.MemberTypeDeveloper, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: callerDeptId, DeptPath: "/700/", MinPermsLevel: math.MaxInt64, }) zero := int64(0) err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &zero, }) require.Error(t, err, "DEVELOPER 不得把目标移出部门树") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Contains(t, ce.Error(), "仅超级管理员或产品管理员可将用户移出部门") u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, targetDeptId, u.DeptId, "被拒绝的请求对 DB 零副作用") } // TC-0815: MEMBER 调用者同理被拒(即便是修改自身的其他字段也不能顺手把自己移出部门)。 // 用户修改自身时,路由层 if caller.UserId == req.Id 分支只拦 DeptId != nil/Status != 0; // 但修改他人为 deptId=0 的分支仍必须 403,以防任何下级调用者漂白组织结构。 func TestUpdateUser_MemberCannotMoveOtherOutOfDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_caller", "/800/") targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_target", "/800/1/") targetId := insertTestUserWithDept(t, bootstrap, "h4_mem", targetDeptId) 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`", callerDeptId, targetDeptId) }) memberCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 88882, Username: "h4_mem", IsSuperAdmin: false, MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: callerDeptId, DeptPath: "/800/", MinPermsLevel: 10, }) zero := int64(0) err := NewUpdateUserLogic(memberCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &zero, }) require.Error(t, err, "MEMBER 更不得移出他人") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, targetDeptId, u.DeptId) } // TC-0816: 产品 ADMIN 有权将他人移出部门(功能不应被修复路径误伤)。 func TestUpdateUser_ProductAdminCanMoveTargetOutOfDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin", "/900/") targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin_target", "/900/1/") targetId := insertTestUserWithDept(t, bootstrap, "h4_admin_tgt", targetDeptId) 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`", adminDeptId, targetDeptId) }) adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 88883, Username: "h4_admin", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: adminDeptId, DeptPath: "/900/", MinPermsLevel: math.MaxInt64, }) zero := int64(0) require.NoError(t, NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &zero, }), "产品 ADMIN 必须仍能执行 deptId=0 的合法运维操作") u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, int64(0), u.DeptId, "ADMIN 的合法 deptId=0 操作必须落盘") } // TC-0817: SuperAdmin 有权将他人移出部门(豁免路径)。 func TestUpdateUser_SuperAdminCanMoveTargetOutOfDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_sa_target", "/950/") targetId := insertTestUserWithDept(t, bootstrap, "h4_sa_tgt", targetDeptId) 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`", targetDeptId) }) zero := int64(0) require.NoError(t, NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &zero, }), "SuperAdmin 的 deptId=0 操作是合法的顶层运维") u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, int64(0), u.DeptId) } func insertEnabledDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, name, path string) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ ParentId: 0, Name: name + "_" + testutil.UniqueId(), Path: path, Sort: 0, DeptType: "NORMAL", Remark: "", Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } // TC-1083: UpdateUser tx 分支在 commit 成功后必须失效 sysUser 低层缓存 func TestUpdateUser_DeptChange_PostCommitInvalidatesSysUserCache(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf) prefix := testutil.GetTestCachePrefix() srcDeptId := insertEnabledDept(t, bootstrap, svcCtx, "r12_1_src", "/r12_1_src/") dstDeptId := insertEnabledDept(t, bootstrap, svcCtx, "r12_1_dst", "/r12_1_dst/") targetId := insertTestUserFull(t, bootstrap, &userModel.SysUser{ Username: "r12_1_upd_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"), Nickname: "orig_nick", Avatar: sql.NullString{}, Email: "r12_1@test.com", Phone: "13800000000", Remark: "orig_remark", DeptId: srcDeptId, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled, }) 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`", srcDeptId, dstDeptId) }) // 走一次 FindOne 预热 id / username 两把低层缓存;UpdateUser 内部 `l.svcCtx.SysUserModel.FindOne` // 也会预热,这里显式做一次把预置断言打实。 pre, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) idKey := fmt.Sprintf("%s:cache:sysUser:id:%d", prefix, targetId) usernameKey := fmt.Sprintf("%s:cache:sysUser:username:%s", prefix, pre.Username) udKey := fmt.Sprintf("%s:ud:%d:%s", prefix, targetId, "test_product") cachedId, err := rds.Get(idKey) require.NoError(t, err) require.NotEmpty(t, cachedId, "预置:sysUser id 缓存已预热") // 先预热 UserDetails 聚合缓存(否则下面判断"Clean 之后为空"会因为本来就空而变成假通过) _, err = svcCtx.UserDetailsLoader.Load(bootstrap, targetId, "test_product") require.NoError(t, err) cachedUd, err := rds.Get(udKey) require.NoError(t, err) require.NotEmpty(t, cachedUd, "预置:UserDetails 聚合缓存已预热") callerCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 88888, Username: "r12_1_super", IsSuperAdmin: true, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", MinPermsLevel: 0, }) newDept := dstDeptId require.NoError(t, NewUpdateUserLogic(callerCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }), "超管改部门应成功走 tx 分支") // 关键断言 1:sysUser:id 低层缓存已被 post-commit 的 InvalidateProfileCache 清掉 afterId, err := rds.Get(idKey) require.NoError(t, err) assert.Empty(t, afterId, "UpdateUser tx 分支返回后,sysUser:id 低层缓存必须已被 InvalidateProfileCache 失效;"+ "若仍有值,则表示 Logic 层遗漏了 post-commit 的显式 DelCache 调用,"+ "并发读回源时会沿用预热时的旧 deptId/昵称/备注 payload") // 关键断言 2:username 低层缓存也要被一并清掉 afterUn, err := rds.Get(usernameKey) require.NoError(t, err) assert.Empty(t, afterUn, "sysUser:username 低层缓存也必须被 InvalidateProfileCache 同批失效") // 关键断言 3:UserDetails 聚合缓存也被清掉(l.svcCtx.UserDetailsLoader.Clean) afterUd, err := rds.Get(udKey) require.NoError(t, err) assert.Empty(t, afterUd, "UserDetailsLoader.Clean 必须在 post-commit 同一阶段被调用,"+ "保证上层聚合缓存和下层 sysUser 缓存一起过期,避免读链任一环读到旧值") // 关键断言 4:下一轮 FindOne 取到新 deptId(双重验证:DB 为权威且缓存已经让步) after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, dstDeptId, after.DeptId, "缓存失效后 FindOne 必须从 DB 读到 tx 已提交的新 deptId;"+ "若缓存未清,这里仍会是 srcDeptId(cache stale 的最终症状)") } 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, "UpdateUser 胜出,user.deptId 必须为 deptX") assert.True(t, deptStillThere, "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(), "DeleteDept 看到新 user 后必须 400'该部门下仍有关联用户'") assert.Contains(t, ce.Error(), "关联用户") } case !upOK.Load() && delOK.Load(): assert.Equal(t, deptAId, u.DeptId, "DeleteDept 胜出,user.deptId 必须保持为 deptA(UpdateUser 被拒绝,不得写入)") assert.False(t, deptStillThere, "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(), "UpdateUser 发现目标 dept 已消失必须 400'部门不存在'") assert.Contains(t, ce.Error(), "部门不存在") } case upOK.Load() && delOK.Load(): t.Fatalf("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("两端都失败是不期望的调度:upErr=%v delErr=%v", upErr.Load(), delErr.Load()) } } // TC-1050: 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, }), "只改昵称不应走事务路径(若走事务会无谓扩大锁范围)") 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,非 关心的契约。若要验证该对偶,请同时改一个 // 真实字段(参见上面的 Nickname 用例)。