feat: sync latest ARR implementation

This commit is contained in:
Wyndham ARR
2026-07-31 15:11:42 +08:00
parent d6f8a747fa
commit bf7939dd1a
185 changed files with 17527 additions and 2260 deletions

View File

@@ -16,12 +16,18 @@ from urllib.parse import parse_qs, urlsplit
from urllib.parse import quote
from zoneinfo import ZoneInfo
from arr_web.auth import LoginAttemptLedger, LoginCredentials
from arr_web.booking_uploads import (
BookingSourceCoordinator,
UnavailableBookingSourceCoordinator,
)
from arr_web.contracts import (
MAX_UPLOAD_BYTES,
PortalError,
Response,
failure,
success,
validate_job_id,
validate_month,
validate_upload_filename,
validate_xml_payload,
@@ -38,9 +44,7 @@ from arr_web.company_jobs import (
UnavailableCompanyReportCoordinator,
)
from arr_web.services import (
AgentResultCoordinator,
MonthlyCoordinator,
UnavailableAgentResultCoordinator,
UnavailableMonthlyCoordinator,
UnavailableUploadCoordinator,
UploadCoordinator,
@@ -57,7 +61,38 @@ STATIC_ROUTES = {
"/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"),
"/login": ("login.html", "text/html; charset=utf-8"),
"/login.html": ("login.html", "text/html; charset=utf-8"),
"/assets/login.css": ("login.css", "text/css; charset=utf-8"),
"/assets/login.js": ("login.js", "text/javascript; charset=utf-8"),
}
LOGIN_STATIC_ROUTES = {
"/login",
"/login.html",
"/assets/login.css",
"/assets/login.js",
}
LOGIN_DOCUMENT_ROUTES = {"/login", "/login.html"}
H5_DOCUMENT_ROUTES = {"/h5", "/h5.html"}
def _paged_success(
data: list[Dict[str, Any]],
*,
total: int,
limit: int,
offset: int,
) -> Dict[str, Any]:
return success(
data,
pagination={
"total": total,
"limit": limit,
"offset": offset,
"has_previous": offset > 0,
"has_next": offset + len(data) < total,
},
)
def _strict_json(raw: bytes) -> Mapping[str, Any]:
@@ -78,6 +113,19 @@ def _strict_json(raw: bytes) -> Mapping[str, Any]:
return value
def _decode_filename_header(
encoded: str,
*,
code: str,
safe_message: str,
) -> str:
try:
padding = "=" * (-len(encoded) % 4)
return base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
except (ValueError, UnicodeDecodeError):
raise PortalError(code, safe_message) from None
@dataclass(frozen=True)
class RuntimeHealth:
database_ready: bool
@@ -85,39 +133,75 @@ class RuntimeHealth:
monthly_ready: bool
download_ready: bool = False
company_reports_ready: bool = False
agent_writeback_ready: bool = False
company_source_upload_ready: bool = False
@dataclass(frozen=True)
class _SessionRecord:
username: str
csrf: str
expires_at: float
class SessionLedger:
"""Small in-process CSRF ledger; deployment can replace it with shared sessions."""
"""Small authenticated-session ledger for the single-process Web runtime."""
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]] = {}
self._sessions: Dict[str, _SessionRecord] = {}
def issue(self) -> tuple[str, str]:
@property
def lifetime_seconds(self) -> int:
return self._lifetime
def issue(self, username: str) -> 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)
self._sessions[session_id] = _SessionRecord(
username=username,
csrf=csrf,
expires_at=now + self._lifetime,
)
if len(self._sessions) > 2048:
self._sessions = {
key: value for key, value in self._sessions.items() if value[1] > now
key: value
for key, value in self._sessions.items()
if value.expires_at > now
}
if len(self._sessions) > 2048:
oldest = sorted(
self._sessions,
key=lambda key: self._sessions[key].expires_at,
)
for key in oldest[: len(self._sessions) - 2048]:
self._sessions.pop(key, None)
return session_id, csrf
def current(self, session_id: Optional[str]) -> Optional[tuple[str, str]]:
if not session_id:
return None
now = time.time()
with self._lock:
value = self._sessions.get(session_id)
if value is None or value.expires_at <= now:
self._sessions.pop(session_id, None)
return None
return value.username, value.csrf
def verify(self, session_id: Optional[str], csrf: Optional[str]) -> bool:
if not session_id or not csrf:
return False
now = time.time()
current = self.current(session_id)
return current is not None and secrets.compare_digest(current[1], csrf)
def revoke(self, session_id: Optional[str]) -> None:
if not session_id:
return
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)
self._sessions.pop(session_id, None)
class PortalApplication:
@@ -125,27 +209,33 @@ class PortalApplication:
self,
repository: Optional[PortalRepository] = None,
upload: Optional[UploadCoordinator] = None,
agent_results: Optional[AgentResultCoordinator] = None,
monthly: Optional[MonthlyCoordinator] = None,
company_reports: Optional[CompanyReportCoordinator] = None,
booking_sources: Optional[BookingSourceCoordinator] = None,
artifact_reader: Optional[ArtifactReader] = None,
*,
health: Optional[RuntimeHealth] = None,
static_root: Path = STATIC_ROOT,
sessions: Optional[SessionLedger] = None,
credentials: Optional[LoginCredentials] = None,
login_attempts: Optional[LoginAttemptLedger] = 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._booking_sources = (
booking_sources or UnavailableBookingSourceCoordinator()
)
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._credentials = credentials
self._login_attempts = login_attempts or LoginAttemptLedger()
self._secure_cookies = secure_cookies
def handle(
@@ -154,30 +244,44 @@ class PortalApplication:
target: str,
headers: Mapping[str, str],
body: bytes = b"",
client_id: str = "direct",
) -> Response:
normalized_headers = {key.lower(): value for key, value in headers.items()}
route = urlsplit(target)
try:
session_id = self._session_id(normalized_headers)
current_session = self._sessions.current(session_id)
if method == "GET" and route.path == "/healthz":
return self._readiness()
if method == "GET" and route.path in LOGIN_STATIC_ROUTES:
if route.path in LOGIN_DOCUMENT_ROUTES and current_session is not None:
return self._redirect(self._safe_next(route.query))
return self._static(route.path)
if method == "POST" and route.path == "/api/login":
return self._login(body, normalized_headers, client_id)
if current_session is None:
return self._authentication_required(method, route.path)
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,
"username": current_session[0],
"csrf_token": current_session[1],
"max_upload_bytes": MAX_UPLOAD_BYTES,
"retention": "month_minimum",
}
),
{"Set-Cookie": cookie},
)
if method == "POST" and route.path == "/api/logout":
self._require_csrf(normalized_headers)
self._sessions.revoke(session_id)
return Response.json(
200,
success({"logged_out": True}),
{"Set-Cookie": self._session_cookie("", max_age=0)},
)
if method == "GET" and route.path == "/api/health":
return Response.json(
@@ -189,7 +293,7 @@ class PortalApplication:
"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,
"company_source_upload_ready": self._health.company_source_upload_ready,
"business_time_zone": "Asia/Bangkok",
"checked_at": datetime.now(ZoneInfo("Asia/Bangkok")).isoformat(),
}
@@ -198,15 +302,48 @@ class PortalApplication:
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)))
limit = self._integer(query, "limit", 50, 1, 200)
offset = self._integer(query, "offset", 0, 0, 10_000_000)
jobs, total = self._repository.list_jobs(month, limit, offset)
return Response.json(
200,
_paged_success(
jobs,
total=total,
limit=limit,
offset=offset,
),
)
job_parts = route.path.strip("/").split("/")
if (
method == "GET"
and len(job_parts) == 4
and job_parts[:2] == ["api", "jobs"]
and job_parts[3] == "trace"
):
job_id = validate_job_id(job_parts[2])
return Response.json(
200,
success(self._repository.get_job_trace(job_id)),
)
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)
limit = self._integer(query, "limit", 50, 1, 200)
offset = self._integer(query, "offset", 0, 0, 10_000_000)
runs, total = self._repository.list_monthly_runs(
month,
limit,
offset,
)
return Response.json(
200,
success(self._repository.list_monthly_runs(month, limit)),
_paged_success(
runs,
total=total,
limit=limit,
offset=offset,
),
)
if method == "GET" and route.path == "/api/months":
return Response.json(200, success(self._repository.list_months()))
@@ -275,13 +412,34 @@ class PortalApplication:
)
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)
limit = self._integer(query, "limit", 50, 1, 200)
offset = self._integer(query, "offset", 0, 0, 10_000_000)
report_month = None
if "month" in query:
report_month = validate_month(self._one(query, "month", ""))
jobs, total = self._company_reports.list_jobs(
report_month,
limit,
offset,
)
return Response.json(
200,
success(self._company_reports.list_jobs(report_month, limit)),
_paged_success(
jobs,
total=total,
limit=limit,
offset=offset,
),
)
if method == "GET" and route.path == "/api/company-reports/source":
return Response.json(200, success(self._booking_sources.current()))
if method == "GET" and route.path == "/api/company-reports/source/draft":
query = parse_qs(route.query, keep_blank_values=True)
limit = self._integer(query, "limit", 50, 1, 200)
offset = self._integer(query, "offset", 0, 0, 10_000_000)
return Response.json(
200,
success(self._booking_sources.draft(limit, offset)),
)
company_parts = route.path.strip("/").split("/")
if (
@@ -320,29 +478,124 @@ class PortalApplication:
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 = _decode_filename_header(
normalized_headers.get("x-arr-filename-b64", ""),
code="UPLOAD_FILENAME_INVALID",
safe_message="请选择 XML 文件",
)
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":
if method == "POST" and route.path == "/api/company-reports/source":
self._require_csrf(normalized_headers)
filename = _decode_filename_header(
normalized_headers.get("x-arr-filename-b64", ""),
code="BOOKING_EXCEL_FILENAME_INVALID",
safe_message="请选择 .xlsx 格式的 Excel 文件",
)
return Response.json(
200,
success(self._booking_sources.submit(filename, body)),
)
if method == "DELETE" and route.path == "/api/company-reports/source/draft/items":
self._require_csrf(normalized_headers)
if len(body) > 8192:
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
payload = _strict_json(body)
if set(payload) != {"draft_id", "item_ids"}:
raise PortalError(
"CONTENT_TYPE_INVALID",
"Agent 回写必须使用 application/json",
415,
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
"批量删除记录请求无效",
)
return Response.json(
200,
success(self._agent_results.accept(body)),
success(
self._booking_sources.delete_items(
payload.get("draft_id"),
payload.get("item_ids"),
)
),
)
if (
method in {"PATCH", "DELETE"}
and len(company_parts) == 6
and company_parts[:5]
== ["api", "company-reports", "source", "draft", "items"]
):
self._require_csrf(normalized_headers)
try:
item_id = int(company_parts[5])
except ValueError:
raise PortalError(
"BOOKING_EXCEL_REVIEW_ITEM_INVALID",
"待确认记录编号无效",
) from None
if item_id <= 0:
raise PortalError(
"BOOKING_EXCEL_REVIEW_ITEM_INVALID",
"待确认记录编号无效",
)
if len(body) > 8192:
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
payload = _strict_json(body)
if method == "PATCH":
if set(payload) != {"draft_id", "room_type", "quantity"}:
raise PortalError(
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
"人工确认字段无效",
)
return Response.json(
200,
success(
self._booking_sources.update_item(
payload.get("draft_id"),
item_id,
payload.get("room_type"),
payload.get("quantity"),
)
),
)
if set(payload) != {"draft_id"}:
raise PortalError(
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
"删除记录请求无效",
)
return Response.json(
200,
success(
self._booking_sources.delete_item(
payload.get("draft_id"),
item_id,
)
),
)
if method == "DELETE" and route.path == "/api/company-reports/source/draft":
self._require_csrf(normalized_headers)
if len(body) > 8192:
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
payload = _strict_json(body)
if set(payload) != {"draft_id"}:
raise PortalError(
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
"放弃提取请求无效",
)
return Response.json(
200,
success(self._booking_sources.discard(payload.get("draft_id"))),
)
if method == "POST" and route.path == "/api/company-reports/source/draft/activate":
self._require_csrf(normalized_headers)
if len(body) > 8192:
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
payload = _strict_json(body)
if set(payload) != {"draft_id"}:
raise PortalError(
"BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
"确认启用请求无效",
)
return Response.json(
200,
success(self._booking_sources.activate(payload.get("draft_id"))),
)
if method == "POST" and route.path == "/api/monthly-runs":
self._require_csrf(normalized_headers)
@@ -359,6 +612,18 @@ class PortalApplication:
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 self._booking_sources.draft(1, 0) is not None:
raise PortalError(
"BOOKING_EXCEL_REVIEW_OPEN",
"请先完成或放弃当前 Excel 提取草稿",
409,
)
if self._booking_sources.current() is None:
raise PortalError(
"BOOKING_EXCEL_SOURCE_REQUIRED",
"请先上传并校验 Excel 报表",
409,
)
if len(body) > 8192:
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
payload = _strict_json(body)
@@ -381,7 +646,7 @@ class PortalApplication:
raise PortalError("ROUTE_NOT_FOUND", "页面或接口不存在", 404)
except PortalDataError as error:
status = 503
if error.code == "DOWNLOAD_NOT_FOUND":
if error.code in {"DOWNLOAD_NOT_FOUND", "JOB_NOT_FOUND"}:
status = 404
elif error.code == "DOWNLOAD_REQUEST_INVALID":
status = 400
@@ -397,6 +662,119 @@ class PortalApplication:
failure(PortalError("INTERNAL_ERROR", "服务暂时无法完成请求", 500)),
)
def _readiness(self) -> Response:
ready = self._health.database_ready and self._health.upload_ready
return Response(
200 if ready else 503,
b"ready\n" if ready else b"unavailable\n",
"text/plain; charset=utf-8",
{"Cache-Control": "no-store"},
)
def _login(
self,
body: bytes,
headers: Mapping[str, str],
client_id: str,
) -> Response:
media_type = headers.get("content-type", "").split(";", 1)[0].strip().lower()
if media_type != "application/json":
raise PortalError("LOGIN_REQUEST_INVALID", "登录请求格式无效", 415)
if len(body) > 8192:
raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
retry_after = self._login_attempts.retry_after(client_id)
if retry_after:
return self._login_rate_limited(retry_after)
payload = _strict_json(body)
if set(payload) != {"username", "password"}:
raise PortalError("LOGIN_REQUEST_INVALID", "登录请求字段无效")
username = payload.get("username")
password = payload.get("password")
if self._credentials is None or not self._credentials.verify(
username,
password,
):
retry_after = self._login_attempts.record_failure(client_id)
if retry_after:
return self._login_rate_limited(retry_after)
raise PortalError("LOGIN_FAILED", "用户名或密码不正确", 401)
assert isinstance(username, str)
self._login_attempts.record_success(client_id)
self._sessions.revoke(self._session_id(headers))
session_id, csrf = self._sessions.issue(username)
return Response.json(
200,
success(
{
"username": username,
"csrf_token": csrf,
"expires_in": self._sessions.lifetime_seconds,
}
),
{"Set-Cookie": self._session_cookie(session_id)},
)
@staticmethod
def _login_rate_limited(retry_after: int) -> Response:
return Response.json(
429,
failure(
PortalError(
"LOGIN_RATE_LIMITED",
"登录尝试过于频繁,请稍后再试",
429,
)
),
{"Retry-After": str(retry_after)},
)
def _authentication_required(self, method: str, route_path: str) -> Response:
if method == "GET" and not route_path.startswith("/api/"):
next_path = "/h5" if route_path in H5_DOCUMENT_ROUTES else "/"
return self._redirect(f"/login?next={quote(next_path, safe='')}")
return Response.json(
401,
failure(PortalError("AUTH_REQUIRED", "请先登录", 401)),
)
@staticmethod
def _redirect(location: str) -> Response:
return Response(
303,
b"",
"text/plain; charset=utf-8",
{"Cache-Control": "no-store", "Location": location},
)
@staticmethod
def _safe_next(query: str) -> str:
values = parse_qs(query, keep_blank_values=True).get("next", [])
if len(values) == 1 and values[0] in {"/", "/h5"}:
return values[0]
return "/"
@staticmethod
def _session_id(headers: Mapping[str, str]) -> Optional[str]:
cookie = SimpleCookie()
try:
cookie.load(headers.get("cookie", ""))
except Exception:
return None
item = cookie.get("arr_session")
return item.value if item is not None else None
def _session_cookie(self, session_id: str, *, max_age: Optional[int] = None) -> str:
age = self._sessions.lifetime_seconds if max_age is None else max_age
cookie = (
f"arr_session={session_id}; Path=/; HttpOnly; "
f"SameSite=Strict; Max-Age={age}"
)
if age == 0:
cookie += "; Expires=Thu, 01 Jan 1970 00:00:00 GMT"
if self._secure_cookies:
cookie += "; Secure"
return cookie
def _static(self, route: str) -> Response:
filename, mime_type = STATIC_ROUTES[route]
path = (self._static_root / filename).resolve()
@@ -425,13 +803,7 @@ class PortalApplication:
)
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
session_id = self._session_id(headers)
if not self._sessions.verify(session_id, headers.get("x-arr-csrf")):
raise PortalError("SESSION_INVALID", "页面会话已失效", 403)