PayGateway: epay-compatible Go payment gateway with plugin channels and verifiable ledger
This commit is contained in:
commit
f2ae88b4e9
201 files changed
+44898
No files matched your search
Executable
+560
@@ -0,0 +1,560 @@
|
||||
# PayGateway 架构设计(最终版)
|
||||
|
||||
> 目标:高性能、高稳定、资金零差错、**支付方式可插拔**的支付网关
|
||||
> 通道底座:**gopay v1.5.119**(9 种通道现成:支付宝/微信/抖音/QQ/通联/拉卡拉/PayPal/Apple/扫呗)
|
||||
> 未来扩展:链上支付(crypto)→ **预留扩展位**,不现在实现
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心思想:三层各司其职,不重复造轮子
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ ① 协议层(对外接口,永不改变) │
|
||||
│ epay: /submit.php /api.php ← New API 等易支付系 │
|
||||
│ REST: /v1/orders ← 未来其他项目(预留) │
|
||||
│ type=alipay|wxpay|paypal|usdt → 路由到对应通道 │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ ② 编排层(资金安全核心,与具体通道无关) │
|
||||
│ 订单状态机 / 金额校验 / 幂等 / 可靠回调(outbox) / 对账 │
|
||||
│ → 只认统一接口 PaymentChannel,不认任何 SDK 细节 │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ ③ 通道层(可插拔,每个通道一个包) │
|
||||
│ ┌─────────────── gopay 库(现成 9 通道)──────────────┐ │
|
||||
│ │ alipay / wechat / paypal / douyin / qq / allinpay │ │
|
||||
│ │ lakala / saobei / apple │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ crypto/(★ 预留目录:未来链上支付,接口已留好) │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**职责划分:**
|
||||
- **gopay** = 通道适配器工厂(把 9 种支付 SDK 的差异封装掉)
|
||||
- **paygateway** = 协议翻译 + 资金安全编排(不重复实现支付 SDK,只做编排)
|
||||
- **crypto** = 未来加一个目录,接口现成
|
||||
|
||||
---
|
||||
|
||||
## 2. 通道抽象(接口 + 注册表)
|
||||
|
||||
### 2.1 统一接口
|
||||
|
||||
```go
|
||||
package channel
|
||||
|
||||
// PaymentChannel 所有支付通道必须实现的统一接口
|
||||
type PaymentChannel interface {
|
||||
// Name 通道标识:alipay / wechat / paypal / usdt ...
|
||||
Name() string
|
||||
|
||||
// Kind 通道类型:回调驱动 or 监听驱动(链上)
|
||||
Kind() ChannelKind // CallbackKind / ListenerKind
|
||||
|
||||
// Modes 支持的支付模式
|
||||
Modes() []PayMode // Page / Wap / QRCode / Address
|
||||
|
||||
// CreatePayment 下单:返回支付凭据(跳转链接/二维码/链上地址)
|
||||
CreatePayment(ctx context.Context, req *PayRequest) (*PayCredential, error)
|
||||
|
||||
// VerifyNotify 验签回调(CallbackKind 用)
|
||||
VerifyNotify(ctx context.Context, raw []byte) (*NotifyResult, error)
|
||||
|
||||
// QueryOrder 主动查单(对账 + 补偿兜底)
|
||||
QueryOrder(ctx context.Context, outTradeNo string) (*OrderStatus, error)
|
||||
|
||||
// Listener 事件监听(ListenerKind 必须实现;CallbackKind 返回 nil)
|
||||
Listener() EventListener
|
||||
}
|
||||
|
||||
type PayRequest struct {
|
||||
OutTradeNo string // 网关订单号
|
||||
Subject string // 商品名
|
||||
AmountMinor int64 // 金额(最小单位,法币=分,链上=wei/satoshi)
|
||||
Currency string // CNY / USD / USDT
|
||||
NotifyURL string
|
||||
ReturnURL string
|
||||
PayMode PayMode // 用户选择的支付模式
|
||||
}
|
||||
|
||||
type PayCredential struct {
|
||||
PayURL string // 跳转链接
|
||||
QRCode string // 二维码内容
|
||||
Address string // 链上收款地址(未来)
|
||||
ExpireAt time.Time
|
||||
}
|
||||
|
||||
type NotifyResult struct {
|
||||
Valid bool // 验签是否通过
|
||||
ChannelTxID string // 通道交易号(去重依据)
|
||||
Status string // SUCCESS / CLOSED / FAILED
|
||||
AmountMinor int64 // 实付金额(必须与订单比对!)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 注册表(配置驱动,零代码路由)
|
||||
|
||||
```go
|
||||
// 注册:每个通道 init() 或显式调用
|
||||
func Register(c PaymentChannel) { registry[c.Name()] = c }
|
||||
|
||||
// 路由:协议层只写一行
|
||||
ch, ok := channel.Get(c.Query("type")) // "alipay" → 支付宝通道
|
||||
if !ok { c.String(400, "不支持的支付方式") }
|
||||
```
|
||||
|
||||
**新增一种支付方式 = 3 步:**
|
||||
1. `internal/channel/wechat/wechat.go` — 实现 4 个方法(内部调 gopay 的微信 SDK)
|
||||
2. `Register(...)` 一行注册
|
||||
3. 配置 `.env` 加微信的密钥
|
||||
|
||||
→ 协议层、订单核心、存储、页面 **零改动**。
|
||||
|
||||
### 2.3 crypto 预留位(未来链上)
|
||||
|
||||
```go
|
||||
// internal/channel/crypto/ 目前只放:
|
||||
// README.md — 设计说明(HD钱包/确认数/重组处理)
|
||||
// stub.go — 返回 "未启用" 的占位实现(注册表可查但拒绝下单)
|
||||
|
||||
// 未来实现时:
|
||||
// Kind() = ListenerKind
|
||||
// Listener() = 区块监听器(WebSocket + 轮询兜底)
|
||||
// CreatePayment() = HD 钱包派生一次性地址
|
||||
// QueryOrder() = 查链上确认数
|
||||
```
|
||||
|
||||
注册表已经支持 `ListenerKind` 通道,**未来 crypto 落地 = 填实现,不动架构**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 资金安全编排层(最不能错的部分)
|
||||
|
||||
```
|
||||
下单: 协议层 → order.Create() → ① 事务写订单(CREATED) → ② 调通道
|
||||
→ ③ 事务更新支付凭据 → ④ 返回页面
|
||||
|
||||
支付: 通道事件(回调/监听)
|
||||
→ ① 验签 → ② 金额比对(实付==订单,不等→拒绝+告警)
|
||||
→ ③ 幂等检查(channel_tx_id 唯一,重复→直接应答成功)
|
||||
→ ④ 条件更新状态(WHERE status='WAIT_PAY',失败→拒绝)
|
||||
→ ⑤ 写 payment_events 流水 → ⑥ 写 outbox 回调任务
|
||||
|
||||
结算: outbox worker → 回调接入方(失败重试 1m/5m/15m/1h → dead 告警)
|
||||
定时补偿:PAID 且未通知 > 30s → 补发
|
||||
定时对账:通道实付 vs 订单金额
|
||||
```
|
||||
|
||||
**5 条铁律**(写入代码评审清单):
|
||||
1. 金额只用最小单位 int64,永不用 float
|
||||
2. 状态变更必须 `WHERE status=当前态`(防回跳/防覆盖)
|
||||
3. 实付金额 ≠ 订单金额 → 拒绝 + 告警
|
||||
4. 同一 channel_tx_id 重复事件 → 幂等返回成功
|
||||
5. 每笔资金事件落 payment_events,可追溯
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据库设计(资金安全地基,最关键的组件)
|
||||
|
||||
### 4.1 选型决策
|
||||
|
||||
| 阶段 | 数据库 | 理由 |
|
||||
|------|--------|------|
|
||||
| **P1 开发/单机生产** | **SQLite**(`modernc.org/sqlite` 纯 Go,无 CGO) | 本机已有;单文件零运维;事务/行锁/唯一约束完整;交易量几百 TPS 足够 |
|
||||
| **正式生产/多机** | PostgreSQL | 用户已声明基础设施有;行级锁 + 强一致 + 可扩展 |
|
||||
|
||||
> **关键**:`Store` 接口抽象,SQLite 与 PG 实现可无缝切换。**P1 直接上 SQLite,不做 JSON 过渡** —— 资金数据从一开始就落在真数据库里。
|
||||
|
||||
### 4.2 数据模型(SQLite 版,PG 同构)
|
||||
|
||||
```sql
|
||||
-- 订单表:唯一约束防重复下单,条件更新防覆盖
|
||||
CREATE TABLE orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
out_trade_no TEXT NOT NULL UNIQUE, -- 接入方订单号(幂等依据)
|
||||
gateway_no TEXT NOT NULL UNIQUE, -- 网关订单号
|
||||
channel TEXT NOT NULL, -- alipay/wechat/paypal/usdt
|
||||
channel_tx_id TEXT, -- 通道交易号(回调去重)
|
||||
amount_minor INTEGER NOT NULL, -- 金额(最小单位:分)
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
status TEXT NOT NULL, -- CREATED/WAIT_PAY/PAID/...
|
||||
payer TEXT, -- 付款方标识
|
||||
pay_url TEXT, -- 支付链接/二维码/链上地址
|
||||
return_url TEXT,
|
||||
notify_url TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
paid_at TEXT,
|
||||
notified_at TEXT,
|
||||
UNIQUE (channel, channel_tx_id) -- 防回调重放
|
||||
);
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
CREATE INDEX idx_orders_created ON orders(created_at);
|
||||
CREATE INDEX idx_orders_channel ON orders(channel);
|
||||
|
||||
-- 交易流水:资金审计,只增不改
|
||||
CREATE TABLE payment_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL, -- created/paid/closed/failed/refunded
|
||||
channel_tx_id TEXT,
|
||||
amount_minor INTEGER NOT NULL,
|
||||
raw_payload TEXT, -- 原始回调/区块数据 JSON
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_events_order ON payment_events(order_id);
|
||||
|
||||
-- 可靠回调投递(Outbox)
|
||||
CREATE TABLE notify_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending/success/dead
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_retry_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_notify_status ON notify_tasks(status, next_retry_at);
|
||||
|
||||
-- 回调投递日志
|
||||
CREATE TABLE callback_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
http_status INTEGER,
|
||||
response TEXT,
|
||||
latency_ms INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 应用日志(管理界面查询用,与文件日志双写)
|
||||
CREATE TABLE app_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
level TEXT NOT NULL, -- debug/info/warn/error
|
||||
scope TEXT, -- 模块:order/channel/notify/scheduler
|
||||
message TEXT NOT NULL,
|
||||
trace_id TEXT, -- 请求追踪 ID
|
||||
fields TEXT, -- 结构化字段 JSON
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_logs_level_time ON app_logs(level, created_at);
|
||||
CREATE INDEX idx_logs_scope_time ON app_logs(scope, created_at);
|
||||
CREATE INDEX idx_logs_trace ON app_logs(trace_id);
|
||||
```
|
||||
|
||||
### 4.3 资金安全约束(写库必须遵守)
|
||||
|
||||
```go
|
||||
// 状态变更必须条件更新(防回跳/防覆盖)
|
||||
UPDATE orders SET status='PAID', paid_at=?, channel_tx_id=?
|
||||
WHERE out_trade_no=? AND status='WAIT_PAY'
|
||||
// 影响行数 = 0 → 状态非法,拒绝(幂等/防覆盖双保险)
|
||||
|
||||
// 金额校验
|
||||
实付金额(amount_minor) == 订单金额 → 才允许 PAID
|
||||
不等 → 记录 payment_events(event_type='amount_mismatch') + 告警 + 拒绝
|
||||
|
||||
// 回调去重
|
||||
INSERT INTO orders(channel, channel_tx_id) ... ON CONFLICT DO NOTHING
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 交易不可篡改设计(资金审计账本)
|
||||
|
||||
> 目标:**每一笔交易都不可篡改** —— 任何人(包括管理员/DBA)改任何一条记录都能被检测。
|
||||
> 方案:**哈希链 + 数字签名 + 只增不改 + 每日快照锚定**(企业级审计账本标准做法)。
|
||||
|
||||
### 5.1 哈希链(账本防篡改核心)
|
||||
|
||||
```
|
||||
genesis(固定) → entry1 → entry2 → entry3 → ... → entryN
|
||||
↑ ↑ ↑
|
||||
prev_hash prev_hash prev_hash ← 每条都链接前一条
|
||||
|
||||
entry_hash(i) = SHA256( prev_hash(i-1) + payload_hash(i) )
|
||||
payload_hash(i) = SHA256( 事件全部内容: 订单号+金额+状态+通道交易号+时间+原始数据 )
|
||||
```
|
||||
|
||||
**篡改任意一条 → 它后面的所有 entry_hash 全部失配 → 校验必失败。**
|
||||
**数据库整体被替换 → 与每日快照锚点比对 → 发现。**
|
||||
|
||||
### 5.2 数字签名(防伪造 + 来源验证)
|
||||
|
||||
- 独立**审计密钥对**(与支付签名密钥物理分离),私钥存 env/密钥文件
|
||||
- 每条 entry 的 `entry_hash` 用私钥 **ECDSA 签名**
|
||||
- 任何人想改数据必须重新签名 → 没有私钥就无法伪造合法记录
|
||||
- 验签失败 = 数据被改过或来源可疑 → 立即告警
|
||||
|
||||
### 5.3 数据库层硬约束(SQLite/PG 双实现)
|
||||
|
||||
```sql
|
||||
-- 账本表:只追加,禁止修改删除(SQLite 触发器强制)
|
||||
CREATE TABLE audit_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
prev_hash TEXT NOT NULL, -- 前一哈希(链)
|
||||
payload_hash TEXT NOT NULL, -- 本条事件内容哈希
|
||||
entry_hash TEXT NOT NULL, -- SHA256(prev+payload)
|
||||
signature TEXT NOT NULL, -- ECDSA(entry_hash)
|
||||
event_id INTEGER, -- 关联 payment_events.id
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 禁止 UPDATE / DELETE(数据库层防篡改)
|
||||
CREATE TRIGGER trg_ledger_no_update BEFORE UPDATE ON audit_ledger
|
||||
BEGIN SELECT RAISE(ABORT, 'audit_ledger is append-only'); END;
|
||||
CREATE TRIGGER trg_ledger_no_delete BEFORE DELETE ON audit_ledger
|
||||
BEGIN SELECT RAISE(ABORT, 'audit_ledger is append-only'); END;
|
||||
```
|
||||
|
||||
> PG 版:DB 账号只授予 INSERT/SELECT 权限(无 UPDATE/DELETE),双保险。
|
||||
|
||||
### 5.4 每日快照(外部锚定点)
|
||||
|
||||
```sql
|
||||
CREATE TABLE daily_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_date TEXT NOT NULL UNIQUE,
|
||||
last_hash TEXT NOT NULL, -- 当日最后 entry_hash(锚点)
|
||||
signature TEXT NOT NULL, -- 对锚点签名
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- 每日 00:00 生成快照:当前链尾哈希 + 签名
|
||||
- 可选增强:把快照哈希发布到外部(邮件/对象存储/链上)→ 防数据库整体替换
|
||||
- **改任何历史数据 → 与快照锚点比对即发现**
|
||||
|
||||
### 5.5 完整性校验任务(scheduler 每日 + 手动)
|
||||
|
||||
```go
|
||||
VerifyLedger() → 从 genesis(或上一快照)重算整条哈希链
|
||||
→ 校验每条 ECDSA 签名
|
||||
→ 比对 daily_snapshots 锚点
|
||||
→ 全过 → INFO 记录"账本完整"
|
||||
→ 任何失配 → ERROR + 管理后台红色告警(重大安全事件)
|
||||
```
|
||||
|
||||
### 5.6 资金事件写入流程(唯一入口,防绕过)
|
||||
|
||||
```
|
||||
通道回调/监听 → order服务校验通过
|
||||
→ ① payment_events INSERT(业务库,只增)
|
||||
→ ② audit_ledger.Append(事件内容) ← 哈希链+签名(独立账本库 ledger.db)
|
||||
→ ③ 账本写入失败 → 回滚订单状态 + 告警(资金事件必须有账本记录)
|
||||
```
|
||||
|
||||
**所有资金变更只能走这一个入口**,不允许其他代码直接写事件表。
|
||||
> 注:账本在独立数据库中,无法与业务库同事务;因此采用"业务库先记事件 → 账本库追加 → 失败则回滚订单状态并告警"的顺序,确保资金事件必有账本记录。
|
||||
|
||||
### 5.7 管理后台联动
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 账本状态 | 显示最后校验时间/结果/链长 |
|
||||
| 手动校验 | 按钮触发全链校验 |
|
||||
| 篡改告警 | 校验失败 → 顶部红色横幅 + 告警日志 |
|
||||
| 事件详情 | 每条事件可看 payload_hash / signature / 原始数据 |
|
||||
|
||||
### 5.8 独立账本数据库(ledger.db,安全隔离)
|
||||
|
||||
> **架构升级**:账本从业务库中独立出来,单独一个 SQLite 文件,与订单/日志/回调完全隔离。
|
||||
|
||||
```
|
||||
paygateway.db(业务库) ledger.db(账本库 🛡️)
|
||||
├── orders ├── audit_ledger
|
||||
├── payment_events └── daily_snapshots
|
||||
├── notify_tasks
|
||||
├── callback_logs
|
||||
├── app_logs
|
||||
└── settings
|
||||
```
|
||||
|
||||
**隔离带来的安全提升:**
|
||||
|
||||
| 防护层 | 机制 | 效果 |
|
||||
|--------|------|------|
|
||||
| 文件隔离 | 账本独立 `ledger.db` | 删业务库不影响账本,审计线索永存 |
|
||||
| 文件权限 | `chmod 0600` | 仅文件所有者可读写,其他用户/进程无法访问 |
|
||||
| 触发器 | 两个表均有 UPDATE/DELETE 触发器 | 通过 SQL 无法修改/删除任何账本数据 |
|
||||
| WAL 模式 | `PRAGMA journal_mode=WAL` | 崩溃安全,写入不丢数据 |
|
||||
| FULL 同步 | `PRAGMA synchronous=FULL` | 每次写入等待落盘,防断电丢数据 |
|
||||
| 连接隔离 | `SetMaxOpenConns(1)` | 单连接串行写,避免并发写入冲突 |
|
||||
|
||||
**实现方式:**
|
||||
|
||||
```go
|
||||
// store.LedgerDB 独立账本存储(internal/store/ledgerdb.go)
|
||||
ledgerDB, err := store.NewLedgerDB("ledger.db") // 0600 + WAL + 触发器
|
||||
lg, err := ledger.New(ledgerDB, cfg.LedgerPrivKey) // 账本只依赖独立库
|
||||
// admin 同时注入业务库 + 账本库
|
||||
adminSrv := admin.New(st, ledgerDB, ...)
|
||||
```
|
||||
|
||||
### 5.9 管理后台 API 与前端
|
||||
|
||||
**账本相关 API:**
|
||||
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `GET /api/ledger` | 账本条目列表(哈希链可视化) |
|
||||
| `POST /api/ledger/verify` | 手动触发全链校验(哈希链 + 签名 + 快照锚点) |
|
||||
| `GET /api/ledger/snapshots` | 每日快照列表 |
|
||||
| `POST /api/ledger/snapshot` | 手动创建当日快照 |
|
||||
| `GET /api/ledger/public-key` | 返回 ECDSA P256 公钥(供第三方验证) |
|
||||
| `GET /api/ledger/export` | 导出账本 + 快照为 CSV(审计报告) |
|
||||
|
||||
**前端功能(Vue3 Ledger 页面):**
|
||||
|
||||
- 📋 **账本条目**:哈希链可视化(每条含 prev_hash / entry_hash / 签名)
|
||||
- 🔍 **校验账本**:一键全链校验,弹窗展示结果(通过/失败 + 错误明细 + 快照锚点状态)
|
||||
- 📸 **快照管理**:卡片列表 + 详情弹窗 + **锚点核对**(对比快照链尾与当前链尾)
|
||||
- 🔑 **账本公钥**:展示 PEM 公钥,可复制供外部审计验证
|
||||
- ⬇️ **导出 CSV**:一键下载完整审计报告(含所有条目 + 快照)
|
||||
- 🔗 **事件跳转**:点击事件 ID 跳转订单页搜索
|
||||
|
||||
**测试覆盖(internal/ledger/ledger_test.go):**
|
||||
|
||||
| 测试 | 验证点 |
|
||||
|------|--------|
|
||||
| TestAppendAndVerify | 追加 + 全链校验通过 |
|
||||
| TestHashChainLinked | 哈希链正确连接 |
|
||||
| TestTamperDetected | 篡改条目 → 校验失败 |
|
||||
| TestTamperSignatureDetected | 篡改内容 → 签名校验失败 |
|
||||
| TestSnapshotCreateAndVerify | 快照创建 + 锚点匹配 |
|
||||
| TestSnapshotAnchorMismatch | 数据库替换 → 锚点不匹配 |
|
||||
| TestSignatureUnique | 防重放(同内容不同事件不同签名) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 日志系统设计(健壮 + 可查询)
|
||||
|
||||
### 5.1 双写架构:文件(排障)+ 数据库(查询)
|
||||
|
||||
```
|
||||
应用 → slog(结构化) → ① 文件轮转日志(/var/log/paygateway/*.log)
|
||||
→ ② 异步批量写 app_logs 表(管理界面查询)
|
||||
```
|
||||
|
||||
### 5.2 技术选型
|
||||
|
||||
| 项 | 方案 |
|
||||
|----|------|
|
||||
| 日志库 | Go 标准库 **slog**(结构化,JSON 输出) |
|
||||
| 轮转 | `gopkg.in/natefinch/lumberjack.v2`(按大小/天数切割,防磁盘爆) |
|
||||
| 写库 | 异步批量(攒 50 条或 1s flush),避免日志拖慢交易主链路 |
|
||||
| 级别 | debug / info / warn / error(env 可配) |
|
||||
| 追踪 | 每个 HTTP 请求生成 `trace_id`(中间件),贯穿 下单→回调→通知 全链路 |
|
||||
|
||||
### 5.3 关键审计日志(钱相关的必须全记)
|
||||
|
||||
```go
|
||||
// 资金事件:writeEvent(orderID, "paid", amountMinor, rawPayload)
|
||||
// - created / paid / closed / failed / amount_mismatch / refunded
|
||||
// 回调事件:writeEvent(orderID, "notify_attempt", ..., httpStatus)
|
||||
// 安全事件:writeEvent(orderID, "verify_failed", ..., 原因) ← 验签失败必须告警
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Web 管理后台设计
|
||||
|
||||
### 6.1 形态:同进程嵌入式(单二进制部署)
|
||||
|
||||
- 同一 Go 进程,路由组 `/admin/*`(端口可独立配置 `ADMIN_ADDR`)
|
||||
- 内置 HTML/JS/CSS(`embed` 打包),无前端构建链
|
||||
- **登录鉴权**:管理 token(`ADMIN_TOKEN` env)+ 可选 Basic Auth;生产必须启用
|
||||
- **接口全部走 `/admin/api/*`**,返回 JSON
|
||||
|
||||
### 6.2 功能清单
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| **仪表盘** | 今日订单量/交易额/成功率/待通知数/告警数(卡片 + 简易图表) |
|
||||
| **订单管理** | 列表(状态/通道/金额/时间筛选,分页)→ 详情(状态机时间线 + 支付凭据 + 原始回调) |
|
||||
| **交易流水** | payment_events 查询(按订单/事件类型/时间) |
|
||||
| **日志查看** | app_logs 查询(级别/模块/trace_id 过滤,实时 tail) |
|
||||
| **回调管理** | notify_tasks 列表;**手动补发**按钮(dead 任务重投);重试状态 |
|
||||
| **通道管理** | 已注册通道列表 + 启用/停用开关(配置驱动) |
|
||||
| **对账报告** | 定时对账结果(订单 vs 通道实付) |
|
||||
| **账本校验** | 哈希链完整性校验 + 篡改告警(红色横幅) |
|
||||
| **系统状态** | 运行时长/goroutine/内存/最近错误 |
|
||||
|
||||
### 6.3 页面路由规划
|
||||
|
||||
```
|
||||
GET /admin/login # 登录页
|
||||
GET /admin/ # 仪表盘(SPA,JS 拉 /admin/api/*)
|
||||
GET /admin/orders # 订单页
|
||||
GET /admin/logs # 日志页
|
||||
GET /admin/notify # 回调页
|
||||
GET /admin/api/dashboard # JSON API
|
||||
GET /admin/api/orders?status=&channel=&page=
|
||||
GET /admin/api/orders/:no # 订单详情(含事件流)
|
||||
GET /admin/api/logs?level=&scope=&trace=&page=
|
||||
GET /admin/api/notify/tasks?status=
|
||||
POST /admin/api/notify/:id/retry # 手动补发
|
||||
GET /admin/api/channels # 通道状态
|
||||
GET /admin/api/health # 系统状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 目录结构(最终目标态)
|
||||
|
||||
```
|
||||
paygateway/
|
||||
├── cmd/gateway/main.go # 入口:组装 config/store/registry/router
|
||||
├── internal/
|
||||
│ ├── config/ # .env 加载 + 通道密钥管理
|
||||
│ ├── model/ # Order / 状态常量 / 金额类型
|
||||
│ ├── logger/ # slog 封装:文件轮转 + 异步写库 + trace_id
|
||||
│ ├── epay/ # 易支付协议(submit/api/notify 签名)
|
||||
│ ├── rest/ # REST API(预留)
|
||||
│ ├── order/ # 编排层:状态机/幂等/金额校验/对账
|
||||
│ ├── channel/
|
||||
│ │ ├── registry.go # 注册表 + Get()
|
||||
│ │ ├── interface.go # PaymentChannel / PayRequest / ...
|
||||
│ │ ├── alipay/ # 支付宝(gopay)——现有代码迁入
|
||||
│ │ ├── wechat/ # 微信(gopay)——未来
|
||||
│ │ ├── paypal/ # PayPal(gopay)——未来
|
||||
│ │ └── crypto/ # ★ 预留:stub + README(未来链上)
|
||||
│ ├── store/ # Store 接口 + sqlite(默认)/pg 实现
|
||||
│ │ ├── store.go # Store 接口 + 数据模型
|
||||
│ │ ├── sqlite.go # 业务库 SQLite 实现(订单/事件/日志)
|
||||
│ │ ├── sqlite_rest.go # 业务库查询实现
|
||||
│ │ └── ledgerdb.go # ★ 独立账本库(ledger.db,0600 权限 + 触发器)
|
||||
│ ├── notify/ # outbox worker + 重试
|
||||
│ ├── ledger/ # ★ 资金审计账本(哈希链+签名+校验)
|
||||
│ ├── scheduler/ # 关单/补偿/对账/账本校验定时任务
|
||||
│ ├── admin/ # ★ 管理后台:路由 + 页面(embed) + API
|
||||
│ ├── web/ # 二维码页/中转页渲染
|
||||
│ └── metrics/ # Prometheus(预留)
|
||||
├── migrations/ # SQL 迁移(sqlite/pg)
|
||||
├── docs/ARCHITECTURE.md # 本文档
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 实施计划(保持现有功能不回归)
|
||||
|
||||
| 阶段 | 内容 | 验收 |
|
||||
|------|------|------|
|
||||
| **P1 基础重建** | 目录分层 + Store 接口 + **SQLite 落地** + 支付宝迁入 `channel/alipay` + 日志系统(文件+DB) + **audit_ledger 哈希链** | 现有沙箱链路回归 ✅;订单/日志/账本入库 ✅ |
|
||||
| **P2 管理后台** | `/admin` 页面(仪表盘/订单/日志/回调)+ API + token 鉴权 | 浏览器可查订单、日志、交易流水 |
|
||||
| **P3 资金安全** | 状态机条件更新 + 金额校验 + 幂等 + outbox 回调重试 + **账本完整性校验任务** + **独立账本库 ledger.db** + **账本管理前端** | 模拟重复/篡改回调不产生错误;篡改一条记录 → 校验失败 ✅;账本独立库 + 0600 权限 + 触发器 ✅;前端校验/快照/公钥/导出 ✅ |
|
||||
| **P4 通道扩展** | 用 gopay 接微信(验证"3 步加通道"流程) | 微信沙箱链路跑通 |
|
||||
| **P5 crypto 预留** | crypto stub + README + 注册表支持 ListenerKind | `type=usdt` 返回"未启用" |
|
||||
| **P6 生产化** | 按你们基础设施切 PG + 监控 + 对账 + 部署脚本 | 生产验收 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 为什么这是最佳实践
|
||||
|
||||
1. **不重复造轮子**:9 种支付 SDK 由 gopay 维护,paygateway 只做编排 → 通道越多,收益越大
|
||||
2. **加支付方式像插 U 盘**:新通道 = 新包 + 注册 + 配置,核心零改动
|
||||
3. **协议层稳定**:易支付/REST 接口永不变 → 现有上游(New API)不受影响
|
||||
4. **资金安全集中**:所有通道都过同一套状态机/金额校验/幂等 → 只维护一套安全逻辑
|
||||
5. **链上预留**:接口已含 ListenerKind + Address 凭据 → 未来 crypto 落地不动架构
|
||||
6. **存储可换**:SQLite(单机起步) → PG(生产),`Store` 接口平滑切换
|
||||
7. **可观测性内置**:日志双写(文件+DB)+ 管理后台 → 出问题能查、能补、能对账
|
||||
8. **交易不可篡改**:哈希链 + 签名 + 只增不改 + 每日快照 → 任何篡改必被发现
|
||||
@@ -0,0 +1,25 @@
|
||||
# DeviceActor:设备操作串行化
|
||||
|
||||
## 背景
|
||||
|
||||
多个后台任务可能同时需要访问同一台授权设备。直接并发执行会导致前台状态、读取结果和恢复动作互相干扰,因此设备访问需要统一的串行化入口。
|
||||
|
||||
## 模型
|
||||
|
||||
```text
|
||||
caller -> queue -> DeviceActor -> device adapter
|
||||
|
|
||||
+-> lease / timeout / health event
|
||||
```
|
||||
|
||||
- 一个设备同一时刻只有一个 active actor;
|
||||
- 请求通过队列进入,调用方等待结果或超时返回;
|
||||
- lease 到期后自动释放,避免异常任务永久占用设备;
|
||||
- 所有失败转化为结构化健康事件并可触发告警;
|
||||
- 手动动作和自动动作不能绕过 actor 直接操作设备。
|
||||
|
||||
## 适用边界
|
||||
|
||||
该模型适用于授权的移动设备测试、UI 自动化和设备实验室。它不提供未经授权访问第三方账户或绕过平台安全控制的方法。
|
||||
|
||||
实现位于 `internal/ledgermon/` 和 `internal/adbmgr/`,测试位于对应的 `*_test.go` 文件。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 公平设备调度:设计概览
|
||||
|
||||
本文档描述多任务设备池的通用调度原则,不包含特定平台的账号、页面或操作细节。
|
||||
|
||||
## 目标
|
||||
|
||||
- 在设备能力上限内分配任务;
|
||||
- 避免单个账户或任务长期占用设备;
|
||||
- 设备异常时快速降权或排除;
|
||||
- 让调度结果可解释、可观测、可测试。
|
||||
|
||||
## 评分因素
|
||||
|
||||
调度器可以综合考虑当前负载、历史任务耗时、设备健康状态、最近失败次数、账户冷却时间、任务优先级、设备能力上限和队列长度。
|
||||
|
||||
调度器只负责选择可用执行资源,不负责确认业务事实。业务确认仍由订单状态机、匹配器和审计账本完成。
|
||||
|
||||
## 安全边界
|
||||
|
||||
设备操作必须经过 actor 串行化和授权检查。设备离线、任务超时或结果不明确时,调度器应停止继续分配并生成健康事件,而不是猜测任务已成功。
|
||||
@@ -0,0 +1,224 @@
|
||||
# dead 通知手动重试(用最新回调地址) + 删除 实施设计(DESIGN-NOTIFY-RETRY-LATEST-URL)
|
||||
|
||||
> 状态:**已实现**(`order.Service.BuildNotifyPayload` 单一来源;`handleRetryDead` 重发前按订单最新
|
||||
> NotifyURL 重建 payload 并重新签名;新增 `handleDeleteDead` + `DeleteNotifyTask`;store 新增
|
||||
> `GetNotifyTask`/`UpdateNotifyTaskPayload`;Reconciler.vue 死信页增删除按钮,NotifyTasks.vue 手动重试接入真实 API)
|
||||
> 关联:docs/RESILIENCE.md(对账兜底)、docs/ARCHITECTURE.md(outbox 回调队列)
|
||||
> 影响模块:`internal/order`、`internal/store`、`internal/admin/reconciler`、`web/admin`
|
||||
> 目标:**死信/失败回调任务手动重试时,用订单当前最新的 notify_url 重建 payload 并重新签名投递,
|
||||
> 解决「配置时 notify_url 错误 → 重发仍打到旧地址 → 一直回调不了 → 死信」;并提供彻底删除死信任务的能力。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题(现状代码事实)
|
||||
|
||||
### 1.1 Outbox 回调队列现状
|
||||
|
||||
- 生产者 `order.Service.EnqueueNotify`(internal/order/service.go:1126):支付成功/人工入账后调用,
|
||||
构造易支付协议参数(含 `notify_url` = `o.NotifyURL`),`epay.BuildSignedParams(params, signKey(pid))`
|
||||
签名,JSON 序列化为 `payload`,`CreateNotifyTask` 落库。
|
||||
- 消费者 `notify.Worker`(internal/notify/worker.go):`process` 解析 payload,
|
||||
`notifyURL := params["notify_url"]`(worker.go:88)作为发送地址,body = 全部 params(含签名)。
|
||||
超过 `MaxRetries=6` 标记 `dead`(worker.go:142-144)。
|
||||
- 状态:`pending` / `success` / `dead`(store.go:121)。
|
||||
|
||||
### 1.2 关键缺陷:payload 固化 notify_url,重放不刷新
|
||||
|
||||
- `EnqueueNotify` 在**任务创建时**把 `o.NotifyURL` 写进 payload。
|
||||
- worker 发送从 **payload** 读 `notify_url`,不是订单当前值。
|
||||
- 现有死信重发 `admin.Handler.handleRetryDead`(internal/admin/reconciler.go:287)只调
|
||||
`UpdateNotifyTask(ctx, id, "pending", 0, now)`——仅更新 `status/retry_count/next_retry_at/updated_at`,
|
||||
**完全不碰 payload**。因此死信重发仍打到**旧地址**。
|
||||
- 后果:商户配置时把 notify_url 配错,或之后改了回调地址,死信重发永远打到旧地址 → 一直回调不了。
|
||||
|
||||
### 1.3 现状已有 vs 缺失
|
||||
|
||||
| 能力 | 现状 |
|
||||
|---|---|
|
||||
| 死信列表 | 已有 `GET /api/reconciler/notify-dead`(handleListDead) |
|
||||
| 死信重发 | 已有 `POST /api/reconciler/notify-dead/:id/retry`,但**不刷新地址** |
|
||||
| NotifyTasks.vue 手动重试 | **占位**(web/admin/src/views/NotifyTasks.vue:123-126,只弹提示) |
|
||||
| 彻底删除死信 | **完全没有**(无 DeleteNotifyTask / DELETE 路由) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
### 2.1 重试用订单最新 notify_url(核心)
|
||||
|
||||
重发前:通过 `task.OrderID` → `GetOrderByID` 取订单,**用订单当前 `NotifyURL` 重建 payload 并重新签名**,
|
||||
再重置为 `pending`。理由:
|
||||
- worker 发送地址 = payload 里的 `notify_url`,所以必须重建 payload 才能换地址。
|
||||
- 商户端按"收到的全部参数重算签名"验签(worker.go:94-96 注释),改了 notify_url 必须**重新签名**,
|
||||
否则验签失败。因此必须复用 `EnqueueNotify` 的完整构造+签名逻辑,不能只改 notify_url 字符串。
|
||||
|
||||
**单一来源重构**:把 `EnqueueNotify` 的 payload 构造逻辑提取为 `Service` 的导出方法
|
||||
`BuildNotifyPayload(o *model.Order) string`,`EnqueueNotify` 与重试 handler 都调用它,
|
||||
保证字段与签名完全一致、单一来源。
|
||||
|
||||
### 2.2 彻底删除死信任务
|
||||
|
||||
提供 `DELETE` 语义:新增 `handleDeleteDead`(`POST /reconciler/notify-dead/:id/delete`)。
|
||||
**风险提示**:若订单已支付但通知未送达,删除后无法再自动补发(人工可重新入队兜底,见下)。属"确认放弃该笔通知"操作。
|
||||
|
||||
### 2.3 前端
|
||||
|
||||
- `Reconciler.vue` 死信页:现有"重发"按钮后端已会刷新地址(无需前端改逻辑);新增"删除"按钮(二次确认)。
|
||||
- `NotifyTasks.vue`:修正占位手动重试——复用 `retryDeadNotify`/`delete`,接入真实后端;或移除占位避免误导。
|
||||
(最小改动:让占位按钮调用真实 retry API。)
|
||||
|
||||
---
|
||||
|
||||
## 3. 实现细节
|
||||
|
||||
### 3.1 `internal/order/service.go` — payload 构造单一来源
|
||||
|
||||
新增导出方法(含签名),`EnqueueNotify` 内部复用它:
|
||||
|
||||
```go
|
||||
// BuildNotifyPayload 构造易支付协议回调参数并签名,返回 JSON payload。
|
||||
// 供 EnqueueNotify(下单入队)与死信手动重发(用订单最新 notify_url)复用,保证字段/签名单一来源。
|
||||
func (s *Service) BuildNotifyPayload(o *model.Order) string {
|
||||
params := map[string]string{
|
||||
"trade_no": o.GatewayNo,
|
||||
"out_trade_no": o.OutTradeNo,
|
||||
"type": o.Channel,
|
||||
"name": o.Subject,
|
||||
"money": o.MoneyYuan(),
|
||||
"trade_status": "TRADE_SUCCESS",
|
||||
"notify_url": o.NotifyURL,
|
||||
}
|
||||
if o.Attach != "" {
|
||||
params["attach"] = o.Attach
|
||||
}
|
||||
if o.ChannelTxID != "" {
|
||||
params["trade_no"] = o.ChannelTxID
|
||||
}
|
||||
epay.BuildSignedParams(params, s.signKey(o.Pid))
|
||||
payload, _ := json.Marshal(params)
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func (s *Service) EnqueueNotify(ctx context.Context, o *model.Order) {
|
||||
payload := s.BuildNotifyPayload(o)
|
||||
taskID, err := s.store.CreateNotifyTask(ctx, o.ID, payload)
|
||||
// ...原有逻辑不变
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `internal/store/store.go` — 接口新增
|
||||
|
||||
```go
|
||||
// UpdateNotifyTaskPayload 更新回调任务 payload(死信重发时按订单最新 notify_url 重建)
|
||||
UpdateNotifyTaskPayload(ctx context.Context, id int64, payload string) error
|
||||
// DeleteNotifyTask 删除回调任务(彻底移除死信,确认放弃该笔通知)
|
||||
DeleteNotifyTask(ctx context.Context, id int64) error
|
||||
```
|
||||
|
||||
### 3.3 `internal/store/sqlite_rest.go` — 实现
|
||||
|
||||
```go
|
||||
func (s *SQLiteStore) UpdateNotifyTaskPayload(ctx context.Context, id int64, payload string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE notify_tasks SET payload=?, updated_at=? WHERE id=?`, payload, nowStr(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) DeleteNotifyTask(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM notify_tasks WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 `internal/admin/reconciler.go` — 重发刷新地址 + 删除
|
||||
|
||||
```go
|
||||
func (h *ReconcilerHandler) handleRetryDead(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
operator := c.GetString("username")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// 取任务
|
||||
tasks, err := h.store.ListNotifyTasks(ctx, "dead", 100) // 或新增 GetNotifyTask(id)
|
||||
// (List 按 id DESC,需从列表定位 task;更干净:新增 GetNotifyTask(id))
|
||||
var task *store.NotifyTask
|
||||
for _, t := range tasks { if t.ID == id { task = t; break } }
|
||||
if task == nil { c.JSON(404, ...); return }
|
||||
|
||||
// 取订单,用最新 NotifyURL 重建 payload + 重签
|
||||
o, err := h.store.GetOrderByID(ctx, task.OrderID)
|
||||
var payload string
|
||||
if err != nil || o == nil {
|
||||
// 订单丢失:保留原 payload(无法重建),仅重置 pending
|
||||
payload = task.Payload
|
||||
} else {
|
||||
payload = h.svc.BuildNotifyPayload(o)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateNotifyTaskPayload(ctx, id, payload); err != nil { ... }
|
||||
h.store.UpdateNotifyTask(ctx, id, "pending", 0, time.Now())
|
||||
logger.Warn("admin", "dead 通知重发(用最新 notify_url)", "task_id", id, "operator", operator)
|
||||
c.JSON(http.StatusOK, gin.H{"data": "已按最新回调地址重新入队"})
|
||||
}
|
||||
|
||||
func (h *ReconcilerHandler) handleDeleteDead(c *gin.Context) {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
operator := c.GetString("username")
|
||||
if err := h.store.DeleteNotifyTask(c.Request.Context(), id); err != nil { ... }
|
||||
logger.Warn("admin", "dead 通知已删除", "task_id", id, "operator", operator)
|
||||
c.JSON(http.StatusOK, gin.H{"data": "已删除"})
|
||||
}
|
||||
```
|
||||
|
||||
> 更干净:store 新增 `GetNotifyTask(ctx, id)` 避免在列表中扫描定位。**设计决策:新增 `GetNotifyTask`**。
|
||||
|
||||
### 3.5 路由
|
||||
|
||||
```go
|
||||
rg.POST("/reconciler/notify-dead/:id/retry", h.handleRetryDead)
|
||||
rg.POST("/reconciler/notify-dead/:id/delete", h.handleDeleteDead) // 新增
|
||||
```
|
||||
|
||||
### 3.6 前端 `api.js` + `Reconciler.vue`
|
||||
|
||||
```js
|
||||
// api.js
|
||||
deleteDeadNotify: (id) => request(`/reconciler/notify-dead/${id}/delete`, { method: 'POST' }),
|
||||
```
|
||||
|
||||
`Reconciler.vue` 死信行操作区加"删除"按钮(ElMessageBox.confirm 二次确认),
|
||||
重发按钮保持(后端已刷新地址)。
|
||||
|
||||
### 3.7 `NotifyTasks.vue` 占位手动重试
|
||||
|
||||
将 `manualRetry` 占位改为调用真实 retry API(当前选中任务 `current.id`):
|
||||
|
||||
```js
|
||||
async function manualRetry() {
|
||||
try { await api.retryDeadNotify(current.value.id); ElMessage.success('已按最新回调地址重新入队'); }
|
||||
catch (e) { ElMessage.error('重试失败: ' + e.message) }
|
||||
finally { payloadDialog.value = false; load() }
|
||||
}
|
||||
```
|
||||
|
||||
> 依赖 api.js 暴露 `retryDeadNotify`(已有,api.js:148)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 边界与风险
|
||||
|
||||
| 场景 | 处理 | 风险 |
|
||||
|---|---|---|
|
||||
| 订单已删除/丢失 | 无法重建 payload → 保留原 payload 仅重置 pending | 仍打旧地址;提示人工 |
|
||||
| 订单 NotifyURL 为空 | 重建后 notify_url 空 → worker 标记 success 跳过 | 极端;需人工 |
|
||||
| 删除已支付但未通知的死信 | 不再自动补发,人工可从异常池/重新入队 | 资金通知可能丢失——删除前二次确认 |
|
||||
| 并发(worker 同时取到同任务重试) | 重置 pending 后 next_retry_at=now,worker 下一轮即发 | 重复投递一次,幂等可接受 |
|
||||
| NotifyTasks 全量页与 Reconciler 死信页 | 两处都接入真实 API | 保持一致 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 回归测试
|
||||
|
||||
- `internal/store`:新增 `UpdateNotifyTaskPayload` / `DeleteNotifyTask` 的单测(落库、读回、删除)。
|
||||
- `internal/admin`:`handleRetryDead` 重建 payload 用最新 notify_url(伪造 task+order,断言 payload 内 notify_url 为最新值且签名有效)。
|
||||
- `internal/order`:`BuildNotifyPayload` 与 `EnqueueNotify` 产物一致(单一来源)。
|
||||
@@ -0,0 +1,86 @@
|
||||
# 订单金额不可变与买家订单替换设计
|
||||
|
||||
> 状态:**已修正**(原“复用订单覆盖金额”方案已废弃)
|
||||
> 关联:docs/MIANQIAN.md、docs/RESILIENCE.md
|
||||
> 修正原因:生产事故中,上游先创建 10 元订单,买家随后改为 1 元;网关把新请求错误地复用到旧订单号并覆盖金额,导致 1 元支付回调到了上游 10 元订单,新的 1 元上游订单则保持 pending。
|
||||
|
||||
## 1. 事故根因
|
||||
|
||||
易支付上游的 `out_trade_no` 与金额是不可变业务主键。旧实现的买家级串行保护在发现同一环境存在 WAIT_PAY 订单时,无论本次请求的订单号是否不同,都调用旧订单的 `reuseOrCreateError`:
|
||||
|
||||
```text
|
||||
上游 OLD=10 元 -> 网关创建 OLD WAIT_PAY
|
||||
上游 NEW=1 元 -> buyer_key 命中 OLD,网关没有创建 NEW
|
||||
网关把 OLD 改成 1 元并刷新 OLD 凭据
|
||||
用户支付 1 元 -> 网关回调 OLD+1 元
|
||||
上游按 OLD 的原始 10 元订单归属回调,NEW 订单无回调保持 pending
|
||||
```
|
||||
|
||||
生产日志中可见 `pending=OLD,new=NEW`,随后凭据仍以 OLD 的订单号创建。
|
||||
|
||||
## 2. 不可变规则
|
||||
|
||||
### 2.1 同一订单号
|
||||
|
||||
同一 `out_trade_no` 一旦存在,金额不能变化:
|
||||
|
||||
| 请求 | 处理 |
|
||||
|---|---|
|
||||
| 同号、同金额 | 幂等复用原订单 |
|
||||
| 同号、不同金额 | 直接拒绝,提示使用新订单号 |
|
||||
| 同号、已成功 | 返回已处理 |
|
||||
| 同号、已关闭、同金额 | 按原有同号复活语义处理 |
|
||||
| 同号、已关闭、不同金额 | 拒绝,不复活改价 |
|
||||
|
||||
网关不再调用 `UpdateOrderAmount` 修改已存在订单。该方法保留在 store 接口中仅为兼容现有实现,当前订单流程不应调用它。
|
||||
|
||||
### 2.2 不同订单号
|
||||
|
||||
个人免签通道中,同一 `buyer_key/fp_key` 只能有一个有效 WAIT_PAY,但金额变化必须通过新订单号实现:
|
||||
|
||||
1. 对旧 WAIT_PAY 执行条件更新 `WAIT_BUYER_PAY -> TRADE_CLOSED`。
|
||||
2. 仅首次 CAS 成功时写一条 `replaced` 事件,释放旧备注码并移出 WAIT_PAY 池。
|
||||
3. 继续正常创建请求中的新订单号、金额、凭据和 `CreatedAt`。
|
||||
4. 新订单进入 WAIT_PAY 池,动态 `CloseAt` 取设备窗口和 `SessionExpiresAt` 的较早值;前端倒计时与后端一致,但不得超过原支付会话硬截止时间。
|
||||
|
||||
旧订单号绝不能被新订单请求复用。商家通道不启用买家级个人免签锁,保持按订单号自身幂等。
|
||||
|
||||
### 2.3 支付会话生命周期
|
||||
|
||||
订单替换不能通过新的 `CreatedAt` 无限续命。个人订单首次创建时持久化
|
||||
`SessionExpiresAt`(默认使用当前订单关闭窗口),替换订单继承该值;WAIT_PAY 池和支付页倒计时
|
||||
都使用 `min(设备动态窗口截止时间, SessionExpiresAt)`。
|
||||
|
||||
当 `SessionExpiresAt` 已到期时,新的金额/订单号替换请求直接拒绝,要求重新发起充值。这样可以同时满足:
|
||||
|
||||
- 支持买家在会话内改变充值金额;
|
||||
- 同一时间同一环境只有一个 WAIT_PAY;
|
||||
- 连续改变金额不能永久占用账户、监听和备注码资源。
|
||||
|
||||
## 3. 并发与资源
|
||||
|
||||
替换使用现有 `UpdateOrderStatus` 的条件更新作为 CAS:
|
||||
|
||||
- CAS 成功:旧订单关闭,记录 `replaced`,释放备注码,移除 WAIT_PAY 池,然后创建新单。
|
||||
- CAS 失败且旧单已成功:结算胜出,不创建新单,避免重复收款。
|
||||
- CAS 失败且旧单已关闭:允许当前新订单继续正常创建,但不重复写替换事件或释放资源。
|
||||
|
||||
替换不是普通超时关单,不调用通用 `CloseOrder`,避免产生重复 generic `closed` 事件、日志和异常池记录。
|
||||
|
||||
旧二维码对应旧订单,订单关闭后按现有状态/过期逻辑失效;旧订单迟到支付不会转移到新订单,继续走关闭订单的人工核查保护。
|
||||
|
||||
## 4. 个人免签匹配安全
|
||||
|
||||
个人免签金额匹配使用 DB 中 `status=WAIT_BUYER_PAY`、精确 `amount_minor` 和收款时间窗口;`SettlePayment` 再次严格校验实付金额与订单金额。因此本次修复不改变匹配算法,修复点是下单时不能吞掉新订单号。
|
||||
|
||||
## 5. 回归测试
|
||||
|
||||
`internal/order/service_test.go` 覆盖:
|
||||
|
||||
1. 旧 10 元 WAIT_PAY + 新订单号 1 元:旧单关闭,新订单号创建,金额/回调身份使用新单。
|
||||
2. 同订单号不同金额:拒绝,DB 金额、状态、凭据和事件不变。
|
||||
3. 同订单号同金额:幂等复用。
|
||||
4. 替换只产生一条 `replaced` 事件,备注码和 WAIT_PAY 池只释放一次。
|
||||
5. 并发替换/结算:CAS 保证只能由一方获胜,不重复结算、不重复创建新单。
|
||||
|
||||
旧版“同号改金额并刷新凭据”的测试已改为金额不可变测试,避免把事故逻辑重新锁定为正确行为。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 设备自动化与个人通道:学习概览
|
||||
|
||||
> 本文档只保留架构和工程实践概览,不提供特定平台的收款操作、设备坐标、账号配置、连接参数或规避平台限制的方法。相关实现代码保留在仓库中,供获得授权的学习和测试使用。
|
||||
|
||||
## 目标
|
||||
|
||||
个人通道是一个可选的实验性适配器,用于研究如何把外部设备事件接入订单状态机。它复用统一的订单、幂等、异常池、账本和告警基础设施,不改变商家通道的核心流程。
|
||||
|
||||
## 工程边界
|
||||
|
||||
- 设备访问通过受控的设备管理层封装,业务代码不直接散落调用设备命令。
|
||||
- 设备事件必须经过来源认证、时间窗口、金额校验和幂等去重。
|
||||
- 设备离线、解析失败、状态冲突和无法确认的事件进入异常池,不自动入账。
|
||||
- 设备调度使用串行化访问、租约、健康状态和负载感知,避免多个任务同时操作同一设备。
|
||||
- 任何个人账户、支付平台或设备的接入,都必须获得账户所有者授权并遵守适用的平台规则。
|
||||
|
||||
## 代码阅读入口
|
||||
|
||||
| 主题 | 目录 |
|
||||
| --- | --- |
|
||||
| 设备连接与健康检查 | `internal/adbmgr/` |
|
||||
| 设备操作编排 | `internal/ledgermon/` |
|
||||
| 事件读取与去重 | `internal/notifywatcher/`、`internal/admin/capture/` |
|
||||
| 个人通道抽象 | `internal/channel/personal/`、`internal/personal/` |
|
||||
| 订单确认与异常处理 | `internal/order/`、`internal/reconciler/` |
|
||||
| 审计账本 | `internal/ledger/`、`internal/store/` |
|
||||
|
||||
## 保留的通用实践
|
||||
|
||||
- DeviceActor/租约式设备访问串行化;
|
||||
- 设备离线与任务超时的安全降级;
|
||||
- 事件指纹、窗口匹配和重复回调幂等;
|
||||
- 设备负载驱动的任务调度;
|
||||
- 账本哈希链、签名和异常事件留痕;
|
||||
- 运行状态、健康指标和告警。
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅用于软件工程学习、授权测试和合法的商户支付集成研究。个人收款、设备自动化和第三方平台接入可能受到平台条款、隐私保护和支付监管要求限制。部署者必须自行确认授权和合规性,不得用于绕过安全控制、未经授权访问账户、欺诈、洗钱或其他违法活动。
|
||||
@@ -0,0 +1,53 @@
|
||||
# Operations
|
||||
|
||||
本文档覆盖通用部署、备份、健康检查和账务异常处理。设备自动化只适用于授权测试环境,具体平台操作不在公开运维手册中展开。
|
||||
|
||||
## 部署原则
|
||||
|
||||
- 使用独立 Linux 用户和 `/opt/paygateway` 等专用目录;
|
||||
- `.env`、数据库、日志、备份和密钥只放在部署主机,不提交到仓库;
|
||||
- 使用 systemd 或受控容器运行,限制文件系统和网络权限;
|
||||
- 启动后先检查配置自检和 `/api/health`;
|
||||
- 生产升级前备份数据库并保留可回滚版本。
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
使用 `deploy/backup.sh` 通过 SQLite 在线备份方式复制业务库。备份目录必须设置访问权限,备份文件不能公开上传。恢复前停止服务、校验备份文件、保留当前数据快照,并在恢复后运行账本校验和健康检查。
|
||||
|
||||
## 健康检查
|
||||
|
||||
关注:
|
||||
|
||||
- HTTP 健康状态;
|
||||
- 数据库可读写状态;
|
||||
- 回调失败率;
|
||||
- 通知队列和异常池积压;
|
||||
- 账本追加与校验结果;
|
||||
- 后台任务崩溃恢复日志;
|
||||
- 授权设备的连接状态和读取器错误。
|
||||
|
||||
设备离线或读取结果不明确时,不应猜测业务已成功。相关订单应保持可追溯状态并进入人工核查流程。
|
||||
|
||||
## 订单与账务异常
|
||||
|
||||
异常处理遵循以下顺序:
|
||||
|
||||
1. 先保留原始订单和事件记录;
|
||||
2. 检查签名、金额、时间窗口和状态转换;
|
||||
3. 重复事件只执行一次;
|
||||
4. 无法确认的情况进入异常池;
|
||||
5. 人工处理必须留下操作者、原因和结果;
|
||||
6. 对账完成后运行账本链校验。
|
||||
|
||||
不要直接修改数据库中的订单状态或账本记录来“修复”问题。应使用应用层提供的补单、关闭或异常解决流程。
|
||||
|
||||
## 日志与隐私
|
||||
|
||||
- 日志不应包含密钥、完整支付凭证、个人信息、API Key 或原始设备标识;
|
||||
- 调试日志仅在授权环境短时启用;
|
||||
- 对外分享事故记录时只使用脱敏和聚合数据;
|
||||
- 日志和数据库备份按最小权限保存并设置生命周期。
|
||||
|
||||
## 合规边界
|
||||
|
||||
部署者必须自行确认商户资质、支付平台条款、隐私保护、数据留存和适用监管要求。设备自动化不得用于未经授权访问账户、绕过安全控制或其他违法活动。
|
||||
@@ -0,0 +1,388 @@
|
||||
# 动态关单与均衡分配:设计与实现基线
|
||||
|
||||
> 本文档记录动态关单和资源均衡的设计基线,代码以实际实现为准。
|
||||
> 关联:docs/DESIGN-FAIR-SCHEDULER.md(公平调度)、docs/MIANQIAN.md(设备自动化概览)、docs/RESILIENCE.md(对账兜底)
|
||||
> 目标:**不漏单(不可商量底线)+ 高/低负载自适应 + 多设备负载均衡 + 性能/稳定性兼顾**
|
||||
>
|
||||
> 本文档覆盖两个模块的开发设计:
|
||||
> - 模块一:WAIT_PAY 订单动态关单时间智能调度(含漏单路径修复)
|
||||
> - 模块二:免签账户订单自动均衡分配(设备 × 账户两层)
|
||||
> - 前置改造:WAIT_PAY 池从「全局单池」升级为「设备维度子池」
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状代码事实(设计依据)
|
||||
|
||||
### 1.1 关单链路(两条路径,安全检查不一致 ⚠️)
|
||||
|
||||
**路径 A:`order.Service.CloseExpiredOrders`**(internal/order/service.go:821,main.go:738 每 60s 触发)
|
||||
|
||||
```go
|
||||
for _, o := range expired {
|
||||
if ch, ok := channel.Lookup(o.Channel); ok && ch.ConfirmAbility() != channel.ConfirmByQuery {
|
||||
// 个人/监听通道:直接超时关单 —— 无离线检查、无最后复查 ⚠️
|
||||
if s.closeExpiredOrder(ctx, o, nil, "") { closed++ }
|
||||
continue
|
||||
}
|
||||
// 商家通道:关单前必查单 ...
|
||||
}
|
||||
```
|
||||
|
||||
**路径 B:`reconciler.Scheduler.confirmPersonal`**(internal/reconciler/scheduler.go:303)
|
||||
|
||||
```go
|
||||
if s.personalCheck != nil {
|
||||
offline, reason := s.personalCheck(ctx, o) // 有离线检查 ✅
|
||||
if offline {
|
||||
s.svc.EnqueueException(...) // 进异常池,绝不直接关
|
||||
return
|
||||
}
|
||||
}
|
||||
// 监听端在线 + 无推送 + 未超复查阈值 → 才关单
|
||||
```
|
||||
|
||||
**结论**:路径 A 对个人通道**直接关单**,绕过路径 B 的离线检查——**设备离线时用户已付款但未监听到,订单被硬关 = 钱收了单关了 = 漏单**。这是本设计首先要修的。
|
||||
|
||||
### 1.2 WAIT_PAY 池(internal/pool/waitpay_pool.go)
|
||||
|
||||
- 结构:`buckets[accountID][outTradeNo] *Entry` + `byNote` + `byAmount`,单 RWMutex,`atomic.Pointer[Snapshot]` 零锁快照
|
||||
- `Entry.CloseAt = CreatedAt + closeWindow`(**固定 30min**,`NewWaitPayPool(closeWindowMinutes)` 传入)
|
||||
- `Snapshot.Accounts[i] = {AccountID, Count, OldestAge, MinTTL}`(按账户聚合)
|
||||
- `DeviceScheduler`(ledgermon/scheduler.go:170)每设备一个 goroutine,读**全局快照**再过滤本设备账户(`onDevice` 映射)
|
||||
- 订单 → 账户 → 设备映射:订单只有 `AccountID`;设备绑定在 personal.db(`Device.AccountID` 主账户 + `DeviceAccountIDs` 附加账户)
|
||||
|
||||
### 1.3 选号链路(internal/channel/personal/personal.go:244)
|
||||
|
||||
```go
|
||||
func (c *Channel) selectServableAccount(ctx) *account.Account {
|
||||
servable := c.servableAccountIDs(ctx) // adb 在线设备的账户集(主+附加)
|
||||
return c.pool.SelectFrom(c.channelName(), "default", servable) // 加权随机,无负载感知
|
||||
}
|
||||
```
|
||||
|
||||
- `AccountPool.SelectFrom`(account/pool.go:268):`filterAvailable`(状态/日限额/冷却)→ priority 分组 → `weightedRandom`(Weight 加权)
|
||||
- **没有任何负载维度**:不感知设备上有多少待确认订单,不感知账户池内积压
|
||||
|
||||
### 1.4 可用信号清单(无需新增即可用)
|
||||
|
||||
| 信号 | 来源 | 用途 |
|
||||
|---|---|---|
|
||||
| 设备 adb 在线 | `adbmgr.Online` 实时探测 | 关单前检查、分配硬过滤 |
|
||||
| 每账户待确认数 | `WaitPayPool.Count(accountID)` | 账户负载 |
|
||||
| 每账户池快照 | `Snapshot.Accounts[i]` | 负载/最久等待/MinTTL |
|
||||
| 活跃账户数 | 池快照中 Count>0 的账户数 | 设备一轮耗时估算 |
|
||||
| 账户状态/失败数/日限额 | `AccountPool`(normal/cooling/frozen/disabled) | 账户健康 |
|
||||
| 单次 poll 成本 | 实测:支付宝≈4.3s、微信≈2.9s(dump 2.5s/OCR 0.7s) | 设备能力估算 |
|
||||
| 调度器参数 | `BaseQuota=3, CapPct=0.6, DwellMax=3, UrgentTTL=8s` | 一轮耗时估算 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计原则(不可商量)
|
||||
|
||||
1. **不漏单**:设备离线/未监听时绝不关单;关单前必须「最后复查」;宁可不关转人工,绝不误关。
|
||||
2. **动态窗口有物理锚点**:下限 = 用户支付操作时间 + 一轮完整确认(5min);上限 = 用户体验红线(10min),超过转人工升级处理,不是无限挂起。
|
||||
3. **按设备负载伸缩,非全局统一**:窗口由订单所在设备的实时负载决定。
|
||||
4. **设备是物理瓶颈**:分配先看设备(最闲最健康),再看设备内账户(最闲最健康);只惩罚短板,不叠加长处。
|
||||
5. **超限显式处理**:设备超限 → 换设备;全部超限 → 拒绝下单 + 日志告警建议加设备。
|
||||
6. **已付款用户秒级到账**:窗口只影响「未支付订单」何时关,不影响确认链路(notify/ledger 照常秒级入账)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 前置改造:WAIT_PAY 池 → 设备维度子池
|
||||
|
||||
### 3.1 数据结构
|
||||
|
||||
```go
|
||||
// internal/pool/waitpay_pool.go —— 总池改造成设备维度管理
|
||||
|
||||
type WaitPayPool struct {
|
||||
mu sync.RWMutex
|
||||
devices map[string]*devicePool // deviceID → 设备子池(新增)
|
||||
// 账户→设备 解析(main.go 注入;解析不到 → 归入 "" 兜底桶,Reconcile 修复)
|
||||
deviceOf func(accountID int64) (deviceID string, ok bool)
|
||||
snap atomic.Pointer[Snapshot] // 全局快照(notifywatcher 的 Total>0 判断用,保持)
|
||||
closeWindow time.Duration // 仅作兜底(设备信息缺失时)
|
||||
winCalc func(deviceLoad int) time.Duration // 动态窗口计算器(模块二注入;nil=固定窗口)
|
||||
}
|
||||
|
||||
type devicePool struct {
|
||||
buckets map[int64]map[string]*Entry
|
||||
byNote map[int64]map[string]string
|
||||
byAmount map[int64]map[int64]map[string]struct{}
|
||||
// 设备级聚合(每子池独立快照,调度器/分配器零锁读)
|
||||
stat atomic.Pointer[DeviceStat]
|
||||
}
|
||||
|
||||
type DeviceStat struct {
|
||||
DeviceID string
|
||||
Total int // 该设备 WAIT_PAY 总数(设备负载)
|
||||
Accounts []AccountStat // 该设备内按账户聚合(与现 AccountStat 同构)
|
||||
ActiveAccts int // 活跃账户数
|
||||
EstRound time.Duration // 估算一轮耗时 = ActiveAccts × 配额 × pollCost
|
||||
CloseWindow time.Duration // 当前动态窗口(模块二算好放这)
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 操作接口(保持对外语义不变,内部按设备分池)
|
||||
|
||||
| 现有方法 | 改造后行为 |
|
||||
|---|---|
|
||||
| `Add(o)` / `Upsert(o)` | 解析 `deviceID = deviceOf(o.AccountID)` → 写入对应子池;CloseAt 用**该设备当前动态窗口**计算 |
|
||||
| `Remove(outTradeNo)` | 遍历子池删除(防御性全表,语义同现在) |
|
||||
| `Snapshot()` | 全局聚合(Total = Σ 设备 Total),**保持**(notifywatcher / 调度器兼容) |
|
||||
| `Count(accountID)` | 不变(按账户计数) |
|
||||
| `MatchByNote / MatchByAmount` | 不变(账户维度匹配,跨设备天然隔离) |
|
||||
| `Rebuild / Reconcile / ReconcileLoop` | 遍历时按 deviceOf 分桶;Reconcile 兜底:解析不到设备的订单归入 `""` 桶并告警 |
|
||||
| **新增** `DeviceSnapshot(deviceID) *DeviceStat` | 设备级快照(零锁),供 DeviceScheduler 与分配器使用 |
|
||||
|
||||
### 3.3 关键改造点
|
||||
|
||||
1. **`orderToEntry`**:`CloseAt` 从固定 `closeWindow` 改为 `winCalc(deviceLoad)`——设备负载在 Add 时点计算。
|
||||
2. **`publishSnapshotLocked`**:先按设备分桶聚合,再汇总全局;同时发布每子池 `DeviceStat`。
|
||||
3. **`DeviceScheduler`**:`plan()` 改为读 `pool.DeviceSnapshot(s.deviceID)`(不再全局快照过滤),活跃账户判断与配额计算**只在本设备子池内进行** → 多设备天然隔离、互不干扰。
|
||||
4. **notifywatcher**:`Snapshot().Total > 0` 判断不变(全局快照保持兼容)。
|
||||
5. **注入 `deviceOf`**:main.go 构造(从 personal.db 设备 + `DeviceAccountIDs` 建 accountID→deviceID 映射;映射在设备增删时刷新)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 模块一:动态关单时间算法
|
||||
|
||||
### 4.1 窗口公式(物理锚点)
|
||||
|
||||
```
|
||||
设备负载 L = 该设备子池 WAIT_PAY 订单总数
|
||||
活跃账户数 A = 该设备子池内 Count>0 的账户数
|
||||
单次 poll 成本 P = 通道加权实测值(支付宝 4.3s / 微信 2.9s,默认取 4s 保守)
|
||||
一轮耗时 T = A × BaseQuota(3) × P ≈ 12s × A
|
||||
排队系数 = 1.5(最坏队尾位置)
|
||||
支付操作预留 = 60s(用户扫码→支付操作时间下限)
|
||||
|
||||
动态窗口 W = clamp( T × 1.5 + 60s, MIN=5min, MAX=10min )
|
||||
CloseAt = CreatedAt + W
|
||||
```
|
||||
|
||||
**单调性(防误关)**:`CloseAt` 在订单生命周期内**只增不减**。负载升高 → 允许推后;负载降低 → 不提前(保守,避免已排队确认的订单被提前关掉)。实现:Add 时记录 `CloseAt`,`RefreshCloseAt`(Upsert 触发)仅在 `newCloseAt > oldCloseAt` 时更新。
|
||||
|
||||
### 4.2 窗口取值表
|
||||
|
||||
| 设备状态 | L / A | T | W |
|
||||
|---|---|---|---|
|
||||
| 空闲(1 账户 1 单) | A=1 | 12s | **5min(下限)** |
|
||||
| 正常(2 账户各有单) | A=2 | 24s | **5min** |
|
||||
| 高峰(4 账户多单) | A=4 | 48s | **5~6min** |
|
||||
| 高负载(6 账户积压) | A=6 | 72s | **~7min** |
|
||||
| 接近超限 | A≥8 | ≥96s | **8~10min** |
|
||||
| 超限 | L ≥ C | — | **10min(上限)→ 换设备** |
|
||||
|
||||
### 4.3 漏单路径修复(最高优先级,先行落地)
|
||||
|
||||
**改造 `CloseExpiredOrders`(order/service.go:821)**:
|
||||
|
||||
```go
|
||||
// 个人/监听通道:不再直接关单,改为「复查后关」——与 confirmPersonal 同语义
|
||||
if ch.ConfirmAbility() != channel.ConfirmByQuery {
|
||||
// ① 设备离线检查(注入 personalCheck,与 reconciler 共用)
|
||||
if s.personalCheck != nil {
|
||||
offline, reason := s.personalCheck(ctx, o)
|
||||
if offline {
|
||||
s.EnqueueException(ctx, o, ExReasonListenOffline, "LISTEN_OFFLINE", "", ...,
|
||||
"监听端掉线,无法确认是否已付款,需人工核实: " + reason)
|
||||
continue // 不关单
|
||||
}
|
||||
}
|
||||
// ② 关单前最后复查(新增):检查池子是否还有该订单 + 复查次数上限
|
||||
if !s.poolStillHolds(ctx, o.OutTradeNo) { continue } // 已入账/已移除,跳过
|
||||
// ③ 复查次数超限 → 进异常池(复用 confirmPersonal 的 QueryFailAlert 语义)
|
||||
// ④ 确认未支付 → 关单(走 closeExpiredOrder,含池同步)
|
||||
}
|
||||
```
|
||||
|
||||
> 语义统一:个人通道关单**只有一条安全路径**(设备在线 + 无新记录 + 未超复查上限)。`CloseExpiredOrders` 是 DB 扫描兜底(防 confirmSched 堆丢失),`confirmPersonal` 是事件驱动主路径,两者最终都收敛到同一安全检查。
|
||||
|
||||
**新增 `personalCheck` 注入**:main.go 组装(`reconciler.AsPersonalCheck(personalDB, adbMgr())`),order.Service 与 reconciler.Scheduler 共用同一检查函数,避免两处逻辑分叉。
|
||||
|
||||
### 4.4 关单扫描改造
|
||||
|
||||
- 扫描器(main.go:738):`before` 从 `now - 固定30min` 改为 `now - MAX_WINDOW(10min) - 裕量(1min)`,候选集放宽到上限内所有 WAIT_PAY。
|
||||
- 命中候选后:在池子中查该订单的 `CloseAt`(动态),`now < CloseAt` → 跳过;`now ≥ CloseAt` → 走 §4.3 复查流程。
|
||||
- `ORDER_CLOSE_INTERVAL` 从 60s 收紧到 **30s**(窗口变小,扫描粒度需跟上)。
|
||||
|
||||
### 4.5 设备超限处理(配合模块三)
|
||||
|
||||
```
|
||||
设备能力 C = (MAX_WINDOW - 支付操作预留) / (单次poll × 每账户最低配额1) × 排队修正
|
||||
= (600s - 60s) / (4s × 1) ≈ 135 单(理论值,可按实测 poll 成本校准)
|
||||
|
||||
L ≥ C×80% → 分配器降权该设备(模块三)
|
||||
L ≥ C → 分配器跳过该设备(换设备处理)
|
||||
全部设备超限 → 下单返回「设备负载超限,暂无可服务设备」+ 日志 ERROR「设备负载超限,建议增加收款设备」
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 模块二:免签账户订单自动均衡分配
|
||||
|
||||
### 5.1 两层分配模型
|
||||
|
||||
```
|
||||
下单(个人通道 CreatePayment)
|
||||
→ 第一层:选设备(最闲 + 最健康)
|
||||
→ 第二层:该设备内选账户(最闲 + 最健康)
|
||||
→ 现有 filterAvailable 安全过滤始终保留(状态/日限额/冷却)
|
||||
```
|
||||
|
||||
### 5.2 第一层:设备评分(物理瓶颈优先)
|
||||
|
||||
```go
|
||||
// internal/channel/personal/ 新增 deviceSelector(或 internal/pool 提供负载查询后在此实现)
|
||||
type DeviceCandidate struct {
|
||||
DeviceID string
|
||||
Serial string
|
||||
Load int // 设备子池 WAIT_PAY 总数(DeviceSnapshot().Total)
|
||||
Health float64 // 最近 N 次 poll 成功率(埋点,见 §6)
|
||||
}
|
||||
|
||||
score_device = Health × 1 / (Load + 1)
|
||||
|
||||
// 规则:
|
||||
// - 设备 adb 离线 → 排除(现有 servableAccountIDs 已过滤)
|
||||
// - Load ≥ C(超限)→ 排除(换设备)
|
||||
// - 其余按 score_device 降序选最优;得分并列 → 按 Load 升序;再并列 → 确定性(DeviceID 字典序)
|
||||
```
|
||||
|
||||
**设备健康度埋点**(新增,§6.2):`ledgermon.DeviceActor` 每次 PollCmd 执行记录成败,滑动窗口最近 20 次,`Health = 成功数/20`。设备无历史(<5 次)→ `Health = 0.9` 中性(不因无数据饿死新设备)。
|
||||
|
||||
### 5.3 第二层:账户评分(设备内)
|
||||
|
||||
```go
|
||||
score_account = Health × 1 / (Count + 1)
|
||||
|
||||
// Health(账户健康,只罚短板):
|
||||
// - 状态 normal=1(cooling/frozen/disabled 已由 filterAvailable 排除)
|
||||
// - 日限额余量:1 - DailyUsed/DailyLimit(DailyLimit=0 视为 1)
|
||||
// - 失败折扣:FailCount>=3 → ×0.2;FailCount>=1 → ×0.5(复用现 ReportResult 计数)
|
||||
// Count = WaitPayPool.Count(accountID)(账户负载)
|
||||
```
|
||||
|
||||
### 5.4 与现有代码的落点
|
||||
|
||||
改造 `selectServableAccount`(personal.go:244):
|
||||
|
||||
```go
|
||||
func (c *Channel) selectServableAccount(ctx) *account.Account {
|
||||
// ① 现有:adb 在线设备 + 账户集(硬过滤,保留)
|
||||
devices, acctIDs := c.servableDevicesAndAccounts(ctx) // 从 servableAccountIDs 拆出设备维度
|
||||
// ② 设备超限过滤(Load ≥ C → 剔除设备及其账户)
|
||||
devices = filterOverloaded(devices)
|
||||
if len(devices) == 0 {
|
||||
logger.Error("personal", "设备负载超限,建议增加收款设备", ...)
|
||||
return nil
|
||||
}
|
||||
// ③ 第一层:选设备(score_device 最高)
|
||||
dev := pickDevice(devices)
|
||||
// ④ 第二层:该设备内选账户(score_account 最高,替代 weightedRandom)
|
||||
return c.pool.SelectFromWeighted(c.channelName(), "default", acctIDsOf(dev),
|
||||
func(id int64) float64 { return accountScore(c.pool, id, c.loadOf, c.healthOf) })
|
||||
}
|
||||
```
|
||||
|
||||
> `SelectFrom` 保留(管理后台/其他调用不受影响),新增 `SelectFromWeighted`(带评分函数参数,替换 weightedRandom 为 argmax 评分)。备注码分配器**不动**(全局唯一 + TTL 5min,均衡分配不改变码的唯一性;byNote 索引按 accountID 隔离,同码跨账户天然不冲突)。
|
||||
|
||||
### 5.5 振荡防护
|
||||
|
||||
- 评分分母 `Load+1` 天然带惯性:分配后目标负载上升 → 分数下降 → 下次转移,无需额外冷却。
|
||||
- 设备健康度滑动窗口(20 次)平滑抖动,不因单次 poll 失败跳变。
|
||||
- 多设备间负载在 `C×80%` 阈值内自然均衡,不会在两台设备间反复横跳(每单只分配一次,分配后负载立即反映)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 新增埋点(轻量,均为内存滑动窗口,不落库)
|
||||
|
||||
| 埋点 | 位置 | 实现 |
|
||||
|---|---|---|
|
||||
| 设备 poll 成功率 | `ledgermon.DeviceActor`(PollCmd 执行处) | 环形缓冲 20 次,`RecordPoll(ok bool)`;`PollHealth() float64` |
|
||||
| 设备一轮实际耗时 | `DeviceScheduler` 窗口结束 | `RecordRound(dur)`;滑动平均(用于校准 pollCost 而非写死 4s) |
|
||||
| 通知栏快路径命中 | `notifywatcher.Handle` 成功匹配时 | 每设备计数器(10min 窗口),供设备健康加成(可选,v0.1 不接) |
|
||||
| 设备超限事件 | 分配器拒绝时 | 日志 ERROR + 可选 Bark/Webhook(复用 alert.Client) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施步骤(顺序即依赖)
|
||||
|
||||
| 步骤 | 内容 | 涉及文件 | 验收 |
|
||||
|---|---|---|---|
|
||||
| **S1 漏单路径修复** | 个人通道关单统一安全检查(personalCheck 注入 + 关单前复查) | order/service.go、reconciler/、main.go | 设备离线时订单绝不关单(单测 + 模拟) |
|
||||
| **S2 池子设备维度改造** | deviceOf 注入、devicePool 分池、DeviceSnapshot、动态窗口计算器挂载 | pool/waitpay_pool.go、ledgermon/scheduler.go、main.go | 多设备各自独立快照;调度器只读本设备池 |
|
||||
| **S3 动态关单** | winCalc 实现(公式 §4.1)、CloseAt 单调更新、扫描器改造(30s/10min 窗口) | pool/、order/service.go、main.go | 窗口随负载在 5~10min 内变化;回归测试 |
|
||||
| **S4 均衡分配** | 设备评分 + 账户评分 + SelectFromWeighted、设备超限过滤 | personal.go、account/pool.go、新增 deviceSelector | 多设备订单分布均衡;超限设备不再接单 |
|
||||
| **S5 埋点** | 设备 poll 成功率 / 一轮耗时滑动统计 | ledgermon/ | 健康度随真实 poll 结果变化 |
|
||||
| **S6 全量验证** | 编译 + vet + 单测 + 资金链路测试 + 真机验证 | — | 见 §8 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
### 模块一(动态关单)
|
||||
1. [ ] 设备离线时,个人通道 WAIT_PAY 订单**不关闭**(转异常池人工核实),无漏单
|
||||
2. [ ] 高峰(4+ 活跃账户)订单窗口自动放宽至 ≥6min;空闲(1 账户)收敛至 5min 下限
|
||||
3. [ ] 窗口恒定在 [5min, 10min] 内,不无限低/无限高
|
||||
4. [ ] 设备负载 ≥ C 时新订单不再路由该设备;全部超限时下单拒绝 + 日志告警
|
||||
5. [ ] 已付款订单在任何负载下都秒级入账(窗口不影响确认链路)
|
||||
6. [ ] 回归:`go build ./... && go vet ./... && go test ./... -count=1` 全绿
|
||||
|
||||
### 模块二(均衡分配)
|
||||
1. [ ] 多设备下订单按设备负载均衡分布,不出现单设备积压
|
||||
2. [ ] 某设备超限/离线 → 流量自动转移至健康设备
|
||||
3. [ ] 设备内多账户按账户负载 + 日限额余量分配,限额快满账户自动降权
|
||||
4. [ ] 备注码全局唯一性在均衡分配下不退化(byNote 账户隔离验证)
|
||||
5. [ ] 分配失败(无可用设备/账户)返回明确错误,不留死单
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与兜底
|
||||
|
||||
| 风险 | 等级 | 兜底 |
|
||||
|---|---|---|
|
||||
| 动态窗口计算器 bug 导致窗口过短 | 高 | 下限 clamp 5min 硬保护 + 单调只增 + 关单前复查三重防线 |
|
||||
| 设备→账户映射漂移(订单在池中但设备已删) | 中 | `""` 兜底桶 + Reconcile 修复 + 告警 |
|
||||
| poll 成本估算偏差(真机实测 vs 4s 默认) | 中 | §6 一轮实际耗时埋点校准,env 可配 `DEVICE_POLL_COST_MS` |
|
||||
| 均衡分配振荡 | 低 | Load+1 分母天然惯性 + 健康度滑动窗口 |
|
||||
| 全设备超限误拒单 | 低 | 超限阈值 C×80% 降权先于 C 硬拒绝,有缓冲带 |
|
||||
| 关单扫描与 confirmSched 双路径竞态 | 中 | 两者收敛同一安全检查 + UpdateOrderStatus 条件更新(WHERE status=WAIT_PAY)幂等 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 配置项(.env 新增)
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `ORDER_CLOSE_MIN` | `5` | 动态关单窗口下限(分钟) |
|
||||
| `ORDER_CLOSE_MAX` | `10` | 动态关单窗口上限(分钟) |
|
||||
| `ORDER_CLOSE_INTERVAL` | `30` | 关单扫描间隔(秒,从 60 收紧) |
|
||||
| `DEVICE_POLL_COST_MS` | `4000` | 单次 poll 成本估算(ms,埋点校准后可不配) |
|
||||
| `DEVICE_LOAD_CAP` | `0` | 设备订单承载上限(0=按公式自动算) |
|
||||
| `DEVICE_OVERLOAD_RATIO` | `0.8` | 设备降权阈值(占 C 的比例) |
|
||||
| `DEVICE_HEALTH_WINDOW` | `20` | 设备健康度滑动窗口(次) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 生产上线前代码审查修复记录(2026-08-31)
|
||||
|
||||
Oracle 只读审查发现的阻断级与中等问题,均已修复并验证:
|
||||
|
||||
| # | 级别 | 问题 | 修复 |
|
||||
|---|------|------|------|
|
||||
| P0 | 🔴 崩溃 | `waitpay_pool.go` Add 在锁外调 `orderToEntry`→`windowFor` 遍历 `dp.buckets`,与并发写操作形成 map 竞态(可致 fatal crash);`-race` 未捕获因并发测试未设 winCalc | `Add` 内 `orderToEntry` 移入写锁;并发测试注入 winCalc+deviceOf 以覆盖该路径 |
|
||||
| P1 | 🔴 功能 | 扫描器候选集 `max(OrderCloseMinutes, dynMax+1min)` 使动态窗口完全失效:默认 30min>11min 时个人订单 5-10min 到期不进候选集 | 候选集改 `min(OrderCloseMinutes, dynMin)`,个人订单 age≥dynMin 起进入,由池内 `CloseAtOf` 精确过滤 |
|
||||
| P2-1 | 🟡 资金 | `CloseExpiredOrders` 个人分支在线直接关单,绕过 `confirmPersonal` 的 LISTEN_TIMEOUT 复查保护(监听端僵死时误关已付款订单) | 在线且已有 LISTEN_TIMEOUT 异常记录 → 进异常池人工核实,不直接关单 |
|
||||
| P2-2 | 🟡 资金 | `Add` 覆盖未删旧设备桶:设备映射变更后同订单残留双设备子池,负载虚高 | `unindexLocked(old)` 后显式 delete 旧桶 + 清理空桶/`acctDev` |
|
||||
| P2-3 | 🟡 一致 | `balanceSelectAccount` 直读 `cfg.OrderCloseMax` 绕过配置退化逻辑(静态窗口下超限过滤仍生效) | `deviceLoadCapOf` 内重复校验 MIN/MAX 合法性,非法 → 返回 0(不启用过滤) |
|
||||
| P2-4 | 🟡 性能 | 扫描器对每个到期订单逐次 `adb Online()` 探测,无设备级缓存 | `CloseExpiredOrders` 内扫描级设备在线缓存(仅缓存已绑定设备,单次扫描内复用) |
|
||||
|
||||
**验证**:`go build`/`go vet`/14 包测试全绿;`go test -race ./internal/pool/ ./internal/order/ ./internal/ledgermon/` 全绿;新增/强化单测:`TestWaitPayPool_ConcurrentStorm`(winCalc 路径)、`TestCloseExpiredOrders_PersonalOnline_StalledListener`、`TestCloseExpiredOrders_Merchant_RespectsFixedWindow`/`OverFixedWindow`。
|
||||
|
||||
**记录为已知限制(非阻断,后续加固)**:
|
||||
- ~~全代码库 0 处 `recover()`~~ **[已修复 2026-08-31]**:8 个后台 goroutine 已加 `defer recover`(关单扫描器/收款码清理+续期/账本日任务/Cloudflare Tunnel/设备加锁/视频转发/告警发送),main.go 统一 `bgRecover` helper 记录 panic+栈;同时修复 notifywatcher 测试闭包变量的 DATA RACE(`orderDriven` 改 atomic.Bool)
|
||||
- `SetWindowCalc` 原地修改已发布 Entry 的 CloseAt:仅启动时调用一次,无并发读者,实际无害
|
||||
- 动态窗口公式 `clamp(active×12s×1.5+60s, 5min, 10min)` 依赖 `DEVICE_POLL_COST_MS=4000` 估算,真机实测可校准
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
# 支付网关容灾与对账体系设计 v3(主动查账 · 事件驱动 · 三级兜底 · 人工异常池)
|
||||
|
||||
> 版本:v3.0(2026-08-09)
|
||||
> 设计约束:
|
||||
> - 临时隧道是防攻击特性,不引入固定域名;回调丢失是常态,主动查账是核心保障。
|
||||
> - 兜底分**三级**:① 自动查单补账(ConfirmScheduler)→ ② 退避重试(查单/通知)→ ③ **人工异常处理池**(自动化兜不住时的最终兜底)。
|
||||
> - 所有资金写入(含人工处理)只走幂等入口(SettlePayment / CloseOrder),不绕过状态机、不手改账本。
|
||||
> - 高性能:事件驱动 + timer heap,不轮询;不出错:幂等 + 关单前查单 + 冲突进差错。
|
||||
|
||||
---
|
||||
|
||||
## 一、设计哲学
|
||||
|
||||
1. **回调是优化,查单是保障** —— 快路径(回调入账)与慢路径(查单补账)殊途同归,都进幂等 `SettlePayment`。
|
||||
2. **数据经过网关,就有信息返回** —— 每个环节可自我验证,异常即触发兜底。
|
||||
3. **三级兜底**:自动补偿 → 退避重试 → **人工异常池**。自动化兜不住才进人工,人工操作也走同一幂等入口。
|
||||
|
||||
---
|
||||
|
||||
## 二、每环节"正常路径 vs 兜底"
|
||||
|
||||
| 环节 | 正常 | 异常→兜底触发 |
|
||||
|------|------|----------------|
|
||||
| ① 上游→网关(下单) | 验签→建单→返回凭据 | 验签/参数失败→拒绝并记录(无资金影响) |
|
||||
| ② 用户→支付宝(支付) | 回调→入账(快路径) | **回调丢失→ConfirmScheduler 到期→查单补账(慢路径)** |
|
||||
| ③ 网关→上游(通知) | 通知 200→标记 | 失败→outbox 重试 31h→dead→人工重发 |
|
||||
| ④ 日终对账 | 一致 | 不一致→差错单→人工 |
|
||||
| ⑤ **任何环节异常不明** | — | **进人工异常池**(兜底链终点) |
|
||||
|
||||
---
|
||||
|
||||
## 三、核心模块:ConfirmScheduler(订单确认调度器)
|
||||
|
||||
内存 min-heap(按 nextCheckAt 排序),事件驱动,**非轮询**。
|
||||
|
||||
```
|
||||
OnOrderCreated → push(now + T1 60s)
|
||||
OnCallbackReceived → remove + SettlePayment(幂等)
|
||||
到期 Confirm(tradeNo):
|
||||
├─ 已 SUCCESS → remove
|
||||
├─ 仍 WAIT 未到关单时间 → QueryOrder 查单
|
||||
│ ├─ TRADE_SUCCESS → SettlePayment 补账 → 通知 → remove
|
||||
│ ├─ 未支付 → push(+30s~5m 自适应)
|
||||
│ └─ 查单失败 → push(+退避),连续失败 N 次 → 进异常池(query_failed)
|
||||
└─ 到关单时间 → 最后查一次:已支付补账;未支付 CloseOrder;查单失败 → 推迟关单 + 进异常池
|
||||
```
|
||||
|
||||
冲突(查单金额≠订单金额、状态矛盾)→ **不自动入账** → 进异常池(amount_mismatch / state_conflict)。
|
||||
|
||||
---
|
||||
|
||||
## 四、人工异常处理池(Manual Exception Pool)★新增核心
|
||||
|
||||
### 4.1 定位
|
||||
**自动化兜底失败后的最终兜底**:任何"不按正常流程"的订单(回调全丢且查单不明、金额矛盾、关单存疑、通知 dead 无法重发)自动进入本池。人工核对证据 → 按流程填写必要信息 → 提交后经幂等入口恢复为正常订单,**订单永久标记"人工处理"**,全程审计。
|
||||
|
||||
### 4.2 数据模型(新表 + orders 标记)
|
||||
|
||||
```sql
|
||||
-- 异常订单池(只增不改,resolved 后可 reopen,不可删除)
|
||||
CREATE TABLE exception_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
out_trade_no TEXT NOT NULL UNIQUE, -- 关联订单
|
||||
pid TEXT, upstream_name TEXT, channel TEXT,
|
||||
amount_minor INTEGER, -- 网关记录金额(分)
|
||||
order_status TEXT, -- 当前订单状态
|
||||
channel_status TEXT, -- 最近查单结果(TRADE_SUCCESS/CLOSED/QUERY_FAILED/UNKNOWN)
|
||||
channel_tx_id TEXT, -- 查单拿到的通道交易号
|
||||
paid_amount INTEGER, -- 人工确认的实际支付金额(默认=amount_minor)
|
||||
payer TEXT, -- 人工确认的付款方
|
||||
reason TEXT, -- query_failed / amount_mismatch / state_conflict / close_uncertain / notify_dead / manual
|
||||
phase TEXT DEFAULT 'pending', -- pending / processing / resolved
|
||||
resolved_action TEXT, -- settle / close / noop / refund
|
||||
evidence TEXT, -- 证据时间线 JSON(自动收集,不可手改)
|
||||
suggestion TEXT, -- 系统建议动作(依据最近查单结果)
|
||||
handler TEXT, handler_note TEXT,
|
||||
resolved_at TEXT, updated_at TEXT,
|
||||
created_at TEXT
|
||||
);
|
||||
|
||||
-- orders 表新增人工处理标记(列表/详情可见,审计可追溯)
|
||||
ALTER TABLE orders ADD COLUMN exception_id INTEGER DEFAULT 0; -- 关联异常池
|
||||
ALTER TABLE orders ADD COLUMN handled_by TEXT DEFAULT ''; -- 处理人
|
||||
ALTER TABLE orders ADD COLUMN handled_action TEXT DEFAULT ''; -- manual_settle / manual_close / noop
|
||||
ALTER TABLE orders ADD COLUMN handled_at TEXT DEFAULT '';
|
||||
ALTER TABLE orders ADD COLUMN handled_note TEXT DEFAULT '';
|
||||
|
||||
-- 操作审计(谁在何时对哪个异常单做了什么)
|
||||
CREATE TABLE exception_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
exception_id INTEGER, action TEXT, operator TEXT, detail TEXT, created_at TEXT
|
||||
);
|
||||
```
|
||||
|
||||
### 4.3 自动进池触发点(ConfirmScheduler / notify 集成)
|
||||
|
||||
| 触发 | reason | 进入时自动收集的证据 |
|
||||
|------|--------|----------------------|
|
||||
| 查单连续失败 ≥ `query_fail_alert`(5 次) | query_failed | 下单/回调/通知记录 + 每次查单结果时间线 |
|
||||
| 查单金额 ≠ 订单金额 | amount_mismatch | 查单快照(金额、交易号、时间) |
|
||||
| 回调与查单状态矛盾 | state_conflict | 两方原始数据 |
|
||||
| 关单时查单失败(存疑) | close_uncertain | 查单错误信息 |
|
||||
| 通知 dead 且重发仍失败 | notify_dead | 通知任务重试历史 |
|
||||
| 管理后台手动加入 | manual | 人工填写的说明 |
|
||||
|
||||
进池时同时**暂停 ConfirmScheduler 对该订单的自动动作**(避免人工处理与自动查单打架),订单转入"待人工"。
|
||||
|
||||
### 4.4 人工处理流程(管理后台「异常订单」页)
|
||||
|
||||
```
|
||||
① 列表(筛选 phase/reason/上游/日期)→ 打开详情
|
||||
② 详情:基本信息 + 证据时间线(自动收集,只读)+ 系统建议动作
|
||||
(可点「再次查单」刷新证据:调用通道查单,把最新结果并入时间线)
|
||||
③ 填写处理表单:
|
||||
动作:settle(确认入账)/ close(确认未支付,关单)/ noop(无需处理)
|
||||
实际支付金额(默认=订单金额,可改)
|
||||
通道交易号(可从支付宝账单人工核对后填入)
|
||||
付款方(可选)
|
||||
备注
|
||||
④ 提交 → 后端校验(金额>0、订单未处理过、动作合法、表单与证据不冲突)
|
||||
→ 执行:
|
||||
settle → Service.SettlePayment(幂等,参数含人工金额/交易号/付款方/来源=manual)
|
||||
→ 成功自动创建通知任务 → 通知上游到账
|
||||
close → Service.CloseOrder(幂等,单笔)
|
||||
noop → 仅记录
|
||||
→ 成功:orders 写 handled_* 标记 + exception_orders 置 resolved + exception_logs 记审计
|
||||
⑤ 该订单在列表/详情永久显示「⚠️ 人工处理」徽标(含处理人/时间/备注/动作)
|
||||
```
|
||||
|
||||
### 4.5 防呆与安全
|
||||
|
||||
| 项 | 设计 |
|
||||
|----|------|
|
||||
| 并发防重 | 处理时 `phase=pending→processing` 行锁,同一订单不可并发处理;resolved 后不可再处理(可管理员 reopen) |
|
||||
| 幂等入口 | settle/close 一律走 Service 幂等方法,**绝不直接 UPDATE 订单状态/金额** |
|
||||
| 金额校验 | 人工金额必须 >0 且为整数分;与查单金额不一致时强制二次确认(输入框要求重复输入) |
|
||||
| 证据只读 | evidence 自动收集,人工不可编辑(防掩盖);人工信息只写在 paid_amount/payer/handler_note |
|
||||
| 权限 | 异常池操作走管理后台认证;敏感动作(settle)可加二次确认口令(可配置) |
|
||||
| 审计 | exception_logs 全程留痕:谁、何时、看了什么、填了什么、做了什么 |
|
||||
| 账本 | settle 走 SettlePayment 自动过账本 hash 链;人工无法绕账本 |
|
||||
|
||||
### 4.6 与上游通知不到的联动
|
||||
人工 settle 成功后自动创建通知任务 → 若上游仍不可达 → outbox 重试 31h → dead → 此时订单已 SUCCESS,**dead 通知单独成清单**("上游未确认"),可在异常池关联查看/重发;上游也可主动调 `/api.php` 查单(网关数据正确,拉取是上游的事)。
|
||||
|
||||
---
|
||||
|
||||
## 五、分阶段落地
|
||||
|
||||
| 阶段 | 内容 | 说明 |
|
||||
|------|------|------|
|
||||
| **P0-1** | 通知重试增强(31h 窗口)+ dead 管理 API | 修复"付钱未到账" |
|
||||
| **P0-2** | ConfirmScheduler 最小版(timer heap + 查单补账 + 关单前查单) | 回调丢失自动兜底 |
|
||||
| **P0-3** | systemd 守护(gateway+new-api)+ 每日 sqlite 备份 | 进程/数据安全 |
|
||||
| **P1-1** | **人工异常处理池**(表 + 进池触发 + 处理流程 + 管理后台页) | 最终兜底 + 人工台 |
|
||||
| **P1-2** | 启动配置自检(沙箱/正式、密钥、回调地址) | is_prod 错配早暴露 |
|
||||
| **P1-3** | 告警(Bark/Webhook:查单连续失败、验签失败率、dead、账本失败、**进池事件**) | 可观测 |
|
||||
| **P2-1** | 日终对账报告(网关 vs 通道 vs 上游) | 资金最终核对 |
|
||||
|
||||
> **存量订单 116**:P0-2 落地后由 ConfirmScheduler 自然发现;若查单亦失败(极端),P1-1 人工池接管——人工从支付宝账单核对后 settle 恢复,标记人工处理。
|
||||
|
||||
---
|
||||
|
||||
## 六、模块与现有代码对应
|
||||
|
||||
| 新组件 | 复用 | 新增 |
|
||||
|--------|------|------|
|
||||
| `reconciler.ConfirmScheduler` | `Channel.QueryOrder`、`Service.SettlePayment`、`CloseExpiredOrders` | timer heap、事件接口 |
|
||||
| `reconciler.ExceptionPool` | admin 框架、审计日志模式、SettlePayment/CloseOrder | exception 表、进池触发、处理流程 API |
|
||||
| notify 增强 | `notify.Worker`、`notify_tasks` | 重试参数、dead 管理 API |
|
||||
| admin 页面 | admin 框架 | 异常订单池 Vue 页(证据时间线 + 处理表单) |
|
||||
| systemd + 备份 | gateway_autostart.sh | service 单元、备份脚本 |
|
||||
|
||||
**核心承诺**:所有资金写入(自动或人工)只走 `SettlePayment` / `CloseOrder` 等幂等方法;人工处理同样过账本 hash 链、同样触发通知、同样留审计;订单永久可追溯"是否人工处理过、谁处理的、依据什么"。
|
||||
Reference in new issue
Block a user