c5949a595a
从"每单唯一 HD 地址"改为"单个固定收款地址 + 每单唯一金额",归集成本 O(订单数)→O(1)。 - store: pay_orders 加 user_ref/expect_amount(唯一金额)/matched_tx_id;新 orphan_payments 表; ActiveOrderByUser(同用户单订单)、AmountRecentlyUsed(迟到窗口内金额不复用)、TxHandled(幂等)、 RecordOrphan。去掉每单派生游标。 - pay: CreateOrder(userRef,sku,priceMicro)——同用户单订单校验 + 分配唯一金额(base+随机微尾数[1,9999]、 cooldown 内不复用),address 恒为收款地址。 - tron: Transfer 加 BlockTs(区块时间秒),取 block_timestamp。 - watcher: 单地址取到账,按"金额==expect && block_ts>建单"匹配 → paid;不匹配的到账 → orphan;幂等。 - httpapi: POST /order 加 user_ref,同用户重复 → 409;main 收款地址=PAY_RECEIVE_ADDRESS 或 xpub index0。 - 测试:唯一金额/同地址、同用户单订单、精确匹配、付错成孤儿、迟到不误配新单、付款早于建单不匹配、 超时、幂等、409,全绿。README 更新为单地址模型+API(user_ref/精确金额/orphan)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
65 lines
1.9 KiB
Go
65 lines
1.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/wangjia/pangolin/pay/internal/pay"
|
|
"github.com/wangjia/pangolin/pay/internal/store"
|
|
)
|
|
|
|
const recvAddr = "TRecv00000000000000000000000000000A"
|
|
|
|
func TestCreateGetAndConflict(t *testing.T) {
|
|
st, _ := store.Open(":memory:")
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
srv := httptest.NewServer(New(pay.New(st, pay.Config{ReceiveAddress: recvAddr})))
|
|
t.Cleanup(srv.Close)
|
|
|
|
body, _ := json.Marshal(map[string]any{"user_ref": "u1", "sku": "pro-year", "amount": 5_000000})
|
|
resp, err := http.Post(srv.URL+"/order", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create status %d", resp.StatusCode)
|
|
}
|
|
var created orderResp
|
|
_ = json.NewDecoder(resp.Body).Decode(&created)
|
|
_ = resp.Body.Close()
|
|
if created.Address != recvAddr || created.Status != "pending" {
|
|
t.Fatalf("create resp: %+v", created)
|
|
}
|
|
if created.ExpectAmount <= 5_000000 {
|
|
t.Fatalf("expect_amount %d should be base+unique tail", created.ExpectAmount)
|
|
}
|
|
|
|
r2, _ := http.Get(srv.URL + "/order/" + created.OrderNo)
|
|
if r2.StatusCode != http.StatusOK {
|
|
t.Fatalf("get status %d", r2.StatusCode)
|
|
}
|
|
_ = r2.Body.Close()
|
|
|
|
// Same user again -> 409 Conflict.
|
|
r3, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader(body))
|
|
if r3.StatusCode != http.StatusConflict {
|
|
t.Fatalf("want 409 for second active order, got %d", r3.StatusCode)
|
|
}
|
|
_ = r3.Body.Close()
|
|
|
|
r4, _ := http.Get(srv.URL + "/order/NOPE")
|
|
if r4.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("want 404, got %d", r4.StatusCode)
|
|
}
|
|
_ = r4.Body.Close()
|
|
|
|
r5, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader([]byte(`{"user_ref":"u2","sku":"x","amount":0}`)))
|
|
if r5.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("want 400 for bad amount, got %d", r5.StatusCode)
|
|
}
|
|
_ = r5.Body.Close()
|
|
}
|