AI 软件开发实战教程(十):把微信群消息变成结构化发布
“AI 软件开发实战教程”系列第 10 篇:把“八点左右”“现在出发”等群聊表达转换成可计算的时间契约,并用 TDD 完成发布、查找、详情和安全群文案。
邻行最初面对的是这样的消息:
【车找人】 【时间】明早 8:05 【路线】万科~环普/中软/阿里/华为它对熟悉小区的人很好理解,对系统却不够明确:
- “明早”是哪一个日期;
- 8:05 是必须准时,还是前后都可以;
- 发布者准备等到什么时候;
- 斜杠分隔的是多个终点,还是大范围方向;
- 消息被重复发送时,是新需求还是同一次点击。
K2 的任务不是给微信群加一个更漂亮的输入框,而是把这些含义变成可以保存、筛选和验收的产品事实。
时间不是一个字段
每条出行信息至少需要四个时间事实:
预计出发时刻 可出发窗口开始 可出发窗口结束 停止产生新候选的截止时刻只填写预计时间时,邻行默认:
预计 08:20 可出发 08:05–08:35 匹配截止 08:05这里“匹配截止”不是最晚出发时间。它表示系统从该时刻起不再形成新候选,让用户还有机会改乘其他交通方式。
上一篇产品审阅中曾出现一个矛盾表达:“可出发到 08:35,最晚等到 08:20”。正式规则把含糊的“最晚等到”拆成两个概念后,页面也不再使用那句话。
先用纯函数固定所有边界
时间规则不应该先藏进表单或数据库模型。K2 先写一个没有数据库副作用的build_schedule,输入当前时刻和用户选择,输出完整时间契约。
测试固定当前时间为北京时间 08:00,覆盖:
- 预计 09:00,得到 08:45–09:15,截止 08:45;
- 预计 08:10,距离不足 15 分钟,截止等于 08:10并标记时间紧迫;
- 选择现在出发,窗口为 08:00–08:15,截止 08:15;
- 把现在出发窗口延长到 08:30,未手改截止时一起延长;
- 用户单独提前截止时不再自动覆盖;
- 预计时刻在过去、窗口不包含预计时刻、截止已到或晚于窗口结束时拒绝。
第一批测试因为apps.trips尚不存在而失败,随后才创建规则模块。
这种做法让时间错误可以精确定位。否则一个浏览器测试只会说“发布失败”,很难判断是时区、表单、数据库还是页面脚本造成。
“现在”不能保存成一个会变化的词
用户点击“现在出发”时,数据库不会保存字符串now。
服务在提交瞬间读取一次服务器时间,保存确定的带时区时间戳:
window_start = 提交时刻 expected_at = 提交时刻 window_end = 提交时刻 + 15 分钟 matching_deadline = 提交时刻 + 15 分钟以后打开详情,看到的是当时保存的具体时刻,而不是每次刷新都向后移动的“现在”。
数据库统一保存 UTC,日期和页面显示按Asia/Shanghai解释。测试也使用带时区的固定时刻,避免依赖运行机器的本地时区。
车找人和人找车不是同一张随意表单
两种信息共享日期、时间和路线,却有一个关键差异:
- 车找人必须填写正整数座位;
- 人找车不能提交座位。
这个规则同时存在于三层:
- 表单给出可修复错误;
- 发布服务拒绝绕过表单的非法调用;
- 数据库 CheckConstraint 保证车主座位大于零、乘客座位为空。
只在页面隐藏座位框是不够的。旧页面、手工请求或后续代码都可能继续提交值,服务和数据库仍要守住同一个事实。
地点必须来自社区词表
首版不采集自由文本说明、联系方式或精确住宅地址。
管理员维护大范围地点,例如:
- 万科悦城;
- 软件新城;
- 国际医学中心。
地点显式属于社区,停用后不能用于新发布。表单使用封闭选择控件,服务再次检查起终点都属于当前用户社区、仍然启用并且不能相同。
地点别名模型也已经建立,管理员可以把“中软”“软件新城”等输入习惯整理到标准地点。当前社区地点很少,页面先使用原生封闭选择;大量地点时的搜索体验留到完整 UI 验收增强,但不能为了搜索方便改回任意文本。
重复点击不能创建两条信息
移动网络中,用户可能双击按钮、返回重试,浏览器也可能在超时后重新提交。
发布页每次展示时生成一个随机幂等键,提交时和用户一起形成数据库唯一约束:
unique(publisher, idempotency_key)第一次请求创建稳定 UUID 信息;后续相同请求返回原信息。服务测试连续执行两次相同命令,证明数据库最终只有一条记录。
幂等键不是短编号。短编号是每条信息独立生成的四位随机辅助标识,用于群里讨论“哪一条信息”,不能稳定追踪同一个发布者。
大厅查询本身就是状态规则
默认大厅只显示:
同一社区 状态有效 当前时间早于匹配截止 当前时间早于预计出发后台过期任务即使晚运行,查询也不会把已经截止或已经出发的信息继续当成可联系内容。
成员可以按日期、类型、起点、终点和本地时间范围筛选。首版没有“方向”控件,也没有关键字搜索。路线兼容关系属于后续候选规则,不应该变成另一个含糊筛选入口。
详情链接使用随机 UUID,并在查询时同时限制社区。知道另一个社区的 URL 也只会得到 404;链接本身不绕过登录和社区资格。
群文案是主动分享的结构化快照
发布成功后,页面生成可以预览和手动复制的群文案:
【车找人】 【日期】2026-08-03 【时间】08:05–08:35 【路线】万科悦城 → 软件新城 【座位】2 个 【状态】正在寻找 【编号】A7K2 【详情】https://example.com/trips/...文案只包含用户已经主动选择公开的大范围信息。它不包含登录账号、微信号、喵码、精确地址或自由说明。
详情页 Open Graph 描述也遵守同样边界。自动测试同时扫描 HTML、群文案和浏览器页面,而不是只检查数据库字段。
浏览器连续发现三个真实问题
HTTP 测试通过后,Playwright 在 375×812 视口执行:
登录 → 打开发布页 → 选择车找人 → 填写日期、时间、地点和座位 → 发布进入详情 → 检查群文案无敏感资料 → 返回大厅看到刚发布的信息第一次失败发生在发布类型。代码定义的是普通 ChoiceField,模板却把它当单选卡片迭代,浏览器实际得到没有正确标签的下拉选项,提交提示类型必填。将字段明确改为 RadioSelect 后修复。
第二次失败发生在路线标题。视觉箭头→被标成读屏忽略,辅助技术只读出两个地点,没有表达方向。页面增加视觉隐藏的“到”,可访问名称变成“万科悦城 到 软件新城”。
第三次失败更接近业务:默认大厅一直为空。无查询参数时筛选表单处于未绑定状态,旧代码只在form.is_valid()为真时执行查询。修正后先执行默认开放信息查询,只有用户实际提交且筛选有效时才覆盖。
这些问题都不是增加更多单元测试能自然发现的。真实浏览器把表单语义、无障碍树和完整页面数据流连在一起。
如何更新验收状态
K2 完成后,自动通过的场景包括:
- 车主和乘客结构化发布;
- 默认窗口、截止、紧迫边界和过去时间拒绝;
- 重复发布幂等;
- 五类大厅筛选且没有方向控件;
- 现在出发及窗口联动;
- 发布表单和群文案没有自由文本与联系方式。
仍然不能提前关闭:
- AC-12 的查询部分已通过,但 K6 还要把状态正式落为过期;
- AC-41 已证明交换前只显示角色和随机短编号,交换后的微信号权限等待 K4;
- 真实提醒、WebKit 和微信真机仍未验证。
任务完成不等于所有相邻能力都已经完成。
本节点结果
K2 收口时:
- 65 个非浏览器测试通过;
- 分支覆盖率 90.24%;
- Ruff、格式、mypy strict、迁移和 Django 检查通过;
- V0、K1、K2 共三条 Chromium 用户旅程通过;
- 发布详情和群文案不包含测试登录账号、微信号或喵码;
- WebKit、PostgreSQL 实跑和微信真机限制继续如实保留;
- 结果只形成本地提交,不推送。
写在最后
把群消息结构化,不是把一段文字拆成几个输入框。
真正的工作是消除会影响匹配和提醒的歧义,同时不采集产品根本不需要的个人信息。日期、时间窗口、截止、地点、座位和状态都必须有单一含义,并能被数据库、查询和页面共同验证。
下一篇进入 K3:当一条车找人和一条人找车先后出现时,系统怎样用明确规则形成“可能同路”的候选,保存可解释原因,并为双方创建同一个业务事件下的两条站内提醒。
关键代码与操作
下面的简化测试固定同一个幂等键连续发布两次,核心断言是数据库最终只有一条信息:
deftest_repeated_publish_returns_one_trip(user,command):first=publish_trip(user=user,command=command,now=NOW)second=publish_trip(user=user,command=command,now=NOW)assertsecond.pk==first.pkassertTripPost.objects.count()==1验证命令:make bugfix TEST=tests/trips/test_publishing.py::test_repeated_publish_command_returns_one_trip
这类测试比只禁用提交按钮更可靠,因为重试请求和旧客户端仍会经过服务与数据库。
本篇验证摘要
- 日期、预计时刻、可出发窗口和匹配截止被保存为含义明确的时间事实;
- 车主座位、社区地点和起终点规则同时由表单、服务与数据库约束保护;
- 发布使用幂等键,重复提交只返回同一条信息;
- 大厅和详情从查询层限制社区与有效状态,不能靠隐藏模板代替权限;
- 移动浏览器已经走通发布、详情、群文案和大厅回看,文案不包含账号或联系方式。
附录:相关工具与仓库
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
