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 }