Files
2meet-data-optimizer/includes/engine/class-tmdo-shadow-diff-logger.php
T
wpdev d36bb954d1 chore: initial snapshot of 2meet-data-optimizer v0.1.0
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
2026-07-31 05:06:36 +08:00

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() );
}
}