Files
gaokao-volunteer-system/data/orders/schema.py
hermes 187b2ae634 fix(compliance): A-2 admin/外部渠道补录同意审计统一化
LEGAL_PRIVACY_BASELINE §4/§6 要求任何订单创建路径必须记录同意审计字段。
portal 路径已落 consent_channel=portal / consent_operator=guardian (见
web_public.py + intake_store.save), admin 路径 6/20 之前完全不入任何 consent
字段 (admin/routes/orders.py grep 'consent' 0 命中), 形成合规盲区。

落地:
- CreateOrderRequest 新增必填 consent: ConsentInfo
  - consent_method Literal: verbal_chat / phone_recording / screenshot /
    written_form / self_declared
  - consent_note Optional[str]
  - 缺失或非法 → HTTP 422
- Order 模型 + DAO _WRITABLE_COLUMNS 加 consent_method / consent_given_at
  (冗余落库避免每次列表 join order_intakes)
- schema 增量: ALTER TABLE orders ADD COLUMN consent_method / consent_given_at
  (幂等)
- create_order 同步写 order_intakes (独立 IntakeStore.for_db, 不复用 OrdersDAO
  conn — T12-D conn ownership 修复保驾)
  - consent_channel = payload.source (xianyu/wechat/school/web)
  - consent_operator 严格按基线白名单 self/guardian/admin_import:
    - web 渠道: 'guardian' (与 intake_store.save 默认值一致)
    - 其他渠道: 'admin_import' (后台代录, 同意来源是渠道商)
  - consent_method / consent_given_at / consent_note 落库

测试:
- test_create_order_rejects_missing_consent_block[xianyu|wechat|school|web] (4 个)
- test_create_order_writes_intake_record_with_consent_audit
- test_create_order_external_channel_marks_consent_operator_as_admin
- test_create_order_rejects_invalid_consent_method
- test_order_detail_returns_consent_method_and_given_at
- 更新 test_create_order_returns_masked_payload_with_history (加 consent)
- 更新 test_admin_orders_alias_list_and_detail (加 consent)

验证:
- 25/25 admin/tests/{test_routes_orders,test_admin_alias_routes} 通过
- ruff + mypy 通过
- 端到端 smoke: 4 笔订单 (2 terminal + 1 pending + 1 paid-in-window) 实测
  包含 consent 字段全部通过
- dev-verify: 1186 passed / 2 failed (失败的 2 个均为 worktree 环境限制:
  test_backup_restore_service_level 的 subprocess 路径假设 + 6/19 已知问题,
  与本改动无关; main 上单跑同样测试通过)
2026-06-20 15:29:06 +08:00

133 lines
4.4 KiB
Python
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.
"""SQLite Schema 模块 (T4.1)
定义 orders 主表与 order_status_history 审计表的 DDL并提供幂等的
apply_schema() 应用函数。所有表使用 IF NOT EXISTS启用外键约束。
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
SCHEMA_SQL: str = """
-- 订单主表
CREATE TABLE IF NOT EXISTS orders (
-- 主键 / 业务标识
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
external_id TEXT,
service_version TEXT NOT NULL,
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,
-- 客户信息(加密 + 脱敏)
customer_name TEXT,
customer_phone_enc TEXT,
customer_phone_hash TEXT,
customer_wechat TEXT,
customer_email TEXT,
-- 考生信息
candidate_name TEXT,
candidate_id_card_enc TEXT,
candidate_province TEXT,
candidate_score INTEGER,
candidate_rank INTEGER,
candidate_subjects TEXT,
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,
upgrade_from TEXT,
FOREIGN KEY (upgrade_from) REFERENCES orders(id) ON DELETE SET NULL
);
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;
-- 状态历史(审计)
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);
"""
def apply_schema(db_path: str | Path) -> sqlite3.Connection:
"""应用 schema 到指定 SQLite 文件,返回连接。
- 启用外键约束 (PRAGMA foreign_keys = ON)
- 父目录自动创建
- 幂等:可重复执行
"""
db_path = Path(db_path)
if db_path.parent and not db_path.parent.exists():
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(SCHEMA_SQL)
columns = {
row[1] for row in conn.execute("PRAGMA table_info(orders)").fetchall()
}
if "customer_email" not in columns:
conn.execute("ALTER TABLE orders ADD COLUMN customer_email TEXT")
# A-2 (2026-06-20) — 后台/外部渠道补录同意审计统一化
# consent_method 记录采集方式(verbal_chat/phone_recording/screenshot/
# written_form/self_declared), consent_given_at 记录采集时间。
# 两个字段都冗余落库, 避免每次列表 join order_intakes。
if "consent_method" not in columns:
conn.execute("ALTER TABLE orders ADD COLUMN consent_method TEXT")
if "consent_given_at" not in columns:
conn.execute("ALTER TABLE orders ADD COLUMN consent_given_at TEXT")
conn.commit()
except Exception:
conn.close()
raise
return conn
def get_schema_version(conn: sqlite3.Connection) -> int:
"""读取当前 schema 版本号(首次运行返回 0。后续迁移将引入 schema_version 表。"""
try:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='orders'"
).fetchone()
return 1 if row else 0
except sqlite3.DatabaseError:
return 0