Files
caiwuzongzhang/server.py
T

1392 lines
56 KiB
Python

from __future__ import annotations
import csv
import io
import json
import os
from pathlib import Path
import re
from http.cookies import SimpleCookie
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from bank_importer import auth, importing, master_data, multipart
from bank_importer.db import connect, migrate, utc_now
ROOT = Path(__file__).resolve().parent
WEB_ROOT = ROOT / "web"
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
DB_PATH = Path(os.environ.get("APP_DB_PATH", ROOT / "data" / "app.db"))
STORAGE_DIR = Path(os.environ.get("APP_STORAGE_DIR", ROOT / "data" / "files"))
SESSION_COOKIE = "cw_session"
# Local plain-HTTP deployment: the cookie intentionally carries no Secure
# flag (see docs/decisions/003-auth.md).
COOKIE_FLAGS = "HttpOnly; SameSite=Lax; Path=/"
class AppHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(WEB_ROOT), **kwargs)
# ------------------------------------------------------------------
# Routing
# ------------------------------------------------------------------
def do_GET(self) -> None:
parsed = urlparse(self.path)
path = parsed.path
query = parse_qs(parsed.query)
if path == "/api/me":
self._handle_me()
return
if path == "/api/batches":
self._handle_batches(query)
return
rows_match = re.fullmatch(r"/api/batches/(\d+)/rows", path)
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
if path == "/api/admin/companies":
self._handle_admin_companies()
return
if path == "/api/admin/users":
self._handle_admin_users()
return
if path == "/api/admin/accounts":
self._handle_admin_accounts(query)
return
aliases_match = re.fullmatch(r"/api/admin/accounts/(\d+)/aliases", path)
if aliases_match:
self._handle_admin_account_aliases(int(aliases_match.group(1)))
return
if path == "/api/admin/master-changes":
self._handle_admin_master_changes(query)
return
if path == "/api/company/accounts":
self._handle_company_accounts()
return
if path == "/api/admin/audit-log":
self._handle_admin_audit_log(query)
return
if path == "/admin.html" and not self._guard_page("admin"):
return
if path == "/company.html" and not self._guard_page("company"):
return
super().do_GET()
def do_POST(self) -> None:
path = urlparse(self.path).path
if path == "/api/login":
self._handle_login()
return
if path == "/api/logout":
self._handle_logout()
return
if path == "/api/password/change":
self._handle_password_change()
return
if path == "/api/parse":
self._handle_parse()
return
if path == "/api/admin/companies":
self._handle_admin_create_company()
return
if path == "/api/admin/users":
self._handle_admin_create_user()
return
user_action = re.fullmatch(
r"/api/admin/users/(\d+)/(disable|enable|reset-password)", path
)
if user_action:
self._handle_admin_user_action(int(user_action.group(1)), user_action.group(2))
return
if path == "/api/company/accounts":
self._handle_company_submit_account()
return
account_review = re.fullmatch(r"/api/admin/accounts/(\d+)/review", path)
if account_review:
self._handle_admin_review_account(int(account_review.group(1)))
return
alias_create = re.fullmatch(r"/api/admin/accounts/(\d+)/aliases", path)
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": "接口不存在。"})
# ------------------------------------------------------------------
# Session helpers
# ------------------------------------------------------------------
def _session_token(self) -> str | None:
header = self.headers.get("Cookie")
if not header:
return None
cookie = SimpleCookie()
try:
cookie.load(header)
except Exception:
return None
morsel = cookie.get(SESSION_COOKIE)
return morsel.value if morsel is not None else None
def _current_user(self, connection):
token = self._session_token()
if not token:
return None
return auth.resolve_session(connection, token)
def _require_user(self, connection, *, pending_password_ok: bool = False):
user = self._current_user(connection)
if user is None:
self._send_json(401, {"status": "error", "message": "请先登录。"})
return None
if user["must_change_password"] and not pending_password_ok:
self._send_json(
403,
{"status": "error", "message": "首次登录须修改密码后才能继续操作。"},
)
return None
return user
def _require_admin(self, connection):
user = self._require_user(connection)
if user is None:
return None
if user["role"] != "admin":
self._send_json(403, {"status": "error", "message": "该操作仅限总账管理员。"})
return None
return user
def _guard_page(self, role: str) -> bool:
"""UX-layer static page gate; real enforcement is on the APIs."""
connection = connect(DB_PATH)
try:
user = self._current_user(connection)
finally:
connection.close()
if user is None or user["role"] != role:
self.send_response(302)
self.send_header("Location", "/")
self.send_header("Content-Length", "0")
self.end_headers()
return False
return True
def _set_session_cookie(self, token: str) -> None:
self.send_header("Set-Cookie", f"{SESSION_COOKIE}={token}; {COOKIE_FLAGS}")
def _clear_session_cookie(self) -> None:
self.send_header(
"Set-Cookie", f"{SESSION_COOKIE}=; {COOKIE_FLAGS}; Max-Age=0"
)
@property
def _client_ip(self) -> str:
return self.client_address[0]
# ------------------------------------------------------------------
# Auth endpoints
# ------------------------------------------------------------------
def _handle_login(self) -> None:
data = self._read_json_body()
if data is None:
return
username = str(data.get("username") or "").strip()
password = str(data.get("password") or "")
portal = str(data.get("portal") or "")
if not username or not password or portal not in {"admin", "company"}:
self._send_json(400, {"status": "error", "message": "请填写账号、密码并选择工作端口。"})
return
connection = connect(DB_PATH)
try:
user, reason = auth.authenticate(connection, username, password, self._client_ip)
if reason == "rate_limited":
self._send_json(
429,
{"status": "error", "message": "失败次数过多,请 10 分钟后再试。"},
)
return
if reason == "disabled":
self._send_json(
403, {"status": "error", "message": "账号已停用,请联系总账管理员。"}
)
return
if user is None:
self._send_json(
401, {"status": "error", "message": "账号或密码不正确。"}
)
return
if user["role"] != portal:
self._send_json(
403, {"status": "error", "message": "账号与该工作端口不匹配。"}
)
return
token = auth.create_session(connection, user["id"])
self._send_json(
200, self._user_payload(connection, user), session_token=token
)
finally:
connection.close()
def _handle_logout(self) -> None:
connection = connect(DB_PATH)
try:
user = self._current_user(connection)
token = self._session_token()
if token:
auth.revoke_session(connection, token)
if user is not None:
auth.audit(connection, "logout", actor=user, ip=self._client_ip)
self._send_json(200, {"status": "ok"}, clear_session=True)
finally:
connection.close()
def _handle_me(self) -> None:
connection = connect(DB_PATH)
try:
user = self._current_user(connection)
if user is None:
self._send_json(401, {"status": "error", "message": "请先登录。"})
return
self._send_json(200, self._user_payload(connection, user))
finally:
connection.close()
def _handle_password_change(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection, pending_password_ok=True)
if user is None:
return
data = self._read_json_body()
if data is None:
return
old_password = str(data.get("old_password") or "")
new_password = str(data.get("new_password") or "")
if not auth.verify_password(old_password, user["password_hash"]):
self._send_json(401, {"status": "error", "message": "原密码不正确。"})
return
error = auth.change_password(
connection, user["id"], old_password, new_password
)
if error is not None:
self._send_json(400, {"status": "error", "message": error})
return
self._send_json(200, {"status": "ok"})
finally:
connection.close()
def _user_payload(self, connection, user) -> dict[str, object]:
company_name = None
if user["company_id"] is not None:
company = connection.execute(
"SELECT name FROM companies WHERE id = ?", (user["company_id"],)
).fetchone()
company_name = company["name"] if company is not None else None
return {
"status": "ok",
"username": user["username"],
"role": user["role"],
"must_change_password": bool(user["must_change_password"]),
"company_id": user["company_id"],
"company_name": company_name,
}
# ------------------------------------------------------------------
# Statement import (existing behavior + tenant binding)
# ------------------------------------------------------------------
def _handle_parse(self) -> None:
connection = connect(DB_PATH)
upload = None
try:
user = self._require_user(connection)
if user is None:
return
try:
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
# company_id in the multipart body is ignored.
company_id = user["company_id"]
if not self._validate_upload_account(connection, user, fields):
return
else:
company_id = self._parse_company_field(
connection, fields.get("company_id")
)
if company_id is None:
return
result = importing.import_statement_path(
connection, STORAGE_DIR, filename, upload.path,
company_id=company_id,
)
auth.audit(
connection,
"import_upload",
actor=user,
target=f"batch:{result.batch_id}",
detail=f"{filename} -> {result.status}",
ip=self._client_ip,
)
payload = self._import_payload(connection, result)
except (ValueError,) as exc:
self._send_json(422, {"status": "exception", "message": str(exc)})
return
except Exception:
self._send_json(
500,
{"status": "error", "message": "文件解析失败,请检查文件是否完整。"},
)
return
if result.status == "exception":
self._send_json(422, payload)
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.
When supplied, the account must belong to the session company and be
enabled inside its effective interval — pending, returned or disabled
accounts never accept uploads.
"""
raw = fields.get("bank_account_id")
if not raw:
return True
try:
account_id = int(raw)
except ValueError:
self._send_json(400, {"status": "error", "message": "bank_account_id 参数无效。"})
return False
account = master_data.get_account(connection, account_id)
if account is None or account["company_id"] != user["company_id"]:
self._send_json(404, {"status": "error", "message": "账户不存在。"})
return False
if not master_data.is_usable(account, master_data.utc_today()):
self._send_json(
409,
{"status": "error", "message": "该账户未启用或已超出有效期,不能上传流水。"},
)
return False
return True
def _parse_company_field(self, connection, raw) -> int | None:
try:
company_id = int(str(raw))
except (TypeError, ValueError):
self._send_json(
400, {"status": "error", "message": "管理员上传必须指定有效的 company_id。"}
)
return None
company = connection.execute(
"SELECT id FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
self._send_json(
400, {"status": "error", "message": "指定的公司不存在。"}
)
return None
return company_id
# ------------------------------------------------------------------
# Batches, rows and export
# ------------------------------------------------------------------
def _handle_batches(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
conditions = []
params: list[object] = []
if user["role"] == "company":
conditions.append("b.company_id = ?")
params.append(user["company_id"])
else:
raw = (query.get("company_id") or [None])[0]
if raw:
try:
company_id = int(raw)
except ValueError:
self._send_json(
400, {"status": "error", "message": "company_id 参数无效。"}
)
return
conditions.append("b.company_id = ?")
params.append(company_id)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
rows = connection.execute(
f"""
SELECT b.id, b.status, b.created_at, b.company_id,
f.original_filename, c.name AS company_name,
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
{where}
ORDER BY b.id DESC
LIMIT 500
""",
params,
).fetchall()
self._send_json(200, {"status": "ok", "batches": [dict(row) for row in rows]})
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:
user = self._require_user(connection)
if user is None:
return
batch = connection.execute(
"SELECT id, company_id FROM import_batches WHERE id = ?", (batch_id,)
).fetchone()
# 404 (not 403) when the batch belongs to another company, so a
# company user cannot probe the existence of other tenants' data.
if batch is None or (
user["role"] == "company" and batch["company_id"] != user["company_id"]
):
self._send_json(404, {"status": "error", "message": "批次不存在。"})
return
rows = connection.execute(
"""
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,
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
""",
(batch_id,),
).fetchall()
self._send_json(200, {"status": "ok", "rows": [dict(row) for row in rows]})
finally:
connection.close()
def _handle_export_csv(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
raw = (query.get("company_id") or [None])[0]
params: list[object] = []
if user["role"] == "company":
if raw is not None and raw != str(user["company_id"]):
self._send_json(
403,
{"status": "error", "message": "只能导出本公司的数据。"},
)
return
where = "WHERE b.company_id = ?"
params.append(user["company_id"])
scope = f"company:{user['company_id']}"
elif raw is not None:
# Admin: company_id optional; absent means all companies.
try:
company_id = int(raw)
except ValueError:
self._send_json(
400, {"status": "error", "message": "company_id 参数无效。"}
)
return
where = "WHERE b.company_id = ?"
params.append(company_id)
scope = f"company:{company_id}"
else:
where = ""
scope = "all"
rows = connection.execute(
f"""
SELECT b.id AS batch_id, s.sheet_name, 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
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
""",
params,
).fetchall()
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(
[
"批次", "工作表", "源行号", "交易时间", "收入", "支出", "余额",
"本方账号", "本方户名", "对方账号", "对方户名", "对方开户行",
"摘要", "用途", "流水号", "币种",
]
)
for row in rows:
writer.writerow([row[key] for key in row.keys()])
# UTF-8 BOM so Excel opens the CSV with the right encoding.
content = (chr(0xFEFF) + buffer.getvalue()).encode("utf-8")
auth.audit(
connection,
"export_csv",
actor=user,
target=scope,
detail=f"rows:{len(rows)}",
ip=self._client_ip,
)
self.send_response(200)
self.send_header("Content-Type", "text/csv; charset=utf-8")
self.send_header("Content-Disposition", 'attachment; filename="export.csv"')
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(content)
finally:
connection.close()
# ------------------------------------------------------------------
# Admin endpoints
# ------------------------------------------------------------------
def _handle_admin_companies(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
rows = connection.execute(
"""
SELECT c.id, c.name, c.credit_code, c.cashier_name, c.status,
c.created_at, c.updated_at,
(SELECT COUNT(*) FROM bank_accounts a WHERE a.company_id = c.id)
AS account_count,
(SELECT GROUP_CONCAT(u.username, '、') FROM users u
WHERE u.company_id = c.id AND u.role = 'company')
AS usernames
FROM companies c
ORDER BY c.id
"""
).fetchall()
self._send_json(200, {"status": "ok", "companies": [dict(row) for row in rows]})
finally:
connection.close()
def _handle_admin_create_company(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
name = str(data.get("name") or "").strip()
username = str(data.get("username") or "").strip()
try:
company_id = master_data.create_company(
connection,
name,
str(data.get("credit_code") or ""),
str(data.get("cashier_name") or ""),
user,
)
except master_data.ConflictError 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
auth.audit(
connection,
"company_create",
actor=user,
target=f"company:{company_id}",
detail=name,
ip=self._client_ip,
)
payload: dict[str, object] = {
"status": "ok", "company_id": company_id, "name": name,
}
if username:
# Optionally create the company login in the same request, so
# a new company is immediately usable without code changes.
# The initial password is a random one-time value shown only
# in this creation response, never stored plaintext or logged;
# must_change_password forces a change at first login.
initial_password = auth.generate_initial_password(exclude=username)
try:
user_id = auth.create_user(
connection, username, initial_password, "company",
company_id=company_id, must_change_password=True,
)
except ValueError as exc:
self._send_json(
400,
{"status": "error",
"message": f"公司已创建,但公司账号创建失败:{exc}"},
)
return
auth.audit(
connection, "user_create", actor=user,
target=f"user:{user_id}", detail=f"company:{company_id}",
ip=self._client_ip,
)
payload.update(
{"user_id": user_id, "username": username,
"initial_password": initial_password}
)
self._send_json(200, payload)
finally:
connection.close()
# ------------------------------------------------------------------
# Bank account master data
# ------------------------------------------------------------------
@staticmethod
def _account_payload(row, *, full: bool) -> dict[str, object]:
"""Serialize an account; company users only ever see the masked form."""
payload: dict[str, object] = {
"id": row["id"],
"company_id": row["company_id"],
"bank_name": row["bank_name"],
"account_name": row["account_name"],
"account_type": row["account_type"],
"status": row["status"],
"effective_from": row["effective_from"],
"effective_to": row["effective_to"],
"reviewed_at": row["reviewed_at"],
"review_reason": row["review_reason"],
"created_at": row["created_at"],
}
if "company_name" in row.keys():
payload["company_name"] = row["company_name"]
if full:
payload["account_number"] = row["account_number"]
else:
payload["account_number_masked"] = master_data.mask_account_number(
row["account_number"]
)
return payload
def _handle_admin_accounts(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
raw_company = (query.get("company_id") or [None])[0]
raw_status = (query.get("status") or [None])[0]
try:
company_id = int(raw_company) if raw_company else None
except ValueError:
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
return
try:
rows = master_data.list_accounts(
connection, company_id=company_id, status=raw_status or None
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(
200,
{"status": "ok",
# The admin audit view is the authorized full-number view.
"accounts": [self._account_payload(row, full=True) for row in rows]},
)
finally:
connection.close()
def _handle_company_accounts(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
return
rows = master_data.list_accounts(connection, company_id=user["company_id"])
usable_ids = {
row["id"]
for row in master_data.usable_accounts(connection, user["company_id"])
}
accounts = []
for row in rows:
payload = self._account_payload(row, full=False)
payload["usable"] = row["id"] in usable_ids
accounts.append(payload)
self._send_json(200, {"status": "ok", "accounts": accounts})
finally:
connection.close()
def _handle_company_submit_account(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
return
data = self._read_json_body()
if data is None:
return
# The company binding always comes from the session; any
# company_id in the body is ignored.
try:
account = master_data.submit_bank_account(
connection,
company_id=user["company_id"],
bank_name=str(data.get("bank_name") or ""),
account_type=data.get("account_type"),
account_number=data.get("account_number"),
account_name=data.get("account_name"),
start_date=data.get("start_date"),
actor=user,
)
except master_data.ConflictError 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
auth.audit(
connection,
"account_submit",
actor=user,
target=f"bank_account:{account['id']}",
detail=master_data.mask_account_number(account["account_number"]),
ip=self._client_ip,
)
payload = self._account_payload(account, full=False)
payload["usable"] = master_data.is_usable(account, master_data.utc_today())
self._send_json(200, {"status": "ok", "account": payload})
finally:
connection.close()
def _handle_admin_review_account(self, account_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
try:
account = master_data.review_bank_account(
connection,
account_id,
str(data.get("decision") or ""),
str(data.get("reason") or ""),
user,
effective_from=data.get("effective_from"),
effective_to=data.get("effective_to"),
)
except LookupError as exc:
self._send_json(404, {"status": "error", "message": str(exc)})
return
except master_data.ConflictError 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
auth.audit(
connection,
f"account_review_{data.get('decision')}",
actor=user,
target=f"bank_account:{account_id}",
detail=master_data.mask_account_number(account["account_number"]),
ip=self._client_ip,
)
self._send_json(
200,
{"status": "ok",
"account": self._account_payload(account, full=True)},
)
finally:
connection.close()
def _handle_admin_account_aliases(self, account_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
if master_data.get_account(connection, account_id) is None:
self._send_json(404, {"status": "error", "message": "账户不存在。"})
return
rows = master_data.list_aliases(connection, account_id)
self._send_json(
200,
{"status": "ok", "aliases": [dict(row) for row in rows]},
)
finally:
connection.close()
def _handle_admin_add_alias(self, account_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
try:
alias_id = master_data.add_alias(
connection,
account_id,
str(data.get("alias_kind") or ""),
data.get("alias_value"),
priority=data.get("priority"),
effective_from=data.get("effective_from"),
effective_to=data.get("effective_to"),
actor=user,
)
except LookupError as exc:
self._send_json(404, {"status": "error", "message": str(exc)})
return
except master_data.ConflictError 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
auth.audit(
connection,
"account_alias_create",
actor=user,
target=f"account_alias:{alias_id}",
detail=f"bank_account:{account_id}",
ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "alias_id": alias_id})
finally:
connection.close()
def _handle_admin_master_changes(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
conditions: list[str] = []
params: list[object] = []
entity_type = (query.get("entity_type") or [None])[0]
entity_id = (query.get("entity_id") or [None])[0]
if entity_type:
conditions.append("entity_type = ?")
params.append(entity_type)
if entity_id:
try:
params.append(int(entity_id))
except ValueError:
self._send_json(400, {"status": "error", "message": "entity_id 参数无效。"})
return
conditions.append("entity_id = ?")
raw_limit = (query.get("limit") or ["100"])[0]
try:
limit = max(1, min(int(raw_limit), 500))
except ValueError:
limit = 100
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
rows = connection.execute(
f"""
SELECT id, entity_type, entity_id, action, before_json, after_json,
reason, actor_user_id, actor_username, created_at
FROM master_data_changes
{where}
ORDER BY id DESC
LIMIT ?
""",
(*params, limit),
).fetchall()
self._send_json(200, {"status": "ok", "changes": [dict(row) for row in rows]})
finally:
connection.close()
def _handle_admin_users(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
rows = connection.execute(
"""
SELECT u.id, u.username, u.role, u.company_id, c.name AS company_name,
u.status, u.must_change_password, u.created_at
FROM users u
LEFT JOIN companies c ON c.id = u.company_id
ORDER BY u.id
"""
).fetchall()
# password_hash is deliberately never selected or serialized.
self._send_json(200, {"status": "ok", "users": [dict(row) for row in rows]})
finally:
connection.close()
def _handle_admin_create_user(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
username = str(data.get("username") or "").strip()
try:
company_id = int(str(data.get("company_id")))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "必须指定有效的 company_id。"})
return
# The initial password is a random one-time value shown only in
# this creation response, never stored plaintext or logged;
# must_change_password forces a change at first login. Password
# reset keeps a random one-time password instead.
initial_password = auth.generate_initial_password(exclude=username)
try:
user_id = auth.create_user(
connection,
username,
initial_password,
"company",
company_id=company_id,
must_change_password=True,
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
# The random one-time password is never written to audit detail;
# only the company binding is recorded.
auth.audit(
connection,
"user_create",
actor=admin,
target=f"user:{user_id}",
detail=f"company:{company_id}",
ip=self._client_ip,
)
self._send_json(
200,
{
"status": "ok",
"user_id": user_id,
"username": username,
"company_id": company_id,
"initial_password": initial_password,
},
)
finally:
connection.close()
def _handle_admin_user_action(self, user_id: int, action: str) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
target = connection.execute(
"SELECT * FROM users WHERE id = ?", (user_id,)
).fetchone()
if target is None:
self._send_json(404, {"status": "error", "message": "用户不存在。"})
return
if action == "disable":
with connection:
connection.execute(
"UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?",
(utc_now(), user_id),
)
auth.revoke_user_sessions(connection, user_id)
auth.audit(
connection, "user_disable", actor=admin,
target=f"user:{user_id}", detail=target["username"], ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "user_id": user_id, "user_status": "disabled"})
elif action == "enable":
with connection:
connection.execute(
"UPDATE users SET status = 'active', updated_at = ? WHERE id = ?",
(utc_now(), user_id),
)
auth.audit(
connection, "user_enable", actor=admin,
target=f"user:{user_id}", detail=target["username"], ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "user_id": user_id, "user_status": "active"})
else: # reset-password
new_password = auth.generate_initial_password()
with connection:
connection.execute(
"""
UPDATE users
SET password_hash = ?, must_change_password = 1, updated_at = ?
WHERE id = ?
""",
(
auth.hash_password(new_password),
utc_now(),
user_id,
),
)
auth.revoke_user_sessions(connection, user_id)
auth.audit(
connection, "user_reset_password", actor=admin,
target=f"user:{user_id}", detail=target["username"], ip=self._client_ip,
)
# Shown once in this response; never logged or audited.
self._send_json(
200,
{"status": "ok", "user_id": user_id, "initial_password": new_password},
)
finally:
connection.close()
def _handle_admin_audit_log(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
raw = (query.get("limit") or ["100"])[0]
try:
limit = max(1, min(int(raw), 500))
except ValueError:
limit = 100
rows = connection.execute(
"""
SELECT id, actor_user_id, actor_username, action, target, detail, ip, created_at
FROM audit_log
ORDER BY id DESC
LIMIT ?
""",
(limit,),
).fetchall()
self._send_json(200, {"status": "ok", "entries": [dict(row) for row in rows]})
finally:
connection.close()
# ------------------------------------------------------------------
# Request/response plumbing
# ------------------------------------------------------------------
def _read_json_body(self) -> dict[str, object] | None:
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
content_length = 0
if content_length <= 0 or content_length > 1024 * 1024:
self._send_json(400, {"status": "error", "message": "请求体无效。"})
return None
try:
data = json.loads(self.rfile.read(content_length).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
self._send_json(400, {"status": "error", "message": "请求体不是有效的 JSON。"})
return None
if not isinstance(data, dict):
self._send_json(400, {"status": "error", "message": "请求体必须是 JSON 对象。"})
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,
"file_sha256": result.sha256,
}
if result.message:
payload["message"] = result.message
if result.status == "duplicate" and not result.duplicate_same_company:
return payload
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 _send_json(
self,
status: int,
payload: dict[str, object],
*,
session_token: str | None = None,
clear_session: bool = False,
) -> None:
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store")
if session_token is not None:
self._set_session_cookie(session_token)
if clear_session:
self._clear_session_cookie()
self.end_headers()
self.wfile.write(content)
def ensure_bootstrap_admin(connection) -> str | None:
"""Create the first admin when none exists; returns the generated password."""
existing = connection.execute(
"SELECT 1 FROM users WHERE role = 'admin' LIMIT 1"
).fetchone()
if existing is not None:
return None
username = os.environ.get("APP_ADMIN_USERNAME", "group-admin")
env_password = os.environ.get("APP_BOOTSTRAP_ADMIN_PASSWORD")
password = env_password or auth.generate_initial_password()
user_id = auth.create_user(connection, username, password, "admin")
auth.audit(connection, "bootstrap_admin", target=f"user:{user_id}", detail=username)
return None if env_password else password
def main() -> None:
host = os.environ.get("APP_HOST", "0.0.0.0")
port = int(os.environ.get("APP_PORT", "4173"))
connection = connect(DB_PATH)
try:
migrate(connection)
initial_password = ensure_bootstrap_admin(connection)
finally:
connection.close()
if initial_password is not None:
# Printed once to stdout; never written to any log file.
print(f"Bootstrap admin initial password (shown once): {initial_password}")
server = ThreadingHTTPServer((host, port), AppHandler)
print(f"Serving on http://{host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()