d6c1540444
- Crypto::is_key_derivable()(A v3.3.2):AUTH_KEY / SECURE_AUTH_SALT 皆缺
時回 false,讓金鑰推導優雅短路
- wp tmdo doctor 新增兩項健檢:
· backup 目錄 HTTP 可及性探測(200 = 紅旗並附 nginx 設定建議,
403/404 = OK,0 = 離線時退回檢查 .htaccess)(A v3.1.9)
· crypto 金鑰可推導性(缺常數時警告 notifier secrets 會以明文儲存)
- doctor_callback 呼叫簽章 3 參數 → 1 參數(A v3.0.3 修復)。**ABI 變更**:
AddOn 註冊的 callback 若依賴 rows / full_name 需自行調整
- Member_Fields::register_admin_prefs_group() 由 7 欄擴到 22 欄(A v3.1.4),
補上 wp_user_level / show_welcome_panel / wp_persisted_preferences /
wp_user-settings / community-events-location 等 15 個 WP 原生 usermeta,
這些先前全部落在 wp_usermeta
- capture_before_value 預設 true → false(A v3.1.5):每次受管寫入省一次
DB read。**行為變更**:需要 value_before 的消費者(audit log)要
add_filter( 'wpdo_capture_before_value', '__return_true' ) 明確開啟
unit 451 / integration 398 GREEN
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
586 lines
16 KiB
PHP
586 lines
16 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',
|
||
),
|
||
array(
|
||
'key' => 'wp_user_level',
|
||
'type' => 'integer',
|
||
'label' => 'WP legacy user level (0-10); written by WP on every role change',
|
||
),
|
||
// WP admin-UI prefs written on first dashboard visit or explicit user action.
|
||
array(
|
||
'key' => 'show_welcome_panel',
|
||
'type' => 'text',
|
||
'label' => 'Dashboard welcome panel visibility (true/false/1/0)',
|
||
),
|
||
array(
|
||
'key' => 'wp_persisted_preferences',
|
||
'type' => 'textarea',
|
||
'label' => 'Block editor persisted preferences (JSON blob)',
|
||
),
|
||
array(
|
||
'key' => 'nav_menu_recently_edited',
|
||
'type' => 'text',
|
||
'label' => 'ID of nav menu most recently edited',
|
||
),
|
||
array(
|
||
'key' => 'wp_dashboard_quick_press_last_post_id',
|
||
'type' => 'integer',
|
||
'label' => 'Post ID from last Quick Draft save',
|
||
),
|
||
array(
|
||
'key' => 'edit_page_per_page',
|
||
'type' => 'integer',
|
||
'label' => 'Rows-per-page in Pages list table',
|
||
),
|
||
array(
|
||
'key' => 'edit_post_per_page',
|
||
'type' => 'integer',
|
||
'label' => 'Rows-per-page in Posts list table',
|
||
),
|
||
array(
|
||
'key' => 'edit_hp_listing_per_page',
|
||
'type' => 'integer',
|
||
'label' => 'Rows-per-page in hp_listing list table',
|
||
),
|
||
// Keys with hyphens: sanitize_column_name() strips hyphens entirely.
|
||
// community-events-location → communityeventslocation (column name)
|
||
// wp_user-settings → wpusersettings
|
||
// wp_user-settings-time → wpusersettingstime
|
||
// managenav-menuscolumnshidden → managenavmenuscolumnshidden
|
||
// metaboxhidden_nav-menus → metaboxhidden_navmenus.
|
||
array(
|
||
'key' => 'community-events-location',
|
||
'type' => 'textarea',
|
||
'label' => 'Dashboard community-events saved location (JSON); column: communityeventslocation',
|
||
),
|
||
array(
|
||
'key' => 'wp_user-settings',
|
||
'type' => 'text',
|
||
'label' => 'WP admin UI settings string; column: wpusersettings',
|
||
),
|
||
array(
|
||
'key' => 'wp_user-settings-time',
|
||
'type' => 'integer',
|
||
'label' => 'Timestamp when wp_user-settings was last written; column: wpusersettingstime',
|
||
),
|
||
array(
|
||
'key' => 'managenav-menuscolumnshidden',
|
||
'type' => 'text',
|
||
'label' => 'Hidden columns in nav-menus screen; column: managenavmenuscolumnshidden',
|
||
),
|
||
array(
|
||
'key' => 'metaboxhidden_nav-menus',
|
||
'type' => 'text',
|
||
'label' => 'Hidden meta-boxes on nav-menus screen; column: metaboxhidden_navmenus',
|
||
),
|
||
array(
|
||
'key' => 'dismissed_no_secure_connection_notice',
|
||
'type' => 'text',
|
||
'label' => 'Admin dismissed "no secure connection" notice (1/empty)',
|
||
),
|
||
array(
|
||
'key' => 'meta-box-order_product',
|
||
'type' => 'textarea',
|
||
'label' => 'Meta-box order on WC Products screen (serialized); column: metaboxorder_product',
|
||
),
|
||
)
|
||
);
|
||
}
|
||
}
|