DI2: удаление старого модуля финансов — пул БД и аудит перенесены в finance-di2
- server/finance/ (19 файлов) удалён: пул финансовой БД перенесён в server/finance-di2/db-core.ts, db-client.ts теперь реэкспортирует из ./db-core - Шедулер аудита перенесён в server/finance-di2/audit-scheduler.ts (03:00 МСК, setInterval, без node-cron), запуск из registerDi2Routes с защитой от повторного старта - История аудита DI2 теперь персистентна: загрузка/запись data/finance-audit-history.json в audit-agent.ts (порт из удалённого server/finance/audit.ts) - registerFinanceRoutes убран из server/routes/index.ts, registerDi2Routes остался на его месте - Клиент: удалены pages/finance/, components/finance/, hooks/useFinance.ts, hooks/useColumnLabels.ts, lib/format.ts, pages/salary.tsx; роуты /finance*, /income*, /expenses*, /salary и lazy-импорты убраны из App.tsx - Навигация: «Финансы» в Sidebar и MobileBottomNav ведёт на субапп /di2 (внешняя ссылка), гейт moduleAccess.finance + finance.view сохранён - index.css: удалён блок .hover-elevate/.hover-elevate-2/.active-elevate (использовался только старым модульным DataTable) - FinanceBottomNav.tsx сохранён как референс для этапа 3 (.superpowers/sdd/di2-restyle/FinanceBottomNav.reference.tsx) - Не тронуто: client-di2/, сиды прав finance.view/manage, finance_access (module-access)
This commit is contained in:
@@ -47,9 +47,33 @@ interface DirectionAudit {
|
||||
treeBreakdown: { name: string; total: number; count: number; isPrimary: boolean }[];
|
||||
}
|
||||
|
||||
const auditHistory: AuditResult[] = [];
|
||||
const HISTORY_PATH = path.join(process.cwd(), "data", "finance-audit-history.json");
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
let auditHistory: AuditResult[] = [];
|
||||
|
||||
// Персистентность истории аудита (порт из удалённого server/finance/audit.ts):
|
||||
// загрузка при старте модуля, запись после каждого runIntegrityAudit.
|
||||
function loadHistory(): void {
|
||||
if (!fs.existsSync(HISTORY_PATH)) return;
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(HISTORY_PATH, "utf-8"));
|
||||
if (Array.isArray(parsed)) auditHistory = parsed;
|
||||
} catch {
|
||||
// повреждённый файл — начинаем с чистой истории
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(): void {
|
||||
try {
|
||||
fs.writeFileSync(HISTORY_PATH, JSON.stringify(auditHistory, null, 2), "utf-8");
|
||||
} catch (err) {
|
||||
console.error("[AUDIT-AGENT] Ошибка сохранения истории:", err);
|
||||
}
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
|
||||
function loadJSON(filePath: string): any {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
@@ -437,6 +461,7 @@ export async function runIntegrityAudit(): Promise<AuditResult> {
|
||||
|
||||
auditHistory.unshift(result);
|
||||
if (auditHistory.length > MAX_HISTORY) auditHistory.pop();
|
||||
saveHistory();
|
||||
|
||||
console.log(`[AUDIT-AGENT] Аудит завершён: ${result.summary}`);
|
||||
console.log(`[AUDIT-AGENT] Доходы: БД=${income.dbCount} строк, деревья=${income.treeCount}, excluded=${income.excludedCount}, нераспр=${income.uncategorizedCount}, gap=${income.gapCount}`);
|
||||
|
||||
53
server/finance-di2/audit-scheduler.ts
Normal file
53
server/finance-di2/audit-scheduler.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Планировщик аудита целостности DI2: ежедневно в 03:00 (МСК) + первый запуск через 10 сек после старта.
|
||||
// Порт из удалённого server/finance/audit.ts (без node-cron, setInterval-проверка).
|
||||
|
||||
import { runIntegrityAudit, getAuditHistory } from "./audit-agent";
|
||||
|
||||
const TICK_MS = 60_000;
|
||||
const MSK_OFFSET_MS = 3 * 60 * 60 * 1000; // МСК = UTC+3
|
||||
const AUDIT_HOUR_MSK = 3;
|
||||
|
||||
let lastRunDay: string | null = getAuditHistory()[0]?.timestamp
|
||||
? new Date(new Date(getAuditHistory()[0].timestamp).getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10)
|
||||
: null;
|
||||
let schedulerStarted = false;
|
||||
|
||||
function mskToday(): string {
|
||||
return new Date(Date.now() + MSK_OFFSET_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function mskHourNow(): number {
|
||||
return new Date(Date.now() + MSK_OFFSET_MS).getUTCHours();
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
const today = mskToday();
|
||||
if (lastRunDay === today) return; // сегодня уже запускался
|
||||
if (mskHourNow() < AUDIT_HOUR_MSK) return; // ещё не наступило 03:00 по МСК
|
||||
lastRunDay = today;
|
||||
try {
|
||||
await runIntegrityAudit();
|
||||
} catch (err) {
|
||||
console.error("[AUDIT-AGENT] Ошибка при выполнении аудита:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function startAuditScheduler(): void {
|
||||
if (schedulerStarted) return; // защита от повторной регистрации роутов (тесты и т.п.)
|
||||
schedulerStarted = true;
|
||||
|
||||
setInterval(() => {
|
||||
tick().catch((err) => console.error("[AUDIT-AGENT] Tick error:", err));
|
||||
}, TICK_MS);
|
||||
console.log("[AUDIT-AGENT] Планировщик запущен (ежедневно в 03:00 по МСК)");
|
||||
|
||||
// Первый запуск через 10 секунд после старта сервера
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const result = await runIntegrityAudit();
|
||||
lastRunDay = new Date(new Date(result.timestamp).getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10);
|
||||
} catch (err) {
|
||||
console.error("[AUDIT-AGENT] Ошибка при начальном аудите:", err);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Обёртка над существующим пулом финансовой БД iistwin (server/finance/db-client.ts).
|
||||
// DI2-код импортирует эти имена из "./db-client" — второй пул не создаётся.
|
||||
// Обёртка над пулом финансовой БД (./db-core) — DI2-код импортирует эти имена из "./db-client".
|
||||
// Пул перенесён из удалённого server/finance/db-client.ts (единый источник — db-core).
|
||||
|
||||
import type { DbConnectionConfig } from "./schema";
|
||||
import {
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
setConfig,
|
||||
isConnectionError,
|
||||
getPoolClient,
|
||||
} from "../finance/db-client";
|
||||
} from "./db-core";
|
||||
|
||||
export { query, testConnection, setConfig, isConnectionError, getPoolClient };
|
||||
|
||||
// В DI2 getConfig() всегда возвращал конфиг (не null) и включал password.
|
||||
// iistwin-версия отдаёт конфиг без пароля и может вернуть null — приводим к DI2-совместимой форме.
|
||||
// Ядро отдаёт конфиг без пароля и может вернуть null — приводим к DI2-совместимой форме.
|
||||
export function getConfig(): DbConnectionConfig {
|
||||
const cfg = financeGetConfig();
|
||||
if (!cfg) {
|
||||
|
||||
139
server/finance-di2/db-core.ts
Normal file
139
server/finance-di2/db-core.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Pool, type PoolClient, type QueryResult, type QueryResultRow } from "pg";
|
||||
import { readFileSync, existsSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
interface DbConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
ssl?: boolean;
|
||||
}
|
||||
|
||||
const CONFIG_PATH = resolve(process.cwd(), "data", "db-config.json");
|
||||
|
||||
let pool: Pool | null = null;
|
||||
let currentConfig: DbConfig | null = null;
|
||||
|
||||
function loadConfigFromEnv(): DbConfig {
|
||||
return {
|
||||
host: process.env.FINANCE_DB_HOST || process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.FINANCE_DB_PORT || process.env.DB_PORT || "5432", 10),
|
||||
database: process.env.FINANCE_DB_NAME || process.env.DB_NAME || "postgres",
|
||||
user: process.env.FINANCE_DB_USER || process.env.DB_USER || "postgres",
|
||||
password: process.env.FINANCE_DB_PASSWORD || process.env.DB_PASSWORD || "",
|
||||
ssl: process.env.FINANCE_DB_SSL === "true" || process.env.DB_SSL === "true",
|
||||
};
|
||||
}
|
||||
|
||||
function loadConfigFromFile(): DbConfig | null {
|
||||
if (!existsSync(CONFIG_PATH)) return null;
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
||||
const parsed = JSON.parse(raw) as DbConfig;
|
||||
if (parsed.host && parsed.database && parsed.user) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createPool(config: DbConfig): Pool {
|
||||
return new Pool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
ssl: config.ssl ? { rejectUnauthorized: false } : false,
|
||||
max: 10,
|
||||
connectionTimeoutMillis: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPool(): Pool {
|
||||
if (!pool) {
|
||||
const fileConfig = loadConfigFromFile();
|
||||
const envConfig = loadConfigFromEnv();
|
||||
currentConfig = fileConfig || envConfig;
|
||||
if (!currentConfig) {
|
||||
throw new Error("Database configuration not found");
|
||||
}
|
||||
pool = createPool(currentConfig);
|
||||
pool.on("error", (err) => {
|
||||
console.error("Unexpected finance DB pool error:", err);
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function query<T extends QueryResultRow = any>(sql: string, params?: any[]): Promise<QueryResult<T>> {
|
||||
const p = getPool();
|
||||
return p.query<T>(sql, params);
|
||||
}
|
||||
|
||||
// Клиент для ручных транзакций (BEGIN/COMMIT/ROLLBACK) — см. google-auth.ts (синхронизация зарплат)
|
||||
export async function getPoolClient(): Promise<PoolClient> {
|
||||
const p = getPool();
|
||||
return p.connect();
|
||||
}
|
||||
|
||||
export async function testConnection(config: DbConfig): Promise<{ ok: boolean; message: string }> {
|
||||
const testPool = createPool(config);
|
||||
try {
|
||||
const client = await testPool.connect();
|
||||
try {
|
||||
await client.query("SELECT 1");
|
||||
return { ok: true, message: "Подключение успешно" };
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err: any) {
|
||||
return { ok: false, message: err?.message || "Ошибка подключения" };
|
||||
} finally {
|
||||
await testPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export function setConfig(config: DbConfig): void {
|
||||
if (pool) {
|
||||
pool.end().catch(() => {});
|
||||
}
|
||||
pool = null;
|
||||
currentConfig = config;
|
||||
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
||||
pool = createPool(config);
|
||||
pool.on("error", (err) => {
|
||||
console.error("Unexpected finance DB pool error:", err);
|
||||
});
|
||||
}
|
||||
|
||||
export function getConfig(): Omit<DbConfig, "password"> | null {
|
||||
if (!currentConfig) return null;
|
||||
const { password: _password, ...rest } = currentConfig;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export async function isConnected(): Promise<boolean> {
|
||||
try {
|
||||
await query("SELECT 1");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const CONNECTION_ERROR_CODES = new Set(["57P01", "57P02", "57P03", "08000", "08001", "08003", "08006"]);
|
||||
|
||||
export function isConnectionError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const code = (err as any).code;
|
||||
if (code && CONNECTION_ERROR_CODES.has(code)) return true;
|
||||
const msg = err.message.toLowerCase();
|
||||
return msg.includes("econnrefused") || msg.includes("etimedout") || msg.includes("enotfound") ||
|
||||
msg.includes("connection terminated") || msg.includes("connection timeout") ||
|
||||
msg.includes("закрытие подключения") || msg.includes("the database system is shutting down");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { Express } from "express";
|
||||
import express from "express";
|
||||
import { authenticateToken, requirePermission } from "../middleware/auth.middleware";
|
||||
import { registerRoutes } from "./routes";
|
||||
import { startAuditScheduler } from "./audit-scheduler";
|
||||
|
||||
const DI2_PREFIXES = [
|
||||
"/api/income",
|
||||
@@ -52,4 +53,8 @@ export async function registerDi2Routes(app: Express): Promise<void> {
|
||||
await registerRoutes(null as any, router as any);
|
||||
|
||||
app.use(router);
|
||||
|
||||
// Планировщик аудита целостности (03:00 МСК) — запускается один раз благодаря
|
||||
// флагу schedulerStarted внутри startAuditScheduler.
|
||||
startAuditScheduler();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user