☰
微信扫码登录实战:Spring Boot 完整接入教程与避坑指南
2026/10/12 9:59:54 网站建设 项目流程

简介:一份基于微信开放平台的Spring Boot Web端扫码登录完整项目,面向需要集成微信扫码认证的中高级Java开发者,解决OAuth 2.0授权流程落地问题。压缩包共121个文件,包含15个Java源码、16个class编译文件、多个properties/XML配置、Spring Beans定义及Git版本元数据等,整体仅172KB,目录结构清晰,便于直接导入工程查阅。已有11382人学习下载,适合参考实现微信扫码登录流程,包括获取二维码、回调code、换取access_token/openid、对接用户系统等关键环节。资料中还涉及Spring Security认证整合、JWT无状态令牌生成、状态码防篡改校验以及HTTPS安全通信等细节,可帮助读者快速复刻并理解从扫码到登录的完整链路。通过阅读源码与配置,可掌握微信开放平台应用注册、回调URL配置、用户信息拉取及本地用户匹配等实战经验。

1. 微信扫码登录:为什么你的站点需要一个官方通道

做 Web 端站点登录时,“微信扫码登录”几乎是绕不开的标配需求。但很多第一次动手的团队容易在这个环节栽跟头:拿着公众号的 AppID 去拼扫码地址,结果微信内置浏览器能打开,PC 浏览器永远白屏;或者回调地址配错一个端口,微信服务器直接把请求丢进黑洞,前端半天收不到响应。微信扫码登录并不是“调一个接口就行”的事,它涉及微信开放平台的网站应用申请、扫码 URL 构造、回调地址配置、code 换 token、用户信息拉取、登录态签发这一整条链路。这篇文章基于微信开放平台,用 Spring Boot 把一个完整的扫码登录项目拆开讲清楚:从平台侧要准备什么资质,到本地开发环境怎么绕过域名限制,再到后端如何接管回调并建立自己的会话体系。适合正在给公司站点接入微信登录的开发者,也适合想搞懂微信 OAuth2.0 授权码模式完整细节的读者——照着这篇文章的步骤走,能少踩一半的坑。

2. 微信登录的三个体系:选错入口等于白干

2.1 公众号网页授权、开放平台网站应用、小程序登录的区别

微信生态里至少有三种“让用户用微信登录”的方式,很多新手在这里就分不清。公众号内的网页授权(OAuth2.0 的 snsapi_base / snsapi_userinfo)走的是公众号 AppID,它面向的是“用户在微信里打开一个 H5 页面”的场景,授权完成之后回跳的域名必须是公众号后台配置的 JS 接口安全域名,而且拿到的用户标识是公众号维度下的 openid。如果你的站点是在 PC 浏览器里被访问,用户用微信扫码,那么走公众号网页授权是行不通的——微信不会在一个外部浏览器里帮你拉起微信内部的授权确认页。

微信开放平台的“网站应用”才是专门给 PC Web 站点用的登录入口。它的授权方式是扫二维码,用户用手机微信扫码后,在微信内部确认授权,然后微信服务器把授权回调发到你配置的 redirect_uri 上,携带一个 code。你的后端拿这个 code 去换 access_token 和 openid/unionid。整个过程不需要用户输入账号密码,也不需要你在前端做任何微信 SDK 的初始化。

小程序登录则是完全另一套体系:用户在某个小程序内部,通过 wx.login() 拿到 code,再传给后端换 session_key 和 openid。它和 Web 端扫码登录没有关系,但如果你同时运营小程序和 PC 站点,可以通过开放平台绑定小程序,用 unionid 把两边的用户打通。这个后面再说。

维度公众号网页授权开放平台网站应用小程序登录
适用端微信内 H5PC/移动浏览器小程序内部
用户交互微信内跳转授权手机扫码确认静默/主动授权
拿到什么openid(公众号维度)openid/unionid(开放平台维度)openid(小程序维度)
关键配置网页授权域名授权回调域无域名要求
Spring Boot 集成成本中等中等低

