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 и вкладка «Журнал ошибок» в Настройках.
81 lines
3.3 KiB
TypeScript
81 lines
3.3 KiB
TypeScript
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;
|