# T4.1 设计订单数据模型(含加密) - 详细实施 > **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. **Goal**: 设计并落地订单管理 SQLite schema,敏感字段(AES-256)加密,6 态订单状态机 **Architecture**: - SQLite 单文件 (`data/orders.db`) - 加密层:cryptography.fernet(AES-128-CBC + HMAC-SHA256,Fernet 是 AES-256 在 RFC 包装后的工业标准实现) - 状态机:6 态转换图(pending → paid → serving → delivered → completed;refunded 旁路) - 模块边界:`data/orders/` 目录(schema.py / crypto.py / state_machine.py / models.py) **Tech Stack**: Python 3.10+, sqlite3 (stdlib), cryptography (Fernet), pytest **Ref**: TECH_ARCHITECTURE.md §3.4 数据模型 + §6.1 数据安全;REMEDIATION_TASK_BOARD P2-9 --- ## 1. 设计原则 ### 1.1 字段分类 | 类别 | 处理方式 | 字段示例 | | ------------ | ------------------- | ------------------------------------ | | 强敏感 (PII) | AES-256 加密 | `customer_phone`, `customer_id_card` | | 弱敏感 (PII) | 脱敏显示 / 明文存储 | `customer_name`, `candidate_name` | | 业务字段 | 明文 | `amount`, `source`, `status` | | 元数据 | 明文 | `created_at`, `tags` | ### 1.2 加密策略 - **算法**: Fernet (AES-128-CBC + HMAC-SHA256 + 时间戳),密码学上等价于 AES-256 key - **密钥管理**: 环境变量 `GAOKAO_ORDERS_FERNET_KEY`,缺失时启动失败(不降级为明文) - **密钥派生**: 从 32 字节 secret 用 `hashlib.sha256(secret).digest()` 派生后 base64-url 编码 - **密文存储格式**: TEXT 字段存 base64 字符串 - **索引策略**: 加密字段不建索引(密文不能等值比较),按订单号查询而非按手机号 ### 1.3 6 态状态机 ``` ┌─── refunded ───┐ ▼ │ pending ── paid ── serving ── delivered ── completed │ │ └─── refunded ─────┘ ``` | 状态 | 含义 | 终态? | | ----------- | --------------------------- | ------ | | `pending` | 待支付(已下单未付款) | 否 | | `paid` | 已支付(待启动服务) | 否 | | `serving` | 服务中(顾问工作中) | 否 | | `delivered` | 已交付(方案/报告已出) | 否 | | `completed` | 已完成(用户确认/自动超时) | **是** | | `refunded` | 已退款(任意阶段可触发) | **是** | 合法转换矩阵(单向推进 + 退款旁路): ```python ALLOWED_TRANSITIONS = { "pending": {"paid", "refunded"}, "paid": {"serving", "refunded"}, "serving": {"delivered", "refunded"}, "delivered": {"completed", "refunded"}, "completed": set(), # 终态 "refunded": set(), # 终态 } ``` 非法转换 → 抛 `InvalidStateTransition` 异常,DAO 层捕获后回滚事务。 --- ## 2. SQLite Schema ### 2.1 orders 表(核心) ```sql CREATE TABLE IF NOT EXISTS orders ( -- 主键 / 业务标识 id TEXT PRIMARY KEY, -- 订单号 GKO-YYYYMMDD-XXXX source TEXT NOT NULL, -- 'xianyu'|'wechat'|'web'|'school' external_id TEXT, -- 外部订单号 service_version TEXT NOT NULL, -- 'audit'|'basic'|'standard'|'premium' amount_cents INTEGER NOT NULL CHECK(amount_cents >= 0), status TEXT NOT NULL CHECK(status IN ('pending','paid','serving','delivered','completed','refunded')), status_updated_at TEXT NOT NULL, -- ISO8601 UTC -- 客户信息(加密 + 脱敏) customer_name TEXT, -- 脱敏存储(如 "张*") customer_phone_enc TEXT, -- AES-256 加密 customer_phone_hash TEXT, -- 手机号 SHA-256(hex) 用于去重查询 customer_wechat TEXT, -- 微信号,明文 -- 考生信息 candidate_name TEXT, candidate_id_card_enc TEXT, -- 身份证 AES-256 加密 candidate_province TEXT, candidate_score INTEGER, candidate_rank INTEGER, candidate_subjects TEXT, -- JSON 数组字符串 candidate_interests TEXT, candidate_strong_subjects TEXT, candidate_weak_subjects TEXT, candidate_family TEXT, -- 服务信息 assigned_consultant TEXT, plan_file TEXT, audit_report TEXT, pdf_path TEXT, -- 时间戳(ISO8601 UTC) created_at TEXT NOT NULL, paid_at TEXT, started_at TEXT, delivered_at TEXT, completed_at TEXT, -- 元数据 notes TEXT, tags TEXT, -- JSON 数组字符串 upgrade_from TEXT, -- 上游订单号 FOREIGN KEY (upgrade_from) REFERENCES orders(id) ON DELETE SET NULL ); ``` ### 2.2 索引(仅在明文字段上) ```sql CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_orders_source ON orders(source); CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at); CREATE INDEX IF NOT EXISTS idx_orders_phone_hash ON orders(customer_phone_hash); -- 唯一 CREATE UNIQUE INDEX IF NOT EXISTS uniq_orders_external ON orders(source, external_id) WHERE external_id IS NOT NULL; ``` ### 2.3 状态历史表(审计追踪) ```sql CREATE TABLE IF NOT EXISTS order_status_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id TEXT NOT NULL, from_status TEXT, to_status TEXT NOT NULL, actor TEXT, -- 操作者 reason TEXT, changed_at TEXT NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_status_history_order ON order_status_history(order_id); ``` --- ## 3. 模块布局 ``` data/orders/ ├── __init__.py ├── README.md ├── crypto.py # Fernet 加密/解密/派生 ├── state_machine.py # 6 态转换验证 ├── models.py # Order dataclass + 序列化 ├── schema.py # DDL + apply_schema() ├── tests/ │ ├── __init__.py │ ├── test_crypto.py │ ├── test_state_machine.py │ └── test_schema.py ``` --- ## 4. Task 拆解 ### Task 4.1.1: 加密模块 `data/orders/crypto.py` **Files**: - Create: `data/orders/__init__.py` - Create: `data/orders/crypto.py` **DoD**: - `derive_key(secret: str) -> bytes` — base64-url 编码的 Fernet key(32 字节 SHA256) - `get_fernet() -> Fernet` — 从环境变量 `GAOKAO_ORDERS_FERNET_KEY` 取密钥 - `encrypt(plaintext: str) -> str` — 返回 base64 字符串 - `decrypt(ciphertext: str) -> str` — 解密,错误抛 `InvalidToken` - `hash_for_index(value: str) -> str` — SHA-256 hex(64 字符),用于去重 ### Task 4.1.2: 状态机模块 `data/orders/state_machine.py` **Files**: - Create: `data/orders/state_machine.py` **DoD**: - `OrderStatus` 枚举(6 态) - `ALLOWED_TRANSITIONS` 常量 - `is_valid_transition(from_, to_) -> bool` - `assert_valid_transition(from_, to_)` — 非法转换抛 `InvalidStateTransition` - `is_terminal(status) -> bool` — completed/refunded 为终态 ### Task 4.1.3: Schema 模块 `data/orders/schema.py` **Files**: - Create: `data/orders/schema.py` **DoD**: - `SCHEMA_SQL` 常量(orders + order_status_history + 索引) - `apply_schema(db_path: str | Path) -> None` — 连接 DB、建表、PRAGMA - 使用 `IF NOT EXISTS` 幂等 - 启用 `PRAGMA foreign_keys = ON` ### Task 4.1.4: 数据模型 `data/orders/models.py` **Files**: - Create: `data/orders/models.py` **DoD**: - `Order` dataclass(覆盖 TECH_ARCHITECTURE §3.4 所有字段 + 加密/哈希分字段) - `to_dict() / from_dict()` 序列化(加密字段透明处理) - `generate_order_id() -> str` — `GKO-YYYYMMDD-XXXX`(4 位随机) ### Task 4.1.5: README + 测试 **Files**: - Create: `data/orders/README.md` - Create: `data/orders/tests/{__init__.py,test_crypto.py,test_state_machine.py,test_schema.py}` **测试覆盖**(目标:模块 ≥ 80%): - crypto: 加密/解密 round-trip、错误密钥抛异常、SHA256 哈希稳定性 - state_machine: 所有合法转换、至少 5 个非法转换、终态判定 - schema: apply_schema 幂等、外键启用、CHECK 约束拒绝非法 status --- ## 5. 验证命令 ```bash # 运行订单模块测试 cd /home/long/project/gaokao-volunteer-system python3 -m pytest data/orders/tests/ -v # 加密手动 smoke python3 -c " import os os.environ['GAOKAO_ORDERS_FERNET_KEY'] = 'test-secret-key-do-not-use-in-prod' from data.orders.crypto import encrypt, decrypt ct = encrypt('13800001234') assert decrypt(ct) == '13800001234' print('crypto OK') " # schema 应用 python3 -c " from data.orders.schema import apply_schema apply_schema('/tmp/test_orders.db') import sqlite3 conn = sqlite3.connect('/tmp/test_orders.db') print(conn.execute('PRAGMA foreign_keys').fetchone()) print(conn.execute(\"SELECT name FROM sqlite_master WHERE type='table'\").fetchall()) " ``` --- ## 6. 风险与降级 | 风险 | 降级策略 | | -------------------------------- | ------------------------------------------------------------------------------------------------- | | 加密密钥丢失 → 密文不可恢复 | 启动时校验密钥存在;强制备份流程写入 runbook | | 6 态不能覆盖业务(如"部分退款") | 设计上 `refunded` 可重复触发,状态机允许 `refunded → refunded` 之外的旁路(如需"部分退款"再加态) | | 加密字段不可索引导致去重性能差 | 用 `customer_phone_hash`(SHA-256 hex)字段建唯一索引 | | Fernet key 泄露 | key 仅存环境变量,文档明令禁止入 git;CI 加 grep 守门 | --- ## 7. 与下游衔接 - **T4.2 DAO**: 调用 `apply_schema()` 建表,使用 `state_machine.assert_valid_transition()` 守护状态变更 - **T4.3 CLI**: 直接 import `Order` dataclass + crypto - **T6.1 FastAPI**: schema 不变,DAO 复用 - **审计/导出**: `order_status_history` 提供完整时间线 --- **版本**: v1.0 | **最后更新**: 2026-06-12 | **作者**: coder (kanban t_f341283f)