Repository: TencentBlueKing/bk-user

Issue:
feat: desensitize phone and email display in personal center



Discussion:

Resolving patch:
diff --git a/src/bk-user/bkuser/apis/web/personal_center/serializers.py b/src/bk-user/bkuser/apis/web/personal_center/serializers.py
--- a/src/bk-user/bkuser/apis/web/personal_center/serializers.py
+++ b/src/bk-user/bkuser/apis/web/personal_center/serializers.py
@@ -23,6 +23,7 @@
 from bkuser.biz.data_source_organization import DataSourceUserHandler
 from bkuser.biz.tenant import TenantUserHandler
 from bkuser.biz.validators import validate_logo, validate_user_password
+from bkuser.common.desensitize import desensitize_email, desensitize_phone
 from bkuser.common.validators import validate_phone_with_country_code
 
 
@@ -53,18 +54,18 @@ class TenantUserRetrieveOutputSLZ(serializers.Serializer):
 
     # 邮箱信息
     is_inherited_email = serializers.BooleanField(help_text="是否继承数据源邮箱")
-    email = serializers.EmailField(help_text="用户邮箱", source="data_source_user.email")
-    custom_email = serializers.EmailField(help_text="自定义用户邮箱")
+    email = serializers.SerializerMethodField(help_text="用户邮箱")
+    custom_email = serializers.SerializerMethodField(help_text="自定义用户邮箱")
 
     # 手机号信息
     is_inherited_phone = serializers.BooleanField(help_text="是否继承数据源手机号")
-    phone = serializers.CharField(help_text="用户手机号", source="data_source_user.phone")
+    phone = serializers.SerializerMethodField(help_text="用户手机号")
     phone_country_code = serializers.CharField(
         help_text="手机号国际区号",
         source="data_source_user.phone_country_code",
         default=settings.DEFAULT_PHONE_COUNTRY_CODE,
     )
-    custom_phone = serializers.CharField(help_text="自定义用户手机号")
+    custom_phone = serializers.SerializerMethodField(help_text="自定义用户手机号")
     custom_phone_country_code = serializers.CharField(help_text="自定义用户手机国际区号")
 
     account_expired_at = serializers.CharField(help_text="账号过期时间", source="account_expired_at_display")
@@ -93,6 +94,22 @@ def get_extras(self, obj: TenantUser) -> Dict[str, Any]:
             k: v for k, v in obj.data_source_user.extras.items() if k in self.context["visible_custom_field_names"]
         }
 
+    @swagger_serializer_method(serializer_or_field=serializers.CharField)
+    def get_email(self, obj: TenantUser) -> str:
+        return desensitize_email(obj.data_source_user.email)
+
+    @swagger_serializer_method(serializer_or_field=serializers.CharField)
+    def get_custom_email(self, obj: TenantUser) -> str:
+        return desensitize_email(obj.custom_email)
+
+    @swagger_serializer_method(serializer_or_field=serializers.CharField)
+    def get_phone(self, obj: TenantUser) -> str:
+        return desensitize_phone(obj.data_source_user.phone)
+
+    @swagger_serializer_method(serializer_or_field=serializers.CharField)
+    def get_custom_phone(self, obj: TenantUser) -> str:
+        return desensitize_phone(obj.custom_phone)
+
 
 class TenantUserPhoneUpdateInputSLZ(serializers.Serializer):
     is_inherited_phone = serializers.BooleanField(help_text="是否继承数据源手机号", required=True)
diff --git a/src/bk-user/bkuser/common/desensitize.py b/src/bk-user/bkuser/common/desensitize.py
new file mode 100644
--- /dev/null
+++ b/src/bk-user/bkuser/common/desensitize.py
@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+"""
+TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
+Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved.
+Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
+You may obtain a copy of the License at http://opensource.org/licenses/MIT
+Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+"""
+import re
+
+import phonenumbers
+
+PLACEHOLDER = "--"
+
+# 邮箱
+EMAIL_REGEX_PATTERN = re.compile(r"[^@]+@[^@]+\.[^@]+")
+# 中国大陆手机号
+MAINLAND_PHONE_PATTERN = re.compile(r"^(?:\+?86)?(1\d{10})$")
+# 香港手机号
+HK_PHONE_PATTERN = re.compile(r"^(?:\+?852)(\d{8})$")
+# 澳门手机号
+MACAO_PHONE_PATTERN = re.compile(r"^(?:\+?853)(\d{8})$")
+# 台湾手机号
+TW_PHONE_PATTERN = re.compile(r"^(?:\+?886)(9\d{8})$")
+# 海外手机号
+OVERSEAS_PHONE_PATTERN = re.compile(r"^(\+\d{1,3})(\d{4,})$")
+# 大陆固定电话
+MAINLAND_LANDLINE_PATTERN = re.compile(r"^(\d{3,4}-)(\d{7,8})$")
+
+
+def desensitize_email(email: str) -> str:
+    """对邮箱进行脱敏"""
+    if not email:
+        return PLACEHOLDER
+
+    # 邮箱格式非法，直接返回
+    if not re.match(EMAIL_REGEX_PATTERN, email):
+        return email
+
+    # 显示 username 前 2 位字符以及 @ 及 @ 后所有字符
+    username, domain = email.split("@")
+    return username[:2] + "****@" + domain
+
+
+def desensitize_phone(phone: str) -> str:
+    """对手机号进行脱敏"""
+    if not phone:
+        return "--"
+
+    # 删除空格
+    phone = phone.replace(" ", "")
+
+    # 大陆手机：展示前 3 后 4，中间用 4 个 * 代替
+    if MAINLAND_PHONE_PATTERN.match(phone):
+        phone = re.sub(MAINLAND_PHONE_PATTERN, r"\1", phone)
+        return phone[:3] + "****" + phone[7:]
+
+    # 香港、澳门：展示前 2 后 2，中间用 4 个 * 代替
+    if HK_PHONE_PATTERN.match(phone):
+        phone = re.sub(HK_PHONE_PATTERN, r"\1", phone)
+        return phone[:2] + "****" + phone[-2:]
+
+    if MACAO_PHONE_PATTERN.match(phone):
+        phone = re.sub(MACAO_PHONE_PATTERN, r"\1", phone)
+        return phone[:2] + "****" + phone[-2:]
+
+    # 台湾：展示前 2 后 3，中间用 4 个 * 代替
+    if TW_PHONE_PATTERN.match(phone):
+        phone = re.sub(TW_PHONE_PATTERN, r"\1", phone)
+        return phone[:2] + "****" + phone[-3:]
+
+    # 海外：只展示地区号和中间 4 位，占位符用 4 个 *
+    if OVERSEAS_PHONE_PATTERN.match(phone):
+        try:
+            ret = phonenumbers.parse(phone)
+        except phonenumbers.NumberParseException:
+            return phone[:2] + "****" + phone[-2:]
+
+        phone, country_code = str(ret.national_number), str(ret.country_code)
+        return "+" + country_code + "****" + phone[-8:-4] + "****"
+
+    # 固定电话：只展示区号和后 4 位，中间用 4 个 * 代替
+    if MAINLAND_LANDLINE_PATTERN.match(phone):
+        area_code, local_number = re.sub(MAINLAND_LANDLINE_PATTERN, r"\1 \2", phone).split()
+        return area_code + "****" + local_number[-4:]
+
+    # 没匹配到的展示前 2 后 2，中间 4 个 * 代替
+    return phone[:2] + "****" + phone[-2:]


