updateUserLogic.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. package user
  2. import (
  3. "context"
  4. "errors"
  5. "strings"
  6. "perms-system-server/internal/consts"
  7. "perms-system-server/internal/loaders"
  8. authHelper "perms-system-server/internal/logic/auth"
  9. "perms-system-server/internal/middleware"
  10. userModel "perms-system-server/internal/model/user"
  11. "perms-system-server/internal/response"
  12. "perms-system-server/internal/svc"
  13. "perms-system-server/internal/types"
  14. "perms-system-server/internal/util"
  15. "github.com/zeromicro/go-zero/core/logx"
  16. "github.com/zeromicro/go-zero/core/stores/sqlx"
  17. )
  18. type UpdateUserLogic struct {
  19. logx.Logger
  20. ctx context.Context
  21. svcCtx *svc.ServiceContext
  22. }
  23. func NewUpdateUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateUserLogic {
  24. return &UpdateUserLogic{
  25. Logger: logx.WithContext(ctx),
  26. ctx: ctx,
  27. svcCtx: svcCtx,
  28. }
  29. }
  30. // UpdateUser 更新用户信息。修改用户昵称、邮箱、手机、备注、部门归属等。用户可修改自身非敏感字段,管理者可修改下属用户信息。
  31. func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
  32. caller := middleware.GetUserDetails(l.ctx)
  33. if caller == nil {
  34. return response.ErrUnauthorized("未登录")
  35. }
  36. if caller.UserId == req.Id {
  37. if req.DeptId != nil || req.Status != 0 {
  38. return response.ErrForbidden("不允许修改自己的部门和状态")
  39. }
  40. }
  41. // 前置 FindOne,后续 CheckManageAccess / ValidateStatusChange 都复用此对象,避免一次请求内
  42. // 对 target 做 2~3 次重复查询(见审计 M-5)。
  43. user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id)
  44. if err != nil {
  45. return response.ErrNotFound("用户不存在")
  46. }
  47. if caller.UserId != req.Id {
  48. productCode := middleware.GetProductCode(l.ctx)
  49. if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode, authHelper.WithPrefetchedTarget(user)); err != nil {
  50. return err
  51. }
  52. }
  53. // req.Status != 0 仅会落在 caller.UserId != req.Id 分支(上方 caller==target 的请求已经拦截),
  54. // 此处沿用 ValidateStatusChange 的超管保护语义,避免再次 FindOne。
  55. if req.Status != 0 && user.IsSuperAdmin == consts.IsSuperAdminYes {
  56. return response.ErrForbidden("不能修改超级管理员的状态")
  57. }
  58. if caller.UserId != req.Id && user.IsSuperAdmin == consts.IsSuperAdminYes {
  59. if req.DeptId != nil {
  60. return response.ErrForbidden("不能通过此接口修改其他超级管理员的部门")
  61. }
  62. }
  63. if req.Nickname != nil && len(*req.Nickname) > 64 {
  64. return response.ErrBadRequest("昵称长度不能超过64个字符")
  65. }
  66. if req.Email != nil && len(*req.Email) > 64 {
  67. return response.ErrBadRequest("邮箱长度不能超过64个字符")
  68. }
  69. if req.Phone != nil && len(*req.Phone) > 32 {
  70. return response.ErrBadRequest("手机号长度不能超过32个字符")
  71. }
  72. if req.Remark != nil && len(*req.Remark) > 255 {
  73. return response.ErrBadRequest("备注长度不能超过255个字符")
  74. }
  75. nickname := user.Nickname
  76. email := user.Email
  77. phone := user.Phone
  78. remark := user.Remark
  79. deptId := user.DeptId
  80. if req.Nickname != nil {
  81. nickname = *req.Nickname
  82. }
  83. if req.Email != nil {
  84. if *req.Email != "" && !util.IsValidEmail(*req.Email) {
  85. return response.ErrBadRequest("邮箱格式不正确")
  86. }
  87. email = *req.Email
  88. }
  89. if req.Phone != nil {
  90. if *req.Phone != "" && !util.IsValidPhone(*req.Phone) {
  91. return response.ErrBadRequest("手机号格式不正确")
  92. }
  93. phone = *req.Phone
  94. }
  95. if req.Remark != nil {
  96. remark = *req.Remark
  97. }
  98. if req.DeptId != nil {
  99. // 审计 L-R13-4:与 CreateUser 对齐,显式拒绝 deptId < 0。原先的 `>0 / else` 二分会把
  100. // 负数一路透传进 UpdateProfile(WithTx),导致 sys_user.deptId 出现 -1 之类的脏值,
  101. // loadDept FindOne(-1) 会 ErrNotFound → 5xx degrade;也会让 FindIdsByDeptId / 部门树
  102. // 接口永远检索不到该用户,形成隐形僵尸账号。
  103. if *req.DeptId < 0 {
  104. return response.ErrBadRequest("部门ID必须为非负整数")
  105. }
  106. if *req.DeptId > 0 {
  107. newDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId)
  108. if err != nil {
  109. return response.ErrBadRequest("部门不存在")
  110. }
  111. // 审计 L-N2:与 UpdateDept 禁用语义闭环 —— 已禁用的部门代表"冻结该部门所有活动",
  112. // 再往该部门调入新成员会破坏不变量(新成员会因 DeptStatus!=Enabled 被撤销 DEV 全权
  113. // 特权),且无法被 AddMember / CheckAddMemberAccess 的校验感知。此处统一拦截。
  114. if newDept.Status != consts.StatusEnabled {
  115. return response.ErrBadRequest("目标部门已停用")
  116. }
  117. // 审计 H-R14-1:DEV 部门承载"加入即在自己所属的任意产品内全权"的跨产品语义
  118. // (loadPerms 对 DeptType=DEV + 在编成员走全权分支,见 userDetailsLoader.go),
  119. // 而 sys_user.deptId 是**全局**字段——产品 ADMIN 在 P1 的作用域内通过本接口把
  120. // 与 P2 同为成员的 target 调入 DEV,就会让 target 在 P2 下的 loadPerms 从
  121. // "普通成员"瞬间升级为"P2 全权",等于绕过了 P2 对 ADMIN 的信任边界。因此
  122. // 调入 DEV 的动作统一回收给 SuperAdmin;产品 ADMIN 仍可在自己部门子树内做
  123. // 任何非 DEV 的调整。CreateUser 已在 H-2/H-3 的修复里通过 DeptPath 前缀校验
  124. // 间接拦住(产品 ADMIN 的 caller.DeptPath 不覆盖 DEV 子树),这里补齐 UpdateUser
  125. // 被 ADMIN 分支短路掉的同构缺口。
  126. if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
  127. return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
  128. }
  129. // 审计 L-R13-3:删除原 `caller.DeptPath != ""` 的冗余条件。
  130. // 走到这里时 caller 一定满足:非本人(line 42-45 已拦 caller==target 改 deptId);
  131. // 非超管、非 ADMIN(见本分支前的判定);且 CheckManageAccess → checkDeptHierarchy
  132. // 已经在 access.go:318-324 对 `caller.DeptId == 0 || caller.DeptPath == ""` fail-close
  133. // 返回 403——因此执行到本行时 caller.DeptPath 恒非空。冗余条件会误导新维护者以为
  134. // "某条分支下 caller.DeptPath 可以为空",诱导把 checkDeptHierarchy 的护栏拆掉。
  135. //
  136. // 注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何**非 DEV**
  137. // 部门;DEV 目标部门的跨产品权限升级路径由上面 H-R14-1 的显式护栏拦截(见审计
  138. // L-R14-3 的注释披露要求)。
  139. if !caller.IsSuperAdmin &&
  140. caller.MemberType != consts.MemberTypeAdmin &&
  141. !strings.HasPrefix(newDept.Path, caller.DeptPath) {
  142. return response.ErrForbidden("无权将用户调入非自己管辖的部门")
  143. }
  144. } else {
  145. // deptId=0 意味着"把用户移出部门树";一旦生效目标将失去 DeptPath,此后 MEMBER / DEVELOPER
  146. // 级别的调用者都通不过 checkDeptHierarchy 对"目标必须归属部门"的强校验,无法再被管辖。
  147. // 因此仅超管和产品 ADMIN 有权执行该破坏组织结构语义的操作(见审计 H-4)。
  148. if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin {
  149. return response.ErrForbidden("仅超级管理员或产品管理员可将用户移出部门")
  150. }
  151. }
  152. deptId = *req.DeptId
  153. }
  154. statusChanged := false
  155. if req.Status != 0 {
  156. if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
  157. return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(冻结)")
  158. }
  159. if user.Status != req.Status {
  160. statusChanged = true
  161. }
  162. }
  163. newStatus := user.Status
  164. if statusChanged {
  165. newStatus = req.Status
  166. }
  167. // 审计 M-R11-3:改 deptId 到 `newDeptId>0` 时必须把 UPDATE 收敛进事务,并在同事务内对目标
  168. // sys_dept[newDeptId] 加 S 锁——这样并发 DeleteDept 持有 sys_dept[X] 的 X 锁,会被 S 锁阻塞,
  169. // 等本事务提交后 DeleteDept 重读 `sys_user WHERE deptId=X FOR SHARE` 就能看到新行并拒绝删除,
  170. // 闭合"两侧都读不到对方提交 → 各自提交 → orphan deptId"的 write skew。
  171. // 其余分支(只改其它字段 / 移出部门 deptId=0)无 write skew 风险,沿用非事务的 UpdateProfile。
  172. needDeptShareLock := req.DeptId != nil && *req.DeptId > 0 && *req.DeptId != user.DeptId
  173. if !needDeptShareLock {
  174. if err := l.svcCtx.SysUserModel.UpdateProfile(
  175. l.ctx, req.Id, user.Username,
  176. nickname, email, phone, remark, deptId,
  177. newStatus, statusChanged, user.UpdateTime,
  178. ); err != nil {
  179. if errors.Is(err, userModel.ErrUpdateConflict) {
  180. return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
  181. }
  182. return err
  183. }
  184. // 审计 L-R13-5 方案 A:post-commit 的 UD 失效与请求 ctx 解耦,避免 client 断连 /
  185. // 请求超时取消后 UD 仍然提供旧 DeptPath / MinPermsLevel / 冻结状态长达 TTL 窗口。
  186. cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
  187. defer cancel()
  188. l.svcCtx.UserDetailsLoader.Clean(cleanCtx, req.Id)
  189. return nil
  190. }
  191. targetDeptId := *req.DeptId
  192. if err := l.svcCtx.SysUserModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
  193. // 事务内 S 锁目标 dept,保证 DeleteDept 的 X 锁被阻塞;顺带在事务内复核 Status。
  194. // 上面非事务的 FindOne 已经校过一遍,这里是"在锁生效后的一致性视图"下的最终校验。
  195. lockedDept, err := l.svcCtx.SysDeptModel.FindOneForShareTx(ctx, session, targetDeptId)
  196. if err != nil {
  197. if errors.Is(err, sqlx.ErrNotFound) {
  198. return response.ErrBadRequest("部门不存在")
  199. }
  200. return err
  201. }
  202. if lockedDept.Status != consts.StatusEnabled {
  203. return response.ErrBadRequest("目标部门已停用")
  204. }
  205. return l.svcCtx.SysUserModel.UpdateProfileWithTx(
  206. ctx, session, req.Id, user.Username,
  207. nickname, email, phone, remark, deptId,
  208. newStatus, statusChanged, user.UpdateTime,
  209. )
  210. }); err != nil {
  211. if errors.Is(err, userModel.ErrUpdateConflict) {
  212. return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
  213. }
  214. return err
  215. }
  216. // 审计 L-R12-1:UpdateProfileWithTx 不再自己 DelCache(避免 pre-commit 窗口里并发 FindOne
  217. // 把未提交旧值灌回缓存);这里在 commit 成功后显式失效 sysUser 低层 id/username 键,再叠加
  218. // UserDetails 聚合缓存的 Clean,整条"两级缓存 → DB 权威"读链回到 cache-miss → loadFromDB。
  219. // 审计 L-R13-5 方案 A:detached ctx + 3s timeout 让 DeptPath 切换 / 冻结状态这类
  220. // 授权相关的失效不受 client 断连影响。
  221. cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
  222. defer cancel()
  223. l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, req.Id, user.Username)
  224. l.svcCtx.UserDetailsLoader.Clean(cleanCtx, req.Id)
  225. return nil
  226. }