
“AI 软件开发实战教程”系列第 11 篇:不用模糊推荐分数,先把同路候选写成可以解释、可以反证、不会重复的业务规则,再用两个浏览器用户验证异步匹配。
上一篇把微信群中的“车找人”和“人找车”变成了结构化信息。但两条信息即使已经同时存在,也不会自动认识彼此。
例如,车主早上发布:
明早 8:05,万科悦城到软件新城,2 个座位乘客可能隔几个小时才发布:
明早 8 点左右,万科悦城到软件新城群聊里,较早的消息早已被刷走。邻行 K3 要做的是在第二条信息出现时,重新检查已经存在的另一类信息,为双方形成一个“可能同路”的候选。
这里最容易犯的错误,是一上来就设计一个看似聪明的匹配分数。首版数据少、地点粗、真实反馈也不足,一个 87 分的结果既难解释,也不能证明比明确规则更有用。
先写“不匹配”的条件
与其问“怎样算很像”,不如先问“什么情况下绝对不能形成候选”。
邻行把必要条件写成一组全部都要满足的判断:
同一个社区一条车找人,一条人找车不是同一个发布者同一个出行日期两条信息都仍然有效双方匹配截止时间都还没到车主仍至少有一个座位可出发时间窗口存在交集起点和终点相同,或命中管理员配置的路线兼容规则其中任何一项失败,结果就是“不形成候选”,而不是降低几分后仍勉强推荐。
这样的规则对早期产品有两个好处:用户能理解为什么看到这条信息;开发者也能为每个反例写出确定测试。
用纯规则把反例固定下来
K3 仍从红灯开始。第一批测试导入尚不存在的 apps.matching,测试收集阶段就失败,证明代码库确实没有这项能力。
随后建立 evaluate_pair。它接收车主信息、乘客信息和明确的当前时刻,返回是否兼容、规则版本和结构化解释,不负责发送提醒。
参数化测试逐个破坏必要条件:
把乘客改成同一个发布者; 把日期推迟一天; 把时间窗口错开三小时; 把信息状态改为关闭; 把匹配截止移到过去; 使用没有兼容关系的地点。
每个变化都必须得到空结果。正例则检查解释中保存了日期相同、时间重合和路线依据。
纯规则的意义不只是代码好测。它迫使产品语言也保持克制:系统知道“大范围路线和时间可能合适”,不知道双方具体在哪个门上车,更不知道最后是否真的约定成功。
路线兼容为什么必须有版本
起点和终点完全相同时,邻行使用内置规则版本 1。社区管理员还可以维护显式兼容关系,例如:
车主:万科悦城 → 软件新城乘客:悦城北门 → 中软解释:大范围路线兼容规则版本:3候选会保存当时使用的版本和解释,而不是每次打开页面重新计算最新说法。
如果管理员以后发现这条关系不合适并发布版本 4,旧候选仍能说明自己为什么在版本 3 下产生,新计算则使用新规则。否则一次规则修改可能让历史页面突然换理由,排查投诉时也找不到当时事实。
兼容关系是有方向的。司机路线和乘客路线分别存储,不能默认反过来也成立,更不能把“附近”扩张成不受控的自由文本。
候选唯一不能只靠代码先查一次
发布服务在新信息保存后触发候选重算。它会扫描同社区、相反类型的有效信息,再交给纯规则判断。
但“先查询,没有再创建”挡不住两个请求同时发生。数据库因此对下面的组合建立唯一约束:
车主信息 + 乘客信息 + 规则版本重算服务仍使用幂等的 get_or_create,数据库约束负责最后防线。测试连续重算两次,最终只能得到:
1 个候选1 个业务事件双方各 1 条站内提醒这比要求消息队列“保证只执行一次”更现实。任务可以重试,业务结果仍然稳定。
一件事,不等于一条通知记录
一个新候选对双方来说是同一件业务事实,但两个人的提醒结果可能不同。
因此数据分为三层:
Candidate └── BusinessEvent:candidate.created ├── 车主的站内提醒 + 渠道投递记录 └── 乘客的站内提醒 + 渠道投递记录业务事件用稳定键保证同一候选只创建一次。每个接收者都有自己的站内提醒和渠道状态:待发送、成功、失败、结果未知或未启用。
K3 只创建这些持久化事实,不在发布请求里直接访问喵提醒。这样第三方变慢不会拖住发布事务,也不会因为其中一个人的喵码无效而抹掉另一个人的站内提醒。
没有启用外部渠道时,投递记录明确标为“未启用”,站内提醒仍然存在。自动测试专门断言了这个降级路径。
需要诚实说明的是:本节点还没有证明喵提醒真正发送成功。实际 Worker、错误分类、每小时上限和摘要属于 K7。现在通过的是事件与独立投递事实,不是外部送达。
候选页面为什么不能写“匹配成功”
用户在页面上看到的是:
可能同路,不是已确认同行日期相同时间窗口有交集起终点相同 / 命中某条兼容规则具体上下车位置仍需双方通过微信确认页面没有匹配百分比,也不说系统已经替双方达成约定。
候选列表和详情只对两个参与者开放。其他登录成员即使猜到链接,也得到 404。页面此时不加载任何微信号或喵码,因为“可能同路”还不构成披露联系方式的授权。
这条边界也为下一节点留下清晰起点:只有参与者主动确认交换后,系统才同时向双方披露微信号。
用两个浏览器验证异步出现
单个浏览器无法证明双方看到的是同一个结果。K3 的 Playwright 测试创建两个完全隔离的浏览器上下文,分别保存自己的登录会话,移动视口都是 375×812。
测试流程是:
车主登录并发布 → 乘客登录并发布 → 第二次发布触发候选重算 → 车主进入候选列表和详情 → 乘客进入候选列表和详情 → 双方都看到“可能同路”和相同的微信确认提示它验证了用户不必同时在线,也不必重新发送群消息。较早发布的人在后来者出现后仍能获得站内结果。
浏览器测试没有伪造复制微信或真实外部提醒;那些能力尚未实现。它只证明当前页面、会话隔离、发布触发和双方授权组成的纵向流程。
本节点怎样验收
K3 收口时得到:
76 个非浏览器测试通过; 分支覆盖率 90.74%; Ruff、格式、mypy strict、迁移和 Django 检查通过; V0 到 K3 共 4 条 Chromium 用户旅程通过; 精确路线和版本化配置路线均有可解释正例; 日期、时间、状态、截止和同用户等反例不会形成候选; 重复重算不增加候选、事件或站内提醒; 无外部渠道时双方站内提醒仍存在; WebKit、PostgreSQL、微信真机和外部实际发送仍未被宣称通过; 结果只形成本地提交,不推送。
验收矩阵中,AC-14 至 AC-17 可以自动通过。AC-18 和 AC-54 只能标为部分通过,因为本节点证明了事件和双方独立记录,却还没有执行 K7 的真实发送。AC-55 也只能部分通过:无渠道降级已经证明,一方失败不影响另一方仍待故障测试。
写在最后
匹配系统的第一版不需要显得聪明,需要显得可信。
明确必要条件、保存当时解释、用数据库保证唯一、把一个业务事实拆成双方独立通知结果,这些基础会让后续功能更容易演进。等真实试用产生足够反馈,再讨论扩大路线兼容、排序或推荐,才有数据可以判断改动是否真的更好。
下一篇进入 K4:当一方决定联系候选时,怎样再次检查权限和时效,在一个事务中同时向双方披露微信号,保存不会随资料修改而变化的加密快照,并让复制失败时仍可手动选择。
关键代码与操作
下面的简化测试把“形成一个候选”继续展开为解释、事件和双方提醒数量:
deftest_matching_pair_creates_explainable_candidate(driver_trip, passenger_trip): candidates = recompute_candidates_for_trip( trip=passenger_trip, now=timezone.now(), )assertlen(candidates) == 1assert candidates[0].explanation == {"date": "same","time": "overlap","route": "exact", }assert BusinessEvent.objects.count() == 1assert InAppNotification.objects.count() == 2验证命令:make bugfix TEST=tests/matching/test_candidate_matching.py::test_matching_pair_creates_one_explainable_candidate_and_two_notifications
候选、解释和通知必须在同一条测试里对齐,否则页面可能显示一个无法追溯来源的结果。
本篇验证摘要
候选只在同社区、角色互补、时间重叠且路线兼容时形成; 路线规则带版本,历史候选保留当时使用的解释,不被后续配置改写; 数据库唯一约束和稳定事件键避免重复候选与重复提醒; 双方分别得到站内提醒和独立投递记录,一方失败不会回滚另一方; 候选页面只展示形成原因和随机短编号,不提前披露联系方式。
附录:相关工具与仓库
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
夜雨聆风