diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index e4fbc65..c3afe3a 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -2990,7 +2990,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' ?>

- +

@@ -3004,13 +3004,14 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' } $tests = array( - 'wpdo_schema_drift' => array( 'check_schema_drift', __( 'Schema 完整性', '2meet-data-optimizer' ) ), - 'wpdo_error_budget' => array( 'check_error_budget', __( '錯誤預算(過去 7 天)', '2meet-data-optimizer' ) ), - 'wpdo_hook_conflicts' => array( 'check_hook_conflicts', __( 'Hook 衝突', '2meet-data-optimizer' ) ), - 'wpdo_autoload_bloat' => array( 'check_autoload_bloat', __( 'Autoload 大小', '2meet-data-optimizer' ) ), - 'wpdo_postmeta_explosion' => array( 'check_postmeta_explosion', __( 'wp_postmeta 爆量', '2meet-data-optimizer' ) ), - 'wpdo_orphan_zone_rows' => array( 'check_orphan_zone_rows', __( 'Orphan zone rows', '2meet-data-optimizer' ) ), - 'wpdo_missing_snapshot' => array( 'check_missing_snapshot', __( '缺少最近快照', '2meet-data-optimizer' ) ), + 'wpdo_schema_drift' => array( 'check_schema_drift', __( 'Schema 完整性', '2meet-data-optimizer' ) ), + 'wpdo_error_budget' => array( 'check_error_budget', __( '錯誤預算(過去 7 天)', '2meet-data-optimizer' ) ), + 'wpdo_hook_conflicts' => array( 'check_hook_conflicts', __( 'Hook 衝突', '2meet-data-optimizer' ) ), + 'wpdo_autoload_bloat' => array( 'check_autoload_bloat', __( 'Autoload 大小', '2meet-data-optimizer' ) ), + 'wpdo_postmeta_explosion' => array( 'check_postmeta_explosion', __( 'wp_postmeta 爆量', '2meet-data-optimizer' ) ), + 'wpdo_orphan_zone_rows' => array( 'check_orphan_zone_rows', __( 'Orphan zone rows', '2meet-data-optimizer' ) ), + 'wpdo_missing_snapshot' => array( 'check_missing_snapshot', __( '缺少最近快照', '2meet-data-optimizer' ) ), + 'wpdo_entity_group_collisions' => array( 'check_entity_group_collisions', __( '實體群組名稱衝突', '2meet-data-optimizer' ) ), ); ?> diff --git a/admin/class-tmdo-help-tabs.php b/admin/class-tmdo-help-tabs.php index c1c36ba..b4899a4 100644 --- a/admin/class-tmdo-help-tabs.php +++ b/admin/class-tmdo-help-tabs.php @@ -184,7 +184,7 @@ class TMDO_Help_Tabs { * @return string */ private static function content_doctor(): string { - return '

' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '

' + return '

' . esc_html__( '8 項自動健康檢查:', '2meet-data-optimizer' ) . '

' . '
    ' . '
  1. schema_drift — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '
  2. ' . '
  3. error_budget — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '
  4. ' @@ -193,6 +193,7 @@ class TMDO_Help_Tabs { . '
  5. postmeta_explosion — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '
  6. ' . '
  7. orphan_zone_rows — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '
  8. ' . '
  9. missing_snapshot — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '
  10. ' + . '
  11. entity_group_collisions — ' . esc_html__( '同名實體群組被不同外掛以不同欄位註冊(後者被忽略)', '2meet-data-optimizer' ) . '
  12. ' . '
' . '

' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '

'; } diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index e227736..5c0023c 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -330,6 +330,25 @@ class TMDO_CLI { WP_CLI::log( ' [OK] Crypto key derivable (AUTH_KEY / SECURE_AUTH_SALT present)' ); } + // ── Entity group name collisions ───────────────────────────────────────── + $collisions = TMDO_Entity_Registry::get_collisions(); + if ( empty( $collisions ) ) { + WP_CLI::log( ' [OK] No entity group name collisions' ); + } + foreach ( $collisions as $c ) { + WP_CLI::warning( + sprintf( + ' [COLLISION] %s/%s registered twice with different fields (second ignored) — added: %s; removed: %s; changed: %s', + $c['entity_type'], + $c['group'], + implode( ', ', $c['added'] ) ?: '-', + implode( ', ', $c['removed'] ) ?: '-', + implode( ', ', $c['changed'] ) ?: '-' + ) + ); + $all_ok = false; + } + // Check recent errors. $errors = TMDO_Logger::get_recent( '', 5 ); if ( ! empty( $errors ) ) { diff --git a/includes/diagnostic/class-tmdo-site-health.php b/includes/diagnostic/class-tmdo-site-health.php index 77db2d9..bba38c2 100644 --- a/includes/diagnostic/class-tmdo-site-health.php +++ b/includes/diagnostic/class-tmdo-site-health.php @@ -2,7 +2,7 @@ /** * TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3). * - * Registers 7 tests under Tools → Site Health → Status: + * Registers 8 tests under Tools → Site Health → Status: * 1. wpdo_schema_drift (critical) * 2. wpdo_error_budget (recommended) * 3. wpdo_hook_conflicts (recommended) @@ -10,6 +10,7 @@ * 5. wpdo_postmeta_explosion (recommended) * 6. wpdo_orphan_zone_rows (recommended) * 7. wpdo_missing_snapshot (critical) + * 8. wpdo_entity_group_collisions (recommended) * * Each test result is cached for 5 minutes to keep Site Health responsive. * @@ -34,13 +35,14 @@ class TMDO_Site_Health { /** Test name → callable suffix mapping. */ private const TESTS = array( - 'wpdo_schema_drift' => 'check_schema_drift', - 'wpdo_error_budget' => 'check_error_budget', - 'wpdo_hook_conflicts' => 'check_hook_conflicts', - 'wpdo_autoload_bloat' => 'check_autoload_bloat', - 'wpdo_postmeta_explosion' => 'check_postmeta_explosion', - 'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows', - 'wpdo_missing_snapshot' => 'check_missing_snapshot', + 'wpdo_schema_drift' => 'check_schema_drift', + 'wpdo_error_budget' => 'check_error_budget', + 'wpdo_hook_conflicts' => 'check_hook_conflicts', + 'wpdo_autoload_bloat' => 'check_autoload_bloat', + 'wpdo_postmeta_explosion' => 'check_postmeta_explosion', + 'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows', + 'wpdo_missing_snapshot' => 'check_missing_snapshot', + 'wpdo_entity_group_collisions' => 'check_entity_group_collisions', ); /** @@ -53,7 +55,7 @@ class TMDO_Site_Health { } /** - * Register the 7 tests with WP Site Health. + * Register the 8 tests with WP Site Health. * * @param array $tests Existing tests. * @return array @@ -76,13 +78,14 @@ class TMDO_Site_Health { */ private static function label_for( string $key ): string { $map = array( - 'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ), - 'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ), - 'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ), - 'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ), - 'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ), - 'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ), - 'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ), + 'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ), + 'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ), + 'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ), + 'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ), + 'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ), + 'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ), + 'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ), + 'wpdo_entity_group_collisions' => __( 'WPDO entity group collisions', '2meet-data-optimizer' ), ); return $map[ $key ] ?? $key; } @@ -425,6 +428,46 @@ class TMDO_Site_Health { // ─── helpers ────────────────────────────────────────────────────────── + // ─── Test 8: Entity group name collisions ─────────────────────────── + + /** + * Report entity groups registered twice with different definitions. + * + * Not cached: the collision list is in-memory state rebuilt on every + * request, so reading it is free and a transient would only go stale. + * + * @return array Site Health result. + */ + public static function check_entity_group_collisions(): array { + $collisions = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_collisions() : array(); + if ( empty( $collisions ) ) { + return self::wrap( 'wpdo_entity_group_collisions', self::pass( __( 'No entity group name collisions.', '2meet-data-optimizer' ) ) ); + } + $lines = array(); + foreach ( $collisions as $c ) { + $lines[] = sprintf( + '%s/%s (+%s −%s ~%s)', + $c['entity_type'], + $c['group'], + implode( ',', $c['added'] ) ?: '-', + implode( ',', $c['removed'] ) ?: '-', + implode( ',', $c['changed'] ) ?: '-' + ); + } + return self::wrap( + 'wpdo_entity_group_collisions', + self::fail( + __( 'WPDO entity group name collisions', '2meet-data-optimizer' ), + sprintf( + /* translators: %s: list of entity/group names with field differences */ + __( 'Another plugin registered these groups with a different field list; its definition was ignored and the first one kept: %s', '2meet-data-optimizer' ), + implode( '; ', $lines ) + ), + 'recommended' + ) + ); + } + /** * Wrap a raw check result with required Site Health envelope fields. * diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php index c4e0a5a..c6e6ec1 100644 --- a/includes/engine/class-tmdo-entity-registry.php +++ b/includes/engine/class-tmdo-entity-registry.php @@ -31,6 +31,9 @@ final class TMDO_Entity_Registry { /** @var array 待建表的 Schema 清單 */ private static array $pending_schemas = array(); + /** @var array "entity/group" → 同名群組以不同定義重複註冊的紀錄 */ + private static array $collisions = array(); + /** 合法的欄位型別 */ public const VALID_TYPES = array( 'text', @@ -54,6 +57,7 @@ final class TMDO_Entity_Registry { self::$groups = array(); self::$field_index = array(); self::$pending_schemas = array(); + self::$collisions = array(); } // ───────────────────────────────────────────────────────── @@ -105,11 +109,6 @@ final class TMDO_Entity_Registry { return false; } - // 防重複登錄 - if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) { - return false; - } - // 驗證並正規化欄位定義 $normalized = array(); @@ -141,15 +140,25 @@ final class TMDO_Entity_Registry { $normalized_field['entity_type'] = $entity_type; $normalized[] = $normalized_field; - - // 建立索引以供快速查詢 - self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; } if ( empty( $normalized ) ) { return false; } + // 防重複登錄:相同定義(init:1 重新觸發)靜默略過;定義不同則記錄衝突,保留先註冊者。 + if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) { + if ( self::$groups[ $entity_type ][ $group_name ] !== $normalized ) { + self::record_collision( $entity_type, $group_name, self::$groups[ $entity_type ][ $group_name ], $normalized ); + } + return false; + } + + // 建立索引以供快速查詢 + foreach ( $normalized as $normalized_field ) { + self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; + } + self::$groups[ $entity_type ][ $group_name ] = $normalized; // 加入待建表佇列 @@ -162,6 +171,51 @@ final class TMDO_Entity_Registry { return true; } + /** + * 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。 + * + * 註冊每個請求都會跑,warning 以 transient 節流為每天一次,避免灌爆 wpdo_errors。 + */ + private static function record_collision( string $entity_type, string $group_name, array $existing, array $incoming ): void { + $id = $entity_type . '/' . $group_name; + if ( isset( self::$collisions[ $id ] ) ) { + return; + } + + $old = array_column( $existing, null, 'key' ); + $new = array_column( $incoming, null, 'key' ); + + $changed = array(); + foreach ( array_intersect_key( $new, $old ) as $key => $field ) { + if ( $field !== $old[ $key ] ) { + $changed[] = (string) $key; + } + } + + self::$collisions[ $id ] = array( + 'entity_type' => $entity_type, + 'group' => $group_name, + 'added' => array_map( 'strval', array_keys( array_diff_key( $new, $old ) ) ), + 'removed' => array_map( 'strval', array_keys( array_diff_key( $old, $new ) ) ), + 'changed' => $changed, + ); + + $throttle = 'tmdo_group_collision_' . md5( (string) wp_json_encode( self::$collisions[ $id ] ) ); + if ( class_exists( 'TMDO_Logger' ) && false === get_transient( $throttle ) ) { + set_transient( $throttle, 1, DAY_IN_SECONDS ); + TMDO_Logger::warning( 'entity_group_collision', self::$collisions[ $id ] ); + } + } + + /** + * 本請求偵測到的同名群組衝突(第二份定義已被捨棄)。 + * + * @return array + */ + public static function get_collisions(): array { + return array_values( self::$collisions ); + } + // ───────────────────────────────────────────────────────── // 查詢 API // ───────────────────────────────────────────────────────── diff --git a/tests/unit/EntityRegistryCollisionTest.php b/tests/unit/EntityRegistryCollisionTest.php new file mode 100644 index 0000000..1b21b49 --- /dev/null +++ b/tests/unit/EntityRegistryCollisionTest.php @@ -0,0 +1,97 @@ +createMock( TMDO_Entity_Adapter_Interface::class ) ); + $GLOBALS['_wp_options'] = array(); + } + + private function first_definition(): array { + return array( + array( 'key' => 'hp_price', 'type' => 'decimal', 'searchable' => true ), + array( 'key' => 'hp_featured', 'type' => 'boolean' ), + ); + } + + public function test_identical_reregistration_is_silent(): void { + $this->assertTrue( TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() ) ); + $this->assertFalse( TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() ) ); + + $this->assertSame( array(), TMDO_Entity_Registry::get_collisions() ); + } + + public function test_different_definition_is_recorded_with_field_diff(): void { + TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() ); + + $second = array( + array( 'key' => 'hp_price', 'type' => 'integer', 'searchable' => true ), + array( 'key' => 'hp_vendor', 'type' => 'integer' ), + ); + $this->assertFalse( TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $second ) ); + + $collisions = TMDO_Entity_Registry::get_collisions(); + $this->assertCount( 1, $collisions ); + $this->assertSame( 'post', $collisions[0]['entity_type'] ); + $this->assertSame( 'hp_listing_core', $collisions[0]['group'] ); + $this->assertSame( array( 'hp_vendor' ), $collisions[0]['added'] ); + $this->assertSame( array( 'hp_featured' ), $collisions[0]['removed'] ); + $this->assertSame( array( 'hp_price' ), $collisions[0]['changed'] ); + } + + public function test_first_definition_wins(): void { + TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() ); + TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', array( array( 'key' => 'hp_vendor', 'type' => 'integer' ) ) ); + + $this->assertSame( array( 'hp_price', 'hp_featured' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_core' ) ); + $this->assertNull( TMDO_Entity_Registry::get_field( 'post', 'hp_vendor' ) ); + } + + public function test_same_collision_is_recorded_once_per_request(): void { + TMDO_Entity_Registry::register_group( 'post', 'g', $this->first_definition() ); + $other = array( array( 'key' => 'x', 'type' => 'text' ) ); + TMDO_Entity_Registry::register_group( 'post', 'g', $other ); + TMDO_Entity_Registry::register_group( 'post', 'g', $other ); + + $this->assertCount( 1, TMDO_Entity_Registry::get_collisions() ); + } + + public function test_init_clears_collisions(): void { + TMDO_Entity_Registry::register_group( 'post', 'g', $this->first_definition() ); + TMDO_Entity_Registry::register_group( 'post', 'g', array( array( 'key' => 'x', 'type' => 'text' ) ) ); + TMDO_Entity_Registry::init(); + + $this->assertSame( array(), TMDO_Entity_Registry::get_collisions() ); + } + + public function test_site_health_reports_collisions(): void { + $this->assertSame( 'good', TMDO_Site_Health::check_entity_group_collisions()['status'] ); + + TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() ); + TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', array( array( 'key' => 'x', 'type' => 'text' ) ) ); + + $result = TMDO_Site_Health::check_entity_group_collisions(); + $this->assertSame( 'recommended', $result['status'] ); + $this->assertStringContainsString( 'post/hp_listing_core', $result['description'] ); + } + + public function test_site_health_registers_the_collision_test(): void { + $tests = TMDO_Site_Health::register_tests( array() ); + $this->assertArrayHasKey( 'wpdo_entity_group_collisions', $tests['direct'] ); + } +}