Logo
活死人の行知路

SCRM 系统接管多公众号实战指南


📅 | 📝 2857 字
#微信开发

业务背景与场景定位

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

在微信官方规范中,这种模式有且仅有一种标准架构——微信开放平台·第三方平台代公众号授权模式(像微盟、有赞、JINGdigital 等商业 SCRM 系统全部采用这种模式)。

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

核心接入流程(端到端时序)

整个接管闭环涉及四方:客户 (C)、SCRM 前端 (F)、SCRM 后端 (B) 与 微信开放平台 (W):

1. 发起授权:用户在平台点击【一键接入公众号】 -> 前端请求后端获取授权链接
2. 生成凭据:后端用 component_access_token 向微信换取 pre_auth_code,拼接 auth_url 下发
3. 扫码确认:前端展示二维码 -> 管理员微信扫码 -> 勾选权限并点击【同意授权】
4. 换证入库:微信带 auth_code 回跳 -> 后端换取永久凭证 authorizer_refresh_token 并落库
5. 业务接管:微信向平台推送代管公众号的粉丝消息与事件 -> 平台解密并分发处理

终端交互全貌(用户视角)

用户在系统后台的实际交互极其轻量:

  • 第一步:在公众号管理页点击 【一键接入微信公众号】。
  • 第二步:网页弹出微信官方授权二维码,管理员手机微信扫码。
  • 第三步:手机端弹出官方权限列表,点击 【同意授权】。
  • 第四步:手机端显示“授权成功”并于 3 秒后自动关闭;PC 端监听到授权完成自动刷新列表,显示刚刚接管的公众号信息。

核心凭证与名词速查表

微信开放平台涉及多个不同作用域与有效期的 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开发期专用必配,未全网发布前防拦截

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

在编写任何业务代码前,必须先在 微信开放平台(注意:是开放平台 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.获取并保存开发者凭据(AppId / AppSecret)

  • 开发者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 与 EncodingAESKey)

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

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

填写示例:


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

由于微信要把心跳 Ticket、授权结果以及被接管公众号的粉丝消息推送到你的电脑,所以需要用内网穿透把本地服务暴露出去。

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.网关一:授权事件与心跳 Ticket 接收网关(平台生命线)

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

2.1路由层:接收微信推送事件 (web/openplatform.go)

// 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 解密微信加密报文,返回明文字节
func (c *WXBizMsgCrypt) DecryptMsg(msgSignature, timestamp, nonce string, postData []byte) ([]byte, error) {
	var encMsg EncryptedXMLMsg
	if err := xml.Unmarshal(postData, &encMsg); err != nil {
		return nil, fmt.Errorf("unmarshal encrypted xml failed: %w", err)
	}

	if !c.VerifySignature(msgSignature, timestamp, nonce, encMsg.Encrypt) {
		return nil, errors.New("signature verification failed")
	}

	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")
	}

	iv := c.aesKey[:16]
	mode := cipher.NewCBCDecrypter(block, iv)
	plainBytes := make([]byte, len(cipherBytes))
	mode.CryptBlocks(plainBytes, cipherBytes)

	// PKCS#7 unpad
	plainBytes = pkcs7Unpad(plainBytes)
	if len(plainBytes) < 20 {
		return nil, errors.New("plain text length too short")
	}

	// 报文结构: 16B 随机字符串 + 4B msg_len (网络字节序) + msg + appid
	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")
	}

	xmlContent := plainBytes[20 : 20+msgLen]
	appIDFromMsg := string(plainBytes[20+msgLen:])

	if c.appID != "" && appIDFromMsg != c.appID {
		return nil, fmt.Errorf("appID mismatch: expected %s, got %s", c.appID, appIDFromMsg)
	}

	return xmlContent, nil
}

