本篇是《SCRM 系统接管多公众号实战指南》的上篇,只做一件事:把第三方平台与微信官方彻底接通——创建开放平台应用、配置参数、搭好授权事件与心跳回调的接收入口,直到微信推来的心跳票据能被稳定接住、
component_access_token能正常换取。**下篇(业务接管)**见 → SCRM 系统接管多公众号实战指南
业务背景与场景定位
在 SCRM(客户关系管理)或者多商户 SaaS 平台中,核心需求是:让客户(包括企业自身或其他入驻商家)把他们的微信公众号一键托管到你的平台,由平台统一管理这些公众号的粉丝、消息、菜单和数据。
对于使用你平台的客户/运营人员而言,他们完全不需要懂技术,不需要登录微信公众平台后台去配置什么 URL、Token 或代码,全靠手机微信“扫码一键授权”完成接管。
但“代管”不是平台单方面能决定的——微信只认一种模式:开放平台·第三方平台。平台必须先被微信认可为第三方平台,微信才会把公众号的授权事件和粉丝消息推给它。
所以第一步不是写业务,而是打通:让微信服务器找得到我们、推得进来、并且推来的加密报文能解开。这一步没完成,连生成授权二维码要用的 component_access_token 都拿不到。
打通流程总览
落到操作上分三个阶段,三方各司其职:
微信只向平台推两类回调:这张图里的是授权事件(打通阶段就开始推心跳票据);另一类是消息与事件(被接管公众号的粉丝消息与交互),要等商户扫码授权之后才有数据,见下篇。
后文把这两条入口简称为授权事件入口和消息与事件入口(名字取自后台里的那两个接收 URL)。每个“入口”= 一条回调 URL + 它背后的验签、解密、分发。
走到 ✅ 这一步,心跳票据能被稳定接住、component_access_token 能正常换取——此时才谈得上扫码授权接管。
微信开放平台应用创建与参数配置
这一章对应总览里的阶段一——动作全在微信后台,不涉及一行代码。但它决定了微信会不会把事件推给我们:应用没建、回调 URL 没登记,阶段二、阶段三就无从谈起。所以先把它做完。
在编写任何业务代码前,必须先在 微信开放平台(注意:是开放平台 open.weixin.qq.com,不是公众平台 mp.weixin.qq.com)创建第三方平台应用主体并完成参数配置。
1.创建第三方平台应用与权限集声明
登录微信开放平台,进入「第三方平台」管理,点击「创建第三方平台」,填写平台基本信息并提交:

创建完成后,需要声明你的 SCRM 平台要代公众号行使哪些管理权限(如消息收发、自定义菜单、粉丝管理等):
- 点击「权限集」的【编辑】按钮,选择「公众号与服务号权限集」:

- 左上角「我的业务与服务」下拉选择「第三方平台」,绑定平台管理员个人微信号:

- 点击【添加权限集】,根据业务场景勾选所需权限:

创建并配置完权限后,平台进入详情管理页。接下来在开放平台后台把关键开发参数一一配置齐全:
[微信开放平台后台详情页 - 关键配置一览]
├── 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 个输入框:
- 授权事件接收 URL:
- 必须填写,这是接收心跳 Ticket 和扫码授权结果的关键地址!
- 填入:
https://scrm-dev.r10.cpolar.top/v1/open/auth-callback
- 公众号开发域名 / 授权发起页域名:
- 填写你前端项目的域名(不需要加
https://)。 - 本地测试时,直接填你的穿透域名即可,例如:
scrm-dev.r10.cpolar.top
- 填写你前端项目的域名(不需要加
- 消息与事件接收配置:
- 点击添加或修改,URL 填入:
https://scrm-dev.r10.cpolar.top/v1/open/msg-callback/$APPID$注意:末尾的
$APPID$必须原样一字不差写上!微信在推流时,会自动把$APPID$替换成具体发生事件的公众号真实 AppID。
- 点击添加或修改,URL 填入:

6.添加授权测试公众号白名单
这是开发期防拦截必备,刚创建的第三方平台属于 “开发中/未全网发布” 状态。
在微信规则中:未发布的平台,任何外来公众号扫码都会被拦截报错“该平台尚未全网发布,无法授权”。
微信专门为开发者开放了测试白名单:
- 在开放平台详情页找到 【授权测试账号列表】。
- 点击添加,把你平时自己用的测试公众号的微信号或**公众号原始ID(gh_xxxxxxxx)**填进去。
- 只有加到这个名单里的公众号,接下来测试扫码才能成功授权!

工程落地:服务配置注入与回调入口
完成微信开放平台的应用创建与参数配置后,微信官方实际上只向我们的服务端发起两类核心 HTTP POST 回调:
- 平台级授权事件回调:接收每 10 分钟一次的心跳票据
component_verify_ticket及商户授权变更通知。 - 业务级代管消息回调:接收所有被接管公众号的粉丝聊天消息与交互事件(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×tamp=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 | 开发期专用 | 必配,未全网发布前防拦截 |
相关笔记
- → SCRM 系统接管多公众号实战指南 下篇:消息与事件入口 + 扫码授权接管与凭据持久化闭环