Logo
活死人の行知路

微信开放平台接入实战:打通授权事件与心跳回调


📅 | 📝 2272 字
#微信开发

本篇是《SCRM 系统接管多公众号实战指南》的上篇,只做一件事:把第三方平台与微信官方彻底接通——创建开放平台应用、配置参数、搭好授权事件与心跳回调的接收入口,直到微信推来的心跳票据能被稳定接住、component_access_token 能正常换取。

**下篇(业务接管)**见 → SCRM 系统接管多公众号实战指南


业务背景与场景定位

在 SCRM(客户关系管理)或者多商户 SaaS 平台中,核心需求是:让客户(包括企业自身或其他入驻商家)把他们的微信公众号一键托管到你的平台,由平台统一管理这些公众号的粉丝、消息、菜单和数据。

对于使用你平台的客户/运营人员而言,他们完全不需要懂技术,不需要登录微信公众平台后台去配置什么 URL、Token 或代码,全靠手机微信“扫码一键授权”完成接管。

但“代管”不是平台单方面能决定的——微信只认一种模式:开放平台·第三方平台。平台必须先被微信认可为第三方平台,微信才会把公众号的授权事件和粉丝消息推给它。

所以第一步不是写业务,而是打通:让微信服务器找得到我们、推得进来、并且推来的加密报文能解开。这一步没完成,连生成授权二维码要用的 component_access_token 都拿不到。

打通流程总览

落到操作上分三个阶段,三方各司其职:

sequenceDiagram autonumber actor Dev as 开发者 participant WXAdmin as 微信开放平台后台 participant BE as SCRM 后端服务 participant WX as 微信服务器 Note over Dev, WX: 阶段一:在微信后台把管道铺好 Dev->>WXAdmin: 创建第三方平台应用 + 声明权限集 WXAdmin-->>Dev: 返回 AppID / AppSecret Dev->>WXAdmin: 配置 IP 白名单、Token、EncodingAESKey Dev->>WXAdmin: 登记授权事件接收 URL、消息与事件接收 URL(均需公网可访问) Dev->>WXAdmin: 添加授权测试公众号白名单 Note over Dev, WX: 阶段二:后端服务接住微信推送 Dev->>BE: 把平台凭证配置进服务并启动 WX->>BE: 每 10 分钟推送一次心跳事件(加密报文) BE->>BE: 验签 → 解密 → 取出 component_verify_ticket BE->>BE: 缓存票据,供后续换取令牌 BE-->>WX: 回复 success Note over Dev, WX: 阶段三:换取平台令牌,打通完成 BE->>WX: 用票据换取 component_access_token WX-->>BE: 返回 component_access_token BE-->>Dev: ✅ 平台与微信彻底接通

微信只向平台推两类回调:这张图里的是授权事件(打通阶段就开始推心跳票据);另一类是消息与事件(被接管公众号的粉丝消息与交互),要等商户扫码授权之后才有数据,见下篇。

后文把这两条入口简称为授权事件入口和消息与事件入口(名字取自后台里的那两个接收 URL)。每个“入口”= 一条回调 URL + 它背后的验签、解密、分发。

走到 ✅ 这一步,心跳票据能被稳定接住、component_access_token 能正常换取——此时才谈得上扫码授权接管。


微信开放平台应用创建与参数配置

这一章对应总览里的阶段一——动作全在微信后台,不涉及一行代码。但它决定了微信会不会把事件推给我们:应用没建、回调 URL 没登记,阶段二、阶段三就无从谈起。所以先把它做完。

在编写任何业务代码前,必须先在 微信开放平台(注意:是开放平台 open.weixin.qq.com,不是公众平台 mp.weixin.qq.com)创建第三方平台应用主体并完成参数配置。

1.创建第三方平台应用与权限集声明

登录微信开放平台,进入「第三方平台」管理,点击「创建第三方平台」,填写平台基本信息并提交:

创建完成后,需要声明你的 SCRM 平台要代公众号行使哪些管理权限(如消息收发、自定义菜单、粉丝管理等):

  1. 点击「权限集」的【编辑】按钮,选择「公众号与服务号权限集」:
  2. 左上角「我的业务与服务」下拉选择「第三方平台」,绑定平台管理员个人微信号:
  3. 点击【添加权限集】,根据业务场景勾选所需权限:

创建并配置完权限后,平台进入详情管理页。接下来在开放平台后台把关键开发参数一一配置齐全:

