## Context DiAL 后端是基于 Bun 的拨测服务,当前有 2 个 checker 类型(http、command),target 规模预计增长到 100,checker 类型预计超过 5 种。 现状问题: 1. `GET /api/targets` 对每个 target 单独查询 `getRecentSamples`,产生 N+3 次 SQL 查询 2. `ProbeEngine.probeGroup` 中 rejected 结果仅 `console.warn`,前端无法感知异常 3. `dev.ts` 和 `scripts/build.ts` 生成的 entry 各自维护相同的启动序列 4. `config-loader.ts` 中 `dataDir` 未基于 `configDir` 解析,相对路径依赖进程 cwd 5. `validatePagination` 无 pageSize 上限,可被滥用 6. `CheckerDefinition` 接口方法参数为 `ResolvedTargetBase`,checker 内部需手动 `as` 断言 ## Goals / Non-Goals **Goals:** - 消除 targets 路由的 N+1 查询,支撑 100 target 规模 - Engine 异常可观测:rejected 结果写入数据库,前端可见 - 启动逻辑单一来源,降低维护成本 - 修复 dataDir 路径解析 bug - API 防御性:pageSize 上限 - CheckerDefinition 泛型化,checker 开发者获得编译期类型安全 **Non-Goals:** - 不做配置热更新 - 不做 API 认证/鉴权 - 不做通知/告警系统 - 不改变 `ResolvedTargetBase` 的 index signature(registry 层仍用类型擦除) - 不改变前端行为 ## Decisions ### Decision 1: 批量查询 recentSamples 使用 window function **选择**:在 `ProbeStore` 中新增 `getAllRecentSamples(limit: number)` 方法,使用 SQLite window function `ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC)` 一次查询所有 target 的最近 N 条采样。 **替代方案**: - UNION ALL 拼接每个 target 的子查询 → SQL 长度随 target 数线性增长,不可控 - 应用层批量(一次查全部再内存分组)→ 数据量大时内存开销高 **理由**:window function 是 SQLite 3.25+ 原生支持的特性,Bun 内置的 SQLite 版本满足要求。单次查询,SQL 固定长度,性能最优。 ### Decision 2: Engine rejected 写入 internal error 记录 **选择**:在 `probeGroup` 中,对 `rejected` 的结果构造一条 `matched: false`、`failure: { kind: "error", phase: "internal", path: "engine", message: reason }` 的 check_result 写入 store。 **替代方案**: - 单独的错误日志表 → 增加 schema 复杂度,前端需要额外查询 - 仅保留 console.warn → 现状,不可观测 **理由**:复用现有 check_results 表和 failure 结构,前端无需改动即可展示异常状态。`phase: "internal"` 区分于正常的 checker 执行失败。通过 `Promise.allSettled` 的索引关联回 target 数组,确保能获取 targetName。 ### Decision 3: 抽取 bootstrap.ts **选择**:新增 `src/server/bootstrap.ts`,导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整启动序列:loadConfig → ProbeStore → syncTargets → ProbeEngine → startServer → 注册 shutdown handler。 **接口设计**: ```typescript interface BootstrapOptions { configPath: string; mode: RuntimeMode; staticAssets?: StaticAssets; } ``` `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })`。 `build.ts` 生成的 entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })`。 **替代方案**: - 保持两处重复 → 维护负担随启动逻辑复杂化线性增长 ### Decision 4: dataDir 基于 configDir 解析 **选择**:在 `config-loader.ts` 的 `loadConfig` 中,对 `dataDir` 使用 `resolve(configDir, dataDir)` 处理。如果 `dataDir` 是绝对路径,`resolve` 会直接返回绝对路径,不影响绝对路径用户。 **影响**:行为变更——之前相对路径基于 cwd,现在基于配置文件目录。由于项目未上线,无需向前兼容。 ### Decision 5: pageSize 上限 200 **选择**:在 `middleware.ts` 的 `validatePagination` 中增加 `pageSize > 200` 的校验,返回 400。 **常量定义**:`MAX_PAGE_SIZE = 200`,定义在 `middleware.ts` 中。 **理由**:200 条/页对于拨测历史记录的展示场景足够。前端当前使用 20,不受影响。 ### Decision 6: CheckerDefinition 泛型化 **选择**: ```typescript interface CheckerDefinition { execute(target: TResolved, ctx: CheckerContext): Promise; resolve(target: RawTargetConfig, context: ResolveContext): TResolved; serialize(target: TResolved): { config: string; target: string }; validate(input: CheckerValidationInput): ConfigValidationIssue[]; readonly configKey: string; readonly schemas: CheckerSchemas; readonly type: string; } ``` - 默认泛型参数 `= ResolvedTargetBase` 保证 registry 等中间层无需指定泛型 - `CheckerRegistry` 内部存储 `CheckerDefinition`(类型擦除) - 各 checker 实现 `implements CheckerDefinition` 等具体类型 - checker 内部 `execute`、`serialize` 方法直接接收具体类型,无需 `as` 断言 **替代方案**: - Discriminated union → 每加 checker 改 union,违背插件化设计 - 维持现状 → 5+ checker 时 `as` 断言散落各处 **影响范围**: - `runner/types.ts`:接口加泛型参数 - `runner/registry.ts`:内部 Map 类型为 `CheckerDefinition`(使用默认参数) - `http/execute.ts`、`command/execute.ts`:`implements CheckerDefinition<具体类型>`,移除方法内的 `as` 断言 - `engine.ts`、`config-loader.ts`、`store.ts`:不变(依赖 base interface) ## Risks / Trade-offs - **window function 兼容性** → Bun 内置 SQLite >= 3.25,已验证支持。如果未来需要外部 SQLite,需确认版本。 - **Engine rejected 写入依赖索引关联** → 通过 `Promise.allSettled` 的索引关联回 target 数组获取 targetName。前提是 `probeGroup` 的 targets 数组与 `Promise.allSettled` 结果数组保持一一对应,当前实现满足此条件。 - **bootstrap.ts 增加一层间接** → 启动流程从 2 处直接代码变为 1 处函数调用。复杂度不增加,只是位置移动。 - **泛型擦除在 registry 层** → `registry.get()` 返回 `CheckerDefinition`(base 类型),engine 调用时仍是 base 类型。这是设计意图:中间层不感知具体 checker 类型。