Files
gaokao-volunteer-system/admin/routes/web_public.py
Hermes Agent d24901b88e
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(t12): expand structured intake wizard and status summary
2026-06-15 17:08:25 +08:00

1040 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""用户端 Web 自助 MVP 公共入口页面与 Portal 路由。"""
from __future__ import annotations
import json
import logging
import secrets
from html import escape
from pathlib import Path
from typing import Any, Literal
from urllib.parse import parse_qsl
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from fastapi.responses import (
FileResponse,
HTMLResponse,
PlainTextResponse,
RedirectResponse,
)
from pydantic import BaseModel
from admin.config import Settings, get_settings_dep
from data.customer_portal.token import (
PortalTokenError,
issue_portal_token,
verify_portal_token,
)
from data.notifications.email_service import DeliveryNotificationService
from data.orders.dao import OrderNotFound, OrdersDAO
from data.orders.intake_schema import IntakePayload
from data.orders.intake_store import IntakeStore
from data.orders.models import Order
from data.orders.public_flow import (
PublicOrderCreate,
create_public_order,
)
from data.payments.service import PaymentError, PaymentService
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": ("已完成", "订单已完成,后续可继续查看历史交付内容。"),
"refund_pending": ("退款申请中", "退款申请已登记,等待处理结果。"),
"refunded": ("已退款", "该订单已完成退款。"),
"payment_failed": ("支付失败", "支付未成功,请重新发起支付。"),
}
_SERVICE_PRICES = {
"audit": 4900,
"basic": 4900,
"standard": 9900,
"premium": 19900,
}
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
class PortalAttachmentUploaded(BaseModel):
order_id: str
intake_status: str
stage: str
attachment: dict[str, Any]
@router.get("/", include_in_schema=False)
def landing_page() -> HTMLResponse:
return HTMLResponse(_render_landing_page())
@router.get("/pricing", include_in_schema=False)
def pricing_page() -> HTMLResponse:
return HTMLResponse(_render_pricing_page())
@router.get("/checkout/{service_version}", include_in_schema=False)
def checkout_page(service_version: ServiceVersion) -> HTMLResponse:
return HTMLResponse(_render_checkout_page(service_version))
@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
with OrdersDAO.connect(settings.orders_db_path) as dao:
order = create_public_order(dao, payload)
portal_token = issue_portal_token(order.id, settings.portal_token_secret)
try:
checkout = payment_service.create_checkout(order.id, portal_token=portal_token)
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:
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,
token: str,
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
return _render_simulated_payment_page(
payment_id, token, settings, provider_slug="mock"
)
@router.post("/pay/mock/{payment_id}/complete", include_in_schema=False)
def complete_mock_payment(
payment_id: str,
token: str,
settings: Settings = Depends(get_settings_dep),
) -> RedirectResponse:
return _complete_simulated_payment(
payment_id, token, settings, provider_slug="mock"
)
@router.get("/pay/alipay-sim/{payment_id}", include_in_schema=False)
def alipay_sim_payment_page(
payment_id: str,
token: str,
settings: Settings = Depends(get_settings_dep),
) -> HTMLResponse:
return _render_simulated_payment_page(
payment_id, token, 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,
token: str,
settings: Settings = Depends(get_settings_dep),
) -> RedirectResponse:
return _complete_simulated_payment(
payment_id, token, settings, provider_slug="alipay-sim"
)
@router.get("/portal/payment-return", include_in_schema=False)
def payment_return_page(
token: str, settings: Settings = Depends(get_settings_dep)
) -> RedirectResponse:
_resolve_order_from_token(token, settings)
return RedirectResponse(url=f"/portal/{token}/status", status_code=303)
@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"]
)
)
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",
"refund_pending",
"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),
"storage_path": str(target),
"kind": "portal_attachment",
}
@router.post("/portal/{token}/attachments", response_model=PortalAttachmentUploaded)
def upload_order_attachment(
token: str,
file: 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"])
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,
)
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
current = intake_store.get(order.id)
payload = dict(current.payload) if current is not None else {}
attachments = list(payload.get("attachments") or [])
attachments.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"],
attachment=attachment,
)
@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:
record = intake_store.save(
order_id=order.id,
payload=payload.model_dump(),
submit=payload.mode == "submit",
)
finally:
intake_store.close()
updates: dict[str, Any] = {}
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,
)
@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))
@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))
@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 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,
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 _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 Path(order.audit_report).is_file())
report_pdf_ready = bool(order.pdf_path and Path(order.pdf_path).is_file())
report_artifacts_ready = report_html_ready and report_pdf_ready
if payment is not None and payment.status == "refund_pending":
stage = "refund_pending"
elif order.status == "refunded" or (
payment is not None and payment.status == "refunded"
):
stage = "refunded"
elif payment is not None and payment.status == "failed":
stage = "payment_failed"
elif payment is None or payment.status == "pending":
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
if intake is not None:
attachment_count = len(list((intake.payload or {}).get("attachments") or []))
intake_summary = {
"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,
"intake": intake,
"intake_summary": intake_summary,
"attachment_count": attachment_count,
"report_html_ready": report_html_ready,
"report_pdf_ready": report_pdf_ready,
"order": order,
"station_notice": latest_station_notice,
}
def _render_landing_page() -> str:
return """<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>高考志愿填报智能系统 - 用户端 Web 自助服务</title>
<style>
body { margin: 0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background: linear-gradient(180deg,#0b1020,#121a31 65%,#f7f9fc 65%); color: #e8edf7; }
.wrap { max-width: 1080px; margin: 0 auto; padding: 48px 20px 72px; }
.hero { display: grid; gap: 20px; }
h1 { margin: 0; font-size: 42px; }
.sub { color: #9fb0d0; line-height: 1.7; }
.btn { display:inline-flex; padding:12px 18px; border-radius:12px; text-decoration:none; font-weight:700; }
.btn-primary { background:#7c9cff; color:#fff; }
.btn-secondary { border:1px solid rgba(159,176,208,.18); color:#fff; }
.grid { display:grid; gap:16px; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-top:28px; }
.card { background:rgba(18,26,49,.84); border:1px solid rgba(159,176,208,.18); border-radius:18px; padding:20px; }
.section { background:#f7f9fc; color:#172033; border-radius:28px; padding:28px; margin-top:32px; }
</style>
</head>
<body>
<main class=\"wrap\">
<section class=\"hero\">
<h1>高考志愿填报智能系统</h1>
<p class=\"sub\">用户端 Web 自助服务已接入公开套餐、下单、Mock 支付沙箱、资料填写、订单状态页与报告查看/下载链路。</p>
<div>
<a class=\"btn btn-primary\" href=\"/pricing\">查看服务套餐</a>
<a class=\"btn btn-secondary\" href=\"/dashboard\">进入运营后台</a>
</div>
<div class=\"grid\">
<article class=\"card\"><strong>49元 AI方案审核</strong><p class=\"sub\">适合已拿到其他 AI 方案,需要快速校验政策、扎堆和数据来源的用户。</p></article>
<article class=\"card\"><strong>99元 完整志愿方案</strong><p class=\"sub\">适合需要从资料收集到完整方案输出的标准 Web 自助用户。</p></article>
<article class=\"card\"><strong>199元 深度辅导版</strong><p class=\"sub\">适合需要人工介入、反复沟通和多轮修订的复杂场景。</p></article>
</div>
</section>
<section class=\"section\">
<h2>当前自助流程建设重点</h2>
<ul>
<li>公开套餐页与下单入口</li>
<li>系统内支付与回调验签</li>
<li>支付后资料填写与订单状态页</li>
<li>站内查看报告 + PDF 交付</li>
</ul>
</section>
</main>
</body>
</html>
"""
def _render_pricing_page() -> str:
return """<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>服务套餐 - 高考志愿填报智能系统</title>
<style>
body { margin: 0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:#f4f7fb; color:#172033; }
.wrap { max-width:1080px; margin:0 auto; padding:40px 20px 72px; }
.grid { display:grid; gap:18px; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); margin-top:28px; }
.card { background:#fff; border:1px solid #dbe3f0; border-radius:18px; padding:22px; box-shadow:0 10px 30px rgba(23,32,51,.06); }
.price { font-size:30px; font-weight:800; margin:10px 0; }
.button { display:inline-flex; margin-top:16px; padding:11px 16px; border-radius:12px; background:#1f6feb; color:#fff; text-decoration:none; font-weight:700; }
.notice { margin-top:26px; padding:16px 18px; border-radius:14px; background:#fff7e6; color:#8a5a00; border:1px solid #f4d39b; }
</style>
</head>
<body>
<main class=\"wrap\">
<h1>服务套餐</h1>
<p>当前页面已接入最小 Web 自助闭环,可直接进入下单与支付沙箱。</p>
<section class=\"grid\">
<article class=\"card\" data-package=\"audit\">
<h2>49元 AI方案审核</h2>
<div class=\"price\">¥49</div>
<a class=\"button\" href=\"/checkout/audit\">立即下单</a>
</article>
<article class=\"card\" data-package=\"standard\">
<h2>99元 完整志愿方案</h2>
<div class=\"price\">¥99</div>
<a class=\"button\" href=\"/checkout/standard\">立即下单</a>
</article>
<article class=\"card\" data-package=\"premium\">
<h2>199元 深度辅导版</h2>
<div class=\"price\">¥199</div>
<a class=\"button\" href=\"/checkout/premium\">立即下单</a>
</article>
</section>
<div class=\"notice\">支付接入建设中:当前使用 Mock 支付沙箱完成本地闭环验证,后续可切换真实 provider。</div>
</main>
</body>
</html>
"""
def _render_checkout_page(service_version: str) -> str:
amount = _SERVICE_PRICES[service_version]
service_label = {
"audit": "49元 AI方案审核",
"basic": "49元 基础版",
"standard": "99元 完整志愿方案",
"premium": "199元 深度辅导版",
}[service_version]
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>下单 - {escape(service_label)}</title>
<style>
body {{ font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:#f4f7fb; color:#172033; margin:0; }}
.wrap {{ max-width:760px; margin:0 auto; padding:32px 20px; }}
.panel {{ background:#fff; border:1px solid #dbe3f0; border-radius:18px; padding:22px; }}
input, textarea {{ width:100%; padding:12px; border-radius:10px; border:1px solid #cfd7e6; margin-top:8px; margin-bottom:14px; }}
button {{ border:none; border-radius:12px; background:#1f6feb; color:#fff; font-weight:700; padding:12px 18px; cursor:pointer; }}
</style>
</head>
<body>
<main class=\"wrap\">
<section class=\"panel\">
<h1>{escape(service_label)}</h1>
<p>提交后会自动创建订单并跳转到支付页。</p>
<form id=\"checkout-form\">
<label>家长称呼<input name=\"customer_name\" required /></label>
<label>手机号<input name=\"customer_phone\" required /></label>
<label>邮箱(用于接收邮件通知)<input name=\"customer_email\" type=\"email\" /></label>
<label>考生姓名<input name=\"candidate_name\" /></label>
<label>省份<input name=\"candidate_province\" value=\"湖南\" required /></label>
<label>备注<textarea name=\"notes\"></textarea></label>
<button type=\"submit\">创建订单并去支付(¥{amount / 100:.0f}</button>
</form>
<p id=\"result\"></p>
</section>
</main>
<script>
document.getElementById('checkout-form').addEventListener('submit', async function (event) {{
event.preventDefault();
const form = new FormData(event.target);
const payload = {{
service_version: '{service_version}',
amount_cents: {amount},
customer_name: form.get('customer_name'),
customer_phone: form.get('customer_phone'),
customer_email: form.get('customer_email') || null,
candidate_name: form.get('candidate_name'),
candidate_province: form.get('candidate_province'),
notes: form.get('notes'),
}};
const resp = await fetch('/api/public/orders', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(payload),
}});
const body = await resp.json();
if (!resp.ok) {{
document.getElementById('result').textContent = body.message || body.detail || '下单失败';
return;
}}
window.location.href = body.checkout_url;
}});
</script>
</body>
</html>
"""
def _render_mock_payment_page(payment_id: str, amount_cents: int, token: str) -> str:
return _render_simulated_payment_html(
title="Mock 支付沙箱",
provider_slug="mock",
payment_id=payment_id,
amount_cents=amount_cents,
token=token,
submit_label="模拟支付成功并返回订单页",
)
def _render_alipay_sim_payment_page(
payment_id: str, amount_cents: int, token: str
) -> str:
return _render_simulated_payment_html(
title="支付宝模拟收银台",
provider_slug="alipay-sim",
payment_id=payment_id,
amount_cents=amount_cents,
token=token,
submit_label="模拟支付宝支付成功并返回订单页",
)
def _render_simulated_payment_html(
*,
title: str,
provider_slug: str,
payment_id: str,
amount_cents: int,
token: str,
submit_label: str,
) -> str:
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>{escape(title)}</title></head>
<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f7fb;padding:24px;\">
<main style=\"max-width:640px;margin:0 auto;background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;\">
<h1>{escape(title)}</h1>
<p>订单支付单号:{escape(payment_id)}</p>
<p>支付金额:¥{amount_cents / 100:.2f}</p>
<form method=\"post\" action=\"/pay/{escape(provider_slug)}/{escape(payment_id)}/complete?token={escape(token)}\">
<button type=\"submit\" style=\"border:none;border-radius:12px;background:#1f6feb;color:#fff;font-weight:700;padding:12px 18px;cursor:pointer;\">{escape(submit_label)}</button>
</form>
</main>
</body>
</html>
"""
def _render_simulated_payment_page(
payment_id: str,
token: str,
settings: Settings,
*,
provider_slug: str,
) -> HTMLResponse:
service = PaymentService.for_db(
settings.orders_db_path,
base_url=settings.payment_base_url,
webhook_secret=settings.payment_webhook_secret,
provider_name=settings.payment_provider,
)
payment = service.get_payment(payment_id)
if payment is None:
raise HTTPException(status_code=404, detail="payment not found")
if provider_slug == "mock":
body = _render_mock_payment_page(payment_id, payment.amount_cents, token)
elif provider_slug == "alipay-sim":
body = _render_alipay_sim_payment_page(payment_id, payment.amount_cents, token)
else:
raise HTTPException(
status_code=404, detail="unsupported simulated payment page"
)
return HTMLResponse(body)
def _complete_simulated_payment(
payment_id: str,
token: str,
settings: Settings,
*,
provider_slug: str,
) -> RedirectResponse:
service = PaymentService.for_db(
settings.orders_db_path,
base_url=settings.payment_base_url,
webhook_secret=settings.payment_webhook_secret,
provider_name=settings.payment_provider,
)
payment = service.get_payment(payment_id)
if payment is None:
raise HTTPException(status_code=404, detail="payment not found")
try:
portal_payload = verify_portal_token(token, settings.portal_token_secret)
except PortalTokenError as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
if (
str(portal_payload["order_id"]) != payment.order_id
or payment.checkout_token != token
):
raise HTTPException(status_code=403, detail="payment token mismatch")
payload, headers = service.provider.build_webhook_request(
payment_id=payment.id,
amount_cents=payment.amount_cents,
provider_trade_no=f"SIM-{payment.id}",
)
if provider_slug == "mock":
signature = headers["X-Mock-Signature"]
elif provider_slug == "alipay-sim":
signature = headers["X-Alipay-Sim-Signature"]
else:
raise HTTPException(
status_code=404, detail="unsupported simulated payment completion"
)
service.handle_webhook(payload, signature)
return RedirectResponse(url=f"/portal/{token}/status", status_code=303)
def _render_info_page(
order: Order, token: str, payload: dict[str, Any], stage: str
) -> str:
consent_version = str(payload.get("consent_version") or "t12-web-mvp-v1")
consent_scope = str(payload.get("consent_scope") or "web-self-service-order-intake")
privacy_checked = "checked" if payload.get("privacy_accepted") else ""
service_terms_checked = "checked" if payload.get("service_terms_accepted") else ""
guardian_checked = "checked" if payload.get("guardian_confirmed") else ""
attachments = payload.get("attachments") or []
attachment_items = []
for item in attachments:
if not isinstance(item, dict):
continue
attachment_items.append(
f"<li>{escape(str(item.get('original_name') or item.get('stored_name') or '未命名附件'))}"
f" ({escape(str(item.get('size_bytes') or '0'))} bytes)</li>"
)
attachments_html = "".join(attachment_items) or "<li>暂无附件</li>"
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>考生资料填写</title></head>
<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f7fb;padding:24px;\">
<main style=\"max-width:760px;margin:0 auto;background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;\">
<h1>考生资料填写</h1>
<p>当前阶段:{escape(_STAGE_META[stage][0])}</p>
<p style=\"background:#fff7e6;border:1px solid #f4d39b;border-radius:12px;padding:12px 14px;color:#8a5a00;\">提交资料即表示:监护人已知情并同意将考生资料用于志愿填报服务;当前版本号:{escape(consent_version)}</p>
<form id=\"intake-form\">
<label>高考分数<input name=\"candidate_score\" value=\"{escape(str(payload.get("candidate_score") or ""))}\" /></label><br/>
<label>位次<input name=\"candidate_rank\" value=\"{escape(str(payload.get("candidate_rank") or ""))}\" /></label><br/>
<label>选科(逗号分隔)<input name=\"candidate_subjects\" value=\"{escape(",".join(payload.get("candidate_subjects") or []))}\" /></label><br/>
<label>兴趣方向<input name=\"candidate_interests\" value=\"{escape(str(payload.get("candidate_interests") or ""))}\" /></label><br/>
<label>目标城市(逗号分隔)<input name=\"target_cities\" value=\"{escape(",".join(payload.get("target_cities") or []))}\" /></label><br/>
<label>目标专业(逗号分隔)<input name=\"target_majors\" value=\"{escape(",".join(payload.get("target_majors") or []))}\" /></label><br/>
<label>院校偏好说明<textarea name=\"university_preferences\">{escape(str(payload.get("university_preferences") or ""))}</textarea></label><br/>
<label>已有方案说明<textarea name=\"existing_plan_summary\">{escape(str(payload.get("existing_plan_summary") or ""))}</textarea></label><br/>
<label>家长备注<textarea name=\"guardian_notes\">{escape(str(payload.get("guardian_notes") or ""))}</textarea></label><br/>
<input type=\"hidden\" name=\"consent_version\" value=\"{escape(consent_version)}\" />
<input type=\"hidden\" name=\"consent_scope\" value=\"{escape(consent_scope)}\" />
<label><input type=\"checkbox\" name=\"privacy_accepted\" {privacy_checked} /> 我已阅读并同意隐私政策草案</label><br/>
<label><input type=\"checkbox\" name=\"service_terms_accepted\" {service_terms_checked} /> 我已阅读并同意服务说明与免责声明</label><br/>
<label><input type=\"checkbox\" name=\"guardian_confirmed\" {guardian_checked} /> 我确认监护人已知情并同意提交资料</label><br/>
<button type=\"button\" onclick=\"submitIntake('draft')\">保存草稿</button>
<button type=\"button\" onclick=\"submitIntake('submit')\">提交资料</button>
</form>
<section style=\"margin-top:16px;padding:12px;border:1px solid #dbe3f0;border-radius:12px;\">
<h2>已上传附件</h2>
<ul>{attachments_html}</ul>
<form id=\"attachment-form\">
<input type=\"file\" name=\"file\" />
<button type=\"button\" onclick=\"uploadAttachment()\">上传 AI 方案 / 资料附件</button>
</form>
</section>
<p><a href=\"/portal/{escape(token)}/status\">返回订单状态页</a></p>
<pre id=\"result\"></pre>
</main>
<script>
async function submitIntake(mode) {{
const form = new FormData(document.getElementById('intake-form'));
const subjects = String(form.get('candidate_subjects') || '').split(',').map(s => s.trim()).filter(Boolean);
const targetCities = String(form.get('target_cities') || '').split(',').map(s => s.trim()).filter(Boolean);
const targetMajors = String(form.get('target_majors') || '').split(',').map(s => s.trim()).filter(Boolean);
const payload = {{
mode,
candidate_score: form.get('candidate_score') ? Number(form.get('candidate_score')) : null,
candidate_rank: form.get('candidate_rank') ? Number(form.get('candidate_rank')) : null,
candidate_subjects: subjects,
candidate_interests: form.get('candidate_interests') || null,
target_cities: targetCities,
target_majors: targetMajors,
university_preferences: form.get('university_preferences') || null,
existing_plan_summary: form.get('existing_plan_summary') || null,
guardian_notes: form.get('guardian_notes') || null,
consent_version: form.get('consent_version') || null,
consent_scope: form.get('consent_scope') || null,
privacy_accepted: form.get('privacy_accepted') === 'on',
service_terms_accepted: form.get('service_terms_accepted') === 'on',
guardian_confirmed: form.get('guardian_confirmed') === 'on',
}};
const resp = await fetch('/portal/{escape(token)}/info', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(payload),
}});
const body = await resp.json();
document.getElementById('result').textContent = JSON.stringify(body, null, 2);
if (resp.ok && mode === 'submit') window.location.href = '/portal/{escape(token)}/status';
}}
async function uploadAttachment() {{
const form = document.getElementById('attachment-form');
const data = new FormData(form);
const file = data.get('file');
if (!file || !(file instanceof File) || !file.name) {{
document.getElementById('result').textContent = '请先选择一个附件';
return;
}}
const resp = await fetch('/portal/{escape(token)}/attachments', {{
method: 'POST',
body: data,
}});
const body = await resp.json();
document.getElementById('result').textContent = JSON.stringify(body, null, 2);
if (resp.ok) window.location.reload();
}}
</script>
</body>
</html>
"""
def _render_status_page(token: str, context: dict[str, Any]) -> str:
order = context["order"]
report_links = ""
can_access_delivery = context["stage"] in {"report_ready", "completed"}
if can_access_delivery and context["report_html_ready"]:
report_links += (
f'<li><a href="/portal/{escape(token)}/report">查看在线报告</a></li>'
)
if can_access_delivery and context["report_pdf_ready"]:
report_links += (
f'<li><a href="/portal/{escape(token)}/report.pdf">下载 PDF</a></li>'
)
if not report_links:
report_links = "<li>报告生成中,交付物就绪后这里会显示查看/下载入口。</li>"
station_notice_html = ""
station_notice = context.get("station_notice")
if isinstance(station_notice, dict):
title = escape(str(station_notice.get("title") or "站内通知"))
body = escape(str(station_notice.get("body") or ""))
sent_at = escape(
str(
station_notice.get("sent_at")
or station_notice.get("delivered_at")
or ""
)
)
station_notice_html = f"""
<section style=\"background:#eef6ff;border:1px solid #b9d7ff;border-radius:18px;padding:24px;\">
<h2>通知已发送</h2>
<p><strong>{title}</strong></p>
<p>{body}</p>
<p style=\"color:#4f6480;font-size:14px;\">发送时间:{sent_at}</p>
</section>"""
summary_html = ""
intake_summary = context.get("intake_summary") or {}
attachment_count = int(context.get("attachment_count") or 0)
if intake_summary:
summary_items = [
f"<li>分数:{escape(str(intake_summary.get('candidate_score') or '-'))}</li>",
f"<li>位次:{escape(str(intake_summary.get('candidate_rank') or '-'))}</li>",
f"<li>选科:{escape(','.join(intake_summary.get('candidate_subjects') or [])) or '-'}</li>",
f"<li>兴趣方向:{escape(str(intake_summary.get('candidate_interests') or '-'))}</li>",
f"<li>目标城市:{escape(','.join(intake_summary.get('target_cities') or [])) or '-'}</li>",
f"<li>目标专业:{escape(','.join(intake_summary.get('target_majors') or [])) or '-'}</li>",
f"<li>院校偏好:{escape(str(intake_summary.get('university_preferences') or '-'))}</li>",
f"<li>已有方案说明:{escape(str(intake_summary.get('existing_plan_summary') or '-'))}</li>",
f"<li>附件数量:{attachment_count}</li>",
]
summary_html = f"""
<section style=\"background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;\">
<h2>当前资料摘要</h2>
<ul>{''.join(summary_items)}</ul>
</section>"""
return f"""<!doctype html>
<html lang=\"zh-CN\"><head><meta charset=\"utf-8\" /><title>订单状态</title></head>
<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f7fb;margin:0;padding:32px 20px;color:#172033;\">
<main style=\"max-width:760px;margin:0 auto;display:grid;gap:16px;\">
<section style=\"background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;\">
<h1>{escape(context["stage_title"])}</h1>
<p>{escape(context["stage_subtitle"])}</p>
<p>订单号:{escape(order.id)}</p>
<p>服务版本:{escape(order.service_version)}</p>
<p>当前订单状态:{escape(order.status)}</p>
</section>
{summary_html}
{station_notice_html}
<section style=\"background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;\">
<h2>下一步操作</h2>
<ul>
<li><a href=\"/portal/{escape(token)}/info\">填写/更新资料</a></li>
{report_links}
</ul>
</section>
</main>
</body>
</html>
"""
def _render_report_page(order: Order) -> str:
if order.audit_report and Path(order.audit_report).is_file():
path = Path(order.audit_report)
content = path.read_text(encoding="utf-8")
if path.suffix.lower() == ".html":
return content
if path.suffix.lower() == ".json":
pretty = json.dumps(json.loads(content), ensure_ascii=False, indent=2)
return f"<html><body><h1>志愿方案报告</h1><pre>{escape(pretty)}</pre></body></html>"
return f"<html><body><h1>志愿方案报告</h1><pre>{escape(content)}</pre></body></html>"
if order.plan_file and Path(order.plan_file).is_file():
content = Path(order.plan_file).read_text(encoding="utf-8")
return f"<html><body><h1>志愿方案报告</h1><pre>{escape(content)}</pre></body></html>"
raise HTTPException(status_code=404, detail="report not found")