// EncryptMsg 加密回复明文并组装成加密 XML
func (c *WXBizMsgCrypt) EncryptMsg(replyXML []byte, timestamp, nonce string) ([]byte, error) {
	rand16 := make([]byte, 16)
	if _, err := io.ReadFull(rand.Reader, rand16); err != nil {
		return nil, fmt.Errorf("generate random bytes failed: %w", err)
	}

	msgLenBytes := make([]byte, 4)
	binary.BigEndian.PutUint32(msgLenBytes, uint32(len(replyXML)))

	var rawBuf bytes.Buffer
	rawBuf.Write(rand16)
	rawBuf.Write(msgLenBytes)
	rawBuf.Write(replyXML)
	rawBuf.WriteString(c.appID)

	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 实现

func (r *openPlatformRepository) SetVerifyTicket(ctx context.Context, platformAppID, ticket string, ttl time.Duration) error {
	return r.cache.SetVerifyTicket(ctx, platformAppID, ticket, ttl)
}

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()
}

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 == "" {
			return "", fmt.Errorf("未获取到 component_verify_ticket,请等待微信推送: %w", err)
		}

		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, _ := io.ReadAll(resp.Body)
		var res struct {
			ComponentAccessToken string `json:"component_access_token"`
			ExpiresIn            int    `json:"expires_in"`
			ErrCode              int    `json:"errcode"`
			ErrMsg               string `json:"errmsg"`
		}
		_ = json.Unmarshal(body, &res)
		if res.ErrCode != 0 || res.ComponentAccessToken == "" {
			return "", fmt.Errorf("wechat api_component_token error [%d]: %s", res.ErrCode, res.ErrMsg)
		}

		// 提前 200 秒过期以平滑续期
		ttl := time.Duration(res.ExpiresIn-200) * time.Second
		_ = s.repo.SetComponentAccessToken(ctx, platformAppID, res.ComponentAccessToken, ttl)
		return res.ComponentAccessToken, nil
	})

	if err != nil {
		return "", err
	}
	return val.(string), 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@@@ 开头的字符串
    

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


3.网关二:代管公众号粉丝消息与事件分发网关(业务生命线)

商户公众号被授权给平台后,微信会将粉丝在该公众号里的所有交互推送到我们在后台登记的 https://你的域名/v1/open/msg-callback/$APPID$。这是 SCRM 实现粉丝画像、智能自动回复、菜单点击统计等业务的核心入口。

3.1消息推送机制与 5 秒超时规范

  1. URL 动态解析:微信在推流时会自动将 $APPID$ 替换为具体发生事件的公众号真实 AppID,发起请求: POST /v1/open/msg-callback/wx_authorizer_appid?msg_signature=xxx&timestamp=xxx&nonce=xxx
  2. 对称密文传输:推送消息体同样采用 AES 加密,使用的加解密密钥仍是第三方平台的 Token、EncodingAESKey 和 ComponentAppID。
  3. 5 秒极速回复:微信要求接收端必须在 5 秒内回复 success 或空字符串,超时将触发 3 次重发,极易引发服务雪崩。因此必须采用非阻塞架构。

3.2路由与 Web 控制器实现 (web/openplatform.go)

// 注册微信消息接收端点,:appid 动态捕获当前触发事件的公众号 AppID
g.POST("/msg-callback/:appid", h.ReceiveMessage)

// ReceiveMessage 接收授权公众号的粉丝消息与事件推送
func (h *OpenPlatformHandler) ReceiveMessage(ctx *gin.Context) {
	authorizerAppID := ctx.Param("appid")
	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.String("authorizerAppID", authorizerAppID), zap.Error(err))
		ctx.String(http.StatusBadRequest, "fail")
		return
	}

	h.l.Debug("接收到微信公众号消息推送",
		zap.String("authorizerAppID", authorizerAppID),
		zap.String("msgSignature", msgSignature),
		zap.String("timestamp", timestamp),
		zap.String("nonce", nonce),
	)

	// 调用 Service 纯业务逻辑(参数解耦,不传递 gin.Context)
	if err := h.svc.HandleMessage(ctx.Request.Context(), authorizerAppID, msgSignature, timestamp, nonce, body); err != nil {
		h.l.Warn("处理微信公众号粉丝消息异常",
			zap.String("authorizerAppID", authorizerAppID),
			zap.Error(err),
		)
		// 微信官方规范:即便业务处理失败,也应回复 success 避免微信服务器 5 秒超时重发风暴
		ctx.String(http.StatusOK, "success")
		return
	}

	// 微信规定收到消息后必须回复 success 或空字符串
	ctx.String(http.StatusOK, "success")
}

