- 新增 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>
494 lines
15 KiB
Go
494 lines
15 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
|
||
qrcode "github.com/skip2/go-qrcode"
|
||
)
|
||
|
||
type App struct {
|
||
cfg *Config
|
||
st *Store
|
||
matcher *Matcher
|
||
notifier *Notifier
|
||
mux *http.ServeMux
|
||
|
||
nonceMu sync.Mutex
|
||
nonces map[string]int64 // nonce → 过期时间 ms
|
||
|
||
rlMu sync.Mutex
|
||
rl map[string][]int64 // 限流:key → 时间戳窗口
|
||
|
||
qrPNG []byte // 由 RECEIVE_LINK 生成的收款二维码(启动时生成一次)
|
||
|
||
qrMu sync.Mutex
|
||
qrCache map[string][]byte // 动态账号收款链接 → 二维码
|
||
}
|
||
|
||
func newApp(cfg *Config) (*App, error) {
|
||
st, err := openStore(cfg.DBPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
st.key = deriveKey(cfg.APIAuthKey)
|
||
a := &App{
|
||
cfg: cfg, st: st,
|
||
matcher: newMatcher(cfg, st),
|
||
notifier: newNotifier(cfg, st),
|
||
nonces: map[string]int64{},
|
||
rl: map[string][]int64{},
|
||
qrCache: map[string][]byte{},
|
||
}
|
||
if cfg.ReceiveLink != "" && cfg.QRImage == "" {
|
||
png, err := qrcode.Encode(cfg.ReceiveLink, qrcode.Medium, 360)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("生成收款二维码失败: %w", err)
|
||
}
|
||
a.qrPNG = png
|
||
}
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("POST /api/v1/orders", a.auth(a.handleCreate))
|
||
mux.HandleFunc("GET /api/v1/orders/{id}", a.auth(a.handleGet))
|
||
mux.HandleFunc("GET /api/v1/orders/by-merchant/{mid}", a.auth(a.handleGetByMerchant))
|
||
mux.HandleFunc("POST /api/v1/orders/{id}/close", a.auth(a.handleClose))
|
||
mux.HandleFunc("POST /api/v1/accounts", a.auth(a.handleCreateAccount))
|
||
mux.HandleFunc("GET /api/v1/accounts/{id}", a.auth(a.handleGetAccount))
|
||
mux.HandleFunc("POST /api/v1/accounts/{id}/verify", a.auth(a.handleVerifyAccount))
|
||
mux.HandleFunc("POST /api/v1/accounts/{id}/disable", a.auth(a.handleAccountStatus("disabled")))
|
||
mux.HandleFunc("POST /api/v1/accounts/{id}/enable", a.auth(a.handleAccountStatus("active")))
|
||
mux.HandleFunc("GET /pay/{token}", a.handleCashier)
|
||
mux.HandleFunc("GET /pay/{token}/status", a.handleStatus)
|
||
mux.HandleFunc("POST /pay/{token}/claim", a.handleClaim)
|
||
mux.HandleFunc("GET /pay/{token}/qr", a.handleQR)
|
||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, 200, map[string]any{"ok": true, "version": version})
|
||
})
|
||
if cfg.DemoEnabled {
|
||
mux.HandleFunc("GET /demo", a.handleDemo)
|
||
mux.HandleFunc("POST /demo/order", a.handleDemoOrder)
|
||
mux.HandleFunc("GET /demo/done", a.handleDemoDone)
|
||
}
|
||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||
if cfg.DemoEnabled {
|
||
http.Redirect(w, r, "/demo", http.StatusFound)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
w.Write([]byte("BinancePayTool " + version + " running\n"))
|
||
})
|
||
a.mux = mux
|
||
return a, nil
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
func ok(w http.ResponseWriter, data any) {
|
||
writeJSON(w, 200, map[string]any{"code": "OK", "data": data})
|
||
}
|
||
|
||
func fail(w http.ResponseWriter, status int, code, msg string) {
|
||
writeJSON(w, status, map[string]any{"code": code, "message": msg})
|
||
}
|
||
|
||
// auth 校验商户请求签名(docs/protocol.md §1.1)。
|
||
func (a *App) auth(next func(http.ResponseWriter, *http.Request, []byte)) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||
if err != nil {
|
||
fail(w, 400, "ERR_PARAM", "读取请求体失败")
|
||
return
|
||
}
|
||
ts := r.Header.Get("X-BPG-Timestamp")
|
||
nonce := r.Header.Get("X-BPG-Nonce")
|
||
sig := r.Header.Get("X-BPG-Signature")
|
||
if ts == "" || len(nonce) < 16 || sig == "" {
|
||
fail(w, 401, "ERR_AUTH", "缺少签名头")
|
||
return
|
||
}
|
||
tsv, err := strconv.ParseInt(ts, 10, 64)
|
||
if err != nil || tsv < nowMs()-300000 || tsv > nowMs()+300000 {
|
||
fail(w, 401, "ERR_AUTH", "时间戳无效或偏差过大")
|
||
return
|
||
}
|
||
expect := signRequest(a.cfg.APIAuthKey, ts, nonce, r.Method, r.URL.Path, body)
|
||
if !sigEqual(expect, sig) {
|
||
fail(w, 401, "ERR_AUTH", "签名错误")
|
||
return
|
||
}
|
||
a.nonceMu.Lock()
|
||
now := nowMs()
|
||
if len(a.nonces) > 10000 {
|
||
for k, exp := range a.nonces {
|
||
if exp < now {
|
||
delete(a.nonces, k)
|
||
}
|
||
}
|
||
}
|
||
if exp, seen := a.nonces[nonce]; seen && exp > now {
|
||
a.nonceMu.Unlock()
|
||
fail(w, 401, "ERR_AUTH", "nonce 重复")
|
||
return
|
||
}
|
||
a.nonces[nonce] = now + 300000
|
||
a.nonceMu.Unlock()
|
||
next(w, r, body)
|
||
}
|
||
}
|
||
|
||
// allow 简单滑动窗口限流。
|
||
func (a *App) allow(key string, max int, windowSec int64) bool {
|
||
a.rlMu.Lock()
|
||
defer a.rlMu.Unlock()
|
||
now := nowMs()
|
||
lo := now - windowSec*1000
|
||
win := a.rl[key]
|
||
var kept []int64
|
||
for _, t := range win {
|
||
if t >= lo {
|
||
kept = append(kept, t)
|
||
}
|
||
}
|
||
if len(kept) >= max {
|
||
a.rl[key] = kept
|
||
return false
|
||
}
|
||
a.rl[key] = append(kept, now)
|
||
return true
|
||
}
|
||
|
||
// accountFor 取订单所属收款账号:default 来自 config.env,其余来自账号表。
|
||
func (a *App) accountFor(id string) (*Account, error) {
|
||
if id == "" || id == defaultAccountID {
|
||
return defaultAccount(a.cfg), nil
|
||
}
|
||
return a.st.GetAccount(id)
|
||
}
|
||
|
||
func (a *App) orderJSON(o *Order) map[string]any {
|
||
actual := ""
|
||
if o.ActualAmount > 0 {
|
||
actual = fmtAmount(o.ActualAmount)
|
||
}
|
||
uid, link, email := a.cfg.BinanceUID, a.cfg.ReceiveLink, a.cfg.ReceiveEmail
|
||
if acc, err := a.accountFor(o.AccountID); err == nil {
|
||
uid, link, email = acc.UID, acc.ReceiveLink, acc.ReceiveEmail
|
||
}
|
||
return map[string]any{
|
||
"order_id": o.ID,
|
||
"account_id": o.AccountID,
|
||
"receive_link": link,
|
||
"receive_email": email,
|
||
"merchant_order_id": o.MerchantOrderID,
|
||
"status": o.Status,
|
||
"currency": o.Currency,
|
||
"base_amount": fmtAmount(o.BaseAmount),
|
||
"pay_amount": fmtAmount(o.PayAmount),
|
||
"actual_amount": actual,
|
||
"note_code": o.NoteCode,
|
||
"pay_url": a.cfg.BaseURL + "/pay/" + o.Token,
|
||
"receive_uid": uid,
|
||
"matched_by": o.MatchedBy,
|
||
"binance_order_id": o.BinanceOrderID,
|
||
"binance_txn_id": o.BinanceTxnID,
|
||
"payer_id": o.PayerID,
|
||
"overpaid": o.Overpaid,
|
||
"created_at": o.CreatedAt,
|
||
"expires_at": o.ExpiresAt,
|
||
"paid_at": o.PaidAt,
|
||
}
|
||
}
|
||
|
||
type createReq struct {
|
||
AccountID string `json:"account_id"`
|
||
MerchantOrderID string `json:"merchant_order_id"`
|
||
Currency string `json:"currency"`
|
||
Amount string `json:"amount"`
|
||
CallbackURL string `json:"callback_url"`
|
||
ReturnURL string `json:"return_url"`
|
||
Timeout int64 `json:"timeout"`
|
||
}
|
||
|
||
func (a *App) handleCreate(w http.ResponseWriter, r *http.Request, body []byte) {
|
||
var req createReq
|
||
if err := json.Unmarshal(body, &req); err != nil {
|
||
fail(w, 400, "ERR_PARAM", "JSON 解析失败")
|
||
return
|
||
}
|
||
req.MerchantOrderID = strings.TrimSpace(req.MerchantOrderID)
|
||
if req.MerchantOrderID == "" || len(req.MerchantOrderID) > 64 {
|
||
fail(w, 400, "ERR_PARAM", "merchant_order_id 必填且 ≤64 字符")
|
||
return
|
||
}
|
||
cur := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||
if !a.cfg.Currencies[cur] {
|
||
fail(w, 400, "ERR_PARAM", "currency 不在允许列表内")
|
||
return
|
||
}
|
||
base, err := parseAmount(req.Amount)
|
||
if err != nil || base <= 0 {
|
||
fail(w, 400, "ERR_PARAM", "amount 非法")
|
||
return
|
||
}
|
||
if decimalsOf(base) > a.cfg.AmountDecimals {
|
||
fail(w, 400, "ERR_PARAM", "amount 小数位超过网关 AMOUNT_DECIMALS")
|
||
return
|
||
}
|
||
ttl := req.Timeout
|
||
if ttl == 0 {
|
||
ttl = a.cfg.OrderTTL
|
||
}
|
||
if ttl < 120 || ttl > 86400 {
|
||
fail(w, 400, "ERR_PARAM", "timeout 须在 120~86400 秒之间")
|
||
return
|
||
}
|
||
accID := strings.TrimSpace(req.AccountID)
|
||
if accID == "" {
|
||
accID = defaultAccountID
|
||
}
|
||
if accID != defaultAccountID {
|
||
acc, err := a.st.GetAccount(accID)
|
||
if errors.Is(err, ErrNotFound) || (err == nil && acc.Status != "active") {
|
||
fail(w, 400, "ERR_PARAM", "account_id 不存在或已停用")
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("[error] 读取账号 %s 失败: %v", accID, err)
|
||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||
return
|
||
}
|
||
}
|
||
o, err := a.st.CreateOrder(a.cfg, accID, req.MerchantOrderID, cur, base, ttl, req.CallbackURL, req.ReturnURL)
|
||
if errors.Is(err, ErrDuplicate) {
|
||
fail(w, 409, "ERR_DUPLICATE", "merchant_order_id 已存在")
|
||
return
|
||
}
|
||
if errors.Is(err, ErrAlloc) {
|
||
fail(w, 503, "ERR_ALLOC", "该金额档位唯一尾数已耗尽,请稍后或换金额")
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("[error] 创建订单失败: %v", err)
|
||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||
return
|
||
}
|
||
log.Printf("[info] 新订单 %s account=%s %s %s 应付 %s", o.ID, o.AccountID, o.Currency, fmtAmount(o.BaseAmount), fmtAmount(o.PayAmount))
|
||
ok(w, a.orderJSON(o))
|
||
}
|
||
|
||
func (a *App) handleGet(w http.ResponseWriter, r *http.Request, _ []byte) {
|
||
o, err := a.st.OrderByID(r.PathValue("id"))
|
||
if errors.Is(err, ErrNotFound) {
|
||
fail(w, 404, "ERR_NOT_FOUND", "订单不存在")
|
||
return
|
||
}
|
||
if err != nil {
|
||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||
return
|
||
}
|
||
ok(w, a.orderJSON(o))
|
||
}
|
||
|
||
func (a *App) handleGetByMerchant(w http.ResponseWriter, r *http.Request, _ []byte) {
|
||
o, err := a.st.OrderByMerchantID(r.PathValue("mid"))
|
||
if errors.Is(err, ErrNotFound) {
|
||
fail(w, 404, "ERR_NOT_FOUND", "订单不存在")
|
||
return
|
||
}
|
||
if err != nil {
|
||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||
return
|
||
}
|
||
ok(w, a.orderJSON(o))
|
||
}
|
||
|
||
func (a *App) handleClose(w http.ResponseWriter, r *http.Request, _ []byte) {
|
||
o, err := a.st.CloseOrder(r.PathValue("id"), a.cfg.SuffixCooldown)
|
||
if errors.Is(err, ErrNotFound) {
|
||
fail(w, 404, "ERR_NOT_FOUND", "订单不存在")
|
||
return
|
||
}
|
||
if errors.Is(err, ErrState) {
|
||
fail(w, 409, "ERR_STATE", "仅待支付订单可关闭")
|
||
return
|
||
}
|
||
if err != nil {
|
||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||
return
|
||
}
|
||
ok(w, a.orderJSON(o))
|
||
}
|
||
|
||
// clientIP 取访客 IP。直连时用 RemoteAddr;TRUST_PROXY 开启时依次信任
|
||
// CF-Connecting-IP(Cloudflare)、X-Real-IP、X-Forwarded-For 首个地址。
|
||
func (a *App) clientIP(r *http.Request) string {
|
||
if a.cfg.TrustProxy {
|
||
for _, h := range []string{"CF-Connecting-IP", "X-Real-IP"} {
|
||
if v := strings.TrimSpace(r.Header.Get(h)); v != "" {
|
||
return v
|
||
}
|
||
}
|
||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||
if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
|
||
return first
|
||
}
|
||
}
|
||
}
|
||
if i := strings.LastIndex(r.RemoteAddr, ":"); i > 0 {
|
||
return r.RemoteAddr[:i]
|
||
}
|
||
return r.RemoteAddr
|
||
}
|
||
|
||
// ---------- 收款账号(多账号模式)----------
|
||
|
||
type accountReq struct {
|
||
Label string `json:"label"`
|
||
APIKey string `json:"api_key"`
|
||
APISecret string `json:"api_secret"`
|
||
UID string `json:"uid"`
|
||
ReceiveLink string `json:"receive_link"`
|
||
ReceiveEmail string `json:"receive_email"`
|
||
}
|
||
|
||
var reUID = regexp.MustCompile(`^[0-9]{1,20}$`)
|
||
|
||
func (a *App) accountJSON(acc *Account) map[string]any {
|
||
masked := acc.APIKey
|
||
if len(masked) > 8 {
|
||
masked = masked[:4] + "…" + masked[len(masked)-4:]
|
||
}
|
||
return map[string]any{
|
||
"account_id": acc.ID,
|
||
"label": acc.Label,
|
||
"api_key_masked": masked,
|
||
"uid": acc.UID,
|
||
"receive_link": acc.ReceiveLink,
|
||
"receive_email": acc.ReceiveEmail,
|
||
"status": acc.Status,
|
||
"last_ok": acc.LastOK,
|
||
"last_err": acc.LastErr,
|
||
"created_at": acc.CreatedAt,
|
||
}
|
||
}
|
||
|
||
// verifyBinanceKey 用只读接口试拉一条流水:Key/Secret/权限/IP 白名单/地区限制任一不对都会报错。
|
||
func (a *App) verifyBinanceKey(key, secret string) error {
|
||
_, err := newBinanceClientFor(a.cfg.BinanceAPIBase, key, secret).PayTransactions(0, 0, 1)
|
||
return err
|
||
}
|
||
|
||
func (a *App) handleCreateAccount(w http.ResponseWriter, r *http.Request, body []byte) {
|
||
var req accountReq
|
||
if err := json.Unmarshal(body, &req); err != nil {
|
||
fail(w, 400, "ERR_PARAM", "JSON 解析失败")
|
||
return
|
||
}
|
||
req.APIKey, req.APISecret = strings.TrimSpace(req.APIKey), strings.TrimSpace(req.APISecret)
|
||
req.UID, req.Label = strings.TrimSpace(req.UID), strings.TrimSpace(req.Label)
|
||
req.ReceiveLink, req.ReceiveEmail = strings.TrimSpace(req.ReceiveLink), strings.TrimSpace(req.ReceiveEmail)
|
||
switch {
|
||
case req.APIKey == "" || req.APISecret == "" || len(req.APIKey) > 128 || len(req.APISecret) > 128:
|
||
fail(w, 400, "ERR_PARAM", "api_key / api_secret 必填")
|
||
return
|
||
case !reUID.MatchString(req.UID):
|
||
fail(w, 400, "ERR_PARAM", "uid 须为数字")
|
||
return
|
||
case req.ReceiveLink != "" && !strings.HasPrefix(req.ReceiveLink, "https://"):
|
||
fail(w, 400, "ERR_PARAM", "receive_link 须为 https:// 链接")
|
||
return
|
||
case len(req.Label) > 64 || len(req.ReceiveLink) > 300 || len(req.ReceiveEmail) > 100:
|
||
fail(w, 400, "ERR_PARAM", "字段过长")
|
||
return
|
||
}
|
||
if err := a.verifyBinanceKey(req.APIKey, req.APISecret); err != nil {
|
||
fail(w, 400, "ERR_BINANCE", "币安校验失败: "+truncate(err.Error(), 200))
|
||
return
|
||
}
|
||
acc := &Account{Label: req.Label, APIKey: req.APIKey, APISecret: req.APISecret, UID: req.UID, ReceiveLink: req.ReceiveLink, ReceiveEmail: req.ReceiveEmail}
|
||
if err := a.st.UpsertAccount(acc); err != nil {
|
||
log.Printf("[error] 保存账号失败: %v", err)
|
||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||
return
|
||
}
|
||
a.st.AccountPolled(acc.ID, true, "")
|
||
acc.LastOK = nowMs()
|
||
log.Printf("[info] 收款账号 %s 已添加/更新 uid=%s label=%s", acc.ID, acc.UID, acc.Label)
|
||
ok(w, a.accountJSON(acc))
|
||
}
|
||
|
||
func (a *App) handleGetAccount(w http.ResponseWriter, r *http.Request, _ []byte) {
|
||
acc, err := a.accountFor(r.PathValue("id"))
|
||
if errors.Is(err, ErrNotFound) {
|
||
fail(w, 404, "ERR_NOT_FOUND", "账号不存在")
|
||
return
|
||
}
|
||
if err != nil {
|
||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||
return
|
||
}
|
||
ok(w, a.accountJSON(acc))
|
||
}
|
||
|
||
func (a *App) handleVerifyAccount(w http.ResponseWriter, r *http.Request, _ []byte) {
|
||
id := r.PathValue("id")
|
||
acc, err := a.accountFor(id)
|
||
if errors.Is(err, ErrNotFound) {
|
||
fail(w, 404, "ERR_NOT_FOUND", "账号不存在")
|
||
return
|
||
}
|
||
if err != nil {
|
||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||
return
|
||
}
|
||
if err := a.verifyBinanceKey(acc.APIKey, acc.APISecret); err != nil {
|
||
msg := truncate(err.Error(), 200)
|
||
if id != defaultAccountID {
|
||
a.st.AccountPolled(id, false, msg)
|
||
}
|
||
fail(w, 400, "ERR_BINANCE", "币安校验失败: "+msg)
|
||
return
|
||
}
|
||
if id != defaultAccountID {
|
||
a.st.AccountPolled(id, true, "")
|
||
acc.LastOK, acc.LastErr = nowMs(), ""
|
||
}
|
||
ok(w, a.accountJSON(acc))
|
||
}
|
||
|
||
func (a *App) handleAccountStatus(status string) func(http.ResponseWriter, *http.Request, []byte) {
|
||
return func(w http.ResponseWriter, r *http.Request, _ []byte) {
|
||
id := r.PathValue("id")
|
||
if id == defaultAccountID {
|
||
fail(w, 400, "ERR_PARAM", "默认账号由 config.env 管理")
|
||
return
|
||
}
|
||
if err := a.st.SetAccountStatus(id, status); errors.Is(err, ErrNotFound) {
|
||
fail(w, 404, "ERR_NOT_FOUND", "账号不存在")
|
||
return
|
||
} else if err != nil {
|
||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||
return
|
||
}
|
||
acc, err := a.st.GetAccount(id)
|
||
if err != nil {
|
||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||
return
|
||
}
|
||
log.Printf("[info] 收款账号 %s → %s", id, status)
|
||
ok(w, a.accountJSON(acc))
|
||
}
|
||
}
|