2.2 开放平台账号资质与网站应用申请流程

要在开放平台上创建“网站应用”,第一步是注册开放平台账号。这里有个硬性门槛:个人主体基本无法完成网站应用的创建,必须要企业资质(营业执照)或个体工商户资质,并且需要完成微信认证(每年需要缴纳认证费用)。如果你们公司已经有认证过的公众号,可以在开放平台里用同一个邮箱注册账号,然后通过“公众号绑定”快速完成开发者资质认证,不需要再单独交一次认证费。这一点在项目启动前就应该确认清楚,否则开发做到一半发现资质下不来,所有代码都得搁置。

创建网站应用的核心步骤是这样的:登录开放平台 → 进入“管理中心” → 创建网站应用 → 填写应用名称、简介、Logo → 填写官网域名和“授权回调域”。这里的“授权回调域”不是完整的回调 URL,而是域名级别,不需要写 http 和路径。比如你的回调地址是https://api.example.com/wx/login/callback,那授权回调域就填api.example.com。提交后进入审核,通常一个工作日内出结果。在审核通过之前,应用状态是“已创建”但未获得正式的 AppID 使用权限,扫码登录流程是不可以上线的。

这里有个容易被忽略的细节:开放平台网站应用的 AppID 和公众号的 AppID 是两个完全不同的值,长得都是 wx 开头的十六进制字符串,但对应的是不同的应用实体。很多人第一次接入时直接把公众号 AppID 填进来,微信服务器会返回invalid appid错误,或者根本不会进入扫码流程。我一般会把开放平台和公众号的 AppID 分开记录在配置中心的注释里,避免在多个环境里搞混。

2.3 网站应用审核通过后要记下的三组关键参数

审核通过之后,你会拿到三组必须妥善保存的参数:AppID、AppSecret、以及你填写的授权回调域。AppID 是公开的,会出现在前端扫码 URL 里,不需要保密。AppSecret 是后端换取 token 的凭证,绝对不能放在前端代码里,也不能提交到 Git 仓库,建议配置在 Nacos / Apollo / 环境变量里,并区分开发、测试、生产三套值。

回调域要记准确,它决定了你的 redirect_uri 是否能通过微信的校验。微信会拿你实际请求里的 redirect_uri 的域名部分和你配置的回调域比对,完全匹配才会继续流程。注意这里的匹配是“域名精确匹配”,api.example.com和example.com不算同一个域名,test-api.example.com也不算。如果回调地址里带了端口,比如http://localhost:8080/wx/login/callback,那么授权回调域里也要写上localhost,但微信对localhost有特殊处理——在实际开放平台后台不推荐这样配置,更好的做法是本地调试时用内网映射工具先映射出一个临时域名,绕开这一层限制。下一章就讲这个问题。

3. 开发环境准备与本地调试:先解决回调地址的难题

3.1 申请测试用的回调域名与内网映射工具配置

扫码登录的流程里,微信服务器需要“主动”向你的后端发起一次 HTTP 请求(code 回调)。在本地开发时,你的电脑在localhost:8080跑着 Spring Boot 服务,微信服务器不可能直接访问到它。很多团队在这个环节卡住,然后选择把代码部署到测试服务器上调试,但每次改代码都要重新构建上传,非常消耗耐心。

常见做法是让本机临时拥有一个公网可访问的域名,把请求转发到本地端口,这属于绕过 NAT 的常规开发工具。我现在习惯的做法是准备两个映射工具交替用:一个图形化程序,一个命令行工具,两个子命令就能看到一个临时公网地址。你可以把微信回调域临时指向某个映射域名,比如xxx.example.com,然后在 Spring Boot 里把回调地址配置成完整的http://xxx.example.com/wx/login/callback。用命令行工具时,命令大概是这样的:

你的映射工具 http 8080

启动后终端会输出一个xxx.example.com之类的公网 URL,映射到本机的 8080 端口。注意免费版的映射域名每次重启都会变,所以微信后台的回调域也要跟着改。我通常会把这段映射命令写进项目的dev-team文档里,每次新成员加入时直接复制即可,不用重复摸索。

