cleanup_unused_water_tables.php 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. <?php
  2. /** 限定清理4张已确认废弃表。默认只检查;必须提供完整备份目录。 */
  3. declare(strict_types=1);
  4. require dirname(__DIR__).'/vendor/autoload.php';
  5. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  6. \support\App::loadAllConfig(['route']);
  7. \Webman\ThinkOrm\ThinkOrm::start(null);
  8. use support\think\Db;
  9. const TARGETS = ['sa_device_operation_profile','sa_article_banner','sa_article','sa_article_category'];
  10. function fingerprint(array $rows): string {
  11. $encoded = [];
  12. foreach ($rows as $row) {
  13. ksort($row);
  14. foreach ($row as &$value) if ($value !== null) $value = (string)$value;
  15. unset($value);
  16. $encoded[] = json_encode($row,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);
  17. }
  18. sort($encoded,SORT_STRING);
  19. return hash('sha256',implode("\n",$encoded));
  20. }
  21. function backupDdl(array $snapshot,string $table,string $target,bool $temporary = false): string {
  22. $schema = $snapshot['tables'][$table]['schema'] ?? '';
  23. $prefix = 'CREATE TABLE `'.$table.'`';
  24. if (!str_starts_with($schema,$prefix)) throw new RuntimeException('Unexpected backup schema for '.$table);
  25. return 'CREATE '.($temporary ? 'TEMPORARY ' : '').'TABLE `'.$target.'`'.substr($schema,strlen($prefix));
  26. }
  27. function insertRows(string $table,array $rows): void {
  28. foreach (array_chunk($rows,100) as $chunk) Db::table($table)->insertAll($chunk);
  29. }
  30. $directory = $argv[1] ?? '';
  31. $file = $directory.'/database.json.gz';
  32. $manifestFile = $directory.'/database-manifest.json';
  33. if (!is_file($file) || !is_file($manifestFile)) throw new RuntimeException('Verified full backup directory required');
  34. if (Db::query('SELECT DATABASE() AS name')[0]['name'] !== 'water') throw new RuntimeException('Refusing to run outside water database');
  35. $manifest = json_decode(file_get_contents($manifestFile),true,512,JSON_THROW_ON_ERROR);
  36. if (!hash_equals($manifest['sha256'],hash_file('sha256',$file))) throw new RuntimeException('Backup checksum mismatch');
  37. $snapshot = json_decode(gzdecode(file_get_contents($file)),true,512,JSON_THROW_ON_ERROR);
  38. foreach (TARGETS as $table) {
  39. if (!isset($snapshot['tables'][$table]) || count($snapshot['tables'][$table]['rows']) !== ($manifest['tables'][$table] ?? -1)) throw new RuntimeException('Missing or inconsistent backup for '.$table);
  40. }
  41. $present = array_column(Db::query('SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()'),'name');
  42. $restore = in_array('--restore',$argv,true);
  43. $apply = in_array('--apply',$argv,true);
  44. if ($restore) {
  45. // 不覆盖任何同名表;恢复需显式 --restore --apply。
  46. foreach (TARGETS as $table) if (in_array($table,$present,true)) throw new RuntimeException('Restore refuses existing table: '.$table);
  47. foreach (TARGETS as $table) {
  48. if ($apply) {
  49. Db::execute(backupDdl($snapshot,$table,$table));
  50. insertRows($table,$snapshot['tables'][$table]['rows']);
  51. if (fingerprint(Db::table($table)->select()->toArray()) !== fingerprint($snapshot['tables'][$table]['rows'])) throw new RuntimeException('Restore verification failed: '.$table);
  52. }
  53. echo ($apply ? 'Restored ' : 'Restore ready ').$table.' rows='.count($snapshot['tables'][$table]['rows'])."\n";
  54. }
  55. exit(0);
  56. }
  57. // 删除前每次复核已知运行路径中的静态引用,防止盘点后新增依赖。
  58. $pattern = '/(?<![a-zA-Z0-9_])(?:sa_)?(?:article|article_banner|article_category|device_operation_profile)(?![a-zA-Z0-9_])/i';
  59. foreach (['app','config','plugin/saiadmin/app','plugin/saiadmin/config','plugin/saipackage/app','plugin/saipackage/config'] as $relative) {
  60. $path = dirname(__DIR__).'/'.$relative;
  61. if (!is_dir($path)) continue;
  62. foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path,FilesystemIterator::SKIP_DOTS)) as $source) {
  63. if ($source->getExtension()==='php' && preg_match($pattern,file_get_contents($source->getPathname()))) throw new RuntimeException('Runtime reference found: '.$source->getPathname());
  64. }
  65. }
  66. $dependencies = array_merge(
  67. Db::query('SELECT REFERENCED_TABLE_NAME AS definition FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE()'),
  68. Db::query('SELECT VIEW_DEFINITION AS definition FROM information_schema.VIEWS WHERE TABLE_SCHEMA=DATABASE()'),
  69. Db::query("SELECT CONCAT(EVENT_OBJECT_TABLE,' ',ACTION_STATEMENT) AS definition FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=DATABASE()"),
  70. Db::query('SELECT ROUTINE_DEFINITION AS definition FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA=DATABASE()'),
  71. Db::query('SELECT EVENT_DEFINITION AS definition FROM information_schema.EVENTS WHERE EVENT_SCHEMA=DATABASE()')
  72. );
  73. foreach ($dependencies as $dependency) {
  74. if ($dependency['definition'] === null) throw new RuntimeException('Cannot inspect a database object definition');
  75. if (preg_match($pattern,$dependency['definition'])) throw new RuntimeException('Database dependency found; cleanup stopped');
  76. }
  77. $beforeRemaining = [];
  78. foreach ($present as $table) {
  79. if (!in_array($table,TARGETS,true)) $beforeRemaining[$table] = (int)Db::table($table)->count();
  80. }
  81. foreach (TARGETS as $table) {
  82. if (!in_array($table,$present,true)) throw new RuntimeException('Target missing before cleanup: '.$table);
  83. $saved = $snapshot['tables'][$table];
  84. if (Db::query('SHOW CREATE TABLE `'.$table.'`')[0]['Create Table'] !== $saved['schema'] || fingerprint(Db::table($table)->select()->toArray()) !== fingerprint($saved['rows'])) {
  85. throw new RuntimeException('Target changed after backup: '.$table);
  86. }
  87. // 临时表仅在本连接内存在,用实际建表+回填+比对验证备份可恢复。
  88. $temporary = '__water_restore_check_'.bin2hex(random_bytes(5));
  89. Db::execute(backupDdl($snapshot,$table,$temporary,true));
  90. try {
  91. insertRows($temporary,$saved['rows']);
  92. if (fingerprint(Db::table($temporary)->select()->toArray()) !== fingerprint($saved['rows'])) throw new RuntimeException('Backup restore rehearsal failed: '.$table);
  93. } finally { Db::execute('DROP TEMPORARY TABLE `'.$temporary.'`'); }
  94. echo 'Verified backup and restore: '.$table.' rows='.count($saved['rows'])."\n";
  95. }
  96. if (!$apply) {
  97. echo "CHECK ONLY: no persistent tables changed. Add --apply to remove the four reviewed tables.\n";
  98. exit(0);
  99. }
  100. // 固定白名单,绝不根据动态“无引用”结果扩大删除范围。
  101. Db::execute('DROP TABLE `sa_device_operation_profile`, `sa_article_banner`, `sa_article`, `sa_article_category`');
  102. $after = array_column(Db::query('SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()'),'name');
  103. foreach (TARGETS as $table) if (in_array($table,$after,true)) throw new RuntimeException('Target remains after cleanup: '.$table);
  104. $changedCounts = [];
  105. foreach ($beforeRemaining as $table=>$count) {
  106. if (!in_array($table,$after,true)) throw new RuntimeException('Unexpected missing table: '.$table);
  107. $now = (int)Db::table($table)->count();
  108. if ($now !== $count) $changedCounts[$table] = ['before'=>$count,'after'=>$now];
  109. }
  110. $result = ['database'=>'water','removed'=>TARGETS,'before_tables'=>count($present),'after_tables'=>count($after),
  111. 'backup_sha256'=>$manifest['sha256'],'remaining_row_count_changes'=>$changedCounts,'finished_at'=>date(DATE_ATOM)];
  112. file_put_contents($directory.'/cleanup-result.json',json_encode($result,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR),LOCK_EX);
  113. echo json_encode($result,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR)."\n";