393 lines
12 KiB
Go
393 lines
12 KiB
Go
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] + "..."
|
||
}
|