// 存量媒体迁移 → 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); });