checklist

工程经验与教训 · Checklist(通用版)

checklist.md 的分工

  • checklist.md —— 本项目专用,精炼、贴着云账本的具体实现,重装/加功能时照着勾
  • 本文件(通用版)—— 把同一批教训抽象成做同类自托管项目都能用的检查表,
    覆盖更全,带 178 项交付前自检和 15 条事故档案

这份清单来自云账本从零到「手机可用 + 公网可达」的全过程,
记录的是踩过的坑验证有效的做法,不是理论。
每条都对应一次真实的故障或返工。

适用场景:自己做一个小型自托管服务,并且要让它在公网上长期稳定地跑。

最后更新:2026-09-12


〇、一句话总纲

自启只负责「开机拉一次」;守护负责「挂了把它拉回来」。两者缺一不可。

「任何长期运行的东西,都必须有人负责把它拉起来」——
而「开机时启动一次」不等于「一直在运行」。

这次服务断掉的直接原因就是:cloudflared 有服务守护、花生壳有服务守护,
唯独云账本自己只靠一个开机快捷方式——挂了就再也没人管。

为什么这条排第一:代码写错最多功能不对,服务悄悄死了是整个系统不可用,
而且你往往几小时后才发现。这个项目在两个组件上先后吃过同一个亏。


一、长期运行 = 必须有守护

1.1 逐个清点,一个都不能漏

组件 本次的做法 挂了会自动重启吗
应用本体(云账本) 计划任务看门狗,每 5 分钟检查端口
Cloudflare 隧道 Windows 服务 cloudflared
花生壳客户端 厂商自带服务

检查方法:把架构图上的每个常驻组件列出来,逐个问「它挂了谁知道?」

1.2 「启动文件夹」是不够的

Windows「启动」文件夹 = 登录时执行一次
     ↓
进程后来崩了 / 被结束了 / 被误杀了
     ↓
没有任何机制知道,也没有任何机制恢复

结论:启动文件夹只解决「开机」,「运行中挂掉」必须另有守护。

1.3 守护的两种形态

形态 优点 缺点 适用
Windows 服务 系统级托管,崩溃自动重启,不依赖登录 需要管理员;普通脚本不能直接当服务 有服务封装的程序(如 cloudflared)
计划任务看门狗 普通权限即可;逻辑自己写,灵活 有一个检查间隔的窗口期 普通脚本/程序(如 node 应用)

看门狗的正确写法deploy/ensure-running.ps1 就是这么做的):

① 先探测端口是否在监听(短超时 TCP 连接,别用重量级命令)
② 在监听 → 直接退出,什么都不做
③ 不在监听 → 拉起进程

关键点

1.4 手动启动的进程不可靠

踩过的坑:我(AI)用工具调用 Start-Process 起的进程,
是那次调用的子进程——会话清理时被连带杀掉。而且它不留下任何错误日志
看起来就像「应用自己消失了」。

教训

1.5 判断「崩溃」还是「被结束」

现象 结论
错误日志里有堆栈 崩溃 → 修 bug
错误日志完全为空 + 日志戛然而止 被结束 → 查是谁杀的、怎么启动的
Windows 事件日志有记录 系统层面问题(内存、权限)

二、Windows 平台的编码与脚本坑(每次都会踩)

2.1 文件编码(最容易反复踩)

文件类型 要求 原因
.cmd / .bat 纯 ASCII cmd.exe 按系统 ANSI 代码页解析,中文会变乱码并报「不是内部或外部命令」
.vbs 纯 ASCII Windows Script Host 同样按 ANSI 读
.ps1 必须带 UTF-8 BOM Windows PowerShell 5.1 无 BOM 时按 GBK 读,中文全乱

做法:中文全部放进 .ps1.cmd 只做纯 ASCII 的转发壳。

@echo off
chcp 65001 >nul
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0do-something.ps1"
if errorlevel 1 pause

血的教训:编辑器/工具保存 .ps1 时经常会把 BOM 吃掉,
所以要用脚本加回 BOM 并纳入测试,而不是靠人记得。