[微信开放平台后台详情页 - 关键配置一览]
   ├── 1. 记下凭据:开发者ID (AppId) & 开发者密码 (AppSecret)
   ├── 2. 配置白名单:把本地电脑的公网出口 IP 加入【IP白名单】
   ├── 3. 基础安全配置:消息校验Token & 消息加解密Key (EncodingAESKey)
   ├── 4. 核心回调配置:授权事件接收URL & 公众号消息接收URL
   └── 5. 开发沙箱配置:在【测试公众号列表】中绑定你的测试公众号

2.获取并保存开发者凭据

  • 开发者ID (AppId):也就是 ComponentAppID,直接复制保存。
  • 开发者密码 (AppSecret):也就是 ComponentAppSecret。点击“生成”按钮,微信会让你用管理员微信扫码验证,验证通过后屏幕会展示一串 32 位的密钥。

    必须立刻复制保存! 微信只展示这一次,关掉窗口后就无法再查看,只能重置。

「详情」页面可以查看到 APPID,首次获取 AppSecret 需要在此页面中生成:


3.配置出口 IP 白名单

这一步是为了防 61004 / 40164 拦截,微信限制只有在白名单内的 IP 地址,才能调用获取 Token 的接口。

  • 打开你的本地终端,查看你当前的公网出口 IP:
    curl ifconfig.me
    # 会输出你的公网 IP,比如 114.88.23.45
    
  • 回到开放平台后台找到 「详情」-「开发配置」-「开发资料」,点击编辑,在 【白名单IP地址列表】 中把这个 IP 填进去保存。

4.配置基础安全密钥

在 「详情」-「开发配置」-「开发资料」-「编辑开发资料」 下:

  • 消息校验 Token:自己随意输入一串英文或数字(例如 my_scrm_token_2026)。
  • 消息加解密 Key(EncodingAESKey):可以使用工具随机生成一串 43 位的密钥。

5.内网穿透与登记两大核心回调 URL

微信只会往公网地址推送事件。开发阶段服务跑在自己电脑上,没有公网地址,所以要用内网穿透把它暴露出去;上线后把回调 URL 换成正式域名即可,其余流程不变。

5.1本地打开终端,启动内网穿透:

(注意:比如我的项目 config/dev.yaml 中配置的本地端口是 4307)

cpolar http 4307

终端会输出一个临时的 HTTPS 地址,例如:

Forwarding          https://scrm-dev.r10.cpolar.top -> http://localhost:4307

