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:
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+72
-14
@@ -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 cleanupAiPreview() {
|
function isDefaultEnhanceParams() {
|
||||||
if (!enhanceAiResultPath || !enhanceEntryId) return;
|
const p = enhanceParams();
|
||||||
const toClean = enhanceAiResultPath;
|
return p.brightness === 100 && p.contrast === 100 && p.saturate === 100 && p.denoise === 0 && p.sharp === 0;
|
||||||
const entryId = enhanceEntryId;
|
}
|
||||||
fetch(`${API}/api/entries/${entryId}/photo/enhance-ai/preview`, {
|
|
||||||
method: 'DELETE',
|
async function applyAiResult() {
|
||||||
headers: hdrJson(),
|
if (!enhanceAiJobId || !enhanceEntryId) return false;
|
||||||
body: JSON.stringify({ path: toClean }),
|
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${enhanceAiJobId}/apply`, {
|
||||||
}).catch(() => {});
|
method: 'POST',
|
||||||
|
headers: hdr(),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Ошибка применения ИИ-результата');
|
||||||
enhanceAiResultPath = null;
|
enhanceAiResultPath = null;
|
||||||
|
enhanceAiJobId = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupAiPreview() {
|
||||||
|
if (!enhanceEntryId) return;
|
||||||
|
if (enhanceAiJobId) {
|
||||||
|
fetch(`${API}/api/entries/${enhanceEntryId}/photo/jobs/${enhanceAiJobId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: hdr(),
|
||||||
|
}).catch(() => {});
|
||||||
|
} else if (enhanceAiResultPath) {
|
||||||
|
fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/preview`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ path: enhanceAiResultPath }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user