B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据
This commit is contained in:
@@ -10,9 +10,8 @@ from http.cookies import SimpleCookie
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from bank_importer import auth, master_data
|
||||
from bank_importer import auth, importing, master_data, multipart
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
from bank_importer.importing import import_statement
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
@@ -49,6 +48,10 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if rows_match:
|
||||
self._handle_batch_rows(int(rows_match.group(1)))
|
||||
return
|
||||
sheets_match = re.fullmatch(r"/api/batches/(\d+)/sheets", path)
|
||||
if sheets_match:
|
||||
self._handle_batch_sheets(int(sheets_match.group(1)))
|
||||
return
|
||||
if path == "/api/export.csv":
|
||||
self._handle_export_csv(query)
|
||||
return
|
||||
@@ -119,6 +122,14 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if alias_create:
|
||||
self._handle_admin_add_alias(int(alias_create.group(1)))
|
||||
return
|
||||
confirm_match = re.fullmatch(r"/api/batches/(\d+)/confirm", path)
|
||||
if confirm_match:
|
||||
self._handle_batch_review(int(confirm_match.group(1)), "confirm")
|
||||
return
|
||||
ignore_match = re.fullmatch(r"/api/batches/(\d+)/ignore", path)
|
||||
if ignore_match:
|
||||
self._handle_batch_review(int(ignore_match.group(1)), "ignore")
|
||||
return
|
||||
self._send_json(404, {"status": "error", "message": "接口不存在。"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -308,15 +319,21 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
def _handle_parse(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
upload = None
|
||||
try:
|
||||
user = self._require_user(connection)
|
||||
if user is None:
|
||||
return
|
||||
try:
|
||||
filename, content, fields = self._read_upload()
|
||||
fields, upload = self._receive_upload()
|
||||
filename = os.path.basename(upload.filename)
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in {".xls", ".xlsx"}:
|
||||
raise ValueError("仅支持 .xls 或 .xlsx 银行流水文件。")
|
||||
if not multipart.valid_file_signature(upload.path, suffix):
|
||||
raise ValueError(
|
||||
"文件内容与扩展名不符,可能已损坏或不是有效的 Excel 文件。"
|
||||
)
|
||||
|
||||
if user["role"] == "company":
|
||||
# Tenant binding comes from the session only; any
|
||||
@@ -325,12 +342,15 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if not self._validate_upload_account(connection, user, fields):
|
||||
return
|
||||
else:
|
||||
company_id = self._parse_company_field(connection, fields.get("company_id"))
|
||||
company_id = self._parse_company_field(
|
||||
connection, fields.get("company_id")
|
||||
)
|
||||
if company_id is None:
|
||||
return
|
||||
|
||||
result = import_statement(
|
||||
connection, STORAGE_DIR, filename, content, company_id=company_id
|
||||
result = importing.import_statement_path(
|
||||
connection, STORAGE_DIR, filename, upload.path,
|
||||
company_id=company_id,
|
||||
)
|
||||
auth.audit(
|
||||
connection,
|
||||
@@ -356,8 +376,40 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
else:
|
||||
self._send_json(200, payload)
|
||||
finally:
|
||||
if upload is not None:
|
||||
upload.path.unlink(missing_ok=True)
|
||||
connection.close()
|
||||
|
||||
def _receive_upload(self) -> tuple[dict[str, str], multipart.UploadedFile]:
|
||||
"""Stream the multipart body into a validated temp file.
|
||||
|
||||
The whole request is never buffered in memory; file bytes are written
|
||||
to a temp file under the storage directory as they arrive and are
|
||||
never trimmed. Only the first file part is accepted.
|
||||
"""
|
||||
raw_length = self.headers.get("Content-Length")
|
||||
if raw_length is None:
|
||||
raise ValueError("上传请求缺少 Content-Length。")
|
||||
try:
|
||||
content_length = int(raw_length)
|
||||
except ValueError:
|
||||
raise ValueError("上传请求的 Content-Length 无效。")
|
||||
if content_length <= 0:
|
||||
raise ValueError("文件为空或超过 20 MB 限制。")
|
||||
if content_length > MAX_UPLOAD_BYTES + multipart.MAX_FIELD_BYTES:
|
||||
raise ValueError("文件超过 20 MB 限制。")
|
||||
try:
|
||||
fields, upload = multipart.parse_upload(
|
||||
self.rfile,
|
||||
content_length,
|
||||
self.headers.get("Content-Type", ""),
|
||||
STORAGE_DIR / ".uploads",
|
||||
max_file_bytes=MAX_UPLOAD_BYTES,
|
||||
)
|
||||
except multipart.MultipartError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
return fields, upload
|
||||
|
||||
def _validate_upload_account(self, connection, user, fields) -> bool:
|
||||
"""Validate the optional bank_account_id on a company upload.
|
||||
|
||||
@@ -435,15 +487,40 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
f"""
|
||||
SELECT b.id, b.status, b.created_at, b.company_id,
|
||||
f.original_filename, c.name AS company_name,
|
||||
COALESCE(SUM(s.transaction_count), 0) AS transactions,
|
||||
MIN(s.period_start) AS period_start,
|
||||
MAX(s.period_end) AS period_end
|
||||
COALESCE((
|
||||
SELECT SUM(s.transaction_count) FROM sheet_batches s
|
||||
WHERE s.import_batch_id = b.id
|
||||
), 0) AS transactions,
|
||||
(SELECT s.bank_name FROM sheet_batches s
|
||||
WHERE s.import_batch_id = b.id
|
||||
ORDER BY s.id LIMIT 1) AS bank_name,
|
||||
(SELECT MIN(s.period_start) FROM sheet_batches s
|
||||
WHERE s.import_batch_id = b.id) AS period_start,
|
||||
(SELECT MAX(s.period_end) FROM sheet_batches s
|
||||
WHERE s.import_batch_id = b.id) AS period_end,
|
||||
(SELECT COUNT(*) FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
|
||||
WHERE s.import_batch_id = b.id AND rv.review_status = 'confirmed')
|
||||
AS confirmed_transactions,
|
||||
(SELECT COUNT(*) FROM sheet_reviews r
|
||||
WHERE r.import_batch_id = b.id AND r.review_status = 'confirmed')
|
||||
AS confirmed_sheets,
|
||||
(SELECT COUNT(*) FROM sheet_reviews r
|
||||
WHERE r.import_batch_id = b.id
|
||||
AND r.outcome = 'parsed' AND r.review_status = 'pending')
|
||||
AS pending_sheets,
|
||||
(SELECT COUNT(*) FROM sheet_reviews r
|
||||
WHERE r.import_batch_id = b.id AND r.outcome = 'exception')
|
||||
AS exception_sheets,
|
||||
(SELECT COUNT(*) FROM sheet_reviews r
|
||||
WHERE r.import_batch_id = b.id
|
||||
AND (r.outcome = 'ignored' OR r.review_status = 'ignored'))
|
||||
AS ignored_sheets
|
||||
FROM import_batches b
|
||||
JOIN source_files f ON f.id = b.source_file_id
|
||||
LEFT JOIN companies c ON c.id = b.company_id
|
||||
LEFT JOIN sheet_batches s ON s.import_batch_id = b.id
|
||||
{where}
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id DESC
|
||||
LIMIT 500
|
||||
""",
|
||||
@@ -453,6 +530,89 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_batch_sheets(self, batch_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_user(connection)
|
||||
if user is None:
|
||||
return
|
||||
company_id = user["company_id"] if user["role"] == "company" else None
|
||||
batch = importing.scoped_batch(connection, batch_id, company_id)
|
||||
if batch is None:
|
||||
self._send_json(404, {"status": "error", "message": "批次不存在。"})
|
||||
return
|
||||
file_row = connection.execute(
|
||||
"""
|
||||
SELECT f.original_filename FROM source_files f
|
||||
JOIN import_batches b ON b.source_file_id = f.id
|
||||
WHERE b.id = ?
|
||||
""",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
rows = importing.sheet_review_rows(connection, batch_id)
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"batch_id": batch_id,
|
||||
"company_id": batch["company_id"],
|
||||
"original_filename": file_row["original_filename"]
|
||||
if file_row is not None
|
||||
else None,
|
||||
"sheets": [self._sheet_payload(row) for row in rows],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_batch_review(self, batch_id: int, decision: str) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_user(connection)
|
||||
if user is None:
|
||||
return
|
||||
data = self._read_json_body()
|
||||
if data is None:
|
||||
return
|
||||
sheets = data.get("sheets")
|
||||
if not isinstance(sheets, list):
|
||||
self._send_json(
|
||||
400, {"status": "error", "message": "sheets 必须是工作表名称数组。"}
|
||||
)
|
||||
return
|
||||
company_id = user["company_id"] if user["role"] == "company" else None
|
||||
try:
|
||||
outcome = importing.review_sheets(
|
||||
connection,
|
||||
batch_id,
|
||||
sheets,
|
||||
decision,
|
||||
user,
|
||||
company_id,
|
||||
reason=str(data.get("reason") or "") if decision == "ignore" else None,
|
||||
)
|
||||
except LookupError as exc:
|
||||
self._send_json(404, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except importing.SheetReviewError as exc:
|
||||
self._send_json(409, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
rows = importing.sheet_review_rows(connection, batch_id)
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"updated": outcome["updated"],
|
||||
"already": outcome["already"],
|
||||
"sheets": [self._sheet_payload(row) for row in rows],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_batch_rows(self, batch_id: int) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
@@ -474,9 +634,11 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
SELECT r.id, r.sheet_batch_id, r.source_row, r.transaction_at,
|
||||
r.income, r.expense, r.balance, r.own_account, r.own_name,
|
||||
r.counterparty_account, r.counterparty_name, r.counterparty_bank,
|
||||
r.summary, r.purpose, r.reference, r.currency
|
||||
r.summary, r.purpose, r.reference, r.currency,
|
||||
s.sheet_name, rv.review_status
|
||||
FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
|
||||
WHERE s.import_batch_id = ?
|
||||
ORDER BY s.id, r.source_row
|
||||
""",
|
||||
@@ -529,6 +691,10 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
FROM source_rows r
|
||||
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
||||
JOIN import_batches b ON b.id = s.import_batch_id
|
||||
-- Only worksheets confirmed by the cashier may leave the
|
||||
-- ledger: parse success is not business confirmation.
|
||||
JOIN sheet_reviews rv
|
||||
ON rv.sheet_batch_id = s.id AND rv.review_status = 'confirmed'
|
||||
{where}
|
||||
ORDER BY b.id, s.id, r.source_row
|
||||
""",
|
||||
@@ -1117,8 +1283,37 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
return None
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _sheet_payload(row) -> dict[str, object]:
|
||||
"""Serialize one persisted per-sheet review record."""
|
||||
return {
|
||||
"sheet_name": row["sheet_name"],
|
||||
"outcome": row["outcome"],
|
||||
"review_status": row["review_status"],
|
||||
"message": row["message"],
|
||||
"scanned_rows": row["scanned_rows"],
|
||||
"candidate_headers": json.loads(row["candidate_headers"])
|
||||
if row["candidate_headers"]
|
||||
else [],
|
||||
"review_reason": row["review_reason"],
|
||||
"bank": row["bank_name"],
|
||||
"template": row["template_id"],
|
||||
"header_row": row["header_row"],
|
||||
"period_start": row["period_start"],
|
||||
"period_end": row["period_end"],
|
||||
"transactions": row["transaction_count"] or 0,
|
||||
"warnings": json.loads(row["warnings"]) if row["warnings"] else [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _import_payload(connection, result) -> dict[str, object]:
|
||||
"""Build the parse response from persisted state, not from memory.
|
||||
|
||||
All worksheet results are returned (no more ``batches[0]``). A
|
||||
cross-company duplicate is opaque: only the generic status, the
|
||||
uploader's own batch id and the sha256 are returned, never the other
|
||||
company's batch id, summary or diagnostics.
|
||||
"""
|
||||
payload: dict[str, object] = {
|
||||
"status": result.status,
|
||||
"batch_id": result.batch_id,
|
||||
@@ -1126,96 +1321,19 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
}
|
||||
if result.message:
|
||||
payload["message"] = result.message
|
||||
if result.status == "duplicate" and not result.duplicate_same_company:
|
||||
return payload
|
||||
|
||||
if result.status == "parsed":
|
||||
batch = result.batches[0]
|
||||
payload.update(
|
||||
{
|
||||
"bank": batch.bank_name,
|
||||
"template": batch.template_id,
|
||||
"header_row": batch.header_row,
|
||||
"period_start": batch.period_start.isoformat()
|
||||
if batch.period_start
|
||||
else None,
|
||||
"period_end": batch.period_end.isoformat()
|
||||
if batch.period_end
|
||||
else None,
|
||||
"transactions": len(batch.transactions),
|
||||
"warnings": list(batch.warnings),
|
||||
}
|
||||
)
|
||||
elif result.status == "duplicate":
|
||||
# Same-company duplicates keep their idempotent summary so the
|
||||
# cashier sees the reused batch's bank/template/period/count.
|
||||
# Cross-company duplicates are opaque: only the generic duplicate
|
||||
# status and the uploader's own new batch id are returned, never
|
||||
# the other company's batch id, summary or diagnostics.
|
||||
if result.duplicate_same_company:
|
||||
sheet = connection.execute(
|
||||
"""
|
||||
SELECT bank_name, template_id, header_row, period_start,
|
||||
period_end, transaction_count, warnings
|
||||
FROM sheet_batches
|
||||
WHERE import_batch_id = ?
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(result.batch_id,),
|
||||
).fetchone()
|
||||
if sheet is not None:
|
||||
payload.update(
|
||||
{
|
||||
"bank": sheet["bank_name"],
|
||||
"template": sheet["template_id"],
|
||||
"header_row": sheet["header_row"],
|
||||
"period_start": sheet["period_start"],
|
||||
"period_end": sheet["period_end"],
|
||||
"transactions": sheet["transaction_count"],
|
||||
"warnings": json.loads(sheet["warnings"]),
|
||||
}
|
||||
)
|
||||
file_row = connection.execute(
|
||||
"SELECT original_filename FROM source_files WHERE id = ?",
|
||||
(result.source_file_id,),
|
||||
).fetchone()
|
||||
if file_row is not None:
|
||||
payload["original_filename"] = file_row["original_filename"]
|
||||
sheets = importing.sheet_review_rows(connection, result.batch_id)
|
||||
payload["sheets"] = [AppHandler._sheet_payload(row) for row in sheets]
|
||||
return payload
|
||||
|
||||
def _read_upload(self) -> tuple[str, bytes, dict[str, str]]:
|
||||
"""Parse the multipart body into (filename, content, text fields)."""
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
if content_length <= 0 or content_length > MAX_UPLOAD_BYTES:
|
||||
raise ValueError("文件为空或超过 20 MB 限制。")
|
||||
|
||||
content_type = self.headers.get("Content-Type", "")
|
||||
boundary_match = re.search(r"boundary=(?:\"([^\"]+)\"|([^;]+))", content_type)
|
||||
if not boundary_match:
|
||||
raise ValueError("上传请求缺少文件边界。")
|
||||
boundary = (boundary_match.group(1) or boundary_match.group(2)).encode()
|
||||
body = self.rfile.read(content_length)
|
||||
|
||||
fields: dict[str, str] = {}
|
||||
for part in body.split(b"--" + boundary):
|
||||
header, separator, content = part.partition(b"\r\n\r\n")
|
||||
if not separator:
|
||||
continue
|
||||
name_match = re.search(br'name="([^\"]+)"', header)
|
||||
if not name_match:
|
||||
continue
|
||||
name = name_match.group(1).decode("utf-8", errors="replace")
|
||||
if name == "file":
|
||||
filename_match = re.search(br'filename="([^\"]+)"', header)
|
||||
filename = (
|
||||
filename_match.group(1).decode("utf-8", errors="replace")
|
||||
if filename_match
|
||||
else "statement.xlsx"
|
||||
)
|
||||
file_content = content.rstrip(b"\r\n")
|
||||
elif b"filename=" not in header:
|
||||
fields[name] = content.rstrip(b"\r\n").decode("utf-8", errors="replace")
|
||||
try:
|
||||
return os.path.basename(filename), file_content, fields
|
||||
except UnboundLocalError:
|
||||
raise ValueError("上传请求中没有找到文件。") from None
|
||||
|
||||
def _read_uploaded_file(self) -> tuple[str, bytes]:
|
||||
filename, content, _fields = self._read_upload()
|
||||
return filename, content
|
||||
|
||||
def _send_json(
|
||||
self,
|
||||
status: int,
|
||||
|
||||
Reference in New Issue
Block a user