Files
2meet-data-optimizer/includes/engine/class-tmdo-mode-manager.php
T
wpdev a8190a6828 fix(compat): CLI 雙命名空間 + 選單 slug 對齊 + i18n text domain(E4/E6/E7)
E4:38 個子指令補 'wp tmdo' 註冊(原本只有 'wp wpdo')
    cli-v2 15、cli-post 8、cli-member 7、cli-term-comment 8。
    CLAUDE.md 宣稱 tmdo 為主命名空間,但 wp tmdo bridge-status /
    snapshot create 等先前根本不存在。

E6:MENU_SLUG 由 '2meet-data-optimizer' 改回 'wp-data-optimizer'。
    B 內部有 10+ 處連結(help-tabs、setup-wizard ×5、conflict-monitor、
    export、3 個 template)硬編碼舊 slug,與註冊值不符 → 這些連結目前
    全部 404。既然本外掛已沿用 wpdo_ 的表/option/cron/hook/CLI 命名空間,
    選單 slug 一併回到同一套最連貫,A 退休後亦無衝突。

E7:13 處殘留的 'uae' text domain 改為 '2meet-data-optimizer'
    (conflict-detector 3、mode-manager 9、entity-migration-engine 1;
    另一處 'uae' 是陣列 key 非 text domain,保留)。

unit 451 / integration 398 GREEN

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:02:19 +08:00

