From a62007083d9947cc54b30e321361ce13f21970fd Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 13 May 2026 19:50:33 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=BD=92=E6=A1=A3=20backend-architect?= =?UTF-8?q?ure-hardening=20=E5=8F=98=E6=9B=B4=E5=B9=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=20delta=20spec=20=E5=88=B0=E4=B8=BB=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.openspec.yaml | 2 - .../backend-architecture-hardening/design.md | 121 ------------------ .../proposal.md | 34 ----- .../specs/batch-data-queries/spec.md | 44 ------- .../specs/checker-runner-abstraction/spec.md | 47 ------- .../specs/probe-api/spec.md | 32 ----- .../specs/probe-config/spec.md | 16 --- .../specs/probe-engine/spec.md | 24 ---- .../backend-architecture-hardening/tasks.md | 42 ------ openspec/specs/batch-data-queries/spec.md | 27 +++- .../specs/checker-runner-abstraction/spec.md | 22 ++-- openspec/specs/probe-api/spec.md | 10 +- openspec/specs/probe-config/spec.md | 15 +++ openspec/specs/probe-engine/spec.md | 12 +- .../specs/server-bootstrap/spec.md | 6 +- 15 files changed, 75 insertions(+), 379 deletions(-) delete mode 100644 openspec/changes/backend-architecture-hardening/.openspec.yaml delete mode 100644 openspec/changes/backend-architecture-hardening/design.md delete mode 100644 openspec/changes/backend-architecture-hardening/proposal.md delete mode 100644 openspec/changes/backend-architecture-hardening/specs/batch-data-queries/spec.md delete mode 100644 openspec/changes/backend-architecture-hardening/specs/checker-runner-abstraction/spec.md delete mode 100644 openspec/changes/backend-architecture-hardening/specs/probe-api/spec.md delete mode 100644 openspec/changes/backend-architecture-hardening/specs/probe-config/spec.md delete mode 100644 openspec/changes/backend-architecture-hardening/specs/probe-engine/spec.md delete mode 100644 openspec/changes/backend-architecture-hardening/tasks.md rename openspec/{changes/backend-architecture-hardening => }/specs/server-bootstrap/spec.md (93%) diff --git a/openspec/changes/backend-architecture-hardening/.openspec.yaml b/openspec/changes/backend-architecture-hardening/.openspec.yaml deleted file mode 100644 index 93831bd..0000000 --- a/openspec/changes/backend-architecture-hardening/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-13 diff --git a/openspec/changes/backend-architecture-hardening/design.md b/openspec/changes/backend-architecture-hardening/design.md deleted file mode 100644 index 0e2a976..0000000 --- a/openspec/changes/backend-architecture-hardening/design.md +++ /dev/null @@ -1,121 +0,0 @@ -## 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 类型。 diff --git a/openspec/changes/backend-architecture-hardening/proposal.md b/openspec/changes/backend-architecture-hardening/proposal.md deleted file mode 100644 index 25851e9..0000000 --- a/openspec/changes/backend-architecture-hardening/proposal.md +++ /dev/null @@ -1,34 +0,0 @@ -## Why - -后端在 target 规模增长(预计到 100)和 checker 类型扩展(预计超过 5 种)的趋势下,存在查询性能瓶颈、可观测性盲区、启动逻辑重复、路径解析 bug 和类型安全不足等问题。本次变更集中修复这些架构短板,为后续扩展打好基础。 - -## What Changes - -- **targets 路由 N+1 查询优化**:`handleTargets` 中对每个 target 单独调用 `getRecentSamples` 改为批量查询,消除 N 次独立 SQL -- **Engine rejected 结果持久化**:`probeGroup` 中 `Promise.allSettled` 的 rejected 结果写入 `matched: false` 的 check_result(failure 标记为 internal error),替代仅 `console.warn` -- **启动逻辑统一**:抽取 `bootstrap.ts`,`dev.ts` 和 build 生成的 entry 共用同一启动序列,消除重复 -- **dataDir 相对路径修复**:`config-loader.ts` 中用 `resolve(configDir, dataDir)` 处理相对路径,确保从任意 cwd 启动时数据库位置一致 -- **validatePagination 加 pageSize 上限**:限制最大 pageSize 为 200,超出返回 400 -- **CheckerDefinition 泛型化**:为 `CheckerDefinition` 加泛型参数 ``,checker 内部获得完整类型安全,registry 用类型擦除保持解耦 -- **availability 精度统一**:`getAllTargetStats` 和 `getTargetStats` 的 availability 计算精度不一致,统一为相同的四舍五入策略 - -## Capabilities - -### New Capabilities - -- `server-bootstrap`: 统一的服务启动引导流程,dev 和 production 共用 - -### Modified Capabilities - -- `batch-data-queries`: 新增 `getAllRecentSamples` 批量采样查询,消除 targets 路由的 N+1 问题;修复 availability 精度不一致 -- `probe-engine`: Engine 对 rejected 结果写入 matched:false 记录而非静默丢弃 -- `probe-config`: dataDir 相对路径基于 configDir 解析 -- `probe-api`: validatePagination 增加 pageSize 上限校验 -- `checker-runner-abstraction`: CheckerDefinition 接口泛型化,checker 内部类型安全 - -## Impact - -- **代码**:`src/server/` 下约 8 个文件变更,新增 `bootstrap.ts` 和 `store.ts` 的批量查询方法;另修复 `src/web/components/ErrorBoundary.tsx` 的 `override` 标记(typecheck 前置修复) -- **API**:pageSize 超过 200 时返回 400(新增约束,当前前端未使用超大 pageSize) -- **构建**:`scripts/build.ts` 生成的 entry 改为调用 bootstrap -- **测试**:需新增/更新 engine、store、middleware、bootstrap 相关测试 diff --git a/openspec/changes/backend-architecture-hardening/specs/batch-data-queries/spec.md b/openspec/changes/backend-architecture-hardening/specs/batch-data-queries/spec.md deleted file mode 100644 index 2d6fec1..0000000 --- a/openspec/changes/backend-architecture-hardening/specs/batch-data-queries/spec.md +++ /dev/null @@ -1,44 +0,0 @@ -## ADDED Requirements - -### Requirement: 批量查询所有目标的最近采样数据 -系统 SHALL 提供 `getAllRecentSamples(limit: number)` 方法,通过单次 SQL 查询获取所有 target 的最近 N 条采样数据,返回 `Map>` 结构。 - -#### Scenario: 获取所有目标的最近采样 -- **WHEN** 调用 `getAllRecentSamples(30)` -- **THEN** 系统 SHALL 通过单次 SQL 查询获取每个 target 最近 30 条记录,返回按 target_id 索引的 Map - -#### Scenario: 目标无历史记录 -- **WHEN** 某 target 在 check_results 表中无任何记录 -- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key - -#### Scenario: 采样数据排序 -- **WHEN** 获取采样数据 -- **THEN** 每个 target 的记录 SHALL 按 timestamp 降序排列(最新在前) - -## MODIFIED Requirements - -### Requirement: targets 列表使用批量方法 -`handleTargets`(routes/targets.ts 中生成 TargetStatus[] 的逻辑)SHALL 使用 `getLatestChecksMap`、`getAllTargetStats` 和 `getAllRecentSamples` 替代逐目标查询,消除 N+1 查询。 - -#### Scenario: 目标列表使用批量查询 -- **WHEN** 处理 `GET /api/targets` 请求 -- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()`、`getAllTargetStats()`、`getAllRecentSamples(30)` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库 - -#### Scenario: 目标无采样数据 -- **WHEN** 某 target 在 getAllRecentSamples 返回的 Map 中不存在 -- **THEN** 该 target 的 recentSamples SHALL 为空数组 - -### Requirement: 批量查询目标统计 -系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。availability 计算精度 SHALL 与 `getTargetStats` 一致,统一使用 `Math.round(value * 100) / 100` 保留两位小数。 - -#### Scenario: 获取所有目标的聚合统计 -- **WHEN** 调用 `getAllTargetStats()` -- **THEN** 系统 SHALL 执行单次 GROUP BY 聚合查询,在内存中计算 availability 并返回 `Map` - -#### Scenario: availability 精度 -- **WHEN** 计算 availability(upCount / totalChecks * 100) -- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致 - -#### Scenario: 目标无历史记录 -- **WHEN** 某 target 在 check_results 表中无任何记录 -- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key diff --git a/openspec/changes/backend-architecture-hardening/specs/checker-runner-abstraction/spec.md b/openspec/changes/backend-architecture-hardening/specs/checker-runner-abstraction/spec.md deleted file mode 100644 index d49717e..0000000 --- a/openspec/changes/backend-architecture-hardening/specs/checker-runner-abstraction/spec.md +++ /dev/null @@ -1,47 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Checker 接口定义 -系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。 - -#### Scenario: Checker 接口包含必要方法 -- **WHEN** 开发者实现一个新的 Checker -- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、TypeBox 配置契约、启动期语义校验、`resolve(target, context): TResolved`(解析配置并填充默认值)、`execute(target: TResolved, ctx)`(执行探测返回 CheckResult)和 `serialize(target: TResolved)`(返回 target 展示文本和 config JSON) - -#### Scenario: CheckerContext 注入 signal -- **WHEN** 引擎调用 `checker.execute(target, ctx)` -- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort - -#### Scenario: resolve 不承担通用契约校验 -- **WHEN** config-loader 调用 checker.resolve() -- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换 - -#### Scenario: 接口方法使用泛型约束 -- **WHEN** 开发者查看 `CheckerDefinition` 接口签名 -- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved` - -#### Scenario: checker 实现无需手动断言 -- **WHEN** HttpChecker 实现 `CheckerDefinition` -- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言 - -#### Scenario: registry 使用默认泛型参数 -- **WHEN** CheckerRegistry 存储和返回 checker 实例 -- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition`),实现类型擦除 - -### Requirement: CheckerRegistry 注册中心 -系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。registry 内部 SHALL 存储 `CheckerDefinition`(使用默认泛型参数),对外提供类型擦除后的接口。 - -#### Scenario: 注册并获取 Checker -- **WHEN** 调用 `registry.register(new HttpChecker())` 后再调用 `registry.get("http")` -- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例(类型为 `CheckerDefinition`) - -#### Scenario: 获取未注册的 type -- **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker -- **THEN** 系统 SHALL 抛出错误,提示不支持的 probe type - -#### Scenario: 重复注册 -- **WHEN** 同一 type 值被重复 `register()` -- **THEN** 系统 SHALL 抛出错误,提示该 type 已注册 - -#### Scenario: 查询支持的 type 列表 -- **WHEN** 注册了 "http" 和 "command" 两个 checker 后查询 `registry.supportedTypes` -- **THEN** 返回的数组 SHALL 包含 `["http", "command"]`(按注册顺序) diff --git a/openspec/changes/backend-architecture-hardening/specs/probe-api/spec.md b/openspec/changes/backend-architecture-hardening/specs/probe-api/spec.md deleted file mode 100644 index 71cee49..0000000 --- a/openspec/changes/backend-architecture-hardening/specs/probe-api/spec.md +++ /dev/null @@ -1,32 +0,0 @@ -## MODIFIED Requirements - -### Requirement: API 错误处理 -系统 SHALL 对不存在的目标 ID、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。 - -#### Scenario: 查询不存在的目标 -- **WHEN** 客户端请求 `GET /api/targets/999/history` -- **THEN** 系统 SHALL 返回 404 状态码和错误信息 - -#### Scenario: 无效的 from/to 参数 -- **WHEN** 客户端请求 `GET /api/targets/1/history?from=invalid` -- **THEN** 系统 SHALL 返回 400 状态码和错误信息 - -#### Scenario: 无效的分页参数 -- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc` -- **THEN** 系统 SHALL 返回 400 状态码和错误信息 - -#### Scenario: pageSize 超过上限 -- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=201` -- **THEN** 系统 SHALL 返回 400 状态码和错误信息,提示 pageSize 不能超过 200 - -#### Scenario: pageSize 等于上限 -- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=200` -- **THEN** 系统 SHALL 正常返回数据 - -#### Scenario: from 或 to 参数缺失 -- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数 -- **THEN** 系统 SHALL 返回 400 状态码和错误信息 - -#### Scenario: 无效的目标 ID -- **WHEN** 客户端请求 `GET /api/targets/abc/history` -- **THEN** 系统 SHALL 返回 400 状态码和错误信息 diff --git a/openspec/changes/backend-architecture-hardening/specs/probe-config/spec.md b/openspec/changes/backend-architecture-hardening/specs/probe-config/spec.md deleted file mode 100644 index 7a5fb40..0000000 --- a/openspec/changes/backend-architecture-hardening/specs/probe-config/spec.md +++ /dev/null @@ -1,16 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 数据目录路径解析 -配置加载流程 SHALL 将 `server.dataDir` 相对路径基于配置文件所在目录(configDir)解析为绝对路径。绝对路径 SHALL 保持不变。 - -#### Scenario: dataDir 为相对路径 -- **WHEN** 配置文件位于 `/opt/dial/probes.yaml`,且 `server.dataDir` 配置为 `./data` -- **THEN** 系统 SHALL 将 dataDir 解析为 `/opt/dial/data`,而非依赖进程 cwd - -#### Scenario: dataDir 为绝对路径 -- **WHEN** `server.dataDir` 配置为 `/var/lib/dial/data` -- **THEN** 系统 SHALL 直接使用该绝对路径,不做额外解析 - -#### Scenario: dataDir 使用默认值 -- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`) -- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径 diff --git a/openspec/changes/backend-architecture-hardening/specs/probe-engine/spec.md b/openspec/changes/backend-architecture-hardening/specs/probe-engine/spec.md deleted file mode 100644 index 9d979b3..0000000 --- a/openspec/changes/backend-architecture-hardening/specs/probe-engine/spec.md +++ /dev/null @@ -1,24 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 组内并发拨测 -系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。当某个目标的 checker 执行 rejected(非正常 CheckResult 返回,而是 Promise reject)时,系统 SHALL 将该异常记录为 `matched: false` 的 check_result,而非仅 console.warn。 - -#### Scenario: 同组目标并发执行 -- **WHEN** 调度器触发一次 tick,该组有 3 个目标,且全局并发余量至少为 3 -- **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行 - -#### Scenario: 单个目标失败不影响同组其他目标 -- **WHEN** 同组中某个目标的检查请求超时或失败(checker 正常返回 CheckResult) -- **THEN** 其他目标的检查 SHALL 正常完成并记录结果 - -#### Scenario: 同组中某个目标的 checker 执行 rejected -- **WHEN** 同组中某个目标的 checker 执行抛出未捕获异常(Promise rejected) -- **THEN** 系统 SHALL 为该目标写入一条 `matched: false` 的 check_result,failure 为 `{ kind: "error", phase: "internal", path: "engine", message: }`,其他目标的检查 SHALL 不受影响 - -#### Scenario: rejected 结果通过索引关联 targetName -- **WHEN** checker 执行 rejected -- **THEN** 系统 SHALL 通过 Promise.allSettled 的索引关联回 target 数组,获取对应的 targetName 用于写入 check_result - -#### Scenario: 全局并发限制生效 -- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3 -- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放 diff --git a/openspec/changes/backend-architecture-hardening/tasks.md b/openspec/changes/backend-architecture-hardening/tasks.md deleted file mode 100644 index 607a9a8..0000000 --- a/openspec/changes/backend-architecture-hardening/tasks.md +++ /dev/null @@ -1,42 +0,0 @@ -## 1. CheckerDefinition 泛型化 - -- [x] 1.1 修改 `src/server/checker/runner/types.ts`:为 CheckerDefinition 接口添加泛型参数 ``,约束 execute、resolve、serialize 方法的 target 参数类型 -- [x] 1.2 修改 `src/server/checker/runner/registry.ts`:内部 Map 类型使用 `CheckerDefinition`(默认泛型参数),确保类型擦除 -- [x] 1.3 修改 `src/server/checker/runner/http/execute.ts`:HttpChecker 实现 `CheckerDefinition`,移除 execute/serialize 方法内的 `as ResolvedHttpTarget` 断言(resolve 方法内对 RawTargetConfig 的断言保留,泛型不覆盖输入参数窄化) -- [x] 1.4 修改 `src/server/checker/runner/command/execute.ts`:CommandChecker 实现 `CheckerDefinition`,移除 execute/serialize 方法内的 `as ResolvedCommandTarget` 断言(resolve 方法内对 RawTargetConfig 的断言保留) -- [x] 1.5 修复 `src/web/components/ErrorBoundary.tsx` 的 `override` 标记(`noImplicitOverride` 规则要求的既有代码修复),运行 `bun run typecheck` 确认类型系统无错误 - -## 2. ProbeStore 批量查询优化 - -- [x] 2.1 在 `src/server/checker/store.ts` 中新增 `getAllRecentSamples(limit: number)` 方法,使用 `ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC)` 实现单次批量查询 -- [x] 2.2 修改 `src/server/checker/store.ts` 中 `getAllTargetStats` 的 availability 计算:将 `Math.round((row.upCount / row.totalChecks) * 10000) / 100` 改为 `Math.round((row.upCount / row.totalChecks) * 100 * 100) / 100`,与 `getTargetStats` 精度一致 -- [x] 2.3 修改 `src/server/routes/targets.ts`:`handleTargets` 使用 `store.getAllRecentSamples(30)` 替代循环调用 `store.getRecentSamples` -- [x] 2.4 在 `tests/server/checker/store.test.ts` 中新增 `getAllRecentSamples` 的单元测试和 availability 精度一致性测试 - -## 3. Engine rejected 结果持久化 - -- [x] 3.1 修改 `src/server/checker/engine.ts`:`probeGroup` 中对 rejected 结果通过索引关联 target,构造 `matched: false`、`failure: { kind: "error", phase: "internal", path: "engine", message }` 的 check_result 写入 store -- [x] 3.2 在 `tests/server/checker/engine.test.ts` 中新增 rejected 结果写入的测试用例 - -## 4. 启动逻辑统一 - -- [x] 4.1 新增 `src/server/bootstrap.ts`,导出 `bootstrap(options: BootstrapOptions)` 函数,封装 loadConfig → ProbeStore → syncTargets → ProbeEngine → startServer → shutdown handler 完整序列 -- [x] 4.2 修改 `src/server/dev.ts`:改为调用 `bootstrap({ configPath, mode: "development" })` -- [x] 4.3 修改 `scripts/build.ts`:生成的 server entry 改为调用 `bootstrap({ configPath, mode: "production", staticAssets })` -- [x] 4.4 在 `tests/server/` 中新增 bootstrap 相关测试 - -## 5. dataDir 路径修复 - -- [x] 5.1 修改 `src/server/checker/config-loader.ts`:对 dataDir 使用 `resolve(configDir, dataDir)` 处理相对路径 -- [x] 5.2 在 `tests/server/checker/config-loader.test.ts` 中新增 dataDir 路径解析的测试用例 - -## 6. pageSize 上限 - -- [x] 6.1 修改 `src/server/middleware.ts`:`validatePagination` 增加 `pageSize > 200` 的校验,返回 400 -- [x] 6.2 在 `tests/server/app.test.ts` 中新增 pageSize 超限的测试用例 - -## 7. 质量保障与文档 - -- [x] 7.1 运行 `bun run check`(schema:check + typecheck + lint + test)确认全部通过 -- [x] 7.2 运行 `bun run build` 确认构建成功 -- [x] 7.3 更新 DEVELOPMENT.md 中相关章节(bootstrap 启动流程、CheckerDefinition 泛型说明、pageSize 上限说明) diff --git a/openspec/specs/batch-data-queries/spec.md b/openspec/specs/batch-data-queries/spec.md index 7715b3c..f4e56b9 100644 --- a/openspec/specs/batch-data-queries/spec.md +++ b/openspec/specs/batch-data-queries/spec.md @@ -17,7 +17,7 @@ - **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key ### Requirement: 批量查询目标统计 -系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。 +系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。availability 计算精度 SHALL 与 `getTargetStats` 一致,统一使用 `Math.round(value * 100) / 100` 保留两位小数。 #### Scenario: 获取所有目标的聚合统计 - **WHEN** 调用 `getAllTargetStats()` @@ -29,7 +29,7 @@ #### Scenario: availability 精度 - **WHEN** 计算 availability(upCount / totalChecks * 100) -- **THEN** 结果 SHALL 四舍五入保留两位小数 +- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致 ### Requirement: summary 查询使用批量方法 `getSummary` 方法 SHALL 使用 `getLatestChecksMap` 一次性获取所有 target 的最新检查结果,而非对每个 target 逐条查询。 @@ -39,11 +39,30 @@ - **THEN** 系统 SHALL 调用 `getLatestChecksMap()` 一次获取所有最新结果,在内存中遍历统计 up/down 数量,而非循环 N 次调用 `getLatestCheck()` ### Requirement: targets 列表使用批量方法 -`createTargetsResponse`(app.ts 中生成 TargetStatus[] 的逻辑)SHALL 使用 `getLatestChecksMap` 和 `getAllTargetStats` 替代逐目标查询 latest checkout、stats 和 samples。 +`handleTargets`(routes/targets.ts 中生成 TargetStatus[] 的逻辑)SHALL 使用 `getLatestChecksMap`、`getAllTargetStats` 和 `getAllRecentSamples` 替代逐目标查询,消除 N+1 查询。 #### Scenario: 目标列表使用批量查询 - **WHEN** 处理 `GET /api/targets` 请求 -- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()`、`getAllTargetStats()` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库 +- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()`、`getAllTargetStats()`、`getAllRecentSamples(30)` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库 + +#### Scenario: 目标无采样数据 +- **WHEN** 某 target 在 getAllRecentSamples 返回的 Map 中不存在 +- **THEN** 该 target 的 recentSamples SHALL 为空数组 + +### Requirement: 批量查询所有目标的最近采样数据 +系统 SHALL 提供 `getAllRecentSamples(limit: number)` 方法,通过单次 SQL 查询获取所有 target 的最近 N 条采样数据,返回 `Map>` 结构。 + +#### Scenario: 获取所有目标的最近采样 +- **WHEN** 调用 `getAllRecentSamples(30)` +- **THEN** 系统 SHALL 通过单次 SQL 查询获取每个 target 最近 30 条记录,返回按 target_id 索引的 Map + +#### Scenario: 目标无历史记录 +- **WHEN** 某 target 在 check_results 表中无任何记录 +- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key + +#### Scenario: 采样数据排序 +- **WHEN** 获取采样数据 +- **THEN** 每个 target 的记录 SHALL 按 timestamp 降序排列(最新在前) ### Requirement: prepared statement 使用 query() 缓存 ProbeStore 中不涉及事务内复用的单次读/写操作 SHALL 使用 `this.db.query()` 而非 `this.db.prepare()`,利用 bun:sqlite 内置的 statement 缓存机制。 diff --git a/openspec/specs/checker-runner-abstraction/spec.md b/openspec/specs/checker-runner-abstraction/spec.md index 5b2e901..ddfe603 100644 --- a/openspec/specs/checker-runner-abstraction/spec.md +++ b/openspec/specs/checker-runner-abstraction/spec.md @@ -50,7 +50,7 @@ - **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串 ### Requirement: Checker 接口定义 -系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的 `CheckerDefinition`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。`CheckerContext` SHALL 包含引擎注入的 `AbortSignal`。接口方法的参数和返回值 SHALL 使用 base interface 类型(`RawTargetConfig`、`ResolvedTargetBase`),各 checker 实现内部自行 narrow 到具体类型。 +系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。 #### Scenario: Checker 接口包含必要方法 - **WHEN** 开发者实现一个新的 Checker @@ -68,20 +68,24 @@ - **WHEN** checker 定义 `type: "tcp"` - **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组 -#### Scenario: 接口方法使用 base 类型 -- **WHEN** 开发者查看 `CheckerDefinition` 接口签名 -- **THEN** `resolve` 的参数 SHALL 为 `RawTargetConfig`,返回值 SHALL 为 `ResolvedTargetBase`;`execute` 的参数 SHALL 为 `ResolvedTargetBase`;`serialize` 的参数 SHALL 为 `ResolvedTargetBase` +#### Scenario: 接口方法使用泛型约束 +- **WHEN** 开发者查看 `CheckerDefinition` 接口签名 +- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved` -#### Scenario: checker 实现内部 narrow -- **WHEN** HttpChecker 的 execute 方法接收 `ResolvedTargetBase` 参数 -- **THEN** 方法内部 SHALL 将参数 narrow 为 `ResolvedHttpTarget`(通过 type assertion),然后使用具体类型的字段 +#### Scenario: checker 实现无需手动断言 +- **WHEN** HttpChecker 实现 `CheckerDefinition` +- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言 + +#### Scenario: registry 使用默认泛型参数 +- **WHEN** CheckerRegistry 存储和返回 checker 实例 +- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition`),实现类型擦除 ### Requirement: CheckerRegistry 注册中心 -系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。 +系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。registry 内部 SHALL 存储 `CheckerDefinition`(使用默认泛型参数),对外提供类型擦除后的接口。 #### Scenario: 注册并获取 Checker - **WHEN** 调用 `registry.register(new HttpChecker())` 后再调用 `registry.get("http")` -- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例 +- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例(类型为 `CheckerDefinition`) #### Scenario: 获取未注册的 type - **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker diff --git a/openspec/specs/probe-api/spec.md b/openspec/specs/probe-api/spec.md index 1ee55a4..1dcd9a4 100644 --- a/openspec/specs/probe-api/spec.md +++ b/openspec/specs/probe-api/spec.md @@ -90,7 +90,7 @@ - **THEN** 系统 SHALL 返回与之前格式一致的健康检查响应 ### Requirement: API 错误处理 -系统 SHALL 对不存在的目标 ID 和无效参数返回适当的 HTTP 错误响应。 +系统 SHALL 对不存在的目标 ID、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。 #### Scenario: 查询不存在的目标 - **WHEN** 客户端请求 `GET /api/targets/999/history` @@ -104,6 +104,14 @@ - **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc` - **THEN** 系统 SHALL 返回 400 状态码和错误信息 +#### Scenario: pageSize 超过上限 +- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=201` +- **THEN** 系统 SHALL 返回 400 状态码和错误信息,提示 pageSize 不能超过 200 + +#### Scenario: pageSize 等于上限 +- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=200` +- **THEN** 系统 SHALL 正常返回数据 + #### Scenario: from 或 to 参数缺失 - **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数 - **THEN** 系统 SHALL 返回 400 状态码和错误信息 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 93ca42f..0255b2a 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -291,3 +291,18 @@ #### Scenario: retention 字段缺省 - **WHEN** 配置文件中未指定 `runtime.retention` - **THEN** 系统 SHALL 使用默认值 `"7d"` + +### Requirement: 数据目录路径解析 +配置加载流程 SHALL 将 `server.dataDir` 相对路径基于配置文件所在目录(configDir)解析为绝对路径。绝对路径 SHALL 保持不变。 + +#### Scenario: dataDir 为相对路径 +- **WHEN** 配置文件位于 `/opt/dial/probes.yaml`,且 `server.dataDir` 配置为 `./data` +- **THEN** 系统 SHALL 将 dataDir 解析为 `/opt/dial/data`,而非依赖进程 cwd + +#### Scenario: dataDir 为绝对路径 +- **WHEN** `server.dataDir` 配置为 `/var/lib/dial/data` +- **THEN** 系统 SHALL 直接使用该绝对路径,不做额外解析 + +#### Scenario: dataDir 使用默认值 +- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`) +- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径 diff --git a/openspec/specs/probe-engine/spec.md b/openspec/specs/probe-engine/spec.md index 715e07f..5f67cb0 100644 --- a/openspec/specs/probe-engine/spec.md +++ b/openspec/specs/probe-engine/spec.md @@ -16,16 +16,24 @@ - **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度 ### Requirement: 组内并发拨测 -系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。 +系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。当某个目标的 checker 执行 rejected(非正常 CheckResult 返回,而是 Promise reject)时,系统 SHALL 将该异常记录为 `matched: false` 的 check_result,而非仅 console.warn。 #### Scenario: 同组目标并发执行 - **WHEN** 调度器触发一次 tick,该组有 3 个目标,且全局并发余量至少为 3 - **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行 #### Scenario: 单个目标失败不影响同组其他目标 -- **WHEN** 同组中某个目标的检查请求超时或失败 +- **WHEN** 同组中某个目标的检查请求超时或失败(checker 正常返回 CheckResult) - **THEN** 其他目标的检查 SHALL 正常完成并记录结果 +#### Scenario: 同组中某个目标的 checker 执行 rejected +- **WHEN** 同组中某个目标的 checker 执行抛出未捕获异常(Promise rejected) +- **THEN** 系统 SHALL 为该目标写入一条 `matched: false` 的 check_result,failure 为 `{ kind: "error", phase: "internal", path: "engine", message: }`,其他目标的检查 SHALL 不受影响 + +#### Scenario: rejected 结果通过索引关联 targetName +- **WHEN** checker 执行 rejected +- **THEN** 系统 SHALL 通过 Promise.allSettled 的索引关联回 target 数组,获取对应的 targetName 用于写入 check_result + #### Scenario: 全局并发限制生效 - **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3 - **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放 diff --git a/openspec/changes/backend-architecture-hardening/specs/server-bootstrap/spec.md b/openspec/specs/server-bootstrap/spec.md similarity index 93% rename from openspec/changes/backend-architecture-hardening/specs/server-bootstrap/spec.md rename to openspec/specs/server-bootstrap/spec.md index 00ce1fa..5dd6230 100644 --- a/openspec/changes/backend-architecture-hardening/specs/server-bootstrap/spec.md +++ b/openspec/specs/server-bootstrap/spec.md @@ -1,4 +1,8 @@ -## ADDED Requirements +## Purpose + +TBD - 统一服务启动引导函数,封装开发和生产模式的完整启动序列。 + +## Requirements ### Requirement: 统一启动引导函数 系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。