- data/orders/README.md: 增加 DAO API 一览表 + 加密透明化边界表 - docs/plans/T4-2-orders-dao.md: 实施记录 (设计原则 / DoD / 验证 / 风险) - CHANGELOG.md: T4.2 条目(51 用例 / 163 总 / 386 data 全绿)
204 lines
8.7 KiB
Markdown
204 lines
8.7 KiB
Markdown
# 订单数据模块 (T4.1 + T4.2 + T11.2)
|
||
|
||
提供订单的 SQLite schema、AES-256 加密(Fernet)、6 态状态机、Order dataclass,以及完整的 DAO 数据访问层(T4.2)。
|
||
|
||
## 目录结构
|
||
|
||
```
|
||
data/orders/
|
||
├── __init__.py
|
||
├── README.md
|
||
├── crypto.py # Fernet 加密/解密/哈希派生
|
||
├── masking.py # 展示脱敏工具 (T11.2)
|
||
├── state_machine.py # 6 态转换验证
|
||
├── 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_dao.py
|
||
```
|
||
|
||
## 字段分类
|
||
|
||
| 类别 | 处理方式 | 字段 |
|
||
| ---------- | ----------------------- | ------------------------------------- |
|
||
| 强敏感 PII | AES-256 加密 + 展示遮罩 | `customer_phone`, `candidate_id_card` |
|
||
| 弱敏感 PII | 脱敏存储 + 展示遮罩 | `customer_name`, `candidate_name` |
|
||
| 索引用哈希 | SHA-256 hex | `customer_phone_hash` |
|
||
| 业务字段 | 明文 | `amount_cents`, `source`, `status` |
|
||
| 元数据 | 明文 | `created_at`, `tags` |
|
||
|
||
> T11.2 起,`Order.to_dict()` 默认走"展示遮罩"(如 `138****1234`),
|
||
> API 响应 / 前端列表无需再做额外处理。
|
||
|
||
## 6 态状态机
|
||
|
||
```
|
||
pending → paid → serving → delivered → completed
|
||
│ │ │ │
|
||
└──┬─────┴────────┴──────────┘
|
||
▼
|
||
refunded (终态)
|
||
```
|
||
|
||
completed / refunded 为终态,不可再转换。
|
||
|
||
## 加密密钥
|
||
|
||
环境变量 `GAOKAO_ORDERS_FERNET_KEY` 必须配置;缺失时 `get_fernet()` 抛 `MissingEncryptionKey`,**禁止降级为明文**。
|
||
|
||
生产环境密钥生成示例:
|
||
|
||
```bash
|
||
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 import (
|
||
OrdersDAO, Order, generate_order_id,
|
||
OrderNotFound, DuplicateOrder, UpsertResult,
|
||
)
|
||
|
||
# 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 落盘
|
||
- [x] 6 态状态机 + 转换合法性校验
|
||
- [x] SQLite schema 幂等可重建
|
||
- [x] 外键约束启用
|
||
- [x] SHA-256 hash 字段支持手机号去重
|
||
- [x] CHECK 约束拒绝非法 status
|
||
- [x] 展示脱敏(T11.2):Order.to_dict 默认走 mask 策略
|
||
- [x] **T4.2 DAO**:CRUD + 事务 + 加密字段处理 + 状态机守护(51 用例全绿)
|
||
|
||
## 展示脱敏 (T11.2)
|
||
|
||
为防止 API 响应 / 前端 / 日志 / 截图泄露完整 PII,订单数据模型支持"展示遮罩":
|
||
|
||
```python
|
||
from data.orders.models import Order
|
||
|
||
order = Order(
|
||
id="GKO-20260612-AAAA",
|
||
source="web",
|
||
service_version="basic",
|
||
customer_name="张三",
|
||
customer_phone="13800001234",
|
||
candidate_id_card="430102200501011234",
|
||
)
|
||
|
||
# 默认:mask 模式 — 适合 API 响应 / 前端
|
||
order.to_dict()
|
||
# {
|
||
# "customer_phone": "138****1234",
|
||
# "candidate_id_card": "430102********1234",
|
||
# "customer_name": "张*",
|
||
# ...
|
||
# }
|
||
|
||
# 显式明文(后台人工核对场景)
|
||
order.to_dict(decrypt_sensitive=True)
|
||
|
||
# 完全移除(对外公开统计/审计日志)
|
||
order.to_dict(decrypt_sensitive=False)
|
||
```
|
||
|
||
`masking` 模块是纯字符串工具,与 `crypto` 正交,可在任意层(API 序列化、Jinja2
|
||
模板过滤器、日志格式化、CSV 导出器)独立复用。
|
||
|
||
## 下游衔接
|
||
|
||
- **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.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 实施
|