chore: initial snapshot of 2meet-data-optimizer v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
@@ -0,0 +1,256 @@
<?php
/**
* Atomic points ledger manager for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Manages member points with atomic DB transactions.
*
* All balance mutations go through _transact(), which wraps
* SELECT … FOR UPDATE + UPDATE membership + INSERT ledger inside a single
* InnoDB transaction. This serialises concurrent debits and prevents the
* classic double-spend race condition (two requests each read balance=100,
* each deduct 60, each write balance=40).
*
* Table layout:
* wp_wpdo_user_membership.points_balance — current snapshot balance
* wp_wpdo_user_points_ledger — append-only journal
*/
final class TMDO_Points_Manager {
/**
* Credit points to a user (positive delta).
*
* @param int $user_id WordPress user ID.
* @param int $delta Points to add (must be > 0).
* @param string $reason Short reason code (≤60 chars).
* @param int $ref_id Optional reference ID (order_id, post_id, …).
* @param string $ref_type Optional reference type ('order', 'post', 'manual', …).
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
*/
public static function credit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '' ): array {
if ( $delta <= 0 ) {
return array(
'ok' => false,
'error' => 'credit delta must be positive',
);
}
return self::transact( $user_id, $delta, $reason, $ref_id, $ref_type, false );
}
/**
* Debit points from a user (negative delta applied internally).
*
* @param int $user_id WordPress user ID.
* @param int $delta Points to deduct (positive number; stored as negative).
* @param string $reason Short reason code (≤60 chars).
* @param int $ref_id Optional reference ID.
* @param string $ref_type Optional reference type.
* @param bool $allow_overdraft When true, debit proceeds even if balance < delta.
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
*/
public static function debit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '', bool $allow_overdraft = false ): array {
if ( $delta <= 0 ) {
return array(
'ok' => false,
'error' => 'debit delta must be positive',
);
}
return self::transact( $user_id, -$delta, $reason, $ref_id, $ref_type, $allow_overdraft );
}
/**
* Return current points balance for a user.
*
* Reads directly from the flat table, bypassing usermeta EAV.
*
* @param int $user_id WordPress user ID.
* @return int Balance (0 when user has no membership row).
*/
public static function get_balance( int $user_id ): int {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_user_membership';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$balance = $wpdb->get_var(
$wpdb->prepare(
"SELECT points_balance FROM `{$table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$user_id
)
);
return (int) ( $balance ?? 0 );
}
/**
* Retrieve ledger entries for a user, newest-first.
*
* The idx_user_created covering index makes this O(log N) regardless of total rows.
*
* @param int $user_id WordPress user ID.
* @param int $limit Max rows to return (default 20).
* @param int $offset Row offset for pagination (default 0).
* @return array<int, array{id:int, delta:int, balance_after:int, reason:string, ref_id:?int, ref_type:?string, created_at:string}>
*/
public static function get_ledger( int $user_id, int $limit = 20, int $offset = 0 ): array {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_user_points_ledger';
$limit = max( 1, min( 500, $limit ) );
$offset = max( 0, $offset );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, delta, balance_after, reason, ref_id, ref_type, created_at FROM `{$table}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$user_id,
$limit,
$offset
),
ARRAY_A
);
return $rows ?: array();
}
// ── Internal ─────────────────────────────────────────────────────────────
/**
* Core atomic transaction: SELECT … FOR UPDATE → validate → UPDATE balance → INSERT ledger.
*
* All balance mutations (credit and debit) route through this single method.
* TMDO_DB::begin() must be called before SELECT … FOR UPDATE; otherwise
* InnoDB ignores the lock hint and the serialisation guarantee is lost.
*
* @param int $user_id WordPress user ID.
* @param int $delta Signed delta (positive = credit, negative = debit).
* @param string $reason Reason code stored in ledger.
* @param int $ref_id Reference ID (0 = none).
* @param string $ref_type Reference type ('' = none).
* @param bool $allow_overdraft Skip balance-floor check when true.
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
*/
private static function transact( int $user_id, int $delta, string $reason, int $ref_id, string $ref_type, bool $allow_overdraft ): array {
global $wpdb;
$mem_table = $wpdb->prefix . 'wpdo_user_membership';
$ledger_table = $wpdb->prefix . 'wpdo_user_points_ledger';
// Truncate reason to column width to avoid silent DB truncation.
$reason = substr( $reason, 0, 60 );
$ref_type = substr( $ref_type, 0, 30 );
TMDO_DB::begin();
try {
// Lock the membership row for this user so concurrent writes wait.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$current_balance = $wpdb->get_var(
$wpdb->prepare(
"SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$user_id
)
);
$current_balance = (int) ( $current_balance ?? 0 );
$new_balance = $current_balance + $delta;
// Reject negative-result debits unless overdraft is explicitly allowed.
if ( ! $allow_overdraft && $new_balance < 0 ) {
TMDO_DB::rollback();
return array(
'ok' => false,
'error' => 'insufficient_balance',
);
}
// Upsert with relative increment — prevents concurrent first-credit race.
// SELECT FOR UPDATE does not lock a non-existent row, so two simultaneous
// first-credits both read balance=0. Using VALUES(points_balance) here means
// InnoDB serialises the two INSERTs: the loser hits ON DUPLICATE KEY and
// applies a relative +delta instead of overwriting with an absolute value.
$upserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare(
"INSERT INTO `{$mem_table}` (user_id, points_balance) VALUES (%d, %d) ON DUPLICATE KEY UPDATE points_balance = points_balance + VALUES(points_balance)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$user_id,
$delta
)
);
if ( false === $upserted ) {
TMDO_DB::rollback();
TMDO_Logger::error( 'points_manager', 'transact', "Membership upsert failed for user {$user_id}: {$wpdb->last_error}" );
return array(
'ok' => false,
'error' => 'db_error',
);
}
// Re-read actual balance so ledger and return value are correct even when
// ON DUPLICATE KEY UPDATE resolved a concurrent race on the first upsert.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$new_balance = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$user_id
)
);
// Append ledger row.
$ledger_data = array(
'user_id' => $user_id,
'delta' => $delta,
'balance_after' => $new_balance,
'reason' => $reason,
'created_at' => current_time( 'mysql' ),
);
$ledger_fmt = array( '%d', '%d', '%d', '%s', '%s' );
if ( $ref_id ) {
$ledger_data['ref_id'] = $ref_id;
$ledger_fmt[] = '%d';
}
if ( '' !== $ref_type ) {
$ledger_data['ref_type'] = $ref_type;
$ledger_fmt[] = '%s';
}
$inserted = $wpdb->insert( $ledger_table, $ledger_data, $ledger_fmt );
if ( false === $inserted ) {
TMDO_DB::rollback();
TMDO_Logger::error( 'points_manager', 'transact', "Ledger insert failed for user {$user_id}: {$wpdb->last_error}" );
return array(
'ok' => false,
'error' => 'db_error',
);
}
$ledger_id = (int) $wpdb->insert_id;
TMDO_DB::commit();
// Notify subscribers — match Hook Bus signature: (type, id, key, value, result, op, before).
do_action( 'wpdo_after_write', 'user', $user_id, 'points_balance', $new_balance, true, 'update', $current_balance );
return array(
'ok' => true,
'balance' => $new_balance,
'ledger_id' => $ledger_id,
);
} catch ( \Throwable $e ) {
TMDO_DB::rollback();
TMDO_Logger::error( 'points_manager', 'transact', $e->getMessage() );
return array(
'ok' => false,
'error' => 'exception',
);
}
}
}