feat: persistent photo job queue, worker, history with rollback

- Replace in-memory photoAiJobs Map with DB-backed photo_jobs table
- Background photo worker (worker.js) with retry, backoff, stale reset
  - Handles both AI (Real-ESRGAN) and server-side (sharp) enhancement
  - Controlled via photo_worker_enabled setting
- Photo job history in enhance modal with before/after thumbnails + rollback
- Worker dashboard: photo jobs section with status counts, recent table,
  compare slider for before/after, rollback from worker UI
- New endpoints: /api/photo-jobs/status|wake|enabled|requeue-failed,
  /api/entries/:id/photo/jobs (history), .../rollback
- swapEntryPhotoFiles logs every mutation to photo_jobs table
This commit is contained in:
dev
2026-09-17 17:36:56 +03:00
parent 27a2238082
commit 1145be02f3
8 changed files with 876 additions and 55 deletions
+20
View File
@@ -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_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); 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 ( CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL, user_id INT REFERENCES users(id) ON DELETE SET NULL,
+20
View File
@@ -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 CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed(); 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); ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
+12
View File
@@ -27,6 +27,7 @@
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; } .enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
.enhance-sliders .settings-field input[type=range] { width: 100%; } .enhance-sliders .settings-field input[type=range] { width: 100%; }
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; } .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) { @media (max-width: 800px) {
.enhance-body { flex-direction: column; } .enhance-body { flex-direction: column; }
.enhance-sliders { flex: 1 1 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } .enhance-sliders { flex: 1 1 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
@@ -215,6 +216,7 @@
</div> </div>
</div> </div>
<div class="card-foot enhance-foot"> <div class="card-foot enhance-foot">
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button> <button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button> <button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button> <button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
@@ -224,6 +226,16 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="photoHistoryModal">
<div class="edit-modal enhance-modal">
<h3 id="photoHistoryTitle">История изменений фотографии</h3>
<div id="photoHistoryList" style="display:flex;flex-direction:column;gap:12px;max-height:60vh;overflow:auto"></div>
<div class="card-foot enhance-foot">
<button type="button" class="btn-primary ghost" id="photoHistoryCloseBtn">Закрыть</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
+97
View File
@@ -673,11 +673,98 @@ function cleanupAiPreview() {
function closeEnhance() { function closeEnhance() {
cleanupAiPreview(); cleanupAiPreview();
document.getElementById('enhanceModal').classList.remove('open'); document.getElementById('enhanceModal').classList.remove('open');
document.getElementById('photoHistoryModal').classList.remove('open');
enhanceEntryId = null; enhanceEntryId = null;
enhanceSourceImg = null; enhanceSourceImg = null;
enhanceAiResultPath = 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 = '<div style="color:var(--muted);font-size:.85rem">Загрузка...</div>';
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 = '<div style="color:var(--muted);font-size:.85rem">Изменений фотографии пока не было.</div>';
return;
}
list.innerHTML = rows.map(r => {
const before = r.before_path ? `<img class="photo-thumb" loading="lazy" src="${esc(photoJobThumb(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
const after = r.after_path ? `<img class="photo-thumb" loading="lazy" src="${esc(photoJobThumb(r.after_path))}" alt="стало">` : '<span style="color:var(--muted)">—</span>';
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback';
const err = r.error ? `<div style="color:#ef4444;font-size:.75rem;margin-top:4px">${esc(r.error)}</div>` : '';
const time = r.finished_at || r.created_at;
return `<div style="display:flex;gap:10px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:10px">
<div style="display:flex;flex-direction:column;gap:4px;text-align:center">
${before}
<span style="font-size:.65rem;color:var(--muted)">было</span>
</div>
<div style="color:var(--muted)">→</div>
<div style="display:flex;flex-direction:column;gap:4px;text-align:center">
${after}
<span style="font-size:.65rem;color:var(--muted)">стало</span>
</div>
<div style="flex:1;min-width:0">
<div style="font-size:.85rem">${esc(PHOTO_JOB_ACTION_LABELS[r.action] || r.action)}
<span style="color:var(--muted);font-size:.75rem;margin-left:6px">${esc(PHOTO_JOB_STATUS_LABELS[r.status] || r.status)} · ${esc(new Date(time).toLocaleString('ru'))}</span>
</div>
${err}
</div>
${canRollback ? `<button type="button" class="btn-primary ghost" style="white-space:nowrap" data-job-rollback="${r.id}">↩ Откатить</button>` : ''}
</div>`;
}).join('');
} catch (e) {
list.innerHTML = `<div style="color:#ef4444;font-size:.85rem">${esc(e.message)}</div>`;
}
}
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) { function medianFilterPass(ctx, w, h) {
const data = ctx.getImageData(0, 0, w, h); const data = ctx.getImageData(0, 0, w, h);
const src = data.data; const src = data.data;
@@ -1257,6 +1344,16 @@ document.getElementById('enhanceAutoBtn').addEventListener('click', autoEnhance)
document.getElementById('enhanceAiBtn').addEventListener('click', runPhotoAi); document.getElementById('enhanceAiBtn').addEventListener('click', runPhotoAi);
document.getElementById('enhanceRestoreBtn').addEventListener('click', restoreOriginal); document.getElementById('enhanceRestoreBtn').addEventListener('click', restoreOriginal);
document.getElementById('enhanceSaveBtn').addEventListener('click', saveEnhance); 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('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); });
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview); document.getElementById('enhanceContrast').addEventListener('input', scheduleEnhancePreview);
+179 -2
View File
@@ -16,11 +16,38 @@ const STATUS_BADGE = {
}; };
let state = null; let state = null;
let photoState = null;
let photoJobRow = null;
let refreshTimer = null; let refreshTimer = null;
let busy = false; let busy = false;
let photoBusy = false;
let recentRows = []; let recentRows = [];
let recentPage = 1; 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) { function fmtDur(ms) {
if (ms === null || ms === undefined || ms < 0) return '—'; if (ms === null || ms === undefined || ms < 0) return '—';
const s = Math.floor(ms / 1000); const s = Math.floor(ms / 1000);
@@ -190,6 +217,90 @@ function render() {
renderIcons(); 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]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`).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 ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
const after = r.after_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.after_path))}" alt="стало">` : '<span style="color:var(--muted)">—</span>';
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback';
const badge = `<span class="ai-badge ${PHOTO_STATUS_BADGE[r.status] || ''}">${esc(PHOTO_STATUS_LABELS[r.status] || r.status)}</span>`;
return `<tr>
<td style="white-space:nowrap;color:var(--muted)">${fmtTime(r.finished_at)}</td>
<td>#${r.entry_id} ${esc(r.student_name || '')}</td>
<td>${esc(PHOTO_ACTION_LABELS[r.action] || r.action)}</td>
<td>${badge}</td>
<td>${before}</td>
<td>${after}</td>
<td style="color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.error || '')}">${r.error ? esc(r.error) : ''}</td>
<td style="white-space:nowrap">
<button type="button" class="btn-link" data-photo-view="${r.id}">Смотреть</button>
${canRollback ? `<button type="button" class="btn-link" data-photo-rollback="${r.id}" data-photo-entry="${r.entry_id}">↩ Откатить</button>` : ''}
</td>
</tr>`;
}).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() { async function load() {
if (busy) return; if (busy) return;
busy = true; busy = true;
@@ -231,15 +342,28 @@ async function requeueFailed() {
function setAutoRefresh(on) { function setAutoRefresh(on) {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } 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('wakeBtn').addEventListener('click', wake);
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled); document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
document.getElementById('requeueBtn').addEventListener('click', requeueFailed); document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
document.getElementById('recentFilter').addEventListener('change', () => { recentPage = 1; renderRecent(); }); document.getElementById('recentFilter').addEventListener('change', () => { recentPage = 1; renderRecent(); });
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked)); 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 => { document.getElementById('recentTable').addEventListener('click', e => {
const btn = e.target.closest('.ai-details'); 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'); 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 () => { (async () => {
if (await requireAdminPage()) { if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page); buildSidebar(document.body.dataset.page);
load(); load();
loadPhoto();
} }
})(); })();
+61
View File
@@ -5,6 +5,21 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Воркер ИИ — Админ-панель</title> <title>Воркер ИИ — Админ-панель</title>
<link rel="stylesheet" href="admin.css"> <link rel="stylesheet" href="admin.css">
<style>
.enhance-compare { position: relative; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
.enhance-stage { transform-origin: 0 0; }
.enhance-stage > img { display: block; width: 100%; height: auto; }
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
.enhance-top .enhance-stage { position: absolute; inset: 0; }
.enhance-top .enhance-stage > img { height: 100%; object-fit: fill; }
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); cursor: ew-resize; }
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
.enhance-badge.left { left: 10px; }
.enhance-badge.right { right: 10px; }
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
</style>
</head> </head>
<body data-page="worker"> <body data-page="worker">
<div class="layout"> <div class="layout">
@@ -24,6 +39,8 @@
<button class="btn-link" id="refreshBtn" type="button">Обновить</button> <button class="btn-link" id="refreshBtn" type="button">Обновить</button>
<button class="btn-link" id="wakeBtn" type="button" title="Запустить обработку очереди немедленно">Разбудить</button> <button class="btn-link" id="wakeBtn" type="button" title="Запустить обработку очереди немедленно">Разбудить</button>
<button class="btn-link" id="requeueBtn" type="button" title="Вернуть все записи с ошибкой в очередь">Перепроверить ошибки</button> <button class="btn-link" id="requeueBtn" type="button" title="Вернуть все записи с ошибкой в очередь">Перепроверить ошибки</button>
<button class="btn-link" id="photoWakeBtn" type="button" title="Разбудить фото-воркер">📷 Разбудить фото</button>
<button class="btn-link" id="photoToggleBtn" type="button">📷 …</button>
<button class="btn-link" id="toggleBtn" type="button">…</button> <button class="btn-link" id="toggleBtn" type="button">…</button>
</div> </div>
@@ -78,6 +95,28 @@
<div class="pager" id="recentPager"></div> <div class="pager" id="recentPager"></div>
</div> </div>
<div class="settings-card wide">
<div class="card-head">
<div class="ic"><i data-lucide="image"></i></div>
<h3>Обработка фотографий</h3>
</div>
<div class="actions-row" style="margin-bottom:10px">
<span class="hint" id="photoStateHint"></span>
<span style="flex:1"></span>
<button class="btn-link" id="photoRequeueBtn" type="button" title="Вернуть задания с ошибкой в очередь">Повторить ошибки фото</button>
</div>
<div class="worker-counts" id="photoCounts" style="margin-bottom:10px"></div>
<div class="audit-wrap">
<table class="audit-table" id="photoTable">
<thead>
<tr><th>Время</th><th>Запись</th><th>Действие</th><th>Статус</th><th>Было</th><th>Стало</th><th>Ошибка</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="photoEmpty" style="display:none">Обработок фотографий пока нет.</div>
</div>
</div>
<div class="settings-card wide" id="errorsCard" style="display:none"> <div class="settings-card wide" id="errorsCard" style="display:none">
<div class="card-head"> <div class="card-head">
<div class="ic"><i data-lucide="triangle-alert"></i></div> <div class="ic"><i data-lucide="triangle-alert"></i></div>
@@ -112,6 +151,28 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal-overlay" id="photoJobModal">
<div class="edit-modal" style="max-width:900px">
<h3 id="photoJobTitle">Обработка фотографии</h3>
<div class="enhance-compare" id="photoJobCompare">
<div class="enhance-stage">
<img id="photoJobBefore" alt="Было">
</div>
<div class="enhance-top" id="photoJobTop" style="clip-path:inset(0 0 0 50%)">
<div class="enhance-stage enhance-stage-fill">
<img id="photoJobAfter" alt="Стало">
</div>
</div>
<div class="enhance-divider" id="photoJobDivider"><div class="enhance-handle">⇔</div></div>
<div class="enhance-badge left">Было</div>
<div class="enhance-badge right">Стало</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:14px">
<button type="button" class="btn-primary ghost" id="photoJobCloseBtn">Закрыть</button>
<button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
+229 -52
View File
@@ -5,7 +5,7 @@ const rateLimit = require('express-rate-limit');
const helmet = require('helmet'); const helmet = require('helmet');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const heicConvert = require('heic-convert'); const heicConvert = require('heic-convert');
const { createEntryAutoChecker } = require('./worker'); const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker');
const https = require('https'); const https = require('https');
const path = require('path'); const path = require('path');
@@ -300,6 +300,14 @@ app.get('/uploads/thumb/:name', fileLimiter, async (req, res) => {
return sendImageThumb(res, fp); 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) => { app.use((req, res, next) => {
const p = req.path; const p = req.path;
if (!p.startsWith('/uploads') && !p.startsWith('/vendor')) { 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() { async function ensureUserTables() {
await pool.query(`CREATE TABLE IF NOT EXISTS users ( await pool.query(`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, 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_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'; const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
let entryAutoChecker = null; let entryAutoChecker = null;
let photoWorker = null;
async function getAiPrompt() { async function getAiPrompt() {
const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT); 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; 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; let originalPath = null;
const { rows: prevRows } = await pool.query('SELECT photo_original_path FROM entries WHERE id = $1', [entryId]); 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; 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]); 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'); const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb); 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 }); await logAudit(req, 'entry.photo.enhance', { entry_id: entryId, old_path: oldPath, new_path: newPath, original_path: originalPath });
invalidateEntries(); invalidateEntries();
} }
@@ -3757,7 +3789,7 @@ app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => {
await convertPhoto(req.file); await convertPhoto(req.file);
newPath = `/uploads/${req.file.filename}`; 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 }); res.json({ ok: true, photo_path: newPath, engine });
} catch (e) { } catch (e) {
if (req.file) safeUnlink(req.file.path); 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) => { app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => {
if (!PHOTO_AI_URL) return res.status(503).json({ error: 'ИИ-обработка фото не настроена' }); if (!PHOTO_AI_URL) return res.status(503).json({ error: 'ИИ-обработка фото не настроена' });
if (req.user.role !== 'admin') { 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]); 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.length) return res.status(404).json({ error: 'Запись не найдена' });
if (!cur[0].photo_path) return res.status(400).json({ error: 'У записи нет фото' }); if (!cur[0].photo_path) return res.status(400).json({ error: 'У записи нет фото' });
const job = { try {
id: crypto.randomBytes(8).toString('hex'), const { rows } = await pool.query(
entryId: parseInt(req.params.id, 10), `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1, 'ai', 'pending', NULL) RETURNING id`,
req: { user: req.user, ip: req.ip }, [req.params.id]
status: 'pending', );
created_at: new Date().toISOString(), if (photoWorker) photoWorker.notify();
}; await logAudit(req, 'entry.photo.enhance-ai.queue', { entry_id: req.params.id, job_id: rows[0].id });
photoAiJobs.set(job.id, job); res.json({ jobId: rows[0].id });
for (const [id, j] of photoAiJobs) { } catch (e) {
if (j.status === 'done' || j.status === 'error') { console.error('POST /api/entries/:id/photo/enhance-ai:', e);
if (Date.now() - new Date(j.created_at).getTime() > 3600000) photoAiJobs.delete(id); 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) => { app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res) => {
const job = photoAiJobs.get(req.params.jobId); const jobId = parseInt(req.params.jobId, 10);
if (!job || job.entryId !== parseInt(req.params.id, 10)) return res.status(404).json({ error: 'Задание не найдено' }); 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 }; const out = { status: job.status };
if (job.status === 'error') out.error = job.error; 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); 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) => { app.delete('/api/entries/:id/photo/enhance-ai/preview', requireAuth, async (req, res) => {
const { path: p } = req.body || {}; const { path: p } = req.body || {};
if (!isSafeUploadPath(p)) return res.status(400).json({ error: 'Некорректный путь' }); 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 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]); 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\//, '')); 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'); const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb); 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 }); await logAudit(req, 'entry.photo.restore_original', { entry_id: req.params.id, restored_path: newPath });
invalidateEntries(); invalidateEntries();
res.json({ ok: true, photo_path: newPath }); 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 }); 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) => { app.get('/api/trash', requireAuth, async (req, res) => {
const { limit, offset } = req.query; const { limit, offset } = req.query;
const bw = branchScope(req.user); 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 ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', 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 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); } try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT }); entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
entryAutoChecker.start(); entryAutoChecker.start();
console.log('AI auto-check worker started'); 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');
})(); })();
+258 -1
View File
@@ -3,6 +3,261 @@ const IDLE_MAX_MS = 60000;
const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10); const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10);
const MAX_INPUT_CHARS = 2000; const MAX_INPUT_CHARS = 2000;
const MIN_TEXT_CHARS = 4; 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 }) { function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080'; 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 }; return { start, notify, getStats, getInfo };
} }
module.exports = { createEntryAutoChecker }; module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker };
(function photoEnhanceWorkerImpl() {})();