bugfix:支付
This commit is contained in:
+91
-20
@@ -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)。
|
||||
|
||||
Reference in New Issue
Block a user