积分与余额账务系统硬切重构

This commit is contained in:
kate522 committed 2026-08-24 15:59:11 +08:00
1 parent ca12226b39
commit 5cae5b5c93
159 files changed
+7275 -4718

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Ledger;
defined('ABSPATH') || exit;
final class AssetAmount {
private function __construct(
private readonly AssetType $asset,
private readonly int $minor
) {}
public static function points(int $points): self {
if ($points < 0) {
throw new \InvalidArgumentException('points_must_be_non_negative');
}
return new self(AssetType::POINTS, $points);
}
public static function balance(string $decimal): self {
$decimal = trim($decimal);
if (!preg_match('/^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/', $decimal, $matches)) {
throw new \InvalidArgumentException('balance_decimal_invalid');
}
$whole = (int) $matches[1];
$fraction = str_pad((string) ($matches[2] ?? ''), 2, '0');
if ($whole > intdiv(PHP_INT_MAX - (int) $fraction, 100)) {
throw new \InvalidArgumentException('balance_decimal_overflow');
}
return new self(AssetType::BALANCE, ($whole * 100) + (int) $fraction);
}
public static function balance_minor(string $decimal): int {
return self::balance($decimal)->minor();
}
public static function decimal_from_minor(int $minor): string {
return self::from_minor(AssetType::BALANCE, $minor)->decimal();
}
/**
* Apply an integer percentage using half-up rounding without floats.
*/
public static function percentage(int $minor, int $percent): int {
if ($minor < 0) {
throw new \InvalidArgumentException('asset_minor_must_be_non_negative');
}
if ($percent < 0 || $percent > 100) {
throw new \InvalidArgumentException('asset_percentage_invalid');
}
$whole = intdiv($minor, 100) * $percent;
$remainder = ($minor % 100) * $percent;
return $whole + intdiv($remainder + 50, 100);
}
public static function from_minor(AssetType|string $asset, int $minor): self {
if ($minor < 0) {
throw new \InvalidArgumentException('asset_minor_must_be_non_negative');
}
return new self(AssetType::from_value($asset), $minor);
}
public function asset(): AssetType {
return $this->asset;
}
public function minor(): int {
return $this->minor;
}
public function decimal(): string {
if ($this->asset === AssetType::POINTS) {
return (string) $this->minor;
}
return sprintf('%d.%02d', intdiv($this->minor, 100), $this->minor % 100);
}
public function formatted(): string {
if ($this->asset === AssetType::POINTS) {
return number_format_i18n($this->minor);
}
return $this->decimal();
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Ledger;
defined('ABSPATH') || exit;
enum AssetType: string {
case POINTS = 'points';
case BALANCE = 'balance';
public static function from_value(self|string $asset): self {
return $asset instanceof self ? $asset : self::from($asset);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Ledger;
defined('ABSPATH') || exit;
final class LedgerAbort extends \RuntimeException {
public function __construct(public readonly \WP_Error $error) {
parent::__construct($error->get_error_code());
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Ledger;
defined('ABSPATH') || exit;
final class LedgerCommand {
/**
* @param array<int,array{user_id:int,asset:string,delta_minor:int,entry_type:string,counterparty_user_id?:int,remark?:string}> $entries
* @param array<string,mixed> $metadata
*/
public function __construct(
public readonly string $operation_key,
public readonly string $reason,
public readonly array $entries,
public readonly string $ref_type = '',
public readonly string $ref_key = '',
public readonly int $actor_user_id = 0,
public readonly array $metadata = []
) {}
/**
* @param array<string,mixed> $metadata
*/
public static function transfer(
string $operation_key,
string $reason,
int $sender_id,
int $recipient_id,
AssetType|string $asset,
int $gross_minor,
int $fee_minor,
string $ref_type = '',
string $ref_key = '',
string $sender_remark = '',
string $recipient_remark = '',
array $metadata = []
): self {
$asset = AssetType::from_value($asset);
if ($sender_id <= 0 || $recipient_id <= 0 || $sender_id === $recipient_id || $gross_minor <= 0 || $fee_minor < 0 || $fee_minor >= $gross_minor) {
throw new \InvalidArgumentException('ledger_transfer_invalid');
}
$net_minor = $gross_minor - $fee_minor;
$entries = [[
'user_id' => $sender_id,
'asset' => $asset->value,
'delta_minor' => -$net_minor,
'entry_type' => 'transfer_out',
'counterparty_user_id' => $recipient_id,
'remark' => $sender_remark,
]];
if ($fee_minor > 0) {
$entries[] = [
'user_id' => $sender_id,
'asset' => $asset->value,
'delta_minor' => -$fee_minor,
'entry_type' => 'fee',
'counterparty_user_id' => $recipient_id,
'remark' => __('转账手续费', 'aksrt'),
];
}
$entries[] = [
'user_id' => $recipient_id,
'asset' => $asset->value,
'delta_minor' => $net_minor,
'entry_type' => 'transfer_in',
'counterparty_user_id' => $sender_id,
'remark' => $recipient_remark,
];
$metadata['gross_minor'] = $gross_minor;
$metadata['fee_minor'] = $fee_minor;
$metadata['net_minor'] = $net_minor;
return new self($operation_key, $reason, $entries, $ref_type, $ref_key, $sender_id, $metadata);
}
public function validate() {
if ($this->operation_key === '' || strlen($this->operation_key) > 191 || !preg_match('/^[A-Za-z0-9:._-]+$/', $this->operation_key)) {
return new \WP_Error('aksrt_ledger_operation_key', __('账务操作标识无效', 'aksrt'), ['status' => 400]);
}
if ($this->reason === '' || strlen($this->reason) > 64 || sanitize_key($this->reason) !== $this->reason) {
return new \WP_Error('aksrt_ledger_reason', __('账务原因无效', 'aksrt'), ['status' => 400]);
}
if ($this->entries === []) {
return new \WP_Error('aksrt_ledger_entries', __('账务明细不能为空', 'aksrt'), ['status' => 400]);
}
if ($this->actor_user_id < 0) {
return new \WP_Error('aksrt_ledger_actor', __('账务操作者无效', 'aksrt'), ['status' => 400]);
}
foreach ($this->entries as $entry) {
try {
AssetType::from((string) ($entry['asset'] ?? ''));
} catch (\ValueError $e) {
return new \WP_Error('aksrt_ledger_asset', __('资产类型无效', 'aksrt'), ['status' => 400]);
}
$user_id = $entry['user_id'] ?? null;
$delta_minor = $entry['delta_minor'] ?? null;
$entry_type = $entry['entry_type'] ?? null;
if (!is_int($user_id) || $user_id <= 0
|| !is_int($delta_minor) || $delta_minor === 0 || $delta_minor === PHP_INT_MIN
|| !is_string($entry_type) || $entry_type === '' || strlen($entry_type) > 32 || sanitize_key($entry_type) !== $entry_type
) {
return new \WP_Error('aksrt_ledger_entry', __('账务明细无效', 'aksrt'), ['status' => 400]);
}
if (isset($entry['counterparty_user_id']) && (!is_int($entry['counterparty_user_id']) || $entry['counterparty_user_id'] <= 0)) {
return new \WP_Error('aksrt_ledger_entry', __('账务明细无效', 'aksrt'), ['status' => 400]);
}
}
return true;
}
public function payload_hash(): string {
$payload = [
'reason' => $this->reason,
'entries' => $this->entries,
'ref_type' => $this->ref_type,
'ref_key' => $this->ref_key,
'actor_user_id' => $this->actor_user_id,
'metadata' => $this->metadata,
];
self::sort_recursive($payload);
return hash('sha256', (string) wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private static function sort_recursive(array &$value): void {
foreach ($value as &$item) {
if (is_array($item)) {
self::sort_recursive($item);
}
}
unset($item);
if (!array_is_list($value)) {
ksort($value);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Ledger;
defined('ABSPATH') || exit;
final class LedgerPresentation {
public static function reason_label(string $reason): string {
$labels = [
'admin_adjustment' => __('管理员调整', 'aksrt'),
'affiliate_commission' => __('推广佣金结算', 'aksrt'),
'card_pass_balance' => __('卡密充值', 'aksrt'),
'card_pass_credits' => __('卡密兑换积分', 'aksrt'),
'content_points_pay' => __('积分购买内容', 'aksrt'),
'creator_income' => __('创作收益结算', 'aksrt'),
'free_comment_like' => __('评论被赞奖励', 'aksrt'),
'free_comment_new' => __('发表评论奖励', 'aksrt'),
'free_followed' => __('被关注奖励', 'aksrt'),
'free_login' => __('每日登录奖励', 'aksrt'),
'free_post_favorite' => __('文章被收藏奖励', 'aksrt'),
'free_post_like' => __('文章被赞奖励', 'aksrt'),
'free_post_new' => __('发布文章奖励', 'aksrt'),
'free_sign_in' => __('签到任务奖励', 'aksrt'),
'free_sign_up' => __('注册奖励', 'aksrt'),
'order_payment' => __('订单支付', 'aksrt'),
'order_refund' => __('订单退款', 'aksrt'),
'points_exchange' => __('余额兑换积分', 'aksrt'),
'referral' => __('推广奖励', 'aksrt'),
'recharge' => __('余额充值', 'aksrt'),
'signin' => __('每日签到', 'aksrt'),
'transfer' => __('用户转账', 'aksrt'),
'tip' => __('用户赞赏', 'aksrt'),
'verify_application' => __('认证申请费用', 'aksrt'),
'vip_points_exchange' => __('积分兑换会员', 'aksrt'),
'withdraw_hold' => __('提现冻结', 'aksrt'),
'withdraw_refund' => __('提现退回', 'aksrt'),
];
$key = sanitize_key($reason);
return $labels[$key] ?? ($reason !== '' ? $reason : __('资产变动', 'aksrt'));
}
}
+245
View File
@@ -0,0 +1,245 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Ledger;
defined('ABSPATH') || exit;
final class LedgerQuery {
private static ?self $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* @param array<string,mixed> $args
* @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int}
*/
public function history(array $args): array {
global $wpdb;
$user_id = max(0, (int) ($args['user_id'] ?? 0));
$asset = sanitize_key((string) ($args['asset'] ?? ''));
$reason = sanitize_key((string) ($args['reason'] ?? ''));
$transaction_id = max(0, (int) ($args['transaction_id'] ?? 0));
$operation_key = trim(sanitize_text_field((string) ($args['operation_key'] ?? '')));
$ref_type = sanitize_key((string) ($args['ref_type'] ?? ''));
$ref_key = trim(sanitize_text_field((string) ($args['ref_key'] ?? '')));
$actor_user_id = max(0, (int) ($args['actor_user_id'] ?? 0));
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
$offset = ($page - 1) * $per_page;
if ($asset !== '' && !in_array($asset, [AssetType::POINTS->value, AssetType::BALANCE->value], true)) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $per_page];
}
$entry_table = \AKSRT\Core\DBMigrator::table('asset_entry');
$transaction_table = \AKSRT\Core\DBMigrator::table('asset_transaction');
$where = ['1=1'];
$params = [];
if ($user_id > 0) {
$where[] = 'e.user_id = %d';
$params[] = $user_id;
}
if ($asset !== '') {
$where[] = 'e.asset = %s';
$params[] = $asset;
}
if ($reason !== '') {
$where[] = 't.reason = %s';
$params[] = $reason;
}
if ($transaction_id > 0) {
$where[] = 't.id = %d';
$params[] = $transaction_id;
}
if ($operation_key !== '') {
$where[] = 't.operation_key = %s';
$params[] = $operation_key;
}
if ($ref_type !== '') {
$where[] = 't.ref_type = %s';
$params[] = $ref_type;
}
if ($ref_key !== '') {
$where[] = 't.ref_key = %s';
$params[] = $ref_key;
}
if ($actor_user_id > 0) {
$where[] = 't.actor_user_id = %d';
$params[] = $actor_user_id;
}
if (!empty($args['date_from'])) {
$where[] = 't.created_at >= %s';
$params[] = (string) $args['date_from'];
}
if (!empty($args['date_to'])) {
$where[] = 't.created_at < %s';
$params[] = (string) $args['date_to'];
}
$where_sql = implode(' AND ', $where);
$count_sql = "SELECT COUNT(DISTINCT e.transaction_id) FROM {$entry_table} e INNER JOIN {$transaction_table} t ON t.id = e.transaction_id WHERE {$where_sql}";
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
$id_sql = "SELECT e.transaction_id, MAX(e.id) newest_entry FROM {$entry_table} e INNER JOIN {$transaction_table} t ON t.id = e.transaction_id WHERE {$where_sql} GROUP BY e.transaction_id ORDER BY newest_entry DESC LIMIT %d OFFSET %d";
$id_rows = $wpdb->get_results($wpdb->prepare($id_sql, array_merge($params, [$per_page, $offset])), ARRAY_A);
$transaction_ids = array_values(array_filter(array_map(static fn($row) => (int) ($row['transaction_id'] ?? 0), (array) $id_rows)));
if ($transaction_ids === []) {
return ['items' => [], 'total' => $total, 'page' => $page, 'per_page' => $per_page];
}
$placeholders = implode(',', array_fill(0, count($transaction_ids), '%d'));
$entry_where = ["e.transaction_id IN ({$placeholders})"];
$entry_params = $transaction_ids;
if ($user_id > 0) {
$entry_where[] = 'e.user_id = %d';
$entry_params[] = $user_id;
}
if ($asset !== '') {
$entry_where[] = 'e.asset = %s';
$entry_params[] = $asset;
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT e.*, t.operation_key, t.reason, t.ref_type, t.ref_key, t.actor_user_id, t.metadata, t.created_at transaction_created_at
FROM {$entry_table} e INNER JOIN {$transaction_table} t ON t.id = e.transaction_id
WHERE " . implode(' AND ', $entry_where) . ' ORDER BY e.transaction_id DESC, e.line_no ASC',
$entry_params
), ARRAY_A);
$groups = [];
foreach ((array) $rows as $row) {
$transaction_id = (int) $row['transaction_id'];
if (!isset($groups[$transaction_id])) {
$groups[$transaction_id] = [
'id' => $transaction_id,
'operation_key' => (string) $row['operation_key'],
'reason' => (string) $row['reason'],
'ref_type' => (string) ($row['ref_type'] ?? ''),
'ref_key' => (string) ($row['ref_key'] ?? ''),
'actor_user_id' => (int) ($row['actor_user_id'] ?? 0),
'metadata' => json_decode((string) ($row['metadata'] ?? ''), true) ?: [],
'created_at' => (string) $row['transaction_created_at'],
'delta_minor' => 0,
'fee_minor' => 0,
'balance_after_minor' => 0,
'counterparty_user_id' => 0,
'remark' => '',
'assets' => [],
'entries' => [],
];
}
$entry = [
'id' => (int) $row['id'],
'line_no' => (int) $row['line_no'],
'user_id' => (int) $row['user_id'],
'asset' => (string) $row['asset'],
'entry_type' => (string) $row['entry_type'],
'delta_minor' => (int) $row['delta_minor'],
'balance_after_minor' => (int) $row['balance_after_minor'],
'counterparty_user_id' => (int) ($row['counterparty_user_id'] ?? 0),
'remark' => (string) ($row['remark'] ?? ''),
];
$groups[$transaction_id]['entries'][] = $entry;
$groups[$transaction_id]['assets'][$entry['asset']] = $entry['asset'];
$groups[$transaction_id]['delta_minor'] += $entry['delta_minor'];
$groups[$transaction_id]['balance_after_minor'] = $entry['balance_after_minor'];
if ($entry['entry_type'] === 'fee') {
$groups[$transaction_id]['fee_minor'] += abs($entry['delta_minor']);
}
if ($groups[$transaction_id]['counterparty_user_id'] === 0 && $entry['counterparty_user_id'] > 0) {
$groups[$transaction_id]['counterparty_user_id'] = $entry['counterparty_user_id'];
}
if ($groups[$transaction_id]['remark'] === '' && $entry['remark'] !== '') {
$groups[$transaction_id]['remark'] = $entry['remark'];
}
}
$order = array_flip($transaction_ids);
$items = array_values($groups);
foreach ($items as &$item) {
$item['assets'] = array_values($item['assets']);
}
unset($item);
usort($items, static fn($a, $b) => ($order[$a['id']] ?? PHP_INT_MAX) <=> ($order[$b['id']] ?? PHP_INT_MAX));
return ['items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
}
/**
* The caller must hold the user's account locks while using this for a limit guard.
*/
public function sum_credits(int $user_id, string $reason, string $start_utc, string $end_utc): int {
global $wpdb;
$entry_table = \AKSRT\Core\DBMigrator::table('asset_entry');
$transaction_table = \AKSRT\Core\DBMigrator::table('asset_transaction');
return max(0, (int) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(e.delta_minor), 0) FROM {$entry_table} e
INNER JOIN {$transaction_table} t ON t.id = e.transaction_id
WHERE e.user_id = %d AND e.asset = %s AND e.delta_minor > 0 AND t.reason = %s AND t.created_at >= %s AND t.created_at < %s",
$user_id,
AssetType::POINTS->value,
$reason,
$start_utc,
$end_utc
)));
}
public function sum_positive(int $user_id, AssetType|string $asset, string $reason): int {
global $wpdb;
if ($user_id <= 0 || sanitize_key($reason) !== $reason || $reason === '') {
return 0;
}
$asset = AssetType::from_value($asset);
$entry_table = \AKSRT\Core\DBMigrator::table('asset_entry');
$transaction_table = \AKSRT\Core\DBMigrator::table('asset_transaction');
return max(0, (int) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(e.delta_minor), 0) FROM {$entry_table} e
INNER JOIN {$transaction_table} t ON t.id = e.transaction_id
WHERE e.user_id = %d AND e.asset = %s AND e.delta_minor > 0 AND t.reason = %s",
$user_id,
$asset->value,
$reason
)));
}
/**
* @param array<string,mixed> $args
* @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int}
*/
public function list_accounts(AssetType|string $asset, array $args = []): array {
global $wpdb;
$asset = AssetType::from_value($asset);
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
$offset = ($page - 1) * $per_page;
$search = trim((string) ($args['search'] ?? ''));
$where = ['1=1'];
$params = [$asset->value];
if ($search !== '') {
$like = '%' . $wpdb->esc_like($search) . '%';
$where[] = '(u.user_login LIKE %s OR u.user_email LIKE %s OR u.display_name LIKE %s)';
array_push($params, $like, $like, $like);
}
$where_sql = implode(' AND ', $where);
$total_sql = "SELECT COUNT(*) FROM {$wpdb->users} u WHERE {$where_sql}";
$total_params = array_slice($params, 1);
$total = (int) ($total_params ? $wpdb->get_var($wpdb->prepare($total_sql, $total_params)) : $wpdb->get_var($total_sql));
$account_table = \AKSRT\Core\DBMigrator::table('asset_account');
$sql = "SELECT u.ID id, u.user_login login, u.user_email email, u.display_name, u.user_registered registered,
COALESCE(a.balance_minor, 0) balance_minor
FROM {$wpdb->users} u LEFT JOIN {$account_table} a ON a.user_id = u.ID AND a.asset = %s
WHERE {$where_sql} ORDER BY u.user_registered DESC LIMIT %d OFFSET %d";
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset])), ARRAY_A);
foreach ((array) $rows as &$row) {
$row['id'] = (int) $row['id'];
$row['balance_minor'] = (int) $row['balance_minor'];
}
unset($row);
return ['items' => (array) $rows, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Ledger;
defined('ABSPATH') || exit;
final class LedgerResult {
/**
* @param array<int,array<string,mixed>> $entries
* @param array<int,array<string,int>> $balances
*/
public function __construct(
public readonly int $transaction_id,
public readonly bool $replayed,
public readonly array $entries,
public readonly array $balances
) {}
public function balance(int $user_id, AssetType|string $asset): int {
$key = AssetType::from_value($asset)->value;
return (int) ($this->balances[$user_id][$key] ?? 0);
}
/** @return array<string,mixed> */
public function to_array(): array {
return [
'transaction_id' => $this->transaction_id,
'replayed' => $this->replayed,
'entries' => $this->entries,
'balances' => $this->balances,
];
}
}
+562
View File
@@ -0,0 +1,562 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Ledger;
defined('ABSPATH') || exit;
final class LedgerService {
private const MAX_ATTEMPTS = 3;
private static ?self $instance = null;
private bool $transaction_active = false;
/** @var array<int,callable> */
private array $after_commit_callbacks = [];
private function __construct() {}
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function transaction_active(): bool {
return $this->transaction_active;
}
public function after_commit(callable $callback): bool {
if (!$this->transaction_active) {
return false;
}
$this->after_commit_callbacks[] = $callback;
return true;
}
public function balance(int $user_id, AssetType|string $asset): int {
global $wpdb;
if ($user_id <= 0) {
return 0;
}
$asset = AssetType::from_value($asset);
$table = $this->table('asset_account');
return max(0, (int) $wpdb->get_var($wpdb->prepare(
"SELECT balance_minor FROM {$table} WHERE user_id = %d AND asset = %s",
$user_id,
$asset->value
)));
}
public function balance_decimal(int $user_id): string {
return AssetAmount::from_minor(AssetType::BALANCE, $this->balance($user_id, AssetType::BALANCE))->decimal();
}
public function format_balance(int $minor): string {
return AssetAmount::from_minor(AssetType::BALANCE, max(0, $minor))->formatted();
}
public function result_for_operation(string $operation_key): ?LedgerResult {
global $wpdb;
if ($operation_key === '' || strlen($operation_key) > 191) {
return null;
}
$transaction_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table('asset_transaction')} WHERE operation_key = %s LIMIT 1",
$operation_key
));
return $transaction_id > 0 ? $this->load_result($transaction_id, true) : null;
}
/**
* @param array<string,mixed> $ref
* @param array<string,mixed> $metadata
*/
public function credit(
int $user_id,
AssetType|string $asset,
int $amount_minor,
string $operation_key,
string $reason,
array $ref = [],
string $remark = '',
int $actor_user_id = 0,
array $metadata = [],
?callable $business_mutation = null,
?callable $guard = null
) {
try {
$asset = AssetType::from_value($asset);
} catch (\ValueError $e) {
return new \WP_Error('aksrt_ledger_asset', __('资产类型无效', 'aksrt'), ['status' => 400]);
}
if ($amount_minor <= 0) {
return new \WP_Error('aksrt_ledger_amount', __('入账金额必须大于零', 'aksrt'), ['status' => 400]);
}
return $this->post(new LedgerCommand(
$operation_key,
sanitize_key($reason),
[[
'user_id' => $user_id,
'asset' => $asset->value,
'delta_minor' => $amount_minor,
'entry_type' => 'credit',
'remark' => $remark,
]],
$this->ref_type($ref),
$this->ref_key($ref),
$actor_user_id,
$metadata
), $guard, $business_mutation);
}
/**
* @param array<string,mixed> $ref
* @param array<string,mixed> $metadata
*/
public function debit(
int $user_id,
AssetType|string $asset,
int $amount_minor,
string $operation_key,
string $reason,
array $ref = [],
string $remark = '',
int $actor_user_id = 0,
array $metadata = [],
?callable $business_mutation = null,
?callable $guard = null
) {
try {
$asset = AssetType::from_value($asset);
} catch (\ValueError $e) {
return new \WP_Error('aksrt_ledger_asset', __('资产类型无效', 'aksrt'), ['status' => 400]);
}
if ($amount_minor <= 0) {
return new \WP_Error('aksrt_ledger_amount', __('扣减金额必须大于零', 'aksrt'), ['status' => 400]);
}
return $this->post(new LedgerCommand(
$operation_key,
sanitize_key($reason),
[[
'user_id' => $user_id,
'asset' => $asset->value,
'delta_minor' => -$amount_minor,
'entry_type' => 'debit',
'remark' => $remark,
]],
$this->ref_type($ref),
$this->ref_key($ref),
$actor_user_id,
$metadata
), $guard, $business_mutation);
}
/**
* @param array<string,mixed> $ref
* @param array<string,mixed> $metadata
*/
public function transfer(
int $sender_id,
int $recipient_id,
AssetType|string $asset,
int $gross_minor,
int $fee_minor,
string $operation_key,
string $reason,
array $ref = [],
string $sender_remark = '',
string $recipient_remark = '',
array $metadata = []
) {
try {
$asset = AssetType::from_value($asset);
} catch (\ValueError $e) {
return new \WP_Error('aksrt_ledger_asset', __('资产类型无效', 'aksrt'), ['status' => 400]);
}
if ($sender_id <= 0 || $recipient_id <= 0 || $sender_id === $recipient_id || $gross_minor <= 0 || $fee_minor < 0 || $fee_minor >= $gross_minor) {
return new \WP_Error('aksrt_ledger_transfer', __('转账参数无效', 'aksrt'), ['status' => 400]);
}
return $this->post(LedgerCommand::transfer(
$operation_key,
sanitize_key($reason),
$sender_id,
$recipient_id,
$asset,
$gross_minor,
$fee_minor,
$this->ref_type($ref),
$this->ref_key($ref),
$sender_remark,
$recipient_remark,
$metadata
));
}
/**
* @param array<string,mixed> $ref
* @param array<string,mixed> $metadata
*/
public function exchange(
int $user_id,
int $balance_minor,
int $points,
string $operation_key,
string $reason,
array $ref = [],
string $remark = '',
array $metadata = [],
?callable $guard = null,
?callable $business_mutation = null
) {
if ($user_id <= 0 || $balance_minor <= 0 || $points <= 0) {
return new \WP_Error('aksrt_ledger_exchange', __('兑换参数无效', 'aksrt'), ['status' => 400]);
}
return $this->post(new LedgerCommand(
$operation_key,
sanitize_key($reason),
[
['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => -$balance_minor, 'entry_type' => 'exchange_out', 'remark' => $remark],
['user_id' => $user_id, 'asset' => AssetType::POINTS->value, 'delta_minor' => $points, 'entry_type' => 'exchange_in', 'remark' => $remark],
],
$this->ref_type($ref),
$this->ref_key($ref),
$user_id,
$metadata
), $guard, $business_mutation);
}
/**
* @param null|callable(int,array<int,array<string,int>>):(true|\WP_Error) $guard
* @param null|callable(int,LedgerResult):(true|\WP_Error) $business_mutation
* @return LedgerResult|\WP_Error
*/
public function post(LedgerCommand $command, ?callable $guard = null, ?callable $business_mutation = null) {
if ($this->transaction_active) {
return new \WP_Error('aksrt_ledger_nested', __('账务事务内禁止再次提交账务', 'aksrt'), ['status' => 500]);
}
$valid = $command->validate();
if (is_wp_error($valid)) {
return $valid;
}
$payload_hash = $command->payload_hash();
for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) {
$result = $this->attempt_post($command, $payload_hash, $guard, $business_mutation);
if (!is_wp_error($result) || $result->get_error_code() !== 'aksrt_ledger_retry') {
return $result;
}
usleep(random_int(10000, 50000));
}
return new \WP_Error('aksrt_ledger_db', __('账务处理失败,请稍后重试', 'aksrt'), ['status' => 500]);
}
private function attempt_post(LedgerCommand $command, string $payload_hash, ?callable $guard, ?callable $business_mutation) {
global $wpdb;
$account_table = $this->table('asset_account');
$transaction_table = $this->table('asset_transaction');
$entry_table = $this->table('asset_entry');
$created_at = self::utc_now();
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_ledger_db', __('无法开启账务事务', 'aksrt'), ['status' => 500]);
}
$this->transaction_active = true;
$this->after_commit_callbacks = [];
try {
$inserted = $wpdb->insert($transaction_table, [
'operation_key' => $command->operation_key,
'payload_hash' => $payload_hash,
'reason' => $command->reason,
'ref_type' => $command->ref_type !== '' ? sanitize_key($command->ref_type) : null,
'ref_key' => $command->ref_key !== '' ? substr(sanitize_text_field($command->ref_key), 0, 191) : null,
'actor_user_id' => max(0, $command->actor_user_id),
'metadata' => $command->metadata !== [] ? wp_json_encode($command->metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
'ip' => self::client_ip(),
'ua' => self::client_ua(),
'created_at' => $created_at,
], ['%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s']);
if ($inserted !== 1) {
$errno = $this->last_db_errno();
$wpdb->query('ROLLBACK');
$this->transaction_active = false;
$this->after_commit_callbacks = [];
$existing = $this->replay($command->operation_key, $payload_hash);
if ($existing !== null) {
return $existing;
}
return $this->db_error($errno);
}
$transaction_id = (int) $wpdb->insert_id;
$account_keys = [];
foreach ($command->entries as $entry) {
$key = (string) $entry['asset'] . ':' . (int) $entry['user_id'];
$account_keys[$key] = ['user_id' => (int) $entry['user_id'], 'asset' => (string) $entry['asset']];
}
$account_keys = array_values($account_keys);
usort($account_keys, static function (array $left, array $right): int {
$asset_order = strcmp((string) $left['asset'], (string) $right['asset']);
return $asset_order !== 0
? $asset_order
: ((int) $left['user_id'] <=> (int) $right['user_id']);
});
foreach ($account_keys as $account) {
$wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$account_table} (user_id, asset, balance_minor, version, updated_at) VALUES (%d, %s, 0, 0, %s)",
$account['user_id'],
$account['asset'],
$created_at
));
}
$balances = [];
foreach ($account_keys as $account) {
$minor = $wpdb->get_var($wpdb->prepare(
"SELECT balance_minor FROM {$account_table} WHERE user_id = %d AND asset = %s FOR UPDATE",
$account['user_id'],
$account['asset']
));
if ($minor === null) {
throw new \RuntimeException('asset_account_lock_failed');
}
$balances[$account['user_id']][$account['asset']] = (int) $minor;
}
if ($guard !== null) {
$guard_result = $guard($transaction_id, $balances);
if (is_wp_error($guard_result)) {
if ($this->is_retryable_errno($this->last_db_errno())) {
throw new \RuntimeException('ledger_guard_lock_failed');
}
throw new LedgerAbort($guard_result);
}
if ($guard_result !== true) {
throw new \RuntimeException('ledger_guard_failed');
}
}
$projected_balances = $balances;
$prepared_entries = [];
foreach ($command->entries as $index => $entry) {
$user_id = (int) $entry['user_id'];
$asset = (string) $entry['asset'];
$delta = (int) $entry['delta_minor'];
$before = (int) $projected_balances[$user_id][$asset];
if ($delta > 0 && $before > PHP_INT_MAX - $delta) {
throw new LedgerAbort(new \WP_Error('aksrt_ledger_overflow', __('资产余额超出可用范围', 'aksrt'), ['status' => 400]));
}
$after = $before + $delta;
if ($after < 0) {
$code = $asset === AssetType::POINTS->value ? 'aksrt_points_insufficient' : 'aksrt_balance_insufficient';
throw new LedgerAbort(new \WP_Error($code, $asset === AssetType::POINTS->value ? __('积分不足', 'aksrt') : __('余额不足', 'aksrt'), [
'status' => 402,
'balance_minor' => $before,
'required_minor' => abs($delta),
]));
}
$projected_balances[$user_id][$asset] = $after;
$prepared_entries[] = [
'transaction_id' => $transaction_id,
'line_no' => $index + 1,
'user_id' => $user_id,
'asset' => $asset,
'entry_type' => sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')),
'delta_minor' => $delta,
'balance_after_minor' => $after,
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)) ?: null,
'remark' => !empty($entry['remark']) ? substr(sanitize_text_field((string) $entry['remark']), 0, 255) : null,
'created_at' => $created_at,
];
}
foreach ($account_keys as $account) {
$user_id = (int) $account['user_id'];
$asset = (string) $account['asset'];
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$account_table} SET balance_minor = %d, version = version + 1, updated_at = %s WHERE user_id = %d AND asset = %s",
(int) $projected_balances[$user_id][$asset],
$created_at,
$user_id,
$asset
));
if ($updated !== 1) {
throw new \RuntimeException('asset_account_update_failed');
}
}
$written_entries = [];
foreach ($prepared_entries as $entry_row) {
if ($wpdb->insert($entry_table, $entry_row, ['%d', '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s']) !== 1) {
throw new \RuntimeException('asset_entry_insert_failed');
}
$written_entries[] = array_merge(['id' => (int) $wpdb->insert_id], $entry_row);
}
$result = new LedgerResult($transaction_id, false, $written_entries, $projected_balances);
if ($business_mutation !== null) {
$mutation_result = $business_mutation($transaction_id, $result);
if (is_wp_error($mutation_result)) {
if ($this->is_retryable_errno($this->last_db_errno())) {
throw new \RuntimeException('ledger_business_lock_failed');
}
throw new LedgerAbort($mutation_result);
}
if ($mutation_result !== true) {
throw new \RuntimeException('ledger_business_mutation_failed');
}
}
if ($wpdb->query('COMMIT') === false) {
throw new \RuntimeException('ledger_commit_failed');
}
$after_commit_callbacks = $this->after_commit_callbacks;
$this->after_commit_callbacks = [];
$this->transaction_active = false;
try {
do_action('aksrt_asset_transaction_committed', $result, $command);
} catch (\Throwable $event_error) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT Ledger Event] ' . $event_error->getMessage());
}
}
foreach ($after_commit_callbacks as $callback) {
try {
$callback();
} catch (\Throwable $event_error) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT Ledger After Commit] ' . $event_error->getMessage());
}
}
}
return $result;
} catch (LedgerAbort $e) {
$wpdb->query('ROLLBACK');
$this->clean_rolled_back_user_caches($command);
$this->transaction_active = false;
$this->after_commit_callbacks = [];
return $e->error;
} catch (\Throwable $e) {
$errno = $this->last_db_errno();
$wpdb->query('ROLLBACK');
$this->clean_rolled_back_user_caches($command);
$this->transaction_active = false;
$this->after_commit_callbacks = [];
return $this->db_error($errno, $e->getMessage());
}
}
private function replay(string $operation_key, string $payload_hash) {
global $wpdb;
$transaction_table = $this->table('asset_transaction');
$row = $wpdb->get_row($wpdb->prepare(
"SELECT id, payload_hash FROM {$transaction_table} WHERE operation_key = %s",
$operation_key
), ARRAY_A);
if (!is_array($row)) {
return null;
}
if (!hash_equals((string) $row['payload_hash'], $payload_hash)) {
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他账务请求', 'aksrt'), ['status' => 409]);
}
return $this->load_result((int) $row['id'], true);
}
private function load_result(int $transaction_id, bool $replayed): LedgerResult {
global $wpdb;
$entry_table = $this->table('asset_entry');
$entries = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$entry_table} WHERE transaction_id = %d ORDER BY line_no ASC",
$transaction_id
), ARRAY_A);
$balances = [];
foreach ((array) $entries as &$entry) {
foreach (['id', 'transaction_id', 'line_no', 'user_id', 'delta_minor', 'balance_after_minor', 'counterparty_user_id'] as $field) {
$entry[$field] = (int) ($entry[$field] ?? 0);
}
$balances[$entry['user_id']][(string) $entry['asset']] = $entry['balance_after_minor'];
}
unset($entry);
return new LedgerResult($transaction_id, $replayed, (array) $entries, $balances);
}
private function db_error(int $errno, string $detail = ''): \WP_Error {
if ($this->is_retryable_errno($errno)) {
return new \WP_Error('aksrt_ledger_retry', __('账务事务发生锁冲突', 'aksrt'), ['status' => 409]);
}
if (defined('WP_DEBUG') && WP_DEBUG && $detail !== '') {
error_log('[AKSRT Ledger] ' . $detail);
}
return new \WP_Error('aksrt_ledger_db', __('账务数据库写入失败', 'aksrt'), ['status' => 500]);
}
private function is_retryable_errno(int $errno): bool {
return in_array($errno, [1205, 1213], true);
}
private function clean_rolled_back_user_caches(LedgerCommand $command): void {
if (!function_exists('clean_user_cache')) {
return;
}
$user_ids = [];
foreach ($command->entries as $entry) {
$user_id = (int) ($entry['user_id'] ?? 0);
if ($user_id > 0) {
$user_ids[$user_id] = $user_id;
}
}
foreach ($user_ids as $user_id) {
clean_user_cache($user_id);
}
}
private function last_db_errno(): int {
global $wpdb;
if (isset($wpdb->dbh) && $wpdb->dbh instanceof \mysqli) {
return mysqli_errno($wpdb->dbh);
}
if (isset($wpdb->last_errno)) {
return (int) $wpdb->last_errno;
}
if (preg_match('/\\b(1205|1213)\\b/', (string) ($wpdb->last_error ?? ''), $matches)) {
return (int) $matches[1];
}
return 0;
}
/** @param array<string,mixed> $ref */
private function ref_type(array $ref): string {
return sanitize_key((string) ($ref['type'] ?? ''));
}
/** @param array<string,mixed> $ref */
private function ref_key(array $ref): string {
foreach (['key', 'order', 'id', 'ref_id'] as $field) {
if (isset($ref[$field]) && (string) $ref[$field] !== '') {
return (string) $ref[$field];
}
}
return '';
}
private function table(string $name): string {
return \AKSRT\Core\DBMigrator::table($name);
}
private static function utc_now(): string {
return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.u');
}
private static function client_ip(): ?string {
$ip = function_exists('aksrt_get_client_ip') ? (string) aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '');
$ip = substr(sanitize_text_field(wp_unslash($ip)), 0, 64);
return $ip !== '' ? $ip : null;
}
private static function client_ua(): ?string {
$ua = substr(sanitize_text_field(wp_unslash((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''))), 0, 255);
return $ua !== '' ? $ua : null;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Ledger;
defined('ABSPATH') || exit;
final class OperationKey {
/** @param array<int,string|int> $parts */
public static function deterministic(string $scope, array $parts): string {
$tokens = [self::token($scope)];
foreach ($parts as $part) {
$tokens[] = self::token((string) $part);
}
$key = implode(':', $tokens);
if (strlen($key) > 191) {
$key = self::token($scope) . ':' . hash('sha256', $key);
}
return $key;
}
/** @return string|\WP_Error */
public static function from_request(\WP_REST_Request $request, string $scope, int $actor_user_id) {
$request_id = trim((string) $request->get_header('Idempotency-Key'));
if (!wp_is_uuid($request_id)) {
return new \WP_Error('aksrt_idempotency_key', __('缺少或无效的 Idempotency-Key 请求头', 'aksrt'), ['status' => 400]);
}
return self::deterministic('client-' . $scope, [$actor_user_id, strtolower($request_id)]);
}
private static function token(string $value): string {
$value = strtolower(trim($value));
$value = (string) preg_replace('/[^a-z0-9._-]+/', '-', $value);
$value = trim($value, '-');
return $value !== '' ? $value : 'none';
}
}