5.2回到微信后台填写以下 3 个输入框:

  1. 授权事件接收 URL:
    • 必须填写,这是接收心跳 Ticket 和扫码授权结果的关键地址!
    • 填入:https://scrm-dev.r10.cpolar.top/v1/open/auth-callback
  2. 公众号开发域名 / 授权发起页域名:
    • 填写你前端项目的域名(不需要加 https://)。
    • 本地测试时,直接填你的穿透域名即可,例如:scrm-dev.r10.cpolar.top
  3. 消息与事件接收配置:
    • 点击添加或修改,URL 填入: https://scrm-dev.r10.cpolar.top/v1/open/msg-callback/$APPID$

      注意:末尾的 $APPID$ 必须原样一字不差写上!微信在推流时,会自动把 $APPID$ 替换成具体发生事件的公众号真实 AppID。


6.添加授权测试公众号白名单

这是开发期防拦截必备,刚创建的第三方平台属于 “开发中/未全网发布” 状态。
在微信规则中:未发布的平台,任何外来公众号扫码都会被拦截报错“该平台尚未全网发布,无法授权”。
微信专门为开发者开放了测试白名单:

  • 在开放平台详情页找到 【授权测试账号列表】。
  • 点击添加,把你平时自己用的测试公众号的微信号或**公众号原始ID(gh_xxxxxxxx)**填进去。
  • 只有加到这个名单里的公众号,接下来测试扫码才能成功授权!


工程落地:服务配置注入与回调入口

完成微信开放平台的应用创建与参数配置后,微信官方实际上只向我们的服务端发起两类核心 HTTP POST 回调:

  1. 平台级授权事件回调:接收每 10 分钟一次的心跳票据 component_verify_ticket 及商户授权变更通知。
  2. 业务级代管消息回调:接收所有被接管公众号的粉丝聊天消息与交互事件(URL 动态匹配 :appid)。

这两条回调入口是整个 SCRM 系统运作的底层基础设施,我们在发起客户扫码前,必须先在服务端将这两套接收与分发链路搭建就绪。本篇先落地授权事件入口(接收心跳票据与授权变更,平台生命线)。

1.服务配置注入 (config/dev.yaml)

回到本地代码仓库,打开 config/dev.yaml,将在开放平台后台获取的凭证与密钥一一填入:

open_platform:
  app_id: "wx58xxxxxxxxxxxxxx"                   # 开发者ID (AppId)
  app_secret: "22a91933f4664a4119118bfd2dacd489" # 开发者密码 (AppSecret)
  token: "my_scrm_token_2026"                    # 消息校验 Token
  encoding_aes_key: "xxx"                        # 消息加解密 Key (EncodingAESKey)

2.授权事件入口:接收心跳票据与授权变更

配置就绪后,接下来落地授权事件入口。它是整个系统的生命线:只有稳定接收并解密微信推过来的 component_verify_ticket,后端才能换取到后续所有接口必需的 component_access_token。微信服务器推过来的 XML 报文是经过 AES 加密的,后端必须使用创建平台时配置的 Token 和 EncodingAESKey 完成验签与解密。

这条链路怎么走——微信主动推、我们被动收:

     微信开放平台服务器(open.weixin.qq.com)
                  │
                  │ 主动 POST:每 10 分钟一次心跳;在后台改任意配置保存也会立刻触发一次
                  │ 查询参数:msg_signature · timestamp · nonce
                  │ 请求体:AES 加密的 XML 报文
                  ▼
   POST https://你的域名/v1/open/auth-callback
   ⚠️ 免登录放行 —— 这条路由不挂 JWT 中间件
      (发起方是微信服务器,没有用户身份可校验)
                  │
                  ▼
        ① 验签   Token + timestamp + nonce + msg_signature
                  ▼
        ② 解密   AES(EncodingAESKey)→ 明文 XML
                  ▼
        ③ 解析   xml.Unmarshal → 读 InfoType
                  ▼
        ④ 存票据  InfoType == component_verify_ticket(打通阶段只会收到这一类)
                  │
                  ▼
        写 Redis:scrm:open:ticket:{appid},TTL 12h 滚动
                  ▼
   回复 "success"(5 秒内;解析失败也回 success,避免微信重发风暴)

这个入口以后还会收到 authorized / unauthorized(商户授权、取消授权),报文里带 AuthorizerAppid 和 AuthorizationCode——那是下篇扫码授权之后的事,本节只需要关心心跳票据。

拿到 ticket 之后怎么换 component_access_token,见下文 2.4。

2.1路由层:接收微信推送事件

web/openplatform.go

func (h *OpenPlatformHandler) RegisterRoutes(server *gin.Engine) {
	// 微信开放平台(第三方平台)路由组
	g := server.Group("/v1/open")
	{
		// 1. 授权事件接收 URL(接收 ticket、授权/取消授权事件)- 对微信服务器免登录放行
		g.POST("/auth-callback", h.ReceiveAuthEvent)
	}
}

// ReceiveAuthEvent 处理微信推送的 component_verify_ticket 及授权变更事件
func (h *OpenPlatformHandler) ReceiveAuthEvent(ctx *gin.Context) {
	msgSignature := ctx.Query("msg_signature")
	timestamp := ctx.Query("timestamp")
	nonce := ctx.Query("nonce")

	body, err := io.ReadAll(ctx.Request.Body)
	if err != nil {
		h.l.Error("读取授权事件请求体失败", zap.Error(err))
		ctx.String(http.StatusBadRequest, "fail")
		return
	}

	event, err := h.svc.ReceiveVerifyTicket(ctx, msgSignature, timestamp, nonce, body)
	if err != nil {
		h.l.Warn("解析授权事件失败", zap.Error(err))
		ctx.String(http.StatusOK, "success") // 无论成功失败均回复 success,避免微信重发风暴
		return
	}

	h.l.Info("处理微信开放平台授权事件成功", zap.String("infoType", event.InfoType))
	ctx.String(http.StatusOK, "success")
}

2.2业务层:解密并将 Ticket 写入 Redis

service/openplatform.go

// TicketXML 微信开放平台推送事件报文结构
type TicketXML struct {
	XMLName               xml.Name `xml:"xml"`
	AppId                 string   `xml:"AppId"`
	CreateTime            int64    `xml:"CreateTime"`
	InfoType              string   `xml:"InfoType"`
	ComponentVerifyTicket string   `xml:"ComponentVerifyTicket"`
	AuthorizerAppid       string   `xml:"AuthorizerAppid"`
	AuthorizationCode     string   `xml:"AuthorizationCode"`
}

func (s *openPlatformService) ReceiveVerifyTicket(ctx context.Context, msgSignature, timestamp, nonce string, encryptedXML []byte) (*TicketXML, error) {
	appID := s.cfg.OpenPlatform.AppId
	token := s.cfg.OpenPlatform.Token
	aesKey := s.cfg.OpenPlatform.EncodingAESKey

	// 1. 初始化微信加解密工具包
	crypt, err := crypto.NewWXBizMsgCrypt(token, aesKey, appID)
	if err != nil {
		return nil, fmt.Errorf("create wxbizmsgcrypt failed: %w", err)
	}

	// 2. 验签并解密 XML 载荷
	decrypted, err := crypt.DecryptMsg(msgSignature, timestamp, nonce, encryptedXML)
	if err != nil {
		return nil, fmt.Errorf("decrypt msg failed: %w", err)
	}

	var event TicketXML
	if err := xml.Unmarshal(decrypted, &event); err != nil {
		return nil, fmt.Errorf("unmarshal decrypted ticket xml failed: %w", err)
	}

	// 3. 提取 component_verify_ticket 并持久化至 Redis(缓存 12 小时滚动更新)
	if event.InfoType == "component_verify_ticket" && event.ComponentVerifyTicket != "" {
		s.l.Info("成功接收并解析微信 component_verify_ticket", zap.String("appId", event.AppId))
		if err := s.repo.SetVerifyTicket(ctx, event.AppId, event.ComponentVerifyTicket, 12*time.Hour); err != nil {
			s.l.Error("保存 component_verify_ticket 至缓存失败", zap.Error(err))
			return &event, err
		}
	}

	return &event, nil
}

加解密:

package crypto

import (
	"bytes"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha1"
	"encoding/base64"
	"encoding/binary"
	"encoding/hex"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"sort"
)

// CDATA 结构
type CDATA struct {
	Value string `xml:",cdata"`
}

// EncryptedXMLMsg 微信加密 XML 消息通用外层结构
type EncryptedXMLMsg struct {
	XMLName    xml.Name `xml:"xml"`
	ToUserName string   `xml:"ToUserName"`
	AppId      string   `xml:"AppId"`
	Encrypt    string   `xml:"Encrypt"`
}

// ResponseXMLMsg 被动回复加密 XML 结构
type ResponseXMLMsg struct {
	XMLName      xml.Name `xml:"xml"`
	Encrypt      CDATA    `xml:"Encrypt"`
	MsgSignature CDATA    `xml:"MsgSignature"`
	TimeStamp    string   `xml:"TimeStamp"`
	Nonce        CDATA    `xml:"Nonce"`
}

// WXBizMsgCrypt 微信消息加解密处理器
type WXBizMsgCrypt struct {
	token          string
	encodingAESKey string
	appID          string
	aesKey         []byte
}

func NewWXBizMsgCrypt(token, encodingAESKey, appID string) (*WXBizMsgCrypt, error) {
	if len(encodingAESKey) != 43 {
		return nil, errors.New("EncodingAESKey 长度必须为 43 位")
	}
	aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
	if err != nil {
		return nil, fmt.Errorf("decode aesKey failed: %w", err)
	}
	return &WXBizMsgCrypt{
		token:          token,
		encodingAESKey: encodingAESKey,
		appID:          appID,
		aesKey:         aesKey,
	}, nil
}

// CalcSignature 计算微信签名
func CalcSignature(token, timestamp, nonce, msgEncrypt string) string {
	params := []string{token, timestamp, nonce, msgEncrypt}
	sort.Strings(params)
	h := sha1.New()
	for _, p := range params {
		h.Write([]byte(p))
	}
	return hex.EncodeToString(h.Sum(nil))
}

// VerifySignature 验证签名
func (c *WXBizMsgCrypt) VerifySignature(msgSignature, timestamp, nonce, msgEncrypt string) bool {
	sig := CalcSignature(c.token, timestamp, nonce, msgEncrypt)
	return sig == msgSignature
}

// DecryptMsg 解密微信加密报文,返回明文字节
//
// 官方开发文档(消息推送与加解密方案):
// https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/2.0/Before_Develop/message_push.html
// 官方多语言示例 (Java/Python/C++/PHP): https://wximg.gtimg.com/shake_tv/mpwiki/cryptoDemo.zip
//
// 核心解密步骤说明:
// 1. 提取密文: 从 HTTP POST 的 XML 报文中解析出 <Encrypt> 密文字符串;
// 2. 防篡改验签: 将 [token, timestamp, nonce, encrypt] 字典排序后计算 SHA-1 签名,与 msg_signature 比对;
// 3. AES-CBC 解密: 对 Encrypt 进行 Base64 解码,使用 32 字节 AESKey 和 IV (固定为 AESKey 前 16 字节) 进行 CBC 解密;
// 4. PKCS#7 剔除: 去除解密内容末尾的 PKCS#7 填充字节,得到 FullStr 二进制混合数据包;
// 5. 协议拆包 (FullStr 内存排布):
//    FullStr = random(16B) + msg_len(4B) + msg + appid
//    - random(16B): 0~16 字节为 16 字节随机串,起混淆与防重放作用,解密端跳过;
//    - msg_len(4B): 16~20 字节为 msg 长度,占 4 字节,采用网络字节序 (大端序 BigEndian);
//    - msg (变长): 从第 20 字节开始截取 msgLen 长度,即公众号/事件的原始 XML 明文 (对应代码 xmlContent);
//    - appid (变长): 20+msgLen 之后直至末尾的字符串,为接收方的 AppID (对应官方示例 from_appid);
// 6. 平台校验: 验证 appIDFromMsg 是否与本第三方平台 AppID 一致,防止多租户/跨平台串包。
func (c *WXBizMsgCrypt) DecryptMsg(msgSignature, timestamp, nonce string, postData []byte) ([]byte, error) {
	// 步骤 1: 解析外层 XML,获取 Encrypt 密文
	var encMsg EncryptedXMLMsg
	if err := xml.Unmarshal(postData, &encMsg); err != nil {
		return nil, fmt.Errorf("unmarshal encrypted xml failed: %w", err)
	}

	// 步骤 2: 验证 SHA-1 安全签名(防篡改)
	if !c.VerifySignature(msgSignature, timestamp, nonce, encMsg.Encrypt) {
		return nil, errors.New("signature verification failed")
	}

	// 步骤 3.1: Base64 解码得到 AES 原始密文字节
	cipherBytes, err := base64.StdEncoding.DecodeString(encMsg.Encrypt)
	if err != nil {
		return nil, fmt.Errorf("base64 decode encrypt failed: %w", err)
	}

	block, err := aes.NewCipher(c.aesKey)
	if err != nil {
		return nil, fmt.Errorf("aes new cipher failed: %w", err)
	}

	if len(cipherBytes) < aes.BlockSize || len(cipherBytes)%aes.BlockSize != 0 {
		return nil, errors.New("cipher text length is invalid")
	}

	// 步骤 3.2: AES-256-CBC 解密
	// 微信官方规范约定: IV 取 AESKey 的前 16 个字节 (而不是另外传输随机 IV)
	iv := c.aesKey[:16]
	mode := cipher.NewCBCDecrypter(block, iv)
	plainBytes := make([]byte, len(cipherBytes))
	mode.CryptBlocks(plainBytes, cipherBytes)

	// 步骤 4: 剔除末尾 PKCS#7 填充字节,还原原始 FullStr 数据包
	plainBytes = pkcs7Unpad(plainBytes)
	if len(plainBytes) < 20 {
		return nil, errors.New("plain text length too short")
	}

	// 步骤 5: 拆解 FullStr 二进制封包
	// 官方结构: random(16B) + msg_len(4B) + msg + appid

	// 5.1 读取 4 字节的 msg_len (网络字节序 / 大端序 Big-Endian,对应官方示例中的 msg_len / xmlLength)
	msgLen := binary.BigEndian.Uint32(plainBytes[16:20])
	totalLen := len(plainBytes)
	if totalLen < 20+int(msgLen) {
		return nil, errors.New("invalid msg len in decrypted content")
	}

	// 5.2 截取真实业务明文 XML 报文 (对应官方文档中的 msg、官方 Java 示例的 xmlContent)
	xmlContent := plainBytes[20 : 20+msgLen]

	// 5.3 截取尾部携带的目标 AppID (对应官方示例中的 from_appid)
	appIDFromMsg := string(plainBytes[20+msgLen:])

	// 步骤 6: 校验 AppID 是否与当前配置一致,防止跨平台错推或串包攻击
	if c.appID != "" && appIDFromMsg != c.appID {
		return nil, fmt.Errorf("appID mismatch: expected %s, got %s", c.appID, appIDFromMsg)
	}

	return xmlContent, nil
}

// EncryptMsg 加密回复明文并组装成微信标准加密 XML
//
// 封包与加密逆过程 (与 DecryptMsg 镜像对称):
// 1. 生成 16 字节随机串 random(16B),作为混淆盐;
// 2. 将回复明文长度 replyXML 转换为 4 字节网络字节序 (大端序 BigEndian);
// 3. 组装 FullStr: random(16B) + msg_len(4B) + replyXML + appid;
// 4. PKCS#7 补位填充对齐;
// 5. AES-256-CBC 加密 (IV 固定为 AESKey 前 16 字节);
// 6. Base64 编码并计算 SHA-1 签名,组装成带有 CDATA 的微信加密响应报文。
func (c *WXBizMsgCrypt) EncryptMsg(replyXML []byte, timestamp, nonce string) ([]byte, error) {
	// 步骤 1: 生成 16 字节安全随机字符串
	rand16 := make([]byte, 16)
	if _, err := io.ReadFull(rand.Reader, rand16); err != nil {
		return nil, fmt.Errorf("generate random bytes failed: %w", err)
	}

	// 步骤 2: 将明文长度转换为 4 字节网络字节序 (大端序)
	msgLenBytes := make([]byte, 4)
	binary.BigEndian.PutUint32(msgLenBytes, uint32(len(replyXML)))

	// 步骤 3: 拼装 FullStr = random(16B) + msg_len(4B) + replyXML + appID
	var rawBuf bytes.Buffer
	rawBuf.Write(rand16)
	rawBuf.Write(msgLenBytes)
	rawBuf.Write(replyXML)
	rawBuf.WriteString(c.appID)

	// 步骤 4: PKCS#7 补位填充
	padded := pkcs7Pad(rawBuf.Bytes(), aes.BlockSize)

	block, err := aes.NewCipher(c.aesKey)
	if err != nil {
		return nil, fmt.Errorf("aes new cipher failed: %w", err)
	}

	iv := c.aesKey[:16]
	mode := cipher.NewCBCEncrypter(block, iv)
	cipherText := make([]byte, len(padded))
	mode.CryptBlocks(cipherText, padded)

	encryptB64 := base64.StdEncoding.EncodeToString(cipherText)
	signature := CalcSignature(c.token, timestamp, nonce, encryptB64)

	res := ResponseXMLMsg{
		Encrypt:      CDATA{Value: encryptB64},
		MsgSignature: CDATA{Value: signature},
		TimeStamp:    timestamp,
		Nonce:        CDATA{Value: nonce},
	}

	return xml.MarshalIndent(res, "", "  ")
}

func pkcs7Pad(data []byte, blockSize int) []byte {
	padding := blockSize - len(data)%blockSize
	padText := bytes.Repeat([]byte{byte(padding)}, padding)
	return append(data, padText...)
}

func pkcs7Unpad(data []byte) []byte {
	length := len(data)
	if length == 0 {
		return nil
	}
	padding := int(data[length-1])
	if padding > length || padding > 32 || padding == 0 {
		return data
	}
	return data[:length-padding]
}

2.3Repository 抽象存储层与 Cache、DAO 实现

func (r *openPlatformRepository) SetVerifyTicket(ctx context.Context, platformAppID, ticket string, ttl time.Duration) error {
	// 1. 写入 Redis 缓存
	cacheErr := r.cache.SetVerifyTicket(ctx, platformAppID, ticket, ttl)

	// 2. 双写持久化到 MySQL,并记录绝对过期时间戳 (当前时间 + ttl)
	expiresAt := time.Now().Add(ttl).UnixMilli()
	_ = r.dao.SaveOrUpdateVerifyTicket(ctx, platformAppID, ticket, expiresAt)

	return cacheErr
}

func (c *openPlatformCache) SetVerifyTicket(ctx context.Context, platformAppID, ticket string, ttl time.Duration) error {
	key := fmt.Sprintf("scrm:open:ticket:%s", platformAppID)
	return c.redis.Set(ctx, key, ticket, ttl).Err()
}

func (d *openPlatformDAO) SaveOrUpdateVerifyTicket(ctx context.Context, appID, ticket string, expiresAt int64) error {
	now := time.Now().UnixMilli()
	return d.db.WithContext(ctx).Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "app_id"}},
		DoUpdates: clause.Assignments(map[string]any{
			"component_verify_ticket": ticket,
			"ticket_expires_at":       expiresAt,
			"updated_at":              now,
		}),
	}).Create(&WxOpenPlatform{
		ID:                    uuid.NewV4().String(),
		AppID:                 appID,
		ComponentVerifyTicket: ticket,
		TicketExpiresAt:       expiresAt,
		CreatedAt:             now,
		UpdatedAt:             now,
	}).Error
}

