Files
sundynix-agentix/sundynix-gateway/internal/wechat/mp.go
T
Blizzard 68608c1592 feat(wechat): 支付成功后微信客服消息推回执(积分包/订阅购买)
用户扫码付款后(在 48h 互动窗口内),主动推一条客服消息回执:
到账积分/开通套餐 + 当前余额。只在 MarkOrderPaid changed=true 首次到账时推,
异步+超时隔离,失败只记日志、绝不影响入账。非微信用户自动跳过。

不做「周期刷新提醒」:客服消息受 48h 窗口限制,定时刷新那刻用户多半已超窗、
必然失败,那类隔天提醒须用模板消息(暂缓)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:23:41 +08:00

284 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package wechat 实现微信公众号(服务号)「带参数二维码 + 关注/扫码事件」登录。
//
// 登录即引导关注公众号(涨粉),流程:
// 1. PC 建 login ticket → 后端用 access_token 调「带参数二维码」接口(scene=ticket) → 得微信二维码图
// 2. PC 显示这张微信二维码
// 3. 用户微信扫 → 弹出公众号关注页 → 用户「关注」
// 4. 微信把事件推到我们服务器(消息推送/服务器配置):
// - 未关注用户 → subscribe 事件,EventKey=qrscene_<ticket>
// - 已关注用户 → SCAN 事件,EventKey=<ticket>
// 两种都带 openid(FromUserName)
// 5. 后端按 openid 找/建用户 → ticket 置 authorized
// 6. PC 轮询 → 登录完成
//
// 消息加解密方式用「明文模式」:回调只验签名(Token),不做 AES 解密。走 HTTPS 已足够。
package wechat
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/sundynix/sundynix-shared/secrets"
)
// DefaultWelcome 是关注后自动回复的默认欢迎语(管理端可覆盖,建议填上下载链接)。
const DefaultWelcome = "欢迎关注!\n下载客户端并扫码登录,即可开始体验。"
// Config 是公众号登录所需配置。AppSecret 加密入库、只写不回显(同微信支付 APIv3 密钥)。
type Config struct {
AppID string `json:"appid"`
AppSecret string `json:"app_secret"`
// Token:消息推送签名校验用,与公众平台「服务器配置」里填的一致。
Token string `json:"token"`
// Welcome:用户关注后自动回复的欢迎语(被动回复)。空则用 DefaultWelcome。非密文。
Welcome string `json:"welcome"`
}
// Enabled 报告配置是否完整到可用(登录二维码需要 appid+secret;回调验签需要 token)。
func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" && c.Token != "" }
// EncryptedForStore 返回 AppSecret 已加密的副本,用于落库。
func (c Config) EncryptedForStore() (Config, error) {
if c.AppSecret == "" {
return c, nil
}
enc, err := secrets.Encrypt(c.AppSecret)
if err != nil {
return c, err
}
c.AppSecret = enc
return c, nil
}
// DecryptFromStore 把库内密文 AppSecret 还原为明文。
func (c Config) DecryptFromStore() Config {
if c.AppSecret != "" {
if plain, err := secrets.Decrypt(c.AppSecret); err == nil {
c.AppSecret = plain
}
}
return c
}
// CheckSignature 校验微信消息推送签名:sha1(sort(token,timestamp,nonce))。
// 服务器配置的 URL 验证(GET echostr)与每条事件推送(POST)都用它。
func (c Config) CheckSignature(signature, timestamp, nonce string) bool {
if c.Token == "" {
return false
}
arr := []string{c.Token, timestamp, nonce}
sort.Strings(arr)
h := sha1.Sum([]byte(strings.Join(arr, "")))
return hex.EncodeToString(h[:]) == signature
}
// FetchAccessToken 拉取 access_token(纯函数,缓存交给调用方)。
// 返回 (token, 有效秒数)。出网 IP 必须在公众平台 IP 白名单里,否则报 40164。
func (c Config) FetchAccessToken(ctx context.Context) (string, int, error) {
q := url.Values{}
q.Set("grant_type", "client_credential")
q.Set("appid", c.AppID)
q.Set("secret", c.AppSecret)
endpoint := "https://api.weixin.qq.com/cgi-bin/token?" + q.Encode()
body, err := httpGet(ctx, endpoint)
if err != nil {
return "", 0, err
}
var r struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", 0, fmt.Errorf("解析 access_token 响应失败: %s", strings.TrimSpace(string(body)))
}
if r.ErrCode != 0 || r.AccessToken == "" {
return "", 0, fmt.Errorf("获取 access_token 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
}
return r.AccessToken, r.ExpiresIn, nil
}
// PullToken 从「中控服务器」拉取 access_token(微信官方推荐架构:单点取 token、多点用)。
// 中控(腾讯云静态 IP)负责换 token 并 serve,这样微信 IP 白名单只需填中控 IP;
// gateway 出网 IP(本地动态宽带)不再参与换 token,避免 IP 一变就 40164。
// tokenURL serve 的 JSON 形如 {"access_token":"...","expires_in":7200}secret 走 Bearer 头鉴权。
func PullToken(ctx context.Context, tokenURL, secret string) (string, int, error) {
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(rctx, http.MethodGet, tokenURL, nil)
if err != nil {
return "", 0, err
}
if secret != "" {
req.Header.Set("Authorization", "Bearer "+secret)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", 0, fmt.Errorf("请求中控 token 失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("中控 token 返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var r struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", 0, fmt.Errorf("解析中控 token 失败: %s", strings.TrimSpace(string(body)))
}
if r.AccessToken == "" {
return "", 0, fmt.Errorf("中控返回空 token: %s", strings.TrimSpace(string(body)))
}
if r.ExpiresIn <= 0 {
r.ExpiresIn = 7200 // 中控没给有效期时按微信默认兜底
}
return r.AccessToken, r.ExpiresIn, nil
}
// CreateLoginQR 用带参数「临时」二维码承载 scene(=登录 ticket)。
// expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 <img> 展示的二维码图 URL。
func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
reqBody := map[string]any{
"expire_seconds": expireSec,
"action_name": "QR_STR_SCENE", // 字符串型 scene,便于放我们的随机 ticket
"action_info": map[string]any{"scene": map[string]any{"scene_str": scene}},
}
raw, _ := json.Marshal(reqBody)
endpoint := "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + url.QueryEscape(accessToken)
body, err := httpPost(ctx, endpoint, raw)
if err != nil {
return "", err
}
var r struct {
Ticket string `json:"ticket"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", fmt.Errorf("解析二维码响应失败: %s", strings.TrimSpace(string(body)))
}
if r.ErrCode != 0 || r.Ticket == "" {
return "", fmt.Errorf("创建二维码失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
}
// showqrcode 是微信提供的二维码图地址,ticket 需 URL 编码
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(r.Ticket), nil
}
// SendCustomText 发一条「客服消息」文本给指定 openid(主动推送)。
// 约束:微信只允许在用户 48 小时内与公众号有过互动时下发(否则 45015 errcode)。
// 适合支付回执这类「用户刚操作完」的即时通知;隔天的提醒需改用模板消息。
// 需 access_token(走中控/直连均可)。
func SendCustomText(ctx context.Context, accessToken, openID, content string) error {
reqBody := map[string]any{
"touser": openID,
"msgtype": "text",
"text": map[string]any{"content": content},
}
raw, _ := json.Marshal(reqBody)
endpoint := "https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=" + url.QueryEscape(accessToken)
body, err := httpPost(ctx, endpoint, raw)
if err != nil {
return err
}
var r struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &r); err != nil {
return fmt.Errorf("解析客服消息响应失败: %s", strings.TrimSpace(string(body)))
}
if r.ErrCode != 0 {
return fmt.Errorf("发送客服消息失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
}
return nil
}
// Event 是微信推送的事件(明文 XML)。只取登录/回复需要的字段。
type Event struct {
XMLName xml.Name `xml:"xml"`
MsgType string `xml:"MsgType"` // event
Event string `xml:"Event"` // subscribe / SCAN / unsubscribe ...
EventKey string `xml:"EventKey"` // subscribe: qrscene_<scene>SCAN: <scene>
FromUserName string `xml:"FromUserName"` // 用户 openid
ToUserName string `xml:"ToUserName"` // 公众号原始 ID(回复时作 FromUserName
CreateTime int64 `xml:"CreateTime"` // 事件时间(秒)
}
// IsSubscribe 报告是否是「新关注」事件(用于自动回复欢迎语)。
func (e Event) IsSubscribe() bool { return e.MsgType == "event" && e.Event == "subscribe" }
// BuildTextReply 组装「被动回复」的文本消息 XML(回调 HTTP 响应体)。
// 被动回复不需要 access_token、不受 IP 白名单限制,微信收到即转发给用户。
// toUser=用户 openid=事件的 FromUserName);fromUser=公众号原始 ID=事件的 ToUserName)。
func BuildTextReply(toUser, fromUser, content string, createTime int64) string {
content = strings.ReplaceAll(content, "]]>", "]] >") // 防 CDATA 提前闭合
return fmt.Sprintf(
"<xml><ToUserName><![CDATA[%s]]></ToUserName><FromUserName><![CDATA[%s]]></FromUserName>"+
"<CreateTime>%d</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[%s]]></Content></xml>",
toUser, fromUser, createTime, content)
}
// Scene 从事件里还原出我们的 scene(登录 ticket)。subscribe 事件带 qrscene_ 前缀,SCAN 不带。
func (e Event) Scene() string {
return strings.TrimPrefix(e.EventKey, "qrscene_")
}
// IsLoginScan 报告该事件是否是"扫我们登录二维码"(关注或已关注扫码),并携带 scene。
func (e Event) IsLoginScan() bool {
if e.MsgType != "event" {
return false
}
return (e.Event == "subscribe" || e.Event == "SCAN") && e.Scene() != ""
}
// ParseEvent 解析明文事件 XML。
func ParseEvent(body []byte) (*Event, error) {
var e Event
if err := xml.Unmarshal(body, &e); err != nil {
return nil, err
}
return &e, nil
}
// ---- HTTP 小工具 ----
func httpGet(ctx context.Context, endpoint string) ([]byte, error) {
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(rctx, http.MethodGet, endpoint, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求微信失败: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func httpPost(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(rctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求微信失败: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}