381 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_Mode_Manager - Per-entity-type bridge 模式管理
*
* 4 種運作模式(每個 entity type 可獨立設定):
*
* disabled — 完全停用 UAE hook,回到原生 wp_*meta EAV。
* Kill-switch,緊急關閉用。
*
* dual_write — 寫入:同時寫 UAE flat table + wp_*meta
* 讀取:走 wp_*meta(原生)
* 用途:遷移前期安全模式,UAE 開始累積資料但不影響讀取
*
* shadow_read — 寫入:同時寫 UAE flat table + wp_*meta
* 讀取:走 UAE flat table,同時比對 wp_*meta 記錄差異
* 用途:驗證期,確認 UAE 資料正確後才進入 aeav_only
*
* aeav_only — 寫入:只寫 UAE flat table
* 讀取:只讀 UAE flat table
* 用途:完成遷移後的最終模式,效能最佳
*
* 合法轉換路徑(安全性):
*
* disabled ←→ dual_write ←→ shadow_read ←→ aeav_only
* ↑ ↑
* └──── aeav_only 可以直接降級到任一前向狀態
*
* 禁止:disabled → shadow_read 或 disabled → aeav_only
* (會讀不到資料,因為 UAE 表還沒有任何寫入)
*
* @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_Mode_Manager {
public const MODE_DISABLED = 'disabled';
public const MODE_DUAL_WRITE = 'dual_write';
public const MODE_SHADOW_READ = 'shadow_read';
public const MODE_AEAV_ONLY = 'aeav_only';
public const ALL_MODES = array(
self::MODE_DISABLED,
self::MODE_DUAL_WRITE,
self::MODE_SHADOW_READ,
self::MODE_AEAV_ONLY,
);
/** wp_options key holding array<entity_type, mode> */
private const OPT_KEY = 'wpdo_bridge_modes';
/**
* Per-entity 進入當前 mode 的 unix timestamp(v1.5.0+ 供 auto-promoter 使用)。
* Shape: array<string entity_type, int timestamp>
*/
public const OPT_ENTERED_AT = 'wpdo_bridge_mode_entered_at';
/** Per-request memoization(避免每次 filter 都查 DB option) */
private static ?array $cache = null;
/**
* 預設模式(v2.5.4 起):
* post → disabled(由 Legacy Feature_Flags FSM 管理,不由 Mode_Manager 控制)
* user / term / comment → dual_write(安全雙寫:寫入 flat table + 原生 EAV,讀取仍走 EAV
*/
private static function defaults(): array {
return array(
'post' => self::MODE_DISABLED,
'user' => self::MODE_DUAL_WRITE,
'term' => self::MODE_DUAL_WRITE,
'comment' => self::MODE_DUAL_WRITE,
);
}
/**
* 取得所有 entity 的 mode
*
* @return array<string, string>
*/
public static function all(): array {
if ( self::$cache !== null ) {
return self::$cache;
}
$stored = get_option( self::OPT_KEY, array() );
$modes = self::defaults();
if ( is_array( $stored ) ) {
foreach ( $stored as $type => $mode ) {
if ( is_string( $type ) && self::is_valid_mode( $mode ) && in_array( $type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
$modes[ $type ] = $mode;
}
}
}
self::$cache = $modes;
return $modes;
}
/**
* 取得單一 entity type 的 mode
*/
public static function get( string $entity_type ): string {
$all = self::all();
return $all[ $entity_type ] ?? self::MODE_DISABLED;
}
/**
* 設定單一 entity type 的 mode(包含轉換安全檢查)
*
* @return true|\WP_Error
*/
public static function set( string $entity_type, string $new_mode ) {
if ( ! in_array( $entity_type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
return new \WP_Error( 'invalid_entity', "Invalid entity type: {$entity_type}" );
}
if ( ! self::is_valid_mode( $new_mode ) ) {
return new \WP_Error( 'invalid_mode', "Invalid mode: {$new_mode}" );
}
$current = self::get( $entity_type );
if ( $current === $new_mode ) {
return true; // no-op
}
// 驗證轉換安全性
if ( ! self::is_safe_transition( $current, $new_mode ) ) {
return new \WP_Error(
'unsafe_transition',
sprintf(
__( '不安全的模式轉換:%1$s → %2$s。建議路徑:disabled → dual_write → shadow_read → aeav_only', '2meet-data-optimizer' ),
$current,
$new_mode
)
);
}
$all = self::all();
$all[ $entity_type ] = $new_mode;
update_option( self::OPT_KEY, $all, false );
self::$cache = $all;
// 記錄進入此 mode 的 timestamp — auto-promoter 用來判斷停留天數。
$entered = get_option( self::OPT_ENTERED_AT, array() );
if ( ! is_array( $entered ) ) {
$entered = array();
}
$entered[ $entity_type ] = time();
update_option( self::OPT_ENTERED_AT, $entered, false );
TMDO_Logger::info(
'bridge_mode_changed',
array(
'entity_type' => $entity_type,
'from' => $current,
'to' => $new_mode,
'user_id' => get_current_user_id(),
)
);
// 模式變更時清除所有相關快取
TMDO_Cache_Orchestrator::flush_entity( $entity_type );
do_action( 'wpdo_bridge_mode_changed', $entity_type, $new_mode, $current );
return true;
}
/**
* 緊急 kill-switch:全部 entity 瞬間切到 disabled
*
* 這是**安全轉換規則的例外** — 任何狀態都可以瞬間降到 disabled,
* 因為 EAV 資料一直存在(dual_write / shadow_read 都還寫 EAV),
* 只有 aeav_only 切到 disabled 才有資料遺失風險。
*
* 呼叫這個方法表示「出事了,先退回安全狀態」,EAV 可能不是最新的,
* 但至少系統不會壞。
*
* @return int 改變狀態的 entity 數量
*/
public static function emergency_disable_all(): int {
$all = self::all();
$changed = 0;
foreach ( $all as $type => $mode ) {
if ( $mode !== self::MODE_DISABLED ) {
$all[ $type ] = self::MODE_DISABLED;
++$changed;
TMDO_Logger::warning(
'bridge_emergency_disable',
array(
'entity_type' => $type,
'previous_mode' => $mode,
'user_id' => get_current_user_id(),
'is_aeav_only' => $mode === self::MODE_AEAV_ONLY,
'warning' => $mode === self::MODE_AEAV_ONLY
? 'CRITICAL: switching from aeav_only to disabled — EAV may be stale'
: 'Normal emergency fallback',
)
);
}
}
if ( $changed > 0 ) {
update_option( self::OPT_KEY, $all, false );
self::$cache = $all;
TMDO_Cache_Orchestrator::flush_all();
do_action( 'wpdo_bridge_emergency_disabled', $changed );
}
return $changed;
}
/**
* 設定全部 entity 到同一個 mode(批次操作,會做轉換檢查)
*
* @return array<string, true|\WP_Error> 逐 entity 的結果
*/
public static function set_all( string $mode ): array {
$results = array();
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $type ) {
$results[ $type ] = self::set( $type, $mode );
}
return $results;
}
/**
* Helper: 目前 mode 是否會寫入 flat table?
*/
public static function writes_to_flat( string $entity_type ): bool {
return in_array(
self::get( $entity_type ),
array(
self::MODE_DUAL_WRITE,
self::MODE_SHADOW_READ,
self::MODE_AEAV_ONLY,
),
true
);
}
/**
* Helper: 目前 mode 是否會讓 WP 原生 EAV 也被寫入?
*/
public static function writes_to_eav( string $entity_type ): bool {
return in_array(
self::get( $entity_type ),
array(
self::MODE_DISABLED,
self::MODE_DUAL_WRITE,
self::MODE_SHADOW_READ,
),
true
);
}
/**
* Helper: 目前 mode 是否從 flat table 讀取?
*/
public static function reads_from_flat( string $entity_type ): bool {
return in_array(
self::get( $entity_type ),
array(
self::MODE_SHADOW_READ,
self::MODE_AEAV_ONLY,
),
true
);
}
/**
* Helper: 是否要做 shadow 比對?
*/
public static function does_shadow_compare( string $entity_type ): bool {
return self::get( $entity_type ) === self::MODE_SHADOW_READ;
}
// ─────────────────────────────────────────────────────────
// 驗證
// ─────────────────────────────────────────────────────────
public static function is_valid_mode( $mode ): bool {
return is_string( $mode ) && in_array( $mode, self::ALL_MODES, true );
}
/**
* 轉換是否安全?
*
* 規則:
* - 相鄰的階梯移動:允許(正向或反向)
* - 跨階梯降級:允許(後面的 fallback,EAV 還在)
* - 跨階梯升級:禁止(下游資料可能還沒跟上)
*
* 階梯順序(index 越大 = 越「靠 UAE」):
* 0: disabled
* 1: dual_write
* 2: shadow_read
* 3: aeav_only
*
* 降級(index 變小)永遠安全(EAV 都還在,除了 aeav_only→disabled 一跳,
* 但那是刻意的 emergency 用法 — 見 emergency_disable_all)。
*
* 升級只允許 +1(disabled→dual_write、dual_write→shadow_read、shadow_read→aeav_only)。
*/
public static function is_safe_transition( string $from, string $to ): bool {
$order = array(
self::MODE_DISABLED => 0,
self::MODE_DUAL_WRITE => 1,
self::MODE_SHADOW_READ => 2,
self::MODE_AEAV_ONLY => 3,
);
if ( ! isset( $order[ $from ], $order[ $to ] ) ) {
return false;
}
$diff = $order[ $to ] - $order[ $from ];
// 升級:只允許 +1
if ( $diff > 0 ) {
return $diff === 1;
}
// 降級:一律允許(這是 fallback,資料都還在)
return true;
}
// ─────────────────────────────────────────────────────────
// 內部
// ─────────────────────────────────────────────────────────
/**
* 測試用:清除 memoization
*/
public static function reset_cache(): void {
self::$cache = null;
}
/**
* Human-readable label
*/
public static function label( string $mode ): string {
switch ( $mode ) {
case self::MODE_DISABLED:
return __( '停用(原生 EAV)', '2meet-data-optimizer' );
case self::MODE_DUAL_WRITE:
return __( '雙寫', '2meet-data-optimizer' );
case self::MODE_SHADOW_READ:
return __( '影子讀取(驗證中)', '2meet-data-optimizer' );
case self::MODE_AEAV_ONLY:
return __( '僅 UAE(生產模式)', '2meet-data-optimizer' );
default:
return $mode;
}
}
/**
* Mode 的說明文字(給 admin UI 用)
*/
public static function description( string $mode ): string {
switch ( $mode ) {
case self::MODE_DISABLED:
return __( '完全停用 UAE 攔截,使用 WordPress 原生 wp_*meta 表。安全 fallback。', '2meet-data-optimizer' );
case self::MODE_DUAL_WRITE:
return __( '寫入 UAE + 原生 meta 表,讀取仍走原生。遷移前期的安全起點。', '2meet-data-optimizer' );
case self::MODE_SHADOW_READ:
return __( '寫入雙寫,讀取走 UAE 並比對原生 meta。驗證資料一致性的階段。', '2meet-data-optimizer' );
case self::MODE_AEAV_ONLY:
return __( '僅讀寫 UAE,不再寫入原生 meta 表。最終生產模式,效能最佳。', '2meet-data-optimizer' );
default:
return '';
}
}
}