1
0

refactor: 全面优化后端代码质量与架构

- app.ts 单体路由拆分为 routes/ + helpers + middleware + static 独立模块
- 类型去重:CheckFailure/CheckResult 以 shared/api.ts 为唯一源头,收紧 phase 联合类型
- es-toolkit 替换:isPlainObject/isNil/isEmptyObject/isEqual/isError/Semaphore/groupBy
- Bun 内置 API:Object.fromEntries 替代手写 headersToRecord
- bun:sqlite 规范:prepare() → query() 利用内置缓存,避免 N+1 查询
- 新增 getLatestChecksMap/allGetTargetStats 批量查询方法
- 新增 backend-code-quality/api-route-separation/batch-data-queries 规范
- 补充 openspec/config.yaml 后端开发规范与 DEVELOPMENT.md 后端开发指引
This commit is contained in:
2026-05-12 15:15:36 +08:00
parent 696db6ffb5
commit f7facb7232
24 changed files with 868 additions and 368 deletions

View File

@@ -10,9 +10,9 @@ context: |
- 这是基于bun实现的前端后一体化项目使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
- src/server目录下是基于bun实现的后端代码
- src/web目录下是基于vite、react、TDesign实现的前端代码
- 代码开发优先使用公共组件实现功能逻辑(优先级:官方库>主流三方库>项目公共工具>自行实现
- 后端库使用优先级Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
- 前端样式开发优先级TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
- 前端严禁组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
- 前端严禁组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务节省上下文空间加速任务执行

View File

@@ -0,0 +1,78 @@
## Purpose
定义后端 API 路由的组织规范:按端点拆分为独立 handler、共享响应工具集中管理、参数校验逻辑抽取为中间件、静态资源服务独立维护。
## Requirements
### Requirement: 路由按职责拆分
系统 SHALL 将 HTTP 路由处理逻辑按 API 端点拆分为独立模块,每个模块导出一个 handler 函数供 app.ts 统一注册。
#### Scenario: health 端点独立路由
- **WHEN** 客户端请求 `GET /health`
- **THEN** `routes/health.ts` 导出的 handler 负责处理,返回 HealthResponse JSON
#### Scenario: summary 端点独立路由
- **WHEN** 客户端请求 `GET /api/summary`
- **THEN** `routes/summary.ts` 导出的 handler 负责处理,委托 store 查询并返回 SummaryResponse JSON
#### Scenario: targets 端点独立路由
- **WHEN** 客户端请求 `GET /api/targets`
- **THEN** `routes/targets.ts` 导出的 handler 负责处理,委托 store 查询并返回 TargetStatus[] JSON
#### Scenario: history 端点独立路由
- **WHEN** 客户端请求 `GET /api/targets/:id/history?from=ISO&to=ISO`
- **THEN** `routes/history.ts` 导出的 handler 负责处理包含参数校验、store 查询和 HistoryResponse 返回
#### Scenario: trend 端点独立路由
- **WHEN** 客户端请求 `GET /api/targets/:id/trend?from=ISO&to=ISO`
- **THEN** `routes/trend.ts` 导出的 handler 负责处理包含参数校验、store 查询和 TrendPoint[] 返回
### Requirement: 共享辅助函数集中管理
系统 SHALL 将跨路由共享的响应格式化函数抽取到 helpers.ts 模块,单一职责、集中管理。
#### Scenario: createApiError 集中定义
- **WHEN** 任意路由需要返回 API 错误响应
- **THEN** 从 `helpers.ts` 导入 `createApiError` 函数,提供错误信息和状态码
#### Scenario: jsonResponse 集中定义
- **WHEN** 任意路由需要返回 JSON 响应
- **THEN** 从 `helpers.ts` 导入 `jsonResponse` 函数,处理 HEAD 方法、Content-Type 和安全头
#### Scenario: mapCheckResult 集中定义
- **WHEN** 需要将 StoredCheckResult 映射为 API CheckResult
- **THEN** 从 `helpers.ts` 导入 `mapCheckResult` 函数,处理 failure JSON 解析和格式转换
### Requirement: 参数校验逻辑抽取为中间件
系统 SHALL 将重复的参数校验逻辑target ID 解析、时间范围校验、分页参数校验、方法检查)抽取到 middleware.ts 模块。
#### Scenario: 方法检查中间件
- **WHEN** 请求方法不是 GET 或 HEAD
- **THEN** `guardGetHead(request, mode)` SHALL 返回 405 Response否则返回 null 表示放行
#### Scenario: Target ID 校验
- **WHEN** URL 中的 id 参数不是正整数
- **THEN** `validateTargetId(idStr)` SHALL 返回 400 ApiError
#### Scenario: 时间范围参数校验
- **WHEN** from 或 to 参数缺失或格式无效
- **THEN** `validateTimeRange(from, to)` SHALL 返回 400 ApiError
#### Scenario: 分页参数校验
- **WHEN** page 或 pageSize 参数不是正整数
- **THEN** `validatePagination(page, pageSize)` SHALL 返回 400 ApiError
### Requirement: 静态资源服务独立管理
系统 SHALL 将静态资源服务、SPA fallback 和 Content-Type 映射逻辑抽取到 static.ts 模块。
#### Scenario: 根路径返回 index.html
- **WHEN** 客户端请求 `/`
- **THEN** `static.ts` 的 handler 返回 index.html设置正确的 Content-Type 和 Cache-Control
#### Scenario: 资源文件返回正确 Content-Type
- **WHEN** 客户端请求 `/assets/main.js`
- **THEN** `static.ts` 的 handler 根据文件扩展名返回正确的 Content-Type`.js``text/javascript`
#### Scenario: SPA fallback
- **WHEN** 客户端请求非 API、非资源的路径`/dashboard`
- **THEN** `static.ts` 的 handler 返回 index.html 实现 SPA 的客户端路由

