Files
qnc-server-v3/app/main/api/internal/service/xpayService.go

350 lines
9.8 KiB
Go
Raw Normal View History

2026-06-06 11:52:06 +08:00
package service
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"qnc-server/app/main/api/internal/config"
"qnc-server/app/main/model"
"qnc-server/pkg/lzkit/lzUtils"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/redis"
)
const (
xpaySessionKeyFmt = "qnc:wx:xpay:session:%s"
xpayNotifiedKeyFmt = "qnc:xpay:notified:%s"
xpayAccessTokenKey = "qnc:wx:xpay:access_token"
xpayMode = "short_series_goods"
xpayVirtualPaymentURI = "requestVirtualPayment"
)
// XpayPrepayData 前端 wx.requestVirtualPayment 参数
type XpayPrepayData struct {
Provider string `json:"provider"`
Mode string `json:"mode"`
SignData string `json:"signData"`
PaySig string `json:"paySig"`
Signature string `json:"signature"`
}
type XpayService struct {
config config.Config
redis *redis.Redis
}
func NewXpayService(c config.Config, r *redis.Redis) *XpayService {
return &XpayService{config: c, redis: r}
}
func (s *XpayService) Enabled() bool {
return s.config.WechatXpay.Enabled && s.config.WechatXpay.OfferId != "" && s.config.WechatXpay.AppKey != ""
}
func (s *XpayService) Env() int {
return s.config.WechatXpay.Env
}
func (s *XpayService) SessionKeyRedisKey(userID string) string {
return fmt.Sprintf(xpaySessionKeyFmt, userID)
}
func (s *XpayService) SaveSessionKey(ctx context.Context, userID, sessionKey string) error {
ttl := int(s.config.WechatXpay.SessionKeyTTL)
if ttl <= 0 {
ttl = int(s.config.JwtAuth.AccessExpire)
}
return s.redis.SetexCtx(ctx, s.SessionKeyRedisKey(userID), sessionKey, ttl)
}
func (s *XpayService) GetSessionKey(ctx context.Context, userID string) (string, error) {
val, err := s.redis.GetCtx(ctx, s.SessionKeyRedisKey(userID))
if err != nil {
return "", err
}
if val == "" {
return "", redis.Nil
}
return val, nil
}
func hmacSHA256Hex(key, data string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(data))
return hex.EncodeToString(mac.Sum(nil))
}
func compactJSON(v interface{}) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
type signDataPayload struct {
OfferId string `json:"offerId"`
BuyQuantity int `json:"buyQuantity"`
Env int `json:"env"`
CurrencyType string `json:"currencyType"`
ProductId string `json:"productId"`
GoodsPrice int64 `json:"goodsPrice"`
OutTradeNo string `json:"outTradeNo"`
Attach string `json:"attach"`
}
// BuildPayParams 构造虚拟支付双签参数
func (s *XpayService) BuildPayParams(ctx context.Context, userID, orderNo, productEn string, amount float64) (*XpayPrepayData, error) {
sessionKey, err := s.GetSessionKey(ctx, userID)
if err != nil {
if err == redis.Nil {
return nil, fmt.Errorf("微信会话已过期,请重新打开小程序")
}
return nil, fmt.Errorf("获取微信会话失败: %w", err)
}
goodsPrice := lzUtils.ToWechatAmount(amount)
payload := signDataPayload{
OfferId: s.config.WechatXpay.OfferId,
BuyQuantity: 1,
Env: s.Env(),
CurrencyType: "CNY",
ProductId: productEn,
GoodsPrice: goodsPrice,
OutTradeNo: orderNo,
Attach: fmt.Sprintf("query:%s", orderNo),
}
signData, err := compactJSON(payload)
if err != nil {
return nil, err
}
appKey := s.config.WechatXpay.AppKey
paySig := hmacSHA256Hex(appKey, xpayVirtualPaymentURI+"&"+signData)
signature := hmacSHA256Hex(sessionKey, signData)
logx.WithContext(ctx).Infof("[xpay] create user=%s order_no=%s env=%d productId=%s goodsPrice=%d",
userID, orderNo, s.Env(), productEn, goodsPrice)
return &XpayPrepayData{
Provider: "xpay",
Mode: xpayMode,
SignData: signData,
PaySig: paySig,
Signature: signature,
}, nil
}
func (s *XpayService) getAccessToken(ctx context.Context) (string, error) {
cached, err := s.redis.GetCtx(ctx, xpayAccessTokenKey)
if err == nil && cached != "" {
return cached, nil
}
appID := s.config.WechatMini.AppID
secret := s.config.WechatMini.AppSecret
url := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", appID, secret)
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", err
}
if tokenResp.ErrCode != 0 || tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取 access_token 失败: errcode=%d errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
ttl := tokenResp.ExpiresIn - 300
if ttl < 60 {
ttl = 60
}
_ = s.redis.SetexCtx(ctx, xpayAccessTokenKey, tokenResp.AccessToken, ttl)
return tokenResp.AccessToken, nil
}
type xpayAPIResp struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Data json.RawMessage `json:"data"`
}
type xpayOrderStatus struct {
Status int `json:"status"`
PaidFee int64 `json:"paid_fee"`
OrderID string `json:"order_id"`
}
func (s *XpayService) callAPI(ctx context.Context, uri, bodyJSON, sessionKey string, needSignature bool) (*xpayAPIResp, error) {
accessToken, err := s.getAccessToken(ctx)
if err != nil {
return nil, err
}
appKey := s.config.WechatXpay.AppKey
paySig := hmacSHA256Hex(appKey, uri+"&"+bodyJSON)
reqURL := fmt.Sprintf("https://api.weixin.qq.com%s?access_token=%s&pay_sig=%s", uri, accessToken, paySig)
if needSignature && sessionKey != "" {
sig := hmacSHA256Hex(sessionKey, bodyJSON)
reqURL += "&signature=" + sig
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader([]byte(bodyJSON)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
httpResp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
var apiResp xpayAPIResp
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return nil, fmt.Errorf("解析 xpay 响应失败: %w, body=%s", err, string(respBody))
}
logx.WithContext(ctx).Infof("[xpay] call uri=%s body=%s", uri, string(respBody))
return &apiResp, nil
}
// QueryOrder 调微信 query_order
func (s *XpayService) QueryOrder(ctx context.Context, openid, orderNo, sessionKey string) (*xpayOrderStatus, error) {
bodyObj := map[string]interface{}{
"openid": openid,
"env": s.Env(),
"order_id": orderNo,
}
bodyJSON, err := compactJSON(bodyObj)
if err != nil {
return nil, err
}
apiResp, err := s.callAPI(ctx, "/xpay/query_order", bodyJSON, sessionKey, true)
if err != nil {
return nil, err
}
if apiResp.ErrCode != 0 {
return nil, fmt.Errorf("query_order errcode=%d errmsg=%s", apiResp.ErrCode, apiResp.ErrMsg)
}
var status xpayOrderStatus
if len(apiResp.Data) > 0 {
_ = json.Unmarshal(apiResp.Data, &status)
}
return &status, nil
}
// NotifyProvideGoods 主动通知微信已发货
func (s *XpayService) NotifyProvideGoods(ctx context.Context, openid, orderNo, wxOrderID, sessionKey string) error {
bodyObj := map[string]interface{}{
"openid": openid,
"env": s.Env(),
"order_id": orderNo,
}
if wxOrderID != "" {
bodyObj["wx_order_id"] = wxOrderID
}
bodyJSON, err := compactJSON(bodyObj)
if err != nil {
return err
}
apiResp, err := s.callAPI(ctx, "/xpay/notify_provide_goods", bodyJSON, sessionKey, true)
if err != nil {
return err
}
if apiResp.ErrCode != 0 && apiResp.ErrCode != 268490004 {
return fmt.Errorf("notify_provide_goods errcode=%d errmsg=%s", apiResp.ErrCode, apiResp.ErrMsg)
}
logx.WithContext(ctx).Infof("[xpay] notify_provide_goods OK order_no=%s", orderNo)
return nil
}
// IsPaidStatus 微信订单状态 2/3/4 视为支付成功
func IsXpayPaidStatus(status int) bool {
return status == 2 || status == 3 || status == 4
}
// IsXpayClosedStatus 5~10 视为关闭
func IsXpayClosedStatus(status int) bool {
return status >= 5 && status <= 10
}
// VerifyPushSignature GET 验签(消息推送配置)
func (s *XpayService) VerifyPushSignature(signature, timestamp, nonce string) bool {
token := s.config.WechatXpay.MessagePushToken
arr := []string{token, timestamp, nonce}
sort.Strings(arr)
hash := sha1.Sum([]byte(strings.Join(arr, "")))
return signature == hex.EncodeToString(hash[:])
}
// XpayDeliverNotify 推送消息体(明文 JSON
type XpayDeliverNotify struct {
Event string `json:"Event"`
OpenId string `json:"OpenId"`
OutTradeNo string `json:"OutTradeNo"`
Env int `json:"Env"`
WeChatPayInfo struct {
TransactionId string `json:"TransactionId"`
PaidTime int64 `json:"PaidTime"`
} `json:"WeChatPayInfo"`
GoodsInfo struct {
ProductId string `json:"ProductId"`
Quantity int `json:"Quantity"`
OrigPrice int64 `json:"OrigPrice"`
ActualPrice int64 `json:"ActualPrice"`
Attach string `json:"Attach"`
} `json:"GoodsInfo"`
}
func (s *XpayService) AlreadyNotified(ctx context.Context, orderNo string) (bool, error) {
key := fmt.Sprintf(xpayNotifiedKeyFmt, orderNo)
val, err := s.redis.GetCtx(ctx, key)
if err == redis.Nil {
return false, nil
}
if err != nil {
return false, err
}
return val != "", nil
}
func (s *XpayService) MarkNotified(ctx context.Context, orderNo string) error {
key := fmt.Sprintf(xpayNotifiedKeyFmt, orderNo)
return s.redis.SetexCtx(ctx, key, "1", 7*24*3600)
}
// GetWxMiniOpenID 查询用户小程序 openid
func (s *XpayService) GetWxMiniOpenID(ctx context.Context, userAuthModel model.UserAuthModel, userID string) (string, error) {
auth, err := userAuthModel.FindOneByUserIdAuthType(ctx, userID, model.UserAuthTypeWxMiniOpenID)
if err != nil {
return "", err
}
return auth.AuthKey, nil
}