feat: photo jobs apply/reject flow with applied flag in history

- DB: add photo_jobs.applied column (init + migration + ensure)
- Worker: generate preview only (after_path), no longer mutates entry
- New POST /api/entries/:id/photo/jobs/:jobId/apply — apply done job
  result to entry (backs up current photo, marks applied)
- New POST /api/entries/:id/photo/jobs/:jobId/reject — discard result,
  delete temp file, mark rejected
- saveEnhance: apply AI result directly when sliders are at defaults
- Photo history: '✓ Применить' action for unapplied done AI jobs;
  'rejected' status label
- sweepOrphanedUploads keeps done-not-applied preview files
This commit is contained in:
dev
2026-09-17 18:51:26 +03:00
parent 881b5b4c67
commit 5874e2b619
6 changed files with 192 additions and 30 deletions
+1
View File
@@ -187,6 +187,7 @@ CREATE TABLE IF NOT EXISTS photo_jobs (
before_path VARCHAR(255), before_path VARCHAR(255),
after_path VARCHAR(255), after_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied BOOLEAN NOT NULL DEFAULT false,
attempts INT NOT NULL DEFAULT 0, attempts INT NOT NULL DEFAULT 0,
error TEXT, error TEXT,
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
+2
View File
@@ -208,6 +208,7 @@ CREATE TABLE IF NOT EXISTS photo_jobs (
before_path VARCHAR(255), before_path VARCHAR(255),
after_path VARCHAR(255), after_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied BOOLEAN NOT NULL DEFAULT false,
attempts INT NOT NULL DEFAULT 0, attempts INT NOT NULL DEFAULT 0,
error TEXT, error TEXT,
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
@@ -221,3 +222,4 @@ INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
ON CONFLICT (key) DO NOTHING; 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);
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
+67 -9
View File
@@ -427,6 +427,7 @@ let enhanceEngineLoaded = false;
let photoAiEnabled = false; let photoAiEnabled = false;
let photoAiPollTimer = null; let photoAiPollTimer = null;
let enhanceAiResultPath = null; let enhanceAiResultPath = null;
let enhanceAiJobId = null;
let enhanceClipPos = 50; let enhanceClipPos = 50;
function setEnhanceClip(p) { function setEnhanceClip(p) {
@@ -554,6 +555,7 @@ async function runPhotoAi() {
throw new Error(err.error || 'Ошибка запуска ИИ'); throw new Error(err.error || 'Ошибка запуска ИИ');
} }
const { jobId } = await res.json(); const { jobId } = await res.json();
enhanceAiJobId = jobId;
showEnhanceResult(false); showEnhanceResult(false);
setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)'); setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)');
btn.textContent = '⏳ ИИ...'; btn.textContent = '⏳ ИИ...';
@@ -627,6 +629,7 @@ function openEnhance(id) {
enhanceEntryId = id; enhanceEntryId = id;
enhanceSourceImg = null; enhanceSourceImg = null;
enhanceAiResultPath = null; enhanceAiResultPath = null;
enhanceAiJobId = null;
['Brightness', 'Contrast', 'Saturate'].forEach(k => { ['Brightness', 'Contrast', 'Saturate'].forEach(k => {
const el = document.getElementById('enhance' + k); const el = document.getElementById('enhance' + k);
el.value = 100; el.value = 100;
@@ -658,16 +661,40 @@ function openEnhance(id) {
document.getElementById('enhanceModal').classList.add('open'); document.getElementById('enhanceModal').classList.add('open');
} }
function isDefaultEnhanceParams() {
const p = enhanceParams();
return p.brightness === 100 && p.contrast === 100 && p.saturate === 100 && p.denoise === 0 && p.sharp === 0;
}
async function applyAiResult() {
if (!enhanceAiJobId || !enhanceEntryId) return false;
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${enhanceAiJobId}/apply`, {
method: 'POST',
headers: hdr(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Ошибка применения ИИ-результата');
enhanceAiResultPath = null;
enhanceAiJobId = null;
return true;
}
function cleanupAiPreview() { function cleanupAiPreview() {
if (!enhanceAiResultPath || !enhanceEntryId) return; if (!enhanceEntryId) return;
const toClean = enhanceAiResultPath; if (enhanceAiJobId) {
const entryId = enhanceEntryId; fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${enhanceAiJobId}/reject`, {
fetch(`${API}/api/entries/${entryId}/photo/enhance-ai/preview`, { method: 'POST',
headers: hdr(),
}).catch(() => {});
} else if (enhanceAiResultPath) {
fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/preview`, {
method: 'DELETE', method: 'DELETE',
headers: hdrJson(), headers: hdrJson(),
body: JSON.stringify({ path: toClean }), body: JSON.stringify({ path: enhanceAiResultPath }),
}).catch(() => {}); }).catch(() => {});
}
enhanceAiResultPath = null; enhanceAiResultPath = null;
enhanceAiJobId = null;
} }
function closeEnhance() { function closeEnhance() {
@@ -686,7 +713,7 @@ function photoJobThumb(p) {
} }
const PHOTO_JOB_ACTION_LABELS = { ai: 'ИИ-обработка', enhance: 'Улучшение', restore: 'Возврат оригинала', rollback: 'Откат' }; const PHOTO_JOB_ACTION_LABELS = { ai: 'ИИ-обработка', enhance: 'Улучшение', restore: 'Возврат оригинала', rollback: 'Откат' };
const PHOTO_JOB_STATUS_LABELS = { pending: 'В очереди', processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка' }; const PHOTO_JOB_STATUS_LABELS = { pending: 'В очереди', processing: 'Обрабатывается', done: 'Готово', error: 'Ошибка', rejected: 'Отклонено' };
async function loadPhotoHistory() { async function loadPhotoHistory() {
if (!enhanceEntryId) return; if (!enhanceEntryId) return;
@@ -703,7 +730,8 @@ async function loadPhotoHistory() {
list.innerHTML = rows.map(r => { 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 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 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 canApply = r.status === 'done' && r.applied === false && r.action === 'ai' && !!r.after_path;
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback' && !canApply;
const err = r.error ? `<div style="color:#ef4444;font-size:.75rem;margin-top:4px">${esc(r.error)}</div>` : ''; 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; 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"> return `<div style="display:flex;gap:10px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:10px">
@@ -723,6 +751,7 @@ async function loadPhotoHistory() {
${err} ${err}
</div> </div>
${canRollback ? `<button type="button" class="btn-primary ghost" style="white-space:nowrap" data-job-rollback="${r.id}">↩ Откатить</button>` : ''} ${canRollback ? `<button type="button" class="btn-primary ghost" style="white-space:nowrap" data-job-rollback="${r.id}">↩ Откатить</button>` : ''}
${canApply ? `<button type="button" class="btn-primary" style="white-space:nowrap" data-job-apply="${r.id}">✓ Применить</button>` : ''}
</div>`; </div>`;
}).join(''); }).join('');
} catch (e) { } catch (e) {
@@ -944,7 +973,11 @@ async function saveEnhance() {
try { try {
let done = false; let done = false;
let engineLabel = 'клиент'; let engineLabel = 'клиент';
if (enhanceEngine !== 'client' && !enhanceAiResultPath) { if (enhanceAiResultPath && isDefaultEnhanceParams()) {
await applyAiResult();
engineLabel = 'ИИ';
done = true;
} else if (enhanceEngine !== 'client' && !enhanceAiResultPath) {
try { try {
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000); const timer = setTimeout(() => ctrl.abort(), 8000);
@@ -1351,9 +1384,34 @@ document.getElementById('photoHistoryModal').addEventListener('click', e => {
}); });
document.getElementById('photoHistoryList').addEventListener('click', e => { document.getElementById('photoHistoryList').addEventListener('click', e => {
const btn = e.target.closest('[data-job-rollback]'); const btn = e.target.closest('[data-job-rollback]');
if (!btn) return; if (btn) {
rollbackPhotoJob(parseInt(btn.dataset.jobRollback, 10)); rollbackPhotoJob(parseInt(btn.dataset.jobRollback, 10));
return;
}
const applyBtn = e.target.closest('[data-job-apply]');
if (applyBtn) applyPhotoJob(parseInt(applyBtn.dataset.jobApply, 10));
}); });
async function applyPhotoJob(jobId) {
if (!enhanceEntryId || enhanceSaving) return;
if (!confirm('Применить обработанную фотографию к записи? Текущая версия сохранится в истории.')) return;
enhanceSaving = true;
try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${jobId}/apply`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Ошибка применения');
showToast('Фотография применена');
const mp = data.photo_path;
const origImg = document.getElementById('enhanceOrig');
if (origImg && mp) origImg.src = `${API}${mp}`;
loadPhotoHistory();
refreshEntryCard(enhanceEntryId);
} catch (e) {
alert(e.message);
} finally {
enhanceSaving = false;
}
}
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);
+1
View File
@@ -35,6 +35,7 @@ const PHOTO_STATUS_LABELS = {
processing: 'Обрабатывается', processing: 'Обрабатывается',
done: 'Готово', done: 'Готово',
error: 'Ошибка', error: 'Ошибка',
rejected: 'Отклонено',
}; };
const PHOTO_STATUS_BADGE = { const PHOTO_STATUS_BADGE = {
done: 'ai-badge-done', done: 'ai-badge-done',
+111 -5
View File
@@ -615,14 +615,15 @@ async function removeEntryFiles(entryId) {
async function sweepOrphanedUploads() { async function sweepOrphanedUploads() {
const dir = path.join(__dirname, 'uploads'); const dir = path.join(__dirname, 'uploads');
if (!fs.existsSync(dir)) return; if (!fs.existsSync(dir)) return;
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }] = await Promise.all([ const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }] = await Promise.all([
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'), pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
pool.query('SELECT path AS p FROM project_files'), pool.query('SELECT path AS p FROM project_files'),
pool.query('SELECT photo_path AS p FROM group_photos'), pool.query('SELECT photo_path AS p FROM group_photos'),
pool.query('SELECT photo_path AS p FROM entry_photos'), pool.query('SELECT photo_path AS p FROM entry_photos'),
pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`),
]); ]);
const refs = new Set(); const refs = new Set();
[...photos, ...files, ...gphotos, ...ephotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, ''))); [...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
for (const f of fs.readdirSync(dir)) { for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f); const fp = path.join(dir, f);
if (!fs.statSync(fp).isFile()) continue; if (!fs.statSync(fp).isFile()) continue;
@@ -719,11 +720,13 @@ async function ensurePhotoJobsTable() {
before_path VARCHAR(255), before_path VARCHAR(255),
after_path VARCHAR(255), after_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied BOOLEAN NOT NULL DEFAULT false,
attempts INT NOT NULL DEFAULT 0, attempts INT NOT NULL DEFAULT 0,
error TEXT, error TEXT,
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
finished_at TIMESTAMPTZ finished_at TIMESTAMPTZ
)`); )`);
await pool.query(`ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false`);
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_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(`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`); await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
@@ -3826,17 +3829,115 @@ app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res
const jobId = parseInt(req.params.jobId, 10); const jobId = parseInt(req.params.jobId, 10);
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' }); if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
const { rows } = await pool.query( const { rows } = await pool.query(
'SELECT status, error, after_path FROM photo_jobs WHERE id = $1 AND entry_id = $2', 'SELECT id, status, error, after_path, applied FROM photo_jobs WHERE id = $1 AND entry_id = $2',
[jobId, req.params.id] [jobId, req.params.id]
); );
if (!rows.length) return res.status(404).json({ error: 'Задание не найдено' }); if (!rows.length) return res.status(404).json({ error: 'Задание не найдено' });
const job = rows[0]; const job = rows[0];
const out = { status: job.status }; const out = { status: job.status, applied: job.applied, job_id: job.id };
if (job.status === 'error') out.error = job.error; if (job.status === 'error') out.error = job.error;
if (job.status === 'done') out.photo_path = job.after_path; if (job.status === 'done') out.photo_path = job.after_path;
res.json(out); res.json(out);
}); });
app.post('/api/entries/:id/photo/jobs/:jobId/apply', 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 client = await pool.connect();
try {
const jobId = parseInt(req.params.jobId, 10);
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
await client.query('BEGIN');
const { rows: jobRows } = await client.query(
`SELECT id, entry_id, action, status, applied, after_path FROM photo_jobs
WHERE id = $1 AND entry_id = $2 FOR UPDATE`,
[jobId, req.params.id]
);
const job = jobRows[0];
if (!job) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Задание не найдено' }); }
if (job.status !== 'done') { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Результат ещё не готов' }); }
if (job.applied) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Результат уже применён' }); }
if (!job.after_path || !isSafeUploadPath(job.after_path)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Некорректный путь результата' });
}
const afterAbs = path.join(UPLOADS_DIR, String(job.after_path).replace(/^\/+/, '').replace(/^uploads\//, ''));
if (!fs.existsSync(afterAbs)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Файл результата не найден' });
}
const { rows: entryRows } = await client.query('SELECT photo_path, photo_original_path FROM entries WHERE id = $1', [req.params.id]);
if (!entryRows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Запись не найдена' }); }
const oldPath = entryRows[0].photo_path;
if (oldPath === job.after_path) {
await client.query(`UPDATE photo_jobs SET applied = true WHERE id = $1`, [jobId]);
await client.query('COMMIT');
return res.json({ ok: true, photo_path: job.after_path });
}
let originalPath = entryRows[0].photo_original_path;
let beforePath = originalPath;
const oldAbs = path.join(UPLOADS_DIR, String(oldPath || '').replace(/^\/+/, '').replace(/^uploads\//, ''));
if (oldPath && 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}`;
if (!originalPath) originalPath = beforePath;
} catch (e) {
console.error('photo apply backup failed:', e);
}
}
await client.query('UPDATE entries SET photo_path = $1, photo_original_path = $2 WHERE id = $3', [job.after_path, originalPath, req.params.id]);
await client.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [job.after_path, req.params.id, oldPath]);
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath || '').replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb);
await client.query(`UPDATE photo_jobs SET applied = true, before_path = COALESCE(before_path, $1) WHERE id = $2`, [beforePath, jobId]);
await client.query('COMMIT');
await logAudit(req, 'entry.photo.apply', { entry_id: req.params.id, job_id: jobId, before_path: beforePath, after_path: job.after_path });
invalidateEntries();
res.json({ ok: true, photo_path: job.after_path });
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
console.error('POST /api/entries/:id/photo/jobs/:jobId/apply:', e);
res.status(500).json({ error: 'Ошибка применения фотографии' });
} finally {
client.release();
}
});
app.post('/api/entries/:id/photo/jobs/:jobId/reject', 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 } = await pool.query(
`SELECT id, status, applied, after_path FROM photo_jobs WHERE id = $1 AND entry_id = $2`,
[jobId, req.params.id]
);
const job = rows[0];
if (!job) return res.status(404).json({ error: 'Задание не найдено' });
if (job.applied) return res.status(400).json({ error: 'Результат уже применён' });
if (job.status === 'done' && job.after_path && isSafeUploadPath(job.after_path)) safeUnlink(job.after_path);
await pool.query(
`UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now()
WHERE id = $1 AND applied = false`,
[jobId]
);
await logAudit(req, 'entry.photo.reject', { entry_id: req.params.id, job_id: jobId });
res.json({ ok: true });
} catch (e) {
console.error('POST /api/entries/:id/photo/jobs/:jobId/reject:', e);
res.status(500).json({ error: 'Ошибка отклонения результата' });
}
});
app.get('/api/entries/:id/photo/jobs', requireAuth, async (req, res) => { app.get('/api/entries/:id/photo/jobs', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') { if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id); const acc = await entryAccessible(req.user, req.params.id);
@@ -3844,7 +3945,7 @@ app.get('/api/entries/:id/photo/jobs', requireAuth, async (req, res) => {
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
} }
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT id, action, status, params, before_path, after_path, error, created_at, finished_at `SELECT id, action, status, applied, params, before_path, after_path, error, created_at, finished_at
FROM photo_jobs WHERE entry_id = $1 ORDER BY id DESC LIMIT 50`, FROM photo_jobs WHERE entry_id = $1 ORDER BY id DESC LIMIT 50`,
[req.params.id] [req.params.id]
); );
@@ -3912,6 +4013,11 @@ app.delete('/api/entries/:id/photo/enhance-ai/preview', requireAuth, async (req,
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: 'Некорректный путь' });
safeUnlink(p); safeUnlink(p);
await pool.query(
`UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now()
WHERE entry_id = $1 AND after_path = $2 AND status = 'done' AND applied = false`,
[req.params.id, p]
);
res.json({ ok: true }); res.json({ ok: true });
}); });
+5 -11
View File
@@ -138,18 +138,12 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
fs.writeFileSync(path.join(uploadsDir, newName), out); fs.writeFileSync(path.join(uploadsDir, newName), out);
return `/uploads/${newName}`; return `/uploads/${newName}`;
} }
async function applyResult(job, photoPath, newPath) { async function applyResult(job, 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( await pool.query(
`UPDATE photo_jobs SET status = 'done', before_path = $1, after_path = $2, error = NULL, finished_at = now() WHERE id = $3`, `UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`,
[beforePath, newPath, job.id] [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 (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath });
if (invalidateEntries) invalidateEntries();
} }
async function processOne(job) { async function processOne(job) {
stats.jobs++; stats.jobs++;
@@ -168,7 +162,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
const srcAbs = path.join(uploadsDir, String(photoPath).replace(/^\/+/, '').replace(/^uploads\//, '')); const srcAbs = path.join(uploadsDir, String(photoPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
try { try {
const newPath = job.action === 'ai' ? await runAiEnhance(srcAbs) : await enhanceWithSharp(srcAbs, job.params); const newPath = job.action === 'ai' ? await runAiEnhance(srcAbs) : await enhanceWithSharp(srcAbs, job.params);
await applyResult(job, photoPath, newPath); await applyResult(job, newPath);
stats.done++; stats.done++;
stats.last_at = new Date().toISOString(); stats.last_at = new Date().toISOString();
stats.last_error = null; stats.last_error = null;