今天开发群里又来了一条需求,标题叫“用户隐私手机号分享”,我的第一反应是手抖。做日常开发的人都知道,凡是和手机号沾边的功能,基本都逃不过一场拉扯:用户要的是“方便联系”,产品要的是“留住用户”,运营要的是“流程好走”,唯独开发要扛下“隐私安全”这口锅。这项目本身不大,却让我被迫和产品、测试、运营各battle了一轮,从方案评审到落地排期,再到上线后半夜查bug,踩的坑比预期多得多。我把这段经历整理出来,包括思路、代码、踩坑和“吵架”经验,给遇到类似“隐私手机号分享”需求的兄弟一个参考。
1. 需求来了:手机号不能裸奔
先说清楚这个需求到底在解决什么问题。不理解的同事会觉得,不就是把手机号展示给对方么,复制粘贴一下就完事了。但实际上,在很多业务场景里,让用户直接看到对方手机号,是会出人命的。
1.1 这个需求要解决什么问题
业务方提了一个场景:二手交易平台里,买家看中一台相机,卖家希望留个电话直接聊;或者某同城服务里,用户约了上门维修,需要把联系方式给师傅。听起来很简单——平台把卖家的手机号加密一下,展示给买家就行。
可问题在于,手机号一旦从平台上泄露出去,会发生什么?买家拿到号码,可能转手卖给中介;卖家被骚扰电话打爆;甚至有人利用手机号做精准诈骗。更麻烦的是,现在监管层面对个人信息保护盯得很紧,一旦出现用户投诉“平台泄露了我的手机号”,轻则下架整改,重则被约谈罚款。所以“隐私手机号分享”这个需求,本质是要在“用户与用户需要建立联系”和“平台不能直接暴露真实手机号”之间找一条缓冲带。
我梳理了一下,这个需求必须满足几个硬性条件:
- 用户A和用户B能通过平台建立的通道联系,但不让A直接看到B的真实号码。
- 如果业务确实需要,可以提供一个“短暂可用的展示形式”,比如脱敏后的号码或者临时中间号。
- 所有访问、分享、失效的行为都要能被追踪,出了问题要能倒查。
- 不能给用户增加过多的操作负担,否则用户会绕开平台去找“微信号”“QQ号”之类的联系方式,功能等于白做。
这些条件一列出来,正常开发的思路就会往“脱敏展示”/“虚拟号”上靠。但做产品的人往往不这么想,他们看到的是“用户想直接打个电话”,于是第一版需求就写成了“下单成功后展示对方完整手机号,并支持一键拨打”。
1.2 为什么开发者第一反应是拒绝
我当时在评审会上,看到“展示完整手机号”几个字,差点把水喷出来。直接亮真实手机号,是隐私合规的红线,不是开发偷懒,而是真不敢碰。
拿数据安全的角度讲,手机号属于敏感个人信息,和姓名、身份证号一样,在《个人信息保护法》里受到严格约束。平台存储手机号都需要最小化原则,更别说主动把手机号展示给另一个陌生人。一旦用户举报,平台必须证明自己“经过了单独同意”“遵循了最小必要”等要求。而现实中,用户并不会仔细看隐私政策,到时候解释成本极高。
从技术落地的角度,展示完整手机号也会带来一系列问题:页面被爬虫抓取怎么办?分享链接被恶意转发怎么办?手机号被截图传播,平台怎么撤回?这些问题不是代码写不出来,而是写了之后无法收场。我那时就跟产品说,你要显示完整号也简单,我两天就能上线,但三天后你可能要去处理用户投诉,风险你自己扛。这么一说,产品才愿意坐下来听方案。
所以这里想分享的第一个经验是:battle不是态度上的强硬,而是要把“为什么不能这么做”的风险给业务方拆开揉碎讲清楚。开发不是挡业务,而是帮业务把雷提前排掉。
2. 方案选型:能显示完整号吗?不能
既然“裸奔”不行,就得找替代方案。隐私手机号分享的常规路径,行业里基本有三种:脱敏展示、AXB中间号、授权转发。不同方案的成本、体验、合规表现各不相同,需要结合自身业务规模和资源做取舍。
2.1 三个主流方案对比
我先把三种方案的核心思想介绍一遍,都是开发日常能接触到的:
脱敏展示:最常见的做法,把手机号中间四位用*代替,只显示前三位和后四位。用户看到的是“138****8888”,想联系就需要通过平台的内置按钮(在线聊天、拨号中转)来间接发起。优点是改造简单、成本低,缺点是不能直接拨打,沟通体验会打折。
AXB中间号:这是运营商的能力,平台申请一批“中间号X”,用户A打给中间号X,运营商转接给真实号码B。这样双方都不知道对方的真实号码。优点是体验接近直接拨打,缺点是依赖运营商资源,每一路通话都产生费用,而且需要申请资质,小团队短时间搞不定。
授权转发:也可以做成临时链接或隐私号卡片,用户A通过平台打开一个链接,链接里展示脱敏号码/未脱敏号码但带水印,并设置有效期(比如24小时)。过期后链接失效,无法继续访问。优点是可以精确控制访问范围,缺点是需要自己做一套授权令牌管理。
我把三方对比做成了表格,方便后面评审用:
| 方案 | 可拨打性 | 隐私强度 | 改造成本 | 使用成本 | 适合场景 |
|---|---|---|---|---|---|
| 脱敏展示 | 低,需平台转接 | 高,号码不泄露 | 低 | 无 | 低频、在线咨询类 |
| AXB中间号 | 高,接近直拨 | 高,双方互相隐藏 | 中高 | 每次通话收费 | 高频、撮合交易类 |
| 授权转发 | 中,可播可不播 | 较高,可持续控制 | 中 | 低 | 低频、偶发联系类 |
2.2 为什么我选了授权转发+脱敏组合
我们的项目情况比较特殊:用户之间不是高频沟通,一般一笔订单只联系几次;团队也没有运营商背景,中间号资源申请周期长,谈不下来;业务方又强烈要求“最好能直接拨打电话”。
综合考虑后,我提了一个组合方案:常规展示走脱敏,特殊场景走授权转发。具体来说:
- 用户A浏览页面时,默认只看到脱敏号码:
138****8888。 - 如果A确实需要联系对方,点击“申请联系方式”,系统生成一个带签名的授权链接,有效期为24小时。
- 授权链接内显示真实手机号,但页面会打上包含“分享人+访问人+时间戳”的水印,防止截图泄露。
- 同时链接自带一次性的转移限制,A把链接转发给C,C打开后会被拦截(因为授权和用户身份绑定)。
这个方案既满足了“能联系上”的业务述求,又保证了“平台不主动暴露完整号码”的底线。更重要的是,它不需要任何外部API合作,开发周期可以压缩到一周以内。
选这个方案还有一个原因:它给后面的业务留了退路。如果以后真的要上AXB中间号,只需要在“申请联系方式”这个动作里接入运营商接口,替换底层的拨号逻辑即可,对用户的前端体验影响很小。
2.3 评审会上怎么说服他们
方案选完,最难的环节来了——说服产品接受“不能直接展示完整手机号”这件事。我当时准备了一张图,把风险分了三层:
- 第一层是“平台风险”:用户手机号被批量爬取,导致平台陷入隐私丑闻。
- 第二层是“用户风险”:用户被骚扰甚至诈骗,必然归咎于平台。
- 第三层是“法律风险”:监管要求查验时,平台无法自证获得用户授权。
产品最怕的不是技术做不到,而是最后背锅。于是我从“出了事咱们一起兜不住”的角度切入,很快产品就同意调整需求优先级。所以,和产品battle的一个重要技巧是:别站在“开发做不到”的角度说,要站在“业务风险不可控”的角度说。
3. 落地实操:代码比想象中多一步
方案定了,剩下的就是实施。这个功能看起来只是“显示号码”,但真正写起来涉及数据库设计、令牌生成、脱敏算法、防滥用策略、日志审计,一环都不能少。我把整个实现过程复盘一遍,重点说关键细节。
3.1 数据库设计:敏感字段加密存储
老规矩,手机号不能明文存。虽然数据库有权限控制,但为了防DBA和运气不好时数据库被拖库,手机号字段必须加密。我用的方案是应用层做AES-256-GCM加密,密钥放配置中心,数据库里存密文。
同时新建了两张表:share_record和share_token,分别是分享记录表和授权令牌表。字段设计如下(以MySQL为例):
-- 分享记录表 CREATE TABLE share_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(64) NOT NULL, owner_user_id VARCHAR(32) NOT NULL, guest_user_id VARCHAR(32) NOT NULL, phone_encrypt VARBINARY(512) NOT NULL COMMENT '真实手机号密文', phone_masked VARCHAR(20) NOT NULL COMMENT '脱敏后的手机号展示', status TINYINT NOT NULL DEFAULT 1 COMMENT '1=可用 2=已撤销 3=过期', created_at DATETIME NOT NULL, expired_at DATETIME NOT NULL, revoked_at DATETIME NULL, INDEX idx_order_guest (order_id, guest_user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 授权令牌表 CREATE TABLE share_token ( id BIGINT PRIMARY KEY AUTO_INCREMENT, share_record_id BIGINT NOT NULL, token_hash CHAR(64) NOT NULL COMMENT 'token哈希,防止DB泄露后可以伪造', access_user_id VARCHAR(32) NOT NULL, access_times INT NOT NULL DEFAULT 0 COMMENT '访问次数', max_access_times INT NOT NULL DEFAULT 5 COMMENT '最大访问次数限制', expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL, INDEX idx_token_hash (token_hash) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个很容易被忽视的点:token字段一定不要存明文,要存哈希。很多开发在写类似功能时会直接把token放进数据库,方便校验。但如果数据库被拖走,攻击者拿着token就能访问所有分享页,等于白做了。所以我用token = base64url(random_bytes(32))生成,存储时用SHA-256哈希。校验时先哈希再比对,这样数据库泄露也不会导致token泄露。同时,share_record里的phone_encrypt字段旁边还存了phone_masked,是为了在列表页、消息页快速展示脱敏号码,不用每次解密手机号去脱敏,减少加密解密的开销。
3.2 脱敏和授权分享的核心逻辑
脱敏算法本身很简单,但要注意标准化。手机号有可能是138 8888 8888这种带空格格式,还有可能是+86 13888888888,必须先做清洗。我写了个工具函数,用的思路是先过滤非数字,再统一按11位处理:
import re def mask_mobile(phone: str) -> str: # 去除所有非数字字符 digits = re.sub(r'\D', '', phone) # 如果带+86,去掉前缀 if len(digits) == 14 and digits.startswith('86'): digits = digits[2:] if len(digits) != 11: raise ValueError(f"invalid phone: {phone}") return digits[:3] + '****' + digits[7:]对应的,校验手机号也要做同样的清洗,避免数据库里出现两个格式不同但实际相同的号码。这块我在测试阶段遇到过,后面在常见问题里细说。
授权分享的时序是这样的:
- 用户A点击“申请联系方式”。
- 后端从订单上下文中拿到收款侧用户B的手机号密文。
- 判断当前订单是否存在有效分享记录;若存在直接复用,避免重复生成。
- 生成token和授权链接,链接格式为
/share/contact?token=xxx。 - 用户A打开链接时,后端校验token、到期时间、访问次数、用户身份。
- 校验通过后,返回真实手机号,但中间插入前端水印脚本。
关键代码如下(节选):
from datetime import datetime, timedelta import hashlib import secrets def create_share_token(order_id: str, owner_user_id: str, guest_user_id: str): # 检查是否已有可用记录 existing = db.query(""" SELECT share_record_id FROM share_record WHERE order_id = %s AND status = 1 AND expired_at > NOW() LIMIT 1 """, (order_id,)) if existing: # 如果share_token表已有该记录token,直接返回 ... return existing['share_record_id'] # 新生成分享记录 record_id = db.insert(""" INSERT INTO share_record (order_id, owner_user_id, guest_user_id, phone_encrypt, phone_masked, status, created_at, expired_at) VALUES (%s,%s,%s,%s,%s,1,%s,%s) """, (order_id, owner_user_id, guest_user_id, encrypt_phone(phone), mask_phone(phone), now, now + timedelta(hours=24))) # 生成随机token token = secrets.token_urlsafe(32) token_hash = hashlib.sha256(token.encode()).hexdigest() db.insert(""" INSERT INTO share_token (share_record_id, token_hash, access_user_id, expires_at, created_at) VALUES (%s,%s,%s,%s,%s) """, (record_id, token_hash, guest_user_id, now + timedelta(hours=24), now)) return record_id, token在用户访问/share/contact时,需要再次校验用户身份。不要把token作为唯一凭证,否则只要有人把链接发出来,任何人都能打开。正确做法是:token绑定guest_user_id,访问时校验当前登录用户和token的access_user_id一致。不一致,直接拒绝。这才是授权分享的核心语义。
3.3 水印和防滥用:把细节做深
真实手机号一旦展示,截图、拍照这些线下泄露行为是技术无法完全阻止的。但这不代表我们什么都不能做,至少可以做到事后可追溯。我在页面模板里加了一个水印层,用CSS生成,包含“当前登录用户ID + 订单号末四位 + 时间戳”,并做成半透明、倾斜、平铺的样式。这样即使有人截图,一旦流传出去,里面也有对方自己的身份信息,能起到威慑作用。
防滥用方面,我设置了三个限制:
- 访问次数上限:同一个token最多访问5次,超出即失效,防止被接口工具高频抓取。
- IP频率限制:同一个IP一分钟内最多访问10次,同时支持动态调整。
- token失效时间:24小时内有效。如果业务需要,可以改成“订单完成后48小时内有效”。
限流这一步很容易被忽略,但一旦上线,发现有人写脚本循环调用接口就能把全站手机号脱敏后的数据拉走,那就真的来不及补了。所以还在网关层加了一把锁:只要短时间内对/share/contact的请求量超过阈值,直接返回429,并触发告警。
3.4 撤销逻辑:给用户一个反悔的按钮
分享出去的联系方式,不应该只有“等它过期”这一条路。用户B在订单完成后,可能反悔了,或者遭到骚扰,这时候必须能主动撤销。我在订单详情页提供一个“停止分享”按钮,调用接口把share_record.status从1改成2,同时将该记录下所有未过期的token都标记为失效。撤销之后,用户A再打开链接,看到的是“该联系方式已失效”。
需要注意的是,撤销和过期是两种状态,最好都记录时间,不要覆盖。我在表里就预留了revoked_at字段,方便后续审计从哪一刻开始链接就不可用了。这些日志在用户投诉时非常有用,能直接证明平台在哪个时间点切断了曝光。
4. 和产品经理battle的那几轮
这部分其实比写代码更精彩。前面说了方案,但真正实施过程里,产品、运营、测试各有各的诉求,每一个诉求背后都可能让方案变形。我整理了三轮典型的冲突,应该能给你一些经验。
4.1 第一轮:产品说“看不到完整号,用户怎么发短信?”
产品经理拿了一个用户调研结果过来,说很多用户习惯在交易前发短信确认地址。如果只显示脱敏号码,用户没法发短信,体验会很差。
我的应对方式不是拒绝,而是给一个折中方案:平台提供“在线留言”功能,同时帮助用户一键生成一条包含订单信息的短信模板,只不过收信人是平台虚拟号。用户点击发送时,请求会通过后端转发到对方,并附加平台提示“您有一条来自×平台的消息”。
这样用户有“发短信”的感觉,但实际号码又被保护住了。本质上是把“用户与用户直接联系”变成“通过平台代理联系”,在体验和隐私之间做了一个平衡。
4.2 第二轮:运营说“导出通话记录做客服判断”
运营提出,如果用户投诉“联系不上对方”,客服需要看到双方真实的通话记录和手机号,以便人工介入。这个诉求很合理,但不能直接把手机号导出到Excel里。
我给运营做了一个专门的客服工作台:客服在内部管理后台搜索订单时,能查看脱敏号码和隐藏若干位的完整号;如果要查看明文,需要单独申请临时权限,并且后台操作全程审计,每次查看都会留下日志。这个方案在合规上站得住脚,因为“可以看”和“可以下载”是两码事。
实践中,我还给操作日志里记录了访问者的账号、IP、时间和查看原因。一旦出现内鬼,可以直接定位责任人。我们不需要假设所有运营都有恶意,但在系统设计上,必须默认所有内部人员都是潜在风险方。
4.3 第三轮:测试说“联调环境没法验证真机拨打”
测试同事反馈,说脱敏逻辑在测试环境没问题,但“申请联系”功能如果走真实短信通道,需要真实手机号,导致自动化测试跑不通。
这个问题让我意识到,大家不是故意找茬,而是环境限制了验证。于是我在代码里加了一个隐私白名单开关:如果当前用户手机号属于测试白名单(比如以1880000xxxx结尾),系统会自动跳过授权直接展示脱敏后的完整测试号,并打上“测试模式”的标记。
这样可以保证开发环境、测试环境能跑通全链路,生产环境又不会受开关影响。当然,这个开关必须放到配置中心,并且线上环境强制关闭,绝不能做成“传参就能开启”的作弊口子。
经过三轮battle,我的总结是:开发不能只做“技术实现者”,还要做“业务风险兜底者”。产品提需求时不知道风险,运营看数据时不关心合规,测试在乎功能能不能跑通。我们要做的是,在尊重各方诉求的前提下,找到一个在隐私边界内实现目标的方案。好处是,一旦你把替代方案做出来,对方通常都会接受。这个比单纯说“不行”有效一万倍。
5. 常见问题与排查实录
上线之后,各种奇奇怪怪的bug才真正涌出来。这里记录几个典型问题,算是给后来者一个避坑清单。
5.1 授权链接一直失效,用户被卡在“无效分享”
现象:用户A在订单页申请联系方式后,点击链接发现提示“链接已失效”,但明明还没过24小时。
排查过程:第一反应是token过期时间计算有问题。仔细看日志发现,用户A访问链接时带了一个from参数,导致路由匹配走了另一个方法,而那个方法里根本没有校验token的有效期,直接返回默认的“已失效”。还有一个原因是服务器时区设置成UTC,数据库里存的expired_at是UTC时间,但前端显示的是北京时间,也就是差8小时,手动测试时把时间算错了。
修正方案:统一所有时间都以UTC时间存储,展示时由前端做时区转换,服务端只认UTC时间。token失效判断时不再用“当前时间小于expired_at”,而是用“expired_at大于now”这种纯数据库比较,避免应用服务器时区不一致。
5.2 脱敏结果“1388888”变成“13888??”
现象:有些手机号脱敏后出现了乱码或者位数不对。原因是数据库里存的手机号包含空格、横杠或者+86前缀。我在清洗函数里做了处理,但有些老数据写入时没有走同一个清洗函数,导致直接调用mask_mobile时抛异常或者返回14位。
处理方式:写了一个一次性数据订正脚本,把所有phone字段重新清洗,并给相关表加了CHECK约束(或者应用层校验),确保新的写入手机号都经过统一清洗后再入库。同时更新了脱敏函数,遇到“+86”前缀主动去掉,避免脱敏结果出现非数字符号。
5.3 授权链接被转发了,导致陌生人看到手机号
现象:用户A把链接转发到微信群里,群里其他人点开后,居然也能看到完整手机号。当时第一版校验只做了token校验,没有绑定用户身份,导致只要有链接就能访问。
修复方式:在token表里加上access_user_id字段,并在访问时与当前登录用户ID比对。同时,链接携带唯一user标识,后端取当前会话里的用户信息,不信任链接参数里的用户ID。这样即使链接被转发,其他登录用户也打不开。
5.4 日志里手机号全是明文
这是隐蔽问题。虽然业务接口返回的是脱敏号,但我在日志切面里把整个请求参数打印出来了,其中包含解密后的明文手机号字段。一旦日志系统被攻破,数据照样泄露。
解决之道:在日志切面里对phone、mobile、phoneEncrypt等敏感字段做脱敏处理,或者干脆不打印。还要检查异常堆栈里会不会带出SQL参数。这个点容易被新手忽略,但属于合规检查的重点项。
5.5 撤销接口被人批量调用
现象:撤销分享接口上线后,发现有人循环调用,把别人的分享记录都撤销了。原因是撤销接口只校验了“是否登录”,没有校验“是否是分享记录的主人”。
修复方案:在撤销接口上增加权限判断,只有分享记录的owner才能操作,否则返回403。同时,在数据库层也加上过滤条件,避免绕过权限校验。这类越权漏洞其实很常见,尤其是在涉及“通过ID操作”的接口上,一定要把“资源归属”校验做扎实。
6. 最后想说的
那次项目上线后,我坐在工位上想,为什么一个看似简单的手机号分享功能,能牵扯出这么多问题。后来想明白了:越贴近用户信息的功能,越需要敬畏。日常开发里,我们常常为了赶进度,把“展示手机号”当成一件小事,但就是这样的小事,一旦出了隐私泄露事故,代价是整个团队几个月白干。所以不要觉得产品提什么就做什么,该battle时就要battle起来。每次“battle”都不是为了怼人,而是把一个隐藏风险放到桌面上摊开,让所有人都意识到它的存在,然后一起想出更稳妥的方案。
后来我把这套“授权链接+脱敏+权限校验+审计日志”的思路沉淀成了一个内部组件,其他团队做类似功能时可以直接复用。如果你现在正面临“隐私手机号分享”的需求,建议先别急着写代码,而是跟业务方把三件事谈清楚:能看什么、看多久、出问题找谁。这三件事谈明白,代码怎么实现其实都是水到渠成的事。希望这篇复盘能帮你少踩几个坑。