增加微信通知功能

This commit is contained in:
2026-09-16 18:06:18 +08:00
parent b48688cbab
commit f386899625
23 changed files with 3539 additions and 18 deletions
+282
View File
@@ -0,0 +1,282 @@
package wechatmp
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// =============================================================
// 微信公众平台服务端 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 ""
}
// 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 == "" {
return "", &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg}
}
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 == "" {
return "", &APIError{API: "创建带参二维码", Code: resp.ErrCode, Msg: resp.ErrMsg}
}
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] + "..."
}