feat(order): 重构退款全流程并新增申请退款中状态

- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
This commit is contained in:
2026-09-16 03:44:09 +08:00
parent 89b59c7b91
commit b94dd6dffb
29 changed files with 2141 additions and 158 deletions
+123
View File
@@ -252,6 +252,129 @@ func TestXPayOrderInfoUnmarshal(t *testing.T) {
}
}
// 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) {
// 只填现网 -> 现网