76c01e44df
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入 並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
259 lines
9.3 KiB
PHP
259 lines
9.3 KiB
PHP
<?php
|
|
/**
|
|
* Atomic points ledger manager for WP Data Optimizer.
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
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',
|
|
);
|
|
}
|
|
}
|
|
}
|