From 08148fe2825afc8ad6292539f5ba3f8518ad5068 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 14 Jun 2026 13:02:03 +0800 Subject: [PATCH] feat: add consent gating and dev verify --- README.md | 17 +++ admin/routes/web_public.py | 16 +++ admin/tests/test_order_info_form.py | 39 +++++++ admin/tests/test_web_public_alipay_sim_e2e.py | 5 + data/orders/intake_schema.py | 15 +++ docs/ACTIVE_EXECUTION_BOARD_2026-06-13.md | 12 +-- docs/DELIVERY_SERVICE_DESIGN.md | 76 +++++++++++++ docs/PAYMENT_DOMAIN_DESIGN.md | 100 ++++++++++++++++++ docs/T12_ACCEPTANCE_CRITERIA.md | 79 ++++++++++++++ docs/plans/T12-payment-implementation.md | 44 ++++++++ scripts/dev-verify.sh | 58 ++++++++++ scripts/gaokao-visual-report-v2.py | 2 +- 12 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 docs/DELIVERY_SERVICE_DESIGN.md create mode 100644 docs/PAYMENT_DOMAIN_DESIGN.md create mode 100644 docs/T12_ACCEPTANCE_CRITERIA.md create mode 100644 docs/plans/T12-payment-implementation.md create mode 100644 scripts/dev-verify.sh diff --git a/README.md b/README.md index 167e02b..7669cc8 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,23 @@ xdg-open http://127.0.0.1:8000/docs 默认会在空库 bootstrap 一个管理员账号;生产环境必须显式设置强密码 `GAOKAO_ADMIN_PASS`(禁止 `admin123`,至少 10 位且覆盖 3 类字符)与高熵 `GAOKAO_JWT_SECRET`。首次启动后应立即轮换默认管理员密码。 +### 本地一键验证(X-06) + +仓库已提供 `scripts/dev-verify.sh`,用于统一执行: + +- 创建/复用 `.venv` +- 安装 `requirements-admin.txt` + `requirements-dev.txt` +- 运行 `pytest` + coverage gate +- 运行 `ruff` / `mypy` + +```bash +# 完整执行(默认会安装/更新依赖) +bash scripts/dev-verify.sh + +# 已有依赖时可跳过安装 +GAOKAO_SKIP_INSTALL=1 bash scripts/dev-verify.sh +``` + ### T6.7 Docker Compose 一键启动 仓库根目录已提供 `Dockerfile`、`docker-compose.yml` 与 `.env.docker.example`。默认镜像会把运行数据写入容器外部卷 `/var/lib/gaokao`,避免覆盖仓库里的 Python 包 `data/`。默认 compose 只绑定 `127.0.0.1` 且以 `dev` 模式启动,适合本机自测;正式部署前请复制 `.env.docker.example` 到 `.env` 并替换密钥/密码。 diff --git a/admin/routes/web_public.py b/admin/routes/web_public.py index e9e3c4b..062e9ae 100644 --- a/admin/routes/web_public.py +++ b/admin/routes/web_public.py @@ -646,6 +646,11 @@ def _complete_simulated_payment( def _render_info_page( order: Order, token: str, payload: dict[str, Any], stage: str ) -> str: + consent_version = str(payload.get("consent_version") or "t12-web-mvp-v1") + consent_scope = str(payload.get("consent_scope") or "web-self-service-order-intake") + privacy_checked = "checked" if payload.get("privacy_accepted") else "" + service_terms_checked = "checked" if payload.get("service_terms_accepted") else "" + guardian_checked = "checked" if payload.get("guardian_confirmed") else "" return f""" 考生资料填写 @@ -653,12 +658,18 @@ def _render_info_page(

考生资料填写

当前阶段:{escape(_STAGE_META[stage][0])}

+

提交资料即表示:监护人已知情并同意将考生资料用于志愿填报服务;当前版本号:{escape(consent_version)}






