feat(server-go): 微信订阅消息骨架(#11,绑定/订阅/巡检触发,配置占位)

This commit is contained in:
weijuesen
2026-08-12 17:28:07 +08:00
parent 79e524b1d4
commit 921b185da2
9 changed files with 517 additions and 4 deletions
+213
View File
@@ -0,0 +1,213 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
)
const wechatAPIBase = "https://api.weixin.qq.com"
// WechatTemplateKey 风险等级 → 订阅消息场景键(green 不推送)
func WechatTemplateKey(level string) string {
switch level {
case "yellow", "orange", "red":
return "inspection"
default:
return ""
}
}
// IsAuthorized 判断授权模板列表是否包含场景键
func IsAuthorized(authorized []string, key string) bool {
for _, k := range authorized {
if k == key {
return true
}
}
return false
}
// levelZh 风险等级中文
func levelZh(level string) string {
switch level {
case "green":
return "绿"
case "yellow":
return "黄"
case "orange":
return "橙"
case "red":
return "红"
default:
return level
}
}
// BuildSubscribeData 构造订阅消息 datathing1=风险等级 thing2=评分;字段名需与微信模板字段一致)
func BuildSubscribeData(level string, score float64) map[string]map[string]string {
return map[string]map[string]string{
"thing1": {"value": levelZh(level)},
"thing2": {"value": fmt.Sprintf("%.0f分", score)},
}
}
// WechatService 微信小程序订阅消息服务(骨架;未配置时 Configured()=false,调用返回明确错误)
type WechatService struct {
appID string
secret string
baseURL string
accessToken string
tokenExpire time.Time
mu sync.Mutex
httpClient *http.Client
}
// NewWechatService 创建微信服务
func NewWechatService(appID, secret string) *WechatService {
return &WechatService{
appID: appID,
secret: secret,
baseURL: wechatAPIBase,
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
// Configured 是否已配置 AppID/Secret
func (s *WechatService) Configured() bool {
return s.appID != "" && s.secret != ""
}
// Code2Session 用 wx.login 的 code 换取 openid
func (s *WechatService) Code2Session(ctx context.Context, code string) (string, error) {
if !s.Configured() {
return "", fmt.Errorf("微信未配置(WECHAT_APPID/WECHAT_SECRET")
}
u := s.baseURL + "/sns/jscode2session?appid=" + url.QueryEscape(s.appID) +
"&secret=" + url.QueryEscape(s.secret) +
"&js_code=" + url.QueryEscape(code) + "&grant_type=authorization_code"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var out struct {
OpenID string `json:"openid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", err
}
if out.ErrCode != 0 {
return "", fmt.Errorf("微信 code2session 失败 (%d): %s", out.ErrCode, out.ErrMsg)
}
return out.OpenID, nil
}
// getAccessToken 获取并缓存 access_token(提前 60 秒过期)
func (s *WechatService) getAccessToken(ctx context.Context) (string, error) {
s.mu.Lock()
if s.accessToken != "" && time.Now().Before(s.tokenExpire) {
tok := s.accessToken
s.mu.Unlock()
return tok, nil
}
s.mu.Unlock()
if !s.Configured() {
return "", fmt.Errorf("微信未配置(WECHAT_APPID/WECHAT_SECRET")
}
u := s.baseURL + "/cgi-bin/token?grant_type=client_credential&appid=" +
url.QueryEscape(s.appID) + "&secret=" + url.QueryEscape(s.secret)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", err
}
if out.AccessToken == "" {
return "", fmt.Errorf("微信 access_token 获取失败 (%d): %s", out.ErrCode, out.ErrMsg)
}
s.mu.Lock()
s.accessToken = out.AccessToken
s.tokenExpire = time.Now().Add(time.Duration(out.ExpiresIn-60) * time.Second)
s.mu.Unlock()
return out.AccessToken, nil
}
// SendSubscribe 发送订阅消息
func (s *WechatService) SendSubscribe(ctx context.Context, openid, templateID string, data map[string]map[string]string, page string) error {
token, err := s.getAccessToken(ctx)
if err != nil {
return err
}
payload := map[string]any{
"touser": openid,
"template_id": templateID,
"data": data,
}
if page != "" {
payload["page"] = page
}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
u := s.baseURL + "/cgi-bin/message/subscribe/send?access_token=" + url.QueryEscape(token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var out struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &out); err != nil {
return err
}
if out.ErrCode != 0 {
return fmt.Errorf("微信订阅消息发送失败 (%d): %s", out.ErrCode, out.ErrMsg)
}
return nil
}