inspect-oss-usage.cjs 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. /**
  2. * 只读探查 OSS bucket 占用分布。
  3. * 用法: node deploy-package/scripts/inspect-oss-usage.cjs [bucket]
  4. * bucket 可选,默认依次尝试 server/.env 里的两个候选
  5. *
  6. * 不删除任何文件。纯统计 + 报告。
  7. */
  8. // 优先用环境变量;否则从 server/.env.production 文本读
  9. let AK = process.env.OSS_ACCESS_KEY_ID;
  10. let SK = process.env.OSS_ACCESS_KEY_SECRET;
  11. let REGION = process.env.OSS_REGION || 'oss-cn-hangzhou';
  12. let EP = process.env.OSS_INTERNAL_ENDPOINT || process.env.OSS_ENDPOINT;
  13. let envBucket = process.env.OSS_BUCKET_NAME;
  14. if (!AK || !SK) {
  15. const fs = require('fs');
  16. const envPath = require('path').join(__dirname, '..', '..', 'server', '.env.production');
  17. if (fs.existsSync(envPath)) {
  18. const txt = fs.readFileSync(envPath, 'utf8');
  19. for (const line of txt.split(/\r?\n/)) {
  20. const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
  21. if (!m) continue;
  22. const k = m[1], v = m[2].replace(/^["']|["']$/g, '');
  23. if (k === 'OSS_ACCESS_KEY_ID' && !AK) AK = v;
  24. if (k === 'OSS_ACCESS_KEY_SECRET' && !SK) SK = v;
  25. if (k === 'OSS_REGION' && !process.env.OSS_REGION) REGION = v;
  26. if (k === 'OSS_INTERNAL_ENDPOINT' && !EP) EP = v;
  27. if (k === 'OSS_ENDPOINT' && !EP) EP = v;
  28. if (k === 'OSS_BUCKET_NAME' && !envBucket) envBucket = v;
  29. }
  30. }
  31. }
  32. // 脚本在 deploy-package/scripts 下,ali-oss 在 server/node_modules,从脚本位置往上找
  33. const path = require('path');
  34. const Module = require('module');
  35. const serverNodeModules = path.join(__dirname, '..', '..', 'server', 'node_modules');
  36. if (!Module.globalPaths.includes(serverNodeModules)) Module.globalPaths.push(serverNodeModules);
  37. const OSS = require(path.join(serverNodeModules, 'ali-oss'));
  38. EP = EP || 'oss-cn-hangzhou.aliyuncs.com';
  39. const BUCKETS = process.argv.slice(2);
  40. if (BUCKETS.length === 0) {
  41. // 顺序: 用户指定的 -> .env 里的
  42. BUCKETS.push('rrbrr-book-audio');
  43. const envBucket = process.env.OSS_BUCKET_NAME;
  44. if (envBucket && !BUCKETS.includes(envBucket)) BUCKETS.push(envBucket);
  45. }
  46. function fmtBytes(b) {
  47. if (b < 1024) return b + ' B';
  48. if (b < 1024 ** 2) return (b / 1024).toFixed(1) + ' KB';
  49. if (b < 1024 ** 3) return (b / 1024 ** 2).toFixed(1) + ' MB';
  50. return (b / 1024 ** 3).toFixed(2) + ' GB';
  51. }
  52. async function listAll(client, prefix) {
  53. const all = [];
  54. let marker = null;
  55. // 加 max-keys 1000 是 aliyun 单次上限
  56. do {
  57. const res = await client.list({ prefix, 'max-keys': 1000, marker });
  58. all.push(...(res.objects || []));
  59. marker = res.nextMarker || null;
  60. // isTruncated 不在 ali-oss 默认返回里,用 nextMarker 兜底
  61. } while (marker);
  62. return all;
  63. }
  64. async function probe(bucket) {
  65. console.log(`\n========== bucket: ${bucket} (region=${REGION}) ==========`);
  66. // 已知 bucket 实际 endpoint(list 接口对 region 严格,先试 shanghai;失败再 fallback 其他)
  67. const epCandidates = [
  68. 'oss-cn-shanghai.aliyuncs.com',
  69. 'oss-cn-shanghai-internal.aliyuncs.com',
  70. EP,
  71. ];
  72. let client = null, lastErr = null;
  73. for (const e of epCandidates) {
  74. try {
  75. const c = new OSS({ accessKeyId: AK, accessKeySecret: SK, bucket, endpoint: e, secure: true });
  76. await c.getBucketInfo(bucket);
  77. client = c;
  78. console.log(` (使用 endpoint: ${e})`);
  79. break;
  80. } catch (err) { lastErr = err; }
  81. }
  82. if (!client) {
  83. console.log(' ❌ 所有 endpoint 都连不上:', lastErr?.code, lastErr?.message?.split('\n')[0]);
  84. return;
  85. }
  86. try {
  87. const info = await client.getBucketInfo(bucket);
  88. console.log(' ✅ 可访问. creationDate:', info.bucket?.CreationDate);
  89. } catch (e) {
  90. console.log(' ❌ 无法访问:', e.code || e.message);
  91. return;
  92. }
  93. // 1) 顶层 prefix 概览
  94. console.log('\n[1] 顶层一级 prefix 概览:');
  95. const topRes = await client.list({ 'max-keys': 1000, delimiter: '/' });
  96. const topPrefixes = (topRes.prefixes || []).sort();
  97. for (const p of topPrefixes) console.log(' 📁', p);
  98. // 2) 详细: audio/ 目录
  99. if (topPrefixes.includes('audio/') || topPrefixes.some(p => p.startsWith('audio'))) {
  100. await drillAudio(client);
  101. }
  102. // 3) 其他目录快速占位
  103. for (const p of topPrefixes) {
  104. if (p.startsWith('audio')) continue;
  105. if (['cover/', 'video/', 'uploads/'].includes(p)) {
  106. const objs = await listAll(client, p);
  107. const total = objs.reduce((s, o) => s + (o.size || 0), 0);
  108. console.log(` 📦 ${p} 文件数=${objs.length} 占用=${fmtBytes(total)}`);
  109. }
  110. }
  111. }
  112. async function drillAudio(client) {
  113. console.log('\n[2] audio/ 目录深度扫描 (可能慢):');
  114. const t0 = Date.now();
  115. const objs = await listAll(client, 'audio/');
  116. const dt = ((Date.now() - t0) / 1000).toFixed(1);
  117. console.log(` 扫描完成,耗时 ${dt}s,文件数 ${objs.length}`);
  118. // 按二级目录聚合 (audio/{segment}/...)
  119. const bySecond = new Map();
  120. let totalBytes = 0;
  121. for (const o of objs) {
  122. const key = o.name;
  123. const parts = key.split('/');
  124. const seg = parts[1] || '(root)';
  125. const cur = bySecond.get(seg) || { count: 0, bytes: 0 };
  126. cur.count++;
  127. cur.bytes += o.size || 0;
  128. bySecond.set(seg, cur);
  129. totalBytes += o.size || 0;
  130. }
  131. console.log(` 总计: ${fmtBytes(totalBytes)} across ${bySecond.size} 二级目录`);
  132. // 找最大 20 个文件
  133. const top = [...objs].sort((a, b) => (b.size || 0) - (a.size || 0)).slice(0, 20);
  134. console.log('\n Top 20 最大文件:');
  135. for (const o of top) {
  136. console.log(` ${fmtBytes(o.size || 0).padStart(10)} ${o.name}`);
  137. }
  138. // 按二级目录分桶统计
  139. const secondArr = [...bySecond.entries()]
  140. .map(([seg, v]) => ({ seg, ...v }))
  141. .sort((a, b) => b.bytes - a.bytes);
  142. // 看下分布: 前 10 大的二级目录 + 它们的"段类型" (uuid / preview-uuid / merged/chapter/...)
  143. console.log('\n 按二级目录 (前 30 大占用):');
  144. for (const x of secondArr.slice(0, 30)) {
  145. let kind = x.seg;
  146. if (x.seg === 'merged') kind = 'merged/ (章节合并)';
  147. else if (x.seg.startsWith('preview-')) kind = `preview-uuid (试听预览) ${x.count} 文件`;
  148. else if (x.seg.startsWith('sync-')) kind = `sync-uuid (同步合成) ${x.count} 文件`;
  149. else if (x.seg.length === 36 && x.seg.includes('-')) kind = `uuid ${x.count} 文件`;
  150. else kind = `${x.seg} ${x.count} 文件`;
  151. console.log(` ${fmtBytes(x.bytes).padStart(10)} ${kind}`);
  152. }
  153. // 统计 uuid 数量
  154. const uuidLike = secondArr.filter(x => /^[0-9a-f-]{36}$/.test(x.seg));
  155. const previewLike = secondArr.filter(x => x.seg.startsWith('preview-'));
  156. const syncLike = secondArr.filter(x => x.seg.startsWith('sync-'));
  157. const mergedTotal = secondArr.filter(x => x.seg === 'merged').reduce((s, x) => s + x.bytes, 0);
  158. console.log('\n[3] 分类汇总:');
  159. console.log(` uuid 形态 (TTS 正常合成): ${uuidLike.length} 个目录, ${fmtBytes(uuidLike.reduce((s, x) => s + x.bytes, 0))}`);
  160. console.log(` preview-uuid 形态 (试听预览): ${previewLike.length} 个目录, ${fmtBytes(previewLike.reduce((s, x) => s + x.bytes, 0))}`);
  161. console.log(` sync-uuid 形态 (同步合成): ${syncLike.length} 个目录, ${fmtBytes(syncLike.reduce((s, x) => s + x.bytes, 0))}`);
  162. console.log(` merged/ 章节合并: ${fmtBytes(mergedTotal)}`);
  163. // 大小分布 (找规律: 是不是大多数都是同样大小的 preview 24MB 之类的)
  164. const sizeBuckets = new Map();
  165. for (const x of secondArr) {
  166. const mb = x.bytes / 1024 / 1024;
  167. let bucket;
  168. if (mb < 0.1) bucket = '<0.1MB';
  169. else if (mb < 1) bucket = '0.1-1MB';
  170. else if (mb < 5) bucket = '1-5MB';
  171. else if (mb < 20) bucket = '5-20MB';
  172. else if (mb < 100) bucket = '20-100MB';
  173. else bucket = '>100MB';
  174. sizeBuckets.set(bucket, (sizeBuckets.get(bucket) || 0) + 1);
  175. }
  176. console.log('\n[4] 单目录大小分布:');
  177. for (const [k, v] of sizeBuckets) console.log(` ${k.padEnd(10)} : ${v} 个目录`);
  178. }
  179. (async () => {
  180. if (!AK || !SK) {
  181. console.error('❌ 缺少 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET,请确认 server/.env.production');
  182. process.exit(1);
  183. }
  184. for (const b of BUCKETS) await probe(b);
  185. console.log('\n--- 完成(只读,未删除任何文件)---\n');
  186. })().catch(e => {
  187. console.error('脚本异常:', e);
  188. process.exit(1);
  189. });