2.2 不要用 PowerShell 改文本文件

踩过的坑:用 PowerShell 的字符串替换去改 README.md
结果整个文件被按 GBK 重新编码,中文全乱、行也丢了,最后只能 git checkout 回滚。

2.3 cmd /c 的引号地狱

踩过的坑:路径里有括号

C:\Program Files (x86)\cloudflared\cloudflared.exe
                ^^^^^

cmd /c ""C:\Program Files (x86)\...\x.exe" args" 会因为这对括号把引号解析搞坏,
命令静默失败(没进程、没日志、没报错)。

2.4 PowerShell 的 $ErrorActionPreference = 'Stop' 陷阱

踩过的坑:脚本里设了 Stop,结果调用 cloudflared 时它往 stderr 写日志,
PowerShell 把外部程序的 stderr 当成终止性错误,脚本直接中断——
服务装了一半、配置复制了一半,后面的步骤全没跑。

function Invoke-Native($exe, [string[]]$cmdArgs) {
  $prev = $ErrorActionPreference
  $ErrorActionPreference = 'Continue'
  try { & $exe @cmdArgs 2>&1 | ForEach-Object { Write-Host "    $_" } }
  finally { $ErrorActionPreference = $prev }
}

2.5 控制台里的「假乱码」

Get-Content 读 UTF-8 文件再 Write-Host,在某些终端组合下会显示成乱码,
但文件本身是好的

2.6 需要管理员的脚本要自己会说话

2.7 找可执行文件要显式枚举路径,别只靠 PATH

# ❌ 只写 node:PATH 里可能是别的 node(沙箱运行时、捆绑运行时、旧版本)
# ✅ 按标准安装位置逐个探,找不到再退回 PATH
$candidates = @(
  "$env:ProgramFiles\nodejs\node.exe",
  "${env:ProgramFiles(x86)}\nodejs\node.exe",
  "$env:LOCALAPPDATA\Programs\nodejs\node.exe"
)
$node = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1

2.8 启动前先查占用,失败要清干净


三、内网穿透与隧道

3.1 先想清楚「谁主动连谁」

外面进不来(没有公网 IP)
    → 让里面的程序主动连出去,保持长连接
    → 服务器把请求顺着这条连接反向送回来

3.2 隧道方案对比(实测)

