
“AI 软件开发实战教程”系列第 12 篇:让候选中的一方主动确认后,双方同时获得对方微信号,同时保证确认前不泄露、重复点击不重复、资料修改不改写历史。
系统找到“可能同路”的人以后,真正的沟通仍要回到微信。邻行不做站内聊天,因此联系方式交换是第一条核心流程中风险最高的一步。
如果只把微信号显示在候选详情里,会出现三个问题:用户没有明确同意把自己的联系方式给对方;页面权限的一处疏漏就可能批量泄露;用户修改微信号后,过去已经交换的结果会悄悄变化。
K4 先把交换定义成一个独立、不可覆盖的业务事实。
“双向交换”不是“双向审批”
产品规则是:候选双方中的任何一方都可以发起。确认页必须明确告诉发起者:
确认后,你会看到对方的微信号;你的微信号也会同时提供给对方。这只是方便双方回微信沟通,不代表已经约定同行。对方不需要再点一次同意。因此页面不能写成“双向确认”,否则发起者会误以为还要等待。
一次操作同时披露,是这个产品在小范围社区场景中的明确选择。它减少了双方异步上线造成的等待,但也要求确认文案、权限复核和审计事实都足够清楚。
红灯先固定授权边界
K4 的第一组测试在导入阶段失败,因为交换模型和服务还不存在。随后测试逐个固定这些拒绝条件:
操作者不是候选双方之一; 任一方账户被限制或不可用; 候选已经失效; 任一出行信息已经关闭、取消或过期; 已到双方较早的预计出发时间; 双方不再属于同一个社区; 任一方尚未设置微信号。
交换服务不相信用户刚刚打开的候选页面。点击确认到请求抵达服务器之间,状态可能已经改变,所以服务会在短事务中重新锁定和检查候选及双方信息。
一个容易混淆的边界是匹配截止。它只表示停止产生新候选。截止前已经形成的候选,即使现在已经停止匹配,只要双方尚未到较早预计出发时间,仍然可以交换微信号。专门的注入时钟测试固定了这条规则。
保存“当时披露的值”
交换不能只保存两个用户 ID,然后每次打开页面去读取最新个人资料。
假设乘客交换时的微信号是 wx_old,后来改成 wx_new。过去的车主已经拿到 wx_old,系统却显示 wx_new,历史事实就不再可信。
因此 ContactExchange 保存:
候选发起者交换时间车主当时微信号的加密快照乘客当时微信号的加密快照密钥版本服务先用个人资料对应版本的密钥解密,再用当前活动密钥重新加密为交换快照。测试在交换后修改乘客资料,旧结果仍必须返回交换时的值。
这里保存快照不是为了无限保留。账户删除任务仍要按产品规则擦除该用户在在线资料和交换快照中的敏感值,那属于后续 K8 的数据生命周期工作。
一对一约束是幂等的最后防线
同一个候选只能有一次联系方式交换。数据模型使用候选的一对一关系,事务中也先返回已经存在的结果。
测试让车主先发起,再由乘客重复发起,最终只能得到:
1 个 ContactExchange1 个 contact.exchanged 业务事件双方各 1 条站内提醒双方各 1 条渠道投递记录无论双击按钮、网络重试,还是双方几乎同时操作,业务语义都不会变成交换两次。
SQLite 上的测试可以证明顺序重试和数据库唯一事实。真正的 PostgreSQL 双连接并发仍应在具备服务环境时补跑,当前机器没有 PostgreSQL,不能把 SQLite 冒充并发证据。
不要把秘密交给模板再隐藏
一个常见但危险的做法,是把车主和乘客的微信号都放入模板上下文,然后用页面条件决定显示哪一个。
隐藏的 DOM、调试输出、错误页或以后新增的前端序列化,都可能把不该出现的值送到浏览器。
邻行的结果服务先判断当前查看者身份:
查看者是车主 → 只解密乘客快照查看者是乘客 → 只解密车主快照其他人 → 拒绝模板上下文只有一个 other_wechat_id。HTTP 测试分别断言:
确认页源码不包含双方任一微信号; 交换后页面包含对方微信号; 页面不包含自己的微信号; 页面不包含登录账号; 非参与者打开确认和结果地址都得到 404。
安全边界应当尽量发生在数据进入表现层之前,而不是依赖 CSS 或前端脚本隐藏。
复制按钮必须允许失败
微信内置浏览器、系统权限和非安全上下文都可能让 Clipboard API 不可用。一键复制只能是增强能力,不能成为唯一出口。
结果页把对方微信号放在只读但可以聚焦、长按和选择的输入框中。复制成功显示“已复制”;失败时自动选中文本并提示:
复制失败,请长按或手动选择上面的微信号。页面不会尝试打开未知的微信协议,也不会自动跳走。下一步文案只告诉用户返回微信添加对方并确认具体上下车位置。
自动浏览器可以证明结构和降级路径已经存在,但 iOS 和 Android 微信中的长按选择、复制权限与返回操作,仍必须由 Gate B 真机验收,不能由桌面 Chromium 最终代替。
双用户浏览器闭环
K3 已有两个隔离浏览器上下文。K4 在同一旅程后继续执行:
乘客打开候选 → 点击“想和对方联系” → 确认页看不到任何微信号 → 阅读互相披露说明并确认 → 乘客只看到车主微信号 → 车主刷新候选并打开结果 → 车主只看到乘客微信号 → 双方都看到返回微信的下一步两个上下文有独立 Cookie 和会话。测试不是把同一个客户端来回切换用户,因此更接近双方异步操作的真实授权路径。
本节点结果
K4 收口时:
85 个非浏览器测试通过; 分支覆盖率 90.79%; Ruff、格式、mypy strict、迁移和 Django 检查通过; 4 条 Chromium 旅程通过,其中双用户旅程已走到互相披露结果; 重复交换、资料修改、非参与者、受限账户、失效状态和时间边界都有自动证据; 确认前不含微信号,结果页每人只收到对方微信号; 外部提醒实际发送、PostgreSQL 并发、WebKit 和微信真机仍未宣称通过; 结果只形成本地提交,不推送。
验收矩阵中,AC-21 至 AC-23 可以在当前页面范围内自动通过,AC-41 也完成交换前后身份展示闭环。AC-24 仍是部分通过:交换事实与双方独立投递记录已经形成,第三方一方失败不影响另一方要等 K7 故障发送测试。
写在最后
敏感数据功能的关键,不是“加密了”三个字,而是明确谁在什么时刻授权、事务中重新检查什么、保存哪个时间点的值,以及秘密最早在哪一层被裁剪。
当结果模板从一开始就只拿到对方微信号,后续页面改版也更难意外泄露另一份数据。好的隐私设计往往同时让代码职责更清晰。
下一篇进入 K5:双方回微信沟通后怎样分别反馈结果,为什么单方确认不能占座,以及如何用数据库锁保证最后一个座位不会同时分给两位乘客。
关键代码与操作
下面的简化测试从双方视角读取同一次交换,证明每个人只能得到对方的微信号:
deftest_one_actor_discloses_both_contacts(candidate, driver, passenger): exchange = exchange_contact( candidate_id=candidate.pk, actor_id=driver.pk, now=timezone.now(), )assert get_disclosed_contact(exchange=exchange, viewer=driver) == "passenger-wechat"assert get_disclosed_contact(exchange=exchange, viewer=passenger) == "driver-wechat"assert BusinessEvent.objects.filter(event_type="contact.exchanged").count() == 1验证命令:make bugfix TEST=tests/matching/test_contact_exchange.py::test_one_actor_discloses_both_contacts_and_creates_one_event
示例只表达权限方向;真实测试还会覆盖重复点击、资料修改、外部成员和过期候选。
本篇验证摘要
双方必须在同一个有效候选中明确同意互相披露,系统才创建交换; 交换保存当时双方微信号的加密快照,之后修改资料不会悄悄改写历史结果; 唯一约束和幂等服务保证重复点击不会创建第二次交换; 查询服务只向每位参与者返回对方的联系方式,页面拿不到自己的解密值; 移动浏览器已验证双方确认、查看和复制流程,提醒码始终不会进入页面。
附录:相关工具与仓库
gstack
仓库:garrytan/gstack 地址:https://github.com/garrytan/gstack
dev-harness
仓库:Dev-Wiki/dev-harness 地址:https://github.com/Dev-Wiki/dev-harness
UI UX Pro Max Skill
仓库:nextlevelbuilder/ui-ux-pro-max-skill 地址:https://github.com/nextlevelbuilder/ui-ux-pro-max-skill
夜雨聆风