要注意一点:这种映射工具只适合开发调试,不能用于生产环境。生产环境必须走正式的 HTTPS 域名,因为微信要求回调域名必须支持 HTTPS。这算是一个“在黑匣子里摸索”了很久的教训——第一次用 HTTP 域名回调时,微信一直报redirect_uri 参数错误,后来才发现是协议不匹配。

3.2 Spring Boot 工程基础结构:依赖引入与配置准备

创建一个标准的 Spring Boot 工程,我习惯用 Java 17 + Spring Boot 3.x。扫码登录本身不依赖特别复杂的组件,核心只需要 Spring Web 和 HTTP 客户端。HTTP 客户端我用 JDK 自带的java.net.http.HttpClient,少一个依赖就少一层版本冲突风险,当然你用 RestTemplate 或者 OkHttp 也一样。

pom.xml里的基础依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>

Redis 的作用后面会讲到,扫码登录场景下它承担两件事:一是保存临时登录凭证(state 参数),二是存微信 access_token 的缓存。如果你不想引入 Redis,用 ConcurrentHashMap 也能撑住单机开发,但生产环境千万别这么干——多个实例之间登录状态不同步,用户会间歇性扫码失败,这种玄学问题会让排查非常痛苦。

application.yml里的配置项:

wx: open: app-id: wx1234567890abcdef app-secret: your-app-secret redirect-uri: http://xxx.example.com/wx/login/callback authorize-url: https://open.weixin.qq.com/connect/qrconnect token-url: https://api.weixin.qq.com/sns/oauth2/access_token userinfo-url: https://api.weixin.qq.com/sns/userinfo

app-secret在生产环境一定要通过环境变量注入,比如${WX_APP_SECRET},不要写死在 yml 里,否则代码仓库泄露就等于账号泄露。redirect-uri这个配置项需要区分环境,本地开发指向映射域名,测试环境指向测试服务器的真实域名,上线前再替换成正式域名。我见过有人把这几个 URL 写死成常量类,换环境的时候改代码重新编译,这种做法不推荐——配置就该有配置的样子。

3.3 生成带状态的授权 URL:state 参数不能省

在进入核心流程之前,先做一个最简单的接口:当用户访问你的 PC 站点并点击“微信登录”按钮时,后端需要生成一个微信授权的跳转 URL,前端拿到这个 URL 后,把用户引导到二维码页面。这个 URL 的格式是有固定规范的,参数顺序也最好按官方文档来,减少一些奇奇怪怪的解析问题。

