- 合并 20+ 细粒度 spec 为粗粒度主题规范:dashboard、data-store、probe-engine、probe-api、probe-config 等 - 删除完全冗余规范:data-retention(被 probe-engine+data-store 覆盖)、backend-code-quality(DEVELOPMENT.md 已记录) - 补充 http-checker 规范至完整标准(配置+执行+expect+校验+observation),匹配代码 440 行实现 - 清理 tcp/udp/llm checker 规范中已废弃 defaults 配置段的残留 Scenario - 清理 checker-cohesion-structure 中的实现路径引用(src/server/...) - 统一所有 spec 格式(## Purpose 开头,去除 # Capability/Title 形式) - 更新 prompt-spec-review.md 审查提示文档
314 lines
18 KiB
Markdown
314 lines
18 KiB
Markdown
## Purpose
|
||
|
||
定义基于 SQLite 的拨测数据持久化存储:targets 同步(含分组信息)、check_results 追加写入、Dashboard 和 Metrics 数据查询支持、延迟百分位取数、时间范围和分页查询、索引与聚合查询、批量查询方法(getLatestChecksMap、getAllTargetStats、getAllRecentSamples)、N+1 查询优化,以及 prepared statement 使用 query() 缓存。
|
||
|
||
## Requirements
|
||
|
||
### Requirement: SQLite 数据库初始化
|
||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果,targets 表 MUST 包含 `grp` 列存储分组信息、`active` 列标记活跃状态,且 targets 表的 `name` 和 `description` 列 MUST 允许 NULL。check_results 表的外键约束 SHALL 使用 `ON DELETE RESTRICT`。
|
||
|
||
#### Scenario: 首次启动创建数据库
|
||
- **WHEN** 指定的数据目录下不存在数据库文件
|
||
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表(含 active INTEGER NOT NULL DEFAULT 1 列)和 check_results 表(外键约束为 ON DELETE RESTRICT),check_results 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(TEXT NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、observation(TEXT)、failure(TEXT),不包含 status_detail 列,不包含 success 列
|
||
|
||
#### Scenario: targets name 列允许 NULL
|
||
- **WHEN** 系统首次创建 targets 表
|
||
- **THEN** targets.name 列 SHALL 允许存储 NULL
|
||
|
||
#### Scenario: 数据目录不存在
|
||
- **WHEN** 配置的数据目录路径不存在
|
||
- **THEN** 系统 SHALL 自动创建该目录
|
||
|
||
#### Scenario: 数据库已存在时启动
|
||
- **WHEN** 数据库文件已存在
|
||
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
|
||
|
||
#### Scenario: 外键约束
|
||
- **THEN** 系统 SHALL 启用 `PRAGMA foreign_keys = ON`
|
||
|
||
#### Scenario: 级联删除改为限制删除
|
||
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE RESTRICT`,确保删除 target 时数据库层面阻止操作而非级联删除关联记录
|
||
|
||
### Requirement: targets 表同步
|
||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、分组信息和目标说明。`targets.expect` 列当前实现写入 NULL,不持久化 expect 快照。配置中不存在的 target SHALL 被标记为非活跃而非删除。系统不需要保存原始用户输入的 Authoring expect 写法;`targets.expect` MUST NOT 被用作恢复用户 YAML 的数据源。
|
||
|
||
#### Scenario: 首次同步目标
|
||
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
|
||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect、grp 和 active=1,其中 name 和 description 均可为 NULL,expect 列写入 NULL
|
||
|
||
#### Scenario: 配置变更后重新同步
|
||
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
||
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入(active=1)、删除的设置 active=0(不删除行)、修改的更新(含 name、description 和 grp 字段),已存在的目标 SHALL 设置 active=1
|
||
|
||
#### Scenario: 配置中移除目标
|
||
- **WHEN** YAML 配置中移除了某个 target,该 target 在数据库中存在且 active=1
|
||
- **THEN** 系统 SHALL 将该 target 的 active 设置为 0,保留该行及所有关联的 check_results
|
||
|
||
#### Scenario: 配置中恢复已移除目标
|
||
- **WHEN** YAML 配置中重新添加了之前移除的 target(数据库中 active=0)
|
||
- **THEN** 系统 SHALL 将该 target 的 active 设置为 1,并更新其他字段,历史 check_results 保留不变
|
||
|
||
#### Scenario: 未配置 name
|
||
- **WHEN** YAML target 未配置 `name`
|
||
- **THEN** targets 表 SHALL 将该目标的 name 存储为 NULL
|
||
|
||
#### Scenario: name 显式 null
|
||
- **WHEN** YAML target 配置 `name: null`
|
||
- **THEN** targets 表 SHALL 将该目标的 name 存储为 NULL
|
||
|
||
#### Scenario: 未配置 description
|
||
- **WHEN** YAML target 未配置 `description`
|
||
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
|
||
|
||
#### Scenario: description 显式 null
|
||
- **WHEN** YAML target 配置 `description: null`
|
||
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
|
||
|
||
#### Scenario: expect 列不保存原始 Authoring 写法
|
||
- **WHEN** target 配置 `expect.body: [{json: {path: "$.status"}}]` 和 `expect.durationMs: 1000`
|
||
- **THEN** targets 表的 expect 列 MUST NOT 保存原始 Authoring JSON 中的 `durationMs: 1000` 简写,当前实现写入 NULL
|
||
|
||
#### Scenario: 未配置 expect 写入 NULL
|
||
- **WHEN** target 未配置任何 expect
|
||
- **THEN** targets 表的 expect 列 SHALL 写入 NULL,即使 Resolved expect 中存在 checker 默认状态语义
|
||
|
||
### Requirement: check_results 表追加写入
|
||
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
|
||
|
||
#### Scenario: 写入检查结果
|
||
- **WHEN** 一次 checker 执行完成
|
||
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、matched、duration_ms、observation、failure 的记录,其中 observation 使用 JSON.stringify 序列化为 TEXT
|
||
|
||
#### Scenario: 查询检查结果
|
||
- **WHEN** 系统查询 latest check 或历史 check_results
|
||
- **THEN** 存储层 SHALL 返回 observation 字段而非 status_detail 字段,供 API 序列化层反序列化并构造 detail
|
||
|
||
#### Scenario: 写入结构化失败信息
|
||
- **WHEN** checker 执行失败或 expect 不匹配
|
||
- **THEN** 系统 SHALL 将首个失败原因序列化写入 failure 字段
|
||
|
||
### Requirement: 时间范围查询索引
|
||
系统 SHALL 在 check_results 表上创建 (target_id, timestamp) 复合索引,加速按目标和时间范围的查询。
|
||
|
||
#### Scenario: 查询某目标的历史记录
|
||
- **WHEN** 查询指定 target_id 的最近 N 条记录
|
||
- **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描
|
||
|
||
### Requirement: 目标列表按分组排序
|
||
系统 SHALL 保证 targets 查询结果按分组排序返回,且仅返回活跃目标。
|
||
|
||
#### Scenario: 分组排序查询
|
||
- **WHEN** 查询所有 targets
|
||
- **THEN** 结果 SHALL 仅返回 active=1 的目标,将 "default" 分组目标排在首位,其余分组按 YAML 配置中首次出现的顺序(即 id 自增顺序)排列
|
||
|
||
### Requirement: 聚合查询支持
|
||
数据存储 SHALL 支持按时间段获取指标计算所需数据,用于后端应用层计算可用率、平均耗时、延迟范围、趋势分桶和可靠性指标。
|
||
|
||
#### Scenario: 轻数据库计算边界
|
||
- **WHEN** 实现指标相关数据查询
|
||
- **THEN** 数据库 SHALL 主要负责存储、过滤、排序、分页、LIMIT 和标准 SQL 基础聚合,业务指标语义 SHALL 在后端应用层计算
|
||
|
||
#### Scenario: 可使用的基础 SQL 聚合
|
||
- **WHEN** 查询需要减少返回数据量
|
||
- **THEN** 系统 MAY 使用标准 SQL 的 COUNT、SUM(CASE)、AVG、MIN、MAX、GROUP BY 等基础能力
|
||
|
||
#### Scenario: 避免数据库承载业务语义
|
||
- **WHEN** 实现状态翻转、故障段、MTTR、最长故障、连续状态、百分位或趋势分桶
|
||
- **THEN** 系统 SHALL 在后端应用层实现这些规则,不依赖 SQLite 专有函数或复杂窗口函数承载业务语义
|
||
|
||
#### Scenario: UP/DOWN 判定
|
||
- **WHEN** 系统需要判定目标当前状态
|
||
- **THEN** 系统 SHALL 基于 latestCheck.matched 判定目标 UP 或 DOWN:matched=true 为 UP,matched=false 为 DOWN
|
||
|
||
### Requirement: Dashboard 数据查询支持
|
||
ProbeStore SHALL 提供 Dashboard 聚合响应所需的批量取数能力,且所有查询 SHALL 仅涉及活跃 target。
|
||
|
||
#### Scenario: 批量获取最新检查
|
||
- **WHEN** Dashboard API 需要计算当前 up/down 和 lastCheckTime
|
||
- **THEN** Store SHALL 支持批量获取每个活跃 target 的最新检查记录,避免 N+1 查询
|
||
|
||
#### Scenario: 批量获取窗口统计基础数据
|
||
- **WHEN** Dashboard API 需要计算各 target 在指定 window 内的 totalChecks、upChecks、downChecks 和 availability
|
||
- **THEN** Store SHALL 支持按 target_id 批量返回指定时间窗口内的基础计数数据,仅包含活跃 target
|
||
|
||
#### Scenario: 批量获取最近样本
|
||
- **WHEN** Dashboard API 需要展示 recentSamples 和计算 capped currentStreak
|
||
- **THEN** Store SHALL 支持批量获取每个活跃 target 最近 recentLimit 条检查记录,按 target_id 分组且每组按 timestamp 降序排列
|
||
|
||
#### Scenario: 获取 Dashboard 异常事件序列
|
||
- **WHEN** Dashboard API 需要计算 incidents
|
||
- **THEN** Store SHALL 支持获取指定时间窗口内所有活跃 target 的 `{ target_id, timestamp, matched }` 序列,按 target_id 和 timestamp 升序排列,供后端应用层计算状态翻转
|
||
|
||
### Requirement: 单目标指标取数支持
|
||
ProbeStore SHALL 提供单目标 metrics 响应所需的取数能力。仅活跃 target 的指标 SHALL 可查询。
|
||
|
||
#### Scenario: 获取目标检查点序列
|
||
- **WHEN** Metrics API 需要计算趋势分桶、故障段、MTTR、最长故障、故障次数和连续状态
|
||
- **THEN** Store SHALL 支持获取指定活跃 target 在 from 到 to 时间范围内的 `{ timestamp, matched, duration_ms }` 数组,按 timestamp 升序排列
|
||
|
||
#### Scenario: 目标不活跃
|
||
- **WHEN** 查询 inactive target 的指标
|
||
- **THEN** Store SHALL 返回 null(getTargetById 不匹配 active=1 的条件)
|
||
|
||
#### Scenario: 无检查记录
|
||
- **WHEN** 时间窗口内无检查记录
|
||
- **THEN** Store SHALL 返回空数组
|
||
|
||
### Requirement: 目标延迟百分位取数
|
||
ProbeStore SHALL 提供 `getTargetDurations(targetId, from, to)` 方法,返回时间窗口内所有成功检查的 duration_ms 数组。
|
||
|
||
#### Scenario: 获取延迟数据
|
||
- **WHEN** 调用 `getTargetDurations(targetId, from, to)`
|
||
- **THEN** 系统 SHALL 返回该目标在时间范围内所有 matched=1 且 duration_ms 不为 null 的 duration_ms 值数组
|
||
|
||
#### Scenario: 延迟数据排序
|
||
- **WHEN** 获取延迟数据
|
||
- **THEN** 返回数组 SHALL 按 duration_ms 升序排列,供后端应用层计算 P95/P99
|
||
|
||
#### Scenario: 无成功检查
|
||
- **WHEN** 时间窗口内无 matched=1 且 duration_ms 不为 null 的记录
|
||
- **THEN** 系统 SHALL 返回空数组
|
||
|
||
### Requirement: 历史记录时间范围和分页查询
|
||
系统 SHALL 继续支持按时间范围筛选并分页查询历史记录。
|
||
|
||
#### Scenario: 按时间范围筛选历史记录
|
||
- **WHEN** 查询指定 target 在 from 到 to 时间范围内的历史记录
|
||
- **THEN** 系统 SHALL 返回该时间范围内的记录,按 timestamp 降序排列
|
||
|
||
#### Scenario: 分页查询历史记录
|
||
- **WHEN** 查询指定 page 和 pageSize 的历史记录
|
||
- **THEN** 系统 SHALL 返回对应页的数据和总记录数
|
||
|
||
### Requirement: 目标展示摘要持久化
|
||
数据存储 SHALL 为每个 target 持久化一个领域无关的展示摘要字段 `target`。
|
||
|
||
#### Scenario: HTTP target 展示摘要
|
||
- **WHEN** 同步 HTTP target
|
||
- **THEN** targets.target SHALL 存储该 target 的 URL
|
||
|
||
#### Scenario: cmd target 展示摘要
|
||
- **WHEN** 同步 cmd target
|
||
- **THEN** targets.target SHALL 存储由 exec 和 args 组成的命令摘要
|
||
|
||
#### Scenario: HTTP target config 序列化
|
||
- **WHEN** 同步 HTTP target
|
||
- **THEN** targets.config SHALL 存储 JSON,包含 url、method、headers、body、maxBodyBytes、ignoreSSL、maxRedirects
|
||
|
||
#### Scenario: cmd target config 序列化
|
||
- **WHEN** 同步 cmd target
|
||
- **THEN** targets.config SHALL 存储 JSON,包含 exec、args、cwd、env、maxOutputBytes
|
||
|
||
### Requirement: 数据清理方法
|
||
ProbeStore SHALL 提供 `prune(retentionMs: number)` 方法,删除超过保留时长的历史检查结果并返回删除行数,同时清理已无关联检查结果的非活跃目标行。
|
||
|
||
#### Scenario: 清理过期数据
|
||
- **WHEN** 调用 `prune(604800000)`(7 天毫秒数)
|
||
- **THEN** 系统 SHALL 删除 `check_results` 表中 `timestamp` 早于当前时间减去 604800000 毫秒的所有记录,并返回实际删除的行数
|
||
|
||
#### Scenario: 无过期数据
|
||
- **WHEN** 调用 `prune()` 但所有记录都在保留期内
|
||
- **THEN** 系统 SHALL 返回 0,不删除任何记录
|
||
|
||
#### Scenario: 清理不影响保留期内数据
|
||
- **WHEN** 调用 `prune()` 且存在保留期内和保留期外的记录
|
||
- **THEN** 系统 SHALL 仅删除保留期外的记录,保留期内的记录 SHALL 不受影响
|
||
|
||
#### Scenario: 清理空壳非活跃目标
|
||
- **WHEN** prune 执行完毕后,存在 active=0 的 target 且该 target 在 check_results 表中无任何关联记录
|
||
- **THEN** 系统 SHALL 删除该空壳 target 行
|
||
|
||
#### Scenario: 非活跃目标仍有历史数据时不清理
|
||
- **WHEN** 存在 active=0 的 target 但该 target 在 check_results 表中仍有关联记录
|
||
- **THEN** 系统 SHALL NOT 删除该 target 行
|
||
|
||
#### Scenario: 活跃目标永不清理
|
||
- **WHEN** 存在 active=1 的 target 且该 target 在 check_results 表中无关联记录
|
||
- **THEN** 系统 SHALL NOT 删除该 target 行
|
||
|
||
### Requirement: 批量查询最新检查结果
|
||
系统 SHALL 提供 `getLatestChecksMap` 方法,通过单次 SQL 查询获取所有 target 的最新一次 check 结果,返回 Map 结构供调用方按 target_id 索引。
|
||
|
||
#### Scenario: 获取所有目标的最新检查
|
||
- **WHEN** 调用 `getLatestChecksMap()`
|
||
- **THEN** 系统 SHALL 执行子查询找到每个 target_id 的 MAX(timestamp),再 JOIN 回 check_results 获取完整行,返回 `Map<number, StoredCheckResult | null>`
|
||
|
||
#### Scenario: 批量查询目标无历史记录
|
||
- **WHEN** 某 target 在 check_results 表中无任何记录
|
||
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
|
||
|
||
### Requirement: 批量查询目标统计
|
||
系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。availability 计算精度 SHALL 与 `getTargetStats` 一致,统一使用 `Math.round(value * 100) / 100` 保留两位小数。
|
||
|
||
#### Scenario: 获取所有目标的聚合统计
|
||
- **WHEN** 调用 `getAllTargetStats()`
|
||
- **THEN** 系统 SHALL 执行 `SELECT target_id, COUNT(*), SUM(CASE WHEN matched=1 THEN 1 ELSE 0 END) FROM check_results GROUP BY target_id`,在内存中计算 availability 并返回 `Map<number, { totalChecks, availability }>`
|
||
|
||
#### Scenario: 统计查询目标无历史记录
|
||
- **WHEN** 某 target 在 check_results 表中无任何记录
|
||
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
|
||
|
||
#### Scenario: availability 精度
|
||
- **WHEN** 计算 availability(upCount / totalChecks * 100)
|
||
- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致
|
||
|
||
### Requirement: 批量查询所有目标的最近采样数据
|
||
系统 SHALL 提供 `getAllRecentSamples(limit: number)` 方法,通过单次 SQL 查询获取所有 target 的最近 N 条采样数据,返回 `Map<number, Array<{ timestamp: string; duration_ms: number | null; matched: number }>>` 结构。
|
||
|
||
#### 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 降序排列(最新在前)
|
||
|
||
#### Scenario: 采样目标无数据返回空数组
|
||
- **WHEN** 某 target 在 getAllRecentSamples 返回的 Map 中不存在
|
||
- **THEN** 该 target 的 recentSamples SHALL 为空数组
|
||
|
||
### Requirement: summary 查询使用批量方法
|
||
`getSummary` 方法 SHALL 使用 `getLatestChecksMap` 一次性获取所有 target 的最新检查结果,而非对每个 target 逐条查询。
|
||
|
||
#### Scenario: 统计总览使用批量查询
|
||
- **WHEN** 调用 `store.getSummary()`
|
||
- **THEN** 系统 SHALL 调用 `getLatestChecksMap()` 一次获取所有最新结果,在内存中遍历统计 up/down 数量,而非循环 N 次调用 `getLatestCheck()`
|
||
|
||
### 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 逐条查询数据库
|
||
|
||
### Requirement: prepared statement 使用 query() 缓存
|
||
ProbeStore 中不涉及事务内复用的单次读/写操作 SHALL 使用 `this.db.query()` 而非 `this.db.prepare()`,利用 bun:sqlite 内置的 statement 缓存机制。
|
||
|
||
#### Scenario: insertCheckResult 使用 query
|
||
- **WHEN** 写入一条检查结果
|
||
- **THEN** `insertCheckResult` SHALL 使用 `this.db.query("INSERT INTO ...").run(...)` 而非 `this.db.prepare("INSERT INTO ...").run(...)`
|
||
|
||
#### Scenario: getHistory 查询使用 query
|
||
- **WHEN** 查询历史记录(包括 COUNT 和分页查询)
|
||
- **THEN** `getHistory` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
|
||
|
||
#### Scenario: getTargetStats 查询使用 query
|
||
- **WHEN** 查询单目标统计
|
||
- **THEN** `getTargetStats` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
|
||
|
||
#### Scenario: getTrend 查询使用 query
|
||
- **WHEN** 查询趋势数据
|
||
- **THEN** `getTrend` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
|
||
|
||
#### Scenario: getRecentSamples 查询使用 query
|
||
- **WHEN** 查询采样数据
|
||
- **THEN** `getRecentSamples` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
|
||
|
||
#### Scenario: syncTargets 事务保持 prepare(例外)
|
||
- **WHEN** 同步 targets 配置(事务内多次复用 insertStmt/updateStmt/deleteStmt)
|
||
- **THEN** `syncTargets` 方法 SHALL 保持使用 `this.db.prepare()`,因需要在事务闭包内持有引用
|