Files
gaokao-volunteer-system/data/orders
coder 9168a10d75 feat(orders): T4.2 DAO 数据访问层 (CRUD + 事务 + 加密 + 状态机)
- data/orders/dao.py (530 lines): OrdersDAO class
  * CRUD: create / get / get_by_external_id / find_by_phone / list / count / stats_by_status / update / delete
  * 事务: transaction() 上下文 + 嵌套深度计数(外层事务中内层不重复 commit)
  * 加密透明化: API 入口 Order dataclass 走明文 PII,DAO 内部 to_db_row/from_db_row 转换
  * 状态机守护: transition_status() 单事务内 UPDATE orders + INSERT order_status_history
    + 时间戳联动 (paid_at/started_at/delivered_at/completed_at COALESCE 保留首次值)
  * 幂等 upsert_by_external_id: 4 种 action 与 data/channel_sync/dao_extension 对齐
  * 防御: update() 显式拒绝 status 字段; _row_factory_ctx() 不污染外部 row_factory
  * 异常: OrderNotFound(LookupError) / DuplicateOrder(ValueError)

- data/orders/tests/test_dao.py (51 cases): 加密透明化、状态机合法/非法路径、
  事务回滚、upsert 4 种 action、状态历史时间线、row_factory 隔离、删除 cascade、
  与 dao_extension 的契约对齐

- data/orders/__init__.py: 导出 OrdersDAO / UpsertResult / StatusChange /
  OrderNotFound / DuplicateOrder

- 验证: data/orders/tests/ 163/163, data/ 386/386, ruff 0 warning, py_compile clean
2026-06-12 16:36:51 +08:00
..

订单数据模块 (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禁止降级为明文

生产环境密钥生成示例:

python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

快速使用 — DAO 方式推荐T4.2

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, ...) 幂等 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

  • AES-256 加密Fernet对 customer_phone / candidate_id_card 落盘
  • 6 态状态机 + 转换合法性校验
  • SQLite schema 幂等可重建
  • 外键约束启用
  • SHA-256 hash 字段支持手机号去重
  • CHECK 约束拒绝非法 status
  • 展示脱敏T11.2Order.to_dict 默认走 mask 策略
  • T4.2 DAOCRUD + 事务 + 加密字段处理 + 状态机守护51 用例全绿)

展示脱敏 (T11.2)

为防止 API 响应 / 前端 / 日志 / 截图泄露完整 PII订单数据模型支持"展示遮罩"

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 warningdata/ 386 用例全绿) v1.1 — 2026-06-12 — T11.2 展示脱敏mask 策略 + masking.py v1.0 — 2026-06-12 — T4.1 实施