162 lines
4.7 KiB
Go
162 lines
4.7 KiB
Go
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
|
||
}
|