Files
InboundVerify/docs/superpowers/specs/2026-07-24-ingest-hook-design.md
Misaka_Company 81dea38310 docs: add ingest-hook design spec and implementation plan
Spec for mounting PostgreSQL ingest as a best-effort, kind-level,
synchronous hook on runtime.dispatch_task after a successful download,
plus the 5-task implementation plan.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 10:27:00 +08:00

291 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 下载后自动入库钩子设计
- **日期**:2026-07-24
- **方案**:在 `runtime.dispatch_task` 下载成功分支挂一个**同步、kind 级、尽力而为**的入库钩子,调用新增的 `store.ingest_task(site, kind)`,仅入库本次刚下载的文件
- **力度**:后端最小闭环(钩子 + `ingest_task` + `ingest_state` 表 + `/status` 字段 + 配置开关/超时);不碰 dashboard UI、不上连接池/后台线程
- **状态**:已与用户对齐(4 个关键决策已逐项确认),待 spec 评审
---
## 1. 背景与目标
各站点的下载流程与 PostgreSQL 持久化模块(`store.py`)**都已实现**,但入库动作目前只能靠 CLI
(`inbound-verify-db ingest` / `python -m inbound_verify.store ingest`)手动触发,**没有挂到任何自动钩子上**。
本设计把入库动作挂到"数据完成下载之后"——具体挂在所有下载任务的唯一汇聚点
`runtime.dispatch_task` 的成功分支,与既有的"下载后写业务日期"钩子 `_record_business_date` 并列。
**目标**
1. 下载成功后**自动**把刚下载的那份数据 UPSERT 进 PostgreSQL无需手动跑 CLI。
2. 入库是**尽力而为**:失败只告警、绝不影响下载任务的成功判定(下载成功 = 任务成功)。
3. 入库结果可查:写入 `state_store`,经 `/api/status` 暴露,便于发现"入库坏了好几天"。
4. 可门控、可降级:配置开关 + PG 连接/语句超时,无 PG/cpolar 的开发机可整体跳过。
**非目标(本 spec 不做)**
- dashboard 前端展示 ingest 态(Next.js 侧API 字段已就绪待消费,单独小任务)。
- 4 站 `-未到数据.xlsx` 入库(既有设计仅百世未到入库4 站未到只给汇总报表)。
- 连接池、后台入库线程、失败补入重试(YAGNI)。
- 任何与下载流程本身、比对逻辑相关的改动。
---
## 2. 现状(决策依据)
### 2.1 汇聚点与既有钩子先例
- `runtime.dispatch_task(ctx, {"site","kind"})` 是全部 9 个下载任务 + 比对任务的**唯一执行入口**;
交互模式(`cli/router`)与服务模式(`cli/server` worker)都走它。
- 它的成功分支**已经有一个"下载后"钩子**:`_record_business_date(site, kind)`——写业务日期到
`state.db`best-effort,失败仅告警。新钩子天然挂在它旁边house style 完全一致。
- 4 站 `undelivered` 任务的 handler(`_site_undelivered_handler`)内部**直接调**
`TASK_HANDLERS[(site,"expected"/"actual")](ctx)`(不经 dispatch_task),故只触发**一次**外层
dispatch_task 钩子——`ingest_task(site,"undelivered")` 需据此同时入 expected+actual。
### 2.2 持久化模块现状
- `store.ingest(site=None)`:读 `downloads/` 现有 xlsx,幂等 UPSERT 到 PG。**站点级**:对顺心/中通/
韵达/安能同时入该站 expected+actual;百世只入 undelivered。**不感知 kind**。
- 三张表:`expected_record`(运单级) / `actual_record`(扫描件级) / `undelivered_record`(仅百世)。
- `_ingest_expected(cur, site, business_date)` / `_ingest_actual(cur, site)` /
`_ingest_undelivered_baishi(cur)` 为内部助手,直接复用,不改。
- `_connect(dbname)`:每次新建一条连接,`options=-c search_path=<schema>`。**经 cpolar 隧道**
(`5.tcp.cpolar.top:10364`),潜在延迟/抖动。
### 2.3 线程模型
- dispatch_task 跑在 Playwright 所属线程(router=主线程;server=单 worker 线程,串行消费
task_queue、空闲跑心跳)。**同步做 PG I/O 会阻塞这条线程**——故入库必须快、可超时、可降级。
### 2.4 数据流定位
- dashboard **不直接读 PG**:全部经 Next.js `/api/*` 代理到 InboundVerify FastAPI(读 state.db +
文件 + 汇总报表)。故自动入库的直接受益者是 **PG 这个下游数仓**(BI/长期归档/未来报表),
不是当前前端。这支撑了"同步内联、不上复杂调度"的判断。
### 2.5 state_store 现状(决定表设计)
- `site_status` 是**每站一行**、按 kind 展开列(`{kind}_ready/_generated_at/_business_date`)。
- `_upsert` 是**手写枚举列**的 read-modify-write(脆)。往里塞 ingest 列(4 列 × 3 kind = 12 列)
会很丑且易错——故选**独立 `ingest_state` 表**(用户选项里也提过"或一张很小的入库记录表")。
---
## 3. 四个关键决策(均已与用户确认)
| 决策 | 选定 | 理由 |
|---|---|---|
| 执行模型 | **同步内联** | 与 `_record_business_date` 一致、零新线程,贴合"刻意不优化结构"风格;cpolar 风险用超时+try/except 兜底 |
| 入库粒度 | **kind 级**(`ingest_task(site,kind)`) | 只入本次刚下载的文件,阻塞最小;"下了什么入什么";CLI `ingest(site)` 保留不动 |
| 配置门控 | **开关 + 连接超时** | `auto_ingest`(默认开)+ `connect_timeout_seconds`(默认 5);无 PG 开发机可关 |
| 失败可见性 | **stdout + state_store** | 仅 stdout 会在服务模式静默失败多天;落库后 `/api/status` 可查 |
---
## 4. 组件设计
### 4.1 `store.py` — 新增 `ingest_task(site, kind)` + 连接/配置增强
**新函数 `ingest_task(site, kind) -> int`**
单连接、单事务,复用现有 `_ingest_*`,返回总条数。路由表:
| site | kind | 调用 |
|---|---|---|
| `__compare__` | * | 无 → 返回 0 |
| 顺心/中通/韵达/安能 | `expected` | `_ingest_expected(cur, site, dates.get(site))` |
| 顺心/中通/韵达/安能 | `actual` | `_ingest_actual(cur, site)` |
| 顺心/中通/韵达/安能 | `undelivered` | `_ingest_expected` + `_ingest_actual` |
| 百世 | `undelivered` | `_ingest_undelivered_baishi(cur)` |
| 百世 | `expected`/`actual` | (无此任务)防御性返回 0 |
`dates = _read_business_dates()`(既有)。骨架:
```python
def ingest_task(site, kind):
"""按 (site, kind) 入库本次刚下载的文件(幂等 UPSERT)。返回总条数。
与 ingest(site) 的区别:只入本次刷新的那一类,避免重读写另一类文件。"""
if site == "__compare__":
return 0
dates = _read_business_dates()
total = 0
with _connect(_load_pg_config()["dbname"]) as conn:
with conn.cursor() as cur:
if kind == "expected":
total += _ingest_expected(cur, site, dates.get(site))
elif kind == "actual":
total += _ingest_actual(cur, site)
elif kind == "undelivered":
if site == "百世":
total += _ingest_undelivered_baishi(cur)
else: # 4 站:handler 内部连带下了 expected+actual
total += _ingest_expected(cur, site, dates.get(site))
total += _ingest_actual(cur, site)
# 其它组合(如 百世/expected):防御性 0
conn.commit()
return total
```
**`_load_pg_config()` 增键**:`auto_ingest`(默认 `True`)、`connect_timeout_seconds`(默认 `5`)。
**`_connect(dbname)` 增强**:
- `psycopg.connect(..., connect_timeout=c["connect_timeout_seconds"])`
- 连上后 `cur.execute("SET statement_timeout = '30s'")`(固定值,带注释说明可按需 knob 化;
connect_timeout 守隧道死连,statement_timeout 守失控查询,小 UPSERT 极少触发)。
- `create_database` / `init_schema` / `ingest` 共用此连接,30s 对它们无影响。
**新薄封装 `ingest_enabled() -> bool`**:读 `_load_pg_config()["auto_ingest"]`
供 runtime 钩子判定开关,避免钩子伸手进 store 私有函数。
**CLI `ingest-one`**(便于脱离下载单测路由):`python -m inbound_verify.store ingest-one 韵达 expected`
→ 直接调 `ingest_task(site, kind)``main()` 增该分支。
**不改**:既有 `ingest(site)` / `_ingest_*` / SQL / `domain`
### 4.2 `runtime.py` — 新增 `_persist_to_db(site, kind)`,挂到 `dispatch_task`
**`dispatch_task` 成功分支**(`_record_business_date` 之后)新增一行:
```python
ret = handler(ctx)
if ret is False:
return (state_store.TASK_FAILED, "任务执行失败(重试耗尽)")
_record_business_date(site, kind)
_persist_to_db(site, kind) # 新增:尽力而为,绝不外抛,不影响任务判定
return (state_store.TASK_SUCCESS, None)
```
**新函数 `_persist_to_db(site, kind) -> None`**:
- `store` **懒导入**(函数内 `from inbound_verify import store`),彻底回避 import 顺序/成环,冷路径无性能影响。
- `if site == "__compare__": return`
- 读开关:`if not store.ingest_enabled(): print(">> [入库] 已关闭,跳过"); return`
- 主逻辑(所有写库/写状态都包 try/except,绝不外抛):
```python
try:
count = store.ingest_task(site, kind)
state_store.set_ingest_state(site, kind, ok=True, count=count)
print(f">> [入库] {site}/{kind} 成功,{count}")
except Exception as e:
print(f">> [warn] 入库失败 {site}/{kind}: {e}")
try:
state_store.set_ingest_state(site, kind, ok=False, error=str(e))
except Exception as e2:
print(f">> [warn] 写入库状态也失败: {e2}")
```
### 4.3 `state_store.py` — 新增独立表 `ingest_state` + 读写
**`init_db()` 增表(幂等)**:
```sql
CREATE TABLE IF NOT EXISTS ingest_state (
site TEXT,
kind TEXT,
ok INTEGER, -- 1=成功 0=失败
ingested_at TEXT,
count INTEGER, -- 入库条数
error TEXT, -- 失败原因(成功则 '')
PRIMARY KEY (site, kind)
)
```
**新函数**:
- `set_ingest_state(site, kind, ok, count=0, error=None)`:UPSERT(短连接,`ingested_at=_now()`,
与现有 `set_*` 风格一致)。
- `get_all_ingest_state() -> {site: {kind: {ok, ingested_at, count, error}}}`(库不存在返回 `{}`)。
**心跳不刷 `ingest_state`**(只由钩子写)。澄清:复用的是 state_store 管线 + `/api/status` 通道,
不是心跳循环。**不碰 `site_status` / `_upsert`**。
### 4.4 `cli/server.py` — `/status` 暴露 ingest 态
`GET /status` 现有返回 `get_all_status()`;追加一个 `ingest` 键 = `state_store.get_all_ingest_state()`
一行改动,前端可按需消费。
### 4.5 配置 — `config.example.yaml` + `config.yaml`
`postgres` 段新增(两文件都加;`config.yaml` 已 gitignore):
```yaml
postgres:
# ...既有 host/port/user/password/dbname/schema...
auto_ingest: true # 下载成功后自动入库;false=跳过(无 PG/cpolar 的开发机)
connect_timeout_seconds: 5 # PG 连接超时(秒);cpolar 抖动兜底
```
顺手把 `config.yaml` 注释里过期的 `db_store.py` 改为 `store.py`(小清理)。
---
## 5. 数据流
```
dispatch_task 成功
→ _record_business_date(site, kind) # 既有:SQLite 业务日期,best-effort
→ _persist_to_db(site, kind) # 新增
auto_ingest? ─ no ─→ print 跳过,return
└ yes ─→ 懒导 store
store.ingest_task(site, kind)
_connect(connect_timeout) → SET statement_timeout
路由 _ingest_* → commit → 返回 count
成功 → set_ingest_state(ok,count) + print
异常 → set_ingest_state(fail,error) + warn
→ return TASK_SUCCESS(始终)
```
---
## 6. 错误处理矩阵
| 情形 | 行为 | 任务判定 |
|---|---|---|
| `auto_ingest=false` | print 跳过,return | SUCCESS(不变) |
| 文件缺失(下载刚成功却无文件,罕见) | `_ingest_*``[跳过]` 返回 0;ingest_task 返回 0;记 ok/count=0 | SUCCESS |
| PG 不可达 / connect_timeout / statement_timeout | psycopg 异常 → 捕获 → fail + warn | SUCCESS |
| 表未初始化(UndefinedTable) | 捕获 → fail + warn(提示"请先 `inbound-verify-db init`") | SUCCESS |
| `set_ingest_state` 自身失败 | 再包 try/except,绝不外抛(呼应 `_record_business_date`) | SUCCESS |
**核心不变式:入库的任何失败都不改变下载任务的成功判定。**
---
## 7. 测试
无 pytest(house 约定)。验证手段:
1. **路由单测(新 CLI)**:`.venv/Scripts/python.exe -m inbound_verify.store ingest-one 韵达 expected`
→ 确认只入韵达应到,不动韵达实到;`ingest-one 百世 undelivered` → 只入百世未到;
`ingest-one 顺心 undelivered` → 入顺心 expected+actual。
2. **端到端**:dashboard/API 触发一次下载 → 看 stdout `[入库]` 行 → 查 PG 行数 →
`GET /api/status``ingest` 字段反映 ok/count/ingested_at。
3. **降级**:`auto_ingest=false` → 下载仍 SUCCESS、无 `[入库]` 行;临时封掉 cpolar 端口 →
下载仍 SUCCESS、`[warn] 入库失败``ingest_state` 记 fail、`/status` 可见。
4. **回归**:`py_compile inbound_verify` + `black inbound_verify` + `compileall` 导入冒烟;
确认未改 `ingest(site)` 行为(手动跑一次 `store ingest` 全站入库仍正常)。
---
## 8. 已确认约束
- InboundVerify 是 git **子模块**;改动在子模块内,父仓库仅跟踪指针。
- **不加测试套件**;验证靠编译/导入冒烟 + 手动端到端。
- 改完 Python **必须跑 Black**
- **不自动提交/推送**:本仓库约定改动后等用户明确说"提交"再 commit/push(覆盖全局 auto-push 默认,
亦覆盖 brainstorming 默认的"写完即提交")。
---
## 9. 实现顺序提示(供 writing-plans 展开)
1. `store.py`:`_load_pg_config` 加键 + `_connect` 加超时/语句超时 → `ingest_task` → CLI `ingest-one`
2. `state_store.py`:`ingest_state` 建表 + `set_ingest_state` + `get_all_ingest_state`
3. `runtime.py`:`_persist_to_db` + 挂到 `dispatch_task`
4. `cli/server.py`:`/status``ingest` 字段。
5. 配置:`config.example.yaml` + `config.yaml` 加键 + 修过期注释。
6. Black + 编译/导入冒烟 + 手动端到端验证。