Files
WhatIDo/scripts/migrate-to-s3.js
dev ddd49707ae 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
2026-09-26 11:08:53 +03:00

129 lines
5.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});