简介:这份基于 Spring Boot 与微信开放平台实现的 Web 端扫码登录完整工程,面向需要集成微信登录能力的 Java 开发者及 Spring Boot 初学者,用于解决第三方授权登录的接入与实现问题。工程覆盖 OAuth 2.0 授权流程、微信开放平台参数配置、带状态码二维码生成,以及通过微信 SDK 换取访问令牌与用户标识、获取微信用户信息,并整合 Spring Security 完成认证与 JWT 令牌签发,形成可复用的端到端登录闭环。资源共 121 个文件,含 15 个 Java 源文件、16 个编译后的 class 文件、4 个 properties 配置、4 个 XML 配置及 jar、Maven 相关文件,压缩包整体仅 172KB,目录清晰,便于导入开发环境直接阅读。目前该项目已有 11382 人次学习,代码紧凑但流程完整,有助于理解扫码状态校验、回调地址配置、无状态登录等关键细节,可作为生产项目集成微信扫码登录时的设计参考。
1. 这个项目解决什么问题:从扫码到登录态,中间隔着三层坑
微信扫码登录在 web 端最常见的困境不是写不出来,而是做出来之后“能用,但说不清为什么灵不灵”。很多团队第一个想法是找别人封装好的开源登录组件,可一旦遇到回调域名不对、AppID 混用、code 只能消费一次这类问题,黑匣子式的封装反而会把人卡死。这个项目说的是用 Spring Boot 直接对接微信开放平台的网站应用扫码能力:后端生成授权二维码地址、接收回调、用 code 换 access_token、拉用户信息,再通过轮询或重定向把登录态交还给 web 前端。核心结论可以先放出来:整个流程代码量不大,难在把“授权 URL → 微信回调 → code 换 token → 用户态写回”这条链路里每一步的凭证时效和域名配置做对。这篇适合后端是 Spring Boot、前端想快速接入扫码登录的团队,也适合想把手里的第三方登录依赖换回官方直连的同学。
2. 接入微信开放平台前的准备:两套账号体系与回调域名
2.1 开放平台和公众平台不是一回事,AppID 不能混用
微信生态里常见的开发者后台有好几个,最容易被弄混的是微信开放平台和微信公众平台。开放平台面向的是网站应用、移动应用、小程序这类“独立应用”,提供网站应用扫码登录、移动应用微信登录、分享到微信等能力;公众平台面向的是公众号和普通小程序,提供的是网页授权、JS-SDK 这类能力。两个后台各自生成一套 AppID 和 AppSecret,它们之间完全不通用。
很多接入失败案例的根源就是拿公众号后台的 AppID 去拼开放平台的扫码登录地址。现象是网页上二维码能显示,但手机扫码后要么跳到“请在微信外打开”的提示,要么打开一个公众号关注页,根本不是登录授权页。排错方法很简单:扫码登录地址open.weixin.qq.com/connect/qrconnect只认开放平台里“网站应用”这个身份的 AppID,公众平台的应用凭证不在这条链路上生效。
| 对比项 | 开放平台(网站应用) | 公众平台(公众号) |
|---|---|---|
| 登录地址 | connect/qrconnect | 公众平台网页授权地址 |
| 典型凭证 | 网站应用 AppID/AppSecret | 公众号 AppID/AppSecret |
| 登录标识 | openid + unionid | openid(需绑定才有 unionid) |
| 适用场景 | PC 网页扫码、App 拉起登录 | 微信内网页授权、公众号菜单 |
2.2 创建网站应用:拿到三个关键参数
注册开放平台账号并完成开发者认证后,在“管理中心”里创建网站应用。创建时需要填写应用官网、应用简介,最关键的一项叫“授权回调域”,也就是用户扫码确认后微信把授权结果重定向回来的域名。它只填域名和端口,不需要填具体接口路径,比如login.example.com或example.com:8080,而且这个域名要求 ICP 备案,不能用 IP 地址或 localhost。
审核通过后,应用详情页会给出两个核心凭证:AppID 和 AppSecret。加上授权回调域,接入前需要准备的参数其实就这三个。AppSecret 建议直接放到配置中心或环境变量里,不要在代码仓库里明文提交,这个字段可以理解为后端调用微信接口时的密码,泄露以后别人可以拿它换 token、拉用户资料。
2.3 本地联调:回调域名怎么映射到开发机
开放平台不认 localhost,但开发阶段我们又确实在本地跑 Spring Boot。常见做法是用一台临时公网机器做一层反向代理,或者直接用内网穿透工具把本地 8080 端口映射成一个临时公网域名,再把这个临时域名填进授权回调域。Spring Boot 起来后监听 8080,内网穿透工具的地址指向这台机器的 8080,这样微信回调就能打到开发机上。
# 以内网穿透工具为例,将本地 8080 映射为公网地址 tool_name http 8080 # 输出类似 https://abc123.example.com # 把 abc123.example.com 这个域名填到开放平台网站应用的授权回调域里填完回调域不是立即生效的,通常需要等一两分钟,个别时候会有 CDN 缓存导致延迟更久。如果扫码后一直报 redirect_uri 错误,先别急着改代码,去后台确认回调域是否已经生效,再在后端日志里确认收到的请求是不是到了对应路径。这个阶段最容易出现的误解是“回调域填了接口完整路径”,正确做法是只填到域名层级,接口路径由代码里的 redirect_uri 自己决定。
3. 服务端对接:授权 URL 生成、回调接口与用户信息获取
3.1 扫码登录的完整链路,一次看清
整个扫码登录走的是 OAuth2 授权码模式,参与方有四个:浏览器页面、微信客户端、开放平台、我们的后端服务。用户打开网页,前端向我们的后端要一个授权 URL,然后把 URL 生成二维码展示在页面上;用户用手机微信扫码并确认授权;微信在浏览器里执行跳转,回到我们配置的回调域,带上code和state;后端拿 code 去调开放平台接口换access_token和openid;最后再用 token 拉取用户基础资料。
这条链路里有几个关键点值得提前说清楚。code只能用一次,有效期五分钟,换完 token 立即失效;state是防 CSRF 的随机串,必须在生成授权 URL 时记录、回调时校验;access_token不是长期凭证,它主要用于拉取用户资料,我们最终要落库的是 openid 或 unionid,以及建立自己的业务登录态。
3.2 后端生成授权二维码 URL
这一步 Spring Boot 后端做的事情很简单:生成一个随机 state,拼一个微信登录授权地址,返回给前端。二维码图片由前端根据这个地址生成,不需要后端处理图片,减轻服务端压力。
@RestController @RequestMapping("/wx") public class WxLoginController { @Value("${wx.open.appid}") private String appId; @Value("${wx.open.redirect-uri}") private String redirectUri; private final StringRedisTemplate redisTemplate; public WxLoginController(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @GetMapping("/qr-url") public Map<String, String> qrUrl() { // 每次打开登录页都生成新 state,防止重复扫码覆盖状态 String state = UUID.randomUUID().toString().replace("-", ""); // state 有效期对齐微信二维码的 5 分钟 redisTemplate.opsForValue().set("wx:qr:state:" + state, "waiting", 5, TimeUnit.MINUTES); String encodedRedirect = URLEncoder.encode(redirectUri, StandardCharsets.UTF_8); String authUrl = "https://open.weixin.qq.com/connect/qrconnect" + "?appid=" + appId + "&redirect_uri=" + encodedRedirect + "&response_type=code" + "&scope=snsapi_login" + "&state=" + state + "#wechat_redirect"; return Map.of("authUrl", authUrl, "state", state); } }这个接口返回的authUrl不是用户资料,也不是已登录状态,它只是一个让微信生成授权页的跳转地址。前端拿到它之后,用二维码库把这段字符串转成二维码图片展示。参数方面解释一下:appid是开放平台网站应用的 AppID;redirect_uri必须做 URL 编码,微信会把它和后台配置的授权回调域做域名级匹配;response_type固定是code;scope固定是snsapi_login,这是网站应用扫码登录的固定值;state是我们自己生成的随机串,整个流程里用它来标识“这一次登录请求”;URL 末尾的#wechat_redirect是微信官方要求的锚点,不能去掉。
3.3 回调接口:校验 state 并用 code 换 access_token
用户扫码确认后,浏览器会带着code和state重定向到我们配置的回调地址。回调接口的职责是校验 state、用 code 换 token、拉用户资料,然后准备登录态。
@GetMapping("/callback") public void callback(@RequestParam("code") String code, @RequestParam("state") String state, HttpServletResponse response) throws IOException { // 1. 校验 state:不存在或已过期说明不是本系统刚发起的扫码请求 String stateValue = redisTemplate.opsForValue().get("wx:qr:state:" + state); if (stateValue == null) { response.getWriter().write("state invalid or expired"); return; } // 2. 用 code 换 access_token 和 openid String tokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token" + "?appid=" + appId + "&secret=" + appSecret + "&code=" + code + "&grant_type=authorization_code"; String tokenBody = restTemplate.getForObject(tokenUrl, String.class); JsonNode tokenNode = objectMapper.readTree(tokenBody); if (tokenNode.has("errcode")) { // errcode 40029 最常见:code 已被使用或过期 response.getWriter().write("code exchange failed: " + tokenNode.get("errcode")); return; } String accessToken = tokenNode.get("access_token").asText(); String openid = tokenNode.get("openid").asText(); // 3. 拉取用户基础资料 String userInfoUrl = "https://api.weixin.qq.com/sns/userinfo" + "?access_token=" + accessToken + "&openid=" + openid; String userBody = restTemplate.getForObject(userInfoUrl, String.class); JsonNode userNode = objectMapper.readTree(userBody); // 4. 把结果写到 state 对应的 key 上,供前端轮询接口读取 Map<String, Object> userInfo = new HashMap<>(); userInfo.put("openid", userNode.get("openid").asText()); userInfo.put("nickname", userNode.has("nickname") ? userNode.get("nickname").asText() : ""); userInfo.put("headimgurl", userNode.has("headimgurl") ? userNode.get("headimgurl").asText() : ""); // 只有开放平台账号下的应用才会返回 unionid,没有时不要强求 userInfo.put("unionid", userNode.has("unionid") ? userNode.get("unionid").asText() : ""); redisTemplate.opsForValue().set( "wx:login:result:" + state, objectMapper.writeValueAsString(userInfo), 90, TimeUnit.SECONDS); response.getWriter().write("ok"); }这里的 RestTemplate 和 ObjectMapper 可以直接由 Spring 容器管理,也可以用构造器注入。为什么回调接口只写一个ok而不做页面重定向?因为当前很多 web 前端是前后端分离的 SPA,回调接口如果直接重定向到前端业务页,浏览器地址栏会出现一大串 code 和 state 参数,既不美观也容易误触发刷新后的重复消费。我把用户态写到 Redis,前端通过另一个接口轮询拿到结果,再决定跳转,整个流程更可控。
需要注意code的时效:它只能消费一次,回调接口被意外触发两次时,第二次调 token 接口就会返回40029 invalid code。所以回调逻辑必须设计成幂等,以 state 为维度判断,如果wx:login:result:{state}已经存在,说明刚才已经处理过,直接返回成功即可。
3.4 用户态交给 Redis:无状态轮询接口
扫码登录里有个容易忽略的边界问题:授权 URL 生成时用的 HttpSession 和微信回调发生的 HttpSession 可能并不是同一个上下文。尤其在多实例部署时,session 可能落在不同机器上,回调请求根本读不到之前存的 session 属性。我现在的习惯是彻底不依赖 HttpSession,完全以 state 为 key 把状态放进 Redis,这样回调接口和轮询接口都是无状态的,任意实例都能处理。
轮询接口同样只认 state,不认 session:
@GetMapping("/login/status") public Map<String, Object> loginStatus(@RequestParam("state") String state) throws Exception { String result = redisTemplate.opsForValue().get("wx:login:result:" + state); if (result == null) { return Map.of("status", "waiting"); } return Map.of("status", "logged", "user", objectMapper.readTree(result)); }前端拿到status=logged后,再调用业务侧自己的登录接口,把从 Redis 读到的 openid、unionid 和业务账号体系匹配起来,签发我们自己的 token。注意别把这个轮询接口做成直接返回登录态,最好只当它是“扫码结果查询口”,真正的登录动作由业务接口完成,这样登录凭证的生成、过期、续期仍然集中在自己的权限体系里。
4. web 端集成:二维码显示、轮询与跨域收尾
4.1 前端把授权 URL 变成二维码
前端只需要一个二维码库,比较常见的是 qrcodejs。页面加载后先请求后端的/wx/qr-url,拿到授权地址和 state,再初始化二维码。
<div id="qrcode"></div> <script src="/js/qrcode.min.js"></script> <script> async function loadQr() { const res = await fetch('/wx/qr-url'); const data = await res.json(); window.wxLoginState = data.state; new QRCode(document.getElementById('qrcode'), { text: data.authUrl, width: 220, height: 220, correctLevel: QRCode.CorrectLevel.M }); startPolling(data.state); } </script>二维码内容就是授权 URL 本身,所以前端拿到字符串直接生成就行。correctLevel: M是这个库默认的纠错级别,页面二维码如果被 logo 遮挡一部分,M 级容忍度够用。二维码生成后记得把 state 存在全局变量里,后面的轮询要反复用到它。
4.2 用轮询接住登录结果:2 秒间隔与 90 秒超时
回调接口拿到的用户信息已经写进 Redis,前端轮询/wx/login/status就能拿到。轮询间隔我一般取 2 秒:1 秒请求频率太密,3 秒用户感知偏慢。总超时设置 90 秒,微信二维码本身 5 分钟有效,但用户从掏出手机到扫码确认一般不会超过 90 秒,超时后主动停掉轮询并提示重新刷新二维码更干净。
var pollTimer = null; function startPolling(state) { var elapsed = 0; pollTimer = setInterval(async () => { elapsed += 2000; const res = await fetch('/wx/login/status?state=' + state); const data = await res.json(); if (data.status === 'logged') { clearInterval(pollTimer); // 拿到扫码结果后,调用业务登录接口换取正式登录态 const loginRes = await fetch('/api/login/by-wechat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: state }) }); const loginData = await loginRes.json(); if (loginData.code === 0) { location.href = '/home?token=' + loginData.data.token; } return; } if (elapsed >= 90000) { clearInterval(pollTimer); alert('二维码已过期,请刷新页面重新扫码'); } }, 2000); }轮询接口只是把扫码结果交还给前端,最终的登录动作放在/api/login/by-wechat,这样后端可以在这一步做用户匹配、账号创建、签发 token,逻辑内聚,也方便后续接入手机号绑定等流程。定时器记得在成功、失败、页面卸载三个时机都清理掉,否则用户离开页面后请求还在发,白白消耗连接。
4.3 跨域与 Cookie:一个容易被忽略的问题
开发环境下前端跑 8080、后端跑 9090 是常态,这就带来跨域问题。最直观的现象是:扫码确认成功了,Redis 里也有结果了,但前端轮询拿不到登录态,因为回调成功后的请求里带了 Cookie,被浏览器跨域策略拦了。
解决办法有两种。开发阶段在后端配置 CORS,注意allowedOrigins不能写*,并且要开allowCredentials(true):
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:8080") .allowedMethods("GET", "POST", "OPTIONS") .allowCredentials(true) .maxAge(3600); } }生产环境我更推荐用 Nginx 把前端静态资源和后端接口代理到同一个域名下,让浏览器认为这就是同一个源,从根上消除跨域问题。只要把/wx/和/api/转发给后端服务,前端代码里的请求路径全部改成相对路径即可。
server { listen 80; server_name login.example.com; location / { proxy_pass http://前端服务地址; } location ^~ /wx/ { proxy_pass http://后端服务地址; proxy_set_header Host $host; } location ^~ /api/ { proxy_pass http://后端服务地址; proxy_set_header Host $host; } }这样配置以后,前端页面里的/wx/qr-url、/wx/login/status都会请求到同一个域名,不存在跨域问题,后端也无需再依赖 Cookie 保持会话。
5. 避坑:微信扫码登录接入中的 5 个高频故障
5.1 扫码提示 redirect_uri 参数错误
现象:手机微信扫码后,页面提示“redirect_uri 参数错误”或“redirect_uri 所在域名未通过校验”。
原因:授权回调域没配对。常见三种错误,一是后台填了 IP 地址,二是填了接口完整路径而微信只认域名层级,三是 redirect_uri 参数在拼 URL 时没做编码。
解决:先到开放平台网站应用后台,确认授权回调域填的是域名且协议正确,比如https://login.example.com;再去代码里确认redirect_uri经过URLEncoder.encode,编码后https://中的冒号和斜杠会变成%3A%2F%2F,这是正常的。如果改了配置,等一两分钟再测试。
5.2 拿公众号 AppID 调扫码登录
现象:二维码扫出来不是授权登录页面,而是公众号关注页,或者直接提示“请在微信外打开”。
原因:把公众平台的 AppID 填进了开放平台的授权地址。开放平台qrconnect这条链路只认网站应用身份,公众号身份不在同一体系。
解决:去开放平台“管理中心”确认用的是网站应用的 AppID,而不是公众号的。两者可以同时存在,但一定要区分清楚。判断方法很简单:在开放平台后台看到的应用类型是“网站应用”,对应的登录方式是扫码;而公众平台后台没有qrconnect对应的网站应用登录入口。
5.3 回调重复触发导致 invalid code
现象:后端日志出现40029 invalid code,或同一个用户重复扫码后第一次成功、后续全部失败。
原因:code 是一次性凭证,换 token 后立即失效。微信的网络重试、用户手动刷新回调页、浏览器预加载都可能导致同一个 code 被请求两次。
解决:回调接口做幂等。以 state 为 key,如果wx:login:result:{state}已经有值,直接返回成功,不再调 token 接口。同时在消费 code 前用redisTemplate.delete("wx:qr:state:" + state)把 state 标记删掉,第二次进来先校验 state 不存在就直接拦截,效果一样。
5.4 扫码成功但前端轮询等不到结果
现象:Redis 里已经有登录结果数据,后端日志也能看到回调进来了,但前端页面一直转圈提示等待扫码。
原因:回调接口把用户信息写进了 HttpSession 或本地内存,而前端轮询请求没有携带同样的会话标识;另一种是多实例部署时,回调被负载均衡分发到了另一台机器。
解决:不要依赖 HttpSession 传用户态,统一用 state 作为 key,把扫码结果放到 Redis。轮询接口也不依赖任何会话,只凭 state 读数据。这样单个实例重启、多实例部署都不会丢状态。这个方案在服务端重启或 Redis 重启时会丢一次状态,但扫码登录场景下用户重新刷新页面即可,影响很小。
5.5 二维码 5 分钟失效,用户扫码扫晚了
现象:用户打开登录页,过了一会才掏出手机扫码,微信侧提示“二维码已失效”或页面无响应。
原因:开放平台的二维码授权链接本身有 5 分钟有效期,二维码过期后微信不再响应。
解决:前端做倒计时,5 分钟一到就重新请求/wx/qr-url并重建二维码,不需要用户手动刷新整个页面。倒计时剩最后 30 秒时还可以自动静默刷新一次,用户正在扫码时突然换图比直接过期体验好一点。
6. 进阶:把扫码登录从“能用”做到“好用”
扫码登录跑通之后,真正值得花时间的是几个容易被忽略的细节。
第一是用户态的一致性问题。用户扫码拿到的 openid 是微信应用维度的标识,同一个用户如果还通过公众号、App 登录过,openid 各自不同,只有开放平台账号下绑定了多个应用的场景才会返回 unionid。建议业务库里直接用 unionid 或 openid+平台标识做唯一键,为将来多端账号打通留好扩展位。
第二是登录态的签发时机。回调接口只负责取得微信侧身份,真正的业务登录应该放在前端拿到扫码结果后调用的/api/login/by-wechat接口里。在这个接口里做新用户建档、老用户匹配、签发自己的 token,同时把 token 有效期和用户操作习惯对齐:内部系统可以 8 小时,面向 C 端的建议短一些,比如 2 小时,配合 refresh token 续期。
第三是安全细节。state 必须每次生成且有过期时间;回调接口不要返回用户的完整资料,只返回处理状态;业务登录接口要做好频率限制,防止有人拿大批 state 恶意刷接口。
我个人的习惯是每次接扫码登录,都会先拿白板把“授权 URL -> 微信回调 -> code 换 token -> 用户态写回 -> 前端轮询”这条链路画一遍,标出每个节点依赖的凭证和有效期,再动手写代码。这样做过几个项目之后,耗时的从来都不是写代码,而是环境配置与凭证时序。把这条链路理解透了,以后接任何第三方 OAuth2 登录都只是换接口地址和参数的事。希望帮到你。
本文还有配套的精品资源,点击获取