feat(v2): crypto 孤儿到账发现——OrphanScanner 扫链核对 + orphan_payments 落表告警(对账兜底)

This commit is contained in:
wangjia
2026-07-10 17:52:10 +08:00
parent 04c3f4f31a
commit 727ed74899
12 changed files with 370 additions and 7 deletions
+63
View File
@@ -240,6 +240,69 @@ func (p *Provider) VerifyCallback(_ context.Context, _ provider.CallbackInput) (
return nil, provider.ErrNotSupported
}
// ScanOrphans 扫地址近 Since 的确认到账,金额不在"任一 Known 的期望金额集"内 → 孤儿。
// 期望金额 = known.AmountMinor + tailFromRef(known.ProviderRef);块时须晚于 Since。
func (p *Provider) ScanOrphans(ctx context.Context, req provider.OrphanScanRequest) ([]provider.OrphanTransfer, error) {
addr, err := p.address(req.AccountID)
if err != nil {
return nil, err
}
expected := make(map[int64]struct{}, len(req.Known))
for _, k := range req.Known {
tail, terr := tailFromRef(k.ProviderRef)
if terr != nil {
continue // 无尾数的 ref 跳过(不误判为孤儿依据)
}
expected[k.AmountMinor+tail] = struct{}{}
}
endpoint := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_confirmed=true&contract_address=%s&limit=50",
p.baseURL, url.PathEscape(addr), url.QueryEscape(USDTContract))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
if k := p.apiKey(req.AccountID); k != "" {
httpReq.Header.Set("TRON-PRO-API-KEY", k)
}
resp, err := p.http.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("crypto: TronGrid HTTP %d: %s", resp.StatusCode, body)
}
var tr trc20Resp
if err := json.Unmarshal(body, &tr); err != nil {
return nil, err
}
sinceUnix := req.Since.Unix()
var out []provider.OrphanTransfer
for _, d := range tr.Data {
if d.To != addr || d.Type != "Transfer" {
continue
}
blockTs := d.BlockMs / 1000
if blockTs < sinceUnix {
continue // 窗外旧款不扫(避免把历史正常单反复报孤儿)
}
val, perr := strconv.ParseInt(d.Value, 10, 64)
if perr != nil {
continue
}
if _, ok := expected[val]; ok {
continue // 金额有主(匹配某 attempt 期望额)→ 非孤儿
}
out = append(out, provider.OrphanTransfer{
TxID: d.TxID, AmountMinor: val, Currency: "USDT", At: time.Unix(blockTs, 0),
})
}
return out, nil
}
// trc20Resp 对应 TronGrid /v1/accounts/{addr}/transactions/trc20 响应
// (canonical tron/client.go 同构;contract_address 查询参数已在服务端过滤合约)。
type trc20Resp struct {
+31
View File
@@ -238,3 +238,34 @@ func TestWarmRebuildsReservationsFromLoader(t *testing.T) {
t.Fatalf("超冷却窗的预留不应恢复, got %v", res)
}
}
// 孤儿到账发现:假 TronGrid 返回两笔确认到账,一笔匹配 known 期望金额(base+tail),
// 一笔无主 → 仅后者报孤儿。
func TestScanOrphansFlagsUnmatchedTransfer(t *testing.T) {
const addr = "TOrphanScanAddr00000000000000000000"
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
// 假 TronGrid:to=addr 两笔确认到账。29990263 匹配 known(base 29990000 + tail 263);88880000 无主。
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[
{"transaction_id":"TX-MATCH","to":"` + addr + `","type":"Transfer","value":"29990263","block_timestamp":` + strconv.FormatInt(now.Add(-5*time.Minute).UnixMilli(), 10) + `},
{"transaction_id":"TX-ORPHAN","to":"` + addr + `","type":"Transfer","value":"88880000","block_timestamp":` + strconv.FormatInt(now.Add(-3*time.Minute).UnixMilli(), 10) + `}
]}`))
}))
defer ts.Close()
t.Setenv("CRY_ADDRESS", addr)
reg := accounts.New([]config.AccountConfig{{AccountID: "cry-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "cry"}})
p := crypto.New(reg, crypto.WithBaseURL(ts.URL), crypto.WithHTTPClient(ts.Client()), crypto.WithNow(func() time.Time { return now }))
orphans, err := p.ScanOrphans(context.Background(), provider.OrphanScanRequest{
AccountID: "cry-1", Since: now.Add(-time.Hour),
Known: []provider.KnownAttempt{{AmountMinor: 29990000, ProviderRef: "CRYPTO-PAY-A-263"}}, // 期望 29990263
})
if err != nil {
t.Fatalf("scan: %v", err)
}
if len(orphans) != 1 || orphans[0].TxID != "TX-ORPHAN" || orphans[0].AmountMinor != 88880000 {
t.Fatalf("只应报 1 笔孤儿 TX-ORPHAN, got %+v", orphans)
}
}
+30
View File
@@ -123,6 +123,36 @@ type RecurringProvider interface {
CancelAgreement(ctx context.Context, agreementRef string) error
}
// ---- 对账:孤儿到账扫描(P6,可选接口)----
// KnownAttempt 是 pay 合法签发过的一笔尝试的对账维度:期望金额 = base(AmountMinor)+ 渠道尾数
// (尾数封在 provider_ref,由渠道自解,pay 不算)。渠道据此判断一笔到账是否"有主"。
type KnownAttempt struct {
AmountMinor int64
ProviderRef string
}
// OrphanScanRequest 扫描某账户 Since 以来、不匹配任何 Known 的到账。
type OrphanScanRequest struct {
AccountID string
Since time.Time
Known []KnownAttempt
}
// OrphanTransfer 一笔"有钱到账但无主"的转账(付错金额/手动转/超窗迟到旧款)。
type OrphanTransfer struct {
TxID string
AmountMinor int64
Currency string
At time.Time
}
// OrphanScanner 自托管渠道(crypto)可选实现:发现到账但不匹配任何 attempt 的转账。
// 网关侧渠道(alipay/stripe)以对账单核对,不实现此接口。
type OrphanScanner interface {
ScanOrphans(ctx context.Context, req OrphanScanRequest) ([]OrphanTransfer, error)
}
// Registry — 方法名 → Provider(设计 §2 Provider adapter 注册表)。启动期注册,运行期只读。
type Registry struct{ providers map[string]Provider }