From 8059cd514748a5e80eb6221c5ef5cd00cc03dbf0 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Thu, 1 Oct 2026 13:42:04 +0800 Subject: [PATCH] =?UTF-8?q?Excel=20=E5=AF=BC=E5=85=A5=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E6=9C=9F=201=EF=BC=9A=E6=AD=A2=E8=A1=80=E4=B8=8E=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=BA=95=E5=BA=A7=E2=80=94=E2=80=94=E5=86=99=E9=94=99?= =?UTF-8?q?=E7=9A=84=E5=88=97=E5=90=8D=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=BE=97=200=EF=BC=8C=E5=AF=BC=E5=85=A5=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=82=84=E6=82=84=E8=AF=BB=E9=94=99=EF=BC=8C=E6=AF=8F=E6=AC=A1?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E7=94=9F=E6=88=90=E4=B8=8D=E5=8F=AF=E5=8F=98?= =?UTF-8?q?=E7=9A=84=E7=89=88=E6=9C=AC=EF=BC=8C=E8=BF=90=E8=A1=8C=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E5=8F=91=E8=B5=B7=E6=97=B6=E7=9A=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连接层 - 所有 SQLite 数据源关闭双引号字符串兼容(DQS),写错的双引号列名直接报错;设置失败就拒绝连接,启动时自检 - 连接缓存按数据源加版本快照分键、按源整体失效、最多 32 个;版本快照以 immutable 只读打开,建连接前核对文件哈希,并在借出连接时核对文件指纹,被改过就拒绝查询 - 结构探查遇到取不了列的视图时逐个对象重取,只跳过坏的那个并注明原因 解析(新 xlsx_scan + tabular) - 先流式扫描真实边界和版式(不信 dimension),再按边界读值,不整表进内存;按 Excel 自身上限设硬上限,并发解析最多两个 - 隐藏和深度隐藏的工作表不导入;稀疏区域、无缓存值的公式、同名工作表、DTD 拒收 - 去掉空行和两侧空列;列名表名统一规范化(新 names 模块)并保留原表头,撞名不再互相覆盖 - 前导零、超长数字按文本;千分位按数字;日期不带 T;带单位的数字文本写进回执 - 数字列混入非数字、交叉表和多块结构必须由用户决定(422),可选存空值或按原样导入(未规整);STRICT 建表,整份在一个事务里写完 版本底座(新 table_versions、raw_store) - 每次上传写新库文件、落盘、0444,事务里写构建、导入记录、快照后才切指针;失败时旧版完整可查 - 原件按哈希存档,不提供下载,可以清除;上传源不许改连接字段、不再重新探查;手工 SQLite 源不许指向上传目录和应用自己的数据文件(按文件身份判断) - 启动时清理半成品与孤儿文件、给老上传补初始版本;被运行引用的版本不回收,发起运行与回收互斥 - 上传接口的解析移到线程池 运行固定数据版本 - 发起时把图里(含子工作流)用到的上传源的当前快照记进运行记录和 run.started,续跑、审批恢复沿用;数据源工具按固定的快照生成描述、表结构并查询 - 固定的版本没了、源被删除后同名重建,都明确报错,不改用当前版本或同名的新源;查询快照记 data_version 证据层 - 新报告记 entity_syntax: 2,[[t:明细]]、[[c:明细.金额]] 和 SQL 里不加引号的中文表名都认得,名字按 SQLite 的口径查找;老报告按原规则复核,结果不变 - 写作规则改为中文表名、字段名一律用 [[t:]] / [[c:]];未规整的表在工具描述、写作目录和裁判摘录里标出 前端 - 上传弹窗接上导入前的决定(混合列、交叉表)和导入回执(跳过的隐藏表、类型转换、警告、原表头对照、未规整标记),上传前告知原件会保存 - 数据源卡片按 origin 认上传的表格,显示当前版本;上传的表格不再给「探查结构」 --- backend/app/api/copilot.py | 9 +- backend/app/api/datasources.py | 413 +++++- backend/app/api/runs.py | 33 +- backend/app/api/tools.py | 13 +- backend/app/data/engine.py | 395 ++++- backend/app/data/introspect.py | 53 +- backend/app/data/names.py | 114 ++ backend/app/data/raw_store.py | 133 ++ backend/app/data/table_versions.py | 1138 ++++++++++++++ backend/app/data/tabular.py | 1392 +++++++++++++++--- backend/app/data/xlsx_scan.py | 995 +++++++++++++ backend/app/db/base.py | 6 + backend/app/db/models.py | 85 ++ backend/app/engine/evidence.py | 291 +++- backend/app/engine/judge.py | 4 + backend/app/engine/nodes/llm.py | 5 +- backend/app/engine/nodes/multi.py | 4 + backend/app/engine/nodes/tools.py | 30 +- backend/app/engine/runner.py | 133 +- backend/app/main.py | 13 + backend/app/tools/datasource.py | 327 +++- backend/app/tools/registry.py | 20 +- backend/tests/test_datasources_upload.py | 829 +++++++++++ backend/tests/test_engine_cache.py | 508 +++++++ backend/tests/test_evidence_unicode_names.py | 492 +++++++ backend/tests/test_excel_p1_integration.py | 174 +++ backend/tests/test_introspect_broken_view.py | 114 ++ backend/tests/test_names.py | 76 + backend/tests/test_run_data_versions.py | 894 +++++++++++ backend/tests/test_sqlite_dqs.py | 229 +++ backend/tests/test_table_versions.py | 968 ++++++++++++ backend/tests/test_tabular.py | 1164 ++++++++++++++- backend/tests/test_xlsx_scan.py | 795 ++++++++++ frontend/src/api/client.ts | 78 +- frontend/src/lib/terms.ts | 81 + frontend/src/pages/DataSourcesTab.tsx | 476 +++++- frontend/src/types.ts | 102 ++ scripts/check-manage.mjs | 414 +++++- 38 files changed, 12465 insertions(+), 535 deletions(-) create mode 100644 backend/app/data/names.py create mode 100644 backend/app/data/raw_store.py create mode 100644 backend/app/data/table_versions.py create mode 100644 backend/app/data/xlsx_scan.py create mode 100644 backend/tests/test_datasources_upload.py create mode 100644 backend/tests/test_engine_cache.py create mode 100644 backend/tests/test_evidence_unicode_names.py create mode 100644 backend/tests/test_excel_p1_integration.py create mode 100644 backend/tests/test_introspect_broken_view.py create mode 100644 backend/tests/test_names.py create mode 100644 backend/tests/test_run_data_versions.py create mode 100644 backend/tests/test_sqlite_dqs.py create mode 100644 backend/tests/test_table_versions.py create mode 100644 backend/tests/test_xlsx_scan.py diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 58b4bbd1..78eb92db 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -200,9 +200,9 @@ NODE_REFERENCE = """\ - [[v:Q1.r0.列名]]:第 1 次查询结果里第 0 行那一列的原值(行号从 0 数) - [[table:Q1 cols=列a,列b rows=0-4]]:把查询结果的几行几列原样生成表格,每一格都点得开出处 - [[i:输入字段]]:运行输入 - - [[t:表名]]、[[c:表名.字段名]]:表和字段,点得开是哪次查询用到的、表结构里是什么类型。写在反引号里的名字 - (`orders`)也会被核对:只写本次运行查过的数据源里真实存在的名字,表结构、查询、结果列里都没有的会被标成 - 「可能是编造的名字」(受管模板的正式出具因此降档) + - [[t:表名]]、[[c:表名.字段名]]:表和字段,点得开是哪次查询用到的、表结构里是什么类型。写在反引号里的英文名 + (`orders`)也会被核对,反引号里的中文名不核对,所以中文表名、字段名一律用 [[t:]] / [[c:]]:只写本次运行 + 查过的数据源里真实存在的名字,表结构、查询、结果列里都没有的会被标成「可能是编造的名字」(受管模板的正式出具因此降档) - [[q:K1|逐字引文]]:知识库检索的原话,必须和命中片段里的字逐字一致(空白不计,至少 4 个字),改一个字就算 没有出处;只是拿检索当依据时写 [[see:K1]]。要引原话,在 report 前面接 retrieve 节点 - 句末的 [[see:m:指标id,Q1,K1]]:这句结论的依据,不显示 @@ -329,7 +329,8 @@ def _user_message(payload: GenerateIn, *, patch: bool) -> str: "output_schema + cite_fields,口径卡读 vars..字段。出口字段写 {{ nodes.report的id.text }}\n" "- 只问表结构、清单(有哪些表、有哪些字段、列出名单)的,不加 report,agent → output 就行\n" # 三期起报告会核对写出来的表名、字段名:instructions 里让它「列出用到的表」时,编出来的名字会被标出来 - "- report 提到表名、字段名时写 [[t:表名]] / [[c:表名.字段名]] 或放进反引号,只写 agent 真查过的名字," + "- report 提到表名、字段名时写 [[t:表名]] / [[c:表名.字段名]](英文名也可以放进反引号,中文名不行:" + "反引号里的中文名不核对),只写 agent 真查过的名字," "编造的会被标出来;要引用知识库原话就在 report 前接 retrieve,用 [[q:K1|原话]] 逐字引用。问数是探索运行," "report 的 claims 不用写:结论句支不支持,由读答案的人点开时按需请模型判断\n" "- 用户要的是周报、要三档出具时,按搭图规则配口径卡和 report_from 契约\n" diff --git a/backend/app/api/datasources.py b/backend/app/api/datasources.py index 24271c5a..49944e9c 100644 --- a/backend/app/api/datasources.py +++ b/backend/app/api/datasources.py @@ -5,28 +5,33 @@ """ from __future__ import annotations +import logging from datetime import datetime, timezone from typing import Any from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api import health -from app.core.errors import AUTH, NETWORK, TIMEOUT, classify, explain, first_line, raw +from app.core.errors import AUTH, NETWORK, TIMEOUT, classify, explain, raw from app.core.config import settings from app.core.crypto import encrypt, mask from app.data import introspect as introspect_mod +from app.data import table_versions from app.data.engine import ( - MASK_COLUMNS_OPTION, MAX_QUERY_TIMEOUT_S, QUERY_TIMEOUT_OPTION, SUPPORTED_KINDS, build_url, engine_args, - engines, mask_columns_problem, query_timeout_problem, + MASK_COLUMNS_OPTION, MAX_QUERY_TIMEOUT_S, QUERY_TIMEOUT_OPTION, SUPPORTED_KINDS, SnapshotTampered, build_url, + engine_args, engines, mask_columns_problem, query_timeout_problem, ) -from app.db.base import get_session -from app.db.models import DataSource +from app.db.base import get_session, new_id +from app.db.models import DataSource, SourceSnapshot, TableImport from app.tools.datasource import tool_names router = APIRouter(prefix="/api/datasources", tags=["datasources"]) +logger = logging.getLogger(__name__) # 名字会成为工具名的一部分(db_query__),而模型的 function name 只认 # ASCII。所以这里收紧成 slug,中文说明放 description 字段。 @@ -91,6 +96,10 @@ class DataSourceOut(BaseModel): last_check_ok: bool | None = None last_latency_ms: int | None = None last_error: str | None = None + #: upload:上传的表格(连接信息由系统维护,不能手改、不能重新探查);manual:手工登记的连接 + origin: str = "manual" + #: 上传表格当前启用的版本:{id, created_at, file_name, raw_state}。手工源恒为 None + current_snapshot: dict[str, Any] | None = None def _cached_schema(cache: dict[str, Any]) -> str | None: @@ -99,10 +108,11 @@ def _cached_schema(cache: dict[str, Any]) -> str | None: return str(cache.get("schema") or "") -def _to_out(row: DataSource) -> DataSourceOut: +def _to_out(row: DataSource, snapshot: dict[str, Any] | None = None) -> DataSourceOut: cache = row.schema_cache or {} tables = cache.get("tables") or {} return DataSourceOut( + origin=row.origin or "manual", current_snapshot=snapshot, cached_schema=_cached_schema(cache), schema_error=str(cache.get("error") or "") if cache.get("failed") else "", available_schemas=list(cache.get("available_schemas") or []), @@ -142,10 +152,107 @@ async def _get_or_404(session: AsyncSession, source_id: str) -> DataSource: return row +def _is_upload(row: DataSource) -> bool: + return (row.origin or "manual") == "upload" + + +async def _snapshot_infos(session: AsyncSession, rows: list[DataSource]) -> dict[str, dict[str, Any]]: + """各上传源当前版本的摘要:{源 id: {id, created_at, file_name, raw_state}}。两次查询取齐,不逐个查。""" + wanted = {r.current_snapshot_id: r.id for r in rows if _is_upload(r) and r.current_snapshot_id} + if not wanted: + return {} + snaps = list((await session.execute( + select(SourceSnapshot).where(SourceSnapshot.id.in_(list(wanted))) + )).scalars()) + # 期 1 一个快照只有一期导入;以后按期累积时,显示最近的那一期 + last_import = {s.id: str(s.imports[-1]) for s in snaps if s.imports} + imports = { + i.id: i for i in (await session.execute( + select(TableImport).where(TableImport.id.in_(list(last_import.values()))) + )).scalars() + } if last_import else {} + out: dict[str, dict[str, Any]] = {} + for snap in snaps: + imp = imports.get(last_import.get(snap.id, "")) + out[snap.source_id] = { + "id": snap.id, + "created_at": snap.created_at.isoformat() if snap.created_at else None, + "file_name": imp.file_name if imp else "", + "raw_state": imp.raw_state if imp else "absent", + } + return out + + +async def _out(session: AsyncSession, row: DataSource) -> DataSourceOut: + return _to_out(row, (await _snapshot_infos(session, [row])).get(row.id)) + + +#: 上传源上由系统维护的字段:连接一律从快照解析,database 只作显示 +_UPLOAD_LOCKED = { + "kind": "类型", "host": "主机", "port": "端口", "database": "数据库文件路径", + "username": "用户名", "password": "密码", "readonly": "只读", +} + + +#: 手工源指向上传目录时的拒收原因:创建、修改时 422,测连接时作为失败原因交回 +_UPLOAD_DIR_REFUSED = "数据库文件路径不能指向上传表格的存放目录。上传的表格请通过「上传表格」更新" +#: 升级前就指向上传目录的手工源(启动时已强制只读)想改回可写时的拒收原因 +_UPLOAD_DIR_READONLY = ("这个数据库文件位于上传表格的存放目录,只能以只读方式连接。" + "上传的表格请通过「上传表格」更新") + + +#: 手工源指向 AgentLab 自己的数据文件时的拒收原因:创建、修改时 422,测连接时作为失败原因交回 +_APP_FILES_REFUSED = ("数据库文件路径不能指向 AgentLab 自己的数据文件(应用数据库、运行检查点、密钥、工件等)。" + "请填写存放业务数据的 SQLite 文件") + + +def _in_upload_dir(kind: str | None, database: str | None) -> bool: + return (kind or "").lower() == "sqlite" and table_versions.under_uploads(database) + + +def _in_app_files(kind: str | None, database: str | None) -> bool: + return (kind or "").lower() == "sqlite" and table_versions.reaches_app_files(database) + + +def _reserved_reason(kind: str | None, database: str | None) -> str | None: + """手工登记的 SQLite 源不许指向的位置:返回拒收原因,没问题时返回 None。 + + 上传目录归版本底座管,手工源指过去会绕开只读和版本;应用自己的数据文件(元数据库等)登记成 + 数据源,经审批的一条 UPDATE 就能改掉上传源的连接字段和快照登记。两处都按文件身份判断。 + """ + if _in_app_files(kind, database): + return _APP_FILES_REFUSED + if _in_upload_dir(kind, database): + return _UPLOAD_DIR_REFUSED + return None + + +def _refuse_reserved(kind: str | None, database: str | None) -> None: + reason = _reserved_reason(kind, database) + if reason: + raise HTTPException(422, reason) + + +def _locked_changes(row: DataSource, data: dict[str, Any]) -> list[str]: + """上传源上这次要改的、由系统维护的连接字段(给人看的名字)。 + + 原样带回的值不算修改(编辑表单会把整份配置一起提交);密码只看是否填了新的——上传源本来就没有密码。 + 修改接口和测连接共用:测连接说「能连」、保存却被拒,两边就对不上了。 + """ + return [label for key, label in _UPLOAD_LOCKED.items() if key in data and ( + bool(data[key]) if key == "password" else data[key] != getattr(row, key) + )] + + +def _locked_refusal(changed: list[str]) -> str: + return f"上传的表格由系统维护连接信息,不能修改「{'」「'.join(changed)}」。如需更新数据,请重新上传" + + @router.get("", response_model=list[DataSourceOut]) async def list_sources(session: AsyncSession = Depends(get_session)) -> list[DataSourceOut]: - rows = (await session.execute(select(DataSource).order_by(DataSource.name))).scalars() - return [_to_out(r) for r in rows] + rows = list((await session.execute(select(DataSource).order_by(DataSource.name))).scalars()) + snaps = await _snapshot_infos(session, rows) + return [_to_out(r, snaps.get(r.id)) for r in rows] #: 每种库都有的「查询时限」。数据库按它自己停下语句,不只是后端不再等 @@ -249,6 +356,7 @@ async def create_source( if exists: raise HTTPException(409, f"已存在名为「{payload.name}」的数据源,请换一个标识") _refuse_bad_options(payload.options) + _refuse_reserved(payload.kind, payload.database) row = DataSource( **payload.model_dump(exclude={"password"}), @@ -269,6 +377,29 @@ async def update_source( ) -> DataSourceOut: row = await _get_or_404(session, source_id) data = payload.model_dump(exclude_unset=True) + if _is_upload(row): + # 上传源的连接由版本底座维护(见 _locked_changes) + changed = _locked_changes(row, data) + if changed: + raise HTTPException(422, _locked_refusal(changed)) + for key in _UPLOAD_LOCKED: + data.pop(key, None) + if data.get("options") is not None: + # 上传库只有一个 main:指定 schema 会让探查去找不存在的对象。原样带回的不算修改,摘掉即可 + options = dict(data["options"]) + wanted = str(options.pop("schema", None) or "").strip() + if wanted and wanted != str((row.options or {}).get("schema") or "").strip(): + raise HTTPException(422, "上传的表格只有一个库,不能指定 schema。如需更新数据,请重新上传") + data["options"] = options + elif "kind" in data or "database" in data: + _refuse_reserved(data.get("kind", row.kind), data.get("database", row.database)) + elif _in_app_files(row.kind, row.database): + # 升级前登记的、指向应用自己数据文件的源:启动时已停用(table_versions.startup)。 + # 这类登记没有正当用途,除了改路径和删除,别的修改(重新启用、改回可写……)一律拒 + raise HTTPException(422, _APP_FILES_REFUSED) + elif data.get("readonly") is False and row.readonly and _in_upload_dir(row.kind, row.database): + # 升级前就指向上传目录的手工源:启动时已强制只读(table_versions.startup),不许再改回可写 + raise HTTPException(422, _UPLOAD_DIR_READONLY) if "options" in data: _refuse_bad_options(data["options"]) if "password" in data: @@ -287,15 +418,21 @@ async def update_source( await session.refresh(row) # 连接参数可能变了,旧连接池不能再用——否则改完密码还在用旧连接,很难排查 await engines.invalidate(source_id) - return _to_out(row) + return await _out(session, row) @router.delete("/{source_id}", status_code=204) async def delete_source(source_id: str, session: AsyncSession = Depends(get_session)) -> None: row = await _get_or_404(session, source_id) + # 上传源的导入记录置 retired 留作审计;库文件和原件交给回收,被运行引用的版本会保留 + await table_versions.retire_source(session, source_id) await session.delete(row) await session.commit() await engines.invalidate(source_id) + try: + await table_versions.gc(session) + except Exception: # noqa: BLE001 - 回收失败不影响删除,重启或下次上传时再收 + logger.exception("删除数据源后的回收失败") #: 测连接最多等多久。内网库防火墙丢包时驱动默认要等一分钟,弹窗里干转一分钟 @@ -404,6 +541,10 @@ async def _probe(source: Any, *, cached: bool) -> dict[str, Any]: "elapsed_ms": int((time.perf_counter() - started) * 1000), "url": _safe_url(source), } + except SnapshotTampered as e: + # 快照文件和登记的哈希对不上:原话就是原因,不翻译成泛泛的连接错误 + return {"ok": False, "error": str(e), "hint": "请重新上传这份表格", "detail": "", + "elapsed_ms": int((time.perf_counter() - started) * 1000), "url": _safe_url(source)} except Exception as e: # noqa: BLE001 reason, hint = _explain_connect(e, (source.kind or "").lower()) return { @@ -465,6 +606,17 @@ async def test_draft( return {"ok": False, "error": f"不支持「{payload.kind}」类型的数据库", "hint": f"目前支持:{'、'.join(SUPPORTED_KINDS)}", "detail": "", "elapsed_ms": 0, "url": ""} saved = await session.get(DataSource, payload.id) if payload.id else None + if saved is not None and _is_upload(saved): + # 上传源:连接字段由系统维护。改了就和保存一样拒;没改,测的就是它当前版本的快照, + # 不拿表单里的路径去连——否则带上一个上传源的 id,就能把上传目录里任何文件拿来试 + changed = _locked_changes(saved, payload.model_dump(exclude_unset=True, include=set(_UPLOAD_LOCKED))) + if changed: + return _refused(_locked_refusal(changed)) + return await _test_saved(session, saved) + reason = _reserved_reason(payload.kind, payload.database) + if reason: + # 保存时会被拒(_refuse_reserved):测连接不能先报「连接成功」,到保存才说不行 + return _refused(reason) draft = _draft_source(payload, saved) result = await _probe(draft, cached=False) last, key = _probe_record(result), _connection(draft) @@ -479,8 +631,32 @@ async def test_draft( @router.post("/{source_id}/test") async def test_source(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]: """测连接。失败时说清原因和怎么办,驱动的原始报错放在 detail——这类问题九成靠它定位。""" - row = await _get_or_404(session, source_id) - result = await _probe(row, cached=True) + return await _test_saved(session, await _get_or_404(session, source_id)) + + +def _refused(reason: str) -> dict[str, Any]: + """不去连就能判定失败的测连接结果(形状和 _probe 的一样)。""" + return {"ok": False, "error": reason, "hint": "", "detail": "", "elapsed_ms": 0, "url": ""} + + +async def _test_saved(session: AsyncSession, row: DataSource) -> dict[str, Any]: + """测一个已保存的数据源,结果记进 last_check(提交)。 + + 上传源测的是它当前版本的快照:只读、immutable、建引擎前核对哈希,和查询走同一条路。 + 拿数据源行本身去连,既不核对哈希(快照被改过也报「连接成功」),还会在连接缓存里留下 + 一个不带版本、不核对的引擎。手工源指向应用自己的数据文件(升级前登记的)时不去连。 + """ + if _is_upload(row): + try: + view = await table_versions.resolve_source(session, row) + except table_versions.SnapshotMissing as e: + result = _refused(str(e)) + else: + result = await _probe(view, cached=True) + elif _in_app_files(row.kind, row.database): + result = _refused(_APP_FILES_REFUSED) + else: + result = await _probe(row, cached=True) row.last_check = _probe_record(result) await session.commit() return result @@ -512,8 +688,12 @@ async def introspect_source( dry_run=true 只看不存:「换个 schema 看看」以前一探就把缓存换掉,用户随后 选了「不改」,助手此刻看到的也已经是另一套表了。 + + 上传的表格不探查:结构在发布那一刻随快照冻结,重新探查只会让缓存和快照对不上。 """ row = await _get_or_404(session, source_id) + if _is_upload(row): + raise HTTPException(409, "上传的表格无需重新探查,重新上传即可更新") try: cache = await introspect_mod.introspect(row, schema=schema) except Exception as e: # noqa: BLE001 @@ -544,7 +724,12 @@ async def introspect_source( @router.get("/{source_id}/schema") async def get_schema(source_id: str, table: str | None = None, session: AsyncSession = Depends(get_session)) -> dict[str, Any]: - row = await _get_or_404(session, source_id) + """表结构。上传的表格返回当前快照里冻结的那份——和查询实际用的库是同一个版本。""" + found = await _get_or_404(session, source_id) + try: + row = await table_versions.resolve_source(session, found) + except table_versions.SnapshotMissing as e: + raise HTTPException(409, str(e)) from e if table: meta = introspect_mod.find_table(row, table) return { @@ -568,6 +753,37 @@ async def get_schema(source_id: str, table: str | None = None, # 上传表格 # -------------------------------------------------------------------------- +#: 「数字列混入非数字」的两种处理:reject 拒收并请用户选择;null 非数字的值存空值、列按数字存 +_MIXED_CHOICES = ("reject", "null") + + +def _decision(e: Exception) -> dict[str, Any] | None: + """解析器要用户先做决定(tabular.NeedsDecision:交叉表、数字列混入非数字)时,取出决定的内容。 + + NeedsDecision 是 UnsupportedTable 的子类,多带 kind 和 details 两个属性;普通的 + UnsupportedTable 没有。按属性认,而不是按类名:接口层只关心「要不要请用户选」。 + """ + kind, details = getattr(e, "kind", None), getattr(e, "details", None) + if not kind or not isinstance(details, dict): + return None + return {"kind": kind, "details": jsonable_encoder(details)} + + +def _table_out(t: dict[str, Any]) -> dict[str, Any]: + """回执里的一张表。原表头和列名并排回显:表头行取错了(比如文件前两行是标题),当场看得出来。""" + return { + "name": t.get("name"), "sheet": t.get("sheet"), "rows": t.get("rows", 0), + "columns": [ + {"name": c.get("name"), "type": c.get("type"), "header": c.get("header")} + for c in t.get("columns") or [] if isinstance(c, dict) + ], + "region": t.get("region"), + "unshaped": bool(t.get("unshaped")), + "blank_rows_skipped": t.get("blank_rows_skipped", 0), + # 原样转交解析器给的值,不替它改类型 + "columns_trimmed": t.get("columns_trimmed", []), + } + @router.post("/upload", response_model=dict, status_code=201) async def upload_table( @@ -575,27 +791,37 @@ async def upload_table( name: str = Form(...), description: str = Form(""), header_row: int = Form(1), + mixed: str = Form("reject"), + raw_mode: bool = Form(False), session: AsyncSession = Depends(get_session), -) -> dict[str, Any]: +) -> Any: """把 Excel / CSV 变成一个可以用 SQL 查的数据源。 - 走 SQLite:落成一个 .db 文件,再建一条 kind='sqlite' 的数据源指过去。 - 下游一行都不用改——SQL 守卫、结构探查、db_query__ 工具、查询快照进 - 工件库、Copilot 的数据源清单,全都白拿。 + 落成一个 SQLite 库,数据源 kind='sqlite' 指过去。下游一行都不用改——SQL 守卫、 + 结构探查、db_query__ 工具、查询快照进工件库、助手的数据源清单,全都白拿。 这条路存在的理由不是"多支持一种格式":表格传进知识库只能被切块检索, 数字就成了模型从片段里读出来的,而这个项目的地基是"所有算术下沉到 SQL 或口径卡"。表格必须变成表。 - **同名就地替换**,不新建。数据源名字会成为工具名(db_query__sales), + **同名就地更新**,不新建。数据源名字会成为工具名(db_query__sales), 而工具名写进了保存过的工作流——重传时另起一个 sales_2 等于悄悄让那些图失效。 + 每次上传发布成一个新版本(data/table_versions.py):写新文件、最后切指针, + 失败时旧版本完整可查;原件按哈希存档,不提供下载。 + + 解析器需要用户先做决定时(交叉表 / 多块结构、数字列混入非数字)回 422,body 里的 + decision 说明要选什么;mixed、raw_mode 是用户选完再传回来的答案。 """ import re as _re + from app.data.tabular import UnsupportedTable + if not _re.match(_NAME_PATTERN, name or ""): raise HTTPException( 400, "名称必须以小写字母开头,只能包含字母、数字和下划线(名称会成为工具名的一部分)" ) + if mixed not in _MIXED_CHOICES: + raise HTTPException(400, "数字列混入非数字时,只能选择拒收或将非数字的值存为空值") # 边读边数,超了立刻停。和知识库上传同一套:那个检查拦的是"入库", # 不是"占内存",读完再判等于先把内存吃掉 @@ -609,67 +835,124 @@ async def upload_table( pieces.append(piece) raw = b"".join(pieces) - tables_dir = settings.uploads_dir / "tables" - tables_dir.mkdir(parents=True, exist_ok=True) - db_path = tables_dir / f"{name}.db" - - from app.data.tabular import UnsupportedTable, load_into - existing = (await session.execute( select(DataSource).where(DataSource.name == name) )).scalar_one_or_none() - if existing and existing.kind != "sqlite": + # 同名的只有上传表格可以更新。迁移前的老上传(路径还在上传目录里)也算 + if existing is not None and not _is_upload(existing) and not ( + existing.kind == "sqlite" and table_versions.under_uploads(existing.database) + ): raise HTTPException( 409, f"已存在名为「{name}」的数据源(非上传表格),请换一个名称" ) - try: - report = load_into(str(db_path), raw, file.filename or name, header_row=header_row) - except UnsupportedTable as e: - raise HTTPException(400, str(e)) from e - except Exception as e: # noqa: BLE001 - raise HTTPException( - 400, f"导入失败:{first_line(e) if isinstance(e, ValueError) else explain(e)[0]}。" - "请确认文件是未加密的 Excel 或 CSV,且表头行号填写正确", - ) from e - row = existing if row is None: - row = DataSource( - name=name, kind="sqlite", database=str(db_path), - readonly=True, description=description, - ) - session.add(row) - else: - row.database = str(db_path) - if description: - row.description = description - # 换了文件内容,缓存的连接还指着旧的那个 engine - await engines.invalidate(row.id) + # 先不进会话:发布失败时这个源不该被建出来,由 publish_upload 在提交前加入 + row = DataSource(id=new_id(), name=name, kind="sqlite", readonly=True, + description=description, origin="upload") + elif description: + row.description = description - await session.flush() - # 建完立刻探查:不探的话 db_query 工具的 description 里没有表清单, - # 模型得先花一步去问"有哪些表" try: - row.schema_cache = await introspect_mod.introspect(row) - row.schema_synced_at = datetime.now(timezone.utc) - except Exception: # noqa: BLE001 - 探查失败不该让导入白做,用户可以手动再探 - pass - await session.commit() - await session.refresh(row) + result = await table_versions.publish_upload( + session, row, raw, file.filename or name, + header_row=header_row, mixed=mixed, raw_mode=raw_mode, + ) + except UnsupportedTable as e: + decision = _decision(e) + if decision is not None: + return JSONResponse(status_code=422, content={"detail": str(e), "decision": decision}) + raise HTTPException(400, str(e)) from e + except table_versions.PublishError as e: + logger.error("上传表格「%s」发布失败:%s", name, e) + raise HTTPException(500, str(e)) from e + await session.refresh(row) + report = result.report return { - "source": _to_out(row).model_dump(), + "source": (await _out(session, row)).model_dump(), "replaced": existing is not None, - # 推断出的列名和类型原样回显:表头行取错了(比如文件前两行是标题), - # 这里当场就能看出来——列名会变成一行数据 - "tables": [ - { - "name": t.name, "sheet": t.source_name, "rows": t.rows, - "columns": [ - {"name": c, "type": ty} for c, ty in zip(t.columns, t.types) - ], - } - for t in report.tables + "import_id": result.import_id, + "snapshot_id": result.snapshot_id, + "build_reused": result.build_reused, + "skipped_sheets": report.get("skipped_sheets") or [], + "conversions": report.get("conversions") or [], + "warnings": report.get("warnings") or [], + "tables": [_table_out(t) for t in report.get("tables") or [] if isinstance(t, dict)], + } + + +def _import_out(imp: TableImport, current: set[str]) -> dict[str, Any]: + return { + "id": imp.id, "seq": imp.seq, "build_id": imp.build_id, + "file_name": imp.file_name, "file_size": imp.file_size, "raw_sha256": imp.raw_sha256, + "raw_state": imp.raw_state, "status": imp.status, "purged": imp.purged, + "current": imp.id in current, + "created_at": imp.created_at.isoformat() if imp.created_at else None, + "activated_at": imp.activated_at.isoformat() if imp.activated_at else None, + } + + +async def _current_imports(session: AsyncSession, row: DataSource) -> set[str]: + if not row.current_snapshot_id: + return set() + snap = await session.get(SourceSnapshot, row.current_snapshot_id) + return {str(x) for x in (snap.imports or [])} if snap else set() + + +@router.get("/{source_id}/imports") +async def list_imports(source_id: str, session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]: + """上传表格的导入记录,新的在前。手工登记的源没有导入记录,返回空列表。""" + row = await _get_or_404(session, source_id) + rows = (await session.execute( + select(TableImport).where(TableImport.source_id == source_id).order_by(TableImport.seq.desc()) + )).scalars() + current = await _current_imports(session, row) + return [_import_out(i, current) for i in rows] + + +class PurgeRawIn(BaseModel): + reason: str = Field(min_length=1, max_length=500, description="为什么清除这份原件") + signed_by: str | None = Field( + default=None, max_length=100, + description="署名(未认证):自填的名字,系统不核实身份,只原样记进清除记录", + ) + + +@router.post("/{source_id}/imports/{import_id}/purge-raw") +async def purge_raw( + source_id: str, import_id: str, payload: PurgeRawIn, session: AsyncSession = Depends(get_session) +) -> dict[str, Any]: + """清除一次导入的原件。导入记录、哈希和库都保留,原件文件删掉,记下时间、署名和理由。 + + 按内容清除:同一份内容可能被别的导入共用(同一个文件传过两次、传给过两个源),清除的 + 目的是让这份内容从服务器上消失,所以文件照删,那些导入一并标成已清除,回执的 + also_purged 逐条列出(期 3 的界面要把它展示出来,不能静默带过)。 + """ + row = await _get_or_404(session, source_id) + imp = await session.get(TableImport, import_id) + if imp is None or imp.source_id != source_id: + raise HTTPException(404, "导入记录不存在") + reason = payload.reason.strip() + if not reason: + raise HTTPException(422, "请填写清除原件的理由") + if imp.raw_state == "absent": + raise HTTPException(409, "这次导入没有保存原件") + if imp.raw_state == "purged": + raise HTTPException(409, "这次导入的原件已经清除") + deleted, others = await table_versions.purge_import_raw( + session, imp, reason=reason, signed_by=payload.signed_by) + current = await _current_imports(session, row) + names = dict((await session.execute( + select(DataSource.id, DataSource.name).where(DataSource.id.in_({o.source_id for o in others})) + )).all()) if others else {} + return { + "import": _import_out(imp, current), + "file_deleted": deleted, + "also_purged": [ + {"id": o.id, "source_id": o.source_id, "source_name": names.get(o.source_id), + "seq": o.seq, "file_name": o.file_name} + for o in others ], } diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index ff622173..7578d1bd 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -193,25 +193,42 @@ async def start_run( # -------------------------------------------------------------------------- -def _bindings(spec: GraphSpec) -> list[tuple[str, str]]: - """图里每一处绑定的工具:(谁绑的, 工具名)。调用工具节点的 tool、Agent 的 tools、协作成员的 tools。""" +def node_setting(spec: GraphSpec, node: Any, key: str) -> Any: + """节点配置的实际取值,和运行时 NodeContext.cfg 同一个规则:节点上没写(None、空串)就取图级 defaults。""" + value = node.config.get(key) + return spec.defaults.get(key) if value in (None, "") else value + + +def tool_bindings(spec: GraphSpec) -> list[tuple[str, str]]: + """图里每一处绑定的工具:(谁绑的, 工具名)。调用工具节点的 tool、Agent 的 tools、协作成员的 tools。 + + 取值和运行时一致(node_setting):Agent 节点上没写 tools、协作节点上没写成员,运行时用的是 + 图级 defaults 里的,这里也算它们——不然发起前检查放过了缺的工具,发起时固定数据版本也漏了源。 + + 模板拼出来的名字({{ vars.tool }})也在内:发起前检查(_bindings)跳过它们,发起时固定数据 + 版本(engine/runner.py)要知道有没有这种名字——有的话说不准会用到哪个数据源。 + """ out: list[tuple[str, str]] = [] for node in spec.nodes: who = f"「{node.title}」({type_label(node.type)})" - cfg = node.config if node.type == NodeType.TOOL: - names = [cfg.get("tool")] + names = [node_setting(spec, node, "tool")] out.extend((who, n) for n in names if isinstance(n, str) and n.strip()) elif node.type == NodeType.AGENT: - out.extend((who, n) for n in cfg.get("tools") or [] if isinstance(n, str) and n.strip()) + out.extend((who, n) for n in node_setting(spec, node, "tools") or [] + if isinstance(n, str) and n.strip()) elif node.type == NodeType.SUPERVISOR: - for member in cfg.get("agents") or []: + for member in node_setting(spec, node, "agents") or []: if not isinstance(member, dict): continue label = f"「{node.title}」的成员「{member.get('name') or '?'}」" out.extend((label, n) for n in member.get("tools") or [] if isinstance(n, str) and n.strip()) - # 模板拼出来的工具名({{ vars.tool }})发起时还不知道是谁,交给运行时 - return [(who, name.strip()) for who, name in out if "{{" not in name] + return [(who, name.strip()) for who, name in out] + + +def _bindings(spec: GraphSpec) -> list[tuple[str, str]]: + """发起前要检查的绑定。模板拼出来的工具名({{ vars.tool }})发起时还不知道是谁,交给运行时。""" + return [(who, name) for who, name in tool_bindings(spec) if "{{" not in name] async def missing_tools(session: AsyncSession, spec: GraphSpec) -> list[tuple[str, str, str]]: diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index bdc7a34d..24c6db50 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -14,7 +14,7 @@ from app.db.models import CustomTool, McpServer from app.tools.custom import ToolFailure, schema_problem, trial from app.tools.mcp_manager import mcp_manager from app.tools.registry import ( - ToolArgsError, ToolBuildError, ToolContext, all_specs, build_tools, call_is_dangerous, + ToolArgsError, ToolBuildError, ToolContext, Versions, all_specs, build_tools, call_is_dangerous, call_tool, get_spec, ) from app.tools.trust import load_trust, set_trust @@ -135,8 +135,9 @@ async def _side_effect(name: str, args: dict[str, Any], session: AsyncSession) - return (f"将调用自定义接口「{name}」" f"({cfg.get('method') or 'GET'} {cfg.get('url') or '(未填写地址)'})") return f"将在沙箱中执行自定义工具「{name}」的代码" - # 数据源工具:只读源上的查询没有副作用,可写源上的写操作有 - tools = await build_tools([name], ToolContext(run_id="playground", node_id="playground"), + # 数据源工具:只读源上的查询没有副作用,可写源上的写操作有。不在运行里,上传表格看当前版本 + tools = await build_tools([name], ToolContext(run_id="playground", node_id="playground", + data_versions=Versions.CURRENT), session=session) if tools and call_is_dangerous(tools[0], name, args): return f"将在可写数据源上执行写操作:{str(args.get('sql') or '')[:120]}" @@ -163,8 +164,9 @@ async def run_tool( # 放进确认框里读起来就不对 raise HTTPException(409, f"{effect}。在工具库中执行不经过审批,确认后才会执行。") + # 不在运行里:上传表格查当前版本(运行里查的是发起时固定的那一版) ctx = ToolContext(run_id="playground", node_id="playground", - sandbox_session=payload.sandbox_session) + sandbox_session=payload.sandbox_session, data_versions=Versions.CURRENT) import time started = time.perf_counter() @@ -285,7 +287,8 @@ async def _trial(row: CustomTool, args: dict[str, Any]) -> dict[str, Any]: import json import time - ctx = ToolContext(run_id="playground", node_id="test", sandbox_session="playground") + ctx = ToolContext(run_id="playground", node_id="test", sandbox_session="playground", + data_versions=Versions.CURRENT) started = time.perf_counter() def took() -> int: diff --git a/backend/app/data/engine.py b/backend/app/data/engine.py index b89d8e51..395e8216 100644 --- a/backend/app/data/engine.py +++ b/backend/app/data/engine.py @@ -6,16 +6,20 @@ MySQL 要 charset、asyncpg 不认 sslmode 这些事。哪天要加一种库, from __future__ import annotations import asyncio +import hashlib import json import logging +import os import re +import sqlite3 import time +from collections import OrderedDict from contextlib import asynccontextmanager from dataclasses import dataclass from typing import Any, AsyncIterator, Awaitable, Callable from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine -from sqlalchemy import text +from sqlalchemy import event, text from app.core.crypto import decrypt from app.data import guard @@ -134,6 +138,122 @@ def build_url(source: Any, *, reveal: bool = False) -> str: return f"{driver}://{auth}{host}/{source.database or ''}{('?' + query) if query else ''}" +class SqliteHardeningError(sqlite3.OperationalError): + """SQLite 连接没能关掉「双引号当字符串」的兼容行为。这条连接不交出去。 + + 继承 sqlite3.OperationalError:SQLAlchemy 按驱动异常包装,上层(测试连接、工具报错) + 照常拿到一个连接失败,而不是一个不认识的异常类型。 + """ + + +class _DqsOffConnection(sqlite3.Connection): + """打开时就关掉 DQS 的 sqlite3 连接(经 sqlite3.connect 的 factory 参数接入)。 + + DQS(double-quoted string)是 SQLite 的历史兼容:双引号里的名字找不到对应的列时, + 当成字符串字面量。于是列名写错不报错:`SUM("金颔")` 得 0,`GROUP BY "金颔"` + 把整张表归成一组,结论错了却没人知道。关掉以后同一条 SQL 报 no such column。 + + 为什么用 factory 而不是 SQLAlchemy 的 connect 事件:aiosqlite 的连接在它自己的 + 线程里建,connect 事件拿到的是异步适配层,要碰底层 sqlite3 连接只能走 aiosqlite + 的私有属性(_execute / _conn),升级就可能失效。factory 是 sqlite3.connect 的公开 + 参数,aiosqlite.connect 的 **kwargs 原样转给它,SQLAlchemy 的 connect_args 又原样 + 转给 aiosqlite.connect——三层都是公开接口。设置在建连接的那个线程里同步完成, + 没设上就在这里抛错,连接根本不会进连接池。而且凡是经 engine_args 建的 engine + (连接缓存、表单里的一次性测试连接)都自动带上,不必逐处挂事件。 + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + try: + _disable_dqs(self) + except BaseException: + self.close() + raise + + +#: 要关掉的两项:DML(查询、增删改)和 DDL(建表、建索引)里的双引号字符串 +_DQS_OPTIONS = ( + ("DML", getattr(sqlite3, "SQLITE_DBCONFIG_DQS_DML", None)), + ("DDL", getattr(sqlite3, "SQLITE_DBCONFIG_DQS_DDL", None)), +) + + +def _disable_dqs(conn: sqlite3.Connection) -> None: + """关掉 DQS,并读回来确认确实关了。任何一步不成立都抛 SqliteHardeningError。 + + Python 3.12 起才有 setconfig / getconfig;没有的话同样拒绝——宁可连不上, + 也不交出一条会把写错的列名静默当成字符串的连接。 + """ + for label, op in _DQS_OPTIONS: + try: + if op is None: + raise AttributeError(f"sqlite3 模块缺少 SQLITE_DBCONFIG_DQS_{label}") + conn.setconfig(op, False) + still_on = conn.getconfig(op) + except Exception as e: # noqa: BLE001 - 原因原样带出,统一换成拒绝连接 + raise SqliteHardeningError(_dqs_refusal(f"{type(e).__name__}: {e}")) from e + if still_on: + raise SqliteHardeningError(_dqs_refusal(f"设置后读回仍为开启({label})")) + + +def _dqs_refusal(cause: str) -> str: + return ("未能关闭 SQLite 的双引号字符串兼容,已拒绝连接" + f"(否则写错的列名会被当成文字,查询不报错却给出错误结果):{cause}") + + +#: 自检先建的探针表 +_SELF_CHECK_SETUP = "CREATE TABLE _dqs_probe (a INTEGER)" +#: 自检的探针:每条都写了一个不存在的双引号列名,关掉 DQS 后都必须报 no such column。 +#: 查询一条验 DML 那项,建索引一条验 DDL 那项——只关一项时另一条会照常执行。 +#: 用 ASCII 名字:DQS 对中英文名字一视同仁,SQL 原文也不必进文案检查 +_SELF_CHECK_PROBES = ( + 'SELECT "no_such_column" FROM _dqs_probe', + 'CREATE INDEX _dqs_probe_i ON _dqs_probe ("no_such_column")', +) + + +def sqlite_dqs_self_check() -> None: + """启动自检:在内存库上确认 DQS 确实关得掉,写错的双引号列名确实报错。 + + 连接层的每条 SQLite 连接都靠 _DqsOffConnection 关 DQS;这里用同一个类开一条内存 + 连接,查询和建索引各验一次。不成立就抛 RuntimeError,让服务起不来——Python 或 + SQLite 换了版本、行为变了,宁可启动失败,也不悄悄退回「列名写错照样出数」。 + + 只认「no such column」这一种报错:探针因为别的原因报错(表没建上、语法变了), + 说明它根本没走到双引号名字那一步,什么也没验到,同样算失败。 + """ + problem: str | None = None + try: + conn = sqlite3.connect(":memory:", factory=_DqsOffConnection) + except Exception as e: # noqa: BLE001 + problem = str(e) + else: + try: + conn.execute(_SELF_CHECK_SETUP) + for sql in _SELF_CHECK_PROBES: + try: + conn.execute(sql) + except sqlite3.OperationalError as e: + if "no such column" not in str(e): + problem = f"{sql} 报错,但不是预期的「no such column」:{e}" + break + else: + problem = f"{sql} 没有报错:双引号里的名字仍被当成字符串" + break + except Exception as e: # noqa: BLE001 - 探针表都建不起来,同样是没验到 + problem = f"{type(e).__name__}: {e}" + finally: + conn.close() + if problem: + logger.critical("SQLite 自检失败,服务不启动:%s", problem) + raise RuntimeError(f"SQLite 自检失败:无法确认写错的双引号列名会报错,服务不启动。{problem}") + + +def _sqlite_extra() -> dict[str, Any]: + """每个 SQLite engine 都带的连接参数:关 DQS。""" + return {"connect_args": {"factory": _DqsOffConnection}} + + def engine_args(source: Any) -> tuple[str, dict[str, Any]]: """真正连库用的连接串和额外参数。只读源在这里拿到**连接层**的只读。 @@ -146,16 +266,17 @@ def engine_args(source: Any) -> tuple[str, dict[str, Any]]: - PostgreSQL:会话的默认事务只读。能关掉它的 SET / set_config() 守卫都拦。 - MySQL / MariaDB:会话事务只读。能关掉它的只有 SET,守卫拦。 - Oracle:没有会话级只读开关,只剩守卫这一道——只读数据源请配只读账号。 + + SQLite 不论只读与否都关 DQS(见 _DqsOffConnection)。source 带 immutable + (上传表格的版本快照,见 table_versions.SourceView)时,以 mode=ro&immutable=1 + 打开,不看 readonly 字段:快照文件发布后就不再变,immutable 让 SQLite 不加锁、 + 不找日志文件,同时也写不进去。 """ url = build_url(source, reveal=True) - if not source.readonly: - return url, {} kind = (source.kind or "").lower() if kind == "sqlite": - path = source.database or "" - if path and path != ":memory:": - from urllib.parse import quote - url = f"{_DRIVERS['sqlite']}:///file:{quote(path)}?mode=ro&uri=true" + return _sqlite_url(source, url), _sqlite_extra() + if not source.readonly: return url, {} if kind in ("postgres", "postgresql"): return url, {"connect_args": {"server_settings": {"default_transaction_read_only": "on"}}} @@ -164,6 +285,19 @@ def engine_args(source: Any) -> tuple[str, dict[str, Any]]: return url, {} +def _sqlite_url(source: Any, url: str) -> str: + from urllib.parse import quote + + path = source.database or "" + if getattr(source, "immutable", False): + if not path or path == ":memory:": + raise ValueError("版本快照必须指向一个数据文件") + return f"{_DRIVERS['sqlite']}:///file:{quote(path)}?mode=ro&immutable=1&uri=true" + if source.readonly and path and path != ":memory:": + return f"{_DRIVERS['sqlite']}:///file:{quote(path)}?mode=ro&uri=true" + return url + + def _timeout_value(value: Any) -> float | None: """options 里的查询时限:表单存的是字符串,也认数字。填得不对返回 None。""" if value is None or isinstance(value, bool): @@ -218,46 +352,247 @@ def _seconds(value: float) -> str: return str(int(value)) if float(value).is_integer() else f"{value:g}" +#: 连接缓存最多留多少个 engine。版本快照各占一个键,长期运行下键只增不减; +#: 超出时关掉最久没用的那个,下次用到再建 +MAX_ENGINES = 32 + + +class SnapshotTampered(ValueError): + """版本快照的数据文件和登记的哈希对不上(或者文件没了)。拒绝查询,也不缓存。""" + + +_TAMPERED = "数据文件与登记的版本不一致,可能被修改过,已拒绝查询" +_SNAPSHOT_UNREADABLE = "登记版本的数据文件无法读取,无法核对是否被修改过,已拒绝查询" +_NO_HASH = "数据版本缺少文件哈希的登记,无法核对数据文件是否被修改过,已拒绝查询" +_REPLACED = "数据文件在核对之后被替换或改动过,已拒绝这次查询;再次查询时会重新核对" + +#: 快照文件的指纹:(st_dev, st_ino, st_size, st_mtime_ns)。整份替换(rename 覆盖)换 inode, +#: 原地改写换大小或修改时间:两种都认得出,而 stat 只要几微秒,每次取引擎、每次借连接都查得起 +Fingerprint = tuple[int, int, int, int] + + +def cache_key(source: Any) -> str: + """连接缓存的键:数据源 id 加版本快照 id(没有快照的源为空)。 + + 上传表格每次发布都是一个新文件,同一个数据源的新旧版本得各用各的连接池—— + 否则固定在旧版本上的运行会查到新文件,或者反过来。 + """ + return f"{source.id}:{getattr(source, 'snapshot_id', None) or ''}" + + +def _source_of(key: str) -> str: + # 快照 id 是十六进制,不含冒号;数据源 id 里就算有冒号,从右边切也切得对 + return key.rsplit(":", 1)[0] + + +def _file_sha256(path: str) -> str: + with open(path, "rb") as f: + return hashlib.file_digest(f, "sha256").hexdigest() + + +def _fingerprint(path: str) -> Fingerprint | None: + try: + st = os.stat(path) + except (OSError, ValueError): + return None + return st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns + + +class _Moving(OSError): + """算哈希的过程中文件变了:算出来的哈希说明不了现在的文件。""" + + +def _hash_and_print(path: str) -> tuple[str, Fingerprint]: + """算文件哈希,连同它的指纹。前后各 stat 一次:对不上说明读的过程中文件被替换或改写了。 + + 指纹和哈希必须描述同一个文件:之后每次取引擎、借连接都拿当时的 stat 和这份指纹比, + 比得上才说明 SQLite 打开的还是核对过的那个文件。 + """ + before = _fingerprint(path) + digest = _file_sha256(path) + after = _fingerprint(path) + if before is None or before != after: + raise _Moving(f"文件在核对过程中发生了变化:{path}") + return digest, after + + +async def _verify_snapshot(source: Any, expected: str) -> Fingerprint: + """建 engine 之前核对快照文件的哈希,返回核对时的文件指纹。大文件读一遍要时间,放到线程里,不卡事件循环。""" + try: + actual, fingerprint = await asyncio.to_thread(_hash_and_print, source.database or "") + except _Moving as e: + logger.warning("快照文件在核对过程中发生了变化:source=%s snapshot=%s path=%s", + source.id, getattr(source, "snapshot_id", None), source.database) + raise SnapshotTampered(_TAMPERED) from e + except OSError as e: + raise SnapshotTampered(_SNAPSHOT_UNREADABLE) from e + if actual.lower() != str(expected).strip().lower(): + logger.warning("快照文件哈希不符:source=%s snapshot=%s path=%s", + source.id, getattr(source, "snapshot_id", None), source.database) + raise SnapshotTampered(_TAMPERED) + return fingerprint + + +@dataclass +class _Pin: + """一个快照引擎核对过的文件:路径和核对时的指纹。stale 表示已经发现对不上,不再可信。""" + + path: str + fingerprint: Fingerprint + stale: bool = False + + def holds(self) -> bool: + return not self.stale and _fingerprint(self.path) == self.fingerprint + + +def _guard_pool(engine: AsyncEngine, pin: _Pin, key: str) -> None: + """每次从连接池借连接时核对文件指纹,对不上就拒绝这条连接。 + + 取引擎时核对过一次,但引擎缓存着、连接池会在之后新开连接(并发查询、pool_recycle、 + 探活失败重连),每条新连接都按路径重新打开文件。文件要是在这期间被整份替换(恢复备份、 + 同步工具),新连接读到的就是没核对过的文件。所以借连接(checkout)时再比一次:新开的 + 连接打开的是哪个文件,这时 stat 看到的就是哪个。对不上就把这个键标成不可信并拒绝; + 抛出的异常让 SQLAlchemy 关掉这条连接,下次取引擎时重新核对哈希(内容没变的恢复会放行)。 + """ + def check(*_args: Any) -> None: + if not pin.holds(): + if not pin.stale: + logger.warning("快照文件在核对之后发生了变化,已拒绝连接:key=%s path=%s", key, pin.path) + pin.stale = True + raise SnapshotTampered(_REPLACED) + + event.listen(engine.sync_engine, "checkout", check) + + class EngineCache: - """按数据源 id 缓存 engine。连接池的建立不便宜,不该每次查询都重来一遍。 + """按数据源(和版本快照)缓存 engine。连接池的建立不便宜,不该每次查询都重来一遍。 配置变了要显式 invalidate——改了密码却还在用旧连接,排查起来很费神; - 改了只读开关却还在用旧连接,只读就形同虚设。 + 改了只读开关却还在用旧连接,只读就形同虚设。invalidate 清掉这个源名下所有版本的连接。 + + 最多留 MAX_ENGINES 个,按最近使用排序,超出时关掉最久没用的。 + source 带 expected_sha256 时,这个键新建 engine 前核对一遍文件哈希,并记下核对时的文件指纹 + (inode、大小、修改时间)。之后缓存命中、连接池借连接时都拿当时的 stat 比这份指纹:快照文件 + 0444 挡不住整份替换(rename 只要目录可写),对不上就摘掉这个引擎、下次重新核对哈希——内容 + 没变(备份恢复回同样的文件)就放行,变了就拒绝。同一个键并发首次使用时只核对一次,大家等同一个结果。 + source 标着 immutable(版本快照)却没给期望哈希的,一律拒绝:默认放行的话,哪条新路径漏传 + 哈希,快照就不经核对地打开了。 """ - def __init__(self) -> None: - self._engines: dict[str, AsyncEngine] = {} + def __init__(self, max_engines: int | None = None) -> None: + self._engines: OrderedDict[str, AsyncEngine] = OrderedDict() + #: 快照引擎核对过的文件,键和 _engines 相同。手工源没有 + self._pins: dict[str, _Pin] = {} self._lock = asyncio.Lock() + #: None 表示按模块常量 MAX_ENGINES(现取,测试里可以改) + self._max = max_engines + #: 正在进行的快照核对,按(缓存键, 文件, 登记的哈希)合并。核对结束(不论成败)即移除, + #: 失败结果不留:下次照常重新核对 + self._verifying: dict[tuple[str, str, str], asyncio.Future[Fingerprint]] = {} + + def _hit(self, key: str, doomed: list[AsyncEngine]) -> AsyncEngine | None: + """缓存里还可信的 engine。快照文件和核对时的指纹对不上的,摘出来放进 doomed 交给调用方关掉。""" + engine = self._engines.get(key) + if engine is None: + return None + pin = self._pins.get(key) + if pin is not None and not pin.holds(): + if not pin.stale: + logger.warning("快照文件在核对之后发生了变化,重新核对:key=%s path=%s", key, pin.path) + del self._engines[key] + self._pins.pop(key, None) + doomed.append(engine) + return None + self._engines.move_to_end(key) + return engine async def get(self, source: Any) -> AsyncEngine: - key = str(source.id) - cached = self._engines.get(key) - if cached is not None: - return cached - async with self._lock: - if key in self._engines: - return self._engines[key] - url, extra = engine_args(source) - engine = create_async_engine( - url, - pool_size=3, - max_overflow=2, - pool_pre_ping=True, # 长时间空闲后连接会被数据库掐掉,先探活再用 - pool_recycle=1800, - **extra, - ) - self._engines[key] = engine + key = cache_key(source) + doomed: list[AsyncEngine] = [] + try: + cached = self._hit(key, doomed) + if cached is not None: + return cached + expected = str(getattr(source, "expected_sha256", None) or "").strip() + if getattr(source, "immutable", False) and not expected: + logger.warning("版本快照没有登记文件哈希,已拒绝:key=%s path=%s", key, source.database) + raise SnapshotTampered(_NO_HASH) + fingerprint: Fingerprint | None = None + if expected: + # 在锁外核对:哈希一个大文件要几秒,不能让别的数据源都排在它后面 + fingerprint = await self._verify_once(key, source, expected) + async with self._lock: + cached = self._hit(key, doomed) + if cached is not None: + return cached + url, extra = engine_args(source) + engine = create_async_engine( + url, + pool_size=3, + max_overflow=2, + pool_pre_ping=True, # 长时间空闲后连接会被数据库掐掉,先探活再用 + pool_recycle=1800, + **extra, + ) + if fingerprint is not None: + pin = _Pin(str(source.database or ""), fingerprint) + _guard_pool(engine, pin, key) + self._pins[key] = pin + self._engines[key] = engine + limit = max(1, self._max if self._max is not None else MAX_ENGINES) + while len(self._engines) > limit: + old_key, old = self._engines.popitem(last=False) + self._pins.pop(old_key, None) + doomed.append(old) return engine + finally: + for old in doomed: + # 正被借出的连接不受影响:dispose 只关池子里空闲的,借出的还回来时随旧池子回收 + await old.dispose() + + async def _verify_once(self, key: str, source: Any, expected: str) -> Fingerprint: + """核对快照哈希;同一个键已经有一次在核对,就等那一次的结果。 + + 一次运行开头往往同时发起几次工具调用,查的是同一个快照。不合并的话每个请求 + 各读一遍整个文件(快照可能几百 MB),还挤占默认线程池。 + + 合并的范围带上文件路径和登记的哈希:键相同而这两样不同的(不该出现,但不赌) + 各核各的,绝不拿别人的结论放行。核对本身包成任务、等的时候加 shield:某个 + 请求被取消,不连累别人在等的那一次。核对结束就移除——成功之后到 engine + 装进缓存之间若恰好来了新请求,它会再核对一次;多算一次,不会少算。 + """ + token = (key, str(source.database or ""), str(expected).strip().lower()) + loop = asyncio.get_running_loop() + pending = self._verifying.get(token) + if pending is None or pending.get_loop() is not loop: + pending = loop.create_task(_verify_snapshot(source, expected)) + self._verifying[token] = pending + pending.add_done_callback(lambda done: self._settle_verify(token, done)) + return await asyncio.shield(pending) + + def _settle_verify(self, token: tuple[str, str, str], done: asyncio.Future[Fingerprint]) -> None: + # 只移除自己:失效后可能已经有新的一次登记在同一个位置上 + if self._verifying.get(token) is done: + del self._verifying[token] async def invalidate(self, source_id: str) -> None: - engine = self._engines.pop(str(source_id), None) - if engine is not None: + sid = str(source_id) + # 正在进行的核对也撇开:失效之后来的请求重新核对,不搭失效之前那一次的便车 + for token in [t for t in self._verifying if _source_of(t[0]) == sid]: + del self._verifying[token] + keys = [key for key in list(self._engines) if _source_of(key) == sid] + doomed = [self._engines.pop(key) for key in keys] + for key in keys: + self._pins.pop(key, None) + for engine in doomed: await engine.dispose() async def close(self) -> None: + self._verifying.clear() for engine in list(self._engines.values()): await engine.dispose() self._engines.clear() + self._pins.clear() engines = EngineCache() diff --git a/backend/app/data/introspect.py b/backend/app/data/introspect.py index 8df4048c..9eec9fb2 100644 --- a/backend/app/data/introspect.py +++ b/backend/app/data/introspect.py @@ -27,6 +27,8 @@ from app.data.engine import engines _MAX_TABLES = 200 # summary 里每张表列几个代表字段。够 Copilot 判断"这表是不是我要的"即可。 _PREVIEW_COLUMNS = 6 +# 批量反射失败、逐个对象重试时,开头连着这么多个都取不到就不再试了 +_GIVE_UP_AFTER = 5 # 各家的系统 schema,发现可用 schema 时要排掉——否则用户面对的是一屏 @@ -141,25 +143,49 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any # shop 那次 "Lost connection to MySQL server during query" # (run 554a0f92)多半是偶发的服务端超时——所以真正的对策不在这里, # 在下面:失败要如实记成失败,让工具和界面都说真话。 - def _multi(fn: Any, **kw: Any) -> dict[Any, Any]: + # + # 批量是一锤子买卖:其中一个对象反射出错,整批都拿不到。SQLite 连接关了双引号 + # 字符串兼容以后,老库里一个写着 `"yes" AS flag` 的视图就让 PRAGMA 报错;以前这里 + # 吞掉异常返回空,好好的表跟着一起消失,探查却显示「成功、0 张表」。所以批量失败 + # 时逐个对象再取一遍,只跳过真正取不到的那个,名字和原因记进 skipped。 + failures: dict[str, str] = {} + + def _multi(fn: Any, single: Any, names: list[str], *, + errors: dict[str, str] | None = None) -> dict[Any, Any]: try: - return dict(fn(schema=target_schema, filter_names=picked, - kind=ObjectKind.ANY, **kw)) + return dict(fn(schema=target_schema, filter_names=names, kind=ObjectKind.ANY)) except NotImplementedError: return {} # 表注释这类不是所有方言都支持 - except Exception: # noqa: BLE001 - 取不到不该让整次探查失败 - return {} + except Exception: # noqa: BLE001 - 批量取不到,逐个对象再试 + pass + out: dict[Any, Any] = {} + for name in names: + try: + out[(target_schema, name)] = single(name, schema=target_schema) + except NotImplementedError: + break + except Exception as e: # noqa: BLE001 - 只跳过这一个对象 + if errors is not None: + errors[name] = first_line(e) or describe_exception(e) + if not out and len(errors or ()) >= _GIVE_UP_AFTER: + break # 开头几个全取不到,多半是连接本身出了问题,不再挨个耗时间 + return out - columns_by = _multi(inspector.get_multi_columns) - pk_by = _multi(inspector.get_multi_pk_constraint) - comment_by = _multi(inspector.get_multi_table_comment) + columns_by = _multi(inspector.get_multi_columns, inspector.get_columns, picked, + errors=failures) + readable = [n for n in picked if columns_by.get((target_schema, n))] + pk_by = _multi(inspector.get_multi_pk_constraint, inspector.get_pk_constraint, readable) + comment_by = _multi(inspector.get_multi_table_comment, inspector.get_table_comment, readable) tables: dict[str, Any] = {} + skipped: dict[str, str] = {} for name in picked: key = (target_schema, name) columns = columns_by.get(key) if not columns: - continue # 这张表反射不出来,跳过,别毁掉整次探查 + # 这个对象反射不出来,跳过,别毁掉整次探查;但要留下名字和原因 + skipped[name] = failures.get(name) or "读不到列" + continue pk = (pk_by.get(key) or {}).get("constrained_columns") or [] comment = (comment_by.get(key) or {}).get("text") @@ -181,7 +207,14 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any "comment": comment, "is_view": name in views, } - return {"tables": tables, "truncated": truncated, "total": len(names) + len(views)} + payload: dict[str, Any] = {"tables": tables, "truncated": truncated, + "total": len(names) + len(views)} + if failures and not tables: + # 一个对象也取不到、而且确实出了错:如实记成失败,不能显示成「已同步、0 张表」 + payload.update(failed=True, error=next(iter(failures.values()))) + elif skipped: + payload["skipped"] = skipped + return payload try: async with engine.connect() as conn: diff --git a/backend/app/data/names.py b/backend/app/data/names.py new file mode 100644 index 00000000..274bd43b --- /dev/null +++ b/backend/app/data/names.py @@ -0,0 +1,114 @@ +"""表名、列名、维度值的规范化和比较。 + +导入(data/tabular.py、data/table_versions.py)和证据层(engine/evidence.py)共用这一份。 +两边各写一套的话,「导入时认为没撞名、核对时认为是同一个名字」这种口径分歧迟早会出现。 + +三个用途,三个函数,不能混用: +- canon:比较和存储维度值、主键用的规范写法(全角、各类连字符、格式字符、空白都抹平); +- name_key:按名字找表、找列。只对 ASCII 不区分大小写、不做 NFKC,和 SQLite 的标识符比较完全一致 + (导入的名字都满足 name_problem、等于自身的 NFKC 形式,对它们做不做 NFKC 没有区别); +- collide_key:判断两个名字算不算撞名。用 casefold,比 SQLite 严:「Дата」和「дата」 + 在 SQLite 里是两张表,但并存只会让人和模型都分不清,所以也不许。 +""" +from __future__ import annotations + +import re +import unicodedata + +#: NFKC 之后仍然不是 ASCII 连字符的各种横线:连字符、不换行连字符、数字线、en dash、 +#: em dash、水平线、减号。全角「-」NFKC 会归一,不在这里 +_DASHES = frozenset("‐‑‒–—―−") +#: 看起来是空白、却不算 Zs 也不算 Cf 的填充字符(韩文填充符一类),混进名字里肉眼看不出来。 +#: 证据层核对 [[t:]] / [[c:]] 的写法时也用它(engine/evidence.py) +FILLERS = frozenset("ᅟᅠㅤᅠ") +_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") +#: 名字上限 48 字:工具描述、表结构里要列出来,太长的名字模型和人都读不全 +NAME_MAX = 48 +_NAME = re.compile(rf"^[^\W\d]\w{{0,{NAME_MAX - 1}}}$") +#: 作名字会让不加引号的 SQL 读错的关键字。不求全,收常见的、表头里可能出现的 +RESERVED = frozenset({ + "abort", "add", "all", "alter", "and", "as", "asc", "between", "by", "case", "check", + "collate", "column", "commit", "constraint", "create", "cross", "default", "delete", + "desc", "distinct", "drop", "else", "end", "escape", "except", "exists", "foreign", + "from", "full", "group", "having", "in", "index", "inner", "insert", "intersect", + "into", "is", "join", "key", "left", "like", "limit", "natural", "not", "null", + "offset", "on", "or", "order", "outer", "primary", "references", "right", "rowid", + "select", "set", "table", "then", "to", "transaction", "trigger", "union", "unique", + "update", "using", "values", "view", "when", "where", "with", +}) + + +def canon(text: str) -> str: + """规范写法:NFKC,各类横线统一成「-」,去掉格式字符(Cf),空白折叠成一个空格、去首尾。 + + 九月的表把「8-9」写成「8–9」(en dash)或「8-9」(全角),按原文存的话, + `WHERE 时段 = '8-9'` 会静默漏掉那个月——所以维度值和主键都按这个存,原文另记。 + """ + s = unicodedata.normalize("NFKC", str(text)) + s = "".join("-" if ch in _DASHES else ch for ch in s if unicodedata.category(ch) != "Cf") + return re.sub(r"\s+", " ", s).strip() + + +def name_problem(name: str) -> str | None: + """表名或列名能不能用。能用返回 None,不能用返回一句给人看的原因。 + + 规则:等于自身的 NFKC 形式(挡住全角 ASCII「ABC」、带圈数字「①」);不含控制字符、 + 格式字符、空格类和组合附加符号,也不含填充字符;字母或汉字开头,只含字母、数字、汉字和 + 下划线,最长 NAME_MAX 字;不以 sqlite_ 开头(SQLite 保留);不是 SQL 关键字。 + """ + if not name: + return "名称不能为空" + if name != unicodedata.normalize("NFKC", name): + return f"名称「{name}」含全角或兼容字符,请改用常规写法" + if any(unicodedata.category(ch) in ("Cc", "Cf", "Zs", "Mn") or ch in FILLERS for ch in name): + return f"名称「{name}」含不可见字符或空格" + if not _NAME.match(name): + return (f"名称「{name}」需以字母或汉字开头,只能包含字母、数字、汉字和下划线," + f"最长 {NAME_MAX} 字") + if name.lower().startswith("sqlite_"): + return f"名称「{name}」以 sqlite_ 开头,这是 SQLite 的保留前缀" + if name.lower() in RESERVED: + return f"名称「{name}」是 SQL 关键字" + return None + + +def name_key(name: str) -> str: + """按名字查找时用的键:只把 ASCII 字母转小写,其余逐字比,不做 NFKC。 + + SQLite 比较标识符时只对 ASCII 不区分大小写(`ABC` 和 `abc` 是同一张表,`Дата` 和 + `дата` 不是),也不做任何归一(全角的 `ABC` 和 `abc` 是两张表,库里可以并存)。证据层用这个键 + 找表找列、给 SQL 里的表名去重,和真正执行 SQL 的数据库口径一致。 + + 不能先做 NFKC:手工接入的库里全角「ORDERS」和「orders」并存时,[[t:orders]] 会被静默绑到 + 全角那张,SQL 台账也会把真正 JOIN 的两张表当成一张。导入产生的名字都满足 name_problem(等于自身的 + NFKC 形式),对它们这两种写法得出的键一样;证据层的 [[t:]] / [[c:]] 引用也要求 NFKC 稳定。 + """ + return str(name).translate(_ASCII_LOWER) + + +def collide_key(name: str) -> str: + """撞名检查用的键:NFKC 加 casefold,比 SQLite 严。""" + return unicodedata.normalize("NFKC", str(name)).casefold() + + +def to_sql_name(header: object, *, fallback: str) -> str: + """原表头 → 一个满足 name_problem 的名字。原表头另存作显示名,这里只管能用。 + + canon 之后把不是字母、数字、汉字、下划线的字符(括号、斜杠、空格、换行、方括号、竖线) + 换成「_」并合并,去掉首尾的「_」;数字开头加「c_」,关键字后面加「_」,超长截断。 + 什么都不剩时用 fallback(一般是 col_<位置>)。「金额(元)」→「金额_元」。 + """ + s = canon("" if header is None else str(header)) + s = re.sub(r"[^\w]+", "_", s) + s = "".join(ch for ch in s if unicodedata.category(ch) not in ("Mn", "Cf") and ch not in FILLERS) + s = re.sub(r"_+", "_", s).strip("_") + if not s: + return fallback + if s[0].isdigit(): + s = f"c_{s}" + if s.lower().startswith("sqlite_"): + s = f"t_{s}" + s = s[:NAME_MAX].rstrip("_") or fallback + if s.lower() in RESERVED: + s = f"{s}_" + return s if name_problem(s) is None else fallback diff --git a/backend/app/data/raw_store.py b/backend/app/data/raw_store.py new file mode 100644 index 00000000..8e18d5db --- /dev/null +++ b/backend/app/data/raw_store.py @@ -0,0 +1,133 @@ +"""上传表格的原件存档:按内容哈希存,只写一次,没有任何下载接口。 + +**为什么要留原件。** 以前上传接口把文件读进内存、交给解析器,最后只剩一个 .db: +解析规则一改只能让用户重传,报告里也说不出「用的是哪份文件」。原件按 sha256 存下来, +构建 id 里记着它的哈希,版本才说得清来历(硬约束 H12)。 + +**为什么不给下载。** 原件里常有不该外传的东西:隐藏工作表里的口令、没导入的批注 +(硬约束 H10)。导入时这些都被挡在库外,开一个下载口子等于又放了出去。要删掉它, +走「清除原件」(purge_raw),导入记录里留下时间、署名和理由。 + +**写法。** 先写同目录的临时文件、fsync,再 os.link 到最终路径——link 不会覆盖已有文件, +两个请求同时存同一份内容时,后到的拿到 FileExistsError,校验哈希后直接复用。最终文件 +改成 0444:存档之后不该再被任何代码路径改写。 +""" +from __future__ import annotations + +import hashlib +import logging +import os +import re +import secrets +from pathlib import Path + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +_SHA = re.compile(r"^[0-9a-f]{64}$") +#: 临时文件名里的标记。启动清理按它找上次崩溃留下的半成品(table_versions.startup) +TMP_MARK = ".tmp-" + + +def raw_root() -> Path: + """原件存档的根目录。按调用时的 settings 现算:测试会换数据目录。""" + return settings.uploads_dir / "raw" + + +def raw_path(sha: str) -> Path: + """哈希 → 存档路径(uploads/raw/<前两位>/)。只认 64 位小写十六进制,挡住路径穿越。""" + if not _SHA.match(sha or ""): + raise ValueError("原件哈希的格式不对") + return raw_root() / sha[:2] / sha + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def fsync_dir(path: Path) -> None: + """目录项也要落盘:只 fsync 文件,断电后可能文件内容在、目录里却没有这个名字。""" + fd = os.open(path, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def put_raw(raw: bytes) -> tuple[str, Path]: + """存一份原件,返回 (sha256, 路径)。同样内容已经存过就校验后复用,不重写。 + + 已有文件的哈希对不上(磁盘损坏、被人改过)时,用这次的字节原子替换掉它:路径本身就是 + 内容的哈希,这次手里的字节才是这个名字应有的内容。 + """ + sha = hashlib.sha256(raw).hexdigest() + path = raw_path(sha) + if path.is_file() and sha256_file(path) == sha: + return sha, path + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.parent / f".{sha}{TMP_MARK}{secrets.token_hex(6)}" + try: + with open(tmp, "wb") as fh: + fh.write(raw) + fh.flush() + os.fsync(fh.fileno()) + try: + os.link(tmp, path) + except FileExistsError: + if sha256_file(path) == sha: + return sha, path # 并发的另一个请求刚存好同一份 + logger.warning("原件存档 %s 的内容与文件名不符,已用本次上传的内容替换", sha) + os.replace(tmp, path) + os.chmod(path, 0o444) + fsync_dir(path.parent) + return sha, path + finally: + tmp.unlink(missing_ok=True) + + +def raw_exists(sha: str) -> bool: + try: + return raw_path(sha).is_file() + except ValueError: + return False + + +def purge_raw(sha: str, *, prune: bool = False) -> bool: + """删掉一份原件。返回是否真的删了文件(本来就不在返回 False)。 + + 只删文件;导入记录上的 raw_state 和清除记录由调用方写——同一份内容可能被几次导入共用, + 删不删由调用方看过引用之后决定。 + + prune:删完后所在的 raw/<前两位>/ 空了就一并删掉。只有持着版本存储锁的调用方能这么做 + (table_versions 的清除原件、回收):put_raw 也在锁里,先建目录再写临时文件,锁外删目录 + 会让并发的存档在两步之间扑空。 + """ + try: + path = raw_path(sha) + except ValueError: + return False + try: + path.unlink() + except FileNotFoundError: + return False + try: + fsync_dir(path.parent) + except OSError: # pragma: no cover - 删已经成功,目录落盘失败不改变结论 + pass + if prune: + remove_empty_dir(path.parent) + return True + + +def remove_empty_dir(path: Path) -> bool: + """目录空了就删掉,返回删没删。不空、不存在、没权限都不算错:留着一个空目录不影响任何事。""" + try: + path.rmdir() + except OSError: + return False + return True diff --git a/backend/app/data/table_versions.py b/backend/app/data/table_versions.py new file mode 100644 index 00000000..9bd0c9bd --- /dev/null +++ b/backend/app/data/table_versions.py @@ -0,0 +1,1138 @@ +"""上传表格的版本底座:构建、导入记录、快照,以及发布、迁移、回收。 + +**为什么要有版本。** 以前重传是往同一个 .db 里 DROP + CREATE:第一张表的 DROP/CREATE 先 +自动提交,后面失败时库里是新旧混杂的数据;正在查询的连接看到半截的库;运行查到的数据和它 +冻结下来的表结构可能不是同一版;原件不留,说不清用的是哪份文件。改成三层,每层不可变: + +- 构建(table_builds):一次解析的产物,id = sha256(源、原件、选项、解析器版本)。库文件写到 + 新路径、改成 0444,之后不再动。同一个源重传同一个文件得到同一个构建。 +- 导入记录(table_imports):一次上传。谁、何时、什么文件、原件留没留,都在这里。 +- 快照(source_snapshots):数据源的一个可查询版本 = 若干期导入 + 冻结的表结构。期 1 只做 + 「每期替换」,一个快照就是一期,库直接用那次构建的文件。 + +数据源上只记「当前快照」这一个指针(current_snapshot_id)。发布时先把文件写好、落盘,最后 +在一个事务里写三层记录并切指针:任何一步失败,指针不动,旧版完整可查。 + +**查询怎么拿到版本。** resolve_source 把上传源换成绑定某个快照的 SourceView(库路径、表结构 +都来自快照,immutable、带期望哈希);运行钉住的快照找不到时明确报错,绝不退回当前指针。 + +**回收。** 任何运行引用的快照、各源当前快照,以及它们用到的构建和原件受保护;其余的删文件、 +记录标 retired 留作审计。 +""" +from __future__ import annotations + +import asyncio +import copy +import dataclasses +import hashlib +import json +import logging +import os +import re +import secrets +import sqlite3 +import stat +import weakref +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Sequence +from urllib.parse import unquote, urlsplit + +from sqlalchemy import func, or_, select, update +from sqlalchemy import inspect as sa_inspect +from sqlalchemy.engine import make_url +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.errors import explain, first_line +from app.data import introspect as introspect_mod +from app.data import raw_store, tabular +from app.data.engine import engines +from app.db import base as db_base +from app.db.base import new_id, utcnow +from app.db.models import DataSource, Run, SourceSnapshot, TableBuild, TableImport + +logger = logging.getLogger(__name__) + +#: 解析器版本,进构建 id。解析规则改了(类型推断、名字规则、区域判定)就要改它:同一份原件 +#: 按新规则建出来的库不是同一个构建,不能复用旧文件 +ENGINE_VER = "tabular/2" +#: 迁移前的老上传:只剩一个 .db,不知道当时按什么规则解析的 +LEGACY_ENGINE_VER = "legacy" + +#: 未规整的表写进表结构的说明。不含数字和坐标:说明会进模型看得到的文本(H11)。定义在 tabular(见那里) +UNSHAPED_NOTE = tabular.UNSHAPED_NOTE + +_CSV_EXTS = (".csv", ".tsv") +_SOURCE_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") +_TMP = raw_store.TMP_MARK +#: 探查新库时引擎缓存用的一次性键的标记(不含冒号:data/engine.py 按最后一个冒号切出源 id) +_PROBE = "~probe~" +#: 上传库只有 main:options 里的这一项对上传源没有意义,留着只会让探查去找不存在的 schema +_SCHEMA_OPTION = "schema" + + +class SnapshotMissing(ValueError): + """要的快照不存在、不属于这个源、已回收或文件丢了。message 直接给人看。""" + + +class PublishError(RuntimeError): + """解析成功了,但没能把库文件安全地发布出去。指针没动,旧版照常可查。""" + + +class ParseFailed(tabular.UnsupportedTable): + """解析时出了意料之外的错(不是 UnsupportedTable 那种「认得出、读不了」)。 + + 继承 UnsupportedTable:接口层统一按 400 回给用户,原异常留在 __cause__ 里供日志定位。 + """ + + +@dataclass +class ImportResult: + import_id: str + snapshot_id: str + build_id: str + build_reused: bool + #: 解析回执(tabular.LoadReport 的 JSON 形状) + report: dict[str, Any] + + +@dataclass +class SourceView: + """绑定了某个快照的数据源。engine / introspect / guard / 工具只读这些字段,和 DataSource 同名。 + + 不是 ORM 对象,不进会话:拿它查询不会顺手把快照路径写回数据源。 + """ + + id: str + name: str + kind: str = "sqlite" + database: str | None = None + options: dict[str, Any] = field(default_factory=dict) + readonly: bool = True + description: str = "" + schema_cache: dict[str, Any] = field(default_factory=dict) + schema_synced_at: datetime | None = None + enabled: bool = True + host: str | None = None + port: int | None = None + username: str | None = None + password: str | None = None + last_check: dict[str, Any] | None = None + origin: str = "upload" + current_snapshot_id: str | None = None + #: 绑定的快照。引擎缓存按「源 id + 快照 id」分开(data/engine.py) + snapshot_id: str | None = None + #: 以 immutable=1 打开:文件发布后不会再变,SQLite 可以不加锁、不查日志 + immutable: bool = True + #: 第一次为这个快照建引擎前核对文件哈希,对不上拒绝查询 + expected_sha256: str | None = None + + +# -------------------------------------------------------------------------- +# id 与路径 +# -------------------------------------------------------------------------- + + +def _sha_json(obj: Any) -> str: + text = json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def parse_options(filename: str, *, header_row: int, mixed: str, raw_mode: bool) -> dict[str, Any]: + """影响解析结果的全部选项。进构建 id:选项不同,建出来的库就不是同一个。 + + 文件名只取会影响结果的部分:扩展名决定按 Excel 还是 CSV 读;CSV 的表名取自文件名, + 所以 CSV 带上文件名。Excel 的表名来自工作表名,同一个文件换个名字再传,仍是同一个构建。 + """ + base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1] + ext = base[base.rfind("."):].lower() if "." in base else "" + options: dict[str, Any] = { + "header_row": int(header_row), "mixed": mixed, "raw_mode": bool(raw_mode), "ext": ext, + } + if ext in _CSV_EXTS: + options["file_name"] = base + return options + + +def build_id(source_id: str, raw_sha: str, options: dict[str, Any]) -> str: + """构建 id:sha256 全长。source_id 在里面——两个源传同一个文件是两个构建,互不引用。""" + return _sha_json({ + "source_id": source_id, "raw_sha256": raw_sha, + "options_sha256": _sha_json(options), "engine_ver": ENGINE_VER, + }) + + +def snapshot_id(source_id: str, import_ids: list[str], engine_ver: str = ENGINE_VER) -> str: + return _sha_json({"source_id": source_id, "imports": list(import_ids), "engine_ver": engine_ver}) + + +def legacy_build_id(source_id: str, file_sha: str) -> str: + """迁移前老上传的 v0 构建 id。带上 source_id:两个源的老库内容相同,id 也不同。""" + return hashlib.sha256(f"legacy{source_id}{file_sha}".encode("utf-8")).hexdigest() + + +def tables_root() -> Path: + return settings.uploads_dir / "tables" + + +def build_path(source_id: str, bid: str) -> Path: + if not _SOURCE_ID.match(source_id or ""): + raise ValueError("数据源 id 的格式不对") + return tables_root() / source_id / "builds" / f"{bid}.db" + + +def _under(path: str | os.PathLike[str], root: Path) -> bool: + try: + return Path(os.path.realpath(path)).is_relative_to(os.path.realpath(root)) + except (OSError, ValueError): + return False + + +def _sqlite_targets(database: str) -> list[str]: + """一个 SQLite「数据库」字段可能打开的文件:原字符串,加上按连接串规则还原出的真实路径。 + + 手工源的 database 原样拼进连接串(data/engine.py build_url)。SQLAlchemy 把 ? 后面拆成 + 连接参数,带 uri=true 时 file: 开头的整段按 SQLite URI 打开(百分号转义、mode=rwc 之类)。 + 只对原字符串做 realpath,「file:/…/builds/x.db?uri=true」会被当成当前目录下的相对路径, + 「…/x.db?x=/../../别处」会被 .. 带到别处——两种写法实际打开的都是上传目录里的文件。 + """ + targets = [database] + try: + url = make_url(f"sqlite:///{database}") + except Exception: # noqa: BLE001 - 连接串都拼不出来的,连库也会失败;只按原字符串判断 + return targets + path = url.database or "" + targets.append(path) + # 不带 uri=true 时 SQLite 一般不按 URI 解析,但编译选项 SQLITE_USE_URI 能打开它:一律按两种都算 + if path[:5].lower() == "file:": + parts = urlsplit(path) + targets += [unquote(parts.path), unquote(path[5:].split("?", 1)[0].split("#", 1)[0])] + return [t for t in dict.fromkeys(targets) if t and t != ":memory:"] + + +#: 文件身份:(st_dev, st_ino)。同一个文件不管怎么写路径,身份都一样 +FileId = tuple[int, int] + + +def _file_id(path: str | os.PathLike[str]) -> FileId | None: + try: + st = os.stat(path) + except (OSError, ValueError): + return None + return st.st_dev, st.st_ino + + +@dataclass(frozen=True) +class _Zone: + """一组受保护的位置:若干目录(连同其下的一切)和若干单个文件。 + + **按文件身份认,不按路径字符串认。** macOS 默认的 APFS 不区分大小写,realpath 也不解析 + firmlink:「…/Uploads/…」「…/DATA/…」「/System/Volumes/Data/Users/…」打开的都是同一个 + 文件,按字符串前缀比却都不算在里面。所以对目标路径取 realpath 后,从它自己往上逐级 stat, + 哪一级和受保护目录是同一个 (st_dev, st_ino) 就算落在里面;目标本身和受保护文件同身份 + (包括硬链接)也算。字符串前缀只作兜底:受保护目录还不存在时,没有身份可比。 + """ + + dirs: tuple[str, ...] = () + dir_ids: frozenset[FileId] = frozenset() + files: tuple[str, ...] = () + file_ids: frozenset[FileId] = frozenset() + #: 还不存在的受保护文件(-journal 之类,SQLite 用到时才建):(所在目录的身份, casefold 后的文件名)。 + #: 不区分大小写的文件系统上「AGENTLAB.DB-JOURNAL」就是它;区分大小写的系统上多拒几个怪名字,无妨 + names: frozenset[tuple[FileId, str]] = frozenset() + #: 目标文件有多个硬链接时,到这些目录里找它的另一个名字 + link_roots: tuple[str, ...] = () + + +def _zone(dirs: Sequence[Path] = (), files: Sequence[Path] = (), *, find_links: bool = False) -> _Zone: + dir_reals = tuple(os.path.realpath(d) for d in dirs) + file_reals = tuple(os.path.realpath(f) for f in files) + names = set() + for real in file_reals: + parent = _file_id(os.path.dirname(real)) + if parent is not None: + names.add((parent, os.path.basename(real).casefold())) + return _Zone( + dirs=dir_reals, dir_ids=frozenset(i for d in dir_reals if (i := _file_id(d)) is not None), + files=file_reals, file_ids=frozenset(i for f in file_reals if (i := _file_id(f)) is not None), + names=frozenset(names), link_roots=dir_reals if find_links else (), + ) + + +def _linked_into(real: str, leaf: FileId, roots: tuple[str, ...]) -> bool: + """目标文件是不是受保护目录里某个文件的硬链接。只在链接数大于 1 时才去目录里找,平时不花时间。""" + try: + st = os.stat(real) + except (OSError, ValueError): + return False + if not stat.S_ISREG(st.st_mode) or st.st_nlink < 2: + return False + for root in roots: + for folder, _dirs, names in os.walk(root): + for name in names: + try: + other = os.lstat(os.path.join(folder, name)) + except OSError: + continue + if (other.st_dev, other.st_ino) == leaf: + return True + return False + + +def _hits(target: str, zone: _Zone) -> bool: + """一个路径(已按连接串规则还原)会不会打开受保护位置里的文件。""" + try: + real = os.path.realpath(target) + except (OSError, ValueError): + return False + if real in zone.files or any(Path(real).is_relative_to(d) for d in zone.dirs): + return True + leaf = _file_id(real) + if leaf is not None and leaf in zone.file_ids: + return True + parent = _file_id(os.path.dirname(real)) + if parent is not None and (parent, os.path.basename(real).casefold()) in zone.names: + return True + # 从目标自己往上逐级比目录身份:目标和中间几级目录还不存在(mode=rwc 新建)时,靠已存在的祖先认 + node = real + while True: + fid = _file_id(node) + if fid is not None and fid in zone.dir_ids: + return True + up = os.path.dirname(node) + if up == node: + break + node = up + return bool(zone.link_roots) and leaf is not None and _linked_into(real, leaf, zone.link_roots) + + +def _reaches(path: str | None, zone: _Zone) -> bool: + if not path or path == ":memory:": + return False + return any(_hits(target, zone) for target in _sqlite_targets(path)) + + +def under_uploads(path: str | None) -> bool: + """手工登记的 SQLite 源会不会打开上传目录里的文件。按文件身份,也按连接串和 URI 的解析规则。 + + 任何一种解读落进上传目录都算:那里的文件归版本底座管,手工源指过去就绕开了只读和版本。 + 大小写不同的写法、firmlink 前缀、符号链接、硬链接都认(见 _Zone)。 + """ + return _reaches(path, _zone([settings.uploads_dir], find_links=True)) + + +#: SQLite 库文件旁边可能出现的日志文件 +_DB_SIDE_FILES = ("", "-wal", "-shm", "-journal") + + +def _app_db_files() -> list[Path]: + """应用自己的库:元数据库、运行检查点库,以及实际连着的元数据库(测试里和 data_dir 下的不是同一个)。""" + dbs = [settings.db_path, settings.checkpoint_path] + try: + live = db_base.engine.url.database + except Exception: # noqa: BLE001 - 取不到就只按配置里的路径判断 + live = None + if live and live != ":memory:": + dbs.append(Path(live)) + return [Path(f"{db}{suffix}") for db in dict.fromkeys(dbs) for suffix in _DB_SIDE_FILES] + + +def reaches_app_files(path: str | None) -> bool: + """手工登记的 SQLite 源会不会打开 AgentLab 自己的数据文件。判断方式同 under_uploads。 + + 元数据库里有数据源的连接字段、快照登记和哈希、运行与审批记录:登记成可写的源,一条经审批的 + UPDATE 就能把上传源改成手工源、指向别处,版本底座和「连接字段只由系统维护」一起失效;只读登记 + 也会把全部元数据交给模型。检查点库、加密密钥、工件、沙箱策略同理。工作区不在其中:那是模型 + 可写的沙箱根目录,登记模型生成的库是正当用法。 + """ + data = settings.data_dir + return _reaches(path, _zone( + [data / "artifacts", data / "sandbox-policies"], [*_app_db_files(), data / ".secret_key"])) + + +def report_to_json(report: Any) -> dict[str, Any]: + """LoadReport(dataclass)→ 能存进 JSON 列的 dict。""" + data = dataclasses.asdict(report) if dataclasses.is_dataclass(report) else dict(report or {}) + return json.loads(json.dumps(data, ensure_ascii=False, default=str)) + + +# -------------------------------------------------------------------------- +# 快照解析 +# -------------------------------------------------------------------------- + + +def upload_options(options: dict[str, Any] | None) -> dict[str, Any]: + """上传源实际生效的 options:去掉 schema(库里只有 main),查询时限、遮罩列照旧。""" + return {k: v for k, v in (options or {}).items() if k != _SCHEMA_OPTION} + + +def bind_snapshot(source: Any, snapshot: SourceSnapshot) -> SourceView: + """数据源 + 快照 → SourceView:只读、库路径和表结构都取快照里冻结的那份。 + + 快照没登记文件哈希(记录损坏、被手工改过)时抛 SnapshotMissing:没有哈希就无从核对文件 + 是否被改过,不能退回「不核对照样打开」。现有的写入路径(发布、迁移)都会写哈希。 + """ + if not str(snapshot.db_sha256 or "").strip(): + logger.warning("快照没有登记文件哈希,已拒绝:source=%s snapshot=%s", source.id, snapshot.id) + raise SnapshotMissing( + f"「{source.name}」的数据版本 {snapshot.id[:12]} 缺少数据文件的哈希登记," + "无法核对数据文件是否被修改过,已拒绝查询,请重新上传" + ) + return SourceView( + id=source.id, name=source.name, kind="sqlite", database=snapshot.db_path, + options=upload_options(source.options), readonly=True, + description=source.description or "", + schema_cache=copy.deepcopy(snapshot.schema_cache or {}), + schema_synced_at=snapshot.created_at, enabled=bool(source.enabled), + last_check=getattr(source, "last_check", None), + origin="upload", current_snapshot_id=getattr(source, "current_snapshot_id", None), + snapshot_id=snapshot.id, immutable=True, expected_sha256=str(snapshot.db_sha256).strip(), + ) + + +async def resolve_source( + session: AsyncSession, source: DataSource, pinned_snapshot_id: str | None = None +) -> DataSource | SourceView: + """查询时实际该用的源。手工源原样返回;上传源换成绑定快照的 SourceView。 + + 钉了快照就只认那一个:它不属于这个源、已回收、文件不在,都抛 SnapshotMissing, + **绝不退回当前指针**——运行前后两段查的必须是同一版数据,否则报告里的数字对不上来历。 + """ + if (getattr(source, "origin", None) or "manual") != "upload": + return source + wanted = pinned_snapshot_id or source.current_snapshot_id + if not wanted: + raise SnapshotMissing(f"上传的表格「{source.name}」没有可用的版本,请重新上传") + snap = await session.get(SourceSnapshot, wanted) + pinned = bool(pinned_snapshot_id) + if snap is None or snap.source_id != source.id: + raise SnapshotMissing( + f"「{source.name}」的数据版本 {wanted[:12]} 不存在或不属于该数据源" + + (",已拒绝查询,不会改用当前版本" if pinned else ",请重新上传") + ) + if snap.retired_at is not None or not Path(snap.db_path).is_file(): + raise SnapshotMissing( + f"「{source.name}」的数据版本 {wanted[:12]} 的数据文件已不存在" + + (",已拒绝查询,不会改用当前版本" if pinned else ",请重新上传") + ) + return bind_snapshot(source, snap) + + +# -------------------------------------------------------------------------- +# 锁:发布、回收、启动清理彼此串行 +# -------------------------------------------------------------------------- + +_locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]" = weakref.WeakKeyDictionary() + + +def _store_lock() -> asyncio.Lock: + """版本存储的写锁,按事件循环各一把。 + + 回收要先看「哪些文件有记录」再删:发布正处在「文件已 link、记录未提交」之间时,回收 + 会把它当孤儿删掉;复用一个刚被判为无人引用的构建,也会撞上正在删它的回收。上传不频繁, + 全部串行最省心。按循环分开是因为 asyncio.Lock 一旦在某个循环里等过,就不能换循环用。 + """ + loop = asyncio.get_running_loop() + lock = _locks.get(loop) + if lock is None: + lock = _locks[loop] = asyncio.Lock() + return lock + + +#: 同时解析的上传最多几个。解析在线程里做,内存和上传数成正比;不设上限的话,几个大文件 +#: 同时上传,内存会叠加起来。超出的排队等前面的解析完 +MAX_PARALLEL_PARSES = 2 +_parse_slots: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore]" = weakref.WeakKeyDictionary() + + +def _parse_slot() -> asyncio.Semaphore: + """解析的并发名额,按事件循环各一份(理由同 _store_lock)。""" + loop = asyncio.get_running_loop() + slot = _parse_slots.get(loop) + if slot is None: + slot = _parse_slots[loop] = asyncio.Semaphore(MAX_PARALLEL_PARSES) + return slot + + +def pin_lock() -> asyncio.Lock: + """固定数据版本时持有的锁(就是版本存储的写锁):从读当前指针到把它写进运行记录,全程持有。 + + 回收按「已提交的运行记录 + 各源当前指针」判断哪些快照还有人用。发起运行(engine/runner.py) + 和运行中途补固定(tools/datasource.py 的 _pin_late)都是先读指针、过一会儿才提交运行记录: + 中间要是有人重传,发布切走指针、紧接着的回收看不到这条还没提交的运行,刚读到的快照就被删了, + 这次运行只能报「固定的数据版本已不存在」。持着这把锁,回收要么在读指针之前做完,要么等运行 + 记录提交之后才开始。只包住读指针到提交这一小段,别在里面做慢事(发布和回收也在等它)。 + """ + return _store_lock() + + +# -------------------------------------------------------------------------- +# 发布 +# -------------------------------------------------------------------------- + + +def _remove_db_files(path: Path) -> None: + """删掉一个 SQLite 文件和它可能留下的日志文件。""" + for suffix in ("", "-journal", "-wal", "-shm"): + Path(f"{path}{suffix}").unlink(missing_ok=True) + + +def _prune_build_dirs(final: Path) -> None: + """删掉构建路径上空了的 builds/ 和 tables/<源 id>/ 两级目录(不空就留着)。""" + builds = final.parent + if raw_store.remove_empty_dir(builds): + raw_store.remove_empty_dir(builds.parent) + + +def _parse_to_tmp(final: Path, raw: bytes, filename: str, header_row: int, mixed: str, + raw_mode: bool) -> tuple[Path, Any]: + """在线程池里解析到一个临时库。失败时删掉临时文件,异常照原样(或包一层)抛出。""" + final.parent.mkdir(parents=True, exist_ok=True) + tmp = final.parent / f"{final.name}{_TMP}{secrets.token_hex(6)}" + try: + report = tabular.load_into(str(tmp), raw, filename, header_row=header_row, + mixed=mixed, raw_mode=raw_mode) + except tabular.UnsupportedTable: + _remove_db_files(tmp) + raise + except Exception as e: + _remove_db_files(tmp) + reason = first_line(e) if isinstance(e, ValueError) else explain(e)[0] + raise ParseFailed( + f"导入失败:{reason}。请确认文件是未加密的 Excel 或 CSV,且表头行号填写正确" + ) from e + except BaseException: + _remove_db_files(tmp) + raise + return tmp, report + + +_JOURNAL_STUCK = "数据文件的日志未能合并,已停止发布,原数据未受影响" + + +def _settle_journal(path: Path) -> None: + """切成回滚日志模式并确认没有残留的 -wal / -journal:发布后以 immutable 打开,SQLite 不会再看日志。 + + 解析器要是用了 WAL、又有连接没关,切换会报「database is locked」或者原样留在 WAL: + 这时库里的一部分数据还在 -wal 里,按 immutable 打开会静默少数据,只能停下。 + """ + try: + conn = sqlite3.connect(str(path)) + try: + mode = conn.execute("PRAGMA journal_mode=DELETE").fetchone() + finally: + conn.close() + except sqlite3.Error as e: + raise PublishError(_JOURNAL_STUCK) from e + if not mode or str(mode[0]).lower() != "delete": + raise PublishError(_JOURNAL_STUCK) + for suffix in ("-wal", "-journal"): + if Path(f"{path}{suffix}").exists(): + raise PublishError(_JOURNAL_STUCK) + Path(f"{path}-shm").unlink(missing_ok=True) + + +def _fsync_file(path: Path) -> None: + fd = os.open(path, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _publish_file(tmp: Path, final: Path, expected_sha: str | None) -> tuple[str, bool]: + """把临时库发布到最终路径,返回 (库文件 sha256, 是否复用了已有文件)。 + + 先落盘再 link:link 不会覆盖已有文件,已发布的版本因此不可能被半个新文件顶掉。 + 目标已存在时(同一个构建传过),只有它的哈希等于登记的 db_sha256 才复用——0 字节的 + 残骸、被改过的文件都不能冒充。没有登记过(上次发布到一半进程没了)时,和这次刚建的 + 临时库逐字节一致也可以接手。临时文件无论成败都删掉。 + """ + try: + _settle_journal(tmp) + sha = raw_store.sha256_file(tmp) + _fsync_file(tmp) + raw_store.fsync_dir(tmp.parent) + try: + os.link(tmp, final) + except FileExistsError: + existing = raw_store.sha256_file(final) + if existing == (expected_sha or sha): + return existing, True + raise PublishError( + "导入失败:服务端已有的同版本数据文件与登记的不一致,可能被修改过。" + "当前版本未受影响,请联系管理员检查数据目录" + ) from None + except OSError as e: + raise PublishError(f"导入失败:无法写入数据文件({first_line(e)}),当前版本未受影响") from e + os.chmod(final, 0o444) + raw_store.fsync_dir(final.parent) + return sha, False + finally: + _remove_db_files(tmp) + + +async def _introspect_view(view: SourceView, report: dict[str, Any]) -> dict[str, Any]: + """探查快照库的结构,给未规整的表注入说明。 + + 走 introspect 而不是自己读 sqlite_master:表结构的形状必须和手工源的一模一样,下游 + (工具描述、db_schema、表结构快照)才不用分两种写。 + + 探查用一次性的源 id:引擎缓存要是只按源 id 分键(data/engine.py 加上快照 id 之前), + 用真 id 就会拿到旧版本留在缓存里的引擎,把旧库的结构冻结进新快照;而上传源不能重新 + 探查、/schema 又按快照回答,错的结构会一直留到下一次上传。用完即关,不留在缓存里。 + """ + probe = dataclasses.replace( + view, id=f"{view.id}{_PROBE}{secrets.token_hex(6)}", options=upload_options(view.options)) + try: + cache = await introspect_mod.introspect(probe) + finally: + await engines.invalidate(probe.id) + unshaped = {str(t.get("name")) for t in report.get("tables") or [] if t.get("unshaped")} + for name, meta in (cache.get("tables") or {}).items(): + if name in unshaped: + meta["comment"] = UNSHAPED_NOTE + return cache + + +def _check_probe(cache: dict[str, Any], report: dict[str, Any], source_id: str) -> None: + """冻结进快照之前核对:探查成功,且探到的表正是解析回执里的那些。 + + 快照的结构一旦冻结就改不了(上传源不能重新探查),所以宁可这次发布失败、旧版照常可查, + 也不留下一个结构为空或者描述的是别的库的版本。 + """ + if cache.get("failed"): + raise PublishError( + f"导入失败:读取新数据的表结构时出错({cache.get('error') or '原因不明'}),当前版本未受影响") + expected = {str(t.get("name")) for t in report.get("tables") or [] if isinstance(t, dict)} + probed = set((cache.get("tables") or {}).keys()) + # 表多到探查截断时只看探到的那些是不是都在回执里 + if not (probed <= expected if cache.get("truncated") else probed == expected): + logger.error("上传表格发布时表结构与解析结果不一致:source=%s 探查=%s 回执=%s", + source_id, sorted(probed), sorted(expected)) + raise PublishError("导入失败:新数据的表结构与解析结果对不上,已停止发布,当前版本未受影响") + + +async def publish_upload( + session: AsyncSession, source: DataSource, raw: bytes, filename: str, *, + header_row: int = 1, mixed: str = "reject", raw_mode: bool = False, +) -> ImportResult: + """解析一份上传、发布成新版本并启用。返回导入回执。 + + 1. 线程池里解析到临时库;NeedsDecision / UnsupportedTable 原样抛出——不留原件、不动指针。 + 2. 发布库文件:落盘、link 到构建路径、0444。 + 3. 存原件;一个事务里写构建、导入记录、快照,旧的 active 导入置 superseded,切数据源指针。 + 任何一步失败都回滚,并删掉这次新建的文件,指针不动。 + 4. 回收一次(失败只记日志)。 + + source 可以是还没入库的新 DataSource:失败时它也不会被建出来。 + """ + if not source.id: + source.id = new_id() + # 失败回滚后 source 的属性会过期,再读就要回库加载;后面一律用这份本地值 + source_id = source.id + raw_sha = hashlib.sha256(raw).hexdigest() + options = parse_options(filename, header_row=header_row, mixed=mixed, raw_mode=raw_mode) + bid = build_id(source_id, raw_sha, options) + final = build_path(source_id, bid) + # 还没入库的新源(接口层每次给一个新 id):失败时它的 tables/<源 id>/builds/ 目录也不该留下。 + # 被拒收的上传在「422 → 用户选择 → 重传」里每轮都是一个新 id,不收拾的话每轮多一个空目录。 + # 新 id 只有这一个请求在用,删目录不会和别的发布撞上;已有的源目录里有当前版本,不动 + state = sa_inspect(source) + fresh = state.transient or state.pending + + try: + async with _parse_slot(): + tmp, parsed = await asyncio.to_thread( + _parse_to_tmp, final, raw, filename, header_row, mixed, raw_mode) + except BaseException: + if fresh: + await asyncio.to_thread(_prune_build_dirs, final) + raise + report = report_to_json(parsed) + + linked = raw_new = False + try: + async with _store_lock(): + # 先读后写:读的时候别把调用方挂起的改动(新源、改说明)提前刷进库。 + # 应用库的写锁只在最后提交那一下持有,解析、哈希、fsync 都不占着它 + with session.no_autoflush: + build = await session.get(TableBuild, bid) + last_seq = (await session.execute( + select(func.max(TableImport.seq)).where(TableImport.source_id == source_id) + )).scalar() or 0 + db_sha, reused_file = await asyncio.to_thread( + _publish_file, tmp, final, build.db_sha256 if build is not None else None) + linked = not reused_file + raw_new = not raw_store.raw_exists(raw_sha) + await asyncio.to_thread(raw_store.put_raw, raw) + + now = utcnow() + if build is None: + build = TableBuild( + id=bid, source_id=source_id, raw_sha256=raw_sha, + options_sha256=_sha_json(options), engine_ver=ENGINE_VER, options=options, + db_path=str(final), db_sha256=db_sha, report=report, created_at=now, + ) + session.add(build) + build_reused = False + else: + if not reused_file: + # 记录在、文件被回收过:这次重新建了出来 + build.db_sha256, build.report = db_sha, report + elif not str(build.db_sha256 or "").strip(): + # 登记的哈希是空的(记录被改坏了):复用的文件和这次新建的逐字节一致才走到这里, + # 补上它的哈希,免得新快照继承一个空哈希 + build.db_sha256 = db_sha + build.db_path, build.retired_at = str(final), None + build_reused = reused_file + report = build.report or report + + imp_id = new_id() + snap_id = snapshot_id(source_id, [imp_id]) + options_now = upload_options(source.options) + view = SourceView( + id=source_id, name=source.name, database=str(final), options=options_now, + description=source.description or "", snapshot_id=snap_id, + expected_sha256=build.db_sha256, + ) + cache = await _introspect_view(view, report) + _check_probe(cache, report, source_id) + + session.add(source) + session.add(TableImport( + id=imp_id, source_id=source_id, seq=last_seq + 1, build_id=bid, + file_name=(filename or "")[:500], file_size=len(raw), raw_sha256=raw_sha, + raw_state="kept", status="active", created_at=now, activated_at=now, + )) + session.add(SourceSnapshot( + id=snap_id, source_id=source_id, imports=[imp_id], db_path=str(final), + db_sha256=build.db_sha256, schema_cache=cache, created_at=now, + )) + await session.execute( + update(TableImport) + .where(TableImport.source_id == source_id, TableImport.status == "active", + TableImport.id != imp_id) + .values(status="superseded") + ) + source.kind, source.origin, source.readonly = "sqlite", "upload", True + source.host = source.port = source.username = source.password = None + if options_now != (source.options or {}): + source.options = options_now + # database 只作显示:查询一律从快照解析 + source.database = str(final) + source.current_snapshot_id = snap_id + source.schema_cache, source.schema_synced_at = cache, now + await session.commit() + except BaseException: + try: + await session.rollback() + except Exception: # noqa: BLE001 - 回滚失败也要先把文件收拾掉,再抛原来的异常 + logger.exception("上传表格发布失败后回滚出错") + # 这次新建的文件没有记录指着,留着就是孤儿。复用的文件、早就存在的原件不动 + if linked: + _remove_db_files(final) + if raw_new: + raw_store.purge_raw(raw_sha) + if fresh: + _remove_db_files(tmp) + _prune_build_dirs(final) + raise + finally: + _remove_db_files(tmp) + # 指针换了(或没换成):按源 id 清掉缓存的引擎,下次按新的快照重建 + await engines.invalidate(source_id) + + try: + await gc(session) + except Exception: # noqa: BLE001 - 回收失败不影响这次导入,下次发布或重启再收 + logger.exception("上传表格发布后的回收失败") + return ImportResult(import_id=imp_id, snapshot_id=snap_id, build_id=bid, + build_reused=build_reused, report=report) + + +# -------------------------------------------------------------------------- +# 删除数据源 +# -------------------------------------------------------------------------- + + +async def retire_source(session: AsyncSession, source_id: str) -> None: + """数据源删除时调用(不提交):它的导入记录全部置 retired。文件交给回收,被运行引用的会保留。""" + await session.execute( + update(TableImport).where(TableImport.source_id == source_id, TableImport.status != "retired") + .values(status="retired") + ) + + +async def purge_import_raw( + session: AsyncSession, imp: TableImport, *, reason: str, signed_by: str | None +) -> tuple[bool, list[TableImport]]: + """清除一次导入的原件(提交)。返回 (是否删了文件, 一并标成已清除的其他导入记录)。 + + **按内容清除。** 原件按哈希只存一份,同一个文件传过两次、传给过两个源,共用的是同一个 + 文件。清除的目的是让含敏感内容的原件从服务器上消失(D13),所以文件一定删;同一份内容 + 下其他仍保留原件的导入一并标成已清除,记录里写明是随哪次导入清除的,回执逐条列出。 + 持版本存储的锁:发布正在把同一份原件登记给新导入时,不能在它提交前把文件删掉。 + """ + async with _store_lock(): + others = list((await session.execute( + select(TableImport).where( + TableImport.raw_sha256 == imp.raw_sha256, TableImport.raw_state == "kept", + TableImport.id != imp.id, + ).order_by(TableImport.source_id, TableImport.seq) + )).scalars()) + record = { + "at": utcnow().isoformat(), "reason": reason, + "signed_by": (signed_by or "").strip() or None, "signed_by_verified": False, + } + imp.raw_state, imp.purged = "purged", record + for other in others: + other.raw_state, other.purged = "purged", {**record, "via_import": imp.id} + await session.commit() + deleted = await asyncio.to_thread(lambda: raw_store.purge_raw(imp.raw_sha256, prune=True)) + return deleted, others + + +# -------------------------------------------------------------------------- +# 回收 +# -------------------------------------------------------------------------- + + +def _snapshots_in(versions: Any) -> set[str]: + """runs.data_versions 里引用的快照 id。形状是 {source_id: {"snapshot": id, ...}},也认直接写 id 的。""" + found: set[str] = set() + if not isinstance(versions, dict): + return found + for value in versions.values(): + if isinstance(value, str) and value: + found.add(value) + elif isinstance(value, dict): + sid = value.get("snapshot") or value.get("snapshot_id") + if isinstance(sid, str) and sid: + found.add(sid) + return found + + +def _delete_store_file(path: str) -> None: + """删一个版本库文件。只删上传目录里的:记录被改坏了也不能删到别处去。""" + if not path or not _under(path, settings.uploads_dir): + if path: + logger.warning("回收跳过了上传目录以外的文件:%s", path) + return + try: + _remove_db_files(Path(path)) + except OSError as e: + logger.warning("回收没能删掉 %s:%s", path, e) + + +async def gc(session: AsyncSession) -> None: + """回收没人用的版本。发布后、删除数据源后、启动时各调一次。 + + 引用集合 = 任何状态的运行(排队、执行中、等审批、已结束)在 runs.data_versions 里引用的 + 快照 ∪ 各源当前的快照。受保护的是这些快照、它们的导入记录、构建库,以及这些导入记录里 + 还留着的原件。其余的:记录先标 retired 并提交,再删文件——删到一半出错,下次回收会接着删 + (已 retired、没被保护的文件照样会被处理)。 + """ + async with _store_lock(): + referenced: set[str] = set() + for (versions,) in (await session.execute(select(Run.data_versions))).all(): + referenced |= _snapshots_in(versions) + referenced |= { + sid for (sid,) in (await session.execute( + select(DataSource.current_snapshot_id).where(DataSource.current_snapshot_id.is_not(None)) + )).all() + } + snaps = list((await session.execute(select(SourceSnapshot))).scalars()) + imports = list((await session.execute(select(TableImport))).scalars()) + builds = list((await session.execute(select(TableBuild))).scalars()) + + kept_imports: set[str] = {i.id for i in imports if i.status == "active"} + kept_paths: set[str] = set() + for snap in snaps: + if snap.id in referenced: + kept_imports |= {str(x) for x in snap.imports or []} + kept_paths.add(snap.db_path) + kept_builds = {i.build_id for i in imports if i.id in kept_imports} + kept_raw = {i.raw_sha256 for i in imports + if i.id in kept_imports and i.raw_state == "kept" and i.raw_sha256} + build_paths = {b.db_path for b in builds} + + now = utcnow() + files: list[str] = [] + raws: set[str] = set() + touched_sources: set[str] = set() + for snap in snaps: + if snap.id in referenced: + continue + if snap.retired_at is None: + snap.retired_at = now + touched_sources.add(snap.source_id) + # 期 1 的快照库就是构建库,由下面的构建处理;以后「按期累积」物化出的独立文件在这里删 + if snap.db_path not in build_paths and snap.db_path not in kept_paths: + files.append(snap.db_path) + for build in builds: + if build.id in kept_builds or build.db_path in kept_paths: + continue + if build.retired_at is None: + build.retired_at = now + touched_sources.add(build.source_id) + files.append(build.db_path) + for imp in imports: + if imp.id in kept_imports: + continue + if imp.status != "retired": + imp.status = "retired" + if imp.raw_state == "kept" and imp.raw_sha256 and imp.raw_sha256 not in kept_raw: + imp.raw_state = "purged" + imp.purged = {"at": now.isoformat(), "reason": "版本已回收,原件随之删除", + "signed_by": None, "auto": True} + raws.add(imp.raw_sha256) + await session.commit() + + def _delete() -> None: + for path in files: + if Path(path).exists(): + _delete_store_file(path) + for sha in raws: + raw_store.purge_raw(sha, prune=True) + + await asyncio.to_thread(_delete) + # 被删掉文件的快照可能还有引擎开着文件句柄:已删除的文件要等句柄关了才真正释放空间 + for source_id in touched_sources: + await engines.invalidate(source_id) + + +# -------------------------------------------------------------------------- +# 启动:清理半成品、迁移老上传、回收 +# -------------------------------------------------------------------------- + + +def _sweep_tmp() -> int: + """删掉上次崩溃留下的 *.tmp-* 半成品(库和原件都是先写临时文件再 link)。""" + removed = 0 + for root in (tables_root(), raw_store.raw_root()): + if not root.is_dir(): + continue + for path in root.rglob(f"*{_TMP}*"): + if path.is_file(): + path.unlink(missing_ok=True) + removed += 1 + return removed + + +async def _sweep_orphans(session: AsyncSession) -> None: + """删掉没有记录的文件:发布时 link 之后、提交之前进程没了,库文件或原件就成了孤儿。""" + build_ids = {bid for (bid,) in (await session.execute(select(TableBuild.id))).all()} + kept_raw = { + sha for (sha,) in (await session.execute( + select(TableImport.raw_sha256).where(TableImport.raw_state == "kept") + )).all() if sha + } + + def _sweep() -> list[Path]: + orphans: list[Path] = [] + root = tables_root() + if root.is_dir(): + for path in root.glob("*/builds/*.db"): + if path.stem not in build_ids: + orphans.append(path) + raw_root = raw_store.raw_root() + if raw_root.is_dir(): + for path in raw_root.glob("*/*"): + if path.is_file() and path.name not in kept_raw and _TMP not in path.name: + orphans.append(path) + for path in orphans: + _remove_db_files(path) + # 空目录:被拒收的新源留下的 tables/<源 id>/builds/、清除原件后的 raw/<前两位>/。 + # 启动时持着锁、还没开始接请求,删了也不会和正在写的发布撞上(发布会按需重建目录) + if root.is_dir(): + for builds in root.glob("*/builds"): + if builds.is_dir() and raw_store.remove_empty_dir(builds): + orphans.append(builds) + for folder in root.iterdir(): + if folder.is_dir() and raw_store.remove_empty_dir(folder): + orphans.append(folder) + if raw_root.is_dir(): + for folder in raw_root.iterdir(): + if folder.is_dir() and raw_store.remove_empty_dir(folder): + orphans.append(folder) + return orphans + + for path in await asyncio.to_thread(_sweep): + logger.info("清理了没有记录的上传文件(或空目录):%s", path) + + +def _legacy_candidate(kind: str | None, database: str | None) -> str | None: + """老上传的库:路径直接落在 uploads/tables/ 下、文件名以 .db 结尾。返回 realpath。""" + if (kind or "").lower() != "sqlite" or not database: + return None + real = os.path.realpath(database) + if not real.endswith(".db") or os.path.dirname(real) != os.path.realpath(tables_root()): + return None + return real + + +def _prepare_legacy(path: str) -> tuple[str, int]: + """老库定稿:合并可能残留的日志,改成 0444,返回 (sha256, 字节数)。""" + p = Path(path) + if Path(f"{path}-wal").exists() or Path(f"{path}-journal").exists(): + _settle_journal(p) + os.chmod(p, 0o444) + return raw_store.sha256_file(p), p.stat().st_size + + +async def _migrate_legacy(session: AsyncSession) -> int: + """给迁移前的上传源补上 v0 版本:构建、导入记录(没有原件)、快照,origin 改成 upload。 + + 幂等:已经是 upload 的源跳过;每个源一个事务,中途失败不会留下半套记录。 + + 一个源迁不过去(文件属主不对 chmod 报错、日志合并不了、提交失败)不该挡住别的源和整个 + 启动。回滚会让会话里所有 ORM 对象过期,之后再读它们的属性就要回库懒加载——在异步会话里 + 这一步直接抛 MissingGreenlet。所以先取纯值,每个源在循环里重新 get,日志只用本地变量。 + """ + candidates = (await session.execute( + select(DataSource.id, DataSource.name, DataSource.kind, DataSource.database).where( + DataSource.kind == "sqlite", + or_(DataSource.origin.is_(None), DataSource.origin != "upload"), + ).order_by(DataSource.name) + )).all() + migrated = 0 + for source_id, name, kind, database in candidates: + path = _legacy_candidate(kind, database) + if path is None: + continue + if not os.path.isfile(path): + logger.warning("上传表格「%s」的数据文件不存在,未迁移:%s", name, path) + continue + try: + row = await session.get(DataSource, source_id) + if row is None or row.origin == "upload": + continue + file_sha, size = await asyncio.to_thread(_prepare_legacy, path) + bid = legacy_build_id(source_id, file_sha) + now = utcnow() + options = {"legacy": True} + if await session.get(TableBuild, bid) is None: + session.add(TableBuild( + id=bid, source_id=source_id, raw_sha256="", options_sha256=_sha_json(options), + engine_ver=LEGACY_ENGINE_VER, options=options, db_path=path, db_sha256=file_sha, + report={}, created_at=now, + )) + last_seq = (await session.execute( + select(func.max(TableImport.seq)).where(TableImport.source_id == source_id) + )).scalar() or 0 + imp_id = new_id() + snap_id = snapshot_id(source_id, [imp_id], LEGACY_ENGINE_VER) + source_options = upload_options(row.options) + cache = dict(row.schema_cache or {}) + if not cache.get("tables"): + # 没有可用的结构缓存:上传源不能再手动探查,迁移时补探一次 + view = SourceView(id=source_id, name=name, database=path, options=source_options, + description=row.description or "", snapshot_id=snap_id, + expected_sha256=file_sha) + try: + cache = await _introspect_view(view, {}) + except Exception as e: # noqa: BLE001 - 探不到就带着空结构迁过去,重传可修 + logger.warning("迁移上传表格「%s」时探查结构失败:%s", name, e) + session.add(TableImport( + id=imp_id, source_id=source_id, seq=last_seq + 1, build_id=bid, file_name="", + file_size=size, raw_sha256="", raw_state="absent", status="active", + created_at=now, activated_at=now, + )) + session.add(SourceSnapshot( + id=snap_id, source_id=source_id, imports=[imp_id], db_path=path, db_sha256=file_sha, + schema_cache=cache, created_at=now, + )) + row.origin, row.readonly, row.current_snapshot_id = "upload", True, snap_id + row.schema_cache = cache + if source_options != (row.options or {}): + row.options = source_options + await session.commit() + migrated += 1 + except Exception: # noqa: BLE001 - 一个源迁不过去不该挡住别的源和整个启动 + try: + await session.rollback() + except Exception: # noqa: BLE001 - 回滚也失败时照样记下原因、接着处理下一个 + logger.exception("迁移上传表格「%s」失败后回滚出错", name) + logger.exception("迁移上传表格「%s」失败", name) + finally: + await engines.invalidate(source_id) + return migrated + + +async def _lock_manual_in_uploads(session: AsyncSession) -> int: + """升级前就指向上传目录、又没迁移成上传源的手工 SQLite 源:强制改成只读(提交)。返回改了几个。 + + 新建、修改时指向上传目录会被拒(api/datasources.py),但升级前已经登记的这类源(路径在 + uploads/tables 更深的目录里、带连接参数的别名……)迁移认不出来,原样留着可写,就能往归版本底座管的 + 目录里写。方案要求迁移时强制只读并标出:这里改只读、逐个记一条警告日志;之后想改回可写,修改接口 + 会拒(同样按 under_uploads 判断)。 + """ + rows = list((await session.execute( + select(DataSource).where( + DataSource.kind == "sqlite", DataSource.readonly.is_(False), + or_(DataSource.origin.is_(None), DataSource.origin != "upload"), + ) + )).scalars()) + locked = [row for row in rows if under_uploads(row.database)] + for row in locked: + row.readonly = True + logger.warning("手工登记的 SQLite 数据源「%s」指向上传表格的存放目录(%s),已强制改为只读", + row.name, row.database) + if locked: + await session.commit() + for row in locked: + await engines.invalidate(row.id) + return len(locked) + + +async def _disable_manual_app_files(session: AsyncSession) -> int: + """升级前登记的、指向 AgentLab 自己数据文件的手工 SQLite 源:停用并改成只读(提交)。返回改了几个。 + + 新建、修改时会被拒(api/datasources.py,按 reaches_app_files 判断)。这类登记没有正当用途: + 只读也会把元数据库整个交给模型,可写就能改上传源的连接字段和快照登记。只改只读不够,所以停用; + 之后除了改路径和删除,修改接口一律拒。 + """ + rows = list((await session.execute( + select(DataSource).where( + DataSource.kind == "sqlite", + or_(DataSource.enabled.is_(True), DataSource.readonly.is_(False)), + or_(DataSource.origin.is_(None), DataSource.origin != "upload"), + ) + )).scalars()) + hit = [row for row in rows if reaches_app_files(row.database)] + for row in hit: + row.readonly, row.enabled = True, False + logger.warning("手工登记的 SQLite 数据源「%s」指向 AgentLab 自己的数据文件(%s),已停用并改为只读", + row.name, row.database) + if hit: + await session.commit() + for row in hit: + await engines.invalidate(row.id) + return len(hit) + + +async def startup(session: AsyncSession) -> None: + """服务启动时调用一次:清理半成品和孤儿文件、迁移老上传、把指向上传目录的手工源改成只读、 + 停用指向应用自己数据文件的手工源、回收。""" + async with _store_lock(): + swept = await asyncio.to_thread(_sweep_tmp) + if swept: + logger.info("清理了 %d 个上次未完成的上传临时文件", swept) + migrated = await _migrate_legacy(session) + if migrated: + logger.info("为 %d 个已有的上传表格建立了初始版本", migrated) + try: + await _lock_manual_in_uploads(session) + except Exception: # noqa: BLE001 - 改不成只读不该挡住启动;下次启动再试 + await session.rollback() + logger.exception("把指向上传目录的手工数据源改成只读时出错") + try: + await _disable_manual_app_files(session) + except Exception: # noqa: BLE001 - 同上 + await session.rollback() + logger.exception("停用指向应用数据文件的手工数据源时出错") + await _sweep_orphans(session) + await gc(session) diff --git a/backend/app/data/tabular.py b/backend/app/data/tabular.py index 007900a3..7b4014a1 100644 --- a/backend/app/data/tabular.py +++ b/backend/app/data/tabular.py @@ -14,199 +14,834 @@ engine.build_url 拿 database 当文件路径。落成 .db 文件再建一条 Da **建表必须用这里的直连,不能走 data.engine.run_query。** guard 的 _ALWAYS_DENIED 里有 create,那是对的——模型不该能建表。而导入是我们自己 发起的可信操作,两件事不该共用一条通道。 + +**不许静默出错。** 以前这里有十几处会把数据悄悄读错:前导零丢掉、19 位卡号变成浮点、 +千分位文本求和只取前缀、日期带上 T00:00:00 查不到、隐藏工作表里的口令进了库、 +空行和合计行灌进 COUNT、数字列混进一个「N/A」就整列变成 TEXT 按字典序比大小。 +现在每一种取舍要么由用户显式选择(NeedsDecision),要么写进导入回执(LoadReport), +读不了的整份拒收(UnsupportedTable)。 + +读法是两遍:xlsx_scan 先流式扫出真实边界和版式元数据,再用 openpyxl read_only 按边界读值。 +值也是两遍:先取前 _TYPE_SAMPLE 行推断类型,再流式写入,写的同时统计整列;整列的结论和样本 +不一致(样本没看到的前导零、小数、占位符),就按整列的结论重写这张表。 """ from __future__ import annotations import csv +import datetime as _dt import io +import itertools +import math import re import sqlite3 -import zipfile +from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass, field from typing import Any from app.core.errors import describe_exception +from app.data import xlsx_scan +from app.data.names import NAME_MAX, collide_key, to_sql_name +from app.data.xlsx_scan import SheetScan, TableScan, UnsupportedTable, WorkbookScan, cell_ref, col_letter -#: 扫多少行来推断列类型。全表扫一遍对几十万行的文件太慢,而类型这种东西 -#: 前几千行看不出来的,后面多半也看不出来——真看错了,SQLite 的动态类型 -#: 也还能把值原样存下去,不会丢数据 +__all__ = [ + "CONVERSION_KINDS", "SHAPE_KINDS", "UNSHAPED_NOTE", "LoadReport", "LoadedTable", "NeedsDecision", + "UnsupportedTable", "infer_type", "load_into", +] + +#: 按原样导入(未规整)的表写进表结构的说明(data/table_versions.py 注入 schema_cache 的 comment)。 +#: 不含数字和坐标:它会进模型看得到的文本(工具描述、db_schema、写作目录、裁判摘录,H11)。 +#: 放在这里而不是 table_versions:证据层、裁判、数据源工具按它认出未规整的表,不必为一句话去导入版本底座 +UNSHAPED_NOTE = "本表按原样导入、未经规整:同一列里混有不同口径的行,不能直接对列求和" + +#: 先看多少行来推断列类型。整列的统计在写入时另做,样本看错了会按整列的结论重写, +#: 所以这个数只影响速度,不影响结果 _TYPE_SAMPLE = 5000 +#: 稀疏判定:边界面积超过 非空格数(至少按 SPARSE_FLOOR 算)的 SPARSE_FACTOR 倍就拒收。 +#: 阈值是推测:两百万格的稠密列表和普通交叉表都远在线下,一个误输入在 XFD 列的格子会让 +#: 一张几百行的表撑成几千万格 +SPARSE_FACTOR = 50 +SPARSE_FLOOR = 20_000 +#: 「数字列混入非数字」:非空值里数字占比不低于这个数…… +MIXED_MIN_SHARE = 0.8 +#: ……且非数字的不同取值不超过这么多种(占位符、错误值、「N/A」之类)。再多就是真的文本列 +MIXED_MAX_DISTINCT = 5 +#: 表头行里有这么多个日期样式的格子,就当作日期横排的交叉表 +DATE_HEADER_MIN = 7 +#: 超过这么多位的数字文本(卡号、订单号)按文本存:REAL 只有 15 到 17 位有效数字,会撞值 +LONG_DIGITS = 15 +#: SQLite 一张表最多 2000 列(SQLITE_MAX_COLUMN 的默认值)。超了 CREATE TABLE 会报一句英文 +MAX_COLUMNS = 2000 _CSV = {".csv", ".tsv"} _EXCEL = {".xlsx", ".xlsm"} _LEGACY_EXCEL = {".xls"} +#: Excel 的错误值。openpyxl 的 data_only 把它们读成这些字符串 +EXCEL_ERRORS = frozenset({ + "#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A", "#GETTING_DATA", + "#SPILL!", "#CALC!", "#FIELD!", "#BLOCKED!", "#UNKNOWN!", "#CONNECT!", "#BUSY!", "#PYTHON!", +}) -class UnsupportedTable(ValueError): - """认不出或读不了。message 直接给用户看。""" + +class NeedsDecision(UnsupportedTable): + """要用户拍板才能继续导入。 + + kind 是 "mixed"(数字列混入非数字:存空值,还是不导入)或 "shape"(交叉表、多块结构: + 按原样导入,还是不导入);details 可以 JSON 化,交给界面逐条展示;str(e) 是给人看的一句话。 + 继承 UnsupportedTable:不认识这个类的老调用方会把它当成一次拒收,不会误导入。 + """ + + def __init__(self, message: str, *, kind: str, details: dict[str, Any]) -> None: + super().__init__(message) + self.kind = kind + self.details = details @dataclass class LoadedTable: name: str - columns: list[str] - types: list[str] + #: 原始工作表名(CSV 是文件名)。清洗成表名之后,用户要能对上是哪一张 + sheet: str + #: [{name, type, header}]:SQL 列名、类型、原表头 + columns: list[dict[str, str]] rows: int - #: 原始 sheet 名。清洗成表名之后,用户要能对上是哪一张 - source_name: str = "" + #: 导入区域(A1,表头行到最后一行数据) + region: str = "" + blank_rows_skipped: int = 0 + #: 去掉的左右两侧整列全空的列(列字母) + columns_trimmed: list[str] = field(default_factory=list) + #: 按原样导入、未经规整(交叉表、多块结构) + unshaped: bool = False + + @property + def source_name(self) -> str: + """旧字段名,给还没改过来的调用方。""" + return self.sheet + + @property + def types(self) -> list[str]: + return [c["type"] for c in self.columns] + + @property + def column_names(self) -> list[str]: + return [c["name"] for c in self.columns] @dataclass class LoadReport: tables: list[LoadedTable] = field(default_factory=list) + #: [{sheet, state, reason}]:reason 是 hidden(隐藏工作表不导入)或 empty(没有内容) + skipped_sheets: list[dict[str, str]] = field(default_factory=list) + #: [{table, column, kind, count, examples}],kind 见 CONVERSION_KINDS + conversions: list[dict[str, Any]] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) @property def total_rows(self) -> int: return sum(t.rows for t in self.tables) +#: 回执里 conversions 的 kind 和它的说法(给界面和日志对照用) +CONVERSION_KINDS = { + "thousands_separator": "千分位文本按数字存", + "nonnumeric_to_null": "非数字的值存为空值", + "kept_as_text": "前导零或超长数字,整列按文本存", +} + + def _ext(filename: str) -> str: idx = filename.rfind(".") return filename[idx:].lower() if idx >= 0 else "" # -------------------------------------------------------------------------- -# 表名 / 列名 +# 值的分类 # -------------------------------------------------------------------------- +_EMPTY, _INT, _FLOAT, _DATE, _KEEP, _TEXT = range(6) -def _table_name(raw: str, taken: set[str]) -> str: - """sheet 名 → 表名。中文原样保留,只替换真正会断句的字符。 +# 只认 ASCII 数字(re.ASCII):Python 的 \d 默认连全角「123」、阿拉伯-印度数字都算, +# 那样会把全角写的编号悄悄转成数字。全角数字按文本,进了数字列就交给用户拍板 +_INT_TEXT = re.compile(r"-?\d+", re.ASCII) +_DEC_TEXT = re.compile(r"-?\d+\.\d+", re.ASCII) +#: 千分位:首组 1 到 3 位且不以 0 开头(「0,250」「00,123」不是千分位写法,多半是逗号当小数点) +_THOUSANDS = re.compile(r"-?[1-9]\d{0,2}(?:,\d{3})+(?:\.\d+)?", re.ASCII) +#: 逗号当小数点的写法(「0,250」「1,5」「1.234,56」)。认不准逗号是什么意思,按文本存,回执里写明 +_COMMA_NUMBER = re.compile(r"-?\d+(?:\.\d{3})*,\d+", re.ASCII) +_DATE_TEXT = re.compile(r"\d{1,2}月\d{1,2}日|\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?", re.ASCII) +#: 只写了月日、没有年份的日期文本(「8月1日」) +_MONTH_DAY = re.compile(r"\d{1,2}月\d{1,2}日", re.ASCII) +#: 带百分号、单位或货币符号的数字(「15%」「1,234元」「¥1234」「12kg」「50万」)。按文本存——百分比是 0.15 +#: 还是 15、「万」要不要乘上去,口径要人来定——但 SQLite 对这种文本 SUM 只取开头的数字(「1,234元」算成 1, +#: 「¥1234」算成 0),MAX 按字典序取,所以回执里要写明(_units_in_text)。单位只认汉字(不以年月日号时点开头: +#: 「2026年」「8月」「3号」「18时」是日期时间,不是数量)和几个常见的英文单位;「12A」这类编号不算 +_UNIT_NUMBER = re.compile( + r"(?P
[¥¥$€£₩₹])?\s?-?(?P[¥¥$€£₩₹])?\s?"
+    r"(?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)(?:\.[0-9]+)?"
+    r"\s?(?P[%‰%]|[\u4e00-\u9fff]{1,4}|(?i:kg|g|mg|t|km|m|cm|mm|ml|l|kwh|kw|w|h|min|pcs|pc|usd|rmb|cny))?"
+)
+_NOT_UNIT_HEAD = frozenset("年月日号时点")
+#: 带单位的数字能以哪些字符开头(先挡掉绝大多数普通文本,不必每格都跑一遍正则)
+_UNIT_LEAD = frozenset("0123456789-¥¥$€£₩₹")
 
-    一开始清洗成了纯 ASCII,结果「明细」「汇总」两张表都变成 sheet、sheet_2——
-    模型和用户都认不出哪张是哪张。而 SQLite 的标识符本来就允许非 ASCII,
-    `FROM 明细` 不加引号也解析得了,所以那道清洗纯属自找麻烦。
 
-    真需要处理的只有空白和标点:`销售 明细` 不引号就断成两个 token。
-    正则的词字符类按 Unicode 判定,汉字算词字符,正好留下。
+def _fmt_float(value: float) -> str:
+    if value.is_integer() and abs(value) < 1e15:
+        return str(int(value))
+    return repr(value)
+
+
+def _fmt_datetime(value: _dt.datetime) -> str:
+    """日期型:零点的只留日期,带时间的用空格分隔(不是 T)。
+
+    `WHERE 日期 = '2026-01-05'` 要能查到;存成 2026-01-05T00:00:00 的话一行都查不到,
+    BETWEEN 还会漏掉月末那天。
     """
-    slug = re.sub(r"[^\w]+", "_", (raw or "").strip(), flags=re.UNICODE).strip("_")
-    if not slug:
-        slug = "sheet"
-    elif slug[0].isdigit():
-        slug = f"t_{slug}"      # 标识符不能数字开头
-    base, n = slug[:48], 2
-    slug = base
-    while slug in taken:
-        slug = f"{base}_{n}"
-        n += 1
-    taken.add(slug)
-    return slug
+    if value.hour == value.minute == value.second == value.microsecond == 0:
+        return value.strftime("%Y-%m-%d")
+    text = value.strftime("%Y-%m-%d %H:%M:%S")
+    if value.microsecond:
+        text += f".{value.microsecond // 1000:03d}"
+    return text
 
 
-def _clean_headers(raw_row: list[Any]) -> list[str]:
-    """表头单元格 → 列名。**保持原样**,只治两种病态。
-
-    不转写、不 slug 化:Excel 表头几乎都是中文,转写是有损的,而 SQLite 认
-    带引号的标识符,db_schema__ 又会把准确列名交给模型。
-    """
-    out: list[str] = []
-    seen: dict[str, int] = {}
-    for i, cell in enumerate(raw_row):
-        name = str(cell).strip() if cell is not None else ""
-        # 空表头给个位置名。合并单元格、末尾多出来的空列都会走到这里
-        if not name:
-            name = f"col_{i + 1}"
-        # 双引号会把带引号的标识符打断,是唯一必须动的字符
-        name = name.replace('"', "'")
-        if name in seen:
-            seen[name] += 1
-            name = f"{name}_{seen[name]}"
-        else:
-            seen[name] = 1
-        out.append(name)
-    return out
+def _fmt_timedelta(value: _dt.timedelta) -> str:
+    seconds = int(value.total_seconds())
+    sign = "-" if seconds < 0 else ""
+    hours, rest = divmod(abs(seconds), 3600)
+    return f"{sign}{hours}:{rest // 60:02d}:{rest % 60:02d}"
 
 
-# --------------------------------------------------------------------------
-# 类型推断
-# --------------------------------------------------------------------------
-
-_INT_RE = re.compile(r"^[+-]?\d{1,18}$")
-_FLOAT_RE = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
+def _classify_text(text: str, thousands: bool = True) -> tuple[int, Any, str | None]:
+    if text in EXCEL_ERRORS:
+        return _TEXT, text, "error"
+    if _INT_TEXT.fullmatch(text):
+        body = text.lstrip("-")
+        # 前导零是编码("00123"),超过 15 位是卡号订单号:按数字存就把它们毁了
+        if (len(body) > 1 and body[0] == "0") or len(body) > LONG_DIGITS:
+            return _KEEP, text, None
+        return _INT, int(text), None
+    if _DEC_TEXT.fullmatch(text):
+        body = text.lstrip("-")
+        head = body.split(".", 1)[0]
+        if (len(head) > 1 and head[0] == "0") or len(body) - 1 > LONG_DIGITS:
+            return _KEEP, text, None
+        return _FLOAT, float(text), None
+    if "," in text:
+        if _THOUSANDS.fullmatch(text):
+            # 分号分隔的 CSV(欧洲的区域设置)里「1,500」可能是 1.5:不转,和「0,250」一样按文本、记下来
+            if not thousands:
+                return _TEXT, text, "comma"
+            plain = text.replace(",", "")
+            if len(plain.lstrip("-").replace(".", "")) > LONG_DIGITS:
+                return _KEEP, text, None
+            if "." in plain:
+                return _FLOAT, float(plain), "thousands"
+            return _INT, int(plain), "thousands"
+        if _COMMA_NUMBER.fullmatch(text):
+            return _TEXT, text, "comma"
+    # "1E5"、"+86"、"12%"、全角数字都不当数字:编码要原样,百分比的口径要人来定。
+    # 带百分号、单位、货币符号的另做记号,回执里要说(见 _UNIT_NUMBER)
+    if text[0] in _UNIT_LEAD and (m := _UNIT_NUMBER.fullmatch(text)) is not None:
+        suffix = m.group("suf")
+        if (m.group("pre") or m.group("pre2") or suffix) and not (suffix and suffix[0] in _NOT_UNIT_HEAD):
+            return _TEXT, text, "unit"
+    return _TEXT, text, None
 
 
-def _norm(value: Any) -> Any:
-    """单元格 → 要存进去的值。空的一律 NULL。
+
+_EMPTY_C: tuple[int, Any, str | None] = (_EMPTY, None, None)
+_LONG_INT = 10 ** LONG_DIGITS
+
+
+def _classify(value: Any, thousands: bool = True) -> tuple[int, Any, str | None]:
+    """单元格 → (类别, 载荷, 备注)。载荷:数字类是数值,其余是要存的文本。
+
+    thousands=False 时千分位文本不转数字(分号分隔的 CSV:逗号可能是小数点)。
 
     空字符串和 NULL 必须分清楚:存成 "" 的话,一个本该是数字的列就被一个
     空单元格拖成了 TEXT,然后 SUM() 静默地不再是你以为的那个 SUM()。
+
+    最常见的 int、float、str 先按确切类型分派:每个单元格都要走一遍,几十万行时这点差别看得出来。
     """
-    if value is None:
-        return None
-    if isinstance(value, str):
+    kind = type(value)
+    if kind is str:
         text = value.strip()
-        return text or None
-    return value
+        return _classify_text(text, thousands) if text else _EMPTY_C
+    if kind is int:
+        # 超过 15 位的整数(程序写进去的卡号)按文本存:Excel 自己存不下这么多位,进了 REAL 会撞值
+        return (_INT, value, None) if -_LONG_INT < value < _LONG_INT else (_KEEP, str(value), None)
+    if kind is float:
+        if math.isnan(value) or math.isinf(value):
+            return _TEXT, repr(value), None
+        return _FLOAT, value, None
+    if value is None:
+        return _EMPTY_C
+    if isinstance(value, bool):
+        # bool 是 int 的子类,但"真/假"不是数量,不该被当成数字去求和
+        return _TEXT, "TRUE" if value else "FALSE", None
+    if isinstance(value, _dt.datetime):
+        return _DATE, _fmt_datetime(value), None
+    if isinstance(value, _dt.date):
+        return _DATE, value.isoformat(), None
+    if isinstance(value, _dt.time):
+        return _TEXT, value.strftime("%H:%M:%S"), None
+    if isinstance(value, _dt.timedelta):
+        return _TEXT, _fmt_timedelta(value), None
+    if isinstance(value, str):
+        return _classify(str(value), thousands)
+    if isinstance(value, int):
+        return _classify(int(value))
+    if isinstance(value, float):
+        return _classify(float(value))
+    return _TEXT, str(value), None
 
 
-def infer_type(values: list[Any]) -> str:
-    """一列的 SQLite 类型。
+def _as_text(value: Any, cls: int, payload: Any) -> str:
+    """要存进 TEXT 列的写法:文本原样(去首尾空白),原生数字按常规写法。"""
+    if isinstance(value, str):
+        return value.strip()
+    if cls in (_DATE, _TEXT, _KEEP):
+        return payload
+    if isinstance(value, float):
+        return _fmt_float(value)
+    return str(value)
+
+
+def _is_date_like(value: Any) -> bool:
+    return _date_like(_classify(value))
+
+
+def _date_like(parsed: tuple[int, Any, str | None]) -> bool:
+    """日期样式:日期型,或者「8月1日」「2026-08-01」这类写法的文本。"""
+    cls, payload, note = parsed
+    if cls == _DATE:
+        return True
+    return cls == _TEXT and note is None and payload[:1].isdigit() and _DATE_TEXT.fullmatch(payload) is not None
+
+
+def _display(value: Any) -> str:
+    """表头和提示里展示一个单元格的写法。"""
+    if value is None:
+        return ""
+    cls, payload, _ = _classify(value)
+    return _as_text(value, cls, payload) if cls != _EMPTY else ""
+
+
+class _ColStats:
+    """一列的分类计数。只记数和少量示例,不留值:几十万行也只占几百字节。"""
+
+    __slots__ = ("nonempty", "ints", "floats", "dates", "keep", "texts", "thousands",
+                 "thousands_ex", "keep_ex", "errors", "errors_ex", "commas", "commas_ex", "units", "units_ex",
+                 "other", "overflow")
+
+    def __init__(self) -> None:
+        self.nonempty = self.ints = self.floats = self.dates = self.keep = self.texts = 0
+        self.thousands = self.errors = self.commas = self.units = 0
+        self.thousands_ex: list[str] = []
+        self.keep_ex: list[str] = []
+        self.errors_ex: list[str] = []
+        #: 逗号当小数点的写法(「0,250」):按文本存,回执里要说
+        self.commas_ex: list[str] = []
+        #: 带百分号、单位、货币符号的数字(「15%」「1,234元」):按文本存,回执里要说
+        self.units_ex: list[str] = []
+        #: 非数字的取值 → 个数,最多记 MIXED_MAX_DISTINCT 种;再多就只记「超了」
+        self.other: dict[str, int] = {}
+        self.overflow = False
+
+    def add(self, parsed: tuple[int, Any, str | None], raw: Any) -> None:
+        cls, payload, note = parsed
+        self.nonempty += 1
+        if cls == _INT:
+            self.ints += 1
+        elif cls == _FLOAT:
+            self.floats += 1
+        elif cls == _KEEP:
+            self.keep += 1
+            if len(self.keep_ex) < 3:
+                self.keep_ex.append(payload)
+            return
+        else:
+            if cls == _DATE:
+                self.dates += 1
+            else:
+                self.texts += 1
+                if note == "error":
+                    self.errors += 1
+                    if len(self.errors_ex) < 3 and payload not in self.errors_ex:
+                        self.errors_ex.append(payload)
+                elif note == "comma":
+                    self.commas += 1
+                    if len(self.commas_ex) < 3 and payload not in self.commas_ex:
+                        self.commas_ex.append(payload)
+                elif note == "unit":
+                    self.units += 1
+                    if len(self.units_ex) < 3 and payload not in self.units_ex:
+                        self.units_ex.append(payload)
+            if payload in self.other:
+                self.other[payload] += 1
+            elif len(self.other) < MIXED_MAX_DISTINCT:
+                self.other[payload] = 1
+            else:
+                self.overflow = True
+            return
+        if note == "thousands":
+            self.thousands += 1
+            if len(self.thousands_ex) < 3:
+                self.thousands_ex.append(str(raw).strip())
+
+    @property
+    def numeric(self) -> int:
+        return self.ints + self.floats
+
+
+def _decide(st: _ColStats) -> tuple[str, bool]:
+    """一列的 (SQLite 类型, 是不是「数字列混入非数字」)。
 
     SQLite 是动态类型,但列**亲和性**决定比较和排序的行为:整数列存成 TEXT,
     ORDER BY 就变成字典序,'10' 排在 '9' 前面;而这正是"Excel 里的数字能不能
-    真的被 SQL 算"的分界线。所以这一步不能省。
+    真的被 SQL 算"的分界线。
 
-    日期统一成 TEXT——SQLite 没有日期类型,而 ISO 格式的字典序恰好等于时间序,
-    存成 ISO 字符串既能排序也能比较。
+    日期统一成 TEXT——SQLite 没有日期类型,而 ISO 格式的字典序恰好等于时间序。
     """
-    import datetime as _dt
+    n = st.nonempty
+    if n == 0 or st.keep:
+        return "TEXT", False
+    num = st.numeric
+    if num == 0:
+        return "TEXT", False
+    numeric_type = "REAL" if st.floats else "INTEGER"
+    if num == n:
+        return numeric_type, False
+    if num >= MIXED_MIN_SHARE * n and not st.overflow:
+        return numeric_type, True
+    return "TEXT", False
 
-    seen = False
-    all_int = all_float = True
+
+def infer_type(values: Sequence[Any]) -> str:
+    """一列值的 SQLite 类型。
+
+    「数字列混入非数字」(数字占八成以上、非数字不超过五种)返回数字类型:那种列导入时要么
+    把非数字存空值、按数字存,要么不导入(load_into 的 mixed 参数),不会落成 TEXT。
+    """
+    st = _ColStats()
     for value in values:
-        norm = _norm(value)
-        if norm is None:
-            continue
-        seen = True
-        if isinstance(norm, bool):
-            # bool 是 int 的子类,但"真/假"不是数量,不该被当成 INTEGER 去求和
-            return "TEXT"
-        if isinstance(norm, (_dt.datetime, _dt.date, _dt.time)):
-            return "TEXT"
-        if isinstance(norm, int):
-            all_float = all_float and True
-            continue
-        if isinstance(norm, float):
-            all_int = False
-            continue
-        text = str(norm)
-        if all_int and not _INT_RE.match(text):
-            all_int = False
-        if all_float and not _FLOAT_RE.match(text):
-            all_float = False
-        if not all_float:
-            return "TEXT"
-    if not seen:
-        return "TEXT"      # 整列都是空的,别猜
-    return "INTEGER" if all_int else ("REAL" if all_float else "TEXT")
-
-
-def _coerce(value: Any, sql_type: str) -> Any:
-    """按推断出的类型把值转过去。转不动就原样存——宁可类型不齐,不可丢数据。"""
-    import datetime as _dt
-
-    norm = _norm(value)
-    if norm is None:
-        return None
-    if isinstance(norm, (_dt.datetime, _dt.date, _dt.time)):
-        return norm.isoformat()
-    if sql_type == "INTEGER":
-        try:
-            return int(str(norm))
-        except (TypeError, ValueError):
-            return str(norm)
-    if sql_type == "REAL":
-        try:
-            return float(str(norm))
-        except (TypeError, ValueError):
-            return str(norm)
-    return norm if isinstance(norm, (int, float)) else str(norm)
+        parsed = _classify(value)
+        if parsed[0] != _EMPTY:
+            st.add(parsed, value)
+    return _decide(st)[0]
 
 
 # --------------------------------------------------------------------------
-# 读文件
+# 一张表的行来源
+# --------------------------------------------------------------------------
+
+
+@dataclass
+class _Source:
+    """一张要导入的表:从表头行开始逐行给值,可以从头再读一遍(两遍都按边界读)。"""
+
+    sheet: str
+    rows: Callable[[], Iterator[Sequence[Any]]]
+    #: 表头所在的行号(Excel 行号;CSV 是第几条记录)
+    first_row: int
+    #: 每行第一个值所在的列号
+    first_col: int
+    scan: SheetScan | None = None
+    full_calc_on_load: bool = False
+    #: 本工作表上的 Excel 表格对象(插入 → 表格)
+    tables: list[TableScan] = field(default_factory=list)
+    #: 千分位文本按数字存。分号分隔的 CSV 关掉:那种文件里逗号多半是小数点
+    thousands: bool = True
+    #: 来自 CSV / TSV:没有工作表,提示里说「文件「…」」
+    csv: bool = False
+
+    @property
+    def where(self) -> str:
+        """提示里指这张表的说法:工作表「Sheet1」,CSV 是文件「订单」。"""
+        return f"{'文件' if self.csv else '工作表'}「{self.sheet}」"
+
+
+@dataclass
+class _Plan:
+    """怎么建这张表:保留哪几列(读取矩形里的下标 [lo, hi)),每列的类型。"""
+
+    lo: int
+    hi: int
+    types: list[str]
+    mixed: list[bool]
+
+    def key(self) -> tuple[int, int, tuple[str, ...]]:
+        return self.lo, self.hi, tuple(self.types)
+
+
+class _Observer:
+    """逐行看值:每列的分类计数、空行、分段标题和日期行的候选。两遍各用一个。"""
+
+    #: 候选最多记多少个。正常的表一个都没有;记满了说明根本不是列表,不用再记
+    MAX_CANDIDATES = 200
+
+    def __init__(self, first_row: int, thousands: bool = True) -> None:
+        self.thousands = thousands
+        self.stats: list[_ColStats] = []
+        self.row_no = first_row          # 当前行号;表头行之后从 first_row + 1 起
+        self.data_rows = 0
+        self.blank_rows = 0
+        self.first_data_row = 0
+        self.last_data_row = 0
+        #: (行号, 列下标, 文字):整行只有一个非空格、而且是文字。最多记 MAX_CANDIDATES 个,总数另计
+        self.single_text: list[tuple[int, int, str]] = []
+        self.single_text_total = 0
+        #: (行号, 日期格的列下标):一行里日期样式的格子达到 DATE_HEADER_MIN
+        self.date_rows: list[tuple[int, tuple[int, ...]]] = []
+        self._pending_date: tuple[int, tuple[int, ...]] | None = None
+
+    def feed(self, row: Sequence[Any]) -> list[tuple[int, Any, str | None]] | None:
+        """看一行。空行返回 None,否则返回每格的分类(写入时直接用)。"""
+        self.row_no += 1
+        stats = self.stats
+        n = len(row)
+        if len(stats) < n:
+            stats.extend(_ColStats() for _ in range(n - len(stats)))
+        parsed = [_EMPTY_C] * n
+        filled = 0
+        first = -1
+        thousands = self.thousands
+        for i, value in enumerate(row):
+            if value is None:
+                continue
+            item = _classify(value, thousands)
+            if item[0] == _EMPTY:
+                continue
+            parsed[i] = item
+            stats[i].add(item, value)
+            filled += 1
+            if first < 0:
+                first = i
+        if not filled:
+            self.blank_rows += 1
+            return None
+        self.data_rows += 1
+        if not self.first_data_row:
+            self.first_data_row = self.row_no
+        self.last_data_row = self.row_no
+
+        if filled == 1 and parsed[first][0] == _TEXT and parsed[first][2] is None:
+            self.single_text_total += 1
+            if len(self.single_text) < self.MAX_CANDIDATES:
+                self.single_text.append((self.row_no, first, parsed[first][1]))
+
+        dates = 0
+        if filled >= DATE_HEADER_MIN:
+            idx = [i for i, item in enumerate(parsed) if item[0] != _EMPTY and _date_like(item)]
+            dates = len(idx)
+        # 日期行要和下一行比:下一行也全是日期,那是一张有很多日期列的明细表,不是横排的表头
+        if self._pending_date is not None:
+            if dates < DATE_HEADER_MIN and len(self.date_rows) < self.MAX_CANDIDATES:
+                self.date_rows.append(self._pending_date)
+            self._pending_date = None
+        if dates >= DATE_HEADER_MIN:
+            self._pending_date = (self.row_no, tuple(idx))
+        return parsed
+
+    def width(self) -> int:
+        return len(self.stats)
+
+
+def _plan(header: Sequence[Any], obs: _Observer) -> _Plan | None:
+    """按观察到的值定下保留哪些列、每列什么类型。左右两侧表头和值都空的列去掉。"""
+    width = max(len(header), obs.width())
+    used = [
+        (i < len(header) and _display(header[i]) != "") or (i < len(obs.stats) and obs.stats[i].nonempty > 0)
+        for i in range(width)
+    ]
+    if not any(used):
+        return None
+    lo = used.index(True)
+    hi = width - used[::-1].index(True)
+    types, mixed = [], []
+    for i in range(lo, hi):
+        sql_type, is_mixed = _decide(obs.stats[i]) if i < len(obs.stats) else ("TEXT", False)
+        types.append(sql_type)
+        mixed.append(is_mixed)
+    return _Plan(lo, hi, types, mixed)
+
+
+def _unique(base: str, taken: set[str]) -> str:
+    """撞名(按 collide_key,比 SQLite 严)就加 _2、_3……,加完仍不超过名字上限。"""
+    name, n = base, 2
+    while collide_key(name) in taken:
+        suffix = f"_{n}"
+        name = base[: NAME_MAX - len(suffix)].rstrip("_") + suffix
+        n += 1
+    taken.add(collide_key(name))
+    return name
+
+
+def _column_names(header: Sequence[Any], plan: _Plan) -> list[tuple[str, str]]:
+    """[(SQL 列名, 原表头)]。列名满足 names.name_problem;原表头另存作显示名。"""
+    taken: set[str] = set()
+    out = []
+    for pos, i in enumerate(range(plan.lo, plan.hi), start=1):
+        raw = _display(header[i]) if i < len(header) else ""
+        out.append((_unique(to_sql_name(raw, fallback=f"col_{pos}"), taken), raw))
+    return out
+
+
+def _to_integer(value: Any, item: tuple[int, Any, str | None]) -> int | None:
+    return item[1] if item[0] == _INT else None
+
+
+def _to_real(value: Any, item: tuple[int, Any, str | None]) -> float | None:
+    cls = item[0]
+    return float(item[1]) if cls == _INT or cls == _FLOAT else None
+
+
+def _to_text(value: Any, item: tuple[int, Any, str | None]) -> str | None:
+    return None if item[0] == _EMPTY else _as_text(value, item[0], item[1])
+
+
+#: 按列类型把值转成要插入的 Python 值。表是 STRICT 的,类型对不上 SQLite 会报错——
+#: 但不靠它:这里先转好,INTEGER 列只给 int,REAL 只给 float,TEXT 只给 str,
+#: 绝不让 SQLite 的亲和性去悄悄转换。
+#:
+#: 数字列里的非数字给 NULL:要么是用户选了「存空值」,要么是样本没看到、整列结论会变,
+#: 这一遍写的会被重写(_Job.finish)。
+_CONVERT: dict[str, Callable[[Any, tuple[int, Any, str | None]], Any]] = {
+    "INTEGER": _to_integer, "REAL": _to_real, "TEXT": _to_text,
+}
+
+
+# --------------------------------------------------------------------------
+# 结构检测:交叉表、多块
+# --------------------------------------------------------------------------
+
+#: NeedsDecision(kind="shape") 的 details.reasons[].kind 和它的说法
+SHAPE_KINDS = {
+    "date_header": "表头是横排的日期",
+    "date_row": "表内有横排的日期行",
+    "section_title": "表内有分段标题行",
+    "table_totals": "表内有表格对象的汇总行",
+    "formula_above": "表内有汇总上方各行的公式",
+}
+
+
+def _clip(text: str, limit: int = 24) -> str:
+    text = " ".join(str(text).split())
+    return text if len(text) <= limit else text[: limit - 1] + "…"
+
+
+def _numeric_col(obs: _Observer, idx: int) -> bool:
+    """这一列往下大多是数字(过半即可,不要求够得上「混合列」的八成:叠放的第二块表头、
+    分段里的文字也在这一列里)。"""
+    st = obs.stats[idx] if idx < len(obs.stats) else None
+    return st is not None and st.numeric > 0 and st.numeric * 2 >= st.nonempty
+
+
+def _section_rows(plan: _Plan, obs: _Observer) -> tuple[list[tuple[int, int, str]], list[tuple[int, int, str]]]:
+    """符合分段标题判据的行:整行只有一个文字格、其余值格全空,而且不是第一行数据。
+
+    返回 (要用户拍板的, 照常导入但写进回执的)。其余列里至少有一列是数字列才要拍板:分段标题
+    混进 COUNT、把数字列切成几段,是会算错的;全是文字列的表(名单、备注表)一行只填了名字很正常,
+    照常导入,但逐行写进回执,不静默放过。只有一列的表没有「其余列」,这条判据无从谈起,不判。
+    """
+    if plan.hi - plan.lo < 2:
+        return [], []
+    numeric = [_numeric_col(obs, k) for k in range(plan.lo, plan.hi)]
+    total = sum(numeric)
+    flagged: list[tuple[int, int, str]] = []
+    exempt: list[tuple[int, int, str]] = []
+    for row, idx, text in obs.single_text:
+        if row == obs.first_data_row or not plan.lo <= idx < plan.hi:
+            continue
+        (flagged if total - numeric[idx - plan.lo] > 0 else exempt).append((row, idx, text))
+    return flagged, exempt
+
+
+def _shape_reasons(src: _Source, header: Sequence[Any], plan: _Plan, obs: _Observer) -> list[dict[str, Any]]:
+    """这张表是不是交叉表或多块结构。每条带坐标,给人看的说明在 message 里。"""
+    reasons: list[dict[str, Any]] = []
+    row0, col0 = src.first_row, src.first_col
+
+    def ref(row: int, idx: int) -> str:
+        return cell_ref(row, col0 + idx)
+
+    # 1. 表头行有一串日期:「8月1日 8月2日 …」横着排,一列一天
+    hits = [i for i in range(plan.lo, min(plan.hi, len(header))) if _is_date_like(header[i])]
+    if len(hits) >= DATE_HEADER_MIN:
+        span = f"{ref(row0, hits[0])}:{ref(row0, hits[-1])}"
+        # 「8月1日」这种只写了月日:哪一年只能靠文件名、标题行猜,导入后按日期筛选、跨年比较都没有依据
+        no_year = all(isinstance(header[i], str) and _MONTH_DAY.fullmatch(header[i].strip()) for i in hits)
+        reasons.append({
+            "kind": "date_header", "cells": [span],
+            "message": f"第 {row0} 行(表头)有 {len(hits)} 个日期样式的单元格({span}),疑似日期横排的交叉表"
+                       + (";这些日期没有写年份" if no_year else ""),
+            **({"no_year": True} if no_year else {}),
+        })
+
+    # 2. 表内有一行日期、下一行不是,而且这些列往下大多是数字:表头行号没填对,或者表格上下叠了
+    #    好几块。日期列本来就是日期的(一行一个项目、好几个里程碑日期)不算
+    shown_rows = 0
+    for row, idx in obs.date_rows:
+        cols = [i for i in idx if plan.lo <= i < plan.hi]
+        if not cols or sum(1 for i in cols if _numeric_col(obs, i)) * 2 < len(cols):
+            continue
+        span = f"{ref(row, cols[0])}:{ref(row, cols[-1])}"
+        reasons.append({
+            "kind": "date_row", "cells": [span],
+            "message": f"第 {row} 行有 {len(cols)} 个日期样式的单元格({span}),疑似表格中间的另一行表头",
+        })
+        shown_rows += 1
+        if shown_rows >= 5:
+            break
+
+    # 3. 分段标题:整行只有一个文字格,其余值格全空,而且不是第一行数据(判据见 _section_rows)
+    hits3, _ = _section_rows(plan, obs)
+    if hits3:
+        shown = "、".join(f"{ref(r, i)}「{_clip(t)}」" for r, i, t in hits3[:5])
+        rows = "、".join(str(r) for r, _, _ in hits3[:5]) + (" 等" if len(hits3) > 5 else "")
+        reasons.append({
+            "kind": "section_title", "cells": [ref(r, i) for r, i, _ in hits3[:50]],
+            "message": f"第 {rows} 行只有一个单元格有文字、其余列为空({shown}),疑似分段标题或备注",
+        })
+
+    # 4. Excel 表格对象(插入 → 表格)勾了「汇总行」:最后一行是 SUBTOTAL(109,销售表[金额]) 之类,
+    #    导入后会和明细一起被求和。表格对象的位置是文件里写明的,比看公式可靠
+    bounds = src.scan.bounds if src.scan is not None else None
+    bottom = bounds.max_row if bounds is not None else max(obs.last_data_row, row0)
+    left, right = col0 + plan.lo, col0 + plan.hi - 1
+    totals_rows: set[int] = set()
+    for table in src.tables:
+        rows_span, span4 = table.totals_rows(), table.span()
+        if rows_span is None or span4 is None:
+            continue
+        top, end = max(rows_span[0], row0 + 1), min(rows_span[1], bottom)
+        c_lo, c_hi = max(span4[1], left), min(span4[3], right)
+        if top > end or c_lo > c_hi:
+            continue
+        cells = f"{cell_ref(top, c_lo)}:{cell_ref(end, c_hi)}"
+        totals_rows.update(range(top, end + 1))
+        which = f"第 {top} 行" if top == end else f"第 {top} 到 {end} 行"
+        reasons.append({
+            "kind": "table_totals", "cells": [cells],
+            "message": f"表格对象「{_clip(table.name)}」({table.ref})的{which}是汇总行({cells}),"
+                       "导入后会和明细行一起被求和",
+        })
+
+    # 5. 公式汇总了上方的行(C26 = SUM(C22:C25)、B5 = B2+B3+B4):表内合计。扫描覆盖整张表,
+    #    只看表头以下的;已经按表格对象的汇总行报过的那几行不重复报
+    if src.scan is not None and src.scan.formulas_above.max_row > row0:
+        fa = src.scan.formulas_above
+        pairs = [(c, t) for c, t in zip(fa.cells, fa.texts)
+                 if (r := xlsx_scan.parse_ref(c)[0]) > row0 and r not in totals_rows]
+        complete = fa.total == len(fa.cells)
+        if pairs or not complete:
+            # 扫描只记了前 MAX_CELLS 个坐标;没记全时说不准表头以下有几个,就不报个数
+            count = f" {len(pairs)} 个" if complete else ""
+            example = f"如 {pairs[0][0]} 的公式 {_clip(pairs[0][1], 40)}" if pairs else "见文件中的合计行"
+            reasons.append({
+                "kind": "formula_above", "cells": [c for c, _ in pairs],
+                "message": f"有{count}单元格的公式汇总了上方的行({example}),疑似表内合计行",
+            })
+    return reasons
+
+
+# --------------------------------------------------------------------------
+# 一张表从头到尾
+# --------------------------------------------------------------------------
+
+
+class _Job:
+    def __init__(self, src: _Source) -> None:
+        self.src = src
+        self.header: Sequence[Any] = ()
+        self.plan: _Plan | None = None
+        self.final: _Plan | None = None
+        self.full: _Observer | None = None
+        self.table = ""
+        self.rows_written = 0
+        self.early_reasons: list[dict[str, Any]] = []
+        self.reasons: list[dict[str, Any]] = []
+        #: 第一遍在表头行之后读到了几行(含空行)。表头行和之后都读不到东西,说明表头行号超出了内容
+        self.rows_seen = 0
+        #: 第一遍(样本)的观察。结构拒收时据此预告混合列:不必等用户选了「按原样导入」再退回一次
+        self.sample_obs: _Observer | None = None
+        #: 样本是不是已经读完了整张表(没到 _TYPE_SAMPLE 行就到底了)。没读完时预告的混合列可能不全
+        self.sampled_all = True
+
+    def sample(self) -> None:
+        """第一遍:只读前 _TYPE_SAMPLE 行数据,定下列和类型,顺便看结构。"""
+        obs = _Observer(self.src.first_row, self.src.thousands)
+        it = self.src.rows()
+        try:
+            self.header = tuple(next(it, ()))
+            for row in it:
+                if obs.feed(row) is not None and obs.data_rows >= _TYPE_SAMPLE:
+                    self.sampled_all = next(it, None) is None
+                    break
+        finally:
+            _close(it)
+        self.sample_obs = obs
+        self.rows_seen = obs.data_rows + obs.blank_rows
+        self.plan = _plan(self.header, obs)
+        if self.plan is not None:
+            self.early_reasons = _shape_reasons(self.src, self.header, self.plan, obs)
+
+    def write(self, conn: sqlite3.Connection, plan: _Plan, *, observe: bool) -> None:
+        """建表并流式写入。observe=True 时同时统计整列(第二遍);重写时不再统计。"""
+        if plan.hi - plan.lo > MAX_COLUMNS:
+            raise UnsupportedTable(
+                f"{self.src.where}有 {plan.hi - plan.lo} 列,超过 {MAX_COLUMNS} 列的上限。"
+                "请删除不需要的列,或检查是否有远处的单元格误输入了内容"
+            )
+        names = _column_names(self.header, plan)
+        cols = ", ".join(f'"{n}" {t}' for (n, _), t in zip(names, plan.types))
+        conn.execute(f'DROP TABLE IF EXISTS "{self.table}"')
+        conn.execute(f'CREATE TABLE "{self.table}" ({cols}) STRICT')
+        obs = _Observer(self.src.first_row, self.src.thousands)
+        lo, hi = plan.lo, plan.hi
+        convert = [(_CONVERT[t], lo + k) for k, t in enumerate(plan.types)]
+        written = 0
+
+        def rows() -> Iterator[list[Any]]:
+            nonlocal written
+            it = self.src.rows()
+            try:
+                next(it, None)
+                for row in it:
+                    parsed = obs.feed(row)
+                    if parsed is None:
+                        continue
+                    if len(row) < hi:           # CSV 的短行:缺的尾巴按空值
+                        row = list(row) + [None] * (hi - len(row))
+                        parsed = parsed + [_EMPTY_C] * (hi - len(parsed))
+                    written += 1
+                    yield [conv(row[j], parsed[j]) for conv, j in convert]
+            finally:
+                _close(it)
+
+        marks = ", ".join("?" * (hi - lo))
+        conn.executemany(f'INSERT INTO "{self.table}" VALUES ({marks})', rows())
+        self.rows_written = written
+        if observe:
+            self.full = obs
+            self.final = _plan(self.header, obs)
+
+    def finish(self, conn: sqlite3.Connection) -> None:
+        """整列的结论和样本不一样(样本外的小数、前导零、占位符、更宽的行):按整列的结论重写。"""
+        if self.final is not None and self.plan is not None and self.final.key() != self.plan.key():
+            self.write(conn, self.final, observe=False)
+
+
+def _close(it: Any) -> None:
+    close = getattr(it, "close", None)
+    if close is not None:
+        close()
+
+
+# --------------------------------------------------------------------------
+# 读文件 → 行来源
 # --------------------------------------------------------------------------
 
 #: CSV 的编码。中文环境里 GBK 系的 CSV 极其常见(Excel 另存为 CSV 的默认行为),
@@ -216,6 +851,12 @@ _ENCODINGS = ("utf-8-sig", "utf-8", "gb18030", "latin-1")
 
 
 def _decode_csv(raw: bytes) -> str:
+    # 带 BOM 的 UTF-16(Excel 的「Unicode 文本」):按 latin-1 读会混进 NUL,列名全乱
+    if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
+        try:
+            return raw.decode("utf-16")
+        except UnicodeDecodeError:
+            pass
     for enc in _ENCODINGS:
         try:
             return raw.decode(enc)
@@ -224,7 +865,7 @@ def _decode_csv(raw: bytes) -> str:
     return raw.decode("latin-1", errors="replace")
 
 
-def _read_csv(raw: bytes, filename: str, *, header_row: int) -> list[tuple[str, list[list[Any]]]]:
+def _csv_sources(raw: bytes, filename: str, header_row: int) -> list[_Source]:
     text = _decode_csv(raw)
     delimiter = "\t" if _ext(filename) == ".tsv" else None
     if delimiter is None:
@@ -232,12 +873,18 @@ def _read_csv(raw: bytes, filename: str, *, header_row: int) -> list[tuple[str,
             delimiter = csv.Sniffer().sniff(text[:8192], delimiters=",;\t|").delimiter
         except csv.Error:
             delimiter = ","      # 嗅不出来就按最常见的来
-    rows = [list(r) for r in csv.reader(io.StringIO(text), delimiter=delimiter)]
+
+    def rows() -> Iterator[Sequence[Any]]:
+        reader = csv.reader(io.StringIO(text), delimiter=delimiter)
+        return itertools.islice(reader, header_row - 1, None)
+
     stem = filename.rsplit("/", 1)[-1].rsplit(".", 1)[0]
-    return [(stem, rows[header_row - 1:])]
+    # 分号分隔是欧洲区域设置的 CSV:那里逗号是小数点,「1,500」是 1.5 而不是一千五
+    return [_Source(sheet=stem, rows=rows, first_row=header_row, first_col=1, thousands=delimiter != ";",
+                    csv=True)]
 
 
-def _read_excel(raw: bytes, *, header_row: int) -> list[tuple[str, list[list[Any]]]]:
+def _open_book(raw: bytes) -> Any:
     try:
         from openpyxl import load_workbook
     except ImportError as e:
@@ -245,105 +892,448 @@ def _read_excel(raw: bytes, *, header_row: int) -> list[tuple[str, list[list[Any
             "读取 Excel 需要安装解析库:pip install 'agentlab-backend[docs]'"
         ) from e
     try:
-        # read_only 走流式,几十万行也不会把整个工作簿读进内存;
-        # data_only 取公式算出来的值而不是公式本身——用户要的是数
-        book = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
+        # read_only 走流式;data_only 取公式算出来的值而不是公式本身——用户要的是数。
+        # keep_links=False:外部链接里是别的工作簿的缓存数据(可以很大),导入用不上,不让它整份建树
+        return load_workbook(io.BytesIO(raw), read_only=True, data_only=True, keep_links=False)
     except Exception as e:  # noqa: BLE001
         # 这句话原样进上传的 400:异常类名留在日志(from e),界面上只说能照着做的
-        reason = "文件不是有效的 .xlsx(可能已损坏,或只是修改了扩展名)" if isinstance(e, zipfile.BadZipFile) \
-            else describe_exception(e)
-        raise UnsupportedTable(f"无法打开该 Excel 文件:{reason}。请在 Excel 中另存为 .xlsx 或 .csv 后重新上传") from e
+        raise UnsupportedTable(
+            f"无法打开该 Excel 文件:{describe_exception(e)}。请在 Excel 中另存为 .xlsx 或 .csv 后重新上传"
+        ) from e
 
-    out: list[tuple[str, list[list[Any]]]] = []
-    for sheet in book.worksheets:
-        rows = [list(r) for r in sheet.iter_rows(min_row=header_row, values_only=True)]
-        # 整张空的 sheet 就跳过,不建空表。Excel 里常挂着几张没删的空白页
-        if any(any(_norm(c) is not None for c in row) for row in rows):
-            out.append((sheet.title, rows))
-    book.close()
-    if not out:
-        raise UnsupportedTable("该 Excel 文件中没有包含内容的工作表")
+
+def _worksheet_for(book: Any, sheet: xlsx_scan.SheetScan) -> Any:
+    """扫描结果里的一张工作表 → openpyxl 里的同一张。按部件路径配对,不按名字。
+
+    按名字取(book[名字])时,openpyxl 返回第一个同名的表;扫描说「可见」的那张和导入实际读的
+    那张就可能不是同一张。路径、名字、可见状态三样都要对得上,对不上就拒收,不猜。
+    _worksheet_path 是 openpyxl 的私有属性,tests/test_tabular.py 有用例钉住它。
+    """
+    found = [ws for ws in book.worksheets if getattr(ws, "_worksheet_path", None) == sheet.path]
+    if len(found) != 1 or found[0].title != sheet.name or getattr(found[0], "sheet_state", None) != "visible":
+        raise UnsupportedTable(
+            f"无法确认工作表「{sheet.name}」对应的内容,已拒绝导入。请在 Excel 中另存为 .xlsx 后重新上传")
+    return found[0]
+
+
+def _excel_sources(scan: WorkbookScan, book: Any, header_row: int, report: LoadReport) -> list[_Source]:
+    sources: list[_Source] = []
+    #: 因为表头行号超出了内容而跳过的可见工作表:(表名, 最后一行)。全被这样跳过时报的是行号填错了
+    beyond: list[tuple[str, int]] = []
+    for sheet in scan.sheets:
+        # 隐藏、深度隐藏的工作表不导入:实测有人把口令放在 veryHidden 表里
+        if sheet.state != "visible":
+            report.skipped_sheets.append({"sheet": sheet.name, "state": sheet.state, "reason": "hidden"})
+            continue
+        bounds = sheet.bounds
+        if bounds is None:
+            report.skipped_sheets.append({"sheet": sheet.name, "state": sheet.state, "reason": "empty"})
+            continue
+        if header_row > bounds.max_row:
+            report.skipped_sheets.append({"sheet": sheet.name, "state": sheet.state, "reason": "empty"})
+            report.warnings.append(f"工作表「{sheet.name}」在第 {header_row} 行(表头行)及以下没有内容,已跳过")
+            beyond.append((sheet.name, bounds.max_row))
+            continue
+        if bounds.area > SPARSE_FACTOR * max(sheet.nonempty, SPARSE_FLOOR):
+            far = "、".join(list(dict.fromkeys(sheet.far_cells))[:3])
+            raise UnsupportedTable(
+                f"工作表「{sheet.name}」的已用区域 {bounds.a1()} 共 {bounds.area:,} 个单元格,"
+                f"但只有 {sheet.nonempty:,} 个单元格有内容;远处的单元格(如 {far})可能是误输入或残留格式。"
+                "请在 Excel 中删除这些单元格后重新上传"
+            )
+        uncached = sheet.formulas_uncached
+        if uncached.max_row >= header_row:
+            cells = [c for c in uncached.cells if xlsx_scan.parse_ref(c)[0] >= header_row][:3]
+            example = f"(如 {'、'.join(cells)})" if cells else ""
+            raise UnsupportedTable(
+                f"工作表「{sheet.name}」中有公式没有保存计算结果{example},文件未经计算保存。"
+                "请在 Excel 中打开并保存后再上传"
+            )
+        ws = _worksheet_for(book, sheet)
+        ws.reset_dimensions()          # 不信 :边界用扫描算出来的
+
+        def rows(ws: Any = ws, top: int = header_row, b: xlsx_scan.Bounds = bounds) -> Iterator[Sequence[Any]]:
+            return ws.iter_rows(min_row=top, max_row=b.max_row, min_col=b.min_col, max_col=b.max_col,
+                                values_only=True)
+
+        sources.append(_Source(sheet=sheet.name, rows=rows, first_row=header_row, first_col=bounds.min_col,
+                               scan=sheet, full_calc_on_load=scan.full_calc_on_load,
+                               tables=[t for t in scan.tables if t.sheet == sheet.name]))
+    if not sources:
+        if beyond:
+            # 表里有内容,只是都在表头行之上:说「没有内容」会让人以为文件坏了,其实是行号填大了
+            shown = "、".join(f"「{name}」到第 {last} 行" for name, last in beyond[:3])
+            raise UnsupportedTable(
+                f"表头行号第 {header_row} 行超出了工作表的内容范围({shown}{' 等' if len(beyond) > 3 else ''}),"
+                "请检查表头行号"
+            )
+        hidden = sum(1 for s in report.skipped_sheets if s["reason"] == "hidden")
+        tail = f"(已跳过 {hidden} 个隐藏的工作表)" if hidden else ""
+        raise UnsupportedTable(f"该 Excel 文件中没有包含内容的可见工作表{tail}")
+    return sources
+
+
+# --------------------------------------------------------------------------
+# 回执
+# --------------------------------------------------------------------------
+
+
+def _spans_in(spans: list[tuple[int, int]], lo: int, hi: int) -> list[int]:
+    """闭区间列表里落在 [lo, hi] 的编号(最多给前几个示例用,个数另算)。"""
+    out: list[int] = []
+    for a, b in spans:
+        a, b = max(a, lo), min(b, hi)
+        if a <= b:
+            out.extend(range(a, min(b, a + 5) + 1))
     return out
 
 
-def read_tables(
-    raw: bytes, filename: str, *, header_row: int = 1
-) -> list[tuple[str, list[list[Any]]]]:
-    """文件字节 → [(sheet 名, 行)]。第一行是表头。"""
-    if header_row < 1:
-        raise UnsupportedTable("表头行号从 1 开始")
-    ext = _ext(filename)
-    if ext in _EXCEL:
-        return _read_excel(raw, header_row=header_row)
-    if ext in _CSV:
-        return _read_csv(raw, filename, header_row=header_row)
-    if ext in _LEGACY_EXCEL:
-        raise UnsupportedTable(
-            f"无法读取 {filename}:.xls 是旧版二进制格式,需要外部转换器。"
-            "请在 Excel 中另存为 .xlsx 或 .csv 后重新上传。"
+def _count_in(spans: list[tuple[int, int]], lo: int, hi: int) -> int:
+    return sum(max(0, min(b, hi) - max(a, lo) + 1) for a, b in spans)
+
+
+def _merge_hits(merged: list[str], top: int, bottom: int, left: int, right: int) -> list[str]:
+    out = []
+    for ref in merged:
+        a, _, b = ref.partition(":")
+        r1, c1 = xlsx_scan.parse_ref(a.replace("$", ""))
+        r2, c2 = xlsx_scan.parse_ref((b or a).replace("$", ""))
+        if r2 >= top and r1 <= bottom and c2 >= left and c1 <= right:
+            out.append(ref)
+    return out
+
+
+def _sheet_warnings(job: _Job, report: LoadReport) -> None:
+    src, plan, obs = job.src, job.final, job.full
+    if plan is None or obs is None:
+        return
+    top = src.first_row
+    bottom = max(obs.last_data_row, top)
+    left, right = src.first_col + plan.lo, src.first_col + plan.hi - 1
+    if all(_display(v) == "" for v in job.header):
+        report.warnings.append(
+            f"{src.where}的表头行(第 {top} 行)没有内容,列名按位置生成;"
+            "如果表头不在这一行,请修改表头行号后重新上传"
         )
-    raise UnsupportedTable(
-        f"无法读取 {filename}:仅支持 Excel(.xlsx)和 CSV / TSV。"
+    sc = src.scan
+    if sc is None:
+        return
+    hits = _merge_hits(sc.merged, top, bottom, left, right)
+    if hits:
+        report.warnings.append(
+            f"工作表「{src.sheet}」的导入区域内有 {len(hits)} 处合并单元格(如 {'、'.join(hits[:3])});"
+            "合并区域只有左上角的单元格有值,其余单元格按空值导入"
+        )
+    n_rows = _count_in(sc.hidden_rows, top + 1, bottom)
+    if n_rows:
+        first = _spans_in(sc.hidden_rows, top + 1, bottom)[0]
+        report.warnings.append(
+            f"工作表「{src.sheet}」的导入区域内有 {n_rows} 行在 Excel 中处于隐藏或筛选状态"
+            f"(如第 {first} 行),已一并导入"
+        )
+    n_cols = _count_in(sc.hidden_cols, left, right)
+    if n_cols:
+        first = _spans_in(sc.hidden_cols, left, right)[0]
+        report.warnings.append(
+            f"工作表「{src.sheet}」的导入区域内有 {n_cols} 列在 Excel 中被隐藏(如 {col_letter(first)} 列),已一并导入"
+        )
+    if src.full_calc_on_load and sc.formulas:
+        report.warnings.append(
+            f"工作簿设置了打开时重新计算,工作表「{src.sheet}」中公式单元格保存的值可能只是占位(例如 0)。"
+            "请在 Excel 中打开并保存后重新上传,以确认这些数值"
+        )
+
+
+def _column_report(job: _Job, report: LoadReport) -> list[dict[str, str]]:
+    plan, obs = job.final, job.full
+    assert plan is not None and obs is not None
+    names = _column_names(job.header, plan)
+    columns = []
+    for k, ((name, header), sql_type) in enumerate(zip(names, plan.types)):
+        st = obs.stats[plan.lo + k] if plan.lo + k < len(obs.stats) else _ColStats()
+        columns.append({"name": name, "type": sql_type, "header": header})
+        entry = {"table": job.table, "column": name}
+        if sql_type != "TEXT" and st.thousands:
+            report.conversions.append({**entry, "kind": "thousands_separator", "count": st.thousands,
+                                       "examples": st.thousands_ex})
+        if plan.mixed[k]:
+            report.conversions.append({**entry, "kind": "nonnumeric_to_null",
+                                       "count": st.nonempty - st.numeric, "examples": list(st.other)})
+        if sql_type == "TEXT" and st.keep:
+            report.conversions.append({**entry, "kind": "kept_as_text", "count": st.keep, "examples": st.keep_ex})
+        if sql_type == "TEXT" and st.errors:
+            report.warnings.append(
+                f"表「{job.table}」的列「{name}」中有 {st.errors} 个错误值(如 {'、'.join(st.errors_ex)}),已按文本保存"
+            )
+        if sql_type == "TEXT" and st.commas:
+            report.warnings.append(
+                f"表「{job.table}」的列「{name}」有 {st.commas} 个带逗号的数字写法"
+                f"(如 {'、'.join(f'「{v}」' for v in st.commas_ex)}),无法确定逗号是小数点还是千分位,已按文本保存;"
+                "如需按数字导入,请在 Excel 中统一数字格式后重新上传"
+            )
+        if not _units_in_text(job.table, name, sql_type, st, report):
+            _numbers_in_text(job.table, name, sql_type, st, report)
+    return columns
+
+
+def _units_in_text(table: str, name: str, sql_type: str, st: _ColStats, report: LoadReport) -> bool:
+    """TEXT 列以「带百分号、单位或货币符号的数字」为主:回执里写明,返回写没写。
+
+    「15%」「1,234元」「¥1234」按文本存(口径要人来定),但列看起来就是一列数:SUM 只取开头的数字
+    (「1,234元」算成 1、「¥1234」算成 0),MAX 按字典序(「9%」大于「29%」)。以前回执里一个字都没有。
+    只在这类值加上纯数字过半时写:备注列里偶尔出现一个「50%」不提。写了这条,就不再写泛泛的
+    「TEXT 列里有数字」(_numbers_in_text),说的是同一件事。
+    """
+    if sql_type != "TEXT" or not st.units or (st.units + st.numeric) * 2 < st.nonempty:
+        return False
+    examples = "、".join(f"「{_clip(v, 12)}」" for v in st.units_ex)
+    plain = f"(另有 {st.numeric} 个不带单位的数字)" if st.numeric else ""
+    report.warnings.append(
+        f"表「{table}」的列「{name}」有 {st.units} 个带百分号、单位或货币符号的数字(如 {examples}){plain},"
+        "已整列按文本保存:对这一列求和、取最大值不会按数值计算,结果是错的。"
+        "如需按数字导入,请在 Excel 中去掉单位或符号、把这一列设为数字格式后重新上传"
+    )
+    return True
+
+
+def _numbers_in_text(table: str, name: str, sql_type: str, st: _ColStats, report: LoadReport) -> None:
+    """TEXT 列里有数字:回执里写明,不静默。
+
+    够不上「数字列混入非数字」(数字不到八成)的列照文本存,但数字一旦进了 TEXT 列,MAX 就按字典序取、
+    SUM 只认能转的前缀。所以只要非数字的取值属于占位符一类(不超过 MIXED_MAX_DISTINCT 种),或者数字
+    过半,就写一条警告;日期列里混进来的数字(多半是没设日期格式的序列号)另写一条。非数字的写法很多、
+    数字只是零星几个的,是真正的文本列(备注、名称),不提。前导零、超长数字另有 kept_as_text 的记录。
+    """
+    if sql_type != "TEXT" or st.keep or not st.numeric:
+        return
+    other = st.nonempty - st.numeric
+    if st.overflow and st.dates and st.numeric * 2 < st.nonempty:
+        report.warnings.append(
+            f"表「{table}」的列「{name}」有 {st.dates} 个日期、{st.numeric} 个数字,数字没有当作日期换算,"
+            "已整列按文本保存;如果这些数字是日期,请在 Excel 中把这一列统一设为日期格式后重新上传"
+        )
+        return
+    if st.overflow and st.numeric * 2 < st.nonempty:
+        return
+    if st.overflow:
+        examples = "、".join(f"「{_clip(v, 12)}」" for v in list(st.other)[:3])
+        why = f"非数字的写法超过 {MIXED_MAX_DISTINCT} 种"
+    else:
+        examples = "、".join(f"「{_clip(v, 12)}」{c} 个" for v, c in st.other.items())
+        why = f"非数字的值超过 {round((1 - MIXED_MIN_SHARE) * 100)}%"
+    report.warnings.append(
+        f"表「{table}」的列「{name}」有 {st.numeric} 个数字、{other} 个非数字的值"
+        f"(如 {examples}),{why},已整列按文本保存,不能直接对这一列求和"
     )
 
 
 # --------------------------------------------------------------------------
-# 建库
+# 入口
 # --------------------------------------------------------------------------
 
 
-def load_into(
-    db_path: str, raw: bytes, filename: str, *, header_row: int = 1
-) -> LoadReport:
-    """把文件里的每张表写进 db_path 指的 SQLite 库。
+def _mixed_columns(jobs: list[_Job], *, sample: bool = False) -> list[dict[str, Any]]:
+    """「数字列混入非数字」的列:[{sheet, table, column, header, numeric, nonnumeric, values}]。
 
-    同名表**整张替换**(DROP + CREATE)。重传的语义是"这份数据更新了",
-    追加会让行数悄悄翻倍,而没有任何地方会提示。
+    sample=True 按第一遍(样本)的观察算,给结构拒收时预告用;否则按整列的统计(写入之后)。
+    样本阶段还没定表名,table 先按工作表名清洗(和写入时的规则一样,只是没处理撞名)。
+    """
+    columns = []
+    for job in jobs:
+        plan, obs = (job.plan, job.sample_obs) if sample else (job.final, job.full)
+        if plan is None or obs is None:
+            continue
+        names = _column_names(job.header, plan)
+        table = job.table or to_sql_name(job.src.sheet, fallback="sheet")
+        for k, is_mixed in enumerate(plan.mixed):
+            if not is_mixed:
+                continue
+            st = obs.stats[plan.lo + k]
+            columns.append({
+                "sheet": job.src.sheet, "table": table, "column": names[k][0], "header": names[k][1],
+                "numeric": st.numeric, "nonnumeric": st.nonempty - st.numeric,
+                "values": [{"value": v, "count": c} for v, c in st.other.items()],
+            })
+    return columns
+
+
+def _shown_values(columns: list[dict[str, Any]]) -> str:
+    values = list(dict.fromkeys(v["value"] for c in columns for v in c["values"]))
+    return "、".join(f"「{_clip(v, 12)}」" for v in values[:4])
+
+
+def _mixed_decision(jobs: list[_Job]) -> NeedsDecision | None:
+    columns = _mixed_columns(jobs)
+    if not columns:
+        return None
+    return NeedsDecision(
+        f"有 {len(columns)} 列以数字为主,但混有非数字的值(如 {_shown_values(columns)})。"
+        "请选择把这些值存为空值、整列按数字导入,或者不导入",
+        kind="mixed", details={"columns": columns},
+    )
+
+
+def _shape_decision(jobs: list[_Job], late: bool) -> NeedsDecision | None:
+    """交叉表、多块结构的拒收。顺带预告混合列(details.mixed):选了「按原样导入」之后还会因为它们再退回一次,
+    第一次就把「·」这类占位符列出来,用户一次看全要拍板的事(以前要等第二轮才看得到)。
+
+    预告按样本算(late=False,写入之前)或按整列算(late=True);样本没读完整张表时 mixed_complete 为假,
+    之后的混合列可能比预告的多。预告只是告知,不是选择:存不存空值仍在第二轮由用户明确选。
+    """
+    reasons = []
+    for job in jobs:
+        for r in (job.reasons if late else job.early_reasons):
+            reasons.append({"sheet": job.src.sheet, **r})
+    if not reasons:
+        return None
+    sheets = list(dict.fromkeys(r["sheet"] for r in reasons))
+    kinds = "、".join(dict.fromkeys(SHAPE_KINDS[r["kind"]] for r in reasons))
+    where = "".join(f"「{s}」" for s in sheets[:3]) + (" 等" if len(sheets) > 3 else "")
+    # CSV 没有工作表:一个文件就是一张表
+    noun = "文件" if all(job.src.csv for job in jobs) else "工作表"
+    message = (f"{noun}{where}不是一行一条记录的规整表格({kinds}),默认不导入。"
+               "如需导入,可以选择「按原样导入(未规整)」,导入后不能直接对列求和")
+    details: dict[str, Any] = {"reasons": reasons}
+    if mixed := _mixed_columns(jobs, sample=not late):
+        message += (f"。另有 {len(mixed)} 列以数字为主、混有非数字的值(如 {_shown_values(mixed)}),"
+                    "选择按原样导入后,还需要选择这些值的处理方式")
+        details["mixed"] = mixed
+        details["mixed_complete"] = late or all(job.sampled_all for job in jobs)
+    return NeedsDecision(message, kind="shape", details=details)
+
+
+def load_into(
+    db_path: str,
+    raw: bytes,
+    filename: str,
+    *,
+    header_row: int = 1,
+    mixed: str = "reject",
+    raw_mode: bool = False,
+    scan: WorkbookScan | None = None,
+) -> LoadReport:
+    """把文件里的每张可见工作表写进 db_path 指的 SQLite 库,返回导入回执。
+
+    - mixed:数字列混入非数字时,"reject" 抛 NeedsDecision(kind="mixed"),"null" 把非数字存空值、
+      列按数字存(记进回执的 conversions)。
+    - raw_mode:「按原样导入(未规整)」。为假时检测到交叉表或多块结构抛 NeedsDecision(kind="shape");
+      为真时照常导入,检测到这类结构的表标记 unshaped。
+    - scan:调用方已经扫描过就传进来,免得再扫一遍;不传时这里扫(只对 Excel)。
+
+    所有表在一个事务里写完:任何一步失败,库里什么都不变。同名表整张替换(DROP + CREATE);
+    版本化的调用方每次给一个新文件,这条规则只对旧的就地导入有意义。
 
     用 sqlite3 直连而不是 data.engine:那条路上有 SQL 守卫,而守卫拒绝
     CREATE——那是对的,模型不该能建表。导入是我们自己发起的可信操作。
     """
-    sheets = read_tables(raw, filename, header_row=header_row)
+    if mixed not in ("reject", "null"):
+        # 调用方(接口层)该先校验;漏过来的也不能把参数名、取值原样露给界面
+        raise UnsupportedTable("导入选项无效:数字列混入非数字时,只能选择存为空值或不导入")
+    if header_row < 1:
+        raise UnsupportedTable("表头行号从 1 开始")
+    ext = _ext(filename)
     report = LoadReport()
-    taken: set[str] = set()
+    book = None
+    if ext in _EXCEL:
+        scan = scan if scan is not None else xlsx_scan.scan(raw)
+        book = _open_book(raw)
+    elif ext in _LEGACY_EXCEL:
+        raise UnsupportedTable(
+            f"无法读取 {filename}:.xls 是旧版二进制格式,需要外部转换器。"
+            "请在 Excel 中另存为 .xlsx 或 .csv 后重新上传。"
+        )
+    elif ext not in _CSV:
+        raise UnsupportedTable(f"无法读取 {filename}:仅支持 Excel(.xlsx)和 CSV / TSV。")
 
-    conn = sqlite3.connect(db_path)
+    conn: sqlite3.Connection | None = None
     try:
-        for sheet_name, rows in sheets:
-            if not rows:
-                continue
-            headers = _clean_headers(rows[0])
-            body = rows[1:]
-            if not headers:
-                continue
+        if book is not None:
+            assert scan is not None
+            sources = _excel_sources(scan, book, header_row, report)
+        else:
+            sources = _csv_sources(raw, filename, header_row)
 
-            # 行长参差是常态:Excel 的末尾空列、CSV 的缺尾逗号。按表头补齐/截断
-            width = len(headers)
-            body = [(row + [None] * width)[:width] for row in body]
+        jobs = [_Job(src) for src in sources]
+        for job in jobs:
+            job.sample()
+        if header_row > 1 and all(not j.header and not j.rows_seen for j in jobs):
+            # 表头行那一行根本不存在(CSV 的行数不到表头行号):是行号填大了,不是文件里没数据
+            raise UnsupportedTable(f"表头行号第 {header_row} 行超出了文件的内容范围,请检查表头行号")
+        jobs = [j for j in jobs if j.plan is not None]
+        if not jobs:
+            raise UnsupportedTable("文件中没有数据行")
+        # 结构问题在写入之前就能下结论:样本里看到的已经足够拒收
+        if not raw_mode and (decision := _shape_decision(jobs, late=False)):
+            raise decision
 
-            sample = body[:_TYPE_SAMPLE]
-            types = [infer_type([row[i] for row in sample]) for i in range(width)]
-
-            table = _table_name(sheet_name, taken)
-            cols = ", ".join(f'"{h}" {t}' for h, t in zip(headers, types))
-            conn.execute(f'DROP TABLE IF EXISTS "{table}"')
-            conn.execute(f'CREATE TABLE "{table}" ({cols})')
-            if body:
-                placeholders = ", ".join("?" * width)
-                conn.executemany(
-                    f'INSERT INTO "{table}" VALUES ({placeholders})',
-                    ([_coerce(row[i], types[i]) for i in range(width)] for row in body),
-                )
-            report.tables.append(
-                LoadedTable(
-                    name=table, columns=headers, types=types,
-                    rows=len(body), source_name=sheet_name,
-                )
-            )
-        conn.commit()
+        conn = sqlite3.connect(db_path, isolation_level=None)
+        conn.execute("BEGIN")
+        taken: set[str] = set()
+        for job in jobs:
+            job.table = _unique(to_sql_name(job.src.sheet, fallback="sheet"), taken)
+            assert job.plan is not None
+            job.write(conn, job.plan, observe=True)
+            if job.final is None:
+                job.final = job.plan
+            assert job.full is not None
+            job.reasons = _shape_reasons(job.src, job.header, job.final, job.full)
+        if not raw_mode and (decision := _shape_decision(jobs, late=True)):
+            raise decision
+        if mixed == "reject" and (decision := _mixed_decision(jobs)):
+            raise decision
+        for job in jobs:
+            job.finish(conn)
+            _report_table(job, report, raw_mode)
+        conn.execute("COMMIT")
+    except csv.Error as e:
+        if conn is not None and conn.in_transaction:
+            conn.execute("ROLLBACK")
+        raise UnsupportedTable(
+            "CSV 文件格式有误(可能是某个单元格过长,或引号没有成对)。请检查后重新上传"
+        ) from e
+    except BaseException:
+        if conn is not None and conn.in_transaction:
+            conn.execute("ROLLBACK")
+        raise
     finally:
-        conn.close()
-
-    if not report.tables:
-        raise UnsupportedTable("文件中没有数据行")
+        if conn is not None:
+            conn.close()
+        if book is not None:
+            book.close()
     return report
+
+
+def _report_table(job: _Job, report: LoadReport, raw_mode: bool) -> None:
+    plan, obs, src = job.final, job.full, job.src
+    assert plan is not None and obs is not None
+    columns = _column_report(job, report)
+    left = src.first_col + plan.lo
+    right = src.first_col + plan.hi - 1
+    bottom = max(obs.last_data_row, src.first_row)
+    trimmed_left = range(src.first_col, left)
+    trimmed_right = range(right + 1, src.first_col + max(len(job.header), obs.width()))
+    unshaped = raw_mode and bool(job.reasons)
+    report.tables.append(LoadedTable(
+        name=job.table,
+        sheet=src.sheet,
+        columns=columns,
+        rows=job.rows_written,
+        region=f"{cell_ref(src.first_row, left)}:{cell_ref(bottom, right)}",
+        blank_rows_skipped=obs.blank_rows,
+        columns_trimmed=[col_letter(c) for c in itertools.chain(trimmed_left, trimmed_right)],
+        unshaped=unshaped,
+    ))
+    if unshaped:
+        kinds = "、".join(dict.fromkeys(SHAPE_KINDS[r["kind"]] for r in job.reasons))
+        report.warnings.append(
+            f"表「{job.table}」按原样导入、未经规整({kinds}):同一列里混有不同口径的行,不能直接对列求和"
+        )
+    _, exempt = _section_rows(plan, obs)
+    if exempt:
+        capped = obs.single_text_total > len(obs.single_text)
+        shown = "、".join(f"{cell_ref(r, src.first_col + i)}「{_clip(t)}」" for r, i, t in exempt[:3])
+        report.warnings.append(
+            f"表「{job.table}」有{'至少 ' if capped else ' '}{len(exempt)} 行只有一个单元格有文字、其余列为空"
+            f"(如 {shown}),已按数据行导入,会计入行数;如果这些是分段标题或备注,请在 Excel 中删除后重新上传"
+        )
+    _sheet_warnings(job, report)
diff --git a/backend/app/data/xlsx_scan.py b/backend/app/data/xlsx_scan.py
new file mode 100644
index 00000000..4b3fd43f
--- /dev/null
+++ b/backend/app/data/xlsx_scan.py
@@ -0,0 +1,995 @@
+"""一遍流式扫描 .xlsx 的版式元数据:openpyxl read_only 拿不到的,这里补上。
+
+**为什么要自己扫。** 导入走 openpyxl 的 read_only(流式、整数不失真、内存低),但 read_only
+没有合并区、隐藏行列、自动筛选、表格对象,`` 也可能是陈旧的或者干脆没有;
+普通模式能拿到这些,代价是整本工作簿进内存(20 万行约 950 MB)。所以元数据另走一遍:
+标准库 zipfile 加 ElementTree.iterparse,按行流式读,读完一行丢一行。
+
+**为什么不信 ``。** 它是写文件的程序自己填的:openpyxl 的 write_only 根本不写,
+有的程序删了数据也不更新。真实边界只能从非空单元格算——这一遍顺手就算出来了,
+导入的第二遍按这个边界读(tabular.load_into)。
+
+**安全。** 上传的文件不可信:
+- 解压后的总大小、单个部件的大小、部件个数都设上限(防 zip 炸弹)。只限字节不够:每个字节在
+  内存里能放大几十倍,所以工作表按 Excel 自己的上限数行、列、挂在树上的元素和单元格文字长度,
+  共享字符串表数条目,openpyxl 整份建树的小部件(样式、文档属性、图表工作表……)数元素和格式条目。
+- 压缩包里**每一个**条目(不看扩展名:工作表改名成 .bin,关系文件照样指着它)都先用 expat 解析到
+  根元素为止,序言里有 DTD 就拒收。OOXML 从不需要 DTD,有 DTD 只可能是实体膨胀之类的攻击。拦在
+  解析器上而不是比字节,是因为字节比较认不出 UTF-16 写的「 int:
+        return (self.max_row - self.min_row + 1) * (self.max_col - self.min_col + 1)
+
+    def a1(self) -> str:
+        return f"{cell_ref(self.min_row, self.min_col)}:{cell_ref(self.max_row, self.max_col)}"
+
+
+@dataclass
+class CellList:
+    """一类单元格的坐标:前 MAX_CELLS 个的坐标,加总数和最大行号。
+
+    最大行号让调用方不必拿到全部坐标也能精确判断「某行以下有没有」:
+    导入区域从表头行一直到边界底部,有没有落在区域里只看 max_row。
+    """
+
+    cells: list[str] = field(default_factory=list)
+    total: int = 0
+    max_row: int = 0
+    #: 和 cells 一一对应的原文(目前只有公式类用:公式文本,截断到 80 字)
+    texts: list[str] = field(default_factory=list)
+
+    def add(self, row: int, col: int, text: str | None = None) -> None:
+        self.total += 1
+        if row > self.max_row:
+            self.max_row = row
+        if len(self.cells) < MAX_CELLS:
+            self.cells.append(cell_ref(row, col))
+            if text is not None:
+                self.texts.append(text[:80])
+
+
+@dataclass
+class SheetScan:
+    name: str
+    #: visible / hidden / veryHidden
+    state: str
+    #: 工作表 XML 在压缩包里的路径
+    path: str
+    #: 真实非空格的边界;整张表没有非空格时是 None
+    bounds: Bounds | None = None
+    #: 非空格数。有公式的格不算空(即使没有缓存值);只有空白字符的文本算空
+    nonempty: int = 0
+    merged: list[str] = field(default_factory=list)
+    merged_total: int = 0
+    #: 隐藏行、隐藏列,合并成闭区间 (起, 止)。筛选隐藏和手动隐藏在 XML 里分不出来
+    hidden_rows: list[tuple[int, int]] = field(default_factory=list)
+    hidden_cols: list[tuple[int, int]] = field(default_factory=list)
+    autofilter: str | None = None
+    formulas: int = 0
+    #: 有公式、却没有缓存值的格(程序生成、没用 Excel 保存过的文件)。data_only 读出来是空
+    formulas_uncached: CellList = field(default_factory=CellList)
+    #: 错误值格(#DIV/0! 之类)
+    errors: CellList = field(default_factory=CellList)
+    #: 汇总了本格上方连续几行的公式(如 C26 的 SUM(C22:C25)):表内合计行的信号
+    formulas_above: CellList = field(default_factory=CellList)
+    #: 离群格示例:稀疏判定拒收时告诉用户是哪几个远处的格子把区域撑大了
+    far_cells: list[str] = field(default_factory=list)
+    #: 文件自己声明的 ,只作记录,不用来定边界
+    dimension: str | None = None
+
+
+@dataclass
+class TableScan:
+    """Excel 的表格对象(插入 → 表格)。ref 连表头带汇总行一起算。"""
+
+    name: str
+    ref: str
+    sheet: str
+    totals_row_count: int = 0
+    header_row_count: int = 1
+    #: 各列的列名,从左到右
+    columns: list[str] = field(default_factory=list)
+
+    def span(self) -> tuple[int, int, int, int] | None:
+        """ref 的 (首行, 首列, 末行, 末列);ref 写坏了是 None。"""
+        a, _, b = self.ref.replace("$", "").partition(":")
+        try:
+            r1, c1 = parse_ref(a)
+            r2, c2 = parse_ref(b or a)
+        except ValueError:
+            return None
+        return min(r1, r2), min(c1, c2), max(r1, r2), max(c1, c2)
+
+    def totals_rows(self) -> tuple[int, int] | None:
+        """汇总行的行号区间(闭区间);没有汇总行是 None。"""
+        span = self.span()
+        if span is None or self.totals_row_count <= 0:
+            return None
+        return max(span[0], span[2] - self.totals_row_count + 1), span[2]
+
+
+@dataclass
+class WorkbookScan:
+    sheets: list[SheetScan] = field(default_factory=list)
+    #: calcPr 的 fullCalcOnLoad:打开时全部重算。XlsxWriter 默认这么写,同时把公式缓存值写成 0
+    full_calc_on_load: bool = False
+    tables: list[TableScan] = field(default_factory=list)
+    #: 定义名称 → 原文。工作表级的名称记作「工作表名!名称」,免得同名互相覆盖
+    defined_names: dict[str, str] = field(default_factory=dict)
+
+    def sheet(self, name: str) -> SheetScan | None:
+        return next((s for s in self.sheets if s.name == name), None)
+
+
+# --------------------------------------------------------------------------
+# 坐标
+# --------------------------------------------------------------------------
+
+_COL_CACHE: dict[str, int] = {}
+
+
+def col_letter(col: int) -> str:
+    out = ""
+    while col > 0:
+        col, rem = divmod(col - 1, 26)
+        out = chr(65 + rem) + out
+    return out
+
+
+def cell_ref(row: int, col: int) -> str:
+    return f"{col_letter(col)}{row}"
+
+
+def _col_number(letters: str) -> int:
+    hit = _COL_CACHE.get(letters)
+    if hit is None:
+        hit = 0
+        for ch in letters.upper():
+            hit = hit * 26 + ord(ch) - 64
+        if len(_COL_CACHE) < 20_000:
+            _COL_CACHE[letters] = hit
+    return hit
+
+
+def parse_ref(ref: str) -> tuple[int, int]:
+    """「AB12」→ (12, 28)。不认 $,调用方给的是单元格的 r 属性,不带 $。"""
+    letters = ref.rstrip(_DIGITS)
+    return int(ref[len(letters):]), _col_number(letters)
+
+
+# --------------------------------------------------------------------------
+# 读压缩包
+# --------------------------------------------------------------------------
+
+
+class _Budget:
+    """整个压缩包解压出来的总字节数。声明的大小先查一遍,读的时候再实数一遍。"""
+
+    def __init__(self) -> None:
+        self.used = 0
+
+    def take(self, n: int) -> None:
+        self.used += n
+        if self.used > MAX_UNCOMPRESSED:
+            raise UnsupportedTable(_too_big())
+
+
+def _too_big() -> str:
+    return (f"文件解压后超过 {MAX_UNCOMPRESSED // 1024 ** 2} MB,无法导入。"
+            "请删除不需要的工作表,或另存为 .csv 后重新上传")
+
+
+class _Guarded:
+    """包住压缩包里的一个 XML 部件:数着字节读,超过大小上限就停。
+
+    iterparse 只要求 source 有 read(n)。DTD 不在这里查:见 _check_prolog。
+    """
+
+    def __init__(self, fp: IO[bytes], part: str, budget: _Budget, limit: int | None = None) -> None:
+        self._fp = fp
+        self._part = part
+        self._budget = budget
+        self._seen = 0
+        #: 这个部件最多读多少字节。None 表示按 MAX_XML_BYTES(现取,测试里可以改)
+        self._limit = limit
+
+    def read(self, n: int = -1) -> bytes:
+        data = self._fp.read(n if n and n > 0 else _CHUNK)
+        if not data:
+            return data
+        self._seen += len(data)
+        self._budget.take(len(data))
+        limit = self._limit if self._limit is not None else MAX_XML_BYTES
+        if self._seen > limit:
+            raise UnsupportedTable(_part_too_big(self._part, limit))
+        return data
+
+    def close(self) -> None:
+        self._fp.close()
+
+
+def _part_too_big(part: str, limit: int) -> str:
+    return (f"文件中的「{part}」解压后超过 {limit // 1024 ** 2} MB,无法导入。"
+            "请拆分工作表,或另存为 .csv 后重新上传")
+
+
+def _abnormal(what: str) -> str:
+    """文件结构超出 Excel 自己的上限、或者前后矛盾:Excel 不会生成这样的文件。"""
+    return f"{what},不是 Excel 正常保存的文件,已拒绝导入。请在 Excel 中另存为 .xlsx 或 .csv 后重新上传"
+
+
+def _local(tag: str) -> str:
+    return tag.rsplit("}", 1)[-1]
+
+
+def _resolve(base_dir: str, target: str) -> str:
+    """关系文件里的 Target → 压缩包内路径。openpyxl 写绝对路径(/xl/...),Excel 写相对路径。"""
+    if target.startswith("/"):
+        return target.lstrip("/")
+    out: list[str] = []
+    for part in f"{base_dir}/{target}".split("/"):
+        if part == "..":
+            if out:
+                out.pop()
+        elif part and part != ".":
+            out.append(part)
+    return "/".join(out)
+
+
+class _Archive:
+    def __init__(self, raw: bytes) -> None:
+        try:
+            self.zip = zipfile.ZipFile(io.BytesIO(raw))
+        except zipfile.BadZipFile as e:
+            raise UnsupportedTable(_not_xlsx()) from e
+        infos = self.zip.infolist()
+        if len(infos) > MAX_ENTRIES:
+            raise UnsupportedTable(_abnormal(f"压缩包中的部件超过 {MAX_ENTRIES:,} 个"))
+        self.names = set(self.zip.namelist())
+        self.budget = _Budget()
+        declared = sum(info.file_size for info in infos)
+        if declared > MAX_UNCOMPRESSED:
+            raise UnsupportedTable(_too_big())
+        #: 已经数过元素个数的小部件(_check_small 只做一次)
+        self.checked: set[str] = set()
+
+    def open(self, path: str, limit: int | None = None) -> _Guarded:
+        """打开一个部件,读出的字节受 limit 限制(None 表示按 MAX_XML_BYTES)。声明的大小先查一遍。"""
+        info = self.zip.getinfo(path)
+        cap = limit if limit is not None else MAX_XML_BYTES
+        if info.file_size > cap:
+            raise UnsupportedTable(_part_too_big(path, cap))
+        return _Guarded(self.zip.open(info), path, self.budget, cap)
+
+    def tree(self, path: str) -> ET.Element:
+        """小部件(workbook.xml、关系文件、表格定义)整个解析。先按小部件的上限数一遍元素,再建树。"""
+        _check_small(self, path)
+        parser = ET.XMLParser()
+        with closing(self.open(path, MAX_PART_BYTES)) as source:
+            while chunk := source.read(_CHUNK):
+                parser.feed(chunk)
+        return parser.close()
+
+    def rels(self, part: str) -> dict[str, tuple[str, str]]:
+        """部件的关系表:Id → (类型, 解析后的路径)。没有关系文件时是空的。"""
+        folder, _, name = part.rpartition("/")
+        rels_path = f"{folder}/_rels/{name}.rels" if folder else f"_rels/{name}.rels"
+        if rels_path not in self.names:
+            return {}
+        out: dict[str, tuple[str, str]] = {}
+        for rel in self.tree(rels_path):
+            target = rel.get("Target") or ""
+            if rel.get("TargetMode") == "External":
+                continue
+            out[rel.get("Id") or ""] = (rel.get("Type") or "", _resolve(folder, target))
+        return out
+
+
+class _RootReached(Exception):
+    """序言读完了(见到了根元素):_check_prolog 用它提前结束解析。"""
+
+
+def _no_dtd(*_args: object) -> None:
+    raise UnsupportedTable(_DTD_MESSAGE)
+
+
+def _root_reached(*_args: object) -> None:
+    raise _RootReached
+
+
+_DTD_MESSAGE = "文件中含有 Excel 文件不应包含的内容(DTD 声明),已拒绝导入"
+_PROLOG_CHUNK = 4096
+
+
+def _check_prolog(archive: _Archive, path: str, *, strict: bool = True) -> bool:
+    """一个部件的序言里有没有 DTD:有就拒收。返回它是不是 XML(读到了根元素)。
+
+    DOCTYPE 只能出现在根元素之前,所以只解析到根元素的开始标签为止,通常只读几百字节。
+    用 expat 解析而不是比字节:编码由 expat 自己认(UTF-8、带或不带 BOM 的 UTF-16……),
+    和 ElementTree、openpyxl 用的是同一套规则,「换个编码就绕过」行不通。
+
+    **压缩包里每个条目都查,不看扩展名。** 工作表、共享字符串表的路径来自关系文件和内容类型,
+    改名成 sheet1.bin 照样会被这里的扫描和 openpyxl 当 XML 解析,只查 .xml/.rels 就漏了。
+    strict(名字是 .xml/.rels)时序言解析不了也拒收:OOXML 规定部件是 UTF-8 或 UTF-16 的合法
+    XML,expat 连序言都读不过去的,别的解析器读出来是什么样说不准。其余名字(图片、
+    printerSettings、vbaProject.bin)解析不了是正常的,跳过:expat 读不过序言的,ElementTree
+    和 openpyxl 也不可能把它当 XML 读出内容。
+
+    唯一的例外:.svg 图片可以带不含内部子集的 DOCTYPE(老版本 Illustrator 导出的 SVG 都带一行
+    ``)。没有内部子集就没有实体定义,expat 也不会去取外部 DTD,
+    改名成 .svg 的工作表因此展开不了任何实体;内部子集、实体声明照样拒收。
+    """
+    svg = path.lower().endswith(".svg")
+
+    def doctype(_name: str, _sysid: str | None, _pubid: str | None, has_internal_subset: int) -> None:
+        if has_internal_subset or not svg:
+            raise UnsupportedTable(_DTD_MESSAGE)
+
+    parser = expat.ParserCreate()
+    parser.StartDoctypeDeclHandler = doctype
+    parser.EntityDeclHandler = _no_dtd
+    parser.StartElementHandler = _root_reached
+    # 读序言不受单个部件的大小上限约束(大图片也只读开头几 KB),读到的字节照样计入总量
+    with closing(archive.open(path, max(archive.zip.getinfo(path).file_size, 1))) as source:
+        try:
+            while chunk := source.read(_PROLOG_CHUNK):
+                parser.Parse(chunk, False)
+        except _RootReached:
+            return True
+        except (expat.ExpatError, LookupError) as e:
+            # LookupError:声明了 Python 不认识的编码,pyexpat 原样抛出
+            if strict:
+                raise UnsupportedTable(_broken()) from e
+            return False
+    # 读到结尾也没见到根元素(空部件之类):没有 DOCTYPE 就不管,读不读得了是真正解析时的事
+    return False
+
+
+def _check_small(archive: _Archive, path: str) -> None:
+    """工作表、共享字符串表以外的 XML 部件:字节数和元素个数都设上限,样式里的格式条目单独设上限。
+
+    这些部件 openpyxl 用 lxml 一次整份建树(样式还要给每个格式条目建对象),内存是字节数的
+    几十倍;只限字节,几 MB 的「几十万个空元素」就能吃掉几个 GB。这里流式数一遍:
+    处理完的元素清空、根下的直接子元素摘掉,数的过程本身不占多少内存。
+    """
+    if path in archive.checked:
+        return
+    total = xf = depth = 0
+    root: ET.Element | None = None
+    with closing(archive.open(path, MAX_PART_BYTES)) as source:
+        for event, el in ET.iterparse(source, events=("start", "end")):
+            if event == "start":
+                depth += 1
+                if root is None:
+                    root = el
+                continue
+            depth -= 1
+            total += 1
+            if total > MAX_PART_ELEMENTS:
+                raise UnsupportedTable(_abnormal(f"文件中的「{path}」包含的元素超过 {MAX_PART_ELEMENTS:,} 个"))
+            if _local(el.tag) == "xf":
+                xf += 1
+                if xf > MAX_XF:
+                    raise UnsupportedTable(_abnormal(f"样式表中的单元格格式超过 {MAX_XF:,} 种"))
+            el.clear()
+            if depth == 1 and root is not None:
+                root.remove(el)
+    archive.checked.add(path)
+
+
+#: 工作簿主部件的几种内容类型,按 openpyxl 找主部件的顺序(reader/excel.py _find_workbook_part)
+_WORKBOOK_TYPES = (
+    "application/vnd.ms-excel.template.macroEnabled.main+xml",
+    "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml",
+    "application/vnd.ms-excel.sheet.macroEnabled.main+xml",
+    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
+)
+_SHARED_STRINGS_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"
+_CONTENT_TYPES = "[Content_Types].xml"
+
+
+def _content_types(archive: _Archive) -> tuple[dict[str, list[str]], set[str]]:
+    """内容类型清单:(内容类型 → 按出现顺序的部件路径, 默认登记过的内容类型)。"""
+    if _CONTENT_TYPES not in archive.names:
+        raise UnsupportedTable(_not_xlsx())
+    overrides: dict[str, list[str]] = {}
+    defaults: set[str] = set()
+    for el in archive.tree(_CONTENT_TYPES):
+        local = _local(el.tag)
+        if local == "Override":
+            overrides.setdefault(el.get("ContentType") or "", []).append((el.get("PartName") or "").lstrip("/"))
+        elif local == "Default":
+            defaults.add(el.get("ContentType") or "")
+    return overrides, defaults
+
+
+def _book_path(archive: _Archive, overrides: dict[str, list[str]], defaults: set[str]) -> str:
+    """工作簿主部件的路径。内容类型清单和包关系文件各指一个:openpyxl 按前者找,这里按后者找,
+    两边不是同一个就拒收——否则扫描看的是一本工作簿,导入读的是另一本。"""
+    claimed = [path for kind in _WORKBOOK_TYPES for path in overrides.get(kind, [])]
+    if len(claimed) > 1:
+        raise UnsupportedTable(_abnormal("文件中登记了不止一个工作簿主部件"))
+    if claimed:
+        by_types = claimed[0]
+    elif defaults & set(_WORKBOOK_TYPES):
+        by_types = "xl/workbook.xml"              # openpyxl 的退路:默认内容类型就是工作簿
+    else:
+        raise UnsupportedTable(_not_xlsx())
+    by_rels = next((path for kind, path in archive.rels("").values() if kind.endswith("/officeDocument")), None)
+    if by_rels is not None and by_rels != by_types:
+        raise UnsupportedTable(_abnormal("文件中两处登记的工作簿主部件不一致"))
+    return by_types
+
+
+def _shared_strings_path(rels: dict[str, tuple[str, str]], overrides: dict[str, list[str]],
+                         archive: _Archive) -> str | None:
+    """共享字符串表的路径。openpyxl 按内容类型清单找,这里按工作簿的关系文件找:两边不一致就拒收,
+    否则扫描判断「格子空不空」用的是一张表,导入读出来的是另一张。"""
+    by_rels = next((path for kind, path in rels.values()
+                    if kind.endswith("/sharedStrings") and path in archive.names), None)
+    claimed = overrides.get(_SHARED_STRINGS_TYPE, [])
+    if len(claimed) > 1 or (claimed and claimed[0] != by_rels):
+        raise UnsupportedTable(_abnormal("文件中两处登记的共享字符串表不一致"))
+    return by_rels
+
+
+def _not_xlsx() -> str:
+    return ("无法打开该 Excel 文件:文件不是有效的 .xlsx(可能已损坏,或只是修改了扩展名)。"
+            "请在 Excel 中另存为 .xlsx 或 .csv 后重新上传")
+
+
+# --------------------------------------------------------------------------
+# 扫描
+# --------------------------------------------------------------------------
+
+
+def _broken() -> str:
+    return "无法打开该 Excel 文件:文件内容不完整或格式不正确。请在 Excel 中另存为 .xlsx 或 .csv 后重新上传"
+
+
+def scan(raw: bytes) -> WorkbookScan:
+    """xlsx 字节 → 版式元数据。读不了就抛 UnsupportedTable(message 给用户看)。"""
+    archive = _Archive(raw)
+    try:
+        return _scan(archive)
+    except UnsupportedTable:
+        raise
+    except (ET.ParseError, KeyError, ValueError, zipfile.BadZipFile, EOFError) as e:
+        # KeyError:压缩包里缺部件;ValueError:坐标、数字写坏了;ParseError:XML 不完整
+        raise UnsupportedTable(_broken()) from e
+    finally:
+        archive.zip.close()
+
+
+def _scan(archive: _Archive) -> WorkbookScan:
+    # 先把每个条目的序言查一遍,再开始真正的解析:openpyxl 之后还要读样式、主题这些
+    # 这里不读的部件,DTD 的拒收要覆盖到它们(不看扩展名,见 _check_prolog)
+    xml_parts = [info for info in archive.zip.infolist() if not info.is_dir() and _check_prolog(
+        archive, info.filename, strict=info.filename.lower().endswith((".xml", ".rels")))]
+    overrides, defaults = _content_types(archive)
+    book_path = _book_path(archive, overrides, defaults)
+    book = archive.tree(book_path)
+    rels = archive.rels(book_path)
+    result = WorkbookScan()
+
+    sst_path = _shared_strings_path(rels, overrides, archive)
+    # 工作表和共享字符串表可以很大,按 MAX_XML_BYTES 和逐行的上限管。这里自己整份解析的小部件
+    # (内容类型清单、工作簿、关系文件、表格定义)经 _Archive.tree 按小部件管;openpyxl 整份解析、
+    # 这里不读的,在这里先查
+    for path in _openpyxl_parts(archive, rels, {info.filename for info in xml_parts}):
+        _check_small(archive, path)
+
+    sst_flags = _scan_shared_strings(archive, sst_path) if sst_path else None
+
+    sheet_names: list[str] = []
+    seen_names: dict[str, str] = {}
+    for el in book.iter():
+        local = _local(el.tag)
+        if local == "calcPr":
+            result.full_calc_on_load = el.get("fullCalcOnLoad") in ("1", "true")
+        elif local == "sheet":
+            rid = next((v for k, v in el.attrib.items() if k.endswith("}id")), None)
+            kind, path = rels.get(rid or "", ("", ""))
+            name = el.get("name") or ""
+            # Excel 的工作表名不区分大小写、不许重复。重名时 openpyxl 按名字取表会拿到第一个
+            # (可能是隐藏的那个),导入的内容就和回执说的对不上了
+            folded = name.casefold()
+            if folded in seen_names:
+                raise UnsupportedTable(_abnormal(
+                    f"工作簿中有两个工作表同名(「{seen_names[folded]}」与「{name}」,工作表名不区分大小写)"))
+            seen_names[folded] = name
+            sheet_names.append(name)
+            # 图表工作表、对话框工作表没有单元格,openpyxl 的 worksheets 里也不含它们
+            if not kind.endswith("/worksheet") or path not in archive.names:
+                continue
+            sheet = SheetScan(name=name, state=el.get("state") or "visible", path=path)
+            # 表格对象先读:扫单元格时要用它的位置认出「销售表[金额]」这种结构化引用汇总了哪几行
+            tables = _scan_tables(archive, path, name)
+            result.tables.extend(tables)
+            _scan_sheet(archive, sheet, sst_flags, {t.name.casefold(): t for t in tables if t.name})
+            result.sheets.append(sheet)
+
+    for el in book.iter():
+        if _local(el.tag) != "definedName":
+            continue
+        name = el.get("name") or ""
+        local_id = el.get("localSheetId")
+        if local_id is not None and local_id.isdigit() and int(local_id) < len(sheet_names):
+            name = f"{sheet_names[int(local_id)]}!{name}"
+        result.defined_names[name] = (el.text or "").strip()
+    return result
+
+
+#: openpyxl 按固定路径整份解析的部件:文档属性、自定义属性、样式(reader/excel.py、styles/stylesheet.py)
+_OPENPYXL_FIXED = ("docProps/core.xml", "docProps/custom.xml", "xl/styles.xml")
+
+
+def _openpyxl_parts(archive: _Archive, rels: dict[str, tuple[str, str]], xml_names: set[str]) -> list[str]:
+    """openpyxl 只读打开时整份建树、而这里的扫描不读的 XML 部件。
+
+    只管真会被解析的:计算链、批注、透视表缓存这些部件可以很大(和公式数、数据行数成正比),
+    但没人解析它们,按小部件设限只会误拒正常文件。外部链接 openpyxl 默认也会读,导入时已关掉
+    (tabular._open_book 的 keep_links=False)。图表工作表连同它引用的绘图、图表一并算进来。
+    """
+    out = [path for path in _OPENPYXL_FIXED if path in xml_names]
+    todo = [path for kind, path in rels.values() if kind.endswith("/chartsheet") and path in xml_names]
+    while todo:
+        path = todo.pop()
+        if path in out:
+            continue
+        out.append(path)
+        folder, _, name = path.rpartition("/")
+        rels_path = f"{folder}/_rels/{name}.rels" if folder else f"_rels/{name}.rels"
+        if rels_path in xml_names:
+            out.append(rels_path)
+            todo += [dep for _kind, dep in archive.rels(path).values() if dep in xml_names and dep not in out]
+    return out
+
+
+def _scan_tables(archive: _Archive, sheet_path: str, sheet_name: str) -> list[TableScan]:
+    out = []
+    for tkind, tpath in archive.rels(sheet_path).values():
+        if not tkind.endswith("/table") or tpath not in archive.names:
+            continue
+        t = archive.tree(tpath)
+        columns = [c.get("name") or "" for c in t.iter() if _local(c.tag) == "tableColumn"]
+        out.append(TableScan(
+            name=t.get("displayName") or t.get("name") or "",
+            ref=t.get("ref") or "",
+            sheet=sheet_name,
+            totals_row_count=int(t.get("totalsRowCount") or 0),
+            header_row_count=int(t.get("headerRowCount") or 1),
+            columns=columns,
+        ))
+    return out
+
+
+def _text_info(container: ET.Element) -> tuple[bool, int]:
+    """ 或  里的文字:(有没有非空白文字, 总字符数)。直接的 ,或者富文本 ;注音  不算。"""
+    nonblank, length = False, 0
+    for child in container:
+        local = _local(child.tag)
+        if local == "t":
+            text = child.text
+            if text:
+                length += len(text)
+                nonblank = nonblank or not text.isspace()
+        elif local == "r":
+            for t in child:
+                text = t.text
+                if text and _local(t.tag) == "t":
+                    length += len(text)
+                    nonblank = nonblank or not text.isspace()
+    return nonblank, length
+
+
+def _text_nonblank(container: ET.Element) -> bool:
+    """ 或  里有没有非空白文字。"""
+    return _text_info(container)[0]
+
+
+def _scan_shared_strings(archive: _Archive, path: str) -> bytearray:
+    """共享字符串表 → 每一项「非空」与否(1/0)。不还原全文:只需要知道引用它的格算不算空。
+
+    条目数、单个条目的文字长度和元素个数都设上限:openpyxl 会把整张表读进一个列表,
+    超出 Excel 自己上限的条目只可能是构造出来撑爆内存的。根下的元素处理完就摘掉
+    (不只是 :写文件的程序塞进来的未知元素也一样),根下不留空壳。
+    """
+    flags = bytearray()
+    root: ET.Element | None = None
+    depth = inner = 0
+    with closing(archive.open(path)) as source:
+        for event, el in ET.iterparse(source, events=("start", "end")):
+            if event == "start":
+                depth += 1
+                if root is None:
+                    root = el
+                continue
+            depth -= 1
+            if depth > 1:
+                inner += 1                 # 条目内部的元素:条目结束时随它一起清掉
+                if inner > MAX_SI_ELEMENTS:
+                    raise UnsupportedTable(_abnormal("共享字符串表中有一个条目包含的元素过多"))
+                continue
+            if depth == 1:
+                inner = 0
+                if _local(el.tag) == "si":
+                    if len(flags) >= MAX_SHARED_STRINGS:
+                        raise UnsupportedTable(_abnormal(f"共享字符串表的条目超过 {MAX_SHARED_STRINGS:,} 条"))
+                    nonblank, length = _text_info(el)
+                    if length > EXCEL_MAX_TEXT:
+                        raise UnsupportedTable(_abnormal(
+                            f"共享字符串表中有一项文字超过 Excel 单元格的上限({EXCEL_MAX_TEXT:,} 个字符)"))
+                    flags.append(1 if nonblank else 0)
+                el.clear()
+                if root is not None:
+                    root.remove(el)        # 处理完就摘掉,根下不留空壳
+    return flags
+
+
+#: 公式里的区域引用:C22:C25、$C$22:$C$25。前面紧挨着「!」的是别的工作表,不算
+_RANGE_REF = re.compile(
+    r"(? bool:
+    """公式是不是汇总了本格正上方的几行。表内合计行(C26 = SUM(C22:C25))是交叉表、多块报表的典型信号。
+
+    认三种写法:
+    - 区域:整个在本行之上、至少两行、列覆盖本格所在列(SUM(C22:C25)、SUBTOTAL(9,D2:D9));
+    - 逐格相加:单格引用至少两个,而且全都在本格正上方的同一列(B2+B3+B4、SUM(C2,C3,C4));
+    - 结构化引用:「销售表[金额]」指的明细行全在本行之上、列覆盖本格(tables 给出本工作表的表格对象,
+      键是 casefold 后的表名)。只引用表头、汇总行或本行([#Totals]、[@金额])的不算。
+
+    逐行算的公式(D3 = B3*C3)、累计(D3 = SUM(C$2:C3),区域含本行)、滚动余额(C3 = C2+B3,
+    引用了别的列)、占比(D5 = C5/SUM(C$5:C$20))都不满足。
+    """
+    if ":" not in formula and "[" not in formula:
+        letters = col_letter(col)
+        if letters not in formula and letters.lower() not in formula:
+            return False                  # 快速路径:绝大多数逐行公式根本不引用本列
+    if tables and "[" in formula and _structured_above(formula, row, col, tables):
+        return True
+    text = _BRACKETS.sub("[]", _QUOTED.sub(lambda m: '""' if m.group(0)[0] == '"' else "'_'", formula))
+    for m in _RANGE_REF.finditer(text):
+        c1, r1, c2, r2 = _col_number(m.group(1)), int(m.group(2)), _col_number(m.group(3)), int(m.group(4))
+        if r1 > r2:
+            r1, r2 = r2, r1
+        if c1 > c2:
+            c1, c2 = c2, c1
+        if r2 < row and r2 > r1 and c1 <= col <= c2:
+            return True
+    refs = {(int(m.group(2)), _col_number(m.group(1))) for m in _CELL_REF.finditer(_ANY_RANGE.sub(" ", text))}
+    return len(refs) >= 2 and all(c == col and r < row for r, c in refs)
+
+
+def _bracket(formula: str, start: int) -> str | None:
+    """formula[start] 是「[」:返回配对的括号里的内容。「'」转义下一个字符。括号不成对时 None。"""
+    depth = 0
+    i = start
+    while i < len(formula):
+        ch = formula[i]
+        if ch == "'":
+            i += 2
+            continue
+        if ch == "[":
+            depth += 1
+        elif ch == "]":
+            depth -= 1
+            if depth == 0:
+                return formula[start + 1:i]
+        i += 1
+    return None
+
+
+def _structured_above(formula: str, row: int, col: int, tables: dict[str, TableScan]) -> bool:
+    for m in _TABLE_REF.finditer(formula):
+        table = tables.get(m.group(1).casefold())
+        span = table.span() if table is not None else None
+        content = _bracket(formula, m.end() - 1) if span is not None else None
+        if table is None or span is None or content is None:
+            continue
+        content = content.strip()
+        items = re.findall(r"\[((?:[^\]']|'.)*)\]", content) if content.startswith("[") else [content]
+        items = [i.strip() for i in items]
+        specials = {i.lower() for i in items if i.lower() in _SPECIALS}
+        if "#this row" in specials or any(i.startswith("@") for i in items):
+            continue                      # 本行:逐行计算
+        if specials and not specials & {"#all", "#data"}:
+            continue                      # 只引用表头或汇总行
+        r1, c1, r2, c2 = span
+        data_lo = r1 + max(table.header_row_count, 0)
+        data_hi = r2 - max(table.totals_row_count, 0)
+        if data_lo > data_hi:
+            continue
+        bottom = r2 if "#all" in specials or "#totals" in specials else data_hi
+        if bottom >= row:
+            continue
+        lo, hi = c1, c2
+        names = [re.sub(r"'(.)", r"\1", i).casefold() for i in items if i and i.lower() not in _SPECIALS]
+        known = [n.casefold() for n in table.columns]
+        if names and all(n in known for n in names):
+            pos = [known.index(n) for n in names]
+            lo, hi = c1 + min(pos), c1 + max(pos)
+        if lo <= col <= hi:
+            return True
+    return False
+
+
+def _descendants(el: ET.Element) -> int:
+    return sum(1 for _ in el.iter()) - 1
+
+
+def _add_span(spans: list[tuple[int, int]], lo: int, hi: int) -> None:
+    if spans and spans[-1][1] + 1 >= lo:
+        spans[-1] = (spans[-1][0], max(spans[-1][1], hi))
+    else:
+        spans.append((lo, hi))
+
+
+def _scan_sheet(archive: _Archive, sheet: SheetScan, sst_flags: bytearray | None,
+                tables: dict[str, TableScan] | None = None) -> None:
+    """一张工作表流式扫一遍。按 OOXML 的元素顺序,合并区、筛选在 sheetData 之后,所以必须扫到底。
+
+    只要 end 事件,单元格在所在行结束时一起处理(这时行号、各格的子元素都齐了),处理完把行清空。
+    要 start 事件的话事件数翻倍,20 万行的表要多花一秒;代价是清空后的行还挂在 sheetData 下,
+    每行几十字节,行以外的元素也一直挂着。所以按 Excel 自己的上限设硬上限:行号和行数不超过
+    1,048,576、列号不超过 16,384,树上同时挂着的元素不超过 MAX_ALIVE(行清空时,它下面的
+    元素随之释放,从计数里减掉);单元格文字、公式也按 Excel 的长度上限查。超了就拒收:
+    正常的文件到不了这些数,到了的只可能是构造出来撑爆内存的(几十 KB 的压缩包能解出几百万个空行)。
+    """
+    tags = _SHEET_TAGS
+    sst_len = len(sst_flags) if sst_flags is not None else 0
+    min_row = min_col = 1 << 30
+    max_row = max_col = 0
+    nonempty = 0
+    far: list[str] = []
+    max_col_cell = max_row_cell = ""
+    cur_row = 0
+    hidden_rows = sheet.hidden_rows
+    formulas_uncached, formulas_above, errors = sheet.formulas_uncached, sheet.formulas_above, sheet.errors
+
+    alive = rows = 0
+    where = f"工作表「{sheet.name}」"
+    # 上限先取到局部变量:每个元素都要比一次,模块属性每次现查要多花时间(测试里照样能改模块常量)
+    max_alive, max_rows, max_cols = MAX_ALIVE, EXCEL_MAX_ROWS, EXCEL_MAX_COLS
+    max_text, max_formula = EXCEL_MAX_TEXT, EXCEL_MAX_FORMULA
+
+    with closing(archive.open(sheet.path)) as source:
+        for _event, el in ET.iterparse(source, events=("end",)):
+            alive += 1
+            if alive > max_alive:
+                raise UnsupportedTable(_abnormal(f"{where}包含的元素过多"))
+            name = tags.get(el.tag)
+            if name is None or name in ("c", "v", "f", "is"):
+                continue
+            if name != "row":
+                if name == "mergeCell":
+                    ref = el.get("ref")
+                    if ref:
+                        sheet.merged_total += 1
+                        if len(sheet.merged) < MAX_MERGED:
+                            sheet.merged.append(ref)
+                    el.clear()
+                elif name == "col":
+                    if el.get("hidden") in ("1", "true"):
+                        lo, hi = int(el.get("min") or 0), int(el.get("max") or 0)
+                        if max(lo, hi) > max_cols:
+                            raise UnsupportedTable(_abnormal(f"{where}超过 Excel 的列数上限({max_cols:,} 列)"))
+                        if lo and hi:
+                            _add_span(sheet.hidden_cols, lo, hi)
+                elif name == "autoFilter":
+                    sheet.autofilter = el.get("ref")
+                elif name == "dimension":
+                    sheet.dimension = el.get("ref")
+                continue
+
+            r = el.get("r")
+            row = int(r) if r else cur_row + 1
+            cur_row = row
+            rows += 1
+            if row > max_rows or rows > max_rows:
+                raise UnsupportedTable(_abnormal(f"{where}超过 Excel 的行数上限({max_rows:,} 行)"))
+            if el.get("hidden") in ("1", "true"):
+                _add_span(hidden_rows, row, row)
+            col = 0
+            #: 这一行清空时随之释放的元素个数(行本身的空壳留着,不算)
+            freed = 0
+            for cell in el:
+                freed += 1
+                if tags.get(cell.tag) != "c":
+                    if len(cell):
+                        freed += _descendants(cell)
+                    continue
+                ref = cell.get("r")
+                if ref:
+                    letters = ref.rstrip(_DIGITS)
+                    col = _col_number(letters)
+                else:
+                    col += 1
+                if col > max_cols:
+                    raise UnsupportedTable(_abnormal(f"{where}超过 Excel 的列数上限({max_cols:,} 列)"))
+                ctype = cell.get("t") or "n"
+                v_el = f_el = is_el = None
+                inline_filled, inline_len = False, 0
+                for child in cell:
+                    freed += 1
+                    cname = tags.get(child.tag)
+                    if cname == "v":
+                        v_el = child
+                    elif cname == "f":
+                        f_el = child
+                    else:
+                        #  下的文字、未知元素下的子孙也随行释放。、 正常没有子元素:
+                        # 构造出来的子元素不减,只会让计数偏大、更早拒收
+                        if len(child):
+                            freed += _descendants(child)
+                        if cname == "is":
+                            is_el = child
+                            inline_filled, inline_len = _text_info(child)
+                vtext = v_el.text if v_el is not None else None
+                if inline_len > max_text or (vtext is not None and len(vtext) > max_text):
+                    raise UnsupportedTable(_abnormal(
+                        f"{where}的单元格 {cell_ref(row, col)} 中的文字超过 Excel 的上限({max_text:,} 个字符)"))
+                if f_el is not None and f_el.text and len(f_el.text) > max_formula:
+                    raise UnsupportedTable(_abnormal(
+                        f"{where}的单元格 {cell_ref(row, col)} 中的公式超过 Excel 的上限({max_formula:,} 个字符)"))
+                if ctype == "s":
+                    if vtext:
+                        idx = int(vtext)
+                        filled = sst_flags[idx] == 1 if 0 <= idx < sst_len and sst_flags is not None else True
+                    else:
+                        filled = False
+                elif ctype == "inlineStr":
+                    filled = inline_filled
+                elif ctype == "str":
+                    filled = bool(vtext and vtext.strip())
+                else:
+                    filled = bool(vtext)
+                if ctype == "e" and vtext:
+                    errors.add(row, col)
+                if f_el is not None:
+                    sheet.formulas += 1
+                    # 公式返回空字符串时 Excel 写 t="str" 加空的 :那是有缓存的空值,不算「无缓存」
+                    if v_el is None or (not vtext and ctype != "str"):
+                        formulas_uncached.add(row, col)
+                    ftext = f_el.text
+                    if ftext and _aggregates_above(ftext, row, col, tables):
+                        formulas_above.add(row, col, ftext)
+                    filled = True          # 有公式的格不算空,哪怕缓存值是空的
+                if not filled:
+                    continue
+                nonempty += 1
+                if row < min_row:
+                    min_row = row
+                if col < min_col:
+                    min_col = col
+                # 同一个格子可能既往右又往下跳得很远(XFD1048576):只记一次
+                jumped = False
+                if col > max_col:
+                    jumped = bool(max_col) and col - max_col > FAR_COL_JUMP
+                    max_col = col
+                    max_col_cell = cell_ref(row, col)
+                if row > max_row:
+                    jumped = jumped or (bool(max_row) and row - max_row > FAR_ROW_JUMP)
+                    max_row = row
+                    max_row_cell = cell_ref(row, col)
+                if jumped and len(far) < MAX_FAR:
+                    far.append(cell_ref(row, col))
+            el.clear()
+            alive -= freed
+
+    sheet.nonempty = nonempty
+    if nonempty:
+        sheet.bounds = Bounds(min_row, min_col, max_row, max_col)
+        # 没有明显的跳跃(比如区域是慢慢变稀的),就拿撑出右边界、下边界的格子当示例
+        sheet.far_cells = far or list(dict.fromkeys(c for c in (max_col_cell, max_row_cell) if c))
diff --git a/backend/app/db/base.py b/backend/app/db/base.py
index 75254db7..e6b0d711 100644
--- a/backend/app/db/base.py
+++ b/backend/app/db/base.py
@@ -168,6 +168,12 @@ _COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [
     ("conversation_turns", "meta", "JSON"),
     ("data_sources", "last_check", "JSON"),
     ("mcp_servers", "last_check", "JSON"),
+    # 存量的源一律先记成 manual;老的上传源由 table_versions.startup 按路径认出来,
+    # 补上 v0 版本后再改成 upload
+    ("data_sources", "origin", "TEXT DEFAULT 'manual'"),
+    ("data_sources", "current_snapshot_id", "VARCHAR(64)"),
+    # 存量运行没有钉过版本,NULL 正好表示「升级前发起的」
+    ("runs", "data_versions", "JSON"),
 ]
 
 
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index 20bdddb9..d0be5c7b 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -61,6 +61,88 @@ class DataSource(Base, TimestampMixin):
     enabled: Mapped[bool] = mapped_column(default=True)
     # 最近一次测连接:{at, ok, latency_ms, error}。记在对象上,换台浏览器也看得到
     last_check: Mapped[dict[str, Any] | None] = mapped_column(default=None)
+    # upload:上传表格建的源,连接信息由系统维护(data/table_versions.py),不许手改;
+    # manual:手工登记的连接。按这个字段判断,而不是猜 database 的路径
+    origin: Mapped[str] = mapped_column(String(20), default="manual")
+    # 上传源当前启用的快照(source_snapshots.id)。查询、探查都从快照解析,
+    # database 只作显示;手工源恒为 None
+    current_snapshot_id: Mapped[str | None] = mapped_column(String(64), default=None)
+
+
+# --------------------------------------------------------------------------
+# 上传表格的版本:构建 → 导入记录 → 快照(data/table_versions.py)
+# --------------------------------------------------------------------------
+
+
+class TableBuild(Base):
+    """一次解析的产物:同一个源、同一份原件、同一组解析选项、同一版解析器,只建一次库。
+
+    id 是这四样的 sha256 全长,source_id 算在里面:两个源传同一个文件得到两个构建,
+    互不引用——一个源删掉、回收,不会连带另一个源的库。库文件写好后是只读的(0444),
+    之后不再改动;回收时删掉文件、记录留着(retired_at)备查。
+    """
+
+    __tablename__ = "table_builds"
+
+    id: Mapped[str] = mapped_column(String(64), primary_key=True)
+    source_id: Mapped[str] = mapped_column(String(32), index=True)
+    raw_sha256: Mapped[str] = mapped_column(String(64), default="")
+    options_sha256: Mapped[str] = mapped_column(String(64), default="")
+    engine_ver: Mapped[str] = mapped_column(String(40), default="")
+    options: Mapped[dict[str, Any]] = mapped_column(default=dict)
+    db_path: Mapped[str] = mapped_column(Text, default="")
+    db_sha256: Mapped[str] = mapped_column(String(64), default="")
+    # 解析回执(tabular.LoadReport):区域、跳过的工作表、类型转换、警告
+    report: Mapped[dict[str, Any]] = mapped_column(default=dict)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
+    # 库文件被回收的时刻。为空表示文件还在
+    retired_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
+
+
+class TableImport(Base):
+    """一次上传。重传同一个文件也是一条新记录(指向同一个构建):谁、何时、传了什么,都要留痕。
+
+    不挂外键:数据源删掉以后,导入记录置 retired 留作审计,不跟着删。
+    """
+
+    __tablename__ = "table_imports"
+    __table_args__ = (Index("ix_table_imports_source_seq", "source_id", "seq"),)
+
+    id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
+    source_id: Mapped[str] = mapped_column(String(32))
+    seq: Mapped[int] = mapped_column(Integer, default=0)
+    build_id: Mapped[str] = mapped_column(String(64), default="")
+    file_name: Mapped[str] = mapped_column(String(500), default="")
+    file_size: Mapped[int] = mapped_column(Integer, default=0)
+    raw_sha256: Mapped[str] = mapped_column(String(64), default="")
+    # kept:原件在 uploads/raw 里;purged:清除过(purged 里记时间、署名、理由);
+    # absent:从来没有原件(迁移前的老上传只剩 .db)
+    raw_state: Mapped[str] = mapped_column(String(20), default="kept")
+    # active:当前快照里的那一期;superseded:被新上传替换、文件还在;retired:已回收或源已删除
+    status: Mapped[str] = mapped_column(String(20), default="active", index=True)
+    purged: Mapped[dict[str, Any] | None] = mapped_column(default=None)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
+    activated_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
+
+
+class SourceSnapshot(Base):
+    """数据源的一个可查询版本:若干期导入合成的一个库,加上冻结的表结构。
+
+    期 1 只有「每期替换」:一个快照就是一期导入,db_path 直接用那次构建的库。
+    运行钉的是快照 id(runs.data_versions),表结构和数据出自同一个版本。
+    """
+
+    __tablename__ = "source_snapshots"
+
+    id: Mapped[str] = mapped_column(String(64), primary_key=True)
+    source_id: Mapped[str] = mapped_column(String(32), index=True)
+    imports: Mapped[list[Any]] = mapped_column(default=list)  # import id 列表
+    db_path: Mapped[str] = mapped_column(Text, default="")
+    db_sha256: Mapped[str] = mapped_column(String(64), default="")
+    schema_cache: Mapped[dict[str, Any]] = mapped_column(default=dict)
+    created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
+    # 没有运行、也不是当前版本时由回收标上;之后钉着它的请求明确报「版本不存在」
+    retired_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
 
 
 class Setting(Base, TimestampMixin):
@@ -199,6 +281,9 @@ class Run(Base, TimestampMixin):
     agent_limits: Mapped[dict[str, Any] | None] = mapped_column(default=None)
     # 失败时能定位到的节点。报错要能落到画布上的那张卡片,而不只是一句话
     error_node_id: Mapped[str | None] = mapped_column(String(64), default=None)
+    # 发起时钉住的上传表格版本:{source_id: {"snapshot": 快照 id, "name": 源名}}。续跑、恢复沿用,
+    # 回收(table_versions.gc)不删任何运行引用的快照。None 是升级前发起的运行
+    data_versions: Mapped[dict[str, Any] | None] = mapped_column(default=None)
 
     events: Mapped[list["RunEvent"]] = relationship(
         back_populates="run", cascade="all, delete-orphan"
diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py
index f501da08..64021a26 100644
--- a/backend/app/engine/evidence.py
+++ b/backend/app/engine/evidence.py
@@ -24,6 +24,9 @@ t / c 仍按一期判为解析不了(「这种引用在后续版本支持」
 写了 entities: off 的,照实说是它关掉的。表结构快照不全(库里的表太多、只存了一部分)时,
 找不到的名字只说「核对不了」,不说「可能是编造的」。自动链接的名字只是标注:一句结论挂没挂
 依据,只看写作者自己写的 [[…]] 和 [[see:…]]。
+表名、字段名怎么写、怎么找,按文档记下的实体语法版本(顶层 entity_syntax,见 ENTITY_SYNTAX):
+2 版起 [[t:]] / [[c:]] 和 SQL 里不加引号的名字认中文([[t:明细]]、FROM 明细),按 SQLite 的口径找(name_key);
+没有这个字段的老文档按 1 版(只认 ASCII 标识符)复核。反引号里的名字核对、正文裸名自动链接两版都只认 ASCII。
 沙箱代码节点的产出不能直接引用(`[[v:N:calc.x]]`),原因写「沙箱算出来的数要进口径卡」。
 
 单元格、整表、引文要读快照:整个模块仍然不碰数据库、不调模型,快照经 loader 取(缺省是
@@ -43,17 +46,29 @@ import difflib
 import json
 import math
 import re
+import unicodedata
 from collections.abc import Callable
 from decimal import ROUND_HALF_UP, Context, Decimal
 from typing import Any, Literal, TypedDict
 
 from app.core.artifact_store import canonical_json, content_hash
 from app.core.artifact_store import load as load_artifact
+from app.data.names import FILLERS, name_key
+from app.data.tabular import UNSHAPED_NOTE
 from app.engine.expressions import CellError, cell_value, column_kind, locate_cell
 from app.engine.issuance import _tolerance, extract_numbers, number_allowance
 from app.engine.labels import option_label
 
 DOC_SCHEMA = "agentlab.report/1"
+#: 实体语法(表名、字段名怎么写、怎么找)的版本。新组装的文档记在顶层 entity_syntax,参与内容哈希。
+#: - 1:名字只认 ASCII 标识符,按 Python 的 lower() 找。没有 entity_syntax 的老文档按它复核;
+#: - 2:[[t:]] / [[c:]] 和 SQL 里不加引号的名字放宽到 Unicode(上传表格的中文表名、列名),按 name_key 找
+#:   (只对 ASCII 不区分大小写、不做 NFKC,和 SQLite 一致;导入和证据层共用 names.py 这一个键)。
+#: 复核必须按文档生成时的那一版:老文档里的 [[t:明细]] 当年判为「格式无法识别」,拿新规则查就解析得了,
+#: 和记下的状态对不上,会被当成文档被改过(state_mismatch)。
+ENTITY_SYNTAX = 2
+ENTITY_SYNTAXES = frozenset({1, 2})
+
 
 # --------------------------------------------------------------------------
 # 数据结构(方案 1.5)。都是普通 dict,TypedDict 只用来写清形状
@@ -223,7 +238,9 @@ def query_entry_fields(payload: Any) -> dict[str, Any] | None:
     schema = data.get("schema_artifact")
     if isinstance(schema, str) and schema:
         # 表结构快照和 SQL 里的表名成对出现:没探查过结构的数据源,只凭 SQL 和结果列核对名字,
-        # 反引号里写的真实表名(没进这条 SQL)会被误标成编造的,这种查询不启用实体核对
+        # 反引号里写的真实表名(没进这条 SQL)会被误标成编造的,这种查询不启用实体核对。
+        # 表名按当前的实体语法版本取:台账条目在查询当时记一次、存下来,复核老文档时目录按存下的台账重建,
+        # 不重新解析 SQL,所以老文档看到的还是当年记的表名
         fields.update(schema_artifact=schema, tables=sql_tables(str(data.get("sql") or "")))
     return fields
 
@@ -235,12 +252,53 @@ def query_entry_fields(payload: Any) -> dict[str, Any] | None:
 #: 数据源工具每次查询时把当时的表结构存成这种工件
 SCHEMA_SNAPSHOT = "schema_snapshot"
 
-_SQL_IDENT = r'(?:"[^"\n]+"|`[^`\n]+`|\[[^\]\n]+\]|[A-Za-z_][A-Za-z0-9_$#]*)'
-_SQL_NAME = re.compile(rf"\s*({_SQL_IDENT}(?:\s*\.\s*{_SQL_IDENT}){{0,2}})")
-_SQL_PART = re.compile(_SQL_IDENT)
-_SQL_FROM = re.compile(r"\b(?:from|join)\b", re.I)
-#: WITH x AS (…), y AS (…):公用表表达式的名字不是真表
-_SQL_CTE = re.compile(rf"(?:\bwith\b(?:\s+recursive\b)?|,)\s*({_SQL_IDENT})\s*(?:\([^()]*\)\s*)?\bas\s*\(", re.I)
+class _SqlSyntax:
+    """sql_tables 用的一套正则,按实体语法版本各一套,差在不加引号的名字由哪些字符组成、名字和关键字的边界怎么定。
+
+    1 版:ASCII 字母或下划线开头,字母、数字、_ $ #;空白和词边界按 Python 的 \\s、\\b。原样留着
+    (ENTITY_SYNTAXES 里的版本都得能复现)。
+    2 版:照数据库的分词来——SQLite、PostgreSQL、MySQL 都把 ASCII 以外的字符一律算作不加引号的标识符的一部分
+    (开头也算),所以名字是 ASCII 字母、下划线或任意非 ASCII 字符开头,后面再加数字和 $ #。上传的表格起的是
+    中文名,导入时也鼓励不加引号写 SQL:`FROM 明细` 按 1 版认不出来,这张表的条目就记不下是哪次查询用到的,
+    写作目录把它归到「其他表」,裁判也拿不到这张表的 SQL。
+    不能只认 names.name_problem 的字符集(字母、数字、汉字、下划线):`FROM 销售数据(2024)` 在库里查的是
+    「销售数据(2024)」这一张表(全角括号、间隔号、全角空格都是名字的一部分),只认 \\w 会截成「销售数据」,
+    记下一张不存在的表,[[t:销售数据]] 就能静默通过。同理,2 版的空白只认 ASCII 空白,FROM / JOIN 等关键字
+    两边不能紧挨非 ASCII 字符(`FROM 明细` 在库里是一个名字,不是 FROM 加表名)。名字切得和数据库一样,
+    才谈得上「拿不准往少认一张表偏」。
+
+    判断「# / $ 前面是不是名字的一部分」用的字符集跟着名字走:2 版里「明细#1」和「abc#1」一样是一个名字,
+    不能把 # 当注释开头。PostgreSQL 美元引号的标签($标签$)也按同样的字符集认。
+    """
+
+    def __init__(self, start: str, rest: str, tag: str, key: Callable[[str], str], *,
+                 ws: str = r"\s", before: str = r"\b", after: str = r"\b") -> None:
+        self.ident = rf'(?:"[^"\n]+"|`[^`\n]+`|\[[^\]\n]+\]|{start}{rest}*)'
+        self.name = re.compile(rf"{ws}*({self.ident}(?:{ws}*\.{ws}*{self.ident}){{0,2}})")
+        self.part = re.compile(self.ident)
+        #: FROM / JOIN:后面跟着表名
+        self.keyword = re.compile(rf"{before}(?:from|join){after}", re.I)
+        #: WITH x AS (…), y AS (…):公用表表达式的名字不是真表
+        self.cte = re.compile(rf"(?:{before}with{after}(?:{ws}+recursive{after})?|,){ws}*({self.ident}){ws}*"
+                              rf"(?:\([^()]*\){ws}*)?{before}as{ws}*\(", re.I)
+        self.alias = re.compile(rf"{ws}+(?:as{ws}+)?({self.ident})", re.I)
+        #: 名字后面紧跟括号:子查询、表函数;紧跟逗号:FROM a, b
+        self.call = re.compile(rf"{ws}*\(")
+        self.comma = re.compile(rf"{ws}*,")
+        #: PostgreSQL 的美元引号字符串:$$…$$、$tag$…$tag$($1 这种参数不是)
+        self.dollar = re.compile(rf"\$(?:{tag})?\$")
+        self.word_char = re.compile(rest)
+        #: 表名去重、和公用表表达式比对用的键:1 版 lower(),2 版 name_key(「Дата」和「дата」是两张表)
+        self.key = key
+
+
+#: 2 版不加引号的名字里能出现的字符:ASCII 字母、数字、_ $ #,加上任意非 ASCII 字符
+_SQL_WORD2 = r"[A-Za-z0-9_$#\x80-\U0010ffff]"
+_SQL = {
+    1: _SqlSyntax("[A-Za-z_]", "[A-Za-z0-9_$#]", "[A-Za-z_][A-Za-z0-9_]*", str.lower),
+    2: _SqlSyntax(r"[A-Za-z_\x80-\U0010ffff]", _SQL_WORD2, r"[A-Za-z_\x80-\U0010ffff][A-Za-z0-9_\x80-\U0010ffff]*",
+                  name_key, ws=r"[ \t\n\r\f\v]", before=rf"(? tuple[str, str]:
+def _sql_scan(sql: str, rules: _SqlSyntax) -> tuple[str, str]:
     """一趟扫完注释、字符串、带引号的标识符,返回两份和原文等长的文本:
 
     - code:注释换成空格,字符串字面量的内容换成空格(引号留着),标识符原样——取表名用
@@ -281,7 +336,7 @@ def _sql_scan(sql: str) -> tuple[str, str]:
     i = 0
     while i < n:
         ch, nxt = sql[i], sql[i + 1] if i + 1 < n else ""
-        word_before = i > 0 and bool(_SQL_WORD_CHAR.match(sql[i - 1]))
+        word_before = i > 0 and bool(rules.word_char.match(sql[i - 1]))
         if (ch == "-" and nxt == "-") or (ch == "#" and not word_before):
             end = sql.find("\n", i)
             end = n if end < 0 else end
@@ -320,7 +375,7 @@ def _sql_scan(sql: str) -> tuple[str, str]:
         elif ch == "[" and (m := _SQL_BRACKET.match(sql, i)):
             blank(i + 1, m.end() - 1, words)
             i = m.end()
-        elif ch == "$" and not word_before and (m := _SQL_DOLLAR.match(sql, i)):
+        elif ch == "$" and not word_before and (m := rules.dollar.match(sql, i)):
             close = sql.find(m.group(0), m.end())
             end = n if close < 0 else close
             blank(m.end(), end, code, words)
@@ -330,8 +385,8 @@ def _sql_scan(sql: str) -> tuple[str, str]:
     return "".join(code), "".join(words)
 
 
-def _unquote(name: str) -> str:
-    return ".".join(p[1:-1] if p[:1] in ('"', "`", "[") else p for p in _SQL_PART.findall(name))
+def _unquote(name: str, rules: _SqlSyntax) -> str:
+    return ".".join(p[1:-1] if p[:1] in ('"', "`", "[") else p for p in rules.part.findall(name))
 
 
 def _inside_call(code: str, pos: int) -> bool:
@@ -351,43 +406,48 @@ def _inside_call(code: str, pos: int) -> bool:
     return False
 
 
-def sql_tables(sql: str) -> list[str]:
+def sql_tables(sql: str, *, syntax: int | None = None) -> list[str]:
     """SQL 里 FROM / JOIN 后面的表名,按第一次出现的顺序、不分大小写去重。
 
     注释、字符串字面量一趟扫掉(_sql_scan:`-- FROM ghost`、'JOIN ghost'、'--x' 后面的正文都
     处理对)。双引号、反引号、方括号括起来的标识符照样认,去掉引号;引号里的 FROM 不算关键字。
     WITH 定义的公用表表达式、子查询、表函数(generate_series(…))不算表。
+
+    syntax 是实体语法版本,缺省当前版(2 版认不加引号的中文表名:FROM 明细、两张中文表 JOIN;名字切得和数据库
+    一样,`FROM 销售数据(2024)` 记的是整个名字,见 _SqlSyntax)。
+    「不分大小写」按版本的口径:2 版是 name_key,「Дата」和「дата」、「ORDERS」和「orders」算两张表。
     """
     if not sql:
         return []
-    code, words = _sql_scan(sql)
-    ctes = {_unquote(code[m.start(1):m.end(1)]).lower() for m in _SQL_CTE.finditer(words)}
+    rules = _SQL[ENTITY_SYNTAX if syntax is None else syntax]
+    code, words = _sql_scan(sql, rules)
+    ctes = {rules.key(_unquote(code[m.start(1):m.end(1)], rules)) for m in rules.cte.finditer(words)}
     out: dict[str, str] = {}
-    for keyword in _SQL_FROM.finditer(words):
+    for keyword in rules.keyword.finditer(words):
         if _inside_call(words, keyword.start()):
             continue
         pos = keyword.end()
         while True:
-            m = _SQL_NAME.match(code, pos)
-            if not m or re.match(r"\s*\(", code[m.end():]):
+            m = rules.name.match(code, pos)
+            if not m or rules.call.match(code, m.end()):
                 break                                   # 子查询、表函数
-            name = _unquote(m.group(1))
+            name = _unquote(m.group(1), rules)
             lowered = name.lower()
             if lowered == "only":                       # FROM ONLY t(PostgreSQL)
                 pos = m.end()
                 continue
             if lowered in _SQL_NOT_TABLE:
                 break
-            if lowered not in ctes:
-                out.setdefault(lowered, name)
+            if rules.key(name) not in ctes:
+                out.setdefault(rules.key(name), name)
             pos = m.end()
-            alias = re.match(rf"\s+(?:as\s+)?({_SQL_IDENT})", code[pos:], re.I)
+            alias = rules.alias.match(code[pos:])
             if alias and alias.group(1).lower() not in _SQL_CLAUSE:
                 pos += alias.end()
-            comma = re.match(r"\s*,", code[pos:])
+            comma = rules.comma.match(code, pos)
             if not comma:
                 break
-            pos += comma.end()
+            pos = comma.end()
     return list(out.values())
 
 
@@ -925,10 +985,18 @@ def _entity_entries(schemas: list[dict[str, Any]], queries: list[tuple[dict[str,
     的按那份查询快照。sources 记全三种来历(schema / sql / result),queries 是哪几次查询用到。
     同名字段在好几张表里(id、amount)另编一条 c:<列>,tables 写明是哪几张:正文里只写字段名时
     指的是哪张说不准,但这个名字确实存在。
+
+    已知限制:表按 Python 的 lower() 并,比 SQLite 宽(SQLite 只对 ASCII 不区分大小写)。手工接入的库里同时有
+    「Дата」和「дата」两张表时,目录里只有先出现的 t:Дата,后一张的字段记成 c:Дата.<列>:[[c:Дата.y]] 能解析
+    (y 其实是 дата 的字段,静默放行),[[t:дата]] 判为疑似编造(误报);SQL 里写 FROM дата 的查询也记在
+    t:Дата 名下。上传的表格碰不到:导入时按 names.collide_key(casefold)判撞名,这样的两张表不会并存。
+    没改成按 name_key 并,是因为目录没有版本:复核时它按存下的台账重新拼,不知道文档是哪一版语法,改了
+    并法,碰上这种库的老文档复核时就对不上当年的目录。以后给目录也加版本时再按 name_key 并。
     """
     tables: dict[str, dict[str, Any]] = {}
     columns: dict[str, dict[str, Any]] = {}
-    by_lower: dict[str, str] = {}                  # 小写的表名、全名、全名的末段 → 条目里的表名
+    #: 小写的表名、全名、全名的末段 → 条目里的表名。lower() 并得比 SQLite 宽,见上面的「已知限制」
+    by_lower: dict[str, str] = {}
     owners: dict[str, list[str]] = {}              # 小写的列名 → 表结构里有它的表
 
     def table(name: str, artifact: Any, *, qualified: str | None = None, source: Any = None) -> str:
@@ -997,9 +1065,30 @@ def _entity_entries(schemas: list[dict[str, Any]], queries: list[tuple[dict[str,
     return {**{e["alias"]: e for e in tables.values()}, **{e["alias"]: e for e in columns.values()}}
 
 
-#: 反引号里、正文里像标识符的名字:字母或下划线开头,最多四段用点连起来
-_ENTITY_NAME = r"[A-Za-z_][A-Za-z0-9_$#]*(?:\.[A-Za-z_][A-Za-z0-9_$#]*){0,3}"
-_IDENTIFIER = re.compile(rf"^{_ENTITY_NAME}$")
+#: 反引号里、正文里像标识符的名字,以及 1 版的 [[t:]] / [[c:]]:ASCII 字母或下划线开头,最多四段用点连起来。
+#: 反引号核对(_checked_name)和裸名链接(_BARE_NAME)两版都只认这个:放宽到 Unicode 的话,反引号里的
+#: 「华东」这种业务词会被判成可疑实体,中文正文没有词边界,整句会被当成一个名字
+_ASCII_NAME = r"[A-Za-z_][A-Za-z0-9_$#]*(?:\.[A-Za-z_][A-Za-z0-9_$#]*){0,3}"
+_ASCII_IDENTIFIER = re.compile(rf"^{_ASCII_NAME}$")
+#: 2 版的 [[t:]] / [[c:]]:每段字母或汉字开头,只含字母、数字、汉字、下划线(names.name_problem 的字符集),
+#: 另认 1 版就认的 $ #;段之间仍用「.」分隔([[c:明细.金额]])
+_UNICODE_IDENTIFIER = re.compile(r"^[^\W\d][\w$#]*(?:\.[^\W\d][\w$#]*){0,3}$")
+
+
+def _entity_ref_ok(ref: str, syntax: int) -> bool:
+    """[[t:]] / [[c:]] 里的名字写法对不对,按实体语法版本。
+
+    2 版另外挡住 names.name_problem 也挡的两类字符:不等于自身 NFKC 形式的(全角「ABC」、带圈数字「①」)——
+    正文照原样显示,和 ABC 肉眼难分,SQLite 里却是另一张表(它不做归一,names.name_key 也不做);以及填充字符
+    (\\w 把它们算作字母,肉眼看不出来)。填充字符里 U+3164、U+FFA0 经 NFKC 会变,上一条就挡住了;U+115F、U+1160 是 NFKC 稳定的,只能靠 names.FILLERS 挡。
+    长度和 SQL 关键字不管:手工接入的库里有长名字、有叫 order 的表,1 版也认。
+    """
+    if syntax < 2:
+        return bool(_ASCII_IDENTIFIER.match(ref))
+    return bool(_UNICODE_IDENTIFIER.match(ref)) and ref == unicodedata.normalize("NFKC", ref) \
+        and not any(ch in FILLERS for ch in ref)
+
+
 #: 反引号里写了这些不算名字:SQL 关键字、字面量、常用函数
 _SQL_WORDS = frozenset({
     "null", "true", "false", "none", "nan", "select", "from", "where", "group", "order", "by", "having", "limit",
@@ -1020,10 +1109,18 @@ class _EntityIndex:
     表和字段的查法分开(t: 只找表,c: 只找字段)。正文里的名字两样都试:先整名当表,再当
     字段(表.字段、只有字段名),最后才把 schema.表 按末段认成表——「orders.amount」不能因为
     恰好有一张叫 amount 的表就认成表。
+
+    「不分大小写」按实体语法版本的口径(self.key):2 版用 name_key,只对 ASCII 不区分大小写,和 SQLite 比较
+    标识符一致(ABC 和 abc 是一张表,Дата 和 дата、ORDERS 和 orders 都不是);1 版照旧用 Python 的 lower(),
+    老文档按当年的口径复核。
+    目录本身怎么并表不分版本(_entity_entries 按 lower() 并),这里只管查:只差非 ASCII 大小写的两张表在目录里
+    已经是一条,见那里的说明。
     """
 
-    def __init__(self, catalog: dict[str, Any]) -> None:
+    def __init__(self, catalog: dict[str, Any], *, syntax: int | None = None) -> None:
         self.catalog = catalog
+        self.key: Callable[[str], str] = name_key if (ENTITY_SYNTAX if syntax is None else syntax) >= 2 \
+            else str.lower
         self.tables: dict[str, str] = {}
         self.columns: dict[str, str] = {}
         self.owners: dict[str, list[str]] = {}
@@ -1042,30 +1139,30 @@ class _EntityIndex:
                 name = str(entry.get("name") or alias[2:])
                 for key in (name, entry.get("qualified"), name.rsplit(".", 1)[-1]):
                     if key:
-                        self.tables.setdefault(str(key).lower(), alias)
+                        self.tables.setdefault(self.key(str(key)), alias)
             elif kind == "column":
                 col = str(locator.get("column") or entry.get("name") or "")
                 if locator.get("table"):
-                    self.columns.setdefault(f"{locator['table']}.{col}".lower(), alias)
-                    self.owners.setdefault(col.lower(), []).append(alias)
+                    self.columns.setdefault(self.key(f"{locator['table']}.{col}"), alias)
+                    self.owners.setdefault(self.key(col), []).append(alias)
                 else:
-                    self.columns.setdefault(col.lower(), alias)
+                    self.columns.setdefault(self.key(col), alias)
             elif kind == "metric":
-                self.others.add(str(locator.get("metric") or "").lower())
+                self.others.add(self.key(str(locator.get("metric") or "")))
             elif kind == "input":
-                self.others.add(str(locator.get("field") or "").lower())
-            self.others.add(alias.lower())
+                self.others.add(self.key(str(locator.get("field") or "")))
+            self.others.add(self.key(alias))
         self.active = bool(self.tables or self.columns)
 
     def table(self, name: str, *, tail: bool = True) -> str | None:
-        lowered = name.lower()
+        lowered = self.key(name)
         hit = self.tables.get(lowered)
         if hit is None and tail and "." in lowered:
             hit = self.tables.get(lowered.rsplit(".", 1)[-1])
         return hit
 
     def column(self, name: str) -> str | None:
-        lowered = name.lower()
+        lowered = self.key(name)
         if lowered in self.columns:
             return self.columns[lowered]
         if "." in lowered:
@@ -1073,7 +1170,7 @@ class _EntityIndex:
             owner = self.table(head)
             if owner is None:
                 return None
-            return self.columns.get(f"{self.catalog[owner]['name']}.{col}".lower())
+            return self.columns.get(self.key(f"{self.catalog[owner]['name']}.{col}"))
         mine = self.owners.get(lowered) or []
         return mine[0] if len(mine) == 1 else None
 
@@ -1089,7 +1186,7 @@ class _EntityIndex:
 
     def known(self, name: str) -> bool:
         """这个名字在这次运行里有没有出处:表、字段,或者指标 id、运行输入、证据编号。"""
-        return self.find(name) is not None or name.lower() in self.others
+        return self.find(name) is not None or self.key(name) in self.others
 
     def unsure(self, name: str) -> bool:
         """一个找不到的名字是不是只能说「核对不了」:表结构快照不全时,它可能在没存下来的表里。
@@ -1103,9 +1200,11 @@ class _EntityIndex:
         return owner is None or owner not in self.described
 
 
-def find_entity(name: str, catalog: dict[str, Any], kind: str | None = None) -> str | None:
-    """按名字找表或字段的目录键(t:orders、c:orders.amount),找不到返回 None。kind 为 table / column 时只找那一种。"""
-    hit = _EntityIndex(catalog).find(name.strip(), kind)
+def find_entity(name: str, catalog: dict[str, Any], kind: str | None = None, *,
+                syntax: int | None = None) -> str | None:
+    """按名字找表或字段的目录键(t:orders、c:orders.amount),找不到返回 None。kind 为 table / column 时只找那一种。
+    syntax 是实体语法版本(决定大小写口径),缺省当前版。"""
+    hit = _EntityIndex(catalog, syntax=syntax).find(name.strip(), kind)
     return hit[1] if hit else None
 
 
@@ -1163,20 +1262,30 @@ class _Snapshots:
     compose / verify / 流式渲染里:出口复核是另一次调用,照样重新取、重新验。
     """
 
-    def __init__(self, loader: Loader | None = None, *, entities: bool = True) -> None:
+    def __init__(self, loader: Loader | None = None, *, entities: bool = True, syntax: int | None = None) -> None:
         self.loader = loader or load_artifact
         self.memo: dict[str, tuple[Any, str | None]] = {}
         self._index: tuple[dict[str, Any], _EntityIndex] | None = None
         #: False:报告节点写了 entities: off,目录里就算有表和字段也整层不管
         self.entities_on = entities
+        #: 这一次解析按哪一版实体语法:组装、流式渲染用当前版,复核按文档记下的版本(verify_doc)
+        self.syntax = ENTITY_SYNTAX if syntax is None else syntax
 
     def entities(self, catalog: dict[str, Any]) -> _EntityIndex:
         if self._index is None or self._index[0] is not catalog:
             shown = catalog if self.entities_on else \
                 {a: e for a, e in catalog.items() if not (isinstance(e, dict) and e.get("kind") in ENTITY_KINDS)}
-            self._index = (catalog, _EntityIndex(shown))
+            self._index = (catalog, _EntityIndex(shown, syntax=self.syntax))
         return self._index[1]
 
+    def with_syntax(self, syntax: int) -> "_Snapshots":
+        """换一版实体语法的同一次解析。取过的快照共用:快照内容和语法版本无关,不必再取、再验一遍。"""
+        if syntax == self.syntax:
+            return self
+        twin = _Snapshots(self.loader, entities=self.entities_on, syntax=syntax)
+        twin.memo = self.memo
+        return twin
+
     def get(self, alias: str, artifact: Any) -> tuple[Any, str | None]:
         """查询快照:(内容, 取不到的原因)。原因以 alias 开头,能直接当 unresolved 的 reason。"""
         content, why = self._fetch(artifact, "查询快照")
@@ -1218,8 +1327,12 @@ class _Snapshots:
         return (None, "absent") if content is None else (content, None)
 
 
-def _snapshots(loader: Loader | _Snapshots | None, *, entities: bool = True) -> _Snapshots:
-    return loader if isinstance(loader, _Snapshots) else _Snapshots(loader, entities=entities)
+def _snapshots(loader: Loader | _Snapshots | None, *, entities: bool = True,
+               syntax: int | None = None) -> _Snapshots:
+    """loader 已经是 _Snapshots 时沿用它(entities 不看);给了 syntax 就换成那一版。"""
+    if isinstance(loader, _Snapshots):
+        return loader if syntax is None else loader.with_syntax(syntax)
+    return _Snapshots(loader, entities=entities, syntax=syntax)
 
 
 _CELL_REF = re.compile(r"^(?P[QK]\d+)\.r(?P\d+)\.(?P.+)$")
@@ -1472,10 +1585,12 @@ def _resolve_entity(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Sna
         return _unresolved(marker, alias, ev_kind, _Reason(ENTITIES_OFF_REASON, ENTITIES_OFF_FIX))
     if not index.active:
         return _unresolved(marker, alias, ev_kind, LATER_REASON)
-    if not ref or not _IDENTIFIER.match(ref):
+    if not ref or not _entity_ref_ok(ref, snaps.syntax):
         return _unresolved(marker, alias, ev_kind, _Reason(
             "表名、字段名引用格式无法识别",
-            "表名、字段名要写成 [[t:表名]]、[[c:表名.字段名]],名字是字母或下划线开头的标识符"))
+            "表名、字段名要写成 [[t:表名]]、[[c:表名.字段名]],名字是字母或下划线开头的标识符" if snaps.syntax < 2
+            else "表名、字段名要写成 [[t:表名]]、[[c:表名.字段名]],名字以字母、汉字或下划线开头,只含字母、数字、"
+                 "汉字和下划线,不含空格、括号和全角字符"))
     hit = index.find(ref, ev_kind)
     if hit is None:
         if index.find(ref, other):
@@ -2205,12 +2320,12 @@ def _normalize_strong(raw: str) -> tuple[str, list[tuple[int, int]]]:
 _CODE_NAME = re.compile(r"^`([^`\n]+)`$")
 #: 复核时找反引号:粗体、链接里面的也算——组装时它们没切成片段(切开会拆坏粗体),违规照记
 _CODE_SPAN = re.compile(r"(? bool:
     """反引号里的这段是不是要核对的名字:像标识符、不止一个字、不是 SQL 关键字和常用函数。"""
-    return len(name) >= 2 and bool(_IDENTIFIER.match(name)) and name.lower() not in _SQL_WORDS
+    return len(name) >= 2 and bool(_ASCII_IDENTIFIER.match(name)) and name.lower() not in _SQL_WORDS
 
 
 def _bare_link(name: str, catalog: dict[str, Any], index: _EntityIndex) -> tuple[str, str] | None:
@@ -2484,8 +2599,9 @@ def compose_doc(
             for c in [*(s["cite"] for s in unit["segments"] if s.get("cite")), *unit["see"]]
             if c.get("status") == "resolved" and c.get("alias")}
     kept = {a: e for a, e in catalog.items() if e.get("kind") not in ENTITY_KINDS or a in used}
+    # entity_syntax:这份文档按哪一版实体语法组装,复核照它走(verify_doc)。在文档顶层,参与内容哈希
     doc: dict[str, Any] = {
-        "schema": DOC_SCHEMA, "run_id": run_id, "node_id": node_id,
+        "schema": DOC_SCHEMA, "entity_syntax": snaps.syntax, "run_id": run_id, "node_id": node_id,
         "markdown": markdown, "source": source, "catalog": kept, "blocks": out_blocks,
     }
     checked = verify_doc(doc, catalog, allow_numbers=allow_numbers, cells_allowed=cells_allowed, loader=snaps)
@@ -2624,13 +2740,18 @@ def verify_doc(
     - 引文重新取检索快照、重新逐字比对
     - 片段标的状态和核对结果一致(界面按状态画线,不能标着「有出处」其实没有)
 
+    表名、字段名按文档记下的实体语法版本解析(entity_syntax,没有这个字段的老文档按 1 版):老文档按
+    它生成时的规则复核,不能因为今天认得中文名了,就把当年「格式无法识别」的片段判成被改过。loader 是
+    _Snapshots 时也以文档为准。记了认不出的版本的,记 bad_schema,按当前版查。
+
     另外返回 uncited:没挂依据的结论句 [{unit, span, text}](按这次核对的引用算,不信文档里的 cites;
     自动链接的名字不算依据)。
 
     stats 里 entities、quotes、unknown_entities、unverified_entities 四个键只在目录里有表和字段、或者
     有知识库检索时才有(它们才可能不是 0):升级前的运行统计的键和以前一模一样。
     """
-    snaps = _snapshots(loader, entities=entities)
+    entity_syntax = doc_entity_syntax(doc)
+    snaps = _snapshots(loader, entities=entities, syntax=entity_syntax or ENTITY_SYNTAX)
     index = snaps.entities(catalog)
     violations: list[dict[str, Any]] = []
     uncited: list[dict[str, Any]] = []
@@ -2645,6 +2766,9 @@ def verify_doc(
         return {"ok": False, "violations": violations, "stats": stats, "uncited": uncited}
     if doc.get("schema") != DOC_SCHEMA:
         violations.append(_violation("bad_schema", f"报告文档格式「{doc.get('schema')}」无法识别"))
+    if entity_syntax is None:
+        violations.append(_violation("bad_schema",
+                                     f"报告文档记录的表名、字段名规则版本「{doc.get('entity_syntax')}」无法识别"))
 
     markdown = str(doc.get("markdown") or "")
     masks: list[tuple[int, int]] = []                  # 引用渲染出来的字
@@ -2791,6 +2915,12 @@ def verify_doc(
     return {"ok": not violations, "violations": violations, "stats": stats, "uncited": uncited}
 
 
+def doc_entity_syntax(doc: Any) -> int | None:
+    """文档按哪一版实体语法组装:没有 entity_syntax 字段的是老文档(1 版),记了认不出的值返回 None。"""
+    raw = doc.get("entity_syntax", 1) if isinstance(doc, dict) else 1
+    return raw if type(raw) is int and raw in ENTITY_SYNTAXES else None
+
+
 def _phase_two_cite(seg: dict[str, Any], ref: str, text: str) -> dict[str, Any] | None:
     """升级前(二期)组装的文档里的 t / c / q:那时一律判为解析不了,正文是占位,原因是 LEGACY_LATER_REASON。
 
@@ -2854,8 +2984,13 @@ CELL_RULES = (
 )
 
 #: 表名、字段名的写法。只在这次运行冻结了表结构(目录里有表和字段)时出现
+#: 只用于组装新文档的写作目录(catalog_prompt → 报告节点),新文档一律按当前的实体语法(ENTITY_SYNTAX)
+#: 组装和复核,所以这里写的就是当前版的规矩;老文档复核不看提示词。反引号里的名字只核对 ASCII 标识符
+#: (_checked_name,中文正文、地名放进反引号不能被判成编造的名字),所以中文名必须用 [[t:]] / [[c:]]:
+#: 照「放进反引号」写 `某表`,编造的中文表名就绕开了核对
 ENTITY_RULES = (
-    "表和字段(系统会核对):提到表名、字段名时写 [[t:表名]]、[[c:表名.字段名]],或者放进反引号(`表名`);"
+    "表和字段(系统会核对):提到表名、字段名时写 [[t:表名]]、[[c:表名.字段名]];英文的名字也可以放进反引号"
+    "(`orders`),中文的表名、字段名一律用 [[t:]] / [[c:]]——反引号里的中文名系统不核对;"
     "只写下面列出的、或者这次查过的数据源里真实存在的名字——本次运行的表结构快照、查询用到的表、"
     "查询结果列里都没有的名字,会被标成「可能是编造的名字」。\n"
     "说明一个数是怎么算出来的(按哪个字段去重、怎么汇总、筛了什么条件)时,句末挂 [[see:Q1]] 指向算出它的那次"
@@ -2957,7 +3092,7 @@ def catalog_prompt(catalog: dict[str, Any], *, budget: int = 12000, cells_allowe
         for e in queries:
             lines.extend(_query_prompt(e, snaps, cells_allowed))
     if (index := snaps.entities(catalog)).active:
-        lines.extend(_entity_prompt(catalog, partial=index.partial))
+        lines.extend(_entity_prompt(catalog, partial=index.partial, snaps=snaps))
     retrievals = [e for e in catalog.values() if e.get("kind") == "retrieval"]
     if retrievals:
         lines.append(f"\n{QUOTE_RULES}")
@@ -2969,8 +3104,38 @@ def catalog_prompt(catalog: dict[str, Any], *, budget: int = 12000, cells_allowe
     return text
 
 
-def _entity_prompt(catalog: dict[str, Any], *, partial: bool = False) -> list[str]:
-    """表和字段在写作目录里的样子:查询用到的表连同字段,查询结果里的列,别的表只列名字。"""
+def unshaped_table(entry: dict[str, Any], fetch: Callable[[Any], Any]) -> bool:
+    """目录里的这张表是不是按原样导入、未经规整的(冻结的表结构里它的 comment 是 tabular.UNSHAPED_NOTE)。
+
+    看的是这次运行冻结的表结构快照(条目 sources 里 kind 为 schema 的那几份),不看数据源现在的样子。
+    fetch 取工件内容,取不到给 None。写作目录和裁判摘录同一个口径。
+    """
+    name = entry.get("name") or (entry.get("locator") or {}).get("table")
+    for origin in entry.get("sources") or []:
+        if not (isinstance(origin, dict) and origin.get("kind") == "schema" and origin.get("artifact")):
+            continue
+        info = schema_table(fetch(origin["artifact"]), name)
+        if info is not None:
+            return info.get("comment") == UNSHAPED_NOTE
+    return False
+
+
+#: 写作目录里未规整的表名后面跟的那句。不含数字(H11)
+_UNSHAPED_MARK = "(按原样导入、未经规整:同一列里混有不同口径的行,不能直接对列求和)"
+
+
+def _entity_prompt(catalog: dict[str, Any], *, partial: bool = False, snaps: _Snapshots | None = None) -> list[str]:
+    """表和字段在写作目录里的样子:查询用到的表连同字段,查询结果里的列,别的表只列名字。
+
+    按原样导入、未经规整的表(上传时选了「按原样导入」)在名字后面写明不能直接对列求和:说明本来只在
+    db_schema 查单表时看得到,写报告的模型多半没看过(R4、D16)。
+    """
+    def fetch(artifact: Any) -> Any:
+        return snaps._fetch(artifact, "表结构快照")[0] if snaps is not None else None
+
+    def mark(entry: dict[str, Any]) -> str:
+        return _UNSHAPED_MARK if snaps is not None and unshaped_table(entry, fetch) else ""
+
     tables = [e for e in catalog.values() if e.get("kind") == "table"]
     fields: dict[str, list[str]] = {}
     loose: list[str] = []
@@ -2987,12 +3152,12 @@ def _entity_prompt(catalog: dict[str, Any], *, partial: bool = False) -> list[st
     for t in used[:_PROMPT_TABLES]:
         cols = fields.get(t["name"], [])
         more = f" 等 {len(cols)} 个" if len(cols) > _PROMPT_COLS else ""
-        lines.append(f"- {t['name']}({'、'.join(t['queries'])} 用到)" + (f":{', '.join(cols[:_PROMPT_COLS])}{more}"
-                                                                          if cols else ""))
+        lines.append(f"- {t['name']}({'、'.join(t['queries'])} 用到){mark(t)}"
+                     + (f":{', '.join(cols[:_PROMPT_COLS])}{more}" if cols else ""))
     if loose:
         more = f" 等 {len(loose)} 个" if len(loose) > _PROMPT_RESULT_COLS else ""
         lines.append(f"- 查询结果里的列:{', '.join(loose[:_PROMPT_RESULT_COLS])}{more}")
-    others = [str(t["name"]) for t in tables if not t.get("queries")]
+    others = [f"{t['name']}{mark(t)}" for t in tables if not t.get("queries")]
     if others:
         more = f" 等 {len(others)} 张" if len(others) > _PROMPT_OTHER_TABLES else ""
         lines.append(f"- 其他表:{'、'.join(others[:_PROMPT_OTHER_TABLES])}{more}")
diff --git a/backend/app/engine/judge.py b/backend/app/engine/judge.py
index e638e5e8..ae4d9ccc 100644
--- a/backend/app/engine/judge.py
+++ b/backend/app/engine/judge.py
@@ -43,6 +43,7 @@ from app.core.artifact_store import canonical_json, content_hash
 from app.core.artifact_store import load as load_artifact
 from app.core.errors import describe_exception, not_configured
 from app.core.events import EventType
+from app.data.tabular import UNSHAPED_NOTE
 from app.db.base import SessionLocal
 from app.db.models import Setting
 from app.engine.evidence import (
@@ -495,6 +496,9 @@ def _table_lines(entry: dict[str, Any], catalog: dict[str, Any], fetch: _Fetch,
                      + (f" | …另有 {more} 个字段" if more else ""))
         if masked_any:
             lines.append("(有的字段按数据源的设置遮罩了,没有列出)")
+        if info.get("comment") == UNSHAPED_NOTE:
+            # 上传时选了「按原样导入(未规整)」:对这张表的列直接求和得出的数,裁判要知道它不可靠(R4、D16)
+            lines.append(f"表的说明:{UNSHAPED_NOTE}")
     if truncated:
         lines.append("注意:表结构快照不完整(数据源的表太多,只保存了一部分),不能据此断定某张表或某个字段不存在")
     return lines
diff --git a/backend/app/engine/nodes/llm.py b/backend/app/engine/nodes/llm.py
index 5e1c0aea..f4ef715b 100644
--- a/backend/app/engine/nodes/llm.py
+++ b/backend/app/engine/nodes/llm.py
@@ -47,7 +47,7 @@ from app.providers.factory import (
     bind_tools_safely,
     get_chat_model,
 )
-from app.tools.datasource import QUERY_PREFIX
+from app.tools.datasource import QUERY_PREFIX, run_versions
 from app.tools.registry import (
     ToolArgsError,
     ToolBuildError,
@@ -333,6 +333,9 @@ async def _resolve_tools(ctx: NodeContext, state: GraphState) -> list[BaseTool]:
         sandbox_session=ctx.run.thread_id,
         memory_scope=ctx.cfg("memory_scope") or ctx.run.memory_scope,
         collection=ctx.cfg("collection") or ctx.run.collection,
+        data_versions=run_versions(ctx.run),
+        # 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late)
+        emit=ctx.emit,
     )
     async with SessionLocal() as session:
         try:
diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py
index 37a136cc..0a3bea51 100644
--- a/backend/app/engine/nodes/multi.py
+++ b/backend/app/engine/nodes/multi.py
@@ -27,6 +27,7 @@ from app.engine.toolcalls import (
 )
 from app.providers import catalog
 from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model
+from app.tools.datasource import run_versions
 from app.tools.registry import (
     ToolArgsError,
     ToolContext,
@@ -311,6 +312,9 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
                 sandbox_session=ctx.run.thread_id,
                 memory_scope=ctx.run.memory_scope,
                 collection=ctx.run.collection,
+                data_versions=run_versions(ctx.run),
+                # 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late)
+                emit=ctx.emit,
             )
             tools = await build_tools(cfg.get("tools", []) or [], tool_ctx, session=session)
 
diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py
index 41a04445..58c95886 100644
--- a/backend/app/engine/nodes/tools.py
+++ b/backend/app/engine/nodes/tools.py
@@ -18,7 +18,7 @@ from app.engine.state import GraphState
 from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounded
 from app.sandbox.base import SandboxLimits
 from app.sandbox.manager import sandbox_manager
-from app.tools.datasource import QUERY_PREFIX
+from app.tools.datasource import DATA_UNAVAILABLE, QUERY_PREFIX, SCHEMA_PREFIX, run_versions
 from app.tools.registry import (
     ToolArgsError,
     ToolBuildError,
@@ -31,8 +31,9 @@ from app.tools.registry import (
 from app.tools.trust import ask_gate, asks_trustable, call_policy, node_task
 
 #: 数据源查询工具没查成时交回的话的开头(tools/datasource.py)。agent 里原话喂回模型让它改 SQL;
-#: tool 节点没有下一轮可改,照常往下走的话,下游拿到的是一句报错当查询结果
-QUERY_FAILED = ("查询失败:", "SQL 被拒绝:")
+#: tool 节点没有下一轮可改,照常往下走的话,下游拿到的是一句报错当查询结果。
+#: DATA_UNAVAILABLE 是数据本身用不了(这次运行固定的版本没了、表格文件被删),改 SQL 没用
+QUERY_FAILED = ("查询失败:", "SQL 被拒绝:", DATA_UNAVAILABLE)
 #: code 节点在证据链里的角色:取数(产出本身就是源数据)还是计算。缺省按计算算
 EVIDENCE_ROLES = ("source", "compute")
 
@@ -44,6 +45,9 @@ def _tool_ctx(ctx: NodeContext) -> ToolContext:
         sandbox_session=ctx.run.thread_id,
         memory_scope=ctx.cfg("memory_scope") or ctx.run.memory_scope,
         collection=ctx.cfg("collection") or ctx.run.collection,
+        data_versions=run_versions(ctx.run),
+        # 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late)
+        emit=ctx.emit,
     )
 
 
@@ -156,11 +160,15 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
 
     elapsed = int((time.perf_counter() - started) * 1000)
     evidence_on = ledger_enabled(ctx.run)
-    if evidence_on and name.startswith(QUERY_PREFIX) and isinstance(result, str) and result.startswith(QUERY_FAILED):
+    # 查表结构的工具只在数据本身用不了时算失败:它别的回话(「结构信息不可用」之类)是正常的结果
+    failed = isinstance(result, str) and (
+        (name.startswith(QUERY_PREFIX) and result.startswith(QUERY_FAILED))
+        or (name.startswith(SCHEMA_PREFIX) and result.startswith(DATA_UNAVAILABLE)))
+    if evidence_on and failed:
         # 没查成:原话就是报错(「查询失败:no such table: x」),照写,别再套一层
         ctx.emit(EventType.TOOL_ERROR, tool=name, error=result[:2000], duration_ms=elapsed)
         if ctx.cfg("fail_fast", True):
-            raise NodeError(ctx.node.id, f"工具 {name} 查询失败:{result}。请据此修改节点中的 SQL 后重新运行")
+            raise NodeError(ctx.node.id, _query_failure(name, result))
         result = {"error": result}
 
     # 取数快照:query(args)和结果集一起进工件库,完整出具时数字回指的就是它
@@ -206,6 +214,18 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
     return updates
 
 
+def _query_failure(name: str, result: str) -> str:
+    """调用工具节点上查询没查成时的报错。
+
+    数据本身用不了(DATA_UNAVAILABLE):原因里已经写了怎么办(重新发起运行、重新上传),照交。
+    不能再加「修改节点中的 SQL」——改 SQL、继续运行都没用,只会再失败一次。SQL 的错才让人改 SQL。
+    原话开头的「查询失败:」去掉,免得和前面的「查询失败:」叠成两遍。
+    """
+    if result.startswith(DATA_UNAVAILABLE):
+        return f"工具 {name} 无法使用:{result.removeprefix(DATA_UNAVAILABLE)}"
+    return f"工具 {name} 查询失败:{result.removeprefix('查询失败:')}。请据此修改节点中的 SQL 后重新运行"
+
+
 async def run_code(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
     """沙箱代码节点。代码本身支持模板插值,可以把上游结果直接嵌进去。"""
     code = ctx.render_str(ctx.cfg("code", ""), state)
diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py
index 1392b0fc..d47639c8 100644
--- a/backend/app/engine/runner.py
+++ b/backend/app/engine/runner.py
@@ -25,6 +25,7 @@ from app.engine.compiler import compile_graph, initial_state
 from app.engine.context import NodeError, RunContext
 from app.engine.replay import PROTOCOL, PROTOCOL_KEY, protocol_of
 from app.engine.schema import GraphSpec, NodeType, loop_steps, topology_of, validate_graph
+from app.tools.datasource import RUN_VERSIONS_KEY
 
 logger = logging.getLogger(__name__)
 
@@ -188,29 +189,35 @@ class RunManager:
             approval_default = await _approval_default(run_class=run_class)
 
         async with SessionLocal() as session:
+            from app.data.table_versions import pin_lock
             from app.tools.trust import run_snapshot
 
             tool_trust = await run_snapshot(session, run_class=run_class)
             agent_limits = await _agent_limits(session)
-            run = Run(
-                workflow_id=workflow_id,
-                workflow_name=workflow_name,
-                status="queued",
-                graph=graph,
-                input=input_payload,
-                run_class=run_class,
-                version=version,
-                version_hash=version_hash,
-                started_by=started_by,
-                memory_scope=memory_scope,
-                collection=collection,
-                approval_default=approval_default,
-                tool_trust=tool_trust,
-                agent_limits=agent_limits,
-            )
-            run.thread_id = run.id  # 一个 run 一条 checkpoint 线程
-            session.add(run)
-            await session.commit()
+            # 读上传表格的当前指针到运行记录提交,持着版本存储的锁:中间有人重传的话,发布后的回收
+            # 看不到这条还没提交的运行,会把刚固定的快照删掉(table_versions.pin_lock)
+            async with pin_lock():
+                data_versions = await _data_versions(session, spec)
+                run = Run(
+                    workflow_id=workflow_id,
+                    workflow_name=workflow_name,
+                    status="queued",
+                    graph=graph,
+                    input=input_payload,
+                    run_class=run_class,
+                    version=version,
+                    version_hash=version_hash,
+                    started_by=started_by,
+                    memory_scope=memory_scope,
+                    collection=collection,
+                    approval_default=approval_default,
+                    tool_trust=tool_trust,
+                    agent_limits=agent_limits,
+                    data_versions=data_versions,
+                )
+                run.thread_id = run.id  # 一个 run 一条 checkpoint 线程
+                session.add(run)
+                await session.commit()
             await session.refresh(run)
 
         self._tasks[run.id] = asyncio.create_task(
@@ -224,6 +231,7 @@ class RunManager:
                 approval_default=approval_default,
                 tool_trust=tool_trust,
                 agent_limits=agent_limits,
+                data_versions=data_versions,
             )
         )
         return run
@@ -576,6 +584,7 @@ class RunManager:
         approval_default: str | None = None,
         tool_trust: dict[str, str] | None = None,
         agent_limits: dict[str, Any] | None = None,
+        data_versions: dict[str, Any] | None = None,
         resumed: list[str] | tuple[str, ...] = (),
         actors: dict[str, str | None] | None = None,
     ) -> None:
@@ -618,6 +627,9 @@ class RunManager:
                           "tool_trust": _trust_event(tool_trust),
                           # agent 护栏的上限(步数兜底、令牌 / 金额预算),None 是升级前的运行
                           "agent_limits": agent_limits,
+                          # 这次运行固定的上传表格版本(源 id → 快照 id、源名)。续跑、恢复记的是
+                          # 运行记录里的那份,含运行中途第一次用到时补上的;None 是升级前的运行
+                          "data_versions": data_versions,
                           # 这一段是按哪一版重放协议跑的,恢复时据此认出升级前做下的工作
                           "replay_protocol": PROTOCOL},
                 )
@@ -632,7 +644,11 @@ class RunManager:
                     approval_default=approval_default,
                     tool_trust=tool_trust,
                     agent_limits=agent_limits,
-                    extra={"resumed": set(resumed), "actors": dict(actors or {})},
+                    # 数据版本放 extra:数据源工具经 ToolContext.data_versions 拿到(tools/datasource.py
+                    # 的 run_versions)。复制一份:运行中途补固定的源就地写进这个 dict,同一段执行里
+                    # 后面的节点都看得到,但不该碰到别处(运行记录、事件)手上的那个对象
+                    extra={"resumed": set(resumed), "actors": dict(actors or {}),
+                           RUN_VERSIONS_KEY: dict(data_versions) if data_versions is not None else None},
                 )
                 app = compile_graph(spec, run_ctx).compile(checkpointer=self.checkpointer)
                 # 循环按自己声明的轮数另记一份预算,全局上限只管没人把关的环
@@ -1003,9 +1019,86 @@ def _carried(run: Run) -> dict[str, Any]:
         "approval_default": run.approval_default,
         "tool_trust": run.tool_trust,
         "agent_limits": run.agent_limits,
+        # 固定的数据版本照运行记录来,不重新读数据源的当前指针:中途重传过,续跑查的仍是发起时那一版
+        "data_versions": run.data_versions,
     }
 
 
+async def _data_versions(session: Any, spec: GraphSpec) -> dict[str, Any]:
+    """发起时固定这次运行用到的上传表格版本:{源 id: {"snapshot": 当前快照 id, "name": 源名}}。
+
+    和 tool_trust、agent_limits 一样在发起这一刻定下、存进运行记录:续跑、审批恢复沿用它,
+    不再读数据源的当前指针——运行中途有人重传,这次运行查的仍是发起时那一版。数据源工具按它
+    解析源(tools/datasource.py),回收按它保留快照(data/table_versions.gc)。
+
+    只算这张图实际用到的源:调用工具节点、Agent、协作成员绑的 db_query__ / db_schema__,连同
+    子工作流里绑的。没用到的源不固定,否则它们的旧版本会因为这次运行白白留着。手工源不分版本,
+    不进来。工具名里有模板({{ }})时,发起时说不准它会落到哪个源,就把所有启用的上传源都固定上
+    (多固定几个只是多留几个旧版本;漏掉一个,这次运行就可能前后查到两版数据)。
+
+    发起时算不到的(继续运行时改了节点配置、没固定版本的子工作流中途被改过、运行中途新建的源)
+    由数据源工具第一次用到时补上(tools/datasource.py 的 _pin_late,记进运行记录并发一条日志事件)。
+    没用到任何上传源时返回空 dict 而不是 None:None 留给升级前发起的运行(照旧用当前版本,不补固定)。
+    """
+    from app.db.models import DataSource
+
+    names, anything = await _bound_sources(session, spec)
+    if not names and not anything:
+        return {}
+    query = select(DataSource.id, DataSource.name, DataSource.current_snapshot_id).where(
+        DataSource.enabled.is_(True), DataSource.origin == "upload",
+        DataSource.current_snapshot_id.is_not(None),
+    ).order_by(DataSource.name)
+    if not anything:
+        query = query.where(DataSource.name.in_(sorted(names)))
+    rows = (await session.execute(query)).all()
+    return {sid: {"snapshot": snap, "name": name} for sid, name, snap in rows}
+
+
+async def _bound_sources(session: Any, spec: GraphSpec) -> tuple[set[str], bool]:
+    """图里(含子工作流)绑定的数据源工具指向的源名,以及有没有带模板、发起时定不下来的工具名。
+
+    绑定的取法和发起前检查同一个(api/runs.py 的 tool_bindings),子工作流照运行时的取法
+    读出来接着找:固定了版本的读那一版,没固定的读工作流现在的图。读不到、图不成形的跳过——
+    那个节点运行时自己会报错。同一个工作流(同一版)只读一次,互相引用也不会转圈。
+    """
+    from app.api.runs import node_setting, tool_bindings
+    from app.db.models import WorkflowVersion
+    from app.tools.datasource import source_of_tool
+
+    names: set[str] = set()
+    anything = False
+    seen: set[tuple[str, str]] = set()
+    queue: list[GraphSpec] = [spec]
+    while queue:
+        graph = queue.pop(0)
+        for _, tool in tool_bindings(graph):
+            if "{{" in tool:
+                anything = True
+            elif (source := source_of_tool(tool)) is not None:
+                names.add(source)
+        for node in graph.nodes:
+            if node.type != NodeType.SUBGRAPH:
+                continue
+            # 和 NodeContext.cfg 一样:节点上没写就取图级 defaults(tool_bindings 读工具也是这个规则)
+            wid, pinned = (node_setting(graph, node, k) for k in ("workflow_id", "workflow_version"))
+            key = (str(wid or ""), str(pinned or ""))
+            if not wid or key in seen:
+                continue
+            seen.add(key)
+            try:
+                # 只取图:Workflow 的 versions 是 selectin,整行读会把所有历史版本一起读出来
+                query = (select(WorkflowVersion.graph).where(
+                    WorkflowVersion.workflow_id == wid, WorkflowVersion.version == int(pinned))
+                    if pinned else select(Workflow.graph).where(Workflow.id == wid))
+                sub = (await session.execute(query)).scalar_one_or_none()
+                if sub:
+                    queue.append(GraphSpec.model_validate(sub))
+            except Exception:  # noqa: BLE001 - 读不到、不成形:子工作流节点运行时会说清楚
+                continue
+    return names, anything
+
+
 async def _agent_limits(session: Any) -> dict[str, Any]:
     from app.api.settings import run_defaults
     from app.engine.guards import run_limits
diff --git a/backend/app/main.py b/backend/app/main.py
index c2f9f8cf..09b046f2 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -28,6 +28,9 @@ logger = logging.getLogger("agentlab")
 
 @asynccontextmanager
 async def lifespan(_: FastAPI) -> AsyncIterator[None]:
+    from app.data.engine import sqlite_dqs_self_check
+
+    sqlite_dqs_self_check()  # 写错的双引号列名必须报错;做不到就记日志并让启动失败
     settings.ensure_dirs()
     await init_db()
     await seed_defaults()
@@ -57,6 +60,16 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
         )
         await _s.commit()
 
+    # 上传表格的版本底座:清理上次没发布完的文件、给老上传补初始版本、回收没人用的版本。
+    # 出错只记日志:最坏是留下几个多余的文件,不该让服务起不来
+    from app.data import table_versions
+
+    async with SessionLocal() as _s:
+        try:
+            await table_versions.startup(_s)
+        except Exception:  # noqa: BLE001
+            logger.exception("上传表格的版本整理失败,服务照常启动")
+
     await run_manager.setup()
     from app.sandbox.manager import sandbox_manager as _sbm
 
diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py
index 769accaa..34110cce 100644
--- a/backend/app/tools/datasource.py
+++ b/backend/app/tools/datasource.py
@@ -8,10 +8,20 @@
 - 权限边界跟着工具走:只读源生成的工具就是只读的,dangerous 标记也各自独立
 
 代价是工具数量随数据源线性增长。真到了几十个数据源的规模,再考虑按需装载。
+
+**上传表格按版本查。** 上传源每次重传是一个新快照(data/table_versions.py)。运行发起时把
+用到的上传源的当前快照固定下来(runs.data_versions),这里按它把源解析成绑定那个快照的视图:
+工具描述、表结构、冻结的表结构快照、查询,全部出自同一个版本——运行中途有人重传,这次运行
+看到的还是发起时那一版。固定的版本没了就明确报错,绝不改用当前版本。
 """
 from __future__ import annotations
 
+import asyncio
+import inspect
 import json
+import logging
+import weakref
+from pathlib import Path
 from typing import Any
 
 from langchain_core.tools import StructuredTool
@@ -19,14 +29,27 @@ from pydantic import BaseModel, Field
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from app.data import introspect
-from app.data.engine import masked_columns, query_timeout, run_query
+from app.core.events import EventType
+from app.data import introspect, table_versions
+from app.data.engine import SnapshotTampered, masked_columns, query_timeout, run_query
 from app.data.guard import QueryLimits, SqlRejected, is_write
+from app.data.tabular import UNSHAPED_NOTE
 from app.db.models import DataSource
-from app.tools.registry import ToolContext
+from app.tools.registry import ToolContext, Versions
+
+logger = logging.getLogger(__name__)
 
 QUERY_PREFIX = "db_query__"
 SCHEMA_PREFIX = "db_schema__"
+#: RunContext.extra 里放这次运行固定的数据版本的键:engine/runner.py 放进去,构造 ToolContext 的节点取出来
+RUN_VERSIONS_KEY = "data_versions"
+#: 数据源工具因为数据本身用不了(这次运行固定的版本没了、表格文件被删)而没执行时交回的话的开头。
+#: 和「查询失败:」分开:那种改了 SQL 能好,这种改 SQL 没用。调用工具节点据此判失败、原因照交,
+#: 不再让人去改 SQL(engine/nodes/tools.py)。仍以「查询失败」起头:Agent 里这句话是作为工具结果
+#: 交回的(tool.end),运行面板(frontend/src/run/decode.ts)按这个开头把那一步标成失败
+DATA_UNAVAILABLE = "查询失败(数据不可用):"
+#: 运行中途补固定一个源时发的那条日志事件的 code(_pin_late)
+LATE_PIN_CODE = "data_version_pinned"
 
 
 class _QueryArgs(BaseModel):
@@ -47,7 +70,36 @@ def tool_names(source: DataSource) -> list[str]:
     return [f"{QUERY_PREFIX}{source.name}", f"{SCHEMA_PREFIX}{source.name}"]
 
 
-def _no_schema_note(source: DataSource) -> str:
+def source_of_tool(name: str) -> str | None:
+    """db_query__<源名> / db_schema__<源名> → 源名;不是数据源工具返回 None。"""
+    for prefix in (QUERY_PREFIX, SCHEMA_PREFIX):
+        if name.startswith(prefix):
+            return name[len(prefix):]
+    return None
+
+
+def run_versions(run_ctx: Any) -> dict[str, Any] | Versions | None:
+    """一次运行固定的数据版本(runner 放在 RunContext.extra 里)。构造 ToolContext 时传进去。
+
+    runner 每段执行都放这个键,升级前发起的运行放 None。键不在,说明这个 RunContext 不是 runner
+    建的,或者往下传的时候丢了(例如子工作流复制 extra 时漏掉):交 Versions.UNSET,上传源据此
+    报错——不能当成 None 改用当前版本,那样漏传一处就会静默查到别的版本。
+    """
+    extra = getattr(run_ctx, "extra", None) or {}
+    return extra[RUN_VERSIONS_KEY] if RUN_VERSIONS_KEY in extra else Versions.UNSET
+
+
+def pinned_snapshot(entry: Any) -> str | None:
+    """runs.data_versions 里一个源的记录 → 快照 id。形状是 {"snapshot": id, "name": 源名},也认直接写 id 的。"""
+    if isinstance(entry, str):
+        return entry or None
+    if isinstance(entry, dict):
+        sid = entry.get("snapshot") or entry.get("snapshot_id")
+        return sid if isinstance(sid, str) and sid else None
+    return None
+
+
+def _no_schema_note(source: Any) -> str:
     """结构拿不到时,把实情和下一步一起交给 agent。
 
     以前这里只说"结构尚未探查,请在设置页里执行一次"。两个问题:它可能是错的
@@ -75,7 +127,14 @@ def _no_schema_note(source: DataSource) -> str:
     return note
 
 
-def _query_description(source: DataSource) -> str:
+def unshaped_tables(source: Any) -> list[str]:
+    """按原样导入、未经规整的表(表结构里的 comment 是 tabular.UNSHAPED_NOTE),按表结构里的顺序。"""
+    tables = (getattr(source, "schema_cache", None) or {}).get("tables") or {}
+    return [meta.get("qualified", name) for name, meta in tables.items()
+            if isinstance(meta, dict) and meta.get("comment") == UNSHAPED_NOTE]
+
+
+def _query_description(source: Any) -> str:
     tables = introspect.table_names(source)
     head = f"在数据源「{source.name}」上执行 SQL 查询。"
     if source.description:
@@ -85,6 +144,11 @@ def _query_description(source: DataSource) -> str:
         listed = "、".join(tables[:25])
         more = f" 等 {len(tables)} 张表" if len(tables) > 25 else ""
         head += f" 可用表:{listed}{more}。"
+        # 未规整的说明以前只在 db_schema 查单表时看得到:只绑了查询工具的 Agent、调用工具节点接报告的
+        # 链路都看不到,照样对列求和。工具描述里点名(不含数字,H11)
+        if raw := unshaped_tables(source):
+            shown = "、".join(raw[:10]) + (f" 等 {len(raw)} 张" if len(raw) > 10 else "")
+            head += f" 其中 {shown} 按原样导入、未经规整:同一列里混有不同口径的行,不能直接对列求和。"
         head += " 字段不确定时先查结构,不要猜字段名。"
     else:
         head += _no_schema_note(source)
@@ -94,7 +158,12 @@ def _query_description(source: DataSource) -> str:
 async def build_datasource_tools(
     names: list[str], ctx: ToolContext, session: AsyncSession
 ) -> list[StructuredTool]:
-    """按工具名反查数据源并构造工具。build_tools 的第四个来源。"""
+    """按工具名反查数据源并构造工具。build_tools 的第四个来源。
+
+    上传源先按 ctx.data_versions 解析成绑定快照的视图(见 _resolve),工具描述、表结构、
+    查询都按那个版本来。固定的版本找不到时建一个同名的替身(_unavailable_tool),
+    每次调用都说明原因,不拿当前版本顶上。
+    """
     wanted: dict[str, list[str]] = {}
     for name in names:
         for prefix in (QUERY_PREFIX, SCHEMA_PREFIX):
@@ -114,20 +183,220 @@ async def build_datasource_tools(
 
     tools: list[StructuredTool] = []
     for row in rows:
-        for prefix in wanted.get(row.name, []):
+        prefixes = wanted.get(row.name, [])
+        try:
+            source, fixed = await _resolve(session, row, ctx)
+        except table_versions.SnapshotMissing as e:
+            tools.extend(_unavailable_tool(row, prefix, str(e)) for prefix in prefixes)
+            continue
+        for prefix in prefixes:
             if prefix == QUERY_PREFIX:
-                tools.append(_make_query_tool(row, ctx))
+                tools.append(_make_query_tool(source, ctx, fixed=fixed))
             else:
-                tools.append(_make_schema_tool(row))
+                tools.append(_make_schema_tool(source, fixed=fixed))
     return tools
 
 
-def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool:
+# --------------------------------------------------------------------------
+# 上传表格的版本
+# --------------------------------------------------------------------------
+
+
+def _fixed_gone(name: str) -> str:
+    """这次运行固定的版本找不到时交回的原因。不提当前版本有什么:说了就等于改用了当前版本。"""
+    return (f"本次运行固定的数据版本已不存在(数据源「{name}」),不会改用当前版本。"
+            "如需使用当前数据,请重新发起运行")
+
+
+def _versions_lost(name: str) -> str:
+    """在运行里却没拿到固定的版本(构造 ToolContext 的地方漏传了)时交回的原因。"""
+    return (f"没有拿到本次运行固定的数据版本(数据源「{name}」),为免前后查到两版数据,不会改用当前版本。"
+            "这是系统内部的问题,请联系管理员")
+
+
+def _rebuilt(name: str) -> str:
+    """这次运行固定的源被删掉、又用同一个名字新建了一个时交回的原因。"""
+    return (f"本次运行固定的数据源「{name}」已被删除或重建,不会改用同名的新数据源。"
+            "如需使用现在的数据,请重新发起运行")
+
+
+def _pinned_under_name(pins: dict[str, Any], name: str) -> bool:
+    """data_versions 里有没有以这个名字固定的源(记录里带 name 的才比得出来;老的直接写 id 的不算)。"""
+    return any(isinstance(entry, dict) and entry.get("name") == name for entry in pins.values())
+
+
+async def _resolve(session: AsyncSession, row: DataSource, ctx: ToolContext) -> tuple[Any, bool]:
+    """查询该用的源,以及它的版本是不是这次运行固定的。
+
+    - 手工源原样返回(不分版本,也不进 data_versions)。
+    - 不在运行里(工具库试用,Versions.CURRENT)、升级前发起的运行(None):当前版本。
+    - 谁都没说用哪一版(Versions.UNSET,在运行里漏传了):抛 SnapshotMissing,不退回当前版本。
+    - 运行里固定了:只认那个快照。不属于这个源、已回收、文件不在,都抛 SnapshotMissing,
+      绝不退回当前版本——运行前后两段查的必须是同一版数据,报告里的数字才说得清来历。
+    - 运行里还没固定:第一次用到时固定当前版本并记进运行(_pin_late)。
+    - 运行里固定过一个同名、id 不同的源(固定的那个被删了,又用同一个名字新建了一个):抛
+      SnapshotMissing,不改用同名的新源,也不当成「运行中途新建的源」去补固定。工具是按名字反查
+      源的,不比对的话续跑会悄悄查到另一份数据。这一步在「手工源原样返回」之前:同名重建成手工源
+      的也要拦住。
+    """
+    pins = ctx.data_versions
+    if isinstance(pins, dict) and row.id not in pins and _pinned_under_name(pins, row.name):
+        raise table_versions.SnapshotMissing(_rebuilt(row.name))
+    if (getattr(row, "origin", None) or "manual") != "upload":
+        return row, False
+    if pins is None or pins is Versions.CURRENT:
+        return await table_versions.resolve_source(session, row), False
+    if not isinstance(pins, dict):
+        logger.error("数据源 %s 的工具没拿到用哪一版(运行 %s 节点 %s 的 ToolContext.data_versions=%r):"
+                     "构造 ToolContext 的地方漏传了 run_versions", row.name, ctx.run_id, ctx.node_id, pins)
+        raise table_versions.SnapshotMissing(_versions_lost(row.name))
+    sid = pinned_snapshot(pins.get(row.id))
+    if sid is None:
+        sid = await _pin_late(row, ctx)
+    try:
+        return await table_versions.resolve_source(session, row, sid), True
+    except table_versions.SnapshotMissing as e:
+        raise table_versions.SnapshotMissing(_fixed_gone(row.name)) from e
+
+
+_late_locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]" = weakref.WeakKeyDictionary()
+
+
+def _late_lock() -> asyncio.Lock:
+    """运行中途固定版本时用的锁,按事件循环各一把(asyncio.Lock 不能跨循环用)。
+
+    并行的两个分支可能同时第一次用到同一个源:不串行的话,中间恰好有人重传,两边会各自
+    固定一个版本。
+    """
+    loop = asyncio.get_running_loop()
+    lock = _late_locks.get(loop)
+    if lock is None:
+        lock = _late_locks[loop] = asyncio.Lock()
+    return lock
+
+
+async def _pin_late(row: DataSource, ctx: ToolContext) -> str:
+    """发起时没固定到的上传源:第一次用到时固定当前版本,记进 runs.data_versions,返回快照 id。
+
+    发起时按图里绑定的工具算(engine/runner.py),有几种情形算不到:继续运行时改了节点配置、
+    改绑了别的数据源;子工作流没固定版本、运行中途被改过;运行中途才新建或启用的源。
+    它们不能每次都取当前版本——中途有人重传,同一次运行前后就查了两版数据。所以第一次用到时
+    固定下来:同一段执行里后面的节点看同一个 dict(ctx.data_versions,就地写入),续跑、恢复
+    从运行记录里读回来;回收也因为运行记录里有它而不删这个快照。记录带 late 标记,说明它不在
+    发起时的 run.started 里。
+
+    补固定这件事本身发一条日志事件(code 为 LATE_PIN_CODE,带源 id、快照 id):当次这一段的
+    run.started 里没有这个源,不发的话事件流里就完全没有它,封存清单(对事件做哈希)也记不到。
+    之后续跑、恢复开出的新一段 run.started 会从运行记录里带上它。
+    """
+    from app.db.base import SessionLocal
+    from app.db.models import Run
+
+    pins = ctx.data_versions
+    assert isinstance(pins, dict)   # _resolve 只在拿到运行固定的 dict 时才走到这里
+    created = False
+    async with _late_lock():
+        if sid := pinned_snapshot(pins.get(row.id)):
+            return sid      # 同一段执行里别的节点刚固定过
+        # 读当前指针到写进运行记录,持着版本存储的锁(table_versions.pin_lock):中间有人重传的话,
+        # 发布后的回收看不到这次补固定,会把刚读到的快照删掉。指针在锁里重新读:row 是建工具前读的,
+        # 那之后可能已经换过版本、旧的已经回收
+        async with table_versions.pin_lock(), SessionLocal() as session:
+            run = await session.get(Run, ctx.run_id) if ctx.run_id else None
+            stored = dict((run.data_versions if run is not None else None) or {})
+            entry = stored.get(row.id)
+            # 运行记录里已经有(上一段执行补固定的,这一段的内存里还没有):认运行记录的
+            sid = pinned_snapshot(entry)
+            if sid is None:
+                sid = (await session.execute(
+                    select(DataSource.current_snapshot_id).where(DataSource.id == row.id)
+                )).scalar_one_or_none()
+                if not sid:
+                    raise table_versions.SnapshotMissing(f"上传的表格「{row.name}」没有可用的版本,请重新上传")
+                entry = {"snapshot": sid, "name": row.name, "late": True}
+                created = True
+                if run is not None:
+                    stored[row.id] = entry
+                    run.data_versions = stored
+                    await session.commit()
+        pins[row.id] = entry
+    if created:
+        await _announce(ctx, EventType.LOG, level="info", code=LATE_PIN_CODE,
+                        message=f"数据源「{row.name}」发起时没有固定版本,本次运行第一次用到它时固定为当时的版本,"
+                                "之后继续运行、审批恢复都沿用这一版",
+                        source_id=row.id, source=row.name, snapshot=sid, late=True)
+    # 一定是非空的快照 id:交给 resolve_source 的空值会被当成「没固定」,退回当前指针
+    return sid
+
+
+async def _announce(ctx: ToolContext, event_type: EventType, **data: Any) -> None:
+    """经 ToolContext.emit 往运行的事件流里发一条事件;没给 emit(不在运行里)就不发。发不出去不影响查询。"""
+    if ctx.emit is None:
+        return
+    try:
+        out = ctx.emit(event_type, **data)
+        if inspect.isawaitable(out):
+            await out
+    except Exception:  # noqa: BLE001 - 事件只是留痕,固定本身已经记进运行记录
+        logger.warning("补固定数据版本的事件没发出去(运行 %s)", ctx.run_id, exc_info=True)
+
+
+def _vanished(source: Any, fixed: bool) -> str | None:
+    """绑定的快照文件在建工具之后没了(被手工删掉):返回原因,否则 None。
+
+    建工具时 resolve_source 查过一次,但工具建好到调用之间隔着模型思考、审批等待。缓存的连接
+    还开着已删除的文件,照样能查出旧数据——那份数据已经无从核对,不如明说。
+    """
+    if not getattr(source, "snapshot_id", None) or Path(source.database or "").is_file():
+        return None
+    return _fixed_gone(source.name) if fixed else f"上传的表格「{source.name}」当前版本的数据文件已不存在,请重新上传"
+
+
+def _unavailable_tool(row: DataSource, prefix: str, reason: str) -> StructuredTool:
+    """数据版本找不到时的替身:名字、参数和正常工具一样,描述和每次调用都说明原因。
+
+    不能干脆不建:节点会报「找不到工具」,看不出是数据版本的问题。也不能拿当前版本顶上。
+    描述里不列表名:表名是当前版本的,交给模型就等于改用了当前版本。
+    """
+    if prefix == QUERY_PREFIX:
+        async def _query(sql: str, limit: int | None = None) -> str:
+            # DATA_UNAVAILABLE 开头:调用工具节点据此判失败、原因照交,不让人去改 SQL(engine/nodes/tools.py)
+            return f"{DATA_UNAVAILABLE}{reason}"
+
+        return StructuredTool(
+            name=f"{QUERY_PREFIX}{row.name}",
+            description=f"在数据源「{row.name}」上执行 SQL 查询。目前无法使用:{reason}。",
+            args_schema=_QueryArgs, coroutine=_query, func=None,
+            metadata={"dangerous_if": lambda args: False, "timeout_s": query_timeout(row)},
+        )
+
+    async def _schema(table: str | list[str] | None = None) -> str:
+        return f"{DATA_UNAVAILABLE}{reason}"
+
+    return StructuredTool(
+        name=f"{SCHEMA_PREFIX}{row.name}",
+        description=f"查看数据源「{row.name}」的表结构。目前无法使用:{reason}。",
+        args_schema=_SchemaArgs, coroutine=_schema, func=None,
+    )
+
+
+# --------------------------------------------------------------------------
+# 工具
+# --------------------------------------------------------------------------
+
+
+def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False) -> StructuredTool:
+    """source 是手工源的 DataSource,或上传源绑定快照的 SourceView(字段同名)。
+
+    fixed:上传源的版本是这次运行固定的(找不到时的说法不同)。
+    """
     # 数据源在 options.query_timeout_s 里配了就用它,否则取缺省。数据库按它停下语句,
     # 引擎按它告诉界面上限是多少(metadata["timeout_s"])
     seconds = query_timeout(source)
 
     async def _run(sql: str, limit: int | None = None) -> str:
+        if gone := _vanished(source, fixed):
+            return f"{DATA_UNAVAILABLE}{gone}"
         limits = QueryLimits(timeout_seconds=seconds, **({"max_rows": int(limit)} if limit else {}))
         try:
             result = await run_query(source, sql, limits=limits)
@@ -135,6 +404,11 @@ def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool:
             # 被守卫拒掉要把原因原样交回模型——它据此改写 SQL 重试,
             # 给一句笼统的"失败了"只会让它瞎猜
             return f"SQL 被拒绝:{e}"
+        except SnapshotTampered as e:
+            # 快照文件和登记的哈希对不上、读不出来:数据本身用不了,改 SQL 没用。按 DATA_UNAVAILABLE
+            # 交回,调用工具节点据此判失败、原因照交,不让人去改 SQL;Agent 也不会当成 SQL 写错去重写。
+            # 下一步只说找管理员:同一个文件重传是同一个构建,会撞上同一个被改过的文件
+            return f"{DATA_UNAVAILABLE}数据源「{source.name}」的{e}。请联系管理员检查数据目录"
         except Exception as e:  # noqa: BLE001
             from app.core.errors import explain, first_line
 
@@ -145,19 +419,25 @@ def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool:
         payload = result.to_payload()
         payload["source"] = source.name
         # 表结构快照:这次查询时数据源的结构冻结下来,报告里写的表名、字段名按它核对。事后数据源
-        # 重新探查、改了结构,已经跑完的运行核对的还是当时那一份
+        # 重新探查、改了结构,已经跑完的运行核对的还是当时那一份。上传源冻结的是绑定快照里那份
         if schema := await _store_schema(source, ctx):
             payload["schema_artifact"] = schema
         # 查询快照进工件库:出具体系的数字回指要能下钻到"这个数是哪条 SQL 查出来的"
         try:
             from app.core.artifact_store import put_json
 
-            # 查询当时数据源设的遮罩列一起记进快照:数据源事后改名、删掉,证据面板仍按当时的遮。
-            # 只进快照,不进交给模型的结果(模型看到的是原值,多个字段只会让它以为数据被遮了);
-            # 没设遮罩的快照和原来一字不差
-            mask = masked_columns(source.options)
+            # 只进快照、不进交给模型的结果的两样(模型看到多余的字段只会误会:以为数据被遮了,
+            # 或者把一串十六进制抄进报告):
+            # - 查询当时数据源设的遮罩列:数据源事后改名、删掉,证据面板仍按当时的遮;
+            # - data_version:上传表格查的是哪个快照,事后据此认得出这个数出自哪一版数据。
+            # 手工源、没设遮罩的,快照和原来一字不差
+            extra: dict[str, Any] = {}
+            if mask := masked_columns(source.options):
+                extra["mask_columns"] = mask
+            if version := getattr(source, "snapshot_id", None):
+                extra["data_version"] = version
             payload["artifact"] = await put_json(
-                {**payload, "mask_columns": mask} if mask else payload, kind="query_snapshot",
+                {**payload, **extra} if extra else payload, kind="query_snapshot",
                 run_id=ctx.run_id or "", node_id=ctx.node_id or "",
             )
         except Exception:  # noqa: BLE001 - 存不下不影响查询本身
@@ -176,9 +456,12 @@ def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool:
     )
 
 
-async def _store_schema(source: DataSource, ctx: ToolContext) -> str | None:
+async def _store_schema(source: Any, ctx: ToolContext) -> str | None:
     """把数据源此刻的 schema_cache 存成 schema_snapshot 工件,返回工件 id。
 
+    上传源传进来的是绑定快照的视图,schema_cache 就是快照里冻结的那份:查的是哪一版,冻结的
+    就是哪一版的结构,不会是重传之后数据源上的新结构。
+
     内容寻址:结构没变的话,同一次运行里查多少次都是同一件,文件只有一份。没探查过结构
     (或者探查失败、一张表都没有)返回 None:没有东西可以冻结,报告也就不核对表名。
     """
@@ -198,8 +481,11 @@ async def _store_schema(source: DataSource, ctx: ToolContext) -> str | None:
         return None
 
 
-def _make_schema_tool(source: DataSource) -> StructuredTool:
+def _make_schema_tool(source: Any, *, fixed: bool = False) -> StructuredTool:
+    """source 同 _make_query_tool:上传源的表结构取绑定快照里冻结的那份。"""
     async def _run(table: str | list[str] | None = None) -> str:
+        if gone := _vanished(source, fixed):
+            return f"{DATA_UNAVAILABLE}{gone}"
         wanted = [table] if isinstance(table, str) else list(table or [])
         # 逗号分隔也认:"user, roles" 和 ["user","roles"] 是同一个意思,
         # 而模型两种都会写。为此拒绝一次调用,纯属浪费一步
@@ -210,8 +496,9 @@ def _make_schema_tool(source: DataSource) -> StructuredTool:
         if not tables:
             # 把实情交出去,而不是让它去点一个它点不到的按钮
             return f"数据源「{source.name}」的结构信息不可用。" + _no_schema_note(source)
+        raw = set(unshaped_tables(source))
         return f"数据源「{source.name}」共 {len(tables)} 张表:\n" + "\n".join(
-            f"  {n}" for n in tables
+            f"  {n}" + ("(按原样导入、未经规整,不能直接对列求和)" if n in raw else "") for n in tables
         )
 
     return StructuredTool(
@@ -227,7 +514,7 @@ def _make_schema_tool(source: DataSource) -> StructuredTool:
     )
 
 
-def is_dangerous_call(source: DataSource, sql: str) -> bool:
+def is_dangerous_call(source: Any, sql: str) -> bool:
     """这次调用要不要走人工审批:非只读源上的写操作要。
 
     只读源上的写不需要审批——守卫和连接层都会拒,批了也写不进去。
diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py
index 5f8db0f0..aaddb729 100644
--- a/backend/app/tools/registry.py
+++ b/backend/app/tools/registry.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import inspect
 import json
 from dataclasses import dataclass, field
+from enum import Enum
 from typing import Any, Awaitable, Callable
 
 from langchain_core.tools import BaseTool, StructuredTool
@@ -11,6 +12,16 @@ from pydantic import BaseModel, ValidationError
 ToolFunc = Callable[..., Awaitable[Any]]
 
 
+class Versions(Enum):
+    """ToolContext.data_versions 除了运行固定的那个 dict 之外的两个取值。"""
+
+    #: 不在运行里(工具库试用、试用前的副作用检查):用数据源的当前版本。这类调用要显式传它
+    CURRENT = "current"
+    #: 缺省值:构造 ToolContext 的地方没说用哪一版。在运行里漏传了固定的版本就是这样——上传源遇到它
+    #: 报错,不退回当前版本:静默改用当前版本,同一次运行就可能前后查到两版数据而没人察觉
+    UNSET = "unset"
+
+
 @dataclass
 class ToolContext:
     """一次调用里工具能用到的运行期依赖。"""
@@ -20,7 +31,14 @@ class ToolContext:
     sandbox_session: str = "default"
     memory_scope: str = "default"
     collection: str = "default"
-    emit: Callable[..., Awaitable[None]] | None = None
+    #: 往运行的事件流里发一条事件:节点传 NodeContext.emit(同步);协程函数也认,调用方 await 它的返回值
+    emit: Callable[..., Any] | None = None
+    #: 用哪一版上传表格。运行里是固定的版本(runs.data_versions:{源 id: {"snapshot": 快照 id, "name": 源名}}),
+    #: 由构造 ToolContext 的节点从运行上下文取来(tools/datasource.py 的 run_versions);同一段执行里各节点
+    #: 拿到的是同一个 dict,运行中途第一次用到的源固定下来后,后面的节点都看得到。
+    #: None 是升级前发起的运行,用当前版本;Versions.CURRENT 是不在运行里的调用,用当前版本;
+    #: 缺省 Versions.UNSET 是谁都没给——上传源遇到它报错,见 Versions
+    data_versions: dict[str, Any] | Versions | None = Versions.UNSET
     extra: dict[str, Any] = field(default_factory=dict)
 
 
diff --git a/backend/tests/test_datasources_upload.py b/backend/tests/test_datasources_upload.py
new file mode 100644
index 00000000..23ca0cd4
--- /dev/null
+++ b/backend/tests/test_datasources_upload.py
@@ -0,0 +1,829 @@
+"""上传表格的接口:发布成版本、要用户先做决定时回 422、不可变的强制、导入记录与清除原件。
+
+库层面的发布、回收、迁移在 test_table_versions.py;这里看接口的形状和限制。解析器替身、
+数据目录隔离两个夹具从那边拿(autouse,导入即生效)。
+"""
+from __future__ import annotations
+
+import os
+import stat
+import uuid
+from pathlib import Path
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy import select
+
+from app.core.config import settings
+from app.data import raw_store, table_versions, tabular
+from app.data.engine import engines
+from app.db.base import SessionLocal
+from app.db.models import DataSource, SourceSnapshot, TableImport
+from app.main import app
+from tests.test_table_versions import (  # noqa: F401 - parser、store 是 autouse 夹具
+    V1, V2, fresh, imports_of, parser, scalar, store, unique, xlsx,
+)
+
+XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+
+
+@pytest.fixture
+async def client():
+    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+        yield c
+
+
+async def upload(client, name, content, filename="客流.xlsx", **form):
+    return await client.post(
+        "/api/datasources/upload",
+        files={"file": (filename, content, XLSX)},
+        data={"name": name, **{k: str(v).lower() if isinstance(v, bool) else str(v) for k, v in form.items()}},
+    )
+
+
+async def by_name(name: str) -> DataSource | None:
+    async with SessionLocal() as session:
+        return (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
+
+
+async def resolve(source_id: str, pinned: str | None = None):
+    async with SessionLocal() as session:
+        row = await session.get(DataSource, source_id)
+        return await table_versions.resolve_source(session, row, pinned)
+
+
+# --------------------------------------------------------------------------
+# 上传
+# --------------------------------------------------------------------------
+
+
+async def test_upload_response_carries_the_receipt(client):
+    name = unique()
+    resp = await upload(client, name, xlsx(V1))
+    assert resp.status_code == 201, resp.text
+    body = resp.json()
+    assert body["replaced"] is False and body["build_reused"] is False
+    for key in ("import_id", "snapshot_id", "skipped_sheets", "conversions", "warnings"):
+        assert key in body
+    src = body["source"]
+    assert src["origin"] == "upload" and src["kind"] == "sqlite" and src["readonly"] is True
+    assert src["current_snapshot"]["id"] == body["snapshot_id"]
+    assert src["current_snapshot"]["file_name"] == "客流.xlsx"
+    assert src["current_snapshot"]["raw_state"] == "kept"
+    assert src["table_count"] == 2
+    table = next(t for t in body["tables"] if t["name"] == "甲表")
+    assert table["sheet"] == "甲表" and table["rows"] == 2 and table["unshaped"] is False
+    assert {c["name"]: c["type"] for c in table["columns"]} == {"区域": "TEXT", "数量": "INTEGER"}
+    assert [c["header"] for c in table["columns"]] == ["区域", "数量"]
+    for key in ("region", "blank_rows_skipped", "columns_trimmed"):
+        assert key in table
+    row = await by_name(name)
+    assert await scalar(await resolve(row.id), 'SELECT SUM("数量") FROM "甲表"') == 3
+
+
+async def test_reupload_switches_version_and_keeps_name_and_id(client):
+    name = unique()
+    first = (await upload(client, name, xlsx(V1))).json()
+    again = await upload(client, name, xlsx(V2), description="新的说明")
+    assert again.status_code == 201, again.text
+    body = again.json()
+    assert body["replaced"] is True
+    assert body["source"]["id"] == first["source"]["id"] and body["source"]["name"] == name
+    assert body["snapshot_id"] != first["snapshot_id"]
+    assert body["source"]["description"] == "新的说明"
+    view = await resolve(body["source"]["id"])
+    assert await scalar(view, 'SELECT SUM("数量") FROM "甲表"') == 100
+
+
+async def test_same_file_twice_reuses_the_build(client):
+    name, raw = unique(), xlsx(V1)
+    first = (await upload(client, name, raw)).json()
+    second = (await upload(client, name, raw)).json()
+    assert second["build_reused"] is True and second["import_id"] != first["import_id"]
+    assert len(await imports_of(first["source"]["id"])) == 2
+
+
+async def test_a_failed_reupload_leaves_the_old_version_queryable(client, store):
+    name = unique()
+    first = (await upload(client, name, xlsx(V1))).json()
+    before = sorted(p for p in (store / "uploads").rglob("*") if p.is_file())
+    resp = await upload(client, name, b"not really an xlsx")
+    assert resp.status_code == 400
+    assert sorted(p for p in (store / "uploads").rglob("*") if p.is_file()) == before
+    row = await by_name(name)
+    assert row.current_snapshot_id == first["snapshot_id"]
+    view = await resolve(row.id)
+    assert await scalar(view, 'SELECT SUM("数量") FROM "甲表"') == 3
+    assert await scalar(view, 'SELECT SUM("客流") FROM "乙表"') == 30
+
+
+def _front_accepts(decision: dict) -> bool:
+    """照 frontend/src/api/client.ts 的 uploadDecision 逐项校验:形状对不上,界面就当普通报错弹一句话,
+    不画选择页。再加上 UploadDecisionBody 画的时候读的字段(types.ts 的 UploadMixedColumn、UploadShapeReason)。"""
+    details = decision.get("details")
+    if not isinstance(details, dict):
+        return False
+    if decision.get("kind") == "mixed":
+        cols = details.get("columns")
+        return isinstance(cols, list) and bool(cols) and all(
+            isinstance(c, dict) and isinstance(c.get("column"), str) and isinstance(c.get("values"), list)
+            and isinstance(c.get("sheet"), str) and isinstance(c.get("table"), str)
+            and isinstance(c.get("header"), str)
+            and isinstance(c.get("numeric"), int) and isinstance(c.get("nonnumeric"), int)
+            and all(isinstance(v, dict) and set(v) == {"value", "count"} and isinstance(v["count"], int)
+                    for v in c["values"])
+            for c in cols)
+    if decision.get("kind") == "shape":
+        reasons = details.get("reasons")
+        if "mixed" in details and not _front_accepts({"kind": "mixed", "details": {"columns": details["mixed"]}}):
+            return False        # 预告的混合列和第二轮的混合列同一个形状
+        return isinstance(reasons, list) and bool(reasons) and all(
+            isinstance(r, dict) and isinstance(r.get("message"), str) and isinstance(r.get("kind"), str)
+            and isinstance(r.get("sheet"), str) and isinstance(r.get("cells"), list)
+            and all(isinstance(c, str) for c in r["cells"])
+            for r in reasons)
+    return False
+
+
+async def test_needs_decision_is_a_422_with_the_choice(client, store):
+    """真解析器走接口:两种决定的 422 形状都是前端认得的(以前这里用替身,values 是 dict,前端会拒收)。"""
+    from tests.test_table_versions import REAL_PARSER, stub_load_into
+
+    assert REAL_PARSER and tabular.load_into is not stub_load_into      # 走的是真解析器,不是替身
+    # 数字列混入非数字
+    mixed = xlsx({"明细": [["地区", "销量"]] + [[f"分区{i}", i * 10] for i in range(1, 10)]
+                  + [["分区x", "N/A"], ["分区y", "·"]]})
+    name = unique()
+    resp = await upload(client, name, mixed)
+    assert resp.status_code == 422, resp.text
+    body = resp.json()
+    assert "混有非数字的值" in body["detail"]
+    decision = body["decision"]
+    assert decision["kind"] == "mixed" and _front_accepts(decision), decision
+    [col] = decision["details"]["columns"]
+    assert (col["sheet"], col["table"], col["column"], col["header"]) == ("明细", "明细", "销量", "销量")
+    assert (col["numeric"], col["nonnumeric"]) == (9, 2)
+    assert col["values"] == [{"value": "N/A", "count": 1}, {"value": "·", "count": 1}]
+    # 交叉表、多块结构:上下叠了两张表,中间一行分段标题
+    stacked = xlsx({"汇总": [["项目", "金额"], ["分区甲", 10], ["分区乙", 20], ["二、费用", None],
+                             ["分区丙", 5], ["分区丁", 7]]})
+    resp = await upload(client, name, stacked)
+    assert resp.status_code == 422, resp.text
+    decision = resp.json()["decision"]
+    assert decision["kind"] == "shape" and _front_accepts(decision), decision
+    [reason] = decision["details"]["reasons"]
+    assert (reason["sheet"], reason["kind"], reason["cells"]) == ("汇总", "section_title", ["A4"])
+    # 被拒收的上传:不建源、不留原件
+    assert await by_name(name) is None
+    assert not [p for p in (store / "uploads").rglob("*") if p.is_file()]
+
+
+async def test_crosstab_is_refused_with_every_reason_on_the_first_try(client, store):
+    """仿客流表的合成交叉表:第一次退回就列出结构原因(日期横排且没写年份、分段标题、表内合计)和要预告的
+    占位符「·」;选「按原样导入」重传,退回的混合列正是预告的那些;再选存空值,导入成功、标未规整。"""
+    from tests.test_tabular import crosstab
+
+    name, raw = unique(), crosstab()
+    resp = await upload(client, name, raw, header_row=4)
+    assert resp.status_code == 422, resp.text
+    decision = resp.json()["decision"]
+    assert decision["kind"] == "shape" and _front_accepts(decision), decision
+    kinds = [r["kind"] for r in decision["details"]["reasons"]]
+    assert kinds == ["date_header", "section_title", "formula_above"]
+    assert decision["details"]["reasons"][0]["no_year"] is True
+    preview = decision["details"]["mixed"]
+    assert {v["value"] for c in preview for v in c["values"]} == {"·"}
+    assert "「·」" in resp.json()["detail"]
+
+    second = await upload(client, name, raw, header_row=4, raw_mode=True)
+    assert second.status_code == 422
+    assert second.json()["decision"]["kind"] == "mixed"
+    assert second.json()["decision"]["details"]["columns"] == preview
+
+    done = await upload(client, name, raw, header_row=4, raw_mode=True, mixed="null")
+    assert done.status_code == 201, done.text
+    assert done.json()["tables"][0]["unshaped"] is True
+
+
+def _empty_dirs(root: Path) -> list[Path]:
+    """上传目录下空着的子目录。tables/、raw/ 这两个根目录本身不算。"""
+    roots = {root / "tables", root / "raw"}
+    return sorted(p for p in root.rglob("*") if p.is_dir() and p not in roots and not any(p.iterdir()))
+
+
+async def test_rejected_uploads_of_new_names_leave_no_empty_directories(client, store):
+    """新名字的上传被拒(打不开、表头行号不对、要先做决定):不建源,也不留下 tables/<源 id>/builds/ 空目录。
+    「422 → 用户选择 → 重传」每一轮都是一个新 id,以前每轮多一个空目录,重启也不清。"""
+    uploads = store / "uploads"
+    mixed = xlsx({"明细": [["地区", "销量"]] + [[f"分区{i}", i] for i in range(1, 10)] + [["分区x", "缺"]]})
+    attempts = [(b"not an xlsx", {}, 400), (xlsx(V1), {"header_row": 500}, 400), (mixed, {}, 422)]
+    for raw, form, status in attempts:
+        name = unique()
+        resp = await upload(client, name, raw, **form)
+        assert resp.status_code == status, resp.text
+        assert await by_name(name) is None
+        assert _empty_dirs(uploads) == [], _empty_dirs(uploads)
+    # 已有的源重传被拒:它的目录里有当前版本,原样留着
+    name = unique()
+    assert (await upload(client, name, xlsx(V1))).status_code == 201
+    assert (await upload(client, name, b"broken")).status_code == 400
+    assert _empty_dirs(uploads) == []
+
+    # 以前留下的空目录(升级前被拒收的上传、清除原件后的前缀目录),启动时清掉
+    leftovers = [uploads / "tables" / uuid.uuid4().hex / "builds", uploads / "raw" / "ab"]
+    for folder in leftovers:
+        folder.mkdir(parents=True)
+    async with SessionLocal() as session:
+        await table_versions.startup(session)
+    assert _empty_dirs(uploads) == []
+    assert (await by_name(name)).current_snapshot_id      # 正常的源不受影响
+
+
+def test_the_front_end_check_refuses_shapes_it_cannot_draw():
+    """校验本身要能拒:dict 形状的 values(以前替身给的)、缺 message 的原因,前端都不认。"""
+    assert not _front_accepts({"kind": "mixed", "details": {"columns": [
+        {"sheet": "甲", "table": "甲表", "column": "数量", "header": "数量", "numeric": 9, "nonnumeric": 3,
+         "values": {"·": 3}}]}})
+    assert not _front_accepts({"kind": "shape", "details": {"reasons": [{"kind": "section_title", "cells": []}]}})
+    assert not _front_accepts({"kind": "other", "details": {}})
+
+
+async def test_mixed_and_raw_mode_are_passed_to_the_parser(client, monkeypatch):
+    seen: dict = {}
+    real = tabular.load_into
+
+    def spy(db_path, raw, filename, **kw):
+        seen.update(kw)
+        return real(db_path, raw, filename, **kw)
+
+    monkeypatch.setattr(tabular, "load_into", spy)
+    resp = await upload(client, unique(), xlsx(V1), mixed="null", raw_mode=True, header_row=1)
+    assert resp.status_code == 201, resp.text
+    assert seen["mixed"] == "null" and seen["raw_mode"] is True and seen["header_row"] == 1
+
+
+async def test_unknown_mixed_choice_is_refused(client):
+    resp = await upload(client, unique(), xlsx(V1), mixed="text")
+    assert resp.status_code == 400
+    assert "拒收" in resp.json()["detail"]
+
+
+async def test_upload_still_refuses_to_take_over_other_sources(client, tmp_path):
+    name = unique()
+    async with SessionLocal() as session:
+        session.add(DataSource(name=name, kind="sqlite", database=str(tmp_path / "manual.db")))
+        await session.commit()
+    resp = await upload(client, name, xlsx(V1))
+    assert resp.status_code == 409
+    assert "请换一个名称" in resp.json()["detail"]
+
+
+async def test_publish_failure_is_reported_without_moving_the_pointer(client):
+    name, raw = unique(), xlsx(V1)
+    first = (await upload(client, name, raw)).json()
+    async with SessionLocal() as session:
+        snap = await session.get(SourceSnapshot, first["snapshot_id"])
+        path = Path(snap.db_path)
+    os.chmod(path, 0o644)
+    path.write_bytes(b"")
+    resp = await upload(client, name, raw)
+    assert resp.status_code == 500
+    assert "不一致" in resp.json()["detail"]
+    assert (await by_name(name)).current_snapshot_id == first["snapshot_id"]
+
+
+# --------------------------------------------------------------------------
+# 不可变的强制
+# --------------------------------------------------------------------------
+
+
+async def test_upload_sources_refuse_connection_edits(client):
+    name = unique()
+    src = (await upload(client, name, xlsx(V1))).json()["source"]
+    sid = src["id"]
+    for patch in ({"readonly": False}, {"database": "/tmp/other.db"}, {"kind": "mysql"},
+                  {"host": "h"}, {"password": "secret"}):
+        resp = await client.patch(f"/api/datasources/{sid}", json=patch)
+        assert resp.status_code == 422, (patch, resp.text)
+        assert "重新上传" in resp.json()["detail"]
+    # 原样带回的连接字段不算修改;说明、启用、遮罩照常能改
+    resp = await client.patch(f"/api/datasources/{sid}", json={
+        "readonly": True, "database": src["database"], "kind": "sqlite", "password": "",
+        "description": "改过的说明", "options": {"mask_columns": "区域"},
+    })
+    assert resp.status_code == 200, resp.text
+    out = resp.json()
+    assert out["description"] == "改过的说明" and out["origin"] == "upload"
+    assert out["current_snapshot"]["id"] == src["current_snapshot"]["id"]
+    row = await by_name(name)
+    assert row.readonly is True and row.database == src["database"]
+
+
+async def test_upload_sources_refuse_a_schema_option(client):
+    """上传库只有 main:设了 schema,下一次发布的探查会落空、把空结构冻结进快照。"""
+    name = unique()
+    src = (await upload(client, name, xlsx(V1))).json()["source"]
+    resp = await client.patch(f"/api/datasources/{src['id']}", json={"options": {"schema": "nope"}})
+    assert resp.status_code == 422, resp.text
+    assert "schema" in resp.json()["detail"] and "重新上传" in resp.json()["detail"]
+    # 空的 schema 摘掉就行,其余配置照常保存
+    resp = await client.patch(f"/api/datasources/{src['id']}",
+                              json={"options": {"schema": "", "mask_columns": "区域"}})
+    assert resp.status_code == 200, resp.text
+    assert resp.json()["options"] == {"mask_columns": "区域"}
+    again = await upload(client, name, xlsx(V2))
+    assert again.status_code == 201, again.text
+    assert again.json()["source"]["table_count"] == 2 and not again.json()["source"]["schema_error"]
+
+
+def test_under_uploads_sees_through_sqlite_uri_and_query_tricks(tmp_path):
+    target = settings.uploads_dir / "tables" / "s" / "builds" / "b.db"
+    inside = [
+        str(target),
+        f"file:{target}?uri=true",
+        f"file:{target}?mode=rwc&uri=true",
+        f"file://{target}?uri=true",
+        f"file://localhost{target}?uri=true",
+        "file:" + str(target).replace("/", "%2F") + "?uri=true",
+        f"FILE:{target}?uri=true",
+        f"{target}?x=/../../../../../../../../tmp/elsewhere",
+        f"{target}?uri=true",
+    ]
+    for path in inside:
+        assert table_versions.under_uploads(path), path
+    for path in (str(tmp_path / "fine.db"), f"file:{tmp_path / 'fine.db'}?uri=true", ":memory:", "", None):
+        assert not table_versions.under_uploads(path), path
+
+
+async def test_manual_sqlite_sources_cannot_reach_uploads_through_uris(client, tmp_path):
+    """手工源的路径原样拼进连接串:file: URI、带 ?…/../ 的写法都不能指进上传目录读上传的数据。"""
+    body = (await upload(client, unique(), xlsx(V1))).json()
+    async with SessionLocal() as session:
+        build_db = (await session.get(SourceSnapshot, body["snapshot_id"])).db_path
+    for database in (f"file:{build_db}?uri=true",
+                     "file:" + build_db.replace("/", "%2F") + "?uri=true",
+                     f"{build_db}?x=/../../../../../../../../../tmp/q",
+                     f"file:{Path(build_db).parent / 'new.db'}?mode=rwc&uri=true"):
+        resp = await client.post("/api/datasources", json={
+            "name": unique(), "kind": "sqlite", "database": database, "readonly": False})
+        assert resp.status_code == 422, (database, resp.text)
+    assert sorted(p.name for p in Path(build_db).parent.iterdir()) == [Path(build_db).name]
+    ok = await client.post("/api/datasources", json={
+        "name": unique(), "kind": "sqlite", "database": str(tmp_path / "fine.db"), "readonly": False})
+    assert ok.status_code == 201
+    resp = await client.patch(f"/api/datasources/{ok.json()['id']}",
+                              json={"database": f"file:{build_db}?uri=true"})
+    assert resp.status_code == 422
+
+
+async def test_manual_sqlite_sources_cannot_point_into_uploads(client, tmp_path):
+    inside = settings.uploads_dir / "tables" / "sneaky.db"
+    resp = await client.post("/api/datasources", json={
+        "name": unique(), "kind": "sqlite", "database": str(inside)})
+    assert resp.status_code == 422
+    assert "上传表格" in resp.json()["detail"]
+
+    # 符号链接绕不过去:按 realpath 判断
+    link = tmp_path / "alias"
+    link.symlink_to(settings.uploads_dir, target_is_directory=True)
+    resp = await client.post("/api/datasources", json={
+        "name": unique(), "kind": "sqlite", "database": str(link / "x.db")})
+    assert resp.status_code == 422
+
+    ok = await client.post("/api/datasources", json={
+        "name": unique(), "kind": "sqlite", "database": str(tmp_path / "fine.db")})
+    assert ok.status_code == 201, ok.text
+    assert ok.json()["origin"] == "manual" and ok.json()["current_snapshot"] is None
+    sid = ok.json()["id"]
+    resp = await client.patch(f"/api/datasources/{sid}", json={"database": str(inside)})
+    assert resp.status_code == 422
+    resp = await client.patch(f"/api/datasources/{sid}", json={"description": "只改说明"})
+    assert resp.status_code == 200
+
+
+async def test_legacy_manual_sources_in_uploads_stay_readonly(client, tmp_path):
+    """升级前就指向上传目录的手工源:启动时强制只读,之后只改「只读」也不许改回可写;别的字段照常能改。"""
+    nested = settings.uploads_dir / "tables" / "deeper"
+    nested.mkdir(parents=True)
+    import sqlite3
+
+    db = sqlite3.connect(nested / "x.db")
+    db.execute("CREATE TABLE t (x)")
+    db.commit()
+    db.close()
+    async with SessionLocal() as session:
+        row = DataSource(name=unique(), kind="sqlite", database=str(nested / "x.db"), readonly=False)
+        session.add(row)
+        await session.commit()
+        sid = row.id
+        await table_versions.startup(session)
+    resp = await client.patch(f"/api/datasources/{sid}", json={"readonly": False})
+    assert resp.status_code == 422
+    assert "只能以只读方式连接" in resp.json()["detail"]
+    resp = await client.patch(f"/api/datasources/{sid}", json={"description": "只改说明", "readonly": True})
+    assert resp.status_code == 200 and resp.json()["readonly"] is True
+
+
+async def test_testing_a_draft_that_points_into_uploads_fails_like_saving_it(client, tmp_path):
+    """表单里先测连接:指向上传目录的路径不能先报「连接成功」、到保存时才被拒。"""
+    first = (await upload(client, unique(), xlsx(V1))).json()
+    async with SessionLocal() as session:
+        build = (await session.get(SourceSnapshot, first["snapshot_id"])).db_path
+    legacy = settings.uploads_dir / "tables" / "legacy_stock.db"
+    import sqlite3
+
+    sqlite3.connect(legacy).close()
+    before = sorted(p for p in settings.uploads_dir.rglob("*"))
+    for path in (build, str(legacy)):
+        for readonly in (True, False):
+            resp = await client.post("/api/datasources/test", json={
+                "name": "draft", "kind": "sqlite", "database": path, "readonly": readonly})
+            assert resp.status_code == 200
+            body = resp.json()
+            assert body["ok"] is False and "上传表格的存放目录" in body["error"], body
+            saved = await client.post("/api/datasources", json={
+                "name": unique(), "kind": "sqlite", "database": path, "readonly": readonly})
+            assert saved.status_code == 422
+    assert sorted(p for p in settings.uploads_dir.rglob("*")) == before
+    # 上传目录外的照常测
+    fine = tmp_path / "fine.db"
+    sqlite3.connect(fine).close()
+    resp = await client.post("/api/datasources/test", json={"name": "draft", "kind": "sqlite", "database": str(fine)})
+    assert resp.json()["ok"] is True, resp.json()
+
+
+FIRMLINK = "/System/Volumes/Data"
+
+
+def _same_file(a: str, b: str) -> bool:
+    try:
+        return os.path.samefile(a, b)
+    except OSError:
+        return False
+
+
+def _other_spellings(path: str) -> list[str]:
+    """同一个上传目录里的文件的另几种写法:数据目录、上传目录两段各换大小写,加 firmlink 前缀。
+
+    只留文件系统真认的写法(和原文件是同一个文件):区分大小写的文件系统上大小写变体会被去掉,
+    不是 macOS 时 firmlink 那种也会被去掉;一种都不剩就跳过(这类绕法在那里本来就不成立)。
+    """
+    real = os.path.realpath(path)
+    data, uploads = os.path.realpath(settings.data_dir), os.path.realpath(settings.uploads_dir)
+    rest = real[len(uploads):]
+    candidates = [
+        os.path.join(os.path.dirname(data), os.path.basename(data).upper(), "uploads") + rest,
+        os.path.join(data, "Uploads") + rest,
+        FIRMLINK + real,
+    ]
+    found = [c for c in candidates if c != real and _same_file(c, real)]
+    if not found:
+        pytest.skip("这个文件系统上没有同一文件的另一种写法(区分大小写、也没有 firmlink)")
+    return found
+
+
+def test_under_uploads_sees_through_case_and_firmlink_spellings(tmp_path):
+    """APFS 不区分大小写、realpath 不解析 firmlink:按路径字符串比,这几种写法都会被当成上传目录以外。"""
+    target = settings.uploads_dir / "tables" / "s" / "builds" / "b.db"
+    target.parent.mkdir(parents=True)
+    target.write_bytes(b"")
+    spellings = _other_spellings(str(target))
+    for path in spellings:
+        for variant in (path, f"file:{path}?uri=true", f"FILE:{path}?mode=rwc&uri=true",
+                        "file:" + path.replace("/", "%2F") + "?uri=true",
+                        # 文件和中间几级目录还不存在(mode=rwc 新建):靠已存在的祖先认
+                        os.path.join(os.path.dirname(path), "new", "x.db")):
+            assert table_versions.under_uploads(variant), variant
+    # 硬链接:路径在上传目录以外,文件却是同一个
+    alias = tmp_path / "alias.db"
+    os.link(target, alias)
+    assert table_versions.under_uploads(str(alias))
+    elsewhere = tmp_path / "fine.db"
+    elsewhere.write_bytes(b"")
+    assert not table_versions.under_uploads(str(elsewhere))
+
+
+async def test_manual_sqlite_sources_cannot_reach_uploads_through_other_spellings(client, tmp_path):
+    """大小写不同的写法、firmlink 前缀、它们的 file: URI:创建、测连接、修改都要拒,不能直接读出上传的数据。"""
+    body = (await upload(client, unique(), xlsx(V1))).json()
+    async with SessionLocal() as session:
+        build_db = (await session.get(SourceSnapshot, body["snapshot_id"])).db_path
+    spellings = _other_spellings(build_db)
+    for path in spellings:
+        for database in (path, f"file:{path}?uri=true"):
+            for readonly in (True, False):
+                resp = await client.post("/api/datasources", json={
+                    "name": unique(), "kind": "sqlite", "database": database, "readonly": readonly})
+                assert resp.status_code == 422, (database, resp.text)
+                assert "上传表格的存放目录" in resp.json()["detail"]
+                probe = (await client.post("/api/datasources/test", json={
+                    "name": "draft", "kind": "sqlite", "database": database, "readonly": readonly})).json()
+                assert probe["ok"] is False and "上传表格的存放目录" in probe["error"], probe
+    ok = await client.post("/api/datasources", json={
+        "name": unique(), "kind": "sqlite", "database": str(tmp_path / "fine.db"), "readonly": False})
+    assert ok.status_code == 201, ok.text
+    for path in spellings:
+        resp = await client.patch(f"/api/datasources/{ok.json()['id']}", json={"database": path})
+        assert resp.status_code == 422, (path, resp.text)
+
+
+async def test_startup_locks_legacy_sources_registered_under_other_spellings(client):
+    """升级前用另一种写法登记、可写的手工源:启动时同样强制只读,之后改不回可写。"""
+    body = (await upload(client, unique(), xlsx(V1))).json()
+    async with SessionLocal() as session:
+        build_db = (await session.get(SourceSnapshot, body["snapshot_id"])).db_path
+    ids = []
+    async with SessionLocal() as session:
+        for path in _other_spellings(build_db):
+            row = DataSource(name=unique(), kind="sqlite", database=path, readonly=False)
+            session.add(row)
+            await session.commit()
+            ids.append(row.id)
+        await table_versions.startup(session)
+    for sid in ids:
+        async with SessionLocal() as session:
+            assert (await session.get(DataSource, sid)).readonly is True
+        resp = await client.patch(f"/api/datasources/{sid}", json={"readonly": False})
+        assert resp.status_code == 422, resp.text
+
+
+def _app_db() -> str:
+    """测试进程实际连着的元数据库(conftest 建的那个)。data_dir 被夹具换过,和 settings.db_path 不是同一个。"""
+    from app.db import base as db_base
+
+    return str(db_base.engine.url.database)
+
+
+async def test_manual_sqlite_sources_cannot_point_at_app_files(client, tmp_path):
+    """应用自己的数据文件不能登记成数据源:可写登记后,一条经审批的 UPDATE 就能把上传源改成手工源、
+    指向别处,快照登记也能改;只读登记也会把全部元数据交给模型。"""
+    live = _app_db()
+    link = tmp_path / "meta-link.db"
+    link.symlink_to(live)
+    hard = tmp_path / "meta-hard.db"
+    os.link(live, hard)
+    data = settings.data_dir
+    targets = [
+        live, f"{live}-wal", f"{live}-journal", f"file:{live}?mode=ro&uri=true",
+        "file:" + live.replace("/", "%2F") + "?uri=true", str(link), str(hard),
+        str(settings.db_path), str(settings.checkpoint_path), f"{settings.db_path}-shm",
+        str(data / ".secret_key"), str(data / "artifacts" / "ab" / "x.db"),
+        str(data / "sandbox-policies" / "x.db"),
+    ]
+    upper = os.path.join(os.path.dirname(live), os.path.basename(live).upper())
+    if _same_file(upper, live):
+        targets.append(upper)
+    for database in targets:
+        for readonly in (True, False):
+            resp = await client.post("/api/datasources", json={
+                "name": unique(), "kind": "sqlite", "database": database, "readonly": readonly})
+            assert resp.status_code == 422, (database, resp.text)
+            assert "AgentLab 自己的数据文件" in resp.json()["detail"]
+            probe = (await client.post("/api/datasources/test", json={
+                "name": "draft", "kind": "sqlite", "database": database, "readonly": readonly})).json()
+            assert probe["ok"] is False and "AgentLab 自己的数据文件" in probe["error"], (database, probe)
+    fine = tmp_path / "fine.db"
+    ok = await client.post("/api/datasources", json={
+        "name": unique(), "kind": "sqlite", "database": str(fine), "readonly": False})
+    assert ok.status_code == 201, ok.text
+    resp = await client.patch(f"/api/datasources/{ok.json()['id']}", json={"database": live})
+    assert resp.status_code == 422
+    # 工作区不在其中:登记模型生成的库是正当用法
+    workspace_db = settings.workspace_dir / "s" / "made.db"
+    workspace_db.parent.mkdir(parents=True)
+    workspace_db.write_bytes(b"")
+    resp = await client.post("/api/datasources", json={
+        "name": unique(), "kind": "sqlite", "database": str(workspace_db)})
+    assert resp.status_code == 201, resp.text
+
+
+async def test_startup_disables_legacy_sources_pointing_at_app_files(client, tmp_path):
+    """升级前登记的、指向元数据库的手工源:启动时停用并改成只读;之后除了改路径和删除,修改一律拒,测连接也不去连。"""
+    async with SessionLocal() as session:
+        row = DataSource(name=unique(), kind="sqlite", database=_app_db(), readonly=False, enabled=True)
+        session.add(row)
+        await session.commit()
+        sid = row.id
+        await table_versions.startup(session)
+    async with SessionLocal() as session:
+        row = await session.get(DataSource, sid)
+        assert row.readonly is True and row.enabled is False
+    for patch in ({"enabled": True}, {"readonly": False}, {"description": "只改说明"}):
+        resp = await client.patch(f"/api/datasources/{sid}", json=patch)
+        assert resp.status_code == 422, (patch, resp.text)
+        assert "AgentLab 自己的数据文件" in resp.json()["detail"]
+    probe = (await client.post(f"/api/datasources/{sid}/test")).json()
+    assert probe["ok"] is False and "AgentLab 自己的数据文件" in probe["error"]
+    assert not any(k.startswith(f"{sid}:") for k in engines._engines)
+    resp = await client.patch(f"/api/datasources/{sid}", json={"database": str(tmp_path / "fine.db")})
+    assert resp.status_code == 200, resp.text
+
+
+async def test_testing_an_upload_source_with_other_connection_fields_fails_like_saving_it(client):
+    """测连接带上传源的 id:连接字段改了就和保存一样拒,不能拿表单里的路径去连上传目录里的别的文件。"""
+    a = (await upload(client, unique(), xlsx(V1))).json()
+    b = (await upload(client, unique(), xlsx(V2))).json()
+    async with SessionLocal() as session:
+        b_build = (await session.get(SourceSnapshot, b["snapshot_id"])).db_path
+        [imp] = list((await session.execute(
+            select(TableImport).where(TableImport.id == a["import_id"]))).scalars())
+    raw_path = str(raw_store.raw_path(imp.raw_sha256))
+    src = a["source"]
+    form = {"id": src["id"], "name": src["name"], "kind": "sqlite", "database": src["database"],
+            "readonly": True, "options": {}}
+    before = sorted(settings.uploads_dir.rglob("*"))
+    for change in ({"database": b_build}, {"readonly": False}, {"database": raw_path},
+                   {"database": str(settings.uploads_dir / "tables" / "nope.db")}, {"kind": "mysql"}):
+        probe = (await client.post("/api/datasources/test", json={**form, **change})).json()
+        assert probe["ok"] is False and "不能修改" in probe["error"], (change, probe)
+        saved = await client.patch(f"/api/datasources/{src['id']}", json=change)
+        assert saved.status_code == 422 and saved.json()["detail"] == probe["error"]
+    assert sorted(settings.uploads_dir.rglob("*")) == before
+    # 没改连接字段:测的是它当前版本的快照(带版本的缓存键),不是表单里的路径
+    probe = (await client.post("/api/datasources/test", json=form)).json()
+    assert probe["ok"] is True, probe
+    assert f"{src['id']}:{a['snapshot_id']}" in engines._engines
+    assert f"{src['id']}:" not in engines._engines
+    await engines.invalidate(src["id"])
+
+
+async def test_testing_an_upload_source_checks_its_snapshot(client):
+    """上传源的「测连接」走快照:核对哈希,不在连接缓存里留下不带版本、不核对的引擎。"""
+    body = (await upload(client, unique(), xlsx(V1))).json()
+    sid = body["source"]["id"]
+    resp = (await client.post(f"/api/datasources/{sid}/test")).json()
+    assert resp["ok"] is True, resp
+    assert set(k for k in engines._engines if k.startswith(f"{sid}:")) == {f"{sid}:{body['snapshot_id']}"}
+
+    async with SessionLocal() as session:
+        path = Path((await session.get(SourceSnapshot, body["snapshot_id"])).db_path)
+    os.chmod(path, 0o644)
+    import sqlite3
+
+    conn = sqlite3.connect(path)
+    conn.execute('UPDATE "甲表" SET "数量" = 999')
+    conn.commit()
+    conn.close()
+    for _ in range(2):            # 第一次撞上缓存的引擎(指纹对不上),第二次重新核对:都要拒
+        resp = (await client.post(f"/api/datasources/{sid}/test")).json()
+        assert resp["ok"] is False and "可能被修改过" in resp["error"], resp
+    assert f"{sid}:" not in engines._engines
+    assert (await by_name(body["source"]["name"])).last_check["ok"] is False
+
+    path.unlink()
+    resp = (await client.post(f"/api/datasources/{sid}/test")).json()
+    assert resp["ok"] is False and "已不存在" in resp["error"], resp
+    await engines.invalidate(sid)
+
+
+async def test_upload_sources_are_not_reintrospected_and_schema_comes_from_the_snapshot(client):
+    name = unique()
+    body = (await upload(client, name, xlsx(V1))).json()
+    sid = body["source"]["id"]
+    for params in ({}, {"dry_run": "true"}):
+        resp = await client.post(f"/api/datasources/{sid}/introspect", params=params)
+        assert resp.status_code == 409
+        assert resp.json()["detail"] == "上传的表格无需重新探查,重新上传即可更新"
+
+    # 数据源上的缓存被别处改坏了,接口仍按当前快照回答
+    async with SessionLocal() as session:
+        row = await session.get(DataSource, sid)
+        row.schema_cache = {"tables": {}}
+        await session.commit()
+    resp = await client.get(f"/api/datasources/{sid}/schema")
+    assert resp.status_code == 200
+    assert set(resp.json()["tables"]) == {"甲表", "乙表"}
+    one = (await client.get(f"/api/datasources/{sid}/schema", params={"table": "甲表"})).json()
+    assert one["found"] is True and [c["name"] for c in one["columns"]] == ["区域", "数量"]
+
+
+async def test_list_shows_origin_and_current_snapshot(client, tmp_path):
+    name = unique()
+    body = (await upload(client, name, xlsx(V1))).json()
+    rows = {r["name"]: r for r in (await client.get("/api/datasources")).json()}
+    assert rows[name]["origin"] == "upload"
+    snap = rows[name]["current_snapshot"]
+    assert snap["id"] == body["snapshot_id"] and snap["file_name"] == "客流.xlsx"
+    assert snap["created_at"] and snap["raw_state"] == "kept"
+
+
+# --------------------------------------------------------------------------
+# 删除、导入记录、清除原件
+# --------------------------------------------------------------------------
+
+
+async def test_delete_retires_imports_and_reclaims_files(client):
+    name = unique()
+    body = (await upload(client, name, xlsx(fresh(V1)))).json()
+    sid = body["source"]["id"]
+    async with SessionLocal() as session:
+        snap = await session.get(SourceSnapshot, body["snapshot_id"])
+        path = Path(snap.db_path)
+    [imp] = await imports_of(sid)
+    assert path.exists() and raw_store.raw_exists(imp.raw_sha256)
+    resp = await client.delete(f"/api/datasources/{sid}")
+    assert resp.status_code == 204
+    [imp] = await imports_of(sid)
+    assert imp.status == "retired"
+    assert not path.exists() and not raw_store.raw_exists(imp.raw_sha256)
+
+
+async def test_list_imports_newest_first(client):
+    name = unique()
+    first = (await upload(client, name, xlsx(V1))).json()
+    second = (await upload(client, name, xlsx(V2), filename="第二期.xlsx")).json()
+    sid = first["source"]["id"]
+    rows = (await client.get(f"/api/datasources/{sid}/imports")).json()
+    assert [r["id"] for r in rows] == [second["import_id"], first["import_id"]]
+    assert rows[0]["current"] is True and rows[0]["status"] == "active"
+    assert rows[0]["file_name"] == "第二期.xlsx" and rows[0]["raw_state"] == "kept"
+    assert rows[1]["current"] is False and rows[1]["status"] != "active"
+    assert (await client.get(f"/api/datasources/{uuid.uuid4().hex}/imports")).status_code == 404
+
+
+async def test_purge_raw_deletes_the_file_and_keeps_the_record(client):
+    name = unique()
+    body = (await upload(client, name, xlsx(fresh(V1)))).json()
+    sid, iid = body["source"]["id"], body["import_id"]
+    [imp] = await imports_of(sid)
+    path = raw_store.raw_path(imp.raw_sha256)
+    assert path.exists() and stat.S_IMODE(path.stat().st_mode) == 0o444
+
+    resp = await client.post(f"/api/datasources/{sid}/imports/{iid}/purge-raw",
+                             json={"reason": "含有不应保留的内容", "signed_by": "测试员"})
+    assert resp.status_code == 200, resp.text
+    out = resp.json()
+    assert out["file_deleted"] is True and out["also_purged"] == []
+    assert out["import"]["raw_state"] == "purged"
+    purged = out["import"]["purged"]
+    assert purged["reason"] == "含有不应保留的内容" and purged["signed_by"] == "测试员"
+    assert purged["signed_by_verified"] is False and purged["at"]
+    assert not path.exists()
+    assert not path.parent.exists()     # 原件所在的 raw/<前两位>/ 空了,一并删掉
+    [imp] = await imports_of(sid)
+    assert imp.raw_state == "purged" and imp.raw_sha256      # 哈希留着:说得清当时用的是哪份文件
+    # 库照常可查,当前版本显示原件已清除
+    assert await scalar(await resolve(sid), 'SELECT COUNT(*) FROM "甲表"') == 2
+    src = next(r for r in (await client.get("/api/datasources")).json() if r["id"] == sid)
+    assert src["current_snapshot"]["raw_state"] == "purged"
+
+    again = await client.post(f"/api/datasources/{sid}/imports/{iid}/purge-raw", json={"reason": "再来"})
+    assert again.status_code == 409
+    blank = await client.post(f"/api/datasources/{sid}/imports/{iid}/purge-raw", json={"reason": "  "})
+    assert blank.status_code == 422
+    missing = await client.post(f"/api/datasources/{sid}/imports/{uuid.uuid4().hex}/purge-raw",
+                                json={"reason": "x"})
+    assert missing.status_code == 404
+
+
+async def test_purge_is_by_content_and_lists_every_affected_import(client):
+    """同一份内容被别的导入共用(另一个源、同一个源传过两次):清除时文件照删,那些导入一并标成已清除。
+
+    清除是为了让含敏感内容的原件从服务器上消失(D13);只标记不删,用户拿到 200 却什么都没去掉。
+    """
+    raw = xlsx(fresh(V1))
+    a_name, b_name = unique(), unique()
+    a = (await upload(client, a_name, raw)).json()
+    b = (await upload(client, b_name, raw)).json()
+    b2 = (await upload(client, b_name, raw, filename="再传一次.xlsx")).json()
+    [imp_a] = await imports_of(a["source"]["id"])
+    path = raw_store.raw_path(imp_a.raw_sha256)
+    assert path.exists()
+
+    resp = await client.post(
+        f"/api/datasources/{a['source']['id']}/imports/{a['import_id']}/purge-raw",
+        json={"reason": "甲方要求", "signed_by": "测试员"},
+    )
+    assert resp.status_code == 200, resp.text
+    out = resp.json()
+    assert out["file_deleted"] is True and not path.exists()
+    assert out["import"]["raw_state"] == "purged"
+    affected = {o["id"]: o for o in out["also_purged"]}
+    # b 的两次导入(第一次已被替换,记录上原件仍是保留着的)都随之标成已清除
+    assert set(affected) == {b["import_id"], b2["import_id"]}
+    assert {o["source_name"] for o in affected.values()} == {b_name}
+    assert affected[b2["import_id"]]["file_name"] == "再传一次.xlsx"
+    imps_b = await imports_of(b["source"]["id"])
+    assert [i.raw_state for i in imps_b] == ["purged", "purged"]
+    assert {i.purged["via_import"] for i in imps_b} == {a["import_id"]}
+    assert {i.purged["reason"] for i in imps_b} == {"甲方要求"}
+    # 两个源的库照常可查
+    for src in (a, b):
+        assert await scalar(await resolve(src["source"]["id"]), 'SELECT COUNT(*) FROM "甲表"') == 2
+    again = await client.post(
+        f"/api/datasources/{b['source']['id']}/imports/{b2['import_id']}/purge-raw", json={"reason": "再来"})
+    assert again.status_code == 409
+
+
+async def test_purge_refuses_imports_without_a_raw_file(client):
+    """迁移来的老上传没有原件。"""
+    name = unique()
+    body = (await upload(client, name, xlsx(V1))).json()
+    sid, iid = body["source"]["id"], body["import_id"]
+    async with SessionLocal() as session:
+        imp = await session.get(TableImport, iid)
+        imp.raw_state = "absent"
+        await session.commit()
+    resp = await client.post(f"/api/datasources/{sid}/imports/{iid}/purge-raw", json={"reason": "x"})
+    assert resp.status_code == 409
+    assert "没有保存原件" in resp.json()["detail"]
diff --git a/backend/tests/test_engine_cache.py b/backend/tests/test_engine_cache.py
new file mode 100644
index 00000000..77b66864
--- /dev/null
+++ b/backend/tests/test_engine_cache.py
@@ -0,0 +1,508 @@
+"""连接缓存按「数据源 + 版本快照」分键;版本快照以 immutable 只读打开,建连接前核对哈希。
+
+上传表格每次发布都是一个新文件,同一个数据源的新旧版本得各用各的连接池,否则钉住
+旧版本的运行会查到新文件。快照用一个带 snapshot_id / immutable / expected_sha256 的
+简单对象模拟(table_versions.SourceView 的形状),不依赖版本底座的实现。
+"""
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import os
+import shutil
+import sqlite3
+import threading
+import uuid
+from types import SimpleNamespace
+from urllib.parse import quote
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.exc import OperationalError
+from sqlalchemy.ext.asyncio import AsyncEngine
+
+from app.data import engine as engine_mod
+from app.data.engine import (
+    MAX_ENGINES, EngineCache, SnapshotTampered, cache_key, engine_args, engines, run_query,
+)
+
+
+def _db(path, rows=(("分区甲", 120),)) -> str:
+    conn = sqlite3.connect(path)
+    conn.execute("CREATE TABLE 时段客流 (分区 TEXT, 客流 INTEGER)")
+    conn.executemany("INSERT INTO 时段客流 VALUES (?, ?)", list(rows))
+    conn.commit()
+    conn.close()
+    return str(path)
+
+
+def _sha(path: str) -> str:
+    with open(path, "rb") as f:
+        return hashlib.sha256(f.read()).hexdigest()
+
+
+def _source(path: str, *, sid: str | None = None, readonly: bool = True, **extra):
+    """手工源的形状;extra 里给 snapshot_id / immutable / expected_sha256 就是快照视图的形状。"""
+    return SimpleNamespace(
+        id=sid or f"src-{uuid.uuid4().hex[:8]}", kind="sqlite", database=path, readonly=readonly,
+        name="snapshot_probe", options={}, password=None, username=None, host=None, port=None,
+        description=None, schema_cache=None, enabled=True, **extra,
+    )
+
+
+#: _snapshot 的 expected 不传时:文件在就按它现在的内容登记哈希(版本快照一定带哈希,见 S2-7 的用例)
+_AUTO = object()
+
+
+def _snapshot(path: str, *, sid: str, snap: str, expected: object = _AUTO, readonly: bool = True):
+    if expected is _AUTO:
+        expected = _sha(path) if os.path.isfile(path) else None
+    return _source(path, sid=sid, readonly=readonly, snapshot_id=snap, immutable=True,
+                   expected_sha256=expected)
+
+
+async def _total(engine: AsyncEngine) -> int:
+    async with engine.connect() as conn:
+        return (await conn.execute(text("SELECT SUM(客流) FROM 时段客流"))).scalar_one()
+
+
+@pytest.fixture
+async def cache():
+    c = EngineCache()
+    yield c
+    await c.close()
+
+
+@pytest.fixture
+def disposed(monkeypatch):
+    """记下哪些 engine 被 dispose 过。"""
+    seen: list[int] = []
+    original = AsyncEngine.dispose
+
+    async def spy(self, close: bool = True):
+        seen.append(id(self))
+        return await original(self, close)
+
+    monkeypatch.setattr(AsyncEngine, "dispose", spy)
+    return seen
+
+
+# ---------------------------------------------------------------------------
+# 缓存键
+# ---------------------------------------------------------------------------
+
+def test_cache_key_carries_the_snapshot():
+    assert cache_key(SimpleNamespace(id="s1")) == "s1:"
+    assert cache_key(SimpleNamespace(id="s1", snapshot_id=None)) == "s1:"
+    assert cache_key(SimpleNamespace(id="s1", snapshot_id="")) == "s1:"
+    assert cache_key(SimpleNamespace(id="s1", snapshot_id="ab12")) == "s1:ab12"
+
+
+async def test_two_snapshots_of_one_source_get_separate_engines(tmp_path, cache):
+    """同一个数据源的两个版本:两个 engine,各查各的文件。"""
+    old = _db(tmp_path / "v1.db", [("分区甲", 100)])
+    new = _db(tmp_path / "v2.db", [("分区甲", 100), ("分区乙", 50)])
+    v1 = _snapshot(old, sid="s1", snap="a" * 64)
+    v2 = _snapshot(new, sid="s1", snap="b" * 64)
+
+    e1, e2 = await cache.get(v1), await cache.get(v2)
+    assert e1 is not e2
+    assert (await _total(e1), await _total(e2)) == (100, 150)
+    # 再取还是同一个
+    assert await cache.get(v1) is e1
+    assert set(cache._engines) == {f"s1:{'a' * 64}", f"s1:{'b' * 64}"}
+
+
+async def test_invalidate_clears_every_version_of_that_source_only(tmp_path, cache, disposed):
+    path = _db(tmp_path / "x.db")
+    plain = _source(path, sid="s1")
+    v1 = _snapshot(path, sid="s1", snap="a" * 64)
+    v2 = _snapshot(path, sid="s1", snap="b" * 64)
+    neighbour = _source(path, sid="s10")              # 前缀相同的另一个源
+    neighbour_v = _snapshot(path, sid="s10", snap="a" * 64)
+
+    doomed = [await cache.get(s) for s in (plain, v1, v2)]
+    kept = [await cache.get(s) for s in (neighbour, neighbour_v)]
+
+    await cache.invalidate("s1")
+    assert set(cache._engines) == {"s10:", f"s10:{'a' * 64}"}
+    assert sorted(disposed) == sorted(id(e) for e in doomed)
+    assert [await cache.get(s) for s in (neighbour, neighbour_v)] == kept
+    # 失效之后再取,新建一个
+    assert await cache.get(v1) is not doomed[1]
+
+
+async def test_lru_evicts_the_least_recently_used(tmp_path, disposed):
+    cache = EngineCache(max_engines=3)
+    try:
+        path = _db(tmp_path / "x.db")
+        a, b, c, d = (_source(path, sid=n) for n in "abcd")
+        ea, eb, ec = await cache.get(a), await cache.get(b), await cache.get(c)
+        assert await cache.get(a) is ea          # a 刚用过,最久没用的是 b
+        ed = await cache.get(d)
+        assert list(cache._engines) == ["c:", "a:", "d:"]
+        assert disposed == [id(eb)]
+        assert await _total(ea) == 120 and await _total(ed) == 120
+        # b 被关掉了,再取是新建的
+        assert await cache.get(b) is not eb
+        assert len(cache._engines) == 3
+    finally:
+        await cache.close()
+
+
+async def test_default_limit_follows_the_module_constant(tmp_path, monkeypatch):
+    assert MAX_ENGINES == 32
+    monkeypatch.setattr(engine_mod, "MAX_ENGINES", 2)
+    cache = EngineCache()
+    try:
+        path = _db(tmp_path / "x.db")
+        for n in "abc":
+            await cache.get(_source(path, sid=n))
+        assert list(cache._engines) == ["b:", "c:"]
+    finally:
+        await cache.close()
+
+
+async def test_global_cache_keeps_versions_apart_for_run_query(tmp_path):
+    """经 run_query(全局缓存)查:钉住旧版本的查询拿到旧数,当前版本拿到新数。"""
+    old = _db(tmp_path / "v1.db", [("分区甲", 100)])
+    new = _db(tmp_path / "v2.db", [("分区甲", 100), ("分区乙", 50)])
+    sid = f"g-{uuid.uuid4().hex[:8]}"
+    v1 = _snapshot(old, sid=sid, snap="1" * 64, expected=_sha(old))
+    v2 = _snapshot(new, sid=sid, snap="2" * 64, expected=_sha(new))
+    try:
+        assert (await run_query(v1, "SELECT SUM(客流) FROM 时段客流")).rows == [[100]]
+        assert (await run_query(v2, "SELECT SUM(客流) FROM 时段客流")).rows == [[150]]
+        assert (await run_query(v1, "SELECT SUM(客流) FROM 时段客流")).rows == [[100]]
+    finally:
+        await engines.invalidate(sid)
+    assert not any(k.startswith(f"{sid}:") for k in engines._engines)
+
+
+# ---------------------------------------------------------------------------
+# 不可变快照的打开方式
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("readonly", [True, False], ids=["readonly=True", "readonly=False"])
+def test_immutable_snapshot_url_ignores_readonly_field(tmp_path, readonly):
+    path = str(tmp_path / "分区 快照#1.db")
+    url, extra = engine_args(_snapshot(path, sid="s1", snap="a" * 64, readonly=readonly))
+    assert url == f"sqlite+aiosqlite:///file:{quote(path)}?mode=ro&immutable=1&uri=true"
+    assert extra["connect_args"]["factory"] is engine_mod._DqsOffConnection
+
+
+def test_immutable_snapshot_needs_a_file():
+    with pytest.raises(ValueError, match="数据文件"):
+        engine_args(_snapshot("", sid="s1", snap="a" * 64))
+    with pytest.raises(ValueError, match="数据文件"):
+        engine_args(_snapshot(":memory:", sid="s1", snap="a" * 64))
+
+
+async def test_immutable_snapshot_reads_and_refuses_writes(tmp_path, cache):
+    """immutable 打开:查询照常,写由 SQLite 拒绝(标着可写也一样),双引号列名写错照样报错。"""
+    path = _db(tmp_path / "分区 快照#1.db")
+    engine = await cache.get(_snapshot(path, sid="s1", snap="a" * 64, readonly=False))
+    assert await _total(engine) == 120
+    async with engine.connect() as conn:
+        with pytest.raises(OperationalError, match="readonly"):
+            await conn.execute(text("DELETE FROM 时段客流"))
+        with pytest.raises(OperationalError, match="no such column"):
+            await conn.execute(text('SELECT SUM("客六") FROM 时段客流'))
+    assert await _total(engine) == 120
+
+
+async def test_immutable_snapshot_ignores_locks(tmp_path, cache):
+    """immutable=1 的效果:不加锁、不看别的连接的锁。普通只读连接在排它锁下读不了,快照连接能读。
+
+    这条钉住 URL 里的 immutable=1 确实传到了 SQLite,而不只是写在字符串里。
+    """
+    path = _db(tmp_path / "locked.db")
+    holder = sqlite3.connect(path, isolation_level=None)
+    holder.execute("PRAGMA journal_mode=DELETE")
+    holder.execute("BEGIN EXCLUSIVE")
+    try:
+        plain = sqlite3.connect(f"file:{quote(path)}?mode=ro", uri=True, timeout=0)
+        try:
+            with pytest.raises(sqlite3.OperationalError, match="locked"):
+                plain.execute("SELECT COUNT(*) FROM 时段客流").fetchall()
+        finally:
+            plain.close()
+        engine = await cache.get(_snapshot(path, sid="s1", snap="a" * 64))
+        assert await _total(engine) == 120
+    finally:
+        holder.execute("ROLLBACK")
+        holder.close()
+
+
+# ---------------------------------------------------------------------------
+# 哈希核对
+# ---------------------------------------------------------------------------
+
+async def test_matching_hash_opens(tmp_path, cache):
+    path = _db(tmp_path / "ok.db")
+    engine = await cache.get(_snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path).upper()))
+    assert await _total(engine) == 120
+
+
+async def test_hash_mismatch_is_refused_and_not_cached(tmp_path, cache):
+    path = _db(tmp_path / "tampered.db")
+    view = _snapshot(path, sid="s1", snap="a" * 64, expected="0" * 64)
+    with pytest.raises(SnapshotTampered) as info:
+        await cache.get(view)
+    assert str(info.value) == "数据文件与登记的版本不一致,可能被修改过,已拒绝查询"
+    assert isinstance(info.value, ValueError)
+    assert cache._engines == {}
+    # 没缓存:每次都核对、每次都拒绝
+    with pytest.raises(SnapshotTampered):
+        await cache.get(view)
+    assert cache._engines == {}
+
+
+async def test_missing_snapshot_file_is_refused(tmp_path, cache):
+    view = _snapshot(str(tmp_path / "gone.db"), sid="s1", snap="a" * 64, expected="0" * 64)
+    with pytest.raises(SnapshotTampered, match="无法读取"):
+        await cache.get(view)
+    assert cache._engines == {}
+
+
+async def test_run_query_surfaces_the_refusal(tmp_path):
+    path = _db(tmp_path / "tampered.db")
+    sid = f"g-{uuid.uuid4().hex[:8]}"
+    view = _snapshot(path, sid=sid, snap="a" * 64, expected="0" * 64)
+    with pytest.raises(SnapshotTampered, match="已拒绝查询"):
+        await run_query(view, "SELECT COUNT(*) FROM 时段客流")
+    assert not any(k.startswith(f"{sid}:") for k in engines._engines)
+
+
+async def test_hash_is_checked_before_each_new_engine(tmp_path, cache):
+    """核对发生在这个键新建 engine 之前;失效(或被 LRU 关掉)之后重建,会重新核对。"""
+    path = _db(tmp_path / "snap.db")
+    view = _snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path))
+    engine = await cache.get(view)
+    assert await cache.get(view) is engine
+    await cache.invalidate("s1")
+    assert await cache.get(view) is not engine
+
+
+def _replace(path: str, rows) -> None:
+    """另造一个库,整份 rename 覆盖到 path 上(恢复备份、同步工具都是这么换文件的)。0444 挡不住。"""
+    other = _db(f"{path}.new", rows)
+    os.chmod(path, 0o444)
+    os.replace(other, path)
+
+
+async def test_in_place_rewrite_after_caching_is_refused(tmp_path, cache):
+    """引擎缓存着、文件被原地改写:缓存命中时指纹对不上,摘掉引擎、重新核对哈希,拒绝。"""
+    path = _db(tmp_path / "snap.db")
+    view = _snapshot(path, sid="s1", snap="a" * 64)
+    await cache.get(view)
+
+    conn = sqlite3.connect(path)
+    conn.execute("INSERT INTO 时段客流 VALUES ('分区乙', 1)")
+    conn.commit()
+    conn.close()
+
+    with pytest.raises(SnapshotTampered, match="可能被修改过"):
+        await cache.get(view)
+    assert cache._engines == {} and cache._pins == {}
+
+
+async def test_replaced_file_is_refused_for_new_pool_connections(tmp_path, cache):
+    """核对通过、引擎进了缓存之后文件被整份替换:已开的连接还读着核对过的那份(旧 inode),
+    逼连接池新开的连接、以及再次取引擎,都必须被拒,不能静默读到替换后的文件。"""
+    path = _db(tmp_path / "snap.db", [("分区甲", 1)])
+    view = _snapshot(path, sid="s1", snap="a" * 64)
+    engine = await cache.get(view)
+    async with engine.connect() as held:
+        assert (await held.execute(text("SELECT SUM(客流) FROM 时段客流"))).scalar_one() == 1
+        _replace(path, [("分区甲", 424242)])
+        # 占着一条,连接池只能新开一条:按路径打开的是替换后的文件,必须被拒
+        with pytest.raises(SnapshotTampered, match="替换或改动"):
+            async with engine.connect() as fresh:
+                await fresh.execute(text("SELECT SUM(客流) FROM 时段客流"))
+        assert (await held.execute(text("SELECT SUM(客流) FROM 时段客流"))).scalar_one() == 1
+    # 还回来的旧连接再借出去也不行:这个引擎已经不可信
+    with pytest.raises(SnapshotTampered):
+        async with engine.connect() as again:
+            await again.execute(text("SELECT 1"))
+    # 再取引擎:摘掉旧的、重新核对哈希,内容变了就拒绝
+    with pytest.raises(SnapshotTampered, match="可能被修改过"):
+        await cache.get(view)
+    assert cache._engines == {}
+
+
+async def test_replacement_between_get_and_connect_is_refused(tmp_path, cache):
+    """取到引擎之后、第一次连接之前文件被换掉(核对完到建出连接之间的窗口):第一条连接就被拒。"""
+    path = _db(tmp_path / "snap.db", [("分区甲", 1)])
+    view = _snapshot(path, sid="s1", snap="a" * 64)
+    engine = await cache.get(view)
+    _replace(path, [("分区甲", 424242)])
+    with pytest.raises(SnapshotTampered):
+        async with engine.connect() as conn:
+            await conn.execute(text("SELECT SUM(客流) FROM 时段客流"))
+
+
+async def test_same_content_restored_under_a_new_inode_is_accepted(tmp_path, cache):
+    """内容没变、只是换了 inode(从备份恢复回同一个文件):重新核对哈希通过,用新指纹重建,照常查询。"""
+    path = _db(tmp_path / "snap.db", [("分区甲", 7)])
+    view = _snapshot(path, sid="s1", snap="a" * 64)
+    engine = await cache.get(view)
+    shutil.copy2(path, f"{path}.bak")
+    os.replace(f"{path}.bak", path)
+    fresh = await cache.get(view)
+    assert fresh is not engine
+    assert await _total(fresh) == 7
+    assert await cache.get(view) is fresh
+
+
+async def test_run_query_refuses_a_replaced_snapshot(tmp_path):
+    """经 run_query(全局缓存):第一次查询通过,文件被替换后再查被拒,不会读到替换后的数。"""
+    path = _db(tmp_path / "snap.db", [("分区甲", 1)])
+    sid = f"g-{uuid.uuid4().hex[:8]}"
+    view = _snapshot(path, sid=sid, snap="a" * 64)
+    try:
+        assert (await run_query(view, "SELECT SUM(客流) FROM 时段客流")).rows == [[1]]
+        _replace(path, [("分区甲", 424242)])
+        with pytest.raises(SnapshotTampered):
+            await run_query(view, "SELECT SUM(客流) FROM 时段客流")
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_hash_is_computed_off_the_event_loop(tmp_path, cache, monkeypatch):
+    """大文件哈希要几秒,不能在事件循环线程里算。"""
+    path = _db(tmp_path / "snap.db")
+    threads: list[int] = []
+    original = engine_mod._file_sha256
+
+    def recording(p: str) -> str:
+        threads.append(threading.get_ident())
+        return original(p)
+
+    monkeypatch.setattr(engine_mod, "_file_sha256", recording)
+    await cache.get(_snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path)))
+    assert threads and threads[0] != threading.get_ident()
+
+
+async def test_sources_without_expected_hash_are_not_hashed(tmp_path, cache, monkeypatch):
+    """手工源:不读整个文件。"""
+    path = _db(tmp_path / "plain.db")
+
+    def boom(p: str) -> str:
+        raise AssertionError("不该计算哈希")
+
+    monkeypatch.setattr(engine_mod, "_file_sha256", boom)
+    await cache.get(_source(path, sid="s1"))
+    assert len(cache._engines) == 1
+
+
+@pytest.mark.parametrize("expected", [None, "", "   "], ids=["None", "empty", "blank"])
+async def test_snapshot_without_a_registered_hash_is_refused(tmp_path, cache, monkeypatch, expected):
+    """版本快照(immutable)却没给期望哈希:拒绝,不退回「不核对照样打开」。哪条新路径漏传哈希都会被测出来。"""
+    path = _db(tmp_path / "plain.db")
+
+    def boom(p: str) -> str:
+        raise AssertionError("不该计算哈希")
+
+    monkeypatch.setattr(engine_mod, "_file_sha256", boom)
+    with pytest.raises(SnapshotTampered, match="缺少文件哈希"):
+        await cache.get(_snapshot(path, sid="s1", snap="a" * 64, expected=expected))
+    assert cache._engines == {}
+
+
+@pytest.fixture
+def hashing(monkeypatch):
+    """数一数算了几次哈希;gate 关着时哈希卡在线程里,用来摆出「核对进行中」的局面。"""
+    state = SimpleNamespace(calls=[], gate=threading.Event(), started=threading.Event())
+    state.gate.set()
+    original = engine_mod._file_sha256
+
+    def counting(p: str) -> str:
+        state.calls.append(p)
+        state.started.set()
+        assert state.gate.wait(timeout=10), "测试没有放行哈希"
+        return original(p)
+
+    monkeypatch.setattr(engine_mod, "_file_sha256", counting)
+    yield state
+    state.gate.set()   # 测试中途失败也别让线程一直卡着
+
+
+async def _until(flag: threading.Event) -> None:
+    for _ in range(500):
+        if flag.is_set():
+            return
+        await asyncio.sleep(0.01)
+    raise AssertionError("等不到哈希开始")
+
+
+async def test_concurrent_first_use_builds_one_engine(tmp_path, cache, hashing):
+    """一次运行开头的几次工具调用同时查同一个快照:只建一个 engine,也只读一遍文件。"""
+    path = _db(tmp_path / "snap.db")
+    view = _snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path))
+    got = await asyncio.gather(*(cache.get(view) for _ in range(5)))
+    assert len({id(e) for e in got}) == 1
+    assert len(cache._engines) == 1
+    assert len(hashing.calls) == 1
+    assert cache._verifying == {}
+
+
+async def test_concurrent_mismatch_is_shared_but_not_kept(tmp_path, cache, hashing):
+    """并发的几个请求共用一次失败的核对,都被拒;失败不留,下一次照常重新核对。"""
+    path = _db(tmp_path / "tampered.db")
+    view = _snapshot(path, sid="s1", snap="a" * 64, expected="0" * 64)
+    got = await asyncio.gather(*(cache.get(view) for _ in range(5)), return_exceptions=True)
+    assert all(isinstance(e, SnapshotTampered) for e in got)
+    assert len(hashing.calls) == 1
+    assert cache._verifying == {} and cache._engines == {}
+
+    with pytest.raises(SnapshotTampered):
+        await cache.get(view)
+    assert len(hashing.calls) == 2
+
+
+async def test_a_cancelled_request_does_not_cancel_the_shared_check(tmp_path, cache, hashing):
+    path = _db(tmp_path / "snap.db")
+    view = _snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path))
+    hashing.gate.clear()
+    first = asyncio.create_task(cache.get(view))
+    await _until(hashing.started)
+    second = asyncio.create_task(cache.get(view))
+    await asyncio.sleep(0)
+    first.cancel()
+    hashing.gate.set()
+    engine = await second
+    assert first.cancelled()
+    assert await _total(engine) == 120
+    assert len(hashing.calls) == 1
+
+
+async def test_different_registered_hash_is_checked_separately(tmp_path, cache, hashing):
+    """同一个键却登记了不同的哈希(不该出现):各核各的,错的那个不能借对的那个的结论放行。"""
+    path = _db(tmp_path / "snap.db")
+    good = _snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path))
+    bad = _snapshot(path, sid="s1", snap="a" * 64, expected="0" * 64)
+    hashing.gate.clear()
+    tasks = [asyncio.create_task(cache.get(v)) for v in (good, bad)]
+    await _until(hashing.started)
+    hashing.gate.set()
+    ok, refused = await asyncio.gather(*tasks, return_exceptions=True)
+    assert isinstance(ok, AsyncEngine)
+    assert isinstance(refused, SnapshotTampered)
+    assert len(hashing.calls) == 2
+
+
+async def test_requests_after_invalidate_check_again(tmp_path, cache, hashing):
+    """核对进行中数据源被失效:之后来的请求重新核对,不搭失效之前那一次的便车。"""
+    path = _db(tmp_path / "snap.db")
+    view = _snapshot(path, sid="s1", snap="a" * 64, expected=_sha(path))
+    hashing.gate.clear()
+    before = asyncio.create_task(cache.get(view))
+    await _until(hashing.started)
+    await cache.invalidate("s1")
+    assert cache._verifying == {}
+    after = asyncio.create_task(cache.get(view))
+    await asyncio.sleep(0.05)
+    hashing.gate.set()
+    await asyncio.gather(before, after)
+    assert len(hashing.calls) == 2
diff --git a/backend/tests/test_evidence_unicode_names.py b/backend/tests/test_evidence_unicode_names.py
new file mode 100644
index 00000000..2135cee4
--- /dev/null
+++ b/backend/tests/test_evidence_unicode_names.py
@@ -0,0 +1,492 @@
+"""证据层按版本放宽中文标识符(H7):上传的表格起中文表名、列名,报告里要能写 [[t:明细]]、[[c:明细.金额]]。
+
+- 新组装的文档在顶层记 entity_syntax: 2(参与内容哈希):显式标记和 SQL 里不加引号的名字认中文,
+  名字按 names.name_key 找(只对 ASCII 不区分大小写,和 SQLite 一致)
+- 没有 entity_syntax 的老文档按原来的 ASCII 规则复核:结果和当年一模一样,不出 state_mismatch
+- 反引号里的名字核对、正文裸名自动链接仍只认 ASCII:「华东」不是可疑实体,中文正文不会整句被当成名字
+
+表名、列名、取值都是假的(明细、汇总、分区甲……)。
+"""
+from __future__ import annotations
+
+import asyncio
+import copy
+import sqlite3
+from types import SimpleNamespace
+
+import pytest
+from langchain_core.messages import AIMessage
+from sqlalchemy import select
+
+from app.core import artifact_store
+from app.core.artifact_store import canonical_json
+from app.data.engine import engines
+from app.db.base import SessionLocal
+from app.db.models import DataSource, Run, RunEvent
+from app.engine import evidence
+from app.engine.evidence import (
+    ENTITY_SYNTAX,
+    StreamRenderer,
+    build_catalog,
+    catalog_prompt,
+    compose_doc,
+    doc_entity_syntax,
+    find_entity,
+    iter_segments,
+    make_eid,
+    query_entry_fields,
+    render_markers,
+    sql_tables,
+    verify_doc,
+)
+from app.engine.runner import run_manager
+
+SOURCE = "cn_ledger"
+TOOL = f"db_query__{SOURCE}"
+SCHEMA_ART = "5" * 64
+Q_ART = "a" * 64
+SQL = "SELECT 区域, SUM(金额) AS 合计 FROM 明细 GROUP BY 区域 ORDER BY 合计 DESC"
+QUERY = {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": Q_ART, "tool": TOOL, "source": SOURCE,
+         "columns": ["区域", "合计"], "rows": 2, "truncated": False, "schema_artifact": SCHEMA_ART,
+         "tables": ["明细"]}
+SCHEMA = {"kind": "schema", "node_id": "fetch", "exec": 1, "artifact": SCHEMA_ART, "source": SOURCE,
+          "tables": {"明细": ["日期", "区域", "金额", "order_id"], "汇总": ["区域", "合计"],
+                     "orders": ["id", "amount"]}}
+SNAPSHOT = {"columns": ["区域", "合计"], "rows": [["分区甲", 1200.5], ["分区乙", 980.0]], "sql": SQL}
+FORMAT_REASON = "表名、字段名引用格式无法识别"
+
+
+def loader(artifact):
+    return SNAPSHOT if artifact == Q_ART else None
+
+
+@pytest.fixture
+def catalog():
+    return build_catalog(nodes={}, ledger=[QUERY, SCHEMA])
+
+
+def compose(text, catalog, **kw):
+    return compose_doc(text, catalog, loader=loader, **kw)
+
+
+def old_doc(text, catalog, monkeypatch):
+    """升级前组装的文档:按 1 版规则组装,没有 entity_syntax 字段(那时还没有这个字段)。
+
+    1 版这条路和升级前的代码逐字相同(组装出的文档字节一致),这里借它造老文档。"""
+    with monkeypatch.context() as m:
+        m.setattr(evidence, "ENTITY_SYNTAX", 1)
+        doc = compose(text, catalog)
+    assert doc.pop("entity_syntax") == 1
+    return doc
+
+
+def codes(result):
+    return [v["code"] for v in result["violations"]]
+
+
+def entities(doc):
+    return [s for s in iter_segments(doc) if s["kind"] == "entity"]
+
+
+# --------------------------------------------------------------------------
+# 新文档:[[t:明细]]、[[c:明细.金额]] 解析得了
+# --------------------------------------------------------------------------
+
+
+def test_new_doc_records_the_syntax_and_resolves_chinese_names(catalog):
+    doc = compose("本月[[t:明细]]的[[c:明细.金额]]按[[c:区域]]汇总,[[v:Q1.r0.区域]]最高。[[see:t:汇总,Q1]]", catalog)
+    assert ENTITY_SYNTAX == 2 and doc["entity_syntax"] == 2 and doc_entity_syntax(doc) == 2
+    assert '"entity_syntax":2' in canonical_json(doc)          # 在文档顶层,参与内容哈希
+    assert doc["markdown"] == "本月明细的明细.金额按区域汇总,分区甲最高。"     # 显示写作者写的名字本身
+    assert doc["violations"] == []
+    table, column, shared = entities(doc)
+    assert table["cite"]["alias"] == "t:明细" and table["state"] == "deterministic"
+    assert table["cite"]["eid"] == make_eid("table", SCHEMA_ART, {"table": "明细"})
+    assert column["cite"]["alias"] == "c:明细.金额"
+    assert column["cite"]["locator"] == {"table": "明细", "column": "金额"}
+    assert shared["cite"]["alias"] == "c:区域"                  # 明细、汇总都有这一列:只写列名也认
+    [unit] = [u for b in doc["blocks"] for u in b["units"]]
+    assert [s["alias"] for s in unit["see"]] == ["t:汇总", "Q1"]
+    again = verify_doc(doc, catalog, loader=loader)
+    assert again["ok"] is True and again["stats"]["entities"] == 3
+
+
+def test_streaming_renders_chinese_names_like_the_full_doc(catalog):
+    text = "本月[[t:明细]]的[[c:明细.金额]]上升。"
+    renderer = StreamRenderer(catalog, loader=loader)
+    streamed = "".join([renderer.feed("本月[[t:明"), renderer.feed("细]]的[[c:明细.金"), renderer.feed("额]]上升。"),
+                        renderer.flush()])
+    assert streamed == render_markers(text, catalog, loader=loader) == compose(text, catalog)["markdown"] \
+        == "本月明细的明细.金额上升。"
+
+
+@pytest.mark.parametrize("ref", [
+    "t:ABC",            # 全角:按 name_key 会归一成另一个名字,正文却照原样显示
+    "t:明细\u3164",        # U+3164 填充字符:\w 认它是字母,肉眼看不出;NFKC 会变成 U+1160,NFKC 检查就挡住了
+    "t:明细\u1160",        # U+1160、U+115F 是 NFKC 稳定的填充字符,只有 names.FILLERS 挡得住
+    "c:明细.\u115f金额",
+    "c:明细.金额(元)",      # 括号
+    "t:明 细",             # 空格
+    "t:1月明细",           # 数字开头
+    "t:①期",               # 带圈数字
+])
+def test_names_outside_the_name_contract_are_a_format_error(catalog, ref):
+    doc = compose(f"见 [[{ref}]]。", catalog)
+    [seg] = [s for s in iter_segments(doc) if s.get("ref")]
+    assert seg["cite"]["status"] == "unresolved" and seg["cite"]["reason"] == FORMAT_REASON
+    assert codes(doc) == ["unresolved_ref"]
+    fix = evidence._resolve_entity({"kind": ref[0], "ref": ref[2:], "body": ref[2:]}, catalog,
+                                   evidence._Snapshots(loader)).get("for_model")
+    assert "汉字" in fix                                         # 给模型的改写指令说的是新规则
+
+
+def test_dollar_and_hash_names_the_old_rules_took_still_parse(catalog):
+    """2 版是 1 版的超集:手工接入的库里 sales$2024 这种名字,1 版认的写法 2 版照样认(找不到是另一回事)。"""
+    doc = compose("见 [[t:sales$2024]]、[[t:orders]]。", catalog)
+    ghost, real = [s for s in iter_segments(doc) if s.get("ref")]
+    assert ghost["issue"] == "unknown_entity" and ghost["cite"]["reason"] != FORMAT_REASON
+    assert real["cite"]["status"] == "resolved"
+
+
+# --------------------------------------------------------------------------
+# 名字按 name_key 找:ABC 和 abc 是同一个,Дата 和 дата 不是
+# --------------------------------------------------------------------------
+
+
+def test_names_resolve_the_way_sqlite_compares_identifiers():
+    catalog = build_catalog(nodes={}, ledger=[{**SCHEMA, "tables": {"Orders": ["Amount"], "Дата": ["Сумма"]}}])
+    doc = compose("[[t:orders]]、[[t:ORDERS]]、[[c:orders.amount]]、[[t:Дата]]、[[c:Дата.Сумма]]。", catalog)
+    assert doc["violations"] == []
+    assert [s["cite"]["alias"] for s in entities(doc)] == ["t:Orders", "t:Orders", "c:Orders.Amount", "t:Дата",
+                                                           "c:Дата.Сумма"]
+    assert [s["text"] for s in entities(doc)] == ["orders", "ORDERS", "orders.amount", "Дата", "Дата.Сумма"]
+    # SQLite 里「дата」是另一张表:在这次运行里找不到,是可疑实体
+    assert codes(compose("[[t:дата]]。", catalog)) == ["unknown_entity"]
+    assert find_entity("ORDERS", catalog) == "t:Orders" and find_entity("дата", catalog) is None
+    # 1 版(复核老文档)照旧按 Python 的 lower() 找
+    assert find_entity("дата", catalog, syntax=1) == "t:Дата"
+
+
+@pytest.mark.parametrize("tables", [
+    {"ORDERS": ["a"], "orders": ["b"]},
+    {"orders": ["b"], "ORDERS": ["a"]},                   # 和目录顺序无关
+])
+def test_a_fullwidth_table_does_not_capture_the_ascii_one(tables):
+    """SQLite 不做 NFKC:全角的「ORDERS」和「orders」是两张表。先做 NFKC 再找的话,两张并存时
+    [[t:orders]] 会被静默绑到全角那张,原本正确的 ASCII 引用反而指错。"""
+    db = sqlite3.connect(":memory:")
+    db.executescript('CREATE TABLE "ORDERS" (a); CREATE TABLE orders (b);')
+    assert [r[0] for r in db.execute("SELECT name FROM pragma_table_info('orders')")] == ["b"]
+
+    catalog = build_catalog(nodes={}, ledger=[{**SCHEMA, "tables": tables}])
+    assert {"t:ORDERS", "t:orders"} <= set(catalog)
+    doc = compose("[[t:orders]]、[[t:ORDERS]]、[[c:orders.b]]。", catalog)
+    assert doc["violations"] == []
+    assert [s["cite"]["alias"] for s in entities(doc)] == ["t:orders", "t:orders", "c:orders.b"]
+    assert codes(compose("[[c:orders.a]]。", catalog)) == ["unknown_entity"]   # a 是全角那张表的字段
+    assert find_entity("orders", catalog) == find_entity("ORDERS", catalog) == "t:orders"
+    assert find_entity("orders", catalog, syntax=1) == "t:orders"
+
+
+def test_a_fullwidth_table_alone_is_not_found_by_its_ascii_lookalike():
+    """库里只有「ABC」时,SQL 里写 FROM abc 是找不到表的:[[t:abc]] 也不能解析到它。"""
+    db = sqlite3.connect(":memory:")
+    db.execute('CREATE TABLE "ABC" (x)')
+    with pytest.raises(sqlite3.OperationalError, match="no such table"):
+        db.execute("SELECT * FROM abc")
+    catalog = build_catalog(nodes={}, ledger=[{**SCHEMA, "tables": {"ABC": ["x"]}}])
+    assert codes(compose("[[t:abc]]。", catalog)) == ["unknown_entity"]
+    assert find_entity("abc", catalog) is None
+
+
+def test_sql_tables_does_not_fold_fullwidth_names_either():
+    assert sql_tables("SELECT * FROM ORDERS JOIN orders ON 1=1") == ["ORDERS", "orders"]
+    assert sql_tables("WITH t AS (SELECT 1) SELECT * FROM t") == ["t"]     # 真表 t 不是公用表表达式 t
+    assert sql_tables("WITH t AS (SELECT 1) SELECT * FROM T") == []          # ASCII 照旧不分大小写
+
+
+@pytest.mark.parametrize("name", ["明细", "Orders", "Дата", "t_2026年", "金额_元", "ORDER_ID", "销售额2024"])
+def test_the_lookup_key_is_name_key_for_every_name_the_import_can_produce(name):
+    """证据层按名字找表用的就是 names.name_key(导入和证据层一套口径,不另写私有的键):导入出来的名字都合
+    name_problem(NFKC 稳定),手工接入的库里不合规的名字(全角)也不做 NFKC,和 SQLite 一致。"""
+    import unicodedata
+
+    from app.data.names import name_key, name_problem
+
+    assert name_problem(name) is None and unicodedata.is_normalized("NFKC", name)
+    assert evidence._SQL[2].key is name_key and evidence._EntityIndex({}, syntax=2).key is name_key
+    assert not hasattr(evidence, "_ident_key")
+    assert name_key("ORDERS") == "ORDERS" != name_key("orders")
+
+
+def test_tables_differing_only_in_non_ascii_case_merge_in_the_catalog():
+    """已知限制,钉住现状(见 evidence._entity_entries 的说明):目录按 lower() 并表,比 SQLite 宽。
+
+    「Дата」和「дата」在 SQLite 里是两张表,目录里却只有先出现的 t:Дата,дата 的字段记在它名下:
+    [[c:Дата.y]] 能解析(y 其实是 дата 的),[[t:дата]] 判为疑似编造。上传的表格碰不到(导入按 collide_key
+    判撞名);目录没有版本,改并法会让碰上这种库的老文档复核对不上,等目录也加版本时再改。"""
+    db = sqlite3.connect(":memory:")
+    db.executescript('CREATE TABLE "Дата" (x); CREATE TABLE "дата" (y);')
+    assert db.execute("SELECT count(*) FROM sqlite_master WHERE type = 'table'").fetchone() == (2,)
+
+    catalog = build_catalog(nodes={}, ledger=[{**SCHEMA, "tables": {"Дата": ["x"], "дата": ["y"]}}])
+    assert {a for a, e in catalog.items() if e["kind"] in ("table", "column")} == {"t:Дата", "c:Дата.x", "c:Дата.y"}
+    doc = compose("[[c:Дата.y]]。", catalog)
+    assert doc["violations"] == [] and entities(doc)[0]["cite"]["alias"] == "c:Дата.y"
+    assert codes(compose("[[t:дата]]。", catalog)) == ["unknown_entity"]
+
+
+# --------------------------------------------------------------------------
+# 老文档:按 1 版复核,结果不变
+# --------------------------------------------------------------------------
+
+OLD_TEXT = ("本月[[t:明细]]的[[c:明细.金额]]按区域汇总,[[v:Q1.r0.区域]]最高,[[t:orders]] 另算,"
+            "`order_id` 去重,`华东` 不算。[[see:Q1,t:汇总]]")
+
+
+def test_old_doc_is_verified_by_the_rules_it_was_written_under(catalog, monkeypatch):
+    doc = old_doc(OLD_TEXT, catalog, monkeypatch)
+    assert doc_entity_syntax(doc) == 1
+    # 当年中文名判为「格式无法识别」:正文是占位、状态 none
+    [table, column] = [s for s in iter_segments(doc) if s.get("ref") in ("t:明细", "c:明细.金额")]
+    assert table["text"] == "⟦?t:明细⟧" and table["state"] == "none" and table["issue"] == "unresolved_ref"
+    assert table["cite"]["reason"] == FORMAT_REASON and column["text"] == "⟦?c:明细.金额⟧"
+    stored = codes(doc)
+    assert stored == ["unresolved_ref", "unresolved_ref", "unresolved_ref"]   # 两个标记 + see 里的 t:汇总
+
+    again = verify_doc(doc, catalog, loader=loader)
+    assert codes(again) == stored and again["stats"] == doc["stats"]
+    assert not {"state_mismatch", "render_mismatch", "eid_mismatch"} & set(codes(again))
+    # 组装时用的那份 _Snapshots(当前版)传进来也一样:以文档记下的版本为准
+    assert codes(verify_doc(doc, catalog, loader=evidence._Snapshots(loader))) == stored
+
+    # 对照:同一份文档要是按 2 版复核,当年的占位就和今天解析出的名字对不上,会被当成文档被改过
+    forged = {**copy.deepcopy(doc), "entity_syntax": 2}
+    assert "render_mismatch" in codes(verify_doc(forged, catalog, loader=loader))
+
+
+def test_old_ascii_docs_verify_clean(catalog, monkeypatch):
+    doc = old_doc("[[t:orders]] 的 [[c:orders.amount]] 已核对,`order_id` 去重。[[see:Q1]]", catalog, monkeypatch)
+    assert doc["violations"] == []
+    assert verify_doc(doc, catalog, loader=loader)["ok"] is True
+
+
+@pytest.mark.parametrize("value", [3, 0, "2", True, None])
+def test_an_unknown_syntax_version_is_a_schema_problem(catalog, value):
+    doc = compose("[[t:明细]]。", catalog)
+    doc["entity_syntax"] = value
+    assert doc_entity_syntax(doc) is None
+    assert "bad_schema" in codes(verify_doc(doc, catalog, loader=loader))
+
+
+# --------------------------------------------------------------------------
+# 反引号、正文裸名:两版都只认 ASCII
+# --------------------------------------------------------------------------
+
+
+def test_chinese_words_in_backticks_are_not_suspicious(catalog):
+    doc = compose("`华东`的客流高于`分区乙`,`明细`也提到了;`ghost_table` 是编的。", catalog)
+    assert codes(doc) == ["unknown_entity"]                    # 只有 ASCII 的 ghost_table
+    assert doc["violations"][0]["text"] == "ghost_table"
+    [flagged] = entities(doc)
+    assert flagged["name"] == "ghost_table" and flagged["state"] == "none"
+    assert codes(verify_doc(doc, catalog, loader=loader)) == ["unknown_entity"]
+
+
+def test_writing_rules_tell_the_model_to_mark_chinese_names_explicitly(catalog):
+    """反引号里的中文名不核对,写作规则就不能再教模型「或者放进反引号」:照写 `某表` 就绕开了核对。"""
+    prompt = catalog_prompt(catalog, loader=loader)
+    assert "或者放进反引号(`表名`)" not in prompt
+    assert "中文的表名、字段名一律用 [[t:]] / [[c:]]" in prompt and "反引号里的中文名系统不核对" in prompt
+    # 规矩和实际一致:显式标记的编造中文表名会被标出,反引号里的不会
+    assert codes(compose("本月[[t:假表]]下降。", catalog)) == ["unknown_entity"]
+    assert codes(compose("本月`假表`下降。", catalog)) == []
+
+
+def test_bare_names_still_link_only_ascii(catalog):
+    doc = compose("明细里的金额按区域汇总,order_id 去重后计数。", catalog)
+    linked = entities(doc)
+    assert [s["text"] for s in linked] == ["order_id"] and linked[0]["auto"] is True
+    assert linked[0]["cite"]["alias"] == "c:明细.order_id"
+    assert doc["violations"] == [] and verify_doc(doc, catalog, loader=loader)["ok"] is True
+
+
+# --------------------------------------------------------------------------
+# SQL 里不加引号的中文表名
+# --------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("sql, tables", [
+    ("SELECT SUM(金额) FROM 明细", ["明细"]),
+    ("SELECT a.区域, SUM(b.合计) FROM 明细 a JOIN 汇总 AS b ON a.区域 = b.区域 GROUP BY a.区域", ["明细", "汇总"]),
+    ("SELECT * FROM 明细, 汇总 WHERE 明细.区域 = 汇总.区域", ["明细", "汇总"]),
+    ("SELECT * FROM orders o LEFT JOIN 明细 d ON d.order_id = o.id", ["orders", "明细"]),
+    ("WITH 本期 AS (SELECT * FROM 明细 WHERE 月份 = '9月') SELECT * FROM 本期 JOIN 汇总 USING (区域)", ["明细", "汇总"]),
+    ("SELECT '来自 FROM 幽灵甲' AS 备注 FROM 明细 -- JOIN 幽灵乙", ["明细"]),
+    ("SELECT * FROM main.明细 JOIN t_2026年 ON 1 = 1", ["main.明细", "t_2026年"]),
+    ("SELECT $标签$ FROM 幽灵 $标签$ AS a FROM 明细", ["明细"]),
+    ("SELECT 明细#1.x FROM 明细#1", ["明细#1"]),
+    ("SELECT * FROM Дата JOIN дата ON 1 = 1", ["Дата", "дата"]),        # SQLite 里是两张表
+    ("select * from Orders join orders on 1=1", ["Orders"]),           # ASCII 照旧不分大小写
+])
+def test_sql_tables_reads_unquoted_chinese_names(sql, tables):
+    assert sql_tables(sql) == tables
+
+
+# 数据库怎么切分不加引号的名字:SQLite、PostgreSQL、MySQL 都把 ASCII 以外的字符算作名字的一部分(开头也算)。
+# 每条都真的在 SQLite 里执行一遍:能查,且查到的就是 sql_tables 给出的那几张表
+DB_TABLES = ["销售数据(2024)", "客流·总计", "明细", "汇总", "明细 甲", "明细,汇总", "1月", " 明细"]
+
+
+@pytest.mark.parametrize("sql, tables", [
+    ("SELECT SUM(金额) FROM 销售数据(2024)", ["销售数据(2024)"]),     # 全角括号是名字的一部分
+    ("SELECT * FROM 客流·总计", ["客流·总计"]),                          # 间隔号也是
+    ("SELECT * FROM 明细 JOIN 客流·总计 ON 1 = 1", ["明细", "客流·总计"]),
+    ("SELECT * FROM 明细 甲", ["明细 甲"]),                            # 全角空格不是空白,是一张表
+    ("SELECT * FROM 明细,汇总", ["明细,汇总"]),                        # 全角逗号不分隔
+    ("SELECT * FROM 1月", ["1月"]),                                    # 全角数字开头也是名字
+    ("SELECT 1 FROM 明细", []),                                        # 「FROM 明细」是一个名字(这里当列别名)
+    ("SELECT * FROM  明细", [" 明细"]),                                # 全角空格开头的名字:不能当空白吃掉
+    ("SELECT * FROM 明细 a JOIN 汇总 b ON a.金额 = b.金额", ["明细", "汇总"]),
+])
+def test_sql_tables_splits_unquoted_names_the_way_the_database_does(sql, tables):
+    db = sqlite3.connect(":memory:")
+    for name in DB_TABLES:
+        db.execute(f'CREATE TABLE "{name}" (金额 REAL)')
+    db.execute(sql).fetchall()                                         # 库里这条 SQL 是能查的
+    assert sql_tables(sql) == tables
+    assert sql_tables(sql, syntax=1) == []                             # 1 版照旧一个也不认
+
+
+def test_a_truncated_table_name_cannot_vouch_for_a_table_that_does_not_exist():
+    """以前 2 版在全角括号处把名字截断:台账记下不存在的「销售数据」,[[t:销售数据]] 判为有出处、无违规,
+    出处链(api/evidence.py 的 _query_present)也判它在这条 SQL 里。"""
+    from app.api.evidence import _query_present
+
+    sql = "SELECT SUM(金额) AS 合计 FROM 销售数据(2024)"
+    fields = query_entry_fields({"artifact": Q_ART, "columns": ["合计"], "rows": [[1.5]], "sql": sql,
+                                 "source": SOURCE, "schema_artifact": SCHEMA_ART})
+    assert fields["tables"] == ["销售数据(2024)"]
+    schema = {**SCHEMA, "tables": {"销售数据(2024)": ["金额"]}}
+    catalog = build_catalog(nodes={}, ledger=[{"kind": "query", "node_id": "fetch", "exec": 1, **fields}, schema])
+    assert "t:销售数据" not in catalog and catalog["t:销售数据(2024)"]["queries"] == ["Q1"]
+
+    doc = compose_doc("[[t:销售数据]]的合计见 [[see:Q1]]。", catalog,
+                      loader=lambda a: {"columns": ["合计"], "rows": [[1.5]], "sql": sql} if a == Q_ART else None)
+    [seg] = entities(doc)
+    assert seg["issue"] == "unknown_entity" and seg["state"] == "none" and seg["cite"]["status"] == "unresolved"
+    assert codes(doc) == ["unknown_entity"]
+    assert _query_present({"sql": sql}, "table", "销售数据", None) is False
+    assert _query_present({"sql": sql}, "table", "销售数据(2024)", None) is True
+
+
+def test_sql_tables_keeps_the_old_rules_for_version_one():
+    assert sql_tables("SELECT SUM(金额) FROM 明细", syntax=1) == []
+    assert sql_tables('SELECT SUM(金额) FROM "明细"', syntax=1) == ["明细"]
+    assert sql_tables('SELECT * FROM "Дата" JOIN "дата" ON 1 = 1', syntax=1) == ["Дата"]
+    assert sql_tables('SELECT * FROM "Дата" JOIN "дата" ON 1 = 1') == ["Дата", "дата"]
+
+
+def test_query_entry_ties_a_chinese_table_to_its_query():
+    """以前 FROM 明细 认不出来:表条目记不下是哪次查询用到的,写作目录把它归到「其他表」。"""
+    fields = query_entry_fields({"artifact": Q_ART, "columns": ["区域", "合计"], "rows": [["分区甲", 1]],
+                                 "sql": SQL, "source": SOURCE, "schema_artifact": SCHEMA_ART})
+    assert fields["tables"] == ["明细"]
+    catalog = build_catalog(nodes={}, ledger=[{"kind": "query", "node_id": "fetch", "exec": 1, **fields}, SCHEMA])
+    assert catalog["t:明细"]["queries"] == ["Q1"] and catalog["t:汇总"]["queries"] == []
+    prompt = catalog_prompt(catalog, loader=loader)
+    assert "- 明细(Q1 用到)" in prompt and "其他表:汇总" in prompt
+
+
+# --------------------------------------------------------------------------
+# 跑一次:中文表名的库,查询 → 报告 → 出口复核
+# --------------------------------------------------------------------------
+
+
+@pytest.fixture
+async def engine_up():
+    await run_manager.setup()
+    yield
+    await run_manager.shutdown()
+
+
+@pytest.fixture
+async def cn_source(tmp_path):
+    """探查过结构的中文表名库:明细、汇总两张表,表名、列名都不加引号也能查。"""
+    from app.data.introspect import introspect
+
+    path = tmp_path / "cn.db"
+    db = sqlite3.connect(path)
+    db.executescript("CREATE TABLE 明细 (日期 TEXT, 区域 TEXT, 金额 REAL);"
+                     "CREATE TABLE 汇总 (区域 TEXT, 合计 REAL);")
+    db.executemany("INSERT INTO 明细 VALUES (?, ?, ?)",
+                   [("2026-09-01", "分区甲", 700.5), ("2026-09-02", "分区甲", 500.0), ("2026-09-02", "分区乙", 980.0)])
+    db.commit()
+    db.close()
+    probe = SimpleNamespace(id=f"probe-{path.name}", name=SOURCE, kind="sqlite", database=str(path), host=None,
+                            port=None, username=None, password=None, options={}, readonly=True, schema_cache={})
+    cache = await introspect(probe)
+    await engines.invalidate(probe.id)
+    async with SessionLocal() as session:
+        row = (await session.execute(select(DataSource).where(DataSource.name == SOURCE))).scalar_one_or_none()
+        if row is None:
+            row = DataSource(name=SOURCE, kind="sqlite", database=str(path), readonly=True, enabled=True)
+            session.add(row)
+        row.database, row.kind, row.enabled, row.readonly, row.schema_cache = str(path), "sqlite", True, True, cache
+        await session.commit()
+        source_id = row.id
+    await engines.invalidate(source_id)
+    yield source_id
+    await engines.invalidate(source_id)
+
+
+def node(nid, ntype, **config):
+    return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
+
+
+async def finish(graph) -> Run:
+    run = await run_manager.start(graph=graph, input_payload={})
+    for _ in range(400):
+        await asyncio.sleep(0.05)
+        async with SessionLocal() as session:
+            row = await session.get(Run, run.id)
+        if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
+            return row
+    raise AssertionError(f"run {run.id} 没有结束:{row.status}")
+
+
+async def events(run_id: str, etype: str) -> list[RunEvent]:
+    async with SessionLocal() as session:
+        q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype).order_by(RunEvent.seq)
+        return list((await session.execute(q)).scalars())
+
+
+async def test_a_report_on_chinese_tables_passes_the_exit_check(engine_up, cn_source, monkeypatch):
+    from app.providers import mock_model
+
+    reply = "[[t:明细]]里[[v:Q1.r0.区域]]的[[c:明细.金额]]合计最高,为 [[v:Q1.r0.合计]]。[[see:Q1]]"
+    monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=reply))
+    nodes = [node("start", "input"), node("fetch", "tool", tool=TOOL, args={"sql": SQL}),
+             node("write", "report", instructions="写一句", on_violation="fail", max_repairs=0),
+             node("out", "output", fields=[{"name": "r", "value": "{{ nodes.write.text }}"}],
+                  contract={"report_from": "write"})]
+    graph = {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
+    row = await finish(graph)
+    assert row.status == "succeeded", row.error
+    assert row.output["r"] == "明细里分区甲的明细.金额合计最高,为 1,200.5。"
+
+    [finished] = [e for e in await events(row.id, "node.finished") if e.node_id == "fetch"]
+    [query] = [e for e in finished.data["evidence"] if e["kind"] == "query"]
+    assert query["tables"] == ["明细"]                           # 不加引号的中文表名记进了台账
+
+    [checked] = await events(row.id, "report.checked")
+    assert checked.data["violations"] == [], checked.data["violations"]
+    doc = artifact_store.load(checked.data["doc_artifact"])
+    assert doc["entity_syntax"] == 2
+    assert {s["cite"]["alias"] for s in iter_segments(doc) if s["kind"] == "entity"} == {"t:明细", "c:明细.金额"}
+    assert doc["catalog"]["t:明细"]["queries"] == ["Q1"]
+
+    [issuance] = await events(row.id, "issuance")
+    # 出口按同一套规则独立复核(目录从状态重建):满档出具,没有缺口
+    assert issuance.data["tier"] == "formal" and issuance.data["gaps"] == [], issuance.data
+    assert issuance.data["unresolved"] == 0 and issuance.data["matched_numbers"] == 1
diff --git a/backend/tests/test_excel_p1_integration.py b/backend/tests/test_excel_p1_integration.py
new file mode 100644
index 00000000..7e81f7b0
--- /dev/null
+++ b/backend/tests/test_excel_p1_integration.py
@@ -0,0 +1,174 @@
+"""期 1 合并后的集成:版本底座(table_versions)发布出来的快照,经连接层(engine)查询。
+
+两边是分开开发的:连接层用替身对象测了 immutable 和哈希核对,版本底座测了发布和钉版本。
+这里把真的 SourceView 交给真的 EngineCache,确认几件只有接起来才看得出的事:
+- 快照按 immutable 只读打开,写错的双引号列名照样报错(关 DQS 对快照也生效);
+- 快照文件被改过,下一次建连接就拒绝查询,不会读到改过的数据;
+- 重传之后,钉在旧快照上的查询读旧数据、当前版本读新数据,两份连接互不串。
+"""
+from __future__ import annotations
+
+import io
+import os
+import stat
+import uuid
+
+import pytest
+from sqlalchemy import select
+
+from app.core.config import settings
+from app.data import table_versions
+from app.data.engine import SnapshotTampered, engine_args, engines, run_query
+from app.db.base import SessionLocal
+from app.db.models import DataSource, Run
+
+
+@pytest.fixture(autouse=True)
+def store(monkeypatch, tmp_path):
+    data = tmp_path / "data"
+    (data / "uploads").mkdir(parents=True)
+    monkeypatch.setattr(settings, "data_dir", data)
+    yield data
+
+
+def xlsx(rows: list[list]) -> bytes:
+    from openpyxl import Workbook
+
+    book = Workbook()
+    sheet = book.active
+    sheet.title = "明细"
+    for row in rows:
+        sheet.append(row)
+    buf = io.BytesIO()
+    book.save(buf)
+    return buf.getvalue()
+
+
+def rows_v1() -> list[list]:
+    return [["区域", "数量"], ["分区甲", 1], ["分区乙", 2], ["标记", uuid.uuid4().int % 1000]]
+
+
+async def publish(name: str, raw: bytes):
+    async with SessionLocal() as session:
+        row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
+        if row is None:
+            row = DataSource(id=uuid.uuid4().hex, name=name, kind="sqlite", readonly=True, origin="upload")
+        result = await table_versions.publish_upload(session, row, raw, "明细.xlsx",
+                                                     header_row=1, mixed="reject", raw_mode=False)
+        return row.id, result
+
+
+async def resolve(source_id: str, pinned: str | None = None):
+    async with SessionLocal() as session:
+        row = await session.get(DataSource, source_id)
+        return await table_versions.resolve_source(session, row, pinned)
+
+
+def name() -> str:
+    return f"p1int_{uuid.uuid4().hex[:10]}"
+
+
+async def test_snapshot_view_opens_immutable_and_rejects_misspelled_columns():
+    sid, _ = await publish(name(), xlsx(rows_v1()))
+    view = await resolve(sid)
+    try:
+        assert view.immutable is True and view.readonly is True and view.expected_sha256
+        url, _ = engine_args(view)
+        assert "mode=ro" in url and "immutable=1" in url
+        assert (await run_query(view, 'SELECT SUM("数量") FROM "明细" WHERE "区域" != \'标记\'')).rows[0][0] == 3
+        # 写错列名:关 DQS 以后报错,不会把 "数目" 当成字符串、静默返回 0
+        with pytest.raises(Exception, match="no such column"):
+            await run_query(view, 'SELECT SUM("数目") FROM "明细"')
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_tampered_snapshot_is_refused_on_next_connection():
+    sid, _ = await publish(name(), xlsx(rows_v1()))
+    view = await resolve(sid)
+    try:
+        await run_query(view, 'SELECT COUNT(*) FROM "明细"')
+        await engines.invalidate(sid)
+        path = view.database
+        os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
+        with open(path, "r+b") as f:       # 改动文件末尾的一个字节:数据页内容变了,哈希随之变
+            f.seek(-1, os.SEEK_END)
+            last = f.read(1)
+            f.seek(-1, os.SEEK_END)
+            f.write(bytes([last[0] ^ 0xFF]))
+        with pytest.raises(SnapshotTampered):
+            await run_query(view, 'SELECT COUNT(*) FROM "明细"')
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_pinned_old_snapshot_and_current_snapshot_do_not_share_connections():
+    src = name()
+    sid, first = await publish(src, xlsx(rows_v1()))
+    # 一条钉在旧快照上的运行(状态是中断,等着续跑):有它引用,重传后的回收不会删掉旧快照
+    async with SessionLocal() as session:
+        session.add(Run(workflow_name="钉版本", status="interrupted",
+                        data_versions={sid: {"snapshot": first.snapshot_id, "name": src}}))
+        await session.commit()
+    _, second = await publish(src, xlsx([["区域", "数量"], ["分区甲", 100], ["标记", uuid.uuid4().int % 1000]]))
+    assert first.snapshot_id != second.snapshot_id
+    old = await resolve(sid, first.snapshot_id)
+    new = await resolve(sid)
+    try:
+        q = 'SELECT SUM("数量") FROM "明细" WHERE "区域" != \'标记\''
+        assert (await run_query(new, q)).rows[0][0] == 100
+        assert (await run_query(old, q)).rows[0][0] == 3
+        # 先查新再查旧、再查新:两份连接各走各的缓存键
+        assert (await run_query(new, q)).rows[0][0] == 100
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_unshaped_note_reaches_every_text_the_model_and_the_judge_read():
+    """按原样导入的表:「未规整」的说明不只在 db_schema 查单表时看得到——查询工具的描述、db_schema 的表清单、
+    报告的写作目录、裁判的摘录里都要标出(R4、D16),而且不含数字(H11)。"""
+    from app.core.artifact_store import load as load_artifact
+    from app.engine import judge
+    from app.engine.evidence import build_catalog, catalog_prompt, extract_numbers, schema_ledger_entry
+    from app.tools.datasource import build_datasource_tools
+    from app.tools.registry import ToolContext, Versions
+    from tests.test_tabular import crosstab
+
+    src = name()
+    async with SessionLocal() as session:
+        row = DataSource(id=uuid.uuid4().hex, name=src, kind="sqlite", readonly=True, origin="upload")
+        await table_versions.publish_upload(session, row, crosstab(), "客流.xlsx",
+                                            header_row=4, mixed="null", raw_mode=True)
+        sid = row.id
+        tools = {t.name: t for t in await build_datasource_tools(
+            [f"db_query__{src}", f"db_schema__{src}"], ToolContext(data_versions=Versions.CURRENT), session)}
+    try:
+        query, schema = tools[f"db_query__{src}"], tools[f"db_schema__{src}"]
+        assert "客流汇总 按原样导入、未经规整" in query.description and "不能直接对列求和" in query.description
+        assert "客流汇总(按原样导入、未经规整,不能直接对列求和)" in await schema.coroutine()
+        assert extract_numbers(table_versions.UNSHAPED_NOTE) == []
+
+        import json
+        payload = json.loads(await query.coroutine(sql='SELECT COUNT(*) AS n FROM "客流汇总"'))
+        frozen = schema_ledger_entry(payload["schema_artifact"], node_id="fetch", exec_no=1)
+        assert frozen is not None
+        catalog = build_catalog(nodes={}, ledger=[frozen])
+        prompt = catalog_prompt(catalog, loader=load_artifact)
+        assert "客流汇总(按原样导入、未经规整:同一列里混有不同口径的行,不能直接对列求和)" in prompt
+
+        excerpt = judge._excerpt("t:客流汇总", catalog["t:客流汇总"], [], load_artifact, None, catalog)
+        assert f"表的说明:{table_versions.UNSHAPED_NOTE}" in excerpt
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_shaped_tables_carry_no_unshaped_note():
+    """规整的表不带这句:别的表、手工源的表结构照旧。"""
+    from app.tools.datasource import _query_description
+
+    sid, _ = await publish(name(), xlsx(rows_v1()))
+    view = await resolve(sid)
+    try:
+        assert "未经规整" not in _query_description(view)
+    finally:
+        await engines.invalidate(sid)
diff --git a/backend/tests/test_introspect_broken_view.py b/backend/tests/test_introspect_broken_view.py
new file mode 100644
index 00000000..6a664df0
--- /dev/null
+++ b/backend/tests/test_introspect_broken_view.py
@@ -0,0 +1,114 @@
+"""SQLite 连接关了双引号字符串兼容以后,老库里一个写坏的视图不能拖垮整次结构探查。
+
+老库里常有 `SELECT "yes" AS flag` 这种视图:当年 SQLite 把找不到的双引号名字当字符串,
+它一直能用。连接层关掉这项兼容后,这个视图本身查询会报 no such column——这是对的;
+但批量反射也因它报错,以前探查吞掉异常、返回「成功、0 张表」,好好的表跟着一起消失,
+还被盖上「已同步」。现在批量失败时逐个对象重试,只跳过坏的那个,并说明原因。
+"""
+from __future__ import annotations
+
+import sqlite3
+import uuid
+from types import SimpleNamespace
+
+import pytest
+from sqlalchemy.engine.reflection import Inspector
+
+from app.data import introspect as introspect_mod
+from app.data.engine import engines, run_query
+from app.data.introspect import introspect
+
+
+def _make(path, *, table: bool = True, views: int = 1) -> str:
+    """建库用的是 Python 自带的 sqlite3 连接(DQS 开着),和当年建出这种老库的情形一样。"""
+    conn = sqlite3.connect(path)
+    if table:
+        conn.execute("CREATE TABLE 时段客流 (id INTEGER PRIMARY KEY, 分区 TEXT, 客流 INTEGER)")
+        conn.executemany("INSERT INTO 时段客流 (分区, 客流) VALUES (?, ?)",
+                         [("分区甲", 120), ("分区乙", 80)])
+        for i in range(views):
+            conn.execute(f'CREATE VIEW 旧视图{i} AS SELECT 分区, "是" AS 标记 FROM 时段客流')
+    else:
+        conn.execute('CREATE VIEW 旧视图 AS SELECT "是" AS 标记')   # 库里只有这一个坏视图
+    conn.commit()
+    conn.close()
+    return str(path)
+
+
+@pytest.fixture
+def make_source():
+    made: list[SimpleNamespace] = []
+
+    def make(path: str, *, readonly: bool = True) -> SimpleNamespace:
+        src = SimpleNamespace(
+            id=f"bv-{uuid.uuid4().hex[:8]}", name="旧库", kind="sqlite", database=path,
+            host=None, port=None, username=None, password=None, options={},
+            readonly=readonly, description=None, schema_cache={},
+        )
+        made.append(src)
+        return src
+
+    yield make
+    for src in made:
+        engines._engines.pop(f"{src.id}:", None)
+
+
+@pytest.mark.parametrize("readonly", [True, False], ids=["只读", "可写"])
+async def test_broken_view_is_skipped_tables_still_listed(tmp_path, make_source, readonly):
+    src = make_source(_make(tmp_path / "旧库.db"), readonly=readonly)
+    payload = await introspect(src)
+
+    assert list(payload["tables"]) == ["时段客流"]
+    table = payload["tables"]["时段客流"]
+    assert [c["name"] for c in table["columns"]] == ["id", "分区", "客流"]
+    assert table["primary_key"] == ["id"]
+    assert not payload.get("failed")
+    assert list(payload["skipped"]) == ["旧视图0"]
+    assert "no such column" in payload["skipped"]["旧视图0"]
+
+    # 表照常可查;坏视图本身查询报错(连接层关 DQS 的本意)
+    assert (await run_query(src, "SELECT SUM(客流) FROM 时段客流")).rows == [[200]]
+    with pytest.raises(Exception, match="no such column"):
+        await run_query(src, "SELECT * FROM 旧视图0")
+
+
+async def test_only_broken_objects_is_a_failure_not_an_empty_success(tmp_path, make_source):
+    """一个对象也取不到:记成失败(不会被盖「已同步」),而不是「成功、0 张表」。"""
+    src = make_source(_make(tmp_path / "只剩视图.db", table=False))
+    payload = await introspect(src)
+    assert payload["tables"] == {}
+    assert payload["failed"] is True
+    assert "no such column" in payload["error"]
+
+
+async def test_healthy_database_payload_unchanged(tmp_path, make_source):
+    src = make_source(_make(tmp_path / "好库.db", views=0))
+    payload = await introspect(src)
+    assert list(payload["tables"]) == ["时段客流"]
+    assert "skipped" not in payload and not payload.get("failed")
+
+
+async def test_per_object_retry_gives_up_when_nothing_is_readable(tmp_path, make_source, monkeypatch):
+    """批量和逐个都失败(比如连接本身出了问题):开头几个都取不到就停,不挨个耗时间。"""
+    path = tmp_path / "多表.db"
+    conn = sqlite3.connect(path)
+    for i in range(12):
+        conn.execute(f"CREATE TABLE t{i} (a INTEGER)")
+    conn.commit()
+    conn.close()
+    src = make_source(str(path))
+
+    calls: list[str] = []
+
+    def batch_fails(self, *a, **kw):
+        raise RuntimeError("模拟:连接中断")
+
+    def single_fails(self, name, *a, **kw):
+        calls.append(name)
+        raise RuntimeError("模拟:连接中断")
+
+    monkeypatch.setattr(Inspector, "get_multi_columns", batch_fails)
+    monkeypatch.setattr(Inspector, "get_columns", single_fails)
+    payload = await introspect(src)
+    assert len(calls) == introspect_mod._GIVE_UP_AFTER
+    assert payload["failed"] is True and "连接中断" in payload["error"]
diff --git a/backend/tests/test_names.py b/backend/tests/test_names.py
new file mode 100644
index 00000000..ba958039
--- /dev/null
+++ b/backend/tests/test_names.py
@@ -0,0 +1,76 @@
+"""名字规范化:导入和证据层共用的口径。"""
+from __future__ import annotations
+
+import pytest
+
+from app.data.names import canon, collide_key, name_key, name_problem, to_sql_name
+
+
+@pytest.mark.parametrize(("raw", "expected"), [
+    ("8-9", "8-9"),
+    ("8–9", "8-9"),            # en dash
+    ("8—9", "8-9"),            # em dash
+    ("8−9", "8-9"),            # 减号
+    ("8-9", "8-9"),          # 全角
+    (" 18-22  时合计 ", "18-22 时合计"),
+    ("金​额", "金额"),     # 零宽空格(Cf)
+    ("(人次)", "(人次)"),
+])
+def test_canon(raw, expected):
+    assert canon(raw) == expected
+
+
+@pytest.mark.parametrize("name", ["日客流", "sales", "Sales_2026", "时段客流_表内合计", "a"])
+def test_good_names(name):
+    assert name_problem(name) is None
+
+
+@pytest.mark.parametrize("name", [
+    "", "日客流ㅤ", "ABC", "①期", "a b", "2026销售", "金额(元)", "sqlite_master", "select",
+    "x" * 49, "a​b", "é",
+])
+def test_bad_names(name):
+    assert name_problem(name)
+
+
+def test_name_key_matches_sqlite():
+    """name_key 和 SQLite 比较标识符完全一致:只对 ASCII 不区分大小写,不做 NFKC。逐对拿真的 SQLite 对照。"""
+    import sqlite3
+
+    pairs = [("ABC", "abc", True), ("Дата", "дата", False), ("ABC", "abc", False), ("明细", "明细", True),
+             ("Orders", "orders", False), ("Sales_2026", "SALES_2026", True)]
+    for a, b, same in pairs:
+        db = sqlite3.connect(":memory:")
+        db.execute(f'CREATE TABLE "{a}" (x)')
+        try:
+            db.execute(f'CREATE TABLE "{b}" (y)')
+            sqlite_same = False
+        except sqlite3.OperationalError:        # table … already exists:SQLite 认为是同一个名字
+            sqlite_same = True
+        db.close()
+        assert sqlite_same is same, (a, b)
+        assert (name_key(a) == name_key(b)) is same, (a, b)
+
+
+def test_collide_key_is_stricter():
+    assert collide_key("Дата") == collide_key("дата")
+    assert collide_key("ABC") == collide_key("abc")
+
+
+@pytest.mark.parametrize(("header", "expected"), [
+    ("金额(元)", "金额_元"),
+    ("销量\n(件)", "销量_件"),
+    ("单价[元]", "单价_元"),
+    ("Unit Price", "Unit_Price"),
+    ("2026年", "c_2026年"),
+    ("order", "order_"),
+    ("sqlite_x", "t_sqlite_x"),
+    ("", "col_3"),
+    (None, "col_3"),
+    ("()", "col_3"),
+    ("全日客流(人次)", "全日客流_人次"),
+])
+def test_to_sql_name(header, expected):
+    out = to_sql_name(header, fallback="col_3")
+    assert out == expected
+    assert name_problem(out) is None
diff --git a/backend/tests/test_run_data_versions.py b/backend/tests/test_run_data_versions.py
new file mode 100644
index 00000000..d28d2cdd
--- /dev/null
+++ b/backend/tests/test_run_data_versions.py
@@ -0,0 +1,894 @@
+"""运行固定上传表格的数据版本(期 1 WP-D)。
+
+上传源每次重传是一个新快照。运行发起时把这张图用到的上传源的当前快照记进 runs.data_versions,
+之后这次运行里的一切——工具描述、db_schema、冻结的表结构、查询结果——都出自那一版:
+
+- 运行中途重传:审批恢复、失败后继续运行,查到的仍是发起时那一版;
+- 固定的快照被回收、文件被删:工具明确报错,绝不改用当前版本;
+- 只固定图里(含子工作流、图级 defaults 绑的)实际用到的上传源;工具名带模板时全部固定;手工源不进来;
+- 子工作流沿用父运行固定的版本;构造工具时没拿到版本(漏传)的上传源报错,不改用当前版本;
+- 发起时没算到的源,第一次用到时补固定、记进运行,并在事件流里留一条日志;
+- 调用工具节点遇到数据本身用不了时判失败,报错照交原因,不让人改 SQL;
+- 查询快照记下 data_version(快照 id),手工源不记;工具库试用(不在运行里)用当前版本。
+
+夹具:表格在测试里用 openpyxl 现造,标签是假名,数字手写。
+"""
+from __future__ import annotations
+
+import asyncio
+import io
+import json
+import os
+import sqlite3
+import stat
+import uuid
+
+import pytest
+from langchain_core.messages import AIMessage, ToolMessage
+from sqlalchemy import select
+
+from app.api.runs import missing_tools
+from app.core import artifact_store
+from app.core.config import settings
+from app.core.events import EventType
+from app.data import table_versions
+from app.data.engine import engines
+from app.db.base import SessionLocal
+from app.db.models import DataSource, Run, RunEvent, SourceSnapshot, Workflow
+from app.engine.context import RunContext
+from app.engine.runner import _data_versions, run_manager
+from app.engine.schema import GraphSpec
+from app.tools.datasource import (
+    DATA_UNAVAILABLE,
+    LATE_PIN_CODE,
+    RUN_VERSIONS_KEY,
+    build_datasource_tools,
+    run_versions,
+)
+from app.tools.registry import ToolContext, Versions
+
+SUM = 'SELECT SUM("数量") AS total FROM "明细"'
+
+
+@pytest.fixture(autouse=True)
+def store(monkeypatch, tmp_path):
+    data = tmp_path / "data"
+    (data / "uploads").mkdir(parents=True)
+    monkeypatch.setattr(settings, "data_dir", data)
+    yield data
+
+
+@pytest.fixture
+async def engine_up(store):
+    # 依赖 store:checkpoint 库跟着 data_dir 走,要先换好目录再建
+    await run_manager.setup()
+    yield
+    await run_manager.shutdown()
+
+
+# --------------------------------------------------------------------------
+# 夹具:表格、数据源
+# --------------------------------------------------------------------------
+
+
+def book(sheets: dict[str, list[list]]) -> bytes:
+    from openpyxl import Workbook
+
+    wb = Workbook()
+    wb.remove(wb.active)
+    for title, rows in sheets.items():
+        ws = wb.create_sheet(title)
+        for row in rows:
+            ws.append(row)
+    buf = io.BytesIO()
+    wb.save(buf)
+    return buf.getvalue()
+
+
+#: 第一版:一张「明细」,数量合计 3
+V1 = {"明细": [["区域", "数量"], ["分区甲", 1], ["分区乙", 2]]}
+#: 第二版:「明细」合计 100,另多一张「新增」。表名、数字都和第一版不同,串了版本一眼看得出
+V2 = {"明细": [["区域", "数量"], ["分区甲", 60], ["分区乙", 40]], "新增": [["时段", "客流"], ["上午", 7]]}
+
+
+def uniq(prefix: str = "dv") -> str:
+    return f"{prefix}_{uuid.uuid4().hex[:10]}"
+
+
+async def publish(name: str, sheets: dict[str, list[list]]) -> tuple[str, str]:
+    """发布一版上传表格,返回 (源 id, 快照 id)。源不存在就新建。"""
+    async with SessionLocal() as session:
+        row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
+        if row is None:
+            row = DataSource(id=uuid.uuid4().hex, name=name, kind="sqlite", readonly=True, origin="upload")
+        result = await table_versions.publish_upload(session, row, book(sheets), "客流.xlsx",
+                                                     header_row=1, mixed="reject", raw_mode=False)
+        return row.id, result.snapshot_id
+
+
+async def manual_source(tmp_path, total: int) -> tuple[str, str, str]:
+    """一个手工登记的 SQLite 源(文件在上传目录外)。返回 (源 id, 源名, 文件路径)。"""
+    path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
+    db = sqlite3.connect(path)
+    db.executescript('CREATE TABLE "明细" ("区域" TEXT, "数量" INTEGER);')
+    db.execute('INSERT INTO "明细" VALUES (?, ?)', ("分区甲", total))
+    db.commit()
+    db.close()
+    name = uniq("manual")
+    async with SessionLocal() as session:
+        row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True)
+        session.add(row)
+        await session.commit()
+        return row.id, name, str(path)
+
+
+async def keep(source_id: str, name: str, snapshot: str) -> str:
+    """登记一条引用这个快照的中断运行,让之后重传触发的回收不删它。返回运行 id。"""
+    async with SessionLocal() as session:
+        run = Run(workflow_name="保留旧版本", status="interrupted",
+                  data_versions={source_id: {"snapshot": snapshot, "name": name}})
+        session.add(run)
+        await session.commit()
+        return run.id
+
+
+async def tools_for(names: list[str], *, data_versions=Versions.CURRENT, run_id: str = "", emit=None) -> dict:
+    """建数据源工具。缺省按不在运行里(工具库试用)建,用当前版本。"""
+    async with SessionLocal() as session:
+        built = await build_datasource_tools(
+            names, ToolContext(run_id=run_id, node_id="n", data_versions=data_versions, emit=emit), session)
+    return {t.name: t for t in built}
+
+
+def total_of(text: str) -> object:
+    return json.loads(text)["rows"][0][0]
+
+
+def pinned_of(pins: dict, sid: str) -> str | None:
+    return (pins.get(sid) or {}).get("snapshot")
+
+
+# --------------------------------------------------------------------------
+# 跑图的工具
+# --------------------------------------------------------------------------
+
+
+def node(nid, ntype, **config):
+    return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
+
+
+def chain(*nodes):
+    return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
+
+
+async def wait(run_id: str, statuses=("interrupted", "failed", "succeeded"), timeout=30.0) -> Run:
+    deadline = asyncio.get_event_loop().time() + timeout
+    while asyncio.get_event_loop().time() < deadline:
+        async with SessionLocal() as session:
+            row = await session.get(Run, run_id)
+        if row and row.status in statuses and run_manager._tasks.get(run_id) is None:
+            return row
+        await asyncio.sleep(0.05)
+    raise AssertionError(f"等超时了:{run_id}")
+
+
+async def events_of(run_id: str, kind: str, node_id: str | None = None) -> list[RunEvent]:
+    async with SessionLocal() as session:
+        query = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == kind)
+        if node_id is not None:
+            query = query.where(RunEvent.node_id == node_id)
+        return list((await session.execute(query.order_by(RunEvent.seq))).scalars())
+
+
+async def started_events(run_id: str) -> list[RunEvent]:
+    return await events_of(run_id, "run.started")
+
+
+async def late_pin_events(run_id: str) -> list[RunEvent]:
+    return [e for e in await events_of(run_id, "log") if (e.data or {}).get("code") == LATE_PIN_CODE]
+
+
+def loop_graph(first: str, second: str, condition: str) -> dict:
+    """t1 查 first → 循环(条件执行时才报错的话停在这里)→ t2 查 second → 输出两次的合计。"""
+    return {
+        "nodes": [
+            node("start", "input"),
+            node("t1", "tool", tool=f"db_query__{first}", args={"sql": SUM}),
+            node("lp", "loop", mode="while", condition=condition, max_iterations=1),
+            node("body", "transform", mode="template", template="转一圈"),
+            node("t2", "tool", tool=f"db_query__{second}", args={"sql": SUM}),
+            node("out", "output", fields=[{"name": "t1", "value": "{{ nodes.t1 }}"},
+                                          {"name": "t2", "value": "{{ nodes.t2 }}"}]),
+        ],
+        "edges": [
+            {"source": "start", "target": "t1"}, {"source": "t1", "target": "lp"},
+            {"source": "lp", "target": "body", "sourceHandle": "body"}, {"source": "body", "target": "lp"},
+            {"source": "lp", "target": "t2", "sourceHandle": "done"}, {"source": "t2", "target": "out"},
+        ],
+    }
+
+
+#: 过得了静态检查、执行时才报错的循环条件(指数超限):运行停在循环节点上,失败
+FAILS_AT_RUNTIME = "2 ** 100000 > 1"
+
+
+def output_total(run: Run, field: str) -> object:
+    value = (run.output or {})[field]
+    return total_of(value if isinstance(value, str) else json.dumps(value))
+
+
+def scripted_agent(monkeypatch, source: str) -> dict:
+    """Agent 先列表结构、再查合计、然后收尾;协作成员(只绑了查询工具)直接查合计。
+
+    记下各自看到的工具描述(按 (谁, 工具名))和工具结果(按调用 id)。
+    """
+    from app.providers import mock_model
+
+    seen: dict = {"descriptions": {}, "results": {}}
+    routes = {"n": 0}
+
+    def _decide(self, messages):
+        if self.response_format:
+            if "assignments" not in json.dumps(self.response_format, ensure_ascii=False):
+                return AIMessage(content="{}")
+            # 调度者:第一轮派给成员,第二轮收工
+            routes["n"] += 1
+            plan = ({"assignments": [{"agent": "analyst", "instruction": "查合计"}]} if routes["n"] == 1
+                    else {"assignments": [], "done": True})
+            return AIMessage(content=json.dumps(plan, ensure_ascii=False))
+        if not self.tools:
+            return AIMessage(content="收尾")
+        names = {t["name"] for t in self.tools}
+        who = "agent" if f"db_schema__{source}" in names else "member"
+        for t in self.tools:
+            seen["descriptions"][(who, t["name"])] = t["description"]
+        done = set()
+        for m in messages:
+            if isinstance(m, ToolMessage):
+                seen["results"][m.tool_call_id] = str(m.content)
+                done.add(m.tool_call_id)
+        plan = [(f"{who}_schema", f"db_schema__{source}", {}), (f"{who}_sum", f"db_query__{source}", {"sql": SUM})]
+        for call_id, name, args in plan:
+            if name in names and call_id not in done:
+                return AIMessage(content="查一下。", tool_calls=[{"name": name, "args": args, "id": call_id}])
+        return AIMessage(content="查完了。")
+
+    monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
+    return seen
+
+
+def json_in(text: str) -> dict:
+    """工具结果交给模型时前面可能标了证据编号:从第一个「{」起读。"""
+    return json.loads(text[text.index("{"):])
+
+
+# --------------------------------------------------------------------------
+# 发起时固定哪些源
+# --------------------------------------------------------------------------
+
+
+async def test_start_pins_only_the_upload_sources_the_graph_uses(engine_up, monkeypatch, tmp_path):
+    used, unused = uniq(), uniq()
+    used_id, used_snap = await publish(used, V1)
+    unused_id, _ = await publish(unused, V1)
+    manual_id, manual, _ = await manual_source(tmp_path, 5)
+    scripted_agent(monkeypatch, used)
+
+    graph = chain(
+        node("start", "input"),
+        node("t1", "tool", tool=f"db_query__{used}", args={"sql": SUM}),
+        node("bot", "agent", prompt="查一下", tools=[f"db_schema__{used}", f"db_query__{manual}"], max_steps=3),
+        node("out", "output", fields=[{"name": "t1", "value": "{{ nodes.t1 }}"}]),
+    )
+    run = await run_manager.start(graph=graph, input_payload={})
+    pinned = {used_id: {"snapshot": used_snap, "name": used}}
+    assert run.data_versions == pinned
+    done = await wait(run.id, ("succeeded", "failed"))
+    assert done.status == "succeeded", done.error
+    assert done.data_versions == pinned
+    assert unused_id not in done.data_versions and manual_id not in done.data_versions
+    # run.started 记下固定的版本:封存清单里查得到这次运行用的是哪一版
+    assert (await started_events(run.id))[0].data["data_versions"] == pinned
+
+
+async def test_subgraph_sources_are_pinned_and_templated_names_pin_every_upload_source(tmp_path):
+    top, inner, other, off = uniq(), uniq(), uniq(), uniq()
+    top_id, top_snap = await publish(top, V1)
+    inner_id, inner_snap = await publish(inner, V1)
+    other_id, _ = await publish(other, V1)
+    off_id, _ = await publish(off, V1)
+    _, manual, _ = await manual_source(tmp_path, 5)
+    async with SessionLocal() as session:
+        (await session.get(DataSource, off_id)).enabled = False
+        sub = Workflow(name="子流程", graph=chain(
+            node("s", "input"), node("q", "tool", tool=f"db_query__{inner}", args={"sql": SUM}), node("o", "output")))
+        session.add(sub)
+        await session.commit()
+        sub_id = sub.id
+
+    graph = chain(
+        node("start", "input"),
+        node("t", "tool", tool=f"db_query__{top}", args={"sql": SUM}),
+        node("sub", "subgraph", workflow_id=sub_id),
+        node("m", "tool", tool=f"db_query__{manual}", args={"sql": SUM}),
+        node("out", "output"),
+    )
+    async with SessionLocal() as session:
+        pinned = await _data_versions(session, GraphSpec.model_validate(graph))
+    # 子工作流里绑的源也固定;没用到的、手工的不固定
+    assert pinned == {top_id: {"snapshot": top_snap, "name": top},
+                      inner_id: {"snapshot": inner_snap, "name": inner}}
+
+    # 工具名带模板:发起时说不准会用哪个源,所有启用着的上传源都固定上,停用的、手工的不算
+    graph["nodes"][1]["data"]["config"]["tool"] = "db_query__{{ vars.which }}"
+    async with SessionLocal() as session:
+        everything = await _data_versions(session, GraphSpec.model_validate(graph))
+        uploads = {r.id: r.current_snapshot_id for r in (await session.execute(select(DataSource).where(
+            DataSource.origin == "upload", DataSource.enabled.is_(True),
+            DataSource.current_snapshot_id.is_not(None)))).scalars()}
+    assert {sid: v["snapshot"] for sid, v in everything.items()} == uploads
+    assert {top_id, inner_id, other_id} <= set(everything) and off_id not in everything
+
+    # 一个上传源都没用到:空 dict(不是 None,None 留给升级前发起的运行)
+    async with SessionLocal() as session:
+        assert await _data_versions(session, GraphSpec.model_validate(
+            chain(node("start", "input"), node("out", "output")))) == {}
+
+
+# --------------------------------------------------------------------------
+# 运行中途重传:恢复、继续运行都还是发起时那一版
+# --------------------------------------------------------------------------
+
+
+async def test_reupload_while_waiting_for_approval_does_not_change_the_run(engine_up, monkeypatch):
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    seen = scripted_agent(monkeypatch, src)
+    graph = chain(
+        node("start", "input"),
+        node("t1", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
+        node("gate", "human", mode="approve", title="看一眼"),
+        node("bot", "agent", prompt="查合计", tools=[f"db_schema__{src}", f"db_query__{src}"], max_steps=4),
+        node("team", "supervisor", goal="查合计", max_rounds=2,
+             agents=[{"name": "analyst", "system": "你是分析员", "tools": [f"db_query__{src}"], "max_steps": 3}]),
+        node("t2", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
+        node("out", "output", fields=[{"name": "t1", "value": "{{ nodes.t1 }}"},
+                                      {"name": "t2", "value": "{{ nodes.t2 }}"}]),
+    )
+    run = await run_manager.start(graph=graph, input_payload={})
+    assert (await wait(run.id)).status == "interrupted"
+
+    # 停在审批上时重传:当前版本变成 S2(合计 100,多一张「新增」)
+    _, s2 = await publish(src, V2)
+    assert s2 != s1
+    await run_manager.resume(run.id, {"approved": True})
+    done = await wait(run.id, ("succeeded", "failed"))
+    assert done.status == "succeeded", done.error
+
+    assert output_total(done, "t1") == 3
+    assert output_total(done, "t2") == 3, "恢复之后查到了重传的新版本"
+    # 恢复后才建的 Agent 和协作成员的工具:描述、表结构、查询都还是 S1
+    for who in ("agent", "member"):
+        desc = seen["descriptions"][(who, f"db_query__{src}")]
+        assert "明细" in desc and "新增" not in desc, who
+    assert "共 1 张表" in seen["results"]["agent_schema"] and "新增" not in seen["results"]["agent_schema"]
+    assert json_in(seen["results"]["agent_sum"])["rows"] == [[3]]
+    assert json_in(seen["results"]["member_sum"])["rows"] == [[3]]
+    # 运行记录和两段 run.started 记的都是 S1
+    assert done.data_versions == {sid: {"snapshot": s1, "name": src}}
+    assert [e.data["data_versions"] for e in await started_events(run.id)] == [
+        {sid: {"snapshot": s1, "name": src}}] * 2
+
+
+async def test_continue_after_failure_keeps_the_version(engine_up):
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+
+    run = await run_manager.start(graph=loop_graph(src, src, FAILS_AT_RUNTIME), input_payload={})
+    assert (await wait(run.id, ("failed", "succeeded"))).status == "failed"
+    await publish(src, V2)
+
+    await run_manager.continue_failed(run.id, graph=loop_graph(src, src, "false"))
+    done = await wait(run.id, ("succeeded", "failed"))
+    assert done.status == "succeeded", done.error
+    assert output_total(done, "t1") == 3
+    assert output_total(done, "t2") == 3, "继续运行时改用了重传的新版本"
+    assert [e.data["data_versions"] for e in await started_events(run.id)] == [
+        {sid: {"snapshot": s1, "name": src}}] * 2
+
+
+# --------------------------------------------------------------------------
+# 工具层:按固定的版本生成、固定的版本没了就报错
+# --------------------------------------------------------------------------
+
+
+async def test_tools_follow_the_pinned_snapshot_and_record_data_version():
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    await keep(sid, src, s1)
+    _, s2 = await publish(src, V2)
+    pins = {sid: {"snapshot": s1, "name": src}}
+    try:
+        tools = await tools_for([f"db_query__{src}", f"db_schema__{src}"], data_versions=pins)
+        query, schema = tools[f"db_query__{src}"], tools[f"db_schema__{src}"]
+        assert "明细" in query.description and "新增" not in query.description
+        listed = await schema.coroutine()
+        assert "共 1 张表" in listed and "新增" not in listed
+        assert "数量" in await schema.coroutine(table="明细")
+
+        text = await query.coroutine(sql=SUM)
+        payload = json.loads(text)
+        assert total_of(text) == 3
+        # data_version 只进查询快照,不进交给模型的结果
+        assert "data_version" not in payload
+        assert artifact_store.load(payload["artifact"])["data_version"] == s1
+        # 冻结的表结构是 S1 的那份,不是数据源上现在的 S2
+        assert set(artifact_store.load(payload["schema_artifact"])["tables"]) == {"明细"}
+
+        # 不在运行里(工具库试用、data_versions 为 None)用当前版本
+        current = (await tools_for([f"db_query__{src}"]))[f"db_query__{src}"]
+        assert "新增" in current.description
+        text = await current.coroutine(sql=SUM)
+        assert total_of(text) == 100
+        assert artifact_store.load(json.loads(text)["artifact"])["data_version"] == s2
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_pinned_snapshot_collected_by_gc_gives_a_clear_error_and_never_reads_current():
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    # 没有运行引用 S1:重传后的回收把它标成已回收、删掉文件
+    await publish(src, V2)
+    async with SessionLocal() as session:
+        snap = await session.get(SourceSnapshot, s1)
+        assert snap.retired_at is not None and not os.path.exists(snap.db_path)
+    try:
+        tools = await tools_for([f"db_query__{src}", f"db_schema__{src}"],
+                                data_versions={sid: {"snapshot": s1, "name": src}})
+        query, schema = tools[f"db_query__{src}"], tools[f"db_schema__{src}"]
+        # 描述不列表名:表名是当前版本的
+        assert "明细" not in query.description and "新增" not in query.description
+        assert "本次运行固定的数据版本已不存在" in query.description
+        text = await query.coroutine(sql=SUM)
+        assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在")
+        assert "不会改用当前版本" in text and "100" not in text
+        # Agent 里这句话作为工具结果交回(tool.end),运行面板按「查询失败」开头把那一步标成失败
+        assert text.startswith("查询失败")
+        listed = await schema.coroutine()
+        assert "本次运行固定的数据版本已不存在" in listed and "新增" not in listed
+
+        # 版本根本不属于这个源(记录被改坏、别的源的快照)也一样
+        other = uniq()
+        _, foreign = await publish(other, V2)
+        tools = await tools_for([f"db_query__{src}"], data_versions={sid: {"snapshot": foreign, "name": src}})
+        text = await tools[f"db_query__{src}"].coroutine(sql=SUM)
+        assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在") and "100" not in text
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_snapshot_file_deleted_after_tools_were_built():
+    """工具建好之后快照文件被删:缓存的连接还开着已删的文件,也不能拿它接着查。"""
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    await keep(sid, src, s1)
+    await publish(src, V2)
+    try:
+        tools = await tools_for([f"db_query__{src}", f"db_schema__{src}"],
+                                data_versions={sid: {"snapshot": s1, "name": src}})
+        query = tools[f"db_query__{src}"]
+        assert total_of(await query.coroutine(sql=SUM)) == 3      # 连接已建好、进了缓存
+        async with SessionLocal() as session:
+            path = (await session.get(SourceSnapshot, s1)).db_path
+        os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
+        os.unlink(path)
+        text = await query.coroutine(sql=SUM)
+        assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在") and "100" not in text
+        assert (await tools[f"db_schema__{src}"].coroutine()).startswith(
+            f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在")
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_source_first_used_mid_run_is_pinned_and_recorded():
+    """发起时没算到的源(继续运行时改绑、子工作流中途改过):第一次用到时固定,记进运行。"""
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    async with SessionLocal() as session:
+        run = Run(workflow_name="中途补固定", status="running", data_versions={})
+        session.add(run)
+        await session.commit()
+        run_id = run.id
+    pins: dict = {}
+    seen: list = []
+    emit = lambda kind, **data: seen.append((kind, data))  # noqa: E731 - 和 NodeContext.emit 一样是同步的
+    try:
+        tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id, emit=emit)
+        assert pins == {sid: {"snapshot": s1, "name": src, "late": True}}
+        async with SessionLocal() as session:
+            assert (await session.get(Run, run_id)).data_versions == pins
+        # 补固定这件事本身进事件流:当次这一段的 run.started 里没有这个源
+        [(kind, data)] = seen
+        assert kind == EventType.LOG and data["code"] == LATE_PIN_CODE
+        assert (data["source_id"], data["snapshot"], data["late"]) == (sid, s1, True)
+        assert src in data["message"]
+        # 之后重传:同一段执行里(同一个 dict)、恢复后(从运行记录读回)都还是 S1
+        await publish(src, V2)
+        assert total_of(await tools[f"db_query__{src}"].coroutine(sql=SUM)) == 3
+        again = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id, emit=emit)
+        assert len(seen) == 1, "已经固定过的源不该再发一遍"
+        assert total_of(await again[f"db_query__{src}"].coroutine(sql=SUM)) == 3
+        async with SessionLocal() as session:
+            stored = dict((await session.get(Run, run_id)).data_versions)
+        resumed = await tools_for([f"db_query__{src}"], data_versions=stored, run_id=run_id)
+        assert total_of(await resumed[f"db_query__{src}"].coroutine(sql=SUM)) == 3
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_late_pin_takes_what_the_run_record_already_has():
+    """运行记录里已经有这个源(上一段执行补固定的),内存里还没有:认运行记录的,不取当前版本。"""
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    async with SessionLocal() as session:
+        run = Run(workflow_name="已记过", status="running",
+                  data_versions={sid: {"snapshot": s1, "name": src, "late": True}})
+        session.add(run)
+        await session.commit()
+        run_id = run.id
+    await publish(src, V2)
+    pins: dict = {}
+    seen: list = []
+
+    async def emit(kind, **data):   # 协程函数也认
+        seen.append((kind, data))
+
+    try:
+        tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id, emit=emit)
+        assert pinned_of(pins, sid) == s1
+        assert seen == [], "上一段执行补固定时已经发过事件"
+        assert total_of(await tools[f"db_query__{src}"].coroutine(sql=SUM)) == 3
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_manual_sources_are_not_versioned(tmp_path):
+    sid, name, path = await manual_source(tmp_path, 5)
+    pins: dict = {}
+    try:
+        tools = await tools_for([f"db_query__{name}"], data_versions=pins)
+        text = await tools[f"db_query__{name}"].coroutine(sql=SUM)
+        assert total_of(text) == 5
+        assert "data_version" not in artifact_store.load(json.loads(text)["artifact"])
+        assert pins == {}, "手工源不该进 data_versions"
+        # 手工源查的就是那个文件此刻的内容
+        db = sqlite3.connect(path)
+        db.execute('UPDATE "明细" SET "数量" = 9')
+        db.commit()
+        db.close()
+        assert total_of(await tools[f"db_query__{name}"].coroutine(sql=SUM)) == 9
+    finally:
+        await engines.invalidate(sid)
+
+
+# --------------------------------------------------------------------------
+# 子工作流、漏传版本、调用工具节点的报错、图级 defaults、补固定的事件
+# --------------------------------------------------------------------------
+
+
+async def test_subgraph_resumed_after_approval_reads_the_pinned_version(engine_up):
+    """子工作流的工具经它自己的 RunContext 拿版本:父运行停在审批时重传,恢复后子工作流查的仍是 S1。"""
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    async with SessionLocal() as session:
+        sub = Workflow(name="子流程取数", graph=chain(
+            node("s", "input"),
+            node("q", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
+            node("o", "output", fields=[{"name": "t", "value": "{{ nodes.q }}"}])))
+        session.add(sub)
+        await session.commit()
+        sub_id = sub.id
+    graph = chain(
+        node("start", "input"),
+        node("gate", "human", mode="approve", title="看一眼"),
+        node("sub", "subgraph", workflow_id=sub_id),
+        node("out", "output", fields=[{"name": "sub", "value": "{{ nodes.sub.output.t }}"}]),
+    )
+    try:
+        run = await run_manager.start(graph=graph, input_payload={})
+        assert run.data_versions == {sid: {"snapshot": s1, "name": src}}
+        assert (await wait(run.id)).status == "interrupted"
+
+        _, s2 = await publish(src, V2)
+        assert s2 != s1
+        await run_manager.resume(run.id, {"approved": True})
+        done = await wait(run.id, ("succeeded", "failed"))
+        assert done.status == "succeeded", done.error
+        assert output_total(done, "sub") == 3, "子工作流恢复后查到了重传的新版本"
+        payload = json.loads((done.output or {})["sub"])
+        assert artifact_store.load(payload["artifact"])["data_version"] == s1
+        # 拿到的是发起时固定的那份,不是第一次用到时才补的
+        assert done.data_versions == {sid: {"snapshot": s1, "name": src}}
+        assert await late_pin_events(run.id) == []
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_tools_built_without_versions_refuse_upload_sources(tmp_path):
+    """构造 ToolContext 时没给版本(在运行里漏传了):上传源报错,不当成「不在运行里」改用当前版本。"""
+    src = uniq()
+    sid, _ = await publish(src, V1)
+    spec = GraphSpec.model_validate(chain(node("start", "input"), node("out", "output")))
+    # 运行上下文里没有这个键(不是 runner 建的,或往下传时丢了):UNSET,不是 None
+    assert run_versions(RunContext(run_id="r", thread_id="r", spec=spec)) is Versions.UNSET
+    assert run_versions(RunContext(run_id="r", thread_id="r", spec=spec, extra={RUN_VERSIONS_KEY: None})) is None
+    pins: dict = {}
+    assert run_versions(RunContext(run_id="r", thread_id="r", spec=spec, extra={RUN_VERSIONS_KEY: pins})) is pins
+    assert ToolContext().data_versions is Versions.UNSET
+
+    manual_id, manual, _ = await manual_source(tmp_path, 5)
+    try:
+        async with SessionLocal() as session:
+            built = {t.name: t for t in await build_datasource_tools(
+                [f"db_query__{src}", f"db_schema__{src}", f"db_query__{manual}"],
+                ToolContext(run_id="r", node_id="n"), session)}
+        query, schema = built[f"db_query__{src}"], built[f"db_schema__{src}"]
+        assert "没有拿到本次运行固定的数据版本" in query.description and "明细" not in query.description
+        text = await query.coroutine(sql=SUM)
+        assert text.startswith(f"{DATA_UNAVAILABLE}没有拿到本次运行固定的数据版本") and "rows" not in text
+        assert (await schema.coroutine()).startswith(f"{DATA_UNAVAILABLE}没有拿到本次运行固定的数据版本")
+        # 手工源不分版本,照常查
+        assert total_of(await built[f"db_query__{manual}"].coroutine(sql=SUM)) == 5
+    finally:
+        await engines.invalidate(sid)
+        await engines.invalidate(manual_id)
+
+
+@pytest.mark.parametrize("prefix", ["db_query__", "db_schema__"])
+async def test_tool_node_fails_with_the_reason_when_its_pinned_snapshot_is_gone(engine_up, prefix):
+    """停在审批时重传、再删掉 S1 的文件:恢复后调用工具节点判失败,报错照交原因,不让人改 SQL。"""
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    tool = f"{prefix}{src}"
+    graph = chain(
+        node("start", "input"),
+        node("gate", "human", mode="approve", title="看一眼"),
+        node("t", "tool", tool=tool, args={"sql": SUM} if prefix == "db_query__" else {}),
+        node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}]),
+    )
+    try:
+        run = await run_manager.start(graph=graph, input_payload={})
+        assert (await wait(run.id)).status == "interrupted"
+        await publish(src, V2)      # 这次运行引用着 S1,回收不删它
+        async with SessionLocal() as session:
+            path = (await session.get(SourceSnapshot, s1)).db_path
+        os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
+        os.unlink(path)
+
+        await run_manager.resume(run.id, {"approved": True})
+        done = await wait(run.id, ("succeeded", "failed"))
+        assert done.status == "failed"
+        error = done.error or ""
+        assert f"工具 {tool} 无法使用:本次运行固定的数据版本已不存在(数据源「{src}」)" in error, error
+        assert "请重新发起运行" in error
+        # 改 SQL 没用,不能这么指;原话的开头也不能和外面那层叠成两遍
+        assert "SQL" not in error and "查询失败" not in error and error.count(DATA_UNAVAILABLE) == 0, error
+        [err] = await events_of(run.id, "tool.error", "t")
+        assert err.data["error"].startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在")
+        assert not await events_of(run.id, "tool.end", "t")
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_tools_bound_through_graph_defaults_are_pinned_at_start():
+    """Agent 节点没写 tools、协作节点没写成员时运行时取图级 defaults,发起时固定版本也按它算。"""
+    agent_src, member_src = uniq(), uniq()
+    agent_id, agent_snap = await publish(agent_src, V1)
+    member_id, member_snap = await publish(member_src, V1)
+    graph = chain(node("start", "input"), node("bot", "agent", prompt="查一下"),
+                  node("team", "supervisor", goal="查合计"), node("out", "output"))
+    graph["defaults"] = {"tools": [f"db_query__{agent_src}"],
+                         "agents": [{"name": "analyst", "tools": [f"db_schema__{member_src}"]}]}
+    async with SessionLocal() as session:
+        assert await _data_versions(session, GraphSpec.model_validate(graph)) == {
+            agent_id: {"snapshot": agent_snap, "name": agent_src},
+            member_id: {"snapshot": member_snap, "name": member_src}}
+
+        # 发起前检查是同一个取法:defaults 里绑的工具不存在,发起前就拦下
+        ghost = uniq("ghost")
+        broken = {**graph, "defaults": {"tools": [f"db_query__{ghost}"]}}
+        missing = await missing_tools(session, GraphSpec.model_validate(broken))
+        assert [(name, where) for _, name, where in missing] == [(f"db_query__{ghost}", "data")]
+
+        # 节点上写了(哪怕是空列表)就不看 defaults:和 NodeContext.cfg 一样
+        graph["nodes"][1]["data"]["config"]["tools"] = []
+        assert set(await _data_versions(session, GraphSpec.model_validate(graph))) == {member_id}
+
+
+async def test_source_first_used_after_continue_is_pinned_and_leaves_an_event(engine_up):
+    """继续运行时改绑了发起时没用到的源:第一次用到时补固定,记进运行,事件流里留一条日志。"""
+    first, later = uniq(), uniq()
+    first_id, first_snap = await publish(first, V1)
+    later_id, later_snap = await publish(later, V1)
+    try:
+        run = await run_manager.start(graph=loop_graph(first, first, FAILS_AT_RUNTIME), input_payload={})
+        failed = await wait(run.id, ("failed", "succeeded"))
+        assert failed.status == "failed"
+        assert failed.data_versions == {first_id: {"snapshot": first_snap, "name": first}}
+
+        await run_manager.continue_failed(run.id, graph=loop_graph(first, later, "false"))
+        done = await wait(run.id, ("succeeded", "failed"))
+        assert done.status == "succeeded", done.error
+        assert done.data_versions[later_id] == {"snapshot": later_snap, "name": later, "late": True}
+        # 这一段的 run.started 里没有它(发起这一段时还没用到):补固定的那条日志是事件流里唯一的记录
+        assert later_id not in (await started_events(run.id))[-1].data["data_versions"]
+        [event] = await late_pin_events(run.id)
+        assert event.node_id == "t2"
+        assert (event.data["source_id"], event.data["snapshot"], event.data["late"]) == (later_id, later_snap, True)
+    finally:
+        await engines.invalidate(first_id)
+        await engines.invalidate(later_id)
+
+
+async def test_continue_after_the_pinned_source_is_deleted_and_recreated_under_the_same_name(engine_up):
+    """固定的源被删、又用同一个名字新建:续跑报错,不改查同名的新源,也不把新源当成中途新建的补固定。"""
+    from httpx import ASGITransport, AsyncClient
+
+    from app.main import app
+
+    src = uniq()
+    a_id, s1 = await publish(src, V1)
+    b_id = None
+    try:
+        run = await run_manager.start(graph=loop_graph(src, src, FAILS_AT_RUNTIME), input_payload={})
+        failed = await wait(run.id, ("failed", "succeeded"))
+        assert failed.status == "failed"
+        assert failed.data_versions == {a_id: {"snapshot": s1, "name": src}}
+
+        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
+            assert (await c.delete(f"/api/datasources/{a_id}")).status_code == 204
+        b_id, _ = await publish(src, V2)
+        assert b_id != a_id
+
+        await run_manager.continue_failed(run.id, graph=loop_graph(src, src, "false"))
+        done = await wait(run.id, ("succeeded", "failed"))
+        assert done.status == "failed", f"续跑改查了同名重建的新源:{done.output}"
+        error = done.error or ""
+        assert f"本次运行固定的数据源「{src}」已被删除或重建,不会改用同名的新数据源" in error, error
+        assert "SQL" not in error and "100" not in error
+        # 新源没有被补固定进运行记录,也没有补固定的日志
+        assert set(done.data_versions) == {a_id}
+        assert not await late_pin_events(run.id)
+    finally:
+        await engines.invalidate(a_id)
+        if b_id:
+            await engines.invalidate(b_id)
+
+
+async def test_same_name_manual_source_does_not_stand_in_for_a_pinned_upload_source(tmp_path):
+    """同名重建成手工源也拦住:手工源不分版本,但运行固定的是另一个源,不能拿它顶上。"""
+    _, manual, _ = await manual_source(tmp_path, 5)
+    pins = {"gone-source-id": {"snapshot": "0" * 64, "name": manual}}
+    tools = await tools_for([f"db_query__{manual}", f"db_schema__{manual}"], data_versions=pins, run_id="r")
+    text = await tools[f"db_query__{manual}"].coroutine(sql=SUM)
+    assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据源「{manual}」已被删除或重建"), text
+    assert "5" not in text.replace(manual, "")
+    assert (await tools[f"db_schema__{manual}"].coroutine()).startswith(DATA_UNAVAILABLE)
+    # 运行里没固定过这个名字的手工源照常查
+    tools = await tools_for([f"db_query__{manual}"], data_versions={}, run_id="r")
+    assert total_of(await tools[f"db_query__{manual}"].coroutine(sql=SUM)) == 5
+
+
+def _tamper(path: str) -> None:
+    """改掉库文件末尾一个字节(发布后是 0444,先放开写)。"""
+    os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
+    with open(path, "r+b") as f:
+        f.seek(-1, os.SEEK_END)
+        last = f.read(1)
+        f.seek(-1, os.SEEK_END)
+        f.write(bytes([last[0] ^ 0xFF]))
+
+
+async def test_tampered_snapshot_is_reported_as_data_unavailable_not_as_a_sql_error(engine_up):
+    """快照文件被改过(哈希对不上):工具按「数据不可用」交回,调用工具节点不让人去改 SQL。"""
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    graph = chain(node("start", "input"), node("t", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
+                  node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}]))
+    try:
+        async with SessionLocal() as session:
+            path = (await session.get(SourceSnapshot, s1)).db_path
+        await engines.invalidate(sid)       # 下次查询新建引擎,建之前核对哈希
+        _tamper(path)
+
+        for pins in (Versions.CURRENT, {sid: {"snapshot": s1, "name": src}}):
+            tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id="r")
+            text = await tools[f"db_query__{src}"].coroutine(sql=SUM)
+            assert text.startswith(f"{DATA_UNAVAILABLE}数据源「{src}」的数据文件与登记的版本不一致"), text
+            assert "SQL" not in text and "请联系管理员" in text
+
+        run = await run_manager.start(graph=graph, input_payload={})
+        done = await wait(run.id, ("succeeded", "failed"))
+        assert done.status == "failed"
+        error = done.error or ""
+        assert error.startswith(f"工具 db_query__{src} 无法使用:数据源「{src}」的数据文件与登记的版本不一致"), error
+        assert "SQL" not in error and "查询失败" not in error, error
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_reupload_while_a_run_is_being_started_does_not_collect_its_snapshot(engine_up, monkeypatch):
+    """发起运行读完指针、运行记录还没提交时有人重传:发布后的回收看不到这条运行,以前会把刚固定的快照删掉,
+    这次运行只能报「固定的数据版本已不存在」。现在读指针到提交持着版本存储的锁,回收等它提交之后才开始。"""
+    from app.engine import runner
+
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    real = runner._data_versions
+    pending: list[asyncio.Task] = []
+
+    async def racing(session, spec):
+        pins = await real(session, spec)
+        # 读完指针的这一刻有人重传。给它足够的时间做完(没有锁的话它会做完,连同回收)
+        task = asyncio.create_task(publish(src, V2))
+        pending.append(task)
+        await asyncio.wait({task}, timeout=0.5)
+        return pins
+
+    monkeypatch.setattr(runner, "_data_versions", racing)
+    graph = chain(node("start", "input"), node("t", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
+                  node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}]))
+    try:
+        run = await run_manager.start(graph=graph, input_payload={})
+        _, s2 = await pending[0]
+        assert run.data_versions == {sid: {"snapshot": s1, "name": src}} and s2 != s1
+        async with SessionLocal() as session:
+            snap = await session.get(SourceSnapshot, s1)
+            assert snap.retired_at is None and os.path.exists(snap.db_path), "回收删掉了刚固定的快照"
+        done = await wait(run.id, ("succeeded", "failed"))
+        assert done.status == "succeeded", done.error
+        assert output_total(done, "t") == 3
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_late_pin_reads_the_pointer_again_under_the_lock(monkeypatch):
+    """补固定时数据源记录是建工具前读的:那之后重传过、旧快照已回收,不能拿旧指针去固定。
+    在版本存储的锁里重新读指针,固定的是此刻的当前版本,而且回收不会在写进运行记录之前删掉它。"""
+    from app.tools import datasource
+
+    src = uniq()
+    sid, s1 = await publish(src, V1)
+    async with SessionLocal() as session:
+        run = Run(workflow_name="补固定撞上重传", status="running", data_versions={})
+        session.add(run)
+        await session.commit()
+        run_id = run.id
+    real = datasource._late_lock
+    reuploaded: list[str] = []
+
+    class Racing:
+        """进补固定的锁之前,先让一次重传整个做完(发布、切指针、回收掉没人引用的 S1)。"""
+
+        async def __aenter__(self):
+            reuploaded.append((await publish(src, V2))[1])
+            self.lock = real()
+            await self.lock.acquire()
+
+        async def __aexit__(self, *exc):
+            self.lock.release()
+
+    monkeypatch.setattr(datasource, "_late_lock", lambda: Racing())
+    pins: dict = {}
+    try:
+        tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id)
+        [s2] = reuploaded
+        assert pinned_of(pins, sid) == s2, "补固定用了建工具前读到的旧指针"
+        text = await tools[f"db_query__{src}"].coroutine(sql=SUM)
+        assert not text.startswith(DATA_UNAVAILABLE), text
+        assert total_of(text) == 100
+        async with SessionLocal() as session:
+            assert pinned_of((await session.get(Run, run_id)).data_versions, sid) == s2
+    finally:
+        await engines.invalidate(sid)
diff --git a/backend/tests/test_sqlite_dqs.py b/backend/tests/test_sqlite_dqs.py
new file mode 100644
index 00000000..a6e51936
--- /dev/null
+++ b/backend/tests/test_sqlite_dqs.py
@@ -0,0 +1,229 @@
+"""SQLite 连接一律关掉 DQS:写错的双引号列名必须报错,而不是被当成字符串。
+
+DQS(double-quoted string)是 SQLite 的历史兼容:双引号里的名字找不到列时当成字符串。
+于是 `SUM("金颔")` 得 0、`GROUP BY "金颔"` 把整张表归成一组,查询不报错,结论却是错的。
+中文列名常被加上双引号,正好踩中。
+
+这组测试覆盖项目实际用到的三种连接形式(只读源的 mode=ro URI、可写源的普通路径、
+表单里的一次性测试连接),以及关不掉时连接被拒、启动自检。
+"""
+from __future__ import annotations
+
+import sqlite3
+import uuid
+from types import SimpleNamespace
+
+import aiosqlite
+import pytest
+from sqlalchemy import text
+from sqlalchemy.exc import OperationalError
+from sqlalchemy.ext.asyncio import create_async_engine
+from sqlalchemy.pool import NullPool
+
+from app.data import engine as engine_mod
+from app.data.engine import engine_args, engines, run_query, sqlite_dqs_self_check
+
+
+def _db(path) -> str:
+    conn = sqlite3.connect(path)
+    conn.execute("CREATE TABLE 时段客流 (分区 TEXT, 客流 INTEGER)")
+    conn.executemany("INSERT INTO 时段客流 VALUES (?, ?)",
+                     [("分区甲", 120), ("分区乙", 80), ("分区甲", 30)])
+    conn.commit()
+    conn.close()
+    return str(path)
+
+
+def _source(path: str, *, readonly: bool = True):
+    return SimpleNamespace(
+        id=f"dqs-{uuid.uuid4().hex[:8]}", kind="sqlite", database=path, readonly=readonly,
+        name="dqs_probe", options={}, password=None, username=None, host=None, port=None,
+    )
+
+
+@pytest.fixture
+async def ro_source(tmp_path):
+    src = _source(_db(tmp_path / "客流 样例.db"))
+    yield src
+    await engines.invalidate(src.id)
+
+
+@pytest.fixture
+async def rw_source(tmp_path):
+    src = _source(_db(tmp_path / "rw.db"), readonly=False)
+    yield src
+    await engines.invalidate(src.id)
+
+
+# ---------------------------------------------------------------------------
+# 三种连接形式下,写错的双引号列名都报错
+# ---------------------------------------------------------------------------
+
+async def test_misspelled_quoted_column_errors_on_readonly_uri_form(ro_source):
+    """只读源走 sqlite+aiosqlite:///file:…?mode=ro&uri=true——critic C4 实测踩坑的正是这个形式。"""
+    url, _ = engine_args(ro_source)
+    assert url.startswith("sqlite+aiosqlite:///file:") and url.endswith("?mode=ro&uri=true")
+
+    for sql in ('SELECT SUM("客六") FROM 时段客流',
+                'SELECT "客六", COUNT(*) FROM 时段客流 GROUP BY "客六"',
+                'SELECT 分区 FROM 时段客流 WHERE "分去" = 1'):
+        with pytest.raises(OperationalError, match="no such column"):
+            await run_query(ro_source, sql)
+
+    # 写对的双引号列名照常可用
+    ok = await run_query(ro_source, 'SELECT SUM("客流") AS n FROM "时段客流"')
+    assert ok.rows == [[230]]
+    # 单引号字符串照常是字符串
+    lit = await run_query(ro_source, "SELECT COUNT(*) FROM 时段客流 WHERE 分区 = '分区甲'")
+    assert lit.rows == [[2]]
+
+
+async def test_misspelled_quoted_column_errors_on_writable_form(rw_source):
+    """可写源走普通路径;DDL 里的双引号字符串也一并关掉。"""
+    url, _ = engine_args(rw_source)
+    assert "mode=ro" not in url
+
+    with pytest.raises(OperationalError, match="no such column"):
+        await run_query(rw_source, 'SELECT SUM("客六") FROM 时段客流')
+
+    engine = await engines.get(rw_source)
+    async with engine.connect() as conn:
+        with pytest.raises(OperationalError, match="no such column"):
+            await conn.execute(text('CREATE INDEX 错索引 ON 时段客流 ("不存在")'))
+
+
+async def test_one_off_probe_engine_is_covered_too(ro_source):
+    """表单里的「测试连接」用 engine_args 现建一次性 engine(datasources._probe),不走缓存,
+    也得关 DQS:关闭措施挂在 engine_args 给出的连接参数上,而不是只挂在缓存建的 engine 上。"""
+    url, extra = engine_args(ro_source)
+    engine = create_async_engine(url, poolclass=NullPool, **extra)
+    try:
+        async with engine.connect() as conn:
+            with pytest.raises(OperationalError, match="no such column"):
+                await conn.execute(text('SELECT SUM("客六") FROM 时段客流'))
+    finally:
+        await engine.dispose()
+
+
+async def test_aiosqlite_passes_factory_through():
+    """钉住中间那一层:aiosqlite.connect 把关键字参数原样交给 sqlite3.connect。
+
+    关 DQS 靠 sqlite3.connect 的 factory 参数,一路经 SQLAlchemy connect_args →
+    aiosqlite.connect(**kwargs) → sqlite3.connect(factory=…) 传下去。哪天 aiosqlite
+    不再转交,这里先失败。
+    """
+    async with aiosqlite.connect(":memory:", factory=engine_mod._DqsOffConnection) as conn:
+        with pytest.raises(sqlite3.OperationalError, match="no such column"):
+            await conn.execute('SELECT "不存在"')
+
+
+# ---------------------------------------------------------------------------
+# 关不掉就拒绝这条连接
+# ---------------------------------------------------------------------------
+
+def _raise_on_setconfig(self, op, enable=True):
+    raise sqlite3.OperationalError("模拟:setconfig 失败")
+
+
+def _ignore_setconfig(self, op, enable=True):
+    return None   # 调用成功,但什么也没设上
+
+
+@pytest.mark.parametrize("fake", [_raise_on_setconfig, _ignore_setconfig],
+                         ids=["setconfig 抛错", "setconfig 没生效"])
+async def test_connection_is_refused_when_dqs_cannot_be_disabled(ro_source, monkeypatch, fake):
+    monkeypatch.setattr(engine_mod._DqsOffConnection, "setconfig", fake)
+    engine = await engines.get(ro_source)
+    with pytest.raises(OperationalError, match="未能关闭 SQLite 的双引号字符串兼容,已拒绝连接"):
+        async with engine.connect() as conn:
+            await conn.execute(text("SELECT 1"))
+    # 走查询入口同样连不上,不会退回一条没关 DQS 的连接
+    with pytest.raises(OperationalError, match="已拒绝连接"):
+        await run_query(ro_source, "SELECT COUNT(*) FROM 时段客流")
+
+
+async def test_connection_is_refused_when_sqlite_lacks_the_option(ro_source, monkeypatch):
+    """Python 3.12 之前的 sqlite3 没有这两个常量:照样拒绝,而不是当作没这回事。"""
+    monkeypatch.setattr(engine_mod, "_DQS_OPTIONS", (("DML", None), ("DDL", None)))
+    engine = await engines.get(ro_source)
+    with pytest.raises(OperationalError, match="已拒绝连接"):
+        async with engine.connect() as conn:
+            await conn.execute(text("SELECT 1"))
+
+
+def test_refusal_is_a_driver_error():
+    """拒绝用的异常是 sqlite3.OperationalError 的子类:SQLAlchemy 按驱动异常包装,
+    上层照常当作「连接失败」处理。"""
+    assert issubclass(engine_mod.SqliteHardeningError, sqlite3.OperationalError)
+
+
+# ---------------------------------------------------------------------------
+# 启动自检
+# ---------------------------------------------------------------------------
+
+def test_self_check_passes_on_this_platform():
+    sqlite_dqs_self_check()
+
+
+@pytest.mark.parametrize("fake", [_raise_on_setconfig, _ignore_setconfig],
+                         ids=["setconfig 抛错", "setconfig 没生效"])
+def test_self_check_fails_when_dqs_cannot_be_disabled(monkeypatch, fake):
+    monkeypatch.setattr(engine_mod._DqsOffConnection, "setconfig", fake)
+    with pytest.raises(RuntimeError, match="SQLite 自检失败"):
+        sqlite_dqs_self_check()
+
+
+def test_self_check_fails_when_quoted_names_still_pass(monkeypatch):
+    """连接建得起来、却没关 DQS(比如关闭措施被人改没了):自检得看结果,不只看有没有抛错。"""
+    monkeypatch.setattr(engine_mod, "_disable_dqs", lambda conn: None)
+    with pytest.raises(RuntimeError, match="没有报错"):
+        sqlite_dqs_self_check()
+
+
+def test_self_check_catches_ddl_left_on(monkeypatch):
+    """只关了 DML 那项、DDL 里的双引号字符串还开着:查询探针照常报错,建索引探针不报——
+    自检得靠建索引那条把它认出来。"""
+    dml_only = tuple(item for item in engine_mod._DQS_OPTIONS if item[0] == "DML")
+    assert len(dml_only) == 1
+    monkeypatch.setattr(engine_mod, "_DQS_OPTIONS", dml_only)
+    with pytest.raises(RuntimeError, match="CREATE INDEX") as info:
+        sqlite_dqs_self_check()
+    assert "没有报错" in str(info.value)
+
+
+@pytest.mark.parametrize("probe", ["SELECT a FROM no_such_table",
+                                   'SELECT "no_such_column" FROM'],
+                         ids=["查不存在的表", "语法错误"])
+def test_self_check_rejects_the_wrong_kind_of_error(monkeypatch, probe):
+    """探针因为别的原因报错,说明它没走到双引号名字那一步,什么也没验到:自检照样失败,
+    不能见到报错就当通过。"""
+    monkeypatch.setattr(engine_mod, "_SELF_CHECK_PROBES", (probe,))
+    with pytest.raises(RuntimeError, match="不是预期的「no such column」"):
+        sqlite_dqs_self_check()
+
+
+def test_self_check_fails_when_the_probe_table_cannot_be_made(monkeypatch):
+    monkeypatch.setattr(engine_mod, "_SELF_CHECK_SETUP", "CREATE TABLE")
+    with pytest.raises(RuntimeError, match="SQLite 自检失败"):
+        sqlite_dqs_self_check()
+
+
+async def test_startup_runs_the_self_check_first(monkeypatch):
+    """main 的启动流程第一步就自检,失败时服务起不来(异常从 lifespan 抛出)。"""
+    from app.main import app, lifespan, settings
+
+    calls: list[str] = []
+
+    def failing() -> None:
+        calls.append("self_check")
+        raise RuntimeError("SQLite 自检失败:模拟")
+
+    def must_not_run(self) -> None:
+        calls.append("ensure_dirs")
+
+    monkeypatch.setattr(engine_mod, "sqlite_dqs_self_check", failing)
+    monkeypatch.setattr(type(settings), "ensure_dirs", must_not_run)
+    with pytest.raises(RuntimeError, match="SQLite 自检失败"):
+        async with lifespan(app):
+            pass
+    assert calls == ["self_check"]
diff --git a/backend/tests/test_table_versions.py b/backend/tests/test_table_versions.py
new file mode 100644
index 00000000..50b8730a
--- /dev/null
+++ b/backend/tests/test_table_versions.py
@@ -0,0 +1,968 @@
+"""上传表格的版本底座:构建、导入记录、快照,发布、迁移、回收、原件存档。
+
+要守住的几件事:
+- 发布失败(解析拒收、文件对不上、写记录出错)时,指针不动、旧版本完整可查、不留新文件;
+- 同一个源重传同一个文件是同一个构建、新的导入记录;两个源传同一个文件是两个构建;
+- 运行钉住的快照不被回收,钉住的快照没了就明确报错,绝不退回当前版本;
+- 原件按哈希只存一份、只读、没有下载口子,清除后记录还在。
+
+夹具一律现造(openpyxl),标签用假名。解析器(tabular.load_into)的新接口由 WP-B 实现:
+它还没合进来时,用这里的最小替身(只认简单的一行表头 + 数据);合进来以后自动换成真的。
+"""
+from __future__ import annotations
+
+import hashlib
+import inspect
+import io
+import os
+import sqlite3
+import stat
+import uuid
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import pytest
+from sqlalchemy import func, select
+
+from app.core.config import settings
+from app.data import raw_store, table_versions, tabular
+from app.data.engine import engines, run_query
+from app.data.table_versions import SnapshotMissing
+from app.db.base import SessionLocal
+from app.db.models import DataSource, Run, SourceSnapshot, TableBuild, TableImport
+
+# --------------------------------------------------------------------------
+# 解析器替身(WP-B 合并前)
+# --------------------------------------------------------------------------
+
+REAL_PARSER = "raw_mode" in inspect.signature(tabular.load_into).parameters
+
+
+@dataclass
+class StubTable:
+    name: str
+    sheet: str
+    columns: list[dict[str, Any]]
+    rows: int
+    region: str
+    blank_rows_skipped: int = 0
+    columns_trimmed: list[str] = field(default_factory=list)
+    unshaped: bool = False
+
+
+@dataclass
+class StubReport:
+    tables: list[StubTable] = field(default_factory=list)
+    skipped_sheets: list[dict[str, Any]] = field(default_factory=list)
+    conversions: list[dict[str, Any]] = field(default_factory=list)
+    warnings: list[str] = field(default_factory=list)
+
+
+class StubNeedsDecision(tabular.UnsupportedTable):
+    """tabular.NeedsDecision 的形状:UnsupportedTable 的子类,带 kind 和 details。"""
+
+    def __init__(self, message: str, *, kind: str, details: dict[str, Any]) -> None:
+        super().__init__(message)
+        self.kind = kind
+        self.details = details
+
+
+def stub_load_into(db_path: str, raw: bytes, filename: str, *, header_row: int = 1,
+                   mixed: str = "reject", raw_mode: bool = False, scan: Any = None) -> StubReport:
+    """一行表头 + 数据的最小解析:整列是整数就 INTEGER,否则 TEXT;STRICT 表。"""
+    from openpyxl import load_workbook
+    from openpyxl.utils import get_column_letter
+
+    if not filename.lower().endswith((".xlsx", ".xlsm")):
+        raise tabular.UnsupportedTable(f"无法读取 {filename}:仅支持 Excel(.xlsx)")
+    try:
+        book = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
+    except Exception as e:  # noqa: BLE001
+        raise tabular.UnsupportedTable("无法打开该 Excel 文件") from e
+    report = StubReport()
+    conn = sqlite3.connect(db_path)
+    try:
+        for ws in book.worksheets:
+            rows = [list(r) for r in ws.iter_rows(min_row=header_row, values_only=True)]
+            if not rows:
+                continue
+            headers = [str(h) for h in rows[0]]
+            body = rows[1:]
+            types = []
+            for i in range(len(headers)):
+                vals = [r[i] for r in body if r[i] is not None]
+                types.append("INTEGER" if vals and all(isinstance(v, int) for v in vals) else "TEXT")
+            cols = ", ".join(f'"{h}" {t}' for h, t in zip(headers, types))
+            conn.execute(f'CREATE TABLE "{ws.title}" ({cols}) STRICT')
+            conn.executemany(
+                f'INSERT INTO "{ws.title}" VALUES ({", ".join("?" * len(headers))})',
+                [[None if v is None else (v if t == "INTEGER" else str(v)) for v, t in zip(r, types)]
+                 for r in body],
+            )
+            report.tables.append(StubTable(
+                name=ws.title, sheet=ws.title,
+                columns=[{"name": h, "type": t, "header": h} for h, t in zip(headers, types)],
+                rows=len(body),
+                region=f"A{header_row}:{get_column_letter(len(headers))}{header_row + len(body)}",
+                unshaped=raw_mode,
+            ))
+        conn.commit()
+    finally:
+        conn.close()
+        book.close()
+    return report
+
+
+@pytest.fixture(autouse=True)
+def parser(monkeypatch):
+    if not REAL_PARSER:
+        monkeypatch.setattr(tabular, "load_into", stub_load_into)
+
+
+@pytest.fixture(autouse=True)
+def store(monkeypatch, tmp_path):
+    """每个测试一个独立的数据目录:上传目录、原件存档都在里面,回收和启动清理只看得到这里。"""
+    data = tmp_path / "data"
+    (data / "uploads").mkdir(parents=True)
+    monkeypatch.setattr(settings, "data_dir", data)
+    yield data
+    # 测试目录随后会被删,权限 0444 的文件挡不住删除(目录可写即可)
+
+
+# --------------------------------------------------------------------------
+# 工具
+# --------------------------------------------------------------------------
+
+
+def xlsx(sheets: dict[str, list[list]]) -> bytes:
+    from openpyxl import Workbook
+
+    book = Workbook()
+    book.remove(book.active)
+    for title, rows in sheets.items():
+        sheet = book.create_sheet(title)
+        for row in rows:
+            sheet.append(row)
+    buf = io.BytesIO()
+    book.save(buf)
+    return buf.getvalue()
+
+
+V1 = {"甲表": [["区域", "数量"], ["分区甲", 1], ["分区乙", 2]],
+      "乙表": [["时段", "客流"], ["早", 10], ["晚", 20]]}
+V2 = {"甲表": [["区域", "数量"], ["分区甲", 100]],
+      "乙表": [["时段", "客流"], ["早", 1000]]}
+
+
+def unique(prefix: str = "wpc") -> str:
+    return f"{prefix}_{uuid.uuid4().hex[:10]}"
+
+
+def fresh(sheets: dict) -> dict:
+    """内容独一份的工作簿。原件按内容共用:别的测试传过同样字节的文件,原件就不会随回收删除。"""
+    return {**sheets, "丙表": [["标记"], [uuid.uuid4().hex]]}
+
+
+async def publish(name: str, sheets: dict | None = None, *, raw: bytes | None = None,
+                  filename: str = "客流.xlsx", **kw: Any):
+    """建(或更新)一个上传源并发布。返回 (源 id, ImportResult)。"""
+    raw = raw if raw is not None else xlsx(sheets or V1)
+    async with SessionLocal() as session:
+        row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
+        if row is None:
+            row = DataSource(id=uuid.uuid4().hex, name=name, kind="sqlite", readonly=True, origin="upload")
+        result = await table_versions.publish_upload(session, row, raw, filename, **kw)
+        return row.id, result
+
+
+async def load(source_id: str) -> DataSource:
+    async with SessionLocal() as session:
+        return await session.get(DataSource, source_id)
+
+
+async def resolve(source_id: str, pinned: str | None = None):
+    async with SessionLocal() as session:
+        row = await session.get(DataSource, source_id)
+        return await table_versions.resolve_source(session, row, pinned)
+
+
+async def scalar(source, sql: str):
+    try:
+        return (await run_query(source, sql)).rows[0][0]
+    finally:
+        await engines.invalidate(source.id)
+
+
+async def imports_of(source_id: str) -> list[TableImport]:
+    async with SessionLocal() as session:
+        return list((await session.execute(
+            select(TableImport).where(TableImport.source_id == source_id).order_by(TableImport.seq)
+        )).scalars())
+
+
+async def get(model, key):
+    async with SessionLocal() as session:
+        return await session.get(model, key)
+
+
+def files_under(root: Path) -> list[Path]:
+    return sorted(p for p in root.rglob("*") if p.is_file()) if root.exists() else []
+
+
+# --------------------------------------------------------------------------
+# id
+# --------------------------------------------------------------------------
+
+
+def test_build_id_covers_source_raw_options_and_engine():
+    opts = table_versions.parse_options("a.xlsx", header_row=1, mixed="reject", raw_mode=False)
+    base = table_versions.build_id("s1", "a" * 64, opts)
+    assert len(base) == 64
+    assert table_versions.build_id("s2", "a" * 64, opts) != base
+    assert table_versions.build_id("s1", "b" * 64, opts) != base
+    other = table_versions.parse_options("a.xlsx", header_row=2, mixed="reject", raw_mode=False)
+    assert table_versions.build_id("s1", "a" * 64, other) != base
+    # Excel 换个文件名还是同一个构建;CSV 的表名取自文件名,所以文件名算进去
+    renamed = table_versions.parse_options("b.xlsx", header_row=1, mixed="reject", raw_mode=False)
+    assert table_versions.build_id("s1", "a" * 64, renamed) == base
+    csv_a = table_versions.parse_options("a.csv", header_row=1, mixed="reject", raw_mode=False)
+    csv_b = table_versions.parse_options("b.csv", header_row=1, mixed="reject", raw_mode=False)
+    assert table_versions.build_id("s1", "a" * 64, csv_a) != table_versions.build_id("s1", "a" * 64, csv_b)
+
+
+def test_build_path_refuses_odd_source_ids():
+    with pytest.raises(ValueError):
+        table_versions.build_path("../etc", "x")
+
+
+# --------------------------------------------------------------------------
+# 原件存档
+# --------------------------------------------------------------------------
+
+
+def test_put_raw_stores_once_by_hash_and_read_only(store):
+    sha, path = raw_store.put_raw(b"hello")
+    assert sha == hashlib.sha256(b"hello").hexdigest()
+    assert path == store / "uploads" / "raw" / sha[:2] / sha
+    assert path.read_bytes() == b"hello"
+    assert stat.S_IMODE(path.stat().st_mode) == 0o444
+    again, path2 = raw_store.put_raw(b"hello")
+    assert (again, path2) == (sha, path)
+    assert raw_store.raw_exists(sha)
+    # 没有临时文件残留
+    assert [p.name for p in path.parent.iterdir()] == [sha]
+
+
+def test_put_raw_replaces_a_corrupted_copy(store):
+    sha, path = raw_store.put_raw(b"payload")
+    os.chmod(path, 0o644)
+    path.write_bytes(b"")
+    raw_store.put_raw(b"payload")
+    assert path.read_bytes() == b"payload"
+
+
+def test_purge_raw_and_bad_hashes(store):
+    sha, path = raw_store.put_raw(b"to be purged")
+    assert raw_store.purge_raw(sha) is True
+    assert not path.exists() and not raw_store.raw_exists(sha)
+    assert raw_store.purge_raw(sha) is False
+    assert raw_store.raw_exists("../../etc/passwd") is False
+    with pytest.raises(ValueError):
+        raw_store.raw_path("../../x")
+
+
+def test_raw_store_has_no_reader():
+    """原件不提供下载:模块里没有读回内容的函数,接口层也没有对应的路由。"""
+    public = {n for n in dir(raw_store) if not n.startswith("_")}
+    assert not {n for n in public if n.startswith(("get", "read", "open", "load"))}
+    from app.main import app
+
+    paths = [getattr(r, "path", "") for r in app.routes]
+    assert not [p for p in paths if "raw" in p and "purge" not in p]
+
+
+# --------------------------------------------------------------------------
+# 发布
+# --------------------------------------------------------------------------
+
+
+async def test_publish_creates_build_import_snapshot_and_switches_pointer(store):
+    name = unique()
+    sid, result = await publish(name)
+    row = await load(sid)
+    assert row.origin == "upload" and row.readonly is True
+    assert row.current_snapshot_id == result.snapshot_id
+
+    build = await get(TableBuild, result.build_id)
+    snap = await get(SourceSnapshot, result.snapshot_id)
+    [imp] = await imports_of(sid)
+    assert imp.id == result.import_id and imp.seq == 1 and imp.status == "active"
+    assert imp.raw_state == "kept" and raw_store.raw_exists(imp.raw_sha256)
+    assert snap.imports == [imp.id] and snap.db_path == build.db_path == row.database
+    assert Path(build.db_path).parent == store / "uploads" / "tables" / sid / "builds"
+    assert build.db_sha256 == raw_store.sha256_file(Path(build.db_path)) == snap.db_sha256
+    assert {t["name"] for t in result.report["tables"]} == {"甲表", "乙表"}
+    # 库文件只读:任何代码路径都写不进去
+    assert stat.S_IMODE(Path(build.db_path).stat().st_mode) == 0o444
+    with pytest.raises(sqlite3.OperationalError):
+        conn = sqlite3.connect(build.db_path)
+        try:
+            conn.execute('INSERT INTO "甲表" VALUES (\'x\', 1)')
+            conn.commit()
+        finally:
+            conn.close()
+    # 结构冻结在快照里,和数据源上的一致
+    assert set(snap.schema_cache["tables"]) == {"甲表", "乙表"}
+    assert row.schema_cache == snap.schema_cache
+    # 没有临时文件
+    assert not [p for p in files_under(store / "uploads") if raw_store.TMP_MARK in p.name]
+
+    view = await resolve(sid)
+    assert view.snapshot_id == snap.id and view.immutable is True and view.readonly is True
+    assert view.expected_sha256 == build.db_sha256 and view.database == snap.db_path
+    assert await scalar(view, 'SELECT SUM("数量") FROM "甲表"') == 3
+
+
+async def test_same_file_twice_is_one_build_and_two_imports(store):
+    name = unique()
+    raw = xlsx(V1)
+    sid, first = await publish(name, raw=raw)
+    _, second = await publish(name, raw=raw, filename="改了名字.xlsx")
+    assert second.build_id == first.build_id and second.build_reused is True
+    assert first.build_reused is False
+    assert second.import_id != first.import_id and second.snapshot_id != first.snapshot_id
+    imps = await imports_of(sid)
+    assert [i.seq for i in imps] == [1, 2]
+    assert imps[1].status == "active" and imps[0].status != "active"
+    assert imps[1].file_name == "改了名字.xlsx"
+    # 库文件只有一个,仍在、仍是当前版本
+    builds = files_under(store / "uploads" / "tables" / sid)
+    assert [p.stem for p in builds] == [first.build_id]
+    assert (await load(sid)).current_snapshot_id == second.snapshot_id
+
+
+async def test_two_sources_same_file_get_separate_builds(store):
+    raw = xlsx(V1)
+    a, ra = await publish(unique(), raw=raw)
+    b, rb = await publish(unique(), raw=raw)
+    assert ra.build_id != rb.build_id
+    ba, bb = await get(TableBuild, ra.build_id), await get(TableBuild, rb.build_id)
+    assert ba.db_path != bb.db_path and ba.source_id == a and bb.source_id == b
+    # 删掉 a 并回收:b 的库和共用的原件都还在
+    async with SessionLocal() as session:
+        await table_versions.retire_source(session, a)
+        await session.delete(await session.get(DataSource, a))
+        await session.commit()
+        await table_versions.gc(session)
+    assert not Path(ba.db_path).exists()
+    assert Path(bb.db_path).exists()
+    assert raw_store.raw_exists(ba.raw_sha256)
+    assert {i.status for i in await imports_of(a)} == {"retired"}
+    assert await scalar(await resolve(b), 'SELECT COUNT(*) FROM "乙表"') == 2
+
+
+async def test_concurrent_reuploads_are_serialized(store):
+    """同一个源同时来两次重传:序号不重、最后只有一条 active,指针指向其中一次。"""
+    import asyncio
+
+    name = unique()
+    sid, _ = await publish(name)
+    results = await asyncio.gather(publish(name, fresh(V2)), publish(name, fresh(V1)))
+    imps = await imports_of(sid)
+    assert [i.seq for i in imps] == [1, 2, 3]
+    assert [i.status for i in imps].count("active") == 1
+    current = (await load(sid)).current_snapshot_id
+    assert current in {r.snapshot_id for _, r in results}
+    active = next(i for i in imps if i.status == "active")
+    assert (await get(SourceSnapshot, current)).imports == [active.id]
+
+
+async def test_parses_run_at_most_two_at_a_time(store, monkeypatch):
+    """解析在线程里做、内存和上传数成正比:同时来五个上传,最多两个在解析,其余排队。"""
+    import asyncio
+    import threading
+    import time
+
+    lock = threading.Lock()
+    state = {"now": 0, "peak": 0}
+
+    def slow(final, raw, filename, header_row, mixed, raw_mode):
+        with lock:
+            state["now"] += 1
+            state["peak"] = max(state["peak"], state["now"])
+        time.sleep(0.05)
+        with lock:
+            state["now"] -= 1
+        raise tabular.UnsupportedTable("测试用的拒收")
+
+    monkeypatch.setattr(table_versions, "_parse_to_tmp", slow)
+    got = await asyncio.gather(*(publish(unique()) for _ in range(5)), return_exceptions=True)
+    assert all(isinstance(e, tabular.UnsupportedTable) for e in got)
+    assert state["peak"] == table_versions.MAX_PARALLEL_PARSES == 2
+
+
+async def test_rejected_parse_keeps_old_version_and_leaves_nothing(store, monkeypatch):
+    name = unique()
+    sid, first = await publish(name)
+    before = files_under(store / "uploads")
+
+    def refuse(*_a, **_k):
+        raise StubNeedsDecision("需要先确认", kind="shape", details={"reasons": ["示例"]})
+
+    monkeypatch.setattr(tabular, "load_into", refuse)
+    with pytest.raises(tabular.UnsupportedTable) as info:
+        await publish(name, V2)
+    assert getattr(info.value, "kind", None) == "shape"
+    assert files_under(store / "uploads") == before          # 没留原件、没留临时库
+    assert (await load(sid)).current_snapshot_id == first.snapshot_id
+    assert len(await imports_of(sid)) == 1
+    view = await resolve(sid)
+    assert await scalar(view, 'SELECT SUM("数量") FROM "甲表"') == 3
+    assert await scalar(view, 'SELECT SUM("客流") FROM "乙表"') == 30
+
+
+async def test_unexpected_parse_error_is_wrapped_for_the_user(store, monkeypatch):
+    def boom(*_a, **_k):
+        raise KeyError("x")
+
+    monkeypatch.setattr(tabular, "load_into", boom)
+    with pytest.raises(table_versions.ParseFailed, match="请确认文件是未加密的 Excel 或 CSV"):
+        await publish(unique())
+    assert not files_under(store / "uploads")
+
+
+async def test_failure_after_link_rolls_back_and_removes_new_files(store, monkeypatch):
+    """写记录这一步失败:指针不动、旧版完整可查,这次新建的库文件和原件都删掉。"""
+    name = unique()
+    sid, first = await publish(name)
+    before = files_under(store / "uploads")
+
+    async def broken(view, report):
+        raise RuntimeError("模拟探查失败")
+
+    monkeypatch.setattr(table_versions, "_introspect_view", broken)
+    with pytest.raises(RuntimeError, match="模拟探查失败"):
+        await publish(name, V2)
+    assert files_under(store / "uploads") == before
+    row = await load(sid)
+    assert row.current_snapshot_id == first.snapshot_id
+    assert [i.status for i in await imports_of(sid)] == ["active"]
+    view = await resolve(sid)
+    assert await scalar(view, 'SELECT SUM("数量") FROM "甲表"') == 3
+
+
+async def test_new_source_is_not_created_when_publishing_fails(store, monkeypatch):
+    async def broken(view, report):
+        raise RuntimeError("模拟失败")
+
+    monkeypatch.setattr(table_versions, "_introspect_view", broken)
+    name = unique()
+    with pytest.raises(RuntimeError):
+        await publish(name)
+    async with SessionLocal() as session:
+        assert (await session.execute(select(DataSource).where(DataSource.name == name))).first() is None
+    assert not files_under(store / "uploads" / "raw")
+
+
+async def test_existing_target_with_wrong_hash_fails_and_keeps_pointer(store):
+    """同一个构建的目标文件已存在、哈希和登记的不一样(0 字节残骸、被改过):导入失败,指针不动。"""
+    name = unique()
+    raw = xlsx(V1)
+    sid, first = await publish(name, raw=raw)
+    build = await get(TableBuild, first.build_id)
+    path = Path(build.db_path)
+    os.chmod(path, 0o644)
+    path.write_bytes(b"")
+    with pytest.raises(table_versions.PublishError, match="不一致"):
+        await publish(name, raw=raw)
+    assert (await load(sid)).current_snapshot_id == first.snapshot_id
+    assert len(await imports_of(sid)) == 1
+    assert path.exists() and path.stat().st_size == 0      # 不是我们建的,不替它删
+    assert raw_store.raw_exists(build.raw_sha256)          # 原件早就在,不能被这次失败删掉
+
+
+async def test_zero_byte_file_at_target_without_record_fails(store):
+    sid = uuid.uuid4().hex
+    raw = xlsx(V1)
+    opts = table_versions.parse_options("客流.xlsx", header_row=1, mixed="reject", raw_mode=False)
+    target = table_versions.build_path(sid, table_versions.build_id(sid, hashlib.sha256(raw).hexdigest(), opts))
+    target.parent.mkdir(parents=True)
+    target.write_bytes(b"")
+    name = unique()
+    async with SessionLocal() as session:
+        row = DataSource(id=sid, name=name, kind="sqlite", readonly=True, origin="upload")
+        with pytest.raises(table_versions.PublishError):
+            await table_versions.publish_upload(session, row, raw, "客流.xlsx")
+    assert await get(DataSource, sid) is None
+    assert not files_under(store / "uploads" / "raw")
+
+
+async def test_reupload_probes_the_new_file_even_when_the_old_engine_is_cached(store):
+    """旧版本的引擎还在缓存里时重传:冻结进新快照的必须是新库的结构,不是缓存引擎指着的旧库。
+
+    引擎缓存的键若只有源 id(WP-A 之前),探查拿到的就是旧库的引擎。探查改用一次性的键,
+    发布时再核对探查到的表和解析回执是否一致。
+    """
+    name = unique()
+    sid, _ = await publish(name, {"旧表": [["区域", "数量"], ["分区甲", 1]]})
+    # 查一次当前版本,不清缓存:引擎留在缓存里,指着旧库
+    assert (await run_query(await resolve(sid), 'SELECT COUNT(*) FROM "旧表"')).rows[0][0] == 1
+    try:
+        _, second = await publish(name, {"新表": [["时段", "客流"], ["早", 10]]})
+        snap = await get(SourceSnapshot, second.snapshot_id)
+        assert list(snap.schema_cache["tables"]) == ["新表"]
+        assert [t["name"] for t in second.report["tables"]] == ["新表"]
+        assert list((await load(sid)).schema_cache["tables"]) == ["新表"]
+        assert await scalar(await resolve(sid), 'SELECT SUM("客流") FROM "新表"') == 10
+    finally:
+        await engines.invalidate(sid)
+
+
+async def test_publish_refuses_a_probe_that_does_not_match_the_report(store, monkeypatch):
+    """探查到的表和解析回执对不上、或者探查本身失败:不冻结进快照,指针不动,新文件删掉。"""
+    from app.data import introspect as introspect_mod
+
+    name = unique()
+    sid, first = await publish(name)
+    before = files_under(store / "uploads")
+    real = introspect_mod.introspect
+
+    async def stale(source, **kw):
+        cache = await real(source, **kw)
+        cache["tables"] = {"别的表": {"qualified": "别的表", "columns": []}}
+        return cache
+
+    async def failed(source, **kw):
+        return {"tables": {}, "truncated": False, "total": 0, "failed": True, "error": "模拟探查失败"}
+
+    for fake, message in ((stale, "对不上"), (failed, "模拟探查失败")):
+        monkeypatch.setattr(introspect_mod, "introspect", fake)
+        with pytest.raises(table_versions.PublishError, match=message):
+            await publish(name, fresh(V2))
+        assert files_under(store / "uploads") == before
+        assert (await load(sid)).current_snapshot_id == first.snapshot_id
+        assert [i.status for i in await imports_of(sid)] == ["active"]
+    monkeypatch.setattr(introspect_mod, "introspect", real)
+    assert await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"') == 3
+
+
+async def test_a_schema_option_on_an_upload_source_does_not_break_publishing(store):
+    """上传库只有 main:数据源 options 里留着的 schema(迁移来的老源、早先手工改过的)不参与探查。"""
+    name = unique()
+    sid, _ = await publish(name)
+    async with SessionLocal() as session:
+        row = await session.get(DataSource, sid)
+        row.options = {"schema": "nope", "mask_columns": "区域"}
+        await session.commit()
+    _, second = await publish(name, fresh(V2))
+    snap = await get(SourceSnapshot, second.snapshot_id)
+    assert {"甲表", "乙表"} <= set(snap.schema_cache["tables"])
+    assert not snap.schema_cache.get("failed")
+    row = await load(sid)
+    # 发布顺手把用不上的 schema 摘掉,其余配置保留
+    assert row.options == {"mask_columns": "区域"}
+    view = await resolve(sid)
+    assert "schema" not in view.options and view.options["mask_columns"] == "区域"
+    assert await scalar(view, 'SELECT SUM("数量") FROM "甲表"') == 100
+
+
+def _wal_parser(keep_open: list):
+    """替身解析器:把临时库切成 WAL 再写。keep_open 非 None 时留一个连接开着,-wal 合并不掉。"""
+
+    def parse(db_path, raw, filename, **kw):
+        conn = sqlite3.connect(db_path)
+        conn.execute("PRAGMA journal_mode=WAL")
+        conn.close()
+        report = stub_load_into(db_path, raw, filename, **kw)
+        if keep_open is not None:
+            holder = sqlite3.connect(db_path, check_same_thread=False)
+            holder.execute("SELECT COUNT(*) FROM sqlite_master").fetchall()
+            keep_open.append(holder)
+        return report
+
+    return parse
+
+
+async def test_published_files_are_in_rollback_journal_mode(store, monkeypatch):
+    """解析器用了 WAL 也要切回回滚日志:库以 immutable 打开时 SQLite 不看日志,留在 WAL 里会少数据。"""
+    monkeypatch.setattr(tabular, "load_into", _wal_parser(None))
+    sid, result = await publish(unique(), fresh(V1))
+    path = Path((await get(TableBuild, result.build_id)).db_path)
+    header = path.read_bytes()[:100]
+    assert header[18] == 1 and header[19] == 1       # 1 = 回滚日志,2 = WAL
+    assert not Path(f"{path}-wal").exists() and not Path(f"{path}-shm").exists()
+    assert await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"') == 3
+
+
+async def test_publish_stops_when_the_wal_cannot_be_merged(store, monkeypatch):
+    name = unique()
+    sid, first = await publish(name)
+    before = files_under(store / "uploads")
+    holders: list = []
+    monkeypatch.setattr(tabular, "load_into", _wal_parser(holders))
+    try:
+        with pytest.raises(table_versions.PublishError, match="日志"):
+            await publish(name, fresh(V2))
+    finally:
+        for conn in holders:
+            conn.close()
+    assert files_under(store / "uploads") == before
+    assert (await load(sid)).current_snapshot_id == first.snapshot_id
+
+
+async def test_unshaped_tables_get_a_note_without_numbers(store, monkeypatch):
+    def unshaped(db_path, raw, filename, **kw):
+        report = stub_load_into(db_path, raw, filename, **kw)
+        report.tables[0].unshaped = True
+        report.tables[1].unshaped = False
+        return report
+
+    monkeypatch.setattr(tabular, "load_into", unshaped)
+    sid, result = await publish(unique(), raw_mode=True)
+    snap = await get(SourceSnapshot, result.snapshot_id)
+    tables = snap.schema_cache["tables"]
+    assert tables["甲表"]["comment"] == table_versions.UNSHAPED_NOTE
+    assert not tables["乙表"].get("comment")
+    assert not any(ch.isdigit() for ch in table_versions.UNSHAPED_NOTE)
+    from app.data import introspect
+
+    assert "未经规整" in introspect.describe_table(await resolve(sid), "甲表")
+
+
+# --------------------------------------------------------------------------
+# 快照解析
+# --------------------------------------------------------------------------
+
+
+async def test_manual_sources_resolve_to_themselves(tmp_path):
+    async with SessionLocal() as session:
+        row = DataSource(name=unique(), kind="sqlite", database=str(tmp_path / "m.db"))
+        session.add(row)
+        await session.commit()
+        assert await table_versions.resolve_source(session, row) is row
+
+
+async def test_pinned_snapshot_must_belong_to_the_source(store):
+    a, ra = await publish(unique())
+    b, _ = await publish(unique())
+    with pytest.raises(SnapshotMissing, match="不会改用当前版本"):
+        await resolve(b, ra.snapshot_id)
+    with pytest.raises(SnapshotMissing):
+        await resolve(b, "f" * 64)
+
+
+async def test_bind_snapshot_uses_the_snapshot_not_the_source(store):
+    sid, first = await publish(unique())
+    async with SessionLocal() as session:
+        row = await session.get(DataSource, sid)
+        snap = await session.get(SourceSnapshot, first.snapshot_id)
+        row.schema_cache = {"tables": {}}       # 数据源上的缓存被改了,视图不受影响
+        row.database = "/elsewhere.db"
+        view = table_versions.bind_snapshot(row, snap)
+        name, snap_path = row.name, snap.db_path
+        await session.rollback()
+    assert set(view.schema_cache["tables"]) == {"甲表", "乙表"}
+    assert view.database == snap_path and view.readonly and view.immutable
+    assert view.id == sid and view.name == name and view.kind == "sqlite"
+    # 视图里的结构是一份拷贝:改它不会改到快照
+    view.schema_cache["tables"].clear()
+    assert set((await get(SourceSnapshot, first.snapshot_id)).schema_cache["tables"]) == {"甲表", "乙表"}
+
+
+def _rewrite_snapshot(path: str, sql: str) -> None:
+    """改快照库里的数据(模拟被改过的文件),改完切回回滚日志模式,不留 -wal / -journal。"""
+    os.chmod(path, 0o644)
+    conn = sqlite3.connect(path)
+    conn.execute(sql)
+    conn.commit()
+    conn.execute("PRAGMA journal_mode=DELETE")
+    conn.close()
+
+
+async def test_snapshot_with_an_empty_hash_is_refused(store):
+    """快照登记的哈希是空的(记录损坏、被手工改过):不能退回「不核对照样打开」,读到被改过的数据。"""
+    sid, first = await publish(unique())
+    snap = await get(SourceSnapshot, first.snapshot_id)
+    _rewrite_snapshot(snap.db_path, 'UPDATE "甲表" SET "数量" = 1000 WHERE "区域" = \'分区甲\'')
+    with pytest.raises(Exception, match="可能被修改过"):      # 对照:哈希还在时,被改过的文件被拒
+        await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"')
+    for blank in ("", "   "):
+        async with SessionLocal() as session:
+            (await session.get(SourceSnapshot, first.snapshot_id)).db_sha256 = blank
+            await session.commit()
+        with pytest.raises(SnapshotMissing, match="缺少数据文件的哈希登记"):
+            await resolve(sid)
+        with pytest.raises(SnapshotMissing, match="缺少数据文件的哈希登记"):
+            await resolve(sid, first.snapshot_id)
+
+
+async def test_reusing_a_build_with_an_empty_hash_fills_it_in(store):
+    """构建记录的哈希是空的,重传同一个文件复用了它:新快照不能继承空哈希,构建记录也补上。"""
+    name, raw = unique(), xlsx(fresh(V1))
+    sid, first = await publish(name, raw=raw)
+    async with SessionLocal() as session:
+        (await session.get(TableBuild, first.build_id)).db_sha256 = ""
+        await session.commit()
+    _, second = await publish(name, raw=raw)
+    assert second.build_reused and second.build_id == first.build_id
+    build = await get(TableBuild, first.build_id)
+    snap = await get(SourceSnapshot, second.snapshot_id)
+    assert build.db_sha256 and snap.db_sha256 == build.db_sha256 == raw_store.sha256_file(Path(build.db_path))
+    assert await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"') == 3
+
+
+# --------------------------------------------------------------------------
+# 回收
+# --------------------------------------------------------------------------
+
+
+async def test_old_versions_are_reclaimed_when_nothing_references_them(store):
+    name = unique()
+    sid, first = await publish(name, fresh(V1))
+    old = await get(TableBuild, first.build_id)
+    _, second = await publish(name, fresh(V2))
+    assert not Path(old.db_path).exists()
+    assert (await get(TableBuild, first.build_id)).retired_at is not None
+    assert (await get(SourceSnapshot, first.snapshot_id)).retired_at is not None
+    imps = await imports_of(sid)
+    assert imps[0].status == "retired" and imps[0].raw_state == "purged"
+    assert imps[0].purged and imps[0].purged.get("auto") is True
+    assert not raw_store.raw_exists(imps[0].raw_sha256)
+    assert raw_store.raw_exists(imps[1].raw_sha256)
+    with pytest.raises(SnapshotMissing, match="不会改用当前版本"):
+        await resolve(sid, first.snapshot_id)
+
+
+async def test_gc_keeps_snapshots_referenced_by_interrupted_runs(store):
+    name = unique()
+    sid, first = await publish(name, fresh(V1))
+    async with SessionLocal() as session:
+        run = Run(workflow_name="wpc-gc", status="interrupted",
+                  data_versions={sid: {"snapshot": first.snapshot_id, "name": name}})
+        session.add(run)
+        await session.commit()
+        run_id = run.id
+    try:
+        _, second = await publish(name, fresh(V2))
+        old = await get(TableBuild, first.build_id)
+        assert Path(old.db_path).exists() and old.retired_at is None
+        assert (await get(SourceSnapshot, first.snapshot_id)).retired_at is None
+        imps = await imports_of(sid)
+        assert imps[0].status == "superseded" and raw_store.raw_exists(imps[0].raw_sha256)
+        # 钉住的旧版仍可查,当前版本是新的
+        assert await scalar(await resolve(sid, first.snapshot_id), 'SELECT SUM("数量") FROM "甲表"') == 3
+        assert await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"') == 100
+    finally:
+        async with SessionLocal() as session:
+            await session.delete(await session.get(Run, run_id))
+            await session.commit()
+    # 运行没了,再回收就删掉
+    async with SessionLocal() as session:
+        await table_versions.gc(session)
+    assert not Path((await get(TableBuild, first.build_id)).db_path).exists()
+    assert not raw_store.raw_exists((await imports_of(sid))[0].raw_sha256)
+    with pytest.raises(SnapshotMissing):
+        await resolve(sid, first.snapshot_id)
+
+
+async def test_gc_never_deletes_outside_the_uploads_dir(store, tmp_path):
+    outside = tmp_path / "outside.db"
+    outside.write_bytes(b"keep me")
+    async with SessionLocal() as session:
+        session.add(TableBuild(id=hashlib.sha256(os.urandom(8)).hexdigest(), source_id="nosuchsource",
+                               db_path=str(outside)))
+        await session.commit()
+        await table_versions.gc(session)
+    assert outside.read_bytes() == b"keep me"
+
+
+# --------------------------------------------------------------------------
+# 启动:孤儿清理、迁移
+# --------------------------------------------------------------------------
+
+
+async def test_startup_removes_orphans_left_by_a_crash(store):
+    """模拟「link 之后、提交之前」进程没了:库文件和原件都在,却没有记录。启动时清掉。"""
+    sid, kept = await publish(unique())
+    kept_build = await get(TableBuild, kept.build_id)
+
+    orphan_src = uuid.uuid4().hex
+    orphan = table_versions.build_path(orphan_src, "0" * 64)
+    orphan.parent.mkdir(parents=True)
+    tmp = orphan.parent / f"{orphan.name}{raw_store.TMP_MARK}abc"
+    conn = sqlite3.connect(tmp)
+    conn.execute("CREATE TABLE t (a INTEGER)")
+    conn.commit()
+    conn.close()
+    table_versions._publish_file(tmp, orphan, None)
+    assert orphan.exists() and not tmp.exists()
+    half = orphan.parent / f"{'1' * 64}.db{raw_store.TMP_MARK}zzz"
+    half.write_bytes(b"half written")
+    orphan_sha, orphan_raw = raw_store.put_raw(b"raw nobody registered")
+
+    async with SessionLocal() as session:
+        await table_versions.startup(session)
+    assert not orphan.exists() and not half.exists() and not orphan_raw.exists()
+    assert Path(kept_build.db_path).exists() and raw_store.raw_exists(kept_build.raw_sha256)
+    assert await scalar(await resolve(sid), 'SELECT COUNT(*) FROM "甲表"') == 2
+
+
+def _legacy_db(path: Path) -> None:
+    conn = sqlite3.connect(path)
+    conn.execute('CREATE TABLE "明细" ("金额" INTEGER)')
+    conn.executemany('INSERT INTO "明细" VALUES (?)', [(5,), (7,)])
+    conn.commit()
+    conn.close()
+
+
+async def test_startup_migrates_legacy_uploads_idempotently(store):
+    tables = table_versions.tables_root()
+    tables.mkdir(parents=True)
+    a_path, b_path = tables / "old_a.db", tables / "old_b.db"
+    _legacy_db(a_path)
+    _legacy_db(b_path)
+    assert a_path.read_bytes() == b_path.read_bytes()       # 内容相同,v0 的 id 也不能撞
+    async with SessionLocal() as session:
+        a = DataSource(name=unique("old"), kind="sqlite", database=str(a_path), readonly=True)
+        b = DataSource(name=unique("old"), kind="sqlite", database=str(b_path), readonly=True,
+                       schema_cache={"tables": {"明细": {"qualified": "明细", "columns": []}}})
+        session.add_all([a, b])
+        await session.commit()
+        ids = (a.id, b.id)
+
+    async with SessionLocal() as session:
+        await table_versions.startup(session)
+    rows = [await load(i) for i in ids]
+    assert [r.origin for r in rows] == ["upload", "upload"]
+    snaps = [await get(SourceSnapshot, r.current_snapshot_id) for r in rows]
+    builds = [await get(TableBuild, (await imports_of(r.id))[0].build_id) for r in rows]
+    assert snaps[0].id != snaps[1].id and builds[0].id != builds[1].id
+    sha = raw_store.sha256_file(a_path)
+    assert builds[0].id == hashlib.sha256(f"legacy{ids[0]}{sha}".encode()).hexdigest()
+    assert builds[0].engine_ver == table_versions.LEGACY_ENGINE_VER
+    for r, snap, path in zip(rows, snaps, (a_path, b_path)):
+        [imp] = await imports_of(r.id)
+        assert imp.raw_state == "absent" and imp.status == "active"
+        assert snap.db_path == os.path.realpath(path) and snap.db_sha256 == sha
+        assert stat.S_IMODE(path.stat().st_mode) == 0o444
+    # 没有结构缓存的补探一次;有的原样冻结
+    assert "明细" in snaps[0].schema_cache["tables"]
+    assert snaps[1].schema_cache == {"tables": {"明细": {"qualified": "明细", "columns": []}}}
+    assert await scalar(await resolve(ids[0]), 'SELECT SUM("金额") FROM "明细"') == 12
+
+    async with SessionLocal() as session:
+        await table_versions.startup(session)
+    for r in rows:
+        assert len(await imports_of(r.id)) == 1
+        assert (await load(r.id)).current_snapshot_id == r.current_snapshot_id
+    async with SessionLocal() as session:
+        count = (await session.execute(
+            select(func.count()).select_from(TableBuild).where(TableBuild.source_id.in_(ids))
+        )).scalar()
+    assert count == 2
+
+
+async def test_one_failing_legacy_source_does_not_block_the_rest_of_startup(store, monkeypatch):
+    """一个老上传迁不过去(文件属主不对、chmod 报 EPERM 之类):别的源照常迁,孤儿清理和回收照常做。"""
+    tables = table_versions.tables_root()
+    tables.mkdir(parents=True)
+    paths = [tables / "aaa_fail.db", tables / "bbb_ok.db"]
+    for path in paths:
+        _legacy_db(path)
+    async with SessionLocal() as session:
+        rows = [DataSource(name=unique("old"), kind="sqlite", database=str(p), readonly=True) for p in paths]
+        session.add_all(rows)
+        await session.commit()
+        ids = [r.id for r in rows]
+    # 一个没人登记的孤儿库、一个没人引用的旧构建:startup 走完时都该被收拾掉
+    orphan = table_versions.build_path(uuid.uuid4().hex, "0" * 64)
+    orphan.parent.mkdir(parents=True)
+    orphan.write_bytes(b"orphan")
+    stale_sid, stale = await publish(unique(), fresh(V1))
+    stale_build = await get(TableBuild, stale.build_id)
+    async with SessionLocal() as session:
+        # 删了源、还没来得及回收(比如回收那一步失败了)
+        await table_versions.retire_source(session, stale_sid)
+        await session.delete(await session.get(DataSource, stale_sid))
+        await session.commit()
+
+    real = table_versions._prepare_legacy
+    calls: list[str] = []
+
+    def flaky(path):
+        calls.append(path)
+        if len(calls) == 1:
+            raise PermissionError(1, "Operation not permitted", path)
+        return real(path)
+
+    monkeypatch.setattr(table_versions, "_prepare_legacy", flaky)
+    async with SessionLocal() as session:
+        await table_versions.startup(session)       # 不抛
+
+    assert len(calls) == 2
+    failed_path = calls[0]
+    by_path = {str(os.path.realpath(p)): i for p, i in zip(paths, ids)}
+    failed_id = by_path[failed_path]
+    ok_id = next(i for i in ids if i != failed_id)
+    assert (await load(failed_id)).origin == "manual"
+    assert (await load(failed_id)).current_snapshot_id is None
+    migrated = await load(ok_id)
+    assert migrated.origin == "upload" and migrated.current_snapshot_id
+    assert await scalar(await resolve(ok_id), 'SELECT SUM("金额") FROM "明细"') == 12
+    assert not orphan.exists()
+    assert (await get(TableBuild, stale.build_id)).retired_at is not None
+    assert not Path(stale_build.db_path).exists()
+
+    # 下次启动把上次失败的那个补上
+    monkeypatch.setattr(table_versions, "_prepare_legacy", real)
+    async with SessionLocal() as session:
+        await table_versions.startup(session)
+    assert (await load(failed_id)).origin == "upload"
+
+
+async def test_startup_leaves_other_sqlite_sources_alone(store, tmp_path):
+    """不是老上传的 SQLite 源不迁移;但指向上传目录的(迁移认不出来的更深一层、带连接参数的别名)
+    强制改成只读:那里的文件归版本底座管,升级前登记的可写手工源不能接着往里写(方案 3.7)。"""
+    elsewhere = tmp_path / "manual.db"
+    _legacy_db(elsewhere)
+    nested = table_versions.tables_root() / "deeper"
+    nested.mkdir(parents=True)
+    _legacy_db(nested / "x.db")
+    _legacy_db(nested / "y.db")
+    async with SessionLocal() as session:
+        m = DataSource(name=unique("man"), kind="sqlite", database=str(elsewhere), readonly=False)
+        n = DataSource(name=unique("man"), kind="sqlite", database=str(nested / "x.db"), readonly=False)
+        q = DataSource(name=unique("man"), kind="sqlite", database=f"{nested / 'y.db'}?timeout=5",
+                       readonly=False)
+        session.add_all([m, n, q])
+        await session.commit()
+        ids = (m.id, n.id, q.id)
+        await table_versions.startup(session)
+    for i in ids:
+        row = await load(i)
+        assert row.origin == "manual" and row.current_snapshot_id is None
+    assert stat.S_IMODE(elsewhere.stat().st_mode) != 0o444
+    assert (await load(m.id)).readonly is False             # 上传目录外的手工库照旧可写
+    assert (await load(n.id)).readonly is True and (await load(q.id)).readonly is True
+    async with SessionLocal() as session:
+        with pytest.raises(Exception, match="readonly|只读|拒绝"):
+            await run_query(await session.get(DataSource, n.id), 'INSERT INTO "明细" VALUES (9)')
+    assert sqlite3.connect(nested / "x.db").execute('SELECT COUNT(*) FROM "明细"').fetchone()[0] == 2
+
+
+async def test_legacy_upload_can_be_updated_by_a_new_upload(store):
+    tables = table_versions.tables_root()
+    tables.mkdir(parents=True)
+    legacy = tables / "old_c.db"
+    _legacy_db(legacy)
+    name = unique("old")
+    async with SessionLocal() as session:
+        row = DataSource(name=name, kind="sqlite", database=str(legacy), readonly=True)
+        session.add(row)
+        await session.commit()
+        await table_versions.startup(session)
+    sid, result = await publish(name, V1)
+    assert (await load(sid)).current_snapshot_id == result.snapshot_id
+    assert not legacy.exists()          # 老库没有运行引用,随回收删除
+    assert [i.status for i in await imports_of(sid)] == ["retired", "active"]
diff --git a/backend/tests/test_tabular.py b/backend/tests/test_tabular.py
index 0a009f67..8016b2d6 100644
--- a/backend/tests/test_tabular.py
+++ b/backend/tests/test_tabular.py
@@ -7,18 +7,31 @@
 
 所以这里最要紧的一条不是"能不能导进去",是**导进去的数字能不能真的被 SQL 算**:
 一个整数列如果落成 TEXT,ORDER BY 就变成字典序('10' < '9'),而没有任何
-地方会报错。
+地方会报错。同样要紧的是**不许静默出错**:前导零、长卡号、千分位、隐藏工作表、
+空行、交叉表——每一种取舍要么让用户拍板(NeedsDecision),要么写进回执,要么整份拒收。
+
+夹具都在这里用 openpyxl 现造,标签是假名,数字是手写或随机的假数。
 """
 
 from __future__ import annotations
 
+import datetime as dt
 import io
+import json
+import random
+import re
 import sqlite3
+import time
+import zipfile
+from collections.abc import Callable
+from dataclasses import asdict
 
 import pytest
 from httpx import ASGITransport, AsyncClient
 
-from app.data.tabular import UnsupportedTable, infer_type, load_into
+from app.data import tabular
+from app.data.names import name_problem
+from app.data.tabular import NeedsDecision, UnsupportedTable, infer_type, load_into
 from app.main import app
 
 
@@ -43,6 +56,38 @@ def xlsx(sheets: dict[str, list[list]]) -> bytes:
     return buf.getvalue()
 
 
+def save(book) -> bytes:
+    buf = io.BytesIO()
+    book.save(buf)
+    return buf.getvalue()
+
+
+def patch(raw: bytes, edits: dict[str, Callable[[str], str]]) -> bytes:
+    """改压缩包里的部件:{路径: 文本 → 文本}。openpyxl 写不出来的形态(缓存值、陈旧 dimension)这么造。"""
+    src = zipfile.ZipFile(io.BytesIO(raw))
+    out = io.BytesIO()
+    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst:
+        for info in src.infolist():
+            data = src.read(info.filename)
+            if info.filename in edits:
+                data = edits[info.filename](data.decode()).encode()
+            dst.writestr(info, data)
+    return out.getvalue()
+
+
+def excel_saved(raw: bytes, values: dict[str, object]) -> bytes:
+    """模拟「在 Excel 里打开并保存过」:给公式补上缓存值,去掉打开时重算的标志。"""
+    def fill(text: str) -> str:
+        def one(m: re.Match[str]) -> str:
+            ref = m.group(1)
+            return f'{m.group(2)}{values[ref]}' if ref in values else m.group(0)
+        return re.sub(r'([^<]*)', one, text)
+    names = zipfile.ZipFile(io.BytesIO(raw)).namelist()
+    edits: dict[str, Callable[[str], str]] = {n: fill for n in names if n.startswith("xl/worksheets/sheet")}
+    edits["xl/workbook.xml"] = lambda t: t.replace(' fullCalcOnLoad="1"', "")
+    return patch(raw, edits)
+
+
 def query(db, sql):
     conn = sqlite3.connect(str(db))
     try:
@@ -55,6 +100,10 @@ def col_types(db, table):
     return {r[1]: r[2] for r in query(db, f'PRAGMA table_info("{table}")')}
 
 
+def tables(db):
+    return [r[0] for r in query(db, "SELECT name FROM sqlite_master WHERE type='table' ORDER BY rowid")]
+
+
 # --------------------------------------------------------------------------
 # 类型推断:这块的全部意义
 # --------------------------------------------------------------------------
@@ -73,6 +122,18 @@ def test_infer_type_basics():
     assert infer_type([1, "", 3]) == "INTEGER"
     # 真/假不是数量,不该被当成 INTEGER 去求和
     assert infer_type([True, False]) == "TEXT"
+    # 千分位文本是数字;前导零、超长数字、科学计数法写法、带加号的是编码
+    assert infer_type(["1,234", "2,000.5"]) == "REAL"
+    assert infer_type(["00123", "00456"]) == "TEXT"
+    assert infer_type([1, 2, "00123"]) == "TEXT"
+    assert infer_type(["6222021234567890123"]) == "TEXT"
+    assert infer_type(["1E5", "2E3"]) == "TEXT"
+    assert infer_type(["+86", "+1"]) == "TEXT"
+    assert infer_type(["123", "456"]) == "TEXT"         # 全角数字不悄悄转成数字
+    # 首组是 0 的不是千分位(「0,250」多半是逗号当小数点),不能放大一千倍
+    assert infer_type(["0,123", "0,250"]) == "TEXT"
+    # 数字列混入少量占位符:按数字类型存(非数字要么存空值,要么不导入,见 load_into 的 mixed)
+    assert infer_type([1, 2, 3, 4, "·"]) == "INTEGER"
 
 
 def test_numbers_sort_as_numbers_not_as_text(tmp_path):
@@ -84,7 +145,7 @@ def test_numbers_sort_as_numbers_not_as_text(tmp_path):
     db = tmp_path / "t.db"
     load_into(str(db), xlsx({"数据": [["名称", "销量"], ["甲", 9], ["乙", 10], ["丙", 100]]}), "a.xlsx")
 
-    table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
+    table = tables(db)[0]
     assert col_types(db, table)["销量"] == "INTEGER"
     assert [r[0] for r in query(db, f'SELECT "名称" FROM "{table}" ORDER BY "销量"')] == \
         ["甲", "乙", "丙"]
@@ -93,8 +154,8 @@ def test_numbers_sort_as_numbers_not_as_text(tmp_path):
 
 def test_empty_cells_become_null_not_empty_string(tmp_path):
     db = tmp_path / "t.db"
-    load_into(str(db), xlsx({"s": [["a", "b"], [1, None], [2, "  "]]}), "a.xlsx")
-    table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
+    load_into(str(db), xlsx({"s": [["a", "b", "c"], [1, None, "x"], [2, "  ", "y"]]}), "a.xlsx")
+    table = tables(db)[0]
     assert query(db, f'SELECT COUNT("b") FROM "{table}"')[0][0] == 0   # COUNT 不数 NULL
     assert query(db, f'SELECT SUM("a") FROM "{table}"')[0][0] == 3
 
@@ -117,42 +178,952 @@ def test_multiple_sheets_become_multiple_tables(tmp_path):
     assert query(db, 'SELECT "金额" FROM 销售明细')[0][0] == 100   # 不加引号也查得了
 
 
-def test_chinese_columns_are_kept_verbatim(tmp_path):
-    """列名保持原样。转写是有损的,而 SQLite 认带引号的标识符。"""
+def test_columns_get_sql_names_and_keep_headers(tmp_path):
+    """列名换成能直接写进 SQL 和实体标记的名字,原表头另记在回执里。
+
+    以前列名保持原样(「本月销量 (万元)」),结果空格、括号、换行、方括号让 `[[c:表.列]]`
+    和 `[[v:]]` 都写不进去,不加引号的 SQL 也断成几截。
+    """
     db = tmp_path / "t.db"
-    load_into(str(db), xlsx({"s": [["本月销量 (万元)", "x"], [12, "a"]]}), "a.xlsx")
-    table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
-    assert "本月销量 (万元)" in col_types(db, table)
-    assert query(db, f'SELECT "本月销量 (万元)" FROM "{table}"')[0][0] == 12
+    report = load_into(str(db), xlsx({"s": [["本月销量 (万元)", "x"], [12, "a"]]}), "a.xlsx")
+    table = report.tables[0]
+    assert table.columns[0] == {"name": "本月销量_万元", "type": "INTEGER", "header": "本月销量 (万元)"}
+    assert query(db, f'SELECT 本月销量_万元 FROM "{table.name}"')[0][0] == 12
 
 
 def test_broken_headers_get_usable_names(tmp_path):
     db = tmp_path / "t.db"
     load_into(str(db), xlsx({"s": [["名称", None, "名称"], ["a", "b", "c"]]}), "a.xlsx")
-    table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
+    table = tables(db)[0]
     cols = list(col_types(db, table))
     assert cols == ["名称", "col_2", "名称_2"], cols
 
 
+def test_column_name_collisions_do_not_break_the_table(tmp_path):
+    """去重要躲开已有的名字,还要不分大小写:SQLite 认为 Name 和 name 是同一列。"""
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({
+        "s": [["a", "a", "a_2", "Name", "name"], [1, 2, 3, 4, 5]],
+    }), "a.xlsx")
+    assert [c["name"] for c in report.tables[0].columns] == ["a", "a_2", "a_2_2", "Name", "name_2"]
+    assert [c["header"] for c in report.tables[0].columns] == ["a", "a", "a_2", "Name", "name"]
+
+
+def test_table_names_that_differ_only_in_case_both_survive(tmp_path):
+    """「Q1 Sales」和「q1-sales」以前清洗成 Q1_Sales、q1_sales,后一张 DROP 掉前一张。"""
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({
+        "Q1 Sales": [["v"], [1]],
+        "q1-sales": [["v"], [2]],
+    }), "a.xlsx")
+    assert [t.name for t in report.tables] == ["Q1_Sales", "q1_sales_2"]
+    assert [t.sheet for t in report.tables] == ["Q1 Sales", "q1-sales"]
+    assert query(db, "SELECT v FROM Q1_Sales")[0][0] == 1
+    assert query(db, "SELECT v FROM q1_sales_2")[0][0] == 2
+
+
+def test_odd_names_become_valid_names(tmp_path):
+    """全角、带圈数字、看不见的填充字符、换行、方括号、竖线、关键字:都换成能用的名字。"""
+    db = tmp_path / "t.db"
+    headers = ["金额\n(元)", "单价[元]", "a|b", "order", "日客流ㅤ", "ABC", "①期"]
+    report = load_into(str(db), xlsx({
+        "ABC": [headers, [1, 2, 3, 4, 5, 6, 7]],
+        "sqlite_表": [["x"], [1]],
+    }), "a.xlsx")
+    names = [t.name for t in report.tables] + [c["name"] for t in report.tables for c in t.columns]
+    assert all(name_problem(n) is None for n in names), names
+    assert [t.name for t in report.tables] == ["ABC", "t_sqlite_表"]
+    assert [c["name"] for c in report.tables[0].columns][:4] == ["金额_元", "单价_元", "a_b", "order_"]
+    assert [c["header"] for c in report.tables[0].columns] == headers
+
+
 def test_header_row_can_be_moved(tmp_path):
     """不猜表头行。猜错的表现是列名变成一行数据,而且没有任何征兆。"""
     db = tmp_path / "t.db"
     sheets = {"s": [["2026 年销售报表", None], ["月份", "金额"], ["1月", 100]]}
 
     load_into(str(db), xlsx(sheets), "a.xlsx", header_row=2)
-    table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
+    table = tables(db)[0]
     assert list(col_types(db, table)) == ["月份", "金额"]
 
     # 取默认第 1 行就会把标题当表头——这正是要让用户当场看见的那种错
-    load_into(str(db), xlsx(sheets), "a.xlsx")
-    assert "2026 年销售报表" in col_types(db, table)
+    report = load_into(str(db), xlsx(sheets), "a.xlsx")
+    assert "c_2026_年销售报表" in col_types(db, table)
+    assert report.tables[0].columns[0]["header"] == "2026 年销售报表"
 
 
 def test_ragged_rows_are_padded(tmp_path):
     db = tmp_path / "t.db"
     load_into(str(db), xlsx({"s": [["a", "b", "c"], [1], [1, 2, 3, 4]]}), "a.xlsx")
-    table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
+    table = tables(db)[0]
     assert query(db, f'SELECT COUNT(*) FROM "{table}"')[0][0] == 2
+    # 比表头长的那一格不能丢:多出一列 col_4
+    assert list(col_types(db, table)) == ["a", "b", "c", "col_4"]
+
+
+# --------------------------------------------------------------------------
+# 工作表、边界、空行空列
+# --------------------------------------------------------------------------
+
+
+def test_hidden_sheets_are_not_imported(tmp_path):
+    """实测有人把口令放在 veryHidden 的工作表里:隐藏的工作表一律不导入,回执里写明。"""
+    from openpyxl import Workbook
+
+    book = Workbook()
+    book.active.title = "明细"
+    book.active.append(["地区", "金额"])
+    book.active.append(["分区甲", 100])
+    hidden = book.create_sheet("草稿")
+    hidden.sheet_state = "hidden"
+    hidden.append(["x"])
+    hidden.append([1])
+    very = book.create_sheet("配置")
+    very.sheet_state = "veryHidden"
+    very.append(["项", "值"])
+    very.append(["口令", "secret-123"])
+    db = tmp_path / "t.db"
+    report = load_into(str(db), save(book), "a.xlsx")
+    assert tables(db) == ["明细"]
+    assert report.skipped_sheets == [
+        {"sheet": "草稿", "state": "hidden", "reason": "hidden"},
+        {"sheet": "配置", "state": "veryHidden", "reason": "hidden"},
+    ]
+
+
+def test_only_hidden_content_is_rejected(tmp_path):
+    from openpyxl import Workbook
+
+    book = Workbook()
+    book.active.title = "空白"
+    very = book.create_sheet("配置")
+    very.sheet_state = "veryHidden"
+    very["A1"] = "口令"
+    with pytest.raises(UnsupportedTable, match="可见工作表.*隐藏"):
+        load_into(str(tmp_path / "t.db"), save(book), "a.xlsx")
+
+
+def _same_named_sheets() -> bytes:
+    """两个同名工作表,前一个深度隐藏(内容是占位的「隐藏内容」),后一个可见。Excel 不会这样存,只能构造。"""
+    from openpyxl import Workbook
+
+    book = Workbook()
+    book.active.title = "明细"
+    book.active.append(["项"])
+    book.active.append(["隐藏内容"])
+    book.active.sheet_state = "veryHidden"
+    shown = book.create_sheet("备注")
+    shown.append(["地区", "金额"])
+    shown.append(["分区甲", 100])
+    return patch(save(book), {"xl/workbook.xml": lambda t: t.replace('name="备注"', 'name="明细"')})
+
+
+def test_same_named_sheets_are_rejected_and_nothing_is_imported(tmp_path):
+    """同名工作表:按名字取表会拿到隐藏的那个,内容按可见表的边界写进库,回执却说隐藏表已跳过。整份拒收。"""
+    db = tmp_path / "t.db"
+    with pytest.raises(UnsupportedTable, match="同名"):
+        load_into(str(db), _same_named_sheets(), "a.xlsx")
+    assert not db.exists() or tables(db) == []
+
+
+def test_worksheets_are_matched_by_part_path(tmp_path):
+    """扫描结果和 openpyxl 的工作表按部件路径配对。钉住 openpyxl 的私有属性 _worksheet_path:
+    它改名或换了格式,这里先失败,而不是导入时静默配错。"""
+    from openpyxl import load_workbook
+
+    from app.data import xlsx_scan
+
+    raw = xlsx({"甲": [["a"], [1]], "乙": [["b"], [2]]})
+    sheets = xlsx_scan.scan(raw).sheets
+    book = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
+    try:
+        assert [ws._worksheet_path for ws in book.worksheets] == [s.path for s in sheets]
+        assert [tabular._worksheet_for(book, s).title for s in sheets] == ["甲", "乙"]
+        # 路径对不上、名字对不上、openpyxl 那边不是可见的:都拒收,不猜
+        for broken in (xlsx_scan.SheetScan(name="甲", state="visible", path="xl/worksheets/none.xml"),
+                       xlsx_scan.SheetScan(name="丙", state="visible", path=sheets[0].path)):
+            with pytest.raises(UnsupportedTable, match="无法确认工作表"):
+                tabular._worksheet_for(book, broken)
+        book.worksheets[1].sheet_state = "hidden"
+        with pytest.raises(UnsupportedTable, match="无法确认工作表"):
+            tabular._worksheet_for(book, sheets[1])
+    finally:
+        book.close()
+
+
+def test_stale_dimension_still_reads_everything(tmp_path):
+    """`` 陈旧(写着 A1:B2)时 read_only 会少读:按扫描算出的边界读。"""
+    rows = [["地区", "金额"]] + [[f"分区{i}", i] for i in range(1, 41)]
+    raw = patch(xlsx({"s": rows}), {
+        "xl/worksheets/sheet1.xml": lambda t: re.sub(r'', '', t),
+    })
+    db = tmp_path / "t.db"
+    report = load_into(str(db), raw, "a.xlsx")
+    assert report.tables[0].rows == 40
+    assert query(db, "SELECT SUM(金额) FROM s")[0][0] == sum(range(1, 41))
+
+
+def test_sparse_sheet_is_rejected_with_coordinates(tmp_path):
+    """A 列、XFD 列各有内容:区域撑成几百万格,读下去就是几百万个空值。要快速拒收并说出坐标。"""
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    for r in range(1, 201):
+        ws.cell(r, 1, r)
+        ws.cell(r, 16384, r)
+    raw = save(book)
+    started = time.perf_counter()
+    with pytest.raises(UnsupportedTable) as info:
+        load_into(str(tmp_path / "t.db"), raw, "a.xlsx")
+    assert time.perf_counter() - started < 2
+    assert "XFD1" in str(info.value)
+    assert not isinstance(info.value, NeedsDecision)
+
+
+def test_sparse_message_names_each_far_cell_once_and_groups_digits(tmp_path):
+    """右下角一个误输入的格子:坐标只说一次,几百亿格的面积按千分位写,读得出量级。"""
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    for r in range(1, 201):
+        ws.cell(r, 1, r)
+        ws.cell(r, 2, r)
+    ws["XFD1048576"] = "x"
+    with pytest.raises(UnsupportedTable) as info:
+        load_into(str(tmp_path / "t.db"), save(book), "a.xlsx")
+    message = str(info.value)
+    assert message.count("XFD1048576") == 2            # 一次在区域 A1:XFD1048576 里,一次是示例
+    assert "(如 XFD1048576)" in message
+    assert "共 17,179,869,184 个单元格" in message and "只有 401 个单元格有内容" in message
+
+
+def test_blank_rows_and_empty_edge_columns_are_dropped(tmp_path):
+    """表头在 C5:前两列不再变成全 NULL 的 col_1/col_2,空行不再灌进 COUNT(*)。"""
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    ws.title = "月报"
+    ws["A1"] = "2026 年月报"
+    ws["C5"] = "地区"
+    ws["D5"] = "金额"
+    ws["C6"], ws["D6"] = "分区甲", 100
+    ws["C8"], ws["D8"] = "分区乙", 200       # 第 7 行空着
+    ws["C9"], ws["D9"] = "分区丙", 300
+    db = tmp_path / "t.db"
+    report = load_into(str(db), save(book), "a.xlsx", header_row=5)
+    table = report.tables[0]
+    assert [c["name"] for c in table.columns] == ["地区", "金额"]
+    assert table.columns_trimmed == ["A", "B"]
+    assert table.blank_rows_skipped == 1
+    assert table.rows == 3
+    assert table.region == "C5:D9"
+    assert query(db, "SELECT COUNT(*), SUM(金额) FROM 月报")[0] == (3, 600)
+
+
+def test_header_row_beyond_content_skips_sheet(tmp_path):
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({
+        "长": [["t"], ["x"], ["k", "v"], ["a", 1]],
+        "短": [["k"], ["a"]],
+    }), "a.xlsx", header_row=3)
+    assert [t.name for t in report.tables] == ["长"]
+    assert {"sheet": "短", "state": "visible", "reason": "empty"} in report.skipped_sheets
+    assert any("「短」" in w for w in report.warnings)
+
+
+def test_blank_header_row_is_warned(tmp_path):
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": [[None, None], ["k", "v"], ["a", 1]]}), "a.xlsx")
+    assert any("表头行(第 1 行)没有内容" in w for w in report.warnings)
+
+
+# --------------------------------------------------------------------------
+# 值
+# --------------------------------------------------------------------------
+
+
+def test_thousands_text_becomes_numbers(tmp_path):
+    """「1,234」以前存成 TEXT,SUM 只取逗号前的 1:3534 算成了 3。"""
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": [["金额", "单价"], ["1,234", "1,234.5"], ["2,000", "10"], [300, "2.5"]]}),
+                       "a.xlsx")
+    assert col_types(db, "s") == {"金额": "INTEGER", "单价": "REAL"}
+    assert query(db, "SELECT SUM(金额), MAX(金额), SUM(单价) FROM s")[0] == (3534, 2000, 1247.0)
+    assert {(c["column"], c["kind"], c["count"]) for c in report.conversions} == {
+        ("金额", "thousands_separator", 2), ("单价", "thousands_separator", 1),
+    }
+
+
+@pytest.mark.parametrize("text", ["0,123", "00,123", "-0,500", "1,5", "1.234,56", "01,234"])
+def test_comma_decimals_are_not_thousands(text):
+    """合法的千分位首组是 1 到 3 位、不以 0 开头。其余带逗号的数字写法按文本,并且记下来。"""
+    assert tabular._classify(text) == (tabular._TEXT, text, "comma")
+
+
+@pytest.mark.parametrize(("text", "value"), [("1,234", 1234), ("-12,345", -12345), ("999,000,000", 999000000),
+                                             ("1,234.5", 1234.5)])
+def test_real_thousands_still_convert(text, value):
+    assert tabular._classify(text)[1:] == (value, "thousands")
+
+
+def test_comma_decimals_in_a_number_column_need_a_decision(tmp_path):
+    """数字列里混进「0,250」:以前被当成 250,现在按非数字的值交给用户选。"""
+    rows = [["名称", "单价"]] + [[f"品{i}", i * 100] for i in range(1, 10)] + [["品x", "0,250"]]
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), xlsx({"s": rows}), "a.xlsx")
+    assert info.value.details["columns"][0]["values"] == [{"value": "0,250", "count": 1}]
+
+
+def test_semicolon_csv_keeps_commas_as_text(tmp_path):
+    """分号分隔的 CSV 来自逗号当小数点的区域设置:「1,500」可能是 1.5,不按千分位转,回执里写明。"""
+    db = tmp_path / "t.db"
+    report = load_into(str(db), "名称;单价\n甲;1,500\n乙;0,250\n丙;2,000\n丁;1,234,567\n".encode(), "x.csv")
+    assert col_types(db, "x")["单价"] == "TEXT"
+    assert query(db, "SELECT 单价 FROM x") == [("1,500",), ("0,250",), ("2,000",), ("1,234,567",)]
+    assert report.conversions == []
+    assert any("列「单价」有 4 个带逗号的数字写法" in w and "小数点还是千分位" in w for w in report.warnings)
+
+
+def test_comma_csv_still_converts_thousands(tmp_path):
+    db = tmp_path / "t.db"
+    report = load_into(str(db), 'k,v\na,"1,500"\nb,"2,000"\n'.encode(), "x.csv")
+    assert query(db, "SELECT SUM(v) FROM x")[0][0] == 3500
+    assert [c["kind"] for c in report.conversions] == ["thousands_separator"]
+
+
+def test_leading_zeros_and_long_numbers_stay_text(tmp_path):
+    """编号「00123」以前变成 123;19 位卡号以前变成同一个浮点数。整列按文本原样存。"""
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": [
+        ["编号", "卡号", "金额"],
+        ["00123", "6222021234567890123", 1],
+        ["00456", "6222021234567890124", 2],
+        [789, "6222021234567890125", 3],
+    ]}), "a.xlsx")
+    assert col_types(db, "s") == {"编号": "TEXT", "卡号": "TEXT", "金额": "INTEGER"}
+    assert [r[0] for r in query(db, "SELECT 编号 FROM s")] == ["00123", "00456", "789"]
+    assert query(db, "SELECT COUNT(DISTINCT 卡号) FROM s")[0][0] == 3
+    kinds = {(c["column"], c["kind"]) for c in report.conversions}
+    assert kinds == {("编号", "kept_as_text"), ("卡号", "kept_as_text")}
+
+
+def test_codes_are_not_scientific_notation_or_phone_prefixes(tmp_path):
+    db = tmp_path / "t.db"
+    load_into(str(db), xlsx({"s": [["代码", "区号"], ["1E5", "+86"], ["2E3", "+1"]]}), "a.xlsx")
+    assert query(db, "SELECT 代码, 区号 FROM s") == [("1E5", "+86"), ("2E3", "+1")]
+
+
+def test_dates_are_plain_iso(tmp_path):
+    """纯日期存 YYYY-MM-DD:以前带 T00:00:00,`= '2026-01-05'` 查不到,BETWEEN 漏掉月末那天。"""
+    db = tmp_path / "t.db"
+    load_into(str(db), xlsx({"s": [
+        ["日期", "时刻", "金额"],
+        [dt.datetime(2026, 1, 5), dt.datetime(2026, 1, 5, 8, 30), 1],
+        [dt.datetime(2026, 1, 31), dt.datetime(2026, 1, 31, 23, 0, 5), 2],
+    ]}), "a.xlsx")
+    assert query(db, "SELECT 日期, 时刻 FROM s") == [
+        ("2026-01-05", "2026-01-05 08:30:00"), ("2026-01-31", "2026-01-31 23:00:05"),
+    ]
+    assert query(db, "SELECT SUM(金额) FROM s WHERE 日期 BETWEEN '2026-01-01' AND '2026-01-31'")[0][0] == 3
+
+
+def test_mixed_column_needs_a_decision(tmp_path):
+    """数字列混进「N/A」「·」:以前整列静默变成 TEXT、MAX 按字典序取。现在要用户选。"""
+    rows = [["地区", "销量"]] + [[f"分区{i}", i * 10] for i in range(1, 10)] + [["分区x", "N/A"], ["分区y", "·"]]
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), xlsx({"明细": rows}), "a.xlsx")
+    e = info.value
+    assert isinstance(e, UnsupportedTable)         # 不认识 NeedsDecision 的老调用方当作拒收
+    assert e.kind == "mixed"
+    assert e.details == {"columns": [{
+        "sheet": "明细", "table": "明细", "column": "销量", "header": "销量", "numeric": 9, "nonnumeric": 2,
+        "values": [{"value": "N/A", "count": 1}, {"value": "·", "count": 1}],
+    }]}
+    json.dumps(e.details)
+    assert "存为空值" in str(e)
+    assert not (tmp_path / "t.db").exists() or tables(tmp_path / "t.db") == []
+
+
+def test_mixed_column_with_null_stores_numbers(tmp_path):
+    rows = [["地区", "销量"]] + [[f"分区{i}", i * 10] for i in range(1, 10)] + [["分区x", "N/A"], ["分区y", "·"]]
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"明细": rows}), "a.xlsx", mixed="null")
+    assert col_types(db, "明细")["销量"] == "INTEGER"
+    assert query(db, "SELECT MAX(销量), typeof(MAX(销量)), COUNT(销量), COUNT(*) FROM 明细")[0] == \
+        (90, "integer", 9, 11)
+    assert report.conversions == [{
+        "table": "明细", "column": "销量", "kind": "nonnumeric_to_null", "count": 2, "examples": ["N/A", "·"],
+    }]
+
+
+def test_error_values_count_as_placeholders(tmp_path):
+    """#DIV/0! 进数字列:按「非数字取值」走混合列规则。"""
+    rows = [["k", "v"]] + [[f"r{i}", i] for i in range(1, 10)] + [["rx", "#DIV/0!"]]
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "a.db"), xlsx({"s": rows}), "a.xlsx")
+    assert info.value.details["columns"][0]["values"] == [{"value": "#DIV/0!", "count": 1}]
+    db = tmp_path / "b.db"
+    load_into(str(db), xlsx({"s": rows}), "a.xlsx", mixed="null")
+    assert query(db, "SELECT MAX(v), COUNT(v) FROM s")[0] == (9, 9)
+
+
+def test_error_values_in_text_columns_are_warned(tmp_path):
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": [["名称", "备注"], ["甲", "正常"], ["乙", "#REF!"], ["丙", "缺货"]]}),
+                       "a.xlsx")
+    assert col_types(db, "s")["备注"] == "TEXT"
+    assert any("列「备注」中有 1 个错误值" in w and "#REF!" in w for w in report.warnings)
+
+
+def test_messy_numeric_column_stays_text_with_a_warning(tmp_path):
+    """非数字的写法太多(超过五种)就不是「占位符」了:整列按文本,回执里明说不能求和。"""
+    rows = [["k", "v"]] + [[f"r{i}", i] for i in range(1, 12)] + [[f"x{i}", w] for i, w in
+                                                                 enumerate(["无", "暂缺", "待定", "-", "?", "不详"])]
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": rows}), "a.xlsx")
+    assert col_types(db, "s")["v"] == "TEXT"
+    assert any("列「v」有 11 个数字" in w and "不能直接对这一列求和" in w for w in report.warnings)
+
+
+def test_placeholder_heavy_column_is_warned(tmp_path):
+    """数字不到八成(「·」占了一大半)够不上混合列,照文本存——但不能静默:回执里写明数字和占位符各几个。"""
+    raw = ("日期,夜间客流\n" + "".join(f"8月{i}日,{i * 10 if i % 5 < 2 else '·'}\n" for i in range(1, 11))).encode()
+    db = tmp_path / "t.db"
+    report = load_into(str(db), raw, "x.csv")
+    assert col_types(db, "x")["夜间客流"] == "TEXT"
+    assert report.conversions == []
+    hits = [w for w in report.warnings if "列「夜间客流」" in w]
+    assert len(hits) == 1
+    assert "有 4 个数字、6 个非数字的值" in hits[0] and "「·」6 个" in hits[0] and "不能直接对这一列求和" in hits[0]
+
+
+def test_text_columns_with_a_few_codes_are_warned_too(tmp_path):
+    """等级 A/B/C 里混了两个数字:取值是占位符一类(不超过五种),数字照样提一句。"""
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": [["名称", "等级"], ["甲", "A"], ["乙", 2], ["丙", "B"], ["丁", 3],
+                                            ["戊", "A"]]}), "a.xlsx")
+    assert any("列「等级」有 2 个数字、3 个非数字的值" in w for w in report.warnings)
+
+
+def test_real_text_columns_with_stray_numbers_are_not_warned(tmp_path):
+    """备注这种真正的文本列(写法很多),偶尔一个数字不提,免得回执里全是噪音。"""
+    notes = ["正常", "缺货", "已补", "待查", "退回", "改期", "加急"]
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": [["名称", "备注"]] + [[f"甲{i}", n] for i, n in enumerate(notes)]
+                                      + [["乙", 12]]}), "a.xlsx")
+    assert not any("列「备注」" in w for w in report.warnings)
+
+
+def test_serial_numbers_in_a_date_column_are_warned(tmp_path):
+    """日期列里混进没设日期格式的序列号(45000):整列按文本存,回执里点名。"""
+    rows = [["日期", "金额"]] + [[dt.datetime(2026, 1, d), d] for d in range(1, 11)] + [[45000, 11]]
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": rows}), "a.xlsx")
+    assert col_types(db, "s")["日期"] == "TEXT"
+    assert any("列「日期」有 10 个日期、1 个数字" in w and "日期格式" in w for w in report.warnings)
+
+
+def test_tables_are_strict_and_values_are_typed(tmp_path):
+    """STRICT 表,插入前已经按列类型转好:不靠 SQLite 的亲和性去悄悄转换。"""
+    db = tmp_path / "t.db"
+    load_into(str(db), xlsx({"s": [
+        ["名称", "数量", "单价", "是否", "日期"],
+        ["甲", 1, 1.5, True, dt.datetime(2026, 1, 1)],
+        ["乙", "2", "2", False, None],
+    ]}), "a.xlsx")
+    assert query(db, "SELECT sql FROM sqlite_master WHERE name='s'")[0][0].rstrip().endswith("STRICT")
+    assert col_types(db, "s") == {"名称": "TEXT", "数量": "INTEGER", "单价": "REAL", "是否": "TEXT", "日期": "TEXT"}
+    assert query(db, "SELECT typeof(名称), typeof(数量), typeof(单价), 是否 FROM s") == [
+        ("text", "integer", "real", "TRUE"), ("text", "integer", "real", "FALSE"),
+    ]
+
+
+# --------------------------------------------------------------------------
+# 样本之外:前 _TYPE_SAMPLE 行看不到的值
+# --------------------------------------------------------------------------
+
+
+def test_values_after_the_sample_rewrite_the_column(tmp_path, monkeypatch):
+    """类型先按样本定,写入时统计整列;整列的结论不一样就按整列重写,不留「样本外静默出错」的口子。"""
+    monkeypatch.setattr(tabular, "_TYPE_SAMPLE", 3)
+    rows = [["编号", "数量", "名称"]] + [[100 + i, i, f"甲{i}"] for i in range(1, 6)] + [["00789", 2.5, "乙"]]
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": rows}), "a.xlsx")
+    assert col_types(db, "s") == {"编号": "TEXT", "数量": "REAL", "名称": "TEXT"}
+    assert query(db, "SELECT 编号, 数量 FROM s ORDER BY rowid DESC LIMIT 1")[0] == ("00789", 2.5)
+    assert query(db, "SELECT COUNT(*), SUM(数量) FROM s")[0] == (6, 17.5)
+    assert [c["type"] for c in report.tables[0].columns] == ["TEXT", "REAL", "TEXT"]
+
+
+def test_placeholder_after_the_sample_still_needs_a_decision(tmp_path, monkeypatch):
+    monkeypatch.setattr(tabular, "_TYPE_SAMPLE", 3)
+    rows = [["k", "v"]] + [[f"r{i}", i] for i in range(1, 10)] + [["rx", "·"]]
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), xlsx({"s": rows}), "a.xlsx")
+    assert info.value.kind == "mixed"
+
+
+def test_wider_csv_rows_after_the_sample_keep_their_values(tmp_path, monkeypatch):
+    monkeypatch.setattr(tabular, "_TYPE_SAMPLE", 2)
+    raw = "a,b\n1,2\n3,4\n5,6\n7,8,9\n".encode()
+    db = tmp_path / "t.db"
+    report = load_into(str(db), raw, "x.csv")
+    assert [c["name"] for c in report.tables[0].columns] == ["a", "b", "col_3"]
+    assert query(db, "SELECT SUM(col_3) FROM x")[0][0] == 9
+
+
+# --------------------------------------------------------------------------
+# 公式
+# --------------------------------------------------------------------------
+
+
+def _with_total(cached: bool) -> bytes:
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    ws.title = "明细"
+    ws.append(["地区", "金额", "税额"])
+    for i, amount in enumerate([100, 200, 300], start=2):
+        ws.append([f"分区{i}", amount, f"=B{i}*0.1"])
+    raw = save(book)
+    return excel_saved(raw, {"C2": 10, "C3": 20, "C4": 30}) if cached else raw
+
+
+def test_uncached_formulas_in_the_table_are_rejected(tmp_path):
+    """程序生成、没用 Excel 保存过的文件:公式没有缓存值,读出来全是空。"""
+    with pytest.raises(UnsupportedTable) as info:
+        load_into(str(tmp_path / "t.db"), _with_total(cached=False), "a.xlsx")
+    text = str(info.value)
+    assert "C2" in text and "在 Excel 中打开并保存" in text
+    assert not isinstance(info.value, NeedsDecision)
+
+
+def test_cached_formulas_are_read(tmp_path):
+    db = tmp_path / "t.db"
+    report = load_into(str(db), _with_total(cached=True), "a.xlsx")
+    assert query(db, "SELECT SUM(税额) FROM 明细")[0][0] == 60
+    assert not any("重新计算" in w for w in report.warnings)
+
+
+def test_uncached_formulas_above_the_header_are_ignored(tmp_path):
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    ws["A1"] = "=TODAY()"                 # 标题区的公式,不在导入区域里
+    ws.append([])
+    ws["A3"], ws["B3"] = "k", "v"
+    ws["A4"], ws["B4"] = "a", 1
+    db = tmp_path / "t.db"
+    report = load_into(str(db), save(book), "a.xlsx", header_row=3)
+    assert report.tables[0].rows == 1
+
+
+def test_full_calc_on_load_is_warned(tmp_path):
+    """XlsxWriter 默认把公式缓存写成 0 并要求打开时重算:缓存值可能只是占位。"""
+    raw = patch(_with_total(cached=True), {"xl/workbook.xml": lambda t: t.replace(
+        " bytes:
+    header = ["指标"] + [f"8月{d}日" for d in range(1, 11)]
+    return xlsx({"日报": [header, ["客流"] + list(range(10, 20)), ["收入"] + list(range(20, 30))]})
+
+
+def test_date_header_needs_a_decision(tmp_path):
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), _date_header_table(), "a.xlsx")
+    e = info.value
+    assert e.kind == "shape"
+    assert [(r["kind"], r["cells"]) for r in e.details["reasons"]] == [("date_header", ["B1:K1"])]
+    assert "按原样导入(未规整)" in str(e)
+    json.dumps(e.details)
+
+
+def test_raw_mode_imports_and_marks_unshaped(tmp_path):
+    db = tmp_path / "t.db"
+    report = load_into(str(db), _date_header_table(), "a.xlsx", raw_mode=True)
+    assert report.tables[0].unshaped is True
+    assert report.tables[0].rows == 2
+    assert any("按原样导入、未经规整" in w for w in report.warnings)
+
+
+def test_section_title_needs_a_decision(tmp_path):
+    rows = [["地区", "一月", "二月", "三月"],
+            ["分区甲", 1, 2, 3],
+            ["东部小计口径", None, None, None],
+            ["分区乙", 4, 5, 6]]
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), xlsx({"s": rows}), "a.xlsx")
+    reason = info.value.details["reasons"][0]
+    assert reason["kind"] == "section_title"
+    assert reason["cells"] == ["A3"]
+
+
+def test_stacked_tables_need_a_decision(tmp_path):
+    """一张工作表里上下叠了两张表:第二张的标题和表头不能被当成第一张的数据行。"""
+    rows = [["地区", "销量", "金额"], ["分区甲", 10, 100], ["分区乙", 20, 200], [],
+            ["二、费用"], ["项目", "金额"], ["差旅", 5], ["办公", 7]]
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), xlsx({"s": rows}), "a.xlsx")
+    assert [(r["kind"], r["cells"]) for r in info.value.details["reasons"]] == [("section_title", ["A5"])]
+
+
+def test_formula_totals_need_a_decision(tmp_path):
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    ws.append(["地区", "金额"])
+    for i, v in enumerate([10, 20, 30], start=2):
+        ws.append([f"分区{i}", v])
+    ws.append(["合计", "=SUM(B2:B4)"])
+    raw = excel_saved(save(book), {"B5": 60})
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), raw, "a.xlsx")
+    reason = info.value.details["reasons"][0]
+    assert reason["kind"] == "formula_above"
+    assert reason["cells"] == ["B5"]
+    assert "SUM(B2:B4)" in reason["message"]
+
+
+def _cached_totals_book() -> bytes:
+    """合计行不用区域写法:B5 = B2+B3+B4,C5 = SUM(C2,C3,C4)。"""
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    ws.title = "s"
+    ws.append(["地区", "金额", "数量"])
+    for row in [["分区甲", 100, 1], ["分区乙", 200, 2], ["分区丙", 300, 3]]:
+        ws.append(row)
+    ws.append(["合计", "=B2+B3+B4", "=SUM(C2,C3,C4)"])
+    return excel_saved(save(book), {"B5": 600, "C5": 6})
+
+
+def test_cell_by_cell_totals_need_a_decision(tmp_path):
+    """手工表格里常见的逐格相加的合计行,以前漏检、照常进库,SUM 翻倍。"""
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), _cached_totals_book(), "a.xlsx")
+    reason = info.value.details["reasons"][0]
+    assert reason["kind"] == "formula_above"
+    assert reason["cells"] == ["B5", "C5"]
+    assert "B2+B3+B4" in reason["message"]
+
+
+def test_running_balance_is_not_a_total(tmp_path):
+    """滚动余额(C3 = C2+B3)引用了上一行,但不是合计:照常导入。"""
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    ws.title = "s"
+    ws.append(["日期", "发生额", "余额"])
+    ws.append(["1日", 100, 100])
+    values = {}
+    balance = 100
+    for r, amount in enumerate([50, -30, 20], start=3):
+        balance += amount
+        ws.append([f"{r - 1}日", amount, f"=C{r - 1}+B{r}"])
+        values[f"C{r}"] = balance
+    db = tmp_path / "t.db"
+    report = load_into(str(db), excel_saved(save(book), values), "a.xlsx")
+    assert report.tables[0].rows == 4
+    assert query(db, "SELECT 余额 FROM s ORDER BY rowid DESC LIMIT 1")[0][0] == 140
+
+
+def _table_with_totals_row() -> bytes:
+    """Excel 的表格对象(插入 → 表格)勾了汇总行:B5 = SUBTOTAL(109,销售表[金额])。"""
+    from openpyxl import Workbook
+    from openpyxl.worksheet.table import Table
+
+    book = Workbook()
+    ws = book.active
+    ws.title = "s"
+    ws.append(["地区", "金额"])
+    for row in [["分区甲", 100], ["分区乙", 200], ["分区丙", 300]]:
+        ws.append(row)
+    ws.append(["汇总", "=SUBTOTAL(109,销售表[金额])"])
+    table = Table(displayName="销售表", ref="A1:B5")
+    table.totalsRowCount = 1
+    ws.add_table(table)
+    return excel_saved(save(book), {"B5": 600})
+
+
+def test_table_totals_row_needs_a_decision(tmp_path):
+    """以前汇总行当成普通数据行导入,SUM 翻倍、COUNT 多一行,回执里一个字都没有。"""
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), _table_with_totals_row(), "a.xlsx")
+    e = info.value
+    assert e.kind == "shape"
+    assert [(r["kind"], r["cells"]) for r in e.details["reasons"]] == [("table_totals", ["A5:B5"])]
+    assert "销售表" in e.details["reasons"][0]["message"]
+    assert "表格对象的汇总行" in str(e)
+
+
+def test_table_totals_row_without_formulas_is_still_detected(tmp_path):
+    """汇总行里只有文字、没有公式(或者公式被改成了数值):表格对象的定义照样说明那是汇总行。"""
+    from openpyxl import Workbook
+    from openpyxl.worksheet.table import Table
+
+    book = Workbook()
+    ws = book.active
+    ws.title = "s"
+    for row in [["地区", "金额"], ["分区甲", 100], ["分区乙", 200], ["汇总", 300]]:
+        ws.append(row)
+    table = Table(displayName="明细表", ref="A1:B4")
+    table.totalsRowCount = 1
+    ws.add_table(table)
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), save(book), "a.xlsx")
+    assert [(r["kind"], r["cells"]) for r in info.value.details["reasons"]] == [("table_totals", ["A4:B4"])]
+
+
+def test_table_totals_row_raw_mode(tmp_path):
+    db = tmp_path / "t.db"
+    report = load_into(str(db), _table_with_totals_row(), "a.xlsx", raw_mode=True)
+    assert report.tables[0].unshaped is True
+    assert any("表格对象的汇总行" in w for w in report.warnings)
+
+
+def test_tables_without_totals_rows_import_normally(tmp_path):
+    from openpyxl import Workbook
+    from openpyxl.worksheet.table import Table
+
+    book = Workbook()
+    ws = book.active
+    ws.title = "s"
+    for row in [["地区", "金额"], ["分区甲", 100], ["分区乙", 200]]:
+        ws.append(row)
+    ws.add_table(Table(displayName="明细表", ref="A1:B3"))
+    db = tmp_path / "t.db"
+    report = load_into(str(db), save(book), "a.xlsx")
+    assert query(db, "SELECT SUM(金额), COUNT(*) FROM s")[0] == (300, 2)
+    assert report.warnings == [w for w in report.warnings if "重新计算" in w]
+
+
+def test_optional_text_columns_are_not_section_titles(tmp_path):
+    """全是文字列的表,一行只填了名字很正常,不拒收;但照常导入的这种行要逐行写进回执,不静默放过。"""
+    rows = [["姓名", "部门", "备注"], ["张甲", "一部", "x"], ["李乙", None, None], ["王丙", "二部", None]]
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": rows}), "a.xlsx")
+    assert report.tables[0].rows == 3 and not report.tables[0].unshaped
+    hits = [w for w in report.warnings if "只有一个单元格有文字" in w]
+    assert len(hits) == 1
+    assert "有 1 行" in hits[0] and "A3「李乙」" in hits[0] and "会计入行数" in hits[0]
+
+
+def test_two_column_tables_have_section_titles_too(tmp_path):
+    """两列的表(地区、金额)里的分段标题同样会灌进 COUNT:不因为列少就放过。"""
+    rows = [["地区", "金额"], ["分区甲", 100], ["华东", None], ["分区乙", 200], ["分区丙", 300],
+            ["华北", None], ["分区丁", 400]]
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), xlsx({"s": rows}), "a.xlsx")
+    reason = info.value.details["reasons"][0]
+    assert reason["kind"] == "section_title" and reason["cells"] == ["A3", "A6"]
+    assert "疑似分段标题" in reason["message"]
+
+
+def test_section_titles_in_mostly_text_tables(tmp_path):
+    """文字列占多数(地区、门店、负责人)、只有销量一列是数字:分段标题照样要检出。"""
+    rows = [["地区", "门店", "负责人", "销量"], ["分区甲", "店一", "张甲", 10], ["华东区", None, None, None],
+            ["分区乙", "店二", "李乙", 20], ["华北区", None, None, None], ["分区丙", "店三", "王丙", 30]]
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), xlsx({"s": rows}), "a.xlsx")
+    assert [(r["kind"], r["cells"]) for r in info.value.details["reasons"]] == [("section_title", ["A3", "A5"])]
+
+
+def test_single_column_lists_are_not_section_titles(tmp_path):
+    """只有一列的名单,每一行都「只有一个格子有字」:没有其余列,这条判据不适用。"""
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": [["姓名"], ["张甲"], ["李乙"], ["王丙"]]}), "a.xlsx")
+    assert report.tables[0].rows == 3
+    assert report.warnings == []
+
+
+def test_many_date_columns_are_not_a_crosstab(tmp_path):
+    """一行一个项目、好几个日期列:日期在值里,不是横排的表头。"""
+    header = ["项目"] + [f"节点{i}" for i in range(1, 9)]
+    rows = [header] + [[f"项目{p}"] + [dt.datetime(2026, 1, p + i) for i in range(8)] for p in range(1, 4)]
+    rows.append(["项目9"] + [dt.datetime(2026, 2, 1)] * 3 + [None] * 5)
+    db = tmp_path / "t.db"
+    report = load_into(str(db), xlsx({"s": rows}), "a.xlsx")
+    assert report.tables[0].rows == 4
+
+
+def crosstab_book(days: int = 31, seed: int = 7):
+    """结构仿照客流报表的合成交叉表:日期横排、三块叠放、分段标题、占位符「·」、公式合计。标签是假名。
+
+    返回 (工作簿, 公式的缓存值):openpyxl 存不了缓存值,存盘后由 excel_saved 补上。
+    """
+    from openpyxl import Workbook
+    from openpyxl.utils import get_column_letter
+
+    rnd = random.Random(seed)
+    book = Workbook()
+    ws = book.active
+    ws.title = "客流汇总"
+    ws["B2"] = "统计时间范围:2026年8月1日至2026年8月31日"
+    ws["B3"] = "单位:人次"
+    ws["B4"] = "指标"
+    for d in range(days):
+        ws.cell(4, 3 + d, f"8月{d + 1}日")
+    for d in range(days):                              # 第一块:全日 = 甲 + 乙
+        a, b = rnd.randint(100, 900), rnd.randint(100, 900)
+        ws.cell(5, 3 + d, a + b)
+        ws.cell(6, 3 + d, a)
+        ws.cell(7, 3 + d, b)
+    ws["B5"], ws["B6"], ws["B7"] = "全日客流(人次)", "分区甲(人次)", "分区乙(人次)"
+    ws["B9"] = "日间时段客流(人次)"                   # 第二块
+    ws.merge_cells(start_row=9, start_column=2, end_row=9, end_column=2 + days)
+    for i, h in enumerate(range(7, 18)):
+        ws.cell(10 + i, 2, f"{h}-{h + 1}")
+        for d in range(days):
+            ws.cell(10 + i, 3 + d, "·" if (h == 7 and d % 5 == 0) else rnd.randint(0, 200))
+    ws["B21"] = "夜间时段客流(人次)"                  # 第三块
+    night: dict[tuple[int, int], int] = {}
+    for i, h in enumerate(range(18, 24)):
+        ws.cell(22 + i, 2, f"{h}-{h + 1}")
+        for d in range(days):
+            night[(22 + i, d)] = v = rnd.randint(0, 100)
+            ws.cell(22 + i, 3 + d, v)
+    cached: dict[str, object] = {}
+    for i, (label, r1, r2) in enumerate([("18-22 时合计", 22, 25), ("22-24 时合计", 26, 27), ("18-24 时合计", 22, 27)]):
+        ws.cell(28 + i, 2, label)
+        for d in range(days):
+            col = get_column_letter(3 + d)
+            ws.cell(28 + i, 3 + d, f"=SUM({col}{r1}:{col}{r2})")
+            cached[f"{col}{28 + i}"] = sum(night[(r, d)] for r in range(r1, r2 + 1))
+    return book, cached
+
+
+def crosstab() -> bytes:
+    book, cached = crosstab_book()
+    return excel_saved(save(book), cached)
+
+
+@pytest.mark.parametrize("header_row", [1, 4])
+def test_synthetic_crosstab_is_rejected_with_reasons(tmp_path, header_row):
+    """表头行填对(第 4 行)或用默认值(第 1 行)都要拒收,并逐条说出原因和坐标。"""
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), crosstab(), "a.xlsx", header_row=header_row)
+    e = info.value
+    assert e.kind == "shape"
+    by_kind = {r["kind"]: r for r in e.details["reasons"]}
+    assert by_kind.keys() == ({"date_header", "section_title", "formula_above"} if header_row == 4
+                              else {"date_row", "section_title", "formula_above"})
+    assert by_kind.get("date_header", by_kind.get("date_row"))["cells"] == ["C4:AG4"]
+    assert {"B9", "B21"} <= set(by_kind["section_title"]["cells"])
+    assert by_kind["formula_above"]["cells"][0] == "C28"
+    assert all(r["sheet"] == "客流汇总" and r["message"] for r in e.details["reasons"])
+    assert not (tmp_path / "t.db").exists() or tables(tmp_path / "t.db") == []
+    if header_row == 4:
+        # 第一次拒收就列全四组:合计(formula_above)、分段标题、日期列里的占位符「·」、日期没有写年份。
+        # 「·」以前要等选了「按原样导入」、第二次退回时才看得到
+        assert by_kind["date_header"]["no_year"] is True and "没有写年份" in by_kind["date_header"]["message"]
+        assert e.details["mixed_complete"] is True
+        preview = e.details["mixed"]
+        assert len(preview) == 7 and {v["value"] for c in preview for v in c["values"]} == {"·"}
+        assert "另有 7 列以数字为主、混有非数字的值(如 「·」)" in str(e)
+        # 预告和第二轮真正要选的是同一批列
+        with pytest.raises(NeedsDecision) as second:
+            load_into(str(tmp_path / "t.db"), crosstab(), "a.xlsx", header_row=4, raw_mode=True)
+        assert second.value.kind == "mixed" and second.value.details["columns"] == preview
+
+
+def test_shape_decision_without_mixed_columns_has_no_preview(tmp_path):
+    """没有混合列的多块表:不带预告,提示里也不提。"""
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    for row in [["项目", "金额"], ["分区甲", 10], ["分区乙", 20], ["二、费用", None], ["分区丙", 5]]:
+        ws.append(row)
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), save(book), "a.xlsx")
+    assert info.value.kind == "shape" and "mixed" not in info.value.details
+    assert "混有非数字" not in str(info.value)
+
+
+def test_synthetic_crosstab_raw_mode(tmp_path):
+    """选「按原样导入(未规整)」:照常导入并标记;「·」要么存空值要么拒收,选存空值后 MAX 是数字。"""
+    db = tmp_path / "t.db"
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(db), crosstab(), "a.xlsx", header_row=4, raw_mode=True)
+    assert info.value.kind == "mixed"
+    assert {v["value"] for c in info.value.details["columns"] for v in c["values"]} == {"·"}
+
+    report = load_into(str(db), crosstab(), "a.xlsx", header_row=4, raw_mode=True, mixed="null")
+    table = report.tables[0]
+    assert table.unshaped is True
+    assert table.columns[0] == {"name": "指标", "type": "TEXT", "header": "指标"}
+    assert table.columns[1] == {"name": "c_8月1日", "type": "INTEGER", "header": "8月1日"}
+    assert table.region == "B4:AG30"
+    assert table.blank_rows_skipped == 1
+    assert query(db, 'SELECT typeof(MAX("c_8月1日")) FROM 客流汇总')[0][0] == "integer"
+    assert {c["kind"] for c in report.conversions} == {"nonnumeric_to_null"}
+    assert any("未经规整" in w for w in report.warnings)
+    assert any("合并单元格" in w and "B9:AG9" in w for w in report.warnings)
+
+
+def test_raw_mode_leaves_tidy_tables_shaped(tmp_path):
+    book, cached = crosstab_book()
+    tidy = book.create_sheet("明细")
+    tidy.append(["地区", "金额"])
+    tidy.append(["分区甲", 1])
+    db = tmp_path / "t.db"
+    raw = excel_saved(save(book), cached)
+    report = load_into(str(db), raw, "a.xlsx", header_row=1, raw_mode=True, mixed="null")
+    assert {t.name: t.unshaped for t in report.tables} == {"客流汇总": True, "明细": False}
+
+
+# --------------------------------------------------------------------------
+# 回执
+# --------------------------------------------------------------------------
+
+
+def test_merged_cells_and_hidden_rows_are_reported(tmp_path):
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    ws.title = "s"
+    ws.append(["地区", "产品", "销量"])
+    ws.append(["分区甲", "A", 1])
+    ws.append([None, "B", 2])
+    ws.append(["分区乙", "A", 3])
+    ws.merge_cells("A2:A3")
+    ws.row_dimensions[4].hidden = True
+    ws.column_dimensions["B"].hidden = True
+    report = load_into(str(tmp_path / "t.db"), save(book), "a.xlsx")
+    text = "\n".join(report.warnings)
+    assert "1 处合并单元格(如 A2:A3)" in text
+    assert "1 行在 Excel 中处于隐藏或筛选状态(如第 4 行)" in text
+    assert "1 列在 Excel 中被隐藏(如 B 列)" in text
+
+
+def test_report_is_json_ready(tmp_path):
+    report = load_into(str(tmp_path / "t.db"), xlsx({"s": [["a", "b"], ["1,000", "00123"]]}), "a.xlsx")
+    data = asdict(report)
+    json.dumps(data, ensure_ascii=False)
+    assert data["tables"][0] == {
+        "name": "s", "sheet": "s",
+        "columns": [{"name": "a", "type": "INTEGER", "header": "a"}, {"name": "b", "type": "TEXT", "header": "b"}],
+        "rows": 1, "region": "A1:B2", "blank_rows_skipped": 0, "columns_trimmed": [], "unshaped": False,
+    }
+    assert report.total_rows == 1
 
 
 # --------------------------------------------------------------------------
@@ -168,18 +1139,51 @@ def test_csv_with_gbk_encoding(tmp_path):
     db = tmp_path / "t.db"
     raw = "月份,金额\n1月,100\n".encode("gb18030")
     load_into(str(db), raw, "销售.csv")
-    table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
+    table = tables(db)[0]
     assert "月份" in col_types(db, table)
     assert query(db, f'SELECT "金额" FROM "{table}"')[0][0] == 100
 
 
+def test_utf16_csv(tmp_path):
+    db = tmp_path / "t.db"
+    load_into(str(db), "月份\t金额\n1月\t100\n".encode("utf-16"), "x.tsv")
+    assert query(db, "SELECT 金额 FROM x")[0][0] == 100
+
+
 def test_tsv_and_semicolon_csv(tmp_path):
     db = tmp_path / "t.db"
     load_into(str(db), b"a\tb\n1\t2\n", "x.tsv")
-    table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
+    table = tables(db)[0]
     assert list(col_types(db, table)) == ["a", "b"]
 
 
+def test_csv_follows_the_same_rules(tmp_path):
+    """CSV 走同样的值、类型、空行、名字规则。"""
+    raw = ("编号,金额,备注,\n"
+           "00123,\"1,234\",甲,\n"
+           ",,,\n"
+           "\n"
+           "00456,\"2,000\",乙,\n").encode()
+    db = tmp_path / "t.db"
+    report = load_into(str(db), raw, "账目 2026.csv")
+    table = report.tables[0]
+    assert table.name == "账目_2026"
+    assert [c["name"] for c in table.columns] == ["编号", "金额", "备注"]
+    assert table.columns_trimmed == ["D"]
+    assert table.blank_rows_skipped == 2
+    assert query(db, "SELECT 编号, 金额 FROM 账目_2026") == [("00123", 1234), ("00456", 2000)]
+
+
+def test_csv_mixed_needs_a_decision(tmp_path):
+    raw = ("k,v\n" + "".join(f"r{i},{i}\n" for i in range(1, 10)) + "rx,-\n").encode()
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "a.db"), raw, "x.csv")
+    assert info.value.details["columns"][0]["values"] == [{"value": "-", "count": 1}]
+    db = tmp_path / "b.db"
+    load_into(str(db), raw, "x.csv", mixed="null")
+    assert query(db, "SELECT MAX(v), COUNT(v) FROM x")[0] == (9, 9)
+
+
 def test_unsupported_formats_say_what_to_do(tmp_path):
     db = tmp_path / "t.db"
     with pytest.raises(UnsupportedTable, match="另存为"):
@@ -188,6 +1192,17 @@ def test_unsupported_formats_say_what_to_do(tmp_path):
         load_into(str(db), b"x", "a.pdf")
 
 
+def test_bad_arguments(tmp_path):
+    with pytest.raises(UnsupportedTable, match="从 1 开始"):
+        load_into(str(tmp_path / "t.db"), b"a\n1\n", "x.csv", header_row=0)
+    with pytest.raises(UnsupportedTable) as info:
+        load_into(str(tmp_path / "t.db"), b"a\n1\n", "x.csv", mixed="maybe")
+    text = str(info.value)
+    assert text.startswith("导入选项无效")
+    # 这句话可能原样进 400:不露参数名和取值
+    assert not re.search(r"mixed|reject|null|maybe", text)
+
+
 # --------------------------------------------------------------------------
 # 重传
 # --------------------------------------------------------------------------
@@ -198,10 +1213,21 @@ def test_reupload_replaces_instead_of_appending(tmp_path):
     db = tmp_path / "t.db"
     load_into(str(db), xlsx({"s": [["a"], [1], [2]]}), "a.xlsx")
     load_into(str(db), xlsx({"s": [["a"], [9]]}), "a.xlsx")
-    table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
+    table = tables(db)[0]
     assert [r[0] for r in query(db, f'SELECT "a" FROM "{table}"')] == [9]
 
 
+def test_failed_reload_leaves_previous_tables_intact(tmp_path):
+    """所有表在一个事务里写:第二张表要拍板时,第一张表不能已经被清空(以前就是这样)。"""
+    db = tmp_path / "t.db"
+    load_into(str(db), xlsx({"s1": [["a"], [1], [2]], "s2": [["b"], [10]]}), "a.xlsx")
+    bad = xlsx({"s1": [["a"], [7]], "s2": [["b"]] + [[i] for i in range(1, 10)] + [["N/A"]]})
+    with pytest.raises(NeedsDecision):
+        load_into(str(db), bad, "a.xlsx")
+    assert [r[0] for r in query(db, "SELECT a FROM s1")] == [1, 2]
+    assert [r[0] for r in query(db, "SELECT b FROM s2")] == [10]
+
+
 # --------------------------------------------------------------------------
 # 接口
 # --------------------------------------------------------------------------
@@ -284,15 +1310,107 @@ async def test_upload_will_not_hijack_a_real_database(client):
     assert "请换一个名称" in resp.json()["detail"]
 
 
-def test_broken_excel_message_has_no_exception_class_name():
+
+
+def test_broken_excel_message_has_no_exception_class_name(tmp_path):
     """坏掉的 .xlsx:报错说人话,不带 Python 异常类名(BadZipFile、InvalidFileException…)。
 
     这句话原样进上传的 400 detail,界面上是红字首行。
     """
-    from app.data.tabular import read_tables
-
     with pytest.raises(UnsupportedTable) as info:
-        read_tables(b"not really an xlsx", "orders.xlsx")
+        load_into(str(tmp_path / "t.db"), b"not really an xlsx", "orders.xlsx")
     text = str(info.value)
     assert text.startswith("无法打开该 Excel 文件")
     assert "BadZipFile" not in text and "Error" not in text and "Exception" not in text
+
+
+# --------------------------------------------------------------------------
+# 表头行号超出内容、CSV 的说法
+# --------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("header_row", [42, 500])
+def test_header_row_beyond_the_content_says_so(tmp_path, header_row):
+    """表里有内容、只是表头行号填大了:报的是行号超出了内容范围、最后一行是第几行,
+    不是「没有包含内容的可见工作表」(那会让人以为文件坏了)。"""
+    from openpyxl import Workbook
+
+    book = Workbook()
+    ws = book.active
+    ws.title = "明细"
+    ws.append(["日期", "金额"])
+    for i in range(40):
+        ws.append([f"2026-01-{i % 28 + 1:02d}", i])
+    with pytest.raises(UnsupportedTable) as info:
+        load_into(str(tmp_path / "t.db"), save(book), "t.xlsx", header_row=header_row)
+    message = str(info.value)
+    assert f"表头行号第 {header_row} 行超出了工作表的内容范围" in message, message
+    assert "「明细」到第 41 行" in message and "没有包含内容" not in message
+    # 刚好在最后一行还能导入(只有表头、没有数据时另有说法)
+    assert load_into(str(tmp_path / "ok.db"), save(book), "t.xlsx", header_row=41).tables
+
+
+def test_csv_header_row_beyond_the_content_says_so(tmp_path):
+    raw = "区域,数量\n甲,1\n乙,2\n".encode()
+    with pytest.raises(UnsupportedTable, match="表头行号第 9 行超出了文件的内容范围"):
+        load_into(str(tmp_path / "t.db"), raw, "t.csv", header_row=9)
+    # 表头那一行是空行、下面有数据的,不是超出范围:照常导入,列名按位置生成
+    report = load_into(str(tmp_path / "ok.db"), "标题\n\n甲,1\n乙,2\n".encode(), "t.csv", header_row=2)
+    assert report.tables[0].rows == 2
+
+
+def test_csv_crosstab_is_called_a_file_not_a_sheet(tmp_path):
+    """CSV 没有工作表:交叉表拒收、表头为空这些提示说「文件「…」」。"""
+    head = "路段," + ",".join(f"2026-01-0{d}" for d in range(1, 9))
+    body = ["甲区" + "," * 8] + [f"路段{i}," + ",".join(str(i * d) for d in range(1, 9)) for i in range(1, 4)] \
+        + ["乙区" + "," * 8] + [f"路段{i}," + ",".join(str(i + d) for d in range(1, 9)) for i in range(4, 7)]
+    raw = "\n".join([head, *body]).encode()
+    with pytest.raises(NeedsDecision) as info:
+        load_into(str(tmp_path / "t.db"), raw, "cross_traffic.csv")
+    message = str(info.value)
+    assert message.startswith("文件「cross_traffic」不是一行一条记录的规整表格"), message
+    assert "工作表" not in message
+    assert {r["sheet"] for r in info.value.details["reasons"]} == {"cross_traffic"}
+
+    report = load_into(str(tmp_path / "blank.db"), ",\n1,2\n3,4\n".encode(), "无表头.csv")
+    assert any(w.startswith("文件「无表头」的表头行(第 1 行)没有内容") for w in report.warnings), report.warnings
+
+
+# --------------------------------------------------------------------------
+# 带百分号、单位、货币符号的数字
+# --------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(("values", "examples"), [
+    (["15%", "25%", "60%"], "「15%」、「25%」、「60%」"),
+    (["1,234元", "2,000元", "500元"], "「1,234元」、「2,000元」、「500元」"),
+    (["¥1234", "¥2000", "¥500"], "「¥1234」、「¥2000」、「¥500」"),
+])
+def test_numbers_with_units_kept_as_text_are_reported(tmp_path, values, examples):
+    """「15%」「1,234元」「¥1234」按文本存(口径要人来定),但 SQLite 对它们 SUM 只取开头的数字、MAX 按字典序:
+    以前回执里一个字都没有(import-surface #17 的另一半)。现在逐列写进警告。"""
+    db = tmp_path / "t.db"
+    raw = ("分区,金额\n" + "\n".join(f'分区{i},"{v}"' for i, v in enumerate(values))).encode()
+    report = load_into(str(db), raw, "a.csv")
+    assert report.tables[0].columns[1]["type"] == "TEXT"
+    [warning] = [w for w in report.warnings if "金额" in w]
+    assert f"有 3 个带百分号、单位或货币符号的数字(如 {examples})" in warning, warning
+    assert "已整列按文本保存" in warning and "不会按数值计算" in warning
+    assert report.conversions == []
+
+
+def test_a_few_unit_values_in_a_text_column_are_not_reported(tmp_path):
+    """备注列里偶尔出现一个「50%」不提:这类值加上纯数字不过半的,是真正的文本列。"""
+    raw = "分区,备注\n甲,正常\n乙,下降约50%\n丙,50%\n丁,待核实\n戊,无\n".encode()
+    report = load_into(str(tmp_path / "t.db"), raw, "a.csv")
+    assert not [w for w in report.warnings if "备注" in w]
+
+
+def test_dates_and_slots_are_not_numbers_with_units(tmp_path):
+    """「8月1日」「2026年」「3号」「8-9」「12A」不是带单位的数量:日期横排的表头照样认得出,编号列不写警告。"""
+    for text in ("8月1日", "2026年", "8月", "3号", "18时", "8-9", "12A", "1E5", "+86"):
+        assert tabular._classify_text(text)[2] != "unit", text
+    for text in ("15%", "60%", "1,234元", "¥1234", "$1,234.50", "-5%", "12kg", "50万", "1.5亿元"):
+        assert tabular._classify_text(text)[2] == "unit", text
+    raw = "编号,数量\n12A,1\n13B,2\n14C,3\n".encode()
+    assert not [w for w in load_into(str(tmp_path / "t.db"), raw, "a.csv").warnings if "编号" in w]
diff --git a/backend/tests/test_xlsx_scan.py b/backend/tests/test_xlsx_scan.py
new file mode 100644
index 00000000..218215ab
--- /dev/null
+++ b/backend/tests/test_xlsx_scan.py
@@ -0,0 +1,795 @@
+"""xlsx 版式元数据的流式扫描:openpyxl read_only 拿不到的,这里要拿全、拿准。
+
+导入的第二遍按这里算出的边界读值(tabular.load_into),所以边界错了就是数据错了:
+`` 陈旧时少读一截、离群格把区域撑成几千万格。夹具都用 openpyxl 现造,
+需要 openpyxl 写不出来的形态(缓存值、无坐标的单元格、DTD)时直接改压缩包里的 XML。
+"""
+
+from __future__ import annotations
+
+import io
+import re
+import zipfile
+from collections.abc import Callable
+from dataclasses import asdict
+
+import pytest
+from openpyxl import Workbook
+
+from app.data import xlsx_scan
+from app.data.xlsx_scan import UnsupportedTable, scan
+
+
+def save(book: Workbook) -> bytes:
+    buf = io.BytesIO()
+    book.save(buf)
+    return buf.getvalue()
+
+
+def patch(raw: bytes, edits: dict[str, Callable[[str], str]]) -> bytes:
+    """改压缩包里的部件:{路径: 文本 → 文本}。"""
+    src = zipfile.ZipFile(io.BytesIO(raw))
+    out = io.BytesIO()
+    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst:
+        for info in src.infolist():
+            data = src.read(info.filename)
+            if info.filename in edits:
+                data = edits[info.filename](data.decode()).encode()
+            dst.writestr(info, data)
+    return out.getvalue()
+
+
+def cache(values: dict[str, str]) -> Callable[[str], str]:
+    """给 openpyxl 写出的公式补上缓存值(模拟 Excel 保存过)。"""
+    def edit(text: str) -> str:
+        def fill(m: re.Match[str]) -> str:
+            ref = m.group(1)
+            return f'{m.group(2)}{values[ref]}' if ref in values else m.group(0)
+        return re.sub(r'([^<]*)', fill, text)
+    return edit
+
+
+SHEET1 = "xl/worksheets/sheet1.xml"
+
+
+# --------------------------------------------------------------------------
+# 边界
+# --------------------------------------------------------------------------
+
+
+def test_bounds_come_from_cells_not_from_dimension():
+    """ 是写文件的程序自己填的,可能是陈旧的:真实边界只能从非空格算。"""
+    book = Workbook()
+    ws = book.active
+    ws["C5"] = "地区"
+    ws["D5"] = "金额"
+    for r in range(6, 26):
+        ws.cell(r, 3, f"分区{r}")
+        ws.cell(r, 4, r)
+    raw = patch(save(book), {SHEET1: lambda t: re.sub(r'', '', t)})
+    sheet = scan(raw).sheets[0]
+    assert sheet.dimension == "A1:B2"            # 记下来,但不信
+    assert sheet.bounds is not None
+    assert sheet.bounds.a1() == "C5:D25"
+    assert sheet.nonempty == 42
+
+
+def test_missing_dimension_is_fine():
+    """openpyxl 的 write_only 根本不写 。"""
+    book = Workbook(write_only=True)
+    ws = book.create_sheet("明细")
+    ws.append(["a", "b"])
+    for i in range(30):
+        ws.append([i, i * 2])
+    sheet = scan(save(book)).sheets[0]
+    assert sheet.dimension is None
+    assert sheet.bounds.a1() == "A1:B31"
+
+
+def test_blank_strings_do_not_count_but_formulas_do():
+    """只有空白的文本算空(导入时也按空值存);有公式的格不算空,哪怕没有缓存值。"""
+    book = Workbook()
+    ws = book.active
+    ws["A1"] = "x"
+    ws["B1"] = "   "              # 共享字符串里的空白
+    ws["E9"] = "=A1"              # 没有缓存值的公式
+    sheet = scan(save(book)).sheets[0]
+    assert sheet.nonempty == 2
+    assert sheet.bounds.a1() == "A1:E9"
+
+
+def test_inline_strings_and_cells_without_coordinates():
+    """有的程序写内联字符串、省略行号和单元格坐标:位置按顺序推出来。"""
+    book = Workbook()
+    book.active["A1"] = "占位"
+    raw = save(book)
+    data = ('甲1'
+            ' 乙'
+            '2')
+    raw = patch(raw, {SHEET1: lambda t: re.sub(r".*", data, t, flags=re.S)})
+    sheet = scan(raw).sheets[0]
+    assert sheet.bounds.a1() == "A1:C2"
+    assert sheet.nonempty == 4                    # 「 」是空白,不算
+
+
+def test_far_cells_name_the_outliers():
+    """稀疏判定拒收时要说出是哪几个远处的格子:A 列一百行数据、XFD 列冒出来一个。"""
+    book = Workbook()
+    ws = book.active
+    for r in range(1, 101):
+        ws.cell(r, 1, r)
+    ws["XFD5"] = "误输入"
+    ws["A90000"] = 1
+    sheet = scan(save(book)).sheets[0]
+    assert sheet.bounds.a1() == "A1:XFD90000"
+    assert sheet.far_cells == ["XFD5", "A90000"]
+
+
+def test_a_far_cell_jumping_both_ways_is_named_once():
+    """右下角一个格子(XFD1048576)同时撑出了右边界和下边界:示例里只出现一次。"""
+    book = Workbook()
+    ws = book.active
+    for r in range(1, 201):
+        ws.cell(r, 1, r)
+        ws.cell(r, 2, r)
+    ws["XFD1048576"] = "x"
+    assert scan(save(book)).sheets[0].far_cells == ["XFD1048576"]
+
+
+# --------------------------------------------------------------------------
+# 版式
+# --------------------------------------------------------------------------
+
+
+def test_merged_hidden_and_autofilter():
+    book = Workbook()
+    ws = book.active
+    for r in range(1, 12):
+        ws.append([f"r{r}", r, r * 10, r * 100])
+    ws.merge_cells("A1:B1")
+    ws.merge_cells("C3:D4")
+    for r in (3, 4, 5, 9):
+        ws.row_dimensions[r].hidden = True
+    ws.column_dimensions["C"].hidden = True
+    ws.auto_filter.ref = "A1:D11"
+    ws.auto_filter.add_filter_column(0, ["r2"])
+    sheet = scan(save(book)).sheets[0]
+    assert sorted(sheet.merged) == ["A1:B1", "C3:D4"]
+    assert sheet.merged_total == 2
+    assert sheet.hidden_rows == [(3, 5), (9, 9)]     # 连续的合成区间
+    assert sheet.hidden_cols == [(3, 3)]
+    assert sheet.autofilter == "A1:D11"
+    #  不是公式:字节级查找 b"