Files
group_xinghuo_jinrong/app/utils/data_masker.py
T
zhanghongyu_0626 b841f68295 feat(visitor): Implement visitor chat functionality and enhance customer service interactions
- Added a new visitor chat API endpoint (`/api/chat/visitor`) to allow unauthenticated users to engage in conversations without requiring customer data.
- Introduced a visitor context dependency to manage visitor interactions seamlessly.
- Enhanced the chat API to support explicit session termination and improved response handling for customer service interactions.
- Updated the database configuration to include Redis client support for caching visitor data.
- Added a new customer note repository to persist user notes independently of the L1 profile slots.

This update significantly improves the customer service experience by enabling visitor interactions and ensuring efficient data handling for both registered and unregistered users.
2026-09-09 18:32:00 +08:00

180 lines
7.1 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.
"""信息脱敏(CS-C-12,BASE-08):手机号 / 身份证 / 姓名 / 银行卡四类敏感信息脱敏。
运行时机(方案 §7.2,两处防线):
1. LLM 输入前:core_ro 结构化查询结果经 ``mask_dict_fields`` 按字段名批量脱敏后再拼 prompt,
防止敏感信息进入 LLM 上下文或日志;
2. API 返回前:回复文本经 ``mask_text`` 正则兜底扫描(与 compliance_guard 并行的最终防线)。
脱敏规则(方案 §7.1):
- 手机号:保留前 3 后 4,中间 4 个 *(``138****5678``)
- 身份证:保留前 3 后 4(18 位中间 11 个 *;兼容 15 位老证、末位 X)
- 姓名:只保留姓(``张三``→``张*``、``张明明``→``张**``)
- 银行卡:仅保留末 4 位,分隔符(空格/连字符)原样保留(``**** **** **** 7890``)
通用约定:None / 空串原样返回;值中已含 ``*`` 视为已脱敏直接放行(防重复打码);
不符合目标格式的值原样返回(不破坏原始数据)。
"""
from __future__ import annotations
import re
from typing import Any, Callable
# ---------------------------------------------------------------------------
# 单字段脱敏
# ---------------------------------------------------------------------------
def mask_phone(phone: str | None) -> str | None:
"""手机号脱敏:保留前 3 后 4。非 11 位标准手机号原样返回。"""
if not phone or "*" in phone:
return phone
m = re.fullmatch(r"\s*(1[3-9]\d)(\d{4})(\d{4})\s*", phone)
if not m:
return phone
return f"{m.group(1)}****{m.group(3)}"
def mask_id_card(id_no: str | None) -> str | None:
"""身份证脱敏:保留前 3 后 4。支持 18 位(末位可为 X)与 15 位老证。"""
if not id_no or "*" in id_no:
return id_no
s = id_no.strip().upper() # 末位 x 归一为 X
# 18 位:前 3 + 中间 11 位打码 + 后 4(末位可能为 X)
m = re.fullmatch(r"(\d{3})(\d{11})(\d{3}[0-9X])", s)
if m:
return f"{m.group(1)}{'*' * 11}{m.group(3)}"
# 15 位老证:前 3 + 中间 8 位打码 + 后 4
m = re.fullmatch(r"(\d{3})(\d{8})(\d{4})", s)
if m:
return f"{m.group(1)}{'*' * 8}{m.group(3)}"
return id_no
def mask_name(name: str | None) -> str | None:
"""姓名脱敏:只保留姓,其余字符全部打 *(``张三``→``张*``、``张明明``→``张**``)。"""
if not name or "*" in name:
return name
s = name.strip()
if len(s) <= 1:
return s
return s[0] + "*" * (len(s) - 1)
def mask_bank_card(card: str | None) -> str | None:
"""银行卡脱敏:仅保留末 4 位数字,分隔符(空格/连字符)原样保留。
数字位数不足 8 的不当作卡号处理,原样返回。
"""
if not card or "*" in card:
return card
s = card.strip()
digit_count = sum(1 for c in s if c.isdigit())
if digit_count < 8:
return card
keep_from = digit_count - 4 # 第几个数字起保留(从 0 计)
out: list[str] = []
digit_idx = 0
for c in s:
if c.isdigit():
out.append(c if digit_idx >= keep_from else "*")
digit_idx += 1
else:
out.append(c) # 分隔符原样保留
return "".join(out)
# ---------------------------------------------------------------------------
# 自由文本自动扫描(API 返回前兜底)
# ---------------------------------------------------------------------------
# 顺序敏感:先长后短。18 位身份证同样满足银行卡 16~19 位长度,必须先替换身份证;
# 所有模式带数字边界断言,防止 11 位手机号从 16 位卡号中被误切。
_ID_CARD_18_RE = re.compile(r"(?<![0-9Xx])(\d{3})(\d{11})(\d{3}[0-9Xx])(?![0-9Xx])")
_BANK_CARD_RE = re.compile(r"(?<!\d)(?:\d[ -]?){15,18}\d(?!\d)") # 16~19 位,允许空格/连字符分隔
_ID_CARD_15_RE = re.compile(r"(?<!\d)(\d{3})(\d{8})(\d{4})(?!\d)")
_PHONE_RE = re.compile(r"(?<!\d)(1[3-9]\d)(\d{4})(\d{4})(?!\d)")
def _mask_bank_card_match(m: re.Match) -> str:
"""银行卡正则命中片段的替换函数:复用 mask_bank_card 的逐字符逻辑。"""
return mask_bank_card(m.group(0)) # type: ignore[arg-type,return-value]
def mask_text(text: str | None) -> str | None:
"""自由文本兜底脱敏:自动扫描并打码身份证 / 银行卡 / 手机号。
姓名不在自由文本中识别(正则无法可靠区分姓名与普通词语,误伤率高);
姓名脱敏只在结构化字段由 ``mask_name`` / ``mask_dict_fields`` 处理。
"""
if not text:
return text
# 18 位身份证 → 银行卡(16~19 位,含分隔符)→ 15 位老证 → 11 位手机号
text = _ID_CARD_18_RE.sub(lambda m: f"{m.group(1)}{'*' * 11}{m.group(3)}", text)
text = _BANK_CARD_RE.sub(_mask_bank_card_match, text)
text = _ID_CARD_15_RE.sub(lambda m: f"{m.group(1)}{'*' * 8}{m.group(3)}", text)
text = _PHONE_RE.sub(lambda m: f"{m.group(1)}****{m.group(3)}", text)
return text
# ---------------------------------------------------------------------------
# 结构化数据批量脱敏(LLM 输入前,core_ro 查询结果)
# ---------------------------------------------------------------------------
# 字段名 → 脱敏类型。core_ro 返回的行是扁平 dict,按列名命中即脱敏;
# 库中 *_mask 列本已脱敏,命中后会被 mask_* 的 "*" 检查直接放行(纵深防御)。
DEFAULT_FIELD_MASKERS: dict[str, str] = {
# 手机号
"phone": "phone",
"phone_mask": "phone",
"mobile": "phone",
# 身份证
"id_no": "id_card",
"id_no_mask": "id_card",
"id_card": "id_card",
# 姓名
"name": "name",
"display_name": "name",
"customer_name": "name",
"counterparty_name": "name",
"payer_name": "name",
# 银行卡 / 账号
"bank_card": "bank_card",
"card_no": "bank_card",
"account_no": "bank_card",
"counterparty_account_mask": "bank_card",
}
_MASKER_FUNCS: dict[str, Callable[[str | None], str | None]] = {
"phone": mask_phone,
"id_card": mask_id_card,
"name": mask_name,
"bank_card": mask_bank_card,
}
def mask_dict_fields(
data: Any,
field_maskers: dict[str, str] | None = None,
) -> Any:
"""对 core_ro 结构化查询结果按字段名批量脱敏。
- 入参可为 dict(单行)或 list[dict](多行,如 list_holdings / list_trades 结果);
- 仅处理字符串值,非字符串(Decimal/date/int 等)原样保留;
- 返回浅拷贝,不修改入参;
- ``field_maskers`` 可自定义 {字段名: 脱敏类型},脱敏类型取
phone / id_card / name / bank_card;默认用 :data:`DEFAULT_FIELD_MASKERS`。
"""
mapping = field_maskers or DEFAULT_FIELD_MASKERS
if isinstance(data, list):
return [mask_dict_fields(item, mapping) if isinstance(item, dict) else item for item in data]
if not isinstance(data, dict):
return data
out = dict(data)
for field, masker_name in mapping.items():
if field in out and isinstance(out[field], str):
out[field] = _MASKER_FUNCS[masker_name](out[field])
return out