updateDeptLogic.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. package dept
  2. import (
  3. "context"
  4. "errors"
  5. "time"
  6. "perms-system-server/internal/consts"
  7. "perms-system-server/internal/loaders"
  8. authHelper "perms-system-server/internal/logic/auth"
  9. deptModel "perms-system-server/internal/model/dept"
  10. "perms-system-server/internal/response"
  11. "perms-system-server/internal/svc"
  12. "perms-system-server/internal/types"
  13. "github.com/zeromicro/go-zero/core/logx"
  14. "github.com/zeromicro/go-zero/core/stores/sqlx"
  15. )
  16. type UpdateDeptLogic struct {
  17. logx.Logger
  18. ctx context.Context
  19. svcCtx *svc.ServiceContext
  20. }
  21. func NewUpdateDeptLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateDeptLogic {
  22. return &UpdateDeptLogic{
  23. Logger: logx.WithContext(ctx),
  24. ctx: ctx,
  25. svcCtx: svcCtx,
  26. }
  27. }
  28. // UpdateDept 更新部门。修改部门名称、排序、类型、备注或启用/禁用状态。使用乐观锁防止并发冲突,
  29. // 变更部门类型或状态时自动清理受影响用户的权限缓存。
  30. //
  31. // 审计 L-R16-2(签发层吊销,与 M-R15-1 / L-R15-3 对齐):
  32. // `loadPerms` 的"全权分支"直接以 DeptType + DeptStatus 作为授权输入(userDetailsLoader.go:539-
  33. // 554),因此以下三种 UpdateDept 变更构成"权限收窄":
  34. // - (a) DeptType: DEV → NORMAL(本部门所有在编成员丢失 DEV 全权);
  35. // - (b) DeptType 不变但 DEV 部门 Status: Enabled → Disabled(DEV 成员丢失全权);
  36. // - (c) NORMAL 部门 Status: Enabled → Disabled(业务语义是"冻结本部门所有活动",一并吊销)。
  37. //
  38. // 这些场景只靠 `UserDetailsLoader.CleanByUserIds` 尽力而为失效的话,Redis 抖动会让 5min TTL 内
  39. // 的旧权限继续生效;现在把"UPDATE sys_dept + 枚举并 S 锁 sys_user 行 + 批量 tokenVersion+1"
  40. // 收敛进同一事务,任一步失败整体回滚。post-commit 再做 dept/user 低层缓存 + UD 聚合缓存的失效。
  41. // 升权方向(NORMAL→DEV、Disabled→Enabled)不递增 tokenVersion,避免把正在使用的合法会话踢下线。
  42. func (l *UpdateDeptLogic) UpdateDept(req *types.UpdateDeptReq) error {
  43. if err := authHelper.RequireSuperAdmin(l.ctx); err != nil {
  44. return err
  45. }
  46. if len(req.Name) > 64 {
  47. return response.ErrBadRequest("部门名称长度不能超过64个字符")
  48. }
  49. if len(req.Remark) > 255 {
  50. return response.ErrBadRequest("备注长度不能超过255个字符")
  51. }
  52. dept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.Id)
  53. if err != nil {
  54. return response.ErrNotFound("部门不存在")
  55. }
  56. // 快照旧值用于判定"收窄方向"。dept 对象会在下方被 req 覆盖,这里必须先取出原 DeptType/Status。
  57. prevType := dept.DeptType
  58. prevStatus := dept.Status
  59. deptTypeChanged := false
  60. statusChanged := false
  61. dept.Name = req.Name
  62. dept.Sort = req.Sort
  63. dept.Remark = req.Remark
  64. if req.DeptType != "" {
  65. if req.DeptType != consts.DeptTypeNormal && req.DeptType != consts.DeptTypeDev {
  66. return response.ErrBadRequest("部门类型无效,仅支持 NORMAL 和 DEV")
  67. }
  68. if dept.DeptType != req.DeptType {
  69. deptTypeChanged = true
  70. dept.DeptType = req.DeptType
  71. }
  72. }
  73. if req.Status != 0 {
  74. if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
  75. return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
  76. }
  77. if dept.Status != req.Status {
  78. statusChanged = true
  79. dept.Status = req.Status
  80. }
  81. }
  82. expectedUpdateTime := dept.UpdateTime
  83. dept.UpdateTime = time.Now().Unix()
  84. // 审计 L-R16-2:识别是否构成"权限收窄"。
  85. // - devFullAccessRevoked: (a) DEV→NORMAL 或 (b) DEV 部门 Enabled→Disabled;两种都让本部门
  86. // 成员的 loadPerms 从全权分支掉回"角色/allow-deny 计算";
  87. // - normalDeptFrozen: (c) NORMAL 部门 Enabled→Disabled;语义上"冻结部门"。
  88. // 升权方向(NORMAL→DEV、Disabled→Enabled)不进入吊销分支。
  89. nextType := dept.DeptType
  90. nextStatus := dept.Status
  91. devFullAccessRevoked := (prevType == consts.DeptTypeDev && nextType == consts.DeptTypeNormal) ||
  92. (prevType == consts.DeptTypeDev && prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled)
  93. normalDeptFrozen := prevType == consts.DeptTypeNormal && nextType == consts.DeptTypeNormal &&
  94. prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled
  95. shouldRevokeSessions := devFullAccessRevoked || normalDeptFrozen
  96. var revokedUserIds []int64
  97. if err := l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
  98. if err := l.svcCtx.SysDeptModel.UpdateWithOptLockTx(ctx, session, dept, expectedUpdateTime); err != nil {
  99. return err
  100. }
  101. if shouldRevokeSessions {
  102. // FindIdsByDeptIdForShareTx 对命中 sys_user 行加 S 锁,与并发 UpdateProfileWithTx 的
  103. // X 锁互斥——防止"枚举时 A 还在本部门、枚举完 A 被并发挪走却已失效吊销"或反过来的
  104. // 漏吊销窗口。
  105. ids, err := l.svcCtx.SysUserModel.FindIdsByDeptIdForShareTx(ctx, session, req.Id)
  106. if err != nil {
  107. return err
  108. }
  109. if len(ids) > 0 {
  110. if err := l.svcCtx.SysUserModel.BatchIncrementTokenVersionWithTx(ctx, session, ids); err != nil {
  111. return err
  112. }
  113. revokedUserIds = ids
  114. }
  115. }
  116. return nil
  117. }); err != nil {
  118. if errors.Is(err, deptModel.ErrUpdateConflict) {
  119. return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
  120. }
  121. return err
  122. }
  123. // post-commit 三级缓存失效(detached ctx 保证 client 断连时仍能完成):
  124. // ① sys_dept 低层缓存(本行刚被 UPDATE,sysDeptIdKey 需要失效,否则 FindOne 会返回旧值);
  125. // ② sys_user 低层缓存(tokenVersion 已 +1,否则 UD loader 下次 miss 会从 sysUser 低层缓存
  126. // 拿到旧 tokenVersion 把递增值抹回,与 UpdateMember / RemoveMember 同口径);
  127. // ③ UserDetails 聚合缓存(DeptType / DeptStatus / TokenVersion 均是字段,必须 Clean 后
  128. // 重新 Load 才能拿到新授权快照)。
  129. cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
  130. defer cancel()
  131. l.svcCtx.SysDeptModel.InvalidateDeptCache(cleanCtx, req.Id)
  132. if deptTypeChanged || statusChanged {
  133. // CleanByUserIds 在 shouldRevokeSessions=false 分支仍然有用:
  134. // 例如 NORMAL 部门 Disabled→Enabled 是升权方向,不吊销 session,但 UD 聚合缓存里的
  135. // DeptStatus 字段仍然是旧值(Disabled),必须 Clean 让 loadPerms 在下次 Load 时回到
  136. // Enabled 对应的分支(对 DEV 部门意味着重新进入全权分支)。
  137. // 当 shouldRevokeSessions=true 时,revokedUserIds 已经在 tx 内批量 +1,这里顺带把 UD
  138. // 也失效,两级缓存一起回到 cache-miss。
  139. userIds := revokedUserIds
  140. if len(userIds) == 0 {
  141. // 不需要吊销会话的场景(升权 / 非收窄的 deptType/status 切换)也要失效 UD。
  142. ids, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
  143. if err != nil {
  144. l.Errorf("UpdateDept id=%d deptType=%s status=%d 部门已更新但 FindIdsByDeptId 失败,用户权限缓存未能主动失效,将等待 TTL 自然过期: %v", req.Id, dept.DeptType, dept.Status, err)
  145. } else {
  146. userIds = ids
  147. }
  148. }
  149. if len(userIds) > 0 {
  150. l.svcCtx.UserDetailsLoader.CleanByUserIds(cleanCtx, userIds)
  151. }
  152. // 当 shouldRevokeSessions=true 时,每个受影响 sys_user 的低层缓存都要失效,防止 sysUser
  153. // 缓存里的旧 tokenVersion 被 UD loader 下次 miss 时读回、把刚递增的值抹回去。
  154. // FindByIds 批量拿 (id, username),避免对 len(userIds) 次 FindOne。
  155. if shouldRevokeSessions && len(revokedUserIds) > 0 {
  156. users, err := l.svcCtx.SysUserModel.FindByIds(cleanCtx, revokedUserIds)
  157. if err != nil {
  158. logx.WithContext(l.ctx).Errorf("UpdateDept post-commit FindByIds(len=%d) failed for token-version cache invalidation: %v", len(revokedUserIds), err)
  159. } else {
  160. for _, u := range users {
  161. if u == nil {
  162. continue
  163. }
  164. l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, u.Id, u.Username)
  165. }
  166. }
  167. }
  168. l.Infof("UpdateDept id=%d deptType=%s status=%d affectedUsers=%d revokedSessions=%d", req.Id, dept.DeptType, dept.Status, len(userIds), len(revokedUserIds))
  169. }
  170. return nil
  171. }