项目:nas-console | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
前 16 篇文档回答的是「系统是什么、怎么运转、怎么维护」;本篇回答一个更上游的问题——为什么是这个样子。nas-console 的每一处结构性选择,几乎都能在两份材料中找到当时的推理现场:一是仓库内的设计稿 docs/v03-probe-design.md(v0.3 探针改造的设计决议,含「关键决策」「不做的」两节),二是散落在源码注释里的决策者自我陈述(如 probe.ts 第 143 行解释为何禁用连接复用、server.ts 第 374 行解释为何 error 不对非白名单分组下发)。本篇把这两类证据汇总为 12 条决策记录(ADR),每条按统一模板展开:背景(当时面对的问题)、备选(被放弃的路线及其放弃理由)、决策(实际采用并落进代码的方案)、源码证据(可检索的文件与注释)、后果(正反两面,含已被 13 篇登记的债务)。
需要预先说明三条口径。第一,本项目没有正式的 ADR 目录,本篇是「事后考古」而非「事前记录」:所有条目都有源码或设计稿证据支撑,但「当时的完整讨论过程」无法从仓库还原,凡推理成分高于证据成分的地方均以「推断」字样标明。第二,条目按「架构层 → 数据层 → 链路层 → 安全层」排列,编号不代表决策时间顺序——从设计稿与代码痕迹看,ADR-001/002/003/004 属于 v0.3 探针改造同批产生,ADR-009/010 属于 0.3.1 门户化阶段。第三,重复内容一律以「见《0X-xxx》」引用前序篇目,本篇只补「为什么」,不重复「是什么」。
背景。v0.2 时代的健康检查完全跑在浏览器里(no-cors fetch + localStorage 逐日积累),它有三个结构性缺陷:页面关了巡检就停,没有 7×24 数据;拿不到 TCP 端口、自签证书、TLS 到期日这类浏览器沙箱外的信息;每个浏览器各自探测,历史互不相通。设计稿第 1 节原文:「v0.2 的健康检查跑在浏览器里,只在页面打开时工作——没有 7×24 巡检、没有连续历史、没有告警」。
备选。可选路线有三:其一,前端定时器常驻(Service Worker 后台同步),受浏览器生命周期约束,不可能 7×24;其二,引入第三方拨测服务(UptimeRobot 等),数据在别人手里、自定义判定规则受限,且与「自托管一切」的项目气质相悖;其三,自建常驻探针进程。设计稿选择了第三条,同时在第 7 节把前端改造点压到最小(「逻辑已隔离,改动集中在此 hook」)。
决策。自建探针进程接管巡检/历史/告警/SSE 四件事;浏览器探测路径不删除,保留为降级模式——探针不可达时回退 v0.2 行为(设计稿第 2 节原文:「浏览器端探测保留为降级路径……照顾 probe 挂掉的情况」)。
源码证据。docs/v03-probe-design.md 第 1、2、7 节;src/api/health.ts(降级探测完整保留);src/api/use-health.ts 第 22-27 行头注释明写「browser:探针不可达时的降级,浏览器端 no-cors 探测 + localStorage 累积(v0.2 路径)」。
后果。正面:探针宕机时页面不白屏、不报错,公开页与管理区照常可用(浏览器模式),代价只是数据口径变弱(no-cors 拿不到状态码与证书);负面:同一个功能永远存在两条代码路径,任何健康相关的改动都要问一遍「降级模式下表现如何」,09 篇 P0 测试清单里的多数用例因此需要双模式覆盖。这条「演进不拆迁」的决策是理解全项目「为什么有两套做同样事情的路径」的钥匙(00 篇第 6 节)。
背景。v0.2 的部署形态是两个容器:前端静态资源由 nas-console-web(nginx:1.27-alpine 挂 dist)提供,API 数据要么浏览器直出要么另起服务。v0.3 加入探针进程时,部署面面临选择:nginx 容器保留、探针另起一容器双端口?还是合并?
备选。保留 nginx 容器的方案意味着:两个容器两个端口,frpc 要转发两条隧道规则,浏览器跨源要配 CORS,部署机要维护两份 compose 与两套生命周期——设计稿称这个方案的代价为「二次反代」。合并方案的代价是探针进程要自己实现静态文件服务(MIME 表、路径安全、缓存头、SPA fallback),代码量增加约 60 行。
决策。替代而非并存:Node 进程直接 serveStatic(dist),单容器单端口 5950,nginx 容器退役。设计稿第 2 节标题即「部署形态(关键决策)」,理由原文:「同源免 CORS、免二次反代,部署面最小」。
源码证据。docs/v03-probe-design.md 第 2 节;server/src/server.ts 第 443-461 行 serveStatic(normalize + 前缀校验 + SPA fallback + assets immutable 缓存);compose.yml 第 13 行注释「与旧 nas-console-web 的 5950:80 端口对齐,frpc/反代无需改动」——连端口号都是为了让旧链路零改动而刻意对齐的。
后果。正面:生产链路收敛为「浏览器 → VPS nginx → frps → frpc → 一个容器」,同源使 SSE/上传/Cookie 全部免配置,前端构建产物与 API 永远同版本(这也是接口无版本前缀的前提,见 03 篇第 1.5 节);负面:静态托管职责进了探针进程,dist 版本错误、目录挂错这类「前端问题」会以容器故障的形态出现,且 STATIC_DIR 默认值相对编译产物位置而非 cwd(index.ts 第 6 行),是 14 篇登记过的高频踩坑点。
背景。探针需要 REST API 与 SSE 两类服务端能力,Node 生态的默认答案是 Express/Fastify/Koa 之一。
备选。Express 生态最成熟但历史包袱重;Fastify 性能好但插件体系对一个十端点的项目是过剩设计;Koa 介于两者之间。三者的共同成本是:一个依赖树、一套中间件心智模型、以及「框架抽象与手写代码之间的翻译层」。而本项目全部端点集中在 server.ts 一个文件的 handleApi 函数里,19 个分支用「方法 + 路径」精确匹配即可穷举——框架的核心价值(中间件编排、路由树、插件生态)在这里一项都用不上。
决策。不引入任何 Web 框架,路由/JSON 解析/文件上传/MIME 表/静态托管/SSE 全部手写,运行时依赖压缩到 better-sqlite3 与 dayjs 两个(server/package.json)。00 篇第 3 节概括项目取向为「能不引依赖就不引」,本条是最典型的执行。
源码证据。server/src/server.ts 第 110-306 行 handleApi 的 if 链;第 401-441 行手写 readRaw/readJson(含体积上限与 req.destroy());server/package.json dependencies 恰好两项。01 篇决策四对此已有概述,本条补充设计稿维度的证据:docs/v03-probe-design.md 通篇未出现任何框架选型讨论,探针从设计之初就被定位为「极小的常驻探针」。
后果。正面:依赖树极浅(npm ci 只装 better-sqlite3 原生二进制与 dayjs),无供应链审计面、无破坏性升级风险,Dockerfile.nas 的 deps 阶段因此可以只 COPY 两个 package 文件;错误处理用一个 try/catch 等效于 Express 的错误中间件。负面:框架免费提供的功能要自己补齐且容易漏——体积上限、路径穿越校验、错误兜底都是手写的,任何一处疏忽都是安全面(08 篇第 7 节列出的输入防御清单正是这些手写件);此外新端点没有约定可循,扩张全靠自律(11 篇总则第 2 条以「分层不越级」补位)。
背景。探针需要持久化三类数据:原始检查记录(支撑延迟走势)、按天聚合(支撑可用率条)、翻转事件(支撑告警与事件列表)。写入速率约每秒 0.6 行,读取是「每访客每 30 秒一次快照」的低频查询。
备选。外部时序库(Prometheus/InfluxDB)被设计稿第 9 节显式排除(「SQLite 足够」);浏览器 localStorage 作为 v0.2 的存储不可靠且不共享;Node 内置的 node:sqlite 在 Node 22 时代仍是实验性;若坚持零依赖也可用 JSON 文件追加写,但按天聚合会退化成全文件重写。驱动层面的选择:better-sqlite3(同步 API)与 node-sqlite3(异步)之间,前者的同步调用在单进程低并发下反而省去异步编排,且预编译二进制对 bookworm(glibc) 的覆盖良好。
决策。better-sqlite3 + WAL 模式 + 三表(checks/daily/events)+ 构造函数内 CREATE TABLE IF NOT EXISTS 充当迁移脚本。store.ts 头注释原文:「WAL 模式 + 同步 better-sqlite3 API,单实例个人规模绰绰有余」;设计稿第 4 节标题即「存储(better-sqlite3,同步 API 足够)」。
源码证据。server/src/store.ts 第 18-48 行(建库、pragma、建表);Dockerfile 第 16 行注释「bookworm(glibc)保证 better-sqlite3 命中预编译二进制;alpine(musl) 需要现场编译」——容器基础镜像的选择反过来由这个依赖决定,可见其在技术栈中的权重。
后果。正面:一个 probe.db 文件即全部状态,备份/迁移/排障都是文件级操作;WAL 让持续写入不阻塞读请求。负面有三:其一,原生二进制与平台强绑定,ARM NAS 上必须在本机构建(14 篇第 3.3 节),也排除了 alpine 镜像;其二,无 ORM 无迁移机制,表结构演进靠手工(好在个人规模表结构稳定);其三,设计稿承诺的「events 100 条/服务」保留策略未实现,成为全项目唯一无界增长点(13 篇问题二,⚠️ 与设计稿不一致已登记)。
背景。探针结果需要近实时地出现在浏览器上。候选是 WebSocket 与 SSE。
备选。WebSocket 提供全双工,但本系统浏览器对探针没有任何上行需求(写操作走 REST),全双工是花钱买闲置;更关键的是生产链路要穿过 VPS nginx 与 frp 两层代理,WebSocket 的协议升级(Upgrade 头透传)在这两层都是配置负担与故障点。SSE 建立在普通 HTTP 之上,与「探针自带静态托管、同源部署」的形态天然契合,且 EventSource 原生自带断线重连。
决策。SSE + 三层递进容错:第一层 status 单播(每次检查完成即广播,秒级实时);第二层 batch 全量快照(服务端固定 30 秒一拍,防客户端丢帧的最终一致兜底);第三层 latest 轮询(前端默认 60 秒,SSE 完全失效时的降级通道)。另配 25 秒注释行心跳(: ping)与 x-accel-buffering: no 响应头对抗中间层的空闲断连与攒包。
源码证据。server/src/sse.ts 全文(53 行,含心跳实现);server/src/server.ts 第 64-66 行 batchTimer;src/api/use-health.ts 第 77-91 行注释「SSE 实时推送:单结果即时合并,batch 兜底全量;断线由 EventSource 自动重连」「latest 轮询保留为 SSE 失效时的兜底」;设计稿第 5 节最初只规划了 status/batch 两类事件,event(翻转)是实施期补充——设计稿与实现的这处差异本身就是「容错层在实践中加厚」的痕迹。
后果。正面:任何一层静默失效系统照常正确,「实时性」成为尽力而为的增强属性而非正确性前提(01 篇第 5.1 节的原文表述);总带宽开销在个位数客户端下不足每秒 1KB(10 篇第 2 节算过账)。负面:30s/25s 两个关键参数硬编码在 server.ts/sse.ts,高抖动链路无法调优(13 篇路线图 v1.3 已列参数化项);event 事件至今前端未消费(事件列表靠 5 分钟历史导入对齐),SSE 协议面与消费面出现了轻微不对称(11 篇 SOP 六有讨论)。
背景。ADR-001 决定保留浏览器降级路径后,一个次生问题浮现:两条路径各自产生的历史数据(按天桶、近期采样、事件)形状若不一致,切换数据源时 UI 层就要维护两套渲染逻辑。
备选。方案一是「两套历史、两套 UI」,组件层按 source 分支——组件数量翻倍,且可用率条/延迟走势这类展示件无法复用;方案二是「统一历史形状、服务端快照整体替换本地」,组件层只认一种结构;方案三是彻底砍掉浏览器历史(降级模式只显示当前状态不留史)——但 v0.2 用户已在 localStorage 积累了历史,砍掉即丢数据。
决策。方案二:浏览器侧 HealthHistoryState(days/recent/events/lastStatus 四个 Record)与服务端 dailyBuckets/recentSamples 的返回结构刻意保持同形,探针模式每 5 分钟调 applyServerSnapshot 整体替换 days/recent、事件按键去重合并、lastStatus 保留本地。applyServerSnapshot 头注释原文:「服务端是 7×24 巡检的权威数据源;探针下线后浏览器巡检会在其基础上继续累积」。
源码证据。src/api/health-history.ts 第 158-182 行;server/src/store.ts 第 92 行 dailyBuckets 注释「形如前端 health-history 的 days 状态」——存储层查询的返回形状直接以前端结构为目标设计,是「前端镜像类型」约束反向传导到 SQL 层的证据;src/api/use-health.ts 第 14 行 HealthSource 三态与第 37 行注释「探针可达性只探测一次(页面加载时),运行中不自动来回切换」。
后果。正面:UptimeBars/抽屉/StatTile 等展示组件对数据来源完全无感,两种模式无缝切换(探针恢复后 5 分钟内本地历史被服务端数据回灌);「模式锁定不自动切换」避免了探针抖动导致数据源反复横跳。负面:模式在挂载时锁定意味着「先开页面再启探针」必须刷新页面才进入探针模式(12 篇 Q1 的高频困惑来源);且「运行期不切换」的代价是探针中途下线后页面停留在 probe 模式显示陈旧数据,源码注释预告「M2 再做自动降级」,该 TODO 至今未兑现(⚠️ 待确认是否仍排期)。
背景。v0.2 的服务清单散在「前端 localStorage 自定义项 + src/config/services.ts 构建期常量」两处,换浏览器就丢配置。v0.3 要把清单迁到探针侧,但内置清单本身是代码的一部分(图标、分组、tags 都随构建演进),完全服务端化又会让「改一行清单」变成「登 NAS 改配置文件」。
备选。完全服务端(内置清单也进 config.json):重建环境时清单无来源,README 不得不维护一份「标准清单文档」并声称会漂移;完全前端(探针只存 check 配置):公开快照、清单 API、探测调度都要跨端拼数据,「权威副本」名存实亡。
决策。双层模型:src/config/services.ts 的 19 项内置清单仍是编译期基线(「服务清单(v1 为 git 管理的静态配置,随构建进 dist)」,services.ts 头注释),探针 config.json 的 services 段是运行时权威副本;管理区布局挂载后每 60 秒把「内置 + 自定义 − 隐藏标记」全量 PUT 给探针,服务端以 upsert 语义合并——已存在 id 保留探针侧手工 check 配置(config.ts 第 78 行 { ...s, check: prev.check, profile }),新 id 推导默认 check,prune 固定 false(隐藏≠删除)。
源码证据。设计稿第 2 节「服务清单的权威归属」;server/src/config.ts 第 34-39 行注释「已存在的 id:刷新元数据字段,保留已有 check 配置(保护手工加的 tcp/关键词/tlsSkipVerify)」与「prune=true 时删除清单里没有的 id(M1 前端隐藏不删,暂不启用)」;src/api/probe.ts 第 100-105 行的客户端侧对称注释。
后果。正面:重建环境时「前端一次访问即完成清单回填」(README 部署节),手工调优的探测参数在前端 60 秒一次的同步轰炸下安然无恙;隐藏只是标记、配置永不丢失。负面有三:元数据(name/description/url/tags)以传入为准,手工编辑这些字段会被前端同步覆盖回去(12 篇 Q4);手工 check 只挂在已存在 id 上才受保护,config.json 丢失重建后调优需重新应用(README 明示);tags 变更需站长访问一次 /console 才下发到公开快照(README 的运维习惯项)。这三条都是「双层权威」模型的固有摩擦,详见 04 篇第 1.4 节的段所有权论述。
背景。项目要接两套外部系统:Umami(访问统计)与 1Panel(主机指标与容器操作)。两者都需要凭据,都有数据要展示,但集成方向完全相反。
备选。统一走服务端代理(所有外部凭据收归探针):Umami 的凭据也被探针持有,探针进程对 Umami 产生运行时依赖,而 Umami 数据只在页面打开时被消费,让 7×24 进程为低频需求背凭据是错配;统一走浏览器直连:1Panel 的 apiKey 等价于容器控制权,下发浏览器等于把 NAS 管理权暴露给任何能开控制台的人,不可接受。
决策。按两个问题定分界(03 篇第 8 节的两问):凭据能否下发给浏览器(安全面)、数据是否需要 7×24 采集(在场性)。Umami:凭据是低敏感的统计只读账号、数据仅页面打开时消费 → 浏览器直连,凭据存 localStorage、token 存 sessionStorage,探针进程对 Umami 完全无感知(umami.ts 头注释「Umami 对 /api/* 开放 CORS」是直连可行的前提)。1Panel:apiKey 高敏感、容器操作是运维动作需要服务端在场 → 严格服务端代理,apiKey 只存 config.json,签名 md5("1panel" + apiKey + ts) 在服务端完成,前端只见 /api/host/* 代理端点。
源码证据。src/api/umami.ts 头注释与全文(190 行,浏览器闭环);server/src/1panel.ts 第 66-71 行 sign 函数与头注释(签名算法、域名绑定 Host 头、面板侧 IP 白名单三重防线);ConsoleSettingsContext.tsx 的凭据存储与 Settings 页「仅存浏览器 localStorage,不上传」文案。
后果。正面:两条链路的故障域彻底隔离——Umami 挂了只影响分析页,1Panel 挂了只影响指标卡与容器操作,互不传染;凭据各自待在泄露后果最小的位置(08 篇第 4 节的对比表)。负面:Umami 凭据留在了浏览器 localStorage,借用他人电脑有残留风险(设置页文案是唯一防线,无「清除凭据」按钮,08 篇已登记);PanelClient 仅支持 http 面板(node:http 实现),面板改 https 即失效(13 篇观察项)。第三套外部系统接入时照抄这条判据即可(11 篇 SOP 四已固化为流程)。
背景。0.3.1 门户化后,管理区需要登录鉴权。单用户系统,会话需求简单:签发、校验、过期,外加防篡改。
备选。JWT 库(jsonwebtoken):带来依赖与 Header/算法协商面,而 alg:none 类历史漏洞恰恰来自「算法可协商」;Express-session 类中间件:依赖服务端会话存储或内存表,且绑定框架(本项目无框架);basic auth 全权代理给 VPS nginx:无法做登录页与失败锁定体验,且 30 天免登 impossible。
决策。零依赖自研:node:crypto 的 HMAC-SHA256,Cookie 形如 <base64url({u,exp})>.<HMAC-SHA256(payload, secret)>,算法硬编码(没有 Header 段,从根上免疫 alg:none),签名密钥 32 字节随机 hex 首启生成落盘 data/session-secret(0600),口令存储 salt$HMAC-SHA256 且比较走 timingSafeEqual。README 0.3.1 节的表述是「无需额外依赖」。
源码证据。server/src/auth.ts 全文(160 行零外部依赖);第 66-67 行 createHmac('sha256', this.secret).update(payloadB64)——对 payload 原文(而非解码后的 JSON)签名,校验侧同样处理,避免了序列化往返不一致的经典错误;server/scripts/hash-password.mjs 直接 import 编译产物 dist/auth.js 的哈希函数,杜绝脚本与校验算法漂移(04 篇第 6 节的同源复用机制)。
后果。正面:无状态校验不查库,验证路径只做签名与过期两项比对;依赖面为零。负面均已登记(08 篇第 2.2/2.3 节):校验签名而非凭据,改密码不使旧会话失效(强制登出要换 session-secret);Cookie 未设 Secure 属性;限流键取 remoteAddress 在 frp/nginx 链路后失真为全局共享。这三条分别进入 13 篇路线图的 v1.1 与观察项。
背景。/api/public/status 对任何匿名访客可用,但清单里既有公网项目(应展示)也有内网面板与数据库(绝不能泄露地址)。脱敏粒度必须比「接口级」细。
备选。接口级隔离(公开接口只返回白名单分组):非白名单分组在状态页上连名字与状态都看不到,而「面板与运维」分组的在线状态本身对访客是有信息价值的(个人主页的「一切正常」观感);服务级开关(每个服务加 public 布尔):配置面爆炸,且容易漏配。
决策。分组级白名单 + 字段级展开:所有分组都出现在快照里(名字/状态/延迟/45 天可用率),但只有 portal.publicGroups 白名单分组(字符串精确匹配)额外展开 url/tags/error/profile 四个字段;error 对非白名单分组也不下发,注释原文「error 里可能含 host:port(ECONNREFUSED 等)」(server.ts 第 374 行);配套地,/api/public/metrics 关闭时返回 404 not found 而非 403——不向访客暴露「这个端点存在但被关闭」的信息(第 196-198 行)。
源码证据。server/src/server.ts 第 341-381 行 publicStatus(条件展开运算符 ...(publicGroups.includes(s.group) ? { url, tags, error, profile } : {}));src/pages/Home.tsx 第 60 行以 s.url 的存在性判定能否成项目卡——字段的存在性即权限,前端不重复实现白名单逻辑。
后果。正面:泄露面被压到「白名单分组自身内容」这一个变量上,前端无需感知脱敏规则,权限判定点只有一处。负面:白名单是分组级而非服务级开关,误把内网服务挪进白名单分组会连地址带错误文案一并公开(08 篇第 3.2 节的三处残余面);profile.story/timeline 是自由文本,sanitizeProfile 只做类型净化不做内容审查——「白名单分组里只放公网服务、档案里不写内网细节」成为运维纪律而非代码保证(12 篇 Q5、06 篇第 6.1 节检查清单均有对应条目)。
背景。巡检引擎每 30 秒对 19 个服务发起出站请求,超时、DNS 失败、拒连、TLS 错误、响应中断都是常态而非异常。若检查函数以 reject 报告失败,引擎要么每处调用都 try/catch,要么承担未处理 Promise rejection 打崩进程的风险(Node 默认行为是告警甚至崩溃)。
备选。engine 侧统一 try/catch:check() 调用点确实包了一层(probe.ts 第 92-94 行),但那只是兜底而非主路径——若检查器自身 reject,错误信息会丢失「发生在哪一步」的语义;Promise.race 超时包装:会留下未被清理的悬挂请求(超时后请求体仍可能在路上)。
决策。checkHttp/checkTcp 是 resolve-only 的 Promise:所有异常路径(timeout/error/end 中断)都 resolve 成 status:'down' + 人类可读 error 文案;超时处理在请求/socket 上直接挂 timeout 事件并 destroy(),不留悬挂连接。设计稿第 3 节的「判定防抖」只覆盖告警侧,resolve-only 是实施期在引擎侧补充的可靠性设计。
源码证据。server/src/probe.ts 第 131、199 行两处 new Promise((resolve) => {...})——参数列表里根本没有 reject;第 182-188 行 timeout/error 分支全部 resolve({ id, status: 'down', ... });09 篇第 2.4 节把这条列为「把不变量写进代码形状」的首例。
后果。正面:checkAndRecord 的 await this.check(service) 永远拿得到结果,引擎主循环没有任何 rejection 路径,日志里「未处理 Promise rejection」理论上是零(12 篇日志清单的「负面清单」因此成立——正常运转时探针几乎不输出);错误文案统一为面向用户的中文字符串,直接进 Tooltip 与告警正文。负面:错误被「吞」进结果对象意味着检查器内部的意外 bug(如代码错误抛 TypeError)也会伪装成「down + error 文案」,需要靠 error 文案的非常规内容来人工识别——09 篇建议的 P1 集成测试(用异常 URL 验证文案归属)正是针对这个盲区。
背景。config.json 是全系统唯一「多方共同读写」的文件:前端每 60 秒 PUT 一次 services 段,站长手工编辑 alerts/auth/portal/onePanel 段,进程启动时整体读入。两个问题必须同时回答:写一半崩溃怎么办?哪些段允许运行期变更?
备选。直接 writeFileSync 覆盖:进程在写入中途被杀(NAS 断电、docker stop)会留下半个 JSON,下次启动 JSON.parse 抛错——且 services 非数组时 loadConfig 会拒绝启动,等于一次中断就让容器进入重启循环;整文件锁:单进程无并发写者(PUT 是唯一写入口),锁是过度设计;全部段热更新:alerts/auth/onePanel 的消费方(AlertManager/Auth/PanelClient)都是构造时注入 config 引用的实例,热更新需要为每个实例建失效机制,复杂度与收益不成比例。
决策。两半:物理写入一律走 saveConfig 的「写 .tmp 再 renameSync」原子替换(注释「原子替换,避免写一半损坏」);段所有权上只有 services 段可被 PUT 热更新(落盘后 engine.apply 立即重排定时器),alerts/auth/portal/onePanel 只在启动时读入,修改后需 docker restart——syncServices 返回 { ...config, services: merged },其余段以展开运算符原样保留(config.ts 第 88 行)。
源码证据。server/src/config.ts 第 16-21 行(saveConfig)与第 72-89 行(syncServices);server/src/types.ts 第 52-58 行三个段的注释均标明「config.json 手工维护,PUT /api/services 不触碰此段」;docker-compose.nas.yml 第 24 行注释「挂目录而非单文件:config.json 的原子写(rename)在单文件挂载上会 EBUSY」——部署层的挂载方式选择是被这条决策反向决定的(rename 语义在单文件 bind mount 上不成立)。
后果。正面:进程崩溃不会留下坏配置;「改什么、怎么生效」有一条可预测的分界线(14 篇第 8 节速查表整表建立在这条分界上);手工段永远不会被前端同步意外覆盖。负面:rename 原子性依赖同目录 rename 语义,在 NAS 单文件挂载场景失效(已通过目录挂载规避,但 compose.yml 旧路径仍保留单文件挂载,06 篇标注为新部署不建议);「重启生效」的段没有任何 UI 提示,站长改完不重启会误以为「改了没用」(12 篇 Q6/Q7 的 FAQ 占比侧面印证了这个认知成本)。
| # | 决策 | 一句话依据 | 详细展开 |
|---|---|---|---|
| 001 | 探针接管巡检,浏览器探测降级保留 | 7×24 是浏览器给不了的 | 设计稿第 1-2、7 节 |
| 002 | 单容器单端口,探针吞并静态托管 | 部署面最小、同源免 CORS | 设计稿第 2 节 |
| 003 | 零 Web 框架 | 19 个端点用不上框架生态 | server/package.json |
| 004 | better-sqlite3 + WAL + 三表 | 同步 API 对 0.6 行/秒绰绰有余 | store.ts 头注释 |
| 005 | SSE + 三层容错 | 不信任长连接,实时性只是增强属性 | sse.ts / server.ts batchTimer |
| 006 | 双模式健康数据,历史结构同形 | 切换数据源时 UI 零改动 | health-history.ts / store.ts |
| 007 | 清单双层权威(基线 + 运行时副本) | 代码即基线,服务端为运行时权威 | config.ts syncServices |
| 008 | Umami 直连 vs 1Panel 代理 | 凭据能否下发 + 是否需要 7×24 在场 | umami.ts / 1panel.ts 头注释 |
| 009 | 自研 HMAC 会话 | JWT 形态减去协商面,零依赖 | auth.ts 全文 |
| 010 | 公开面字段级脱敏 | error 里可能含 host:port | server.ts publicStatus |
| 011 | 检查器 resolve-only | 异常是常态,不该以异常机制传导 | probe.ts 两处 Promise |
| 012 | 原子写 + 段所有权分界 | 崩溃不留坏配置,热更新只限 services | config.ts / compose.nas 注释 |
把 12 条并排看,有两条元原则贯穿始终,值得作为接手者做新决策时的校准基准。第一条是「按量级取物」:每一个「不引入 X」的决策(框架、ORM、时序库、JWT 库、状态库)背后都是同一道算术——服务数 19、用户 1、写入 0.6 行/秒、SSE 客户个位数,任何为十倍以上量级准备的东西都被判为过度设计;反过来,为这个量级必须准备的东西(原子写、体积上限、防抖、路径校验)一个都没有省。第二条是「演进不拆迁」:v0.2 的浏览器探测、旧端口 5950、Mantine 时代的兼容层,都以「降级路径」「端口对齐」「薄桥」的形态活了下来,而不是被删除——这让系统每一步演进都可回退,但也累积了双路径维护成本(13 篇 react-ui 遗留项是这条原则的唯一一次失手:该拆的没拆)。新决策落入这两条原则的,通常与项目气质相符;偏离的,则应在 ADR 里明确写出偏离的理由。
各决策的运行期行为细节见 05-核心流程(时序)与 07-模块详解(函数级);决策的负面后果中已登记的债务见 13-技术债务与演进建议;安全类决策(009/010)的威胁模型语境见 08-安全与权限设计。