feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
This commit is contained in:
@@ -259,7 +259,7 @@ CREATE TABLE `orders` (
|
||||
`total_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '订单总金额(分)',
|
||||
`pay_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '实付金额(分)',
|
||||
`pay_channel` TINYINT NOT NULL DEFAULT 0 COMMENT '支付渠道 1=微信支付 2=微信虚拟支付',
|
||||
`order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款',
|
||||
`order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中',
|
||||
`transaction_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信支付流水号',
|
||||
`member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '购买的会员天数(快照)',
|
||||
`goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型快照',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 回滚:将 orders.order_status 列注释还原为不含 6=申请退款中 的文案。
|
||||
ALTER TABLE `orders`
|
||||
MODIFY COLUMN `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 订单状态新增 6=申请退款中:仅更新列注释,便于运营按状态筛选待处理退款单,无数据回填。
|
||||
ALTER TABLE `orders`
|
||||
MODIFY COLUMN `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 6=申请退款中';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
// 只填现网 -> 现网
|
||||
|
||||
Reference in New Issue
Block a user