This commit is contained in:
2026-03-13 15:51:59 +08:00
parent 4db2386bbf
commit 4e91f4cede
133 changed files with 19502 additions and 37 deletions

View File

@@ -0,0 +1,161 @@
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
}