Files
pay/internal/reconcile/refund_apply.go
T
wangjia 9117de7dcf fix(v2): 退款自愈扫描加回溯窗(防 LIMIT 尾部饥饿)+ 清死行
RefundApplyTask 的候选查询按 out_trade_no ASC/id ASC 取前 200,succeeded 退款历史
只增不减、partially_refunded 长期驻留,超过 limit 后最新的崩溃窗口永远排在候选外
扫不到。两路查询都加 since 回溯窗:RefundStore.ListDistinctOutTradeNosByStatusSince
(completed_at>=since)、OrderStore.ListOrdersByStatusSince(updated_at>=since),旧的
无窗方法原样保留。RefundApplyTask 新增 lookback/now 参数,config.ReconcileConfig 新增
RefundApplyLookbackMin(默认 48h)。顺带按 T6 复审补 OrderTTLMin 字段注释(须 ≫ crypto
orderTTL 15min),清 sync_test.go 死行 `_ = time.Second`。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 17:59:57 +08:00

126 lines
5.0 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 reconcile
import (
"context"
"log"
"time"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/store"
)
// RefundApplyTask 是「RefundApply 修复扫描」:P4 T3 opus review 追加的义务(P4 退款
// 代码本体在主 checkout,未落到本 worktree;此处只对本 worktree 已有的
// store.RefundStore/OrderStore 接口(P4 T2,在 base 里)建自愈扫描,设计为能在合并
// P4 后继续工作)。
//
// 动机:退款成功的崩溃窗口 —— RefundStore.MarkRefundStatus 把某笔退款翻成
// succeeded 后,调用方在再调 OrderStore.ApplyRefundToOrder 前进程崩溃/网络抖动,
// 订单状态卡在 paid(或旧的 partially_refunded),与「已实际退款成功」的事实脱节。
// 本任务周期重算每个候选订单的 succeeded 退款之和,按既有 ApplyRefundToOrder 的
// 条件 UPDATE 语义重新 apply 一次 —— 状态已一致时 UPDATE 影响 0 行,天然幂等。
//
// 候选订单 = distinct(有 succeeded 退款的订单) (当前处于 refunding/partially_refunded
// 态的订单):前者直接命中"退款成功但订单未跟上"的崩溃窗口;后者兜住"已在退款流程
// 中、但后续又有退款 succeeded 未被重算"的情形。
//
// 两路查询都带 lookback 回溯窗(since = now-lookback),防「LIMIT 尾部饥饿」:succeeded
// 退款历史只增不减、partially_refunded 订单长期驻留,若不设窗口按 ASC 取前 limit 条,
// 超过 limit 后最新的崩溃窗口(本扫描要自愈的目标)会被挤出候选、永远扫不到。lookback
// 只覆盖近期崩溃窗口——历史一致性由已收敛状态保证,不需要扫全表。
func RefundApplyTask(orders *store.OrderStore, refunds *store.RefundStore, lookback time.Duration, now func() time.Time, limit int) func(ctx context.Context) error {
return func(ctx context.Context) error {
since := now().Add(-lookback)
succeededNos, err := refunds.ListDistinctOutTradeNosByStatusSince(model.RefundSucceeded, since, limit)
if err != nil {
return err
}
refundingOrders, err := orders.ListOrdersByStatusSince(
[]model.OrderStatusV2{model.OrderRefundingV2, model.OrderPartRefundedV2}, since, limit)
if err != nil {
return err
}
seen := make(map[string]bool, len(succeededNos)+len(refundingOrders))
candidates := make([]string, 0, len(succeededNos)+len(refundingOrders))
for _, no := range succeededNos {
if !seen[no] {
seen[no] = true
candidates = append(candidates, no)
}
}
for i := range refundingOrders {
no := refundingOrders[i].OutTradeNo
if !seen[no] {
seen[no] = true
candidates = append(candidates, no)
}
}
for _, no := range candidates {
if err := reapplyRefundState(orders, refunds, no); err != nil {
log.Printf("[reconcile] 退款修复扫描 out_trade_no=%s: %v", no, err)
}
}
return nil
}
}
// reapplyRefundState 对单个订单重算 succeeded 退款之和并按需重新 apply 状态转移。
// 只有目标态与当前态不同才真正调用 ApplyRefundToOrder(避免每轮扫描都打"翻转"日志噪声);
// 没有 succeeded 退款(sum==0)的订单跳过 —— 它不属于本扫描要修的窗口。
func reapplyRefundState(orders *store.OrderStore, refunds *store.RefundStore, outTradeNo string) error {
succ, err := refunds.RefundSum(outTradeNo, model.RefundSucceeded)
if err != nil {
return err
}
if succ <= 0 {
return nil
}
o, err := orders.GetOrder(outTradeNo)
if err != nil {
return err
}
fully := succ >= o.AmountMinor
next := model.OrderPartRefundedV2
if fully {
next = model.OrderRefundedV2
}
if o.Status == next {
return nil // 已一致,无需自愈
}
flipped, err := orders.ApplyRefundToOrder(outTradeNo, fully)
if err != nil {
return err
}
if flipped {
log.Printf("[reconcile][退款自愈] out_trade_no=%s 本地曾卡于 %s,succeeded 退款 %d/%d → 重新 apply 为 %s",
outTradeNo, o.Status, succ, o.AmountMinor, next)
}
return nil
}
// RefundStuckAlertTask 是「卡滞 processing/manual_pending 退款告警」义务:同 sweep
// 家族的姊妹任务,只读观测 —— 只打 WARN,绝不改状态(状态机翻转是
// RefundApplyTask/业务方的事)。渠道退款查询 API 面(主动向渠道问退款进度)留待后续;
// 这里先用「本地卡滞时长」兜底可见性。
func RefundStuckAlertTask(refunds *store.RefundStore, threshold time.Duration, now func() time.Time) func(ctx context.Context) error {
return func(ctx context.Context) error {
cutoff := now().Add(-threshold)
stuck, err := refunds.ListStuckRefunds(
[]model.RefundStatus{model.RefundProcessing, model.RefundManualPending}, cutoff, 200)
if err != nil {
return err
}
for i := range stuck {
r := &stuck[i]
log.Printf("[reconcile][WARN][退款卡滞] refund_id=%s out_trade_no=%s status=%s 已卡滞 %s(阈值 %s)——"+
"仅观测告警不改状态;渠道退款查询 API 面留待后续",
r.RefundID, r.OutTradeNo, r.Status, now().Sub(r.UpdatedAt).Round(time.Minute), threshold)
}
return nil
}
}