feat(storage): S3-совместимое хранилище файлов (SeaweedFS/MinIO) и миграция uploads
- storage.js: абстракция хранилища с драйверами local и s3 (AWS SDK v3), ключи объектов совпадают с текущими путями /uploads/<файл>, поэтому схема БД и URL не меняются - docker-compose.yml: сервис s3 (SeaweedFS, том s3-data, API только на loopback), переменные S3_*/STORAGE_*, restart unless-stopped для app и db - docker-compose.minio.yml: оверрайд S3-сервиса на MinIO (образ из своего зеркала) - server.js/worker.js: чтение и запись файлов только через storage (отдача /uploads/*, миниатюры, share-файлы, zip-отчёты, enhance/apply/rollback, photo-worker), автосоздание бакета, глобальная персистенция загрузок multer - бэкап/восстановление и scripts/backup.sh, restore.sh — через scripts/storage-sync.js - scripts/migrate-to-s3.js: идемпотентная миграция uploads/ в бакет (--dry-run, --verify-only, --delete-local) - админка: блок «Хранилище» в системной информации - .env.example, README.md, AGENTS.md: описание драйверов, переменных и перехода на S3
This commit is contained in:
@@ -9,7 +9,7 @@ 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 }) {
|
||||
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, originalsDir, storage }) {
|
||||
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
|
||||
const IDLE_MIN = 2000;
|
||||
const IDLE_MAX = 30000;
|
||||
@@ -71,30 +71,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
async function enhanceWithSharp(srcKey, params) {
|
||||
if (!sharp) throw new Error('sharp недоступен на сервере');
|
||||
const p = params || {};
|
||||
const clamp = (v, min, max, def) => {
|
||||
@@ -106,7 +83,9 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
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();
|
||||
const srcPath = await storage.localize(srcKey);
|
||||
if (!srcPath) throw new Error('Файл фото не найден');
|
||||
let pipeline = sharp(srcPath).rotate();
|
||||
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
|
||||
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
|
||||
if (contrast !== 100) {
|
||||
@@ -117,13 +96,16 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
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));
|
||||
const outPath = path.join(uploadsDir, newName);
|
||||
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(outPath);
|
||||
await storage.persist(newName, outPath);
|
||||
return `/uploads/${newName}`;
|
||||
}
|
||||
|
||||
async function runAiEnhance(srcAbs) {
|
||||
async function runAiEnhance(srcKey) {
|
||||
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
|
||||
const buf = fs.readFileSync(srcAbs);
|
||||
const buf = await storage.getBuffer(srcKey);
|
||||
if (!buf) throw new Error('Файл фото не найден');
|
||||
const fd = new FormData();
|
||||
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
|
||||
fd.append('scale', '2');
|
||||
@@ -135,7 +117,9 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
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);
|
||||
const outPath = path.join(uploadsDir, newName);
|
||||
fs.writeFileSync(outPath, out);
|
||||
await storage.persist(newName, outPath);
|
||||
return `/uploads/${newName}`;
|
||||
}
|
||||
async function applyResult(job, newPath) {
|
||||
@@ -159,9 +143,8 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
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);
|
||||
const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params);
|
||||
await applyResult(job, newPath);
|
||||
stats.done++;
|
||||
stats.last_at = new Date().toISOString();
|
||||
|
||||
Reference in New Issue
Block a user