社群团购系统类快团团模式开发——微信小程序登录:code2Session 之后还有 5 件事要做

网上 90% 的微信登录教程到 code2Session 换出 openid 就结束了。在普通商城里这样写能跑,但在团购系统里会埋下返工地雷——因为这个系统的用户身份锚点是微信,而业务身份是团长体系,登录要做的事远比"换一个 openid"多。

先给结论:code2Session 拿到 openid 只是第一步,之后还有 5 件事必须做,漏一件后期都要返工:

  1. 幂等地查/建用户——并发首次登录不重复建号;
  2. 绑定业务角色——一个微信号可能同时是消费者、帮卖团长、供货团长,登录时把角色集合一起带出;
  3. 签发并管理 JWT 登录态——2 小时 access token + 7 天可吊销的 refresh token;
  4. 手机号授权链路——按需触发,加密存储,服务于售后和提现实名;
  5. unionid 预埋与账号合并——多端规划第一天就存 unionid,等用户数据对不上再补是迁移灾难。

功能架构

本篇给出完整的登录时序、每一步的落地代码(Spring Boot 3 + Spring Security 过滤器链),以及一份踩坑清单。


1. code2Session:六步时序与三个必须知道的细节

先看完整链路。微信官方的登录协议分六步,前两步在前端,中间两步是服务端与微信服务器的唯一交互,后两步是本篇的重点:

小程序                 服务端                    微信服务器
  │                     │                          │
  │ ① wx.login()        │                          │
  │──┐ 获取临时 code     │                          │
  │<─┘                  │                          │
  │ ② 携带 code 调登录接口 │                          │
  │────────────────────>│ ③ code2session(code)     │
  │                     │─────────────────────────>│
  │                     │ ④ openid + session_key   │
  │                     │<─────────────────────────│
  │                     │ ⑤ 查/建用户 → 签发 JWT     │
  │ ⑥ token + 用户信息    │    (本篇 5 件事都在这里)    │
  │<────────────────────│                          │

③④ 步的调用很简单:GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=CODE&grant_type=authorization_code。但三个细节决定线上稳定性:

第一,code 一次性、5 分钟有效。 前端每次进小程序都要重新 wx.login,服务端绝不缓存 code——重放会报 40163 code been used,这是该链路最高频的线上故障(踩坑清单里细说)。code 的生命周期设计成"用完即弃",前后端都要贯彻。

第二,session_key 不是给业务用的。 它用于解密微信开放数据(老版手机号、运动步数),必须服务端保密存储、永不下发前端——下发等于把用户数据的解密钥匙交给客户端。2021 年后手机号获取有了更好的方案(第 5 件事),业务代码里 session_key 的存在感应该趋近于零。

第三,appid/secret 是最高敏感级配置。 走配置中心加密存储,不进代码库不进日志;测试号和生产号的 appid 必须环境隔离,混用是"登录时好时坏"这类灵异故障的头号来源。


2. 第 1 件事:幂等地查/建用户

拿到 openid 后第一件事是找到或创建对应用户。直觉写法是"先查,查不到就插入":

// 反例:并发首次登录会插入两条
User user = userMapper.selectByOpenid(openid);
if (user == null) {
    user = User.init(openid);
    userMapper.insert(user);      // 两个请求同时到这里,插入两条
}

单用户场景看不出问题,但新用户第一次打开小程序时,前端往往并发发起多个请求(登录 + 静默埋点 + 配置拉取),两个请求同时走完"查不到"的分支,同一个 openid 插入两条用户记录,之后的查询谁先命中算谁,用户数据从此错乱。

正确做法:uk_openid 唯一键兜底 + 冲突后重查(数据库层面保证幂等,和第 6 篇 uk_biz_serial 是同一个思想):

ALTER TABLE `t_user`
  ADD UNIQUE KEY `uk_openid` (`openid`),
  ADD KEY `uk_unionid` (`unionid`);   -- unionid 可空,唯一键允许多个 NULL
public User findOrCreateByOpenid(String openid) {
    User user = userMapper.selectByOpenid(openid);
    if (user != null) {
        return user;                            // 老用户直接返回
    }
    User fresh = User.init(openid);
    try {
        userMapper.insert(fresh);
        return fresh;                           // 首登用户
    } catch (DuplicateKeyException e) {
        return userMapper.selectByOpenid(openid); // 并发首登:撞唯一键后重查
    }
}

DuplicateKeyException 的 catch 分支是正常路径不是异常处理——它恰好是"另一个并发请求已经建好了号"的信号,重查拿到那条记录即可。用户体系里的幂等习惯,和资金模块(第 6 篇)完全同源:唯一键是并发安全的地基,代码只是它的使用者。


3. 第 2 件事:绑定业务角色——一个微信号四种身份

