“AI 软件开发实战教程”系列第 5 篇:产品规划批准以后,不急着创建项目和安装框架,先把并发、幂等、隐私、后台任务和测试边界说清楚,让后面的开发计划真正可执行。
上一篇完成了关键外部服务验证,“邻行”也通过了正式产品规划审批。
现在终于可以讨论技术了。
这一步最容易变成一张技术名词清单:前端用什么,后端用什么,数据库用什么,要不要 Redis,要不要消息队列,要不要微服务。
但这些名字本身不是架构。
邻行真正需要回答的是:
- 两个乘客同时确认最后一个座位时,谁成功;
- 用户重复点击“想和对方联系”时,会不会重复交换和重复提醒;
- 第三方提醒超时以后,站内候选是否还存在;
- 后台任务晚运行几分钟时,已经截止的信息会不会继续匹配;
- 微信号和喵码会不会出现在页面源码、日志或管理后台;
- 将来删除账户时,已经交换过的联系方式怎样清理;
- 产品规划中的 56 个验收场景,怎样进入自动测试和真机验收。
如果这些问题没有答案,即使项目已经能启动,也只是“选好了工具”,还没有形成可以落地的工程方案。
架构不是从框架开始,而是从风险开始
这次先从产品规划中挑出最难补救的失败方式。
风险一:最后一个座位被占两次
假设车主只剩一个座位,两个乘客几乎同时完成双方确认。
如果代码按下面的顺序运行:
查询剩余座位:还有 1 个 → 保存乘客甲确认 查询剩余座位:还有 1 个 → 保存乘客乙确认两个请求都可能看到旧数据,最后得到两个“确认成功”。
这不是页面提示能够修复的问题,而是数据库并发问题。
因此架构规定:双方确认、座位占用和满员状态必须在同一个数据库事务中发生,并锁定同一条车主信息。两个请求竞争时,后取得锁的一方必须重新读取座位,而不是继续使用事务外查到的旧结果。
产品规则“最后一个座位不能被两个配对同时确认”,到这里才真正变成了工程约束。
风险二:外部提醒失败污染产品事实
喵提醒验证已经证明:超时不代表第三方一定没有发送,相同请求也确实会产生重复消息。
如果发布候选的代码直接调用第三方:
创建候选 → 调用喵提醒 → 收到成功后保存数据库那么一次超时就会把系统推入两难:
- 回滚候选,会丢失本来成立的产品事实;
- 自动重试,可能让用户收到两条提醒;
- 继续等待,会把网页请求和第三方稳定性绑在一起。
最终采用的是“业务事件先落库,渠道发送后处理”:
同一数据库事务 ├─ 保存候选 ├─ 保存唯一业务事件 ├─ 保存双方站内提醒 └─ 保存双方外部发送待办 提交 ↓ 后台工作进程再调用第三方这样,第三方失败只改变“发送结果”,不会改变候选是否成立。
这也是为什么架构里需要区分三个容易混在一起的概念:
候选成立 ≠ 已创建提醒 ≠ 第三方已接受风险三:重复操作制造重复事实
移动网络下,用户可能连续点击;浏览器也可能因为超时再次提交;后台任务可能在保存结果前中断并重新执行。
如果只靠“按钮点击后禁用”防重复,换一个请求入口问题仍然存在。
所以邻行把幂等放到数据库事实层:
- 同一发布请求拥有稳定幂等键;
- 同一对信息在同一规则版本下只有一个候选;
- 同一候选只有一次联系方式交换;
- 同一业务事件键只能写入一次;
- 同一事件与接收者只有一条站内提醒;
- 重复请求返回已经存在的结果,而不是再次制造结果。
页面防重复仍然可以提升体验,但唯一约束才是最后一道保障。
风险四:敏感资料在非预期位置泄露
微信号只允许在合法候选中交换,喵码只允许后端触发提醒。
因此“数据库字段加密”还不够。架构继续规定:
- 微信号、喵码和交换时的微信号快照使用应用层认证加密;
- 页面在完成权限判断前不把敏感字段装入模板上下文;
- 普通日志不记录请求体、Cookie、微信号、喵码和第三方完整请求地址;
- 社区管理员的普通后台没有查看私密资料的入口;
- 喵码设置页只显示是否已经设置,不把原值解密回显;
- 账户删除时不仅清理当前资料,也清理过去交换记录中的加密快照;
- 自动测试要主动断言页面正文、源码和日志中“不包含”这些值。
安全不是最后跑一次扫描,而是决定哪些数据在什么时刻能够进入哪一层。
为什么首版选择一个“朴素”的系统
邻行当前有两个微信群,成员总数不到七百且可能重复,活跃时每天约 20–30 条消息。
这个规模当然可能增长,但现在没有证据证明它需要微服务、Kafka、独立搜索、实时推送集群或复杂前端状态管理。
首版最终选择:
- Python 3.12;
- Django 5.2 LTS;
- PostgreSQL 17;
- Django 服务端页面;
- 少量原生 JavaScript;
- 一个独立的 Django 后台工作进程;
- 当时先用 Docker Compose 定义 Web、Worker 和 PostgreSQL 的共同契约;后续实现又补齐了 SQLite 本地源码运行,以及 Gunicorn、systemd、Nginx 的源码生产部署。架构约束是运行组件一致,不是强制所有环境都用 Docker。
选择 Django 不是因为它能自动解决业务问题,而是因为它已经提供账号、会话、表单校验、CSRF 防护、数据库迁移和管理后台。对一个小型受控试用项目,这能减少重复搭基础设施的时间。架构初稿曾选择 Python 3.13;V0 检查真实开发机后发现系统原生提供 3.12,而 Django 5.2 LTS 同样正式支持,因此把基线收敛为能够立即运行完整测试的 Python 3.12。
选择 PostgreSQL 也不是为了追求“更专业”。它直接支持这个产品已经确定需要的事务、唯一约束和行锁;在 SQLite 上通过的测试,不能证明最后一个座位的真实并发正确。
Django 官方把 5.2 标记为 LTS,支持到 2028 年 4 月,并建议始终使用当前安全补丁;PostgreSQL 17 支持到 2029 年 11 月。这里选择长期支持系列,同时在依赖锁定时跟随当前补丁,而不是把调研当天的补丁号永远写死。
参考资料:Django 版本与支持周期
地址:https://www.djangoproject.com/download/
参考资料:PostgreSQL 版本策略
地址:https://www.postgresql.org/support/versioning/
为什么没有先上消息队列
外部提醒需要后台发送,很容易让人直接想到 Redis 和 Celery。
但邻行首先需要的能力只有:
- 业务事务中可靠创建发送待办;
- 一个工作进程能够互斥领取;
- 进程中断后待办仍然存在;
- 每次发送结果可以查询;
- 截止、过期和数据保留任务可以重复执行而不重复制造事件。
PostgreSQL 的待办表已经能够满足这些要求。
后台进程使用行锁领取一小批到期记录。SKIP LOCKED可以让多个消费者跳过正在处理的行,PostgreSQL 官方也明确说明它适合队列式表,但不适合普通业务查询,因为它看到的并不是一致数据视图。
参考资料:PostgreSQL 锁定子句
地址:https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
所以架构只允许在提醒待办表使用它。
如果以后待办延迟持续超过服务目标,再根据真实瓶颈决定是否加入专用消息队列。现在增加 Redis 和 Celery 只会增加部署、监控、故障恢复和本地开发成本,并不会让产品规则更正确。
后台任务不能成为时间规则的唯一保障
邻行有两个重要时刻:匹配截止和出发时间。
后台工作进程可能因为部署、故障或进程调度晚运行几分钟。如果系统只依靠一条定时任务把状态从“开放”改成“截止”,就可能出现:
已经到 08:20 → 截止任务还没有运行 → 页面仍把信息当成开放 → 08:22 又创建了新候选正确做法是双重约束:
- 查询和每个关键操作都用服务器当前时间判断是否已经截止;
- 后台任务负责补写截止结果、发送提醒和整理持久化状态。
前者保证规则立即生效,后者保证后续事件最终完成。
这也是架构中的一个通用原则:定时任务负责“最终处理”,不能成为权限和业务有效性的唯一判断者。
把模块按业务事实拆开
虽然首版是一个应用,也不能把所有逻辑都写进页面处理函数。
邻行按业务职责拆成几个模块:
accounts 登录、邀请注册、私密资料和账户删除 communities 社区、地点词表和路线兼容规则 trips 发布、查找、编辑和状态 matching 候选、联系方式交换、同行确认和座位 notifications 业务事件、站内提醒、渠道发送和后台任务 audit 最小安全审计与保留 web 移动页面组合与公共说明这不是把单体假装成微服务。
这些模块仍然在一个代码库、一个部署单元和一个数据库中运行。划分的价值是明确谁能修改哪类事实,以及事务应该在哪里开始和结束。
例如页面处理函数不能自己修改候选、座位和提醒三张表。它应该调用一个应用服务,由这个服务完成权限判断、锁定、状态变化和事件写入。
先决定数据事实,再决定页面怎样显示
架构阶段还没有设计具体页面,但一些数据选择已经会影响交互。
例如联系方式交换保存“交换当时的微信号快照”。
如果只在页面打开时读取对方当前微信号,那么对方后来修改资料后,已经发生过的交换内容会悄悄变化。这既不利于审计,也会让双方看到的事实不稳定。
因此系统保存当时披露值的加密快照,并在账户删除时一并擦除。
再例如,每条出行信息使用随机短编号,而不是用户的固定昵称。这样同一成员发布多条信息时,其他人不能在交换前用公开编号稳定追踪这个人的出行规律。
这些决定不是数据库设计师的个人偏好,而是产品规划中的隐私边界在工程层的表达。
测试策略也属于架构
如果架构文档只画运行图,却没有说明怎样证明它正确,开发阶段仍然会把测试留到最后。
邻行把测试分为六层:
- 纯规则测试:时间窗口、地点兼容、状态变化和提醒分类;
- 服务测试:邀请码、发布幂等、候选唯一、交换、删除和事件一致性;
- PostgreSQL 并发测试:最后一个座位、重复交换和多工作进程领取;
- HTTP 集成测试:认证、跨社区、CSRF、表单错误和源码不泄密;
- Playwright 浏览器测试:两个独立用户完成发布、候选、交换和确认;
- iOS 与 Android 微信真机验收:验证登录保持、复制、返回微信和分享预览。
Playwright 可以模拟移动设备并覆盖 Chromium、WebKit,也可以用两个隔离的浏览器上下文测试车主和乘客。
参考资料:Playwright pytest 插件
地址:https://playwright.dev/python/docs/test-runners
但它不能证明微信内置浏览器成品已经可用。
所以产品规划中的 AC-31 至 AC-35 在自动化完成后只能标记为“已经准备好人工验证”,必须由真实 iOS 和 Android 微信环境最终通过。
让每一条产品验收进入开发计划
产品规划已经有 56 个验收场景。
架构没有再复制一套新的需求,而是规定开发计划为它们建立稳定编号AC-01到AC-56,每个任务写明自己负责哪些场景,以及用哪一层测试证明。
这个映射解决两个问题:
- 不会出现代码写完了,却不知道哪一条产品规则已经实现;
- 不会出现看板全部完成了,仍有验收场景无人负责。
其中几条必须保留特殊证明方式:
- 隐私场景要有“看不到敏感值”的否定断言;
- 最后一个座位要在 PostgreSQL 做真实并发测试;
- 微信浏览器场景只能由真机最终通过;
- 常规自动测试不能使用真实喵码和真实第三方发送。
TDD 不是“先随便写几个测试”,而是先知道哪条产品事实将用什么证据证明。
AI 无人值守推进也要保留诚实边界
这次工程架构是在产品负责人休息期间,根据已经批准的事实源自动完成的。
原计划中的 gstack 工程评审是一套交互式流程,需要产品负责人逐项选择和确认。如果为了让流程看起来完整,让 AI 自己模拟提问、再假装用户已经同意,就会产生一份虚假的评审记录。
所以本节点做了两件事:
- 完成不依赖新产品选择的架构设计和反向自检;
- 明确记录正式交互式工程评审尚未执行,醒来后可以选择复核。
用户授权 AI 继续工作,不等于授权 AI 编造人的意见。
同样的边界也适用于后面的真机验收、群管理员同意、隐私和法律审查:代码可以准备好,AI 可以生成清单,但不能替真实的人和设备提供证据。
本节点真正交付了什么
工程架构完成以后,项目新增的不是一串框架名称,而是一组可以约束实现的决定:
- 使用适合当前规模的模块化单体;
- 明确产品事实与外部发送结果的边界;
- 明确最后一个座位的锁和事务;
- 明确发布、候选、交换和提醒的幂等约束;
- 明确敏感数据的加密、授权、日志和删除范围;
- 明确后台任务延迟时产品规则仍然成立;
- 明确 56 个验收场景怎样进入测试;
- 明确自动化不能冒充微信真机、社区同意和法律审查。
下一步不是立即堆业务代码,而是先完成移动端页面体验设计,再把产品规则、架构决定和 56 个验收场景拆成纵向开发任务和看板。
到那时,每一项任务才会同时拥有:用户能完成的动作、系统必须守住的规则、要先写的失败测试和可以验收的结果。
写在最后
一个小产品不需要复杂架构,但需要清楚架构。
所谓“清楚”,不是图画得多漂亮,而是遇到重复、并发、超时、泄露、删除和任务延迟时,团队已经知道哪一个事实不能错、由哪一层保证、用什么测试证明。
产品规划告诉我们要做什么。
工程架构的作用,是让这些句子在真实故障和真实并发中仍然成立。
本篇验证摘要
- 首版采用 Django 模块化单体、PostgreSQL 和数据库驱动的后台工作进程;
- 跨实体状态变化使用显式事务,第三方调用放在业务事实提交之后;
- 幂等键、唯一约束和状态机共同处理重复提交与重复事件;
- SQLite 只承担快速测试,最后座位等并发结论仍需使用 PostgreSQL 双连接验证;
- 架构只覆盖已确认的首版能力,没有提前加入消息队列、支付、地图或站内聊天。
附录:相关工具与仓库
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