+ + +
+
+
@@ -676,6 +687,11 @@ def _render_info_page( candidate_subjects: subjects, candidate_interests: form.get('candidate_interests') || null, guardian_notes: form.get('guardian_notes') || null, + consent_version: form.get('consent_version') || null, + consent_scope: form.get('consent_scope') || null, + privacy_accepted: form.get('privacy_accepted') === 'on', + service_terms_accepted: form.get('service_terms_accepted') === 'on', + guardian_confirmed: form.get('guardian_confirmed') === 'on', }}; const resp = await fetch('/portal/{escape(token)}/info', {{ method: 'POST', diff --git a/admin/tests/test_order_info_form.py b/admin/tests/test_order_info_form.py index e970d60..628869c 100644 --- a/admin/tests/test_order_info_form.py +++ b/admin/tests/test_order_info_form.py @@ -46,6 +46,7 @@ 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 draft = client.post( f"/portal/{token}/info", @@ -56,6 +57,11 @@ def test_order_info_form_accepts_draft_and_submit(client, settings): "candidate_subjects": ["物理", "化学", "生物"], "candidate_interests": "计算机", "guardian_notes": "更看重省内城市", + "consent_version": "t12-web-mvp-v1", + "consent_scope": "web-self-service-order-intake", + "privacy_accepted": False, + "service_terms_accepted": False, + "guardian_confirmed": False, }, ) assert draft.status_code == 200, draft.text @@ -71,6 +77,11 @@ def test_order_info_form_accepts_draft_and_submit(client, settings): "candidate_subjects": ["物理", "化学", "生物"], "candidate_interests": "计算机", "guardian_notes": "更看重省内城市", + "consent_version": "t12-web-mvp-v1", + "consent_scope": "web-self-service-order-intake", + "privacy_accepted": True, + "service_terms_accepted": True, + "guardian_confirmed": True, }, ) assert submit.status_code == 200, submit.text @@ -110,6 +121,34 @@ def test_order_info_form_becomes_read_only_after_report_ready( "candidate_score": 600, "candidate_rank": 999, "candidate_subjects": ["物理"], + "consent_version": "t12-web-mvp-v1", + "consent_scope": "web-self-service-order-intake", + "privacy_accepted": True, + "service_terms_accepted": True, + "guardian_confirmed": True, }, ) assert resp.status_code == 409 + + +def test_submit_requires_consent_fields(client, settings): + order = _seed_order(settings.orders_db_path, order_id="GKO-20260614-INFO-CONSENT") + _mark_paid(settings, order) + token = issue_portal_token(order.id, settings.jwt_secret) + + resp = client.post( + f"/portal/{token}/info", + json={ + "mode": "submit", + "candidate_score": 578, + "candidate_rank": 12034, + "candidate_subjects": ["物理", "化学", "生物"], + "consent_version": "t12-web-mvp-v1", + "consent_scope": "web-self-service-order-intake", + "privacy_accepted": False, + "service_terms_accepted": True, + "guardian_confirmed": True, + }, + ) + assert resp.status_code == 422 + assert "privacy_accepted" in resp.text diff --git a/admin/tests/test_web_public_alipay_sim_e2e.py b/admin/tests/test_web_public_alipay_sim_e2e.py index e616bf1..dde2d13 100644 --- a/admin/tests/test_web_public_alipay_sim_e2e.py +++ b/admin/tests/test_web_public_alipay_sim_e2e.py @@ -85,6 +85,11 @@ def test_alipay_sim_public_user_e2e_flow(tmp_path, monkeypatch): "candidate_subjects": ["物理", "化学", "生物"], "candidate_interests": "计算机", "guardian_notes": "更看重省内城市", + "consent_version": "t12-web-mvp-v1", + "consent_scope": "web-self-service-order-intake", + "privacy_accepted": True, + "service_terms_accepted": True, + "guardian_confirmed": True, }, ) assert submit_info.status_code == 200, submit_info.text diff --git a/data/orders/intake_schema.py b/data/orders/intake_schema.py index 10d2ac5..0f6262a 100644 --- a/data/orders/intake_schema.py +++ b/data/orders/intake_schema.py @@ -15,6 +15,11 @@ class IntakePayload(BaseModel): candidate_subjects: list[str] = Field(default_factory=list) candidate_interests: Optional[str] = None guardian_notes: Optional[str] = None + consent_version: Optional[str] = None + consent_scope: Optional[str] = None + privacy_accepted: bool = False + service_terms_accepted: bool = False + guardian_confirmed: bool = False @model_validator(mode="after") def _validate_submit_payload(self) -> "IntakePayload": @@ -25,6 +30,16 @@ class IntakePayload(BaseModel): raise ValueError("candidate_rank 为提交必填项") if not self.candidate_subjects: raise ValueError("candidate_subjects 为提交必填项") + if not self.consent_version: + raise ValueError("consent_version 为提交必填项") + if not self.consent_scope: + raise ValueError("consent_scope 为提交必填项") + if not self.privacy_accepted: + raise ValueError("privacy_accepted 为提交必填项") + if not self.service_terms_accepted: + raise ValueError("service_terms_accepted 为提交必填项") + if not self.guardian_confirmed: + raise ValueError("guardian_confirmed 为提交必填项") return self diff --git a/docs/ACTIVE_EXECUTION_BOARD_2026-06-13.md b/docs/ACTIVE_EXECUTION_BOARD_2026-06-13.md index a15f49b..5549254 100644 --- a/docs/ACTIVE_EXECUTION_BOARD_2026-06-13.md +++ b/docs/ACTIVE_EXECUTION_BOARD_2026-06-13.md @@ -47,7 +47,7 @@ Owner: planner / PM 优先级: P0 -状态: pending +状态: completed 目标: @@ -78,7 +78,7 @@ Owner: planner / PM Owner: tech-lead / engineer 优先级: P0 -状态: pending +状态: completed 目标: @@ -111,7 +111,7 @@ Owner: tech-lead / engineer Owner: tech-lead / engineer 优先级: P0 -状态: pending +状态: completed 目标: @@ -142,7 +142,7 @@ Owner: tech-lead / engineer Owner: PM / legal / ops 优先级: P1 -状态: pending +状态: completed 目标: @@ -174,7 +174,7 @@ Owner: PM / legal / ops Owner: ops 优先级: P1 -状态: pending +状态: completed 目标: @@ -207,7 +207,7 @@ Owner: ops Owner: engineer / ops 优先级: P1 -状态: pending +状态: completed 目标: diff --git a/docs/DELIVERY_SERVICE_DESIGN.md b/docs/DELIVERY_SERVICE_DESIGN.md new file mode 100644 index 0000000..5a73f67 --- /dev/null +++ b/docs/DELIVERY_SERVICE_DESIGN.md @@ -0,0 +1,76 @@ +# DELIVERY_SERVICE_DESIGN + +最后更新: 2026-06-14 + +## 1. 目标 + +将“报告生成完成”与“已交付给用户”明确区分,避免把 `delivered` 误当成只是一条后台状态修改。 + +## 2. 设计原则 + +1. 交付是独立业务对象,不等于报告文件存在 +2. 用户可见状态必须基于真实交付物 +3. 通知触发点应尽量靠近稳定主链,而不是只挂在单一路由 +4. 至少一种 MVP 交付方式先闭环 + +## 3. MVP 交付方式 + +当前优先: + +- 站内查看 HTML 报告 +- 站内下载 PDF + +后续可扩展: + +- 邮件投递 +- 微信/渠道通知 + +## 4. 建议对象 + +### delivery_job + +- `delivery_id` +- `order_id` +- `channel` (`station/email/...`) +- `status` (`pending/ready/sent/failed`) +- `payload_json` +- `created_at` +- `updated_at` + +### delivery_attempt + +- `delivery_id` +- `attempt_no` +- `status` +- `failure_reason` +- `created_at` + +## 5. 当前实现状态 + +已存在: + +- `delivery_notifications` 事件表 +- portal 状态页 / 报告页 / PDF 下载页 +- `delivered` 但无交付物时不再误报 `report_ready` + +未完成: + +- 通知触发点下沉到稳定主链 +- 独立 `delivery_job` / `delivery_attempt` 模型 +- 重试与失败原因追踪 +- 多通道统一投递状态 + +## 6. 当前最短闭环 + +1. 订单进入 `delivered` +2. 真实 HTML/PDF 都存在 +3. portal 显示 `report_ready` +4. 状态页提供查看/下载入口 +5. `report_ready` 事件落库且幂等 + +## 7. 下一步实施建议 + +1. 统一 `report_ready` 触发点 +2. 增加 `delivery_status` 最小字段 +3. 增加失败重试/失败原因 +4. 再决定是否扩到邮件通道 diff --git a/docs/PAYMENT_DOMAIN_DESIGN.md b/docs/PAYMENT_DOMAIN_DESIGN.md new file mode 100644 index 0000000..1b822f6 --- /dev/null +++ b/docs/PAYMENT_DOMAIN_DESIGN.md @@ -0,0 +1,100 @@ +# PAYMENT_DOMAIN_DESIGN + +最后更新: 2026-06-14 + +## 1. 设计目标 + +把 T12 中的支付、退款、回调、对账从“页面动作”提升为一等领域对象,避免把订单状态与支付状态混为一体。 + +## 2. 领域对象 + +### payment_order + +- `payment_id` +- `order_id` +- `provider` +- `amount_cents` +- `status` (`pending/paying/paid/failed/refund_pending/refunded`) +- `checkout_token` +- `provider_trade_no` +- `created_at` +- `updated_at` + +### payment_attempt + +- 用于保留多次发起支付/重试尝试 +- MVP 可先不单独建表,但设计必须保留扩展位 + +### refund + +- `refund_id` +- `payment_id` +- `order_id` +- `amount_cents` +- `status` +- `reason` +- `created_at` + +### reconciliation_job + +- 用于后续对账任务 +- MVP 允许只停留在设计层 + +## 3. 状态机拆分 + +### 订单状态 + +- `pending` +- `paid` +- `serving` +- `delivered` +- `completed` +- `refunded` + +### 支付状态 + +- `pending` +- `paying` +- `paid` +- `failed` +- `refund_pending` +- `refunded` + +原则: + +- 订单状态不直接替代支付状态 +- portal 展示阶段由订单 + 支付 + 交付物共同推导 + +## 4. 回调处理要求 + +回调验收至少要覆盖: + +1. provider 签名校验 +2. `payment_id / order_id` 对应关系校验 +3. `amount_cents` 金额校验 +4. 幂等处理(重复通知不能重复记账) +5. provider trade no 持久化 +6. 异常记录与人工补偿入口 + +## 5. 当前实现状态 + +已存在: + +- `data/payments/models.py` +- `data/payments/dao.py` +- `data/payments/service.py` +- `mock` / `alipay_sim` provider +- webhook 归一化处理 + +未完成: + +- 真实 `alipay` provider +- refund 一等对象 +- reconciliation job +- provider 级签名/证书联调 + +## 6. 决策 + +- MVP 只允许一个真实 provider 先闭环 +- `alipay_sim` 只用于上线前模拟,不可冒充真实支付验收 +- 真实支付上线前,必须先完成 provider doctor 与环境前置校验 diff --git a/docs/T12_ACCEPTANCE_CRITERIA.md b/docs/T12_ACCEPTANCE_CRITERIA.md new file mode 100644 index 0000000..7bb2dbc --- /dev/null +++ b/docs/T12_ACCEPTANCE_CRITERIA.md @@ -0,0 +1,79 @@ +# T12_ACCEPTANCE_CRITERIA + +最后更新: 2026-06-14 +范围: T12 用户端 Web 自助 MVP + +--- + +## 1. 目标 + +T12 的验收对象不是后台运营链路,而是用户端 Web 自助主链: + +`落地页 → 套餐页 → 下单 → 支付 → 资料填写 → 后台接单 → 状态页 → 站内查看/下载交付` + +--- + +## 2. 必须满足的 MVP 验收项 + +### A. 用户入口 + +- `/` 与 `/pricing` 可访问 +- `/checkout/{service_version}` 可创建公开订单 +- 公开订单必须带 `portal` 访问链路 + +### B. 支付 + +- 至少一个 provider 形成真实闭环(当前可先用 `alipay_sim` 做上线前模拟) +- 支付成功后订单自动进入可填写资料阶段 +- 支付失败/重复回调/金额不一致不能静默成功 + +### C. 资料填写 + +- 用户能保存草稿 +- 用户能提交资料 +- 正式提交必须记录同意信息: + - `consent_version` + - `consent_scope` + - `privacy_accepted` + - `service_terms_accepted` + - `guardian_confirmed` + +### D. 后台接单 + +- 未提交资料不得进入 `serving` +- 后台订单列表/详情可见资料提交状态 +- 后台可基于提交状态继续处理 + +### E. 交付 + +- 报告未真实存在时,不得在 portal 误报“报告已就绪” +- 用户至少支持: + - 站内查看报告 + - 下载 PDF +- 交付状态对用户可见 + +### F. 合规与恢复基线 + +- 已有隐私/数据保留/备份恢复/密钥管理文档基线 +- 前台资料提交链路有明确知情与监护人同意提示 + +--- + +## 3. 明确未完成不得误报的事项 + +以下任一未满足,T12 不得报“整体完成”: + +1. 真实支付 provider 未完成且目标口径要求真实支付 +2. 交付事件仅停留在路由级补丁,未形成稳定主链能力 +3. 前台仅有文案,无同意字段落库 +4. 备份恢复只有文档,没有最小验证 + +--- + +## 4. 当前状态分级规则 + +- 设计完成: 文档/状态机/实施板齐备 +- 实现完成: 代码与测试已落地 +- 本地验证完成: pytest/ruff/mypy/dev-verify 通过 +- 线上验证完成: 真实服务器链路通过 +- 整体完成: 全部验收项无剩余缺口 diff --git a/docs/plans/T12-payment-implementation.md b/docs/plans/T12-payment-implementation.md new file mode 100644 index 0000000..80c18ba --- /dev/null +++ b/docs/plans/T12-payment-implementation.md @@ -0,0 +1,44 @@ +# T12 Payment Implementation Plan + +> **For Hermes:** 先补真实 provider 前的模型/回调/验签/退款占位,再做外部联调。 + +**Goal:** 让 T12 支付域从当前 `mock/alipay_sim` 过渡到真实 provider 可接入、可验签、可追踪的结构。 + +**Architecture:** 复用现有 `data/payments/*`,优先保持 provider 抽象统一;订单状态与支付状态分离,portal 阶段由聚合逻辑推导。 + +**Tech Stack:** FastAPI、SQLite、现有 payment service、provider requirements、doctor 脚本。 + +--- + +## 阶段 1:当前已完成 + +- provider requirements 基线 +- `mock` / `alipay_sim` provider +- 模拟支付宝用户 E2E + +## 阶段 2:当前应继续推进 + +1. 真实 provider 文件骨架 + - `data/payments/providers/alipay.py` +2. notify 回调入口 + - `POST /api/public/payments/alipay/notify` +3. return 回跳入口 + - `GET /portal/payment-return` +4. provider 签名校验 +5. 金额校验 / 幂等防重 +6. refund 占位模型与接口约束 + +## 阶段 3:外部前置到位后执行 + +- 商户 app_id +- 私钥/支付宝公钥 +- notify_url / return_url +- webhook secret +- 真实域名与公网入口 + +## 阶段 4:验收 + +- provider doctor ready=true +- 本地/测试环境 notify 模拟通过 +- 真实沙箱/真实商户联调通过 +- 支付成功后 portal 阶段自动推进 diff --git a/scripts/dev-verify.sh b/scripts/dev-verify.sh new file mode 100644 index 0000000..7a87298 --- /dev/null +++ b/scripts/dev-verify.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VENV_DIR="${ROOT_DIR}/.venv" +PYTHON_BIN="${PYTHON_BIN:-python3}" +SKIP_INSTALL="${GAOKAO_SKIP_INSTALL:-0}" + +log() { + printf '[dev-verify] %s\n' "$1" +} + +ensure_venv() { + if [[ ! -d "${VENV_DIR}" ]]; then + log "creating venv at ${VENV_DIR}" + "${PYTHON_BIN}" -m venv "${VENV_DIR}" + fi + # shellcheck disable=SC1091 + source "${VENV_DIR}/bin/activate" + python -m pip install --upgrade pip >/dev/null +} + +install_requirements() { + if [[ "${SKIP_INSTALL}" == "1" ]]; then + log "skip install enabled" + return + fi + log "installing requirements" + pip install -r "${ROOT_DIR}/requirements-admin.txt" -r "${ROOT_DIR}/requirements-dev.txt" +} + +run_checks() { + cd "${ROOT_DIR}" + log "running pytest with coverage gate" + python -m pytest admin/tests tests data \ + --ignore=.venv \ + --ignore=.worktrees \ + --cov=. \ + --cov-report=term-missing \ + --cov-report=xml \ + --cov-fail-under=80 \ + -q + + log "running ruff" + python -m ruff check . --exclude .venv,.worktrees + + log "running mypy" + python -m mypy . +} + +main() { + ensure_venv + install_requirements + run_checks + log "all checks passed" +} + +main "$@" diff --git a/scripts/gaokao-visual-report-v2.py b/scripts/gaokao-visual-report-v2.py index b7a8544..b9aa6c3 100644 --- a/scripts/gaokao-visual-report-v2.py +++ b/scripts/gaokao-visual-report-v2.py @@ -16,7 +16,7 @@ except ImportError: print("警告: jinja2 未安装,HTML模板功能将受限") try: - from weasyprint import HTML # type: ignore[import-not-found] + from weasyprint import HTML # type: ignore[import-untyped] HAS_WEASYPRINT = True except ImportError: