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

83
src/server/bootstrap.ts Normal file
View File

@@ -0,0 +1,83 @@
import { join } from "node:path";
import type { RuntimeMode } from "../shared/api";
import type { StaticAssets } from "./app";
import type { StartServerOptions } from "./server";
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
import { ProbeEngine } from "./checker/engine";
import { ProbeStore } from "./checker/store";
import { startServer } from "./server";
export interface BootstrapDependencies {
createEngine?: (
store: ProbeStore,
targets: ResolvedConfig["targets"],
maxConcurrentChecks: number,
retentionMs: number,
) => BootstrapEngine;
createStore?: (dbPath: string) => ProbeStore;
exit?: (code: number) => never;
loadConfig?: (configPath: string) => Promise<ResolvedConfig>;
logError?: (...data: unknown[]) => void;
onSignal?: (signal: ShutdownSignal, handler: () => void) => void;
startServer?: (options: StartServerOptions) => unknown;
}
export interface BootstrapOptions {
configPath: string;
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
type ShutdownSignal = "SIGINT" | "SIGTERM";
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
const load = dependencies.loadConfig ?? loadConfig;
const createStore = dependencies.createStore ?? ((dbPath: string) => new ProbeStore(dbPath));
const createEngine =
dependencies.createEngine ??
((store: ProbeStore, targets: ResolvedConfig["targets"], maxConcurrentChecks: number, retentionMs: number) =>
new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs));
const serve = dependencies.startServer ?? startServer;
const onSignal =
dependencies.onSignal ??
((signal: ShutdownSignal, handler: () => void) => {
process.on(signal, handler);
});
const exit = dependencies.exit ?? ((code: number) => process.exit(code));
const logError = dependencies.logError ?? console.error;
let store: ProbeStore | undefined;
let engine: BootstrapEngine | undefined;
try {
const config = await load(options.configPath);
store = createStore(join(config.dataDir, "probe.db"));
store.syncTargets(config.targets);
engine = createEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs);
engine.start();
const shutdown = () => {
engine?.stop();
store?.close();
exit(0);
};
onSignal("SIGINT", shutdown);
onSignal("SIGTERM", shutdown);
serve({
config: { host: config.host, port: config.port },
mode: options.mode,
staticAssets: options.staticAssets,
store,
});
} catch (error) {
engine?.stop();
store?.close();
logError("启动失败:", error instanceof Error ? error.message : error);
exit(1);
}
}

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;

View File

@@ -1,32 +1,9 @@
import { loadConfig } from "./checker/config-loader";
import { ProbeEngine } from "./checker/engine";
import { ProbeStore } from "./checker/store";
import { bootstrap } from "./bootstrap";
import { readRuntimeConfig } from "./config";
import { startServer } from "./server";
async function main() {
const { configPath } = readRuntimeConfig();
const config = await loadConfig(configPath);
const store = new ProbeStore(`${config.dataDir}/probe.db`);
store.syncTargets(config.targets);
const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs);
engine.start();
const shutdown = () => {
engine.stop();
store.close();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
startServer({
config: { host: config.host, port: config.port },
mode: "development",
store,
});
await bootstrap({ configPath, mode: "development" });
}
void main().catch((error) => {

View File

@@ -2,6 +2,8 @@ import type { RuntimeMode } from "../shared/api";
import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers";
const MAX_PAGE_SIZE = 200;
export function guardGetHead(method: string, mode: RuntimeMode): null | Response {
if (!allowsGetHead(method)) {
return methodNotAllowedResponse(["GET", "HEAD"], mode);
@@ -29,6 +31,9 @@ export function validatePagination(
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 });
}
if (pageSize > MAX_PAGE_SIZE) {
return jsonResponse(createApiError(`pageSize must not exceed ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 });
}
}
return { page, pageSize };

View File

@@ -7,11 +7,12 @@ export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMo
const targets = store.getTargets();
const latestChecksMap = store.getLatestChecksMap();
const allStats = store.getAllTargetStats();
const allRecentSamples = store.getAllRecentSamples(30);
const result: TargetStatus[] = targets.map((target) => {
const latest = latestChecksMap.get(target.id) ?? null;
const stats = allStats.get(target.id) ?? { availability: 0, totalChecks: 0 };
const recentSamples = store.getRecentSamples(target.id, 30);
const recentSamples = allRecentSamples.get(target.id) ?? [];
return {
group: target.grp,