prefix . 'wpdo_shadow_diffs'; } /** * 比對並記錄差異 * * @param string $entity_type * @param int $entity_id * @param string $meta_key * @param mixed $wpdo_value 從 UAE flat table 取得的值(已型別轉換) * @param array $field_def field definition(含 type) */ public static function compare_and_log( string $entity_type, int $entity_id, string $meta_key, $wpdo_value, array $field_def ): void { // 直接從 native meta 表取(繞過 UAE filter 避免無限迴圈) $eav_raw = self::get_eav_value_raw( $entity_type, $entity_id, $meta_key ); // 兩邊都無 → 一致 if ( $eav_raw === null && ( $wpdo_value === null || $wpdo_value === '' ) ) { return; } $type = $field_def['type'] ?? 'text'; $eav_val = self::cast_eav( $eav_raw, $type ); // 比對 if ( self::values_equal( $eav_val, $wpdo_value, $type ) ) { return; // 一致 } // 不一致 → 檢查 rate limit if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) { return; } // 寫入 diff log self::record( $entity_type, $entity_id, $meta_key, self::stringify_for_log( $eav_val ), self::stringify_for_log( $wpdo_value ), $type ); // Trim 舊 rows self::maybe_trim(); } /** * 從 wp_*meta 直接取值(繞過 UAE filter) */ private static function get_eav_value_raw( string $entity_type, int $entity_id, string $meta_key ): ?string { global $wpdb; $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); if ( ! $adapter ) { return null; } $table = $adapter->get_native_meta_table(); $id_col = $adapter->get_entity_id_column(); // 直接 SQL 繞過 get_metadata 系列 filter $value = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s LIMIT 1", $entity_id, $meta_key ) ); return $value === null ? null : (string) $value; } /** * 把 EAV 原始字串轉為該型別的值(用來比對) */ private static function cast_eav( ?string $raw, string $type ) { if ( $raw === null ) { return null; } // v2.13.3: object-injection-safe unserialize (fixes L-DESER-1). // Mirrors WP's maybe_unserialize but with allowed_classes=false. $raw = TMDO_Safe_Unserialize::run( $raw ); switch ( $type ) { case 'integer': return is_numeric( $raw ) ? (int) $raw : null; case 'decimal': return is_numeric( $raw ) ? (float) $raw : null; case 'boolean': return (bool) $raw; case 'json': if ( is_array( $raw ) || is_object( $raw ) ) { return $raw; } $decoded = json_decode( (string) $raw, true ); return $decoded !== null ? $decoded : $raw; default: return $raw; } } /** * 兩值是否一致(依型別做合適比對) */ private static function values_equal( $a, $b, string $type ): bool { if ( $a === $b ) { return true; } // JSON / array 比對:用 canonical JSON if ( $type === 'json' || is_array( $a ) || is_array( $b ) ) { return wp_json_encode( $a ) === wp_json_encode( $b ); } // Numeric:比數值 if ( in_array( $type, array( 'integer', 'decimal' ), true ) ) { return is_numeric( $a ) && is_numeric( $b ) ? (float) $a === (float) $b : $a === $b; } // Boolean:寬鬆 if ( $type === 'boolean' ) { return (bool) $a === (bool) $b; } // Default:鬆散字串比較(EAV 字串 vs casted) return (string) $a === (string) $b; } /** * Rate limit 判斷(同 entity+key 在 300 秒內只記一次) */ private static function is_rate_limited( string $entity_type, int $entity_id, string $meta_key ): bool { $key = 'wpdo_sd_rl_' . md5( "{$entity_type}:{$entity_id}:{$meta_key}" ); if ( get_transient( $key ) !== false ) { return true; } set_transient( $key, 1, self::RATELIMIT_WINDOW ); return false; } /** * 寫入 diff row * * v2.1.6 fix: aligned to actual `wpdo_shadow_diffs` schema installed by * TMDO_Installer. The columns are: ts / entity_type / entity_id / meta_key / * postmeta_value / zone_value / diff_hash. Logger code previously assumed * eav_value / wpdo_value / created_at / field_type — schema-vs-code drift * that silently no-op'd every diff INSERT (wpdb returns 0, no exception). * * `field_type` is consumed only as input to diff_hash so the same * (entity_type, entity_id, meta_key) tuple records distinct rows when the * field's interpreted type changes (rare; mostly a defence-in-depth bucket). */ private static function record( string $entity_type, int $entity_id, string $meta_key, string $eav_value, string $wpdo_value, string $field_type ): void { global $wpdb; $table = self::table_name(); $diff_hash = sha1( $entity_type . '|' . $meta_key . '|' . $field_type . '|' . $eav_value . '|' . $wpdo_value ); $wpdb->insert( $table, array( 'ts' => current_time( 'mysql', true ), 'entity_type' => $entity_type, 'entity_id' => $entity_id, 'meta_key' => $meta_key, 'postmeta_value' => $eav_value, 'zone_value' => $wpdo_value, 'diff_hash' => $diff_hash, ), array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' ) ); TMDO_Logger::warning( 'shadow_read_diff', array( 'entity_type' => $entity_type, 'entity_id' => $entity_id, 'meta_key' => $meta_key, ) ); } /** * 轉為 log 字串 */ private static function stringify_for_log( $value ): string { if ( is_scalar( $value ) || $value === null ) { return (string) $value; } return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } /** * 超過 MAX_ROWS 時 trim 最舊的(chance 1% 才執行,不用每次都跑) */ private static function maybe_trim(): void { if ( wp_rand( 1, 100 ) !== 1 ) { return; } global $wpdb; $table = self::table_name(); $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); if ( $count <= self::MAX_ROWS ) { return; } $to_delete = $count - self::MAX_ROWS; $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d", $to_delete ) ); } // ───────────────────────────────────────────────────────── // Read API (for admin UI + CLI) // ───────────────────────────────────────────────────────── /** * 取得最近 N 筆 diffs */ public static function recent( int $limit = 100, ?string $entity_type = null ): array { global $wpdb; $table = self::table_name(); if ( $entity_type ) { $sql = $wpdb->prepare( "SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d", $entity_type, $limit ); } else { $sql = $wpdb->prepare( "SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d", $limit ); } $rows = $wpdb->get_results( $sql, ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * 統計:按 entity / meta_key 聚合 */ public static function stats_by_key( int $limit = 20 ): array { global $wpdb; $table = self::table_name(); // v2.1.6: column is `ts`, not `created_at` (matches installer schema). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT entity_type, meta_key, COUNT(*) AS c, MAX(ts) AS last_seen FROM `{$table}` GROUP BY entity_type, meta_key ORDER BY c DESC LIMIT %d", $limit ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * 全部 diff 總數 */ public static function total_count(): int { global $wpdb; return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' ); } /** * 按 entity type 統計 */ public static function count_by_entity(): array { global $wpdb; $rows = $wpdb->get_results( 'SELECT entity_type, COUNT(*) AS c FROM `' . self::table_name() . '` GROUP BY entity_type', ARRAY_A ); $result = array(); foreach ( $rows ?: array() as $r ) { $result[ $r['entity_type'] ] = (int) $r['c']; } return $result; } /** * 清空所有 diffs(admin confirmed) */ public static function clear_all(): int { global $wpdb; $table = self::table_name(); $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); $wpdb->query( "TRUNCATE TABLE `{$table}`" ); TMDO_Logger::info( 'shadow_diffs_cleared', array( 'count' => $count, 'user_id' => get_current_user_id(), ) ); return $count; } /** * 清除特定 entity 的 diffs */ public static function clear_entity( string $entity_type ): int { global $wpdb; return (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM `' . self::table_name() . '` WHERE entity_type = %s', $entity_type ) ); } // ───────────────────────────────────────────────────────── // Schema // ───────────────────────────────────────────────────────── public static function install_table(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table_name(); $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(20) NOT NULL, entity_id BIGINT UNSIGNED NOT NULL, meta_key VARCHAR(255) NOT NULL, field_type VARCHAR(20) NOT NULL DEFAULT 'text', eav_value LONGTEXT NULL, wpdo_value LONGTEXT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_entity (entity_type, entity_id), KEY idx_key (entity_type, meta_key(191)), KEY idx_created (created_at) ) {$charset};"; dbDelta( $sql ); } public static function drop_table(): void { global $wpdb; $wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() ); } }