package dept import ( "context" "errors" "time" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" authHelper "perms-system-server/internal/logic/auth" deptModel "perms-system-server/internal/model/dept" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/types" "github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/core/stores/sqlx" ) type UpdateDeptLogic struct { logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewUpdateDeptLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateDeptLogic { return &UpdateDeptLogic{ Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } // UpdateDept 更新部门。修改部门名称、排序、类型、备注或启用/禁用状态。使用乐观锁防止并发冲突, // 变更部门类型或状态时自动清理受影响用户的权限缓存。 // // 审计 L-R16-2(签发层吊销,与 M-R15-1 / L-R15-3 对齐): // `loadPerms` 的"全权分支"直接以 DeptType + DeptStatus 作为授权输入(userDetailsLoader.go:539- // 554),因此以下三种 UpdateDept 变更构成"权限收窄": // - (a) DeptType: DEV → NORMAL(本部门所有在编成员丢失 DEV 全权); // - (b) DeptType 不变但 DEV 部门 Status: Enabled → Disabled(DEV 成员丢失全权); // - (c) NORMAL 部门 Status: Enabled → Disabled(业务语义是"冻结本部门所有活动",一并吊销)。 // // 这些场景只靠 `UserDetailsLoader.CleanByUserIds` 尽力而为失效的话,Redis 抖动会让 5min TTL 内 // 的旧权限继续生效;现在把"UPDATE sys_dept + 枚举并 S 锁 sys_user 行 + 批量 tokenVersion+1" // 收敛进同一事务,任一步失败整体回滚。post-commit 再做 dept/user 低层缓存 + UD 聚合缓存的失效。 // 升权方向(NORMAL→DEV、Disabled→Enabled)不递增 tokenVersion,避免把正在使用的合法会话踢下线。 func (l *UpdateDeptLogic) UpdateDept(req *types.UpdateDeptReq) error { if err := authHelper.RequireSuperAdmin(l.ctx); err != nil { return err } if len(req.Name) > 64 { return response.ErrBadRequest("部门名称长度不能超过64个字符") } if len(req.Remark) > 255 { return response.ErrBadRequest("备注长度不能超过255个字符") } dept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.Id) if err != nil { return response.ErrNotFound("部门不存在") } // 快照旧值用于判定"收窄方向"。dept 对象会在下方被 req 覆盖,这里必须先取出原 DeptType/Status。 prevType := dept.DeptType prevStatus := dept.Status deptTypeChanged := false statusChanged := false dept.Name = req.Name dept.Sort = req.Sort dept.Remark = req.Remark if req.DeptType != "" { if req.DeptType != consts.DeptTypeNormal && req.DeptType != consts.DeptTypeDev { return response.ErrBadRequest("部门类型无效,仅支持 NORMAL 和 DEV") } if dept.DeptType != req.DeptType { deptTypeChanged = true dept.DeptType = req.DeptType } } if req.Status != 0 { if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled { return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)") } if dept.Status != req.Status { statusChanged = true dept.Status = req.Status } } expectedUpdateTime := dept.UpdateTime dept.UpdateTime = time.Now().Unix() // 审计 L-R16-2:识别是否构成"权限收窄"。 // - devFullAccessRevoked: (a) DEV→NORMAL 或 (b) DEV 部门 Enabled→Disabled;两种都让本部门 // 成员的 loadPerms 从全权分支掉回"角色/allow-deny 计算"; // - normalDeptFrozen: (c) NORMAL 部门 Enabled→Disabled;语义上"冻结部门"。 // 升权方向(NORMAL→DEV、Disabled→Enabled)不进入吊销分支。 nextType := dept.DeptType nextStatus := dept.Status devFullAccessRevoked := (prevType == consts.DeptTypeDev && nextType == consts.DeptTypeNormal) || (prevType == consts.DeptTypeDev && prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled) normalDeptFrozen := prevType == consts.DeptTypeNormal && nextType == consts.DeptTypeNormal && prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled shouldRevokeSessions := devFullAccessRevoked || normalDeptFrozen var revokedUserIds []int64 if err := l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error { if err := l.svcCtx.SysDeptModel.UpdateWithOptLockTx(ctx, session, dept, expectedUpdateTime); err != nil { return err } if shouldRevokeSessions { // FindIdsByDeptIdForShareTx 对命中 sys_user 行加 S 锁,与并发 UpdateProfileWithTx 的 // X 锁互斥——防止"枚举时 A 还在本部门、枚举完 A 被并发挪走却已失效吊销"或反过来的 // 漏吊销窗口。 ids, err := l.svcCtx.SysUserModel.FindIdsByDeptIdForShareTx(ctx, session, req.Id) if err != nil { return err } if len(ids) > 0 { if err := l.svcCtx.SysUserModel.BatchIncrementTokenVersionWithTx(ctx, session, ids); err != nil { return err } revokedUserIds = ids } } return nil }); err != nil { if errors.Is(err, deptModel.ErrUpdateConflict) { return response.ErrConflict("数据已被其他操作修改,请刷新后重试") } return err } // post-commit 三级缓存失效(detached ctx 保证 client 断连时仍能完成): // ① sys_dept 低层缓存(本行刚被 UPDATE,sysDeptIdKey 需要失效,否则 FindOne 会返回旧值); // ② sys_user 低层缓存(tokenVersion 已 +1,否则 UD loader 下次 miss 会从 sysUser 低层缓存 // 拿到旧 tokenVersion 把递增值抹回,与 UpdateMember / RemoveMember 同口径); // ③ UserDetails 聚合缓存(DeptType / DeptStatus / TokenVersion 均是字段,必须 Clean 后 // 重新 Load 才能拿到新授权快照)。 cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx) defer cancel() l.svcCtx.SysDeptModel.InvalidateDeptCache(cleanCtx, req.Id) if deptTypeChanged || statusChanged { // CleanByUserIds 在 shouldRevokeSessions=false 分支仍然有用: // 例如 NORMAL 部门 Disabled→Enabled 是升权方向,不吊销 session,但 UD 聚合缓存里的 // DeptStatus 字段仍然是旧值(Disabled),必须 Clean 让 loadPerms 在下次 Load 时回到 // Enabled 对应的分支(对 DEV 部门意味着重新进入全权分支)。 // 当 shouldRevokeSessions=true 时,revokedUserIds 已经在 tx 内批量 +1,这里顺带把 UD // 也失效,两级缓存一起回到 cache-miss。 userIds := revokedUserIds if len(userIds) == 0 { // 不需要吊销会话的场景(升权 / 非收窄的 deptType/status 切换)也要失效 UD。 ids, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id) if err != nil { l.Errorf("UpdateDept id=%d deptType=%s status=%d 部门已更新但 FindIdsByDeptId 失败,用户权限缓存未能主动失效,将等待 TTL 自然过期: %v", req.Id, dept.DeptType, dept.Status, err) } else { userIds = ids } } if len(userIds) > 0 { l.svcCtx.UserDetailsLoader.CleanByUserIds(cleanCtx, userIds) } // 当 shouldRevokeSessions=true 时,每个受影响 sys_user 的低层缓存都要失效,防止 sysUser // 缓存里的旧 tokenVersion 被 UD loader 下次 miss 时读回、把刚递增的值抹回去。 // FindByIds 批量拿 (id, username),避免对 len(userIds) 次 FindOne。 if shouldRevokeSessions && len(revokedUserIds) > 0 { users, err := l.svcCtx.SysUserModel.FindByIds(cleanCtx, revokedUserIds) if err != nil { logx.WithContext(l.ctx).Errorf("UpdateDept post-commit FindByIds(len=%d) failed for token-version cache invalidation: %v", len(revokedUserIds), err) } else { for _, u := range users { if u == nil { continue } l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, u.Id, u.Username) } } } l.Infof("UpdateDept id=%d deptType=%s status=%d affectedUsers=%d revokedSessions=%d", req.Id, dept.DeptType, dept.Status, len(userIds), len(revokedUserIds)) } return nil }