/** * 只读探查 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); });