feat: чанкинг batch-create >100 задач + журнал ошибок API
B: BatchCreateDialog режет список на чанки ≤100 (лимит сервера), каждый чанк со своим idempotency-key (requestId-N) — повтор после ошибки не создаёт дублей; 10-минутный лок армируется только при полном успехе, а не до запроса (раньше блокировал даже после 400). D: таблица error_logs (миграция 0066), захват ответов >=400 в request middleware (без 401/health/events, дедуп 60с), admin API GET/DELETE /api/error-logs и вкладка «Журнал ошибок» в Настройках.
This commit is contained in:
@@ -18,7 +18,7 @@ import { fileURLToPath } from "url";
|
||||
import { isS3Enabled, ensureS3Bucket, streamFromS3 } from "./utils/s3";
|
||||
import { getKnownEmbeddingDim, detectEmbeddingDimension, type LlmProviderConfig } from "./services/llm-provider";
|
||||
import { decrypt as decryptSecret } from "./crypto";
|
||||
import { fileUploads } from "@shared/schema";
|
||||
import { fileUploads, errorLogs } from "@shared/schema";
|
||||
import { authenticateToken, authenticateFileToken, type AuthenticatedRequest } from "./middleware/auth.middleware";
|
||||
import { EXT_TO_MIME, getFileExt } from "./utils/upload";
|
||||
import crypto from "crypto";
|
||||
@@ -287,12 +287,55 @@ app.use((req, res, next) => {
|
||||
}
|
||||
|
||||
log(logLine);
|
||||
|
||||
// Журнал ошибок API (просмотр админом в Настройках → «Журнал ошибок»)
|
||||
captureErrorLog(req, res.statusCode, path, capturedJsonResponse);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// --- Захват ошибок API в таблицу error_logs ---
|
||||
// Шум исключаем: 401 (просроченные токены/SSE), health-check и SSE-поток.
|
||||
// Дедупликация: одинаковые (method, path, status, message) не чаще раза в 60 сек.
|
||||
const ERROR_LOG_EXCLUDED_PATHS = new Set(['/api/health', '/api/events']);
|
||||
const errorLogDedupe = new Map<string, number>();
|
||||
|
||||
function captureErrorLog(req: Request, statusCode: number, path: string, body?: Record<string, any>) {
|
||||
try {
|
||||
if (statusCode < 400 || statusCode === 401) return;
|
||||
if (ERROR_LOG_EXCLUDED_PATHS.has(path)) return;
|
||||
|
||||
const rawMessage = (body && (body.error || body.message)) || '';
|
||||
const message = String(rawMessage).slice(0, 2000) || `HTTP ${statusCode}`;
|
||||
|
||||
const key = `${req.method} ${path} ${statusCode} ${message.slice(0, 200)}`;
|
||||
const now = Date.now();
|
||||
const lastSeen = errorLogDedupe.get(key) ?? 0;
|
||||
if (now - lastSeen < 60_000) return;
|
||||
errorLogDedupe.set(key, now);
|
||||
// Периодическая чистка map, чтобы не разрастался
|
||||
if (errorLogDedupe.size > 5000) {
|
||||
for (const [k, ts] of errorLogDedupe) {
|
||||
if (now - ts > 60_000) errorLogDedupe.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
db.insert(errorLogs).values({
|
||||
organizationId: authReq.organizationId ?? null,
|
||||
userId: authReq.user?.id ?? null,
|
||||
method: req.method,
|
||||
path: path.slice(0, 500),
|
||||
statusCode,
|
||||
message,
|
||||
}).catch((e: unknown) => console.error('[error-logs] insert failed:', e));
|
||||
} catch (e) {
|
||||
console.error('[error-logs] capture failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Page seeds — canonical JS code stored in server/page-seeds/ as fallback/recovery.
|
||||
* Seeds are applied ONLY when the current DB code contains known critical bugs.
|
||||
|
||||
80
server/routes/error-logs.routes.ts
Normal file
80
server/routes/error-logs.routes.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Router } from "express";
|
||||
import { desc, eq, and, gte, or, ilike, sql } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { errorLogs, users } from "@shared/schema";
|
||||
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Только администратор приложения
|
||||
function requireAppAdmin(req: AuthenticatedRequest, res: any, next: any) {
|
||||
if (req.user?.appRole !== 'admin') {
|
||||
return res.status(403).json({ success: false, error: 'Доступно только администратору' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// GET /api/error-logs?limit=50&offset=0&status=500&search=batch&days=7
|
||||
router.get('/api/error-logs', authenticateToken, requireAppAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const organizationId = req.organizationId!;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const status = req.query.status ? parseInt(req.query.status as string) : undefined;
|
||||
const search = (req.query.search as string || '').trim();
|
||||
const days = req.query.days ? parseInt(req.query.days as string) : undefined;
|
||||
|
||||
const conditions = [eq(errorLogs.organizationId, organizationId)];
|
||||
if (status && !isNaN(status)) {
|
||||
if (status === 400) conditions.push(and(gte(errorLogs.statusCode, 400), sql`${errorLogs.statusCode} < 500`)!);
|
||||
else conditions.push(gte(errorLogs.statusCode, status));
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(or(ilike(errorLogs.path, `%${search}%`), ilike(errorLogs.message, `%${search}%`))!);
|
||||
}
|
||||
if (days && !isNaN(days) && days > 0) {
|
||||
conditions.push(gte(errorLogs.createdAt, new Date(Date.now() - days * 24 * 60 * 60 * 1000)));
|
||||
}
|
||||
const where = and(...conditions);
|
||||
|
||||
const [rows, [{ count }]] = await Promise.all([
|
||||
db.select({
|
||||
id: errorLogs.id,
|
||||
method: errorLogs.method,
|
||||
path: errorLogs.path,
|
||||
statusCode: errorLogs.statusCode,
|
||||
message: errorLogs.message,
|
||||
createdAt: errorLogs.createdAt,
|
||||
userId: errorLogs.userId,
|
||||
userFirstName: users.firstName,
|
||||
userLastName: users.lastName,
|
||||
userEmail: users.email,
|
||||
})
|
||||
.from(errorLogs)
|
||||
.leftJoin(users, eq(errorLogs.userId, users.id))
|
||||
.where(where)
|
||||
.orderBy(desc(errorLogs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`count(*)::int` }).from(errorLogs).where(where),
|
||||
]);
|
||||
|
||||
res.json({ success: true, logs: rows, total: count, limit, offset });
|
||||
} catch (error) {
|
||||
console.error('Get error logs error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при получении журнала ошибок' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/error-logs — очистить журнал организации
|
||||
router.delete('/api/error-logs', authenticateToken, requireAppAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
await db.delete(errorLogs).where(eq(errorLogs.organizationId, req.organizationId!));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Clear error logs error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при очистке журнала ошибок' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -20,6 +20,7 @@ import taskInboxHiddenRouter from "./task-inbox-hidden.routes";
|
||||
import taskBulkRouter from "./task-bulk.routes";
|
||||
import ragRouter from "./rag.routes";
|
||||
import llmProvidersRouter from "./llm-providers.routes";
|
||||
import errorLogsRouter from "./error-logs.routes";
|
||||
import { registerBotRoutes } from "./bots.routes";
|
||||
import { registerExternalRoutes } from "./external.routes";
|
||||
import { registerBotApiRoutes } from "./bot-api.routes";
|
||||
@@ -96,6 +97,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
app.use(taskInboxHiddenRouter);
|
||||
app.use(ragRouter);
|
||||
app.use(llmProvidersRouter);
|
||||
app.use(errorLogsRouter);
|
||||
|
||||
// Domain route registrations
|
||||
registerBotRoutes(app);
|
||||
|
||||
Reference in New Issue
Block a user