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
+11
View File
@@ -174,8 +174,18 @@ node api.smoketest.js # e2e, needs running sta
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
docker compose stop redis && node api.smoketest.js # app must keep working in-memory
docker compose start redis # app reconnects on its own
# Audit diff checks
node diff.selftest.js # unit, no stack needed
```
`diff.js` builds the audit payload for text changes: word-level segments
(`eq`/`del`/`add`), per-step stats (`added_words`, `removed_words`, `chars_before/after`) and a
light `summarizeChanges`/`stripDiffs` pair for the audit list. Two rules to keep:
the diff payload stored in `audit_log.target` must stay capped (it is rendered raw in the audit
UI), and `GET /api/audit` must keep stripping `diff` while `GET /api/audit/:id` returns it —
otherwise the list endpoint ships kilobytes of text per row.
Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
`fallbackOps`, `used_memory_human`, `keys`).
@@ -208,6 +218,7 @@ guards against.
| `storage.js` | Storage abstraction: `local` and `s3` drivers, key normalization, cache/thumb helpers |
| `redis.js` | Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback |
| `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable |
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
| `worker.js` | Background AI auto-check worker for entry messages + photo enhance worker |
| `db/init.sql` | Initial schema (runs on fresh DB) |
+32
View File
@@ -250,9 +250,38 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
- `group_photos` — фотохроника групп
- `share_links` — публичные ссылки-витрины
- `settings` — пары ключ/значение (анти-спам интервал, футер)
- `audit_log` — журнал действий (`action`, `target` JSONB, `ip`, `user_id`)
Схема инициализируется при первом запуске из `db/init.sql`; миграции существующей БД — в `db/migration.sql`.
## Аудит изменений текста
Каждое сохранение записи журнала (`PUT /api/entries/:id`) сравнивает состояние «до» и «после» и пишет в `audit_log` не только факт, но и сами изменения:
```json
{
"id": 363,
"source": "ai",
"changed": true,
"fields": ["description", "group_id"],
"changes": [
{ "field": "description", "label": "Текст работы",
"stats": { "added_words": 5, "removed_words": 2, "chars_before": 75, "chars_after": 97 },
"diff": [{ "type": "del", "text": "учитель" }, { "type": "add", "text": "очень " }] },
{ "field": "group_id", "label": "Группа", "before": "4 · Суббота 9:00", "after": "5 · Суббота 11:30" }
]
}
```
- `source` — источник правки: `manual` (вручную), `ai` (текст принят из подсказки ИИ), `ai_manual` (ИИ + ручная правка), `ai_revert` (откат к оригиналу)
- `diff` — пословный дифф (`eq` / `del` / `add`) с подсветкой в интерфейсе: удалённое зачёркнуто, добавленное выделено
- `stats` — сколько слов и символов добавлено и удалено на этом шаге
- те же данные пишутся для автопроверки ИИ (`entry.ai.auto-check`) и отката (`entry.ai.revert`)
Список `GET /api/audit` отдаёт облегчённый `target` (без `diff`), полный — `GET /api/audit/:id`: страница «Аудит» подгружает его при открытии деталей.
Пословный дифф и сборка изменений вынесены в `diff.js` (без зависимостей, с обрезкой слишком больших текстов), тесты — `node diff.selftest.js`.
## Хранилище файлов
По умолчанию загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`) — это драйвер `local`. Данные БД хранятся в именованном томе `pgdata`.
@@ -437,6 +466,7 @@ node api.smoketest.js # сквозная проверка API (нужен
| `GET/POST/PUT/DELETE` | `/api/branches`, `/api/branches/:id` | admin (список — любой активный) |
| `GET/POST/DELETE` | `/api/settings` | admin |
| `GET` | `/api/audit` | admin |
| `GET` | `/api/audit/:id` | admin (полный target с текстовым диффом) |
| `GET` | `/api/backup`, `POST /api/restore` | admin |
Сессия хранится в таблице `sessions` (срок 30 дней) и кэшируется в Redis на 30 секунд.
@@ -452,6 +482,8 @@ node api.smoketest.js # сквозная проверка API (нужен
├── storage.js # абстракция хранилища: драйверы local и s3
├── redis.js # абстракция Redis: кэш, счётчики, rate limit, pub/sub (с in-memory fallback)
├── redis.selftest.js # тесты слоя Redis, включая деградацию при недоступном сервере
├── diff.js # пословный diff текста и сборка изменений записи для аудита
├── diff.selftest.js # тесты diff.js (вставки, удаления, большие тексты, обрезка)
├── api.smoketest.js # сквозная проверка API по поднятому стеку
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
+215
View File
@@ -0,0 +1,215 @@
const MAX_CELLS = 400000;
const MAX_DIFF_CHARS = 6000;
const MAX_SEGMENTS = 80;
const FIELD_LABELS = {
student_name: 'ФИО ученика',
group_id: 'Группа',
module_id: 'Тема модуля',
description: 'Текст работы'
};
const SIMPLE_FIELDS = ['student_name', 'group_id', 'module_id'];
const NAME_FIELDS = { group_id: 'group_name', module_id: 'module_name' };
function asText(v) {
if (v === null || v === undefined) return '';
return typeof v === 'string' ? v : String(v);
}
function tokenize(text) {
return asText(text).split(/(\s+)/).filter(t => t.length > 0);
}
function compact(segments) {
const out = [];
for (const seg of segments) {
if (!seg.text) continue;
const last = out[out.length - 1];
if (last && last.type === seg.type) last.text += seg.text;
else out.push({ type: seg.type, text: seg.text });
}
return out;
}
function lcsSegments(a, b) {
const n = a.length;
const m = b.length;
if (!n) return m ? [{ type: 'add', text: b.join('') }] : [];
if (!m) return [{ type: 'del', text: a.join('') }];
const w = m + 1;
const dp = new Int32Array((n + 1) * w);
for (let i = n - 1; i >= 0; i--) {
const rowBase = i * w;
const nextBase = (i + 1) * w;
for (let j = m - 1; j >= 0; j--) {
dp[rowBase + j] = a[i] === b[j]
? dp[nextBase + j + 1] + 1
: Math.max(dp[nextBase + j], dp[rowBase + j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { out.push({ type: 'eq', text: a[i] }); i++; j++; }
else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) { out.push({ type: 'del', text: a[i] }); i++; }
else { out.push({ type: 'add', text: b[j] }); j++; }
}
while (i < n) { out.push({ type: 'del', text: a[i] }); i++; }
while (j < m) { out.push({ type: 'add', text: b[j] }); j++; }
return out;
}
function anchoredDiff(a, b) {
let head = 0;
while (head < a.length && head < b.length && a[head] === b[head]) head++;
let tailA = a.length;
let tailB = b.length;
while (tailA > head && tailB > head && a[tailA - 1] === b[tailB - 1]) { tailA--; tailB--; }
const out = head ? [{ type: 'eq', text: a.slice(0, head).join('') }] : [];
const midA = a.slice(head, tailA);
const midB = b.slice(head, tailB);
if (midA.length * midB.length <= MAX_CELLS) {
out.push(...lcsSegments(midA, midB));
} else {
if (midA.length) out.push({ type: 'del', text: midA.join('') });
if (midB.length) out.push({ type: 'add', text: midB.join('') });
}
if (tailA < a.length) out.push({ type: 'eq', text: a.slice(tailA).join('') });
return out;
}
function capSegments(segments) {
const out = [];
let chars = 0;
let truncated = false;
for (const seg of segments) {
const room = MAX_DIFF_CHARS - chars;
if (out.length >= MAX_SEGMENTS || room <= 0) { truncated = true; break; }
if (seg.text.length > room) {
out.push({ type: seg.type, text: seg.text.slice(0, room) });
chars += room;
truncated = true;
break;
}
out.push({ type: seg.type, text: seg.text });
chars += seg.text.length;
}
if (truncated) out.push({ type: 'eq', text: '…' });
return { segments: out, truncated };
}
function countWords(text) {
const t = text.trim();
return t ? t.split(/\s+/).length : 0;
}
function diffStats(segments, before, after) {
let addedChars = 0;
let removedChars = 0;
let addedWords = 0;
let removedWords = 0;
for (const seg of segments) {
if (seg.type === 'add') { addedChars += seg.text.length; addedWords += countWords(seg.text); }
else if (seg.type === 'del') { removedChars += seg.text.length; removedWords += countWords(seg.text); }
}
return {
added_chars: addedChars,
removed_chars: removedChars,
added_words: addedWords,
removed_words: removedWords,
chars_before: before.length,
chars_after: after.length
};
}
function textDiff(beforeRaw, afterRaw) {
const before = asText(beforeRaw);
const after = asText(afterRaw);
if (before === after) {
return { changed: false, segments: [], truncated: false, stats: diffStats([], before, after) };
}
const a = tokenize(before);
const b = tokenize(after);
const raw = a.length * b.length <= MAX_CELLS ? lcsSegments(a, b) : anchoredDiff(a, b);
const full = compact(raw);
const capped = capSegments(full);
return {
changed: true,
segments: capped.segments,
truncated: capped.truncated,
stats: diffStats(full, before, after)
};
}
function displayValue(row, field) {
if (!row) return null;
const value = row[field];
const name = row[NAME_FIELDS[field]];
if (value === null || value === undefined || value === '') return name ? `— (${name})` : null;
if (name) return `${value} · ${name}`;
return String(value);
}
function buildEntryDiff(before, after) {
const changes = [];
for (const field of SIMPLE_FIELDS) {
const prev = displayValue(before, field);
const next = displayValue(after, field);
if (prev !== next) changes.push({ field, label: FIELD_LABELS[field], before: prev, after: next });
}
const beforeText = asText(before && before.description);
const afterText = asText(after && after.description);
if (beforeText !== afterText) {
const d = textDiff(beforeText, afterText);
changes.push({
field: 'description',
label: FIELD_LABELS.description,
stats: d.stats,
diff: d.segments,
truncated: d.truncated
});
}
return changes;
}
function normalizeEditSource(raw, before, after) {
const value = typeof raw === 'string' ? raw.trim() : '';
const beforeText = asText(before && before.description);
const afterText = asText(after && after.description);
const aiText = asText(after && after.description_ai);
const textChanged = beforeText !== afterText;
if (!textChanged) return 'manual';
if (value === 'ai' || value === 'ai_manual') return value;
if (aiText && afterText === aiText) return 'ai';
return 'manual';
}
function summarizeChanges(changes) {
if (!Array.isArray(changes)) return [];
return changes.map(change => {
const item = { field: change.field, label: change.label || change.field };
if ('before' in change) item.before = change.before;
if ('after' in change) item.after = change.after;
if (change.stats) item.stats = change.stats;
if (change.truncated) item.truncated = true;
return item;
});
}
function stripDiffs(target) {
if (!target || typeof target !== 'object' || Array.isArray(target)) return target;
if (!Array.isArray(target.changes)) return target;
return { ...target, changes: summarizeChanges(target.changes) };
}
module.exports = {
FIELD_LABELS,
textDiff,
buildEntryDiff,
normalizeEditSource,
summarizeChanges,
stripDiffs
};
+215
View File
@@ -0,0 +1,215 @@
const assert = require('assert');
const { textDiff, buildEntryDiff, normalizeEditSource, stripDiffs } = require('./diff');
function reconstruct(segments, types) {
return segments.filter(s => types.includes(s.type)).map(s => s.text).join('');
}
function baseEntry(over = {}) {
return {
student_name: 'Иванов Иван',
group_id: 1,
group_name: 'Первый класс',
module_id: 5,
module_name: 'Модуль 1',
description: 'Я сделал проект по окружающему миру и сдал его вчера.',
description_ai: null,
...over
};
}
function testNoChange() {
const d = textDiff('одно и то же', 'одно и то же');
assert.strictEqual(d.changed, false, 'identical text is not changed');
assert.deepStrictEqual(d.segments, [], 'no segments for identical text');
assert.strictEqual(d.stats.added_chars, 0, 'no added chars');
assert.strictEqual(d.stats.removed_chars, 0, 'no removed chars');
}
function testReconstruct() {
const before = 'Он купил хлеб и молоко вчера';
const after = 'Она купила хлеб и молоко сегодня';
const d = textDiff(before, after);
assert.strictEqual(d.changed, true, 'text changed');
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), before, 'before is reconstructible');
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), after, 'after is reconstructible');
assert.ok(d.stats.removed_words >= 1, 'removed words counted');
assert.ok(d.stats.added_words >= 1, 'added words counted');
assert.ok(d.stats.chars_before === before.length, 'chars_before');
assert.ok(d.stats.chars_after === after.length, 'chars_after');
}
function testReconstructInsertOnly() {
const d = textDiff('сделал проект', 'сделал большой проект');
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал проект', 'before is reconstructible');
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал большой проект', 'after is reconstructible');
}
function testReconstructDeleteOnly() {
const d = textDiff('сделал большой проект', 'сделал проект');
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал большой проект', 'before is reconstructible');
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал проект', 'after is reconstructible');
}
function testInsertOnly() {
const d = textDiff('сделал проект', 'сделал большой проект');
assert.strictEqual(d.stats.removed_chars, 0, 'insert removes nothing');
assert.ok(d.stats.added_words === 1, 'one word added');
assert.ok(reconstruct(d.segments, 'add').includes('большой'), 'added word visible');
}
function testDeleteOnly() {
const d = textDiff('сделал большой проект', 'сделал проект');
assert.strictEqual(d.stats.added_chars, 0, 'delete adds nothing');
assert.ok(d.stats.removed_words === 1, 'one word removed');
assert.ok(reconstruct(d.segments, 'del').includes('большой'), 'removed word visible');
}
function testEmptyToText() {
const d = textDiff(null, 'новый текст');
assert.strictEqual(d.changed, true, 'null to text is a change');
assert.strictEqual(reconstruct(d.segments, 'add'), 'новый текст', 'whole text added');
assert.strictEqual(d.stats.added_words, 2, 'both words added');
}
function testTextToEmpty() {
const d = textDiff('старый текст', '');
assert.strictEqual(d.changed, true, 'text to empty is a change');
assert.strictEqual(reconstruct(d.segments, 'del'), 'старый текст', 'whole text removed');
assert.strictEqual(d.stats.removed_words, 2, 'both words removed');
}
function testMultiline() {
const before = 'строка один\nстрока два\nстрока три';
const after = 'строка один\nстрока ДВА\nстрока три';
const d = textDiff(before, after);
assert.strictEqual(d.changed, true, 'multiline changed');
assert.ok(reconstruct(d.segments, 'del').includes('два'), 'old word marked removed');
assert.ok(reconstruct(d.segments, 'add').includes('ДВА'), 'new word marked added');
}
function testLargeTextFallback() {
const before = Array.from({ length: 4000 }, (_, i) => `слово${i}`).join(' ');
const after = before.replace('слово2000 ', 'слово2000И ');
const started = Date.now();
const d = textDiff(before, after);
const elapsed = Date.now() - started;
assert.strictEqual(d.changed, true, 'large text changed');
assert.ok(d.stats.removed_words >= 1 && d.stats.added_words >= 1, 'large diff still counts words');
assert.ok(d.segments.length > 0, 'large diff still has segments');
assert.ok(reconstruct(d.segments, ['eq', 'del']).length > 0, 'large diff keeps removed text');
assert.ok(reconstruct(d.segments, ['eq', 'add']).length > 0, 'large diff keeps added text');
assert.ok(elapsed < 2000, `large text diff is fast (${elapsed}ms)`);
}
function testCapping() {
const before = 'a '.repeat(6000);
const after = 'b '.repeat(6000);
const d = textDiff(before, after);
assert.strictEqual(d.truncated, true, 'huge diff is truncated');
const total = d.segments.reduce((n, s) => n + s.text.length, 0);
assert.ok(total <= 6200, `diff payload is capped (${total} chars)`);
assert.ok(d.stats.removed_words > 100, 'stats are computed on the full text');
}
function testEntryDiffDescription() {
const before = baseEntry();
const after = baseEntry({ description: 'Я сделал проект по окружающему миру и сдал его сегодня.' });
const changes = buildEntryDiff(before, after);
assert.strictEqual(changes.length, 1, 'only description changed');
assert.strictEqual(changes[0].field, 'description', 'field is description');
assert.strictEqual(changes[0].label, 'Текст работы', 'label is human readable');
assert.ok(changes[0].diff.length > 0, 'diff segments present');
assert.strictEqual(changes[0].stats.removed_words, 1, 'one word removed');
assert.strictEqual(changes[0].stats.added_words, 1, 'one word added');
}
function testEntryDiffFields() {
const before = baseEntry();
const after = baseEntry({
student_name: 'Петров Пётр',
group_id: 2,
group_name: 'Второй класс',
module_id: null,
module_name: null
});
const changes = buildEntryDiff(before, after);
const fields = changes.map(c => c.field).sort();
assert.deepStrictEqual(fields, ['group_id', 'module_id', 'student_name'], 'three fields changed');
const group = changes.find(c => c.field === 'group_id');
assert.strictEqual(group.before, '1 · Первый класс', 'group before with name');
assert.strictEqual(group.after, '2 · Второй класс', 'group after with name');
const mod = changes.find(c => c.field === 'module_id');
assert.ok(!mod.after, 'module cleared -> null');
assert.strictEqual(mod.before, '5 · Модуль 1', 'module before with name');
}
function testEntryDiffNoChange() {
const before = baseEntry();
const changes = buildEntryDiff(before, baseEntry());
assert.deepStrictEqual(changes, [], 'no changes detected');
}
function testEditSource() {
const before = baseEntry();
const afterAi = baseEntry({ description: 'Текст от ИИ', description_ai: 'Текст от ИИ' });
assert.strictEqual(normalizeEditSource('ai', before, afterAi), 'ai', 'ai source');
assert.strictEqual(normalizeEditSource('', before, afterAi), 'ai', 'ai detected from description_ai');
assert.strictEqual(normalizeEditSource('ai', before, baseEntry({ description: 'Текст руками', description_ai: 'Текст от ИИ' })), 'ai', 'explicit ai claim is trusted');
assert.strictEqual(normalizeEditSource('ai_manual', before, afterAi), 'ai_manual', 'ai_manual kept');
assert.strictEqual(normalizeEditSource('', before, baseEntry({ description: 'Текст руками' })), 'manual', 'manual by default');
assert.strictEqual(normalizeEditSource('ai', baseEntry(), baseEntry({ group_id: 2 })), 'manual', 'no text change -> manual');
assert.strictEqual(normalizeEditSource('<script>', before, baseEntry({ description: 'x' })), 'manual', 'garbage source ignored');
}
function testStripDiffs() {
const before = baseEntry();
const after = baseEntry({ description: 'Другой текст целиком' });
const target = { id: 1, source: 'manual', changes: buildEntryDiff(before, { ...after, group_id: 2, group_name: 'Два' }) };
const light = stripDiffs(target);
assert.strictEqual(light.changes.length, 2, 'changes preserved');
const desc = light.changes.find(c => c.field === 'description');
assert.ok(desc, 'description change kept');
assert.strictEqual(desc.diff, undefined, 'diff dropped in list payload');
assert.ok(desc.stats, 'stats preserved');
assert.ok(target.changes.find(c => c.field === 'description').diff.length > 0, 'original target still has diff');
const grp = light.changes.find(c => c.field === 'group_id');
assert.ok(grp && 'before' in grp && 'after' in grp, 'simple field before/after kept in list payload');
assert.strictEqual(grp.before, '1 · Первый класс', 'group before kept');
assert.strictEqual(grp.after, '2 · Два', 'group after kept');
assert.strictEqual(stripDiffs(null), null, 'null target');
assert.deepStrictEqual(stripDiffs({ a: 1 }), { a: 1 }, 'target without changes untouched');
assert.strictEqual(stripDiffs({ changes: [] }).changes.length, 0, 'empty changes kept');
}
const tests = [
testNoChange,
testReconstruct,
testReconstructInsertOnly,
testReconstructDeleteOnly,
testInsertOnly,
testDeleteOnly,
testEmptyToText,
testTextToEmpty,
testMultiline,
testLargeTextFallback,
testCapping,
testEntryDiffDescription,
testEntryDiffFields,
testEntryDiffNoChange,
testEditSource,
testStripDiffs
];
let failed = 0;
for (const t of tests) {
try {
t();
console.log('ok ', t.name);
} catch (e) {
failed++;
console.log('FAIL', t.name, '-', e.message);
}
}
console.log(failed ? `\n${failed} из ${tests.length} тестов упали` : `\nВсе ${tests.length} тестов пройдены`);
process.exit(failed ? 1 : 0);
+21
View File
@@ -406,6 +406,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.audit-details:hover{text-decoration:underline}
.audit-details-body{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:60vh;overflow:auto}
.audit-details-body pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text)}
.audit-details-body.audit-loading{opacity:.6}
.audit-details-head{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
.audit-pill{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:3px 10px;font-size:.72rem;color:var(--muted)}
.audit-changes{display:flex;flex-direction:column;gap:12px}
.audit-change{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px}
.audit-change-title{font-weight:600;font-size:.82rem;margin-bottom:6px}
.audit-change-stats{display:flex;flex-wrap:wrap;gap:10px;font-size:.75rem;margin-bottom:8px}
.audit-change-line{font-size:.8rem;line-height:1.6;word-break:break-word}
.audit-tag-del,.audit-tag-add{display:inline-block;min-width:52px;font-size:.7rem;text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border-radius:4px;margin-right:6px}
.audit-tag-del{background:rgba(239,68,68,.14);color:#dc2626}
.audit-tag-add{background:rgba(22,163,74,.14);color:#16a34a}
.audit-stat-add{color:#16a34a;font-weight:600}
.audit-stat-del{color:#dc2626;font-weight:600}
.audit-stat-muted{color:var(--muted)}
.audit-diff{white-space:pre-wrap;word-break:break-word;font-size:.85rem;line-height:1.6;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;max-height:40vh;overflow:auto}
.audit-diff-add{background:rgba(22,163,74,.18);color:#14532d;text-decoration:none;border-radius:3px;padding:0 2px}
.audit-diff-del{background:rgba(239,68,68,.16);color:#7f1d1d;border-radius:3px;padding:0 2px}
.audit-truncated{margin-top:6px;font-size:.72rem;color:#b45309}
.audit-raw{margin-top:12px;font-size:.78rem;color:var(--muted)}
.audit-raw summary{cursor:pointer}
.audit-raw pre{margin-top:8px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:10px}
/* --- AI Correction --- */
.ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px}
+4 -2
View File
@@ -29,6 +29,7 @@
<thead>
<tr>
<th>Время</th>
<th>Кто</th>
<th>Действие</th>
<th>Детали</th>
<th>IP</th>
@@ -42,9 +43,10 @@
</div>
</div>
<div class="modal-overlay" id="auditDetailsModal">
<div class="edit-modal" style="max-width:640px">
<div class="edit-modal" style="max-width:820px">
<h3 id="auditDetailsTitle">Детали действия</h3>
<div class="audit-details-body"><pre id="auditDetailsBody"></pre></div>
<div class="audit-details-head" id="auditDetailsHead"></div>
<div class="audit-details-body" id="auditDetailsBody"></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="cancel" id="auditDetailsCloseBtn">Закрыть</button>
</div>
+113 -8
View File
@@ -77,10 +77,30 @@ let auditPage = 1;
let auditTotal = 0;
let auditRows = [];
const EDIT_SOURCE_LABELS = {
manual: 'Вручную',
ai: 'ИИ',
ai_manual: 'ИИ + правка вручную',
ai_revert: 'Откат к оригиналу (ИИ)'
};
const FIELD_LABELS = {
student_name: 'ФИО ученика',
group_id: 'Группа',
module_id: 'Тема модуля',
description: 'Текст работы'
};
function fmtTime(t) {
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
}
function parseTarget(t) {
if (t === null || t === undefined) return null;
if (typeof t === 'object') return t;
try { return JSON.parse(t); } catch { return null; }
}
function dt(t) {
if (!t) return '';
if (typeof t === 'object') {
@@ -100,6 +120,76 @@ function fullTarget(t) {
} catch { return String(t); }
}
function changeLabel(c) {
return c.label || FIELD_LABELS[c.field] || c.field;
}
function changeSummary(c) {
if (c.field === 'description' && c.stats) return `текст: +${c.stats.added_words}/−${c.stats.removed_words} слов`;
if (c.field === 'description') return 'текст работы';
return changeLabel(c).toLowerCase();
}
function targetSummary(t) {
const o = parseTarget(t);
if (!o) return dt(t);
if (Array.isArray(o.changes)) {
if (!o.changes.length) return esc(`без изменений${o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : ''}`);
const parts = o.changes.map(changeSummary);
if (o.photos_added) parts.push(`фото +${o.photos_added}`);
const src = o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : '';
return esc(parts.join(', ') + src);
}
return dt(t);
}
function renderDiffSegments(segments) {
return (segments || []).map(s => {
const text = esc(s.text);
if (s.type === 'add') return `<ins class="audit-diff-add">${text}</ins>`;
if (s.type === 'del') return `<del class="audit-diff-del">${text}</del>`;
return `<span>${text}</span>`;
}).join('');
}
function renderChange(c) {
const label = changeLabel(c);
if (c.field !== 'description') {
return `<div class="audit-change">
<div class="audit-change-title">${esc(label)}</div>
<div class="audit-change-line"><span class="audit-tag-del">было</span> ${esc(c.before === null || c.before === undefined ? '—' : c.before)}</div>
<div class="audit-change-line"><span class="audit-tag-add">стало</span> ${esc(c.after === null || c.after === undefined ? '—' : c.after)}</div>
</div>`;
}
const s = c.stats || {};
const parts = [];
if (s.added_words || s.added_chars) parts.push(`<span class="audit-stat-add">+${s.added_words || 0} слов (+${s.added_chars || 0} симв.)</span>`);
if (s.removed_words || s.removed_chars) parts.push(`<span class="audit-stat-del">−${s.removed_words || 0} слов (−${s.removed_chars || 0} симв.)</span>`);
if (s.chars_before !== undefined) parts.push(`<span class="audit-stat-muted">было ${s.chars_before} симв. → стало ${s.chars_after} симв.</span>`);
return `<div class="audit-change">
<div class="audit-change-title">${esc(label)}</div>
<div class="audit-change-stats">${parts.join('')}</div>
<div class="audit-diff">${renderDiffSegments(c.diff) || '<span class="audit-stat-muted">нет текстового диффа</span>'}</div>
${c.truncated ? '<div class="audit-truncated">Показана только часть изменений</div>' : ''}
</div>`;
}
function renderTargetDetails(target) {
const o = parseTarget(target);
if (!o) return `<pre>${esc(fullTarget(target))}</pre>`;
if (!Array.isArray(o.changes)) return `<pre>${esc(fullTarget(target))}</pre>`;
const head = [];
if (o.id) head.push(`<span class="audit-pill">Запись #${esc(o.id)}</span>`);
if (o.source) head.push(`<span class="audit-pill">Источник: ${esc(EDIT_SOURCE_LABELS[o.source] || o.source)}</span>`);
head.push(`<span class="audit-pill">Полей затронуто: ${o.changes.length}</span>`);
if (o.photos_added) head.push(`<span class="audit-pill">Добавлено фото: ${o.photos_added}</span>`);
const body = o.changes.length
? o.changes.map(renderChange).join('')
: '<div class="audit-stat-muted">Изменений не зафиксировано.</div>';
return `${head.join('')}<div class="audit-changes">${body}</div>
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
}
function renderPager(container, page, totalPages, onPageChange) {
if (totalPages <= 1) { container.innerHTML = ''; return; }
const pages = [];
@@ -120,21 +210,20 @@ function renderPager(container, page, totalPages, onPageChange) {
async function loadAudit() {
const el = document.getElementById('auditTable').querySelector('tbody');
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Загрузка…</td></tr>';
const p = new URLSearchParams();
p.set('limit', AUDIT_PAGE_SIZE);
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
const action = document.getElementById('auditActionFilter').value;
if (action) p.set('action', action);
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
if (!res.ok) { el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
const rows = await res.json();
// Total count for pagination (approximate: if we got less than page size, we're on last page)
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
auditTotal = totalPages;
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
const filtered = q ? rows.filter(r => `${ACTION_LABELS[r.action] || r.action} ${targetSummary(r.target)}`.toLowerCase().includes(q)) : rows;
if (!filtered.length) {
document.getElementById('auditEmpty').style.display = 'block';
el.innerHTML = '';
@@ -144,8 +233,9 @@ async function loadAudit() {
el.innerHTML = filtered.map((r, i) => `
<tr>
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
<td style="white-space:nowrap">${esc(r.user_name || 'система')}</td>
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
<td><button type="button" class="audit-details" data-audit-idx="${i}">${dt(r.target)}</button></td>
<td><button type="button" class="audit-details" data-audit-idx="${i}">${targetSummary(r.target)}</button></td>
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
</tr>`).join('');
}
@@ -172,14 +262,29 @@ document.getElementById('auditActionFilter').addEventListener('change', () => {
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
document.getElementById('auditTable').addEventListener('click', e => {
document.getElementById('auditTable').addEventListener('click', async e => {
const btn = e.target.closest('.audit-details');
if (!btn) return;
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
if (!r) return;
const modal = document.getElementById('auditDetailsModal');
const headEl = document.getElementById('auditDetailsHead');
const bodyEl = document.getElementById('auditDetailsBody');
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target);
document.getElementById('auditDetailsModal').classList.add('open');
headEl.innerHTML = `<span class="audit-pill">${esc(fmtTime(r.created_at))}</span><span class="audit-pill">${esc(r.user_name || 'система')}</span>${r.ip ? `<span class="audit-pill">IP ${esc(r.ip)}</span>` : ''}`;
bodyEl.innerHTML = renderTargetDetails(r.target);
modal.classList.add('open');
if (!r.id) return;
bodyEl.classList.add('audit-loading');
try {
const res = await fetch(`${API}/api/audit/${r.id}`, { headers: hdr() });
if (!res.ok) return;
const full = await res.json();
bodyEl.innerHTML = renderTargetDetails(full.target);
} catch (err) {
} finally {
bodyEl.classList.remove('audit-loading');
}
});
document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => {
document.getElementById('auditDetailsModal').classList.remove('open');
+9
View File
@@ -19,6 +19,7 @@ let searchTimer = null;
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
let aiCorrecting = false;
let editDeletePhoto = false;
let editAiSuggestion = null;
let linkSettings = null;
async function loadLinkSettings() {
@@ -395,6 +396,7 @@ async function openEdit(id) {
if (!e) return;
editId = id;
editDeletePhoto = false;
editAiSuggestion = null;
fillAutocomplete();
document.getElementById('editName').value = e.student_name;
document.getElementById('editDesc').value = e.description;
@@ -1140,6 +1142,7 @@ function acceptAiCorrect() {
const suggestion = document.getElementById('aiSuggestion').textContent;
if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
document.getElementById('editDesc').value = suggestion;
editAiSuggestion = suggestion.trim();
}
closeAiCorrect();
}
@@ -1190,6 +1193,11 @@ async function recheckAi(id) {
refreshEntryCard(id);
}
function editSourceFor(description) {
if (!editAiSuggestion) return 'manual';
return description === editAiSuggestion ? 'ai' : 'ai_manual';
}
async function saveEdit() {
if (!editId) return;
const name = document.getElementById('editName').value.trim();
@@ -1205,6 +1213,7 @@ async function saveEdit() {
formData.append('group_id', group_id);
formData.append('description', description);
formData.append('module_id', moduleId);
formData.append('edit_source', editSourceFor(description));
const photoFiles = document.getElementById('editPhoto').files;
for (const f of photoFiles) formData.append('photo', f);
+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 });
+13 -1
View File
@@ -6,6 +6,7 @@ const MIN_TEXT_CHARS = 4;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { textDiff, FIELD_LABELS } = require('./diff');
const PHOTO_MAX_ATTEMPTS = 3;
const PHOTO_AI_TIMEOUT_MS = 300000;
@@ -411,7 +412,18 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
if (changed) stats.corrected++; else stats.unchanged++;
stats.last_at = new Date().toISOString();
stats.last_error = null;
if (logAudit) await logAudit(null, 'entry.ai.auto-check', { id: row.id, changed });
if (logAudit) {
const d = textDiff(String(row.description ?? ''), changed ? checked : original);
await logAudit(null, 'entry.ai.auto-check', {
id: row.id,
source: 'ai',
changed,
fields: changed ? ['description'] : [],
changes: changed
? [{ field: 'description', label: FIELD_LABELS.description, stats: d.stats, diff: d.segments, truncated: d.truncated }]
: []
});
}
return true;
} catch (e) {
const message = (e && e.message ? e.message : 'error').slice(0, 500);