普通商城是"一个用户一个身份",这套系统不行:帮卖团长自己也会买东西(同时是消费者);供货团长也可能帮别人卖货(同时是帮卖团长)。角色必须是关系,不能是用户表上的字段。

CREATE TABLE `t_user_role` (
  `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`     BIGINT   NOT NULL,
  `role`        VARCHAR(20) NOT NULL COMMENT 'CONSUMER/LEADER/SUPPLIER/ADMIN',
  `status`      TINYINT  NOT NULL DEFAULT 10 COMMENT '10待审核 20生效 30冻结',
  `apply_time`  DATETIME(3) NULL COMMENT '申请时间(LEADER 审核 Tide)',
  `audit_time`  DATETIME(3) NULL COMMENT '审核时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_role` (`user_id`, `role`)
) ENGINE=InnoDB COMMENT='用户角色关系表';

登录时角色绑定做两件事:status=20 生效 的角色集合查出来放进 JWT payload(待审核、冻结的角色不下发——申请成为帮卖团长就是插一行 status=10,审核通过改 20,身份变更全程留痕,风控可查);把角色集合的快照时间记下来,为第 8 篇的"冻结窗口期"问题做铺垫。四个角色各自的关键权限、审核机制和实时校验,第 8 篇 RBAC 专文展开,这里只需记住结论:登录链路的职责是把"生效角色集合"安全地带给后续请求,而不做任何权限判断。


4. 第 3 件事:JWT 登录态设计——选型与过滤器链

4.1 JWT 还是 Session

维度 Session(Redis 集中存储) JWT(自包含令牌)
服务端状态 有,每次请求查 Redis 无,签名验证即通过
水平扩容 依赖共享存储 天然无状态
主动踢人/注销 删记录即可,立刻生效 难,需要黑名单补偿
小程序适配 Cookie 机制不友好 Header 携带,无障碍
撤销实时性 弱(需配合黑名单)

这套系统选 JWT 为主、Redis 黑名单补短的组合:小程序环境里 Cookie 依赖弱,JWT 的 Header 传递天然适配;但 JWT "签发后无法撤销"的缺陷在资金场景不可接受(冻结团长账号后他的 token 还能用两小时),所以用 Redis 黑名单补上主动撤销能力。

payload 设计只有四个业务字段,其他一律不放:

public record JwtPayload(
        long userId,
        String openid,
        Set<String> roles,       // 生效角色集合
        long exp                 // 过期时间戳
) {}

为什么 payload 不塞用户昵称头像?因为 JWT 是签名不加密,payload 任何人可解码——只放鉴权必需的最小字段,敏感信息零容忍。有效期设计:access token 2 小时,refresh token 7 天;access 过期前端用 refresh 静默续期,用户无感。

4.2 过滤器链落地

Spring Security 过滤器链里插一个自定义 JWT 过滤器,完整实现:

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final StringRedisTemplate redis;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String token = resolveToken(request);
        if (token == null) {
            chain.doFilter(request, response);
            return;                                  // 无 token 交给后续匿名处理
        }
        try {
            JwtPayload payload = jwtService.verify(token);  // 验签 + 过期校验
            // 黑名单校验:注销/踢下线/角色冻结时写入,TTL = token 剩余有效期
            Boolean banned = redis.hasKey("gbs:auth:black:" + payload.userId()
                    + ":" + jwtService.jtiOf(token));
            if (Boolean.TRUE.equals(banned)) {
                response.sendError(HttpStatus.SC_UNAUTHORIZED, "登录态已失效");
                return;
            }
            List<SimpleGrantedAuthority> authorities = payload.roles().stream()
                    .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                    .toList();
            UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(payload.userId(), null, authorities);
            SecurityContextHolder.getContext().setAuthentication(auth);
        } catch (JwtException e) {
            response.sendError(HttpStatus.SC_UNAUTHORIZED, "token 无效");
            return;
        }
        chain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        return (header != null && header.startsWith("Bearer "))
                ? header.substring(7) : null;
    }
}

三个实现要点:黑名单的 Key 粒度到 userId + jti(单个令牌失效),冻结整个用户则按 userId 批量拉黑;黑名单 TTL 必须等于 token 剩余有效期——token 自然过期后黑名单自动消失,Redis 不积压;userId 放进 Authentication 的 principal,后续业务代码从 SecurityContext 取当前用户,绝不从请求参数里取 userId——那是一切水平越权漏洞的入口(第 8 篇展开)。

