"""订单数据模型 (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-XXXX(4 位大写字母+数字)。""" 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)