*/ private const TARGETS = [ 'ecommerce_products' => ['name', 'description'], 'ecommerce_categories' => ['name', 'description'], 'ecommerce_brands' => ['name'], 'ecommerce_promotion_coupons' => ['name', 'description'], 'ecommerce_product_common_infos' => ['name', 'content'], 'boards' => ['name', 'description'], 'boards_report_logs' => ['snapshot'], 'pages' => ['title', 'content'], ]; /** * 커맨드를 실행합니다. * * @return int 종료 코드 */ public function handle(): int { $dryRun = $this->option('dry-run'); $targetTable = $this->option('table'); $chunkSize = (int) $this->option('chunk'); if ($dryRun) { $this->components->info('Dry-run 모드: 실제 변환 없이 대상 건수만 표시합니다.'); } $targets = self::TARGETS; if ($targetTable) { if (! isset($targets[$targetTable])) { $this->components->error("알 수 없는 테이블: {$targetTable}"); $this->components->info('사용 가능한 테이블: '.implode(', ', array_keys($targets))); return self::FAILURE; } $targets = [$targetTable => $targets[$targetTable]]; } $totalConverted = 0; $totalSkipped = 0; foreach ($targets as $table => $columns) { $prefix = DB::getTablePrefix(); if (! $this->tableExists($prefix.$table)) { $this->components->warn("{$table} 테이블이 존재하지 않습니다. 스킵합니다."); continue; } foreach ($columns as $column) { [$converted, $skipped] = $this->processColumn($table, $column, $chunkSize, $dryRun); $totalConverted += $converted; $totalSkipped += $skipped; } } $this->newLine(); if ($dryRun) { $this->components->info("변환 대상: {$totalConverted}건, 스킵(이미 UTF-8): {$totalSkipped}건"); } else { $this->components->info("변환 완료: {$totalConverted}건, 스킵(이미 UTF-8): {$totalSkipped}건"); } return self::SUCCESS; } /** * 테이블 존재 여부를 확인합니다. * * @param string $table 테이블명 (프리픽스 포함) * @return bool 존재 여부 */ private function tableExists(string $table): bool { try { DB::select("SELECT 1 FROM `{$table}` LIMIT 1"); return true; } catch (\Exception) { return false; } } /** * 특정 테이블의 특정 컬럼을 변환합니다. * * @param string $table 테이블명 * @param string $column 컬럼명 * @param int $chunkSize 청크 크기 * @param bool $dryRun 드라이런 모드 * @return array{int, int} [변환 건수, 스킵 건수] */ private function processColumn(string $table, string $column, int $chunkSize, bool $dryRun): array { $converted = 0; $skipped = 0; // \uXXXX 패턴이 포함된 행만 조회 (이미 변환된 행은 스킵) $query = DB::table($table) ->whereNotNull($column) ->where($column, 'LIKE', '%\\\\u%'); $total = $query->count(); if ($total === 0) { $this->components->twoColumnDetail("{$table}.{$column}", '변환 대상 없음'); return [0, 0]; } $this->components->twoColumnDetail("{$table}.{$column}", "대상 {$total}건 처리 중..."); // chunkById(키셋 순회) 필수 — 콜백이 필터 조건 컬럼을 UTF-8 로 다시 써서 처리된 // 행이 `LIKE '%\u%'` 결과에서 이탈한다. OFFSET 기반 chunk() 는 그만큼 앞으로 밀려 // 아직 변환되지 않은 행을 건너뛴다. (대상 테이블 8종 모두 `id` auto-increment PK) DB::table($table) ->whereNotNull($column) ->where($column, 'LIKE', '%\\\\u%') ->chunkById($chunkSize, function ($rows) use ($table, $column, $dryRun, &$converted, &$skipped) { foreach ($rows as $row) { $original = $row->{$column}; $decoded = json_decode($original, true); if ($decoded === null) { $skipped++; continue; } $reencoded = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); // 변환 전후가 동일하면 스킵 if ($reencoded === $original) { $skipped++; continue; } if (! $dryRun) { DB::table($table) ->where('id', $row->id) ->update([$column => $reencoded]); } $converted++; } }); $label = $dryRun ? '변환 대상' : '변환 완료'; $this->components->twoColumnDetail( " └ {$label}", "{$converted}건 (스킵: {$skipped}건)" ); return [$converted, $skipped]; } }