| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207 |
- /**
- * 只读探查 OSS bucket 占用分布。
- * 用法: node deploy-package/scripts/inspect-oss-usage.cjs [bucket]
- * bucket 可选,默认依次尝试 server/.env 里的两个候选
- *
- * 不删除任何文件。纯统计 + 报告。
- */
- // 优先用环境变量;否则从 server/.env.production 文本读
- let AK = process.env.OSS_ACCESS_KEY_ID;
- let SK = process.env.OSS_ACCESS_KEY_SECRET;
- let REGION = process.env.OSS_REGION || 'oss-cn-hangzhou';
- let EP = process.env.OSS_INTERNAL_ENDPOINT || process.env.OSS_ENDPOINT;
- let envBucket = process.env.OSS_BUCKET_NAME;
- if (!AK || !SK) {
- const fs = require('fs');
- const envPath = require('path').join(__dirname, '..', '..', 'server', '.env.production');
- if (fs.existsSync(envPath)) {
- const txt = fs.readFileSync(envPath, 'utf8');
- for (const line of txt.split(/\r?\n/)) {
- const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
- if (!m) continue;
- const k = m[1], v = m[2].replace(/^["']|["']$/g, '');
- if (k === 'OSS_ACCESS_KEY_ID' && !AK) AK = v;
- if (k === 'OSS_ACCESS_KEY_SECRET' && !SK) SK = v;
- if (k === 'OSS_REGION' && !process.env.OSS_REGION) REGION = v;
- if (k === 'OSS_INTERNAL_ENDPOINT' && !EP) EP = v;
- if (k === 'OSS_ENDPOINT' && !EP) EP = v;
- if (k === 'OSS_BUCKET_NAME' && !envBucket) envBucket = v;
- }
- }
- }
- // 脚本在 deploy-package/scripts 下,ali-oss 在 server/node_modules,从脚本位置往上找
- const path = require('path');
- const Module = require('module');
- const serverNodeModules = path.join(__dirname, '..', '..', 'server', 'node_modules');
- if (!Module.globalPaths.includes(serverNodeModules)) Module.globalPaths.push(serverNodeModules);
- const OSS = require(path.join(serverNodeModules, 'ali-oss'));
- EP = EP || 'oss-cn-hangzhou.aliyuncs.com';
- const BUCKETS = process.argv.slice(2);
- if (BUCKETS.length === 0) {
- // 顺序: 用户指定的 -> .env 里的
- BUCKETS.push('rrbrr-book-audio');
- const envBucket = process.env.OSS_BUCKET_NAME;
- if (envBucket && !BUCKETS.includes(envBucket)) BUCKETS.push(envBucket);
- }
- function fmtBytes(b) {
- if (b < 1024) return b + ' B';
- if (b < 1024 ** 2) return (b / 1024).toFixed(1) + ' KB';
- if (b < 1024 ** 3) return (b / 1024 ** 2).toFixed(1) + ' MB';
- return (b / 1024 ** 3).toFixed(2) + ' GB';
- }
- async function listAll(client, prefix) {
- const all = [];
- let marker = null;
- // 加 max-keys 1000 是 aliyun 单次上限
- do {
- const res = await client.list({ prefix, 'max-keys': 1000, marker });
- all.push(...(res.objects || []));
- marker = res.nextMarker || null;
- // isTruncated 不在 ali-oss 默认返回里,用 nextMarker 兜底
- } while (marker);
- return all;
- }
- async function probe(bucket) {
- console.log(`\n========== bucket: ${bucket} (region=${REGION}) ==========`);
- // 已知 bucket 实际 endpoint(list 接口对 region 严格,先试 shanghai;失败再 fallback 其他)
- const epCandidates = [
- 'oss-cn-shanghai.aliyuncs.com',
- 'oss-cn-shanghai-internal.aliyuncs.com',
- EP,
- ];
- let client = null, lastErr = null;
- for (const e of epCandidates) {
- try {
- const c = new OSS({ accessKeyId: AK, accessKeySecret: SK, bucket, endpoint: e, secure: true });
- await c.getBucketInfo(bucket);
- client = c;
- console.log(` (使用 endpoint: ${e})`);
- break;
- } catch (err) { lastErr = err; }
- }
- if (!client) {
- console.log(' ❌ 所有 endpoint 都连不上:', lastErr?.code, lastErr?.message?.split('\n')[0]);
- return;
- }
- try {
- const info = await client.getBucketInfo(bucket);
- console.log(' ✅ 可访问. creationDate:', info.bucket?.CreationDate);
- } catch (e) {
- console.log(' ❌ 无法访问:', e.code || e.message);
- return;
- }
- // 1) 顶层 prefix 概览
- console.log('\n[1] 顶层一级 prefix 概览:');
- const topRes = await client.list({ 'max-keys': 1000, delimiter: '/' });
- const topPrefixes = (topRes.prefixes || []).sort();
- for (const p of topPrefixes) console.log(' 📁', p);
- // 2) 详细: audio/ 目录
- if (topPrefixes.includes('audio/') || topPrefixes.some(p => p.startsWith('audio'))) {
- await drillAudio(client);
- }
- // 3) 其他目录快速占位
- for (const p of topPrefixes) {
- if (p.startsWith('audio')) continue;
- if (['cover/', 'video/', 'uploads/'].includes(p)) {
- const objs = await listAll(client, p);
- const total = objs.reduce((s, o) => s + (o.size || 0), 0);
- console.log(` 📦 ${p} 文件数=${objs.length} 占用=${fmtBytes(total)}`);
- }
- }
- }
- async function drillAudio(client) {
- console.log('\n[2] audio/ 目录深度扫描 (可能慢):');
- const t0 = Date.now();
- const objs = await listAll(client, 'audio/');
- const dt = ((Date.now() - t0) / 1000).toFixed(1);
- console.log(` 扫描完成,耗时 ${dt}s,文件数 ${objs.length}`);
- // 按二级目录聚合 (audio/{segment}/...)
- const bySecond = new Map();
- let totalBytes = 0;
- for (const o of objs) {
- const key = o.name;
- const parts = key.split('/');
- const seg = parts[1] || '(root)';
- const cur = bySecond.get(seg) || { count: 0, bytes: 0 };
- cur.count++;
- cur.bytes += o.size || 0;
- bySecond.set(seg, cur);
- totalBytes += o.size || 0;
- }
- console.log(` 总计: ${fmtBytes(totalBytes)} across ${bySecond.size} 二级目录`);
- // 找最大 20 个文件
- const top = [...objs].sort((a, b) => (b.size || 0) - (a.size || 0)).slice(0, 20);
- console.log('\n Top 20 最大文件:');
- for (const o of top) {
- console.log(` ${fmtBytes(o.size || 0).padStart(10)} ${o.name}`);
- }
- // 按二级目录分桶统计
- const secondArr = [...bySecond.entries()]
- .map(([seg, v]) => ({ seg, ...v }))
- .sort((a, b) => b.bytes - a.bytes);
- // 看下分布: 前 10 大的二级目录 + 它们的"段类型" (uuid / preview-uuid / merged/chapter/...)
- console.log('\n 按二级目录 (前 30 大占用):');
- for (const x of secondArr.slice(0, 30)) {
- let kind = x.seg;
- if (x.seg === 'merged') kind = 'merged/ (章节合并)';
- else if (x.seg.startsWith('preview-')) kind = `preview-uuid (试听预览) ${x.count} 文件`;
- else if (x.seg.startsWith('sync-')) kind = `sync-uuid (同步合成) ${x.count} 文件`;
- else if (x.seg.length === 36 && x.seg.includes('-')) kind = `uuid ${x.count} 文件`;
- else kind = `${x.seg} ${x.count} 文件`;
- console.log(` ${fmtBytes(x.bytes).padStart(10)} ${kind}`);
- }
- // 统计 uuid 数量
- const uuidLike = secondArr.filter(x => /^[0-9a-f-]{36}$/.test(x.seg));
- const previewLike = secondArr.filter(x => x.seg.startsWith('preview-'));
- const syncLike = secondArr.filter(x => x.seg.startsWith('sync-'));
- const mergedTotal = secondArr.filter(x => x.seg === 'merged').reduce((s, x) => s + x.bytes, 0);
- console.log('\n[3] 分类汇总:');
- console.log(` uuid 形态 (TTS 正常合成): ${uuidLike.length} 个目录, ${fmtBytes(uuidLike.reduce((s, x) => s + x.bytes, 0))}`);
- console.log(` preview-uuid 形态 (试听预览): ${previewLike.length} 个目录, ${fmtBytes(previewLike.reduce((s, x) => s + x.bytes, 0))}`);
- console.log(` sync-uuid 形态 (同步合成): ${syncLike.length} 个目录, ${fmtBytes(syncLike.reduce((s, x) => s + x.bytes, 0))}`);
- console.log(` merged/ 章节合并: ${fmtBytes(mergedTotal)}`);
- // 大小分布 (找规律: 是不是大多数都是同样大小的 preview 24MB 之类的)
- const sizeBuckets = new Map();
- for (const x of secondArr) {
- const mb = x.bytes / 1024 / 1024;
- let bucket;
- if (mb < 0.1) bucket = '<0.1MB';
- else if (mb < 1) bucket = '0.1-1MB';
- else if (mb < 5) bucket = '1-5MB';
- else if (mb < 20) bucket = '5-20MB';
- else if (mb < 100) bucket = '20-100MB';
- else bucket = '>100MB';
- sizeBuckets.set(bucket, (sizeBuckets.get(bucket) || 0) + 1);
- }
- console.log('\n[4] 单目录大小分布:');
- for (const [k, v] of sizeBuckets) console.log(` ${k.padEnd(10)} : ${v} 个目录`);
- }
- (async () => {
- if (!AK || !SK) {
- console.error('❌ 缺少 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET,请确认 server/.env.production');
- process.exit(1);
- }
- for (const b of BUCKETS) await probe(b);
- console.log('\n--- 完成(只读,未删除任何文件)---\n');
- })().catch(e => {
- console.error('脚本异常:', e);
- process.exit(1);
- });
|