简介这份基于 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 unionidopenid需绑定才有 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 MapString, 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_typecode scopesnsapi_login state state #wechat_redirect; return Map.of(authUrl, authUrl, state, state); } }这个接口返回的authUrl不是用户资料也不是已登录状态它只是一个让微信生成授权页的跳转地址。前端拿到它之后用二维码库把这段字符串转成二维码图片展示。参数方面解释一下appid是开放平台网站应用的 AppIDredirect_uri必须做 URL 编码微信会把它和后台配置的授权回调域做域名级匹配response_type固定是codescope固定是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_typeauthorization_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 上供前端轮询接口读取 MapString, 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不认 sessionGetMapping(/login/status) public MapString, 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)); }前端拿到statuslogged后再调用业务侧自己的登录接口把从 Redis 读到的 openid、unionid 和业务账号体系匹配起来签发我们自己的 token。注意别把这个轮询接口做成直接返回登录态最好只当它是“扫码结果查询口”真正的登录动作由业务接口完成这样登录凭证的生成、过期、续期仍然集中在自己的权限体系里。4. web 端集成二维码显示、轮询与跨域收尾4.1 前端把授权 URL 变成二维码前端只需要一个二维码库比较常见的是 qrcodejs。页面加载后先请求后端的/wx/qr-url拿到授权地址和 state再初始化二维码。div idqrcode/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 登录都只是换接口地址和参数的事。希望帮到你。本文还有配套的精品资源点击获取