Files
yunzerwebsiteallinone/go/services/wechatmp/api.go
T
2026-10-31 01:21:55 +08:00

393 lines
12 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 wechatmp
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/beego/beego/v2/core/logs"
)
// =============================================================
// 微信公众平台服务端 API 调用(access_token / 带参二维码 / 模板消息 / 订阅通知 / 用户信息)
// =============================================================
const apiBase = "https://api.weixin.qq.com"
var httpClient = &http.Client{Timeout: 10 * time.Second}
// APIError 微信接口业务错误(HTTP 200 但 errcode 非 0)。
// Error() 输出保留 errcode / errmsg 原始信息,便于日志检索与前端展示。
type APIError struct {
API string // 调用的接口(如「获取 access_token」)
Code int
Msg string
}
func (e *APIError) Error() string {
return fmt.Sprintf("%s 失败:errcode=%d errmsg=%s", e.API, e.Code, e.Msg)
}
// Explain 返回常见 errcode 的中文排查建议;未知错误码返回空串。
func (e *APIError) Explain() string {
switch e.Code {
case 40013:
return "AppID 无效:请核对微信后台「设置与开发 → 基本配置」中的 AppID"
case 40125:
return "AppSecret 错误:请在微信后台重置后复制新的 AppSecret 填入本页"
case 40164, 61004:
return "服务器出口 IP 不在白名单:请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP(服务器执行 curl ifconfig.me 可查看)"
case 45009:
return "接口调用频次超限:请稍后重试"
case 48001:
return "接口未获授权:需认证服务号,或该账号未开通此接口能力"
}
return ""
}
// ============================ 出口 IP 探测(40164 IP 白名单排查用) ============================
var (
outboundIPMu sync.Mutex
outboundIPCache string
outboundIPAt time.Time
)
// outboundIPTTL 出口 IP 缓存时长(探测失败也按此负缓存,避免每次请求都外网探测)
const outboundIPTTL = 5 * time.Minute
// ipDetectServices 出口 IP 探测服务,按顺序尝试,任一返回 IPv4 即采用
var ipDetectServices = []string{"https://ifconfig.me/ip", "https://api.ipify.org", "https://myip.ipip.net"}
var ipv4Re = regexp.MustCompile(`\d{1,3}(?:\.\d{1,3}){3}`)
// OutboundIP 返回本服务器访问公网时的出口 IP(微信后台「IP白名单」需填入该 IP)。
// 结果内存缓存 5 分钟;服务器无法访问探测服务时返回空串,不影响主流程。
func OutboundIP() string {
outboundIPMu.Lock()
if time.Since(outboundIPAt) < outboundIPTTL {
ip := outboundIPCache
outboundIPMu.Unlock()
return ip
}
outboundIPMu.Unlock()
ip := detectOutboundIP()
outboundIPMu.Lock()
outboundIPCache = ip
outboundIPAt = time.Now()
outboundIPMu.Unlock()
return ip
}
func detectOutboundIP() string {
client := &http.Client{Timeout: 3 * time.Second}
for _, svc := range ipDetectServices {
resp, err := client.Get(svc)
if err != nil {
continue
}
body, rerr := io.ReadAll(io.LimitReader(resp.Body, 256))
resp.Body.Close()
if rerr != nil {
continue
}
if ip := firstIPv4(string(body)); ip != "" {
return ip
}
}
return ""
}
// firstIPv4 取文本中第一个合法 IPv4(形如 1.2.3.4),无则返回空串。
func firstIPv4(s string) string {
m := ipv4Re.FindString(s)
if m == "" || net.ParseIP(m) == nil {
return ""
}
return m
}
// ErrMsgIPs 从微信 errmsg 中提取 IP(如 "invalid ip 1.2.3.4 ipv6 ::ffff:1.2.3.4")。
func ErrMsgIPs(errmsg string) []string {
var out []string
seen := make(map[string]bool)
for _, m := range ipv4Re.FindAllString(errmsg, -1) {
if net.ParseIP(m) == nil || seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
return out
}
// logAPIError 记录微信接口错误详情:
// errcode / errmsg / 微信提示的 IP / 本服务器出口 IP / 排查建议只落服务端日志。
// 注册页、登录页等匿名可访问页面不应回显这些信息(暴露服务器出口 IP 与内部错误)。
func logAPIError(scene string, err error) {
if err == nil {
return
}
detail := err.Error()
var ae *APIError
if errors.As(err, &ae) && ae != nil {
if hint := ae.Explain(); hint != "" {
detail += ";" + hint
}
if ips := ErrMsgIPs(ae.Msg); len(ips) > 0 {
detail += ";微信提示的 IP:" + strings.Join(ips, ", ")
}
}
if ip := OutboundIP(); ip != "" {
detail += ";本服务器出口 IP:" + ip
}
logs.Warning("[wechat-mp] %s 失败:%s", scene, detail)
}
// ExplainError 提取微信接口错误的中文排查建议;无可用建议时返回 ("", false)。
func ExplainError(err error) (string, bool) {
var ae *APIError
if errors.As(err, &ae) {
if hint := ae.Explain(); hint != "" {
return hint, true
}
}
return "", false
}
// GetAccessToken 获取 access_token(内存缓存,提前 5 分钟刷新)
func GetAccessToken(cfg *Config) (string, error) {
if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
return "", fmt.Errorf("AppID / AppSecret 未配置")
}
tokenMu.Lock()
if tokenCache.appID == cfg.AppID && tokenCache.token != "" && time.Now().Before(tokenCache.expiresAt) {
t := tokenCache.token
tokenMu.Unlock()
return t, nil
}
tokenMu.Unlock()
api := fmt.Sprintf("%s/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
apiBase, url.QueryEscape(cfg.AppID), url.QueryEscape(cfg.AppSecret))
var resp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := httpGetJSON(api, &resp); err != nil {
return "", err
}
if resp.AccessToken == "" {
apiErr := &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg}
logAPIError("获取 access_token", apiErr)
return "", apiErr
}
ttl := time.Duration(resp.ExpiresIn) * time.Second
if ttl <= tokenMinTTL {
ttl = time.Hour
}
tokenMu.Lock()
tokenCache = tokenEntry{appID: cfg.AppID, token: resp.AccessToken, expiresAt: time.Now().Add(ttl - tokenMinTTL)}
tokenMu.Unlock()
return resp.AccessToken, nil
}
// CreateQRCode 创建带参数临时二维码(字符串场景值),返回二维码图片地址。
// scene 会通过「关注事件的 EventKey(qrscene_<scene>)/ 扫码事件的 EventKey(<scene>)」回传。
func CreateQRCode(cfg *Config, scene string, expireSeconds int) (string, error) {
token, err := GetAccessToken(cfg)
if err != nil {
return "", err
}
if expireSeconds <= 0 || expireSeconds > 2592000 {
expireSeconds = 300
}
payload := map[string]interface{}{
"expire_seconds": expireSeconds,
"action_name": "QR_STR_SCENE",
"action_info": map[string]interface{}{
"scene": map[string]string{"scene_str": scene},
},
}
api := fmt.Sprintf("%s/cgi-bin/qrcode/create?access_token=%s", apiBase, url.QueryEscape(token))
var resp struct {
Ticket string `json:"ticket"`
ExpireSeconds int `json:"expire_seconds"`
URL string `json:"url"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := httpPostJSON(api, payload, &resp); err != nil {
return "", err
}
if resp.Ticket == "" {
apiErr := &APIError{API: "创建带参二维码", Code: resp.ErrCode, Msg: resp.ErrMsg}
logAPIError("创建带参二维码", apiErr)
return "", apiErr
}
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(resp.Ticket), nil
}
// SendTemplateMessage 发送模板消息(需认证服务号 + 模板ID)
// data 键为模板字段名(first / keyword1 / remark ...),值为文本内容。
func SendTemplateMessage(cfg *Config, openid, jumpURL string, data map[string]string) error {
if cfg.TemplateID == "" {
return fmt.Errorf("未配置模板消息 ID(认证后在公众号后台申请)")
}
token, err := GetAccessToken(cfg)
if err != nil {
return err
}
fields := make(map[string]map[string]string, len(data))
for k, v := range data {
fields[k] = map[string]string{"value": v}
}
payload := map[string]interface{}{
"touser": openid,
"template_id": cfg.TemplateID,
"data": fields,
}
if jumpURL != "" {
payload["url"] = jumpURL
}
api := fmt.Sprintf("%s/cgi-bin/message/template/send?access_token=%s", apiBase, url.QueryEscape(token))
var resp struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
MsgID int64 `json:"msgid"`
}
if err := httpPostJSON(api, payload, &resp); err != nil {
return err
}
if resp.ErrCode != 0 {
return &APIError{API: "发送模板消息", Code: resp.ErrCode, Msg: resp.ErrMsg}
}
return nil
}
// SendSubscribeMessage 发送订阅通知(一次性订阅,需用户先在页面完成订阅授权)
func SendSubscribeMessage(cfg *Config, openid, templateID, page string, data map[string]string) error {
if templateID == "" {
return fmt.Errorf("未配置订阅通知模板 ID")
}
token, err := GetAccessToken(cfg)
if err != nil {
return err
}
fields := make(map[string]map[string]string, len(data))
for k, v := range data {
fields[k] = map[string]string{"value": v}
}
payload := map[string]interface{}{
"touser": openid,
"template_id": templateID,
"data": fields,
}
if page != "" {
payload["page"] = page
}
api := fmt.Sprintf("%s/cgi-bin/message/subscribe/bizsend?access_token=%s", apiBase, url.QueryEscape(token))
var resp struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := httpPostJSON(api, payload, &resp); err != nil {
return err
}
if resp.ErrCode != 0 {
return &APIError{API: "发送订阅通知", Code: resp.ErrCode, Msg: resp.ErrMsg}
}
return nil
}
// UserInfo 用户基本信息(昵称/头像等;未认证公众号可能接口受限,调用失败由上层忽略)
type UserInfo struct {
OpenID string `json:"openid"`
Nickname string `json:"nickname"`
Sex int `json:"sex"`
City string `json:"city"`
Province string `json:"province"`
Country string `json:"country"`
HeadImgURL string `json:"headimgurl"`
Subscribe int `json:"subscribe"`
}
// FetchUserInfo 拉取用户基本信息(失败不算错,由调用方容错)
func FetchUserInfo(cfg *Config, openid string) (*UserInfo, error) {
token, err := GetAccessToken(cfg)
if err != nil {
return nil, err
}
api := fmt.Sprintf("%s/cgi-bin/user/info?access_token=%s&openid=%s&lang=zh_CN",
apiBase, url.QueryEscape(token), url.QueryEscape(openid))
var resp struct {
UserInfo
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := httpGetJSON(api, &resp); err != nil {
return nil, err
}
if resp.ErrCode != 0 {
return nil, &APIError{API: "获取用户信息", Code: resp.ErrCode, Msg: resp.ErrMsg}
}
return &resp.UserInfo, nil
}
// ============================ HTTP 辅助 ============================
func httpGetJSON(api string, out interface{}) error {
resp, err := httpClient.Get(api)
if err != nil {
return fmt.Errorf("请求微信接口失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取微信响应失败: %w", err)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200))
}
return nil
}
func httpPostJSON(api string, payload interface{}, out interface{}) error {
bs, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(api, "application/json; charset=utf-8", bytes.NewReader(bs))
if err != nil {
return fmt.Errorf("请求微信接口失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取微信响应失败: %w", err)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200))
}
return nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}