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

181 lines
6.4 KiB
Python
Raw Normal View History

"""订单敏感字段展示脱敏工具 (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] + "**"
2026-06-13 14:49:58 +08:00
def mask_wechat(value: Optional[str]) -> Optional[str]:
"""微信号脱敏:保留前 2 后 2中段用 * 填充。"""
if value is None:
return None
if not isinstance(value, str):
return None
s = value.strip()
if not s:
return ""
if len(s) <= 2:
return "*" * len(s)
if len(s) <= 4:
return s[0] + "*" * (len(s) - 2) + s[-1]
return s[:2] + "*" * (len(s) - 4) + s[-2:]
def mask_email(value: Optional[str]) -> Optional[str]:
if value is None:
return None
if not isinstance(value, str):
return None
s = value.strip()
if not s:
return ""
if "@" not in s:
return "***"
local, domain = s.split("@", 1)
if not local:
return f"***@{domain}"
keep = local[0]
return f"{keep}***@{domain}"
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"])
2026-06-13 14:49:58 +08:00
if "customer_wechat" in masked and masked["customer_wechat"] is not None:
masked["customer_wechat"] = mask_wechat(masked["customer_wechat"])
if "customer_email" in masked and masked["customer_email"] is not None:
masked["customer_email"] = mask_email(masked["customer_email"])
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",
2026-06-13 14:49:58 +08:00
"mask_wechat",
"mask_email",
"mask_sensitive_dict",
]