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>
This commit is contained in:
Misaka_Company
2026-07-24 10:27:00 +08:00
parent f61f52bcbe
commit 81dea38310
2 changed files with 812 additions and 0 deletions

View File

@@ -0,0 +1,522 @@
# 下载后自动入库钩子 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 PostgreSQL 入库动作挂到 `runtime.dispatch_task` 下载成功分支使每次下载成功后自动、尽力而为、kind 级地把刚下载的数据 UPSERT 进 PG。
**Architecture:** 新增 `store.ingest_task(site, kind)`kind 级路由,复用现有 `_ingest_*`+ `runtime._persist_to_db(site, kind)`(同步内联钩子,懒导入 store绝不外抛+ `state_store.ingest_state` 表(结果可查,经 `/status` 暴露)+ 配置开关/超时。详见 spec `docs/superpowers/specs/2026-07-24-ingest-hook-design.md`
**Tech Stack:** Python 3.10+、psycopg(v3)、sqlite3、Playwright(不动)、FastAPI(仅 `/status` 加字段)、pyyaml。包以可编辑模式安装`pip install -e .`),命令走 `.venv/Scripts/python.exe -m inbound_verify...`
## Global Constraints
- **Python 一律走项目虚拟环境**`.venv/Scripts/python.exe`(全局 CLAUDE.md 协议)。命令默认在 `InboundVerify/` 根目录执行。
- **无 pytest 测试套件**(本仓库约定,覆盖 writing-plans 默认的 TDD-pytest 步骤):每个任务的验证用 `py_compile` + `black` + 导入冒烟 + 功能/手动校验,不写 pytest。
- **改完任何 .py 必须跑 Black**`.venv/Scripts/python.exe -m black inbound_verify`
- **不自动提交/推送**(本仓库约定,覆盖 writing-plans 默认的"每任务即提交"):每个任务的 Commit 步骤**仅在用户明确说"提交"/"commit"时执行**;否则完成任务后停在待提交态,告知用户。
- **commit message 一律英文**(全局 CLAUDE.md
- **不改下载流程/比对逻辑**4 站 `-未到数据.xlsx` 不入库(既有设计);不上连接池/后台线程/补入重试。
- 导入约束:`runtime` 已 import `compare``store` 也 import `compare`;钩子里对 `store` **懒导入**以回避成环。
---
## File Structure
| 文件 | 责任 | 本计划改动 |
|---|---|---|
| `inbound_verify/store.py` | PG 持久化(建库/建表/入库 CLI | 改 `_load_pg_config`/`_connect`;加 `ingest_enabled()``ingest_task()`CLI 加 `ingest-one` |
| `inbound_verify/state_store.py` | SQLite 状态持久化 | `init_db``ingest_state` 表;加 `set_ingest_state()``get_all_ingest_state()` |
| `inbound_verify/runtime.py` | 两种模式共享核心(含 dispatch_task | 加 `_persist_to_db()`;在 `dispatch_task` 成功分支调用 |
| `inbound_verify/cli/server.py` | FastAPI 服务模式 | `/status` 返回加 `ingest` 字段 |
| `config.example.yaml` | 配置模板(提交) | `postgres` 段加 `auto_ingest`/`connect_timeout_seconds`;修过期 `db_store.py``store.py` 注释 |
| `config.yaml` | 真实配置gitignored | 同上两键 + 修注释 |
| `README.md` | 用户文档 | DB CLI 命令列表加 `ingest-one` |
任务依赖Task 2 依赖 Task 1Task 4 依赖 Task 1+2+3Task 5 依赖 Task 3。Task 3 独立。建议顺序 1→2→3→4→5。
---
### Task 1: store.py 配置键 + 连接超时层
**Files:**
- Modify: `inbound_verify/store.py:48-78``_load_pg_config``_connect`
- Modify: `config.example.yaml:72-89``config.yaml`postgres 段)
**Interfaces:**
- Consumes: 无(配置层根基)
- Produces: `_load_pg_config()` 返回新增 `auto_ingest: bool``connect_timeout_seconds: int``_connect(dbname)` 连接带 `connect_timeout` 且会话级 `statement_timeout=30s`;新函数 `ingest_enabled() -> bool`。后续任务依赖 `ingest_enabled()` 与超时连接。
- [ ] **Step 1: 改 `_load_pg_config` 加两键**
`inbound_verify/store.py``_load_pg_config` 返回 dict`schema` 之后追加两键:
```python
return {
"host": pg.get("host", "127.0.0.1"),
"port": int(pg.get("port", 5432)),
"user": pg.get("user", "postgres"),
"password": pg.get("password", ""),
"dbname": pg.get("dbname", "CQHXDB"),
"schema": pg.get("schema", "inbound_verify"),
"auto_ingest": bool(pg.get("auto_ingest", True)),
"connect_timeout_seconds": int(pg.get("connect_timeout_seconds", 5)),
}
```
- [ ] **Step 2: 改 `_connect` 加 connect_timeout + statement_timeout**
`_connect` 替换为(用 `options` 一次性设 search_path + statement_timeout等价于 spec 的 SET 但不引入额外事务):
```python
def _connect(dbname):
"""用关键字参数连接(避开 conninfo 对密码特殊字符的解析)。
options 设 search_path 到专用 schema + 会话级 statement_timeout=30s
cpolar 隧道上防失控查询connect_timeout 守连接阶段)。"""
c = _load_pg_config()
return psycopg.connect(
host=c["host"],
port=c["port"],
dbname=dbname,
user=c["user"],
password=c["password"],
options=f"-c search_path={c['schema']} -c statement_timeout=30s",
connect_timeout=c["connect_timeout_seconds"],
)
```
- [ ] **Step 3: 加 `ingest_enabled()` 薄封装**
`_connect` 之后、`# 建库 / 建表` 分节注释之前插入:
```python
def ingest_enabled():
"""是否启用下载后自动入库config.yaml postgres.auto_ingest默认 True
供 runtime 钩子判定开关,避免它伸手进 _load_pg_config。"""
return _load_pg_config()["auto_ingest"]
```
- [ ] **Step 4: 更新 store.py 模块 docstring 的命令列表**
把文件顶部 docstring 的命令行小节,在 `ingest` 行后补一行 `ingest-one`Task 2 会实现该命令docstring 先行):
```
python -m inbound_verify.store ingest [site] 入库全站或单站(幂等 UPSERT
python -m inbound_verify.store ingest-one <site> <kind> 仅入库指定站/类(钩子同款路由)
python -m inbound_verify.store all createdb → init → 全站 ingest 一条龙
```
- [ ] **Step 5: config.example.yaml 加两键 + 修过期注释**
`config.example.yaml` 的 postgres 段:把 `# PostgreSQL 数据持久化(到货核销数据入库,详见 db_store.py` 改为 `... 详见 store.py`;命令注释里的 `python db_store.py ...` 改为 `python -m inbound_verify.store ...`;在 `schema: inbound_verify` 之后追加:
```yaml
schema: inbound_verify
# 下载成功后自动入库(钩子,见 runtime._persist_to_dbfalse=跳过(无 PG/cpolar 的开发机)。
auto_ingest: true
# PG 连接超时cpolar 抖动时快速失败,不拖垮下载 worker。
connect_timeout_seconds: 5
```
- [ ] **Step 6: config.yaml 同步gitignored 本地文件)**
`config.yaml` 做同样两键追加,并把注释里的 `db_store.py` 改为 `store.py`。保留其真实凭据不动。
- [ ] **Step 7: 编译 + Black**
```
.venv/Scripts/python.exe -m py_compile inbound_verify/store.py
.venv/Scripts/python.exe -m black inbound_verify/store.py
```
Expected: py_compile 无输出black 报 `reformatted``left unchanged`
- [ ] **Step 8: 回归既有 CLI 不破**
```
.venv/Scripts/python.exe -c "from inbound_verify import store; c=store._load_pg_config(); assert c['auto_ingest'] is True and c['connect_timeout_seconds']==5; assert store.ingest_enabled() is True; print('config ok')"
```
Expected: `config ok`
- [ ] **Step 9: Commitgated**
仅当用户说"提交"时执行:
```bash
git add inbound_verify/store.py config.example.yaml
git commit -m "feat(store): add auto_ingest config + pg connect/statement timeouts"
```
`config.yaml` 已 gitignore不入提交。
---
### Task 2: store.ingest_task + ingest-one CLI
**Files:**
- Modify: `inbound_verify/store.py`(加 `ingest_task``main()``ingest-one` 分支)
**Interfaces:**
- Consumes: Task 1 的 `_connect`(超时)、`_load_pg_config`、既有 `_ingest_expected/_ingest_actual/_ingest_undelivered_baishi``_read_business_dates``ALL_SITES``_site_cfg`
- Produces: `ingest_task(site: str, kind: str) -> int`(返回入库总条数;`__compare__`/不支持组合返回 0
- [ ] **Step 1: 加 `ingest_task`**
`ingest(site)` 函数之后、`# 命令行` 分节之前插入:
```python
def ingest_task(site, kind):
"""按 (site, kind) 入库本次刚下载的文件(幂等 UPSERT返回总条数。
与 ingest(site) 的区别:只入本次刷新的那一类,避免重读写另一类文件(同步钩子里减少阻塞)。
kind 路由:
expected/actual 各入其列;
undelivered 百世 入未到;
undelivered 4 站 _site_undelivered_handler 内部连带下了 expected+actual故入两者
__compare__ / 其它组合 返回 0。
"""
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: # 顺心/中通/韵达/安能
total += _ingest_expected(cur, site, dates.get(site))
total += _ingest_actual(cur, site)
# 其它组合(如 百世/expected正常不经钩子触发防御性返回 0
conn.commit()
return total
```
- [ ] **Step 2: `main()` 加 `ingest-one` 分支**
`main()``elif cmd == "all":` 分支之后、`else:` 之前插入:
```python
elif cmd == "ingest-one":
kind = sys.argv[3] if len(sys.argv) > 3 else None
if not site or kind not in ("expected", "actual", "undelivered"):
print("用法: python -m inbound_verify.store ingest-one <site> <expected|actual|undelivered>")
sys.exit(1)
total = ingest_task(site, kind)
print(f">> [ingest-one] {site}/{kind} 入库 {total}")
```
- [ ] **Step 3: 编译 + Black**
```
.venv/Scripts/python.exe -m py_compile inbound_verify/store.py
.venv/Scripts/python.exe -m black inbound_verify/store.py
```
Expected: 无编译错误。
- [ ] **Step 4: 路由正确性(手动,需 PG 已 createdb+init 且 downloads/ 有数据)**
逐条验证 kind 级只入对应文件:
```
.venv/Scripts/python.exe -m inbound_verify.store ingest-one 韵达 expected
```
Expected: stdout 只出现 `[应到] 韵达N 条运单`**不**出现 `[实到] 韵达` 行;结尾 `[ingest-one] 韵达/expected 入库 N 条`
```
.venv/Scripts/python.exe -m inbound_verify.store ingest-one 顺心 undelivered
```
Expected: stdout 同时出现 `[应到] 顺心``[实到] 顺心`undelivered→两者
```
.venv/Scripts/python.exe -m inbound_verify.store ingest-one 百世 undelivered
```
Expected: stdout 出现 `[未到] 百世`
> 若某站 downloads/ 无文件:`_ingest_*` 打印 `[跳过] ... 文件不存在`ingest_task 返回 0属正常非错误
- [ ] **Step 5: 无 PG 时的降级(手动,可选)**
临时把 `config.yaml``host` 改成不可达地址,重跑 Step 4 任一命令:应在 `connect_timeout_seconds`(默认 5s内报 psycopg 连接错误并退出(非 hang。验证后改回真实 host。
- [ ] **Step 6: Commitgated**
```bash
git add inbound_verify/store.py
git commit -m "feat(store): add kind-level ingest_task + ingest-one CLI subcommand"
```
---
### Task 3: state_store ingest_state 表 + 读写函数
**Files:**
- Modify: `inbound_verify/state_store.py:32-113``init_db` 加表)、`:219-247`(加两函数)
**Interfaces:**
- Consumes: 无(独立叶子,仅依赖 paths + sqlite3 + datetime
- Produces: `set_ingest_state(site, kind, ok, count=0, error=None) -> None``get_all_ingest_state() -> dict`(形状 `{site: {kind: {ok, ingested_at, count, error}}}`。Task 4 与 Task 5 依赖这两个。
- [ ] **Step 1: `init_db` 加 `ingest_state` 表**
`init_db` 内、`site_settings` 表 CREATE 之后(`conn.commit()` 之前)插入:
```python
conn.execute("""
CREATE TABLE IF NOT EXISTS ingest_state (
site TEXT,
kind TEXT,
ok INTEGER,
ingested_at TEXT,
count INTEGER,
error TEXT,
PRIMARY KEY (site, kind)
)
""")
```
- [ ] **Step 2: 加 `set_ingest_state` 与 `get_all_ingest_state`**
`get_all_status()` 函数之后插入:
```python
def set_ingest_state(site, kind, ok, count=0, error=None):
"""记录一次入库结果UPSERT。ok: boolcount: 入库条数error: 失败原因或 None。"""
with sqlite3.connect(STATE_DB_PATH) as conn:
conn.execute(
"INSERT INTO ingest_state (site, kind, ok, ingested_at, count, error) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(site, kind) DO UPDATE SET "
"ok=excluded.ok, ingested_at=excluded.ingested_at, "
"count=excluded.count, error=excluded.error",
(site, kind, 1 if ok else 0, _now(), int(count or 0), error or ""),
)
conn.commit()
def get_all_ingest_state():
"""返回 {site: {kind: {ok, ingested_at, count, error}}};库不存在返回 {}"""
if not os.path.exists(STATE_DB_PATH):
return {}
with sqlite3.connect(STATE_DB_PATH) as conn:
rows = conn.execute(
"SELECT site, kind, ok, ingested_at, count, error FROM ingest_state"
).fetchall()
out = {}
for site, kind, ok, ingested_at, count, error in rows:
out.setdefault(site, {})[kind] = {
"ok": bool(ok),
"ingested_at": ingested_at or "",
"count": int(count or 0),
"error": error or "",
}
return out
```
- [ ] **Step 3: 编译 + Black**
```
.venv/Scripts/python.exe -m py_compile inbound_verify/state_store.py
.venv/Scripts/python.exe -m black inbound_verify/state_store.py
```
Expected: 无错误。
- [ ] **Step 4: set/get 往返冒烟**
```
.venv/Scripts/python.exe -c "from inbound_verify import state_store as s; s.init_db(); s.set_ingest_state('韵达','expected',True,count=42); s.set_ingest_state('韵达','expected',False,error='boom'); d=s.get_all_ingest_state(); r=d['韵达']['expected']; assert r['ok'] is False and r['count']==42 and r['error']=='boom' and r['ingested_at']; print('ingest_state ok')"
```
Expected: `ingest_state ok`(验证 UPSERT 覆盖:第二次写把 ok 改 Falsecount 保留 42error 写入)。
- [ ] **Step 5: Commitgated**
```bash
git add inbound_verify/state_store.py
git commit -m "feat(state_store): add ingest_state table + set/get helpers"
```
---
### Task 4: runtime._persist_to_db 钩子 + 挂到 dispatch_task
**Files:**
- Modify: `inbound_verify/runtime.py`(加 `_persist_to_db`,位置在 `_record_business_date` 之后、`dispatch_task` 之前;改 `dispatch_task` 成功分支 `:552-557`
**Interfaces:**
- Consumes: Task 1 `store.ingest_enabled()`、Task 2 `store.ingest_task(site, kind)`、Task 3 `state_store.set_ingest_state(...)`;既有 `state_store`runtime 已 import
- Produces: `_persist_to_db(site, kind)``dispatch_task` 在下载成功后调用;下载任务的成功判定**不变**。
- [ ] **Step 1: 加 `_persist_to_db`**
`_record_business_date` 函数之后(`def dispatch_task` 之前)插入:
```python
def _persist_to_db(site, kind):
"""下载成功后把本次数据入库 PostgreSQL尽力而为绝不外抛不影响任务判定
- __compare__ 无源数据,跳过。
- auto_ingest=false 时跳过(无 PG/cpolar 的开发机)。
- 懒导入 store 以回避 import 顺序store↔compare 与 runtime↔compare 共存)。
- 结果写 state_store.ingest_state供 /api/status 反映入库健康。
所有写库/写状态都包 try/except失败仅告警绝不改变 dispatch_task 的 SUCCESS 判定。"""
if site == "__compare__":
return
try:
from inbound_verify import store # 懒导入:冷路径(每下载一次),回避成环
except Exception as e:
print(f">> [warn] 入库模块不可用: {e}")
return
if not store.ingest_enabled():
print(">> [入库] 已关闭 (auto_ingest=false),跳过")
return
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}")
```
- [ ] **Step 2: 挂到 `dispatch_task` 成功分支**
`dispatch_task` 内的成功分支:
```python
_record_business_date(site, kind)
return (state_store.TASK_SUCCESS, None)
```
改为:
```python
_record_business_date(site, kind)
_persist_to_db(site, kind)
return (state_store.TASK_SUCCESS, None)
```
`_persist_to_db` 绝不外抛,故不会被外层 `except Exception` 误判为任务失败。)
- [ ] **Step 3: 编译 + Black + 导入冒烟**
```
.venv/Scripts/python.exe -m py_compile inbound_verify/runtime.py
.venv/Scripts/python.exe -m black inbound_verify/runtime.py
.venv/Scripts/python.exe -c "from inbound_verify.runtime import dispatch_task, _persist_to_db; print('runtime import ok')"
```
Expected: `runtime import ok`
- [ ] **Step 4: 端到端(手动,需站点已登录)**
任选一种模式触发一次真实下载并观察钩子:
- 服务模式:`POST /tasks` `{"site":"韵达","kind":"expected"}`(或经 dashboard 触发),下载完成后看 worker stdout
- 成功:`>> [入库] 韵达/expected 成功N 条`
- 失败(如 PG 未 init`>> [warn] 入库失败 ...`,且任务本身仍 `success``GET /tasks/{id}` 验证)。
- 交互模式:菜单 `[6]` 韵达应到,完成后看同样的 `[入库]` 行。
并查 `state/state.db`
```
.venv/Scripts/python.exe -c "from inbound_verify import state_store as s; print(s.get_all_ingest_state())"
```
Expected: 含 `韵达`/`expected` 的记录,`ok` 与 stdout 一致。
- [ ] **Step 5: 关开关降级(手动)**
`config.yaml``auto_ingest``false`再触发一次下载stdout 应出现 `>> [入库] 已关闭 (auto_ingest=false),跳过`,且不连 PG任务仍 SUCCESS。验证后改回 `true`
- [ ] **Step 6: Commitgated**
```bash
git add inbound_verify/runtime.py
git commit -m "feat(runtime): auto-ingest hook after successful download"
```
---
### Task 5: /status 暴露 ingest 态 + README
**Files:**
- Modify: `inbound_verify/cli/server.py:189-196``get_status`
- Modify: `README.md`DB CLI 命令列表)
**Interfaces:**
- Consumes: Task 3 `state_store.get_all_ingest_state()`
- Produces: `GET /status` 返回体新增 `ingest` 字段。
- [ ] **Step 1: `/status` 加 `ingest` 字段**
`get_status` 的返回 dict
```python
return {
"worker_ready": worker_state["ready"],
"worker_error": worker_state["error"],
"sites": state_store.get_all_status(),
}
```
改为:
```python
return {
"worker_ready": worker_state["ready"],
"worker_error": worker_state["error"],
"sites": state_store.get_all_status(),
"ingest": state_store.get_all_ingest_state(),
}
```
并把 docstring 顺手补一句(可选):`"""各站登录态 + 数据态 + 入库态(前端状态盘用),另含 worker 就绪状态。"""`
- [ ] **Step 2: README DB CLI 命令列表加 `ingest-one`**
`README.md` 第五章"运行"下、DB CLI 注释行(`# 或inbound-verify-db createdb|init|ingest|all` 附近)补一行说明自动入库 + 新命令:
```
# DB CLI建库 / 初始化 / 灌数据 / 全流程 / 单站单类
.venv/Scripts/python.exe -m inbound_verify.store createdb # 或 init | ingest | ingest-one <site> <kind> | all
# 注下载成功后会自动入库postgres.auto_ingest默认开ingest-one 用于手动重灌指定站/类。
```
- [ ] **Step 3: 编译 + Black**
```
.venv/Scripts/python.exe -m py_compile inbound_verify/cli/server.py
.venv/Scripts/python.exe -m black inbound_verify/cli/server.py
```
Expected: 无错误。
- [ ] **Step 4: `/status` 含 ingest 字段(手动,服务模式已启动)**
```
curl -s http://127.0.0.1:8000/status | python -m json.tool
```
(或浏览器 `http://127.0.0.1:8000/docs``/status`Expected: 返回体含 `"ingest": {...}` 键(无入库记录时为 `{}`Task 4 跑过后会有值)。经 dashboard 的 `GET /api/status`(代理)同样可见。
- [ ] **Step 5: 全量回归冒烟**
```
.venv/Scripts/python.exe -m py_compile inbound_verify
.venv/Scripts/python.exe -m black inbound_verify
.venv/Scripts/python.exe -c "import inbound_verify.store, inbound_verify.state_store, inbound_verify.runtime, inbound_verify.cli.server; print('all imports ok')"
```
Expected: `all imports ok`black 无 diff。
- [ ] **Step 6: Commitgated**
```bash
git add inbound_verify/cli/server.py README.md
git commit -m "feat(server): expose ingest state in /status; doc auto-ingest in README"
```
---
## Self-Review 结论
- **Spec 覆盖**spec §4.1 → Task 1+2§4.2 → Task 4§4.3 → Task 3§4.4 → Task 5§4.5 配置 → Task 1config 两文件§7 测试手段 → 各任务手动步ingest-one 路由、端到端、降级、回归)。无遗漏。
- **占位符**:无 TBD/TODO每步含完整代码与确切命令。
- **类型/命名一致**`ingest_task(site, kind)``ingest_enabled()``set_ingest_state(site, kind, ok, count=0, error=None)``get_all_ingest_state()``_persist_to_db(site, kind)` 在各任务间签名一致;`/status` 字段名 `ingest``get_all_ingest_state` 返回一致。
- **已标注偏离**(a) 无 pytest → 用 py_compile/black/冒烟/手动代替Global Constraints(b) 不自动提交 → Commit 步骤 gatedGlobal Constraints + 每任务注明);(c) `_connect``options` 设 statement_timeout 取代 spec 的 `SET`等价、无额外事务Task 1 Step 2 注释说明)。

View File

@@ -0,0 +1,290 @@
# 下载后自动入库钩子设计
- **日期**: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 + 编译/导入冒烟 + 手动端到端验证。