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:
+20
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
<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="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
||||
@@ -224,6 +226,16 @@
|
||||
</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>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
@@ -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 = '<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) {
|
||||
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);
|
||||
|
||||
+179
-2
@@ -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]) => `<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() {
|
||||
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();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Воркер ИИ — Админ-панель</title>
|
||||
<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>
|
||||
<body data-page="worker">
|
||||
<div class="layout">
|
||||
@@ -24,6 +39,8 @@
|
||||
<button class="btn-link" id="refreshBtn" type="button">Обновить</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="photoWakeBtn" type="button" title="Разбудить фото-воркер">📷 Разбудить фото</button>
|
||||
<button class="btn-link" id="photoToggleBtn" type="button">📷 …</button>
|
||||
<button class="btn-link" id="toggleBtn" type="button">…</button>
|
||||
</div>
|
||||
|
||||
@@ -78,6 +95,28 @@
|
||||
<div class="pager" id="recentPager"></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="card-head">
|
||||
<div class="ic"><i data-lucide="triangle-alert"></i></div>
|
||||
@@ -112,6 +151,28 @@
|
||||
</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>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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() {})();
|
||||
|
||||
Reference in New Issue
Block a user