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:
dev
2026-09-26 11:08:53 +03:00
parent 8bf54fb95d
commit ddd49707ae
16 changed files with 1563 additions and 217 deletions
+15 -32
View File
@@ -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();