3.3业务层解密与策略路由分发引擎 (service/openplatform.go)

在业务层,采用**策略/插件模式(Strategy Pattern)**与协程池异步消费:

// CommonMsgXML 微信公众号粉丝基础消息/事件 XML 结构
type CommonMsgXML struct {
	XMLName      xml.Name `xml:"xml"`
	ToUserName   string   `xml:"ToUserName"`   // 开发者微信号 / 公众号原始ID
	FromUserName string   `xml:"FromUserName"` // 发送方帐号(粉丝的 OpenID)
	CreateTime   int64    `xml:"CreateTime"`   // 消息创建时间
	MsgType      string   `xml:"MsgType"`      // 消息类型:text / image / event 等
	Event        string   `xml:"Event"`        // 事件类型:subscribe / unsubscribe / CLICK 等
	Content      string   `xml:"Content"`      // 文本消息内容
	EventKey     string   `xml:"EventKey"`     // 事件 KEY 值(如带参二维码参数)
}

// OpenPlatformMessageHandler 消息处理器插件接口
type OpenPlatformMessageHandler interface {
	Process(ctx context.Context, authorizerAppID string, rawXML []byte, msg map[string]string) error
}

// HandleMessage 解密并路由分发公众号消息与事件
func (s *openPlatformService) HandleMessage(
	ctx context.Context,
	authorizerAppID, msgSignature, timestamp, nonce string,
	body []byte,
) error {
	crypt, err := crypto.NewWXBizMsgCrypt(s.cfg.OpenPlatform.Token, s.cfg.OpenPlatform.EncodingAESKey, s.cfg.OpenPlatform.AppId)
	if err != nil {
		s.l.Error("初始化加解密失败", zap.Error(err))
		return err
	}

	decrypted, err := crypt.DecryptMsg(msgSignature, timestamp, nonce, body)
	if err != nil {
		s.l.Warn("解密粉丝消息失败", zap.String("authorizerAppID", authorizerAppID), zap.Error(err))
		return err
	}

	var msg CommonMsgXML
	if err := xml.Unmarshal(decrypted, &msg); err != nil {
		s.l.Error("反序列化消息明文失败", zap.String("authorizerAppID", authorizerAppID), zap.Error(err))
		return err
	}

	s.l.Info("收到授权公众号粉丝消息/事件",
		zap.String("authorizerAppID", authorizerAppID),
		zap.String("fromUser", msg.FromUserName),
		zap.String("msgType", msg.MsgType),
		zap.String("event", msg.Event),
		zap.String("content", msg.Content),
	)

	// 组装路由 Key(如 "text" 或 "event:subscribe")
	key := msg.MsgType
	if msg.Event != "" {
		key = fmt.Sprintf("%s:%s", msg.MsgType, msg.Event)
	}

	// 命中注册的处理器则异步分发执行,不阻塞当前 HTTP 响应
	if handlerVal, ok := s.handlers.Load(key); ok {
		handler := handlerVal.(OpenPlatformMessageHandler)
		m := map[string]string{
			"ToUserName":   msg.ToUserName,
			"FromUserName": msg.FromUserName,
			"MsgType":      msg.MsgType,
			"Event":        msg.Event,
			"Content":      msg.Content,
			"EventKey":     msg.EventKey,
		}
		go func() {
			if err := handler.Process(context.Background(), authorizerAppID, decrypted, m); err != nil {
				s.l.Error("处理器处理消息失败", zap.String("key", key), zap.Error(err))
			}
		}()
	}

	return nil
}

// RegisterMessageHandler 注册自定义事件监听器
func (s *openPlatformService) RegisterMessageHandler(msgType, eventType string, handler OpenPlatformMessageHandler) {
	key := msgType
	if eventType != "" {
		key = fmt.Sprintf("%s:%s", msgType, eventType)
	}
	s.handlers.Store(key, handler)
}

扫码授权接管与凭据持久化闭环

到此为止,服务端的两大微信回调网关均已全部就绪并正常运转。接下来进入最核心的商户授权闭环:生成授权链接 → 管理员扫码授权 → 换取凭证并持久化落库。

