Files
gaokao-volunteer-system/data/orders/schema.py

133 lines
4.4 KiB
Python
Raw Normal View History

"""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")
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
# 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