1
0

refactor: 后端架构加固 — 泛型化、批量查询、bootstrap 统一、路径修复与 pageSize 上限

- CheckerDefinition 泛型化,HTTP/Command checker 移除 resolved target 断言
- 新增 ProbeStore.getAllRecentSamples 消除 targets 路由 N+1 查询
- 统一 getAllTargetStats 与 getTargetStats 的 availability 精度
- Engine rejected 结果写入 internal error 记录,提升可观测性
- 新增 bootstrap.ts 统一 dev/production 启动序列
- dataDir 相对路径改为基于配置文件目录解析
- validatePagination 增加 pageSize 上限 200 校验
- 修复 ErrorBoundary override 标记
- 更新 README/DEVELOPMENT 文档,新增完整测试覆盖
This commit is contained in:
2026-05-13 18:15:46 +08:00
parent 6ea185315f
commit 147a2559ae
30 changed files with 930 additions and 129 deletions

View File

@@ -67,7 +67,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const host = server.host ?? DEFAULT_HOST;
const port = server.port ?? DEFAULT_PORT;
const dataDir = server.dataDir ?? DEFAULT_DATA_DIR;
const dataDir = resolve(configDir, server.dataDir ?? DEFAULT_DATA_DIR);
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
const retentionMs = resolveRetention(runtime);

View File

@@ -1,8 +1,9 @@
import { groupBy, Semaphore } from "es-toolkit";
import { groupBy, isError, Semaphore } from "es-toolkit";
import type { ProbeStore } from "./store";
import type { CheckResult, ResolvedTargetBase } from "./types";
import { errorFailure } from "./expect/failure";
import { checkerRegistry } from "./runner";
const PRUNE_INTERVAL_MS = 3600000;
@@ -64,11 +65,21 @@ export class ProbeEngine {
}),
);
for (const result of results) {
for (const [index, result] of results.entries()) {
if (result.status === "fulfilled") {
this.writeResult(result.value);
} else {
const target = targets[index];
console.warn("探针执行失败:", result.reason);
if (!target) continue;
this.writeResult({
durationMs: null,
failure: errorFailure("internal", "engine", formatReason(result.reason)),
matched: false,
statusDetail: null,
targetName: target.name,
timestamp: new Date().toISOString(),
});
}
}
}
@@ -106,3 +117,7 @@ export class ProbeEngine {
});
}
}
function formatReason(reason: unknown): string {
return isError(reason) ? reason.message : String(reason);
}

View File

@@ -1,8 +1,8 @@
import { isError } from "es-toolkit";
import { resolve } from "node:path";
import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types";
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types";
import { checkDuration } from "../../expect/duration";
@@ -13,15 +13,14 @@ import { commandCheckerSchemas } from "./schema";
import { checkTextRules } from "./text";
import { validateCommandConfig } from "./validate";
export class CommandChecker implements Checker {
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
readonly configKey = "command";
readonly schemas = commandCheckerSchemas;
readonly type = "command";
async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedCommandTarget;
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
@@ -169,7 +168,7 @@ export class CommandChecker implements Checker {
};
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget {
const t = target as RawTargetConfig & { command: CommandTargetConfig; type: "command" };
const commandDefaults = context.defaults["command"] as undefined | { cwd?: string; maxOutputBytes?: string };
@@ -197,8 +196,7 @@ export class CommandChecker implements Checker {
} satisfies ResolvedCommandTarget;
}
serialize(target: ResolvedTargetBase): { config: string; target: string } {
const t = target as ResolvedCommandTarget;
serialize(t: ResolvedCommandTarget): { config: string; target: string } {
const parts = [t.command.exec, ...t.command.args];
return {
config: JSON.stringify({

View File

@@ -1,7 +1,7 @@
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types";
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types";
import { checkDuration } from "../../expect/duration";
@@ -16,15 +16,14 @@ const CHARSET_RE = /charset="?([^";\s]+)"?/i;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
export class HttpChecker implements Checker {
export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
readonly configKey = "http";
readonly schemas = httpCheckerSchemas;
readonly type = "http";
async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedHttpTarget;
async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const expect = t.expect;
const start = performance.now();
@@ -117,7 +116,7 @@ export class HttpChecker implements Checker {
}
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase {
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget {
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults["http"] as
| undefined
@@ -145,8 +144,7 @@ export class HttpChecker implements Checker {
} satisfies ResolvedHttpTarget;
}
serialize(target: ResolvedTargetBase): { config: string; target: string } {
const t = target as ResolvedHttpTarget;
serialize(t: ResolvedHttpTarget): { config: string; target: string } {
return {
config: JSON.stringify({
body: t.http.body,

View File

@@ -3,18 +3,18 @@ import type { TSchema } from "@sinclair/typebox";
import type { ConfigValidationIssue } from "../schema/issues";
import type { CheckResult, DefaultsConfig, RawTargetConfig, ResolvedTargetBase } from "../types";
export type Checker = CheckerDefinition;
export type Checker<TResolved extends ResolvedTargetBase = ResolvedTargetBase> = CheckerDefinition<TResolved>;
export interface CheckerContext {
signal: AbortSignal;
}
export interface CheckerDefinition {
export interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
readonly configKey: string;
execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase;
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
readonly schemas: CheckerSchemas;
serialize(target: ResolvedTargetBase): { config: string; target: string };
serialize(target: TResolved): { config: string; target: string };
readonly type: string;
validate(input: CheckerValidationInput): ConfigValidationIssue[];
}

View File

@@ -57,6 +57,40 @@ export class ProbeStore {
this.db.close();
}
getAllRecentSamples(
limit: number,
): Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
const rows = this.db
.query(
`SELECT target_id, timestamp, duration_ms, matched
FROM (
SELECT
target_id,
timestamp,
duration_ms,
matched,
ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC) as row_num
FROM check_results
)
WHERE row_num <= ?
ORDER BY target_id, timestamp DESC`,
)
.all(limit) as Array<{
duration_ms: null | number;
matched: number;
target_id: number;
timestamp: string;
}>;
const result = new Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>>();
for (const row of rows) {
const samples = result.get(row.target_id) ?? [];
samples.push({ duration_ms: row.duration_ms, matched: row.matched, timestamp: row.timestamp });
result.set(row.target_id, samples);
}
return result;
}
getAllTargetStats(): Map<number, { availability: number; totalChecks: number }> {
const rows = this.db
.query(
@@ -69,7 +103,7 @@ export class ProbeStore {
const result = new Map<number, { availability: number; totalChecks: number }>();
for (const row of rows) {
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 10000) / 100 : 0;
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 100 * 100) / 100 : 0;
result.set(row.target_id, { availability, totalChecks: row.totalChecks });
}
return result;