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

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

+193 -54
View File
@@ -10,7 +10,7 @@ defined('ABSPATH') || exit;
/**
* 站内余额支付网关(内置实现)。
*
* 仅扣现金钱包(\AKSRT\Credits\Balance / pay_balance_s),不再回退到积分。
* 仅扣统一资产账本中的现金余额。
*/
class BalanceGateway implements GatewayInterface {
@@ -24,8 +24,7 @@ class BalanceGateway implements GatewayInterface {
}
public function is_configured(): bool {
// Cash wallet only — do not treat points as 余额.
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
if (!$this->balance_enabled()) {
return false;
}
if (class_exists('\AKSRT\Settings\PaymentRuntime')) {
@@ -36,32 +35,85 @@ class BalanceGateway implements GatewayInterface {
public function create_payment(array $order): array {
$user_id = (int) ($order['user_id'] ?? 0);
$amount = round((float) ($order['amount'] ?? 0), 2);
$order_number = (string) ($order['order_number'] ?? '');
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
if (!$this->balance_enabled()) {
return ['success' => false, 'message' => __('余额功能未启用', 'aksrt')];
}
if ($user_id <= 0) {
return ['success' => false, 'message' => __('请先登录', 'aksrt')];
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('order');
$amount = $wpdb->get_var($wpdb->prepare(
"SELECT amount FROM {$table} WHERE order_number = %s",
$order_number
));
try {
$amount_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($amount) ? $amount : '')->minor();
} catch (\InvalidArgumentException $e) {
return ['success' => false, 'message' => __('支付金额无效', 'aksrt')];
}
$balance = \AKSRT\Credits\Balance::balance($user_id);
if ($balance < $amount) {
return ['success' => false, 'message' => __('余额不足', 'aksrt')];
}
$result = \AKSRT\Credits\Balance::deduct(
$transaction_code = 'BAL_' . $order_number;
$guard = static function () use ($order_number, $user_id, $amount_minor) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('order');
$current = $wpdb->get_row($wpdb->prepare(
"SELECT user_id, amount, status FROM {$table} WHERE order_number = %s FOR UPDATE",
$order_number
), ARRAY_A);
try {
$current_minor = is_array($current)
? \AKSRT\Ledger\AssetAmount::balance((string) ($current['amount'] ?? ''))->minor()
: -1;
} catch (\InvalidArgumentException $e) {
$current_minor = -1;
}
if (!is_array($current)
|| (int) ($current['user_id'] ?? 0) !== $user_id
|| $current_minor !== $amount_minor
|| !in_array((string) ($current['status'] ?? ''), [Order::STATUS_CREATED, Order::STATUS_PENDING], true)
) {
return new \WP_Error('aksrt_order_confirm_failed', __('订单状态或金额已变化', 'aksrt'), ['status' => 409]);
}
return true;
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->debit(
$user_id,
$amount,
\AKSRT\Ledger\AssetType::BALANCE,
$amount_minor,
\AKSRT\Ledger\OperationKey::deterministic('order-payment', [$order_number]),
'order_payment',
['type' => 'order', 'id' => 0, 'order' => $order_number],
sprintf(__('订单 %s 支付', 'aksrt'), $order_number)
['type' => 'order', 'key' => $order_number],
sprintf(__('订单 %s 支付', 'aksrt'), $order_number),
$user_id,
['order_number' => $order_number],
static function () use ($order_number, $transaction_code, $order) {
$paid = OrderRepository::get_instance()->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => 'balance',
'payment_transaction_id' => $transaction_code,
'note' => (string) ($order['_ledger_paid_note'] ?? __('余额支付成功', 'aksrt')),
'require_transition' => true,
]);
return $paid ? true : new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]);
},
$guard
);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message() ?: __('扣款失败', 'aksrt')];
$error_data = $result->get_error_data();
return [
'success' => false,
'code' => $result->get_error_code(),
'status' => is_array($error_data) ? (int) ($error_data['status'] ?? 500) : 500,
'message' => $result->get_error_message() ?: __('扣款失败', 'aksrt'),
];
}
return [
'success' => true,
'transaction_id' => 'BAL_' . ($order_number !== '' ? $order_number : (string) time()),
'transaction_id' => $transaction_code,
'ledger_transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'order_committed' => true,
'message' => __('余额支付成功', 'aksrt'),
];
}
@@ -86,7 +138,7 @@ class BalanceGateway implements GatewayInterface {
];
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
public function refund(string $order_number, string $amount, string $request_id = ''): array {
$order_repo = OrderRepository::get_instance();
$order = $order_repo->get($order_number);
@@ -95,53 +147,140 @@ class BalanceGateway implements GatewayInterface {
}
$user_id = (int) $order['user_id'];
$amount = round((float) $amount, 2);
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
if (!$this->balance_enabled()) {
return ['success' => false, 'message' => __('余额功能未启用', 'aksrt')];
}
$request_id = sanitize_text_field($request_id);
$refund_key = $request_id !== '' ? $request_id : 'order:' . $order_number;
$lock_key = 'aksrt_balance_refund_' . hash('sha256', $refund_key);
$existing = get_option($lock_key, []);
if (is_array($existing) && ($existing['status'] ?? '') === 'succeeded') {
return [
'success' => true,
'refund_id' => 'BAL_REF_' . $order_number,
'message' => __('退款已处理(幂等)', 'aksrt'),
];
}
if (is_array($existing) && ($existing['status'] ?? '') === 'pending') {
return ['success' => false, 'status' => 'pending', 'message' => __('退款正在处理中,请稍后查询', 'aksrt')];
}
$marker = ['status' => 'pending', 'token' => wp_generate_uuid4(), 'created_at' => time()];
if (!add_option($lock_key, $marker, '', false)) {
return ['success' => false, 'status' => 'pending', 'message' => __('退款正在处理中,请稍后查询', 'aksrt')];
}
try {
$result = \AKSRT\Credits\Balance::add(
$user_id,
$amount,
'order_refund',
['type' => 'order', 'order' => $order_number, 'request_id' => $request_id],
sprintf(__('订单 %s 退款', 'aksrt'), $order_number)
);
try {
$amount_minor = \AKSRT\Ledger\AssetAmount::balance($amount)->minor();
if ($amount_minor <= 0) {
return ['success' => false, 'message' => __('退款金额无效', 'aksrt')];
}
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('order-refund', [$request_id !== '' ? $request_id : $order_number]);
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $operation_key, 'page' => 1, 'per_page' => 1]);
if (!empty($history['items'][0])) {
$committed = $history['items'][0];
$refund_entry = null;
foreach ((array) ($committed['entries'] ?? []) as $entry) {
if ((int) ($entry['user_id'] ?? 0) === $user_id
&& (string) ($entry['asset'] ?? '') === \AKSRT\Ledger\AssetType::BALANCE->value
&& (string) ($entry['entry_type'] ?? '') === 'refund'
) {
$refund_entry = $entry;
break;
}
}
if ((string) ($committed['reason'] ?? '') !== 'order_refund'
|| (string) ($committed['ref_type'] ?? '') !== 'order'
|| (string) ($committed['ref_key'] ?? '') !== $order_number
|| !is_array($refund_entry)
|| (int) ($refund_entry['delta_minor'] ?? 0) !== $amount_minor
) {
return ['success' => false, 'message' => __('相同退款标识已用于其他请求', 'aksrt')];
}
return [
'success' => true,
'refund_id' => 'BAL_REF_' . $order_number,
'transaction_id' => (int) ($committed['id'] ?? 0),
'ledger_transaction_id' => (int) ($committed['id'] ?? 0),
'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('退款成功', 'aksrt'),
];
}
$commission = CommissionLedger::get_instance();
$commission_plan = $commission->prepare_full_refund($order_number);
if (is_wp_error($commission_plan)) {
return ['success' => false, 'message' => $commission_plan->get_error_message()];
}
$guard = static function () use ($order_number, $user_id, $amount_minor, $commission, $commission_plan) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('order');
$current = $wpdb->get_row($wpdb->prepare(
"SELECT user_id, amount, payment_method, status FROM {$table} WHERE order_number = %s FOR UPDATE",
$order_number
), ARRAY_A);
try {
$order_minor = is_array($current)
? \AKSRT\Ledger\AssetAmount::balance((string) ($current['amount'] ?? ''))->minor()
: -1;
} catch (\InvalidArgumentException $e) {
$order_minor = -1;
}
if (!is_array($current)
|| (int) ($current['user_id'] ?? 0) !== $user_id
|| sanitize_key((string) ($current['payment_method'] ?? '')) !== 'balance'
|| $order_minor !== $amount_minor
|| !in_array((string) ($current['status'] ?? ''), [Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_COMPLETED, Order::STATUS_REFUNDING], true)
) {
return new \WP_Error('aksrt_order_refund_conflict', __('退款订单状态或金额已变化', 'aksrt'), ['status' => 409]);
}
return $commission->guard_full_refund($commission_plan);
};
$mutation = static function () use ($order_number, $commission, $commission_plan) {
$repo = OrderRepository::get_instance();
$current = $repo->get($order_number);
if (!is_array($current)) {
return new \WP_Error('aksrt_order_refund_failed', __('退款订单不存在', 'aksrt'), ['status' => 404]);
}
if ((string) ($current['status'] ?? '') !== Order::STATUS_REFUNDING
&& !$repo->update_status($order_number, Order::STATUS_REFUNDING, ['note' => __('余额退款处理中', 'aksrt')])) {
return new \WP_Error('aksrt_order_refund_failed', __('退款状态更新失败', 'aksrt'), ['status' => 409]);
}
if (!$repo->update_status($order_number, Order::STATUS_REFUNDED, ['note' => __('余额退款已完成', 'aksrt')])) {
return new \WP_Error('aksrt_order_refund_failed', __('退款状态更新失败', 'aksrt'), ['status' => 409]);
}
return $commission->apply_full_refund($commission_plan);
};
$entries = [[
'user_id' => $user_id,
'asset' => \AKSRT\Ledger\AssetType::BALANCE->value,
'delta_minor' => $amount_minor,
'entry_type' => 'refund',
'remark' => sprintf(__('订单 %s 退款', 'aksrt'), $order_number),
]];
if (!empty($commission_plan['active']) && (string) ($commission_plan['status'] ?? '') === 'settled') {
$entries[] = [
'user_id' => (int) $commission_plan['promoter_id'],
'asset' => \AKSRT\Ledger\AssetType::BALANCE->value,
'delta_minor' => -(int) $commission_plan['amount_minor'],
'entry_type' => 'commission_reversal',
'counterparty_user_id' => $user_id,
'remark' => sprintf(__('订单 %s 返佣退款冲正', 'aksrt'), $order_number),
];
}
$result = \AKSRT\Ledger\LedgerService::get_instance()->post(new \AKSRT\Ledger\LedgerCommand(
$operation_key,
'order_refund',
$entries,
'order',
$order_number,
0,
['request_id' => $request_id, 'commission_id' => (int) ($commission_plan['id'] ?? 0)]
), $guard, $mutation);
if (is_wp_error($result)) {
delete_option($lock_key);
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
}
update_option($lock_key, [
'status' => 'succeeded',
'refund_id' => 'BAL_REF_' . $order_number,
'completed_at' => time(),
], false);
return ['success' => true, 'refund_id' => 'BAL_REF_' . $order_number, 'message' => __('退款成功', 'aksrt')];
return [
'success' => true,
'refund_id' => 'BAL_REF_' . $order_number,
'transaction_id' => $result->transaction_id,
'ledger_transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('退款成功', 'aksrt'),
];
} catch (\Throwable $e) {
delete_option($lock_key);
return ['success' => false, 'message' => $e->getMessage()];
}
}
private function balance_enabled(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) {
return false;
}
$settings = \AKSRT\Settings\Settings::get_payment_recharge_settings();
return !empty($settings['enabled']);
}
}
+372 -199
View File
@@ -45,6 +45,11 @@ class ChargeCard {
'methods' => 'POST',
'callback' => [$this, 'rest_redeem'],
'permission_callback' => [$this, 'check_logged_in_rest'],
'args' => [
'password' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 80, 'sanitize_callback' => 'sanitize_text_field'],
'card' => ['type' => 'string', 'default' => '', 'maxLength' => 60, 'sanitize_callback' => 'sanitize_text_field'],
'type' => ['type' => 'string', 'default' => '', 'enum' => ['', self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS]],
],
]);
}
@@ -90,14 +95,19 @@ class ChargeCard {
if (!is_array($params)) {
$params = $request->get_params();
}
$password = sanitize_text_field((string) ($params['password'] ?? $params['code'] ?? ''));
$password = sanitize_text_field((string) ($params['password'] ?? ''));
$card = sanitize_text_field((string) ($params['card'] ?? ''));
$type = sanitize_key((string) ($params['type'] ?? ''));
if ($type !== '' && !in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
$type = '';
}
$result = $this->redeem_for_user(get_current_user_id(), $password, $card, $type);
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'card-redeem', get_current_user_id());
if (is_wp_error($operation_key)) {
return $operation_key;
}
$result = $this->redeem_for_user(get_current_user_id(), $password, $card, $type, $operation_key);
if (is_wp_error($result)) {
return $result;
}
@@ -159,18 +169,17 @@ class ChargeCard {
}
}
// Points card-pass: legacy points_pass_exchange_* (stored in aksrt_settings).
$credits = ['enabled' => false, 'desc' => '', 'only_password' => true];
$all = class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_all')
? \AKSRT\Settings\Settings::get_all()
: (array) get_option('aksrt_settings', []);
if (!empty($all['points_pass_exchange_s'])) {
if (!empty($all['asset_points_card_enabled'])) {
// Require credits system on when available.
$credits_on = !class_exists('\AKSRT\Settings\Settings') || !method_exists('\AKSRT\Settings\Settings', 'credits_enabled') || \AKSRT\Settings\Settings::credits_enabled();
$credits = [
'enabled' => $credits_on,
'desc' => sanitize_textarea_field((string) ($all['points_pass_exchange_desc'] ?? '')),
'only_password' => !isset($all['points_pass_exchange_only_password']) || !empty($all['points_pass_exchange_only_password']),
'desc' => sanitize_textarea_field((string) ($all['asset_points_card_description'] ?? '')),
'only_password' => !isset($all['asset_points_card_password_only']) || !empty($all['asset_points_card_password_only']),
];
}
@@ -186,7 +195,7 @@ class ChargeCard {
* Create one card.
*
* Accepted $data keys:
* - password|code (required unless auto)
* - password (required)
* - card (optional)
* - type balance|vip|credits
* - amount (balance)
@@ -203,7 +212,7 @@ class ChargeCard {
$type = self::TYPE_BALANCE;
}
$password = sanitize_text_field((string) ($data['password'] ?? $data['code'] ?? ''));
$password = sanitize_text_field((string) ($data['password'] ?? ''));
if ($password === '') {
return 0;
}
@@ -225,7 +234,7 @@ class ChargeCard {
$expires_at = sanitize_text_field((string) ($data['expires_at'] ?? ''));
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
$amount_col = (float) ($meta['amount'] ?? $meta['points'] ?? 0);
$amount_col = (string) ($meta['amount'] ?? $meta['points'] ?? '0');
$result = $wpdb->insert($this->table(), [
'card' => $card,
@@ -276,9 +285,9 @@ class ChargeCard {
}
/**
* @return array{success:bool,message:string,type?:string,amount?:float,level?:int,days?:int,order_number?:string,balance?:int}|\WP_Error
* @return array{success:bool,message:string,type?:string,amount?:string|int,level?:int,days?:int,order_number?:string,balance?:string|int}|\WP_Error
*/
public function redeem_for_user(int $user_id, string $password, string $card = '', string $expected_type = '') {
public function redeem_for_user(int $user_id, string $password, string $card = '', string $expected_type = '', string $operation_key = '') {
$user_id = max(0, $user_id);
$password = sanitize_text_field($password);
$card = sanitize_text_field($card);
@@ -328,7 +337,7 @@ class ChargeCard {
return new \WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]);
}
if (!empty($row['expires_at']) && strtotime((string) $row['expires_at']) < time()) {
if ((string) ($row['status'] ?? '') === self::STATUS_ACTIVE && !empty($row['expires_at']) && strtotime((string) $row['expires_at']) < time()) {
global $wpdb;
$wpdb->update($this->table(), ['status' => self::STATUS_EXPIRED], ['id' => (int) $row['id']], ['%s'], ['%d']);
return new \WP_Error('aksrt_card_expired', __('卡密已过期', 'aksrt'), ['status' => 400]);
@@ -337,47 +346,16 @@ class ChargeCard {
$meta = $this->decode_meta($row);
$card_id = (int) $row['id'];
// Create audit order first (pending), then CAS mark used, then fulfill + mark paid.
$order_number = $this->create_redeem_order($user_id, $type, $meta, $card_id, $password);
if ($order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
if (!$this->mark_used_cas($card_id, $user_id, $order_number)) {
// Race lost.
if (class_exists('AKSRT\\Payment\\Order')) {
Order::get_instance()->update_status($order_number, Order::STATUS_CANCELLED, [
'note' => __('卡密已被使用,订单取消', 'aksrt'),
]);
}
return new \WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 400]);
}
$fulfill = $this->fulfill($user_id, $type, $meta, $order_number, $card_id);
if (is_wp_error($fulfill)) {
return $fulfill;
}
if (class_exists('AKSRT\\Payment\\Order')) {
Order::get_instance()->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => 'card_pass',
'payment_transaction_id' => 'card_pass_' . $card_id,
'note' => (string) ($fulfill['message'] ?? __('卡密兑换成功', 'aksrt')),
]);
}
$out = array_merge([
'success' => true,
'type' => $type,
'order_number' => $order_number,
'reload' => true,
], $fulfill);
if (class_exists('\AKSRT\Credits\Credits')) {
$out['balance'] = \AKSRT\Credits\Credits::balance($user_id);
}
return $out;
if (in_array($type, [self::TYPE_BALANCE, self::TYPE_CREDITS], true)) {
if ($operation_key === '') {
return new \WP_Error('aksrt_idempotency_key', __('缺少操作标识', 'aksrt'), ['status' => 400]);
}
return $this->redeem_asset_card($user_id, $row, $meta, $card, $operation_key);
}
if ($operation_key === '') {
return new \WP_Error('aksrt_idempotency_key', __('缺少操作标识', 'aksrt'), ['status' => 400]);
}
return $this->redeem_vip_card($user_id, $row, $meta, $card, $operation_key);
}
/**
@@ -401,139 +379,342 @@ class ChargeCard {
if (!$row) {
return null;
}
if ((string) ($row['status'] ?? '') !== self::STATUS_ACTIVE) {
return null;
}
return $row;
}
public function mark_used_cas(int $card_id, int $user_id, string $order_number): bool {
global $wpdb;
if ($card_id <= 0) {
return false;
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, used_by = %d, used_at = %s, order_number = %s, balance = 0
WHERE id = %d AND status = %s",
self::STATUS_USED,
$user_id,
current_time('mysql'),
$order_number,
$card_id,
self::STATUS_ACTIVE
));
return $updated === 1;
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed> $meta
* @return array<string,mixed>|\WP_Error
*/
private function redeem_asset_card(int $user_id, array $row, array $meta, string $submitted_card, string $operation_key) {
$type = sanitize_key((string) ($row['type'] ?? ''));
$card_id = (int) ($row['id'] ?? 0);
$asset = $type === self::TYPE_BALANCE ? \AKSRT\Ledger\AssetType::BALANCE : \AKSRT\Ledger\AssetType::POINTS;
if ($asset === \AKSRT\Ledger\AssetType::BALANCE) {
try {
$amount_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($meta['amount'] ?? null) ? $meta['amount'] : '')->minor();
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_card_amount', __('当前卡密充值金额无效', 'aksrt'), ['status' => 400]);
}
$reason = 'card_pass_balance';
$remark = sprintf(__('卡密充值 %s 元', 'aksrt'), \AKSRT\Ledger\AssetAmount::from_minor($asset, $amount_minor)->decimal());
} else {
$amount_minor = (int) ($meta['points'] ?? 0);
$reason = 'card_pass_credits';
$remark = sprintf(__('卡密兑换积分 %d', 'aksrt'), $amount_minor);
}
if ($card_id <= 0 || $amount_minor <= 0) {
return new \WP_Error('aksrt_card_amount', __('当前卡密可兑换资产为 0', 'aksrt'), ['status' => 400]);
}
$order_number = 'CARD' . $card_id;
$guard = function () use ($card_id, $user_id, $submitted_card, $type) {
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE", $card_id), ARRAY_A);
if (!is_array($current)
|| (string) ($current['status'] ?? '') !== self::STATUS_ACTIVE
|| sanitize_key((string) ($current['type'] ?? '')) !== $type
) {
return new \WP_Error('aksrt_card_used', __('该卡密已使用或不可用', 'aksrt'), ['status' => 409]);
}
if ((string) ($current['card'] ?? '') !== '' && $submitted_card !== '' && !hash_equals((string) $current['card'], $submitted_card)) {
return new \WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]);
}
if (!empty($current['expires_at']) && strtotime((string) $current['expires_at']) < time()) {
return new \WP_Error('aksrt_card_expired', __('卡密已过期', 'aksrt'), ['status' => 400]);
}
if ((int) ($current['used_by'] ?? 0) > 0 && (int) $current['used_by'] !== $user_id) {
return new \WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 409]);
}
return true;
};
$mutation = function () use ($user_id, $type, $meta, $card_id, $order_number, $operation_key) {
global $wpdb;
$created = $this->create_redeem_order($user_id, $type, $meta, $card_id, '', $operation_key, $order_number, Order::STATUS_PAID);
if ($created !== $order_number) {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
$used = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, used_by = %d, used_at = %s, order_number = %s, balance = 0 WHERE id = %d AND status = %s",
self::STATUS_USED,
$user_id,
current_time('mysql'),
$order_number,
$card_id,
self::STATUS_ACTIVE
));
return $used === 1 ? true : new \WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 409]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
$asset,
$amount_minor,
$operation_key,
$reason,
['type' => 'charge_card', 'id' => $card_id],
$remark,
$user_id,
['card_type' => $type],
$mutation,
$guard
);
if (is_wp_error($result)) {
return $result;
}
$balance = $result->balance($user_id, $asset);
return [
'success' => true,
'type' => $type,
'order_number' => $order_number,
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'message' => $asset === \AKSRT\Ledger\AssetType::BALANCE
? sprintf(__('卡密充值成功,充值金额:%s 元', 'aksrt'), \AKSRT\Ledger\AssetAmount::from_minor($asset, $amount_minor)->decimal())
: sprintf(__('卡密兑换成功,积分:%d', 'aksrt'), $amount_minor),
'amount' => $asset === \AKSRT\Ledger\AssetType::BALANCE
? \AKSRT\Ledger\AssetAmount::from_minor($asset, $amount_minor)->decimal()
: $amount_minor,
'balance' => $asset === \AKSRT\Ledger\AssetType::BALANCE
? \AKSRT\Ledger\AssetAmount::from_minor($asset, $balance)->decimal()
: $balance,
'reload' => true,
];
}
/**
* @param array<string,mixed> $meta
* @return array{message:string,amount?:float,level?:int,days?:int}|\WP_Error
*/
private function fulfill(int $user_id, string $type, array $meta, string $order_number, int $card_id) {
if ($type === self::TYPE_BALANCE) {
$amount = round((float) ($meta['amount'] ?? 0), 2);
if ($amount <= 0) {
return new \WP_Error('aksrt_card_amount', __('当前卡密可充值金额为0', 'aksrt'), ['status' => 400]);
}
// Cash wallet first (legacy pay_balance); fall back to points if wallet class missing.
if (class_exists('\AKSRT\Credits\Balance')) {
$new = \AKSRT\Credits\Balance::add($user_id, $amount, 'card_pass_balance', [
'type' => 'charge_card',
'id' => $card_id,
'order' => $order_number,
], sprintf(__('卡密充值 %s 元', 'aksrt'), (string) $amount));
if (is_wp_error($new)) {
return new \WP_Error('aksrt_balance_fail', $new->get_error_message() ?: __('余额入账失败', 'aksrt'), ['status' => 500]);
}
return [
'message' => sprintf(__('卡密充值成功,充值金额:%s 元', 'aksrt'), (string) $amount),
'amount' => (float) $amount,
];
}
if (!class_exists('\AKSRT\Credits\Credits')) {
return new \WP_Error('aksrt_card_amount', __('余额系统不可用', 'aksrt'), ['status' => 500]);
}
$pts = (int) round($amount);
$new = \AKSRT\Credits\Credits::add($user_id, $pts, 'card_pass_balance', [
'ref_type' => 'charge_card',
'ref_id' => $card_id,
'order' => $order_number,
], sprintf(__('卡密充值 %d', 'aksrt'), $pts));
if ($new === null || $new === false || is_wp_error($new)) {
return new \WP_Error('aksrt_credit_fail', __('余额入账失败', 'aksrt'), ['status' => 500]);
}
return [
'message' => sprintf(__('卡密充值成功,充值金额:%s', 'aksrt'), (string) $pts),
'amount' => (float) $pts,
];
}
/**
* Redeem a VIP card with the card row, order row, and membership metadata in one transaction.
*
* @param array<string,mixed> $row
* @param array<string,mixed> $meta
* @return array<string,mixed>|\WP_Error
*/
private function redeem_vip_card(int $user_id, array $row, array $meta, string $submitted_card, string $operation_key) {
global $wpdb;
if (!class_exists('\AKSRT\Content\VipService') || !class_exists(OrderRepository::class)) {
return new \WP_Error('aksrt_vip_unavailable', __('会员系统不可用', 'aksrt'), ['status' => 500]);
}
$card_id = (int) ($row['id'] ?? 0);
$level = max(1, min(2, (int) ($meta['level'] ?? 1)));
$days = max(0, (int) ($meta['days'] ?? 0));
$order_number = 'CARD' . $card_id;
$order_table = \AKSRT\Core\DBMigrator::table('order');
if ($card_id <= 0 || sanitize_key((string) ($row['type'] ?? '')) !== self::TYPE_VIP) {
return new \WP_Error('aksrt_card_type', __('当前卡密不是会员卡', 'aksrt'), ['status' => 400]);
}
if ($type === self::TYPE_VIP) {
$level = max(1, min(2, (int) ($meta['level'] ?? 1)));
$days = max(0, (int) ($meta['days'] ?? 0));
if (!class_exists('\AKSRT\Content\VipService')) {
return new \WP_Error('aksrt_vip_unavailable', __('会员系统不可用', 'aksrt'), ['status' => 500]);
}
\AKSRT\Content\VipService::get_instance()->activate_vip($user_id, $level, $days);
$msg = $days > 0
? sprintf(
/* translators: 1: level 2: days */
__('卡密兑换成功:VIP%s %d 天', 'aksrt'),
(string) $level,
$days
)
: sprintf(
/* translators: %s: level */
__('卡密兑换成功:VIP%s 永久', 'aksrt'),
(string) $level
);
return [
'message' => $msg,
'level' => $level,
'days' => $days,
];
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_card_db', __('无法开启卡密兑换事务', 'aksrt'), ['status' => 500]);
}
try {
$current = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE",
$card_id
), ARRAY_A);
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$order_table} WHERE request_key = %s FOR UPDATE",
$operation_key
), ARRAY_A);
if (is_array($existing)) {
$replay = $this->vip_card_replay_result($existing, $current, $user_id, $card_id, $level, $days, $operation_key);
if (is_wp_error($replay)) {
throw new \AKSRT\Ledger\LedgerAbort($replay);
}
if ($wpdb->query('COMMIT') === false) {
throw new \RuntimeException('vip_card_replay_commit_failed');
}
return $replay;
}
if ($type === self::TYPE_CREDITS) {
$points = (int) ($meta['points'] ?? 0);
if ($points <= 0 || !class_exists('\AKSRT\Credits\Credits')) {
return new \WP_Error('aksrt_card_points', __('当前卡密可兑换积分为0', 'aksrt'), ['status' => 400]);
}
\AKSRT\Credits\Credits::add($user_id, $points, 'card_pass_credits', [
'ref_type' => 'charge_card',
'ref_id' => $card_id,
'order' => $order_number,
], sprintf(__('卡密兑换积分 %d', 'aksrt'), $points));
return [
'message' => sprintf(__('卡密兑换成功,积分:%d', 'aksrt'), $points),
'amount' => (float) $points,
];
}
if (!is_array($current)
|| (string) ($current['status'] ?? '') !== self::STATUS_ACTIVE
|| sanitize_key((string) ($current['type'] ?? '')) !== self::TYPE_VIP
|| !hash_equals((string) ($row['password'] ?? ''), (string) ($current['password'] ?? ''))
) {
throw new \AKSRT\Ledger\LedgerAbort(new \WP_Error('aksrt_card_used', __('该卡密已使用或不可用', 'aksrt'), ['status' => 409]));
}
if ((string) ($current['card'] ?? '') !== '' && $submitted_card !== '' && !hash_equals((string) $current['card'], $submitted_card)) {
throw new \AKSRT\Ledger\LedgerAbort(new \WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]));
}
if (!empty($current['expires_at']) && strtotime((string) $current['expires_at']) < time()) {
throw new \AKSRT\Ledger\LedgerAbort(new \WP_Error('aksrt_card_expired', __('卡密已过期', 'aksrt'), ['status' => 400]));
}
return new \WP_Error('aksrt_card_type', __('不支持的卡密类型', 'aksrt'), ['status' => 400]);
}
$vip_meta_keys = ['aksrt_vip_level', 'aksrt_vip_expires', 'aksrt_vip_base_level', 'aksrt_vip_base_expires', 'aksrt_vip_level_expired'];
$placeholders = implode(',', array_fill(0, count($vip_meta_keys), '%s'));
$wpdb->get_results($wpdb->prepare(
"SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key IN ({$placeholders}) FOR UPDATE",
array_merge([$user_id], $vip_meta_keys)
));
$current_level = max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true));
$current_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
$activation_base = max($current_expiry, time());
$created = $this->create_redeem_order(
$user_id,
self::TYPE_VIP,
['level' => $level, 'days' => $days],
$card_id,
(string) ($current['password'] ?? ''),
$operation_key,
$order_number,
Order::STATUS_PAID
);
if ($created !== $order_number) {
throw new \RuntimeException('vip_card_order_create_failed');
}
\AKSRT\Content\VipService::get_instance()->activate_vip($user_id, $level, $days);
$actual_level = max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true));
$actual_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
$permanent_preserved = $days > 0 && $current_level > 0 && $level <= $current_level && $current_expiry === 0;
$expiry_valid = $days <= 0
? $actual_expiry === 0
: ($permanent_preserved ? $actual_expiry === 0 : $actual_expiry >= $activation_base + ($days * DAY_IN_SECONDS));
if ($actual_level < max($current_level, $level) || !$expiry_valid) {
throw new \RuntimeException('vip_card_fulfillment_failed');
}
$used = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, used_by = %d, used_at = %s, order_number = %s, balance = 0 WHERE id = %d AND status = %s",
self::STATUS_USED,
$user_id,
current_time('mysql'),
$order_number,
$card_id,
self::STATUS_ACTIVE
));
if ($used !== 1) {
throw new \AKSRT\Ledger\LedgerAbort(new \WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 409]));
}
if ($wpdb->query('COMMIT') === false) {
throw new \RuntimeException('vip_card_commit_failed');
}
$message = $this->vip_card_message($level, $days);
$paid_order = OrderRepository::get_instance()->get($order_number);
if (is_array($paid_order)) {
try {
do_action('aksrt_order_status_changed', $order_number, Order::STATUS_PAID, $paid_order);
do_action('aksrt_order_paid', $order_number, $paid_order);
} catch (\Throwable $event_error) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT VIP Card Event] ' . $event_error->getMessage());
}
}
}
return [
'success' => true,
'type' => self::TYPE_VIP,
'order_number' => $order_number,
'transaction_id' => 0,
'replayed' => false,
'message' => $message,
'level' => $level,
'days' => $days,
'reload' => true,
];
} catch (\AKSRT\Ledger\LedgerAbort $e) {
$wpdb->query('ROLLBACK');
clean_user_cache($user_id);
return $e->error;
} catch (\Throwable $e) {
$errno = $this->last_db_errno();
$wpdb->query('ROLLBACK');
clean_user_cache($user_id);
$replay_order = OrderRepository::get_instance()->get_by_request_key($operation_key);
if (is_array($replay_order)) {
$replay_card = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $card_id), ARRAY_A);
return $this->vip_card_replay_result($replay_order, $replay_card, $user_id, $card_id, $level, $days, $operation_key);
}
if (in_array($errno, [1205, 1213], true) && $attempt < 3) {
usleep(random_int(10000, 50000));
continue;
}
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT VIP Card] ' . $e->getMessage());
}
return new \WP_Error('aksrt_card_db', __('卡密兑换失败,请稍后重试', 'aksrt'), ['status' => 500]);
}
}
return new \WP_Error('aksrt_card_db', __('卡密兑换失败,请稍后重试', 'aksrt'), ['status' => 500]);
}
/** @return array<string,mixed>|\WP_Error */
private function vip_card_replay_result($order, $card, int $user_id, int $card_id, int $level, int $days, string $operation_key) {
$order = is_array($order) ? $order : [];
$card = is_array($card) ? $card : [];
$note = json_decode((string) ($order['note'] ?? ''), true);
$note = is_array($note) ? $note : [];
$stored_meta = is_array($note['meta'] ?? null) ? $note['meta'] : [];
$same = (string) ($order['request_key'] ?? '') === $operation_key
&& (int) ($order['user_id'] ?? 0) === $user_id
&& sanitize_key((string) ($order['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP
&& (int) ($order['product_id'] ?? 0) === $card_id
&& sanitize_key((string) ($order['payment_method'] ?? '')) === 'card_pass'
&& in_array((string) ($order['status'] ?? ''), [Order::STATUS_PAID, Order::STATUS_COMPLETED], true)
&& (int) ($note['card_id'] ?? 0) === $card_id
&& sanitize_key((string) ($note['card_type'] ?? '')) === self::TYPE_VIP
&& (int) ($stored_meta['level'] ?? 0) === $level
&& (int) ($stored_meta['days'] ?? -1) === $days
&& (string) ($card['status'] ?? '') === self::STATUS_USED
&& (int) ($card['used_by'] ?? 0) === $user_id
&& (string) ($card['order_number'] ?? '') === (string) ($order['order_number'] ?? '');
if (!$same) {
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他卡密兑换请求', 'aksrt'), ['status' => 409]);
}
return [
'success' => true,
'type' => self::TYPE_VIP,
'order_number' => (string) $order['order_number'],
'transaction_id' => 0,
'replayed' => true,
'message' => $this->vip_card_message($level, $days),
'level' => $level,
'days' => $days,
'reload' => true,
];
}
private function vip_card_message(int $level, int $days): string {
return $days > 0
? sprintf(__('卡密兑换成功:VIP%s %d 天', 'aksrt'), (string) $level, $days)
: sprintf(__('卡密兑换成功:VIP%s 永久', 'aksrt'), (string) $level);
}
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;
}
return preg_match('/\\b(1205|1213)\\b/', (string) ($wpdb->last_error ?? ''), $matches) ? (int) $matches[1] : 0;
}
/**
* @param array<string,mixed> $meta
*/
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password): string {
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password, string $request_key = '', string $order_number = '', string $status = ''): string {
if (!class_exists('AKSRT\\Payment\\OrderRepository') || !class_exists('\AKSRT\Core\DomainModel')) {
return 'CARD' . $card_id . '_' . time();
return '';
}
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE;
$product_id = $card_id;
$amount = 0.0;
$amount = '0.00';
$currency = 'CNY';
if ($type === self::TYPE_VIP) {
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP;
$amount = 0.0;
$amount = '0.00';
} elseif ($type === self::TYPE_BALANCE) {
$amount = round((float) ($meta['amount'] ?? 0), 2);
$parsed_amount = \AKSRT\Ledger\AssetAmount::balance(is_string($meta['amount'] ?? null) ? $meta['amount'] : '');
$amount = $parsed_amount->decimal();
$currency = 'CNY';
} elseif ($type === self::TYPE_CREDITS) {
$amount = (float) ($meta['points'] ?? 0);
$amount = (int) ($meta['points'] ?? 0);
$currency = 'points';
}
@@ -545,17 +726,25 @@ class ChargeCard {
'password' => substr(md5($password), 0, 8),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return OrderRepository::get_instance()->create([
$order_status = $status !== '' ? $status : Order::STATUS_PENDING;
$order_data = [
'order_number' => $order_number !== '' ? $order_number : null,
'user_id' => $user_id,
'product_type' => $product_type,
'product_id' => $product_id,
'amount' => $amount,
'currency' => $currency,
'status' => class_exists('AKSRT\\Payment\\Order') ? Order::STATUS_PENDING : 'pending',
'status' => $order_status,
'payment_method' => 'card_pass',
'payment_transaction_id' => $order_status === Order::STATUS_PAID ? 'card_pass_' . $card_id : '',
'paid_at' => $order_status === Order::STATUS_PAID ? current_time('mysql') : null,
'note' => is_string($note) ? $note : '',
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
]);
'request_key' => $request_key,
];
if ($order_status === Order::STATUS_PENDING) {
$order_data['expires_at'] = \AKSRT\Settings\PaymentRuntime::get_order_expiry_at();
}
return OrderRepository::get_instance()->create($order_data);
}
/**
@@ -587,19 +776,27 @@ class ChargeCard {
}
if ($type === self::TYPE_BALANCE) {
$amount = round((float) ($raw['amount'] ?? $data['amount'] ?? 0), 2);
if ($amount <= 0) {
$value = $raw['amount'] ?? $data['amount'] ?? '';
if (!is_string($value)) {
return null;
}
try {
$amount = \AKSRT\Ledger\AssetAmount::balance($value);
} catch (\InvalidArgumentException $e) {
return null;
}
if ($amount->minor() <= 0) {
return null;
}
return ['amount' => $amount];
return ['amount' => $amount->decimal()];
}
if ($type === self::TYPE_VIP) {
$level = max(1, min(2, (int) ($raw['level'] ?? $data['level'] ?? 1)));
$days = max(0, (int) ($raw['days'] ?? $data['days'] ?? $data['vip_days'] ?? 0));
$days = max(0, (int) ($raw['days'] ?? $data['days'] ?? 0));
return ['level' => $level, 'days' => $days];
}
if ($type === self::TYPE_CREDITS) {
$points = max(0, (int) ($raw['points'] ?? $data['points'] ?? $data['credits'] ?? 0));
$points = max(0, (int) ($raw['points'] ?? $data['points'] ?? 0));
if ($points <= 0) {
return null;
}
@@ -624,15 +821,11 @@ class ChargeCard {
}
}
}
// Legacy amount-only rows.
if ($meta === [] && isset($row['amount'])) {
$meta = ['amount' => (float) $row['amount']];
}
return $meta;
}
/**
* Admin list helper (supports new + legacy columns).
* Admin list helper.
*
* @return array{items:array,total:int,page:int,max_pages:int}
*/
@@ -652,9 +845,6 @@ class ChargeCard {
$params[] = $status;
}
if ($type !== '') {
if ($type === 'points') {
$type = self::TYPE_CREDITS;
}
$where[] = 'type = %s';
$params[] = $type;
}
@@ -715,25 +905,8 @@ class ChargeCard {
'total' => (int) ($row['total'] ?? 0),
'active' => (int) ($row['active'] ?? 0),
'used' => (int) ($row['used'] ?? 0),
'total_amount' => (float) ($row['total_amount'] ?? 0),
'used_amount' => (float) ($row['used_amount'] ?? 0),
];
}
/**
* Legacy alias used by older admin code.
*
* @return array{success:bool,message:string,amount?:float}
*/
public function redeem(string $code, int $user_id): array {
$result = $this->redeem_for_user($user_id, $code, '', self::TYPE_BALANCE);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message()];
}
return [
'success' => true,
'message' => (string) ($result['message'] ?? __('兑换成功', 'aksrt')),
'amount' => (float) ($result['amount'] ?? 0),
'total_amount' => (string) ($row['total_amount'] ?? '0.00'),
'used_amount' => (string) ($row['used_amount'] ?? '0.00'),
];
}
}
+347 -78
View File
@@ -6,13 +6,9 @@ defined('ABSPATH') || exit;
final class CommissionLedger {
private static ?self $instance = null;
private const DEBT_META = 'aksrt_affiliate_debt';
public static function get_instance(): self { return self::$instance ??= new self(); }
private function __construct() {}
public function init(): void {
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2);
add_action('aksrt_order_status_changed', [$this, 'on_order_status_changed'], 20, 3);
add_action('aksrt_points_exchange_completed', [$this, 'on_points_exchange_completed'], 20, 4);
add_action('aksrt_affiliate_settle_due', [$this, 'settle_due']);
add_action('rest_api_init', [$this, 'register_routes']);
if (!wp_next_scheduled('aksrt_affiliate_settle_due')) {
@@ -20,20 +16,29 @@ final class CommissionLedger {
}
}
private function table(string $name): string { return \AKSRT\Core\DBMigrator::table($name); }
public function on_order_paid(string $order_number, array $order = []): void { $this->record_paid_order(array_merge($order, ['order_number' => $order_number])); }
public function on_order_status_changed(string $order_number, string $status, array $order = []): void {
if (in_array($status, ['refunding', 'refunded'], true)) { $this->reverse($order_number, (float) ($order['amount'] ?? 0), 'refund'); }
}
public function on_points_exchange_completed(int $user_id, float $amount, int $points, string $exchange_ref): void {
$this->record_paid_order([
'order_number' => 'points-exchange-' . sanitize_key($exchange_ref),
'product_type' => 'credits',
'user_id' => $user_id,
'amount' => $amount,
'exchange_ref' => $exchange_ref,
'points' => $points,
]);
}
public function record_order_transition(string $order_number, string $status, array $order = []) {
if ($status !== Order::STATUS_PAID) {
return true;
}
$result = $this->record_paid_order(array_merge($order, [
'order_number' => $order_number,
'status' => $status,
]));
return $result >= 0
? true
: new \WP_Error('aksrt_commission_write_failed', __('返佣记录写入失败', 'aksrt'), ['status' => 500]);
}
public function record_points_exchange(int $user_id, string $amount, int $points, string $operation_key): bool {
$result = $this->record_paid_order([
'order_number' => 'PX' . substr(hash('sha256', $operation_key), 0, 30),
'product_type' => 'credits',
'user_id' => $user_id,
'amount' => $amount,
'exchange_ref' => $operation_key,
'points' => $points,
]);
return $result >= 0;
}
public function record_paid_order(array $order): int {
if (!\AKSRT\Settings\Settings::affiliate_enabled()) return 0;
$type = sanitize_key((string) ($order['product_type'] ?? ''));
@@ -43,13 +48,19 @@ final class CommissionLedger {
if (!in_array($order_type, (array) $settings['order_types'], true)) return 0;
$order_no = sanitize_text_field((string) ($order['order_number'] ?? ''));
$buyer = (int) ($order['user_id'] ?? 0);
$amount = round((float) ($order['amount'] ?? 0), 2);
if ($order_no === '' || $buyer <= 0 || $amount <= 0) return 0;
try {
$amount = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? $order['amount'] : '');
} catch (\InvalidArgumentException $e) {
return -1;
}
if ($order_no === '' || $buyer <= 0 || $amount->minor() <= 0) return 0;
$promoter = ReferralAttribution::get_instance()->resolve($buyer);
if ($promoter <= 0) return 0;
$ratio = (float) \AKSRT\Settings\PaymentRuntime::affiliate_ratio_for_user($promoter);
$commission = round($amount * $ratio / 100, 2);
if ($commission <= 0) return 0;
$ratio = \AKSRT\Settings\PaymentRuntime::affiliate_ratio_for_user($promoter);
$commission_minor = (intdiv($amount->minor(), 100) * $ratio)
+ intdiv((($amount->minor() % 100) * $ratio) + 50, 100);
if ($commission_minor <= 0) return 0;
$commission = \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $commission_minor)->decimal();
global $wpdb;
$table = $this->table('commission_ledger');
$identity = $order_type === 'credits' && !empty($order['exchange_ref'])
@@ -58,8 +69,23 @@ final class CommissionLedger {
$key = hash('sha256', 'affiliate:' . $identity);
$now = current_time('mysql', true);
$available = gmdate('Y-m-d H:i:s', time() + ((int) $settings['settlement_days'] * DAY_IN_SECONDS));
$ok = $wpdb->insert($table, ['promoter_id'=>$promoter,'source_user_id'=>$buyer,'order_number'=>$order_no,'order_type'=>$order_type,'eligible_base'=>$amount,'ratio'=>$ratio,'original_amount'=>$commission,'adjustment_total'=>0,'status'=>'pending','idempotency_key'=>$key,'available_at'=>$available,'created_at'=>$now], ['%d','%d','%s','%s','%f','%f','%f','%f','%s','%s','%s','%s']);
return $ok ? (int) $wpdb->insert_id : (int) $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE idempotency_key=%s", $key));
$ok = $wpdb->insert($table, ['promoter_id'=>$promoter,'source_user_id'=>$buyer,'order_number'=>$order_no,'order_type'=>$order_type,'eligible_base'=>$amount->decimal(),'ratio'=>$ratio,'original_amount'=>$commission,'adjustment_total'=>'0.00','status'=>'pending','idempotency_key'=>$key,'available_at'=>$available,'created_at'=>$now], ['%d','%d','%s','%s','%s','%d','%s','%s','%s','%s','%s','%s']);
if ($ok) {
return (int) $wpdb->insert_id;
}
$existing = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE idempotency_key=%s", $key), ARRAY_A);
if (!is_array($existing)
|| (int) $existing['promoter_id'] !== $promoter
|| (int) $existing['source_user_id'] !== $buyer
|| (string) $existing['order_number'] !== $order_no
|| (string) $existing['order_type'] !== $order_type
|| (string) $existing['eligible_base'] !== $amount->decimal()
|| (int) $existing['ratio'] !== $ratio
|| (string) $existing['original_amount'] !== $commission
) {
return -1;
}
return (int) $existing['id'];
}
public function settle_due(int $limit = 100): int {
global $wpdb; $table = $this->table('commission_ledger');
@@ -68,79 +94,322 @@ final class CommissionLedger {
foreach ((array) $rows as $row) if ($this->settle_one((int)$row['id'])) $count++;
return $count;
}
private function settle_one(int $id): bool {
private function settle_one(int $id, string $operation_key = '') {
global $wpdb; $table = $this->table('commission_ledger');
$wpdb->query('START TRANSACTION');
try {
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id=%d AND status='pending' FOR UPDATE", $id), ARRAY_A);
if (!$row) { $wpdb->query('ROLLBACK'); return false; }
$amount = round((float)$row['original_amount'] + (float)$row['adjustment_total'], 2);
if ($amount > 0 && class_exists('\\AKSRT\\Credits\\Balance')) {
$credited = \AKSRT\Credits\Balance::add((int)$row['promoter_id'], $amount, 'affiliate_commission', ['type'=>'commission','id'=>$id], sprintf(__('推广佣金结算(订单 %s)','aksrt'), $row['order_number']));
if (is_wp_error($credited)) { $wpdb->query('ROLLBACK'); return false; }
}
$updated = $wpdb->query($wpdb->prepare("UPDATE {$table} SET status='settled', settled_at=%s WHERE id=%d AND status='pending'", current_time('mysql', true), $id)) === 1;
if (!$updated) { $wpdb->query('ROLLBACK'); return false; }
$wpdb->query('COMMIT'); return true;
} catch (\Throwable $e) { $wpdb->query('ROLLBACK'); return false; }
}
public function reverse(string $order_number, float $refund_amount, string $reason = 'refund'): int {
global $wpdb; $table = $this->table('commission_ledger'); $adjust = $this->table('commission_adjustment');
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE order_number=%s LIMIT 1", $order_number), ARRAY_A);
if (!$row || $refund_amount <= 0) return 0;
$base = max(0.01, (float)$row['eligible_base']); $negative = -round((float)$row['original_amount'] * min(1, $refund_amount / $base), 2);
$source = hash('sha256', 'refund:' . $order_number . ':' . number_format($refund_amount,2,'.',''));
$ok = $wpdb->insert($adjust, ['ledger_id'=>$row['id'],'promoter_id'=>$row['promoter_id'],'amount'=>$negative,'reason'=>sanitize_key($reason),'source_key'=>$source,'operator_id'=>0,'created_at'=>current_time('mysql', true)], ['%d','%d','%f','%s','%s','%d','%s']);
if (!$ok) return 0;
$wpdb->query($wpdb->prepare("UPDATE {$table} SET adjustment_total=adjustment_total+%f, status=IF(status='pending','cancelled',status) WHERE id=%d", $negative, $row['id']));
if ($row['status'] === 'settled' && class_exists('\\AKSRT\\Credits\\Balance')) {
$debit = min((float)$row['original_amount'], abs($negative));
$result = \AKSRT\Credits\Balance::deduct((int)$row['promoter_id'], $debit, 'affiliate_refund', ['type'=>'commission','id'=>$row['id']], __('推广佣金退款冲正','aksrt'));
if (is_wp_error($result)) update_user_meta((int)$row['promoter_id'], self::DEBT_META, round((float)get_user_meta((int)$row['promoter_id'], self::DEBT_META, true) + $debit, 2));
}
return (int)$wpdb->insert_id;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id=%d", $id), ARRAY_A);
if (!is_array($row)) return false;
try {
$original_minor = $this->signed_balance_minor((string) ($row['original_amount'] ?? '0'));
$adjustment_minor = $this->signed_balance_minor((string) ($row['adjustment_total'] ?? '0'));
if ($adjustment_minor > 0 && $original_minor > PHP_INT_MAX - $adjustment_minor) {
return false;
}
if ($adjustment_minor < 0 && $original_minor < PHP_INT_MIN - $adjustment_minor) {
return false;
}
$amount_minor = $original_minor + $adjustment_minor;
} catch (\InvalidArgumentException $e) { return false; }
if ($amount_minor <= 0 || (int)$row['promoter_id'] <= 0) return false;
$guard = function () use ($table, $id, $row) {
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare("SELECT promoter_id, original_amount, adjustment_total, status FROM {$table} WHERE id=%d FOR UPDATE", $id), ARRAY_A);
if (!is_array($current) || (string)$current['status'] !== 'pending'
|| (int)$current['promoter_id'] !== (int)$row['promoter_id']
|| (string)$current['original_amount'] !== (string)$row['original_amount']
|| (string)$current['adjustment_total'] !== (string)$row['adjustment_total']) {
return new \WP_Error('aksrt_commission_state', __('佣金记录状态已变化','aksrt'), ['status'=>409]);
}
return true;
};
$mutation = function () use ($table, $id) {
global $wpdb;
return $wpdb->query($wpdb->prepare("UPDATE {$table} SET status='settled', settled_at=%s WHERE id=%d AND status='pending'", current_time('mysql', true), $id)) === 1
? true : new \WP_Error('aksrt_commission_state', __('佣金记录状态已变化','aksrt'), ['status'=>409]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
(int)$row['promoter_id'], \AKSRT\Ledger\AssetType::BALANCE, $amount_minor,
$operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('commission-settle', [$id]), 'affiliate_commission',
['type'=>'commission','id'=>$id], sprintf(__('推广佣金结算(订单 %s)','aksrt'), $row['order_number']),
0, [], $mutation, $guard
);
return is_wp_error($result) ? false : $result;
}
/** @return array<string,mixed>|\WP_Error */
public function prepare_full_refund(string $order_number) {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table('commission_ledger')} WHERE order_number = %s LIMIT 1",
$order_number
), ARRAY_A);
if (!is_array($row) || (string) ($row['status'] ?? '') === 'cancelled') {
return ['active' => false];
}
try {
$original_minor = $this->signed_balance_minor((string) ($row['original_amount'] ?? '0'));
$adjustment_minor = $this->signed_balance_minor((string) ($row['adjustment_total'] ?? '0'));
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_commission_amount', __('返佣金额无效', 'aksrt'), ['status' => 500]);
}
$remaining_minor = $original_minor + $adjustment_minor;
if ($remaining_minor <= 0) {
return ['active' => false];
}
$status = (string) ($row['status'] ?? '');
if (!in_array($status, ['pending', 'settled'], true)) {
return new \WP_Error('aksrt_commission_state', __('返佣记录状态不可退款', 'aksrt'), ['status' => 409]);
}
return [
'active' => true,
'id' => (int) $row['id'],
'promoter_id' => (int) $row['promoter_id'],
'status' => $status,
'original_amount' => (string) $row['original_amount'],
'adjustment_total' => (string) $row['adjustment_total'],
'amount_minor' => $remaining_minor,
'source_key' => hash('sha256', 'order-full-refund:' . $order_number),
];
}
/** @param array<string,mixed> $plan */
public function guard_full_refund(array $plan) {
if (empty($plan['active'])) return true;
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare(
"SELECT promoter_id, status, original_amount, adjustment_total FROM {$this->table('commission_ledger')} WHERE id = %d FOR UPDATE",
(int) $plan['id']
), ARRAY_A);
if (!is_array($current)
|| (int) ($current['promoter_id'] ?? 0) !== (int) $plan['promoter_id']
|| (string) ($current['status'] ?? '') !== (string) $plan['status']
|| (string) ($current['original_amount'] ?? '') !== (string) $plan['original_amount']
|| (string) ($current['adjustment_total'] ?? '') !== (string) $plan['adjustment_total']
) {
return new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
return true;
}
/** @param array<string,mixed> $plan */
public function apply_full_refund(array $plan) {
if (empty($plan['active'])) return true;
global $wpdb;
$negative = '-' . \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $plan['amount_minor'])->decimal();
$inserted = $wpdb->insert($this->table('commission_adjustment'), [
'ledger_id' => (int) $plan['id'],
'promoter_id' => (int) $plan['promoter_id'],
'amount' => $negative,
'reason' => 'order_refund',
'source_key' => (string) $plan['source_key'],
'operator_id' => get_current_user_id(),
'created_at' => current_time('mysql', true),
], ['%d', '%d', '%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
return new \WP_Error('aksrt_commission_adjustment', __('返佣退款记录写入失败', 'aksrt'), ['status' => 409]);
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table('commission_ledger')} SET adjustment_total = adjustment_total + %s, status = 'cancelled' WHERE id = %d AND status = %s",
$negative,
(int) $plan['id'],
(string) $plan['status']
));
return $updated === 1
? true
: new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
private function signed_balance_minor(string $decimal): int {
$negative = str_starts_with($decimal, '-');
$value = $negative ? substr($decimal, 1) : $decimal;
$minor = \AKSRT\Ledger\AssetAmount::balance($value)->minor();
return $negative ? -$minor : $minor;
}
public function overview(int $user_id): array {
global $wpdb; $table=$this->table('commission_ledger');
$row=$wpdb->get_row($wpdb->prepare("SELECT COALESCE(SUM(original_amount+adjustment_total),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN original_amount+adjustment_total ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN original_amount+adjustment_total ELSE 0 END),0) settled FROM {$table} WHERE promoter_id=%d",$user_id),ARRAY_A) ?: [];
$withdrawn = 0.0;
$withdrawn = '0.00';
$withdrawal_table = $this->table('withdrawal');
if ($withdrawal_table !== '') {
$withdrawn = (float) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(CASE WHEN status='completed' THEN CASE WHEN gross_amount > 0 THEN gross_amount ELSE amount END ELSE 0 END),0) FROM {$withdrawal_table} WHERE user_id=%d",
$withdrawn = $this->balance_decimal((string) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(CASE WHEN status='completed' THEN gross_amount ELSE 0 END),0) FROM {$withdrawal_table} WHERE user_id=%d",
$user_id
));
)));
}
return ['total'=>round((float)($row['total']??0),2),'pending'=>round((float)($row['pending']??0),2),'settled'=>round((float)($row['settled']??0),2),'wallet'=>class_exists('\\AKSRT\\Credits\\Balance')?\AKSRT\Credits\Balance::balance($user_id):0.0,'withdrawn'=>round($withdrawn,2),'debt'=>round((float)get_user_meta($user_id,self::DEBT_META,true),2)];
return ['total'=>$this->balance_decimal((string)($row['total']??'0')),'pending'=>$this->balance_decimal((string)($row['pending']??'0')),'settled'=>$this->balance_decimal((string)($row['settled']??'0')),'wallet'=>\AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),'withdrawn'=>$withdrawn];
}
public function list_entries(int $user_id, array $args=[]): array {
global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $where='promoter_id=%d'; $params=[$user_id]; $status=sanitize_key((string)($args['status']??'')); if(in_array($status,['pending','settled','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params)); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))];
}
public function register_routes(): void {
register_rest_route('aksrt/v1','/earnings/overview',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn()=>rest_ensure_response($this->overview(get_current_user_id())),'permission_callback'=>fn()=>is_user_logged_in()]);
register_rest_route('aksrt/v1','/earnings/commissions',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn(\WP_REST_Request $r)=>rest_ensure_response($this->list_entries(get_current_user_id(),$r->get_params())),'permission_callback'=>fn()=>is_user_logged_in()]);
register_rest_route('aksrt/v1','/earnings/withdrawals',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn(\WP_REST_Request $r)=>rest_ensure_response($this->withdrawals(get_current_user_id(),$r->get_params())),'permission_callback'=>fn()=>is_user_logged_in()]);
register_rest_route('aksrt/v1','/earnings/withdrawals',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>[$this,'create_withdrawal'],'permission_callback'=>fn()=>is_user_logged_in()]);
register_rest_route('aksrt/v1','/payment/affiliate/ledger/(?P<id>\d+)/settle',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>fn(\WP_REST_Request $r)=>$this->admin_settle((int)$r['id']),'permission_callback'=>fn()=>current_user_can('manage_options')]);
register_rest_route('aksrt/v1','/payment/affiliate/ledger/(?P<id>\d+)/cancel',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>fn(\WP_REST_Request $r)=>$this->admin_cancel((int)$r['id']),'permission_callback'=>fn()=>current_user_can('manage_options')]);
register_rest_route('aksrt/v1','/payment/affiliate/overview',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn()=>rest_ensure_response($this->admin_overview()),'permission_callback'=>fn()=>current_user_can('manage_options')]);
register_rest_route('aksrt/v1','/payment/affiliate/ledger',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn(\WP_REST_Request $r)=>rest_ensure_response($this->admin_ledger($r->get_params())),'permission_callback'=>fn()=>current_user_can('manage_options')]);
register_rest_route('aksrt/v1','/payment/withdrawals/(?P<id>\d+)/transition',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>fn(\WP_REST_Request $r)=>$this->transition_withdrawal((int)$r['id'],(string)$r->get_param('status'),(string)$r->get_param('note')),'permission_callback'=>fn()=>current_user_can('manage_options')]);
$list_args = [
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
'status' => ['type' => 'string', 'enum' => ['pending', 'processing', 'completed', 'rejected', 'settled', 'cancelled']],
];
register_rest_route('aksrt/v1', '/earnings/overview', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn() => rest_ensure_response($this->overview(get_current_user_id())),
'permission_callback' => [$this, 'check_user_rest'],
]);
register_rest_route('aksrt/v1', '/earnings/commissions', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->list_entries(get_current_user_id(), $request->get_params())),
'permission_callback' => [$this, 'check_user_rest'],
'args' => $list_args,
]);
register_rest_route('aksrt/v1', '/earnings/withdrawals', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->withdrawals(get_current_user_id(), $request->get_params())),
'permission_callback' => [$this, 'check_user_rest'],
'args' => $list_args,
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_withdrawal'],
'permission_callback' => [$this, 'check_user_write_rest'],
'args' => [
'amount' => ['type' => 'string', 'required' => true, 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
'method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
'account' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 500, 'sanitize_callback' => 'sanitize_text_field'],
],
],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger/(?P<id>\d+)/settle', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'admin_settle'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger/(?P<id>\d+)/cancel', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'admin_cancel'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/overview', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn() => rest_ensure_response($this->admin_overview()),
'permission_callback' => [$this, 'check_admin_rest'],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->admin_ledger($request->get_params())),
'permission_callback' => [$this, 'check_admin_rest'],
'args' => $list_args,
]);
register_rest_route('aksrt/v1', '/payment/withdrawals/(?P<id>\d+)/transition', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'transition_withdrawal'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => [
'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
'status' => ['type' => 'string', 'required' => true, 'enum' => ['processing', 'completed', 'rejected']],
'note' => ['type' => 'string', 'default' => '', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'],
],
]);
}
public function admin_settle(int $id): \WP_REST_Response { $ok=$this->settle_one($id); return new \WP_REST_Response(['success'=>$ok],$ok?200:409); }
public function admin_cancel(int $id): \WP_REST_Response { global $wpdb; $ok=$wpdb->query($wpdb->prepare("UPDATE {$this->table('commission_ledger')} SET status='cancelled' WHERE id=%d AND status='pending'",$id))===1; return new \WP_REST_Response(['success'=>$ok],$ok?200:409); }
public function admin_overview(): array { global $wpdb; $table=$this->table('commission_ledger'); $row=$wpdb->get_row("SELECT COUNT(*) total, SUM(status='pending') pending, SUM(status='settled') settled, COALESCE(SUM(original_amount+adjustment_total),0) amount FROM {$table}",ARRAY_A) ?: []; return ['total'=>(int)($row['total']??0),'pending'=>(int)($row['pending']??0),'settled'=>(int)($row['settled']??0),'amount'=>(float)($row['amount']??0)]; }
public function check_user_rest(\WP_REST_Request $request) {
return is_user_logged_in() ? true : new \WP_Error('aksrt_auth_required', __('请先登录', 'aksrt'), ['status' => 401]);
}
public function check_user_write_rest(\WP_REST_Request $request) {
$allowed = $this->check_user_rest($request);
return is_wp_error($allowed) ? $allowed : $this->verify_rest_nonce($request);
}
public function check_admin_rest(\WP_REST_Request $request) {
return current_user_can('manage_options') ? true : new \WP_Error('aksrt_admin_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]);
}
public function check_admin_write_rest(\WP_REST_Request $request) {
$allowed = $this->check_admin_rest($request);
return is_wp_error($allowed) ? $allowed : $this->verify_rest_nonce($request);
}
private function verify_rest_nonce(\WP_REST_Request $request) {
$nonce = (string) $request->get_header('X-WP-Nonce');
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest')
? true
: new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
public function admin_settle(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-commission-settle', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
$result = $this->settle_one((int) $request['id'], $operation_key);
if (!$result instanceof \AKSRT\Ledger\LedgerResult) {
return new \WP_REST_Response(['success' => false, 'message' => __('佣金结算失败', 'aksrt')], 409);
}
return new \WP_REST_Response(['success' => true, 'transaction_id' => $result->transaction_id, 'replayed' => $result->replayed], 200);
}
public function admin_cancel(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-commission-cancel', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
global $wpdb;
$id = (int) $request['id'];
$payload_hash = hash('sha256', (string) wp_json_encode(['id' => $id, 'status' => 'cancelled']));
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_commission_db', __('无法开启返佣取消事务', 'aksrt'), ['status' => 500]);
}
$row = $wpdb->get_row($wpdb->prepare("SELECT status, cancel_operation_key, cancel_payload_hash FROM {$this->table('commission_ledger')} WHERE id=%d FOR UPDATE", $id), ARRAY_A);
if (!is_array($row)) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_missing', __('返佣记录不存在', 'aksrt'), ['status' => 404]);
}
$stored_key = (string) ($row['cancel_operation_key'] ?? '');
$stored_hash = (string) ($row['cancel_payload_hash'] ?? '');
if ($stored_key !== '') {
if (hash_equals($stored_key, $operation_key) && $stored_hash !== '' && hash_equals($stored_hash, $payload_hash) && (string) $row['status'] === 'cancelled') {
if ($wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_db', __('返佣取消重放失败', 'aksrt'), ['status' => 500]);
}
return new \WP_REST_Response(['success' => true, 'operation_key' => $operation_key, 'transaction_id' => 0, 'replayed' => true], 200);
}
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_idempotency_conflict', __('相同操作标识已用于其他返佣取消请求', 'aksrt'), ['status' => 409]);
}
if ((string) ($row['status'] ?? '') !== 'pending') {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
$updated = $wpdb->update($this->table('commission_ledger'), [
'status' => 'cancelled',
'cancel_operation_key' => $operation_key,
'cancel_payload_hash' => $payload_hash,
], ['id' => $id, 'status' => 'pending'], ['%s', '%s', '%s'], ['%d', '%s']);
if ($updated !== 1 || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
$conflict = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$this->table('commission_ledger')} WHERE cancel_operation_key=%s", $operation_key));
return new \WP_Error($conflict ? 'aksrt_commission_idempotency_conflict' : 'aksrt_commission_db', $conflict ? __('相同操作标识已用于其他返佣取消请求', 'aksrt') : __('返佣取消失败', 'aksrt'), ['status' => $conflict ? 409 : 500]);
}
return new \WP_REST_Response(['success' => true, 'operation_key' => $operation_key, 'transaction_id' => 0, 'replayed' => false], 200);
}
public function admin_overview(): array { global $wpdb; $table=$this->table('commission_ledger'); $row=$wpdb->get_row("SELECT COUNT(*) total, SUM(status='pending') pending, SUM(status='settled') settled, COALESCE(SUM(original_amount+adjustment_total),0) amount FROM {$table}",ARRAY_A) ?: []; return ['total'=>(int)($row['total']??0),'pending'=>(int)($row['pending']??0),'settled'=>(int)($row['settled']??0),'amount'=>$this->balance_decimal((string)($row['amount']??'0'))]; }
public function admin_ledger(array $args): array { global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $status=sanitize_key((string)($args['status']??'')); $where='1=1'; $params=[]; if(in_array($status,['pending','settled','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)($wpdb->get_var($params?$wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params):"SELECT COUNT(*) FROM {$table}")); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))]; }
public function withdrawals(int $user_id, array $args): array { return \AKSRT\Payment\Withdrawal::get_instance()->get_user_withdrawals($user_id, ['status'=>sanitize_key((string)($args['status']??'')),'limit'=>max(1,min(100,(int)($args['per_page']??20))),'offset'=>max(0,((int)($args['page']??1)-1)*max(1,(int)($args['per_page']??20)))]); }
public function create_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$user_id=get_current_user_id(); $amount=(int)round((float)$request->get_param('amount')); $method=sanitize_key((string)$request->get_param('method')); $account=sanitize_text_field((string)$request->get_param('account')); $settings=\AKSRT\Settings\Settings::get_withdrawal_settings(); $affiliate=\AKSRT\Settings\Settings::get_affiliate_settings();
$user_id=get_current_user_id(); $method=sanitize_key((string)$request->get_param('method')); $account=sanitize_text_field((string)$request->get_param('account')); $settings=\AKSRT\Settings\Settings::get_withdrawal_settings(); $affiliate=\AKSRT\Settings\Settings::get_affiliate_settings();
$raw_amount=$request->get_param('amount');
try { $amount=\AKSRT\Ledger\AssetAmount::balance(is_string($raw_amount)?$raw_amount:''); }
catch (\InvalidArgumentException $e) { return new \WP_Error('affiliate_withdraw_amount',__('提现金额格式无效。','aksrt'),['status'=>400]); }
$operation_key=\AKSRT\Ledger\OperationKey::from_request($request,'affiliate-withdraw',$user_id); if(is_wp_error($operation_key)) return $operation_key;
if (empty($affiliate['enabled'])) return new \WP_Error('affiliate_disabled',__('推广返佣未启用。','aksrt'),['status'=>403]);
if (\AKSRT\Payment\Withdrawal::get_instance()->has_open_withdrawal($user_id)) return new \WP_Error('affiliate_withdraw_pending',__('已有待处理提现申请。','aksrt'),['status'=>409]);
if ($amount < (int)ceil((float)$settings['min_amount'])) return new \WP_Error('affiliate_withdraw_min',__('未达到最低提现金额。','aksrt'),['status'=>400]);
if ((float)get_user_meta($user_id,self::DEBT_META,true)>0) return new \WP_Error('affiliate_withdraw_debt',__('存在待偿还的佣金欠款,暂不可提现。','aksrt'),['status'=>409]);
$minimum=\AKSRT\Ledger\AssetAmount::balance(is_string($settings['min_amount'] ?? null) ? $settings['min_amount'] : '');
if ($amount->minor() < $minimum->minor()) return new \WP_Error('affiliate_withdraw_min',__('未达到最低提现金额。','aksrt'),['status'=>400]);
if (!in_array($method,(array)$settings['payout_channels'],true) || $account==='') return new \WP_Error('affiliate_withdraw_account',__('请提供有效的收款渠道和收款码。','aksrt'),['status'=>400]);
$result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount,$method,$account,$settings); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201);
$result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount->minor(),$method,$account,$settings,$operation_key); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201);
}
public function admin_withdrawals(array $args): array { $service=\AKSRT\Payment\Withdrawal::get_instance(); return ['items'=>$service->get_all(['status'=>sanitize_key((string)($args['status']??'')),'limit'=>max(1,min(100,(int)($args['per_page']??20))),'offset'=>max(0,((int)($args['page']??1)-1)*max(1,(int)($args['per_page']??20)))]),'stats'=>$service->get_stats()]; }
public function transition_withdrawal(int $id,string $status,string $note): \WP_REST_Response|\WP_Error { $status = ['approved'=>'processing','paid'=>'completed'][$status] ?? $status; $result=\AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id,$status,$note); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'status'=>$status],200); }
public function transition_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-withdraw-transition', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
$status = sanitize_key((string) $request->get_param('status'));
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change((int) $request['id'], $status, (string) $request->get_param('note'), $operation_key);
if (is_wp_error($result)) return $result;
$data = is_array($result) ? $result : [];
return new \WP_REST_Response(array_merge(['success' => true, 'status' => $status], $data), 200);
}
private function balance_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} catch (\InvalidArgumentException $e) {
return '0.00';
}
}
}
+96 -27
View File
@@ -44,20 +44,29 @@ class Coupon {
$type = self::TYPE_FIXED;
}
$value = $type === self::TYPE_PERCENT
? $this->normalize_percent_input($data['value'] ?? null)
: $this->normalize_money_input($data['value'] ?? null);
$min_amount = $this->normalize_money_input($data['min_amount'] ?? '0');
$max_discount = $this->normalize_money_input($data['max_discount'] ?? '0');
if ($value === null || $min_amount === null || $max_discount === null) {
return 0;
}
$wpdb->insert($this->table(), [
'code' => $code,
'type' => $type,
'value' => (float) ($data['value'] ?? 0),
'min_amount' => (float) ($data['min_amount'] ?? 0),
'value' => (string) $value,
'min_amount' => $min_amount,
'usage_limit' => (int) ($data['usage_limit'] ?? 0),
'used_count' => 0,
'expires_at' => sanitize_text_field((string) ($data['expires_at'] ?? '')),
'per_user_limit' => (int) ($data['per_user_limit'] ?? 0),
'product_scope' => wp_json_encode($data['product_scope'] ?? [], JSON_UNESCAPED_UNICODE),
'max_discount' => (float) ($data['max_discount'] ?? 0),
'max_discount' => $max_discount,
'status' => 'active',
'created_at' => current_time('mysql'),
], ['%s', '%s', '%f', '%f', '%d', '%d', '%s', '%d', '%s', '%f', '%s', '%s']);
], ['%s', '%s', '%s', '%s', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%s']);
return (int) $wpdb->insert_id;
}
@@ -114,9 +123,15 @@ class Coupon {
*
* @return array{valid: bool, coupon?: array, message?: string}
*/
public function validate(string $code, float $order_amount = 0, array $args = []): array {
public function validate(string $code, string $order_amount = '0', array $args = []): array {
global $wpdb;
try {
$order_minor = \AKSRT\Ledger\AssetAmount::balance_minor($order_amount);
} catch (\InvalidArgumentException $e) {
return ['valid' => false, 'message' => '订单金额格式无效'];
}
$code = sanitize_text_field($code);
if ($code === '') {
return ['valid' => false, 'message' => '请输入优惠码'];
@@ -145,7 +160,7 @@ class Coupon {
return ['valid' => false, 'message' => '优惠码已用完'];
}
if ($coupon['min_amount'] > 0 && $order_amount < $coupon['min_amount']) {
if ($coupon['min_amount_minor'] > 0 && $order_minor < $coupon['min_amount_minor']) {
return ['valid' => false, 'message' => '订单金额不满足最低消费要求'];
}
@@ -196,22 +211,23 @@ class Coupon {
}
// Calculate discount
$discount = 0;
$discount_minor = 0;
if ($coupon['type'] === self::TYPE_PERCENT) {
$discount = $order_amount * ($coupon['value'] / 100);
$discount_minor = \AKSRT\Ledger\AssetAmount::percentage($order_minor, (int) $coupon['value']);
} else {
$discount = min($coupon['value'], $order_amount);
$discount_minor = min((int) $coupon['value_minor'], $order_minor);
}
// Max discount cap
if ($coupon['max_discount'] > 0 && $discount > $coupon['max_discount']) {
$discount = $coupon['max_discount'];
if ($coupon['max_discount_minor'] > 0 && $discount_minor > $coupon['max_discount_minor']) {
$discount_minor = $coupon['max_discount_minor'];
}
return [
'valid' => true,
'coupon' => $coupon,
'discount' => round($discount, 2),
'discount' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($discount_minor),
'discount_minor' => $discount_minor,
];
}
@@ -223,11 +239,11 @@ class Coupon {
$code = sanitize_text_field($code);
$result = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET used_count = used_count + 1 WHERE code = %s",
"UPDATE {$this->table()} SET used_count = used_count + 1 WHERE code = %s AND status = 'active' AND (usage_limit = 0 OR used_count < usage_limit)",
$code
));
return $result !== false;
return $result === 1;
}
/**
@@ -266,7 +282,8 @@ class Coupon {
*/
public function update(int $id, array $data): bool {
global $wpdb;
if ($id <= 0 || !$this->get($id)) {
$current = $id > 0 ? $this->get($id) : null;
if (!$current) {
return false;
}
@@ -282,16 +299,31 @@ class Coupon {
$format[] = '%s';
}
if (array_key_exists('value', $data)) {
$fields['value'] = (float) $data['value'];
$format[] = '%f';
$target_type = (string) ($fields['type'] ?? $current['type']);
$value = $target_type === self::TYPE_PERCENT
? $this->normalize_percent_input($data['value'])
: $this->normalize_money_input($data['value']);
if ($value === null) {
return false;
}
$fields['value'] = (string) $value;
$format[] = '%s';
}
if (array_key_exists('min_amount', $data)) {
$fields['min_amount'] = (float) $data['min_amount'];
$format[] = '%f';
$value = $this->normalize_money_input($data['min_amount']);
if ($value === null) {
return false;
}
$fields['min_amount'] = $value;
$format[] = '%s';
}
if (array_key_exists('max_discount', $data)) {
$fields['max_discount'] = (float) $data['max_discount'];
$format[] = '%f';
$value = $this->normalize_money_input($data['max_discount']);
if ($value === null) {
return false;
}
$fields['max_discount'] = $value;
$format[] = '%s';
}
if (array_key_exists('usage_limit', $data)) {
$fields['usage_limit'] = (int) $data['usage_limit'];
@@ -371,29 +403,66 @@ class Coupon {
/**
* Log a coupon usage record.
*/
public function log_usage(int $coupon_id, int $user_id, string $order_number, float $discount): void {
public function log_usage(int $coupon_id, int $user_id, string $order_number, string $discount): bool {
global $wpdb;
$discount = $this->normalize_money_input($discount);
if ($coupon_id <= 0 || $order_number === '' || $discount === null) {
return false;
}
$table = \AKSRT\Core\DBMigrator::table('coupon_usage');
$wpdb->insert($table, [
return $wpdb->insert($table, [
'coupon_id' => $coupon_id,
'user_id' => $user_id,
'order_number' => $order_number,
'discount' => $discount,
'created_at' => current_time('mysql'),
], ['%d', '%d', '%s', '%f', '%s']);
], ['%d', '%d', '%s', '%s', '%s']) === 1;
}
private function normalize_row(array $row): array {
$row['id'] = (int) $row['id'];
$row['value'] = (float) $row['value'];
$row['min_amount'] = (float) $row['min_amount'];
$row['type'] = in_array((string) ($row['type'] ?? ''), [self::TYPE_PERCENT, self::TYPE_FIXED], true)
? (string) $row['type']
: self::TYPE_FIXED;
if ($row['type'] === self::TYPE_PERCENT) {
$row['value'] = $this->normalize_percent_input((string) ($row['value'] ?? '')) ?? 0;
$row['value_minor'] = 0;
} else {
$row['value'] = $this->normalize_money_input((string) ($row['value'] ?? '')) ?? '0.00';
$row['value_minor'] = \AKSRT\Ledger\AssetAmount::balance_minor($row['value']);
}
$row['min_amount'] = $this->normalize_money_input((string) ($row['min_amount'] ?? '')) ?? '0.00';
$row['min_amount_minor'] = \AKSRT\Ledger\AssetAmount::balance_minor($row['min_amount']);
$row['usage_limit'] = (int) $row['usage_limit'];
$row['used_count'] = (int) $row['used_count'];
$row['per_user_limit'] = (int) ($row['per_user_limit'] ?? 0);
$row['max_discount'] = (float) ($row['max_discount'] ?? 0);
$row['max_discount'] = $this->normalize_money_input((string) ($row['max_discount'] ?? '')) ?? '0.00';
$row['max_discount_minor'] = \AKSRT\Ledger\AssetAmount::balance_minor($row['max_discount']);
if (!empty($row['product_scope']) && is_string($row['product_scope'])) {
$row['product_scope'] = json_decode($row['product_scope'], true) ?: [];
}
return $row;
}
private function normalize_money_input($value): ?string {
if (!is_string($value)) {
return null;
}
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} catch (\InvalidArgumentException $e) {
return null;
}
}
private function normalize_percent_input($value): ?int {
if (!is_string($value)) {
return null;
}
$value = trim($value);
if (!preg_match('/^(100|[1-9]?\d)(?:\.0{1,2})?$/', $value)) {
return null;
}
return (int) $value;
}
}
+12 -8
View File
@@ -350,24 +350,28 @@ class Gateway {
// Provider-confirmed external payments must include an exact amount and transaction ID.
$expected_currency = strtoupper((string) ($order['currency'] ?? ''));
$paid_currency = strtoupper(sanitize_key((string) ($verify['currency'] ?? '')));
$expected = (float) ($order['amount'] ?? 0);
$paid_amount = (float) ($verify['amount'] ?? 0);
try {
$expected = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? $order['amount'] : '');
$paid_amount = \AKSRT\Ledger\AssetAmount::balance(is_string($verify['amount'] ?? null) ? $verify['amount'] : '');
} catch (\InvalidArgumentException $e) {
return false;
}
$transaction_id = sanitize_text_field((string) ($verify['transaction_id'] ?? ''));
if ($expected <= 0
|| $paid_amount <= 0
if ($expected->minor() <= 0
|| $paid_amount->minor() <= 0
|| $transaction_id === ''
|| $expected_currency === ''
|| $paid_currency === ''
|| !hash_equals($expected_currency, $paid_currency)
|| abs($expected - $paid_amount) > 0.01) {
|| $expected->minor() !== $paid_amount->minor()) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf(
'[AKSRT Payment] invalid completion order=%s out_trade_no=%s expected=%.2f %s paid=%.2f %s transaction=%s gateway=%s',
'[AKSRT Payment] invalid completion order=%s out_trade_no=%s expected=%s %s paid=%s %s transaction=%s gateway=%s',
$order_number,
$provider_out_trade_no,
$expected,
$expected->decimal(),
$expected_currency,
$paid_amount,
$paid_amount->decimal(),
$paid_currency,
$transaction_id !== '' ? 'present' : 'missing',
$gateway_id
+3 -3
View File
@@ -23,7 +23,7 @@ interface GatewayInterface {
* 验证回调
*
* @param \WP_REST_Request $request 回调请求
* @return array{success: bool, order_number?: string, amount?: float, transaction_id?: string, message?: string}
* @return array{success: bool, order_number?: string, amount?: string, transaction_id?: string, message?: string}
*/
public function verify_callback(\WP_REST_Request $request): array;
@@ -39,11 +39,11 @@ interface GatewayInterface {
* 退款
*
* @param string $order_number 订单号
* @param float $amount 退款金额
* @param string $amount 最多两位小数的退款金额
* @param string $request_id 幂等退款请求号
* @return array{success: bool, status?: string, refund_id?: string, request_id?: string, message?: string}
*/
public function refund(string $order_number, float $amount, string $request_id = ''): array;
public function refund(string $order_number, string $amount, string $request_id = ''): array;
/**
* 获取网关显示名称
+30 -12
View File
@@ -46,11 +46,11 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$amount = $this->amount_string($order['amount'] ?? null);
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
$payment_type = $this->get_config()['payment_type'];
if ($order_number === '' || $out_trade_no === '' || $amount <= 0) {
if ($order_number === '' || $out_trade_no === '' || $amount === '') {
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
}
@@ -60,7 +60,7 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
$payload = [
'_method' => 'GET',
'out_trade_no' => $out_trade_no,
'total_amount' => number_format($amount, 2, '.', ''),
'total_amount' => $amount,
'subject' => mb_substr($subject, 0, 256),
];
@@ -109,16 +109,17 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
$order_number = (string) ($data['out_trade_no'] ?? '');
$trade_no = (string) ($data['trade_no'] ?? '');
$amount = $this->amount_string($data['total_amount'] ?? null);
if (($data['app_id'] ?? '') !== $config['app_id']
|| $order_number === ''
|| ($paid && ($trade_no === '' || (float) ($data['total_amount'] ?? 0) <= 0))) {
|| ($paid && ($trade_no === '' || $amount === ''))) {
return ['success' => false, 'message' => __('支付宝回调身份或订单数据无效', 'aksrt')];
}
return [
'success' => true,
'order_number' => $order_number,
'amount' => (float) ($data['total_amount'] ?? 0),
'amount' => $amount,
'currency' => 'CNY',
'transaction_id' => $trade_no,
'status' => $paid ? 'paid' : 'pending',
@@ -171,7 +172,7 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
'success' => true,
// Keep provider out_trade_no for Gateway::complete_order resolve.
'order_number' => (string) ($data['out_trade_no'] ?? $out_trade_no),
'amount' => (float) ($data['total_amount'] ?? 0),
'amount' => $this->amount_string($data['total_amount'] ?? null),
'currency' => 'CNY',
'transaction_id' => (string) ($data['trade_no'] ?? ''),
'status' => $status,
@@ -200,8 +201,13 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
}
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
if ($amount <= 0) {
public function refund(string $order_number, string $amount, string $request_id = ''): array {
try {
$refund_amount = \AKSRT\Ledger\AssetAmount::balance($amount);
} catch (\InvalidArgumentException $e) {
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
}
if ($refund_amount->minor() <= 0) {
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
}
if (!$this->is_configured()) {
@@ -219,14 +225,14 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
);
$result = Pay::alipay()->refund([
'out_trade_no' => $out_trade_no,
'refund_amount' => number_format($amount, 2, '.', ''),
'refund_amount' => $refund_amount->decimal(),
'refund_reason' => __('用户申请退款', 'aksrt'),
'out_request_no' => $refund_request_id,
]);
$data = YansongdaPayFactory::to_array($result);
$fund_change = strtoupper((string) ($data['fund_change'] ?? ''));
$status = ($fund_change === 'Y' || (float) ($data['refund_fee'] ?? 0) > 0)
$status = ($fund_change === 'Y' || $this->amount_string($data['refund_fee'] ?? null) !== '')
? 'succeeded'
: 'unknown';
@@ -256,8 +262,8 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
$result = Pay::alipay()->query($params);
$data = YansongdaPayFactory::to_array($result);
$refund_status = strtoupper((string) ($data['refund_status'] ?? ''));
$refund_amount = (float) ($data['refund_amount'] ?? 0);
$status = ($refund_status === 'REFUND_SUCCESS' || $refund_amount > 0)
$refund_amount = $this->amount_string($data['refund_amount'] ?? null);
$status = ($refund_status === 'REFUND_SUCCESS' || $refund_amount !== '')
? 'succeeded'
: ($refund_status === 'REFUND_FAIL' ? 'failed' : 'pending');
return [
@@ -291,6 +297,18 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
];
}
private function amount_string(mixed $value): string {
if (!is_string($value)) {
return '';
}
try {
$amount = \AKSRT\Ledger\AssetAmount::balance(trim($value));
return $amount->minor() > 0 ? $amount->decimal() : '';
} catch (\InvalidArgumentException $e) {
return '';
}
}
/**
* Map local order_number → provider out_trade_no (supports resume attempts).
*/
+21 -9
View File
@@ -50,9 +50,9 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$amount = $this->amount_string($order['amount'] ?? null);
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
if ($order_number === '' || $out_trade_no === '' || $amount <= 0) {
if ($order_number === '' || $out_trade_no === '' || $amount === null) {
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
}
@@ -64,7 +64,7 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
'notify_url' => rest_url('aksrt/v1/payment/' . $this->get_id() . '/notify'),
'return_url' => rest_url('aksrt/v1/payment/' . $this->get_id() . '/return'),
'name' => mb_substr(wp_strip_all_tags($subject), 0, 127),
'money' => number_format($amount, 2, '.', ''),
'money' => $amount,
];
$params['sign'] = $this->sign($params, $config['key']);
$params['sign_type'] = 'MD5';
@@ -147,7 +147,7 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
$order_number = sanitize_text_field((string) ($params['out_trade_no'] ?? ''));
$transaction_id = sanitize_text_field((string) ($params['trade_no'] ?? ''));
$amount = (float) ($params['money'] ?? 0);
$amount = $this->amount_string($params['money'] ?? null);
$trade_status = (string) ($params['trade_status'] ?? '');
$type = sanitize_key((string) ($params['type'] ?? ''));
@@ -157,14 +157,14 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
}
$paid = ($trade_status === '' || $trade_status === 'TRADE_SUCCESS');
if ($order_number === '' || ($paid && ($transaction_id === '' || $amount <= 0))) {
if ($order_number === '' || ($paid && ($transaction_id === '' || $amount === null))) {
return ['success' => false, 'message' => __('易支付回调数据无效', 'aksrt')];
}
return [
'success' => true,
'order_number' => $order_number,
'amount' => $amount,
'amount' => $amount ?? '0.00',
'currency' => 'CNY',
'transaction_id' => $transaction_id,
'status' => $paid ? 'paid' : 'pending',
@@ -209,20 +209,20 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
$status = (int) ($body['status'] ?? $body['trade_status'] ?? 0);
$paid = $code === 1 && ((string) $status === '1' || (string) ($body['trade_status'] ?? '') === 'TRADE_SUCCESS');
$amount = (float) ($body['money'] ?? $body['amount'] ?? 0);
$amount = $this->amount_string($body['money'] ?? $body['amount'] ?? null);
$tx = (string) ($body['trade_no'] ?? $body['api_trade_no'] ?? '');
return [
'success' => true,
'order_number' => (string) ($body['out_trade_no'] ?? $out_trade_no),
'amount' => $amount,
'amount' => $amount ?? '0.00',
'currency' => 'CNY',
'transaction_id' => $tx,
'status' => $paid ? 'paid' : 'pending',
];
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
public function refund(string $order_number, string $amount, string $request_id = ''): array {
return [
'success' => false,
'status' => 'failed',
@@ -288,6 +288,18 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
return rtrim($apiurl, '/') . '/' . ltrim($file, '/');
}
private function amount_string(mixed $value): ?string {
if (!is_string($value)) {
return null;
}
try {
$amount = \AKSRT\Ledger\AssetAmount::balance(trim($value));
return $amount->minor() > 0 ? $amount->decimal() : null;
} catch (\InvalidArgumentException $e) {
return null;
}
}
private function prefer_mobile(string $device): bool {
if ($device === 'mobile') {
return true;
+24 -5
View File
@@ -40,13 +40,16 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$amount = $this->amount_string($order['amount'] ?? null);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$order_currency = strtoupper((string) ($order['currency'] ?? ''));
$currency = strtoupper((string) ($config['currency'] ?? 'USD'));
if ($order_currency === '' || !hash_equals($order_currency, $currency)) {
return ['success' => false, 'message' => __('PayPal 币种与订单币种不一致', 'aksrt')];
}
if ($order_number === '' || $out_trade_no === '' || $amount === null) {
return ['success' => false, 'message' => __('订单金额无效', 'aksrt')];
}
try {
$response = $this->api_request('POST', '/v2/checkout/orders', [
@@ -56,7 +59,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
'description' => $subject,
'amount' => [
'currency_code' => $currency,
'value' => number_format($amount, 2, '.', ''),
'value' => $amount,
],
]],
'application_context' => [
@@ -142,7 +145,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
}
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
public function refund(string $order_number, string $amount, string $request_id = ''): array {
return [
'success' => false,
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
@@ -167,7 +170,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
* 用户在 return_url 返回后调用,确认真正扣款。
*
* @param string $paypal_order_id PayPal 订单 ID(create 时返回的 id)
* @return array{success:bool, order_number?:string, amount?:float, transaction_id?:string, status?:string, message?:string}
* @return array{success:bool, order_number?:string, amount?:string, transaction_id?:string, status?:string, message?:string}
*/
public function capture_order(string $paypal_order_id): array {
if ($paypal_order_id === '') {
@@ -192,16 +195,32 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
private function completed_order_evidence(array $body): array {
$unit = $body['purchase_units'][0] ?? [];
$capture = $unit['payments']['captures'][0] ?? [];
$amount = $this->amount_string($capture['amount']['value'] ?? null);
if ($amount === null) {
return ['success' => false, 'message' => __('PayPal 返回金额无效', 'aksrt')];
}
return [
'success' => true,
'order_number' => (string) ($unit['reference_id'] ?? ''),
'amount' => (float) ($capture['amount']['value'] ?? 0),
'amount' => $amount,
'currency' => strtoupper((string) ($capture['amount']['currency_code'] ?? '')),
'transaction_id' => (string) ($capture['id'] ?? ($body['id'] ?? '')),
'status' => 'paid',
];
}
private function amount_string(mixed $value): ?string {
if (!is_string($value)) {
return null;
}
try {
$amount = \AKSRT\Ledger\AssetAmount::balance(trim($value));
return $amount->minor() > 0 ? $amount->decimal() : null;
} catch (\InvalidArgumentException $e) {
return null;
}
}
/**
* 获取 OAuth2 access token(v2 REST 必需)。
*/
+37 -10
View File
@@ -42,13 +42,17 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$amount = is_string($order['amount'] ?? null) ? trim($order['amount']) : '';
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$order_currency = strtoupper((string) ($order['currency'] ?? ''));
$currency = strtolower($config['currency'] ?? 'usd');
if ($order_currency === '' || !hash_equals($order_currency, strtoupper($currency))) {
return ['success' => false, 'message' => __('Stripe 币种与订单币种不一致', 'aksrt')];
}
$smallest_amount = $this->to_smallest_unit($amount, $currency);
if ($order_number === '' || $out_trade_no === '' || $smallest_amount === null || $smallest_amount <= 0) {
return ['success' => false, 'message' => __('订单金额无效', 'aksrt')];
}
try {
$stripe = $this->create_client($config);
@@ -62,7 +66,7 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
'product_data' => [
'name' => $subject,
],
'unit_amount' => $this->to_smallest_unit($amount, $currency),
'unit_amount' => $smallest_amount,
],
'quantity' => 1,
]],
@@ -204,7 +208,15 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
}
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
public function refund(string $order_number, string $amount, string $request_id = ''): array {
try {
$refund_amount = \AKSRT\Ledger\AssetAmount::balance($amount);
} catch (\InvalidArgumentException $e) {
return ['success' => false, 'message' => __('退款金额无效', 'aksrt')];
}
if ($refund_amount->minor() <= 0) {
return ['success' => false, 'message' => __('退款金额无效', 'aksrt')];
}
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
}
@@ -228,6 +240,10 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
}
$currency = strtolower((string) ($found->currency ?? $config['currency'] ?? 'usd'));
$refund_smallest = $this->to_smallest_unit($refund_amount->decimal(), $currency);
if ($refund_smallest === null || $refund_smallest <= 0) {
return ['success' => false, 'message' => __('退款金额与币种精度不匹配', 'aksrt')];
}
$options = ['api_key' => $config['secret_key']];
if ($request_id !== '') {
@@ -235,7 +251,7 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
}
$refund = \Stripe\Refund::create([
'payment_intent' => $paymentIntentId,
'amount' => $this->to_smallest_unit($amount, $currency),
'amount' => $refund_smallest,
'reason' => 'requested_by_customer',
'metadata' => [
'aksrt_request_id' => $request_id,
@@ -390,26 +406,37 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
return array_values(array_intersect($methods, $valid)) ?: ['card'];
}
private function from_smallest_unit(int $amount, string $currency): float {
private function from_smallest_unit(int $amount, string $currency): string {
if ($amount < 0) {
return '0.00';
}
$zero_decimal = ['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'];
if (in_array(strtolower($currency), $zero_decimal, true)) {
return (float) $amount;
if ($amount > intdiv(PHP_INT_MAX, 100)) {
return '0.00';
}
return \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $amount * 100)->decimal();
}
return $amount / 100;
return \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $amount)->decimal();
}
/**
* 将金额转换为最小单位(分/cent)。
*/
private function to_smallest_unit(float $amount, string $currency): int {
private function to_smallest_unit(string $amount, string $currency): ?int {
try {
$parsed = \AKSRT\Ledger\AssetAmount::balance($amount);
} catch (\InvalidArgumentException $e) {
return null;
}
// 零小数货币(日元、韩元等)
$zeroDecimal = ['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'];
if (in_array(strtolower($currency), $zeroDecimal, true)) {
return (int) $amount;
return $parsed->minor() % 100 === 0 ? intdiv($parsed->minor(), 100) : null;
}
return (int) round($amount * 100);
return $parsed->minor();
}
}
+24 -6
View File
@@ -47,10 +47,13 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
$payment_type = $this->get_config()['payment_type'];
$total_fen = (int) round($amount * 100);
try {
$total_fen = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? trim($order['amount']) : '')->minor();
} catch (\InvalidArgumentException $e) {
$total_fen = 0;
}
if ($order_number === '' || $out_trade_no === '' || $total_fen <= 0) {
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
@@ -178,7 +181,7 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
return [
'success' => true,
'order_number' => $order_number,
'amount' => $total_fen > 0 ? ($total_fen / 100) : 0.0,
'amount' => $this->fen_decimal($total_fen),
'currency' => strtoupper((string) YansongdaPayFactory::dig($data, 'amount.currency', 'CNY')),
'transaction_id' => $transaction_id,
'status' => $paid ? 'paid' : 'pending',
@@ -242,7 +245,7 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
'success' => true,
// Keep provider out_trade_no so Gateway::complete_order can resolve the local order.
'order_number' => (string) ($data['out_trade_no'] ?? $out_trade_no),
'amount' => ((int) YansongdaPayFactory::dig($data, 'amount.total', 0)) / 100,
'amount' => $this->fen_decimal((int) YansongdaPayFactory::dig($data, 'amount.total', 0)),
'currency' => strtoupper((string) YansongdaPayFactory::dig($data, 'amount.currency', 'CNY')),
'transaction_id' => (string) ($data['transaction_id'] ?? ''),
'status' => $status,
@@ -271,7 +274,15 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
}
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
public function refund(string $order_number, string $amount, string $request_id = ''): array {
try {
$refund_amount = \AKSRT\Ledger\AssetAmount::balance($amount);
} catch (\InvalidArgumentException $e) {
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
}
if ($refund_amount->minor() <= 0) {
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
}
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
@@ -288,7 +299,7 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
]);
$q = YansongdaPayFactory::to_array($query);
$total_fen = (int) YansongdaPayFactory::dig($q, 'amount.total', 0);
$refund_fen = (int) round($amount * 100);
$refund_fen = $refund_amount->minor();
if ($total_fen <= 0) {
return ['success' => false, 'status' => 'failed', 'message' => __('无法确认原订单金额', 'aksrt')];
}
@@ -387,6 +398,13 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
return $map[$type] ?? 'native';
}
private function fen_decimal(int $amount): string {
return \AKSRT\Ledger\AssetAmount::from_minor(
\AKSRT\Ledger\AssetType::BALANCE,
max(0, $amount)
)->decimal();
}
/**
* Map local order_number → provider out_trade_no (supports resume attempts).
*/
+2 -2
View File
@@ -44,9 +44,9 @@ class Order {
add_action('wp_ajax_aksrt_order_stats', [$this, 'ajax_stats']);
}
public function create(int $user_id, string $product_type, int $product_id, float $amount, string $currency = 'CNY'): string {
public function create(int $user_id, string $product_type, int $product_id, string $amount, string $currency = 'CNY'): string {
// Guest content orders may use user_id=0 (legacy pay_no_logged_in).
if ($user_id < 0 || $product_id < 0 || $amount < 0) {
if ($user_id < 0 || $product_id < 0) {
return '';
}
+21 -3
View File
@@ -53,7 +53,9 @@ class OrderCenterRest {
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'pay'],
'permission_callback' => [$this, 'require_logged_in'],
'args' => $route_args,
'args' => array_merge($route_args, [
'payment_method' => ['type' => 'string', 'sanitize_callback' => 'sanitize_key'],
]),
]);
register_rest_route($namespace, '/orders/' . $order_number . '/cancel', [
'methods' => \WP_REST_Server::CREATABLE,
@@ -76,10 +78,14 @@ class OrderCenterRest {
}
/** @return true|\WP_Error */
public function require_logged_in() {
public function require_logged_in(\WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new \WP_Error('aksrt_rest_forbidden', __('请先登录', 'aksrt'), ['status' => 401]);
}
$nonce = (string) $request->get_header('X-WP-Nonce');
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
return true;
}
@@ -92,6 +98,10 @@ class OrderCenterRest {
}
public function pay(\WP_REST_Request $request): \WP_REST_Response {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'order-pay', get_current_user_id());
if (is_wp_error($operation_key)) {
return new \WP_REST_Response(['success' => false, 'message' => $operation_key->get_error_message()], (int) ($operation_key->get_error_data()['status'] ?? 400));
}
$params = $request->get_json_params();
$params = is_array($params) ? $params : [];
$result = UserOrders::get_instance()->pay_order(
@@ -101,10 +111,14 @@ class OrderCenterRest {
);
return !empty($result['success'])
? $this->success(['data' => $result['data'] ?? []])
: $this->error((string) ($result['message'] ?? __('支付失败', 'aksrt')), 400);
: $this->error((string) ($result['message'] ?? __('支付失败', 'aksrt')), (int) ($result['status'] ?? 400));
}
public function cancel(\WP_REST_Request $request): \WP_REST_Response {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'order-cancel', get_current_user_id());
if (is_wp_error($operation_key)) {
return new \WP_REST_Response(['success' => false, 'message' => $operation_key->get_error_message()], (int) ($operation_key->get_error_data()['status'] ?? 400));
}
$result = UserOrders::get_instance()->cancel_order(
get_current_user_id(),
sanitize_text_field((string) $request->get_param('order_number'))
@@ -115,6 +129,10 @@ class OrderCenterRest {
}
public function refresh(\WP_REST_Request $request): \WP_REST_Response {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'order-refresh', get_current_user_id());
if (is_wp_error($operation_key)) {
return new \WP_REST_Response(['success' => false, 'message' => $operation_key->get_error_message()], (int) ($operation_key->get_error_data()['status'] ?? 400));
}
$order = UserOrders::get_instance()->refresh_order(
get_current_user_id(),
sanitize_text_field((string) $request->get_param('order_number'))
+3
View File
@@ -12,6 +12,9 @@ class OrderRepository {
use OrderRepositoryAdmin;
private static $instance = null;
private int $status_transaction_depth = 0;
/** @var array<int,array{hook:string,args:array<int,mixed>}> */
private array $status_after_commit_events = [];
+71 -24
View File
@@ -174,7 +174,7 @@ trait OrderRepositoryAdmin {
* orderby?: string,
* order?: string
* } $args
* @return array{items: list<array{product_id:int,product_type:string,sales_count:int,avg_amount:float,revenue:float}>, total: int}
* @return array{items: list<array{product_id:int,product_type:string,sales_count:int,avg_amount:int|string,revenue:int|string}>, total: int}
*/
public function get_content_sales_summary(array $args = []): array {
global $wpdb;
@@ -237,12 +237,13 @@ trait OrderRepositoryAdmin {
$list_sql = "SELECT product_id,
product_type,
currency,
COUNT(*) AS sales_count,
AVG(amount) AS avg_amount,
SUM(amount) AS revenue
FROM {$table}
{$where_sql}
GROUP BY product_id, product_type
GROUP BY product_id, product_type, currency
ORDER BY {$orderby_col} {$order_dir}
LIMIT %d OFFSET %d";
$list_params = $params;
@@ -252,12 +253,28 @@ trait OrderRepositoryAdmin {
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
$items = [];
foreach ((array) $rows as $row) {
$sales_count = max(1, (int) ($row['sales_count'] ?? 0));
$is_points = (string) ($row['currency'] ?? '') === 'points';
if ($is_points) {
$revenue = max(0, (int) ($row['revenue'] ?? 0));
$avg_amount = intdiv($revenue + intdiv($sales_count, 2), $sales_count);
} else {
try {
$revenue_minor = \AKSRT\Ledger\AssetAmount::balance_minor((string) ($row['revenue'] ?? '0'));
} catch (\InvalidArgumentException $e) {
$revenue_minor = 0;
}
$revenue = \AKSRT\Ledger\AssetAmount::decimal_from_minor($revenue_minor);
$avg_amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor(
intdiv($revenue_minor + intdiv($sales_count, 2), $sales_count)
);
}
$items[] = [
'product_id' => (int) ($row['product_id'] ?? 0),
'product_type' => (string) ($row['product_type'] ?? $product_type),
'sales_count' => (int) ($row['sales_count'] ?? 0),
'avg_amount' => round((float) ($row['avg_amount'] ?? 0), 2),
'revenue' => round((float) ($row['revenue'] ?? 0), 2),
'avg_amount' => $avg_amount,
'revenue' => $revenue,
];
}
@@ -273,7 +290,7 @@ trait OrderRepositoryAdmin {
* Paid order totals for a set of product IDs (resource content).
*
* @param int[] $product_ids
* @return array<int, array{sales_count:int,avg_amount:float,revenue:float}>
* @return array<int, array{sales_count:int,avg_amount:int|string,revenue:int|string}>
*/
public function get_content_sales_map(array $product_ids, string $product_type = ''): array {
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
@@ -304,8 +321,8 @@ trait OrderRepositoryAdmin {
}
$map[$pid] = [
'sales_count' => (int) ($item['sales_count'] ?? 0),
'avg_amount' => (float) ($item['avg_amount'] ?? 0),
'revenue' => (float) ($item['revenue'] ?? 0),
'avg_amount' => $item['avg_amount'] ?? '0.00',
'revenue' => $item['revenue'] ?? '0.00',
];
}
return $map;
@@ -384,7 +401,7 @@ trait OrderRepositoryAdmin {
'order_number' => $order_number,
'status' => '',
'status_label' => '',
'amount' => 0.0,
'amount' => '0.00',
'currency_symbol' => '¥',
'created_at' => '',
'paid_at' => '',
@@ -403,10 +420,10 @@ trait OrderRepositoryAdmin {
],
'coupon' => [],
'pricing' => [
'subtotal' => 0.0,
'shipping_fee' => 0.0,
'discount' => 0.0,
'amount' => 0.0,
'subtotal' => '0.00',
'shipping_fee' => '0.00',
'discount' => '0.00',
'amount' => '0.00',
],
'items' => [],
'customer_note' => '',
@@ -430,10 +447,17 @@ trait OrderRepositoryAdmin {
$shipping_method = is_array($meta['shipping_method'] ?? null) ? $meta['shipping_method'] : [];
$coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : [];
$items = is_array($meta['items'] ?? null) ? $meta['items'] : [];
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
$discount = (float) ($meta['discount'] ?? 0);
$amount = (float) ($order['amount'] ?? 0);
$subtotal = $this->calculate_order_subtotal($items, max(0, $amount + $discount - $shipping_fee));
$currency = (string) ($order['currency'] ?? 'CNY');
$is_points = $currency === 'points';
$shipping_minor = $is_points ? max(0, (int) ($meta['shipping_fee'] ?? 0)) : $this->cash_minor((string) ($meta['shipping_fee'] ?? '0'));
$discount_minor = $is_points ? max(0, (int) ($meta['discount'] ?? 0)) : $this->cash_minor((string) ($meta['discount'] ?? '0'));
$amount_minor = $is_points ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($order['amount'] ?? '0'));
$fallback_minor = max(0, $amount_minor + $discount_minor - $shipping_minor);
$subtotal_minor = $this->calculate_order_subtotal_minor($items, $fallback_minor, $is_points);
$amount = $is_points ? $amount_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($amount_minor);
$shipping_fee = $is_points ? $shipping_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($shipping_minor);
$discount = $is_points ? $discount_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($discount_minor);
$subtotal = $is_points ? $subtotal_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($subtotal_minor);
$address['full_text'] = $this->build_address_text($address);
@@ -444,7 +468,7 @@ trait OrderRepositoryAdmin {
'status' => $status,
'status_label' => class_exists('AKSRT\\Payment\\Order') ? (Order::get_valid_statuses()[$status] ?? $status) : $status,
'amount' => $amount,
'currency_symbol' => '¥',
'currency_symbol' => $is_points ? __('积分', 'aksrt') : '¥',
'created_at' => (string) ($order['created_at'] ?? ''),
'paid_at' => (string) ($order['paid_at'] ?? ''),
'product_type' => (string) ($order['product_type'] ?? ''),
@@ -492,26 +516,49 @@ trait OrderRepositoryAdmin {
/**
* @param array<int, array<string, mixed>> $items
*/
private function calculate_order_subtotal(array $items, float $fallback): float {
private function calculate_order_subtotal_minor(array $items, int $fallback, bool $is_points): int {
if (empty($items)) {
return round($fallback, 2);
return max(0, $fallback);
}
$subtotal = 0.0;
$subtotal = 0;
foreach ($items as $item) {
if (isset($item['line_total'])) {
$subtotal += (float) $item['line_total'];
$line_minor = $is_points
? max(0, (int) $item['line_total'])
: $this->cash_minor((string) $item['line_total']);
if ($subtotal > PHP_INT_MAX - $line_minor) {
return max(0, $fallback);
}
$subtotal += $line_minor;
continue;
}
$subtotal += (float) ($item['price'] ?? 0) * max(1, (int) ($item['quantity'] ?? 1));
$price_minor = $is_points
? max(0, (int) ($item['price'] ?? 0))
: $this->cash_minor((string) ($item['price'] ?? '0'));
$quantity = max(1, (int) ($item['quantity'] ?? 1));
if ($price_minor > intdiv(PHP_INT_MAX, $quantity)
|| $subtotal > PHP_INT_MAX - ($price_minor * $quantity)
) {
return max(0, $fallback);
}
$subtotal += $price_minor * $quantity;
}
if ($subtotal <= 0) {
return round($fallback, 2);
return max(0, $fallback);
}
return round($subtotal, 2);
return $subtotal;
}
private function cash_minor(string $value): int {
try {
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
} catch (\InvalidArgumentException $e) {
return 0;
}
}
+199 -62
View File
@@ -41,13 +41,19 @@ trait OrderRepositoryCore {
$note = sanitize_textarea_field($note_raw);
}
$row = [
$currency = sanitize_key((string) ($data['currency'] ?? 'CNY'));
$amount = $this->normalize_order_amount($data['amount'] ?? null, $currency);
if ($amount === null) {
return '';
}
$row = [
'order_number' => $order_number,
'user_id' => (int) ($data['user_id'] ?? 0),
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
'product_id' => (int) ($data['product_id'] ?? 0),
'amount' => (float) ($data['amount'] ?? 0),
'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')),
'amount' => $amount,
'currency' => $currency,
'status' => \AKSRT\Core\DomainModel::normalize_order_status((string) ($data['status'] ?? \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING)),
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
'payment_transaction_id' => sanitize_text_field((string) ($data['payment_transaction_id'] ?? '')),
@@ -55,7 +61,7 @@ trait OrderRepositoryCore {
'paid_at' => $data['paid_at'] ?? null,
'note' => $note,
];
$formats = ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
$formats = ['%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
// Pending orders always receive an expiry. Explicit caller values remain
// supported for compatibility, while new flows use the shared runtime policy.
@@ -67,6 +73,11 @@ trait OrderRepositoryCore {
$formats[] = '%s';
}
if (!empty($data['request_key'])) {
$row['request_key'] = substr(sanitize_text_field((string) $data['request_key']), 0, 191);
$formats[] = '%s';
}
$wpdb->insert($this->table(), $row, $formats);
if ($wpdb->insert_id) {
@@ -96,6 +107,19 @@ trait OrderRepositoryCore {
return null;
}
public function get_by_request_key(string $request_key): ?array {
global $wpdb;
$request_key = substr(sanitize_text_field($request_key), 0, 191);
if ($request_key === '') {
return null;
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE request_key = %s",
$request_key
), ARRAY_A);
return is_array($row) ? $this->normalize_row($row) : null;
}
/**
@@ -264,12 +288,92 @@ trait OrderRepositoryCore {
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
global $wpdb;
$preflight = $this->get($order_number);
if (!$preflight) {
return false;
}
if (
$new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID
&& sanitize_key((string) ($preflight['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
&& class_exists('\AKSRT\Credits\BalanceService')
) {
return \AKSRT\Credits\BalanceService::get_instance()->complete_recharge_payment(
$preflight,
sanitize_key((string) ($extra['payment_method'] ?? $preflight['payment_method'] ?? '')),
sanitize_text_field((string) ($extra['payment_transaction_id'] ?? $preflight['payment_transaction_id'] ?? '')),
(string) ($extra['note'] ?? '')
);
}
$ledger_active = class_exists('\AKSRT\Ledger\LedgerService')
&& \AKSRT\Ledger\LedgerService::get_instance()->transaction_active();
$root_call = $this->status_transaction_depth === 0;
if ($root_call) {
$this->status_after_commit_events = [];
}
$owns_transaction = $root_call && !$ledger_active;
if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
return false;
}
$this->status_transaction_depth++;
try {
$result = $this->update_status_in_transaction($order_number, $new_status, $extra);
if (!$result) {
if ($owns_transaction) {
$wpdb->query('ROLLBACK');
}
if ($root_call) {
$this->status_after_commit_events = [];
}
return false;
}
if ($owns_transaction && $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
$this->status_after_commit_events = [];
return false;
}
if ($root_call) {
$events = $this->status_after_commit_events;
$this->status_after_commit_events = [];
if ($owns_transaction) {
$this->dispatch_status_events($events);
} elseif ($ledger_active) {
\AKSRT\Ledger\LedgerService::get_instance()->after_commit(function () use ($events): void {
$this->dispatch_status_events($events);
});
}
}
return true;
} catch (\Throwable $e) {
if ($owns_transaction) {
$wpdb->query('ROLLBACK');
}
if ($root_call) {
$this->status_after_commit_events = [];
}
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT Order] transactional status update failed: ' . $e->getMessage());
}
return false;
} finally {
$this->status_transaction_depth--;
}
}
private function update_status_in_transaction(string $order_number, string $new_status, array $extra = []): bool {
global $wpdb;
$valid = \AKSRT\Core\DomainModel::order_status_labels();
if (!isset($valid[$new_status])) return false;
$order = $this->get($order_number);
$order_row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
$order_number
), ARRAY_A);
$order = is_array($order_row) ? $this->normalize_row($order_row) : null;
if (!$order) return false;
$current_status = $order['status'] ?? 'created';
@@ -370,8 +474,11 @@ trait OrderRepositoryCore {
return is_array($latest) && ($latest['status'] ?? '') === $new_status;
}
if ($result > 0) {
do_action('aksrt_order_status_changed', $order_number, $new_status, array_merge($order, $update_data));
if ($result > 0) {
$this->status_after_commit_events[] = [
'hook' => 'aksrt_order_status_changed',
'args' => [$order_number, $new_status, array_merge($order, $update_data)],
];
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
// 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
@@ -398,19 +505,60 @@ trait OrderRepositoryCore {
}
}
if (class_exists('\AKSRT\Payment\CommissionLedger')) {
$commission = \AKSRT\Payment\CommissionLedger::get_instance()->record_order_transition(
$order_number,
$new_status,
$paid_order
);
if (is_wp_error($commission)) {
return false;
}
}
$product_type = sanitize_key((string) ($paid_order['product_type'] ?? ''));
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE && class_exists('\AKSRT\Content\Pay')) {
$fulfilled = \AKSRT\Content\Pay::get_instance()->fulfill_paid_order($order_number, $paid_order);
if (is_wp_error($fulfilled)) {
return false;
}
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP && class_exists('\AKSRT\Content\VipService')) {
$fulfilled = \AKSRT\Content\VipService::get_instance()->fulfill_paid_order($order_number, $paid_order);
if (is_wp_error($fulfilled)) {
return false;
}
}
/**
* 订单到账。
*
* @param string $order_number 订单号
* @param array $paid_order 订单数据(status=paid)
*/
do_action('aksrt_order_paid', $order_number, $paid_order);
$this->status_after_commit_events[] = [
'hook' => 'aksrt_order_paid',
'args' => [$order_number, $paid_order],
];
}
}
return $result !== false;
}
/** @param array<int,array{hook:string,args:array<int,mixed>}> $events */
private function dispatch_status_events(array $events): void {
foreach ($events as $event) {
try {
do_action((string) $event['hook'], ...$event['args']);
} catch (\Throwable $e) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT Order Event] ' . $e->getMessage());
}
}
}
}
/**
@@ -494,7 +642,7 @@ trait OrderRepositoryCore {
'refunding' => (int) ($row['refunding'] ?? 0),
'cancelled' => (int) ($row['cancelled'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'revenue' => (float) ($row['revenue'] ?? 0),
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
];
}
@@ -516,57 +664,6 @@ trait OrderRepositoryCore {
/* ------------------------------------------------------------------
* Migration bridge: option → table
* ----------------------------------------------------------------*/
/**
* 一次性迁移:把 option 中的订单搬到表中。
* 幂等 — 已存在的 order_number 不会重复插入。
*/
public function migrate_from_option(): int {
global $wpdb;
$orders = get_option('aksrt_orders', []);
if (!is_array($orders) || empty($orders)) return 0;
$migrated = 0;
foreach ($orders as $order_number => $data) {
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE order_number = %s",
$order_number
));
if ($existing) continue;
$wpdb->insert($this->table(), [
'order_number' => $order_number,
'user_id' => (int) ($data['user_id'] ?? 0),
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
'product_id' => (int) ($data['product_id'] ?? 0),
'amount' => (float) ($data['amount'] ?? 0),
'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')),
'status' => \AKSRT\Core\DomainModel::normalize_order_status((string) ($data['status'] ?? \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING)),
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
'created_at' => $data['created_at'] ?? current_time('mysql'),
'paid_at' => $data['paid_at'] ?? null,
'delivered_at' => $data['delivered_at'] ?? null,
'completed_at' => $data['completed_at'] ?? null,
'cancelled_at' => $data['cancelled_at'] ?? null,
'refunded_at' => $data['refunded_at'] ?? null,
'expires_at' => $data['expires_at'] ?? null,
'note' => '',
], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
if ($wpdb->insert_id) $migrated++;
}
update_option(\AKSRT\Core\DomainModel::ORDER_MIGRATED_OPTION, time());
return $migrated;
}
/* ------------------------------------------------------------------
* Internal helpers
* ----------------------------------------------------------------*/
@@ -620,7 +717,10 @@ trait OrderRepositoryCore {
$row['id'] = (int) $row['id'];
$row['user_id'] = (int) $row['user_id'];
$row['product_id'] = (int) $row['product_id'];
$row['amount'] = (float) $row['amount'];
$row['amount'] = $this->normalize_order_amount((string) ($row['amount'] ?? ''), (string) ($row['currency'] ?? ''), true);
if ($row['amount'] === null) {
$row['amount'] = (string) ($row['currency'] ?? '') === 'points' ? 0 : '0.00';
}
$row['status'] = \AKSRT\Core\DomainModel::normalize_order_status((string) ($row['status'] ?? ''));
$row['meta_payload'] = [];
@@ -639,6 +739,39 @@ trait OrderRepositoryCore {
return $row;
}
private function normalize_order_amount(mixed $value, string $currency, bool $from_database = false): string|int|null {
if ($currency === 'points') {
$raw = is_int($value) ? (string) $value : (is_string($value) ? trim($value) : '');
if ($from_database && preg_match('/^(0|[1-9]\d*)\.0+$/', $raw, $matches)) {
$raw = $matches[1];
}
if (!preg_match('/^(0|[1-9]\d*)$/', $raw)) {
return null;
}
$max = (string) PHP_INT_MAX;
if (strlen($raw) > strlen($max) || (strlen($raw) === strlen($max) && strcmp($raw, $max) > 0)) {
return null;
}
return (int) $raw;
}
if (!is_string($value)) {
return null;
}
try {
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->decimal();
} catch (\InvalidArgumentException $e) {
return null;
}
}
private function cash_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} catch (\InvalidArgumentException $e) {
return '0.00';
}
}
private function find_latest_success_order(int $user_id, string $product_type, int $product_id = 0, bool $ignore_product_id = false): ?array {
@@ -676,7 +809,11 @@ trait OrderRepositoryCore {
* Column + major gateways (WeChat out_trade_no) cap at 32 chars, so the
* prefix is trimmed to leave room for {YmdHis}{4 digits} (=18).
*/
private function generate_order_number(): string {
public function allocate_order_number(): string {
return $this->generate_order_number();
}
private function generate_order_number(): string {
$prefix = 'AKSRT';
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_order_settings')) {
$settings = \AKSRT\Settings\Settings::get_order_settings();
+22 -36
View File
@@ -95,10 +95,17 @@ class PaymentOrchestrator {
}
}
$order['_ledger_paid_note'] = (string) ($opts['paid_note'] ?? __('用户完成支付。', 'aksrt'));
$result = $gateway->create_payment($order);
if (empty($result['success'])) {
$message = (string) ($result['message'] ?? __('支付失败。', 'aksrt'));
return self::fail(self::failure_code($method, $message), $message);
$code = sanitize_key((string) ($result['code'] ?? ''));
$status = (int) ($result['status'] ?? 0);
return self::fail(
$code !== '' ? $code : self::failure_code($method, $message),
$message,
$status >= 400 && $status <= 599 ? $status : 400
);
}
$payment_url = (string) ($result['payment_url'] ?? '');
@@ -116,8 +123,18 @@ class PaymentOrchestrator {
];
}
// Synchronous gateway (balance): funds already moved inside create_payment().
// Synchronous asset gateways commit the debit and paid transition together.
$transaction_id = sanitize_text_field((string) ($result['transaction_id'] ?? ''));
if (!empty($result['order_committed'])) {
return [
'success' => true,
'redirect' => false,
'transaction_id' => $transaction_id,
'ledger_transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
'replayed' => !empty($result['replayed']),
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
];
}
$paid = $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $method,
'payment_transaction_id' => $transaction_id,
@@ -128,13 +145,7 @@ class PaymentOrchestrator {
'require_transition' => true,
]);
if (!$paid) {
$rolled_back = self::rollback($gateway, $order_number, (float) ($order['amount'] ?? 0), $method);
return self::fail(
'aksrt_order_confirm_failed',
$rolled_back
? __('订单状态已变化,扣款已退回。', 'aksrt')
: __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')
);
return self::fail('aksrt_order_confirm_failed', __('订单状态确认失败。', 'aksrt'));
}
return [
@@ -167,31 +178,6 @@ class PaymentOrchestrator {
return ['gateway' => $gateway];
}
/**
* Compensate a synchronous deduction that could not be committed.
*/
private static function rollback(GatewayInterface $gateway, string $order_number, float $amount, string $method): bool {
if ($amount <= 0) {
return true;
}
$request_id = 'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24);
try {
$compensation = $gateway->refund($order_number, $amount, $request_id);
} catch (\Throwable $e) {
$compensation = ['success' => false, 'message' => $e->getMessage()];
}
if (!empty($compensation['success'])) {
return true;
}
error_log(sprintf(
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
$order_number,
$method,
(string) ($compensation['message'] ?? 'unknown error')
));
return false;
}
private static function failure_code(string $method, string $message): string {
if ($method === 'balance' && (str_contains($message, '余额') || str_contains($message, '不足'))) {
return 'aksrt_balance_insufficient';
@@ -202,7 +188,7 @@ class PaymentOrchestrator {
/**
* @return array{success:false,code:string,message:string}
*/
private static function fail(string $code, string $message): array {
return ['success' => false, 'code' => $code, 'message' => $message];
private static function fail(string $code, string $message, int $status = 400): array {
return ['success' => false, 'code' => $code, 'status' => $status, 'message' => $message];
}
}
-235
View File
@@ -1,235 +0,0 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class Rebate {
private static $instance = null;
const TYPE_ORDER = 'order';
const STATUS_PENDING = 'pending';
const STATUS_SETTLED = 'settled';
const STATUS_CANCELLED = 'cancelled';
private function refresh_user_metric(int $user_id): void {
if ($user_id <= 0) {
return;
}
global $wpdb;
$total = (float) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(amount), 0) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending', 'settled')",
$user_id
));
update_user_meta($user_id, 'aksrt_pay_rebate_sum', round($total, 2));
}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function table(): string {
return \AKSRT\Core\DBMigrator::table('rebate_log');
}
/**
* 创建返佣记录
*/
public function create(array $data): int {
global $wpdb;
$user_id = (int) ($data['user_id'] ?? 0);
$order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
$source_user_id = (int) ($data['source_user_id'] ?? 0);
$amount = (float) ($data['amount'] ?? 0);
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_ORDER));
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
if ($user_id <= 0 || $amount <= 0) {
return 0;
}
$valid_types = [self::TYPE_ORDER];
if (!in_array($type, $valid_types, true)) {
$type = self::TYPE_ORDER;
}
$result = $wpdb->insert($this->table(), [
'user_id' => $user_id,
'order_number' => $order_number,
'source_user_id' => $source_user_id > 0 ? $source_user_id : null,
'amount' => $amount,
'type' => $type,
'status' => self::STATUS_PENDING,
'created_at' => current_time('mysql'),
'note' => $note,
]);
$id = $result ? (int) $wpdb->insert_id : 0;
if ($id > 0) {
do_action('aksrt_pay_rebate_added', $user_id, $amount, [
'rebate_id' => $id,
'order_number' => $order_number,
'type' => $type,
]);
}
return $id;
}
/**
* 结算返佣
*/
public function settle(int $id): bool {
global $wpdb;
if (!class_exists('\AKSRT\Credits\Balance')) {
return false;
}
$wpdb->query('START TRANSACTION');
try {
$row = $wpdb->get_row($wpdb->prepare(
"SELECT id, user_id, amount, order_number FROM {$this->table()} WHERE id = %d AND status = %s FOR UPDATE",
$id,
self::STATUS_PENDING
), ARRAY_A);
if (!is_array($row)) {
$wpdb->query('ROLLBACK');
return false;
}
$credited = \AKSRT\Credits\Balance::add(
(int) $row['user_id'],
(float) $row['amount'],
'rebate',
['type' => 'rebate', 'id' => (int) $row['id']],
sprintf(__('推广返佣结算(订单 %s)', 'aksrt'), (string) $row['order_number'])
);
if (is_wp_error($credited)) {
$wpdb->query('ROLLBACK');
return false;
}
$settled = $wpdb->update(
$this->table(),
[
'status' => self::STATUS_SETTLED,
'settled_at' => current_time('mysql'),
],
['id' => $id, 'status' => self::STATUS_PENDING],
['%s', '%s'],
['%d', '%s']
);
if ($settled !== 1) {
$wpdb->query('ROLLBACK');
return false;
}
$wpdb->query('COMMIT');
$this->refresh_user_metric((int) $row['user_id']);
return true;
} catch (\Throwable $e) {
$wpdb->query('ROLLBACK');
return false;
}
}
/**
* 取消返佣
*/
public function cancel(int $id): bool {
global $wpdb;
$updated = $wpdb->update($this->table(), [
'status' => self::STATUS_CANCELLED,
], ['id' => $id, 'status' => self::STATUS_PENDING]);
if ($updated === 1) {
$row = $wpdb->get_row($wpdb->prepare("SELECT user_id FROM {$this->table()} WHERE id = %d", $id), ARRAY_A);
$this->refresh_user_metric((int) ($row['user_id'] ?? 0));
}
return $updated === 1;
}
/**
* 获取返佣列表
*/
public function get_list(array $args = []): array {
global $wpdb;
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$status = sanitize_key((string) ($args['status'] ?? ''));
$user_id = (int) ($args['user_id'] ?? 0);
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
if ($user_id > 0) {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
$total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql);
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return [
'items' => $items ?: [],
'total' => $total,
'page' => floor($offset / $limit) + 1,
'max_pages' => ceil($total / $limit),
];
}
/**
* 获取统计
*/
public function get_stats(int $user_id = 0): array {
global $wpdb;
$table = $this->table();
$where = '';
$params = [];
if ($user_id > 0) {
$where = 'WHERE user_id = %d';
$params[] = $user_id;
}
$sql = "SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) as pending_amount,
SUM(CASE WHEN status = 'settled' THEN 1 ELSE 0 END) as settled,
SUM(CASE WHEN status IN ('pending', 'settled') THEN amount ELSE 0 END) as total_amount,
SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END) as settled_amount
FROM {$table} {$where}";
$row = $params
? $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A)
: $wpdb->get_row($sql, ARRAY_A);
return [
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'pending_amount' => (float) ($row['pending_amount'] ?? 0),
'settled' => (int) ($row['settled'] ?? 0),
'total_amount' => (float) ($row['total_amount'] ?? 0),
'settled_amount' => (float) ($row['settled_amount'] ?? 0),
];
}
}
+55 -65
View File
@@ -175,81 +175,71 @@ final class ReferralAttribution {
);
return;
}
if (!class_exists('\AKSRT\Credits\Credits')) {
return;
}
if (!empty($settings['require_verify']) && !$this->is_verified($invitee_id)) {
return;
}
$wpdb->query('START TRANSACTION');
try {
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE invitee_id = %d AND status = 'bound' FOR UPDATE",
$invitee_id
), ARRAY_A);
if (!is_array($row) || (string) ($row['reward_status'] ?? 'pending') !== 'pending') {
$wpdb->query('ROLLBACK');
return;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE invitee_id = %d AND status = 'bound'",
$invitee_id
), ARRAY_A);
if (!is_array($row) || (string) ($row['reward_status'] ?? 'pending') !== 'pending') {
return;
}
$relation_id = (int) $row['id'];
$promoter_id = (int) $row['promoter_id'];
if ($promoter_id <= 0) {
$wpdb->update($table, ['reward_status' => 'skipped'], ['id' => $relation_id, 'reward_status' => 'pending'], ['%s'], ['%d', '%s']);
return;
}
$promoter_amount = max(0, (int) ($settings['promoter_amount'] ?? 0));
$referred_amount = max(0, (int) ($settings['referred_amount'] ?? 0));
$entries = [];
if ($promoter_amount > 0) {
$entries[] = ['user_id' => $promoter_id, 'asset' => \AKSRT\Ledger\AssetType::POINTS->value, 'delta_minor' => $promoter_amount, 'entry_type' => 'credit', 'counterparty_user_id' => $invitee_id, 'remark' => __('推广注册奖励', 'aksrt')];
}
if ($referred_amount > 0) {
$entries[] = ['user_id' => $invitee_id, 'asset' => \AKSRT\Ledger\AssetType::POINTS->value, 'delta_minor' => $referred_amount, 'entry_type' => 'credit', 'counterparty_user_id' => $promoter_id, 'remark' => __('推广注册新用户奖励', 'aksrt')];
}
if ($entries === []) {
$wpdb->update($table, ['reward_status' => 'rewarded', 'rewarded_at' => current_time('mysql', true)], ['id' => $relation_id, 'reward_status' => 'pending'], ['%s', '%s'], ['%d', '%s']);
return;
}
$level_points = max(0, (int) ($settings['level_points'] ?? 0));
$command = new \AKSRT\Ledger\LedgerCommand(
\AKSRT\Ledger\OperationKey::deterministic('referral-reward', [$relation_id]),
'referral',
$entries,
'referral',
(string) $relation_id,
0,
['invitee_id' => $invitee_id, 'promoter_id' => $promoter_id]
);
$guard = function () use ($wpdb, $table, $relation_id, $settings, $row) {
$locked = $wpdb->get_row($wpdb->prepare("SELECT reward_status FROM {$table} WHERE id = %d FOR UPDATE", $relation_id), ARRAY_A);
if (!is_array($locked) || (string) $locked['reward_status'] !== 'pending') {
return new \WP_Error('aksrt_referral_state', __('推广奖励状态已变化', 'aksrt'), ['status' => 409]);
}
$relation_id = (int) $row['id'];
$promoter_id = (int) $row['promoter_id'];
if ($promoter_id <= 0 || !$this->ip_daily_check($row, $settings)) {
$wpdb->update($table, ['reward_status' => 'skipped'], ['id' => $relation_id], ['%s'], ['%d']);
$wpdb->query('COMMIT');
return;
}
$claimed = $wpdb->update(
$table,
['reward_status' => 'rewarding'],
['id' => $relation_id, 'reward_status' => 'pending'],
['%s'],
['%d', '%s']
);
if ($claimed !== 1) {
$wpdb->query('ROLLBACK');
return;
}
$ref = ['type' => 'referral', 'id' => $relation_id];
$promoter_amount = max(0, (int) ($settings['promoter_amount'] ?? 0));
$referred_amount = max(0, (int) ($settings['referred_amount'] ?? 0));
if ($promoter_amount > 0) {
$result = \AKSRT\Credits\Credits::add($promoter_id, $promoter_amount, 'referral', $ref, __('推广注册奖励', 'aksrt'), false);
if (is_wp_error($result)) {
throw new \RuntimeException($result->get_error_message());
}
}
if ($referred_amount > 0) {
$result = \AKSRT\Credits\Credits::add($invitee_id, $referred_amount, 'referral_new_user', $ref, __('推广注册新用户奖励', 'aksrt'), false);
if (is_wp_error($result)) {
throw new \RuntimeException($result->get_error_message());
}
}
$level_points = max(0, (int) ($settings['level_points'] ?? 0));
return $this->ip_daily_check($row, $settings)
? true
: new \WP_Error('aksrt_referral_daily_limit', __('当前网络今日推广奖励次数已达上限', 'aksrt'), ['status' => 409]);
};
$mutation = function () use ($wpdb, $table, $relation_id, $level_points, $promoter_id) {
if ($level_points > 0 && class_exists('\AKSRT\User\Level')) {
\AKSRT\User\Level::add($promoter_id, 'referral', $level_points, true);
}
$rewarded_at = current_time('mysql', true);
$updated = $wpdb->update(
$table,
['reward_status' => 'rewarded', 'rewarded_at' => $rewarded_at],
['id' => $relation_id, 'reward_status' => 'rewarding'],
['reward_status' => 'rewarded', 'rewarded_at' => current_time('mysql', true)],
['id' => $relation_id, 'reward_status' => 'pending'],
['%s', '%s'],
['%d', '%s']
);
if ($updated !== 1) {
throw new \RuntimeException('Unable to finalize referral reward.');
}
$wpdb->query('COMMIT');
return $updated === 1 ? true : new \WP_Error('aksrt_referral_update', __('推广奖励状态更新失败', 'aksrt'), ['status' => 500]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->post($command, $guard, $mutation);
if (!is_wp_error($result) && !$result->replayed) {
do_action('aksrt_referral_rewarded', $relation_id, $promoter_id, $invitee_id);
} catch (\Throwable $e) {
$wpdb->query('ROLLBACK');
}
}
@@ -408,7 +398,7 @@ final class ReferralAttribution {
'points_reward' => !empty($reward['enabled']),
'affiliate' => !empty($affiliate['enabled']),
],
'current_ratio' => (float) \AKSRT\Settings\Settings::affiliate_ratio_for_user($user_id),
'current_ratio' => (int) \AKSRT\Settings\Settings::affiliate_ratio_for_user($user_id),
'ratios' => (array) ($affiliate['ratios'] ?? []),
'order_types' => (array) ($affiliate['order_types'] ?? ['membership', 'content']),
'rules' => [
@@ -655,12 +645,12 @@ final class ReferralAttribution {
$today_utc = (new \DateTimeImmutable('today', wp_timezone()))
->setTimezone(new \DateTimeZone('UTC'))
->format('Y-m-d H:i:s');
$count = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE registration_ip = %s AND reward_status = 'rewarded' AND rewarded_at >= %s FOR UPDATE",
$ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM {$table} WHERE registration_ip = %s AND reward_status = 'rewarded' AND rewarded_at >= %s ORDER BY id ASC FOR UPDATE",
$ip,
$today_utc
));
return $count < $cap;
return count($ids) < $cap;
}
private function client_ip(): string {
+215 -118
View File
@@ -43,14 +43,6 @@ class Revenue {
return 0;
}
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1",
$order_number
));
if ((int) $existing > 0) {
return (int) $existing;
}
$meta = $this->decode_order_meta($order);
if (empty($meta['content_pay'])) {
return 0;
@@ -71,11 +63,8 @@ class Revenue {
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
$is_points = $currency === 'points' || ($meta['pay_modo'] ?? '') === 'points';
$currency = $is_points ? 'points' : 'CNY';
$original_amount = round((float) ($order['amount'] ?? $meta['amount'] ?? $meta['payable'] ?? 0), 2);
if ($is_points) {
$original_amount = (float) ceil($original_amount);
}
if ($original_amount <= 0) {
$original_minor = $this->order_amount_minor($order['amount'] ?? $meta['amount'] ?? $meta['payable'] ?? null, $is_points);
if ($original_minor === null || $original_minor <= 0) {
return 0;
}
@@ -86,12 +75,32 @@ class Revenue {
if ($ratio <= 0) {
return 0;
}
$amount = $is_points
? (float) max(1, (int) floor($original_amount * $ratio / 100))
: round($original_amount * $ratio / 100, 2);
if ($amount <= 0) {
$amount_minor = $this->percentage_minor($original_minor, $ratio, !$is_points);
if ($is_points) {
$amount_minor = max(1, $amount_minor);
}
if ($amount_minor <= 0) {
return 0;
}
$original_amount = $this->stored_amount($original_minor, $is_points);
$amount = $this->stored_amount($amount_minor, $is_points);
$expected = [
'author_id' => $author_id,
'order_number' => $order_number,
'post_id' => $post_id,
'buyer_id' => $buyer_id,
'currency' => $currency,
'original_minor' => $original_minor,
'ratio' => $ratio,
'amount_minor' => $amount_minor,
];
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1",
$order_number
), ARRAY_A);
if (is_array($existing)) {
return $this->revenue_matches($existing, $expected) ? (int) $existing['id'] : -1;
}
$inserted = $wpdb->insert($this->table(), [
'author_id' => $author_id,
@@ -106,26 +115,19 @@ class Revenue {
'type' => 'order',
'status' => 'pending',
'created_at' => current_time('mysql'),
], ['%d', '%s', '%d', '%d', '%s', '%f', '%f', '%d', '%f', '%s', '%s', '%s']);
], ['%d', '%s', '%d', '%d', '%s', $is_points ? '%d' : '%s', '%d', '%d', $is_points ? '%d' : '%s', '%s', '%s', '%s']);
if ($inserted === false) {
// Duplicate callbacks are a successful no-op by contract.
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1",
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1",
$order_number
));
return (int) $existing;
), ARRAY_A);
return is_array($existing) && $this->revenue_matches($existing, $expected)
? (int) $existing['id']
: -1;
}
$id = (int) $wpdb->insert_id;
if ($id > 0) {
do_action('aksrt_creator_income_added', $author_id, $amount, [
'order_number' => $order_number,
'currency' => $currency,
'revenue_id' => $id,
]);
}
return $id;
return (int) $wpdb->insert_id;
}
/**
@@ -134,11 +136,22 @@ class Revenue {
* @param int|array<int,int> $record_ids
* @param int $actor_id
*/
public function settle($record_ids, int $actor_id = 0): bool {
$result = $this->settle_batch($record_ids, $actor_id);
public function settle($record_ids, int $actor_id = 0, string $operation_key = ''): bool {
if (!is_array($record_ids)) {
return $this->settle_result((int) $record_ids, $actor_id, $operation_key) instanceof \AKSRT\Ledger\LedgerResult;
}
$result = $this->settle_batch($record_ids, $actor_id, $operation_key);
return !empty($result['settled']) && empty($result['failed']);
}
/** @return \AKSRT\Ledger\LedgerResult|\WP_Error */
public function settle_result(int $revenue_id, int $actor_id = 0, string $operation_key = '') {
if ($revenue_id <= 0 || !$this->is_enabled()) {
return new \WP_Error('aksrt_revenue_invalid', __('收益记录无效或结算功能未启用', 'aksrt'), ['status' => 400]);
}
return $this->settle_one($revenue_id, $actor_id, $operation_key);
}
/**
* Settle a batch while preserving per-record outcomes for operational feedback.
*
@@ -146,7 +159,7 @@ class Revenue {
* @param int $actor_id
* @return array{settled:array<int,int>,failed:array<int,int>}
*/
public function settle_batch($record_ids, int $actor_id = 0): array {
public function settle_batch($record_ids, int $actor_id = 0, string $operation_key = ''): array {
$ids = is_array($record_ids) ? $record_ids : [$record_ids];
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
if (!$ids || !$this->is_enabled()) {
@@ -155,8 +168,11 @@ class Revenue {
$settled = [];
$failed = [];
foreach ($ids as $revenue_id) {
if ($this->settle_one($revenue_id, $actor_id)) {
foreach ($ids as $revenue_id) {
$item_key = $operation_key !== '' && count($ids) === 1
? $operation_key
: ($operation_key !== '' ? \AKSRT\Ledger\OperationKey::deterministic('revenue-settle-item', [$operation_key, $revenue_id]) : '');
if ($this->settle_one($revenue_id, $actor_id, $item_key) instanceof \AKSRT\Ledger\LedgerResult) {
$settled[] = $revenue_id;
} else {
$failed[] = $revenue_id;
@@ -200,70 +216,69 @@ class Revenue {
return $settled;
}
private function settle_one(int $revenue_id, int $actor_id): bool {
/** @return \AKSRT\Ledger\LedgerResult|\WP_Error */
private function settle_one(int $revenue_id, int $actor_id, string $operation_key = '') {
global $wpdb;
$wpdb->query('START TRANSACTION');
try {
$revenue = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d AND status = %s FOR UPDATE",
$revenue_id,
'pending'
), ARRAY_A);
if (!is_array($revenue)) {
$wpdb->query('COMMIT');
return false;
}
$ref = ['type' => 'revenue', 'id' => $revenue_id, 'currency' => (string) ($revenue['currency'] ?? 'CNY')];
$remark = sprintf(__('创作收益结算(订单 %s)', 'aksrt'), (string) ($revenue['order_number'] ?? ''));
if (!$this->has_balance_entry((int) $revenue['author_id'], $ref)) {
$credited = (string) ($revenue['currency'] ?? '') === 'points'
? (class_exists('\\AKSRT\\Credits\\Credits') ? \AKSRT\Credits\Credits::add((int) $revenue['author_id'], (int) ceil((float) $revenue['amount']), 'creator_income', $ref, $remark) : new \WP_Error('credits_unavailable'))
: (class_exists('\\AKSRT\\Credits\\Balance') ? \AKSRT\Credits\Balance::add((int) $revenue['author_id'], (float) $revenue['amount'], 'creator_income', $ref, $remark) : new \WP_Error('balance_unavailable'));
if (is_wp_error($credited)) {
$wpdb->query('ROLLBACK');
return false;
}
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, settled_at = %s, audit_json = %s WHERE id = %d AND status = %s",
'settled',
current_time('mysql'),
$this->audit_json('settled', '', $actor_id),
$revenue_id,
'pending'
));
if ($updated !== 1) {
throw new \RuntimeException('revenue_settle_failed');
}
$wpdb->query('COMMIT');
return true;
} catch (\Throwable $e) {
$wpdb->query('ROLLBACK');
return false;
}
}
private function has_balance_entry(int $user_id, array $ref): bool {
global $wpdb;
if ($user_id <= 0 || (int) ($ref['id'] ?? 0) <= 0) {
return false;
}
$table = (string) ($ref['currency'] ?? '') === 'points'
? \AKSRT\Core\DBMigrator::table('credit_log')
: \AKSRT\Core\DBMigrator::table('balance_log');
if ($table === '') {
return false;
}
return (bool) $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND ref_type = %s AND ref_id = %d AND reason = %s LIMIT 1",
$user_id,
'revenue',
(int) $ref['id'],
'creator_income'
));
}
$revenue = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $revenue_id), ARRAY_A);
if (!is_array($revenue)) {
return new \WP_Error('aksrt_revenue_missing', __('收益记录不存在', 'aksrt'), ['status' => 404]);
}
$asset = (string) ($revenue['currency'] ?? '') === 'points'
? \AKSRT\Ledger\AssetType::POINTS
: \AKSRT\Ledger\AssetType::BALANCE;
try {
$amount_minor = $this->database_amount_minor((string) $revenue['amount'], $asset === \AKSRT\Ledger\AssetType::POINTS);
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_revenue_amount', __('收益金额无效', 'aksrt'), ['status' => 500]);
}
if ($amount_minor === null || $amount_minor <= 0 || (int) ($revenue['author_id'] ?? 0) <= 0) {
return new \WP_Error('aksrt_revenue_amount', __('收益金额或收款用户无效', 'aksrt'), ['status' => 500]);
}
$expected = [
'author_id' => (int) $revenue['author_id'],
'currency' => (string) $revenue['currency'],
'amount' => (string) $revenue['amount'],
];
$guard = function () use ($revenue_id, $expected) {
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE", $revenue_id), ARRAY_A);
if (!is_array($current)
|| (string) ($current['status'] ?? '') !== 'pending'
|| (int) ($current['author_id'] ?? 0) !== $expected['author_id']
|| (string) ($current['currency'] ?? '') !== $expected['currency']
|| (string) ($current['amount'] ?? '') !== $expected['amount']
) {
return new \WP_Error('aksrt_revenue_state', __('收益记录状态已变化', 'aksrt'), ['status' => 409]);
}
return true;
};
$mutation = function () use ($revenue_id, $actor_id) {
global $wpdb;
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, settled_at = %s, audit_json = %s WHERE id = %d AND status = %s",
'settled',
current_time('mysql'),
$this->audit_json('settled', '', $actor_id),
$revenue_id,
'pending'
));
return $updated === 1 ? true : new \WP_Error('aksrt_revenue_state', __('收益记录状态已变化', 'aksrt'), ['status' => 409]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$expected['author_id'],
$asset,
$amount_minor,
$operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('revenue-settle', [$revenue_id]),
'creator_income',
['type' => 'revenue', 'id' => $revenue_id],
sprintf(__('创作收益结算(订单 %s)', 'aksrt'), (string) ($revenue['order_number'] ?? '')),
$actor_id,
['currency' => $expected['currency']],
$mutation,
$guard
);
return $result;
}
private function is_enabled(): bool {
return class_exists('\\AKSRT\\Settings\\Settings')
@@ -302,10 +317,11 @@ class Revenue {
$offset = max(0, (int) ($args['offset'] ?? 0));
$params[] = $limit;
$params[] = $offset;
return (array) $wpdb->get_results($wpdb->prepare(
$rows = (array) $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d",
$params
), ARRAY_A);
return array_map([$this, 'normalize_revenue_row'], $rows);
}
/**
@@ -316,10 +332,10 @@ class Revenue {
public function get_user_income_view(int $user_id, array $args = []): array {
global $wpdb;
$empty_stats = [
'today' => ['CNY' => 0.0, 'points' => 0.0],
'total' => ['CNY' => 0.0, 'points' => 0.0],
'pending' => ['CNY' => 0.0, 'points' => 0.0],
'settled' => ['CNY' => 0.0, 'points' => 0.0],
'today' => ['CNY' => '0.00', 'points' => 0],
'total' => ['CNY' => '0.00', 'points' => 0],
'pending' => ['CNY' => '0.00', 'points' => 0],
'settled' => ['CNY' => '0.00', 'points' => 0],
];
if ($user_id <= 0) {
return ['stats' => $empty_stats, 'items' => [], 'total' => 0, 'max_pages' => 1, 'page' => 1, 'per_page' => 20];
@@ -350,10 +366,11 @@ class Revenue {
if (!isset($empty_stats['total'][$key])) {
continue;
}
$empty_stats['today'][$key] = (float) ($row['today_amount'] ?? 0);
$empty_stats['total'][$key] = (float) ($row['total_amount'] ?? 0);
$empty_stats['pending'][$key] = (float) ($row['pending_amount'] ?? 0);
$empty_stats['settled'][$key] = (float) ($row['settled_amount'] ?? 0);
$is_points = $key === 'points';
$empty_stats['today'][$key] = $this->display_amount((string) ($row['today_amount'] ?? '0'), $is_points);
$empty_stats['total'][$key] = $this->display_amount((string) ($row['total_amount'] ?? '0'), $is_points);
$empty_stats['pending'][$key] = $this->display_amount((string) ($row['pending_amount'] ?? '0'), $is_points);
$empty_stats['settled'][$key] = $this->display_amount((string) ($row['settled_amount'] ?? '0'), $is_points);
}
$status = sanitize_key((string) ($args['status'] ?? ''));
@@ -430,20 +447,20 @@ class Revenue {
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
$items_params = array_merge($params, [$limit, $offset]);
$items = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", $items_params), ARRAY_A);
$items = array_map([$this, 'normalize_revenue_row'], (array) $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", $items_params), ARRAY_A));
return ['items' => $items ?: [], 'total' => $total, 'page' => (int) floor($offset / $limit) + 1, 'max_pages' => (int) ceil(max(1, $total) / $limit)];
}
/**
* Return separate cash and points totals; amounts must never be added across units.
*
* @return array{cash:array{total:float,pending:float,settled:float,count:int},points:array{total:float,pending:float,settled:float,count:int}}
* @return array{cash:array{total:string,pending:string,settled:string,count:int},points:array{total:int,pending:int,settled:int,count:int}}
*/
public function get_stats(int $user_id = 0): array {
global $wpdb;
$empty = [
'cash' => ['total' => 0.0, 'pending' => 0.0, 'settled' => 0.0, 'count' => 0],
'points' => ['total' => 0.0, 'pending' => 0.0, 'settled' => 0.0, 'count' => 0],
'cash' => ['total' => '0.00', 'pending' => '0.00', 'settled' => '0.00', 'count' => 0],
'points' => ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0],
];
$where = ["status IN ('pending', 'settled')"];
$params = [];
@@ -460,24 +477,104 @@ class Revenue {
$rows = $wpdb->get_results($params ? $wpdb->prepare($stats_sql, $params) : $stats_sql, ARRAY_A);
foreach ((array) $rows as $row) {
$key = (string) ($row['currency'] ?? '') === 'points' ? 'points' : 'cash';
$is_points = $key === 'points';
$empty[$key] = [
'total' => (float) ($row['total'] ?? 0),
'pending' => (float) ($row['pending'] ?? 0),
'settled' => (float) ($row['settled'] ?? 0),
'total' => $this->display_amount((string) ($row['total'] ?? '0'), $is_points),
'pending' => $this->display_amount((string) ($row['pending'] ?? '0'), $is_points),
'settled' => $this->display_amount((string) ($row['settled'] ?? '0'), $is_points),
'count' => (int) ($row['count'] ?? 0),
];
}
return $empty;
}
private function resolve_share_ratio(string $currency, int $author_level): float {
private function resolve_share_ratio(string $currency, int $author_level): int {
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
$bucket = $currency === 'points' ? 'points' : 'cash';
$level_key = $author_level >= 2 ? 'vip2' : ($author_level === 1 ? 'vip1' : 'ordinary');
$ratio = $settings['ratios'][$bucket][$level_key] ?? 0;
return max(0, min(100, (float) $ratio));
return max(0, min(100, (int) $ratio));
}
private function order_amount_minor(mixed $value, bool $is_points): ?int {
if ($is_points) {
$raw = is_int($value) ? (string) $value : (is_string($value) ? trim($value) : '');
if (!preg_match('/^(0|[1-9]\d*)$/', $raw)) {
return null;
}
$max = (string) PHP_INT_MAX;
if (strlen($raw) > strlen($max) || (strlen($raw) === strlen($max) && strcmp($raw, $max) > 0)) {
return null;
}
return (int) $raw;
}
if (!is_string($value)) {
return null;
}
try {
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->minor();
} catch (\InvalidArgumentException $e) {
return null;
}
}
private function percentage_minor(int $base_minor, int $ratio, bool $round_half_up): int {
$whole = intdiv($base_minor, 100) * $ratio;
$remainder = ($base_minor % 100) * $ratio;
return $whole + intdiv($remainder + ($round_half_up ? 50 : 0), 100);
}
private function stored_amount(int $minor, bool $is_points): string|int {
return $is_points
? $minor
: \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $minor)->decimal();
}
private function database_amount_minor(string $value, bool $is_points): ?int {
if ($is_points) {
if (!preg_match('/^(0|[1-9]\d*)(?:\.0+)?$/', $value, $matches)) {
return null;
}
return $this->order_amount_minor($matches[1], true);
}
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->minor();
} catch (\InvalidArgumentException $e) {
return null;
}
}
/** @param array<string,mixed> $row @param array<string,mixed> $expected */
private function revenue_matches(array $row, array $expected): bool {
$is_points = $expected['currency'] === 'points';
return (int) ($row['author_id'] ?? 0) === $expected['author_id']
&& (string) ($row['order_number'] ?? '') === $expected['order_number']
&& (int) ($row['post_id'] ?? 0) === $expected['post_id']
&& (int) ($row['buyer_id'] ?? 0) === $expected['buyer_id']
&& (string) ($row['currency'] ?? '') === $expected['currency']
&& $this->database_amount_minor((string) ($row['original_amount'] ?? ''), $is_points) === $expected['original_minor']
&& (int) ($row['share_ratio'] ?? -1) === $expected['ratio']
&& $this->database_amount_minor((string) ($row['amount'] ?? ''), $is_points) === $expected['amount_minor'];
}
private function display_amount(string $value, bool $is_points): string|int {
$minor = $this->database_amount_minor($value, $is_points);
if ($minor === null) {
return $is_points ? 0 : '0.00';
}
return $this->stored_amount($minor, $is_points);
}
/** @param array<string,mixed> $row @return array<string,mixed> */
private function normalize_revenue_row(array $row): array {
$is_points = (string) ($row['currency'] ?? '') === 'points';
foreach (['original_amount', 'amount', 'reversal_amount'] as $field) {
$row[$field] = $this->display_amount((string) ($row[$field] ?? '0'), $is_points);
}
$row['share_ratio'] = (int) ($row['share_ratio'] ?? 0);
return $row;
}
private function decode_order_meta(array $order): array {
if (is_array($order['meta_payload'] ?? null)) {
return $order['meta_payload'];
+34 -7
View File
@@ -199,7 +199,7 @@ class UserOrders {
$view = [
'id' => (int) ($order['id'] ?? 0),
'order_number' => $order_number,
'amount' => (float) ($order['amount'] ?? 0),
'amount' => (string) ($order['amount'] ?? '0.00'),
'currency' => $currency,
'currency_symbol' => $is_points
? (class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt'))
@@ -417,9 +417,28 @@ class UserOrders {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$repo = OrderRepository::get_instance();
$order = $repo->get_user_order($user_id, $order_number);
if (!$order || $this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) {
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
$order = $repo->get_user_order($user_id, $order_number);
if (!$order) {
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
}
if ($this->status_group((string) ($order['status'] ?? '')) === OrderCenterQuery::STATUS_PAID) {
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('order-payment', [$order_number]);
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
if (!empty($history['items'][0])) {
return ['success' => true, 'data' => [
'redirect' => false,
'payment_url' => '',
'order_number' => $order_number,
'order' => $this->get_order_detail_view($user_id, $order_number),
'transaction_id' => (int) $history['items'][0]['id'],
'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('支付成功。', 'aksrt'),
]];
}
}
if ($this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) {
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
}
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
@@ -435,13 +454,21 @@ class UserOrders {
$order['payment_method'] = $payment_method;
$result = PaymentOrchestrator::pay($gateway, $order, ['paid_note' => __('用户在前台完成支付。', 'aksrt')]);
if (empty($result['success'])) {
return ['success' => false, 'message' => (string) ($result['message'] ?? __('支付失败。', 'aksrt'))];
return [
'success' => false,
'code' => (string) ($result['code'] ?? 'aksrt_pay_failed'),
'status' => (int) ($result['status'] ?? 400),
'message' => (string) ($result['message'] ?? __('支付失败。', 'aksrt')),
];
}
return ['success' => true, 'data' => [
return ['success' => true, 'data' => [
'redirect' => !empty($result['redirect']),
'payment_url' => (string) ($result['payment_url'] ?? ''),
'order_number' => $order_number,
'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null,
'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null,
'transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
'replayed' => !empty($result['replayed']),
'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null,
'message' => (string) ($result['message'] ?? ''),
]];
}
+332 -169
View File
@@ -27,29 +27,6 @@ class Withdrawal {
return \AKSRT\Core\DBMigrator::table('withdrawal');
}
/**
* 创建提现申请
*/
public function create(int $user_id, float $amount, string $method = '', string $account_info = '', ?float $gross_amount = null): int {
global $wpdb;
if ($user_id <= 0 || $amount <= 0) {
return 0;
}
$wpdb->insert($this->table(), [
'user_id' => $user_id,
'amount' => $amount,
'gross_amount' => $gross_amount === null ? $amount : $gross_amount,
'method' => sanitize_key($method),
'account_info' => sanitize_textarea_field($account_info),
'status' => self::STATUS_PENDING,
'created_at' => current_time('mysql'),
], ['%d', '%f', '%f', '%s', '%s', '%s', '%s']);
return (int) $wpdb->insert_id;
}
/**
* Daily withdrawal window in the configured WordPress timezone.
* @return array{start:string,end:string}
@@ -65,100 +42,172 @@ class Withdrawal {
return ['start' => $start->format('Y-m-d H:i:s'), 'end' => $start->modify('+1 day')->format('Y-m-d H:i:s')];
}
/** @return array{count:int,total:float,remaining_count:int,remaining_amount:float,window:array{start:string,end:string}} */
/** @return array{count:int,total:string,remaining_count:int,remaining_amount:string,window:array{start:string,end:string}} */
public function daily_usage(int $user_id, array $settings): array {
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
return $this->daily_usage_in_window($user_id, $settings, $window);
}
/** @return array{count:int,total:float,remaining_count:int,remaining_amount:float,window:array{start:string,end:string}} */
/** @return array{count:int,total:string,remaining_count:int,remaining_amount:string,window:array{start:string,end:string}} */
private function daily_usage_in_window(int $user_id, array $settings, array $window): array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT COUNT(*) AS count, COALESCE(SUM(CASE WHEN gross_amount > 0 THEN gross_amount ELSE amount END), 0) AS total FROM {$this->table()} WHERE user_id = %d AND created_at >= %s AND created_at < %s",
$user_id, $window['start'], $window['end']
), ARRAY_A);
$count = (int) ($row['count'] ?? 0);
$total = round((float) ($row['total'] ?? 0), 2);
$max_count = max(0, (int) ($settings['daily_count'] ?? 3));
$max_amount = max(0, (float) ($settings['daily_limit'] ?? 50));
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window);
return [
'count' => $count,
'total' => $total,
'remaining_count' => max(0, $max_count - $count),
'remaining_amount' => max(0, round($max_amount - $total, 2)),
'count' => $usage['count'],
'total' => $this->balance_decimal($usage['total_minor']),
'remaining_count' => $usage['remaining_count'],
'remaining_amount' => $this->balance_decimal($usage['remaining_minor']),
'window' => $window,
];
}
/** @return array{count:int,total_minor:int,remaining_count:int,remaining_minor:int,window:array{start:string,end:string}} */
private function daily_usage_minor_in_window(int $user_id, array $settings, array $window, bool $lock = false): array {
global $wpdb;
$sql = $wpdb->prepare(
"SELECT id, gross_amount, amount FROM {$this->table()} WHERE user_id = %d AND created_at >= %s AND created_at < %s ORDER BY id ASC" . ($lock ? ' FOR UPDATE' : ''),
$user_id,
$window['start'],
$window['end']
);
$rows = (array) $wpdb->get_results($sql, ARRAY_A);
$total_minor = 0;
foreach ($rows as $row) {
$decimal = (string) ($row['gross_amount'] ?? '');
try {
$row_minor = \AKSRT\Ledger\AssetAmount::balance($decimal)->minor();
if ($row_minor <= 0) {
throw new \InvalidArgumentException('withdrawal_amount_invalid');
}
if ($total_minor > PHP_INT_MAX - $row_minor) {
throw new \RuntimeException('withdrawal_total_overflow');
}
$total_minor += $row_minor;
} catch (\InvalidArgumentException $e) {
throw new \RuntimeException('withdrawal_amount_invalid');
}
}
$max_count = max(0, (int) ($settings['daily_count'] ?? 3));
$max_minor = $this->setting_minor($settings['daily_limit'] ?? '500.00');
return [
'count' => count($rows),
'total_minor' => $total_minor,
'remaining_count' => max(0, $max_count - count($rows)),
'remaining_minor' => max(0, $max_minor - $total_minor),
'window' => $window,
];
}
/**
* Atomically validates quotas, freezes wallet funds, writes the ledger and creates an order.
* @return array{withdrawal_id:int,net_amount:float,wallet_balance:float,usage:array}|\WP_Error
* @return array{withdrawal_id:int,net_amount:string,wallet_balance:string,transaction_id:int,replayed:bool,usage:array}|\WP_Error
*/
public function create_balance_request(int $user_id, int $gross_amount, string $method, string $account, array $settings) {
public function create_balance_request(int $user_id, int $gross_minor, string $method, string $account, array $settings, string $operation_key) {
global $wpdb;
if ($user_id <= 0 || $gross_amount <= 0) {
return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须为正整数', 'aksrt'), ['status' => 400]);
if ($user_id <= 0 || $gross_minor <= 0) {
return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须大于零', 'aksrt'), ['status' => 400]);
}
if (!class_exists('\AKSRT\\Credits\\Balance')) {
return new \WP_Error('aksrt_withdraw_unavailable', __('提现服务不可用', 'aksrt'), ['status' => 500]);
$fee_rate = max(0, min(50, (int) ($settings['service_charge_rate'] ?? 5)));
$fee_minor = (intdiv($gross_minor, 100) * $fee_rate) + intdiv(($gross_minor % 100) * $fee_rate, 100);
$net_minor = $gross_minor - $fee_minor;
if ($net_minor < 1) {
return new \WP_Error('aksrt_withdraw_net', __('扣除手续费后到账金额必须至少为 0.01 元', 'aksrt'), ['status' => 400]);
}
$meta_table = $wpdb->usermeta;
$meta_key = \AKSRT\Credits\Balance::META_KEY;
if (!metadata_exists('user', $user_id, $meta_key)) {
add_user_meta($user_id, $meta_key, '0', true);
}
$wpdb->query('START TRANSACTION');
try {
$open = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') FOR UPDATE", $user_id));
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
$usage = null;
$withdrawal_id = 0;
$guard = function () use ($user_id, $gross_minor, $settings, $window, &$usage) {
global $wpdb;
$wpdb->get_results($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') ORDER BY id ASC FOR UPDATE",
$user_id
));
$open = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing')",
$user_id
));
if ($open > 0) {
$wpdb->query('ROLLBACK');
return new \WP_Error('withdraw_pending', __('已有待处理提现申请', 'aksrt'), ['status' => 409]);
}
$meta_id = $wpdb->get_var($wpdb->prepare("SELECT umeta_id FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1 FOR UPDATE", $user_id, $meta_key));
if (!$meta_id) {
throw new \RuntimeException('wallet row unavailable');
}
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
$usage = $this->daily_usage_in_window($user_id, $settings, $window);
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, true);
if ($usage['remaining_count'] <= 0) {
$wpdb->query('ROLLBACK');
return new \WP_Error('withdraw_count_limit', __('当日提现次数已用尽', 'aksrt'), ['status' => 409]);
}
if ($gross_amount > $usage['remaining_amount']) {
$wpdb->query('ROLLBACK');
if ($gross_minor > $usage['remaining_minor']) {
return new \WP_Error('withdraw_amount_limit', __('本次提现后将超出单日提现总额上限', 'aksrt'), ['status' => 409]);
}
$before = (float) $wpdb->get_var($wpdb->prepare("SELECT meta_value FROM {$meta_table} WHERE umeta_id = %d", $meta_id));
if ($before < $gross_amount) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]);
}
$after = round($before - $gross_amount, 2);
if ($wpdb->update($meta_table, ['meta_value' => (string) $after], ['umeta_id' => $meta_id], ['%s'], ['%d']) !== 1) {
throw new \RuntimeException('wallet update failed');
}
$fee_rate = max(0, min(50, (int) ($settings['service_charge_rate'] ?? 5)));
$net = round($gross_amount * (100 - $fee_rate) / 100, 2);
$payload = wp_json_encode(['account' => $account, 'gross_amount' => $gross_amount, 'fee_rate' => $fee_rate, 'source' => 'balance'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
return true;
};
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id) {
global $wpdb;
$payload = wp_json_encode([
'account' => $account,
'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(),
'fee_rate' => $fee_rate,
'source' => 'balance',
'operation_key' => $operation_key,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
$ok = $wpdb->insert($this->table(), [
'user_id' => $user_id, 'amount' => $net, 'gross_amount' => $gross_amount, 'method' => sanitize_key($method),
'account_info' => $payload, 'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'),
], ['%d', '%f', '%f', '%s', '%s', '%s', '%s']);
if (!$ok) { throw new \RuntimeException('withdrawal insert failed'); }
'user_id' => $user_id,
'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(),
'method' => sanitize_key($method),
'account_info' => $payload,
'status' => self::STATUS_PENDING,
'ledger_transaction_id' => $transaction_id,
'created_at' => current_time('mysql'),
], ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
if ($ok !== 1) {
return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]);
}
$withdrawal_id = (int) $wpdb->insert_id;
\AKSRT\Credits\Balance::log($user_id, -$gross_amount, $after, 'withdraw_hold', ['type' => 'withdraw', 'id' => $withdrawal_id], __('余额提现申请冻结', 'aksrt'));
$wpdb->query('COMMIT');
wp_cache_delete($user_id, 'user_meta');
$usage['count']++;
$usage['total'] = round($usage['total'] + $gross_amount, 2);
$usage['remaining_count']--;
$usage['remaining_amount'] = max(0, round($usage['remaining_amount'] - $gross_amount, 2));
return ['withdrawal_id' => $withdrawal_id, 'net_amount' => $net, 'wallet_balance' => $after, 'usage' => $usage];
} catch (\Throwable $e) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]);
return true;
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->debit(
$user_id,
\AKSRT\Ledger\AssetType::BALANCE,
$gross_minor,
$operation_key,
'withdraw_hold',
['type' => 'withdraw', 'key' => $operation_key],
__('余额提现申请冻结', 'aksrt'),
$user_id,
['fee_minor' => $fee_minor, 'net_minor' => $net_minor],
$mutation,
$guard
);
if (is_wp_error($result)) {
return $result;
}
if ($withdrawal_id <= 0) {
$withdrawal_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE ledger_transaction_id = %d",
$result->transaction_id
));
}
if ($withdrawal_id <= 0) {
return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 500]);
}
if (!is_array($usage)) {
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window);
} else {
$usage['count']++;
$usage['total_minor'] += $gross_minor;
$usage['remaining_count'] = max(0, $usage['remaining_count'] - 1);
$usage['remaining_minor'] = max(0, $usage['remaining_minor'] - $gross_minor);
}
return [
'withdrawal_id' => $withdrawal_id,
'net_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'usage' => [
'count' => $usage['count'],
'total' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $usage['total_minor'])->decimal(),
'remaining_count' => $usage['remaining_count'],
'remaining_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $usage['remaining_minor'])->decimal(),
],
];
}
/**
@@ -204,10 +253,11 @@ class Withdrawal {
* Status transition with balance side-effects.
* Reject of balance-source holds refunds gross_amount once (idempotent).
*
* @return true|\WP_Error
* @return array{transaction_id:int,replayed:bool,wallet_balance:string}|\WP_Error
*/
public function process_status_change(int $withdrawal_id, string $new_status, string $note = '') {
public function process_status_change(int $withdrawal_id, string $new_status, string $note = '', string $operation_key = '') {
$new_status = sanitize_key($new_status);
$note = sanitize_textarea_field($note);
$allowed = [
self::STATUS_PENDING,
self::STATUS_PROCESSING,
@@ -215,17 +265,29 @@ class Withdrawal {
self::STATUS_REJECTED,
];
if ($withdrawal_id <= 0 || !in_array($new_status, $allowed, true)) {
return new \WP_Error('aksrt_withdraw_status', __('提现状态无效', 'aksrt'));
return new \WP_Error('aksrt_withdraw_status', __('提现状态无效', 'aksrt'), ['status' => 400]);
}
if ($operation_key === '' || strlen($operation_key) > 191 || !preg_match('/^[A-Za-z0-9:._-]+$/', $operation_key)) {
return new \WP_Error('aksrt_withdraw_operation_key', __('提现状态操作标识无效', 'aksrt'), ['status' => 400]);
}
$row = $this->get($withdrawal_id);
if (!$row) {
return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'));
return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 404]);
}
$old_status = sanitize_key((string) ($row['status'] ?? ''));
if ($old_status === $new_status && $note === '') {
return true;
$payload_hash = hash('sha256', (string) wp_json_encode([
'withdrawal_id' => $withdrawal_id,
'status' => $new_status,
'note' => $note,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
$preflight_replay = $this->status_operation_result($meta, $operation_key, $payload_hash);
if (is_wp_error($preflight_replay)) {
return $preflight_replay;
}
if (is_array($preflight_replay)) {
$preflight_replay['wallet_balance'] = \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal((int) ($row['user_id'] ?? 0));
return $preflight_replay;
}
$transitions = [
self::STATUS_PENDING => [self::STATUS_PROCESSING, self::STATUS_REJECTED],
@@ -233,67 +295,122 @@ class Withdrawal {
self::STATUS_COMPLETED => [],
self::STATUS_REJECTED => [],
];
$old_status = sanitize_key((string) ($row['status'] ?? ''));
if (!in_array($new_status, $transitions[$old_status] ?? [], true)) {
return new \WP_Error('aksrt_withdraw_transition', __('提现状态转换不合法', 'aksrt'), ['status' => 409]);
}
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
$user_id = (int) ($row['user_id'] ?? 0);
$ledger_result = null;
$locked_row = null;
$locked_meta = [];
$operation_replay = null;
$lock_and_validate = function () use ($withdrawal_id, $new_status, $transitions, $operation_key, $payload_hash, &$locked_row, &$locked_meta, &$operation_replay) {
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE",
$withdrawal_id
), ARRAY_A);
if (!is_array($current)) {
return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 404]);
}
$locked_meta = $this->parse_account_meta((string) ($current['account_info'] ?? ''));
$replay = $this->status_operation_result($locked_meta, $operation_key, $payload_hash);
if (is_wp_error($replay)) {
return $replay;
}
if (is_array($replay)) {
$locked_row = $current;
$operation_replay = $replay;
return true;
}
$current_status = sanitize_key((string) ($current['status'] ?? ''));
if (!in_array($new_status, $transitions[$current_status] ?? [], true)) {
return new \WP_Error('aksrt_withdraw_transition', __('提现状态转换不合法', 'aksrt'), ['status' => 409]);
}
$locked_row = $current;
return true;
};
$update_locked = function (int $transaction_id = 0) use ($withdrawal_id, $new_status, $note, $operation_key, $payload_hash, &$locked_row, &$locked_meta, &$operation_replay) {
global $wpdb;
if (!is_array($locked_row)) {
return new \WP_Error('aksrt_withdraw_update', __('提现记录未锁定', 'aksrt'), ['status' => 500]);
}
if (is_array($operation_replay)) {
return true;
}
$update = ['status' => $new_status];
$formats = ['%s'];
$locked_meta['status_operations'] = is_array($locked_meta['status_operations'] ?? null)
? $locked_meta['status_operations']
: [];
$locked_meta['status_operations'][$operation_key] = [
'payload_hash' => $payload_hash,
'status' => $new_status,
'transaction_id' => max(0, $transaction_id),
];
if ($new_status === self::STATUS_REJECTED) {
$locked_meta['released_at'] = current_time('mysql');
}
$update['account_info'] = $this->encode_account_meta($locked_meta, '');
$formats[] = '%s';
if (in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
$update['processed_at'] = current_time('mysql');
$formats[] = '%s';
}
$merged_note = $this->merge_note((string) ($locked_row['note'] ?? ''), $note);
if ($merged_note !== '') {
$update['note'] = $merged_note;
$formats[] = '%s';
}
return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id, 'status' => (string) $locked_row['status']], $formats, ['%d', '%s']) === 1
? true
: new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]);
};
// Refund frozen balance when moving into rejected (once).
if (
$new_status === self::STATUS_REJECTED
&& $old_status !== self::STATUS_REJECTED
&& empty($meta['balance_refunded'])
&& $this->is_balance_source($meta)
&& class_exists('\AKSRT\Credits\Balance')
&& $user_id > 0
) {
$gross = $this->resolve_gross_amount($row, $meta);
if ($gross > 0) {
$refund = \AKSRT\Credits\Balance::add(
$user_id,
$gross,
'withdraw_refund',
['type' => 'withdraw', 'id' => $withdrawal_id],
sprintf(__('提现拒绝退回 #%d', 'aksrt'), $withdrawal_id)
);
if (is_wp_error($refund)) {
return $refund;
}
$meta['balance_refunded'] = true;
$meta['refunded_at'] = current_time('mysql');
if ($new_status === self::STATUS_REJECTED && $this->is_balance_source($meta) && $user_id > 0) {
try {
$gross_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($row['gross_amount'] ?? ''))->minor();
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_withdraw_amount', __('提现记录金额无效', 'aksrt'), ['status' => 500]);
}
if ($gross_minor <= 0) {
return new \WP_Error('aksrt_withdraw_amount', __('提现记录金额无效', 'aksrt'), ['status' => 500]);
}
$ledger_result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
\AKSRT\Ledger\AssetType::BALANCE,
$gross_minor,
$operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('withdraw-refund', [$withdrawal_id]),
'withdraw_refund',
['type' => 'withdraw', 'id' => $withdrawal_id],
sprintf(__('提现拒绝退回 #%d', 'aksrt'), $withdrawal_id),
get_current_user_id(),
['withdrawal_id' => $withdrawal_id, 'target_status' => $new_status, 'note_hash' => hash('sha256', $note)],
static fn(int $transaction_id) => $update_locked($transaction_id),
static fn() => $lock_and_validate()
);
if (is_wp_error($ledger_result)) {
return $ledger_result;
}
} else {
global $wpdb;
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]);
}
$locked = $lock_and_validate();
$updated = is_wp_error($locked) ? $locked : $update_locked(0);
if (is_wp_error($updated) || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return is_wp_error($updated) ? $updated : new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]);
}
}
$account_info = $this->encode_account_meta($meta, (string) ($row['account_info'] ?? ''));
$merged_note = $this->merge_note((string) ($row['note'] ?? ''), $note);
global $wpdb;
$update = [
'status' => $new_status,
'account_info' => $account_info,
];
$format = ['%s', '%s'];
if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) {
$update['processed_at'] = current_time('mysql');
$format[] = '%s';
}
if ($merged_note !== '') {
$update['note'] = $merged_note;
$format[] = '%s';
}
$ok = $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false;
if (!$ok) {
return new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'));
}
// Notify user on terminal status (legacy withdraw_reply parity).
if (class_exists('\AKSRT\Messages\Message') && $user_id > 0 && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
// Notify only after the financial transaction has committed.
$replayed = ($ledger_result instanceof \AKSRT\Ledger\LedgerResult && $ledger_result->replayed) || is_array($operation_replay);
if (class_exists('\AKSRT\Messages\Message') && $user_id > 0 && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true) && !$replayed) {
$gross = $this->resolve_gross_amount($row, $meta);
$amount_txt = class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format($gross) : number_format($gross, 2);
$amount_txt = $gross;
if ($new_status === self::STATUS_REJECTED) {
$title = __('提现已拒绝', 'aksrt');
$body = sprintf(__('您申请提现 %s 元已被拒绝,冻结金额已退回钱包。', 'aksrt'), $amount_txt);
@@ -310,7 +427,11 @@ class Withdrawal {
]);
}
return true;
return [
'transaction_id' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->transaction_id : 0,
'replayed' => $replayed,
'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
];
}
/**
@@ -338,25 +459,33 @@ class Withdrawal {
/**
* Sum of frozen gross amounts still open (balance source).
*/
public function sum_pending_hold(int $user_id): float {
public function sum_pending_hold(int $user_id): string {
global $wpdb;
if ($user_id <= 0) {
return 0.0;
return '0.00';
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT amount, account_info FROM {$this->table()}
"SELECT gross_amount, account_info FROM {$this->table()}
WHERE user_id = %d AND status IN ('pending','processing')",
$user_id
), ARRAY_A);
$total = 0.0;
$total_minor = 0;
foreach ((array) $rows as $row) {
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
if (!$this->is_balance_source($meta) || !empty($meta['balance_refunded'])) {
if (!$this->is_balance_source($meta)) {
continue;
}
$total += $this->resolve_gross_amount($row, $meta);
try {
$minor = \AKSRT\Ledger\AssetAmount::balance((string) ($row['gross_amount'] ?? ''))->minor();
if ($minor <= 0 || $total_minor > PHP_INT_MAX - $minor) {
return '0.00';
}
$total_minor += $minor;
} catch (\InvalidArgumentException $e) {
return '0.00';
}
}
return round($total, 2);
return $this->balance_decimal($total_minor);
}
/**
@@ -459,7 +588,7 @@ class Withdrawal {
'processing' => (int) ($row['processing'] ?? 0),
'rejected' => (int) ($row['rejected'] ?? 0),
'completed' => (int) ($row['completed'] ?? 0),
'total_paid' => (float) ($row['total_paid'] ?? 0),
'total_paid' => $this->database_balance_decimal((string) ($row['total_paid'] ?? '0')),
];
}
@@ -472,6 +601,7 @@ class Withdrawal {
$gross = $this->resolve_gross_amount($row, $meta);
$row['meta'] = $meta;
$row['gross_amount'] = $gross;
$row['amount'] = $this->database_balance_decimal((string) ($row['amount'] ?? '0'));
$row['fee_rate'] = (int) ($meta['fee_rate'] ?? 0);
$display = (string) ($meta['account'] ?? $row['account_info'] ?? '');
$row['display_account'] = strlen($display) > 4 ? substr($display, 0, 2) . str_repeat('*', max(2, strlen($display) - 4)) . substr($display, -2) : '****';
@@ -506,24 +636,17 @@ class Withdrawal {
* @param array<string,mixed> $meta
*/
private function is_balance_source(array $meta): bool {
$source = sanitize_key((string) ($meta['source'] ?? 'balance'));
// Empty / balance = cash wallet freeze path.
return $source === '' || $source === 'balance';
$source = sanitize_key((string) ($meta['source'] ?? ''));
return $source === 'balance';
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed> $meta
*/
private function resolve_gross_amount(array $row, array $meta): float {
if ((float) ($row['gross_amount'] ?? 0) > 0) {
return max(0, round((float) $row['gross_amount'], 2));
}
if (isset($meta['gross_amount'])) {
return max(0, round((float) $meta['gross_amount'], 2));
}
// Legacy rows: amount was net or gross without meta.
return max(0, round((float) ($row['amount'] ?? 0), 2));
private function resolve_gross_amount(array $row, array $meta): string {
unset($meta);
return $this->database_balance_decimal((string) ($row['gross_amount'] ?? '0'));
}
private function merge_note(string $old, string $new): string {
@@ -537,4 +660,44 @@ class Withdrawal {
}
return $old . "\n" . $new;
}
private function setting_minor(mixed $value): int {
if (!is_string($value)) {
throw new \RuntimeException('withdrawal_setting_invalid');
}
try {
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->minor();
} catch (\InvalidArgumentException $e) {
throw new \RuntimeException('withdrawal_setting_invalid');
}
}
/** @return array{transaction_id:int,replayed:bool}|\WP_Error|null */
private function status_operation_result(array $meta, string $operation_key, string $payload_hash) {
$history = is_array($meta['status_operations'] ?? null) ? $meta['status_operations'] : [];
$stored = $history[$operation_key] ?? null;
if (!is_array($stored)) {
return null;
}
$stored_hash = (string) ($stored['payload_hash'] ?? '');
if ($stored_hash === '' || !hash_equals($stored_hash, $payload_hash)) {
return new \WP_Error('aksrt_withdraw_idempotency_conflict', __('相同操作标识已用于其他提现状态请求', 'aksrt'), ['status' => 409]);
}
return [
'transaction_id' => max(0, (int) ($stored['transaction_id'] ?? 0)),
'replayed' => true,
];
}
private function database_balance_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} catch (\InvalidArgumentException $e) {
return '0.00';
}
}
private function balance_decimal(int $minor): string {
return \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, max(0, $minor))->decimal();
}
}