docs: 同步引擎设计定稿,API 时刻改为 Unix 秒

独立 apps/engine、setTimeout 轮次、engine_status 快照、不为 CFW 预留;
公共约定 §4 响应时刻统一 Unix 秒;契约 engine 增 sync_interval_ms。
This commit is contained in:
2026-10-07 12:53:37 +08:00
parent de48e5d39c
commit 3e12db1706
5 changed files with 213 additions and 6 deletions
+4 -3
View File
@@ -56,9 +56,10 @@
## 4. 时间口径
- **传输格式:ISO 8601 UTC 字符串**(如 `2026-10-02T14:22:05Z`)——自描述;存储层维持 UTC Unix 秒(表设计约定),DTO 层转换;北京时间展示由前端完成(共性 §3.3)
- **时间范围参数**:`from` / `to`,秒级 ISO 8601 UTC,**左闭右开 `[from, to)`**——快捷项边界无歧义、无跨桶重复计数
- 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数;「本订阅期间」的期间数据来源归后两篇各自定义
- **响应时刻字段:Unix 秒整数**(UTC 时刻,如 `1780000000`)——无时区歧义;本地/北京时间由展示层转换
- **请求时间参数**(`from` / `to` 等):ISO 8601 UTC 字符串(便于试调);范围 **左闭右开 `[from, to)`**
- 存储层维持 timestamptz / 上游 Unix 秒;DTO 输出统一 `toUnixSec`
- 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数
## 5. 数值口径(对齐共性 §3.2)
+4 -2
View File
@@ -67,6 +67,7 @@
```json
{
"running": true,
"sync_interval_ms": 300000,
"last_round": {
"started_at": "2026-10-02T08:00:00Z",
"keys_total": 18, "succeeded": 16, "failed": 2,
@@ -75,7 +76,8 @@
}
```
`failed_api_key_ids` 支撑 Key 管理「最近轮次失败」筛选与健康带失败数跳转;`last_round` 为 `null`(引擎尚未跑过)合法。**状态存储与字段细节归同步引擎设计(待立稿),两稿以本端点字段为衔接对齐点。**
`sync_interval_ms`:当前同步周期(毫秒),**只读观测**,经 env 配置、无修改接口。
`failed_api_key_ids` 支撑 Key 管理「最近轮次失败」筛选与健康带失败数跳转;`last_round` 为 `null`(引擎尚未跑过)合法。状态存储为 `engine_status` 单行快照(《同步引擎设计》§5)。
## 4. 全员总览
@@ -264,5 +266,5 @@
1. **管理面日志明细的界面落点**——**已裁决:实现期定**(先做「跳 Key 管理定位到该行」,详情视图待真实数据再定);
2. **审计日志表结构**——归技术稿,以本文 §9 DTO 字段为对齐锚点;
3. **`GET /api/admin/engine` 的状态存储**——归同步引擎设计(待立稿),两稿衔接对齐;
3. **`GET /api/admin/engine` 的状态存储**——已对齐《同步引擎设计》§5(`engine_status` 单行快照 + 只读 `sync_interval_ms`);
4. **令牌生成细节**(随机源 / 长度 / 前缀)——归技术稿。
+203
View File
@@ -0,0 +1,203 @@
# 同步引擎设计
> 母体形态下同步引擎的进程形态、调度与轮次语义:拉什么、写什么、失败怎么办、状态如何暴露。**范围声明(2026-10-05 作者裁决)**:① 本阶段**不考虑 Cloudflare Workers,也不为 CFW 做准备**;② 引擎**独立于 HTTP 服务进程**(`apps/engine`),避免后台 CPU 与 API 抢事件循环;③ 调度用**手写 `setTimeout` 循环**(不引入 cron 库),周期**可配置**。字段与去重的权威仍在七篇表设计;本文定「引擎怎么转」。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-05) |
| 关联 | 《api_keys表字段设计》§4 写入时机 · 《usage_logs表字段设计》§3.1 去重 · 《qpu_history表字段设计》§4 采样 · 《API契约-管理面》§3 `engine` 端点 · 《技术选型》§4 monorepo(本文增补 `apps/engine`)· `docs/references/NewAPI-接口范围.md` |
| 产出 | 进程形态 · 调度与轮次语义 · 失败与观测 · 与 `/api/admin/engine` 的状态衔接 · 配置与验收 |
---
## 1. 进程形态(与后端分离)
同步引擎是**独立入口进程**(`apps/engine`),与 `apps/server`(Hono)**分进程、可同机部署**:
```text
apps/server ──► HTTP(/api/admin · /api/member)──► packages/db
apps/engine ──► 定时 syncRound() ──► packages/db
│
└────────► packages/core/upstream(NewAPI)
```
| 约定 | 说明 |
|------|------|
| 共享 | `packages/core`(上游客户端、dedupe、折算)· `packages/db`(仓储)——**业务能力仍一份** |
| 不共享 | 进程内存、事件循环、调度器;HTTP 与引擎**互不调用** |
| 不调自家 API | 引擎只打上游 NewAPI + 写库;不走 `/api/admin/*` |
| 部署 | 开发:两个命令(`dev:server` / `dev:engine`);生产 compose 两个 service(均连同一 PostgreSQL) |
**为何分进程(2026-10-05 复审)**:一轮可能解析上千条日志并做指纹/加密/批量写,若与 API 同进程会争事件循环,查询延迟与「引擎是否在跑」绑死;定时器漂移本身对本业务无害,**互扰才是拆分动因**。
## 2. 调度(方案 A:手写 `setTimeout`)
不引入 `croner` / `node-cron` 等库;用标准定时器实现「一轮结束 → 约下一枪」。
```text
loop():
if inFlight: log warn; schedule(); return
inFlight = true
try:
await syncRound()
finally:
inFlight = false
schedule() // 本轮结束后再等 interval
schedule():
setTimeout(loop, SYNC_INTERVAL_MS)
```
| 项 | 约定 |
|----|------|
| 周期 | **`SYNC_INTERVAL_MS`**,默认 `300000`(5 分钟);由 env 配置,非法值启动失败 |
| 语义 | **结束后 + interval**(周期 = 间隔 + 上轮耗时);不追求整点 cron |
| 防堆积 | 上一轮未结束则跳过 / 不叠加(`inFlight`);**禁止**裸 `setInterval(async …)` |
| 启动 | `apps/engine` 进程起来后立即跑一轮,再进循环 |
| 停止 | 收到退出信号:不强制中断本轮(可等完);不保证优雅排空 |
| 错过 | 不追补「错过的时刻」;下一轮全量拉最近 1000 条即补偿(上游窗口语义) |
> 与交互文案「约 5 分钟 / ≤5 分钟」对齐:实际最坏接近 `interval + 单轮耗时`;若单轮通常远小于 interval,观感仍约为 5 分钟。需要「整点对齐」时再改为 `nextSlot = ceil(now / interval) * interval`(非本阶段)。
## 3. 一轮 `syncRound()` 做什么
```text
1. 选 key:api_keys.status = active 全量(id 升序,串行)
2. 对每个 key:
a. 解密 key_ciphertext → sk 明文
b. GET /api/usage/token
成功 → UPDATE 上游字段全组 + usage_synced_at
失败 → 记入本轮 failed,不写 api_keys
c. GET /api/log/token
成功 → 逐行 dedupe_key → INSERT ON CONFLICT DO NOTHING
→ UPDATE log_synced_at
失败 → 记入本轮 failed,不写 log_synced_at
d. (建议)每 key 之间 setImmediate/短 sleep 让出,降低单轮 CPU 连续占用
3. QPU 采样(每轮一次,与 key 无关):
GET /api/status → quota_per_unit
与 qpu_history 最新值相同 → 不写;变化 → INSERT(effective_from = 本轮时刻)
4. 收束 last_round(§5),打一行轮次日志
```
### 3.1 与表设计的对齐(勿另立标准)
| 行为 | 权威出处 |
|------|----------|
| 仅写成功结果;失败不写行 | 《api_keys表字段设计》§4 |
| `dedupe_key` 规则(type=2 / 非 2 / 空 request_id) | 《usage_logs表字段设计》§3.1 |
| `ON CONFLICT (dedupe_key) DO NOTHING` | 同文写入时机 |
| QPU **值变化才追加** | 《qpu_history表字段设计》§4 |
| 登记后**异步补一轮**,不阻塞 201 | 《api_keys表字段设计》§4 |
### 3.2 幂等与重试
- 重复拉取 → `dedupe_key` 合并,零副作用。
- 单 key 失败不影响其他 key;不写「半行」已成功的 key 会保留。
- 不做单 key 积极重试;**周期即重试**。
### 3.3 登记后补跑(已裁决:方案 A)
- Key 登记(`POST /api/admin/keys`)成功后,**不**在 HTTP 请求里同步拉日志,**也不**引入秒级唤醒/优先队列。
- **最迟一个 `SYNC_INTERVAL_MS` 内**由下一轮自然覆盖(独立进程下避免 IPC;与交互文案「约 5 分钟内完成」一致)。
- 与《api_keys表字段设计》§4「首轮日志同步不阻塞登记响应」不冲突:登记 201 立即返回;首轮日志异步、且上限为一个调度周期。
## 4. 失败语义(R2:记录、不决策)
| 情况 | 引擎行为 | 可见 |
|------|----------|------|
| 401 / 403 / 5xx / 超时 | 该 key 本轮失败;不写 api_keys / log_synced_at | `log_synced_at` 停滞;`last_round.failed_api_key_ids` |
| 登记阶段 422/502 | 不属引擎 | 登记接口已处理 |
**不做**:自动停用、自动摘除、自动告警(R4 延后)。
运行日志(stdout)建议**中文描述**(与平台文案基调一致),例如:
```text
同步轮次完成:key=18 成功=16 失败=2 耗时=812ms
同步失败:key_id=3 原因=401
```
访问日志、启动日志与错误日志同样使用中文表述(实现已统一)。
## 5. 引擎状态与 `/api/admin/engine` 衔接
契约响应形态不变。**存储:`engine_status` 单行快照表**(已裁决);engine 每轮写入,server 的 `/api/admin/engine` 只读该表(不 import engine 包、不走 IPC)。
```text
engine_status(单行)
running: boolean
sync_interval_ms: number // 只读观测:当前生效周期
last_round: { started_at, keys_total, succeeded, failed, failed_api_key_ids } | null
updated_at
```
- 与《API契约-管理面》§3 字段一一对应(`last_round` 为 `null` 合法);`sync_interval_ms` 为**只读观测**,便于界面展示,**不提供修改接口**。
- `GET /api/admin/keys?recent_failed=true` 读 `failed_api_key_ids`。
- **不做**完整 `engine_rounds` 历史表;表名/列名实现期可微调,语义不变。
## 6. 代码落点
| 逻辑 | 位置 | 约束 |
|------|------|------|
| 上游 HTTP | `packages/core/upstream.ts`(补 `fetchLogs`) | Web 标准 API |
| dedupe / 折算 | `packages/core` | 已有 |
| SQL | `packages/db`(engine 写入用仓储) | 方言不外泄 |
| `syncRound` + 定时循环 + 进程入口 | **`apps/engine`**(如 `src/loop.ts`、`src/sync-round.ts`、`src/index.ts`) | 独立 `package.json` |
| `/api/admin/engine` 读状态 | `apps/server` 读 `engine_status` | 不 import engine 包 |
Monorepo 增补:
```text
apps/
server/ # HTTP
engine/ # 同步循环(本设计)
web/
```
## 7. 配置(env,由 `apps/engine` 读取)
| 键 | 默认 | 含义 |
|----|------|------|
| `SYNC_INTERVAL_MS` | `300000` | 轮询间隔毫秒(**可配置**;默认 5 分钟)。**仅 env、仅启动时读**;管理面/API **不提供修改**(运维旋钮而非业务口径) |
| 复用 | `DATABASE_URL` · `SK_KEY_ENCRYPTION_SECRET` · `NEWAPI_BASE` | 与 server 相同语义 |
`server` **不读** `SYNC_*`。
## 8. 非目标
1. **CFW / Cron Triggers / D1**——不预留;
2. **cron 库 / cron 表达式**——本阶段方案 A,只用毫秒间隔;
3. **经 server / API / 管理面修改同步周期**——interval 只走 env;状态快照中的 `sync_interval_ms` 仅只读观测;
4. **R3 备份 / R4 告警**;
5. **多 key 并行、分布式锁、多 engine 实例**——单 engine 实例部署约定(文档写明即可);
6. **轮次历史表、调用自家 HTTP API、进程间 IPC**——协作只经 PostgreSQL。
## 9. 验收清单(实现后)
- [ ] 只启 `dev:engine`(或 compose 仅 engine+db):日志入库、`log_synced_at` 前进
- [ ] 只启 `dev:server`:HTTP 正常,无轮询日志
- [ ] 同一数据重复轮次不产生重复行
- [ ] 某 key 401:其余 key 正常;`engine_status.last_round.failed_api_key_ids` 含之
- [ ] QPU 不变无新行,变化 +1
- [ ] `GET /api/admin/engine` 与契约一致(跨进程读到 engine 写的状态)
- [ ] `SYNC_INTERVAL_MS` 可调;**不启动 `apps/engine` 即不轮询**(独立进程,无 `SYNC_ENABLED` 开关)
- [ ] 无定时器叠加(人为拉长一轮耗时,验证 `inFlight`)
## 10. 裁决记录
### 2026-10-05 · 进程与调度形态
- 独立 `apps/engine`,与 `apps/server` 分进程;不为 CFW 预留。
- 调度方案 A:手写 `setTimeout`,一轮结束再约下一枪;`SYNC_INTERVAL_MS` 可配置。
### 2026-10-05 · 补跑与状态(作者认可推荐项)
1. **登记后补跑 = 方案 A**:最迟一个 interval 内由下一轮覆盖;无秒级唤醒/优先队列。
2. **`engine_status` 单行快照表**:engine 写、server 读;表名实现期可微调;可含只读 `sync_interval_ms`。
3. 周期文案:默认 5 分钟、可配置(`SYNC_INTERVAL_MS`)。
### 2026-10-05 · 周期修改通道
**不通过 server / API 修改 interval**——仅 env、启动时读;管理面无改配。协作只经 PostgreSQL,无 IPC/内嵌 HTTP。
实现落点:`syncRound` + 调度循环在 `apps/engine`(见 §6)。
+1 -1
View File
@@ -19,7 +19,7 @@
| 运行时与工具链 | **Bun**(runtime + 包管理 + 测试 + 打包一体化) |
| HTTP | **Hono** |
| 数据访问 | **Drizzle ORM** + postgres.js + **PostgreSQL** |
| 调度 | 进程内定时(细则归同步引擎设计) |
| 调度 | **独立进程 `apps/engine`**:手写 `setTimeout` 循环(细则见《同步引擎设计》) |
| 加密 | WebCrypto(Bun 原生) |
| 前端 | **Vue 3 + antdv-next + Vite** |
| 部署 | **Docker**(倾向,§6) |