整个授权闭环按照严格的四个步骤推进:


步骤 1:生成授权链接与二维码

在商户后台点击【一键接入微信公众号】时,前端会先请求后端生成一条预授权链接。

1.1业务流程与核心逻辑

  1. 携带平台自身的 component_access_token 调用微信接口创建预授权码 pre_auth_code(10 分钟有效)。
  2. 拼接带有商户 account_id 与 pre_auth_code 的微信官方扫码授权 URL。

1.2核心后端代码 (service/openplatform.go)

// 1. 向微信官方换取 pre_auth_code
func (s *openPlatformService) GetPreAuthCode(ctx context.Context, platformAppID string) (string, error) {
	if platformAppID == "" {
		platformAppID = s.cfg.OpenPlatform.AppId
	}
	compToken, err := s.GetComponentAccessToken(ctx, platformAppID)
	if err != nil {
		return "", fmt.Errorf("get component token failed: %w", err)
	}

	reqBody, _ := json.Marshal(map[string]string{
		"component_appid": platformAppID,
	})
	apiURL := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/component/api_create_preauthcode?component_access_token=%s", compToken)
	resp, err := s.httpClient.Post(apiURL, "application/json", bytes.NewReader(reqBody))
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	var res struct {
		PreAuthCode string `json:"pre_auth_code"`
		ErrCode     int    `json:"errcode"`
		ErrMsg      string `json:"errmsg"`
	}
	_ = json.Unmarshal(body, &res)
	if res.ErrCode != 0 || res.PreAuthCode == "" {
		return "", fmt.Errorf("wechat api_create_preauthcode error [%d]: %s", res.ErrCode, res.ErrMsg)
	}
	return res.PreAuthCode, nil
}

// 2. 拼装 PC 端与移动端适用的完整授权链接
func (s *openPlatformService) GenerateAuthURL(ctx context.Context, redirectURI, accountID string) (string, string, string, error) {
	platformAppID := s.cfg.OpenPlatform.AppId
	if redirectURI == "" {
		redirectURI = s.cfg.OpenPlatform.RedirectURI
	}

	// 生成会话追踪 ticket,并拼入 redirect_uri 中传递给微信
	ticket := uuid.NewV4().String()
	if parsed, err := url.Parse(redirectURI); err == nil {
		q := parsed.Query()
		if q.Get("account_id") == "" {
			q.Set("account_id", accountID)
		}
		q.Set("ticket", ticket)
		parsed.RawQuery = q.Encode()
		redirectURI = parsed.String()
	}

	preAuthCode, err := s.GetPreAuthCode(ctx, platformAppID)
	if err != nil {
		return "", "", "", err
	}

	// 电脑端打开的标准授权页面
	authURL := fmt.Sprintf(
		"https://mp.weixin.qq.com/cgi-bin/componentloginpage?component_appid=%s&pre_auth_code=%s&redirect_uri=%s&auth_type=1",
		platformAppID, preAuthCode, url.QueryEscape(redirectURI),
	)
	// 手机微信客户端直接扫码直达授权页
	scanURL := fmt.Sprintf(
		"https://open.weixin.qq.com/wxaopen/safe/bindcomponent?action=bindcomponent&auth_type=1&no_scan=1&component_appid=%s&pre_auth_code=%s&redirect_uri=%s#wechat_redirect",
		platformAppID, preAuthCode, url.QueryEscape(redirectURI),
	)
	return authURL, scanURL, ticket, nil
}

1.3极简验证:无需前端代码,用 Postman 或浏览器直接生成与扫码

作为后端或全栈开发者调试时,完全不需要提前去写复杂的前端代码,直接通过接口就能完成验证:

方式一:Postman / 浏览器调用接口获取链接

直接向你的服务发起 GET 请求(将 redirect_uri 替换为你的穿透域名回调地址):

GET http://127.0.0.1:4307/v1/open/auth-url?redirect_uri=https://scrm-dev.r10.cpolar.top/v1/open/auth-success

接口返回带有微信官方前缀的授权链接:

