package user import ( "context" "errors" "strings" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" authHelper "perms-system-server/internal/logic/auth" "perms-system-server/internal/middleware" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/types" "perms-system-server/internal/util" "github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/core/stores/sqlx" ) type UpdateUserLogic struct { logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewUpdateUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateUserLogic { return &UpdateUserLogic{ Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } // UpdateUser 更新用户信息。修改用户昵称、邮箱、手机、备注、部门归属等。用户可修改自身非敏感字段,管理者可修改下属用户信息。 func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error { caller := middleware.GetUserDetails(l.ctx) if caller == nil { return response.ErrUnauthorized("未登录") } if caller.UserId == req.Id { if req.DeptId != nil || req.Status != 0 { return response.ErrForbidden("不允许修改自己的部门和状态") } } // 前置 FindOne,后续 CheckManageAccess / ValidateStatusChange 都复用此对象,避免一次请求内 // 对 target 做 2~3 次重复查询(见审计 M-5)。 user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id) if err != nil { return response.ErrNotFound("用户不存在") } if caller.UserId != req.Id { productCode := middleware.GetProductCode(l.ctx) if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode, authHelper.WithPrefetchedTarget(user)); err != nil { return err } } // req.Status != 0 仅会落在 caller.UserId != req.Id 分支(上方 caller==target 的请求已经拦截), // 此处沿用 ValidateStatusChange 的超管保护语义,避免再次 FindOne。 if req.Status != 0 && user.IsSuperAdmin == consts.IsSuperAdminYes { return response.ErrForbidden("不能修改超级管理员的状态") } if caller.UserId != req.Id && user.IsSuperAdmin == consts.IsSuperAdminYes { if req.DeptId != nil { return response.ErrForbidden("不能通过此接口修改其他超级管理员的部门") } } if req.Nickname != nil && len(*req.Nickname) > 64 { return response.ErrBadRequest("昵称长度不能超过64个字符") } if req.Email != nil && len(*req.Email) > 64 { return response.ErrBadRequest("邮箱长度不能超过64个字符") } if req.Phone != nil && len(*req.Phone) > 32 { return response.ErrBadRequest("手机号长度不能超过32个字符") } if req.Remark != nil && len(*req.Remark) > 255 { return response.ErrBadRequest("备注长度不能超过255个字符") } nickname := user.Nickname email := user.Email phone := user.Phone remark := user.Remark deptId := user.DeptId if req.Nickname != nil { nickname = *req.Nickname } if req.Email != nil { if *req.Email != "" && !util.IsValidEmail(*req.Email) { return response.ErrBadRequest("邮箱格式不正确") } email = *req.Email } if req.Phone != nil { if *req.Phone != "" && !util.IsValidPhone(*req.Phone) { return response.ErrBadRequest("手机号格式不正确") } phone = *req.Phone } if req.Remark != nil { remark = *req.Remark } if req.DeptId != nil { // 审计 L-R13-4:与 CreateUser 对齐,显式拒绝 deptId < 0。原先的 `>0 / else` 二分会把 // 负数一路透传进 UpdateProfile(WithTx),导致 sys_user.deptId 出现 -1 之类的脏值, // loadDept FindOne(-1) 会 ErrNotFound → 5xx degrade;也会让 FindIdsByDeptId / 部门树 // 接口永远检索不到该用户,形成隐形僵尸账号。 if *req.DeptId < 0 { return response.ErrBadRequest("部门ID必须为非负整数") } if *req.DeptId > 0 { newDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId) if err != nil { return response.ErrBadRequest("部门不存在") } // 审计 L-N2:与 UpdateDept 禁用语义闭环 —— 已禁用的部门代表"冻结该部门所有活动", // 再往该部门调入新成员会破坏不变量(新成员会因 DeptStatus!=Enabled 被撤销 DEV 全权 // 特权),且无法被 AddMember / CheckAddMemberAccess 的校验感知。此处统一拦截。 if newDept.Status != consts.StatusEnabled { return response.ErrBadRequest("目标部门已停用") } // 审计 H-R14-1:DEV 部门承载"加入即在自己所属的任意产品内全权"的跨产品语义 // (loadPerms 对 DeptType=DEV + 在编成员走全权分支,见 userDetailsLoader.go), // 而 sys_user.deptId 是**全局**字段——产品 ADMIN 在 P1 的作用域内通过本接口把 // 与 P2 同为成员的 target 调入 DEV,就会让 target 在 P2 下的 loadPerms 从 // "普通成员"瞬间升级为"P2 全权",等于绕过了 P2 对 ADMIN 的信任边界。因此 // 调入 DEV 的动作统一回收给 SuperAdmin;产品 ADMIN 仍可在自己部门子树内做 // 任何非 DEV 的调整。CreateUser 已在 H-2/H-3 的修复里通过 DeptPath 前缀校验 // 间接拦住(产品 ADMIN 的 caller.DeptPath 不覆盖 DEV 子树),这里补齐 UpdateUser // 被 ADMIN 分支短路掉的同构缺口。 if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin { return response.ErrForbidden("仅超级管理员可将用户调入研发部门") } // 审计 L-R13-3:删除原 `caller.DeptPath != ""` 的冗余条件。 // 走到这里时 caller 一定满足:非本人(line 42-45 已拦 caller==target 改 deptId); // 非超管、非 ADMIN(见本分支前的判定);且 CheckManageAccess → checkDeptHierarchy // 已经在 access.go:318-324 对 `caller.DeptId == 0 || caller.DeptPath == ""` fail-close // 返回 403——因此执行到本行时 caller.DeptPath 恒非空。冗余条件会误导新维护者以为 // "某条分支下 caller.DeptPath 可以为空",诱导把 checkDeptHierarchy 的护栏拆掉。 // // 注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何**非 DEV** // 部门;DEV 目标部门的跨产品权限升级路径由上面 H-R14-1 的显式护栏拦截(见审计 // L-R14-3 的注释披露要求)。 if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin && !strings.HasPrefix(newDept.Path, caller.DeptPath) { return response.ErrForbidden("无权将用户调入非自己管辖的部门") } } else { // 审计 L-R15-1:deptId=0 意味着"把用户从**全局**部门树里移除"——sys_user.deptId // 是全局字段,一次 UpdateUser 会让目标在**所有**他已加入的产品视角里同时失去 // DeptPath / DeptType。与 H-R14-1(调入 DEV)对称:caller 在产品 P1 的授权范围 // 天然仅覆盖 P1,不应具备改变共享全局字段的能力。原来的"产品 ADMIN 也可移出" // 会让 P1 ADMIN 把共有成员 B 在 P2 视角里变成"DeptId=0 的孤儿"——P2 日常管理层级 // (MEMBER/DEVELOPER/子 ADMIN)全部通不过 checkDeptHierarchy 对目标 DeptId 的 // 强校验,B 成为 P2 侧的隐形成员,DeptTree 里也找不到。业务上"移出部门"属于 // 离职/转岗这类 HR 行政流程,统一回收给 SuperAdmin 执行更合理。 if !caller.IsSuperAdmin { return response.ErrForbidden("仅超级管理员可将用户移出部门") } } deptId = *req.DeptId } statusChanged := false if req.Status != 0 { if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled { return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(冻结)") } if user.Status != req.Status { statusChanged = true } } newStatus := user.Status if statusChanged { newStatus = req.Status } // 审计 M-R11-3:改 deptId 到 `newDeptId>0` 时必须把 UPDATE 收敛进事务,并在同事务内对目标 // sys_dept[newDeptId] 加 S 锁——这样并发 DeleteDept 持有 sys_dept[X] 的 X 锁,会被 S 锁阻塞, // 等本事务提交后 DeleteDept 重读 `sys_user WHERE deptId=X FOR SHARE` 就能看到新行并拒绝删除, // 闭合"两侧都读不到对方提交 → 各自提交 → orphan deptId"的 write skew。 // 其余分支(只改其它字段 / 移出部门 deptId=0)无 write skew 风险,沿用非事务的 UpdateProfile。 needDeptShareLock := req.DeptId != nil && *req.DeptId > 0 && *req.DeptId != user.DeptId if !needDeptShareLock { if err := l.svcCtx.SysUserModel.UpdateProfile( l.ctx, req.Id, user.Username, nickname, email, phone, remark, deptId, newStatus, statusChanged, user.UpdateTime, ); err != nil { if errors.Is(err, userModel.ErrUpdateConflict) { return response.ErrConflict("数据已被其他操作修改,请刷新后重试") } return err } // 审计 L-R13-5 方案 A:post-commit 的 UD 失效与请求 ctx 解耦,避免 client 断连 / // 请求超时取消后 UD 仍然提供旧 DeptPath / MinPermsLevel / 冻结状态长达 TTL 窗口。 cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx) defer cancel() l.svcCtx.UserDetailsLoader.Clean(cleanCtx, req.Id) return nil } targetDeptId := *req.DeptId if err := l.svcCtx.SysUserModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error { // 事务内 S 锁目标 dept,保证 DeleteDept 的 X 锁被阻塞;顺带在事务内复核 Status。 // 上面非事务的 FindOne 已经校过一遍,这里是"在锁生效后的一致性视图"下的最终校验。 lockedDept, err := l.svcCtx.SysDeptModel.FindOneForShareTx(ctx, session, targetDeptId) if err != nil { if errors.Is(err, sqlx.ErrNotFound) { return response.ErrBadRequest("部门不存在") } return err } if lockedDept.Status != consts.StatusEnabled { return response.ErrBadRequest("目标部门已停用") } return l.svcCtx.SysUserModel.UpdateProfileWithTx( ctx, session, req.Id, user.Username, nickname, email, phone, remark, deptId, newStatus, statusChanged, user.UpdateTime, ) }); err != nil { if errors.Is(err, userModel.ErrUpdateConflict) { return response.ErrConflict("数据已被其他操作修改,请刷新后重试") } return err } // 审计 L-R12-1:UpdateProfileWithTx 不再自己 DelCache(避免 pre-commit 窗口里并发 FindOne // 把未提交旧值灌回缓存);这里在 commit 成功后显式失效 sysUser 低层 id/username 键,再叠加 // UserDetails 聚合缓存的 Clean,整条"两级缓存 → DB 权威"读链回到 cache-miss → loadFromDB。 // 审计 L-R13-5 方案 A:detached ctx + 3s timeout 让 DeptPath 切换 / 冻结状态这类 // 授权相关的失效不受 client 断连影响。 cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx) defer cancel() l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, req.Id, user.Username) l.svcCtx.UserDetailsLoader.Clean(cleanCtx, req.Id) return nil }