self::DEFAULT_BATCH_SIZE, 'sleep_ms' => self::DEFAULT_SLEEP_MS, 'resume' => true, 'dry_run' => false, ); $options = wp_parse_args( $options, $defaults ); $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); if ( ! $adapter ) { return self::error_result( "Adapter not found: {$entity_type}" ); } $group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); if ( empty( $group_fields ) ) { return self::error_result( "Group not registered: {$entity_type}/{$group_name}" ); } $managed_keys = array_column( $group_fields, 'key' ); if ( empty( $managed_keys ) ) { return self::error_result( 'No keys to migrate' ); } $target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); if ( ! TMDO_Schema_Manager::table_exists( $target_table ) && ! $options['dry_run'] ) { return self::error_result( "Target table does not exist: {$target_table}" ); } // 斷點 $last_id = $options['resume'] ? self::get_checkpoint( $entity_type, $group_name ) : 0; // 標記開始 if ( ! $options['dry_run'] ) { self::mark_migration_started( $entity_type, $group_name ); } $stats = array( 'entity_type' => $entity_type, 'group' => $group_name, 'migrated' => 0, 'errors' => 0, 'skipped' => 0, 'elapsed_sec' => 0, 'dry_run' => $options['dry_run'], ); $start_time = microtime( true ); global $wpdb; $meta_table = $adapter->get_native_meta_table(); $id_col = $adapter->get_entity_id_column(); $field_map = array_column( $group_fields, null, 'key' ); // 建立 IN 子句佔位符 $keys_placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) ); do { // Cursor-based 分頁 $query = $wpdb->prepare( "SELECT DISTINCT `{$id_col}` FROM `{$meta_table}` WHERE `meta_key` IN ({$keys_placeholders}) AND `{$id_col}` > %d ORDER BY `{$id_col}` ASC LIMIT %d", ...array_merge( $managed_keys, array( $last_id, $options['batch_size'] ) ) ); $entity_ids = $wpdb->get_col( $query ); if ( empty( $entity_ids ) ) { break; } foreach ( $entity_ids as $entity_id ) { $entity_id = (int) $entity_id; try { $migrated = self::migrate_single_entity( $entity_type, $group_name, $entity_id, $managed_keys, $field_map, $adapter, $options['dry_run'] ); if ( $migrated ) { ++$stats['migrated']; } else { ++$stats['skipped']; } } catch ( \Throwable $e ) { ++$stats['errors']; error_log( sprintf( '[UAE Migration] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) ); } $last_id = $entity_id; } // 更新斷點 if ( ! $options['dry_run'] ) { self::update_checkpoint( $entity_type, $group_name, $last_id, $stats['migrated'] ); } // 釋放記憶體 $wpdb->flush(); // 延遲(降低 DB 負載) if ( $options['sleep_ms'] > 0 ) { usleep( $options['sleep_ms'] * 1000 ); } } while ( count( $entity_ids ) === $options['batch_size'] ); // 標記完成 if ( ! $options['dry_run'] ) { self::mark_migration_completed( $entity_type, $group_name ); } $stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 ); return $stats; } /** * 遷移單一 entity 的所有 meta */ private static function migrate_single_entity( string $entity_type, string $group_name, int $entity_id, array $managed_keys, array $field_map, TMDO_Entity_Adapter_Interface $adapter, bool $dry_run ): bool { global $wpdb; $meta_table = $adapter->get_native_meta_table(); $id_col = $adapter->get_entity_id_column(); // 取出此 entity 所有相關的 meta $placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) ); $metas = $wpdb->get_results( $wpdb->prepare( "SELECT `meta_key`, `meta_value` FROM `{$meta_table}` WHERE `{$id_col}` = %d AND `meta_key` IN ({$placeholders})", ...array_merge( array( $entity_id ), $managed_keys ) ), ARRAY_A ); if ( empty( $metas ) ) { return false; } // 組裝 row 資料 $data = array( $id_col => $entity_id ); $formats = array( '%d' ); foreach ( $metas as $meta ) { $key = $meta['meta_key']; $value = $meta['meta_value']; if ( ! isset( $field_map[ $key ] ) ) { continue; } $field_def = $field_map[ $key ]; // 安全反序列化 — wp_usermeta 是使用者可寫表,攻擊者可植入序列化物件 // 觸發 __wakeup/__destruct gadget chain。allowed_classes=false 阻擋。 $value = self::safe_unserialize( $value ); $col = TMDO_Schema_Manager::sanitize_column_name( $key ); $data[ $col ] = TMDO_Type_Caster::to_db( $value, $field_def ); $formats[] = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] ); } if ( count( $data ) <= 1 ) { return false; } if ( $dry_run ) { return true; } $target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); // REPLACE 作 Upsert return $wpdb->replace( $target_table, $data, $formats ) !== false; } // ───────────────────────────────────────────────────────── // 單批次非同步遷移(WP Cron 用) // ───────────────────────────────────────────────────────── /** * 執行一個批次遷移並回傳進度(由 WP Cron 驅動,自動重排直到完成)。 * * @param string $entity_type * @param string $group_name * @param int $batch_size * @return array{done:bool,migrated:int,last_id:int,total:int,status:string,error?:string} */ public static function migrate_group_batch( string $entity_type, string $group_name, int $batch_size = self::DEFAULT_BATCH_SIZE ): array { $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); if ( ! $adapter ) { return array( 'done' => true, 'migrated' => 0, 'last_id' => 0, 'total' => 0, 'status' => 'error', 'error' => "Adapter not found: {$entity_type}", ); } $group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); if ( empty( $group_fields ) ) { return array( 'done' => true, 'migrated' => 0, 'last_id' => 0, 'total' => 0, 'status' => 'error', 'error' => "Group not registered: {$entity_type}/{$group_name}", ); } $managed_keys = array_column( $group_fields, 'key' ); $target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); if ( ! TMDO_Schema_Manager::table_exists( $target_table ) ) { return array( 'done' => true, 'migrated' => 0, 'last_id' => 0, 'total' => 0, 'status' => 'error', 'error' => "Target table not found: {$target_table}", ); } global $wpdb; $status_table = self::get_status_table(); $row = $wpdb->get_row( $wpdb->prepare( "SELECT last_id, total_migrated FROM `{$status_table}` WHERE entity_type = %s AND group_name = %s", $entity_type, $group_name ), ARRAY_A ); $last_id = (int) ( $row['last_id'] ?? 0 ); $prev_total = (int) ( $row['total_migrated'] ?? 0 ); self::mark_migration_started( $entity_type, $group_name ); $meta_table = $adapter->get_native_meta_table(); $id_col = $adapter->get_entity_id_column(); $field_map = array_column( $group_fields, null, 'key' ); $keys_placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) ); $entity_ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT `{$id_col}` FROM `{$meta_table}` WHERE `meta_key` IN ({$keys_placeholders}) AND `{$id_col}` > %d ORDER BY `{$id_col}` ASC LIMIT %d", ...array_merge( $managed_keys, array( $last_id, $batch_size ) ) ) ); $batch_migrated = 0; foreach ( $entity_ids as $entity_id ) { $entity_id = (int) $entity_id; try { if ( self::migrate_single_entity( $entity_type, $group_name, $entity_id, $managed_keys, $field_map, $adapter, false ) ) { ++$batch_migrated; } } catch ( \Throwable $e ) { error_log( sprintf( '[UAE Migration Batch] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) ); } $last_id = $entity_id; } $total = $prev_total + $batch_migrated; $done = count( $entity_ids ) < $batch_size; if ( $done ) { self::mark_migration_completed( $entity_type, $group_name ); } else { self::update_checkpoint( $entity_type, $group_name, $last_id, $total ); } $wpdb->flush(); return array( 'done' => $done, 'migrated' => $batch_migrated, 'last_id' => $last_id, 'total' => $total, 'status' => $done ? 'completed' : 'running', ); } // ───────────────────────────────────────────────────────── // 斷點管理 // ───────────────────────────────────────────────────────── private static function get_status_table(): string { global $wpdb; return $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status'; } public static function get_checkpoint( string $entity_type, string $group_name ): int { global $wpdb; $table = self::get_status_table(); $last_id = $wpdb->get_var( $wpdb->prepare( "SELECT last_id FROM `{$table}` WHERE entity_type = %s AND group_name = %s", $entity_type, $group_name ) ); return (int) ( $last_id ?? 0 ); } private static function update_checkpoint( string $entity_type, string $group_name, int $last_id, int $total ): void { global $wpdb; $table = self::get_status_table(); $wpdb->replace( $table, array( 'entity_type' => $entity_type, 'group_name' => $group_name, 'last_id' => $last_id, 'total_migrated' => $total, 'status' => 'running', ), array( '%s', '%s', '%d', '%d', '%s' ) ); } private static function mark_migration_started( string $entity_type, string $group_name ): void { global $wpdb; $table = self::get_status_table(); $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM `{$table}` WHERE entity_type = %s AND group_name = %s", $entity_type, $group_name ) ); if ( $existing ) { $wpdb->update( $table, array( 'status' => 'running', 'started_at' => current_time( 'mysql' ), ), array( 'id' => $existing ), array( '%s', '%s' ), array( '%d' ) ); } else { $wpdb->insert( $table, array( 'entity_type' => $entity_type, 'group_name' => $group_name, 'status' => 'running', 'started_at' => current_time( 'mysql' ), ), array( '%s', '%s', '%s', '%s' ) ); } } private static function mark_migration_completed( string $entity_type, string $group_name ): void { global $wpdb; $table = self::get_status_table(); $wpdb->update( $table, array( 'status' => 'completed', 'completed_at' => current_time( 'mysql' ), ), array( 'entity_type' => $entity_type, 'group_name' => $group_name, ), array( '%s', '%s' ), array( '%s', '%s' ) ); } public static function reset_checkpoint( string $entity_type, string $group_name ): void { global $wpdb; $wpdb->delete( self::get_status_table(), array( 'entity_type' => $entity_type, 'group_name' => $group_name, ), array( '%s', '%s' ) ); } // ───────────────────────────────────────────────────────── // 驗證 // ───────────────────────────────────────────────────────── /** * 驗證遷移資料完整性(抽樣比對) */ public static function verify( string $entity_type, string $group_name, int $sample_size = 100 ): array { global $wpdb; $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); if ( ! $adapter ) { return array( 'error' => 'Adapter not found' ); } $wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); $meta_table = $adapter->get_native_meta_table(); $id_col = $adapter->get_entity_id_column(); if ( ! TMDO_Schema_Manager::table_exists( $wpdo_table ) ) { return array( 'error' => "UAE table not found: {$wpdo_table}" ); } // 隨機抽樣 UAE 表中的 IDs $sample_ids = $wpdb->get_col( $wpdb->prepare( "SELECT `{$id_col}` FROM `{$wpdo_table}` ORDER BY RAND() LIMIT %d", $sample_size ) ); if ( empty( $sample_ids ) ) { return array( 'sampled' => 0, 'match' => 0, 'mismatch' => 0, ); } $group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); $field_map = array_column( $group_fields, null, 'key' ); $managed_keys = array_column( $group_fields, 'key' ); $match = 0; $mismatch = 0; $details = array(); foreach ( $sample_ids as $entity_id ) { $entity_id = (int) $entity_id; // 取 UAE 資料 $wpdo_row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `{$wpdo_table}` WHERE `{$id_col}` = %d", $entity_id ), ARRAY_A ); // 取原始 meta $placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) ); $raw_metas = $wpdb->get_results( $wpdb->prepare( "SELECT `meta_key`, `meta_value` FROM `{$meta_table}` WHERE `{$id_col}` = %d AND `meta_key` IN ({$placeholders})", ...array_merge( array( $entity_id ), $managed_keys ) ), ARRAY_A ); $is_match = true; foreach ( $raw_metas as $raw ) { $key = $raw['meta_key']; if ( ! isset( $field_map[ $key ] ) ) { continue; } $raw_value = self::safe_unserialize( $raw['meta_value'] ); $col = TMDO_Schema_Manager::sanitize_column_name( $key ); $field_def = $field_map[ $key ]; $wpdo_value = TMDO_Type_Caster::from_db( $wpdo_row[ $col ] ?? null, $field_def ); // v2.1.6: type-aware comparison. The previous `(string)` cast falsely // flagged numeric-precision differences (`"5678.90" !== "5678.9"`) // even though both are numerically equal. Aligned with // TMDO_Shadow_Diff_Logger::values_equal() semantics. if ( ! self::loose_equal( $raw_value, $wpdo_value, $field_def['type'] ?? 'text' ) ) { $is_match = false; $details[] = array( 'entity_id' => $entity_id, 'key' => $key, 'raw' => $raw_value, 'uae' => $wpdo_value, ); break; } } $is_match ? $match++ : $mismatch++; } return array( 'sampled' => count( $sample_ids ), 'match' => $match, 'mismatch' => $mismatch, 'match_rate_pct' => count( $sample_ids ) > 0 ? round( $match / count( $sample_ids ) * 100, 2 ) : 0, 'mismatch_details' => array_slice( $details, 0, 10 ), ); } // ───────────────────────────────────────────────────────── // 回溯(UAE → wp_*meta 反向遷移) // ───────────────────────────────────────────────────────── /** * 反向遷移:將 UAE 表資料寫回 wp_*meta * 用於解除安裝前的資料保留 */ public static function rollback_group( string $entity_type, string $group_name, array $options = array() ): array { $defaults = array( 'batch_size' => 500, 'sleep_ms' => 50, ); $options = wp_parse_args( $options, $defaults ); global $wpdb; $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); if ( ! $adapter ) { return self::error_result( "Adapter not found: {$entity_type}" ); } $wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); $meta_table = $adapter->get_native_meta_table(); $id_col = $adapter->get_entity_id_column(); $group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); $field_map = array_column( $group_fields, null, 'key' ); $stats = array( 'written' => 0, 'errors' => 0, ); $last_id = 0; do { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM `{$wpdo_table}` WHERE `{$id_col}` > %d ORDER BY `{$id_col}` ASC LIMIT %d", $last_id, $options['batch_size'] ), ARRAY_A ); if ( empty( $rows ) ) { break; } foreach ( $rows as $row ) { $entity_id = (int) $row[ $id_col ]; foreach ( $field_map as $key => $field_def ) { $col = TMDO_Schema_Manager::sanitize_column_name( $key ); $value = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field_def ); if ( $value === null ) { continue; } // 直接寫 wp_*meta,繞過 UAE 攔截(因為我們正在反向遷移) $wpdb->replace( $meta_table, array( $id_col => $entity_id, 'meta_key' => $key, 'meta_value' => maybe_serialize( $value ), ), array( '%d', '%s', '%s' ) ); ++$stats['written']; } $last_id = $entity_id; } $wpdb->flush(); usleep( $options['sleep_ms'] * 1000 ); } while ( count( $rows ) === $options['batch_size'] ); return $stats; } private static function error_result( string $message ): array { return array( 'error' => $message, 'migrated' => 0, 'errors' => 1, ); } /** * Object-safe replacement for `maybe_unserialize()`. * * `wp_usermeta` / `wp_postmeta` rows can contain attacker-planted serialized * objects. `maybe_unserialize()` calls `unserialize()` with default options, * which materializes objects and triggers `__wakeup`/`__destruct` gadgets. * During backfill the migration engine runs in admin context, so any gadget * chain in vendor/ becomes RCE. * * This wrapper passes `allowed_classes => false` so PHP returns * `__PHP_Incomplete_Class` instances without ever invoking magic methods on * the original class. We then convert those to null so they cannot leak * into a flat-table column. * * @param mixed $value Raw meta_value from native EAV table. * @return mixed Unserialized array/scalar, or original string if not serialized. */ private static function safe_unserialize( $value ) { if ( ! is_string( $value ) ) { return $value; } $trimmed = trim( $value ); // Cheap inline detector — does not rely on WP's is_serialized() so this // function works in CLI / standalone migration contexts. Mirrors the // shape checks WP does: type-tag at offset 0, ':' at offset 1, plausible // terminator. PHP serialize tokens are: a (array), O (object), // s (string), i (int), d (float), b (bool), N; (null). if ( 'N;' !== $trimmed ) { if ( strlen( $trimmed ) < 4 || ':' !== ( $trimmed[1] ?? '' ) ) { return $value; } if ( ! in_array( $trimmed[0] ?? '', array( 'a', 'O', 's', 'i', 'd', 'b' ), true ) ) { return $value; } } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- explicit allowed_classes=false hardens against object injection. $result = @unserialize( $trimmed, array( 'allowed_classes' => false ) ); if ( false === $result && 'b:0;' !== $trimmed ) { return $value; } // Strip __PHP_Incomplete_Class artefacts (allowed_classes=false replaces // any object marker with this stub). They must never reach a flat-table // JSON column or a downstream consumer. if ( is_object( $result ) ) { return null; } if ( is_array( $result ) ) { array_walk_recursive( $result, static function ( &$v ) { if ( is_object( $v ) ) { $v = null; } } ); } return $result; } /** * Type-aware loose equality check for verify() sample comparison. * * Mirrors TMDO_Shadow_Diff_Logger::values_equal() so the two divergence * detection paths (cron verify + live shadow_compare) agree on what * constitutes a real mismatch vs a representation difference. * * @param mixed $eav Native wp_*meta value (after maybe_unserialize). * @param mixed $flat Value from the WPDO flat table (after type cast). * @param string $type Field type from registry (text/integer/decimal/...). * @return bool */ private static function loose_equal( $eav, $flat, string $type ): bool { if ( $eav === $flat ) { return true; } if ( in_array( $type, array( 'integer', 'decimal' ), true ) ) { return is_numeric( $eav ) && is_numeric( $flat ) && (float) $eav === (float) $flat; } if ( 'boolean' === $type ) { return (bool) $eav === (bool) $flat; } if ( 'json' === $type || is_array( $eav ) || is_array( $flat ) ) { return wp_json_encode( $eav ) === wp_json_encode( $flat ); } return (string) $eav === (string) $flat; } /** * 取得所有遷移狀態 */ public static function get_all_statuses(): array { global $wpdb; $table = self::get_status_table(); return $wpdb->get_results( "SELECT * FROM `{$table}` ORDER BY entity_type, group_name", ARRAY_A ) ?: array(); } }