PayGateway: epay-compatible Go payment gateway with plugin channels and verifiable ledger

This commit is contained in:
lim39869-create committed 2026-09-28 11:49:29 +08:00
commit f2ae88b4e9
201 files changed
+44898

No files matched your search

+560
View File
@@ -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. **交易不可篡改**:哈希链 + 签名 + 只增不改 + 每日快照 → 任何篡改必被发现
+25
View File
@@ -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` 文件。
+20
View File
@@ -0,0 +1,20 @@
# 公平设备调度:设计概览
本文档描述多任务设备池的通用调度原则,不包含特定平台的账号、页面或操作细节。
## 目标
- 在设备能力上限内分配任务;
- 避免单个账户或任务长期占用设备;
- 设备异常时快速降权或排除;
- 让调度结果可解释、可观测、可测试。
## 评分因素
调度器可以综合考虑当前负载、历史任务耗时、设备健康状态、最近失败次数、账户冷却时间、任务优先级、设备能力上限和队列长度。
调度器只负责选择可用执行资源,不负责确认业务事实。业务确认仍由订单状态机、匹配器和审计账本完成。
## 安全边界
设备操作必须经过 actor 串行化和授权检查。设备离线、任务超时或结果不明确时,调度器应停止继续分配并生成健康事件,而不是猜测任务已成功。
+224
View File
@@ -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` 产物一致(单一来源)。
+86
View File
@@ -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 保证只能由一方获胜,不重复结算、不重复创建新单。
旧版“同号改金额并刷新凭据”的测试已改为金额不可变测试,避免把事故逻辑重新锁定为正确行为。
+39
View File
@@ -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/租约式设备访问串行化;
- 设备离线与任务超时的安全降级;
- 事件指纹、窗口匹配和重复回调幂等;
- 设备负载驱动的任务调度;
- 账本哈希链、签名和异常事件留痕;
- 运行状态、健康指标和告警。
## 免责声明
本项目仅用于软件工程学习、授权测试和合法的商户支付集成研究。个人收款、设备自动化和第三方平台接入可能受到平台条款、隐私保护和支付监管要求限制。部署者必须自行确认授权和合规性,不得用于绕过安全控制、未经授权访问账户、欺诈、洗钱或其他违法活动。
+53
View File
@@ -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 或原始设备标识;
- 调试日志仅在授权环境短时启用;
- 对外分享事故记录时只使用脱敏和聚合数据;
- 日志和数据库备份按最小权限保存并设置生命周期。
## 合规边界
部署者必须自行确认商户资质、支付平台条款、隐私保护、数据留存和适用监管要求。设备自动化不得用于未经授权访问账户、绕过安全控制或其他违法活动。
+388
View File
@@ -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` 估算,真机实测可校准
+173
View File
@@ -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 链、同样触发通知、同样留审计;订单永久可追溯"是否人工处理过、谁处理的、依据什么"。