积分与余额账务系统硬切重构
This commit is contained in:
1 parent
ca12226b39
commit
5cae5b5c93
159 files changed
+7275
-4718
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user