Files
wyndham-ARR/arr_web/app.py
2026-07-29 16:38:05 +08:00

468 lines
20 KiB
Python

"""Framework-free ARR portal application with fixed, auditable routes."""
from __future__ import annotations
import base64
import json
import secrets
import threading
import time
from dataclasses import dataclass
from datetime import date, datetime
from http.cookies import SimpleCookie
from pathlib import Path
from typing import Any, Dict, Mapping, Optional
from urllib.parse import parse_qs, urlsplit
from urllib.parse import quote
from zoneinfo import ZoneInfo
from arr_web.contracts import (
MAX_UPLOAD_BYTES,
PortalError,
Response,
failure,
success,
validate_month,
validate_upload_filename,
validate_xml_payload,
)
from arr_web.repository import (
PortalDataError,
PortalRepository,
UnavailablePortalRepository,
)
from arr_web.downloads import ArtifactReader, UnavailableArtifactReader
from arr_web.company_jobs import (
COMPANY_NAMES,
CompanyReportCoordinator,
UnavailableCompanyReportCoordinator,
)
from arr_web.services import (
AgentResultCoordinator,
MonthlyCoordinator,
UnavailableAgentResultCoordinator,
UnavailableMonthlyCoordinator,
UnavailableUploadCoordinator,
UploadCoordinator,
)
STATIC_ROOT = Path(__file__).resolve().parent / "static"
STATIC_ROUTES = {
"/": ("index.html", "text/html; charset=utf-8"),
"/index.html": ("index.html", "text/html; charset=utf-8"),
"/h5": ("h5.html", "text/html; charset=utf-8"),
"/h5.html": ("h5.html", "text/html; charset=utf-8"),
"/assets/styles.css": ("styles.css", "text/css; charset=utf-8"),
"/assets/app.js": ("app.js", "text/javascript; charset=utf-8"),
"/assets/h5.css": ("h5.css", "text/css; charset=utf-8"),
"/assets/h5.js": ("h5.js", "text/javascript; charset=utf-8"),
}
def _strict_json(raw: bytes) -> Mapping[str, Any]:
def pairs(values: list[tuple[str, Any]]) -> Dict[str, Any]:
output: Dict[str, Any] = {}
for key, value in values:
if key in output:
raise ValueError("duplicate key")
output[key] = value
return output
try:
value = json.loads(raw.decode("utf-8"), object_pairs_hook=pairs)
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
raise PortalError("REQUEST_JSON_INVALID", "请求内容不是有效 JSON") from None
if not isinstance(value, Mapping):
raise PortalError("REQUEST_JSON_INVALID", "请求内容不是有效 JSON")
return value
@dataclass(frozen=True)
class RuntimeHealth:
database_ready: bool
upload_ready: bool
monthly_ready: bool
download_ready: bool = False
company_reports_ready: bool = False
agent_writeback_ready: bool = False
class SessionLedger:
"""Small in-process CSRF ledger; deployment can replace it with shared sessions."""
def __init__(self, lifetime_seconds: int = 8 * 60 * 60) -> None:
self._lifetime = lifetime_seconds
self._lock = threading.Lock()
self._sessions: Dict[str, tuple[str, float]] = {}
def issue(self) -> tuple[str, str]:
session_id = secrets.token_urlsafe(24)
csrf = secrets.token_urlsafe(24)
now = time.time()
with self._lock:
self._sessions[session_id] = (csrf, now + self._lifetime)
if len(self._sessions) > 2048:
self._sessions = {
key: value for key, value in self._sessions.items() if value[1] > now
}
return session_id, csrf
def verify(self, session_id: Optional[str], csrf: Optional[str]) -> bool:
if not session_id or not csrf:
return False
now = time.time()
with self._lock:
value = self._sessions.get(session_id)
if value is None or value[1] <= now:
self._sessions.pop(session_id, None)
return False
return secrets.compare_digest(value[0], csrf)
class PortalApplication:
def __init__(
self,
repository: Optional[PortalRepository] = None,
upload: Optional[UploadCoordinator] = None,
agent_results: Optional[AgentResultCoordinator] = None,
monthly: Optional[MonthlyCoordinator] = None,
company_reports: Optional[CompanyReportCoordinator] = None,
artifact_reader: Optional[ArtifactReader] = None,
*,
health: Optional[RuntimeHealth] = None,
static_root: Path = STATIC_ROOT,
sessions: Optional[SessionLedger] = None,
secure_cookies: bool = False,
) -> None:
self._repository = repository or UnavailablePortalRepository()
self._upload = upload or UnavailableUploadCoordinator()
self._agent_results = agent_results or UnavailableAgentResultCoordinator()
self._monthly = monthly or UnavailableMonthlyCoordinator()
self._company_reports = (
company_reports or UnavailableCompanyReportCoordinator()
)
self._artifact_reader = artifact_reader or UnavailableArtifactReader()
self._health = health or RuntimeHealth(False, False, False)
self._static_root = static_root.resolve()
self._sessions = sessions or SessionLedger()
self._secure_cookies = secure_cookies
def handle(
self,
method: str,
target: str,
headers: Mapping[str, str],
body: bytes = b"",
) -> Response:
normalized_headers = {key.lower(): value for key, value in headers.items()}
route = urlsplit(target)
try:
if method == "GET" and route.path in STATIC_ROUTES:
return self._static(route.path)
if method == "GET" and route.path == "/api/session":
session_id, csrf = self._sessions.issue()
cookie = (
f"arr_session={session_id}; Path=/; HttpOnly; "
"SameSite=Strict; Max-Age=28800"
)
if self._secure_cookies:
cookie += "; Secure"
return Response.json(
200,
success(
{
"csrf_token": csrf,
"max_upload_bytes": MAX_UPLOAD_BYTES,
"retention": "month_minimum",
}
),
{"Set-Cookie": cookie},
)
if method == "GET" and route.path == "/api/health":
return Response.json(
200,
success(
{
"database_ready": self._health.database_ready,
"processing_ready": self._health.upload_ready,
"monthly_ready": self._health.monthly_ready,
"download_ready": self._health.download_ready,
"company_reports_ready": self._health.company_reports_ready,
"agent_writeback_ready": self._health.agent_writeback_ready,
"business_time_zone": "Asia/Bangkok",
"checked_at": datetime.now(ZoneInfo("Asia/Bangkok")).isoformat(),
}
),
)
if method == "GET" and route.path == "/api/jobs":
query = parse_qs(route.query, keep_blank_values=True)
month = validate_month(self._one(query, "month", self._current_month()))
limit = self._integer(query, "limit", 100, 1, 200)
return Response.json(200, success(self._repository.list_jobs(month, limit)))
if method == "GET" and route.path == "/api/monthly-runs":
query = parse_qs(route.query, keep_blank_values=True)
month = validate_month(self._one(query, "month", self._current_month()))
limit = self._integer(query, "limit", 100, 1, 200)
return Response.json(
200,
success(self._repository.list_monthly_runs(month, limit)),
)
if method == "GET" and route.path == "/api/months":
return Response.json(200, success(self._repository.list_months()))
if method == "GET" and route.path == "/api/h5/months":
return Response.json(
200,
{
"months": [
{
"month_key": item.get("month_key"),
"updated_at": item.get("updated_at"),
"max_arrival_date": item.get("max_arrival_date"),
}
for item in self._repository.list_months()
]
},
)
if method == "GET" and route.path == "/api/analytics":
query = parse_qs(route.query, keep_blank_values=True)
month = validate_month(self._one(query, "month", self._current_month()))
return Response.json(200, success(self._repository.read_dashboard(month)))
analytics_parts = route.path.strip("/").split("/")
if (
method == "GET"
and len(analytics_parts) == 4
and analytics_parts[:2] == ["api", "monthly"]
and analytics_parts[3] == "analytics"
):
try:
month = validate_month(analytics_parts[2])
except PortalError as error:
raise PortalError(
"MONTH_KEY_INVALID",
error.safe_message,
422,
) from None
try:
dashboard = self._repository.read_dashboard(month)
except PortalDataError as error:
if error.code == "ANALYTICS_MONTH_NOT_FOUND":
raise PortalError(
"MONTHLY_NOT_FOUND",
"找不到该月份的正式月报。",
404,
) from None
raise
return Response.json(200, dashboard)
if method == "GET" and route.path == "/api/channel-detail":
query = parse_qs(route.query, keep_blank_values=True)
month = validate_month(self._one(query, "month", self._current_month()))
worksheet = self._one(query, "worksheet", "")
if not worksheet or len(worksheet) > 31:
raise PortalError("CHANNEL_INVALID", "渠道名称无效")
limit = self._integer(query, "limit", 100, 1, 500)
offset = self._integer(query, "offset", 0, 0, 10_000_000)
return Response.json(
200,
success(
self._repository.read_channel_detail(
month,
worksheet,
limit,
offset,
)
),
)
if method == "GET" and route.path == "/api/company-reports/jobs":
query = parse_qs(route.query, keep_blank_values=True)
limit = self._integer(query, "limit", 100, 1, 200)
report_month = None
if "month" in query:
report_month = validate_month(self._one(query, "month", ""))
return Response.json(
200,
success(self._company_reports.list_jobs(report_month, limit)),
)
company_parts = route.path.strip("/").split("/")
if (
method == "GET"
and len(company_parts) == 6
and company_parts[:3] == ["api", "company-reports", "jobs"]
and company_parts[4] == "downloads"
):
job_id = company_parts[3]
company = company_parts[5]
if company not in COMPANY_NAMES:
raise PortalError(
"COMPANY_REPORT_COMPANY_INVALID", "公司名称无效"
)
return self._download(
self._company_reports.resolve_download(job_id, company)
)
if (
method == "GET"
and len(company_parts) == 4
and company_parts[:3] == ["api", "company-reports", "jobs"]
):
return Response.json(
200,
success(self._company_reports.get_job(company_parts[3])),
)
if method == "GET" and route.path == "/api/download/daily":
query = parse_qs(route.query, keep_blank_values=True)
job_id = self._one(query, "job_id", "")
if not job_id:
raise PortalError("DOWNLOAD_REQUEST_INVALID", "下载请求无效")
return self._download(self._repository.resolve_daily_download(job_id))
if method == "GET" and route.path == "/api/download/monthly":
query = parse_qs(route.query, keep_blank_values=True)
report_id = self._integer(query, "report_id", 0, 1, 9_223_372_036_854_775_807)
return self._download(self._repository.resolve_monthly_download(report_id))
if method == "POST" and route.path == "/api/jobs":
self._require_csrf(normalized_headers)
encoded = normalized_headers.get("x-arr-filename-b64", "")
try:
padding = "=" * (-len(encoded) % 4)
filename = base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
except (ValueError, UnicodeDecodeError):
raise PortalError("UPLOAD_FILENAME_INVALID", "请选择 XML 文件") from None
filename = validate_upload_filename(filename)
validate_xml_payload(body)
return Response.json(202, success(self._upload.submit(filename, body)))
if (
method == "POST"
and route.path == "/api/integrations/super-agent/results"
):
content_type = normalized_headers.get("content-type", "").split(";", 1)[0]
if content_type.strip().lower() != "application/json":
raise PortalError(
"CONTENT_TYPE_INVALID",
"Agent 回写必须使用 application/json",
415,
)
return Response.json(
200,
success(self._agent_results.accept(body)),
)
if method == "POST" and route.path == "/api/monthly-runs":
self._require_csrf(normalized_headers)
if len(body) > 8192:
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
payload = _strict_json(body)
if set(payload) != {"month", "as_of_date"}:
raise PortalError("MONTHLY_REQUEST_INVALID", "月报请求字段无效")
month = validate_month(str(payload.get("month", "")))
try:
as_of = date.fromisoformat(str(payload.get("as_of_date", "")))
except ValueError:
raise PortalError("MONTHLY_REQUEST_INVALID", "月报截止日期无效") from None
return Response.json(202, success(self._monthly.generate(month, as_of)))
if method == "POST" and route.path == "/api/company-reports/jobs":
self._require_csrf(normalized_headers)
if len(body) > 8192:
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
payload = _strict_json(body)
if set(payload) != {"report_month", "period"}:
raise PortalError(
"COMPANY_REPORT_REQUEST_INVALID",
"渠道明细请求字段无效",
)
report_month = payload.get("report_month")
period = payload.get("period")
if not isinstance(report_month, str) or not isinstance(period, str):
raise PortalError(
"COMPANY_REPORT_REQUEST_INVALID",
"渠道明细请求字段无效",
)
return Response.json(
202,
success(self._company_reports.create(report_month, period)),
)
raise PortalError("ROUTE_NOT_FOUND", "页面或接口不存在", 404)
except PortalDataError as error:
status = 503
if error.code == "DOWNLOAD_NOT_FOUND":
status = 404
elif error.code == "DOWNLOAD_REQUEST_INVALID":
status = 400
return Response.json(
status,
failure(PortalError(error.code, error.safe_message, status)),
)
except PortalError as error:
return Response.json(error.status, failure(error))
except Exception:
return Response.json(
500,
failure(PortalError("INTERNAL_ERROR", "服务暂时无法完成请求", 500)),
)
def _static(self, route: str) -> Response:
filename, mime_type = STATIC_ROUTES[route]
path = (self._static_root / filename).resolve()
try:
path.relative_to(self._static_root)
body = path.read_bytes()
except (ValueError, OSError):
raise PortalError("STATIC_NOT_FOUND", "页面资源不存在", 404) from None
return Response(200, body, mime_type, {"Cache-Control": "no-cache"})
def _download(self, descriptor: Any) -> Response:
body = self._artifact_reader.read(descriptor)
encoded = quote(descriptor.original_filename, safe="")
return Response(
200,
body,
descriptor.mime_type,
{
"Cache-Control": "private, no-store",
"Content-Disposition": (
'attachment; filename="arr-report.xlsx"; '
f"filename*=UTF-8''{encoded}"
),
"X-Artifact-SHA256": descriptor.sha256,
},
)
def _require_csrf(self, headers: Mapping[str, str]) -> None:
cookie = SimpleCookie()
try:
cookie.load(headers.get("cookie", ""))
except Exception:
raise PortalError("SESSION_INVALID", "页面会话已失效", 403) from None
item = cookie.get("arr_session")
session_id = item.value if item is not None else None
if not self._sessions.verify(session_id, headers.get("x-arr-csrf")):
raise PortalError("SESSION_INVALID", "页面会话已失效", 403)
@staticmethod
def _current_month() -> str:
return datetime.now(ZoneInfo("Asia/Bangkok")).strftime("%Y-%m")
@staticmethod
def _one(query: Mapping[str, list[str]], key: str, default: str) -> str:
values = query.get(key)
if values is None:
return default
if len(values) != 1:
raise PortalError("QUERY_INVALID", "查询参数无效")
return values[0]
@classmethod
def _integer(
cls,
query: Mapping[str, list[str]],
key: str,
default: int,
minimum: int,
maximum: int,
) -> int:
raw = cls._one(query, key, str(default))
try:
value = int(raw)
except ValueError:
raise PortalError("QUERY_INVALID", "查询参数无效") from None
if not minimum <= value <= maximum:
raise PortalError("QUERY_INVALID", "查询参数无效")
return value