Files
qnc-server-v3/app/main/api/internal/service/xpayService.go
2026-06-07 15:11:33 +08:00

471 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
xpayProductID := ResolveXpayProductId(productEn)
payload := signDataPayload{
OfferId: s.config.WechatXpay.OfferId,
BuyQuantity: 1,
Env: s.Env(),
CurrencyType: "CNY",
ProductId: xpayProductID,
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 product_en=%s xpay_product_id=%s goodsPrice=%d",
userID, orderNo, s.Env(), productEn, xpayProductID, 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"`
Order json.RawMessage `json:"order"` // query_order 官方返回 order 字段
}
type xpayOrderStatus struct {
Status int `json:"status"`
PaidFee int64 `json:"paid_fee"`
LeftFee int64 `json:"left_fee"`
OrderID string `json:"order_id"`
WxOrderID string `json:"wx_order_id"`
ErrMsg string `json:"err_msg"`
RawOrder string `json:"-"` // query_order 原始 order JSON便于日志排查
}
// XpayAPIError 微信 xpay 接口返回 errcode != 0
type XpayAPIError struct {
URI string
ErrCode int
ErrMsg string
RawBody string
}
func (e *XpayAPIError) Error() string {
return fmt.Sprintf("xpay %s errcode=%d errmsg=%s body=%s", e.URI, e.ErrCode, e.ErrMsg, e.RawBody)
}
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")
logx.WithContext(ctx).Infof("[xpay] req uri=%s env=%d body=%s", uri, s.Env(), bodyJSON)
httpResp, err := http.DefaultClient.Do(req)
if err != nil {
logx.WithContext(ctx).Errorf("[xpay] http err uri=%s err=%v", uri, err)
return nil, err
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
respStr := string(respBody)
var apiResp xpayAPIResp
if err := json.Unmarshal(respBody, &apiResp); err != nil {
logx.WithContext(ctx).Errorf("[xpay] parse err uri=%s http=%d body=%s err=%v", uri, httpResp.StatusCode, respStr, err)
return nil, fmt.Errorf("解析 xpay 响应失败: %w, body=%s", err, respStr)
}
logx.WithContext(ctx).Infof("[xpay] resp uri=%s http=%d errcode=%d errmsg=%s body=%s",
uri, httpResp.StatusCode, apiResp.ErrCode, apiResp.ErrMsg, respStr)
if apiResp.ErrCode != 0 {
return &apiResp, &XpayAPIError{
URI: uri,
ErrCode: apiResp.ErrCode,
ErrMsg: apiResp.ErrMsg,
RawBody: respStr,
}
}
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
}
// 官方文档query_order 仅需 pay_sig不需用户态 signature
apiResp, err := s.callAPI(ctx, "/xpay/query_order", bodyJSON, "", false)
if err != nil {
if xpayErr, ok := err.(*XpayAPIError); ok {
logx.WithContext(ctx).Errorf("[xpay] query_order FAIL order_no=%s openid=%s errcode=%d errmsg=%s raw=%s",
orderNo, openid, xpayErr.ErrCode, xpayErr.ErrMsg, xpayErr.RawBody)
}
return nil, err
}
orderRaw := apiResp.Order
if len(orderRaw) == 0 {
orderRaw = apiResp.Data
}
var status xpayOrderStatus
if len(orderRaw) > 0 {
status.RawOrder = string(orderRaw)
_ = json.Unmarshal(orderRaw, &status)
}
logx.WithContext(ctx).Infof("[xpay] query_order order_no=%s status=%d paid_fee=%d left_fee=%d wx_order_id=%s err_msg=%s",
orderNo, status.Status, status.PaidFee, status.LeftFee, status.WxOrderID, status.ErrMsg)
return &status, nil
}
// RefundOrder 启动 xpay 退款任务(全额退)
func (s *XpayService) RefundOrder(ctx context.Context, openid, orderNo, refundOrderID string, refundFeeFen int64) error {
if refundFeeFen <= 0 {
return fmt.Errorf("退款金额无效")
}
status, err := s.QueryOrder(ctx, openid, orderNo, "")
if err != nil {
return fmt.Errorf("退款前查单失败: %w", err)
}
if IsXpayAlreadyRefunded(status) {
logx.WithContext(ctx).Infof("[xpay] refund_order skip already refunded order_no=%s wx_status=%d left_fee=%d paid_fee=%d",
orderNo, status.Status, status.LeftFee, status.PaidFee)
return nil
}
leftFee := status.LeftFee
if leftFee <= 0 {
leftFee = status.PaidFee
}
if leftFee <= 0 {
leftFee = refundFeeFen
}
if refundFeeFen > leftFee {
refundFeeFen = leftFee
}
bodyObj := map[string]interface{}{
"openid": openid,
"env": s.Env(),
"order_id": orderNo,
"refund_order_id": refundOrderID,
"left_fee": leftFee,
"refund_fee": refundFeeFen,
"biz_meta": fmt.Sprintf("refund:%s", orderNo),
"refund_reason": 5,
"req_from": 3,
}
bodyJSON, err := compactJSON(bodyObj)
if err != nil {
return err
}
apiResp, err := s.callAPI(ctx, "/xpay/refund_order", bodyJSON, "", false)
if err != nil {
if xpayErr, ok := err.(*XpayAPIError); ok {
if xpayErr.ErrCode == 268490004 || xpayErr.ErrCode == 268490005 || xpayErr.ErrCode == 268490014 {
logx.WithContext(ctx).Infof("[xpay] refund_order idempotent order_no=%s errcode=%d", orderNo, xpayErr.ErrCode)
return nil
}
logx.WithContext(ctx).Errorf("[xpay] refund_order FAIL order_no=%s errcode=%d errmsg=%s raw=%s",
orderNo, xpayErr.ErrCode, xpayErr.ErrMsg, xpayErr.RawBody)
}
return err
}
_ = apiResp
logx.WithContext(ctx).Infof("[xpay] refund_order OK order_no=%s refund_order_id=%s fee=%d", orderNo, refundOrderID, refundFeeFen)
return 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
}
_, err = s.callAPI(ctx, "/xpay/notify_provide_goods", bodyJSON, sessionKey, true)
if err != nil {
if xpayErr, ok := err.(*XpayAPIError); ok {
if xpayErr.ErrCode == 268490004 {
logx.WithContext(ctx).Infof("[xpay] notify_provide_goods idempotent order_no=%s", orderNo)
return nil
}
logx.WithContext(ctx).Errorf("[xpay] notify_provide_goods FAIL order_no=%s errcode=%d errmsg=%s raw=%s",
orderNo, xpayErr.ErrCode, xpayErr.ErrMsg, xpayErr.RawBody)
}
return err
}
logx.WithContext(ctx).Infof("[xpay] notify_provide_goods OK order_no=%s", orderNo)
return nil
}
// IsXpayPaidStatus 微信订单状态 2/3/4 视为支付成功
func IsXpayPaidStatus(status int) bool {
return status == 2 || status == 3 || status == 4
}
// IsXpayRefundedStatus 微信侧已退款5=订单已经退款8=用户退款完成
func IsXpayRefundedStatus(status int) bool {
return status == 5 || status == 8
}
// IsXpayAlreadyRefunded 查单结果表示微信侧已无可退金额(含在微信后台手动退款、本地未同步的情况)
func IsXpayAlreadyRefunded(status *xpayOrderStatus) bool {
if status == nil {
return false
}
if IsXpayRefundedStatus(status.Status) {
return true
}
return status.PaidFee > 0 && status.LeftFee == 0 && status.Status >= 2
}
// IsXpayClosedStatus 6 视为关闭
func IsXpayClosedStatus(status int) bool {
return status == 6
}
// 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
}