Files
gaokao-volunteer-system/data/orders/models.py
Hermes Agent e18abedb4f feat(orders): T11.2 敏感字段展示脱敏工具 + to_dict 三态策略
- 新增 data/orders/masking.py (141 行):
  - mask_phone: 11 位 → 138****1234;支持 +86/86 国家码剥离、空格/横杠分隔符
  - mask_id_card: 18 位 → 430102********1234(保留前 6 行政区划 + 末 4);15 位老版兼容;13-14 位非标准也走前 6 后 4 遮罩
  - mask_name: 1 字原样 / 2 字 张* / 3 字 张*丰 / 4+ 字 张**;非中日韩字符全遮
  - mask_sensitive_dict: 一键遮罩订单字典中所有已知 PII 字段,不动 _enc/_hash 索引/密文
  - 默认安全:None/空串/非字符串均不抛错,统一返回 None/''

- 扩展 Order.to_dict(decrypt_sensitive) 三态策略:
  - True   : 完整明文(权限内接口使用,如后台人工核对)
  - False  : 完全移除明文(对外公开统计/审计日志)
  - 'mask' : 部分遮罩(默认,推荐;列表/详情直接可用)
  - 未知字符串策略值回退为 mask(防误传 plaintext 导致明文泄露)

- 新增 data/orders/tests/test_masking.py (246 行,32 个 pytest 用例):
  - 覆盖各 mask 函数边界(标准/带 +86/短串/非法输入)
  - 覆盖 Order.to_dict 三态与默认 mask 的串联通路

- 验证:
  - data/orders/tests 全套 112 用例通过(原 80 + 新增 32)
  - 全仓 344/344 通过
  - ruff check 0 warning / ruff format --check 已规范

- 与 T4.1 落盘加密的关系:crypto 负责'落盘形态'(密文+hash),
masking 负责'展示形态'(部分遮罩),两者正交互补。
2026-06-12 16:26:57 +08:00

175 lines
6.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.
"""订单数据模型 (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
# 考生
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
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.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("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)