From ef4bb462953a5a6c536e9aeb3ce8e8eea01e37ae Mon Sep 17 00:00:00 2001 From: coder Date: Fri, 12 Jun 2026 16:36:32 +0800 Subject: [PATCH] =?UTF-8?q?docs(orders):=20T4.2=20DAO=20=E6=96=87=E6=A1=A3?= =?UTF-8?q?=20+=20=E5=AE=9E=E6=96=BD=E8=AE=B0=E5=BD=95=20+=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - data/orders/README.md: 增加 DAO API 一览表 + 加密透明化边界表 - docs/plans/T4-2-orders-dao.md: 实施记录 (设计原则 / DoD / 验证 / 风险) - CHANGELOG.md: T4.2 条目(51 用例 / 163 总 / 386 data 全绿) --- CHANGELOG.md | 47 ++++++++++ data/orders/README.md | 124 ++++++++++++++++++------- docs/plans/T4-2-orders-dao.md | 167 ++++++++++++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 34 deletions(-) create mode 100644 docs/plans/T4-2-orders-dao.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a42b18..b9dcb08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ ### 🚧 进行中 +#### 新增(T9.3 已完成) + +- ✨ **结构化 JSON 日志 `admin/logging_utils.py`** + - 新增 `JsonLogFormatter`:单行 JSON 输出 `ts / level / logger / msg / ctx / exc` + - 新增 `log_event()` / `log_event_exc()`:业务侧一行写结构化事件,避免散落字符串拼接 + - 新增 `contextvars` 请求上下文:自动注入 `request_id / path / method`,并在请求结束后清理,避免串请求污染 + - `admin/app.py` 新增 `request_context_middleware` 与 `--log-format json|plain` 启动参数;CLI 默认输出 JSON,保留 plain 便于本地调试 + - `admin/errors/exceptions.py` 的 `BusinessError` / `HTTPException` / `RequestValidationError` / 兜底异常日志全部升级为结构化事件 + - 新增 `admin/tests/test_logging.py` 8 个测试,覆盖 formatter、上下文绑定、异常 traceback、FastAPI 端到端 JSON 日志 + - 定向验证通过:`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` 通过 + - 当前已知缺口:`pytest -q admin/tests` 仍有 1 个非 T9.3 既有失败(`admin/tests/test_routes.py::test_stats_orders_real_shape`,`sqlite3.OperationalError: no such table: orders`) + +#### 新增(T4.2 已完成) + +- ✨ **订单 DAO 数据访问层 `data/orders/dao.py`**(530 行,`OrdersDAO` 类) + - **CRUD 完整**:`create()` / `get()` / `get_by_external_id()` / `find_by_phone()` / `list()` / `count()` / `stats_by_status()` / `update()` / `delete()` + - **事务守护**:`dao.transaction()` 上下文管理器;嵌套语义(外层在事务中时内层不重复 commit,异常统一回滚外层) + - **加密透明化**:API 入口接收 `Order` dataclass(明文 PII),DAO 内部用 `to_db_row()` 加密落盘 / `from_db_row()` 解密读回;调用方无需接触 `*_enc` 字段 + - **状态机守护**:`transition_status()` 走 `assert_valid_transition()` 单事务内 `UPDATE orders` + `INSERT order_status_history`;非法抛 `InvalidStateTransition` 并回滚 + - **时间戳联动**:`paid → paid_at` / `serving → started_at` / `delivered → delivered_at` / `completed → completed_at` 由状态自动置位(`COALESCE` 保留首次值) + - **幂等 upsert**:`upsert_by_external_id()` 接管 T8.1 闲鱼 Webhook 路径,4 种 `action` 与 `data/channel_sync/dao_extension.py` 完全对齐(`inserted` / `updated` / `unchanged` / `illegal_transition`) + - **防御设计**:`_row_factory_ctx()` 临时切 `sqlite3.Row`、退出后恢复外部 row_factory;`update()` 显式拒绝 `status` 字段(强制走 `transition_status()`) + - **51 个 pytest 用例全绿**(覆盖加密透明化、状态机合法/非法路径、事务回滚、upsert 4 种 action、状态历史时间线、row_factory 不污染、删除 cascade) + - **data/orders 模块总测试 163/163 通过**(含 T4.1 既有 112 + T4.2 新增 51);`data/` 全模块 386/386 通过;ruff 0 warning +- 🔧 **`data/orders/__init__.py` 导出 DAO 公共 API**:`OrdersDAO` / `UpsertResult` / `StatusChange` / `OrderNotFound` / `DuplicateOrder` 一行 import +- 📝 **`data/orders/README.md`** 更新 T4.2 DAO 一览表、加密透明化边界表、DoD 勾选、API 下游衔接 + #### 新增(T2.4 已完成) - ✨ **扎堆报告生成器 `risk_report.py`**(`data/crowd_db/risk_report.py`,183 行) @@ -71,6 +98,26 @@ - CLI:`create / resolve / revoke / list / stats / purge`,全部子命令 JSON 输出 - 25 个 pytest 用例全部通过(base62 编解码、碰撞重试、TTL、密码、撤销、列表、统计、清理、路由) +#### 新增(T7.3 已完成) + +- ✨ **分享权限策略**(`data/share/permission.py`) + - 新增 `PermissionPolicy.for_permission(permission)`:把 `read/comment/edit/admin` 归一化成分享页能力策略 + - 能力判断:`can_view / can_comment / can_edit / allows_field / can("...")` + - 安全兜底:未知 permission 一律回退到最严格的 `read` 策略,防止越权 +- ✨ **姓名脱敏策略复用** + - 复用 `data/orders/masking.py::mask_name`,避免在分享链路重复实现脱敏算法 + - `read/comment` 默认不暴露姓名;若后续 UI 白名单显式放开姓名字段,则自动按 `mask_name` 规则脱敏 + - `edit`(以及历史兼容 `admin`)下姓名原样展示 +- ✨ **权限感知路由辅助**(`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** + #### 新增(T10.1 已完成) - ✨ **GitHub Actions CI 流水线** diff --git a/data/orders/README.md b/data/orders/README.md index e7a22b7..07d7a0a 100644 --- a/data/orders/README.md +++ b/data/orders/README.md @@ -1,6 +1,6 @@ -# 订单数据模块 (T4.1) +# 订单数据模块 (T4.1 + T4.2 + T11.2) -提供订单的 SQLite schema、AES-256 加密(Fernet)、6 态状态机、Order dataclass。 +提供订单的 SQLite schema、AES-256 加密(Fernet)、6 态状态机、Order dataclass,以及完整的 DAO 数据访问层(T4.2)。 ## 目录结构 @@ -11,14 +11,17 @@ data/orders/ ├── crypto.py # Fernet 加密/解密/哈希派生 ├── masking.py # 展示脱敏工具 (T11.2) ├── state_machine.py # 6 态转换验证 -├── models.py # Order dataclass + 序列化(to_dict 支持 mask 策略) +├── models.py # Order dataclass + 序列化 (to_dict 支持 mask 策略) ├── schema.py # DDL + apply_schema() +├── dao.py # OrdersDAO — CRUD + 事务 + 状态机守护 (T4.2) └── tests/ ├── __init__.py ├── test_crypto.py ├── test_masking.py + ├── test_models.py + ├── test_schema.py ├── test_state_machine.py - └── test_schema.py + └── test_dao.py ``` ## 字段分类 @@ -56,40 +59,90 @@ completed / refunded 为终态,不可再转换。 python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" ``` -## 快速使用 +## 快速使用 — DAO 方式(推荐,T4.2) ```python import os os.environ["GAOKAO_ORDERS_FERNET_KEY"] = "your-secret-here" -from data.orders.schema import apply_schema -from data.orders.crypto import encrypt, decrypt, hash_for_index -from data.orders.state_machine import OrderStatus, assert_valid_transition -from data.orders.models import Order, generate_order_id - -# 1. 建表 -conn = apply_schema("data/orders.db") - -# 2. 创建订单 -order = Order( - id=generate_order_id(), - source="xianyu", - service_version="standard", - amount_cents=9900, - status=OrderStatus.PENDING.value, - customer_name="张*", - customer_phone="13800001234", - candidate_name="张同学", -) -conn.execute( - "INSERT INTO orders (...) VALUES (...)", - order.to_db_row(), +from data.orders import ( + OrdersDAO, Order, generate_order_id, + OrderNotFound, DuplicateOrder, UpsertResult, ) -# 3. 状态转换 -assert_valid_transition(order.status, "paid") +# 1. 连接 + 建表(幂等) +with OrdersDAO.connect("data/orders.db") as dao: + # 2. 创建订单(明文 PII 入口,内部加密落盘) + order = Order( + id=generate_order_id(), + source="xianyu", + service_version="standard", + amount_cents=9900, + status="pending", + customer_name="张三", + customer_phone="13800001234", + candidate_name="张同学", + ) + created = dao.create(order, actor="manual_entry") + + # 3. 读回 — PII 自动解密 + out = dao.get(created.id) + assert out.customer_phone == "13800001234" + + # 4. 状态转换(事务内:UPDATE orders + INSERT order_status_history) + paid = dao.transition_status(created.id, "paid", reason="wechat_pay") + + # 5. 查历史 + history = dao.get_status_history(created.id) + for h in history: + print(h.from_status, "→", h.to_status, "by", h.actor) + + # 6. 幂等 upsert(接 T8.1 闲鱼 Webhook) + result = dao.upsert_by_external_id(order) + print(result.action) # 'inserted' / 'updated' / 'unchanged' / 'illegal_transition' + + # 7. 批量 / 显式事务 + with dao.transaction() as conn: + conn.execute(...) # 任何异常自动回滚 ``` +## DAO API 一览 (T4.2) + +| 方法 | 用途 | +| ------------------------------------------------------ | ---------------------------------------------------- | +| `OrdersDAO.connect(path)` | 按路径建连接 + 应用 schema + 强制 `sqlite3.Row` 工厂 | +| `OrdersDAO(conn)` | 接管已有连接(不自动关闭) | +| `dao.create(order, actor=..., reason=...)` | 插入订单 + 写首条 status_history | +| `dao.update(id, {field: value})` | 改业务字段(**禁止改 status**,状态机字段) | +| `dao.transition_status(id, to, ...)` | 状态机守护的转换(事务内 UPDATE + INSERT history) | +| `dao.get(id)` / `dao.get_by_external_id(src, eid)` | 单查(解密 PII) | +| `dao.find_by_phone(phone)` | 按手机号 hash 查询(去重 / 客户识别) | +| `dao.list(status=..., source=..., limit=50, offset=0)` | 分页列表 | +| `dao.count(status=..., source=...)` | 统计 | +| `dao.stats_by_status()` | 按状态分组统计(含 0 计数的完整 6 态) | +| `dao.get_status_history(id)` | 状态变更时间线(`StatusChange` 列表) | +| `dao.upsert_by_external_id(order, ...)` | 幂等 upsert(4 种 `action` 区分) | +| `dao.delete(id)` | 物理删除(CASCADE 清空 status_history) | +| `dao.transaction()` | 显式事务上下文(异常回滚 / 正常提交) | +| `with dao as ...:` | 上下文管理器(推荐) | + +## 异常 + +- `OrderNotFound(LookupError)` — 主键 / 唯一键查不到 +- `DuplicateOrder(ValueError)` — 主键或 `(source, external_id)` 冲突 +- `InvalidStateTransition(ValueError)` — 非法状态转换(`transition_status` / `upsert` 时) + +## 加密透明化的边界 + +| 入参 / 出参 | 形态 | +| -------------------------------- | -------------------------------------------------- | +| `create(order)` 入参 Order | **明文 PII** | +| `create()` 抛 `DuplicateOrder` | DB 已落密文 | +| `get(id).customer_phone` | **明文**(已解密) | +| `get(id).customer_phone_enc` | **不存在**(DAO 内部消化) | +| `list()` / `find_by_phone()` | 全部解密返回 Order | +| `to_dict(decrypt_sensitive=...)` | 应用层控制(True 明文 / False 移除 / "mask" 遮罩) | + ## 验收(DoD) - [x] AES-256 加密(Fernet)对 customer_phone / candidate_id_card 落盘 @@ -98,7 +151,8 @@ assert_valid_transition(order.status, "paid") - [x] 外键约束启用 - [x] SHA-256 hash 字段支持手机号去重 - [x] CHECK 约束拒绝非法 status -- [x] 展示脱敏(T11.2):Order.to_dict 默认走 mask 策略(手机/身份证/姓名) +- [x] 展示脱敏(T11.2):Order.to_dict 默认走 mask 策略 +- [x] **T4.2 DAO**:CRUD + 事务 + 加密字段处理 + 状态机守护(51 用例全绿) ## 展示脱敏 (T11.2) @@ -137,11 +191,13 @@ order.to_dict(decrypt_sensitive=False) ## 下游衔接 -- **T4.2 DAO**: 调用 `apply_schema()` 建表,使用 `state_machine.assert_valid_transition()` 守护状态变更 -- **T4.3 CLI**: 直接 import `Order` dataclass + crypto -- **T6.1 FastAPI**: schema 不变,DAO 复用 +- **T4.3 CLI**: `gaokao-order-manager` 直接 import `OrdersDAO` + `Order` +- **T6.1 FastAPI**: schema 不变,DAO 复用(API 层只做权限校验 + `to_dict(decrypt_sensitive=...)`) +- **T8.1 闲鱼 Webhook**: 用 `dao.upsert_by_external_id()` 取代 `data/channel_sync/dao_extension.py` + 的同名函数(行为已对齐 — 4 种 `action` 字符串完全相同;该扩展模块可删除) ## 版本 -v1.1 — 2026-06-12 — T11.2 展示脱敏(mask 策略 + masking.py) +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/docs/plans/T4-2-orders-dao.md b/docs/plans/T4-2-orders-dao.md new file mode 100644 index 0000000..b733530 --- /dev/null +++ b/docs/plans/T4-2-orders-dao.md @@ -0,0 +1,167 @@ +# T4.2 实现订单数据访问层(DAO) - 实施记录 + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal**: 订单 DAO 层(CRUD + 事务 + 加密字段处理 + 6 态状态机守护),落地为 `data/orders/dao.py` + +**Architecture**: + +- `OrdersDAO` class — 封装 `sqlite3.Connection`,所有方法自包含事务或事务上下文 +- 加密/解密透明化 — API 入口 `Order` dataclass 走明文 PII,DAO 内部 `to_db_row()` / `from_db_row()` 转换 +- 状态机守护 — `transition_status()` 单事务内 `UPDATE orders` + `INSERT order_status_history` +- 嵌套事务 — `_tx_depth` 计数;外层事务中内层不重复 commit,异常统一回滚外层 +- row_factory 隔离 — `_row_factory_ctx()` 临时切 `sqlite3.Row`、退出后恢复 + +**Tech Stack**: Python 3.10+, sqlite3 (stdlib), pytest, ruff + +**Ref**: TECH_ARCHITECTURE.md §3.4 数据模型 + §6.1 数据安全; REMEDIATION_TASK_BOARD P2-9; T4-1-order-schema.md + +--- + +## 1. 设计原则 + +### 1.1 API 形态 + +```python +with OrdersDAO.connect("data/orders.db") as dao: + # 1) 写 + created = dao.create(order, actor="manual_entry", reason="wechat_inquiry") + dao.update(created.id, {"plan_file": "/data/plans/abc.md"}) + paid = dao.transition_status(created.id, "paid", reason="wechat_pay") + # 2) 查 + out = dao.get(created.id) # 解密 PII + rows = dao.list(status="paid", limit=20) + history = dao.get_status_history(created.id) + # 3) 幂等 + result = dao.upsert_by_external_id(order) # 'inserted'|'updated'|'unchanged'|'illegal_transition' + # 4) 显式事务 + with dao.transaction() as conn: + conn.execute(...) +``` + +### 1.2 加密透明化 + +| API | 形态 | +| ---------------------------- | ------------------------------- | +| `create(order)` 入参 | `Order`(明文 PII) | +| `get(id).customer_phone` | **明文**(已解密) | +| `get(id).customer_phone_enc` | **不存在**(DAO 内部消化) | +| `list()` / `find_by_phone()` | 全部解密返回 `Order` | +| `Order.to_dict(decrypt=...)` | 应用层控制(True/False/"mask") | + +### 1.3 状态机守护 + +- 6 态转换图(沿用 T4.1 状态机):`pending → paid → serving → delivered → completed`,任意非终态 → `refunded` +- `transition_status(id, to, ...)` 单事务: + 1. 读 `SELECT status FROM orders WHERE id=?` + 2. `assert_valid_transition(from, to)` —— 非法抛 `InvalidStateTransition` 并回滚 + 3. `UPDATE orders SET status=?, status_updated_at=?, =COALESCE(, ?)` + 4. `INSERT INTO order_status_history(from, to, actor, reason)` +- 时间戳联动:`paid → paid_at`、`serving → started_at`、`delivered → delivered_at`、`completed → completed_at` 由状态自动置位(`COALESCE` 保留首次值) + +### 1.4 幂等 upsert 契约(与 T8.1 对齐) + +`upsert_by_external_id()` 返回 4 种 `action` 字符串,与 `data/channel_sync/dao_extension.upsert_by_external_id` 完全对齐: + +| action | 含义 | +| -------------------- | -------------------------------------------- | +| `inserted` | DB 不存在该 `(source, external_id)`,已新建 | +| `updated` | 已存在且状态可推进,已 UPDATE + 写 history | +| `unchanged` | 已存在但状态未变 | +| `illegal_transition` | 已存在但状态非法回退;`error` 字段附非法原因 | + +T8.1 现有的 `data/channel_sync/dao_extension.py` 可在迁移完成后删除(行为已对齐)。 + +### 1.5 事务嵌套 + +```python +def transaction(self): + self._tx_depth += 1 + try: + if self._tx_depth == 1: + yield self._conn + self._conn.commit() + else: + yield self._conn # 嵌套不 commit + except Exception: + if self._tx_depth == 1: + self._conn.rollback() + raise + finally: + self._tx_depth -= 1 +``` + +这让 `create()` 内部用 `with self.transaction():` 时,在外层 `dao.transaction()` 包住的情况下不会重复 commit;任一层异常 → 外层回滚。 + +--- + +## 2. 模块结构 + +### 2.1 新增文件 + +``` +data/orders/ +├── dao.py # T4.2 — OrdersDAO +└── tests/ + └── test_dao.py # T4.2 — 51 用例 +``` + +### 2.2 修改文件 + +- `data/orders/__init__.py` — 导出 `OrdersDAO` / `UpsertResult` / `StatusChange` / `OrderNotFound` / `DuplicateOrder` +- `data/orders/README.md` — DAO API 一览表 + 加密透明化边界表 + 下游衔接 +- `CHANGELOG.md` — T4.2 条目 + +--- + +## 3. DoD + +- [x] CRUD 完整(create / get / list / count / update / delete) +- [x] 事务守护(`transaction()` 上下文 + 嵌套语义) +- [x] 加密字段透明化(明文入口 → 密文落盘 → 明文读回) +- [x] 6 态状态机守护(合法转换 + 非法回滚 + 终态不可再转) +- [x] 时间戳联动(paid_at / started_at / delivered_at / completed_at) +- [x] 幂等 upsert(4 种 action 与 T8.1 对齐) +- [x] 状态历史审计(`order_status_history` 自动写入) +- [x] 重复主键 / 重复 `(source, external_id)` 抛 `DuplicateOrder` +- [x] `update()` 显式拒绝 `status` 字段(必须走 `transition_status`) +- [x] row_factory 不污染外部连接 +- [x] 模块 51 用例全绿 / data/orders 总计 163 用例全绿 / data/ 386 全绿 +- [x] ruff 0 warning / py_compile clean + +--- + +## 4. 验证命令 + +```bash +cd /home/long/project/gaokao-volunteer-system +python3 -m pytest data/orders/tests/test_dao.py -v +python3 -m pytest data/orders/tests/ -q # 163/163 +python3 -m pytest data/ -q # 386/386 +ruff check data/orders/ +python3 -m py_compile data/orders/dao.py data/orders/__init__.py +``` + +--- + +## 5. 风险与降级 + +| 风险 | 降级策略 | +| ------------------------------------- | --------------------------------------------------------------------------------------- | +| Fernet 密钥丢失 → 密文不可恢复 | 启动时校验密钥存在;强制备份流程写入 runbook(沿用 T4.1) | +| `transactions` 嵌套语义不直观 | 文档明示 + 测试覆盖三层嵌套 (`create()` 内部 `transaction()` + 外层 `transaction()`) | +| `dao_extension` 与本 DAO 行为漂移 | `TestContractAlignment` 测试组 4 种 action 显式对齐;dao_extension 删除前必须过这个测试 | +| 业务字段在 `update()` 中误传 `status` | 显式 `ValueError("禁止通过 update() 改 status;请使用 transition_status()")` | + +--- + +## 6. 下游衔接 + +- **T4.3 CLI (`gaokao-order-manager`)**: 直接 `with OrdersDAO.connect(...) as dao:` +- **T6.1 FastAPI**: DAO 复用 + `to_dict(decrypt_sensitive=...)` 控制 API 响应 +- **T8.1 闲鱼 Webhook**: `dao.upsert_by_external_id()` 替代 `dao_extension.upsert_by_external_id` +- **T11.1 性能基线**: DAO 的 `list()` 走 `customer_phone_hash` / `(source, external_id)` 索引 + +--- + +**版本**: v1.0 | **最后更新**: 2026-06-12 | **作者**: coder (kanban t_168e0ece)