
接了个微信公众号后端的私活对方一开口就问“你们后端用PHP还是Java”。我说Java对面明显愣了一下。说实话在公众号开发这个领域PHP的教程确实铺天盖地但用Java做公众号后端一点不冷门尤其是近年Spring Boot把配置和部署成本打下来之后Java后端的开发效率早就不是当年SSH时代的样子了。这篇文章把我用Spring Boot集成WxJava做微信公众号后端从0到1的完整过程捋一遍方案选型、接入配置、消息路由、网页授权、模板消息、生产环境踩坑全部基于真实跑通的代码和线上经验。适合正在接公众号项目、或者公司需要自建公众号服务的Java后端同学参考。1. 为什么是Spring Boot WxJava方案选型的底层逻辑1.1 自己封装微信API的四个坑很多Java开发者拿到公众号需求后的第一反应是自己写HTTP调用反正微信API就是一堆REST接口嘛。真正做起来才知道光一个AccessToken就能折腾掉一半的精力token有效期只有7200秒过期要刷新刷新前要判断是否过期而且微信对AccessToken的获取次数有严格限制每天上限10万次单小时调用次数也有约束一旦多实例部署没做好token共享A实例刷新了tokenB实例还在用旧token请求直接就401了。这还只是第一个坑。第二个坑是消息签名和加解密。微信服务器回调你的接口时URL上带的signature参数需要你用token、timestamp、nonce做字典排序然后SHA1加密比对消息体如果是安全模式还得做AES加解密密钥是43位Base64编码的EncodingAESKey加解密的过程涉及到PKCS7填充、AES-256-CBC、消息体XML拼接和拆分。这些代码自己写也能写但网上抄来的版本往往只覆盖了明文模式切到安全模式就莫名其妙报错而且报错信息基本靠猜。第三个坑是消息类型和事件类型的解析微信的消息/事件有几十种类型文本、图片、语音、视频、短视频、地理位置、链接、关注/取关、扫码、菜单点击、模板消息送达、群发结果……每种都有自己的XML结构。自己维护一个XML到Java对象的映射体系工作量和后续维护成本都不低。第四个坑是接口的HTTP调用细节比如素材上传用的不是普通JSON而是multipart/form-data下载素材是的网络流要自行关闭网页授权code换token时要拼接正确的grant_type参数凡是涉及网络IO的地方都有各种边界条件。1.2 WxJava到底帮你省了什么WxJavaweixin-java这个开源项目把这些乱七八糟的细节全部封装掉了。它不是一个单一模块而是一个按公众号类型拆分的多模块项目weixin-java-mp管公众号订阅号服务号weixin-java-miniapp管小程序weixin-java-open管开放平台weixin-java-cp管企业微信。做公众号后端引入weixin-java-mp就够了。用上WxJava之后AccessToken的获取和刷新是内置的默认实现基于本地内存也提供了Redis存储的实现类多实例部署直接换一个存储实现就行不用自己写缓存逻辑。消息加解密内置了WxMpCryptUtil签名校验一行代码搞定。几十种消息类型全部映射成了WxMpXmlMessage直接set属性就能读。素材上传、模板消息、客服消息、菜单管理、用户管理、数据分析这些接口都封装成了Service方法。最实用的还是它支持路由模式WxMpMessageRouter会把不同类型的消息和事件自动分发给对应的Handler代码组织起来非常干净。1.3 技术栈与依赖引入我的实际技术栈是Spring Boot 2.7.x WxJava 4.5.xJDK用的1.8这个搭配非常稳。Spring Boot 3.x也支持WxJava但如果你跟我一样有大量老项目依赖要兼容2.7更省心。Maven引入方式如下dependency groupIdcom.github.binarywang/groupId artifactIdweixin-java-mp/artifactId version4.5.0/version /dependencyWxJava从4.x开始把weixin-java-mp里面的子模块拆得更细了正常引入这一个坐标就够了它会把wx-java-mp-common、weixin-java-common这些依赖自动带过来。如果只需要某几个功能可以按需引用weixin-java-mp的特定模块但绝大多数场景下直接引全量包没什么问题。配置方面在application.yml里维护一份基础配置wx: mp: app-id: wx1234567890abcdef secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx token: myToken aes-key: xxxxx然后在配置类里实例化WxMpService和WxMpMessageRouterConfiguration public class WxMpConfig { Bean public WxMpService wxMpService(WxMpProperties properties) { WxMpDefaultConfigImpl config new WxMpDefaultConfigImpl(); config.setAppId(properties.getAppId()); config.setSecret(properties.getSecret()); config.setToken(properties.getToken()); config.setAesKey(properties.getAesKey()); WxMpServiceImpl service new WxMpServiceImpl(); service.setWxMpConfigStorage(config); return service; } }WxMpDefaultConfigImpl是WxJava提供的基于本机内存的配置存储实现单机部署够用。如果服务部署在多台机器上后面第六部分会讲到用Redis缓存方案。这套依赖和Bean配置是整个项目的地基地基打好了后面所有功能都是在WxMpService这棵树上长出来的分支。2. 公众号后台接入从配置到第一个握手协议2.1 公众平台侧的前置配置清单在写代码之前先在微信公众平台mp.weixin.qq.com后台把账号和权限准备好。一个小细节个人主体的订阅号很多接口权限是受限的比如模板消息、网页授权里的用户详细信息获取这些都需要服务号或者已认证的订阅号才开放。如果公司预算充足直接申请服务号接口权限完整度差别很大。在“设置与开发 - 基本配置”页面你需要准备三样东西AppID应用的唯一标识、AppSecret调用接口的密钥生成后只显示一次一定要自己存好、服务器配置里的URL、Token和EncodingAESKey。URL就是你的后端接口地址必须是公网可访问的HTTP服务微信只支持80端口和443端口而且域名必须完成ICP备案。Token是你自己定义的一串字母数字随便起但后面代码里要保持一致。EncodingAESKey可以在页面上随机生成也可以自己填43位字符。填好这些点提交微信服务器会往你的URL上发一个GET请求做验证参数是signature、timestamp、nonce、echostr。你的接口需要正确校验signature并原样返回echostr才能验证通过。很多人第一次提交服务器配置失败十有八九是Token不一致或者接口还没有发布到公网。2.2 服务器配置与签名校验代码实现接入验证的Controller长这样RestController RequestMapping(/wx/portal/{appId}) public class WxPortalController { GetMapping(/receive) public String auth(PathVariable String appId, RequestParam(signature) String signature, RequestParam(timestamp) String timestamp, RequestParam(nonce) String nonce, RequestParam(echostr) String echostr) { if (wxMpService.checkSignature(timestamp, nonce, signature)) { return echostr; } return invalid signature; } }checkSignature方法内部会把token、timestamp、nonce三个参数按字典序排序拼接成一个字符串做SHA1加密然后和微信传来的signature比对。这里有个容易被忽略的点如果你的项目里面没有配置tokenWxMpDefaultConfigImpl里的token就是空的checkSignature永远返回false联调的时候就会一直提示“服务器配置未生效”排查半天全是这个低级原因。POST请求才是真正接收消息和事件的入口同一个URL、同一个Controller方法但方法签名上既有GET又有POST。我习惯用RequestMapping(value /receive, method {RequestMethod.GET, RequestMethod.POST})来同时处理两种情况或者干脆写两个方法指向同一个路径。注意在POST请求里不需要返回echostr而是返回XML格式的响应消息如果不主动回复返回空字符串或success也行。2.3 回调URL的常见陷阱回调地址上有几个细节我踩过之后才彻底搞清楚第一URL路径不能太浅也不要太深。太浅容易和其他模块冲突太深影响可读性我习惯用/wx/portal/{appId}/receive这种结构因为一个服务可能同时对接多个公众号比如总公司服务号分公司服务号用appId做路径变量可以在入口处就直接定位配置文件。第二微信回调时URL上带的参数的顺序是固定的signature、timestamp、nonce、echostr但实际GET请求还可能带有别的参数比如当你在后台设置了jsapi安全域名后回调会带一个encrypt_type参数。你的代码参数如果写死成四个字段遇到多余参数会直接报错所以Controller入参建议全部用RequestParam(required false)兜底或者用一个Map把所有参数接住。第三微信要求开发者服务器在5秒内响应超时的话微信会重试三次重试的请求会带一个不同的MsgId所以接收消息时要做消息去重不然用户发一条消息你的业务逻辑可能被触发四次。这个后面在消息路由部分细说。3. 消息与事件闭环用Router把请求治理成业务3.1 消息路由的分发逻辑服务器配置验证通过之后公众号就真正跑起来了。用户给你发消息、关注公众号、点击菜单微信都会往你的POST接口推数据。如果靠if-else去判断消息类型代码会越来越难以维护。我用WxMpMessageRouter做了分发这是WxJava里最值得用的一个设计。先初始化Router绑上HandlerConfiguration public class WxMpRouterConfig { Bean public WxMpMessageRouter wxMpMessageRouter(WxMpService wxMpService, TextMessageHandler textHandler, SubscribeHandler subscribeHandler) { WxMpMessageRouter router new WxMpMessageRouter(wxMpService); router.rule().async(false) .msgType(WxConsts.XmlMsgType.TEXT) .handler(textHandler) .end(); router.rule().async(false) .event(WxConsts.EventType.SUBSCRIBE) .handler(subscribeHandler) .end(); return router; } }然后在Controller的POST方法里统一入口PostMapping(/receive) public String receive(RequestBody String requestBody, RequestParam(signature) String signature, RequestParam(timestamp) String timestamp, RequestParam(nonce) String nonce) { if (!wxMpService.checkSignature(timestamp, nonce, signature)) { return invalid signature; } WxMpXmlMessage inMessage WxMpXmlMessage.fromXml(requestBody); WxMpXmlOutMessage outMessage wxMpMessageRouter.route(inMessage); return outMessage null ? success : outMessage.toXml(); }Router的rule可以链式配置多个条件比如msgType加event加content正则一起用精确匹配某类业务消息。handler里就是你的业务逻辑返回WxMpXmlOutMessage作为被动回复消息。这个结构的核心价值是把“收到什么消息”和“做什么处理”解耦了新增一种消息类型就是新增一个Handler类老代码一行都不用改。3.2 关注/取关事件与菜单点击的处理关注事件是公众号运营里最高频的事件之一。用户扫二维码关注、搜索公众号关注、别人分享名片关注微信服务器都会推送subscribe事件。处理关注事件的第一件事是判断这个用户是通过什么渠道关注的。如果关注动作发生在扫描带参数二维码之后XML里会带一个EventKey字段格式是qrscene_开头的参数这里的参数可以在生成带参二维码时自定义。很多裂变活动就是靠这个参数识别渠道来源的。取关事件是unsubscribe微信平台有个限制取关事件的推送中用户的openId依然是可用的但你无法主动给已取关用户发消息。所以取关事件一般只用来做用户状态标记不建议做太重的清理逻辑。菜单点击事件分为两种click类型点击菜单按钮触发和view类型跳转链接微信直接打开URL不会推送事件给你。click类型的事件会带一个EventKey这个值就是你配置菜单时填的key字段。我在实践中会把菜单key设计成和业务动作强相关的编码比如AUTHORIZE_BIND、OPEN_COUPON、CHECK_ORDERHandler里拿到key直接做switch分发比再去数据库查一遍菜单配置要快得多。3.3 被动回复与主动推送的边界微信公众号的消息机制对新手来说最容易绕晕的就是“被动回复”和“主动推送”的区别。用户给你发消息你需要在5秒内回复这叫被动回复。如果你5秒内没回复或者压根没有消息触发你想主动给用户发一条消息这就属于主动推送主动权在微信手里服务号每个月有4条群发额度认证服务号是每月4次订阅号每天可以群发1次而且这些推送只能通过模板消息、客服消息、群发消息三个通道实现。客服消息比较特殊它要求用户48小时内和公众号有过互动才能发这就是为什么很多公众号在你发消息之后会立刻自动回复“回复1获取xxx”本质是为了激活48小时会话窗口为后续的客服消息推送创造条件。弄清楚了这层机制后端设计上就能避开很多坑用户触发的业务逻辑里不要直接同步调客服消息接口因为客服消息有48小时限制如果用户上次互动已经超过48小时调用会报45047错误。建议做成先展示被动回复同时把推送任务丢进消息队列异步判断用户可推性再执行推送。4. 网页授权与用户身份打通4.1 OAuth2授权链路的实现公众号里经常要做H5页面比如用户点菜单打开一个活动页页面要显示微信昵称头像要拿到用户的openId做绑定。这就要用到网页授权。网页授权有两种scopesnsapi_base静默授权拿openId不弹授权框和snsapi_userinfo用户授权弹出确认框可以拿到昵称、头像、性别、地区等详细信息。snsapi_base适用于只想识别用户身份的场景snsapi_userinfo适用于需要展示用户资料的场景。注意snsapi_userinfo只有在服务号或已认证订阅号下才能拿到非匿名信息。实现流程上用WxJava非常简单String redirectUrl https://your-domain.com/wx/user/profile; String authorizeUrl wxMpService.getOAuth2Service().buildAuthorizationUrl( redirectUrl, WxConsts.OAuth2Scope.SNSAPI_USERINFO, state123 ); // 跳转到authorizeUrl用户授权后微信会302跳回redirectUrl并带上code和state参数。后端拿code换用户信息WxMpOAuth2AccessToken token wxMpService.getOAuth2Service().getAccessToken(code); String openId token.getOpenId(); WxMpUser user wxMpService.getOAuth2Service().getUserInfo(token, zh_CN);到这里有个关键点要提一下在上面buildAuthorizationUrl里回调URL必须是微信公众号后台“网页授权域名”配置的域名下的地址而且不能带端口号http://ip:8080这种直接不行。很多人在本地联调时发现跳不过去就是这个原因。解决思路是本地配hosts把线上域名解析到本地或者开发环境单独配一套公网转发服务。4.2 UnionID与OpenID的取舍OpenID是用户在当前公众号下的唯一标识同一个用户在不同公众号下openId不同。如果公司有服务号小程序网站等多端业务需要识别“同一个人”就必须用UnionID。UnionID在开放平台open.weixin.qq.com下绑定同一主体的公众号和小程序后才会生成openId和unionId的对应关系是有微信官方维护的。WxJava里获取用户信息接口返回的WxMpUser对象里同时包含openId和unionId所以建议在用户表结构里把两个字段都存上业务关联用unionId消息推送用openId。5. 模板消息、客服消息与素材管理5.1 模板消息推送模板消息是公众号触达用户最重要的通道。它的特点是消息由固定模板动态参数组成在后台申请模板审核通过后得到模板ID发送时必须传入用户openId、模板ID、跳转URL和参数值。后端代码实现WxMpTemplateMessage templateMsg WxMpTemplateMessage.builder() .toUser(openId) .templateId(模板ID) .url(https://your-domain.com/order/detail/123) .build(); templateMsg.addData(new WxMpTemplateData(orderId, 20240112001, #000000)); templateMsg.addData(new WxMpTemplateData(status, 已发货, #FF0000)); wxMpService.getTemplateMsgService().sendTemplateMsg(templateMsg);注意几个细节模板里的字段名必须和模板内容完全一致后面的颜色参数不是必填的大多数场景不用自定义颜色url参数不填的话用户点击模板消息不会跳转但公众号规定模板消息必须带点击跳转能力所以不建议省略。还有一点模板消息的发送存在频控单个用户每分钟最多收到一条模板消息不同模板相同用户也占用频控如果业务有高频推送需求要考虑聚合发送而不是逐条发。5.2 客服消息与素材上传客服消息接口用于在48小时互动窗口内主动推消息给用户支持文本、图片、语音、视频、图文、小程序卡片等类型。发图片和语音之前需要先上传素材拿到mediaIdWxJava封装的素材上传方法File file new File(/path/to/image.jpg); WxMediaUploadResult result wxMpService.getMaterialService() .mediaUpload(WxConsts.MediaType.IMAGE, file); String mediaId result.getMediaId();mediaType可以传IMAGE、VOICE、VIDEO、THUMB图片素材会经过微信的压缩和尺寸限制校验大于2MB或者宽高比例不正常的图片可能上传失败处理办法是先本地压缩再传。永久素材接口mediaUpload和mediaUploadImg的区别经常有人搞混mediaUpload上传的是永久素材有数量上限图片上限5000个mediaUploadImg上传的是文章内图片素材不计入素材库数量配额返回的URL可以直接放在图文消息正文里使用。图文消息news的发送稍微特殊一点需要先构造WxMpNews然后调getMaterialService().materialNewsUpload一次可以传10篇以内上传成功后得到一个mediaId再通过客服消息或群发消息推送出去。6. 生产环境落地多账号、缓存与性能6.1 多公众号场景的配置管理很多公司的公众号不止一个集团服务号、分公司订阅号、活动号一套代码要全部接住。WxJava为此提供了多账号配置方案。核心思路是实例化多个WxMpService每个Service对应一个公众号或者用WxMpMultiConfigStorage配合路由。我在项目中用的是比较朴素的方式定义一个WxMpProperties用Map接收多套配置wx: mp: configs: - app-id: wxAAAA secret: xxx token: t1 - app-id: wxBBBB secret: yyy token: t2启动时遍历配置为每个appId创建独立的WxMpService放入Map入口Controller已经按appId路径区分了直接get对应的Service处理即可。这样做的优势是逻辑清晰每个公众号的配置和缓存独立不会出现互相覆盖token的情况。6.2 AccessToken缓存与刷新策略前面说了单机内存缓存够用但到了多实例部署WxJava默认的内存缓存就有问题实例A刷新了一个token实例B还是旧token两个token互相覆盖会导致接口调用频繁报40001。生产环境需要把token缓存统一放到Redis。WxJava提供了Redis缓存实现类用的是Redisson。引入Redisson依赖后Config redissonConfig new Config(); redissonConfig.useSingleServer().setAddress(redis://your-host:6379); Redisson redisson (Redisson) Redisson.create(redissonConfig); RedisTemplateString, String redisTemplate new RedisTemplate(); WxMpRedisConfigImpl config new WxMpRedisConfigImpl(redisson);WxMpRedisConfigImpl会把accessToken、jsapiTicket等各类凭据统一存到Rediskey和ttl都由框架管理。这里有个提醒如果你的Redis集群没有配置密码会有一个安全风险至少也要限制IP访问不要把Redis裸奔在公网上。6.3 接口超时与异步处理的取舍微信的5秒响应限制是绕不开的硬指标。Controller入口方法里消息进来之后不能在里面做数据库慢查询、调第三方接口、发短信等耗时操作。我的习惯是在Handler里把核心业务判断做完立即返回success同时把消息内容塞进线程池或消息队列异步消费。纯异步派发要注意消息丢失问题如果服务在异步任务执行前重启消息就丢了。稳妥的方案是把消息先落库或者进MQ确认投递消费端做好幂等这样即使哪一步挂了MQ重试也能补上。另外WxMpMessageRouter的rule有个async参数设为true可以让这个规则异步执行但异步执行时Handler返回的WxMpXmlOutMessage不会发给微信因为响应已经返回了所以需要异步回复的场景要配合客服消息来做或者先在Handler里同步返回一个友好的提示再异步做业务推送。7. 踩坑实录这些问题我帮你们趟过了7.1 域名与IP白名单问题微信接口调用里有两个白名单概念一个是“IP白名单”指的是调用接口的服务器IP这个IP必须在公众平台后台配置否则所有接口调用都会报40164另一个是“网页授权域名”和“JS接口安全域名”指的是前端页面所在的域名。这两个东西经常搞混。我在一次上线中把服务器IP加进了白名单但网页授权一直跳转失败排查半天才发现网页授权域名填的是内网域名微信回调根本打不到内网。记住网页授权域名必须是公网可访问的完整域名不需要带http://但必须是备案域名。7.2 消息加解密的模式选择公众号消息有三种模式明文模式、兼容模式、安全模式。明文模式消息体和响应XML都是明文的兼容模式是明文的XML里附带了加密字段安全模式则全部加密。切换模式必须在公众平台后台操作代码里需要正确配置EncodingAESKey并设置WxMpService的加密开关。WxJava里的处理方式是配置好aesKey之后默认会同时支持明文和密文的自动识别因为它在解析XML之前会判断encrypt_type参数。但Code里有个小坑如果你在后台选择了安全模式而代码中WxMpService的ConfigStorage没有设置AesKey比如漏配了就会报出解密失败异常。我的建议是不管是明文还是安全模式都把aesKey配好这样随时切换模式都能正常收发。7.3 测试号与线上号的区别很多入门教程会用微信公众平台的测试号演示开发测试号不用申请、接口权限全开确实方便。但测试号有两个问题第一测试号的appid和secret只在测试号管理页面生效和正式的订阅号/服务号不互通第二测试号的服务器配置地址只能填到微信测试号管理页面配置的地方很多人误把测试号配置拿去做正式号接入发现怎么都验签失败。我的建议是学习阶段用测试号没问题但项目上线前一定要回归一遍正式号的接入流程尤其是域名备案、IP白名单、网页授权域名这些需要正式资质的配置测试号里往往不会暴露问题。7.4 前端联调时的跨域问题公众号里的H5页面如果是前后端分离架构前端Vue页面调用后端接口时必然涉及跨域。这里的跨域和普通Web项目还不一样页面运行在微信内置浏览器里微信浏览器基于Chromium内核对CORS的标准支持是没问题的但要注意微信公众号的JS-SDK签名是依赖当前页面URL的如果你用前端devServer代理URL变成localhost:8080签名的URL参数就跟线上不一致导致wx.config报错。解决方案是在开发环境里让前端访问的域名和线上保持一致通过本地hosts映射nginx代理而不是依赖webpack的proxy这样JS-SDK签名才能稳定通过。还有一个跨域细节如果你的后端设置了全局CORS过滤器注意放行前端域名时要精确匹配不要用通配符因为微信JS-SDK的部分接口调用的Referer校验很严格Access-Control-Allow-Origin如果带会导致部分浏览器行为异常。7.5 消息去重与幂等设计微信的机制是消息推送失败会重试重试会生成新的MsgId实际上重试的消息MsgId是一样的公众号文档里说“重试的消息MsgId与原始消息相同”。无论如何你必须在消费侧做幂等。我的做法是在消息入库时给msgId加唯一索引重复消息直接丢弃异步任务处理时用业务订单号做分布式锁确保同一订单的推送任务不重复执行。这套设计上线以后消息重试带来的重复业务问题基本绝迹了。最后再分享一个我在项目落地时的体会公众号后端开发真正的难点从来不是某个API怎么调而是一整套消息机制、权限边界、频控约束的组合。WxJava把这些API的边界条件都封装好了但开发者还是要理解微信背后的设计逻辑为什么有48小时客服窗口、为什么模板消息有频控、为什么AccessToken要全局共享。把这些机制想透遇到问题排查起来会顺手很多。希望这篇实战记录能让你在接到类似需求时少走几步弯路。