{
    "code": 0,
    "msg": "OK",
    "data": {
        "auth_url": "https://mp.weixin.qq.com/cgi-bin/componentloginpage?component_appid=wx391b50c00911423a&pre_auth_code=preauthcode@@@j3Ao3zHByB0CAQdbJ9vjsnQaCIbjAUm-UFnFRzl160IniwSiGfeEdemCw6zodAdQocpUMNThxH4lI5ZztIqWGA&redirect_uri=https%3A%2F%2F287a01f1.r1.cpolar.top%2Fv1%2Fopen%2Fauth-success%3Faccount_id%3D42735d9f-47b6-4b8d-82dc-17de9d81cdc1%26ticket%3D415422ef-4fcd-4cdc-b3cc-4020b3953f3f&auth_type=1",
        "scan_url": "https://open.weixin.qq.com/wxaopen/safe/bindcomponent?action=bindcomponent&auth_type=1&no_scan=1&component_appid=wx391b50c00911423a&pre_auth_code=preauthcode@@@j3Ao3zHByB0CAQdbJ9vjsnQaCIbjAUm-UFnFRzl160IniwSiGfeEdemCw6zodAdQocpUMNThxH4lI5ZztIqWGA&redirect_uri=https%3A%2F%2F287a01f1.r1.cpolar.top%2Fv1%2Fopen%2Fauth-success%3Faccount_id%3D42735d9f-47b6-4b8d-82dc-17de9d81cdc1%26ticket%3D415422ef-4fcd-4cdc-b3cc-4020b3953f3f#wechat_redirect",
        "stream_url": "/v1/open/auth-stream?ticket=415422ef-4fcd-4cdc-b3cc-4020b3953f3f",
        "ticket": "415422ef-4fcd-4cdc-b3cc-4020b3953f3f"
    }
}

方式二:直接扫码测试
  • 电脑端浏览器直接打开 auth_url:屏幕上会直接展示微信官方的绿色授权二维码,掏出手机微信扫码即可。
  • 手机微信直接访问 scan_url:或者把 scan_url 复制到微信“文件传输助手”中点击打开,可以直接调起授权页面,甚至连扫码都省了!
  • 在线二维码工具:也可以把 scan_url 复制到任意在线二维码生成器(如草料二维码 cli.im)生成一个二维码直接扫。

💡 如果后续接入前端管理系统,前端也仅仅是拿 scan_url 渲染成一个弹窗二维码而已,核心协议逻辑完全在后端。


步骤 2:管理员扫码授权与经典排坑

将生成的 auth_url 渲染为二维码后,管理员使用手机微信扫码。

2.1最经典的排坑:回调域名与【授权发起页域名】不一致

在本地开发调试阶段扫码后,大概率会先撞上一个微信官方的安全拦截页:

这个拦截其实是好消息:它说明你的 pre_auth_code 已经成功生成,且成功连通了微信官方服务器,只差最后这一步“域名一致性”匹配。

为什么微信会拦截? 微信开放平台有一条非常严格的安全策略:

授权完成后的回调地址(redirect_uri)的域名,必须与你在开放平台后台登记的【授权发起页域名】完全一致。

对比两边的域名配置,矛盾一目了然:

域名来源错误的值示例产生的冲突与原因
请求传给微信的 redirect_urihttp://localhost:4300/scrm/oa/list微信后台严禁登记 localhost 作为授权域名
微信后台【开发配置】登记的域名scrm-dev.r10.cpolar.top本地测试填的是内网穿透公网域名,与 localhost 冲突

冲突根源:手机微信扫码后,发现授权成功要重定向的目标是 localhost,与后台登记的安全域名不匹配。为防止第三方恶意钓鱼劫持授权码,微信直接阻断并提示:“请确认授权入口页所在域名与授权后回调页所在域名相同”。

解决方案:调用 auth-url 接口时,将 redirect_uri 显式传为与后台登记完全一致的内网穿透公网地址:

GET http://127.0.0.1:4307/v1/open/auth-url?redirect_uri=https://scrm-dev.r10.cpolar.top/v1/open/auth-success

2.2正常授权流程的三步交互

域名匹配成功后,手机微信扫码后会依次经历以下标准界面:

