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:
@@ -615,14 +615,15 @@ async function removeEntryFiles(entryId) {
|
||||
async function sweepOrphanedUploads() {
|
||||
const dir = path.join(__dirname, 'uploads');
|
||||
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 path AS p FROM project_files'),
|
||||
pool.query('SELECT photo_path AS p FROM group_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();
|
||||
[...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)) {
|
||||
const fp = path.join(dir, f);
|
||||
if (!fs.statSync(fp).isFile()) continue;
|
||||
@@ -719,11 +720,13 @@ async function ensurePhotoJobsTable() {
|
||||
before_path VARCHAR(255),
|
||||
after_path VARCHAR(255),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
applied BOOLEAN NOT NULL DEFAULT false,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
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_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`);
|
||||
@@ -3826,17 +3829,115 @@ app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res
|
||||
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',
|
||||
'SELECT id, status, error, after_path, applied FROM photo_jobs WHERE id = $1 AND entry_id = $2',
|
||||
[jobId, req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Задание не найдено' });
|
||||
const job = rows[0];
|
||||
const out = { status: job.status };
|
||||
const out = { status: job.status, applied: job.applied, job_id: job.id };
|
||||
if (job.status === 'error') out.error = job.error;
|
||||
if (job.status === 'done') out.photo_path = job.after_path;
|
||||
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) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
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: 'Нет доступа к этой записи' });
|
||||
}
|
||||
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`,
|
||||
[req.params.id]
|
||||
);
|
||||
@@ -3912,6 +4013,11 @@ app.delete('/api/entries/:id/photo/enhance-ai/preview', requireAuth, async (req,
|
||||
const { path: p } = req.body || {};
|
||||
if (!isSafeUploadPath(p)) return res.status(400).json({ error: 'Некорректный путь' });
|
||||
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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user