主题
专题验证 · 运维监控
常驻清单,不归档。判断层专用——机械层在
scripts/ops-verify.sh,先跑它。 对象:/admin/ops一页 +supabase/sql/monitoring.sql+scripts/backup-supabase.sh体裁与纪律见README.md
bash
./scripts/ops-verify.sh0. 开工前必须先问的一句
这套东西的全部意义是让静默故障可见,所以它最危险的失效方式不是报错,而是 假绿——界面一切正常,底下什么都没发生。开发与三轮验收里已实际抓到 7 处, 全在这套监控自己身上。
因此每一格都要问:
🔴 如果它要判的那件事变了,这个数字会不会跟着变?
变不了,就是接错线——无论它当前显示什么颜色。已抓到的 7 处全是这个形状。
四条配套纪律:
- 看到绿,先问它凭什么绿。 逐条对着「假绿风险」列排除。
- 优先做反向验证(§2)。只验通过路径的清单,和没验是一样的。
- 区分「取不到」和「正常」。 这是本系统最主要的缺陷模式。
- 恒红与恒绿一样坏。 因与所守之物无关的原因而常红的闸门,会训练出忽视—— 真失败就混在里面没人分得出来。看到红先问「它为什么红」。
⚠️ 元前提:先看同步表里有没有数据
run_db_audit() 的 7 组检查有 6 组是「在现有数据里找异常」。表若是空的 (如刚跑过 scripts/reset-dev-data.sh),它们全部无异常可找—— 此时「巡检全绿」几乎不携带信息量。这是最容易踩的元假绿: 你验的不是「系统健康」,而是「没有数据可供检查」。
ops-verify.sh --verbose 会打出当前行数。报告第一句就要写明这个前提。
📌 2026-08-26:库里已恢复真实同步数据,此前「6/7 组检查无异常可查」的元前提已解除 —— 同日 run_db_audit() 在有数据的情况下仍返回 [],那是一次有信息量的全绿。
1. 判断层逐格判据
机械层已覆盖的不再重复(表齐/非空、anon 隔离、探测目标与 §12 声明一致、部署态 definer、 PostgREST 路径、cron 齐全与新鲜度、告警通道指向、凭据台账三态)。
以下是只能看上下文才能判的部分。
1.1 总览与汇总
| # | 问句 | 假绿风险 |
|---|---|---|
| J1 | 总览条的每盏灯,与下方那张卡调的是不是同一个函数? | ✅ 2026-08-26 修:判据全部收进 admin/lib/ops-status-rules.ts,卡片 / 总览 / 首页横幅共用。问法已经变了 —— 旧问法是「两处一不一致」,那只能事后发现漂移;现在问的是「有没有第二份判据」。🔴 复发信号:ops-console.tsx 的 overview 数组里出现任何三元判断,或某张卡自己内联判色 |
| J2 | 汇总把 unknown 当成 ok 了吗? | 把「不知道」画成绿是运维看板最致命的错误 |
| J30 | 你现在看的这份快照,是几点取的? 页面开着不动一小时,界面上有任何东西会变吗? | ✅ 2026-08-27 修:全页每一格判的都是「取数那一刻」,而取数时刻此前只在 mount 与写操作后推进 —— 于是「探测超过 90 分钟算过期」这类判定永远不会触发,你盯着几小时前的快照而它显示正常(一层温和的假绿)。现在:切回前台自动重拉 + 每分钟推墙钟,超 SNAPSHOT_STALE_MINUTES 则整页总体判 unknown 并出提示条;首页横幅同治(它此前连 fetchedAt 都没有,而首页最容易长期开着)。🔒 per-card 的基准仍是 fetchedAt,这条刻意没改 —— 判「拿到这份数据时它成不成立」比拿旧数据跟此刻墙钟比更诚实。🔴 重跑时问:把 freshness 从总体里摘掉,有没有东西会红?(单测扫接线,见 §2.4) |
1.2 数据安全三卡
| # | 问句 | 假绿风险 |
|---|---|---|
| J3 | 「本机备份心跳」显示正常时,stamp 是不是几周前?阈值判定还活着吗? | 心跳陈旧 = 备份停了(dead man's switch)。显示「正常」但 stamp 很旧 = 判定坏了 |
| J4 | 「巡检自身还活着吗」读的是 ops_heartbeat['db-audit'] 还是 findings 的 max(checked_at)? | 🔴 用 findings 判 → 全绿巡检不留痕 → 此卡永远报不出 ok。机械层 M5 已验心跳会无条件推进,但读侧口径要人看 |
| J5 | 「巡检发现」为空时,你怎么知道是「无异常」而不是「没在跑」? | 必须与 J4 心跳合看。二者数据同形 |
| J6 | 一条 finding 的「还在不在」判据,是这条 check 自己最后一次留痕有多新吗? | 🔴 若判据牵扯全表最新时间戳,谁最后写了表就能改变它的状态。ops_audit_findings 有三个独立写入者,一次探测失败就能推进全表时间戳 |
| J7 | 老化后的 finding,界面写的是「上次出现 X 前」还是「已消失」? | 巡检只在问题存在时留痕,数据只支持前者。写「已消失」是拿推测冒充事实 |
J6 的反向验证:让另一个写入者(探测)写一行,看某条仍然成立的 finding 状态会不会跳变。
1.3 平台与探测
| # | 问句 | 假绿风险 |
|---|---|---|
| J8 | 「站点可达」除了 consecutive_failures,看不看 last_checked_at 的新鲜度? | ✅ 2026-08-26 修:超过 PROBE_STALE_MINUTES(= 3 个探测周期;2026-08-27 起由 tests/unit/cron-manifest.spec.ts 钉在 supabase/sql 里 probe-endpoints 的真排期上,排期一改测试就红)没有新结果即判 unknown —— 「不知道站点通不通」不是「正常」。已知的失败仍判 critical(过期不会把坏消息变成好消息)。重跑时要问的是:那个容差还配得上当前排期吗 |
| J9 | cron 卡:last_status=null 的任务什么颜色?被 unschedule 而从列表里消失的呢? | ✅ 2026-08-26 修,当时是三个洞叠在一起:null(从未跑过)判 ok、succeeded 但早已停摆判 ok、以及最坏的一个 —— 任务被 unschedule 后直接从 cron.job 消失,所有 some(...) 恒 false → 也判 ok(监控任务没了,监控页说一切正常)。现在按 EXPECTED_CRON_JOBS 声明式核对集合,容差从 schedule 现推。2026-08-27 补上了守门:那张声明表曾是第二本账(与 SQL 里的真排期、与 M6 写死的 MAXAGE_H 三份并存,对得上只是证据不是守门)——现在真相源统一为 supabase/sql 的 cron.schedule,M6 从它派生集合、tests/unit/cron-manifest.spec.ts 对它核 EXPECTED_CRON_JOBS,M6 另新增「live 排期 vs 仓库声明」比对(抓 cron.alter_job 改了生产而仓库不知道 —— 那种漂移下容差会乖乖跟着变,数字变了意图没变)。🔴 任务真的退役时声明表会变成恒红:届时改声明,不是删检查 |
| J10 | 容量卡的 RPC 失败时,显示的是错误还是 0? | 显示 0 = 「没数据」被画成「很空闲」 |
| J11 | top5 表的「行数」是精确值还是 reltuples 估算?界面有没有标出来? | 估算值在刚清库后会长期偏离真实值 |
1.4 外部平台三卡
| # | 问句 | 假绿风险 |
|---|---|---|
| J12 | 「发布链路一致性」三值任一取不到时,是黄色 + 列 unverified,还是绿色? | 🔴 曾在「最新 Release —」时仍显示绿。判据必须是 mismatches 与 unverified 双空才绿 |
| J13 | Release 列表是显式按 published_at 排序的吗? | GitHub 列表端点不按时间排序。实测 v0.1.0-dev.10 排在第 5 位,取 [0] 会拿到 dev.8 → 假红。这条修复当前正在承重 |
| J14 | 「部署与 CI」那格:未配 token 时显示「未配置」还是「正常」? | 未配置被渲染成绿 |
| J15 | 你看到的「取不到」是现在取不到吗? | ✅ 2026-08-26 修:cached() 按值挑 TTL —— 失败、以及「成功但里面写着某一路没取到」(发布链路的 unverified、Sentry 的单 project 失败)都只缓存 30s,成功照旧 10 分钟。刻意不改成「失败完全不缓存」:外部源真挂时每次刷新要吃满 3 个 4s 超时,且 GitHub 匿名只有 60 req/h |
| J16 | Sentry 那格:单个 project 出问题时,另外两个的数字还看得到吗? | ✅ 2026-08-26 修:getSentryHealth 改 Promise.allSettled,逐 project 降级(取不到的那张 tile 显示原因,其余照常出数)。卡片状态在「部分取不到」时是 warn —— 不能因为剩下两个是 0 就报绿 |
| J17 | 「告警通道」这盏灯接的是哪条通道? | ✅ 2026-08-26 修:改判 ops_notify() 真正读的那几个值 —— ops_monitor_config 三段齐不齐、alert_enabled、静音,外加与本站 DSN 的指向核对(判据同机械层 M7,只是搬进了界面)。🔒 仍是只读显示,没有写入面(monitoring.sql §11);三段的值不再下发浏览器,只跨结论。Sentry 卡上现在是两块:DB 告警通道 / 浏览器 SDK 的 DSN —— 它们是两条路,别再合并回一块 |
1.5 写入面
| # | 问句 | 假绿风险 |
|---|---|---|
| J18 | 「立刻跑一次巡检」跑的是全部检查吗?界面有没有让人以为是? | 🔴 按钮只调 run_db_audit()(不含下列三项);backup_heartbeat / credential_expiry / credential_review 住在 ops_daily_monitor() 里,按钮永远不会产出也永远不会刷新它们。点完看到「全部通过,无异常」会以为备份和凭据也刚查过。✅ 2026-08-26 修文案:按钮改叫「立刻跑一次一致性巡检」,明写不含哪三项、它们归谁跑;结果行改成「一致性检查全部通过 —— 备份与凭据不在本次范围内」。⚠️ 刻意不写「N 组检查」(同轮就新增了 tombstone_parent,写死的数字必然过期)。顺带修掉 JSX 里被当字面量渲染的 **不发告警** |
| J19 | 每个可写阈值都有上界、且界面写明了吗? | 🔴 上界比下界危险:下界填错天天误报(吵,但你会发现),上界填错永远不告警(静默失效) |
| J20 | 静音有到期时间吗?有没有「永久静音」入口? | 忘记恢复的静音比没有告警更危险——你以为有告警 |
| J21 | 凭据台账逐行看:有没有任何一列含密钥值本身? | 🔒 这是本表的存在前提 |
| J22 | 「不过期」这三个字凭什么?界面能区分「有硬到期日 / 确实不过期(有复查日) / 没人填」吗? | 后两者若渲染成同一个样子,漏填的行会以「正常」的样子躺着,而告警侧也因 expires_at is null 跳过它——界面和告警同时沉默。机械层 M8 已断言无第三类行 |
1.6 鉴权与密钥(多数已由 e2e 覆盖,这里只留判断项)
pnpm --dir admin test 覆盖门/锁/CSP/密钥扫描。要人判的是扫描器本身有没有效:
| # | 问句 | 假绿风险 |
|---|---|---|
| J23 | 阳性对照还在吗(扫描器必须能看见 anon key、必须能在全 chunk 里找到 ops 页的 canary)? | 🔴 否定断言最危险的失效不是误判,而是根本没扫到东西——会安静全绿。/admin/ops 过门 307,未设 maxRedirects: 0 的抓取会跟随重定向抓到登录页 |
| J24 | 扫 github_pat_* / sntry[su]_* 形状的那条,在测试环境里有可能命中吗? | 本地/CI 都不注入这两个 token,正则找的是一个不可能出现的字符串——那是空断言。当前状态:防御纵深,无法命中(变量名那条是能命中的) |
| J25 | admin.lampio.app 与 *.vercel.app 都是公网可达的吗? | Vercel 的 SSO 只罩带 hash 的部署 URL,不罩生产别名。proxy.ts 是唯一屏障,而 admin 持 service_role |
1.7 文档与真相源
| # | 问句 |
|---|---|
| J26 | docs/database-operations.md 描述的判据与代码实现一致吗?尤其「巡检存活」那条(判据换过一次) |
| J27 | monitoring.sql §11 的 definer 清单与 pg_proc.prosecdef 逐个对得上吗?(机械层 M4 只验两个关键函数,清单完整性要人看) |
| J28 | §12 seed 模板里那两行探测目标,写的是当前真实生产域名吗? |
| J29 | 台账内容有没有在文档里被抄第二份?(真相源是 ops_credentials 表,2026-08-18 已因此删过一次文档副本) |
2. 反向验证配方
只验通过路径 = 没验。 以下三个配方都不依赖「记得改回来」。
2.1 巡检真能报出问题(事务内注入 + rollback)
最重要的一项。findings 表长期为空既可能是「很健康」,也可能是「检查根本没在跑」, 二者数据同形。
bash
psql "$DB" -f <(...) # begin; 注入; select run_db_audit(); rollback;要点:
- 造孤儿行 / 跨用户行 / 悬挂 FK / 墓碑回声 / 悬空 Storage 指针 / 失败 cron 记录, 全部包在一个事务里,函数写进
ops_audit_findings的行、心跳、乃至pg_net的发送队列都会一并回滚。不依赖记忆,也不怕中途断线。 push_stalled需要先造行再把audit_stale_days压到 -1(同一事务内)。 空表时max()为 NULL、NULL > n求值为 NULL 而非 true →push_stalled静默跳过。该盲区由push_empty单独补(K1,2026-08-26 已启用)。- 注意唯一约束:
user_learning_entries(user_id, word)与user_word_page_links(learning_entry_id, reading_page_id)都有 UNIQUE, 注入多行时要错开取值。 - ⚠️
orphan_rows注不进去:user_*_user_id_fkey挡着,非超级用户也禁不掉系统触发器。 这不是失败,但必须在结论里写明「这条检查从未被实际触发验证过」(K2),别记成通过。 - 跑完仍要复核零残留,别只信 rollback 打出来了。
2026-08-25 实测结果:7 组里 6 组被成功触发、severity 全对,rollback 零残留。
2.2 探测能变红(新增一行,不碰现役)
不要临时改 admin/landing 那两行的 url——「改回原值」被忘掉的风险不为零, 而那两行一旦忘在错误值上,表现正是一直绿但探的是别处。
改为新增一行 ACCEPT-TEST-* 指向 https://<something>.invalid/health (.invalid 是 RFC 2606 保留 TLD,DNS 必然失败,不打任何真实主机), 连跑几轮看 consecutive_failures 递增,验完 delete 该行。
⚠️ 这会真的触发告警:跨过 probe_fail_threshold 后 ops_probe_endpoints 会写 endpoint_down finding 并发 Sentry。删掉 finding 行不会撤回已送达的事件, 需要去 lampio-admin 手动 resolve。这一步顺带就把「告警落点是否正确」验了—— 能在 lampio-admin 里看到它,就证明通道指向对。
2.4 判据类修复的反向验证:成对写单测,明写「旧判据会说什么」
判据(灯该是什么颜色)是纯函数,不需要造生产故障也能反向验证 —— 造一个故障输入喂进去看它变不变色即可。落点在 admin/tests/unit/ops-status-rules.spec.ts,每组两条:
- 正向:故障输入 → 新判据变色;
- 🔴 反向:同一个输入,把旧判据的表达式原样写进断言,钉住它当时会说
ok。
反向那半才是重点。它同时回答两件事:修的确实是一处真缺陷(不是我以为的)、 以及这条修复没被后人改回去(改回去就红)。
配套:拿生产真实行过一遍新判据(只读,一行不写)—— 先确认今天全绿 (否则就是造了个恒红闸门),再在内存里把 last_checked_at 推老 / 把某个 cron 任务从列表里删掉 / 把 sentry_project_id 改成垃圾值,看颜色跟不跟着动。 比事务 + rollback 更安全,验的是同一条判据路径。
⚠️ 纯函数断言有一条盲区:判据算得再对,组件不调它也是白搭。 2026-08-27 加「快照新鲜度」那一格时实测过:把 freshness 从 ops 页的总体判定里摘掉, 上面那一整组纯函数断言照样全绿,而页面当场退回「八格全绿 = 正常」。 所以判据落地时要连接线一起扫 —— ops-status-rules.spec.ts 末尾那组直接读 ops-console.tsx / ops-health-banner.tsx 的源码,断言「总体判定里必须带 freshness」 与「组件里不许自己算分钟数」(红线 9 的接线层),并配一条阳性对照 (文件读不到就会退化成空断言)。
2.3 闸门修复后必做:确认没把它弄钝
修掉一个假红/假绿之后,再注入一次真实缺陷,确认它仍然会红。
2026-08-25 修 CI Rust 行尾问题时就这么做的:golden 转成 CRLF → 通过; CRLF 再叠加一处真实 schema 漂移(抹掉 known_words.deleted_at)→ 仍然 FAILED。 证明修掉的是噪声,不是把闸门弄钝。
3. 已知未修 / 有意接受
不要当成新发现报上来,但每轮仍要独立确认现状(还在?变严重了?条件变了?)。
| # | 事情 | 为什么不修 | 什么条件下重新处理 |
|---|---|---|---|
| K1 | ✅ 已重新启用(2026-08-26):push_empty 加回 run_db_audit(monitoring.sql §5),并补上当初漏掉的另一半 —— scripts/db-audit.sh §5 同款(文件头写着「同源同判据,改一处两处都改」,2026-08-25 那版只改了 SQL 一处) | 判据 c = 0 and exists (select 1 from auth.users),两处逐字一致。双向验证过:三表非空时报 0 条;事务内清空两张表后报出 push_empty:user_reading_pages + push_empty:user_word_page_links,rollback 零残留 | ⚠️ 哪天再跑 reset-dev-data,它会重新变成每天 3 条的噪声。那时该做的是先恢复数据,不是再删一次这支检查 —— 「告警对」与「告警有用」是两回事,这支的启停史就是实例 |
| K2 | orphan_rows 从未被实际触发验证 | FK 挡住注入,非超级用户禁不掉系统触发器 | 有 scratch 库可用时;或改用「恢复演练后的库」——那正是这条检查针对的场景 |
| K3 | launchd 下异地副本清不掉 | iCloud Drive 受 TCC 保护,launchd agent 写得进、列不出。现在会在日志里说明并跳过,不再致命 | 副本累积到占空间时(约 20MB/年);或异地目录挪出 iCloud Drive。不建议给 /bin/bash 授 Full Disk Access |
| K4 | 心跳 detail.host 在 launchd 下是 localhost | hostname -s 在该上下文的行为,不影响告警 | 出现第二台备份机时 |
⚠️ 这张表本身也会腐烂。 每轮验收后更新它——一条「已知未修」若其实已经被修了, 会让下一轮跳过一次本该做的检查,那是最贵的一种过期。
📌 08-25 那轮报出的六处(J1 / J8 / J9 / J15 / J16 / J17 / J18)已于 2026-08-26 全部修掉, 判据变化写在上面对应行里。backlog 的 §运维监控假绿修复 随之关闭,只剩 monitoring.sql §12 seed 模板里那个旧 admin 探测 URL 没动 —— 重跑 seed 前必看。
4. 验收台账
每轮一行。不新建报告文档——产物按 README.md 的规矩拆散。
| 日期 | 基线 | 机械层 | 判断层结论 | 留痕 |
|---|---|---|---|---|
| 2026-08-14 → 08-18 | 0.1.0-dev.10 | 尚无 | 开发中自曝 4 处假绿,全部修复 | CHANGELOG「admin 运维面」 |
| 2026-08-24 | 0.1.0-dev.10 | 尚无 | 3 处假绿 + 3 处正在发生的故障 | 已并入 CHANGELOG |
| 2026-08-25 | 0.1.0-dev.10 | 尚无(本轮后建) | 冷启动独立复验:S9 反向验证 6/7 组通过;新抓 CI Rust 恒红 17 次(已修)+ 3 处假绿 + 4 处文档漂移 | CHANGELOG d5c009d;假绿进 backlog |
| 2026-08-26 | 0.1.0-dev.10 | PASS=23 FAIL=0(首次) | 机械层建成并连跑 5 次稳定 | 本目录 |
| 2026-08-26(补) | 同上 | 同上 | 库里恢复真实数据 → §0 元前提解除,巡检的全绿首次有信息量。K1 的重启条件已满足 | 本目录 |
| 2026-08-26(补二) | 同上 | 23 条 | tombstone_parent 落库并验证:部署后经 PostgREST 实调报出库里那 2 条孤儿 link(warn cnt=2)→ 清理后归零;CREATE OR REPLACE 后 definer / search_path(pg_temp 仍在最后)/ ACL 均未漂移。K1 关闭:push_empty 重新启用并补齐 db-audit.sh 侧 | 本目录 |
| 2026-08-26(补三) | 同上 | PASS=23 FAIL=0 SKIP=0 | 08-25 报出的六处假绿全部修掉(J1/J8/J9/J15/J16/J17/J18)。判据收进 admin/lib/ops-status-rules.ts,卡片 / 总览 / 首页横幅共用一份;反向验证成对写进单测,另以生产真实行只读复核过。修复已随 25542b8 上线,生产实测:hydration 完成、门仍 307(这一条只有真浏览器能验,HTTP 200 抓不到)。M6 顺带独立确认了 cronToleranceHours() 现推的容差与机械层逐个一致 —— ⚠️ 是证据不是守门,两处仍可能各自漂 | CHANGELOG「运维看板判据层」 |
| 2026-08-27 | 0.1.0-dev.10 | PASS=24 FAIL=0 SKIP=0 | 交接单里那三个残留全部做掉(J8/J9/J28/J30)。三处都从「对得上」升级成「漂了会响」:cron 集合与排期的真相源统一为 supabase/sql(M6 派生 + 新增 live↔仓库排期比对,单测核 EXPECTED_CRON_JOBS 与 PROBE_STALE_MINUTES);§12 seed 模板改成被 M3 消费的声明(顺带修掉那行旧别名);ops 页与首页横幅补上快照新鲜度。每条都做了注入验证(改 SQL 副本 / 塞假任务名 / 摘掉 freshness → 对应断言变红)。⚠️ 残留:容差分档在 TS 与 M6 python 各一份(不随排期漂,已注明)。⚠️ 同日晚些时候 M4 转为 SKIP(21/0/1):直连 DB 主机已 IPv6-only 而本机无 IPv6 出口,psql 连不上(03:18 那次还通)。M4 是唯一走 psql 的检查(部署态 definer / search_path / ACL),它一 SKIP 就等于那三条本轮没验。根因与修法见 docs/plans/backlog.md §④(不止影响验证脚本,backup-supabase.sh 也在里面);🔴 更正(另一会话 2026-08-27 复验):原记「经 SOCKS5 127.0.0.1:7897 实测能够到该主机:5432,要么本地 TCP 转发、要么换池化连接串」——前半句是误判,那条路已实测是死的。SOCKS5 CONNECT 确实返回 rep=0,但绑定地址是代理自己的 127.0.0.1:7897;紧接着发一个 PostgreSQL SSLRequest(int32 len=8, int32 code=80877103)过去,一个字节都收不回来,直接 EOF —— 那个 rep=0 是乐观应答,隧道根本没建起来(代理自己多半也没有 IPv6 出口)。⚠️ 这条与 M4 转 SKIP 同源,是同一个教训的第二次现形:「拿到了成功返回码」不等于「真的通了」,要验通不通得让数据真的走一趟。故唯一修法是换池化连接串,别在本地 TCP 转发上耗时间。全部推理见 docs/plans/backlog.md §④ | CHANGELOG「运维看板」三条 |
| 2026-08-27(补) | 0.1.0-dev.10 | PASS=24 FAIL=0 SKIP=0 | M4 从 SKIP 恢复(definer / search_path / ACL 三条本轮已实测通过)。根因是同日那条 IPv6-only 直连主机,已由另一会话把 Keychain 换成 pooler(IPv4 可达)解决 —— 见 docs/plans/backlog.md §④ 与 privacy-auth.md K7。⚠️ 顺带更正上一行的 SOCKS 数据点:那条路已实测是死的(rep=0 是乐观应答,发数据过去直接 EOF)。另:backup-supabase.sh 同日真跑一次成功,首次以非零数据走通 storage 备份路径——此前每次都是「落盘 0 个对象 ✅」,K3 的异地清理在交互 shell 下也正常(launchd 下仍受 TCC 限制,K3 不变) | 本目录 |
📌 前三轮的完整报告已归档在
docs/plans/archive/admin-ops-acceptance-*.md, 只作为历史存档,不再维护。判据的真相源是本文件 +scripts/ops-verify.sh。