148 lines
6.0 KiB
Go
148 lines
6.0 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/wangjia/pay/internal/model"
|
|
"github.com/wangjia/pay/internal/provider"
|
|
"github.com/wangjia/pay/internal/store"
|
|
)
|
|
|
|
type SettleResult string
|
|
|
|
const (
|
|
SettleIgnored SettleResult = "ignored" // 非成功状态(pending/failed)
|
|
SettleNotFound SettleResult = "not_found" // provider_ref 无对应 attempt
|
|
SettleAmountMismatch SettleResult = "amount_mismatch" // 币种不符 / 少付
|
|
SettleDuplicate SettleResult = "duplicate" // 订单已非 pending(幂等 no-op)
|
|
SettleProcessed SettleResult = "processed" // 本次真正翻转为 paid
|
|
SettleFailed SettleResult = "failed" // 暂时性失败(outbox 入队失败等):未翻转,渠道应重投
|
|
)
|
|
|
|
var ErrAmountMismatch = errors.New("gateway: paid amount/currency mismatch")
|
|
|
|
// Settle normalizes a PaidEvent into activation: locate order via provider_ref →
|
|
// attempt → order (设计 §4「归一化 PaidEvent」), reconcile currency+amount, then
|
|
// enqueue-before-flip:先幂等入队 payment.succeeded(unique 键,重复 no-op),
|
|
// 再经 P1 MarkAttemptPaid 幂等翻转(order=pending 原子守卫)。
|
|
//
|
|
// 顺序不变量(资金命脉):**订单为 paid ⇒ outbox 行必已存在**。
|
|
// - 先翻转后入队:两步间崩溃 → 已收钱但业务方永不知情,且无任何机制重试 → 客诉才发现。
|
|
// - 先入队后翻转:两步间崩溃 → outbox 里躺着一条"单还没付"的行;Notifier 投递前有
|
|
// "订单已付"门禁(Task 6),不会把未付单通知出去。渠道因拿不到 200 会重投回调
|
|
// (查单兜底同样收敛),重投时入队/翻转都幂等,自愈。
|
|
// - 入队本身失败:返回 SettleFailed 且不翻转,同样交给渠道重投恢复。
|
|
func (g *Gateway) Settle(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) {
|
|
if ev.Status != provider.PaidSucceeded {
|
|
return SettleIgnored, nil // 非成功状态:确认收到即可
|
|
}
|
|
att, err := g.orders.AttemptByProviderRef(ev.ProviderRef)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrAttemptNotFound) {
|
|
return SettleNotFound, nil
|
|
}
|
|
// 读库瞬时失败是可重试态,不能与"查无此单"终态混淆。
|
|
return SettleFailed, err
|
|
}
|
|
// 金额/币种核对:币种须一致,实付须 ≥ 应收(允许 crypto 多付,拒少付)。
|
|
if ev.PaidCurrency != att.Currency || ev.PaidAmountMinor < att.AmountMinor {
|
|
return SettleAmountMismatch, ErrAmountMismatch
|
|
}
|
|
// paid_at 优先用渠道报的支付时间(对账时与渠道流水对得上),渠道不报才落收到时间。
|
|
paidAt := time.Now()
|
|
if ev.PaidAt != nil {
|
|
paidAt = *ev.PaidAt
|
|
}
|
|
|
|
if err := g.enqueuePaymentSucceeded(att, paidAt); err != nil {
|
|
return SettleFailed, err // 未入队绝不翻转;渠道重投时幂等恢复
|
|
}
|
|
flipped, err := g.orders.MarkAttemptPaid(att.OutTradeNo, att.Channel, ev.ProviderRef, paidAt)
|
|
if err != nil {
|
|
return SettleFailed, err
|
|
}
|
|
if !flipped {
|
|
return SettleDuplicate, nil // 已处理过 / 已取消 / 已过期 → 幂等 no-op
|
|
}
|
|
return SettleProcessed, nil
|
|
}
|
|
|
|
// enqueuePaymentSucceeded 组 webhook 领域 payload 并幂等入队。仅订单仍 pending 时入队:
|
|
// 订单已 paid 说明翻转已发生,而翻转严格发生在成功入队之后(顺序不变量),行必已存在;
|
|
// 订单已 canceled 则不该通知(晚到支付走 P4 退款/P6 对账,不自动开通)。
|
|
func (g *Gateway) enqueuePaymentSucceeded(att *model.Attempt, paidAt time.Time) error {
|
|
o, err := g.orders.GetOrder(att.OutTradeNo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if o.BizSystem == "" || o.Status != model.OrderPendingV2 {
|
|
return nil // 独立收款无业务方回调;或已翻转/已取消
|
|
}
|
|
data := map[string]any{
|
|
"event_type": "payment.succeeded",
|
|
"out_trade_no": o.OutTradeNo,
|
|
"biz_system": o.BizSystem,
|
|
"biz_ref": o.BizRef,
|
|
"product_biz_code": o.BizCode, // 设计 §5:业务方按套餐码映射权益(时长/档位),不硬编码 product_id
|
|
"amount_minor": o.AmountMinor,
|
|
"currency": o.Currency,
|
|
"channel": att.Channel,
|
|
"paid_at": paidAt.Format(time.RFC3339),
|
|
}
|
|
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, "payment.succeeded", "", data)
|
|
}
|
|
|
|
// HandleCallback runs a channel's raw callback through its Provider.VerifyCallback
|
|
// (验签/解析封死在渠道内) then settles the normalized event.
|
|
func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider.CallbackInput) (SettleResult, error) {
|
|
prov, err := g.providers.Get(method)
|
|
if err != nil {
|
|
return SettleNotFound, err
|
|
}
|
|
ev, err := prov.VerifyCallback(ctx, in)
|
|
if err != nil {
|
|
return SettleNotFound, err
|
|
}
|
|
return g.Settle(ctx, ev)
|
|
}
|
|
|
|
// SyncPendingAttempts polls every pending attempt via its Provider.Query and
|
|
// settles hits — the query-based backstop for lost webhooks (设计 §8 对账优先).
|
|
// Returns how many attempts were newly settled to paid.
|
|
func (g *Gateway) SyncPendingAttempts(ctx context.Context, limit int) (int, error) {
|
|
atts, err := g.orders.ListAttemptsByStatus(model.AttemptPending, limit)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
settled := 0
|
|
for i := range atts {
|
|
att := &atts[i]
|
|
prov, err := g.providers.Get(att.Channel)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// 查单带尝试完整上下文(crypto 需要地址/金额/时间窗才能扫链核对)。
|
|
ev, err := prov.Query(ctx, provider.QueryRequest{
|
|
ProviderRef: att.ProviderRef, OutTradeNo: att.OutTradeNo, AccountID: att.AccountID,
|
|
AmountMinor: att.AmountMinor, Currency: att.Currency,
|
|
CreatedAt: att.CreatedAt, ExpiresAt: att.ExpiresAt,
|
|
})
|
|
if err != nil || ev == nil {
|
|
continue
|
|
}
|
|
res, serr := g.Settle(ctx, ev)
|
|
switch res {
|
|
case SettleProcessed:
|
|
settled++
|
|
case SettleIgnored, SettleDuplicate:
|
|
// 常态:未付/已处理,不刷日志
|
|
default: // not_found / amount_mismatch / failed —— 对账兜底的盲区,必须可见
|
|
log.Printf("[settle-sync] attempt=%s channel=%s result=%s err=%v", att.ProviderRef, att.Channel, res, serr)
|
|
}
|
|
}
|
|
return settled, nil
|
|
}
|