docs(orders): T4.2 DAO 文档 + 实施记录 + CHANGELOG

- data/orders/README.md: 增加 DAO API 一览表 + 加密透明化边界表
- docs/plans/T4-2-orders-dao.md: 实施记录 (设计原则 / DoD / 验证 / 风险)
- CHANGELOG.md: T4.2 条目(51 用例 / 163 总 / 386 data 全绿)
This commit is contained in:
coder
2026-06-12 16:36:32 +08:00
parent 6af6e0156b
commit ef4bb46295
3 changed files with 304 additions and 34 deletions

View File

@@ -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明文 PIIDAO 内部用 `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 流水线**

View File

@@ -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, ...)` | 幂等 upsert4 种 `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.2Order.to_dict 默认走 mask 策略(手机/身份证/姓名)
- [x] 展示脱敏T11.2Order.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 warningdata/ 386 用例全绿)
v1.1 — 2026-06-12 — T11.2 展示脱敏mask 策略 + masking.py
v1.0 — 2026-06-12 — T4.1 实施

View File

@@ -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 走明文 PIIDAO 内部 `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=?, <ts_col>=COALESCE(<ts_col>, ?)`
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] 幂等 upsert4 种 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)