Files
zt-closet-backend/pkg/wechat/virtualpay_test.go
T
zhengchiliu b94dd6dffb feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
2026-09-16 03:44:09 +08:00

400 lines
13 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 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")
}
}