clean-db-deadlinks.cjs 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. /**
  2. * DB 死链清理 (audioUrl 指向 OSS 但文件已删)
  3. *
  4. * 策略: 跟 OSS 上实际存在的文件做差集, 找出 DB 里指向死链的 audioUrl 字段
  5. *
  6. * 用法:
  7. * node clean-db-deadlinks.cjs --dry-run # 默认, 列出死链不更新
  8. * node clean-db-deadlinks.cjs --apply # 真清 (UPDATE audioUrl='', audioDuration=0)
  9. * node clean-db-deadlinks.cjs --apply --out deadlinks.json # 留档
  10. */
  11. const fs = require('fs');
  12. const path = require('path');
  13. const envTxt = fs.readFileSync(path.join(__dirname, '..', '..', 'server', '.env.production'), 'utf8');
  14. const env = {};
  15. for (const line of envTxt.split(/\r?\n/)) {
  16. const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
  17. if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
  18. }
  19. const serverNM = path.join(__dirname, '..', '..', 'server', 'node_modules');
  20. const OSS = require(path.join(serverNM, 'ali-oss'));
  21. const mysql = require(path.join(serverNM, 'mysql2', 'promise'));
  22. const args = process.argv.slice(2);
  23. const opts = { apply: args.includes('--apply'), dryRun: !args.includes('--apply'), out: null };
  24. for (let i = 0; i < args.length; i++) if (args[i] === '--out') opts.out = args[++i];
  25. // 解析 URL 拿 key, 不管 host
  26. function urlToKey(url) {
  27. if (!url) return null;
  28. const s = String(url).trim();
  29. if (!s || s.startsWith('/')) return null;
  30. try { return decodeURIComponent(new URL(s).pathname.replace(/^\/+/, '')); } catch { return null; }
  31. }
  32. (async () => {
  33. console.log(`\n=== DB 死链清理 (${opts.apply ? '🗑 真实更新' : '👀 dry-run'}) ===`);
  34. // 1. 列 OSS audio/ 所有 key
  35. const client = new OSS({ accessKeyId: env.OSS_ACCESS_KEY_ID, accessKeySecret: env.OSS_ACCESS_KEY_SECRET, bucket: 'rrbrr-book-audio', endpoint: 'oss-cn-shanghai.aliyuncs.com', secure: true });
  36. const liveKeys = new Set();
  37. let marker = null;
  38. do {
  39. const r = await client.list({ prefix: 'audio/', 'max-keys': 1000, marker });
  40. for (const o of (r.objects || [])) liveKeys.add(o.name);
  41. marker = r.nextMarker || null;
  42. } while (marker);
  43. console.log(`OSS 活文件数: ${liveKeys.size}`);
  44. // 2. 查 DB 所有 OSS audioUrl
  45. const conn = await mysql.createConnection({ uri: env.DATABASE_URL });
  46. const [bcRows] = await conn.query(`SELECT id, audioUrl, audioDuration FROM BookChapter WHERE audioUrl LIKE '%aliyuncs.com%'`);
  47. const [arRows] = await conn.query(`SELECT audioId, audioUrl, audioDuration FROM AudioRecord WHERE status='completed' AND audioUrl IS NOT NULL AND audioUrl<>'' AND audioUrl LIKE '%aliyuncs.com%'`);
  48. await conn.end();
  49. console.log(`DB BookChapter 有 OSS URL: ${bcRows.length}`);
  50. console.log(`DB AudioRecord 有 OSS URL: ${arRows.length}`);
  51. // 3. 找死链
  52. const deadBC = [];
  53. for (const r of bcRows) {
  54. const k = urlToKey(r.audioUrl);
  55. if (k && !liveKeys.has(k)) deadBC.push(r);
  56. }
  57. const deadAR = [];
  58. for (const r of arRows) {
  59. const k = urlToKey(r.audioUrl);
  60. if (k && !liveKeys.has(k)) deadAR.push(r);
  61. }
  62. console.log(`\n BookChapter 死链: ${deadBC.length}`);
  63. console.log(` AudioRecord 死链: ${deadAR.length}`);
  64. console.log(` 总计: ${deadBC.length + deadAR.length}`);
  65. if (deadBC.length) {
  66. console.log(`\n BookChapter 死链前 5:`);
  67. for (const r of deadBC.slice(0, 5)) console.log(` id=${r.id} ${r.audioUrl}`);
  68. }
  69. if (deadAR.length) {
  70. console.log(`\n AudioRecord 死链前 5:`);
  71. for (const r of deadAR.slice(0, 5)) console.log(` audioId=${r.audioId} ${r.audioUrl}`);
  72. }
  73. if (opts.out) {
  74. fs.writeFileSync(opts.out, JSON.stringify({ scannedAt: new Date().toISOString(), deadBookChapter: deadBC, deadAudioRecord: deadAR }, null, 2));
  75. console.log(`\n 📄 死链清单已写到 ${opts.out}`);
  76. }
  77. if (opts.dryRun) {
  78. console.log(`\n👀 DRY-RUN 模式, 没动任何数据`);
  79. console.log(` 确认后用 --apply 真清:`);
  80. console.log(` UPDATE BookChapter SET audioUrl='', audioDuration=0 WHERE id IN (${deadBC.map(r=>r.id).join(',')})`);
  81. console.log(` UPDATE AudioRecord SET audioUrl='', audioDuration=0 WHERE audioId IN (${deadAR.map(r=>`"${r.audioId}"`).join(',')})`);
  82. return;
  83. }
  84. // 4. 真清
  85. const conn2 = await mysql.createConnection({ uri: env.DATABASE_URL });
  86. let updatedBC = 0, updatedAR = 0;
  87. if (deadBC.length) {
  88. const ids = deadBC.map(r => r.id);
  89. // 一次最多 1000 个
  90. for (let i = 0; i < ids.length; i += 1000) {
  91. const batch = ids.slice(i, i + 1000);
  92. const [r] = await conn2.query(`UPDATE BookChapter SET audioUrl='', audioDuration=0 WHERE id IN (?)`, [batch]);
  93. updatedBC += r.affectedRows;
  94. }
  95. }
  96. if (deadAR.length) {
  97. const ids = deadAR.map(r => r.audioId);
  98. for (let i = 0; i < ids.length; i += 1000) {
  99. const batch = ids.slice(i, i + 1000);
  100. const [r] = await conn2.query(`UPDATE AudioRecord SET audioUrl='', audioDuration=0 WHERE audioId IN (?)`, [batch]);
  101. updatedAR += r.affectedRows;
  102. }
  103. }
  104. await conn2.end();
  105. console.log(`\n✅ BookChapter 更新: ${updatedBC} 行`);
  106. console.log(`✅ AudioRecord 更新: ${updatedAR} 行`);
  107. })().catch(e => { console.error('异常:', e); process.exit(1); });