clean-oss-orphans.cjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. /**
  2. * OSS audio/ 孤儿文件清理
  3. *
  4. * 策略:OSS audio/ 下的文件,跟 DB 里的 AudioRecord.audioUrl + BookChapter.audioUrl 做差集。
  5. * DB 没引用的 = 孤儿,可选删除。
  6. *
  7. * 用法:
  8. * node deploy-package/scripts/clean-oss-orphans.cjs --dry-run # 默认,列出孤儿不删
  9. * node deploy-package/scripts/clean-oss-orphans.cjs --apply # 真删
  10. * node deploy-package/scripts/clean-oss-orphans.cjs --keep-recent-days 7 --dry-run
  11. * node deploy-package/scripts/clean-oss-orphans.cjs --max-delete 1000 --apply # 单次上限
  12. * node deploy-package/scripts/clean-oss-orphans.cjs --out orphans.json --apply # 留档
  13. *
  14. * 保护: merged/ 目录(章节合并稳定路径)默认不删; BookChapter.audioUrl 引用的绝不删;
  15. * AudioRecord.status='completed' 引用的绝不删; --keep-recent-days 内的不删。
  16. */
  17. const fs = require('fs');
  18. const path = require('path');
  19. // ---- 读 .env.production ----
  20. const envTxt = fs.readFileSync(path.join(__dirname, '..', '..', 'server', '.env.production'), 'utf8');
  21. const env = {};
  22. for (const line of envTxt.split(/\r?\n/)) {
  23. const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
  24. if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
  25. }
  26. const AK = env.OSS_ACCESS_KEY_ID;
  27. const SK = env.OSS_ACCESS_KEY_SECRET;
  28. if (!AK || !SK) {
  29. console.error('❌ 缺少 OSS AK/SK');
  30. process.exit(1);
  31. }
  32. const serverNM = path.join(__dirname, '..', '..', 'server', 'node_modules');
  33. const OSS = require(path.join(serverNM, 'ali-oss'));
  34. let mysql = null;
  35. try { mysql = require(path.join(serverNM, 'mysql2', 'promise')); } catch {}
  36. // ---- 解析参数 ----
  37. const args = process.argv.slice(2);
  38. const opts = {
  39. apply: args.includes('--apply'),
  40. dryRun: !args.includes('--apply'),
  41. keepRecentDays: 0,
  42. maxDelete: Infinity,
  43. bucket: 'rrbrr-book-audio',
  44. out: null,
  45. limitPrefix: null, // 调试用: 只扫某个 prefix
  46. liveUrlsFile: null, // 外部提供的"活 URL"清单 (TSV: src<TAB>url)
  47. skipDb: false,
  48. };
  49. for (let i = 0; i < args.length; i++) {
  50. if (args[i] === '--keep-recent-days') opts.keepRecentDays = parseInt(args[++i], 10);
  51. if (args[i] === '--max-delete') opts.maxDelete = parseInt(args[++i], 10);
  52. if (args[i] === '--out') opts.out = args[++i];
  53. if (args[i] === '--bucket') opts.bucket = args[++i];
  54. if (args[i] === '--prefix') opts.limitPrefix = args[++i];
  55. if (args[i] === '--live-urls-file') opts.liveUrlsFile = args[++i];
  56. if (args[i] === '--skip-db') opts.skipDb = true;
  57. }
  58. function fmtBytes(b) {
  59. if (b < 1024) return b + ' B';
  60. if (b < 1024 ** 2) return (b / 1024).toFixed(1) + ' KB';
  61. if (b < 1024 ** 3) return (b / 1024 ** 2).toFixed(1) + ' MB';
  62. return (b / 1024 ** 3).toFixed(2) + ' GB';
  63. }
  64. async function listAll(client, prefix) {
  65. const all = [];
  66. let marker = null;
  67. do {
  68. const res = await client.list({ prefix, 'max-keys': 1000, marker });
  69. all.push(...(res.objects || []));
  70. marker = res.nextMarker || null;
  71. } while (marker);
  72. return all;
  73. }
  74. // 从 URL 提取 OSS object key。兼容本地路径 / 其它域。
  75. // 不管 host 是哪个 bucket / 哪个 region,只取 path 部分。OSS key 是全局唯一的,bucket 不影响匹配。
  76. function urlToKey(url) {
  77. if (!url) return null;
  78. const s = String(url).trim();
  79. if (!s) return null;
  80. if (s.startsWith('/')) return null; // 本地路径,不算 OSS key
  81. try {
  82. const u = new URL(s);
  83. return decodeURIComponent(u.pathname.replace(/^\/+/, ''));
  84. } catch {
  85. return null;
  86. }
  87. }
  88. (async () => {
  89. console.log(`\n=== OSS 孤儿清理 (bucket=${opts.bucket}) ===`);
  90. console.log(`模式: ${opts.apply ? '🗑 真实删除' : '👀 dry-run(不删)'}`);
  91. if (opts.keepRecentDays) console.log(`保留: 最近 ${opts.keepRecentDays} 天内的文件不删`);
  92. if (isFinite(opts.maxDelete)) console.log(`单次上限: ${opts.maxDelete} 个`);
  93. // ---- 1. 连 OSS ----
  94. const epCandidates = ['oss-cn-shanghai.aliyuncs.com', 'oss-cn-shanghai-internal.aliyuncs.com', 'oss-cn-hangzhou.aliyuncs.com'];
  95. let client = null, usedEp = null;
  96. for (const e of epCandidates) {
  97. try {
  98. const c = new OSS({ accessKeyId: AK, accessKeySecret: SK, bucket: opts.bucket, endpoint: e, secure: true });
  99. await c.getBucketInfo(opts.bucket);
  100. client = c; usedEp = e; break;
  101. } catch {}
  102. }
  103. if (!client) { console.error('❌ OSS 连不上'); process.exit(1); }
  104. console.log(`endpoint: ${usedEp}`);
  105. // ---- 2. 列 OSS audio/ ----
  106. const t0 = Date.now();
  107. const prefix = opts.limitPrefix || 'audio/';
  108. console.log(`\n[1] 扫描 OSS ${prefix} ...`);
  109. const ossObjs = await listAll(client, prefix);
  110. console.log(` 完成,文件数=${ossObjs.length} (${((Date.now()-t0)/1000).toFixed(1)}s)`);
  111. // ---- 3. 拿所有活引用 ----
  112. console.log(`\n[2] 查活引用...`);
  113. const liveKeys = new Set();
  114. const liveKeysBySource = { AudioRecord_completed: 0, AudioRecord_failed: 0, AudioRecord_processing: 0, BookChapter: 0 };
  115. if (opts.liveUrlsFile) {
  116. // 外部提供的清单(SSH 到线上 dump 下来的)
  117. const lines = fs.readFileSync(opts.liveUrlsFile, 'utf8').split(/\r?\n/);
  118. for (const line of lines) {
  119. if (!line.trim()) continue;
  120. const [src, url] = line.split(/\t/);
  121. const k = urlToKey(url);
  122. if (k) {
  123. liveKeys.add(k);
  124. const tag = src === 'BC' ? 'BookChapter' : `AudioRecord_${src === 'AR' ? 'completed' : src}`;
  125. liveKeysBySource[tag] = (liveKeysBySource[tag] || 0) + 1;
  126. }
  127. }
  128. } else if (mysql && !opts.skipDb) {
  129. // 用本地 .env.production 里的 DATABASE_URL 直接连(用户机器上的 DB)
  130. const conn = await mysql.createConnection({ uri: env.DATABASE_URL, multipleStatements: false });
  131. const [arRows] = await conn.query(`SELECT audioId, audioUrl, status, createdAt FROM AudioRecord`);
  132. const [bcRows] = await conn.query(`SELECT id, audioUrl, bookId FROM BookChapter WHERE audioUrl IS NOT NULL AND audioUrl <> ''`);
  133. await conn.end();
  134. for (const r of arRows) {
  135. const k = urlToKey(r.audioUrl);
  136. if (k) { liveKeys.add(k); liveKeysBySource[`AudioRecord_${r.status || 'unknown'}`] = (liveKeysBySource[`AudioRecord_${r.status || 'unknown'}`] || 0) + 1; }
  137. }
  138. for (const r of bcRows) {
  139. const k = urlToKey(r.audioUrl);
  140. if (k) { liveKeys.add(k); liveKeysBySource.BookChapter++; }
  141. }
  142. } else {
  143. console.log(' ⚠️ 未提供 --live-urls-file 且未配置 mysql2 / --skip-db,跳过活引用收集');
  144. }
  145. console.log(` 活引用 keys 总数: ${liveKeys.size}`);
  146. for (const [k, v] of Object.entries(liveKeysBySource)) console.log(` ${k}: ${v}`);
  147. // ---- 4. 找孤儿 ----
  148. console.log(`\n[3] 匹配并找孤儿...`);
  149. const orphanList = [];
  150. const keptList = { live: 0, merged: 0, recent: 0, malformed: 0 };
  151. const nowMs = Date.now();
  152. for (const o of ossObjs) {
  153. const key = o.name;
  154. // 保护 merged/ 章节合并稳定路径
  155. if (key.startsWith('audio/merged/')) { keptList.merged++; continue; }
  156. // 保护有 DB 引用
  157. if (liveKeys.has(key)) { keptList.live++; continue; }
  158. // 保护最近 N 天的
  159. if (opts.keepRecentDays > 0 && o.lastModified) {
  160. const ageMs = nowMs - new Date(o.lastModified).getTime();
  161. if (ageMs < opts.keepRecentDays * 86400_000) { keptList.recent++; continue; }
  162. }
  163. orphanList.push({ key, size: o.size, lastModified: o.lastModified });
  164. }
  165. // 按 size 降序
  166. orphanList.sort((a, b) => b.size - a.size);
  167. const totalOrphanBytes = orphanList.reduce((s, x) => s + x.size, 0);
  168. const totalOssBytes = ossObjs.reduce((s, x) => s + x.size, 0);
  169. console.log(`\n[4] 结果:`);
  170. console.log(` OSS audio/ 总文件: ${ossObjs.length.toLocaleString()} (${fmtBytes(totalOssBytes)})`);
  171. console.log(` ✅ 保留(DB 活引用): ${keptList.live.toLocaleString()}`);
  172. console.log(` ✅ 保留(merged/): ${keptList.merged.toLocaleString()}`);
  173. if (opts.keepRecentDays) console.log(` ✅ 保留(最近${opts.keepRecentDays}天): ${keptList.recent.toLocaleString()}`);
  174. console.log(` ❌ 孤儿(将被删除): ${orphanList.length.toLocaleString()} (${fmtBytes(totalOrphanBytes)})`);
  175. console.log(` 占 OSS 总占用: ${(totalOrphanBytes / totalOssBytes * 100).toFixed(1)}%`);
  176. // 孤儿按月分布
  177. const byMonth = new Map();
  178. for (const x of orphanList) {
  179. const m = x.lastModified ? x.lastModified.slice(0, 7) : '(未知)';
  180. const c = byMonth.get(m) || { count: 0, bytes: 0 };
  181. c.count++; c.bytes += x.size; byMonth.set(m, c);
  182. }
  183. console.log(`\n 孤儿按月分布:`);
  184. for (const [m, c] of [...byMonth.entries()].sort()) {
  185. console.log(` ${m} ${String(c.count).padStart(6)} ${fmtBytes(c.bytes)}`);
  186. }
  187. console.log(`\n 孤儿 Top 20 最大:`);
  188. for (const x of orphanList.slice(0, 20)) {
  189. console.log(` ${fmtBytes(x.size).padStart(10)} ${x.key} (${x.lastModified?.slice(0,10) || '?'})`);
  190. }
  191. // 落档
  192. if (opts.out) {
  193. fs.writeFileSync(opts.out, JSON.stringify({ scannedAt: new Date().toISOString(), bucket: opts.bucket, totalOssBytes, orphanCount: orphanList.length, orphanBytes: totalOrphanBytes, orphans: orphanList }, null, 2));
  194. console.log(`\n 📄 孤儿清单已写到 ${opts.out}`);
  195. }
  196. if (opts.dryRun) {
  197. console.log(`\n👀 DRY-RUN 模式,没动任何文件。`);
  198. console.log(` 确认无误后用 --apply 真实删除:`);
  199. console.log(` node deploy-package/scripts/clean-oss-orphans.cjs --apply${opts.keepRecentDays ? ' --keep-recent-days ' + opts.keepRecentDays : ''}${isFinite(opts.maxDelete) ? ' --max-delete ' + opts.maxDelete : ''}`);
  200. return;
  201. }
  202. // ---- 5. 真实删除 ----
  203. const toDelete = orphanList.slice(0, opts.maxDelete);
  204. console.log(`\n[5] 开始删除 ${toDelete.length} 个文件...`);
  205. const batchSize = 1000; // aliyun deleteMulti 一次最多 1000
  206. let deleted = 0, deletedBytes = 0, fail = 0;
  207. for (let i = 0; i < toDelete.length; i += batchSize) {
  208. const batch = toDelete.slice(i, i + batchSize);
  209. try {
  210. await client.deleteMulti(batch.map(x => x.key));
  211. deleted += batch.length;
  212. deletedBytes += batch.reduce((s, x) => s + x.size, 0);
  213. process.stdout.write(`\r 进度 ${deleted}/${toDelete.length} (${fmtBytes(deletedBytes)}) `);
  214. } catch (e) {
  215. fail++;
  216. console.error(`\n ❌ batch ${i}-${i+batch.length} 删除失败:`, e.code || e.message);
  217. }
  218. }
  219. console.log(`\n\n✅ 完成: 删了 ${deleted} 个文件,释放 ${fmtBytes(deletedBytes)}。失败 batch: ${fail}`);
  220. })().catch(e => { console.error('异常:', e); process.exit(1); });