2026-06-12 16:26:57 +08:00
|
|
|
|
"""订单敏感字段展示脱敏工具 (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:]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 15:36:24 +08:00
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 16:26:57 +08:00
|
|
|
|
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"])
|
2026-06-18 15:36:24 +08:00
|
|
|
|
if "customer_email" in masked and masked["customer_email"] is not None:
|
|
|
|
|
|
masked["customer_email"] = mask_email(masked["customer_email"])
|
2026-06-12 16:26:57 +08:00
|
|
|
|
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",
|
2026-06-18 15:36:24 +08:00
|
|
|
|
"mask_email",
|
2026-06-12 16:26:57 +08:00
|
|
|
|
"mask_sensitive_dict",
|
|
|
|
|
|
]
|