Spring Boot 微信公众号后端服务开发实战指南

文章目录

    • 一、写在前面
    • 二、技术架构
      • [2.1 技术栈](#2.1 技术栈)
      • [2.2 系统整体架构](#2.2 系统整体架构)
      • [2.3 系统交互架构](#2.3 系统交互架构)
      • [2.3 项目模块结构](#2.3 项目模块结构)
    • 三、开发步骤详解
      • [3.1 配置公众号服务器](#3.1 配置公众号服务器)
      • [3.2 配置 application.yml](#3.2 配置 application.yml)
      • [3.3 服务器地址验证(GET 请求)](#3.3 服务器地址验证(GET 请求))
      • [3.4 接收消息推送(POST 请求)](#3.4 接收消息推送(POST 请求))
      • [3.5 解析 XML 消息](#3.5 解析 XML 消息)
      • [3.6 消息类型分发处理](#3.6 消息类型分发处理)
      • [3.7 分阶段自动回复(核心功能)](#3.7 分阶段自动回复(核心功能))
      • [3.8 生成小程序 URL Link](#3.8 生成小程序 URL Link)
      • [3.9 数据表设计](#3.9 数据表设计)
    • 四、实战中遇到的问题与解决方案
      • [4.1 MySQL 低版本不支持 `ADD COLUMN IF NOT EXISTS`](#4.1 MySQL 低版本不支持 ADD COLUMN IF NOT EXISTS)
      • [4.2 SQL 客户端不支持存储过程 DELIMITER 语法](#4.2 SQL 客户端不支持存储过程 DELIMITER 语法)
      • [4.3 小程序 URL Link 接口返回 412 错误](#4.3 小程序 URL Link 接口返回 412 错误)
      • [4.4 Spring Boot 3.2 + MyBatis-Plus 启动异常](#4.4 Spring Boot 3.2 + MyBatis-Plus 启动异常)
    • 五、部署清单
      • [5.1 环境准备](#5.1 环境准备)
      • [5.2 配置检查](#5.2 配置检查)
      • [5.3 数据库初始化](#5.3 数据库初始化)
      • [5.4 公众号配置](#5.4 公众号配置)
    • 六、后续可扩展功能
    • 七、总结

版本说明:本文基于 Spring Boot 3.2.0、MyBatis-Plus 3.5.5、MySQL 5.7+、Java 17 编写,代码示例可直接运行于对应版本环境。

一、写在前面

本文解决什么问题

本文面向已有 Spring Boot 后端开发基础、需要快速接入微信公众号服务的开发者,完整记录从公众号服务器配置、签名验证、消息接收,到分阶段自动回复、小程序 URL Link 跳转的完整开发流程。

阅读本文后,你将能够:

  1. 独立完成公众号服务器配置与签名验证
  2. 实现消息接收、解析与分类处理
  3. 基于用户交互次数(interaction_count)实现分阶段自动回复
  4. 生成小程序 URL Link 并在回复中嵌入跳转链接
  5. 规避开发过程中常见的坑点(MySQL 兼容、412 错误等)

前置条件

  • Java 17 及以上版本
  • Spring Boot 3.2.x(与 MyBatis-Plus 3.5.5 兼容)
  • MySQL 5.7+(或 8.0)
  • 已注册微信公众号(订阅号/服务号均可)
  • 如需 URL Link 功能,需准备已认证的小程序

二、技术架构

2.1 技术栈

层级 技术选型 版本 说明
后端框架 Spring Boot 3.2.0 主体框架
ORM 框架 MyBatis-Plus 3.5.5 需确认与 Spring Boot 3.2 兼容
数据库 MySQL 5.7+ 生产环境建议使用 8.0
构建工具 Maven 3.8+ -
JDK OpenJDK 17 Spring Boot 3.x 最低要求
通信协议 HTTP/HTTPS + XML - 微信服务器交互协议

版本提示:Spring Boot 3.2 与 MyBatis-Plus 3.5.5 存在已知的兼容性问题,详见第 4.4 节。

2.2 系统整体架构

#mermaid-svg-3xnDShFB92HPENOK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3xnDShFB92HPENOK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3xnDShFB92HPENOK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3xnDShFB92HPENOK .error-icon{fill:#552222;}#mermaid-svg-3xnDShFB92HPENOK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3xnDShFB92HPENOK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3xnDShFB92HPENOK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3xnDShFB92HPENOK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3xnDShFB92HPENOK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3xnDShFB92HPENOK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3xnDShFB92HPENOK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3xnDShFB92HPENOK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3xnDShFB92HPENOK .marker.cross{stroke:#333333;}#mermaid-svg-3xnDShFB92HPENOK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3xnDShFB92HPENOK p{margin:0;}#mermaid-svg-3xnDShFB92HPENOK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3xnDShFB92HPENOK .cluster-label text{fill:#333;}#mermaid-svg-3xnDShFB92HPENOK .cluster-label span{color:#333;}#mermaid-svg-3xnDShFB92HPENOK .cluster-label span p{background-color:transparent;}#mermaid-svg-3xnDShFB92HPENOK .label text,#mermaid-svg-3xnDShFB92HPENOK span{fill:#333;color:#333;}#mermaid-svg-3xnDShFB92HPENOK .node rect,#mermaid-svg-3xnDShFB92HPENOK .node circle,#mermaid-svg-3xnDShFB92HPENOK .node ellipse,#mermaid-svg-3xnDShFB92HPENOK .node polygon,#mermaid-svg-3xnDShFB92HPENOK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3xnDShFB92HPENOK .rough-node .label text,#mermaid-svg-3xnDShFB92HPENOK .node .label text,#mermaid-svg-3xnDShFB92HPENOK .image-shape .label,#mermaid-svg-3xnDShFB92HPENOK .icon-shape .label{text-anchor:middle;}#mermaid-svg-3xnDShFB92HPENOK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3xnDShFB92HPENOK .rough-node .label,#mermaid-svg-3xnDShFB92HPENOK .node .label,#mermaid-svg-3xnDShFB92HPENOK .image-shape .label,#mermaid-svg-3xnDShFB92HPENOK .icon-shape .label{text-align:center;}#mermaid-svg-3xnDShFB92HPENOK .node.clickable{cursor:pointer;}#mermaid-svg-3xnDShFB92HPENOK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3xnDShFB92HPENOK .arrowheadPath{fill:#333333;}#mermaid-svg-3xnDShFB92HPENOK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3xnDShFB92HPENOK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3xnDShFB92HPENOK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3xnDShFB92HPENOK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3xnDShFB92HPENOK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3xnDShFB92HPENOK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3xnDShFB92HPENOK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3xnDShFB92HPENOK .cluster text{fill:#333;}#mermaid-svg-3xnDShFB92HPENOK .cluster span{color:#333;}#mermaid-svg-3xnDShFB92HPENOK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3xnDShFB92HPENOK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3xnDShFB92HPENOK rect.text{fill:none;stroke-width:0;}#mermaid-svg-3xnDShFB92HPENOK .icon-shape,#mermaid-svg-3xnDShFB92HPENOK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3xnDShFB92HPENOK .icon-shape p,#mermaid-svg-3xnDShFB92HPENOK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3xnDShFB92HPENOK .icon-shape .label rect,#mermaid-svg-3xnDShFB92HPENOK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3xnDShFB92HPENOK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3xnDShFB92HPENOK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3xnDShFB92HPENOK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 数据层
后端服务
微信生态
发送消息
返回回复
HTTP/HTTPS + XML
MyBatis-Plus
微信用户
微信服务器
WechatController

消息入口
SignUtil

签名验证
WechatServiceImpl

业务处理
MiniAppUtil

URL Link生成
WechatUtil

XML解析/构造
MySQL 5.7+

2.3 系统交互架构

MySQL数据库 Spring Boot 后端 微信服务器 微信用户 MySQL数据库 Spring Boot 后端 微信服务器 微信用户 #mermaid-svg-XZIU4FWz8aYdVTLD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XZIU4FWz8aYdVTLD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XZIU4FWz8aYdVTLD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XZIU4FWz8aYdVTLD .error-icon{fill:#552222;}#mermaid-svg-XZIU4FWz8aYdVTLD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XZIU4FWz8aYdVTLD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XZIU4FWz8aYdVTLD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XZIU4FWz8aYdVTLD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XZIU4FWz8aYdVTLD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XZIU4FWz8aYdVTLD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XZIU4FWz8aYdVTLD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XZIU4FWz8aYdVTLD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XZIU4FWz8aYdVTLD .marker.cross{stroke:#333333;}#mermaid-svg-XZIU4FWz8aYdVTLD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XZIU4FWz8aYdVTLD p{margin:0;}#mermaid-svg-XZIU4FWz8aYdVTLD .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XZIU4FWz8aYdVTLD text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-XZIU4FWz8aYdVTLD .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XZIU4FWz8aYdVTLD .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-XZIU4FWz8aYdVTLD .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-XZIU4FWz8aYdVTLD .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-XZIU4FWz8aYdVTLD #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-XZIU4FWz8aYdVTLD .sequenceNumber{fill:white;}#mermaid-svg-XZIU4FWz8aYdVTLD #sequencenumber{fill:#333;}#mermaid-svg-XZIU4FWz8aYdVTLD #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-XZIU4FWz8aYdVTLD .messageText{fill:#333;stroke:none;}#mermaid-svg-XZIU4FWz8aYdVTLD .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XZIU4FWz8aYdVTLD .labelText,#mermaid-svg-XZIU4FWz8aYdVTLD .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-XZIU4FWz8aYdVTLD .loopText,#mermaid-svg-XZIU4FWz8aYdVTLD .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-XZIU4FWz8aYdVTLD .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XZIU4FWz8aYdVTLD .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-XZIU4FWz8aYdVTLD .noteText,#mermaid-svg-XZIU4FWz8aYdVTLD .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-XZIU4FWz8aYdVTLD .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XZIU4FWz8aYdVTLD .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XZIU4FWz8aYdVTLD .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XZIU4FWz8aYdVTLD .actorPopupMenu{position:absolute;}#mermaid-svg-XZIU4FWz8aYdVTLD .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-XZIU4FWz8aYdVTLD .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XZIU4FWz8aYdVTLD .actor-man circle,#mermaid-svg-XZIU4FWz8aYdVTLD line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-XZIU4FWz8aYdVTLD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 解析XML消息 2. 判断消息类型 3. 根据交互次数回复 发送消息 POST /wx/callback (XML) 查询/更新用户数据 返回用户交互次数 回复XML消息 (文本/链接) 返回回复内容

交互流程说明:

  1. 微信用户向公众号发送消息
  2. 微信服务器将消息以 XML 格式 POST 推送到后端服务
  3. 后端服务解析 XML,根据 MsgType 分发到不同处理逻辑
  4. 处理过程中可能查询/更新 MySQL 中的用户交互次数
  5. 后端服务构造回复 XML 返回给微信服务器
  6. 微信服务器将回复内容展示给用户

2.3 项目模块结构

复制代码
src/main/java/com/example/wxservice/
├── WxServiceApplication.java      # 启动类
├── config/
│   └── WeChatConfig.java          # 微信公众号配置类(@ConfigurationProperties)
├── controller/
│   └── WechatController.java      # 消息接收入口(GET验证 + POST接收)
├── entity/
│   └── WxUser.java                # 用户实体(含 interaction_count 字段)
├── mapper/
│   └── WxUserMapper.java          # 用户数据访问(MyBatis-Plus BaseMapper)
├── service/
│   ├── WechatService.java         # 业务接口
│   └── impl/
│       └── WechatServiceImpl.java # 业务实现(核心逻辑:关注处理、消息分发、分阶段回复)
└── util/
    ├── MiniAppUtil.java           # 小程序 URL Link 生成(调用微信 API)
    ├── SignUtil.java              # 签名验证工具(SHA1 字典序排序)
    └── WechatUtil.java            # XML 解析、消息构造(CDATA 兼容)

三、开发步骤详解

3.1 配置公众号服务器

登录 微信公众平台 → 开发 → 基本配置,填写以下信息:

配置项 说明 示例值
服务器 URL 外网可访问的 HTTPS 地址 https://域名/wx/callback
Token 自定义令牌,用于签名验证 9DNI6IEfqN1lEVs5SvGLr5l5wCCtU
EncodingAESKey 消息加解密密钥(可选) 自动生成(明文模式可不填)
消息加密方式 明文/兼容/安全模式 明文模式(开发阶段推荐)

为什么需要 Token? Token 是微信服务器与后端服务之间的"暗号"。微信发送请求时会携带 signature(由 Token、timestamp、nonce 计算出的 SHA1 签名),后端通过相同的算法验证签名,确认请求确实来自微信官方服务器,防止恶意请求。

3.2 配置 application.yml

yaml 复制代码
# application.yml
server:
  port: 8088

spring:
  application:
    name: wx-service
  datasource:
    url: jdbc:mysql://localhost:3306/wx_service?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
    username: root
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver

# MyBatis-Plus 配置
mybatis-plus:
  mapper-locations: classpath*:mapper/**/*.xml
  type-aliases-package: com.example.wxservice.entity
  configuration:
    map-underscore-to-camel-case: true

# 微信公众号配置
wx:
  # 公众号配置(从微信公众平台 -> 开发 -> 基本配置获取)
  app-id: wx175ab95db5361a27
  app-secret: 85fba4e2d45a1987c1ab9e7e5d19aa03
  token: 9DNI6IEfqN1lEVs5SvGLr5l5wCCtU
  debug: true
  
  # 小程序配置(用于生成 URL Link 跳转小程序)
  mini-app-id: wx8bba9e3c31c52c86
  mini-app-secret: 0519397d042233f5b061d90ab0098503
  mini-app-path: pages/index/index

3.3 服务器地址验证(GET 请求)

微信首次配置服务器 URL 时,会发送 GET 请求进行地址有效性验证。

核心代码(Controller):

java 复制代码
@RestController
@RequestMapping("/wx")
public class WechatController {

    private final WechatService wechatService;

    /**
     * 服务器地址验证(GET)
     * 微信配置服务器URL时,会携带 signature、timestamp、nonce、echostr 参数访问此接口
     */
    @GetMapping("/callback")
    public String verifyServer(
            @RequestParam("signature") String signature,
            @RequestParam("timestamp") String timestamp,
            @RequestParam("nonce") String nonce,
            @RequestParam("echostr") String echostr) {
        
        String result = wechatService.verifyServer(signature, timestamp, nonce, echostr);
        return result != null ? result : "验证失败";
    }
}

签名验证算法(SignUtil)------核心原理:

java 复制代码
/**
 * 微信签名验证算法(SHA1)
 * 
 * 验证步骤:
 * 1. 将 token、timestamp、nonce 三个参数按字典序排序
 * 2. 将排序后的字符串拼接成一个字符串
 * 3. 对拼接后的字符串进行 SHA1 加密
 * 4. 比对加密结果与微信传来的 signature 是否一致
 */
public static boolean checkSignature(String token, String signature, 
                                     String timestamp, String nonce) {
    // 1. 字典序排序
    String[] arr = new String[]{token, timestamp, nonce};
    Arrays.sort(arr);
    
    // 2. 拼接字符串
    StringBuilder content = new StringBuilder();
    for (String s : arr) {
        content.append(s);
    }
    
    // 3. SHA1 加密
    String sha1 = sha1(content.toString());
    
    // 4. 比对签名
    return signature.equals(sha1);
}

签名验证流程图:
#mermaid-svg-7OZC8zTW4K6e2FY8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7OZC8zTW4K6e2FY8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7OZC8zTW4K6e2FY8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7OZC8zTW4K6e2FY8 .error-icon{fill:#552222;}#mermaid-svg-7OZC8zTW4K6e2FY8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7OZC8zTW4K6e2FY8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7OZC8zTW4K6e2FY8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7OZC8zTW4K6e2FY8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7OZC8zTW4K6e2FY8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7OZC8zTW4K6e2FY8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7OZC8zTW4K6e2FY8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7OZC8zTW4K6e2FY8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7OZC8zTW4K6e2FY8 .marker.cross{stroke:#333333;}#mermaid-svg-7OZC8zTW4K6e2FY8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7OZC8zTW4K6e2FY8 p{margin:0;}#mermaid-svg-7OZC8zTW4K6e2FY8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7OZC8zTW4K6e2FY8 .cluster-label text{fill:#333;}#mermaid-svg-7OZC8zTW4K6e2FY8 .cluster-label span{color:#333;}#mermaid-svg-7OZC8zTW4K6e2FY8 .cluster-label span p{background-color:transparent;}#mermaid-svg-7OZC8zTW4K6e2FY8 .label text,#mermaid-svg-7OZC8zTW4K6e2FY8 span{fill:#333;color:#333;}#mermaid-svg-7OZC8zTW4K6e2FY8 .node rect,#mermaid-svg-7OZC8zTW4K6e2FY8 .node circle,#mermaid-svg-7OZC8zTW4K6e2FY8 .node ellipse,#mermaid-svg-7OZC8zTW4K6e2FY8 .node polygon,#mermaid-svg-7OZC8zTW4K6e2FY8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7OZC8zTW4K6e2FY8 .rough-node .label text,#mermaid-svg-7OZC8zTW4K6e2FY8 .node .label text,#mermaid-svg-7OZC8zTW4K6e2FY8 .image-shape .label,#mermaid-svg-7OZC8zTW4K6e2FY8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-7OZC8zTW4K6e2FY8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7OZC8zTW4K6e2FY8 .rough-node .label,#mermaid-svg-7OZC8zTW4K6e2FY8 .node .label,#mermaid-svg-7OZC8zTW4K6e2FY8 .image-shape .label,#mermaid-svg-7OZC8zTW4K6e2FY8 .icon-shape .label{text-align:center;}#mermaid-svg-7OZC8zTW4K6e2FY8 .node.clickable{cursor:pointer;}#mermaid-svg-7OZC8zTW4K6e2FY8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7OZC8zTW4K6e2FY8 .arrowheadPath{fill:#333333;}#mermaid-svg-7OZC8zTW4K6e2FY8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7OZC8zTW4K6e2FY8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7OZC8zTW4K6e2FY8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7OZC8zTW4K6e2FY8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7OZC8zTW4K6e2FY8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7OZC8zTW4K6e2FY8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7OZC8zTW4K6e2FY8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7OZC8zTW4K6e2FY8 .cluster text{fill:#333;}#mermaid-svg-7OZC8zTW4K6e2FY8 .cluster span{color:#333;}#mermaid-svg-7OZC8zTW4K6e2FY8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7OZC8zTW4K6e2FY8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7OZC8zTW4K6e2FY8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-7OZC8zTW4K6e2FY8 .icon-shape,#mermaid-svg-7OZC8zTW4K6e2FY8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7OZC8zTW4K6e2FY8 .icon-shape p,#mermaid-svg-7OZC8zTW4K6e2FY8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7OZC8zTW4K6e2FY8 .icon-shape .label rect,#mermaid-svg-7OZC8zTW4K6e2FY8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7OZC8zTW4K6e2FY8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7OZC8zTW4K6e2FY8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7OZC8zTW4K6e2FY8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
微信服务器发送 GET 请求
携带参数

signature, timestamp, nonce, echostr
后端接收参数
提取 token, timestamp, nonce
按字典序排序
拼接成字符串
SHA1 加密
加密结果 == signature?
返回 echostr

验证通过
返回失败

验证不通过

3.4 接收消息推送(POST 请求)

用户发送消息后,微信服务器会向配置的 URL 发送 POST 请求,请求体为 XML 格式。

java 复制代码
/**
 * 接收微信消息推送(POST)
 * 
 * 注意:此接口的 Content-Type 是 application/xml,不是 application/json
 */
@PostMapping(value = "/callback", produces = "application/xml; charset=UTF-8")
public String receiveMessage(HttpServletRequest request) {
    try {
        // 读取请求体中的 XML 数据
        StringBuilder sb = new StringBuilder();
        BufferedReader reader = request.getReader();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line);
        }
        String xmlData = sb.toString();
        
        // 交给 Service 层处理
        return wechatService.handleMessage(xmlData);
    } catch (Exception e) {
        log.error("接收微信消息时发生错误", e);
        return "success"; // 即使异常也要返回 success,否则微信会重试
    }
}

3.5 解析 XML 消息

微信推送的 XML 消息示例:

xml 复制代码
<xml>
    <ToUserName><![CDATA[gh_xxx]]></ToUserName>
    <FromUserName><![CDATA[openid_xxx]]></FromUserName>
    <CreateTime>123456789</CreateTime>
    <MsgType><![CDATA[text]]></MsgType>
    <Content><![CDATA[你好]]></Content>
</xml>

解析方法------为什么需要兼容 CDATA?

微信的 XML 节点值包裹在 <![CDATA[...]]> 中,这是为了防止特殊字符(如 <、>、&)破坏 XML 结构。解析时需要先尝试 CDATA 格式,再尝试普通标签格式。

java 复制代码
/**
 * 解析 XML 消息中的节点值
 * 
 * 支持两种格式:
 * 1. CDATA 格式:<TagName><![CDATA[value]]></TagName>
 * 2. 普通格式:<TagName>value</TagName>
 */
public static String getXmlValue(String xml, String nodeName) {
    if (xml == null || xml.isEmpty()) {
        return null;
    }
    
    // 优先尝试 CDATA 格式
    String cdataStart = "<" + nodeName + "><![CDATA[";
    String cdataEnd = "]]></" + nodeName + ">";
    int startIndex = xml.indexOf(cdataStart);
    int endIndex = xml.indexOf(cdataEnd);
    if (startIndex != -1 && endIndex != -1) {
        return xml.substring(startIndex + cdataStart.length(), endIndex);
    }

    // 再尝试普通标签格式
    String startTag = "<" + nodeName + ">";
    String endTag = "</" + nodeName + ">";
    startIndex = xml.indexOf(startTag);
    endIndex = xml.indexOf(endTag);
    if (startIndex != -1 && endIndex != -1) {
        return xml.substring(startIndex + startTag.length(), endIndex);
    }

    return null;
}

3.6 消息类型分发处理

消息处理流程图:
#mermaid-svg-rhmKRgcgVKOFe1Tn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rhmKRgcgVKOFe1Tn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rhmKRgcgVKOFe1Tn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rhmKRgcgVKOFe1Tn .error-icon{fill:#552222;}#mermaid-svg-rhmKRgcgVKOFe1Tn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rhmKRgcgVKOFe1Tn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rhmKRgcgVKOFe1Tn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rhmKRgcgVKOFe1Tn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rhmKRgcgVKOFe1Tn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rhmKRgcgVKOFe1Tn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rhmKRgcgVKOFe1Tn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rhmKRgcgVKOFe1Tn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rhmKRgcgVKOFe1Tn .marker.cross{stroke:#333333;}#mermaid-svg-rhmKRgcgVKOFe1Tn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rhmKRgcgVKOFe1Tn p{margin:0;}#mermaid-svg-rhmKRgcgVKOFe1Tn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rhmKRgcgVKOFe1Tn .cluster-label text{fill:#333;}#mermaid-svg-rhmKRgcgVKOFe1Tn .cluster-label span{color:#333;}#mermaid-svg-rhmKRgcgVKOFe1Tn .cluster-label span p{background-color:transparent;}#mermaid-svg-rhmKRgcgVKOFe1Tn .label text,#mermaid-svg-rhmKRgcgVKOFe1Tn span{fill:#333;color:#333;}#mermaid-svg-rhmKRgcgVKOFe1Tn .node rect,#mermaid-svg-rhmKRgcgVKOFe1Tn .node circle,#mermaid-svg-rhmKRgcgVKOFe1Tn .node ellipse,#mermaid-svg-rhmKRgcgVKOFe1Tn .node polygon,#mermaid-svg-rhmKRgcgVKOFe1Tn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rhmKRgcgVKOFe1Tn .rough-node .label text,#mermaid-svg-rhmKRgcgVKOFe1Tn .node .label text,#mermaid-svg-rhmKRgcgVKOFe1Tn .image-shape .label,#mermaid-svg-rhmKRgcgVKOFe1Tn .icon-shape .label{text-anchor:middle;}#mermaid-svg-rhmKRgcgVKOFe1Tn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rhmKRgcgVKOFe1Tn .rough-node .label,#mermaid-svg-rhmKRgcgVKOFe1Tn .node .label,#mermaid-svg-rhmKRgcgVKOFe1Tn .image-shape .label,#mermaid-svg-rhmKRgcgVKOFe1Tn .icon-shape .label{text-align:center;}#mermaid-svg-rhmKRgcgVKOFe1Tn .node.clickable{cursor:pointer;}#mermaid-svg-rhmKRgcgVKOFe1Tn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rhmKRgcgVKOFe1Tn .arrowheadPath{fill:#333333;}#mermaid-svg-rhmKRgcgVKOFe1Tn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rhmKRgcgVKOFe1Tn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rhmKRgcgVKOFe1Tn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rhmKRgcgVKOFe1Tn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rhmKRgcgVKOFe1Tn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rhmKRgcgVKOFe1Tn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rhmKRgcgVKOFe1Tn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rhmKRgcgVKOFe1Tn .cluster text{fill:#333;}#mermaid-svg-rhmKRgcgVKOFe1Tn .cluster span{color:#333;}#mermaid-svg-rhmKRgcgVKOFe1Tn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rhmKRgcgVKOFe1Tn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rhmKRgcgVKOFe1Tn rect.text{fill:none;stroke-width:0;}#mermaid-svg-rhmKRgcgVKOFe1Tn .icon-shape,#mermaid-svg-rhmKRgcgVKOFe1Tn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rhmKRgcgVKOFe1Tn .icon-shape p,#mermaid-svg-rhmKRgcgVKOFe1Tn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rhmKRgcgVKOFe1Tn .icon-shape .label rect,#mermaid-svg-rhmKRgcgVKOFe1Tn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rhmKRgcgVKOFe1Tn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rhmKRgcgVKOFe1Tn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rhmKRgcgVKOFe1Tn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} event
text/voice/file
其他
微信用户发送消息
微信服务器 POST 推送
后端接收 XML 消息
解析 MsgType
消息类型?
handleEvent

关注/取消关注
handleTextMessage

分阶段回复
返回 success
构造回复 XML
结束
返回微信服务器
微信展示给用户

微信消息有多种类型(text、voice、image、event 等),需要根据 MsgType 进行分发:

java 复制代码
@Override
public String handleMessage(String xmlData) {
    String msgType = WechatUtil.getXmlValue(xmlData, "MsgType");
    String fromUser = WechatUtil.getXmlValue(xmlData, "FromUserName");
    String toUser = WechatUtil.getXmlValue(xmlData, "ToUserName");

    // 处理事件(关注、取消关注、扫码等)
    if ("event".equals(msgType)) {
        return handleEvent(xmlData, fromUser, toUser);
    }

    // 处理文本、语音、文档消息
    if ("text".equals(msgType) || "voice".equals(msgType) || "file".equals(msgType)) {
        return handleTextMessage(xmlData, fromUser, toUser);
    }

    // 其他类型消息,回复默认信息
    return "success";
}

3.7 分阶段自动回复(核心功能)

设计思路:

通过数据库 interaction_count 字段记录用户与公众号的交互次数,根据次数返回不同的回复内容:

阶段 触发条件 回复内容 业务目的
关注之后 用户首次关注 纯文本欢迎语 建立第一印象
第一次 interaction_count == 0 带小程序链接(报修/报装) 引导用户使用服务
第二次 interaction_count == 1 带链接 + 引导描述需求 收集用户需求
第 N 次 interaction_count >= 2 纯文本安抚 告知已收到反馈

分阶段自动回复流程图:
#mermaid-svg-7zZ81iVZdReSwCMg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7zZ81iVZdReSwCMg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7zZ81iVZdReSwCMg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7zZ81iVZdReSwCMg .error-icon{fill:#552222;}#mermaid-svg-7zZ81iVZdReSwCMg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7zZ81iVZdReSwCMg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7zZ81iVZdReSwCMg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7zZ81iVZdReSwCMg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7zZ81iVZdReSwCMg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7zZ81iVZdReSwCMg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7zZ81iVZdReSwCMg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7zZ81iVZdReSwCMg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7zZ81iVZdReSwCMg .marker.cross{stroke:#333333;}#mermaid-svg-7zZ81iVZdReSwCMg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7zZ81iVZdReSwCMg p{margin:0;}#mermaid-svg-7zZ81iVZdReSwCMg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7zZ81iVZdReSwCMg .cluster-label text{fill:#333;}#mermaid-svg-7zZ81iVZdReSwCMg .cluster-label span{color:#333;}#mermaid-svg-7zZ81iVZdReSwCMg .cluster-label span p{background-color:transparent;}#mermaid-svg-7zZ81iVZdReSwCMg .label text,#mermaid-svg-7zZ81iVZdReSwCMg span{fill:#333;color:#333;}#mermaid-svg-7zZ81iVZdReSwCMg .node rect,#mermaid-svg-7zZ81iVZdReSwCMg .node circle,#mermaid-svg-7zZ81iVZdReSwCMg .node ellipse,#mermaid-svg-7zZ81iVZdReSwCMg .node polygon,#mermaid-svg-7zZ81iVZdReSwCMg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7zZ81iVZdReSwCMg .rough-node .label text,#mermaid-svg-7zZ81iVZdReSwCMg .node .label text,#mermaid-svg-7zZ81iVZdReSwCMg .image-shape .label,#mermaid-svg-7zZ81iVZdReSwCMg .icon-shape .label{text-anchor:middle;}#mermaid-svg-7zZ81iVZdReSwCMg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7zZ81iVZdReSwCMg .rough-node .label,#mermaid-svg-7zZ81iVZdReSwCMg .node .label,#mermaid-svg-7zZ81iVZdReSwCMg .image-shape .label,#mermaid-svg-7zZ81iVZdReSwCMg .icon-shape .label{text-align:center;}#mermaid-svg-7zZ81iVZdReSwCMg .node.clickable{cursor:pointer;}#mermaid-svg-7zZ81iVZdReSwCMg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7zZ81iVZdReSwCMg .arrowheadPath{fill:#333333;}#mermaid-svg-7zZ81iVZdReSwCMg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7zZ81iVZdReSwCMg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7zZ81iVZdReSwCMg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7zZ81iVZdReSwCMg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7zZ81iVZdReSwCMg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7zZ81iVZdReSwCMg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7zZ81iVZdReSwCMg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7zZ81iVZdReSwCMg .cluster text{fill:#333;}#mermaid-svg-7zZ81iVZdReSwCMg .cluster span{color:#333;}#mermaid-svg-7zZ81iVZdReSwCMg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7zZ81iVZdReSwCMg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7zZ81iVZdReSwCMg rect.text{fill:none;stroke-width:0;}#mermaid-svg-7zZ81iVZdReSwCMg .icon-shape,#mermaid-svg-7zZ81iVZdReSwCMg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7zZ81iVZdReSwCMg .icon-shape p,#mermaid-svg-7zZ81iVZdReSwCMg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7zZ81iVZdReSwCMg .icon-shape .label rect,#mermaid-svg-7zZ81iVZdReSwCMg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7zZ81iVZdReSwCMg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7zZ81iVZdReSwCMg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7zZ81iVZdReSwCMg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
用户发送消息
查询用户 interaction_count
interaction_count == 0?
第一次回复

带小程序链接
set interaction_count = 1
interaction_count == 1?
第二次回复

引导描述需求
set interaction_count = 2
第N次回复

纯文本安抚
interaction_count + 1
返回回复内容

核心实现:

java 复制代码
private String handleTextMessage(String xmlData, String fromUser, String toUser) {
    // ...
    WxUser user = wxUserMapper.selectByOpenId(fromUser);
    int interactionCount = user.getInteractionCount() != null ? user.getInteractionCount() : 0;

    if (interactionCount == 0) {
        // 第一次:引导使用服务(带小程序链接)
        String reply = buildDefaultNewsReply(fromUser, toUser);
        user.setInteractionCount(1);
        wxUserMapper.updateById(user);
        return reply;
    } else if (interactionCount == 1) {
        // 第二次:引导描述需求
        String reply = buildSecondReply(fromUser, toUser);
        user.setInteractionCount(2);
        wxUserMapper.updateById(user);
        return reply;
    } else {
        // 第N次:安抚用户
        user.setInteractionCount(interactionCount + 1);
        wxUserMapper.updateById(user);
        return WechatUtil.buildTextMessage(fromUser, toUser, 
            "感谢您的反馈,稍后会有服务人员与您联系,请您关注公众号信息。");
    }
}

URL Link 生成流程图:
#mermaid-svg-ihNVeSPxSSjOj07a{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ihNVeSPxSSjOj07a .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ihNVeSPxSSjOj07a .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ihNVeSPxSSjOj07a .error-icon{fill:#552222;}#mermaid-svg-ihNVeSPxSSjOj07a .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ihNVeSPxSSjOj07a .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ihNVeSPxSSjOj07a .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ihNVeSPxSSjOj07a .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ihNVeSPxSSjOj07a .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ihNVeSPxSSjOj07a .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ihNVeSPxSSjOj07a .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ihNVeSPxSSjOj07a .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ihNVeSPxSSjOj07a .marker.cross{stroke:#333333;}#mermaid-svg-ihNVeSPxSSjOj07a svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ihNVeSPxSSjOj07a p{margin:0;}#mermaid-svg-ihNVeSPxSSjOj07a .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ihNVeSPxSSjOj07a .cluster-label text{fill:#333;}#mermaid-svg-ihNVeSPxSSjOj07a .cluster-label span{color:#333;}#mermaid-svg-ihNVeSPxSSjOj07a .cluster-label span p{background-color:transparent;}#mermaid-svg-ihNVeSPxSSjOj07a .label text,#mermaid-svg-ihNVeSPxSSjOj07a span{fill:#333;color:#333;}#mermaid-svg-ihNVeSPxSSjOj07a .node rect,#mermaid-svg-ihNVeSPxSSjOj07a .node circle,#mermaid-svg-ihNVeSPxSSjOj07a .node ellipse,#mermaid-svg-ihNVeSPxSSjOj07a .node polygon,#mermaid-svg-ihNVeSPxSSjOj07a .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ihNVeSPxSSjOj07a .rough-node .label text,#mermaid-svg-ihNVeSPxSSjOj07a .node .label text,#mermaid-svg-ihNVeSPxSSjOj07a .image-shape .label,#mermaid-svg-ihNVeSPxSSjOj07a .icon-shape .label{text-anchor:middle;}#mermaid-svg-ihNVeSPxSSjOj07a .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ihNVeSPxSSjOj07a .rough-node .label,#mermaid-svg-ihNVeSPxSSjOj07a .node .label,#mermaid-svg-ihNVeSPxSSjOj07a .image-shape .label,#mermaid-svg-ihNVeSPxSSjOj07a .icon-shape .label{text-align:center;}#mermaid-svg-ihNVeSPxSSjOj07a .node.clickable{cursor:pointer;}#mermaid-svg-ihNVeSPxSSjOj07a .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ihNVeSPxSSjOj07a .arrowheadPath{fill:#333333;}#mermaid-svg-ihNVeSPxSSjOj07a .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ihNVeSPxSSjOj07a .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ihNVeSPxSSjOj07a .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ihNVeSPxSSjOj07a .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ihNVeSPxSSjOj07a .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ihNVeSPxSSjOj07a .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ihNVeSPxSSjOj07a .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ihNVeSPxSSjOj07a .cluster text{fill:#333;}#mermaid-svg-ihNVeSPxSSjOj07a .cluster span{color:#333;}#mermaid-svg-ihNVeSPxSSjOj07a div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ihNVeSPxSSjOj07a .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ihNVeSPxSSjOj07a rect.text{fill:none;stroke-width:0;}#mermaid-svg-ihNVeSPxSSjOj07a .icon-shape,#mermaid-svg-ihNVeSPxSSjOj07a .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ihNVeSPxSSjOj07a .icon-shape p,#mermaid-svg-ihNVeSPxSSjOj07a .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ihNVeSPxSSjOj07a .icon-shape .label rect,#mermaid-svg-ihNVeSPxSSjOj07a .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ihNVeSPxSSjOj07a .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ihNVeSPxSSjOj07a .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ihNVeSPxSSjOj07a :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
接收 path 参数
path 包含 '?'?
分离 pagePath 和 query
pagePath = path

query =
获取 AccessToken
构造请求体
调用微信接口

wxa/generate_urllink
返回 URL Link

为什么需要分离 path 和 query?

微信 generate_urllink 接口要求 path 字段不能携带 query 参数 ,需要拆分为 path 和 query 两个字段分别传递。

java 复制代码
public String generateUrlLink(String path) {
    String accessToken = getAccessToken();  // 带本地缓存
    
    // 分离 path 和 query
    String pagePath = path;
    String query = "";
    int queryIndex = path.indexOf("?");
    if (queryIndex != -1) {
        pagePath = path.substring(0, queryIndex);
        query = path.substring(queryIndex + 1);
    }
    
    // 调用微信接口
    Map<String, Object> body = new HashMap<>();
    body.put("path", pagePath);
    body.put("query", query);
    body.put("is_expire", false);
    // ...
}

3.9 数据表设计

sql 复制代码
CREATE TABLE wx_user (
    id                  BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    open_id             VARCHAR(64) NOT NULL COMMENT '微信用户的OpenID(唯一标识)',
    nick_name           VARCHAR(128) DEFAULT NULL COMMENT '用户昵称',
    sex                 INT DEFAULT 0 COMMENT '性别 0:未知 1:男 2:女',
    city                VARCHAR(64) DEFAULT NULL COMMENT '用户所在城市',
    province            VARCHAR(64) DEFAULT NULL COMMENT '用户所在省份',
    country             VARCHAR(64) DEFAULT NULL COMMENT '用户所在国家',
    head_img_url        VARCHAR(512) DEFAULT NULL COMMENT '用户头像URL',
    subscribe_status    INT DEFAULT 1 COMMENT '关注状态 0:取消关注 1:已关注',
    subscribe_time      DATETIME DEFAULT NULL COMMENT '关注时间',
    unsubscribe_time    DATETIME DEFAULT NULL COMMENT '取消关注时间',
    interaction_count   INT DEFAULT 0 COMMENT '用户交互次数(用于自动回复规则)',
    create_time         DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_time         DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY uk_open_id (open_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号用户表';

四、实战中遇到的问题与解决方案

4.1 MySQL 低版本不支持 ADD COLUMN IF NOT EXISTS

现象:

复制代码
#1064 - You have an error in your SQL syntax

原因: ALTER TABLE ... ADD COLUMN IF NOT EXISTS 是 MySQL 8.0+ 语法,MySQL 5.7 不支持。

解决方案: 直接使用标准的 ALTER TABLE 语句:

sql 复制代码
ALTER TABLE wx_user ADD COLUMN interaction_count INT DEFAULT 0 
COMMENT '用户交互次数(用于自动回复规则)';

风险提示 :如果字段已存在会提示 Duplicate column name,可手动忽略。

4.2 SQL 客户端不支持存储过程 DELIMITER 语法

现象: 在 Navicat、phpMyAdmin 等图形化客户端中执行含 DELIMITER $$ 的存储过程时报错。

原因: 图形化 SQL 客户端通常不支持自定义定界符语法。

解决方案: 避免使用存储过程,直接执行简单的 ALTER TABLE 语句。如果必须使用存储过程,建议在 MySQL 命令行客户端中执行。

现象:

json 复制代码
{"errcode": 412, "errmsg": "..."}

可能原因:

  1. 小程序未通过微信认证
  2. 服务器 IP 未加入小程序 IP 白名单
  3. 接口权限未开通(需企业级主体)
  4. 请求参数格式错误(path 携带了 query 参数)

排查步骤:

  1. 检查小程序认证状态
  2. 在"开发 → 开发管理 → 开发设置"中添加服务器 IP 白名单
  3. 确认接口权限已开通
  4. 检查请求参数是否符合微信文档要求(path 与 query 分离)

4.4 Spring Boot 3.2 + MyBatis-Plus 启动异常

现象: 启动时报 factoryBeanObjectType 类型错误。

原因: MyBatis-Plus 3.5.5 与 Spring Boot 3.2.0 存在兼容性问题。

解决方案: 确保使用兼容 Spring Boot 3.2 的 MyBatis-Plus 版本,或在 pom.xml 中排除冲突依赖。

五、部署清单

5.1 环境准备

  • JDK 17 安装配置
  • MySQL 5.7+ 数据库服务
  • 公网可访问的服务器或内网穿透工具

5.2 配置检查

  • 公众号 AppID、AppSecret、Token 配置正确
  • 小程序 AppID、AppSecret 配置正确(如需 URL Link 功能)
  • 数据库连接配置正确
  • 服务器 IP 已加入小程序白名单

5.3 数据库初始化

  • 执行建表 SQL(新环境)
  • 执行 ALTER 语句添加新字段(已有数据环境)

5.4 公众号配置

  • 配置服务器 URL
  • 配置 Token
  • 启用服务器配置
  • 测试关注/取消关注事件
  • 测试文本消息回复
  • 测试语音/文档消息回复

六、后续可扩展功能

序号 扩展方向 核心说明
1 关键词自动回复(数据库化) 从静态代码改为后台动态配置,支持正则匹配和多种回复类型
2 模板消息推送 48 小时内主动触达用户,用于工单通知、活动提醒等
3 自定义菜单与事件处理 底部菜单配置,click/view/miniprogram 三种类型
4 用户画像与数据分析 记录交互日志,统计活跃用户数、消息分布、留存率等
5 消息队列(MQ)异步处理 引入 RabbitMQ/Kafka 削峰填谷,避免消息丢失
6 Redis 缓存优化 持久化 AccessToken、缓存用户信息、分布式锁
7 多公众号接入支持 抽象配置,按 ToUserName 路由不同公众号
8 消息加解密(安全模式) 启用 AES 加解密,提升传输安全性
9 客服消息系统 特定关键词转人工,工单分配、聊天记录、满意度评价
10 素材管理与多媒体消息 图片/语音/视频素材管理,智能识别回复

七、总结

本文从公众号服务器配置 → 签名验证 → 消息接收 → XML 解析 → 分阶段自动回复 → 小程序 URL Link 六个环节,完整梳理了微信公众号后端服务的开发流程。

核心要点回顾:

  1. 签名验证是安全保障:通过 Token + SHA1 确保请求来自微信官方
  2. interaction_count 是实现分阶段回复的关键:关注→第一次→第二次→第 N 次,形成完整的用户交互链路
  3. path 与 query 分离是调用微信 API 的常识 :generate_urllink 等多个接口都有此要求
  4. MySQL 5.7 兼容性需提前考虑:避免使用高版本语法

如果你在开发过程中遇到其他问题,欢迎在评论区交流。

相关推荐
BingoGo1 小时前
别再只用数组了,这 10 种 PHP 数据结构值得了解
后端·php
ss2731 小时前
基于springboot+vue+mysql「校园二手教材循环交易」小程序,三端全跑通
vue.js·spring boot·mysql
JaguarJack1 小时前
别再只用数组了,这 10 种 PHP 数据结构值得了解
后端·php·服务端
程序员海军10 小时前
用了两年多 GPT,我最近开始换模型了,还折腾了个求职避坑 Agent
前端·人工智能·后端
码士集团小青10 小时前
SpringBoot 集成缓存性能之王 Caffeine
spring boot·后端·缓存
excel12 小时前
IndexNow:让搜索引擎更快发现网站更新的主动通知协议
前端·后端·架构
Json____12 小时前
用 FastAPI + Vue3 打造一套完整的在线考试系统:从组卷到阅卷的业务闭环实践
后端·node.js·fastapi·it学习·wwwoop.com
小灰灰搞电子13 小时前
Rust+Slint 实现FTP客户端调试助手源码分享
开发语言·后端·rust
专业程序开发源14 小时前
springboot欧缇蔓月子中心管理系统82697-计算机课程设计、毕业设计
java·spring boot·后端·python·spring·django·课程设计