draft
This commit is contained in:
161
backend/internal/service/wechat.go
Normal file
161
backend/internal/service/wechat.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user