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:
@@ -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精心设计并行任务,节省上下文空间,加速任务执行
|
||||
|
||||
78
openspec/specs/api-route-separation/spec.md
Normal file
78
openspec/specs/api-route-separation/spec.md
Normal 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 的客户端路由
|
||||
102
openspec/specs/backend-code-quality/spec.md
Normal file
102
openspec/specs/backend-code-quality/spec.md
Normal 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 的对象
|
||||
73
openspec/specs/batch-data-queries/spec.md
Normal file
73
openspec/specs/batch-data-queries/spec.md
Normal 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** 计算 availability(upCount / 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()`,因需要在事务闭包内持有引用
|
||||
Reference in New Issue
Block a user