用户管理模块:创建、删除、查询与会话管理

用户管理模块,说白了就是SDK的第一道门禁。它管的是“谁可以进来、进来后能待多久、走的时候干不干净”。我做了这么多年RBAC,发现大部分权限泄露的根因,不是角色模型没设计好,而是用户管理的这几个基础操作写得不严谨。今天咱们就把创建、删除、查询和会话管理这四块掰开揉碎了说。

4.1 用户创建——别光加数据,得管好上下文

在SDK里创建一个用户,不只是往数据库里INSERT一条记录那么简单。用户创建至少需要三个步骤:

  1. 属性校验——用户名、邮箱、手机号这些唯一标识必须去重,密码强度要够,自定义属性要符合Schema约束。
  2. 角色绑定——新用户至少要给一个默认角色,否则后面鉴权直接空指针。我遇到过一家的SDK,新用户创建完没给默认Role,结果所有API都返回403,排查了半天才发现是初始化漏了。
  3. 账户激活——邮箱验证、短信验证、管理员手动激活,至少选一种。别一股脑全放通。

核心原则:用户创建时,立即完成“账户-角色”绑定,不要留中间态。留中间态意味着你需要在每一次鉴权时都检查“这个用户到底有没有角色”,性能损耗和逻辑复杂度都会翻倍。

下面是我建议的用户创建接口设计思路。注意看Lazy Activation的处理:

// 用户创建核心逻辑(伪代码)
function createUser(request) {
    // 1. 校验业务属性
    validateUserAttrs(request.attrs);

    // 2. 生成账户 ID 和凭据
    userId = generateUserId();
    credential = hashPassword(request.password);

    // 3. 创建账户记录(状态: PENDING)
    account = db.insert({
        id: userId,
        credential: credential,
        status: 'PENDING',   // 关键:先冻结
        createdAt: now()
    });

    // 4. 绑定默认角色(必须在插入账户之后立即执行)
    bindDefaultRole(userId, request.defaultRoleId);

    // 5. 触发激活流程(异步发送邮件/短信)
    triggerActivation(userId, request.email);

    return userId;
}

我曾经踩过的一个坑:在同一个事务里先insert用户,再异步发激活邮件。结果邮件发送失败了,用户数据却已经落库。后来我改成两步——插入用户和绑定角色放在一个事务里,激活单独走重试队列。这样就算邮件发不出去,用户也不会有角色,不会对正式权限产生影响。

4.2 用户删除——软删除还是硬删除?看你业务怎么赔

删除操作在SDK里不能物理删除。为什么?

  • 审计日志需要记录操作者,如果用户物理删除了,日志里只剩一个孤立的userId,查都查不到。
  • 关联的会话、、角色绑定如果不清理干净,下一回有人注册相同的用户名,可能会继承之前的残余权限。
  • 法律合规——GDPR要求可删除,但通常是“匿名化处理”,不是物理抹掉。

我个人习惯用三段式删除策略:

阶段 状态 可操作时长 数据保留
第一阶段 DISABLED 30天 全部保留
第二阶段 ANONYMIZED 永久 脱敏后保留
第三阶段 PURGED 最终 物理删除

实际在SDK里,用户调用删除接口时,你把状态改成DISABLED,然后异步清理会话和缓存。等过了30天恢复期,再跑一个定时任务把超过期限的记录匿名化。你想想看,这样既给了用户反悔的机会,又满足了合规要求。

实战技巧:删除用户时,别忘了清理它在Redis或本地缓存里的Session。我见过一个案例:用户删除了,但旧的还在缓存里存活了15分钟,结果攻击者利用这个时间窗口搞了一波横向移动。解决方案很简单——删除时发送一条SESSION_INVALIDATE事件,所有会话层监听到后立即驱逐对应缓存。

4.3 用户查询——性能与安全的平衡

用户查询是调用最频繁的接口,没有之一。查询效率直接影响到SDK的整体延迟。

常见的查询场景分三类:

  1. 精确查询:根据userId或用户名查单个用户。走主键索引,问题不大。
  2. 模糊查询:按邮箱、手机号、姓名中的某一段模糊匹配。这里要注意,数据库的LIKE '%xxx%'会全表扫描,量大了以后基本扛不住。
  3. 角色关联查询:查某个角色下所有的用户。这在权限管理中必用,但如果角色对应的用户量级很大(比如“普通员工”角色下有10万人),一次查完直接OOM。

