diff --git a/admin/app.py b/admin/app.py index 6501898..137b46a 100644 --- a/admin/app.py +++ b/admin/app.py @@ -38,6 +38,9 @@ from admin.logging_utils import ( configure_logging, ) from admin.routes import ( + admin_cases_router, + admin_orders_router, + admin_stats_router, auth_router, cases_router, health_router, @@ -164,8 +167,11 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI: app.include_router(health_router) app.include_router(auth_router) app.include_router(cases_router) + app.include_router(admin_cases_router) app.include_router(orders_router) + app.include_router(admin_orders_router) app.include_router(stats_router) + app.include_router(admin_stats_router) app.include_router(ui_router) app.include_router(meta_router) app.include_router(users_router) diff --git a/admin/routes/__init__.py b/admin/routes/__init__.py index 929b60b..fcd0417 100644 --- a/admin/routes/__init__.py +++ b/admin/routes/__init__.py @@ -1,12 +1,15 @@ """路由模块 (T6.1).""" from admin.routes.auth import router as auth_router +from admin.routes.cases import admin_router as admin_cases_router from admin.routes.cases import router as cases_router from admin.routes.health import router as health_router from admin.routes.meta import router as meta_router from admin.routes.notifications import page_router as notifications_page_router from admin.routes.notifications import router as notifications_router +from admin.routes.orders import admin_router as admin_orders_router from admin.routes.orders import router as orders_router +from admin.routes.stats import admin_router as admin_stats_router from admin.routes.stats import router as stats_router from admin.routes.ui import router as ui_router from admin.routes.users import router as users_router @@ -15,12 +18,15 @@ from admin.routes.web_public import router as web_public_router __all__ = [ "auth_router", "cases_router", + "admin_cases_router", "health_router", "meta_router", "notifications_router", "notifications_page_router", "orders_router", + "admin_orders_router", "stats_router", + "admin_stats_router", "ui_router", "users_router", "web_public_router", diff --git a/admin/routes/cases.py b/admin/routes/cases.py index b8c278d..cbb116c 100644 --- a/admin/routes/cases.py +++ b/admin/routes/cases.py @@ -17,6 +17,7 @@ from data.cases.dao import CaseNotFound, CasesDAO from data.cases.models import CaseRecord router = APIRouter(prefix="/api/cases", tags=["cases"]) +admin_router = APIRouter(prefix="/api/admin/cases", tags=["cases"]) CaseCategory = Literal["success", "typical", "warning"] CaseReviewStatus = Literal["pending", "approved", "rejected"] @@ -64,6 +65,7 @@ def _to_payload(record: CaseRecord) -> dict[str, Any]: @router.get("", response_model=CaseListResponse, summary="案例列表(T6.5)") +@admin_router.get("", response_model=CaseListResponse, summary="案例列表(T6.5)") def list_cases( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), @@ -93,6 +95,12 @@ def list_cases( status_code=status.HTTP_201_CREATED, summary="创建案例(T6.5)", ) +@admin_router.post( + "", + response_model=CaseDetailResponse, + status_code=status.HTTP_201_CREATED, + summary="创建案例(T6.5)", +) def create_case( payload: CaseBasePayload, settings: Settings = Depends(get_settings_dep), @@ -116,6 +124,11 @@ def create_case( response_model=CaseDetailResponse, summary="案例详情(T6.5)", ) +@admin_router.get( + "/{case_id}", + response_model=CaseDetailResponse, + summary="案例详情(T6.5)", +) def get_case( case_id: int = Path(..., ge=1), settings: Settings = Depends(get_settings_dep), @@ -134,6 +147,11 @@ def get_case( response_model=CaseDetailResponse, summary="更新案例(T6.5)", ) +@admin_router.patch( + "/{case_id}", + response_model=CaseDetailResponse, + summary="更新案例(T6.5)", +) def update_case( payload: CaseBasePayload, case_id: int = Path(..., ge=1), @@ -162,6 +180,11 @@ def update_case( response_model=CaseDetailResponse, summary="审核案例(T6.5)", ) +@admin_router.post( + "/{case_id}/review", + response_model=CaseDetailResponse, + summary="审核案例(T6.5)", +) def review_case( payload: ReviewCaseRequest, case_id: int = Path(..., ge=1), @@ -190,6 +213,12 @@ def review_case( summary="删除案例(T6.5)", response_class=Response, ) +@admin_router.delete( + "/{case_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="删除案例(T6.5)", + response_class=Response, +) def delete_case( case_id: int = Path(..., ge=1), settings: Settings = Depends(get_settings_dep), diff --git a/admin/routes/orders.py b/admin/routes/orders.py index 77ea3b2..1dd9171 100644 --- a/admin/routes/orders.py +++ b/admin/routes/orders.py @@ -36,6 +36,7 @@ from data.orders.state_machine import InvalidStateTransition, next_states router = APIRouter(prefix="/api/orders", tags=["orders"]) +admin_router = APIRouter(prefix="/api/admin/orders", tags=["orders"]) OrderSource = Literal["xianyu", "wechat", "web", "school"] ServiceVersion = Literal["audit", "basic", "standard", "premium"] @@ -250,6 +251,11 @@ def _transition_error(order_id: str, to_status: str, exc: Exception) -> Business response_model=list[OrderSummaryResponse], summary="订单列表(T6.4)", ) +@admin_router.get( + "", + response_model=list[OrderSummaryResponse], + summary="订单列表(T6.4)", +) def list_orders( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), @@ -276,6 +282,10 @@ def list_orders( "/export", summary="订单导出(CSV,默认脱敏)", ) +@admin_router.get( + "/export", + summary="订单导出(CSV,默认脱敏)", +) def export_orders_csv( limit: int = Query(1000, ge=1, le=1000), status_filter: Optional[OrderStatus] = Query(None, alias="status"), @@ -305,6 +315,11 @@ def export_orders_csv( response_model=OrderDetailPayload, summary="订单详情(T6.4)", ) +@admin_router.get( + "/{order_id}", + response_model=OrderDetailPayload, + summary="订单详情(T6.4)", +) def get_order( order_id: str = Path(..., min_length=1), settings: Settings = Depends(get_settings_dep), @@ -329,6 +344,12 @@ def get_order( status_code=status.HTTP_201_CREATED, summary="手工录入订单(T6.4)", ) +@admin_router.post( + "", + response_model=OrderMutationResponse, + status_code=status.HTTP_201_CREATED, + summary="手工录入订单(T6.4)", +) def create_order( payload: CreateOrderRequest, settings: Settings = Depends(get_settings_dep), @@ -378,6 +399,11 @@ def create_order( response_model=OrderMutationResponse, summary="订单更新 / 状态流转 / 退款(T6.4)", ) +@admin_router.patch( + "/{order_id}", + response_model=OrderMutationResponse, + summary="订单更新 / 状态流转 / 退款(T6.4)", +) def patch_order( payload: UpdateOrderRequest, order_id: str = Path(..., min_length=1), @@ -464,6 +490,11 @@ def patch_order( response_model=OrderDeletionResponse, summary="订单删除 / 匿名化(T12/A-4)", ) +@admin_router.delete( + "/{order_id}", + response_model=OrderDeletionResponse, + summary="订单删除 / 匿名化(T12/A-4)", +) def delete_or_anonymize_order( order_id: str = Path(..., min_length=1), mode: Literal["delete", "anonymize"] = Query("delete"), diff --git a/admin/routes/stats.py b/admin/routes/stats.py index 48376df..c3f1b01 100644 --- a/admin/routes/stats.py +++ b/admin/routes/stats.py @@ -26,6 +26,7 @@ from admin.stats import ( router = APIRouter(prefix="/api/stats", tags=["stats"]) +admin_router = APIRouter(prefix="/api/admin/stats", tags=["stats"]) class DashboardResponse(BaseModel): @@ -59,6 +60,16 @@ class OrderStatsResponse(BaseModel): "来源分布 + 服务版本分布 + 今日/7d/30d 趋势序列 (日粒度, 0 填充)。" ), ) +@admin_router.get( + "/dashboard", + response_model=DashboardResponse, + summary="仪表盘统计(T6.2)", + description=( + "返回管理后台仪表盘完整 payload: " + "summary(订单/用户/收入 + 今日/7d/30d 切片) + 6 态分布 + " + "来源分布 + 服务版本分布 + 今日/7d/30d 趋势序列 (日粒度, 0 填充)。" + ), +) def get_dashboard( request: Request, settings: Settings = Depends(get_settings_dep), @@ -89,6 +100,15 @@ def get_dashboard( "字段名保持 T6.1 stub 阶段不变,前端旧契约不破。" ), ) +@admin_router.get( + "/orders", + response_model=OrderStatsResponse, + summary="订单维度统计(T6.2 真实聚合)", + description=( + "订单维度统计:T6.1 阶段为占位,T6.2 接入真实 SQL 聚合。" + "字段名保持 T6.1 stub 阶段不变,前端旧契约不破。" + ), +) def get_order_stats( request: Request, settings: Settings = Depends(get_settings_dep), diff --git a/admin/routes/ui.py b/admin/routes/ui.py index 3bf00dd..13bddfc 100644 --- a/admin/routes/ui.py +++ b/admin/routes/ui.py @@ -24,6 +24,7 @@ _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: """返回最小仪表盘页面壳。""" return FileResponse(_DASHBOARD_HTML) diff --git a/admin/tests/test_admin_alias_routes.py b/admin/tests/test_admin_alias_routes.py new file mode 100644 index 0000000..9acdb11 --- /dev/null +++ b/admin/tests/test_admin_alias_routes.py @@ -0,0 +1,68 @@ +from __future__ import annotations + + +def test_admin_dashboard_alias_served(client): + resp = client.get("/admin/dashboard") + 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", + headers=auth_headers, + json={ + "source": "web", + "service_version": "standard", + "amount_cents": 9900, + "customer_name": "张家长", + "customer_phone": "13800138000", + "candidate_name": "张三", + "candidate_province": "湖南", + }, + ) + assert created.status_code == 201, created.text + order_id = created.json()["order"]["id"] + + listed = client.get("/api/admin/orders", headers=auth_headers) + assert listed.status_code == 200, listed.text + assert any(item["id"] == order_id for item in listed.json()) + + detail = client.get(f"/api/admin/orders/{order_id}", headers=auth_headers) + assert detail.status_code == 200, detail.text + assert detail.json()["order"]["id"] == order_id + + +def test_admin_cases_alias_crud(client, auth_headers): + created = client.post( + "/api/admin/cases", + headers=auth_headers, + json={ + "title": "后台别名案例", + "category": "success", + "summary": "别名路径创建", + "content": "别名路径正文", + "tags": ["alias"], + }, + ) + assert created.status_code == 201, created.text + case_id = created.json()["id"] + + listed = client.get("/api/admin/cases", headers=auth_headers) + assert listed.status_code == 200, listed.text + assert any(item["id"] == case_id for item in listed.json()["items"]) + + detail = client.get(f"/api/admin/cases/{case_id}", headers=auth_headers) + assert detail.status_code == 200, detail.text + assert detail.json()["id"] == case_id + + +def test_admin_stats_aliases(client, auth_headers): + dashboard = client.get("/api/admin/stats/dashboard", headers=auth_headers) + assert dashboard.status_code == 200, dashboard.text + assert "summary" in dashboard.json() + + orders = client.get("/api/admin/stats/orders", headers=auth_headers) + assert orders.status_code == 200, orders.text + assert "total_orders" in orders.json() diff --git a/reports/LOCAL_BROWSER_E2E_COVERAGE_2026-06-16.md b/reports/LOCAL_BROWSER_E2E_COVERAGE_2026-06-16.md new file mode 100644 index 0000000..69198e2 --- /dev/null +++ b/reports/LOCAL_BROWSER_E2E_COVERAGE_2026-06-16.md @@ -0,0 +1,114 @@ +# GAOKAO 本地浏览器 E2E 覆盖矩阵(2026-06-16) + +> 范围:用户端 Web 自助 MVP 全部浏览器可达页面 + 后台审计/管理页。 +> 环境:本地 FastAPI 8011 / 临时 SQLite / alipay_sim 支付 / dev 默认密钥。 +> 验证方式:真实浏览器 + curl/Python 真实 HTTP。 + +## 0. 顶层结论 + +| 项目 | 状态 | +| ------------------------------------------------- | ------------------------------------------------------------------- | +| 公开落地页 / 政策页 / 下单页 | ✅ 浏览器 HTTP 200 | +| 公开下单 → 模拟支付 → portal status | ✅ 浏览器真实走通 | +| portal 5-step 资料填写 → 提交 → status 推进 | ✅ 浏览器真实走通,摘要前后端一致 | +| portal report.html / report.pdf 下载 | ✅ 浏览器/HTTP 200,PDF 头正确 | +| portal 删除申请提交 | ✅ HTTP 200,admin 审计页可看到 | +| admin 登录 + dashboard + 删除审计页 + 通知审计页 + 运维告警页 | ✅ 浏览器+HTTP 全部 200,命中真实数据 | +| 后台 API 兼容别名 `/api/admin/{orders,cases,stats}` | ✅ 已补齐兼容别名,旧路径与新路径并存 | +| 真实支付宝(alipay)acceptance | ⛔ 阻塞:缺商户凭据 / 备案域名 / 公网 notify_url | + +## 1. 覆盖矩阵 + +### 1.1 公共侧(无需登录) + +| 路径 | 真实浏览器 | HTTP | 备注 | +| -------------------- | ---------- | ---- | ---------------------- | +| `/` (landing) | ✅ | 200 | 历史报告 + 本次复核 | +| `/pricing` | ✅ | 200 | | +| `/privacy` | ✅ | 200 | | +| `/service-terms` | ✅ | 200 | | +| `/deletion-policy` | ✅ | 200 | | +| `/checkout/standard` | ✅ | 200 | 本次真实填单并完成支付 | +| `/checkout/audit` | ✅ | 200 | | +| `/checkout/premium` | ✅ | 200 | | + +### 1.2 支付 + portal(无需登录,凭 portal token) + +| 路径 | 真实浏览器 | HTTP | 备注 | +| --------------------------------------------- | ---------- | ------------ | --------------------------------------------------- | +| `/pay/alipay-sim/{payment_id}` | ✅ | 200 | 模拟收银台页打开 | +| `POST /pay/alipay-sim/{payment_id}/complete` | ✅ | 303 → status | 模拟支付成功跳转 | +| `GET /portal/{token}/status` | ✅ | 200 | 状态推进 `paid` → `serving` → `delivered` | +| `GET /portal/{token}/info` | ✅ | 200 | 5-step 表单 + 真实 Step 5 摘要 | +| `POST /portal/{token}/info` (submit) | ✅ | 200 | 返回 `intake_status=submitted, stage=processing` | +| `GET /portal/{token}/report` | ✅ | 200 | HTML 报告内容与上传一致 | +| `GET /portal/{token}/report.pdf` | ✅ | 200 | `Content-Type: application/pdf`、头 `%PDF-1.4` | +| `GET /portal/{token}/notifications` | ⏸️ | n/a | 本轮未直接点开;API 侧 admin/通知审计已显示对应通知 | +| `GET /portal/{token}/deletion-request` (页面) | ⏸️ | 200 | 本轮通过 API 提交并验证 admin 端可见 | +| `POST /portal/{token}/deletion-request` | ✅ | 200 | 返回 `request_logged=true` | + +### 1.3 后台 API + +| 路径 | HTTP | 命中真实数据 | +| ------------------------------------------------ | ---- | -------------------------------------------------------------- | +| `POST /api/auth/login` | 200 | 拿到 JWT,5 分钟过期 | +| `GET /api/auth/me` | 200 | `{id:1, role:admin, last_login_at:...}` | +| `GET /api/admin/notifications` | 200 | total=2,包含 `report_ready_email`、order_id=GKO-20260615-OVIG | +| `GET /api/admin/notifications/deletion-requests` | 200 | 1 条,含 `张家长/13800138000/浏览器 e2e` | +| `GET /api/admin/notifications/ops-alerts` | 200 | 0 条(正常) | +| `GET /api/admin/users` | 200 | total≥1 | +| `GET /api/admin/users/{user_key}` | n/a | 本轮未单点 | +| `GET /api/admin/orders` | 200 | 已补兼容别名;与 `/api/orders` 返回同一数据契约 | +| `GET /api/admin/cases` | 200 | 已补兼容别名;与 `/api/cases` 返回同一数据契约 | +| `GET /api/admin/stats/dashboard` | 200 | 已补兼容别名;返回 `summary/by_status/...` | +| `GET /api/admin/stats/orders` | 200 | 已补兼容别名;返回 `total_orders/...` | +| `GET /health` | 200 | | + +### 1.4 后台页面(`page_router`) + +| 路径 | 真实浏览器 | HTTP | 命中真实数据 | +| -------------------------- | ---------- | ---- | --------------------------------------------------------------------------- | +| `/admin/dashboard` | ✅ | 200 | 已补后台兼容入口;内容与 `/dashboard` 一致 | +| `/admin/deletion-requests` | ✅ | 200 | `

删除申请审计`、总数=1、订单、家长、联系方式、原因 | +| `/admin/notifications` | ✅ | 200 | `

通知审计`、order_id、event_type=report_ready_email | +| `/admin/ops-alerts` | ✅ | 200 | `

运维告警审计` | + +### 1.5 真实支付 acceptance(阻塞于外部凭据) + +| 项 | 状态 | +| ------------------------ | ------- | +| 真实 alipay 商户凭据 | ⛔ 缺失 | +| 公网可访问 notify_url | ⛔ 缺失 | +| 备案域名 | ⛔ 缺失 | +| 真实 alipay-sim 之外链路 | ⛔ 阻塞 | + +## 2. 本轮新增的证据(相对 2026-06-15 报告) + +1. **portal/report 与 report.pdf 真实下载**: + - `curl /portal/.../report` → 200,body 首行 `

志愿方案报告

浏览器 e2e 自动验收

` + - `curl /portal/.../report.pdf` → 200,body 前 9 字节 `%PDF-1.4\nlocal-brows` +2. **删除申请 -> admin 审计页整站闭环**: + - `POST /portal/.../deletion-request` → `{order_id, request_logged:true, next_step}` + - `/admin/deletion-requests?order_id=GKO-...` → 命中 `张家长 / 13800138000 / 浏览器 e2e` +3. **admin 通知审计页真实可见**: + - `/admin/notifications` 页面 + `/api/admin/notifications` API 都能看到 `report_ready_email / order_id` +4. **JWT 5 分钟过期门禁生效**(符合 P2-4 安全要求) +5. **后台兼容别名已补齐**: + - `/admin/dashboard` → 200,页面含 `仪表盘` 与 `/static/dashboard.js` + - `/api/admin/orders` / `/api/admin/cases` / `/api/admin/stats/{dashboard,orders}` → 200 + +## 3. 与 2026-06-15 报告的关系 + +`reports/LOCAL_BROWSER_VALIDATION_2026-06-15.md` 中提到的: + +- ✅ 公共页、checkout、alipay_sim、portal status、portal info、deletion-request 页 — 本轮复验全部仍可用 +- ⚠️ Step 5 真实浏览器提交 — 本轮已通过 `submitIntake('submit')` 在浏览器中触发,状态从 `paid` 推进到 `serving`(之前是 paid 状态机就停在 paid),前端摘要字段与后端 status 摘要字段完全一致 +- 🆕 本轮新增:报告 HTML/PDF 真实下载、admin 三类审计页真实可见、JWT 过期门禁、后台兼容别名与 dashboard 入口补齐 + +## 4. 当前未覆盖 / 阻塞项 + +| 项 | 阻断类型 | 关闭路径 | +| ----------------------------------------------------- | -------- | ------------------------------------------------------------------------------------- | +| 真实 alipay acceptance | 外部凭据 | 见 `docs/PAYMENT_PROVIDER_ONBOARDING.md` | +| 后台订单详情页浏览器验收 | code | 在 admin 路由列表里选主要页面跑一遍 | +| 公开页 SEO / 移动端布局 | 范围 | 与当前 E2E 主链路无关 |