updateUserLogic_test.go 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185
  1. package user
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "github.com/stretchr/testify/assert"
  8. "github.com/stretchr/testify/require"
  9. "github.com/zeromicro/go-zero/core/stores/redis"
  10. "math"
  11. "perms-system-server/internal/consts"
  12. "perms-system-server/internal/loaders"
  13. deptLogic "perms-system-server/internal/logic/dept"
  14. "perms-system-server/internal/middleware"
  15. deptModel "perms-system-server/internal/model/dept"
  16. userModel "perms-system-server/internal/model/user"
  17. "perms-system-server/internal/response"
  18. "perms-system-server/internal/svc"
  19. "perms-system-server/internal/testutil"
  20. "perms-system-server/internal/testutil/ctxhelper"
  21. "perms-system-server/internal/types"
  22. "sync"
  23. "sync/atomic"
  24. "testing"
  25. "time"
  26. )
  27. func insertTestDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext) int64 {
  28. t.Helper()
  29. now := time.Now().Unix()
  30. res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  31. Name: testutil.UniqueId(),
  32. ParentId: 0,
  33. Path: "/",
  34. Sort: 0,
  35. DeptType: "NORMAL",
  36. Status: consts.StatusEnabled,
  37. CreateTime: now,
  38. UpdateTime: now,
  39. })
  40. require.NoError(t, err)
  41. id, _ := res.LastInsertId()
  42. return id
  43. }
  44. // TC-0156: 正常更新
  45. func TestUpdateUser_Success(t *testing.T) {
  46. ctx := ctxhelper.SuperAdminCtx()
  47. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  48. conn := testutil.GetTestSqlConn()
  49. username := testutil.UniqueId()
  50. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  51. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  52. deptId := insertTestDept(t, ctx, svcCtx)
  53. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  54. logic := NewUpdateUserLogic(ctx, svcCtx)
  55. err := logic.UpdateUser(&types.UpdateUserReq{
  56. Id: userId,
  57. Nickname: strPtr("新昵称"),
  58. Email: strPtr("[email protected]"),
  59. Phone: strPtr("13900139000"),
  60. Remark: strPtr("更新备注"),
  61. DeptId: int64Ptr(deptId),
  62. })
  63. require.NoError(t, err)
  64. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  65. require.NoError(t, err)
  66. assert.Equal(t, "新昵称", user.Nickname)
  67. assert.Equal(t, "[email protected]", user.Email)
  68. assert.Equal(t, "13900139000", user.Phone)
  69. assert.Equal(t, "更新备注", user.Remark)
  70. assert.Equal(t, deptId, user.DeptId)
  71. }
  72. // TC-0157: 不存在
  73. func TestUpdateUser_NotFound(t *testing.T) {
  74. ctx := ctxhelper.SuperAdminCtx()
  75. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  76. logic := NewUpdateUserLogic(ctx, svcCtx)
  77. err := logic.UpdateUser(&types.UpdateUserReq{
  78. Id: 999999999,
  79. Nickname: strPtr("ghost"),
  80. })
  81. require.Error(t, err)
  82. var codeErr *response.CodeError
  83. require.True(t, errors.As(err, &codeErr))
  84. assert.Equal(t, 404, codeErr.Code())
  85. assert.Equal(t, "用户不存在", codeErr.Error())
  86. }
  87. // TC-0158: 仅传id
  88. func TestUpdateUser_OnlyId_NothingChanges(t *testing.T) {
  89. ctx := ctxhelper.SuperAdminCtx()
  90. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  91. conn := testutil.GetTestSqlConn()
  92. username := testutil.UniqueId()
  93. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  94. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  95. // 真实使用场景: 用户打开编辑页面后再提交, 插入与更新之间存在时间间隔,
  96. // updateTime 粒度为秒级, 因此在 INSERT 之后等待 1 秒再发起 UPDATE, 避免同秒 no-op SQL 被 MySQL 视为 0 affected rows.
  97. time.Sleep(1100 * time.Millisecond)
  98. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  99. require.NoError(t, err)
  100. logic := NewUpdateUserLogic(ctx, svcCtx)
  101. err = logic.UpdateUser(&types.UpdateUserReq{Id: userId})
  102. require.NoError(t, err)
  103. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  104. require.NoError(t, err)
  105. assert.Equal(t, before.Nickname, after.Nickname)
  106. assert.Equal(t, before.Email, after.Email)
  107. assert.Equal(t, before.Phone, after.Phone)
  108. assert.Equal(t, before.DeptId, after.DeptId)
  109. }
  110. // TC-0159: 清空nickname
  111. func TestUpdateUser_ClearNickname(t *testing.T) {
  112. ctx := ctxhelper.SuperAdminCtx()
  113. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  114. conn := testutil.GetTestSqlConn()
  115. username := testutil.UniqueId()
  116. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  117. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  118. logic := NewUpdateUserLogic(ctx, svcCtx)
  119. err := logic.UpdateUser(&types.UpdateUserReq{
  120. Id: userId,
  121. Nickname: strPtr(""),
  122. })
  123. require.NoError(t, err)
  124. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  125. require.NoError(t, err)
  126. assert.Equal(t, "", user.Nickname)
  127. }
  128. // TC-0160: 清空email
  129. func TestUpdateUser_ClearEmail(t *testing.T) {
  130. ctx := ctxhelper.SuperAdminCtx()
  131. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  132. conn := testutil.GetTestSqlConn()
  133. username := testutil.UniqueId()
  134. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  135. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  136. logic := NewUpdateUserLogic(ctx, svcCtx)
  137. err := logic.UpdateUser(&types.UpdateUserReq{
  138. Id: userId,
  139. Email: strPtr(""),
  140. })
  141. require.NoError(t, err)
  142. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  143. require.NoError(t, err)
  144. assert.Equal(t, "", user.Email)
  145. }
  146. // TC-0162: 非法email格式
  147. func TestUpdateUser_InvalidEmail(t *testing.T) {
  148. ctx := ctxhelper.SuperAdminCtx()
  149. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  150. conn := testutil.GetTestSqlConn()
  151. username := testutil.UniqueId()
  152. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  153. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  154. logic := NewUpdateUserLogic(ctx, svcCtx)
  155. err := logic.UpdateUser(&types.UpdateUserReq{
  156. Id: userId,
  157. Email: strPtr("bad-email"),
  158. })
  159. require.Error(t, err)
  160. var codeErr *response.CodeError
  161. require.True(t, errors.As(err, &codeErr))
  162. assert.Equal(t, 400, codeErr.Code())
  163. assert.Equal(t, "邮箱格式不正确", codeErr.Error())
  164. }
  165. // TC-0166: DeptId设为0(取消部门)
  166. func TestUpdateUser_DeptIdZero_Clear(t *testing.T) {
  167. ctx := ctxhelper.SuperAdminCtx()
  168. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  169. conn := testutil.GetTestSqlConn()
  170. username := testutil.UniqueId()
  171. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  172. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  173. deptId := insertTestDept(t, ctx, svcCtx)
  174. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  175. logic := NewUpdateUserLogic(ctx, svcCtx)
  176. err := logic.UpdateUser(&types.UpdateUserReq{
  177. Id: userId,
  178. DeptId: int64Ptr(deptId),
  179. })
  180. require.NoError(t, err)
  181. err = logic.UpdateUser(&types.UpdateUserReq{
  182. Id: userId,
  183. DeptId: int64Ptr(0),
  184. })
  185. require.NoError(t, err)
  186. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  187. require.NoError(t, err)
  188. assert.Equal(t, int64(0), user.DeptId)
  189. }
  190. // TC-0167: DeptId设为正值
  191. func TestUpdateUser_DeptIdSet(t *testing.T) {
  192. ctx := ctxhelper.SuperAdminCtx()
  193. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  194. conn := testutil.GetTestSqlConn()
  195. username := testutil.UniqueId()
  196. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  197. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  198. deptId := insertTestDept(t, ctx, svcCtx)
  199. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  200. logic := NewUpdateUserLogic(ctx, svcCtx)
  201. err := logic.UpdateUser(&types.UpdateUserReq{
  202. Id: userId,
  203. DeptId: int64Ptr(deptId),
  204. })
  205. require.NoError(t, err)
  206. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  207. require.NoError(t, err)
  208. assert.Equal(t, deptId, user.DeptId)
  209. }
  210. // TC-0168: DeptId不传(nil)
  211. func TestUpdateUser_NilDeptId_Unchanged(t *testing.T) {
  212. ctx := ctxhelper.SuperAdminCtx()
  213. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  214. conn := testutil.GetTestSqlConn()
  215. username := testutil.UniqueId()
  216. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  217. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  218. deptId := insertTestDept(t, ctx, svcCtx)
  219. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  220. logic := NewUpdateUserLogic(ctx, svcCtx)
  221. err := logic.UpdateUser(&types.UpdateUserReq{
  222. Id: userId,
  223. DeptId: int64Ptr(deptId),
  224. })
  225. require.NoError(t, err)
  226. err = logic.UpdateUser(&types.UpdateUserReq{
  227. Id: userId,
  228. Nickname: strPtr("changed"),
  229. })
  230. require.NoError(t, err)
  231. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  232. require.NoError(t, err)
  233. assert.Equal(t, deptId, user.DeptId)
  234. assert.Equal(t, "changed", user.Nickname)
  235. }
  236. // TC-0161: 清空remark
  237. func TestUpdateUser_ClearRemark(t *testing.T) {
  238. ctx := ctxhelper.SuperAdminCtx()
  239. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  240. conn := testutil.GetTestSqlConn()
  241. username := testutil.UniqueId()
  242. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  243. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  244. logic := NewUpdateUserLogic(ctx, svcCtx)
  245. err := logic.UpdateUser(&types.UpdateUserReq{
  246. Id: userId,
  247. Remark: strPtr("some remark"),
  248. })
  249. require.NoError(t, err)
  250. err = logic.UpdateUser(&types.UpdateUserReq{
  251. Id: userId,
  252. Remark: strPtr(""),
  253. })
  254. require.NoError(t, err)
  255. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  256. require.NoError(t, err)
  257. assert.Equal(t, "", user.Remark)
  258. }
  259. // TC-0164: 合法phone
  260. func TestUpdateUser_ValidInternationalPhone(t *testing.T) {
  261. ctx := ctxhelper.SuperAdminCtx()
  262. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  263. conn := testutil.GetTestSqlConn()
  264. username := testutil.UniqueId()
  265. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  266. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  267. logic := NewUpdateUserLogic(ctx, svcCtx)
  268. err := logic.UpdateUser(&types.UpdateUserReq{
  269. Id: userId,
  270. Phone: strPtr("+8613800138000"),
  271. })
  272. require.NoError(t, err)
  273. user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  274. require.NoError(t, err)
  275. assert.Equal(t, "+8613800138000", user.Phone)
  276. }
  277. // TC-0163: 非法phone格式
  278. func TestUpdateUser_InvalidPhone(t *testing.T) {
  279. ctx := ctxhelper.SuperAdminCtx()
  280. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  281. conn := testutil.GetTestSqlConn()
  282. username := testutil.UniqueId()
  283. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  284. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  285. logic := NewUpdateUserLogic(ctx, svcCtx)
  286. err := logic.UpdateUser(&types.UpdateUserReq{
  287. Id: userId,
  288. Phone: strPtr("12345"),
  289. })
  290. require.Error(t, err)
  291. var codeErr *response.CodeError
  292. require.True(t, errors.As(err, &codeErr))
  293. assert.Equal(t, 400, codeErr.Code())
  294. assert.Equal(t, "手机号格式不正确", codeErr.Error())
  295. }
  296. // TC-0165: 不传email(nil)
  297. func TestUpdateUser_NilEmail_Unchanged(t *testing.T) {
  298. ctx := ctxhelper.SuperAdminCtx()
  299. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  300. conn := testutil.GetTestSqlConn()
  301. username := testutil.UniqueId()
  302. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  303. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  304. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  305. require.NoError(t, err)
  306. originalEmail := before.Email
  307. logic := NewUpdateUserLogic(ctx, svcCtx)
  308. err = logic.UpdateUser(&types.UpdateUserReq{
  309. Id: userId,
  310. Nickname: strPtr("changed-nick"),
  311. })
  312. require.NoError(t, err)
  313. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  314. require.NoError(t, err)
  315. assert.Equal(t, originalEmail, after.Email)
  316. assert.Equal(t, "changed-nick", after.Nickname)
  317. }
  318. // TC-0542: MEMBER用户尝试修改其他用户被CheckManageAccess拒绝
  319. func TestUpdateUser_MemberCannotManageOtherUser(t *testing.T) {
  320. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  321. conn := testutil.GetTestSqlConn()
  322. superCtx := ctxhelper.SuperAdminCtx()
  323. targetName := testutil.UniqueId()
  324. targetId := insertTestUser(t, superCtx, targetName, testutil.HashPassword("pass"))
  325. t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_user`", targetId) })
  326. ctx := ctxhelper.MemberCtx("test_product")
  327. logic := NewUpdateUserLogic(ctx, svcCtx)
  328. err := logic.UpdateUser(&types.UpdateUserReq{Id: targetId, Nickname: strPtr("hacked")})
  329. require.Error(t, err)
  330. var ce *response.CodeError
  331. require.True(t, errors.As(err, &ce))
  332. assert.Equal(t, 403, ce.Code())
  333. }
  334. // TC-0170: 产品管理员可以修改其管理范围内的用户信息
  335. func TestUpdateUser_ProductAdminCanManageUser(t *testing.T) {
  336. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  337. conn := testutil.GetTestSqlConn()
  338. superCtx := ctxhelper.SuperAdminCtx()
  339. deptId := insertTestDept(t, superCtx, svcCtx)
  340. t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_dept`", deptId) })
  341. targetName := testutil.UniqueId()
  342. targetId := insertTestUser(t, superCtx, targetName, testutil.HashPassword("pass"))
  343. t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_user`", targetId) })
  344. productCode := "test_product"
  345. mId := insertTestMember(t, svcCtx, productCode, targetId)
  346. t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) })
  347. ctx := ctxhelper.AdminCtx(productCode)
  348. logic := NewUpdateUserLogic(ctx, svcCtx)
  349. err := logic.UpdateUser(&types.UpdateUserReq{Id: targetId, Nickname: strPtr("new-nick")})
  350. require.NoError(t, err)
  351. user, err := svcCtx.SysUserModel.FindOne(superCtx, targetId)
  352. require.NoError(t, err)
  353. assert.Equal(t, "new-nick", user.Nickname)
  354. }
  355. // TC-0171: UpdateUser 昵称超过64字符被拒绝
  356. func TestUpdateUser_NicknameTooLong(t *testing.T) {
  357. ctx := ctxhelper.SuperAdminCtx()
  358. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  359. conn := testutil.GetTestSqlConn()
  360. username := testutil.UniqueId()
  361. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  362. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  363. longNick := string(make([]byte, 65))
  364. for i := range longNick {
  365. longNick = longNick[:i] + "a" + longNick[i+1:]
  366. }
  367. logic := NewUpdateUserLogic(ctx, svcCtx)
  368. err := logic.UpdateUser(&types.UpdateUserReq{Id: userId, Nickname: &longNick})
  369. require.Error(t, err)
  370. var ce *response.CodeError
  371. require.True(t, errors.As(err, &ce))
  372. assert.Equal(t, 400, ce.Code())
  373. assert.Contains(t, ce.Error(), "昵称长度不能超过64个字符")
  374. }
  375. // TC-0172: UpdateUser 部门不存在被拒绝
  376. func TestUpdateUser_DeptNotExists(t *testing.T) {
  377. ctx := ctxhelper.SuperAdminCtx()
  378. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  379. conn := testutil.GetTestSqlConn()
  380. username := testutil.UniqueId()
  381. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  382. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  383. logic := NewUpdateUserLogic(ctx, svcCtx)
  384. err := logic.UpdateUser(&types.UpdateUserReq{Id: userId, DeptId: int64Ptr(999999999)})
  385. require.Error(t, err)
  386. var ce *response.CodeError
  387. require.True(t, errors.As(err, &ce))
  388. assert.Equal(t, 400, ce.Code())
  389. assert.Contains(t, ce.Error(), "部门不存在")
  390. }
  391. // TC-0543: updateUser自己修改DeptId被拒绝
  392. func TestUpdateUser_SelfEditDeptIdRejected(t *testing.T) {
  393. ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
  394. UserId: 100,
  395. Username: "self_user",
  396. Status: consts.StatusEnabled,
  397. })
  398. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  399. logic := NewUpdateUserLogic(ctx, svcCtx)
  400. err := logic.UpdateUser(&types.UpdateUserReq{Id: 100, DeptId: int64Ptr(5)})
  401. require.Error(t, err)
  402. var ce *response.CodeError
  403. require.True(t, errors.As(err, &ce))
  404. assert.Equal(t, 403, ce.Code())
  405. assert.Equal(t, "不允许修改自己的部门和状态", ce.Error())
  406. }
  407. // TC-0544: updateUser自己修改Status被拒绝
  408. func TestUpdateUser_SelfEditStatusRejected(t *testing.T) {
  409. ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
  410. UserId: 100,
  411. Username: "self_user",
  412. Status: consts.StatusEnabled,
  413. })
  414. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  415. logic := NewUpdateUserLogic(ctx, svcCtx)
  416. err := logic.UpdateUser(&types.UpdateUserReq{Id: 100, Status: 2})
  417. require.Error(t, err)
  418. var ce *response.CodeError
  419. require.True(t, errors.As(err, &ce))
  420. assert.Equal(t, 403, ce.Code())
  421. assert.Equal(t, "不允许修改自己的部门和状态", ce.Error())
  422. }
  423. // TC-0545: updateUser未登录被拒绝
  424. func TestUpdateUser_NotLoggedInRejected(t *testing.T) {
  425. ctx := context.Background()
  426. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  427. logic := NewUpdateUserLogic(ctx, svcCtx)
  428. err := logic.UpdateUser(&types.UpdateUserReq{Id: 1, Nickname: strPtr("hacked")})
  429. require.Error(t, err)
  430. var ce *response.CodeError
  431. require.True(t, errors.As(err, &ce))
  432. assert.Equal(t, 401, ce.Code())
  433. assert.Equal(t, "未登录", ce.Error())
  434. }
  435. // TC-0169: 超管A通过updateUser修改超管B的状态被拒绝(修复验证)
  436. func TestUpdateUser_SuperAdminCannotFreezeOtherSuperAdmin(t *testing.T) {
  437. ctx := ctxhelper.SuperAdminCtx()
  438. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  439. conn := testutil.GetTestSqlConn()
  440. now := time.Now().Unix()
  441. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  442. Username: testutil.UniqueId(),
  443. Password: testutil.HashPassword("pass"),
  444. Nickname: "super_b",
  445. IsSuperAdmin: consts.IsSuperAdminYes,
  446. MustChangePassword: 2,
  447. Status: consts.StatusEnabled,
  448. CreateTime: now,
  449. UpdateTime: now,
  450. })
  451. require.NoError(t, err)
  452. superBId, _ := uRes.LastInsertId()
  453. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", superBId) })
  454. logic := NewUpdateUserLogic(ctx, svcCtx)
  455. err = logic.UpdateUser(&types.UpdateUserReq{
  456. Id: superBId,
  457. Status: consts.StatusDisabled,
  458. })
  459. require.Error(t, err)
  460. var ce *response.CodeError
  461. require.True(t, errors.As(err, &ce))
  462. assert.Equal(t, 403, ce.Code())
  463. // 最新重构:Status 校验统一走 authHelper.ValidateStatusChange,文案为"不能修改超级管理员的状态"
  464. assert.Equal(t, "不能修改超级管理员的状态", ce.Error())
  465. user, err := svcCtx.SysUserModel.FindOne(ctx, superBId)
  466. require.NoError(t, err)
  467. assert.Equal(t, int64(consts.StatusEnabled), user.Status, "超管B的状态不应被修改")
  468. }
  469. // TC-0173: updateUser 修改状态时会递增 tokenVersion(修复验证)
  470. func TestUpdateUser_StatusChange_IncrementsTokenVersion(t *testing.T) {
  471. ctx := ctxhelper.SuperAdminCtx()
  472. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  473. conn := testutil.GetTestSqlConn()
  474. username := testutil.UniqueId()
  475. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  476. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  477. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  478. require.NoError(t, err)
  479. origTv := before.TokenVersion
  480. logic := NewUpdateUserLogic(ctx, svcCtx)
  481. err = logic.UpdateUser(&types.UpdateUserReq{Id: userId, Status: consts.StatusDisabled})
  482. require.NoError(t, err)
  483. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  484. require.NoError(t, err)
  485. assert.Equal(t, int64(consts.StatusDisabled), after.Status)
  486. assert.Equal(t, origTv+1, after.TokenVersion, "状态变化应递增 tokenVersion")
  487. }
  488. // TC-0174: updateUser 只改 profile 不会递增 tokenVersion
  489. func TestUpdateUser_ProfileOnly_NoTokenVersionChange(t *testing.T) {
  490. ctx := ctxhelper.SuperAdminCtx()
  491. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  492. conn := testutil.GetTestSqlConn()
  493. username := testutil.UniqueId()
  494. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  495. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  496. before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  497. require.NoError(t, err)
  498. logic := NewUpdateUserLogic(ctx, svcCtx)
  499. err = logic.UpdateUser(&types.UpdateUserReq{
  500. Id: userId,
  501. Nickname: strPtr("新名字"),
  502. Email: strPtr("[email protected]"),
  503. })
  504. require.NoError(t, err)
  505. after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  506. require.NoError(t, err)
  507. assert.Equal(t, "新名字", after.Nickname)
  508. assert.Equal(t, before.TokenVersion, after.TokenVersion, "不改状态时 tokenVersion 不应变化")
  509. }
  510. // TC-0175: updateUser 乐观锁冲突 -> 409
  511. // 乐观锁依赖秒级 updateTime, 需在两次更新之间保证 >= 1 秒的间隔, 否则 MySQL 看到的新/旧 updateTime 相同无法生效.
  512. func TestUpdateUser_OptimisticLockConflict_Returns409(t *testing.T) {
  513. ctx := ctxhelper.SuperAdminCtx()
  514. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  515. conn := testutil.GetTestSqlConn()
  516. username := testutil.UniqueId()
  517. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  518. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  519. orig, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  520. require.NoError(t, err)
  521. time.Sleep(1100 * time.Millisecond)
  522. logic := NewUpdateUserLogic(ctx, svcCtx)
  523. err = logic.UpdateUser(&types.UpdateUserReq{Id: userId, Nickname: strPtr("first")})
  524. require.NoError(t, err)
  525. err = svcCtx.SysUserModel.UpdateProfile(ctx, userId, orig.Username, "second",
  526. orig.Email, orig.Phone, orig.Remark, orig.DeptId, orig.Status, false, orig.UpdateTime)
  527. require.ErrorIs(t, err, userModel.ErrUpdateConflict, "基于旧 updateTime 的更新应失败")
  528. }
  529. func insertTestDeptForScope(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, tag, path string) int64 {
  530. t.Helper()
  531. now := time.Now().Unix()
  532. res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  533. ParentId: 0, Name: tag + "_" + testutil.UniqueId(), Path: path, Sort: 0,
  534. DeptType: "NORMAL", Remark: "", Status: 1, CreateTime: now, UpdateTime: now,
  535. })
  536. require.NoError(t, err)
  537. id, _ := res.LastInsertId()
  538. return id
  539. }
  540. func insertTestUserWithDept(t *testing.T, ctx context.Context, tag string, deptId int64) int64 {
  541. t.Helper()
  542. now := time.Now().Unix()
  543. return insertTestUserFull(t, ctx, &userModel.SysUser{
  544. Username: "ddu_" + tag + "_" + testutil.UniqueId(),
  545. Password: testutil.HashPassword("pw"),
  546. Nickname: "n",
  547. Avatar: sql.NullString{},
  548. Email: "[email protected]",
  549. Phone: "13800000000",
  550. DeptId: deptId,
  551. IsSuperAdmin: consts.IsSuperAdminNo,
  552. MustChangePassword: 2,
  553. Status: consts.StatusEnabled,
  554. CreateTime: now,
  555. UpdateTime: now,
  556. })
  557. }
  558. // TC-0746: -F 修复回归 —— DEVELOPER 调用者不得将目标用户的 deptId 调到
  559. // 自己 DeptPath 子树之外的部门。UpdateUser 必须在 req.DeptId 变更时做 Path 前缀校验。
  560. func TestUpdateUser_DeveloperCannotMoveTargetOutsideSubtree(t *testing.T) {
  561. bootstrap := context.Background()
  562. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  563. conn := testutil.GetTestSqlConn()
  564. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "caller", "/100/")
  565. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "target", "/100/200/")
  566. outsideDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "outside", "/999/")
  567. targetId := insertTestUserWithDept(t, bootstrap, "lf_out", targetDeptId)
  568. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  569. t.Cleanup(func() {
  570. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  571. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  572. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId, outsideDeptId)
  573. })
  574. devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  575. UserId: 55555, Username: "lf_dev",
  576. IsSuperAdmin: false,
  577. MemberType: consts.MemberTypeDeveloper,
  578. Status: consts.StatusEnabled,
  579. ProductCode: "test_product",
  580. DeptId: callerDeptId,
  581. DeptPath: "/100/",
  582. MinPermsLevel: math.MaxInt64,
  583. })
  584. newDept := outsideDeptId
  585. err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  586. Id: targetId,
  587. DeptId: &newDept,
  588. })
  589. require.Error(t, err, "调入外部部门应被拒绝")
  590. var ce *response.CodeError
  591. require.True(t, errors.As(err, &ce))
  592. assert.Equal(t, 403, ce.Code())
  593. assert.Contains(t, ce.Error(), "无权将用户调入")
  594. user, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  595. require.NoError(t, err)
  596. assert.Equal(t, targetDeptId, user.DeptId, "被拒绝的请求必须不改动 DB")
  597. }
  598. // TC-0747: -F 正向回归 —— DEVELOPER 将目标用户调入自己子树下的部门应允许。
  599. func TestUpdateUser_DeveloperCanMoveTargetWithinSubtree(t *testing.T) {
  600. bootstrap := context.Background()
  601. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  602. conn := testutil.GetTestSqlConn()
  603. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "caller_in", "/200/")
  604. srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "src_in", "/200/1/")
  605. dstDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dst_in", "/200/2/")
  606. targetId := insertTestUserWithDept(t, bootstrap, "lf_in", srcDeptId)
  607. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  608. t.Cleanup(func() {
  609. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  610. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  611. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, srcDeptId, dstDeptId)
  612. })
  613. devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  614. UserId: 66666, Username: "lf_dev_ok",
  615. IsSuperAdmin: false,
  616. MemberType: consts.MemberTypeDeveloper,
  617. Status: consts.StatusEnabled,
  618. ProductCode: "test_product",
  619. DeptId: callerDeptId,
  620. DeptPath: "/200/",
  621. MinPermsLevel: math.MaxInt64,
  622. })
  623. newDept := dstDeptId
  624. require.NoError(t,
  625. NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  626. Id: targetId, DeptId: &newDept,
  627. }),
  628. "caller DeptPath 的前缀子部门必须允许调入")
  629. user, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  630. require.NoError(t, err)
  631. assert.Equal(t, dstDeptId, user.DeptId)
  632. }
  633. // TC-0748: -F —— 产品 ADMIN 调用者被豁免 DeptPath 前缀校验(可跨部门转移)。
  634. func TestUpdateUser_ProductAdminExemptFromSubtreeCheck(t *testing.T) {
  635. bootstrap := context.Background()
  636. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  637. conn := testutil.GetTestSqlConn()
  638. adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "admin_home", "/300/")
  639. targetHomeDept := insertTestDeptForScope(t, bootstrap, svcCtx, "target_home", "/400/")
  640. anywhereDept := insertTestDeptForScope(t, bootstrap, svcCtx, "anywhere", "/500/")
  641. targetId := insertTestUserWithDept(t, bootstrap, "lf_admin", targetHomeDept)
  642. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  643. t.Cleanup(func() {
  644. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  645. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  646. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, targetHomeDept, anywhereDept)
  647. })
  648. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  649. UserId: 77777, Username: "lf_admin",
  650. IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
  651. Status: consts.StatusEnabled, ProductCode: "test_product",
  652. DeptId: adminDeptId, DeptPath: "/300/", MinPermsLevel: math.MaxInt64,
  653. })
  654. newDept := anywhereDept
  655. require.NoError(t,
  656. NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  657. Id: targetId, DeptId: &newDept,
  658. }),
  659. "产品 ADMIN 在 UpdateUser 的 DeptPath 前缀校验中被豁免")
  660. }
  661. func TestUpdateUser_DeveloperCannotMoveTargetOutOfDept(t *testing.T) {
  662. bootstrap := context.Background()
  663. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  664. conn := testutil.GetTestSqlConn()
  665. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_caller_dev", "/700/")
  666. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_target_dev", "/700/1/")
  667. targetId := insertTestUserWithDept(t, bootstrap, "h4_dev", targetDeptId)
  668. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  669. t.Cleanup(func() {
  670. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  671. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  672. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId)
  673. })
  674. devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  675. UserId: 88881,
  676. Username: "h4_dev",
  677. IsSuperAdmin: false,
  678. MemberType: consts.MemberTypeDeveloper,
  679. Status: consts.StatusEnabled,
  680. ProductCode: "test_product",
  681. DeptId: callerDeptId,
  682. DeptPath: "/700/",
  683. MinPermsLevel: math.MaxInt64,
  684. })
  685. zero := int64(0)
  686. err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  687. Id: targetId,
  688. DeptId: &zero,
  689. })
  690. require.Error(t, err, "DEVELOPER 不得把目标移出部门树")
  691. var ce *response.CodeError
  692. require.True(t, errors.As(err, &ce))
  693. assert.Equal(t, 403, ce.Code())
  694. assert.Contains(t, ce.Error(), "仅超级管理员或产品管理员可将用户移出部门")
  695. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  696. require.NoError(t, err)
  697. assert.Equal(t, targetDeptId, u.DeptId, "被拒绝的请求对 DB 零副作用")
  698. }
  699. // TC-0815: MEMBER 调用者同理被拒(即便是修改自身的其他字段也不能顺手把自己移出部门)。
  700. // 用户修改自身时,路由层 if caller.UserId == req.Id 分支只拦 DeptId != nil/Status != 0;
  701. // 但修改他人为 deptId=0 的分支仍必须 403,以防任何下级调用者漂白组织结构。
  702. func TestUpdateUser_MemberCannotMoveOtherOutOfDept(t *testing.T) {
  703. bootstrap := context.Background()
  704. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  705. conn := testutil.GetTestSqlConn()
  706. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_caller", "/800/")
  707. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_target", "/800/1/")
  708. targetId := insertTestUserWithDept(t, bootstrap, "h4_mem", targetDeptId)
  709. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  710. t.Cleanup(func() {
  711. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  712. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  713. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId)
  714. })
  715. memberCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  716. UserId: 88882,
  717. Username: "h4_mem",
  718. IsSuperAdmin: false,
  719. MemberType: consts.MemberTypeMember,
  720. Status: consts.StatusEnabled,
  721. ProductCode: "test_product",
  722. DeptId: callerDeptId,
  723. DeptPath: "/800/",
  724. MinPermsLevel: 10,
  725. })
  726. zero := int64(0)
  727. err := NewUpdateUserLogic(memberCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  728. Id: targetId,
  729. DeptId: &zero,
  730. })
  731. require.Error(t, err, "MEMBER 更不得移出他人")
  732. var ce *response.CodeError
  733. require.True(t, errors.As(err, &ce))
  734. assert.Equal(t, 403, ce.Code())
  735. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  736. require.NoError(t, err)
  737. assert.Equal(t, targetDeptId, u.DeptId)
  738. }
  739. // TC-0816: 产品 ADMIN 有权将他人移出部门(功能不应被修复路径误伤)。
  740. func TestUpdateUser_ProductAdminCanMoveTargetOutOfDept(t *testing.T) {
  741. bootstrap := context.Background()
  742. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  743. conn := testutil.GetTestSqlConn()
  744. adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin", "/900/")
  745. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin_target", "/900/1/")
  746. targetId := insertTestUserWithDept(t, bootstrap, "h4_admin_tgt", targetDeptId)
  747. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  748. t.Cleanup(func() {
  749. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  750. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  751. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, targetDeptId)
  752. })
  753. adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  754. UserId: 88883, Username: "h4_admin",
  755. IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
  756. Status: consts.StatusEnabled, ProductCode: "test_product",
  757. DeptId: adminDeptId, DeptPath: "/900/", MinPermsLevel: math.MaxInt64,
  758. })
  759. zero := int64(0)
  760. require.NoError(t,
  761. NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  762. Id: targetId, DeptId: &zero,
  763. }),
  764. "产品 ADMIN 必须仍能执行 deptId=0 的合法运维操作")
  765. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  766. require.NoError(t, err)
  767. assert.Equal(t, int64(0), u.DeptId, "ADMIN 的合法 deptId=0 操作必须落盘")
  768. }
  769. // TC-0817: SuperAdmin 有权将他人移出部门(豁免路径)。
  770. func TestUpdateUser_SuperAdminCanMoveTargetOutOfDept(t *testing.T) {
  771. bootstrap := context.Background()
  772. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  773. conn := testutil.GetTestSqlConn()
  774. targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_sa_target", "/950/")
  775. targetId := insertTestUserWithDept(t, bootstrap, "h4_sa_tgt", targetDeptId)
  776. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  777. t.Cleanup(func() {
  778. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  779. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  780. testutil.CleanTable(bootstrap, conn, "`sys_dept`", targetDeptId)
  781. })
  782. zero := int64(0)
  783. require.NoError(t,
  784. NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{
  785. Id: targetId, DeptId: &zero,
  786. }),
  787. "SuperAdmin 的 deptId=0 操作是合法的顶层运维")
  788. u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  789. require.NoError(t, err)
  790. assert.Equal(t, int64(0), u.DeptId)
  791. }
  792. func insertEnabledDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, name, path string) int64 {
  793. t.Helper()
  794. now := time.Now().Unix()
  795. res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  796. ParentId: 0, Name: name + "_" + testutil.UniqueId(), Path: path, Sort: 0,
  797. DeptType: "NORMAL", Remark: "", Status: consts.StatusEnabled,
  798. CreateTime: now, UpdateTime: now,
  799. })
  800. require.NoError(t, err)
  801. id, _ := res.LastInsertId()
  802. return id
  803. }
  804. // TC-1083: UpdateUser tx 分支在 commit 成功后必须失效 sysUser 低层缓存
  805. func TestUpdateUser_DeptChange_PostCommitInvalidatesSysUserCache(t *testing.T) {
  806. bootstrap := context.Background()
  807. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  808. conn := testutil.GetTestSqlConn()
  809. rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
  810. prefix := testutil.GetTestCachePrefix()
  811. srcDeptId := insertEnabledDept(t, bootstrap, svcCtx, "r12_1_src", "/r12_1_src/")
  812. dstDeptId := insertEnabledDept(t, bootstrap, svcCtx, "r12_1_dst", "/r12_1_dst/")
  813. targetId := insertTestUserFull(t, bootstrap, &userModel.SysUser{
  814. Username: "r12_1_upd_" + testutil.UniqueId(),
  815. Password: testutil.HashPassword("pw"),
  816. Nickname: "orig_nick",
  817. Avatar: sql.NullString{},
  818. Email: "[email protected]",
  819. Phone: "13800000000",
  820. Remark: "orig_remark",
  821. DeptId: srcDeptId,
  822. IsSuperAdmin: consts.IsSuperAdminNo,
  823. MustChangePassword: 2,
  824. Status: consts.StatusEnabled,
  825. })
  826. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  827. t.Cleanup(func() {
  828. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  829. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  830. testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, dstDeptId)
  831. })
  832. // 走一次 FindOne 预热 id / username 两把低层缓存;UpdateUser 内部 `l.svcCtx.SysUserModel.FindOne`
  833. // 也会预热,这里显式做一次把预置断言打实。
  834. pre, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  835. require.NoError(t, err)
  836. idKey := fmt.Sprintf("%s:cache:sysUser:id:%d", prefix, targetId)
  837. usernameKey := fmt.Sprintf("%s:cache:sysUser:username:%s", prefix, pre.Username)
  838. udKey := fmt.Sprintf("%s:ud:%d:%s", prefix, targetId, "test_product")
  839. cachedId, err := rds.Get(idKey)
  840. require.NoError(t, err)
  841. require.NotEmpty(t, cachedId, "预置:sysUser id 缓存已预热")
  842. // 先预热 UserDetails 聚合缓存(否则下面判断"Clean 之后为空"会因为本来就空而变成假通过)
  843. _, err = svcCtx.UserDetailsLoader.Load(bootstrap, targetId, "test_product")
  844. require.NoError(t, err)
  845. cachedUd, err := rds.Get(udKey)
  846. require.NoError(t, err)
  847. require.NotEmpty(t, cachedUd, "预置:UserDetails 聚合缓存已预热")
  848. callerCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  849. UserId: 88888, Username: "r12_1_super",
  850. IsSuperAdmin: true,
  851. MemberType: consts.MemberTypeAdmin,
  852. Status: consts.StatusEnabled,
  853. ProductCode: "test_product",
  854. MinPermsLevel: 0,
  855. })
  856. newDept := dstDeptId
  857. require.NoError(t,
  858. NewUpdateUserLogic(callerCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  859. Id: targetId,
  860. DeptId: &newDept,
  861. }),
  862. "超管改部门应成功走 tx 分支")
  863. // 关键断言 1:sysUser:id 低层缓存已被 post-commit 的 InvalidateProfileCache 清掉
  864. afterId, err := rds.Get(idKey)
  865. require.NoError(t, err)
  866. assert.Empty(t, afterId,
  867. "UpdateUser tx 分支返回后,sysUser:id 低层缓存必须已被 InvalidateProfileCache 失效;"+
  868. "若仍有值,则表示 Logic 层遗漏了 post-commit 的显式 DelCache 调用,"+
  869. "并发读回源时会沿用预热时的旧 deptId/昵称/备注 payload")
  870. // 关键断言 2:username 低层缓存也要被一并清掉
  871. afterUn, err := rds.Get(usernameKey)
  872. require.NoError(t, err)
  873. assert.Empty(t, afterUn,
  874. "sysUser:username 低层缓存也必须被 InvalidateProfileCache 同批失效")
  875. // 关键断言 3:UserDetails 聚合缓存也被清掉(l.svcCtx.UserDetailsLoader.Clean)
  876. afterUd, err := rds.Get(udKey)
  877. require.NoError(t, err)
  878. assert.Empty(t, afterUd,
  879. "UserDetailsLoader.Clean 必须在 post-commit 同一阶段被调用,"+
  880. "保证上层聚合缓存和下层 sysUser 缓存一起过期,避免读链任一环读到旧值")
  881. // 关键断言 4:下一轮 FindOne 取到新 deptId(双重验证:DB 为权威且缓存已经让步)
  882. after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
  883. require.NoError(t, err)
  884. assert.Equal(t, dstDeptId, after.DeptId,
  885. "缓存失效后 FindOne 必须从 DB 读到 tx 已提交的新 deptId;"+
  886. "若缓存未清,这里仍会是 srcDeptId(cache stale 的最终症状)")
  887. }
  888. func TestUpdateUser_DeptIdSwitch_VsDeleteDept_NoWriteSkew(t *testing.T) {
  889. bootstrap := ctxhelper.SuperAdminCtx()
  890. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  891. conn := testutil.GetTestSqlConn()
  892. // 构造:用户 U 在 deptA,新候选部门 deptX 空(无子部门 + 无关联用户,满足 DeleteDept 可删条件)。
  893. deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptA", "/3100/")
  894. deptXId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptX", "/3200/")
  895. targetId := insertTestUserWithDept(t, bootstrap, "m113_user", deptAId)
  896. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  897. t.Cleanup(func() {
  898. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  899. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  900. testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId, deptXId)
  901. })
  902. // 超管身份用于 UpdateUser / DeleteDept。
  903. superCtx := ctxhelper.SuperAdminCtx()
  904. // 两个 goroutine 并发:
  905. // G1: UpdateUser targetId.deptId = deptXId
  906. // G2: DeleteDept deptXId
  907. var (
  908. wg sync.WaitGroup
  909. upErr atomic.Value
  910. upOK atomic.Bool
  911. delErr atomic.Value
  912. delOK atomic.Bool
  913. unexpected atomic.Value
  914. )
  915. start := make(chan struct{})
  916. wg.Add(2)
  917. go func() {
  918. defer wg.Done()
  919. <-start
  920. err := NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  921. Id: targetId,
  922. DeptId: &deptXId,
  923. })
  924. if err == nil {
  925. upOK.Store(true)
  926. } else {
  927. upErr.Store(err)
  928. }
  929. }()
  930. go func() {
  931. defer wg.Done()
  932. <-start
  933. err := deptLogic.NewDeleteDeptLogic(superCtx, svcCtx).DeleteDept(&types.DeleteDeptReq{
  934. Id: deptXId,
  935. })
  936. if err == nil {
  937. delOK.Store(true)
  938. } else {
  939. delErr.Store(err)
  940. }
  941. }()
  942. close(start)
  943. wg.Wait()
  944. // 允许的结果共两种:
  945. // A) upOK && !delOK:user.deptId==deptX,sys_dept[deptX] 仍在,DeleteDept 收 400
  946. // B) !upOK && delOK:user.deptId==deptA(未动),sys_dept[deptX] 已删,UpdateUser 收 400
  947. // 绝不能同时成功(write skew)。DB 终态须自洽。
  948. u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
  949. require.NoError(t, err)
  950. // dept X 存在性:**绕过 go-zero 的 WithCache**,直接从 MySQL 查真相,避免 UpdateUser
  951. // 在 FindOne 时预热的缓存把 DeleteDept 的真实删除"遮住"。
  952. var deptCount int64
  953. require.NoError(t,
  954. conn.QueryRowCtx(context.Background(), &deptCount,
  955. "SELECT COUNT(*) FROM `sys_dept` WHERE `id` = ?", deptXId))
  956. deptStillThere := deptCount > 0
  957. switch {
  958. case upOK.Load() && !delOK.Load():
  959. assert.Equal(t, deptXId, u.DeptId,
  960. "UpdateUser 胜出,user.deptId 必须为 deptX")
  961. assert.True(t, deptStillThere,
  962. "UpdateUser 胜出后 deptX 必须仍存在,否则存在 orphan 引用")
  963. var ce *response.CodeError
  964. require.NotNil(t, delErr.Load(), "DeleteDept 应返回 400 解释失败原因")
  965. if errors.As(delErr.Load().(error), &ce) {
  966. assert.Equal(t, 400, ce.Code(),
  967. "DeleteDept 看到新 user 后必须 400'该部门下仍有关联用户'")
  968. assert.Contains(t, ce.Error(), "关联用户")
  969. }
  970. case !upOK.Load() && delOK.Load():
  971. assert.Equal(t, deptAId, u.DeptId,
  972. "DeleteDept 胜出,user.deptId 必须保持为 deptA(UpdateUser 被拒绝,不得写入)")
  973. assert.False(t, deptStillThere,
  974. "DeleteDept 胜出后 deptX 必须已被删除")
  975. var ce *response.CodeError
  976. require.NotNil(t, upErr.Load(), "UpdateUser 应返回 400 解释失败原因")
  977. if errors.As(upErr.Load().(error), &ce) {
  978. assert.Equal(t, 400, ce.Code(),
  979. "UpdateUser 发现目标 dept 已消失必须 400'部门不存在'")
  980. assert.Contains(t, ce.Error(), "部门不存在")
  981. }
  982. case upOK.Load() && delOK.Load():
  983. t.Fatalf("UpdateUser + DeleteDept **同时成功** —— write skew 未被闭合。" +
  984. "DB 现在持有 user.deptId 指向已被删 dept 的 orphan 数据。")
  985. case !upOK.Load() && !delOK.Load():
  986. unexpected.Store(struct{ up, del error }{upErr.Load().(error), delErr.Load().(error)})
  987. t.Fatalf("两端都失败是不期望的调度:upErr=%v delErr=%v", upErr.Load(), delErr.Load())
  988. }
  989. }
  990. // TC-1050: UpdateUser 只改 deptId 之外的字段(或 deptId=0)时不进事务(性能与锁范围约束)
  991. // 这是修复的**对偶契约**:避免 DEV 未来不分 case 把所有 UpdateProfile 都塞进事务 / 或反之。
  992. // 用"目标部门不存在但仅改 Nickname"的 case 证明:非 deptId 变更路径不需要 FindOneForShareTx,
  993. // 且走的是 UpdateProfile(非事务)。该契约只能以"只调昵称也能成功"的正向场景间接证实:
  994. func TestUpdateUser_OnlyNicknameUpdate_DoesNotRequireDeptShareLock(t *testing.T) {
  995. bootstrap := ctxhelper.SuperAdminCtx()
  996. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  997. conn := testutil.GetTestSqlConn()
  998. deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_onlyNick", "/3300/")
  999. targetId := insertTestUserWithDept(t, bootstrap, "m113_onlyNick", deptAId)
  1000. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  1001. t.Cleanup(func() {
  1002. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  1003. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  1004. testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId)
  1005. })
  1006. newNick := "only_nick_mutate"
  1007. superCtx := ctxhelper.SuperAdminCtx()
  1008. require.NoError(t,
  1009. NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  1010. Id: targetId, Nickname: &newNick,
  1011. }),
  1012. "只改昵称不应走事务路径(若走事务会无谓扩大锁范围)")
  1013. u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
  1014. require.NoError(t, err)
  1015. assert.Equal(t, newNick, u.Nickname)
  1016. assert.Equal(t, deptAId, u.DeptId, "deptId 未变")
  1017. }
  1018. // 备注:原本想写一条"deptId 从 A 改到 A 不走事务路径"的对偶用例,但 MySQL 对"所有字段都
  1019. // 不变"的 UPDATE 返回 RowsAffected=0,UpdateProfile 会把它升格为 ErrUpdateConflict → 409。
  1020. // 这是底层驱动/引擎层的 side-effect,非 关心的契约。若要验证该对偶,请同时改一个
  1021. // 真实字段(参见上面的 Nickname 用例)。