我的做法:

  • 模糊查询统一走搜索引擎(Elasticsearch或类似),不做数据库的like查询。
  • 角色关联查询必须分页,默认每页20条,最高不超过100条。前端要查全部?那你得允许它循环拉取,或者用游标翻页。
  • 高频查询在查询结果上做一层本地缓存(比如Caffeine),TTL设置30秒。注意:只对读操作做缓存,写入或删除时要立即失效。
// 用户查询接口示例(带缓存)
function queryUsersByRole(roleId, page, size) {
    cacheKey = "role_users:" + roleId + ":" + page + ":" + size;

    // 命中缓存直接返回
    cached = cache.get(cacheKey);
    if (cached != null) return cached;

    // 未命中查数据库
    data = db.query(
        "SELECT u.* FROM users u JOIN user_role ur ON u.id = ur.user_id " +
        "WHERE ur.role_id = ? LIMIT ? OFFSET ?",
        [roleId, size, page * size]
    );

    // 写入本地缓存,30秒过期
    cache.put(cacheKey, data, Duration.ofSeconds(30));
    return data;
}

4.4 会话管理——登录只是开始,保持状态才是关键

会话管理在实际SDK开发中是最容易被轻视的。很多人觉得“不就是生成一个存Redis嘛”,结果一上线就暴露出各种问题:过期了服务端不知道、同一用户多地登录互相踢下线、会话被劫持等等。

我总结了一张会话管理的核心要素表,基本能覆盖大部分场景:

要素 说明 常见做法
生成 JWT或UUID JWT推荐带一次签名的短有效期
存储 服务端存一份 Redis,Key=userId+设备ID
校验逻辑 每次都查,还是用本地验证 JWT可以本地验证但不推荐太长
并发控制 单个用户能同时登录几个 默认1个,高级版可以配置上限
超时处理 无操作多久自动过期 滑动过期30分钟,过期24小时

曾经有一个事故让我记忆深刻:某个内部系统的SDK,生成的JWT没有绑设备指纹。结果员工离职后,他的被人从旧浏览器里找到,直接拿去调API删了一堆数据。从那以后,我所有都强制绑定了deviceId,并且服务端每次校验时对比设备指纹。不一样就立即踢下线。

会话管理的核心数据流,我画了一张图:

用户管理模块核心数据流 用户管理模块核心数据流 用户创建 用户删除 用户查询 会话管理 角色绑定 清理会话/ 缓存结果 验证/刷新 说明 用户管理的四个核心操作为数据流入源头。 下层处理节点必须与对应上层操作紧耦合,不能异步延迟或跳过。

图中的数据流向其实很直观:创建用户必须立即绑定角色;删除用户必须同步清理会话和缓存;查询用户要利用缓存提高性能;会话管理则负责持续验证和刷新,确保用户的“在线状态”始终可控。

嗯,会话管理还有一个容易被忽视的细节——并发控制。说白了就是一个用户允许同时登录几个设备。我推荐的做法是:

  • 普通用户:只允许1个活跃会话(新登录踢掉旧的)。
  • VIP/管理员:可以配置最多3个。
  • 服务账号:不做限制,但要打上特殊标记,审计时能区分出来。

实现上很简单:每次创建新时,先查一下该用户当前的活跃会话数,如果超过上限,就按“最早活跃”的原则踢掉一个。踢掉的同时给被踢的会话发送一个SESSION_REVOKED事件,客户端收到后应当主动退出登录。

实用建议:会话管理中的刷新策略,我一般用双重模式——一个短期的AccessToken(15分钟过期),一个长期的RefreshToken(7天过期)。AccessToken走本地JWT验证,RefreshToken走服务端Redis验证。这样就算AccessToken泄露,影响范围也只有15分钟。而且大部分SDK的会话查库压力只集中在RefreshToken更新这个节点上,理论上可以减少80%的查库频率。

最后总结一句:用户管理模块在SDK中不是独立存在的。它和后面的角色绑定、权限校验、审计日志是联动的。我看到很多团队把这四个操作拆成了四个微服务,结果每次创建用户要调四次接口,延迟爆炸。你想想看,用户管理本质上是“围绕一个用户身份做状态转换”,所以我建议这四个操作在逻辑上保持紧密的协同,最好封装在一个领域服务里。