花生壳 Cloudflare 命名隧道
实测延迟 0.3~0.6 秒 🏆 1.0~1.2 秒
年成本 ¥0.01(促销价,可能涨 ¥10(自己的域名)
域名归属 厂商分配 自己的
到期风险 ⚠️ 按年续费,促销价不保证 隧道永久有效
访问控制 免费版几乎没有 免费版可配 IP 白名单 / Access

结论:两个都留着最划算——快的当主力,自己域名的当备用,互为保险。

3.3 为什么国内源站用 Cloudflare 反而慢

Cloudflare 的优势是「全球任播、就近接入」,
但如果源站在国内Cloudflare 在大陆没有节点
流量会被调度到境外(实测到了 lax / sjc),一来一回跨境两次

3.4 cloudflared service install 装出来的服务是残的

踩过的坑:装完服务一直是 Stopped,事件日志只有 Cloudflared service starting
没有任何错误。查服务的 binPath 才发现:

BINARY_PATH_NAME : "C:\...\cloudflared.exe"      ← 就这些,没有 tunnel run

cloudflared.exe 不带参数运行只会打印「请用 tunnel run 启动」然后退出。

官方文档第 13~14 步也要求手工改注册表

HKLM\SYSTEM\CurrentControlSet\Services\cloudflared → ImagePath
改成: "<exe>" --config="<config.yml>" tunnel run

3.5 服务账号读不到你的用户目录

服务以 SYSTEM 运行,它的「家目录」是:

C:\Windows\System32\config\systemprofile

所以 ~/.cloudflared/config.yml 找不到。

3.6 隧道建不起来,先怀疑 DNS

临时隧道一直连不上时,别急着怪隧道服务:


四、前端与缓存(最隐蔽的一类问题)

4.1 Service Worker 会让你「改了代码不生效」

踩过的坑:后端明明正确,界面上就是不弹新加的确认框。
原因是 SW 对静态资源用 stale-while-revalidate

caches.match(req).then((cached) => {
  const network = fetch(req).then(...);   // 后台悄悄更新缓存
  return cached || network;               // ← 但这次仍然返回旧缓存!
});

两个问题叠加

问题 后果
stale-while-revalidate 一次请求永远返回上次缓存的那份,新版本要第二次才生效
缓存名写死(VERSION = 'yzb-v1' 不手动改就永远不失效,装成 PWA 更久

4.2 正确的做法

4.3 底部弹层是「单层」的

export function openSheet(opts) {
  if (activeSheet) closeSheet();   // ← 会先关掉当前那个
}

踩过的坑:表单提交成功后 formSheetcloseSheet()
而我在 onSubmit 里弹了一个新确认框——被这次 closeSheet 一起关掉了,一闪就没。

4.4 状态更新要集中成一处

踩过的坑:流水页切换月份时,model.ym 改了、列表也重新加载了,
但中间的月份标签一直不更新——因为 renderMonthBar() 只在页面首次渲染时调用过。


五、数据与账务一致性

5.1 分清「计划」和「实际」

预算层(计划)  ──「生成到账本」──►  账本层(实际)
      ▲                                    │
      └────────「对比」只读 ────────────────┘

5.2 关联与生成是两件事

踩过的坑:用户说「取消关联」,他其实想要「取消自动记账」。
而系统把两者绑在一起——有关联就会生成。

最终设计

关联科目 + 资金账户  →  生成的前提
取消关联            →  自动撤回它已经生成的凭证(对称、可逆)

5.3 计划改了,派生数据会过期

踩过的坑:生成到账本之后,再改预算金额,已生成的凭证原封不动
(代码遇到 alreadyPosted 直接跳过)。想更新只能一个月一个月地撤销重来。

正确的同步语义

情况 处理
计划里还有它、内容变了 删掉旧凭证 + 按新内容重新生成
计划里已经没它了 删掉旧凭证
没变 不动
已对账 跳过并如实报告(改了就跟银行账单对不上)
没生成过的月份 不动(那是「生成」的职责,不能顺手多生成一堆)

5.4 时点事件与逐月事件分开处理

5.5 别漏掉「期初」和「折旧」

踩过的坑 ①:预算里设了期初盈余 8 万,但账本里没有对应凭证
→ 预算的历史盈余和账本的净资产差整整 8 万

踩过的坑 ②:预算按 -15%/年 折算房产市值,账本按原值记
→ 差额逐年累积,到 2040 年差 94 万。

5.6 时间边界要写对

踩过的坑balanceOf 写的是「首期还款之前 = 原始本金」,
没有区分「还没放款」和「已放款但未到首期」。
结果贷款 9 月才发放,1~8 月的净资产却被算成 -96 万

5.7 每次都要验会计恒等式

资产 − 负债 = 期初权益 + 收入 − 支出

5.8 差异必须能拆解到分

验证有效的做法:发现差 675,700 时,逐项拆解

预算里有、账本里没有的预估开销      +676,000
  ├─ 家庭开销      52 个月 × 5,000  =  260,000
  ├─ 爸爸医疗健康  40 个月 × 10,000 =  400,000
  └─ 房租           4 个月 × 4,000  =   16,000
账本里有、预算里没有的手工记账      −300
  └─ 一笔手机上记的「儿子手机」
                            ────────────
                            675,700  ✅ 分毫不差

5.9 统计 SQL 别漏软删除

踩过的坑:写对账/汇总 SQL 时用

LEFT JOIN transactions t ON t.id = s.tx_id AND t.deleted = 0

看起来没问题,但软删除交易的 splits 仍然会被 SUM 进去
(LEFT JOIN 的条件在 ON 里,不匹配时 t.id 为 NULL,但 s.amount_cents 照样累加)。

正确写法:

COALESCE(SUM(CASE WHEN t.id IS NOT NULL THEN s.amount_cents ELSE 0 END), 0)

5.10 写入路径的六条纪律(记账类应用尤其重要)

这几条是「数据一旦写坏就很难发现」的防线,做任何有金额/有状态的应用都适用。

① 金额全程用「分」为整数,绝不用浮点

// ❌ 0.1 + 0.2 !== 0.3,在钱上是事故
// ✅ 全部以「分」存整数;展示时除 100;解析输入时四舍五入成整数
const amountCents = Math.round(parseFloat(input) * 100);

② 服务端强制借贷平衡,不信前端传过来的数

③ 写操作包事务

BEGIN IMMEDIATE;   -- 立刻拿写锁,避免并发写冲突
... 多条 INSERT/UPDATE ...
COMMIT;            -- 任何一步出错 → ROLLBACK

④ 幂等键:重试不能变成重复记账

⑤ 软删除 + 部分唯一索引(唯一约束要排除已删除行)

-- ❌ 唯一索引包含已删除行:删掉后重建同名/同 clientId 的记录会失败
CREATE UNIQUE INDEX uq_tx_client ON transactions(book_id, client_id);

-- ✅ 只对未删除的行生效
CREATE UNIQUE INDEX uq_tx_client ON transactions(book_id, client_id) WHERE deleted = 0;

⑥ 生成类操作要可撤销

5.11 计算引擎要能兜底,不能死循环

凡是「按公式逐期推算」的东西(贷款摊销、折旧、分期、配额),边界一定要写死:


六、安全清单

6.1 公网可达 = 内网信息一律不下发

踩过的坑:设置页显示「手机访问 http://192.168.44.4:51194」「数据目录 E:...」「数据库大小」。
更严重的是接口层就在下发,而且两个公开接口也在下发

接口 是否需要登录 泄露了什么
/api/health ❌ 公开 内网地址
/api/auth/status ❌ 公开 内网地址
/api/system/info ✅ 需登录 内网地址、数据目录、数据库完整路径、库大小、运行平台
登录页 ❌ 未登录可见 把内网地址印在表单下面

6.2 隧道隐藏了什么、没隐藏什么

公网攻击者 同一局域网的人
真实 IP ✅ 隐藏
本地端口 ✅ 隐藏 ❌ 暴露
明文 HTTP 访问 明文可达

踩过的坑:应用监听 0.0.0.0带着笔记本连任何 Wi-Fi,同网段的人就能明文访问

curl http://192.168.44.4:51194/api/health   # → 200,明文

6.3 TLS 在中间终止 —— 中间方能看到明文

手机 ══加密══► 中间服务器 ══加密══► 你的电脑
                    ▲
              在这里解密,登录密码也是明文

这是所有「中间人转发」架构的共性,不是某家的缺陷

6.4 平台账号被盗 = 钓鱼(比密码弱更危险)

攻击者拿到你的隧道平台账号
  → 把域名映射改成指向他自己的服务器
  → 放一个一模一样的登录页
  → 你输密码 → 密码直接进他手里

你完全无法分辨——地址栏、证书、页面全都对。

6.5 其他必做

6.6 一个反直觉的点

自定义域名的证书会进公开的证书透明日志(CT log)
任何人都能搜到你的子域名。

6.7 TRUST_PROXY 两个方向都会出事

这个开关决定「要不要采信 X-Forwarded-For」,开和不开都有风险

情况 后果
监听 0.0.0.0没开 TRUST_PROXY 登录限流按直连来源算;攻击者可伪造 XFF 绕过限流。局域网同网段还能明文直连
有可信反代但没开 TRUST_PROXY 限流把所有用户当成同一个来源(代理 IP),一个人触发就锁住所有人
没有可信反代却开了 TRUST_PROXY 客户端可以随便伪造来源 IP,限流形同虚设

6.8 备份与恢复的纪律

备份

恢复(这一步出事代价最大):

6.9 时区:日期类应用的隐形杀手


七、测试与排查方法

7.1 每个 bug 都要有测试守住,而且测试要真能捕获它

踩过的坑(断言写错)

错误断言 问题
created.some(x => x.sourceType === 'opening') created 里根本没有 sourceType 字段
summary.items.some(x => x.sourceType === ...) summary 是按月聚合,没有 sourceType
assert.ok(r.json.dbSize > 0) 字段已经删掉了,应该反过来断言不存在
扫描整页文本找 127.0.0.1 「登录设备」卡片正常显示会话 IP,误报
loanBalanceCents === 0 那是所有贷款的合计,测试库里有别的贷款

7.2 浏览器测试能发现「光看代码发现不了」的问题

本次靠浏览器测试抓到的:

  • 弹层被表单收尾的 closeSheet() 一起关掉(第五节 4.3)

  • 月份标签不更新(第四节 4.4)

  • 新的硬性表单校验把既有流程挡住了

7.3 测试之间不要互相污染

踩过的坑:为了验证登录页,测试里清了 localStorage
结果后续用例的登录态全坏,一片红。

7.4 用「增量」隔离共享状态

有效的做法:验证「某笔贷款在放款前的余额」时,
loanBalanceCents 是所有贷款的合计,测试库里还有别的贷款。

const delta = (m) => afterAt(m) - beforeAt(m);   // 加这笔贷款前后的差
assert.equal(delta('2026-01'), 0);

7.5 排查顺序(省时间的)

① 换个客户端/通道试 → 判断是服务端还是链路
② 看进程在不在 → 判断是「没运行」还是「运行了但不对」
③ 看日志(应用日志 + 错误日志分开)→ 判断崩溃还是被结束
④ 看服务的实际命令行 / 配置 → 判断配置有没有生效
⑤ 直接打接口看响应 → 判断是后端还是前端
⑥ 看前端资源版本 / 缓存 → 判断是不是浏览器跑了旧代码

7.6 改完代码后必须验证

7.7 端到端测试至少要覆盖这些

不是「随便点两下」,而是一条能贯穿的链路 + 几个容易坏的角落:

7.8 排查口诀与应急卡(贴在运维手册最显眼处)

先换通道 → 看日志 → 看进程 → 重启电脑
不要一上来就重装。

几个高频判断

现象 结论
应用日志有新记录 请求到了,问题在回程(隧道/网络)
应用日志完全没动静 请求没到,应用或通道没起
错误日志为空 + 日志戛然而止 被结束,不是崩溃
地址栏有锁但页面空白 通道还在,后端没了(或映射到期)
白屏 / 样式乱 浏览器缓存旧资源 → 清缓存
反复被守护拉起 应用有真 bug → 看错误日志,不是守护的问题

应急卡(手机打不开时按顺序):

① 换另一个网址试(两条通道互为备用)
② 看 data\server.log 有没有新记录
③ 看守护日志:data\watchdog.log —— 是不是刚被拉起过
④ 看映射/隧道是否到期
⑤ 重启电脑(所有守护会把服务重新拉起来,等 1 分钟再试)

八、协作与流程

8.1 先对齐语义,再动手

踩过的坑:用户说「家庭开销不应该关联」,
字面理解是「清空关联字段」,实际想要的是「不要自动记账」。
这两个在当时的系统里是绑在一起的。

8.2 大改动先给方案

8.3 每次提交说清楚「改了什么、为什么」

8.4 文档分三类,各管一段

文档 回答什么问题 给谁看
README 这是什么、怎么用 使用者
部署文档 当初怎么搭起来的(含踩坑记录) 未来的自己
运维手册 坏了怎么查、东西在哪 出事时的自己
原理文档 为什么这样设计 想深挖的自己
本清单 下次做类似项目怎么避坑 下次的自己

8.5 待办要显性化

8.6 定期做一次「外部视角」的代码审查

自己写的东西看不出自己的问题。这个项目在一次外部走读里被点出三类问题,
都是自己反复看过却没发现的:

类型 实例 为什么会漏
同类函数不一致 对账模块里 5 个函数查会话都带 AND book_id = ?,唯独 setMarks 漏了 写的时候「照着上一个抄」,抄漏了没人提醒;单账本下没后果,更难发现
算了又不用 const sign = naturalSign(type) 紧跟一句 void sign 看着「有这行代码」,其实是无用功;void x 是给自己看的遮羞布
一行两句 if (...) throw ...; sendJson(...) 挤压在一起的语句提交时容易看漏

反过来也要注意:外部审查的结论要自己核实
同一份审查把「测试失败」归因为「跨用例状态污染」,
实际是当时断言写在了错误的数据结构上——现象对、归因错
收到结论先复现一遍,再决定改什么。


附:交付前自检清单(可直接勾)

守护与可用性

网络与访问

安全

数据一致性

备份与恢复

前端

脚本与编码

测试

运维准备


附:本次事故档案(供复盘)

# 现象 根因 教训
1 改了预算保存后没弹同步框 Service Worker 返回旧缓存 + 版本号写死 代码类资源要网络优先;版本号要自动变
2 jz.dlink.work 502,挂了 1 小时 cloudflared 进程静默退出,无人守护 常驻组件必须有守护
3 服务装上了但一直 Stopped service install 写出的命令行不完整 装完必须查 binPath
4 隧道 VBS 完全静默失败 Program Files (x86) 的括号搞坏 cmd 引号 外部程序调用绕开 cmd
5 服务安装脚本跑到一半中断 $ErrorActionPreference='Stop' + 外部程序写 stderr 调用外部程序时放宽
6 应用进程莫名消失,无错误日志 是我用临时会话启动的子进程,会话清理被连带杀掉 长期运行的进程要用系统级方式启动
7 净资产凭空为负 96 万 没区分「未放款」和「未到首期」 时间边界要全部列出来确认
8 预算与账本差 94 万 折旧只算在预算、没落账本 两边口径设计阶段就要对齐
9 差 675,700 查不出原因 拆解后发现是「未录入的预估开销」+「一笔手工记账」 差异必须拆解到分
10 汇总金额含已删除凭证 LEFT JOIN 的 deleted = 0 写在 ON 里仍会累加 软删除表的聚合要显式 CASE
11 切月份标签不更新 renderMonthBar() 只在首次渲染调用 状态更新集中成一个函数
12 新弹窗一闪就没 单层弹层 + 表单收尾的 closeSheet 用 afterClose 钩子
13 设置页泄露内网地址 接口层就在下发,两个公开接口也有 修在接口层,公开接口重点审
14 README 被改乱码 用 PowerShell 做字符串替换,按 GBK 重写 文本文件别用 shell 改
15 .cmd 报「不是内部或外部命令」 cmd.exe 按 ANSI 读不了 UTF-8 中文 .cmd 纯 ASCII,中文进 .ps1
16 「取消生成 → 重新生成」一直失败 唯一索引没排除软删除行,旧记录仍占着 client_id 唯一约束要问一句「删掉后还能重建吗」
17 自检误报「服务账号目录不存在」 普通权限读 System32\config 是「拒绝访问」,被当成「不存在」 权限不足 ≠ 资源缺失,读不到就别下结论
18 自检文件被写坏 又用 PowerShell 的 Set-Content 重写 .js,中文按 GBK 重编码 同一个坑踩了两次 → 已成硬规则

附:这份清单怎么用

场景 过哪些章节
新起一个自托管小服务 〇(守护)+ 二(平台坑)+ 六(安全)+ 5.10(写入纪律)
要开放到公网之前 六(全部)+ 3.x(隧道)+ 交付前自检的安全段
跑了一段时间要体检 交付前自检(全部)+ 7.8(排查口诀)
加新功能 五(一致性)+ 7.1~7.7(测试)+ 8.x(流程)
换机器 / 从零恢复 六.8(备份恢复)+ 一(守护)+ 二(脚本编码)
出了故障 7.8(口诀)→ 运维手册的故障对照表

最有价值的不是「做对了什么」,而是「为什么会做错」。
每条后面的「为什么」都对应一次真实的返工,别跳过。

posted @ 2026-09-12 20:51  omig001  阅读(10)  评论(0)    收藏  举报