d36bb954d1
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
408 lines
12 KiB
PHP
408 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* TMDO_Shadow_Diff_Logger - Shadow-read 差異偵測與記錄
|
|
*
|
|
* 當 bridge 為 shadow_read 模式時,每次讀取都會:
|
|
* 1. 從 UAE flat table 取值
|
|
* 2. 從 wp_*meta 原生表取值
|
|
* 3. 比對
|
|
* 4. 若不一致 → 記錄到 wp_wpdo_uni_shadow_diffs 表
|
|
*
|
|
* 這個表是**持久化**的(不像 AEAV 原本用 transient — 會被 flush 掉),
|
|
* 因為驗證期可能跨多日,不能丟失。
|
|
*
|
|
* 寫入有 rate limit(同 entity+key 在 5 分鐘內只記一筆),防止熱門欄位寫爆。
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
|
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
final class TMDO_Shadow_Diff_Logger {
|
|
|
|
/** 保留最近 N 筆 diffs(超過就 rolling delete)。防止表爆炸。 */
|
|
public const MAX_ROWS = 5000;
|
|
|
|
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
|
|
public const RATELIMIT_WINDOW = 300;
|
|
|
|
public static function table_name(): string {
|
|
global $wpdb;
|
|
return $wpdb->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() );
|
|
}
|
|
}
|