接手一个短视频平台小程序项目时,登录这块被排在了第一周。当时我以为这事顶多半天:小程序端点一下授权按钮,后端调个接口换 openid,塞进 Redis,返回个 token,收工。结果真正做下去,发现真正的难点根本不在"调用接口"这四个字上,而在这条链路上每一环的有效期、一次性、以及失败后的兜底。同一个 code 被重复提交、session_key 被并发登录冲掉导致解密手机号失败、体验版和正式版 appid 混用导致换出来的 openid 对不上人——这些问题不会在文档的第一页出现,但会在上线第一周准时出现。
这篇东西写给谁:手里已经有一台能跑 Spring Boot 的服务、要给小程序做登录的 Java 后端;或者前端已经把tt.login调通了、卡在"拿到 code 之后怎么办"的同学。我会把整条链路拆开讲清楚每一段的职责边界,给出可直接落地的 Java 代码,重点放在那些文档里不写、但实际会让人返工两天的地方。抖音小程序只是入口,这套思路换到别的小程序平台也基本通用,差别主要在接口地址和字段名。
1. 登录链路到底由几段拼成:从临时 code 到自家 token
很多人对这条链路有误解,以为"登录"是一个动作。实际上它是三个独立的小动作串起来的,每个动作解决一个不同的问题,混在一起想就会乱。
1.1 小程序端拿到的永远只是一个一次性临时凭证
小程序端能做的事非常有限。它调tt.login(),拿到的是一个code,这个 code 什么都不代表——不含用户身份、不含昵称、不含手机号,它唯一的作用是"向服务端证明这次请求来自一个真实的小程序运行环境"。
这个 code 有三个硬约束,必须刻在脑子里:有效期极短(通常几分钟)、只能用一次、和 appid 绑定。用过一次之后再拿它去换,服务端接口会直接返回错误;用 A 应用的 code 配 B 应用的 appid 去换,同样报错。我见过最典型的错误用法,是前端把 code 存在 storage 里,用户下次打开小程序直接拿旧 code 去请求登录接口,结果一直报错,还找不到原因。
所以前端正确的姿势是:每次需要登录时重新调tt.login()拿新 code,拿到立刻发给后端,不做任何缓存。这一点跟网页端把 token 存 localStorage 反复用的习惯完全相反,很容易惯性写错。
另外要注意,tt.login()本身有失败回调。用户网络抖动、小程序基础库异常都可能触发,前端必须有重试逻辑,而不能假设它一定成功。
1.2 服务端 code 换 session:这一步才是真正的"认人"
后端拿到 code 之后,带着appid和appSecret去调平台的服务端接口,换回来的是一组字段:openid、session_key,条件满足时还有unionid。
这三个字段的含义差别很大,搞混了会做出一堆错误设计:
| 字段 | 含义 | 是否稳定 | 能否给前端 |
|---|---|---|---|
| openid | 用户在该应用内的唯一标识 | 同一应用内永久稳定 | 可以(但没必要) |
| session_key | 会话密钥,用于解密敏感数据 | 会随登录刷新变化 | 绝对不可以 |
| unionid | 用户在同一开发者主体下多个应用的统一标识 | 需绑定开放平台后才有 | 可以(谨慎) |
openid是你做用户表主键关联的锚点,它在这个应用内永远不变,所以第一次登录时拿它去查库,查不到就注册新用户,查到了就更新登录时间,这是标准做法。session_key不是身份,它是密钥,只是恰好和身份一起下发。把session_key返回给小程序端是最常见的严重错误——它一旦泄露,攻击者可以伪造解密敏感数据,等于把保险箱钥匙和保险箱一起寄出去。
unionid的坑在于它不一定有。只有在开发者账号下绑定了开放平台、并且小程序也归属该主体时才会返回。代码里如果直接对unionid做非空断言,某些情况下会直接空指针。正确的写法是:有就用,没有就存 null,做多应用打通时再考虑。
1.3 为什么不能把 openid 当登录态直接下发
新手最省事的做法是:换到 openid,直接把 openid 返回给前端,之后每次请求带上 openid 就当已登录。这个方案能跑通,但等于没有登录。
原因是 openid 是标识而不是凭证。标识可以被猜到、被抄走、被从前端日志里翻出来,而凭证应该是不可伪造、有有效期、可主动失效的。openid 一旦泄露,别人拿去就能冒充这个用户;而且你无法让它"过期",也没法在用户换设备时强制下线。
所以第三步必须存在:服务端自己签发一个登录态凭证。这个凭证可以是随机串(不透明 token),也可以是 JWT,两者各有取舍,后面第 4 章会细说。要点是:这个凭证由你自己控制生命周期,能续期、能吊销、能和设备绑定。
把这三段串起来的完整时序大概是这样:小程序端tt.login()→ 拿到 code → POST 到你自己的/api/auth/login→ 后端调平台接口换openid和session_key→ 查库或注册用户 → 生成自定义 token →session_key存服务端(Redis,key 用 openid 或用户 id)→ 只把 token 返回前端。前端把 token 存起来,之后所有业务请求带在 header 里。
2. Java 侧工程落地:配置、HTTP 调用与错误分流
理论清楚之后,落到 Spring Boot 工程里,有几处细节决定这套东西是"能跑"还是"跑得稳"。
2.1 凭证配置:别把 appSecret 写进代码和日志
appId和appSecret属于最高等级的敏感配置。最低要求是走配置文件加环境变量,本地用application-dev.yml,线上走环境变量或配置中心,仓库里的文件只放占位符。
miniapp: app-id: ${MINIAPP_APP_ID:} app-secret: ${MINIAPP_APP_SECRET:} code2session-url: https://developer.toutiao.com/api/apps/v2/jscode2session phone-url: https://developer.toutiao.com/api/apps/v1/get_phone_number connect-timeout-ms: 2000 read-timeout-ms: 3000再配一个强类型绑定类,避免满项目@Value满天飞:
@Data @Component @ConfigurationProperties(prefix = "miniapp") public class MiniAppProperties { private String appId; private String appSecret; private String code2sessionUrl; private String phoneUrl; private int connectTimeoutMs = 2000; private int readTimeoutMs = 3000; }这里有一条容易被忽略的纪律:调用平台接口的请求体和响应体在打日志时,必须先把 secret、session_key、code 这几个字段脱敏。我见过排障时顺手把整个响应体log.info出来,session_key 就这么进了日志平台,后来做安全审计被点出来,全部日志还得清理一遍。宁可多写一行替换逻辑,也别图省事。
2.2 用 RestTemplate 还是 OkHttp:先看你的调用量级
登录接口的调用特征是:低频(每用户每次进小程序最多一次)、对延迟敏感(用户在等)、失败必须能区分原因。这个特征下,选型的关键不是性能,而是超时控制和错误可读性。
RestTemplate 胜在和 Spring 生态融合好、配置简单;OkHttp 胜在连接池和拦截器更灵活。登录这种量级,RestTemplate 完全够用,把超时显式设上就行。真正要避免的是用默认构造的 RestTemplate——它默认没有连接超时,平台接口一旦抖动,你的线程池会被慢慢占满,最后整个服务卡死,而登录接口是所有人进小程序的第一步,它挂了等于全站挂了。
@Configuration public class HttpConfig { @Bean public RestTemplate miniAppRestTemplate(MiniAppProperties props) { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(props.getConnectTimeoutMs()); factory.setReadTimeout(props.getReadTimeoutMs()); return new RestTemplate(factory); } }超时数值我给的建议是连接 2 秒、读取 3 秒。读取时间不要设得太短,平台接口偶尔会有 1 秒以上的响应,设 500 毫秒会造成大量假失败;也不要设太长,超过 5 秒用户已经退出小程序了,你还在等结果,这时候成功也没意义。
2.3 code 换 session 的实现与错误码分流
新版接口是 POST,请求体里放appid、secret、code。响应结构里有一个err_no和err_tips,成功时err_no为 0,业务数据在data里。
@Slf4j @Service @RequiredArgsConstructor public class MiniAppAuthClient { private final RestTemplate miniAppRestTemplate; private final MiniAppProperties props; public Code2SessionResult code2Session(String code) { Map<String, Object> body = new HashMap<>(); body.put("appid", props.getAppId()); body.put("secret", props.getAppSecret()); body.put("code", code); ResponseEntity<String> resp; try { resp = miniAppRestTemplate.postForEntity( props.getCode2sessionUrl(), body, String.class); } catch (ResourceAccessException e) { // 超时或连接失败,属于可重试类错误 log.warn("code2session 网络异常, code={}", mask(code), e); throw new LoginException(LoginError.PLATFORM_TIMEOUT); } JSONObject json = JSON.parseObject(resp.getBody()); int errNo = json.getIntValue("err_no"); if (errNo != 0) { log.warn("code2session 失败, errNo={}, tips={}", errNo, json.getString("err_tips")); throw new LoginException(mapErrNo(errNo)); } JSONObject data = json.getJSONObject("data"); Code2SessionResult result = new Code2SessionResult(); result.setOpenId(data.getString("openid")); result.setSessionKey(data.getString("session_key")); result.setUnionId(data.getString("unionid")); return result; } private LoginError mapErrNo(int errNo) { // 40029 等属于 code 无效;其余按平台侧错误处理 return errNo == 40029 ? LoginError.INVALID_CODE : LoginError.PLATFORM_ERROR; } }这段代码里有两个设计决定值得展开说。
第一个是把"网络异常"和"业务失败"分开。网络异常可以给前端一个"请重试"的提示,业务失败里的 code 无效则应该让前端重新调tt.login()。这两种情况用户看到的话术不同,前端处理逻辑也不同,全揉成 500 错误,前端只能瞎猜。
第二个是错误码宁可粗也要准。平台侧错误码会变,我不建议把每个码都硬编码成一个中文提示,那玩意儿维护成本极高。正确做法是分三类:code 无效类、参数配置类、平台异常类。前者让前端重登,中者告警给运维,后者打日志加兜底。剩下的细节靠日志和 traceId 去查。
2.4 收到 session_key 之后立刻做的事
拿到session_key后有两个动作要立刻完成,顺序不能乱。
一是查库或注册用户。用openid做唯一索引,先select,为空则insert,注意别用"先查再插"这种非原子操作,并发首次登录会撞出重复数据。稳妥写法是给openid加唯一索引,插入时捕获DuplicateKeyException,捕获到了就重新查一次。这个坑在新用户首次进入、小程序冷启动时特别容易触发,因为前端有时会并发发两次登录请求。
二是把 session_key 落到 Redis,key 设计成miniapp:sk:{openid},value 是会话密钥,过期时间设得比 session_key 实际有效期略短一点,比如 25 分钟。为什么要缩短?因为如果按"刚好"设,可能出现你这边还没过期、平台侧已经失效,解密必然失败的窗口期,缩短几十秒能大幅降低这种概率。
stringRedisTemplate.opsForValue() .set("miniapp:sk:" + openId, sessionKey, Duration.ofMinutes(25));3. 手机号与用户资料:两种授权路径怎么选
openid 登录只能解决"这个人是谁",能免登录、能存数据。但很多业务需要手机号(下单、客服回访),需要昵称头像(社区展示)。这部分是第二层授权,逻辑和第一层完全不同。
3.1 手机号快速验证:前端拿 code,后端换号码
现在主流方式是小程序端用按钮触发手机号授权,前端拿到一个code,把这个 code 传给后端,后端调接口换真实号码。这个流程的好处是手机号全程不经过前端,前端只能拿到一个临时凭证,安全性比旧方案高一个档次。
public String getPhoneNumber(String phoneCode) { Map<String, Object> body = new HashMap<>(); body.put("code", phoneCode); String url = props.getPhoneUrl() + "?appid=" + props.getAppId(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("X-Token", accessToken()); // 如果需要服务端 access_token ResponseEntity<String> resp = miniAppRestTemplate.postForEntity( url, new HttpEntity<>(body, headers), String.class); JSONObject json = JSON.parseObject(resp.getBody()); if (json.getIntValue("err_no") != 0) { throw new LoginException(LoginError.PHONE_FAILED); } return json.getJSONObject("data").getString("phone_number"); }这段代码里那个accessToken()是很多人第一次会漏掉的东西。手机号接口需要服务端凭证,这个凭证要通过 appid 和 secret 换取,有效期两小时,必须缓存复用。如果每次请求都去换一遍,一是浪费,二是并发时会触发平台的频率限制,直接把你限流。
缓存的写法要带"提前刷新":Redis 里存 token,过期时间设为官方有效期的 90% 左右,比如有效两小时就存 108 分钟,剩下 12 分钟作为缓冲。同时在取的时候加个互斥锁,避免缓存刚过期时几十个线程同时去刷新。这些细节不写,业务量一上来就会碰到。
3.2 旧的加密数据解密路径:AES 的四个参数一个都不能错
有些老项目或特殊场景还在用encryptedData+iv的方式拿手机号。这条路径需要后端用session_key做 AES-128-CBC 解密,参数四件套是:密文、密钥(session_key)、初始向量(iv)、填充方式(PKCS#7)。
public String decrypt(String encryptedData, String iv, String sessionKey) { try { byte[] keyBytes = Base64.getDecoder().decode(sessionKey); byte[] ivBytes = Base64.getDecoder().decode(iv); byte[] dataBytes = Base64.getDecoder().decode(encryptedData); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new IvParameterSpec(ivBytes)); return new String(cipher.doFinal(dataBytes), StandardCharsets.UTF_8); } catch (Exception e) { throw new LoginException(LoginError.DECRYPT_FAILED); } }Java 里写PKCS5Padding是对的,虽然标准名是 PKCS#7,但 JDK 对 AES 的 PKCS5 实现实际就是 PKCS#7,两者在这里等价,不用纠结。真正会让人崩溃的是解密失败的各种原因,我按踩坑频率排了个序:
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 解密后全是乱码 | session_key 与密文不是同一次会话产生的 | 重新走完整登录 |
| BadPaddingException | iv 传错或没做 Base64 解码 | 检查前端传参 |
| IllegalArgumentException: Invalid key | session_key 被 urlencode 过 | 先 urldecode 再解码 |
| 解密成功但号码为空 | 用户拒绝授权或字段名写错 | 看原始 JSON |
第二种和第三种是新手高频问题。session_key里可能含有+、/这类字符,如果中间经过一次 URL 传输又被错误处理,长度会变,AES 一算就报密钥长度不合法。我现在的习惯是:拿到 session_key 后先打日志确认长度(Base64 解码后应该是 16 字节),长度不对就不再往下试,直接判定为会话失效,让前端重新登录,省下大量排查时间。
还有一个隐蔽的坑:解密用的 session_key 必须是产生这份密文那一次登录的。如果用户在别的地方又登录了一次,平台下发了新的 session_key,旧的自然失效。而你的 Redis 里如果存的已经是新的,拿新钥匙去开旧锁,必然报错。这就是第 4 章要讲的并发问题。
3.3 昵称头像:别指望一次授权全拿到
昵称和头像的获取方式这些年变过好几轮,现在主流做法是让用户主动填写:头像用按钮open-type="chooseAvatar",昵称用input type="nickname"。前端把用户选择的头像临时路径和昵称文本传给你,你上传头像文件、存昵称。
这里的工程注意点是头像临时路径会失效。前端拿到的是本地临时文件路径,几个小时后就不能访问了。所以你不能只存路径字符串,必须在接收时立刻把文件传到自己的对象存储,存的是你自己的永久 URL。我见过项目直接存临时路径,上线第二天用户头像全变成空白,回头补数据脚本补了一晚上。
另外昵称要做长度和内容校验。用户可能输入极长字符串或特殊字符,直接入库会在某些展示位上出问题,也会给后续的搜索、导出埋雷。建议入库前统一做 trim、长度截断到 30 字符以内、过滤掉控制字符。
4. 登录态维护:token 设计与会话密钥的生命周期
登录做完只是开始,真正的维护成本在"这个登录态怎么活、怎么死、怎么续"。
4.1 随机 token 还是 JWT:先想清楚要不要"踢人下线"
这两个方案的差别不在技术上,而在业务上。
JWT 的好处是无状态,服务端不查库就能校验,扩展方便。坏处是没法主动作废——除非你维护一个黑名单,那就又变成有状态了。如果业务里有"封号立即生效""换设备强制下线""管理员踢人"这类需求,JWT 会让你做得很别扭。
随机 token(不透明串)的好处是每次都查 Redis,可控性拉满:想失效就删 key,想续期就改 TTL。坏处是多一次 Redis 查询,但这个开销在登录场景下可以忽略。
我的选择是:面向普通用户的业务系统优选随机 token。原因很实际——用户体系的运营动作太多了,封禁、合并、风控、异地登录提醒,每一样都需要即时生效,无状态凭证在这些场景下只会让你写一堆补丁。JWT 更适合服务间调用或者短生命周期的临时凭证。
具体落地:
public LoginResult login(String code) { Code2SessionResult sess = authClient.code2Session(code); User user = userRepository.findByOpenId(sess.getOpenId()) .orElseGet(() -> registerNewUser(sess.getOpenId())); String token = UUID.randomUUID().toString().replace("-", ""); // token -> userId,30 天 stringRedisTemplate.opsForValue().set( "auth:token:" + token, String.valueOf(user.getId()), Duration.ofDays(30)); // session_key 单独存,与 token 解耦 stringRedisTemplate.opsForValue().set( "miniapp:sk:" + sess.getOpenId(), sess.getSessionKey(), Duration.ofMinutes(25)); return new LoginResult(token, user.getId()); }注意我把 token 和 session_key 分开存,key 前缀也不同。原因是它们的生命周期完全不一样:token 可以活 30 天,session_key 只能活 25 分钟。如果硬塞到一个结构里,session_key 一过期就把 token 带走了,用户第二天进来还得重新登录,体验很差。
4.2 session_key 过期引发的连锁反应
session_key 是登录链路里最脆弱的一环。它的特点是:只有用户重新走一次登录才会刷新,而且有效期不由你控制。如果你的业务里有解密敏感数据的操作(手机号、加密的用户信息),就必须处理它过期的情况。
判断过期的信号很直接:解密抛异常。这时候不能简单返回"解密失败"给用户,那样用户一脸懵。正确的处理是返回一个明确的业务错误码,比如NEED_RELOGIN,前端捕获这个码之后重新调tt.login()拿 code,再请求一次登录接口,拿到新的 session_key 后重试刚才的操作。整个流程对用户应该是无感的——他只是在点手机号授权按钮时多等了半秒。
这里有个细节:重试一定要有次数上限。我见过写成死循环的代码,session_key 因为某些配置问题一直换不对,前端就一直重登,把接口打出一堆流量,日志刷爆。设个两次上限,第二次仍失败就报错并上报告警。
4.3 静默登录与 token 续期:让用户感受不到"登录"这件事
好的小程序登录体验是:用户从来不觉得"我在登录"。第一次进来静默换 openid 建档,之后每次进来靠 token 直接进业务页,token 快过期了自动续。
实现上分三层:
第一层,入口静默登录。小程序启动时检查本地 token,没有就静默走一遍登录流程,全程不弹任何 UI。这一步只拿 token,不请求任何授权,用户完全无感。
第二层,业务请求拦截续期。后端在 token 校验的拦截器里,如果发现这个 token 剩余有效期低于某个阈值(比如 7 天),就自动延长 TTL,并把新 token 通过响应头下发。这就是所谓的滑动过期。
Long userId = getUserIdFromToken(token); if (userId == null) { throw new UnauthorizedException(); } Long ttl = stringRedisTemplate.getExpire("auth:token:" + token, TimeUnit.SECONDS); if (ttl != null && ttl > 0 && ttl < 7 * 24 * 3600) { stringRedisTemplate.expire("auth:token:" + token, Duration.ofDays(30)); }第三层,敏感操作的二次确认。涉及手机号、支付、修改绑定信息这类操作,即使 token 有效,也不能只靠 token。要么重新走一次手机号授权,要么要求用户在近期内有过交互。这一层是安全边界,不能省。
滑动过期有个副作用要提前说:Redis 里会沉淀大量长期不活跃用户的 token。如果用户量做到几十万,这些 key 会吃掉不少内存。解决方式是定期扫一遍 TTL 剩余很长的 key 做抽样清理,或者干脆把续期上限封顶——比如 token 签发后最多续到 90 天,超过就强制重新登录一次。这个上限值按业务敏感度定,越敏感越短。
5. 我踩过的坑与完整排查链路
前面讲的都是"应该怎么做",这一章讲"做完了为什么还是不对"。下面三个问题我都在真实项目里遇到过,排查过程记录下来,比结论更有用。
5.1 一直报 code 无效:从请求到配置逐层剥离
现象:前端调tt.login()拿到了 code,后端调接口 100% 返回 code 无效,前端重试也没用。
我的排查顺序是这样的,一层一层往下砍:
第一步,确认 code 是新的。让前端在拿到 code 的瞬间执行console.log,同时后端记录收到的 code 前 8 位和后 8 位。两边对一下,如果后端收到的是上一个版本缓存的值,问题就在前端。这一步排掉了大约三成的案例。
第二步,确认 code 只被用过一次。在后端接口里数一下某个 code 被请求了几次。如果发现前端在页面onLoad和某个组件mounted里各调了一次登录,两次请求带着同一个 code,那第二次必然失败。解决方案不是让后端兼容,而是前端合并登录请求——用一个 Promise 做单例,正在登录中就直接复用同一个 Promise。
第三步,确认 appid 匹配。这一条最难查,因为表面上一切正常。小程序的开发版、体验版、正式版对应不同的 appid(有时是同一个主体下的不同应用),如果后端配置的 appid 跟当前跑的小程序版本不一致,code 一定换不出来。验证方法是:让前端在小程序里打印tt.getAccountInfoSync().miniProgram.appId,和后端配置里的值逐字符对比。我就在这上面浪费过大半天,最后发现是测试环境的配置被顺手改成了另一个应用的凭证。
第四步,确认 code 没被 URL 编码搞坏。code 里可能包含特殊字符,如果前端拼 URL 时没编码,或者后端接收时多做了一次解码,传过去的值就和原始值不一样了。这里最简单的验证是把后端发出的完整请求 URL 打出来看一眼。
这个排查顺序的价值在于:每一步都能独立证伪,不会陷入"到处改一点试试"的状态。线上问题最怕的就是没有排查路径,改一处重启一次,最后自己都不知道是哪一处改好的。
5.2 体验版能登录,正式版一直失败
这个现象的迷惑性极强,因为代码一模一样。原因基本都在配置层面。
最常见的三种:一是正式版没在小程序后台配置服务器域名白名单,请求直接被拦,前端只看到网络错误;二是正式版的 appSecret 和测试环境用的不是同一套,或者复制时多了一个空格;三是正式版的包用的是另一套环境变量,后端读到的 appid 是空的。
第一种的验证很直接:在正式版里看请求是否真的发出去了。如果前端显示请求失败但后端日志一条都没有,那就是域名白名单的问题。这个配置在开发阶段经常被临时关掉,上线前忘了补,属于典型的环境差异坑。
第二种多一个空格的坑我要单独说,因为它太隐蔽了。appSecret从后台复制时偶尔会带上首尾空格或换行符,配置文件里肉眼看不出来,调接口就是签名不对。我现在的习惯是在应用启动时打一行日志,输出 appId 和 secret 的长度(不输出值),长度对不上立刻就能发现。
5.3 并发登录把 session_key 冲掉了
这个坑最典型,也最值得讲,因为它涉及的是设计问题而不是配置问题。
现象:用户点手机号授权,偶尔失败;重试有时候好有时候不好;日志里解密报错,但把错误日志拉出来看,session_key 明明是刚换的。
原因是这样的:小程序有两个入口(比如首页和某个活动页),用户快速切换时,两处都触发了登录。第一次登录拿到 session_key A,写入 Redis;第二次登录拿到 session_key B,覆盖了 A。而用户上一次点击授权时,加密用的是 A。后端拿 B 去解 A 加密的数据,必然失败。
更麻烦的是,这类问题复现率很低,测试同学点十次可能只碰到一次,开发自测根本碰不到,只有真实用户快速操作时才会撞上。
我的解决方案是给 session_key 加上版本号,允许多份并存:
Redis 里不存单个值,存一个 hash:miniapp:sk:{openid},field 是会话版本(可以用时间戳或递增序号),value 是 session_key,整个 hash 设 30 分钟过期。登录时写入新版本,前端拿到 token 的同时也拿到当前版本号,后续解密请求带上版本号,后端按版本取对应的 key。
如果不想改动这么大,退一步的方案是:解密失败时不立刻报错,先用 Redis 里当前的 session_key 试一次,失败再走完整重登流程。这个方案简单,代价是多一次接口调用,但能在绝大部分情况下自愈。
真正不能接受的方案是"解密失败就告诉用户操作失败"。用户完全不知道自己该干什么,只会觉得这个功能时好时坏。
5.4 一个容易被忽略的细节:openid 与 user_id 的映射要双向可查
最后一个坑偏架构。项目初期很多人直接用 openid 当用户主键,图省事。到了后期要做多端打通(比如再上一个 App 或公众号),发现 openid 有多个,主键不能变了,只能加映射表,迁移成本很高。
我的做法从第一个项目起就固定:内部用户表用自增 id 或雪花 id 做主键,openid 作为唯一索引存在关联表里。这样以后接入第二个渠道,只是多加一行关联记录,主键不变,所有业务表的外键都不用动。多花半小时建一张表,能省掉后面几天的数据迁移。
6. 上线前值得过一遍的自检项
代码写完、联调通过,不代表可以上线。登录这条链路是整个小程序的入口,它出问题等于所有功能都进不去,所以上线前我会固定过一遍下面这些项。
配置侧:产品环境的 appId、appSecret 是否确实从对应应用的后台取;服务器域名白名单是否配齐,包括业务接口域名和文件上传域名;HTTPS 证书是否在有效期内且链完整(有些工具会自动补中间证书,真机可能不认)。
接口侧:所有对外接口是否都有超时设置;平台接口调用是否都有重试且重试次数有上限;token 校验拦截器是否排除了登录接口本身(不然永远进不去);错误响应里是否泄露了内部异常栈。
数据侧:openid 是否建了唯一索引;用户表是否有创建时间和最后登录时间的默认值;session_key 是否只存在服务端且有过期时间;日志里是否还有明文的 secret 或 session_key。
体验侧:首次进入是否无感;token 过期后重新进入是否自动续上;手机号授权被拒绝时是否有友好提示且能再次触发;弱网下点击授权按钮是否有 loading 且不会重复提交。
监控侧:登录成功率和失败率是否分开统计;code 无效、平台超时、解密失败这三个错误码是否有独立告警;登录接口的 P99 耗时是否在可接受范围。
最后分享一个我在实际项目里坚持的小习惯:给每个登录请求生成一个 traceId,透传到下游接口,日志里全部带上。登录问题九成发生在"前端说失败了但后端说成功了"这种扯皮场景里,有 traceId 就能把一次完整请求的所有环节串起来看,比来回问"你几点点的"高效太多。这套东西搭起来不复杂,一个 Filter 加 MDC,十几行代码,但排障时的收益是实打实的。
另外提一个后续可以扩展的方向:如果业务量继续涨,登录接口会成为全站最热的接口之一。这时候可以在 openid 查询用户那一步加一层本地缓存(Caffeine 之类),把数据库压力削掉一大截,同时注意用户信息变更时主动失效缓存。不过这属于优化范畴,先把链路做对、做稳,再谈快,顺序别搞反。