diff --git a/internal/provider/crypto/crypto.go b/internal/provider/crypto/crypto.go index 92b0eaa..39132b1 100644 --- a/internal/provider/crypto/crypto.go +++ b/internal/provider/crypto/crypto.go @@ -51,7 +51,7 @@ type Provider struct { now func() time.Time mu sync.Mutex - reserved map[string]time.Time // "/" → 预留到期(canonical AmountRecentlyUsed 的进程内等价) + reserved map[string]time.Time // "
/" → 预留到期(链上匹配维度,对齐 Query 的 to==addr)(canonical AmountRecentlyUsed 的进程内等价) } type Option func(*Provider) @@ -99,9 +99,27 @@ func (p *Provider) apiKey(accountID string) string { return os.Getenv("TRONGRID_API_KEY") } +// GetReserved 仅用于测试,返回预留表副本。 +func (p *Provider) GetReserved() map[string]time.Time { + p.mu.Lock() + defer p.mu.Unlock() + m := make(map[string]time.Time, len(p.reserved)) + for k, v := range p.reserved { + m[k] = v + } + return m +} + // allocateAmount 移植 canonical pay/service.go:随机尾数 [1,tailMax] + 冷却预留, // 保证同(地址,金额)在冷却窗内唯一——迟到付款绝不可能匹配到新单。64 次重试。 +// 预留键基于地址(链上匹配维度),防止共享地址的不同账户产生同金额碰撞。 func (p *Provider) allocateAmount(accountID string, base int64) (amount, tail int64, err error) { + // 解析地址(同 Query 逻辑,作为链上匹配维度的真相源) + addr, err := p.address(accountID) + if err != nil { + return 0, 0, err + } + p.mu.Lock() defer p.mu.Unlock() now := p.now() @@ -116,7 +134,7 @@ func (p *Provider) allocateAmount(accountID string, base int64) (amount, tail in return 0, 0, rerr } amt := base + t - key := accountID + "/" + strconv.FormatInt(amt, 10) + key := addr + "/" + strconv.FormatInt(amt, 10) if _, used := p.reserved[key]; used { continue } diff --git a/internal/provider/crypto/crypto_test.go b/internal/provider/crypto/crypto_test.go index 0f7ac3b..0be18aa 100644 --- a/internal/provider/crypto/crypto_test.go +++ b/internal/provider/crypto/crypto_test.go @@ -141,3 +141,68 @@ func TestVerifyCallbackNotSupported(t *testing.T) { t.Fatal("crypto 无异步回调,VerifyCallback 应返回 ErrNotSupported") } } + +// 预留键按地址而非 accountID 维度:防止共享地址的不同账户在同金额上碰撞。 +// 两个 accountID 配置相同 ADDRESS,分别创建订单 → 验证预留键格式为 "address/amount", +// 而非 "accountID/amount"。 +func TestReservationKeyByAddress(t *testing.T) { + const sharedAddr = "TSHARED_ADDRESS_FOR_TEST" + t.Setenv("ACC1_ADDRESS", sharedAddr) + t.Setenv("ACC1_TRONGRID_KEY", "key1") + t.Setenv("ACC2_ADDRESS", sharedAddr) + t.Setenv("ACC2_TRONGRID_KEY", "key2") + + ts := httptest.NewServer(http.NotFoundHandler()) + defer ts.Close() + + reg := accounts.New([]config.AccountConfig{ + {AccountID: "acc-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "acc1"}, + {AccountID: "acc-2", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "acc2"}, + }) + p := crypto.New(reg, crypto.WithBaseURL(ts.URL), crypto.WithHTTPClient(ts.Client())) + + base := int64(50000000) + + // 为账户 1 创建订单 + sess1, err1 := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "ORDER-1", + AmountMinor: base, + Currency: "USDT", + Account: config.AccountConfig{AccountID: "acc-1", CredentialEnvPrefix: "acc1"}, + }) + if err1 != nil { + t.Fatalf("acc-1 create: %v", err1) + } + amt1 := sess1.Payload["amount_minor"].(int64) + + // 为账户 2 创建订单(同 base,共享地址) + sess2, err2 := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "ORDER-2", + AmountMinor: base, + Currency: "USDT", + Account: config.AccountConfig{AccountID: "acc-2", CredentialEnvPrefix: "acc2"}, + }) + if err2 != nil { + t.Fatalf("acc-2 create: %v", err2) + } + amt2 := sess2.Payload["amount_minor"].(int64) + + // 白盒验证:检查预留键格式(应为 "address/amount" 而非 "accountID/amount") + reserved := p.GetReserved() + + // 新代码:键应为 "address/amount" + expectedKey1 := sharedAddr + "/" + strconv.FormatInt(amt1, 10) + expectedKey2 := sharedAddr + "/" + strconv.FormatInt(amt2, 10) + + if _, found := reserved[expectedKey1]; !found { + t.Fatalf("预留键应为 %q(address/amount 格式), 实际键: %v", expectedKey1, reserved) + } + if _, found := reserved[expectedKey2]; !found { + t.Fatalf("预留键应为 %q(address/amount 格式), 实际键: %v", expectedKey2, reserved) + } + + // 两个账户应分配不同金额(防止链上同地址同金额碰撞) + if amt1 == amt2 { + t.Fatalf("共享地址的两个账户不应分配相同金额: %d", amt1) + } +}