简介面向需要实现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 为空直接进 ServiceMyBatis-Plus用户表读写换成 JPA 也只是改一行spring-boot-starter-data-redis存 state 和本地 token单机也可以用内存 Map 顶替Lombok少写 getter/setter团队不喜欢可以不引MySQL Connector数据库驱动建表脚本在 resources/db 下对应的pom.xml关键片段dependencies !-- Web 与参数校验回调接口必须用得上 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- state 与本地 token 都放 Redis天然带过期时间 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 用户表持久层代码里用的是 MyBatis-Plus 的 BaseMapper -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies依赖说明里有两个取舍值得说。第一没引 HTTP 工具库直接用 Spring 自带的RestTemplate少一个坐标就少一次版本冲突第二state 和本地 token 都放 Redis不是因为性能而是因为「过期」这件事必须交给存储层做——用HashMap存 state 的话用户扫码停留超过十分钟脏数据会一直躺在内存里时间长了就是一颗定时炸弹。单机本地开发如果不想起 Redis可以写一个带ScheduledExecutorService清理的内存实现接口保持一致就行。2.3 三段式时序二维码、回调、换票据整个流程拆开只有三段理解这三段后面所有代码都能自己推出来。第一段是浏览器向业务后端要二维码地址。后端不是自己画二维码而是拼一个带appid、redirect_uri、scopesnsapi_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 ?fromweb这就直接导致后面的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_typecode scopesnsapi_login state state #wechat_redirect; } }逐项说清楚。state用 UUID 是图省事它的作用是防 CSRF 和防重放只要不可预测就行存 Redis 时把scene一起写进去是为了回调后知道这次登录是从 PC 首页还是从某个活动页发起的这个小设计在后期做渠道统计时非常好用。scopesnsapi_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() ?loginfailreasoncancel); return; } // 校验 state既不相等也不存在一律拒绝不给重放留口子 String sceneKey wx:state: state; String scene wxOpenService.consumeState(state); if (scene null) { response.sendRedirect(props.getFrontendUrl() ?loginfailreasonstate); 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和空判断体验会好很多前端也能根据reasoncancel显示一句「已取消登录」。第二处是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_typeauthorization_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_typeauthorization_code是固定值code直接拼在 URL 上因为它只包含字母数字和少数符号不需要额外编码但如果你的代码在别处对它做过一次编码这里就会报invalid code这是排查时的高频误区。restTemplate.getForObject(url, String.class)拿字符串再手动解析是这套接口最稳的写法具体原因放在第 5 章展开。3.3 本地用户落库与 token 签发拿到openid之后本地这一侧要做的事是「查不到就建查到就更新」然后把用户身份换成本地 tokenService 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 LambdaQueryWrapperSysUser().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 能直接删 keyJWT 还得再维护一个黑名单。在这个规模下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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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?scenepc返回的应该是一段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或者一个简单的MapString, LoginStrategy都行。这里的关键设计是「返回 null 表示需要绑定」这个约定它让上层逻辑不用关心具体策略只判断结果即可。验证方式建议用一组固定用例改完就跑一遍新 openid 第一次登录、同一 openid 第二次登录、绑定后再扫码、用户在手机端取消、state 过期后回调。这五个用例覆盖了绝大多数改坏的地方尤其是「取消」这一条很多人改完策略就忘了兜底分支线上直接 500。改造时还有一个容易被忽略的点sys_user表加字段要加默认值老数据迁移脚本要么一次性刷成NULL要么在代码里做兼容判断。我曾经在一次迁移里漏了这一步查询用了eq(open_id, xxx)结果历史用户全部查不到被当成新用户重新注册了一遍用户表里出现大量重复账号善后花了整个晚上。从那以后我每次改登录链路都强制先用五个用例在测试环境跑一遍再去碰线上配置宁可慢半天也不想再写一次数据订正脚本。希望这些踩过的点能帮你少走几步弯路。本文还有配套的精品资源点击获取