4bb92209ca
- store(SQLite,modernc 纯 Go):pay_orders + addr_cursor(HD 派生游标,地址不复用);
建单/查单/ListPending/MarkPaid(幂等,仅 pending→paid)/MarkExpired。
- pay 服务:CreateOrder 每单 NextAddrIndex→从 xpub watch-only 派生唯一收款地址→写 pending 单(TTL 15min)。
- tron:TronGrid 客户端读已确认 TRC20 到账(only_confirmed + USDT 合约,micro-USDT 整数)。
- watcher:Tick 先过期逾期单,再对每个 pending 单查到账、金额≥期望→MarkPaid;幂等(同 tx 只认一次)、
网络错误跳过下轮重试;Loop 定时轮询。
- httpapi:POST /order、GET /order/{orderNo}、/healthz;cmd/paywatch 用 env 装配 + 优雅退出。
- 测试:store/service/watcher(mock TronGrid)/httpapi 全绿——建单派生地址正确、到账侦测、
欠额不认、幂等、超时过期、404/400。热服务无私钥。
- README:安全模型 + Phase A 离线备钱包步骤 + 运行/API/测试。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
89 lines
2.5 KiB
Go
89 lines
2.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func openMem(t *testing.T) *Store {
|
|
t.Helper()
|
|
s, err := Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s
|
|
}
|
|
|
|
func TestNextAddrIndexMonotonic(t *testing.T) {
|
|
s := openMem(t)
|
|
ctx := context.Background()
|
|
for want := uint32(0); want < 5; want++ {
|
|
got, err := s.NextAddrIndex(ctx)
|
|
if err != nil {
|
|
t.Fatalf("next: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("index got %d want %d", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOrderRoundtripAndMarkPaidIdempotent(t *testing.T) {
|
|
s := openMem(t)
|
|
ctx := context.Background()
|
|
now := time.Unix(1_700_000_000, 0)
|
|
o := &Order{
|
|
OrderNo: "PAY1", SKU: "pro-year", ExpectAmount: 5_000000, AddrIndex: 0,
|
|
Address: "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", Status: StatusPending,
|
|
CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
|
|
}
|
|
if err := s.CreateOrder(ctx, o); err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
got, err := s.GetOrder(ctx, "PAY1")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got.SKU != "pro-year" || got.ExpectAmount != 5_000000 || got.Status != StatusPending {
|
|
t.Fatalf("roundtrip mismatch: %+v", got)
|
|
}
|
|
|
|
ok, err := s.MarkPaid(ctx, "PAY1", "tx-abc")
|
|
if err != nil || !ok {
|
|
t.Fatalf("first MarkPaid ok=%v err=%v (want true,nil)", ok, err)
|
|
}
|
|
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-dup")
|
|
if err != nil || ok2 {
|
|
t.Fatalf("second MarkPaid ok=%v err=%v (want false,nil — idempotent)", ok2, err)
|
|
}
|
|
got, _ = s.GetOrder(ctx, "PAY1")
|
|
if got.Status != StatusPaid || got.TxID != "tx-abc" {
|
|
t.Fatalf("after paid: status=%s tx=%s (want paid,tx-abc)", got.Status, got.TxID)
|
|
}
|
|
}
|
|
|
|
func TestMarkExpired(t *testing.T) {
|
|
s := openMem(t)
|
|
ctx := context.Background()
|
|
base := time.Unix(1_700_000_000, 0)
|
|
past := &Order{OrderNo: "old", SKU: "x", ExpectAmount: 1, Address: "T1", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Minute)}
|
|
future := &Order{OrderNo: "new", SKU: "x", ExpectAmount: 1, Address: "T2", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Hour)}
|
|
_ = s.CreateOrder(ctx, past)
|
|
_ = s.CreateOrder(ctx, future)
|
|
|
|
n, err := s.MarkExpired(ctx, base.Add(10*time.Minute))
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("MarkExpired n=%d err=%v (want 1)", n, err)
|
|
}
|
|
oldO, _ := s.GetOrder(ctx, "old")
|
|
newO, _ := s.GetOrder(ctx, "new")
|
|
if oldO.Status != StatusExpired {
|
|
t.Fatalf("old should be expired, got %s", oldO.Status)
|
|
}
|
|
if newO.Status != StatusPending {
|
|
t.Fatalf("new should still be pending, got %s", newO.Status)
|
|
}
|
|
}
|