"""用户端 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()} 前注入。""" 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'

最近一次分享:{escape(perm)}' if suffix_parts: status_line += ( "(" + ",".join(escape(part) for part in suffix_parts) + ")" ) status_line += "

" access_count = int(latest_info.get("access_count") or 0) last_access = latest_info.get("last_access_at_iso") status_line += ( f'

访问次数:{access_count}' + (f" · 最后访问:{escape(str(last_access))}" if last_access else "") + "

" ) 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"""

当前分享状态

{status_line}

正式链接用于向考生/家长稳定分享,不会随订单状态变更失效;你可以在这里复制、重生成或撤销当前链接。

""" 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"{escape(title)}" "" '' f"{_render_global_nav()}{nav}
" f"{back_link}" f"
{escape(eyebrow)}

{escape(title)}

{lead}

" f"{sections_html}" f"{_render_footer_links(footer_token)}" "
{_render_global_toast_script()}" ) def _render_placeholder_shell( *, title: str, max_width: int, body_html: str, ) -> str: top_toolbar = '
返回首页
' return f"""{escape(title)}
{top_toolbar}{body_html}
{_render_global_toast_script()}""" def _find_legal_doc_path(doc_filename: str) -> Path | None: """定位 docs/ 法务文档;找不到返回 None。 搜索顺序:项目根 docs/(开发态)→ 包内 admin/legal/(部署态)。 """ candidates = [ Path(__file__).resolve().parents[2] / "docs" / doc_filename, Path(__file__).resolve().parent / "legal" / doc_filename, ] for path in candidates: if path.is_file(): return path return None def _render_legal_doc_page( *, doc_filename: str, title: str, eyebrow: str, lead: str, footer_token: str | None = None, ) -> str | None: """渲染完整法务文档为 HTML 页面。 找不到文档时返回 None,调用方应 fallback 到简版概要页。 """ doc_path = _find_legal_doc_path(doc_filename) if doc_path is None: return None try: raw = doc_path.read_text(encoding="utf-8") except OSError: return None body_html = markdown.markdown( raw, extensions=["tables", "fenced_code", "sane_lists"], ) nav = '' back_link = '
← 返回首页
' return ( "" "" f"{escape(title)}" "" '" f"{_render_global_nav()}{nav}
" f"{back_link}" f"
{escape(eyebrow)}

{escape(title)}

{escape(lead)}

" f"" f"{_render_footer_links(footer_token)}" "
{_render_global_toast_script()}" ) @router.get("/privacy", include_in_schema=False) def privacy_page(token: str | None = None) -> HTMLResponse: full_html = _render_legal_doc_page( doc_filename="PRIVACY_POLICY_DRAFT.md", title="隐私政策", eyebrow="隐私说明", lead="运营主体:龙某某(个人开发者)|联系邮箱:lon22@qq.com|版本:privacy-policy-v2026.06.25|生效:2026-06-25。我们只收集下单、资料填写、支付与交付所需的最小信息,用于志愿服务流程,不用于营销出售或无关用途。", footer_token=token, ) if full_html is not None: return HTMLResponse(full_html) sections_html = "

你可以预期什么

" return HTMLResponse( _render_basic_page( title="隐私政策", eyebrow="隐私说明", lead="运营主体:龙某某(个人开发者)|联系邮箱:lon22@qq.com|版本:privacy-policy-v2026.06.25|生效:2026-06-25。我们只收集下单、资料填写、支付与交付所需的最小信息,用于志愿服务流程,不用于营销出售或无关用途。", sections_html=sections_html, footer_token=token, ) ) @router.get("/service-terms", include_in_schema=False) def service_terms_page(token: str | None = None) -> HTMLResponse: full_html = _render_legal_doc_page( doc_filename="SERVICE_TERMS.md", title="服务说明与使用条款", eyebrow="服务边界", lead="运营主体:龙某某(个人开发者)|联系邮箱:lon22@qq.com|版本:service-terms-v2026.06.25|生效:2026-06-25。本服务提供志愿填报辅助建议、方案审计与交付支持,不承诺录取结果;提交资料前请确认监护人与考生已知情。", footer_token=token, ) if full_html is not None: return HTMLResponse(full_html) sections_html = "

下单前请了解

" return HTMLResponse( _render_basic_page( title="服务说明与免责声明", eyebrow="服务边界", lead="运营主体:龙某某(个人开发者)|联系邮箱:lon22@qq.com|版本:service-terms-v2026.06.25|生效:2026-06-25。本服务提供志愿填报辅助建议、方案审计与交付支持,不承诺录取结果;提交资料前请确认监护人与考生已知情。", sections_html=sections_html, footer_token=token, ) ) def _normalized_profile_snapshot(payload: dict[str, Any]) -> dict[str, Any]: def _clean_list(value: Any) -> list[str]: return [str(item).strip() for item in list(value or []) if str(item).strip()] return { "candidate_province": str(payload.get("candidate_province") or "").strip(), "candidate_subjects": _clean_list(payload.get("candidate_subjects")), "candidate_score": payload.get("candidate_score"), "candidate_rank": payload.get("candidate_rank"), "target_cities": _clean_list(payload.get("target_cities")), "target_majors": _clean_list(payload.get("target_majors")), "target_schools": _clean_list(payload.get("target_schools")), "school_region_preferences": _clean_list( payload.get("school_region_preferences") ), "school_preference_types": _clean_list(payload.get("school_preference_types")), "disliked_majors": _clean_list(payload.get("disliked_majors")), "employment_region_preferences": _clean_list( payload.get("employment_region_preferences") ), "candidate_interests": str(payload.get("candidate_interests") or "").strip(), "university_preferences": str( payload.get("university_preferences") or "" ).strip(), "priority_strategy": str(payload.get("priority_strategy") or "").strip(), "graduation_plan": str(payload.get("graduation_plan") or "").strip(), "tuition_preference": str(payload.get("tuition_preference") or "").strip(), "family_background": str(payload.get("family_background") or "").strip(), "industry_resources": str(payload.get("industry_resources") or "").strip(), "extra_notes": str(payload.get("extra_notes") or "").strip(), "interest_assessment_type": str( payload.get("interest_assessment_type") or "" ).strip(), "interest_assessment_result": str( payload.get("interest_assessment_result") or "" ).strip(), "interest_assessment_notes": str( payload.get("interest_assessment_notes") or "" ).strip(), "existing_plan_summary": str( payload.get("existing_plan_summary") or "" ).strip(), "guardian_notes": str(payload.get("guardian_notes") or "").strip(), } def _profile_stage_label(index: int) -> str: labels = ["初始档案方案", "查分后校准方案", "正式填报前调整方案"] if index < len(labels): return labels[index] return f"第 {index + 1} 次档案快照" def _profile_version_id(index: int) -> str: return f"profile_v{index + 1}" def _ensure_profile_version_metadata( payload: dict[str, Any], *, source: str = "portal" ) -> dict[str, Any]: updated = dict(payload) snapshot = _normalized_profile_snapshot(updated) versions = [ item for item in list(updated.get("profile_versions") or []) if isinstance(item, dict) ] if versions and versions[-1].get("snapshot_payload") == snapshot: updated["profile_versions"] = versions updated["latest_profile_version_id"] = str( versions[-1].get("profile_version_id") or _profile_version_id(len(versions) - 1) ) return updated version = { "profile_version_id": _profile_version_id(len(versions)), "stage_label": _profile_stage_label(len(versions)), "snapshot_payload": snapshot, "created_at": utc_now_iso(), "source": source, } versions.append(version) updated["profile_versions"] = versions updated["latest_profile_version_id"] = version["profile_version_id"] return updated def _latest_profile_version(payload: dict[str, Any]) -> dict[str, Any] | None: versions = [ item for item in list(payload.get("profile_versions") or []) if isinstance(item, dict) ] latest_id = str(payload.get("latest_profile_version_id") or "").strip() for item in versions: if str(item.get("profile_version_id") or "") == latest_id: return item return versions[-1] if versions else None def _profile_version_label_from_payload(payload: dict[str, Any]) -> str: latest = _latest_profile_version(payload) if latest is not None: return str(latest.get("profile_version_id") or "profile-step1-incomplete") return _profile_version_label( { "intake_summary": payload, "profile_minimum_complete": _is_profile_minimum_complete(payload), } ) def _report_version_profile_reference( payload: dict[str, Any], *, report_version_id: str, fallback_profile_version_id: str ) -> str: for item in list(payload.get("report_versions") or []): if not isinstance(item, dict): continue if str(item.get("report_version_id") or "") != report_version_id: continue referenced = str(item.get("profile_version_id") or "").strip() if referenced: return referenced return fallback_profile_version_id def _ensure_report_version_metadata( payload: dict[str, Any], *, report_version_id: str, profile_version_id: str, review_result_version_id: str | None, artifact_refs: dict[str, Any], ) -> dict[str, Any]: updated = dict(payload) versions = [ item for item in list(updated.get("report_versions") or []) if isinstance(item, dict) ] for item in versions: if str(item.get("report_version_id") or "") == report_version_id: updated["latest_report_version_id"] = report_version_id return updated versions.append( { "report_version_id": report_version_id, "profile_version_id": profile_version_id, "review_result_version_id": review_result_version_id, "artifact_refs": artifact_refs, "created_at": utc_now_iso(), } ) updated["report_versions"] = versions updated["latest_report_version_id"] = report_version_id return updated def _current_intake_payload(context: dict[str, Any]) -> dict[str, Any]: intake = context.get("intake") if intake is not None and isinstance(getattr(intake, "payload", None), dict): return dict(intake.payload) summary = context.get("intake_summary") if isinstance(summary, dict): return dict(summary) return {} def _render_trust_banner_html( *, province: str, official_source: str, last_updated: str, scope: str, confidence_label: str, boundary_note: str, ) -> str: return ( '
' f'

可信度说明:官方来源:{escape(official_source)} · 更新时间:{escape(last_updated or "未公布")}

' f'

适用范围:{escape(scope)} · 适用省份:{escape(province)} · 置信等级:{escape(confidence_label)}

' f'

{escape(boundary_note)}

' "
" ) def _helper_entry_hrefs(payload: dict[str, Any], order: Order) -> tuple[str, str]: province = ( str( payload.get("candidate_province") or order.candidate_province or "湖南" ).strip() or "湖南" ) score = payload.get("candidate_score") or order.candidate_score or 0 return ( f"/policy-center?province={escape(province)}", f"/same-score-reference?province={escape(province)}&score={escape(str(score))}", ) def _profile_version_state(payload: dict[str, Any], profile_version_id: str) -> str: latest_profile_version = str( payload.get("latest_profile_version_id") or profile_version_id ) if profile_version_id == latest_profile_version: return "基于最新档案生成" return "基于历史档案版本生成,建议刷新" def _render_auxiliary_factor_section(payload: dict[str, Any]) -> str: rows: list[str] = [] if payload.get("interest_assessment_result"): rows.append( f"
  • 测评结果:{escape(str(payload.get('interest_assessment_result') or ''))}
  • " ) if payload.get("family_background"): rows.append( f"
  • 家庭背景:{escape(str(payload.get('family_background') or ''))}
  • " ) if payload.get("industry_resources"): rows.append( f"
  • 行业资源:{escape(str(payload.get('industry_resources') or ''))}
  • " ) if payload.get("extra_notes"): rows.append( f"
  • 补充说明:{escape(str(payload.get('extra_notes') or ''))}
  • " ) if not rows: return "" return ( '

    辅助判断因子

    ' '

    这些内容只用于辅助解释,不作为唯一判断。

    ' f"
    " ) def _sync_report_version_metadata( order_id: str, settings: Settings, *, report_version_id: str, profile_version_id: str, review_result_version_id: str | None, artifact_refs: dict[str, Any], ) -> None: intake_store = IntakeStore.for_db(settings.orders_db_path) try: current = intake_store.get(order_id) if current is None: return updated_payload = _ensure_report_version_metadata( current.payload, report_version_id=report_version_id, profile_version_id=profile_version_id, review_result_version_id=review_result_version_id, artifact_refs=artifact_refs, ) if updated_payload != current.payload: intake_store.save( order_id=order_id, payload=updated_payload, submit=(current.status == "submitted"), ) finally: intake_store.close() def _public_supported_provinces() -> set[str]: return { "北京", "天津", "上海", "重庆", "河北", "河南", "山东", "山西", "陕西", "辽宁", "吉林", "黑龙江", "江苏", "浙江", "安徽", "福建", "江西", "湖北", "湖南", "广东", "海南", "四川", "贵州", "云南", "甘肃", "青海", "新疆", } @router.get("/policy-center", include_in_schema=False) def policy_center_page(province: str = "湖南") -> HTMLResponse: safe_province = province or "湖南" supported = safe_province in _public_supported_provinces() trust_banner = _render_trust_banner_html( province=safe_province, official_source=f"{safe_province}省教育考试院", last_updated="2026-06-22", scope="政策摘要与填报提醒", confidence_label="参考" if supported else "暂不支持", boundary_note=( "未覆盖内容必须显式标注;页面仅提供摘要,不替代官方全文。" if supported else "当前省份尚未进入公开支持集,请不要把本页内容视为可用填报依据。" ), ) support_notice = ( "" if supported else "

    当前省份暂不支持

    公开政策摘要与同分段参考当前未覆盖该省份,请勿继续使用本页作为填报依据。

    " ) same_score_href = f"/same-score-reference?province={escape(safe_province)}&score=0" body = f"""政策中心{_render_global_nav()}
    政策中心

    政策中心

    适用省份:{escape(safe_province)}

    {trust_banner}
    {support_notice}

    时间节点

    • 查分后先核对成绩、位次与选科要求。
    • 正式填报前再次确认批次与专业组选科约束。

    批次规则

    当前只整理普通类主链的关键规则;特殊批次、艺体类等未覆盖内容需另行核对。

    提前批与专项计划

    提前批和专项计划是可以降分进入好学校的特殊渠道,但各有报考条件限制。以下列出主要类型:

    • 军校(本科提前批):入学即入伍,毕业分配工作。需通过政审+军检,年龄不超过20周岁。比同层次普通院校低30-80分。
    • 公安院校(本科提前批):毕业参加公安联考入警率90%+,需通过体检+体能测试+政审。注意只有公安专业才能参加联考。
    • 国家专项计划:面向脱贫县农村考生,需当地连续3年户籍+学籍。通常降10-30分。
    • 地方专项计划:面向农村户籍考生,各省自定实施区域。通常降15-40分。
    • 高校专项计划:教育部直属高校面向农村考生,需4-5月提前报名。通常降20-60分。
    • 公费师范生:免学费+住宿费+补贴,毕业包分配有编有岗,需回生源省任教6年。
    • 免费医学定向:免学费,毕业回基层卫生院事业编,需服务6年。

    具体资格要求、体检标准和降分幅度以当地教育考试院和各院校官方招生简章为准。

    选科要求

    先核对目标专业组选科要求,再判断是否要调整冲稳保结构。

    常见误区

    • 不要把同分段参考当成最终结论。
    • 不要用去年的批次规则替代当年正式发布口径。

    以{escape(safe_province)}省教育考试院官方信息为准。

    {_render_footer_links()}
    {_render_global_toast_script()}""" return HTMLResponse(body) @router.get("/same-score-reference", include_in_schema=False) def same_score_reference_page(province: str = "湖南", score: int = 0) -> HTMLResponse: safe_province = province or "湖南" supported = safe_province in _public_supported_provinces() loader = CrowdDBLoader(warn_low_confidence=False) metadata = loader.load_metadata(safe_province) if supported else None metadata = metadata or { "province": safe_province, "last_updated": "", "source": "", "confidence": None, } recommendations = ( loader.find_recommendations(safe_province, score) if (score and supported) else [] ) schools = [ rec.get("name", "") for rec in recommendations[:5] if isinstance(rec, dict) ] majors = [ rec.get("major", "") for rec in recommendations[:5] if isinstance(rec, dict) ] cities: list[str] = [] for rec in recommendations[:5]: if not isinstance(rec, dict): continue for alt in rec.get("alternatives", []) or []: if isinstance(alt, dict) and alt.get("name"): cities.append(str(alt.get("name"))) confidence = metadata.get("confidence") confidence_label = ( "高" if supported and isinstance(confidence, (int, float)) and confidence >= 0.8 else ("参考" if supported else "暂不支持") ) trust_banner = _render_trust_banner_html( province=safe_province, official_source=str( metadata.get("source") or f"{safe_province}省公开同分段参考整理" ), last_updated=str(metadata.get("last_updated") or "未公布"), scope="同分段参考与扎堆风险提示", confidence_label=confidence_label, boundary_note=( "仅作参考,不替代正式填报判断;非高置信数据不得作为强推荐依据。当前高置信省份以数据质量白名单为准。" if supported else "当前省份暂不支持公开同分段参考,请不要把“暂无”理解为普通空数据。" ), ) unsupported_notice = ( "" if supported else "

    当前省份暂不支持

    公开同分段参考当前未覆盖该省份;请勿把当前页面的“暂无”视为普通空数据。

    " ) policy_href = f"/policy-center?province={escape(safe_province)}" body = f"""同分段参考{_render_global_nav()}
    同分段参考

    同分段参考

    适用省份:{escape(safe_province)} · 置信等级:{escape(confidence_label)} · 数据说明:仅作参考,不替代正式填报判断

    {trust_banner}
    {unsupported_notice}

    同分段热门学校

    {escape("、".join([item for item in schools if item]) or "暂无")}

    同分段热门专业

    {escape("、".join([item for item in majors if item]) or "暂无")}

    同分段热门城市

    {escape("、".join([item for item in cities if item]) or "暂无")}

    扎堆风险提示

    若热门学校/专业高度集中,建议回到审核页或冲稳保页重新看梯度风险。

    {_render_footer_links()}
    {_render_global_toast_script()}""" return HTMLResponse(body) @router.get("/deletion-policy", include_in_schema=False) def deletion_policy_page() -> HTMLResponse: sections_html = "

    你可以怎么做

    " return HTMLResponse( _render_basic_page( title="删除申请 / 数据删除说明", eyebrow="数据删除", lead="如需申请删除订单资料、附件或交付物,可在支付后的 Portal 中提交删除申请;系统会保留必要的审计记录,并由人工核验后处理。", sections_html=sections_html, ) ) @router.get("/my-orders", include_in_schema=False) def my_orders_page( request: Request, token: str | None = None, phone: str | None = None, # 已废弃:C 方案下不再支持手机号直查,保留参数仅为兼容旧链接不报错 settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: """C 方案:必须持有有效 portal token 才能查看订单。 旧逻辑(手机号直查 → 现签 portal token)已废弃,因为它构成越权: 任何知道家长手机号的人都能枚举他人订单和交付物入口。 现逻辑: - 无 token → 显示说明页,引导用户通过下单时收到的 portal 链接进入 - 有 token → 验证 token → 只展示该 token 对应的那一个订单(不查全量) - phone 参数保留仅为兼容旧链接,不再触发任何查询 """ from data.customer_portal.token import verify_portal_token, PortalTokenError nav = '' # 无 token:显示引导说明(不再显示手机号查询表单) if not token: body = f"""我的订单{_render_global_nav()}{nav}
    返回首页

    我的订单

    请通过订单链接进入

    为保护你的订单隐私,我们不再支持凭手机号查询订单。请使用下单时收到的订单进度链接(短信/页面跳转中已包含),直接进入你的订单状态页。如果链接已丢失,请联系客服核实身份后重新获取。

    {_render_global_toast_script()}""" return HTMLResponse(body) # 有 token:验证并只展示该 token 对应的订单 try: payload = verify_portal_token(token, settings.portal_token_secret) order_id = str(payload["order_id"]) except PortalTokenError: body = f"""我的订单{_render_global_nav()}{nav}
    返回首页

    我的订单

    {_render_global_toast_script()}""" return HTMLResponse(body, status_code=401) from data.orders.dao import OrdersDAO, OrderNotFound try: with OrdersDAO.connect(settings.orders_db_path) as dao: order = dao.get(order_id) except OrderNotFound: body = f"""我的订单{_render_global_nav()}{nav}
    返回首页

    我的订单

    订单不存在

    该订单可能已被删除。如有疑问请联系客服。

    {_render_global_toast_script()}""" return HTMLResponse(body, status_code=404) # 只展示这一个订单,不查全量,不签发新 token(复用当前 token) status_url = f"/portal/{token}/status" orders_html = ( f"

    当前订单

    " f"" f"" f"" f"" f"" f"" f"" f"" f"" f"" f"" f"" f"" f"
    订单号套餐状态创建时间操作
    {escape(order.id)}{escape(order.service_version)}{escape(order.status)}{escape(str(order.created_at or '')[:16])}查看详情
    " f"

    仅显示当前链接对应的订单。如需查看其他订单,请使用对应订单的进度链接。

    " f"
    " ) body = f"""我的订单{_render_global_nav()}{nav}
    返回首页

    我的订单

    {orders_html}
    {_render_global_toast_script()}""" return HTMLResponse(body) @router.get("/my-reports", include_in_schema=False) def my_reports_page( request: Request, token: str | None = None, phone: str | None = None, # 已废弃:C 方案下不再支持手机号直查 settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: """C 方案:必须持有有效 portal token 才能查看报告。 旧逻辑(手机号直查)已废弃,原因同 my_orders_page。 - 无 token → 显示说明页 - 有 token → 验证 → 只展示该 token 对应订单的报告(如有交付) """ from data.customer_portal.token import verify_portal_token, PortalTokenError nav = '' if not token: body = f"""我的报告{_render_global_nav()}{nav}
    返回首页

    我的报告

    请通过订单链接进入

    为保护你的报告隐私,我们不再支持凭手机号查询报告。请使用下单时收到的订单进度链接进入,在订单状态页查看已交付的报告。如果链接已丢失,请联系客服核实身份后重新获取。

    {_render_global_toast_script()}""" return HTMLResponse(body) try: payload = verify_portal_token(token, settings.portal_token_secret) order_id = str(payload["order_id"]) except PortalTokenError: body = f"""我的报告{_render_global_nav()}{nav}
    返回首页

    我的报告

    {_render_global_toast_script()}""" return HTMLResponse(body, status_code=401) from data.orders.dao import OrdersDAO, OrderNotFound try: with OrdersDAO.connect(settings.orders_db_path) as dao: order = dao.get(order_id) except OrderNotFound: body = f"""我的报告{_render_global_nav()}{nav}
    返回首页

    我的报告

    订单不存在

    该订单可能已被删除。如有疑问请联系客服。

    {_render_global_toast_script()}""" return HTMLResponse(body, status_code=404) # 只展示该订单的报告(如果已交付) if order.status in ("delivered", "completed") and order.audit_report: report_url = f"/portal/{token}/report" reports_html = ( f"

    当前订单的报告

    " f"" f"" f"" f"" f"" f"" f"" f"" f"" f"" f"" f"" f"" f"
    订单号套餐状态创建时间操作
    {escape(order.id)}{escape(order.service_version)}已交付{escape(str(order.created_at or '')[:16])}查看报告
    " ) else: reports_html = ( "
    " "

    当前订单暂无可查看的报告

    " "

    报告在订单完成交付后才会显示。请关注订单状态页的进度更新。

    " "
    " ) body = f"""我的报告{_render_global_nav()}{nav}
    返回首页

    我的报告

    {reports_html}
    {_render_global_toast_script()}""" return HTMLResponse(body) @router.get("/data-query", include_in_schema=False) def data_query_page( request: Request, settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: """数据查询入口页:分数线 / 位次 / 政策 / 同分段。""" body = ( '' '' "数据查询" '' "" + '' + '
    ' + '
    返回首页
    ' + '

    数据查询

    查询高考相关的基础数据,辅助你做志愿填报决策。

    ' + '
    ' + '

    分数线查询

    查询全国各省本科线、特殊类型控制线等官方录取分数线。

    进入分数线查询
    ' + '

    位次估算

    输入分数,基于一分一段数据估算全省位次(支持省份取决于数据覆盖)。

    进入位次估算
    ' + '

    专业库

    搜索教育部公布的专业目录,查看专业代码、学科门类、学位类型。

    进入专业库查询
    ' + '

    院校库

    搜索院校,查看院校代码、招生专业和所属省份。

    进入院校库查询
    ' + '

    政策中心

    查看各省高考政策摘要、批次规则、选科要求和常见误区。

    进入政策中心
    ' + '

    同分段参考

    查看某个分数段在各省份的热门学校、专业和城市分布。

    进入同分段参考
    ' + "
    " + _render_footer_links() + "
    {_render_global_toast_script()}" ) return HTMLResponse(body) @router.get("/score-line-query", include_in_schema=False) def score_line_query_page( request: Request, province: str | None = None, settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: """分数线查询页:从 crowd_db 的 score_distribution 读各省本科线。""" from data.crowd_db.loader import CrowdDBLoader loader = CrowdDBLoader(warn_low_confidence=False) nav = '' # 查询逻辑 result_html = "" query_province = (province or "").strip() if query_province: try: data = loader.load_province(query_province) if data and data.get("score_distribution"): sd = data["score_distribution"] subjects = sd.get("subjects", {}) rows = [] for subject_name, subj_data in subjects.items(): bsl = subj_data.get("bachelor_score_line", 0) rows.append( f"{escape(subject_name)}" f"{bsl}" f"{escape(str(sd.get('data_year', '')))}" f'查看来源' ) if rows: result_html = ( f"

    {escape(query_province)} 本科分数线

    " f"" f"" f"" f"" f"{''.join(rows)}
    科类本科线数据年份来源
    " ) else: result_html = "

    该省份暂无分数线数据

    当前 crowd_db 尚未接入该省份的分数线数据。

    " else: result_html = "

    该省份暂无分数线数据

    当前 crowd_db 尚未接入该省份的分数线数据。

    " except Exception: result_html = "" # 省份选项 all_provinces = loader.list_supported_provinces() province_options = "".join( f'' for p in all_provinces ) body = f"""分数线查询{_render_global_nav()}{nav}
    返回数据查询

    分数线查询

    选择省份,查看官方公布的本科录取分数线。

    {result_html}
    {_render_global_toast_script()}""" return HTMLResponse(body) @router.get("/rank-estimator", include_in_schema=False) def rank_estimator_page( request: Request, province: str | None = None, score: int | None = None, settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: """位次估算页:基于 crowd_db 的 score_distribution.benchmarks 估算位次。""" from data.crowd_db.loader import CrowdDBLoader loader = CrowdDBLoader(warn_low_confidence=False) nav = '' result_html = "" query_province = (province or "").strip() query_score = score or 0 if query_province and query_score: try: data = loader.load_province(query_province) if data and data.get("score_distribution"): sd = data["score_distribution"] subjects = sd.get("subjects", {}) estimates = [] for subject_name, subj_data in subjects.items(): benchmarks = subj_data.get("benchmarks", []) # 线性插值估算位次 estimated_rank = None if benchmarks: sorted_bms = sorted( benchmarks, key=lambda b: b.get("score", 0), reverse=True ) for i, bm in enumerate(sorted_bms): if query_score >= bm["score"]: estimated_rank = bm["cumulative_count"] break if estimated_rank is None: estimated_rank = sorted_bms[-1].get("cumulative_count", 0) if estimated_rank is not None: estimates.append( f"
  • {escape(subject_name)}:估算累计位次约 {estimated_rank:,} 名(基于{escape(query_province)}一分一段数据插值)
  • " ) if estimates: result_html = f"

    {escape(query_province)} 分数 {query_score} 位次估算

    ⚠ 这是基于公开一分一段锚点的估算值,非精确位次。最终位次以官方一分一段表为准。

    " else: result_html = "

    该省份暂无位次锚点数据

    当前 crowd_db 尚未接入该省份的一分一段表。

    " else: result_html = "

    该省份暂无位次数据

    " except Exception: result_html = "" all_provinces = loader.list_supported_provinces() province_options = "".join( f'' for p in all_provinces ) body = f"""位次估算{_render_global_nav()}{nav}
    返回数据查询

    位次估算

    选择省份并输入分数,基于公开一分一段数据估算你的全省位次。

    {result_html}
    {_render_global_toast_script()}""" return HTMLResponse(body) @router.get("/majors-query", include_in_schema=False) def majors_query_page( request: Request, q: str | None = None, settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: """专业库查询页:从 majors_catalog 搜索专业。""" import json as _json from pathlib import Path as _Path nav_html = _render_global_nav() result_html = "" query = (q or "").strip() if query: try: data = _json.loads( _Path("data/majors_catalog/national/latest.json").read_text() ) all_majors = data.get("majors", []) # 过滤匹配的 matched = [ m for m in (all_majors or []) if query.lower() in m.get("name", "").lower() or query.lower() in m.get("category", "").lower() or query.lower() in m.get("discipline", "").lower() ] if matched: rows = [] for m in matched: rows.append( f"" f"{m.get('code', '')}" f"{m.get('name', '')}" f"{m.get('discipline', '')}" f"{m.get('category', '')}" f"{m.get('degree', '')}" f"{m.get('status', '')}" f"" ) result_html = ( f"

    搜索结果({len(matched)} 个专业)

    " f"" f"" f"" f"" f"" f"{''.join(rows)}
    代码专业名称学科门类专业类学位状态
    " ) else: result_html = f"

    未找到匹配"{escape(query)}"的专业

    当前专业目录为 MVP 子集(13 个核心专业),完整目录待后续扩充。

    " except Exception as e: result_html = f"" body = f"""专业库查询{nav_html}{_render_global_nav()}
    返回数据查询

    专业库查询

    搜索教育部公布的专业目录,查看专业代码、学科门类、学位类型。

    {result_html}
    {_render_global_toast_script()}""" return HTMLResponse(body) @router.get("/schools-query", include_in_schema=False) def schools_query_page( request: Request, q: str | None = None, settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: """院校库查询页:从 majors_catalog/schools/ 搜索院校。""" import json as _json from pathlib import Path as _Path nav_html = _render_global_nav() result_html = "" query = (q or "").strip() if query: try: schools_dir = _Path("data/majors_catalog/schools/2026") school_files = list(schools_dir.glob("*.json")) matched = [] for f in school_files: data = _json.loads(f.read_text()) if query.lower() in data.get( "school_name", "" ).lower() or query in data.get("province", ""): offerings = data.get("offerings", []) offering_html = "".join( f"
  • {escape(o.get('major_name', ''))} ({escape(o.get('major_code', ''))})
  • " for o in offerings ) matched.append( f"

    {escape(data.get('school_name', ''))}

    " f"

    院校代码:{escape(data.get('school_code', ''))} · 省份:{escape(data.get('province', ''))} · 招生年份:{escape(str(data.get('admission_year', '')))}

    " f"

    开设专业({len(offerings)} 个)

    " f"
    " ) if matched: result_html = "".join(matched) else: result_html = f"

    未找到匹配"{escape(query)}"的院校

    当前院校目录仅含 MVP 子集,完整目录待后续扩充。

    " except Exception as e: result_html = f"" body = f"""院校库查询{nav_html}{_render_global_nav()}
    返回数据查询

    院校库查询

    搜索院校,查看院校代码、招生专业和所属省份。

    {result_html}
    {_render_global_toast_script()}""" return HTMLResponse(body) @router.get("/compare-reports", include_in_schema=False) def compare_reports_page( request: Request, token: str | None = None, tokens: str | None = None, phone: str | None = None, # 已废弃:C 方案下不再支持手机号直查 settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: """报告对比闭环:支持用户输入多个 portal token 进行真实对比。""" from data.customer_portal.token import verify_portal_token, PortalTokenError nav = _render_global_nav() # 解析 token 列表(query param tokens=token1,token2,...) token_list = [] if tokens: token_list = [t.strip() for t in tokens.split(",") if t.strip()] elif token: token_list = [token] # 无 token:显示输入表单 if not token_list: body = f"""报告对比{nav}
    返回首页

    报告对比

    请输入 2 份以上报告链接中的 token

    为保护你的报告隐私,我们不再支持凭手机号查询报告。请把多个订单链接中的 token 复制到这里(逗号分隔),系统会并排对比这些报告的关键信息。

    {_render_global_toast_script()}""" return HTMLResponse(body) # 验证每个 token 并收集数据 compare_rows = [] invalid_tokens = [] from data.orders.dao import OrdersDAO, OrderNotFound for tk in token_list: try: payload = verify_portal_token(tk, settings.portal_token_secret) order_id = str(payload["order_id"]) with OrdersDAO.connect(settings.orders_db_path) as dao: order = dao.get(order_id) context = _build_portal_context(order, settings) intake = context.get("intake_summary") or {} latest_review = _load_latest_review_result(order.id, settings) compare_rows.append( { "token": tk, "order_id": order.id, "service_version": order.service_version, "status": order.status, "score": intake.get("candidate_score") or order.candidate_score or "-", "rank": intake.get("candidate_rank") or order.candidate_rank or "-", "subjects": ", ".join( intake.get("candidate_subjects") or order.candidate_subjects or [] ) or "-", "target_cities": ", ".join(intake.get("target_cities") or []) or "-", "target_majors": ", ".join(intake.get("target_majors") or []) or "-", "latest_review": latest_review.review_result_id if latest_review else "-", "has_pdf": "是" if context.get("report_pdf_ready") else "否", "report_url": f"/portal/{tk}/report", "status_url": f"/portal/{tk}/status", } ) except (PortalTokenError, OrderNotFound): invalid_tokens.append(tk) # 对比结果 if len(compare_rows) < 2: body = f"""报告对比{nav}
    返回首页

    报告对比

    有效报告不足 2 份,无法对比

    请提供至少 2 个有效的 portal token。无效 token:{escape(", ".join(invalid_tokens) or "无")}

    {_render_global_toast_script()}""" return HTMLResponse(body) # 构建对比表(以列为报告) headers = [f"报告 {i + 1}" for i in range(len(compare_rows))] fields = [ ("订单号", "order_id"), ("套餐", "service_version"), ("状态", "status"), ("分数", "score"), ("位次", "rank"), ("选科", "subjects"), ("目标城市", "target_cities"), ("目标专业", "target_majors"), ("最近复核版本", "latest_review"), ("是否有 PDF", "has_pdf"), ] rows_html = [] for label, key in fields: cells = "".join( f"{escape(str(row[key]))}" for row in compare_rows ) rows_html.append( f"{label}{cells}" ) quick_links = "".join( f"
  • 查看 {escape(str(row['order_id']))} 报告 · 查看状态
  • " for row in compare_rows ) body = f"""报告对比{nav}
    返回首页

    报告对比

    已对比 {len(compare_rows)} 份有效报告。无效 token:{escape(", ".join(invalid_tokens) or "无")}

    {"".join(f"" for h in headers)}{"".join(rows_html)}
    字段{escape(h)}

    快速跳转

      {quick_links}
    {_render_global_toast_script()}""" return HTMLResponse(body) def _render_landing_page(request: Request, settings: Settings) -> str: query = dict(request.query_params) escape(str(query.get("consult") or "")) consult_province = escape(str(query.get("province") or "")) consult_score = escape(str(query.get("score") or "")) consult_goal = escape(str(query.get("goal") or "")) consult_subjects = escape(str(query.get("subjects") or query.get("subject") or "")) portal_token = str(query.get("token") or "").strip() latest_review_href = ( f"/review/start?source=home&token={escape(portal_token)}" if portal_token else "" ) latest_review_version_html = "" latest_contract: ReviewResultContract | None = None workspace_context: dict[str, Any] | None = None if portal_token: try: order = _resolve_order_from_token(portal_token, settings) latest_contract = _load_latest_review_result(order.id, settings) workspace_context = _build_portal_context(order, settings) if latest_contract is not None: latest_review_version_html = f'

    最新版本:{escape(latest_contract.review_result_id)}

    ' except HTTPException: latest_contract = None workspace_context = None latest_review_version_html = "" latest_review_html = ( f'

    最近一次复核结果

    如果你刚完成过复核,可以从这里继续回到复核入口,决定是去冲稳保、补 Step 1,还是进入完整规划。

    {latest_review_version_html}
    ' if latest_contract is not None else "" ) workspace_primary_label = "先做方案复核" workspace_primary_href = "/review/start?source=home" if portal_token: workspace_primary_label = "继续补充 Step 1" workspace_primary_href = f"/portal/{escape(portal_token)}/info" if latest_contract is not None: workspace_primary_label = "继续查看最近一次复核" workspace_primary_href = latest_review_href elif workspace_context is not None and workspace_context.get( "profile_minimum_complete" ): workspace_primary_label = "开始方案复核" workspace_primary_href = latest_review_href workspace_card_html = ( f'

    工作台主动作

    根据当前资料完整度与最近一次复核结果,给出默认下一步。

    ' if portal_token else "" ) return f""" 高考志愿填报智能规划服务
    新高考志愿填报 · 志愿决策支持

    高考志愿填报智能规划服务

    先免费复核一次你的现有方案,再决定要不要继续付费。

    提交省份、分数、目标后,我们先判断你的方案是否需要复核。复核现有方案本身免费;新方案生成与深度辅导在支付后启动。

    {workspace_card_html}{latest_review_html}

    先做一次免费复核

    先提交最小必要信息,我们会判断你当前方案值不值得继续;如果你已经拿到老师、机构或其他平台给出的方案,后续资料页也可以上传文档继续复核。

    🔒 这些输入只用于判断要不要复核你的方案,不会留底、不会用于生成方案、不会发邮件推销。如果你决定不进入付费方案,提交的资料不会保存到我们的数据库。

    {f'' if portal_token else ""}

    后续资料页可以上传已有方案文档,我们会先做免费复核,再决定是否进入付费完整规划。

    直接做完整规划
    📄

    已有方案文档?拖拽或点击上传

    支持 Word / PDF / 截图格式,最大 10MB。上传后先做免费复核,再决定是否进入付费完整规划。

    上传方案文档并进入完整规划 →

    不会收到营销短信,提交后你也可以随时要求删除已填资料。

    为什么选择我们

    高考志愿填报不是只看一个分数,而是要在时间、信息、风险和沟通成本之间做平衡。我们的特点不是只“生成方案”,而是先帮你审计现有方案、识别扎堆和踩线风险,再决定是否进入更完整的规划与交付。

    方案审计

    先判断现有方案值不值得继续

    如果你已经拿到老师、机构或 AI 给出的方案,我们会先审计是否踩线、是否扎堆、是否存在明显结构风险,再决定下一步。

    风险沟通

    把风险解释清楚

    不只输出结果,还会说明冲稳保梯度、可能踩线的位置,以及需要重点确认的选择风险。

    交付透明

    过程可追踪

    从下单、资料填写、通知到报告查看,都能在站内看到当前进度,减少反复追问。

    服务流程

    你先拿到判断,再决定是否付费进入完整规划。流程越清楚,越不容易在关键节点反复来回确认。

    1

    先判断入口

    判断你需要的是方案复核(免费)还是完整方案 / 深度辅导(付费)。

    2

    确认下单

    选完整方案或深度辅导时,先填写考生姓名、手机号等最小信息后再支付。

    3

    补充资料

    支付成功后进入资料向导,分步提交分数、位次、偏好与已有方案附件。

    4

    查看交付

    在站内追踪状态、查看通知,并在交付就绪后在线阅读或下载 PDF。

    已有方案?先免费复核;明确要做?看付费套餐

    如果已经拿到一版方案,可先做一次免费复核;明确要完整方案或深度辅导,可直接进入套餐页查看内容和下单说明。

    {_render_footer_links()}
    {_render_global_toast_script()} """ def _render_pricing_page(request: Request) -> str: query = dict(request.query_params) consult_text = str(query.get("consult") or "").strip() province = str(query.get("province") or "").strip() score = str(query.get("score") or "").strip() goal = str(query.get("goal") or "").strip() recommendation_title = "你可以先从 99 元完整志愿方案开始" recommendation_body = ( "如果你没有现成方案,直接进入完整规划最省时间;如果已有方案,再先审计。" ) if consult_text or "审计" in goal or "审核" in goal: recommendation_title = "更适合先做 49 元方案审核" recommendation_body = "你提到已有方案或需要先判断风险,先做审核更符合当前目标。" elif "深度" in goal or "沟通" in goal: recommendation_title = "更适合 199 元深度辅导版" recommendation_body = "如果你需要多轮解释或目标冲突较大,深度辅导更合适。" return f""" 服务套餐 - 高考志愿填报智能规划服务
    返回首页

    服务套餐

    先按服务深度选择适合自己的方案:复核现有方案本身免费;如果你已经拿到其他方案,先做复核判断风险;如果希望一次拿到完整建议,优先看 99 元完整志愿方案;如果需要更多人工沟通和多轮修订,再选择 199 元深度辅导版。

    {escape(recommendation_title)}

    {escape(recommendation_body)}{" 当前输入:" if (province or score or goal) else ""}{escape(" ".join(filter(None, [province, score, goal])))}

    💡 如果你还没决定,先做一次免费复核,再决定要不要进入付费方案。

    复核免费 / 方案付费免费帮你审现有方案的风险;新方案生成和深度辅导在支付后启动。
    主推档更清晰99 元方案覆盖大多数用户最关心的完整线上交付路径。
    进度站内可查资料、通知与报告状态都能在站内持续追踪。
    隐私与删除入口可见隐私政策、服务说明和删除申请入口始终保留。
    复核 / 风险

    49元 AI方案审核

    ¥49/ 份

    适合已经拿到其他 AI 志愿方案,想先判断方案是否踩线、是否扎堆、是否存在明显风险的家庭。审核只针对你提供的现有方案,不会重新生成志愿表。

    • 针对你提供的现有方案做风险复核
    • 聚焦风险点、冲稳保结构与明显异常
    • 给出是否值得继续深做的判断
    • 不重新生成志愿表(生成需选 99 元)
    选择付费审核
    生成 / 深度

    199元 深度辅导版

    ¥199/ 份

    适合志愿范围复杂、目标城市/专业冲突较大,或需要更多人工沟通、反复修订和深度解释的家庭。在 99 元完整方案基础上提供多轮修订和深度解释。

    • 适合目标复杂或分歧较大的家庭
    • 包含 99 元完整方案的所有交付
    • 留出多轮沟通与补充说明空间
    • 更强调过程解释与决策支持
    选择深度辅导
    站内可追踪下单后可以查看资料提交、通知记录和交付状态,不需要反复追问进度。
    资料入口清晰支付前只收必要下单信息,详细资料在支付后通过资料向导分步补充。
    复核免费 / 方案付费还没决定?回到首页 先做一次免费复核,再决定要不要进入付费方案。

    家长和学生的选择

    31 省
    覆盖省级录取数据
    3 个版本
    从免费复核到深度辅导
    站内全流程
    下单→支付→资料→交付→追踪

    已有方案?先 免费复核,再决定是否进入付费方案。

    复核是免费的吗?包含什么?

    提交基本情况后我们免费帮你判断你的现有方案是否需要复核,并说明后续可走的步骤。复核本身免费;如需正式 AI 方案审核报告(49 元)或重新生成完整方案(99 元起)才需要支付。

    为什么推荐 99 元完整志愿方案?

    它覆盖大多数用户最关心的完整资料收集、站内进度追踪与报告交付,是当前最适合线上自助下单的标准路径。99 元是 生成 一份完整方案的价格,不是 查看 价格。

    支付后还要补哪些信息?

    主要是分数、位次、选科、目标城市/专业、已有方案说明与附件。支付前不让用户一次填太长的表单。

    我已经有一版志愿方案怎么办?

    免费复核 看是否值得继续;如需正式审核报告再选 49 元版本;如已决定要重做或生成完整方案,再进入 99 元完整方案。

    还没决定?先做一次免费复核,再决定要不要进入付费方案。如果你已经拿到一版方案,49 元审核版会给出明确风险判断;如果已经决定要做完整方案,优先选择 99 元完整志愿方案。
    {_render_footer_links()}
    {_render_global_toast_script()} """ 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] service_desc = { "audit": "适合已经拿到其他方案、希望先快速校验风险的家庭。", "basic": "适合需求较轻、希望先收敛选校范围的家庭。", "standard": "适合大多数希望一次拿到完整建议与站内交付追踪的家庭。", "premium": "适合需要更多沟通、补充说明与深度修订支持的家庭。", }[service_version] province_options = [ "", "北京", "上海", "天津", "重庆", "河北", "河南", "山东", "山西", "陕西", "辽宁", "吉林", "黑龙江", "江苏", "浙江", "安徽", "福建", "江西", "湖北", "湖南", "广东", "广西", "海南", "四川", "贵州", "云南", "甘肃", "青海", "宁夏", "新疆", "内蒙古", "西藏", ] province_html = "".join( f'' for p in province_options ) return f""" 下单 - {escape(service_label)}
    ← 返回首页
    1 填写信息
    2 支付
    3 补充资料
    在线下单

    {escape(service_label)}

    {escape(service_desc)} 现在先确认联系人与考生基础信息;支付成功后,再进入资料向导补充分数、位次、偏好和已有方案附件。

    填写下单信息

    当前这一步只收会影响下单与后续联系的必要信息,不要求你一次性填完整个长表单。

    先下单再补资料先把关键联系信息确认下来,再进入资料向导补充分数、位次和偏好。
    隐私入口始终可见隐私政策、服务说明与删除申请入口在这条链路上持续可访问。
    如果你暂时还没决定,也可以支付后在资料向导中补充。
    提交后会进入支付与资料完善流程。

    💳 支付方式 支持支付宝在线支付
    ↩️ 退款政策 报告交付前可全额退款;交付后如有不满意,联系客服协商处理。
    🔒 支付安全 通过官方支付渠道处理,不存储银行卡信息。

    支付后可进入资料向导继续补充详细信息;提交资料后,后续状态、通知与交付入口都将在站内持续更新。

    {_render_footer_links()}
    {_render_global_toast_script()} """ def _render_payment_success_page(token: str, context: dict[str, Any]) -> str: stage = str(context.get("stage") or "") next_action = "立即补充资料" next_href = f"/portal/{escape(token)}/info" if stage in {"processing", "report_ready", "completed"}: next_action = "查看订单状态" next_href = f"/portal/{escape(token)}/status" payment_status = escape(str(context.get("payment_status") or "pending")) next_hint = "当前最重要的是补充分数、位次、选科和目标信息,提交后系统会继续推进。" if stage in {"processing", "report_ready", "completed"}: next_hint = "订单已进入后续处理阶段,可以直接查看进度和交付结果。" return f""" 支付成功 {_render_global_nav()}
    支付成功

    订单已创建,下一步继续补资料

    支付状态:{payment_status}。{escape(next_hint)}

    你现在要做什么

    补充基础信息先填分数、位次、选科。
    填写偏好目标补充城市、专业或院校偏好。
    持续查看进度资料提交后可继续查看状态、通知和报告。
    {_render_global_toast_script()} """ def _render_mock_payment_page(payment_id: str, amount_cents: int) -> str: return _render_simulated_payment_html( title="支付确认页", provider_slug="mock", payment_id=payment_id, amount_cents=amount_cents, submit_label="确认支付并返回订单页", ) def _render_alipay_sim_payment_page(payment_id: str, amount_cents: int) -> str: return _render_simulated_payment_html( title="支付宝模拟收银台", provider_slug="alipay-sim", payment_id=payment_id, amount_cents=amount_cents, submit_label="模拟支付宝支付成功并返回订单页", ) def _render_simulated_payment_html( *, title: str, provider_slug: str, payment_id: str, amount_cents: int, submit_label: str, ) -> str: return f""" {escape(title)} {_render_global_nav()}
    ← 返回首页

    {escape(title)}

    订单支付单号:{escape(payment_id)}

    支付金额:¥{amount_cents / 100:.2f}

    {_render_global_toast_script()} """ def _is_profile_minimum_complete(payload: dict[str, Any]) -> bool: return bool( payload.get("candidate_province") and payload.get("candidate_subjects") and payload.get("candidate_score") is not None and payload.get("candidate_rank") is not None ) def _profile_missing_fields(payload: dict[str, Any]) -> list[str]: missing: list[str] = [] if not payload.get("candidate_province"): missing.append("candidate_province") if payload.get("candidate_score") is None: missing.append("candidate_score") if payload.get("candidate_rank") is None: missing.append("candidate_rank") if not payload.get("candidate_subjects"): missing.append("candidate_subjects") return missing def _province_options_html(selected: str) -> str: province_options = [ "", "北京", "上海", "天津", "重庆", "河北", "河南", "山东", "山西", "陕西", "辽宁", "吉林", "黑龙江", "江苏", "浙江", "安徽", "福建", "江西", "湖北", "湖南", "广东", "海南", "四川", "贵州", "云南", "甘肃", "青海", "新疆", ] return "".join( f'" for p in province_options ) def _render_simulated_payment_page( payment_id: 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) elif provider_slug == "alipay-sim": body = _render_alipay_sim_payment_page(payment_id, payment.amount_cents) else: raise HTTPException( status_code=404, detail="unsupported simulated payment page" ) return HTMLResponse(body) def _complete_simulated_payment( payment_id: 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") 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) portal_token = issue_portal_token(payment.order_id, settings.portal_token_secret) return RedirectResponse( url=f"/portal/{portal_token}/payment-success", status_code=303 ) def _render_info_page( order: Order, token: str, payload: dict[str, Any], stage: str, settings: Settings, ) -> str: consent_version = str(payload.get("consent_version") or settings.consent_version) consent_scope = str(payload.get("consent_scope") or settings.consent_scope_portal) 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 [] missing_items: list[str] = [] if not payload.get("candidate_province"): missing_items.append("省份") if not payload.get("candidate_score"): missing_items.append("分数") if not payload.get("candidate_rank"): missing_items.append("位次") if not payload.get("candidate_subjects"): missing_items.append("选科") attachment_items = [] for item in attachments: if not isinstance(item, dict): continue attachment_items.append( f"
  • {escape(str(item.get('original_name') or item.get('stored_name') or '未命名附件'))}" f"{escape(str(item.get('size_bytes') or '0'))} bytes
  • " ) attachments_html = "".join(attachment_items) or "
  • 暂无附件
  • " stage_title, stage_subtitle = _STAGE_META[stage] return f""" 考生资料填写 {_render_global_nav()}
    资料填写向导

    考生资料填写

    资料填写向导

    支付完成后,请按向导逐步补充分数、位次、偏好与已有方案信息。我们会把这份资料作为后续方案分析与交付的基础。

    当前还需要补充:{escape("、".join(missing_items) or "资料已基本完整,可以继续提交")}

    五步资料向导

    你不需要一次性完成所有内容。可以先保存草稿,再回来继续补充;第 4 步完成偏好与协议确认,第 5 步补已有方案与附件。

    1. 基础信息
    2. 院校偏好
    3. 专业偏好
    4. 其他偏好与确认
    5. 已有方案与附件

    基础信息

    先确认最影响志愿方案判断的基本数据:省份、分数、位次、选科。

    这是 Step 1 的第一项,先选省份再继续。
    如暂未最终确认,也可先保存草稿。
    建议填写与成绩单一致的最新位次。
    例如:物理,化学,生物
    {_render_footer_links(token)}
    {_render_global_toast_script()} """ def _render_delivery_next_steps(token: str, stage: str) -> str: """当报告已交付时,给出明确的下一步行动引导。""" if stage not in ("report_ready", "completed"): return "" return f"""

    报告已就绪,你可以:

    下载 PDF 在线查看 继续规划其他方案

    如需与家人商量,可生成正式分享链接并发送给家人。

    {_render_share_status_panel(result_type="report", token=token)} {_render_share_link_script(result_type="report", token=token, copy_id="report-copy-link", share_id="report-share-btn", status_id="report-share-status", title="志愿报告分享")}
    """ def _render_status_page(token: str, context: dict[str, Any], settings: Settings) -> str: order = context["order"] stage = str(context["stage"]) report_links = "" can_access_delivery = stage in {"report_ready", "completed"} if can_access_delivery and context["report_html_ready"]: report_links += ( f'
  • 查看在线报告
  • ' ) if can_access_delivery and context["report_pdf_ready"]: report_links += ( f'
  • 下载 PDF
  • ' ) if not report_links: report_links = "
  • 报告生成中,交付物就绪后这里会显示查看/下载入口。
  • " 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"""

    通知已发送

    {title}

    {body}

    发送时间:{sent_at}

    """ summary_html = "" intake_summary = context.get("intake_summary") or {} attachment_count = int(context.get("attachment_count") or 0) attachments = context.get("attachments") or [] if intake_summary or attachments: summary_items = [ f"
  • 分数:{escape(str(intake_summary.get('candidate_score') or '-'))}
  • ", f"
  • 位次:{escape(str(intake_summary.get('candidate_rank') or '-'))}
  • ", f"
  • 选科:{escape(','.join(intake_summary.get('candidate_subjects') or [])) or '-'}
  • ", f"
  • 兴趣方向:{escape(str(intake_summary.get('candidate_interests') or '-'))}
  • ", f"
  • 目标城市:{escape(','.join(intake_summary.get('target_cities') or [])) or '-'}
  • ", f"
  • 目标专业:{escape(','.join(intake_summary.get('target_majors') or [])) or '-'}
  • ", f"
  • 院校偏好:{escape(str(intake_summary.get('university_preferences') or '-'))}
  • ", f"
  • 已有方案说明:{escape(str(intake_summary.get('existing_plan_summary') or '-'))}
  • ", f"
  • 附件数量:{attachment_count}
  • ", ] attachment_lines = [] for item in attachments: if not isinstance(item, dict): continue attachment_lines.append( f"
  • {escape(str(item.get('original_name') or item.get('stored_name') or '未命名附件'))}" f"{escape(str(item.get('content_type') or '-'))} · {escape(str(item.get('size_bytes') or '0'))} bytes
  • " ) attachment_html = "".join(attachment_lines) or "
  • 暂无附件
  • " summary_html = f"""

    当前资料摘要

    已上传附件

    """ payment_status = escape(str(context.get("payment_status") or "pending")) delivery_html = f"""

    支付与交付状态

    支付状态{payment_status}
    资料状态{escape(context["stage_title"])}
    HTML 报告{"已就绪" if context["report_html_ready"] else "未就绪"}
    PDF 报告{"已就绪" if context["report_pdf_ready"] else "未就绪"}
    交付阶段{escape(context["stage"])}
    """ share_status_panel = _render_share_status_panel( result_type="report", token=token, settings=settings, ) return f""" 订单进度总览 {_render_global_nav()}
    订单进度总览

    {escape(context["stage_title"])}

    1. 支付成功
    2. 资料处理中
    3. 报告交付

    {escape(context["stage_subtitle"])}

    当前阶段:{escape(context["stage"])}
    订单号{escape(order.id)}
    服务版本{escape(order.service_version)}
    当前订单状态{escape(order.status)}
    Step 1 最小建档{"已完成" if context["profile_minimum_complete"] else "未完成"}
    {summary_html}
    {delivery_html}
    {share_status_panel} {station_notice_html}

    Step 1 最小建档状态

    • Step 1 最小建档:{"已完成" if context["profile_minimum_complete"] else "未完成"}
    • 缺失字段:{escape("、".join(context["profile_missing_fields"]) or "无")}

    下一步操作

    {_render_delivery_next_steps(token, stage)}
    {_render_global_toast_script()} """ def _render_notification_audit_page(token: str, order: Order, events: list[Any]) -> str: rows = [] for event in events: failure_reason = escape(str(event.failure_reason or "-")) rows.append( "" f"{escape(str(event.channel))}" f"{escape(str(event.event_type))}" f"{escape(str(event.status))}" f"{escape(str(event.attempt_count))}" f"{escape(str(event.last_attempt_at or '-'))}" f"{failure_reason}" "" ) rows_html = "".join(rows) or "暂无通知事件" return f""" 通知审计 {_render_global_nav()}

    通知审计

    订单号:{escape(order.id)} · 服务版本:{escape(order.service_version)}

    返回订单状态页

    这里只展示通知摘要,方便你确认系统是否已经发出站内提醒或邮件通知;原始 payload 与附件路径不会在前台显示。

    {rows_html}
    渠道事件状态尝试次数最后尝试时间失败原因
    {_render_global_toast_script()} """ def _report_version_label(order: Order) -> str: timestamp = ( order.delivered_at or order.completed_at or order.paid_at or order.created_at ) return f"report-{timestamp}" if timestamp else f"report-{order.id}" def _profile_version_label(context: dict[str, Any]) -> str: intake_summary = context.get("intake_summary") or {} if context.get("profile_minimum_complete"): province = intake_summary.get("candidate_province") or "unknown" score = intake_summary.get("candidate_score") or "x" rank = intake_summary.get("candidate_rank") or "x" return f"profile-step1-{province}-{score}-{rank}" return "profile-step1-incomplete" def _review_version_label(contract: ReviewResultContract | None) -> str: if contract is None: return "review-none" return contract.review_result_id def _review_history_summary(order_id: str, settings: Settings) -> str: intake_store = IntakeStore.for_db(settings.orders_db_path) try: current = intake_store.get(order_id) finally: intake_store.close() if current is None: return "暂无版本历史" review_map = dict(current.payload.get("review_results") or {}) if not review_map: return "暂无版本历史" return f"版本历史:{len(review_map)} 个版本,最新 {escape(str(current.payload.get('latest_review_result_id') or 'unknown'))}" def _render_report_shell( token: str, order: Order, context: dict[str, Any], report_body_html: str, settings: Settings, ) -> str: contract = _load_latest_review_result(order.id, settings) payload = _current_intake_payload(context) report_version = _report_version_label(order) latest_profile_version = ( _profile_version_label_from_payload(payload) if payload else _profile_version_label(context) ) profile_version = _report_version_profile_reference( payload, report_version_id=report_version, fallback_profile_version_id=latest_profile_version, ) review_version = _review_version_label(contract) history_summary = _review_history_summary(order.id, settings) _sync_report_version_metadata( order.id, settings, report_version_id=report_version, profile_version_id=profile_version, review_result_version_id=(review_version if contract is not None else None), artifact_refs={ "audit_report": order.audit_report, "pdf_path": order.pdf_path, "plan_file": order.plan_file, }, ) next_href_map = { "cwb": f"/portal/{escape(token)}/cwb", "step1": f"/portal/{escape(token)}/info", "full_plan": f"/portal/{escape(token)}/full-plan", "none": f"/portal/{escape(token)}/full-plan", } next_href = next_href_map.get( (contract.review_followup_action if contract is not None else "none"), f"/portal/{escape(token)}/full-plan", ) review_summary = ( f"
    {escape(json.dumps(contract.model_dump(), ensure_ascii=False, indent=2))}
    " if contract is not None else "

    当前暂无复核结果,可先从状态页或首页进入方案复核。

    " ) policy_href, same_score_href = _helper_entry_hrefs(payload, order) auxiliary_html = _render_auxiliary_factor_section(payload) version_state = _profile_version_state(payload, profile_version) return f"""志愿报告资产页{_render_global_nav()}

    志愿报告资产页

    报告版本:{escape(report_version)} · 基于哪个档案版本:{escape(profile_version)} · 当前复核版本:{escape(review_version)}

    {escape(version_state)} · {history_summary}

    {_render_share_status_panel(result_type="report", token=token, settings=settings)}

    当前复核摘要

    {review_summary}
    {auxiliary_html}

    原始报告内容

    {report_body_html}

    下一步建议

    {_render_footer_links(token)}
    {_render_global_toast_script()}""" def _render_report_page(order: Order, settings: Settings) -> str: token = issue_portal_token(order.id, settings.portal_token_secret) context = _build_portal_context(order, settings) if ( order.audit_report and _is_trusted_report_path(order.audit_report, settings) 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 _render_report_shell(token, order, context, content, settings) if path.suffix.lower() == ".json": pretty = json.dumps(json.loads(content), ensure_ascii=False, indent=2) return _render_report_shell( token, order, context, f"
    {escape(pretty)}
    ", settings ) return _render_report_shell( token, order, context, f"
    {escape(content)}
    ", settings ) if ( order.plan_file and _is_trusted_report_path(order.plan_file, settings) and Path(order.plan_file).is_file() ): content = Path(order.plan_file).read_text(encoding="utf-8") return _render_report_shell( token, order, context, f"
    {escape(content)}
    ", settings ) raise HTTPException(status_code=409, detail="report not ready") def _review_payload_key() -> str: return "review_result_contract" def _review_constraints_subjects_text(value: Any) -> str: if isinstance(value, list): parts = [str(item).strip() for item in value if str(item).strip()] return " / ".join(parts) or "待补充" text = str(value or "").strip() return text or "待补充" def _review_constraints_display(value: Any) -> str: text = str(value or "").strip() return text or "待补充" def _get_crowd_db_recs_for_review(constraints: dict[str, Any]) -> list[dict[str, Any]]: """根据当前约束从 crowd_db 取同分段参考。""" province = str(constraints.get("candidate_province") or "").strip() score = constraints.get("candidate_score") if not province or score in (None, ""): return [] try: score_int = int(str(score)) except (TypeError, ValueError): return [] try: loader = CrowdDBLoader(warn_low_confidence=False) return loader.find_recommendations(province, score_int) except Exception: return [] def _llm_review_contract( *, settings: Settings, review_result_id: str, source: Literal["home", "status", "report", "direct"], resolved_summary: str, resolved_constraints: dict[str, Any], resolved_attachments: list[str], ) -> ReviewResultContract | None: """尝试用 LLM 生成审核结果;未配置或失败时返回 None。""" client = LLMClient(settings) if not client.is_configured: return None province = ( str(resolved_constraints.get("candidate_province") or "").strip() or "湖南" ) score = resolved_constraints.get("candidate_score") rank = resolved_constraints.get("candidate_rank") subjects = list(resolved_constraints.get("candidate_subjects") or []) crowd_recs = _get_crowd_db_recs_for_review(resolved_constraints) system, user = build_audit_prompt( province=province, score=int(str(score)) if score not in (None, "") else None, rank=int(str(rank)) if rank not in (None, "") else None, subjects=[str(s) for s in subjects], existing_plan=resolved_summary, crowd_db_recs=crowd_recs, ) try: resp = client.chat_with_system(system, user, temperature=0.3) data = json.loads(resp.content) except (LLMError, json.JSONDecodeError, TypeError, ValueError): return None risk_level = str(data.get("risk_level") or "medium").lower() if risk_level not in {"low", "medium", "high"}: risk_level = "medium" findings = [ str(x).strip() for x in list(data.get("key_findings") or []) if str(x).strip() ][:5] if not findings: findings = [str(data.get("risk_summary") or "当前方案可继续复核")] cwb = data.get("cwb_suggestions") or {} cwb_suggestions = { "rush": [ f"{item.get('school', '?')} - {item.get('major', '?')}" for item in list(cwb.get("rush") or []) if isinstance(item, dict) ], "stable": [ f"{item.get('school', '?')} - {item.get('major', '?')}" for item in list(cwb.get("stable") or []) if isinstance(item, dict) ], "safety": [ f"{item.get('school', '?')} - {item.get('major', '?')}" for item in list(cwb.get("safety") or []) if isinstance(item, dict) ], } profile_ready = _is_profile_minimum_complete(resolved_constraints) recommended_action: Literal["go_cwb", "go_step1", "go_full_plan"] = ( "go_cwb" if profile_ready else "go_step1" ) return ReviewResultContract( review_result_id=review_result_id, risk_level=risk_level, # type: ignore[arg-type] top_findings=findings, recommended_action=recommended_action, available_actions=["go_cwb", "go_step1", "go_full_plan"], review_entry_source=source, review_followup_action="none", review_input_summary=resolved_summary or "未提供现有方案说明", review_input_attachments=resolved_attachments, review_constraints=resolved_constraints, llm_generated=True, llm_summary=str(data.get("risk_summary") or ""), llm_cwb_suggestions=cwb_suggestions, ) def _start_review_result( *, source: Literal["home", "status", "report", "direct"], token: str | None, settings: Settings, review_input_summary: str | None = None, review_constraints: dict[str, Any] | None = None, ) -> ReviewResultContract: resolved_summary = (review_input_summary or "").strip() resolved_constraints = dict(review_constraints or {}) resolved_attachments: list[str] = [] order = None if token: order = _resolve_order_from_token(token, settings) context = _build_portal_context(order, settings) intake_summary = context.get("intake_summary") or {} if not resolved_summary: resolved_summary = str( intake_summary.get("existing_plan_summary") or "" ).strip() if not resolved_constraints: resolved_constraints = { "candidate_province": intake_summary.get("candidate_province") or order.candidate_province or "", "candidate_subjects": intake_summary.get("candidate_subjects") or order.candidate_subjects or [], "candidate_score": intake_summary.get("candidate_score") or order.candidate_score, "candidate_rank": intake_summary.get("candidate_rank") or order.candidate_rank, } resolved_attachments = [ str(item.get("original_name") or item.get("stored_name") or "").strip() for item in list(context.get("attachments") or []) if isinstance(item, dict) and (item.get("original_name") or item.get("stored_name")) ] existing = _load_latest_review_result(order.id, settings) if existing is not None: existing_summary = (existing.review_input_summary or "").strip() if existing_summary == "未提供现有方案说明": existing_summary = "" existing_constraints = dict(existing.review_constraints or {}) existing_attachments = [ str(item).strip() for item in existing.review_input_attachments if str(item).strip() ] if ( existing_summary == resolved_summary and existing_constraints == resolved_constraints and existing_attachments == resolved_attachments ): return existing review_result_id = f"rvw_{secrets.token_hex(4)}" profile_ready = _is_profile_minimum_complete(resolved_constraints) recommended_action: Literal["go_cwb", "go_step1", "go_full_plan"] = ( "go_cwb" if profile_ready else "go_step1" ) contract = _llm_review_contract( settings=settings, review_result_id=review_result_id, source=source, resolved_summary=resolved_summary, resolved_constraints=resolved_constraints, resolved_attachments=resolved_attachments, ) if contract is None: top_finding = ( "Step 1 已齐全,可直接进入冲稳保微调,再决定是否进入完整规划。" if profile_ready else "当前方案建议先补充 Step 1 后再继续判断梯度风险。" ) contract = ReviewResultContract( review_result_id=review_result_id, risk_level="medium", top_findings=[top_finding], recommended_action=recommended_action, available_actions=["go_cwb", "go_step1", "go_full_plan"], review_entry_source=source, review_followup_action="none", review_input_summary=resolved_summary, review_input_attachments=resolved_attachments, review_constraints=resolved_constraints, ) if order is not None: 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 {} payload["latest_review_result_id"] = contract.review_result_id review_map = dict(payload.get("review_results") or {}) review_map[contract.review_result_id] = contract.model_dump() payload["review_results"] = review_map intake_store.save( order_id=order.id, payload=payload, submit=(current.status == "submitted") if current is not None else False, ) finally: intake_store.close() return contract def _render_review_start_page(contract: ReviewResultContract, token: str | None) -> str: token_input = ( f'' if token else "" ) findings_html = ( "".join(f"
  • {escape(str(item))}
  • " for item in contract.top_findings) or "
  • 暂无核心问题
  • " ) attachment_html = ( "、".join( escape(str(item)) for item in contract.review_input_attachments if str(item).strip() ) or "无附件" ) llm_summary_html = ( f'

    AI 风险总结

    {escape(contract.llm_summary)}

    ' if contract.llm_generated and contract.llm_summary else "" ) constraints = contract.review_constraints or {} profile_ready = bool( constraints.get("candidate_province") and constraints.get("candidate_score") ) recommended_label = { "go_cwb": "先去看冲稳保建议", "go_step1": "先补齐基础信息", "go_full_plan": "进入完整规划", }[contract.recommended_action] # 根据是否有token决定按钮行为:无token时跳首页表单,有token时走portal流程 if token is None: # 免费复核无订单场景:引导用户先去首页完善信息或下单 primary_action = ( "完善信息后重新复核", "home", "当前是免费复核,建议先完善分数、位次、选科等信息,或选择付费服务获得完整分析。", ) else: # 有订单场景:跳转到portal相应页面 primary_action = { "go_cwb": ( "去看冲稳保建议", "cwb", "如果你的基础信息已经完整,下一步先看冲稳保梯度更合适。", ), "go_step1": ( "先补齐基础信息", "step1", "当前更适合先补齐省份、分数、位次、选科等基础信息,再继续判断梯度风险。", ), "go_full_plan": ( "进入完整规划", "full_plan", "如果你已经明确要进入完整服务,可以直接继续到完整规划页。", ), }[contract.recommended_action] summary = escape(contract.review_input_summary or "未提供现有方案说明") # 风险等级视觉映射 risk_level_map = { "low": ("偏低(风险可控)", "#1f6feb", "#eef6ff", "#d7e3f1"), "medium": ("中等(需要关注)", "#f59e0b", "#fffbeb", "#fcd34d"), "high": ("偏高(尽快调整)", "#dc2626", "#fef2f2", "#fecaca"), } risk_label, risk_color, risk_bg, risk_border = risk_level_map.get( contract.risk_level.lower(), risk_level_map["medium"] ) # 建议标签 recommended_label = { "go_cwb": "先去看冲稳保建议", "go_step1": "先补齐基础信息", "go_full_plan": "进入完整规划", }[contract.recommended_action] province = escape( _review_constraints_display(constraints.get("candidate_province")) ) subjects = escape( _review_constraints_subjects_text(constraints.get("candidate_subjects")) ) score = escape(_review_constraints_display(constraints.get("candidate_score"))) # 信息完整性提示 info_complete_html = ( ( '
    ' '

    ⚠ 信息不完整:选科组合或位次为"待补充",无法给出具体录取差距分析。

    ' "
    " ) if not profile_ready else ( '
    ' '

    ✓ 信息完整:已具备生成冲稳保建议的条件。

    ' "
    " ) ) rank = escape(_review_constraints_display(constraints.get("candidate_rank"))) share_hint = "此页可生成正式分享链接" body_html = f"""
    免费复核结果 返回首页

    复核结果

    基于你当前提交的信息,下面先告诉你当前的风险判断和最适合的下一步。

    分享给家人商量

    {share_hint}。生成的将是独立分享链接,而不是当前页面地址。

    {_render_share_status_panel(result_type="review_result", token=token or "", settings=None)} {llm_summary_html}

    你当前提交的信息

    {info_complete_html}

    初步评估结果

    风险等级:{risk_label} 建议:{recommended_label}

    核心问题

    风险等级说明:低 = 当前方案结构基本合理,微调即可;中 = 存在踩线或扎堆风险,需要进一步判断;高 = 存在明显梯度失衡或结构风险,建议尽快调整。

    下一步建议

    {escape(primary_action[2])}

    {token_input}
    {token_input}
    进入完整规划(付费)

    免费复核帮你判断风险方向;完整规划和深度辅导在支付后启动,会给你逐志愿解析、冲稳保梯度表和交付报告。

    {_render_share_link_script(result_type="review_result", token=token or "", copy_id="copy-link-btn", share_id="share-btn", status_id="share-status", title="高考志愿复核结果")} """ return _render_placeholder_shell( title="复核结果", max_width=960, body_html=body_html ) def _apply_review_followup_action( token: str, action: Literal["cwb", "step1", "full_plan"], settings: Settings ) -> ReviewActionResponse: order = _resolve_order_from_token(token, settings) intake_store = IntakeStore.for_db(settings.orders_db_path) try: current = intake_store.get(order.id) if current is None: raise HTTPException(status_code=404, detail="review result not found") payload = dict(current.payload) review_id = payload.get("latest_review_result_id") review_map = dict(payload.get("review_results") or {}) contract_raw = review_map.get(review_id) if review_id else None if not isinstance(contract_raw, dict): raise HTTPException(status_code=404, detail="review result not found") contract = ReviewResultContract.model_validate(contract_raw) updated = contract.model_copy(update={"review_followup_action": action}) review_map[updated.review_result_id] = updated.model_dump() payload["review_results"] = review_map intake_store.save( order_id=order.id, payload=payload, submit=(current.status == "submitted"), ) finally: intake_store.close() next_href = { "cwb": f"/portal/{token}/cwb", "step1": f"/portal/{token}/info", "full_plan": f"/portal/{token}/full-plan", }[action] return ReviewActionResponse( review_result_id=updated.review_result_id, review_followup_action=action, next_href=next_href, ) def _load_latest_review_result( order_id: str, settings: Settings ) -> ReviewResultContract | None: intake_store = IntakeStore.for_db(settings.orders_db_path) try: current = intake_store.get(order_id) finally: intake_store.close() if current is None: return None review_id = current.payload.get("latest_review_result_id") review_map = dict(current.payload.get("review_results") or {}) contract_raw = review_map.get(review_id) if review_id else None if not isinstance(contract_raw, dict): return None return ReviewResultContract.model_validate(contract_raw) def _render_cwb_placeholder_page( token: str, order: Order, contract: ReviewResultContract | None, context: dict[str, Any], ) -> str: payload = ( contract.model_dump() if contract is not None else {"review_followup_action": "cwb"} ) intake_payload = _current_intake_payload(context) policy_href, same_score_href = _helper_entry_hrefs(intake_payload, order) auxiliary_html = _render_auxiliary_factor_section(intake_payload) findings = contract.top_findings if contract is not None else [] findings_html = ( "".join(f"
  • {escape(str(item))}
  • " for item in findings) or "
  • 当前暂无复核摘要,请先回到复核入口。
  • " ) recommendation = { "go_cwb": "优先微调冲稳保梯度,先补齐冲刺 / 稳妥 / 保底三档,再决定是否进入完整规划。", "go_step1": "先补齐 Step 1 的省份 / 分数 / 位次 / 选科,再回到冲稳保判断。", "go_full_plan": "当前资料已可进入完整规划,建议继续补学校、专业与家庭约束。", }.get( (contract.recommended_action if contract is not None else "go_step1"), "先补齐关键信息再继续。", ) # 基于考生分数和 crowd_db 生成三档真实建议 candidate_score_raw = intake_payload.get("candidate_score") or order.candidate_score candidate_province = ( intake_payload.get("candidate_province") or order.candidate_province or "湖南" ) candidate_score = None try: candidate_score = int(candidate_score_raw) if candidate_score_raw else None except (ValueError, TypeError): pass def _cwb_tier(title: str, offset: int, color: str) -> str: """根据分数偏移生成一档建议,优先使用 LLM 建议,fallback 到 crowd_db。""" # 1. 优先使用 LLM 生成的三档建议 tier_key = {"冲刺建议": "rush", "稳妥建议": "stable", "保底建议": "safety"}.get( title ) if contract is not None and contract.llm_cwb_suggestions and tier_key: llm_suggestions = contract.llm_cwb_suggestions.get(tier_key) or [] if llm_suggestions: schools_html = "".join( f"
  • {escape(s)}
  • " for s in llm_suggestions[:3] ) return ( f'
    ' f"

    {title}

    " f'' f'

    🤖 LLM 结合你的分数、位次和同分段数据生成

    ' f"
    " ) # 2. fallback 到 crowd_db if candidate_score is None: return ( f'
    ' f"

    {title}

    " f'

    补齐当前分数后,这里会基于同分段数据给出具体的院校方向建议。

    ' f"
    " ) target_score = candidate_score + offset try: from data.crowd_db.loader import CrowdDBLoader loader = CrowdDBLoader(warn_low_confidence=False) recs = loader.find_recommendations(candidate_province, target_score) schools = [ f"{r.get('name', '?')} - {r.get('major', '?')}" for r in recs[:3] if isinstance(r, dict) ] if not schools: schools = ["该分段暂无 crowd_db 推荐,请参考同分段参考页"] except Exception: schools = ["数据加载中,请稍后查看"] risk_note = "" if offset > 0: risk_note = f'

    ⚠ 这档分数要求更高(目标分 ≈ {target_score}),存在不被录取的风险,只作为冲刺方向参考。

    ' elif offset < 0: risk_note = f'

    ✓ 这档分数要求更低(目标分 ≈ {target_score}),录取概率更高,作为保底。

    ' else: risk_note = f'

    → 围绕当前分数({candidate_score})匹配,作为主力选择。

    ' schools_html = "".join(f"
  • {escape(s)}
  • " for s in schools) return ( f'
    ' f"

    {title}

    " f'' f"{risk_note}" f"
    " ) cwb_tier_html = ( f'
    ' f"{_cwb_tier('冲刺建议', 20, '#fff5f5')}" f"{_cwb_tier('稳妥建议', 0, '#f0f7ff')}" f"{_cwb_tier('保底建议', -20, '#f0fff4')}" f"
    " ) score_context = ( f'

    当前分数:{escape(str(candidate_score or "待补充"))} · 省份:{escape(candidate_province)} · 冲刺+20分 / 稳妥0分 / 保底-20分

    ' if candidate_score else "" ) body_html = f"""

    冲稳保建议页

    订单号:{escape(order.id)}。基于你当前分数和同分段数据,这里给出三档策略建议。

    {score_context}

    当前建议

    {escape(recommendation)}

    {cwb_tier_html}

    当前复核摘要

    {escape(json.dumps(payload, ensure_ascii=False, indent=2))}
    {auxiliary_html}

    下一步建议

    """ return _render_placeholder_shell( title="冲稳保建议页", max_width=1080, body_html=body_html ) def _render_full_plan_placeholder_page( token: str, order: Order, context: dict[str, Any], contract: ReviewResultContract | None, settings: Settings | None = None, ) -> str: payload = ( contract.model_dump() if contract is not None else {"review_followup_action": "full_plan"} ) missing = "、".join(context.get("profile_missing_fields") or []) or "无" intake_payload = _current_intake_payload(context) versions = [ item for item in list(intake_payload.get("profile_versions") or []) if isinstance(item, dict) ] version_lines = ( "".join( f"
  • {escape(str(item.get('stage_label') or item.get('profile_version_id') or '未知版本'))}
  • " for item in versions ) or "
  • 暂无版本历史
  • " ) auxiliary_html = _render_auxiliary_factor_section(intake_payload) # LLM 生成完整规划方案(如果配置了 LLM) llm_plan_html = "" llm_client = LLMClient(settings) if settings else None if llm_client and llm_client.is_configured: try: intake_summary = context.get("intake_summary") or {} crowd_recs = _get_crowd_db_recs_for_review(intake_summary) system, user = build_full_plan_prompt( province=str( intake_summary.get("candidate_province") or order.candidate_province or "湖南" ), score=int( intake_summary.get("candidate_score") or order.candidate_score or 0 ) if intake_summary.get("candidate_score") or order.candidate_score else 0, rank=int( intake_summary.get("candidate_rank") or order.candidate_rank or 0 ) if intake_summary.get("candidate_rank") or order.candidate_rank else None, subjects=list(intake_summary.get("candidate_subjects") or []), target_cities=list(intake_summary.get("target_cities") or []), target_majors=list(intake_summary.get("target_majors") or []), family_background=str(intake_summary.get("family_background") or ""), interest_assessment=str( intake_summary.get("interest_assessment_result") or "" ), existing_plan=str(intake_summary.get("existing_plan_summary") or ""), crowd_db_recs=crowd_recs, ) resp = llm_client.chat_with_system(system, user, temperature=0.5) plan_data = json.loads(resp.content) volunteers = plan_data.get("volunteers") or [] vol_rows = [] for v in volunteers: tier_badge = {"冲": "🔴", "稳": "🟢", "保": "🔵"}.get( v.get("tier", ""), "•" ) vol_rows.append( f'{tier_badge} {escape(v.get("tier", ""))}' f'{escape(v.get("school", ""))}' f'{escape(v.get("major", ""))}' f'{escape(v.get("reason", ""))}' f"" ) llm_plan_html = ( f'

    🤖 AI 生成的完整志愿方案

    ' f'

    {escape(plan_data.get("overall_assessment", ""))}

    ' f'

    核心策略:{escape(plan_data.get("strategy", ""))}

    ' f'' f'' f'' f'' f"{''.join(vol_rows)}
    档位院校专业推荐理由
    " f"
    " ) warnings = plan_data.get("warnings") or [] if warnings: warn_html = "".join(f"
  • {escape(str(w))}
  • " for w in warnings[:5]) llm_plan_html += f'

    ⚠️ 注意事项

    ' except (LLMError, json.JSONDecodeError, TypeError, ValueError): llm_plan_html = '

    AI 方案生成

    AI 方案生成暂时不可用,请稍后重试或联系客服。

    ' planning_summary = ( "当前资料已进入完整规划阶段,可继续围绕学校、专业、预算与家庭约束做结构化取舍。" ) body_html = f"""

    完整规划建议页

    订单号:{escape(order.id)}。这里承接复核后的完整规划建议,不再只是入口说明。

    {escape(planning_summary)}

    {llm_plan_html}

    方案优先级

    版本历史

    {auxiliary_html}

    当前复核摘要

    {escape(json.dumps(payload, ensure_ascii=False, indent=2))}
    继续补充资料
    """ return _render_placeholder_shell( title="完整规划建议页", max_width=960, body_html=body_html ) def _render_deletion_request_page(token: str, order: Order, settings: Settings) -> str: # 2026-06-19 T12-C: 让用户先看见"我的订单处于哪个保留期阶段", 决定是否现在就申请 retention_status = _resolve_retention_status(order, settings) if retention_status == "outside": retention_card = ( "
    " "保留期状态" "

    已超过 180 天保留期,可申请删除订单资料、附件与交付物。" "提交后我们会同步做人工核验,并在订单状态链路中继续反馈处理进度。

    " "
    " ) elif retention_status == "within": retention_card = ( "
    " "保留期状态" "

    当前订单仍在 180 天保留期内。提交删除申请后,我们仍会先做人工核验," "并按既定保留期规则处理订单、资料与附件。

    " "
    " ) else: retention_card = ( "
    " "保留期状态" "

    本订单当前不需要走保留期清理流程(尚未进入服务完成阶段)。" "如需撤回资料或申请删除,提交申请后我们会先做人工核验。

    " "
    " ) return f""" 删除申请 {_render_global_nav()}
    删除申请

    提交删除申请

    可用于申请删除资料、附件或交付物。提交后,系统会保留必要审计记录,并由人工核验订单与监护人信息后处理。

    {retention_card}

    订单号:{escape(order.id)}

    {_render_footer_links(token)}
    {_render_global_toast_script()}"""