Files
qianyuandClaude Fable 5.1 0e0f73e2c7 多收款账号:API 动态添加币安账号,订单按账号隔离轮询与匹配
- 新增 accounts 表与接口:POST /api/v1/accounts(用只读接口试拉流水校验,失败 ERR_BINANCE)、
  GET /api/v1/accounts/{id}、POST .../verify、.../disable、.../enable;Secret 以 AES-256-GCM 加密存储,密钥由 API_AUTH_KEY 派生
- 订单增加 account_id(旧库自动补列,默认 default);唯一金额按账号分配;Matcher 按账号各自轮询、缓存与匹配,
  无待核销订单的账号不调币安接口;回填(claim)只在订单所属账号的流水里找
- 收银页按订单所属账号展示 UID / 收款链接 / 二维码;订单与回调体新增 account_id,订单响应新增 receive_link / receive_email
- Go / Python SDK 同步(AccountID、账号方法);协议文档 §2.6;e2e 覆盖多账号隔离、回填、停用与默认账号保护

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 09:39:48 +08:00

605 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"database/sql"
"errors"
"fmt"
"math/rand"
"strings"
"sync"
_ "modernc.org/sqlite"
)
var (
ErrDuplicate = errors.New("duplicate merchant_order_id")
ErrAlloc = errors.New("no unique amount available")
ErrNotFound = errors.New("not found")
ErrState = errors.New("invalid state")
)
type Order struct {
ID string
Token string
AccountID string // 收款账号,缺省 default(config.env 里的账号)
MerchantOrderID string
Currency string
BaseAmount int64
PayAmount int64
ActualAmount int64
Status string // pending|paid|underpaid|expired|closed
NoteCode string
BinanceOrderID string
BinanceTxnID string
PayerID string
PayerName string
MatchedBy string // amount|note|claim
CallbackURL string
ReturnURL string
CreatedAt int64
ExpiresAt int64
PaidAt int64
Overpaid bool
}
// Account 动态添加的收款账号(默认账号来自 config.env,不入库)。
type Account struct {
ID string
Label string
APIKey string
APISecret string // 内存中为明文,库中 AES-GCM 加密
UID string
ReceiveLink string
ReceiveEmail string
Status string // active | disabled
LastOK int64
LastErr string
CreatedAt int64
}
type cbJob struct {
ID int64
OrderID string
Event string
Attempt int
}
type Store struct {
db *sql.DB
mu sync.Mutex
key []byte // 账号密钥加密用,见 secrets.go
}
const schema = `
CREATE TABLE IF NOT EXISTS orders(
id TEXT PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
merchant_order_id TEXT UNIQUE NOT NULL,
currency TEXT NOT NULL,
base_amount INTEGER NOT NULL,
pay_amount INTEGER NOT NULL,
actual_amount INTEGER,
status TEXT NOT NULL,
note_code TEXT NOT NULL,
binance_order_id TEXT,
binance_txn_id TEXT,
payer_id TEXT,
payer_name TEXT,
matched_by TEXT,
callback_url TEXT,
return_url TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
paid_at INTEGER,
overpaid INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_amount ON orders(currency, pay_amount);
CREATE TABLE IF NOT EXISTS consumed(
binance_order_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cooldown(
currency TEXT NOT NULL,
amount INTEGER NOT NULL,
until INTEGER NOT NULL,
PRIMARY KEY(currency, amount)
);
CREATE TABLE IF NOT EXISTS callbacks(
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL,
event TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
next_at INTEGER NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
last_err TEXT
);
CREATE INDEX IF NOT EXISTS idx_callbacks_due ON callbacks(done, next_at);
CREATE TABLE IF NOT EXISTS accounts(
id TEXT PRIMARY KEY,
label TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL UNIQUE,
api_secret TEXT NOT NULL,
uid TEXT NOT NULL,
receive_link TEXT NOT NULL DEFAULT '',
receive_email TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
last_ok INTEGER NOT NULL DEFAULT 0,
last_err TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
`
// migrations 给旧库补列;重复执行会因「duplicate column」报错,忽略即可。
var migrations = []string{
`ALTER TABLE orders ADD COLUMN account_id TEXT NOT NULL DEFAULT 'default'`,
`CREATE INDEX IF NOT EXISTS idx_orders_account ON orders(account_id, status)`,
}
func openStore(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
for _, p := range []string{
"PRAGMA journal_mode=WAL;",
"PRAGMA busy_timeout=5000;",
"PRAGMA synchronous=NORMAL;",
} {
if _, err := db.Exec(p); err != nil {
return nil, err
}
}
if _, err := db.Exec(schema); err != nil {
return nil, err
}
for _, m := range migrations {
if _, err := db.Exec(m); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return nil, err
}
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
const orderCols = `id,token,merchant_order_id,currency,base_amount,pay_amount,COALESCE(actual_amount,0),status,note_code,
COALESCE(binance_order_id,''),COALESCE(binance_txn_id,''),COALESCE(payer_id,''),COALESCE(payer_name,''),COALESCE(matched_by,''),
COALESCE(callback_url,''),COALESCE(return_url,''),created_at,expires_at,COALESCE(paid_at,0),overpaid,COALESCE(account_id,'default')`
type rowScanner interface{ Scan(...any) error }
func scanOrder(row rowScanner) (*Order, error) {
var o Order
var overpaid int
err := row.Scan(&o.ID, &o.Token, &o.MerchantOrderID, &o.Currency, &o.BaseAmount, &o.PayAmount, &o.ActualAmount,
&o.Status, &o.NoteCode, &o.BinanceOrderID, &o.BinanceTxnID, &o.PayerID, &o.PayerName, &o.MatchedBy,
&o.CallbackURL, &o.ReturnURL, &o.CreatedAt, &o.ExpiresAt, &o.PaidAt, &overpaid, &o.AccountID)
if err != nil {
return nil, err
}
o.Overpaid = overpaid != 0
return &o, nil
}
func (s *Store) orderBy(where string, arg any) (*Order, error) {
row := s.db.QueryRow(`SELECT `+orderCols+` FROM orders WHERE `+where, arg)
o, err := scanOrder(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return o, err
}
func (s *Store) OrderByID(id string) (*Order, error) { return s.orderBy("id=?", id) }
func (s *Store) OrderByToken(tok string) (*Order, error) { return s.orderBy("token=?", tok) }
func (s *Store) OrderByMerchantID(mid string) (*Order, error) {
return s.orderBy("merchant_order_id=?", mid)
}
// CandidateOrders 返回某收款账号下可被入账流水核销的订单:待支付,或过期但仍在宽限期内。
func (s *Store) CandidateOrders(accountID string, now, graceMs int64) ([]*Order, error) {
rows, err := s.db.Query(`SELECT `+orderCols+` FROM orders
WHERE account_id=? AND (status='pending' OR (status='expired' AND expires_at+? > ?))`, accountID, graceMs, now)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Order
for rows.Next() {
o, err := scanOrder(rows)
if err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}
// CreateOrder 分配唯一金额并落库。分配与插入在同一把锁内,杜绝并发撞金额。唯一金额按收款账号隔离。
func (s *Store) CreateOrder(cfg *Config, accountID, mid, currency string, base int64, ttlSec int64, callbackURL, returnURL string) (*Order, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := nowMs()
var exist string
err := s.db.QueryRow(`SELECT id FROM orders WHERE merchant_order_id=?`, mid).Scan(&exist)
if err == nil {
return nil, ErrDuplicate
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
unit := int64(1)
for i := 0; i < 8-cfg.AmountDecimals; i++ {
unit *= 10
}
maxK := int64(1)
for i := 0; i < cfg.AmountDecimals; i++ {
maxK *= 10
}
maxK--
lo, hi := base+unit, base+maxK*unit
if cfg.SuffixMode == "sub" {
lo, hi = base-maxK*unit, base-unit
if lo < unit {
lo = unit
}
if hi < lo {
return nil, ErrAlloc
}
}
taken := map[int64]bool{}
graceMs := cfg.ExpiredGrace * 1000
rows, err := s.db.Query(`SELECT pay_amount FROM orders WHERE account_id=? AND currency=? AND pay_amount BETWEEN ? AND ?
AND (status='pending' OR (status IN ('expired','underpaid') AND expires_at+? > ?))`,
accountID, currency, lo, hi, graceMs, now)
if err != nil {
return nil, err
}
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
rows.Close()
return nil, err
}
taken[v] = true
}
rows.Close()
rows, err = s.db.Query(`SELECT amount FROM cooldown WHERE currency=? AND amount BETWEEN ? AND ? AND until > ?`,
currency, lo, hi, now)
if err != nil {
return nil, err
}
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
rows.Close()
return nil, err
}
taken[v] = true
}
rows.Close()
// 递进式尾数:优先最小的两位档(付款方最多多付 0.0099),
// 该档用满自动升到三位、四位档,兼顾「多付最少」与并发容量。
candOf := func(k int64) int64 {
if cfg.SuffixMode == "sub" {
return base - k*unit
}
return base + k*unit
}
pick := int64(0)
rangeLo := int64(1)
for _, rangeHi := range []int64{99, 999, maxK} {
if rangeHi > maxK {
rangeHi = maxK
}
if rangeHi < rangeLo {
break
}
span := rangeHi - rangeLo + 1
for i := int64(0); i < 200 && i < span*2; i++ {
k := rangeLo + rand.Int63n(span)
c := candOf(k)
if c > 0 && !taken[c] {
pick = c
break
}
}
if pick == 0 {
for k := rangeLo; k <= rangeHi; k++ {
c := candOf(k)
if c > 0 && !taken[c] {
pick = c
break
}
}
}
if pick != 0 {
break
}
rangeLo = rangeHi + 1
}
if pick == 0 {
return nil, ErrAlloc
}
o := &Order{
ID: newOrderID(), Token: newToken(), AccountID: accountID, MerchantOrderID: mid, Currency: currency,
BaseAmount: base, PayAmount: pick, Status: "pending", NoteCode: newNoteCode(),
CallbackURL: callbackURL, ReturnURL: returnURL,
CreatedAt: now, ExpiresAt: now + ttlSec*1000,
}
_, err = s.db.Exec(`INSERT INTO orders(id,token,account_id,merchant_order_id,currency,base_amount,pay_amount,status,note_code,callback_url,return_url,created_at,expires_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
o.ID, o.Token, o.AccountID, o.MerchantOrderID, o.Currency, o.BaseAmount, o.PayAmount, o.Status, o.NoteCode,
o.CallbackURL, o.ReturnURL, o.CreatedAt, o.ExpiresAt)
if err != nil {
return nil, err
}
return o, nil
}
// Finish 用一笔币安流水核销订单(paid 或 underpaid)。以币安 orderId 幂等:已消费返回 false。
func (s *Store) Finish(o *Order, status, matchedBy, binanceOrderID, binanceTxnID, payerID, payerName string, actual int64, cooldownSec int64) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := nowMs()
tx, err := s.db.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err := tx.Exec(`INSERT INTO consumed(binance_order_id,order_id,at) VALUES(?,?,?)`, binanceOrderID, o.ID, now); err != nil {
return false, nil // 已被消费(唯一键冲突),不视为错误
}
res, err := tx.Exec(`UPDATE orders SET status=?, matched_by=?, binance_order_id=?, binance_txn_id=?, payer_id=?, payer_name=?,
actual_amount=?, paid_at=?, overpaid=? WHERE id=? AND status IN ('pending','expired')`,
status, matchedBy, binanceOrderID, binanceTxnID, payerID, payerName,
actual, now, boolInt(actual > o.PayAmount), o.ID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
if n == 0 {
return false, nil // 订单已被其他流水处理
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO cooldown(currency,amount,until) VALUES(?,?,?)`,
o.Currency, o.PayAmount, now+cooldownSec*1000); err != nil {
return false, err
}
event := status // paid / underpaid
if _, err := tx.Exec(`INSERT INTO callbacks(order_id,event,next_at) VALUES(?,?,?)`, o.ID, event, now); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// ExpireDue 把到期订单置为 expired,并入冷却、发回调。返回处理条数。
func (s *Store) ExpireDue(cooldownSec int64) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := nowMs()
rows, err := s.db.Query(`SELECT `+orderCols+` FROM orders WHERE status='pending' AND expires_at < ?`, now)
if err != nil {
return 0, err
}
var due []*Order
for rows.Next() {
o, err := scanOrder(rows)
if err != nil {
rows.Close()
return 0, err
}
due = append(due, o)
}
rows.Close()
for _, o := range due {
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
if _, err := tx.Exec(`UPDATE orders SET status='expired' WHERE id=? AND status='pending'`, o.ID); err != nil {
tx.Rollback()
return 0, err
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO cooldown(currency,amount,until) VALUES(?,?,?)`,
o.Currency, o.PayAmount, now+cooldownSec*1000); err != nil {
tx.Rollback()
return 0, err
}
if _, err := tx.Exec(`INSERT INTO callbacks(order_id,event,next_at) VALUES(?, 'expired', ?)`, o.ID, now); err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
}
return len(due), nil
}
// CloseOrder 商户主动关单。
func (s *Store) CloseOrder(id string, cooldownSec int64) (*Order, error) {
s.mu.Lock()
defer s.mu.Unlock()
o, err := s.OrderByID(id)
if err != nil {
return nil, err
}
if o.Status != "pending" {
return nil, ErrState
}
now := nowMs()
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE orders SET status='closed' WHERE id=? AND status='pending'`, id); err != nil {
return nil, err
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO cooldown(currency,amount,until) VALUES(?,?,?)`,
o.Currency, o.PayAmount, now+cooldownSec*1000); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
o.Status = "closed"
return o, nil
}
func (s *Store) Consumed(binanceOrderID string) (bool, error) {
var x string
err := s.db.QueryRow(`SELECT order_id FROM consumed WHERE binance_order_id=?`, binanceOrderID).Scan(&x)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (s *Store) DueCallbacks(limit int) ([]cbJob, error) {
rows, err := s.db.Query(`SELECT id,order_id,event,attempt FROM callbacks WHERE done=0 AND next_at<=? ORDER BY id LIMIT ?`, nowMs(), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []cbJob
for rows.Next() {
var j cbJob
if err := rows.Scan(&j.ID, &j.OrderID, &j.Event, &j.Attempt); err != nil {
return nil, err
}
out = append(out, j)
}
return out, rows.Err()
}
func (s *Store) CallbackDone(id int64) error {
_, err := s.db.Exec(`UPDATE callbacks SET done=1, last_err='' WHERE id=?`, id)
return err
}
func (s *Store) CallbackRetry(id int64, attempt int, nextAt int64, lastErr string, giveUp bool) error {
if giveUp {
_, err := s.db.Exec(`UPDATE callbacks SET done=1, attempt=?, last_err=? WHERE id=?`, attempt, "GIVE_UP: "+lastErr, id)
return err
}
_, err := s.db.Exec(`UPDATE callbacks SET attempt=?, next_at=?, last_err=? WHERE id=?`, attempt, nextAt, lastErr, id)
return err
}
// ---------- 收款账号 ----------
const accountCols = `id,label,api_key,api_secret,uid,receive_link,receive_email,status,last_ok,last_err,created_at`
func (s *Store) scanAccount(row rowScanner) (*Account, error) {
var a Account
var enc string
if err := row.Scan(&a.ID, &a.Label, &a.APIKey, &enc, &a.UID, &a.ReceiveLink, &a.ReceiveEmail, &a.Status, &a.LastOK, &a.LastErr, &a.CreatedAt); err != nil {
return nil, err
}
sec, err := decryptStr(s.key, enc)
if err != nil {
return nil, fmt.Errorf("解密账号 %s 的密钥失败(API_AUTH_KEY 是否变更?): %w", a.ID, err)
}
a.APISecret = sec
return &a, nil
}
func (s *Store) GetAccount(id string) (*Account, error) {
a, err := s.scanAccount(s.db.QueryRow(`SELECT `+accountCols+` FROM accounts WHERE id=?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return a, err
}
func (s *Store) ListAccounts(activeOnly bool) ([]*Account, error) {
q := `SELECT ` + accountCols + ` FROM accounts`
if activeOnly {
q += ` WHERE status='active'`
}
rows, err := s.db.Query(q + ` ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Account
for rows.Next() {
a, err := s.scanAccount(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// UpsertAccount 按 api_key 幂等:已存在则更新资料并重新启用,否则新建。写回 a.ID / a.CreatedAt。
func (s *Store) UpsertAccount(a *Account) error {
s.mu.Lock()
defer s.mu.Unlock()
enc, err := encryptStr(s.key, a.APISecret)
if err != nil {
return err
}
var id string
var created int64
err = s.db.QueryRow(`SELECT id, created_at FROM accounts WHERE api_key=?`, a.APIKey).Scan(&id, &created)
if err == nil {
a.ID, a.CreatedAt, a.Status = id, created, "active"
_, err = s.db.Exec(`UPDATE accounts SET label=?, api_secret=?, uid=?, receive_link=?, receive_email=?, status='active', last_err='' WHERE id=?`,
a.Label, enc, a.UID, a.ReceiveLink, a.ReceiveEmail, id)
return err
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
a.ID, a.CreatedAt, a.Status = newAccountID(), nowMs(), "active"
_, err = s.db.Exec(`INSERT INTO accounts(id,label,api_key,api_secret,uid,receive_link,receive_email,status,created_at) VALUES(?,?,?,?,?,?,?,'active',?)`,
a.ID, a.Label, a.APIKey, enc, a.UID, a.ReceiveLink, a.ReceiveEmail, a.CreatedAt)
return err
}
func (s *Store) SetAccountStatus(id, status string) error {
res, err := s.db.Exec(`UPDATE accounts SET status=? WHERE id=?`, status, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// AccountPolled 记录账号最近一次轮询结果。
func (s *Store) AccountPolled(id string, ok bool, errStr string) error {
if ok {
_, err := s.db.Exec(`UPDATE accounts SET last_ok=?, last_err='' WHERE id=?`, nowMs(), id)
return err
}
_, err := s.db.Exec(`UPDATE accounts SET last_err=? WHERE id=?`, errStr, id)
return err
}