Test patch:
diff --git a/src/bk-user/tests/apis/web/natural_user/test_natural_user.py b/src/bk-user/tests/apis/web/natural_user/test_natural_user.py
--- a/src/bk-user/tests/apis/web/natural_user/test_natural_user.py
+++ b/src/bk-user/tests/apis/web/natural_user/test_natural_user.py
@@ -15,6 +15,7 @@
 from bkuser.apps.data_source.models import DataSourceDepartmentUserRelation, DataSourceUserLeaderRelation
 from bkuser.apps.natural_user.models import DataSourceUserNaturalUserRelation
 from bkuser.apps.tenant.models import TenantDepartment, TenantUser
+from bkuser.common.desensitize import desensitize_email, desensitize_phone
 from django.urls import reverse
 from rest_framework import status
 from rest_framework.test import APIClient
@@ -126,17 +127,17 @@ def _check_general_property(self, tenant_user: TenantUser, general_property: Dic
 
         assert general_property["username"] == data_source_user.username
         assert general_property["full_name"] == data_source_user.full_name
-        assert general_property["email"] == data_source_user.email
-        assert general_property["phone"] == data_source_user.phone
+        assert general_property["email"] == desensitize_email(data_source_user.email)
+        assert general_property["phone"] == desensitize_phone(data_source_user.phone)
         assert general_property["phone_country_code"] == data_source_user.phone_country_code
         assert general_property["extras"] == data_source_user.extras
 
         assert general_property["is_inherited_phone"] == tenant_user.is_inherited_phone
-        assert general_property["custom_phone"] == tenant_user.custom_phone
+        assert general_property["custom_phone"] == desensitize_phone(tenant_user.custom_phone)
         assert general_property["custom_phone_country_code"] == tenant_user.custom_phone_country_code
 
         assert general_property["is_inherited_email"] == tenant_user.is_inherited_email
-        assert general_property["custom_email"] == tenant_user.custom_email
+        assert general_property["custom_email"] == desensitize_email(tenant_user.custom_email)
 
     def test_retrieve_tenant_user_with_natural_user(
         self,
diff --git a/src/bk-user/tests/common/test_desensitize.py b/src/bk-user/tests/common/test_desensitize.py
new file mode 100644
--- /dev/null
+++ b/src/bk-user/tests/common/test_desensitize.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+"""
+TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
+Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved.
+Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
+You may obtain a copy of the License at http://opensource.org/licenses/MIT
+Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+"""
+import pytest
+from bkuser.common.desensitize import PLACEHOLDER, desensitize_email, desensitize_phone
+
+
+@pytest.mark.parametrize(
+    ("email", "excepted"),
+    [
+        ("user@example.com", "us****@example.com"),
+        ("ab@example.com", "ab****@example.com"),
+        ("a@example.com", "a****@example.com"),
+        ("this is not an email", "this is not an email"),
+        ("", PLACEHOLDER),
+    ],
+)
+def test_desensitize_email(email, excepted):
+    assert desensitize_email(email) == excepted
+
+
+@pytest.mark.parametrize(
+    ("phone", "excepted"),
+    [
+        # 中国大陆
+        ("13812345678", "138****5678"),
+        ("+8613812345678", "138****5678"),
+        # 大陆固定电话
+        ("021-12345678", "021-****5678"),
+        # 香港
+        ("+85212345678", "12****78"),
+        # 澳门
+        ("+85312345678", "12****78"),
+        # 台湾
+        ("+886912345678", "91****678"),
+        # 海外
+        ("+11234567890", "+1****3456****"),
+        # 特殊场景
+        (" 138 1234 5678 ", "138****5678"),
+        (" 138-1234-5678 ", "13****78"),
+        ("", PLACEHOLDER),
+    ],
+)
+def test_desensitize_phone(phone, excepted):
+    assert desensitize_phone(phone) == excepted