Files
pay-bridge/backend/internal/service/wechat.go
2026-03-13 15:51:59 +08:00

162 lines
4.7 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 service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"pay-bridge/internal/model"
"pay-bridge/internal/repository"
"pay-bridge/pkg/crypto"
)
const (
wxTokenURL = "https://api.weixin.qq.com/cgi-bin/token"
wxSendMsgURL = "https://api.weixin.qq.com/cgi-bin/message/template/send"
accessTokenTTL = 90 * time.Minute // 微信 access_token 有效期 2h提前 30min 刷新
)
// WechatService 微信模板消息服务
type WechatService struct {
wechatRepo *repository.WechatRepository
cryptoKey string
httpClient *http.Client
// 内存缓存 access_token避免频繁调用微信接口
tokenCache map[string]*tokenEntry
}
type tokenEntry struct {
token string
expiresAt time.Time
}
func NewWechatService(wechatRepo *repository.WechatRepository, cryptoKey string) *WechatService {
return &WechatService{
wechatRepo: wechatRepo,
cryptoKey: cryptoKey,
httpClient: &http.Client{Timeout: 10 * time.Second},
tokenCache: make(map[string]*tokenEntry),
}
}
// SendPaymentNotify 发送支付成功通知
func (s *WechatService) SendPaymentNotify(ctx context.Context, appID, tradeNo, openID string, amount int64) error {
binding, err := s.wechatRepo.GetBinding(ctx, appID)
if err != nil || binding == nil {
return nil // 未配置微信通知,跳过
}
data := map[string]any{
"trade_no": map[string]string{"value": tradeNo},
"amount": map[string]string{"value": fmt.Sprintf("%.2f 元", float64(amount)/100)},
"time": map[string]string{"value": time.Now().Format("2006-01-02 15:04:05")},
}
return s.sendTemplate(ctx, appID, binding, openID, tradeNo, data)
}
// sendTemplate 发送模板消息
func (s *WechatService) sendTemplate(ctx context.Context, appID string, binding *model.WechatBinding,
openID, tradeNo string, data map[string]any) error {
log := &model.WechatMessageLog{
AppID: appID,
TradeNo: tradeNo,
OpenID: openID,
TemplateID: binding.TemplateID,
Status: model.WechatMessageStatusPending,
}
if err := s.wechatRepo.CreateMessageLog(ctx, log); err != nil {
return err
}
token, err := s.getAccessToken(ctx, binding)
if err != nil {
updates := map[string]any{"status": model.WechatMessageStatusFailed, "err_msg": err.Error()}
s.wechatRepo.UpdateMessageLog(ctx, log.ID, updates)
return err
}
payload := map[string]any{
"touser": openID,
"template_id": binding.TemplateID,
"data": data,
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("%s?access_token=%s", wxSendMsgURL, token)
resp, err := s.httpClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
updates := map[string]any{"status": model.WechatMessageStatusFailed, "err_msg": err.Error()}
s.wechatRepo.UpdateMessageLog(ctx, log.ID, updates)
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
json.Unmarshal(respBody, &result)
now := time.Now()
if result.ErrCode == 0 {
updates := map[string]any{"status": model.WechatMessageStatusSuccess, "sent_at": now}
s.wechatRepo.UpdateMessageLog(ctx, log.ID, updates)
slog.InfoContext(ctx, "wechat template sent", "trade_no", tradeNo, "open_id", openID)
} else {
errMsg := fmt.Sprintf("errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
updates := map[string]any{"status": model.WechatMessageStatusFailed, "err_msg": errMsg}
s.wechatRepo.UpdateMessageLog(ctx, log.ID, updates)
return fmt.Errorf("wechat send failed: %s", errMsg)
}
return nil
}
// getAccessToken 获取微信 access_token带内存缓存
func (s *WechatService) getAccessToken(ctx context.Context, binding *model.WechatBinding) (string, error) {
if entry, ok := s.tokenCache[binding.WxAppID]; ok && time.Now().Before(entry.expiresAt) {
return entry.token, nil
}
// 解密 secret
secret, err := crypto.Decrypt(binding.WxSecret, s.cryptoKey)
if err != nil {
return "", fmt.Errorf("decrypt wx secret: %w", err)
}
url := fmt.Sprintf("%s?grant_type=client_credential&appid=%s&secret=%s",
wxTokenURL, binding.WxAppID, secret)
resp, err := s.httpClient.Get(url)
if err != nil {
return "", fmt.Errorf("get wx token: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
if result.ErrCode != 0 {
return "", fmt.Errorf("wx token error: %d %s", result.ErrCode, result.ErrMsg)
}
s.tokenCache[binding.WxAppID] = &tokenEntry{
token: result.AccessToken,
expiresAt: time.Now().Add(accessTokenTTL),
}
return result.AccessToken, nil
}