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

13 KiB
Raw Blame History

下载后自动入库钩子设计

  • 日期: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.dbbest-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()(既有)。骨架:

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 之后)新增一行:

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,绝不外抛):
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() 增表(幂等):

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):

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/statusingest 字段反映 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:/statusingest 字段。
  5. 配置:config.example.yaml + config.yaml 加键 + 修过期注释。
  6. Black + 编译/导入冒烟 + 手动端到端验证。