2.4凭据基石:换取平台令牌 component_access_token

一旦有了 Ticket,平台就可以调微信接口换取 component_access_token。为了防高并发击穿,采用 singleflight 锁保护:

func (s *openPlatformService) GetComponentAccessToken(ctx context.Context, platformAppID string) (string, error) {
	if platformAppID == "" {
		platformAppID = s.cfg.OpenPlatform.AppId
	}

	// 1. 先查缓存
	token, err := s.repo.GetComponentAccessToken(ctx, platformAppID)
	if err == nil && token != "" {
		return token, nil
	}

	// 2. singleflight 保护防击穿
	val, err, _ := s.sf.Do("component_token:"+platformAppID, func() (any, error) {
		token, err := s.repo.GetComponentAccessToken(ctx, platformAppID)
		if err == nil && token != "" {
			return token, nil
		}

		ticket, err := s.repo.GetVerifyTicket(ctx, platformAppID)
		if err != nil || ticket == "" {
			// 异步触发微信主动补发推送,不阻塞当前请求过长时间
			go func() {
				bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
				defer cancel()
				if pushErr := s.TriggerStartPushTicket(bgCtx, platformAppID); pushErr != nil {
					s.l.Warn("主动触发微信推送 component_verify_ticket 失败", zap.Error(pushErr), zap.String("platformAppID", platformAppID))
				} else {
					s.l.Info("已触发微信推送 component_verify_ticket 补发流程,等待微信异步推送...", zap.String("platformAppID", platformAppID))
				}
			}()
			return "", fmt.Errorf("开放平台验证票据未就绪,已触发微信推送补偿,请等待约1分钟后重试")
		}

		reqBody, _ := json.Marshal(map[string]string{
			"component_appid":         platformAppID,
			"component_appsecret":     s.cfg.OpenPlatform.AppSecret,
			"component_verify_ticket": ticket,
		})

		url := "https://api.weixin.qq.com/cgi-bin/component/api_component_token"
		resp, err := s.httpClient.Post(url, "application/json", bytes.NewReader(reqBody))
		if err != nil {
			return "", fmt.Errorf("request api_component_token failed: %w", err)
		}
		defer resp.Body.Close()

		body, err := io.ReadAll(resp.Body)
		if err != nil {
			return "", fmt.Errorf("read component token response failed: %w", err)
		}

		var res struct {
			ComponentAccessToken string `json:"component_access_token"`
			ExpiresIn            int    `json:"expires_in"`
			ErrCode              int    `json:"errcode"`
			ErrMsg               string `json:"errmsg"`
		}

		if err := json.Unmarshal(body, &res); err != nil {
			return "", fmt.Errorf("unmarshal component token response failed: %w", err)
		}

		if res.ErrCode != 0 || res.ComponentAccessToken == "" {
			return "", fmt.Errorf("wechat api_component_token error [%d]: %s", res.ErrCode, res.ErrMsg)
		}

		ttl := time.Duration(res.ExpiresIn-200) * time.Second
		if ttl <= 0 {
			ttl = 7000 * time.Second
		}

		_ = s.repo.SetComponentAccessToken(ctx, platformAppID, res.ComponentAccessToken, ttl)
		return res.ComponentAccessToken, nil
	})

	if err != nil {
		return "", err
	}
	return val.(string), nil
}