View File

@@ -0,0 +1,102 @@
## Purpose
定义后端代码中 es-toolkit 和 Bun 内置 API 的使用规范:类型判断、空值检测、深度比较、错误判断、并发控制、集合分组和 Web API 标准方法,替代手写实现落实库使用优先级规则。
## Requirements
### Requirement: 使用 es-toolkit 进行类型判断
系统 SHALL 使用 es-toolkit 的 `isPlainObject` 替代手写的对象类型判断函数,用于 expect 校验中区分纯值(原始值)和操作符对象。
#### Scenario: 识别纯对象为操作符
- **WHEN** body 校验规则中 expected 配置为 `{ equals: "value" }`(纯对象操作符)
- **THEN** `isPlainObject(expected)` SHALL 返回 true系统按操作符语义处理
#### Scenario: 排除非纯对象作为操作符
- **WHEN** body 校验规则中 expected 为原始值如 `"value"` 或数字 `200`
- **THEN** `isPlainObject(expected)` SHALL 返回 false系统按 equals 默认操作符处理
### Requirement: 使用 es-toolkit 进行空值检测
系统 SHALL 使用 es-toolkit 的 `isNil` 替代手写的 `actual === null || actual === undefined` 检测,用于 expect 中 `empty` 操作符的空值判断。
#### Scenario: null 值判定为空
- **WHEN** 校验值为 null
- **THEN** `isNil(null)` SHALL 返回 true
#### Scenario: undefined 值判定为空
- **WHEN** 校验值为 undefined
- **THEN** `isNil(undefined)` SHALL 返回 true
#### Scenario: 非空值判定为非空
- **WHEN** 校验值为 0、"false"、空数组 `[]` 等非 nil 值
- **THEN** `isNil(value)` SHALL 返回 false
### Requirement: 使用 es-toolkit 进行空对象检测
系统 SHALL 使用 es-toolkit 的 `isEmptyObject` 替代手写的 `typeof actual === "object" && Object.keys(actual).length === 0` 检测,用于 expect 中 `empty` 操作符的空对象判断。
#### Scenario: 空对象判定为空
- **WHEN** 校验值为 `{}`
- **THEN** `isEmptyObject({})` SHALL 返回 true
#### Scenario: 非空对象判定为非空
- **WHEN** 校验值为 `{ key: "val" }`
- **THEN** `isEmptyObject({ key: "val" })` SHALL 返回 false
#### Scenario: null 不是空对象
- **WHEN** 校验值为 null
- **THEN** `isEmptyObject(null)` SHALL 返回 false空值由 isNil 前置处理)
### Requirement: 使用 es-toolkit 进行深度相等比较
系统 SHALL 使用 es-toolkit 的 `isEqual` 替代 `!==` 浅比较,用于 expect 中 `equals` 操作符的值比较,支持对象和数组的深度比较。
#### Scenario: 原始值浅比较
- **WHEN** expected 和 actual 均为原始值字符串、数字、布尔值、null
- **THEN** `isEqual(actual, expected)` 的行为 SHALL 与 `actual === expected` 一致
#### Scenario: 对象深度比较
- **WHEN** expected 和 actual 均为对象(如从 JSONPath 提取的结构化数据)
- **THEN** `isEqual(actual, expected)` SHALL 递归比较所有属性值,而非引用比较
### Requirement: 使用 es-toolkit 进行错误类型判断
系统 SHALL 使用 es-toolkit 的 `isError` 替代 `error instanceof Error`,用于 HTTP runner 和 command runner 中的错误类型判断。
#### Scenario: Error 实例识别
- **WHEN** 错误对象为 `new Error("msg")`
- **THEN** `isError(error)` SHALL 返回 true
#### Scenario: Error 子类识别
- **WHEN** 错误对象为继承 Error 的自定义类型
- **THEN** `isError(error)` SHALL 返回 true
#### Scenario: 非 Error 对象识别
- **WHEN** 错误对象为字符串或普通对象
- **THEN** `isError(error)` SHALL 返回 false
### Requirement: 使用 es-toolkit Semaphore 实现并发控制
系统 SHALL 使用 es-toolkit 的 `Semaphore` 类替代手写的信号量实现(计数器 + Promise 队列),用于 ProbeEngine 中的组内并发拨测控制。
#### Scenario: 获取并发槽位
- **WHEN** 当前并发数未达上限
- **THEN** `semaphore.acquire()` SHALL 立即返回,不阻塞
#### Scenario: 等待并发槽位
- **WHEN** 当前并发数已达上限 maxConcurrentChecks
- **THEN** `semaphore.acquire()` SHALL 阻塞等待,直到其他任务调用 `semaphore.release()`
#### Scenario: 释放并发槽位
- **WHEN** 调用 `semaphore.release()`
- **THEN** 系统 SHALL 唤醒一个等待中的 acquire() 调用
### Requirement: 使用 es-toolkit groupBy 实现 target 分组
系统 SHALL 使用 es-toolkit 的 `groupBy` 函数替代手写的 Map 循环分组,用于 ProbeEngine 中按 interval 分组拨测目标。
#### Scenario: 按 interval 分组
- **WHEN** 输入包含不同 intervalMs 值的多个 target
- **THEN** `groupBy(targets, t => t.intervalMs)` SHALL 返回 key 为 intervalMs 值的分组对象,值为对应 target 数组
### Requirement: 使用 Bun 内置 API 进行 Headers 转换
系统 SHALL 使用 `Object.fromEntries(headers)` 标准 Web API 替代手写的 `headersToRecord` 函数,用于将 Fetch API 的 Headers 对象转换为键值对。
#### Scenario: 转换响应头
- **WHEN** HTTP runner 获取到 response headers
- **THEN** `Object.fromEntries(response.headers)` SHALL 返回以 header 名称为 key、header 值为 value 的对象

