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

@@ -1,3 +1,4 @@
import { isError } from "es-toolkit";
import type { CheckResult, ResolvedCommandTarget } from "./types";
import { checkCommandExpect } from "./expect/command";
import { errorFailure } from "./expect/failure";
@@ -73,7 +74,7 @@ export async function runCommandCheck(target: ResolvedCommandTarget): Promise<Ch
matched: false,
durationMs,
statusDetail: null,
failure: errorFailure("exitCode", "spawn", error instanceof Error ? error.message : String(error)),
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
};
}

View File

@@ -2,32 +2,31 @@ import type { CheckResult, ResolvedTarget } from "./types";
import type { ProbeStore } from "./store";
import { runHttpCheck } from "./fetcher";
import { runCommandCheck } from "./command-runner";
import { groupBy, Semaphore } from "es-toolkit";
export class ProbeEngine {
private timers: ReturnType<typeof setInterval>[] = [];
private store: ProbeStore;
private targets: ResolvedTarget[];
private targetNameToId: Map<string, number> = new Map();
private maxConcurrentChecks: number;
private running = 0;
private queue: Array<() => void> = [];
private semaphore: Semaphore;
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
this.store = store;
this.targets = targets;
this.maxConcurrentChecks = maxConcurrentChecks ?? 20;
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
this.refreshCache();
}
start(): void {
const groups = this.groupByInterval(this.targets);
const groups = groupBy(this.targets, (t) => t.intervalMs);
for (const [intervalMs, groupTargets] of groups) {
for (const [intervalMs, groupTargets] of Object.entries(groups)) {
void this.probeGroup(groupTargets);
const timer = setInterval(() => {
void this.probeGroup(groupTargets);
}, intervalMs);
}, Number(intervalMs));
this.timers.push(timer);
}
@@ -40,45 +39,14 @@ export class ProbeEngine {
this.timers = [];
}
private groupByInterval(targets: ResolvedTarget[]): Map<number, ResolvedTarget[]> {
const groups = new Map<number, ResolvedTarget[]>();
for (const target of targets) {
const group = groups.get(target.intervalMs) ?? [];
group.push(target);
groups.set(target.intervalMs, group);
}
return groups;
}
private async acquire(): Promise<void> {
if (this.running < this.maxConcurrentChecks) {
this.running++;
return;
}
return new Promise<void>((resolve) => {
this.queue.push(resolve);
});
}
private release(): void {
const next = this.queue.shift();
if (next) {
next();
} else {
this.running--;
}
}
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
const results = await Promise.allSettled(
targets.map(async (target) => {
await this.acquire();
await this.semaphore.acquire();
try {
return await this.runCheck(target);
} finally {
this.release();
this.semaphore.release();
}
}),
);

View File

