社群团购系统类快团团模式开发——微信小程序登录:code2Session 之后还有 5 件事要做
网上 90% 的微信登录教程到 code2Session 换出 openid 就结束了。在普通商城里这样写能跑,但在团购系统里会埋下返工地雷——因为这个系统的用户身份锚点是微信,而业务身份是团长体系,登录要做的事远比"换一个 openid"多。
先给结论:code2Session 拿到 openid 只是第一步,之后还有 5 件事必须做,漏一件后期都要返工:
- 幂等地查/建用户——并发首次登录不重复建号;
- 绑定业务角色——一个微信号可能同时是消费者、帮卖团长、供货团长,登录时把角色集合一起带出;
- 签发并管理 JWT 登录态——2 小时 access token + 7 天可吊销的 refresh token;
- 手机号授权链路——按需触发,加密存储,服务于售后和提现实名;
- 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. 踩坑清单
40163 code been used:前端在多个页面生命周期里重复调wx.login,第二个 code 覆盖第一个导致重放。规范:code 只在登录函数内现取现用,取一次用一个;- 登录态过期瞬间的并发 401:token 失效时页面上同时飞着 5 个请求,各自触发刷新导致 refresh token 被轮换后失效。前端要做刷新队列:第一个 401 触发续期,其余请求挂起等新 token;
- session_key 下发前端:老代码里为了前端解密把 session_key 传出去,等于开放数据裸奔。全部收拢到服务端;
- 测试/生产 appid 混用:表现为"部分用户登录失败",排查半天发现灰度环境用了生产 secret。配置中心按环境隔离 + 启动时打印 appid 指纹(前 8 位)自检;
- request 合法域名漏配:H5 侧调接口跨域报错,小程序侧 request 合法域名未配置直接失败。上线检查清单必备项。
9. 本篇小结
- code2Session 只是第一步,之后 5 件事:幂等建号、角色绑定、JWT 登录态、手机号按需授权、unionid 预埋与账号合并;
- 查/建用户靠
uk_openid唯一键 + 冲突重查,并发首登不重复建号——唯一键兜底幂等的思想与资金模块同源; - 角色是关系不是字段,登录时带出"生效角色集合"进 JWT payload,权限判断留给第 8 篇;
- JWT 为主、Redis 黑名单补短:2 小时 access + 7 天可吊销 refresh;黑名单 TTL 等于 token 剩余寿命;userId 从登录态取,绝不从请求参数取;
- 手机号按需触发 + AES 加密存储,把授权时机当产品设计问题;
- unionid 第一天就存,多端规划不等人;账号合并"消费数据自动迁、资金数据人工审"。
登录解决了"你是谁",但"你能干什么"是另一个问题:帮卖团长能看到供货价吗?运营能改结算单吗?一个微信号四种身份在接口层怎么区分?下一篇《RBAC 权限设计:当一个微信号有 4 种身份时怎么办》——用户-角色-权限三层模型、注解式权限 AOP 的完整实现,以及"帮卖只能看自己的单"的数据权限防线。
浙公网安备 33010602011771号