a0847a27bf
- wpdo_audit CREATE 補 group_name varchar(50) 與 action varchar(20): TMDO_Audit_Logger::write_row() 一直在寫這兩個 key,表卻沒有該欄位。 SCHEMA_VERSION 2.0.0 → 2.1.0 觸發既有站台 dbDelta ALTER;主檔與兩個 test bootstrap 的 TMDO_DB_VERSION 同步(BackCompatTest 守門要求一致)。 - TMDO_Core::run() 註冊 TMDO_Audit_Logger::init():先前完全沒掛, wpdo_after_write / wpdo_after_delete 的稽核監聽器形同不存在。 - register_sso_group() 補 known_login_ips(A v3.4.4):2meet-spoke-sso 的 TMSO_Login_Notify 以 set_entity 寫此欄,未註冊時降級落 wp_usermeta。 對應測試改為 test_sso_group_has_eight_fields。 unit 379 / integration 398 GREEN Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
504 lines
14 KiB
PHP
504 lines
14 KiB
PHP
<?php
|
||
/**
|
||
* Member entity field registration for WP Data Optimizer.
|
||
*
|
||
* Registers four user entity groups designed for 千萬 (10M) member scale.
|
||
* All groups use TMDO_Entity_Registry → flat tables instead of wp_usermeta EAV.
|
||
*
|
||
* @package WP_Data_Optimizer
|
||
*/
|
||
|
||
if ( ! defined( 'ABSPATH' ) ) {
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* Registers four user entity groups for membership, activity, profile, and SSO.
|
||
*
|
||
* Groups → flat tables:
|
||
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
|
||
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
|
||
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
|
||
* sso → wp_wpdo_user_sso (Hub token cache, replaces _tmso_* usermeta)
|
||
*
|
||
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
|
||
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
|
||
*/
|
||
final class TMDO_Member_Fields {
|
||
|
||
/**
|
||
* Hook into WPDO entity field registration.
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function register(): void {
|
||
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
|
||
}
|
||
|
||
/**
|
||
* Register all user entity groups.
|
||
*
|
||
* V2.7.0: Adds four legacy-key groups (core_profile, social, commerce, hp_user)
|
||
* to absorb wp_usermeta rows that previously bypassed the entity bridge —
|
||
* driving the wp_users:wp_usermeta ratio from 1:5.5 toward 1:2.5.
|
||
*
|
||
* V2.8.4: Adds admin_prefs group — the 7 default keys WP core writes for
|
||
* EVERY new user via wp_insert_user (rich_editing, syntax_highlighting,
|
||
* comment_shortcuts, admin_color, use_ssl, show_admin_bar_front,
|
||
* dismissed_wp_pointers). Without this group, fresh users always show
|
||
* ratio 1:9 regardless of any other optimization.
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function register_entity_fields(): void {
|
||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||
return;
|
||
}
|
||
|
||
self::register_membership_group();
|
||
self::register_activity_group();
|
||
self::register_profile_group();
|
||
self::register_sso_group();
|
||
self::register_core_profile_group();
|
||
self::register_social_group();
|
||
self::register_commerce_group();
|
||
self::register_hp_user_group();
|
||
self::register_admin_prefs_group();
|
||
}
|
||
|
||
// ── Group definitions ────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Tier level, points balance, expiry — primary search target.
|
||
*
|
||
* Extra composite indexes (idx_level_expires, idx_expires_level, idx_points_bal)
|
||
* are applied by TMDO_Installer::install_member_indexes() after table creation.
|
||
*
|
||
* @return void
|
||
*/
|
||
private static function register_membership_group(): void {
|
||
TMDO_Entity_Registry::register_group(
|
||
'user',
|
||
'membership',
|
||
array(
|
||
array(
|
||
'key' => 'membership_level',
|
||
'type' => 'enum',
|
||
'searchable' => true,
|
||
'options' => array( 'bronze', 'silver', 'gold', 'platinum', 'custom' ),
|
||
'label' => 'Membership tier level',
|
||
),
|
||
array(
|
||
'key' => 'points_balance',
|
||
'type' => 'integer',
|
||
'searchable' => true,
|
||
'default' => 0,
|
||
'label' => 'Current points balance',
|
||
),
|
||
array(
|
||
'key' => 'membership_expires_at',
|
||
'type' => 'datetime',
|
||
'searchable' => true,
|
||
'label' => 'Membership expiry datetime',
|
||
),
|
||
array(
|
||
'key' => 'membership_activated_at',
|
||
'type' => 'datetime',
|
||
'label' => 'Membership activation datetime',
|
||
),
|
||
array(
|
||
'key' => 'tier_source',
|
||
'type' => 'text',
|
||
'label' => 'Tier source: manual / wc_subscription / admin_set',
|
||
),
|
||
array(
|
||
'key' => 'custom_tier_label',
|
||
'type' => 'text',
|
||
'label' => 'Custom tier display label (when level=custom)',
|
||
),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Login counters and last-active timestamps — separated to avoid lock
|
||
* contention with membership reads during high-traffic periods.
|
||
*
|
||
* @return void
|
||
*/
|
||
private static function register_activity_group(): void {
|
||
TMDO_Entity_Registry::register_group(
|
||
'user',
|
||
'activity',
|
||
array(
|
||
array(
|
||
'key' => 'login_count',
|
||
'type' => 'integer',
|
||
'searchable' => true,
|
||
'default' => 0,
|
||
'label' => 'Cumulative login count',
|
||
),
|
||
array(
|
||
'key' => 'last_active_at',
|
||
'type' => 'datetime',
|
||
'searchable' => true,
|
||
'label' => 'Last activity datetime',
|
||
),
|
||
array(
|
||
'key' => 'last_login_at',
|
||
'type' => 'datetime',
|
||
'label' => 'Last login datetime',
|
||
),
|
||
array(
|
||
'key' => 'last_order_at',
|
||
'type' => 'datetime',
|
||
'label' => 'Last order datetime',
|
||
),
|
||
array(
|
||
'key' => 'session_count',
|
||
'type' => 'integer',
|
||
'default' => 0,
|
||
'label' => 'Total session count',
|
||
),
|
||
array(
|
||
'key' => 'account_flags',
|
||
'type' => 'integer',
|
||
'default' => 0,
|
||
'label' => 'Bitmask: 1=email_verified 2=phone_verified 4=kyc 8=social_signup',
|
||
),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Display fields, specialties, avatar — written infrequently.
|
||
*
|
||
* @return void
|
||
*/
|
||
private static function register_profile_group(): void {
|
||
TMDO_Entity_Registry::register_group(
|
||
'user',
|
||
'profile',
|
||
array(
|
||
array(
|
||
'key' => 'specialties',
|
||
'type' => 'json',
|
||
'label' => 'Professional specialties (JSON array)',
|
||
),
|
||
array(
|
||
'key' => 'bio_url',
|
||
'type' => 'text',
|
||
'label' => 'Bio or portfolio URL',
|
||
),
|
||
array(
|
||
'key' => 'avatar_url',
|
||
'type' => 'text',
|
||
'label' => 'Avatar image URL',
|
||
),
|
||
array(
|
||
'key' => 'display_name_custom',
|
||
'type' => 'text',
|
||
'searchable' => true,
|
||
'fulltext' => true,
|
||
'label' => 'Custom display name (fulltext searchable)',
|
||
),
|
||
array(
|
||
'key' => 'locale',
|
||
'type' => 'text',
|
||
'label' => 'User locale (e.g. zh_TW)',
|
||
),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Hub/Spoke SSO token cache — replaces _tmso_* usermeta.
|
||
*
|
||
* Silent refresh fires every 15 min per user; at 10M users this is a
|
||
* high-frequency EAV hot-spot. A flat table + object cache hit cuts DB
|
||
* load 10–50× versus a wp_usermeta EAV scan per refresh.
|
||
*
|
||
* last_id_token is NOT stored in plaintext (privacy + volume). Only the
|
||
* SHA-256 hash is kept for SLO token comparison.
|
||
*
|
||
* @return void
|
||
*/
|
||
private static function register_sso_group(): void {
|
||
TMDO_Entity_Registry::register_group(
|
||
'user',
|
||
'sso',
|
||
array(
|
||
array(
|
||
'key' => 'hub_global_user_id',
|
||
'type' => 'text',
|
||
'searchable' => true,
|
||
'label' => 'Hub global user UUID (2mso_user_mapping.global_user_id bridge key)',
|
||
),
|
||
array(
|
||
'key' => 'picture_url',
|
||
'type' => 'text',
|
||
'label' => 'Social / SSO profile picture URL',
|
||
),
|
||
array(
|
||
'key' => 'last_id_token_hash',
|
||
'type' => 'text',
|
||
'label' => 'SHA-256(last_id_token) for SLO comparison — no plaintext stored',
|
||
),
|
||
array(
|
||
'key' => 'refresh_token_enc',
|
||
'type' => 'textarea',
|
||
'label' => 'Encrypted refresh token (TMSO_Crypto — key-versioned enc_vN:ciphertext)',
|
||
),
|
||
array(
|
||
'key' => 'token_expires_at',
|
||
'type' => 'datetime',
|
||
'searchable' => true,
|
||
'label' => 'SSO token expiry (set to past to force re-auth on next request)',
|
||
),
|
||
array(
|
||
'key' => 'sso_last_login_at',
|
||
'type' => 'datetime',
|
||
'label' => 'Last SSO-initiated login datetime',
|
||
),
|
||
array(
|
||
'key' => 'sso_login_count',
|
||
'type' => 'integer',
|
||
'default' => 0,
|
||
'label' => 'SSO login count',
|
||
),
|
||
array(
|
||
'key' => 'known_login_ips',
|
||
'type' => 'textarea',
|
||
'label' => 'Recent login IPs JSON array (new-device notification) — replaces _tmso_known_ips usermeta',
|
||
),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* WP core user fields stored as multi-row EAV in wp_usermeta.
|
||
*
|
||
* Absorbing these here lets the Hook Bus short-circuit get_user_meta() /
|
||
* update_user_meta() for the keys WP itself uses for display_name resolution
|
||
* and the WP profile UI.
|
||
*
|
||
* @return void
|
||
*/
|
||
private static function register_core_profile_group(): void {
|
||
TMDO_Entity_Registry::register_group(
|
||
'user',
|
||
'core_profile',
|
||
array(
|
||
array(
|
||
'key' => 'nickname',
|
||
'type' => 'text',
|
||
'searchable' => true,
|
||
'label' => 'WP nickname',
|
||
),
|
||
array(
|
||
'key' => 'first_name',
|
||
'type' => 'text',
|
||
'searchable' => true,
|
||
'label' => 'WP first name',
|
||
),
|
||
array(
|
||
'key' => 'last_name',
|
||
'type' => 'text',
|
||
'searchable' => true,
|
||
'label' => 'WP last name',
|
||
),
|
||
array(
|
||
'key' => 'description',
|
||
'type' => 'textarea',
|
||
'label' => 'WP user bio',
|
||
),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Social profile URLs (HivePress vendor-profile + WP user-contact-methods).
|
||
*
|
||
* 15 keys × 8 vendor users ≈ 120 EAV rows in this dataset.
|
||
*
|
||
* @return void
|
||
*/
|
||
private static function register_social_group(): void {
|
||
$social_keys = array(
|
||
'facebook',
|
||
'twitter',
|
||
'instagram',
|
||
'youtube',
|
||
'tiktok',
|
||
'linkedin',
|
||
'vimeo',
|
||
'vkontakte',
|
||
'mastodon',
|
||
'medium',
|
||
'wordpress',
|
||
'odnoklassniki',
|
||
'pinterest',
|
||
'dribbble',
|
||
'github',
|
||
);
|
||
|
||
// Social URLs typed as `textarea` (TEXT) — VARCHAR(255) silently truncates
|
||
// long share URLs (utm params, deep paths) under WP's default non-strict
|
||
// SQL mode. TEXT (64KB) covers all realistic URL lengths.
|
||
$fields = array();
|
||
foreach ( $social_keys as $key ) {
|
||
$fields[] = array(
|
||
'key' => $key,
|
||
'type' => 'textarea',
|
||
'label' => ucfirst( $key ) . ' profile URL',
|
||
);
|
||
}
|
||
|
||
TMDO_Entity_Registry::register_group( 'user', 'social', $fields );
|
||
}
|
||
|
||
/**
|
||
* WooCommerce billing & shipping address fields.
|
||
*
|
||
* Billing_email is searchable for guest-checkout customer lookups.
|
||
*
|
||
* @return void
|
||
*/
|
||
private static function register_commerce_group(): void {
|
||
$address_keys = array(
|
||
'first_name',
|
||
'last_name',
|
||
'company',
|
||
'address_1',
|
||
'address_2',
|
||
'city',
|
||
'state',
|
||
'postcode',
|
||
'country',
|
||
);
|
||
|
||
$fields = array();
|
||
foreach ( $address_keys as $key ) {
|
||
$fields[] = array(
|
||
'key' => 'billing_' . $key,
|
||
'type' => 'text',
|
||
'label' => 'WC billing ' . str_replace( '_', ' ', $key ),
|
||
);
|
||
$fields[] = array(
|
||
'key' => 'shipping_' . $key,
|
||
'type' => 'text',
|
||
'label' => 'WC shipping ' . str_replace( '_', ' ', $key ),
|
||
);
|
||
}
|
||
|
||
// Email + phone are billing-only.
|
||
$fields[] = array(
|
||
'key' => 'billing_email',
|
||
'type' => 'text',
|
||
'searchable' => true,
|
||
'label' => 'WC billing email',
|
||
);
|
||
$fields[] = array(
|
||
'key' => 'billing_phone',
|
||
'type' => 'text',
|
||
'label' => 'WC billing phone',
|
||
);
|
||
$fields[] = array(
|
||
'key' => 'shipping_phone',
|
||
'type' => 'text',
|
||
'label' => 'WC shipping phone',
|
||
);
|
||
|
||
TMDO_Entity_Registry::register_group( 'user', 'commerce', $fields );
|
||
}
|
||
|
||
/**
|
||
* HivePress per-user fields (favorites + avatar attachment).
|
||
*
|
||
* Hp_favorited_listings is a serialized array of post IDs in legacy storage;
|
||
* the migration engine safe_unserialize()s it (allowed_classes=false to block
|
||
* PHP-object injection) then the json type encodes back to a JSON array column.
|
||
*
|
||
* @return void
|
||
*/
|
||
private static function register_hp_user_group(): void {
|
||
TMDO_Entity_Registry::register_group(
|
||
'user',
|
||
'hp_user',
|
||
array(
|
||
array(
|
||
'key' => 'hp_favorited_listings',
|
||
'type' => 'json',
|
||
'label' => 'HivePress favorited listing IDs (array)',
|
||
),
|
||
array(
|
||
'key' => 'hp_image',
|
||
'type' => 'text',
|
||
'label' => 'HivePress avatar attachment ID',
|
||
),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* WP-core admin pref defaults — written by `wp_insert_user()` for EVERY
|
||
* new user regardless of role. Without registering these, a fresh user
|
||
* lands at ratio 1:9 (7 admin prefs + wp_capabilities + wp_user_level).
|
||
* Once registered, Hook Bus intercepts `update_user_meta()` calls from
|
||
* `wp_insert_user()` and routes them to `wp_wpdo_user_admin_prefs` flat
|
||
* table → fresh user ratio drops to 1:2.
|
||
*
|
||
* Values are stored as text because WP itself stores 'true'/'false'
|
||
* strings (not booleans), 'fresh' / 'classic' (admin_color enum strings),
|
||
* and integer-as-string for `use_ssl`. Preserving WP's textual storage
|
||
* shape ensures downstream code (e.g. theme switchers reading
|
||
* `admin_color`) sees the exact same value as before.
|
||
*
|
||
* @since 2.8.4
|
||
* @return void
|
||
*/
|
||
private static function register_admin_prefs_group(): void {
|
||
TMDO_Entity_Registry::register_group(
|
||
'user',
|
||
'admin_prefs',
|
||
array(
|
||
array(
|
||
'key' => 'rich_editing',
|
||
'type' => 'text',
|
||
'label' => 'Visual editor enabled (true/false string)',
|
||
),
|
||
array(
|
||
'key' => 'syntax_highlighting',
|
||
'type' => 'text',
|
||
'label' => 'Code editor syntax highlighting (true/false string)',
|
||
),
|
||
array(
|
||
'key' => 'comment_shortcuts',
|
||
'type' => 'text',
|
||
'label' => 'Comment moderation keyboard shortcuts (true/false string)',
|
||
),
|
||
array(
|
||
'key' => 'admin_color',
|
||
'type' => 'text',
|
||
'label' => 'Admin colour scheme (fresh/classic/etc)',
|
||
),
|
||
array(
|
||
'key' => 'use_ssl',
|
||
'type' => 'text',
|
||
'label' => 'Force SSL on admin (0/1 as string)',
|
||
),
|
||
array(
|
||
'key' => 'show_admin_bar_front',
|
||
'type' => 'text',
|
||
'label' => 'Show admin bar on front-end (true/false string)',
|
||
),
|
||
array(
|
||
'key' => 'dismissed_wp_pointers',
|
||
'type' => 'textarea',
|
||
'label' => 'Comma-separated dismissed pointer IDs',
|
||
),
|
||
)
|
||
);
|
||
}
|
||
}
|