Files
gaokao-volunteer-system/data/orders/models.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

191 lines
7.2 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.
"""订单数据模型 (T4.1)
Order dataclass 覆盖 TECH_ARCHITECTURE §3.4 所有字段;敏感字段按加密/明文分字段存储。
to_dict / from_dict 负责明文↔密文自动转换DAO 层直接调用)。
"""
from __future__ import annotations
import json
import random
import string
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from typing import Any, List, Optional, Union
from .crypto import encrypt, decrypt, hash_for_index
from .masking import mask_sensitive_dict
# 解密策略:
# - True : 完整明文(权限内接口使用,如后台人工核对)
# - False : 完全移除明文字段(对外公开统计/审计日志)
# - "mask": 部分遮罩(列表/详情默认,138****1234)
DecryptPolicy = Union[bool, str]
def utc_now_iso() -> str:
"""返回当前 UTC 时间的 ISO8601 字符串(秒精度)。"""
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def generate_order_id() -> str:
"""生成订单号 GKO-YYYYMMDD-XXXX4 位大写字母+数字)。"""
date_part = datetime.now(timezone.utc).strftime("%Y%m%d")
rand = "".join(random.choices(string.ascii_uppercase + string.digits, k=4))
return f"GKO-{date_part}-{rand}"
@dataclass
class Order:
"""订单数据模型。
加密约定:
- customer_phone / candidate_id_card 在 from_dict 时加密to_dict 时解密;
数据库落盘只看到 _enc 后缀字段。
- customer_phone_hash 仅用于去重查询SHA-256 hex
"""
id: str
source: str # 'xianyu'|'wechat'|'web'|'school'
external_id: Optional[str] = None
service_version: str = "basic" # 'audit'|'basic'|'standard'|'premium'
amount_cents: int = 0
status: str = "pending"
status_updated_at: Optional[str] = None
# 客户(明文/加密分字段)
customer_name: Optional[str] = None
customer_phone: Optional[str] = None # 明文API 入口接收)
customer_phone_hash: Optional[str] = None # 自动派生
customer_wechat: Optional[str] = None
customer_email: Optional[str] = None # 明文API 入口接收,落盘加密)
# 考生
candidate_name: Optional[str] = None
candidate_id_card: Optional[str] = None # 明文API 入口接收)
candidate_province: Optional[str] = None
candidate_score: Optional[int] = None
candidate_rank: Optional[int] = None
candidate_subjects: List[str] = field(default_factory=list)
candidate_interests: Optional[str] = None
candidate_strong_subjects: Optional[str] = None
candidate_weak_subjects: Optional[str] = None
candidate_family: Optional[str] = None
# 服务
assigned_consultant: Optional[str] = None
plan_file: Optional[str] = None
audit_report: Optional[str] = None
pdf_path: Optional[str] = None
# 时间戳
created_at: Optional[str] = None
paid_at: Optional[str] = None
started_at: Optional[str] = None
delivered_at: Optional[str] = None
completed_at: Optional[str] = None
# 元数据
notes: Optional[str] = None
tags: List[str] = field(default_factory=list)
upgrade_from: Optional[str] = None
# 同意审计A-2 2026-06-20 落地,冗余落库避免每次列表 join order_intakes
# - consent_method: 见 routes/orders.py 中 ConsentInfo 的 consent_method Literal
# - consent_given_at: ISO8601, 与 order_intakes.payload_json.consent_given_at 同源
consent_method: Optional[str] = None
consent_given_at: Optional[str] = None
def __post_init__(self) -> None:
"""自动派生 hash 与时间戳。"""
if self.customer_phone and not self.customer_phone_hash:
self.customer_phone_hash = hash_for_index(self.customer_phone)
if not self.created_at:
self.created_at = utc_now_iso()
if not self.status_updated_at:
self.status_updated_at = self.created_at
# tags/subjects 入库为 JSON 字符串
if isinstance(self.tags, list):
self._tags_json = json.dumps(self.tags, ensure_ascii=False)
if isinstance(self.candidate_subjects, list):
self._subjects_json = json.dumps(
self.candidate_subjects, ensure_ascii=False
)
# 序列化到 DB加密敏感字段
def to_db_row(self) -> dict[str, Any]:
"""返回可直接写入 orders 表的字典(敏感字段已加密)。"""
data = asdict(self)
# 移除明文敏感字段
data.pop("customer_phone", None)
data.pop("candidate_id_card", None)
# 加密落盘字段
if self.customer_phone:
data["customer_phone_enc"] = encrypt(self.customer_phone)
if self.customer_email:
data["customer_email"] = encrypt(self.customer_email)
if self.candidate_id_card:
data["candidate_id_card_enc"] = encrypt(self.candidate_id_card)
# tags/subjects JSON 化
data["tags"] = json.dumps(self.tags, ensure_ascii=False)
data["candidate_subjects"] = json.dumps(
self.candidate_subjects, ensure_ascii=False
)
return data
# 从 DB 反序列化(解密敏感字段)
@classmethod
def from_db_row(cls, row: dict[str, Any]) -> "Order":
"""从数据库行构造 Order自动解密敏感字段。"""
data = dict(row)
if data.get("customer_phone_enc"):
data["customer_phone"] = decrypt(data["customer_phone_enc"])
else:
data["customer_phone"] = None
if data.get("customer_email"):
try:
data["customer_email"] = decrypt(data["customer_email"])
except Exception:
pass
else:
data["customer_email"] = None
if data.get("candidate_id_card_enc"):
data["candidate_id_card"] = decrypt(data["candidate_id_card_enc"])
else:
data["candidate_id_card"] = None
# JSON 列表字段
for key in ("tags", "candidate_subjects"):
raw = data.get(key)
if raw:
try:
data[key] = json.loads(raw)
except (json.JSONDecodeError, TypeError):
data[key] = []
else:
data[key] = []
# 移除 DB-only 加密字段
data.pop("customer_phone_enc", None)
data.pop("candidate_id_card_enc", None)
return cls(**data)
def to_dict(self, decrypt_sensitive: DecryptPolicy = "mask") -> dict[str, Any]:
"""导出为字典。
decrypt_sensitive 取值:
- True : 敏感字段以明文返回(权限内 API,如后台人工核对)
- False : 完全移除明文字段(对外公开统计/审计日志,仅保留 hash)
- "mask" : 部分遮罩(列表/详情默认,如 138****1234,推荐)
当传入未知字符串时,回退为 "mask",保证前端拿到的是遮罩而非明文 — 默认安全。
"""
data = asdict(self)
if decrypt_sensitive is True:
return data
if decrypt_sensitive is False:
data.pop("customer_phone", None)
data.pop("candidate_id_card", None)
return data
# 默认 / "mask" / 其他字符串:走遮罩路径
return mask_sensitive_dict(data)