- 新增 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>
343 lines
9.5 KiB
Go
343 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const defaultAccountID = "default"
|
|
|
|
// defaultAccount 把 config.env 里的收款账号包装成 Account,与动态账号统一处理。
|
|
func defaultAccount(cfg *Config) *Account {
|
|
return &Account{ID: defaultAccountID, Label: "default", APIKey: cfg.BinanceKey, APISecret: cfg.BinanceSecret, UID: cfg.BinanceUID,
|
|
ReceiveLink: cfg.ReceiveLink, ReceiveEmail: cfg.ReceiveEmail, Status: "active"}
|
|
}
|
|
|
|
// accountState 一个收款账号的轮询状态:币安客户端 + 最近一次拉到的流水缓存。
|
|
type accountState struct {
|
|
acc *Account
|
|
bc *binanceClient
|
|
cache []payTxn
|
|
cacheAt int64
|
|
lastOKWrite int64
|
|
lastErr string
|
|
}
|
|
|
|
// Matcher 按收款账号轮询币安流水并按三条规则核销该账号的订单:
|
|
// ① 唯一金额精确匹配 ② 备注含 note_code ③ 收银页回填币安订单编号(claim)。
|
|
// 不同账号的流水与订单严格隔离。
|
|
type Matcher struct {
|
|
cfg *Config
|
|
st *Store
|
|
mu sync.Mutex
|
|
accs map[string]*accountState
|
|
}
|
|
|
|
func newMatcher(cfg *Config, st *Store) *Matcher {
|
|
m := &Matcher{cfg: cfg, st: st, accs: map[string]*accountState{}}
|
|
m.accs[defaultAccountID] = &accountState{acc: defaultAccount(cfg), bc: newBinanceClient(cfg)}
|
|
return m
|
|
}
|
|
|
|
// refresh 同步数据库里的账号:新增的建状态,停用的移除,密钥变更的重建客户端。
|
|
func (m *Matcher) refresh() []*accountState {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if list, err := m.st.ListAccounts(true); err != nil {
|
|
log.Printf("[error] 读取账号列表失败: %v", err)
|
|
} else {
|
|
live := map[string]bool{defaultAccountID: true}
|
|
for _, a := range list {
|
|
live[a.ID] = true
|
|
as, ok := m.accs[a.ID]
|
|
if !ok || as.acc.APIKey != a.APIKey || as.acc.APISecret != a.APISecret {
|
|
as = &accountState{bc: newBinanceClientFor(m.cfg.BinanceAPIBase, a.APIKey, a.APISecret)}
|
|
m.accs[a.ID] = as
|
|
}
|
|
as.acc = a
|
|
}
|
|
for id := range m.accs {
|
|
if !live[id] {
|
|
delete(m.accs, id)
|
|
}
|
|
}
|
|
}
|
|
out := make([]*accountState, 0, len(m.accs))
|
|
for _, as := range m.accs {
|
|
out = append(out, as)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].acc.ID < out[j].acc.ID })
|
|
return out
|
|
}
|
|
|
|
// stateFor 取某账号的状态;刚创建还没被 refresh 到的账号即时加载。
|
|
func (m *Matcher) stateFor(id string) *accountState {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if as, ok := m.accs[id]; ok {
|
|
return as
|
|
}
|
|
if id != defaultAccountID {
|
|
if a, err := m.st.GetAccount(id); err == nil && a.Status == "active" {
|
|
as := &accountState{acc: a, bc: newBinanceClientFor(m.cfg.BinanceAPIBase, a.APIKey, a.APISecret)}
|
|
m.accs[id] = as
|
|
return as
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Matcher) run(ctx context.Context) {
|
|
t := time.NewTicker(time.Duration(m.cfg.PollInterval) * time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if n, err := m.st.ExpireDue(m.cfg.SuffixCooldown); err != nil {
|
|
log.Printf("[error] 过期处理失败: %v", err)
|
|
} else if n > 0 {
|
|
log.Printf("[info] %d 个订单已过期", n)
|
|
}
|
|
for _, as := range m.refresh() {
|
|
polled, err := m.scan(as)
|
|
m.record(as, polled, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// record 把轮询结果写回账号表:成功每分钟最多写一次,错误只在内容变化时写。
|
|
func (m *Matcher) record(as *accountState, polled bool, err error) {
|
|
if err != nil {
|
|
log.Printf("[error] 账号 %s 流水扫描失败: %v", as.acc.ID, err)
|
|
}
|
|
if as.acc.ID == defaultAccountID || !polled {
|
|
return
|
|
}
|
|
now := nowMs()
|
|
if err != nil {
|
|
if msg := truncate(err.Error(), 200); msg != as.lastErr {
|
|
as.lastErr = msg
|
|
m.st.AccountPolled(as.acc.ID, false, msg)
|
|
}
|
|
return
|
|
}
|
|
if as.lastErr != "" || now-as.lastOKWrite > 60000 {
|
|
as.lastErr = ""
|
|
as.lastOKWrite = now
|
|
m.st.AccountPolled(as.acc.ID, true, "")
|
|
}
|
|
}
|
|
|
|
// fetchWindow 拉取自 startMs 以来的流水,满 100 条时向前翻页(最多 5 页),按时间升序返回。
|
|
func (m *Matcher) fetchWindow(as *accountState, startMs int64) ([]payTxn, error) {
|
|
seen := map[string]bool{}
|
|
var all []payTxn
|
|
endMs := int64(0)
|
|
for page := 0; page < 5; page++ {
|
|
txns, err := as.bc.PayTransactions(startMs, endMs, 100)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
minT := int64(0)
|
|
for _, t := range txns {
|
|
key := t.OrderID
|
|
if key == "" {
|
|
key = t.TransactionID
|
|
}
|
|
if key == "" || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
all = append(all, t)
|
|
if minT == 0 || t.Time < minT {
|
|
minT = t.Time
|
|
}
|
|
}
|
|
if len(txns) < 100 || minT == 0 {
|
|
break
|
|
}
|
|
endMs = minT - 1
|
|
}
|
|
sort.Slice(all, func(i, j int) bool { return all[i].Time < all[j].Time })
|
|
return all, nil
|
|
}
|
|
|
|
// scan 扫描一个账号:没有待核销订单就不调币安接口(省权重)。返回是否真的拉取了流水。
|
|
func (m *Matcher) scan(as *accountState) (bool, error) {
|
|
now := nowMs()
|
|
graceMs := m.cfg.ExpiredGrace * 1000
|
|
cands, err := m.st.CandidateOrders(as.acc.ID, now, graceMs)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(cands) == 0 {
|
|
return false, nil
|
|
}
|
|
minCreated := cands[0].CreatedAt
|
|
for _, o := range cands {
|
|
if o.CreatedAt < minCreated {
|
|
minCreated = o.CreatedAt
|
|
}
|
|
}
|
|
txns, err := m.fetchWindow(as, minCreated-10*60*1000)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
m.mu.Lock()
|
|
as.cache, as.cacheAt = txns, now
|
|
m.mu.Unlock()
|
|
for _, t := range txns {
|
|
if err := m.applyTxn(as, t); err != nil {
|
|
log.Printf("[error] 核销流水 %s 失败: %v", t.OrderID, err)
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func payerOf(t payTxn) string {
|
|
if t.PayerInfo.BinanceID > 0 {
|
|
return strconv64(t.PayerInfo.BinanceID)
|
|
}
|
|
return strconv64(t.CounterpartyID)
|
|
}
|
|
|
|
// applyTxn 尝试用一笔流水核销该账号的订单(规则 ①②)。
|
|
func (m *Matcher) applyTxn(as *accountState, t payTxn) error {
|
|
amt, err := parseAmount(t.Amount)
|
|
if err != nil || amt <= 0 {
|
|
return nil // 非入账
|
|
}
|
|
cur := strings.ToUpper(t.Currency)
|
|
if !m.cfg.Currencies[cur] {
|
|
return nil
|
|
}
|
|
if t.OrderID == "" {
|
|
return nil
|
|
}
|
|
if done, err := m.st.Consumed(t.OrderID); err != nil || done {
|
|
return err
|
|
}
|
|
now := nowMs()
|
|
cands, err := m.st.CandidateOrders(as.acc.ID, now, m.cfg.ExpiredGrace*1000)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payerID := payerOf(t)
|
|
// ① 唯一金额精确匹配
|
|
for _, o := range cands {
|
|
if o.Currency == cur && o.PayAmount == amt && t.Time >= o.CreatedAt-120*1000 {
|
|
ok, err := m.st.Finish(o, "paid", "amount", t.OrderID, t.TransactionID, payerID, t.PayerInfo.Name, amt, m.cfg.SuffixCooldown)
|
|
if ok {
|
|
log.Printf("[info] 订单 %s 已支付(金额匹配)account=%s binanceOrderId=%s amount=%s %s", o.ID, as.acc.ID, t.OrderID, fmtAmount(amt), cur)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
// ② 备注含 note_code
|
|
note := strings.ToUpper(t.Note)
|
|
if note == "" {
|
|
return nil
|
|
}
|
|
for _, o := range cands {
|
|
if o.Currency != cur || !strings.Contains(note, o.NoteCode) {
|
|
continue
|
|
}
|
|
status := "paid"
|
|
if amt < o.BaseAmount {
|
|
status = "underpaid"
|
|
}
|
|
ok, err := m.st.Finish(o, status, "note", t.OrderID, t.TransactionID, payerID, t.PayerInfo.Name, amt, m.cfg.SuffixCooldown)
|
|
if ok {
|
|
log.Printf("[info] 订单 %s → %s(备注匹配)account=%s binanceOrderId=%s amount=%s %s", o.ID, status, as.acc.ID, t.OrderID, fmtAmount(amt), cur)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type claimResult struct {
|
|
Code string // OK / NOT_FOUND / CONSUMED / CURRENCY / UNDERPAID / STATE
|
|
Status string // 处理后订单状态
|
|
}
|
|
|
|
// Claim 处理收银页回填的币安订单编号(规则 ③),只在订单所属账号的流水里找。
|
|
func (m *Matcher) Claim(o *Order, binanceOrderID string) (claimResult, error) {
|
|
if o.Status == "paid" {
|
|
return claimResult{Code: "OK", Status: "paid"}, nil
|
|
}
|
|
if o.Status != "pending" && o.Status != "expired" {
|
|
return claimResult{Code: "STATE", Status: o.Status}, nil
|
|
}
|
|
if done, err := m.st.Consumed(binanceOrderID); err != nil {
|
|
return claimResult{}, err
|
|
} else if done {
|
|
return claimResult{Code: "CONSUMED", Status: o.Status}, nil
|
|
}
|
|
as := m.stateFor(o.AccountID)
|
|
if as == nil {
|
|
return claimResult{}, errors.New("订单所属收款账号不可用")
|
|
}
|
|
// 先查缓存;未命中则强制现拉一次(付款可能刚到账,缓存虽新但没有它)
|
|
find := func(txns []payTxn) *payTxn {
|
|
for i := range txns {
|
|
if txns[i].OrderID == binanceOrderID {
|
|
return &txns[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
m.mu.Lock()
|
|
hit := find(as.cache)
|
|
m.mu.Unlock()
|
|
if hit == nil {
|
|
fresh, err := m.fetchWindow(as, o.CreatedAt-10*60*1000)
|
|
if err != nil {
|
|
return claimResult{}, err
|
|
}
|
|
m.mu.Lock()
|
|
as.cache, as.cacheAt = fresh, nowMs()
|
|
m.mu.Unlock()
|
|
hit = find(fresh)
|
|
}
|
|
if hit == nil {
|
|
return claimResult{Code: "NOT_FOUND", Status: o.Status}, nil
|
|
}
|
|
t := *hit
|
|
amt, err := parseAmount(t.Amount)
|
|
if err != nil || amt <= 0 {
|
|
return claimResult{Code: "NOT_FOUND", Status: o.Status}, nil
|
|
}
|
|
if strings.ToUpper(t.Currency) != o.Currency {
|
|
return claimResult{Code: "CURRENCY", Status: o.Status}, nil
|
|
}
|
|
status, code := "paid", "OK"
|
|
if amt < o.BaseAmount {
|
|
status, code = "underpaid", "UNDERPAID"
|
|
}
|
|
ok, err := m.st.Finish(o, status, "claim", t.OrderID, t.TransactionID, payerOf(t), t.PayerInfo.Name, amt, m.cfg.SuffixCooldown)
|
|
if err != nil {
|
|
return claimResult{}, err
|
|
}
|
|
if !ok {
|
|
return claimResult{Code: "CONSUMED", Status: o.Status}, nil
|
|
}
|
|
log.Printf("[info] 订单 %s → %s(回填匹配)account=%s binanceOrderId=%s", o.ID, status, o.AccountID, t.OrderID)
|
|
return claimResult{Code: code, Status: status}, nil
|
|
}
|
|
|
|
func strconv64(v int64) string {
|
|
if v == 0 {
|
|
return ""
|
|
}
|
|
return strconv.FormatInt(v, 10)
|
|
}
|