Files
AgentLab/backend/app/data/raw_store.py
T
ThearchyHelios 8059cd5147 Excel 导入改造期 1:止血与版本底座——写错的列名不再静默得 0,导入不再悄悄读错,每次上传生成不可变的版本,运行固定发起时的数据版本
连接层
- 所有 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 认上传的表格,显示当前版本;上传的表格不再给「探查结构」
2026-10-01 13:42:04 +08:00

134 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""上传表格的原件存档:按内容哈希存,只写一次,没有任何下载接口。
**为什么要留原件。** 以前上传接口把文件读进内存、交给解析器,最后只剩一个 .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/<前两位>/<sha>)。只认 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