feat(v2): crypto 孤儿到账发现——OrphanScanner 扫链核对 + orphan_payments 落表告警(对账兜底)
This commit is contained in:
@@ -174,3 +174,17 @@ func (s *OrderStore) ListOrdersByStatus(statuses []model.OrderStatusV2, limit in
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAttemptsByChannelSince 列某渠道 created_at>=since 的 attempt(任意状态),
|
||||
// 供 orphan 扫描构造"已知期望金额集"(凡 pay 合法签发过的金额都不算孤儿)。
|
||||
func (s *OrderStore) ListAttemptsByChannelSince(channel string, since time.Time, limit int) ([]model.Attempt, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
}
|
||||
var out []model.Attempt
|
||||
if err := s.db.Where("channel = ? AND created_at >= ?", channel, since).
|
||||
Order("id DESC").Limit(limit).Find(&out).Error; err != nil {
|
||||
return nil, fmt.Errorf("store.ListAttemptsByChannelSince: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -176,3 +176,28 @@ func TestListOrdersByStatus(t *testing.T) {
|
||||
t.Fatalf("命中集合不对: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAttemptsByChannelSince 供 orphan 扫描构造"已知期望金额集":同渠道、
|
||||
// created_at>=since、任意状态的 attempt(pending/paid/expired 都算——凡 pay
|
||||
// 合法签发过的金额都不算孤儿)。
|
||||
func TestListAttemptsByChannelSince(t *testing.T) {
|
||||
db := model.OpenTestDB(t)
|
||||
s := store.NewOrderStore(db)
|
||||
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
mk := func(no, ch string, ago time.Duration) {
|
||||
_ = s.CreateAttempt(&model.Attempt{OutTradeNo: no, Channel: ch, ProviderRef: "R-" + no,
|
||||
AmountMinor: 100, Currency: "USDT", Status: model.AttemptPending})
|
||||
_ = db.Model(&model.Attempt{}).Where("out_trade_no = ?", no).Update("created_at", now.Add(-ago)).Error
|
||||
}
|
||||
mk("C1", "crypto", 10*time.Minute)
|
||||
mk("C2", "crypto", 5*time.Hour) // 太旧
|
||||
mk("A1", "alipay", 1*time.Minute)
|
||||
|
||||
got, err := s.ListAttemptsByChannelSince("crypto", now.Add(-time.Hour), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].OutTradeNo != "C1" {
|
||||
t.Fatalf("只应含近期 crypto, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
)
|
||||
|
||||
type OrphanStore struct{ db *gorm.DB }
|
||||
|
||||
func NewOrphanStore(db *gorm.DB) *OrphanStore { return &OrphanStore{db: db} }
|
||||
|
||||
// Record 幂等落一条孤儿(tx_id 冲突 no-op)。返回是否新记(供告警只喊一次)。
|
||||
func (s *OrphanStore) Record(o *model.OrphanPayment) (bool, error) {
|
||||
res := s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tx_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(o)
|
||||
if res.Error != nil {
|
||||
return false, fmt.Errorf("store.OrphanStore.Record: %w", res.Error)
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
)
|
||||
|
||||
func TestOrphanStoreRecordIdempotent(t *testing.T) {
|
||||
os := store.NewOrphanStore(model.OpenTestDB(t))
|
||||
o := &model.OrphanPayment{Channel: "crypto", AccountID: "cry-1", TxID: "TX-1",
|
||||
AmountMinor: 12345, Currency: "USDT", DetectedAt: time.Now()}
|
||||
first, err := os.Record(o)
|
||||
if err != nil || !first {
|
||||
t.Fatalf("首次应记入, first=%v err=%v", first, err)
|
||||
}
|
||||
again, err := os.Record(&model.OrphanPayment{Channel: "crypto", AccountID: "cry-1", TxID: "TX-1",
|
||||
AmountMinor: 12345, Currency: "USDT", DetectedAt: time.Now()})
|
||||
if err != nil || again {
|
||||
t.Fatalf("同 tx_id 应幂等 no-op, again=%v err=%v", again, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user