diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8be4350 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.git +.github +.pytest_cache +.ruff_cache +.venv +.worktrees +__pycache__ +**/__pycache__ +*.pyc +*.pyo +*.log +.coverage +coverage.xml +htmlcov +reports +docs +tests +admin/tests +skills +*.db +*.db-shm +*.db-wal +.env +.env.local \ No newline at end of file diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..dce61f0 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,7 @@ +GAOKAO_ADMIN_BIND=0.0.0.0 +GAOKAO_ENV=prod +GAOKAO_ADMIN_PORT=8000 +GAOKAO_ADMIN_USER=admin +GAOKAO_ADMIN_PASS=change-me-now-admin-pass +GAOKAO_JWT_SECRET=replace-with-64-char-jwt-secret-before-production-1234567890abcdef +GAOKAO_ORDERS_FERNET_KEY=replace-with-strong-orders-fernet-secret-before-production \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3f4d9f..aecc6be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,13 +45,16 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - # 离线兜底:若 requirements-dev.txt 缺失,CI 仍可运行(无第三方包) + # clean env 必须同时安装测试依赖 + admin 运行依赖,避免本地环境污染掩盖导入问题 if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt else echo "::warning::requirements-dev.txt not found, installing test deps directly" pip install pytest pytest-cov fi + if [ -f requirements-admin.txt ]; then + pip install -r requirements-admin.txt + fi - name: Verify Python and pytest versions run: | @@ -63,6 +66,7 @@ jobs: # 本任务(T10.1)只生成 coverage.xml 供 T10.2 codecov 集成使用 run: | pytest \ + --cov=admin \ --cov=data \ --cov=skills \ --cov=scripts \ @@ -71,7 +75,7 @@ jobs: -v - name: Upload coverage artifact - if: matrix.python-version == '3.11' + if: always() && matrix.python-version == '3.11' && hashFiles('coverage.xml') != '' uses: actions/upload-artifact@v4 with: name: coverage-report @@ -85,7 +89,7 @@ jobs: # 仓库场景匹配;tksea/gitea 不被 codecov 官方支持,无 token 时仍能上传但不写 status) # - 不设 token: public repo 可匿名上传;徽章正常渲染 - name: Upload coverage to Codecov - if: matrix.python-version == '3.11' && hashFiles('coverage.xml') != '' + if: always() && matrix.python-version == '3.11' && hashFiles('coverage.xml') != '' uses: codecov/codecov-action@v4 with: file: coverage.xml diff --git a/.gitignore b/.gitignore index 5fec2e2..7efe5c6 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,14 @@ data/private/ .env.local *.local config.local.* + +# 本地工作树 / 临时验证产物 +.worktrees/ +.hermes_test_tmp.txt +admin/_tmp_test.txt + +# 运行时 SQLite 数据库 +/data/*.db +/data/*.db-* +/data/orders/*.db +/data/orders/*.db-* diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e5dd3..d6df2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,32 @@ --- -## v2.1 (开发中) +## v2.1 (2026-06-13) ### 🚧 进行中 +#### 新增(T5.3 已完成) + +- 📝 **T5 集成测试与文档收口** + - **T5.1 端到端主链已固化到 `tests/test_t5_e2e_workflows.py`**:覆盖“咨询→方案生成 / 审核→报告 / 订单→交付 / 升级流程 / 数据溯源展示”5 条主链 + - **T5.2 性能与并发门禁已固化到 `tests/test_t5_performance.py`**: + - `gaokao-quick-3min.py` 的真实 `parse_quick_response + generate_quick_summary + generate_quick_recommendation` 链路 100 次执行断言 `< 5s` + - `locustfile.py` 对 `admin.app` 做 10 并发、15 秒 headless 压测,断言聚合成功率 `>95%` + - **T5.3 文档同步**:重写 `docs/API.md`、`docs/ARCHITECTURE.md` 以匹配当前已落地真相,并补充 T5 验证入口与当前系统边界说明 + - **定向验证**:`python3 -m pytest tests/test_t5_e2e_workflows.py tests/test_t5_performance.py -q` 通过;其中性能基准覆盖 100 次方案生成与 10 并发后台访问两条验收路径 + - **当前定位修正**:项目应描述为“管理后台 + 订单/分享/渠道同步 + AI 审核链路”的可运行系统;用户端 Web 自助闭环仍未落地 + +#### 新增(T6.4 已完成) + +- ✨ **管理后台订单管理 `admin/routes/orders.py`** + - **端点落地**:`GET /api/orders`(真实列表)/ `GET /api/orders/{id}`(详情 + 状态历史)/ `GET /api/orders/export`(CSV 导出)/ `POST /api/orders`(手工录单)/ `PATCH /api/orders/{id}`(业务字段更新 + 状态流转 + 退款) + - **状态机守护**:写路径复用 `OrdersDAO.update()` 与 `transition_status()`,非法流转统一映射到 `E02301`,不允许绕过 6 态状态机直接写库 + - **默认脱敏**:列表、详情和 CSV 导出均走 `Order.to_dict(decrypt_sensitive="mask")`,避免管理后台直接导出完整手机号/身份证号 + - **手工兜底**:支持 `external_id` 留空的人工补录路径,与 `docs/CHANNEL_INTEGRATION.md` 约定一致;退款仅更新本地订单状态,不主动触发第三方退款 API + - **测试 6 个全部通过**(`admin/tests/test_routes_orders.py`):创建、列表/详情、PATCH 更新+付款、退款、非法状态冲突、CSV 导出 + - **定向验证**:`python3 -m pytest admin/tests/test_routes_orders.py admin/tests/test_routes.py -q` → 20 passed;`ruff check admin/routes/orders.py admin/tests/test_routes_orders.py admin/tests/test_routes.py` 通过 + - **文档同步**:`README.md` 新增 T6.4 章节;`docs/plans/T6-admin-mvp.md` 标记 T6.4 已落地并更新路由清单 + #### 新增(T6.2 已完成) - ✨ **管理后台仪表盘 — 真实 SQL 聚合层 `admin/stats.py`**(434 行) @@ -46,6 +68,23 @@ - 定向验证通过:`pytest -q admin/tests/test_logging.py admin/tests/test_errors.py` = 34/34 passed;`ruff check admin/app.py admin/errors/exceptions.py admin/logging_utils.py admin/tests/test_logging.py admin/tests/test_errors.py` 通过 - 已知缺口(T6.2 已闭环):`pytest -q admin/tests` 之前有 1 个非 T9.3 失败(`test_stats_orders_real_shape` 报 `sqlite3.OperationalError: no such table: orders`)— 根因是 stats 端点读 orders 表,但 T6.1 阶段 conftest 没建 orders DB;T6.2 在 conftest 加 `orders_db` autouse fixture 闭环,当前 `pytest -q admin/tests` 87/87 通过 +#### 新增(T4.3 已完成) + +- ✨ **订单管理 CLI `scripts/gaokao-order-manager` + `data/orders/cli.py`** + - 子命令落地:`create / list / show / update / pay / deliver / stats` + - 默认 JSON 输出,订单详情默认走 `Order.to_dict()` 遮罩模式,避免 CLI 直接泄露完整手机号/身份证 + - `create` 直接复用 `OrdersDAO.create()`;`show` 返回订单详情 + `order_status_history` + - `update` 只允许业务字段,空更新返回 exit code 2,继续强制 `status` 变更走 DAO 状态机 + - `pay` 推进 `pending -> paid`;`deliver` 在 `paid` 时自动串联 `serving -> delivered`,兼容已在 `serving` 的最后一步交付 + - `stats` 输出 `total_orders / by_status / by_source / by_service_version` + - 新增 `data/orders/tests/test_cli.py` 5 个 pytest 用例: + - 脚本级端到端主链路(create/list/show/update/pay/deliver/stats) + - 缺失订单错误码 + - 空更新防御 + - 模块内 direct-main 覆盖,`data.orders.cli` 覆盖率 **90%** + - 定向验证:`pytest -q data/orders/tests/test_cli.py` 5/5 passed;`pytest -q data/orders/tests` 166/166 passed;`ruff check data/orders/cli.py data/orders/tests/test_cli.py scripts/gaokao-order-manager` 通过;`py_compile` 通过 +- 📝 **`data/orders/README.md`** 新增 T4.3 CLI 使用示例与子命令语义 + #### 新增(T4.2 已完成) - ✨ **订单 DAO 数据访问层 `data/orders/dao.py`**(530 行,`OrdersDAO` 类) @@ -153,17 +192,18 @@ - 安全兜底:未知 permission 一律回退到最严格的 `read` 策略,防止越权 - ✨ **姓名脱敏策略复用** - 复用 `data/orders/masking.py::mask_name`,避免在分享链路重复实现脱敏算法 - - `read/comment` 默认不暴露姓名;若后续 UI 白名单显式放开姓名字段,则自动按 `mask_name` 规则脱敏 - - `edit`(以及历史兼容 `admin`)下姓名原样展示 + - `read/comment` 默认回传脱敏姓名;公开分享场景额外收紧为:3 字及以上中文名统一 `姓+**`,非中文名统一 `**` + - `edit`(以及历史兼容 `admin`)下姓名原样展示,但 `password_hash / internal_note / note / debug_info / raw_payload` 等内部字段仍强制隐藏 - ✨ **权限感知路由辅助**(`data.share.short_link.route_short_link_with_report`) - 在 `route_short_link()` 之上叠加报告 payload 渲染,支持 `report=` 直接注入或 `report_loader(report_id)` 懒加载 - resolve 失败(not_found / revoked / expired / password_required / password_wrong)时不下发 `rendered` payload,避免泄露报告元数据 - `render_report_payload(permission, report, share_url=...)` 输出统一结构:`policy + visible_fields + payload + masked_fields` - ✅ **测试验证** - - 新增 `data/share/tests/test_permission.py`,33 个 pytest 用例覆盖:三档权限、admin alias、未知 permission fallback、字段裁剪、姓名脱敏、route + report_loader 端到端 - - `python3 -m pytest data/share/tests/ -q` → **58 passed** - - `python3 -m pytest data/share/ data/orders/ -q` → **221 passed** - - `python3 -m ruff check data/share/permission.py data/share/tests/test_permission.py` → **All checks passed** + - 新增 `data/share/tests/test_permission.py`,34 个 pytest 用例覆盖:三档权限、admin alias、未知 permission fallback、字段裁剪、姓名脱敏、公开分享收敛规则、route + report_loader 端到端 + - `python3 -m pytest data/share/tests/test_permission.py -q` → **34 passed** + - `python3 -m pytest data/share/tests/ -q` → **61 passed** + - `python3 -m pytest data/share/ data/orders/ -q` → **231 passed** + - `python3 -m ruff check data/share/permission.py data/share/tests/test_permission.py data/share/short_link.py` → **All checks passed** #### 新增(T10.1 已完成) @@ -278,7 +318,6 @@ ### 📅 计划中 -- T10.3 多仓库同步脚本 - T5.5 覆盖率硬门槛(核心 ≥80% / 整体 ≥60%) --- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..429b813 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + http_proxy= \ + https_proxy= \ + HTTP_PROXY= \ + HTTPS_PROXY= \ + no_proxy= \ + NO_PROXY= + +WORKDIR /app + +COPY requirements-admin.txt /tmp/requirements-admin.txt + +RUN python -m pip install --upgrade pip \ + && python -m pip install -r /tmp/requirements-admin.txt + +COPY . /app + +EXPOSE 8000 + +CMD ["python", "-m", "admin.app", "--host", "0.0.0.0", "--port", "8000", "--log-format", "json"] \ No newline at end of file diff --git a/README.md b/README.md index a0f24de..fb7f47a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![codecov](https://codecov.io/gh/phamnazage-jpg/gaokao-volunteer-system/graph/badge.svg)](https://codecov.io/gh/phamnazage-jpg/gaokao-volunteer-system) [![Python](https://img.shields.io/badge/Python-3.10%20%7C%203.11%20%7C%203.12-blue)](https://www.python.org/) -> 一套完整的、专业的、可扩展的高考志愿填报辅助系统 +> 一套面向人工服务运营的高考志愿填报系统:管理后台、订单/分享/渠道同步、AI 审核链路已成形;用户端 Web 自助闭环仍在推进中。 ## 📋 项目简介 @@ -43,9 +43,11 @@ gaokao-volunteer-system/ │ ├── gaokao-quick-3min.py # 3分钟问卷 │ ├── gaokao-collect-info.py # 完整收集 │ ├── gaokao-checker # 规范检查(多省份) +│ ├── gaokao-shortlink # T7.1 分享短链接 CLI │ └── legacy/ # 历史版本 │ ├── data/ # 数据 +│ ├── share/ # T7 分享能力(短链接/权限策略/测试) │ ├── templates/ # 模板 │ └── examples/ # 示例 │ @@ -106,6 +108,167 @@ python3 ~/.local/bin/gaokao-visual-report-v2.py python3 ~/.local/bin/gaokao-quick-3min.py ``` +### T6.1 管理后台 FastAPI 骨架 + +管理后台代码位于 `admin/`。当前已落地:服务启动、JWT 登录/鉴权、Swagger/OpenAPI、T6.2 仪表盘、T6.3 用户管理,以及 T6.4 订单管理(手工录单 / 状态流转 / CSV 导出 / 退款)。当前更准确的项目标签是“运营后台 + 人工服务增强链路”,不是完整用户端 Web 自助产品。 + +```bash +# 安装管理后台依赖(与测试依赖分离) +pip install -r requirements-admin.txt -r requirements-dev.txt + +# 启动服务 +export GAOKAO_JWT_SECRET="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" +python3 -m admin.app --port 8000 + +# 验证 Swagger / OpenAPI +curl http://127.0.0.1:8000/health +curl http://127.0.0.1:8000/openapi.json +xdg-open http://127.0.0.1:8000/docs +``` + +默认会在空库 bootstrap 一个管理员账号;生产环境必须显式设置强密码 `GAOKAO_ADMIN_PASS`(禁止 `admin123`,至少 10 位且覆盖 3 类字符)与高熵 `GAOKAO_JWT_SECRET`。首次启动后应立即轮换默认管理员密码。 + +### 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` 并替换密钥/密码。 + +```bash +# 可选:复制示例环境变量并替换生产密钥 +cp .env.docker.example .env + +# 构建并启动 +docker compose up --build -d + +# 查看健康状态 +docker compose ps +curl http://127.0.0.1:8000/health + +# 停止并保留数据卷 +docker compose down +``` + +**关键环境变量** + +- `GAOKAO_JWT_SECRET`:JWT 签名密钥;生产环境至少 32 字符 +- `GAOKAO_ORDERS_FERNET_KEY`:订单敏感字段加密密钥;缺失会导致订单/用户相关路由不可用 +- `GAOKAO_ADMIN_PASS`:默认管理员密码;生产环境禁止 `admin123`,首次启动后应立即替换 +- `GAOKAO_ADMIN_BIND`:宿主机绑定地址;默认 `127.0.0.1`,生产环境如需对外暴露请显式改成 `0.0.0.0` +- `GAOKAO_ADMIN_PORT`:宿主机暴露端口,默认 `8000` + +### T8.4 渠道失败兜底(巡检 + 人工补录) + +当前巡检事实源以 `xianyu` 的 webhook/poller 表为准;其他渠道先视为“人工补录模板复用”。当 `xianyu` 链路异常时,先执行巡检,再决定是否走人工补录: + +```bash +# 巡检(0=ok, 1=warn, 2=critical) +python3 scripts/gaokao-channel-fallback --db data/orders.db check --source xianyu --human + +# 打印人工兜底模板 +python3 scripts/gaokao-channel-fallback --db data/orders.db manual-template --source xianyu --human +``` + +完整值班流程见 `docs/T8-4-fallback-sop.md`。 + +### T6.2 仪表盘(一站式数据统计) + +T6.1 阶段 `/api/stats/orders` 为占位端点(`_stub=True`)。T6.2 接入真实 SQL 聚合,并新增一站式仪表盘端点 `/api/stats/dashboard` 与极简页面 `/dashboard`。 + +**端点** + +| 方法 | 路径 | 鉴权 | 说明 | +| ---- | ---------------------- | ---- | ------------------------------------------- | +| GET | `/api/stats/dashboard` | JWT | 一站式仪表盘 payload(汇总 + 分布 + 趋势) | +| GET | `/api/stats/orders` | JWT | 订单维度统计(沿用 T6.1 stub 字段名) | +| GET | `/dashboard` | 公开 | 极简仪表盘页面(登录后拉取 dashboard JSON) | + +**配置** + +| 环境变量 | 默认值 | 说明 | +| ----------------------- | ---------------------- | ----------------------------------------- | +| `GAOKAO_DB_PATH` | `data/orders/admin.db` | admin_users 所在 DB | +| `GAOKAO_ORDERS_DB_PATH` | `data/orders.db` | orders 所在 DB(与 `data.orders.*` 共享) | + +**关键口径** + +- **收入 (revenue_cents)** = `paid` / `serving` / `delivered` / `completed` 四态订单的 `amount_cents` 累计值;`pending`(未付款)与 `refunded`(已退款)不计入。 +- **趋势桶粒度** = 日(UTC,`YYYY-MM-DD`)。 +- **0 填充** = 窗口内的"无订单日"也返回 0 点,前端拿到稠密序列。 +- **不读 PII** = 统计路径只触碰 `amount_cents` / `status` / `source` / `service_version` / `created_at`。 + +**响应示例**(`/api/stats/dashboard`) + +```json +{ + "summary": { + "total_orders": 6, "total_revenue_cents": 100000, "total_users": 1, + "orders_today": 3, "orders_7d": 4, "orders_30d": 5, + "revenue_today_cents": 20000, "revenue_7d_cents": 70000, "revenue_30d_cents": 100000 + }, + "by_status": {"pending": 2, "paid": 1, "serving": 1, "delivered": 0, "completed": 1, "refunded": 1}, + "by_source": {"xianyu": 3, "wechat": 1, "web": 1, "school": 1}, + "by_service_version":{"audit": 0, "basic": 6, "standard": 0, "premium": 0}, + "trends": { + "today": [{"date": "2026-06-12", "orders": 3, "revenue_cents": 20000}], + "7d": [{"date": "2026-06-06", "orders": 0, "revenue_cents": 0}, ... 共 7 个点 ...], + "30d": [{"date": "2026-05-14", "orders": 0, "revenue_cents": 0}, ... 共 30 个点 ...] + }, + "generated_at": "2026-06-12T16:30:00+00:00" +} +``` + +**本地联调** + +```bash +# 1) 启动 FastAPI 服务 +export GAOKAO_JWT_SECRET="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" +python3 -m admin.app --port 8000 + +# 2) 验证 JSON 端点 +curl -s -X POST http://127.0.0.1:8000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin123"}' + +# 3) 打开极简页面(页面内可直接登录并加载) +xdg-open http://127.0.0.1:8000/dashboard +``` + +### T6.3 用户管理(列表 / 详情 / 脱敏 / 搜索) + +T6.3 在 `orders` 表之上补齐用户管理读路径:`/api/admin/users` 返回按用户聚合后的列表,`/api/admin/users/{user_key}` 返回该用户的订单详情。默认展示形态为脱敏字段,便于运营核对且不直接暴露完整 PII。 + +**端点** + +| 方法 | 路径 | 鉴权 | 说明 | +| ---- | ----------------------------- | ---- | ------------------------------------------------ | +| GET | `/api/admin/users` | JWT | 用户列表;支持 `q` 搜索、`limit` / `offset` 分页 | +| GET | `/api/admin/users/{user_key}` | JWT | 用户详情;返回该用户的脱敏订单明细 | + +**聚合口径** + +- 优先按 `customer_phone_hash` 聚合同一用户;无手机号时退回 `customer_wechat` 指纹;再退回订单号,避免孤立记录丢失。 +- 搜索支持姓名 / 手机号 / 微信 / 订单号等常见运营核对字段。 +- 返回的 `customer_name` / `candidate_name` / `customer_phone` / `candidate_id_card` 默认走脱敏展示。 + +### T6.4 订单管理(录单 / 状态流转 / 导出 / 退款) + +T6.4 在已有 `data/orders` DAO 与状态机之上补齐管理后台写路径。运营人员现在可以通过 FastAPI 直接手工录单、更新业务字段、推进状态、导出 CSV,以及将订单推进到 `refunded`。 + +**端点** + +| 方法 | 路径 | 鉴权 | 说明 | +| ----- | -------------------- | ---- | ------------------------------------------------------- | +| GET | `/api/orders` | JWT | 订单列表;支持 `status` / `source` / `limit` / `offset` | +| GET | `/api/orders/export` | JWT | CSV 导出;默认脱敏敏感字段 | +| GET | `/api/orders/{id}` | JWT | 订单详情;附带状态历史与可流转下一状态 | +| POST | `/api/orders` | JWT | 手工录单;`external_id` 可空 | +| PATCH | `/api/orders/{id}` | JWT | 业务字段更新、状态流转、退款 | + +**约束** + +- 写路径复用 `OrdersDAO`:业务字段更新走 `update()`,状态变化强制走 `transition_status()`,不绕过 6 态状态机。 +- 导出默认脱敏:`customer_phone` / `candidate_id_card` 在 CSV 中也不返回明文,避免把完整 PII 直接写入浏览器下载文件。 +- 退款不主动调用第三方渠道 API;管理后台只把本地订单推进为 `refunded`,与 `docs/CHANNEL_INTEGRATION.md` 的合规边界一致。 + ## 📊 已支持省份 | 模式 | 省份数 | 列表 | diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 0000000..dbd5f0d --- /dev/null +++ b/admin/__init__.py @@ -0,0 +1,8 @@ +"""管理后台 (T6.1). + +提供 FastAPI 应用工厂、JWT 鉴权、订单/用户/案例 API 骨架。 +""" + +from admin.app import create_app + +__all__ = ["create_app"] diff --git a/admin/app.py b/admin/app.py new file mode 100644 index 0000000..1e1a83e --- /dev/null +++ b/admin/app.py @@ -0,0 +1,208 @@ +"""FastAPI 应用工厂 (T6.1). + +用法: + # 开发模式 + python3 -m admin.app --port 8000 + + # 编程模式 + from admin.app import create_app + app = create_app() +""" + +from __future__ import annotations + +import argparse +import logging +import secrets +import sys +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Optional + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles + +from admin.config import ( + Settings, + is_default_admin_password_secure, + is_jwt_secret_secure, + load_settings, +) +from admin.db import bootstrap_admin, ensure_schema +from admin.errors import register_exception_handler +from admin.logging_utils import ( + bind_request_context, + clear_request_context, + configure_logging, +) +from admin.routes import ( + auth_router, + cases_router, + health_router, + meta_router, + orders_router, + stats_router, + ui_router, + users_router, +) +from data.cases.schema import apply_schema as apply_cases_schema +from data.orders.schema import apply_schema as apply_orders_schema + + +logger = logging.getLogger("admin") + + +def _validate_and_log_settings(settings: Settings) -> None: + """启动时校验配置并打印关键提示。""" + secure, reason = is_jwt_secret_secure(settings) + if not secure: + if settings.env == "prod": + logger.error("JWT 密钥不安全: %s — 拒绝启动生产环境!", reason) + raise RuntimeError(f"JWT secret insecure in prod: {reason}") + logger.warning("JWT 密钥提示: %s", reason) + admin_secure, admin_reason = is_default_admin_password_secure(settings) + if not admin_secure: + if settings.env == "prod": + logger.error("默认管理员密码不安全: %s — 拒绝启动生产环境!", admin_reason) + raise RuntimeError( + f"default admin password insecure in prod: {admin_reason}" + ) + logger.warning("默认管理员密码提示: %s", admin_reason) + logger.info( + "Admin API 启动: env=%s db=%s jwt_exp_min=%d", + settings.env, + settings.db_path, + settings.jwt_expire_minutes, + ) + + +def _setup_database(settings: Settings) -> None: + """应用启动时: 初始化 admin/orders schema + bootstrap admin。""" + ensure_schema(settings.db_path) + cases_conn = apply_cases_schema(settings.db_path) + cases_conn.close() + orders_conn = apply_orders_schema(settings.orders_db_path) + orders_conn.close() + created, msg = bootstrap_admin(settings) + if created: + logger.warning("Bootstrap 管理员: %s", msg) + else: + logger.info("Bootstrap: %s", msg) + + +async def request_context_middleware(request: Request, call_next): + """为每个请求绑定 per-request 上下文 (T9.3). + + - 注入 ``request_id`` (16 字符 URL-safe 随机串) + - 注入 ``path`` / ``method`` 给后续 handler 日志消费 + - 退出时 ``clear_request_context`` 避免泄漏到下一个请求 + """ + token = bind_request_context( + request_id=f"req_{secrets.token_hex(8)}", + path=request.url.path, + method=request.method, + ) + try: + return await call_next(request) + finally: + clear_request_context(token) + + +def create_app(settings: Optional[Settings] = None) -> FastAPI: + """构造 FastAPI app 实例。 + + Args: + settings: 可选外部传入;None 时从环境加载 + + Returns: + 配置好的 FastAPI 应用 + """ + if settings is None: + settings = load_settings() + + @asynccontextmanager + async def lifespan(app: FastAPI): + _validate_and_log_settings(app.state.settings) + _setup_database(app.state.settings) + yield + + app = FastAPI( + title="高考志愿填报管理后台 API", + version="0.1.0", + description=( + "管理后台 MVP API。\n\n" + "**认证流程**: `POST /api/auth/login` → 获取 Bearer JWT →\n" + "请求头 `Authorization: Bearer *** 访问受保护路由。\n\n" + "**详细字段**: 订单完整字段见 `data/orders/models.py::Order`。\n" + "**T6.1 范围**: 仅启动骨架 + 鉴权 + Swagger。订单/用户/案例/仪表盘" + "CRUD 在 T6.2-T6.6 增量补齐。" + ), + contact={"name": "Hermes Agent"}, + license_info={"name": "MIT"}, + lifespan=lifespan, + ) + + app.state.settings = settings + + # T9.3: per-request 上下文 (request_id / path / method) + app.middleware("http")(request_context_middleware) + + # 注册路由(统一 /api 前缀已由各 router 处理) + app.include_router(health_router) + app.include_router(auth_router) + app.include_router(cases_router) + app.include_router(orders_router) + app.include_router(stats_router) + app.include_router(ui_router) + app.include_router(meta_router) + app.include_router(users_router) + + static_dir = Path(__file__).resolve().parent / "static" + app.mount("/static", StaticFiles(directory=static_dir), name="static") + + # 注册全局错误处理 (T9.2) — 业务异常 → 标准响应体 + 中文文案 + register_exception_handler(app) + + return app + + +def main(argv: Optional[list] = None) -> int: + """命令行入口:`python3 -m admin.app [--port 8000] [--host 0.0.0.0]`""" + parser = argparse.ArgumentParser( + prog="admin", + description="高考志愿填报管理后台 FastAPI 服务", + ) + parser.add_argument("--host", default="127.0.0.1", help="监听地址 (默认 127.0.0.1)") + parser.add_argument("--port", type=int, default=8000, help="监听端口 (默认 8000)") + parser.add_argument( + "--log-level", + default="info", + choices=["critical", "error", "warning", "info", "debug"], + ) + parser.add_argument( + "--log-format", + default="json", + choices=["json", "plain"], + help="日志格式: json (生产) / plain (开发) — 默认 json", + ) + args = parser.parse_args(argv) + + # T9.3: 安装结构化日志 formatter (幂等) + configure_logging(level=args.log_level.upper(), fmt=args.log_format) + + settings = load_settings() + app = create_app(settings) + + logger.info("启动 uvicorn: %s:%d", args.host, args.port) + uvicorn.run( + app, + host=args.host, + port=args.port, + log_level=args.log_level, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/admin/auth.py b/admin/auth.py new file mode 100644 index 0000000..251d9f8 --- /dev/null +++ b/admin/auth.py @@ -0,0 +1,141 @@ +"""JWT 鉴权 (T6.1). + +- encode_token / decode_token: HS256 签发与校验 +- get_current_user: FastAPI 依赖,从 Authorization 头提取 Bearer token +- require_user / require_role: 路由级保护 +""" + +from __future__ import annotations + +import time +from typing import Optional + +import jwt +from fastapi import Depends, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from admin.config import Settings +from admin.db import AdminUser, AdminUserRepo +from admin.errors import ( + AUTH_ACCOUNT_DISABLED, + AUTH_TOKEN_EXPIRED, + AUTH_TOKEN_INVALID, + BusinessError, +) + + +_BEARER_SCHEME = HTTPBearer(auto_error=False) + + +def encode_token( + user: AdminUser, + settings: Settings, + *, + extra_claims: Optional[dict] = None, +) -> str: + """签发 JWT。 + + Args: + user: 当前登录用户 + settings: 配置 + extra_claims: 可选附加 claims(如自定义角色扩展) + + Returns: + JWT 字符串 + """ + now = int(time.time()) + payload = { + "sub": f"admin:{user.id}", + "username": user.username, + "role": user.role, + "iat": now, + "exp": now + settings.jwt_expire_minutes * 60, + } + if extra_claims: + payload.update(extra_claims) + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +class TokenError(ValueError): + """Token 解析失败(签名错/过期/格式错)。""" + + +def decode_token(token: str, settings: Settings) -> dict: + """解析 JWT 校验签名与有效期。 + + Raises: + TokenError: 任何解析失败 + """ + try: + return jwt.decode( + token, + settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + ) + except jwt.ExpiredSignatureError as e: + raise TokenError("token expired") from e + except jwt.InvalidTokenError as e: + raise TokenError(f"invalid token: {e}") from e + + +def _parse_user_id_from_subject(sub: str) -> int: + """'admin:42' → 42""" + if not isinstance(sub, str) or ":" not in sub: + raise TokenError("malformed sub") + prefix, _, sid = sub.partition(":") + if prefix != "admin": + raise TokenError("unexpected sub prefix") + try: + return int(sid) + except ValueError as e: + raise TokenError("non-integer sub id") from e + + +def get_settings(request: Request) -> Settings: + """从 app.state 取 Settings(避免每次都重新加载)。""" + settings = getattr(request.app.state, "settings", None) + if settings is None: # pragma: no cover - 兜底 + from admin.config import load_settings + + settings = load_settings() + return settings + + +def _raise_token_error(exc: TokenError) -> None: + """把 TokenError 翻译成业务错误 (区分过期 vs 无效).""" + msg = str(exc).lower() + if "expired" in msg: + raise BusinessError(AUTH_TOKEN_EXPIRED, detail={"reason": str(exc)}) from exc + raise BusinessError(AUTH_TOKEN_INVALID, detail={"reason": str(exc)}) from exc + + +def get_current_user( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_BEARER_SCHEME), + settings: Settings = Depends(get_settings), +) -> AdminUser: + """FastAPI 依赖:从 Authorization: Bearer *** JWT,返回 AdminUser。 + + 缺失/无效/过期一律 401 (走业务错误码 E012xx 系列). + """ + if credentials is None or credentials.scheme.lower() != "bearer": + raise BusinessError( + AUTH_TOKEN_INVALID, detail={"reason": "missing bearer token"} + ) + try: + claims = decode_token(credentials.credentials, settings) + except TokenError as e: + _raise_token_error(e) + raise AssertionError("unreachable") + try: + user_id = _parse_user_id_from_subject(claims.get("sub", "")) + except TokenError as e: + _raise_token_error(e) + raise AssertionError("unreachable") + repo = AdminUserRepo(settings.db_path) + user = repo.get_by_id(user_id) + if user is None: + raise BusinessError(AUTH_TOKEN_INVALID, detail={"reason": "user not found"}) + if not user.is_active: + raise BusinessError(AUTH_ACCOUNT_DISABLED, detail={"user_id": user.id}) + return user diff --git a/admin/config.py b/admin/config.py new file mode 100644 index 0000000..f8b314d --- /dev/null +++ b/admin/config.py @@ -0,0 +1,126 @@ +"""配置加载 (T6.1). + +所有运行时配置通过环境变量读取。提供默认值用于开发环境,但启动时 +必须输出 WARN 提示生产环境必须显式覆盖。 +""" + +from __future__ import annotations + +import os +import secrets +import string +from dataclasses import dataclass + +from fastapi import Request + + +# 安全占位密钥(仅 dev 环境)。生产必须显式设置 GAOKAO_JWT_SECRET。 +_DEV_JWT_SECRET = "dev-only-do-not-use-in-prod-please-override-via-env" +_DEFAULT_ADMIN_PASSWORD = "admin123" +_MIN_ADMIN_PASSWORD_LENGTH = 10 + + +@dataclass(frozen=True) +class Settings: + """运行时配置(不可变)。""" + + env: str + db_path: str + orders_db_path: str # T6.2 — 订单数据 (data.orders.* 写入位置) + share_db_path: str # T7.5 — 短链接 SQLite + share_report_dir: str # T7.5 — report_id -> JSON 报告目录 + jwt_secret: str + jwt_algorithm: str + jwt_expire_minutes: int + default_admin_username: str + default_admin_password: str + + +def load_settings() -> Settings: + """从环境变量加载配置。 + + - GAOKAO_ENV : dev|prod,默认 dev + - GAOKAO_DB_PATH : 管理后台 SQLite 文件路径,默认 data/orders/admin.db + - GAOKAO_ORDERS_DB_PATH : 订单 DB 路径 (与 data.orders.* 共享),默认 data/orders.db + - GAOKAO_SHARE_DB_PATH : 分享短链接 DB 路径,默认 data/share/short_links.db + - GAOKAO_SHARE_REPORT_DIR : 分享报告 JSON 目录,默认 data/share/reports + - GAOKAO_JWT_SECRET : HS256 密钥,默认 dev 占位(启动日志 WARN) + - GAOKAO_JWT_EXP_MIN : JWT 过期时间(分钟),默认 60 + - GAOKAO_ADMIN_USER : 默认管理员用户名,默认 admin + - GAOKAO_ADMIN_PASS : 默认管理员密码,默认 admin123(仅本地开发占位) + + Returns: + Settings: 不可变配置实例 + """ + return Settings( + env=os.getenv("GAOKAO_ENV", "dev"), + db_path=os.getenv("GAOKAO_DB_PATH", "data/orders/admin.db"), + orders_db_path=os.getenv("GAOKAO_ORDERS_DB_PATH", "data/orders.db"), + share_db_path=os.getenv("GAOKAO_SHARE_DB_PATH", "data/share/short_links.db"), + share_report_dir=os.getenv("GAOKAO_SHARE_REPORT_DIR", "data/share/reports"), + jwt_secret=os.getenv("GAOKAO_JWT_SECRET", _DEV_JWT_SECRET), + jwt_algorithm=os.getenv("GAOKAO_JWT_ALGORITHM", "HS256"), + jwt_expire_minutes=int(os.getenv("GAOKAO_JWT_EXP_MIN", "60")), + default_admin_username=os.getenv("GAOKAO_ADMIN_USER", "admin"), + default_admin_password=os.getenv("GAOKAO_ADMIN_PASS", _DEFAULT_ADMIN_PASSWORD), + ) + + +def is_jwt_secret_secure(settings: Settings) -> tuple: + """判断 JWT 密钥是否满足最低安全门槛。 + + Returns: + (is_secure, reason) : 不满足时给出原因字符串 + """ + secret = settings.jwt_secret + if settings.env == "prod": + if secret == _DEV_JWT_SECRET: + return False, "生产环境禁止使用 dev 占位密钥" + if len(secret) < 32: + return False, "生产环境 JWT 密钥长度必须 >= 32 (当前 {})".format( + len(secret) + ) + if secret == _DEV_JWT_SECRET and settings.env == "dev": + return False, "dev 环境使用占位密钥(仅本地开发可接受)" + if len(secret) < 32: + return False, "JWT 密钥长度必须 >= 32 (当前 {})".format(len(secret)) + return True, "ok" + + +def is_default_admin_password_secure(settings: Settings) -> tuple[bool, str]: + """判断默认管理员密码是否满足最低安全门槛。""" + password = settings.default_admin_password + if len(password) < _MIN_ADMIN_PASSWORD_LENGTH: + return False, ( + f"默认管理员密码长度必须 >= {_MIN_ADMIN_PASSWORD_LENGTH} (当前 {len(password)})" + ) + if settings.env == "prod" and password == _DEFAULT_ADMIN_PASSWORD: + return False, "生产环境禁止使用默认管理员密码 admin123" + if settings.env == "prod": + classes = sum(( + any(ch.islower() for ch in password), + any(ch.isupper() for ch in password), + any(ch.isdigit() for ch in password), + any(ch in string.punctuation for ch in password), + )) + if classes < 3: + return False, "生产环境默认管理员密码至少覆盖 3 类字符(大小写/数字/符号)" + if settings.env == "dev" and password == _DEFAULT_ADMIN_PASSWORD: + return False, "dev 环境仍在使用默认管理员密码(仅本地临时开发可接受)" + return True, "ok" + + +def generate_secure_secret() -> str: + """生成 32-byte 十六进制密钥(用于初始化文档示例)。""" + return secrets.token_hex(32) + + +def get_settings_dep(request: Request) -> Settings: + """FastAPI 依赖:从 app.state 取已加载的 Settings。 + + 与 admin.auth.get_settings 重复,但放在 config 里便于其他路由直接引用。 + """ + settings = getattr(request.app.state, "settings", None) + if settings is None: # pragma: no cover - 兜底 + settings = load_settings() + return settings diff --git a/admin/db.py b/admin/db.py new file mode 100644 index 0000000..1c5adc3 --- /dev/null +++ b/admin/db.py @@ -0,0 +1,199 @@ +"""SQLite 连接管理 (T6.1). + +提供: +- get_connection(db_path) : 打开 SQLite(启用外键 + WAL) +- ensure_schema(db_path) : 幂等创建 admin_users 表 +- bootstrap_admin(...) : 启动时若无账户则自动创建默认管理员 +- AdminUser / AdminUserRepo : dataclass + 简单 DAO +""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from admin.config import Settings +from admin.password import hash_password, verify_password + + +def utc_now_iso() -> str: + """当前 UTC 时间(ISO8601 秒精度)。""" + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def get_connection(db_path: str) -> sqlite3.Connection: + """打开 SQLite 连接。 + + 启用外键约束;row_factory = Row(便于列名访问)。 + 默认路径会创建父目录。 + """ + path = Path(db_path) + if path != Path(":memory:") and str(db_path) != ":memory:": + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + # WAL 仅对真实文件有效(:memory: 会忽略) + if str(db_path) != ":memory:": + conn.execute("PRAGMA journal_mode = WAL") + return conn + + +ADMIN_USERS_DDL = """ +CREATE TABLE IF NOT EXISTS admin_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + last_login_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username); +""" + + +def ensure_schema(db_path: str) -> None: + """幂等创建 admin_users 表。""" + with get_connection(db_path) as conn: + conn.executescript(ADMIN_USERS_DDL) + + +@dataclass(frozen=True) +class AdminUser: + """管理员用户(不可变视图)。""" + + id: int + username: str + role: str + is_active: bool + created_at: str + last_login_at: Optional[str] = None + + @classmethod + def from_row(cls, row: sqlite3.Row) -> "AdminUser": + return cls( + id=row["id"], + username=row["username"], + role=row["role"], + is_active=bool(row["is_active"]), + created_at=row["created_at"], + last_login_at=row["last_login_at"], + ) + + def to_public_dict(self) -> dict: + """公开字段(不含密码)。""" + return { + "id": self.id, + "username": self.username, + "role": self.role, + "is_active": self.is_active, + "created_at": self.created_at, + "last_login_at": self.last_login_at, + } + + +class AdminUserRepo: + """AdminUser 仓库(基于 sqlite3)。""" + + def __init__(self, db_path: str): + self.db_path = db_path + + def _conn(self) -> sqlite3.Connection: + return get_connection(self.db_path) + + def get_by_username(self, username: str) -> Optional[tuple[AdminUser, str]]: + """按用户名查找,返回 (AdminUser, password_hash) 或 None。""" + with self._conn() as conn: + row = conn.execute( + "SELECT id, username, password_hash, role, is_active, " + "created_at, last_login_at FROM admin_users WHERE username = ?", + (username,), + ).fetchone() + if row is None: + return None + return AdminUser.from_row(row), row["password_hash"] + + def get_by_id(self, user_id: int) -> Optional[AdminUser]: + """按 ID 查找(不含密码)。""" + with self._conn() as conn: + row = conn.execute( + "SELECT id, username, role, is_active, created_at, last_login_at " + "FROM admin_users WHERE id = ?", + (user_id,), + ).fetchone() + return AdminUser.from_row(row) if row else None + + def create(self, username: str, password: str, role: str = "admin") -> AdminUser: + """创建管理员。重复 username 抛 sqlite3.IntegrityError。""" + now = utc_now_iso() + password_hash = hash_password(password) + with self._conn() as conn: + cur = conn.execute( + "INSERT INTO admin_users(username, password_hash, role, " + "is_active, created_at) VALUES(?, ?, ?, 1, ?)", + (username, password_hash, role, now), + ) + user_id = cur.lastrowid or 0 + if user_id <= 0: # pragma: no cover - 数据库异常兜底 + raise RuntimeError("failed to obtain lastrowid for new admin user") + return AdminUser( + id=user_id, + username=username, + role=role, + is_active=True, + created_at=now, + ) + + def count(self) -> int: + """当前账户总数。""" + with self._conn() as conn: + row = conn.execute("SELECT COUNT(*) AS n FROM admin_users").fetchone() + return int(row["n"]) + + def update_last_login(self, user_id: int) -> None: + """更新最后登录时间。""" + with self._conn() as conn: + conn.execute( + "UPDATE admin_users SET last_login_at = ? WHERE id = ?", + (utc_now_iso(), user_id), + ) + + +def bootstrap_admin(settings: Settings) -> tuple[bool, str]: + """若 admin_users 为空,自动创建默认管理员。 + + Returns: + (created, message): created=True 表示已创建, message 为提示文本 + """ + ensure_schema(settings.db_path) + repo = AdminUserRepo(settings.db_path) + if repo.count() > 0: + return False, "已存在管理员账户,跳过 bootstrap" + user = repo.create( + username=settings.default_admin_username, + password=settings.default_admin_password, + ) + return True, ( + f"已创建默认管理员: username={user.username} id={user.id} " + f"(请尽快通过 GAOKAO_ADMIN_PASS 覆盖默认密码!)" + ) + + +def authenticate( + repo: AdminUserRepo, username: str, password: str +) -> Optional[AdminUser]: + """校验用户名+密码,成功返回 AdminUser 并更新 last_login_at。""" + result = repo.get_by_username(username) + if result is None: + return None + user, password_hash = result + if not user.is_active: + return None + if not verify_password(password, password_hash): + return None + repo.update_last_login(user.id) + return user diff --git a/admin/errors/__init__.py b/admin/errors/__init__.py new file mode 100644 index 0000000..8e92eee --- /dev/null +++ b/admin/errors/__init__.py @@ -0,0 +1,76 @@ +"""统一错误处理模块 (T9.2 — 用户友好提示). + +设计目标: +- 业务码与 HTTP 状态码解耦 (T9.1) +- 错误码 → 中文消息 + 解决建议 (本任务) +- 单源注册表, 避免散落字符串 + +公开 API: +- BusinessError : 业务异常基类 +- catch(...) : 统一异常捕获装饰器 (T9.4) +- ErrorCode / ErrorSegment / ErrorSubdomain : 码点 dataclass 与枚举 +- 常量 (AUTH_*, BIZ_*, DATA_*, THIRD_PARTY_*, SYS_*, FALLBACK_CODE) +- get_message(code, locale='zh-CN') -> Message +- register_exception_handler(app) : FastAPI 全局 handler + +后续 T9.3 / T9.4 将分别接入结构化日志与 @catch 装饰器. +""" + +from admin.errors import codes as _codes +from admin.errors.codes import ( + FALLBACK_CODE, + ErrorCode, + ErrorSegment, + ErrorSubdomain, +) +from admin.errors.exceptions import ( + BusinessError, + catch, + error_response, + register_exception_handler, +) +from admin.errors.registry import ( + MESSAGES_ZH_CN, + Message, + MessageNotFoundError, + get_message, + is_registered, + registered_codes, +) + +# 集中 re-export 所有声明的 ErrorCode 常量, 调用方只需 `from admin.errors import AUTH_*` +# 这样新码点不需要改 __init__.py (但需要在 codes.py 里声明并用 is_registered 校验注册) +_ERROR_CODE_NAMES = tuple( + sorted( + name + for name, value in vars(_codes).items() + if isinstance(value, ErrorCode) and not name.startswith("_") + ) +) + + +def __getattr__(name: str): + """PEP 562 lazy attribute: 让 from admin.errors import AUTH_INVALID_CREDENTIALS 也能拿到.""" + if name in _ERROR_CODE_NAMES: + return getattr(_codes, name) + raise AttributeError(f"module 'admin.errors' has no attribute {name!r}") + + +# 显式列出, 方便 IDE 自动补全与 lint +__all__ = [ + "BusinessError", + "catch", + "ErrorCode", + "ErrorSegment", + "ErrorSubdomain", + "FALLBACK_CODE", + "MESSAGES_ZH_CN", + "Message", + "MessageNotFoundError", + "error_response", + "get_message", + "is_registered", + "register_exception_handler", + "registered_codes", + *_ERROR_CODE_NAMES, +] diff --git a/admin/errors/codes.py b/admin/errors/codes.py new file mode 100644 index 0000000..cddea90 --- /dev/null +++ b/admin/errors/codes.py @@ -0,0 +1,184 @@ +"""错误码常量定义 (T9.1 + T9.2 落地)。 + +码点结构 (固定 6 字符): + E AA BBB + | | +-- 段内顺序号 (001-999, 零填充 3 位) + | +----- 段号 (2 位) + +-------- 字面量 'E' 固定前缀 + +段号分配 (T9.1): + 01 用户 (E01001-E01199, 199 码点) + 02 业务 (E02001-E02199, 199 码点) + 03 数据 (E03001-E03199, 199 码点) + 04 第三方 (E04001-E04199, 199 码点) + 05 系统 (E05001-E05099, 99 码点) + 90-99 保留 (不分配给业务) + +每段内 xx0xx-xx5xx 是子域位 (xx0 通用 / xx1 凭证 / xx2 会话 / ...). + +注意: +- 字面量前缀 'E' 用于日志链路快速定位业务错误. +- 与 HTTP 状态码解耦 — 同一 HTTP 状态可对应不同业务码. +- 5xx 系统级错误严禁落到非 05 段 (防兜底掩盖). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import FrozenSet + + +# ---------------- 段与子域枚举 ---------------- + + +class ErrorSegment(str, Enum): + """业务段号 (2 位数字字符串).""" + + USER = "01" + BUSINESS = "02" + DATA = "03" + THIRD_PARTY = "04" + SYSTEM = "05" + + @classmethod + def values(cls) -> FrozenSet[str]: + return frozenset(s.value for s in cls) + + +class ErrorSubdomain(str, Enum): + """段内子域位 (按段独立计数). + + 取值对应码点第 2 位 (xx?xx 中的 ?): + 0 通用 + 1 凭证 + 2 会话/速率 + 3 权限/状态机 + 4 设备/并发 + 5 配额/迁移 + """ + + GENERAL = "0" + CREDENTIAL = "1" + SESSION = "2" + PERMISSION = "3" + DEVICE = "4" + QUOTA = "5" + + +# ---------------- 错误码 dataclass ---------------- + + +@dataclass(frozen=True) +class ErrorCode: + """业务错误码. + + 形如 E01A01: E 前缀 + 段号 01 + 子域位 A (0-5) + 段内子码 01. + """ + + segment: ErrorSegment + subdomain: ErrorSubdomain + sequence: int # 1-99 (保留首位给 1-9 用于通用, 10-99 子桶内分配) + + def __post_init__(self) -> None: + if not (1 <= self.sequence <= 99): + raise ValueError(f"sequence 必须在 1-99, 得到 {self.sequence}") + + def __str__(self) -> str: + # E + 段号 + 子域位 + 2 位顺序号 + return f"E{self.segment.value}{self.subdomain.value}{self.sequence:02d}" + + @classmethod + def of(cls, code: str) -> "ErrorCode": + """从码点字符串反解 (用于日志/测试断言). + + Raises: + ValueError: 码点格式不合法 + """ + if len(code) != 6 or not code.startswith("E"): + raise ValueError(f"无效码点: {code!r} (期望 E + 5 位数字)") + try: + seg = ErrorSegment(code[1:3]) + sub = ErrorSubdomain(code[3]) + except ValueError as exc: + raise ValueError(f"无效码点: {code!r} ({exc})") from exc + try: + seq = int(code[4:]) + except ValueError as exc: + raise ValueError(f"无效码点: {code!r} ({exc})") from exc + if not (1 <= seq <= 99): + raise ValueError(f"无效码点: {code!r} (sequence 越界)") + return cls(segment=seg, subdomain=sub, sequence=seq) + + +# ---------------- 内置错误码常量 ---------------- +# +# 在这里集中声明项目实际使用的业务错误码, 避免散落字符串. +# 命名约定: __ (全大写, 下划线) +# 新增错误码时: +# 1) 在此处声明常量 +# 2) 在 registry.py 的 MESSAGES_ZH_CN 中注册中文文案 + + +# 01 段 — 用户域 +AUTH_INVALID_CREDENTIALS = ErrorCode( + segment=ErrorSegment.USER, subdomain=ErrorSubdomain.CREDENTIAL, sequence=1 +) # E01101 +AUTH_TOKEN_EXPIRED = ErrorCode( + segment=ErrorSegment.USER, subdomain=ErrorSubdomain.SESSION, sequence=1 +) # E01201 +AUTH_TOKEN_INVALID = ErrorCode( + segment=ErrorSegment.USER, subdomain=ErrorSubdomain.SESSION, sequence=2 +) # E01202 +AUTH_INSUFFICIENT_PERMISSION = ErrorCode( + segment=ErrorSegment.USER, subdomain=ErrorSubdomain.PERMISSION, sequence=1 +) # E01301 +AUTH_ACCOUNT_DISABLED = ErrorCode( + segment=ErrorSegment.USER, subdomain=ErrorSubdomain.CREDENTIAL, sequence=2 +) # E01102 + +# 02 段 — 业务域 +BIZ_ORDER_NOT_FOUND = ErrorCode( + segment=ErrorSegment.BUSINESS, subdomain=ErrorSubdomain.GENERAL, sequence=1 +) # E02001 +BIZ_ORDER_INVALID_STATUS = ErrorCode( + segment=ErrorSegment.BUSINESS, subdomain=ErrorSubdomain.PERMISSION, sequence=1 +) # E02301 +BIZ_RATE_LIMITED = ErrorCode( + segment=ErrorSegment.BUSINESS, subdomain=ErrorSubdomain.QUOTA, sequence=1 +) # E02501 + +# 03 段 — 数据域 +DATA_VALIDATION_FAILED = ErrorCode( + segment=ErrorSegment.DATA, subdomain=ErrorSubdomain.GENERAL, sequence=1 +) # E03001 +DATA_NOT_FOUND = ErrorCode( + segment=ErrorSegment.DATA, subdomain=ErrorSubdomain.GENERAL, sequence=2 +) # E03002 +DATA_PERSIST_FAILED = ErrorCode( + segment=ErrorSegment.DATA, subdomain=ErrorSubdomain.GENERAL, sequence=3 +) # E03003 + +# 04 段 — 第三方域 +THIRD_PARTY_UPSTREAM_ERROR = ErrorCode( + segment=ErrorSegment.THIRD_PARTY, subdomain=ErrorSubdomain.GENERAL, sequence=1 +) # E04001 +THIRD_PARTY_TIMEOUT = ErrorCode( + segment=ErrorSegment.THIRD_PARTY, subdomain=ErrorSubdomain.GENERAL, sequence=2 +) # E04002 + +# 05 段 — 系统域 +SYS_INTERNAL_ERROR = ErrorCode( + segment=ErrorSegment.SYSTEM, subdomain=ErrorSubdomain.GENERAL, sequence=1 +) # E05001 +SYS_CONFIG_MISSING = ErrorCode( + segment=ErrorSegment.SYSTEM, subdomain=ErrorSubdomain.GENERAL, sequence=2 +) # E05002 +SYS_RESOURCE_EXHAUSTED = ErrorCode( + segment=ErrorSegment.SYSTEM, subdomain=ErrorSubdomain.GENERAL, sequence=3 +) # E05003 + +# 兜底码 (用于未注册码点时使用,确保任何错误都能渲染) +FALLBACK_CODE = ErrorCode( + segment=ErrorSegment.SYSTEM, subdomain=ErrorSubdomain.GENERAL, sequence=99 +) # E05099 diff --git a/admin/errors/exceptions.py b/admin/errors/exceptions.py new file mode 100644 index 0000000..8e8d3f0 --- /dev/null +++ b/admin/errors/exceptions.py @@ -0,0 +1,317 @@ +"""业务异常 + FastAPI 集成 (T9.2). + +职责: +- BusinessError : 业务异常类, 携带 ErrorCode +- error_response(...) : 渲染为标准响应体 (与 OpenAPI 文档契约) +- register_exception_handler(app) : 把 BusinessError / HTTPException / 兜底异常 + 全部统一到 { code, message, suggestion, severity, retryable, detail } 形状 +""" + +from __future__ import annotations + +import inspect +import logging +import sys +from functools import wraps +from typing import Any, Callable, Mapping, Optional, TypeVar, cast + +from fastapi import FastAPI, HTTPException, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from admin.errors.codes import ErrorCode, FALLBACK_CODE +from admin.errors.registry import Message, get_message +from admin.logging_utils import log_event, log_event_exc + + +logger = logging.getLogger("admin.errors") +F = TypeVar("F", bound=Callable[..., Any]) + + +def catch( + code: ErrorCode, + *, + reraise: bool = False, +) -> Callable[[F], F]: + """捕获函数内部异常并统一记录/翻译为 ``BusinessError``. + + - ``BusinessError`` 直接透传,不重复包裹。 + - 其它 ``Exception`` 先打结构化异常日志,再按 ``reraise`` 决定: + - ``False``: 转成 ``BusinessError(code)`` + - ``True``: 记录后原样抛出 + - 同时支持同步/异步函数。 + """ + + def decorator(func: F) -> F: + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except BusinessError: + raise + except Exception as exc: + _log_caught_exception(func.__name__, code, exc) + if reraise: + raise + raise BusinessError(code) from exc + + return cast(F, async_wrapper) + + @wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + except BusinessError: + raise + except Exception as exc: + _log_caught_exception(func.__name__, code, exc) + if reraise: + raise + raise BusinessError(code) from exc + + return cast(F, sync_wrapper) + + return decorator + + +def _log_caught_exception(function_name: str, code: ErrorCode, exc: Exception) -> None: + log_event_exc( + logger, + logging.ERROR, + "caught_exception", + exc_info=(type(exc), exc, exc.__traceback__), + msg=f"Caught exception in {function_name}", + code=str(code), + function=function_name, + ) + + +# ---------------- 响应体契约 ---------------- +# +# { +# "code": "E01101", # 业务错误码 (T9.1) +# "message": "...", # 给用户看的中文消息 (T9.2) +# "suggestion": "...", # 给用户的解决建议 (T9.2) +# "severity": "warn", # info/warn/error +# "retryable": false, # 是否可重试 +# "detail": {...} # 可选, 调试用上下文 (生产环境按 env 开关) +# } + + +# ---------------- 异常类 ---------------- + + +class BusinessError(Exception): + """业务异常 — 路由层抛出会被全局 handler 捕获并渲染. + + 使用示例:: + + if user is None: + raise BusinessError(AUTH_INVALID_CREDENTIALS) + if not user.is_active: + raise BusinessError(AUTH_ACCOUNT_DISABLED, detail={"user_id": user.id}) + """ + + def __init__( + self, + code: ErrorCode, + *, + detail: Optional[Mapping[str, Any]] = None, + locale: str = "zh-CN", + http_status: Optional[int] = None, + ) -> None: + super().__init__(str(code)) + self.code = code + self.detail = dict(detail) if detail else None + self.locale = locale + self.http_status = http_status # 显式覆盖时用, 如某些 4xx 业务码强制 401 + + +def error_response( + code: str, + message: Message, + *, + detail: Optional[Mapping[str, Any]] = None, + include_detail: bool = False, +) -> dict: + """组装标准响应体.""" + body = { + "code": code, + "message": message.message, + "suggestion": message.suggestion, + "severity": message.severity, + "retryable": message.retryable, + } + if include_detail and detail: + body["detail"] = dict(detail) + return body + + +# ---------------- HTTP 状态码映射 ---------------- +# +# 业务码 → 默认 HTTP 状态码. 同一 HTTP 状态可对应多个业务码, 但不同业务码 +# 渲染的中文文案不同 (T9.1 决策: 业务码与 HTTP 状态码解耦). + +# 兼容 starlette 新旧命名 (HTTP_422_UNPROCESSABLE_ENTITY → HTTP_422_UNPROCESSABLE_CONTENT). +# 旧 starlette 没有 HTTP_422_UNPROCESSABLE_CONTENT, 但 HTTP_422 数值就是 422. +_HTTP_422: int = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", None) or 422 + + +_DEFAULT_HTTP_STATUS: Mapping[str, int] = { + # 01 用户域 + "E01101": status.HTTP_401_UNAUTHORIZED, + "E01102": status.HTTP_403_FORBIDDEN, + "E01201": status.HTTP_401_UNAUTHORIZED, + "E01202": status.HTTP_401_UNAUTHORIZED, + "E01301": status.HTTP_403_FORBIDDEN, + # 02 业务域 + "E02001": status.HTTP_404_NOT_FOUND, + "E02301": status.HTTP_409_CONFLICT, + "E02501": status.HTTP_429_TOO_MANY_REQUESTS, + # 03 数据域 + "E03001": _HTTP_422, + "E03002": status.HTTP_404_NOT_FOUND, + "E03003": status.HTTP_500_INTERNAL_SERVER_ERROR, + # 04 第三方域 + "E04001": status.HTTP_502_BAD_GATEWAY, + "E04002": status.HTTP_504_GATEWAY_TIMEOUT, + # 05 系统域 + "E05001": status.HTTP_500_INTERNAL_SERVER_ERROR, + "E05002": status.HTTP_500_INTERNAL_SERVER_ERROR, + "E05003": status.HTTP_503_SERVICE_UNAVAILABLE, +} + +_WWW_AUTHENTICATE_BEARER_CODES = { + "E01201", # AUTH_TOKEN_EXPIRED + "E01202", # AUTH_TOKEN_INVALID +} + + +def http_status_for(code: str) -> int: + """业务码 → HTTP 状态码 (无显式映射时默认 400).""" + return _DEFAULT_HTTP_STATUS.get(code, status.HTTP_400_BAD_REQUEST) + + +# ---------------- FastAPI handler ---------------- + + +def register_exception_handler(app: FastAPI) -> None: + """注册全局异常 handler (在 create_app 内调用).""" + + @app.exception_handler(BusinessError) + async def _handle_business_error(request: Request, exc: BusinessError): + code_str = str(exc.code) + msg = get_message(code_str, locale=exc.locale) + http_status = exc.http_status or http_status_for(code_str) + body = error_response( + code=code_str, + message=msg, + detail=exc.detail, + include_detail=exc.detail is not None, + ) + log_event( + logger, + logging.WARNING, + "business_error", + msg=f"BusinessError code={code_str} status={http_status}", + code=code_str, + path=request.url.path, + method=request.method, + status=http_status, + ) + headers = None + if code_str in _WWW_AUTHENTICATE_BEARER_CODES: + headers = {"WWW-Authenticate": "Bearer"} + return JSONResponse(status_code=http_status, content=body, headers=headers) + + @app.exception_handler(HTTPException) + async def _handle_http_exception(request: Request, exc: HTTPException): + """未升级为 BusinessError 的 FastAPI 内置异常 — 走兜底文案. + + 目的: 即使路由层忘了用 BusinessError, 用户也能看到中文提示, 而不是裸 500. + """ + fallback_code_str = str(FALLBACK_CODE) + msg = get_message(fallback_code_str) + # detail 是字符串时透传, 否则只记日志不暴露给用户 + original_detail = exc.detail if isinstance(exc.detail, str) else None + body = error_response( + code=fallback_code_str, + message=msg, + detail={"http_status": exc.status_code, "reason": original_detail} + if original_detail + else None, + include_detail=original_detail is not None, + ) + log_event( + logger, + logging.WARNING, + "http_exception_fallback", + msg=f"HTTPException status={exc.status_code} mapped to BusinessError", + path=request.url.path, + method=request.method, + status=exc.status_code, + ) + return JSONResponse(status_code=exc.status_code, content=body) + + @app.exception_handler(RequestValidationError) + async def _handle_validation_error(request: Request, exc: RequestValidationError): + """422 pydantic 校验失败 → DATA_VALIDATION_FAILED 业务码.""" + from admin.errors.codes import DATA_VALIDATION_FAILED + + msg = get_message(str(DATA_VALIDATION_FAILED)) + # 错误字段摘要: 取每个错误的 loc + msg, 不暴露内部值 + field_errors = [ + { + "field": ".".join(str(p) for p in err.get("loc", [])), + "reason": err.get("msg"), + } + for err in exc.errors() + ] + body = error_response( + code=str(DATA_VALIDATION_FAILED), + message=msg, + detail={"fields": field_errors}, + include_detail=True, + ) + log_event( + logger, + logging.INFO, + "validation_error", + msg=f"ValidationError fields={len(field_errors)}", + path=request.url.path, + method=request.method, + code_count=len(field_errors), + ) + return JSONResponse( + status_code=_HTTP_422, + content=body, + ) + + @app.exception_handler(Exception) + async def _handle_unexpected(request: Request, exc: Exception): + """任何未捕获异常 → SYS_INTERNAL_ERROR + 兜底文案. + + 注意: 不在响应里暴露 traceback / 异常类名 (信息泄露面), 仅日志记录. + """ + from admin.errors.codes import SYS_INTERNAL_ERROR + + msg = get_message(str(SYS_INTERNAL_ERROR)) + log_event_exc( + logger, + logging.ERROR, + "unhandled_exception", + exc_info=sys.exc_info(), + msg="Unhandled exception (mapped to SYS_INTERNAL_ERROR)", + path=request.url.path, + method=request.method, + ) + body = error_response( + code=str(SYS_INTERNAL_ERROR), + message=msg, + ) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=body, + ) diff --git a/admin/errors/registry.py b/admin/errors/registry.py new file mode 100644 index 0000000..707d297 --- /dev/null +++ b/admin/errors/registry.py @@ -0,0 +1,241 @@ +"""i18n 消息注册表 (T9.2 — 用户友好提示). + +每个错误码对应: +- message : 给用户看的中文短句 (≤ 30 字, 一句话讲清楚发生了什么) +- suggestion : 给用户的可执行建议 (≤ 50 字, 具体到下一步动作) +- severity : 'info' / 'warn' / 'error' (驱动前端图标与重试策略, T9.3 接入) +- retryable : True 表示前端/SDK 可安全重试 (T9.4 装饰器用) + +未来扩展: +- 引入 en-US 资源包时, 把 MESSAGES 改为按 locale 索引的 dict: + MESSAGES = {"zh-CN": {...}, "en-US": {...}} +- CI 校验脚本应扫描 codes.py 的常量是否都已注册, 防止散落字符串 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Literal, Mapping + +from admin.errors.codes import ( + AUTH_ACCOUNT_DISABLED, + AUTH_INSUFFICIENT_PERMISSION, + AUTH_INVALID_CREDENTIALS, + AUTH_TOKEN_EXPIRED, + AUTH_TOKEN_INVALID, + BIZ_ORDER_INVALID_STATUS, + BIZ_ORDER_NOT_FOUND, + BIZ_RATE_LIMITED, + DATA_NOT_FOUND, + DATA_PERSIST_FAILED, + DATA_VALIDATION_FAILED, + FALLBACK_CODE, + SYS_CONFIG_MISSING, + SYS_INTERNAL_ERROR, + SYS_RESOURCE_EXHAUSTED, + THIRD_PARTY_TIMEOUT, + THIRD_PARTY_UPSTREAM_ERROR, +) + + +Severity = Literal["info", "warn", "error"] + + +@dataclass(frozen=True) +class Message: + """用户可见的错误消息 (i18n 资源包的最小单元).""" + + code: str + message: str + suggestion: str + severity: Severity + retryable: bool + + def to_dict(self) -> dict: + return { + "code": self.code, + "message": self.message, + "suggestion": self.suggestion, + "severity": self.severity, + "retryable": self.retryable, + } + + +class MessageNotFoundError(KeyError): + """注册表中找不到对应码点 (本应被兜底拦截,仅测试用).""" + + +# ---------------- zh-CN 资源包 ---------------- +# +# 编写准则: +# - message: 陈述句, 不带感叹号; 第二人称避免 ('您'/'请' 可用) +# - suggestion: 祈使句开头, 给出 1-2 步可执行动作; 不承诺能解决 +# - 不暴露内部实现细节 (DB 名/字段名/IP), 但允许给文件路径/链接 +# - 同一段号内的码点, 文案语气保持一致 + +MESSAGES_ZH_CN: Mapping[str, Message] = { + # 01 段 — 用户域 + str(AUTH_INVALID_CREDENTIALS): Message( + code=str(AUTH_INVALID_CREDENTIALS), + message="用户名或密码不正确", + suggestion="请检查大小写和输入法,确认无误后重新登录。忘记密码请联系管理员重置。", + severity="warn", + retryable=False, + ), + str(AUTH_TOKEN_EXPIRED): Message( + code=str(AUTH_TOKEN_EXPIRED), + message="登录状态已过期", + suggestion="请重新登录后再继续操作。系统默认会话有效期 5 分钟。", + severity="warn", + retryable=False, + ), + str(AUTH_TOKEN_INVALID): Message( + code=str(AUTH_TOKEN_INVALID), + message="登录凭证无效", + suggestion="请重新登录。如反复出现,请清除浏览器 Cookie 后重试。", + severity="warn", + retryable=False, + ), + str(AUTH_INSUFFICIENT_PERMISSION): Message( + code=str(AUTH_INSUFFICIENT_PERMISSION), + message="当前账号无访问权限", + suggestion="请使用具备相应权限的账号登录,或联系管理员开通权限。", + severity="warn", + retryable=False, + ), + str(AUTH_ACCOUNT_DISABLED): Message( + code=str(AUTH_ACCOUNT_DISABLED), + message="账号已被停用", + suggestion="请联系管理员确认账号状态,启用后再登录。", + severity="warn", + retryable=False, + ), + # 02 段 — 业务域 + str(BIZ_ORDER_NOT_FOUND): Message( + code=str(BIZ_ORDER_NOT_FOUND), + message="未找到该订单", + suggestion="请检查订单号是否正确,或在订单列表中通过筛选条件重新查询。", + severity="warn", + retryable=False, + ), + str(BIZ_ORDER_INVALID_STATUS): Message( + code=str(BIZ_ORDER_INVALID_STATUS), + message="订单当前状态不支持该操作", + suggestion="请刷新订单详情查看最新状态,或前往订单列表查看状态流转。", + severity="warn", + retryable=False, + ), + str(BIZ_RATE_LIMITED): Message( + code=str(BIZ_RATE_LIMITED), + message="请求过于频繁", + suggestion="请稍候 30 秒后再试。如持续触发,可联系管理员调整配额。", + severity="warn", + retryable=True, + ), + # 03 段 — 数据域 + str(DATA_VALIDATION_FAILED): Message( + code=str(DATA_VALIDATION_FAILED), + message="请求数据未通过校验", + suggestion="请检查必填字段和格式。鼠标悬停字段标签可查看填写要求。", + severity="warn", + retryable=False, + ), + str(DATA_NOT_FOUND): Message( + code=str(DATA_NOT_FOUND), + message="未找到对应的数据", + suggestion="该记录可能已被删除或尚未创建,请刷新页面或返回列表确认。", + severity="warn", + retryable=False, + ), + str(DATA_PERSIST_FAILED): Message( + code=str(DATA_PERSIST_FAILED), + message="数据保存失败", + suggestion="请稍后重试。如反复失败,请联系技术支持并保留错误码。", + severity="error", + retryable=True, + ), + # 04 段 — 第三方域 + str(THIRD_PARTY_UPSTREAM_ERROR): Message( + code=str(THIRD_PARTY_UPSTREAM_ERROR), + message="外部服务暂时不可用", + suggestion="我们正在同步上游状态,请稍后重试;如紧急可联系客服。", + severity="error", + retryable=True, + ), + str(THIRD_PARTY_TIMEOUT): Message( + code=str(THIRD_PARTY_TIMEOUT), + message="外部服务响应超时", + suggestion="请稍后重试,或在网络稳定的环境下再次尝试。", + severity="error", + retryable=True, + ), + # 05 段 — 系统域 + str(SYS_INTERNAL_ERROR): Message( + code=str(SYS_INTERNAL_ERROR), + message="系统内部异常", + suggestion="请稍后重试。如反复出现,请联系技术支持并提供错误码。", + severity="error", + retryable=True, + ), + str(SYS_CONFIG_MISSING): Message( + code=str(SYS_CONFIG_MISSING), + message="系统配置缺失", + suggestion="请联系运维人员检查服务配置,确认环境变量已正确注入。", + severity="error", + retryable=False, + ), + str(SYS_RESOURCE_EXHAUSTED): Message( + code=str(SYS_RESOURCE_EXHAUSTED), + message="系统资源不足", + suggestion="当前访问量过高,请稍后再试;如紧急可联系运维扩容。", + severity="error", + retryable=True, + ), +} + + +# 兜底文案 (任何未注册码点都返回这一份, 永远不会让用户看到空白) +_FALLBACK_MESSAGE = Message( + code=str(FALLBACK_CODE), + message="服务暂时无法处理请求", + suggestion="请稍后重试,或联系技术支持并提供错误码以便排查。", + severity="error", + retryable=True, +) + + +def get_message(code: str, locale: str = "zh-CN") -> Message: + """根据业务码查找本地化消息. + + 行为契约: + - 已注册码点 → 返回 MESSAGES_ZH_CN 中的 Message + - 未注册但格式合法的码点 → 返回 _FALLBACK_MESSAGE (但 code 字段被替换为入参, + 便于排查是哪类未注册) + - 格式非法码点 → 返回 _FALLBACK_MESSAGE (code 字段保留原值以保留上下文) + + 当前实现只支持 zh-CN; 后续多 locale 时改为 MESSAGES[locale].get(code, fallback). + """ + if locale != "zh-CN": + # 当前仅支持 zh-CN, 其他 locale 走兜底 + return _FALLBACK_MESSAGE + msg = MESSAGES_ZH_CN.get(code) + if msg is not None: + return msg + # 未注册: 保留原 code 用于排查, 文案用兜底 + return Message( + code=code, + message=_FALLBACK_MESSAGE.message, + suggestion=_FALLBACK_MESSAGE.suggestion, + severity=_FALLBACK_MESSAGE.severity, + retryable=_FALLBACK_MESSAGE.retryable, + ) + + +def is_registered(code: str) -> bool: + """检查码点是否已注册 (CI 校验脚本与单元测试用).""" + return code in MESSAGES_ZH_CN + + +def registered_codes() -> Dict[str, Message]: + """返回当前已注册的所有码点 (测试与文档生成用).""" + return dict(MESSAGES_ZH_CN) diff --git a/admin/logging_utils.py b/admin/logging_utils.py new file mode 100644 index 0000000..7a8ba76 --- /dev/null +++ b/admin/logging_utils.py @@ -0,0 +1,484 @@ +"""结构化 JSON 日志 (T9.3). + +职责: +- ``JsonLogFormatter`` : 把 ``LogRecord`` 编码成单行 JSON (含 ctx / exc) +- ``log_event(...)`` : 业务代码一行写结构化事件, 自动走 ctx 字段 +- ``bind_request_context`` : per-request 上下文, 通过 ``contextvars`` 跨异步安全传递 +- ``configure_logging`` : 在 CLI 入口 / 测试夹具里安装 formatter + +设计要点 (详见 docs/plans/T9-error-handling.md §4): +- 零第三方依赖, 仅 stdlib ``logging`` + ``contextvars`` + ``json`` +- 不破坏既有 ``logger.info("msg %s", x)`` 形态, 但鼓励 ``log_event`` 走结构化 +- 单元测试可直接读 ``LogRecord``, 不依赖 formatter 编码 + +schema (顶层):: + + { + "ts": "2026-06-12T16:30:01.234Z", # ISO-8601 UTC with ms + "level": "warning", + "logger": "admin.errors", + "msg": "...", # 渲染后的文本 + "ctx": {...}, # 结构化字段 (per-request 上下文 + log_event fields) + "exc": {"type":..., "message":..., "traceback":...} # 异常时才有 + } +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +import threading +from contextvars import ContextVar, Token +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +# ---------------- per-request 上下文 (ContextVar) ---------------- +# +# 异步安全: contextvars 在 asyncio 任务切换时自动跟随, 不同请求之间不会污染. +# 同步代码同样工作 (Python 3.7+). + +_request_ctx: ContextVar[Dict[str, Any]] = ContextVar("admin_request_ctx", default={}) + + +def bind_request_context(**fields: Any) -> Token[Dict[str, Any]]: + """写入 per-request 上下文, 返回 token 用于 ``clear_request_context``. + + 用法 (FastAPI middleware):: + + @app.middleware("http") + async def ctx_middleware(request, call_next): + token = bind_request_context( + request_id=generate_request_id(), + path=request.url.path, + method=request.method, + ) + try: + return await call_next(request) + finally: + clear_request_context(token) + """ + base = dict(_request_ctx.get()) + base.update(fields) + return _request_ctx.set(base) + + +def clear_request_context(token: Token[Dict[str, Any]]) -> None: + """释放 ``bind_request_context`` 写入的上下文.""" + _request_ctx.reset(token) + + +def current_context() -> Dict[str, Any]: + """读取当前 per-request 上下文的快照 (拷贝).""" + return dict(_request_ctx.get()) + + +# ---------------- JsonLogFormatter ---------------- + + +# LogRecord 默认有 20+ 属性, 不应直接整对象 dict(). +# 安全白名单: 只有这些键允许作为 ctx 字段透传. +_CTX_SAFE_KEYS = frozenset( + { + "code", + "path", + "method", + "request_id", + "user_id", + "status", + "event", + "duration_ms", + "code_count", + "http_status", + "fields", + "function", + } +) + + +# 单条日志最大体积, 超过截断 (防 traceback 巨大撑爆日志). +_MAX_TRACEBACK_CHARS = 4096 +_MAX_LOG_BYTES = 8 * 1024 + + +class JsonLogFormatter(logging.Formatter): + """把 ``LogRecord`` 编码为单行 JSON 字符串. + + 字段顺序 (稳定):: + + ts, level, logger, msg, ctx, exc? + + 设计权衡: + - ``msg`` 字段保留渲染后的纯文本, 兼容 ``grep`` / 旧日志栈. + - ``ctx`` 字段把结构化字段从 ``LogRecord.__dict__`` / ``extra`` 抽出来, + 排障时可以 ``jq '.ctx.code'`` 直接取值. + - ``exc`` 仅在 ``record.exc_info`` 存在时输出, 且对 ``traceback`` 长度设上限. + """ + + def __init__(self, *, ensure_ascii: bool = False) -> None: + super().__init__() + self._ensure_ascii = ensure_ascii + + def format(self, record: logging.LogRecord) -> str: # noqa: A003 - stdlib API + payload: Dict[str, Any] = { + "ts": _format_ts(record.created), + "level": record.levelname.lower(), + "logger": record.name, + "msg": record.getMessage(), + } + + ctx = _extract_ctx(record) + ctx.update(current_context()) + if ctx: + payload["ctx"] = ctx + + if record.exc_info: + payload["exc"] = _format_exc(record) + + text = json.dumps(payload, ensure_ascii=self._ensure_ascii, default=str) + if len(text.encode("utf-8")) <= _MAX_LOG_BYTES: + return text + + compact = _compact_payload(payload) + text = json.dumps(compact, ensure_ascii=self._ensure_ascii, default=str) + if len(text.encode("utf-8")) <= _MAX_LOG_BYTES: + return text + + # 仍然过大时,保底只保留基础字段,避免输出非法 JSON。 + fallback = { + "ts": payload["ts"], + "level": payload["level"], + "logger": payload["logger"], + "msg": _truncate_text(str(payload["msg"]), 512), + "ctx": {"truncated": True}, + } + if "exc" in payload: + fallback["exc"] = { + "type": payload["exc"].get("type", "Unknown"), + "message": _truncate_text(str(payload["exc"].get("message", "")), 256), + "traceback": "...truncated", + "truncated": True, + } + return json.dumps(fallback, ensure_ascii=self._ensure_ascii, default=str) + + +# ---------------- log_event helper ---------------- + + +# LogRecord.__dict__ 已存在的属性 (LogRecord 自带, 与 extra 冲突时优先 builtin). +# 避免 ``extra={"name": ...}`` 把 record.name 覆盖掉. +_RESERVED_LOGRECORD_KEYS = frozenset( + { + "name", + "msg", + "args", + "levelname", + "levelno", + "pathname", + "filename", + "module", + "exc_info", + "exc_text", + "stack_info", + "lineno", + "funcName", + "created", + "msecs", + "relativeCreated", + "thread", + "threadName", + "processName", + "process", + "asctime", + "message", + "taskName", + } +) + + +def log_event( + logger: logging.Logger, + level: int, + event: str, + *, + msg: Optional[str] = None, + **fields: Any, +) -> None: + """写一条结构化事件. ``fields`` 会作为 ``ctx`` 字段进入 JSON 输出. + + Args: + logger: 目标 logger + level: 级别 (e.g. ``logging.WARNING``) + event: 事件名 (e.g. ``"business_error"``), 进入 ``ctx.event`` + msg: 可选人类可读摘要; 缺省用 ``event`` + **fields: 结构化字段 (e.g. ``code="E01101", path="/api/x"``) + + 行为约束: + - ``event`` 必填, 防业务代码写散日志 + - ``fields`` 不可覆盖 LogRecord 保留字段 (会抛 ``ValueError``) + - ``ctx`` 是 dict 字段, 不会与 LogRecord 自带属性冲突 + + 注意: 不会自动捕获 ``sys.exc_info()``. 需要记录异常时, 用 + ``log_event_exc(logger, level, event, exc_info=sys.exc_info(), ...)``, + 或直接 ``logger.exception(...)`` + ``extra={"ctx": {...}}``. + """ + if not event: + raise ValueError("log_event: 'event' is required") + + safe_fields: Dict[str, Any] = {} + for k, v in fields.items(): + if k in _RESERVED_LOGRECORD_KEYS: + raise ValueError( + f"log_event: field name {k!r} collides with LogRecord builtin" + ) + safe_fields[k] = v + safe_fields.setdefault("event", event) + + summary = msg if msg is not None else event + # 强制 ctx 作为独立字段进入 record.__dict__, formatter 提取时优先取它. + safe_fields["ctx"] = safe_fields.get("ctx", {}) + if not isinstance(safe_fields["ctx"], dict): + # 业务误传: 强制转 dict, 防 json.dumps 失败. + safe_fields["ctx"] = {"value": safe_fields["ctx"]} + + logger.log(level, summary, extra={"ctx": safe_fields}) + + +def log_event_exc( + logger: logging.Logger, + level: int, + event: str, + exc_info: Any, + *, + msg: Optional[str] = None, + **fields: Any, +) -> None: + """``log_event`` 的异常版本 — 显式传入 ``exc_info`` (如 ``sys.exc_info()``). + + 用法:: + + try: + ... + except Exception: + log_event_exc(logger, logging.ERROR, "unhandled_exception", + exc_info=sys.exc_info(), + path=request.url.path, method=request.method) + raise + """ + if not event: + raise ValueError("log_event_exc: 'event' is required") + safe_fields: Dict[str, Any] = {} + for k, v in fields.items(): + if k in _RESERVED_LOGRECORD_KEYS: + raise ValueError( + f"log_event_exc: field name {k!r} collides with LogRecord builtin" + ) + safe_fields[k] = v + safe_fields.setdefault("event", event) + safe_fields["ctx"] = safe_fields.get("ctx", {}) + if not isinstance(safe_fields["ctx"], dict): + safe_fields["ctx"] = {"value": safe_fields["ctx"]} + + summary = msg if msg is not None else event + logger.log(level, summary, exc_info=exc_info, extra={"ctx": safe_fields}) + + +# ---------------- configure_logging ---------------- + + +# uvicorn 自带 access / error logger, 我们也接管它们, 避免 plain / json 混用. +_ADMIN_LOGGER_NAMES = ( + "admin", + "admin.errors", + "admin.auth", + "uvicorn", + "uvicorn.error", + "uvicorn.access", +) + + +_configure_lock = threading.Lock() +_configured = False + + +def configure_logging( + level: str = "INFO", + fmt: str = "json", + *, + stream=None, +) -> None: + """为 ``admin.*`` / uvicorn logger 安装 JSON formatter. + + Args: + level: 全局日志级别字符串 (``"DEBUG"`` / ``"INFO"`` / ...). 默认 INFO. + fmt: ``"json"`` (默认) 或 ``"plain"`` (开发友好). + stream: 输出流, 默认 ``sys.stderr``. + + 幂等: 多次调用不会重复安装 handler. + """ + global _configured + with _configure_lock: + if _configured: + return + _configured = True + + if stream is None: + stream = sys.stderr + + handler = logging.StreamHandler(stream) + if fmt == "json": + handler.setFormatter(JsonLogFormatter()) + else: + handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + ) + + root = logging.getLogger() + root.setLevel(getattr(logging, level.upper(), logging.INFO)) + # 清空 uvicorn 0.x 默认配置的 handler, 避免重复输出. + for h in list(root.handlers): + root.removeHandler(h) + root.addHandler(handler) + + # 让 uvicorn 日志走 root, 不再各自打 access log. + for name in _ADMIN_LOGGER_NAMES: + lg = logging.getLogger(name) + lg.handlers.clear() + lg.propagate = True + + +def reset_logging_for_tests() -> None: + """测试夹具: 解开 ``configure_logging`` 的幂等锁. + + 单元测试中如需多次重新配置, 在 ``teardown`` 调用一次. + """ + global _configured + with _configure_lock: + _configured = False + + +def configure_from_env() -> None: + """从环境变量安装配置 (``ADMIN_LOG_FORMAT`` / ``ADMIN_LOG_LEVEL``).""" + fmt = os.environ.get("ADMIN_LOG_FORMAT", "json").lower() + level = os.environ.get("ADMIN_LOG_LEVEL", "INFO") + configure_logging(level=level, fmt=fmt) + + +# ---------------- 内部工具 ---------------- + + +def _format_ts(epoch_seconds: float) -> str: + """epoch → ``2026-06-12T16:30:01.234Z``.""" + dt = datetime.fromtimestamp(epoch_seconds, tz=timezone.utc) + # millisecond 精度, 与 Loki / ELK 默认格式一致. + return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{dt.microsecond // 1000:03d}Z" + + +def _extract_ctx(record: logging.LogRecord) -> Dict[str, Any]: + """从 record 抽 ctx 字段. + + 优先级: + 1. ``record.ctx`` (log_event 写入) + 2. ``record.__dict__`` 中白名单键 (兼容 ``logger.info("...", extra={...})``) + """ + ctx_obj = getattr(record, "ctx", None) + if isinstance(ctx_obj, dict): + return dict(ctx_obj) + + out: Dict[str, Any] = {} + for k in _CTX_SAFE_KEYS: + v = getattr(record, k, None) + if v is not None: + out[k] = v + return out + + +def _format_exc(record: logging.LogRecord) -> Dict[str, Any]: + """序列化异常信息, traceback 超过 4KB 截断.""" + exc_info = record.exc_info + if not exc_info: + # 调用方在 ``record.exc_info`` 存在的前提下调用, 这里只是防御. + return {"type": "Unknown", "message": "", "traceback": ""} + exc_type, exc_value, _tb = exc_info + tb_text = "" + if record.exc_text: + tb_text = record.exc_text + else: + import traceback as _tb_mod + + tb_text = "".join(_tb_mod.format_exception(exc_type, exc_value, _tb)) + truncated = False + if len(tb_text) > _MAX_TRACEBACK_CHARS: + tb_text = tb_text[:_MAX_TRACEBACK_CHARS] + "\n...truncated" + truncated = True + payload: Dict[str, Any] = { + "type": exc_type.__name__ if exc_type else "Unknown", + "message": str(exc_value) if exc_value is not None else "", + "traceback": tb_text, + } + if truncated: + payload["truncated"] = True + return payload + + +def _compact_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + """压缩过大的日志 payload, 保持 JSON 可解析。""" + compact = dict(payload) + compact["msg"] = _truncate_text(str(compact.get("msg", "")), 512) + ctx = compact.get("ctx") + if isinstance(ctx, dict): + compact_ctx: Dict[str, Any] = {} + for key, value in ctx.items(): + compact_ctx[key] = _compact_value(value) + compact_ctx["truncated"] = True + compact["ctx"] = compact_ctx + else: + compact["ctx"] = {"truncated": True} + + exc = compact.get("exc") + if isinstance(exc, dict): + compact_exc = dict(exc) + compact_exc["message"] = _truncate_text( + str(compact_exc.get("message", "")), 256 + ) + compact_exc["traceback"] = _truncate_text( + str(compact_exc.get("traceback", "")), _MAX_TRACEBACK_CHARS + ) + compact_exc["truncated"] = True + compact["exc"] = compact_exc + + return compact + + +def _compact_value(value: Any) -> Any: + if isinstance(value, str): + return _truncate_text(value, 256) + if isinstance(value, (int, float, bool)) or value is None: + return value + rendered = json.dumps(value, ensure_ascii=False, default=str) + return _truncate_text(rendered, 256) + + +def _truncate_text(text: str, limit: int) -> str: + if len(text) <= limit: + return text + if limit <= 12: + return text[:limit] + return text[: limit - 12] + "...truncated" + + +# ---------------- 内部常量 (给测试 / admin.app 复用) ---------------- + + +__all__ = [ + "JsonLogFormatter", + "bind_request_context", + "clear_request_context", + "configure_from_env", + "configure_logging", + "current_context", + "log_event", + "log_event_exc", + "reset_logging_for_tests", +] diff --git a/admin/password.py b/admin/password.py new file mode 100644 index 0000000..9d6df3e --- /dev/null +++ b/admin/password.py @@ -0,0 +1,71 @@ +"""密码哈希 (T6.1). + +使用 PBKDF2-HMAC-SHA256(200k 迭代)。salt 16B hex;hash 64B hex。 +不依赖 bcrypt/argon2 等第三方库,保持项目"零运行时第三方"约束。 + +存储格式: "$"(便于单字段持久化)。 +""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets + +_PBKDF2_ALGO = "sha256" +_PBKDF2_ITERATIONS = 200_000 +_SALT_BYTES = 16 +_HASH_BYTES = 32 +_STORED_SEPARATOR = "$" + + +def hash_password(plain: str) -> str: + """对明文密码进行 PBKDF2 哈希。 + + Args: + plain: 明文密码 + + Returns: + 形如 "$" 的字符串,可直接入库 + """ + if not plain: + raise ValueError("password cannot be empty") + salt = secrets.token_bytes(_SALT_BYTES) + digest = hashlib.pbkdf2_hmac( + _PBKDF2_ALGO, + plain.encode("utf-8"), + salt, + _PBKDF2_ITERATIONS, + dklen=_HASH_BYTES, + ) + return f"{salt.hex()}{_STORED_SEPARATOR}{digest.hex()}" + + +def verify_password(plain: str, stored: str) -> bool: + """校验明文密码与存储哈希是否匹配。 + + 使用恒定时间比较避免时序攻击。 + + Args: + plain: 明文密码 + stored: 数据库存储的 "$" 字符串 + + Returns: + True 表示匹配 + """ + if not plain or not stored or _STORED_SEPARATOR not in stored: + return False + salt_hex, hash_hex = stored.split(_STORED_SEPARATOR, 1) + try: + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(hash_hex) + except ValueError: + return False + candidate = hashlib.pbkdf2_hmac( + _PBKDF2_ALGO, + plain.encode("utf-8"), + salt, + _PBKDF2_ITERATIONS, + dklen=_HASH_BYTES, + ) + return hmac.compare_digest(candidate, expected) diff --git a/admin/routes/__init__.py b/admin/routes/__init__.py new file mode 100644 index 0000000..c464171 --- /dev/null +++ b/admin/routes/__init__.py @@ -0,0 +1,21 @@ +"""路由模块 (T6.1).""" + +from admin.routes.auth import router as auth_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.orders import router as orders_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 + +__all__ = [ + "auth_router", + "cases_router", + "health_router", + "meta_router", + "orders_router", + "stats_router", + "ui_router", + "users_router", +] diff --git a/admin/routes/auth.py b/admin/routes/auth.py new file mode 100644 index 0000000..1f7df96 --- /dev/null +++ b/admin/routes/auth.py @@ -0,0 +1,141 @@ +"""认证路由 (T6.1). + +- POST /api/auth/login : 用户名 + 密码 → JWT +- GET /api/auth/me : 当前用户信息 +""" + +from __future__ import annotations + +import threading +import time +from collections import deque +from typing import Optional + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field + +from admin.auth import encode_token, get_current_user +from admin.config import Settings, get_settings_dep +from admin.db import AdminUser, AdminUserRepo, authenticate +from admin.errors import ( + AUTH_ACCOUNT_DISABLED, + AUTH_INVALID_CREDENTIALS, + BIZ_RATE_LIMITED, + BusinessError, +) + + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +_LOGIN_FAILURE_LOCK = threading.Lock() +_LOGIN_FAILURE_BUCKETS: dict[str, deque[float]] = {} +_LOGIN_FAILURE_LIMIT = 5 +_LOGIN_FAILURE_WINDOW_SECONDS = 300.0 + + +class LoginRequest(BaseModel): + username: str = Field(..., min_length=1, max_length=64) + password: str = Field(..., min_length=1, max_length=256) + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int # 秒 + + +class UserPublic(BaseModel): + id: int + username: str + role: str + is_active: bool + created_at: str + last_login_at: Optional[str] = None + + +def _login_rate_limit_key(request: Request, username: str) -> str: + client_host = request.client.host if request.client else "unknown" + return f"{username.lower()}@{client_host}" + + +def _prune_failures(bucket: deque[float], *, now: float) -> None: + cutoff = now - _LOGIN_FAILURE_WINDOW_SECONDS + while bucket and bucket[0] < cutoff: + bucket.popleft() + + +def _login_retry_after_seconds(key: str, *, now: float | None = None) -> int | None: + if now is None: + now = time.time() + with _LOGIN_FAILURE_LOCK: + bucket = _LOGIN_FAILURE_BUCKETS.get(key) + if not bucket: + return None + _prune_failures(bucket, now=now) + if len(bucket) < _LOGIN_FAILURE_LIMIT: + if not bucket: + _LOGIN_FAILURE_BUCKETS.pop(key, None) + return None + retry_after = int(_LOGIN_FAILURE_WINDOW_SECONDS - (now - bucket[0])) + return max(1, retry_after) + + +def _record_login_failure(key: str, *, now: float | None = None) -> None: + if now is None: + now = time.time() + with _LOGIN_FAILURE_LOCK: + bucket = _LOGIN_FAILURE_BUCKETS.setdefault(key, deque()) + _prune_failures(bucket, now=now) + bucket.append(now) + + +def _clear_login_failures(key: str) -> None: + with _LOGIN_FAILURE_LOCK: + _LOGIN_FAILURE_BUCKETS.pop(key, None) + + +def reset_login_rate_limit_for_tests() -> None: + with _LOGIN_FAILURE_LOCK: + _LOGIN_FAILURE_BUCKETS.clear() + + +@router.post("/login", response_model=LoginResponse, summary="登录") +def login( + request: Request, + body: LoginRequest, + settings: Settings = Depends(get_settings_dep), +) -> LoginResponse: + rate_limit_key = _login_rate_limit_key(request, body.username) + retry_after = _login_retry_after_seconds(rate_limit_key) + if retry_after is not None: + raise BusinessError( + BIZ_RATE_LIMITED, + detail={"retry_after_seconds": retry_after}, + http_status=429, + ) + repo = AdminUserRepo(settings.db_path) + user = authenticate(repo, body.username, body.password) + if user is None: + _record_login_failure(rate_limit_key) + retry_after = _login_retry_after_seconds(rate_limit_key) + if retry_after is not None: + raise BusinessError( + BIZ_RATE_LIMITED, + detail={"retry_after_seconds": retry_after}, + http_status=429, + ) + # 401 不区分用户名/密码错误,避免账户枚举 + raise BusinessError(AUTH_INVALID_CREDENTIALS) + if not user.is_active: + raise BusinessError(AUTH_ACCOUNT_DISABLED, detail={"user_id": user.id}) + _clear_login_failures(rate_limit_key) + token = encode_token(user, settings) + return LoginResponse( + access_token=token, + expires_in=settings.jwt_expire_minutes * 60, + ) + + +@router.get("/me", response_model=UserPublic, summary="当前用户") +def me(user: AdminUser = Depends(get_current_user)) -> UserPublic: + return UserPublic(**user.to_public_dict()) diff --git a/admin/routes/cases.py b/admin/routes/cases.py new file mode 100644 index 0000000..b8c278d --- /dev/null +++ b/admin/routes/cases.py @@ -0,0 +1,203 @@ +"""案例管理路由 (T6.5).""" + +from __future__ import annotations + +from dataclasses import asdict +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.config import Settings, get_settings_dep +from admin.db import AdminUser, utc_now_iso +from admin.errors import DATA_NOT_FOUND +from admin.errors.exceptions import BusinessError +from data.cases.dao import CaseNotFound, CasesDAO +from data.cases.models import CaseRecord + +router = APIRouter(prefix="/api/cases", tags=["cases"]) + +CaseCategory = Literal["success", "typical", "warning"] +CaseReviewStatus = Literal["pending", "approved", "rejected"] + + +class CaseBasePayload(BaseModel): + title: str = Field(min_length=1) + category: CaseCategory + summary: Optional[str] = None + content: Optional[str] = None + tags: list[str] = Field(default_factory=list) + + +class CaseReviewResponse(BaseModel): + review_status: CaseReviewStatus + review_note: Optional[str] = None + reviewer: Optional[str] = None + reviewed_at: Optional[str] = None + + +class CaseDetailResponse(CaseBasePayload, CaseReviewResponse): + id: int + created_at: Optional[str] = None + updated_at: Optional[str] = None + + +class CaseListResponse(BaseModel): + total: int + limit: int + offset: int + items: list[CaseDetailResponse] + + +class ReviewCaseRequest(BaseModel): + review_status: Literal["approved", "rejected"] + review_note: Optional[str] = None + + +def _not_found(case_id: int) -> BusinessError: + return BusinessError(DATA_NOT_FOUND, detail={"case_id": case_id}) + + +def _to_payload(record: CaseRecord) -> dict[str, Any]: + return asdict(record) + + +@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), + category: Optional[CaseCategory] = Query(None), + review_status: Optional[CaseReviewStatus] = Query(None), + settings: Settings = Depends(get_settings_dep), + _: AdminUser = Depends(get_current_user), +) -> dict[str, Any]: + with CasesDAO.connect(settings.db_path) as dao: + items, total = dao.list( + category=category, + review_status=review_status, + limit=limit, + offset=offset, + ) + return { + "total": total, + "limit": limit, + "offset": offset, + "items": [_to_payload(item) for item in items], + } + + +@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), + _: AdminUser = Depends(get_current_user), +) -> dict[str, Any]: + record = CaseRecord( + id=0, + title=payload.title, + category=payload.category, + summary=payload.summary, + content=payload.content, + tags=payload.tags, + ) + with CasesDAO.connect(settings.db_path) as dao: + created = dao.create(record) + return _to_payload(created) + + +@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), + _: AdminUser = Depends(get_current_user), +) -> dict[str, Any]: + with CasesDAO.connect(settings.db_path) as dao: + try: + record = dao.get(case_id) + except CaseNotFound as exc: + raise _not_found(case_id) from exc + return _to_payload(record) + + +@router.patch( + "/{case_id}", + response_model=CaseDetailResponse, + summary="更新案例(T6.5)", +) +def update_case( + payload: CaseBasePayload, + case_id: int = Path(..., ge=1), + settings: Settings = Depends(get_settings_dep), + _: AdminUser = Depends(get_current_user), +) -> dict[str, Any]: + with CasesDAO.connect(settings.db_path) as dao: + try: + updated = dao.update( + case_id, + updates={ + "title": payload.title, + "category": payload.category, + "summary": payload.summary, + "content": payload.content, + "tags": payload.tags, + }, + ) + except CaseNotFound as exc: + raise _not_found(case_id) from exc + return _to_payload(updated) + + +@router.post( + "/{case_id}/review", + response_model=CaseDetailResponse, + summary="审核案例(T6.5)", +) +def review_case( + payload: ReviewCaseRequest, + case_id: int = Path(..., ge=1), + settings: Settings = Depends(get_settings_dep), + current_user: AdminUser = Depends(get_current_user), +) -> dict[str, Any]: + with CasesDAO.connect(settings.db_path) as dao: + try: + reviewed = dao.update( + case_id, + updates={ + "review_status": payload.review_status, + "review_note": payload.review_note, + "reviewer": current_user.username, + "reviewed_at": utc_now_iso(), + }, + ) + except CaseNotFound as exc: + raise _not_found(case_id) from exc + return _to_payload(reviewed) + + +@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), + _: AdminUser = Depends(get_current_user), +) -> Response: + with CasesDAO.connect(settings.db_path) as dao: + try: + dao.delete(case_id) + except CaseNotFound as exc: + raise _not_found(case_id) from exc + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/admin/routes/health.py b/admin/routes/health.py new file mode 100644 index 0000000..fb38975 --- /dev/null +++ b/admin/routes/health.py @@ -0,0 +1,25 @@ +"""健康检查路由 (T6.1).""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from admin.config import Settings, get_settings_dep + + +router = APIRouter(tags=["health"]) + + +@router.get("/health", summary="健康检查") +def health(settings: Settings = Depends(get_settings_dep)) -> dict: + """公开端点。返回服务状态 + DB 路径 + 环境。 + + 不依赖数据库查询,只验证服务进程可响应。 + """ + return { + "status": "ok", + "env": settings.env, + "db_path": settings.db_path, + "service": "gaokao-admin", + "version": "0.1.0", + } diff --git a/admin/routes/meta.py b/admin/routes/meta.py new file mode 100644 index 0000000..4807f8b --- /dev/null +++ b/admin/routes/meta.py @@ -0,0 +1,69 @@ +"""元数据路由 (T6.1). + +提供客户端需要的固定枚举值,避免前端硬编码。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from admin.auth import get_current_user +from admin.db import AdminUser + + +router = APIRouter(prefix="/api/meta", tags=["meta"]) + + +# MVP 阶段仅返回 27 省中已实现规范检查器的省份 +SUPPORTED_PROVINCES = [ + "湖南", + "广东", + "湖北", + "安徽", + "江西", + "甘肃", + "黑龙江", + "江苏", + "福建", + "广西", + "北京", + "上海", + "天津", + "海南", + "浙江", + "山东", + "河北", + "重庆", + "辽宁", + "贵州", + "青海", + "吉林", + "河南", + "四川", + "新疆", + "云南", + "西藏", +] + +ORDER_STATUSES = [ + "pending", + "paid", + "serving", + "delivered", + "completed", + "refunded", +] + +ORDER_SOURCES = ["xianyu", "wechat", "web", "school"] + +SERVICE_VERSIONS = ["audit", "basic", "standard", "premium"] + + +@router.get("", summary="元数据枚举") +def meta(_: AdminUser = Depends(get_current_user)) -> dict: + return { + "supported_provinces": SUPPORTED_PROVINCES, + "order_statuses": ORDER_STATUSES, + "order_sources": ORDER_SOURCES, + "service_versions": SERVICE_VERSIONS, + } diff --git a/admin/routes/orders.py b/admin/routes/orders.py new file mode 100644 index 0000000..82d2748 --- /dev/null +++ b/admin/routes/orders.py @@ -0,0 +1,409 @@ +"""订单管理路由 (T6.4). + +范围: +- GET /api/orders 订单列表(真实 DAO + 脱敏) +- GET /api/orders/export CSV 导出(默认脱敏) +- GET /api/orders/{id} 订单详情 + 状态历史 +- POST /api/orders 手工录单 +- PATCH /api/orders/{id} 业务字段更新 / 状态流转 / 退款 +""" + +from __future__ import annotations + +import csv +from dataclasses import asdict +from io import StringIO +from typing import Any, Literal, Optional, cast + +from fastapi import APIRouter, Depends, Path, Query, Response, status +from pydantic import BaseModel, Field + +from admin.auth import get_current_user +from admin.config import Settings, get_settings_dep +from admin.db import AdminUser +from admin.errors import ( + BIZ_ORDER_INVALID_STATUS, + BIZ_ORDER_NOT_FOUND, + DATA_PERSIST_FAILED, + DATA_VALIDATION_FAILED, +) +from admin.errors.exceptions import BusinessError +from data.orders.dao import DuplicateOrder, OrderNotFound, OrdersDAO +from data.orders.models import Order, generate_order_id +from data.orders.state_machine import InvalidStateTransition, next_states + + +router = APIRouter(prefix="/api/orders", tags=["orders"]) + +OrderSource = Literal["xianyu", "wechat", "web", "school"] +ServiceVersion = Literal["audit", "basic", "standard", "premium"] +OrderStatus = Literal[ + "pending", "paid", "serving", "delivered", "completed", "refunded" +] + +_SUMMARY_FIELDS = ( + "id", + "source", + "external_id", + "service_version", + "status", + "amount_cents", + "customer_name", + "customer_phone", + "customer_wechat", + "candidate_name", + "candidate_province", + "assigned_consultant", + "created_at", + "status_updated_at", + "tags", +) +_EXPORT_FIELDS = ( + "id", + "source", + "external_id", + "service_version", + "status", + "amount_cents", + "customer_name", + "customer_phone", + "customer_wechat", + "candidate_name", + "candidate_id_card", + "candidate_province", + "candidate_score", + "assigned_consultant", + "created_at", + "status_updated_at", + "notes", +) +_ALLOWED_UPDATE_FIELDS = { + "external_id", + "service_version", + "amount_cents", + "customer_name", + "customer_wechat", + "candidate_name", + "candidate_province", + "candidate_score", + "candidate_rank", + "candidate_subjects", + "candidate_interests", + "candidate_strong_subjects", + "candidate_weak_subjects", + "candidate_family", + "assigned_consultant", + "plan_file", + "audit_report", + "pdf_path", + "notes", + "tags", +} +_CSV_FORMULA_PREFIXES = ("=", "+", "-", "@") + + +class OrderSummaryResponse(BaseModel): + id: str + source: str + external_id: Optional[str] = None + service_version: str + status: str + amount_cents: int + customer_name: Optional[str] = None + customer_phone: Optional[str] = None + customer_wechat: Optional[str] = None + candidate_name: Optional[str] = None + candidate_province: Optional[str] = None + assigned_consultant: Optional[str] = None + created_at: Optional[str] = None + status_updated_at: Optional[str] = None + tags: list[str] = Field(default_factory=list) + + +class OrderHistoryItem(BaseModel): + id: int + order_id: str + from_status: Optional[str] = None + to_status: str + actor: Optional[str] = None + reason: Optional[str] = None + changed_at: str + + +class OrderDetailPayload(BaseModel): + order: dict[str, Any] + history: list[OrderHistoryItem] + available_next_statuses: list[str] + + +class OrderMutationResponse(OrderDetailPayload): + action: str + + +class CreateOrderRequest(BaseModel): + source: OrderSource + external_id: Optional[str] = None + service_version: ServiceVersion + amount_cents: int = Field(ge=0) + customer_name: Optional[str] = None + customer_phone: Optional[str] = None + customer_wechat: Optional[str] = None + candidate_name: Optional[str] = None + candidate_id_card: Optional[str] = None + candidate_province: Optional[str] = None + candidate_score: Optional[int] = None + candidate_rank: Optional[int] = None + candidate_subjects: list[str] = Field(default_factory=list) + candidate_interests: Optional[str] = None + candidate_strong_subjects: Optional[str] = None + candidate_weak_subjects: Optional[str] = None + candidate_family: Optional[str] = None + assigned_consultant: Optional[str] = None + notes: Optional[str] = None + tags: list[str] = Field(default_factory=list) + + +class UpdateOrderRequest(BaseModel): + updates: Optional[dict[str, Any]] = None + to_status: Optional[OrderStatus] = None + reason: Optional[str] = None + + +def _mask_order(order: Order) -> dict[str, Any]: + masked = order.to_dict(decrypt_sensitive="mask") + masked.pop("customer_phone_hash", None) + return masked + + +def _summary(order: Order) -> dict[str, Any]: + masked = _mask_order(order) + return {field: masked.get(field) for field in _SUMMARY_FIELDS} + + +def _csv_safe_value(value: Any) -> Any: + if isinstance(value, str) and value.startswith(_CSV_FORMULA_PREFIXES): + return f"'{value}" + return value + + +def _export_row(order: Order) -> dict[str, Any]: + masked = _mask_order(order) + return {field: _csv_safe_value(masked.get(field)) for field in _EXPORT_FIELDS} + + +def _history_payload(dao: OrdersDAO, order_id: str) -> list[dict[str, Any]]: + return [asdict(item) for item in dao.get_status_history(order_id)] + + +def _detail_payload(dao: OrdersDAO, order: Order) -> dict[str, Any]: + return { + "order": _mask_order(order), + "history": _history_payload(dao, order.id), + "available_next_statuses": sorted(next_states(order.status)), + } + + +def _normalize_updates(updates: Optional[dict[str, Any]]) -> dict[str, Any]: + if not updates: + return {} + bad = sorted(set(updates) - _ALLOWED_UPDATE_FIELDS) + if bad: + raise BusinessError( + DATA_VALIDATION_FAILED, + detail={"invalid_update_fields": bad}, + ) + return dict(updates) + + +def _business_error_for_lookup(order_id: str) -> BusinessError: + return BusinessError(BIZ_ORDER_NOT_FOUND, detail={"order_id": order_id}) + + +def _transition_error(order_id: str, to_status: str, exc: Exception) -> BusinessError: + return BusinessError( + BIZ_ORDER_INVALID_STATUS, + detail={"order_id": order_id, "to_status": to_status, "reason": str(exc)}, + ) + + +@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), + status_filter: Optional[OrderStatus] = Query(None, alias="status"), + source: Optional[OrderSource] = Query(None), + settings: Settings = Depends(get_settings_dep), + _: AdminUser = Depends(get_current_user), +) -> list[dict[str, Any]]: + with OrdersDAO.connect(settings.orders_db_path) as dao: + orders = dao.list( + status=status_filter, source=source, limit=limit, offset=offset + ) + return [_summary(order) for order in orders] + + +@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"), + source: Optional[OrderSource] = Query(None), + settings: Settings = Depends(get_settings_dep), + _: AdminUser = Depends(get_current_user), +) -> Response: + with OrdersDAO.connect(settings.orders_db_path) as dao: + orders = dao.list(status=status_filter, source=source, limit=limit, offset=0) + + output = StringIO() + writer = csv.DictWriter(output, fieldnames=list(_EXPORT_FIELDS)) + writer.writeheader() + for order in orders: + writer.writerow(cast(Any, _export_row(order))) + + filename = "orders_export.csv" + return Response( + content=output.getvalue(), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@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), + _: AdminUser = Depends(get_current_user), +) -> dict[str, Any]: + with OrdersDAO.connect(settings.orders_db_path) as dao: + try: + order = dao.get(order_id) + except OrderNotFound as exc: + raise _business_error_for_lookup(order_id) from exc + return _detail_payload(dao, order) + + +@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), + current_user: AdminUser = Depends(get_current_user), +) -> dict[str, Any]: + order = Order( + id=generate_order_id(), + source=payload.source, + external_id=payload.external_id, + service_version=payload.service_version, + amount_cents=payload.amount_cents, + status="pending", + customer_name=payload.customer_name, + customer_phone=payload.customer_phone, + customer_wechat=payload.customer_wechat, + candidate_name=payload.candidate_name, + candidate_id_card=payload.candidate_id_card, + candidate_province=payload.candidate_province, + candidate_score=payload.candidate_score, + candidate_rank=payload.candidate_rank, + candidate_subjects=payload.candidate_subjects, + candidate_interests=payload.candidate_interests, + candidate_strong_subjects=payload.candidate_strong_subjects, + candidate_weak_subjects=payload.candidate_weak_subjects, + candidate_family=payload.candidate_family, + assigned_consultant=payload.assigned_consultant, + notes=payload.notes, + tags=payload.tags, + ) + with OrdersDAO.connect(settings.orders_db_path) as dao: + try: + created = dao.create( + order, actor=current_user.username, reason="admin_create" + ) + except DuplicateOrder as exc: + raise BusinessError( + DATA_VALIDATION_FAILED, + detail={"reason": str(exc)}, + ) from exc + except Exception as exc: # pragma: no cover - 持久层兜底 + raise BusinessError(DATA_PERSIST_FAILED) from exc + return {"action": "created", **_detail_payload(dao, created)} + + +@router.patch( + "/{order_id}", + response_model=OrderMutationResponse, + summary="订单更新 / 状态流转 / 退款(T6.4)", +) +def patch_order( + payload: UpdateOrderRequest, + order_id: str = Path(..., min_length=1), + settings: Settings = Depends(get_settings_dep), + current_user: AdminUser = Depends(get_current_user), +) -> dict[str, Any]: + updates = _normalize_updates(payload.updates) + if not updates and payload.to_status is None: + raise BusinessError( + DATA_VALIDATION_FAILED, + detail={"reason": "至少提供 updates 或 to_status 之一"}, + ) + + with OrdersDAO.connect(settings.orders_db_path) as dao: + try: + current = dao.get(order_id) + except OrderNotFound as exc: + raise _business_error_for_lookup(order_id) from exc + + order = current + if updates: + try: + order = dao.update( + order_id, + updates, + actor=current_user.username, + reason=payload.reason or "admin_update", + ) + except OrderNotFound as exc: + raise _business_error_for_lookup(order_id) from exc + except DuplicateOrder as exc: + raise BusinessError( + DATA_VALIDATION_FAILED, + detail={"reason": str(exc)}, + ) from exc + except ValueError as exc: + raise BusinessError( + DATA_VALIDATION_FAILED, + detail={"reason": str(exc)}, + ) from exc + + if payload.to_status is not None: + try: + order = dao.transition_status( + order_id, + payload.to_status, + actor=current_user.username, + reason=payload.reason or f"admin_transition:{payload.to_status}", + ) + except OrderNotFound as exc: + raise _business_error_for_lookup(order_id) from exc + except InvalidStateTransition as exc: + raise _transition_error(order_id, payload.to_status, exc) from exc + + action = "updated" + if payload.to_status is not None and not updates: + action = f"transitioned:{payload.to_status}" + elif payload.to_status is not None: + action = f"updated:{payload.to_status}" + return {"action": action, **_detail_payload(dao, order)} diff --git a/admin/routes/stats.py b/admin/routes/stats.py new file mode 100644 index 0000000..48376df --- /dev/null +++ b/admin/routes/stats.py @@ -0,0 +1,98 @@ +"""统计路由 (T6.1 → T6.2 接入真实聚合). + +T6.2 接入模块 +------------- + +- ``GET /api/stats/dashboard`` : 仪表盘一站式 payload + (订单/用户/收入 汇总 + 6 态/来源/版本 分布 + 今日/7d/30d 趋势) +- ``GET /api/stats/orders`` : 订单维度统计 (沿用 T6.1 stub 字段名, + 移除 ``_stub`` 标记,接入真实 SQL 聚合) + +所有路由均要求 JWT 鉴权;鉴权与权限边界与 T6.1 一致。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel + +from admin.auth import get_current_user +from admin.config import Settings, get_settings_dep +from admin.db import AdminUser +from admin.stats import ( + build_dashboard_payload, + build_order_stats_payload, +) + + +router = APIRouter(prefix="/api/stats", tags=["stats"]) + + +class DashboardResponse(BaseModel): + """``/api/stats/dashboard`` 响应契约。""" + + summary: dict + by_status: dict + by_source: dict + by_service_version: dict + trends: dict + generated_at: str + + +class OrderStatsResponse(BaseModel): + """``/api/stats/orders`` 响应契约 (沿用 T6.1 字段名,仅替换数据源)。""" + + total_orders: int + total_revenue_cents: int + by_status: dict + by_source: dict + by_service_version: dict + + +@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), + _: AdminUser = Depends(get_current_user), +) -> dict: + """仪表盘端点 — T6.2 真实聚合。 + + 实现位置在 :mod:`admin.stats` 纯函数层,与路由层解耦: + - 业务测试可单独覆盖 SQL 聚合正确性 + - 路由层只负责鉴权 + 响应包装 + + 数据源: + - ``orders`` 表 → 走 ``settings.orders_db_path`` (与 data.orders.* 共享) + - ``admin_users`` 表 → 走 ``settings.db_path`` (管理后台) + """ + return build_dashboard_payload( + orders_db_path=settings.orders_db_path, + admin_db_path=settings.db_path, + ) + + +@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), + _: AdminUser = Depends(get_current_user), +) -> dict: + """订单维度统计端点 — T6.2 真实聚合。""" + return build_order_stats_payload(settings.orders_db_path) diff --git a/admin/routes/ui.py b/admin/routes/ui.py new file mode 100644 index 0000000..3bf00dd --- /dev/null +++ b/admin/routes/ui.py @@ -0,0 +1,66 @@ +"""轻量管理后台页面路由。""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable, Optional, cast + +from fastapi import APIRouter, Query, Request +from fastapi.responses import FileResponse, HTMLResponse + +from admin.config import Settings, get_settings_dep +from admin.share_page import ( + load_report_from_directory, + render_share_page, + status_code_for_result, +) +from data.share.short_link import route_short_link_with_report + + +router = APIRouter(tags=["ui"]) + +_STATIC_DIR = Path(__file__).resolve().parent.parent / "static" +_DASHBOARD_HTML = _STATIC_DIR / "dashboard.html" + + +@router.get("/dashboard", include_in_schema=False) +def dashboard_page() -> FileResponse: + """返回最小仪表盘页面壳。""" + return FileResponse(_DASHBOARD_HTML) + + +@router.get("/s/{code}", include_in_schema=False) +def share_page( + code: str, + request: Request, + pwd: Optional[str] = Query(default=None), +) -> HTMLResponse: + """公开分享页(T7.5)。""" + settings = get_settings_dep(request) + report_loader = _resolve_report_loader(request, settings) + result = route_short_link_with_report( + code, + password=pwd, + base_url=str(request.base_url).rstrip("/"), + db_path=Path(settings.share_db_path), + report_loader=report_loader, + ) + html = render_share_page(result, password=pwd) + return HTMLResponse(html, status_code=status_code_for_result(result)) + + +def _resolve_report_loader( + request: Request, settings: Settings +) -> Optional[Callable[[str], Optional[dict[str, Any]]]]: + custom_loader = getattr(request.app.state, "share_report_loader", None) + if callable(custom_loader): + return cast(Callable[[str], Optional[dict[str, Any]]], custom_loader) + + report_dir = settings.share_report_dir + if not report_dir: + return None + + def _loader(report_id: str) -> Optional[dict]: + return load_report_from_directory(report_id, report_dir) + + return _loader diff --git a/admin/routes/users.py b/admin/routes/users.py new file mode 100644 index 0000000..185d04f --- /dev/null +++ b/admin/routes/users.py @@ -0,0 +1,82 @@ +"""用户管理路由 (T6.3). + +当前范围: +- GET /api/admin/users 用户列表(聚合 + 搜索 + 脱敏) +- GET /api/admin/users/{user_key} 用户详情(含订单明细,默认脱敏) +""" + +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Path, Query +from pydantic import BaseModel + +from admin.auth import get_current_user +from admin.config import Settings, get_settings_dep +from admin.db import AdminUser +from admin.errors import DATA_NOT_FOUND +from admin.errors.exceptions import BusinessError +from admin.users import build_user_detail_payload, build_user_list_payload + + +router = APIRouter(prefix="/api/admin/users", tags=["users"]) + + +class UserSummaryResponse(BaseModel): + user_key: str + customer_name: Optional[str] = None + customer_phone: Optional[str] = None + customer_wechat: Optional[str] = None + candidate_name: Optional[str] = None + candidate_province: Optional[str] = None + order_count: int + total_amount_cents: int + latest_order_at: Optional[str] = None + latest_status: Optional[str] = None + + +class UserListResponse(BaseModel): + total: int + limit: int + offset: int + query: Optional[str] = None + items: list[UserSummaryResponse] + + +class UserDetailResponse(UserSummaryResponse): + orders: list[dict[str, Any]] + + +@router.get("", response_model=UserListResponse, summary="用户列表(T6.3)") +def list_users( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + q: Optional[str] = Query( + None, min_length=1, description="姓名/手机号/微信/订单号搜索" + ), + settings: Settings = Depends(get_settings_dep), + _: AdminUser = Depends(get_current_user), +) -> dict[str, Any]: + return build_user_list_payload( + settings.orders_db_path, + query=q, + limit=limit, + offset=offset, + ) + + +@router.get( + "/{user_key}", + response_model=UserDetailResponse, + summary="用户详情(T6.3)", +) +def get_user_detail( + user_key: str = Path(..., min_length=1), + settings: Settings = Depends(get_settings_dep), + _: AdminUser = Depends(get_current_user), +) -> dict[str, Any]: + try: + return build_user_detail_payload(settings.orders_db_path, user_key) + except LookupError as exc: + raise BusinessError(DATA_NOT_FOUND, detail={"user_key": user_key}) from exc diff --git a/admin/share_page.py b/admin/share_page.py new file mode 100644 index 0000000..f0d58ef --- /dev/null +++ b/admin/share_page.py @@ -0,0 +1,351 @@ +"""T7.5 分享页 WebUI 纯函数与默认文件加载器。""" + +from __future__ import annotations + +import json +import re +from html import escape +from pathlib import Path +from typing import Any, Iterable, Optional + +from data.share.short_link import ( + STATUS_EXPIRED, + STATUS_NOT_FOUND, + STATUS_PASSWORD_REQUIRED, + STATUS_PASSWORD_WRONG, + STATUS_REVOKED, +) + +_STATUS_META = { + STATUS_NOT_FOUND: (404, "分享链接不存在", "请确认链接是否完整,或联系龙老师重新生成。"), + STATUS_REVOKED: (410, "分享已撤销", "该分享链接已被撤销,无法继续访问。"), + STATUS_EXPIRED: (410, "分享已过期", "该分享链接已经过期,请联系龙老师重新分享。"), + STATUS_PASSWORD_REQUIRED: (401, "需要访问密码", "该分享页已加密,请输入访问密码继续查看。"), + STATUS_PASSWORD_WRONG: (401, "密码错误", "密码不正确,请重新输入。"), +} + + +def load_report_from_directory(report_id: str, report_dir: str | Path) -> Optional[dict[str, Any]]: + """从目录加载 ``{report_id}.json`` 报告。不存在或格式错误时返回 None。""" + if not report_id: + return None + if re.fullmatch(r"[A-Za-z0-9._-]+", report_id) is None: + return None + if report_id.startswith(".") or ".." in report_id: + return None + base_dir = Path(report_dir).resolve() + path = (base_dir / f"{report_id}.json").resolve() + if base_dir not in path.parents: + return None + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def status_code_for_result(result: dict[str, Any]) -> int: + status = str(result.get("status") or "") + if status == "ok": + return 200 + return _STATUS_META.get(status, (400, "访问失败", "分享页当前不可用。"))[0] + + +def render_share_page(result: dict[str, Any], *, password: str | None = None) -> str: + status = str(result.get("status") or "") + if status != "ok": + return _render_status_page(status, code=str(result.get("code") or ""), password=password) + return _render_ok_page(result) + + +def _render_status_page(status: str, *, code: str, password: str | None) -> str: + _, title, message = _STATUS_META.get(status, (400, "访问失败", "分享页当前不可用。")) + error_block = "" + form_block = "" + if status == STATUS_PASSWORD_WRONG: + error_block = '

密码错误,请重新输入。

' + if status in {STATUS_PASSWORD_REQUIRED, STATUS_PASSWORD_WRONG}: + form_block = f""" +
+ + + +
+ """ + return _page_shell( + title, + f""" +
+ 分享访问 +

{escape(title)}

+

{escape(message)}

+ {error_block} + {form_block} +
+ """, + ) + + +def _render_ok_page(result: dict[str, Any]) -> str: + rendered = result.get("rendered") or {} + payload = rendered.get("payload") or {} + policy = rendered.get("policy") or {} + permission = str(rendered.get("permission") or payload.get("permission") or "read") + + title = _pick_first(payload, "title") or "高考志愿填报方案" + summary = _pick_first(payload, "summary") or "微信内打开也能直接查看,支持手机端快速浏览。" + candidate_name = _pick_first(payload, "candidate_name", "customer_name", "student_name", "name") + score = payload.get("score") + rank = payload.get("rank") + province = payload.get("province") + year = payload.get("year") + report_id = payload.get("report_id") or result.get("report_id") + share_url = payload.get("share_url") or result.get("url") + + info_cards = [] + if candidate_name: + info_cards.append(_metric_card("考生", str(candidate_name))) + if score is not None: + info_cards.append(_metric_card("分数", str(score))) + if rank is not None: + info_cards.append(_metric_card("位次", _format_number(rank))) + if province or year: + info_cards.append(_metric_card("地区/年份", " / ".join([str(x) for x in (province, year) if x]))) + info_cards_html = "".join(info_cards) if info_cards else _metric_card("状态", "已授权查看") + + recommendations = _render_recommendations(payload.get("recommendations")) + volunteers = _render_volunteers(payload.get("volunteers")) + permission_hint = _permission_hint(permission, policy) + + limited_block = "" + if permission == "read": + limited_block = """ +
+

分享信息受限

+

当前链接只开放最小信息展示,完整方案需更高权限或联系龙老师。

+
+ """ + + report_meta = [] + if report_id: + report_meta.append(f"报告ID:{escape(str(report_id))}") + if share_url: + report_meta.append(f"分享链接:{escape(str(share_url))}") + report_meta_html = "
".join(report_meta) if report_meta else "公开分享页" + + copy_button = "" + if share_url: + copy_button = f'' + + body = f""" +
+ 龙老师 · 高考志愿填报方案 +

{escape(str(title))}

+

{escape(str(summary))}

+

{permission_hint}

+
{copy_button}
+
+ +
{info_cards_html}
+ + {limited_block} + + {recommendations} + {volunteers} + + + + """ + return _page_shell(str(title), body) + + +def _pick_first(payload: dict[str, Any], *keys: str) -> Any: + for key in keys: + value = payload.get(key) + if value not in (None, ""): + return value + return None + + +def _render_recommendations(items: Any) -> str: + rows = [] + for item in _take_dicts(items, limit=3): + school = _pick_from_item(item, "school", "name") or "院校待补充" + major = _pick_from_item(item, "major") or "专业待补充" + prob = _pick_from_item(item, "prob", "probability") + prob_text = _format_probability(prob) + rows.append(f"
  • {escape(str(school))}{escape(str(major))}{escape(prob_text)}
  • ") + if not rows: + return "" + return f""" +
    +

    推荐院校 TOP3

    +
      {''.join(rows)}
    +
    + """ + + +def _render_volunteers(items: Any) -> str: + rows = [] + for item in _take_dicts(items, limit=3): + school = _pick_from_item(item, "school", "name") or "志愿待补充" + majors = item.get("majors") if isinstance(item.get("majors"), list) else None + if majors: + detail = " / ".join(str(x) for x in majors[:3]) + else: + detail = _pick_from_item(item, "major", "type") or "" + rows.append(f"
  • {escape(str(school))}{escape(str(detail))}
  • ") + if not rows: + return "" + return f""" +
    +

    志愿预览

    +
      {''.join(rows)}
    +
    + """ + + +def _take_dicts(items: Any, *, limit: int) -> Iterable[dict[str, Any]]: + if not isinstance(items, list): + return [] + out = [] + for item in items: + if isinstance(item, dict): + out.append(item) + if len(out) >= limit: + break + return out + + +def _pick_from_item(item: dict[str, Any], *keys: str) -> Any: + for key in keys: + value = item.get(key) + if value not in (None, ""): + return value + return None + + +def _permission_hint(permission: str, policy: dict[str, Any]) -> str: + labels = [] + if policy.get("can_comment"): + labels.append("可评论") + if policy.get("can_edit"): + labels.append("可编辑") + if not labels: + labels.append("仅查看") + return f"权限:{escape(permission)} · {' / '.join(labels)}" + + +def _metric_card(label: str, value: str) -> str: + return f""" +
    +
    {escape(label)}
    +
    {escape(value)}
    +
    + """ + + +def _format_probability(value: Any) -> str: + if value in (None, ""): + return "" + try: + num = float(value) + except (TypeError, ValueError): + return str(value) + if num <= 1: + num *= 100 + return f"录取概率 {num:.0f}%" + + +def _format_number(value: Any) -> str: + try: + return f"{int(value):,}" + except (TypeError, ValueError): + return str(value) + + +def _page_shell(title: str, body: str) -> str: + return f""" + + + + + + {escape(title)} + + + +
    {body}
    + + +""" \ No newline at end of file diff --git a/admin/static/dashboard.html b/admin/static/dashboard.html new file mode 100644 index 0000000..cae40e2 --- /dev/null +++ b/admin/static/dashboard.html @@ -0,0 +1,345 @@ + + + + + + 管理后台仪表盘 + + + +
    +
    +
    +

    最小仪表盘

    +

    3 个指标卡 + 1 个趋势图 + 3 个分布图,仅保留 7d/30d 切换。

    +
    +
    +
    接口: /api/stats/dashboard
    +
    尚未加载
    +
    +
    + + + +
    提示:请通过服务端 `/dashboard` 页面访问;先登录或粘贴 JWT 后拉取数据。
    +
    + +
    +
    +
    订单数
    +
    --
    +
    今日 -- / 7d -- / 30d --
    +
    +
    +
    用户数
    +
    --
    +
    admin_users 总数
    +
    +
    +
    收入
    +
    --
    +
    今日 -- / 7d -- / 30d --
    +
    +
    + +
    +
    +
    +

    7 天趋势

    +

    订单数与收入共用同一时间轴。

    +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    +

    状态分布

    +

    观察订单漏斗积压。

    +
    +
    +
    +
    + +
    +
    +
    +

    来源分布

    +

    查看渠道结构。

    +
    +
    +
    +
    + +
    +
    +
    +

    服务版本分布

    +

    查看产品结构。

    +
    +
    +
    +
    +
    +
    + + + + + + diff --git a/admin/static/dashboard.js b/admin/static/dashboard.js new file mode 100644 index 0000000..6a684cd --- /dev/null +++ b/admin/static/dashboard.js @@ -0,0 +1,244 @@ +const TOKEN_KEY = "gaokao_admin_dashboard_token"; + +const state = { + charts: {}, + payload: null, + range: "7d", +}; + +function resolveApiBase() { + const raw = document.getElementById("api-base").value.trim(); + return raw || window.location.origin; +} + +function formatMoney(cents) { + return `¥${(Number(cents || 0) / 100).toFixed(2)}`; +} + +function setMessage(text, isSuccess = false) { + const el = document.getElementById("message"); + el.textContent = text; + el.className = isSuccess ? "hint success" : "hint"; +} + +function setError(text) { + document.getElementById("error").textContent = text || ""; +} + +function getToken() { + return document.getElementById("token").value.trim(); +} + +function setToken(token) { + document.getElementById("token").value = token || ""; + if (token) { + window.sessionStorage.setItem(TOKEN_KEY, token); + } else { + window.sessionStorage.removeItem(TOKEN_KEY); + } +} + +async function loginAndStoreToken() { + const username = document.getElementById("username").value.trim(); + const password = document.getElementById("password").value; + if (!username || !password) { + throw new Error("请输入用户名和密码,或直接粘贴 Bearer Token。"); + } + + const resp = await fetch(`${resolveApiBase()}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + if (!resp.ok) { + throw new Error(`登录失败: HTTP ${resp.status}`); + } + + const body = await resp.json(); + if (!body.access_token) { + throw new Error("登录响应缺少 access_token"); + } + + setToken(body.access_token); + return body.access_token; +} + +function renderSummary(summary) { + document.getElementById("total-orders").textContent = String( + summary.total_orders ?? 0, + ); + document.getElementById("total-users").textContent = String( + summary.total_users ?? 0, + ); + document.getElementById("total-revenue").textContent = formatMoney( + summary.total_revenue_cents ?? 0, + ); + document.getElementById("orders-subtitle").textContent = + `今日 ${summary.orders_today ?? 0} / 7d ${summary.orders_7d ?? 0} / 30d ${summary.orders_30d ?? 0}`; + document.getElementById("revenue-subtitle").textContent = + `今日 ${formatMoney(summary.revenue_today_cents)} / 7d ${formatMoney(summary.revenue_7d_cents)} / 30d ${formatMoney(summary.revenue_30d_cents)}`; +} + +function ensureChart(id) { + if (!state.charts[id]) { + state.charts[id] = echarts.init(document.getElementById(id)); + } + return state.charts[id]; +} + +function renderTrend(trends) { + const points = Array.isArray(trends?.[state.range]) ? trends?.[state.range] : []; + const chart = ensureChart("trend-chart"); + document.getElementById("trend-title").textContent = + state.range === "30d" ? "30 天趋势" : "7 天趋势"; + chart.setOption({ + tooltip: { trigger: "axis" }, + legend: { data: ["订单数", "收入(元)"] }, + grid: { left: 48, right: 48, top: 48, bottom: 32 }, + xAxis: { + type: "category", + boundaryGap: false, + data: points.map((item) => item.date), + }, + yAxis: [ + { type: "value", name: "订单数" }, + { type: "value", name: "收入(元)" }, + ], + series: [ + { + name: "订单数", + type: "line", + smooth: true, + data: points.map((item) => item.orders ?? 0), + }, + { + name: "收入(元)", + type: "line", + smooth: true, + yAxisIndex: 1, + data: points.map((item) => Number(item.revenue_cents ?? 0) / 100), + }, + ], + }); +} + +function renderBarChart(chartId, title, dataMap, color) { + const entries = Object.entries(dataMap || {}); + const chart = ensureChart(chartId); + chart.setOption({ + tooltip: { trigger: "axis" }, + grid: { left: 48, right: 24, top: 32, bottom: 48 }, + xAxis: { + type: "category", + axisLabel: { interval: 0, rotate: entries.length > 4 ? 20 : 0 }, + data: entries.map(([label]) => label), + }, + yAxis: { type: "value", minInterval: 1 }, + series: [ + { + name: title, + type: "bar", + data: entries.map(([, value]) => value ?? 0), + itemStyle: { color }, + barMaxWidth: 48, + }, + ], + }); +} + +function renderDistributions(payload) { + renderBarChart("status-chart", "状态分布", payload.by_status, "#2563eb"); + renderBarChart("source-chart", "来源分布", payload.by_source, "#7c3aed"); + renderBarChart( + "service-chart", + "服务版本分布", + payload.by_service_version, + "#059669", + ); +} + +function updateRangeButtons() { + const isThirty = state.range === "30d"; + document.getElementById("range-7d").className = isThirty ? "secondary" : "active-range"; + document.getElementById("range-30d").className = isThirty ? "active-range" : "secondary"; +} + +async function fetchDashboard(token) { + const resp = await fetch(`${resolveApiBase()}/api/stats/dashboard`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!resp.ok) { + throw new Error(`/api/stats/dashboard 请求失败: HTTP ${resp.status}`); + } + return resp.json(); +} + +function renderDashboard(payload) { + state.payload = payload; + renderSummary(payload.summary || {}); + renderTrend(payload.trends || {}); + renderDistributions(payload); + updateRangeButtons(); + document.getElementById("generated-at").textContent = + `更新时间: ${payload.generated_at || "unknown"}`; +} + +async function loadDashboard({ loginFirst = false } = {}) { + try { + setError(""); + setMessage(loginFirst ? "登录中..." : "加载中..."); + + let token = getToken(); + if (!token && loginFirst) { + token = await loginAndStoreToken(); + setMessage("登录成功,开始加载仪表盘...", true); + } + if (!token) { + throw new Error("缺少 JWT,请先登录或粘贴 Bearer Token。"); + } + + const payload = await fetchDashboard(token); + renderDashboard(payload); + setMessage("仪表盘加载成功", true); + } catch (error) { + setError(error.message || String(error)); + setMessage("加载失败,请检查认证或服务状态。"); + } +} + +function bindRangeButton(id, range) { + document.getElementById(id).addEventListener("click", () => { + state.range = range; + updateRangeButtons(); + if (state.payload) { + renderTrend(state.payload.trends || {}); + } + }); +} + +function bootstrap() { + const cachedToken = window.sessionStorage.getItem(TOKEN_KEY); + if (cachedToken && !getToken()) { + setToken(cachedToken); + } + + window.addEventListener("resize", () => { + Object.values(state.charts).forEach((chart) => chart && chart.resize()); + }); + + document.getElementById("login-btn").addEventListener("click", () => { + loadDashboard({ loginFirst: true }); + }); + document.getElementById("refresh-btn").addEventListener("click", () => { + loadDashboard({ loginFirst: false }); + }); + bindRangeButton("range-7d", "7d"); + bindRangeButton("range-30d", "30d"); + updateRangeButtons(); +} + +window.addEventListener("DOMContentLoaded", bootstrap); diff --git a/admin/static/echarts.min.js b/admin/static/echarts.min.js new file mode 100644 index 0000000..e8c000c --- /dev/null +++ b/admin/static/echarts.min.js @@ -0,0 +1 @@ +(()=>{function h(v){return String(v??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]))}function m(arr){return arr.reduce((x,y)=>Math.max(x,Number(y)||0),0)||1}function line(el,o){const cats=o?.xAxis?.data||[];const a=o?.series?.[0]?.data||[];const b=o?.series?.[1]?.data||[];const max=m(a.concat(b));const pts=(vals)=>vals.map((v,i)=>{const x=cats.length>1?(i*100)/(cats.length-1):0;const y=90-((Number(v)||0)/max)*80;return `${x},${y}`}).join(" ");el.innerHTML=`
    ${cats.map(x=>`${h(x)}`).join("")}
    `}function bar(el,o){const cats=o?.xAxis?.data||[];const vals=o?.series?.[0]?.data||[];const color=o?.series?.[0]?.itemStyle?.color||"#2563eb";const max=m(vals);el.innerHTML=`
    ${cats.map((cat,i)=>{const value=Number(vals[i]||0);const width=Math.max(6,Math.round((value/max)*100));return `
    ${h(cat)}${value}
    `}).join("")}
    `}window.echarts={init(el){return{setOption(o){if((o?.series||[])[0]?.type==="bar"){bar(el,o);return}line(el,o)},resize(){}}}}})(); diff --git a/admin/static/vendor/echarts.min.js b/admin/static/vendor/echarts.min.js new file mode 100644 index 0000000..e8c000c --- /dev/null +++ b/admin/static/vendor/echarts.min.js @@ -0,0 +1 @@ +(()=>{function h(v){return String(v??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]))}function m(arr){return arr.reduce((x,y)=>Math.max(x,Number(y)||0),0)||1}function line(el,o){const cats=o?.xAxis?.data||[];const a=o?.series?.[0]?.data||[];const b=o?.series?.[1]?.data||[];const max=m(a.concat(b));const pts=(vals)=>vals.map((v,i)=>{const x=cats.length>1?(i*100)/(cats.length-1):0;const y=90-((Number(v)||0)/max)*80;return `${x},${y}`}).join(" ");el.innerHTML=`
    ${cats.map(x=>`${h(x)}`).join("")}
    `}function bar(el,o){const cats=o?.xAxis?.data||[];const vals=o?.series?.[0]?.data||[];const color=o?.series?.[0]?.itemStyle?.color||"#2563eb";const max=m(vals);el.innerHTML=`
    ${cats.map((cat,i)=>{const value=Number(vals[i]||0);const width=Math.max(6,Math.round((value/max)*100));return `
    ${h(cat)}${value}
    `}).join("")}
    `}window.echarts={init(el){return{setOption(o){if((o?.series||[])[0]?.type==="bar"){bar(el,o);return}line(el,o)},resize(){}}}}})(); diff --git a/admin/stats.py b/admin/stats.py new file mode 100644 index 0000000..5ad80b9 --- /dev/null +++ b/admin/stats.py @@ -0,0 +1,466 @@ +"""管理后台仪表盘统计 (T6.2). + +提供一组纯函数,接受 db_path 返回仪表盘 payload。SQL 聚合在 +:mod:`data.orders.dao.OrdersDAO` 之外的轻量连接里直接执行 (避免 +DAO 解密 PII 字段带来的开销) — 统计只涉及 ``status`` / +``source`` / ``service_version`` / ``amount_cents`` / ``created_at`` 等 +非敏感列。 + +公开 API +-------- + +- :func:`build_dashboard_payload` : 一次返回 dashboard 全部数据 +- :func:`compute_summary` : 汇总卡片 (订单/用户/收入 + 今日/7d/30d) +- :func:`compute_by_status` : 6 态分布 +- :func:`compute_by_source` : 来源分布 +- :func:`compute_by_service_version` : 服务版本分布 +- :func:`compute_trends` : 今日 / 7d / 30d 趋势 (日粒度) +- :func:`generate_day_series` : 趋势补零辅助 (0 填充生成完整序列) + +口径约定 +-------- + +- **收入 (revenue)** = 所有 **非 pending 且非 refunded** 订单的 + ``amount_cents`` 累计值。pending 表示未付款,refunded 表示已退款, + 两者均不计入有效收入。 +- **趋势桶粒度** = 日 (UTC 日期, ``YYYY-MM-DD``)。ISO8601 字符串的 + 前 10 位切片 (``substr(created_at, 1, 10)``) 与 UTC 日期等价。 +- **"今日"** = 服务器当前 UTC 日;**7d/30d** = 含今日回溯 7/30 个 + 完整日。窗口外的点会被丢弃,缺失日以 0 填充。 +- **不读 PII** : 统计路径只触碰 ``amount_cents``/``status``/``source`` + /``service_version``/``created_at``,避免进入加密层。 +""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Sequence + +from admin.db import get_connection + + +# --------------------------------------------------------------------------- +# 常量 +# --------------------------------------------------------------------------- + +# 收入口径:进入这些状态的订单算有效收入 (paid 之后才付款, refunded 排除) +_REVENUE_STATUSES: tuple[str, ...] = ( + "paid", + "serving", + "delivered", + "completed", +) + +# 完整 6 态 (分布统计时强制输出 0 计数) +_ALL_STATUSES: tuple[str, ...] = ( + "pending", + "paid", + "serving", + "delivered", + "completed", + "refunded", +) + +# 完整来源 (与 meta.py 对齐) +_ALL_SOURCES: tuple[str, ...] = ("xianyu", "wechat", "web", "school") + +# 完整服务版本 +_ALL_SERVICE_VERSIONS: tuple[str, ...] = ("audit", "basic", "standard", "premium") + + +# --------------------------------------------------------------------------- +# 数据结构 +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class TrendPoint: + """单日趋势点。""" + + date: str # YYYY-MM-DD + orders: int + revenue_cents: int + + def to_dict(self) -> dict: + return { + "date": self.date, + "orders": int(self.orders), + "revenue_cents": int(self.revenue_cents), + } + + +# --------------------------------------------------------------------------- +# 时间窗口辅助 +# --------------------------------------------------------------------------- + + +def _utc_today() -> datetime: + """返回当前 UTC 的午夜 (00:00:00)。""" + now = datetime.now(timezone.utc) + return now.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _date_range(end: datetime, days: int) -> List[str]: + """返回 end 当天及之前 ``days`` 个 UTC 日期字符串 (含端点)。""" + return [ + (end - timedelta(days=i)).date().isoformat() for i in range(days - 1, -1, -1) + ] + + +def _format_utc_now_iso() -> str: + """当前 UTC 时间的 ISO8601 字符串 (秒精度)。""" + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +# --------------------------------------------------------------------------- +# 单查询辅助 +# --------------------------------------------------------------------------- + + +def _open_conn(db_path: str) -> sqlite3.Connection: + """打开 SQLite 连接 (统一走 admin.db.get_connection 避免 PRAGMA 分裂)。""" + return get_connection(db_path) + + +def _fetchall_dict( + conn: sqlite3.Connection, sql: str, params: Sequence[Any] = () +) -> List[dict]: + """执行查询并以 dict 形式返回全部行。""" + cur = conn.execute(sql, params) + rows = cur.fetchall() + out: List[dict] = [] + for row in rows: + # sqlite3.Row 走 keys() + 索引; tuple 走索引 + if hasattr(row, "keys"): + out.append({k: row[k] for k in row.keys()}) + else: + out.append(dict(row)) + return out + + +# --------------------------------------------------------------------------- +# 聚合函数 +# --------------------------------------------------------------------------- + + +def compute_summary( + orders_db_path: str, + admin_db_path: Optional[str] = None, + *, + today: Optional[datetime] = None, +) -> dict: + """汇总卡片:订单/用户/收入,以及今日/7d/30d 三个窗口的切片。 + + Args: + orders_db_path: orders / order_status_history 所在 DB (data.orders.*) + admin_db_path: admin_users 所在 DB;传 None 时跳过用户统计,返回 0 + today: 测试可注入的"当前 UTC 日" (默认 = 真实 now) + + Returns: + dict 形如:: + + { + "total_orders": int, + "total_revenue_cents": int, + "total_users": int, + "orders_today": int, + "orders_7d": int, + "orders_30d": int, + "revenue_today_cents": int, + "revenue_7d_cents": int, + "revenue_30d_cents": int, + } + """ + if today is None: + today = _utc_today() + today_iso = today.date().isoformat() + seven_ago = (today - timedelta(days=6)).date().isoformat() # 7 天窗口含今天 + thirty_ago = (today - timedelta(days=29)).date().isoformat() # 30 天窗口含今天 + + revenue_status_placeholder = ",".join("?" for _ in _REVENUE_STATUSES) + + with _open_conn(orders_db_path) as conn: + # 总订单数 / 总收入 (一次性走单条聚合 SQL) + row = conn.execute( + f""" + SELECT + COUNT(*) AS total_orders, + COALESCE(SUM( + CASE WHEN status IN ({revenue_status_placeholder}) + THEN amount_cents ELSE 0 END + ), 0) AS total_revenue_cents + FROM orders + """, + _REVENUE_STATUSES, + ).fetchone() + + # 今日 / 7d / 30d 切片 (用 substr 切日期) + slice_row = conn.execute( + f""" + SELECT + COALESCE(SUM( + CASE WHEN substr(created_at, 1, 10) = ? THEN 1 ELSE 0 END + ), 0) AS orders_today, + COALESCE(SUM( + CASE WHEN substr(created_at, 1, 10) >= ? THEN 1 ELSE 0 END + ), 0) AS orders_7d, + COALESCE(SUM( + CASE WHEN substr(created_at, 1, 10) >= ? THEN 1 ELSE 0 END + ), 0) AS orders_30d, + COALESCE(SUM( + CASE WHEN substr(created_at, 1, 10) = ? + AND status IN ({revenue_status_placeholder}) + THEN amount_cents ELSE 0 END + ), 0) AS revenue_today_cents, + COALESCE(SUM( + CASE WHEN substr(created_at, 1, 10) >= ? + AND status IN ({revenue_status_placeholder}) + THEN amount_cents ELSE 0 END + ), 0) AS revenue_7d_cents, + COALESCE(SUM( + CASE WHEN substr(created_at, 1, 10) >= ? + AND status IN ({revenue_status_placeholder}) + THEN amount_cents ELSE 0 END + ), 0) AS revenue_30d_cents + FROM orders + """, + ( + today_iso, + seven_ago, + thirty_ago, + today_iso, + *_REVENUE_STATUSES, + seven_ago, + *_REVENUE_STATUSES, + thirty_ago, + *_REVENUE_STATUSES, + ), + ).fetchone() + + # 用户数 (单独连接 admin DB,避免在 orders DB 上去找可能不存在的表) + total_users = 0 + if admin_db_path is not None: + with _open_conn(admin_db_path) as conn: + user_row = conn.execute("SELECT COUNT(*) AS n FROM admin_users").fetchone() + total_users = int(user_row["n"] or 0) + + return { + "total_orders": int(row["total_orders"] or 0), + "total_revenue_cents": int(row["total_revenue_cents"] or 0), + "total_users": total_users, + "orders_today": int(slice_row["orders_today"] or 0), + "orders_7d": int(slice_row["orders_7d"] or 0), + "orders_30d": int(slice_row["orders_30d"] or 0), + "revenue_today_cents": int(slice_row["revenue_today_cents"] or 0), + "revenue_7d_cents": int(slice_row["revenue_7d_cents"] or 0), + "revenue_30d_cents": int(slice_row["revenue_30d_cents"] or 0), + } + + +def compute_by_status(db_path: str) -> Dict[str, int]: + """按 status 分组统计订单数,缺失的 6 态以 0 填充。""" + with _open_conn(db_path) as conn: + rows = _fetchall_dict( + conn, + "SELECT status, COUNT(*) AS n FROM orders GROUP BY status", + ) + result: Dict[str, int] = {s: 0 for s in _ALL_STATUSES} + for r in rows: + s = str(r.get("status", "")) + if s in result: + result[s] = int(r.get("n", 0) or 0) + return result + + +def compute_by_source(db_path: str) -> Dict[str, int]: + """按 source 分组统计订单数,缺失的来源以 0 填充。""" + with _open_conn(db_path) as conn: + rows = _fetchall_dict( + conn, + "SELECT source, COUNT(*) AS n FROM orders GROUP BY source", + ) + result: Dict[str, int] = {s: 0 for s in _ALL_SOURCES} + for r in rows: + s = str(r.get("source", "")) + if s in result: + result[s] = int(r.get("n", 0) or 0) + return result + + +def compute_by_service_version(db_path: str) -> Dict[str, int]: + """按 service_version 分组统计订单数,缺失版本以 0 填充。""" + with _open_conn(db_path) as conn: + rows = _fetchall_dict( + conn, + "SELECT service_version, COUNT(*) AS n FROM orders GROUP BY service_version", + ) + result: Dict[str, int] = {s: 0 for s in _ALL_SERVICE_VERSIONS} + for r in rows: + s = str(r.get("service_version", "")) + if s in result: + result[s] = int(r.get("n", 0) or 0) + return result + + +def generate_day_series( + db_path: str, *, days: int, today: Optional[datetime] = None +) -> List[TrendPoint]: + """按日粒度聚合订单数 + 收入(收入走 :data:`_REVENUE_STATUSES` 口径),并 0 填充空日。 + + Args: + db_path: SQLite 路径 + days: 窗口大小 (7 / 30);含今天,共生成 ``days`` 个点 + today: 测试可注入的"当前 UTC 日" + + Returns: + 按日期升序排列的 :class:`TrendPoint` 列表,长度 = ``days``。 + """ + if days <= 0: + raise ValueError(f"days 必须 >= 1, 得到 {days}") + if today is None: + today = _utc_today() + end = today + start = end - timedelta(days=days - 1) + start_iso = start.date().isoformat() + end_iso = end.date().isoformat() + date_keys = _date_range(end, days) + + revenue_status_placeholder = ",".join("?" for _ in _REVENUE_STATUSES) + + sql = f""" + SELECT + substr(created_at, 1, 10) AS day, + COUNT(*) AS orders, + COALESCE(SUM( + CASE WHEN status IN ({revenue_status_placeholder}) + THEN amount_cents ELSE 0 END + ), 0) AS revenue_cents + FROM orders + WHERE substr(created_at, 1, 10) >= ? + AND substr(created_at, 1, 10) <= ? + GROUP BY substr(created_at, 1, 10) + """ + params: List[Any] = [*_REVENUE_STATUSES, start_iso, end_iso] + + with _open_conn(db_path) as conn: + rows = _fetchall_dict(conn, sql, params) + + by_day: Dict[str, TrendPoint] = {} + for r in rows: + day = str(r.get("day") or "") + if not day: + continue + by_day[day] = TrendPoint( + date=day, + orders=int(r.get("orders", 0) or 0), + revenue_cents=int(r.get("revenue_cents", 0) or 0), + ) + + return [ + by_day.get(d, TrendPoint(date=d, orders=0, revenue_cents=0)) for d in date_keys + ] + + +def compute_trends( + db_path: str, *, today: Optional[datetime] = None +) -> Dict[str, List[dict]]: + """返回今日 / 7d / 30d 三个窗口的趋势点 (每个点 to_dict)。 + + "今日" 返回 1 个点 (当前 UTC 日); 7d / 30d 分别返回 7 / 30 个点。 + """ + return { + "today": [ + p.to_dict() for p in generate_day_series(db_path, days=1, today=today) + ], + "7d": [p.to_dict() for p in generate_day_series(db_path, days=7, today=today)], + "30d": [ + p.to_dict() for p in generate_day_series(db_path, days=30, today=today) + ], + } + + +# --------------------------------------------------------------------------- +# 顶层 payload +# --------------------------------------------------------------------------- + + +def build_dashboard_payload( + orders_db_path: str, + admin_db_path: Optional[str] = None, + *, + today: Optional[datetime] = None, +) -> dict: + """组装仪表盘一站式 payload,供 ``GET /api/stats/dashboard`` 渲染。 + + Args: + orders_db_path: orders / order_status_history 所在 DB + admin_db_path: admin_users 所在 DB (传 None 时 ``total_users`` 为 0) + today: 测试可注入的"当前 UTC 日" + + Returns: + dict,字段详见模块 docstring。空库也返回完整骨架 (全 0 计数 + 完整 + 趋势序列),前端可直接渲染。 + """ + return { + "summary": compute_summary( + orders_db_path, admin_db_path=admin_db_path, today=today + ), + "by_status": compute_by_status(orders_db_path), + "by_source": compute_by_source(orders_db_path), + "by_service_version": compute_by_service_version(orders_db_path), + "trends": compute_trends(orders_db_path, today=today), + "generated_at": _format_utc_now_iso(), + } + + +# --------------------------------------------------------------------------- +# 兼容层 — /api/stats/orders 老端点 +# --------------------------------------------------------------------------- +# +# T6.1 时挂在 ``/api/stats/orders`` 上的占位端点 (返回 ``_stub=True``) +# 在 T6.2 被赋以"订单维度统计"的实际意义,前端若还在用旧契约 (只读 +# by_status / by_source / by_service_version / total_orders / +# total_revenue_cents) 不会破。前端新接 dashboard 端点可获完整聚合。 +# --------------------------------------------------------------------------- + + +def build_order_stats_payload( + orders_db_path: str, + admin_db_path: Optional[str] = None, + *, + today: Optional[datetime] = None, +) -> dict: + """``/api/stats/orders`` 真实数据。 + + 字段集 = :func:`build_dashboard_payload` 的"订单子集",保持 T6.1 + stub 阶段的字段名不变,避免前端破契约。 + + Args: + orders_db_path: orders / order_status_history 所在 DB + admin_db_path: 当前未使用 (T6.2 端点不含 user 字段),保留便于未来扩展 + today: 测试可注入的"当前 UTC 日" + """ + del admin_db_path # 当前未消费,显式忽略 + summary = compute_summary(orders_db_path, today=today) + return { + "total_orders": summary["total_orders"], + "total_revenue_cents": summary["total_revenue_cents"], + "by_status": compute_by_status(orders_db_path), + "by_source": compute_by_source(orders_db_path), + "by_service_version": compute_by_service_version(orders_db_path), + } + + +__all__ = [ + "TrendPoint", + "build_dashboard_payload", + "build_order_stats_payload", + "compute_summary", + "compute_by_status", + "compute_by_source", + "compute_by_service_version", + "compute_trends", + "generate_day_series", +] diff --git a/admin/tests/__init__.py b/admin/tests/__init__.py new file mode 100644 index 0000000..b953256 --- /dev/null +++ b/admin/tests/__init__.py @@ -0,0 +1 @@ +"""测试模块标记。""" diff --git a/admin/tests/conftest.py b/admin/tests/conftest.py new file mode 100644 index 0000000..9fc93e4 --- /dev/null +++ b/admin/tests/conftest.py @@ -0,0 +1,115 @@ +"""pytest 配置 (T6.1). + +提供 fixture: +- settings: 内存 SQLite + 安全 JWT 密钥 + 短过期 +- app: FastAPI 实例(lifespan 已运行 → admin 表已建 + bootstrap 用户) +- client: httpx TestClient +- auth_token: 登录后的 Bearer JWT +- auth_headers: {"Authorization": f"Bearer ..."} +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +# 确保项目根在 sys.path +_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + + +@pytest.fixture +def secure_secret() -> str: + """64-char 安全 JWT 密钥。""" + return "x" * 64 # 确定性,便于测试断言 + + +@pytest.fixture +def settings(tmp_path, secure_secret, monkeypatch): + """隔离的 Settings 实例:tmp_path SQLite + 安全密钥 + 短过期。 + + 用真实文件而不是 :memory: 是因为 admin/db.py 中每次 get_connection + 都新建连接,:memory: 在 SQLite 下不共享状态。 + """ + db_path = str(tmp_path / "admin.db") + orders_db_path = str(tmp_path / "orders.db") + share_db_path = str(tmp_path / "short_links.db") + share_report_dir = str(tmp_path / "share_reports") + monkeypatch.setenv("GAOKAO_ENV", "dev") + monkeypatch.setenv("GAOKAO_DB_PATH", db_path) + monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", orders_db_path) + monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", share_db_path) + monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", share_report_dir) + monkeypatch.setenv("GAOKAO_JWT_SECRET", secure_secret) + monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5") + monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin") + monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123") + + from admin.config import load_settings + + return load_settings() + + +@pytest.fixture +def orders_db(settings): + """T6.2 起:管理后台统计端点会读 orders 表,因此 conftest 顺带建一个 + 空 orders DB (T4.1 schema),后续 dashboard 测试可在里面塞 fixture 数据。 + """ + from data.orders.schema import apply_schema + + conn = apply_schema(settings.orders_db_path) + conn.close() + return settings.orders_db_path + + +@pytest.fixture(autouse=True) +def _auto_orders_db(settings, orders_db): + """所有测试都默认有空 orders DB 可用,避免 T6.2 端点在不相关测试里 + 报 'no such table: orders'。``orders_db`` 显式依赖确保已建。 + """ + return orders_db + + +@pytest.fixture(autouse=True) +def _reset_login_rate_limit(): + from admin.routes.auth import reset_login_rate_limit_for_tests + + reset_login_rate_limit_for_tests() + yield + reset_login_rate_limit_for_tests() + + +@pytest.fixture +def app(settings): + """FastAPI 实例(lifespan 已运行)。""" + from admin.app import create_app + + return create_app(settings) + + +@pytest.fixture +def client(app): + """httpx TestClient。""" + from fastapi.testclient import TestClient + + with TestClient(app) as c: + yield c + + +@pytest.fixture +def auth_token(client) -> str: + """登录后返回 Bearer JWT。""" + resp = client.post( + "/api/auth/login", + json={"username": "admin", "password": "test-pass-123"}, + ) + assert resp.status_code == 200, resp.text + return resp.json()["access_token"] + + +@pytest.fixture +def auth_headers(auth_token) -> dict: + return {"Authorization": f"Bearer {auth_token}"} diff --git a/admin/tests/test_app.py b/admin/tests/test_app.py new file mode 100644 index 0000000..87c6ada --- /dev/null +++ b/admin/tests/test_app.py @@ -0,0 +1,194 @@ +"""FastAPI app 启动 + Swagger 测试 (T6.1). + +验证: +- app 工厂可用 +- lifespan 触发 bootstrap +- /openapi.json 含所有路由 +- /docs 返回 HTML +""" + +from __future__ import annotations + +import sqlite3 + +import pytest + + +def test_create_app_runs_lifespan(client): + """TestClient 上下文会触发 lifespan,验证 bootstrap 完成。""" + # 客户端创建时 lifespan 已运行 → 已有 admin 用户 + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["service"] == "gaokao-admin" + assert body["env"] == "dev" + + +def test_openapi_json_exposes_all_routes(client): + """/openapi.json 含所有 T6.1 路由。""" + resp = client.get("/openapi.json") + assert resp.status_code == 200 + schema = resp.json() + paths = set(schema["paths"].keys()) + + expected = { + "/health", + "/api/auth/login", + "/api/auth/me", + "/api/orders", + "/api/orders/{order_id}", + "/api/stats/dashboard", + "/api/stats/orders", + "/api/meta", + } + missing = expected - paths + assert not missing, f"OpenAPI 缺失路径: {missing}" + + +def test_docs_serves_swagger_ui(client): + """/docs 返回 HTML(200 + 含 swagger-ui 字样)。""" + resp = client.get("/docs") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + body = resp.text.lower() + assert "swagger" in body or "openapi" in body + + +def test_redoc_served(client): + """/redoc 也可用。""" + resp = client.get("/redoc") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + +def test_dashboard_page_served(client): + """/dashboard 提供 T6.6 所需的 ECharts 页面骨架。""" + resp = client.get("/dashboard") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + body = resp.text + assert "仪表盘" in body + assert "/static/dashboard.js" in body + assert 'id="trend-chart"' in body + assert 'id="status-chart"' in body + assert 'id="source-chart"' in body + assert 'id="service-chart"' in body + assert 'id="range-7d"' in body + assert 'id="range-30d"' in body + assert "/static/vendor/echarts.min.js" in body + assert "jsdelivr.net" not in body + + +def test_dashboard_static_js_served(client): + """前端脚本包含趋势切换与 3 张分布图渲染逻辑。""" + resp = client.get("/static/dashboard.js") + assert resp.status_code == 200 + assert "javascript" in resp.headers["content-type"] + body = resp.text + assert "renderSummary" in body + assert "/api/stats/dashboard" in body + assert "status-chart" in body + assert "source-chart" in body + assert "service-chart" in body + assert 'range: "7d"' in body + assert "trends?.[state.range]" in body + assert "sessionStorage" in body + assert "localStorage" not in body + + +def test_bootstrap_admin_only_once(client, settings): + """lifespan 已 bootstrap 后,再次调用应报告已存在,不再创建。""" + from admin.db import AdminUserRepo, bootstrap_admin + + # client fixture 已触发 lifespan → 已有 bootstrap 用户 + created1, msg1 = bootstrap_admin(settings) + assert created1 is False + assert "已存在" in msg1 + + repo = AdminUserRepo(settings.db_path) + assert repo.count() == 1 + + +def test_default_admin_login_works(client, settings): + """默认 admin/admin123 (此处覆写为 test-pass-123) 可登录。""" + resp = client.post( + "/api/auth/login", + json={"username": "admin", "password": "test-pass-123"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["token_type"] == "bearer" + assert body["expires_in"] == settings.jwt_expire_minutes * 60 + assert isinstance(body["access_token"], str) + assert len(body["access_token"]) > 20 + + +def test_create_app_bootstraps_orders_schema(tmp_path, monkeypatch): + """真实启动流程会顺手初始化 orders DB,fresh app 的 dashboard 空库可用。""" + db_path = tmp_path / "admin.db" + orders_db_path = tmp_path / "orders.db" + monkeypatch.setenv("GAOKAO_ENV", "dev") + monkeypatch.setenv("GAOKAO_DB_PATH", str(db_path)) + monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db_path)) + monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64) + monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin") + monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123") + + from fastapi.testclient import TestClient + + from admin.app import create_app + from admin.config import load_settings + + app = create_app(load_settings()) + with TestClient(app) as client: + login = client.post( + "/api/auth/login", + json={"username": "admin", "password": "test-pass-123"}, + ) + assert login.status_code == 200 + token = login.json()["access_token"] + + resp = client.get( + "/api/stats/dashboard", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + assert resp.json()["summary"] == { + "total_orders": 0, + "total_revenue_cents": 0, + "total_users": 1, + "orders_today": 0, + "orders_7d": 0, + "orders_30d": 0, + "revenue_today_cents": 0, + "revenue_7d_cents": 0, + "revenue_30d_cents": 0, + } + + with sqlite3.connect(orders_db_path) as conn: + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='orders'" + ).fetchone() + assert row is not None + + +def test_prod_rejects_default_admin_password(tmp_path, monkeypatch): + db_path = tmp_path / "admin.db" + orders_db_path = tmp_path / "orders.db" + monkeypatch.setenv("GAOKAO_ENV", "prod") + monkeypatch.setenv("GAOKAO_DB_PATH", str(db_path)) + monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db_path)) + monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64) + monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin") + monkeypatch.setenv("GAOKAO_ADMIN_PASS", "admin123") + + from fastapi.testclient import TestClient + + from admin.app import create_app + from admin.config import load_settings + + app = create_app(load_settings()) + with pytest.raises(RuntimeError, match="default admin password insecure in prod"): + with TestClient(app): + pass diff --git a/admin/tests/test_auth.py b/admin/tests/test_auth.py new file mode 100644 index 0000000..7ba2af2 --- /dev/null +++ b/admin/tests/test_auth.py @@ -0,0 +1,136 @@ +"""JWT 鉴权测试 (T6.1). + +覆盖: +- encode/decode roundtrip +- 过期 / 签名错 / sub 格式错 → TokenError +- get_current_user 通过/拒绝路径 +""" + +from __future__ import annotations + +import time + +import jwt +import pytest + +from admin.auth import TokenError, decode_token, encode_token +from admin.db import AdminUser + + +def _make_user() -> AdminUser: + return AdminUser( + id=7, + username="alice", + role="admin", + is_active=True, + created_at="2026-06-12T00:00:00+00:00", + ) + + +def test_encode_decode_roundtrip(settings): + user = _make_user() + token = encode_token(user, settings) + claims = decode_token(token, settings) + assert claims["sub"] == "admin:7" + assert claims["username"] == "alice" + assert claims["role"] == "admin" + assert claims["exp"] > claims["iat"] + assert claims["exp"] - claims["iat"] == settings.jwt_expire_minutes * 60 + + +def test_decode_expired_raises(settings): + """手工签发已过期 token → TokenError。""" + user = _make_user() + now = int(time.time()) + payload = { + "sub": f"admin:{user.id}", + "username": user.username, + "role": user.role, + "iat": now - 3600, + "exp": now - 1, + } + bad = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + with pytest.raises(TokenError, match="expired"): + decode_token(bad, settings) + + +def test_decode_bad_signature_raises(settings): + user = _make_user() + token = encode_token(user, settings) + forged = jwt.encode( + jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]), + "wrong-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + algorithm=settings.jwt_algorithm, + ) + with pytest.raises(TokenError, match="invalid"): + decode_token(forged, settings) + + +def test_decode_malformed_sub_raises(): + """sub 不是 'admin:N' 格式 → _parse_user_id_from_subject 抛 TokenError。""" + from admin.auth import _parse_user_id_from_subject + + # wrong prefix + with pytest.raises(TokenError, match="prefix"): + _parse_user_id_from_subject("user:7") + # malformed + with pytest.raises(TokenError, match="malformed"): + _parse_user_id_from_subject("no-colon") + # non-int id + with pytest.raises(TokenError, match="non-integer"): + _parse_user_id_from_subject("admin:abc") + + +def test_get_current_user_rejects_missing_token(client): + """/api/auth/me 无 token → 401 + WWW-Authenticate。""" + resp = client.get("/api/auth/me") + assert resp.status_code == 401 + assert resp.headers.get("WWW-Authenticate", "").lower() == "bearer" + + +def test_get_current_user_rejects_bogus_token(client): + resp = client.get("/api/auth/me", headers={"Authorization": "Bearer not-a-jwt"}) + assert resp.status_code == 401 + + +def test_get_current_user_accepts_valid_token(client, auth_token): + resp = client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["username"] == "admin" + assert body["role"] == "admin" + assert body["is_active"] is True + + +def test_get_current_user_rejects_inactive(settings): + """直接构造 inactive 用户 token → 403。""" + from admin.db import AdminUser, AdminUserRepo, ensure_schema + from admin.auth import encode_token + from fastapi.testclient import TestClient + from admin.app import create_app + + ensure_schema(settings.db_path) + repo = AdminUserRepo(settings.db_path) + user = repo.create(username="inactive_test", password="p@ss123") + # 手动停用 + import sqlite3 as _sq + + with _sq.connect(settings.db_path) as conn: + conn.execute("UPDATE admin_users SET is_active = 0 WHERE id = ?", (user.id,)) + + inactive_user = AdminUser( + id=user.id, + username=user.username, + role=user.role, + is_active=False, + created_at=user.created_at, + ) + token = encode_token(inactive_user, settings) + + app = create_app(settings) + with TestClient(app) as c: + resp = c.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 403 diff --git a/admin/tests/test_catch.py b/admin/tests/test_catch.py new file mode 100644 index 0000000..2d52c3d --- /dev/null +++ b/admin/tests/test_catch.py @@ -0,0 +1,74 @@ +"""T9.4 @catch 装饰器测试.""" + +from __future__ import annotations + +import asyncio +import io +import json + +import pytest + +from admin.errors import BusinessError, DATA_PERSIST_FAILED, SYS_INTERNAL_ERROR +from admin.logging_utils import configure_logging, reset_logging_for_tests + + +class TestCatchDecorator: + def test_maps_sync_exception_to_business_error_and_logs(self): + stream = io.StringIO() + reset_logging_for_tests() + configure_logging(level="INFO", fmt="json", stream=stream) + + from admin.errors import catch + + @catch(DATA_PERSIST_FAILED) + def _boom() -> None: + raise ValueError("db unavailable") + + with pytest.raises(BusinessError) as cm: + _boom() + + assert cm.value.code == DATA_PERSIST_FAILED + assert isinstance(cm.value.__cause__, ValueError) + + payload = json.loads(stream.getvalue().strip()) + assert payload["level"] == "error" + assert payload["ctx"]["event"] == "caught_exception" + assert payload["ctx"]["code"] == str(DATA_PERSIST_FAILED) + assert payload["ctx"]["function"] == "_boom" + assert payload["exc"]["type"] == "ValueError" + + def test_passthrough_existing_business_error(self): + from admin.errors import catch + + @catch(DATA_PERSIST_FAILED) + def _boom() -> None: + raise BusinessError(SYS_INTERNAL_ERROR) + + with pytest.raises(BusinessError) as cm: + _boom() + + assert cm.value.code == SYS_INTERNAL_ERROR + assert cm.value.__cause__ is None + + def test_supports_async_function(self): + from admin.errors import catch + + @catch(DATA_PERSIST_FAILED) + async def _boom() -> None: + raise RuntimeError("async boom") + + with pytest.raises(BusinessError) as cm: + asyncio.run(_boom()) + + assert cm.value.code == DATA_PERSIST_FAILED + assert isinstance(cm.value.__cause__, RuntimeError) + + def test_reraise_true_preserves_original_exception(self): + from admin.errors import catch + + @catch(DATA_PERSIST_FAILED, reraise=True) + def _boom() -> None: + raise RuntimeError("keep original") + + with pytest.raises(RuntimeError, match="keep original"): + _boom() diff --git a/admin/tests/test_db.py b/admin/tests/test_db.py new file mode 100644 index 0000000..805fe4c --- /dev/null +++ b/admin/tests/test_db.py @@ -0,0 +1,132 @@ +"""db.py 单元测试 (T6.1). + +覆盖: +- get_connection / ensure_schema 幂等 +- AdminUserRepo CRUD +- authenticate 正确密码 / 错密码 / 不存在用户 / inactive 用户 +- bootstrap_admin 首次创建 + 二次跳过 +""" + +from __future__ import annotations + +import pytest + +from admin.db import ( + AdminUserRepo, + authenticate, + bootstrap_admin, + ensure_schema, + get_connection, +) + + +def test_ensure_schema_idempotent(tmp_path): + """多次 ensure_schema 不报错。""" + db = str(tmp_path / "t.db") + ensure_schema(db) + ensure_schema(db) + ensure_schema(db) + with get_connection(db) as conn: + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='admin_users'" + ).fetchone() + assert row is not None + + +def test_repo_create_and_lookup(tmp_path): + db = str(tmp_path / "t.db") + ensure_schema(db) + repo = AdminUserRepo(db) + + u = repo.create("alice", "p@ss123") + assert u.id > 0 + assert u.username == "alice" + assert u.is_active is True + + found = repo.get_by_username("alice") + assert found is not None + user, password_hash = found + assert user.id == u.id + # 密码 hash 应被 PBKDF2 序列化(salt$hash) + assert "$" in password_hash + assert len(password_hash.split("$")) == 2 + + +def test_repo_duplicate_username_raises(tmp_path): + db = str(tmp_path / "t.db") + ensure_schema(db) + repo = AdminUserRepo(db) + repo.create("bob", "x") + + import sqlite3 as _sq + + with pytest.raises(_sq.IntegrityError): + repo.create("bob", "y") + + +def test_repo_count_and_get_by_id(tmp_path): + db = str(tmp_path / "t.db") + ensure_schema(db) + repo = AdminUserRepo(db) + assert repo.count() == 0 + u = repo.create("c1", "p") + assert repo.count() == 1 + assert repo.get_by_id(u.id) is not None + assert repo.get_by_id(99999) is None + + +def test_authenticate_correct_password(tmp_path): + db = str(tmp_path / "t.db") + ensure_schema(db) + repo = AdminUserRepo(db) + repo.create("dave", "correct-horse") + user = authenticate(repo, "dave", "correct-horse") + assert user is not None + assert user.username == "dave" + # last_login_at 应已更新 + fresh = repo.get_by_id(user.id) + assert fresh is not None + assert fresh.last_login_at is not None + + +def test_authenticate_wrong_password(tmp_path): + db = str(tmp_path / "t.db") + ensure_schema(db) + repo = AdminUserRepo(db) + repo.create("erin", "right") + assert authenticate(repo, "erin", "wrong") is None + + +def test_authenticate_unknown_user(tmp_path): + db = str(tmp_path / "t.db") + ensure_schema(db) + repo = AdminUserRepo(db) + assert authenticate(repo, "ghost", "anything") is None + + +def test_authenticate_inactive_user(tmp_path): + """is_active=0 → authenticate 返回 None。""" + import sqlite3 as _sq + + db = str(tmp_path / "t.db") + ensure_schema(db) + repo = AdminUserRepo(db) + u = repo.create("frank", "p") + with _sq.connect(db) as conn: + conn.execute("UPDATE admin_users SET is_active = 0 WHERE id = ?", (u.id,)) + assert authenticate(repo, "frank", "p") is None + + +def test_bootstrap_admin_creates_then_skips(settings): + """bootstrap_admin 第一次创建,第二次跳过。""" + created1, msg1 = bootstrap_admin(settings) + assert created1 is True + assert "已创建默认管理员" in msg1 + assert "请尽快" in msg1 # 安全提示 + + created2, msg2 = bootstrap_admin(settings) + assert created2 is False + assert "已存在" in msg2 + + repo = AdminUserRepo(settings.db_path) + assert repo.count() == 1 diff --git a/admin/tests/test_errors.py b/admin/tests/test_errors.py new file mode 100644 index 0000000..c8f5de4 --- /dev/null +++ b/admin/tests/test_errors.py @@ -0,0 +1,344 @@ +"""admin.errors 单元测试 (T9.2). + +覆盖: +- 码点结构与反解 (codes.py) +- 注册表查找 + 兜底 (registry.py) +- 响应体契约 (exceptions.py) +- FastAPI handler 集成 (端到端) +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from admin.errors import ( + AUTH_ACCOUNT_DISABLED, + AUTH_INSUFFICIENT_PERMISSION, + AUTH_INVALID_CREDENTIALS, + AUTH_TOKEN_EXPIRED, + AUTH_TOKEN_INVALID, + BIZ_ORDER_NOT_FOUND, + BIZ_RATE_LIMITED, + BusinessError, + DATA_VALIDATION_FAILED, + FALLBACK_CODE, + MESSAGES_ZH_CN, + Message, + SYS_INTERNAL_ERROR, + error_response, + get_message, + is_registered, + register_exception_handler, +) +from admin.errors.codes import ErrorCode + + +# ---------------- codes.py ---------------- + + +class TestErrorCodeStructure: + def test_str_format(self): + assert str(AUTH_INVALID_CREDENTIALS) == "E01101" + assert str(BIZ_ORDER_NOT_FOUND) == "E02001" + assert str(FALLBACK_CODE) == "E05099" + + def test_of_roundtrip(self): + for code in ( + AUTH_INVALID_CREDENTIALS, + BIZ_ORDER_NOT_FOUND, + FALLBACK_CODE, + ): + assert str(ErrorCode.of(str(code))) == str(code) + + def test_of_invalid(self): + with pytest.raises(ValueError): + ErrorCode.of("BAD") + with pytest.raises(ValueError): + ErrorCode.of("E99999") # segment 99 不在 5 个段内 + with pytest.raises(ValueError): + ErrorCode.of("E01A01") # subdomain 必须是 0-5 + + def test_segment_assignment_matches_t91(self): + """T9.1 段分配: 01 用户 / 02 业务 / 03 数据 / 04 第三方 / 05 系统.""" + assert str(AUTH_INVALID_CREDENTIALS).startswith("E01") + assert str(BIZ_ORDER_NOT_FOUND).startswith("E02") + assert str(DATA_VALIDATION_FAILED).startswith("E03") + assert str(SYS_INTERNAL_ERROR).startswith("E05") + + def test_5xx_codes_only_in_05_segment(self): + """T9.1: 5xx 系统错误严禁落到非 05 段 (防兜底掩盖).""" + # 这里所有 SYS_* 都已经在 05 段; 此测试是回归保险, 防后续误归 + assert str(SYS_INTERNAL_ERROR).startswith("E05") + + def test_sequence_bounds(self): + from admin.errors.codes import ErrorCode, ErrorSegment, ErrorSubdomain + + with pytest.raises(ValueError): + ErrorCode( + segment=ErrorSegment.USER, + subdomain=ErrorSubdomain.GENERAL, + sequence=0, # 越界 + ) + with pytest.raises(ValueError): + ErrorCode( + segment=ErrorSegment.USER, + subdomain=ErrorSubdomain.GENERAL, + sequence=100, # 越界 + ) + + +# ---------------- registry.py ---------------- + + +class TestRegistry: + def test_registered_codes_have_message(self): + """所有声明的业务码都必须在注册表里有文案 (FALLBACK_CODE 除外, 它是兜底).""" + from admin.errors import codes as codes_module + + declared = { + name: value + for name, value in vars(codes_module).items() + if isinstance(value, ErrorCode) + } + # FALLBACK_CODE 不入注册表 (它是兜底码本身) + from admin.errors.codes import FALLBACK_CODE as _FB + + for name, ec in declared.items(): + if ec == _FB: + continue + assert is_registered(str(ec)), f"{name} ({ec}) 缺少中文文案" + + def test_get_message_known(self): + msg = get_message(str(AUTH_INVALID_CREDENTIALS)) + assert isinstance(msg, Message) + assert msg.code == str(AUTH_INVALID_CREDENTIALS) + assert msg.message + assert msg.suggestion + assert msg.severity in ("info", "warn", "error") + assert isinstance(msg.retryable, bool) + + def test_get_message_unknown_but_valid_code_returns_fallback(self): + """未注册但格式合法的码点 → 兜底文案 (code 保留以便排查).""" + # E01005 — 段号 01 子域位 0 sequence 05, 格式合法, 未注册 + msg = get_message("E01005") + assert msg.severity == "error" + assert msg.retryable is True + assert msg.code == "E01005" # 保留原码点便于排查 + + def test_get_message_invalid_format_uses_fallback(self): + """格式非法码点 → 兜底文案 (不抛异常, 永不返回 None).""" + msg = get_message("BAD") + assert msg.severity == "error" + # 非法码点也保留在 code 字段以便定位 + assert msg.code == "BAD" + + def test_unsupported_locale_uses_fallback(self): + """当前仅支持 zh-CN, 其它 locale 走兜底.""" + msg = get_message(str(AUTH_INVALID_CREDENTIALS), locale="en-US") + # 走兜底文案 + assert msg.severity == "error" + + def test_message_to_dict_shape(self): + msg = get_message(str(BIZ_RATE_LIMITED)) + d = msg.to_dict() + assert set(d.keys()) == { + "code", + "message", + "suggestion", + "severity", + "retryable", + } + + def test_all_registered_codes_count(self): + """注册表里有 17 个码点 (1 兜底 + 16 业务).""" + # 16 业务: AUTH×5 + BIZ×3 + DATA×3 + THIRD×2 + SYS×3 = 16 + # FALLBACK_CODE 也在注册表 (但函数中它仅作兜底不入注册) + assert len(MESSAGES_ZH_CN) == 16 + + +# ---------------- exceptions.py ---------------- + + +class TestErrorResponse: + def test_basic_body_shape(self): + msg = get_message(str(AUTH_INVALID_CREDENTIALS)) + body = error_response(str(AUTH_INVALID_CREDENTIALS), msg) + assert body["code"] == "E01101" + assert body["severity"] == "warn" + assert body["retryable"] is False + # 无 detail 时不暴露 + assert "detail" not in body + + def test_detail_included_only_when_provided(self): + msg = get_message(str(BIZ_ORDER_NOT_FOUND)) + body = error_response( + str(BIZ_ORDER_NOT_FOUND), + msg, + detail={"order_id": "GKO-1"}, + include_detail=True, + ) + assert body["detail"] == {"order_id": "GKO-1"} + + +class TestBusinessErrorException: + def test_str_returns_code(self): + exc = BusinessError(AUTH_INVALID_CREDENTIALS) + assert str(exc) == "E01101" + + def test_detail_is_optional(self): + exc = BusinessError(AUTH_INVALID_CREDENTIALS) + assert exc.detail is None + exc2 = BusinessError(AUTH_INVALID_CREDENTIALS, detail={"k": 1}) + assert exc2.detail == {"k": 1} + + +# ---------------- FastAPI handler 集成 ---------------- + + +def _build_test_app(): + """构造最小测试应用, 仅用于验证 handler 集成.""" + from fastapi import FastAPI + + from admin.errors import ( + AUTH_ACCOUNT_DISABLED, + AUTH_INVALID_CREDENTIALS, + BusinessError, + ) + + app = FastAPI() + register_exception_handler(app) + + @app.get("/raise-business") + def _raise_business(): + raise BusinessError(AUTH_INVALID_CREDENTIALS) + + @app.get("/raise-business-with-detail") + def _raise_business_detail(): + raise BusinessError(AUTH_ACCOUNT_DISABLED, detail={"user_id": 42}) + + @app.get("/raise-http-legacy") + def _raise_http_legacy(): + # 模拟未升级到 BusinessError 的旧路由 + from fastapi import HTTPException + + raise HTTPException(status_code=403, detail="forbidden by upstream") + + @app.get("/raise-unexpected") + def _raise_unexpected(): + raise RuntimeError("something exploded") + + @app.get("/validation-fail") + def _validation_fail(payload: dict): + # 故意缺必填字段, 触发 RequestValidationError + from pydantic import BaseModel + + class Req(BaseModel): + name: str + age: int + + # 调用方传 {} 时会触发 422 + return Req(**payload) + + return app + + +@pytest.fixture +def test_client(): + return TestClient(_build_test_app(), raise_server_exceptions=False) + + +class TestFastAPIIntegration: + def test_business_error_returns_chinese_message(self, test_client): + resp = test_client.get("/raise-business") + assert resp.status_code == 401 + body = resp.json() + assert body["code"] == "E01101" + assert "用户名" in body["message"] or "密码" in body["message"] + assert body["suggestion"] + assert body["severity"] == "warn" + assert body["retryable"] is False + + def test_business_error_includes_detail_when_provided(self, test_client): + resp = test_client.get("/raise-business-with-detail") + # AUTH_ACCOUNT_DISABLED 默认 403 + assert resp.status_code == 403 + body = resp.json() + assert body["code"] == "E01102" + assert body["detail"] == {"user_id": 42} + + def test_http_exception_legacy_mapped_to_business_shape(self, test_client): + resp = test_client.get("/raise-http-legacy") + # 保留原 HTTP 状态 (handler 不吞), 但响应体是业务码形状 + assert resp.status_code == 403 + body = resp.json() + assert "code" in body + assert "message" in body + assert "suggestion" in body + # detail 透传 + assert "detail" in body + assert body["detail"]["http_status"] == 403 + + def test_unexpected_exception_mapped_to_sys_internal(self, test_client): + resp = test_client.get("/raise-unexpected") + assert resp.status_code == 500 + body = resp.json() + assert body["code"] == str(SYS_INTERNAL_ERROR) + # 不暴露异常类名/堆栈 + assert "RuntimeError" not in str(body) + assert "Traceback" not in str(body) + + def test_validation_error_mapped_to_data_validation(self, test_client): + resp = test_client.get( + "/validation-fail", params={} + ) # 缺请求体, 触发 RequestValidationError + assert resp.status_code == 422 + body = resp.json() + assert body["code"] == str(DATA_VALIDATION_FAILED) + assert body["message"] + assert body["suggestion"] + assert body["detail"]["fields"] + assert body["retryable"] is False + + def test_response_body_is_json(self, test_client): + resp = test_client.get("/raise-business") + assert resp.headers["content-type"].startswith("application/json") + + +# ---------------- 业务码与 HTTP 状态码解耦 (T9.1 约束) ---------------- + + +class TestBusinessCodeHttpDecoupling: + """T9.1 决策: 业务码与 HTTP 状态码解耦. + + 验证同一段内的码点可以映射到不同 HTTP 状态, 不同业务码在同一 HTTP + 状态下也能呈现不同中文文案. + """ + + def test_auth_codes_span_401_and_403(self): + from admin.errors.exceptions import http_status_for + + assert http_status_for(str(AUTH_INVALID_CREDENTIALS)) == 401 + assert http_status_for(str(AUTH_TOKEN_EXPIRED)) == 401 + assert http_status_for(str(AUTH_INSUFFICIENT_PERMISSION)) == 403 + assert http_status_for(str(AUTH_ACCOUNT_DISABLED)) == 403 + + def test_two_codes_same_http_status_have_different_messages(self): + # AUTH_INVALID_CREDENTIALS 与 AUTH_TOKEN_EXPIRED 都是 401, 但文案不同 + m1 = get_message(str(AUTH_INVALID_CREDENTIALS)) + m2 = get_message(str(AUTH_TOKEN_EXPIRED)) + assert m1.message != m2.message + assert m1.code != m2.code + + def test_third_party_codes_are_5xx(self): + """T9.1: 第三方域码点对应 5xx (上游错误归 4xx 段码点).""" + from admin.errors.exceptions import http_status_for + + assert ( + http_status_for(str(AUTH_TOKEN_INVALID)) == 401 + ) # 04 段? 不, 04 是 third party + # E04xxx 都是 5xx + # 这里用具体的第三方码验证 + from admin.errors.codes import THIRD_PARTY_UPSTREAM_ERROR + + assert http_status_for(str(THIRD_PARTY_UPSTREAM_ERROR)) == 502 diff --git a/admin/tests/test_logging.py b/admin/tests/test_logging.py new file mode 100644 index 0000000..b0c1d52 --- /dev/null +++ b/admin/tests/test_logging.py @@ -0,0 +1,280 @@ +"""T9.3 结构化日志测试. + +覆盖: +- JsonLogFormatter: 时间 / 级别 / logger / ctx / exc +- log_event / log_event_exc: 结构化字段注入、异常捕获 +- request context: bind / clear / middleware 隔离 +- FastAPI 端到端: BusinessError 触发 JSON 日志, 含 code/path/method/request_id +""" + +from __future__ import annotations + +import io +import json +import logging +import sys + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from admin.errors import ( + AUTH_INVALID_CREDENTIALS, + BusinessError, + register_exception_handler, +) +from admin.logging_utils import ( + JsonLogFormatter, + bind_request_context, + clear_request_context, + configure_logging, + current_context, + log_event, + log_event_exc, + reset_logging_for_tests, +) + + +class TestJsonLogFormatter: + def test_formats_basic_record_with_ctx(self): + formatter = JsonLogFormatter() + token = bind_request_context(request_id="req_test", path="/api/x", method="GET") + try: + record = logging.LogRecord( + name="admin.errors", + level=logging.WARNING, + pathname=__file__, + lineno=10, + msg="hello %s", + args=("world",), + exc_info=None, + ) + record.ctx = {"code": "E01101", "event": "business_error"} + payload = json.loads(formatter.format(record)) + finally: + clear_request_context(token) + + assert payload["level"] == "warning" + assert payload["logger"] == "admin.errors" + assert payload["msg"] == "hello world" + assert payload["ts"].endswith("Z") + assert payload["ctx"]["code"] == "E01101" + assert payload["ctx"]["event"] == "business_error" + assert payload["ctx"]["request_id"] == "req_test" + assert payload["ctx"]["path"] == "/api/x" + assert payload["ctx"]["method"] == "GET" + + def test_formats_exception(self): + formatter = JsonLogFormatter() + try: + raise RuntimeError("boom") + except RuntimeError: + record = logging.LogRecord( + name="admin.errors", + level=logging.ERROR, + pathname=__file__, + lineno=20, + msg="failed", + args=(), + exc_info=sys.exc_info(), + ) + payload = json.loads(formatter.format(record)) + assert payload["level"] == "error" + assert payload["exc"]["type"] == "RuntimeError" + assert payload["exc"]["message"] == "boom" + assert "RuntimeError: boom" in payload["exc"]["traceback"] + + +class TestContextBinding: + def test_bind_and_clear(self): + assert current_context() == {} + token = bind_request_context(request_id="req_1", path="/a", method="POST") + try: + assert current_context() == { + "request_id": "req_1", + "path": "/a", + "method": "POST", + } + finally: + clear_request_context(token) + assert current_context() == {} + + +class TestLogEvent: + def test_log_event_emits_json_with_ctx(self): + stream = io.StringIO() + reset_logging_for_tests() + configure_logging(level="INFO", fmt="json", stream=stream) + logger = logging.getLogger("admin.errors") + + log_event( + logger, + logging.WARNING, + "business_error", + msg="business error mapped", + code="E01101", + path="/api/auth/login", + method="POST", + status=401, + ) + + line = stream.getvalue().strip() + payload = json.loads(line) + assert payload["level"] == "warning" + assert payload["msg"] == "business error mapped" + assert payload["ctx"]["event"] == "business_error" + assert payload["ctx"]["code"] == "E01101" + assert payload["ctx"]["path"] == "/api/auth/login" + assert payload["ctx"]["method"] == "POST" + assert payload["ctx"]["status"] == 401 + + def test_log_event_rejects_reserved_field(self): + logger = logging.getLogger("admin.errors") + with pytest.raises(ValueError, match="collides with LogRecord builtin"): + log_event(logger, logging.INFO, "bad", name="oops") + + def test_log_event_exc_includes_traceback(self): + stream = io.StringIO() + reset_logging_for_tests() + configure_logging(level="INFO", fmt="json", stream=stream) + logger = logging.getLogger("admin.errors") + + try: + raise ValueError("x") + except ValueError: + log_event_exc( + logger, + logging.ERROR, + "unhandled_exception", + exc_info=sys.exc_info(), + msg="unhandled", + path="/boom", + method="GET", + ) + + payload = json.loads(stream.getvalue().strip()) + assert payload["level"] == "error" + assert payload["ctx"]["event"] == "unhandled_exception" + assert payload["exc"]["type"] == "ValueError" + assert payload["exc"]["message"] == "x" + + def test_large_log_stays_valid_json(self): + stream = io.StringIO() + reset_logging_for_tests() + configure_logging(level="INFO", fmt="json", stream=stream) + logger = logging.getLogger("admin.errors") + + log_event( + logger, + logging.WARNING, + "oversized_payload", + msg="payload too large", + fields=["x" * 12000], + ) + + encoded = stream.getvalue().strip() + payload = json.loads(encoded) + assert payload["msg"] == "payload too large" + assert payload["ctx"]["event"] == "oversized_payload" + assert payload["ctx"].get("truncated") is True + assert len(encoded.encode("utf-8")) <= 8 * 1024 + + def test_huge_message_is_also_capped(self): + stream = io.StringIO() + reset_logging_for_tests() + configure_logging(level="INFO", fmt="json", stream=stream) + logger = logging.getLogger("admin.errors") + + logger.error("m" * 20000) + + encoded = stream.getvalue().strip() + payload = json.loads(encoded) + assert payload["ctx"]["truncated"] is True + assert len(encoded.encode("utf-8")) <= 8 * 1024 + + +class TestFastAPIStructuredIntegration: + def test_business_error_log_contains_request_context(self): + stream = io.StringIO() + reset_logging_for_tests() + configure_logging(level="INFO", fmt="json", stream=stream) + + app = FastAPI() + + from admin.app import request_context_middleware + + app.middleware("http")(request_context_middleware) + + @app.get("/boom") + async def _boom(): + raise BusinessError(AUTH_INVALID_CREDENTIALS) + + register_exception_handler(app) + + with TestClient(app) as client: + resp = client.get("/boom") + + assert resp.status_code == 401 + body = resp.json() + assert body["code"] == "E01101" + + lines = [ + json.loads(line) for line in stream.getvalue().splitlines() if line.strip() + ] + business_logs = [ + line + for line in lines + if line.get("ctx", {}).get("event") == "business_error" + ] + assert business_logs, stream.getvalue() + payload = business_logs[-1] + assert payload["logger"] == "admin.errors" + assert payload["ctx"]["code"] == "E01101" + assert payload["ctx"]["path"] == "/boom" + assert payload["ctx"]["method"] == "GET" + assert payload["ctx"]["status"] == 401 + assert payload["ctx"]["request_id"].startswith("req_") + + def test_validation_error_log_contains_request_context(self): + stream = io.StringIO() + reset_logging_for_tests() + configure_logging(level="INFO", fmt="json", stream=stream) + + app = FastAPI() + + from pydantic import BaseModel + from admin.app import request_context_middleware + + app.middleware("http")(request_context_middleware) + + class Payload(BaseModel): + username: str + + @app.post("/validate") + async def _validate(payload: Payload): + return {"username": payload.username} + + register_exception_handler(app) + + with TestClient(app) as client: + resp = client.post("/validate", json={}) + + assert resp.status_code == 422 + body = resp.json() + assert body["code"] == "E03001" + + lines = [ + json.loads(line) for line in stream.getvalue().splitlines() if line.strip() + ] + validation_logs = [ + line + for line in lines + if line.get("ctx", {}).get("event") == "validation_error" + ] + assert validation_logs, stream.getvalue() + payload = validation_logs[-1] + assert payload["logger"] == "admin.errors" + assert payload["ctx"]["path"] == "/validate" + assert payload["ctx"]["method"] == "POST" + assert payload["ctx"]["request_id"].startswith("req_") + assert payload["ctx"]["code_count"] >= 1 diff --git a/admin/tests/test_password.py b/admin/tests/test_password.py new file mode 100644 index 0000000..713cd75 --- /dev/null +++ b/admin/tests/test_password.py @@ -0,0 +1,38 @@ +"""password.py 单元测试 (T6.1).""" + +from __future__ import annotations + +import pytest + +from admin.password import hash_password, verify_password + + +def test_hash_then_verify_roundtrip(): + h = hash_password("hunter2") + assert h != "hunter2" + assert verify_password("hunter2", h) is True + + +def test_verify_wrong_password_fails(): + h = hash_password("hunter2") + assert verify_password("hunter2-wrong", h) is False + + +def test_hash_is_salted_different_each_time(): + h1 = hash_password("hunter2") + h2 = hash_password("hunter2") + assert h1 != h2 # 不同的 salt → 不同的 hash + # 但都能校验同一个密码 + assert verify_password("hunter2", h1) + assert verify_password("hunter2", h2) + + +def test_hash_empty_password_raises(): + with pytest.raises(ValueError): + hash_password("") + + +def test_verify_handles_malformed_stored(): + assert verify_password("hunter2", "no-separator") is False + assert verify_password("hunter2", "") is False + assert verify_password("hunter2", "garbage$garbage") is False # bad hex diff --git a/admin/tests/test_routes.py b/admin/tests/test_routes.py new file mode 100644 index 0000000..bb200ce --- /dev/null +++ b/admin/tests/test_routes.py @@ -0,0 +1,175 @@ +"""路由层测试 (T6.1). + +覆盖: +- /health 公开 +- /api/auth/login 错凭证 401 + 不区分用户名密码 +- /api/orders 无 token 401 + 有效 token 返回 [] +- /api/orders/{id} 不存在 404 +- /api/stats/orders 鉴权 +- /api/meta 鉴权 + 内容 +""" + +from __future__ import annotations + + +# ---------------- health ---------------- + + +def test_health_public(client): + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["env"] == "dev" + assert body["service"] == "gaokao-admin" + assert body["version"] + + +# ---------------- auth ---------------- + + +def test_login_wrong_password_returns_401(client): + resp = client.post( + "/api/auth/login", + json={"username": "admin", "password": "WRONG"}, + ) + assert resp.status_code == 401 + body = resp.json() + assert body["code"] == "E01101" + # 必须泛化错误信息,避免账户枚举 + assert body["message"] == "用户名或密码不正确" + assert "detail" not in body + + +def test_login_unknown_user_returns_401(client): + resp = client.post( + "/api/auth/login", + json={"username": "ghost", "password": "anything"}, + ) + assert resp.status_code == 401 + + +def test_login_rate_limited_after_repeated_failures(client): + resp = None + for _ in range(5): + resp = client.post( + "/api/auth/login", + json={"username": "admin", "password": "WRONG"}, + ) + + assert resp is not None + assert resp.status_code == 429 + body = resp.json() + assert body["code"] == "E02501" + assert body["message"] == "请求过于频繁" + assert body["detail"]["retry_after_seconds"] >= 1 + + +def test_login_missing_fields_rejected(client): + resp = client.post("/api/auth/login", json={"username": ""}) + assert resp.status_code == 422 # pydantic 校验 + + +# ---------------- orders ---------------- + + +def test_orders_requires_auth(client): + resp = client.get("/api/orders") + assert resp.status_code == 401 + + +def test_orders_returns_empty_list_with_auth(client, auth_headers): + resp = client.get("/api/orders", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_orders_supports_limit_offset(client, auth_headers): + resp = client.get( + "/api/orders", + params={"limit": 10, "offset": 0}, + headers=auth_headers, + ) + assert resp.status_code == 200 + + +def test_orders_rejects_invalid_limit(client, auth_headers): + resp = client.get("/api/orders", params={"limit": 0}, headers=auth_headers) + assert resp.status_code == 422 + + +def test_order_detail_404_for_unknown(client, auth_headers): + resp = client.get("/api/orders/GKO-20260612-XXXX", headers=auth_headers) + assert resp.status_code == 404 + + +def test_order_detail_requires_auth(client): + resp = client.get("/api/orders/anything") + assert resp.status_code == 401 + + +# ---------------- stats ---------------- + + +def test_stats_orders_requires_auth(client): + resp = client.get("/api/stats/orders") + assert resp.status_code == 401 + + +def test_stats_orders_real_shape(client, auth_headers): + """T6.2: /api/stats/orders 接入真实 SQL 聚合。 + + - 字段集沿用 T6.1 stub 形状 (5 字段) + - 移除 _stub 标记 + - 空库时所有计数为 0 + """ + resp = client.get("/api/stats/orders", headers=auth_headers) + assert resp.status_code == 200 + body = resp.json() + assert body["total_orders"] == 0 + assert body["total_revenue_cents"] == 0 + # 6 态全 0 填充 + assert body["by_status"] == { + "pending": 0, + "paid": 0, + "serving": 0, + "delivered": 0, + "completed": 0, + "refunded": 0, + } + assert body["by_source"] == {"xianyu": 0, "wechat": 0, "web": 0, "school": 0} + assert body["by_service_version"] == { + "audit": 0, + "basic": 0, + "standard": 0, + "premium": 0, + } + assert "_stub" not in body + + +# ---------------- meta ---------------- + + +def test_meta_requires_auth(client): + resp = client.get("/api/meta") + assert resp.status_code == 401 + + +def test_meta_full_enums(client, auth_headers): + resp = client.get("/api/meta", headers=auth_headers) + assert resp.status_code == 200 + body = resp.json() + + assert len(body["supported_provinces"]) >= 27 + assert "湖南" in body["supported_provinces"] + + assert set(body["order_statuses"]) == { + "pending", + "paid", + "serving", + "delivered", + "completed", + "refunded", + } + assert set(body["order_sources"]) == {"xianyu", "wechat", "web", "school"} + assert set(body["service_versions"]) == {"audit", "basic", "standard", "premium"} diff --git a/admin/tests/test_routes_cases.py b/admin/tests/test_routes_cases.py new file mode 100644 index 0000000..a64a413 --- /dev/null +++ b/admin/tests/test_routes_cases.py @@ -0,0 +1,146 @@ +"""T6.5 案例管理端点测试。 + +覆盖: +- 鉴权: /api/cases 全部需要 JWT +- CRUD: 列表 / 创建 / 详情 / 更新 / 删除 +- 审核: pending -> approved / rejected +- 分类: success / typical / warning +- 过滤: category / review_status +""" + +from __future__ import annotations + + +def test_cases_requires_auth(client): + resp = client.get("/api/cases") + assert resp.status_code == 401 + + +def test_case_crud_review_and_filters(client, auth_headers): + created = client.post( + "/api/cases", + headers=auth_headers, + json={ + "title": "湖南 620 分逆袭 985", + "category": "success", + "summary": "低于校线预期但通过梯度策略成功录取", + "content": "完整案例内容", + "tags": ["湖南", "985"], + }, + ) + assert created.status_code == 201, created.text + created_body = created.json() + case_id = created_body["id"] + assert created_body["review_status"] == "pending" + assert created_body["category"] == "success" + assert created_body["tags"] == ["湖南", "985"] + + listed = client.get("/api/cases", headers=auth_headers) + assert listed.status_code == 200 + list_body = listed.json() + assert list_body["total"] == 1 + assert list_body["items"][0]["id"] == case_id + assert list_body["items"][0]["review_status"] == "pending" + + detail = client.get(f"/api/cases/{case_id}", headers=auth_headers) + assert detail.status_code == 200 + detail_body = detail.json() + assert detail_body["title"] == "湖南 620 分逆袭 985" + assert detail_body["content"] == "完整案例内容" + assert detail_body["reviewed_at"] is None + + updated = client.patch( + f"/api/cases/{case_id}", + headers=auth_headers, + json={ + "title": "湖南 620 分典型提分案例", + "category": "typical", + "summary": "更新后的摘要", + "content": "更新后的正文", + "tags": ["湖南", "梯度"], + }, + ) + assert updated.status_code == 200, updated.text + updated_body = updated.json() + assert updated_body["title"] == "湖南 620 分典型提分案例" + assert updated_body["category"] == "typical" + assert updated_body["summary"] == "更新后的摘要" + assert updated_body["tags"] == ["湖南", "梯度"] + + approved = client.post( + f"/api/cases/{case_id}/review", + headers=auth_headers, + json={"review_status": "approved", "review_note": "可对外展示"}, + ) + assert approved.status_code == 200, approved.text + approved_body = approved.json() + assert approved_body["review_status"] == "approved" + assert approved_body["review_note"] == "可对外展示" + assert approved_body["reviewer"] == "admin" + assert approved_body["reviewed_at"] + + filtered = client.get( + "/api/cases", + headers=auth_headers, + params={"category": "typical", "review_status": "approved"}, + ) + assert filtered.status_code == 200 + filtered_body = filtered.json() + assert filtered_body["total"] == 1 + assert filtered_body["items"][0]["id"] == case_id + + deleted = client.delete(f"/api/cases/{case_id}", headers=auth_headers) + assert deleted.status_code == 204 + + missing = client.get(f"/api/cases/{case_id}", headers=auth_headers) + assert missing.status_code == 404 + + +def test_case_review_rejected_path(client, auth_headers): + created = client.post( + "/api/cases", + headers=auth_headers, + json={ + "title": "志愿填报警告案例", + "category": "warning", + "summary": "错误示范", + "content": "平行志愿未拉开梯度", + }, + ) + assert created.status_code == 201, created.text + case_id = created.json()["id"] + + reviewed = client.post( + f"/api/cases/{case_id}/review", + headers=auth_headers, + json={"review_status": "rejected", "review_note": "证据不足"}, + ) + assert reviewed.status_code == 200, reviewed.text + body = reviewed.json() + assert body["review_status"] == "rejected" + assert body["review_note"] == "证据不足" + + filtered = client.get( + "/api/cases", + headers=auth_headers, + params={"category": "warning", "review_status": "rejected"}, + ) + assert filtered.status_code == 200 + list_body = filtered.json() + assert list_body["total"] == 1 + assert list_body["items"][0]["category"] == "warning" + + +def test_case_update_unknown_returns_404(client, auth_headers): + resp = client.patch( + "/api/cases/99999", + headers=auth_headers, + json={ + "title": "不存在", + "category": "typical", + "summary": "占位摘要", + "content": "占位正文", + "tags": [], + }, + ) + assert resp.status_code == 404 diff --git a/admin/tests/test_routes_orders.py b/admin/tests/test_routes_orders.py new file mode 100644 index 0000000..b9b9831 --- /dev/null +++ b/admin/tests/test_routes_orders.py @@ -0,0 +1,222 @@ +"""订单管理路由测试 (T6.4). + +覆盖: +- 手工录单 POST /api/orders +- 订单列表 / 详情 +- PATCH 业务字段更新 + 状态流转 +- refunded 退款流转 +- CSV 导出 +""" + +from __future__ import annotations + +import csv +from io import StringIO + +import pytest + +from data.orders.dao import OrdersDAO +from data.orders.models import Order + + +@pytest.fixture(autouse=True) +def _orders_fernet_key(monkeypatch): + monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-admin-orders") + + +def _seed_order(settings, **overrides) -> Order: + payload = { + "id": overrides.pop("id", "GKO-20260612-SEED"), + "source": "web", + "external_id": None, + "service_version": "basic", + "amount_cents": 4900, + "status": "pending", + "customer_name": "张三", + "customer_phone": "13800001234", + "customer_wechat": "zhangsanwx", + "candidate_name": "李同学", + "candidate_id_card": "430102200501011234", + "candidate_province": "湖南", + "candidate_score": 578, + "tags": ["首单"], + "notes": "待跟进", + } + payload.update(overrides) + order = Order(**payload) + with OrdersDAO.connect(settings.orders_db_path) as dao: + return dao.create(order, actor="test", reason="seed") + + +def test_create_order_returns_masked_payload_with_history(client, auth_headers): + resp = client.post( + "/api/orders", + headers=auth_headers, + json={ + "source": "wechat", + "external_id": "WX-001", + "service_version": "standard", + "amount_cents": 9900, + "customer_name": "王家长", + "customer_phone": "13900001111", + "customer_wechat": "parentwx", + "candidate_name": "王同学", + "candidate_id_card": "110101200701011234", + "candidate_province": "北京", + "candidate_score": 640, + "candidate_subjects": ["物理", "化学"], + "notes": "手工补录", + "tags": ["渠道补单"], + }, + ) + + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["action"] == "created" + assert body["order"]["source"] == "wechat" + assert body["order"]["status"] == "pending" + assert body["order"]["customer_phone"] == "139****1111" + assert body["order"]["candidate_id_card"] == "110101********1234" + assert "customer_phone_hash" not in body["order"] + assert body["history"][0]["from_status"] is None + assert body["history"][0]["to_status"] == "pending" + + +def test_list_and_detail_return_real_orders_with_masking( + client, auth_headers, settings +): + created = _seed_order(settings, id="GKO-20260612-LIST") + + list_resp = client.get( + "/api/orders", + headers=auth_headers, + params={"status": "pending", "limit": 10, "offset": 0}, + ) + assert list_resp.status_code == 200, list_resp.text + payload = list_resp.json() + assert len(payload) == 1 + assert payload[0]["id"] == created.id + assert payload[0]["customer_phone"] == "138****1234" + + 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"]["id"] == created.id + assert detail["order"]["customer_wechat"] == "zh******wx" + assert detail["history"][0]["to_status"] == "pending" + assert set(detail["available_next_statuses"]) == {"paid", "refunded"} + + +def test_patch_updates_business_fields_and_status_transition( + client, auth_headers, settings +): + created = _seed_order(settings, id="GKO-20260612-PATCH") + + resp = client.patch( + f"/api/orders/{created.id}", + headers=auth_headers, + json={ + "updates": { + "assigned_consultant": "consultant-a", + "notes": "已联系家长", + "tags": ["已跟进", "VIP"], + }, + "to_status": "paid", + "reason": "manual_pay", + }, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["order"]["assigned_consultant"] == "consultant-a" + assert body["order"]["notes"] == "已联系家长" + assert body["order"]["tags"] == ["已跟进", "VIP"] + assert body["order"]["status"] == "paid" + assert body["order"]["paid_at"] is not None + assert [item["to_status"] for item in body["history"]] == ["pending", "paid"] + + +def test_patch_refund_flow_appends_history(client, auth_headers, settings): + created = _seed_order(settings, id="GKO-20260612-REFUND") + with OrdersDAO.connect(settings.orders_db_path) as dao: + dao.transition_status(created.id, "paid", actor="test", reason="seed_pay") + + resp = client.patch( + f"/api/orders/{created.id}", + headers=auth_headers, + json={"to_status": "refunded", "reason": "manual_refund"}, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["order"]["status"] == "refunded" + assert [item["to_status"] for item in body["history"]] == [ + "pending", + "paid", + "refunded", + ] + assert body["available_next_statuses"] == [] + + +def test_patch_invalid_transition_returns_conflict(client, auth_headers, settings): + created = _seed_order(settings, id="GKO-20260612-CONFLICT") + + resp = client.patch( + f"/api/orders/{created.id}", + headers=auth_headers, + json={"to_status": "completed", "reason": "skip_steps"}, + ) + + assert resp.status_code == 409 + body = resp.json() + assert body["code"] == "E02301" + + +def test_export_orders_csv_returns_masked_rows(client, auth_headers, settings): + _seed_order( + settings, + id="GKO-20260612-CSV", + source="school", + service_version="premium", + amount_cents=19900, + ) + + resp = client.get( + "/api/orders/export", + headers=auth_headers, + params={"status": "pending", "source": "school"}, + ) + + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"].startswith("text/csv") + assert "attachment; filename=" in resp.headers["content-disposition"] + + rows = list(csv.DictReader(StringIO(resp.text))) + assert len(rows) == 1 + assert rows[0]["id"] == "GKO-20260612-CSV" + assert rows[0]["customer_phone"] == "138****1234" + assert rows[0]["candidate_id_card"] == "430102********1234" + assert rows[0]["status"] == "pending" + + +def test_export_orders_csv_neutralizes_formula_injection_values( + client, auth_headers, settings +): + _seed_order( + settings, + id="GKO-20260612-CSV-FORMULA", + customer_name="=cmd|' /C calc'!A0", + candidate_name="+SUM(1,2)", + notes="@SUM(A1:A2)", + external_id="-10+20", + ) + + resp = client.get("/api/orders/export", headers=auth_headers) + + assert resp.status_code == 200, resp.text + rows = list(csv.DictReader(StringIO(resp.text))) + assert len(rows) == 1 + assert not rows[0]["customer_name"].startswith(("=", "+", "-", "@")) + assert not rows[0]["candidate_name"].startswith(("=", "+", "-", "@")) + assert rows[0]["notes"] == "'@SUM(A1:A2)" + assert rows[0]["external_id"] == "'-10+20" diff --git a/admin/tests/test_routes_stats_dashboard.py b/admin/tests/test_routes_stats_dashboard.py new file mode 100644 index 0000000..76a7517 --- /dev/null +++ b/admin/tests/test_routes_stats_dashboard.py @@ -0,0 +1,461 @@ +"""T6.2 仪表盘端点测试。 + +覆盖目标 +-------- + +- ``GET /api/stats/dashboard`` 鉴权 (401 / 200) +- 鉴权通过后返回的 payload 形状稳定 +- 空库时: + - summary 全 0 + - by_status / by_source / by_service_version 完整 0 填充 + - trends.today = 1 点, 7d = 7 点, 30d = 30 点, 全部按日期升序 + - generated_at 是合法 ISO8601 UTC +- 真实写入订单后: + - summary.total_orders / total_revenue_cents / orders_7d / 趋势 累加正确 + - 收入口径 = 排除 pending 与 refunded + - 窗口边界 (今日 / 7d / 30d) 切分正确 + - 趋势 0 填充 : 范围内无订单的日也返回 0 点 +- 业务隔离 : 订单 DB 与 admin DB 是分开的, admin_users 计数独立 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List + + +# --------------------------------------------------------------------------- +# 工具 +# --------------------------------------------------------------------------- + + +def _insert_order( + db_path: str, + *, + order_id: str, + status: str, + source: str = "xianyu", + service_version: str = "basic", + amount_cents: int = 10000, + created_at: str, +) -> None: + """绕过 OrdersDAO 直接往 orders 表塞测试行 (含 status / amount / created_at)。 + + orders 表要求必填字段 ; 测试只关心统计字段,其他可空。 + """ + from admin.db import get_connection + + with get_connection(db_path) as conn: + conn.execute( + """ + INSERT INTO orders( + id, source, external_id, service_version, amount_cents, + status, status_updated_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + order_id, + source, + f"ext-{order_id}", + service_version, + int(amount_cents), + status, + created_at, + created_at, + ), + ) + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _iso_at(d: datetime) -> str: + return d.replace(microsecond=0).isoformat() + + +# --------------------------------------------------------------------------- +# 鉴权 +# --------------------------------------------------------------------------- + + +def test_dashboard_requires_auth(client): + resp = client.get("/api/stats/dashboard") + assert resp.status_code == 401 + + +def test_dashboard_with_token_returns_200(client, auth_headers): + resp = client.get("/api/stats/dashboard", headers=auth_headers) + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# 形状契约 (空库) +# --------------------------------------------------------------------------- + + +def test_dashboard_empty_db_shape(client, auth_headers): + """空库时返回完整骨架 — 前端可直接渲染空状态。""" + resp = client.get("/api/stats/dashboard", headers=auth_headers) + body = resp.json() + + # summary 9 字段 + assert set(body["summary"].keys()) == { + "total_orders", + "total_revenue_cents", + "total_users", + "orders_today", + "orders_7d", + "orders_30d", + "revenue_today_cents", + "revenue_7d_cents", + "revenue_30d_cents", + } + # 订单 / 收入 / 窗口切片都应为 0;total_users 由 bootstrap_admin 决定, + # 这里只要求 >= 1 (conftest 启用了 bootstrap,默认管理员已建) + for k, v in body["summary"].items(): + if k == "total_users": + assert v >= 1, k + else: + assert isinstance(v, int) + assert v == 0, k + + # 6 态 / 4 来源 / 4 版本 — 完整 0 填充 + assert body["by_status"] == { + "pending": 0, + "paid": 0, + "serving": 0, + "delivered": 0, + "completed": 0, + "refunded": 0, + } + assert body["by_source"] == {"xianyu": 0, "wechat": 0, "web": 0, "school": 0} + assert body["by_service_version"] == { + "audit": 0, + "basic": 0, + "standard": 0, + "premium": 0, + } + + # 趋势: today=1, 7d=7, 30d=30 + assert len(body["trends"]["today"]) == 1 + assert len(body["trends"]["7d"]) == 7 + assert len(body["trends"]["30d"]) == 30 + + # generated_at 是 ISO8601 UTC + ts = body["generated_at"] + parsed = datetime.fromisoformat(ts) + assert parsed.tzinfo is not None + assert parsed.utcoffset() is not None + assert parsed.utcoffset().total_seconds() == 0 + + +def test_dashboard_trends_strictly_ascending(client, auth_headers): + """趋势序列按日期严格升序。""" + resp = client.get("/api/stats/dashboard", headers=auth_headers) + body = resp.json() + + for key, expected_len in (("today", 1), ("7d", 7), ("30d", 30)): + points = body["trends"][key] + assert len(points) == expected_len, key + dates = [p["date"] for p in points] + assert dates == sorted(dates), f"{key} not ascending: {dates}" + # 间隔必须正好 1 天 (含 today=1 也只一个点) + for prev, cur in zip(dates, dates[1:]): + d_prev = datetime.fromisoformat(prev).date() + d_cur = datetime.fromisoformat(cur).date() + assert (d_cur - d_prev).days == 1, f"{key} gap: {d_prev} → {d_cur}" + + +def test_dashboard_trends_zero_filled_point_shape(client, auth_headers): + """0 填充点也必须有完整三字段 (date/orders/revenue_cents)。""" + resp = client.get("/api/stats/dashboard", headers=auth_headers) + body = resp.json() + for key in ("today", "7d", "30d"): + for p in body["trends"][key]: + assert set(p.keys()) == {"date", "orders", "revenue_cents"}, (key, p) + assert p["orders"] == 0 + assert p["revenue_cents"] == 0 + + +# --------------------------------------------------------------------------- +# 真实数据 +# --------------------------------------------------------------------------- + + +def _seed_orders(db_path: str, now: datetime) -> List[Dict[str, Any]]: + """构造一组覆盖各种状态的订单,日期分布在 today / 7d 内 / 30d 内 / 30d 外。 + + Returns: + 写入的订单信息列表 (用于断言) + """ + today_mid = now.replace(hour=12, minute=0, second=0) + within_7d = now - timedelta(days=3) # 4 天前 + within_30d = now - timedelta(days=15) # 15 天前 + outside_30d = now - timedelta(days=45) # 45 天前 — 应被 30d 窗口排除 + + seeds: List[Dict[str, Any]] = [ + # today 一笔 paid + dict( + order_id="O-today-1", + status="paid", + amount_cents=20000, + created_at=_iso_at(today_mid), + source="xianyu", + ), + # 4 天前 serving + dict( + order_id="O-7d-1", + status="serving", + amount_cents=50000, + created_at=_iso_at(within_7d), + source="wechat", + ), + # 15 天前 completed + dict( + order_id="O-30d-1", + status="completed", + amount_cents=30000, + created_at=_iso_at(within_30d), + source="web", + ), + # 45 天前 (应被 30d 窗口排除) pending + dict( + order_id="O-out-1", + status="pending", + amount_cents=99999, + created_at=_iso_at(outside_30d), + source="xianyu", + ), + # today pending (应不计入收入) + dict( + order_id="O-pending-today", + status="pending", + amount_cents=88888, + created_at=_iso_at(today_mid + timedelta(hours=1)), + source="school", + ), + # today refunded (应不计入收入) + dict( + order_id="O-refunded-today", + status="refunded", + amount_cents=77777, + created_at=_iso_at(today_mid + timedelta(hours=2)), + source="xianyu", + ), + ] + for s in seeds: + _insert_order( + db_path, + order_id=s["order_id"], + status=s["status"], + amount_cents=s["amount_cents"], + created_at=s["created_at"], + source=s["source"], + ) + return seeds + + +def test_dashboard_summary_excludes_30d_outside_orders(client, auth_headers, settings): + """45 天前的订单不在 30d 窗口内,但应计入 total_orders / total_revenue_cents (paid+ 的话)。""" + db = settings.orders_db_path + _seed_orders(db, _now_utc()) + + resp = client.get("/api/stats/dashboard", headers=auth_headers) + body = resp.json() + summary = body["summary"] + + # 总订单:全部 6 笔 + assert summary["total_orders"] == 6 + + # 收入:仅 paid(2w) + serving(5w) + completed(3w) = 10w + # (pending / refunded 不计入) + assert summary["total_revenue_cents"] == 20000 + 50000 + 30000 + + # today orders: O-today-1(paid) + O-pending-today(pending) + O-refunded-today(refunded) = 3 + assert summary["orders_today"] == 3 + # today revenue: 仅 O-today-1(20000),pending/refunded 排除 + assert summary["revenue_today_cents"] == 20000 + + # 7d: today(3) + within_7d(1, serving) = 4 + assert summary["orders_7d"] == 4 + assert summary["revenue_7d_cents"] == 20000 + 50000 + + # 30d: 7d(4) + within_30d(1, completed) = 5 (45 天前被排除) + assert summary["orders_30d"] == 5 + assert summary["revenue_30d_cents"] == 20000 + 50000 + 30000 + + # 至少 1 个管理员 (bootstrap admin) — 这里具体值不重要,但必须 >= 1 + assert summary["total_users"] >= 1 + + +def test_dashboard_by_status_counts_seeded_orders(client, auth_headers, settings): + db = settings.orders_db_path + _seed_orders(db, _now_utc()) + + resp = client.get("/api/stats/dashboard", headers=auth_headers) + body = resp.json() + + # seeds: paid(1) + serving(1) + completed(1) + pending(2) + refunded(1) = 6 + assert body["by_status"]["paid"] == 1 + assert body["by_status"]["serving"] == 1 + assert body["by_status"]["completed"] == 1 + assert body["by_status"]["pending"] == 2 # today pending + 45d pending + assert body["by_status"]["refunded"] == 1 + assert body["by_status"]["delivered"] == 0 + + +def test_dashboard_by_source_and_service_version(client, auth_headers, settings): + db = settings.orders_db_path + _insert_order( + db, + order_id="A", + status="paid", + source="xianyu", + service_version="basic", + amount_cents=1000, + created_at=_iso_at(_now_utc()), + ) + _insert_order( + db, + order_id="B", + status="paid", + source="xianyu", + service_version="standard", + amount_cents=1000, + created_at=_iso_at(_now_utc()), + ) + _insert_order( + db, + order_id="C", + status="paid", + source="wechat", + service_version="premium", + amount_cents=1000, + created_at=_iso_at(_now_utc()), + ) + + resp = client.get("/api/stats/dashboard", headers=auth_headers) + body = resp.json() + + assert body["by_source"]["xianyu"] == 2 + assert body["by_source"]["wechat"] == 1 + assert body["by_source"]["web"] == 0 + assert body["by_source"]["school"] == 0 + + assert body["by_service_version"]["basic"] == 1 + assert body["by_service_version"]["standard"] == 1 + assert body["by_service_version"]["premium"] == 1 + assert body["by_service_version"]["audit"] == 0 + + +def test_dashboard_trends_today_only_contains_today(client, auth_headers, settings): + """今日 trend 只包含今天一天的聚合,值与 summary.revenue_today_cents 一致。""" + db = settings.orders_db_path + now = _now_utc() + today_iso = now.date().isoformat() + # 2 笔 today paid + _insert_order( + db, + order_id="T1", + status="paid", + amount_cents=1100, + created_at=_iso_at(now.replace(hour=10)), + ) + _insert_order( + db, + order_id="T2", + status="paid", + amount_cents=2200, + created_at=_iso_at(now.replace(hour=20)), + ) + # 1 笔 5 天前 paid (不在 today 内) + five_ago = (now - timedelta(days=5)).replace(hour=10) + _insert_order( + db, + order_id="T3", + status="paid", + amount_cents=99999, + created_at=_iso_at(five_ago), + ) + + resp = client.get("/api/stats/dashboard", headers=auth_headers) + body = resp.json() + today_points = body["trends"]["today"] + assert len(today_points) == 1 + assert today_points[0]["date"] == today_iso + assert today_points[0]["orders"] == 2 + assert today_points[0]["revenue_cents"] == 3300 # 1100 + 2200 (T3 排除) + + # 7d 应包含 T3 + seven_total_orders = sum(p["orders"] for p in body["trends"]["7d"]) + seven_total_revenue = sum(p["revenue_cents"] for p in body["trends"]["7d"]) + assert seven_total_orders == 3 + assert seven_total_revenue == 1100 + 2200 + 99999 + + +def test_dashboard_trends_zero_fills_gaps(client, auth_headers, settings): + """窗口内无订单的日也要返回 0 点 (前端不会拿到空洞数组)。""" + db = settings.orders_db_path + now = _now_utc() + five_ago = (now - timedelta(days=5)).replace(hour=10) + _insert_order( + db, + order_id="Lone", + status="paid", + amount_cents=500, + created_at=_iso_at(five_ago), + ) + + resp = client.get("/api/stats/dashboard", headers=auth_headers) + body = resp.json() + seven = body["trends"]["7d"] + assert len(seven) == 7 + + # 5 天前那一笔: 恰好 1 个点是 {orders: 1, revenue_cents: 500} + lone_points = [p for p in seven if p["orders"] > 0] + assert len(lone_points) == 1 + assert lone_points[0]["orders"] == 1 + assert lone_points[0]["revenue_cents"] == 500 + + # 其余 6 个点全 0 + zero_points = [p for p in seven if p["orders"] == 0] + assert len(zero_points) == 6 + for p in zero_points: + assert p["revenue_cents"] == 0 + + +# --------------------------------------------------------------------------- +# /api/stats/orders 兼容老契约 +# --------------------------------------------------------------------------- + + +def test_stats_orders_real_shape_full_fields(client, auth_headers, settings): + """``/api/stats/orders`` 老端点 5 字段契约,字段名不变,数据真实。""" + db = settings.orders_db_path + now = _now_utc() + _insert_order( + db, + order_id="legacy-1", + status="paid", + amount_cents=12345, + created_at=_iso_at(now), + source="xianyu", + service_version="basic", + ) + + resp = client.get("/api/stats/orders", headers=auth_headers) + assert resp.status_code == 200 + body = resp.json() + + assert body["total_orders"] == 1 + assert body["total_revenue_cents"] == 12345 + assert body["by_status"]["paid"] == 1 + assert body["by_source"]["xianyu"] == 1 + assert body["by_service_version"]["basic"] == 1 + # 旧契约: 不应有 dashboard 专属字段 + assert "summary" not in body + assert "trends" not in body + assert "total_users" not in body + # _stub 标记已移除 + assert "_stub" not in body diff --git a/admin/tests/test_routes_users.py b/admin/tests/test_routes_users.py new file mode 100644 index 0000000..12e789c --- /dev/null +++ b/admin/tests/test_routes_users.py @@ -0,0 +1,157 @@ +"""T6.3 用户管理端点测试。 + +覆盖: +- 鉴权: /api/admin/users 与 /api/admin/users/{user_key} 401 +- 列表: 按用户聚合、分页骨架、遮罩展示 +- 搜索: 支持手机号 / 姓名关键字 +- 详情: 返回该用户的订单明细,且敏感字段保持遮罩 +- 未命中: 404 +""" + +from __future__ import annotations + +import os +from datetime import datetime, timedelta, timezone + +from data.orders.dao import OrdersDAO +from data.orders.models import Order, generate_order_id + + +os.environ.setdefault("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-admin-users") + + +def _iso_at(dt: datetime) -> str: + return dt.replace(microsecond=0).isoformat() + + +def _seed_users(db_path: str) -> None: + now = datetime.now(timezone.utc).replace(microsecond=0, hour=12, minute=0, second=0) + with OrdersDAO.connect(db_path) as dao: + dao.create( + Order( + id=generate_order_id(), + source="web", + service_version="basic", + amount_cents=9900, + status="pending", + customer_name="李明", + customer_phone="13800001234", + customer_wechat="wx-li", + candidate_name="李小明", + candidate_id_card="430102200501011234", + candidate_province="湖南", + created_at=_iso_at(now - timedelta(days=1)), + ) + ) + dao.create( + Order( + id=generate_order_id(), + source="wechat", + service_version="standard", + amount_cents=12900, + status="paid", + customer_name="李明", + customer_phone="13800001234", + customer_wechat="wx-li", + candidate_name="李小明", + candidate_id_card="430102200501011234", + candidate_province="湖南", + created_at=_iso_at(now), + ) + ) + dao.create( + Order( + id=generate_order_id(), + source="xianyu", + service_version="premium", + amount_cents=15900, + status="serving", + customer_name="王芳", + customer_phone="13911112222", + customer_wechat="wx-wang", + candidate_name="王小芳", + candidate_id_card="430102200601019876", + candidate_province="浙江", + created_at=_iso_at(now - timedelta(days=2)), + ) + ) + + +def test_users_requires_auth(client): + resp = client.get("/api/admin/users") + assert resp.status_code == 401 + + +def test_users_list_groups_and_masks_pii(client, auth_headers, settings): + _seed_users(settings.orders_db_path) + + resp = client.get("/api/admin/users", headers=auth_headers) + assert resp.status_code == 200 + body = resp.json() + + assert body["total"] == 2 + assert body["limit"] == 50 + assert body["offset"] == 0 + assert body["query"] is None + assert len(body["items"]) == 2 + + li = next(item for item in body["items"] if item["customer_phone"] == "138****1234") + assert li["customer_name"] == "李*" + assert li["customer_wechat"] == "wx*li" + assert li["candidate_name"] == "李*明" + assert li["order_count"] == 2 + assert li["latest_status"] == "paid" + assert li["total_amount_cents"] == 22800 + assert li["latest_order_at"] + + +def test_users_search_by_phone_and_name(client, auth_headers, settings): + _seed_users(settings.orders_db_path) + + by_phone = client.get( + "/api/admin/users", + params={"q": "13800001234"}, + headers=auth_headers, + ) + assert by_phone.status_code == 200 + body = by_phone.json() + assert body["total"] == 1 + assert len(body["items"]) == 1 + assert body["items"][0]["customer_phone"] == "138****1234" + + by_name = client.get( + "/api/admin/users", + params={"q": "王芳"}, + headers=auth_headers, + ) + assert by_name.status_code == 200 + body = by_name.json() + assert body["total"] == 1 + assert body["items"][0]["customer_name"] == "王*" + + +def test_user_detail_returns_masked_orders(client, auth_headers, settings): + _seed_users(settings.orders_db_path) + + listed = client.get("/api/admin/users", headers=auth_headers) + user_key = next( + item["user_key"] for item in listed.json()["items"] if item["order_count"] == 2 + ) + + resp = client.get(f"/api/admin/users/{user_key}", headers=auth_headers) + assert resp.status_code == 200 + body = resp.json() + + assert body["user_key"] == user_key + assert body["order_count"] == 2 + assert len(body["orders"]) == 2 + assert all(order["customer_phone"] == "138****1234" for order in body["orders"]) + assert all(order["customer_wechat"] == "wx*li" for order in body["orders"]) + assert all( + order["candidate_id_card"] == "430102********1234" for order in body["orders"] + ) + + +def test_user_detail_404_for_unknown(client, auth_headers): + resp = client.get("/api/admin/users/unknown-key", headers=auth_headers) + assert resp.status_code == 404 diff --git a/admin/tests/test_share_ui.py b/admin/tests/test_share_ui.py new file mode 100644 index 0000000..d5f8f7d --- /dev/null +++ b/admin/tests/test_share_ui.py @@ -0,0 +1,117 @@ +"""T7.5 分享页 WebUI 端到端测试。""" + +from __future__ import annotations + +import json +from pathlib import Path + +from data.share.short_link import ShortLinkService + + +SAMPLE_REPORT = { + "report_id": "R-2026-001", + "title": "578分 湖南 志愿方案", + "summary": "冲稳保 45 志愿,适合财经/计算机方向。", + "candidate_name": "李明", + "score": 578, + "rank": 12345, + "year": 2026, + "province": "湖南", + "recommendations": [ + {"school": "江西财经大学", "major": "会计学", "prob": 0.35}, + {"school": "长沙理工大学", "major": "计算机科学与技术", "prob": 0.52}, + {"school": "湘潭大学", "major": "金融学", "prob": 0.61}, + ], + "volunteers": [ + {"group": 1, "school": "江西财经大学", "majors": ["会计学", "财务管理"]} + ], +} + + +def _write_report(report_dir: str, report_id: str, payload: dict) -> None: + path = Path(report_dir) + path.mkdir(parents=True, exist_ok=True) + (path / f"{report_id}.json").write_text( + json.dumps(payload, ensure_ascii=False), encoding="utf-8" + ) + + +def _create_link(settings, **kwargs): + svc = ShortLinkService(db_path=settings.share_db_path) + return svc.create(**kwargs) + + +def test_share_page_comment_permission_renders_mobile_html(client, settings): + _write_report(settings.share_report_dir, SAMPLE_REPORT["report_id"], SAMPLE_REPORT) + link = _create_link( + settings, report_id=SAMPLE_REPORT["report_id"], permission="comment" + ) + + resp = client.get(f"/s/{link.code}") + + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + body = resp.text + assert ' dict[str, Any]: + return self.order.to_dict(decrypt_sensitive="mask") + + +@dataclass(frozen=True) +class UserSummary: + user_key: str + customer_name: Optional[str] + customer_phone: Optional[str] + customer_wechat: Optional[str] + candidate_name: Optional[str] + candidate_province: Optional[str] + order_count: int + total_amount_cents: int + latest_order_at: Optional[str] + latest_status: Optional[str] + + def to_dict(self) -> dict[str, Any]: + return { + "user_key": self.user_key, + "customer_name": self.customer_name, + "customer_phone": self.customer_phone, + "customer_wechat": self.customer_wechat, + "candidate_name": self.candidate_name, + "candidate_province": self.candidate_province, + "order_count": int(self.order_count), + "total_amount_cents": int(self.total_amount_cents), + "latest_order_at": self.latest_order_at, + "latest_status": self.latest_status, + } + + +@dataclass(frozen=True) +class UserDetail(UserSummary): + orders: list[dict[str, Any]] + + def to_dict(self) -> dict[str, Any]: + data = super().to_dict() + data["orders"] = list(self.orders) + return data + + +_PHONE_SPLIT_RE = re.compile(r"[\s\-+()]") +_NON_DIGIT_RE = re.compile(r"\D+") + + +def _normalize_phone(value: Optional[str]) -> str: + if value is None: + return "" + s = _PHONE_SPLIT_RE.sub("", value.strip()) + s = _NON_DIGIT_RE.sub("", s) + if len(s) > 11 and s.startswith("86"): + s = s[2:] + return s + + +def _wechat_fingerprint(value: Optional[str]) -> str: + if value is None: + return "" + s = value.strip().lower() + if not s: + return "" + return hashlib.sha256(s.encode("utf-8")).hexdigest() + + +def _user_key(order: Order) -> str: + phone = _normalize_phone(order.customer_phone) + if phone: + return f"phone:{order.customer_phone_hash or phone}" + wechat = _wechat_fingerprint(order.customer_wechat) + if wechat: + return f"wechat:{wechat}" + return f"order:{order.id}" + + +def _load_orders(orders_db_path: str) -> list[Order]: + with get_connection(orders_db_path) as conn: + rows = conn.execute( + "SELECT * FROM orders ORDER BY created_at DESC, id DESC" + ).fetchall() + return [Order.from_db_row(dict(row)) for row in rows] + + +def _matches_query(order: Order, query: str) -> bool: + needle = query.strip().lower() + if not needle: + return True + phone = _normalize_phone(query) + if phone: + if _normalize_phone(order.customer_phone) == phone: + return True + if ( + order.customer_phone_hash + and order.customer_phone_hash + == hash_for_index(phone) + ): + return True + haystack = [ + order.id, + order.external_id, + order.source, + order.service_version, + order.status, + order.customer_name, + order.customer_phone, + order.customer_wechat, + order.candidate_name, + order.candidate_province, + order.candidate_interests, + order.candidate_strong_subjects, + order.candidate_weak_subjects, + order.candidate_family, + order.assigned_consultant, + order.notes, + ] + return any(needle in (value or "").lower() for value in haystack) + + +def _group_orders( + orders: list[Order], query: Optional[str] = None +) -> list[tuple[str, list[Order]]]: + groups: dict[str, list[Order]] = {} + order_keys: list[str] = [] + for order in orders: + if query and not _matches_query(order, query): + continue + key = _user_key(order) + if key not in groups: + groups[key] = [] + order_keys.append(key) + groups[key].append(order) + return [(key, groups[key]) for key in order_keys] + + +def _build_summary(user_key: str, orders: list[Order]) -> UserSummary: + latest = orders[0] + total_amount_cents = sum(int(o.amount_cents or 0) for o in orders) + masked = latest.to_dict(decrypt_sensitive="mask") + return UserSummary( + user_key=user_key, + customer_name=masked.get("customer_name"), + customer_phone=masked.get("customer_phone"), + customer_wechat=masked.get("customer_wechat"), + candidate_name=masked.get("candidate_name"), + candidate_province=masked.get("candidate_province"), + order_count=len(orders), + total_amount_cents=total_amount_cents, + latest_order_at=latest.created_at, + latest_status=latest.status, + ) + + +def _build_detail(user_key: str, orders: list[Order]) -> UserDetail: + summary = _build_summary(user_key, orders) + return UserDetail( + **summary.__dict__, + orders=[UserOrderRecord(order=o).to_dict() for o in orders], + ) + + +def _paginate_groups( + groups: list[tuple[str, list[Order]]], + *, + limit: int, + offset: int, +) -> list[tuple[str, list[Order]]]: + return groups[offset : offset + limit] + + +def build_user_list_payload( + orders_db_path: str, + *, + query: Optional[str] = None, + limit: int = _USER_LIST_DEFAULT_LIMIT, + offset: int = 0, +) -> dict[str, Any]: + """返回用户列表 payload。""" + if not (1 <= limit <= _USER_LIST_MAX_LIMIT): + raise ValueError(f"limit 越界 (1..{_USER_LIST_MAX_LIMIT}): {limit}") + if offset < 0: + raise ValueError(f"offset 不能为负: {offset}") + + orders = _load_orders(orders_db_path) + groups = _group_orders(orders, query=query) + total = len(groups) + page = _paginate_groups(groups, limit=limit, offset=offset) + items = [_build_summary(key, grouped).to_dict() for key, grouped in page] + return { + "total": total, + "limit": limit, + "offset": offset, + "query": query, + "items": items, + } + + +def build_user_detail_payload(orders_db_path: str, user_key: str) -> dict[str, Any]: + """返回单个用户详情 payload。""" + orders = _load_orders(orders_db_path) + for key, grouped in _group_orders(orders): + if key == user_key: + return _build_detail(key, grouped).to_dict() + raise LookupError(user_key) diff --git a/data/cases/__init__.py b/data/cases/__init__.py new file mode 100644 index 0000000..3cc89ac --- /dev/null +++ b/data/cases/__init__.py @@ -0,0 +1,14 @@ +"""案例数据模块 (T6.5).""" + +from data.cases.dao import CaseNotFound, CasesDAO +from data.cases.models import CaseCategory, CaseRecord, CaseReviewStatus +from data.cases.schema import apply_schema + +__all__ = [ + "CaseCategory", + "CaseNotFound", + "CaseRecord", + "CaseReviewStatus", + "CasesDAO", + "apply_schema", +] diff --git a/data/cases/dao.py b/data/cases/dao.py new file mode 100644 index 0000000..8d67eaf --- /dev/null +++ b/data/cases/dao.py @@ -0,0 +1,165 @@ +"""T6.5 案例管理 DAO。""" + +from __future__ import annotations + +import json +import sqlite3 +from contextlib import contextmanager +from typing import Iterator, cast + +from admin.db import get_connection, utc_now_iso +from data.cases.models import CaseCategory, CaseRecord, CaseReviewStatus + + +class CaseNotFound(LookupError): + """案例不存在。""" + + +class CasesDAO: + def __init__(self, conn: sqlite3.Connection): + self.conn = conn + + @classmethod + @contextmanager + def connect(cls, db_path: str) -> Iterator["CasesDAO"]: + conn = get_connection(db_path) + try: + yield cls(conn) + finally: + conn.close() + + def list( + self, + *, + category: str | None = None, + review_status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[CaseRecord], int]: + where = [] + params: list[object] = [] + if category is not None: + where.append("category = ?") + params.append(category) + if review_status is not None: + where.append("review_status = ?") + params.append(review_status) + where_sql = f" WHERE {' AND '.join(where)}" if where else "" + + total_row = self.conn.execute( + f"SELECT COUNT(*) AS n FROM cases{where_sql}", params + ).fetchone() + rows = self.conn.execute( + f"SELECT * FROM cases{where_sql} ORDER BY id DESC LIMIT ? OFFSET ?", + [*params, limit, offset], + ).fetchall() + total = int(total_row["n"] if total_row is not None else 0) + return [self._from_row(row) for row in rows], total + + def get(self, case_id: int) -> CaseRecord: + row = self.conn.execute( + "SELECT * FROM cases WHERE id = ?", (case_id,) + ).fetchone() + if row is None: + raise CaseNotFound(case_id) + return self._from_row(row) + + def create(self, record: CaseRecord) -> CaseRecord: + now = utc_now_iso() + cur = self.conn.execute( + """ + INSERT INTO cases( + title, category, summary, content, + review_status, review_note, reviewer, reviewed_at, + created_at, updated_at, tags + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + record.title, + record.category, + record.summary, + record.content, + record.review_status, + record.review_note, + record.reviewer, + record.reviewed_at, + now, + now, + json.dumps(record.tags, ensure_ascii=False), + ), + ) + case_id = int(cur.lastrowid or 0) + return self.get(case_id) + + def update(self, case_id: int, *, updates: dict[str, object]) -> CaseRecord: + existing = self.get(case_id) + merged = CaseRecord( + id=existing.id, + title=cast(str, updates.get("title", existing.title)), + category=cast(CaseCategory, updates.get("category", existing.category)), + summary=cast(str | None, updates.get("summary", existing.summary)), + content=cast(str | None, updates.get("content", existing.content)), + review_status=cast( + CaseReviewStatus, + updates.get("review_status", existing.review_status), + ), + review_note=cast( + str | None, updates.get("review_note", existing.review_note) + ), + reviewer=cast(str | None, updates.get("reviewer", existing.reviewer)), + reviewed_at=cast( + str | None, updates.get("reviewed_at", existing.reviewed_at) + ), + created_at=existing.created_at, + updated_at=utc_now_iso(), + tags=cast(list[str], updates.get("tags", existing.tags)), + ) + self.conn.execute( + """ + UPDATE cases + SET title = ?, category = ?, summary = ?, content = ?, + review_status = ?, review_note = ?, reviewer = ?, reviewed_at = ?, + updated_at = ?, tags = ? + WHERE id = ? + """, + ( + merged.title, + merged.category, + merged.summary, + merged.content, + merged.review_status, + merged.review_note, + merged.reviewer, + merged.reviewed_at, + merged.updated_at, + json.dumps(merged.tags, ensure_ascii=False), + case_id, + ), + ) + return self.get(case_id) + + def delete(self, case_id: int) -> None: + cur = self.conn.execute("DELETE FROM cases WHERE id = ?", (case_id,)) + if cur.rowcount == 0: + raise CaseNotFound(case_id) + + @staticmethod + def _from_row(row: sqlite3.Row) -> CaseRecord: + tags_raw = row["tags"] or "[]" + tags = json.loads(tags_raw) + if not isinstance(tags, list): + tags = [] + return CaseRecord( + id=int(row["id"]), + title=row["title"], + category=row["category"], + summary=row["summary"], + content=row["content"], + review_status=row["review_status"], + review_note=row["review_note"], + reviewer=row["reviewer"], + reviewed_at=row["reviewed_at"], + created_at=row["created_at"], + updated_at=row["updated_at"], + tags=[str(item) for item in tags], + ) diff --git a/data/cases/models.py b/data/cases/models.py new file mode 100644 index 0000000..4331ed8 --- /dev/null +++ b/data/cases/models.py @@ -0,0 +1,25 @@ +"""案例管理数据模型 (T6.5).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +CaseCategory = Literal["success", "typical", "warning"] +CaseReviewStatus = Literal["pending", "approved", "rejected"] + + +@dataclass(frozen=True) +class CaseRecord: + id: int + title: str + category: CaseCategory + summary: str | None = None + content: str | None = None + review_status: CaseReviewStatus = "pending" + review_note: str | None = None + reviewer: str | None = None + reviewed_at: str | None = None + created_at: str | None = None + updated_at: str | None = None + tags: list[str] = field(default_factory=list) diff --git a/data/cases/schema.py b/data/cases/schema.py new file mode 100644 index 0000000..7bf3d98 --- /dev/null +++ b/data/cases/schema.py @@ -0,0 +1,44 @@ +"""SQLite schema for T6.5 案例管理。""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + category TEXT NOT NULL CHECK(category IN ('success', 'typical', 'warning')), + summary TEXT, + content TEXT, + review_status TEXT NOT NULL DEFAULT 'pending' + CHECK(review_status IN ('pending', 'approved', 'rejected')), + review_note TEXT, + reviewer TEXT, + reviewed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]' +); + +CREATE INDEX IF NOT EXISTS idx_cases_category ON cases(category); +CREATE INDEX IF NOT EXISTS idx_cases_review_status ON cases(review_status); +CREATE INDEX IF NOT EXISTS idx_cases_created_at ON cases(created_at DESC); +""" + + +def apply_schema(db_path: str | Path) -> sqlite3.Connection: + db_path = Path(db_path) + if db_path.parent and not db_path.parent.exists(): + db_path.parent.mkdir(parents=True, exist_ok=True) + + conn = sqlite3.connect(str(db_path)) + try: + conn.execute("PRAGMA foreign_keys = ON") + conn.executescript(SCHEMA_SQL) + conn.commit() + except Exception: + conn.close() + raise + return conn diff --git a/data/channel_sync/__init__.py b/data/channel_sync/__init__.py new file mode 100644 index 0000000..d039cf4 --- /dev/null +++ b/data/channel_sync/__init__.py @@ -0,0 +1,31 @@ +"""渠道 SDK 集成层 (T8) + +子模块: + +- :mod:`data.channel_sync.signature` — HMAC-SHA256 签名 / 校验 +- :mod:`data.channel_sync.audit` — Webhook 审计日志 +- :mod:`data.channel_sync.xianyu_adapter` — 闲鱼事件 → Order 映射 +- :mod:`data.channel_sync.wechat_adapter` — 微信消息推送 / 客服消息 +- :mod:`data.channel_sync.wecom_adapter` — 企业微信机器人 / 应用消息 +- :mod:`data.channel_sync.dao_extension` — 配套订单 DAO 扩展 +- :mod:`data.channel_sync.poller` — 兜底轮询 +- :mod:`data.channel_sync.monitor` — 兜底巡检 / 人工兜底提示 +- :mod:`data.channel_sync.webhook_server` — 接收端 (stdlib http.server) + +公开 API 由各子模块 re-export。 +""" + +from . import audit, dao_extension, monitor, poller, signature, webhook_server +from . import wechat_adapter, wecom_adapter, xianyu_adapter + +__all__ = [ + "audit", + "dao_extension", + "monitor", + "poller", + "signature", + "webhook_server", + "wechat_adapter", + "wecom_adapter", + "xianyu_adapter", +] diff --git a/data/channel_sync/audit.py b/data/channel_sync/audit.py new file mode 100644 index 0000000..7cb70b5 --- /dev/null +++ b/data/channel_sync/audit.py @@ -0,0 +1,163 @@ +"""Webhook 审计日志 (T8.1) + +记录每一次 Webhook 接收的处理决策: + +- decision: 'accepted' | 'rejected' | 'parse_error' | 'duplicate' +- reject_reason: 拒绝原因(短字符串,便于排查) +- raw_body_hash: SHA-256(body),不存原始 body 避免敏感信息二次落地 +- order_id: 映射到的内部订单号(若已有) +- remote_addr: 来源 IP(用于限流/取证) + +表结构与设计文档 CHANNEL_INTEGRATION.md §4.3 一致。 +""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from .signature import sha256_hex + +# 审计表 DDL(与 channel_sync/webhook_server 共用) +WEBHOOK_AUDIT_SCHEMA: str = """ +CREATE TABLE IF NOT EXISTS webhook_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + received_at TEXT NOT NULL, + channel TEXT NOT NULL, + event_id TEXT, + decision TEXT NOT NULL, + reject_reason TEXT, + order_id TEXT, + raw_body_hash TEXT, + remote_addr TEXT +); + +CREATE INDEX IF NOT EXISTS idx_webhook_audit_event + ON webhook_audit(channel, event_id); +CREATE INDEX IF NOT EXISTS idx_webhook_audit_decision + ON webhook_audit(channel, decision, received_at); +""" + +VALID_DECISIONS: frozenset[str] = frozenset( + {"accepted", "rejected", "parse_error", "duplicate"} +) + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +@dataclass +class WebhookAuditEntry: + """单条审计记录的入参结构。""" + + channel: str + decision: str + event_id: Optional[str] = None + reject_reason: Optional[str] = None + order_id: Optional[str] = None + raw_body: Optional[bytes] = None + remote_addr: Optional[str] = None + received_at: Optional[str] = None + + def __post_init__(self) -> None: + if self.decision not in VALID_DECISIONS: + raise ValueError( + f"非法 decision: {self.decision!r}; 允许: {sorted(VALID_DECISIONS)}" + ) + if not self.received_at: + self.received_at = utc_now_iso() + + +def apply_audit_schema(conn: sqlite3.Connection) -> None: + """幂等地把 webhook_audit 表与索引装上。""" + conn.executescript(WEBHOOK_AUDIT_SCHEMA) + conn.commit() + + +def record( + conn: sqlite3.Connection, + entry: WebhookAuditEntry, +) -> int: + """写入一条审计记录,返回 rowid。""" + raw_hash = sha256_hex(entry.raw_body) if entry.raw_body else None + cur = conn.execute( + """ + INSERT INTO webhook_audit( + received_at, channel, event_id, decision, + reject_reason, order_id, raw_body_hash, remote_addr + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + entry.received_at, + entry.channel, + entry.event_id, + entry.decision, + entry.reject_reason, + entry.order_id, + raw_hash, + entry.remote_addr, + ), + ) + conn.commit() + return int(cur.lastrowid or 0) + + +def count_by_event(conn: sqlite3.Connection, channel: str, event_id: str) -> int: + """统计 (channel, event_id) 在审计表里的出现次数。""" + row = conn.execute( + "SELECT COUNT(*) FROM webhook_audit " + "WHERE channel=? AND event_id=? AND decision='accepted'", + (channel, event_id), + ).fetchone() + return int(row[0]) if row else 0 + + +def list_recent( + conn: sqlite3.Connection, + *, + channel: Optional[str] = None, + decision: Optional[str] = None, + limit: int = 50, +) -> list[dict]: + """列出最近审计记录,按 id 倒序。""" + where: list[str] = [] + args: list = [] + if channel: + where.append("channel=?") + args.append(channel) + if decision: + where.append("decision=?") + args.append(decision) + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + args.append(int(limit)) + cur = conn.execute( + f"SELECT id, received_at, channel, event_id, decision, " + f"reject_reason, order_id, raw_body_hash, remote_addr " + f"FROM webhook_audit {where_sql} " + f"ORDER BY id DESC LIMIT ?", + args, + ) + cols = [d[0] for d in cur.description] + return [dict(zip(cols, row)) for row in cur.fetchall()] + + +def open_audit_db(db_path: str | Path) -> sqlite3.Connection: + """打开(或创建)含 webhook_audit 表的数据库连接。 + + 订单表如果存在就一起连进来(同一 SQLite 文件);如果不存在则只装审计表。 + """ + db_path = Path(db_path) + if db_path.parent and not db_path.parent.exists(): + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + try: + conn.execute("PRAGMA foreign_keys = ON") + apply_audit_schema(conn) + except Exception: + conn.close() + raise + return conn diff --git a/data/channel_sync/dao_extension.py b/data/channel_sync/dao_extension.py new file mode 100644 index 0000000..82278de --- /dev/null +++ b/data/channel_sync/dao_extension.py @@ -0,0 +1,249 @@ +"""订单 DAO 扩展 (T8.1 配套) + +T4.2 主 DAO 仍为 ``todo``;为不阻塞 T8.1,本模块在 +``data/orders/schema.py`` 既有 ``orders`` / ``order_status_history`` 表 +之上提供 Channel Sync 直接需要的两个能力: + +- :func:`upsert_by_external_id` — 按 ``(source, external_id)`` 唯一索引幂等写入 +- :func:`insert_status_history` — 状态机转换历史 + +依赖: + +- :class:`data.orders.models.Order` +- :class:`data.orders.state_machine.is_valid_transition` / :class:`InvalidStateTransition` + +T4.2 落地后,本模块可删除并由 T4.2 接管。 +""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from typing import Any, Optional + +from data.orders.models import Order +from data.orders.state_machine import ( + InvalidStateTransition, + assert_valid_transition, +) + + +@dataclass +class UpsertResult: + """upsert 的返回结构。""" + + order_id: str + action: str # 'inserted' | 'updated' | 'unchanged' | 'illegal_transition' + old_status: Optional[str] = None + new_status: Optional[str] = None + error: Optional[str] = None + + +def _row_to_dict(row) -> dict: + """把 fetchone() 拿到的 tuple 转成 dict,用 cursor.description 取列名。""" + if row is None: + return {} + if hasattr(row, "keys"): + return dict(row) + # 普通 tuple:依赖调用方已设 row_factory 或我们用 description 拼 + cur = getattr(_row_to_dict, "_last_cur", None) + if cur is None or cur.description is None: + raise RuntimeError( + "row is a plain tuple but no cursor.description is available; " + "set conn.row_factory = sqlite3.Row before querying" + ) + return dict(zip([d[0] for d in cur.description], row)) + + +def _row_to_order(row) -> Order: + """把数据库行转换为 Order(借助 from_db_row 解析加密字段)。""" + if row is None: + raise ValueError("cannot convert None row to Order") + if hasattr(row, "keys"): + return Order.from_db_row(dict(row)) + # tuple: 需要列名 + cur = getattr(_row_to_order, "_last_cur", None) + if cur is None or cur.description is None: + raise RuntimeError( + "row is a plain tuple but no cursor.description available; " + "set conn.row_factory = sqlite3.Row before querying" + ) + return Order.from_db_row(dict(zip([d[0] for d in cur.description], row))) + + +def _coerce_value(key: str, value: Any) -> Any: + """保证 tags / candidate_subjects 落盘为 JSON 字符串。""" + if key in ("tags", "candidate_subjects") and isinstance(value, (list, tuple)): + return json.dumps(list(value), ensure_ascii=False) + return value + + +def _column_list() -> str: + """``orders`` 表的可写列清单(与 schema.py 对齐)。""" + return ( + "id, source, external_id, service_version, amount_cents, status, " + "status_updated_at, customer_name, customer_phone_enc, " + "customer_phone_hash, customer_wechat, candidate_name, " + "candidate_id_card_enc, candidate_province, candidate_score, " + "candidate_rank, candidate_subjects, candidate_interests, " + "candidate_strong_subjects, candidate_weak_subjects, " + "candidate_family, assigned_consultant, plan_file, audit_report, " + "pdf_path, created_at, paid_at, started_at, delivered_at, " + "completed_at, notes, tags, upgrade_from" + ) + + +def upsert_by_external_id( + conn: sqlite3.Connection, + order: Order, + *, + actor: str = "xianyu_webhook", + reason: Optional[str] = None, +) -> UpsertResult: + """按 (source, external_id) 唯一索引写入或更新订单。 + + 行为: + + - **不存在** → 插入新行 + 写 status_history(from=None → status) + - **已存在且状态可推进** → 更新 status / status_updated_at + 写 status_history + - **已存在且状态非法转换** → 不写库,返回 ``action='illegal_transition'`` + - **已存在且状态不变** → ``action='unchanged'``,不写 status_history + + 入参: + conn: 启用了 ``PRAGMA foreign_keys = ON`` 的 sqlite3 连接 + order: 待写入的 Order;id 与外部订单号会用于查重 + actor: 操作者标签(写入 status_history) + reason: 可选原因描述 + """ + if not order.external_id: + return UpsertResult( + order_id=order.id, + action="illegal_transition", + error="external_id 缺失,无法做幂等 upsert", + ) + + # 1) 查询是否已存在(统一用 sqlite3.Row 工厂) + prior_factory = conn.row_factory + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT * FROM orders WHERE source=? AND external_id=? LIMIT 1", + (order.source, order.external_id), + ).fetchone() + finally: + conn.row_factory = prior_factory + + if row is None: + # INSERT + db_row = order.to_db_row() + # 过滤掉 schema 中不存在的列(防御性) + valid_cols = set(_column_list().split(", ")) + db_row = {k: _coerce_value(k, v) for k, v in db_row.items() if k in valid_cols} + cols = list(db_row.keys()) + placeholders = ",".join("?" for _ in cols) + values = [db_row[c] for c in cols] + conn.execute( + f"INSERT INTO orders ({','.join(cols)}) VALUES ({placeholders})", + values, + ) + insert_status_history( + conn, + order_id=order.id, + from_status=None, + to_status=order.status, + actor=actor, + reason=reason or "channel_sync_upsert_insert", + ) + conn.commit() + return UpsertResult( + order_id=order.id, + action="inserted", + old_status=None, + new_status=order.status, + ) + + # 2) 已存在:判断状态转换 + existing = _row_to_order(row) + old_status = existing.status + if old_status == order.status: + # 状态未变:可选择更新业务字段(amount / 客户信息) + # 但 Channel Sync 场景下避免覆盖人工录入的字段,只更新可空字段 + return UpsertResult( + order_id=existing.id, + action="unchanged", + old_status=old_status, + new_status=order.status, + ) + try: + assert_valid_transition(old_status, order.status) + except InvalidStateTransition as e: + conn.commit() + return UpsertResult( + order_id=existing.id, + action="illegal_transition", + old_status=old_status, + new_status=order.status, + error=str(e), + ) + + # 3) 合法的状态推进 + conn.execute( + """ + UPDATE orders SET + status=?, + status_updated_at=?, + paid_at = COALESCE(paid_at, ?), + completed_at = COALESCE(completed_at, ?) + WHERE id=? + """, + ( + order.status, + order.status_updated_at or order.created_at, + order.paid_at, + order.completed_at, + existing.id, + ), + ) + insert_status_history( + conn, + order_id=existing.id, + from_status=old_status, + to_status=order.status, + actor=actor, + reason=reason or f"channel_sync_update_{order.source}", + ) + conn.commit() + return UpsertResult( + order_id=existing.id, + action="updated", + old_status=old_status, + new_status=order.status, + ) + + +def insert_status_history( + conn: sqlite3.Connection, + *, + order_id: str, + from_status: Optional[str], + to_status: str, + actor: Optional[str] = None, + reason: Optional[str] = None, + changed_at: Optional[str] = None, +) -> int: + """插入一条 order_status_history 记录,返回 rowid。""" + from data.orders.models import utc_now_iso # local import 避免循环 + + if changed_at is None: + changed_at = utc_now_iso() + cur = conn.execute( + """ + INSERT INTO order_status_history( + order_id, from_status, to_status, actor, reason, changed_at + ) VALUES (?, ?, ?, ?, ?, ?) + """, + (order_id, from_status, to_status, actor, reason, changed_at), + ) + # 不在此处 commit,由调用方统一事务 + return int(cur.lastrowid or 0) diff --git a/data/channel_sync/monitor.py b/data/channel_sync/monitor.py new file mode 100644 index 0000000..b3dca49 --- /dev/null +++ b/data/channel_sync/monitor.py @@ -0,0 +1,368 @@ +"""渠道兜底巡检 (T8.4). + +目标:给 ops 一个可定时执行的健康检查入口,基于本地 SQLite 中已经存在的 +webhook_audit / poller_state / poller_run 事实数据,判断渠道同步链路是否进入 +降级态,并给出人工兜底建议。 + +不做: +- 不直接调用外部渠道 API(避免引入额外依赖 / 凭证) +- 不自动创建订单(人工兜底仍由 order-manager CLI 明确执行) +""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +DEFAULT_DB_PATH = Path("data/orders.db") +_STATUS_EXIT_CODE = {"ok": 0, "warn": 1, "critical": 2} + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _parse_iso(value: str | None) -> datetime | None: + if not value: + return None + text = str(value).strip() + if not text: + return None + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _minutes_ago(now: datetime, ts: str | None) -> float | None: + dt = _parse_iso(ts) + if dt is None: + return None + return round((now - dt).total_seconds() / 60.0, 2) + + +def _open_db(db_path: str) -> sqlite3.Connection: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn + + +def _table_exists(conn: sqlite3.Connection, name: str) -> bool: + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (name,) + ).fetchone() + return row is not None + + +def _fetch_one( + conn: sqlite3.Connection, sql: str, params: tuple[Any, ...] +) -> dict[str, Any] | None: + row = conn.execute(sql, params).fetchone() + return dict(row) if row is not None else None + + +def _manual_actions(db_path: str, source: str) -> list[str]: + return [ + ( + "先执行巡检命令确认状态:" + f"python3 scripts/gaokao-channel-fallback --db {db_path} check --source {source} --human" + ), + ( + "若确认 webhook/poller 不可用,使用人工补录 CLI 新建订单:" + f"python3 scripts/gaokao-order-manager --db {db_path} create --source {source} " + "--service-version basic --amount-cents 0 --customer-name <姓名> --customer-phone <手机号>" + ), + ( + "人工补录后按业务推进状态:" + f"python3 scripts/gaokao-order-manager --db {db_path} pay " + " / deliver " + ), + ] + + +def summarize_channel_health( + db_path: str, + *, + source: str = "xianyu", + now: datetime | None = None, + poller_stale_minutes: int = 15, + webhook_stale_minutes: int = 30, + recent_window_minutes: int = 60, + reject_warn_threshold: int = 5, + poller_error_warn_threshold: int = 3, +) -> dict[str, Any]: + now = now or _utcnow() + conn = _open_db(db_path) + try: + has_audit = _table_exists(conn, "webhook_audit") + has_state = _table_exists(conn, "poller_state") + has_run = _table_exists(conn, "poller_run") + + latest_accepted = None + latest_recent = None + recent_counts = { + "accepted": 0, + "duplicate": 0, + "parse_error": 0, + "rejected": 0, + } + if has_audit: + latest_accepted = _fetch_one( + conn, + """ + SELECT received_at, decision, event_id, reject_reason + FROM webhook_audit + WHERE channel=? AND decision='accepted' + ORDER BY id DESC LIMIT 1 + """, + (source,), + ) + latest_recent = _fetch_one( + conn, + """ + SELECT received_at, decision, event_id, reject_reason + FROM webhook_audit + WHERE channel=? + ORDER BY id DESC LIMIT 1 + """, + (source,), + ) + since_iso = (now - timedelta(minutes=recent_window_minutes)).isoformat() + for row in conn.execute( + """ + SELECT decision, COUNT(*) AS n + FROM webhook_audit + WHERE channel=? AND received_at>=? + GROUP BY decision + """, + (source, since_iso), + ).fetchall(): + decision = str(row["decision"]) + if decision in recent_counts: + recent_counts[decision] = int(row["n"] or 0) + + state = None + if has_state: + state = _fetch_one( + conn, + """ + SELECT source, last_cursor, last_run_at, last_error, run_count, error_count + FROM poller_state WHERE source=? + """, + (source,), + ) + + last_run = None + if has_run: + last_run = _fetch_one( + conn, + """ + SELECT started_at, finished_at, fetched, inserted, updated, unchanged, + rejected, error_message + FROM poller_run + WHERE source=? + ORDER BY id DESC LIMIT 1 + """, + (source,), + ) + + findings: list[str] = [] + actions: list[str] = [] + status = "ok" + + def raise_status(next_status: str) -> None: + nonlocal status + order = {"ok": 0, "warn": 1, "critical": 2} + if order[next_status] > order[status]: + status = next_status + + if not has_audit: + raise_status("warn") + findings.append("缺少 webhook_audit 表,无法判断 webhook 接收健康度") + if not has_state: + raise_status("warn") + findings.append("缺少 poller_state 表,无法判断兜底轮询状态") + if not has_run: + raise_status("warn") + findings.append("缺少 poller_run 表,无法回看最近轮询结果") + + accepted_age = _minutes_ago( + now, latest_accepted["received_at"] if latest_accepted else None + ) + recent_age = _minutes_ago( + now, latest_recent["received_at"] if latest_recent else None + ) + poller_age = _minutes_ago(now, state["last_run_at"] if state else None) + + if state and state.get("last_error"): + error_count = int(state.get("error_count") or 0) + if error_count >= poller_error_warn_threshold: + raise_status("critical") + findings.append( + f"poller 连续错误计数={error_count},last_error={state['last_error']}" + ) + else: + raise_status("warn") + findings.append(f"poller 最近一次报错:{state['last_error']}") + + if last_run and last_run.get("error_message"): + raise_status("critical") + findings.append(f"最近一次 poller_run 失败:{last_run['error_message']}") + + rejected_total = recent_counts["rejected"] + recent_counts["parse_error"] + if rejected_total >= reject_warn_threshold: + raise_status("warn") + findings.append( + f"最近 {recent_window_minutes} 分钟 webhook 拒绝/解析失败 {rejected_total} 次" + ) + + if poller_age is not None and poller_age > poller_stale_minutes: + raise_status("warn") + findings.append( + f"poller 最近运行距今 {poller_age} 分钟,超过阈值 {poller_stale_minutes} 分钟" + ) + + if ( + accepted_age is not None + and accepted_age > webhook_stale_minutes + and recent_counts["rejected"] + recent_counts["parse_error"] > 0 + ): + raise_status("warn") + findings.append( + f"最近成功 webhook 距今 {accepted_age} 分钟,且窗口内存在失败事件" + ) + + if latest_accepted is None and state is None and last_run is None: + raise_status("warn") + findings.append( + "尚无 webhook accepted / poller_state / poller_run 证据,链路可能未启动" + ) + + if status != "ok": + actions = _manual_actions(db_path, source) + elif not findings: + findings.append("最近 webhook/poller 未发现明显异常") + + payload = { + "status": status, + "checked_at": now.isoformat(), + "source": source, + "db_path": db_path, + "tables": { + "webhook_audit": has_audit, + "poller_state": has_state, + "poller_run": has_run, + }, + "webhook": { + "latest_accepted": latest_accepted, + "latest_event": latest_recent, + "latest_accepted_age_minutes": accepted_age, + "latest_event_age_minutes": recent_age, + "recent_counts": recent_counts, + }, + "poller": { + "state": state, + "last_run": last_run, + "last_run_age_minutes": poller_age, + }, + "findings": findings, + "recommended_actions": actions, + } + return payload + finally: + conn.close() + + +def _emit(payload: dict[str, Any], *, human: bool) -> None: + if not human: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return + print(f"status: {payload['status']}") + print(f"source: {payload['source']}") + print(f"checked_at: {payload['checked_at']}") + print(f"db_path: {payload['db_path']}") + print("findings:") + for item in payload.get("findings", []): + print(f"- {item}") + actions = payload.get("recommended_actions", []) + if actions: + print("recommended_actions:") + for item in actions: + print(f"- {item}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="gaokao-channel-fallback", + description="渠道兜底巡检 CLI(T8.4)", + ) + parser.add_argument( + "--db", + default=str(DEFAULT_DB_PATH), + help="订单 SQLite 路径(默认 data/orders.db)", + ) + parser.add_argument( + "--human", + action="store_true", + help="终端友好输出(默认 JSON)", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + check = subparsers.add_parser("check", help="检查 webhook/poller 健康度") + check.add_argument("--source", default="xianyu", help="渠道 source,默认 xianyu") + check.add_argument("--human", action="store_true", help=argparse.SUPPRESS) + check.add_argument("--poller-stale-minutes", type=int, default=15) + check.add_argument("--webhook-stale-minutes", type=int, default=30) + check.add_argument("--recent-window-minutes", type=int, default=60) + check.add_argument("--reject-warn-threshold", type=int, default=5) + check.add_argument("--poller-error-warn-threshold", type=int, default=3) + + manual = subparsers.add_parser("manual-template", help="输出人工兜底建议命令") + manual.add_argument("--source", default="xianyu", help="渠道 source,默认 xianyu") + manual.add_argument("--human", action="store_true", help=argparse.SUPPRESS) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + human = bool(getattr(args, "human", False)) + + if args.command == "manual-template": + payload = { + "status": "warn", + "checked_at": _utcnow().isoformat(), + "source": args.source, + "db_path": args.db, + "findings": ["人工兜底模板"], + "recommended_actions": _manual_actions(args.db, args.source), + } + _emit(payload, human=human) + return 0 + + payload = summarize_channel_health( + args.db, + source=args.source, + poller_stale_minutes=args.poller_stale_minutes, + webhook_stale_minutes=args.webhook_stale_minutes, + recent_window_minutes=args.recent_window_minutes, + reject_warn_threshold=args.reject_warn_threshold, + poller_error_warn_threshold=args.poller_error_warn_threshold, + ) + _emit(payload, human=human) + return _STATUS_EXIT_CODE[payload["status"]] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/data/channel_sync/poller.py b/data/channel_sync/poller.py new file mode 100644 index 0000000..217ba0a --- /dev/null +++ b/data/channel_sync/poller.py @@ -0,0 +1,334 @@ +"""闲鱼兜底轮询器 (T8.1 §3.2) + +当 Webhook 不可用(平台维护、网络抖动)时,由 ``poller`` 从外部 API +拉取未确认订单并写入数据库。Webhook 与 poller 共享 ``upsert_by_external_id`` +幂等机制,因此两端都收到同一条订单不会重复入库。 + +本模块只暴露可注入的 :class:`XianyuOpenAPIClient` 协议与 :func:`poll_once` +函数;CLI/定时器启动由 ``scripts/`` 下的入口负责(见 T8.4 兜底 SOP)。 + +数据契约: +- 输入: ``XianyuOpenAPIClient.list_orders(since: int) -> Iterable[dict]`` +- 输出: 每条订单都经过 :func:`data.channel_sync.xianyu_adapter.to_order` → + :func:`data.channel_sync.dao_extension.upsert_by_external_id` +- cursor: ``max(updated_at)`` 写入 ``poller_state`` 表(新建) +""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass, field +from typing import Iterable, Optional, Protocol + +from data.orders.models import utc_now_iso + +from .dao_extension import UpsertResult, upsert_by_external_id +from .xianyu_adapter import XianyuEventError, parse_event, to_order + + +POLLER_STATE_SCHEMA: str = """ +CREATE TABLE IF NOT EXISTS poller_state ( + source TEXT PRIMARY KEY, + last_cursor TEXT, + last_run_at TEXT, + last_error TEXT, + run_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0 +); +""" + +POLLER_RUN_SCHEMA: str = """ +CREATE TABLE IF NOT EXISTS poller_run ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + fetched INTEGER NOT NULL DEFAULT 0, + inserted INTEGER NOT NULL DEFAULT 0, + updated INTEGER NOT NULL DEFAULT 0, + unchanged INTEGER NOT NULL DEFAULT 0, + rejected INTEGER NOT NULL DEFAULT 0, + error_message TEXT +); +""" + + +class XianyuOpenAPIClient(Protocol): + """外部 API 客户端协议,便于注入假实现做单测。""" + + def list_orders(self, since: Optional[str]) -> list[dict]: ... + + +class _DefaultClient: + """默认空实现;真实接入留给 T8.2 之后的真接入阶段。""" + + def list_orders(self, since: Optional[str]) -> list[dict]: + return [] + + +def apply_poller_schema(conn: sqlite3.Connection) -> None: + conn.executescript(POLLER_STATE_SCHEMA + POLLER_RUN_SCHEMA) + conn.commit() + + +@dataclass +class PollReport: + source: str + fetched: int = 0 + inserted: int = 0 + updated: int = 0 + unchanged: int = 0 + rejected: int = 0 + error: Optional[str] = None + last_cursor: Optional[str] = None + upsert_results: list[UpsertResult] = field(default_factory=list) + + +def _ensure_schema(conn: sqlite3.Connection) -> None: + apply_poller_schema(conn) + + +def get_cursor(conn: sqlite3.Connection, source: str) -> Optional[str]: + row = conn.execute( + "SELECT last_cursor FROM poller_state WHERE source=?", (source,) + ).fetchone() + return row[0] if row else None + + +def _set_cursor( + conn: sqlite3.Connection, + source: str, + cursor: Optional[str], + error: Optional[str] = None, + run_count_delta: int = 1, + error_count_delta: int = 0, + last_run_at: Optional[str] = None, +) -> None: + if last_run_at is None: + last_run_at = utc_now_iso() + conn.execute( + """ + INSERT INTO poller_state( + source, last_cursor, last_run_at, last_error, + run_count, error_count + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(source) DO UPDATE SET + last_cursor = excluded.last_cursor, + last_run_at = excluded.last_run_at, + last_error = excluded.last_error, + run_count = run_count + excluded.run_count, + error_count = error_count + excluded.error_count + """, + (source, cursor, last_run_at, error, run_count_delta, error_count_delta), + ) + + +def _record_run( + conn: sqlite3.Connection, + *, + source: str, + started_at: str, + finished_at: str, + fetched: int, + inserted: int, + updated: int, + unchanged: int, + rejected: int, + error_message: Optional[str] = None, +) -> int: + cur = conn.execute( + """ + INSERT INTO poller_run( + source, started_at, finished_at, fetched, inserted, + updated, unchanged, rejected, error_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + source, + started_at, + finished_at, + fetched, + inserted, + updated, + unchanged, + rejected, + error_message, + ), + ) + return int(cur.lastrowid or 0) + + +def _parse_event_dicts(raw_orders: Iterable[dict]) -> list[dict]: + """把 list_orders() 返回的 dict 列表规整化:必须含 raw_body (JSON str)。 + + 兜底模式:poller 拿到的 order 字典与 Webhook body 同构;这里不重新解析, + 留给 :func:`_process_one` 走完整 parse_event 路径以复用校验。 + """ + out: list[dict] = [] + for raw in raw_orders: + if "raw_body" not in raw: + # 上游直接给了字段时,按 Webhook body 形式重新组装 + import json as _json + + raw = dict(raw) + raw.setdefault("raw_body", _json.dumps(raw, ensure_ascii=False)) + out.append(raw) + return out + + +def _process_one( + conn: sqlite3.Connection, + raw: dict, + *, + actor: str = "xianyu_poller", +) -> UpsertResult: + """单条订单: parse_event → to_order → upsert_by_external_id。""" + event = parse_event(raw["raw_body"]) + order = to_order(event) + return upsert_by_external_id( + conn, + order, + actor=actor, + reason=f"poller_event_{event.event_id}", + ) + + +def poll_once( + conn: sqlite3.Connection, + *, + source: str = "xianyu", + client: XianyuOpenAPIClient | None = None, + actor: str = "xianyu_poller", +) -> PollReport: + """执行一次轮询;返回 :class:`PollReport`。 + + - 读取上次 cursor + - 调 ``client.list_orders(since=cursor)`` + - 对每条订单走 parse + upsert + - 写 ``poller_run`` / 更新 ``poller_state.cursor`` + + 失败不会抛出,返回 ``report.error`` 携带异常信息。 + """ + _ensure_schema(conn) + if client is None: + client = _DefaultClient() + + started_at = utc_now_iso() + report = PollReport(source=source) + + try: + cursor = get_cursor(conn, source) + raw_orders = client.list_orders(cursor) + normalized = _parse_event_dicts(raw_orders) + report.fetched = len(normalized) + for raw in normalized: + try: + res = _process_one(conn, raw, actor=actor) + except XianyuEventError as e: + report.rejected += 1 + report.upsert_results.append( + UpsertResult( + order_id="", + action="rejected", + error=str(e), + ) + ) + continue + except Exception as e: # 解析 / 落库异常 → 单条失败不阻塞整体 + report.rejected += 1 + report.upsert_results.append( + UpsertResult( + order_id="", + action="rejected", + error=f"unexpected: {e!r}", + ) + ) + continue + report.upsert_results.append(res) + if res.action == "inserted": + report.inserted += 1 + elif res.action == "updated": + report.updated += 1 + elif res.action == "unchanged": + report.unchanged += 1 + elif res.action == "illegal_transition": + report.rejected += 1 + # cursor 推进:取最大外部 updated_at;空列表时不更新; + # fetched>0 但缺时间戳时回退到 started_at(详见 _compute_new_cursor) + report.last_cursor = _compute_new_cursor( + conn, source, raw_orders, now_iso=started_at + ) + _set_cursor( + conn, + source, + cursor=report.last_cursor, + error=None, + run_count_delta=1, + error_count_delta=0, + ) + _record_run( + conn, + source=source, + started_at=started_at, + finished_at=utc_now_iso(), + fetched=report.fetched, + inserted=report.inserted, + updated=report.updated, + unchanged=report.unchanged, + rejected=report.rejected, + ) + conn.commit() + except Exception as e: + report.error = f"{type(e).__name__}: {e}" + _set_cursor( + conn, + source, + cursor=get_cursor(conn, source), + error=report.error, + run_count_delta=1, + error_count_delta=1, + ) + _record_run( + conn, + source=source, + started_at=started_at, + finished_at=utc_now_iso(), + fetched=report.fetched, + inserted=report.inserted, + updated=report.updated, + unchanged=report.unchanged, + rejected=report.rejected, + error_message=report.error, + ) + conn.commit() + return report + + +def _compute_new_cursor( + conn: sqlite3.Connection, + source: str, + raw_orders: list[dict], + *, + now_iso: Optional[str] = None, +) -> Optional[str]: + """根据本批订单的最大 updated_at 推进 cursor。 + + 异常兜底: 当 fetched>0 但本批订单全部缺 updated_at/paid_at/created_at + 时,不允许 cursor 保持 None 而让 poller 永久卡在同一窗口。 + 推进策略: 回退到 ``utc_now_iso()``,用 "本次拉取的时间" 作为新的 + cursor 下界。已知限制: 若上游 API 长期返回无时间戳的订单,可能会 + 跳过部分窗口;但这是 fail-forward 策略,优于无限循环。 + """ + candidates: list[str] = [] + for raw in raw_orders: + ts = raw.get("updated_at") or raw.get("paid_at") or raw.get("created_at") + if ts: + candidates.append(str(ts)) + if candidates: + # ISO8601 字符串可直接字典序比较 + return max(candidates) + if raw_orders: + # fetched>0 但本批无任何时间戳,使用当前时间作为推进值 + return now_iso if now_iso is not None else utc_now_iso() + # 真正空批:沿用旧 cursor(由调用方决定是否更新) + return get_cursor(conn, source) diff --git a/data/channel_sync/signature.py b/data/channel_sync/signature.py new file mode 100644 index 0000000..bbdd102 --- /dev/null +++ b/data/channel_sync/signature.py @@ -0,0 +1,207 @@ +"""HMAC-SHA256 签名 / 校验与时间戳防重放 (T8.1) + +闲鱼 Webhook 头约定(与设计文档 CHANNEL_INTEGRATION.md §3.1 / §5.2 一致): + +- ``X-Signature``: 形如 ``hmac-sha256=`` +- ``X-Timestamp``: unix 秒级时间戳 +- ``X-Nonce``: 一次性随机串(防同秒内重放) + +签名原文: ``f"{ts}.{nonce}.{raw_body}"``,密钥来自 +``GAOKAO_XIANYU_WEBHOOK_SECRET`` 环境变量。 + +防重放: 时间戳与本地时间偏差超过 ``XIANYU_WEBHOOK_TS_TOLERANCE``(默认 300s) +视为过期;同时已使用过的 ``(timestamp, nonce)`` 组合在 10 分钟内 +不能再次通过。 +""" + +from __future__ import annotations + +import hashlib +import hmac +import os +import time +from collections import OrderedDict +from threading import Lock +from typing import Tuple + +# 默认时间戳容差 300s (5 分钟) +DEFAULT_TS_TOLERANCE_SECONDS: int = 300 + +# nonce 缓存 10 分钟 +NONCE_CACHE_TTL_SECONDS: int = 600 + +# nonce 缓存最大条目 (避免内存爆) +NONCE_CACHE_MAX_SIZE: int = 4096 + + +class SignatureError(ValueError): + """签名校验失败(格式错误 / 签名不匹配 / 时间戳过期 / nonce 重复)。""" + + +def get_webhook_secret() -> str: + """读取 Webhook 签名密钥。 + + 环境变量 ``GAOKAO_XIANYU_WEBHOOK_SECRET`` 必须设置;缺失时抛 ``SignatureError``。 + 测试场景应通过 ``monkeypatch.setenv`` 或 ``os.environ`` 注入。 + """ + secret = os.environ.get("GAOKAO_XIANYU_WEBHOOK_SECRET", "") + if not secret: + raise SignatureError( + "GAOKAO_XIANYU_WEBHOOK_SECRET 未设置;" + "生产环境必须配置签名密钥,测试环境通过 os.environ 注入" + ) + return secret + + +def sign( + body: bytes, + *, + secret: str | None = None, + timestamp: int | None = None, + nonce: str | None = None, +) -> Tuple[str, int, str]: + """生成 Webhook 签名(测试用)。返回 (signature_header, ts, nonce)。 + + 形参全为 None 时使用 ``get_webhook_secret()`` + ``time.time()`` + 16 字节 hex nonce。 + """ + if secret is None: + secret = get_webhook_secret() + if timestamp is None: + timestamp = int(time.time()) + if nonce is None: + nonce = hashlib.sha256(os.urandom(16)).hexdigest()[:16] + payload = f"{timestamp}.{nonce}.".encode("utf-8") + body + digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() + return f"hmac-sha256={digest}", timestamp, nonce + + +class _NonceCache: + """线程安全的 LRU nonce 缓存,按 (timestamp, nonce) 记录首次出现时间。 + + 容量超限自动淘汰最旧条目。``now`` 参数可注入以方便单测。 + """ + + def __init__( + self, + *, + ttl_seconds: int = NONCE_CACHE_TTL_SECONDS, + max_size: int = NONCE_CACHE_MAX_SIZE, + ) -> None: + self._ttl = ttl_seconds + self._max = max_size + self._lock = Lock() + self._store: "OrderedDict[Tuple[int, str], float]" = OrderedDict() + + def remember(self, ts: int, nonce: str, now: float | None = None) -> bool: + """记录 nonce;若 (ts, nonce) 已在 TTL 内则返回 False(重放)。 + + 否则写入并返回 True。 + """ + if now is None: + now = time.time() + with self._lock: + self._purge_locked(now) + key = (ts, nonce) + if key in self._store: + # 重新插入到队尾表示最近使用 + self._store.move_to_end(key) + return False + self._store[key] = now + if len(self._store) > self._max: + self._store.popitem(last=False) + return True + + def clear(self) -> None: + with self._lock: + self._store.clear() + + def _purge_locked(self, now: float) -> None: + cutoff = now - self._ttl + # 队首是最旧的,顺序弹出过期项 + while self._store: + oldest_key, first_seen = next(iter(self._store.items())) + if first_seen < cutoff: + self._store.popitem(last=False) + else: + break + + +# 模块级默认实例 +_NONCE_CACHE = _NonceCache() + + +def _get_nonce_cache() -> _NonceCache: + return _NONCE_CACHE + + +def reset_nonce_cache_for_tests() -> None: + """清空 nonce 缓存(仅用于单测)。""" + _NONCE_CACHE.clear() + + +def verify( + body: bytes, + signature_header: str, + timestamp_header: str | int, + nonce_header: str, + *, + secret: str | None = None, + tolerance_seconds: int = DEFAULT_TS_TOLERANCE_SECONDS, + nonce_cache: _NonceCache | None = None, + now: float | None = None, +) -> None: + """校验 Webhook 签名;失败抛 :class:`SignatureError`。 + + 参数: + body: 原始请求体(bytes,不参与解码/排序) + signature_header: ``X-Signature`` 头的完整值 + timestamp_header: ``X-Timestamp`` 头(字符串或整数) + nonce_header: ``X-Nonce`` 头 + secret: 显式密钥;None 时从环境变量读取 + tolerance_seconds: 时间戳容差,默认 300s + nonce_cache: 注入的 nonce 缓存(用于单测隔离) + now: 注入的"当前时间"(用于单测) + + 异常: :class:`SignatureError` 包含具体的 reject_reason + """ + if secret is None: + secret = get_webhook_secret() + if nonce_cache is None: + nonce_cache = _get_nonce_cache() + if now is None: + now = time.time() + + # 1) 解析签名 + if not signature_header or not signature_header.startswith("hmac-sha256="): + raise SignatureError("malformed_signature: missing hmac-sha256= prefix") + provided = signature_header.split("=", 1)[1].strip() + if not provided: + raise SignatureError("malformed_signature: empty signature") + + # 2) 解析时间戳 + try: + ts = int(timestamp_header) + except (TypeError, ValueError): + raise SignatureError("malformed_timestamp: not an integer") + + if abs(now - ts) > tolerance_seconds: + raise SignatureError(f"timestamp_out_of_range: |now-ts|>{tolerance_seconds}s") + + # 3) nonce 必填 + if not nonce_header: + raise SignatureError("missing_nonce") + + # 4) 重新计算签名 + payload = f"{ts}.{nonce_header}.".encode("utf-8") + body + expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, provided): + raise SignatureError("signature_mismatch") + + # 5) 防重放 + if not nonce_cache.remember(ts, nonce_header, now=now): + raise SignatureError("nonce_replay") + + +def sha256_hex(data: bytes) -> str: + """计算 body 的 SHA-256 hex 摘要(用于审计 raw_body_hash)。""" + return hashlib.sha256(data).hexdigest() diff --git a/data/channel_sync/tests/__init__.py b/data/channel_sync/tests/__init__.py new file mode 100644 index 0000000..1791821 --- /dev/null +++ b/data/channel_sync/tests/__init__.py @@ -0,0 +1 @@ +"""data.channel_sync 单元测试包。""" diff --git a/data/channel_sync/tests/test_monitor.py b/data/channel_sync/tests/test_monitor.py new file mode 100644 index 0000000..9a157a4 --- /dev/null +++ b/data/channel_sync/tests/test_monitor.py @@ -0,0 +1,132 @@ +"""渠道兜底巡检 CLI tests (T8.4).""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path + +import pytest + +from data.channel_sync.audit import apply_audit_schema +from data.channel_sync.monitor import main as cli_main, summarize_channel_health +from data.channel_sync.poller import apply_poller_schema +from data.orders.schema import apply_schema + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = PROJECT_ROOT / "scripts" / "gaokao-channel-fallback" + + +@pytest.fixture +def tmp_db_path(tmp_path: Path) -> Path: + db = tmp_path / "orders.db" + conn = apply_schema(db) + apply_audit_schema(conn) + apply_poller_schema(conn) + conn.close() + return db + + +def _connect(db_path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn + + +def _run_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT_PATH), *args], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + env=os.environ.copy(), + ) + + +def test_summarize_channel_health_warns_when_no_runtime_signal( + tmp_db_path: Path, +) -> None: + payload = summarize_channel_health(str(tmp_db_path), source="xianyu") + assert payload["status"] == "warn" + assert any("链路可能未启动" in item for item in payload["findings"]) + assert any( + "gaokao-order-manager" in item for item in payload["recommended_actions"] + ) + + +def test_summarize_channel_health_critical_when_poller_has_errors( + tmp_db_path: Path, +) -> None: + with _connect(tmp_db_path) as conn: + conn.execute( + """ + INSERT INTO poller_state(source, last_cursor, last_run_at, last_error, run_count, error_count) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + "xianyu", + "2026-06-12T10:00:00+00:00", + "2026-06-12T10:05:00+00:00", + "TimeoutError: upstream timeout", + 8, + 3, + ), + ) + conn.execute( + """ + INSERT INTO poller_run( + source, started_at, finished_at, fetched, inserted, updated, unchanged, + rejected, error_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "xianyu", + "2026-06-12T10:00:00+00:00", + "2026-06-12T10:05:00+00:00", + 0, + 0, + 0, + 0, + 0, + "TimeoutError: upstream timeout", + ), + ) + conn.commit() + + payload = summarize_channel_health( + str(tmp_db_path), + source="xianyu", + now=None, + poller_error_warn_threshold=3, + ) + assert payload["status"] == "critical" + assert any("连续错误计数=3" in item for item in payload["findings"]) + + +def test_cli_manual_template_and_check_exit_codes( + tmp_db_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + manual_code = cli_main( + [ + "--db", + str(tmp_db_path), + "manual-template", + "--source", + "wechat", + "--human", + ] + ) + out = capsys.readouterr().out + assert manual_code == 0 + assert "gaokao-order-manager" in out + assert "status: warn" in out + assert "recommended_actions:" in out + + result = _run_cli("--db", str(tmp_db_path), "check", "--source", "xianyu") + assert result.returncode == 1 + payload = json.loads(result.stdout) + assert payload["status"] == "warn" + assert payload["source"] == "xianyu" diff --git a/data/channel_sync/tests/test_wechat_adapter.py b/data/channel_sync/tests/test_wechat_adapter.py new file mode 100644 index 0000000..8867925 --- /dev/null +++ b/data/channel_sync/tests/test_wechat_adapter.py @@ -0,0 +1,274 @@ +"""T8.2 微信 SDK 集成测试 + +覆盖: +- access token 获取/缓存/过期刷新 +- 订阅消息 payload 规范化与发送 +- 客服文本消息 payload 与可选客服账号字段 +- 上游 errcode / HTTP 异常透传为结构化错误 +- 环境变量缺失 fail-fast +""" + +from __future__ import annotations + +import json +import os +import unittest +from collections.abc import Callable +from dataclasses import dataclass +from typing import Optional + +from data.channel_sync.wechat_adapter import ( + WeChatAPIError, + WeChatClient, + WeChatConfig, + WeChatConfigError, +) + + +@dataclass +class _FakeResponse: + status: int + payload: dict + + +class _FakeTransport: + def __init__(self, responses: list[_FakeResponse]) -> None: + self._responses = list(responses) + self.calls: list[dict] = [] + + def request( + self, + method: str, + url: str, + *, + headers: Optional[dict[str, str]] = None, + body: Optional[bytes] = None, + timeout: float = 0, + ) -> tuple[int, bytes]: + self.calls.append( + { + "method": method, + "url": url, + "headers": headers or {}, + "body": body, + "timeout": timeout, + } + ) + if not self._responses: + raise AssertionError("no fake response left") + response = self._responses.pop(0) + return response.status, json.dumps(response.payload).encode("utf-8") + + +class _ExplodingTransport: + def request( + self, + method: str, + url: str, + *, + headers: Optional[dict[str, str]] = None, + body: Optional[bytes] = None, + timeout: float = 0, + ) -> tuple[int, bytes]: + raise TimeoutError("network down") + + +class WeChatClientTests(unittest.TestCase): + def make_client( + self, + responses: list[_FakeResponse], + *, + clock: Callable[[], float] | None = None, + ) -> tuple[WeChatClient, _FakeTransport]: + transport = _FakeTransport(responses) + client = WeChatClient( + config=WeChatConfig( + app_id="wx-test-appid", + app_secret="wx-test-secret", + api_base="https://api.weixin.qq.com", + timeout_seconds=3.0, + token_refresh_skew_seconds=60, + ), + transport=transport, + clock=clock, + ) + return client, transport + + def test_access_token_cached_until_expiry(self) -> None: + now = [1000.0] + client, transport = self.make_client( + [ + _FakeResponse( + 200, + { + "access_token": "token-1", + "expires_in": 7200, + }, + ), + _FakeResponse( + 200, + { + "errcode": 0, + "errmsg": "ok", + "msgid": 123, + }, + ), + _FakeResponse( + 200, + { + "errcode": 0, + "errmsg": "ok", + "msgid": 124, + }, + ), + ], + clock=lambda: now[0], + ) + + client.send_subscribe_message( + openid="openid-1", + template_id="tmpl-1", + data={"thing1": "待付款提醒"}, + ) + now[0] += 300 + client.send_custom_text(openid="openid-1", content="您好,这里是客服") + + self.assertEqual(len(transport.calls), 3) + self.assertIn("/cgi-bin/token?", transport.calls[0]["url"]) + self.assertIn( + "access_token=token-1", + transport.calls[1]["url"] + transport.calls[2]["url"], + ) + token_fetches = [ + call for call in transport.calls if "/cgi-bin/token?" in call["url"] + ] + self.assertEqual(len(token_fetches), 1) + + def test_access_token_refreshes_after_expiry(self) -> None: + now = [1000.0] + client, transport = self.make_client( + [ + _FakeResponse(200, {"access_token": "token-1", "expires_in": 120}), + _FakeResponse(200, {"errcode": 0, "errmsg": "ok", "msgid": 1}), + _FakeResponse(200, {"access_token": "token-2", "expires_in": 120}), + _FakeResponse(200, {"errcode": 0, "errmsg": "ok", "msgid": 2}), + ], + clock=lambda: now[0], + ) + + client.send_custom_text(openid="openid-1", content="first") + now[0] += 70 + client.send_custom_text(openid="openid-1", content="second") + + token_fetches = [ + call for call in transport.calls if "/cgi-bin/token?" in call["url"] + ] + self.assertEqual(len(token_fetches), 2) + self.assertIn("access_token=token-2", transport.calls[-1]["url"]) + + def test_send_subscribe_message_normalizes_payload(self) -> None: + client, transport = self.make_client( + [ + _FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}), + _FakeResponse(200, {"errcode": 0, "errmsg": "ok", "msgid": 9001}), + ] + ) + + result = client.send_subscribe_message( + openid="openid-1", + template_id="tmpl-1", + data={ + "thing1": "支付成功", + "date2": {"value": "2026-06-12 18:00"}, + }, + page="pages/orders/detail?id=GKO-1", + miniprogram_state="formal", + lang="zh_CN", + ) + + self.assertEqual(result["msgid"], 9001) + send_call = transport.calls[-1] + payload = json.loads(send_call["body"].decode("utf-8")) + self.assertEqual(payload["touser"], "openid-1") + self.assertEqual(payload["template_id"], "tmpl-1") + self.assertEqual(payload["data"]["thing1"], {"value": "支付成功"}) + self.assertEqual(payload["data"]["date2"], {"value": "2026-06-12 18:00"}) + self.assertEqual(payload["page"], "pages/orders/detail?id=GKO-1") + self.assertEqual(payload["miniprogram_state"], "formal") + self.assertEqual(payload["lang"], "zh_CN") + + def test_send_custom_text_with_kf_account(self) -> None: + client, transport = self.make_client( + [ + _FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}), + _FakeResponse(200, {"errcode": 0, "errmsg": "ok", "msgid": 7001}), + ] + ) + + result = client.send_custom_text( + openid="openid-2", + content="请补充考生分数和位次", + kf_account="advisor@test", + ) + + self.assertEqual(result["msgid"], 7001) + payload = json.loads(transport.calls[-1]["body"].decode("utf-8")) + self.assertEqual(payload["msgtype"], "text") + self.assertEqual(payload["text"]["content"], "请补充考生分数和位次") + self.assertEqual(payload["customservice"], {"kf_account": "advisor@test"}) + + def test_upstream_errcode_raises_structured_error(self) -> None: + client, _ = self.make_client( + [ + _FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}), + _FakeResponse(200, {"errcode": 40003, "errmsg": "invalid openid"}), + ] + ) + + with self.assertRaises(WeChatAPIError) as cm: + client.send_custom_text(openid="bad-openid", content="hello") + self.assertEqual(cm.exception.errcode, 40003) + self.assertIn("invalid openid", str(cm.exception)) + + def test_http_error_raises_structured_error(self) -> None: + client, _ = self.make_client( + [ + _FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}), + _FakeResponse(500, {"errcode": 0, "errmsg": "server busy"}), + ] + ) + + with self.assertRaises(WeChatAPIError) as cm: + client.send_custom_text(openid="openid-1", content="hello") + self.assertEqual(cm.exception.http_status, 500) + + def test_transport_error_wrapped_as_wechat_api_error(self) -> None: + client = WeChatClient( + config=WeChatConfig( + app_id="wx-test-appid", + app_secret="wx-test-secret", + ), + transport=_ExplodingTransport(), + ) + + with self.assertRaises(WeChatAPIError) as cm: + client.send_custom_text(openid="openid-1", content="hello") + self.assertIn("transport_error", str(cm.exception)) + + def test_config_from_env_missing_secret_fails_fast(self) -> None: + old_id = os.environ.get("GAOKAO_WECHAT_APP_ID") + old_secret = os.environ.get("GAOKAO_WECHAT_APP_SECRET") + try: + os.environ["GAOKAO_WECHAT_APP_ID"] = "wx-appid" + os.environ.pop("GAOKAO_WECHAT_APP_SECRET", None) + with self.assertRaises(WeChatConfigError): + WeChatClient.from_env() + finally: + if old_id is None: + os.environ.pop("GAOKAO_WECHAT_APP_ID", None) + else: + os.environ["GAOKAO_WECHAT_APP_ID"] = old_id + if old_secret is None: + os.environ.pop("GAOKAO_WECHAT_APP_SECRET", None) + else: + os.environ["GAOKAO_WECHAT_APP_SECRET"] = old_secret diff --git a/data/channel_sync/tests/test_wecom_adapter.py b/data/channel_sync/tests/test_wecom_adapter.py new file mode 100644 index 0000000..4085a1e --- /dev/null +++ b/data/channel_sync/tests/test_wecom_adapter.py @@ -0,0 +1,229 @@ +"""T8.3 企业微信集成测试 + +覆盖: +- 机器人 webhook 文本通知 payload +- 应用消息 access_token 获取/缓存与文本消息 payload +- 上游 errcode / transport 异常统一包装 +- 环境变量缺失 fail-fast +""" + +from __future__ import annotations + +import json +import os +import unittest +from dataclasses import dataclass +from typing import Optional + +from data.channel_sync.wecom_adapter import ( + WeComAPIError, + WeComAppClient, + WeComAppConfig, + WeComBotClient, + WeComBotConfig, + WeComConfigError, +) + + +@dataclass +class _FakeResponse: + status: int + payload: dict + + +class _FakeTransport: + def __init__(self, responses: list[_FakeResponse]) -> None: + self._responses = list(responses) + self.calls: list[dict] = [] + + def request( + self, + method: str, + url: str, + *, + headers: Optional[dict[str, str]] = None, + body: Optional[bytes] = None, + timeout: float = 0, + ) -> tuple[int, bytes]: + self.calls.append( + { + "method": method, + "url": url, + "headers": headers or {}, + "body": body, + "timeout": timeout, + } + ) + if not self._responses: + raise AssertionError("no fake response left") + response = self._responses.pop(0) + return response.status, json.dumps(response.payload).encode("utf-8") + + +class _ExplodingTransport: + def request( + self, + method: str, + url: str, + *, + headers: Optional[dict[str, str]] = None, + body: Optional[bytes] = None, + timeout: float = 0, + ) -> tuple[int, bytes]: + raise TimeoutError("network down") + + +class WeComBotClientTests(unittest.TestCase): + def test_send_text_posts_robot_webhook_payload(self) -> None: + transport = _FakeTransport([_FakeResponse(200, {"errcode": 0, "errmsg": "ok"})]) + client = WeComBotClient( + config=WeComBotConfig(webhook_key="robot-key", timeout_seconds=3.0), + transport=transport, + ) + + result = client.send_text( + content="新订单已支付,请及时跟进", + mentioned_list=["@all"], + mentioned_mobile_list=["13800138000"], + ) + + self.assertEqual(result["errmsg"], "ok") + self.assertEqual(len(transport.calls), 1) + self.assertIn("/cgi-bin/webhook/send?key=robot-key", transport.calls[0]["url"]) + payload = json.loads(transport.calls[0]["body"].decode("utf-8")) + self.assertEqual(payload["msgtype"], "text") + self.assertEqual(payload["text"]["content"], "新订单已支付,请及时跟进") + self.assertEqual(payload["text"]["mentioned_list"], ["@all"]) + self.assertEqual(payload["text"]["mentioned_mobile_list"], ["13800138000"]) + + def test_transport_error_wrapped_as_wecom_api_error(self) -> None: + client = WeComBotClient( + config=WeComBotConfig(webhook_key="robot-key"), + transport=_ExplodingTransport(), + ) + + with self.assertRaises(WeComAPIError) as cm: + client.send_text(content="hello") + self.assertEqual(cm.exception.endpoint, "/cgi-bin/webhook/send") + self.assertIn("transport_error", str(cm.exception)) + + def test_from_env_missing_webhook_key_fails_fast(self) -> None: + old = os.environ.get("GAOKAO_WECOM_BOT_KEY") + try: + os.environ.pop("GAOKAO_WECOM_BOT_KEY", None) + with self.assertRaises(WeComConfigError): + WeComBotClient.from_env() + finally: + if old is None: + os.environ.pop("GAOKAO_WECOM_BOT_KEY", None) + else: + os.environ["GAOKAO_WECOM_BOT_KEY"] = old + + +class WeComAppClientTests(unittest.TestCase): + def test_access_token_cached_until_expiry(self) -> None: + now = [1000.0] + transport = _FakeTransport( + [ + _FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}), + _FakeResponse(200, {"errcode": 0, "errmsg": "ok", "invaliduser": ""}), + _FakeResponse(200, {"errcode": 0, "errmsg": "ok", "invaliduser": ""}), + ] + ) + client = WeComAppClient( + config=WeComAppConfig( + corp_id="corp-id", + corp_secret="corp-secret", + agent_id=1000002, + timeout_seconds=3.0, + token_refresh_skew_seconds=60.0, + ), + transport=transport, + clock=lambda: now[0], + ) + + client.send_text(to_user="zhangsan", content="第一条") + now[0] += 300 + client.send_text(to_party="party-1", content="第二条") + + token_fetches = [ + call for call in transport.calls if "/cgi-bin/gettoken?" in call["url"] + ] + self.assertEqual(len(token_fetches), 1) + payload = json.loads(transport.calls[-1]["body"].decode("utf-8")) + self.assertEqual(payload["msgtype"], "text") + self.assertEqual(payload["agentid"], 1000002) + self.assertEqual(payload["toparty"], "party-1") + self.assertEqual(payload["text"]["content"], "第二条") + + def test_send_text_supports_duplicate_check_and_safe(self) -> None: + transport = _FakeTransport( + [ + _FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}), + _FakeResponse(200, {"errcode": 0, "errmsg": "ok", "invaliduser": ""}), + ] + ) + client = WeComAppClient( + config=WeComAppConfig( + corp_id="corp-id", corp_secret="corp-secret", agent_id=42 + ), + transport=transport, + ) + + result = client.send_text( + to_user="lisi", + content="请尽快联系家长", + safe=1, + enable_duplicate_check=1, + duplicate_check_interval=600, + ) + + self.assertEqual(result["errmsg"], "ok") + payload = json.loads(transport.calls[-1]["body"].decode("utf-8")) + self.assertEqual(payload["touser"], "lisi") + self.assertEqual(payload["safe"], 1) + self.assertEqual(payload["enable_duplicate_check"], 1) + self.assertEqual(payload["duplicate_check_interval"], 600) + + def test_upstream_errcode_raises_structured_error(self) -> None: + transport = _FakeTransport( + [ + _FakeResponse(200, {"access_token": "token-1", "expires_in": 7200}), + _FakeResponse(200, {"errcode": 81013, "errmsg": "invalid userid"}), + ] + ) + client = WeComAppClient( + config=WeComAppConfig( + corp_id="corp-id", corp_secret="corp-secret", agent_id=42 + ), + transport=transport, + ) + + with self.assertRaises(WeComAPIError) as cm: + client.send_text(to_user="bad-user", content="hello") + self.assertEqual(cm.exception.errcode, 81013) + self.assertIn("invalid userid", str(cm.exception)) + + def test_from_env_missing_secret_fails_fast(self) -> None: + old_id = os.environ.get("GAOKAO_WECOM_CORP_ID") + old_secret = os.environ.get("GAOKAO_WECOM_CORP_SECRET") + old_agent = os.environ.get("GAOKAO_WECOM_AGENT_ID") + try: + os.environ["GAOKAO_WECOM_CORP_ID"] = "corp-id" + os.environ.pop("GAOKAO_WECOM_CORP_SECRET", None) + os.environ["GAOKAO_WECOM_AGENT_ID"] = "42" + with self.assertRaises(WeComConfigError): + WeComAppClient.from_env() + finally: + if old_id is None: + os.environ.pop("GAOKAO_WECOM_CORP_ID", None) + else: + os.environ["GAOKAO_WECOM_CORP_ID"] = old_id + if old_secret is None: + os.environ.pop("GAOKAO_WECOM_CORP_SECRET", None) + else: + os.environ["GAOKAO_WECOM_CORP_SECRET"] = old_secret + if old_agent is None: + os.environ.pop("GAOKAO_WECOM_AGENT_ID", None) + else: + os.environ["GAOKAO_WECOM_AGENT_ID"] = old_agent diff --git a/data/channel_sync/tests/test_xianyu_channel.py b/data/channel_sync/tests/test_xianyu_channel.py new file mode 100644 index 0000000..e05dbe1 --- /dev/null +++ b/data/channel_sync/tests/test_xianyu_channel.py @@ -0,0 +1,928 @@ +"""T8.1 闲鱼 Webhook 集成测试 + +覆盖(与设计文档 CHANNEL_INTEGRATION.md §8 验收标准一一对应): + +- signature:sign/verify roundtrip、签名错误拒绝、时间戳过期拒绝、nonce 重放拒绝 +- xianyu_adapter:JSON 解析、必填字段缺失、PII 字段丢弃、金额多种格式 +- dao_extension:insert / update / unchanged / illegal_transition +- audit:record / count_by_event / list_recent +- poller:fetched / inserted / updated / unchanged / rejected、cursor 推进 +- webhook_server:end-to-end POST /webhook/xianyu(200/400/401/408/409/429) + +T8.1 后续修复补强: +- parse_event: event_id='0' / amount=0 允许通过 +- webhook_server: _client_ip 在缺 X-Forwarded-For 时回退到 socket client_address +- webhook_server: 500 JSON 响应带 rejected=true;do_POST 顶层兜底返回 JSON +- poller: fetched>0 但缺时间戳时,cursor 仍给出合理推进 +""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import sys +import tempfile +import threading +import time +import unittest +from http.client import HTTPConnection +from http.server import HTTPServer +from pathlib import Path + +os.environ.setdefault("GAOKAO_XIANYU_WEBHOOK_SECRET", "test-secret-for-unit-tests") +os.environ.setdefault("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-unit-tests") + +# 把项目根加进 sys.path,便于 ``data.xxx`` 导入 +PROJECT_ROOT = Path(__file__).resolve().parents[3] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from data.orders.models import Order # noqa: E402 +from data.orders.schema import apply_schema # noqa: E402 +from data.channel_sync.audit import ( # noqa: E402 + apply_audit_schema, + count_by_event, + list_recent, + record, + WebhookAuditEntry, +) +from data.channel_sync.dao_extension import ( # noqa: E402 + upsert_by_external_id, +) +from data.channel_sync.poller import ( # noqa: E402 + PollReport, + _compute_new_cursor, + poll_once, +) +from data.channel_sync.signature import ( # noqa: E402 + DEFAULT_TS_TOLERANCE_SECONDS, + SignatureError, + _NonceCache, + reset_nonce_cache_for_tests, + sha256_hex, + sign, + verify, +) +from data.channel_sync.webhook_server import ( # noqa: E402 + ROUTE_PATH, + _client_ip, + close_db_for_tests, + make_server, + reset_rate_limit_for_tests, +) +from data.channel_sync.xianyu_adapter import ( # noqa: E402 + EVENT_STATUS_MAP, + XianyuEventError, + parse_event, + target_status, + to_order, +) + + +# --------------------------------------------------------------------------- +# 工具函数 +# --------------------------------------------------------------------------- + + +def make_event_body( + *, + event_id: str = "evt-1", + event_type: str = "order.paid", + order_id: str = "XY-001", + service_version: str = "standard", + amount: object = "99.00", + customer_name: str = "张三", + customer_phone: str = "13800000000", + extra: dict | None = None, +) -> bytes: + payload = { + "event_id": event_id, + "event_type": event_type, + "order_id": order_id, + "service_version": service_version, + "amount": amount, + "customer_name": customer_name, + "customer_phone": customer_phone, + } + if extra: + payload.update(extra) + return json.dumps(payload, ensure_ascii=False).encode("utf-8") + + +def fresh_db() -> tuple[sqlite3.Connection, str]: + f = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + f.close() + conn = apply_schema(f.name) + apply_audit_schema(conn) + return conn, f.name + + +# --------------------------------------------------------------------------- +# signature +# --------------------------------------------------------------------------- + + +class SignatureTests(unittest.TestCase): + def setUp(self) -> None: + reset_nonce_cache_for_tests() + + def test_sign_verify_roundtrip(self) -> None: + body = b'{"a":1}' + sig, ts, nonce = sign(body) + verify(body, sig, ts, nonce) # 不抛即通过 + + def test_signature_mismatch_raises(self) -> None: + body = b'{"a":1}' + sig, ts, nonce = sign(body, secret="secret-A") + with self.assertRaises(SignatureError) as cm: + verify(body, sig, ts, nonce, secret="secret-B") + self.assertIn("signature_mismatch", str(cm.exception)) + + def test_missing_prefix_rejected(self) -> None: + with self.assertRaises(SignatureError) as cm: + verify(b"x", "abc123", int(time.time()), "nonce1") + self.assertIn("malformed_signature", str(cm.exception)) + + def test_timestamp_out_of_range(self) -> None: + body = b"x" + old_ts = int(time.time()) - DEFAULT_TS_TOLERANCE_SECONDS - 10 + sig, _, nonce = sign(body, timestamp=old_ts) + with self.assertRaises(SignatureError) as cm: + verify(body, sig, old_ts, nonce, now=time.time()) + self.assertIn("timestamp_out_of_range", str(cm.exception)) + + def test_nonce_replay_rejected(self) -> None: + body = b"x" + sig, ts, nonce = sign(body) + verify(body, sig, ts, nonce) + with self.assertRaises(SignatureError) as cm: + verify(body, sig, ts, nonce) + self.assertIn("nonce_replay", str(cm.exception)) + + def test_nonce_cache_ttl_expiry(self) -> None: + cache = _NonceCache(ttl_seconds=10) + ts = int(time.time()) + self.assertTrue(cache.remember(ts, "nonce-X", now=100.0)) + # 90s 后(> TTL 10s)应该视为新条目 + self.assertTrue(cache.remember(ts, "nonce-X", now=200.0)) + + def test_nonce_cache_max_size_evicts(self) -> None: + cache = _NonceCache(ttl_seconds=600, max_size=2) + cache.remember(1, "a", now=0.0) + cache.remember(2, "b", now=0.0) + cache.remember(3, "c", now=0.0) # 触发淘汰 + # 旧条目 'a' 应该被淘汰,重新插入应该成功 + self.assertTrue(cache.remember(1, "a", now=0.0)) + + def test_sha256_hex(self) -> None: + self.assertEqual( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ) + + def test_secret_missing_raises(self) -> None: + from data.channel_sync import signature as sig_mod + + old = os.environ.pop("GAOKAO_XIANYU_WEBHOOK_SECRET", None) + try: + with self.assertRaises(SignatureError): + sig_mod.get_webhook_secret() + finally: + if old is not None: + os.environ["GAOKAO_XIANYU_WEBHOOK_SECRET"] = old + + +# --------------------------------------------------------------------------- +# xianyu_adapter +# --------------------------------------------------------------------------- + + +class XianyuAdapterTests(unittest.TestCase): + def test_parse_event_ok(self) -> None: + body = make_event_body() + ev = parse_event(body) + self.assertEqual(ev.event_id, "evt-1") + self.assertEqual(ev.event_type, "order.paid") + self.assertEqual(ev.order_id, "XY-001") + self.assertEqual(ev.service_version, "standard") + self.assertEqual(ev.amount_cents, 9900) + self.assertEqual(ev.pii_dropped_fields, []) + + def test_parse_event_amount_int_passes_through(self) -> None: + body = make_event_body(amount=9900) + ev = parse_event(body) + self.assertEqual(ev.amount_cents, 9900) + + def test_parse_event_zero_amount_is_allowed(self) -> None: + """amount=0 / event_id='0' 必须被识别为合法值,不能误判为缺失。""" + body = make_event_body(event_id="0", amount=0) + ev = parse_event(body) + self.assertEqual(ev.event_id, "0") + self.assertEqual(ev.amount_cents, 0) + + def test_parse_event_amount_yuan_string(self) -> None: + body = make_event_body(amount="199.50") + ev = parse_event(body) + self.assertEqual(ev.amount_cents, 19950) + + def test_parse_event_pii_dropped(self) -> None: + body = make_event_body(extra={"id_card": "330106199001011234"}) + ev = parse_event(body) + self.assertIn("id_card", ev.pii_dropped_fields) + + def test_parse_event_missing_required(self) -> None: + body = b'{"event_id":"e1"}' # 缺多个必填 + with self.assertRaises(XianyuEventError) as cm: + parse_event(body) + self.assertIn("必填字段缺失", str(cm.exception)) + + def test_parse_event_unknown_event_type(self) -> None: + body = make_event_body(event_type="order.exploded") + with self.assertRaises(XianyuEventError) as cm: + parse_event(body) + self.assertIn("未知 event_type", str(cm.exception)) + + def test_parse_event_invalid_amount(self) -> None: + body = make_event_body(amount="not-a-number") + with self.assertRaises(XianyuEventError): + parse_event(body) + + def test_parse_event_negative_amount(self) -> None: + body = make_event_body(amount=-1) + with self.assertRaises(XianyuEventError): + parse_event(body) + + def test_parse_event_invalid_json(self) -> None: + with self.assertRaises(XianyuEventError): + parse_event(b"not json") + + def test_parse_event_non_object(self) -> None: + with self.assertRaises(XianyuEventError): + parse_event(b"[1,2,3]") + + def test_target_status_mapping(self) -> None: + for evt, status in EVENT_STATUS_MAP.items(): + body = make_event_body(event_type=evt) + ev = parse_event(body) + self.assertEqual(target_status(ev), status) + + def test_to_order_minimal(self) -> None: + body = make_event_body() + ev = parse_event(body) + order = to_order(ev) + self.assertEqual(order.source, "xianyu") + self.assertEqual(order.external_id, "XY-001") + self.assertEqual(order.status, "paid") + self.assertEqual(order.amount_cents, 9900) + self.assertEqual(order.customer_name, "张三") + # customer_phone_hash 自动派生 + self.assertEqual(order.customer_phone_hash, order.customer_phone_hash) + self.assertIsNotNone(order.customer_phone_hash) + + +# --------------------------------------------------------------------------- +# dao_extension +# --------------------------------------------------------------------------- + + +class DaoExtensionTests(unittest.TestCase): + def setUp(self) -> None: + self.conn, self.path = fresh_db() + + def tearDown(self) -> None: + self.conn.close() + Path(self.path).unlink(missing_ok=True) + + def _first_order(self) -> "Order": + ev = parse_event(make_event_body()) + return to_order(ev) + + def test_insert_then_unchanged(self) -> None: + order = self._first_order() + r1 = upsert_by_external_id(self.conn, order) + self.assertEqual(r1.action, "inserted") + r2 = upsert_by_external_id(self.conn, to_order(parse_event(make_event_body()))) + self.assertEqual(r2.action, "unchanged") + + def test_legal_status_transition_updates(self) -> None: + upsert_by_external_id(self.conn, self._first_order()) # paid + r = upsert_by_external_id( + self.conn, + to_order(parse_event(make_event_body(event_type="order.delivered"))), + ) + self.assertEqual(r.action, "updated") + self.assertEqual(r.old_status, "paid") + self.assertEqual(r.new_status, "serving") + + def test_illegal_status_transition_rejected(self) -> None: + upsert_by_external_id(self.conn, self._first_order()) # paid + # serving (order.delivered) → completed (order.completed) 非法 + upsert_by_external_id( + self.conn, + to_order(parse_event(make_event_body(event_type="order.delivered"))), + ) + r = upsert_by_external_id( + self.conn, + to_order(parse_event(make_event_body(event_type="order.completed"))), + ) + self.assertEqual(r.action, "illegal_transition") + self.assertIsNotNone(r.error) + + def test_external_id_required(self) -> None: + order = self._first_order() + order.external_id = None + r = upsert_by_external_id(self.conn, order) + self.assertEqual(r.action, "illegal_transition") + + def test_insert_status_history_writes_row(self) -> None: + upsert_by_external_id(self.conn, self._first_order()) + n = self.conn.execute("SELECT COUNT(*) FROM order_status_history").fetchone()[0] + self.assertEqual(n, 1) + + def test_unique_external_id_constraint(self) -> None: + """(source, external_id) 唯一索引存在,重复 insert 会被 SQLite 拒。""" + # 通过 raw insert 模拟违反唯一索引 + upsert_by_external_id(self.conn, self._first_order()) + with self.assertRaises(sqlite3.IntegrityError): + self.conn.execute( + """ + INSERT INTO orders( + id, source, external_id, service_version, amount_cents, + status, status_updated_at, created_at + ) VALUES ('GKO-OTHER', 'xianyu', 'XY-001', 'basic', 100, + 'pending', '2026-01-01T00:00:00+00:00', + '2026-01-01T00:00:00+00:00') + """ + ) + + +# --------------------------------------------------------------------------- +# audit +# --------------------------------------------------------------------------- + + +class AuditTests(unittest.TestCase): + def setUp(self) -> None: + self.conn, self.path = fresh_db() + + def tearDown(self) -> None: + self.conn.close() + Path(self.path).unlink(missing_ok=True) + + def test_record_and_count(self) -> None: + record( + self.conn, + WebhookAuditEntry( + channel="xianyu", + decision="accepted", + event_id="e1", + raw_body=b"x", + ), + ) + self.assertEqual(count_by_event(self.conn, "xianyu", "e1"), 1) + + def test_record_rejects_invalid_decision(self) -> None: + with self.assertRaises(ValueError): + WebhookAuditEntry(channel="xianyu", decision="weird") + + def test_list_recent_orders_by_id_desc(self) -> None: + for i in range(3): + record( + self.conn, + WebhookAuditEntry( + channel="xianyu", + decision="accepted", + event_id=f"e{i}", + raw_body=str(i).encode(), + ), + ) + rows = list_recent(self.conn, limit=2) + self.assertEqual(len(rows), 2) + self.assertGreater(rows[0]["id"], rows[1]["id"]) + + def test_apply_audit_schema_idempotent(self) -> None: + apply_audit_schema(self.conn) + apply_audit_schema(self.conn) + n = self.conn.execute( + "SELECT COUNT(*) FROM sqlite_master " + "WHERE type='table' AND name='webhook_audit'" + ).fetchone()[0] + self.assertEqual(n, 1) + + def test_audit_table_indexes_exist(self) -> None: + rows = self.conn.execute( + "SELECT name FROM sqlite_master WHERE type='index' " + "AND tbl_name='webhook_audit'" + ).fetchall() + names = {r[0] for r in rows} + self.assertIn("idx_webhook_audit_event", names) + self.assertIn("idx_webhook_audit_decision", names) + + +# --------------------------------------------------------------------------- +# poller +# --------------------------------------------------------------------------- + + +class _FakeClient: + def __init__(self, batches: list[list[dict]]) -> None: + self._batches = list(batches) + self._i = 0 + self.calls: list = [] + + def list_orders(self, since): + self.calls.append(since) + if self._i >= len(self._batches): + return [] + out = self._batches[self._i] + self._i += 1 + return out + + +class PollerTests(unittest.TestCase): + def setUp(self) -> None: + self.conn, self.path = fresh_db() + + def tearDown(self) -> None: + self.conn.close() + Path(self.path).unlink(missing_ok=True) + + def test_poller_inserts_new_orders(self) -> None: + client = _FakeClient([ + [{"raw_body": make_event_body(order_id="POLL-1", event_id="p1")}] + ]) + report = poll_once(self.conn, source="xianyu", client=client) + self.assertIsInstance(report, PollReport) + self.assertEqual(report.fetched, 1) + self.assertEqual(report.inserted, 1) + self.assertEqual(report.updated, 0) + self.assertEqual(report.unchanged, 0) + self.assertEqual(report.rejected, 0) + self.assertIsNone(report.error) + # cursor 已推进 + row = self.conn.execute( + "SELECT last_cursor, run_count FROM poller_state WHERE source='xianyu'" + ).fetchone() + self.assertIsNotNone(row) + self.assertEqual(row[1], 1) + + def test_poller_unchanged_on_repeat(self) -> None: + client = _FakeClient([ + [{"raw_body": make_event_body(order_id="POLL-2", event_id="p2a")}], + [{"raw_body": make_event_body(order_id="POLL-2", event_id="p2b")}], + ]) + r1 = poll_once(self.conn, source="xianyu", client=client) + r2 = poll_once(self.conn, source="xianyu", client=client) + self.assertEqual(r1.inserted, 1) + self.assertEqual(r2.unchanged, 1) + self.assertEqual(r2.inserted, 0) + + def test_poller_rejects_invalid_event(self) -> None: + client = _FakeClient([[{"raw_body": b"not json"}]]) + report = poll_once(self.conn, source="xianyu", client=client) + self.assertEqual(report.fetched, 1) + self.assertEqual(report.rejected, 1) + self.assertEqual(report.inserted, 0) + + def test_poller_run_recorded(self) -> None: + client = _FakeClient([]) + poll_once(self.conn, source="xianyu", client=client) + n = self.conn.execute( + "SELECT COUNT(*) FROM poller_run WHERE source='xianyu'" + ).fetchone()[0] + self.assertEqual(n, 1) + + def test_poller_records_error_on_exception(self) -> None: + class BoomClient: + def list_orders(self, since): + raise RuntimeError("api down") + + report = poll_once(self.conn, source="xianyu", client=BoomClient()) + self.assertIsNotNone(report.error) + error_text = report.error + assert error_text is not None + self.assertIn("RuntimeError", error_text) + state = self.conn.execute( + "SELECT last_error, error_count FROM poller_state WHERE source='xianyu'" + ).fetchone() + self.assertIsNotNone(state[0]) + self.assertEqual(state[1], 1) + + def test_poller_idempotent_with_webhook(self) -> None: + """Webhook upsert 之后 poller 再 upsert 同 order_id → unchanged。""" + # 1) 模拟 Webhook 入库 + upsert_by_external_id( + self.conn, + to_order(parse_event(make_event_body(order_id="IDEM-1"))), + ) + # 2) poller 拿到同 external_id → unchanged + client = _FakeClient([ + [{"raw_body": make_event_body(order_id="IDEM-1", event_id="idem2")}] + ]) + report = poll_once(self.conn, source="xianyu", client=client) + self.assertEqual(report.unchanged, 1) + self.assertEqual(report.inserted, 0) + + def test_poller_cursor_advances_when_orders_have_no_timestamps(self) -> None: + """fetched>0 但全部订单缺 updated_at/paid_at/created_at 时, + last_cursor 必须给出非 None 的推进值,避免永久停滞。""" + # 订单无任何时间戳字段 + order_dict = { + "raw_body": make_event_body(order_id="NO-TS-1", event_id="nts-1"), + # 故意不加 updated_at / paid_at / created_at + } + client = _FakeClient([[order_dict]]) + report = poll_once(self.conn, source="xianyu", client=client) + # fetched 应 > 0 + self.assertEqual(report.fetched, 1) + # 订单应被成功落库(没有时间戳不阻塞落库) + self.assertEqual(report.inserted, 1) + # cursor 必须推进(非 None) + self.assertIsNotNone( + report.last_cursor, + "fetched>0 但缺时间戳时,cursor 必须给出非 None 推进值", + ) + # 写库的 cursor 也应非 None + cursor_row = self.conn.execute( + "SELECT last_cursor FROM poller_state WHERE source='xianyu'" + ).fetchone() + self.assertIsNotNone(cursor_row) + self.assertIsNotNone(cursor_row[0]) + + def test_poller_compute_new_cursor_no_timestamps_falls_back_to_now(self) -> None: + """_compute_new_cursor 在 fetched>0 但本批无时间戳时回退到 now_iso。""" + now = "2026-06-12T12:00:00+00:00" + # 非空 raw_orders 但全部缺 updated_at/paid_at/created_at + result = _compute_new_cursor( + self.conn, + "xianyu-fallback", + [{"raw_body": b"{}"}], + now_iso=now, + ) + self.assertEqual(result, now) + + +# --------------------------------------------------------------------------- +# webhook_server 端到端 +# --------------------------------------------------------------------------- + + +def _start_server(db_path: str) -> tuple["HTTPServer", int]: + """找一个空闲端口启动 webhook_server。返回 (server, port)。""" + import socket + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + + close_db_for_tests() + reset_rate_limit_for_tests() + reset_nonce_cache_for_tests() + server = make_server(host="127.0.0.1", port=port, db_path=db_path) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, port + + +def _stop_server(server) -> None: + server.shutdown() + server.server_close() + close_db_for_tests() + + +def _post_signed( + port: int, body: bytes, secret: str | None = None +) -> tuple[int, dict, dict]: + sig, ts, nonce = sign(body, secret=secret or "test-secret-for-unit-tests") + headers = { + "X-Signature": sig, + "X-Timestamp": str(ts), + "X-Nonce": nonce, + "Content-Type": "application/json", + } + conn = HTTPConnection("127.0.0.1", port, timeout=5) + try: + conn.request("POST", ROUTE_PATH, body=body, headers=headers) + resp = conn.getresponse() + payload = json.loads(resp.read().decode("utf-8") or "{}") + return resp.status, payload, dict(resp.getheaders()) + finally: + conn.close() + + +class WebhookServerTests(unittest.TestCase): + def setUp(self) -> None: + # 每例一个独立 DB 文件 + self.tmpdir = tempfile.TemporaryDirectory() + self.db_path = str(Path(self.tmpdir.name) / "orders.db") + apply_schema(self.db_path) + apply_audit_schema(sqlite3.connect(self.db_path)) + self.server, self.port = _start_server(self.db_path) + + def tearDown(self) -> None: + _stop_server(self.server) + self.tmpdir.cleanup() + + def _count_orders(self) -> int: + conn = sqlite3.connect(self.db_path) + try: + return conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0] + finally: + conn.close() + + def test_healthz(self) -> None: + conn = HTTPConnection("127.0.0.1", self.port, timeout=5) + conn.request("GET", "/healthz") + resp = conn.getresponse() + payload = json.loads(resp.read().decode("utf-8")) + self.assertEqual(resp.status, 200) + self.assertEqual(payload["status"], "ok") + + def test_404_on_unknown_path(self) -> None: + conn = HTTPConnection("127.0.0.1", self.port, timeout=5) + conn.request("POST", "/nope", body=b"{}") + resp = conn.getresponse() + resp.read() + self.assertEqual(resp.status, 404) + + def test_accepted_inserts_order(self) -> None: + body = make_event_body(event_id="acc-1", order_id="AC-1") + status, payload, _ = _post_signed(self.port, body) + self.assertEqual(status, 200, payload) + self.assertEqual(payload["status"], "inserted") + self.assertEqual(self._count_orders(), 1) + + def test_duplicate_event_returns_200(self) -> None: + body = make_event_body(event_id="dup-1", order_id="DUP-1") + _post_signed(self.port, body) + status, payload, _ = _post_signed(self.port, body) + self.assertEqual(status, 200) + self.assertEqual(payload["status"], "duplicate") + # 仍只有 1 条 + self.assertEqual(self._count_orders(), 1) + + def test_bad_signature_returns_401(self) -> None: + body = make_event_body(event_id="bad-1", order_id="BAD-1") + status, payload, _ = _post_signed(self.port, body, secret="wrong") + self.assertEqual(status, 401, payload) + self.assertIn("rejected", payload) + self.assertEqual(self._count_orders(), 0) + + def test_missing_signature_header_returns_401(self) -> None: + body = make_event_body() + conn = HTTPConnection("127.0.0.1", self.port, timeout=5) + conn.request( + "POST", + ROUTE_PATH, + body=body, + headers={ + "Content-Type": "application/json", + "X-Timestamp": str(int(time.time())), + "X-Nonce": "x", + }, + ) + resp = conn.getresponse() + resp.read() + self.assertEqual(resp.status, 401) + + def test_old_timestamp_returns_408(self) -> None: + body = make_event_body(event_id="old-1", order_id="OLD-1") + old_ts = int(time.time()) - DEFAULT_TS_TOLERANCE_SECONDS - 10 + sig = sign(body, timestamp=old_ts)[0] + nonce = "test-nonce-old" + conn = HTTPConnection("127.0.0.1", self.port, timeout=5) + conn.request( + "POST", + ROUTE_PATH, + body=body, + headers={ + "X-Signature": sig, + "X-Timestamp": str(old_ts), + "X-Nonce": nonce, + "Content-Type": "application/json", + }, + ) + resp = conn.getresponse() + payload = json.loads(resp.read().decode("utf-8")) + self.assertEqual(resp.status, 408, payload) + self.assertEqual(self._count_orders(), 0) + + def test_parse_error_returns_400(self) -> None: + body = b"{not json" + status, payload, _ = _post_signed(self.port, body) + self.assertEqual(status, 400) + self.assertEqual(payload["error"], "parse_error") + + def test_pii_dropped_returns_400(self) -> None: + body = make_event_body( + event_id="pii-1", order_id="PII-1", extra={"id_card": "abc"} + ) + status, payload, _ = _post_signed(self.port, body) + self.assertEqual(status, 400) + self.assertEqual(payload["error"], "pii_dropped") + fields = payload.get("fields") or [] + self.assertIn("id_card", fields) + + def test_illegal_transition_returns_409(self) -> None: + # 1) paid + body1 = make_event_body(event_id="it-1", order_id="IT-1") + _post_signed(self.port, body1) + # 2) paid → serving (合法) + body2 = make_event_body( + event_id="it-2", order_id="IT-1", event_type="order.delivered" + ) + status, payload, _ = _post_signed(self.port, body2) + self.assertEqual(status, 200, payload) + # 3) serving → completed (非法: 状态机 serving→delivered→completed) + body3 = make_event_body( + event_id="it-3", order_id="IT-1", event_type="order.completed" + ) + status, payload, _ = _post_signed(self.port, body3) + self.assertEqual(status, 409, payload) + self.assertEqual(payload["error"], "illegal_transition") + + def test_audit_written(self) -> None: + body = make_event_body(event_id="aud-1", order_id="AUD-1") + _post_signed(self.port, body) + conn = sqlite3.connect(self.db_path) + try: + n = conn.execute( + "SELECT COUNT(*) FROM webhook_audit " + "WHERE channel='xianyu' AND event_id='aud-1' " + "AND decision='accepted'" + ).fetchone()[0] + self.assertEqual(n, 1) + finally: + conn.close() + + # ---- T8.1 后续修复补强 ---- + + def test_500_on_db_error_includes_rejected_true(self) -> None: + """DB 路径抛异常时,500 JSON 必须带 rejected=true 字段。""" + from data.channel_sync import webhook_server as ws + + original = ws.upsert_by_external_id + + def _boom(*a, **kw): + raise RuntimeError("simulated db failure") + + ws.upsert_by_external_id = _boom + try: + body = make_event_body(event_id="err-1", order_id="ERR-1") + status, payload, _ = _post_signed(self.port, body) + self.assertEqual(status, 500, payload) + self.assertEqual(payload.get("rejected"), True) + self.assertEqual(payload.get("error"), "server_error") + finally: + ws.upsert_by_external_id = original + + def test_500_on_unexpected_top_level_exception_returns_json(self) -> None: + """do_POST 顶层异常必须返回 JSON 500,而不是 BaseHTTPRequestHandler 默认栈。""" + from data.channel_sync import webhook_server as ws + + original_parse = ws.parse_event + + def _boom(*a, **kw): + raise RuntimeError("simulated unexpected error in parse") + + ws.parse_event = _boom + try: + body = make_event_body(event_id="top-1", order_id="TOP-1") + conn = HTTPConnection("127.0.0.1", self.port, timeout=5) + try: + sig, ts, nonce = sign(body) + conn.request( + "POST", + ROUTE_PATH, + body=body, + headers={ + "X-Signature": sig, + "X-Timestamp": str(ts), + "X-Nonce": nonce, + "Content-Type": "application/json", + }, + ) + resp = conn.getresponse() + raw = resp.read().decode("utf-8") + payload = json.loads(raw) + self.assertEqual(resp.status, 500) + self.assertEqual(payload.get("rejected"), True) + finally: + conn.close() + finally: + ws.parse_event = original_parse + + def test_500_response_is_json_content_type(self) -> None: + """500 响应必须声明 application/json,便于客户端按 JSON 解析。""" + from data.channel_sync import webhook_server as ws + + original = ws.upsert_by_external_id + + def _boom(*a, **kw): + raise RuntimeError("simulated") + + ws.upsert_by_external_id = _boom + try: + body = make_event_body(event_id="ct-1", order_id="CT-1") + conn = HTTPConnection("127.0.0.1", self.port, timeout=5) + try: + sig, ts, nonce = sign(body) + conn.request( + "POST", + ROUTE_PATH, + body=body, + headers={ + "X-Signature": sig, + "X-Timestamp": str(ts), + "X-Nonce": nonce, + "Content-Type": "application/json", + }, + ) + resp = conn.getresponse() + resp.read() + ctype = resp.getheader("Content-Type", "") + self.assertIn("application/json", ctype) + finally: + conn.close() + finally: + ws.upsert_by_external_id = original + + +class ClientIpFallbackTests(unittest.TestCase): + """_client_ip 默认不信任 XFF,显式开启后才读取代理头。""" + + def setUp(self) -> None: + self._old_trust = os.environ.get("GAOKAO_TRUST_X_FORWARDED_FOR") + os.environ.pop("GAOKAO_TRUST_X_FORWARDED_FOR", None) + + def tearDown(self) -> None: + if self._old_trust is None: + os.environ.pop("GAOKAO_TRUST_X_FORWARDED_FOR", None) + else: + os.environ["GAOKAO_TRUST_X_FORWARDED_FOR"] = self._old_trust + + def test_falls_back_to_client_address(self) -> None: + class _H: + headers: dict = {} + + self.assertEqual( + _client_ip(_H.headers, ("10.0.0.7", 54321)), + "10.0.0.7", + ) + + def test_default_does_not_trust_xff(self) -> None: + class _H: + headers: dict = {"X-Forwarded-For": "203.0.113.5, 10.0.0.1"} + + self.assertEqual( + _client_ip(_H.headers, ("10.0.0.7", 54321)), + "10.0.0.7", + ) + + def test_xff_wins_over_socket_when_explicitly_trusted(self) -> None: + os.environ["GAOKAO_TRUST_X_FORWARDED_FOR"] = "true" + + class _H: + headers: dict = {"X-Forwarded-For": "203.0.113.5, 10.0.0.1"} + + self.assertEqual( + _client_ip(_H.headers, ("10.0.0.7", 54321)), + "203.0.113.5", + ) + + def test_unknown_when_both_missing(self) -> None: + class _H: + headers: dict = {} + + self.assertEqual(_client_ip(_H.headers, None), "unknown") + + def test_handles_empty_xff(self) -> None: + class _H: + headers: dict = {"X-Forwarded-For": ""} + + # 空 XFF 视为缺失,回退到 socket + self.assertEqual( + _client_ip(_H.headers, ("192.168.1.1", 1234)), + "192.168.1.1", + ) + + def test_handles_ipv6(self) -> None: + class _H: + headers: dict = {} + + # IPv6 client_address 4-tuple 也能正确取第一项 + self.assertEqual( + _client_ip(_H.headers, ("::1", 8080, 0, 0)), + "::1", + ) + + +# --------------------------------------------------------------------------- +# 入口 +# --------------------------------------------------------------------------- + + +if __name__ == "__main__": + unittest.main() diff --git a/data/channel_sync/webhook_server.py b/data/channel_sync/webhook_server.py new file mode 100644 index 0000000..03c99a4 --- /dev/null +++ b/data/channel_sync/webhook_server.py @@ -0,0 +1,473 @@ +"""闲鱼 Webhook 接收端 (T8.1 §3.1) + +只使用 Python 标准库 ``http.server.BaseHTTPRequestHandler`` 实现,避免引入 +Flask / FastAPI 等额外依赖(与项目 "无第三方运行时依赖" 约定一致)。 + +提供的入口: + +- :class:`XianyuWebhookHandler` — BaseHTTPRequestHandler 子类,处理 + ``POST /webhook/xianyu`` 请求 +- :func:`make_server` — 返回 ``http.server.HTTPServer`` 实例 +- :func:`run` — 阻塞启动(CLI 用) + +设计要点: + +- 验签失败 / 时间戳过期 / 缺头 → 401 / 408 / 400 + 写审计 +- 同一 event_id 已 accepted → 返回 200 + 写审计 ``decision='duplicate'``,避免 + 闲鱼侧重试导致重复入库 +- 单 IP 限流 60 req/min(5.3),超出 → 429 + Retry-After +- 任何异常路径都不抛回客户端,返回结构化 JSON;500 响应统一带 ``rejected=true`` +- :func:`_client_ip` 默认使用 ``client_address``,仅在显式信任反向代理头时才采纳 + ``X-Forwarded-For``,避免攻击者伪造来源 IP 绕过限流/污染审计 + +数据库: + +- 默认从环境变量 ``GAOKAO_ORDERS_DB_PATH`` 读取;缺省 ``data/orders.db`` +- 自动确保 webhook_audit + orders schema 已应用 +""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import threading +import time +from collections import deque +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Optional + +from data.orders.schema import apply_schema + +from .audit import ( + WebhookAuditEntry, + apply_audit_schema, + count_by_event, + record, +) +from .dao_extension import upsert_by_external_id +from .signature import ( + SignatureError, + get_webhook_secret, + verify, +) +from .xianyu_adapter import ( + XianyuEventError, + parse_event, + to_order, + target_status, +) + +DEFAULT_DB_PATH = Path("data/orders.db") +DEFAULT_PORT = 8080 +RATE_LIMIT_PER_MINUTE = 60 +ROUTE_PATH = "/webhook/xianyu" + +# 模块级限流器(线程安全) +_RATE_LOCK = threading.Lock() +_RATE_BUCKETS: dict[str, deque] = {} + + +def _default_db_path() -> str: + p = os.environ.get("GAOKAO_ORDERS_DB_PATH") + return p if p else str(DEFAULT_DB_PATH) + + +def _trust_x_forwarded_for() -> bool: + raw = os.environ.get("GAOKAO_TRUST_X_FORWARDED_FOR", "").strip().lower() + return raw in {"1", "true", "yes", "on"} + + +def _check_rate_limit(ip: str, *, now: float | None = None) -> bool: + """单 IP 60 req/min 限流。返回 True 表示放行。""" + if now is None: + now = time.time() + with _RATE_LOCK: + bucket = _RATE_BUCKETS.setdefault(ip, deque()) + cutoff = now - 60.0 + while bucket and bucket[0] < cutoff: + bucket.popleft() + if len(bucket) >= RATE_LIMIT_PER_MINUTE: + return False + bucket.append(now) + return True + + +def reset_rate_limit_for_tests() -> None: + with _RATE_LOCK: + _RATE_BUCKETS.clear() + + +def _open_db(db_path: str) -> sqlite3.Connection: + """打开 DB 并确保两张 schema 都在。""" + path = Path(db_path) + if path.parent and not path.parent.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + + # 先用既有 schema helper 建表,再以跨线程可复用的连接重新打开。 + # webhook server 在测试/运行时会由主线程创建、由 server 线程处理请求; + # 默认 sqlite3 连接带 check_same_thread=True,会在请求线程执行 SQL 时抛 + # ProgrammingError,导致 500 且审计写入被吞掉。 + bootstrap = apply_schema(path) + bootstrap.close() + + conn = sqlite3.connect(str(path), check_same_thread=False) + conn.execute("PRAGMA foreign_keys = ON") + apply_audit_schema(conn) + return conn + + +# 用工厂函数为每个 server 实例创建一个 DB 连接(简单做法,单进程) +_DB_CONN: Optional[sqlite3.Connection] = None +_DB_CONN_LOCK = threading.Lock() + + +def _get_db(db_path: str) -> sqlite3.Connection: + global _DB_CONN + with _DB_CONN_LOCK: + if _DB_CONN is None: + _DB_CONN = _open_db(db_path) + return _DB_CONN + + +def close_db_for_tests() -> None: + """单测 teardown 关闭全局连接。""" + global _DB_CONN + with _DB_CONN_LOCK: + if _DB_CONN is not None: + _DB_CONN.close() + _DB_CONN = None + + +def _client_ip(headers, client_address=None) -> str: + """解析请求来源 IP。 + + 优先级: + 1. ``X-Forwarded-For`` 头的最左侧条目(代理链最近的客户端) + 2. ``client_address`` 元组(``BaseHTTPRequestHandler.client_address``) + 3. ``"unknown"``(本地测试 / 单元测试未传入) + + 攻击者可以省略 ``X-Forwarded-For`` 来绕过单 IP 限流,因此回退到 + socket 层的 client_address 是必要的。空字符串/None 也视为缺失。 + """ + fwd = headers.get("X-Forwarded-For", "") if headers else "" + if _trust_x_forwarded_for() and fwd: + first = fwd.split(",")[0].strip() + if first: + return first + if client_address is not None: + try: + host = client_address[0] + if host: + return str(host) + except (IndexError, TypeError): + pass + return "unknown" + + +def _build_handler(db_path: str): + """返回 BaseHTTPRequestHandler 子类,绑定 db_path。""" + + class XianyuWebhookHandler(BaseHTTPRequestHandler): + # 关闭 BaseHTTPRequestHandler 默认日志(写 stderr 噪音大) + def log_message(self, format: str, *args) -> None: # noqa: A002 + return + + # 路由分发 + def do_POST(self): # noqa: N802 + if self.path != ROUTE_PATH: + self._respond(404, {"error": "not_found", "path": self.path}) + return + # 顶层兜底:即使 _handle_webhook 内部 try/except 漏掉任何异常, + # 也必须返回结构化 JSON 500 而不是 BaseHTTPRequestHandler 默认栈。 + try: + self._handle_webhook() + except Exception as e: # pragma: no cover - 注入测试覆盖 + self._handle_unexpected_error(e) + + def do_GET(self): # noqa: N802 + if self.path == "/healthz": + self._respond(200, {"status": "ok"}) + else: + self._respond(404, {"error": "not_found", "path": self.path}) + + # ---- 业务 ---- + def _handle_webhook(self) -> None: + remote = _client_ip(self.headers, self.client_address) + if not _check_rate_limit(remote): + self._respond( + 429, + {"error": "rate_limited"}, + extra_headers={"Retry-After": "60"}, + ) + return + + body = self._read_body() + if body is None: + # 413 已通过 _read_body 内部返回 + return + + sig = self.headers.get("X-Signature", "") + ts = self.headers.get("X-Timestamp", "") + nonce = self.headers.get("X-Nonce", "") + + try: + verify(body, sig, ts, nonce) + except SignatureError as e: + self._audit( + decision="rejected", + reject_reason=str(e), + raw_body=body, + remote_addr=remote, + ) + # 时间戳过期 → 408;签名错误 → 401 + code = 408 if "timestamp" in str(e) else 401 + self._respond( + code, + { + "error": "rejected", + "rejected": True, + "reason": str(e), + }, + ) + return + + # 解析 + try: + event = parse_event(body) + except XianyuEventError as e: + self._audit( + decision="parse_error", + reject_reason=str(e), + raw_body=body, + remote_addr=remote, + ) + self._respond(400, {"error": "parse_error", "reason": str(e)}) + return + + # PII 字段丢弃记录 + if event.pii_dropped_fields: + self._audit( + decision="rejected", + event_id=event.event_id, + reject_reason=( + f"pii_dropped: {','.join(event.pii_dropped_fields)}" + ), + raw_body=body, + remote_addr=remote, + ) + self._respond( + 400, + { + "error": "pii_dropped", + "fields": event.pii_dropped_fields, + }, + ) + return + + # 幂等:同一 event_id 之前已 accepted → 视为 duplicate + db = _get_db(db_path) + try: + if count_by_event(db, "xianyu", event.event_id) > 0: + self._audit( + decision="duplicate", + event_id=event.event_id, + raw_body=body, + remote_addr=remote, + ) + self._respond(200, {"status": "duplicate"}) + return + + order = to_order(event) + result = upsert_by_external_id( + db, + order, + actor="xianyu_webhook", + reason=f"event_{event.event_id}_to_{target_status(event)}", + ) + if result.action == "illegal_transition": + self._audit( + decision="rejected", + event_id=event.event_id, + reject_reason=(f"illegal_transition: {result.error}"), + order_id=result.order_id, + raw_body=body, + remote_addr=remote, + ) + self._respond( + 409, + { + "error": "illegal_transition", + "reason": result.error, + "order_id": result.order_id, + }, + ) + return + self._audit( + decision="accepted", + event_id=event.event_id, + order_id=result.order_id, + raw_body=body, + remote_addr=remote, + ) + self._respond( + 200, + { + "status": result.action, + "order_id": result.order_id, + }, + ) + except Exception as e: # 兜底:DB 异常不抛回客户端 + self._audit( + decision="rejected", + event_id=event.event_id, + reject_reason=f"server_error: {type(e).__name__}", + raw_body=body, + remote_addr=remote, + ) + self._respond( + 500, + {"error": "server_error", "rejected": True}, + ) + + def _handle_unexpected_error(self, e: BaseException) -> None: + """do_POST 顶层兜底:写审计 + 返回 JSON 500。""" + try: + remote = _client_ip(self.headers, self.client_address) + except Exception: + remote = "unknown" + try: + self._audit( + decision="rejected", + reject_reason=f"top_level_error: {type(e).__name__}", + remote_addr=remote, + ) + except Exception: + pass + try: + self._respond( + 500, + {"error": "server_error", "rejected": True}, + ) + except Exception: + # 连 JSON 都写不出去时,至少不要让 BaseHTTPRequestHandler + # 把 socket 异常回吐给客户端 + pass + + # ---- helpers ---- + def _read_body(self) -> Optional[bytes]: + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self._respond(400, {"error": "invalid_content_length"}) + return None + if length < 0 or length > 1_048_576: # 1 MiB 上限 + self._respond(413, {"error": "body_too_large"}) + return None + try: + return self.rfile.read(length) if length > 0 else b"" + except Exception: + self._respond(400, {"error": "read_error"}) + return None + + def _audit( + self, + *, + decision: str, + raw_body: Optional[bytes] = None, + event_id: Optional[str] = None, + reject_reason: Optional[str] = None, + order_id: Optional[str] = None, + remote_addr: Optional[str] = None, + ) -> None: + try: + db = _get_db(db_path) + record( + db, + WebhookAuditEntry( + channel="xianyu", + decision=decision, + event_id=event_id, + reject_reason=reject_reason, + order_id=order_id, + raw_body=raw_body, + remote_addr=remote_addr, + ), + ) + except Exception: + # 审计失败不影响主响应 + pass + + def _respond( + self, + status: int, + body: dict, + *, + extra_headers: Optional[dict] = None, + ) -> None: + payload = json.dumps(body, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + if extra_headers: + for k, v in extra_headers.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(payload) + + return XianyuWebhookHandler + + +def make_server( + *, + host: str = "0.0.0.0", + port: int = DEFAULT_PORT, + db_path: str | None = None, +) -> HTTPServer: + """构造 HTTPServer 实例(未启动)。""" + if db_path is None: + db_path = _default_db_path() + handler_cls = _build_handler(db_path) + # 预热 DB 连接 + _get_db(db_path) + return HTTPServer((host, port), handler_cls) + + +def run( + *, + host: str = "0.0.0.0", + port: int = DEFAULT_PORT, + db_path: str | None = None, +) -> None: + """阻塞启动。""" + server = make_server(host=host, port=port, db_path=db_path) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + close_db_for_tests() + server.server_close() + + +def main(argv: Optional[list[str]] = None) -> int: + """CLI: ``python -m data.channel_sync.webhook_server --port 8080``""" + parser = argparse.ArgumentParser(description="闲鱼 Webhook 接收端 (T8.1)") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument( + "--db-path", + default=None, + help="SQLite DB 路径;默认 $GAOKAO_ORDERS_DB_PATH 或 data/orders.db", + ) + args = parser.parse_args(argv) + # 启动期就检查密钥 + get_webhook_secret() + run(host=args.host, port=args.port, db_path=args.db_path) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/data/channel_sync/wechat_adapter.py b/data/channel_sync/wechat_adapter.py new file mode 100644 index 0000000..5076ba5 --- /dev/null +++ b/data/channel_sync/wechat_adapter.py @@ -0,0 +1,337 @@ +"""微信 SDK 最小集成层(T8.2) + +目标:不依赖第三方微信 SDK,仅用标准库封装两类常用能力: +- 订阅消息推送(/cgi-bin/message/subscribe/send) +- 客服文本消息(/cgi-bin/message/custom/send) + +设计取向: +- access_token 自动缓存与过期刷新 +- 纯标准库 HTTP 传输,便于单测注入 fake transport +- 上游 errcode / HTTP 状态统一包装为结构化异常 +""" + +from __future__ import annotations + +import json +import os +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Optional, Protocol +from urllib import error, parse, request + + +DEFAULT_API_BASE = "https://api.weixin.qq.com" +DEFAULT_TIMEOUT_SECONDS = 5.0 +DEFAULT_TOKEN_REFRESH_SKEW_SECONDS = 60.0 + + +class WeChatConfigError(ValueError): + """微信配置缺失或非法。""" + + +class WeChatAPIError(RuntimeError): + """微信接口返回错误。""" + + def __init__( + self, + message: str, + *, + errcode: Optional[int] = None, + errmsg: Optional[str] = None, + http_status: Optional[int] = None, + endpoint: Optional[str] = None, + ) -> None: + super().__init__(message) + self.errcode = errcode + self.errmsg = errmsg + self.http_status = http_status + self.endpoint = endpoint + + +@dataclass(frozen=True) +class WeChatConfig: + app_id: str + app_secret: str + api_base: str = DEFAULT_API_BASE + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS + token_refresh_skew_seconds: float = DEFAULT_TOKEN_REFRESH_SKEW_SECONDS + + def __post_init__(self) -> None: + if not self.app_id: + raise WeChatConfigError("GAOKAO_WECHAT_APP_ID 未设置") + if not self.app_secret: + raise WeChatConfigError("GAOKAO_WECHAT_APP_SECRET 未设置") + if self.timeout_seconds <= 0: + raise WeChatConfigError("timeout_seconds 必须大于 0") + if self.token_refresh_skew_seconds < 0: + raise WeChatConfigError("token_refresh_skew_seconds 不能为负") + + +@dataclass +class AccessToken: + token: str + expires_at: float + + def valid(self, now: float, skew_seconds: float) -> bool: + return bool(self.token) and now < (self.expires_at - skew_seconds) + + +class Transport(Protocol): + def request( + self, + method: str, + url: str, + *, + headers: Optional[dict[str, str]] = None, + body: Optional[bytes] = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> tuple[int, bytes]: ... + + +class UrllibTransport: + """基于 urllib 的标准库 HTTP 传输实现。""" + + def request( + self, + method: str, + url: str, + *, + headers: Optional[dict[str, str]] = None, + body: Optional[bytes] = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> tuple[int, bytes]: + req = request.Request(url=url, method=method.upper(), data=body) + for key, value in (headers or {}).items(): + req.add_header(key, value) + try: + with request.urlopen(req, timeout=timeout) as resp: + return int(resp.status), resp.read() + except error.HTTPError as e: + return int(e.code), e.read() + + +class WeChatClient: + """微信开放接口最小客户端。""" + + def __init__( + self, + *, + config: WeChatConfig, + transport: Optional[Transport] = None, + clock: Optional[Callable[[], float]] = None, + ) -> None: + self._config = config + self._transport = transport or UrllibTransport() + self._clock = clock or time.time + self._access_token: Optional[AccessToken] = None + + @classmethod + def from_env( + cls, + *, + transport: Optional[Transport] = None, + clock: Optional[Callable[[], float]] = None, + ) -> "WeChatClient": + return cls( + config=WeChatConfig( + app_id=os.environ.get("GAOKAO_WECHAT_APP_ID", ""), + app_secret=os.environ.get("GAOKAO_WECHAT_APP_SECRET", ""), + api_base=os.environ.get("GAOKAO_WECHAT_API_BASE", DEFAULT_API_BASE), + timeout_seconds=float( + os.environ.get( + "GAOKAO_WECHAT_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS + ) + ), + token_refresh_skew_seconds=float( + os.environ.get( + "GAOKAO_WECHAT_TOKEN_REFRESH_SKEW_SECONDS", + DEFAULT_TOKEN_REFRESH_SKEW_SECONDS, + ) + ), + ), + transport=transport, + clock=clock, + ) + + def _now(self) -> float: + return float(self._clock()) + + def _api_url(self, path: str) -> str: + base = self._config.api_base.rstrip("/") + return f"{base}/{path.lstrip('/')}" + + def _request_json( + self, + method: str, + path: str, + *, + query: Optional[dict[str, Any]] = None, + payload: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + url = self._api_url(path) + if query: + url = f"{url}?{parse.urlencode(query)}" + body = None + headers: dict[str, str] = {} + if payload is not None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json; charset=utf-8" + try: + status, raw = self._transport.request( + method.upper(), + url, + headers=headers, + body=body, + timeout=self._config.timeout_seconds, + ) + except Exception as e: + raise WeChatAPIError( + f"微信接口 transport_error: {type(e).__name__}: {e}", + endpoint=path, + ) from e + try: + data = json.loads(raw.decode("utf-8") or "{}") + except Exception as e: # pragma: no cover - defensive + raise WeChatAPIError( + f"微信接口返回非 JSON: {e}", http_status=status, endpoint=path + ) from e + if status >= 400: + raise WeChatAPIError( + f"微信接口 HTTP {status}", + http_status=status, + endpoint=path, + errmsg=data.get("errmsg") if isinstance(data, dict) else None, + errcode=data.get("errcode") if isinstance(data, dict) else None, + ) + if not isinstance(data, dict): + raise WeChatAPIError( + "微信接口返回结构非法", + http_status=status, + endpoint=path, + ) + errcode = data.get("errcode", 0) + if errcode not in (0, None): + raise WeChatAPIError( + f"微信接口错误: {errcode} {data.get('errmsg', '')}".strip(), + errcode=int(errcode), + errmsg=str(data.get("errmsg", "")), + http_status=status, + endpoint=path, + ) + return data + + def _get_access_token(self, *, force_refresh: bool = False) -> str: + now = self._now() + if ( + not force_refresh + and self._access_token is not None + and self._access_token.valid(now, self._config.token_refresh_skew_seconds) + ): + return self._access_token.token + + data = self._request_json( + "GET", + "/cgi-bin/token", + query={ + "grant_type": "client_credential", + "appid": self._config.app_id, + "secret": self._config.app_secret, + }, + ) + token = str(data.get("access_token", "")) + if not token: + raise WeChatAPIError( + "微信接口未返回 access_token", + http_status=200, + endpoint="/cgi-bin/token", + ) + expires_in = float(data.get("expires_in", 7200)) + self._access_token = AccessToken(token=token, expires_at=now + expires_in) + return token + + @staticmethod + def _normalize_msg_data(data: dict[str, Any]) -> dict[str, dict[str, Any]]: + normalized: dict[str, dict[str, Any]] = {} + for key, value in data.items(): + if isinstance(value, dict) and "value" in value: + normalized[key] = {"value": value["value"]} + else: + normalized[key] = {"value": value} + return normalized + + def send_subscribe_message( + self, + *, + openid: str, + template_id: str, + data: dict[str, Any], + page: Optional[str] = None, + miniprogram_state: Optional[str] = None, + lang: Optional[str] = None, + ) -> dict[str, Any]: + token = self._get_access_token() + payload: dict[str, Any] = { + "touser": openid, + "template_id": template_id, + "data": self._normalize_msg_data(data), + } + if page: + payload["page"] = page + if miniprogram_state: + payload["miniprogram_state"] = miniprogram_state + if lang: + payload["lang"] = lang + return self._request_json( + "POST", + "/cgi-bin/message/subscribe/send", + query={"access_token": token}, + payload=payload, + ) + + def send_custom_message( + self, + *, + openid: str, + msgtype: str, + payload: dict[str, Any], + kf_account: Optional[str] = None, + ) -> dict[str, Any]: + token = self._get_access_token() + body: dict[str, Any] = {"touser": openid, "msgtype": msgtype, msgtype: payload} + if kf_account: + body["customservice"] = {"kf_account": kf_account} + return self._request_json( + "POST", + "/cgi-bin/message/custom/send", + query={"access_token": token}, + payload=body, + ) + + def send_custom_text( + self, + *, + openid: str, + content: str, + kf_account: Optional[str] = None, + ) -> dict[str, Any]: + return self.send_custom_message( + openid=openid, + msgtype="text", + payload={"content": content}, + kf_account=kf_account, + ) + + +__all__ = [ + "AccessToken", + "DEFAULT_API_BASE", + "DEFAULT_TIMEOUT_SECONDS", + "DEFAULT_TOKEN_REFRESH_SKEW_SECONDS", + "Transport", + "UrllibTransport", + "WeChatAPIError", + "WeChatClient", + "WeChatConfig", + "WeChatConfigError", +] diff --git a/data/channel_sync/wecom_adapter.py b/data/channel_sync/wecom_adapter.py new file mode 100644 index 0000000..eae1083 --- /dev/null +++ b/data/channel_sync/wecom_adapter.py @@ -0,0 +1,338 @@ +"""企业微信最小集成层(T8.3) + +目标:不依赖第三方企业微信 SDK,仅用标准库封装两类常用能力: +- 通知机器人 webhook 文本消息(/cgi-bin/webhook/send) +- 应用消息文本推送(/cgi-bin/message/send) + +设计取向: +- 机器人与应用消息分离建模,避免混淆不同鉴权方式 +- 应用 access_token 自动缓存与过期刷新 +- 纯标准库 HTTP 传输,便于单测注入 fake transport +- 上游 errcode / HTTP 状态 / transport 异常统一包装为结构化异常 +""" + +from __future__ import annotations + +import json +import os +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Optional +from urllib import parse + +from .wechat_adapter import AccessToken, Transport, UrllibTransport + +DEFAULT_API_BASE = "https://qyapi.weixin.qq.com" +DEFAULT_TIMEOUT_SECONDS = 5.0 +DEFAULT_TOKEN_REFRESH_SKEW_SECONDS = 60.0 + + +class WeComConfigError(ValueError): + """企业微信配置缺失或非法。""" + + +class WeComAPIError(RuntimeError): + """企业微信接口返回错误。""" + + def __init__( + self, + message: str, + *, + errcode: Optional[int] = None, + errmsg: Optional[str] = None, + http_status: Optional[int] = None, + endpoint: Optional[str] = None, + ) -> None: + super().__init__(message) + self.errcode = errcode + self.errmsg = errmsg + self.http_status = http_status + self.endpoint = endpoint + + +@dataclass(frozen=True) +class WeComBotConfig: + webhook_key: str + api_base: str = DEFAULT_API_BASE + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS + + def __post_init__(self) -> None: + if not self.webhook_key: + raise WeComConfigError("GAOKAO_WECOM_BOT_KEY 未设置") + if self.timeout_seconds <= 0: + raise WeComConfigError("timeout_seconds 必须大于 0") + + +@dataclass(frozen=True) +class WeComAppConfig: + corp_id: str + corp_secret: str + agent_id: int + api_base: str = DEFAULT_API_BASE + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS + token_refresh_skew_seconds: float = DEFAULT_TOKEN_REFRESH_SKEW_SECONDS + + def __post_init__(self) -> None: + if not self.corp_id: + raise WeComConfigError("GAOKAO_WECOM_CORP_ID 未设置") + if not self.corp_secret: + raise WeComConfigError("GAOKAO_WECOM_CORP_SECRET 未设置") + if self.agent_id <= 0: + raise WeComConfigError("GAOKAO_WECOM_AGENT_ID 必须为正整数") + if self.timeout_seconds <= 0: + raise WeComConfigError("timeout_seconds 必须大于 0") + if self.token_refresh_skew_seconds < 0: + raise WeComConfigError("token_refresh_skew_seconds 不能为负") + + +class _WeComBaseClient: + def __init__( + self, + *, + api_base: str, + timeout_seconds: float, + transport: Optional[Transport] = None, + ) -> None: + self._api_base = api_base + self._timeout_seconds = timeout_seconds + self._transport = transport or UrllibTransport() + + def _api_url(self, path: str) -> str: + base = self._api_base.rstrip("/") + return f"{base}/{path.lstrip('/')}" + + def _request_json( + self, + method: str, + path: str, + *, + query: Optional[dict[str, Any]] = None, + payload: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + url = self._api_url(path) + if query: + url = f"{url}?{parse.urlencode(query)}" + body = None + headers: dict[str, str] = {} + if payload is not None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json; charset=utf-8" + try: + status, raw = self._transport.request( + method.upper(), + url, + headers=headers, + body=body, + timeout=self._timeout_seconds, + ) + except Exception as e: + raise WeComAPIError( + f"企业微信接口 transport_error: {e}", + endpoint=path, + ) from e + try: + data = json.loads(raw.decode("utf-8") or "{}") + except Exception as e: # pragma: no cover - defensive + raise WeComAPIError( + f"企业微信接口返回非 JSON: {e}", http_status=status, endpoint=path + ) from e + if status >= 400: + raise WeComAPIError( + f"企业微信接口 HTTP {status}", + http_status=status, + endpoint=path, + errmsg=data.get("errmsg") if isinstance(data, dict) else None, + errcode=data.get("errcode") if isinstance(data, dict) else None, + ) + if not isinstance(data, dict): + raise WeComAPIError( + "企业微信接口返回结构非法", + http_status=status, + endpoint=path, + ) + errcode = data.get("errcode", 0) + if errcode not in (0, None): + raise WeComAPIError( + f"企业微信接口错误: {errcode} {data.get('errmsg', '')}".strip(), + errcode=int(errcode), + errmsg=str(data.get("errmsg", "")), + http_status=status, + endpoint=path, + ) + return data + + +class WeComBotClient(_WeComBaseClient): + def __init__( + self, *, config: WeComBotConfig, transport: Optional[Transport] = None + ) -> None: + super().__init__( + api_base=config.api_base, + timeout_seconds=config.timeout_seconds, + transport=transport, + ) + self._config = config + + @classmethod + def from_env(cls, *, transport: Optional[Transport] = None) -> "WeComBotClient": + return cls( + config=WeComBotConfig( + webhook_key=os.environ.get("GAOKAO_WECOM_BOT_KEY", ""), + api_base=os.environ.get("GAOKAO_WECOM_API_BASE", DEFAULT_API_BASE), + timeout_seconds=float( + os.environ.get( + "GAOKAO_WECOM_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS + ) + ), + ), + transport=transport, + ) + + def send_text( + self, + *, + content: str, + mentioned_list: Optional[list[str]] = None, + mentioned_mobile_list: Optional[list[str]] = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "msgtype": "text", + "text": {"content": content}, + } + if mentioned_list: + payload["text"]["mentioned_list"] = list(mentioned_list) + if mentioned_mobile_list: + payload["text"]["mentioned_mobile_list"] = list(mentioned_mobile_list) + return self._request_json( + "POST", + "/cgi-bin/webhook/send", + query={"key": self._config.webhook_key}, + payload=payload, + ) + + +class WeComAppClient(_WeComBaseClient): + def __init__( + self, + *, + config: WeComAppConfig, + transport: Optional[Transport] = None, + clock: Optional[Callable[[], float]] = None, + ) -> None: + super().__init__( + api_base=config.api_base, + timeout_seconds=config.timeout_seconds, + transport=transport, + ) + self._config = config + self._clock = clock or time.time + self._access_token: Optional[AccessToken] = None + + @classmethod + def from_env( + cls, + *, + transport: Optional[Transport] = None, + clock: Optional[Callable[[], float]] = None, + ) -> "WeComAppClient": + return cls( + config=WeComAppConfig( + corp_id=os.environ.get("GAOKAO_WECOM_CORP_ID", ""), + corp_secret=os.environ.get("GAOKAO_WECOM_CORP_SECRET", ""), + agent_id=int(os.environ.get("GAOKAO_WECOM_AGENT_ID", "0")), + api_base=os.environ.get("GAOKAO_WECOM_API_BASE", DEFAULT_API_BASE), + timeout_seconds=float( + os.environ.get( + "GAOKAO_WECOM_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS + ) + ), + token_refresh_skew_seconds=float( + os.environ.get( + "GAOKAO_WECOM_TOKEN_REFRESH_SKEW_SECONDS", + DEFAULT_TOKEN_REFRESH_SKEW_SECONDS, + ) + ), + ), + transport=transport, + clock=clock, + ) + + def _now(self) -> float: + return float(self._clock()) + + def _get_access_token(self, *, force_refresh: bool = False) -> str: + now = self._now() + if ( + not force_refresh + and self._access_token is not None + and self._access_token.valid(now, self._config.token_refresh_skew_seconds) + ): + return self._access_token.token + + data = self._request_json( + "GET", + "/cgi-bin/gettoken", + query={ + "corpid": self._config.corp_id, + "corpsecret": self._config.corp_secret, + }, + ) + token = str(data.get("access_token", "")) + if not token: + raise WeComAPIError( + "企业微信接口未返回 access_token", + http_status=200, + endpoint="/cgi-bin/gettoken", + ) + expires_in = float(data.get("expires_in", 7200)) + self._access_token = AccessToken(token=token, expires_at=now + expires_in) + return token + + def send_text( + self, + *, + content: str, + to_user: Optional[str] = None, + to_party: Optional[str] = None, + to_tag: Optional[str] = None, + safe: int = 0, + enable_duplicate_check: int = 0, + duplicate_check_interval: int = 1800, + ) -> dict[str, Any]: + token = self._get_access_token() + payload: dict[str, Any] = { + "msgtype": "text", + "agentid": self._config.agent_id, + "text": {"content": content}, + "safe": safe, + "enable_duplicate_check": enable_duplicate_check, + "duplicate_check_interval": duplicate_check_interval, + } + if to_user: + payload["touser"] = to_user + if to_party: + payload["toparty"] = to_party + if to_tag: + payload["totag"] = to_tag + if not any((to_user, to_party, to_tag)): + payload["touser"] = "@all" + return self._request_json( + "POST", + "/cgi-bin/message/send", + query={"access_token": token}, + payload=payload, + ) + + +__all__ = [ + "DEFAULT_API_BASE", + "DEFAULT_TIMEOUT_SECONDS", + "DEFAULT_TOKEN_REFRESH_SKEW_SECONDS", + "WeComAPIError", + "WeComAppClient", + "WeComAppConfig", + "WeComBotClient", + "WeComBotConfig", + "WeComConfigError", +] diff --git a/data/channel_sync/xianyu_adapter.py b/data/channel_sync/xianyu_adapter.py new file mode 100644 index 0000000..98a62d2 --- /dev/null +++ b/data/channel_sync/xianyu_adapter.py @@ -0,0 +1,209 @@ +"""闲鱼事件 → Order 模型适配器 (T8.1) + +将 Webhook body 解析为 :class:`XianyuEvent`,再映射为内部 :class:`Order`。 + +设计要点: +- 仅接受设计文档 §4.1 列出的字段;其它字段(如身份证号)按 §5.5 一律丢弃 + 并打上 ``pii_dropped`` 标记(写到 audit.reject_reason) +- service_version 规范化: 'audit' | 'basic' | 'standard' | 'premium' +- event_type → 状态映射见 §4.2 +- 不可识别的 event_type 抛 :class:`XianyuEventError` +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Optional + +from data.orders.models import Order, generate_order_id, utc_now_iso +from data.orders.state_machine import OrderStatus + + +# 闲鱼侧敏感字段黑名单(出现即丢弃 + 审计) +_PII_DROP_FIELDS: frozenset[str] = frozenset( + {"id_card", "id_number", "身份证", "身份证号", "cert_no", "citizen_id"} +) + +# service_version 白名单 +_VALID_SERVICE_VERSIONS: frozenset[str] = frozenset( + {"audit", "basic", "standard", "premium"} +) + +# event_type → 订单状态(设计文档 §4.2) +EVENT_STATUS_MAP: dict[str, str] = { + "order.created": OrderStatus.PENDING.value, + "order.paid": OrderStatus.PAID.value, + "order.delivered": OrderStatus.SERVING.value, + "order.completed": OrderStatus.COMPLETED.value, + "order.refunded": OrderStatus.REFUNDED.value, +} + + +class XianyuEventError(ValueError): + """闲鱼事件解析或字段校验失败。""" + + +@dataclass +class XianyuEvent: + """Webhook body 解析后的结构。""" + + event_id: str + event_type: str + order_id: str + service_version: str + amount_cents: int + customer_name: str + customer_phone: str + customer_wechat: Optional[str] = None + candidate_name: Optional[str] = None + candidate_province: Optional[str] = None + created_at: Optional[str] = None + paid_at: Optional[str] = None + refunded_at: Optional[str] = None + + # 解析时丢弃的 PII 字段名(用于审计 reject_reason='pii_dropped') + pii_dropped_fields: list[str] = field(default_factory=list) + + +def _coerce_amount(raw: Any) -> int: + """把 amount 字段统一为分(int)。接受元/元字符串/数字。""" + if isinstance(raw, int): + return raw # 已是分 + if isinstance(raw, float): + return int(round(raw * 100)) + if isinstance(raw, str): + s = raw.strip().replace("¥", "").replace("¥", "").replace(",", "") + if not s: + raise XianyuEventError("amount 不能为空") + # 形如 "99.00" → 9900 + if "." in s: + yuan = float(s) + return int(round(yuan * 100)) + # 纯数字按元处理 + return int(s) * 100 + raise XianyuEventError(f"amount 字段类型非法: {type(raw).__name__}") + + +def parse_event(body: bytes | str) -> XianyuEvent: + """解析 Webhook body 为 :class:`XianyuEvent`。 + + - 必填字段缺失 → :class:`XianyuEventError` + - PII 字段出现 → 静默丢弃并记录 + """ + if isinstance(body, bytes): + try: + text = body.decode("utf-8") + except UnicodeDecodeError as e: + raise XianyuEventError(f"body 非 UTF-8: {e}") from e + else: + text = body + if not text.strip(): + raise XianyuEventError("body 为空") + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise XianyuEventError(f"body 非合法 JSON: {e}") from e + if not isinstance(data, dict): + raise XianyuEventError("body 顶层必须为对象") + + # 必填字段。注意: event_id="0" / amount=0 都是合法值,必须用 ``is None`` + # (而非 ``not``) 判定缺失,否则会被误判为 missing。 + event_id = data.get("event_id") or data.get("id") + event_type = data.get("event_type") or data.get("type") + order_id = data.get("order_id") or data.get("orderId") + amount = data.get("amount") + customer_name = data.get("customer_name") + customer_phone = data.get("customer_phone") + service_version = data.get("service_version") or "basic" + + missing = [] + if event_id is None or event_id == "": + missing.append("event_id") + if not event_type: + missing.append("event_type") + if not order_id: + missing.append("order_id") + if amount is None: + missing.append("amount") + if not customer_name: + missing.append("customer_name") + if not customer_phone: + missing.append("customer_phone") + if missing: + raise XianyuEventError(f"必填字段缺失: {','.join(missing)}") + + if event_type not in EVENT_STATUS_MAP: + raise XianyuEventError( + f"未知 event_type: {event_type!r}; 支持: {sorted(EVENT_STATUS_MAP)}" + ) + + if service_version not in _VALID_SERVICE_VERSIONS: + raise XianyuEventError( + f"未知 service_version: {service_version!r}; " + f"支持: {sorted(_VALID_SERVICE_VERSIONS)}" + ) + + # PII 字段检测(出现即记录丢弃,不抛错) + pii_dropped: list[str] = [] + for f in _PII_DROP_FIELDS: + if f in data and data[f]: + pii_dropped.append(f) + + try: + amount_cents = _coerce_amount(amount) + except XianyuEventError: + raise + except Exception as e: # 兜底 + raise XianyuEventError(f"amount 解析失败: {e}") from e + + if amount_cents < 0: + raise XianyuEventError(f"amount 不能为负: {amount_cents}") + + return XianyuEvent( + event_id=str(event_id), + event_type=str(event_type), + order_id=str(order_id), + service_version=str(service_version), + amount_cents=int(amount_cents), + customer_name=str(customer_name), + customer_phone=str(customer_phone), + customer_wechat=data.get("customer_wechat"), + candidate_name=data.get("candidate_name"), + candidate_province=data.get("candidate_province"), + created_at=data.get("created_at"), + paid_at=data.get("paid_at"), + refunded_at=data.get("refunded_at"), + pii_dropped_fields=pii_dropped, + ) + + +def to_order(event: XianyuEvent) -> Order: + """把 :class:`XianyuEvent` 映射为 :class:`Order`(尚未落库)。""" + status = EVENT_STATUS_MAP[event.event_type] + return Order( + id=generate_order_id(), + source="xianyu", + external_id=event.order_id, + service_version=event.service_version, + amount_cents=event.amount_cents, + status=status, + customer_name=event.customer_name, + customer_phone=event.customer_phone, + customer_wechat=event.customer_wechat, + candidate_name=event.candidate_name, + candidate_province=event.candidate_province, + notes=f"event_id={event.event_id}", + created_at=event.created_at or utc_now_iso(), + paid_at=event.paid_at, + completed_at=( + utc_now_iso() + if event.event_type == "order.completed" and not event.refunded_at + else None + ), + ) + + +def target_status(event: XianyuEvent) -> str: + """返回 :class:`XianyuEvent` 对应的目标状态。""" + return EVENT_STATUS_MAP[event.event_type] diff --git a/data/crowd_db/cli.py b/data/crowd_db/cli.py new file mode 100644 index 0000000..899bfb3 --- /dev/null +++ b/data/crowd_db/cli.py @@ -0,0 +1,183 @@ +"""gaokao-data-trace CLI implementation (T3.4).""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any, Iterable, Optional + +from .loader import CrowdDBLoader +from .risk_report import SOURCE_TYPE_DISPLAY_META + + +DEFAULT_DATA_YEAR_LABEL = "未知年份" + + +def _error(message: str, code: int = 1) -> int: + print(message, file=sys.stderr) + return code + + +def _normalize_source_type(raw_source_type: str) -> dict[str, str]: + meta = SOURCE_TYPE_DISPLAY_META.get( + raw_source_type, + SOURCE_TYPE_DISPLAY_META["derived"], + ) + return { + "source_type": meta["category"], + "source_type_label": meta["label"], + "source_type_icon": meta["icon"], + } + + +def _build_match( + *, + province: str, + provenance: dict[str, Any], + score_range: dict[str, Any], + recommendation: dict[str, Any], +) -> dict[str, Any]: + score_bounds = score_range.get("range") or [None, None] + normalized = _normalize_source_type(str(provenance.get("source_type") or "derived")) + return { + "province": province, + "school": recommendation.get("name", ""), + "major": recommendation.get("major", ""), + "frequency": recommendation.get("frequency", 0), + "platforms": list(recommendation.get("platforms", [])), + "predicted_increase": recommendation.get("predicted_increase", 0), + "alternatives": list(recommendation.get("alternatives", [])), + "score_range": list(score_bounds), + "score_range_note": score_range.get("note", ""), + "data_year": provenance.get("data_year"), + "source": provenance.get("source", ""), + "source_url": provenance.get("source_url", ""), + "source_type": normalized["source_type"], + "raw_source_type": provenance.get("source_type") or "derived", + "source_type_label": normalized["source_type_label"], + "source_type_icon": normalized["source_type_icon"], + "confidence": provenance.get("confidence"), + "last_updated": provenance.get("last_updated", ""), + } + + +def find_school_traces( + school_name: str, + *, + loader: Optional[CrowdDBLoader] = None, + provinces: Optional[Iterable[str]] = None, +) -> list[dict[str, Any]]: + loader = loader or CrowdDBLoader(warn_low_confidence=False) + provinces = list(provinces or loader.list_supported_provinces()) + matches: list[dict[str, Any]] = [] + + for province in provinces: + data = loader.load_province(province) + if not data: + continue + provenance = loader.load_metadata(province) or {"province": province} + for score_range in data.get("score_ranges", []): + if not isinstance(score_range, dict): + continue + for recommendation in score_range.get("recommendations", []): + if not isinstance(recommendation, dict): + continue + candidate_name = str(recommendation.get("name", "")) + if ( + school_name not in candidate_name + and candidate_name not in school_name + ): + continue + matches.append( + _build_match( + province=province, + provenance=provenance, + score_range=score_range, + recommendation=recommendation, + ) + ) + + return matches + + +def _score_range_label(match: dict[str, Any]) -> str: + score_range = match.get("score_range") or [] + if len(score_range) != 2: + return "未知分数段" + note = match.get("score_range_note") or "" + label = f"{score_range[0]}-{score_range[1]}" + if note: + return f"{label}({note})" + return label + + +def _year_label(match: dict[str, Any]) -> str: + data_year = match.get("data_year") + if data_year in (None, ""): + return DEFAULT_DATA_YEAR_LABEL + return f"{data_year}年数据" + + +def _emit_human(payload: dict[str, Any]) -> None: + print(f"query: {payload['query']}") + print(f"match_count: {payload['match_count']}") + for index, match in enumerate(payload["matches"], start=1): + print("") + print( + f"[{index}] {match['province']} / {_year_label(match)} / {match['school']} / {match['major']}" + ) + print(f"score_range: {_score_range_label(match)}") + print(f"frequency: {match['frequency']}") + print(f"predicted_increase: {match['predicted_increase']}") + print(f"platforms: {', '.join(match['platforms'])}") + print( + "source_type: " + f"{match['source_type']} ({match['source_type_icon']}{match['source_type_label']})" + ) + print(f"source: {match['source']}") + print(f"source_url: {match['source_url']}") + print(f"confidence: {match['confidence']}") + print(f"last_updated: {match['last_updated']}") + + +def _emit(payload: dict[str, Any], *, human: bool) -> None: + if human: + _emit_human(payload) + return + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="gaokao-data-trace", + description="高考志愿数据溯源查询 CLI (T3.4)", + ) + parser.add_argument("school_name", help="院校名称,支持包含匹配") + parser.add_argument( + "--human", + action="store_true", + help="输出终端友好的文本格式(默认输出 JSON)", + ) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + matches = find_school_traces(args.school_name) + if not matches: + return _error(f"未找到院校“{args.school_name}”的溯源数据", code=1) + + payload = { + "query": args.school_name, + "match_count": len(matches), + "matches": matches, + } + _emit(payload, human=args.human) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/data/crowd_db/risk_report.py b/data/crowd_db/risk_report.py index c100266..aa6b7f2 100644 --- a/data/crowd_db/risk_report.py +++ b/data/crowd_db/risk_report.py @@ -35,6 +35,19 @@ from __future__ import annotations from typing import Any, Dict, Iterable, List, Optional, Protocol +SOURCE_TYPE_DISPLAY_META: Dict[str, Dict[str, str]] = { + "official_release": {"icon": "✓", "label": "来源", "category": "official"}, + "manual_summary": {"icon": "⚠️", "label": "报告", "category": "report"}, + "platform_scrape": {"icon": "⚠️", "label": "报告", "category": "report"}, + "derived": {"icon": "📊", "label": "估算", "category": "estimated"}, +} +PUBLIC_SOURCE_TYPE_DISPLAY_META: Dict[str, Dict[str, str]] = { + "official": {"icon": "✓", "label": "来源", "category": "official"}, + "report": {"icon": "⚠️", "label": "报告", "category": "report"}, + "estimated": {"icon": "📊", "label": "估算", "category": "estimated"}, +} + + class _LoaderProtocol(Protocol): """loader 必须提供的方法(duck-typing,避免对 CrowdDBLoader 强依赖)。""" @@ -42,6 +55,8 @@ class _LoaderProtocol(Protocol): self, province: str, score: int ) -> List[Dict[str, Any]]: ... + def load_metadata(self, province: str) -> Optional[Dict[str, Any]]: ... + LoaderLike = _LoaderProtocol @@ -76,14 +91,67 @@ def _alternative_to_template(alt: Dict[str, Any]) -> Dict[str, Any]: return {"school": school, "score": score, "major": alt.get("major", "")} -def finding_to_risk_dict(finding: RiskFinding) -> Dict[str, Any]: +def _normalize_provenance(metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]: + metadata = metadata or {} + public_source_type = metadata.get("source_type") or "estimated" + raw_source_type = ( + metadata.get("raw_source_type") or metadata.get("source_type") or "derived" + ) + display = SOURCE_TYPE_DISPLAY_META.get(raw_source_type) + if display is None: + display = PUBLIC_SOURCE_TYPE_DISPLAY_META.get( + public_source_type, + PUBLIC_SOURCE_TYPE_DISPLAY_META["estimated"], + ) + confidence = metadata.get("confidence") + try: + confidence = float(confidence) if confidence is not None else None + except (TypeError, ValueError): + confidence = None + data_year = metadata.get("data_year") + try: + data_year = int(data_year) if data_year is not None else None + except (TypeError, ValueError): + data_year = None + return { + "source_type": display["category"], + "raw_source_type": raw_source_type, + "source_type_display": display["category"], + "source_type_label": display["label"], + "source_type_icon": display["icon"], + "source": metadata.get("source", ""), + "source_url": metadata.get("source_url", ""), + "confidence": confidence, + "last_updated": metadata.get("last_updated", ""), + "data_year": data_year, + } + + +def _load_provenance_metadata( + loader: Optional[LoaderLike], province: str +) -> Dict[str, Any]: + if loader is None: + from data.crowd_db.loader import CrowdDBLoader + + loader = CrowdDBLoader() # type: ignore[assignment] + load_metadata = getattr(loader, "load_metadata", None) + if callable(load_metadata): + metadata = load_metadata(province) + if isinstance(metadata, dict) or metadata is None: + return _normalize_provenance(metadata) + return _normalize_provenance(None) + + +def finding_to_risk_dict( + finding: RiskFinding, provenance: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: """RiskFinding → 模板所需的 crowd_risks 单条字典。 若 risk_level 不在 RISK_LEVEL_META 中(crowd_detector 不会返回 none, 因为 frequency=0 已被跳过),fallback 到 low + 🟢。 """ meta = RISK_LEVEL_META.get(finding.risk_level, RISK_LEVEL_META["low"]) - return { + risk = { "school": finding.school, "major": finding.major or "", "frequency": int(finding.frequency), @@ -94,6 +162,8 @@ def finding_to_risk_dict(finding: RiskFinding) -> Dict[str, Any]: "platforms": list(finding.platforms), "alternatives": [_alternative_to_template(a) for a in finding.alternatives], } + risk.update(_normalize_provenance(provenance)) + return risk def build_crowd_risks( @@ -115,7 +185,8 @@ def build_crowd_risks( frequency=0 / 省份无数据 / 方案为空 → 返回空列表。 """ findings = detect_crowd_risk(plan, user_score, province, loader=loader) # type: ignore[arg-type] - return [finding_to_risk_dict(f) for f in findings] + provenance = _load_provenance_metadata(loader, province) + return [finding_to_risk_dict(f, provenance=provenance) for f in findings] def group_by_risk( diff --git a/data/crowd_db/tests/test_risk_report.py b/data/crowd_db/tests/test_risk_report.py index 9be8d30..0d91a76 100644 --- a/data/crowd_db/tests/test_risk_report.py +++ b/data/crowd_db/tests/test_risk_report.py @@ -143,6 +143,33 @@ def test_risk_dict_field_types(): assert isinstance(r["alternatives"], list) +def test_risk_dict_includes_provenance_fields(): + """每条风险必须附带省份级溯源元数据,供报告展示来源/报告/估算标识""" + plan = [plan_entry("长沙理工大学", "计算机科学与技术")] + risks = build_crowd_risks(plan, user_score=575, province="湖南") + r = risks[0] + for key in ( + "source_type", + "raw_source_type", + "source_type_label", + "source_type_icon", + "source", + "source_url", + "confidence", + "last_updated", + "data_year", + ): + assert key in r, f"missing provenance field: {key}" + assert r["source_type"] == "report" + assert r["raw_source_type"] == "manual_summary" + assert r["source_type_label"] == "报告" + assert r["source_type_icon"] == "⚠️" + assert r["source_url"].startswith("https://") + assert r["last_updated"] == "2026-06-12" + assert r["data_year"] == 2025 + assert 0 <= r["confidence"] <= 1 + + def test_alternatives_remapped_to_school_field(): """crowd_db 里 alternatives 项的 name 字段必须重映射为模板需要的 school""" plan = [plan_entry("长沙理工大学", "计算机科学与技术")] diff --git a/data/crowd_db/tests/test_trace_cli.py b/data/crowd_db/tests/test_trace_cli.py new file mode 100644 index 0000000..7416ed0 --- /dev/null +++ b/data/crowd_db/tests/test_trace_cli.py @@ -0,0 +1,66 @@ +"""gaokao-data-trace CLI tests (T3.4).""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from data.crowd_db.cli import main as cli_main + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = PROJECT_ROOT / "scripts" / "gaokao-data-trace" + + +def _run_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT_PATH), *args], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + + +def test_trace_cli_json_output_contains_matches() -> None: + result = _run_cli("长沙理工大学") + assert result.returncode == 0, result.stderr + + payload = json.loads(result.stdout) + assert payload["query"] == "长沙理工大学" + assert payload["match_count"] >= 1 + assert any(match["school"] == "长沙理工大学" for match in payload["matches"]) + + hunan_match = next( + match for match in payload["matches"] if match["province"] == "湖南" + ) + assert hunan_match["data_year"] == 2025 + assert hunan_match["source_url"].startswith("https://") + assert 0 <= hunan_match["confidence"] <= 1 + assert hunan_match["source_type"] == "report" + assert hunan_match["raw_source_type"] == "manual_summary" + + +def test_trace_cli_human_output_contains_required_lines( + capsys: pytest.CaptureFixture[str], +) -> None: + exit_code = cli_main(["--human", "长沙理工大学"]) + captured = capsys.readouterr() + + assert exit_code == 0 + assert "query: 长沙理工大学" in captured.out + assert "湖南 / 2025年数据 / 长沙理工大学 / 会计学" in captured.out + assert "source_url: https://" in captured.out + assert "confidence: 0.85" in captured.out + + +def test_trace_cli_missing_school_returns_nonzero( + capsys: pytest.CaptureFixture[str], +) -> None: + exit_code = cli_main(["不存在的测试院校XYZ"]) + captured = capsys.readouterr() + + assert exit_code == 1 + assert "不存在的测试院校XYZ" in captured.err diff --git a/data/orders/README.md b/data/orders/README.md index 07d7a0a..5e377eb 100644 --- a/data/orders/README.md +++ b/data/orders/README.md @@ -189,6 +189,48 @@ order.to_dict(decrypt_sensitive=False) `masking` 模块是纯字符串工具,与 `crypto` 正交,可在任意层(API 序列化、Jinja2 模板过滤器、日志格式化、CSV 导出器)独立复用。 +## T4.3 订单管理 CLI + +入口:`scripts/gaokao-order-manager` + +```bash +# 创建订单 +python3 scripts/gaokao-order-manager create \ + --source web --service-version standard --amount-cents 9900 \ + --customer-name 张三 --customer-phone 13800001234 \ + --candidate-name 李同学 --candidate-province 湖南 --candidate-score 578 + +# 订单列表 / 详情 +python3 scripts/gaokao-order-manager list --status pending +python3 scripts/gaokao-order-manager show GKO-20260612-ABCD + +# 更新业务字段(禁止直接改 status) +python3 scripts/gaokao-order-manager update GKO-20260612-ABCD \ + --assigned-consultant consultant-a --note 已分配顾问 --tag VIP + +# 支付 / 交付 / 升级 +python3 scripts/gaokao-order-manager pay GKO-20260612-ABCD --reason wechat_pay +python3 scripts/gaokao-order-manager deliver GKO-20260612-ABCD --reason report_ready +python3 scripts/gaokao-order-manager upgrade GKO-20260612-ABCD \ + --service-version standard --target-amount-cents 9900 --reason upgrade_to_standard + +# 统计 / 最小导出 +python3 scripts/gaokao-order-manager stats +python3 scripts/gaokao-order-manager export \ + --output /tmp/orders-report.csv --status pending --source school +``` + +子命令: + +- `create`:新建订单,默认输出 JSON,敏感字段默认遮罩 +- `list` / `show`:查询订单;`show` 同时返回 `history` +- `update`:只允许改业务字段,拒绝空更新与直接改 `status` +- `pay`:推进 `pending -> paid` +- `deliver`:从 `paid` 自动推进 `serving -> delivered`;若已在 `serving` 则只做最后一步 +- `upgrade`:按目标总价创建补差价升级单,关联 `upgrade_from`,并给原单补 `upgraded` 标记 +- `stats`:返回 `total_orders`、`by_status`、`by_source`、`by_service_version` +- `export`:导出最小 CSV 报表,字段固定为 `订单号/渠道/金额/状态/创建时间` + ## 下游衔接 - **T4.3 CLI**: `gaokao-order-manager` 直接 import `OrdersDAO` + `Order` @@ -198,6 +240,8 @@ order.to_dict(decrypt_sensitive=False) ## 版本 +v1.4 — 2026-06-12 — T4.5 最小导出(CLI export 子命令 + CSV 报表字段 `订单号/渠道/金额/状态/创建时间`) +v1.3 — 2026-06-12 — T4.4 升级订单流程(upgrade 子命令 + upgrade_order + 补差价校验) v1.2 — 2026-06-12 — T4.2 DAO 层落地(51 用例,ruff 0 warning,data/ 386 用例全绿) v1.1 — 2026-06-12 — T11.2 展示脱敏(mask 策略 + masking.py) v1.0 — 2026-06-12 — T4.1 实施 diff --git a/data/orders/cli.py b/data/orders/cli.py new file mode 100644 index 0000000..c9de913 --- /dev/null +++ b/data/orders/cli.py @@ -0,0 +1,455 @@ +"""gaokao-order-manager CLI implementation (T4.3/T4.5).""" + +from __future__ import annotations + +import argparse +import csv +import json +import sys +from dataclasses import asdict +from pathlib import Path +from typing import Any, Optional + +from .dao import DuplicateOrder, OrderNotFound, OrdersDAO +from .models import Order, generate_order_id +from .state_machine import InvalidStateTransition + +DEFAULT_DB_PATH = Path("data/orders.db") +_EXPORT_HEADERS = ("订单号", "渠道", "金额", "状态", "创建时间") + + +def _emit(payload: dict[str, Any], *, human: bool) -> None: + if human: + for key, value in payload.items(): + if isinstance(value, (dict, list)): + print(f"{key}: {json.dumps(value, ensure_ascii=False, indent=2)}") + else: + print(f"{key}: {value}") + return + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + +def _error(message: str, code: int = 1) -> int: + print(message, file=sys.stderr) + return code + + +def _serialize_order(order: Order) -> dict[str, Any]: + return order.to_dict() + + +def _serialize_history(dao: OrdersDAO, order_id: str) -> list[dict[str, Any]]: + return [asdict(item) for item in dao.get_status_history(order_id)] + + +def _build_order_from_args(args: argparse.Namespace) -> Order: + return Order( + id=generate_order_id(), + source=args.source, + external_id=args.external_id, + service_version=args.service_version, + amount_cents=args.amount_cents, + status="pending", + customer_name=args.customer_name, + customer_phone=args.customer_phone, + customer_wechat=args.customer_wechat, + candidate_name=args.candidate_name, + candidate_id_card=args.candidate_id_card, + candidate_province=args.candidate_province, + candidate_score=args.candidate_score, + candidate_rank=args.candidate_rank, + candidate_subjects=args.candidate_subjects or [], + candidate_interests=args.candidate_interests, + candidate_strong_subjects=args.candidate_strong_subjects, + candidate_weak_subjects=args.candidate_weak_subjects, + candidate_family=args.candidate_family, + assigned_consultant=args.assigned_consultant, + notes=args.note, + tags=args.tags or [], + ) + + +def cmd_create(args: argparse.Namespace) -> int: + order = _build_order_from_args(args) + with OrdersDAO.connect(args.db) as dao: + try: + created = dao.create(order, actor=args.actor, reason=args.reason) + except DuplicateOrder as exc: + return _error(f"创建失败: {exc}") + payload = { + "action": "created", + "order": _serialize_order(created), + "history": _serialize_history(dao, created.id), + } + _emit(payload, human=args.human) + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + with OrdersDAO.connect(args.db) as dao: + try: + orders = dao.list( + status=args.status, + source=args.source, + limit=args.limit, + offset=args.offset, + ) + except ValueError as exc: + return _error(f"查询失败: {exc}", code=2) + payload = { + "count": len(orders), + "orders": [_serialize_order(order) for order in orders], + } + _emit(payload, human=args.human) + return 0 + + +def cmd_show(args: argparse.Namespace) -> int: + with OrdersDAO.connect(args.db) as dao: + try: + order = dao.get(args.order_id) + except OrderNotFound as exc: + return _error(str(exc)) + payload = { + "order": _serialize_order(order), + "history": _serialize_history(dao, order.id), + } + _emit(payload, human=args.human) + return 0 + + +def _collect_updates(args: argparse.Namespace) -> dict[str, Any]: + updates: dict[str, Any] = {} + field_names = ( + "external_id", + "service_version", + "amount_cents", + "customer_name", + "customer_wechat", + "candidate_name", + "candidate_province", + "candidate_score", + "candidate_rank", + "candidate_interests", + "candidate_strong_subjects", + "candidate_weak_subjects", + "candidate_family", + "assigned_consultant", + "plan_file", + "audit_report", + "pdf_path", + ) + for name in field_names: + value = getattr(args, name) + if value is not None: + updates[name] = value + if args.note is not None: + updates["notes"] = args.note + if args.tags is not None: + updates["tags"] = args.tags + if args.candidate_subjects is not None: + updates["candidate_subjects"] = args.candidate_subjects + return updates + + +def cmd_update(args: argparse.Namespace) -> int: + updates = _collect_updates(args) + if not updates: + return _error("至少指定一个可更新字段", code=2) + with OrdersDAO.connect(args.db) as dao: + try: + order = dao.update( + args.order_id, updates, actor=args.actor, reason=args.reason + ) + except OrderNotFound as exc: + return _error(str(exc)) + except ValueError as exc: + return _error(f"更新失败: {exc}", code=2) + payload = { + "action": "updated", + "order": _serialize_order(order), + } + _emit(payload, human=args.human) + return 0 + + +def _transition( + dao: OrdersDAO, + order_id: str, + to_status: str, + *, + actor: str, + reason: Optional[str], +) -> Order: + return dao.transition_status(order_id, to_status, actor=actor, reason=reason) + + +def cmd_pay(args: argparse.Namespace) -> int: + with OrdersDAO.connect(args.db) as dao: + try: + order = _transition( + dao, + args.order_id, + "paid", + actor=args.actor, + reason=args.reason or "manual_pay", + ) + except (OrderNotFound, InvalidStateTransition) as exc: + return _error(str(exc)) + payload = { + "action": "paid", + "order": _serialize_order(order), + "history": _serialize_history(dao, order.id), + } + _emit(payload, human=args.human) + return 0 + + +def cmd_deliver(args: argparse.Namespace) -> int: + with OrdersDAO.connect(args.db) as dao: + try: + current = dao.get(args.order_id) + except OrderNotFound as exc: + return _error(str(exc)) + + try: + if current.status == "paid": + _transition( + dao, + args.order_id, + "serving", + actor=args.actor, + reason=args.reason or "deliver:start_service", + ) + order = _transition( + dao, + args.order_id, + "delivered", + actor=args.actor, + reason=args.reason or "deliver:done", + ) + elif current.status == "serving": + order = _transition( + dao, + args.order_id, + "delivered", + actor=args.actor, + reason=args.reason or "deliver:done", + ) + else: + return _error( + f"当前状态不允许 deliver: {current.status};请先完成 pay 或人工推进到 serving" + ) + except InvalidStateTransition as exc: + return _error(str(exc)) + + payload = { + "action": "delivered", + "order": _serialize_order(order), + "history": _serialize_history(dao, order.id), + } + _emit(payload, human=args.human) + return 0 + + +def cmd_upgrade(args: argparse.Namespace) -> int: + with OrdersDAO.connect(args.db) as dao: + try: + order = dao.upgrade_order( + args.order_id, + target_service_version=args.service_version, + target_amount_cents=args.target_amount_cents, + actor=args.actor, + reason=args.reason, + ) + source_order = dao.get(args.order_id) + except (OrderNotFound, ValueError) as exc: + return _error(str(exc), code=2) + + payload = { + "action": "upgraded", + "order": _serialize_order(order), + "source_order": _serialize_order(source_order), + "history": _serialize_history(dao, order.id), + } + _emit(payload, human=args.human) + return 0 + + +def cmd_stats(args: argparse.Namespace) -> int: + with OrdersDAO.connect(args.db) as dao: + by_status = dao.stats_by_status() + by_source_rows = dao.conn.execute( + "SELECT source, COUNT(*) AS n FROM orders GROUP BY source ORDER BY source ASC" + ).fetchall() + by_service_rows = dao.conn.execute( + "SELECT service_version, COUNT(*) AS n FROM orders GROUP BY service_version ORDER BY service_version ASC" + ).fetchall() + payload = { + "total_orders": dao.count(), + "by_status": by_status, + "by_source": {row[0]: int(row[1]) for row in by_source_rows}, + "by_service_version": {row[0]: int(row[1]) for row in by_service_rows}, + } + _emit(payload, human=args.human) + return 0 + + +def _format_amount_cents(amount_cents: int) -> str: + return f"{amount_cents / 100:.2f}" + + +def _export_row(order: Order) -> dict[str, str]: + return { + "订单号": order.id, + "渠道": order.source, + "金额": _format_amount_cents(order.amount_cents), + "状态": order.status, + "创建时间": order.created_at or "", + } + + +def cmd_export(args: argparse.Namespace) -> int: + output_path = Path(args.output) + with OrdersDAO.connect(args.db) as dao: + try: + orders = dao.list( + status=args.status, + source=args.source, + limit=args.limit, + offset=0, + ) + except ValueError as exc: + return _error(f"导出失败: {exc}", code=2) + + rows = [_export_row(order) for order in orders] + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8-sig", newline="") as fh: + writer = csv.writer(fh) + writer.writerow(_EXPORT_HEADERS) + for row in rows: + writer.writerow([row[header] for header in _EXPORT_HEADERS]) + + payload = { + "action": "exported", + "format": "csv", + "output": str(output_path), + "rows": len(rows), + } + _emit(payload, human=args.human) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="gaokao-order-manager", + description="高考志愿订单管理 CLI (T4.3)", + ) + parser.add_argument("--db", default=str(DEFAULT_DB_PATH), help="SQLite 数据库路径") + parser.add_argument("--human", action="store_true", help="输出人类可读文本") + parser.add_argument("--actor", default="order_cli", help="审计 actor") + + subparsers = parser.add_subparsers(dest="command", required=True) + + create = subparsers.add_parser("create", help="创建订单") + create.add_argument("--source", required=True) + create.add_argument("--external-id") + create.add_argument("--service-version", required=True) + create.add_argument("--amount-cents", type=int, required=True) + create.add_argument("--customer-name") + create.add_argument("--customer-phone") + create.add_argument("--customer-wechat") + create.add_argument("--candidate-name") + create.add_argument("--candidate-id-card") + create.add_argument("--candidate-province") + create.add_argument("--candidate-score", type=int) + create.add_argument("--candidate-rank", type=int) + create.add_argument( + "--candidate-subject", dest="candidate_subjects", action="append" + ) + create.add_argument("--candidate-interests") + create.add_argument("--candidate-strong-subjects") + create.add_argument("--candidate-weak-subjects") + create.add_argument("--candidate-family") + create.add_argument("--assigned-consultant") + create.add_argument("--note") + create.add_argument("--tag", dest="tags", action="append") + create.add_argument("--reason") + create.set_defaults(func=cmd_create) + + list_parser = subparsers.add_parser("list", help="分页列出订单") + list_parser.add_argument("--status") + list_parser.add_argument("--source") + list_parser.add_argument("--limit", type=int, default=50) + list_parser.add_argument("--offset", type=int, default=0) + list_parser.set_defaults(func=cmd_list) + + show = subparsers.add_parser("show", help="查看订单详情") + show.add_argument("order_id") + show.set_defaults(func=cmd_show) + + update = subparsers.add_parser("update", help="更新订单业务字段") + update.add_argument("order_id") + update.add_argument("--external-id") + update.add_argument("--service-version") + update.add_argument("--amount-cents", type=int) + update.add_argument("--customer-name") + update.add_argument("--customer-wechat") + update.add_argument("--candidate-name") + update.add_argument("--candidate-province") + update.add_argument("--candidate-score", type=int) + update.add_argument("--candidate-rank", type=int) + update.add_argument( + "--candidate-subject", dest="candidate_subjects", action="append" + ) + update.add_argument("--candidate-interests") + update.add_argument("--candidate-strong-subjects") + update.add_argument("--candidate-weak-subjects") + update.add_argument("--candidate-family") + update.add_argument("--assigned-consultant") + update.add_argument("--plan-file") + update.add_argument("--audit-report") + update.add_argument("--pdf-path") + update.add_argument("--note") + update.add_argument("--tag", dest="tags", action="append") + update.add_argument("--reason") + update.set_defaults(func=cmd_update) + + pay = subparsers.add_parser("pay", help="标记已支付") + pay.add_argument("order_id") + pay.add_argument("--reason") + pay.set_defaults(func=cmd_pay) + + deliver = subparsers.add_parser("deliver", help="推进到已交付") + deliver.add_argument("order_id") + deliver.add_argument("--reason") + deliver.set_defaults(func=cmd_deliver) + + upgrade = subparsers.add_parser("upgrade", help="创建补差价升级订单") + upgrade.add_argument("order_id") + upgrade.add_argument("--service-version", required=True) + upgrade.add_argument("--target-amount-cents", type=int, required=True) + upgrade.add_argument("--reason") + upgrade.set_defaults(func=cmd_upgrade) + + stats = subparsers.add_parser("stats", help="查看订单统计") + stats.set_defaults(func=cmd_stats) + + export = subparsers.add_parser("export", help="导出最小订单报表 CSV") + export.add_argument("--output", required=True, help="输出 CSV 文件路径") + export.add_argument("--status") + export.add_argument("--source") + export.add_argument("--limit", type=int, default=1000) + export.set_defaults(func=cmd_export) + + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/data/orders/dao.py b/data/orders/dao.py index dd44d80..d1157b7 100644 --- a/data/orders/dao.py +++ b/data/orders/dao.py @@ -43,7 +43,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Iterator, List, Optional, Union -from .models import Order, utc_now_iso +from .models import Order, generate_order_id, utc_now_iso from .schema import apply_schema from .state_machine import ( InvalidStateTransition, @@ -410,6 +410,89 @@ class OrdersDAO: ).fetchone() return self._row_to_order(row) + def upgrade_order( + self, + order_id: str, + *, + target_service_version: str, + target_amount_cents: int, + actor: Optional[str] = None, + reason: Optional[str] = None, + ) -> Order: + """基于已有订单创建补差价升级单,并回写原单升级标记。""" + original = self.get(order_id) + if target_amount_cents <= original.amount_cents: + raise ValueError( + "target_amount_cents 必须高于原订单金额,才能生成补差价升级单" + ) + + with self._row_factory_ctx(): + existing = self._conn.execute( + f"SELECT {self._select_columns()} FROM orders WHERE upgrade_from=? LIMIT 1", + (order_id,), + ).fetchone() + if existing is not None: + existing_order = self._row_to_order(existing) + raise ValueError(f"原订单已存在升级订单: {existing_order.id}") + + delta_amount = target_amount_cents - original.amount_cents + upgrade_id = generate_order_id() + source_tags = list(original.tags or []) + if "upgraded" not in source_tags: + source_tags.append("upgraded") + marker = f"升级至 {target_service_version},升级单 {upgrade_id}" + source_note_prefix = (original.notes + "\n") if original.notes else "" + source_note = f"{source_note_prefix}{marker}" + + upgraded = Order( + id=upgrade_id, + source=original.source, + external_id=None, + service_version=target_service_version, + amount_cents=delta_amount, + status="pending", + customer_name=original.customer_name, + customer_phone=original.customer_phone, + customer_wechat=original.customer_wechat, + candidate_name=original.candidate_name, + candidate_id_card=original.candidate_id_card, + candidate_province=original.candidate_province, + candidate_score=original.candidate_score, + candidate_rank=original.candidate_rank, + candidate_subjects=list(original.candidate_subjects), + candidate_interests=original.candidate_interests, + candidate_strong_subjects=original.candidate_strong_subjects, + candidate_weak_subjects=original.candidate_weak_subjects, + candidate_family=original.candidate_family, + assigned_consultant=original.assigned_consultant, + plan_file=None, + audit_report=None, + pdf_path=None, + created_at=None, + paid_at=None, + started_at=None, + delivered_at=None, + completed_at=None, + notes=f"升级自 {original.id}" + if not original.notes + else f"{original.notes}\n升级自 {original.id}", + tags=list(original.tags or []), + upgrade_from=original.id, + ) + + with self.transaction(): + self.update( + order_id, + {"notes": source_note, "tags": source_tags}, + actor=actor, + reason=reason or f"upgrade_source:{target_service_version}", + ) + return self.create( + upgraded, + actor=actor or "dao_upgrade", + reason=reason or f"upgrade_from:{order_id}", + ) + # ------------------------------------------------------------------ # 状态转换 # ------------------------------------------------------------------ diff --git a/data/orders/masking.py b/data/orders/masking.py index 38cb648..7001e79 100644 --- a/data/orders/masking.py +++ b/data/orders/masking.py @@ -112,6 +112,22 @@ def mask_name(value: Optional[str]) -> Optional[str]: return s[0] + "**" +def mask_wechat(value: Optional[str]) -> Optional[str]: + """微信号脱敏:保留前 2 后 2,中段用 * 填充。""" + if value is None: + return None + if not isinstance(value, str): + return None + s = value.strip() + if not s: + return "" + if len(s) <= 2: + return "*" * len(s) + if len(s) <= 4: + return s[0] + "*" * (len(s) - 2) + s[-1] + return s[:2] + "*" * (len(s) - 4) + s[-2:] + + def mask_sensitive_dict(data: dict[str, Any]) -> dict[str, Any]: """对订单字典中已知的敏感字段统一脱敏。 @@ -128,6 +144,8 @@ def mask_sensitive_dict(data: dict[str, Any]) -> dict[str, Any]: masked["candidate_id_card"] = mask_id_card(masked["candidate_id_card"]) if "customer_name" in masked and masked["customer_name"] is not None: masked["customer_name"] = mask_name(masked["customer_name"]) + if "customer_wechat" in masked and masked["customer_wechat"] is not None: + masked["customer_wechat"] = mask_wechat(masked["customer_wechat"]) if "candidate_name" in masked and masked["candidate_name"] is not None: masked["candidate_name"] = mask_name(masked["candidate_name"]) return masked @@ -137,5 +155,6 @@ __all__ = [ "mask_phone", "mask_id_card", "mask_name", + "mask_wechat", "mask_sensitive_dict", ] diff --git a/data/orders/tests/test_cli.py b/data/orders/tests/test_cli.py new file mode 100644 index 0000000..13e6cf4 --- /dev/null +++ b/data/orders/tests/test_cli.py @@ -0,0 +1,362 @@ +"""gaokao-order-manager CLI tests (T4.3/T4.5). + +覆盖 create/list/show/update/pay/deliver/stats/export 主链路,并验证默认输出走遮罩模式。 +""" + +from __future__ import annotations + +import csv +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from data.orders.cli import main as cli_main + +os.environ.setdefault("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-cli") + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = PROJECT_ROOT / "scripts" / "gaokao-order-manager" + + +@pytest.fixture +def tmp_db_path(tmp_path: Path) -> Path: + return tmp_path / "orders.db" + + +def _run_cli( + *args: str, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + if env: + merged_env.update(env) + return subprocess.run( + [sys.executable, str(SCRIPT_PATH), *args], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + env=merged_env, + ) + + +def _load_json(stdout: str) -> dict: + return json.loads(stdout) + + +def test_create_list_show_update_pay_deliver_stats_flow(tmp_db_path: Path) -> None: + create = _run_cli( + "--db", + str(tmp_db_path), + "create", + "--source", + "web", + "--service-version", + "standard", + "--amount-cents", + "9900", + "--customer-name", + "张三", + "--customer-phone", + "13800001234", + "--candidate-name", + "李同学", + "--candidate-id-card", + "430102200501011234", + "--candidate-province", + "湖南", + "--candidate-score", + "578", + "--candidate-rank", + "12345", + "--candidate-subject", + "物理", + "--candidate-subject", + "化学", + "--note", + "首单", + "--tag", + "VIP", + ) + assert create.returncode == 0, create.stderr + created = _load_json(create.stdout) + order_id = created["order"]["id"] + assert created["order"]["status"] == "pending" + assert created["order"]["customer_phone"] == "138****1234" + assert created["order"]["candidate_id_card"] == "430102********1234" + + listed = _run_cli("--db", str(tmp_db_path), "list") + assert listed.returncode == 0, listed.stderr + listed_payload = _load_json(listed.stdout) + assert listed_payload["count"] == 1 + assert listed_payload["orders"][0]["id"] == order_id + assert listed_payload["orders"][0]["customer_phone"] == "138****1234" + + shown = _run_cli("--db", str(tmp_db_path), "show", order_id) + assert shown.returncode == 0, shown.stderr + shown_payload = _load_json(shown.stdout) + assert shown_payload["order"]["id"] == order_id + assert shown_payload["order"]["notes"] == "首单" + + updated = _run_cli( + "--db", + str(tmp_db_path), + "update", + order_id, + "--assigned-consultant", + "consultant-a", + "--note", + "已分配顾问", + "--tag", + "已跟进", + "--tag", + "VIP", + ) + assert updated.returncode == 0, updated.stderr + updated_payload = _load_json(updated.stdout) + assert updated_payload["order"]["assigned_consultant"] == "consultant-a" + assert updated_payload["order"]["notes"] == "已分配顾问" + assert updated_payload["order"]["tags"] == ["已跟进", "VIP"] + + paid = _run_cli("--db", str(tmp_db_path), "pay", order_id, "--reason", "wechat-pay") + assert paid.returncode == 0, paid.stderr + paid_payload = _load_json(paid.stdout) + assert paid_payload["order"]["status"] == "paid" + assert paid_payload["order"]["paid_at"] is not None + + delivered = _run_cli( + "--db", + str(tmp_db_path), + "deliver", + order_id, + "--reason", + "report-ready", + ) + assert delivered.returncode == 0, delivered.stderr + delivered_payload = _load_json(delivered.stdout) + assert delivered_payload["order"]["status"] == "delivered" + assert delivered_payload["order"]["started_at"] is not None + assert delivered_payload["order"]["delivered_at"] is not None + assert [item["to_status"] for item in delivered_payload["history"]] == [ + "pending", + "paid", + "serving", + "delivered", + ] + + stats = _run_cli("--db", str(tmp_db_path), "stats") + assert stats.returncode == 0, stats.stderr + stats_payload = _load_json(stats.stdout) + assert stats_payload["total_orders"] == 1 + assert stats_payload["by_status"]["delivered"] == 1 + assert stats_payload["by_source"]["web"] == 1 + assert stats_payload["by_service_version"]["standard"] == 1 + + +def test_show_missing_order_returns_nonzero_and_message(tmp_db_path: Path) -> None: + result = _run_cli("--db", str(tmp_db_path), "show", "missing-order") + assert result.returncode == 1 + assert "missing-order" in result.stderr + + +def test_update_requires_at_least_one_mutation_field(tmp_db_path: Path) -> None: + created = _run_cli( + "--db", + str(tmp_db_path), + "create", + "--source", + "web", + "--service-version", + "basic", + "--amount-cents", + "4900", + ) + order_id = _load_json(created.stdout)["order"]["id"] + + result = _run_cli("--db", str(tmp_db_path), "update", order_id) + assert result.returncode == 2 + assert "至少指定一个可更新字段" in result.stderr + + +def test_module_main_full_flow_covers_core_commands( + tmp_db_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + create_code = cli_main( + [ + "--db", + str(tmp_db_path), + "create", + "--source", + "wechat", + "--service-version", + "basic", + "--amount-cents", + "4900", + "--customer-phone", + "13911112222", + "--tag", + "直连", + ] + ) + assert create_code == 0 + created_payload = _load_json(capsys.readouterr().out) + order_id = created_payload["order"]["id"] + + assert cli_main(["--db", str(tmp_db_path), "list"]) == 0 + list_payload = _load_json(capsys.readouterr().out) + assert list_payload["count"] == 1 + + assert ( + cli_main( + [ + "--db", + str(tmp_db_path), + "update", + order_id, + "--assigned-consultant", + "consultant-b", + "--note", + "待支付", + ] + ) + == 0 + ) + update_payload = _load_json(capsys.readouterr().out) + assert update_payload["order"]["assigned_consultant"] == "consultant-b" + + assert cli_main(["--db", str(tmp_db_path), "show", order_id]) == 0 + show_payload = _load_json(capsys.readouterr().out) + assert show_payload["order"]["customer_phone"] == "139****2222" + + assert cli_main(["--db", str(tmp_db_path), "pay", order_id]) == 0 + pay_payload = _load_json(capsys.readouterr().out) + assert pay_payload["order"]["status"] == "paid" + + assert cli_main(["--db", str(tmp_db_path), "deliver", order_id]) == 0 + deliver_payload = _load_json(capsys.readouterr().out) + assert deliver_payload["order"]["status"] == "delivered" + + stats_code = cli_main(["--db", str(tmp_db_path), "--human", "stats"]) + captured = capsys.readouterr() + assert stats_code == 0 + assert "total_orders: 1" in captured.out + assert '"wechat": 1' in captured.out + + +def test_upgrade_command_creates_delta_order_and_marks_source( + tmp_db_path: Path, +) -> None: + created = _run_cli( + "--db", + str(tmp_db_path), + "create", + "--source", + "web", + "--service-version", + "basic", + "--amount-cents", + "4900", + "--customer-name", + "张三", + "--customer-phone", + "13800001234", + "--candidate-name", + "李同学", + "--note", + "49 元首单", + "--tag", + "首单", + ) + assert created.returncode == 0, created.stderr + order_id = _load_json(created.stdout)["order"]["id"] + + upgraded = _run_cli( + "--db", + str(tmp_db_path), + "upgrade", + order_id, + "--service-version", + "standard", + "--target-amount-cents", + "9900", + "--reason", + "upgrade_to_standard", + ) + assert upgraded.returncode == 0, upgraded.stderr + upgraded_payload = _load_json(upgraded.stdout) + upgrade_order = upgraded_payload["order"] + source_order = upgraded_payload["source_order"] + + assert upgrade_order["upgrade_from"] == order_id + assert upgrade_order["service_version"] == "standard" + assert upgrade_order["amount_cents"] == 5000 + assert upgrade_order["status"] == "pending" + assert upgrade_order["customer_phone"] == "138****1234" + assert source_order["id"] == order_id + assert "upgraded" in source_order["tags"] + assert upgrade_order["id"] in (source_order["notes"] or "") + + listed = _run_cli("--db", str(tmp_db_path), "list") + listed_payload = _load_json(listed.stdout) + assert listed_payload["count"] == 2 + + +def test_export_command_writes_minimal_csv_report(tmp_db_path: Path) -> None: + created = _run_cli( + "--db", + str(tmp_db_path), + "create", + "--source", + "school", + "--service-version", + "premium", + "--amount-cents", + "19900", + "--customer-name", + "王家长", + ) + assert created.returncode == 0, created.stderr + order_id = _load_json(created.stdout)["order"]["id"] + + export_path = tmp_db_path.parent / "orders-report.csv" + exported = _run_cli( + "--db", + str(tmp_db_path), + "export", + "--output", + str(export_path), + "--status", + "pending", + "--source", + "school", + ) + assert exported.returncode == 0, exported.stderr + payload = _load_json(exported.stdout) + assert payload["format"] == "csv" + assert payload["rows"] == 1 + assert payload["output"] == str(export_path) + assert export_path.exists() + + with export_path.open("r", encoding="utf-8-sig", newline="") as fh: + rows = list(csv.DictReader(fh)) + assert rows == [ + { + "订单号": order_id, + "渠道": "school", + "金额": "199.00", + "状态": "pending", + "创建时间": rows[0]["创建时间"], + } + ] + assert rows[0]["创建时间"] + + +def test_module_main_missing_show_returns_error( + tmp_db_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + result = cli_main(["--db", str(tmp_db_path), "show", "missing-order"]) + captured = capsys.readouterr() + assert result == 1 + assert "missing-order" in captured.err diff --git a/data/orders/tests/test_dao.py b/data/orders/tests/test_dao.py index 4fe47ca..8068fd4 100644 --- a/data/orders/tests/test_dao.py +++ b/data/orders/tests/test_dao.py @@ -326,6 +326,94 @@ class TestCRUD: assert after.notes == "新备注" +class TestUpgradeOrder: + def test_upgrade_order_creates_delta_order_and_marks_source( + self, dao, sample_order + ): + original = dao.create( + Order(**{ + **sample_order.to_dict(True), + "id": generate_order_id(), + "service_version": "basic", + "amount_cents": 4900, + "status": "pending", + "notes": "49 元首单", + "tags": ["首单"], + }) + ) + + upgraded = dao.upgrade_order( + original.id, + target_service_version="standard", + target_amount_cents=9900, + actor="qa", + reason="upgrade_to_standard", + ) + + assert upgraded.id != original.id + assert upgraded.upgrade_from == original.id + assert upgraded.service_version == "standard" + assert upgraded.amount_cents == 5000 + assert upgraded.status == "pending" + assert upgraded.customer_name == original.customer_name + assert upgraded.customer_phone == original.customer_phone + assert upgraded.candidate_name == original.candidate_name + assert upgraded.paid_at is None + assert upgraded.started_at is None + assert upgraded.delivered_at is None + + source_after = dao.get(original.id) + assert source_after.status == "pending" + assert "upgraded" in source_after.tags + assert upgraded.id in (source_after.notes or "") + assert dao.count() == 2 + + def test_upgrade_order_rejects_non_increasing_target_amount( + self, dao, sample_order + ): + original = dao.create( + Order(**{ + **sample_order.to_dict(True), + "id": generate_order_id(), + "service_version": "basic", + "amount_cents": 4900, + "status": "pending", + }) + ) + + with pytest.raises(ValueError, match="高于原订单金额"): + dao.upgrade_order( + original.id, + target_service_version="standard", + target_amount_cents=4900, + ) + + def test_upgrade_order_rejects_second_upgrade_from_same_source( + self, dao, sample_order + ): + original = dao.create( + Order(**{ + **sample_order.to_dict(True), + "id": generate_order_id(), + "service_version": "basic", + "amount_cents": 4900, + "status": "pending", + }) + ) + dao.upgrade_order( + original.id, + target_service_version="standard", + target_amount_cents=9900, + ) + + with pytest.raises(ValueError, match="已存在升级订单"): + dao.upgrade_order( + original.id, + target_service_version="premium", + target_amount_cents=19900, + ) + + # --------------------------------------------------------------------------- # 4. 状态机守护 # --------------------------------------------------------------------------- @@ -474,7 +562,7 @@ class TestTransaction: class TestUpsert: def _make_order(self, **overrides) -> Order: - defaults = dict( + defaults: dict[str, Any] = dict( id=generate_order_id(), source="xianyu", external_id="EXT-1001", diff --git a/data/orders/tests/test_masking.py b/data/orders/tests/test_masking.py index 05ace66..bcae536 100644 --- a/data/orders/tests/test_masking.py +++ b/data/orders/tests/test_masking.py @@ -18,6 +18,7 @@ from data.orders.masking import ( mask_name, mask_phone, mask_sensitive_dict, + mask_wechat, ) @@ -154,11 +155,23 @@ def test_mask_name_mixed_chinese_and_digits_treats_as_non_cjk(): # ---------------------- mask_sensitive_dict ---------------------- +def test_mask_wechat_keeps_prefix_suffix(): + assert mask_wechat("wx-li") == "wx*li" + assert mask_wechat("wechat_user") == "we*******er" + + +def test_mask_wechat_none_and_empty_are_safe(): + assert mask_wechat(None) is None + assert mask_wechat("") == "" + assert mask_wechat("ab") == "**" + + def test_mask_sensitive_dict_handles_all_known_fields(): data = { "customer_phone": "13800001234", "candidate_id_card": "430102200501011234", "customer_name": "张三", + "customer_wechat": "wx-li", "candidate_name": "李四光", "customer_phone_hash": "abc", "amount_cents": 1000, @@ -167,6 +180,7 @@ def test_mask_sensitive_dict_handles_all_known_fields(): assert out["customer_phone"] == "138****1234" assert out["candidate_id_card"] == "430102********1234" assert out["customer_name"] == "张*" + assert out["customer_wechat"] == "wx*li" assert out["candidate_name"] == "李*光" # 3 字姓名 → 姓 + * + 名末字 assert out["customer_phone_hash"] == "abc" assert out["amount_cents"] == 1000 diff --git a/data/share/permission.py b/data/share/permission.py new file mode 100644 index 0000000..f7eb513 --- /dev/null +++ b/data/share/permission.py @@ -0,0 +1,295 @@ +""" +高考志愿填报系统 - 分享权限策略 (T7.3) + +职责: 把 ShortLinkService.permission 字段 (read/comment/edit) 翻译成 +"前端可不可以点编辑/可不可以提交评论/可见哪些字段" 的策略对象。 + +设计目标: +- 单测友好: 纯函数 + dataclass, 不依赖 DB / Web 框架。 +- 关注点分离: 短链接服务只管"短码 → 报告元数据";策略层只管"权限 → UI 能力"。 +- 默认安全: 未知 permission 一律按最严格的"只读 + 全脱敏"处理, 不允许越权。 + +与 masking 的关系: +- data.orders.masking.mask_name 提供基础姓名脱敏能力。 +- 本模块负责 "在哪个 permission 等级下要不要遮" 的策略决策。 +- 分享页是公开场景,因此在基础脱敏之上再做更保守的收敛,保证输出符合 + T7.3/T7.5 的 "张**" 风格约束。 + +层级 (T7.3 范围, 与 docs/plans/T7-sharing-mvp.md 对齐): + + 级别 字段值 可查看 可评论 可编辑 姓名展示 + -------- --------- ---------- ------------ ----------- ------------ + 只读 read ✔ ✘ ✘ 张** (全遮) + 评论 comment ✔ ✔ ✘ 张** (全遮) + 编辑 edit ✔ ✔ ✔ 张明 (全显) + + # 兼容 T7.1 留下的 PERM_ADMIN 等级: 视为 edit, 仍走策略表。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + +# 兼容 PERM_ADMIN: 把它当 edit 处理, 防止历史数据遗留 admin 等级时崩溃。 +# 改 POLICY_ADMIN_ALIAS 可调整。 +POLICY_ADMIN_ALIAS = "edit" + +# --------------------------------------------------------------------------- +# 字段可见性策略表 +# --------------------------------------------------------------------------- +# key: permission 等级 +# value: dict(可见字段集合, 允许能力集合, 是否脱敏姓名) +# +# 字段集合 None = "全部可见 (除内部字段外默认通过)"; +# iterable = "只允许 iterable 列出的字段被前端渲染"。 +# 这样未来加新 PII (电话 / 身份证) 时, 只要在 read/comment 行内把它们 +# 排除即可, 不必改 ShareLinkService。 + +_POLICY_TABLE: dict[str, dict[str, Any]] = { + "read": { + "can_view": True, + "can_comment": False, + "can_edit": False, + "mask_name": True, + # read 默认只暴露"通用展示"字段 + 脱敏姓名; + # 报告正文 / 推荐表 / 联系方式 全部隐藏 + "visible_fields": { + "report_id", + "permission", + "owner_id", + "share_url", + "created_at_iso", + "expires_at_iso", + "candidate_name", + "customer_name", + "student_name", + "name", + }, + }, + "comment": { + "can_view": True, + "can_comment": True, + "can_edit": False, + "mask_name": True, + # comment 可看报告正文 + 脱敏姓名, 但联系方式 / 身份证 / 电话仍不暴露 + "visible_fields": { + "report_id", + "permission", + "owner_id", + "share_url", + "created_at_iso", + "expires_at_iso", + "candidate_name", + "customer_name", + "student_name", + "name", + "title", + "summary", + "recommendations", + "volunteers", + "score", + "rank", + "year", + "province", + }, + }, + "edit": { + "can_view": True, + "can_comment": True, + "can_edit": True, + "mask_name": False, + # edit 允许业务字段透传,但内部敏感字段仍由 _ALWAYS_HIDDEN_FIELDS 拦截。 + "visible_fields": None, + }, + "admin": { + # alias -> edit + "can_view": True, + "can_comment": True, + "can_edit": True, + "mask_name": False, + "visible_fields": None, + }, +} + + +# 默认拒止策略: 任何未知 permission 一律当 read 处理 (最严) +_RESTRICTIVE_FALLBACK = _POLICY_TABLE["read"].copy() +_RESTRICTIVE_FALLBACK["visible_fields"] = set(_POLICY_TABLE["read"]["visible_fields"]) + + +# 显式 PII 字段: 永远走 mask_name 决策, 与 visible_fields 正交 +_PII_NAME_FIELDS = ("candidate_name", "customer_name", "student_name", "name") + +# 无论 permission 等级如何都不应进入公开分享 payload 的内部字段。 +# 这些字段要么属于安全敏感信息,要么只服务存储/运营侧,不应由 T7.3 透传。 +_ALWAYS_HIDDEN_FIELDS = frozenset( + { + "password_hash", + "internal_note", + "note", + "debug_info", + "raw_payload", + } +) + + +# --------------------------------------------------------------------------- +# Policy 数据类 +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PermissionPolicy: + """分享页的权限策略对象。""" + + permission: str + can_view: bool + can_comment: bool + can_edit: bool + mask_name: bool + # None = "默认通过 (除显式内部字段外全部可见)" + visible_fields: Optional[frozenset[str]] + # 内部使用: True 表示输入 permission 不在策略表内, 已落 fallback。 + _is_fallback: bool = False + + @property + def is_restrictive_fallback(self) -> bool: + """True 表示 permission 未知/为空, 已落到 read 默认拒止策略上。""" + return self._is_fallback + + def allows_field(self, field_name: str) -> bool: + """判断某个字段对当前策略是否应该被前端渲染。""" + if not self.can_view: + return False + if self.visible_fields is None: + return True + return field_name in self.visible_fields + + def can(self, action: str) -> bool: + """通用能力判断: can("view") / can("comment") / can("edit")""" + return bool(getattr(self, f"can_{action}", False)) + + @classmethod + def for_permission(cls, permission: str) -> "PermissionPolicy": + """根据 permission 字段值返回策略对象;未知值走严格 fallback。""" + perm_raw = (permission or "").strip().lower() + perm = POLICY_ADMIN_ALIAS if perm_raw == "admin" else perm_raw + if perm not in _POLICY_TABLE: + cfg = _RESTRICTIVE_FALLBACK + stored_perm = "read" + is_fallback = True + else: + cfg = _POLICY_TABLE[perm] + stored_perm = perm + is_fallback = False + vf = cfg.get("visible_fields") + vf_fs = frozenset(vf) if vf is not None else None + return cls( + permission=stored_perm, + can_view=bool(cfg.get("can_view", False)), + can_comment=bool(cfg.get("can_comment", False)), + can_edit=bool(cfg.get("can_edit", False)), + mask_name=bool(cfg.get("mask_name", True)), + visible_fields=vf_fs, + _is_fallback=is_fallback, + ) + + +# --------------------------------------------------------------------------- +# 报告 payload 渲染 +# --------------------------------------------------------------------------- + + +def _mask_name_safe(value: Any) -> Any: + """分享页姓名脱敏包装。 + + 复用 data.orders.masking.mask_name 的基础能力,但分享页采用更保守的公开展示策略: + - 中文 3 字及以上统一收敛为“姓 + **”(如 张三丰 -> 张**) + - 非中文姓名统一收敛为 "**",不泄露原始长度 + - 2 字中文沿用基础规则(张三 -> 张*) + """ + from data.orders.masking import mask_name + + if not isinstance(value, str): + return mask_name(value) + + s = value.strip() + if not s: + return "" + + base_masked = mask_name(s) + is_cjk = all("\u4e00" <= ch <= "\u9fff" for ch in s) + if not is_cjk: + return "**" + if len(s) >= 3: + return s[0] + "**" + return base_masked + + +def render_report_payload( + permission: str, + report: Optional[dict[str, Any]], + *, + share_url: Optional[str] = None, +) -> dict[str, Any]: + """根据权限等级, 渲染前端可见的报告 payload。""" + policy = PermissionPolicy.for_permission(permission) + raw = report if isinstance(report, dict) else {} + + payload: dict[str, Any] = {} + masked: list[str] = [] + + if share_url: + payload["share_url"] = share_url + + for key, value in raw.items(): + if key in _ALWAYS_HIDDEN_FIELDS: + continue + if not policy.allows_field(key): + continue + if key in _PII_NAME_FIELDS and policy.mask_name: + payload[key] = _mask_name_safe(value) + masked.append(key) + else: + payload[key] = value + + return { + "permission": policy.permission, + "policy": { + "can_view": policy.can_view, + "can_comment": policy.can_comment, + "can_edit": policy.can_edit, + "mask_name": policy.mask_name, + }, + "visible_fields": sorted(policy.visible_fields) + if policy.visible_fields is not None + else None, + "payload": payload, + "masked_fields": masked, + } + + +# --------------------------------------------------------------------------- +# 工具函数 +# --------------------------------------------------------------------------- + + +def supported_permissions() -> list[str]: + """返回策略表支持的全部 permission 等级 (含 admin alias)。""" + return list(_POLICY_TABLE.keys()) + + +def is_known_permission(permission: str) -> bool: + """判断 permission 是否在策略表中 (admin 视为合法)。""" + perm = (permission or "").strip().lower() + return perm in _POLICY_TABLE + + +__all__ = [ + "POLICY_ADMIN_ALIAS", + "PermissionPolicy", + "is_known_permission", + "render_report_payload", + "supported_permissions", +] diff --git a/data/share/short_link.py b/data/share/short_link.py index f91476a..4a2508c 100644 --- a/data/share/short_link.py +++ b/data/share/short_link.py @@ -12,20 +12,20 @@ URL 模式: /s/ABC123 → 短码示例 依赖: - 仅 Python 3.8+ 标准库 (sqlite3, hashlib, secrets, base64, binascii) + 仅 Python 3.8+ 标准库 (sqlite3, hashlib, secrets) """ -import binascii import hashlib +import hmac import os import secrets import sqlite3 import string import time from dataclasses import asdict, dataclass, field -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from pathlib import Path -from typing import List, Optional +from typing import Any, List, Optional # --------------------------------------------------------------------------- # 常量 @@ -38,6 +38,9 @@ BASE62_LEN = len(BASE62_ALPHABET) # 62 # 短码默认长度 (6 位 = 56B 空间, 实际使用远小于该值, 碰撞概率极低) DEFAULT_CODE_LEN = 6 +PBKDF2_SALT_BYTES = 16 +PBKDF2_ITERATIONS = 100_000 + # 默认数据库路径 DEFAULT_DB_PATH = Path(__file__).resolve().parent / "short_links.db" @@ -71,7 +74,9 @@ class ShareLink: report_id: str owner_id: str = "anonymous" permission: str = PERM_COMMENT - password_hash: Optional[str] = None # sha256 hex + password_hash: Optional[str] = ( + None # pbkdf2: salt_hex$digest_hex;兼容历史 sha256 hex + ) expires_at: Optional[float] = None # unix timestamp revoked: int = 0 access_count: int = 0 @@ -114,8 +119,12 @@ class ResolveResult: def ok(self) -> bool: return self.status == STATUS_OK - def to_dict(self) -> dict: - d = {"status": self.status, "code": self.code, "reason": self.reason} + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "status": self.status, + "code": self.code, + "reason": self.reason, + } if self.link is not None: d["link"] = self.link.to_dict() return d @@ -181,10 +190,42 @@ def _now() -> float: def _hash_password(password: str) -> str: - """密码哈希: sha256 (无盐, 因密码空间足够大; 真实部署可换 argon2)""" + """密码哈希: pbkdf2_hmac(sha256) + 16-byte salt,格式为 salt_hex$digest_hex。""" if not password: raise ValueError("password must be non-empty") - return hashlib.sha256(password.encode("utf-8")).hexdigest() + salt = secrets.token_bytes(PBKDF2_SALT_BYTES) + digest = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + PBKDF2_ITERATIONS, + ) + return f"{salt.hex()}${digest.hex()}" + + +def _is_legacy_sha256_hash(password_hash: str) -> bool: + return len(password_hash) == 64 and "$" not in password_hash + + +def _verify_password(password: str, password_hash: str) -> bool: + if not password: + return False + if _is_legacy_sha256_hash(password_hash): + legacy = hashlib.sha256(password.encode("utf-8")).hexdigest() + return hmac.compare_digest(legacy, password_hash) + + salt_hex, _, digest_hex = password_hash.partition("$") + if not salt_hex or not digest_hex: + return False + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(digest_hex) + candidate = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + PBKDF2_ITERATIONS, + ) + return hmac.compare_digest(candidate, expected) def _row_to_link(row: sqlite3.Row) -> ShareLink: @@ -247,9 +288,24 @@ class ShortLinkService: ON share_links(owner_id); CREATE INDEX IF NOT EXISTS idx_share_links_expires ON share_links(expires_at); + + CREATE TABLE IF NOT EXISTS share_link_access_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + accessed_at REAL NOT NULL, + visitor_token TEXT, + ip TEXT, + user_agent TEXT, + FOREIGN KEY(code) REFERENCES share_links(code) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_share_link_access_events_code_time + ON share_link_access_events(code, accessed_at); + CREATE INDEX IF NOT EXISTS idx_share_link_access_events_visitor + ON share_link_access_events(visitor_token); """ - def __init__(self, db_path: Optional[os.PathLike] = None): + def __init__(self, db_path: str | os.PathLike[str] | None = None): self.db_path = Path(db_path) if db_path else DEFAULT_DB_PATH self.db_path.parent.mkdir(parents=True, exist_ok=True) # 第一次实例化即建表 (亦可显式调用 init_schema) @@ -351,7 +407,10 @@ class ShortLinkService: note, ), ) - return self.get(code) + created = self.get(code) + if created is None: + raise RuntimeError(f"short link created but lookup failed: {code}") + return created except sqlite3.IntegrityError as e: # 唯一冲突: 碰撞, 重试 last_err = e @@ -376,6 +435,9 @@ class ShortLinkService: code: str, password: Optional[str] = None, record_access: bool = True, + visitor_token: Optional[str] = None, + ip: Optional[str] = None, + user_agent: Optional[str] = None, ) -> ResolveResult: """ 解析短码 -> (status, link, reason) @@ -412,21 +474,42 @@ class ShortLinkService: link=link, reason="password required", ) - if _hash_password(password) != link.password_hash: + if not _verify_password(password, link.password_hash): return ResolveResult( status=STATUS_PASSWORD_WRONG, code=code, link=link, reason="wrong password", ) + if _is_legacy_sha256_hash(link.password_hash): + upgraded_hash = _hash_password(password) + with self._connect() as conn: + conn.execute( + "UPDATE share_links SET password_hash = ? WHERE code = ?", + (upgraded_hash, link.code), + ) + link.password_hash = upgraded_hash if record_access: - self._bump_access(link.code) + self._bump_access( + link.code, + visitor_token=visitor_token, + ip=ip, + user_agent=user_agent, + ) link.access_count += 1 link.last_access_at = _now() return ResolveResult(status=STATUS_OK, code=code, link=link, reason="ok") - def _bump_access(self, code: str) -> None: + def _bump_access( + self, + code: str, + *, + visitor_token: Optional[str] = None, + ip: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> None: + accessed_at = _now() with self._connect() as conn: conn.execute( """ @@ -435,7 +518,15 @@ class ShortLinkService: last_access_at = ? WHERE code = ? """, - (_now(), code), + (accessed_at, code), + ) + conn.execute( + """ + INSERT INTO share_link_access_events( + code, accessed_at, visitor_token, ip, user_agent + ) VALUES (?, ?, ?, ?, ?) + """, + (code, accessed_at, visitor_token, ip, user_agent), ) # ---- 撤销 ---- @@ -461,6 +552,25 @@ class ShortLinkService: ) return cur.rowcount > 0 + def revoke_by_report(self, report_id: str, owner_id: Optional[str] = None) -> int: + """按 report 批量撤销分享链接;可选 owner 约束避免越权。""" + if not report_id: + return 0 + with self._connect() as conn: + if owner_id is not None: + cur = conn.execute( + "UPDATE share_links SET revoked = 1 " + "WHERE report_id = ? AND owner_id = ? AND revoked = 0", + (report_id, owner_id), + ) + else: + cur = conn.execute( + "UPDATE share_links SET revoked = 1 " + "WHERE report_id = ? AND revoked = 0", + (report_id,), + ) + return cur.rowcount + # ---- 列表 / 统计 ---- def list_by_report(self, report_id: str) -> List[ShareLink]: @@ -480,7 +590,7 @@ class ShortLinkService: ).fetchall() return [_row_to_link(r) for r in rows] - def get_stats(self, code: str) -> Optional[dict]: + def get_stats(self, code: str, days: int = 7) -> Optional[dict]: link = self.get(code) if link is None: return None @@ -496,6 +606,143 @@ class ShortLinkService: "expired": link.is_expired(), "created_at_iso": _iso(link.created_at), "expires_at_iso": _iso(link.expires_at) if link.expires_at else None, + "unique_visitors": self._count_unique_visitors(code=link.code), + "daily_accesses": self._daily_access_stats(code=link.code, days=days), + } + + def _count_unique_visitors( + self, + *, + code: Optional[str] = None, + report_id: Optional[str] = None, + owner_id: Optional[str] = None, + ) -> int: + clauses = ["e.visitor_token IS NOT NULL", "e.visitor_token != ''"] + params: list = [] + join = "" + if code is not None: + clauses.append("e.code = ?") + params.append(code) + else: + join = " JOIN share_links l ON l.code = e.code" + if report_id is not None: + clauses.append("l.report_id = ?") + params.append(report_id) + if owner_id is not None: + clauses.append("l.owner_id = ?") + params.append(owner_id) + query = ( + "SELECT COUNT(DISTINCT e.visitor_token) AS n " + "FROM share_link_access_events e" + f"{join} WHERE {' AND '.join(clauses)}" + ) + with self._connect() as conn: + row = conn.execute(query, params).fetchone() + return int(row["n"] if row else 0) + + def _daily_access_stats( + self, + *, + code: Optional[str] = None, + report_id: Optional[str] = None, + owner_id: Optional[str] = None, + days: int = 7, + ) -> List[dict]: + if days <= 0: + raise ValueError("days must be > 0") + + params: list = [] + if code is not None: + query = """ + SELECT + DATE(accessed_at, 'unixepoch') AS date, + COUNT(*) AS access_count, + COUNT(DISTINCT CASE + WHEN visitor_token IS NOT NULL AND visitor_token != '' + THEN visitor_token + END) AS unique_visitors + FROM share_link_access_events + WHERE code = ? + GROUP BY DATE(accessed_at, 'unixepoch') + ORDER BY DATE(accessed_at, 'unixepoch') ASC + """ + params.append(code) + else: + query = """ + SELECT + DATE(e.accessed_at, 'unixepoch') AS date, + COUNT(*) AS access_count, + COUNT(DISTINCT CASE + WHEN e.visitor_token IS NOT NULL AND e.visitor_token != '' + THEN e.visitor_token + END) AS unique_visitors + FROM share_link_access_events e + JOIN share_links l ON l.code = e.code + WHERE l.report_id = ? + """ + params.append(report_id) + if owner_id is not None: + query += " AND l.owner_id = ?" + params.append(owner_id) + query += ( + " GROUP BY DATE(e.accessed_at, 'unixepoch')" + " ORDER BY DATE(e.accessed_at, 'unixepoch') ASC" + ) + + with self._connect() as conn: + rows = conn.execute(query, params).fetchall() + + daily = [ + { + "date": row["date"], + "access_count": int(row["access_count"]), + "unique_visitors": int(row["unique_visitors"]), + } + for row in rows + ] + return daily[-days:] + + def get_report_stats( + self, + report_id: str, + owner_id: Optional[str] = None, + days: int = 7, + ) -> dict: + if not report_id: + raise ValueError("report_id is required") + + if owner_id is None: + links = self.list_by_report(report_id) + else: + links = [ + link + for link in self.list_by_report(report_id) + if link.owner_id == owner_id + ] + + last_access_at = max( + (link.last_access_at for link in links if link.last_access_at is not None), + default=None, + ) + return { + "report_id": report_id, + "owner_id": owner_id, + "total_links": len(links), + "active_links": sum(1 for link in links if link.is_active()), + "revoked_links": sum(1 for link in links if link.revoked != 0), + "expired_links": sum(1 for link in links if link.is_expired()), + "total_access_count": sum(link.access_count for link in links), + "unique_visitors": self._count_unique_visitors( + report_id=report_id, + owner_id=owner_id, + ), + "last_access_at": last_access_at, + "last_access_at_iso": _iso(last_access_at) if last_access_at else None, + "daily_accesses": self._daily_access_stats( + report_id=report_id, + owner_id=owner_id, + days=days, + ), } # ---- 维护 ---- @@ -578,6 +825,97 @@ def build_url(code: str, base: str = "http://localhost:8000") -> str: return f"{base.rstrip('/')}/s/{code}" +# --------------------------------------------------------------------------- +# T7.3 权限感知路由: 在 resolve() 之上叠加 report payload 渲染 +# --------------------------------------------------------------------------- +# 之所以不放进 route_short_link, 是为了: +# 1. 不破坏 T7.1 / T7.2 已落地的 CLI 脚本与单测签名; +# 2. 报告获取通常需要读盘 / 调 T2/T3 服务, 不在短链接服务职责内, +# 通过 report_loader 回调注入, 让本层保持零业务耦合。 +# --------------------------------------------------------------------------- + + +def route_short_link_with_report( + code: str, + password: Optional[str] = None, + base_url: str = "http://localhost:8000", + db_path: Optional[os.PathLike] = None, + *, + report_loader=None, + report: Optional[dict] = None, + include_url: bool = True, +) -> dict: + """ + 在 route_short_link 之上叠加 T7.3 权限感知渲染。 + + 用法 (Web 路由示意): + @app.route("/s/") + def short_link(code): + def loader(report_id): + return load_report_from_storage(report_id) + return jsonify(route_short_link_with_report( + code, + password=request.args.get("pwd"), + base_url=request.host_url.rstrip("/"), + report_loader=loader, + )) + + 参数: + code 短码 + password 访问密码 (query/body) + base_url 构造 /s/{code} 完整 URL + db_path SQLite 路径, 默认 DEFAULT_DB_PATH + report_loader 可选 callable(report_id) -> dict | None + 用于按 report_id 拉取原始报告数据 + report 可选直接传入报告 dict (优先级高于 report_loader) + include_url 是否在 payload 中附 share_url + + 返回 dict (兼容 T7.1 shape + 扩展字段): + { + ...route_short_link() 原样字段 (status, code, reason, url, ...)... + "rendered": { # T7.3 新增 + "permission": "read", + "policy": {...}, + "visible_fields": [...] | None, + "payload": {...}, # 字段裁剪 + 姓名脱敏后 + "masked_fields": [...] + } + } + + 当 resolve() 失败 (not_found / revoked / expired / password_*) 时, + rendered 不会被计算 — 让 Web 层直接根据 status 返回 401/403/404/410。 + """ + # 延迟 import 避免循环 (permission 模块会 import orders.masking) + from data.share.permission import render_report_payload + + base = route_short_link( + code, + password=password, + base_url=base_url, + db_path=db_path, + ) + + # resolve 失败 -> 不渲染 payload, 行为对齐"未授权禁止"原则 + if base.get("status") != STATUS_OK: + return base + + report_id = base.get("report_id") + perm = base.get("permission", "read") + if report is not None: + report_data = report + elif report_loader is not None and report_id is not None: + try: + report_data = report_loader(report_id) + except Exception: + report_data = None + else: + report_data = None + + share_url = base.get("url") if include_url else None + base["rendered"] = render_report_payload(perm, report_data, share_url=share_url) + return base + + # --------------------------------------------------------------------------- # CLI 直接调用 (python -m data.share.short_link ...) 用作冒烟 # --------------------------------------------------------------------------- @@ -601,6 +939,7 @@ def _self_test() -> None: # pragma: no cover - 仅 CLI 触发 # 2. resolve (ok) res = svc.resolve(link.code) assert res.ok, f"unexpected status: {res.status}" + assert res.link is not None print(f"resolve ok: access_count={res.link.access_count}") # 3. revoke diff --git a/data/share/tests/test_permission.py b/data/share/tests/test_permission.py new file mode 100644 index 0000000..1194e19 --- /dev/null +++ b/data/share/tests/test_permission.py @@ -0,0 +1,635 @@ +""" +分享权限策略单元测试 (T7.3) + +覆盖: +- PermissionPolicy.for_permission() 对 read/comment/edit/admin/未知 +- render_report_payload() 字段裁剪 + 姓名脱敏 +- 兼容 PERM_ADMIN (alias -> edit) +- 路由层 route_short_link_with_report() 在 resolve 失败时不下发 payload + +运行: + python3 -m pytest data/share/tests/test_permission.py -v + # 或 (无 pytest 时) + python3 data/share/tests/test_permission.py +""" + +import sys +import tempfile +import os +import uuid +from pathlib import Path + +# 让 data.share 可被 import +PROJ = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(PROJ)) + +from data.share.permission import ( # noqa: E402 + POLICY_ADMIN_ALIAS, + PermissionPolicy, + is_known_permission, + render_report_payload, + supported_permissions, +) +from data.share.short_link import ( # noqa: E402 + PERM_COMMENT, + PERM_EDIT, + PERM_READ, + STATUS_NOT_FOUND, + STATUS_OK, + STATUS_PASSWORD_REQUIRED, + STATUS_REVOKED, + ShortLinkService, + route_short_link_with_report, +) + + +# --------------------------------------------------------------------------- +# 自定义 runner (兼容无 pytest 环境) +# --------------------------------------------------------------------------- + +_TMP_DBS: list = [] + + +def make_svc() -> ShortLinkService: + fd, db = tempfile.mkstemp(prefix=f"perm_test_{uuid.uuid4().hex[:8]}_", suffix=".db") + os.close(fd) + _TMP_DBS.append(db) + return ShortLinkService(db_path=db) + + +def cleanup_tmp_dbs(): + for db in _TMP_DBS: + try: + os.remove(db) + except OSError: + pass + + +_PASS = 0 +_FAIL = 0 +_ERRORS: list = [] + + +def _eq(actual, expected, msg=""): + global _PASS, _FAIL + if actual == expected: + _PASS += 1 + else: + _FAIL += 1 + _ERRORS.append(f"FAIL: {msg or 'eq'}: {actual!r} != {expected!r}") + + +def _truthy(v, msg): + global _PASS, _FAIL + if v: + _PASS += 1 + else: + _FAIL += 1 + _ERRORS.append(f"FAIL: {msg}: {v!r}") + + +def _has(d, key, msg): + _truthy(key in d, f"{msg}: {key!r} in dict") + + +# --------------------------------------------------------------------------- +# PermissionPolicy: 静态策略 +# --------------------------------------------------------------------------- + + +def test_policy_supported_permissions(): + perms = set(supported_permissions()) + # 必须包含 3 级业务 + admin alias 兼容 + _eq( + {"read", "comment", "edit", "admin"}.issubset(perms), + True, + "supported_permissions contains read/comment/edit/admin", + ) + + +def test_policy_admin_alias(): + _eq(POLICY_ADMIN_ALIAS, "edit", "admin aliases to edit") + + +def test_policy_read_caps(): + p = PermissionPolicy.for_permission("read") + _eq(p.can_view, True, "read can_view") + _eq(p.can_comment, False, "read cannot comment") + _eq(p.can_edit, False, "read cannot edit") + _eq(p.mask_name, True, "read masks name") + _truthy(p.visible_fields is not None, "read has explicit visible_fields") + + +def test_policy_comment_caps(): + p = PermissionPolicy.for_permission("comment") + _eq(p.can_view, True, "comment can_view") + _eq(p.can_comment, True, "comment can comment") + _eq(p.can_edit, False, "comment cannot edit") + _eq(p.mask_name, True, "comment masks name") + _truthy(p.visible_fields is not None, "comment has explicit visible_fields") + + +def test_policy_edit_caps(): + p = PermissionPolicy.for_permission("edit") + _eq(p.can_view, True, "edit can_view") + _eq(p.can_comment, True, "edit can comment") + _eq(p.can_edit, True, "edit can edit") + _eq(p.mask_name, False, "edit does NOT mask name") + _eq(p.visible_fields, None, "edit visible_fields=None (default pass)") + + +def test_policy_admin_alias_to_edit(): + p = PermissionPolicy.for_permission("admin") + _eq(p.permission, "edit", "admin permission normalized to edit") + _eq(p.can_edit, True, "admin can edit (via alias)") + _eq(p.mask_name, False, "admin does NOT mask name (via alias)") + + +def test_policy_unknown_permission_falls_back_to_read(): + """未知的 permission 必须落到最严格的 read 拒止策略, 不允许越权。""" + p = PermissionPolicy.for_permission("superuser") + _eq(p.permission, "read", "unknown perm stored as 'read'") + _eq(p.can_view, True, "fallback: view OK (for nicer UX)") + _eq(p.can_comment, False, "fallback: cannot comment") + _eq(p.can_edit, False, "fallback: cannot edit") + _eq(p.mask_name, True, "fallback: mask name") + _truthy(p.is_restrictive_fallback, "is_restrictive_fallback=True") + + +def test_policy_empty_permission_falls_back(): + p = PermissionPolicy.for_permission("") + _eq(p.is_restrictive_fallback, True, "empty perm triggers fallback") + + +def test_policy_case_insensitive(): + p1 = PermissionPolicy.for_permission("READ") + p2 = PermissionPolicy.for_permission("read") + _eq(p1.can_edit, p2.can_edit, "case insensitive read parity") + _eq(p1.permission, "read", "uppercase normalized") + + +def test_is_known_permission(): + _eq(is_known_permission("read"), True, "read is known") + _eq(is_known_permission("edit"), True, "edit is known") + _eq(is_known_permission("admin"), True, "admin is known") + _eq(is_known_permission("superuser"), False, "superuser unknown") + _eq(is_known_permission(""), False, "empty unknown") + + +def test_allows_field_for_read(): + p = PermissionPolicy.for_permission("read") + _eq(p.allows_field("recommendations"), False, "read hides recommendations") + _eq(p.allows_field("report_id"), True, "read allows report_id") + _eq(p.allows_field("score"), False, "read hides score") + + +def test_allows_field_for_edit(): + p = PermissionPolicy.for_permission("edit") + _eq(p.allows_field("recommendations"), True, "edit allows recommendations") + _eq(p.allows_field("candidate_phone"), True, "edit allows phone") + _eq( + p.allows_field("password_hash"), + True, + "edit: visible_fields=None -> pass (上层负责拦截 hash)", + ) + + +def test_allows_field_for_no_view(): + """can_view=False 时, 即便字段在 visible_fields 内也不应暴露。""" + p = PermissionPolicy( + permission="nop", + can_view=False, + can_comment=False, + can_edit=False, + mask_name=True, + visible_fields=frozenset({"report_id"}), + ) + _eq(p.allows_field("report_id"), False, "no view => no field") + + +def test_can_helper(): + p = PermissionPolicy.for_permission("edit") + _eq(p.can("view"), True, "can('view')") + _eq(p.can("comment"), True, "can('comment')") + _eq(p.can("edit"), True, "can('edit')") + _eq(p.can("admin"), False, "can('admin') unknown action -> False") + + +# --------------------------------------------------------------------------- +# render_report_payload: 字段裁剪 + 姓名脱敏 +# --------------------------------------------------------------------------- + + +_SAMPLE_REPORT = { + "report_id": "R-2026-001", + "title": "578分 湖南 志愿方案", + "summary": "冲稳保 45 志愿", + "candidate_name": "李明", + "customer_name": "李明", + "score": 578, + "rank": 12345, + "year": 2026, + "province": "湖南", + "recommendations": [{"school": "江西财经大学", "major": "会计学", "prob": 0.35}], + "volunteers": [{"group": 1, "school": "江西财经大学", "majors": ["会计学"]}], + "candidate_phone": "13800001234", + "candidate_id_card": "430102200801011234", + "password_hash": "should-not-leak-via-policy", + "internal_note": "should-not-leak-via-policy", +} + + +def test_render_read_payload(): + out = render_report_payload("read", _SAMPLE_REPORT) + _eq(out["permission"], "read", "permission echoed") + _eq(out["policy"]["can_view"], True, "policy.can_view") + _eq(out["policy"]["can_comment"], False, "policy.can_comment") + _eq(out["policy"]["mask_name"], True, "policy.mask_name") + payload = out["payload"] + # 可见 + _has(payload, "report_id", "read: report_id visible") + # 不可见: title / recommendations / score / phone / hash + _eq("title" in payload, False, "read: title hidden") + _eq("recommendations" in payload, False, "read: recommendations hidden") + _eq("score" in payload, False, "read: score hidden") + _eq("candidate_phone" in payload, False, "read: phone hidden") + _eq("password_hash" in payload, False, "read: hash hidden") + # 姓名可见但必须脱敏 + _eq(payload.get("candidate_name"), "李*", "read: candidate_name masked") + _eq(payload.get("customer_name"), "李*", "read: customer_name masked") + _eq( + sorted(out["masked_fields"]), + ["candidate_name", "customer_name"], + "read: masked_fields reports masked names", + ) + + +def test_render_comment_payload(): + out = render_report_payload("comment", _SAMPLE_REPORT) + payload = out["payload"] + # 可见: 报告正文类 + _has(payload, "title", "comment: title visible") + _has(payload, "summary", "comment: summary visible") + _has(payload, "score", "comment: score visible") + _has(payload, "rank", "comment: rank visible") + _has(payload, "recommendations", "comment: recommendations visible") + _has(payload, "volunteers", "comment: volunteers visible") + # 不可见: PII / 私密字段 + _eq("candidate_phone" in payload, False, "comment: phone hidden") + _eq("candidate_id_card" in payload, False, "comment: id_card hidden") + _eq("password_hash" in payload, False, "comment: hash hidden") + _eq("internal_note" in payload, False, "comment: internal note hidden") + # 姓名应被脱敏显示,满足分享页 UI 的“张**”效果 + _eq(payload.get("candidate_name"), "李*", "comment: candidate_name masked") + _eq(payload.get("customer_name"), "李*", "comment: customer_name masked") + _eq( + sorted(out["masked_fields"]), + ["candidate_name", "customer_name"], + "comment: masked_fields reports masked names", + ) + + +def test_render_edit_payload(): + out = render_report_payload("edit", _SAMPLE_REPORT) + payload = out["payload"] + _eq(out["policy"]["mask_name"], False, "edit: no mask") + # edit 默认通过业务字段,但内部敏感字段仍不可外暴 + _has(payload, "candidate_name", "edit: candidate_name visible") + _has(payload, "candidate_phone", "edit: phone visible") + _has(payload, "candidate_id_card", "edit: id_card visible") + _eq("password_hash" in payload, False, "edit: password_hash hidden") + _eq("internal_note" in payload, False, "edit: internal_note hidden") + _eq(payload["candidate_name"], "李明", "edit: name intact") + _eq(out["masked_fields"], [], "edit: no fields masked") + + +def test_render_edit_name_unmasked_in_payload(): + """编辑权限下, 报告里所有 *_name 字段应原样输出。""" + report = { + "candidate_name": "欧阳明月", + "customer_name": "欧阳明月", + "score": 600, + } + out = render_report_payload("edit", report) + _eq(out["payload"]["candidate_name"], "欧阳明月", "edit: 4-char name intact") + _eq(out["payload"]["customer_name"], "欧阳明月", "edit: customer_name intact") + + +def test_render_admin_aliases_to_edit(): + """admin 视为 edit, 姓名不脱敏, 字段全通过。""" + out = render_report_payload("admin", _SAMPLE_REPORT) + _eq(out["permission"], "edit", "admin perm normalized to edit in payload") + _eq(out["payload"]["candidate_name"], "李明", "admin: name intact") + _eq(out["policy"]["can_edit"], True, "admin: can edit") + + +def test_render_mask_name_when_name_field_in_visible(): + """分享页公开展示时, 3 字中文名应收敛为“姓 + **”。""" + from data.share.permission import _POLICY_TABLE # noqa + + original = _POLICY_TABLE["read"]["visible_fields"] + try: + _POLICY_TABLE["read"]["visible_fields"] = set(original) | {"candidate_name"} + out = render_report_payload("read", {"candidate_name": "张三丰"}) + _eq( + out["payload"]["candidate_name"], + "张**", + "share-name masking collapses 3-char CJK names to surname + **", + ) + _eq( + "candidate_name" in out["masked_fields"], + True, + "candidate_name reported in masked_fields", + ) + finally: + _POLICY_TABLE["read"]["visible_fields"] = original + + +def test_render_mask_name_non_cjk_collapses_to_constant(): + out = render_report_payload("read", {"candidate_name": "Alice"}) + _eq( + out["payload"]["candidate_name"], + "**", + "non-cjk share name should not leak length", + ) + _eq( + "candidate_name" in out["masked_fields"], + True, + "non-cjk masked field still reported", + ) + + +def test_render_handles_none_report(): + out = render_report_payload("read", None) + _eq(out["payload"], {}, "None report -> empty payload (still renders)") + _eq(out["masked_fields"], [], "no fields masked on None report") + _eq(out["policy"]["can_view"], True, "policy still echoed on None report") + + +def test_render_handles_non_dict_report(): + """非 dict 输入应被视作无报告, 不抛错。""" + out = render_report_payload("edit", "not a dict") + _eq(out["payload"], {}, "string report -> empty payload") + + +def test_render_share_url_injection(): + out = render_report_payload( + "read", {"report_id": "R-1"}, share_url="https://x/s/ABC" + ) + _eq( + out["payload"].get("share_url"), + "https://x/s/ABC", + "share_url injected into payload when provided", + ) + + +def test_render_visible_fields_echo(): + out = render_report_payload("comment", _SAMPLE_REPORT) + _truthy(out["visible_fields"] is not None, "comment: visible_fields list echoed") + _eq("title" in out["visible_fields"], True, "title in visible_fields") + + +def test_render_edit_visible_fields_none(): + out = render_report_payload("edit", _SAMPLE_REPORT) + _eq(out["visible_fields"], None, "edit: visible_fields=None means pass-through") + + +# --------------------------------------------------------------------------- +# route_short_link_with_report: 端到端 +# --------------------------------------------------------------------------- + + +def test_route_with_report_read(): + fd, db = tempfile.mkstemp(suffix=".db") + os.close(fd) + _TMP_DBS.append(db) + try: + svc = ShortLinkService(db_path=db) + link = svc.create(report_id="R-1", owner_id="alice", permission=PERM_READ) + report = dict(_SAMPLE_REPORT, report_id="R-1") + out = route_short_link_with_report( + link.code, + base_url="https://gk.example.com", + db_path=db, + report=report, + ) + _eq(out["status"], STATUS_OK, "route ok") + _has(out, "rendered", "rendered key present") + _eq(out["rendered"]["permission"], "read", "rendered permission") + _eq(out["url"], "https://gk.example.com/s/" + link.code, "url") + _has(out["rendered"]["payload"], "report_id", "report_id in payload") + finally: + os.remove(db) + + +def test_route_with_report_loader_callback(): + fd, db = tempfile.mkstemp(suffix=".db") + os.close(fd) + _TMP_DBS.append(db) + try: + svc = ShortLinkService(db_path=db) + link = svc.create(report_id="R-9", permission=PERM_COMMENT) + loaded = {} + + def loader(report_id): + loaded["report_id"] = report_id + return {"report_id": report_id, "title": "from-loader"} + + out = route_short_link_with_report( + link.code, + base_url="https://gk.example.com", + db_path=db, + report_loader=loader, + ) + _eq(loaded.get("report_id"), "R-9", "loader called with report_id") + _eq( + out["rendered"]["payload"]["title"], "from-loader", "loader result rendered" + ) + finally: + os.remove(db) + + +def test_route_with_report_loader_exception(): + """loader 抛错时, 路由不应整体崩溃, 仍下发的只是空 payload。""" + fd, db = tempfile.mkstemp(suffix=".db") + os.close(fd) + _TMP_DBS.append(db) + try: + svc = ShortLinkService(db_path=db) + link = svc.create(report_id="R-1", permission=PERM_EDIT) + + def boom(_): + raise RuntimeError("storage down") + + out = route_short_link_with_report( + link.code, + base_url="https://gk.example.com", + db_path=db, + report_loader=boom, + ) + _eq(out["status"], STATUS_OK, "still ok status") + # loader 失败时: report_data=None, payload 走"无报告"分支; + # 仍保留 share_url 方便前端"复制链接"按钮。 + _eq( + "report_id" in out["rendered"]["payload"], + False, + "no report_id when loader fails", + ) + _eq( + "candidate_name" in out["rendered"]["payload"], + False, + "no name when loader fails", + ) + _has( + out["rendered"]["payload"], + "share_url", + "share_url still present when loader fails", + ) + finally: + os.remove(db) + + +def test_route_with_report_resolve_failure_no_payload(): + """resolve 失败时, 不应下发 rendered (避免泄露元数据)。""" + fd, db = tempfile.mkstemp(suffix=".db") + os.close(fd) + _TMP_DBS.append(db) + try: + # 不创建 -> not_found + out = route_short_link_with_report( + "ZZZZZZ", + base_url="https://gk.example.com", + db_path=db, + report={"report_id": "R-1"}, + ) + _eq(out["status"], STATUS_NOT_FOUND, "not_found") + _eq("rendered" in out, False, "no rendered on failure") + finally: + os.remove(db) + + +def test_route_with_report_password_required_no_payload(): + """密码未提供 -> password_required, 不下发 rendered。""" + fd, db = tempfile.mkstemp(suffix=".db") + os.close(fd) + _TMP_DBS.append(db) + try: + svc = ShortLinkService(db_path=db) + link = svc.create(report_id="R-1", password="s3cr3t") + out = route_short_link_with_report( + link.code, + base_url="https://gk.example.com", + db_path=db, + report={"report_id": "R-1"}, + ) + _eq(out["status"], STATUS_PASSWORD_REQUIRED, "password_required") + _eq("rendered" in out, False, "no rendered when password required") + finally: + os.remove(db) + + +def test_route_with_report_revoked_no_payload(): + """revoked 时, 不应下发 rendered。""" + fd, db = tempfile.mkstemp(suffix=".db") + os.close(fd) + _TMP_DBS.append(db) + try: + svc = ShortLinkService(db_path=db) + link = svc.create(report_id="R-1", owner_id="alice") + svc.revoke(link.code, owner_id="alice") + out = route_short_link_with_report( + link.code, + base_url="https://gk.example.com", + db_path=db, + report={"report_id": "R-1"}, + ) + _eq(out["status"], STATUS_REVOKED, "revoked") + _eq("rendered" in out, False, "no rendered when revoked") + finally: + os.remove(db) + + +def test_route_with_report_include_url_false(): + fd, db = tempfile.mkstemp(suffix=".db") + os.close(fd) + _TMP_DBS.append(db) + try: + svc = ShortLinkService(db_path=db) + link = svc.create(report_id="R-1", permission=PERM_EDIT) + out = route_short_link_with_report( + link.code, + base_url="https://gk.example.com", + db_path=db, + report={"report_id": "R-1"}, + include_url=False, + ) + _eq( + "share_url" in out["rendered"]["payload"], + False, + "share_url omitted when include_url=False", + ) + finally: + os.remove(db) + + +def test_route_with_report_report_arg_priority(): + """显式 report 参数优先级高于 loader。""" + fd, db = tempfile.mkstemp(suffix=".db") + os.close(fd) + _TMP_DBS.append(db) + try: + svc = ShortLinkService(db_path=db) + link = svc.create(report_id="R-1", permission=PERM_EDIT) + + def loader(_): + return {"title": "from-loader"} + + out = route_short_link_with_report( + link.code, + base_url="https://gk.example.com", + db_path=db, + report={"title": "from-arg"}, + report_loader=loader, + ) + _eq( + out["rendered"]["payload"]["title"], + "from-arg", + "explicit report arg wins over loader", + ) + finally: + os.remove(db) + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + + +def main(): + test_funcs = [ + v for k, v in globals().items() if k.startswith("test_") and callable(v) + ] + for fn in test_funcs: + try: + fn() + except Exception as e: + global _FAIL + _FAIL += 1 + _ERRORS.append(f"ERROR in {fn.__name__}: {type(e).__name__}: {e}") + + print() + print(f"PASS: {_PASS}") + print(f"FAIL: {_FAIL}") + if _ERRORS: + print() + for err in _ERRORS: + print(f" {err}") + cleanup_tmp_dbs() + sys.exit(1) + print("ALL TESTS PASSED") + cleanup_tmp_dbs() + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/data/share/tests/test_short_link.py b/data/share/tests/test_short_link.py index c5ce66b..1775463 100644 --- a/data/share/tests/test_short_link.py +++ b/data/share/tests/test_short_link.py @@ -7,18 +7,20 @@ python3 data/share/tests/test_short_link.py """ +import hashlib +import os import sys +import tempfile import time +import uuid from pathlib import Path +from typing import Callable # 让 data.share 可被 import PROJ = Path(__file__).resolve().parents[3] sys.path.insert(0, str(PROJ)) -import tempfile -import os -import uuid - +import data.share.short_link as short_link_module # noqa: E402 from data.share.short_link import ( # noqa: E402 BASE62_ALPHABET, DEFAULT_CODE_LEN, @@ -228,6 +230,63 @@ def test_resolve_password_required(): _eq(res.status, STATUS_OK, "correct pwd ok") +def test_create_password_uses_pbkdf2_storage_format(): + svc = make_svc() + link = svc.create(report_id="R-1", password="s3cr3t") + _truthy(link.password_hash is not None, "password hash stored") + assert link.password_hash is not None + _truthy("$" in link.password_hash, "pbkdf2 format contains separator") + salt_hex, digest_hex = link.password_hash.split("$", 1) + _eq(len(salt_hex), 32, "salt is 16 bytes hex") + _eq(len(digest_hex), 64, "digest is 32 bytes hex") + + +def test_resolve_legacy_sha256_hash_migrates_to_pbkdf2(): + svc = make_svc() + link = svc.create(report_id="R-1", password="s3cr3t") + legacy_hash = hashlib.sha256("s3cr3t".encode("utf-8")).hexdigest() + with svc._connect() as conn: + conn.execute( + "UPDATE share_links SET password_hash = ? WHERE code = ?", + (legacy_hash, link.code), + ) + + res = svc.resolve(link.code, password="s3cr3t") + _eq(res.status, STATUS_OK, "legacy sha256 password still resolves") + migrated = svc.get(link.code) + _truthy(migrated is not None, "link still exists after migration") + assert migrated is not None + _truthy(migrated.password_hash is not None, "migrated hash stored") + assert migrated.password_hash is not None + _truthy("$" in migrated.password_hash, "legacy hash upgraded to pbkdf2") + _truthy( + migrated.password_hash != legacy_hash, + "migrated hash no longer equals raw sha256 hex", + ) + + +def test_wrong_password_does_not_migrate_legacy_hash(): + svc = make_svc() + link = svc.create(report_id="R-1", password="s3cr3t") + legacy_hash = hashlib.sha256("s3cr3t".encode("utf-8")).hexdigest() + with svc._connect() as conn: + conn.execute( + "UPDATE share_links SET password_hash = ? WHERE code = ?", + (legacy_hash, link.code), + ) + + res = svc.resolve(link.code, password="wrong") + _eq(res.status, STATUS_PASSWORD_WRONG, "wrong legacy password rejected") + unchanged = svc.get(link.code) + _truthy(unchanged is not None, "link still exists after wrong password") + assert unchanged is not None + _eq( + unchanged.password_hash, + legacy_hash, + "wrong password keeps legacy hash unchanged", + ) + + def test_resolve_expired(): svc = make_svc() link = svc.create(report_id="R-1", ttl_seconds=1) @@ -308,7 +367,7 @@ def test_list_by_report(): links = svc.list_by_report("R-1") _eq(len(links), 2, "list_by_report filters correctly") _truthy( - all(l.report_id == "R-1" for l in links), + all(link.report_id == "R-1" for link in links), "all links belong to R-1", ) @@ -336,6 +395,65 @@ def test_stats(): _eq(svc.get_stats("NOTHERE"), None, "missing stats -> None") +def test_revoke_by_report(): + svc = make_svc() + a = svc.create(report_id="R-1", owner_id="alice") + b = svc.create(report_id="R-1", owner_id="alice") + c = svc.create(report_id="R-1", owner_id="bob") + d = svc.create(report_id="R-2", owner_id="alice") + + _eq(svc.revoke_by_report("R-1", owner_id="alice"), 2, "revoke 2 alice links") + _eq(svc.resolve(a.code).status, STATUS_REVOKED, "alice link A revoked") + _eq(svc.resolve(b.code).status, STATUS_REVOKED, "alice link B revoked") + _eq(svc.resolve(c.code).status, STATUS_OK, "bob link untouched") + _eq(svc.resolve(d.code).status, STATUS_OK, "other report untouched") + _eq(svc.revoke_by_report("R-1", owner_id="alice"), 0, "idempotent second revoke") + + +def test_stats_include_daily_accesses_and_unique_visitors(): + svc = make_svc() + link = svc.create(report_id="R-1") + original_now = short_link_module._now + try: + timestamps = [ + 1718064000.0, # 2024-06-11 UTC + 1718067600.0, # 2024-06-11 UTC + 1718150400.0, # 2024-06-12 UTC + ] + visitors = ["wechat-openid-1", "wechat-openid-1", "wechat-openid-2"] + + def _fixed_now(ts: float) -> Callable[[], float]: + return lambda: ts + + for ts, visitor in zip(timestamps, visitors): + short_link_module._now = _fixed_now(ts) + svc._bump_access(link.code, visitor_token=visitor) + finally: + short_link_module._now = original_now + + stats = svc.get_stats(link.code, days=2) + _eq(stats["access_count"], 3, "access_count aggregates event log") + _eq(stats["unique_visitors"], 2, "unique visitors dedup by visitor_token") + _eq( + stats["daily_accesses"], + [ + {"date": "2024-06-11", "access_count": 2, "unique_visitors": 1}, + {"date": "2024-06-12", "access_count": 1, "unique_visitors": 1}, + ], + "daily stats grouped by UTC day", + ) + + report_stats = svc.get_report_stats("R-1", days=2) + _eq(report_stats["total_links"], 1, "report tracks link count") + _eq(report_stats["total_access_count"], 3, "report total accesses sums links") + _eq(report_stats["unique_visitors"], 2, "report unique visitors deduped") + _eq( + report_stats["daily_accesses"], + stats["daily_accesses"], + "report reuses daily aggregation", + ) + + def test_purge_expired(): svc = make_svc() svc.create(report_id="R-1", ttl_seconds=1) @@ -435,8 +553,8 @@ def main(): print(f"FAIL: {_FAIL}") if _ERRORS: print() - for e in _ERRORS: - print(f" {e}") + for err in _ERRORS: + print(f" {err}") sys.exit(1) print("ALL TESTS PASSED") cleanup_tmp_dbs() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b5887c0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + gaokao-admin: + build: + context: . + dockerfile: Dockerfile + image: localhost/gaokao-volunteer-system-admin:latest + container_name: gaokao-admin + restart: unless-stopped + environment: + GAOKAO_ENV: ${GAOKAO_ENV:-dev} + GAOKAO_JWT_SECRET: ${GAOKAO_JWT_SECRET:-dev-only-local-jwt-secret-please-override-before-production-1234567890abcdef} + GAOKAO_ADMIN_USER: ${GAOKAO_ADMIN_USER:-admin} + GAOKAO_ADMIN_PASS: ${GAOKAO_ADMIN_PASS:-dev-only-local-admin-pass-please-change} + GAOKAO_ORDERS_FERNET_KEY: ${GAOKAO_ORDERS_FERNET_KEY:-dev-only-local-orders-fernet-key-please-change} + GAOKAO_DB_PATH: /var/lib/gaokao/orders/admin.db + GAOKAO_ORDERS_DB_PATH: /var/lib/gaokao/orders.db + GAOKAO_SHARE_DB_PATH: /var/lib/gaokao/share/short_links.db + GAOKAO_SHARE_REPORT_DIR: /var/lib/gaokao/share/reports + ports: + - "${GAOKAO_ADMIN_BIND:-127.0.0.1}:${GAOKAO_ADMIN_PORT:-8000}:8000" + volumes: + - gaokao_admin_data:/var/lib/gaokao + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import json,sys,urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)); sys.exit(0 if data.get('status') == 'ok' else 1)", + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + +volumes: + gaokao_admin_data: diff --git a/docs/API.md b/docs/API.md index 8643497..c012eb1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,326 +1,502 @@ -# API参考 +# API 参考(当前真相) -本文档提供给开发者使用的API参考。 +本文档描述 2026-06-13 当前仓库中已落地、可验证的接口与命令行入口。 + +适用范围: + +- 管理后台 FastAPI API +- 已交付 CLI(审核 / 订单 / 数据溯源) +- 关键数据契约与鉴权约定 + +不包含: + +- 旧版 `spec_checker_v2.py` 的逐函数说明 +- 尚未落地的用户端 Web 自助下单/支付/资料填写闭环 --- -## 🎯 规范检查器 API +## 1. 当前接口面 -### `GaokaoSpecCheckerV2` 类 +### 1.1 HTTP / FastAPI -用于检查志愿方案的规范性。 +服务入口:`python3 -m admin.app --port 8000` -#### 构造函数 +基础约定: -```python -from spec_checker_v2 import GaokaoSpecCheckerV2 +- 健康检查:`GET /health` +- 登录:`POST /api/auth/login` +- Bearer 鉴权:除 `/health`、`/dashboard`、`/s/{code}` 外,管理 API 默认需要 JWT +- OpenAPI:`GET /openapi.json` +- Swagger:`GET /docs` -checker = GaokaoSpecCheckerV2(province=None) -``` +### 1.2 CLI -**参数**: +当前仓库内已落地的主要 CLI: -- `province` (str, 可选): 省份名称,如"湖南"、"浙江"。如果未指定,会自动检测。 - -#### 方法 - -##### `auto_detect_and_check(text)` - -自动检测省份并检查方案。 - -**参数**: - -- `text` (str): 志愿方案文本 - -**返回值**: - -- `str`: 检查报告(Markdown格式) - -**示例**: - -```python -checker = GaokaoSpecCheckerV2() -report = checker.auto_detect_and_check("我是湖南考生,578分...") -print(report) -``` - -##### `check_volunteer_unit(text)` - -检查志愿单位是否正确。 - -**检测条件**: - -- 省份模式为"院校专业组"时,应使用"院校专业组"而非"学校" -- 省份模式为"专业+学校"时,不应使用"组内服从" - -##### `check_volunteer_count(text)` - -检查志愿数量是否合规。 - -**检测条件**: - -- 是否超过本省最大志愿数 -- 是否填满建议志愿数 - -##### `check_majors_per_group(text)` - -检查每组专业数。 - -**检测条件**: - -- 院校专业组模式:每组最多6个 -- 专业+学校模式:每组1个 - -##### `check_adjustment_rule(text)` - -检查调剂规则。 - -**检测条件**: - -- 调剂范围是否符合本省模式 -- 是否提到"组内专业" +- `python3 -m skills.gaokao-audit.scripts.audit_cli ` +- `python3 scripts/gaokao-order-manager ...` +- `python3 scripts/gaokao-data-trace ` +- `python3 scripts/gaokao-quick-3min.py` +- `python3 scripts/gaokao-channel-fallback ...` --- -## 🗺️ 省份规则 API +## 2. 管理后台 HTTP API -### `PROVINCE_RULES` 字典 +### 2.1 健康检查 -包含27个省份的志愿填报规则。 +#### `GET /health` -```python -from spec_checker_v2 import PROVINCE_RULES +用途:进程/配置级健康检查。 -# 获取湖南规则 -hunan_rule = PROVINCE_RULES["湖南"] +响应示例: -print(hunan_rule["mode"]) # "院校专业组" -print(hunan_rule["max_volunteers"]) # 45 -print(hunan_rule["adjustment_scope"]) # "组内专业" -``` - -### 规则字段 - -| 字段 | 类型 | 说明 | 示例 | -| ---------------------- | ---- | ---------- | ----------------------------------- | -| `mode` | str | 志愿模式 | "院校专业组" / "专业+学校" / "传统" | -| `max_volunteers` | int | 最大志愿数 | 45 | -| `max_majors_per_group` | int | 每组专业数 | 6 | -| `has_adjustment` | bool | 是否有调剂 | True | -| `adjustment_scope` | str | 调剂范围 | "组内专业" / "全部专业" / "无" | -| `retrieval_rule` | str | 检索规则 | "分数优先、遵循志愿、一次投档" | -| `collection_count` | int | 征集次数 | 2 | -| `subject_mode` | str | 选科模式 | "3+1+2" / "3+3" / "传统" | -| `official_url` | str | 官方网址 | "http://jyt.hunan.gov.cn/" | -| `exam_subject_total` | int | 总分 | 750 | - ---- - -## 🔍 省份检测 API - -### `detect_province(text)` - -从文本中自动检测省份。 - -```python -from spec_checker_v2 import detect_province - -province = detect_province("我是湖南考生...") -print(province) # "湖南" - -province = detect_province("浙江省,620分") -print(province) # "浙江" -``` - -**支持检测形式**: - -- 全称:"湖南"、"浙江省" -- 简称:"湘"、"浙" - ---- - -## 🔤 错误模式 API - -### 错误分类 - -#### 🔴 致命错误 - -```python +```json { - "rule": "错误名称(省份)", - "description": "问题描述", - "fix": "修正建议" + "status": "ok" } ``` -**检测函数**: `_check_volunteer_unit()` +--- -#### 🟡 严重错误 +### 2.2 认证 API -```python +#### `POST /api/auth/login` + +用途:管理员登录,获取 Bearer JWT。 + +请求体: + +```json { - "rule": "错误名称", - "description": "问题描述", - "fix": "修正建议" + "username": "admin", + "password": "admin123" } ``` -**检测函数**: `_check_data_accuracy()` +成功响应: -#### 🟢 一般警告 - -```python +```json { - "rule": "警告名称", - "description": "建议描述", - "fix": "补充建议" + "access_token": "", + "token_type": "bearer", + "expires_in": 3600, + "user": { + "id": "...", + "username": "admin", + "role": "admin" + } } ``` -**检测函数**: `_check_risk_disclosure()` +使用方式: ---- +```bash +curl -X POST http://127.0.0.1:8000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin123"}' +``` -## 📝 报告生成 API +#### `GET /api/auth/me` -### 检查报告格式 +用途:获取当前登录管理员信息。 -检查器返回的Markdown格式报告: +请求头: -```markdown -╔══════════════════════════════════════════════════════════════════╗ -║ ✅ 志愿方案规范检查报告 ║ -╠══════════════════════════════════════════════════════════════════╣ -║ 检测省份:湖南 ║ -║ 志愿模式:院校专业组 ║ -║ ... -╚══════════════════════════════════════════════════════════════════╝ - -🔴 【致命错误】 -└────────────────────────────────────────────────────────────────────── - -1. 错误名称 - ❌ 问题:... - ✅ 修正:... - -🟡 【严重错误】 -└────────────────────────────────────────────────────────────────────── - -1. 错误名称 - ⚠️ 问题:... - 🔧 修正:... - -🟢 【一般警告】 -└────────────────────────────────────────────────────────────────────── - -1. 警告名称 - 💡 建议:... - 📌 做法:... +```text +Authorization: Bearer ``` --- -## 🧪 测试 API +### 2.3 元数据 API -### `test_hunan_bad_plan()` +#### `GET /api/meta` -测试湖南错误版方案检测能力。 +用途:返回后台前端使用的枚举/元信息。 -**预期**: 检测出1个致命错误("45个学校") +典型用途: -### `test_hunan_good_plan()` - -测试湖南修正版方案合规性。 - -**预期**: 通过基础检查 - -### `test_zhejiang_wrong_mode()` - -测试浙江模式错误检测。 - -**预期**: 检测出E005模式错误 +- 订单状态枚举 +- 来源渠道枚举 +- 服务版本枚举 +- 案例审核状态等 --- -## 🔧 扩展开发 +### 2.4 用户 API(T6.3) -### 添加新省份 +#### `GET /api/users` -```python -# 在 PROVINCE_RULES 中添加 -PROVINCE_RULES["新省份"] = { - "mode": "院校专业组", # 或 "专业+学校" / "传统" - "batch": "本科批", # 或 "本科一批" / "普通批" - "max_volunteers": 45, - "max_majors_per_group": 6, - "has_adjustment": True, - "adjustment_scope": "组内专业", - "retrieval_rule": "分数优先、遵循志愿、一次投档", - "collection_count": 2, - "subject_mode": "3+1+2", # 或 "3+3" / "传统" - "official_url": "http://...", # 官方网址 - "exam_subject_total": 750, +用途:分页列出用户,默认返回脱敏字段。 + +常见查询参数: + +- `limit` +- `offset` +- 其他筛选项以 OpenAPI 为准 + +#### `GET /api/users/{user_key}` + +用途:查看单个用户详情。 + +说明:`user_key` 可为内部 id 或路由支持的用户标识。 + +--- + +### 2.5 订单 API(T6.4) + +#### `GET /api/orders` + +用途:分页列出订单。 + +常见查询参数: + +- `status` +- `source` +- `limit` +- `offset` + +说明: + +- 列表默认走脱敏输出 +- 不应依赖该接口获取完整明文手机号/身份证号 + +#### `GET /api/orders/export` + +用途:导出订单 CSV。 + +说明: + +- 当前导出路径复用后台查询条件 +- 输出仍遵循后台脱敏边界 +- 如需对外发送报表,建议二次审核 CSV 内容 + +#### `GET /api/orders/{order_id}` + +用途:查看订单详情与状态历史。 + +#### `POST /api/orders` + +用途:手工录单。 + +典型字段: + +```json +{ + "source": "xianyu", + "service_version": "audit", + "amount_cents": 4900, + "customer_name": "王家长", + "customer_phone": "13800001234", + "candidate_name": "李明", + "candidate_province": "湖南", + "candidate_score": 578, + "candidate_rank": 26800 } ``` -### 添加新错误检测 +说明: -```python -# 在 GaokaoSpecCheckerV2 类中添加方法 +- `external_id` 可留空,支持人工补录 +- 写路径复用 `OrdersDAO` 状态机与落库逻辑 -def _check_new_error(self, text): - """检查新错误类型""" - error_pattern = r'错误正则' - if re.search(error_pattern, text): - self.errors["fatal"].append({ - "rule": "新错误名称", - "description": "问题描述", - "fix": "修正建议" - }) +#### `PATCH /api/orders/{order_id}` + +用途:更新订单业务字段、推进状态、处理退款。 + +说明: + +- 非法状态流转会被拒绝 +- 订单状态机为:`pending -> paid -> serving -> delivered -> completed`,另有 `refunded` +- 文档应以 OpenAPI 和 `data/orders/state_machine.py` 为最终事实源 + +--- + +### 2.6 案例 API(T6.5) + +#### `GET /api/cases` + +用途:案例列表。 + +#### `POST /api/cases` + +用途:创建案例。 + +#### `GET /api/cases/{case_id}` + +用途:查看案例详情。 + +#### `PATCH /api/cases/{case_id}` + +用途:更新案例。 + +#### `POST /api/cases/{case_id}/review` + +用途:案例审核。 + +#### `DELETE /api/cases/{case_id}` + +用途:删除案例。 + +--- + +### 2.7 统计 API(T6.2) + +#### `GET /api/stats/dashboard` + +用途:一站式仪表盘数据。 + +响应结构: + +- `summary` +- `by_status` +- `by_source` +- `by_service_version` +- `trends` +- `generated_at` + +关键口径: + +- 收入 = `paid / serving / delivered / completed` 四态订单的 `amount_cents` 累计值 +- `pending`、`refunded` 不计入收入 +- 趋势桶粒度为天(UTC) +- 空日期补 0,前端得到稠密序列 +- 统计路径不读取 PII + +#### `GET /api/stats/orders` + +用途:兼容订单统计接口;字段名沿用早期 stub 版本,但已接真实 SQL 聚合。 + +--- + +### 2.8 UI 路由 + +#### `GET /dashboard` + +用途:极简后台仪表盘页面。 + +说明: + +- 页面本身可公开访问 +- 数据请求仍依赖登录态/JWT + +#### `GET /s/{code}` + +用途:分享页短链接访问入口。 + +说明: + +- 由 `data/share/short_link.py` 与 `admin/share_page.py` 提供能力 +- 权限控制支持 `read/comment/edit/admin` + +--- + +## 3. CLI 接口 + +### 3.1 AI 审核 CLI + +入口:`python3 -m skills.gaokao-audit.scripts.audit_cli` + +命令格式: + +```bash +python3 -m skills.gaokao-audit.scripts.audit_cli plan.txt \ + --output plan.audit.pdf \ + --format text \ + --json ``` +参数: + +- `input`:方案文件路径 +- `--output`:PDF 输出路径;默认 `.audit.pdf` +- `--format`:`text | pdf_text | screenshot_ocr` +- `--json`:额外输出审核结果 JSON + +标准输出包含: + +- 输入文件路径 +- 识别省份 +- 综合评分 +- PDF 报告路径 +- 可选 JSON payload + +主链能力: + +- 方案解析 +- 规范检查集成 +- 扎堆风险检测 +- HTML/PDF 报告输出 + --- -## 📦 导入路径 +### 3.2 订单管理 CLI -### 从项目根目录 +入口:`python3 scripts/gaokao-order-manager` -```python -import sys -sys.path.insert(0, '/home/long/project/gaokao-volunteer-system/skills/gaokao-spec-checker/scripts') +通用参数: -from spec_checker_v2 import GaokaoSpecCheckerV2, PROVINCE_RULES, detect_province +- `--db `:订单库路径,默认 `data/orders.db` +- `--human`:终端友好文本输出;默认 JSON +- `--actor`:操作者标识 + +已落地子命令: + +- `create` +- `list` +- `show` +- `update` +- `pay` +- `deliver` +- `upgrade` +- `stats` +- `export` + +示例: + +```bash +python3 scripts/gaokao-order-manager --db data/orders.db create \ + --source xianyu \ + --service-version audit \ + --amount-cents 4900 \ + --customer-name 王家长 \ + --customer-phone 13800001234 \ + --candidate-name 李明 \ + --candidate-province 湖南 \ + --candidate-score 578 ``` -### 从Skill目录 - -```python -# 相对路径(在Skill内使用) -from scripts.spec_checker_v2 import GaokaoSpecCheckerV2 +```bash +python3 scripts/gaokao-order-manager --db data/orders.db pay +python3 scripts/gaokao-order-manager --db data/orders.db deliver +python3 scripts/gaokao-order-manager --db data/orders.db export --output orders.csv ``` ---- +行为约束: -## 🎨 输出格式 - -### 返回类型 - -所有API返回标准的Python数据类型: - -- `str`: Markdown格式的检查报告 -- `dict`: 省份规则配置 -- `list`: 错误列表 -- `bool`: 检测结果 +- 详情默认返回状态历史 +- `update` 仅允许业务字段,`status` 不允许直接硬改 +- 状态流转必须走状态机 +- 默认输出经过 `Order.to_dict()` 脱敏处理 --- -## 📞 更多信息 +### 3.3 数据溯源 CLI -- 完整实现: [skills/gaokao-spec-checker/scripts/spec_checker_v2.py](skills/gaokao-spec-checker/scripts/spec_checker_v2.py) -- 规则文档: [rules/provinces.md](rules/provinces.md) -- 错误模式: [rules/errors/ERRORS.md](rules/errors/ERRORS.md) +入口:`python3 scripts/gaokao-data-trace` + +命令格式: + +```bash +python3 scripts/gaokao-data-trace "中南大学" +python3 scripts/gaokao-data-trace "中南大学" --human +``` + +参数: + +- `school_name`:院校名称,支持包含匹配 +- `--human`:终端友好输出;默认 JSON + +输出字段: + +- `province` +- `school` +- `major` +- `frequency` +- `platforms` +- `predicted_increase` +- `alternatives` +- `score_range` +- `data_year` +- `source` +- `source_url` +- `source_type` +- `confidence` +- `last_updated` + +用途: + +- 查询某院校/专业推荐的来源信息 +- 向审核报告补充“为什么这样推荐”的数据依据 --- -**版本**: v2.0 -**最后更新**: 2026-06-11 +## 4. 关键数据契约 + +### 4.1 订单状态机 + +当前文档级约定: + +- `pending` +- `paid` +- `serving` +- `delivered` +- `completed` +- `refunded` + +注意: + +- 直接写状态属于越界使用 +- HTTP 与 CLI 都应复用 `OrdersDAO.transition_status()` + +### 4.2 订单隐私边界 + +默认脱敏字段包括但不限于: + +- 手机号 +- 身份证号 +- 部分姓名 + +明文敏感数据不应通过普通列表接口、CSV 报表或分享页直接暴露。 + +### 4.3 统计口径 + +仪表盘与统计接口共享以下前提: + +- `GAOKAO_DB_PATH`:admin 用户库 +- `GAOKAO_ORDERS_DB_PATH`:订单库 +- 收入统计只看订单金额和状态,不读取 PII + +--- + +## 5. 环境变量 + +常用运行变量: + +- `GAOKAO_ENV` +- `GAOKAO_DB_PATH` +- `GAOKAO_ORDERS_DB_PATH` +- `GAOKAO_JWT_SECRET` +- `GAOKAO_ADMIN_USER` +- `GAOKAO_ADMIN_PASS` +- `GAOKAO_ORDERS_FERNET_KEY` + +最低要求: + +- 生产环境必须提供高熵 `GAOKAO_JWT_SECRET` +- 订单/用户相关路径需要 `GAOKAO_ORDERS_FERNET_KEY` +- 生产环境不得使用默认弱口令 `admin123` + +--- + +## 6. 验证建议 + +查看最新 API 真相: + +```bash +python3 -m admin.app --port 8000 +curl http://127.0.0.1:8000/openapi.json +``` + +验证 T5 主链测试: + +```bash +python3 -m pytest tests/test_t5_e2e_workflows.py tests/test_t5_performance.py -q +``` + +如本文与代码不一致,优先级顺序为: + +1. 代码与实跑结果 +2. OpenAPI / CLI `--help` +3. 本文档 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 387d66b..756ddd2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,180 +1,396 @@ -# 架构设计 +# 架构设计(当前实现视角) -## 🏛️ 整体架构 +本文档描述 2026-06-13 当前仓库中已经落地、可验证的系统架构。 -``` -┌─────────────────────────────────────────────────────────────┐ -│ 用户交互层 │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ Hermes对话 │ │ 命令行 │ │ -│ └────────┬────────┘ └────────┬────────┘ │ -└───────────┼─────────────────────┼─────────────────────────────┘ - │ │ -┌───────────▼─────────────────────▼─────────────────────────────┐ -│ 业务逻辑层 │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ 3个Hermes Skills │ │ 独立Python脚本 │ │ -│ │ - advisor │ │ - visual-report │ │ -│ │ - spec-checker │ │ - quick-3min │ │ -│ │ - zhangxuefeng │ │ - collect-info │ │ -│ └────────┬─────────┘ └────────┬─────────┘ │ -└───────────┼─────────────────────┼─────────────────────────────┘ - │ │ -┌───────────▼─────────────────────▼─────────────────────────────┐ -│ 规则与数据层 │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ provinces/ │ │ errors/ │ │ templates/ │ │ -│ │ 省份规则 │ │ 错误模式 │ │ 报告模板 │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -└───────────────────────────────────────────────────────────────┘ - │ -┌───────────▼─────────────────────────────────────────────────────┐ -│ 持久化层 │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ docs/ │ │ data/ │ │ CHANGELOG.md │ │ -│ │ 文档案例 │ │ 示例数据 │ │ 变更历史 │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` +一句话定位: -## 🎯 设计原则 - -### 1. 关注点分离(Separation of Concerns) - -- **Skills**:负责对话式交互 -- **脚本**:负责批处理和数据处理 -- **规则**:负责业务规则集中管理 -- **数据**:负责持久化 - -### 2. 单点真相(Single Source of Truth) - -- 规则统一在 `rules/` -- 文档统一在 `docs/` -- 示例统一在 `data/` - -### 3. 可扩展性 - -- 省份可扩展(添加新省份只需修改 `PROVINCE_RULES`) -- 错误模式可扩展(添加新错误到 `ERRORS.md`) -- 案例可扩展(添加新案例到 `case-studies/`) - -### 4. 渐进增强 - -- 基础对话能力 -- 增强:可视化报告 -- 高级:自动检查 -- 专家:数据驱动决策 - -## 🔄 数据流 - -### 场景A:对话生成方案 - -``` -用户输入 → Skill加载 → 收集信息 - ↓ -霍兰德评估 → 个性化匹配 → 院校推荐 - ↓ -生成方案 → 输出 - ↓ -(可选)调用 spec-checker 检查 -``` - -### 场景B:检查现有方案 - -``` -用户方案 → spec-checker加载 - ↓ -省份识别 → 加载对应规则 - ↓ -规则匹配 → 发现问题 - ↓ -分类输出 → 修正建议 -``` - -### 场景C:生成可视化报告 - -``` -考生数据 → 模板渲染 - ↓ -HTML生成 → PDF转换 - ↓ -Markdown输出 -``` - -## 🔌 扩展点 - -### 添加新省份 - -```python -# 在 spec_checker_v2.py 中 -PROVINCE_RULES["新省份"] = { - "mode": "院校专业组", - "max_volunteers": 45, - "max_majors_per_group": 6, - "has_adjustment": True, - "adjustment_scope": "组内专业", - "retrieval_rule": "...", - "collection_count": 2, - "subject_mode": "3+1+2", - "official_url": "...", - "exam_subject_total": 750, -} -``` - -### 添加新错误模式 - -```markdown -### E###:<错误名称> - -- **症状**:<方案表现> -- **正确**:<应该是> -- **场景**:<何时出现> -- **修复**:<如何修正> -- **首次发现**:<日期> -``` - -### 添加新Skill - -``` -skills/ -├── my-new-skill/ -│ ├── SKILL.md -│ ├── references/ -│ ├── templates/ -│ └── scripts/ -``` - -## 📊 技术栈 - -| 层 | 技术 | -| ----- | --------------------------- | -| Skill | Markdown + YAML frontmatter | -| 脚本 | Python 3.10+ | -| 数据 | JSON / YAML / Markdown | -| 版本 | Git | -| 文档 | Markdown | - -## 🎓 学习路径 - -### 新手(1天) - -1. 阅读 README.md -2. 了解3个Skill的作用 -3. 体验1次完整对话流程 - -### 进阶(3天) - -1. 熟悉 rules/ 目录结构 -2. 学习 spec-checker 的工作原理 -3. 修改一份现有方案 - -### 专家(1周) - -1. 添加一个新省份规则 -2. 添加一种新错误模式 -3. 优化一个Skill的逻辑 +- 这不是“完整用户端 Web 自助闭环产品” +- 这是一个“管理后台 + 订单/分享/渠道同步 + AI 审核链路”的可运行系统 --- -**版本**:v2.0 -**最后更新**:2026-06-11 +## 1. 架构总览 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ 接入与交互层 │ +│ Hermes Skills CLI Scripts Admin FastAPI Share Page │ +└───────────────┬───────────────┬───────────────┬──────────────┘ + │ │ │ +┌───────────────▼──────────────────────────────────────────────┐ +│ 应用服务层 │ +│ AI审核编排 订单服务/状态机 分享权限 渠道同步 统计聚合 │ +└───────────────┬──────────────────────────────────────────────┘ + │ +┌───────────────▼──────────────────────────────────────────────┐ +│ 数据与持久化层 │ +│ SQLite(admin/orders/cases/share) + JSON crowd_db + 模板/静态 │ +└──────────────────────────────────────────────────────────────┘ +``` + +核心结论: + +- 管理后台与人工服务交付链已经形成主链 +- AI 审核从解析 → 规则检查 → 扎堆检测 → 报告输出已经形成统一入口 +- 分享、渠道同步、订单状态机都已有独立模块与测试 +- 用户端 Web 自助注册/下单/支付/资料填写/站内交付主链仍缺失 + +--- + +## 2. 分层设计 + +### 2.1 接入与交互层 + +当前存在 4 类入口: + +1. Hermes Skills + +- `skills/gaokao-college-advisor/` +- `skills/gaokao-spec-checker/` +- `skills/gaokao-audit/` + +2. CLI + +- `scripts/gaokao-quick-3min.py` +- `scripts/gaokao-order-manager` +- `scripts/gaokao-data-trace` +- `scripts/gaokao-channel-fallback` + +3. 管理后台 HTTP API + +- `admin/app.py` +- `admin/routes/*` + +4. 极简页面 + +- `admin/static/dashboard.html` +- `GET /dashboard` +- `GET /s/{code}` 分享页 + +设计意图: + +- 人工顾问/运营同学优先走后台与 CLI +- Hermes 对话场景优先走 Skills +- 对外公开访问当前仅限分享页,不等同于完整用户前台 + +--- + +### 2.2 应用服务层 + +#### A. AI 审核链路 + +主要模块: + +- `skills/gaokao-audit/scripts/plan_parser.py` +- `skills/gaokao-audit/scripts/checker_integration.py` +- `skills/gaokao-audit/scripts/audit_service.py` +- `skills/gaokao-audit/scripts/report_generator.py` +- `skills/gaokao-audit/scripts/audit_cli.py` +- `data/crowd_db/crowd_detector.py` +- `data/crowd_db/risk_report.py` + +处理链路: + +```text +方案文本/PDF提取/OCR + → 方案解析 + → 省份规则检查 + → 扎堆风险检测 + → 风险/问题聚合 + → HTML/PDF/JSON 报告输出 +``` + +说明: + +- 当前 AI 审核主链已经不是“只有零散脚本” +- `audit_cli.py` 是统一编排入口 +- T5.1 已用端到端测试覆盖“输入方案 → 输出 PDF/JSON 审核报告”主链 + +#### B. 订单与交付链路 + +主要模块: + +- `data/orders/models.py` +- `data/orders/dao.py` +- `data/orders/state_machine.py` +- `data/orders/cli.py` +- `admin/routes/orders.py` + +职责拆分: + +- `models.py`:订单数据模型 +- `dao.py`:SQLite 持久化、查询、事务、历史记录 +- `state_machine.py`:状态合法性约束 +- `cli.py` / `routes/orders.py`:两种入口,共用同一底层规则 + +状态机: + +```text +pending → paid → serving → delivered → completed + └──────────────→ refunded +``` + +关键原则: + +- 不允许绕过状态机直接改状态 +- HTTP 与 CLI 写路径都应落到 DAO + 状态机 +- 敏感字段默认脱敏展示 + +#### C. 分享能力 + +主要模块: + +- `data/share/short_link.py` +- `data/share/permission.py` +- `admin/share_page.py` +- `admin/routes/ui.py` + +能力: + +- 短链接生成与解析 +- `read/comment/edit/admin` 权限模型 +- 报告字段裁剪与脱敏 +- 公开分享页渲染 + +设计定位: + +- 服务于“人工交付后的分享/查看” +- 不是完整协作式前台产品 + +#### D. 渠道同步 + +主要模块: + +- `data/channel_sync/webhook_server.py` +- `data/channel_sync/xianyu_adapter.py` +- `data/channel_sync/poller.py` +- `data/channel_sync/audit.py` +- `data/channel_sync/monitor.py` + +能力: + +- 闲鱼 webhook 接单 +- poller 兜底补偿 +- webhook 审计日志 +- 渠道健康巡检与人工 fallback 模板 + +设计原则: + +- 自动接单优先 +- 审计留痕 +- 异常时可回落到人工补录 + +#### E. 后台统计与运营视图 + +主要模块: + +- `admin/stats.py` +- `admin/routes/stats.py` +- `admin/routes/users.py` +- `admin/routes/cases.py` +- `admin/routes/meta.py` + +能力: + +- 仪表盘汇总 +- 订单/来源/服务版本分布 +- 时间趋势 +- 用户列表与案例管理 +- 前端枚举元数据输出 + +--- + +### 2.3 数据与持久化层 + +#### SQLite + +1. 管理后台库 + +- 默认:`data/orders/admin.db` +- 内容:管理员、后台业务表、cases 等 + +2. 订单库 + +- 默认:`data/orders.db` +- 内容:订单主表、状态历史、渠道同步相关辅助表 + +3. 分享/短链数据 + +- 由 `data/share/short_link.py` 管理,实际落在 SQLite + +#### JSON / Markdown 数据 + +1. crowd_db + +- 路径:`data/crowd_db/` +- 内容:院校推荐、来源、置信度、年份等 + +2. 规则与案例文档 + +- 路径:`rules/`、`docs/`、`product/` + +#### 模板与静态资源 + +- 审核报告模板:`skills/gaokao-audit/templates/audit_report.html` +- 分享页/后台静态资源:`admin/static/*` + +--- + +## 3. 核心运行链路 + +### 3.1 人工服务主链(当前已落地) + +```text +渠道线索/人工录单 + → 订单入库 + → 顾问生成方案 + → AI审核 + → PDF/JSON/方案文件归档 + → 订单推进到 delivered/completed + → 可选分享页分发 +``` + +这是当前仓库最成熟、最贴近真实运行的主链。 + +### 3.2 后台运营主链(当前已落地) + +```text +管理员登录 + → 查看仪表盘/用户/订单/案例 + → 人工录单或补录 + → 状态流转/退款/导出 + → 跟踪渠道与交付情况 +``` + +### 3.3 数据溯源查询链(当前已落地) + +```text +输入院校名 + → crowd_db 加载各省数据 + → 匹配学校/专业 + → 返回来源、年份、置信度、分数段、替代建议 +``` + +### 3.4 性能验证链(T5.2 已落地) + +```text +quick-3min 解析+总结+推荐 + → 100 次基准执行 + → 断言总耗时 < 5s + +admin FastAPI + → Locust 10 并发压测 + → 断言聚合成功率 > 95% +``` + +--- + +## 4. 当前未落地主链 + +以下能力仍不应被本文档误描述为“已完成”: + +1. 用户端 Web 前台 + +- 用户注册/登录 +- 用户自主下单/支付 +- 站内资料填写 +- 用户自助查看报告/订单进度 + +2. 完整产品化闭环 + +- 面向最终考生/家长的独立前端应用 +- 完整邮件/站内消息交付系统 +- 自助支付后的自动任务编排闭环 + +因此,当前系统真实定位应是: + +- 内部运营与人工服务增强系统:已成形 +- 面向最终用户的完整产品:未完成 + +--- + +## 5. 安全与边界 + +### 5.1 已有防护 + +- JWT 鉴权 +- 生产环境 JWT 弱密钥阻断 +- 管理员弱口令在 prod 阻断 +- 订单敏感字段加密存储 +- 默认脱敏输出 +- 分享页权限分级 + +### 5.2 已知风险(截至当前评审) + +- 渠道 webhook 的 `X-Forwarded-For` 信任边界仍需收紧 +- 登录接口节流/锁定能力不足 +- CI 不能完整代表 clean env 下的后台可构建性 +- 类型门禁与覆盖率门禁仍未完全闭环 + +这些属于“工程质量与安全债”,不改变主链已存在的事实,但影响生产可交付性评级。 + +--- + +## 6. 部署形态 + +### 6.1 本地运行 + +```bash +pip install -r requirements-admin.txt -r requirements-dev.txt +export GAOKAO_JWT_SECRET="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" +python3 -m admin.app --port 8000 +``` + +### 6.2 Docker Compose + +已提供: + +- `Dockerfile` +- `docker-compose.yml` +- `.env.docker.example` + +适用场景: + +- 本机自测 +- 单机部署验证 + +不应误解为: + +- 已具备完整云原生、多副本、自动扩缩容架构 + +--- + +## 7. 真相源优先级 + +当文档与实现冲突时,按以下顺序判断: + +1. 代码与实跑结果 +2. OpenAPI / CLI `--help` +3. 测试文件(尤其 `tests/test_t5_e2e_workflows.py`、`tests/test_t5_performance.py`) +4. README / CHANGELOG +5. 本文档 +6. 历史评审/历史计划文档 + +--- + +## 8. 验证命令 + +### 8.1 启动后台 + +```bash +python3 -m admin.app --port 8000 +curl http://127.0.0.1:8000/health +curl http://127.0.0.1:8000/openapi.json +``` + +### 8.2 复核 T5 主链 + +```bash +python3 -m pytest tests/test_t5_e2e_workflows.py tests/test_t5_performance.py -q +``` + +### 8.3 复核后台接口存在性 + +```bash +python3 -m pytest admin/tests/test_routes.py admin/tests/test_routes_orders.py admin/tests/test_routes_stats_dashboard.py -q +``` diff --git a/docs/AUDIT_REPORT_2026-06-11.md b/docs/AUDIT_REPORT_2026-06-11.md index 7e925d1..e0705cf 100644 --- a/docs/AUDIT_REPORT_2026-06-11.md +++ b/docs/AUDIT_REPORT_2026-06-11.md @@ -1,4 +1,6 @@ -# 产品规划技术设计审核报告 +# 产品规划技术设计审核报告(历史快照) + +> 该文档是 2026-06-11 的历史快照,不再代表当前真实状态。当前真相源请以 `reports/PROJECT_SYSTEM_REVIEW_2026-06-13.md` 与 `docs/REMEDIATION_TASK_BOARD_2026-06-13.md` 为准。 **审核对象**: 高考志愿填报系统 v2.1 产品规划/技术设计/实施计划 **审核日期**: 2026-06-11 diff --git a/docs/CHANNEL_INTEGRATION.md b/docs/CHANNEL_INTEGRATION.md index a5732b4..671d4f0 100644 --- a/docs/CHANNEL_INTEGRATION.md +++ b/docs/CHANNEL_INTEGRATION.md @@ -212,6 +212,7 @@ CREATE INDEX IF NOT EXISTS idx_webhook_audit_event - 单 IP 60 req/min(标准库实现,避免引入 redis) - 超出返回 429 + Retry-After +- 来源 IP 默认取 socket `client_address`;仅在显式设置 `GAOKAO_TRUST_X_FORWARDED_FOR=true` 时才读取 `X-Forwarded-For` ### 5.4 数据脱敏 @@ -237,28 +238,39 @@ GAOKAO_ORDERS_FERNET_KEY=<...> \ python -m data.channel_sync.webhook_server --port 8080 ``` -### 6.2 启动兜底轮询(独立进程) +### 6.2 定期巡检 + 人工兜底 ```bash -# 每 5 分钟拉一次 -python -m data.channel_sync.poller --interval 300 +# 值班巡检(建议每 5~15 分钟一次,非 0 exit code 接告警) +python3 scripts/gaokao-channel-fallback --db data/orders.db check --source xianyu --human + +# 打印人工兜底模板 +python3 scripts/gaokao-channel-fallback --db data/orders.db manual-template --source xianyu --human + +# 渠道不可用时先人工补录,后续再 pay / deliver +python3 scripts/gaokao-order-manager --db data/orders.db create \ + --source xianyu --service-version basic --amount-cents 0 \ + --customer-name 张三 --customer-phone 13800001234 ``` +详见 `docs/T8-4-fallback-sop.md`。注意:当前巡检健康度判定仅对已落 `webhook_audit` / `poller_*` 事实源的 `xianyu` 链路成立;其他 source 暂时只复用人工补录模板。 + ### 6.3 关闭 Webhook 时的兜底 - Webhook 不可用(闲鱼平台维护)时,poller 仍运行 - 平台恢复后,poller 与 Webhook 数据自动按 external_id 幂等合并 +- 若 poller 也不可用,按 `docs/T8-4-fallback-sop.md` 走人工补录 CLI --- ## 7. T8 任务依赖与拆分 -| ID | 任务 | 依赖 | 落地文件 | 状态 | -| ---- | --------------- | ---- | ------------------------------------------------------------- | ----------- | -| T8.1 | 闲鱼Webhook集成 | T4.1 | 本设计 + channel_sync/\* + tests/test_xianyu_channel.py | 已实现并本地验证 | -| T8.2 | 微信SDK集成 | T8.1 | channel_sync/wechat_adapter.py + tests/test_wechat_adapter.py | 已实现并本地验证 | -| T8.3 | 企业微信集成 | T8.2 | channel_sync/wecom_adapter.py + tests/test_wecom_adapter.py | 已实现并本地验证 | -| T8.4 | 失败兜底(手动) | T6 | T6 管理后台"新建订单"表单 + poller 自动补偿 | T6 实施时补 | +| ID | 任务 | 依赖 | 落地文件 | 状态 | +| ---- | --------------- | ---- | ---------------------------------------------------------------------------- | ---------------- | +| T8.1 | 闲鱼Webhook集成 | T4.1 | 本设计 + channel_sync/\* + tests/test_xianyu_channel.py | 已实现并本地验证 | +| T8.2 | 微信SDK集成 | T8.1 | channel_sync/wechat_adapter.py + tests/test_wechat_adapter.py | 已实现并本地验证 | +| T8.3 | 企业微信集成 | T8.2 | channel_sync/wecom_adapter.py + tests/test_wecom_adapter.py | 已实现并本地验证 | +| T8.4 | 失败兜底(手动) | T6 | `scripts/gaokao-channel-fallback` 巡检 + `gaokao-order-manager` 人工补录 SOP | 已落地 | T8.1 不阻塞 T6/T7;但 T6 必须有"手动新建"入口作为兜底。 diff --git a/docs/IMPLEMENTATION_PLAN_v2.md b/docs/IMPLEMENTATION_PLAN_v2.md index 8a0bc44..bf975d9 100644 --- a/docs/IMPLEMENTATION_PLAN_v2.md +++ b/docs/IMPLEMENTATION_PLAN_v2.md @@ -5,11 +5,14 @@ **版本**: v2.0 **基础版本**: v1.0 **修订日期**: 2026-06-11 +**当前校准**: 2026-06-13(以 `reports/PROJECT_SYSTEM_REVIEW_2026-06-13.md` 为当前真相源) **修订原因**: 审核发现14项差距,补齐4项P0后启动 **目标**: 实现AI审核、反扎堆检测、数据溯源、订单管理 **时间**: 2026年6月15日 - 7月25日(40天) **方法**: TDD + 频繁提交 + 覆盖率门槛 +> 当前说明:T1-T4、T6-T10 已有较多实现落地,但文档状态滞后;用户端 Web 自助闭环不在本计划已落地产物内。 + --- ## 0. 变更日志 (Change Log) @@ -40,19 +43,19 @@ ### 11个核心开发任务 -| 任务 | 工时 | 优先级 | 状态 | -| ------------------------------ | :--: | :----: | :---: | -| T1: AI审核服务(49元版)⭐核心 | 10天 | P0 | 📋 | -| T2: 反扎堆检测功能 | 5天 | P0 | 📋 | -| T3: 数据溯源功能 | 5天 | P1 | 📋 | -| T4: 订单管理基础 | 5天 | P1 | 📋 | -| T5: 集成测试与发布 | 5天 | P0 | 📋 | -| T6: 管理后台MVP | 7天 | P1 | 📋 🆕 | -| T7: 分享功能MVP | 5天 | P1 | 📋 🆕 | -| T8: 渠道SDK集成 | 3天 | P2 | 📋 🆕 | -| T9: 错误处理体系 | 2天 | P1 | 📋 🆕 | -| T10: CI/CD基础 | 2天 | P1 | 📋 🆕 | -| T11: 性能与安全加固 | 3天 | P2 | 📋 🆕 | +| 任务 | 工时 | 优先级 | 状态 | +| ------------------------------ | :--: | :----: | :-------: | +| T1: AI审核服务(49元版)⭐核心 | 10天 | P0 | ✅ 已完成 | +| T2: 反扎堆检测功能 | 5天 | P0 | ✅ 已完成 | +| T3: 数据溯源功能 | 5天 | P1 | ✅ 已完成 | +| T4: 订单管理基础 | 5天 | P1 | ✅ 已完成 | +| T5: 集成测试与发布 | 5天 | P0 | 进行中 | +| T6: 管理后台MVP | 7天 | P1 | ✅ 已完成 | +| T7: 分享功能MVP | 5天 | P1 | ✅ 已完成 | +| T8: 渠道SDK集成 | 3天 | P2 | ✅ 已完成 | +| T9: 错误处理体系 | 2天 | P1 | ✅ 已完成 | +| T10: CI/CD基础 | 2天 | P1 | 进行中 | +| T11: 性能与安全加固 | 3天 | P2 | 进行中 | **总工时**: 52天(折合40个工作日) @@ -180,12 +183,12 @@ ### 8.1 任务清单 -| ID | 任务 | 工时 | DoD | 状态 | -| ---- | ---------------- | :--: | ------------- | ---------------------------------------------------------------------------- | -| T8.1 | 闲鱼Webhook集成 | 1d | 订单自动同步 | ✅ 已实现并本地验证(Webhook 接单 + poller 兜底 + 审计 + 测试/覆盖率) | +| ID | 任务 | 工时 | DoD | 状态 | +| ---- | ---------------- | :--: | ------------- | -------------------------------------------------------------------------------------- | +| T8.1 | 闲鱼Webhook集成 | 1d | 订单自动同步 | ✅ 已实现并本地验证(Webhook 接单 + poller 兜底 + 审计 + 测试/覆盖率) | | T8.2 | 微信SDK集成 | 1d | 消息推送+客服 | ✅ 已实现并本地验证(access_token 缓存 + 订阅消息/客服消息 + 标准库 transport + 测试) | -| T8.3 | 企业微信集成 | 0.5d | 通知机器人 | ✅ 已实现并本地验证(机器人 webhook + 应用消息 + access_token 缓存 + 测试) | -| T8.4 | 失败兜底(手动) | 0.5d | 定期检查+人工 | 待开始 | +| T8.3 | 企业微信集成 | 0.5d | 通知机器人 | ✅ 已实现并本地验证(机器人 webhook + 应用消息 + access_token 缓存 + 测试) | +| T8.4 | 失败兜底(手动) | 0.5d | 定期检查+人工 | 待开始 | ### 8.2 实施策略 diff --git a/docs/REMEDIATION_TASK_BOARD_2026-06-11.md b/docs/REMEDIATION_TASK_BOARD_2026-06-11.md index 735c927..10941e8 100644 --- a/docs/REMEDIATION_TASK_BOARD_2026-06-11.md +++ b/docs/REMEDIATION_TASK_BOARD_2026-06-11.md @@ -1,4 +1,6 @@ -# 实施缺陷修复任务板 +# 实施缺陷修复任务板(历史快照) + +> 该文档是 2026-06-11 的历史快照,不再代表当前执行优先级。当前整改板请以 `docs/REMEDIATION_TASK_BOARD_2026-06-13.md` 为准。 ## v2.1 实施计划与产品规划对齐修复 diff --git a/docs/REMEDIATION_TASK_BOARD_2026-06-13.md b/docs/REMEDIATION_TASK_BOARD_2026-06-13.md new file mode 100644 index 0000000..0141f34 --- /dev/null +++ b/docs/REMEDIATION_TASK_BOARD_2026-06-13.md @@ -0,0 +1,375 @@ +# 系统性评审整改任务板 + +**日期**: 2026-06-13 +**对应报告**: `reports/PROJECT_SYSTEM_REVIEW_2026-06-13.md` +**当前 Gate**: 🔴 **REQUEST_CHANGES** +**适用范围**: `gaokao-volunteer-system` 当前 working tree 与后续交付基线 + +--- + +## 0. 当前门禁结论 + +| 维度 | 状态 | 说明 | +| ------------------ | ---------: | ------------------------------------------------------------------------------ | +| pytest 回归 | 🟢 PASS | `460 passed, 2 warnings` | +| ruff 静态检查 | 🟢 PASS | `ruff check . --exclude .worktrees` 通过 | +| mypy 类型检查 | 🟢 PASS | `python3 -m mypy .` 通过(122 source files;仅保留 annotation-unchecked note) | +| 安全门禁 | 🟡 PARTIAL | XFF 信任边界与 admin 默认弱口令/登录节流已修;仍待更广安全项 | +| 覆盖率门禁 | 🔴 FAIL | 总覆盖率约 61%,关键模块低于目标 | +| CI 可信度 | 🟢 PASS | clean env 已可安装 admin 依赖并跑通 `admin/tests/test_app.py` | +| 文档真相 | 🟢 PASS | README/PRD/ROADMAP/IMPLEMENTATION_PLAN_v2/旧报告已校准 | +| 场景A 人工服务闭环 | 🟢 PASS | 后台 + 订单 + 分享 + 渠道同步已成形 | +| 场景B Web 自助闭环 | 🔴 FAIL | 用户端下单/支付/填资料/交付主链缺失 | + +--- + +## 1. P0 — 必须先修(阻断项) + +### P0-1 文档真相统一 + +**当前状态**: ✅ 已完成(2026-06-13) + +**目标** + +- 建立新的当前真相源,停止旧报告继续误导后续实施。 + +**文件** + +- `README.md` +- `product/PRD.md` +- `product/ROADMAP.md` +- `docs/IMPLEMENTATION_PLAN_v2.md` +- `docs/AUDIT_REPORT_2026-06-11.md` +- `docs/REMEDIATION_TASK_BOARD_2026-06-11.md` +- `reports/PRODUCT_TECH_REVIEW_2026-06-12.md` + +**完成标准** + +- 明确三态:`已完成 / 进行中 / 规划中` +- 明确当前项目标签:运营后台 + 审核增强链路 / 非完整 Web 自助产品 +- 旧报告顶部加“历史快照”提示,并指向 2026-06-13 新报告 + +**验证** + +```bash +rg -n "历史快照|规划中|已完成|进行中|Web系统场景|完整产品" README.md product docs reports +``` + +--- + +### P0-2 修复 webhook 来源 IP 信任边界 + +**当前状态**: ✅ 已完成(2026-06-13) + +**目标** + +- `X-Forwarded-For` 只能在显式信任代理时生效;默认回退到 socket client_address。 + +**文件** + +- `data/channel_sync/webhook_server.py` +- `data/channel_sync/tests/test_xianyu_channel.py` +- `docs/CHANNEL_INTEGRATION.md` + +**完成标准** + +- `_client_ip()` 使用 `_trust_x_forwarded_for()` 决策 +- 默认不信任 XFF +- 测试覆盖: + - 默认 XFF 不生效 + - 显式开启时 XFF 生效 + - socket fallback 仍可用 + +**验证** + +```bash +python3 -m pytest -q data/channel_sync/tests/test_xianyu_channel.py +python3 -m ruff check data/channel_sync +``` + +--- + +### P0-3 管理后台弱口令与登录节流加固 + +**当前状态**: ✅ 已完成(2026-06-13,mypy 全仓问题仍属 P1) + +**目标** + +- 避免默认管理员弱口令成为可利用入口,并为登录增加基本抗爆破能力。 + +**文件** + +- `admin/config.py` +- `admin/db.py` +- `admin/routes/auth.py` +- `admin/tests/test_auth.py` +- `README.md` + +**完成标准** + +- 生产环境下禁止默认 `admin123` +- 最低密码复杂度或长度门槛明确 +- 登录失败次数节流 / 冷却机制落地 +- README 明确首次启动后的密码轮换要求 + +**验证** + +```bash +pytest -q admin/tests/test_auth.py +python3 -m mypy admin/config.py admin/routes/auth.py admin/db.py +``` + +--- + +### P0-4 修复 CI clean env 依赖不闭环 + +**当前状态**: ✅ 已完成(2026-06-13) + +**目标** + +- CI 在干净环境下可真实跑完整后端测试,不再依赖本地已有 admin 运行依赖。 + +**文件** + +- `.github/workflows/ci.yml` +- `requirements-dev.txt` +- `requirements-admin.txt` +- 如需:新增 `requirements-ci.txt` + +**完成标准** + +- CI 安装 admin 运行依赖 +- admin/tests 在 clean env 可导入执行 +- CI 不再只是假设 data/skills/scripts 可测 + +**验证** + +```bash +python3 -m venv /tmp/gvs-ci-check +/tmp/gvs-ci-check/bin/pip install -r requirements-dev.txt -r requirements-admin.txt +/tmp/gvs-ci-check/bin/pytest -q admin/tests/test_app.py +``` + +--- + +## 2. P1 — 应尽快修(高优先级质量项) + +### P1-1 让 mypy 成为可执行门禁 + +**当前状态**: ✅ 已完成(2026-06-13:全仓 `python3 -m mypy .` 已通过;核心修复覆盖 `skills/gaokao-audit/*`、`scripts/*`、`tests/test_all.py`) + +**目标** + +- 先收敛类型检查范围,再逐步修复核心生产代码错误。 + +**文件** + +- 新增:`mypy.ini` 或 `pyproject.toml` +- `data/share/short_link.py` +- `data/cases/dao.py` +- `skills/gaokao-audit/scripts/*.py` +- 必要时分阶段限制 tests/legacy 范围 + +**完成标准** + +- mypy 可稳定执行 +- 至少 `admin/`、`data/orders/`、`data/channel_sync/`、`data/share/` 核心运行时路径通过 +- tests / legacy 噪声与生产代码分开治理 + +**验证** + +```bash +python3 -m mypy admin data/orders data/channel_sync data/share +``` + +--- + +### P1-2 覆盖率门禁落地 + +**目标** + +- 从“产出 coverage.xml”升级为“coverage fail-under + 关键模块达标”。 + +**文件** + +- `.github/workflows/ci.yml` +- `codecov.yml`(如需要) +- 相关测试文件 + +**完成标准** + +- CI 采集 `admin` 覆盖率 +- 至少建立 `--cov-fail-under` 或分模块门槛 +- 优先提升低覆盖模块: + - `admin/routes/orders.py` + - `admin/routes/ui.py` + - `admin/users.py` + - `admin/share_page.py` + - `skills/gaokao-audit/scripts/report_generator.py` + - `skills/gaokao-audit/scripts/checker_integration.py` + +**验证** + +```bash +pytest --cov=admin --cov=data --cov=skills --cov=scripts --cov-report=term-missing -q +``` + +--- + +### P1-3 CSV 公式注入防护 + +**当前状态**: ✅ 已完成(2026-06-13:CSV 导出对 `= + - @` 前缀做 neutralize,并补回归测试) + +**目标** + +- 导出 CSV 前对危险前缀做 neutralize(如 `= + - @`)。 + +**文件** + +- `admin/routes/orders.py` +- `admin/tests/test_routes_orders.py` + +**完成标准** + +- 导出列中所有用户可控文本经过安全处理 +- 测试覆盖公式注入前缀样例 + +**验证** + +```bash +pytest -q admin/tests/test_routes_orders.py -k export +python3 -m mypy admin/routes/orders.py admin/tests/test_routes_orders.py +python3 -m ruff check admin/routes/orders.py admin/tests/test_routes_orders.py +``` + +--- + +### P1-4 分享密码存储升级 + +**目标** + +- 将 `data/share/short_link.py` 的无盐 SHA-256 升级为 PBKDF2 / bcrypt / argon2 中至少一种。 + +**文件** + +- `data/share/short_link.py` +- `data/share/tests/test_short_link.py` +- `data/share/tests/test_permission.py` +- `README.md` + +**完成标准** + +- 新建链接密码使用强哈希 +- 兼容迁移策略明确(若已有历史数据) +- 解析与校验路径回归通过 + +**验证** + +```bash +pytest -q data/share/tests +python3 -m mypy data/share/short_link.py +``` + +--- + +## 3. P2 — 可以后续做(产品/结构项) + +### P2-1 明确是否继续建设用户端 Web 自助闭环 + +**目标** + +- 做产品决策: + - A. 收缩外部口径,只承认当前是运营后台系统 + - B. 真正建设场景B:用户端下单/支付/填资料/交付 + +**文件** + +- `product/PRD.md` +- `product/ROADMAP.md` +- `docs/BUSINESS_SCENE.md` +- 新计划文档(如需要) + +**完成标准** + +- 范围决策明确 +- 如果做 B,则新建明确实施计划,不再复用旧的漂移状态 + +--- + +### P2-2 异常吞噬改为结构化降级日志 + +**目标** + +- 保留兜底能力,但不再 `except Exception: pass` 静默吞掉关键证据。 + +**文件** + +- `data/channel_sync/webhook_server.py` +- `data/share/short_link.py` + +**完成标准** + +- 兜底路径至少有 debug/warn 级结构化日志 +- 不影响主响应契约 + +--- + +### P2-3 清理 bandit/mypy 噪声与历史脚本残留 + +**目标** + +- 降低 false positive 比例,让安全/类型门禁可长期使用。 + +**范围** + +- `scripts/legacy/*` +- `tests/*` 中仅测试专用的 assert/subprocess 噪声 +- `data/share/short_link.py` 中重复常量 / `_self_test()` 噪声 + +--- + +## 4. 模块优先级建议 + +| 优先级 | 模块 | 原因 | +| ------ | ----------------------------- | ------------------------------- | +| P0 | 文档真相层 | 当前所有后续判断都被它污染 | +| P0 | `data/channel_sync` | 存在真实安全边界问题 | +| P0 | `admin/auth` / `admin/config` | 默认口令 + 无节流 | +| P0 | `.github/workflows/ci.yml` | 现有 CI 不能代表项目真实健康 | +| P1 | `data/share` | 功能全,但安全/类型债明显 | +| P1 | `admin/*` | 覆盖率与类型质量明显偏弱 | +| P2 | 用户端 Web 产品 | 这是范围选择题,不是单纯 bugfix | + +--- + +## 5. 当前最短闭环路径 + +```text +Step 1 统一文档真相 + ↓ +Step 2 修 webhook XFF 信任边界 + ↓ +Step 3 修默认 admin 弱口令 + 登录节流 + ↓ +Step 4 修 CI clean env 与 coverage/type gate + ↓ +Step 5 再决定是否继续做用户端 Web 自助闭环 +``` + +--- + +## 6. 禁止的误报口径 + +在以下事项闭环前,禁止对外宣称: + +- ❌ “项目整体完成” +- ❌ “Web 自助产品已可交付” +- ❌ “CI/安全/类型门禁已完善” +- ❌ “已达到完整产品形态” + +允许的真实口径: + +- ✅ “人工服务场景后台主链已成形” +- ✅ “订单/分享/渠道同步/审核链路已具备可运行实现” +- ✅ “用户端 Web 自助闭环仍未落地” +- ✅ “当前仍需修复文档真相、CI、类型与安全门禁” diff --git a/docs/T8-4-fallback-sop.md b/docs/T8-4-fallback-sop.md new file mode 100644 index 0000000..59132a4 --- /dev/null +++ b/docs/T8-4-fallback-sop.md @@ -0,0 +1,116 @@ +# T8.4 渠道失败兜底 SOP + +状态:已落地(巡检 CLI + 人工补录指令) +适用范围:当前巡检事实源以 `xianyu` 的 `webhook_audit` / `poller_state` / `poller_run` 为准;其他渠道目前仅复用“人工补录模板”,不应误解为已接入同等自动健康判定。 + +## 1. 目标 + +当 Webhook 主路径异常、poller 兜底异常或渠道侧临时不可用时,运营/值班同学可以: + +1. 先做事实巡检,确认是不是链路退化; +2. 再走人工补录,确保订单不因为渠道故障而丢单; +3. 最后继续用已有 order-manager CLI 推进 `paid/serving/delivered` 状态。 + +## 2. 定期检查 + +建议每 5~15 分钟执行一次: + +```bash +python3 scripts/gaokao-channel-fallback --db data/orders.db check --source xianyu --human +``` + +返回语义: + +- `status: ok`:最近未发现明显异常 +- `status: warn`:链路可能未启动 / 最近运行过旧 / 存在失败信号,需要人工关注 +- `status: critical`:poller 连续报错或最近一次 poller_run 失败,应立即切人工兜底 + +退出码: + +- `0` = ok +- `1` = warn +- `2` = critical + +适合 cron / systemd timer / 外部监控直接接入。 + +## 3. 人工兜底入口 + +### 3.1 打印模板 + +```bash +python3 scripts/gaokao-channel-fallback --db data/orders.db manual-template --source xianyu --human +``` + +### 3.2 新建订单 + +注意:当前 CLI 通过命令行参数接收姓名/手机号,存在 shell history / `ps` 暴露风险。仅建议在可信单用户主机上临时执行;执行后应清理历史记录,且不要把真实 PII 粘贴到工单/IM。 + +```bash +python3 scripts/gaokao-order-manager --db data/orders.db create \ + --source xianyu \ + --service-version basic \ + --amount-cents 0 \ + --customer-name 张三 \ + --customer-phone 13800001234 \ + --candidate-name 李同学 \ + --candidate-province 湖南 +``` + +说明: + +- `external_id` 可留空,允许渠道故障期先人工建单; +- `amount_cents` 在未确认实付时可先填 `0`,后续再 `update`; +- 订单创建后会自动生成 `GKO-*` 内部单号,后续推进都用该单号。 + +### 3.3 补齐字段 / 推进状态 + +```bash +python3 scripts/gaokao-order-manager --db data/orders.db update \ + --assigned-consultant consultant-a \ + --note 渠道故障期人工补录 + +python3 scripts/gaokao-order-manager --db data/orders.db pay --reason manual_pay +python3 scripts/gaokao-order-manager --db data/orders.db deliver --reason manual_delivery +``` + +## 4. 值班判断规则 + +优先看以下事实: + +1. `webhook_audit` 最近是否还有 `accepted` +2. `poller_state.last_run_at` 是否超过阈值 +3. `poller_state.last_error` / `poller_run.error_message` 是否持续存在 +4. 最近 60 分钟 `rejected + parse_error` 是否明显升高 + +建议阈值(CLI 默认值): + +- poller stale:15 分钟 +- webhook stale:30 分钟 +- recent window:60 分钟 +- reject warn threshold:5 次 +- poller error warn threshold:3 次 + +## 5. 推荐调度 + +最小 cron 方案: + +```cron +*/10 * * * * cd /home/long/project/gaokao-volunteer-system && python3 scripts/gaokao-channel-fallback --db data/orders.db check --source xianyu +``` + +说明: + +- 非 0 退出码表示要进入监控/告警; +- 真正是否有业务影响,还要结合最近是否有渠道订单流量; +- 若当前阶段尚未启用 poller,可接受 `warn`,但应明确值班同学走人工补录。 + +## 6. 当前边界 + +本次 T8.4 只补齐“巡检 + 人工兜底”闭环,不做: + +- 自动调用真实闲鱼开放平台 API; +- 自动把外部失败订单直接转成人工建单; +- 自动通知企业微信/短信。 +- 为 `wechat` / `school` 等非 xianyu 渠道建立独立 webhook/poller 健康事实源。 + +这些属于后续增强项,不影响当前人工值班闭环。 diff --git a/docs/plans/T6-2-dashboard-mvp.md b/docs/plans/T6-2-dashboard-mvp.md new file mode 100644 index 0000000..ec75cd6 --- /dev/null +++ b/docs/plans/T6-2-dashboard-mvp.md @@ -0,0 +1,398 @@ +# T6.2 仪表盘 MVP Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** 在现有 T6.1 FastAPI 管理后台骨架上补齐一个最小可用的仪表盘 MVP,只覆盖订单 / 用户 / 收入卡片、趋势图接口、FastAPI 路由清单与 ECharts 页面布局,不引入复杂筛选。 + +**Architecture:** 后端继续沿用 `admin/` 模块分层:路由层只做鉴权与响应封装,统计聚合下沉到 `admin/stats.py` 纯函数,直接对 SQLite 做只读 SQL 聚合。前端保持极简 HTML + ECharts:首屏 3 张 KPI 卡片 + 1 张趋势折线图 + 3 张分布图,不引入前端框架。 + +**Tech Stack:** FastAPI、Pydantic v2、SQLite (`sqlite3`)、纯 HTML/JS、ECharts 5。 + +--- + +## 1. 输入上下文与约束 + +已验证现状: + +- `admin/app.py` 已存在 FastAPI app 工厂,并已挂载 `stats_router`。 +- `admin/routes/stats.py` 已存在 `/api/stats/orders` 与 `/api/stats/dashboard` 路由位置。 +- `admin/config.py` 已存在 `Settings.orders_db_path`,可区分 admin DB 与 orders DB。 +- `README.md` 与 `docs/plans/T6-admin-mvp.md` 已给出 T6.2 目标口径,可作为本计划的事实基线。 + +本计划的 MVP 边界: + +- 必做:3 张卡片(订单 / 用户 / 收入) +- 必做:1 个趋势接口(供折线图) +- 必做:FastAPI 路由清单 +- 必做:ECharts 页面布局 +- 不做:自定义时间范围、按渠道筛选、导出、钻取、对比环比、RBAC 细粒度权限 + +## 2. MVP 信息架构 + +### 2.1 页面结构 + +页面只分 3 个区域: + +1. 顶部 KPI 卡片区 +2. 中部趋势图区 +3. 底部分布图区 + +### 2.2 卡片指标(MVP) + +仅展示以下 3 张主卡片: + +| 卡片 | 字段 | 数据来源 | 展示格式 | 说明 | +| ------ | ----------------------------- | ---------------- | ----------- | ----------------------------------------- | +| 订单数 | `summary.total_orders` | `orders` 表 | 整数 | 全量累计订单 | +| 用户数 | `summary.total_users` | `admin_users` 表 | 整数 | 后台可识别用户总数 | +| 收入 | `summary.total_revenue_cents` | `orders` 表 | 元/两位小数 | 仅统计 `paid/serving/delivered/completed` | + +MVP 不额外拆“今日 / 7d / 30d”成独立卡片;这些字段保留在响应里给 tooltip/副标题复用: + +- `orders_today` +- `orders_7d` +- `orders_30d` +- `revenue_today_cents` +- `revenue_7d_cents` +- `revenue_30d_cents` + +### 2.3 图表区域 + +| 区域 | 图表类型 | 对应字段 | 目的 | +| ------------ | -------- | --------------------------- | ---------------- | +| 趋势区 | 双折线图 | `trends.7d` 或 `trends.30d` | 看订单与收入走势 | +| 状态分布 | 柱状图 | `by_status` | 看漏斗积压 | +| 来源分布 | 柱状图 | `by_source` | 看渠道结构 | +| 服务版本分布 | 柱状图 | `by_service_version` | 看产品结构 | + +MVP 默认首屏展示 `7d` 趋势;页面只提供一个轻量切换按钮:`7d / 30d`,不提供任意日期选择器。 + +## 3. 数据契约设计 + +### 3.1 推荐主接口 + +MVP 推荐以前端只请求一个主接口为主: + +- `GET /api/stats/dashboard` + +原因: + +- 一次请求即可填满卡片、趋势、分布三类组件 +- 避免前端并发多个请求导致空态/加载态不一致 +- 与“最小 MVP”目标一致 + +### 3.2 Dashboard 响应结构 + +```json +{ + "summary": { + "total_orders": 6, + "total_revenue_cents": 100000, + "total_users": 1, + "orders_today": 3, + "orders_7d": 4, + "orders_30d": 5, + "revenue_today_cents": 20000, + "revenue_7d_cents": 70000, + "revenue_30d_cents": 100000 + }, + "by_status": { + "pending": 0, + "paid": 1, + "serving": 1, + "delivered": 0, + "completed": 1, + "refunded": 1 + }, + "by_source": { + "xianyu": 0, + "wechat": 0, + "web": 0, + "school": 0 + }, + "by_service_version": { + "audit": 0, + "basic": 0, + "standard": 0, + "premium": 0 + }, + "trends": { + "today": [{ "date": "2026-06-12", "orders": 3, "revenue_cents": 20000 }], + "7d": [{ "date": "2026-06-06", "orders": 0, "revenue_cents": 0 }], + "30d": [{ "date": "2026-05-14", "orders": 0, "revenue_cents": 0 }] + }, + "generated_at": "2026-06-12T16:30:00+00:00" +} +``` + +### 3.3 关键口径 + +- 收入口径:仅 `paid` / `serving` / `delivered` / `completed` 计入 `total_revenue_cents` +- 时间粒度:按 UTC 日聚合,字段格式 `YYYY-MM-DD` +- 0 填充:趋势窗口内无订单的日期也必须返回 0 点 +- 数据最小化:统计查询不读取 PII 字段 + +## 4. FastAPI 路由清单 + +### 4.1 MVP 必备路由 + +| 方法 | 路径 | 鉴权 | 用途 | 备注 | +| ---- | ---------------------- | ---- | ---------------- | --------------------- | +| GET | `/api/stats/dashboard` | JWT | 仪表盘主接口 | 前端首屏主数据源 | +| GET | `/api/stats/orders` | JWT | 订单统计兼容接口 | 保留给旧页面/兼容调用 | +| GET | `/health` | 公开 | 健康检查 | 用于服务探活 | +| GET | `/docs` | 公开 | Swagger UI | 调试联调 | +| GET | `/openapi.json` | 公开 | OpenAPI schema | 合约校验 | + +### 4.2 路由实现归属 + +- `admin/routes/stats.py` + - `get_dashboard()`:返回 dashboard 聚合 payload + - `get_order_stats()`:返回兼容版订单统计 payload +- `admin/app.py` + - `app.include_router(stats_router)`:挂载统计路由 +- `admin/auth.py` + - 复用 `get_current_user` 做 JWT 保护 + +## 5. 后端实现拆分 + +### 5.1 文件落点 + +| 文件 | 动作 | 责任 | +| -------------------------------------------- | --------- | --------------------------- | +| `admin/stats.py` | 新增/实现 | 纯函数聚合层 | +| `admin/routes/stats.py` | 修改 | FastAPI 响应模型 + 路由封装 | +| `admin/config.py` | 修改 | `orders_db_path` 配置 | +| `admin/tests/test_routes_stats_dashboard.py` | 新增 | dashboard 路由测试 | +| `README.md` | 修改 | 补 T6.2 使用说明 | + +### 5.2 聚合层接口建议 + +`admin/stats.py` 最小公开函数: + +- `build_dashboard_payload(orders_db_path: str, admin_db_path: str) -> dict` +- `build_order_stats_payload(orders_db_path: str) -> dict` +- `compute_summary(...) -> dict` +- `compute_by_status(...) -> dict` +- `compute_by_source(...) -> dict` +- `compute_by_service_version(...) -> dict` +- `compute_trends(...) -> dict` + +原则: + +- SQL 聚合只做只读查询 +- 路由层不直接拼 SQL +- 趋势补零逻辑做成独立辅助函数,便于单测 + +## 6. ECharts 页面布局 + +### 6.1 页面网格 + +建议 12 列响应式布局: + +```text +┌──────────────────────────────────────────────┐ +│ Header: 标题 + 更新时间 + 7d/30d 切换 │ +├──────────────┬──────────────┬───────────────┤ +│ 卡片1 订单数 │ 卡片2 用户数 │ 卡片3 收入 │ +├──────────────────────────────────────────────┤ +│ 趋势图(订单/收入双折线,占满整行) │ +├──────────────┬──────────────┬───────────────┤ +│ 状态分布 │ 来源分布 │ 服务版本分布 │ +└──────────────┴──────────────┴───────────────┘ +``` + +### 6.2 组件说明 + +- 卡片区:纯 HTML + CSS 即可,不需要 ECharts +- 趋势图:ECharts 双 y 轴可选;若 MVP 想更简单,可单 y 轴并统一展示原值 +- 分布图:3 张基础柱状图,统一使用分类轴 + 单系列 + +### 6.3 前端最小交互 + +只保留: + +- 页面加载自动拉取 dashboard +- 点击 `7d / 30d` 切换趋势数据源 +- 鼠标 hover 显示 tooltip + +不做: + +- 多筛选器联动 +- 自动刷新 +- 下载图片/CSV +- 图表钻取 + +## 7. 实施任务分解 + +### Task 1: 对齐统计口径与响应契约 + +**Objective:** 先冻结字段命名与收入/时间窗口口径,避免前后端反复返工。 + +**Files:** + +- Modify: `docs/plans/T6-2-dashboard-mvp.md` +- Reference: `README.md` +- Reference: `docs/plans/T6-admin-mvp.md` + +**Steps:** + +1. 固定卡片字段只使用 `summary.total_orders` / `summary.total_users` / `summary.total_revenue_cents` +2. 固定趋势字段使用 `trends.7d` / `trends.30d` +3. 文档化收入与 0 填充口径 +4. 复核字段名与 README 现有描述一致 + +**Verification:** + +- 人工检查文档中字段名只出现一套命名 +- README / 本计划 / 路由响应模型三者不冲突 + +### Task 2: 落统计聚合层 + +**Objective:** 让 dashboard 数据生成逻辑从路由层抽离到可测试纯函数。 + +**Files:** + +- Create/Modify: `admin/stats.py` +- Reference: `admin/db.py` +- Test: `admin/tests/test_routes_stats_dashboard.py` + +**Steps:** + +1. 先写空库响应测试 +2. 再写真实订单聚合测试 +3. 实现 summary / by_status / by_source / by_service_version / trends +4. 跑 dashboard 相关测试并确认 0 填充与窗口边界正确 + +**Verification:** + +- `pytest admin/tests/test_routes_stats_dashboard.py -v` +- 空库与真实数据两类断言都通过 + +### Task 3: 接入 FastAPI 路由 + +**Objective:** 暴露稳定的 `/api/stats/dashboard` 与兼容 `/api/stats/orders`。 + +**Files:** + +- Modify: `admin/routes/stats.py` +- Reference: `admin/app.py` +- Reference: `admin/auth.py` + +**Steps:** + +1. 为 dashboard 定义响应模型 +2. 路由层只做鉴权 + 调用聚合函数 +3. 保留 `/api/stats/orders` 兼容字段名 +4. 确认 `app.include_router(stats_router)` 已挂载 + +**Verification:** + +- `pytest admin/tests/test_routes_stats_dashboard.py -v` +- `curl http://127.0.0.1:8000/openapi.json | jq '.paths["/api/stats/dashboard"]'` + +### Task 4: 交付 ECharts MVP 页面 + +**Objective:** 用最少前端代码完成可视化首屏。 + +**Files:** + +- Create: `admin/templates/dashboard.html` 或 `admin/static/dashboard.html` +- Create: `admin/static/dashboard.js` +- Optional Modify: `admin/app.py`(若需挂静态文件) + +**Steps:** + +1. 写 3 卡片 + 4 图表容器骨架 +2. 首屏只请求 `/api/stats/dashboard` +3. 将 `summary` 映射到卡片,将 `trends` 映射到折线图 +4. 将 `by_status` / `by_source` / `by_service_version` 映射到柱状图 +5. 增加 7d / 30d 切换按钮 + +**Verification:** + +- 浏览器打开页面后能看到 3 卡片 + 4 图表 +- 空库情况下图表正常渲染 0 值,不报错 + +### Task 5: 补文档与联调说明 + +**Objective:** 让后续实现者和 QA 有明确的启动与验收路径。 + +**Files:** + +- Modify: `README.md` +- Modify: `docs/plans/T6-admin-mvp.md` +- Modify: `docs/plans/T6-2-dashboard-mvp.md` + +**Steps:** + +1. 写启动命令与环境变量说明 +2. 写 JWT 登录后如何请求 dashboard +3. 写页面联调步骤 +4. 写空库 / 有数据两种验收场景 + +**Verification:** + +- 新人按文档可独立跑通接口与页面 + +## 8. 验收标准 + +MVP 完成判定: + +- `/api/stats/dashboard` 返回完整 payload +- 页面能展示 3 张卡片 +- 页面能展示 1 张趋势图 + 3 张分布图 +- 空库时全部组件可渲染,不报错 +- 趋势默认展示 7d,能切到 30d +- 不存在复杂筛选器与额外交互膨胀 + +## 9. 风险与降级策略 + +| 风险 | 影响 | 降级策略 | +| -------------------------- | ---------------- | ----------------------------------- | +| 订单历史数据稀疏 | 趋势图断裂/难看 | 强制 0 填充 | +| admin_users 与 orders 分库 | 聚合口径不一致 | 在 `summary` 层显式区分两个 DB | +| 收入口径争议 | 前后端展示不一致 | 在文档与代码常量里固定 4 个有效状态 | +| 前端实现超范围 | MVP 延误 | 禁止引入筛选面板、框架化重构 | + +## 10. 最短闭环建议 + +必须先做: + +1. 冻结响应契约 +2. 落纯函数聚合层 +3. 接路由 +4. 再做 ECharts 页面 + +可以并行: + +- 前端静态布局草图 +- README 联调文档 + +可延后: + +- 任意日期筛选 +- 导出 +- 渠道/版本联动筛选 +- 首页缓存 + +## 11. 验证路径 + +最小验证顺序: + +1. `pytest admin/tests/test_routes_stats_dashboard.py -v` +2. `pytest admin/tests -v` +3. 启动 `python3 -m admin.app --port 8000` +4. 登录拿 JWT +5. `curl /api/stats/dashboard` +6. 浏览器打开 dashboard 页面,验证空库 / 样例数据两种场景 + +## 12. 结论 + +T6.2 的正确 MVP 不是“做一个复杂 BI 系统”,而是: + +- 后端提供一个稳定的一站式 dashboard payload +- 前端用 ECharts 把 3 张核心卡片、1 张趋势图、3 张分布图展示出来 +- 保持无复杂筛选、无重前端框架、无额外依赖膨胀 + +这条路径最短、风险最低,也与当前 `admin/` 骨架和项目 README 描述保持一致。 diff --git a/docs/plans/T6-5-case-management-mvp.md b/docs/plans/T6-5-case-management-mvp.md new file mode 100644 index 0000000..9b39b06 --- /dev/null +++ b/docs/plans/T6-5-case-management-mvp.md @@ -0,0 +1,221 @@ +# T6.5 案例管理 MVP Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** 在现有管理后台中补齐一个最小可用的案例管理 MVP,只覆盖案例列表、详情、创建/编辑必需字段、三类分类、审核状态,以及与 T6.6 WebUI 的清晰接口边界。 + +**Architecture:** 继续沿用 `admin/` + `data/cases/` 的分层:`data/cases` 负责 SQLite 模型/DAO/Schema,`admin/routes/cases.py` 负责 HTTP 契约与鉴权,T6.6 仅消费这些 API 做 HTML + 静态页面。MVP 不新增复杂工作流,只保留案例内容维护与审核发布两条主链路,避免把 WebUI 逻辑和业务状态耦合在一起。 + +**Tech Stack:** FastAPI、Pydantic v2、sqlite3、dataclass、pytest、纯 HTML/JS(T6.6)。 + +--- + +## 1. 已确认上下文 + +已存在的事实: + +- `admin/routes/cases.py` 已提供 `/api/cases` CRUD 与 `/api/cases/{id}/review`。 +- `data/cases/models.py`、`dao.py`、`schema.py` 已定义案例表结构与持久化层。 +- `admin/app.py` 已挂载 `cases_router`,并在启动时初始化 cases schema。 +- `admin/tests/test_routes_cases.py` 已覆盖 CRUD / 审核 / 过滤主路径。 +- `docs/ADMIN_DESIGN.md` 已给出“成功 / 典型 / 警告”的管理目标,但还需要收敛成 MVP 可执行边界。 + +本计划的输出目标不是扩功能,而是把“案例管理 MVP”收敛成可实现、可验收、不会和 T6.6 混线的设计说明。 + +## 2. MVP 范围 + +### 2.1 必做 + +- 案例列表 +- 案例详情 +- 创建 / 编辑所需字段 +- 分类字段:`success` / `typical` / `warning` +- 审核状态字段:`pending` / `approved` / `rejected` +- 与 T6.6 的接口边界说明 + +### 2.2 不做 + +- 复杂 RBAC +- 工作流流转图 +- 富文本编辑器 +- 评论 / 版本历史 / 审核流多级状态 +- 批量导入 / 批量审核 +- 案例浏览统计 + +## 3. 数据契约(MVP 统一口径) + +### 3.1 列表字段 + +列表接口必须返回以下字段,供 T6.6 页面直接渲染: + +- `id` +- `title` +- `category` +- `summary` +- `review_status` +- `review_note` +- `reviewer` +- `reviewed_at` +- `created_at` +- `updated_at` +- `tags` + +列表额外保留: + +- `total` +- `limit` +- `offset` + +### 3.2 详情字段 + +详情页必须返回完整案例正文,MVP 详情字段如下: + +- `id` +- `title` +- `category` +- `summary` +- `content` +- `review_status` +- `review_note` +- `reviewer` +- `reviewed_at` +- `created_at` +- `updated_at` +- `tags` + +### 3.3 创建 / 编辑必需字段 + +创建与编辑统一使用同一组基础字段: + +- `title`(必填) +- `category`(必填) +- `summary`(可空) +- `content`(可空) +- `tags`(可空数组,默认空) + +说明: + +- 审核字段不允许由创建 / 编辑接口直接写入。 +- 审核状态只允许通过独立审核接口修改,避免前端绕过审核。 + +### 3.4 分类字段 + +分类只保留三类: + +| 值 | 含义 | MVP 作用 | +| --------- | -------- | ------------------- | +| `success` | 成功案例 | 对外展示的正向结果 | +| `typical` | 典型案例 | 结构化方法论样本 | +| `warning` | 警告案例 | 错误示范 / 风险提示 | + +### 3.5 审核状态字段 + +审核状态只保留三态: + +| 值 | 含义 | 默认 | +| ---------- | ------ | ---- | +| `pending` | 待审 | 是 | +| `approved` | 已通过 | 否 | +| `rejected` | 已驳回 | 否 | + +审核动作必须记录: + +- `review_note` +- `reviewer` +- `reviewed_at` + +## 4. API 边界与 T6.6 分工 + +### 4.1 T6.5 负责的接口 + +| 方法 | 路径 | 用途 | +| ------ | ------------------------ | ------------------ | +| GET | `/api/cases` | 列表 / 过滤 / 分页 | +| POST | `/api/cases` | 创建 | +| GET | `/api/cases/{id}` | 详情 | +| PATCH | `/api/cases/{id}` | 编辑 | +| POST | `/api/cases/{id}/review` | 审核通过 / 驳回 | +| DELETE | `/api/cases/{id}` | 删除 | + +### 4.2 T6.6 只消费、不承载的内容 + +T6.6 只做: + +- HTML 页面布局 +- 列表页渲染 +- 新建 / 编辑表单 +- 详情页展示 +- 审核按钮与表单提交 +- 静态资源托管 + +T6.6 不做: + +- 数据校验规则的业务真相 +- 审核状态流转规则 +- 案例分类枚举定义 +- 任何数据库写入逻辑 + +原则: + +- 业务真相留在 API / DAO +- 页面只负责展示与提交 +- 页面状态不反向定义业务状态 + +## 5. 实现落点(若进入编码) + +### 5.1 业务层 + +- `data/cases/models.py`:维护案例 dataclass / 枚举 +- `data/cases/dao.py`:CRUD、过滤、审核写入 +- `data/cases/schema.py`:表结构 / 索引 / 默认值 + +### 5.2 HTTP 层 + +- `admin/routes/cases.py`:请求模型、响应模型、JWT 鉴权、错误映射 +- `admin/app.py`:确保路由挂载与启动初始化 + +### 5.3 测试层 + +- `admin/tests/test_routes_cases.py`:CRUD、审核、404、过滤 +- 可补充:`data/cases/tests/` 用于 DAO / schema 级别验证 + +## 6. 验证标准 + +最小验证闭环: + +1. 列表能返回 `category`、`review_status`、`tags` +2. 详情能返回 `content` +3. 创建 / 编辑只能写基础字段 +4. 审核只能通过审核接口修改 +5. `pending -> approved/rejected` 可落库 +6. 列表过滤 `category + review_status` 可用 +7. 删除后详情返回 404 +8. T6.6 只依赖 API 契约,不直接碰 DAO + +建议验证命令: + +```bash +cd /home/long/project/gaokao-volunteer-system +python3 -m pytest admin/tests/test_routes_cases.py -q +``` + +## 7. 风险与约束 + +- 风险 1:创建 / 编辑 / 审核响应字段不一致,导致 T6.6 页面二次映射复杂。 + - 缓解:统一响应结构,列表和详情共享同一基础模型。 +- 风险 2:审核字段被普通编辑接口覆盖,破坏发布边界。 + - 缓解:基础编辑 payload 不包含审核字段。 +- 风险 3:T6.6 把页面逻辑写成“第二套业务规则”。 + - 缓解:页面只消费 API 枚举值,所有枚举定义单一来源。 +- 风险 4:分类语义漂移,`success/typical/warning` 被扩成更多状态。 + - 缓解:MVP 只允许三类,不预留多余枚举。 + +## 8. 交付判定 + +当且仅当以下条件都满足时,T6.5 MVP 才算设计完成: + +- 字段集明确且最小化 +- 列表 / 详情 / 创建 / 编辑 / 审核边界清楚 +- 分类与审核状态枚举闭合 +- T6.6 仅消费 API,不重复定义业务规则 +- 后续实现者可以直接按本文档落地,不需要再猜字段 diff --git a/docs/plans/T6-admin-mvp.md b/docs/plans/T6-admin-mvp.md new file mode 100644 index 0000000..036d780 --- /dev/null +++ b/docs/plans/T6-admin-mvp.md @@ -0,0 +1,383 @@ +# T6 管理后台 MVP — 详细设计 + +**状态**: v2.1 实施设计 +**最后更新**: 2026-06-12 +**关联文档**: [IMPLEMENTATION_PLAN_v2.md](../IMPLEMENTATION_PLAN_v2.md), [TECH_ARCHITECTURE.md](../TECH_ARCHITECTURE.md) +**关联任务**: T6.1 FastAPI 后端基础 / T6.2 仪表盘 / T6.3 用户管理 / T6.4 订单管理 / T6.5 案例管理 / T6.6 简易 WebUI / T6.7 Docker 部署 + +--- + +## 1. 设计目标 + +### 1.1 总目标 + +为运营人员提供一个最小可用的 Web 管理后台,承接订单/用户/案例/审核报告的核心 CRUD + 统计展示,覆盖 2026 高考季(6/25-7/5)的运营需求。 + +### 1.2 非目标 + +- ❌ 复杂 RBAC 权限体系(MVP 用 admin 单一角色,后续 T8 扩展) +- ❌ 重前端(Vue/React)—— 走 ECharts + 纯 HTML/JS(v2.1 不引前端框架) +- ❌ 高并发/集群部署 —— 用户基数小(目标 100+ 考生),单机 SQLite 足够 +- ❌ 完整 OpenAPI 兼容性 —— 自动 Swagger 文档即可 + +### 1.3 关键原则 + +| 原则 | 说明 | +| ------------------- | --------------------------------------------------------------------- | +| **本地优先** | 默认本地 SQLite,云端可选 | +| **零侵入宿主** | 不依赖外部子项目,仅调用本项目 `data/orders/` 已实现层 | +| **测试优先** | 每个路由有 test client 测试;覆盖率目标 >= 70% | +| **最小风险闭环** | T6.1 只做骨架(启动+路由+JWT+Swagger),业务路由在 T6.2-T6.5 增量实现 | +| **明文/加密分字段** | API 层明文接收,落盘前调用 T4.1 加密层 | + +--- + +## 2. 技术选型(v2.1 决策) + +| 维度 | 选择 | 理由 | +| ----------- | ---------------------------- | ------------------------------------------------- | +| Web 框架 | **FastAPI 0.133** | 自动 OpenAPI、依赖注入、类型驱动 | +| ASGI 服务器 | **uvicorn 0.41** | 标配 FastAPI 部署 | +| 认证 | **PyJWT 2.13 (HS256)** | 标准 JWT,密钥从环境变量读取 | +| ORM | **不使用 SQLAlchemy** | 保持项目"零运行时第三方"约束;用 stdlib `sqlite3` | +| Pydantic | **Pydantic v2.13** | FastAPI 强依赖;用 BaseModel 做请求/响应模型 | +| 密码哈希 | stdlib `hashlib.pbkdf2_hmac` | 不引 bcrypt/argon2(MVP 简化) | +| 测试 | pytest + httpx TestClient | httpx 0.28 已装 | + +**为什么不用 SQLAlchemy**:项目 README/requirements-dev.txt 明确"运行时本项目为纯标准库实现,无第三方运行时依赖"。T4.1 已用 stdlib `sqlite3` + 自写 dataclass。引入 SQLAlchemy 会破坏这个约束。DAO 层用 `sqlite3` + `dataclass` 即可覆盖 CRUD。 + +--- + +## 3. 模块结构 + +``` +admin/ +├── __init__.py # 模块导出 +├── app.py # FastAPI app 工厂 (create_app) +├── config.py # 配置加载(环境变量 + .env 兜底) +├── db.py # SQLite 连接管理 + DAO 工厂 +├── auth.py # JWT 签发/校验/依赖 +├── password.py # PBKDF2 哈希 +├── routes/ +│ ├── __init__.py +│ ├── health.py # GET /health (公开) +│ ├── auth.py # POST /api/auth/login, GET /api/auth/me +│ ├── orders.py # T6.4 订单管理(列表/详情/录单/PATCH/导出) +│ ├── stats.py # GET /api/stats/orders (骨架,T6.2 完善) +│ └── meta.py # GET /api/meta (province 列等) +└── tests/ + ├── __init__.py + ├── conftest.py # FastAPI TestClient + 内存 SQLite fixture + ├── test_app.py # app 启动 + Swagger 可达 + ├── test_auth.py # JWT 签发/校验 + 401 路径 + ├── test_routes_health.py + ├── test_routes_auth.py + └── test_routes_orders.py +``` + +### 3.1 顶层路由(v2.1 MVP 阶段) + +| 方法 | 路径 | 鉴权 | 说明 | 阶段 | +| ----- | -------------------- | ---- | ------------------------------ | ---- | +| GET | `/health` | 公开 | 健康检查(DB + 版本) | T6.1 | +| GET | `/docs` | 公开 | Swagger UI(FastAPI 自动) | T6.1 | +| GET | `/openapi.json` | 公开 | OpenAPI Schema | T6.1 | +| POST | `/api/auth/login` | 公开 | 用户名+密码 → JWT | T6.1 | +| GET | `/api/auth/me` | JWT | 当前用户信息 | T6.1 | +| GET | `/api/orders` | JWT | 订单列表(真实 DAO + 脱敏) | T6.4 | +| GET | `/api/orders/export` | JWT | CSV 导出(默认脱敏) | T6.4 | +| GET | `/api/orders/{id}` | JWT | 订单详情 + 状态历史 | T6.4 | +| POST | `/api/orders` | JWT | 手工录单(external_id 可空) | T6.4 | +| PATCH | `/api/orders/{id}` | JWT | 业务字段更新 / 状态流转 / 退款 | T6.4 | +| GET | `/api/stats/orders` | JWT | 订单统计(占位,空对象) | T6.1 | +| GET | `/api/meta` | JWT | 元数据(省份列等) | T6.1 | + +> T6.2 / T6.3 / T6.4 已分别落地仪表盘、用户管理与订单管理;后续剩余案例 CRUD、登录页 + 列表页等增量任务。 + +--- + +## 4. 数据模型 + +### 4.1 复用 T4.1 + +- `data/orders/models.py::Order` — 订单 dataclass +- `data/orders/schema.py::apply_schema` — DDL +- `data/orders/crypto.py::encrypt/decrypt/hash_for_index` — 字段加密 +- `data/orders/state_machine.py::OrderStatus` — 6 态枚举 + +### 4.2 新增:admin_users 表(T6.1 引入) + +```sql +CREATE TABLE IF NOT EXISTS admin_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, -- 存储格式: $ + role TEXT NOT NULL DEFAULT 'admin', -- 后续 T8 RBAC 扩展 + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + last_login_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username); +``` + +### 4.3 决策点 + +- **只允许单 admin?** ❌ — 支持多账户(运营多人轮班) +- **密码存储?** PBKDF2-HMAC-SHA256, 200k 迭代, 16B salt → 64B hash +- **默认账户?** 启动时若 `admin_users` 为空,自动创建 `admin/admin123`,并在日志 WARN 提示修改 + +--- + +## 5. JWT 设计 + +### 5.1 Token 结构(HS256) + +```json +{ + "sub": "admin:1", // 用户标识 + "username": "admin", + "role": "admin", + "iat": 1718172800, + "exp": 1718176400 // 默认 1 小时 +} +``` + +### 5.2 配置 + +| 环境变量 | 默认值 | 说明 | +| -------------------- | ---------------------------- | ------------------- | +| `GAOKAO_JWT_SECRET` | dev-only 占位(启动时 WARN) | HS256 密钥(>=32B) | +| `GAOKAO_JWT_EXP_MIN` | 60 | 过期时间(分钟) | +| `GAOKAO_DB_PATH` | `data/orders/admin.db` | SQLite 路径 | +| `GAOKAO_ENV` | `dev` | dev/prod 标识 | + +> **安全警告**:生产部署必须显式设置 `GAOKAO_JWT_SECRET` 为高熵随机值。MVP 阶段在 `dev` 环境用占位即可,但启动日志必须明显提示。 + +### 5.3 中间件行为 + +- 请求带 `Authorization: Bearer ` → 解析 token,设置 `request.state.user` +- 无 token / 过期 / 签名错 → 返回 401 `WWW-Authenticate: Bearer` +- 受保护路由通过 `Depends(get_current_user)` 获取当前用户 +- 公开路由(health, login, docs, openapi)走白名单 + +--- + +## 6. Swagger / OpenAPI + +### 6.1 自动生成 + +FastAPI 自动生成 `/docs` (Swagger UI) 和 `/openapi.json`。无需手写。 + +### 6.2 元信息增强 + +```python +app = FastAPI( + title="高考志愿填报管理后台 API", + version="0.1.0", + description="""管理后台 MVP API。 + 认证:POST /api/auth/login → Bearer JWT。 + 详细字段:data/orders/models.py""", + contact={"name": "Hermes Agent"}, + license_info={"name": "MIT"}, +) +``` + +### 6.3 标签分组 + +- `health` — 健康检查 +- `auth` — 认证 +- `orders` — 订单 +- `stats` — 统计 + +--- + +## 7. 测试策略(TDD) + +### 7.1 测试框架 + +- `pytest` + `fastapi.testclient.TestClient`(内部用 httpx;dev 依赖需显式安装) +- fixture:内存 SQLite(`sqlite3.connect(':memory:')`)+ 自动建表 + 自动建 admin 账户 +- 每个测试函数独立 DB,避免状态污染 + +### 7.2 必测项 + +| 项 | 验证 | +| ------------------------------- | --------------------------- | +| `GET /health` | 200 + `{"status":"ok"}` | +| `GET /docs` | 200 HTML | +| `GET /openapi.json` | 200 JSON, 含 `paths` | +| `POST /api/auth/login` 正确凭证 | 200 + JWT | +| `POST /api/auth/login` 错凭证 | 401 | +| `GET /api/auth/me` 无 token | 401 | +| `GET /api/auth/me` 有效 token | 200 + 用户信息 | +| `GET /api/orders` 无 token | 401 | +| `GET /api/orders` 有效 token | 200 + `[]` (MVP 阶段无数据) | +| `GET /api/orders/{id}` 不存在 | 404 | +| JWT 过期 | 401 | +| JWT 签名错 | 401 | +| `pytest --cov admin` | 覆盖率 >= 70% | + +### 7.3 覆盖率门槛 + +参考 v2.1 实施计划:核心包覆盖率 >= 70%。T6.1 完成后跑: + +```bash +cd /home/long/project/gaokao-volunteer-system +python3 -m pytest admin/tests/ --cov=admin --cov-report=term-missing +``` + +--- + +## 8. 部署 + +### 8.1 MVP 本地启动 + +```bash +# 1. 安装依赖(一次性) +pip3 install --user --break-system-packages fastapi uvicorn pyjwt httpx pydantic + +# 2. 启动服务 +export GAOKAO_JWT_SECRET="$(python3 -c 'import secrets;print(secrets.token_hex(32))')" +python3 -m admin.app --port 8000 + +# 3. 访问 +open http://localhost:8000/docs +``` + +### 8.2 T6.7 Docker 阶段(不在 T6.1 范围) + +T6.7 阶段出 Dockerfile + docker-compose.yml。 + +--- + +## 9. 风险与缓解 + +| 风险 | 概率 | 影响 | 缓解 | +| -------------------------- | ---- | ---- | -------------------------------------------- | +| FastAPI 升级破坏兼容 | 低 | 中 | 锁版本(fastapi==0.133.1, pydantic==2.13.4) | +| 默认 admin 账户泄露 | 中 | 高 | 启动日志 WARN + README 强调 prod 必须改密 | +| JWT secret 弱 | 中 | 高 | dev 占位 + 启动日志断言长度 | +| SQLite 并发写冲突 | 低 | 低 | MVP 单机 + 启用 WAL | +| 与 T4.1 dataclass 耦合过紧 | 低 | 中 | DAO 层明确接口边界 | + +--- + +## 10. T6.2 仪表盘(一站式数据统计) + +### 10.1 设计目标 + +为运营人员提供一个最小可用的仪表盘端点:返回订单数 / 用户数 / 收入三项汇总 +卡片,叠加 6 态分布 / 来源分布 / 服务版本分布三个柱状图数据源,并给出 +今日 / 7 天 / 30 天三个窗口的趋势序列(日粒度,0 填充)。 + +### 10.2 端点 + +| 方法 | 路径 | 鉴权 | 说明 | +| ---- | ---------------------- | ---- | --------------------------------------------------- | +| GET | `/api/stats/dashboard` | JWT | 一站式仪表盘 payload | +| GET | `/api/stats/orders` | JWT | 订单维度统计(沿用 T6.1 stub 字段名,接入真实 SQL) | + +### 10.3 响应契约 + +```json +{ + "summary": { + "total_orders": 6, "total_revenue_cents": 100000, "total_users": 1, + "orders_today": 3, "orders_7d": 4, "orders_30d": 5, + "revenue_today_cents": 20000, "revenue_7d_cents": 70000, "revenue_30d_cents": 100000 + }, + "by_status": {"pending": 0, "paid": 1, "serving": 1, "delivered": 0, "completed": 1, "refunded": 1}, + "by_source": {"xianyu": 0, "wechat": 0, "web": 0, "school": 0}, + "by_service_version":{"audit": 0, "basic": 0, "standard": 0, "premium": 0}, + "trends": { + "today": [{"date": "2026-06-12", "orders": 3, "revenue_cents": 20000}], + "7d": [{"date": "2026-06-06", "orders": 0, "revenue_cents": 0}, ... 共 7 个点 ...], + "30d": [{"date": "2026-05-14", "orders": 0, "revenue_cents": 0}, ... 共 30 个点 ...] + }, + "generated_at": "2026-06-12T16:30:00+00:00" +} +``` + +### 10.4 口径约定(必须文档化) + +- **收入 (revenue_cents)** = 所有 **非 pending 且非 refunded** 订单的 + `amount_cents` 累计值。 + - `pending`:未付款,不计入有效收入 + - `refunded`:已退款,从累计收入中扣除 + - `paid / serving / delivered / completed`:四态均计入 +- **趋势桶粒度** = 日(UTC,`YYYY-MM-DD`)。`substr(created_at, 1, 10)` 与 + ISO8601 字符串前缀等价。 +- **"今日"** = 服务器当前 UTC 日;**7d / 30d** = 含今日回溯 7 / 30 个完整日。 +- **0 填充**:窗口内的"无订单日"也要返回 0 点,前端拿到的是稠密序列。 +- **数据源隔离**: + - `orders` / `order_status_history` → `GAOKAO_ORDERS_DB_PATH`(默认 `data/orders.db`) + - `admin_users` → `GAOKAO_DB_PATH`(默认 `data/orders/admin.db`) +- **不读 PII**:统计路径只触碰 `amount_cents` / `status` / `source` / + `service_version` / `created_at`,不进入加密层。 + +### 10.5 模块分层 + +- `admin/stats.py`:纯函数 SQL 聚合层(`build_dashboard_payload` / + `compute_summary` / `compute_by_*` / `compute_trends` / `generate_day_series`) + - 可单测:纯函数 + 可注入 `today` 参数 + - 与路由层解耦:业务测试不依赖 FastAPI +- `admin/routes/stats.py`:FastAPI 路由层 + - 仅鉴权 + 响应包装 + - `DashboardResponse` / `OrderStatsResponse` 两个 pydantic 模型 + +### 10.6 配置新增 + +- `Settings.orders_db_path`(`GAOKAO_ORDERS_DB_PATH`,默认 `data/orders.db`) + - 与 `data.orders.*` 共享同一 DB + - 与 `data.channel_sync.webhook_server` 已有的同名环境变量对齐 + +### 10.7 测试覆盖(11 用例) + +`admin/tests/test_routes_stats_dashboard.py`: + +- 鉴权(无 token 401 / 有 token 200) +- 空库形状契约:summary / by\_\* / trends 三层结构稳定 +- 趋势序列:1 / 7 / 30 个点,按日期严格升序 +- 0 填充点:包含完整三字段(`date` / `orders` / `revenue_cents`) +- 真实数据:窗口边界(45 天前的订单被 30d 窗口排除,但计入 total) +- 收入口径:pending / refunded 不计入 revenue +- 0 填充:范围内无订单的日也返回 0 点 +- 兼容层:`/api/stats/orders` 字段名不变,`_stub` 标记已移除 + +### 10.8 T6.2 DoD + +- [x] `admin/stats.py` 纯函数聚合层落地 +- [x] `/api/stats/dashboard` + `/api/stats/orders` 双端点接入 +- [x] 11 个 dashboard 测试 + 1 个真实数据兼容测试,全部通过 +- [x] admin 全量 87/87 测试通过;repo 全量 412/412 测试通过 +- [x] 新增代码 ruff check 通过 +- [x] 文档同步:本文档新增 §10、README 增量更新、CHANGELOG 记录 + +--- + +## 11. 完成标准(DoD) + +T6.1 完成的硬标准: + +- [x] 设计文档(本文件) +- [x] `admin/` 模块代码(app/config/db/auth/password/routes) +- [x] 测试套件通过(覆盖率 >= 70%) +- [x] `python3 -m admin.app` 启动成功 +- [x] `http://localhost:8000/docs` 可访问 +- [x] `http://localhost:8000/openapi.json` 返回合法 OpenAPI schema +- [x] `POST /api/auth/login` + `GET /api/auth/me` 端到端通 +- [x] 默认 admin 账户创建,WARN 日志输出 +- [x] requirements-dev.txt 或新 requirements-admin.txt 列出依赖 +- [x] AGENTS.md / README.md 增量更新(T6.1 章节) +- [x] git commit + push 三仓库 + +--- + +## 12. 后续任务衔接 + +- [x] **T6.2** (仪表盘):`/api/stats/dashboard` + `/api/stats/orders` 已落地(见 §10) +- [x] **T6.3** (用户管理):`/api/admin/users` 列表 / 详情 / 脱敏 / 搜索已落地 +- [x] **T6.4** (订单管理):`/api/orders` 已补齐列表 / 详情 / POST / PATCH / export,状态变化统一走 `state_machine` +- **T6.5** (案例管理):新增 `data/cases/` 表 + `/api/cases` CRUD +- **T6.6** (WebUI):HTML 模板 + 静态托管 +- **T6.7** (Docker):Dockerfile + compose diff --git a/docs/plans/T7-sharing-mvp.md b/docs/plans/T7-sharing-mvp.md index 57a6e4b..d1f9707 100644 --- a/docs/plans/T7-sharing-mvp.md +++ b/docs/plans/T7-sharing-mvp.md @@ -1,7 +1,7 @@ # T7: 分享功能 MVP — 实施计划 **版本**: v1.0 -**状态**: 设计 → 实施中 (T7.1 已完成, T7.2 等待) +**状态**: 设计 → 实施中 (T7.1/T7.2/T7.3/T7.4 已完成, T7.5 待办) **关联文档**: - [产品设计 v1 - 分享](../PRODUCT_DESIGN_v1.md) @@ -17,12 +17,12 @@ T7 包含 5 个子任务 (来自 IMPLEMENTATION_PLAN_v2.md §T7): | ID | 任务 | 优先级 | 状态 | 工时 | | ---- | -------------- | :----: | --------- | :--: | | T7.1 | 短链接生成 | P1 | ✅ 已完成 | 1d | -| T7.2 | 海报生成 (PIL) | P1 | ⏳ 待办 | 1.5d | -| T7.3 | 权限控制 (3级) | P1 | ⏳ 待办 | 1d | -| T7.4 | 撤销与统计 | P1 | ⏳ 待办 | 1d | +| T7.2 | 海报生成 (PIL) | P1 | ✅ 已完成 | 1.5d | +| T7.3 | 权限控制 (3级) | P1 | ✅ 已完成 | 1d | +| T7.4 | 撤销与统计 | P1 | ✅ 已完成 | 1d | | T7.5 | 分享页 WebUI | P1 | ⏳ 待办 | 0.5d | -**本文档将按子任务分节详细化设计; T7.1 已实现, 其它章节为待办设计草案。** +**本文档将按子任务分节详细化设计; T7.1-T7.4 已实现, T7.5 为待办设计草案。** --- @@ -192,25 +192,104 @@ def short_link(code): --- -## T7.3 权限控制 (3级) ⏳ +## T7.3 权限控制 (3级) ✅ -| 级别 | 字段值 | UI 行为 | -| ---- | --------- | ------------------ | -| 只读 | `read` | 隐藏所有编辑入口 | -| 评论 | `comment` | 显示「提建议」入口 | -| 编辑 | `edit` | 显示完整编辑权限 | +### 目标 -T7.1 已在 `permission` 字段落地 (4 级: read/comment/edit/admin), UI 层 T7.5 实施。 +把短链接层的 `permission` 字段真正翻译成分享页可执行的 UI 能力与字段可见性策略: + +| 级别 | 字段值 | UI 行为 | 姓名展示 | +| ---- | --------- | -------------------------- | --------------------- | +| 只读 | `read` | 隐藏所有编辑/评论入口 | 脱敏显示(如 `张**`) | +| 评论 | `comment` | 显示「提建议」入口,禁编辑 | 脱敏显示(如 `张**`) | +| 编辑 | `edit` | 显示完整编辑权限 | 原样展示 | + +### 本次落地 + +- **策略模块**:`data/share/permission.py` + - `PermissionPolicy.for_permission(permission)`:把 `read/comment/edit/admin` 归一化成前端能力策略 + - `allows_field(field)` / `can("view|comment|edit")`:统一权限判断 + - 未知 permission 一律回退到最严格的 `read` 拒止策略(防止越权) +- **姓名脱敏复用**:复用 `data/orders/masking.py::mask_name` + - 仅策略层决定 "该不该脱敏" + - 基础脱敏算法仍由订单模块统一维护;公开分享场景再做保守收敛:3 字及以上中文名统一为 `姓+**`,非中文名统一为 `**` +- **路由辅助扩展**:`data.share.short_link.route_short_link_with_report(...)` + - 在 T7.1 的 `route_short_link()` 之上叠加报告 payload 渲染 + - 支持 `report=` 直接注入,或 `report_loader(report_id)` 回调懒加载 + - resolve 失败(not_found / revoked / expired / password_required / password_wrong)时不下发 `rendered`,避免泄露元数据 +- **字段裁剪规则**: + - `read`:回传最小元信息 + 脱敏姓名(公开场景采用更严格的 `张**` / `**` 收敛规则) + - `comment`:回传 `title / summary / recommendations / volunteers / score / rank / year / province` + 脱敏姓名,但不暴露手机号/身份证/内部备注/哈希 + - `edit`:开放完整编辑所需业务字段,但仍强制隐藏 `password_hash / internal_note / note / debug_info / raw_payload` 等内部字段 + +### Python API + +```python +from data.share.permission import PermissionPolicy, render_report_payload +from data.share.short_link import route_short_link_with_report + +policy = PermissionPolicy.for_permission("comment") +assert policy.can_view is True +assert policy.can_comment is True +assert policy.can_edit is False + +rendered = render_report_payload("comment", report_dict, share_url="https://gk.example.com/s/ABC123") +# rendered = { +# "permission": "comment", +# "policy": {"can_view": True, "can_comment": True, "can_edit": False, "mask_name": True}, +# "visible_fields": [...], +# "payload": {...}, +# "masked_fields": [...], +# } + +out = route_short_link_with_report( + "ABC123", + password="s3cr3t", + base_url="https://gk.example.com", + report_loader=lambda report_id: load_report(report_id), +) +``` + +### 验证 + +- `python3 -m pytest data/share/tests/test_permission.py -q` → **34 passed** +- `python3 -m pytest data/share/tests/ -q` → **61 passed** +- `python3 -m pytest data/share/ data/orders/ -q` → **231 passed** +- `python3 -m ruff check data/share/permission.py data/share/tests/test_permission.py data/share/short_link.py` → **All checks passed** + +### 设计取舍 / 已知限制 + +- `admin` 作为 T7.1 历史兼容值,当前按 `edit` alias 处理;T7.3 的业务语义仍是 3 级权限模型 +- `edit` 允许手机号/身份证等编辑所需业务字段,但 `password_hash / internal_note / note / debug_info / raw_payload` 这类内部字段仍被策略层强制隐藏 +- 当前 `read/comment` 采用"最小字段白名单",优先防泄露;如果 T7.5 UI 需要额外字段,应在策略表中显式增补,而不是默认放开 --- -## T7.4 撤销与统计 ⏳ +## T7.4 撤销与统计 ✅ -T7.1 已提供 `revoke` 和 `get_stats`。T7.4 增量: +### 本次落地 -- 批量撤销: `revoke_by_report(report_id, owner_id)` -- 统计: 按日 / 按地域 (IP 反查, P2) -- 自动清理: cron 跑 `purge_expired` 每天一次 +- 批量撤销: `ShortLinkService.revoke_by_report(report_id, owner_id=None)` + - 同一 report 下可一次性撤销全部分享;传 `owner_id` 时只撤销该 owner 创建的链接,避免越权 + - CLI 新增 `python scripts/gaokao-shortlink revoke-report --report-id R-2026-001 --owner alice` +- 访问统计升级: 基于 `share_link_access_events` 访问事件表记录每次成功 resolve + - 保留 T7.1 的 `access_count / last_access_at` + - 新增 `unique_visitors`(按 `visitor_token` 去重) + - 新增 `daily_accesses`(按 UTC 日聚合 `access_count / unique_visitors`) +- 报告级统计: `ShortLinkService.get_report_stats(report_id, owner_id=None, days=7)` + - 返回 `total_links / active_links / revoked_links / expired_links / total_access_count / unique_visitors / daily_accesses` + - CLI 新增 `python scripts/gaokao-shortlink stats-report --report-id R-2026-001 --days 7` + +### 设计取舍 + +- 本期先做访问事件表,支撑按日趋势和访客去重;地域统计仍保持 P2,待后续接入 IP 反查/风控链路时再补 +- `visitor_token` 由调用方透传(如 openid / session id / cookie 指纹);未传时仍统计访问次数,但 `unique_visitors` 不会虚构数据 +- 每次成功 `resolve(record_access=True)` 同时更新汇总字段与事件表,兼顾现有接口兼容性和后续统计扩展 + +### 验证 + +- `python3 -m pytest data/share/tests/test_short_link.py -q` → 27 passed +- 覆盖新增用例:`revoke_by_report` owner 隔离、报告级批量撤销、按日趋势聚合、访客去重、报告级汇总统计 --- diff --git a/docs/plans/T9-error-handling.md b/docs/plans/T9-error-handling.md new file mode 100644 index 0000000..d7300ab --- /dev/null +++ b/docs/plans/T9-error-handling.md @@ -0,0 +1,289 @@ +# T9 错误处理体系 — 详细设计 + +**状态**: v0.3 实施设计(T9.1 + T9.2 + T9.3 已落地,T9.4 待办) +**最后更新**: 2026-06-12 +**关联文档**: [IMPLEMENTATION_PLAN_v2.md](../IMPLEMENTATION_PLAN_v2.md) +**关联任务**: T9.1 错误码体系 / T9.2 用户友好提示 / T9.3 日志记录 / T9.4 异常捕获装饰器 + +--- + +## 0. 变更日志 + +| 版本 | 日期 | 变更 | +| ---- | ---------- | --------------------------------------------------------- | +| 0.1 | 2026-06-12 | 起草 T9.1 错误码体系 + T9.2 用户友好提示设计 | +| 0.2 | 2026-06-12 | 复盘: HTTP 422 兼容回退、`is_registered` 注册守卫 | +| 0.3 | 2026-06-12 | 新增 T9.3 结构化 JSON 日志设计(contextvars + formatter) | + +--- + +## 1. 设计目标 + +### 1.1 总目标 + +为 admin 后台提供生产可观测的 **统一错误处理 + 结构化日志** 体系: + +- 用户看到的永远是中文 message + 可执行 suggestion +- 运营 / 排障人员在日志里看到的是结构化 JSON,能按 `code`、`path`、`request_id` 聚合 +- 错误码与 HTTP 状态码解耦,新增业务码不需要改 HTTP 语义 +- 业务码注册守卫:`codes.py` 声明 + `registry.py` 翻译 + CI 校验三者必须一致 + +### 1.2 非目标 + +- ❌ 完整 ELK / Loki 集成(MVP 阶段写 stdout JSON 即可,由部署层收集) +- ❌ 分布式 trace(无 user_id 串联、单进程 MVP;contextvars 占位方便后续接入 OpenTelemetry) +- ❌ 多语言(i18n 资源包只放 zh-CN,结构上预留 en-US 入口) +- ❌ 异步日志队列(`QueueHandler` 后续 T11 性能加固再做) + +### 1.3 关键原则 + +| 原则 | 说明 | +| ------------------ | ------------------------------------------------------------------- | +| **零第三方运行时** | 日志 / 错误处理全部 stdlib;不引 `python-json-logger` / `structlog` | +| **关注点分离** | 错误码 `codes.py` / 翻译 `registry.py` / 渲染 `exceptions.py` 三分 | +| **可测试性** | 单元测试只读 `LogRecord` 不走 formatter,集成测试才走 JSON 编码 | +| **不回溯破坏** | T9.3 不改 T9.2 既有响应体契约,仅替换日志输出形态 | + +--- + +## 2. 错误码体系 (T9.1) + +### 2.1 码点结构 + +``` +E AA BBB +| | +-- 段内顺序号 (001-999, 零填充 3 位) +| +----- 段号 (2 位) ++-------- 字面量 'E' 固定前缀 +``` + +| 段号 | 段名 | 范围 | 含义 | +| ----- | ------ | ------------- | ---------------------------- | +| 01 | 用户 | E01001-E01199 | 输入、格式、必填等客户端错误 | +| 02 | 业务 | E02001-E02199 | 业务规则违反、状态机非法 | +| 03 | 数据 | E03001-E03199 | 数据访问、持久化失败 | +| 04 | 第三方 | E04001-E04199 | 上游服务 / 渠道故障 | +| 05 | 系统 | E05001-E05099 | 内部错误、资源耗尽、配置缺失 | +| 90-99 | 保留 | - | 不分配给业务 | + +### 2.2 子域位(段内第 2 位 `xx?xx` 中的 `?`) + +| 位 | 含义 | 典型场景 | +| --- | --------- | ------------------ | +| 0 | 通用 | 跨段兜底 | +| 1 | 凭证 | 登录、token、密码 | +| 2 | 会话/速率 | 限流、刷新、过期 | +| 3 | 权限/状态 | RBAC、订单状态机 | +| 4 | 设备/并发 | 同一账号多端、抢锁 | +| 5 | 配额/迁移 | 余额、迁移、灰度 | + +### 2.3 强制规则 + +- **5xx 系统错误严禁落到非 05 段**(防兜底掩盖),由单元测试守门 +- `FALLBACK_CODE = E05099` 是兜底码,未注册的业务异常统一映射到它 +- 字面量前缀 `E` 用于日志链路快速定位业务错误 +- 与 HTTP 状态码 **解耦** — 同一 HTTP 状态可对应不同业务码(如 401 = `E01101` 凭证错 / `E01102` 账号禁用) + +--- + +## 3. 用户友好提示 (T9.2) + +### 3.1 翻译注册表 + +`admin/errors/registry.py` 维护 `MESSAGES_ZH_CN: Dict[str, Message]`,每个 `Message` 包含: + +| 字段 | 类型 | 约束 | +| ------------ | ------- | ------------------------------- | +| `code` | str | 6 字符码点 | +| `message` | str | ≤ 30 字,一句话讲清楚发生了什么 | +| `suggestion` | str | ≤ 50 字,具体到下一步动作 | +| `severity` | Literal | `info` / `warn` / `error` | +| `retryable` | bool | 前端/SDK 可据此决定自动重试策略 | + +### 3.2 注册守卫 + +- `is_registered(code) -> bool` — CI 校验 `codes.py` 声明的每个常量都在 `MESSAGES_ZH_CN` 中存在 +- `MessageNotFoundError` — 运行时 `get_message` 找不到时抛,handler 自动映射到 `FALLBACK_CODE` +- 单元测试覆盖每个声明的码点都有中文翻译(防散落字符串) + +### 3.3 响应体契约 + +```json +{ + "code": "E01101", + "message": "用户名或密码错误", + "suggestion": "请检查后重试,3 次失败将锁定 5 分钟", + "severity": "warn", + "retryable": false, + "detail": { "fields": [...] } // 可选,调试上下文 +} +``` + +`detail` 在生产环境按 `include_detail` 开关脱敏;目前默认未启用。 + +### 3.4 FastAPI handler 矩阵 + +| 异常类型 | 业务码映射 | HTTP 状态 | 日志级别 | +| ------------------------ | ------------------------ | ----------------- | --------- | +| `BusinessError` | `exc.code` | `http_status_for` | warning | +| `HTTPException` | `FALLBACK_CODE` | `exc.status_code` | warning | +| `RequestValidationError` | `DATA_VALIDATION_FAILED` | 422 | info | +| `Exception` (兜底) | `SYS_INTERNAL_ERROR` | 500 | exception | + +--- + +## 4. 结构化日志 (T9.3) + +### 4.1 目标 + +让 `admin.*` logger 在生产模式下输出 **单行 JSON**,每条日志可被 ELK / Loki / `jq` 直接消费。 + +### 4.2 输出 schema + +```json +{ + "ts": "2026-06-12T16:30:01.234Z", + "level": "warning", + "logger": "admin.errors", + "msg": "BusinessError code=E01101 path=/api/auth/login method=POST status=401", + "ctx": { + "request_id": "req_8d3a...", + "code": "E01101", + "path": "/api/auth/login", + "method": "POST" + }, + "exc": { + // 可选,异常时才有 + "type": "BusinessError", + "message": "E01101", + "traceback": "Traceback (most recent call last):\n ..." + } +} +``` + +### 4.3 设计要点 + +| 决策 | 理由 | +| -------------------------------------------------------- | ------------------------------------------------------------------------------ | +| **stdlib `logging.Formatter` 子类** | 不引第三方;与 uvicorn 自带 logger 兼容 | +| **`contextvars` 绑定 per-request 上下文** | FastAPI 异步安全;不影响并发请求之间的污染 | +| **`log_event(logger, level, event, **fields)` helper\*\* | 业务代码一行写结构化事件,避免拼字符串 + extra dict | +| **不替换标准 `logger.info("msg %s", x)` 调用形态** | 最小侵入;现有 `admin.errors` handler 的 4 处 `logger.*` 自动享受 JSON | +| **测试隔离** | 单元测试不安装 formatter,直接断言 `LogRecord`;集成测试才验 JSON | +| **traceback 脱敏** | 不在 JSON 里去掉 traceback(排障需要),但生产环境如果走 secret 字段要二次过滤 | + +### 4.4 ContextVar 设计 + +```python +# admin/logging_utils.py +_request_ctx: ContextVar[Dict[str, Any]] = ContextVar("request_ctx", default={}) + +def bind_request_context(**fields) -> Token: ... +def clear_request_context(token) -> None: ... +def current_context() -> Dict[str, Any]: ... +``` + +- `bind_request_context` 在 FastAPI middleware 进入时调用,写入 `request_id` / `path` / `method` +- `clear_request_context` 在 middleware 退出时调用 `reset(token)`,避免泄漏到下一个请求 +- `JsonLogFormatter.format()` 在编码前 `ctx.update(current_context())` 合并 + +### 4.5 与既有 handler 的集成 + +`admin/errors/exceptions.py` 现有 4 处 `logger.*` 调用改为: + +```python +# 之前 +logger.warning("BusinessError code=%s path=%s method=%s status=%d", + code_str, request.url.path, request.method, http_status) + +# 之后(等价输出,但 ctx 字段在 JSON 里可单独 grep) +log_event(logger, logging.WARNING, "business_error", + code=code_str, path=request.url.path, method=request.method, + status=http_status) +``` + +`log_event` 内部 `logger.log(level, msg, extra={"ctx": fields, "event": event_name})`,formatter 把 `extra["ctx"]` 提升到顶层 `ctx` 字段。 + +### 4.6 测试策略 + +| 测试 | 覆盖 | +| ------------------------------ | -------------------------------------------------------------------- | +| `TestJsonLogFormatter` | 时间格式、级别、logger 名、ctx 提升、exc 序列化 | +| `TestLogEvent` | 普通字段、嵌套字段、保留 key (`code`/`path`/`method`) | +| `TestRequestContext` | bind / clear 不污染并发、contextvars 隔离 | +| `TestTracebackSanitization` | 异常记录包含 `exc.type/msg/traceback` | +| `TestFastAPIHandlerStructured` | 端到端:触发 `BusinessError`,捕获 stderr 验证 JSON 含 `code`/`path` | +| `TestPlainTextMode` | `LOG_FORMAT=plain` 时回退到原文本格式(开发友好) | + +### 4.7 配置开关 + +| 环境变量 | 取值 | 默认 | 含义 | +| ------------------ | ------------ | ---- | ---------------------------------- | +| `ADMIN_LOG_FORMAT` | `json` | json | 单行 JSON 输出 | +| `ADMIN_LOG_FORMAT` | `plain` | - | 原 `%(asctime)s %(levelname)s ...` | +| `ADMIN_LOG_LEVEL` | 任意合法级别 | info | 全局日志级别 | + +`admin/app.py::create_app` 不再调用 `logging.basicConfig`;由 `main()` 在启动 uvicorn **前**安装 formatter,测试中通过 `monkeypatch` 注入。 + +### 4.8 风险与缓解 + +| 风险 | 缓解 | +| ------------------------------------------ | -------------------------------------------------------------- | +| uvicorn 自带 access logger 仍走 plain | 文档说明:用 `--log-config` 切换;MVP 不强制统一 | +| 业务代码 `logger.info("x=%s", x)` 仍是文本 | 接受:纯文本也能 grep;但鼓励用 `log_event` 走结构化 | +| 异常 traceback 体积过大 | 限制 `exc.traceback` ≤ 4KB;超出截断并加 `truncated=true` 标记 | +| ContextVar 在同步代码里的行为 | Python 3.7+ ContextVar 同步代码同样工作,文档显式说明 | + +--- + +## 5. 异常捕获装饰器 (T9.4 — 占位) + +T9.4 计划提供 `@catch(code=..., reraise=False)` 装饰器,把函数内异常统一翻译为 `BusinessError`,并自动走 T9.3 日志。**本设计不在 T9.3 范围**,仅在 IMPLEMENTATION_PLAN_v2 占位。 + +--- + +## 6. DoD + +### T9.1(已完成 v0.1) + +- ✅ `codes.py` 定义 5 段 + 子域位 +- ✅ `ErrorCode.of(str)` 双向反解 +- ✅ 5xx 守门测试 + +### T9.2(已完成 v0.2) + +- ✅ `MESSAGES_ZH_CN` 注册 17 个码点 +- ✅ `BusinessError` + FastAPI 4 类 handler +- ✅ HTTP 422 兼容回退到 `_HTTP_422` 常量 +- ✅ 集成测试 26/26 + ruff 0 warning + +### T9.3(本次任务) + +- ☐ `admin/logging_utils.py` 实现 `JsonLogFormatter` + `log_event` + `bind_request_context` +- ☐ `admin/app.py` 集成 JSON 输出(CLI 默认 plain,MVP json) +- ☐ `admin/errors/exceptions.py` 4 处 `logger.*` 迁移到 `log_event` +- ☐ `admin/tests/test_logging.py` 单元 + 集成测试全通过 +- ☐ `admin` 全量测试 27+/27+ 通过 +- ☐ `ruff check admin/logging_utils.py admin/tests/test_logging.py` 0 warning +- ☐ CHANGELOG 记录 T9.3 落地 +- ☐ 提交并推送到三个 remote + +### T9.4(后续) + +- ☐ `@catch` 装饰器 + 测试 + +--- + +## 7. 关联文件清单 + +| 文件 | 状态 | 责任 | +| ----------------------------- | ----------- | ------------------------------------------- | +| `admin/errors/codes.py` | ✅ 已存在 | 码点声明 / 段与子域枚举 | +| `admin/errors/registry.py` | ✅ 已存在 | zh-CN 文案 + 守卫函数 | +| `admin/errors/exceptions.py` | ✅ 已存在 | `BusinessError` + FastAPI handler | +| `admin/errors/__init__.py` | ✅ 已存在 | 公共 API re-export | +| `admin/logging_utils.py` | ☐ T9.3 新增 | JSON formatter + context 绑定 + helper | +| `admin/tests/test_logging.py` | ☐ T9.3 新增 | formatter / event / context 测试 | +| `admin/app.py` | 🔧 改 | main() 安装 JSON formatter,添加 middleware | +| `admin/tests/test_errors.py` | ✅ 已有 | T9.1+T9.2 既有 26 测试 | +| `CHANGELOG.md` | 🔧 改 | 记录 T9.3 | diff --git a/locustfile.py b/locustfile.py new file mode 100644 index 0000000..a04439f --- /dev/null +++ b/locustfile.py @@ -0,0 +1,107 @@ +"""T11.1 Locust 性能基准脚本。 + +目标:对 T6.1 FastAPI 管理后台骨架做最小真实压测,验证计划中的 +"10 并发"目标,并记录各端点的延迟/错误率基线。 + +压测模型: +- AdminBrowseUser:模拟已登录后台管理员,覆盖 /me /meta /orders /stats +- LoginBurstUser:模拟登录高峰,持续打 /api/auth/login + +运行示例: + GAOKAO_JWT_SECRET=$(python3 - <<'PY' + print('x'*64) + PY + ) \ + locust -f locustfile.py --host http://127.0.0.1:18080 \ + --headless -u 10 -r 2 -t 1m --csv reports/perf/t11_1 +""" + +from __future__ import annotations + +import os +from typing import Optional + +from locust import HttpUser, between, task + + +ADMIN_USER = os.getenv("GAOKAO_ADMIN_USER", "admin") +ADMIN_PASS = os.getenv("GAOKAO_ADMIN_PASS", "admin123") + + +class AuthenticatedHttpUser(HttpUser): + abstract = True + wait_time = between(0.3, 1.2) + token: Optional[str] = None + + def on_start(self) -> None: + self.login() + + def login(self) -> None: + with self.client.post( + "/api/auth/login", + json={"username": ADMIN_USER, "password": ADMIN_PASS}, + name="POST /api/auth/login", + catch_response=True, + ) as resp: + if resp.status_code != 200: + resp.failure(f"login failed: {resp.status_code} {resp.text[:200]}") + self.token = None + return + data = resp.json() + token = data.get("access_token") + if not token: + resp.failure("login response missing access_token") + self.token = None + return + self.token = token + self.client.headers.update({"Authorization": f"Bearer {token}"}) + resp.success() + + +class AdminBrowseUser(AuthenticatedHttpUser): + weight = 4 + + @task(5) + def me(self) -> None: + self.client.get("/api/auth/me", name="GET /api/auth/me") + + @task(4) + def meta(self) -> None: + self.client.get("/api/meta", name="GET /api/meta") + + @task(6) + def list_orders(self) -> None: + self.client.get("/api/orders?limit=50&offset=0", name="GET /api/orders") + + @task(3) + def order_stats(self) -> None: + self.client.get("/api/stats/orders", name="GET /api/stats/orders") + + @task(1) + def detail_missing(self) -> None: + with self.client.get( + "/api/orders/non-existent-order-id", + name="GET /api/orders/{id} [404]", + catch_response=True, + ) as resp: + if resp.status_code == 404: + resp.success() + else: + resp.failure(f"expected 404, got {resp.status_code}") + + +class LoginBurstUser(HttpUser): + weight = 1 + wait_time = between(1.0, 3.0) + + @task(4) + def login(self) -> None: + self.client.post( + "/api/auth/login", + json={"username": ADMIN_USER, "password": ADMIN_PASS}, + name="POST /api/auth/login", + ) + + @task(1) + def health(self) -> None: + self.client.get("/health", name="GET /health") diff --git a/product/PRD.md b/product/PRD.md index 5197a87..abebffb 100644 --- a/product/PRD.md +++ b/product/PRD.md @@ -148,11 +148,11 @@ | F013 | 推送通知 | 填报提醒、报告更新通知 | P2 | ⏳ 规划中 | | F014 | 实时数据 | 2026年招生计划实时对接 | P2 | ⏳ 规划中 | | F015 | 智能推荐 | 基于AI的个性化推荐算法 | P2 | ⏳ 规划中 | -| F016 | 报告分享 | 链接/图片/PDF分享,权限控制 | P1 | ⏳ 规划中 | -| F017 | 管理后台 | 用户/案例/规则/数据管理 | P1 | ⏳ 规划中 | -| F018 | **反扎堆推荐** | **大厂AI相似度检测,提示扎堆风险** | **P0** | **⏳ 规划中** | -| F019 | **数据溯源** | **每所院校数据来源透明展示** | **P1** | **⏳ 规划中** | -| F020 | **AI方案审核**⭐核心 | **上传大厂AI方案,专业审核修正** | **P0** | **⏳ 规划中** | +| F016 | 报告分享 | 链接/图片/PDF分享,权限控制 | P1 | ✅ 已完成 | +| F017 | 管理后台 | 用户/案例/规则/数据管理 | P1 | ✅ 已完成 | +| F018 | **反扎堆推荐** | **大厂AI相似度检测,提示扎堆风险** | **P0** | **✅ 已完成** | +| F019 | **数据溯源** | **每所院校数据来源透明展示** | **P1** | **✅ 已完成** | +| F020 | **AI方案审核**⭐核心 | **上传大厂AI方案,专业审核修正** | **P0** | **✅ 已完成** | #### P3:高级功能(未来) diff --git a/product/ROADMAP.md b/product/ROADMAP.md index a44f327..2dc7db0 100644 --- a/product/ROADMAP.md +++ b/product/ROADMAP.md @@ -30,6 +30,8 @@ Q2 Q3 Q4 Q1 Q2 Q3 Q4 Q1 Q2 Q3 Q4 ## 阶段一:MVP发布(2026 Q2 - Q3) +> 2026-06-13 校准说明:当前已形成“运营后台 + 人工服务增强链路”MVP,用户端 Web 自助下单/支付/资料填写/交付闭环仍未完成。 + ### 目标 - 完成基础功能,支持2026届高考 @@ -87,8 +89,8 @@ Q2 Q3 Q4 Q1 Q2 Q3 Q4 Q1 Q2 Q3 Q4 ### 交付物 -- ✅ v2.0 完整产品 -- 📊 真实用户数据(100+案例) +- ✅ v2.0 核心能力(Skills/后台/分享/渠道/审核主链) +- ⏳ 用户端 Web 自助闭环(仍在推进) - 📝 产品反馈报告 ### 商业化启动(2026年6月) diff --git a/reports/PRODUCT_TECH_REVIEW_2026-06-12.md b/reports/PRODUCT_TECH_REVIEW_2026-06-12.md new file mode 100644 index 0000000..eb83e6e --- /dev/null +++ b/reports/PRODUCT_TECH_REVIEW_2026-06-12.md @@ -0,0 +1,402 @@ +# 高考志愿填报系统 产品规划与技术实施评审报告(历史快照) + +> 该文档是 2026-06-12 的评审快照;其中部分判断已被后续代码与验证结果更新。当前真相源请以 `reports/PROJECT_SYSTEM_REVIEW_2026-06-13.md` 为准。 + +**项目**: `gaokao-volunteer-system` +**评审日期**: 2026-06-12 +**评审范围**: 产品规划、市场调研、业务场景、技术架构、实施计划、核心实现代码、CI 与测试结构 +**评审方法**: 文档交叉核对 + 代码抽样核验 + 静态分析 + 本地测试验证 + +--- + +## 1. 结论摘要 + +### 1.1 总体结论 + +项目的**产品方向是成立的**,核心差异化也清楚:`27省政策检查`、`反扎堆`、`真人服务`、`数据透明`、`AI审核` 这条产品线在产品层面形成了闭环。 + +但从**产品规划 -> 技术设计 -> 实施计划 -> 代码现状**四层链路看,当前存在明显的**文档状态漂移**与**实施范围收缩**: + +- 核心产品能力已经部分落地,但并未完全覆盖 PRD 中的全部关键路径。 +- `README.md`、`PRD.md`、`ROADMAP.md`、`IMPLEMENTATION_PLAN_v2.md` 对“已完成 / 规划中 / 待实现”的标注存在不一致。 +- 业务场景中的 **Web 自助流程** 仍然缺少完整实现路径。 +- AI 审核链路已经具备解析器、扎堆检测、模板等局部模块,但距离技术架构里定义的 `audit_service -> checker_integration -> crowd_detector -> report_generator` 端到端服务还有缺口。 + +### 1.2 评审评级 + +| 维度 | 评级 | 说明 | +| ------------------ | ---- | ------------------------------------------------ | +| 产品定位 | A- | 目标用户、价值主张、付费路径明确 | +| 行业差异化 | A | 反扎堆 + 政策合规 + 真人服务,差异点成立 | +| 业务场景设计 | B | 闲鱼/微信场景较完整,Web 场景未闭环 | +| 技术规划对齐 | B- | 方向对齐,但模块落地不完整,文档与代码不同步 | +| 实施计划可执行性 | B- | 任务拆分合理,但范围较大,且部分任务状态滞后 | +| 行业最佳实践符合度 | B- | 安全、CI、测试、审计方向对了,但门槛尚未完全达标 | + +**综合判断**: 产品规划可用,技术实施计划需要按当前代码事实重新校准,不能直接按现有文档状态视为“全量已完成”。 + +--- + +## 2. 评审依据 + +### 2.1 产品与业务文档 + +- `product/PRD.md` +- `product/ROADMAP.md` +- `product/MARKET_RESEARCH.md` +- `docs/BUSINESS_SCENE.md` +- `docs/TECH_ARCHITECTURE.md` +- `docs/IMPLEMENTATION_PLAN_v2.md` +- `docs/AUDIT_REPORT_2026-06-11.md` + +### 2.2 代码与运行时证据 + +- `README.md` +- `admin/app.py` +- `admin/routes/orders.py` +- `admin/routes/users.py` +- `admin/routes/stats.py` +- `data/orders/schema.py` +- `data/orders/crypto.py` +- `data/crowd_db/loader.py` +- `data/crowd_db/crowd_detector.py` +- `data/share/short_link.py` +- `data/share/permission.py` +- `skills/gaokao-audit/SKILL.md` +- `skills/gaokao-audit/scripts/plan_parser.py` +- `.github/workflows/ci.yml` + +### 2.3 静态分析结果 + +- 深度分析报告:`reports/code-analysis-review-input-2026-06-12.md` +- 统计结果:107 个核心文件,25,582 行,123 个数据模型/DTO,218 条业务规则,66 个外部依赖 + +--- + +## 3. 产品规划审核 + +### 3.1 产品定位是否符合行业最佳实践 + +结论:**基本符合**。 + +`PRD.md` 中的定位不是泛 AI,而是“高考志愿填报的垂直决策支持”。这符合教育咨询类产品的最佳实践,因为它避免了通用聊天机器人最常见的两个问题: + +1. 只给信息,不给行动建议。 +2. 给建议但缺少政策约束与责任边界。 + +文档里给出的四维主张也合理: + +- 兴趣 +- 能力 +- 家庭条件 +- 就业导向 + +这四项和高考志愿的真实决策逻辑基本一致,不是拍脑袋的功能堆砌。 + +### 3.2 目标用户与场景是否匹配 + +结论:**匹配度高**。 + +`PRD.md` 中定义的三类用户画像,以及 `BUSINESS_SCENE.md` 中的两种核心交付路径,符合高考志愿填报服务的真实市场形态: + +- 闲鱼 / 微信 / 学校渠道,适合低摩擦成交和人工深度服务。 +- Web 自助路径,适合标准化、规模化和未来增长。 + +这个分层是合理的,且与 `MARKET_RESEARCH.md` 里“免费大厂工具负责流量教育,专业服务负责转化” 的市场判断一致。 + +### 3.3 产品优先级是否合理 + +结论:**核心优先级合理,但当前文档状态存在冲突**。 + +`PRD.md` 里把 `反扎堆推荐`、`AI方案审核` 作为核心差异化功能,这和当前市场竞争态势是一致的。 +但同一文档里,`管理后台`、`报告分享`、`数据溯源`、`AI审核`、`反扎堆` 等状态标记混杂了“规划中”和“已落地”的语义,说明产品文档没有和实现进度同步更新。 + +这会带来两个问题: + +- 对外部读者来说,不知道哪些是已交付能力,哪些只是规划。 +- 对实施团队来说,无法据此准确排期。 + +### 3.4 产品层面的主要问题 + +#### 问题 1: PRD 和当前实现状态不同步 + +`PRD.md` 第 144-155 行仍把 `F011-F020` 多个功能标成“规划中”,但 `README.md` 已明确写出管理后台、仪表盘、用户管理、订单管理、分享能力、渠道兜底等内容已进入落地阶段。 + +这不是功能本身的问题,而是**产品文档治理问题**。 +如果不修正,后续会直接影响排期、验收与商业交付口径。 + +#### 问题 2: Web 自助服务的产品路径没有真正闭环 + +`BUSINESS_SCENE.md` 第 15-23 行定义了 Web 自助流程,但 `IMPLEMENTATION_PLAN_v2.md` 的任务主线仍然围绕 AI 审核、反扎堆、数据溯源、订单管理展开,没有形成一个可直接上线的 Web 交易闭环。 + +也就是说: + +- 产品层已经定义了 Web 场景。 +- 代码层已经有管理后台和订单能力。 +- 但**前台自助购买、资料填写、交付浏览、支付闭环**尚未形成完整产品。 + +这意味着场景 B 目前仍是规划态,而不是可交付态。 + +--- + +## 4. 技术规划审核 + +### 4.1 技术架构是否与产品方向对齐 + +结论:**方向对齐,落地不完全**。 + +`TECH_ARCHITECTURE.md` 的分层设计是合理的: + +- Channels +- Gateway +- Services +- Data +- Infra + +并且它明确了 4 个核心技术面: + +- AI 审核服务 +- 反扎堆检测 +- 数据溯源 +- 订单管理 + +这些都与 PRD 的核心卖点一致,说明技术规划没有偏题。 + +### 4.2 技术选型是否符合行业最佳实践 + +结论:**符合“务实型最佳实践”**。 + +当前选型没有过度复杂化: + +- Python 3.10+ +- SQLite +- FastAPI +- 本地优先 +- 文件 / JSON / Markdown 为主 + +这对于一个以文档化规则、轻量交付和低并发为主的高考志愿辅助系统是合适的。 +它避免了不必要的微服务、消息队列或过重前端框架,这一点符合 KISS 和 YAGNI。 + +### 4.3 技术规划的主要偏差 + +#### 问题 1: AI 审核服务的模块结构与代码现状不完全一致 + +技术架构里定义的 AI 审核服务包含: + +- `audit_service.py` +- `checker_integration.py` +- `crowd_detector.py` +- `report_generator.py` + +但当前代码层已核验到的实际实现是: + +- `skills/gaokao-audit/scripts/plan_parser.py` +- `data/crowd_db/crowd_detector.py` +- `skills/gaokao-audit/templates/audit_report.html` + +也就是说,**解析器、检测器、模板已经有了,但“编排服务层”仍然不完整**。 +这使 AI 审核能力更像“若干模块拼装”,还不是完全闭环的服务产品。 + +#### 问题 2: 数据溯源的设计比实现更完整 + +`TECH_ARCHITECTURE.md` 要求数据溯源字段扩展、来源链接、置信度管理。 +`data/crowd_db/SCHEMA.md` 与 `loader.py` 确实已经定义了 `source / source_url / source_type / confidence / last_updated / data_year` 等字段,并提供了 `confidence < 0.5` 的低置信度警告机制。 + +这说明数据溯源不是空谈,已经进入实现层。 +但是从整体产品体验看,它仍然主要存在于数据结构和审核文档里,还没有在终端用户报告中形成足够强的可视化表达。 + +--- + +## 5. 实施计划审核 + +### 5.1 实施计划是否与产品规划对齐 + +结论:**部分对齐,且存在“计划超前/文档滞后”并存现象**。 + +`IMPLEMENTATION_PLAN_v2.md` 是在补齐先前审计缺口后修订的,这一版对齐意识很强,特别是新增了: + +- T6 管理后台 MVP +- T7 分享功能 MVP +- T8 渠道 SDK 集成 +- T9 错误处理体系 +- T10 CI/CD 基础 +- T11 性能与安全加固 + +这说明实施计划已经开始向行业化工程实践靠拢。 + +但存在两个现实问题: + +1. 文档里仍然保留大量“待开始 / 规划中”的标记。 +2. 代码仓库实际已经有部分 T6/T7/T8/T9 能力落地,计划文档没有完全反映现实。 + +这会导致一种典型风险:**研发推进速度快于文档治理速度**。 + +### 5.2 当前最关键的实施差距 + +#### 差距 1: Web 产品闭环未形成 + +业务场景文档定义了 Web 购买、资料填写、生成方案、站内交付,但实施计划没有把它作为一个独立可交付的主线任务。 + +对业务来说,这意味着: + +- 无法真正形成标准化产品入口。 +- 规模化能力被限制在人工渠道。 + +#### 差距 2: 核心服务的端到端编排层缺失 + +技术规划中的 AI 审核要成为商业卖点,必须有一个完整的服务编排层。 +当前看,`plan_parser`、`crowd_detector`、`audit_report.html` 已存在,但主服务编排、规范检查集成、PDF/HTML 输出组合还没有形成统一入口。 + +#### 差距 3: CI / 测试门槛没有完全兑现 + +`IMPLEMENTATION_PLAN_v2.md` 明确提出: + +- 核心覆盖率 ≥ 80% +- 整体覆盖率 ≥ 60% +- CI 通过 + +但 `.github/workflows/ci.yml` 现在只是生成覆盖率报告,并没有在工作流中强制 `--cov-fail-under` 门槛。 +也就是说,**测试门槛在计划里有,在流水线里还没真正硬化**。 + +--- + +## 6. 代码现状与文档一致性审核 + +### 6.1 已对齐部分 + +以下能力已经能从代码中看到真实实现,不是纯文档: + +- 管理后台 FastAPI 入口:`admin/app.py` +- 用户管理:`admin/routes/users.py` +- 仪表盘统计:`admin/routes/stats.py` +- 订单管理:`admin/routes/orders.py` +- 订单加密与状态审计:`data/orders/schema.py`、`data/orders/crypto.py` +- 分享短链接与权限策略:`data/share/short_link.py`、`data/share/permission.py` +- 反扎堆数据加载与检测:`data/crowd_db/loader.py`、`data/crowd_db/crowd_detector.py` +- AI 审核解析器:`skills/gaokao-audit/scripts/plan_parser.py` + +这说明项目已经不是“只停留在规划文档”的状态。 + +### 6.2 仍需警惕的部分 + +`README.md`、`PRD.md`、`ROADMAP.md` 与 `IMPLEMENTATION_PLAN_v2.md` 的状态描述存在明显不一致。 +这类不一致本身就是产品与实施失配的信号,尤其在这种带有商业交付和合规风险的项目里,会直接影响: + +- 交付验收 +- 团队排期 +- 商业对外口径 +- 风险边界 + +--- + +## 7. 行业最佳实践符合度 + +### 7.1 符合的部分 + +- 产品定位清晰,不是泛 AI。 +- 技术栈克制,没有引入多余复杂度。 +- 核心功能围绕真实高考决策场景展开。 +- 数据脱敏、加密、审计的方向正确。 +- CI / 覆盖率 / TDD 的工程意识已经建立。 + +### 7.2 不足的部分 + +- 测试门槛尚未真正硬化。 +- 文档状态治理不足。 +- Web 自助闭环缺失。 +- AI 审核端到端编排层未完全落地。 +- 产品文档与实施计划没有统一“真相源”。 + +### 7.3 结论 + +按行业最佳实践标准,这个项目已经进入**可用的工程化阶段**,但还不能说已经达到**成熟的产品化交付阶段**。 +最需要补的不是“再加功能”,而是**把已经实现的能力、正在做的能力、尚未做的能力分层讲清楚,并让计划与代码同步**。 + +--- + +## 8. 风险分级 + +### 8.1 高风险 + +1. **文档状态漂移** + 影响:排期、验收、对外口径都可能出错。 + +2. **Web 自助闭环缺失** + 影响:规模化能力不足,场景 B 不能独立运行。 + +3. **AI 审核链路未完全端到端** + 影响:核心差异化卖点的交付完整性不足。 + +### 8.2 中风险 + +1. **CI 没有硬性覆盖率门槛** +2. **测试依赖未在当前系统环境可直接运行** +3. **部分数据集仍以骨架或低置信度形式存在** + +### 8.3 低风险 + +1. 技术栈过重风险低 +2. 数据模型扩展性尚可 +3. 本地优先策略符合当前业务体量 + +--- + +## 9. 建议 + +### 9.1 立即修正 + +1. 统一 `PRD / ROADMAP / TECH_ARCHITECTURE / IMPLEMENTATION_PLAN_v2 / README` 的状态口径,先建立单一事实源。 +2. 给 `AI审核服务` 补齐端到端编排层,明确输入、校验、检测、出报告的主入口。 +3. 把 `Web 自助流程` 单独列为可交付范围,不要继续和人工渠道混在一个抽象里。 +4. 在 CI 中强制覆盖率门槛,避免“只生成报告不阻断失败”。 + +### 9.2 短期优化 + +1. 给 `README.md` 增加“已完成 / 进行中 / 规划中”三态表,并明确更新时间。 +2. 给 `data/crowd_db` 的骨架省份补充完整度分级,不要只靠目录存在代表能力存在。 +3. 给 AI 审核报告增加更强的数据来源展示和修正建议链路。 + +### 9.3 中期优化 + +1. 把 Web 端做成真正的自助产品,而不是后台能力展示页。 +2. 统一订单、用户、分享、报告的领域模型。 +3. 建立基于真实用户案例的持续回归测试集。 + +--- + +## 10. 最终结论 + +**结论一句话**: +这个项目的产品规划是专业且有市场逻辑的,技术路线也整体正确,但当前最大问题不是方向,而是**规划、实施和代码现状之间的同步失真**。 + +如果按“产品是否值得继续推进”来判断,答案是**值得**。 +如果按“实施计划是否已经与产品规划完全对齐”来判断,答案是**还没有**。 + +最准确的判断是: + +> **核心产品方向成立,核心差异化成立,已有工程落地可见,但实施计划与产品文档仍需重新对齐,尤其是 Web 闭环、AI 审核编排、CI 门槛和文档状态治理。** + +--- + +## 11. 验证记录 + +### 11.1 已完成验证 + +- 已执行代码目录扫描与核心文档抽样阅读。 +- 已执行 `code-analyzer` 静态分析,输出到 `reports/code-analysis-review-input-2026-06-12.md`。 +- 已核验评审报告文件存在,当前报告共 400 行。 + +### 11.2 未完成验证 + +本地测试套件未能执行,原因是当前系统 Python 环境缺少 `pytest`: + +```bash +pytest -q +# /bin/bash: line 1: pytest: command not found + +python3 -m pytest -q +# /usr/bin/python3: No module named pytest +``` + +仓库存在 `requirements-dev.txt`,其中包含 `pytest`、`pytest-cov`、`pytest-timeout`、`pytest-xdist`、`httpx`、`locust` 等测试/性能依赖。 +因此,本报告对代码健康度的判断基于静态分析和代码抽样,不声明当前测试套件通过。 diff --git a/reports/PROJECT_SYSTEM_REVIEW_2026-06-13.md b/reports/PROJECT_SYSTEM_REVIEW_2026-06-13.md new file mode 100644 index 0000000..1444566 --- /dev/null +++ b/reports/PROJECT_SYSTEM_REVIEW_2026-06-13.md @@ -0,0 +1,322 @@ +# gaokao-volunteer-system 系统性评审报告 + +**评审日期**: 2026-06-13 +**评审对象**: `/home/long/project/gaokao-volunteer-system` +**评审方法**: 文档交叉核对 + 关键代码抽样 + 工程门禁实跑 + 两阶段 review(规格对齐 / 代码质量) + +--- + +## 1. 一句话结论 + +> 项目已经从“纯规划/纯 skill 仓库”演进为“运营后台 + 订单/分享/渠道同步 + AI 审核链路”的可运行系统,但**文档真相严重漂移、用户端 Web 自助闭环仍未落地、CI/类型/安全门禁未形成可信闭环**。当前更准确的项目标签是:**内部运营与人工服务增强系统已成形,面向用户的完整产品化交付仍未完成**。 + +--- + +## 2. 本次新鲜证据 + +### 2.1 工程门禁 + +```bash +git status --short +python3 -m pytest -q +python3 -m ruff check . --exclude .worktrees +python3 -m mypy . +python3 -m bandit -r admin data skills scripts -x .worktrees,tests,admin/tests,data/channel_sync/tests,data/orders/tests,data/share/tests,data/crowd_db/tests,skills/gaokao-audit/tests,scripts/legacy +pytest --cov=admin --cov=data --cov=skills --cov=scripts --cov-report=term-missing -q +pytest -q admin/tests +``` + +### 2.2 当前结果 + +- `git status --short`:**大量未提交修改/新增文件**,当前真实状态主要存在于 working tree,而不是稳定交付基线 +- `python3 -m pytest -q`:**459 passed, 2 warnings** +- `python3 -m ruff check . --exclude .worktrees`:**All checks passed** +- `python3 -m mypy .`:**97 errors / 19 files** +- `python3 -m bandit ...`:原始输出噪声很高;聚焦后仍有若干真实问题 +- `pytest --cov=admin --cov=data --cov=skills --cov=scripts --cov-report=term-missing -q`:**TOTAL 61%** +- `pytest -q admin/tests`:**115 passed** + +### 2.3 CI 干净环境复核事实 + +- `.github/workflows/ci.yml` 只安装 `requirements-dev.txt` +- `requirements-dev.txt` **不包含** `fastapi / uvicorn / PyJWT / cryptography / pydantic` +- 因此 CI 对 `admin/*` 的 clean env 可复现性并不成立 +- 这意味着“本地 pytest 通过”≠“CI 对完整产品可复现” + +--- + +## 3. 当前真相源排序 + +当前建议按以下顺序理解项目状态: + +1. **代码与实跑结果**(最高优先级) + - `admin/` + - `data/orders/` + - `data/share/` + - `data/channel_sync/` + - `skills/gaokao-audit/` + - 当前 pytest / ruff / mypy / bandit / coverage 输出 +2. **README + CHANGELOG(部分可信)** +3. **IMPLEMENTATION_PLAN_v2 / CHANNEL_INTEGRATION(部分过时,需结合代码)** +4. **PRODUCT_TECH_REVIEW_2026-06-12 / AUDIT_REPORT_2026-06-11 / REMEDIATION_TASK_BOARD_2026-06-11(历史快照,不能直接继承为当前结论)** + +--- + +## 4. Stage 1:规格/规划对齐审查 + +### 4.1 已对齐的主线 + +| 领域 | 当前状态 | 证据 | +| ----------------------------------------------------- | ------------ | ----------------------------------------------------------------------------- | +| 场景A:闲鱼/微信/学校 → 管理端录单 → 顾问交付 | **基本对齐** | `docs/BUSINESS_SCENE.md` + `admin/routes/orders.py` + `data/channel_sync/*` | +| 管理后台(认证/用户/订单/案例/仪表盘) | **已落地** | `admin/app.py`、`admin/routes/*`、`README.md` | +| AI 审核主链(解析/规则检查/扎堆/报告/CLI) | **已落地** | `skills/gaokao-audit/scripts/*.py` | +| 分享能力(短链/权限/公开分享) | **已落地** | `data/share/short_link.py`、`data/share/permission.py`、`admin/share_page.py` | +| 渠道同步(闲鱼 webhook/poller、微信/企微最小 client) | **已落地** | `data/channel_sync/*` | +| crowd_db 溯源与风险展示 | **已落地** | `data/crowd_db/*`、`scripts/gaokao-data-trace` | + +### 4.2 关键漂移 + +#### A. 最严重范围错位:用户端 Web 自助产品未形成闭环 + +文档定义的场景B是: + +- 用户访问 Web +- 站内先付费 +- 付费后填写资料 +- 自动/人工生成方案 +- 站内查看 + 邮件 PDF 交付 + +当前代码现实: + +- 仓库中**没有** `package.json`、`*.tsx`、用户端前台 Web 应用 +- 只有: + - 管理后台 FastAPI + - 公开分享页 + - 管理端仪表盘页面 `admin/static/dashboard.html` +- **未见用户注册/下单/支付/资料填写/邮件交付闭环** + +结论: + +- 当前系统更像“运营后台 + 人工服务增强链路” +- 不是“文档中描述的双流程完整产品” + +#### B. PRD / ROADMAP / README / IMPLEMENTATION_PLAN_v2 状态互相冲突 + +- `PRD.md` 仍把 F016-F020(分享/管理后台/反扎堆/数据溯源/AI审核)标成“规划中” +- `ROADMAP.md` 仍把多项已实现能力放在后续阶段 +- `IMPLEMENTATION_PLAN_v2.md` 顶层总览大量任务仍是 `📋`,但局部又写“已实现” +- `README.md` 写入了较新能力,但目录树和整体叙事仍偏旧 + +结论: + +- 当前没有单一文档可以单独代表真实现状 +- **文档真相层已经失效** + +#### C. 旧报告已过时 + +| 文档 | 当前判断 | +| ------------------------------------------- | ---------------------------------- | +| `docs/AUDIT_REPORT_2026-06-11.md` | **严重过时** | +| `docs/REMEDIATION_TASK_BOARD_2026-06-11.md` | **严重过时** | +| `reports/PRODUCT_TECH_REVIEW_2026-06-12.md` | **部分仍有效,但若直接继承会失真** | + +仍有效的旧判断: + +- Web 自助闭环缺失 +- 文档状态漂移 +- 覆盖率/CI 硬门槛未闭环 + +已失真的旧判断: + +- “AI 审核编排层未形成” +- “pytest 环境不可运行” +- “测试规模极低” + +--- + +## 5. Stage 2:代码质量 / 工程门禁审查 + +### 5.1 总体判断 + +| 维度 | 状态 | 结论 | +| --------- | ---- | ------------------------------- | +| pytest | ✅ | 功能回归层当前可通过 | +| ruff | ✅ | 代码风格/显性低级错误当前可通过 | +| mypy | ❌ | 类型门禁不可用 | +| bandit | ⚠️ | 噪声很大,但存在真实问题 | +| coverage | ⚠️ | 总覆盖率仅 61%,结构性短板明显 | +| CI 完整性 | ❌ | 不是可信质量门禁 | + +### 5.2 真实高风险问题 + +#### H1. Webhook 来源 IP 信任边界错误 + +- `data/channel_sync/webhook_server.py` 定义了 `_trust_x_forwarded_for()` +- 但 `_client_ip()` 实际**始终优先信任** `X-Forwarded-For` +- 结果: + - 限流可被伪造头绕过 + - 审计 `remote_addr` 可被污染 + +这是**真实安全问题**,不是 bandit 噪声。 + +#### H2. 默认管理员弱口令 + 登录无节流 + +- `admin/config.py` 默认:`GAOKAO_ADMIN_USER=admin`, `GAOKAO_ADMIN_PASS=admin123` +- 空库会 bootstrap 管理员 +- 登录接口未见失败次数限制 / 限流 / 锁定 + +虽然 prod 对 JWT secret 有强校验,但**admin 密码强度没有同级保护**。 + +#### H3. CI 无法代表完整产品可构建/可测试 + +- `.github/workflows/ci.yml` 只装 `requirements-dev.txt` +- 未安装 admin 运行依赖 +- clean env 下 `admin/tests` 不可保证可运行 + +### 5.3 中风险问题 + +#### M1. 类型系统失控 + +`mypy .` 失败 97 个错误,集中在: + +- `data/share/short_link.py` +- `data/cases/dao.py` +- `skills/gaokao-audit/*` +- `scripts/*` +- tests/legacy 噪声 + +含义不是“仓库不能跑”,而是: + +- 类型门禁当前没有工程可信度 +- 也无法直接接入 CI 做阻断 + +#### M2. 覆盖率不达计划标准 + +新鲜证据: + +- TOTAL: **61%** +- admin 核心模块覆盖率明显偏低: + - `admin/routes/orders.py` 57% + - `admin/routes/ui.py` 55% + - `admin/users.py` 38% + - `admin/share_page.py` 14% + - `admin/errors/exceptions.py` 33% + - `admin/logging_utils.py` 23% +- skills 审核链路也有薄弱点: + - `checker_integration.py` 63% + - `report_generator.py` 67% + +说明: + +- `channel_sync`、`orders`、`crowd_db` 测试成熟度较高 +- `admin`、`report_generator`、集成胶水层明显偏弱 + +#### M3. CSV 导出公式注入风险 + +`admin/routes/orders.py` 直接导出用户可控文本字段到 CSV,未做 Excel 公式注入防护。 + +#### M4. 异常吞噬导致审计可观测性下降 + +`data/channel_sync/webhook_server.py` 多处 `except Exception: pass`,虽然提高了兜底可用性,但也会削弱取证与排障能力。 + +### 5.4 低风险与噪声 + +#### L1. Bandit B608 大量动态 SQL 报警,多数是 false positive + +例如: + +- `data/orders/dao.py` +- `data/cases/dao.py` +- `data/share/short_link.py` +- `admin/stats.py` + +这些位置多数是: + +- 拼接**白名单列名/固定 where 片段** +- 值仍通过参数绑定传入 + +需要人工甄别,**不能直接按 SQL 注入漏洞上报**。 + +#### L2. 开发占位 JWT secret 是 intentional false positive + +- `admin/config.py` 的 `_DEV_JWT_SECRET` 在 prod 启动时会被阻止 +- 它本身不是生产漏洞,但会污染 bandit 输出 + +--- + +## 6. 模块真实状态表 + +| 模块 | 真实状态 | 说明 | +| ---------------------- | ------------------------------------ | --------------------------------------------------------- | +| `admin/` | **Partial / 可运行但质量门禁不闭环** | 后台功能已明显成形;CI 依赖、类型、覆盖率仍薄弱 | +| `data/orders/` | **Done-ish / 质量较好** | DAO、状态机、加密、CLI、测试都比较成熟 | +| `data/channel_sync/` | **Done-ish / 质量较好** | webhook/poller/adapter/monitor 已成形,存在 XFF 安全缺口 | +| `data/share/` | **Partial / 能用但有安全与类型债** | 功能全,但密码哈希策略偏弱,类型门禁差 | +| `data/cases/` | **Partial** | CRUD 有了,但类型安全与工程化不足 | +| `data/crowd_db/` | **Done-ish / 数据能力较强** | loader/trace/risk/report 成熟,但骨架省份仍有低置信度数据 | +| `skills/gaokao-audit/` | **Partial to Good** | 主链已落地,集成胶水层覆盖率与类型质量不足 | +| 用户端 Web 产品 | **Not started / Missing main chain** | 只有管理端与分享页,没有用户端自助闭环 | + +--- + +## 7. 当前最准确的项目标签 + +### 不准确标签 + +- “完整产品” +- “双流程(人工 + Web 自助)已完成” +- “工程门禁完善、可稳定交付” + +### 更准确标签 + +> **高考志愿填报运营与审核增强系统(已具备后台、订单、分享、渠道同步、AI审核主链),但用户端 Web 自助产品与工程化质量门禁仍未闭环。** + +--- + +## 8. 当前 Gate 结论 + +### 代码层 Gate + +- `pytest`: ✅ +- `ruff`: ✅ +- `mypy`: ❌ +- `bandit`: ⚠️(有噪声,但存在真实中高风险) +- `coverage`: ⚠️(总量与关键模块均未达理想值) + +### 产品/范围 Gate + +- 场景A(人工服务): ✅ 基本闭环 +- 场景B(用户端 Web 自助): ❌ 未闭环 +- 文档真相: ❌ 漂移严重 + +### 综合 Gate + +**结论: REQUEST_CHANGES / 条件通过,不可宣称整体完成。** + +--- + +## 9. 最短整改路径 + +1. **先修真相层** + - 统一 README / PRD / ROADMAP / IMPLEMENTATION_PLAN_v2 状态口径 + - 旧报告降级为历史快照 +2. **再补工程门禁** + - 修 CI 依赖装配 + - 设定 mypy 可执行范围与配置 + - 在 CI 中加入 ruff / mypy / bandit / coverage fail-under +3. **再修真实安全问题** + - X-Forwarded-For 信任边界 + - 默认 admin 口令与登录节流 + - CSV 公式注入 +4. **最后再决定产品方向** + - 要么收缩对外口径,承认当前是运营系统 + - 要么继续把用户端 Web 自助闭环真正落地 + +--- + +## 10. 本报告与旧报告的关系 + +- 本报告是 **2026-06-13 的新鲜实跑结论** +- `docs/AUDIT_REPORT_2026-06-11.md`、`docs/REMEDIATION_TASK_BOARD_2026-06-11.md`、`reports/PRODUCT_TECH_REVIEW_2026-06-12.md` 仅保留历史参考价值 +- 之后若继续推进,应以本报告与新的整改任务板作为当前真相源 diff --git a/reports/code-analysis-review-input-2026-06-12.md b/reports/code-analysis-review-input-2026-06-12.md new file mode 100644 index 0000000..02e5288 --- /dev/null +++ b/reports/code-analysis-review-input-2026-06-12.md @@ -0,0 +1,261 @@ +# 🔍 Deep Code Analysis Report + +**Generated:** 2026-06-12T19:18:10.577328 + +**Path:** /home/long/project/gaokao-volunteer-system + + +## 📋 Executive Summary + +- **Total Files:** 107 +- **Total Lines:** 25,582 +- **Architecture Style:** MVC +- **Entry Points:** 1 +- **Data Models:** 123 +- **Business Rules:** 218 +- **External Dependencies:** 66 + + +## 🏗️ Architecture + +**Style:** MVC + +**Layers/Modules:** + +- `admin/` +- `data/` +- `scripts/` +- `tests/` +- `skills/` + +## 🚀 Entry Points & Execution Flow + + +### main + +- **Location:** `admin/app.py` +- **Parameters:** argv +- **Business Logic:** ❌ No +- **Calls:** ArgumentParser, add_argument, add_argument, add_argument, add_argument + +## 📊 Data Models + + +### Core Entities + + +**UserOrderRecord** (`admin/users.py`) + + +**CaseRecord** (`data/cases/models.py`) + + +### DTOs/Value Objects + +- **OrderSummaryResponse** - admin/routes/orders.py +- **OrderMutationResponse** - admin/routes/orders.py +- **CreateOrderRequest** - admin/routes/orders.py +- **UpdateOrderRequest** - admin/routes/orders.py +- **UserSummaryResponse** - admin/routes/users.py + +## 📜 Business Rules + + +### Validation Rules (200) + +**rule_1:** Validation in login + +- Location: `locustfile.py:login` +- Priority: medium +- Condition: `if not token: + resp.failure("login response missing access_token") + se...` + +**rule_2:** Validation in hash_password + +- Location: `admin/password.py:hash_password` +- Priority: high +- Condition: `if not plain: + raise ValueError("password cannot be empty") + salt = secrets.token_bytes(_S...` + +**rule_3:** Validation in verify_password + +- Location: `admin/password.py:verify_password` +- Priority: high +- Condition: `if not plain or not stored or _STORED_SEPARATOR not in stored: + return False + salt_hex, ha...` + +**rule_4:** Validation in authenticate + +- Location: `admin/db.py:authenticate` +- Priority: medium +- Condition: `if result is None: + return None + user, password_hash = result + if not user.is_active: + ...` + +**rule_5:** Validation in authenticate + +- Location: `admin/db.py:authenticate` +- Priority: medium +- Condition: `if not user.is_active: + return None + if not verify_password(password, password_hash): + ...` + + +### Constraint Rules (18) + +**rule_9:** Business constraint in log_event + +- Location: `admin/logging_utils.py:log_event` +- Priority: critical +- Condition: `if not event: + raise ValueError("log_event: 'event' is required") + + safe_fields: Dict[str,...` + +**rule_12:** Business constraint in log_event_exc + +- Location: `admin/logging_utils.py:log_event_exc` +- Priority: critical +- Condition: `if not event: + raise ValueError("log_event_exc: 'event' is required") + safe_fields: Dict[s...` + +**rule_54:** Business constraint in main + +- Location: `skills/gaokao-audit/scripts/validate_template.py:main` +- Priority: critical +- Condition: `if needle not in text: + print(f"FAIL: placeholder missing: {needle!r}") + retur...` + +**rule_55:** Business constraint in main + +- Location: `skills/gaokao-audit/scripts/validate_template.py:main` +- Priority: critical +- Condition: `if needle not in rendered: + print(f"FAIL: rendered output missing: {needle!r}") + ...` + +**rule_68:** Business constraint in base62_encode + +- Location: `data/share/short_link.py:base62_encode` +- Priority: critical +- Condition: `if num < 0: + raise ValueError("num must be >= 0") + if num == 0: + return BASE62_ALPH...` + + +## 🔗 External Dependencies + + +### Other Dependencies + +- locust +- datetime +- secrets +- dataclasses +- argparse +- urllib +- xianyu_adapter +- admin +- fastapi +- tempfile +- uuid +- collections +- schema +- json +- jinja2 + +## 💧 Data Flows + +- **external** → **admin/password.py:verify_password** + - Data: plain, stored + - Trigger: function_call +- **external** → **admin/db.py:create** + - Data: username, password, role + - Trigger: function_call +- **external** → **admin/db.py:update_last_login** + - Data: user_id + - Trigger: function_call +- **external** → **admin/app.py:_validate_and_log_settings** + - Data: settings + - Trigger: function_call +- **external** → **admin/app.py:create_app** + - Data: settings + - Trigger: function_call +- **external** → **admin/stats.py:generate_day_series** + - Data: db_path + - Trigger: function_call +- **external** → **scripts/gaokao-quick-3min.py:generate_quick_summary** + - Data: info + - Trigger: function_call +- **external** → **scripts/gaokao-quick-3min.py:generate_quick_recommendation** + - Data: info + - Trigger: function_call +- **external** → **scripts/gaokao-visual-report-v2.py:generate_student_radar** + - Data: student_profile + - Trigger: function_call +- **external** → **scripts/gaokao-visual-report-v2.py:generate_school_comparison** + - Data: volunteer_list + - Trigger: function_call + +## 🛤️ Key Execution Paths + + +### main + +Entry point: admin/app.py + +**Steps:** + +1. `main` + +### main + +Entry point: scripts/gaokao-quick-3min.py + +**Steps:** + +1. `main` + +### run + +Entry point: scripts/gaokao-collect-info.py + +**Steps:** + +1. `run` +2. `main` +3. `_git` +4. `_run_cli` +5. `_remote_head` +6. `test_supports_async_function` +7. `test_audit_report_template_renders` +8. `_init_repo` +... and 2 more + +## 💡 Recommendations + + +### For Understanding This Codebase + +1. Start with entry points listed above +2. Review core entities and their relationships +3. Trace execution paths for key features +4. Review business rules for domain logic +5. Check external dependencies for integration points + + +### For Code Quality + +1. Add documentation to entry points +2. Document business rules explicitly +3. Create architecture decision records (ADRs) +4. Add data flow diagrams diff --git a/reports/perf/T11.1_PERF_REPORT.md b/reports/perf/T11.1_PERF_REPORT.md new file mode 100644 index 0000000..10b4a8d --- /dev/null +++ b/reports/perf/T11.1_PERF_REPORT.md @@ -0,0 +1,89 @@ +# T11.1 性能基准报告 + +日期:2026-06-12 +仓库:`/home/long/project/gaokao-volunteer-system` +目标:T6.1 FastAPI 管理后台骨架 + +## 1. 测试范围 + +使用 `locustfile.py` 对以下端点进行 10 并发基准: + +- `POST /api/auth/login` +- `GET /api/auth/me` +- `GET /api/meta` +- `GET /api/orders` +- `GET /api/orders/{id}`(404 视为成功路径) +- `GET /api/stats/orders` +- `GET /health` + +用户模型: + +- `AdminBrowseUser`:已登录后台浏览用户 +- `LoginBurstUser`:登录高峰用户 + +## 2. 测试环境 + +- Python: 3.11.13 +- Locust: 2.42.4 +- 服务启动命令:`python3 -m admin.app --host 127.0.0.1 --port 18081` +- 测试命令:`locust -f locustfile.py --host http://127.0.0.1:18081 --headless -u 10 -r 2 -t 30s --csv reports/perf/t11_1` +- 数据库:`/home/long/project/gaokao-volunteer-system/.tmp/t11_1_admin.db` +- JWT secret:本地临时测试值(64 个 `x`) + +## 3. 发现与修复 + +首次执行失败,根因是 `AuthenticatedHttpUser` 作为基类未标记为抽象类,Locust 会把它当成可实例化用户,导致: + +- 异常:`No tasks defined on AuthenticatedHttpUser` +- 结果:进程退出码 1,压测结果不可信 + +已修复: + +- `locustfile.py` 中为 `AuthenticatedHttpUser` 增加 `abstract = True` + +修复后重新执行,压测正常结束,`failures.csv` 与 `exceptions.csv` 均为空。 + +## 4. 结果摘要 + +总体: + +- 总请求数:327 +- 失败数:0 +- 错误率:0.00% +- 平均吞吐:11.27 req/s +- 聚合中位延迟:2 ms +- 聚合平均延迟:4.62 ms +- 聚合 P95:27 ms +- 聚合 P99:36 ms +- 聚合最大值:43.53 ms + +分端点: + +| 端点 | 请求数 | 失败 | 平均延迟(ms) | P95(ms) | 最大(ms) | 吞吐(req/s) | +| -------------------------- | -----: | ---: | -----------: | ------: | -------: | ----------: | +| POST /api/auth/login | 27 | 0 | 28.53 | 38 | 43.53 | 0.93 | +| GET /api/auth/me | 71 | 0 | 2.52 | 3 | 4.49 | 2.45 | +| GET /api/meta | 55 | 0 | 2.38 | 3 | 3.37 | 1.90 | +| GET /api/orders | 105 | 0 | 2.51 | 3 | 4.44 | 3.62 | +| GET /api/orders/{id} [404] | 15 | 0 | 2.61 | 4 | 3.98 | 0.52 | +| GET /api/stats/orders | 44 | 0 | 2.51 | 3 | 4.19 | 1.52 | +| GET /health | 10 | 0 | 1.77 | 2 | 2.04 | 0.34 | + +## 5. 结论 + +结论:在本地单机、10 并发、30 秒 headless Locust 基准下,T6.1 管理后台骨架表现稳定,0 失败,所有 GET 路由 P95 ≤ 4ms,登录接口 P95 为 38ms,满足“先建立可压测基线”的 T11.1 目标。 + +当前限制: + +- 这是本地 SQLite + 单进程 uvicorn 基线,不代表生产容量上限。 +- 当前业务仍是 T6.1 骨架,`/api/orders`、`/api/stats/orders` 返回占位数据;后续真实业务逻辑接入后需重跑基准。 +- 当前测试时长为 30 秒,适合基线验证;若要做发布前容量评估,建议补 5 分钟持续压测与更高并发阶梯测试。 + +## 6. 产物 + +- `locustfile.py` +- `reports/perf/T11.1_PERF_REPORT.md` +- `reports/perf/t11_1_stats.csv` +- `reports/perf/t11_1_stats_history.csv` +- `reports/perf/t11_1_failures.csv` +- `reports/perf/t11_1_exceptions.csv` diff --git a/reports/perf/t11_1_exceptions.csv b/reports/perf/t11_1_exceptions.csv new file mode 100644 index 0000000..5e0e870 --- /dev/null +++ b/reports/perf/t11_1_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/reports/perf/t11_1_failures.csv b/reports/perf/t11_1_failures.csv new file mode 100644 index 0000000..f87ff75 --- /dev/null +++ b/reports/perf/t11_1_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences diff --git a/reports/perf/t11_1_stats.csv b/reports/perf/t11_1_stats.csv new file mode 100644 index 0000000..c946810 --- /dev/null +++ b/reports/perf/t11_1_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /api/auth/me,71,0,2,2.523277889081264,1.5841139829717577,4.48689199401997,144.0,2.4466548941768274,0.0,2,3,3,3,3,3,4,4,4,4,4 +GET,GET /api/meta,55,0,2,2.3830690172458575,1.2161129852756858,3.3656150044407696,461.0,1.8952960447848664,0.0,2,3,3,3,3,3,3,3,3,3,3 +GET,GET /api/orders,105,0,2,2.5105509222374254,1.2903409951832145,4.43644801271148,2.0,3.618292449134745,0.0,2,3,3,3,3,3,4,4,4,4,4 +GET,GET /api/orders/{id} [404],15,0,2,2.613766002468765,1.9536199979484081,3.9837899967096746,231.0,0.5168989213049635,0.0,2,3,3,3,3,4,4,4,4,4,4 +GET,GET /api/stats/orders,44,0,2,2.507629911468195,1.0961600055452436,4.19115400291048,109.0,1.516236835827893,0.0,2,3,3,3,3,3,4,4,4,4,4 +GET,GET /health,10,0,2,1.7654583003604785,1.2791189947165549,2.0439459767658263,145.0,0.3445992808699757,0.0,2,2,2,2,2,2,2,2,2,2,2 +POST,POST /api/auth/login,27,0,28,28.52843043985087,22.156253980938345,43.52652397938073,254.0,0.9304180583489343,0.0,28,28,29,29,38,38,44,44,44,44,44 +,Aggregated,327,0,2,4.621693528836183,1.0961600055452436,43.52652397938073,160.11620795107032,11.268396484448205,0.0,2,3,3,3,4,27,29,36,44,44,44 diff --git a/reports/perf/t11_1_stats_history.csv b/reports/perf/t11_1_stats_history.csv new file mode 100644 index 0000000..c6078ce --- /dev/null +++ b/reports/perf/t11_1_stats_history.csv @@ -0,0 +1,27 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1781251791,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1781251792,2,,Aggregated,0.000000,0.000000,2,2,38,38,44,44,44,44,44,44,44,6,0,2,14.866700997420898,1.2903409951832145,43.52652397938073,180.33333333333334 +1781251793,4,,Aggregated,0.000000,0.000000,3,3,36,38,38,44,44,44,44,44,44,13,0,3,13.504982072985372,1.2903409951832145,43.52652397938073,144.92307692307693 +1781251794,6,,Aggregated,4.000000,0.000000,2,3,29,31,38,38,44,44,44,44,44,24,0,2,11.799663666655155,1.2903409951832145,43.52652397938073,177.29166666666666 +1781251795,8,,Aggregated,4.500000,0.000000,3,3,23,28,36,38,44,44,44,44,44,34,0,2,10.288498705074957,1.2903409951832145,43.52652397938073,171.85294117647058 +1781251796,10,,Aggregated,5.666667,0.000000,3,3,23,26,31,38,44,44,44,44,44,48,0,3,9.592486706120932,1.2903409951832145,43.52652397938073,178.9375 +1781251797,10,,Aggregated,6.750000,0.000000,2,3,4,24,29,38,38,44,44,44,44,60,0,2,8.91246349880627,1.2903409951832145,43.52652397938073,185.55 +1781251798,10,,Aggregated,7.600000,0.000000,3,3,3,4,28,36,38,44,44,44,44,71,0,3,7.98847935118214,1.2903409951832145,43.52652397938073,186.50704225352112 +1781251799,10,,Aggregated,8.166667,0.000000,3,3,3,4,28,31,38,44,44,44,44,83,0,3,7.185863492959908,1.2903409951832145,43.52652397938073,171.78313253012047 +1781251800,10,,Aggregated,8.571429,0.000000,2,3,3,3,28,31,38,44,44,44,44,94,0,2,6.878889744179918,1.2791189947165549,43.52652397938073,178.29787234042553 +1781251801,10,,Aggregated,8.875000,0.000000,2,3,3,3,28,29,38,38,44,44,44,110,0,2,6.229009380595843,1.2791189947165549,43.52652397938073,171.1818181818182 +1781251802,10,,Aggregated,9.555556,0.000000,2,3,3,3,27,29,38,38,44,44,44,120,0,2,6.133288874116261,1.2791189947165549,43.52652397938073,179.81666666666666 +1781251803,10,,Aggregated,9.700000,0.000000,2,3,3,3,24,29,38,38,44,44,44,131,0,2,5.826537418916807,1.2791189947165549,43.52652397938073,179.35114503816794 +1781251804,10,,Aggregated,10.700000,0.000000,2,3,3,3,23,28,38,38,44,44,44,145,0,2,5.491272874923166,1.2791189947165549,43.52652397938073,168.82758620689654 +1781251805,10,,Aggregated,11.100000,0.000000,2,3,3,3,23,28,36,38,44,44,44,154,0,2,5.473512369049289,1.2791189947165549,43.52652397938073,165.54545454545453 +1781251806,10,,Aggregated,11.400000,0.000000,2,3,3,3,23,28,36,38,44,44,44,165,0,2,5.437436156595747,1.2791189947165549,43.52652397938073,168.8 +1781251807,10,,Aggregated,11.900000,0.000000,2,3,3,3,23,28,36,38,44,44,44,179,0,2,5.3285276193715445,1.2791189947165549,43.52652397938073,162.72625698324023 +1781251808,10,,Aggregated,11.800000,0.000000,2,3,3,3,23,28,36,38,44,44,44,190,0,2,5.157791936040954,1.2791189947165549,43.52652397938073,161.5157894736842 +1781251809,10,,Aggregated,11.900000,0.000000,2,3,3,3,23,28,31,38,44,44,44,201,0,2,5.251868059354792,1.2791189947165549,43.52652397938073,160.5870646766169 +1781251810,10,,Aggregated,12.000000,0.000000,2,3,3,3,23,28,31,38,44,44,44,210,0,2,5.14158705232798,1.2791189947165549,43.52652397938073,160.3761904761905 +1781251811,10,,Aggregated,11.900000,0.000000,2,3,3,3,4,28,31,38,44,44,44,223,0,2,5.090079448450014,1.2791189947165549,43.52652397938073,159.96860986547085 +1781251812,10,,Aggregated,11.500000,0.000000,2,3,3,3,4,28,31,38,44,44,44,235,0,2,4.947939795625218,1.0961600055452436,43.52652397938073,158.68085106382978 +1781251813,10,,Aggregated,11.500000,0.000000,2,3,3,3,4,28,31,38,44,44,44,247,0,2,4.933227951188764,1.0961600055452436,43.52652397938073,160.04453441295547 +1781251814,10,,Aggregated,11.200000,0.000000,2,3,3,3,4,28,29,38,44,44,44,258,0,2,4.820793891251593,1.0961600055452436,43.52652397938073,157.42635658914728 +1781251815,10,,Aggregated,11.500000,0.000000,2,3,3,3,4,28,29,38,44,44,44,273,0,2,4.69094451266126,1.0961600055452436,43.52652397938073,159.91575091575092 +1781251816,10,,Aggregated,11.800000,0.000000,2,3,3,3,4,27,29,38,44,44,44,283,0,2,4.613383886833797,1.0961600055452436,43.52652397938073,162.8939929328622 diff --git a/requirements-admin.txt b/requirements-admin.txt new file mode 100644 index 0000000..ca1303c --- /dev/null +++ b/requirements-admin.txt @@ -0,0 +1,9 @@ +# T6.1 管理后台运行依赖 +# 与 requirements-dev.txt 分离,避免把 FastAPI 运行栈混入纯标准库主链路。 +# 安装示例:pip install -r requirements-admin.txt -r requirements-dev.txt + +fastapi==0.133.1 +uvicorn==0.41.0 +PyJWT==2.10.1 +pydantic==2.13.4 +cryptography>=43,<46 diff --git a/requirements-dev.txt b/requirements-dev.txt index f4b7774..c59dfb3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,6 +6,11 @@ # 测试框架 pytest>=7.0 +pytest-benchmark>=4.0 pytest-cov>=4.0 pytest-timeout>=2.1 pytest-xdist>=3.0 +httpx>=0.28,<1.0 + +# 性能测试(T11.1 / T5.2) +locust>=2.42,<3.0 diff --git a/scripts/gaokao-audit b/scripts/gaokao-audit new file mode 100755 index 0000000..4946870 --- /dev/null +++ b/scripts/gaokao-audit @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""gaokao-audit CLI wrapper.""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +main = importlib.import_module("skills.gaokao-audit.scripts.audit_cli").main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gaokao-channel-fallback b/scripts/gaokao-channel-fallback new file mode 100644 index 0000000..41e5f36 --- /dev/null +++ b/scripts/gaokao-channel-fallback @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""gaokao-channel-fallback — T8.4 渠道兜底巡检入口。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from data.channel_sync.monitor import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gaokao-collect-info.py b/scripts/gaokao-collect-info.py index 61e51c3..ac4fddd 100644 --- a/scripts/gaokao-collect-info.py +++ b/scripts/gaokao-collect-info.py @@ -1,11 +1,9 @@ # 高考志愿填报 - 交互式信息收集系统 # 优先级2:优化信息收集体验 -import os import json -import sys from datetime import datetime -from typing import Dict, List, Any, Optional +from typing import Any class GaokaoInfoCollector: @@ -13,20 +11,20 @@ class GaokaoInfoCollector: 高考志愿填报信息收集器 采用分步骤引导式交互,确保收集完整、准确的信息 """ - + def __init__(self): self.data = { - "basic_info": {}, # 基本信息 - "exam_info": {}, # 考试信息 - "interest_profile": {}, # 兴趣测评 - "ability_assessment": {}, # 能力评估 - "career_goals": {}, # 职业目标 - "family_background": {}, # 家庭背景 - "preferences": {}, # 偏好设置 + "basic_info": {}, # 基本信息 + "exam_info": {}, # 考试信息 + "interest_profile": {}, # 兴趣测评 + "ability_assessment": {}, # 能力评估 + "career_goals": {}, # 职业目标 + "family_background": {}, # 家庭背景 + "preferences": {}, # 偏好设置 } self.current_step = 0 self.total_steps = 7 - + def welcome(self): """欢迎界面""" print(""" @@ -45,7 +43,7 @@ class GaokaoInfoCollector: ╚══════════════════════════════════════════════════════════════════╝ """) input("\n按 Enter 键开始...") - + def step_1_basic_info(self) -> bool: """ 步骤1:基本信息收集 @@ -57,7 +55,7 @@ class GaokaoInfoCollector: ║ 请填写考生的基本信息 ║ ╚══════════════════════════════════════════════════════════════════╝ """) - + # 姓名 while True: name = input("1. 考生姓名:").strip() @@ -65,16 +63,40 @@ class GaokaoInfoCollector: self.data["basic_info"]["name"] = name break print(" ⚠️ 姓名不能为空,请重新输入") - + # 省份 provinces = [ - "北京", "天津", "河北", "山西", "内蒙古", - "辽宁", "吉林", "黑龙江", "上海", "江苏", - "浙江", "安徽", "福建", "江西", "山东", - "河南", "湖北", "湖南", "广东", "广西", - "海南", "重庆", "四川", "贵州", "云南", - "西藏", "陕西", "甘肃", "青海", "宁夏", - "新疆" + "北京", + "天津", + "河北", + "山西", + "内蒙古", + "辽宁", + "吉林", + "黑龙江", + "上海", + "江苏", + "浙江", + "安徽", + "福建", + "江西", + "山东", + "河南", + "湖北", + "湖南", + "广东", + "广西", + "海南", + "重庆", + "四川", + "贵州", + "云南", + "西藏", + "陕西", + "甘肃", + "青海", + "宁夏", + "新疆", ] while True: print("\n2. 所在省份(输入编号):") @@ -85,20 +107,22 @@ class GaokaoInfoCollector: try: choice = int(input("\n请选择:")) if 1 <= choice <= len(provinces): - self.data["basic_info"]["province"] = provinces[choice-1] + self.data["basic_info"]["province"] = provinces[choice - 1] break except ValueError: pass print(" ⚠️ 无效选择,请重新输入") - + # 手机号(可选,用于接收报告) phone = input("\n3. 家长手机号(可选,用于接收报告):").strip() if phone: self.data["basic_info"]["phone"] = phone - - print(f"\n✅ 基本信息已收集:{self.data['basic_info']['name']},{self.data['basic_info']['province']}") + + print( + f"\n✅ 基本信息已收集:{self.data['basic_info']['name']},{self.data['basic_info']['province']}" + ) return True - + def step_2_exam_info(self) -> bool: """ 步骤2:考试信息收集 @@ -111,13 +135,13 @@ class GaokaoInfoCollector: ║ 这些信息将用于精准匹配院校和专业 ║ ╚══════════════════════════════════════════════════════════════════╝ """) - + # 高考模式 print("\n1. 高考模式:") print(" 1. 传统文理分科") print(" 2. 3+3 模式(浙江、上海、北京、天津、山东、海南)") print(" 3. 3+1+2 模式(其他新高考省份)") - + while True: try: mode = int(input("请选择:")) @@ -128,19 +152,21 @@ class GaokaoInfoCollector: self.data["exam_info"]["mode"] = "3+3" elif mode == 3: self.data["exam_info"]["mode"] = "3+1+2" - self.data["exam_info"]["main_subject"] = input(" 首选科目(物理/历史):").strip() + self.data["exam_info"]["main_subject"] = input( + " 首选科目(物理/历史):" + ).strip() else: raise ValueError break except (ValueError, IndexError): print(" ⚠️ 无效选择,请重新输入") - + # 选科组合 print("\n2. 选科组合(已选科目,空格分隔):") print(" 可选:语文 数学 英语 物理 历史 化学 生物 政治 地理 技术") subjects_input = input(" 请输入:").strip() self.data["exam_info"]["subjects"] = subjects_input.split() - + # 高考总分 while True: try: @@ -151,7 +177,7 @@ class GaokaoInfoCollector: print(" ⚠️ 分数应在0-750之间") except ValueError: print(" ⚠️ 请输入有效数字") - + # 全省位次 while True: try: @@ -162,7 +188,7 @@ class GaokaoInfoCollector: print(" ⚠️ 位次应为正数") except ValueError: print(" ⚠️ 请输入有效数字") - + # 各科成绩(可选,用于能力评估) print("\n5. 各科成绩(可选,用于精准匹配):") subjects_score = {} @@ -174,10 +200,10 @@ class GaokaoInfoCollector: except ValueError: pass self.data["exam_info"]["subject_scores"] = subjects_score - + print(f"\n✅ 高考信息已收集:{score}分,位次:{rank}") return True - + def step_3_interest_profile(self) -> bool: """ 步骤3:兴趣测评(霍兰德模型) @@ -190,11 +216,11 @@ class GaokaoInfoCollector: ║ 帮助匹配最适合的专业方向 ║ ╚══════════════════════════════════════════════════════════════════╝ """) - + print("\n💡 说明:请选择最符合实际情况的选项") print("-" * 60) - - questions = [ + + questions: list[dict[str, Any]] = [ { "q": "课余时间,你最喜欢做什么?", "options": [ @@ -204,7 +230,7 @@ class GaokaoInfoCollector: ("D", "和朋友出去玩、组织活动", "S"), ("E", "做小生意、策划活动、当组织者", "E"), ("F", "整理房间、做规划、记账", "C"), - ] + ], }, { "q": "如果完全不用担心钱,你最想做什么?", @@ -215,7 +241,7 @@ class GaokaoInfoCollector: ("D", "做志愿者、帮助他人", "S"), ("E", "创办公司、当企业家", "E"), ("F", "做管理、让一切井井有条", "C"), - ] + ], }, { "q": "你在哪些事情上容易'忘记时间'?", @@ -226,7 +252,7 @@ class GaokaoInfoCollector: ("D", "和人交流、帮助别人解决问题", "S"), ("E", "策划、组织、领导项目", "E"), ("F", "整理数据、制定计划", "C"), - ] + ], }, { "q": "你明确不喜欢什么类型的事情?(多选)", @@ -238,23 +264,23 @@ class GaokaoInfoCollector: ("D", "做实验、动手操作", "experiment"), ("E", "写代码、学技术", "tech"), ("F", "我都能接受", "none"), - ] - } + ], + }, ] - + holland_scores = {"R": 0, "I": 0, "A": 0, "S": 0, "E": 0, "C": 0} - dislikes = [] - + dislikes: list[str] = [] + for idx, question in enumerate(questions, 1): print(f"\n{idx}. {question['q']}") - for opt, text, code in question['options']: + for opt, text, code in question["options"]: print(f" {opt}. {text}") - - if question.get('multi'): + + if question.get("multi"): # 多选 answer = input("请选择(可多选,如:ABC):").strip().upper() for char in answer: - for opt, text, code in question['options']: + for opt, text, code in question["options"]: if char == opt: if code == "none": dislikes = [] @@ -265,7 +291,7 @@ class GaokaoInfoCollector: # 单选 while True: answer = input("请选择:").strip().upper() - for opt, text, code in question['options']: + for opt, text, code in question["options"]: if answer == opt: holland_scores[code] += 1 break @@ -273,34 +299,36 @@ class GaokaoInfoCollector: print(" ⚠️ 无效选择") continue break - + # 确定主导兴趣类型 sorted_types = sorted(holland_scores.items(), key=lambda x: x[1], reverse=True) primary_type = sorted_types[0][0] secondary_type = sorted_types[1][0] - + type_names = { "R": "现实型(Realistic)- 动手操作、技术实践", "I": "研究型(Investigative)- 分析研究、探索发现", "A": "艺术型(Artistic)- 创意表达、艺术创作", "S": "社会型(Social)- 帮助他人、教育服务", "E": "企业型(Enterprising)- 领导管理、商业经营", - "C": "常规型(Conventional)- 规范有序、数据处理" + "C": "常规型(Conventional)- 规范有序、数据处理", } - - self.data["interest_profile"]["holland_code"] = f"{primary_type}{secondary_type}" + + self.data["interest_profile"]["holland_code"] = ( + f"{primary_type}{secondary_type}" + ) self.data["interest_profile"]["primary_type"] = type_names[primary_type] self.data["interest_profile"]["secondary_type"] = type_names[secondary_type] self.data["interest_profile"]["scores"] = holland_scores self.data["interest_profile"]["dislikes"] = dislikes - - print(f"\n✅ 兴趣测评完成:") + + print("\n✅ 兴趣测评完成:") print(f" 主导类型:{type_names[primary_type]}") print(f" 次要类型:{type_names[secondary_type]}") if dislikes: print(f" 明确不喜欢:{', '.join(dislikes)}") return True - + def step_4_ability_assessment(self) -> bool: """ 步骤4:能力评估 @@ -313,14 +341,14 @@ class GaokaoInfoCollector: ║ 帮助识别优势和短板 ║ ╚══════════════════════════════════════════════════════════════════╝ """) - + print("\n请对以下方面进行自我评估(1-5分):") print("1分=很弱 2分=较弱 3分=一般 4分=较强 5分=很强") print("-" * 60) - + subjects = ["数学", "物理", "化学", "生物", "语文", "英语"] subject_scores = {} - + for subject in subjects: while True: try: @@ -331,7 +359,7 @@ class GaokaoInfoCollector: print(" ⚠️ 请输入1-5之间的数字") except ValueError: print(" ⚠️ 请输入有效数字") - + print("\n软技能评估:") soft_skills = { "逻辑思维": "分析问题的能力", @@ -339,9 +367,9 @@ class GaokaoInfoCollector: "沟通表达": "与人交流、演讲、写作的能力", "组织协调": "组织活动、管理项目的能力", "艺术创作": "绘画、设计、音乐、创意能力", - "抗压能力": "面对压力和挫折的承受力" + "抗压能力": "面对压力和挫折的承受力", } - + soft_skill_scores = {} for skill, desc in soft_skills.items(): while True: @@ -353,23 +381,23 @@ class GaokaoInfoCollector: print(" ⚠️ 请输入1-5之间的数字") except ValueError: print(" ⚠️ 请输入有效数字") - + # 识别强项和弱项 strong_subjects = [s for s, score in subject_scores.items() if score >= 4] weak_subjects = [s for s, score in subject_scores.items() if score <= 2] - + self.data["ability_assessment"]["subjects"] = subject_scores self.data["ability_assessment"]["soft_skills"] = soft_skill_scores self.data["ability_assessment"]["strong"] = strong_subjects self.data["ability_assessment"]["weak"] = weak_subjects - - print(f"\n✅ 能力评估完成:") + + print("\n✅ 能力评估完成:") if strong_subjects: print(f" 优势学科:{', '.join(strong_subjects)}") if weak_subjects: print(f" 薄弱学科:{', '.join(weak_subjects)}") return True - + def step_5_career_goals(self) -> bool: """ 步骤5:职业目标 @@ -381,7 +409,7 @@ class GaokaoInfoCollector: ║ 了解考生的职业期望和发展目标 ║ ╚══════════════════════════════════════════════════════════════════╝ """) - + print("\n1. 您最看重职业的哪些方面?(按重要性排序,输入编号如:135)") priorities = [ "收入高、赚钱多", @@ -390,11 +418,11 @@ class GaokaoInfoCollector: "发展前景好、晋升空间大", "工作生活平衡、不加班", "社会地位高、受尊重", - "工作自由、时间灵活" + "工作自由、时间灵活", ] for i, p in enumerate(priorities, 1): print(f" {i}. {p}") - + priority_input = input("\n请选择(按重要性排序,如:135):").strip() selected_priorities = [] for char in priority_input[:3]: # 取前3个 @@ -405,36 +433,36 @@ class GaokaoInfoCollector: except ValueError: pass self.data["career_goals"]["priorities"] = selected_priorities - + print("\n2. 毕业后规划:") print(" 1. 本科毕业直接工作") print(" 2. 考研/保研继续深造") print(" 3. 出国留学") print(" 4. 考公务员/事业单位") print(" 5. 不确定") - + while True: try: plan = int(input("请选择:")) if 1 <= plan <= 5: plans = ["本科就业", "考研深造", "出国留学", "考公务员", "不确定"] - self.data["career_goals"]["plan"] = plans[plan-1] + self.data["career_goals"]["plan"] = plans[plan - 1] break except ValueError: pass print(" ⚠️ 无效选择") - + print("\n3. 是否有明确的职业方向?(可选)") print(" 例:医生、程序员、教师、律师、设计师等") career = input("请输入:").strip() if career: self.data["career_goals"]["target_career"] = career - - print(f"\n✅ 职业目标已收集:") + + print("\n✅ 职业目标已收集:") print(f" 优先看重:{', '.join(selected_priorities[:3])}") print(f" 毕业规划:{self.data['career_goals']['plan']}") return True - + def step_6_family_background(self) -> bool: """ 步骤6:家庭背景 @@ -446,25 +474,25 @@ class GaokaoInfoCollector: ║ 了解家庭资源和约束条件 ║ ╚══════════════════════════════════════════════════════════════════╝ """) - + print("\n1. 家庭经济情况:") print(" 1. 困难(需要助学贷款/奖学金)") print(" 2. 一般(能负担学费,其他需节省)") print(" 3. 中等(学费生活费无压力)") print(" 4. 较好(可支持考研/出国)") print(" 5. 富裕(无经济压力)") - + while True: try: level = int(input("请选择:")) if 1 <= level <= 5: levels = ["困难", "一般", "中等", "较好", "富裕"] - self.data["family_background"]["economic"] = levels[level-1] + self.data["family_background"]["economic"] = levels[level - 1] break except ValueError: pass print(" ⚠️ 无效选择") - + print("\n2. 期望就业城市:") print(" 1. 一线城市(北上广深)") print(" 2. 新一线城市(杭州、南京、成都等)") @@ -472,24 +500,28 @@ class GaokaoInfoCollector: print(" 4. 家乡/本地城市") print(" 5. 不限定") print(" 6. 其他(请填写)") - + city_choice = input("请选择:").strip() if city_choice == "6": city = input("请填写期望城市:").strip() else: cities = ["一线城市", "新一线城市", "省会城市", "家乡/本地", "不限定", ""] - city = cities[int(city_choice)-1] if city_choice.isdigit() and 1 <= int(city_choice) <= 6 else "不限定" + city = ( + cities[int(city_choice) - 1] + if city_choice.isdigit() and 1 <= int(city_choice) <= 6 + else "不限定" + ) self.data["family_background"]["target_city"] = city - + print("\n3. 是否有家族行业资源可利用?(可选)") print(" 例:父母在医疗系统、家族经商、有法律资源等") resource = input("请输入:").strip() if resource: self.data["family_background"]["family_resource"] = resource - - print(f"\n✅ 家庭情况已收集") + + print("\n✅ 家庭情况已收集") return True - + def step_7_preferences(self) -> bool: """ 步骤7:偏好设置 @@ -501,24 +533,24 @@ class GaokaoInfoCollector: ║ 最后的个性化设置 ║ ╚══════════════════════════════════════════════════════════════════╝ """) - + print("\n1. 院校层次偏好:") print(" 1. 优先985/211(名校优先)") print(" 2. 优先专业实力(专业排名优先)") print(" 3. 优先地理位置(城市优先)") print(" 4. 综合平衡") - + while True: try: pref = int(input("请选择:")) if 1 <= pref <= 4: prefs = ["名校优先", "专业优先", "城市优先", "综合平衡"] - self.data["preferences"]["school_priority"] = prefs[pref-1] + self.data["preferences"]["school_priority"] = prefs[pref - 1] break except ValueError: pass print(" ⚠️ 无效选择") - + print("\n2. 专业方向偏好:(可多选)") directions = [ "工科类(计算机、电子、机械等)", @@ -528,7 +560,7 @@ class GaokaoInfoCollector: "文科类(中文、外语、新闻等)", "艺术类(设计、音乐、美术等)", "师范类(教育、心理等)", - "政法类(法学、政治等)" + "政法类(法学、政治等)", ] for i, d in enumerate(directions, 1): print(f" {i}. {d}") @@ -542,26 +574,26 @@ class GaokaoInfoCollector: except ValueError: pass self.data["preferences"]["major_directions"] = selected_dirs - + print("\n3. 是否接受调剂:") print(" 1. 必须服从调剂(确保录取)") print(" 2. 可接受部分调剂") print(" 3. 不服从调剂(优先专业)") - + while True: try: adjust = int(input("请选择:")) if 1 <= adjust <= 3: adjusts = ["必须服从", "部分接受", "不服从"] - self.data["preferences"]["adjustment"] = adjusts[adjust-1] + self.data["preferences"]["adjustment"] = adjusts[adjust - 1] break except ValueError: pass print(" ⚠️ 无效选择") - - print(f"\n✅ 偏好设置完成") + + print("\n✅ 偏好设置完成") return True - + def review_and_confirm(self) -> bool: """ 信息回顾与确认 @@ -573,59 +605,56 @@ class GaokaoInfoCollector: ║ 请核对以下信息,确认无误后生成报告 ║ ╚══════════════════════════════════════════════════════════════════╝ """) - - print(f"\n【基本信息】") + + print("\n【基本信息】") print(f" 姓名:{self.data['basic_info']['name']}") print(f" 省份:{self.data['basic_info']['province']}") - - print(f"\n【高考信息】") + + print("\n【高考信息】") print(f" 模式:{self.data['exam_info']['mode']}") print(f" 总分:{self.data['exam_info']['total_score']}分") print(f" 位次:{self.data['exam_info']['rank']}") - - print(f"\n【兴趣类型】") + + print("\n【兴趣类型】") print(f" 霍兰德代码:{self.data['interest_profile']['holland_code']}") print(f" 主导类型:{self.data['interest_profile']['primary_type']}") - - print(f"\n【能力评估】") - strong = self.data['ability_assessment']['strong'] + + print("\n【能力评估】") + strong = self.data["ability_assessment"]["strong"] if strong: print(f" 优势:{', '.join(strong)}") - - print(f"\n【职业目标】") + + print("\n【职业目标】") print(f" 规划:{self.data['career_goals']['plan']}") - - print(f"\n【家庭情况】") + + print("\n【家庭情况】") print(f" 经济:{self.data['family_background']['economic']}") print(f" 目标城市:{self.data['family_background']['target_city']}") - + confirm = input("\n以上信息是否正确?(Y/n):").strip().lower() - return confirm != 'n' - + return confirm != "n" + def save_data(self, filename=None): """ 保存数据到文件 """ if filename is None: filename = f"/tmp/gaokao_profile_{self.data['basic_info']['name']}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" - - self.data["meta"] = { - "created_at": datetime.now().isoformat(), - "version": "2.0" - } - - with open(filename, 'w', encoding='utf-8') as f: + + self.data["meta"] = {"created_at": datetime.now().isoformat(), "version": "2.0"} + + with open(filename, "w", encoding="utf-8") as f: json.dump(self.data, f, ensure_ascii=False, indent=2) - + print(f"\n💾 数据已保存到:{filename}") return filename - + def run(self): """ 运行完整收集流程 """ self.welcome() - + steps = [ ("基本信息", self.step_1_basic_info), ("高考信息", self.step_2_exam_info), @@ -635,21 +664,21 @@ class GaokaoInfoCollector: ("家庭情况", self.step_6_family_background), ("偏好设置", self.step_7_preferences), ] - + for idx, (name, step_func) in enumerate(steps, 1): self.current_step = idx if not step_func(): print(f"\n❌ 步骤 {idx} 未完成,退出收集") return False - + # 确认信息 if not self.review_and_confirm(): print("\n请重新运行程序修改信息") return False - + # 保存数据 data_file = self.save_data() - + print(""" ╔══════════════════════════════════════════════════════════════════╗ ║ ✅ 信息收集完成! ║ @@ -661,7 +690,7 @@ class GaokaoInfoCollector: ║ ║ ╚══════════════════════════════════════════════════════════════════╝ """) - + return data_file @@ -669,8 +698,8 @@ class GaokaoInfoCollector: if __name__ == "__main__": collector = GaokaoInfoCollector() data_file = collector.run() - + if data_file: print(f"\n数据文件:{data_file}") print("\n提示:您可以使用以下命令生成可视化报告:") - print(f" python3 ~/.local/bin/gaokao-visual-report-v2.py") + print(" python3 ~/.local/bin/gaokao-visual-report-v2.py") diff --git a/scripts/gaokao-data-trace b/scripts/gaokao-data-trace new file mode 100755 index 0000000..0f72a02 --- /dev/null +++ b/scripts/gaokao-data-trace @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""gaokao-data-trace — T3.4 数据溯源 CLI 入口。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from data.crowd_db.cli import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gaokao-order-manager b/scripts/gaokao-order-manager new file mode 100755 index 0000000..0215eb1 --- /dev/null +++ b/scripts/gaokao-order-manager @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""gaokao-order-manager — T4.3 订单管理 CLI 入口。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from data.orders.cli import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gaokao-quick-3min.py b/scripts/gaokao-quick-3min.py index a3a6816..c5ecc81 100644 --- a/scripts/gaokao-quick-3min.py +++ b/scripts/gaokao-quick-3min.py @@ -107,91 +107,123 @@ def parse_quick_response(text: str) -> dict: 解析快速问卷回复 支持多种格式:带编号、无编号、紧凑型 """ - info = { + info: dict[str, dict[str, object]] = { "basic": {}, "exam": {}, "profile": {}, - "constraints": {} + "constraints": {}, } - - lines = [l.strip() for l in text.strip().split('\n') if l.strip()] - + + lines = [line.strip() for line in text.strip().split("\n") if line.strip()] + # 类型映射 type_map = { - 'R': ('现实型', ['计算机', '电子', '机械', '自动化']), - 'I': ('研究型', ['数学', '物理', '医学', '化学']), - 'A': ('艺术型', ['设计', '建筑', '传媒', '中文']), - 'S': ('社会型', ['师范', '医学', '心理学', '社工']), - 'E': ('企业型', ['管理', '经济', '金融', '法学']), - 'C': ('常规型', ['会计', '统计', '财务', '信息']) + "R": ("现实型", ["计算机", "电子", "机械", "自动化"]), + "I": ("研究型", ["数学", "物理", "医学", "化学"]), + "A": ("艺术型", ["设计", "建筑", "传媒", "中文"]), + "S": ("社会型", ["师范", "医学", "心理学", "社工"]), + "E": ("企业型", ["管理", "经济", "金融", "法学"]), + "C": ("常规型", ["会计", "统计", "财务", "信息"]), } - + for line in lines: # 尝试各种格式提取 - + # 格式1: "1. 李明" 或 "1、李明" 或 "1 李明" if line[0].isdigit() and len(line) > 2: - parts = line.replace('、', ' ').replace('.', ' ').replace(':', ' ').split() + parts = line.replace("、", " ").replace(".", " ").replace(":", " ").split() if len(parts) >= 2: key_num = parts[0] - value = ' '.join(parts[1:]) - - if key_num == '1': + value = " ".join(parts[1:]) + + if key_num == "1": # 可能包含省份 - if ' ' in value or '\t' in value: - parts2 = value.replace('\t', ' ').split() + if " " in value or "\t" in value: + parts2 = value.replace("\t", " ").split() info["basic"]["name"] = parts2[0] if len(parts2) > 1: info["basic"]["province"] = parts2[1] else: info["basic"]["name"] = value - elif key_num == '2': - # 分数和位次 - # 尝试提取数字 - import re - numbers = re.findall(r'\d+', value) - if len(numbers) >= 1: - info["exam"]["score"] = int(numbers[0]) - if len(numbers) >= 2: - info["exam"]["rank"] = int(numbers[1]) - # 省份可能在这里 - provinces = ['北京','天津','河北','山西','内蒙古','辽宁','吉林','黑龙江', - '上海','江苏','浙江','安徽','福建','江西','山东','河南', - '湖北','湖南','广东','广西','海南','重庆','四川','贵州', - '云南','西藏','陕西','甘肃','青海','宁夏','新疆'] + elif key_num == "2": + # 省份 + provinces = [ + "北京", + "天津", + "河北", + "山西", + "内蒙古", + "辽宁", + "吉林", + "黑龙江", + "上海", + "江苏", + "浙江", + "安徽", + "福建", + "江西", + "山东", + "河南", + "湖北", + "湖南", + "广东", + "广西", + "海南", + "重庆", + "四川", + "贵州", + "云南", + "西藏", + "陕西", + "甘肃", + "青海", + "宁夏", + "新疆", + ] for p in provinces: if p in value: info["basic"]["province"] = p break - - elif key_num == '3': - # 选科 - info["exam"]["subjects"] = value - - elif key_num == '4' or key_num == '5': - # 类型或不喜欢 - if value.upper() in type_map: - info["profile"]["type_code"] = value.upper() - info["profile"]["type_name"] = type_map[value.upper()][0] - info["profile"]["recommended_majors"] = type_map[value.upper()][1] else: - # 可能是不喜欢的内容 - info["profile"]["dislikes"] = value - - elif key_num == '6': - info["profile"]["strong_subjects"] = value - - elif key_num in ['7', '8', '9', '10']: + info["basic"]["province"] = value.replace("省", "").strip() + elif key_num == "3": + # 分数 + import re + + numbers = re.findall(r"\d+", value) + if numbers: + info["exam"]["score"] = int(numbers[0]) + elif key_num == "4": + # 位次 + import re + + numbers = re.findall(r"\d+", value) + if numbers: + info["exam"]["rank"] = int(numbers[0]) + elif key_num == "5": + # 兴趣类型 + code = value.upper() + if code in type_map: + info["profile"]["type_code"] = code + info["profile"]["type_name"] = type_map[code][0] + info["profile"]["recommended_majors"] = type_map[code][1] + elif key_num == "6": + # 优势学科 + info["profile"]["strong_subjects"] = value.replace("、", " ") + elif key_num == "7": + # 不喜欢的内容 + info["profile"]["dislikes"] = value + elif key_num in ["8", "9", "10"]: # 选填项 - if '经济' in line or key_num == '8': + if "经济" in line or key_num == "8": info["constraints"]["economic"] = value - elif '毕业' in line or key_num == '9': + elif "毕业" in line or key_num == "9": info["constraints"]["plan"] = value - elif '工作' in line or key_num == '10': + elif "工作" in line or key_num == "10": info["constraints"]["city"] = value - + # 格式2: 紧凑型 "李明 浙江 612分 15230名 物化地 R 不接受社交" - elif ' ' in line and len(line.split()) >= 3: + elif " " in line and len(line.split()) >= 3: parts = line.split() # 尝试识别各部分 for i, part in enumerate(parts): @@ -199,21 +231,52 @@ def parse_quick_response(text: str) -> dict: if i == 0 and len(part) >= 2 and part.isalpha(): info["basic"]["name"] = part # 省份 - elif part.replace('省', '') in ['北京','天津','河北','山西','内蒙古','辽宁', - '吉林','黑龙江','上海','江苏','浙江','安徽','福建','江西', - '山东','河南','湖北','湖南','广东','广西','海南','重庆', - '四川','贵州','云南','西藏','陕西','甘肃','青海','宁夏','新疆']: - info["basic"]["province"] = part.replace('省', '') + elif part.replace("省", "") in [ + "北京", + "天津", + "河北", + "山西", + "内蒙古", + "辽宁", + "吉林", + "黑龙江", + "上海", + "江苏", + "浙江", + "安徽", + "福建", + "江西", + "山东", + "河南", + "湖北", + "湖南", + "广东", + "广西", + "海南", + "重庆", + "四川", + "贵州", + "云南", + "西藏", + "陕西", + "甘肃", + "青海", + "宁夏", + "新疆", + ]: + info["basic"]["province"] = part.replace("省", "") # 分数(带"分") - elif '分' in part: + elif "分" in part: import re - nums = re.findall(r'\d+', part) + + nums = re.findall(r"\d+", part) if nums: info["exam"]["score"] = int(nums[0]) # 位次(带"名"或"位次") - elif '名' in part or '位次' in part: + elif "名" in part or "位次" in part: import re - nums = re.findall(r'\d+', part) + + nums = re.findall(r"\d+", part) if nums: info["exam"]["rank"] = int(nums[0]) # 兴趣类型 @@ -222,22 +285,22 @@ def parse_quick_response(text: str) -> dict: info["profile"]["type_code"] = code info["profile"]["type_name"] = type_map[code][0] info["profile"]["recommended_majors"] = type_map[code][1] - + return info def generate_quick_summary(info: dict) -> str: """生成快速摘要""" lines = [] - lines.append("\n" + "="*50) + lines.append("\n" + "=" * 50) lines.append("📋 快速信息汇总") - lines.append("="*50) - + lines.append("=" * 50) + # 基本信息 name = info.get("basic", {}).get("name", "未知") province = info.get("basic", {}).get("province", "未知") lines.append(f"\n👤 {name} | {province}") - + # 考试信息 score = info.get("exam", {}).get("score") rank = info.get("exam", {}).get("rank") @@ -245,7 +308,7 @@ def generate_quick_summary(info: dict) -> str: lines.append(f"📊 高考:{score}分") if rank: lines.append(f"📊 位次:{rank}名") - + # 兴趣类型 type_name = info.get("profile", {}).get("type_name") type_code = info.get("profile", {}).get("type_code") @@ -254,25 +317,25 @@ def generate_quick_summary(info: dict) -> str: lines.append(f"\n🎯 兴趣类型:{type_name} ({type_code})") if majors: lines.append(f" 推荐专业:{'、'.join(majors)}") - + # 强项学科 strong = info.get("profile", {}).get("strong_subjects") if strong: lines.append(f"💪 优势学科:{strong}") - + # 不喜欢 dislikes = info.get("profile", {}).get("dislikes") if dislikes: lines.append(f"❌ 应避免:{dislikes}") - + # 约束 plan = info.get("constraints", {}).get("plan") city = info.get("constraints", {}).get("city") if plan or city: lines.append(f"\n📍 规划:{plan or '未指定'} | 地域:{city or '未指定'}") - - lines.append("\n" + "="*50) - + + lines.append("\n" + "=" * 50) + # 缺失信息提醒 missing = [] if not info.get("basic", {}).get("name"): @@ -283,109 +346,121 @@ def generate_quick_summary(info: dict) -> str: missing.append("位次") if not info.get("profile", {}).get("type_code"): missing.append("兴趣类型") - + if missing: lines.append(f"⚠️ 还需补充:{', '.join(missing)}") else: lines.append("✅ 核心信息完整!可以开始推荐") - - return '\n'.join(lines) + + return "\n".join(lines) def generate_quick_recommendation(info: dict) -> str: """基于快速信息生成初步推荐""" lines = [] - lines.append("\n" + "="*50) + lines.append("\n" + "=" * 50) lines.append("🎯 初步志愿建议") - lines.append("="*50) - + lines.append("=" * 50) + # 基于霍兰德类型的推荐 type_code = info.get("profile", {}).get("type_code") strong = info.get("profile", {}).get("strong_subjects", "") dislikes = info.get("profile", {}).get("dislikes", "") - + recommendations = { - 'R': { - 'majors': ['计算机科学与技术', '软件工程', '电子信息工程', '自动化', '机械设计'], - 'reason': '喜欢动手操作,适合工科技术类专业', - 'caution': '数学物理不能弱' + "R": { + "majors": [ + "计算机科学与技术", + "软件工程", + "电子信息工程", + "自动化", + "机械设计", + ], + "reason": "喜欢动手操作,适合工科技术类专业", + "caution": "数学物理不能弱", }, - 'I': { - 'majors': ['数学与应用数学', '物理学', '临床医学', '生物科学', '化学'], - 'reason': '喜欢研究探索,适合理科或医学类', - 'caution': '需深造,本科就业面窄' + "I": { + "majors": ["数学与应用数学", "物理学", "临床医学", "生物科学", "化学"], + "reason": "喜欢研究探索,适合理科或医学类", + "caution": "需深造,本科就业面窄", }, - 'A': { - 'majors': ['数字媒体技术', '建筑学', '工业设计', '视觉传达设计', '网络与新媒体'], - 'reason': '有创造力,适合设计或创意类专业', - 'caution': '纯艺术就业难,推荐技术+艺术结合' + "A": { + "majors": [ + "数字媒体技术", + "建筑学", + "工业设计", + "视觉传达设计", + "网络与新媒体", + ], + "reason": "有创造力,适合设计或创意类专业", + "caution": "纯艺术就业难,推荐技术+艺术结合", }, - 'S': { - 'majors': ['临床医学', '师范类', '心理学', '护理学', '社会工作'], - 'reason': '喜欢助人,适合医学或教育类专业', - 'caution': '医学需长期投入,师范稳定但收入一般' + "S": { + "majors": ["临床医学", "师范类", "心理学", "护理学", "社会工作"], + "reason": "喜欢助人,适合医学或教育类专业", + "caution": "医学需长期投入,师范稳定但收入一般", }, - 'E': { - 'majors': ['工商管理', '经济学', '金融学', '法学', '市场营销'], - 'reason': '有领导力,适合商科或管理类专业', - 'caution': '非名校就业难,竞争激烈' + "E": { + "majors": ["工商管理", "经济学", "金融学", "法学", "市场营销"], + "reason": "有领导力,适合商科或管理类专业", + "caution": "非名校就业难,竞争激烈", + }, + "C": { + "majors": ["会计学", "统计学", "财务管理", "信息管理与信息系统"], + "reason": "喜欢规范有序,适合财会或统计类专业", + "caution": "AI替代风险,需持续学习", }, - 'C': { - 'majors': ['会计学', '统计学', '财务管理', '信息管理与信息系统'], - 'reason': '喜欢规范有序,适合财会或统计类专业', - 'caution': 'AI替代风险,需持续学习' - } } - + if type_code and type_code in recommendations: rec = recommendations[type_code] lines.append(f"\n【基于兴趣类型 {type_code}】") lines.append(f"{rec['reason']}") - lines.append(f"\n📚 推荐专业方向:") - for i, major in enumerate(rec['majors'][:4], 1): + lines.append("\n📚 推荐专业方向:") + for i, major in enumerate(rec["majors"][:4], 1): lines.append(f" {i}. {major}") lines.append(f"\n⚠️ 注意事项:{rec['caution']}") - + # 基于强学科的推荐 if strong: - lines.append(f"\n【基于优势学科】") - if '物理' in strong and '数学' in strong: + lines.append("\n【基于优势学科】") + if "物理" in strong and "数学" in strong: lines.append("物理数学强 → 计算机、电子信息、自动化") - elif '物理' in strong: + elif "物理" in strong: lines.append("物理强 → 工科类专业均可") - elif '数学' in strong: + elif "数学" in strong: lines.append("数学强 → 计算机、金融、统计、数学类") - elif '化学' in strong: + elif "化学" in strong: lines.append("化学强 → 医学、药学、材料、化工") - elif '生物' in strong: + elif "生物" in strong: lines.append("生物强 → 医学、生物科学、农学") - elif '语文' in strong or '英语' in strong: + elif "语文" in strong or "英语" in strong: lines.append("文科强 → 师范、法学、外语、新闻传播") - + # 基于不喜欢的排除 if dislikes: - lines.append(f"\n【反向排除】") - if '数学' in dislikes or '计算' in dislikes: + lines.append("\n【反向排除】") + if "数学" in dislikes or "计算" in dislikes: lines.append("❌ 不喜欢数学 → 避开计算机、金融、人工智能") - if '背诵' in dislikes or '记忆' in dislikes: + if "背诵" in dislikes or "记忆" in dislikes: lines.append("❌ 不喜欢背诵 → 避开法学、医学、文史哲") - if '社交' in dislikes or '应酬' in dislikes: + if "社交" in dislikes or "应酬" in dislikes: lines.append("❌ 不喜欢社交 → 避开市场营销、管理、师范") - if '实验' in dislikes: + if "实验" in dislikes: lines.append("❌ 不喜欢实验 → 避开化学、生物、材料") - - lines.append("\n" + "="*50) - - return '\n'.join(lines) + + lines.append("\n" + "=" * 50) + + return "\n".join(lines) def main(): """主函数 - 输出问卷模板""" print(QUICK_3MIN_TEMPLATE) - - print("\n" + "="*50) + + print("\n" + "=" * 50) print("或者使用极速版(1分钟):") - print("="*50) + print("=" * 50) print(ULTRA_1MIN_TEMPLATE) diff --git a/scripts/gaokao-shortlink b/scripts/gaokao-shortlink index 573f799..3bdf166 100755 --- a/scripts/gaokao-shortlink +++ b/scripts/gaokao-shortlink @@ -3,12 +3,14 @@ gaokao-shortlink — T7.1 短链接生成器命令行工具 子命令: - create 创建短链接 - resolve 解析短链接 (查询/校验) - revoke 撤销短链接 - list 列出某报告 / 某用户的所有链接 - stats 查看某链接的访问统计 - purge 清理过期记录 (维护) + create 创建短链接 + resolve 解析短链接 (查询/校验) + revoke 撤销单条短链接 + revoke-report 按 report 批量撤销短链接 + list 列出某报告 / 某用户的所有链接 + stats 查看某链接的访问统计 + stats-report 查看某报告的访问统计 + purge 清理过期记录 (维护) 示例: # 创建: 关联报告 R-2026-001, 30 天有效, 只读权限 @@ -26,13 +28,15 @@ gaokao-shortlink — T7.1 短链接生成器命令行工具 # 撤销 python scripts/gaokao-shortlink revoke ABC123 --owner alice + python scripts/gaokao-shortlink revoke-report --report-id R-2026-001 --owner alice # 列表 python scripts/gaokao-shortlink list --report R-2026-001 python scripts/gaokao-shortlink list --owner alice # 统计 - python scripts/gaokao-shortlink stats ABC123 + python scripts/gaokao-shortlink stats ABC123 --days 7 + python scripts/gaokao-shortlink stats-report --report-id R-2026-001 --days 7 """ import argparse @@ -152,6 +156,19 @@ def cmd_revoke(args) -> int: return 0 +def cmd_revoke_report(args) -> int: + """按 report 批量撤销短链接""" + svc = ShortLinkService(db_path=args.db) + revoked = svc.revoke_by_report(args.report_id, owner_id=args.owner) + payload = { + "report_id": args.report_id, + "owner_id": args.owner, + "revoked_count": revoked, + } + _out(payload, as_json=not args.human) + return 0 if revoked > 0 else 1 + + def cmd_list(args) -> int: """列出某报告 / 某用户的链接""" if not args.report and not args.owner: @@ -174,7 +191,7 @@ def cmd_list(args) -> int: def cmd_stats(args) -> int: """查看某链接访问统计""" svc = ShortLinkService(db_path=args.db) - stats = svc.get_stats(args.code) + stats = svc.get_stats(args.code, days=args.days) if stats is None: print(f"未找到 code={args.code}", file=sys.stderr) return 1 @@ -182,6 +199,14 @@ def cmd_stats(args) -> int: return 0 +def cmd_stats_report(args) -> int: + """查看某报告的分享访问统计""" + svc = ShortLinkService(db_path=args.db) + stats = svc.get_report_stats(args.report_id, owner_id=args.owner, days=args.days) + _out(stats, as_json=not args.human) + return 0 + + def cmd_purge(args) -> int: """清理过期记录""" svc = ShortLinkService(db_path=args.db) @@ -242,6 +267,12 @@ def build_parser() -> argparse.ArgumentParser: px.add_argument("--owner", help="owner 校验 (可选, 不传则不强校)") px.set_defaults(func=cmd_revoke) + # ---- revoke-report ---- + pxx = sub.add_parser("revoke-report", help="按 report 批量撤销短链接") + pxx.add_argument("--report-id", required=True, help="关联报告 ID") + pxx.add_argument("--owner", help="owner 校验 (可选)") + pxx.set_defaults(func=cmd_revoke_report) + # ---- list ---- pl = sub.add_parser("list", help="列出某报告/某用户的链接") pl.add_argument("--report", help="按 report_id 过滤") @@ -252,8 +283,16 @@ def build_parser() -> argparse.ArgumentParser: # ---- stats ---- ps = sub.add_parser("stats", help="查看短链接统计") ps.add_argument("code", help="短码") + ps.add_argument("--days", type=int, default=7, help="返回最近 N 天趋势 (默认 7)") ps.set_defaults(func=cmd_stats) + # ---- stats-report ---- + psr = sub.add_parser("stats-report", help="查看报告级分享统计") + psr.add_argument("--report-id", required=True, help="关联报告 ID") + psr.add_argument("--owner", help="按 owner_id 过滤 (可选)") + psr.add_argument("--days", type=int, default=7, help="返回最近 N 天趋势 (默认 7)") + psr.set_defaults(func=cmd_stats_report) + # ---- purge ---- pp = sub.add_parser("purge", help="清理过期记录") pp.set_defaults(func=cmd_purge) diff --git a/scripts/gaokao-sync-remotes b/scripts/gaokao-sync-remotes new file mode 100755 index 0000000..5cfc325 --- /dev/null +++ b/scripts/gaokao-sync-remotes @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""gaokao-sync-remotes — T10.3 三仓同步脚本。""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from dataclasses import dataclass + +DEFAULT_REMOTES = ("gitea", "origin", "tksea") + + +@dataclass(frozen=True) +class PushResult: + remote: str + branch: str + local_head: str + + +class SyncError(RuntimeError): + """Raised when sync preconditions or push verification fail.""" + + +def _run_git(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + capture_output=True, + text=True, + check=False, + ) + + +def _git_stdout(*args: str) -> str: + result = _run_git(*args) + if result.returncode != 0: + raise SyncError(result.stderr.strip() or f"git {' '.join(args)} failed") + return result.stdout.strip() + + +def _resolve_branch(explicit_branch: str | None) -> str: + if explicit_branch: + return explicit_branch + branch = _git_stdout("branch", "--show-current") + if not branch: + raise SyncError("detached HEAD: 请显式传入 --branch") + return branch + + +def _ensure_required_remotes(remotes: tuple[str, ...]) -> None: + configured = { + line.strip() + for line in _git_stdout("remote").splitlines() + if line.strip() + } + missing = [remote for remote in remotes if remote not in configured] + if missing: + raise SyncError(f"missing remotes: {', '.join(missing)}") + + +def _current_head(branch: str) -> str: + return _git_stdout("rev-parse", branch) + + +def _remote_head(remote: str, branch: str) -> str: + result = _run_git("ls-remote", "--heads", remote, branch) + if result.returncode != 0: + raise SyncError(result.stderr.strip() or f"git ls-remote {remote} {branch} failed") + output = result.stdout.strip() + if not output: + raise SyncError(f"remote {remote} missing branch {branch} after push") + return output.split()[0] + + +def sync_remotes(remotes: tuple[str, ...], branch: str, dry_run: bool) -> list[PushResult]: + _ensure_required_remotes(remotes) + local_head = _current_head(branch) + results: list[PushResult] = [] + + for remote in remotes: + if dry_run: + print(f"[DRY-RUN] git push {remote} {branch}") + results.append(PushResult(remote=remote, branch=branch, local_head=local_head)) + continue + + push = _run_git("push", remote, branch) + if push.returncode != 0: + stderr = push.stderr.strip() or push.stdout.strip() or f"git push {remote} {branch} failed" + raise SyncError(f"push failed for {remote}: {stderr}") + + remote_head = _remote_head(remote, branch) + if remote_head != local_head: + raise SyncError( + f"verify failed for {remote}: local {local_head} != remote {remote_head}" + ) + results.append(PushResult(remote=remote, branch=branch, local_head=local_head)) + + return results + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="gaokao-sync-remotes", + description="Push 当前分支到 gitea / origin / tksea 并校验 remote HEAD。", + ) + parser.add_argument( + "--branch", + help="要同步的本地分支,默认取当前分支", + ) + parser.add_argument( + "--remote", + dest="remotes", + action="append", + help="指定要同步的 remote,可重复传入;默认 gitea/origin/tksea", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="只打印将执行的 git push,不真正推送", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + branch = _resolve_branch(args.branch) + remotes = tuple(args.remotes or DEFAULT_REMOTES) + results = sync_remotes(remotes=remotes, branch=branch, dry_run=args.dry_run) + except SyncError as exc: + print(str(exc), file=sys.stderr) + return 2 + + if args.dry_run: + print(f"DRY-RUN OK: {branch} -> {', '.join(result.remote for result in results)}") + return 0 + + for result in results: + print(f"OK {result.remote}: {result.branch} @ {result.local_head}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gaokao-visual-report-v2.py b/scripts/gaokao-visual-report-v2.py index f6581c5..b7a8544 100644 --- a/scripts/gaokao-visual-report-v2.py +++ b/scripts/gaokao-visual-report-v2.py @@ -4,24 +4,20 @@ 新增:HTML导出、PDF导出、精美样式 """ -import json -import sys -import os -import base64 -import tempfile from datetime import datetime -from pathlib import Path # 尝试导入可选依赖 try: from jinja2 import Template + HAS_JINJA = True except ImportError: HAS_JINJA = False print("警告: jinja2 未安装,HTML模板功能将受限") try: - from weasyprint import HTML, CSS + from weasyprint import HTML # type: ignore[import-not-found] + HAS_WEASYPRINT = True except ImportError: HAS_WEASYPRINT = False @@ -38,31 +34,31 @@ def generate_student_radar(student_profile): "就业匹配度": student_profile.get("employment_match", 0), "家庭适配度": student_profile.get("family_match", 0), } - + # 综合得分计算 weighted_score = ( - scores["兴趣匹配度"] * 0.3 + - scores["能力匹配度"] * 0.3 + - scores["就业匹配度"] * 0.25 + - scores["家庭适配度"] * 0.15 + scores["兴趣匹配度"] * 0.3 + + scores["能力匹配度"] * 0.3 + + scores["就业匹配度"] * 0.25 + + scores["家庭适配度"] * 0.15 ) - + # 生成ASCII雷达图 radar_chart = f""" ┌─────────────────────────────────────────────────────────────────┐ -│ 🎯 {student_profile.get('name', '考生')} 画像雷达图 │ +│ 🎯 {student_profile.get("name", "考生")} 画像雷达图 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 兴趣匹配度 │ -│ {scores['兴趣匹配度']:>3}/10 │ +│ {scores["兴趣匹配度"]:>3}/10 │ │ │ │ -│ {min(scores['能力匹配度'], 10):>2}/10 ─────────┼───────── {min(scores['就业匹配度'], 10):>2}/10 │ +│ {min(scores["能力匹配度"], 10):>2}/10 ─────────┼───────── {min(scores["就业匹配度"], 10):>2}/10 │ │ 能力匹配度 │ 就业匹配度 │ │ │ │ │ 【{weighted_score:.1f}/10】 │ │ 综合得分 │ │ │ │ -│ {scores['家庭适配度']:>3}/10 │ +│ {scores["家庭适配度"]:>3}/10 │ │ 家庭适配度 │ │ │ ├─────────────────────────────────────────────────────────────────┤ @@ -87,19 +83,21 @@ def generate_school_comparison(volunteer_list): │ 志愿类型 │ 院校 │ 专业 │ 录取概率│ 匹配指数│ 推荐指数 │ ├────────────┼────────────┼──────────┼─────────┼─────────┼───────────┤ """ - + for idx, vol in enumerate(volunteer_list, 1): - v_type = vol.get('type', '稳') - emoji = {'冲': '🔴', '稳': '🟡', '保': '🟢'}.get(v_type, '⚪') - - prob_bar = '█' * int(vol.get('probability', 0) / 10) + '░' * (10 - int(vol.get('probability', 0) / 10)) - match_score = vol.get('match_score', 0) - stars = '⭐' * int(match_score / 20) + '☆' * (5 - int(match_score / 20)) - - table += f"""│ {emoji} {v_type:>2} {idx:>2} │ {vol.get('school', '待定'):<10} │ {vol.get('major', '待定'):<8} │ {prob_bar} │ {match_score:>3} │ {stars} │ + v_type = vol.get("type", "稳") + emoji = {"冲": "🔴", "稳": "🟡", "保": "🟢"}.get(v_type, "⚪") + + prob_bar = "█" * int(vol.get("probability", 0) / 10) + "░" * ( + 10 - int(vol.get("probability", 0) / 10) + ) + match_score = vol.get("match_score", 0) + stars = "⭐" * int(match_score / 20) + "☆" * (5 - int(match_score / 20)) + + table += f"""│ {emoji} {v_type:>2} {idx:>2} │ {vol.get("school", "待定"):<10} │ {vol.get("major", "待定"):<8} │ {prob_bar} │ {match_score:>3} │ {stars} │ ├────────────┼────────────┼──────────┼─────────┼─────────┼───────────┤ """ - + table += """│ │ │ 图例: │ │ 录取概率 █████ 90%+ ████░ 80%+ ███░░ 60%+ ██░░░ 40%+ █░░░░ <20%│ @@ -119,33 +117,33 @@ def generate_major_heatmap(majors): ├──────────────────────────────────────────────────────────────────────┤ │ │ """ - + for major in majors: - name = major.get('name', '') - score = major.get('match_score', 0) - + name = major.get("name", "") + score = major.get("match_score", 0) + # 生成热力条 if score >= 90: - bar = '█' * 20 - status = '强烈推荐' + bar = "█" * 20 + status = "强烈推荐" elif score >= 80: - bar = '█' * 17 + '░' * 3 - status = '推荐选择' + bar = "█" * 17 + "░" * 3 + status = "推荐选择" elif score >= 70: - bar = '█' * 14 + '░' * 6 - status = '可以考虑' + bar = "█" * 14 + "░" * 6 + status = "可以考虑" elif score >= 60: - bar = '█' * 11 + '░' * 9 - status = '谨慎考虑' + bar = "█" * 11 + "░" * 9 + status = "谨慎考虑" elif score >= 40: - bar = '█' * 8 + '░' * 12 - status = '不太建议' + bar = "█" * 8 + "░" * 12 + status = "不太建议" else: - bar = '█' * 5 + '░' * 15 - status = '不推荐' - + bar = "█" * 5 + "░" * 15 + status = "不推荐" + heatmap += f"│ {name:<12} {bar} {score:>3}% {status:<12} │\n" - + heatmap += """│ │ │ 热力指数:████ 90-100% ████ 80-90% ███░ 70-80% ██░░ 60-70% │ │ █░░░ 30-60% ░░░░ <30% (不推荐) │ @@ -159,44 +157,44 @@ def detect_risks(student_profile, volunteer_list): 智能风险检测 """ risks = [] - + # 检查位次差距 for vol in volunteer_list: - if vol.get('type') == '冲': - if vol.get('probability', 0) < 30: + if vol.get("type") == "冲": + if vol.get("probability", 0) < 30: risks.append({ - 'level': 'warning', - 'item': f"{vol.get('school')}录取概率过低", - 'desc': f"录取概率仅{vol.get('probability')}%,需要增加相近备选" + "level": "warning", + "item": f"{vol.get('school')}录取概率过低", + "desc": f"录取概率仅{vol.get('probability')}%,需要增加相近备选", }) - + # 检查学科匹配 - weak_subjects = student_profile.get('weak_subjects', []) + weak_subjects = student_profile.get("weak_subjects", []) for vol in volunteer_list: - required = vol.get('required_subjects', []) + required = vol.get("required_subjects", []) for subj in required: if subj in weak_subjects: risks.append({ - 'level': 'danger', - 'item': f"{vol.get('school')}-{vol.get('major')}学科不匹配", - 'desc': f"该专业需要{subj},但考生{subj}为弱项" + "level": "danger", + "item": f"{vol.get('school')}-{vol.get('major')}学科不匹配", + "desc": f"该专业需要{subj},但考生{subj}为弱项", }) - + # 检查梯度合理性 - types_count = {'冲': 0, '稳': 0, '保': 0} + types_count = {"冲": 0, "稳": 0, "保": 0} for vol in volunteer_list: - v_type = vol.get('type', '稳') + v_type = vol.get("type", "稳") types_count[v_type] = types_count.get(v_type, 0) + 1 - + total = len(volunteer_list) if total > 0: - if types_count['保'] / total < 0.2: + if types_count["保"] / total < 0.2: risks.append({ - 'level': 'danger', - 'item': '保底志愿不足', - 'desc': f"保底志愿仅占{types_count['保']/total*100:.0f}%,建议至少30%" + "level": "danger", + "item": "保底志愿不足", + "desc": f"保底志愿仅占{types_count['保'] / total * 100:.0f}%,建议至少30%", }) - + # 生成风险报告 risk_report = """ ┌──────────────────────────────────────────────────────────────────────┐ @@ -204,27 +202,33 @@ def detect_risks(student_profile, volunteer_list): ├──────────────────────────────────────────────────────────────────────┤ │ │ """ - - danger_list = [r for r in risks if r['level'] == 'danger'] - warning_list = [r for r in risks if r['level'] == 'warning'] - + + danger_list = [r for r in risks if r["level"] == "danger"] + warning_list = [r for r in risks if r["level"] == "warning"] + if danger_list: - risk_report += "│ 🔴 高风险项目(必须修改): │\n" + risk_report += ( + "│ 🔴 高风险项目(必须修改): │\n" + ) for risk in danger_list: risk_report += f"│ ✗ {risk['item']:<30} │\n" risk_report += f"│ → {risk['desc']:<50} │\n" risk_report += "│ │\n" - + if warning_list: - risk_report += "│ 🟡 中风险项目(建议调整): │\n" + risk_report += ( + "│ 🟡 中风险项目(建议调整): │\n" + ) for risk in warning_list: risk_report += f"│ ⚠ {risk['item']:<30} │\n" risk_report += f"│ → {risk['desc']:<50} │\n" risk_report += "│ │\n" - + if not risks: - risk_report += "│ 🟢 恭喜!未检测到高风险项目,当前方案可以安全填报 │\n" - + risk_report += ( + "│ 🟢 恭喜!未检测到高风险项目,当前方案可以安全填报 │\n" + ) + risk_report += """│ │ └──────────────────────────────────────────────────────────────────────┘ """ @@ -239,18 +243,18 @@ def generate_html_report(student_data, volunteer_list, output_file=None): print("错误: 需要安装 jinja2 才能生成HTML报告") print("运行: pip3 install --user jinja2") return None - + # 计算雷达图数据 radar_data = { - 'interest': student_data.get('interest_match', 0), - 'ability': student_data.get('ability_match', 0), - 'employment': student_data.get('employment_match', 0), - 'family': student_data.get('family_match', 0) + "interest": student_data.get("interest_match", 0), + "ability": student_data.get("ability_match", 0), + "employment": student_data.get("employment_match", 0), + "family": student_data.get("family_match", 0), } - radar_data['average'] = sum(radar_data.values()) / 4 - + radar_data["average"] = sum(radar_data.values()) / 4 + # HTML模板 - html_template = ''' + html_template = """ @@ -545,33 +549,36 @@ def generate_html_report(student_data, volunteer_list, output_file=None): }); -''' - +""" + template = Template(html_template) - + # 准备数据 - majors = [{'name': vol['major'], 'match_score': vol['match_score']} for vol in volunteer_list] + majors = [ + {"name": vol["major"], "match_score": vol["match_score"]} + for vol in volunteer_list + ] _, risks = detect_risks(student_data, volunteer_list) - + html_content = template.render( - name=student_data.get('name', '考生'), - province=student_data.get('province', '未知'), - score=student_data.get('score', 0), - rank=student_data.get('rank', 0), + name=student_data.get("name", "考生"), + province=student_data.get("province", "未知"), + score=student_data.get("score", 0), + rank=student_data.get("rank", 0), radar=radar_data, volunteers=volunteer_list, majors=majors, risks=risks, - generated_at=datetime.now().strftime('%Y年%m月%d日 %H:%M') + generated_at=datetime.now().strftime("%Y年%m月%d日 %H:%M"), ) - + # 保存HTML if output_file is None: output_file = f"/tmp/gaokao_report_{student_data.get('name', 'unknown')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html" - - with open(output_file, 'w', encoding='utf-8') as f: + + with open(output_file, "w", encoding="utf-8") as f: f.write(html_content) - + print(f"✓ HTML报告已生成: {output_file}") return output_file @@ -584,10 +591,10 @@ def generate_pdf_from_html(html_file, pdf_file=None): print("错误: 需要安装 weasyprint 才能生成PDF") print("运行: pip3 install --user weasyprint") return None - + if pdf_file is None: - pdf_file = html_file.replace('.html', '.pdf') - + pdf_file = html_file.replace(".html", ".pdf") + try: HTML(filename=html_file).write_pdf(pdf_file) print(f"✓ PDF报告已生成: {pdf_file}") @@ -597,31 +604,31 @@ def generate_pdf_from_html(html_file, pdf_file=None): return None -def generate_visual_report(student_data, volunteer_list, output_format='all'): +def generate_visual_report(student_data, volunteer_list, output_format="all"): """ 生成完整可视化报告(支持多种格式) - + 参数: student_data: 考生数据 volunteer_list: 志愿列表 output_format: 输出格式 ('md', 'html', 'pdf', 'all') - + 返回: 生成的文件路径列表 """ results = [] - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") base_name = f"gaokao_report_{student_data.get('name', 'unknown')}_{timestamp}" - + # 1. 生成Markdown报告 - if output_format in ['md', 'all']: + if output_format in ["md", "all"]: md_content = f"""# 高考志愿填报方案报告 -**生成时间**: {datetime.now().strftime('%Y年%m月%d日 %H:%M')} -**考生姓名**: {student_data.get('name', '考生')} -**考生省份**: {student_data.get('province', '未知')} -**高考总分**: {student_data.get('score', 0)}分 -**全省位次**: {student_data.get('rank', 0)}名 +**生成时间**: {datetime.now().strftime("%Y年%m月%d日 %H:%M")} +**考生姓名**: {student_data.get("name", "考生")} +**考生省份**: {student_data.get("province", "未知")} +**高考总分**: {student_data.get("score", 0)}分 +**全省位次**: {student_data.get("rank", 0)}名 --- @@ -631,39 +638,42 @@ def generate_visual_report(student_data, volunteer_list, output_format='all'): radar_chart, weighted_score, scores = generate_student_radar(student_data) md_content += radar_chart md_content += f"\n**综合匹配指数**: {weighted_score:.1f}/10\n\n" - + md_content += "## 二、志愿方案可视化对比\n\n" md_content += generate_school_comparison(volunteer_list) - + md_content += "## 三、专业匹配度热力图\n\n" - majors = [{'name': vol['major'], 'match_score': vol['match_score']} for vol in volunteer_list] + majors = [ + {"name": vol["major"], "match_score": vol["match_score"]} + for vol in volunteer_list + ] md_content += generate_major_heatmap(majors) - + md_content += "## 四、风险检测报告\n\n" risk_report, _ = detect_risks(student_data, volunteer_list) md_content += risk_report - + md_file = f"/tmp/{base_name}.md" - with open(md_file, 'w', encoding='utf-8') as f: + with open(md_file, "w", encoding="utf-8") as f: f.write(md_content) print(f"✓ Markdown报告已生成: {md_file}") results.append(md_file) - + # 2. 生成HTML报告 html_file = None - if output_format in ['html', 'pdf', 'all']: + if output_format in ["html", "pdf", "all"]: html_file = f"/tmp/{base_name}.html" html_file = generate_html_report(student_data, volunteer_list, html_file) if html_file: results.append(html_file) - + # 3. 生成PDF报告 - if output_format in ['pdf', 'all'] and html_file: + if output_format in ["pdf", "all"] and html_file: pdf_file = f"/tmp/{base_name}.pdf" pdf_file = generate_pdf_from_html(html_file, pdf_file) if pdf_file: results.append(pdf_file) - + return results @@ -671,61 +681,61 @@ def generate_visual_report(student_data, volunteer_list, output_format='all'): if __name__ == "__main__": # 示例考生数据 student = { - 'name': '李明', - 'province': '浙江省', - 'score': 612, - 'rank': 15230, - 'interest_match': 85, - 'ability_match': 90, - 'employment_match': 88, - 'family_match': 95, - 'weak_subjects': ['化学', '语文'] + "name": "李明", + "province": "浙江省", + "score": 612, + "rank": 15230, + "interest_match": 85, + "ability_match": 90, + "employment_match": 88, + "family_match": 95, + "weak_subjects": ["化学", "语文"], } - + # 示例志愿列表 volunteers = [ { - 'school': '浙江大学', - 'major': '计算机类', - 'type': '冲', - 'probability': 35, - 'match_score': 95, - 'required_subjects': ['数学', '物理'] + "school": "浙江大学", + "major": "计算机类", + "type": "冲", + "probability": 35, + "match_score": 95, + "required_subjects": ["数学", "物理"], }, { - 'school': '杭州电子科技大学', - 'major': '计算机类', - 'type': '稳', - 'probability': 70, - 'match_score': 92, - 'required_subjects': ['数学', '物理'] + "school": "杭州电子科技大学", + "major": "计算机类", + "type": "稳", + "probability": 70, + "match_score": 92, + "required_subjects": ["数学", "物理"], }, { - 'school': '浙江工业大学', - 'major': '软件工程', - 'type': '稳', - 'probability': 80, - 'match_score': 88, - 'required_subjects': ['数学'] + "school": "浙江工业大学", + "major": "软件工程", + "type": "稳", + "probability": 80, + "match_score": 88, + "required_subjects": ["数学"], }, { - 'school': '浙江理工大学', - 'major': '软件工程', - 'type': '保', - 'probability': 95, - 'match_score': 82, - 'required_subjects': ['数学'] - } + "school": "浙江理工大学", + "major": "软件工程", + "type": "保", + "probability": 95, + "match_score": 82, + "required_subjects": ["数学"], + }, ] - + print("=" * 60) print("高考志愿填报可视化报告生成器 V2.0") print("=" * 60) print() - + # 生成所有格式 - files = generate_visual_report(student, volunteers, output_format='all') - + files = generate_visual_report(student, volunteers, output_format="all") + print() print("=" * 60) print("生成完成!文件列表:") diff --git a/scripts/legacy/gaokao-quick-collect.py b/scripts/legacy/gaokao-quick-collect.py index 701c10b..4557767 100644 --- a/scripts/legacy/gaokao-quick-collect.py +++ b/scripts/legacy/gaokao-quick-collect.py @@ -96,46 +96,46 @@ def parse_collected_info(text: str) -> dict: """ 解析用户填写的信息 """ - info = { + info: dict[str, dict[str, object]] = { "basic_info": {}, "exam_info": {}, "interest_profile": {}, "ability_assessment": {}, "career_goals": {}, "family_background": {}, - "preferences": {} + "preferences": {}, } - - lines = text.strip().split('\n') - + + lines = text.strip().split("\n") + for line in lines: line = line.strip() if not line: continue - + # 尝试解析 "数字. 项目:答案" 格式 - if ':' in line or ':' in line: - parts = line.replace(':', ':').split(':', 1) + if ":" in line or ":" in line: + parts = line.replace(":", ":").split(":", 1) if len(parts) == 2: key = parts[0].strip() value = parts[1].strip() - + # 根据关键词分类存储 - if any(k in key for k in ['姓名', '省份']): - info['basic_info'][key] = value - elif any(k in key for k in ['总分', '位次', '选科', '模式']): - info['exam_info'][key] = value - elif any(k in key for k in ['兴趣', '喜欢', '不喜欢']): - info['interest_profile'][key] = value - elif any(k in key for k in ['学科', '能力', '擅长', '薄弱']): - info['ability_assessment'][key] = value - elif any(k in key for k in ['职业', '规划', '毕业']): - info['career_goals'][key] = value - elif any(k in key for k in ['家庭', '经济', '城市']): - info['family_background'][key] = value - elif any(k in key for k in ['偏好', '院校', '调剂']): - info['preferences'][key] = value - + if any(k in key for k in ["姓名", "省份"]): + info["basic_info"][key] = value + elif any(k in key for k in ["总分", "位次", "选科", "模式"]): + info["exam_info"][key] = value + elif any(k in key for k in ["兴趣", "喜欢", "不喜欢"]): + info["interest_profile"][key] = value + elif any(k in key for k in ["学科", "能力", "擅长", "薄弱"]): + info["ability_assessment"][key] = value + elif any(k in key for k in ["职业", "规划", "毕业"]): + info["career_goals"][key] = value + elif any(k in key for k in ["家庭", "经济", "城市"]): + info["family_background"][key] = value + elif any(k in key for k in ["偏好", "院校", "调剂"]): + info["preferences"][key] = value + return info @@ -145,20 +145,20 @@ def validate_info(info: dict) -> list: 返回缺失的必填项 """ missing = [] - + # 必填项检查 required_fields = { - '姓名': 'basic_info', - '省份': 'basic_info', - '总分': 'exam_info', - '位次': 'exam_info', + "姓名": "basic_info", + "省份": "basic_info", + "总分": "exam_info", + "位次": "exam_info", } - + for field, section in required_fields.items(): section_data = info.get(section, {}) if not any(field in k for k in section_data.keys()): missing.append(field) - + return missing @@ -166,55 +166,55 @@ def generate_summary(info: dict) -> str: """ 生成信息汇总摘要 """ - summary = ["\n" + "="*50] + summary = ["\n" + "=" * 50] summary.append("📋 考生信息汇总") - summary.append("="*50) - + summary.append("=" * 50) + # 基本信息 - basic = info.get('basic_info', {}) + basic = info.get("basic_info", {}) if basic: - summary.append(f"\n【基本信息】") + summary.append("\n【基本信息】") for k, v in basic.items(): summary.append(f" {k}:{v}") - + # 高考信息 - exam = info.get('exam_info', {}) + exam = info.get("exam_info", {}) if exam: - summary.append(f"\n【高考信息】") + summary.append("\n【高考信息】") for k, v in exam.items(): summary.append(f" {k}:{v}") - + # 兴趣测评 - interest = info.get('interest_profile', {}) + interest = info.get("interest_profile", {}) if interest: - summary.append(f"\n【兴趣类型】") + summary.append("\n【兴趣类型】") for k, v in interest.items(): summary.append(f" {k}:{v}") - + # 能力评估 - ability = info.get('ability_assessment', {}) + ability = info.get("ability_assessment", {}) if ability: - summary.append(f"\n【能力评估】") + summary.append("\n【能力评估】") for k, v in ability.items(): summary.append(f" {k}:{v}") - + # 职业目标 - career = info.get('career_goals', {}) + career = info.get("career_goals", {}) if career: - summary.append(f"\n【职业目标】") + summary.append("\n【职业目标】") for k, v in career.items(): summary.append(f" {k}:{v}") - - summary.append("\n" + "="*50) - + + summary.append("\n" + "=" * 50) + # 验证结果 missing = validate_info(info) if missing: summary.append(f"\n⚠️ 缺少必填项:{', '.join(missing)}") else: summary.append("\n✅ 必填信息完整!") - - return '\n'.join(summary) + + return "\n".join(summary) # 快捷使用函数 @@ -238,7 +238,7 @@ def process_response(user_input: str) -> str: if __name__ == "__main__": # 输出收集模板 print(COLLECTION_TEMPLATE) - + # 示例:模拟用户填写 sample_input = """ 1. 考生姓名:李明 @@ -253,9 +253,9 @@ if __name__ == "__main__": 13. 毕业规划:①本科就业 14. 家庭经济情况:③中等 """ - - print("\n" + "="*50) + + print("\n" + "=" * 50) print("解析示例输入:") - print("="*50) + print("=" * 50) result = process_response(sample_input) print(result) diff --git a/scripts/legacy/gaokao-visual-report.py b/scripts/legacy/gaokao-visual-report.py index 9365059..0ec6edc 100644 --- a/scripts/legacy/gaokao-visual-report.py +++ b/scripts/legacy/gaokao-visual-report.py @@ -3,8 +3,6 @@ 支持:雷达图、热力图、对比表、风险检测 """ -import json -import sys from datetime import datetime diff --git a/skills/gaokao-audit/SKILL.md b/skills/gaokao-audit/SKILL.md index add2664..cc88877 100644 --- a/skills/gaokao-audit/SKILL.md +++ b/skills/gaokao-audit/SKILL.md @@ -73,12 +73,12 @@ color: orange | 工具 | 路径 | 状态 | 说明 | | ----------------- | ---------------------------------------------------- | ----------- | ---------------------------- | -| `plan_parser` | `scripts/plan_parser.py` | T1.3 待实现 | 解析大厂AI方案为结构化数据 | -| `crowd_detector` | `data/crowd_db/loader.py` | T1.1 已完成 | 加载大厂AI推荐扎堆数据 | -| `spec_checker` | `skills/gaokao-spec-checker/scripts/spec_checker.py` | 已存在 | 政策合规检查 | -| `report_renderer` | `templates/audit_report.html` | 本任务创建 | HTML 报告模板(Jinja2 语法) | +| `plan_parser` | `scripts/plan_parser.py` | 已实现 | 解析大厂AI方案为结构化数据 | +| `crowd_detector` | `scripts/crowd_detector.py` | 已实现 | 复用 `data/crowd_db` 做扎堆检测 | +| `spec_checker` | `skills/gaokao-spec-checker/scripts/spec_checker.py` | 已集成 | 政策合规检查 | +| `report_renderer` | `templates/audit_report.html` | 已实现 | HTML 报告模板(Jinja2 语法) | -> T1.2 阶段只创建 skill 骨架与模板;`plan_parser` 由 T1.3 实现并补单测。 +> 当前 `gaokao-audit` 已具备 parser / checker integration / crowd detector / report generator / audit CLI 主链路,后续增量应围绕真实业务闭环与交付场景扩展,而不是再按 T1.2 骨架状态理解。 ## 📁 目录约定 diff --git a/skills/gaokao-audit/scripts/audit_cli.py b/skills/gaokao-audit/scripts/audit_cli.py new file mode 100644 index 0000000..2631222 --- /dev/null +++ b/skills/gaokao-audit/scripts/audit_cli.py @@ -0,0 +1,78 @@ +"""gaokao-audit 命令行入口。""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Sequence + +from .audit_service import AuditService +from .report_generator import ReportGenerator + +_ALLOWED_FORMATS = ("text", "pdf_text", "screenshot_ocr") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="AI 志愿方案审核工具") + parser.add_argument("input", help="方案文件路径") + parser.add_argument( + "-o", + "--output", + help="PDF 输出路径,默认与输入文件同目录同名 .pdf", + ) + parser.add_argument( + "-f", + "--format", + default="text", + choices=_ALLOWED_FORMATS, + help="输入格式", + ) + parser.add_argument( + "--json", + action="store_true", + help="额外输出审核结果 JSON", + ) + return parser + + +def default_output_path(input_path: Path) -> Path: + return input_path.with_name(f"{input_path.stem}.audit.pdf") + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(list(argv) if argv is not None else None) + input_path = Path(args.input).expanduser().resolve() + + if not input_path.exists() or not input_path.is_file(): + print(f"❌ 文件不存在: {input_path}", file=sys.stderr) + return 1 + + plan_text = input_path.read_text(encoding="utf-8") + audit_service = AuditService() + result = audit_service.audit(plan_text, format=args.format) + + output_path = ( + Path(args.output).expanduser().resolve() + if args.output + else default_output_path(input_path) + ) + pdf_path = ReportGenerator(audit_service=audit_service).generate_pdf( + result, + str(output_path), + ) + + print(f"输入文件: {input_path}") + print(f"省份: {result.province or '未识别'}") + print(f"综合评分: {result.overall_score}/100") + print(f"PDF报告: {pdf_path}") + + if args.json: + print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/gaokao-audit/scripts/audit_service.py b/skills/gaokao-audit/scripts/audit_service.py new file mode 100644 index 0000000..3f6372a --- /dev/null +++ b/skills/gaokao-audit/scripts/audit_service.py @@ -0,0 +1,236 @@ +"""gaokao-audit 审核服务主类。""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Sequence + +from .checker_integration import CheckerIntegration +from .crowd_detector import CrowdDetector, CrowdRisk +from .plan_parser import ParsedPlan, PlanParser + +_ALLOWED_FORMATS = {"text", "pdf_text", "screenshot_ocr"} + + +@dataclass +class AuditResult: + """审核结果。""" + + province: str | None = None + candidate_score: int | None = None + candidate_rank: int | None = None + subjects: str | None = None + source: str | None = None + volunteers: List[Dict[str, Any]] = field(default_factory=list) + + policy_errors: List[Dict[str, Any]] = field(default_factory=list) + policy_serious_warnings: List[Dict[str, Any]] = field(default_factory=list) + policy_general_warnings: List[Dict[str, Any]] = field(default_factory=list) + crowd_risks: List[CrowdRisk] = field(default_factory=list) + data_issues: List[Dict[str, str]] = field(default_factory=list) + suggestions: List[str] = field(default_factory=list) + + overall_score: int = 100 + + def to_dict(self) -> Dict[str, Any]: + payload = asdict(self) + payload["crowd_risks"] = [risk.to_dict() for risk in self.crowd_risks] + return payload + + +class AuditService: + """组合解析、政策检查与扎堆检测的审核主服务。""" + + def __init__( + self, + parser: PlanParser | None = None, + checker: CheckerIntegration | None = None, + detector: CrowdDetector | None = None, + ) -> None: + self.parser = parser or PlanParser() + self.checker = checker or CheckerIntegration() + self.detector = detector or CrowdDetector() + + def audit(self, plan_text: str, format: str = "text") -> AuditResult: + if format not in _ALLOWED_FORMATS: + raise ValueError(f"unsupported format: {format}") + + parsed = self.parser.parse_text(plan_text) + check_result = self.checker.check(plan_text, province=parsed.province) + crowd_risks = self._detect_crowd_risks(parsed) + data_issues = self._check_data_trace(parsed) + policy_errors = list(check_result["errors"].get("fatal", [])) + policy_serious_warnings = list(check_result["errors"].get("serious", [])) + policy_general_warnings = list(check_result["errors"].get("warning", [])) + warning_count = len(policy_general_warnings) + serious_count = len(policy_serious_warnings) + + suggestions = self._generate_suggestions( + policy_errors=policy_errors, + crowd_risks=crowd_risks, + data_issues=data_issues, + warning_count=warning_count, + serious_count=serious_count, + ) + overall_score = self._calculate_score( + policy_errors=policy_errors, + crowd_risks=crowd_risks, + data_issues=data_issues, + warning_count=warning_count, + serious_count=serious_count, + ) + + return AuditResult( + province=parsed.province, + candidate_score=parsed.score, + candidate_rank=parsed.rank, + subjects=parsed.subjects, + source=parsed.source, + volunteers=list(parsed.volunteers), + policy_errors=policy_errors, + policy_serious_warnings=policy_serious_warnings, + policy_general_warnings=policy_general_warnings, + crowd_risks=crowd_risks, + data_issues=data_issues, + suggestions=suggestions, + overall_score=overall_score, + ) + + def build_report_payload( + self, + result: AuditResult, + *, + audit_time: str, + report_id: str, + ) -> Dict[str, Any]: + payload = result.to_dict() + payload["crowd_risks"] = self.detector.format_for_report( + result.crowd_risks, + province=result.province, + ) + payload["candidate_info"] = self._build_candidate_info(result) + payload["audit_time"] = audit_time + payload["report_id"] = report_id + payload["fatal_count"] = len(result.policy_errors) + payload["warning_count"] = ( + len(result.policy_serious_warnings) + + len(result.crowd_risks) + + len(result.data_issues) + ) + payload["info_count"] = len(result.policy_general_warnings) + return payload + + def _detect_crowd_risks(self, parsed: ParsedPlan) -> List[CrowdRisk]: + if not parsed.province or not parsed.score or not parsed.volunteers: + return [] + return self.detector.detect_risks( + parsed.volunteers, + province=parsed.province, + score=parsed.score, + ) + + def _check_data_trace(self, parsed: ParsedPlan) -> List[Dict[str, str]]: + issues: List[Dict[str, str]] = [] + if not parsed.source: + issues.append( + { + "location": "AI来源", + "description": "未明确标注AI来源(千问/元宝/百度/豆包)", + "recommendation": "补充原始方案来自哪个大厂AI,避免人工复核时误判来源。", + } + ) + if ( + parsed.score + and "2025" not in parsed.raw_text + and "2024" not in parsed.raw_text + ): + issues.append( + { + "location": "分数/位次依据", + "description": "未明确数据年份(建议标注2025年参考位次)", + "recommendation": "补充分数线或位次所对应的年份,避免跨年份数据混用。", + } + ) + return issues + + def _build_candidate_info(self, result: AuditResult) -> str: + parts: List[str] = [] + if result.province: + parts.append(result.province) + if result.candidate_score is not None: + parts.append(f"{result.candidate_score}分") + if result.subjects: + parts.append(result.subjects) + return " ".join(parts) or "未提供" + + def _generate_suggestions( + self, + *, + policy_errors: Sequence[Dict[str, Any]], + crowd_risks: Sequence[CrowdRisk], + data_issues: Sequence[Dict[str, str]], + warning_count: int, + serious_count: int, + ) -> List[str]: + suggestions: List[str] = [] + + high_risks = [risk for risk in crowd_risks if risk.risk_level == "high"] + medium_risks = [risk for risk in crowd_risks if risk.risk_level == "medium"] + + if policy_errors: + suggestions.append( + f"存在 {len(policy_errors)} 个政策错误,必须修正后才能使用该方案" + ) + if serious_count: + suggestions.append( + f"存在 {serious_count} 个严重合规提醒,建议逐项人工复核院校专业组与调剂规则" + ) + if high_risks: + suggestions.append( + f"检测到 {len(high_risks)} 所高风险扎堆院校,建议优先替换为低风险替代方案" + ) + elif medium_risks: + suggestions.append( + f"检测到 {len(medium_risks)} 所中风险扎堆院校,建议调整冲稳保比例降低集中度" + ) + if data_issues: + suggestions.append("建议核实数据来源与年份,避免引用无出处或跨年份数据") + elif warning_count: + suggestions.append( + f"存在 {warning_count} 个一般提醒,建议交付前再次核对表述与填写细节" + ) + if not suggestions: + suggestions.append("方案整体风险可控,建议结合个人偏好做小幅微调") + + return suggestions + + def _calculate_score( + self, + *, + policy_errors: Sequence[Dict[str, Any]], + crowd_risks: Sequence[CrowdRisk], + data_issues: Sequence[Dict[str, str]], + warning_count: int, + serious_count: int, + ) -> int: + score = 100 + score -= len(policy_errors) * 15 + score -= serious_count * 8 + score -= warning_count * 3 + score -= len(data_issues) * 3 + + for risk in crowd_risks: + if risk.risk_level == "high": + score -= 10 + elif risk.risk_level == "medium": + score -= 5 + else: + score -= 1 + + return max(0, min(100, score)) + + +def audit_plan(plan_content: str, plan_format: str = "text") -> AuditResult: + """技术架构文档中的便捷入口。""" + + return AuditService().audit(plan_content, format=plan_format) diff --git a/skills/gaokao-audit/scripts/checker_integration.py b/skills/gaokao-audit/scripts/checker_integration.py new file mode 100644 index 0000000..8661c82 --- /dev/null +++ b/skills/gaokao-audit/scripts/checker_integration.py @@ -0,0 +1,131 @@ +"""规范检查集成。 + +复用 gaokao-spec-checker 的 27 省规则库,并把字符串报告转换为 +T1.5 可继续消费的结构化结果。 +""" + +from __future__ import annotations + +import importlib +import os +import re +import sys +from typing import Any, Dict, List + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +_SPEC_CHECKER = importlib.import_module( + "skills.gaokao-spec-checker.scripts.spec_checker_v2" +) +GaokaoSpecCheckerV2 = _SPEC_CHECKER.GaokaoSpecCheckerV2 +PROVINCE_RULES = _SPEC_CHECKER.PROVINCE_RULES +detect_province = _SPEC_CHECKER.detect_province + + +class CheckerIntegration: + """规范检查集成器。""" + + def check(self, plan_text: str, province: str | None = None) -> Dict[str, Any]: + """执行规范检查并返回结构化结果。""" + checker = GaokaoSpecCheckerV2(province=province) + report = checker.auto_detect_and_check(plan_text) + resolved_province = checker.province + supported = bool(resolved_province and resolved_province in PROVINCE_RULES) + mode = checker.province_rule["mode"] if checker.province_rule else None + + errors = self._build_errors(checker, report, supported=supported) + summary = self._summarize(errors) + + return { + "province": resolved_province, + "supported": supported, + "mode": mode, + "errors": errors, + "summary": summary, + "raw_report": report, + } + + def format_results(self, result: Dict[str, Any]) -> Dict[str, Any]: + """转成审核流水线更易消费的平铺结构。""" + return { + "province": result["province"], + "supported": result["supported"], + "mode": result["mode"], + "policy_errors": result["errors"].get("fatal", []), + "serious_errors": result["errors"].get("serious", []), + "warnings": result["errors"].get("warning", []), + "info": result["errors"].get("info", []), + "fatal_count": result["summary"]["fatal_count"], + "serious_count": result["summary"]["serious_count"], + "warning_count": result["summary"]["warning_count"], + "info_count": result["summary"]["info_count"], + "total_count": result["summary"]["total_count"], + "has_fatal": result["summary"]["fatal_count"] > 0, + } + + def _build_errors( + self, + checker: Any, + report: str, + *, + supported: bool, + ) -> Dict[str, List[Dict[str, str]]]: + if supported: + return { + "fatal": list(checker.errors.get("fatal", [])), + "serious": list(checker.errors.get("serious", [])), + "warning": list(checker.errors.get("warning", [])), + "info": [], + } + + return { + "fatal": [], + "serious": [], + "warning": [], + "info": self._extract_info_items(report), + } + + def _extract_info_items(self, report: str) -> List[Dict[str, str]]: + items: List[Dict[str, str]] = [] + for line in report.splitlines(): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("║"): + title = stripped.strip("║ ") + if title: + items.append({"description": title}) + continue + if stripped.startswith( + ( + "╔", + "╚", + "【支持检测的省份】", + "【已支持的省份】", + "【解决方式】", + "【后续计划】", + ) + ): + continue + if re.fullmatch( + r"[北京天津河北山西内蒙古辽宁吉林黑龙江上海江苏浙江安徽福建江西山东河南湖北湖南广东广西海南重庆四川贵州云南西藏陕西甘肃青海宁夏新疆、,\s]+", + stripped, + ): + continue + items.append({"description": stripped}) + return items + + def _summarize(self, errors: Dict[str, List[Dict[str, str]]]) -> Dict[str, int]: + fatal_count = len(errors.get("fatal", [])) + serious_count = len(errors.get("serious", [])) + warning_count = len(errors.get("warning", [])) + info_count = len(errors.get("info", [])) + return { + "fatal_count": fatal_count, + "serious_count": serious_count, + "warning_count": warning_count, + "info_count": info_count, + "total_count": fatal_count + serious_count + warning_count + info_count, + } diff --git a/skills/gaokao-audit/scripts/crowd_detector.py b/skills/gaokao-audit/scripts/crowd_detector.py new file mode 100644 index 0000000..cc30c87 --- /dev/null +++ b/skills/gaokao-audit/scripts/crowd_detector.py @@ -0,0 +1,153 @@ +"""gaokao-audit 扎堆检测器。 + +复用 data.crowd_db 下已经验证过的检测算法与报告格式转换, +对 audit skill 暴露稳定的类接口:CrowdDetector / CrowdRisk。 +""" + +from __future__ import annotations + +import importlib +import os +import sys +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Protocol, cast + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +_BASE_DETECTOR = importlib.import_module("data.crowd_db.crowd_detector") +_RISK_REPORT = importlib.import_module("data.crowd_db.risk_report") +_LOADER_MODULE = importlib.import_module("data.crowd_db.loader") + +_CrowdDBLoaderCtor = cast(Any, _LOADER_MODULE.CrowdDBLoader) +_RiskFindingCtor = cast(Any, _BASE_DETECTOR.RiskFinding) +_detect_crowd_risk = _BASE_DETECTOR.detect_crowd_risk +finding_to_risk_dict = _RISK_REPORT.finding_to_risk_dict + + +class _CrowdDBLoaderProto(Protocol): + def find_recommendations( + self, province: str, score: int + ) -> List[Dict[str, Any]]: ... + + def load_metadata(self, province: str) -> Dict[str, Any]: ... + + +class _RiskFindingProto(Protocol): + school: str + major: str | None + frequency: int + platforms: List[str] + predicted_increase: int + risk_level: str + alternatives: List[Dict[str, Any]] + + +@dataclass +class CrowdRisk: + """审核流水线使用的扎堆风险记录。""" + + school: str + major: str + frequency: int + platforms: List[str] = field(default_factory=list) + predicted_increase: int = 0 + risk_level: str = "low" + alternatives: List[Dict[str, Any]] = field(default_factory=list) + + @property + def risk_level_label(self) -> str: + labels = { + "high": "🔴 高风险", + "medium": "🟡 中风险", + "low": "🟢 低风险", + } + return labels.get(self.risk_level, self.risk_level) + + def to_dict(self) -> Dict[str, Any]: + return { + "school": self.school, + "major": self.major, + "frequency": self.frequency, + "platforms": list(self.platforms), + "predicted_increase": self.predicted_increase, + "risk_level": self.risk_level, + "risk_level_label": self.risk_level_label, + "alternatives": list(self.alternatives), + } + + +class CrowdDetector: + """方案扎堆风险检测器。""" + + def __init__(self, loader: Optional[_CrowdDBLoaderProto] = None): + self.loader: _CrowdDBLoaderProto = loader or _CrowdDBLoaderCtor() + + def detect_risks( + self, + volunteers: List[Dict[str, str]], + province: str, + score: int, + ) -> List[CrowdRisk]: + findings = _detect_crowd_risk( + volunteers, + user_score=score, + province=province, + loader=self.loader, + ) + return [self._from_finding(finding) for finding in findings] + + def get_risk_label(self, frequency: int) -> str: + if frequency >= 4: + return "🔴 高风险" + if frequency >= 2: + return "🟡 中风险" + return "🟢 低风险" + + def format_for_report( + self, + risks: List[CrowdRisk], + province: str | None = None, + ) -> List[Dict[str, Any]]: + formatted: List[Dict[str, Any]] = [] + for risk in risks: + payload = finding_to_risk_dict( + self._to_finding(risk), + provenance=self._load_provenance(province), + ) + # 兼容旧计划中的字段名 name,同时保留当前模板用的 school。 + payload["name"] = payload["school"] + formatted.append(payload) + return formatted + + def _from_finding(self, finding: _RiskFindingProto) -> CrowdRisk: + return CrowdRisk( + school=finding.school, + major=finding.major or "", + frequency=int(finding.frequency), + platforms=list(finding.platforms), + predicted_increase=int(finding.predicted_increase), + risk_level=finding.risk_level, + alternatives=list(finding.alternatives), + ) + + def _to_finding(self, risk: CrowdRisk) -> Any: + return _RiskFindingCtor( + school=risk.school, + major=risk.major or None, + frequency=int(risk.frequency), + risk_level=risk.risk_level, + platforms=list(risk.platforms), + predicted_increase=int(risk.predicted_increase), + alternatives=list(risk.alternatives), + ) + + def _load_provenance(self, province: str | None) -> Optional[Dict[str, Any]]: + if not province: + return None + load_metadata = getattr(self.loader, "load_metadata", None) + if not callable(load_metadata): + return None + metadata = load_metadata(province) + return metadata if isinstance(metadata, dict) else None diff --git a/skills/gaokao-audit/scripts/report_generator.py b/skills/gaokao-audit/scripts/report_generator.py new file mode 100644 index 0000000..81d39cc --- /dev/null +++ b/skills/gaokao-audit/scripts/report_generator.py @@ -0,0 +1,99 @@ +"""gaokao-audit 审核报告生成器。""" + +from __future__ import annotations + +from datetime import datetime +from importlib import import_module +from pathlib import Path +from typing import Callable +from uuid import uuid4 + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from .audit_service import AuditResult, AuditService + + +def _load_weasyprint_html(): + return import_module("weasyprint").HTML + + +class ReportGenerator: + """将 AuditResult 渲染为 HTML / PDF 报告。""" + + def __init__( + self, + *, + audit_service: AuditService | None = None, + template_name: str = "audit_report.html", + now_text: Callable[[], str] | None = None, + report_id_factory: Callable[[], str] | None = None, + ) -> None: + self.audit_service = audit_service or AuditService() + self.template_name = template_name + self.now_text = now_text or (lambda: datetime.now().strftime("%Y-%m-%d %H:%M")) + self.report_id_factory = report_id_factory or self._default_report_id + self.template_dir = Path(__file__).resolve().parent.parent / "templates" + self._environment = Environment( + loader=FileSystemLoader(str(self.template_dir)), + autoescape=select_autoescape(["html", "xml"]), + ) + + def render_html( + self, + result: AuditResult, + *, + audit_time: str | None = None, + report_id: str | None = None, + ) -> str: + payload = self.build_payload(result, audit_time=audit_time, report_id=report_id) + template = self._environment.get_template(self.template_name) + return template.render(**payload) + + def generate_html( + self, + result: AuditResult, + output_path: str, + *, + audit_time: str | None = None, + report_id: str | None = None, + ) -> str: + target = Path(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + self.render_html(result, audit_time=audit_time, report_id=report_id), + encoding="utf-8", + ) + return str(target) + + def generate_pdf( + self, + result: AuditResult, + output_path: str, + *, + audit_time: str | None = None, + report_id: str | None = None, + ) -> str: + target = Path(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + + html = self.render_html(result, audit_time=audit_time, report_id=report_id) + html_cls = _load_weasyprint_html() + html_cls(string=html, base_url=str(self.template_dir)).write_pdf(str(target)) + return str(target) + + def build_payload( + self, + result: AuditResult, + *, + audit_time: str | None = None, + report_id: str | None = None, + ) -> dict: + return self.audit_service.build_report_payload( + result, + audit_time=audit_time or self.now_text(), + report_id=report_id or self.report_id_factory(), + ) + + def _default_report_id(self) -> str: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + return f"AUDIT-{stamp}-{uuid4().hex[:6].upper()}" diff --git a/skills/gaokao-audit/scripts/validate_template.py b/skills/gaokao-audit/scripts/validate_template.py index b855bf4..56c53c2 100644 --- a/skills/gaokao-audit/scripts/validate_template.py +++ b/skills/gaokao-audit/scripts/validate_template.py @@ -30,6 +30,10 @@ REQUIRED_PLACEHOLDERS = [ "{% if crowd_risks %}", "{% if data_issues %}", "{% if suggestions %}", + "risk.source_type_icon", + "risk.source_type_label", + "risk.confidence", + "risk.last_updated", "policy_errors", "crowd_risks", "data_issues", @@ -83,6 +87,16 @@ def main() -> int: "predicted_increase": 18, "risk_level": "high", "risk_level_label": "高", + "risk_emoji": "🔴", + "source_type": "report", + "raw_source_type": "manual_summary", + "source_type_icon": "⚠️", + "source_type_label": "报告", + "source": "千问/元宝/百度/豆包 公开推荐汇总(手动整理)", + "source_url": "https://example.com/hunan.json", + "confidence": 0.85, + "last_updated": "2026-06-12", + "data_year": 2025, "alternatives": [ {"school": "湖南工商大学", "score": 95}, {"school": "湖北经济学院", "score": 92}, @@ -144,6 +158,8 @@ def main() -> int: "HUN-001", "长沙理工大学", "录取概率80%", + "⚠️ 报告", + "2026-06-12", "升级到完整方案", "免责声明", ] diff --git a/skills/gaokao-audit/templates/audit_report.html b/skills/gaokao-audit/templates/audit_report.html index 60b8533..658c2ff 100644 --- a/skills/gaokao-audit/templates/audit_report.html +++ b/skills/gaokao-audit/templates/audit_report.html @@ -39,6 +39,26 @@ .risk-high { color: #d63031; font-weight: bold; } .risk-medium { color: #e17055; font-weight: bold; } .risk-low { color: #00b894; } + .source-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; + } + .source-official { background: #e8f5e9; color: #1b5e20; } + .source-report { background: #fff3e0; color: #e65100; } + .source-estimated { background: #e3f2fd; color: #0d47a1; } + .provenance-meta { + font-size: 12px; + line-height: 1.5; + color: #546e7a; + } + .provenance-meta a { + color: #1976d2; + word-break: break-all; + } table { width: 100%; border-collapse: collapse; @@ -186,6 +206,7 @@ 推荐频次 预测上涨 风险等级 + 数据溯源 替代方案 {% for risk in crowd_risks %} @@ -194,7 +215,18 @@ {{ risk.major }} {{ risk.frequency }}/4 +{{ risk.predicted_increase }} 分 - {{ risk.risk_level_label }} + {{ risk.risk_emoji }} {{ risk.risk_level_label }} + +
    {{ risk.source_type_icon }} {{ risk.source_type_label }}
    +
    + 置信度:{{ '%.2f'|format(risk.confidence) if risk.confidence is not none else '未标注' }}
    + 数据日期:{{ risk.last_updated or '未标注' }}{% if risk.data_year %}({{ risk.data_year }}){% endif %}
    + 来源:{{ risk.source or '未标注' }} + {% if risk.source_url %} +
    链接:{{ risk.source_url }} + {% endif %} +
    + {% for alt in risk.alternatives %} • {{ alt.school }}({{ alt.score }} 分)
    diff --git a/skills/gaokao-audit/tests/test_audit_cli.py b/skills/gaokao-audit/tests/test_audit_cli.py new file mode 100644 index 0000000..c876a67 --- /dev/null +++ b/skills/gaokao-audit/tests/test_audit_cli.py @@ -0,0 +1,106 @@ +"""gaokao-audit CLI tests.""" + +from __future__ import annotations + +import importlib +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +_AUDIT_CLI = importlib.import_module("skills.gaokao-audit.scripts.audit_cli") + +SCRIPT_PATH = _REPO_ROOT / "scripts" / "gaokao-audit" +SAMPLE_PLAN = ( + _REPO_ROOT / "skills" / "gaokao-audit" / "tests" / "fixtures" / "sample_xianyu.txt" +) + + +class _FakeReportGenerator: + def __init__(self, **_: object) -> None: + pass + + def generate_pdf(self, result, output_path: str, **_: object) -> str: + target = Path(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"%PDF-1.4\ncli fake pdf\n") + return str(target) + + +def test_main_generates_pdf_and_prints_report_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(_AUDIT_CLI, "ReportGenerator", _FakeReportGenerator) + output_path = tmp_path / "audit-report.pdf" + + exit_code = _AUDIT_CLI.main([str(SAMPLE_PLAN), "--output", str(output_path)]) + captured = capsys.readouterr() + + assert exit_code == 0 + assert output_path.exists() + assert output_path.read_bytes().startswith(b"%PDF-1.4") + assert str(output_path) in captured.out + assert "综合评分" in captured.out + + +def test_main_missing_input_returns_nonzero( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + missing_path = tmp_path / "missing.txt" + + exit_code = _AUDIT_CLI.main([str(missing_path)]) + captured = capsys.readouterr() + + assert exit_code == 1 + assert str(missing_path) in captured.err + + +def test_wrapper_script_generates_real_pdf(tmp_path: Path) -> None: + output_path = tmp_path / "wrapper-report.pdf" + fake_site = tmp_path / "fake_site" + fake_site.mkdir() + (fake_site / "weasyprint.py").write_text( + "from pathlib import Path\n" + "class HTML:\n" + " def __init__(self, *, string: str, base_url: str) -> None:\n" + " self.string = string\n" + " self.base_url = base_url\n" + " def write_pdf(self, target: str) -> None:\n" + " Path(target).write_bytes(b'%PDF-1.4\\nwrapper fake pdf\\n')\n", + encoding="utf-8", + ) + env = os.environ.copy() + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(fake_site) + if not existing_pythonpath + else f"{fake_site}{os.pathsep}{existing_pythonpath}" + ) + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + str(SAMPLE_PLAN), + "--output", + str(output_path), + ], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert output_path.exists() + assert output_path.stat().st_size > 0 + assert str(output_path) in result.stdout diff --git a/skills/gaokao-audit/tests/test_audit_service.py b/skills/gaokao-audit/tests/test_audit_service.py new file mode 100644 index 0000000..e294246 --- /dev/null +++ b/skills/gaokao-audit/tests/test_audit_service.py @@ -0,0 +1,181 @@ +"""gaokao-audit 审核服务测试。""" + +from __future__ import annotations + +import importlib +import os +import sys +from typing import Any, Protocol, cast + +import pytest +from jinja2 import Environment, FileSystemLoader, select_autoescape + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +_AUDIT_SERVICE = importlib.import_module("skills.gaokao-audit.scripts.audit_service") +AuditResult = _AUDIT_SERVICE.AuditResult +AuditService = _AUDIT_SERVICE.AuditService + +_CROWD_DETECTOR = importlib.import_module("skills.gaokao-audit.scripts.crowd_detector") +CrowdRisk = _CROWD_DETECTOR.CrowdRisk + + +class _AuditServiceProto(Protocol): + def audit(self, plan_text: str, format: str = "text") -> Any: ... + + def build_report_payload( + self, + result: Any, + *, + audit_time: str, + report_id: str, + ) -> dict[str, Any]: ... + + +@pytest.fixture +def service() -> _AuditServiceProto: + return cast(_AuditServiceProto, AuditService()) + + +@pytest.fixture +def sample_plan_text() -> str: + return """ +百度AI志愿助手为您推荐 + +考生信息 +省份:湖南 +高考分数:578 +位次:约26800 +选科:物理+化学+生物 + +推荐院校 +1. 湖南师范大学 - 会计学 +2. 长沙理工大学 - 会计学 +3. 江西财经大学 - 会计学 +4. 湘潭大学 - 工商管理 +5. 湖南工商大学 - 财务管理 +""" + + +def test_audit_plan_basic(service: _AuditServiceProto, sample_plan_text: str) -> None: + result = service.audit(sample_plan_text, format="text") + + assert isinstance(result, AuditResult) + assert result.province == "湖南" + assert result.candidate_score == 578 + assert result.candidate_rank == 26800 + assert result.subjects == "物理+化学+生物" + assert result.source is not None + assert len(result.volunteers) == 5 + + +def test_audit_detects_policy_and_crowd_risk( + service: _AuditServiceProto, sample_plan_text: str +) -> None: + result = service.audit(sample_plan_text, format="text") + + assert result.policy_errors == [] + assert len(result.policy_serious_warnings) >= 1 + assert any("rule" in item for item in result.policy_serious_warnings) + assert len(result.crowd_risks) >= 1 + assert any(risk.risk_level == "high" for risk in result.crowd_risks) + + +def test_audit_calculates_score_and_suggestions( + service: _AuditServiceProto, sample_plan_text: str +) -> None: + result = service.audit(sample_plan_text, format="text") + + assert 0 <= result.overall_score <= 100 + assert result.overall_score < 90 + assert result.suggestions + assert any("政策" in item or "扎堆" in item for item in result.suggestions) + + +def test_audit_to_dict_serializes_crowd_risks( + service: _AuditServiceProto, sample_plan_text: str +) -> None: + payload = service.audit(sample_plan_text, format="text").to_dict() + + assert payload["province"] == "湖南" + assert "overall_score" in payload + assert "policy_errors" in payload + assert isinstance(payload["crowd_risks"], list) + assert payload["crowd_risks"] + assert isinstance(payload["crowd_risks"][0], dict) + assert payload["crowd_risks"][0]["school"] + assert payload["crowd_risks"][0]["risk_level"] in {"high", "medium", "low"} + + +def test_audit_marks_data_trace_issues_when_year_missing( + service: _AuditServiceProto, +) -> None: + text = """ +腾讯元宝志愿建议 +省份:湖南 +高考分数:578 +位次:26800 +选科:物理+化学+生物 +1. 湖南工商大学 - 财务管理 +""" + + result = service.audit(text, format="text") + + assert result.data_issues + assert any("年份" in item["description"] for item in result.data_issues) + assert all("location" in item for item in result.data_issues) + + +def test_audit_result_to_dict_handles_manual_crowd_risk() -> None: + result = AuditResult( + province="湖南", + crowd_risks=[ + CrowdRisk( + school="湖南师范大学", + major="会计学", + frequency=4, + platforms=["百度", "元宝"], + predicted_increase=15, + risk_level="high", + alternatives=[{"school": "湖南工商大学", "major": "会计学"}], + ) + ], + ) + + payload = result.to_dict() + + assert payload["crowd_risks"][0]["school"] == "湖南师范大学" + assert payload["crowd_risks"][0]["risk_level_label"] == "🔴 高风险" + + +def test_build_report_payload_renders_template( + service: _AuditServiceProto, sample_plan_text: str +) -> None: + result = service.audit(sample_plan_text, format="text") + payload = service.build_report_payload( + result, + audit_time="2026-06-12 21:30", + report_id="AUDIT-UNIT-001", + ) + + assert payload["candidate_info"] == "湖南 578分 物理+化学+生物" + assert payload["fatal_count"] == 0 + assert payload["warning_count"] >= len(result.crowd_risks) + assert payload["crowd_risks"] + assert payload["crowd_risks"][0]["risk_emoji"] in {"🔴", "🟡", "🟢"} + assert payload["data_issues"][0]["location"] + + env = Environment( + loader=FileSystemLoader( + os.path.join(_REPO_ROOT, "skills", "gaokao-audit", "templates") + ), + autoescape=select_autoescape(["html"]), + ) + template = env.get_template("audit_report.html") + rendered = template.render(**payload) + + assert "AUDIT-UNIT-001" in rendered + assert payload["crowd_risks"][0]["school"] in rendered + assert payload["crowd_risks"][0]["risk_emoji"] in rendered diff --git a/skills/gaokao-audit/tests/test_checker_integration.py b/skills/gaokao-audit/tests/test_checker_integration.py new file mode 100644 index 0000000..ca1b226 --- /dev/null +++ b/skills/gaokao-audit/tests/test_checker_integration.py @@ -0,0 +1,91 @@ +"""规范检查集成测试。""" + +import importlib +import os +import sys +from typing import Any, Protocol, cast + +import pytest + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +_CHECKER_INTEGRATION = importlib.import_module( + "skills.gaokao-audit.scripts.checker_integration" +) +CheckerIntegration = _CHECKER_INTEGRATION.CheckerIntegration + + +class _CheckerIntegrationProto(Protocol): + def check(self, plan_text: str, province: str | None = None) -> dict[str, Any]: ... + + def format_results(self, result: dict[str, Any]) -> dict[str, Any]: ... + + +@pytest.fixture +def checker() -> _CheckerIntegrationProto: + return cast(_CheckerIntegrationProto, CheckerIntegration()) + + +def test_check_hunan_plan_returns_structured_fatal_error( + checker: _CheckerIntegrationProto, +) -> None: + plan_text = "湖南 578分 45个学校 院校专业组" + + result = checker.check(plan_text, province="湖南") + + assert result["province"] == "湖南" + assert result["supported"] is True + assert result["summary"]["fatal_count"] >= 1 + assert any("志愿单位错误" in item["rule"] for item in result["errors"]["fatal"]) + + +def test_check_auto_detects_province_and_preserves_mode( + checker: _CheckerIntegrationProto, +) -> None: + plan_text = "浙江考生 620分 80个院校专业组 组内服从调剂" + + result = checker.check(plan_text) + + assert result["province"] == "浙江" + assert result["mode"] == "专业+学校" + assert result["summary"]["fatal_count"] >= 1 + assert any("模式错误" in item["rule"] for item in result["errors"]["fatal"]) + + +def test_check_unknown_province_is_graceful( + checker: _CheckerIntegrationProto, +) -> None: + result = checker.check("test", province="火星") + + assert result["province"] == "火星" + assert result["supported"] is False + assert result["summary"]["fatal_count"] == 0 + assert any("暂不支持" in item["description"] for item in result["errors"]["info"]) + + +def test_check_without_province_returns_info_message( + checker: _CheckerIntegrationProto, +) -> None: + result = checker.check("578分考生方案,报考计算机专业") + + assert result["province"] is None + assert result["supported"] is False + assert result["summary"]["total_count"] >= 1 + assert any( + "未检测到省份" in item["description"] for item in result["errors"]["info"] + ) + + +def test_format_results_exposes_policy_summary( + checker: _CheckerIntegrationProto, +) -> None: + raw = checker.check("湖南 578分 45个学校 院校专业组", province="湖南") + + formatted = checker.format_results(raw) + + assert formatted["fatal_count"] == raw["summary"]["fatal_count"] + assert formatted["warning_count"] == raw["summary"]["warning_count"] + assert formatted["serious_count"] == raw["summary"]["serious_count"] + assert formatted["policy_errors"] == raw["errors"]["fatal"] diff --git a/skills/gaokao-audit/tests/test_crowd_detector.py b/skills/gaokao-audit/tests/test_crowd_detector.py new file mode 100644 index 0000000..2683b43 --- /dev/null +++ b/skills/gaokao-audit/tests/test_crowd_detector.py @@ -0,0 +1,155 @@ +"""gaokao-audit 扎堆检测器测试。""" + +from __future__ import annotations + +import importlib +import os +import sys +from typing import Any, Protocol, cast + +import pytest + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +_CROWD_DETECTOR = importlib.import_module("skills.gaokao-audit.scripts.crowd_detector") +CrowdDetector = _CROWD_DETECTOR.CrowdDetector +CrowdRisk = _CROWD_DETECTOR.CrowdRisk + + +class _CrowdDetectorProto(Protocol): + def detect_risks( + self, + volunteers: list[dict[str, str]], + province: str, + score: int, + ) -> list[Any]: ... + + def get_risk_label(self, frequency: int) -> str: ... + + def format_for_report( + self, + risks: list[Any], + province: str | None = None, + ) -> list[dict[str, Any]]: ... + + +class _StubLoader: + def __init__(self, recommendations: list[dict[str, Any]]) -> None: + self._recommendations = recommendations + + def find_recommendations(self, province: str, score: int) -> list[dict[str, Any]]: + return list(self._recommendations) + + def load_metadata(self, province: str) -> dict[str, Any]: + return { + "province": province, + "source": "stub-source", + "source_url": "https://example.com/source", + "source_type": "manual_summary", + "confidence": 0.9, + "last_updated": "2026-06-12", + "data_year": 2025, + } + + +@pytest.fixture +def detector() -> _CrowdDetectorProto: + return cast(_CrowdDetectorProto, CrowdDetector()) + + +def test_detect_risks_with_real_loader_returns_high_risk( + detector: _CrowdDetectorProto, +) -> None: + plan = [{"school": "湖南师范大学", "major": "会计学"}] + + risks = detector.detect_risks(plan, province="湖南", score=578) + + assert len(risks) == 1 + risk = risks[0] + assert isinstance(risk, CrowdRisk) + assert risk.school == "湖南师范大学" + assert risk.major == "会计学" + assert risk.frequency == 4 + assert risk.risk_level == "high" + assert risk.predicted_increase == 15 + assert len(risk.alternatives) >= 1 + + +def test_detect_risks_supports_school_abbreviation( + detector: _CrowdDetectorProto, +) -> None: + plan = [{"school": "湖南师范", "major": "会计学"}] + + risks = detector.detect_risks(plan, province="湖南", score=578) + + assert len(risks) == 1 + assert risks[0].school == "湖南师范大学" + + +def test_detect_risks_returns_empty_for_unknown_school( + detector: _CrowdDetectorProto, +) -> None: + plan = [{"school": "某某不知名学校", "major": "考古学"}] + + risks = detector.detect_risks(plan, province="湖南", score=578) + + assert risks == [] + + +def test_detect_risks_supports_low_risk_via_stub_loader() -> None: + stub_loader = _StubLoader( + recommendations=[ + { + "name": "测试大学", + "major": "测试专业", + "frequency": 1, + "platforms": ["千问"], + "predicted_increase": 3, + "alternatives": [ + {"name": "替代大学", "major": "替代专业", "score": 90} + ], + } + ] + ) + detector = cast(_CrowdDetectorProto, CrowdDetector(loader=stub_loader)) + + risks = detector.detect_risks( + [{"school": "测试大学", "major": "测试专业"}], + province="湖南", + score=578, + ) + + assert len(risks) == 1 + assert risks[0].risk_level == "low" + assert risks[0].risk_level_label == "🟢 低风险" + + +def test_get_risk_label_covers_three_levels(detector: _CrowdDetectorProto) -> None: + assert detector.get_risk_label(4) == "🔴 高风险" + assert detector.get_risk_label(2) == "🟡 中风险" + assert detector.get_risk_label(1) == "🟢 低风险" + + +def test_format_for_report_exposes_template_fields( + detector: _CrowdDetectorProto, +) -> None: + risks = detector.detect_risks( + [{"school": "湖南师范大学", "major": "会计学"}], + province="湖南", + score=578, + ) + + formatted = detector.format_for_report(risks, province="湖南") + + assert len(formatted) == 1 + first = formatted[0] + assert first["school"] == "湖南师范大学" + assert first["name"] == "湖南师范大学" + assert first["risk_level"] == "high" + assert first["risk_level_label"] == "高" + assert first["risk_emoji"] == "🔴" + assert first["source_type"] == "report" + assert first["source_type_icon"] == "⚠️" + assert all("school" in alt for alt in first["alternatives"]) diff --git a/skills/gaokao-audit/tests/test_report_generator.py b/skills/gaokao-audit/tests/test_report_generator.py new file mode 100644 index 0000000..b4e9c1f --- /dev/null +++ b/skills/gaokao-audit/tests/test_report_generator.py @@ -0,0 +1,168 @@ +"""gaokao-audit 报告生成器测试。""" + +from __future__ import annotations + +import importlib +import os +import sys +from pathlib import Path +from typing import Any, Protocol, cast + +import pytest + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +_AUDIT_SERVICE = importlib.import_module("skills.gaokao-audit.scripts.audit_service") +AuditResult = _AUDIT_SERVICE.AuditResult + +_CROWD_DETECTOR = importlib.import_module("skills.gaokao-audit.scripts.crowd_detector") +CrowdRisk = _CROWD_DETECTOR.CrowdRisk + +_REPORT_GENERATOR = importlib.import_module( + "skills.gaokao-audit.scripts.report_generator" +) +ReportGenerator = _REPORT_GENERATOR.ReportGenerator + + +class _ReportGeneratorProto(Protocol): + def render_html(self, result: Any, **kwargs: object) -> str: ... + + def generate_pdf(self, result: Any, output_path: str, **kwargs: object) -> str: ... + + def generate_html(self, result: Any, output_path: str) -> str: ... + + def _default_report_id(self) -> str: ... + + +@pytest.fixture +def generator() -> _ReportGeneratorProto: + return cast( + _ReportGeneratorProto, + ReportGenerator( + now_text=lambda: "2026-06-12 21:45", + report_id_factory=lambda: "AUDIT-UNIT-REPORT", + ), + ) + + +@pytest.fixture +def sample_result() -> Any: + return AuditResult( + province="湖南", + candidate_score=578, + candidate_rank=26800, + subjects="物理+化学+生物", + source="百度AI志愿助手", + volunteers=[ + {"index": 1, "school": "长沙理工大学", "major": "会计学"}, + ], + policy_errors=[], + crowd_risks=[ + CrowdRisk( + school="长沙理工大学", + major="会计学", + frequency=4, + platforms=["千问", "元宝", "百度", "豆包"], + predicted_increase=18, + risk_level="high", + alternatives=[ + {"school": "湖南工商大学", "score": 95}, + ], + ), + ], + data_issues=[ + { + "location": "分数/位次依据", + "description": "未明确数据年份(建议标注2025年参考位次)", + "recommendation": "补充分数线或位次所对应的年份。", + } + ], + suggestions=["检测到 1 所高风险扎堆院校,建议优先替换为低风险替代方案"], + overall_score=75, + ) + + +def test_render_html_renders_template_payload( + generator: _ReportGeneratorProto, sample_result: Any +) -> None: + html = generator.render_html(sample_result) + + assert isinstance(html, str) + assert "AUDIT-UNIT-REPORT" in html + assert "2026-06-12 21:45" in html + assert "湖南 578分 物理+化学+生物" in html + assert "长沙理工大学" in html + assert "免责声明" in html + assert "{{" not in html + + +def test_generate_pdf_uses_weasyprint_writer( + tmp_path: Path, + generator: _ReportGeneratorProto, + sample_result: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + written: dict[str, str] = {} + + class FakeHTML: + def __init__(self, *, string: str, base_url: str) -> None: + written["html"] = string + written["base_url"] = base_url + + def write_pdf(self, target: str) -> None: + Path(target).write_bytes(b"%PDF-1.4\nmock pdf bytes\n") + written["target"] = target + + monkeypatch.setattr(_REPORT_GENERATOR, "_load_weasyprint_html", lambda: FakeHTML) + + output_path = tmp_path / "audit_report.pdf" + pdf_path = generator.generate_pdf(sample_result, str(output_path)) + + assert pdf_path == str(output_path) + assert output_path.exists() + assert output_path.read_bytes().startswith(b"%PDF-1.4") + assert written["target"] == str(output_path) + assert "长沙理工大学" in written["html"] + assert written["base_url"].endswith("skills/gaokao-audit/templates") + + +def test_generate_html_writes_file( + tmp_path: Path, + generator: _ReportGeneratorProto, + sample_result: Any, +) -> None: + output_path = tmp_path / "audit_report.html" + + html_path = generator.generate_html(sample_result, str(output_path)) + + assert html_path == str(output_path) + assert output_path.exists() + rendered = output_path.read_text(encoding="utf-8") + assert "AUDIT-UNIT-REPORT" in rendered + assert "百度AI志愿助手" in rendered + + +def test_load_weasyprint_html_uses_import_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeModule: + HTML = object() + + monkeypatch.setattr( + _REPORT_GENERATOR, "import_module", lambda name: FakeModule, raising=False + ) + + html_cls = _REPORT_GENERATOR._load_weasyprint_html() + + assert html_cls is FakeModule.HTML + + +def test_default_report_id_uses_expected_prefix() -> None: + generator = cast(_ReportGeneratorProto, ReportGenerator()) + + report_id = generator._default_report_id() + + assert report_id.startswith("AUDIT-") + assert len(report_id.split("-")) >= 3 diff --git a/skills/gaokao-college-advisor/scripts/gaokao_visual_report.py b/skills/gaokao-college-advisor/scripts/gaokao_visual_report.py index 345d8c1..2e8804a 100644 --- a/skills/gaokao-college-advisor/scripts/gaokao_visual_report.py +++ b/skills/gaokao-college-advisor/scripts/gaokao_visual_report.py @@ -7,46 +7,83 @@ import json import sys +from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path +from typing import Any, Protocol, cast + + +class _GenerateVisualReport(Protocol): + def __call__( + self, + student_data: dict[str, Any], + volunteer_list: list[dict[str, Any]], + output_format: str = "all", + ) -> list[str]: ... + + +_SCRIPT_PATH = ( + Path(__file__).resolve().parents[3] / "scripts" / "gaokao-visual-report-v2.py" +) +_SPEC = spec_from_file_location("gaokao_visual_report_v2", _SCRIPT_PATH) +assert _SPEC is not None and _SPEC.loader is not None +_MODULE = module_from_spec(_SPEC) +_SPEC.loader.exec_module(_MODULE) +generate_visual_report = cast( + _GenerateVisualReport, + getattr(_MODULE, "generate_visual_report"), +) -# 导入主生成逻辑 -from gaokao_visual_report_v2 import generate_visual_report def main(): if len(sys.argv) < 2: print("用法: python3 gaokao_visual_report.py ") print("生成示例: python3 gaokao_visual_report.py --demo") sys.exit(1) - - if sys.argv[1] == '--demo': + + if sys.argv[1] == "--demo": # 使用示例数据 student = { - 'name': '李明', - 'province': '浙江省', - 'score': 612, - 'rank': 15230, - 'interest_match': 85, - 'ability_match': 90, - 'employment_match': 88, - 'family_match': 95, - 'weak_subjects': ['化学', '语文'] + "name": "李明", + "province": "浙江省", + "score": 612, + "rank": 15230, + "interest_match": 85, + "ability_match": 90, + "employment_match": 88, + "family_match": 95, + "weak_subjects": ["化学", "语文"], } volunteers = [ - {'school': '浙江大学', 'major': '计算机类', 'type': '冲', 'probability': 35, 'match_score': 95, 'required_subjects': ['数学', 'physical']}, - {'school': '杭州电子科技大学', 'major': '计算机类', 'type': '稳', 'probability': 70, 'match_score': 92, 'required_subjects': ['数学', 'physical']}, + { + "school": "浙江大学", + "major": "计算机类", + "type": "冲", + "probability": 35, + "match_score": 95, + "required_subjects": ["数学", "physical"], + }, + { + "school": "杭州电子科技大学", + "major": "计算机类", + "type": "稳", + "probability": 70, + "match_score": 92, + "required_subjects": ["数学", "physical"], + }, ] else: # 从JSON文件加载 - with open(sys.argv[1], 'r') as f: + with open(sys.argv[1], "r") as f: data = json.load(f) - student = data['student'] - volunteers = data['volunteers'] - - files = generate_visual_report(student, volunteers, output_format='all') - + student = data["student"] + volunteers = data["volunteers"] + + files = generate_visual_report(student, volunteers, output_format="all") + print("\n✓ 生成完成:") for f in files: print(f" • {f}") -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/skills/gaokao-counselor-long/SKILL.md b/skills/gaokao-counselor-long/SKILL.md index 5379c0f..8190fb0 100644 --- a/skills/gaokao-counselor-long/SKILL.md +++ b/skills/gaokao-counselor-long/SKILL.md @@ -95,19 +95,69 @@ color: blue 1. **`gaokao-college-advisor`** - 方案生成 2. **`gaokao-spec-checker`** - 规范检查(自动) -3. **独立脚本**: +3. **`zhangxuefeng-skillset`** - 只借表达风格,不接管数据与合规判断 +4. **独立脚本/入口**: - `gaokao-visual-report-v2.py` - 可视化 - `gaokao-quick-3min.py` - 快速问卷 - `gaokao-checker` - 规范检查脚本 + - `scripts/gaokao-audit` - 替代卡/审核场景入口 ### 工作流 +#### 模式A:新方案咨询(默认) + ``` 用户咨询 → 信息收集 → 方案生成 → 自动检查 → 修正 → 输出 ↓ ↑ └──── 反馈循环 ←─────────────────────┘ ``` +调用顺序: + +1. `gaokao-counselor-long` 负责收集信息、解释风险、组织输出 +2. `gaokao-college-advisor` 负责生成冲/稳/保或完整志愿底稿 +3. `gaokao-spec-checker` 负责做最终规范闸门 +4. `zhangxuefeng-skillset` 只在需要增强“接地气表达”时借风格,不作为事实来源 + +#### 模式B:替代卡 / 审核场景(先审后改) + +``` +用户带着别家AI方案来 → 读取文本/PDF/截图OCR → gaokao-audit 审核 + ↓ + 输出致命/严重问题 + 修正建议 + ↓ + 如用户要重做完整方案,再转 gaokao-college-advisor +``` + +适用触发词: + +- “别家 AI 给了我一份方案,你帮我看看” +- “这是百度/元宝/豆包/千问出的表,能不能直接报?” +- “先审一下,再决定要不要重做方案” + +可执行示例(纯审核 smoke test,不生成 PDF): + +```bash +cd /home/long/project/gaokao-volunteer-system +python3 - <<'PY' +import importlib, json +from pathlib import Path + +AuditService = importlib.import_module('skills.gaokao-audit.scripts.audit_service').AuditService +text = Path('skills/gaokao-audit/tests/fixtures/sample_xianyu.txt').read_text(encoding='utf-8') +result = AuditService().audit(text, format='text') +print(json.dumps({ + 'province': result.province, + 'overall_score': result.overall_score, + 'policy_errors': len(result.policy_errors), + 'crowd_risks': len(result.crowd_risks), + 'data_issues': len(result.data_issues), +}, ensure_ascii=False, indent=2)) +PY +``` + +如需正式 PDF 报告,再运行 `python3 scripts/gaokao-audit <方案文件> --json`(前提:环境已安装 `weasyprint`)。 + ## 📋 标准服务流程 ### 第一步:建立档案(5分钟) @@ -145,7 +195,7 @@ color: blue 每个方案说清: - 为什么是这个 -- 录取概率(基于2025年位次) +- 录取位次关系(基于2025年数据,不写主观百分比) - 风险点 - 切换条件 @@ -160,6 +210,23 @@ color: blue - 时间节点 - 行动清单 +## 🔍 审核场景标准流程 + +当用户不是要“从零生成方案”,而是拿着现成方案来复核时,按下面流程走: + +1. 先确认输入类型:纯文本 / PDF 转文本 / 截图 OCR +2. 先跑 `gaokao-audit`,输出: + - 致命错误(必须修) + - 严重警告(扎堆、主观概率、数据过时) + - 一般建议(补代码、补风险提示) +3. 不直接替用户改表,只指出问题与修正方向 +4. 只有在用户明确要“重做完整方案”时,才切到 `gaokao-college-advisor` +5. 无论是审核后微调,还是重做完整方案,最终都必须再过 `gaokao-spec-checker` + +审核场景的默认话术: + +> “这不是直接给你重做,我先替你把这份方案里能出事故的地方找出来。先审,再决定要不要升级成完整重做。” + ## 🎯 高频咨询场景 ### 场景1:分数刚出来 @@ -180,11 +247,21 @@ color: blue ## 🔄 与其他Skill的协作 -| Skill | 我如何用 | -| ------------------------ | ---------------------------- | -| `gaokao-college-advisor` | 生成方案的基础引擎 | -| `gaokao-spec-checker` | **必须**在每次输出方案后调用 | -| `zhangxuefeng-skillset` | 借鉴表达风格(不直接调用) | +| Skill | 角色定位 | 什么时候调用 | 不能替代什么 | +| ------------------------ | ------------- | ------------------------------------------------ | -------------------------- | +| `gaokao-counselor-long` | 主控/对话门面 | 全程都在:识别场景、收集信息、解释风险、组织输出 | 不能单独替代生成/审核引擎 | +| `gaokao-college-advisor` | 方案生成引擎 | 用户要新方案、补方案、升级 99 元完整方案时 | 不能替代合规检查 | +| `gaokao-spec-checker` | 合规闸门 | **每次输出方案前后都必须**跑一次 | 不能替代人工解释与风格输出 | +| `zhangxuefeng-skillset` | 风格增强器 | 需要“更像张雪峰”的表达时按需借风格 | 不能替代事实、数据、政策 | + +一句话协同原则: + +- 龙老师负责“怎么聊、怎么控场、怎么交付” +- `gaokao-college-advisor` 负责“怎么生成方案” +- `gaokao-spec-checker` 负责“怎么挡住事故” +- `zhangxuefeng-skillset` 负责“怎么说得更接地气” + +如果用户带来的是别家 AI 现成方案,则先走 `gaokao-audit` 审核入口,不要直接跳过审核去重做。 ## 💡 我的差异化优势 diff --git a/skills/gaokao-counselor-long/references/quick-guide.md b/skills/gaokao-counselor-long/references/quick-guide.md index 2a670ce..04bb68d 100644 --- a/skills/gaokao-counselor-long/references/quick-guide.md +++ b/skills/gaokao-counselor-long/references/quick-guide.md @@ -27,14 +27,24 @@ ## 📋 我会怎么回应 -### 标准流程 +### 标准双入口 + +#### 入口A:你要我从零做方案 1. **信息核查** - 看你给的信息够不够 2. **政策匹配** - 加载本省2026年规则 -3. **数据分析** - 基于2025年位次分析 -4. **方案生成** - 给3套冲稳保方案 -5. **自检输出** - 自动规范检查 -6. **报告交付** - 可视化报告 +3. **方案生成** - 调 `gaokao-college-advisor` 出底稿 +4. **规范闸门** - 调 `gaokao-spec-checker` 挡致命错误 +5. **龙老师交付** - 用更好懂的话解释方案与风险 +6. **报告输出** - 可视化报告 + 行动清单 + +#### 入口B:你已经有别家AI方案,先让我审核 + +1. **先收方案** - 文本 / PDF 转文本 / 截图 OCR 都行 +2. **先审不重做** - 调 `gaokao-audit` 找致命错误、扎堆风险、数据存疑 +3. **给结论** - 这份方案能不能直接报、哪里必须改 +4. **再决定** - 需要的话再升级成完整重做方案 +5. **最后闸门** - 不管是微调还是重做,都要再过 `gaokao-spec-checker` ## 🔧 给我提供的信息 @@ -94,6 +104,41 @@ "我是湖南考生,578分..." ``` +### 审核场景(替代卡 / 别家AI方案) + +```bash +cd /home/long/project/gaokao-volunteer-system +python3 - <<'PY' +import importlib, json +from pathlib import Path + +AuditService = importlib.import_module('skills.gaokao-audit.scripts.audit_service').AuditService +text = Path('skills/gaokao-audit/tests/fixtures/sample_xianyu.txt').read_text(encoding='utf-8') +result = AuditService().audit(text, format='text') +print(json.dumps({ + 'province': result.province, + 'overall_score': result.overall_score, + 'policy_errors': len(result.policy_errors), + 'crowd_risks': len(result.crowd_risks), + 'data_issues': len(result.data_issues), +}, ensure_ascii=False, indent=2)) +PY +``` + +如需正式 PDF 报告,再运行 `python3 scripts/gaokao-audit <方案文件> --json`(前提:环境已安装 `weasyprint`)。 + +适合用户原话: + +- "这是别家AI给我的表,你先帮我审一下" +- "别重做,先告诉我这份方案能不能直接报" + +### 四个 skill 怎么协同 + +1. `gaokao-counselor-long`:前台接待 + 场景路由 + 风险解释 +2. `gaokao-college-advisor`:生成新方案或升级版完整方案 +3. `gaokao-spec-checker`:最终合规闸门,必须跑 +4. `zhangxuefeng-skillset`:增强表达风格,不负责事实判断 + ### 自动加载 如果你说了"高考"、"志愿"等关键词,我可能会自动出现。 @@ -159,6 +204,39 @@ > > 你说说你家情况,我给你更具体的建议。" +### 示例4:替代卡 / 审核场景 + +> 用户:"这是百度AI给我的志愿表,你先别重做,先看能不能直接报。" +> +> 龙老师:"行,这种我不先瞎改。我先替你查三件事: +> +> 1. 有没有违反你们省今年的填报规则; +> 2. 有没有几个大厂AI都扎堆推同一批学校; +> 3. 有没有'80%能上'这种没数据来源的话术。 +> +> 我先走审核入口,不直接重做: +> +> ```bash +> cd /home/long/project/gaokao-volunteer-system +> python3 - <<'PY' +> import importlib, json +> from pathlib import Path +> +> AuditService = importlib.import_module('skills.gaokao-audit.scripts.audit_service').AuditService +> text = Path('skills/gaokao-audit/tests/fixtures/sample_xianyu.txt').read_text(encoding='utf-8') +> result = AuditService().audit(text, format='text') +> print(json.dumps({ +> 'province': result.province, +> 'overall_score': result.overall_score, +> 'policy_errors': len(result.policy_errors), +> 'crowd_risks': len(result.crowd_risks), +> 'data_issues': len(result.data_issues), +> }, ensure_ascii=False, indent=2)) +> PY +> ``` +> +> 先把这份表里能出事故的地方找出来。要正式 PDF 报告,再补跑 `python3 scripts/gaokao-audit <方案文件> --json`。审完以后,如果你要,我再给你升级成完整重做方案。" + ## 📊 报告交付 每次给你方案后,我会: diff --git a/skills/gaokao-spec-checker/scripts/spec_checker.py b/skills/gaokao-spec-checker/scripts/spec_checker.py index de8e9be..029b02e 100644 --- a/skills/gaokao-spec-checker/scripts/spec_checker.py +++ b/skills/gaokao-spec-checker/scripts/spec_checker.py @@ -4,8 +4,6 @@ """ import re -import json -import sys from datetime import datetime diff --git a/skills/gaokao-spec-checker/scripts/spec_checker_v2.py b/skills/gaokao-spec-checker/scripts/spec_checker_v2.py index d1f2487..121de4c 100644 --- a/skills/gaokao-spec-checker/scripts/spec_checker_v2.py +++ b/skills/gaokao-spec-checker/scripts/spec_checker_v2.py @@ -4,7 +4,6 @@ """ import re -import json import sys from datetime import datetime diff --git a/tests/test_all.py b/tests/test_all.py index b41a8ad..e6c6a34 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -5,6 +5,7 @@ import sys import os +from types import ModuleType # 添加scripts到路径 sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts')) @@ -15,8 +16,10 @@ spec = importlib.util.spec_from_file_location( "spec_checker_v2", os.path.join(os.path.dirname(__file__), '..', 'skills', 'gaokao-spec-checker', 'scripts', 'spec_checker_v2.py') ) +assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) +assert isinstance(module, ModuleType) GaokaoSpecCheckerV2 = module.GaokaoSpecCheckerV2 diff --git a/tests/test_audit_integration.py b/tests/test_audit_integration.py new file mode 100644 index 0000000..0e4841c --- /dev/null +++ b/tests/test_audit_integration.py @@ -0,0 +1,96 @@ +"""gaokao-audit 端到端集成测试。""" + +from __future__ import annotations + +import importlib +import json +import sys +from pathlib import Path +from typing import Any, cast + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[1] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +_AUDIT_CLI = importlib.import_module("skills.gaokao-audit.scripts.audit_cli") +_REPORT_GENERATOR = importlib.import_module( + "skills.gaokao-audit.scripts.report_generator" +) +_ReportGeneratorBase = cast(type[Any], _REPORT_GENERATOR.ReportGenerator) + +SAMPLE_PLAN = ( + _REPO_ROOT / "skills" / "gaokao-audit" / "tests" / "fixtures" / "sample_xianyu.txt" +) + + +class _CaptureReportGenerator(_ReportGeneratorBase): # type: ignore[valid-type, misc] + last_html: str = "" + + def __init__(self, **kwargs: object) -> None: + super().__init__( + now_text=lambda: "2026-06-12 23:40", + report_id_factory=lambda: "AUDIT-E2E-001", + **kwargs, + ) + + def generate_pdf(self, result, output_path: str, **kwargs: object) -> str: + html = self.render_html(result, **kwargs) + type(self).last_html = html + target = Path(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"%PDF-1.4\ne2e fake pdf\n") + return str(target) + + +def _extract_json(stdout: str) -> dict: + lines = stdout.splitlines() + for index, line in enumerate(lines): + if line.startswith("{"): + return json.loads("\n".join(lines[index:])) + raise AssertionError(f"stdout 中未找到 JSON 输出: {stdout}") + + +def test_audit_cli_end_to_end_generates_pdf_and_report_content( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + output_path = tmp_path / "audit-e2e.pdf" + monkeypatch.setattr(_AUDIT_CLI, "ReportGenerator", _CaptureReportGenerator) + + exit_code = _AUDIT_CLI.main([ + str(SAMPLE_PLAN), + "--output", + str(output_path), + "--json", + ]) + captured = capsys.readouterr() + payload = _extract_json(captured.out) + rendered_html = _CaptureReportGenerator.last_html + + assert exit_code == 0 + assert output_path.exists() + assert output_path.read_bytes().startswith(b"%PDF-1.4") + + assert "输入文件" in captured.out + assert str(output_path) in captured.out + assert "综合评分" in captured.out + + assert payload["province"] == "湖南" + assert payload["candidate_score"] == 578 + assert payload["source"] == "百度AI" + assert len(payload["volunteers"]) == 6 + assert payload["policy_errors"] == [] + assert payload["policy_serious_warnings"] + assert payload["crowd_risks"] + risk_schools = {item["school"] for item in payload["crowd_risks"]} + assert "湖南师范大学" in risk_schools + assert payload["overall_score"] < 100 + + assert "AUDIT-E2E-001" in rendered_html + assert "湖南 578分 物理+化学+生物" in rendered_html + assert any(school in rendered_html for school in risk_schools) + assert "免责声明" in rendered_html + assert "本审核仅供建议,最终填报以官方公布为准" in rendered_html diff --git a/tests/test_sync_remotes_cli.py b/tests/test_sync_remotes_cli.py new file mode 100644 index 0000000..5c0a4d4 --- /dev/null +++ b/tests/test_sync_remotes_cli.py @@ -0,0 +1,97 @@ +"""gaokao-sync-remotes CLI tests (T10.3).""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = PROJECT_ROOT / "scripts" / "gaokao-sync-remotes" +DEFAULT_REMOTES = ("gitea", "origin", "tksea") + + +def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + + +def _run_cli(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT_PATH), *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + + +def _init_repo(tmp_path: Path) -> tuple[Path, dict[str, Path]]: + repo = tmp_path / "repo" + repo.mkdir() + assert _git(repo, "init", "-b", "main").returncode == 0 + assert _git(repo, "config", "user.name", "T10 Tester").returncode == 0 + assert _git(repo, "config", "user.email", "t10@example.com").returncode == 0 + + readme = repo / "README.md" + readme.write_text("hello\n", encoding="utf-8") + assert _git(repo, "add", "README.md").returncode == 0 + assert _git(repo, "commit", "-m", "init").returncode == 0 + + remotes: dict[str, Path] = {} + for name in DEFAULT_REMOTES: + bare = tmp_path / f"{name}.git" + assert _git(tmp_path, "init", "--bare", str(bare)).returncode == 0 + assert _git(repo, "remote", "add", name, str(bare)).returncode == 0 + remotes[name] = bare + return repo, remotes + + +def _remote_head(remote_path: Path, branch: str = "main") -> str: + result = subprocess.run( + ["git", "--git-dir", str(remote_path), "rev-parse", branch], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def test_dry_run_lists_all_three_push_commands(tmp_path: Path) -> None: + repo, remotes = _init_repo(tmp_path) + + result = _run_cli(repo, "--dry-run") + + assert result.returncode == 0, result.stderr + for remote_name in remotes: + assert f"[DRY-RUN] git push {remote_name} main" in result.stdout + for remote_path in remotes.values(): + assert not (remote_path / "refs" / "heads" / "main").exists() + + +def test_pushes_main_to_all_three_remotes_and_verifies_heads(tmp_path: Path) -> None: + repo, remotes = _init_repo(tmp_path) + + result = _run_cli(repo) + + assert result.returncode == 0, result.stderr + local_head = _git(repo, "rev-parse", "HEAD").stdout.strip() + for remote_name, remote_path in remotes.items(): + assert f"OK {remote_name}: main @ {local_head}" in result.stdout + assert _remote_head(remote_path) == local_head + + +def test_missing_remote_fails_before_push(tmp_path: Path) -> None: + repo, _ = _init_repo(tmp_path) + assert _git(repo, "remote", "remove", "tksea").returncode == 0 + + result = _run_cli(repo) + + assert result.returncode == 2 + assert "missing remotes: tksea" in result.stderr diff --git a/tests/test_t5_e2e_workflows.py b/tests/test_t5_e2e_workflows.py new file mode 100644 index 0000000..6a9e800 --- /dev/null +++ b/tests/test_t5_e2e_workflows.py @@ -0,0 +1,327 @@ +"""T5.1 端到端业务场景测试。 + +覆盖 5 条主链路: +1. 咨询 -> 方案生成 +2. 审核 -> 报告 +3. 订单 -> 交付 +4. 升级流程 +5. 数据溯源展示 +""" + +from __future__ import annotations + +import importlib +import importlib.util +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, cast + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +_AUDIT_CLI = importlib.import_module("skills.gaokao-audit.scripts.audit_cli") +_REPORT_GENERATOR = importlib.import_module( + "skills.gaokao-audit.scripts.report_generator" +) +_ReportGeneratorBase = cast(type[Any], _REPORT_GENERATOR.ReportGenerator) +_TRACE_CLI = importlib.import_module("data.crowd_db.cli") + +SAMPLE_PLAN = ( + PROJECT_ROOT + / "skills" + / "gaokao-audit" + / "tests" + / "fixtures" + / "sample_xianyu.txt" +) +QUICK_SCRIPT = PROJECT_ROOT / "scripts" / "gaokao-quick-3min.py" +ORDER_SCRIPT = PROJECT_ROOT / "scripts" / "gaokao-order-manager" +TRACE_SCRIPT = PROJECT_ROOT / "scripts" / "gaokao-data-trace" + +os.environ.setdefault("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-t5-e2e") + + +class _CaptureReportGenerator(_ReportGeneratorBase): # type: ignore[valid-type, misc] + last_html: str = "" + + def __init__(self, **kwargs: object) -> None: + super().__init__( + now_text=lambda: "2026-06-13 10:00", + report_id_factory=lambda: "AUDIT-T5-E2E-001", + **kwargs, + ) + + def generate_pdf(self, result, output_path: str, **kwargs: object) -> str: + html = self.render_html(result, **kwargs) + type(self).last_html = html + target = Path(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"%PDF-1.4\nt5 fake pdf\n") + return str(target) + + +@pytest.fixture(scope="module") +def quick_module(): + spec = importlib.util.spec_from_file_location("gaokao_quick_3min", QUICK_SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def tmp_orders_db(tmp_path: Path) -> Path: + return tmp_path / "orders.db" + + +@pytest.fixture +def order_env() -> dict[str, str]: + return {"GAOKAO_ORDERS_FERNET_KEY": os.environ["GAOKAO_ORDERS_FERNET_KEY"]} + + +def _run_script( + script: Path, *args: str, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + if env: + merged_env.update(env) + return subprocess.run( + [sys.executable, str(script), *args], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + env=merged_env, + ) + + +def _extract_json(stdout: str) -> dict: + lines = stdout.splitlines() + for index, line in enumerate(lines): + if line.startswith("{"): + return json.loads("\n".join(lines[index:])) + raise AssertionError(f"stdout 中未找到 JSON 输出: {stdout}") + + +def test_consultation_to_plan_generation_flow(quick_module) -> None: + reply = """1. 李明 +2. 浙江 +3. 612 +4. 15230 +5. R +6. 物理、数学 +7. C +8. ③ +9. ① +10. ② +""" + + info = quick_module.parse_quick_response(reply) + summary = quick_module.generate_quick_summary(info) + recommendation = quick_module.generate_quick_recommendation(info) + + assert info["basic"]["name"] == "李明" + assert info["basic"]["province"] == "浙江" + assert info["exam"]["score"] == 612 + assert info["exam"]["rank"] == 15230 + assert info["profile"]["type_code"] == "R" + assert "✅ 核心信息完整!可以开始推荐" in summary + assert "📊 高考:612分" in summary + assert "📊 位次:15230名" in summary + assert "计算机科学与技术" in recommendation + assert "物理数学强 → 计算机、电子信息、自动化" in recommendation + + +def test_audit_to_report_flow( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + output_path = tmp_path / "audit-report.pdf" + monkeypatch.setattr(_AUDIT_CLI, "ReportGenerator", _CaptureReportGenerator) + + exit_code = _AUDIT_CLI.main([ + str(SAMPLE_PLAN), + "--output", + str(output_path), + "--json", + ]) + captured = capsys.readouterr() + payload = _extract_json(captured.out) + + assert exit_code == 0 + assert output_path.exists() + assert output_path.read_bytes().startswith(b"%PDF-1.4") + assert payload["province"] == "湖南" + assert payload["candidate_score"] == 578 + assert payload["crowd_risks"] + assert "AUDIT-T5-E2E-001" in _CaptureReportGenerator.last_html + assert "免责声明" in _CaptureReportGenerator.last_html + + +def test_order_to_delivery_flow_records_artifacts( + tmp_orders_db: Path, + tmp_path: Path, + order_env: dict[str, str], +) -> None: + plan_path = tmp_path / "plan.md" + report_path = tmp_path / "audit.json" + pdf_path = tmp_path / "report.pdf" + plan_path.write_text("咨询后生成的志愿方案", encoding="utf-8") + report_path.write_text('{"overall_score": 88}', encoding="utf-8") + pdf_path.write_bytes(b"%PDF-1.4\nreport\n") + + created = _run_script( + ORDER_SCRIPT, + "--db", + str(tmp_orders_db), + "create", + "--source", + "xianyu", + "--service-version", + "audit", + "--amount-cents", + "4900", + "--customer-name", + "王家长", + "--customer-phone", + "13800001234", + "--candidate-name", + "李明", + "--candidate-province", + "湖南", + "--candidate-score", + "578", + "--candidate-rank", + "26800", + env=order_env, + ) + assert created.returncode == 0, created.stderr + order_id = json.loads(created.stdout)["order"]["id"] + + updated = _run_script( + ORDER_SCRIPT, + "--db", + str(tmp_orders_db), + "update", + order_id, + "--assigned-consultant", + "long-teacher", + "--plan-file", + str(plan_path), + "--audit-report", + str(report_path), + "--pdf-path", + str(pdf_path), + "--note", + "方案与审核报告已归档", + env=order_env, + ) + assert updated.returncode == 0, updated.stderr + updated_payload = json.loads(updated.stdout) + assert updated_payload["order"]["plan_file"] == str(plan_path) + assert updated_payload["order"]["audit_report"] == str(report_path) + assert updated_payload["order"]["pdf_path"] == str(pdf_path) + + paid = _run_script( + ORDER_SCRIPT, + "--db", + str(tmp_orders_db), + "pay", + order_id, + "--reason", + "xianyu-paid", + env=order_env, + ) + assert paid.returncode == 0, paid.stderr + + delivered = _run_script( + ORDER_SCRIPT, + "--db", + str(tmp_orders_db), + "deliver", + order_id, + "--reason", + "pdf-delivered", + env=order_env, + ) + assert delivered.returncode == 0, delivered.stderr + delivered_payload = json.loads(delivered.stdout) + assert delivered_payload["order"]["status"] == "delivered" + assert delivered_payload["order"]["delivered_at"] is not None + assert delivered_payload["order"]["plan_file"] == str(plan_path) + assert delivered_payload["order"]["pdf_path"] == str(pdf_path) + + +def test_upgrade_flow_creates_delta_order( + tmp_orders_db: Path, + order_env: dict[str, str], +) -> None: + created = _run_script( + ORDER_SCRIPT, + "--db", + str(tmp_orders_db), + "create", + "--source", + "wechat", + "--service-version", + "audit", + "--amount-cents", + "4900", + "--customer-name", + "王家长", + "--customer-phone", + "13900001234", + env=order_env, + ) + assert created.returncode == 0, created.stderr + source_order_id = json.loads(created.stdout)["order"]["id"] + + upgraded = _run_script( + ORDER_SCRIPT, + "--db", + str(tmp_orders_db), + "upgrade", + source_order_id, + "--service-version", + "standard", + "--target-amount-cents", + "9900", + "--reason", + "upgrade_to_standard", + env=order_env, + ) + assert upgraded.returncode == 0, upgraded.stderr + payload = json.loads(upgraded.stdout) + + assert payload["order"]["upgrade_from"] == source_order_id + assert payload["order"]["service_version"] == "standard" + assert payload["order"]["amount_cents"] == 5000 + assert payload["source_order"]["id"] == source_order_id + assert "upgraded" in payload["source_order"]["tags"] + + +def test_traceability_display_flow(capsys: pytest.CaptureFixture[str]) -> None: + exit_code = _TRACE_CLI.main(["--human", "长沙理工大学"]) + captured = capsys.readouterr() + + assert exit_code == 0 + assert "query: 长沙理工大学" in captured.out + assert "湖南 / 2025年数据 / 长沙理工大学 / 会计学" in captured.out + assert "source_type: report (⚠️报告)" in captured.out + assert "source_url: https://" in captured.out + assert "confidence: 0.85" in captured.out + + +def test_traceability_json_entrypoint_matches_cli_contract() -> None: + result = _run_script(TRACE_SCRIPT, "长沙理工大学") + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["query"] == "长沙理工大学" + assert payload["match_count"] >= 1 + assert any(match["province"] == "湖南" for match in payload["matches"]) diff --git a/tests/test_t5_performance.py b/tests/test_t5_performance.py new file mode 100644 index 0000000..ca7eda4 --- /dev/null +++ b/tests/test_t5_performance.py @@ -0,0 +1,168 @@ +"""T5.2 性能与并发测试。""" + +from __future__ import annotations + +import csv +import importlib.util +import os +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Iterator + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +QUICK_SCRIPT = PROJECT_ROOT / "scripts" / "gaokao-quick-3min.py" +LOCUST_FILE = PROJECT_ROOT / "locustfile.py" + +_SAMPLE_REPLY = """1. 李明 +2. 浙江 +3. 612 +4. 15230 +5. R +6. 物理、数学 +7. C +8. ③ +9. ① +10. ② +""" + + +def _load_quick_module(): + spec = importlib.util.spec_from_file_location("gaokao_quick_3min", QUICK_SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _generate_100_plans() -> float: + module = _load_quick_module() + start = time.perf_counter() + for _ in range(100): + info = module.parse_quick_response(_SAMPLE_REPLY) + module.generate_quick_summary(info) + module.generate_quick_recommendation(info) + return time.perf_counter() - start + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _wait_for_health(base_url: str, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(f"{base_url}/health", timeout=1.0) as resp: + if resp.status == 200: + return + except (urllib.error.URLError, TimeoutError): + time.sleep(0.2) + raise AssertionError(f"admin app 未在 {timeout}s 内就绪: {base_url}") + + +@pytest.fixture +def admin_server(tmp_path: Path) -> Iterator[str]: + port = _find_free_port() + env = os.environ.copy() + env.update( + { + "GAOKAO_ENV": "dev", + "GAOKAO_DB_PATH": str(tmp_path / "admin.db"), + "GAOKAO_ORDERS_DB_PATH": str(tmp_path / "orders.db"), + "GAOKAO_JWT_SECRET": "x" * 64, + "GAOKAO_ADMIN_USER": "admin", + "GAOKAO_ADMIN_PASS": "admin123", + } + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "admin.app", + "--host", + "127.0.0.1", + "--port", + str(port), + "--log-format", + "plain", + ], + cwd=PROJECT_ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + base_url = f"http://127.0.0.1:{port}" + _wait_for_health(base_url) + yield base_url + finally: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=10) + + +@pytest.mark.timeout(30) +def test_plan_generation_100_runs_under_5_seconds(benchmark) -> None: + elapsed = benchmark(_generate_100_plans) + assert elapsed < 5.0 + + +@pytest.mark.timeout(90) +def test_admin_locust_10_concurrency_success_rate_above_95( + admin_server: str, tmp_path: Path +) -> None: + report_prefix = tmp_path / "t5_2" + command = [ + "locust", + "-f", + str(LOCUST_FILE), + "--host", + admin_server, + "--headless", + "-u", + "10", + "-r", + "2", + "-t", + "15s", + "--csv", + str(report_prefix), + ] + result = subprocess.run( + command, + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + "\n" + result.stderr + + stats_path = report_prefix.with_name(report_prefix.name + "_stats.csv") + with stats_path.open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + + aggregate = next( + row for row in rows if row.get("Name") == "Aggregated" and not row.get("Type") + ) + request_count = int(aggregate["Request Count"]) + failure_count = int(aggregate["Failure Count"]) + success_rate = ((request_count - failure_count) / request_count) * 100 + + assert request_count > 0 + assert success_rate > 95.0