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:
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user