From e18abedb4fcf7976c51baadb770d06bea44fc208 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 12 Jun 2026 16:26:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(orders):=20T11.2=20=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=B1=95=E7=A4=BA=E8=84=B1=E6=95=8F=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20+=20to=5Fdict=20=E4=B8=89=E6=80=81=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 负责'展示形态'(部分遮罩),两者正交互补。 --- data/orders/masking.py | 141 +++++++++++++++++ data/orders/models.py | 27 +++- data/orders/tests/test_masking.py | 246 ++++++++++++++++++++++++++++++ 3 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 data/orders/masking.py create mode 100644 data/orders/tests/test_masking.py diff --git a/data/orders/masking.py b/data/orders/masking.py new file mode 100644 index 0000000..38cb648 --- /dev/null +++ b/data/orders/masking.py @@ -0,0 +1,141 @@ +"""订单敏感字段展示脱敏工具 (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", +] diff --git a/data/orders/models.py b/data/orders/models.py index 3c12942..7ca8d81 100644 --- a/data/orders/models.py +++ b/data/orders/models.py @@ -11,9 +11,17 @@ import random import string from dataclasses import dataclass, field, asdict from datetime import datetime, timezone -from typing import Any, List, Optional +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: @@ -145,13 +153,22 @@ class Order: data.pop("candidate_id_card_enc", None) return cls(**data) - def to_dict(self, decrypt_sensitive: bool = True) -> dict[str, Any]: + def to_dict(self, decrypt_sensitive: DecryptPolicy = "mask") -> dict[str, Any]: """导出为字典。 - decrypt_sensitive=True 时敏感字段以明文返回(API 响应);False 时只返回 hash。 + decrypt_sensitive 取值: + - True : 敏感字段以明文返回(权限内 API,如后台人工核对) + - False : 完全移除明文字段(对外公开统计/审计日志,仅保留 hash) + - "mask" : 部分遮罩(列表/详情默认,如 138****1234,推荐) + + 当传入未知字符串时,回退为 "mask",保证前端拿到的是遮罩而非明文 — 默认安全。 """ data = asdict(self) - if not decrypt_sensitive: + 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 + return data + # 默认 / "mask" / 其他字符串:走遮罩路径 + return mask_sensitive_dict(data) diff --git a/data/orders/tests/test_masking.py b/data/orders/tests/test_masking.py new file mode 100644 index 0000000..05ace66 --- /dev/null +++ b/data/orders/tests/test_masking.py @@ -0,0 +1,246 @@ +"""masking 模块单元测试 (T11.2). + +覆盖: +- 各 mask 函数的正常路径与边界(None / 空串 / 非字符串 / 非标准长度) +- Order.to_dict 的 mask 模式串联通路 +- 默认安全:未知 decrypt_sensitive 值回退 mask 而非明文 +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-unit-tests") + +from data.orders.models import Order +from data.orders.masking import ( + mask_id_card, + mask_name, + mask_phone, + mask_sensitive_dict, +) + + +# ---------------------- mask_phone ---------------------- + + +def test_mask_phone_standard_11_digits(): + assert mask_phone("13800001234") == "138****1234" + + +def test_mask_phone_strip_prefix_and_separator(): + assert mask_phone("+86 138-0000-1234") == "138****1234" + assert mask_phone(" 13800001234 ") == "138****1234" + + +def test_mask_phone_none_returns_none(): + assert mask_phone(None) is None + + +def test_mask_phone_empty_returns_empty(): + assert mask_phone("") == "" + assert mask_phone(" ") == "" + + +def test_mask_phone_non_string_returns_none(): + assert mask_phone(13800001234) is None # type: ignore[arg-type] + assert mask_phone(["13800001234"]) is None # type: ignore[arg-type] + + +def test_mask_phone_short_falls_back_to_partial_mask(): + """7 位临界值:走遮罩(至少 1 个 *),8-10 位类似。""" + out7: str = mask_phone("1234567") or "" # type: ignore[assignment] + assert out7.startswith("123") + assert out7.endswith("4567") + assert "*" in out7 + # 6 位及以下 — 长度不足,原样返回 + assert mask_phone("123456") == "123456" + + +def test_mask_phone_keeps_non_standard_length_prefix_suffix(): + """长度 >= 7 但不是 11 位(如带分机 12 位)也走遮罩 — 至少遮 1 个 *。""" + # 12 位带分机号 — 至少 1 个 *,前 3 后 4 保留 + out: str = mask_phone("138000012345") or "" # type: ignore[assignment] + assert out.startswith("138*") + assert out.endswith("2345") + assert "*" in out + + +# ---------------------- mask_id_card ---------------------- + + +def test_mask_id_card_18_digits_keeps_district_and_tail(): + assert mask_id_card("430102200501011234") == "430102********1234" + + +def test_mask_id_card_18_with_x_checksum_lowercased_ok(): + """末位 X/x 视为合法的身份证校验位 — 遮罩保留末 4 位(含 X)。""" + assert mask_id_card("43010220050101123X") == "430102********123X" + + +def test_mask_id_card_15_legacy_format(): + assert mask_id_card("430102050101123") == "430102******123" + + +def test_mask_id_card_none_returns_none(): + assert mask_id_card(None) is None + + +def test_mask_id_card_empty_returns_empty(): + assert mask_id_card("") == "" + assert mask_id_card(" ") == "" + + +def test_mask_id_card_non_string_returns_none(): + assert mask_id_card(123) is None # type: ignore[arg-type] + + +def test_mask_id_card_short_below_threshold_unchanged(): + """< 13 位视为非身份证 — 原样返回,避免误遮短串。""" + assert mask_id_card("4301021234") == "4301021234" + assert mask_id_card("12345") == "12345" + + +def test_mask_id_card_non_standard_length_partial_mask(): + """13-14 位非标准长度:前 6 后 4 保留,中段至少 2 个 *。""" + out: str = mask_id_card("4301022005011") or "" # type: ignore[assignment] # 13 位 + assert out.startswith("430102") + assert out.endswith("5011") # 末 4 位 + assert "*" in out + + +# ---------------------- mask_name ---------------------- + + +def test_mask_name_one_char_unchanged(): + assert mask_name("张") == "张" + + +def test_mask_name_two_chars_surname_plus_star(): + assert mask_name("张三") == "张*" + + +def test_mask_name_three_chars_surname_given_name_partial(): + """3 字姓名 → 姓 + 1 个 * + 名末字(如"张三丰" → "张*丰")。""" + assert mask_name("张三丰") == "张*丰" + + +def test_mask_name_four_chars_collapsed_to_surname_two_stars(): + """4+ 字姓名 → 姓 + 2 个 *(不暴露名长度信息)。""" + assert mask_name("欧阳明月") == "欧**" + assert mask_name("上官婉儿") == "上**" + + +def test_mask_name_none_returns_none(): + assert mask_name(None) is None + + +def test_mask_name_empty_returns_empty(): + assert mask_name("") == "" + assert mask_name(" ") == "" + + +def test_mask_name_english_fully_masked(): + assert mask_name("Alice") == "*****" + assert mask_name("Bo") == "**" + + +def test_mask_name_mixed_chinese_and_digits_treats_as_non_cjk(): + """含非中日韩字符 → 全遮,不暴露字符数。""" + assert mask_name("张3") == "**" + assert mask_name("John 张") == "******" # 6 字符全遮 + + +# ---------------------- mask_sensitive_dict ---------------------- + + +def test_mask_sensitive_dict_handles_all_known_fields(): + data = { + "customer_phone": "13800001234", + "candidate_id_card": "430102200501011234", + "customer_name": "张三", + "candidate_name": "李四光", + "customer_phone_hash": "abc", + "amount_cents": 1000, + } + out = mask_sensitive_dict(data) + assert out["customer_phone"] == "138****1234" + assert out["candidate_id_card"] == "430102********1234" + assert out["customer_name"] == "张*" + assert out["candidate_name"] == "李*光" # 3 字姓名 → 姓 + * + 名末字 + assert out["customer_phone_hash"] == "abc" + assert out["amount_cents"] == 1000 + + +def test_mask_sensitive_dict_keeps_none_fields_none(): + data = {"customer_phone": None, "candidate_id_card": None} + out = mask_sensitive_dict(data) + assert out["customer_phone"] is None + assert out["candidate_id_card"] is None + + +def test_mask_sensitive_dict_does_not_mutate_input(): + data = {"customer_phone": "13800001234"} + out = mask_sensitive_dict(data) + assert data["customer_phone"] == "13800001234" # 原字典未变 + assert out["customer_phone"] == "138****1234" + + +def test_mask_sensitive_dict_non_dict_passthrough(): + assert mask_sensitive_dict("not a dict") == "not a dict" # type: ignore[arg-type] + assert mask_sensitive_dict(None) is None # type: ignore[arg-type] + + +# ---------------------- Order.to_dict mask 集成 ---------------------- + + +def _make_order() -> Order: + return Order( + id="GKO-20260612-MASK", + source="web", + service_version="basic", + customer_name="张三", + customer_phone="13800001234", + candidate_name="李四光", + candidate_id_card="430102200501011234", + ) + + +def test_order_to_dict_default_is_mask(): + """不传参 = mask 模式 — 默认安全。""" + order = _make_order() + d = order.to_dict() + assert d["customer_phone"] == "138****1234" + assert d["candidate_id_card"] == "430102********1234" + assert d["customer_name"] == "张*" + assert d["candidate_name"] == "李*光" # 3 字姓名规则 + # hash 字段保留 + assert "customer_phone_hash" in d + + +def test_order_to_dict_mask_explicit_string(): + order = _make_order() + d = order.to_dict(decrypt_sensitive="mask") + assert d["customer_phone"] == "138****1234" + + +def test_order_to_dict_unknown_string_falls_back_to_mask(): + """默认安全:未知策略值回退 mask,而非明文。""" + order = _make_order() + d = order.to_dict(decrypt_sensitive="plaintext-oops") # type: ignore[arg-type] + assert d["customer_phone"] == "138****1234" + + +def test_order_to_dict_true_returns_plaintext(): + order = _make_order() + d = order.to_dict(decrypt_sensitive=True) + assert d["customer_phone"] == "13800001234" + assert d["candidate_id_card"] == "430102200501011234" + + +def test_order_to_dict_false_strips_sensitive(): + order = _make_order() + d = order.to_dict(decrypt_sensitive=False) + assert "customer_phone" not in d + assert "candidate_id_card" not in d + assert "customer_phone_hash" in d