手机号脱敏这块需求几乎所有做业务系统开发的人都碰到过。无论是用户列表展示、订单详情、聊天记录还是后台审计日志总有个地方需要把手机号中间四位打上星号再展示出来。今天就把这个需求从方案选型到代码实现、再到生产环境里的各种坑完整梳理一遍。1. 需求分析与方案选型为什么要把中间四位遮住先说场景。手机号属于个人敏感信息按照现在的主流合规要求在界面展示、接口返回、日志打印这些环节都不能直接暴露完整手机号。最常见的做法就是保留前三位和后四位把中间四位用星号代替比如138****5678。这个需求看起来简单但想做得优雅、可维护、不遗漏其实有几个层次要考虑单纯字符串截取拼接还是用正则表达式在前端做还是后端做接口层统一处理还是每个业务单独调用遇到非11位手机号、带区号座机号、或者中间插入空格和短横线的格式怎么办先说方案选型。字符串截取思路是substring(0, 3) **** substring(7)这个方案的问题在于它假设了手机号一定是11位。一旦遇到异常数据比如用户只填了10位数字、或者多填了一个空格截取结果就完全错乱。正则表达式的优势在于它通过模式匹配来替换天然具备对格式的容错能力——可以从任意字符串中抓取“看起来像手机号”的部分只对匹配到的部分做脱敏。我个人的建议是如果这个系统只有一两处地方需要脱敏怎么简单怎么来如果是一个用户量过万、接口被多方调用的系统一定要把脱敏能力收敛成公共工具或组件由统一代码处理避免每个开发各写一套最后格式五花八门。2. 核心实现思路正则表达式与替换逻辑拆解2.1 从零开始写一条手机号脱敏正则先看最核心的一条正则表达式const reg /^(\d{3})\d{4}(\d{4})$/; const mask 13812345678.replace(reg, $1****$2); // 输出138****5678这条正则是这么拆解的^和$分别锚定字符串的开头和结尾表示必须匹配整个字符串也就是要求字符串就是11位数字。(\d{3})是第一个捕获组抓取前三位。\d{4}是中间四位不加括号因为不需要保留。(\d{4})是第二个捕获组抓取后四位。替换串里的$1和$2分别引用第一个和第二个捕获组捕获到的内容****是固定填充的星号。这条规则对标准11位手机号来说是最优解——定义清晰、一眼能看懂、不会有意外匹配。但实际开发中用户提交的数据往往不那么干净。常见情况有三种手机号中间有空格138 1234 5678手机号带了横线138-1234-5678手机号前带了86或0086前缀这些情况用上面那条严格锚定的正则会匹配失败。为了增强容错我通常会把正则放宽一层做成一个专门用于脱敏的工具方法。2.2 兼容各种格式的增强版脱敏方法针对需要兼容空格、横线、国家代码前缀的场景推荐写成这样一个方法function masksPhone(phone) { if (!phone) return ; // 先去掉所有非数字字符把 86、空格、横线全部过滤掉 const digits phone.replace(/\D/g, ); // 处理以 86 开头的 13 位号码去掉国家代码 const trimmed digits.startsWith(86) digits.length 13 ? digits.slice(2) : digits; // 校验是否为 11 位手机号 if (/^1\d{10}$/.test(trimmed)) { return trimmed.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); } // 不是 11 位手机号时返回原串或做兜底处理 return phone; }这里的关键设计点有两个。第一个是replace(/\D/g, )把所有非数字字符删掉这样什么空格、横线、括号全部清理干净后续逻辑只需要面对纯数字串。第二个是startsWith(86)的处理当号码带了国家区号时先剥离区号再做后续匹配。这两个设计让方法面对脏数据时依然能正确脱敏。那为什么不直接在正则里写(?:\?86)?这样的可选前缀呢因为各种边缘情况堆叠起来正则的可读性会断崖式下降。先清洗数据再用简单正则是工程上更稳妥的选择。2.3 边界情况与异常数据处理脱敏方法至少要考虑以下边界情况输入示例处理结果说明13812345678138****5678标准11位号码138 1234 5678138****5678清洗空格后匹配86 13812345678138****5678剥离86前缀0086-13812345678138****5678剥离0086前缀1234512345非11位保持原样空字符串空字符串入参为空直接返回12345678901212345678901212位数字不做脱敏注意最后一种情况12位纯数字可能是座机号也可能是误输入。我做脱敏工具时默认的策略是校验失败就不脱敏返回原始输入。这么做的好处是日志里仍然能看到完整数据便于排查问题。如果你在接口返回的场景需要严格隐蔽可以把兜底值改成***或空串根据业务需求取舍。3. 多语言实现Java、Python、SQL 中的等价写法这套脱敏逻辑在多个语言生态里都很常用我分别给出参考实现。3.1 Java 实现public static String masksPhone(String phone) { if (phone null || phone.isEmpty()) { return phone; } // 清洗非数字字符 String digits phone.replaceAll(\\D, ); // 剥离86区号 if (digits.length() 13 digits.startsWith(86)) { digits digits.substring(2); } // 匹配11位手机号 if (digits.matches(^1\\d{10}$)) { return digits.replaceAll(^(\\d{3})\\d{4}(\\d{4})$, $1****$2); } return phone; }Java 的replaceAll和 JavaScript 的replace行为一致都支持捕获组和$1引用。工程上有一个建议如果这个方法会被高频调用比如每次接口返回都执行Pattern对象应该作为静态常量预编译避免每次方法调用都重新编译正则表达式。3.2 Python 实现import re def masks_phone(phone): if not phone: return phone digits re.sub(r\D, , phone) if len(digits) 13 and digits.startswith(86): digits digits[2:] if re.fullmatch(r1\d{10}, digits): return re.sub(r^(\d{3})\d{4}(\d{4})$, r\1****\2, digits) return phonePython 和 JavaScript 在正则替换语法上完全一致$1换成\1即可。这里用re.sub对脱敏逻辑同样安全。注意 Python 的\D在替换字符串里如果写成r\D原始字符串就不会出现转义问题。3.3 SQL 实现SQL 里做手机号脱敏通常用在导出报表或数据查询场景。MySQL 的高版本支持正则替换低版本可以用字符串函数处理。MySQL 8.0 以上写法SELECT REGEXP_REPLACE(phone, ^(\\d{3})\\d{4}(\\d{4})$, \\1****\\2) AS masked_phone FROM user_table;MySQL 8.0 以下建议用字符串拼接SELECT CONCAT(LEFT(phone, 3), ****, RIGHT(phone, 4)) AS masked_phone FROM user_table WHERE phone REGEXP ^1[0-9]{10}$;第二种写法在WHERE里先过滤出合法手机号再对合法行做固定位置截取虽然匹配逻辑没那么动态但在性能上有天然优势因为CONCAT和左右截取都不走正则引擎。4. 生产环境应用接口统一处理与前端展示策略4.1 后端统一拦截还是前端各自处理我的明确建议是后端返回脱敏后的数据前端拿到什么就展示什么前端不做二次脱敏。原因有三条前端脱敏只防君子不防小人。打开浏览器控制台看接口原始响应数据一目了然。真正需要保护个人敏感信息时脱敏必须发生在数据离开后端之前。前端的展示逻辑通常复杂多变。同一个手机号在列表页展示脱敏在详情页可能又要展示完整版供操作。如果前端各自处理口径不好统一。后端统一封装后测试也好做。接口自动化用例可以批量断言返回字段不含完整手机号防止某个新接手的开发忘了脱敏直接把字段透出。后端实现时推荐采用注解或切面的方式统一处理。例如定义切面在 Controller 返回统一响应体时对响应体里的手机号字段做递归脱敏。这样业务代码里根本不用感知脱敏逻辑新加的接口只要遵循统一的响应结构自动就会套上脱敏规则。4.2 日志脱敏最容易遗漏的隐蔽角落这是我在生产环境里踩过最深的一个坑。接口返回都做了脱敏但应用日志里可能还打着完整手机号。比如业务代码里有类似log.info(用户手机号为: {}, user.getPhone())的日志接口返回脱敏做得再好日志文件泄露出去依然是安全事故。日志脱敏有两个落点一是加一个日志脱敏组件在日志输出前对phone、mobile等关键字段做正则替换。二是在日志框架层面配置Converter统一处理%msg里的敏感信息。实操建议是先扫一遍现有代码里所有打印手机号的日志确认字段再接入自动脱敏组件。接好之后写一个定时任务或随机抽样脚本扫描最近日志文件里是否存在11位连续数字一旦发现就告警防止以后又有人写出不带脱敏的新日志。4.3 大数据量场景下的性能优化当脱敏方法被高频调用时正则表达式的编译开销就不能忽视了。JavaScript 端可以通过把正则对象定义在模块作用域来复用Java 端用静态Pattern编译Python 端可以用re.compile预编译。实测效果上预编译比每次直接调用re.sub有数量级的性能提升特别是在网关层或统一响应过滤器里这个优化非常值得做。如果是百万级数据导出场景比如批量导出用户手机号脱敏后的报表建议直接用 SQL 里的字符串截取方案或者用流式读取逐条处理避免一次性把全量数据加载到内存里再统一脱敏。5. 常见问题与排查技巧实录5.1 脱敏结果变成空串或原样数据表现为调用脱敏方法后返回的不是138****5678样式的字符串而是空字符串或者原始手机号。优先排查两个地方入参是否包含、空格等非数字字符。如果忘了清洗环节正则匹配会失败直接走了兜底逻辑。号码位数异常。默认真实手机号是11位但测试数据可能造了12位数字。前面步骤里没有剥离区号逻辑的话12位会被判定为异常数据。这类问题最好通过单元测试锁定。把常见输入样例整理成测试用例比上线后拿真实用户数据试错要稳得多。5.2 正则表达式里的转义陷阱在各个语言里写\d和\1的时候转义规则很容易把人绕晕。JavaScript 字符串写\\d和在正则字面量/\\d/里写\d是不同的。SQL 里要写\\d是因为字符串本身对反斜杠做了转义。Python 的原始字符串r\d最省心推荐优先用。我自己踩过这个坑Java 里写String regex ^(\\d{3})\\d{4}(\\d{4})$;然后replaceAll(regex, $1****$2)是正常的但有一次想偷懒用单反斜杠\d结果编译期报错排查了半天才发现是这个原因。建议把常用正则的转义写法固定成模板需要新场景时直接套模板微调。5.3 手机号区间误判虚拟运营商号段与港澳台号码严格按^1\d{10}$来匹配是能覆盖虚拟运营商号码的因为虚拟运营商也使用11位、以1开头的号段。需要特别留意的是港澳台手机号位数和号段规则完全不同如果业务涉及港澳台用户建议单独维护一份地区号码规则配置不能一刀切按内地的11位规则去脱敏。这部分经验的总结就是脱敏工具只做通用处理特殊地区的号码规则必须由业务侧提供配置。5.4 测试用例与回归保障脱敏逻辑虽然简单但因为它涉及用户隐私又分布在各种不同场景里非常值得投入测试用例。建议至少覆盖以下内容正常11位手机号脱敏后格式正确前三位、后四位原样保留中间四位全部替换为星号空字符串、长度为0、null 值不报错非手机号字符串不被错误脱敏带 86、0086、空格、横线前缀的字符串正确清洗12位数字不被错误处理把这条清单固化成单元测试在持续集成流水线里自动跑能防住很多后续改动引入的回归问题。6. 工具类设计的一点经验沉淀最后分享一个工具类设计的细节把脱敏方法做成纯静态无状态方法输入一个字符串输出一个字符串内部不依赖任何外部配置或全局变量。这样做的最大好处是方便做单元测试也方便在多个项目之间通过公共依赖直接复用。唯一的例外是当业务有特殊地区号码规则时把规则配置化通过依赖注入传入而不是硬编码在方法内部。如果你所在公司有统一的基础组件库强烈建议把“手机号脱敏”做成组件库里的一等公民。因为需求太过常见几乎每个业务都会用到单独维护一套公共实现能避免各个业务线重复造轮子也方便以后在一次升级中覆盖所有业务。这个需求本身的技术难度不高难点全在“统一口径”和“不遗漏场景”上。按我上面这套思路去落地不管是新项目还是改造老代码都能比较稳地交付。