ARTICLE · 992926
AI 软件开发实战教程(十八):让程序能部署,也能恢复

系列第 18 篇:功能和浏览器测试完成以后,用 TDD 补齐首次管理员、可配置端口、正式运行、后台心跳、脱敏日志、加密备份与匿名指标,同时避免把“写好脚本”说成“已经生产验证”。
前十七篇已经让邻行完成从邀请注册到双方确认、提醒、删除和移动浏览器 QA 的闭环。此时最危险的一句话是:“功能都做完了,部署一下就能用了。”
开发环境里只要执行 runserver,Django 会顺手提供静态文件;后台任务也可以在另一个终端人工启动;数据库坏了,大不了重建测试数据。真实用户环境没有这些便利。K10 要回答的是:服务怎样启动,样式怎样进入镜像,谁执行迁移,后台任务死了怎么知道,日志会不会泄露微信号,备份究竟能不能恢复,以及试用数据是否值得以牺牲隐私为代价。
1. 先把生产事实写成失败测试
这一轮没有先写 Docker 配置,而是先加测试,要求:
正式环境通过 WhiteNoise 提供压缩并带指纹的静态文件; JSON 日志只接受明确列出的字段,即使程序误传微信号、喵码和密码也不能输出; Worker 每次循环都更新数据库心跳; 运行快照只能输出积压、延迟、状态计数和心跳年龄; 备份必须加密,保留期不能超过 30 天; 恢复脚本没有“这是隔离数据库”的显式确认就拒绝执行; 镜像必须真的复制模板和样式,迁移必须是单独的一次性任务; 本地端口可以覆盖,不能因为 8000 被其他项目占用就访问到错误应用; 首个系统管理员只能创建一次,密码不能进入日志,重复部署不能重置密码或把普通成员静默提升为管理员。
第一次运行测试直接报错:日志格式器、心跳模型和运行快照都不存在。这是预期的红灯。
部署文档初稿完成后,真实运行又暴露了第二轮红灯。开发命令把端口写死,8000 已经被另一个 Uvicorn 项目占用,随后尝试的 18000 也被同一项目使用。邻行启动失败后,浏览器访问旧端口仍然得到 JSON 404,很容易让人误以为邻行路由坏了。与此同时,部署指南只写了交互式 createsuperuser,没有回答自动部署怎样安全产生第一个管理员。
这说明部署测试不能只检查“文件存在”。还要检查一个新人能否按文档从空数据库走到首次登录。
2. 首次管理员不是写死一个默认密码
最省事的做法,是在代码里创建 admin/admin。它也最危险:镜像、README、日志和所有安装实例都会共享同一个公开密码。
邻行增加了幂等的 bootstrap_admin 命令。“幂等”是指重复执行结果保持一致:
账号不存在
→ 使用一次性环境密码,或生成随机强密码写入 0600 文件
→ 创建 is_staff + is_superuser 系统管理员
账号已经是系统管理员
→ 报告 unchanged,不改密码
同名普通成员已经存在
→ 拒绝执行,不静默提权
密码明文不会出现在标准输出。Web 和 Worker 启动都不会调用这个命令,因此扩容、重启和版本更新不会改变管理员身份。
K10 完成后,项目又把容易混淆的配置入口进一步分开。本地 make dev-bootstrap-admin 不读取 根目录 .env,随机密码写入 var/.initial_admin_password;Docker Compose 只读取.env.compose,源码生产部署只读取 /etc/linxing/linxing.env。两种生产部署没有临时注入 密码时,随机密码都写入受保护的 /var/lib/linxing/.initial_admin_password。如果通过LINXING_INIT_ADMIN_PASSWORD 提供一次性密码,应在首次登录并改成长期密码后从对应的生产 配置中删除。deploy/env/ 中的文件只是模板,不能复制成根目录 .env 作为共享配置。
对应的 TDD 红灯先证明四件事尚未成立:配置密码会创建管理员、命令重复运行不会重置密码、随机密码文件权限是 0600、同名普通成员不会被提升。命令不存在时四条测试全部失败;实现后四条同时转绿。
3. 端口是运行配置,不是代码常量
邻行本地默认端口调整为 18001,同时保留覆盖入口:
make dev-run
make dev-run PORT=18002
Docker 使用 LINXING_HTTP_PORT 控制宿主机回环端口,容器内部仍监听 8000;源码部署则让 systemd 的 Gunicorn 绑定端口和 Nginx 上游保持一致。端口冲突时先确认占用者,不应为了启动当前项目就误停另一个项目。
更重要的是,浏览器能返回内容不代表当前应用启动成功。必须同时确认启动终端没有报错、监听进程属于正确项目、首页特征正确,健康端点也来自同一个服务。
4. 首次部署是一条完整链路
管理员补齐后,首次部署顺序才真正闭合:
配置秘密 → 启动 PostgreSQL → 执行迁移 → 初始化系统管理员
→ 登录后台创建社区和地点 → 生成首个邀请
→ 启动 Web 与 Worker → 验证 live、ready 和 Worker 心跳
迁移和管理员初始化都是一次性运维任务。把它们塞进每个 Web 进程启动,看起来自动,实际上会在多实例并发启动时制造竞争,也让一次普通重启意外拥有修改账号的权限。
5. 镜像能启动,不代表页面完整
检查现有 Dockerfile 时发现,它只复制了 src/ 和 manage.py。邻行的模板与 CSS 在仓库根目录,因此根本不会进入镜像。Gunicorn 本身又不负责像开发服务器那样提供静态资源。
修复包含三层:
把 templates/、static/和运维脚本复制进镜像;构建镜像时执行 collectstatic,生成带内容指纹的静态清单;在生产设置中让 WhiteNoise 紧跟 Django 安全中间件,提供压缩静态资源。
这里又遇到一次有价值的红灯。最初把压缩清单存储放进基础设置后,33 个页面测试同时失败,因为测试环境没有预先生成静态清单。最终把中间件留在通用配置,把严格清单存储收紧到生产和镜像构建配置。方向正确不代表配置层级正确,全量回归帮助我们找到了边界。
6. 后台任务不能只看“进程还在”
原 Worker 只负责取一条提醒。即使生命周期截止和删除保留已有管理逻辑,正式循环却没有调用它们。进程列表显示“正在运行”,业务状态仍可能永远不结束。
新的每次循环按同一时刻执行:
写入心跳 → 扫描截止与过期 → 执行删除保留 → 处理一条提醒
成功时清空错误类型,失败时记录异常类别并继续让进程以错误退出,而不是吞掉问题。runtime_snapshot 可以看到心跳是否超过 60 秒、待发送数量、最老积压年龄、各种投递状态、逾期信息数量和最大截止延迟。
快照不包含业务正文和个人字段。监控需要知道“有四条提醒积压了五分钟”,不需要知道这四个人是谁、去哪里或微信号是什么。
7. 日志采用允许名单,不采用事后遮盖
常见做法是先记录完整请求,再用正则表达式遮盖密码。这很脆弱:字段名称可能变化,Cookie、请求体和第三方地址也可能包含秘密。
邻行反过来做。日志格式器只接受请求编号、请求方法、安全路径、状态码、耗时、内部用户/社区编号、Worker 名称、是否处理任务和异常类别。其他字段即使被代码放进日志记录也会被丢弃。
请求编号会写回 X-Request-ID,以后用户报告错误时可以定位同一次请求;客户端传入的编号必须符合短而安全的字符规则,否则重新生成,避免把任意文本带进日志。
8. 备份的目标不是“每天产生一个文件”
备份脚本使用 PostgreSQL 自定义格式导出,通过独立口令立即加密,权限限制为当前用户,并只在专用目录轮换符合固定命名的加密文件。保留期默认 30 天,超过 30 会直接拒绝。
恢复比备份更危险。恢复脚本要求目标是隔离数据库,并且必须显式设置 ALLOW_ISOLATED_RESTORE=yes;临时解密文件使用随机名称和 0600 权限,退出时清除。
但是本节点没有声称恢复已经通过。当前机器已有 pg_dump 和 pg_restore 客户端,但没有 Docker 或可用的 PostgreSQL 17 服务,只能验证脚本结构、安全门和 Shell 语法,无法完成真实隔离恢复。项目因此把恢复演练表保留为 NOT RUN。正式试用前,必须在隔离实例完成解密、恢复、迁移、匿名计数、加密字段抽查和环境销毁。
“备份成功”只说明写出了文件;“恢复演练成功”才说明这个文件可能在事故中有用。
9. 试用指标不等于页面监控
产品规划需要知道发布是否形成候选、候选是否进入联系方式交换、双方是否确认,以及截止时是否仍然没有候选。这些事实已经存在业务表里,不需要录制用户页面。
export_pilot_metrics 只输出聚合计数与比率:有效发布、出现候选的发布、第三方接受的候选提醒、联系方式交换、双方确认、实际同行正向反馈和截止无候选。分母为零时比率输出 null,不会把尚未测量伪装成 0%。
注册页放弃、提醒码页放弃和复制后是否真的加微信目前无法可靠计算。首版宁可承认不知道,也不接入会话回放或采集输入内容。若真实试用证明某个断点很重要,再设计不含内容、有限保留的业务事件。
10. 本节点结果
K10 的平台中立代码与文档经过补充后完成:可覆盖端口、幂等管理员初始化、首次社区数据顺序、生产静态资源、代理 HTTPS 设置、一次性迁移服务、Worker 心跳、生命周期与保留调度、允许名单日志、运行快照、加密备份/隔离恢复脚本和匿名试用指标都有自动测试。
本地静态清单生成成功,快速测试与静态检查通过。真实 Docker 镜像、PostgreSQL 就绪、恢复演练、TLS、WebKit、微信双平台和外部隐私法律审查仍未通过。
因此开发节点可以收尾,项目状态应是“代码完成,等待外部门禁”,而不是“已经上线”。下一步不再由 AI 擅自跨越:需要产品负责人提供可访问的 HTTPS 环境与两部真机完成 Gate B,再由群管理员和隐私责任人完成 Gate C。
11. 关键代码与操作
下面的部署契约测试只检查公开结构,不读取或输出任何真实密码:
deftest_production_config_sources_remain_separate():
compose = Path("compose.yaml").read_text()
source_example = Path("deploy/env/linxing.env.example").read_text()
assert"env_file: .env.compose"in compose
assert"DJANGO_SETTINGS_MODULE=linxing.settings.production"in source_example
assert"LINXING_INITIAL_ADMIN_PASSWORD_FILE="in source_example
assert"/var/lib/linxing/"in source_example
验证命令:make bugfix TEST=tests/operations/test_runtime_contract.py
这条测试证明配置入口没有重新混在一起;真实部署还必须验证文件属主、权限、服务账号和隔离恢复。
12. 本篇验证摘要
本地开发、Docker Compose 和 Linux 源码部署使用彼此独立的配置入口; Docker 只读取 .env.compose,源码生产部署只读取/etc/linxing/linxing.env;两种生产部署的随机初始管理员密码都写入受限的 /var/lib/linxing/.initial_admin_password;迁移和管理员初始化是显式的一次性任务,不随每个 Web 或 Worker 进程重复执行; 静态资源、Worker 心跳、允许名单日志、加密备份和匿名指标已有自动测试; 真实 PostgreSQL 恢复、TLS、WebKit 和微信双平台仍未完成,当前结论是代码就绪而非已经上线。
13. 附录:相关工具与仓库
13.1 gstack
仓库:garrytan/gstack 地址:https://github.com/garrytan/gstack
13.2 dev-harness
仓库:Dev-Wiki/dev-harness 地址:https://github.com/Dev-Wiki/dev-harness
13.3 UI UX Pro Max Skill
仓库:nextlevelbuilder/ui-ux-pro-max-skill 地址:https://github.com/nextlevelbuilder/ui-ux-pro-max-skill