updateUserLogic.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  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. deptModel "perms-system-server/internal/model/dept"
  11. userModel "perms-system-server/internal/model/user"
  12. "perms-system-server/internal/response"
  13. "perms-system-server/internal/svc"
  14. "perms-system-server/internal/types"
  15. "perms-system-server/internal/util"
  16. "github.com/zeromicro/go-zero/core/logx"
  17. "github.com/zeromicro/go-zero/core/stores/sqlx"
  18. )
  19. type UpdateUserLogic struct {
  20. logx.Logger
  21. ctx context.Context
  22. svcCtx *svc.ServiceContext
  23. }
  24. func NewUpdateUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateUserLogic {
  25. return &UpdateUserLogic{
  26. Logger: logx.WithContext(ctx),
  27. ctx: ctx,
  28. svcCtx: svcCtx,
  29. }
  30. }
  31. // UpdateUser 更新用户信息。修改用户昵称、邮箱、手机、备注、部门归属等。用户可修改自身非敏感字段,管理者可修改下属用户信息。
  32. func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
  33. caller := middleware.GetUserDetails(l.ctx)
  34. if caller == nil {
  35. return response.ErrUnauthorized("未登录")
  36. }
  37. if caller.UserId == req.Id {
  38. if req.DeptId != nil || req.Status != 0 {
  39. return response.ErrForbidden("不允许修改自己的部门和状态")
  40. }
  41. }
  42. // 前置 FindOne,后续 CheckManageAccess / ValidateStatusChange 都复用此对象,避免一次请求内
  43. // 对 target 做 2~3 次重复查询(见审计 M-5)。
  44. user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id)
  45. if err != nil {
  46. return response.ErrNotFound("用户不存在")
  47. }
  48. if caller.UserId != req.Id {
  49. productCode := middleware.GetProductCode(l.ctx)
  50. if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode, authHelper.WithPrefetchedTarget(user)); err != nil {
  51. return err
  52. }
  53. }
  54. // req.Status != 0 仅会落在 caller.UserId != req.Id 分支(上方 caller==target 的请求已经拦截),
  55. // 此处沿用 ValidateStatusChange 的超管保护语义,避免再次 FindOne。
  56. if req.Status != 0 && user.IsSuperAdmin == consts.IsSuperAdminYes {
  57. return response.ErrForbidden("不能修改超级管理员的状态")
  58. }
  59. if caller.UserId != req.Id && user.IsSuperAdmin == consts.IsSuperAdminYes {
  60. if req.DeptId != nil {
  61. return response.ErrForbidden("不能通过此接口修改其他超级管理员的部门")
  62. }
  63. }
  64. if req.Nickname != nil && len(*req.Nickname) > 64 {
  65. return response.ErrBadRequest("昵称长度不能超过64个字符")
  66. }
  67. if req.Email != nil && len(*req.Email) > 64 {
  68. return response.ErrBadRequest("邮箱长度不能超过64个字符")
  69. }
  70. if req.Phone != nil && len(*req.Phone) > 32 {
  71. return response.ErrBadRequest("手机号长度不能超过32个字符")
  72. }
  73. if req.Remark != nil && len(*req.Remark) > 255 {
  74. return response.ErrBadRequest("备注长度不能超过255个字符")
  75. }
  76. nickname := user.Nickname
  77. email := user.Email
  78. phone := user.Phone
  79. remark := user.Remark
  80. deptId := user.DeptId
  81. if req.Nickname != nil {
  82. nickname = *req.Nickname
  83. }
  84. if req.Email != nil {
  85. if *req.Email != "" && !util.IsValidEmail(*req.Email) {
  86. return response.ErrBadRequest("邮箱格式不正确")
  87. }
  88. email = *req.Email
  89. }
  90. if req.Phone != nil {
  91. if *req.Phone != "" && !util.IsValidPhone(*req.Phone) {
  92. return response.ErrBadRequest("手机号格式不正确")
  93. }
  94. phone = *req.Phone
  95. }
  96. if req.Remark != nil {
  97. remark = *req.Remark
  98. }
  99. // 审计 L-R16-2:识别"从 DEV 部门调出"的收窄方向——loadPerms 的 DEV 全权分支以
  100. // (MemberType != "") && (DeptType == DEV) && (DeptStatus == Enabled) 为条件,只要 target 的
  101. // deptId 离开 DEV 部门(挪到 NORMAL 或 deptId=0),该用户在其所属的**所有**产品内都失去
  102. // 全权。统一走"事务内 tokenVersion+1"签发层吊销,避免 Redis 抖动时 5min TTL 残留全权。
  103. var devAccessRevoked bool
  104. var newDept *deptModel.SysDept
  105. if req.DeptId != nil {
  106. // 审计 L-R13-4:与 CreateUser 对齐,显式拒绝 deptId < 0。原先的 `>0 / else` 二分会把
  107. // 负数一路透传进 UpdateProfile(WithTx),导致 sys_user.deptId 出现 -1 之类的脏值,
  108. // loadDept FindOne(-1) 会 ErrNotFound → 5xx degrade;也会让 FindIdsByDeptId / 部门树
  109. // 接口永远检索不到该用户,形成隐形僵尸账号。
  110. if *req.DeptId < 0 {
  111. return response.ErrBadRequest("部门ID必须为非负整数")
  112. }
  113. if *req.DeptId > 0 {
  114. nd, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId)
  115. if err != nil {
  116. return response.ErrBadRequest("部门不存在")
  117. }
  118. newDept = nd
  119. // 审计 L-N2:与 UpdateDept 禁用语义闭环 —— 已禁用的部门代表"冻结该部门所有活动",
  120. // 再往该部门调入新成员会破坏不变量(新成员会因 DeptStatus!=Enabled 被撤销 DEV 全权
  121. // 特权),且无法被 AddMember / CheckAddMemberAccess 的校验感知。此处统一拦截。
  122. if newDept.Status != consts.StatusEnabled {
  123. return response.ErrBadRequest("目标部门已停用")
  124. }
  125. // 审计 H-R14-1:DEV 部门承载"加入即在自己所属的任意产品内全权"的跨产品语义
  126. // (loadPerms 对 DeptType=DEV + 在编成员走全权分支,见 userDetailsLoader.go),
  127. // 而 sys_user.deptId 是**全局**字段——产品 ADMIN 在 P1 的作用域内通过本接口把
  128. // 与 P2 同为成员的 target 调入 DEV,就会让 target 在 P2 下的 loadPerms 从
  129. // "普通成员"瞬间升级为"P2 全权",等于绕过了 P2 对 ADMIN 的信任边界。因此
  130. // 调入 DEV 的动作统一回收给 SuperAdmin;产品 ADMIN 仍可在自己部门子树内做
  131. // 任何非 DEV 的调整。CreateUser 已在 H-2/H-3 的修复里通过 DeptPath 前缀校验
  132. // 间接拦住(产品 ADMIN 的 caller.DeptPath 不覆盖 DEV 子树),这里补齐 UpdateUser
  133. // 被 ADMIN 分支短路掉的同构缺口。
  134. if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
  135. return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
  136. }
  137. // 审计 L-R16-1:删除原 ADMIN 分支的短路。sys_user.deptId 是**全局**字段,产品
  138. // ADMIN 在 P1 的授权范围仅覆盖 P1,把同时也是 P2 成员的 target 挪到 P1 子树外的
  139. // 任意(非 DEV)部门同样是"在 P2 视角制造结构性失联"的越权——与 L-R15-1 的
  140. // deptId=0 场景完全同构,只是落点从"0"换成"P1 某子树"。修复口径与 CreateUser
  141. // (createUserLogic.go:102-109 对非超管强制 DeptPath 前缀校验,无 ADMIN 豁免)
  142. // 对齐:非超管(含 ADMIN)必须把目标调入自身 DeptPath 子树之内,跨子树调度统一
  143. // 走 SuperAdmin 审批流。
  144. //
  145. // 审计 L-R13-3:走到这里时 caller 一定满足:非本人(line 42-45 已拦 caller==target
  146. // 改 deptId);非超管(见本分支前的判定);且 CheckManageAccess → checkDeptHierarchy
  147. // 已经对 `caller.DeptId == 0 || caller.DeptPath == ""` fail-close 返回 403——因此
  148. // 执行到本行时 caller.DeptPath 恒非空,无需再冗余判定空串。
  149. if !caller.IsSuperAdmin &&
  150. !strings.HasPrefix(newDept.Path, caller.DeptPath) {
  151. return response.ErrForbidden("无权将用户调入非自己管辖的部门")
  152. }
  153. } else {
  154. // deptId = 0:把用户移出全局部门树(L-R15-1 已收敛给 SuperAdmin)。
  155. // 审计 L-R15-1:deptId=0 意味着"把用户从**全局**部门树里移除"——sys_user.deptId
  156. // 是全局字段,一次 UpdateUser 会让目标在**所有**他已加入的产品视角里同时失去
  157. // DeptPath / DeptType。与 H-R14-1(调入 DEV)对称:caller 在产品 P1 的授权范围
  158. // 天然仅覆盖 P1,不应具备改变共享全局字段的能力。原来的"产品 ADMIN 也可移出"
  159. // 会让 P1 ADMIN 把共有成员 B 在 P2 视角里变成"DeptId=0 的孤儿"——P2 日常管理层级
  160. // (MEMBER/DEVELOPER/子 ADMIN)全部通不过 checkDeptHierarchy 对目标 DeptId 的
  161. // 强校验,B 成为 P2 侧的隐形成员,DeptTree 里也找不到。业务上"移出部门"属于
  162. // 离职/转岗这类 HR 行政流程,统一回收给 SuperAdmin 执行更合理。
  163. if !caller.IsSuperAdmin {
  164. return response.ErrForbidden("仅超级管理员可将用户移出部门")
  165. }
  166. }
  167. // 审计 L-R16-2:若 deptId 发生真实变更,且用户原本在 DEV 部门内,而新归属不再是"Enabled 的
  168. // DEV 部门"(挪到 NORMAL 或 deptId=0),则 loadPerms 的 DEV 全权分支对该用户失效,构成
  169. // "单用户的权限收窄"。仅在确实变更时才读取 oldDept 比对——避免 FindOne 成本被无谓请求放大。
  170. if *req.DeptId != user.DeptId && user.DeptId > 0 {
  171. oldDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, user.DeptId)
  172. if err == nil && oldDept != nil && oldDept.DeptType == consts.DeptTypeDev && oldDept.Status == consts.StatusEnabled {
  173. // newDept == nil 即 deptId=0(SuperAdmin-only 路径);newDept != nil 且 DeptType==NORMAL
  174. // 即挪到 NORMAL 部门;两种都构成 DEV 全权收窄。
  175. // 审计 L-R18-7:原来的第三分支 `newDept.Status != consts.StatusEnabled` 是死条件——
  176. // newDept != nil 时 Status 必为 Enabled(line 136-138 已在进入本分支前用
  177. // `req.BadRequest("目标部门已停用")` 拦截过),保留只会误导读者以为还有"调入已禁用
  178. // DEV 部门"之类的残留路径。
  179. if newDept == nil || newDept.DeptType == consts.DeptTypeNormal {
  180. devAccessRevoked = true
  181. }
  182. }
  183. }
  184. deptId = *req.DeptId
  185. }
  186. statusChanged := false
  187. if req.Status != 0 {
  188. if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
  189. return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(冻结)")
  190. }
  191. if user.Status != req.Status {
  192. statusChanged = true
  193. }
  194. }
  195. newStatus := user.Status
  196. if statusChanged {
  197. newStatus = req.Status
  198. }
  199. // 审计 M-R11-3:改 deptId 到 `newDeptId>0` 时必须把 UPDATE 收敛进事务,并在同事务内对目标
  200. // sys_dept[newDeptId] 加 S 锁——这样并发 DeleteDept 持有 sys_dept[X] 的 X 锁,会被 S 锁阻塞,
  201. // 等本事务提交后 DeleteDept 重读 `sys_user WHERE deptId=X FOR SHARE` 就能看到新行并拒绝删除,
  202. // 闭合"两侧都读不到对方提交 → 各自提交 → orphan deptId"的 write skew。
  203. //
  204. // 审计 L-R16-2:devAccessRevoked(DEV 全权收窄)同样需要走事务,这样"UPDATE sys_user +
  205. // tokenVersion+1"在同一事务内要么一起生效要么一起回滚——避免"部门已从 DEV 挪走但
  206. // tokenVersion 没 +1"让旧 access token 在 5min TTL 窗口内继续享有 DEV 全权。
  207. //
  208. // 其余分支(只改其它字段 / 移出部门 deptId=0 且原本也不是 DEV)无 write skew、无签发层吊销
  209. // 诉求,沿用非事务的 UpdateProfile。
  210. needShareLock := req.DeptId != nil && *req.DeptId > 0 && *req.DeptId != user.DeptId
  211. needTx := needShareLock || devAccessRevoked
  212. if !needTx {
  213. if err := l.svcCtx.SysUserModel.UpdateProfile(
  214. l.ctx, req.Id, user.Username,
  215. nickname, email, phone, remark, deptId,
  216. newStatus, statusChanged, user.UpdateTime,
  217. ); err != nil {
  218. if errors.Is(err, userModel.ErrUpdateConflict) {
  219. return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
  220. }
  221. return err
  222. }
  223. // 审计 L-R13-5 方案 A:post-commit 的 UD 失效与请求 ctx 解耦,避免 client 断连 /
  224. // 请求超时取消后 UD 仍然提供旧 DeptPath / MinPermsLevel / 冻结状态长达 TTL 窗口。
  225. cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
  226. defer cancel()
  227. l.svcCtx.UserDetailsLoader.Clean(cleanCtx, req.Id)
  228. return nil
  229. }
  230. if err := l.svcCtx.SysUserModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
  231. if needShareLock {
  232. // 事务内 S 锁目标 dept,保证 DeleteDept 的 X 锁被阻塞;顺带在事务内复核 Status。
  233. // 上面非事务的 FindOne 已经校过一遍,这里是"在锁生效后的一致性视图"下的最终校验。
  234. lockedDept, err := l.svcCtx.SysDeptModel.FindOneForShareTx(ctx, session, *req.DeptId)
  235. if err != nil {
  236. if errors.Is(err, sqlx.ErrNotFound) {
  237. return response.ErrBadRequest("部门不存在")
  238. }
  239. return err
  240. }
  241. if lockedDept.Status != consts.StatusEnabled {
  242. return response.ErrBadRequest("目标部门已停用")
  243. }
  244. }
  245. // 审计 L-R16-2:DEV 全权收窄(DEV→NORMAL / DEV→deptId=0)在 tx 内把 tokenVersion +1,
  246. // 与 UpdateMember 的 M-R15-1 签发层吊销口径对齐。
  247. //
  248. // 顺序约束:必须**先** UpdateProfileWithTx(带 `WHERE updateTime=expectedUpdateTime`
  249. // 的乐观锁),**再** IncrementTokenVersionWithTx。颠倒顺序会让 IncrementTokenVersionWithTx
  250. // 先把 updateTime 改到 now(),导致紧随其后的 UpdateProfileWithTx 的乐观锁 WHERE 匹配不到
  251. // (affected=0 → ErrUpdateConflict),把本来应该成功的请求误判为并发冲突。
  252. //
  253. // 审计 M-R17-2 · 双递增语义澄清:
  254. // 当 devAccessRevoked && statusChanged 并发发生在同一请求时(例如一次 UpdateUser 同时把
  255. // target 从 DEV 部门调到 NORMAL 并把 status 置为 Disabled),tokenVersion 在本事务内会
  256. // 被**连续递增两次** —— UpdateProfileWithTx 内部因 statusChanged=true 先自增 1,下一行
  257. // 对 devAccessRevoked 的显式 IncrementTokenVersionWithTx 再自增 1,净效果 +2。
  258. // 这在**安全语义上等价于 +1**:tokenVersion 是单调递增信号量,jwtauthMiddleware 只要求
  259. // claims 里的 tokenVersion >= DB 当前值才放行,旧 token 只会被一票否决不会"被跳过"。
  260. // 运维侧若用 tokenVersion 样本做"活跃会话批次"分析,看到单次 UpdateUser 让 tokenVersion
  261. // 跳 2 不是异常,而是这两条收窄路径重叠命中的预期行为。
  262. //
  263. // 之所以不在本层"收敛到只 +1":
  264. // - UpdateProfileWithTx 的 statusChanged 分支内嵌的 tokenVersion+1 是跨 UpdateUser /
  265. // UpdateUserStatus / UpdateMember 多调用方共享的契约(M-R15-1 / L-R15-3 都依赖它),
  266. // 拆出条件开关反而会让签发层吊销的不变量分散到每个调用方,回归面变大;
  267. // - +2 不破坏正确性,只多消耗一次极廉价的 `UPDATE ... SET tokenVersion=tokenVersion+1`。
  268. // 因此这里**有意**保留"可能 +2"的行为,与安全性无矛盾。
  269. if err := l.svcCtx.SysUserModel.UpdateProfileWithTx(
  270. ctx, session, req.Id, user.Username,
  271. nickname, email, phone, remark, deptId,
  272. newStatus, statusChanged, user.UpdateTime,
  273. ); err != nil {
  274. return err
  275. }
  276. if devAccessRevoked {
  277. if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, req.Id); err != nil {
  278. return err
  279. }
  280. }
  281. return nil
  282. }); err != nil {
  283. if errors.Is(err, userModel.ErrUpdateConflict) {
  284. return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
  285. }
  286. return err
  287. }
  288. // 审计 L-R12-1:UpdateProfileWithTx 不再自己 DelCache(避免 pre-commit 窗口里并发 FindOne
  289. // 把未提交旧值灌回缓存);这里在 commit 成功后显式失效 sysUser 低层 id/username 键,再叠加
  290. // UserDetails 聚合缓存的 Clean,整条"两级缓存 → DB 权威"读链回到 cache-miss → loadFromDB。
  291. // 审计 L-R13-5 方案 A:detached ctx + 3s timeout 让 DeptPath 切换 / 冻结状态这类
  292. // 授权相关的失效不受 client 断连影响。
  293. cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
  294. defer cancel()
  295. l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, req.Id, user.Username)
  296. l.svcCtx.UserDetailsLoader.Clean(cleanCtx, req.Id)
  297. return nil
  298. }