125 lines
5.8 KiB
JavaScript
125 lines
5.8 KiB
JavaScript
// 存量媒体迁移 → MinIO(老板 2026-08-05:文件存储改用 MinIO)。
|
||
//
|
||
// 把库里指向【本地 /uploads】(可选:也含【阿里云 OSS】)的媒体 URL,下载字节 → 传到 MinIO → 回写库里的 URL。
|
||
// 深度遍历每条文档的所有字符串字段(自动覆盖 media/cover/videoUrl/bundle.* 等嵌套,无需逐字段列举)。
|
||
//
|
||
// 前提:
|
||
// ① 在【服务器后端目录】跑(本地 /uploads 文件在这台机上才读得到)。
|
||
// ② .env 已配好 MINIO_*(minioConfigured 必须为真,否则拒绝运行)。
|
||
//
|
||
// 用法:
|
||
// node scripts/migrate-media-to-minio.mjs # 【干跑】只统计不写库(强烈建议先跑这个)
|
||
// node scripts/migrate-media-to-minio.mjs --commit # 真迁移(下载→传 MinIO→回写库 URL)
|
||
// node scripts/migrate-media-to-minio.mjs --commit --include-oss # 连 OSS 上的也迁到 MinIO
|
||
// node scripts/migrate-media-to-minio.mjs --commit creations create_jobs # 只迁指定集合
|
||
//
|
||
// 幂等:MinIO key = 原文件名(basename,已带时间戳/随机段);重复跑同一文件覆盖同 key,库里 URL 已是 MinIO 的会跳过。
|
||
// 说明:只迁【http(s) 公开媒体 URL】;母带(存的是 {key,driver} 对象,非 URL)不在此列,如需迁移母带另说。
|
||
import 'dotenv/config';
|
||
import fs from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import { connectDb, getDb, closeDb } from '../src/db.js';
|
||
import { config } from '../src/config.js';
|
||
import { saveMedia, minioConfigured } from '../src/services/storage.js';
|
||
|
||
const COMMIT = process.argv.includes('--commit');
|
||
const INCLUDE_OSS = process.argv.includes('--include-oss');
|
||
const COLL_ARGS = process.argv.slice(2).filter((a) => !a.startsWith('--'));
|
||
// 默认扫这些含媒体 URL 的集合(可用命令行参数覆盖)。
|
||
const DEFAULT_COLLS = ['creations', 'create_jobs', 'users', 'twins', 'messages', 'memories', 'masters', 'social_messages', 'shares', 'knowledge'];
|
||
const COLLS = COLL_ARGS.length ? COLL_ARGS : DEFAULT_COLLS;
|
||
|
||
const localPrefix = `${config.publicUrl}/uploads/`;
|
||
const localDir = config.storage.localDir;
|
||
const minioBase = config.storage.minio.publicBase;
|
||
const minioBucket = config.storage.minio.bucket;
|
||
const ossBase = config.storage.oss.publicBase;
|
||
|
||
function isLocal(u) { return u.startsWith(localPrefix) || u.startsWith('/uploads/'); }
|
||
function isMinio(u) { return (minioBase && u.startsWith(minioBase)) || (minioBucket && u.includes(`/${minioBucket}/`)); }
|
||
function isOss(u) { return /\.aliyuncs\.com\//.test(u) || (ossBase && u.startsWith(ossBase)); }
|
||
// 只处理我们自己托管的公开媒体 URL;第三方外链、data: 一律不动。
|
||
function migratable(u) {
|
||
if (!/^(https?:)?\/\//.test(u) && !u.startsWith('/uploads/')) return false;
|
||
if (isMinio(u)) return false; // 已在 MinIO
|
||
return isLocal(u) || (INCLUDE_OSS && isOss(u));
|
||
}
|
||
|
||
async function bytesFor(u) {
|
||
if (isLocal(u)) {
|
||
const base = path.basename(u.split('?')[0]);
|
||
return fs.readFile(path.join(localDir, base)); // 本地盘直接读
|
||
}
|
||
const r = await fetch(u); // OSS/远程:HTTP 拉
|
||
if (!r.ok) throw new Error(`fetch ${r.status}`);
|
||
return Buffer.from(await r.arrayBuffer());
|
||
}
|
||
|
||
const cache = new Map(); // 旧 URL → 新 URL(同一文件只迁一次)
|
||
let migrated = 0, failed = 0;
|
||
async function migrateUrl(u) {
|
||
if (cache.has(u)) return cache.get(u);
|
||
try {
|
||
const buf = await bytesFor(u);
|
||
const base = path.basename(u.split('?')[0]) || 'file';
|
||
const newUrl = COMMIT ? await saveMedia(buf, base) : `MINIO:${base}`; // 干跑不真传
|
||
cache.set(u, newUrl);
|
||
migrated++;
|
||
if (migrated % 20 === 0) console.log(` …已处理 ${migrated} 个文件`);
|
||
return newUrl;
|
||
} catch (e) {
|
||
failed++;
|
||
console.warn(` ✗ 跳过 ${u} —— ${e.message}`);
|
||
cache.set(u, null);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// BSON 特殊类型(ObjectId/Date/Binary 等)不递归。
|
||
function isPlainContainer(o) {
|
||
return o && typeof o === 'object' && !o._bsontype && !(o instanceof Date) && !Buffer.isBuffer(o);
|
||
}
|
||
async function walk(node) {
|
||
let changed = false;
|
||
const entries = Array.isArray(node) ? node.map((v, i) => [i, v]) : Object.entries(node);
|
||
for (const [k, v] of entries) {
|
||
if (typeof v === 'string') {
|
||
if (migratable(v)) { const nu = await migrateUrl(v); if (nu) { node[k] = nu; changed = true; } }
|
||
} else if (isPlainContainer(v)) {
|
||
if (await walk(v)) changed = true;
|
||
}
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
async function run() {
|
||
if (!minioConfigured) {
|
||
console.error('❌ 未配置 MinIO(MINIO_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY)。先在 .env 配好再跑。');
|
||
process.exit(1);
|
||
}
|
||
console.log(`模式: ${COMMIT ? '★ COMMIT(真写库)' : '干跑(只统计,不改库/不传文件)'} | 含OSS: ${INCLUDE_OSS ? '是' : '否'} | 集合: ${COLLS.join(', ')}`);
|
||
await connectDb();
|
||
const db = getDb();
|
||
let totalChanged = 0;
|
||
for (const coll of COLLS) {
|
||
let scanned = 0, changedDocs = 0;
|
||
try {
|
||
for await (const doc of db.collection(coll).find({})) {
|
||
scanned++;
|
||
if (await walk(doc)) {
|
||
changedDocs++;
|
||
if (COMMIT) await db.collection(coll).replaceOne({ _id: doc._id }, doc);
|
||
}
|
||
}
|
||
} catch (e) { console.warn(`[${coll}] 扫描出错(可能无此集合): ${e.message}`); continue; }
|
||
totalChanged += changedDocs;
|
||
console.log(`[${coll}] 扫描 ${scanned} 文档,需迁移 ${changedDocs} 条`);
|
||
}
|
||
console.log('──────────────────────────');
|
||
console.log(`唯一文件: ${cache.size} 迁移成功: ${migrated} 失败: ${failed} 受影响文档: ${totalChanged}`);
|
||
console.log(COMMIT ? '✅ 已写库。' : 'ℹ️ 干跑结束——加 --commit 才会真迁移。');
|
||
await closeDb();
|
||
process.exit(0);
|
||
}
|
||
run().catch((e) => { console.error('迁移失败:', e); process.exit(1); });
|