bugfix:支付

This commit is contained in:
2026-09-14 02:39:00 +08:00
parent d87f904e33
commit 714d8ff396
11 changed files with 699 additions and 42 deletions
+91 -20
View File
@@ -21,6 +21,7 @@ import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -29,6 +30,7 @@ import (
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
@@ -41,8 +43,9 @@ const (
xpayBaseURL = "https://api.weixin.qq.com"
stableTokenURL = "https://api.weixin.qq.com/cgi-bin/stable_token"
xpayQueryOrderPath = "/xpay/query_order"
xpayRefundOrderPath = "/xpay/refund_order"
xpayQueryOrderPath = "/xpay/query_order"
xpayRefundOrderPath = "/xpay/refund_order"
xpayNotifyProvideGoodsPath = "/xpay/notify_provide_goods"
// requestVirtualPaymentURI 前端调起支付时计算 paySig 使用的固定 uri。
requestVirtualPaymentURI = "requestVirtualPayment"
@@ -61,6 +64,10 @@ type virtualPayClient struct {
env int // 激活环境 0=现网 1=沙箱
token string // 消息推送验签 Token(配置回调地址时自定义)
// 消息推送加解密:encodingAESKey 为空表示明文模式;aesKey 为预解码的 32 字节密钥。
encodingAESKey string
aesKey []byte
appID string // 获取 access_token 用的小程序 AppID
appSecret string // 小程序 AppSecret
@@ -90,6 +97,26 @@ func resolveEnvKey(envAppKey, sandboxAppKey string) (appKey string, env int, err
return "", 0, errors.New("virtual pay app key not configured: both env_app_key and sandbox_app_key are empty")
}
// decodeEncodingAESKey 解码消息加解密密钥:尾部补 "=" 后 base64 解码,必须得到 32 字节。
// 空字符串表示未启用安全模式(明文模式),返回 nil 且不报错。
func decodeEncodingAESKey(encodingAESKey string) ([]byte, error) {
if encodingAESKey == "" {
return nil, nil
}
if len(encodingAESKey) != wxEncodingAESKeyLen {
return nil, fmt.Errorf("virtual pay encoding_aes_key must be %d chars, got %d",
wxEncodingAESKeyLen, len(encodingAESKey))
}
key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return nil, fmt.Errorf("virtual pay decode encoding_aes_key: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("virtual pay encoding_aes_key must decode to 32 bytes, got %d", len(key))
}
return key, nil
}
// InitVirtualPayClient 初始化全局虚拟支付客户端。
// 复用 global.WechatMiniSetting 的 AppID/AppSecret 获取 access_token(同一小程序)。
// 环境选择:只填沙箱 AppKey 走沙箱(env=1,测试不真实扣款);只填现网或两把都填走现网(env=0)。
@@ -108,14 +135,21 @@ func InitVirtualPayClient(s *setting.VirtualPaySettingS) error {
return errors.New("wechat mini setting incomplete, cannot fetch virtual pay access_token")
}
aesKey, aerr := decodeEncodingAESKey(s.EncodingAESKey)
if aerr != nil {
return aerr
}
c := &virtualPayClient{
offerID: s.OfferID,
appKey: appKey,
env: env,
token: s.Token,
appID: global.WechatMiniSetting.AppID,
appSecret: global.WechatMiniSetting.AppSecret,
httpClient: &http.Client{Timeout: 10 * time.Second},
offerID: s.OfferID,
appKey: appKey,
env: env,
token: s.Token,
encodingAESKey: s.EncodingAESKey,
aesKey: aesKey,
appID: global.WechatMiniSetting.AppID,
appSecret: global.WechatMiniSetting.AppSecret,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
virtualPayMu.Lock()
@@ -384,6 +418,24 @@ func QueryOrder(ctx context.Context, openid, orderID, wxOrderID string) (*XPayOr
return out, nil
}
// NotifyProvideGoods 通知微信「已发货完成」(只能通知现金单,作用于激活环境)。
//
// 对应道具直购流程的 9.3:仅「发货轮询/查单补偿」分支需要调用——该分支没有推送应答的机会,
// 必须主动把微信侧订单置为已发货。走 xpay_goods_deliver_notify 推送且应答成功(ErrCode:0)
// 时,微信已视同发货完成,无需再调用本接口。
// 幂等:微信侧按订单号置状态,重复调用无副作用。
func NotifyProvideGoods(ctx context.Context, orderID string) error {
if orderID == "" {
return errors.New("notify provide goods: order_id is required")
}
c, err := getVirtualPayClient()
if err != nil {
return err
}
body := map[string]any{"order_id": orderID, "env": c.env}
return c.callXPay(ctx, xpayNotifyProvideGoodsPath, body, nil)
}
// RefundOrder 启动虚拟支付退款任务(仅启动任务,结果需轮询 QueryOrder;作用于激活环境)。
// - leftFee:剩余金额(分),即退款前的订单实付金额;
// - refundFee:本次退款金额(分)。
@@ -532,6 +584,12 @@ func ParseNotify(raw []byte) (*XPayNotifyBase, error) {
if len(raw) == 0 {
return nil, errors.New("empty xpay notify body")
}
// MP 后台「消息推送 → 数据格式」可选 JSON/XML(默认 XML),本实现只支持 JSON。
// 收到 XML 时给出明确提示,否则只会表现为「反复重试 + 无业务日志」,极难定位。
if trimmed := bytes.TrimLeft(raw, " \t\r\n"); len(trimmed) > 0 && trimmed[0] == '<' {
return nil, errors.New("xpay notify is XML but only JSON is supported; " +
"please set 消息推送 → 数据格式 = JSON in MP console")
}
base := &XPayNotifyBase{}
if err := json.Unmarshal(raw, base); err != nil {
return nil, fmt.Errorf("parse xpay notify: %w", err)
@@ -550,29 +608,42 @@ func BuildNotifyResponse(success bool) []byte {
return []byte(`{"ErrCode":1,"ErrMsg":"处理失败"}`)
}
// BuildIOSRefundQueryResponse 构造 iOS 退款问询应答(3 秒内必须返回)。
// resultCode: 0 建议退款、1 拒绝退款;evidence 为决策凭据。
// 字段名以官方文档为准(result_code / evidence)。
// BuildIOSRefundQueryResponse 构造 iOS 退款问询应答(Apple 最多连续问询 3 次,须 3 秒内返回)。
// 应答必须包一层 IosRefundQueryResponse,并带 ErrCode/ErrMsg,字段名以官方文档为准:
// - result_code:0 放过(建议退款)、1 拦截(拒绝退款)
// - result_info:结果描述
// - evidence:决策凭据(必填,须具体,用于退款审计,不可为空或过于笼统)
//
// 使用 json.Marshal 保证字符串转义符合 JSON 规范(避免 fmt %q 的 Go 风格转义)。
func BuildIOSRefundQueryResponse(resultCode int, evidence string) []byte {
func BuildIOSRefundQueryResponse(resultCode int, resultInfo, evidence string) []byte {
b, _ := json.Marshal(map[string]any{
"result_code": resultCode,
"evidence": evidence,
"ErrCode": 0,
"ErrMsg": "success",
"IosRefundQueryResponse": map[string]any{
"result_code": resultCode,
"result_info": resultInfo,
"evidence": evidence,
},
})
return b
}
// sortedSHA1Hex 微信各类签名的统一算法:参与项按字典序排序后拼接,取 sha1 小写 hex。
// 用于 GET URL 验证(token/timestamp/nonce)与安全模式的接收/回包签名(多一个 Encrypt)。
func sortedSHA1Hex(parts ...string) string {
sorted := append([]string(nil), parts...)
sort.Strings(sorted)
h := sha1.Sum([]byte(strings.Join(sorted, "")))
return hex.EncodeToString(h[:])
}
// CheckURLSignature 校验消息推送 URL 配置时的 GET 验签(SHA1)。
// token 为配置回调地址时自定义的 Token;通过则返回 true 并原样回显 echostr。
func CheckURLSignature(token, timestamp, nonce, signature string) bool {
if token == "" || signature == "" {
return false
}
arr := []string{token, timestamp, nonce}
sort.Strings(arr)
joined := arr[0] + arr[1] + arr[2]
h := sha1.Sum([]byte(joined))
return hex.EncodeToString(h[:]) == signature
return sortedSHA1Hex(token, timestamp, nonce) == signature
}
// VerifyNotifyURL 校验消息推送 URL 配置时的 GET 验签(使用已初始化的 Token)。