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:
@@ -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) |
|
||||
|
||||
@@ -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 не хранится)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user