Files
2meet-data-optimizer/cli/class-tmdo-cli-member.php
wpdev f524ea3f16 build: 移植 PHPCS + PHPStan L6 品質 gate(PR-I 前半)
- phpcs.xml(自 A 移植):ruleset 改名、*/tools/* 例外換成 */back-compat/*、
  中文註解全域排除 Squiz.Commenting.InlineComment.InvalidEndChar、
  interface 別名檔排除 OneObjectStructurePerFile
- 檔頭正規化:16 個檔案的 declare(strict_types=1) 與前導 // 註解移到
  file docblock 之後,並移除 <?php 後多餘空行(phpcbf 另自動修 190 處)
- phpstan.neon + .phpstan/stubs.php(TMDO_ 與 WPDO_ 兩套常數)+
  重新產生的 phpstan-baseline.neon(710 errors,A 的 3877 行 baseline
  因前綴與路徑不同無法沿用)

現況:PHPCS 0 errors / 0 warnings、PHPStan L6 No errors、
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:22:00 +08:00

572 lines
19 KiB
PHP

<?php
/**
* TMDO_CLI_Member — Member flat-table CLI subcommands.
*
* Adds the following subcommands under `wp wpdo`:
*
* wp wpdo member-audit — Mode, row counts, shadow diff rate.
* wp wpdo member-backfill — Backfill usermeta → flat table.
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
* wp wpdo member-cutover — Switch user mode to aeav_only.
* wp wpdo member-points-check — Show balance + recent ledger for a user.
* wp wpdo member-sso-sync — Sync Hub membership claims to flat table.
* wp wpdo member-force-logout — Set token_expires_at to past → re-auth.
*
* @package WP_Data_Optimizer
* @since 2.5.5
*/
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_CLI callbacks accept ($args, $assoc_args) by contract; many subcommands only need one of them.
if ( ! class_exists( 'WP_CLI' ) ) {
return;
}
/**
* Member flat-table CLI subcommands. Loaded only in WP_CLI context.
*/
class TMDO_CLI_Member {
/**
* Valid group names managed by this CLI.
*
* V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb
* the legacy WP/WC/HP wp_usermeta keys.
*/
private const VALID_GROUPS = array(
'membership',
'activity',
'profile',
'sso',
'core_profile',
'social',
'commerce',
'hp_user',
'admin_prefs',
);
// ── member-audit ─────────────────────────────────────────────────────────
/**
* Show current user bridge mode, flat-table row counts, and shadow diff rate.
*
* ## EXAMPLES
*
* wp wpdo member-audit
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments (unused).
*/
public function member_audit( $args, $assoc_args ): void {
global $wpdb;
$mode = TMDO_Mode_Manager::get( 'user' );
WP_CLI::log( "User bridge mode: {$mode}" );
WP_CLI::log( '' );
$items = array();
foreach ( self::VALID_GROUPS as $group ) {
$table = $wpdb->prefix . 'wpdo_user_' . $group;
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
$rows = $exists
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" )
: -1;
$items[] = array(
'group' => $group,
'table' => $table,
'exists' => $exists ? 'yes' : 'NO',
'rows' => $exists ? $rows : '(table missing)',
);
}
WP_CLI\Utils\format_items( 'table', $items, array( 'group', 'table', 'exists', 'rows' ) );
WP_CLI::log( '' );
// Shadow diff rate.
$shadow_table = $wpdb->prefix . 'wpdo_shadow_diffs';
$shadow_exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $shadow_table )
);
if ( $shadow_exists ) {
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
"SELECT COUNT(*) FROM `{$shadow_table}` WHERE entity_type = 'user'" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
WP_CLI::log( "Shadow diffs (entity_type=user): {$total}" );
} else {
WP_CLI::log( 'Shadow diffs table: not present.' );
}
}
// ── member-backfill ───────────────────────────────────────────────────────
/**
* Backfill wp_usermeta rows into the specified user flat-table group.
*
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
* 500 rows/batch, checkpoint stored in wpdo_migration_status.
*
* ## OPTIONS
*
* [--group=<group>]
* : Which group to backfill: membership, activity, profile, sso. Default: membership.
*
* [--batch-size=<n>]
* : Rows per batch (default 500, max 2000).
*
* [--dry-run]
* : Preview without writing.
*
* [--reset]
* : Clear checkpoint and restart from the beginning.
*
* ## EXAMPLES
*
* wp wpdo member-backfill --group=membership --dry-run
* wp wpdo member-backfill --group=membership
* wp wpdo member-backfill --group=sso --reset
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_backfill( $args, $assoc_args ): void {
$group = (string) ( $assoc_args['group'] ?? 'membership' );
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
$dry_run = isset( $assoc_args['dry-run'] );
$reset = isset( $assoc_args['reset'] );
if ( ! in_array( $group, self::VALID_GROUPS, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) );
}
if ( $reset ) {
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
WP_CLI::log( "Checkpoint cleared for user/{$group}." );
}
WP_CLI::log( $dry_run ? "[dry-run] Backfill user/{$group} ..." : "Backfill user/{$group} ..." );
$result = TMDO_Entity_Migration_Engine::migrate_group(
'user',
$group,
array(
'batch_size' => $batch_size,
'dry_run' => $dry_run,
'resume' => ! $reset,
)
);
// Pre-flight failure (adapter/group/keys/table missing).
if ( ! empty( $result['error'] ) ) {
WP_CLI::error( $result['error'] );
}
$migrated = (int) ( $result['migrated'] ?? 0 );
$errors = (int) ( $result['errors'] ?? 0 );
$skipped = (int) ( $result['skipped'] ?? 0 );
// Row-level failures: migrate_group() catches Throwables per-entity, increments
// $stats['errors'], and continues. The returned $stats has no 'error' key,
// so without this branch the CLI would silently report success.
if ( $errors > 0 && 0 === $migrated ) {
WP_CLI::error(
sprintf(
'All %d row(s) failed during migration — see error_log for details.',
$errors
)
);
}
if ( $errors > 0 ) {
WP_CLI::warning(
sprintf( '%d row(s) failed during migration — see error_log for details.', $errors )
);
}
WP_CLI::success(
sprintf(
'Migrated %d rows, %d errors, %d skipped (%.2f sec)%s.',
$migrated,
$errors,
$skipped,
$result['elapsed_sec'] ?? 0,
$dry_run ? ' [dry-run, no changes written]' : ''
)
);
}
// ── member-shadow-report ──────────────────────────────────────────────────
/**
* Display recent shadow_diffs rows for entity_type=user.
*
* ## OPTIONS
*
* [--limit=<n>]
* : Max rows to display (default 20).
*
* [--format=<format>]
* : Output format: table, json, csv. Default: table.
*
* ## EXAMPLES
*
* wp wpdo member-shadow-report
* wp wpdo member-shadow-report --limit=50 --format=json
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_shadow_report( $args, $assoc_args ): void {
global $wpdb;
$limit = min( 500, max( 1, (int) ( $assoc_args['limit'] ?? 20 ) ) );
$format = (string) ( $assoc_args['format'] ?? 'table' );
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
WP_CLI::warning( 'wpdo_shadow_diffs table not present — no shadow diffs collected yet.' );
return;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, entity_id, meta_key, postmeta_value, zone_value, diff_hash, ts FROM `{$table}` WHERE entity_type = 'user' ORDER BY ts DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$limit
),
ARRAY_A
);
if ( empty( $rows ) ) {
WP_CLI::success( 'No shadow diffs found for entity_type=user.' );
return;
}
WP_CLI::log( count( $rows ) . " diff(s) found (limit {$limit}):" );
WP_CLI\Utils\format_items(
$format,
$rows,
array( 'id', 'entity_id', 'meta_key', 'diff_hash', 'ts' )
);
}
// ── member-cutover ────────────────────────────────────────────────────────
/**
* Switch the user bridge mode to aeav_only (reads and writes go to flat table only).
*
* Requires --confirm to prevent accidental invocation.
* Recommended only after shadow diff rate drops below 0.1%.
*
* ## OPTIONS
*
* [--confirm]
* : Required confirmation flag.
*
* ## EXAMPLES
*
* wp wpdo member-cutover --confirm
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_cutover( $args, $assoc_args ): void {
if ( ! isset( $assoc_args['confirm'] ) ) {
WP_CLI::error( 'You must pass --confirm to cut over. Check shadow diff rate first: wp wpdo member-shadow-report' );
}
$current = TMDO_Mode_Manager::get( 'user' );
WP_CLI::log( "Current user mode: {$current}" );
if ( 'aeav_only' === $current ) {
WP_CLI::success( 'User mode is already aeav_only. Nothing to do.' );
return;
}
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
if ( isset( $result['error'] ) ) {
WP_CLI::error( 'Cutover failed: ' . $result['error'] );
}
WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' );
}
// ── member-points-check ───────────────────────────────────────────────────
/**
* Show points balance and recent ledger entries for a user.
*
* ## OPTIONS
*
* <user_id>
* : WordPress user ID.
*
* [--limit=<n>]
* : Ledger rows to show (default 10).
*
* ## EXAMPLES
*
* wp wpdo member-points-check 42
* wp wpdo member-points-check 42 --limit=20
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_points_check( $args, $assoc_args ): void {
$user_id = (int) ( $args[0] ?? 0 );
if ( $user_id <= 0 ) {
WP_CLI::error( 'Usage: wp wpdo member-points-check <user_id>' );
}
$limit = min( 100, max( 1, (int) ( $assoc_args['limit'] ?? 10 ) ) );
$balance = TMDO_Points_Manager::get_balance( $user_id );
WP_CLI::log( "User {$user_id} — points balance: {$balance}" );
WP_CLI::log( '' );
$ledger = TMDO_Points_Manager::get_ledger( $user_id, $limit );
if ( empty( $ledger ) ) {
WP_CLI::log( 'No ledger entries found.' );
return;
}
WP_CLI\Utils\format_items(
'table',
$ledger,
array( 'id', 'delta', 'balance_after', 'reason', 'ref_type', 'ref_id', 'created_at' )
);
}
// ── member-sso-sync ───────────────────────────────────────────────────────
/**
* Sync Hub membership claims into the user flat table.
*
* Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token
* from usermeta and writes them into the sso group flat table.
* Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing
* directly to the sso group via the Hook Bus.
*
* ## OPTIONS
*
* [--user-id=<id>]
* : Single user ID to sync.
*
* [--all]
* : Sync all users that have _tmso_refresh_token in usermeta (batch 500).
*
* [--dry-run]
* : Preview without writing.
*
* ## EXAMPLES
*
* wp wpdo member-sso-sync --user-id=42
* wp wpdo member-sso-sync --all --dry-run
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_sso_sync( $args, $assoc_args ): void {
global $wpdb;
$user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0;
$all = isset( $assoc_args['all'] );
$dry_run = isset( $assoc_args['dry-run'] );
if ( ! $user_id && ! $all ) {
WP_CLI::error( 'Provide --user-id=<id> or --all.' );
}
$sso_table = $wpdb->prefix . 'wpdo_user_sso';
$table_exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $sso_table )
);
if ( ! $table_exists ) {
WP_CLI::error( "SSO flat table {$sso_table} does not exist. Run `wp wpdo doctor` first." );
}
$user_ids = array();
if ( $user_id ) {
$user_ids = array( $user_id );
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$user_ids = $wpdb->get_col(
"SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = '_tmso_refresh_token' LIMIT 5000" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
}
if ( empty( $user_ids ) ) {
WP_CLI::log( 'No users with _tmso_refresh_token found.' );
return;
}
$synced = 0;
$skipped = 0;
$sso_keys = array(
'hub_global_user_id' => '_tmso_global_user_id',
'picture_url' => '_tmso_picture_url',
'refresh_token_enc' => '_tmso_refresh_token',
);
foreach ( $user_ids as $uid ) {
$uid = (int) $uid;
$data = array( 'user_id' => $uid );
foreach ( $sso_keys as $flat_col => $meta_key ) {
$val = get_user_meta( $uid, $meta_key, true );
if ( '' !== $val ) {
// Reject plaintext refresh tokens — must carry the enc_vN: envelope
// written by TMSO_Crypto::encrypt(). Storing cleartext here would
// silently bypass the encryption layer.
if ( 'refresh_token_enc' === $flat_col && ! preg_match( '/^enc_v\d+:/', (string) $val ) ) {
TMDO_Logger::error( 'sso_sync', 'plaintext_token_rejected', "user_id={$uid} meta_key={$meta_key}" );
continue;
}
$data[ $flat_col ] = $val;
}
}
if ( count( $data ) <= 1 ) {
++$skipped;
continue;
}
if ( ! $dry_run ) {
$wpdb->replace( $sso_table, $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
}
++$synced;
}
WP_CLI::success(
sprintf(
'SSO sync complete — synced: %d, skipped (no meta): %d%s.',
$synced,
$skipped,
$dry_run ? ' [dry-run]' : ''
)
);
}
// ── member-force-logout ───────────────────────────────────────────────────
/**
* Force re-authentication by setting token_expires_at to a past datetime.
*
* The Spoke silent-refresh logic detects an expired token_expires_at and
* redirects the user to re-authenticate against the Hub. If the Hub has
* the account blocked, the user is logged out from all Spokes.
*
* ## OPTIONS
*
* [--user-id=<id>]
* : WordPress local user ID to force-logout.
*
* [--global-user-id=<uuid>]
* : Hub global_user_id to force-logout (looks up via hub_global_user_id column).
*
* [--confirm]
* : Required safety flag — confirms intent to invalidate live SSO tokens.
*
* ## EXAMPLES
*
* wp wpdo member-force-logout --user-id=42 --confirm
* wp wpdo member-force-logout --global-user-id=abc-123 --confirm
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_force_logout( $args, $assoc_args ): void {
if ( ! isset( $assoc_args['confirm'] ) ) {
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
}
global $wpdb;
$sso_table = $wpdb->prefix . 'wpdo_user_sso';
$past = '2000-01-01 00:00:00';
if ( isset( $assoc_args['user-id'] ) ) {
$uid = (int) $assoc_args['user-id'];
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE `{$sso_table}` SET token_expires_at = %s WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$past,
$uid
)
);
if ( $updated ) {
TMDO_Logger::info(
'member_force_logout',
array(
'user_id' => $uid,
'via' => 'user-id',
)
);
WP_CLI::success( "Force-logout applied to user_id={$uid}. Token invalidated." );
} else {
WP_CLI::warning( "No SSO row found for user_id={$uid}." );
}
return;
}
if ( isset( $assoc_args['global-user-id'] ) ) {
$global_id = sanitize_text_field( (string) $assoc_args['global-user-id'] );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE `{$sso_table}` SET token_expires_at = %s WHERE hub_global_user_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$past,
$global_id
)
);
if ( $updated ) {
TMDO_Logger::info(
'member_force_logout',
array(
'global_user_id' => $global_id,
'affected_rows' => $updated,
'via' => 'global-user-id',
)
);
WP_CLI::success( "Force-logout applied to global_user_id={$global_id}. Affected rows: {$updated}." );
} else {
WP_CLI::warning( "No SSO row found for global_user_id={$global_id}." );
}
return;
}
WP_CLI::error( 'Provide --user-id=<id> or --global-user-id=<uuid>.' );
}
}
// ── Register subcommands ──────────────────────────────────────────────────────
WP_CLI::add_command( 'wpdo member-audit', array( 'TMDO_CLI_Member', 'member_audit' ) );
WP_CLI::add_command( 'tmdo member-audit', array( 'TMDO_CLI_Member', 'member_audit' ) );
WP_CLI::add_command( 'wpdo member-backfill', array( 'TMDO_CLI_Member', 'member_backfill' ) );
WP_CLI::add_command( 'tmdo member-backfill', array( 'TMDO_CLI_Member', 'member_backfill' ) );
WP_CLI::add_command( 'wpdo member-shadow-report', array( 'TMDO_CLI_Member', 'member_shadow_report' ) );
WP_CLI::add_command( 'tmdo member-shadow-report', array( 'TMDO_CLI_Member', 'member_shadow_report' ) );
WP_CLI::add_command( 'wpdo member-cutover', array( 'TMDO_CLI_Member', 'member_cutover' ) );
WP_CLI::add_command( 'tmdo member-cutover', array( 'TMDO_CLI_Member', 'member_cutover' ) );
WP_CLI::add_command( 'wpdo member-points-check', array( 'TMDO_CLI_Member', 'member_points_check' ) );
WP_CLI::add_command( 'tmdo member-points-check', array( 'TMDO_CLI_Member', 'member_points_check' ) );
WP_CLI::add_command( 'wpdo member-sso-sync', array( 'TMDO_CLI_Member', 'member_sso_sync' ) );
WP_CLI::add_command( 'tmdo member-sso-sync', array( 'TMDO_CLI_Member', 'member_sso_sync' ) );
WP_CLI::add_command( 'wpdo member-force-logout', array( 'TMDO_CLI_Member', 'member_force_logout' ) );
WP_CLI::add_command( 'tmdo member-force-logout', array( 'TMDO_CLI_Member', 'member_force_logout' ) );