B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据
This commit is contained in:
+50
-1
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user