diff --git a/db/init.sql b/db/init.sql index a5d614c..eb596c8 100644 --- a/db/init.sql +++ b/db/init.sql @@ -179,6 +179,26 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +CREATE TABLE IF NOT EXISTS photo_jobs ( + id SERIAL PRIMARY KEY, + entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + action VARCHAR(20) NOT NULL DEFAULT 'ai', + params JSONB, + before_path VARCHAR(255), + after_path VARCHAR(255), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + error TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + finished_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id); + +INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') +ON CONFLICT (key) DO NOTHING; + CREATE TABLE IF NOT EXISTS audit_log ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE SET NULL, diff --git a/db/migration.sql b/db/migration.sql index 4fe8bd9..3c53fa3 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -200,4 +200,24 @@ DROP TRIGGER IF EXISTS trg_entries_notify ON entries; CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries FOR EACH ROW EXECUTE FUNCTION notify_entries_changed(); +CREATE TABLE IF NOT EXISTS photo_jobs ( + id SERIAL PRIMARY KEY, + entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + action VARCHAR(20) NOT NULL DEFAULT 'ai', + params JSONB, + before_path VARCHAR(255), + after_path VARCHAR(255), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + error TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + finished_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id); + +INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') +ON CONFLICT (key) DO NOTHING; + ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255); diff --git a/public/journal.html b/public/journal.html index 27b006e..379665b 100644 --- a/public/journal.html +++ b/public/journal.html @@ -27,6 +27,7 @@ .enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; } .enhance-sliders .settings-field input[type=range] { width: 100%; } .enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; } +.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; } @media (max-width: 800px) { .enhance-body { flex-direction: column; } .enhance-sliders { flex: 1 1 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } @@ -215,6 +216,7 @@
+ @@ -224,6 +226,16 @@
+ +
diff --git a/public/js/journal.js b/public/js/journal.js index dbc522d..8b3a5d8 100644 --- a/public/js/journal.js +++ b/public/js/journal.js @@ -673,11 +673,98 @@ function cleanupAiPreview() { function closeEnhance() { cleanupAiPreview(); document.getElementById('enhanceModal').classList.remove('open'); + document.getElementById('photoHistoryModal').classList.remove('open'); enhanceEntryId = null; enhanceSourceImg = null; enhanceAiResultPath = null; } +function photoJobThumb(p) { + if (!p) return ''; + if (p.startsWith('/uploads/.originals/')) return `${API}${p}`; + return `${API}/uploads/thumb/${p.split('/').pop()}`; +} + +const PHOTO_JOB_ACTION_LABELS = { ai: 'ИИ-обработка', enhance: 'Улучшение', restore: 'Возврат оригинала', rollback: 'Откат' }; +const PHOTO_JOB_STATUS_LABELS = { pending: 'В очереди', processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка' }; + +async function loadPhotoHistory() { + if (!enhanceEntryId) return; + const list = document.getElementById('photoHistoryList'); + list.innerHTML = '
Загрузка...
'; + try { + const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs`, { headers: hdr() }); + if (!res.ok) throw new Error('Ошибка загрузки истории'); + const rows = await res.json(); + if (!rows.length) { + list.innerHTML = '
Изменений фотографии пока не было.
'; + return; + } + list.innerHTML = rows.map(r => { + const before = r.before_path ? `было` : '—'; + const after = r.after_path ? `стало` : '—'; + const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback'; + const err = r.error ? `
${esc(r.error)}
` : ''; + const time = r.finished_at || r.created_at; + return `
+
+ ${before} + было +
+
→
+
+ ${after} + стало +
+
+
${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)} + ${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(new Date(time).toLocaleString('ru'))} +
+ ${err} +
+ ${canRollback ? `` : ''} +
`; + }).join(''); + } catch (e) { + list.innerHTML = `
${esc(e.message)}
`; + } +} + +function openPhotoHistory() { + document.getElementById('photoHistoryTitle').textContent = `История изменений фотографии — запись #${enhanceEntryId}`; + document.getElementById('photoHistoryModal').classList.add('open'); + loadPhotoHistory(); +} + +async function rollbackPhotoJob(jobId) { + if (!enhanceEntryId || enhanceSaving) return; + if (!confirm('Откатить фотографию к сохранённой версии? Текущая версия сохранится в истории.')) return; + enhanceSaving = true; + try { + const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${jobId}/rollback`, { method: 'POST', headers: hdr() }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Ошибка отката'); + showToast('Фотография отката'); + document.getElementById('photoHistoryModal').classList.remove('open'); + const mp = data.photo_path; + document.getElementById('enhanceOrig').src = `${API}${mp}`; + showEnhanceResult(false); + setEnhanceLoadingText('Загрузка...'); + const img = new Image(); + img.onload = () => { + enhanceSourceImg = img; + renderEnhancePreview(); + }; + img.onerror = () => setEnhanceLoadingText('Не удалось загрузить фото'); + img.src = `${API}${mp}`; + refreshEntryCard(enhanceEntryId); + } catch (e) { + alert(e.message); + } finally { + enhanceSaving = false; + } +} + function medianFilterPass(ctx, w, h) { const data = ctx.getImageData(0, 0, w, h); const src = data.data; @@ -1257,6 +1344,16 @@ document.getElementById('enhanceAutoBtn').addEventListener('click', autoEnhance) document.getElementById('enhanceAiBtn').addEventListener('click', runPhotoAi); document.getElementById('enhanceRestoreBtn').addEventListener('click', restoreOriginal); document.getElementById('enhanceSaveBtn').addEventListener('click', saveEnhance); +document.getElementById('enhanceHistoryBtn').addEventListener('click', openPhotoHistory); +document.getElementById('photoHistoryCloseBtn').addEventListener('click', () => document.getElementById('photoHistoryModal').classList.remove('open')); +document.getElementById('photoHistoryModal').addEventListener('click', e => { + if (e.target === e.currentTarget) e.currentTarget.classList.remove('open'); +}); +document.getElementById('photoHistoryList').addEventListener('click', e => { + const btn = e.target.closest('[data-job-rollback]'); + if (!btn) return; + rollbackPhotoJob(parseInt(btn.dataset.jobRollback, 10)); +}); document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); }); document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview); diff --git a/public/js/worker.js b/public/js/worker.js index 83fba26..aac7db8 100644 --- a/public/js/worker.js +++ b/public/js/worker.js @@ -16,11 +16,38 @@ const STATUS_BADGE = { }; let state = null; +let photoState = null; +let photoJobRow = null; let refreshTimer = null; let busy = false; +let photoBusy = false; let recentRows = []; let recentPage = 1; +const PHOTO_ACTION_LABELS = { + ai: 'ИИ-обработка', + enhance: 'Улучшение', + restore: 'Возврат оригинала', + rollback: 'Откат', +}; +const PHOTO_STATUS_LABELS = { + pending: 'В очереди', + processing: 'Обрабатывается', + done: 'Готово', + error: 'Ошибка', +}; +const PHOTO_STATUS_BADGE = { + done: 'ai-badge-done', + error: 'ai-badge-error', + processing: 'ai-badge-skipped', +}; + +function thumbUrl(p) { + if (!p) return ''; + if (p.startsWith('/uploads/.originals/')) return `${API}${p}`; + return `${API}/uploads/thumb/${p.split('/').pop()}`; +} + function fmtDur(ms) { if (ms === null || ms === undefined || ms < 0) return '—'; const s = Math.floor(ms / 1000); @@ -190,6 +217,90 @@ function render() { renderIcons(); } +function renderPhoto() { + if (!photoState) return; + const w = photoState.worker || {}; + document.getElementById('photoStateHint').textContent = !photoState.enabled + ? 'Обработка фотографий выключена' + : (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди')); + document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер'; + const c = photoState.counts || {}; + document.getElementById('photoCounts').innerHTML = [ + ['pending', 'В очереди', c.pending || 0], + ['processing', 'Обрабатывается', c.processing || 0], + ['done', 'Готово', c.done || 0], + ['error', 'Ошибка', c.error || 0], + ].map(([cls, label, n]) => `${label}: ${n}`).join(''); + const rows = photoState.recent || []; + const tbody = document.querySelector('#photoTable tbody'); + const empty = document.getElementById('photoEmpty'); + if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; } + empty.style.display = 'none'; + tbody.innerHTML = rows.map(r => { + const before = r.before_path ? `было` : '—'; + const after = r.after_path ? `стало` : '—'; + const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback'; + const badge = `${esc(PHOTO_STATUS_LABELS[r.status] || r.status)}`; + return ` + ${fmtTime(r.finished_at)} + #${r.entry_id} ${esc(r.student_name || '')} + ${esc(PHOTO_ACTION_LABELS[r.action] || r.action)} + ${badge} + ${before} + ${after} + ${r.error ? esc(r.error) : ''} + + + ${canRollback ? `` : ''} + + `; + }).join(''); + renderIcons(); +} + +function findPhotoJob(id) { + return (photoState && photoState.recent || []).find(r => r.id === id) || null; +} + +function setPhotoJobClip(p) { + const v = Math.min(100, Math.max(0, p)); + document.getElementById('photoJobTop').style.clipPath = `inset(0 0 0 ${v}%)`; + document.getElementById('photoJobDivider').style.left = v + '%'; +} + +function openPhotoJob(r) { + photoJobRow = r; + document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`; + document.getElementById('photoJobBefore').src = thumbUrl(r.before_path); + document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : ''; + document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none'; + setPhotoJobClip(50); + document.getElementById('photoJobModal').classList.add('open'); +} + +async function loadPhoto() { + if (photoBusy) return; + photoBusy = true; + try { + const res = await fetch(`${API}/api/photo-jobs/status`, { headers: hdr() }); + if (!res.ok) return; + photoState = await res.json(); + renderPhoto(); + } catch { + } finally { + photoBusy = false; + } +} + +async function postPhoto(path_, body) { + const opts = { method: 'POST', headers: body ? hdrJson() : hdr() }; + if (body) opts.body = JSON.stringify(body); + const res = await fetch(`${API}${path_}`, opts); + const data = await res.json().catch(() => ({})); + if (!res.ok) { showToast(data.error || 'Ошибка'); return null; } + return data; +} + async function load() { if (busy) return; busy = true; @@ -231,15 +342,28 @@ async function requeueFailed() { function setAutoRefresh(on) { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } - if (on) refreshTimer = setInterval(load, AUTO_REFRESH_MS); + if (on) refreshTimer = setInterval(() => { load(); loadPhoto(); }, AUTO_REFRESH_MS); } -document.getElementById('refreshBtn').addEventListener('click', load); +document.getElementById('refreshBtn').addEventListener('click', () => { load(); loadPhoto(); }); document.getElementById('wakeBtn').addEventListener('click', wake); document.getElementById('toggleBtn').addEventListener('click', toggleEnabled); document.getElementById('requeueBtn').addEventListener('click', requeueFailed); document.getElementById('recentFilter').addEventListener('change', () => { recentPage = 1; renderRecent(); }); document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked)); +document.getElementById('photoWakeBtn').addEventListener('click', async () => { + if (await postPhoto('/api/photo-jobs/wake')) { showToast('Фото-воркер разбужен'); loadPhoto(); } +}); +document.getElementById('photoToggleBtn').addEventListener('click', async () => { + const next = !(photoState && photoState.enabled); + const data = await postPhoto('/api/photo-jobs/enabled', { enabled: next }); + if (data) { showToast(next ? 'Обработка фото включена' : 'Обработка фото выключена'); loadPhoto(); } +}); +document.getElementById('photoRequeueBtn').addEventListener('click', async () => { + if (!confirm('Вернуть все задания с ошибкой в очередь на повторную обработку?')) return; + const data = await postPhoto('/api/photo-jobs/requeue-failed'); + if (data) { showToast(`В очередь возвращено: ${data.count}`); loadPhoto(); } +}); document.getElementById('recentTable').addEventListener('click', e => { const btn = e.target.closest('.ai-details'); @@ -258,10 +382,63 @@ document.getElementById('aiDetailModal').addEventListener('click', e => { if (e.target === e.currentTarget) e.currentTarget.classList.remove('open'); }); +document.getElementById('photoTable').addEventListener('click', async e => { + const viewBtn = e.target.closest('[data-photo-view]'); + if (viewBtn) { + const r = findPhotoJob(parseInt(viewBtn.dataset.photoView, 10)); + if (r) openPhotoJob(r); + return; + } + const rb = e.target.closest('[data-photo-rollback]'); + if (rb) { + const entryId = rb.dataset.photoEntry; + const jobId = rb.dataset.photoRollback; + if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return; + const data = await postPhoto(`/api/entries/${entryId}/photo/jobs/${jobId}/rollback`); + if (data) { showToast('Фотография отката'); loadPhoto(); } + } +}); + +document.getElementById('photoJobCloseBtn').addEventListener('click', () => { + document.getElementById('photoJobModal').classList.remove('open'); +}); +document.getElementById('photoJobModal').addEventListener('click', e => { + if (e.target === e.currentTarget) e.currentTarget.classList.remove('open'); +}); +document.getElementById('photoJobRollbackBtn').addEventListener('click', async () => { + const r = photoJobRow; + if (!r) return; + if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return; + const data = await postPhoto(`/api/entries/${r.entry_id}/photo/jobs/${r.id}/rollback`); + if (data) { + showToast('Фотография отката'); + document.getElementById('photoJobModal').classList.remove('open'); + loadPhoto(); + } +}); + +(function initPhotoJobDrag() { + const area = document.getElementById('photoJobCompare'); + let dragging = false; + const move = ev => { + const rect = area.getBoundingClientRect(); + setPhotoJobClip(((ev.clientX - rect.left) / rect.width) * 100); + }; + area.addEventListener('pointerdown', ev => { + dragging = true; + area.setPointerCapture(ev.pointerId); + move(ev); + }); + area.addEventListener('pointermove', ev => { if (dragging) move(ev); }); + area.addEventListener('pointerup', () => { dragging = false; }); + area.addEventListener('pointercancel', () => { dragging = false; }); +})(); + (async () => { if (await requireAdminPage()) { buildSidebar(document.body.dataset.page); load(); + loadPhoto(); } })(); diff --git a/public/worker.html b/public/worker.html index 30bbb2f..e6a0846 100644 --- a/public/worker.html +++ b/public/worker.html @@ -5,6 +5,21 @@ Воркер ИИ — Админ-панель +
@@ -24,6 +39,8 @@ + +
@@ -78,6 +95,28 @@
+
+
+
+

Обработка фотографий

+
+
+ + + +
+
+
+ + + + + +
ВремяЗаписьДействиеСтатусБылоСталоОшибка
+ +
+
+ +
diff --git a/server.js b/server.js index 0ec626e..1c6733c 100644 --- a/server.js +++ b/server.js @@ -5,7 +5,7 @@ const rateLimit = require('express-rate-limit'); const helmet = require('helmet'); const bcrypt = require('bcrypt'); const heicConvert = require('heic-convert'); -const { createEntryAutoChecker } = require('./worker'); +const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker'); const https = require('https'); const path = require('path'); @@ -300,6 +300,14 @@ app.get('/uploads/thumb/:name', fileLimiter, async (req, res) => { return sendImageThumb(res, fp); }); +app.get('/uploads/.originals/:name', fileLimiter, async (req, res) => { + const name = req.params.name; + if (!/^[A-Za-z0-9._-]+$/.test(name)) return res.status(400).end(); + const fp = path.join(ORIGINALS_DIR, name); + if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) return res.status(404).end(); + return sendImageThumb(res, fp); +}); + app.use((req, res, next) => { const p = req.path; if (!p.startsWith('/uploads') && !p.startsWith('/vendor')) { @@ -702,6 +710,25 @@ async function ensureEntryAiColumns() { } } +async function ensurePhotoJobsTable() { + await pool.query(`CREATE TABLE IF NOT EXISTS photo_jobs ( + id SERIAL PRIMARY KEY, + entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + action VARCHAR(20) NOT NULL DEFAULT 'ai', + params JSONB, + before_path VARCHAR(255), + after_path VARCHAR(255), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + error TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + finished_at TIMESTAMPTZ + )`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending'`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id)`); + await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') ON CONFLICT (key) DO NOTHING`); +} + async function ensureUserTables() { await pool.query(`CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, @@ -1071,6 +1098,7 @@ const PHOTO_AI_URL = process.env.PHOTO_AI_URL || ''; const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf'; const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'; let entryAutoChecker = null; +let photoWorker = null; async function getAiPrompt() { const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT); @@ -3678,7 +3706,7 @@ function clampEnhanceParam(v, min, max, def) { return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def; } -async function swapEntryPhotoFiles(req, entryId, oldPath, newPath, { keepOriginal = true } = {}) { +async function swapEntryPhotoFiles(req, entryId, oldPath, newPath, { keepOriginal = true, action = 'enhance' } = {}) { let originalPath = null; const { rows: prevRows } = await pool.query('SELECT photo_original_path FROM entries WHERE id = $1', [entryId]); const prevOriginal = prevRows.length ? prevRows[0].photo_original_path : null; @@ -3702,6 +3730,10 @@ async function swapEntryPhotoFiles(req, entryId, oldPath, newPath, { keepOrigina await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, entryId, oldPath]); const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp'); safeUnlink(oldThumb); + await pool.query( + 'INSERT INTO photo_jobs (entry_id, action, status, before_path, after_path, finished_at) VALUES ($1, $2, $3, $4, $5, now())', + [entryId, action, 'done', originalPath, newPath] + ); await logAudit(req, 'entry.photo.enhance', { entry_id: entryId, old_path: oldPath, new_path: newPath, original_path: originalPath }); invalidateEntries(); } @@ -3757,7 +3789,7 @@ app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => { await convertPhoto(req.file); newPath = `/uploads/${req.file.filename}`; } - await swapEntryPhotoFiles(req, req.params.id, oldPath, newPath); + await swapEntryPhotoFiles(req, req.params.id, oldPath, newPath, { action: 'enhance' }); res.json({ ok: true, photo_path: newPath, engine }); } catch (e) { if (req.file) safeUnlink(req.file.path); @@ -3766,37 +3798,6 @@ app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => { } }); -const photoAiJobs = new Map(); -let photoAiChain = Promise.resolve(); - -async function runPhotoAiJob(job) { - job.status = 'processing'; - try { - const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entryId]); - if (!rows.length || !rows[0].photo_path) throw new Error('У записи нет фото'); - const photoPath = rows[0].photo_path; - const srcAbs = path.join(UPLOADS_DIR, photoPath.replace(/^\/+/, '').replace(/^uploads\//, '')); - const buf = fs.readFileSync(srcAbs); - const fd = new FormData(); - fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg'); - fd.append('scale', '2'); - const resp = await fetch(PHOTO_AI_URL.replace(/\/+$/, '') + '/enhance', { - method: 'POST', - body: fd, - signal: AbortSignal.timeout(300000), - }); - if (!resp.ok) throw new Error('AI service error: ' + resp.status); - const out = Buffer.from(await resp.arrayBuffer()); - const newName = crypto.randomBytes(12).toString('hex') + '.jpg'; - fs.writeFileSync(path.join(UPLOADS_DIR, newName), out); - job.photo_path = `/uploads/${newName}`; - job.status = 'done'; - } catch (e) { - job.status = 'error'; - job.error = e.message || String(e); - } -} - app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => { if (!PHOTO_AI_URL) return res.status(503).json({ error: 'ИИ-обработка фото не настроена' }); if (req.user.role !== 'admin') { @@ -3807,32 +3808,106 @@ app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => { const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]); if (!cur.length) return res.status(404).json({ error: 'Запись не найдена' }); if (!cur[0].photo_path) return res.status(400).json({ error: 'У записи нет фото' }); - const job = { - id: crypto.randomBytes(8).toString('hex'), - entryId: parseInt(req.params.id, 10), - req: { user: req.user, ip: req.ip }, - status: 'pending', - created_at: new Date().toISOString(), - }; - photoAiJobs.set(job.id, job); - for (const [id, j] of photoAiJobs) { - if (j.status === 'done' || j.status === 'error') { - if (Date.now() - new Date(j.created_at).getTime() > 3600000) photoAiJobs.delete(id); - } + try { + const { rows } = await pool.query( + `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1, 'ai', 'pending', NULL) RETURNING id`, + [req.params.id] + ); + if (photoWorker) photoWorker.notify(); + await logAudit(req, 'entry.photo.enhance-ai.queue', { entry_id: req.params.id, job_id: rows[0].id }); + res.json({ jobId: rows[0].id }); + } catch (e) { + console.error('POST /api/entries/:id/photo/enhance-ai:', e); + res.status(500).json({ error: 'Ошибка постановки задания в очередь' }); } - photoAiChain = photoAiChain.then(() => runPhotoAiJob(job)).catch(() => {}); - res.json({ jobId: job.id }); }); app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res) => { - const job = photoAiJobs.get(req.params.jobId); - if (!job || job.entryId !== parseInt(req.params.id, 10)) return res.status(404).json({ error: 'Задание не найдено' }); + const jobId = parseInt(req.params.jobId, 10); + if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' }); + const { rows } = await pool.query( + 'SELECT status, error, after_path FROM photo_jobs WHERE id = $1 AND entry_id = $2', + [jobId, req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: 'Задание не найдено' }); + const job = rows[0]; const out = { status: job.status }; if (job.status === 'error') out.error = job.error; - if (job.status === 'done') out.photo_path = job.photo_path; + if (job.status === 'done') out.photo_path = job.after_path; res.json(out); }); +app.get('/api/entries/:id/photo/jobs', requireAuth, async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } + const { rows } = await pool.query( + `SELECT id, action, status, params, before_path, after_path, error, created_at, finished_at + FROM photo_jobs WHERE entry_id = $1 ORDER BY id DESC LIMIT 50`, + [req.params.id] + ); + res.json(rows); +}); + +app.post('/api/entries/:id/photo/jobs/:jobId/rollback', requireAuth, async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } + try { + const jobId = parseInt(req.params.jobId, 10); + if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' }); + const { rows: jobRows } = await pool.query( + `SELECT id, before_path FROM photo_jobs WHERE id = $1 AND entry_id = $2 AND status = 'done'`, + [jobId, req.params.id] + ); + if (!jobRows.length) return res.status(404).json({ error: 'Задание не найдено' }); + const job = jobRows[0]; + if (!job.before_path || !job.before_path.startsWith('/uploads/.originals/')) { + return res.status(400).json({ error: 'Нет сохранённой версии для отката' }); + } + const beforeName = path.basename(job.before_path); + if (!/^[A-Za-z0-9._-]+$/.test(beforeName)) return res.status(400).json({ error: 'Некорректный путь' }); + const beforeAbs = path.join(ORIGINALS_DIR, beforeName); + if (!fs.existsSync(beforeAbs)) return res.status(400).json({ error: 'Файл версии не найден' }); + const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]); + if (!cur.length) return res.status(404).json({ error: 'Запись не найдена' }); + const oldPath = cur[0].photo_path; + if (!oldPath) return res.status(400).json({ error: 'У записи нет фото' }); + const ext = path.extname(beforeName) || '.jpg'; + const newPath = `/uploads/${crypto.randomBytes(12).toString('hex')}${ext}`; + fs.copyFileSync(beforeAbs, path.join(UPLOADS_DIR, path.basename(newPath))); + const oldAbs = path.join(UPLOADS_DIR, String(oldPath).replace(/^\/+/, '').replace(/^uploads\//, '')); + let rollbackBefore = null; + if (fs.existsSync(oldAbs)) { + try { + const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg'); + fs.renameSync(oldAbs, path.join(ORIGINALS_DIR, backupName)); + rollbackBefore = `/uploads/.originals/${backupName}`; + } catch (e) { + console.error('photo rollback backup failed:', e); + } + } + const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp'); + safeUnlink(oldThumb); + await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, req.params.id]); + await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]); + await pool.query( + 'INSERT INTO photo_jobs (entry_id, action, status, before_path, after_path, error, finished_at) VALUES ($1, $2, $3, $4, $5, NULL, now())', + [req.params.id, 'rollback', 'done', rollbackBefore, newPath] + ); + await logAudit(req, 'entry.photo.rollback', { entry_id: req.params.id, job_id: jobId, restored_path: newPath }); + invalidateEntries(); + res.json({ ok: true, photo_path: newPath }); + } catch (e) { + console.error('POST /api/entries/:id/photo/jobs/:jobId/rollback:', e); + res.status(500).json({ error: 'Ошибка отката фотографии' }); + } +}); + app.delete('/api/entries/:id/photo/enhance-ai/preview', requireAuth, async (req, res) => { const { path: p } = req.body || {}; if (!isSafeUploadPath(p)) return res.status(400).json({ error: 'Некорректный путь' }); @@ -3861,9 +3936,22 @@ app.post('/api/entries/:id/photo/restore-original', requireAuth, async (req, res await pool.query('UPDATE entries SET photo_path = $1, photo_original_path = NULL WHERE id = $2', [newPath, req.params.id]); await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]); const oldAbs = path.join(UPLOADS_DIR, String(oldPath).replace(/^\/+/, '').replace(/^uploads\//, '')); - try { if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs); } catch {} + let beforePath = null; + if (fs.existsSync(oldAbs)) { + try { + const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg'); + fs.renameSync(oldAbs, path.join(ORIGINALS_DIR, backupName)); + beforePath = `/uploads/.originals/${backupName}`; + } catch (e) { + console.error('photo original backup failed:', e); + } + } const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp'); safeUnlink(oldThumb); + await pool.query( + 'INSERT INTO photo_jobs (entry_id, action, status, before_path, after_path, error, finished_at) VALUES ($1, $2, $3, $4, $5, NULL, now())', + [req.params.id, 'restore', 'done', beforePath, newPath] + ); await logAudit(req, 'entry.photo.restore_original', { entry_id: req.params.id, restored_path: newPath }); invalidateEntries(); res.json({ ok: true, photo_path: newPath }); @@ -4232,6 +4320,82 @@ app.post('/api/entries/:id/ai/revert', requireAuth, async (req, res) => { res.json({ ok: true }); }); +app.get('/api/photo-jobs/status', requireAdmin, async (_, res) => { + const { rows } = await pool.query( + `SELECT status, count(*)::int AS n FROM photo_jobs GROUP BY status` + ); + const counts = { pending: 0, processing: 0, done: 0, error: 0 }; + rows.forEach(r => { counts[r.status] = r.n; }); + const [pending, recent, errors] = await Promise.all([ + pool.query( + `SELECT j.id, j.entry_id, j.action, j.created_at, e.student_name, g.name AS group_name + FROM photo_jobs j + JOIN entries e ON e.id = j.entry_id + JOIN groups g ON g.id = e.group_id + WHERE j.status IN ('pending', 'processing') + ORDER BY j.id ASC LIMIT 20` + ), + pool.query( + `SELECT j.id, j.entry_id, j.action, j.status, j.params, j.before_path, j.after_path, j.error, j.created_at, j.finished_at, + e.student_name, g.name AS group_name + FROM photo_jobs j + JOIN entries e ON e.id = j.entry_id + JOIN groups g ON g.id = e.group_id + WHERE j.finished_at IS NOT NULL + ORDER BY j.finished_at DESC LIMIT 30` + ), + pool.query( + `SELECT j.id, j.entry_id, j.action, j.error, j.finished_at, e.student_name, g.name AS group_name + FROM photo_jobs j + JOIN entries e ON e.id = j.entry_id + JOIN groups g ON g.id = e.group_id + WHERE j.status = 'error' + ORDER BY j.finished_at DESC NULLS LAST, j.id DESC LIMIT 20` + ), + ]); + const enabled = String(await getSetting('photo_worker_enabled', 'true')) !== 'false'; + const service = await aiHealthCheck(); + res.json({ + enabled, + ai_configured: !!PHOTO_AI_URL, + ai_url: PHOTO_AI_URL, + worker: photoWorker ? photoWorker.getInfo() : null, + counts, + pending: pending.rows, + recent: recent.rows, + errors: errors.rows, + }); +}); + +app.post('/api/photo-jobs/wake', requireAdmin, async (req, res) => { + if (photoWorker) photoWorker.notify(); + await logAudit(req, 'photo-jobs.wake', {}); + res.json({ ok: true }); +}); + +app.post('/api/photo-jobs/enabled', requireAdmin, async (req, res) => { + const enabled = !!req.body?.enabled; + await pool.query( + `INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', $1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [enabled ? 'true' : 'false'] + ); + if (enabled && photoWorker) photoWorker.notify(); + await logAudit(req, 'photo-jobs.enabled', { enabled }); + res.json({ ok: true, enabled }); +}); + +app.post('/api/photo-jobs/requeue-failed', requireAdmin, async (req, res) => { + const { rowCount } = await pool.query( + `UPDATE photo_jobs SET status = 'pending', error = NULL, finished_at = NULL + WHERE status = 'error'` + ); + if (photoWorker) photoWorker.notify(); + await logAudit(req, 'photo-jobs.requeue-failed', { count: rowCount }); + invalidateEntries(); + res.json({ ok: true, count: rowCount }); +}); + app.get('/api/trash', requireAuth, async (req, res) => { const { limit, offset } = req.query; const bw = branchScope(req.user); @@ -4351,8 +4515,21 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); } try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); } try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); } + try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); } try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); } entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT }); entryAutoChecker.start(); console.log('AI auto-check worker started'); + photoWorker = createPhotoEnhanceWorker({ + pool, + getSetting, + logAudit, + invalidateEntries, + sharp, + photoAiUrl: PHOTO_AI_URL, + uploadsDir: UPLOADS_DIR, + originalsDir: ORIGINALS_DIR, + }); + photoWorker.start(); + console.log('Photo enhance worker started'); })(); diff --git a/worker.js b/worker.js index 0c14380..0bb5662 100644 --- a/worker.js +++ b/worker.js @@ -3,6 +3,261 @@ const IDLE_MAX_MS = 60000; const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10); const MAX_INPUT_CHARS = 2000; const MIN_TEXT_CHARS = 4; +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const PHOTO_MAX_ATTEMPTS = 3; +const PHOTO_AI_TIMEOUT_MS = 300000; + +function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, originalsDir }) { + const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || ''; + const IDLE_MIN = 2000; + const IDLE_MAX = 30000; + + let started = false; + let stopped = false; + let idleMs = IDLE_MIN; + let wake = null; + let processing = false; + let currentId = null; + let startedAt = null; + const stats = { jobs: 0, done: 0, errors: 0, last_at: null, last_error: null }; + const CONFIG = { + idle_min_ms: IDLE_MIN, + idle_max_ms: IDLE_MAX, + ai_timeout_ms: PHOTO_AI_TIMEOUT_MS, + max_attempts: PHOTO_MAX_ATTEMPTS, + ai_url: AI_URL, + }; + + function sleep(ms) { + return new Promise((resolve) => { + const t = setTimeout(() => { wake = null; resolve(); }, ms); + wake = () => { clearTimeout(t); wake = null; resolve(); }; + }); + } + + function notify() { + if (wake) wake(); + } + + async function isEnabled() { + const v = await getSetting('photo_worker_enabled', 'true'); + return String(v) !== 'false'; + } + + async function claimNext() { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query( + `SELECT j.id, j.entry_id, j.action, j.params + FROM photo_jobs j + WHERE j.status = 'pending' + ORDER BY j.id ASC LIMIT 1 FOR UPDATE SKIP LOCKED` + ); + if (!rows.length) { + await client.query('COMMIT'); + return null; + } + await client.query(`UPDATE photo_jobs SET status = 'processing' WHERE id = $1`, [rows[0].id]); + await client.query('COMMIT'); + return rows[0]; + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + throw e; + } finally { + client.release(); + } + } + + function safeUnlinkPath(p) { + try { + if (!p) return; + const abs = path.resolve(p); + const root = path.resolve(uploadsDir); + if (abs !== root && abs.startsWith(root + path.sep)) fs.unlinkSync(abs); + } catch (e) { console.error('photo worker unlink:', e.message); } + } + + function backupOldFile(oldRelPath) { + if (!oldRelPath) return null; + const oldAbs = path.join(uploadsDir, String(oldRelPath).replace(/^\/+/, '').replace(/^uploads\//, '')); + if (!fs.existsSync(oldAbs)) return null; + const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg'); + const backupPath = path.join(originalsDir, backupName); + try { + fs.renameSync(oldAbs, backupPath); + return `/uploads/.originals/${backupName}`; + } catch (e) { + console.error('photo worker backup failed:', e.message); + return null; + } + } + async function enhanceWithSharp(srcAbs, params) { + if (!sharp) throw new Error('sharp недоступен на сервере'); + const p = params || {}; + const clamp = (v, min, max, def) => { + const n = parseFloat(v); + return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def; + }; + const brightness = clamp(p.brightness, 10, 300, 100); + const contrast = clamp(p.contrast, 10, 300, 100); + const saturate = clamp(p.saturate, 0, 300, 100); + const sharpAmt = clamp(p.sharp, 0, 100, 0); + const denoise = clamp(p.denoise, 0, 100, 0); + let pipeline = sharp(srcAbs).rotate(); + if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3); + pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 }); + if (contrast !== 100) { + const a = contrast / 100; + pipeline = pipeline.linear(a, 128 * (1 - a)); + } + if (sharpAmt > 0) { + pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 }); + } + const newName = crypto.randomBytes(12).toString('hex') + '.jpg'; + await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(path.join(uploadsDir, newName)); + return `/uploads/${newName}`; + } + + async function runAiEnhance(srcAbs) { + if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен'); + const buf = fs.readFileSync(srcAbs); + const fd = new FormData(); + fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg'); + fd.append('scale', '2'); + const resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', { + method: 'POST', + body: fd, + signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS), + }); + if (!resp.ok) throw new Error('AI service error: ' + resp.status); + const out = Buffer.from(await resp.arrayBuffer()); + const newName = crypto.randomBytes(12).toString('hex') + '.jpg'; + fs.writeFileSync(path.join(uploadsDir, newName), out); + async function applyResult(job, photoPath, newPath) { + const beforePath = backupOldFile(photoPath); + await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, job.entry_id]); + await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, job.entry_id, photoPath]); + const oldThumb = path.join(uploadsDir, '.thumbs', path.basename(photoPath).replace(/\.[^.]+$/, '') + '.webp'); + safeUnlinkPath(oldThumb); + await pool.query( + `UPDATE photo_jobs SET status = 'done', before_path = $1, after_path = $2, error = NULL, finished_at = now() WHERE id = $3`, + [beforePath, newPath, job.id] + ); + if (logAudit) await logAudit(null, 'photo.job.done', { entry_id: job.entry_id, job_id: job.id, action: job.action, before_path: beforePath, after_path: newPath }); + if (invalidateEntries) invalidateEntries(); + } + async function processOne(job) { + stats.jobs++; + const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entry_id]); + if (!rows.length || !rows[0].photo_path) { + await pool.query( + `UPDATE photo_jobs SET status = 'error', error = 'У записи нет фото', finished_at = now() WHERE id = $1`, + [job.id] + ); + stats.errors++; + stats.last_at = new Date().toISOString(); + stats.last_error = 'У записи нет фото'; + return true; + } + const photoPath = rows[0].photo_path; + const srcAbs = path.join(uploadsDir, String(photoPath).replace(/^\/+/, '').replace(/^uploads\//, '')); + try { + const newPath = job.action === 'ai' ? await runAiEnhance(srcAbs) : await enhanceWithSharp(srcAbs, job.params); + await applyResult(job, photoPath, newPath); + stats.done++; + stats.last_at = new Date().toISOString(); + stats.last_error = null; + return true; + } catch (e) { + const message = (e && e.message ? e.message : 'error').slice(0, 500); + stats.last_at = new Date().toISOString(); + stats.last_error = message; + const { rows: cur } = await pool.query('SELECT attempts FROM photo_jobs WHERE id = $1', [job.id]); + const tries = ((cur[0] && cur[0].attempts) || 0) + 1; + if (tries < PHOTO_MAX_ATTEMPTS) { + await pool.query(`UPDATE photo_jobs SET status = 'pending', attempts = $1, error = $2 WHERE id = $3`, [tries, message, job.id]); + if (logAudit) await logAudit(null, 'photo.job.retry', { entry_id: job.entry_id, job_id: job.id, attempt: tries, error: message }); + return false; + } + await pool.query( + `UPDATE photo_jobs SET status = 'error', attempts = $1, error = $2, finished_at = now() WHERE id = $3`, + [tries, message, job.id] + ); + async function loop() { + while (!stopped) { + let job = null; + try { + if (!(await isEnabled())) { + await sleep(IDLE_MAX); + continue; + } + job = await claimNext(); + } catch (e) { + console.error('Photo worker claim error:', e); + await sleep(IDLE_MAX); + continue; + } + if (!job) { + await sleep(idleMs); + idleMs = Math.min(idleMs * 2, IDLE_MAX); + continue; + } + idleMs = IDLE_MIN; + processing = true; + currentId = job.id; + let ok = true; + try { + ok = await processOne(job); + } catch (e) { + console.error('Photo worker process error:', e); + } finally { + processing = false; + currentId = null; + } + if (!ok) await sleep(IDLE_MAX); + } + } + + async function resetStale() { + await pool.query(`UPDATE photo_jobs SET status = 'pending' WHERE status = 'processing'`); + } + + function start() { + if (started) return; + started = true; + startedAt = Date.now(); + resetStale() + .catch((e) => console.error('Photo worker reset error:', e)) + .finally(() => { loop().catch((e) => console.error('Photo worker loop error:', e)); }); + } + + function getInfo() { + return { + started_at: startedAt ? new Date(startedAt).toISOString() : null, + uptime_ms: startedAt ? Date.now() - startedAt : 0, + processing, + current_id: currentId, + config: CONFIG, + ...stats, + }; + } + + return { start, notify, getInfo }; +} + + stats.errors++; + if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message }); + return true; + } + } + + + return `/uploads/${newName}`; + } + function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) { const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080'; @@ -258,4 +513,6 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm return { start, notify, getStats, getInfo }; } -module.exports = { createEntryAutoChecker }; +module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker }; + +(function photoEnhanceWorkerImpl() {})();