| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- 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"
- deptModel "perms-system-server/internal/model/dept"
- 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
- }
- // 审计 L-R16-2:识别"从 DEV 部门调出"的收窄方向——loadPerms 的 DEV 全权分支以
- // (MemberType != "") && (DeptType == DEV) && (DeptStatus == Enabled) 为条件,只要 target 的
- // deptId 离开 DEV 部门(挪到 NORMAL 或 deptId=0),该用户在其所属的**所有**产品内都失去
- // 全权。统一走"事务内 tokenVersion+1"签发层吊销,避免 Redis 抖动时 5min TTL 残留全权。
- var devAccessRevoked bool
- var newDept *deptModel.SysDept
- 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 {
- nd, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId)
- if err != nil {
- return response.ErrBadRequest("部门不存在")
- }
- newDept = nd
- // 审计 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-R16-1:删除原 ADMIN 分支的短路。sys_user.deptId 是**全局**字段,产品
- // ADMIN 在 P1 的授权范围仅覆盖 P1,把同时也是 P2 成员的 target 挪到 P1 子树外的
- // 任意(非 DEV)部门同样是"在 P2 视角制造结构性失联"的越权——与 L-R15-1 的
- // deptId=0 场景完全同构,只是落点从"0"换成"P1 某子树"。修复口径与 CreateUser
- // (createUserLogic.go:102-109 对非超管强制 DeptPath 前缀校验,无 ADMIN 豁免)
- // 对齐:非超管(含 ADMIN)必须把目标调入自身 DeptPath 子树之内,跨子树调度统一
- // 走 SuperAdmin 审批流。
- //
- // 审计 L-R13-3:走到这里时 caller 一定满足:非本人(line 42-45 已拦 caller==target
- // 改 deptId);非超管(见本分支前的判定);且 CheckManageAccess → checkDeptHierarchy
- // 已经对 `caller.DeptId == 0 || caller.DeptPath == ""` fail-close 返回 403——因此
- // 执行到本行时 caller.DeptPath 恒非空,无需再冗余判定空串。
- if !caller.IsSuperAdmin &&
- !strings.HasPrefix(newDept.Path, caller.DeptPath) {
- return response.ErrForbidden("无权将用户调入非自己管辖的部门")
- }
- } else {
- // deptId = 0:把用户移出全局部门树(L-R15-1 已收敛给 SuperAdmin)。
- // 审计 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("仅超级管理员可将用户移出部门")
- }
- }
- // 审计 L-R16-2:若 deptId 发生真实变更,且用户原本在 DEV 部门内,而新归属不再是"Enabled 的
- // DEV 部门"(挪到 NORMAL 或 deptId=0),则 loadPerms 的 DEV 全权分支对该用户失效,构成
- // "单用户的权限收窄"。仅在确实变更时才读取 oldDept 比对——避免 FindOne 成本被无谓请求放大。
- if *req.DeptId != user.DeptId && user.DeptId > 0 {
- oldDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, user.DeptId)
- if err == nil && oldDept != nil && oldDept.DeptType == consts.DeptTypeDev && oldDept.Status == consts.StatusEnabled {
- // newDept == nil 即 deptId=0(SuperAdmin-only 路径);newDept != nil 且 DeptType==NORMAL
- // 即挪到 NORMAL 部门;两种都构成 DEV 全权收窄。
- // 审计 L-R18-7:原来的第三分支 `newDept.Status != consts.StatusEnabled` 是死条件——
- // newDept != nil 时 Status 必为 Enabled(line 136-138 已在进入本分支前用
- // `req.BadRequest("目标部门已停用")` 拦截过),保留只会误导读者以为还有"调入已禁用
- // DEV 部门"之类的残留路径。
- if newDept == nil || newDept.DeptType == consts.DeptTypeNormal {
- devAccessRevoked = true
- }
- }
- }
- 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。
- //
- // 审计 L-R16-2:devAccessRevoked(DEV 全权收窄)同样需要走事务,这样"UPDATE sys_user +
- // tokenVersion+1"在同一事务内要么一起生效要么一起回滚——避免"部门已从 DEV 挪走但
- // tokenVersion 没 +1"让旧 access token 在 5min TTL 窗口内继续享有 DEV 全权。
- //
- // 其余分支(只改其它字段 / 移出部门 deptId=0 且原本也不是 DEV)无 write skew、无签发层吊销
- // 诉求,沿用非事务的 UpdateProfile。
- needShareLock := req.DeptId != nil && *req.DeptId > 0 && *req.DeptId != user.DeptId
- needTx := needShareLock || devAccessRevoked
- if !needTx {
- 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
- }
- if err := l.svcCtx.SysUserModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
- if needShareLock {
- // 事务内 S 锁目标 dept,保证 DeleteDept 的 X 锁被阻塞;顺带在事务内复核 Status。
- // 上面非事务的 FindOne 已经校过一遍,这里是"在锁生效后的一致性视图"下的最终校验。
- lockedDept, err := l.svcCtx.SysDeptModel.FindOneForShareTx(ctx, session, *req.DeptId)
- if err != nil {
- if errors.Is(err, sqlx.ErrNotFound) {
- return response.ErrBadRequest("部门不存在")
- }
- return err
- }
- if lockedDept.Status != consts.StatusEnabled {
- return response.ErrBadRequest("目标部门已停用")
- }
- }
- // 审计 L-R16-2:DEV 全权收窄(DEV→NORMAL / DEV→deptId=0)在 tx 内把 tokenVersion +1,
- // 与 UpdateMember 的 M-R15-1 签发层吊销口径对齐。
- //
- // 顺序约束:必须**先** UpdateProfileWithTx(带 `WHERE updateTime=expectedUpdateTime`
- // 的乐观锁),**再** IncrementTokenVersionWithTx。颠倒顺序会让 IncrementTokenVersionWithTx
- // 先把 updateTime 改到 now(),导致紧随其后的 UpdateProfileWithTx 的乐观锁 WHERE 匹配不到
- // (affected=0 → ErrUpdateConflict),把本来应该成功的请求误判为并发冲突。
- //
- // 审计 M-R17-2 · 双递增语义澄清:
- // 当 devAccessRevoked && statusChanged 并发发生在同一请求时(例如一次 UpdateUser 同时把
- // target 从 DEV 部门调到 NORMAL 并把 status 置为 Disabled),tokenVersion 在本事务内会
- // 被**连续递增两次** —— UpdateProfileWithTx 内部因 statusChanged=true 先自增 1,下一行
- // 对 devAccessRevoked 的显式 IncrementTokenVersionWithTx 再自增 1,净效果 +2。
- // 这在**安全语义上等价于 +1**:tokenVersion 是单调递增信号量,jwtauthMiddleware 只要求
- // claims 里的 tokenVersion >= DB 当前值才放行,旧 token 只会被一票否决不会"被跳过"。
- // 运维侧若用 tokenVersion 样本做"活跃会话批次"分析,看到单次 UpdateUser 让 tokenVersion
- // 跳 2 不是异常,而是这两条收窄路径重叠命中的预期行为。
- //
- // 之所以不在本层"收敛到只 +1":
- // - UpdateProfileWithTx 的 statusChanged 分支内嵌的 tokenVersion+1 是跨 UpdateUser /
- // UpdateUserStatus / UpdateMember 多调用方共享的契约(M-R15-1 / L-R15-3 都依赖它),
- // 拆出条件开关反而会让签发层吊销的不变量分散到每个调用方,回归面变大;
- // - +2 不破坏正确性,只多消耗一次极廉价的 `UPDATE ... SET tokenVersion=tokenVersion+1`。
- // 因此这里**有意**保留"可能 +2"的行为,与安全性无矛盾。
- if err := l.svcCtx.SysUserModel.UpdateProfileWithTx(
- ctx, session, req.Id, user.Username,
- nickname, email, phone, remark, deptId,
- newStatus, statusChanged, user.UpdateTime,
- ); err != nil {
- return err
- }
- if devAccessRevoked {
- if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, req.Id); err != nil {
- return err
- }
- }
- return nil
- }); 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
- }
|