View File

@@ -0,0 +1,73 @@
## Purpose
定义 ProbeStore 的批量查询方法getLatestChecksMap、getAllTargetStats以及 getSummary 和 createTargetsResponse 的 N+1 查询优化规范。同时约定单次查询操作使用 db.query() 利用内置缓存。
## Requirements
### 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
#### 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** 计算 availabilityupCount / totalChecks * 100
- **THEN** 结果 SHALL 四舍五入保留两位小数
### Requirement: summary 查询使用批量方法
`getSummary` 方法 SHALL 使用 `getLatestChecksMap` 一次性获取所有 target 的最新检查结果,而非对每个 target 逐条查询。
#### Scenario: 统计总览使用批量查询
- **WHEN** 调用 `store.getSummary()`
- **THEN** 系统 SHALL 调用 `getLatestChecksMap()` 一次获取所有最新结果,在内存中遍历统计 up/down 数量,而非循环 N 次调用 `getLatestCheck()`
### Requirement: targets 列表使用批量方法
`createTargetsResponse`app.ts 中生成 TargetStatus[] 的逻辑SHALL 使用 `getLatestChecksMap``getAllTargetStats` 替代逐目标查询 latest checkout、stats 和 samples。
#### Scenario: 目标列表使用批量查询
- **WHEN** 处理 `GET /api/targets` 请求
- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()``getAllTargetStats()` 批量获取数据,在内存中组装 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()`,因需要在事务闭包内持有引用