Files
gaokao-volunteer-system/docs/plans/T4-2-orders-dao.md
coder ef4bb46295 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 全绿)
2026-06-12 16:36:32 +08:00

168 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)