From 54cf5bbfa4931df50ee9b67be82e40b1c172153d Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 27 Sep 2026 11:29:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(audit):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D0=BF=D0=BE=20=D1=88?= =?UTF-8?q?=D0=B0=D0=B3=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При сохранении записи журнала (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 обновлены --- AGENTS.md | 11 +++ README.md | 32 +++++++ diff.js | 215 +++++++++++++++++++++++++++++++++++++++++++ diff.selftest.js | 215 +++++++++++++++++++++++++++++++++++++++++++ public/admin.css | 21 +++++ public/audit.html | 6 +- public/js/audit.js | 121 ++++++++++++++++++++++-- public/js/journal.js | 9 ++ server.js | 85 ++++++++++++++++- worker.js | 14 ++- 10 files changed, 713 insertions(+), 16 deletions(-) create mode 100644 diff.js create mode 100644 diff.selftest.js diff --git a/AGENTS.md b/AGENTS.md index f04ba95..e3d612a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) | diff --git a/README.md b/README.md index c4c08df..ad2a627 100644 --- a/README.md +++ b/README.md @@ -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 не хранится) diff --git a/diff.js b/diff.js new file mode 100644 index 0000000..534aae6 --- /dev/null +++ b/diff.js @@ -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 +}; diff --git a/diff.selftest.js b/diff.selftest.js new file mode 100644 index 0000000..5f7d1ca --- /dev/null +++ b/diff.selftest.js @@ -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('