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:
dev
2026-09-27 11:29:17 +03:00
parent 19676c9b64
commit 54cf5bbfa4
10 changed files with 713 additions and 16 deletions
+80 -5
View File
@@ -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 });