@RestController @RequestMapping("/wx/login") public class WxLoginController { @Value("${wx.open.app-id}") private String appId; @Value("${wx.open.redirect-uri}") private String redirectUri; @Value("${wx.open.authorize-url}") private String authorizeUrl; @GetMapping("/qr") public Result<String> getQrUrl() { String state = UUID.randomUUID().toString().replace("-", ""); // 实际项目中 state 要存 Redis,并设置 5 分钟过期 String url = String.format( "%s?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_login&state=%s#wechat_redirect", authorizeUrl, appId, URLEncoder.encode(redirectUri, StandardCharsets.UTF_8), state ); return Result.success(url); } }

这段代码的核心点有两个。第一,redirect_uri必须做 URL Encode,因为微信回调地址本身可能含有特殊字符,比如路径分隔符冒号、斜杠,不编码的话微信解析会错位。第二,state参数是防 CSRF 的关键,微信原样返回这个值,你要在回调接口里校验它与发起扫码时是否一致。我见过有人在项目里直接把 state 写死成固定字符串,这等于把这个防护机制废掉了——攻击者可以构造一个恶意链接诱导用户扫码,然后把用户绑定到攻击者的账号上。

这个 URL 最终会跳转到微信开放平台的二维码展示页,页面长什么样微信管,你不需要自己在前端画二维码。这里很多人有个误区,以为需要引入qrcode.js去生成二维码图片。其实微信开放平台的扫码登录流程里,二维码由微信官方页面负责渲染,你只要把浏览器 window.location 指向那个授权 URL 即可。真正需要前端配合的是后续的轮询操作,下一章展开讲。

4. 扫码登录核心流程:二维码展示、轮询与回调处理

4.1 完整时序:从点击登录到建立会话要经过几次心跳

先把整个扫码登录的参与方理清:用户浏览器(前端)、你的 Spring Boot 后端、微信服务器。三者之间一共要经历两轮交互。

第一轮是前端引导用户到微信的二维码页面。后端返回一个wx.qrlogin.cn的登录 URL,前端window.location.href跳过去。用户看到二维码后,掏出手机微信扫码,在手机微信上确认授权。这一步完成后,微信服务器会自动往你的 redirect_uri 发一个 GET 请求,带code和state两个参数。注意这里不需要用户再手动输入什么,整个扫码到授权确认大概 3 到 5 秒。

第二轮是后端收到 code 后,用 code 去换 access_token 和 openid。这一步是后端到后端的请求,前端不参与。换到 token 后,再调用户信息接口拿昵称、头像、性别等资料。与此同时,你的前端页面需要一直“盯着”登录状态的变化——因为用户在微信里确认授权之后,浏览器并不会自动跳转回你的站点,而是停留在微信的“授权成功”页面上。这时候前端的轮询接口开始工作:每隔 2 到 3 秒问一次后端“这个用户登录成功了没”,后端查询会话状态,一旦发现 code 已经被处理完,就返回登录成功和业务 token,前端再跳转。

如果你在微信授权成功页上看到“请回到最初页面”之类的提示,其实这就是微信在提醒用户“你现在该回到你自己的网页了,那边会在几秒内刷新”。很多用户不理解这个提示,直接关掉了页面。这也是为什么扫码登录要在前端加一个“检测到微信授权成功,即将自动跳转”的过渡页,能显著降低用户流失率。

4.2 前端页面:二维码加载状态与轮询机制

既然上文说了授权 URL 由后端生成,那前端的任务就简单了:拿到 URL、跳转微信页面、轮询等待结果。但这里有个体验问题是很多站点会犯的——直接把用户从首页跳走,万一用户扫到一半不想登录了,退回来时已经回不到原来的页面了。我一般会在点击登录时打开一个新窗口或弹窗,把授权页放在弹窗里,主站窗口保持不动,轮询放在主站窗口里执行。这样用户就算取消登录,关掉弹窗也不会丢失当前位置。

前端轮询代码(用一个简易的 JS 片段表示核心逻辑):

// 点击"微信登录"按钮后 const state = generateState(); // 和前端无关,由后端生成,这里简化 const qrUrl = await fetch('/wx/login/qr').then(res => res.json()).then(res => res.data); window.open(qrUrl, 'wechatLogin', 'width=800,height=600'); // 主窗口里轮询登录状态 const pollTimer = setInterval(async () => { const res = await fetch(`/wx/login/check?state=${state}`); const data = await res.json(); if (data.success) { clearInterval(pollTimer); localStorage.setItem('token', data.token); window.location.href = '/dashboard'; } }, 2000);

这个代码里有几个参数值得说。轮询间隔我习惯用 2000 毫秒,太短会让后端压力变大且容易碰到微信侧尚未回调完成的间隙;太长则会让用户体验到明显的延迟感,尤其是在二维码刚扫完的那几秒里,用户会盯着页面发呆。你也可以改成基于 SSE 或 WebSocket 的推送,但扫码登录场景下用户本身就只有扫码后的几秒需要等待,用不着引入长连接这么重的机制,轮询最简单也最可靠。

后端对应的轮询接口逻辑很简单:查 Redis 里wx:login:state:{state}这个 key 是否已经存在用户信息。如果存在,说明微信回调已完成,返回登录态;如果不存在,返回WAITING,前端继续轮询。这个 key 在用户扫码成功后写入值,设置 5 分钟过期。

4.3 后端回调接口:接收 code、换取 access_token 与用户信息

回调接口是整条链路里最核心的一段代码。微信会把 code 通过 GET 请求带到 redirect_uri 上,比如http://xxx.example.com/wx/login/callback?code=abc&state=xyz。你的 Controller 接收这两个参数,先校验 state 与发起扫码时是否一致,再拿 code 去微信的 token 接口交换。交换是 GET 请求,参数直接拼在 URL 上。

@GetMapping("/callback") public Result<String> wxCallback(@RequestParam String code, @RequestParam String state) { // 1. 校验 state,防止 CSRF String cachedState = loginStateService.getAndDelete(state); if (cachedState == null) { return Result.error("state 校验失败,请重试"); } // 2. code 换 access_token String tokenUrl = String.format( "%s?appid=%s&secret=%s&code=%s&grant_type=authorization_code", wxConfig.getTokenUrl(), wxConfig.getAppId(), wxConfig.getAppSecret(), code ); String tokenResp = httpClient.get(tokenUrl); JsonNode tokenJson = objectMapper.readTree(tokenResp); if (tokenJson.has("errcode")) { log.error("换 token 失败: {}", tokenResp); return Result.error("微信授权失败,请重试"); } String accessToken = tokenJson.get("access_token").asText(); String openid = tokenJson.get("openid").asText(); // 3. 拉取用户信息 String userInfoUrl = String.format( "%s?access_token=%s&openid=%s", wxConfig.getUserInfoUrl(), accessToken, openid ); String userInfoResp = httpClient.get(userInfoUrl); JsonNode userJson = objectMapper.readTree(userInfoResp); // 4. 根据 openid 创建或获得本地用户 SysUser user = userService.findOrCreate(openid, userJson); // 5. 将登录结果写入 Redis,供轮询接口检测 loginStateService.markSuccess(state, user.getId()); // 6. 这个响应其实用户看不见,微信会展示授权成功页面 return Result.success("登录成功"); }

这里有个关键点:微信回调接口是在用户扫码授权的瞬间触发的,但是用户浏览器还停在微信页面上。所以这个回调的响应内容用户看不到,真正让前端感知到登录成功的是轮询接口。很多第一次接入的人会困惑“回调回来了,我该返回什么页面”,实际上返回什么都不重要,微信会自行决定展示什么给用户。你真正要做的是把登录结果记录到一个可查询的中间状态里。

state的校验有个细节:我用的getAndDelete是取出来就删掉,保证同一个 state 不能被二次使用。如果用户对同一个二维码扫码两次,第二次回调的 state 已经被删,直接报错。这个设计思路和一次性 token 是一致的。

4.4 用户信息拉取与 openid 的业务意义

微信接口返回的用户信息里,openid是每个用户在“某个应用+某个微信账号”下的唯一标识。同一个用户在不同应用下 openid 不同,所以你不能拿 openid 作为公司级用户主键——如果以后接入小程序或公众号,会出现同一个用户对应多个 openid 的困境。如果你在开放平台同时绑定过公众号或小程序,接口里可能会返回unionid,它才是用户在全平台下的唯一标识。这也是为什么userService.findOrCreate的设计里,优先用 unionid 查用户,没有 unionid 时才退化为 openid。

注意一点:拉取用户信息需要userinfo的 scope 权限。网站应用的登录 scope 固定是snsapi_login,这个 scope 包含用户基本信息的查看权限,但拿到的字段比公众号网页授权的snsapi_userinfo少一些。具体能拿到哪些字段以开放平台文档为准,我遇到过的实际案例里,昵称、头像、性别、城市这几个字段基本稳定,但偶尔会有用户什么都没填导致字段缺失,所以解析代码里一定要做空值兜底。

5. 登录态与会话建立:扫码成功后怎么让用户“记住”

5.1 本地用户表:自动注册与字段映射

微信扫码登录对用户来说体验很好,但对你自己的用户体系来说意味着“用户可能是第一次来”。你不能假设用户一定已经在你的系统里注册过,更常见的场景是用户第一次扫码就自动创建账号,第二次扫码再直接登录。这对应着一张sys_user表,至少要包含这几个字段:id(自增主键)、unionid(微信唯一标识)、openid(应用维度标识)、nickname、avatar、create_time、last_login_time。

自动注册的代码逻辑很简单,先查 unionid(或 openid)是否存在,不存在就插入一条新记录。但这里有个并发问题:如果用户快速扫了两次码,两个请求同时进来都发现用户不存在,就会插入两条重复数据。解决办法是给unionid和openid加唯一索引,或者在后端代码里做同步锁。我没用分布式锁,因为扫码登录场景的并发量不高,唯一索引兜底足够了,插入失败时重新查一次再返回即可。

public SysUser findOrCreate(String openid, JsonNode userInfo) { String unionid = userInfo.has("unionid") ? userInfo.get("unionid").asText() : null; SysUser user = userMapper.findByUnionid(unionid != null ? unionid : openid); if (user != null) { userMapper.updateLastLoginTime(user.getId()); return user; } SysUser newUser = new SysUser(); newUser.setOpenid(openid); newUser.setUnionid(unionid); newUser.setNickname(userInfo.has("nickname") ? userInfo.get("nickname").asText() : "微信用户"); newUser.setAvatar(userInfo.has("headimgurl") ? userInfo.get("headimgurl").asText() : ""); userMapper.insert(newUser); return newUser; }

注意unionid可能为空,比如用户在隐私设置里关了“通过开放平台关联”之类的选项。这种情况下只能拿 openid 将就。但这样会导致同一个用户在你的 PC 站点和未来上线的小程序里被当成两个人,历史数据无法自动合并。如果业务上能接受,就先这样跑着,后续再做一个“用户自助绑定”的功能来收敛多账号问题。

5.2 签发业务 token:JWT 还是 Redis Session

微信侧鉴权完成后,你的系统还需要签发自己的登录凭证。两种主流方案:JWT 无状态 token,或者 Redis 存储的 session id。JWT 的优点是后端不用存 session,扩展方便;缺点是 token 无法主动失效,一旦泄露只能等过期。Redis session 的优点是随时可以踢人下线、控制会话数;缺点是每次请求都要查一次 Redis。扫码登录场景下我倾向前者——登录态有效期短、用户要求不高、动手快。

我一般用 JWT,pom.xml里引入jjwt依赖,签发时把userId放进 claims,过期时间设 7 天。如果你的站点对安全要求高,可以把过期时间缩到 2 小时然后搭配 refresh token 机制。不过扫码登录的频率通常不高,用户一次登录可以维持较长时间,7 天比较省事。签发时加一点随机因子进去,避免所有用户在同一秒签发时得到相同的 token 前缀暴露用户数量。

前端拿到 token 后存放在localStorage里,后续所有请求都带上Authorization: Bearer <token>头。这里有个小提醒:localStorage有 XSS 风险,如果你的站点需要很强的安全等级,应该把 token 放在HttpOnlyCookie 里。但 Cookie 方案又要处理 CSRF 问题,权衡之下多数内部系统选 localStorage 就够了。

5.3 前端轮询接口返回什么:状态机设计

轮询接口设计成状态机比较清晰。状态有三个:WAITING(用户还没扫码,或扫码未确认)、SUCCESS(微信回调完成,用户已登录)、EXPIRED(二维码失效或 state 过期)。前端只认这三种状态,超过 5 分钟还没变SUCCESS就直接显示二维码过期提示。

@GetMapping("/check") public Result<LoginStatus> check(@RequestParam String state) { LoginState loginState = loginStateService.get(state); if (loginState == null) { return Result.success(LoginStatus.EXPIRED); } if (loginState.isSuccess()) { String token = jwtService.createToken(loginState.getUserId()); // 成功后删除 state,防止重复登录 loginStateService.delete(state); return Result.success(LoginStatus.SUCCESS, token); } return Result.success(LoginStatus.WAITING); }

这里有个体验细节:不要让前端在SUCCESS状态下暴露userId,只返回 token。原因很简单,扫码登录的所有用户信息都是微信侧给的,userId 是内部实现,暴露出去没有任何好处。另外check接口要做简单的限流,每个state最多每秒查一次,防止有人用非常长的 state 参数刷接口打满 Redis。

6. 微信扫码登录的六大踩坑:现象、原因、解决

6.1 微信内置浏览器能扫码,PC 浏览器打开却是白屏

现象:把授权 URL 在微信里打开能正常显示二维码,但在 PC 浏览器里访问同一个 URL 是空白页面,或者显示“此网站无法访问”。

原因:AppID 配错了。大概率你用的是公众号的 AppID,而不是开放平台网站应用的 AppID。公众号 AppID 对应的授权接口是open.weixin.qq.com/connect/oauth2/authorize,它需要微信内置浏览器环境;开放平台的是connect/qrconnect,它才能在普通浏览器里渲染二维码。这两个 URL 看起来很像,但响应逻辑完全不同。

解决:去开放平台管理后台,确认创建的是“网站应用”而不是“公众号应用”,替换appid参数后重新测试。排查时可以先在浏览器里直接访问授权 URL,观察地址栏是否跳到微信的二维码页。如果停留在一个白屏域名上,基本就是 AppID 或 scope 参数不对。

6.2 扫码后页面提示 redirect_uri 参数错误

现象:用户扫完码,微信页面直接报“redirect_uri 参数错误”或者“当前访问的页面路径不在授权回调域名内”,浏览器地址栏里能看到 code 已经生成但无法跳转。

原因:两个可能。一是你在开放平台后台配置的“授权回调域”与实际请求的 redirect_uri 域名不匹配,比如配了example.com但用了www.example.com。二是回调地址里的 redirect_uri 没有做 URL Encode,导致微信解析的域名部分被截断。

解决:在后台把授权回调域改成实际使用的域名(不需要写路径),然后检查生成的授权 URL,redirect_uri参数必须是编码后的完整 URL。我习惯写一个单元测试来校验 URL 拼接逻辑,断言编码结果里不包含裸的冒号和斜杠。

6.3 code 换 token 时报 invalid code

现象:后端日志出现{"errcode":40029,"errmsg":"invalid code"}。

原因:code 是一次性的,而且有效期极短(官方说是 5 分钟)。最常见的情况是你在回调接口里不小心对同一个 code 调了两次微信接口,第一次成功后第二次必然失败。另一种可能是有个定时任务把回调消息重放了一遍。

解决:在代码里保证每个 code 只被消费一次。可以用 Redis 做标记,收到 code 时先setIfAbsent,只有第一次设置的请求才去换 token,第二次直接忽略。同时检查日志,排除 Nginx 重试或消息队列重投递导致的重入问题。

6.4 二维码扫了没反应,前端一直 WAITING

现象:用户扫码并确认授权了,但你的站点页面一直停在“等待扫码”,轮询接口始终返回 WAITING。

原因:微信回调打到了后端,但你的回调接口在处理时抛了异常,比如 code 换 token 超时、用户信息字段解析失败。异常发生后没有把成功状态写入 Redis,前端自然永远等不到。另一个原因是回调成功写入了 Redis,但前端轮询时传的 state 和扫码时用的 state 不一致。

解决:给回调接口加上全局异常捕获,任何异常都要记录完整参数(code、state、原始响应)。同时要定义一个兜底逻辑:回调接口无论成功与否,都往 Redis 写一个状态,至少让前端知道“微信那边有反应了”,而不是无限停留在等待中。

6.5 同一个微信用户扫出两个账号

现象:用户第一次扫码登录后,换了一台设备或换了一个浏览器再扫码,系统给他创建了第二个账号。

原因:用户唯一标识选择错了。如果你只用 openid 做唯一键,而 openid 在不同应用下是不同值,就会导致同一个微信用户在不同入口(比如 PC 端和未来新增的小程序端)下被认为是两个人。如果你在开放平台绑定了多个应用,微信返回的 unionid 才是一致的。

解决:优先使用 unionid 作为用户本地唯一标识。如果发现有的用户没有 unionid,考虑做一个老数据迁移脚本,通过手机号绑定或用户自助关联等方式把历史账号合并掉。这一步在项目初期就要想清楚,否则上线半年后数据量大了很难清洗。

6.6 二维码过期后没有及时提示

现象:用户把二维码页面打开放在那里不去扫,过了几分钟再扫,提示“二维码已失效”。

原因:state 在 Redis 里设置了 5 分钟过期,但前端轮询接口没有对EXPIRED状态做响应处理,还在傻乎乎地轮询等 SUCCESS。

解决:前端轮询代码里判断返回状态,遇到EXPIRED就清除定时器,显示“二维码已过期,请点击刷新”。后端也可以把授权 URL 里的 state 有效期和二维码展示时间对齐,保证用户扫的时候 state 一定还在。这里前后端要约定好同一套状态码,我用的是纯字符串,也有人用 boolean + message 组合,但状态多了之后字符串枚举更清晰。

7. 进阶技巧:扫码后自动注册与“无缝登录”的引导设计

扫完码之后,用户的动作路径一般是:在手机微信上确认授权、回到电脑页面、看到正在登录、进入系统首页。但有个隐性场景很容易被忽略:老用户之前用账号密码注册过,现在第一次使用微信登录,系统自动给他创建了新账号,导致用户打开首页发现“我的订单”全没了——他原来的账号里才有订单。

这时候“自动注册”不能是默认策略,更合理的设计是“优先匹配,匹配不到再引导绑定”。做法是:微信回调拿到 unionid 后,先查本地用户表,如果命中就直接登录;如果没命中,不要立刻创建新用户,而是进入一个“首次微信登录”的过渡状态,让用户在页面上选择“直接注册新账号”或“绑定已有账号”。绑定已有账号通常需要输入手机号加验证码,验证通过后把微信的 unionid 更新到老用户记录上。第一次扫码与用户耐心的黄金窗口只有几分钟,绑定流程越短越好——我见过一个项目把绑定做成了五步表单,结果一半用户卡在中途流失了。

另一个值得动手优化的是扫码成功回跳时机。微信默认在授权完成后展示“授权成功”页,用户需要手动回到你的站点。如果站点能在检测到登录成功后,把“正在进入系统”的过渡提示放在二维码弹窗旁边的主页面里,用户体验会比“自己切窗口”顺滑很多。前端轮询一旦返回 SUCCESS,立即用window.opener或window.parent把登录结果传回主窗口并关闭弹窗,这就是“无缝登录”的核心实现。弹窗路线用window.open,iframe 路线用postMessage,两者代码路径要分开保持清洁。

还有一个小技巧:扫码登录的二维码 URL 可以在后端缓存起来按用户维度复用,减少每次刷新生成新 state 的冗余。比如用户在某一个页面停留时,二维码的授权 URL 生成过一次后 10 分钟内不变,用户刷新二维码时前端直接请求同一个 URL 而不是重新生成,这样轮询接口一直拿着同一个 state 就能连续工作。但如果每次都重新生成 state,前端需要同步更新 state,否则旧轮询会一直打到空的 Redis key 上。这个细节看似不起眼,却是很多扫码偶发无效的根源。

我在项目里把扫码登录的 state 管理做成了一个独立的 Redis Service 类,所有读写都走这一个类,避免多个 Controller 各写各的导致 key 命名混乱。后来接入小程序登录时,直接复用这个 Service,只是换了 appid 和授权来源,改动量很小。建议你也把“微信登录”和“业务登录”两层拆开——微信层只负责认证,业务层只负责会话——这样以后想再接抖音扫码或支付宝扫码,都只改最上面一层,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询