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 } }