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:
@@ -0,0 +1,128 @@
|
||||
const path = require('path');
|
||||
const { createStorage } = require('../storage');
|
||||
|
||||
const SKIP_PREFIXES = ['.thumbs/', '.cache/'];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { dryRun: false, deleteLocal: false, verifyOnly: false, concurrency: 3, prefix: null, help: false };
|
||||
for (const arg of argv.slice(2)) {
|
||||
if (arg === '--dry-run') opts.dryRun = true;
|
||||
else if (arg === '--delete-local') opts.deleteLocal = true;
|
||||
else if (arg === '--keep-local') opts.deleteLocal = false;
|
||||
else if (arg === '--verify-only') opts.verifyOnly = true;
|
||||
else if (arg.startsWith('--concurrency=')) opts.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 1);
|
||||
else if (arg.startsWith('--prefix=')) opts.prefix = arg.split('=')[1].replace(/^\/+|\/+$/g, '');
|
||||
else if (arg === '--help' || arg === '-h') opts.help = true;
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`Миграция uploads/ -> S3 (MinIO).
|
||||
|
||||
Использование:
|
||||
node scripts/migrate-to-s3.js [ключи]
|
||||
|
||||
Ключи:
|
||||
--dry-run показать, что будет загружено, ничего не менять
|
||||
--verify-only только проверить наличие объектов в S3
|
||||
--delete-local удалить локальные файлы после успешной проверки
|
||||
--keep-local оставить локальные файлы (по умолчанию)
|
||||
--concurrency=N параллельных загрузок (по умолчанию 3)
|
||||
--prefix=PREFIX префикс ключей внутри бакета
|
||||
|
||||
Переменные окружения: STORAGE_DRIVER=s3, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY.`);
|
||||
}
|
||||
|
||||
async function runPool(items, limit, worker) {
|
||||
const queue = items.slice();
|
||||
const results = { ok: 0, skip: 0, fail: 0 };
|
||||
const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const item = queue.shift();
|
||||
const res = await worker(item);
|
||||
if (res === 'ok') results.ok++;
|
||||
else if (res === 'skip') results.skip++;
|
||||
else results.fail++;
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
if (opts.help) return usage();
|
||||
const dir = path.join(__dirname, '..', 'uploads');
|
||||
const source = createStorage({ dir, driver: 'local' });
|
||||
const target = createStorage({ driver: 's3', prefix: opts.prefix || undefined });
|
||||
if (!target.isRemote()) {
|
||||
console.error('STORAGE_DRIVER не s3: задайте STORAGE_DRIVER=s3 и переменные S3_*.');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!target.bucket) {
|
||||
console.error('Не задан S3_BUCKET.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const all = await source.listAll('');
|
||||
const objects = all.filter(o => !SKIP_PREFIXES.some(p => o.key.startsWith(p)));
|
||||
const totalBytes = objects.reduce((sum, o) => sum + (o.size || 0), 0);
|
||||
if (!opts.dryRun) await target.ensureBucket();
|
||||
console.log(`Локально: ${objects.length} файлов, ${(totalBytes / 1048576).toFixed(2)} МБ`);
|
||||
console.log(`Бакет: ${target.bucket}${opts.prefix ? ' префикс ' + opts.prefix : ''} (${process.env.S3_ENDPOINT || 'endpoint по умолчанию'})`);
|
||||
if (opts.dryRun) console.log('Режим --dry-run: изменения не применяются.');
|
||||
|
||||
const failures = [];
|
||||
const result = await runPool(objects, opts.concurrency, async (obj) => {
|
||||
const localPath = source.localFile(obj.key);
|
||||
if (!localPath) {
|
||||
failures.push(`${obj.key}: некорректный путь`);
|
||||
return 'fail';
|
||||
}
|
||||
try {
|
||||
const remote = await target.head(obj.key);
|
||||
if (remote && remote.size === obj.size) {
|
||||
if (opts.deleteLocal && !opts.dryRun) source.unlinkLocalOnly(obj.key);
|
||||
return 'skip';
|
||||
}
|
||||
if (opts.verifyOnly) {
|
||||
failures.push(`${obj.key}: нет объекта в S3`);
|
||||
return 'fail';
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
console.log(` + ${obj.key} (${obj.size} Б)`);
|
||||
return 'ok';
|
||||
}
|
||||
await target.putFile(obj.key, localPath);
|
||||
const check = await target.head(obj.key);
|
||||
if (!check || check.size !== obj.size) {
|
||||
failures.push(`${obj.key}: размер не совпал после загрузки`);
|
||||
return 'fail';
|
||||
}
|
||||
if (opts.deleteLocal) source.unlinkLocalOnly(obj.key);
|
||||
return 'ok';
|
||||
} catch (err) {
|
||||
failures.push(`${obj.key}: ${err.message}`);
|
||||
return 'fail';
|
||||
}
|
||||
});
|
||||
|
||||
const usage = await target.usage().catch(() => null);
|
||||
console.log(`Загружено: ${result.ok}, уже было: ${result.skip}, ошибок: ${result.fail}`);
|
||||
if (usage) console.log(`В бакете: ${usage.count} объектов, ${(usage.size_bytes / 1048576).toFixed(2)} МБ`);
|
||||
if (failures.length) {
|
||||
console.error('Ошибки:');
|
||||
for (const f of failures.slice(0, 50)) console.error(' - ' + f);
|
||||
if (failures.length > 50) console.error(` ... ещё ${failures.length - 50}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!opts.deleteLocal && !opts.dryRun) {
|
||||
console.log('Локальные файлы оставлены. Удалить после проверки: node scripts/migrate-to-s3.js --delete-local');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Миграция не выполнена:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user