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 (
|
2026-06-06 17:03:08 +08:00
|
|
|
|
xpaySessionKeyFmt = "qnc:wx:xpay:session:%s"
|
|
|
|
|
|
xpayNotifiedKeyFmt = "qnc:xpay:notified:%s"
|
|
|
|
|
|
xpayAccessTokenKey = "qnc:wx:xpay:access_token"
|
|
|
|
|
|
xpayMode = "short_series_goods"
|
2026-06-06 11:52:06 +08:00
|
|
|
|
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)
|
2026-06-06 17:03:08 +08:00
|
|
|
|
xpayProductID := ResolveXpayProductId(productEn)
|
2026-06-06 11:52:06 +08:00
|
|
|
|
payload := signDataPayload{
|
|
|
|
|
|
OfferId: s.config.WechatXpay.OfferId,
|
|
|
|
|
|
BuyQuantity: 1,
|
|
|
|
|
|
Env: s.Env(),
|
|
|
|
|
|
CurrencyType: "CNY",
|
2026-06-06 17:03:08 +08:00
|
|
|
|
ProductId: xpayProductID,
|
2026-06-06 11:52:06 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-06-06 17:03:08 +08:00
|
|
|
|
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)
|
2026-06-06 11:52:06 +08:00
|
|
|
|
|
|
|
|
|
|
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"`
|
2026-06-06 17:03:08 +08:00
|
|
|
|
Order json.RawMessage `json:"order"` // query_order 官方返回 order 字段
|
2026-06-06 11:52:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type xpayOrderStatus struct {
|
2026-06-06 17:03:08 +08:00
|
|
|
|
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"`
|
2026-06-07 14:39:21 +08:00
|
|
|
|
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)
|
2026-06-06 11:52:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
2026-06-07 14:39:21 +08:00
|
|
|
|
logx.WithContext(ctx).Infof("[xpay] req uri=%s env=%d body=%s", uri, s.Env(), bodyJSON)
|
|
|
|
|
|
|
2026-06-06 11:52:06 +08:00
|
|
|
|
httpResp, err := http.DefaultClient.Do(req)
|
|
|
|
|
|
if err != nil {
|
2026-06-07 14:39:21 +08:00
|
|
|
|
logx.WithContext(ctx).Errorf("[xpay] http err uri=%s err=%v", uri, err)
|
2026-06-06 11:52:06 +08:00
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer httpResp.Body.Close()
|
|
|
|
|
|
respBody, _ := io.ReadAll(httpResp.Body)
|
2026-06-07 14:39:21 +08:00
|
|
|
|
respStr := string(respBody)
|
2026-06-06 11:52:06 +08:00
|
|
|
|
|
|
|
|
|
|
var apiResp xpayAPIResp
|
|
|
|
|
|
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
2026-06-07 14:39:21 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-06-06 11:52:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:03:08 +08:00
|
|
|
|
// 官方文档:query_order 仅需 pay_sig,不需用户态 signature
|
|
|
|
|
|
apiResp, err := s.callAPI(ctx, "/xpay/query_order", bodyJSON, "", false)
|
2026-06-06 11:52:06 +08:00
|
|
|
|
if err != nil {
|
2026-06-07 14:39:21 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-06-06 11:52:06 +08:00
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:03:08 +08:00
|
|
|
|
orderRaw := apiResp.Order
|
|
|
|
|
|
if len(orderRaw) == 0 {
|
|
|
|
|
|
orderRaw = apiResp.Data
|
|
|
|
|
|
}
|
2026-06-06 11:52:06 +08:00
|
|
|
|
var status xpayOrderStatus
|
2026-06-06 17:03:08 +08:00
|
|
|
|
if len(orderRaw) > 0 {
|
2026-06-07 14:39:21 +08:00
|
|
|
|
status.RawOrder = string(orderRaw)
|
2026-06-06 17:03:08 +08:00
|
|
|
|
_ = json.Unmarshal(orderRaw, &status)
|
2026-06-06 11:52:06 +08:00
|
|
|
|
}
|
2026-06-07 15:11:33 +08:00
|
|
|
|
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)
|
2026-06-06 11:52:06 +08:00
|
|
|
|
return &status, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:03:08 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2026-06-07 15:11:33 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-06-06 17:03:08 +08:00
|
|
|
|
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 {
|
2026-06-07 14:39:21 +08:00
|
|
|
|
if xpayErr, ok := err.(*XpayAPIError); ok {
|
2026-06-07 15:11:33 +08:00
|
|
|
|
if xpayErr.ErrCode == 268490004 || xpayErr.ErrCode == 268490005 || xpayErr.ErrCode == 268490014 {
|
2026-06-07 14:39:21 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-06-06 17:03:08 +08:00
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-06-07 14:39:21 +08:00
|
|
|
|
_ = apiResp
|
2026-06-06 17:03:08 +08:00
|
|
|
|
logx.WithContext(ctx).Infof("[xpay] refund_order OK order_no=%s refund_order_id=%s fee=%d", orderNo, refundOrderID, refundFeeFen)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 11:52:06 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 14:39:21 +08:00
|
|
|
|
_, err = s.callAPI(ctx, "/xpay/notify_provide_goods", bodyJSON, sessionKey, true)
|
2026-06-06 11:52:06 +08:00
|
|
|
|
if err != nil {
|
2026-06-07 14:39:21 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-06-06 11:52:06 +08:00
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
logx.WithContext(ctx).Infof("[xpay] notify_provide_goods OK order_no=%s", orderNo)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 15:11:33 +08:00
|
|
|
|
// IsXpayPaidStatus 微信订单状态 2/3/4 视为支付成功
|
2026-06-06 11:52:06 +08:00
|
|
|
|
func IsXpayPaidStatus(status int) bool {
|
|
|
|
|
|
return status == 2 || status == 3 || status == 4
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 15:11:33 +08:00
|
|
|
|
// 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 视为关闭
|
2026-06-06 11:52:06 +08:00
|
|
|
|
func IsXpayClosedStatus(status int) bool {
|
2026-06-07 15:11:33 +08:00
|
|
|
|
return status == 6
|
2026-06-06 11:52:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-06-06 17:03:08 +08:00
|
|
|
|
Event string `json:"Event"`
|
|
|
|
|
|
OpenId string `json:"OpenId"`
|
|
|
|
|
|
OutTradeNo string `json:"OutTradeNo"`
|
|
|
|
|
|
Env int `json:"Env"`
|
2026-06-06 11:52:06 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|