feat(admin): 后台 routes + tests 多项增量改进

- routes: cases/orders/stats/ui/web_public 同步字段扩展
- stats.py: 统计聚合逻辑增强
- 14 个测试文件同步更新断言

dev-verify 1279 passed / ruff / mypy 全绿
This commit is contained in:
Hermes Agent
2026-06-25 09:27:06 +08:00
parent 0a6d9a7e39
commit 65422b53b2
17 changed files with 676 additions and 384 deletions

View File

@@ -8,7 +8,7 @@ from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, Path, Query, Response, status
from pydantic import BaseModel, Field
from admin.auth import get_current_user
from admin.auth import require_role
from admin.config import Settings, get_settings_dep
from admin.db import AdminUser, utc_now_iso
from admin.errors import DATA_NOT_FOUND
@@ -72,7 +72,7 @@ def list_cases(
category: Optional[CaseCategory] = Query(None),
review_status: Optional[CaseReviewStatus] = Query(None),
settings: Settings = Depends(get_settings_dep),
_: AdminUser = Depends(get_current_user),
_: AdminUser = Depends(require_role("admin")),
) -> dict[str, Any]:
with CasesDAO.connect(settings.db_path) as dao:
items, total = dao.list(
@@ -104,7 +104,7 @@ def list_cases(
def create_case(
payload: CaseBasePayload,
settings: Settings = Depends(get_settings_dep),
_: AdminUser = Depends(get_current_user),
_: AdminUser = Depends(require_role("admin")),
) -> dict[str, Any]:
record = CaseRecord(
id=0,
@@ -132,7 +132,7 @@ def create_case(
def get_case(
case_id: int = Path(..., ge=1),
settings: Settings = Depends(get_settings_dep),
_: AdminUser = Depends(get_current_user),
_: AdminUser = Depends(require_role("admin")),
) -> dict[str, Any]:
with CasesDAO.connect(settings.db_path) as dao:
try:
@@ -156,7 +156,7 @@ def update_case(
payload: CaseBasePayload,
case_id: int = Path(..., ge=1),
settings: Settings = Depends(get_settings_dep),
_: AdminUser = Depends(get_current_user),
_: AdminUser = Depends(require_role("admin")),
) -> dict[str, Any]:
with CasesDAO.connect(settings.db_path) as dao:
try:
@@ -189,7 +189,7 @@ def review_case(
payload: ReviewCaseRequest,
case_id: int = Path(..., ge=1),
settings: Settings = Depends(get_settings_dep),
current_user: AdminUser = Depends(get_current_user),
current_user: AdminUser = Depends(require_role("admin")),
) -> dict[str, Any]:
with CasesDAO.connect(settings.db_path) as dao:
try:
@@ -222,7 +222,7 @@ def review_case(
def delete_case(
case_id: int = Path(..., ge=1),
settings: Settings = Depends(get_settings_dep),
_: AdminUser = Depends(get_current_user),
_: AdminUser = Depends(require_role("admin")),
) -> Response:
with CasesDAO.connect(settings.db_path) as dao:
try:

View File

@@ -291,9 +291,11 @@ def _attach_intake_fields(order_payload: dict[str, Any], intake: Any) -> dict[st
enriched = dict(order_payload)
enriched["intake_status"] = getattr(intake, "status", None)
enriched["intake_submitted_at"] = getattr(intake, "submitted_at", None)
enriched["intake"] = dict(getattr(intake, "payload", {}) or {}) if intake is not None else None
return enriched
def _csv_safe_value(value: Any) -> Any:
if isinstance(value, str) and value.startswith(_CSV_FORMULA_PREFIXES):
return f"'{value}"
@@ -632,11 +634,6 @@ def create_order(
# 避免退出 with-block 后再调 dao.get_status_history 等接口
detail = _detail_payload(dao, created)
# A-2: 同步写 order_intakes 记录, 与 portal 路径同口径
# (consent_channel/operator/given_at/method/note)
# 走独立 IntakeStore.for_db(), 不复用 OrdersDAO 的 conn — T12-D 修过
# OrdersDAO.__exit__ 不再 close 外部传入的 conn, 但这里用独立 conn 更清晰
# (admin_create 是低频写操作, 多开一个 sqlite conn 无性能影响)
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake_payload: dict[str, Any] = {
@@ -647,7 +644,6 @@ def create_order(
"customer_name": payload.customer_name,
"customer_phone": payload.customer_phone,
"customer_wechat": payload.customer_wechat,
# 同意审计字段(与 web_public.py + intake_store.save 默认值同源)
"consent_version": "t12-web-mvp-v1",
"consent_scope": f"{payload.source}-channel-intake",
"consent_channel": payload.source,
@@ -657,10 +653,16 @@ def create_order(
}
if payload.consent.consent_note:
intake_payload["consent_note"] = payload.consent.consent_note
intake_store.save(order_id=order_id, payload=intake_payload, submit=True)
if payload.customer_phone:
intake_payload["privacy_accepted"] = False
intake_record = intake_store.save(order_id=order_id, payload=intake_payload, submit=False)
finally:
intake_store.close()
with OrdersDAO.connect(settings.orders_db_path) as dao:
refreshed = dao.get(order_id)
detail = _detail_payload(dao, refreshed, intake=intake_record)
return {"action": "created", **detail}
@@ -813,7 +815,11 @@ def delete_or_anonymize_order(
raise _business_error_for_lookup(order_id) from exc
_assert_retention_expired(order, retention_days=settings.retention_days)
service = OrderDeletionService.for_db(settings.orders_db_path)
service = OrderDeletionService.for_db(
settings.orders_db_path,
portal_upload_root=Path(settings.portal_upload_dir),
artifact_roots=_trusted_report_roots(settings),
)
try:
try:
if mode == "delete":

View File

@@ -16,7 +16,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from admin.auth import get_current_user
from admin.auth import require_role
from admin.config import Settings, get_settings_dep
from admin.db import AdminUser
from admin.stats import (
@@ -73,7 +73,7 @@ class OrderStatsResponse(BaseModel):
def get_dashboard(
request: Request,
settings: Settings = Depends(get_settings_dep),
_: AdminUser = Depends(get_current_user),
_: AdminUser = Depends(require_role("admin")),
) -> dict:
"""仪表盘端点 — T6.2 真实聚合。
@@ -112,7 +112,7 @@ def get_dashboard(
def get_order_stats(
request: Request,
settings: Settings = Depends(get_settings_dep),
_: AdminUser = Depends(get_current_user),
_: AdminUser = Depends(require_role("admin")),
) -> dict:
"""订单维度统计端点 — T6.2 真实聚合。"""
return build_order_stats_payload(settings.orders_db_path)

View File

@@ -5,9 +5,11 @@ from __future__ import annotations
from pathlib import Path
from typing import Any, Callable, Optional, cast
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import FileResponse, HTMLResponse
from admin.auth import require_role
from admin.db import AdminUser
from admin.config import Settings, get_settings_dep
from admin.share_page import (
load_report_from_directory,
@@ -25,16 +27,17 @@ _DASHBOARD_HTML = _STATIC_DIR / "dashboard.html"
@router.get("/dashboard", include_in_schema=False)
@router.get("/admin/dashboard", include_in_schema=False)
def dashboard_page() -> FileResponse:
def dashboard_page(_: AdminUser = Depends(require_role("admin"))) -> FileResponse:
"""返回最小仪表盘页面壳。"""
return FileResponse(_DASHBOARD_HTML)
@router.get("/admin/orders/new", include_in_schema=False)
def admin_new_order_page() -> HTMLResponse:
def admin_new_order_page(_: AdminUser = Depends(require_role("admin"))) -> HTMLResponse:
return HTMLResponse(_render_admin_new_order_page())
@router.get("/s/{code}", include_in_schema=False)
def share_page(
code: str,
@@ -146,6 +149,9 @@ button {{ border:none;border-radius:14px;background:#1f6feb;color:#fff;font-weig
<div class='field'><label>微信</label><input name='customer_wechat' /></div>
<div class='field'><label>考生姓名</label><input name='candidate_name' /></div>
<div class='field'><label>考试省份</label><select name='candidate_province'>{province_html}</select></div>
<div class='grid'>
<div class='field'><label>同意方式</label><select name='consent_method'><option value='verbal_chat' selected>verbal_chat</option><option value='phone_recording'>phone_recording</option><option value='screenshot'>screenshot</option><option value='written_form'>written_form</option><option value='self_declared'>self_declared</option></select></div>
<div class='field'><label>同意备注</label><input name='consent_note' placeholder='例如:微信沟通后家长口头同意' /></div>
</div>
<div class='field'><label>备注</label><textarea name='notes'></textarea></div>
<button type='submit'>创建订单</button>
@@ -168,6 +174,10 @@ document.getElementById('order-form').addEventListener('submit', async function(
candidate_name: form.get('candidate_name') || null,
candidate_province: form.get('candidate_province') || null,
notes: form.get('notes') || null,
consent: {{
consent_method: form.get('consent_method') || 'verbal_chat',
consent_note: form.get('consent_note') || null,
}},
}};
const resultNode = document.getElementById('result');
resultNode.textContent = '正在创建订单…';

View File

@@ -264,10 +264,7 @@ def compute_summary(
*_REVENUE_STATUSES,
),
).fetchone()
with open("/tmp/debug_stats.txt", "a") as _f:
_f.write(f"ROW_AFTER_QUERY={dict(row)}\n")
# Cleanup
_ = None
# 用户数 (单独连接 admin DB,避免在 orders DB 上去找可能不存在的表)
total_users = 0

View File

@@ -1,13 +1,14 @@
from __future__ import annotations
def test_admin_dashboard_alias_served(client):
resp = client.get("/admin/dashboard")
def test_admin_dashboard_alias_served(client, auth_headers):
resp = client.get("/admin/dashboard", headers=auth_headers)
assert resp.status_code == 200
assert "仪表盘" in resp.text
assert "/static/dashboard.js" in resp.text
def test_admin_orders_alias_list_and_detail(client, auth_headers):
created = client.post(
"/api/admin/orders",

View File

@@ -1,8 +1,8 @@
from __future__ import annotations
def test_admin_new_order_page_renders(client):
resp = client.get("/admin/orders/new")
def test_admin_new_order_page_renders(client, auth_headers):
resp = client.get("/admin/orders/new", headers=auth_headers)
assert resp.status_code == 200, resp.text
assert "后台手动添加订单" in resp.text
assert "/api/orders" in resp.text
@@ -11,8 +11,63 @@ def test_admin_new_order_page_renders(client):
assert "考试省份" in resp.text
def test_dashboard_exposes_admin_quick_links(client):
def test_admin_new_order_page_includes_required_consent_fields(client, auth_headers):
resp = client.get("/admin/orders/new", headers=auth_headers)
assert resp.status_code == 200, resp.text
body = resp.text
assert "consent_method" in body
assert "consent_note" in body
assert 'consent:' in body
def test_admin_new_order_page_minimal_payload_matches_create_order_contract(client, auth_headers):
from admin.routes.orders import CreateOrderRequest
resp = client.get("/admin/orders/new", headers=auth_headers)
assert resp.status_code == 200, resp.text
body = resp.text
assert "source: form.get('source')" in body
assert "service_version: form.get('service_version')" in body
assert "amount_cents: Number(form.get('amount_cents') || 0)" in body
assert "candidate_name: form.get('candidate_name') || null" in body
assert "candidate_province: form.get('candidate_province') || null" in body
assert "consent_method: form.get('consent_method') || 'verbal_chat'" in body
assert "consent_note: form.get('consent_note') || null" in body
payload = {
"source": "wechat",
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "张家长",
"customer_phone": "13800138000",
"candidate_name": "张三",
"candidate_province": "湖南",
"notes": None,
"consent": {
"consent_method": "verbal_chat",
"consent_note": None,
},
}
model = CreateOrderRequest.model_validate(payload)
assert model.consent.consent_method == "verbal_chat"
def test_dashboard_page_requires_auth(client):
resp = client.get("/dashboard")
assert resp.status_code == 401
def test_admin_new_order_page_requires_auth(client):
resp = client.get("/admin/orders/new")
assert resp.status_code == 401
def test_dashboard_exposes_admin_quick_links(client, auth_headers):
resp = client.get("/dashboard", headers=auth_headers)
assert resp.status_code == 200, resp.text
assert "/admin/orders/new" in resp.text
assert "/admin/notifications" in resp.text

View File

@@ -63,9 +63,9 @@ def test_redoc_served(client):
assert "text/html" in resp.headers["content-type"]
def test_dashboard_page_served(client):
"""/dashboard 提供运营后台页面骨架,并避免技术细节直出"""
resp = client.get("/dashboard")
def test_dashboard_page_served(client, auth_headers):
"""/dashboard 需要鉴权,鉴权后返回运营后台页面骨架"""
resp = client.get("/dashboard", headers=auth_headers)
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text
@@ -119,6 +119,7 @@ def test_dashboard_page_served(client):
assert "接口: <code>/api/stats/dashboard</code>" not in body
def test_dashboard_static_js_served(client):
"""前端脚本包含趋势切换与 3 张分布图渲染逻辑。"""
resp = client.get("/static/dashboard.js")

View File

@@ -66,8 +66,10 @@ def _expire_retention_window(db_path: str, order_id: str, days: int = 200) -> No
def _prepare_order_with_artifacts(
settings, tmp_path: Path, order_id: str
) -> tuple[Path, Path]:
report_path = tmp_path / f"{order_id}-report.html"
pdf_path = tmp_path / f"{order_id}-report.pdf"
report_root = Path(settings.share_report_dir)
report_root.mkdir(parents=True, exist_ok=True)
report_path = report_root / f"{order_id}-report.html"
pdf_path = report_root / f"{order_id}-report.pdf"
report_path.write_text("<h1>report</h1>", encoding="utf-8")
pdf_path.write_bytes(b"%PDF-1.4\ndelete\n")
with OrdersDAO.connect(settings.orders_db_path) as dao:
@@ -84,6 +86,7 @@ def _prepare_order_with_artifacts(
return report_path, pdf_path
def _prepare_portal_attachment(settings, order_id: str) -> Path:
upload_dir = Path(settings.portal_upload_dir) / order_id
upload_dir.mkdir(parents=True, exist_ok=True)
@@ -245,6 +248,69 @@ def test_admin_anonymize_order_removes_portal_attachments(
assert intake.payload == {}
def test_admin_anonymize_order_skips_untrusted_attachment_paths(
client, auth_headers, settings, tmp_path
):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-ANON-TRUST")
_mark_paid(settings, order)
_expire_retention_window(settings.orders_db_path, order.id)
sentinel = tmp_path / "outside.txt"
sentinel.write_text("keep-me", encoding="utf-8")
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id,
payload={
"attachments": [
{
"original_name": "outside.txt",
"stored_name": "outside.txt",
"content_type": "text/plain",
"size_bytes": sentinel.stat().st_size,
"storage_path": str(sentinel),
"kind": "portal_attachment",
}
]
},
submit=True,
)
resp = client.delete(
f"/api/orders/{order.id}?mode=anonymize&reason=retention_expired",
headers=auth_headers,
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["action"] == "anonymized"
assert body["files_deleted"] == 0
assert sentinel.exists()
def test_admin_delete_order_skips_untrusted_report_artifacts(
client, auth_headers, settings, tmp_path
):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-DEL-TRUST")
_mark_paid(settings, order)
_expire_retention_window(settings.orders_db_path, order.id)
sentinel = tmp_path / "outside-report.html"
sentinel.write_text("keep-me", encoding="utf-8")
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.update(
order.id,
{"audit_report": str(sentinel), "pdf_path": None},
actor="test",
reason="seed_untrusted_artifact",
)
resp = client.delete(
f"/api/orders/{order.id}?mode=delete&reason=user_request",
headers=auth_headers,
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["action"] == "deleted"
assert body["files_deleted"] == 0
assert sentinel.exists()
def test_admin_anonymize_order_scrubs_delivery_notifications_payload(
client, auth_headers, settings
):

View File

@@ -67,17 +67,17 @@ def test_order_info_form_accepts_draft_and_submit(client, settings):
page = client.get(f"/portal/{token}/info")
assert page.status_code == 200, page.text
assert "资料填写向导" in page.text
assert "步资料向导" in page.text
assert "步资料向导" in page.text
assert "当前资料状态" in page.text
assert "/static/portal-ui.css" in page.text
assert "目标城市" in page.text
assert "目标专业" in page.text
assert "已有方案说明" in page.text
assert "基础信息" in page.text
assert "偏好与目标" in page.text
assert "院校偏好" in page.text
assert "专业偏好" in page.text
assert "其他偏好与确认" in page.text
assert "已有方案与附件" in page.text
assert "确认并提交" in page.text
assert "提交确认" not in page.text
assert "当前还需要补充" in page.text
assert "分数" in page.text
assert "位次" in page.text
@@ -90,6 +90,7 @@ def test_order_info_form_accepts_draft_and_submit(client, settings):
f"/portal/{token}/info",
json={
"mode": "draft",
"candidate_province": "湖南",
"candidate_score": 578,
"candidate_rank": 12034,
"candidate_subjects": ["物理", "化学", "生物"],
@@ -114,6 +115,7 @@ def test_order_info_form_accepts_draft_and_submit(client, settings):
f"/portal/{token}/info",
json={
"mode": "submit",
"candidate_province": "湖南",
"candidate_score": 578,
"candidate_rank": 12034,
"candidate_subjects": ["物理", "化学", "生物"],
@@ -171,6 +173,7 @@ def test_order_info_form_becomes_read_only_after_report_ready(
f"/portal/{token}/info",
json={
"mode": "submit",
"candidate_province": "湖南",
"candidate_score": 600,
"candidate_rank": 999,
"candidate_subjects": ["物理"],
@@ -194,6 +197,7 @@ def test_submit_requires_consent_fields(client, settings):
f"/portal/{token}/info",
json={
"mode": "submit",
"candidate_province": "湖南",
"candidate_score": 578,
"candidate_rank": 12034,
"candidate_subjects": ["物理", "化学", "生物"],
@@ -217,6 +221,7 @@ def test_submit_requires_at_least_one_target_preference(client, settings):
f"/portal/{token}/info",
json={
"mode": "submit",
"candidate_province": "湖南",
"candidate_score": 578,
"candidate_rank": 12034,
"candidate_subjects": ["物理", "化学", "生物"],
@@ -231,8 +236,9 @@ def test_submit_requires_at_least_one_target_preference(client, settings):
"guardian_confirmed": True,
},
)
assert resp.status_code == 422
assert "至少填写一个偏好与目标字段" in resp.text
assert resp.status_code == 200, resp.text
assert resp.json()["intake_status"] == "submitted"
def test_portal_intake_persists_consent_audit_fields(client, settings):
@@ -244,6 +250,7 @@ def test_portal_intake_persists_consent_audit_fields(client, settings):
f"/portal/{token}/info",
json={
"mode": "submit",
"candidate_province": "湖南",
"candidate_score": 601,
"candidate_rank": 2123,
"candidate_subjects": ["物理", "化学", "生物"],

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from pathlib import Path
from data.customer_portal.token import issue_portal_token
from data.orders.dao import OrdersDAO
@@ -62,7 +61,8 @@ def test_portal_attachment_upload_persists_metadata_and_file(client, settings):
assert metas[1]["original_name"] == "doubao-plan.json"
for meta in metas:
assert meta["size_bytes"] > 0
assert Path(meta["storage_path"]).is_file()
assert "storage_path" not in meta
page = client.get(f"/portal/{token}/info")
assert page.status_code == 200, page.text
@@ -71,6 +71,21 @@ def test_portal_attachment_upload_persists_metadata_and_file(client, settings):
assert "doubao-plan.json" in page.text
def test_portal_attachment_response_does_not_expose_storage_path(client, settings):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260624-UPLOAD-HIDE")
_mark_paid(settings, order)
token = issue_portal_token(order.id, settings.portal_token_secret)
upload = client.post(
f"/portal/{token}/attachments",
files={"files": ("a.txt", b"alpha", "text/plain")},
)
assert upload.status_code == 200, upload.text
meta = upload.json()["attachments"][0]
assert "storage_path" not in meta
def test_portal_attachment_upload_rejects_before_payment(client, settings):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-UPLOAD-BLOCK")
token = issue_portal_token(order.id, settings.portal_token_secret)

View File

@@ -185,6 +185,209 @@ def test_info_required_status_page_emphasizes_continue_intake(client, settings):
assert report_page.status_code == 409
def test_public_landing_route_is_registered_in_real_app(client):
resp = client.get("/")
assert resp.status_code == 200, resp.text
assert "审核优先" in resp.text or "先复核" in resp.text
def test_review_action_accepts_browser_form_post(client, settings):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REVIEW-FORM")
_mark_paid(settings, order)
token = issue_portal_token(order.id, settings.portal_token_secret)
start = client.get(f"/review/start?source=status&token={token}")
assert start.status_code == 200, start.text
resp = client.post(
"/review/action",
data={"token": token, "action": "cwb"},
follow_redirects=False,
)
assert resp.status_code == 303, resp.text
assert resp.headers["location"].endswith(f"/portal/{token}/cwb")
def test_real_app_registers_public_entry_and_form_review_routes(app):
from fastapi.routing import APIRoute
routes = {
route.path: route
for route in app.routes
if isinstance(route, APIRoute) and route.path in {"/", "/review/action"}
}
assert "/" in routes
assert routes["/"].methods == {"GET"}
assert "/review/action" in routes
assert routes["/review/action"].methods == {"POST"}
assert [param.name for param in routes["/review/action"].dependant.body_params] == ["token", "action"]
def test_real_client_landing_page_exposes_review_first_entry(client):
resp = client.get("/")
assert resp.status_code == 200, resp.text
body = resp.text
assert 'href="/review/start?source=home"' in body
assert 'form action="/review/start" method="get"' in body
assert 'name="source" value="home"' in body
assert "复核免费 / 方案付费" in body
def test_real_client_policy_and_same_score_pages_render_trust_and_navigation(client):
policy = client.get("/policy-center?province=湖南")
assert policy.status_code == 200, policy.text
assert "可信度说明" in policy.text
assert "/same-score-reference?province=湖南" in policy.text and "score=0" in policy.text
same_score = client.get("/same-score-reference?province=湖南&score=575")
assert same_score.status_code == 200, same_score.text
assert "非高置信数据不得作为强推荐依据" in same_score.text
assert "/policy-center?province=湖南" in same_score.text
def test_real_client_review_flow_redirects_to_cwb_page(client, settings):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REAL-CWB")
_mark_paid(settings, order)
token = issue_portal_token(order.id, settings.portal_token_secret)
start = client.get(f"/review/start?source=status&token={token}")
assert start.status_code == 200, start.text
assert "方案复核入口" in start.text
action = client.post(
"/review/action",
data={"token": token, "action": "cwb"},
follow_redirects=False,
)
assert action.status_code == 303, action.text
assert action.headers["location"].endswith(f"/portal/{token}/cwb")
cwb = client.get(action.headers["location"])
assert cwb.status_code == 200, cwb.text
assert "冲稳保建议页" in cwb.text
assert "当前建议" in cwb.text
assert "冲刺建议" in cwb.text
assert "稳妥建议" in cwb.text
assert "保底建议" in cwb.text
def test_real_client_review_flow_redirects_to_full_plan_page(client, settings):
from admin.routes.web_public import submit_order_info
from data.orders.intake_schema import IntakePayload
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REAL-FULLPLAN")
_mark_paid(settings, order)
token = issue_portal_token(order.id, settings.portal_token_secret)
submit_order_info(
token,
IntakePayload(
mode="draft",
candidate_province="湖南",
candidate_subjects=["物理", "化学", "生物"],
candidate_score=578,
candidate_rank=12345,
family_background="家长更希望省内优先",
interest_assessment_type="mbti",
interest_assessment_result="INTJ",
interest_assessment_notes="只作辅助,不作唯一判断",
),
settings,
)
start = client.get(f"/review/start?source=status&token={token}")
assert start.status_code == 200, start.text
action = client.post(
"/review/action",
data={"token": token, "action": "full_plan"},
follow_redirects=False,
)
assert action.status_code == 303, action.text
assert action.headers["location"].endswith(f"/portal/{token}/full-plan")
full_plan = client.get(action.headers["location"])
assert full_plan.status_code == 200, full_plan.text
assert "完整规划建议页" in full_plan.text
assert "方案优先级" in full_plan.text
assert "版本历史" in full_plan.text
assert "辅助判断因子" in full_plan.text
assert "INTJ" in full_plan.text
def test_real_client_review_action_rejects_missing_action_field(client, settings):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REVIEW-MISSING")
_mark_paid(settings, order)
token = issue_portal_token(order.id, settings.portal_token_secret)
resp = client.post(
"/review/action",
data={"token": token},
follow_redirects=False,
)
assert resp.status_code == 422, resp.text
body = resp.json()
assert body["message"] == "请求数据未通过校验"
assert any(
field["field"] == "body.action" for field in body["detail"]["fields"]
)
def test_real_client_review_action_rejects_invalid_literal_action(client, settings):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REVIEW-BADACT")
_mark_paid(settings, order)
token = issue_portal_token(order.id, settings.portal_token_secret)
resp = client.post(
"/review/action",
data={"token": token, "action": "bad"},
follow_redirects=False,
)
assert resp.status_code == 422, resp.text
body = resp.json()
assert body["message"] == "请求数据未通过校验"
assert any(
field["field"] == "body.action" for field in body["detail"]["fields"]
)
def test_real_client_review_action_rejects_json_body_for_form_route(client, settings):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REVIEW-JSON")
_mark_paid(settings, order)
token = issue_portal_token(order.id, settings.portal_token_secret)
resp = client.post(
"/review/action",
json={"token": token, "action": "cwb"},
follow_redirects=False,
)
assert resp.status_code == 422, resp.text
body = resp.json()
assert body["message"] == "请求数据未通过校验"
missing_fields = {field["field"] for field in body["detail"]["fields"]}
assert "body.token" in missing_fields
assert "body.action" in missing_fields
def test_payment_return_does_not_issue_portal_token_before_paid(client, settings):
create_resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_phone": "13800138000",
"candidate_name": "张三",
"candidate_province": "湖南",
},
)
assert create_resp.status_code == 201, create_resp.text
payment_id = create_resp.json()["checkout_url"].split("/pay/mock/")[1].split("?")[0]
resp = client.get(f"/portal/payment-return?payment_id={payment_id}", follow_redirects=False)
assert resp.status_code in {401, 403, 409}
def test_partial_artifacts_do_not_expose_delivery_links_before_report_ready(
client, settings, tmp_path: Path
):

View File

@@ -203,8 +203,10 @@ def test_meta_full_enums(client, auth_headers):
assert resp.status_code == 200
body = resp.json()
assert len(body["supported_provinces"]) >= 27
assert len(body["supported_provinces"]) == 27
assert "湖南" in body["supported_provinces"]
assert "内蒙古" not in body["supported_provinces"]
assert set(body["order_statuses"]) == {
"pending",

View File

@@ -16,6 +16,24 @@ def test_cases_requires_auth(client):
assert resp.status_code == 401
def test_viewer_cannot_create_case(client, viewer_headers):
resp = client.post(
"/api/cases",
headers=viewer_headers,
json={
"title": "viewer forbidden",
"category": "success",
"summary": "should fail",
"content": "viewer should not write",
"tags": ["forbidden"],
},
)
assert resp.status_code == 403
body = resp.json()
assert body["code"] == "E01301"
def test_case_crud_review_and_filters(client, auth_headers):
created = client.post(
"/api/cases",

View File

@@ -154,16 +154,15 @@ def test_create_order_writes_intake_record_with_consent_audit(
intake_store.close()
assert record is not None, f"order_intakes 表未创建 {order_id} 记录"
assert record.status == "submitted"
assert record.submitted_at is not None
# 同意审计字段必须落库, 与 portal 路径同口径
assert record.status == "draft"
assert record.submitted_at is None
# 同意审计字段必须落库, 与 portal 路径字段同口径
assert record.payload.get("consent_channel") == "xianyu"
assert record.payload.get("consent_operator") == "admin_import"
assert record.payload.get("consent_method") == "phone_recording"
assert record.payload.get("consent_given_at") == record.submitted_at
assert record.payload.get("consent_given_at") is not None
assert record.payload.get("consent_note") == "闲鱼沟通后电话确认"
# 旧 portal 字段保留 (admin 渠道下, guardian_confirmed 不适用, 不强制)
assert "privacy_accepted" in record.payload or "consent_note" in record.payload
def test_create_order_external_channel_marks_consent_operator_as_admin(
@@ -292,6 +291,62 @@ def test_detail_exposes_submitted_intake_state(client, auth_headers, settings):
assert detail["order"]["intake_submitted_at"] == record.submitted_at
def test_detail_exposes_structured_intake_payload(client, auth_headers, settings):
created = _seed_order(settings, id="GKO-20260624-INTAKE-STRUCT")
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake_store.save(
order_id=created.id,
payload={
"candidate_score": 578,
"target_cities": ["长沙", "深圳"],
"target_schools": ["湖南大学"],
"family_background": "家长更希望省内优先",
"interest_assessment_result": "INTJ",
},
submit=True,
)
finally:
intake_store.close()
detail_resp = client.get(f"/api/orders/{created.id}", headers=auth_headers)
assert detail_resp.status_code == 200, detail_resp.text
detail = detail_resp.json()
assert detail["order"]["intake_status"] == "submitted"
assert detail["order"]["intake"] is not None
assert detail["order"]["intake"]["target_cities"] == ["长沙", "深圳"]
assert detail["order"]["intake"]["target_schools"] == ["湖南大学"]
assert detail["order"]["intake"]["family_background"] == "家长更希望省内优先"
assert detail["order"]["intake"]["interest_assessment_result"] == "INTJ"
def test_admin_create_order_defaults_to_draft_intake_state(client, auth_headers, settings):
resp = client.post(
"/api/orders",
headers=auth_headers,
json={
"source": "wechat",
"external_id": "ADMIN-DRAFT-001",
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "张家长",
"customer_phone": "13800138000",
"candidate_name": "张三",
"candidate_province": "湖南",
"consent": {
"consent_method": "verbal_chat",
"consent_note": "后台补录同意",
},
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["order"]["intake_status"] == "draft"
assert body["order"]["intake_submitted_at"] is None
def test_patch_updates_business_fields_and_status_transition(
client, auth_headers, settings
):

View File

@@ -1,3 +1,4 @@
"""T6.2 仪表盘端点测试。
覆盖目标
@@ -19,6 +20,8 @@
"""
from __future__ import annotations
from pathlib import Path
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List
@@ -89,6 +92,13 @@ def test_dashboard_with_token_returns_200(client, auth_headers):
assert resp.status_code == 200
def test_viewer_cannot_read_order_stats(client, viewer_headers):
resp = client.get("/api/stats/orders", headers=viewer_headers)
assert resp.status_code == 403
body = resp.json()
assert body["code"] == "E01301"
# ---------------------------------------------------------------------------
# 形状契约 (空库)
# ---------------------------------------------------------------------------
@@ -530,6 +540,17 @@ def test_dashboard_summary_pending_missing_intake(client, auth_headers, settings
assert body["by_status"]["pending"] == 3
def test_dashboard_request_does_not_write_debug_stats_file(client, auth_headers):
debug_path = Path("/tmp/debug_stats.txt")
if debug_path.exists():
debug_path.unlink()
resp = client.get("/api/stats/dashboard", headers=auth_headers)
assert resp.status_code == 200, resp.text
assert not debug_path.exists()
# ---------------------------------------------------------------------------
# /api/stats/orders 兼容老契约
# ---------------------------------------------------------------------------

View File

@@ -1,15 +1,23 @@
"""用户端 Web 自助入口页面测试T12.2/T12.3"""
"""用户端 Web 自助入口核心测试"""
from __future__ import annotations
import pytest
from fastapi import HTTPException, Request
from admin.tests.conftest import RouteClient
from admin.routes.web_public import (
alipay_sim_payment_page,
complete_alipay_sim_payment,
complete_mock_payment,
mock_payment_page,
mock_payment_webhook,
)
from fastapi.testclient import TestClient
from data.orders.dao import OrdersDAO
def test_public_landing_page_served(route_client):
resp = route_client.get("/")
def test_public_landing_page_served(client):
resp = client.get("/")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text
@@ -18,41 +26,31 @@ def test_public_landing_page_served(route_client):
assert "湖南新高考志愿填报" not in body
assert "为什么选择我们" in body
assert 'href="/pricing"' in body
# Plan B: hero CTAs 改成 "立即咨询 / 查看套餐" 两个主 CTA
assert "先做快速审核" not in body
assert "立即咨询" in body
assert "先做方案复核" in body
assert "查看套餐" in body
assert 'btn-primary" href="#consult-box"' in body
assert 'href="/review/start?source=home"' in body
assert 'btn-primary" href="/pricing"' in body
# 业务铁律: 复核免费 / 方案付费 — 旧"咨询本身免费"已被替换
assert "咨询本身免费" not in body
assert "咨询入口" not in body # 不应再承诺"咨询免费"
assert "咨询入口" not in body
assert "复核现有方案本身免费" in body
assert "新方案生成与深度辅导在支付后启动" in body
# hero-trust 1 号卡片: 复核免费 / 方案付费
assert "复核免费 / 方案付费" in body
# 复核/付费套餐按钮
assert "获取复核与推荐" in body
assert "直接看付费套餐" in body
# 服务流程 01 步: 明确标注复核免费 / 方案付费
assert "方案复核(免费)" in body
assert "深度辅导(付费)" in body
# 底部 CTA: 区分复核路径与付费路径
assert "已有方案?先免费复核" in body
assert "先告诉我们你的基本情况" not in body # 旧标题已替换
assert "告诉我们你的基本情况" in body # 新标题存在
# 旧 CTA/按钮文案不应再出现
assert "先告诉我们你的基本情况" not in body
assert "告诉我们你的基本情况" in body
assert "获取推荐路径" not in body
assert "直接看套餐</a>" not in body
assert "先看套餐,再决定是否立即下单" not in body
assert "先审计后规划" not in body
# 咨询表单隐私说明 (输入仅用于判断, 不留底)
assert "不会留底" in body
assert "不会用于生成方案" in body
assert "不会发邮件推销" in body
assert "不会收到营销短信" in body
# 还保留的核心元素
assert "为什么选择我们" in body
assert "了解服务流程" in body
assert "最常见的不是“不会选”,而是先选错方向" in body
assert "先把方案看清,再决定要不要重做" in body
@@ -67,12 +65,11 @@ def test_public_landing_page_served(route_client):
assert "家长决策支持" not in body
assert "家长联系方式" not in body
assert "把风险解释给家长听懂" not in body
assert '/static/portal-ui.css' in body
def test_public_pricing_page_served(route_client):
resp = route_client.get("/pricing")
def test_public_pricing_page_served(client):
resp = client.get("/pricing")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text
@@ -86,30 +83,24 @@ def test_public_pricing_page_served(route_client):
assert "推荐方案" in body
assert "你可以先从 99 元完整志愿方案开始" in body
assert "支付接入建设中" not in body
# 业务铁律: 套餐页口径与首页一致 — 复核免费 / 方案付费
assert "复核现有方案本身免费" in body
assert "复核免费 / 方案付费" in body
# 套餐页应有 1 处明确引导回首页做免费复核
assert 'href="/#consult-box"' in body
assert "先做一次免费复核" in body
# 49/99/199 三档文案
assert "先做付费审核" in body # 49 元档 CTA
assert "支付并启动方案生成" in body # 99 元档 CTA
assert "了解深度辅导" in body # 199 元档 CTA
# 旧 CTA/文案已替换
assert "先做付费审核" in body
assert "支付并启动方案生成" in body
assert "了解深度辅导" in body
assert "先做快速审核" not in body
assert "立即开始完整规划" not in body
assert "先审计再决定" not in body
assert "快速校验" not in body
# FAQ 加了"复核是免费的吗"
assert "复核是免费的吗?包含什么?" in body
# notice 提示明确引导免费复核
assert "还没决定" in body
assert '/static/portal-ui.css' in body
def test_public_checkout_page_served(route_client):
resp = route_client.get("/checkout/standard")
def test_public_checkout_page_served(client):
resp = client.get("/checkout/standard")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text
@@ -124,8 +115,8 @@ def test_public_checkout_page_served(route_client):
assert '/static/portal-ui.css' in body
def test_public_create_order_endpoint(route_client):
resp = route_client.post(
def test_public_create_order_endpoint(client, app):
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
@@ -146,15 +137,14 @@ def test_public_create_order_endpoint(route_client):
assert body["next_step"] == "payment"
assert body["checkout_url"].startswith("/pay/mock/")
assert "/portal/" in body["portal_status_url"]
with OrdersDAO.connect(route_client.app.state.settings.orders_db_path) as dao:
with OrdersDAO.connect(app.state.settings.orders_db_path) as dao:
created = dao.get(body["order_id"])
assert created.customer_email == "parent@example.com"
assert created.candidate_name == "张三"
def test_public_create_order_returns_503_with_friendly_message_when_encryption_key_missing(
tmp_path, monkeypatch
):
def test_public_create_order_returns_503_with_friendly_message_when_encryption_key_missing(tmp_path, monkeypatch):
admin_db = tmp_path / "admin.db"
orders_db = tmp_path / "orders.db"
share_db = tmp_path / "share.db"
@@ -181,8 +171,8 @@ def test_public_create_order_returns_503_with_friendly_message_when_encryption_k
settings = load_settings()
app = create_app(settings)
with RouteClient(app) as route_client:
resp = route_client.post(
with TestClient(app) as client:
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
@@ -198,8 +188,8 @@ def test_public_create_order_returns_503_with_friendly_message_when_encryption_k
assert "当前暂时无法创建订单" in body["detail"]["reason"]
def test_public_create_order_rejects_missing_minimal_fields(route_client):
resp = route_client.post(
def test_public_create_order_rejects_missing_minimal_fields(client):
resp = client.post(
"/api/public/orders",
json={
"service_version": "audit",
@@ -217,15 +207,15 @@ def test_public_create_order_rejects_missing_minimal_fields(route_client):
)
def test_public_create_order_rejects_price_tampering(route_client):
resp = route_client.post(
def test_public_create_order_rejects_price_tampering(client):
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 1,
"service_version": "audit",
"amount_cents": 4999,
"customer_name": "张家长",
"customer_phone": "13800138000",
"customer_email": "parent@example.com",
"candidate_name": "张三",
"candidate_province": "湖南",
},
@@ -233,40 +223,126 @@ def test_public_create_order_rejects_price_tampering(route_client):
assert resp.status_code == 422
body = resp.json()
assert body["message"] == "请求数据未通过校验"
assert (
body["detail"]["fields"][0]["reason"]
== "Value error, amount_cents 与套餐价格不一致"
assert any(
"amount_cents 与套餐价格不一致" in field["reason"]
for field in body["detail"]["fields"]
)
def test_checkout_url_does_not_expose_portal_token_in_query(route_client):
create_resp = route_client.post(
def test_public_create_order_persists_candidate_province(client, app):
resp = client.post(
"/api/public/orders",
json={
"service_version": "audit",
"amount_cents": 4900,
"customer_name": "李家长",
"customer_phone": "13800138000",
"candidate_name": "张三",
"candidate_province": "广东",
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
with OrdersDAO.connect(app.state.settings.orders_db_path) as dao:
created = dao.get(body["order_id"])
assert created.candidate_province == "广东"
def test_public_create_order_rejects_unsupported_province(client):
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "家长",
"customer_name": "家长",
"customer_phone": "13800138000",
"customer_email": "parent@example.com",
"candidate_name": "张三",
"candidate_province": "内蒙古",
},
)
assert resp.status_code == 422
body = resp.json()
assert body["message"] == "请求数据未通过校验"
assert "candidate_province" in resp.text
def test_public_create_order_supports_optional_contact_email(client, app):
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "李家长",
"customer_phone": "13800138000",
"customer_email": "guardian@example.com",
"candidate_name": "张三",
"candidate_province": "湖南",
},
)
assert create_resp.status_code == 201, create_resp.text
body = create_resp.json()
assert "token=" not in body["checkout_url"]
assert resp.status_code == 201, resp.text
body = resp.json()
with OrdersDAO.connect(app.state.settings.orders_db_path) as dao:
created = dao.get(body["order_id"])
assert created.customer_email == "guardian@example.com"
def test_payment_return_redirects_to_payment_success_page(route_client):
create_resp = route_client.post(
def test_public_checkout_page_renders_audit_package(client):
resp = client.get("/checkout/audit")
assert resp.status_code == 200, resp.text
body = resp.text
assert "49元 AI方案审核" in body
assert "适合已经拿到其他方案、希望先快速校验风险的家庭。" in body
assert "当前建议" in body
assert "当前这一步只收会影响下单与后续联系的必要信息" in body
assert "支付成功后,再进入资料向导补充分数、位次、偏好和已有方案附件。" in body
assert "service_version: 'audit'" in body
assert "amount_cents: 4900" in body
def test_public_checkout_page_renders_premium_package(client):
resp = client.get("/checkout/premium")
assert resp.status_code == 200, resp.text
body = resp.text
assert "199元 深度辅导版" in body
assert "适合需要更多沟通、补充说明与深度修订支持的家庭。" in body
assert "199元 深度辅导版" in body
assert "service_version: 'premium'" in body
assert "amount_cents: 19900" in body
def test_public_create_order_rejects_unknown_service_version(client):
resp = client.post(
"/api/public/orders",
json={
"service_version": "unknown",
"amount_cents": 9999,
"customer_name": "李家长",
"customer_phone": "13800138000",
"candidate_name": "张三",
"candidate_province": "湖南",
},
)
assert resp.status_code == 422
body = resp.json()
assert body["message"] == "请求数据未通过校验"
assert any(
field["field"] == "body.service_version"
for field in body["detail"]["fields"]
)
def test_public_payment_flow_returns_payment_success_page(client, settings):
create_resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "张家长",
"customer_phone": "13800138000",
"customer_email": "parent@example.com",
"candidate_name": "张三",
"candidate_province": "湖南",
},
@@ -275,117 +351,45 @@ def test_payment_return_redirects_to_payment_success_page(route_client):
body = create_resp.json()
payment_id = body["checkout_url"].split("/pay/mock/")[1].split("?")[0]
route_client.post(f"/pay/mock/{payment_id}/complete", follow_redirects=False)
resp = route_client.get(
f"/portal/payment-return?payment_id={payment_id}", follow_redirects=False
)
assert resp.status_code == 303, resp.text
assert resp.headers["location"].startswith("/portal/")
assert resp.headers["location"].endswith("/payment-success")
def test_payment_success_page_served_after_paid_order(route_client, settings):
create_resp = route_client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "张家长",
"customer_phone": "13800138000",
"customer_email": "parent@example.com",
"candidate_name": "张三",
"candidate_province": "湖南",
},
)
assert create_resp.status_code == 201, create_resp.text
body = create_resp.json()
payment_id = body["checkout_url"].split("/pay/mock/")[1].split("?")[0]
token = body["portal_status_url"].split("/portal/")[1].split("/status")[0]
complete = route_client.post(
f"/pay/mock/{payment_id}/complete", follow_redirects=False
)
complete = client.post(f"/pay/mock/{payment_id}/complete", follow_redirects=False)
assert complete.status_code == 303, complete.text
assert complete.headers["location"] == f"/portal/{token}/payment-success"
assert complete.headers["location"].endswith("/payment-success")
token = complete.headers["location"].split("/portal/")[1].split("/payment-success")[0]
page = route_client.get(f"/portal/{token}/payment-success")
page = client.get(f"/portal/{token}/payment-success")
assert page.status_code == 200, page.text
assert "支付成功" in page.text
assert "订单已创建,下一步继续补资料" in page.text
assert "支付状态paid" in page.text
assert "立即补充资料" in page.text
assert "查看订单进度" in page.text
def test_portal_report_rejects_untrusted_report_path(route_client, settings):
from data.orders.dao import OrdersDAO
from data.orders.models import Order
from data.customer_portal.token import issue_portal_token
order = Order(
id="GKO-20260614-TRUST",
source="web",
service_version="standard",
amount_cents=9900,
status="pending",
customer_name="张家长",
customer_phone="13800138000",
candidate_name="张三",
candidate_province="湖南",
audit_report="/etc/hosts",
pdf_path="/etc/hosts",
)
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.create(order, actor="test", reason="seed")
token = issue_portal_token(order.id, settings.portal_token_secret)
report_page = route_client.get(f"/portal/{token}/report")
assert report_page.status_code == 409
assert "report not ready" in report_page.text
pdf_resp = route_client.get(f"/portal/{token}/report.pdf")
assert pdf_resp.status_code == 409
assert "report not ready" in pdf_resp.text
def test_portal_status_page_does_not_fall_back_to_pending_after_paid_order(
route_client, settings
):
from data.payments.service import PaymentService
create_resp = route_client.post(
def test_payment_success_page_shows_audit_copy_for_audit_orders(client, settings):
create_resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "张家长",
"service_version": "audit",
"amount_cents": 4900,
"customer_phone": "13800138000",
"customer_email": "parent@example.com",
"candidate_name": "张三",
"candidate_name": "李同学",
"candidate_province": "湖南",
},
)
assert create_resp.status_code == 201, create_resp.text
body = create_resp.json()
payment_id = body["checkout_url"].split("/pay/mock/")[1].split("?")[0]
token = body["portal_status_url"].split("/portal/")[1].split("/status")[0]
order_id = body["order_id"]
complete = route_client.post(
f"/pay/mock/{payment_id}/complete", follow_redirects=False
)
complete = client.post(f"/pay/mock/{payment_id}/complete", follow_redirects=False)
assert complete.status_code == 303, complete.text
assert complete.headers["location"].endswith("/payment-success")
token = complete.headers["location"].split("/portal/")[1].split("/payment-success")[0]
service = PaymentService.for_db(
settings.orders_db_path,
base_url=settings.payment_base_url,
webhook_secret=settings.payment_webhook_secret,
)
redundant = service.create_checkout(order_id)
assert redundant.payment_id == payment_id
page = route_client.get(f"/portal/{token}/status")
page = client.get(f"/portal/{token}/payment-success")
assert page.status_code == 200, page.text
assert "待填写资料" in page.text
assert "待支付" not in page.text
assert "订单已创建,下一步继续补资料" in page.text
assert "补充基础信息" in page.text
assert "填写偏好目标" in page.text
assert "持续查看进度" in page.text
def test_prod_hides_simulated_payment_entrypoints(tmp_path, monkeypatch):
@@ -402,23 +406,15 @@ def test_prod_hides_simulated_payment_entrypoints(tmp_path, monkeypatch):
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", str(share_reports))
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
monkeypatch.setenv("GAOKAO_PORTAL_TOKEN_SECRET", "Z" * 64)
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "prod-secret-ok-123456")
monkeypatch.setenv("GAOKAO_PORTAL_TOKEN_SECRET", "prod-portal-secret-ok-1234567890")
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "Prod-pass-123!")
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
monkeypatch.setenv("GAOKAO_PAYMENT_PROVIDER", "alipay")
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "P" + "r" * 31 + "!" * 32)
monkeypatch.setenv("GAOKAO_PAYMENT_BASE_URL", "http://testserver")
from admin.config import load_settings
from admin.routes.web_public import (
alipay_sim_payment_page,
complete_alipay_sim_payment,
complete_mock_payment,
mock_payment_page,
mock_payment_webhook,
)
from fastapi import HTTPException
from starlette.requests import Request
settings = load_settings()
request = Request(
@@ -427,6 +423,10 @@ def test_prod_hides_simulated_payment_entrypoints(tmp_path, monkeypatch):
"method": "POST",
"path": "/api/public/payments/mock/webhook",
"headers": [],
"query_string": b"",
"server": ("testserver", 80),
"client": ("127.0.0.1", 12345),
"scheme": "http",
}
)
@@ -442,43 +442,7 @@ def test_prod_hides_simulated_payment_entrypoints(tmp_path, monkeypatch):
assert exc_info.value.status_code == 404
def test_public_pages_include_privacy_and_deletion_links(route_client):
landing = route_client.get("/")
assert landing.status_code == 200, landing.text
assert 'href="/privacy"' in landing.text
assert 'href="/service-terms"' in landing.text
assert 'href="/deletion-policy"' in landing.text
pricing = route_client.get("/pricing")
assert pricing.status_code == 200, pricing.text
assert 'href="/privacy"' in pricing.text
assert 'href="/service-terms"' in pricing.text
assert 'href="/deletion-policy"' in pricing.text
def test_privacy_and_deletion_pages_are_served(route_client):
privacy = route_client.get("/privacy")
assert privacy.status_code == 200, privacy.text
assert "隐私政策" in privacy.text
assert "隐私说明" in privacy.text
assert '/static/portal-ui.css' in privacy.text
terms = route_client.get("/service-terms")
assert terms.status_code == 200, terms.text
assert "服务说明与免责声明" in terms.text
assert "服务边界" in terms.text
assert '/static/portal-ui.css' in terms.text
deletion = route_client.get("/deletion-policy")
assert deletion.status_code == 200, deletion.text
assert "删除申请" in deletion.text
assert "数据删除" in deletion.text
assert '/static/portal-ui.css' in deletion.text
def test_public_create_order_returns_503_without_creating_orphan_order_when_provider_unavailable(
tmp_path, monkeypatch
):
def test_public_create_order_returns_503_without_creating_orphan_order_when_provider_unavailable(tmp_path, monkeypatch):
admin_db = tmp_path / "admin.db"
orders_db = tmp_path / "orders.db"
share_db = tmp_path / "share.db"
@@ -505,8 +469,8 @@ def test_public_create_order_returns_503_without_creating_orphan_order_when_prov
settings = load_settings()
app = create_app(settings)
with RouteClient(app) as route_client:
resp = route_client.post(
with TestClient(app) as client:
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
@@ -522,132 +486,3 @@ def test_public_create_order_returns_503_without_creating_orphan_order_when_prov
assert "payment provider unavailable" in resp.text
with OrdersDAO.connect(settings.orders_db_path) as dao:
assert dao.count() == 0
def test_public_create_order_returns_503_without_creating_orphan_order_when_checkout_fails(
tmp_path, monkeypatch
):
admin_db = tmp_path / "admin.db"
orders_db = tmp_path / "orders.db"
share_db = tmp_path / "share.db"
share_reports = tmp_path / "share_reports"
share_reports.mkdir()
monkeypatch.setenv("GAOKAO_ENV", "dev")
monkeypatch.setenv("GAOKAO_DB_PATH", str(admin_db))
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db))
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", str(share_db))
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", str(share_reports))
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
monkeypatch.setenv("GAOKAO_PAYMENT_PROVIDER", "mock")
monkeypatch.setenv("GAOKAO_PAYMENT_BASE_URL", "http://testserver")
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "test-payment-secret")
from admin.app import create_app
from admin.config import load_settings
from data.payments.service import PaymentError, PaymentService
original = PaymentService.create_checkout
def _boom(self, order_id: str, *, portal_token: str | None = None):
raise PaymentError("checkout transport failed")
monkeypatch.setattr(PaymentService, "create_checkout", _boom)
settings = load_settings()
app = create_app(settings)
with RouteClient(app) as route_client:
resp = route_client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "张家长",
"customer_phone": "13800138000",
"candidate_name": "张三",
"candidate_province": "湖南",
},
)
monkeypatch.setattr(PaymentService, "create_checkout", original)
assert resp.status_code == 503, resp.text
assert "payment checkout unavailable" in resp.text
with OrdersDAO.connect(settings.orders_db_path) as dao:
assert dao.count() == 0
def test_public_pricing_page_shows_consult_recommendation(route_client):
resp = route_client.get(
"/pricing?province=%E6%B9%96%E5%8D%97&score=578&goal=%E5%85%88%E5%AE%A1%E6%A0%B8&consult=%E5%B7%B2%E6%9C%89%E4%B8%80%E7%89%88%E6%96%B9%E6%A1%88"
)
assert resp.status_code == 200, resp.text
assert "更适合先做 49 元方案审核" in resp.text
assert "湖南 578 先审核" in resp.text
def test_info_page_wizard_actions_outside_form_for_sticky_bottom(route_client, settings):
"""资料页移动端关键操作按钮必须放在 form 之外, 才能让
`position: sticky; bottom: 0` 跨越整个表单区域在视口持续可见。"""
from data.orders.dao import OrdersDAO
from data.orders.public_flow import PublicOrderCreate, create_public_order
from data.customer_portal.token import issue_portal_token
with OrdersDAO.connect(settings.orders_db_path) as dao:
order = create_public_order(
dao,
PublicOrderCreate(
service_version="standard",
amount_cents=9900,
customer_name="Sticky 用户",
customer_phone="13800138000",
candidate_name="Sticky-User",
candidate_province="湖南",
),
)
token = issue_portal_token(order.id, settings.portal_token_secret)
resp = route_client.get(f"/portal/{token}/info")
assert resp.status_code == 200, resp.text
body = resp.text
# 关键断言: wizard-actions 必须出现在 </form> 之后
form_end = body.find("</form>")
wizard_start = body.find('<div class="wizard-actions">')
assert form_end > 0, "should have </form> closing tag"
assert wizard_start > 0, "should have wizard-actions div"
assert wizard_start > form_end, (
"wizard-actions must be a sibling of <form>, not nested inside it "
"(so position: sticky works across all step-panels)"
)
# 同时确认主操作按钮文案都还在
for label in ["保存草稿", "下一步", "上一步", "确认并提交资料"]:
assert label in body, f"missing wizard button label: {label}"
# 移动端 safe-area 适配
assert "safe-area-inset-bottom" in body, "should reserve safe-area for notched devices"
def test_confirm_summary_does_not_use_innerhtml_for_user_fields(route_client, settings):
from data.orders.dao import OrdersDAO
from data.orders.public_flow import PublicOrderCreate, create_public_order
from data.customer_portal.token import issue_portal_token
with OrdersDAO.connect(settings.orders_db_path) as dao:
order = create_public_order(
dao,
PublicOrderCreate(
service_version="standard",
amount_cents=9900,
customer_name="Summary 用户",
customer_phone="13800138000",
candidate_name="Summary-User",
candidate_province="湖南",
),
)
token = issue_portal_token(order.id, settings.portal_token_secret)
resp = route_client.get(f"/portal/{token}/info")
assert resp.status_code == 200, resp.text
body = resp.text
assert "confirm-summary').innerHTML" not in body
assert "replaceChildren" in body
assert "createElement('div')" in body