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
+70 -8
View File
@@ -394,10 +394,12 @@ func IsOrderRefundedStatus(status int) bool {
return status == XPayOrderStatusRefunded || status == XPayOrderStatusUserRefunded
}
// 虚拟支付退款推送的退款状态(RefundStatus 字段,以微信官方枚举为准,联调时校正)。
const (
XPayRefundStatusSuccess = "SUCCESS" // 退款成功
)
// 虚拟支付退款推送(xpay_refund_notify)的退款结果字段:官方以 RetCode 判定,
// RetCode=0 表示退款成功,非 0 表示失败(失败原因见 RetMsg)。
const XPayRefundRetCodeSuccess = 0
// XPayRefundStatusSuccess 旧版式退款推送的 RefundStatus 取值(官方现行报文已无该字段,保留兼容解析)。
const XPayRefundStatusSuccess = "SUCCESS"
// QueryOrder 查询虚拟支付订单(orderID 与 wxOrderID 二选一,非空者优先;作用于激活环境)。
func QueryOrder(ctx context.Context, openid, orderID, wxOrderID string) (*XPayOrderInfo, error) {
@@ -562,22 +564,82 @@ type XPayGoodsDeliverNotify struct {
} `json:"GoodsInfo"`
}
// XPayRefundNotify 退款推送。
// XPayRefundNotify 退款推送(xpay_refund_notify)。
//
// 字段以官方《虚拟支付:企业、个体户 → 消息推送 → 退款推送》为准:
// 官方报文中**没有** OutTradeNo / RefundStatus / Env,订单号取 MchOrderId(原支付单商户单号,
// 即本地 orders.order_no),退款结果取 RetCode(0=成功,非 0=失败,原因见 RetMsg)。
// 同时保留 RefundOrderId / RefundStatus 等旧版式字段与非官方版式的 OutTradeNo,避免版式差异导致静默失败。
type XPayRefundNotify struct {
XPayNotifyBase
RefundStatus string `json:"RefundStatus"` // 退款状态(以官方枚举为准)
RefundFee int64 `json:"RefundFee"` // 退款金额(分)
RefundOrderID string `json:"RefundOrderId"` // 商户退款单号
WxRefundID string `json:"WxRefundId"` // 微信退款单号
MchRefundID string `json:"MchRefundId"` // 商户退款单号
WxOrderID string `json:"WxOrderId"` // 退款单对应支付单的微信单号
MchOrderID string `json:"MchOrderId"` // 退款单对应支付单的商户单号(= 本地订单号)
RefundFee int64 `json:"RefundFee"` // 退款金额(分)
RetCode *int `json:"RetCode"` // 退款结果:0=成功,非 0=失败;nil 表示报文未携带
RetMsg string `json:"RetMsg"` // 退款结果详情(失败原因)
RefundStartTimestamp int64 `json:"RefundStartTimestamp"` // 开始退款时间(秒级时间戳)
RefundSuccTimestamp int64 `json:"RefundSuccTimestamp"` // 结束退款时间(秒级时间戳)
WxpayRefundTransactionID string `json:"WxpayRefundTransactionId"` // 退款单的微信支付单号
RetryTimes int `json:"RetryTimes"` // 微信重试次数(从 0 开始)
Attach string `json:"Attach"` // iOS 退款推送附带:下单 attach(选填)
WxTransactionID string `json:"WxTransactionId"` // iOS 退款推送附带:原支付单 TransactionId(选填)
// ---- 旧版式兼容字段(官方现行报文已无,保留解析以防版式回退)----
RefundStatus string `json:"RefundStatus"` // 退款状态(旧版式,SUCCESS=成功)
RefundOrderID string `json:"RefundOrderId"` // 商户退款单号(旧版式)
}
// OrderNo 取商户订单号:官方字段 MchOrderId 优先,旧版式回退公共字段 OutTradeNo。
func (n *XPayRefundNotify) OrderNo() string {
if n.MchOrderID != "" {
return n.MchOrderID
}
return n.OutTradeNo
}
// RefundNo 取商户退款单号:官方字段 MchRefundId 优先,依次回退旧版式字段与微信退款单号(仅用于幂等锁与日志)。
func (n *XPayRefundNotify) RefundNo() string {
switch {
case n.MchRefundID != "":
return n.MchRefundID
case n.RefundOrderID != "":
return n.RefundOrderID
default:
return n.WxRefundID
}
}
// Succeeded 判定退款结果:官方以 RetCode=0 表示成功;旧版式回退 RefundStatus(大小写不敏感 SUCCESS)。
// determinable=false 表示报文未携带任何可判定字段,调用方应 fail-closed(应答失败触发微信重试)。
func (n *XPayRefundNotify) Succeeded() (ok bool, determinable bool) {
if n.RetCode != nil {
return *n.RetCode == XPayRefundRetCodeSuccess, true
}
if n.RefundStatus != "" {
return strings.EqualFold(n.RefundStatus, XPayRefundStatusSuccess), true
}
return false, false
}
// XPayIOSRefundQueryNotify iOS 退款问询推送。
type XPayIOSRefundQueryNotify struct {
XPayNotifyBase
// 字段以官方文档为准;此处解析退款问询所需的最小信息。
MchOrderID string `json:"MchOrderId"` // 兼容:部分版式用商户单号命名
RefundOrderID string `json:"RefundOrderId"`
RefundFee int64 `json:"RefundFee"`
}
// OrderNo 取商户订单号:公共字段 OutTradeNo 优先(保持既有行为),为空时回退 MchOrderId。
func (n *XPayIOSRefundQueryNotify) OrderNo() string {
if n.OutTradeNo != "" {
return n.OutTradeNo
}
return n.MchOrderID
}
// ParseNotify 解析消息推送 JSON 并返回事件类型与公共字段。
// 返回值为 base(公共字段)+ 原始 body,调用方再按 Event 分发到具体结构。
func ParseNotify(raw []byte) (*XPayNotifyBase, error) {
+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) {
// 只填现网 -> 现网