{escape(title)}
{lead}
"""用户端 Web 自助 MVP 公共入口页面与 Portal 路由。""" from __future__ import annotations import json import logging import secrets from html import escape from pathlib import Path from datetime import datetime, timedelta, timezone from typing import Any, Literal from urllib.parse import parse_qsl import markdown from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi.responses import ( FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse, ) from pydantic import BaseModel, Field from admin.config import Settings, get_settings_dep from admin.errors import DATA_NOT_FOUND, DATA_VALIDATION_FAILED from admin.errors.exceptions import BusinessError from data.customer_portal.token import ( PortalTokenError, issue_portal_token, verify_portal_token, ) from data.crowd_db.loader import CrowdDBLoader from data.llm import ( LLMClient, LLMError, build_audit_prompt, build_full_plan_prompt, ) from data.notifications.email_service import DeliveryNotificationService from data.orders import crypto from data.orders.dao import OrderNotFound, OrdersDAO from data.orders.deletion_service import RETENTION_GUARDED_STATUSES from data.orders.intake_schema import IntakePayload from data.orders.intake_store import IntakeStore from data.orders.models import Order, utc_now_iso from data.orders.public_flow import ( PublicOrderCreate, create_public_order, ) from data.orders.crypto import MissingEncryptionKey from data.payments.service import PaymentError, PaymentService from data.share.short_link import ShortLinkService, build_url router = APIRouter(tags=["web-public"]) logger = logging.getLogger(__name__) ServiceVersion = Literal["audit", "basic", "standard", "premium"] _STAGE_META: dict[str, tuple[str, str]] = { "pending_payment": ("待支付", "请先完成支付,系统会自动推进到资料填写。"), "info_required": ("待填写资料", "支付已确认,请补充考生资料后进入处理队列。"), "info_submitted": ("资料已提交", "资料已进入系统,待后台接单处理。"), "processing": ("处理中", "后台已接单,正在生成审核/方案结果。"), "report_ready": ("报告已就绪", "已可站内查看报告并下载 PDF。"), "completed": ("已完成", "订单已完成,后续可继续查看历史交付内容。"), "refunded": ("已退款", "该订单已完成退款。"), } _SERVICE_PRICES = { "audit": 4900, "basic": 4900, "standard": 9900, "premium": 19900, } _SIMULATED_PAYMENT_ROUTE_NOT_FOUND = "not found" _DELETION_SCOPE_VALUES = ("order_only", "order_and_attachments", "full_account") _DELETION_NEXT_STEP_OUTSIDE = ( "已超过 180 天保留期,可申请删除;提交成功后将由人工核验后处理" ) _DELETION_NEXT_STEP_WITHIN = "提交成功后将由人工核验后处理(订单仍在 180 天保留期内)" _DELETION_NEXT_STEP_PENDING = "提交成功后将由人工核验后处理" class PublicOrderCreated(BaseModel): order_id: str source: str status: str service_version: str amount_cents: int next_step: str checkout_url: str portal_status_url: str portal_info_url: str class WebhookAck(BaseModel): payment_id: str processed: bool idempotent: bool order_status: str class PortalIntakeResponse(BaseModel): intake_status: str stage: str order_id: str profile_minimum_complete: bool profile_missing_fields: list[str] class PortalAttachmentUploaded(BaseModel): order_id: str intake_status: str stage: str attachments: list[dict[str, Any]] class ReviewResultContract(BaseModel): review_result_id: str risk_level: Literal["high", "medium", "low"] top_findings: list[str] recommended_action: Literal["go_cwb", "go_step1", "go_full_plan"] available_actions: list[Literal["go_cwb", "go_step1", "go_full_plan"]] review_entry_source: Literal["home", "status", "report", "direct"] review_followup_action: Literal["cwb", "step1", "full_plan", "none"] review_input_summary: str = "" review_input_attachments: list[str] = Field(default_factory=list) review_constraints: dict[str, Any] = Field(default_factory=dict) llm_generated: bool = False llm_summary: str = "" llm_cwb_suggestions: dict[str, list[str]] = Field(default_factory=dict) class ReviewActionRequest(BaseModel): token: str action: Literal["cwb", "step1", "full_plan"] class ReviewActionResponse(BaseModel): review_result_id: str review_followup_action: Literal["cwb", "step1", "full_plan"] next_href: str class DeletionRequestCreate(BaseModel): # 2026-06-19 T12-C: scope 锁定到白名单, confirm_guardian 强制要求 requester_name: str = Field(..., min_length=1, max_length=64) requester_contact: str = Field(..., min_length=1, max_length=128) reason: str = Field(..., min_length=1, max_length=2000) scope: Literal["order_only", "order_and_attachments", "full_account"] confirm_guardian: bool = Field(...) def _resolve_retention_status(order: Order, settings: Settings) -> str: """返回订单的保留期相对位置。 - "pending": 订单状态不在保留期门禁子集内 (例如 pending) - "within": 订单在保留期门禁子集且未到保留期 - "outside": 订单在保留期门禁子集且已超过保留期 """ if order.status not in RETENTION_GUARDED_STATUSES: return "pending" updated_raw = ( order.status_updated_at or order.completed_at or order.created_at or "" ).strip() if not updated_raw: return "within" try: updated = datetime.fromisoformat(updated_raw.replace("Z", "+00:00")) except ValueError: return "within" if updated.tzinfo is None: updated = updated.replace(tzinfo=timezone.utc) elapsed = datetime.now(timezone.utc) - updated if elapsed >= timedelta(days=settings.retention_days): return "outside" return "within" def _next_step_for_retention(status: str) -> str: if status == "outside": return _DELETION_NEXT_STEP_OUTSIDE if status == "within": return _DELETION_NEXT_STEP_WITHIN return _DELETION_NEXT_STEP_PENDING class DeletionRequestCreated(BaseModel): order_id: str request_logged: bool next_step: str def _log_deletion_request( order_id: str, payload: DeletionRequestCreate, settings: Settings ) -> None: from datetime import datetime path = Path(settings.deletion_request_log_path) path.parent.mkdir(parents=True, exist_ok=True) item = { "created_at": datetime.utcnow().isoformat() + "Z", "order_id": order_id, "requester_name": payload.requester_name, "requester_contact": payload.requester_contact, "reason": payload.reason, "scope": payload.scope, "confirm_guardian": payload.confirm_guardian, } with path.open("a", encoding="utf-8") as fh: fh.write(json.dumps(item, ensure_ascii=False) + "\n") def _assert_simulated_payment_routes_allowed(settings: Settings) -> None: if settings.env == "prod": raise HTTPException(status_code=404, detail=_SIMULATED_PAYMENT_ROUTE_NOT_FOUND) @router.get("/", include_in_schema=False) def landing_page( request: Request, settings: Settings = Depends(get_settings_dep) ) -> HTMLResponse: return HTMLResponse(_render_landing_page(request, settings)) @router.get("/pricing", include_in_schema=False) def pricing_page(request: Request) -> HTMLResponse: return HTMLResponse(_render_pricing_page(request)) @router.get("/checkout/{service_version}", include_in_schema=False) def checkout_page(service_version: ServiceVersion) -> HTMLResponse: return HTMLResponse(_render_checkout_page(service_version)) @router.get("/portal/{token}/payment-success", include_in_schema=False) def payment_success_page( token: str, settings: Settings = Depends(get_settings_dep) ) -> HTMLResponse: order = _resolve_order_from_token(token, settings) context = _build_portal_context(order, settings) return HTMLResponse(_render_payment_success_page(token, context)) @router.post("/api/public/orders", response_model=PublicOrderCreated, status_code=201) def create_public_order_endpoint( payload: PublicOrderCreate, settings: Settings = Depends(get_settings_dep), ) -> PublicOrderCreated: try: payment_service = _payment_service(settings) except PaymentError as exc: raise HTTPException( status_code=503, detail=f"payment provider unavailable: {exc}", ) from exc try: with OrdersDAO.connect(settings.orders_db_path) as dao: order = create_public_order(dao, payload) except MissingEncryptionKey as exc: crypto.get_fernet.cache_clear() logger.warning("public order create blocked by missing encryption key: %s", exc) raise HTTPException( status_code=503, detail="当前暂时无法创建订单,请稍后重试或联系客服获取人工协助。", ) from exc portal_token = issue_portal_token(order.id, settings.portal_token_secret) try: checkout = payment_service.create_checkout(order.id) except PaymentError as exc: with OrdersDAO.connect(settings.orders_db_path) as dao: dao.delete(order.id) raise HTTPException( status_code=503, detail=f"payment checkout unavailable: {exc}", ) from exc return PublicOrderCreated( order_id=order.id, source=order.source, status=order.status, service_version=order.service_version, amount_cents=order.amount_cents, next_step="payment", checkout_url=checkout.checkout_url, portal_status_url=f"/portal/{portal_token}/status", portal_info_url=f"/portal/{portal_token}/info", ) @router.post("/api/public/payments/mock/webhook", response_model=WebhookAck) def mock_payment_webhook( payload: dict[str, Any], request: Request, settings: Settings = Depends(get_settings_dep), ) -> WebhookAck: _assert_simulated_payment_routes_allowed(settings) signature = request.headers.get("X-Mock-Signature", "") service = _payment_service(settings) try: result = service.handle_webhook(payload, signature) except PaymentError as exc: message = str(exc) status_code = 409 if "amount mismatch" in message else 400 raise HTTPException(status_code=status_code, detail=message) from exc return WebhookAck(**result.__dict__) @router.post("/api/public/payments/alipay/notify", include_in_schema=False) async def alipay_notify_webhook( request: Request, settings: Settings = Depends(get_settings_dep), ) -> PlainTextResponse: body = (await request.body()).decode("utf-8") payload = {key: value for key, value in parse_qsl(body, keep_blank_values=True)} signature = payload.pop("sign", "") payload.pop("sign_type", None) service = _payment_service(settings) try: service.handle_webhook(payload, signature) except PaymentError as exc: message = str(exc) status_code = 409 if "amount mismatch" in message else 400 raise HTTPException(status_code=status_code, detail=message) from exc return PlainTextResponse("success") @router.get("/pay/mock/{payment_id}", include_in_schema=False) def mock_payment_page( payment_id: str, settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: _assert_simulated_payment_routes_allowed(settings) return _render_simulated_payment_page(payment_id, settings, provider_slug="mock") @router.post("/pay/mock/{payment_id}/complete", include_in_schema=False) def complete_mock_payment( payment_id: str, settings: Settings = Depends(get_settings_dep), ) -> RedirectResponse: _assert_simulated_payment_routes_allowed(settings) return _complete_simulated_payment(payment_id, settings, provider_slug="mock") @router.get("/pay/alipay-sim/{payment_id}", include_in_schema=False) def alipay_sim_payment_page( payment_id: str, settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: _assert_simulated_payment_routes_allowed(settings) return _render_simulated_payment_page( payment_id, settings, provider_slug="alipay-sim" ) @router.post("/pay/alipay-sim/{payment_id}/complete", include_in_schema=False) def complete_alipay_sim_payment( payment_id: str, settings: Settings = Depends(get_settings_dep), ) -> RedirectResponse: _assert_simulated_payment_routes_allowed(settings) return _complete_simulated_payment(payment_id, settings, provider_slug="alipay-sim") @router.get("/portal/payment-return", include_in_schema=False) def payment_return_page( payment_id: str, settings: Settings = Depends(get_settings_dep) ) -> RedirectResponse: service = _payment_service(settings) payment = service.get_payment(payment_id) if payment is None: raise HTTPException(status_code=404, detail="payment not found") if payment.status != "paid": raise HTTPException(status_code=409, detail="payment not ready") portal_token = issue_portal_token(payment.order_id, settings.portal_token_secret) return RedirectResponse( url=f"/portal/{portal_token}/payment-success", status_code=303 ) @router.get("/review/start", include_in_schema=False) def review_start_page( source: Literal["home", "status", "report", "direct"] = "direct", token: str | None = None, province: str | None = None, score: str | None = None, goal: str | None = None, consult: str | None = None, settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: review_input_summary = (consult or goal or "").strip() or None review_constraints: dict[str, Any] = {} if province: review_constraints["candidate_province"] = province.strip() if score: parts = [part.strip() for part in score.replace("/", "/").split("/", 1)] if parts and parts[0]: review_constraints["candidate_score"] = parts[0] if len(parts) > 1 and parts[1]: review_constraints["candidate_rank"] = parts[1] contract = _start_review_result( source=source, token=token, settings=settings, review_input_summary=review_input_summary, review_constraints=review_constraints or None, ) return HTMLResponse(_render_review_start_page(contract, token)) @router.get("/portal/{token}/cwb", include_in_schema=False) def cwb_placeholder_page( token: str, settings: Settings = Depends(get_settings_dep) ) -> HTMLResponse: order = _resolve_order_from_token(token, settings) contract = _load_latest_review_result(order.id, settings) context = _build_portal_context(order, settings) return HTMLResponse(_render_cwb_placeholder_page(token, order, contract, context)) @router.get("/portal/{token}/full-plan", include_in_schema=False) def full_plan_placeholder_page( token: str, settings: Settings = Depends(get_settings_dep) ) -> HTMLResponse: order = _resolve_order_from_token(token, settings) context = _build_portal_context(order, settings) contract = _load_latest_review_result(order.id, settings) return HTMLResponse( _render_full_plan_placeholder_page( token, order, context, contract, settings=settings ) ) @router.get("/portal/{token}/info", include_in_schema=False) def order_info_page( token: str, settings: Settings = Depends(get_settings_dep) ) -> HTMLResponse: order = _resolve_order_from_token(token, settings) intake_store = IntakeStore.for_db(settings.orders_db_path) try: intake = intake_store.get(order.id) finally: intake_store.close() context = _build_portal_context(order, settings) return HTMLResponse( _render_info_page( order, token, intake.payload if intake else {}, context["stage"], settings, ) ) def _assert_portal_info_mutable(stage: str) -> None: if stage == "pending_payment": raise HTTPException(status_code=409, detail="payment required before intake") if stage in { "processing", "report_ready", "completed", "refunded", }: raise HTTPException( status_code=409, detail="intake is read-only at current stage" ) def _sanitize_upload_filename(name: str) -> str: raw = Path(name or "upload.bin").name return raw.replace("/", "_").replace("\\", "_") def _validate_upload( file_name: str, content_type: str | None, payload: bytes, settings: Settings ) -> None: suffix = Path(file_name).suffix.lower() allowed_suffixes = { ".pdf", ".txt", ".md", ".json", ".png", ".jpg", ".jpeg", ".webp", } if suffix not in allowed_suffixes: raise HTTPException( status_code=415, detail=f"unsupported attachment type: {suffix or 'unknown'}", ) if len(payload) > settings.portal_upload_max_bytes: raise HTTPException(status_code=413, detail="attachment too large") if not payload: raise HTTPException(status_code=400, detail="empty attachment") def _store_portal_attachment( *, order_id: str, upload_name: str, content_type: str | None, payload: bytes, settings: Settings, ) -> dict[str, Any]: safe_name = _sanitize_upload_filename(upload_name) _validate_upload(safe_name, content_type, payload, settings) upload_dir = Path(settings.portal_upload_dir) / order_id upload_dir.mkdir(parents=True, exist_ok=True) stored_name = f"{secrets.token_hex(8)}-{safe_name}" target = upload_dir / stored_name target.write_bytes(payload) return { "original_name": safe_name, "stored_name": stored_name, "content_type": content_type or "application/octet-stream", "size_bytes": len(payload), "kind": "portal_attachment", } @router.post("/portal/{token}/attachments", response_model=PortalAttachmentUploaded) def upload_order_attachment( token: str, files: list[UploadFile] = File(...), settings: Settings = Depends(get_settings_dep), ) -> PortalAttachmentUploaded: order = _resolve_order_from_token(token, settings) context = _build_portal_context(order, settings) _assert_portal_info_mutable(context["stage"]) intake_store = IntakeStore.for_db(settings.orders_db_path) uploaded: list[dict[str, Any]] = [] try: current = intake_store.get(order.id) payload = dict(current.payload) if current is not None else {} attachments = list(payload.get("attachments") or []) if len(attachments) + len(files) > settings.portal_upload_max_files: raise HTTPException( status_code=413, detail=f"too many attachments: max {settings.portal_upload_max_files}", ) for file in files: raw = file.file.read() attachment = _store_portal_attachment( order_id=order.id, upload_name=file.filename or "upload.bin", content_type=file.content_type, payload=raw, settings=settings, ) attachments.append(attachment) uploaded.append(attachment) payload["attachments"] = attachments record = intake_store.save( order_id=order.id, payload=payload, submit=(current.status == "submitted") if current is not None else False, ) finally: intake_store.close() refreshed_order = _resolve_order_from_token(token, settings) refreshed_context = _build_portal_context(refreshed_order, settings) return PortalAttachmentUploaded( order_id=order.id, intake_status=record.status, stage=refreshed_context["stage"], attachments=uploaded, ) @router.post("/portal/{token}/info", response_model=PortalIntakeResponse) def submit_order_info( token: str, payload: IntakePayload, settings: Settings = Depends(get_settings_dep), ) -> PortalIntakeResponse: order = _resolve_order_from_token(token, settings) context = _build_portal_context(order, settings) _assert_portal_info_mutable(context["stage"]) intake_store = IntakeStore.for_db(settings.orders_db_path) try: current = intake_store.get(order.id) base_payload = dict(current.payload) if current is not None else {} base_payload.update(payload.model_dump()) saved_payload = _ensure_profile_version_metadata(base_payload) record = intake_store.save( order_id=order.id, payload=saved_payload, submit=payload.mode == "submit", ) finally: intake_store.close() updates: dict[str, Any] = {} if payload.candidate_province is not None: updates["candidate_province"] = payload.candidate_province if payload.candidate_score is not None: updates["candidate_score"] = payload.candidate_score if payload.candidate_rank is not None: updates["candidate_rank"] = payload.candidate_rank if payload.candidate_subjects: updates["candidate_subjects"] = payload.candidate_subjects if payload.candidate_interests is not None: updates["candidate_interests"] = payload.candidate_interests if payload.guardian_notes is not None: updates["notes"] = payload.guardian_notes if updates or (payload.mode == "submit" and order.status == "paid"): with OrdersDAO.connect(settings.orders_db_path) as dao: if updates: dao.update( order.id, updates, actor="portal", reason=f"portal_{record.status}" ) if payload.mode == "submit" and order.status == "paid": dao.transition_status( order.id, "serving", actor="portal", reason="portal_submit_auto_queue", ) refreshed_order = _resolve_order_from_token(token, settings) refreshed_context = _build_portal_context(refreshed_order, settings) return PortalIntakeResponse( intake_status=record.status, stage=refreshed_context["stage"], order_id=order.id, profile_minimum_complete=_is_profile_minimum_complete(record.payload), profile_missing_fields=_profile_missing_fields(record.payload), ) @router.post("/review/action", include_in_schema=False) def review_action_endpoint( token: str = Form(...), action: Literal["cwb", "step1", "full_plan"] = Form(...), settings: Settings = Depends(get_settings_dep), ) -> RedirectResponse: result = _apply_review_followup_action(token, action, settings) return RedirectResponse(url=result.next_href, status_code=303) @router.get("/portal/{token}/deletion-request", include_in_schema=False) def deletion_request_page( token: str, settings: Settings = Depends(get_settings_dep) ) -> HTMLResponse: order = _resolve_order_from_token(token, settings) return HTMLResponse(_render_deletion_request_page(token, order, settings)) @router.post("/portal/{token}/deletion-request", response_model=DeletionRequestCreated) def submit_deletion_request( token: str, payload: DeletionRequestCreate, settings: Settings = Depends(get_settings_dep), ) -> DeletionRequestCreated: order = _resolve_order_from_token(token, settings) _log_deletion_request(order.id, payload, settings) # 2026-06-19 T12-C: next_step 根据订单保留期分文案, 给用户明确预期 retention_status = _resolve_retention_status(order, settings) return DeletionRequestCreated( order_id=order.id, request_logged=True, next_step=_next_step_for_retention(retention_status), ) @router.get("/portal/{token}/notifications", include_in_schema=False) def notification_audit_page( token: str, settings: Settings = Depends(get_settings_dep) ) -> HTMLResponse: order = _resolve_order_from_token(token, settings) notification_service = DeliveryNotificationService.for_db(settings.orders_db_path) try: events = notification_service.list_events(order.id) finally: notification_service.close() return HTMLResponse(_render_notification_audit_page(token, order, events)) def _portal_share_target( *, result_type: str, target_token: str, settings: Settings, ) -> tuple[Order, str]: order = _resolve_order_from_token(target_token, settings) if result_type == "review_result": contract = _load_latest_review_result(order.id, settings) if contract is None: raise BusinessError( DATA_NOT_FOUND, detail={"reason": "review_result not found"}, ) return order, contract.review_result_id if result_type == "report": if order.status not in {"delivered", "completed"} or not order.audit_report: raise BusinessError( DATA_NOT_FOUND, detail={"reason": "report not ready"}, ) return order, order.id raise BusinessError( DATA_VALIDATION_FAILED, detail={"reason": "result_type must be review_result or report"}, ) def _latest_portal_share_link( *, svc: ShortLinkService, target_id: str, result_type: str, order_id: str, ) -> Any | None: prefix = f"{result_type}:{order_id}" links = svc.list_by_report(target_id) active = [ link for link in links if str(link.note or "") == prefix and not link.revoked and not link.is_expired() ] if active: return active[0] for link in links: if str(link.note or "") == prefix: return link return None def _portal_share_response( *, request: Request, link: Any, result_type: str, target_id: str, stats: dict[str, Any] | None = None, ) -> dict[str, Any]: payload = { "code": link.code, "share_url": build_url(link.code, base=str(request.base_url).rstrip("/")), "permission": link.permission, "result_type": result_type, "target_id": target_id, "expires_at_iso": link.to_dict().get("expires_at_iso"), "revoked": bool(link.revoked), "access_count": link.access_count, "last_access_at_iso": link.to_dict().get("last_access_at_iso"), } if stats is not None: payload["stats"] = stats return payload @router.get("/portal/share-link/latest") def portal_latest_share_link( result_type: str, target_token: str, request: Request, settings: Settings = Depends(get_settings_dep), ) -> dict[str, Any]: order, target_id = _portal_share_target( result_type=result_type, target_token=target_token, settings=settings, ) svc = ShortLinkService(db_path=settings.share_db_path) link = _latest_portal_share_link( svc=svc, target_id=target_id, result_type=result_type, order_id=order.id, ) if link is None: raise BusinessError( DATA_NOT_FOUND, detail={"reason": "share_link not found", "target_id": target_id}, ) return _portal_share_response( request=request, link=link, result_type=result_type, target_id=target_id, stats=svc.get_stats(link.code), ) @router.post("/portal/share-link", status_code=201) def portal_create_share_link( payload: dict[str, Any], request: Request, settings: Settings = Depends(get_settings_dep), ) -> dict[str, Any]: result_type = str(payload.get("result_type") or "").strip() target_token = str(payload.get("target_token") or "").strip() permission = str(payload.get("permission") or "read").strip() ttl_days_raw = payload.get("ttl_days") replace_existing = bool(payload.get("replace_existing")) if not target_token: raise BusinessError( DATA_VALIDATION_FAILED, detail={"reason": "target_token is required"}, ) ttl_days: int | None = None if ttl_days_raw is not None: try: ttl_days = int(ttl_days_raw) except (TypeError, ValueError) as exc: raise BusinessError( DATA_VALIDATION_FAILED, detail={"reason": "ttl_days must be integer"}, ) from exc if ttl_days <= 0: raise BusinessError( DATA_VALIDATION_FAILED, detail={"reason": "ttl_days must be > 0"}, ) order, target_id = _portal_share_target( result_type=result_type, target_token=target_token, settings=settings, ) svc = ShortLinkService(db_path=settings.share_db_path) existing = _latest_portal_share_link( svc=svc, target_id=target_id, result_type=result_type, order_id=order.id, ) if replace_existing and existing is not None and not existing.revoked: svc.revoke(existing.code) link = svc.create( report_id=target_id, owner_id=f"portal:{order.id}", permission=permission, ttl_days=ttl_days, note=f"{result_type}:{order.id}", ) return _portal_share_response( request=request, link=link, result_type=result_type, target_id=target_id, ) @router.post("/portal/share-link/{code}/revoke") def portal_revoke_share_link( code: str, payload: dict[str, Any], request: Request, settings: Settings = Depends(get_settings_dep), ) -> dict[str, Any]: result_type = str(payload.get("result_type") or "").strip() target_token = str(payload.get("target_token") or "").strip() order, target_id = _portal_share_target( result_type=result_type, target_token=target_token, settings=settings, ) svc = ShortLinkService(db_path=settings.share_db_path) link = _latest_portal_share_link( svc=svc, target_id=target_id, result_type=result_type, order_id=order.id, ) if link is None or link.code != code: raise BusinessError(DATA_NOT_FOUND, detail={"code": code}) changed = svc.revoke(code) current = svc.get(code) if current is None: raise BusinessError(DATA_NOT_FOUND, detail={"code": code}) payload = _portal_share_response( request=request, link=current, result_type=result_type, target_id=target_id, stats=svc.get_stats(code), ) payload["changed"] = changed return payload @router.get("/portal/{token}/status", include_in_schema=False) def order_status_page( token: str, settings: Settings = Depends(get_settings_dep) ) -> HTMLResponse: order = _resolve_order_from_token(token, settings) context = _build_portal_context(order, settings) return HTMLResponse(_render_status_page(token, context, settings)) @router.get("/portal/{token}/report", include_in_schema=False) def report_view_page( token: str, settings: Settings = Depends(get_settings_dep) ) -> HTMLResponse: order = _resolve_order_from_token(token, settings) context = _build_portal_context(order, settings) if context["stage"] not in {"report_ready", "completed"}: raise HTTPException(status_code=409, detail="report not ready") return HTMLResponse(_render_report_page(order, settings)) @router.get("/portal/{token}/report.pdf", include_in_schema=False) def report_pdf_download( token: str, settings: Settings = Depends(get_settings_dep) ) -> FileResponse: order = _resolve_order_from_token(token, settings) context = _build_portal_context(order, settings) if context["stage"] not in {"report_ready", "completed"}: raise HTTPException(status_code=409, detail="report not ready") if ( not order.pdf_path or not _is_trusted_report_path(order.pdf_path, settings) or not Path(order.pdf_path).is_file() ): raise HTTPException(status_code=404, detail="pdf not found") return FileResponse( order.pdf_path, media_type="application/pdf", filename=Path(order.pdf_path).name ) def _payment_service(settings: Settings) -> PaymentService: return PaymentService.for_db( settings.orders_db_path, base_url=settings.payment_base_url, webhook_secret=settings.payment_webhook_secret, provider_name=settings.payment_provider, notify_url=settings.payment_notify_url, return_url=settings.payment_return_url, app_id=settings.payment_app_id, merchant_id=settings.payment_merchant_id, private_key_path=settings.payment_private_key_path, alipay_public_key_path=settings.payment_alipay_public_key_path, ) def _resolve_order_from_token(token: str, settings: Settings) -> Order: try: payload = verify_portal_token(token, settings.portal_token_secret) except PortalTokenError as exc: raise HTTPException(status_code=401, detail=str(exc)) from exc with OrdersDAO.connect(settings.orders_db_path) as dao: try: return dao.get(str(payload["order_id"])) except OrderNotFound as exc: raise HTTPException(status_code=404, detail="order not found") from exc def _is_trusted_report_path(path: str | None, settings: Settings) -> bool: if not path: return False candidate = Path(path).resolve() trusted_roots = ( Path(settings.share_report_dir).resolve(), ( Path(settings.portal_upload_dir).resolve().parent / "order_artifacts" ).resolve(), Path("data/examples").resolve(), ) return any(root == candidate or root in candidate.parents for root in trusted_roots) def _build_portal_context(order: Order, settings: Settings) -> dict[str, Any]: payment_service = _payment_service(settings) payment = payment_service.get_payment_by_order(order.id) intake_store = IntakeStore.for_db(settings.orders_db_path) try: intake = intake_store.get(order.id) finally: intake_store.close() notification_service = DeliveryNotificationService.for_db(settings.orders_db_path) try: sent_station_events = notification_service.list_events( order.id, status="validated", channel="station", ) if not sent_station_events: sent_station_events = notification_service.list_events( order.id, status="delivered", channel="station", ) finally: notification_service.close() stage = "pending_payment" report_html_ready = bool( order.audit_report and _is_trusted_report_path(order.audit_report, settings) and Path(order.audit_report).is_file() ) report_pdf_ready = bool( order.pdf_path and _is_trusted_report_path(order.pdf_path, settings) and Path(order.pdf_path).is_file() ) report_artifacts_ready = report_html_ready and report_pdf_ready if order.status == "refunded" or ( payment is not None and payment.status == "refunded" ): stage = "refunded" elif payment is None or payment.status in {"pending", "failed"}: stage = "pending_payment" elif order.status == "completed": stage = "completed" elif order.status == "delivered" and report_artifacts_ready: stage = "report_ready" elif order.status in {"serving", "delivered"}: stage = "processing" elif intake is None or intake.status == "draft": stage = "info_required" elif intake.status == "submitted": stage = "info_submitted" title, subtitle = _STAGE_META[stage] latest_station_notice: dict[str, Any] | None = None intake_summary = None attachment_count = 0 attachment_items: list[dict[str, Any]] = [] profile_payload = ( intake.payload if intake is not None else { "candidate_province": order.candidate_province, "candidate_score": order.candidate_score, "candidate_rank": order.candidate_rank, "candidate_subjects": order.candidate_subjects, } ) if intake is not None: attachment_items = [ item for item in list((intake.payload or {}).get("attachments") or []) if isinstance(item, dict) ] attachment_count = len(attachment_items) intake_summary = { "candidate_province": intake.payload.get("candidate_province") or order.candidate_province, "candidate_score": intake.payload.get("candidate_score"), "candidate_rank": intake.payload.get("candidate_rank"), "candidate_subjects": intake.payload.get("candidate_subjects") or [], "candidate_interests": intake.payload.get("candidate_interests"), "target_cities": intake.payload.get("target_cities") or [], "target_majors": intake.payload.get("target_majors") or [], "university_preferences": intake.payload.get("university_preferences"), "existing_plan_summary": intake.payload.get("existing_plan_summary"), "guardian_notes": intake.payload.get("guardian_notes"), } if sent_station_events: try: latest_payload = json.loads(sent_station_events[-1].payload_json) except json.JSONDecodeError: latest_payload = {} station_notice = latest_payload.get("station_notice") if isinstance(station_notice, dict): latest_station_notice = station_notice return { "stage": stage, "stage_title": title, "stage_subtitle": subtitle, "payment": payment, "payment_status": payment.status if payment is not None else "pending", "intake": intake, "intake_summary": intake_summary, "attachment_count": attachment_count, "attachments": attachment_items, "report_html_ready": report_html_ready, "report_pdf_ready": report_pdf_ready, "order": order, "station_notice": latest_station_notice, "profile_minimum_complete": _is_profile_minimum_complete(profile_payload), "profile_missing_fields": _profile_missing_fields(profile_payload), } def _render_global_nav() -> str: """全局统一导航栏。所有页面必须注入。""" return '' def _render_global_toast_script() -> str: """全局 toast + loading 注入脚本。在 {_render_global_toast_script()}
{_render_global_nav()}{nav}{lead}
前注入。""" return """
""" def _render_footer_links(token: str | None = None) -> str: privacy_href = "/privacy" terms_href = "/service-terms" if token: privacy_href = "/privacy" terms_href = "/service-terms" deletion_href = f"/portal/{escape(token)}/deletion-request" else: deletion_href = "/deletion-policy" return ( f'" ) def _render_share_status_panel( *, result_type: str, token: str, settings: Settings | None = None ) -> str: """渲染"当前分享状态"面板。 settings 可选;不传则现场加载。即使查询失败也会渲染骨架。 """ if not token or result_type not in {"review_result", "report"}: return "" latest_info: dict[str, Any] | None = None s = settings if s is None: try: from admin.config import load_settings as _load_settings s = _load_settings() except Exception: # noqa: BLE001 s = None if s is not None: try: order, target_id = _portal_share_target( result_type=result_type, target_token=token, settings=s, ) svc = ShortLinkService(db_path=s.share_db_path) chosen = _latest_portal_share_link( svc=svc, target_id=target_id, result_type=result_type, order_id=order.id, ) if chosen is not None: d = chosen.to_dict() stats = svc.get_stats(chosen.code) or {} latest_info = { "permission": chosen.permission, "expires_at_iso": d.get("expires_at_iso"), "revoked": bool(chosen.revoked), "expired": chosen.is_expired(), "access_count": int(stats.get("access_count") or 0), "last_access_at_iso": stats.get("last_access_at_iso"), } except Exception: # noqa: BLE001 latest_info = None if latest_info is None: status_line = '
' else: perm = str(latest_info.get("permission") or "read") suffix_parts: list[str] = [] if latest_info.get("revoked"): suffix_parts.append("已撤销") elif latest_info.get("expired"): suffix_parts.append("已过期") elif latest_info.get("expires_at_iso"): suffix_parts.append("7天后过期") status_line = f'
" access_count = int(latest_info.get("access_count") or 0) last_access = latest_info.get("last_access_at_iso") status_line += ( f'
" ) escaped_token = escape(token) escaped_result_type = escape(result_type) latest_query = f"/portal/share-link/latest?result_type={escaped_result_type}&target_token={escaped_token}" return f""" """ def _render_share_link_script( *, result_type: str, token: str, copy_id: str, share_id: str, status_id: str, title: str, ) -> str: escaped_title = escape(title) escaped_token = escape(token) escaped_result_type = escape(result_type) escaped_copy = escape(copy_id) escaped_share = escape(share_id) escaped_status = escape(status_id) return f"""""" def _render_basic_page( *, title: str, eyebrow: str, lead: str, sections_html: str, footer_token: str | None = None, ) -> str: nav = '' back_link = '
' return ( "
" "" f"
" "" '' f"