1. 选择授权公众号
① 选择要接管的公众号
2. 勾选管理权限
②-① 确认权限集列表(首次授权)
2. 勾选管理权限
②-② 确认权限集列表(更新授权)
3. 确认同意授权
③ 点击【同意授权】

步骤 3:授权成功后的回调与绑定闭环

管理员在手机端点击 【同意授权】 后,微信服务器会立即 302 重定向到我们在授权链接中指定的 redirect_uri,并在 URL 中携带授权凭证:auth_code 与 expires_in。系统支持两种标准的闭环处理形式:

形式 A:后端直收回调(全自动换证落库)

将授权链接的 redirect_uri 直接指向后端的接收端点(如 https://你的域名/v1/open/auth-success):

  1. 微信客户端在扫码同意后,直接向后端端点发起 GET 请求:
    GET /v1/open/auth-success?auth_code=queryauthcode@@@xxx&expires_in=600
    
  2. 服务端在 Controller 中直接拦截并提取 auth_code,调用 Service 完成换证与持久化落库。
  3. 服务端向手机微信端返回一个友好的极简原生 HTML 成功页,提示管理员“授权成功,请关闭窗口返回电脑继续操作”。

形式 B:前端中转 / Postman 调试模式(主动调用 /v1/open/bind)

将授权链接的 redirect_uri 指向前台页面或在本地测试:

  1. 微信将页面重定向到前端后台(或浏览器直接打开的测试页),URL 携带 auth_code:
    http://localhost:3000/scrm/oa/list?auth_code=xxxxxx&expires_in=600
    
  2. 触发绑定调用:
    • 生产环境:前端管理后台页面在 Mounted 时检测 URL 中的 auth_code,主动发起 POST /v1/open/bind 请求;
    • 本地调试/写笔记:在浏览器地址栏直接复制 auth_code 的值,打开 Postman 调用 POST /v1/open/bind 接口完成绑定与落库验证:
    POST http://localhost:4307/v1/open/bind
    Content-Type: application/json
    
    {
      "auth_code": "queryauthcode@@@xxx",
      "account_id": "dev_admin_test"
    }
    
  3. 绑定成功后,后端完成公众号资料与永久凭证入库,返回商户绑定的公众号信息。

步骤 4:核心后端实现与架构规范

无论采用哪种模式,后端的底层核心动作完全一致:拿到 auth_code 换取令牌与资料 → 领域实体与持久化实体解耦 → 持久化入库。

4.1Web 控制器实现:商户绑定端点 (web/openplatform.go)

// 注册绑定端点
g.POST("/bind", iam.RequireAction("scrm:open:auth:Bind", "绑定授权公众号"), h.BindAuthorizer)

// BindAuthorizerReq 绑定请求体
type BindAuthorizerReq struct {
	AuthCode  string `json:"auth_code" binding:"required"`
	AccountID string `json:"account_id"`
}

// BindAuthorizer 商户扫码授权成功后,使用 auth_code 换取 refresh_token 并绑定入库
func (h *OpenPlatformHandler) BindAuthorizer(ctx *gin.Context) {
	var req BindAuthorizerReq
	if valid, errs := validator.BindAndValid(ctx, &req); !valid {
		h.l.Warn("绑定授权公众号参数校验失败", zap.Any("errs", errs))
		response.Fail(ctx, ecode.InvalidParams.WithDetails(errs))
		return
	}

	accountID := req.AccountID
	if accountID == "" {
		accountID = ctx.GetString("userId")
	}
	if accountID == "" {
		accountID = "dev_admin_test"
	}

	// 调用业务层换证并持久化落库
	authorizer, err := h.svc.FetchAndSaveAuthorizerInfo(ctx, accountID, req.AuthCode)
	if err != nil {
		h.l.Error("换取并绑定授权微信账号信息失败", zap.Error(err))
		response.Fail(ctx, ecode.ServerError.WithDetails(err.Error()))
		return
	}

	h.l.Info("商户成功绑定微信账号",
		zap.String("appid", authorizer.AuthorizerAppID),
		zap.String("nick_name", authorizer.NickName),
		zap.String("user_name", authorizer.UserName),
		zap.String("principal_name", authorizer.PrincipalName),
		zap.Int("account_type", authorizer.AccountType),
		zap.Int("verify_type", authorizer.VerifyType),
	)

	response.Success(ctx, authorizer)
}

4.2业务层实现:换取永久凭据与公众号详细资料 (service/openplatform.go)

// FetchAndSaveAuthorizerInfo 使用 auth_code 换取被授权方的永久刷新令牌与详情并持久化
func (s *openPlatformService) FetchAndSaveAuthorizerInfo(ctx context.Context, accountID, authCode string) (*domain.WxAuthorizer, error) {
	platformAppID := s.cfg.OpenPlatform.AppId
	compToken, err := s.GetComponentAccessToken(ctx, platformAppID)
	if err != nil {
		return nil, fmt.Errorf("get component access token failed: %w", err)
	}

	// 1. 使用一次性 auth_code 换取该公众号的调用凭证与永久 refresh_token
	queryAuthURL := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/component/api_query_auth?component_access_token=%s", compToken)
	reqBody, _ := json.Marshal(map[string]string{
		"component_appid":    platformAppID,
		"authorization_code": authCode,
	})
	resp, err := s.httpClient.Post(queryAuthURL, "application/json", bytes.NewReader(reqBody))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var queryAuthRes struct {
		AuthorizationInfo struct {
			AuthorizerAppid        string `json:"authorizer_appid"`
			AuthorizerRefreshToken string `json:"authorizer_refresh_token"`
			FuncInfo               []any  `json:"func_info"`
		} `json:"authorization_info"`
		ErrCode int `json:"errcode"`
	}
	_ = json.NewDecoder(resp.Body).Decode(&queryAuthRes)
	authorizerAppID := queryAuthRes.AuthorizationInfo.AuthorizerAppid
	refreshToken := queryAuthRes.AuthorizationInfo.AuthorizerRefreshToken

	// 2. 调用微信接口拉取该公众号的真实资料(昵称、头像、主体名称、原始ID等)
	getInfoURL := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_info?component_access_token=%s", compToken)
	infoBody, _ := json.Marshal(map[string]string{
		"component_appid":  platformAppID,
		"authorizer_appid": authorizerAppID,
	})
	infoResp, _ := s.httpClient.Post(getInfoURL, "application/json", bytes.NewReader(infoBody))
	var nickName, headImg, userName, principalName string
	if infoResp != nil {
		defer infoResp.Body.Close()
		var infoRes struct {
			AuthorizerInfo struct {
				NickName      string `json:"nick_name"`
				HeadImg       string `json:"head_img"`
				UserName      string `json:"user_name"` // 原始ID (gh_xxx)
				PrincipalName string `json:"principal_name"`
			} `json:"authorizer_info"`
		}
		_ = json.NewDecoder(infoResp.Body).Decode(&infoRes)
		nickName = infoRes.AuthorizerInfo.NickName
		headImg = infoRes.AuthorizerInfo.HeadImg
		userName = infoRes.AuthorizerInfo.UserName
		principalName = infoRes.AuthorizerInfo.PrincipalName
	}

	// 3. 构建 Domain 实体并交由 Repository 落库
	funcInfoBytes, _ := json.Marshal(queryAuthRes.AuthorizationInfo.FuncInfo)
	authorizer := &domain.WxAuthorizer{
		ID:                     uuid.NewV4().String(),
		AccountID:              accountID,
		AuthorizerAppID:        authorizerAppID,
		NickName:               nickName,
		HeadImg:                headImg,
		UserName:               userName,
		PrincipalName:          principalName,
		AuthorizerRefreshToken: refreshToken, // 接管的核心:必须落库的永久令牌
		FuncInfo:               string(funcInfoBytes),
		Status:                 "NORMAL",
		UpdatedAt:              time.Now().UnixMilli(),
	}

	if err := s.repo.SaveAuthorizer(ctx, authorizer); err != nil {
		return nil, fmt.Errorf("持久化公众号失败: %w", err)
	}
	return authorizer, nil
}

4.3架构分层规范:Domain 层与 DAO 层的防腐隔离

遵循清晰的微服务分层边界规范:

  • Domain 层(业务实体):纯净无污染,严禁携带任何 ORM 专属标签(如 gorm 标签、TableName() 等)。
  • DAO 层(持久化实体 PO):专职维护数据库表字段、索引和标签。
  • Repository 层(防腐转换):负责在两者之间做双向转换,屏蔽底层存储技术细节。
// 1. DAO 层持久化实体 (dao/openplatform.go)
type WxAuthorizer struct {
    ID                     string `gorm:"primaryKey;type:char(36)"`
    AccountID              string `gorm:"column:account_id;type:char(36);not null;index"`
    AuthorizerAppID        string `gorm:"column:authorizer_appid;type:varchar(100);not null;uniqueIndex"`
    NickName               string `gorm:"column:nick_name;type:varchar(100)"`
    AuthorizerRefreshToken string `gorm:"column:authorizer_refresh_token;type:varchar(200);not null"`
    Status                 string `gorm:"column:status;type:varchar(32);default:NORMAL"`
    UpdatedAt              int64  `gorm:"column:updated_at;autoUpdateTime:milli"`
}

// 2. Repository 层双向防腐转换 (repository/openplatform.go)
func (r *openPlatformRepository) SaveAuthorizer(ctx context.Context, authorizer *domain.WxAuthorizer) error {
    if authorizer == nil {
        return nil
    }
    return r.dao.SaveOrUpdateAuthorizer(ctx, r.toDaoEntity(authorizer))
}

func (r *openPlatformRepository) toDaoEntity(d *domain.WxAuthorizer) *dao.WxAuthorizer {
    return &dao.WxAuthorizer{
        ID:                     d.ID,
        AccountID:              d.AccountID,
        AuthorizerAppID:        d.AuthorizerAppID,
        NickName:               d.NickName,
        AuthorizerRefreshToken: d.AuthorizerRefreshToken,
        Status:                 d.Status,
        UpdatedAt:              d.UpdatedAt,
    }
}

打开数据库查看 wx_authorizer 表,你会发现该测试公众号的昵称、头像、原始 ID、AppID,以及最重要的永久凭证 authorizer_refresh_token 已经完整落库。至此,SCRM 系统全自动代管公众号的授权闭环彻底打通!


常见报错排查速查表

把实操中容易踩的坑集中成一张表,扫码不成功时先来这里对号入座:

现象 / 报错原因修复对应章节
换 token 报 61004 / 40164当前公网出口 IP 不在平台的 IP 白名单把 curl ifconfig.me 查到的 IP 加进【IP白名单】微信开放平台应用创建与参数配置·3
第三方平台接口报 ticket 无效用旧的 component_verify_ticket 去换 token(微信校验"最新值")确认授权事件接收 URL 可达、Redis 里存的是最新 ticket工程落地·网关一
扫码提示"该平台尚未全网发布,无法授权"第三方平台处于"开发中 / 未全网发布"状态把要测的公众号加进测试白名单(授权测试账号列表)微信开放平台应用创建与参数配置·6
提示"授权入口页域名与回调页域名不同"redirect_uri 用了 localhost 等与后台登记域名不一致的地址让 redirect_uri 与【授权发起页域名】一致,本地开发用内网穿透域名扫码授权接管·步骤 2
AppSecret 忘了 / 找不回来微信只展示一次重置 AppSecret 并立即复制保存微信开放平台应用创建与参数配置·2
接管后收不到粉丝消息 / 事件消息与事件接收 URL 没配对,或漏了 $APPID$ 占位符URL 形如 https://你的域名/v1/open/msg-callback/$APPID$,$APPID$ 原样保留微信开放平台应用创建与参数配置·5
用 auth_code 换 token 报 code 失效授权码一次性、短时有效拿到 auth_code 后立即调 bind,别拿同一次授权反复手工测试扫码授权接管·步骤 4

对应微信官方文档入口

开发 SCRM 第三方平台,查阅的全部是微信开放平台官方文档:

  1. 第三方平台总览与开发指南
  2. 代公众号发起授权流程(预授权码与扫码技术规范)
  3. 验证票据推送(component_verify_ticket)规范
  4. 获取/刷新 authorizer_access_token 接口
  5. 代公众号接收与处理粉丝消息/事件