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

142 lines
5.3 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.
"""订单敏感字段展示脱敏工具 (T11.2).
职责:把明文 PII 转成"可展示但不完整"的形态,用于 API 响应、UI 列表、日志输出。
与 crypto.py 的关系:
- crypto.py 负责"落盘形态"AES 密文 / SHA-256 hash目的是不让磁盘/数据库泄露明文。
- masking.py 负责"展示形态"(部分遮罩),目的是最小特权展示,不让前端/日志/截图拿到完整 PII。
- 两者正交:脱敏展示不替代落盘加密,解密后仍然可以再脱敏展示。
设计原则:
1. 默认安全None / 空串 / 非法输入 → 返回 None绝不抛错给前端。
2. 不依赖具体加密密钥:纯字符串处理,可在 API 序列化层、模板、日志过滤器、CSV 导出器等任意层复用。
3. 不丢语义:保留可识别前缀(如手机前 3 位、身份证前 6 位行政区划),便于运营核对。
4. 不可逆无法从展示形态恢复原文mask 后字符是"原始位数"而非真实值)。
"""
from __future__ import annotations
import re
from typing import Any, Optional
# 11 位中国手机号(已剔除 +86 / 空格 / 横杠后)
_PHONE_11 = re.compile(r"^\d{11}$")
# 18 位身份证(含 17 位 + 末位校验位 X/x
_ID_CARD_18 = re.compile(r"^\d{17}[\dXx]$")
# 15 位老版身份证
_ID_CARD_15 = re.compile(r"^\d{15}$")
# 86 / +86 国家码前缀(中国大陆手机号常见形态)
_PHONE_PREFIX_86 = re.compile(r"^(\+?86)[\s\-]?")
def mask_phone(value: Optional[str]) -> Optional[str]:
"""手机号脱敏138****1234。
接受:
- 11 位纯数字 → "前3位****后4位"
- 含 +86 / 86 / 空格 / 横杠 → 先剥离国家码与分隔再判定
- None / 空串 / 长度不足 → 原样返回None / "")。
- 非字符串 → 返回 None不抛错给前端
"""
if value is None:
return None
if not isinstance(value, str):
return None
s = value.strip()
if not s:
return ""
# 显式剥离 +86 / 86 国家码(只剥一次,避免误处理纯数字 86 开头)
s = _PHONE_PREFIX_86.sub("", s, count=1)
# 去掉剩余的空格 / - / + 分隔符
stripped = re.sub(r"[\s\-]", "", s)
if _PHONE_11.match(stripped):
return f"{stripped[:3]}****{stripped[-4:]}"
# 非标准形态:长度 >= 7 时遮中段(至少遮 1 个 *),否则原样
if len(stripped) >= 7:
return stripped[:3] + "*" * max(1, len(stripped) - 7) + stripped[-4:]
return s
def mask_id_card(value: Optional[str]) -> Optional[str]:
"""身份证脱敏:保留前 6 位行政区划 + 末 4 位。
18 位430102********1234
15 位430102******123兼容老版
非标准长度13-14 位):按"前 6 后 4"遮中段,否则原样返回。
< 13 位视为非身份证,原样返回以免误遮。
"""
if value is None:
return None
if not isinstance(value, str):
return None
s = value.strip()
if not s:
return ""
if _ID_CARD_18.match(s):
return f"{s[:6]}********{s[-4:]}"
if _ID_CARD_15.match(s):
return f"{s[:6]}******{s[-3:]}"
if len(s) >= 13:
return s[:6] + "*" * max(2, len(s) - 10) + s[-4:]
return s
def mask_name(value: Optional[str]) -> Optional[str]:
"""姓名脱敏:保留姓氏。
规则:
- 1 字姓名 → 原样返回(已是最低信息粒度)。
- 2 字姓名 → 张*(姓 + 1 个 *)。
- 3 字姓名 → 张*丰(姓 + 名首字 + 1 个 *)。
- 4+ 字姓名 → 张**(姓 + 2 个 *,不暴露名长度)。
- 非中文字符(英文/数字/混合) → 全遮 *(不暴露字符数)。
"""
if value is None:
return None
if not isinstance(value, str):
return None
s = value.strip()
if not s:
return ""
# 非中日韩文字:按"全遮"处理
if not all("\u4e00" <= ch <= "\u9fff" for ch in s):
return "*" * len(s)
n = len(s)
if n == 1:
return s
if n == 2:
return s[0] + "*"
if n == 3:
return s[0] + "*" + s[2]
# 4 字及以上:保留首字 + 2 个 *(不暴露名长度信息)
return s[0] + "**"
def mask_sensitive_dict(data: dict[str, Any]) -> dict[str, Any]:
"""对订单字典中已知的敏感字段统一脱敏。
适配 Order.to_dict 输出:仅对明文 PII 字段做 mask不修改 _enc / _hash 字段
(那些字段本就不应出现在 API 响应里;如果出现了,说明上游泄露 — 本函数不动它,
由调用方负责移除)。
"""
if not isinstance(data, dict):
return data
masked = dict(data)
if "customer_phone" in masked and masked["customer_phone"] is not None:
masked["customer_phone"] = mask_phone(masked["customer_phone"])
if "candidate_id_card" in masked and masked["candidate_id_card"] is not None:
masked["candidate_id_card"] = mask_id_card(masked["candidate_id_card"])
if "customer_name" in masked and masked["customer_name"] is not None:
masked["customer_name"] = mask_name(masked["customer_name"])
if "candidate_name" in masked and masked["candidate_name"] is not None:
masked["candidate_name"] = mask_name(masked["candidate_name"])
return masked
__all__ = [
"mask_phone",
"mask_id_card",
"mask_name",
"mask_sensitive_dict",
]