@@ -3,8 +3,7 @@ import * as cheerio from "cheerio";
import * as xpath from "xpath";
import { DOMParser } from "@xmldom/xmldom";
import { mismatchFailure, errorFailure } from "./failure";
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit";
export function evaluateJsonPath(json: unknown, path: string): unknown {
if (!path.startsWith("$.")) return undefined;
@@ -34,7 +33,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
switch (key) {
case "equals":
if (actual !== expected) return false;
if (!isEqual(actual, expected)) return false;
break;
case "contains":
if (!String(actual).includes(expected as string)) return false;
@@ -44,11 +43,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
break;
case "empty": {
const isEmpty =
actual === null ||
actual === undefined ||
actual === "" ||
(Array.isArray(actual) && actual.length === 0) ||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
if (expected !== isEmpty) return false;
break;
}
@@ -78,7 +73,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
}
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
if (isObject(expected)) {
if (isPlainObject(expected)) {
return applyOperator(actual, expected as ExpectOperator);
}
return applyOperator(actual, { equals: expected as string | number | boolean | null });

View File

@@ -1,14 +1,7 @@
import type { CheckResult, ResolvedHttpTarget } from "./types";
import { checkHttpExpect } from "./expect/http";
import { errorFailure } from "./expect/failure";
function headersToRecord(headers: Headers): Record<string, string> {
const result: Record<string, string> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
import { isError } from "es-toolkit";
export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckResult> {
const timestamp = new Date().toISOString();
@@ -27,7 +20,7 @@ export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckRes
const durationMs = Math.round(performance.now() - start);
const statusCode = response.status;
const responseHeaders = headersToRecord(response.headers);
const responseHeaders = Object.fromEntries(response.headers);
const hasBodyRules = !!(target.expect?.body && target.expect.body.length > 0);
@@ -93,7 +86,7 @@ export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckRes
failure: errorFailure(
"status",
"request",
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
};
}

View File

@@ -112,7 +112,7 @@ export class ProbeStore {
}): void {
if (this.closed) return;
this.db
.prepare(
.query(
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(
@@ -139,12 +139,12 @@ export class ProbeStore {
pageSize = 20,
): { items: StoredCheckResult[]; total: number; page: number; pageSize: number } {
const countRow = this.db
.prepare("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
.query("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
.get(targetId, from, to) as { total: number };
const offset = (page - 1) * pageSize;
const items = this.db
.prepare(
.query(
"SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
)
.all(targetId, from, to, pageSize, offset) as StoredCheckResult[];
@@ -157,7 +157,7 @@ export class ProbeStore {
availability: number;
} {
const row = this.db
.prepare(
.query(
`SELECT
COUNT(*) as totalChecks,
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
@@ -186,7 +186,7 @@ export class ProbeStore {
totalChecks: number;
}> {
return this.db
.prepare(
.query(
`SELECT
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
AVG(CASE WHEN matched = 1 THEN duration_ms END) as avgDurationMs,
@@ -212,12 +212,13 @@ export class ProbeStore {
lastCheckTime: string | null;
} {
const targets = this.getTargets();
const latestChecksMap = this.getLatestChecksMap();
let up = 0;
let down = 0;
let lastCheckTime: string | null = null;
for (const target of targets) {
const latest = this.getLatestCheck(target.id);
const latest = latestChecksMap.get(target.id);
if (latest) {
if (latest.matched) {
@@ -247,7 +248,7 @@ export class ProbeStore {
limit: number,
): Array<{ timestamp: string; duration_ms: number | null; matched: number }> {
return this.db
.prepare(
.query(
"SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
)
.all(targetId, limit) as Array<{
@@ -257,6 +258,38 @@ export class ProbeStore {
}>;
}
getLatestChecksMap(): Map<number, StoredCheckResult> {
const rows = this.db
.query(
`SELECT cr.* FROM check_results cr
INNER JOIN (
SELECT target_id, MAX(timestamp) as max_ts
FROM check_results
GROUP BY target_id
) latest ON cr.target_id = latest.target_id AND cr.timestamp = latest.max_ts`,
)
.all() as StoredCheckResult[];
return new Map(rows.map((r) => [r.target_id, r]));
}
getAllTargetStats(): Map<number, { totalChecks: number; availability: number }> {
const rows = this.db
.query(
`SELECT target_id, COUNT(*) as totalChecks,
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
FROM check_results
GROUP BY target_id`,
)
.all() as Array<{ target_id: number; totalChecks: number; upCount: number }>;
const result = new Map<number, { totalChecks: number; availability: number }>();
for (const row of rows) {
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 10000) / 100 : 0;
result.set(row.target_id, { totalChecks: row.totalChecks, availability });
}
return result;
}
close(): void {
this.closed = true;
this.db.close();

View File

@@ -1,3 +1,5 @@
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api";
export type TargetType = "http" | "command";
export interface ProbeConfig {
@@ -147,22 +149,9 @@ export interface ResolvedCommandConfig {
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget;
export interface CheckFailure {
kind: "error" | "mismatch";
phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr";
path: string;
expected?: unknown;
actual?: unknown;
message: string;
}
export interface CheckResult {
export type { CheckFailure };
export interface CheckResult extends ApiCheckResult {
targetName: string;
timestamp: string;
matched: boolean;
durationMs: number | null;
statusDetail: string | null;
failure: CheckFailure | null;
}
export interface StoredTarget {