feat(audit): показывать изменения текста записи по шагам
При сохранении записи журнала (PUT /api/entries/:id) сравнивается состояние до и после, и в audit_log пишется не только факт правки, но и сами изменения: пословный дифф текста, статистика добавленных и удалённых слов, а также смена ФИО, группы и темы модуля. - diff.js: пословный LCS-дифф без зависимостей, обрезка больших текстов, сборка изменений по полям записи, облегчённый target для списка аудита - source правки: manual / ai / ai_manual / ai_revert; журнал шлёт edit_source, сервер доверяет явному значению и определяет источник по description_ai как запасной вариант - те же диффы пишутся для автопроверки ИИ (entry.ai.auto-check) и отката к оригиналу (entry.ai.revert) - GET /api/audit отдаёт список без diff, GET /api/audit/:id — полный target, чтобы не грузить килобайты текста на каждую строку - Аудит: колонка «Кто», сводка в таблице, модалка с подсветкой удалённого и добавленного текста, «было/стало» для полей - auth.login теперь пишет user_id, иначе колонка «Кто» показывала «система» - diff.selftest.js: 16 тестов диффа; README и AGENTS обновлены
This commit is contained in:
@@ -10,6 +10,7 @@ const { createZipWriter, renderStudentReport } = require('./student-report');
|
||||
|
||||
const { createStorage } = require('./storage');
|
||||
const { createRedis } = require('./redis');
|
||||
const { buildEntryDiff, textDiff, normalizeEditSource, stripDiffs } = require('./diff');
|
||||
|
||||
const https = require('https');
|
||||
const path = require('path');
|
||||
@@ -961,7 +962,7 @@ app.post('/api/auth/login', apiLimiter, async (req, res) => {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||
await pool.query('INSERT INTO sessions (user_id, token, expires_at) VALUES ($1, $2, $3)', [user.id, token, expiresAt.toISOString()]);
|
||||
await logAudit(req, 'auth.login', { username: user.username });
|
||||
await logAudit({ ip: req.ip, user: { id: user.id } }, 'auth.login', { username: user.username });
|
||||
res.json({ token, expires_at: expiresAt.toISOString() });
|
||||
});
|
||||
|
||||
@@ -1305,7 +1306,20 @@ app.get('/api/audit', requireAdmin, async (req, res) => {
|
||||
${where} ORDER BY a.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params
|
||||
);
|
||||
res.json(rows);
|
||||
res.json(rows.map(r => ({ ...r, target: stripDiffs(r.target) })));
|
||||
});
|
||||
|
||||
app.get('/api/audit/:id', requireAdmin, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.id, a.action, a.target, a.ip, a.created_at, a.user_id, u.username AS user_name
|
||||
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE a.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
// --- Backup / Restore ---
|
||||
@@ -4201,6 +4215,42 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
function entryStateWithNames(row, names) {
|
||||
return {
|
||||
student_name: row.student_name,
|
||||
group_id: row.group_id,
|
||||
group_name: names.group_name,
|
||||
module_id: row.module_id,
|
||||
module_name: names.module_name,
|
||||
description: row.description,
|
||||
description_ai: row.description_ai
|
||||
};
|
||||
}
|
||||
|
||||
async function buildEntryUpdateTarget(req, before, after, extra = {}) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
(SELECT name FROM groups WHERE id = $1) AS group_name_before,
|
||||
(SELECT name FROM modules WHERE id = $2) AS module_name_before,
|
||||
(SELECT name FROM groups WHERE id = $3) AS group_name_after,
|
||||
(SELECT name FROM modules WHERE id = $4) AS module_name_after`,
|
||||
[before.group_id, before.module_id, after.group_id, after.module_id]
|
||||
);
|
||||
const n = rows[0] || {};
|
||||
const beforeState = entryStateWithNames(before, { group_name: n.group_name_before, module_name: n.module_name_before });
|
||||
const afterState = entryStateWithNames(after, { group_name: n.group_name_after, module_name: n.module_name_after });
|
||||
const changes = buildEntryDiff(beforeState, afterState);
|
||||
const source = normalizeEditSource(req.body?.edit_source, beforeState, afterState);
|
||||
return {
|
||||
id: before.id,
|
||||
source,
|
||||
changed: changes.length > 0 || (extra.photos || 0) > 0,
|
||||
fields: changes.map(c => c.field),
|
||||
changes,
|
||||
photos_added: extra.photos || 0
|
||||
};
|
||||
}
|
||||
|
||||
app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
@@ -4228,6 +4278,17 @@ app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req,
|
||||
}
|
||||
}
|
||||
|
||||
const beforeRes = await pool.query(
|
||||
`SELECT id, student_name, group_id, module_id, description, description_ai
|
||||
FROM entries WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!beforeRes.rows.length) {
|
||||
newPhotos.forEach(p => safeUnlink(p.path));
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
const before = beforeRes.rows[0];
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE entries SET
|
||||
student_name = COALESCE($1, student_name),
|
||||
@@ -4265,7 +4326,7 @@ app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req,
|
||||
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
|
||||
}
|
||||
|
||||
await logAudit(req, 'entry.update', { id: req.params.id });
|
||||
await logAudit(req, 'entry.update', await buildEntryUpdateTarget(req, before, rows[0], { photos: newPhotos.length }));
|
||||
invalidateEntries();
|
||||
invalidateStats();
|
||||
res.json(rows[0]);
|
||||
@@ -5077,14 +5138,28 @@ app.post('/api/entries/:id/ai/revert', requireAuth, async (req, res) => {
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const beforeRes = await pool.query(
|
||||
'SELECT id, description, description_ai FROM entries WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE entries SET description = description_original, description_ai = NULL,
|
||||
ai_status = 'reverted', ai_error = NULL, ai_checked_at = now()
|
||||
WHERE id = $1 AND description_original IS NOT NULL RETURNING id`,
|
||||
WHERE id = $1 AND description_original IS NOT NULL RETURNING id, description, description_ai`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(400).json({ error: 'Оригинал текста недоступен' });
|
||||
await logAudit(req, 'entry.ai.revert', { id: req.params.id });
|
||||
const before = beforeRes.rows[0];
|
||||
const revertDiff = textDiff(before ? before.description : '', rows[0].description);
|
||||
await logAudit(req, 'entry.ai.revert', {
|
||||
id: rows[0].id,
|
||||
source: 'ai_revert',
|
||||
changed: revertDiff.changed,
|
||||
fields: revertDiff.changed ? ['description'] : [],
|
||||
changes: revertDiff.changed
|
||||
? [{ field: 'description', label: 'Текст работы', stats: revertDiff.stats, diff: revertDiff.segments, truncated: revertDiff.truncated }]
|
||||
: []
|
||||
});
|
||||
invalidateEntries();
|
||||
invalidateStats();
|
||||
res.json({ ok: true });
|
||||
|
||||
Reference in New Issue
Block a user