简介:面向需要实现Web端微信扫码登录的Java开发者,这套SpringBoot项目基于微信开放平台,完整串起二维码获取、授权回调接收code、换取access_token与openid、拉取微信用户资料、本地账号匹配或新建,以及JWT无状态鉴权等关键环节,并涉及Spring Security认证、随机状态码校验、回调URL配置与HTTPS传输安全,适合用作课程设计、毕业项目或企业登录模块的参考实现。压缩包共121个文件,约172KB,以16个class、15个java源码和4个properties、4个xml配置为主,另含少量jar、说明及工程文件,便于导入IDE后按包结构梳理流程。已有11405人学习下载,可帮助读者理解扫码登录全链路、前后端集成方式与常见安全注意点。
1. 后台系统还在发账号密码,不如换个入口
上周帮朋友看一个内部运营后台,登录页还挂着「账号 + 密码 + 图形验证码」三件套,运营同事每季度改一次密码,就有小一半人来问怎么重置。这种场景其实特别适合把登录入口换成微信扫码登录:用户不用记密码,管理员也不用维护一套密码策略。这个基于 SpringBoot 的项目干的就是这件事——对接微信开放平台的网站应用扫码登录,前端拉起二维码,用户手机扫一下、点确认,浏览器带着 code 回到后端,后端换到 openid,再落到自己的用户表里签发本地会话。
它适合正在做后台管理、内部工具、内容站点登录的开发者,尤其是团队里没人愿意逐字啃开放平台文档的那种情况。整套链路代码量不大,一个 Controller 加一个 Service 就撑起来了,但 state 校验、回调域名、code 一次性这几个细节,足够让人在联调时耗掉一整个下午。
2. 项目全景与技术选型:为什么是 SpringBoot 接 OAuth2 回调
扫码登录本质上是标准 OAuth2 授权码模式的一个变体:浏览器跳到授权页,用户确认,平台把授权码回传给业务方,业务方拿码换票据。SpringBoot 在这套流程里承担的角色很明确——提供一个能公网访问的 HTTP 端点接收回调,再封装对外部 API 的调用和本地会话的签发。选它不是因为时髦,而是这类项目天然是「少量接口 + 少量配置 + 一个数据表」的形态,用 SpringBoot 起步最快,打包成一个 jar 扔到服务器上就能跑,不需要额外容器。
真正需要想清楚的是边界划分。开放平台只负责告诉你「这个人是谁」(openid / unionid),它不管你系统里的角色、权限、菜单。所以项目里一定会有两套身份:一套是微信侧的唯一标识,一套是本地用户表的主键。中间的映射关系才是二次开发时改动最多的地方,后面第 6 章会专门讲。
2.1 目录结构与模块职责
一份结构清晰的分包,比任何文档都管用。下面这套目录是按「配置、入口、业务、存储」四层切的,规模再翻一倍也不会乱:
wx-login/ ├── pom.xml ├── src/main/java/com/example/wxlogin/ │ ├── WxLoginApplication.java 启动类 │ ├── config/ │ │ ├── WxOpenProperties.java 开放平台参数绑定(appId/secret/redirectUri) │ │ ├── RestTemplateConfig.java 带超时的 HTTP 客户端 │ │ └── WebMvcConfig.java 跨域与拦截器注册 │ ├── controller/ │ │ ├── WxLoginController.java 二维码地址、回调、登出 │ │ └── UserController.java 获取当前登录用户信息 │ ├── service/ │ │ ├── WxOpenService.java 外部接口调用(换 token、拉用户信息) │ │ └── UserService.java 本地用户落库与查询 │ ├── entity/ │ │ └── SysUser.java 本地用户表实体 │ ├── dto/ │ │ ├── WxTokenResult.java 换票接口的返回结构 │ │ └── WxUserInfo.java 用户信息接口的返回结构 │ └── common/ │ ├── R.java 统一返回体 │ ├── BizException.java 业务异常 │ └── TokenStore.java 本地 token 与用户的映射 └── src/main/resources/ ├── application.yml 参数配置 ├── static/login.html 最小演示页 └── db/schema.sql 建表脚本config包只做参数绑定和基础组件,不放业务逻辑;service里的WxOpenService是对外的唯一出口,所有 HTTP 调用都收在这里,将来换 HTTP 客户端或者加埋点只改一个文件;common/TokenStore是我比较坚持的一层——把「本地会话」这件事单独拎出来,后面想从 Redis 换成 JWT 或者数据库,调用方完全无感。这种切法在只有几百行的项目里看起来有点小题大做,但扫码登录的坑基本都集中在回调这一端,边界清楚,排查时才好定位。
2.2 技术栈与依赖清单
项目本身的依赖很少,核心就四个:Web、参数校验、一个持久层、一个缓存。下面这张表是我在几个类似项目里反复用到的最小集合:
| 组件 | 用途 | 备注 |
|---|---|---|
| Spring Boot Web | 提供回调端点、静态页 | 2.7.x 或 3.x 都能跑 |
| Validation | 校验回调入参 | 防止 code 为空直接进 Service |
| MyBatis-Plus | 用户表读写 | 换成 JPA 也只是改一行 |
| spring-boot-starter-data-redis | 存 state 和本地 token | 单机也可以用内存 Map 顶替 |
| Lombok | 少写 getter/setter | 团队不喜欢可以不引 |
| MySQL Connector | 数据库驱动 | 建表脚本在 resources/db 下 |
对应的pom.xml关键片段:
<dependencies> <!-- Web 与参数校验,回调接口必须用得上 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- state 与本地 token 都放 Redis,天然带过期时间 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 用户表持久层,代码里用的是 MyBatis-Plus 的 BaseMapper --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>依赖说明里有两个取舍值得说。第一,没引 HTTP 工具库,直接用 Spring 自带的RestTemplate,少一个坐标就少一次版本冲突;第二,state 和本地 token 都放 Redis,不是因为性能,而是因为「过期」这件事必须交给存储层做——用HashMap存 state 的话,用户扫码停留超过十分钟,脏数据会一直躺在内存里,时间长了就是一颗定时炸弹。单机本地开发如果不想起 Redis,可以写一个带ScheduledExecutorService清理的内存实现,接口保持一致就行。
2.3 三段式时序:二维码、回调、换票据
整个流程拆开只有三段,理解这三段,后面所有代码都能自己推出来。
第一段是浏览器向业务后端要二维码地址。后端不是自己画二维码,而是拼一个带appid、redirect_uri、scope=snsapi_login、state的授权链接返回给前端,前端把它塞进 iframe 或者用前端库渲染成二维码图片。这一步的关键是state——它是一个后端生成、后端留存的一次性随机串,用来在回调时确认「这次扫码确实是从我这儿发起的」。
第二段是用户手机扫码确认后,平台把浏览器重定向回redirect_uri,并在 URL 上带code和state。注意这里的跳转发生在用户的浏览器里,不是服务器的回调,所以后端接口要处理的是「接收 GET 参数,然后 302 跳到前端页面」,而不是返回 JSON。
第三段是后端拿code去换access_token和openid。这个code只能用一次,有效期也很短,所以这一步必须同步做完,失败了只能让用户重新扫。换到openid之后,项目会去用户表里查有没有绑定记录,有就直接签发本地 token,没有就走注册或引导绑定,最后重定向回前端并带上 token。
3. 核心链路拆解:一次扫码从拼链接到拿 openid
这一段是项目的心脏,代码量不大,但每一行都有对应的坑。下面按调用顺序把三个关键方法拆开讲。
3.1 拼授权地址与 state 的一次性留档
先看参数绑定类,把开放平台的三个关键配置收进一个对象,避免散落在代码各处:
@Data @Component @ConfigurationProperties(prefix = "wx.open") public class WxOpenProperties { /** 网站应用 AppID,开放平台后台可查 */ private String appId; /** 网站应用 AppSecret,泄露等于把登录入口送人,务必放配置中心或环境变量 */ private String appSecret; /** 授权回调地址,必须与开放平台后台填写的完全一致,含协议和路径 */ private String redirectUri; /** 前端登录成功后的落地页,用于回调后 302 跳转 */ private String frontendUrl; }三个字段里最容易出错的是redirectUri。开放平台对它的校验是字符串级的完全匹配,协议、域名、端口、路径、末尾斜杠,差一个字符都过不去。很多人习惯在代码里拼接参数,比如redirectUri + "?from=web",这就直接导致后面的redirect_uri参数校验失败。正确做法是拼好之后整体做一次 URL 编码,redirectUri本身不带任何 query。
下面是生成二维码地址的方法:
@Service public class WxOpenServiceImpl implements WxOpenService { private static final String STATE_PREFIX = "wx:state:"; private static final String QR_CONNECT = "https://open.weixin.qq.com/connect/qrconnect"; private final WxOpenProperties props; private final StringRedisTemplate redis; public WxOpenServiceImpl(WxOpenProperties props, StringRedisTemplate redis) { this.props = props; this.redis = redis; } @Override public String buildQrConnectUrl(String scene) { // state 用 UUID 去掉横线,足够随机且不带特殊字符,避免 URL 编码歧义 String state = UUID.randomUUID().toString().replace("-", ""); // 服务端留档 5 分钟,值里带上业务场景,回调时能知道用户从哪个入口进来的 redis.opsForValue().set(STATE_PREFIX + state, scene == null ? "default" : scene, Duration.ofMinutes(5)); // redirect_uri 必须整体编码,且只能出现一次,不要在里面再拼参数 String encodedRedirect = URLEncoder.encode(props.getRedirectUri(), StandardCharsets.UTF_8); return QR_CONNECT + "?appid=" + props.getAppId() + "&redirect_uri=" + encodedRedirect + "&response_type=code" + "&scope=snsapi_login" + "&state=" + state + "#wechat_redirect"; } }逐项说清楚。state用 UUID 是图省事,它的作用是防 CSRF 和防重放,只要不可预测就行;存 Redis 时把scene一起写进去,是为了回调后知道这次登录是从 PC 首页还是从某个活动页发起的,这个小设计在后期做渠道统计时非常好用。scope=snsapi_login是网站应用扫码的固定值,写错了二维码直接出不来。末尾的#wechat_redirect不能省,它是平台要求的锚点标记,缺了之后部分环境下回调行为会变得很怪。
Duration.ofMinutes(5)这个过期时间需要权衡。设太短,用户扫码时犹豫一下回来就失效了;设太长,脏数据堆积。五分钟是我试过比较稳的区间,用户在二维码页面上停留超过五分钟的概率很低,真超时了重新点一下刷新也符合直觉。
最后强调一遍URLEncoder.encode:它对空格会编成+而不是%20,如果redirectUri里真的带空格(基本不会),要小心。正常的域名路径不会有事。
3.2 回调接口:接收 code 并换出 openid
回调接口是整个项目里唯一必须公网可达的端点,它接收两个 query 参数,做完业务后 302 回前端:
@RestController @RequestMapping("/api/wx") public class WxLoginController { private final WxOpenService wxOpenService; private final UserService userService; private final TokenStore tokenStore; private final WxOpenProperties props; @GetMapping("/callback") public void callback(@RequestParam(value = "code", required = false) String code, @RequestParam(value = "state", required = false) String state, HttpServletResponse response) throws IOException { // 用户在手机上点了取消,平台不会带回 code,这里要有个兜底跳转 if (!StringUtils.hasText(code)) { response.sendRedirect(props.getFrontendUrl() + "?login=fail&reason=cancel"); return; } // 校验 state:既不相等也不存在,一律拒绝,不给重放留口子 String sceneKey = "wx:state:" + state; String scene = wxOpenService.consumeState(state); if (scene == null) { response.sendRedirect(props.getFrontendUrl() + "?login=fail&reason=state"); return; } // 换票:code 一次性,这一步失败只能让用户重新扫码 WxTokenResult token = wxOpenService.exchangeCode(code); // 落库或绑定,返回本地用户 ID Long userId = userService.loginByOpenId(token.getOpenId(), token.getUnionId(), scene); // 签发本地会话 token,前端后续请求带它 String localToken = tokenStore.issue(userId); // 注意这里是重定向,不是返回 JSON,因为发起方是用户的浏览器 response.sendRedirect(props.getFrontendUrl() + "?token=" + localToken); } }逻辑上有三处必须解释清楚。
第一处是「用户取消」的分支。很多实现只判断code非空,但用户点了取消按钮时,平台回调是带state不带code的,接口会直接抛 400。加上required = false和空判断,体验会好很多,前端也能根据reason=cancel显示一句「已取消登录」。
第二处是consumeState的语义。这个方法的实现里是先get再delete,两步都做完才算校验通过。为什么要删?因为state是一次性的,如果只校验不删,同一个 state 在五分钟内可以被反复使用,防重放就形同虚设了。这个细节属于典型的「不看源码想不到,看了源码一身冷汗」。
第三处是重定向而不是返回 JSON。回调是由用户浏览器发起的顶层导航,如果这里返回一段 JSON,用户看到的就是一屏原始数据。所有失败分支都要sendRedirect到前端页面,带上错误码,由前端决定怎么展示。
再往下就是真正的对外调用。换票接口返回的结构大概是access_token、expires_in、refresh_token、openid、scope、unionid,用哪个字段取决于你后续要不要做多端打通:
@Override public WxTokenResult exchangeCode(String code) { String url = "https://api.weixin.qq.com/sns/oauth2/access_token" + "?appid=" + props.getAppId() + "&secret=" + props.getAppSecret() + "&code=" + code + "&grant_type=authorization_code"; // 微信接口返回的 Content-Type 是 text/plain,直接映射对象会抛异常 String body = restTemplate.getForObject(url, String.class); JSONObject json = JSONUtil.parseObj(body); // 出错时返回体里是 errcode + errmsg,没有 access_token if (json.containsKey("errcode") && json.getInt("errcode") != 0) { throw new BizException("换取票据失败:" + json.getStr("errcode") + " - " + json.getStr("errmsg")); } WxTokenResult result = new WxTokenResult(); result.setOpenId(json.getStr("openid")); result.setUnionId(json.getStr("unionid")); result.setAccessToken(json.getStr("access_token")); result.setExpiresIn(json.getInt("expires_in")); return result; }参数说明:grant_type=authorization_code是固定值;code直接拼在 URL 上,因为它只包含字母数字和少数符号,不需要额外编码,但如果你的代码在别处对它做过一次编码,这里就会报invalid code,这是排查时的高频误区。restTemplate.getForObject(url, String.class)拿字符串再手动解析,是这套接口最稳的写法,具体原因放在第 5 章展开。
3.3 本地用户落库与 token 签发
拿到openid之后,本地这一侧要做的事是「查不到就建,查到就更新」,然后把用户身份换成本地 token:
@Service public class UserServiceImpl implements UserService { private final SysUserMapper userMapper; @Override @Transactional(rollbackFor = Exception.class) public Long loginByOpenId(String openId, String unionId, String scene) { SysUser user = userMapper.selectOne( new LambdaQueryWrapper<SysUser>().eq(SysUser::getOpenId, openId)); if (user != null) { // 老用户:只更新最近登录时间和 unionid,避免覆盖用户自己改过的资料 SysUser update = new SysUser(); update.setId(user.getId()); update.setLastLoginAt(LocalDateTime.now()); if (unionId != null && user.getUnionId() == null) { update.setUnionId(unionId); } userMapper.updateById(update); return user.getId(); } // 新用户:默认给一个游客角色,真正的权限由后台授予,不能一上来就给管理员 SysUser fresh = new SysUser(); fresh.setOpenId(openId); fresh.setUnionId(unionId); fresh.setNickname("wx_" + openId.substring(Math.max(0, openId.length() - 6))); fresh.setStatus(1); fresh.setSource(scene); fresh.setCreatedAt(LocalDateTime.now()); fresh.setLastLoginAt(LocalDateTime.now()); userMapper.insert(fresh); return fresh.getId(); } }建表脚本对应如下,open_id上加唯一索引是关键:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, open_id VARCHAR(64) NOT NULL COMMENT '微信用户在当前应用下的唯一标识', union_id VARCHAR(64) NULL COMMENT '开放平台账号唯一标识,多应用打通时用', nickname VARCHAR(64) NULL, avatar VARCHAR(255) NULL, status TINYINT DEFAULT 1 COMMENT '1 正常 0 禁用', source VARCHAR(32) NULL COMMENT '登录来源场景', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login_at DATETIME NULL, UNIQUE KEY uk_open_id (open_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;唯一索引不只是为了查询快,更是并发下的兜底。用户连点两次二维码,两次回调几乎同时到达,两个线程都查不到记录,都去 insert,没有唯一索引就会产生两条脏数据。加上索引之后,后到的那个会抛主键冲突异常,捕获后转成「再查一次」即可。这种做法看起来不够优雅,但在扫码回调这种并发不高、又不能加分布式锁的场景里,是最省事的方案。
TokenStore的实现保持极简:
@Component public class TokenStore { private static final String TOKEN_PREFIX = "wx:token:"; private final StringRedisTemplate redis; /** 签发本地 token,两小时过期,前端放 header 里带回来 */ public String issue(Long userId) { String token = UUID.randomUUID().toString().replace("-", ""); redis.opsForValue().set(TOKEN_PREFIX + token, String.valueOf(userId), Duration.ofHours(2)); return token; } /** 解析 token,返回用户 ID,无效或过期返回 null */ public Long resolve(String token) { if (token == null) { return null; } String userId = redis.opsForValue().get(TOKEN_PREFIX + token); return userId == null ? null : Long.valueOf(userId); } }为什么不直接下发 JWT?因为扫码登录的会话天然适合「可主动失效」,运营把某个账号禁用之后,希望下一次请求就失效,用 Redis 存 token 能直接删 key,JWT 还得再维护一个黑名单。在这个规模下,Redis 方案的运维成本反而更低。
4. 跑起来:本地联调配置与最小闭环验证
代码看懂了,下一步是让它在自己机器上转起来。这一步的难点不在 Java 代码,而在开放平台侧的配置和本地回调地址怎么打通。
4.1 开放平台侧的准备清单
动手之前,先在开放平台上有几件事必须完成,顺序不能乱:
第一,创建「网站应用」,不是公众号、不是小程序、不是移动应用。类型选错,后面拿到的appid不支持snsapi_login,二维码根本出不来。创建时会要求填应用名称、图标、官网地址,这些按实际情况填,图标尺寸有要求,不符合会打回。
第二,等待审核通过。这一步是纯人工,通常需要一到三个工作日,没有加急通道。很多人在本地写完代码、发现二维码拉起空白,排查半天最后才意识到应用还在审核中,这是最常见的无效调试。
第三,在应用详情里配置「授权回调域」。这里填的是域名,不带协议、不带路径、不带端口,例如login.example.com。审核通过后这个字段才能改,改完有一定生效延迟。
第四,记下AppID和AppSecret。AppSecret只显示一次,丢了只能重置,重置后旧的值立即失效,线上服务会瞬间登录失败。我的习惯是拿到之后直接写进配置中心,代码仓库里只留占位符。
提示:
AppSecret绝对不能提交到公开仓库,也不能打进前端包。它一旦泄露,任何人都能冒充你的应用换取用户标识。
4.2 application.yml 与配置类
配置项集中写在一个地方,方便环境切换:
server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/wx_login?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD:123456} redis: host: 127.0.0.1 port: 6379 database: 0 wx: open: app-id: ${WX_APP_ID:wxxxxxxxxxxxxxxxxx} app-secret: ${WX_APP_SECRET:请通过环境变量注入} # 必须与开放平台后台填写的授权回调域严格对应 redirect-uri: https://login.example.com/api/wx/callback # 登录完成后前端落地页,token 会拼在 query 上 frontend-url: https://login.example.com/static/login.html环境变量优先的写法不是摆样子。开发、测试、生产三套环境的appid和回调地址都不同,硬编码进 yml 之后每次发版都要改文件,改错一次就是一次线上事故。
配置类已经在第 3 章给过了,这里补充RestTemplate的超时设置,这个细节能避免回调接口被拖死:
@Configuration public class RestTemplateConfig { @Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); // 连接超时 3 秒,读超时 5 秒,外部接口卡住时快速失败 factory.setConnectTimeout(3000); factory.setReadTimeout(5000); return new RestTemplate(factory); } }默认的RestTemplate没有超时,用的是操作系统的默认值,可能是几十秒。开放平台偶发慢响应时,回调接口会一直挂着,用户的浏览器转圈,最后看到 502。加上显式超时之后,最坏情况是五秒后失败,用户可以立刻重试。
4.3 启动验证与最小闭环
启动顺序建议是:先起 MySQL 和 Redis,执行建表脚本,再mvn spring-boot:run。启动日志里看到 Tomcat 监听 8080 就说明基本环境没问题。
验证分三层,逐层往上走,不要跳步。
第一层,本地验证不依赖微信的接口。直接curl一下二维码地址接口:
curl "http://127.0.0.1:8080/api/wx/qrcode?scene=pc"返回的应该是一段https://open.weixin.qq.com/connect/qrconnect?...链接,复制到浏览器打开,如果页面能显示一个二维码(哪怕提示「应用未上线」),说明appid和链接拼接是对的。如果打开是一片空白或者直接报错,问题就在参数上,去看 URL 里appid、redirect_uri有没有明显异常。
第二层,验证回调。这一步需要把本地服务暴露到一个公网可达的域名上,否则平台回调不进来。常见做法是用一个带域名的映射工具把本地 8080 端口映射出去,得到一个临时域名,然后把这个域名对应的路径填到开放平台的回调域里。注意回调域只填域名部分,路径在代码的redirect-uri里控制,两边要能对上。
第三层,完整走一遍扫码。手机扫码、确认、浏览器跳回、落到login.html、页面拿到 token 后请求/api/user/me能返回用户信息,整条链路就通了。这一步不通的时候,先看回调接口有没有收到请求——在callback方法第一行打一条日志,日志里没有记录,说明请求压根没到服务器,问题在开放平台配置或网络;有记录但报错,问题在代码。
5. 避坑与排查:扫码登录最容易翻车的五个点
这套流程我前后搭过三次,每次都能踩出新花样。下面五条是按出现频率排的,每条都按「现象、原因、解决」写清楚。
5.1 回调报 10003 或 redirect_uri 参数错误
现象:浏览器打开授权页,页面直接显示一段错误提示,提示redirect_uri参数错误,或者扫码后不跳回业务站点。
原因基本只有三种。一是redirect_uri没有做 URL 编码,URL 里的:和/被当成参数分隔符截断;二是编码做了两次,%3A变成了%253A,平台解析出来的地址和你填的完全不一样;三是代码里的地址和开放平台后台配置的授权回调域不匹配,比如代码写的是https://login.example.com/api/wx/callback,后台只配了login.example.com,这本身没问题,但如果代码里写的是http而不是https,就会失败。
解决方式很机械:把最终生成的授权链接原样打印出来,人工核对其中的redirect_uri解码后是不是和预期完全一致。这一行日志能省掉至少半小时。
5.2 第二次请求报 invalid code 或 code been used
现象:扫码后回调接口第一次成功,用户刷新页面或者前端重复请求,接口报错,用户会看到登录失败。
原因是code严格一次性,用掉即失效,有效期也只有五分钟。触发场景通常是回调接口被多次调用,比如前端做了重试、用户手动刷新了回调 URL、或者浏览器预加载。
解决思路是把「回调」和「登录状态」分开。回调接口只负责用 code 换一次身份,成功之后立刻 302 到前端并带上 token,后续所有请求都走 token,不再碰 code。另外在回调方法入口加一层幂等判断:如果请求里已经带了有效 token,直接跳转,不再消费 code。
5.3 换票接口抛异常,提示无法解析响应
现象:RestTemplate调用换票接口时抛RestClientException,或者返回的对象字段全是 null。
原因是这个接口的响应头是Content-Type: text/plain,而RestTemplate默认按 JSON 处理,找不到合适的HttpMessageConverter就抛异常。这个坑非常隐蔽,因为浏览器里直接打开同样的 URL,看到的是完美的一段 JSON。
解决办法就是把返回值声明成String.class,拿到字符串之后自己用 JSON 库解析,第 3 章给出的代码就是这个写法。同理,任何对接外部平台接口的地方,只要对方文档没明确写application/json,一律先拿字符串。
5.4 state 校验失败,日志里查不到对应的 key
现象:回调进来之后,consumeState返回 null,用户被重定向到失败页,但二维码明明刚生成。
原因有三个方向。第一,state 的过期时间设得太短,用户扫码加确认耗时就超了;第二,服务部署了多个实例,生成 state 的实例和接收回调的实例不是同一台,如果 state 存在本地内存里就会查不到;第三,用户在同一个浏览器里打开了多个登录页,后生成的 state 覆盖了前一个的上下文。
第二个原因最容易被忽略,也最值得警惕。state 必须存在一个所有实例都能访问的地方,Redis 就是干这个的。如果是本地开发单机跑,用内存 Map 没问题,但一定要在代码里注释清楚,避免上线时忘了换。
5.5 手机扫码提示链接无法访问
现象:二维码能出来,手机扫了之后提示「该链接无法访问」或者一直白屏。
根因是二维码里编码的地址是电脑能访问、手机访问不了的地址。比如本地开发时没有配置公网映射,二维码里带的是127.0.0.1,手机自然打不开;或者映射工具给的临时域名需要额外确认页,手机端体验就会很怪。
处理方式是在生成二维码之前就把回调地址确定成公网可访问的域名,本地开发阶段也不要图省事用localhost。实在需要本地调试,就把redirect-uri直接指向映射出来的公网地址,一次性配好,别在代码里做环境判断。
6. 二次开发:把扫码登录接进自己的账号体系
项目跑通之后,绝大多数人要做的第一件事不是优化性能,而是改「新用户从哪来、老用户怎么认」。原始实现是静默注册,扫一下码就自动建号,这在内部工具里没问题,但放到有既有账号体系的系统里就不合适了。
6.1 三种绑定策略的取舍
| 策略 | 适用场景 | 实现要点 | 副作用 |
|---|---|---|---|
| 静默注册 | 内部工具、新系统 | 查到就登录,查不到直接建号 | 用户表会被一次性账号撑大 |
| 强制绑定已有账号 | 已有账号体系的存量系统 | 未绑定跳绑定页,验证手机号或原密码后写入 open_id | 多一步交互,转化率下降 |
| unionid 多端打通 | 同时有公众号、小程序、网站 | 以 unionid 为主键,openid 做附属 | 需要各端都绑定到同一开放平台账号 |
选哪种不看技术难度,看业务。内部后台用静默注册最省事;面向 C 端的存量系统,强制绑定更安全,因为账号里可能有订单、余额这类资产;多端产品必须用 unionid,否则同一个用户在网页端和小程序端会是两个人。
6.2 用策略模式把登录逻辑抽出来
改造的核心是把loginByOpenId里那段 if-else 抽成接口,新增策略时不用动现有代码:
public interface LoginStrategy { /** 返回本地用户 ID;返回 null 表示需要引导用户去绑定页 */ Long handle(WxTokenResult token, String scene); } /** 静默注册:查不到就建号,适合内部系统 */ @Component public class AutoRegisterStrategy implements LoginStrategy { @Override public Long handle(WxTokenResult token, String scene) { // 这里复用第 3 章的落库逻辑,open_id 唯一索引兜底并发 return userService.loginByOpenId(token.getOpenId(), token.getUnionId(), scene); } } /** 强制绑定:没绑定过就返回 null,由上层重定向到绑定页 */ @Component public class BindRequiredStrategy implements LoginStrategy { @Override public Long handle(WxTokenResult token, String scene) { SysUser user = userService.findByOpenId(token.getOpenId()); if (user == null) { // 把 openid 暂存到 Redis,绑定页提交时再取出来 redis.opsForValue().set("wx:pending:" + token.getOpenId(), "1", Duration.ofMinutes(10)); return null; } return user.getId(); } }改动之后,Controller 里只需要按配置选一个实现,@ConditionalOnProperty或者一个简单的Map<String, LoginStrategy>都行。这里的关键设计是「返回 null 表示需要绑定」这个约定,它让上层逻辑不用关心具体策略,只判断结果即可。
验证方式建议用一组固定用例,改完就跑一遍:新 openid 第一次登录、同一 openid 第二次登录、绑定后再扫码、用户在手机端取消、state 过期后回调。这五个用例覆盖了绝大多数改坏的地方,尤其是「取消」这一条,很多人改完策略就忘了兜底分支,线上直接 500。
改造时还有一个容易被忽略的点:sys_user表加字段要加默认值,老数据迁移脚本要么一次性刷成NULL,要么在代码里做兼容判断。我曾经在一次迁移里漏了这一步,查询用了eq(open_id, xxx),结果历史用户全部查不到,被当成新用户重新注册了一遍,用户表里出现大量重复账号,善后花了整个晚上。
从那以后我每次改登录链路,都强制先用五个用例在测试环境跑一遍,再去碰线上配置,宁可慢半天,也不想再写一次数据订正脚本。希望这些踩过的点能帮你少走几步弯路。
本文还有配套的精品资源,点击获取