B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据

This commit is contained in:
腾讯WorkBuddy
2026-08-17 01:00:19 +08:00
parent 7f1a93f6a6
commit 486842963e
15 changed files with 2019 additions and 183 deletions
+50 -1
View File
@@ -307,6 +307,53 @@ MIGRATIONS: tuple[Migration, ...] = (
ALTER TABLE bank_accounts_new RENAME TO bank_accounts;
""",
),
Migration(
version=4,
name="0004_per_sheet_reviews",
# Per-worksheet lifecycle: the parse outcome (parsed/exception/ignored)
# is immutable evidence captured at import time; the human decision
# (pending/confirmed/ignored) is the audit-gated gate that lets a
# worksheet participate in later matching and calculation.
up="""
CREATE TABLE sheet_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
sheet_name TEXT NOT NULL,
outcome TEXT NOT NULL
CHECK (outcome IN ('parsed', 'exception', 'ignored')),
message TEXT,
scanned_rows INTEGER,
candidate_headers TEXT,
sheet_batch_id INTEGER REFERENCES sheet_batches (id),
review_status TEXT NOT NULL DEFAULT 'pending'
CHECK (review_status IN ('pending', 'confirmed', 'ignored')),
review_reason TEXT,
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
created_at TEXT NOT NULL,
UNIQUE (import_batch_id, sheet_name)
);
CREATE INDEX idx_sheet_reviews_batch ON sheet_reviews (import_batch_id);
CREATE TRIGGER sheet_reviews_evidence_immutable BEFORE UPDATE ON sheet_reviews
BEGIN
SELECT RAISE (ABORT, 'sheet_reviews parse evidence is immutable')
WHERE OLD.outcome != NEW.outcome
OR OLD.message IS NOT NEW.message
OR OLD.scanned_rows IS NOT NEW.scanned_rows
OR OLD.candidate_headers IS NOT NEW.candidate_headers
OR OLD.sheet_batch_id IS NOT NEW.sheet_batch_id
OR OLD.import_batch_id IS NOT NEW.import_batch_id
OR OLD.sheet_name IS NOT NEW.sheet_name;
END;
""",
down="""
DROP TRIGGER IF EXISTS sheet_reviews_evidence_immutable;
DROP INDEX IF EXISTS idx_sheet_reviews_batch;
DROP TABLE IF EXISTS sheet_reviews;
""",
),
)
@@ -314,7 +361,9 @@ def connect(path: str | Path) -> sqlite3.Connection:
db_path = Path(path)
if str(db_path) != ":memory:":
db_path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(str(db_path))
# Long busy timeout so concurrent uploads/confirmations wait for the
# single SQLite writer instead of surfacing "database is locked" 500s.
connection = sqlite3.connect(str(db_path), timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
+357 -13
View File
@@ -1,10 +1,16 @@
"""Immutable statement import pipeline.
"""Immutable statement import pipeline with per-worksheet review lifecycle.
Every uploaded file is hashed (SHA-256) and written once to content-addressed
storage before parsing. A repeated upload of identical bytes never creates a
second set of facts: it records a ``duplicate`` batch that points at the
original batch. Parse failures keep the batch and its diagnostics as an
``exception`` batch without producing any confirmed source rows.
Each worksheet is parsed independently into a :class:`SheetResult`
(``parsed`` / ``exception`` / ``ignored``) that is persisted in
``sheet_reviews`` as immutable evidence. Whether a parsed sheet may take part
in later matching and calculation is a separate, auditable human decision
(``review_status``): only ``confirmed`` sheets participate.
"""
from __future__ import annotations
@@ -14,11 +20,18 @@ import hashlib
import json
import os
from pathlib import Path
import shutil
import sqlite3
from . import auth
from .db import utc_now
from .models import StatementBatch
from .parser import StatementParseError, parse_statement
from .models import SheetResult, StatementBatch
from .parser import StatementParseError, analyze_workbook
from .reader import CorruptWorkbookError
class SheetReviewError(ValueError):
"""A review decision cannot be applied to the worksheet (mapped to 409)."""
@dataclass(frozen=True)
@@ -28,6 +41,7 @@ class ImportResult:
sha256: str
source_file_id: int
batches: tuple[StatementBatch, ...] = ()
sheets: tuple[SheetResult, ...] = ()
message: str | None = None
duplicate_same_company: bool = False # only meaningful when status == 'duplicate'
@@ -48,6 +62,78 @@ def import_statement(
return _record_duplicate(connection, existing_file["id"], sha256, company_id)
stored_path = _store_immutable(Path(storage_dir), original_filename, content, sha256)
try:
source_file_id, batch_id = _create_batch_records(
connection, sha256, original_filename, len(content), stored_path, company_id
)
except sqlite3.IntegrityError:
# Lost a concurrent-insert race on the sha256 UNIQUE constraint: the
# identical content was already persisted by another request, so this
# upload is a duplicate. The content-addressed file already exists.
existing = connection.execute(
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
).fetchone()
if existing is not None:
return _record_duplicate(connection, existing["id"], sha256, company_id)
raise
return _parse_and_persist(
connection, stored_path, sha256, original_filename, source_file_id, batch_id
)
def import_statement_path(
connection: sqlite3.Connection,
storage_dir: str | Path,
original_filename: str,
upload_path: str | Path,
company_id: int | None = None,
) -> ImportResult:
"""Import from an already-downloaded upload file (streaming-friendly).
``upload_path`` names the temp file the multipart handler streamed to
disk; it is hashed incrementally and either discarded (duplicate) or
published into the content-addressed store. The upload file itself is
never modified.
"""
source = Path(upload_path)
sha256 = _file_sha256(source)
existing_file = connection.execute(
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
).fetchone()
if existing_file is not None:
source.unlink(missing_ok=True)
return _record_duplicate(connection, existing_file["id"], sha256, company_id)
stored_path = _publish_immutable(Path(storage_dir), original_filename, source, sha256)
size = stored_path.stat().st_size
try:
source_file_id, batch_id = _create_batch_records(
connection, sha256, original_filename, size, stored_path, company_id
)
except sqlite3.IntegrityError:
# Lost a concurrent-insert race on the sha256 UNIQUE constraint: the
# identical content was already persisted by another request, so this
# upload is a duplicate. The content-addressed file already exists.
existing = connection.execute(
"SELECT id FROM source_files WHERE sha256 = ?", (sha256,)
).fetchone()
if existing is not None:
return _record_duplicate(connection, existing["id"], sha256, company_id)
raise
return _parse_and_persist(
connection, stored_path, sha256, original_filename, source_file_id, batch_id
)
def _create_batch_records(
connection: sqlite3.Connection,
sha256: str,
original_filename: str,
size_bytes: int,
stored_path: Path,
company_id: int | None,
) -> tuple[int, int]:
now = utc_now()
with connection:
cursor = connection.execute(
@@ -55,7 +141,7 @@ def import_statement(
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(sha256, original_filename, len(content), str(stored_path), now),
(sha256, original_filename, size_bytes, str(stored_path), now),
)
source_file_id = cursor.lastrowid
cursor = connection.execute(
@@ -66,27 +152,64 @@ def import_statement(
(source_file_id, company_id, now, now),
)
batch_id = cursor.lastrowid
return source_file_id, batch_id
def _parse_and_persist(
connection: sqlite3.Connection,
stored_path: Path,
sha256: str,
original_filename: str,
source_file_id: int,
batch_id: int,
) -> ImportResult:
try:
batches = parse_statement(stored_path)
except StatementParseError as exc:
sheets = analyze_workbook(stored_path)
except CorruptWorkbookError as exc:
message = _clean_message(str(exc), stored_path, original_filename)
with connection:
_insert_exception(connection, batch_id, "parse", message, original_filename)
_insert_exception(
connection, batch_id, "parse", message, original_filename,
diagnostics={"original_filename": original_filename},
)
_set_batch_status(connection, batch_id, "exception")
return ImportResult(batch_id, "exception", sha256, source_file_id, message=message)
except Exception as exc:
message = f"文件解析失败,请检查文件是否完整。({type(exc).__name__}"
with connection:
_insert_exception(connection, batch_id, "internal", message, original_filename)
_insert_exception(
connection, batch_id, "internal", message, original_filename,
diagnostics={"original_filename": original_filename},
)
_set_batch_status(connection, batch_id, "failed")
raise
with connection:
for batch in batches:
_insert_sheet_batch(connection, batch_id, batch)
parsed_batches: list[StatementBatch] = []
for sheet in sheets:
if sheet.outcome == "parsed" and sheet.batch is not None:
sheet_batch_id = _insert_sheet_batch(connection, batch_id, sheet.batch)
_insert_sheet_review(connection, batch_id, sheet, sheet_batch_id)
parsed_batches.append(sheet.batch)
else:
_insert_sheet_review(connection, batch_id, sheet, None)
if not parsed_batches:
message = _whole_file_message(original_filename, sheets)
with connection:
_insert_exception(
connection, batch_id, "parse", message, original_filename,
diagnostics=_exception_diagnostics(original_filename, sheets),
)
_set_batch_status(connection, batch_id, "exception")
return ImportResult(
batch_id, "exception", sha256, source_file_id,
message=message, sheets=sheets,
)
_set_batch_status(connection, batch_id, "parsed")
return ImportResult(batch_id, "parsed", sha256, source_file_id, batches=batches)
return ImportResult(
batch_id, "parsed", sha256, source_file_id,
batches=tuple(parsed_batches), sheets=sheets,
)
def _record_duplicate(
@@ -169,9 +292,39 @@ def _store_immutable(
return target
def _publish_immutable(
storage_dir: Path, original_filename: str, upload_path: Path, sha256: str
) -> Path:
suffix = Path(original_filename).suffix.lower()
target_dir = storage_dir / sha256[:2]
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / f"{sha256}{suffix}"
temp = target_dir / f".{sha256}.tmp"
try:
# Copy keeps the same-filesystem guarantee even if the upload temp
# lives elsewhere; the hard link then publishes atomically.
shutil.copyfile(upload_path, temp)
try:
os.link(temp, target)
except FileExistsError:
pass
finally:
temp.unlink(missing_ok=True)
upload_path.unlink(missing_ok=True)
return target
def _file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _insert_sheet_batch(
connection: sqlite3.Connection, batch_id: int, batch: StatementBatch
) -> None:
) -> int:
now = utc_now()
cursor = connection.execute(
"""
@@ -226,6 +379,45 @@ def _insert_sheet_batch(
now,
),
)
return sheet_batch_id
def _insert_sheet_review(
connection: sqlite3.Connection,
batch_id: int,
sheet: SheetResult,
sheet_batch_id: int | None,
) -> None:
# Parse-side ignored sheets are terminal: there is nothing to confirm, so
# their review lifecycle is closed with a machine reason.
if sheet.outcome == "ignored":
review_status = "ignored"
review_reason = "空表或无有效内容,自动忽略。"
else:
review_status = "pending"
review_reason = None
connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, message, scanned_rows,
candidate_headers, sheet_batch_id, review_status, review_reason, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
batch_id,
sheet.sheet_name,
sheet.outcome,
sheet.message,
sheet.scanned_rows,
json.dumps(list(sheet.candidate_headers), ensure_ascii=False)
if sheet.candidate_headers
else None,
sheet_batch_id,
review_status,
review_reason,
utc_now(),
),
)
def _insert_exception(
@@ -234,7 +426,12 @@ def _insert_exception(
stage: str,
message: str,
original_filename: str,
*,
diagnostics: dict[str, object] | None = None,
) -> None:
payload = {"original_filename": original_filename}
if diagnostics:
payload.update(diagnostics)
connection.execute(
"""
INSERT INTO import_exceptions (import_batch_id, stage, message, diagnostics, created_at)
@@ -244,7 +441,7 @@ def _insert_exception(
batch_id,
stage,
message,
json.dumps({"original_filename": original_filename}, ensure_ascii=False),
json.dumps(payload, ensure_ascii=False),
utc_now(),
),
)
@@ -257,7 +454,154 @@ def _set_batch_status(connection: sqlite3.Connection, batch_id: int, status: str
)
def _whole_file_message(original_filename: str, sheets: tuple[SheetResult, ...]) -> str:
detail = "".join(
f"{sheet.sheet_name}{sheet.message}" for sheet in sheets if sheet.message
)
return f"文件「{original_filename}」没有可确认的工作表。{detail}"
def _exception_diagnostics(
original_filename: str, sheets: tuple[SheetResult, ...]
) -> dict[str, object]:
return {
"original_filename": original_filename,
"sheets": [
{
"sheet_name": sheet.sheet_name,
"outcome": sheet.outcome,
"message": sheet.message,
"scanned_rows": sheet.scanned_rows,
"candidate_headers": list(sheet.candidate_headers),
}
for sheet in sheets
],
}
def _clean_message(message: str, stored_path: Path, original_filename: str) -> str:
return message.replace(str(stored_path), original_filename).replace(
stored_path.name, original_filename
)
# ---------------------------------------------------------------------------
# Per-sheet review lifecycle
# ---------------------------------------------------------------------------
def sheet_review_rows(connection: sqlite3.Connection, batch_id: int) -> list[sqlite3.Row]:
return connection.execute(
"""
SELECT r.id, r.sheet_name, r.outcome, r.message, r.scanned_rows,
r.candidate_headers, r.review_status, r.review_reason,
r.reviewed_by, r.reviewed_at,
s.bank_name, s.template_id, s.header_row, s.own_account, s.own_name,
s.period_start, s.period_end, s.transaction_count, s.warnings
FROM sheet_reviews r
LEFT JOIN sheet_batches s ON s.id = r.sheet_batch_id
WHERE r.import_batch_id = ?
ORDER BY r.id
""",
(batch_id,),
).fetchall()
def scoped_batch(
connection: sqlite3.Connection, batch_id: int, company_id: int | None
) -> sqlite3.Row | None:
"""Load a batch, scoping company users to their own tenant.
``company_id is None`` (admin) sees any batch; a company user only sees
batches owned by that company. Returns None when out of scope so callers
can answer 404 without leaking the batch's existence.
"""
if company_id is None:
return connection.execute(
"SELECT id, company_id FROM import_batches WHERE id = ?", (batch_id,)
).fetchone()
return connection.execute(
"SELECT id, company_id FROM import_batches WHERE id = ? AND company_id = ?",
(batch_id, company_id),
).fetchone()
def review_sheets(
connection: sqlite3.Connection,
batch_id: int,
sheet_names: list[str],
decision: str,
actor: sqlite3.Row | None,
company_id: int | None,
reason: str | None = None,
) -> dict[str, object]:
"""Apply ``confirm`` or ``ignore`` to whole worksheets atomically.
Multi-sheet writes run in one transaction; a failure rolls back every
sheet in the request. Idempotent repeats of the same decision succeed
without touching the audit log; changing a settled decision is a
conflict. Returns ``{"updated": [...], "already": [...]}``.
"""
if decision not in ("confirm", "ignore"):
raise ValueError("decision 必须是 confirm 或 ignore。")
names = [str(name).strip() for name in sheet_names]
if not names:
raise ValueError("必须至少指定一个工作表。")
reason = (reason or "").strip() or None
if decision == "ignore" and not reason:
raise ValueError("忽略工作表必须填写原因。")
batch = scoped_batch(connection, batch_id, company_id)
if batch is None:
raise LookupError("批次不存在。")
placeholders = ",".join("?" for _ in names)
rows = connection.execute(
f"""
SELECT * FROM sheet_reviews
WHERE import_batch_id = ? AND sheet_name IN ({placeholders})
""",
(batch_id, *names),
).fetchall()
found = {row["sheet_name"] for row in rows}
missing = [name for name in names if name not in found]
if missing:
raise ValueError(f"工作表不存在或不属于该批次:{''.join(missing)}")
target = "confirmed" if decision == "confirm" else "ignored"
now = utc_now()
updated: list[str] = []
already: list[str] = []
with connection:
for row in rows:
if row["review_status"] == target:
already.append(row["sheet_name"])
continue
if decision == "confirm" and row["outcome"] != "parsed":
raise SheetReviewError(
f"工作表「{row['sheet_name']}」没有可确认的交易,只能忽略或保留待处理。"
)
if row["review_status"] != "pending":
raise SheetReviewError(
f"工作表「{row['sheet_name']}」已处理,不能更改决定。"
)
cursor = connection.execute(
"""
UPDATE sheet_reviews
SET review_status = ?, review_reason = ?, reviewed_by = ?, reviewed_at = ?
WHERE id = ? AND review_status = 'pending'
""",
(target, reason, actor["id"] if actor is not None else None, now, row["id"]),
)
if cursor.rowcount:
updated.append(row["sheet_name"])
if updated:
auth.audit(
connection,
f"sheet_{decision}",
actor=actor,
target=f"batch:{batch_id}",
detail=f"sheets:{','.join(updated)}" + (f";reason:{reason}" if reason else ""),
)
return {"updated": updated, "already": already}
+20
View File
@@ -40,3 +40,23 @@ class StatementBatch:
transactions: tuple[NormalizedTransaction, ...]
warnings: tuple[str, ...]
template_version: int = 1
@dataclass(frozen=True)
class SheetResult:
"""Per-worksheet parse outcome with the four diagnostic evidence items.
``outcome`` is the deterministic parse verdict: ``parsed`` (transactions
normalized), ``exception`` (template/header/balance problems) or
``ignored`` (empty or single-cell sheets with no bank content). The human
decision about whether a parsed sheet may participate in calculations is
a separate lifecycle stage (``review_status``), persisted in
``sheet_reviews``; it is never decided here.
"""
sheet_name: str
outcome: str # parsed | exception | ignored
message: str | None = None
scanned_rows: int | None = None
candidate_headers: tuple[str, ...] = ()
batch: StatementBatch | None = None
+275
View File
@@ -0,0 +1,275 @@
"""Streaming multipart/form-data parser for the stdlib HTTP server.
The upload body is consumed in chunks from the request stream and never
assembled into memory as a whole. File parts are streamed straight to a temp
file on disk with an incremental size cap; text fields are capped at a small
field size. File bytes are preserved exactly (no trailing-byte trimming), the
first file part is the only one accepted, and both the extension and the file
signature are validated by the caller.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import re
from typing import BinaryIO
MAX_FIELD_BYTES = 16 * 1024
MAX_HEADER_BYTES = 32 * 1024
CHUNK_SIZE = 64 * 1024
# .xlsx files are ZIP containers (PK\x03\x04 local header or an empty-archive
# PK\x05\x06); .xls files are OLE2 compound documents.
XLSX_MAGIC = (b"PK\x03\x04", b"PK\x05\x06")
XLS_MAGIC = (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",)
class MultipartError(ValueError):
pass
@dataclass(frozen=True)
class UploadedFile:
filename: str
path: Path
size: int
def valid_file_signature(path: Path, suffix: str) -> bool:
"""True when the file's leading bytes match its declared extension."""
with path.open("rb") as handle:
head = handle.read(8)
if suffix == ".xlsx":
return any(head.startswith(magic) for magic in XLSX_MAGIC)
if suffix == ".xls":
return any(head.startswith(magic) for magic in XLS_MAGIC)
return False
class _MultipartStream:
def __init__(
self,
rfile: BinaryIO,
content_length: int,
content_type: str,
*,
max_body_bytes: int,
) -> None:
lowered = content_type.lower()
if not lowered.startswith("multipart/form-data"):
raise MultipartError("上传请求必须是 multipart/form-data。")
match = re.search(r"boundary=(?:\"([^\"]+)\"|([^;]+))", content_type)
if not match:
raise MultipartError("上传请求缺少文件边界。")
boundary = (match.group(1) or match.group(2)).strip().encode("utf-8")
if not boundary or len(boundary) > 200:
raise MultipartError("上传请求的文件边界无效。")
if content_length <= 0:
raise MultipartError("上传请求为空。")
if content_length > max_body_bytes:
raise MultipartError(f"上传请求超过 {max_body_bytes // (1024 * 1024)} MB 限制。")
self.rfile = rfile
self.remaining = content_length
self.total_read = 0
self.buffer = bytearray()
self.boundary = boundary
self._fill()
# ------------------------------------------------------------------
# Low-level stream helpers
# ------------------------------------------------------------------
def _fill(self) -> None:
if self.total_read >= self.remaining:
return
want = min(CHUNK_SIZE, self.remaining - self.total_read)
data = self.rfile.read(want)
if not data:
# Client closed early; clamp remaining so every helper sees EOF.
self.remaining = self.total_read
return
self.total_read += len(data)
self.buffer.extend(data)
def _take(self, count: int) -> bytes:
while len(self.buffer) < count:
if self.total_read >= self.remaining:
raise MultipartError("multipart 请求体不完整。")
self._fill()
out = bytes(self.buffer[:count])
del self.buffer[:count]
return out
def _read_line(self) -> bytes:
while True:
index = self.buffer.find(b"\n")
if index >= 0:
line = bytes(self.buffer[: index + 1])
del self.buffer[: index + 1]
return line
if self.total_read >= self.remaining:
if not self.buffer:
raise MultipartError("multipart 请求体不完整。")
line = bytes(self.buffer)
self.buffer.clear()
return line
self._fill()
def _skip_preamble(self) -> None:
marker = b"--" + self.boundary
while True:
index = self.buffer.find(marker)
if index >= 0:
del self.buffer[: index + len(marker)]
return
keep = len(marker) - 1
if len(self.buffer) > keep:
del self.buffer[: len(self.buffer) - keep]
if self.total_read >= self.remaining:
raise MultipartError("上传请求中没有找到文件。")
self._fill()
def _iter_content(self, boundary: bytes):
"""Yield content bytes up to (excluding) the ``\\r\\n--boundary`` marker."""
marker = b"\r\n--" + boundary
keep = len(marker) - 1
while True:
index = self.buffer.find(marker)
if index >= 0:
content = bytes(self.buffer[:index])
del self.buffer[:index]
if content:
yield content
return
if len(self.buffer) > keep:
safe = len(self.buffer) - keep
content = bytes(self.buffer[:safe])
del self.buffer[:safe]
if content:
yield content
if self.total_read >= self.remaining:
raise MultipartError("multipart 请求体缺少结束边界。")
self._fill()
def _after_content_boundary(self) -> str:
"""Consume the content-ending marker; return 'part' or 'end'."""
marker = b"\r\n--" + self.boundary
del self.buffer[: len(marker)]
indicator = self._take(2)
if indicator == b"--":
if self.buffer.startswith(b"\r\n"):
del self.buffer[:2]
elif self.buffer.startswith(b"\n"):
del self.buffer[:1]
return "end"
if indicator != b"\r\n":
raise MultipartError("上传请求格式无效。")
return "part"
# ------------------------------------------------------------------
# Part parsing
# ------------------------------------------------------------------
def _read_headers(self) -> bytes:
total = 0
lines: list[bytes] = []
while True:
line = self._read_line()
total += len(line)
if total > MAX_HEADER_BYTES:
raise MultipartError("multipart 头部过长。")
if line in (b"\r\n", b"\n"):
return b"".join(lines)
lines.append(line)
@staticmethod
def _parse_disposition(header: bytes) -> tuple[str | None, str | None]:
name = None
filename = None
name_match = re.search(br'name="([^"]*)"', header)
if name_match:
name = name_match.group(1).decode("utf-8", errors="replace")
filename_match = re.search(br'filename="([^"]*)"', header)
if filename_match:
filename = filename_match.group(1).decode("utf-8", errors="replace")
return name, filename
def parse(self, work_dir: Path) -> tuple[dict[str, str], UploadedFile | None]:
import tempfile
self._skip_preamble()
next_bytes = self._take(2)
if next_bytes == b"--":
# Empty multipart body: opening boundary immediately closes.
return {}, None
if next_bytes != b"\r\n":
raise MultipartError("上传请求格式无效。")
fields: dict[str, str] = {}
uploaded: UploadedFile | None = None
while True:
header = self._read_headers()
name, filename = self._parse_disposition(header)
if name is None:
raise MultipartError("上传请求缺少字段名。")
if filename is not None:
if uploaded is not None:
raise MultipartError("一次只能上传一个文件。")
with tempfile.NamedTemporaryFile(
dir=str(work_dir), prefix="upload-", delete=False
) as sink:
temp_path = Path(sink.name)
size = 0
try:
for chunk in self._iter_content(self.boundary):
size += len(chunk)
sink.write(chunk)
except Exception:
temp_path.unlink(missing_ok=True)
raise
uploaded = UploadedFile(
filename=filename, path=temp_path, size=size
)
if self._after_content_boundary() == "end":
break
else:
chunks: list[bytes] = []
total = 0
for chunk in self._iter_content(self.boundary):
total += len(chunk)
if total > MAX_FIELD_BYTES:
raise MultipartError("表单字段超过大小限制。")
chunks.append(chunk)
fields[name] = b"".join(chunks).decode(
"utf-8", errors="replace"
).strip()
if self._after_content_boundary() == "end":
break
return fields, uploaded
def parse_upload(
rfile: BinaryIO,
content_length: int,
content_type: str,
work_dir: str | Path,
*,
max_file_bytes: int,
) -> tuple[dict[str, str], UploadedFile | None]:
"""Stream a multipart upload; returns ``(fields, uploaded_file)``.
Raises :class:`MultipartError` for malformed or oversized bodies. The
caller owns the uploaded temp file and must remove it when done.
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
stream = _MultipartStream(
rfile, content_length, content_type, max_body_bytes=max_file_bytes + MAX_FIELD_BYTES
)
fields, uploaded = stream.parse(work)
if uploaded is None:
raise MultipartError("上传请求中没有找到文件。")
if uploaded.size > max_file_bytes:
raise MultipartError(f"文件超过 {max_file_bytes // (1024 * 1024)} MB 限制。")
return fields, uploaded
+78 -22
View File
@@ -7,7 +7,7 @@ from pathlib import Path
import re
from typing import Any, Iterable
from .models import NormalizedTransaction, StatementBatch
from .models import NormalizedTransaction, SheetResult, StatementBatch
from .reader import RawSheet, read_workbook
from .templates import BankTemplate, TEMPLATES, normalize_header
@@ -24,8 +24,11 @@ class AmbiguousTemplateError(StatementParseError):
pass
SCAN_LIMIT = 50
def detect_header(
rows: tuple[tuple[Any, ...], ...], scan_limit: int = 50
rows: tuple[tuple[Any, ...], ...], scan_limit: int = SCAN_LIMIT
) -> tuple[BankTemplate, int, dict[str, int]]:
candidates: list[tuple[int, BankTemplate, int, dict[str, int]]] = []
for row_index, row in enumerate(rows[:scan_limit]):
@@ -42,8 +45,8 @@ def detect_header(
if not candidates:
inspected = min(len(rows), scan_limit)
candidate_rows = _header_candidate_summary(rows[:scan_limit])
detail = f";候选表头:{candidate_rows}" if candidate_rows else ""
candidate_rows = _header_candidate_rows(rows[:scan_limit])
detail = f";候选表头:{''.join(candidate_rows)}" if candidate_rows else ""
raise UnknownTemplateError(
f"未识别到受支持的银行表头(已扫描前 {inspected} 行){detail}"
)
@@ -60,21 +63,69 @@ def detect_header(
def parse_statement(path: str | Path) -> tuple[StatementBatch, ...]:
source = Path(path)
batches: list[StatementBatch] = []
errors: list[str] = []
for sheet in read_workbook(source):
if not any(any(_text(value) for value in row) for row in sheet.rows):
continue
try:
batches.append(_parse_sheet(source, sheet))
except UnknownTemplateError as exc:
errors.append(f"{sheet.name}: {exc}")
batches = tuple(result.batch for result in analyze_workbook(path) if result.batch)
if not batches:
detail = "; ".join(errors) or "Workbook contains no readable worksheets."
raise UnknownTemplateError(f"{source.name}: {detail}")
return tuple(batches)
raise UnknownTemplateError(f"{Path(path).name}: 工作簿中没有可解析的工作表。")
return batches
def analyze_workbook(path: str | Path) -> tuple[SheetResult, ...]:
"""Parse every worksheet into an independent result.
Each worksheet yields exactly one :class:`SheetResult` whose ``outcome``
is ``parsed``, ``exception`` or ``ignored``. Unreadable workbooks raise
``CorruptWorkbookError``; a workbook that reads but contains no parsable
sheet still returns one result per sheet so the UI can surface the
filename / sheet / scanned range / candidate headers evidence.
"""
source = Path(path)
return tuple(_analyze_sheet(source, sheet) for sheet in read_workbook(source))
def _analyze_sheet(source: Path, sheet: RawSheet) -> SheetResult:
rows = sheet.rows
scanned = min(len(rows), SCAN_LIMIT)
if not rows:
return SheetResult(
sheet_name=sheet.name,
outcome="ignored",
message=f"工作表「{sheet.name}」为空,已跳过。",
scanned_rows=0,
)
if not any(any(_text(value) for value in row) for row in rows):
return SheetResult(
sheet_name=sheet.name,
outcome="ignored",
message=f"工作表「{sheet.name}」无有效内容,已跳过。",
scanned_rows=scanned,
)
try:
batch = _parse_sheet(source, sheet)
except UnknownTemplateError as exc:
return SheetResult(
sheet_name=sheet.name,
outcome="exception",
message=_clean_sheet_message(str(exc), source),
scanned_rows=scanned,
candidate_headers=_header_candidate_rows(rows),
)
except (AmbiguousTemplateError, StatementParseError) as exc:
return SheetResult(
sheet_name=sheet.name,
outcome="exception",
message=_clean_sheet_message(str(exc), source),
scanned_rows=scanned,
)
return SheetResult(
sheet_name=sheet.name,
outcome="parsed",
scanned_rows=scanned,
batch=batch,
)
def _clean_sheet_message(message: str, source: Path) -> str:
return message.replace(str(source), source.name).replace(source.name, "本文件")
def parse_directory(path: str | Path) -> tuple[StatementBatch, ...]:
@@ -90,9 +141,14 @@ def parse_directory(path: str | Path) -> tuple[StatementBatch, ...]:
return tuple(batch for file in files for batch in parse_statement(file))
def _header_candidate_summary(
def _header_candidate_rows(
rows: tuple[tuple[Any, ...], ...], limit: int = 3
) -> str:
) -> tuple[str, ...]:
"""Preview up to ``limit`` plausible header rows.
Every non-empty row counts as a candidate, including single-cell rows, so
an ambiguous workbook always has explicit scan evidence for the UI.
"""
known_headers = {
alias
for template in TEMPLATES
@@ -102,7 +158,7 @@ def _header_candidate_summary(
candidates: list[tuple[int, int, int, tuple[str, ...]]] = []
for row_index, row in enumerate(rows):
values = tuple(_text(value) for value in row if _text(value))
if len(values) < 2:
if not values:
continue
matched = sum(normalize_header(value) in known_headers for value in values)
candidates.append((matched, len(values), row_index, values))
@@ -114,7 +170,7 @@ def _header_candidate_summary(
if len(values) > 8:
preview += "……"
summaries.append(f"{row_index + 1} 行「{preview}")
return "".join(summaries)
return tuple(summaries)
def _parse_sheet(source: Path, sheet: RawSheet) -> StatementBatch:
+59 -13
View File
@@ -10,6 +10,23 @@ class UnsupportedWorkbookError(RuntimeError):
pass
class CorruptWorkbookError(RuntimeError):
"""The file cannot be opened as a valid Excel workbook.
Raised for corrupt/truncated files, wrong signatures and files that
exceed the resource limits. The message is user-safe: it never contains
server-side paths or the content-addressed storage filename.
"""
# Workbook resource guards (defense against decompression bombs and
# accidentally giant exports). Bank statements are small; these bounds are
# generous enough for real exports while keeping memory bounded.
MAX_SHEETS = 50
MAX_ROWS_PER_SHEET = 200_000
MAX_COLS_PER_SHEET = 64
@dataclass(frozen=True)
class RawSheet:
name: str
@@ -18,11 +35,18 @@ class RawSheet:
def read_workbook(path: Path) -> tuple[RawSheet, ...]:
suffix = path.suffix.lower()
if suffix == ".xlsx":
return _read_xlsx(path)
if suffix == ".xls":
return _read_xls(path)
raise UnsupportedWorkbookError(f"Unsupported workbook type: {suffix}")
try:
if suffix == ".xlsx":
return _read_xlsx(path)
if suffix == ".xls":
return _read_xls(path)
raise UnsupportedWorkbookError(f"Unsupported workbook type: {suffix}")
except (UnsupportedWorkbookError, CorruptWorkbookError):
raise
except Exception as exc:
raise CorruptWorkbookError(
"文件无法读取,可能已损坏或不是有效的 Excel 文件。"
) from exc
def _read_xlsx(path: Path) -> tuple[RawSheet, ...]:
@@ -35,13 +59,24 @@ def _read_xlsx(path: Path) -> tuple[RawSheet, ...]:
workbook = load_workbook(path, read_only=True, data_only=True)
try:
return tuple(
RawSheet(
name=worksheet.title,
rows=tuple(tuple(row) for row in worksheet.iter_rows(values_only=True)),
)
for worksheet in workbook.worksheets
)
sheets: list[RawSheet] = []
for worksheet in workbook.worksheets:
rows: list[tuple[Any, ...]] = []
for row_index, row in enumerate(worksheet.iter_rows(values_only=True)):
if row_index >= MAX_ROWS_PER_SHEET:
raise CorruptWorkbookError(
f"工作表「{worksheet.title}」超过 {MAX_ROWS_PER_SHEET} 行,已拒绝读取。"
)
values = tuple(row)
if len(values) > MAX_COLS_PER_SHEET:
raise CorruptWorkbookError(
f"工作表「{worksheet.title}」列数超过 {MAX_COLS_PER_SHEET},已拒绝读取。"
)
rows.append(values)
sheets.append(RawSheet(name=worksheet.title, rows=tuple(rows)))
if len(sheets) > MAX_SHEETS:
raise CorruptWorkbookError(f"工作簿工作表数量超过 {MAX_SHEETS} 个,已拒绝读取。")
return tuple(sheets)
finally:
workbook.close()
@@ -57,8 +92,19 @@ def _read_xls(path: Path) -> tuple[RawSheet, ...]:
workbook = xlrd.open_workbook(path, on_demand=True)
sheets: list[RawSheet] = []
try:
for sheet_name in workbook.sheet_names():
names = workbook.sheet_names()
if len(names) > MAX_SHEETS:
raise CorruptWorkbookError(f"工作簿工作表数量超过 {MAX_SHEETS} 个,已拒绝读取。")
for sheet_name in names:
worksheet = workbook.sheet_by_name(sheet_name)
if worksheet.nrows > MAX_ROWS_PER_SHEET:
raise CorruptWorkbookError(
f"工作表「{sheet_name}」超过 {MAX_ROWS_PER_SHEET} 行,已拒绝读取。"
)
if worksheet.ncols > MAX_COLS_PER_SHEET:
raise CorruptWorkbookError(
f"工作表「{sheet_name}」列数超过 {MAX_COLS_PER_SHEET},已拒绝读取。"
)
rows: list[tuple[Any, ...]] = []
for row_index in range(worksheet.nrows):
values: list[Any] = []