数字我 Digital Me:全量入库(大文件走 Git LFS)
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s

This commit is contained in:
2026-10-10 16:24:43 +08:00
commit 0593ff7e62
1893 changed files with 361723 additions and 0 deletions
@@ -0,0 +1,124 @@
// 存量媒体迁移 → 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); });