1
0
Files
DiAL/src/server/checker/store.ts
lanyuanxiaoyao f7facb7232 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 后端开发指引
2026-05-12 15:15:36 +08:00

333 lines
9.5 KiB
TypeScript

import { Database } from "bun:sqlite";
import { mkdirSync as fsMkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
target TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
interval_ms INTEGER NOT NULL,
timeout_ms INTEGER NOT NULL,
expect TEXT,
grp TEXT NOT NULL DEFAULT 'default'
)
`;
const CREATE_CHECK_RESULTS_TABLE = `
CREATE TABLE IF NOT EXISTS check_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL,
timestamp TEXT NOT NULL,
matched INTEGER NOT NULL,
duration_ms REAL,
status_detail TEXT,
failure TEXT,
FOREIGN KEY (target_id) REFERENCES targets(id) ON DELETE CASCADE
)
`;
const CREATE_INDEX = `
CREATE INDEX IF NOT EXISTS idx_check_results_target_timestamp
ON check_results (target_id, timestamp)
`;
export class ProbeStore {
private db: Database;
private closed = false;
constructor(dbPath: string) {
ensureDir(dirname(dbPath));
this.db = new Database(dbPath, { create: true });
this.db.run("PRAGMA journal_mode = WAL");
this.db.run("PRAGMA foreign_keys = ON");
this.db.run(CREATE_TARGETS_TABLE);
this.db.run(CREATE_CHECK_RESULTS_TABLE);
this.db.run(CREATE_INDEX);
}
syncTargets(targets: ResolvedTarget[]): void {
if (this.closed) return;
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{
id: number;
name: string;
}>;
const existingMap = new Map(existingRows.map((r) => [r.name, r.id]));
const configNames = new Set(targets.map((t) => t.name));
const insertStmt = this.db.prepare(
"INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
);
const updateStmt = this.db.prepare(
"UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
);
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
const tx = this.db.transaction(() => {
for (const t of targets) {
const type = t.type;
const target = buildTargetDisplay(t);
const config = buildTargetConfig(t);
const expect = t.expect ? JSON.stringify(t.expect) : null;
if (existingMap.has(t.name)) {
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, existingMap.get(t.name)!);
} else {
insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
}
}
for (const [name, id] of existingMap) {
if (!configNames.has(name)) {
deleteStmt.run(id);
}
}
});
tx();
}
getTargets(): StoredTarget[] {
if (this.closed) return [];
return this.db
.query("SELECT * FROM targets ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id")
.all() as StoredTarget[];
}
getTargetById(id: number): StoredTarget | null {
if (this.closed) return null;
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as StoredTarget | null;
}
insertCheckResult(result: {
targetId: number;
timestamp: string;
matched: boolean;
durationMs: number | null;
statusDetail: string | null;
failure: CheckFailure | null;
}): void {
if (this.closed) return;
this.db
.query(
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(
result.targetId,
result.timestamp,
result.matched ? 1 : 0,
result.durationMs,
result.statusDetail,
result.failure ? JSON.stringify(result.failure) : null,
);
}
getLatestCheck(targetId: number): StoredCheckResult | null {
return this.db
.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1")
.get(targetId) as StoredCheckResult | null;
}
getHistory(
targetId: number,
from: string,
to: string,
page = 1,
pageSize = 20,
): { items: StoredCheckResult[]; total: number; page: number; pageSize: number } {
const countRow = this.db
.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
.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[];
return { items, total: countRow.total, page, pageSize };
}
getTargetStats(targetId: number): {
totalChecks: number;
availability: number;
} {
const row = this.db
.query(
`SELECT
COUNT(*) as totalChecks,
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
FROM check_results
WHERE target_id = ?`,
)
.get(targetId) as { totalChecks: number; upCount: number };
const totalChecks = row.totalChecks;
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
return {
totalChecks,
availability: Math.round(availability * 100) / 100,
};
}
getTrend(
targetId: number,
from: string,
to: string,
): Array<{
hour: string;
avgDurationMs: number | null;
availability: number;
totalChecks: number;
}> {
return this.db
.query(
`SELECT
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
AVG(CASE WHEN matched = 1 THEN duration_ms END) as avgDurationMs,
CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability,
COUNT(*) as totalChecks
FROM check_results
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?
GROUP BY hour
ORDER BY hour`,
)
.all(targetId, from, to) as Array<{
hour: string;
avgDurationMs: number | null;
availability: number;
totalChecks: number;
}>;
}
getSummary(): {
total: number;
up: number;
down: number;
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 = latestChecksMap.get(target.id);
if (latest) {
if (latest.matched) {
up++;
} else {
down++;
}
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
lastCheckTime = latest.timestamp;
}
} else {
down++;
}
}
return {
total: targets.length,
up,
down,
lastCheckTime,
};
}
getRecentSamples(
targetId: number,
limit: number,
): Array<{ timestamp: string; duration_ms: number | null; matched: number }> {
return this.db
.query(
"SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
)
.all(targetId, limit) as Array<{
timestamp: string;
duration_ms: number | null;
matched: number;
}>;
}
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();
}
}
function buildTargetDisplay(t: ResolvedTarget): string {
if (t.type === "http") {
return t.http.url;
}
const parts = [t.command.exec, ...t.command.args];
return `exec ${parts.join(" ")}`;
}
function buildTargetConfig(t: ResolvedTarget): string {
if (t.type === "http") {
return JSON.stringify({
url: t.http.url,
method: t.http.method,
headers: t.http.headers,
body: t.http.body,
maxBodyBytes: t.http.maxBodyBytes,
});
}
return JSON.stringify({
exec: t.command.exec,
args: t.command.args,
cwd: t.command.cwd,
env: t.command.env,
maxOutputBytes: t.command.maxOutputBytes,
});
}
function ensureDir(dir: string): void {
try {
fsMkdirSync(dir, { recursive: true });
} catch {
// already exists
}
}