路由前缀再补一层粗粒度防护:/api/c/**(消费者)、/api/leader/**(团长)、/api/admin/**(运营)在 Security 配置里按前缀声明角色要求,注解级细粒度校验交给第 8 篇的 AOP。

4.3 refresh token 续期:轮换与一次性

refresh token 的续期接口有一个必须做对的细节——轮换(rotation):每次续期发新 refresh token、旧的立即作废。不轮换的话,refresh token 一旦泄露,攻击者可以用它无限续期,等于拿到了永久登录权;轮换后,被泄露的旧 token 在第一次续期时就失效,配合"同一 refresh token 第二次使用即触发该用户全部登录态作废"的风控规则,窃取者的使用行为本身会暴露泄露事件:

@PostMapping("/api/c/auth/refresh")
public Result<LoginVO> refresh(@RequestBody @Valid RefreshDTO dto) {
    JwtPayload payload = jwtService.verify(dto.getRefreshToken());
    String key = "gbs:auth:refresh:" + payload.userId();
    // 一次性校验:旧 jti 不在白名单里 = 已被轮换或已注销
    if (Boolean.FALSE.equals(redis.opsForHash().hasKey(key, jwtService.jtiOf(dto.getRefreshToken())))) {
        // 可疑:一个已作废的 refresh token 再次出现,全部登录态作废并告警
        authRiskService.revokeAllAndAlert(payload.userId(), "refresh token 重放");
        throw new BizException(ErrorCode.UNAUTHORIZED);
    }
    // 轮换:删旧 jti,签发新的 access + refresh
    redis.opsForHash().delete(key, jwtService.jtiOf(dto.getRefreshToken()));
    Set<String> roles = roleService.activeRolesOf(payload.userId());  // 角色重新查库,天然携带最新状态
    return Result.ok(LoginVO.of(
            jwtService.sign(new JwtPayload(payload.userId(), payload.openid(), roles, exp(2, HOURS))),
            jwtService.signRefresh(payload.userId(), exp(7, DAYS)),
            roles));
}

注意续期时角色集合重新查库而不是沿用旧 payload——这等于每 2 小时给角色状态做一次强制刷新,和第 8 篇的实时校验互为补充。

4.4 code2session 的防腐层封装

与微信的交互要在 infra 层封装成防腐层(第 4 篇的铁律),微信改版时只改一处:

@Component
@RequiredArgsConstructor
public class WxApiClient {
    private final RestClient restClient;   // Boot 3 自带,替代 RestTemplate
    @Value("${gbs.wx.appid}")    private String appid;
    @Value("${gbs.wx.secret}")   private String secret;

    public WxSession code2session(String code) {
        WxSessionResp resp = restClient.get()
                .uri("https://api.weixin.qq.com/sns/jscode2session?appid={a}&secret={s}&js_code={c}&grant_type=authorization_code",
                        appid, secret, code)
                .retrieve().body(WxSessionResp.class);
        if (resp == null || resp.getErrcode() != null && resp.getErrcode() != 0) {
            // 40163=code 已使用;40029=code 无效;45011=频率限制,分错误码记日志便于排障
            throw new BizException(ErrorCode.WX_LOGIN_FAILED, resp == null ? "空响应" : resp.getErrcode());
        }
        return new WxSession(resp.getOpenid(), resp.getSessionKey(), resp.getUnionid());
    }
}

错误码分支要细:40163(code 已用)和 40029(code 无效)在排障时指向完全不同的原因(前端重复调用 vs 前端没拿到 code),混成一个"登录失败"会把简单问题排查复杂化。


5. 第 4 件事:手机号授权链路

手机号在这套系统里服务于两件事:售后联系(自提核销、快递异常)和提现实名(结算打款前校验)。2021 年后获取方案不再走 session_key 解密,而是按钮授权

小程序端                                      服务端
   │ button open-type="getPhoneNumber"           │
   │────────── e.detail.code ───────────────────>│
   │                                             │ 调微信 phonenumber.getPhoneNumber 接口
   │                                             │ 用 code 换手机号明文
   │                                             │ AES 加密落库
@PostMapping("/api/c/auth/phone")
public Result<Void> bindPhone(@RequestBody PhoneBindDTO dto) {
    String phone = wxApiClient.getPhoneNumber(dto.getCode()); // code 换明文,code 同样一次性
    String cipher = aesService.encrypt(phone);                // 落库前加密
    userService.bindPhone(SecurityUtil.currentUserId(), cipher);
    return Result.ok();
}

两条设计纪律:按需触发而不是登录强制——一进小程序就弹手机号授权弹窗,转化率会肉眼可见地掉,正确时机是下单或申请提现时引导;加密存储——手机号是个人信息保护法下的敏感个人信息,库里存 AES 密文,密钥在配置中心,展示层脱敏(138****5678)。授权弹窗每多弹一次都是对用户耐心的一次消耗,把"什么时候必须要手机号"当成产品设计问题,不只是技术问题。


6. 第 5 件事:unionid 预埋与账号合并

openid 是单个 appid 下的用户标识——同一个用户在小程序和公众号里 openid 不同。unionid 则是同一开放平台账号下所有应用的统一标识。这个区别在单小程序阶段没有体感,但客户规划一旦出现"小程序 + 公众号 H5"(团购系统太常见了:公众号推文导流),两套 openid 的用户数据就对不上了。

所以用户表第一天就同时存两个字段(第 2 节 DDL 已带):绑定微信开放平台后,登录链路里 code2session 响应如果带 unionid 就顺手落库——预埋成本是零,事后补是数据迁移灾难:两套系统的用户表已经各自长了几个月,合并要处理订单归属、佣金账户、提现记录的迁移和冲突。

真正的难点是账号合并冲突:用户先在公众号领了卡、下了单,后来又用小程序登录,unionid 一致但已是两条 user 记录。合并策略:

冲突资源 处理策略
基础资料 保留注册更早的账号为主账号
订单/售后单 全部迁移到主账号,来源标记
佣金记录 合并前人工审核(涉及资金,禁止自动合并)
提现账户 冲突时冻结提现,人工核验后恢复

原则一句话:消费类数据自动迁,资金类数据人工审。 合并动作本身要落合并流水表(谁、何时、从哪些账号并到哪),可回溯——用户体系的每一步资金相邻操作,都按资金标准对待。


7. 完整登录链路(把 5 件事串起来)

服务端登录接口的完整实现,5 件事各归其位:

@PostMapping("/api/c/auth/login")
public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto) {
    // code2session:微信交互唯一一步(第 1 步之前)
    WxSession wx = wxApiClient.code2session(dto.getCode());

    // 5 件事之一:幂等查/建用户(uk_openid 兜底并发首登)
    User user = userService.findOrCreateByOpenid(wx.getOpenid());

    // 5 件事之二:带出生效角色集合(待审核/冻结不下发)
    Set<String> roles = roleService.activeRolesOf(user.getId());

    // 5 件事之三:签发 JWT(2h)+ refresh token(7d,可吊销)
    String token  = jwtService.sign(new JwtPayload(user.getId(), wx.getOpenid(), roles, exp(2, HOURS)));
    String refresh = jwtService.signRefresh(user.getId(), exp(7, DAYS));

    // 5 件事之五:unionid 预埋(响应里有就落库)
    userService.fillUnionidIfPresent(user.getId(), wx.getUnionid());

    // 5 件事之四:手机号不在此处,按需在下单/提现时引导(见第 5 节)
    return Result.ok(LoginVO.of(token, refresh, roles));
}

8. 踩坑清单

  1. 40163 code been used:前端在多个页面生命周期里重复调 wx.login,第二个 code 覆盖第一个导致重放。规范:code 只在登录函数内现取现用,取一次用一个;
  2. 登录态过期瞬间的并发 401:token 失效时页面上同时飞着 5 个请求,各自触发刷新导致 refresh token 被轮换后失效。前端要做刷新队列:第一个 401 触发续期,其余请求挂起等新 token;
  3. session_key 下发前端:老代码里为了前端解密把 session_key 传出去,等于开放数据裸奔。全部收拢到服务端;
  4. 测试/生产 appid 混用:表现为"部分用户登录失败",排查半天发现灰度环境用了生产 secret。配置中心按环境隔离 + 启动时打印 appid 指纹(前 8 位)自检;
  5. request 合法域名漏配:H5 侧调接口跨域报错,小程序侧 request 合法域名未配置直接失败。上线检查清单必备项。

9. 本篇小结

  1. code2Session 只是第一步,之后 5 件事:幂等建号、角色绑定、JWT 登录态、手机号按需授权、unionid 预埋与账号合并;
  2. 查/建用户靠 uk_openid 唯一键 + 冲突重查,并发首登不重复建号——唯一键兜底幂等的思想与资金模块同源;
  3. 角色是关系不是字段,登录时带出"生效角色集合"进 JWT payload,权限判断留给第 8 篇;
  4. JWT 为主、Redis 黑名单补短:2 小时 access + 7 天可吊销 refresh;黑名单 TTL 等于 token 剩余寿命;userId 从登录态取,绝不从请求参数取;
  5. 手机号按需触发 + AES 加密存储,把授权时机当产品设计问题;
  6. unionid 第一天就存,多端规划不等人;账号合并"消费数据自动迁、资金数据人工审"。

登录解决了"你是谁",但"你能干什么"是另一个问题:帮卖团长能看到供货价吗?运营能改结算单吗?一个微信号四种身份在接口层怎么区分?下一篇《RBAC 权限设计:当一个微信号有 4 种身份时怎么办》——用户-角色-权限三层模型、注解式权限 AOP 的完整实现,以及"帮卖只能看自己的单"的数据权限防线。

posted on 2026-09-07 14:59  程序员李铁牛  阅读(20)  评论(0)    收藏  举报