ReplaceOssUrl.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. <?php
  2. namespace App\Console\Commands;
  3. use Illuminate\Console\Command;
  4. use Illuminate\Support\Facades\DB;
  5. class ReplaceOssUrl extends Command
  6. {
  7. protected $signature = 'oss:replace-url
  8. {--dry-run : 仅预览将要修改的数据,不实际执行更新}
  9. {--model= : 仅处理指定模型,例如:ShopModels\\\\Shop}';
  10. protected $description = '将数据库中所有aliyuncs.com域名统一替换为https://cqzhw.oss-cn-chengdu.aliyuncs.com';
  11. private $targetDomain = 'https://cqzhw.oss-cn-chengdu.aliyuncs.com';
  12. private $totalUpdated = 0;
  13. private $totalRecords = 0;
  14. private $scannedTables = 0;
  15. private $scannedColumns = 0;
  16. private $hitTables = 0;
  17. public function handle()
  18. {
  19. $dryRun = $this->option('dry-run');
  20. $specificModel = $this->option('model');
  21. if ($dryRun) {
  22. $this->warn('=== 预览模式(--dry-run):不会实际修改数据库 ===');
  23. }
  24. $this->line('');
  25. $this->info("目标域名: {$this->targetDomain}");
  26. $this->info("匹配规则: 字段值包含 aliyuncs.com 即替换其域名部分");
  27. $this->line('');
  28. // ============ Step 1: 发现模型 ============
  29. $this->info('━━━ Step 1: 发现模型 ━━━');
  30. if ($specificModel) {
  31. $models = $this->resolveModels([$specificModel]);
  32. } else {
  33. $models = $this->discoverAllModels();
  34. }
  35. if (empty($models)) {
  36. $this->error('未找到任何模型,请检查 app/Models 目录');
  37. return 1;
  38. }
  39. $this->info("发现 " . count($models) . " 个 Eloquent 模型");
  40. $this->line('');
  41. // ============ Step 2: 扫描每张表 ============
  42. $this->info('━━━ Step 2: 扫描数据表 ━━━');
  43. $bar = $this->output->createProgressBar(count($models));
  44. $bar->setFormat('%current%/%max% [%bar%] %percent:3s%% -- %message%');
  45. $bar->setMessage('开始扫描...');
  46. $bar->start();
  47. foreach ($models as $modelClass) {
  48. $shortName = (new \ReflectionClass($modelClass))->getShortName();
  49. $bar->setMessage($shortName);
  50. $this->processModel($modelClass, $dryRun);
  51. $bar->advance();
  52. }
  53. $bar->finish();
  54. // ============ Step 3: 汇总 ============
  55. $this->line('');
  56. $this->line('');
  57. $this->info('━━━ 扫描汇总 ━━━');
  58. $this->info("模型总数: " . count($models));
  59. $this->info("已扫表数: {$this->scannedTables}");
  60. $this->info("已扫字段: {$this->scannedColumns}");
  61. $this->info("命中表数: {$this->hitTables}");
  62. $this->info("命中记录: {$this->totalRecords}");
  63. if (!$dryRun && $this->totalUpdated > 0) {
  64. $this->info("已更新记录: {$this->totalUpdated}");
  65. }
  66. }
  67. private function processModel($modelClass, $dryRun)
  68. {
  69. try {
  70. if (!class_exists($modelClass)) {
  71. $this->line('');
  72. $this->warn(" [跳过] 类不存在: {$modelClass}");
  73. return;
  74. }
  75. /** @var \Illuminate\Database\Eloquent\Model $instance */
  76. $instance = new $modelClass;
  77. $table = $instance->getTable();
  78. $connection = $instance->getConnectionName() ?: config('database.default');
  79. // 跳过视图
  80. if ($table === 'member_perfor_view') {
  81. return;
  82. }
  83. // 获取该表所有文本类型字段
  84. $textColumns = $this->getTextColumns($table, $connection);
  85. if (empty($textColumns)) {
  86. $this->line('');
  87. $this->warn(" [{$connection}:{$table}] 无文本字段或查询失败");
  88. return;
  89. }
  90. $this->scannedTables++;
  91. $this->scannedColumns += count($textColumns);
  92. $tableHit = false;
  93. foreach ($textColumns as $column) {
  94. $count = DB::connection($connection)
  95. ->table($table)
  96. ->where($column, 'like', '%aliyuncs.com%')
  97. ->count();
  98. if ($count === 0) {
  99. continue;
  100. }
  101. if (!$tableHit) {
  102. $tableHit = true;
  103. $this->hitTables++;
  104. $this->line('');
  105. }
  106. $this->totalRecords += $count;
  107. $this->info(" [{$connection}:{$table}.{$column}] 发现 {$count} 条");
  108. if ($dryRun) {
  109. $samples = DB::connection($connection)
  110. ->table($table)
  111. ->select('id', $column)
  112. ->where($column, 'like', '%aliyuncs.com%')
  113. ->limit(3)
  114. ->get();
  115. foreach ($samples as $sample) {
  116. $original = $sample->$column;
  117. $replaced = $this->replaceOssDomain($original);
  118. $this->line(" #{$sample->id}");
  119. $this->line(" 旧: " . mb_strcut($original, 0, 150));
  120. $this->line(" 新: " . mb_strcut($replaced, 0, 150));
  121. }
  122. } else {
  123. $updated = DB::connection($connection)
  124. ->table($table)
  125. ->where($column, 'like', '%aliyuncs.com%')
  126. ->update([
  127. $column => DB::raw("
  128. CASE
  129. WHEN {$column} LIKE '%aliyuncs.com/%' THEN
  130. CONCAT('{$this->targetDomain}/', SUBSTRING_INDEX({$column}, 'aliyuncs.com/', -1))
  131. ELSE
  132. '{$this->targetDomain}'
  133. END
  134. ")
  135. ]);
  136. $this->totalUpdated += $updated;
  137. $this->line(" → 已更新 {$updated} 条");
  138. }
  139. }
  140. } catch (\Exception $e) {
  141. $this->line('');
  142. $this->error(" [异常] {$modelClass}: " . $e->getMessage());
  143. }
  144. }
  145. /**
  146. * 获取指定表的所有文本类型字段
  147. *
  148. * 注意:information_schema 存的是物理表名(含前缀 x_),
  149. * 而 getTable() 返回的是不含前缀的逻辑表名,查询时必须补上。
  150. */
  151. private function getTextColumns($table, $connection)
  152. {
  153. try {
  154. $db = DB::connection($connection);
  155. $database = $db->getDatabaseName();
  156. $prefix = $db->getTablePrefix();
  157. $physicalTable = $prefix . $table;
  158. $rows = $db->select(
  159. "SELECT COLUMN_NAME FROM information_schema.COLUMNS
  160. WHERE TABLE_SCHEMA = ?
  161. AND TABLE_NAME = ?
  162. AND DATA_TYPE IN ('varchar', 'char', 'text', 'mediumtext', 'longtext', 'json')",
  163. [$database, $physicalTable]
  164. );
  165. return array_column($rows, 'COLUMN_NAME');
  166. } catch (\Exception $e) {
  167. $this->line('');
  168. $this->error(" [information_schema查询失败] connection={$connection} table={$table}: " . $e->getMessage());
  169. return [];
  170. }
  171. }
  172. /**
  173. * PHP层面的域名替换(预览用)
  174. */
  175. private function replaceOssDomain($value)
  176. {
  177. if (empty($value) || !is_string($value)) {
  178. return $value;
  179. }
  180. return preg_replace(
  181. '#https?://[^/]*aliyuncs\.com#i',
  182. $this->targetDomain,
  183. $value
  184. );
  185. }
  186. /**
  187. * 解析用户指定的模型类名
  188. */
  189. private function resolveModels($input)
  190. {
  191. $models = [];
  192. foreach ($input as $name) {
  193. // 完整类名
  194. if (strpos($name, 'App\\Models\\') === 0 && class_exists($name)) {
  195. $models[] = $name;
  196. continue;
  197. }
  198. // 相对写法 App\Models\ShopModels\Shop
  199. $fullName = 'App\\Models\\' . ltrim($name, '\\');
  200. if (class_exists($fullName)) {
  201. $models[] = $fullName;
  202. continue;
  203. }
  204. $this->error("模型类不存在: {$name}");
  205. }
  206. return $models;
  207. }
  208. /**
  209. * 自动发现 app/Models 下所有 Eloquent 模型
  210. */
  211. private function discoverAllModels()
  212. {
  213. $models = [];
  214. $basePath = app_path('Models');
  215. if (!is_dir($basePath)) {
  216. $this->error("目录不存在: {$basePath}");
  217. return $models;
  218. }
  219. $this->line(" 扫描目录: {$basePath}");
  220. $iterator = new \RecursiveIteratorIterator(
  221. new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS)
  222. );
  223. $fileCount = 0;
  224. foreach ($iterator as $file) {
  225. if ($file->getExtension() !== 'php') {
  226. continue;
  227. }
  228. $fileCount++;
  229. $pathname = $file->getPathname();
  230. // 统一处理 Windows / Linux 路径分隔符
  231. $relative = str_replace([$basePath . DIRECTORY_SEPARATOR, $basePath . '/', $basePath . '\\'], '', $pathname);
  232. $relative = str_replace(['/', '\\', '.php'], ['\\', '\\', ''], $relative);
  233. $class = 'App\\Models\\' . $relative;
  234. if (class_exists($class) && is_subclass_of($class, \Illuminate\Database\Eloquent\Model::class)) {
  235. $models[] = $class;
  236. }
  237. }
  238. $this->line(" 扫描到 {$fileCount} 个 PHP 文件,其中 " . count($models) . " 个是 Eloquent 模型");
  239. // 打印前5个模型作为样例
  240. $samples = array_slice($models, 0, 5);
  241. foreach ($samples as $m) {
  242. $instance = new $m;
  243. $conn = $instance->getConnectionName() ?: config('database.default');
  244. $this->line(" - {$m} [{$conn}:{$instance->getTable()}]");
  245. }
  246. if (count($models) > 5) {
  247. $this->line(" ... 还有 " . (count($models) - 5) . " 个");
  248. }
  249. return $models;
  250. }
  251. }