updateDeptLogic.go 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  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. // 审计 L-R18-5:与 CreateDept 同口径,把 Sort 合法区间约束为 [-100000, 100000],
  53. // 避免越界值被透传到 DB 产生排序异常。
  54. if req.Sort < -100000 || req.Sort > 100000 {
  55. return response.ErrBadRequest("排序值必须在 -100000 到 100000 之间")
  56. }
  57. dept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.Id)
  58. if err != nil {
  59. return response.ErrNotFound("部门不存在")
  60. }
  61. // 快照旧值用于判定"收窄方向"。dept 对象会在下方被 req 覆盖,这里必须先取出原 DeptType/Status。
  62. prevType := dept.DeptType
  63. prevStatus := dept.Status
  64. deptTypeChanged := false
  65. statusChanged := false
  66. dept.Name = req.Name
  67. dept.Sort = req.Sort
  68. dept.Remark = req.Remark
  69. if req.DeptType != "" {
  70. if req.DeptType != consts.DeptTypeNormal && req.DeptType != consts.DeptTypeDev {
  71. return response.ErrBadRequest("部门类型无效,仅支持 NORMAL 和 DEV")
  72. }
  73. if dept.DeptType != req.DeptType {
  74. deptTypeChanged = true
  75. dept.DeptType = req.DeptType
  76. }
  77. }
  78. if req.Status != 0 {
  79. if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
  80. return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
  81. }
  82. if dept.Status != req.Status {
  83. statusChanged = true
  84. dept.Status = req.Status
  85. }
  86. }
  87. expectedUpdateTime := dept.UpdateTime
  88. dept.UpdateTime = time.Now().Unix()
  89. // 审计 L-R16-2:识别是否构成"权限收窄"。
  90. // - devFullAccessRevoked: (a) DEV→NORMAL 或 (b) DEV 部门 Enabled→Disabled;两种都让本部门
  91. // 成员的 loadPerms 从全权分支掉回"角色/allow-deny 计算";
  92. // - normalDeptFrozen: (c) NORMAL 部门 Enabled→Disabled;语义上"冻结部门"。
  93. // 升权方向(NORMAL→DEV、Disabled→Enabled)不进入吊销分支。
  94. nextType := dept.DeptType
  95. nextStatus := dept.Status
  96. devFullAccessRevoked := (prevType == consts.DeptTypeDev && nextType == consts.DeptTypeNormal) ||
  97. (prevType == consts.DeptTypeDev && prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled)
  98. normalDeptFrozen := prevType == consts.DeptTypeNormal && nextType == consts.DeptTypeNormal &&
  99. prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled
  100. shouldRevokeSessions := devFullAccessRevoked || normalDeptFrozen
  101. var revokedUserIds []int64
  102. if err := l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
  103. if err := l.svcCtx.SysDeptModel.UpdateWithOptLockTx(ctx, session, dept, expectedUpdateTime); err != nil {
  104. return err
  105. }
  106. if shouldRevokeSessions {
  107. // FindIdsByDeptIdForShareTx 对命中 sys_user 行加 S 锁,与并发 UpdateProfileWithTx 的
  108. // X 锁互斥——防止"枚举时 A 还在本部门、枚举完 A 被并发挪走却已失效吊销"或反过来的
  109. // 漏吊销窗口。
  110. ids, err := l.svcCtx.SysUserModel.FindIdsByDeptIdForShareTx(ctx, session, req.Id)
  111. if err != nil {
  112. return err
  113. }
  114. if len(ids) > 0 {
  115. if err := l.svcCtx.SysUserModel.BatchIncrementTokenVersionWithTx(ctx, session, ids); err != nil {
  116. return err
  117. }
  118. revokedUserIds = ids
  119. }
  120. }
  121. return nil
  122. }); err != nil {
  123. if errors.Is(err, deptModel.ErrUpdateConflict) {
  124. return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
  125. }
  126. return err
  127. }
  128. // post-commit 三级缓存失效(detached ctx 保证 client 断连时仍能完成):
  129. // ① sys_dept 低层缓存(本行刚被 UPDATE,sysDeptIdKey 需要失效,否则 FindOne 会返回旧值);
  130. // ② sys_user 低层缓存(tokenVersion 已 +1,否则 UD loader 下次 miss 会从 sysUser 低层缓存
  131. // 拿到旧 tokenVersion 把递增值抹回,与 UpdateMember / RemoveMember 同口径);
  132. // ③ UserDetails 聚合缓存(DeptType / DeptStatus / TokenVersion 均是字段,必须 Clean 后
  133. // 重新 Load 才能拿到新授权快照)。
  134. cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
  135. defer cancel()
  136. l.svcCtx.SysDeptModel.InvalidateDeptCache(cleanCtx, req.Id)
  137. if deptTypeChanged || statusChanged {
  138. // CleanByUserIds 在 shouldRevokeSessions=false 分支仍然有用:
  139. // 例如 NORMAL 部门 Disabled→Enabled 是升权方向,不吊销 session,但 UD 聚合缓存里的
  140. // DeptStatus 字段仍然是旧值(Disabled),必须 Clean 让 loadPerms 在下次 Load 时回到
  141. // Enabled 对应的分支(对 DEV 部门意味着重新进入全权分支)。
  142. // 当 shouldRevokeSessions=true 时,revokedUserIds 已经在 tx 内批量 +1,这里顺带把 UD
  143. // 也失效,两级缓存一起回到 cache-miss。
  144. userIds := revokedUserIds
  145. if len(userIds) == 0 {
  146. // 不需要吊销会话的场景(升权 / 非收窄的 deptType/status 切换)也要失效 UD。
  147. ids, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
  148. if err != nil {
  149. l.Errorf("UpdateDept id=%d deptType=%s status=%d 部门已更新但 FindIdsByDeptId 失败,用户权限缓存未能主动失效,将等待 TTL 自然过期: %v", req.Id, dept.DeptType, dept.Status, err)
  150. } else {
  151. userIds = ids
  152. }
  153. }
  154. if len(userIds) > 0 {
  155. l.svcCtx.UserDetailsLoader.CleanByUserIds(cleanCtx, userIds)
  156. }
  157. // 当 shouldRevokeSessions=true 时,每个受影响 sys_user 的低层缓存都要失效,防止 sysUser
  158. // 缓存里的旧 tokenVersion 被 UD loader 下次 miss 时读回、把刚递增的值抹回去。
  159. // FindByIds 批量拿 (id, username),避免对 len(userIds) 次 FindOne。
  160. if shouldRevokeSessions && len(revokedUserIds) > 0 {
  161. users, err := l.svcCtx.SysUserModel.FindByIds(cleanCtx, revokedUserIds)
  162. if err != nil {
  163. logx.WithContext(l.ctx).Errorf("UpdateDept post-commit FindByIds(len=%d) failed for token-version cache invalidation: %v", len(revokedUserIds), err)
  164. } else {
  165. for _, u := range users {
  166. if u == nil {
  167. continue
  168. }
  169. l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, u.Id, u.Username)
  170. }
  171. }
  172. }
  173. l.Infof("UpdateDept id=%d deptType=%s status=%d affectedUsers=%d revokedSessions=%d", req.Id, dept.DeptType, dept.Status, len(userIds), len(revokedUserIds))
  174. }
  175. return nil
  176. }