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 @@
+
+
+
История изменений фотографии
+
+
+
+
+
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() {})();