// TriggerStartPushTicket 向微信官方主动申请补发 component_verify_ticket
// 官方文档:https://developers.weixin.qq.com/doc/oplatform/openApi/ticket-token/api_startpushticket.html
func (s *openPlatformService) TriggerStartPushTicket(ctx context.Context) error {
	platformAppID := s.cfg.OpenPlatform.AppId
	appSecret := s.cfg.OpenPlatform.AppSecret
	if appSecret == "" {
		return errors.New("开放平台 AppSecret 未配置")
	}

	// 频率保护:单平台原子时间戳判断,1 分钟内限制调用一次 (防抖保护与限流拦截)
	nowMs := time.Now().UnixMilli()
	lastMs := s.lastPushTriggerMs.Load()
	if nowMs-lastMs < 60*1000 {
		s.l.Info("1分钟内已触发过微信推送补发,跳过本次请求",
			zap.String("platformAppID", platformAppID),
			zap.Int64("remainingCooldownMs", 60*1000-(nowMs-lastMs)),
		)
		return nil
	}

	reqBody, _ := json.Marshal(map[string]string{
		"component_appid":  platformAppID,
		"component_secret": appSecret,
	})

	url := "https://api.weixin.qq.com/cgi-bin/component/api_start_push_ticket"
	resp, err := s.httpClient.Post(url, "application/json", bytes.NewReader(reqBody))
	if err != nil {
		return fmt.Errorf("request api_start_push_ticket failed: %w", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("read api_start_push_ticket response failed: %w", err)
	}

	var res struct {
		ErrCode int    `json:"errcode"`
		ErrMsg  string `json:"errmsg"`
	}
	if err := json.Unmarshal(body, &res); err != nil {
		return fmt.Errorf("unmarshal api_start_push_ticket response failed: %w", err)
	}

	if res.ErrCode != 0 {
		return fmt.Errorf("wechat api_start_push_ticket error [%d]: %s", res.ErrCode, res.ErrMsg)
	}

	s.lastPushTriggerMs.Store(time.Now().UnixMilli())
	s.l.Info("成功调用微信 api_start_push_ticket 申请补推 ticket", zap.String("platformAppID", platformAppID))
	return nil
}

2.5启动服务与心跳联通状态验证

确保环境中 Redis(如本地:localhost:6379)和 MySQL 已经启动,然后启动后端服务(如示例默认端口 4307)。

微信服务器每隔 10 分钟会自动向你的 https://你的域名/v1/open/auth-callback 发送一次心跳 POST 请求。

  • 快速触发心跳技巧:在微信开放平台后台,随便修改一下任意无关配置并点击保存,微信服务器就会立即向你的回调接口主动推送一次测试报文!

  • 查看后端服务终端日志,应该能看到:

     2026-09-05T01:48:58.587+0800	INFO	service/openplatform.go:100	成功接收并解析微信 component_verify_ticket	{"appId": "wx391b50c00911423a"}
     2026-09-05T01:48:58.599+0800	INFO	web/openplatform.go:75	处理微信开放平台授权事件成功	{"infoType": "component_verify_ticket"}
     [GIN] 2026/09/05 - 01:48:58 | 200 | 113.86ms |  101.35.214.209 | POST     "/v1/open/auth-callback?signature=fb9ee37d39bdff141b8b68ecd53f014f79163b97&timestamp=1788544138&nonce=1611377474&encrypt_type=aes&msg_signature=d4438745e75a84aaf0c0fde4909039378e9766df"
    
  • 查看 Redis: 打开 Redis 客户端,执行:

    KEYS scrm:open:*
    # 会看到键:scrm:open:ticket:wx58xxxxxxxxxxxxxx
    GET scrm:open:ticket:wx58xxxxxxxxxxxxxx
    # 会输出一串以 ticket@@@ 开头的字符串
    

👉 看到这一步,说明你的平台授权事件入口已经彻底与微信官方接通了!


常见报错排查速查表(打通阶段)

把平台接入阶段容易踩的坑集中成一张表,对号入座:

现象 / 报错原因修复对应章节
换 token 报 61004 / 40164当前公网出口 IP 不在平台的 IP 白名单把 curl ifconfig.me 查到的 IP 加进【IP白名单】微信开放平台应用创建与参数配置·3
第三方平台接口报 ticket 无效用旧的 component_verify_ticket 去换 token(微信校验"最新值")确认授权事件接收 URL 可达、Redis 里存的是最新 ticket工程落地(上)·授权事件入口
扫码提示"该平台尚未全网发布,无法授权"第三方平台处于"开发中 / 未全网发布"状态把要测的公众号加进测试白名单(授权测试账号列表)微信开放平台应用创建与参数配置·6
AppSecret 忘了 / 找不回来微信只展示一次重置 AppSecret 并立即复制保存微信开放平台应用创建与参数配置·2

扫码授权阶段的报错(redirect_uri 域名不一致、auth_code 失效、接管后收不到粉丝消息)见下篇 → SCRM 系统接管多公众号实战指南


核心凭证与名词速查表

微信开放平台涉及多个不同作用域与有效期的 Token,记住这条核心凭证流通链即可理清整条调用关系:

核心凭证链:component_verify_ticket(微信心跳推) → component_access_token(平台令牌) → pre_auth_code(预授权码) → auth_code(扫码兑换券) → authorizer_refresh_token(公众号永久托管密钥)

凭证名称作用说明获取方式有效期存储要求
ComponentAppID第三方平台自身的“身份证号”开放平台创建第三方平台时分配长期写入项目代码配置
ComponentAppSecret第三方平台通信密钥(只展示一次)开放平台后台生成并由管理员扫码查看长期(泄露可重置)写入项目代码配置
component_verify_ticket微信向平台发送的心跳票据,一切凭证的前提微信每 10 分钟自动推送到授权事件接收 URL滚动更新(最新有效)必须存入 Redis / DB
component_access_token第三方平台自身的全局调用凭证后端携带最新 ticket + 平台凭证向微信换取约 2 小时Redis 缓存并自动刷新
pre_auth_code预授权码,用于拼装管理员扫码授权链接后端携带 component_access_token 调微信接口换取约 10 分钟临时使用
auth_code扫码成功后微信回传的一次性授权码管理员手机点击“同意授权”后微信带回一次性(短时有效)立即向微信换取正式凭证
authorizer_refresh_token被托管公众号的永久凭据(接管核心)后端用 auth_code 向微信官方换取长期(直至公众号取消授权)必须落库持久化
授权测试公众号列表开发阶段允许扫码授权的白名单开放平台后台手动登记微信号/原始ID开发期专用必配,未全网发布前防拦截

相关笔记