- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
400 lines
13 KiB
Go
400 lines
13 KiB
Go
package wechat
|
||
|
||
import (
|
||
"crypto/sha1"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// TestHMACSHA256Hex 用 RFC 4231 测试向量校验 HMAC-SHA256 实现。
|
||
func TestHMACSHA256Hex(t *testing.T) {
|
||
key := strings.Repeat("\x0b", 20) // 0x0b * 20
|
||
data := "Hi There"
|
||
want := "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
|
||
if got := hmacSHA256Hex(key, data); got != want {
|
||
t.Fatalf("hmacSHA256Hex mismatch: got %s, want %s", got, want)
|
||
}
|
||
}
|
||
|
||
// TestBuildPaymentSign 校验 signData 字典序、paySig 与 signature 的内部一致性。
|
||
func TestBuildPaymentSign(t *testing.T) {
|
||
const (
|
||
appKey = "test-app-key"
|
||
sessionKey = "test-session-key"
|
||
offerID = "wx1234567890"
|
||
productID = "monthly_vip"
|
||
outTradeNo = "OD20260908120000abcdef"
|
||
attach = "uid=42"
|
||
)
|
||
|
||
virtualPayMu.Lock()
|
||
defaultVirtualPayClient = &virtualPayClient{appKey: appKey, env: XPayEnvProd}
|
||
virtualPayMu.Unlock()
|
||
defer func() {
|
||
virtualPayMu.Lock()
|
||
defaultVirtualPayClient = nil
|
||
virtualPayMu.Unlock()
|
||
}()
|
||
|
||
sign, err := BuildPaymentSign(offerID, productID, outTradeNo, attach, sessionKey, 1900)
|
||
if err != nil {
|
||
t.Fatalf("BuildPaymentSign error: %v", err)
|
||
}
|
||
|
||
// signData 字段必须按字典序排列(微信侧按字典序重组验签)。
|
||
wantOrder := []string{"attach", "buyQuantity", "currencyType", "env", "goodsPrice", "offerId", "outTradeNo", "productId"}
|
||
last := -1
|
||
for _, key := range wantOrder {
|
||
idx := strings.Index(sign.SignData, `"`+key+`"`)
|
||
if idx < 0 {
|
||
t.Fatalf("signData missing key %q: %s", key, sign.SignData)
|
||
}
|
||
if idx < last {
|
||
t.Fatalf("signData key order wrong at %q: %s", key, sign.SignData)
|
||
}
|
||
last = idx
|
||
}
|
||
|
||
// signData 必须是合法 JSON,且 offerId 为字符串。
|
||
var m map[string]any
|
||
if err := json.Unmarshal([]byte(sign.SignData), &m); err != nil {
|
||
t.Fatalf("signData is not valid JSON: %v", err)
|
||
}
|
||
if _, ok := m["offerId"].(string); !ok {
|
||
t.Fatalf("offerId must be string, got %T", m["offerId"])
|
||
}
|
||
if v, ok := m["goodsPrice"].(float64); !ok || v != 1900 {
|
||
t.Fatalf("goodsPrice mismatch: %v", m["goodsPrice"])
|
||
}
|
||
|
||
// paySig = hmac(appKey, "requestVirtualPayment&"+signData);signature = hmac(sessionKey, signData)。
|
||
wantPaySig := hmacSHA256Hex(appKey, requestVirtualPaymentURI+"&"+sign.SignData)
|
||
wantSignature := hmacSHA256Hex(sessionKey, sign.SignData)
|
||
if sign.PaySig != wantPaySig {
|
||
t.Fatalf("paySig mismatch: got %s, want %s", sign.PaySig, wantPaySig)
|
||
}
|
||
if sign.Signature != wantSignature {
|
||
t.Fatalf("signature mismatch: got %s, want %s", sign.Signature, wantSignature)
|
||
}
|
||
}
|
||
|
||
// TestCheckURLSignature 校验消息推送 URL 验签(SHA1 字典序拼接)。
|
||
func TestCheckURLSignature(t *testing.T) {
|
||
token := "my-token"
|
||
timestamp := "1756000000"
|
||
nonce := "abc123"
|
||
|
||
// 独立计算期望值:["1756000000","abc123","my-token"] 字典序拼接后做 SHA1。
|
||
arr := []string{token, timestamp, nonce}
|
||
sort.Strings(arr)
|
||
sum := sha1.Sum([]byte(arr[0] + arr[1] + arr[2]))
|
||
wantSig := hex.EncodeToString(sum[:])
|
||
|
||
if !CheckURLSignature(token, timestamp, nonce, wantSig) {
|
||
t.Fatal("CheckURLSignature should pass with correct signature")
|
||
}
|
||
if CheckURLSignature(token, timestamp, nonce, "bad") {
|
||
t.Fatal("CheckURLSignature should fail with wrong signature")
|
||
}
|
||
if CheckURLSignature("", timestamp, nonce, wantSig) {
|
||
t.Fatal("CheckURLSignature should fail with empty token")
|
||
}
|
||
}
|
||
|
||
// TestParseNotifyAndResponse 校验发货推送解析与应答构造。
|
||
func TestParseNotifyAndResponse(t *testing.T) {
|
||
raw := []byte(`{
|
||
"ToUserName":"gh_test",
|
||
"FromUserName":"official_openid",
|
||
"CreateTime":1756000000,
|
||
"MsgType":"event",
|
||
"Event":"xpay_goods_deliver_notify",
|
||
"OpenId":"o_user",
|
||
"OutTradeNo":"OD20260908120000abcdef",
|
||
"Env":0,
|
||
"WeChatPayInfo":{"MchOrderNo":"mch_123","TransactionId":"wx_123","PaidTime":1756000000},
|
||
"GoodsInfo":{"ProductId":"monthly_vip","Quantity":1,"OrigPrice":1900,"ActualPrice":1900,"Attach":"uid=42"}
|
||
}`)
|
||
|
||
base, err := ParseNotify(raw)
|
||
if err != nil {
|
||
t.Fatalf("ParseNotify error: %v", err)
|
||
}
|
||
if base.Event != EventGoodsDeliverNotify {
|
||
t.Fatalf("unexpected event: %s", base.Event)
|
||
}
|
||
if base.OutTradeNo != "OD20260908120000abcdef" {
|
||
t.Fatalf("unexpected out_trade_no: %s", base.OutTradeNo)
|
||
}
|
||
|
||
var deliver XPayGoodsDeliverNotify
|
||
if err := json.Unmarshal(raw, &deliver); err != nil {
|
||
t.Fatalf("unmarshal deliver notify: %v", err)
|
||
}
|
||
if deliver.GoodsInfo.ProductID != "monthly_vip" || deliver.GoodsInfo.ActualPrice != 1900 {
|
||
t.Fatalf("deliver goods info mismatch: %+v", deliver.GoodsInfo)
|
||
}
|
||
|
||
okBody := BuildNotifyResponse(true)
|
||
if !strings.Contains(string(okBody), `"ErrCode":0`) {
|
||
t.Fatalf("success response missing ErrCode 0: %s", okBody)
|
||
}
|
||
failBody := BuildNotifyResponse(false)
|
||
if !strings.Contains(string(failBody), `"ErrCode":1`) {
|
||
t.Fatalf("fail response missing ErrCode 1: %s", failBody)
|
||
}
|
||
}
|
||
|
||
// TestIsOrderPaidStatus 校验订单支付状态白名单判断:仅「已支付及之后」为已支付,
|
||
// 未支付/已退款/已关闭等一律按未支付(fail-closed)。
|
||
func TestIsOrderPaidStatus(t *testing.T) {
|
||
cases := []struct {
|
||
status int
|
||
want bool
|
||
}{
|
||
{XPayOrderStatusPaid, true},
|
||
{XPayOrderStatusProviding, true},
|
||
{XPayOrderStatusProvided, true},
|
||
{XPayOrderStatusInit, false},
|
||
{XPayOrderStatusCreated, false},
|
||
{XPayOrderStatusRefunded, false},
|
||
{XPayOrderStatusClosed, false},
|
||
{XPayOrderStatusRefundFailed, false},
|
||
}
|
||
for _, c := range cases {
|
||
if got := IsOrderPaidStatus(c.status); got != c.want {
|
||
t.Fatalf("IsOrderPaidStatus(%d) = %v, want %v", c.status, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestIsOrderRefundedStatus 校验退款状态白名单判断:仅已退款/用户退款完成为已退款。
|
||
func TestIsOrderRefundedStatus(t *testing.T) {
|
||
cases := []struct {
|
||
status int
|
||
want bool
|
||
}{
|
||
{XPayOrderStatusRefunded, true},
|
||
{XPayOrderStatusUserRefunded, true},
|
||
{XPayOrderStatusPaid, false},
|
||
{XPayOrderStatusRefundFailed, false},
|
||
{XPayOrderStatusCreated, false},
|
||
}
|
||
for _, c := range cases {
|
||
if got := IsOrderRefundedStatus(c.status); got != c.want {
|
||
t.Fatalf("IsOrderRefundedStatus(%d) = %v, want %v", c.status, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestBuildIOSRefundQueryResponse 校验 iOS 退款问询应答结构:
|
||
// 必须带 ErrCode/ErrMsg 并包一层 IosRefundQueryResponse,字段名与官方一致。
|
||
// 回归防护:早期实现返回平铺的 {result_code, evidence},Apple 侧无法解析。
|
||
func TestBuildIOSRefundQueryResponse(t *testing.T) {
|
||
body := BuildIOSRefundQueryResponse(1, "拒绝退款", "订单 OD1 发放的权益已被使用")
|
||
var got struct {
|
||
ErrCode int `json:"ErrCode"`
|
||
ErrMsg string `json:"ErrMsg"`
|
||
Resp struct {
|
||
ResultCode int `json:"result_code"`
|
||
ResultInfo string `json:"result_info"`
|
||
Evidence string `json:"evidence"`
|
||
} `json:"IosRefundQueryResponse"`
|
||
}
|
||
if err := json.Unmarshal(body, &got); err != nil {
|
||
t.Fatalf("unmarshal ios refund query response: %v, body=%s", err, body)
|
||
}
|
||
if got.ErrCode != 0 || got.ErrMsg != "success" {
|
||
t.Fatalf("ErrCode/ErrMsg = %d/%q, want 0/success", got.ErrCode, got.ErrMsg)
|
||
}
|
||
if got.Resp.ResultCode != 1 || got.Resp.ResultInfo != "拒绝退款" || got.Resp.Evidence == "" {
|
||
t.Fatalf("IosRefundQueryResponse mismatch: %+v", got.Resp)
|
||
}
|
||
}
|
||
|
||
// TestXPayOrderInfoUnmarshal 校验 query_order 响应解析:订单字段包在 order 子对象内,status 为数字枚举。
|
||
// 回归防护:早期实现把字段平铺在顶层且 status 定义为 string,导致反查恒失败、订单永远停在待支付。
|
||
func TestXPayOrderInfoUnmarshal(t *testing.T) {
|
||
raw := []byte(`{
|
||
"errcode": 0,
|
||
"errmsg": "",
|
||
"order": {
|
||
"order_id": "OD20260914014559fc1cfee91b3748a2",
|
||
"wx_order_id": "wx_order_1",
|
||
"status": 2,
|
||
"order_fee": 990,
|
||
"paid_fee": 990,
|
||
"order_type": 0,
|
||
"refund_fee": 0,
|
||
"left_fee": 990,
|
||
"env_type": 2,
|
||
"biz_meta": "monthly_vip"
|
||
}
|
||
}`)
|
||
var info XPayOrderInfo
|
||
if err := json.Unmarshal(raw, &info); err != nil {
|
||
t.Fatalf("unmarshal query_order: %v", err)
|
||
}
|
||
if info.Order.Status != XPayOrderStatusPaid {
|
||
t.Fatalf("status = %d, want %d", info.Order.Status, XPayOrderStatusPaid)
|
||
}
|
||
if info.Order.PaidFee != 990 {
|
||
t.Fatalf("paid_fee = %d, want 990", info.Order.PaidFee)
|
||
}
|
||
if info.Order.OrderID != "OD20260914014559fc1cfee91b3748a2" {
|
||
t.Fatalf("order_id = %q", info.Order.OrderID)
|
||
}
|
||
if !IsOrderPaidStatus(info.Order.Status) {
|
||
t.Fatalf("IsOrderPaidStatus(%d) = false, want true", info.Order.Status)
|
||
}
|
||
}
|
||
|
||
// TestXPayRefundNotifyOfficialPayload 官方退款推送报文:订单号取 MchOrderId、退款单号取 MchRefundId、
|
||
// 结果由 RetCode=0 判定为成功(回归防护:早期误用 OutTradeNo/RefundStatus 导致取不到订单号而静默丢单)。
|
||
func TestXPayRefundNotifyOfficialPayload(t *testing.T) {
|
||
raw := []byte(`{
|
||
"ToUserName":"gh_test",
|
||
"FromUserName":"official_openid",
|
||
"CreateTime":1789500611,
|
||
"MsgType":"event",
|
||
"Event":"xpay_refund_notify",
|
||
"OpenId":"o_user",
|
||
"WxRefundId":"wx_refund_1",
|
||
"MchRefundId":"RF20260916033011abcdef",
|
||
"WxOrderId":"wx_order_1",
|
||
"MchOrderId":"OD20260916030000fc1cfee91b3748a2",
|
||
"RefundFee":990,
|
||
"RetCode":0,
|
||
"RetMsg":"",
|
||
"RefundStartTimestamp":1789500600,
|
||
"RefundSuccTimestamp":1789500611,
|
||
"WxpayRefundTransactionId":"wxpay_refund_1",
|
||
"RetryTimes":0
|
||
}`)
|
||
|
||
var refund XPayRefundNotify
|
||
if err := json.Unmarshal(raw, &refund); err != nil {
|
||
t.Fatalf("unmarshal refund notify: %v", err)
|
||
}
|
||
if got := refund.OrderNo(); got != "OD20260916030000fc1cfee91b3748a2" {
|
||
t.Fatalf("OrderNo() = %q", got)
|
||
}
|
||
if got := refund.RefundNo(); got != "RF20260916033011abcdef" {
|
||
t.Fatalf("RefundNo() = %q", got)
|
||
}
|
||
if refund.RefundFee != 990 {
|
||
t.Fatalf("RefundFee = %d, want 990", refund.RefundFee)
|
||
}
|
||
succ, determinable := refund.Succeeded()
|
||
if !determinable || !succ {
|
||
t.Fatalf("Succeeded() = (%v, %v), want (true, true)", succ, determinable)
|
||
}
|
||
}
|
||
|
||
// TestXPayRefundNotifyFailurePayload 官方失败报文:RetCode 非 0 即退款失败,原因在 RetMsg。
|
||
func TestXPayRefundNotifyFailurePayload(t *testing.T) {
|
||
raw := []byte(`{
|
||
"Event":"xpay_refund_notify",
|
||
"OpenId":"o_user",
|
||
"MchOrderId":"OD1",
|
||
"MchRefundId":"RF1",
|
||
"RefundFee":990,
|
||
"RetCode":268490005,
|
||
"RetMsg":"已退款不可再退"
|
||
}`)
|
||
|
||
var refund XPayRefundNotify
|
||
if err := json.Unmarshal(raw, &refund); err != nil {
|
||
t.Fatalf("unmarshal refund notify: %v", err)
|
||
}
|
||
succ, determinable := refund.Succeeded()
|
||
if !determinable || succ {
|
||
t.Fatalf("Succeeded() = (%v, %v), want (false, true)", succ, determinable)
|
||
}
|
||
if refund.RetMsg == "" {
|
||
t.Fatal("RetMsg should be preserved for fail_reason")
|
||
}
|
||
}
|
||
|
||
// TestXPayRefundNotifyLegacyPayload 旧版式报文(OutTradeNo + RefundStatus)仍可解析并判定(大小写不敏感)。
|
||
func TestXPayRefundNotifyLegacyPayload(t *testing.T) {
|
||
raw := []byte(`{
|
||
"Event":"xpay_refund_notify",
|
||
"OutTradeNo":"OD-LEGACY",
|
||
"RefundOrderId":"RF-LEGACY",
|
||
"RefundStatus":"success",
|
||
"RefundFee":100
|
||
}`)
|
||
|
||
var refund XPayRefundNotify
|
||
if err := json.Unmarshal(raw, &refund); err != nil {
|
||
t.Fatalf("unmarshal refund notify: %v", err)
|
||
}
|
||
if got := refund.OrderNo(); got != "OD-LEGACY" {
|
||
t.Fatalf("OrderNo() = %q, want OD-LEGACY", got)
|
||
}
|
||
if got := refund.RefundNo(); got != "RF-LEGACY" {
|
||
t.Fatalf("RefundNo() = %q, want RF-LEGACY", got)
|
||
}
|
||
succ, determinable := refund.Succeeded()
|
||
if !determinable || !succ {
|
||
t.Fatalf("Succeeded() = (%v, %v), want (true, true)", succ, determinable)
|
||
}
|
||
}
|
||
|
||
// TestXPayRefundNotifyUndeterminable 报文未携带 RetCode 与 RefundStatus 时必须报「不可判定」,
|
||
// 调用方据此应答失败触发微信重试(而非静默按成功处理)。
|
||
func TestXPayRefundNotifyUndeterminable(t *testing.T) {
|
||
raw := []byte(`{"Event":"xpay_refund_notify","MchOrderId":"OD1","RefundFee":100}`)
|
||
|
||
var refund XPayRefundNotify
|
||
if err := json.Unmarshal(raw, &refund); err != nil {
|
||
t.Fatalf("unmarshal refund notify: %v", err)
|
||
}
|
||
succ, determinable := refund.Succeeded()
|
||
if determinable || succ {
|
||
t.Fatalf("Succeeded() = (%v, %v), want (false, false)", succ, determinable)
|
||
}
|
||
if got := refund.RefundNo(); got != "" {
|
||
t.Fatalf("RefundNo() = %q, want empty", got)
|
||
}
|
||
}
|
||
|
||
// TestXPayIOSRefundQueryOrderNoFallback iOS 退款问询推送订单号:OutTradeNo 优先,为空回退 MchOrderId。
|
||
func TestXPayIOSRefundQueryOrderNoFallback(t *testing.T) {
|
||
withOutTradeNo := XPayIOSRefundQueryNotify{XPayNotifyBase: XPayNotifyBase{OutTradeNo: "OD-A"}, MchOrderID: "OD-B"}
|
||
if got := withOutTradeNo.OrderNo(); got != "OD-A" {
|
||
t.Fatalf("OrderNo() = %q, want OD-A (OutTradeNo 优先)", got)
|
||
}
|
||
onlyMch := XPayIOSRefundQueryNotify{MchOrderID: "OD-B"}
|
||
if got := onlyMch.OrderNo(); got != "OD-B" {
|
||
t.Fatalf("OrderNo() = %q, want OD-B (回退 MchOrderId)", got)
|
||
}
|
||
}
|
||
|
||
// TestResolveEnvKey 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。
|
||
func TestResolveEnvKey(t *testing.T) {
|
||
// 只填现网 -> 现网
|
||
key, env, err := resolveEnvKey("prod-key", "")
|
||
if err != nil || key != "prod-key" || env != XPayEnvProd {
|
||
t.Fatalf("only env key: key=%q env=%d err=%v", key, env, err)
|
||
}
|
||
// 只填沙箱 -> 沙箱
|
||
key, env, err = resolveEnvKey("", "sandbox-key")
|
||
if err != nil || key != "sandbox-key" || env != XPayEnvSandbox {
|
||
t.Fatalf("only sandbox key: key=%q env=%d err=%v", key, env, err)
|
||
}
|
||
// 两把都填 -> 现网优先
|
||
key, env, err = resolveEnvKey("prod-key", "sandbox-key")
|
||
if err != nil || key != "prod-key" || env != XPayEnvProd {
|
||
t.Fatalf("both keys: key=%q env=%d err=%v", key, env, err)
|
||
}
|
||
// 两把都空 -> 报错
|
||
if _, _, err := resolveEnvKey("", ""); err == nil {
|
||
t.Fatal("empty keys should return error")
|
||
}
|
||
}
|