积分与余额账务系统硬切重构
This commit is contained in:
@@ -23,8 +23,20 @@ abstract class ControllerBase {
|
||||
/**
|
||||
* Permission callback — requires manage_options capability.
|
||||
*/
|
||||
public function check_permission(): bool {
|
||||
return current_user_can('manage_options');
|
||||
public function check_permission(?\WP_REST_Request $request = null) {
|
||||
if (!current_user_can('manage_options')) {
|
||||
if ($request === null) {
|
||||
return false;
|
||||
}
|
||||
return new \WP_Error('aksrt_admin_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if ($request !== null) {
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -307,7 +307,9 @@ class ImportExportController extends ControllerBase {
|
||||
|
||||
// Database tables
|
||||
$expected_tables = [
|
||||
$wpdb->prefix . 'aksrt_credit_log',
|
||||
$wpdb->prefix . 'aksrt_asset_account',
|
||||
$wpdb->prefix . 'aksrt_asset_transaction',
|
||||
$wpdb->prefix . 'aksrt_asset_entry',
|
||||
$wpdb->prefix . 'aksrt_signin_log',
|
||||
$wpdb->prefix . 'aksrt_dl_token',
|
||||
$wpdb->prefix . 'aksrt_notification',
|
||||
|
||||
@@ -50,7 +50,6 @@ class PayChargeCards {
|
||||
'balance' => __('余额', 'aksrt'),
|
||||
'vip' => __('VIP', 'aksrt'),
|
||||
'credits' => __('积分', 'aksrt'),
|
||||
'points' => __('积分', 'aksrt'),
|
||||
];
|
||||
$status_labels = [
|
||||
'active' => __('可用', 'aksrt'),
|
||||
@@ -81,7 +80,7 @@ class PayChargeCards {
|
||||
echo '<p><label>' . esc_html__('前缀', 'aksrt') . '<br><input type="text" name="prefix" value="" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('面额/积分', 'aksrt') . '<br><input type="number" step="0.01" name="amount" value="10" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('VIP等级', 'aksrt') . '<br><input type="number" name="vip_level" min="1" max="2" value="1" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('VIP天数', 'aksrt') . '<br><input type="number" name="vip_days" min="1" value="30" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('VIP天数', 'aksrt') . '<br><input type="number" name="days" min="1" value="30" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('过期时间', 'aksrt') . '<br><input type="datetime-local" name="expires_at" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('备注', 'aksrt') . '<br><input type="text" name="note" value="" /></label></p>';
|
||||
submit_button(__('生成', 'aksrt'), 'primary', '', false);
|
||||
@@ -179,7 +178,7 @@ class PayChargeCards {
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html((string) $id) . '</td>';
|
||||
echo '<td>' . esc_html($type_labels[$row_type] ?? $row_type) . '</td>';
|
||||
echo '<td><code>' . esc_html((string) ($row['password'] ?? $row['code'] ?? '')) . '</code></td>';
|
||||
echo '<td><code>' . esc_html((string) ($row['password'] ?? '')) . '</code></td>';
|
||||
echo '<td>' . esc_html((string) ($row['amount'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($status_labels[$st] ?? $st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['expires_at'] ?? '—')) . '</td>';
|
||||
@@ -252,10 +251,10 @@ class PayChargeCards {
|
||||
$count = isset($_POST['count']) ? (int) $_POST['count'] : 10;
|
||||
$length = isset($_POST['length']) ? (int) $_POST['length'] : 16;
|
||||
$prefix = isset($_POST['prefix']) ? sanitize_text_field((string) wp_unslash($_POST['prefix'])) : '';
|
||||
$amount = isset($_POST['amount']) ? (float) wp_unslash($_POST['amount']) : 0;
|
||||
$amount = isset($_POST['amount']) ? trim(sanitize_text_field((string) wp_unslash($_POST['amount']))) : '';
|
||||
$note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : '';
|
||||
$level = isset($_POST['vip_level']) ? max(1, min(2, (int) $_POST['vip_level'])) : 1;
|
||||
$days = isset($_POST['vip_days']) ? max(1, (int) $_POST['vip_days']) : 30;
|
||||
$days = isset($_POST['days']) ? max(1, (int) $_POST['days']) : 30;
|
||||
$expires = isset($_POST['expires_at']) ? sanitize_text_field((string) wp_unslash($_POST['expires_at'])) : '';
|
||||
if ($expires !== '') {
|
||||
$expires = str_replace('T', ' ', $expires);
|
||||
@@ -264,15 +263,30 @@ class PayChargeCards {
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'points') {
|
||||
$type = 'credits';
|
||||
if (!in_array($type, ['balance', 'vip', 'credits'], true)) {
|
||||
wp_safe_redirect($this->page_url('aksrt_charge_cards', ['aksrt_pay_msg' => 'card_batch_failed']));
|
||||
exit;
|
||||
}
|
||||
|
||||
$meta = [];
|
||||
if ($type === 'balance') {
|
||||
$meta['amount'] = max(0, $amount);
|
||||
try {
|
||||
$balance_amount = \AKSRT\Ledger\AssetAmount::balance($amount);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
wp_safe_redirect($this->page_url('aksrt_charge_cards', ['aksrt_pay_msg' => 'card_batch_failed']));
|
||||
exit;
|
||||
}
|
||||
if ($balance_amount->minor() <= 0) {
|
||||
wp_safe_redirect($this->page_url('aksrt_charge_cards', ['aksrt_pay_msg' => 'card_batch_failed']));
|
||||
exit;
|
||||
}
|
||||
$meta['amount'] = $balance_amount->decimal();
|
||||
} elseif ($type === 'credits') {
|
||||
$meta['points'] = max(0, (int) $amount);
|
||||
if (!preg_match('/^[1-9]\d*$/', $amount)) {
|
||||
wp_safe_redirect($this->page_url('aksrt_charge_cards', ['aksrt_pay_msg' => 'card_batch_failed']));
|
||||
exit;
|
||||
}
|
||||
$meta['points'] = (int) $amount;
|
||||
} elseif ($type === 'vip') {
|
||||
$meta['level'] = $level;
|
||||
$meta['days'] = $days;
|
||||
|
||||
@@ -255,9 +255,9 @@ class PayCoupons {
|
||||
$id = \AKSRT\Payment\Coupon::get_instance()->create([
|
||||
'code' => sanitize_text_field((string) wp_unslash($_POST['code'] ?? '')),
|
||||
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'fixed')),
|
||||
'value' => (float) ($_POST['value'] ?? 0),
|
||||
'min_amount' => (float) ($_POST['min_amount'] ?? 0),
|
||||
'max_discount' => (float) ($_POST['max_discount'] ?? 0),
|
||||
'value' => trim((string) wp_unslash($_POST['value'] ?? '0')),
|
||||
'min_amount' => trim((string) wp_unslash($_POST['min_amount'] ?? '0')),
|
||||
'max_discount' => trim((string) wp_unslash($_POST['max_discount'] ?? '0')),
|
||||
'usage_limit' => (int) ($_POST['usage_limit'] ?? 0),
|
||||
'per_user_limit' => (int) ($_POST['per_user_limit'] ?? 0),
|
||||
'product_scope' => $this->parse_product_scope_from_request(),
|
||||
@@ -281,9 +281,9 @@ class PayCoupons {
|
||||
if (class_exists('\AKSRT\Payment\Coupon')) {
|
||||
$result = \AKSRT\Payment\Coupon::get_instance()->batch_create([
|
||||
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'fixed')),
|
||||
'value' => (float) ($_POST['value'] ?? 0),
|
||||
'min_amount' => (float) ($_POST['min_amount'] ?? 0),
|
||||
'max_discount' => (float) ($_POST['max_discount'] ?? 0),
|
||||
'value' => trim((string) wp_unslash($_POST['value'] ?? '0')),
|
||||
'min_amount' => trim((string) wp_unslash($_POST['min_amount'] ?? '0')),
|
||||
'max_discount' => trim((string) wp_unslash($_POST['max_discount'] ?? '0')),
|
||||
'usage_limit' => (int) ($_POST['usage_limit'] ?? 0),
|
||||
'per_user_limit' => (int) ($_POST['per_user_limit'] ?? 0),
|
||||
'product_scope' => $this->parse_product_scope_from_request(),
|
||||
@@ -315,9 +315,9 @@ class PayCoupons {
|
||||
if ($id > 0 && class_exists('\AKSRT\Payment\Coupon')) {
|
||||
$ok = \AKSRT\Payment\Coupon::get_instance()->update($id, [
|
||||
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'fixed')),
|
||||
'value' => (float) ($_POST['value'] ?? 0),
|
||||
'min_amount' => (float) ($_POST['min_amount'] ?? 0),
|
||||
'max_discount' => (float) ($_POST['max_discount'] ?? 0),
|
||||
'value' => trim((string) wp_unslash($_POST['value'] ?? '0')),
|
||||
'min_amount' => trim((string) wp_unslash($_POST['min_amount'] ?? '0')),
|
||||
'max_discount' => trim((string) wp_unslash($_POST['max_discount'] ?? '0')),
|
||||
'usage_limit' => (int) ($_POST['usage_limit'] ?? 0),
|
||||
'per_user_limit' => (int) ($_POST['per_user_limit'] ?? 0),
|
||||
'product_scope' => $this->parse_product_scope_from_request(),
|
||||
|
||||
@@ -60,18 +60,26 @@ class PayIncome {
|
||||
|
||||
$cash_stats = (array) ($stats['cash'] ?? []);
|
||||
$points_stats = (array) ($stats['points'] ?? []);
|
||||
$cash_amount = static function ($value): string {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance(is_string($value) ? $value : '')->formatted();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
};
|
||||
echo '<p>' . esc_html(sprintf(
|
||||
__('现金:合计 ¥%1$s · 待结算 ¥%2$s · 已结算 ¥%3$s;积分:合计 %4$s · 待结算 %5$s · 已结算 %6$s', 'aksrt'),
|
||||
number_format((float) ($cash_stats['total'] ?? 0), 2),
|
||||
number_format((float) ($cash_stats['pending'] ?? 0), 2),
|
||||
number_format((float) ($cash_stats['settled'] ?? 0), 2),
|
||||
number_format((float) ($points_stats['total'] ?? 0), 0),
|
||||
number_format((float) ($points_stats['pending'] ?? 0), 0),
|
||||
number_format((float) ($points_stats['settled'] ?? 0), 0)
|
||||
$cash_amount($cash_stats['total'] ?? '0'),
|
||||
$cash_amount($cash_stats['pending'] ?? '0'),
|
||||
$cash_amount($cash_stats['settled'] ?? '0'),
|
||||
number_format_i18n(max(0, (int) ($points_stats['total'] ?? 0))),
|
||||
number_format_i18n(max(0, (int) ($points_stats['pending'] ?? 0))),
|
||||
number_format_i18n(max(0, (int) ($points_stats['settled'] ?? 0)))
|
||||
)) . '</p>';
|
||||
|
||||
echo '<form id="aksrt-pay-batch-settle-income" method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin:12px 0;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_batch_settle_income" />';
|
||||
echo '<input type="hidden" name="operation_key" value="' . esc_attr(wp_generate_uuid4()) . '" />';
|
||||
wp_nonce_field('aksrt_pay_batch_settle_income');
|
||||
submit_button(__('批量结算选中记录', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
@@ -135,8 +143,10 @@ class PayIncome {
|
||||
echo '<td><code>' . esc_html((string) ($row['order_number'] ?? '')) . '</code></td>';
|
||||
$is_points = (string) ($row['currency'] ?? 'CNY') === 'points';
|
||||
$row_currency = $is_points ? __('积分', 'aksrt') : '¥';
|
||||
$row_decimals = $is_points ? 0 : 2;
|
||||
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), $row_decimals) . ' ' . $row_currency) . '</td>';
|
||||
$row_amount = $is_points
|
||||
? number_format_i18n(max(0, (int) ($row['amount'] ?? 0)))
|
||||
: $cash_amount($row['amount'] ?? '0');
|
||||
echo '<td>' . esc_html($row_amount . ' ' . $row_currency) . '</td>';
|
||||
echo '<td>' . esc_html($st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td>';
|
||||
@@ -144,6 +154,7 @@ class PayIncome {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_settle_income" />';
|
||||
echo '<input type="hidden" name="revenue_id" value="' . esc_attr((string) $id) . '" />';
|
||||
echo '<input type="hidden" name="operation_key" value="' . esc_attr(wp_generate_uuid4()) . '" />';
|
||||
wp_nonce_field('aksrt_pay_settle_income_' . $id);
|
||||
submit_button(__('结算', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
@@ -163,7 +174,11 @@ class PayIncome {
|
||||
}
|
||||
$id = isset($_POST['revenue_id']) ? (int) $_POST['revenue_id'] : 0;
|
||||
check_admin_referer('aksrt_pay_settle_income_' . $id);
|
||||
$ok = $id > 0 && class_exists('\AKSRT\Payment\Revenue') && \AKSRT\Payment\Revenue::get_instance()->settle($id, get_current_user_id());
|
||||
$request_key = sanitize_text_field((string) wp_unslash($_POST['operation_key'] ?? ''));
|
||||
$operation_key = wp_is_uuid($request_key)
|
||||
? \AKSRT\Ledger\OperationKey::deterministic('admin-revenue-settle', [get_current_user_id(), strtolower($request_key)])
|
||||
: '';
|
||||
$ok = $id > 0 && $operation_key !== '' && class_exists('\AKSRT\Payment\Revenue') && \AKSRT\Payment\Revenue::get_instance()->settle($id, get_current_user_id(), $operation_key);
|
||||
wp_safe_redirect($this->page_url('aksrt_income', ['aksrt_pay_msg' => $ok ? 'income_settled' : 'income_failed']));
|
||||
exit;
|
||||
}
|
||||
@@ -187,8 +202,12 @@ class PayIncome {
|
||||
}
|
||||
}
|
||||
$ids = array_values(array_unique($ids));
|
||||
$batch_result = !empty($ids) && class_exists('\\AKSRT\\Payment\\Revenue')
|
||||
? \AKSRT\Payment\Revenue::get_instance()->settle_batch($ids, get_current_user_id())
|
||||
$request_key = sanitize_text_field((string) wp_unslash($_POST['operation_key'] ?? ''));
|
||||
$operation_key = wp_is_uuid($request_key)
|
||||
? \AKSRT\Ledger\OperationKey::deterministic('admin-revenue-settle-batch', [get_current_user_id(), strtolower($request_key)])
|
||||
: '';
|
||||
$batch_result = !empty($ids) && $operation_key !== '' && class_exists('\\AKSRT\\Payment\\Revenue')
|
||||
? \AKSRT\Payment\Revenue::get_instance()->settle_batch($ids, get_current_user_id(), $operation_key)
|
||||
: ['settled' => [], 'failed' => $ids];
|
||||
$message = empty($batch_result['settled'])
|
||||
? 'income_failed'
|
||||
|
||||
@@ -107,17 +107,6 @@ class PayMenus {
|
||||
wp_die(esc_html__('分成模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'rebate' => [
|
||||
'slug' => 'aksrt_rebate',
|
||||
'title' => __('推广返佣', 'aksrt'),
|
||||
'callback' => static function (): void {
|
||||
if (class_exists('AKSRT\\Admin\\Native\\PayRebate')) {
|
||||
PayRebate::get_instance()->render_rebate_page();
|
||||
return;
|
||||
}
|
||||
wp_die(esc_html__('返佣模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'withdraw' => [
|
||||
'slug' => 'aksrt_withdraw',
|
||||
'title' => __('提现审核', 'aksrt'),
|
||||
@@ -224,7 +213,7 @@ class PayMenus {
|
||||
$this->stat_card(__('订单总数', 'aksrt'), (string) (int) ($order_stats['total'] ?? 0), $this->page_url('aksrt_orders'));
|
||||
$this->stat_card(__('待支付', 'aksrt'), (string) (int) ($order_stats['pending'] ?? 0), $this->page_url('aksrt_orders', ['status' => 'pending']));
|
||||
$this->stat_card(__('已支付', 'aksrt'), (string) (int) ($order_stats['paid'] ?? 0), $this->page_url('aksrt_orders', ['status' => 'paid']));
|
||||
$this->stat_card(__('营收', 'aksrt'), number_format((float) ($order_stats['revenue'] ?? 0), 2), $this->page_url('aksrt_orders'));
|
||||
$this->stat_card(__('营收', 'aksrt'), (string) ($order_stats['revenue'] ?? '0.00'), $this->page_url('aksrt_orders'));
|
||||
$this->stat_card(__('待审提现', 'aksrt'), (string) (int) ($withdraw_stats['pending'] ?? 0), $this->page_url('aksrt_withdraw', ['status' => 'pending']));
|
||||
$this->stat_card(__('可用卡密', 'aksrt'), (string) (int) ($card_stats['active'] ?? 0), $this->page_url('aksrt_charge_cards', ['status' => 'active']));
|
||||
echo '</div>';
|
||||
|
||||
@@ -213,7 +213,7 @@ class PayOrders {
|
||||
echo '<td><code>' . esc_html($order_number) . '</code></td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['product_type'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . ' ' . esc_html((string) ($row['currency'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($this->cash_decimal((string) ($row['amount'] ?? '0'))) . ' ' . esc_html((string) ($row['currency'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($status_label) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">';
|
||||
@@ -296,7 +296,7 @@ class PayOrders {
|
||||
__('用户', 'aksrt') => $user ? $user->display_name . ' (#' . $user_id . ')' : (string) $user_id,
|
||||
__('商品类型', 'aksrt') => (string) ($order['product_type'] ?? ''),
|
||||
__('商品 ID', 'aksrt') => (string) ($order['product_id'] ?? ''),
|
||||
__('金额', 'aksrt') => number_format((float) ($order['amount'] ?? 0), 2) . ' ' . (string) ($order['currency'] ?? ''),
|
||||
__('金额', 'aksrt') => $this->cash_decimal((string) ($order['amount'] ?? '0')) . ' ' . (string) ($order['currency'] ?? ''),
|
||||
__('状态', 'aksrt') => $labels[$status] ?? $status,
|
||||
__('支付方式', 'aksrt') => (string) ($order['payment_method'] ?? ''),
|
||||
__('交易号', 'aksrt') => (string) ($order['payment_transaction_id'] ?? ''),
|
||||
@@ -465,6 +465,14 @@ class PayOrders {
|
||||
}
|
||||
}
|
||||
|
||||
private function cash_decimal(string $value): string {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
private function render_header(string $title): void {
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html($title) . '</h1>';
|
||||
|
||||
@@ -94,17 +94,16 @@ class PayProducts {
|
||||
$modo_label = $modo === 'points' ? __('积分', 'aksrt') : __('现金', 'aksrt');
|
||||
|
||||
if ($modo === 'points') {
|
||||
$list_price = (int) ceil((float) ($meta['points_price'] ?? $meta['price'] ?? 0));
|
||||
$list_price = max(0, (int) ($meta['points_price'] ?? 0));
|
||||
$price_text = sprintf(__('%d 积分', 'aksrt'), $list_price);
|
||||
} else {
|
||||
$list_price = round((float) ($meta['price'] ?? 0), 2);
|
||||
$price_text = number_format($list_price, 2);
|
||||
$price_text = $this->cash_decimal((string) ($meta['price'] ?? '0'));
|
||||
}
|
||||
|
||||
$sales = $sales_map[$post_id] ?? ['sales_count' => 0, 'avg_amount' => 0.0, 'revenue' => 0.0];
|
||||
$sales = $sales_map[$post_id] ?? ['sales_count' => 0, 'avg_amount' => '0.00', 'revenue' => '0.00'];
|
||||
$db_sales = (int) ($sales['sales_count'] ?? 0);
|
||||
$avg = (float) ($sales['avg_amount'] ?? 0);
|
||||
$revenue = (float) ($sales['revenue'] ?? 0);
|
||||
$avg = $this->cash_decimal((string) ($sales['avg_amount'] ?? '0'));
|
||||
$revenue = $this->cash_decimal((string) ($sales['revenue'] ?? '0'));
|
||||
$meta_sales = (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY, true);
|
||||
|
||||
$edit_url = get_edit_post_link($post_id, 'raw');
|
||||
@@ -130,10 +129,10 @@ class PayProducts {
|
||||
echo '<td>' . esc_html((string) $pt_label) . '</td>';
|
||||
echo '<td>' . esc_html($modo_label) . '</td>';
|
||||
echo '<td>' . esc_html($price_text) . '</td>';
|
||||
echo '<td>' . esc_html(number_format($avg, 2)) . '</td>';
|
||||
echo '<td>' . esc_html($avg) . '</td>';
|
||||
echo '<td>' . esc_html((string) $db_sales) . '</td>';
|
||||
echo '<td>' . esc_html((string) max(0, $meta_sales)) . '</td>';
|
||||
echo '<td>' . esc_html(number_format($revenue, 2)) . '</td>';
|
||||
echo '<td>' . esc_html($revenue) . '</td>';
|
||||
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;">';
|
||||
if ($edit_url) {
|
||||
echo '<a class="button button-small" href="' . esc_url($edit_url) . '">' . esc_html__('编辑', 'aksrt') . '</a>';
|
||||
@@ -337,6 +336,14 @@ class PayProducts {
|
||||
}
|
||||
}
|
||||
|
||||
private function cash_decimal(string $value): string {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
private function render_header(string $title): void {
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html($title) . '</h1>';
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class PayRebate {
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
public const PAGE_SLUG = 'aksrt_rebate';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
if (!is_admin()) {
|
||||
return;
|
||||
}
|
||||
add_action('admin_post_aksrt_pay_settle_rebate', [$this, 'handle_settle_rebate']);
|
||||
add_action('admin_post_aksrt_pay_cancel_rebate', [$this, 'handle_cancel_rebate']);
|
||||
}
|
||||
|
||||
public function render_rebate_page(): void {
|
||||
$this->ensure_cap();
|
||||
$this->render_header(__('推广返佣', 'aksrt'));
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Rebate')) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__('返佣模块未初始化。', 'aksrt') . '</p></div>';
|
||||
$this->render_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
|
||||
$user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : (isset($_GET['referrer_id']) ? max(0, (int) $_GET['referrer_id']) : 0);
|
||||
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$per_page = 20;
|
||||
$svc = \AKSRT\Payment\Rebate::get_instance();
|
||||
$stats = $svc->get_stats();
|
||||
|
||||
echo '<p>' . esc_html(sprintf(
|
||||
/* translators: 1: total 2: pending 3: settled amount */
|
||||
__('记录 %1$d · 待结算 %2$d · 已结算金额 %3$s', 'aksrt'),
|
||||
(int) ($stats['total'] ?? 0),
|
||||
(int) ($stats['pending'] ?? 0),
|
||||
number_format((float) ($stats['settled_amount'] ?? 0), 2)
|
||||
)) . '</p>';
|
||||
|
||||
echo '<form method="get" style="margin:12px 0;">';
|
||||
echo '<input type="hidden" name="page" value="aksrt_rebate" />';
|
||||
echo '<select name="status">';
|
||||
foreach (['' => __('全部状态', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已结算', 'aksrt'), 'cancelled' => __('已取消', 'aksrt')] as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($status, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<input type="number" min="0" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" placeholder="' . esc_attr__('推广用户 ID', 'aksrt') . '" class="small-text" /> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
if ($user_id > 0) {
|
||||
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_rebate')) . '">' . esc_html__('清除用户过滤', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</form>';
|
||||
|
||||
$list_args = [
|
||||
'status' => $status,
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page_num - 1) * $per_page,
|
||||
];
|
||||
if ($user_id > 0) {
|
||||
$list_args['user_id'] = $user_id;
|
||||
}
|
||||
$result = $svc->get_list($list_args);
|
||||
$items = $result['items'] ?? [];
|
||||
$total = (int) ($result['total'] ?? 0);
|
||||
$pages = max(1, (int) ceil(max(1, $total) / $per_page));
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
|
||||
echo '<th>ID</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('订单号', 'aksrt') . '</th><th>' . esc_html__('金额', 'aksrt') . '</th><th>' . esc_html__('状态', 'aksrt') . '</th><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
if (!$items) {
|
||||
echo '<tr><td colspan="7">' . esc_html__('暂无返佣记录', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
foreach ($items as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$st = (string) ($row['status'] ?? '');
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html((string) $id) . '</td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name) : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td><code>' . esc_html((string) ($row['order_number'] ?? '')) . '</code></td>';
|
||||
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . '</td>';
|
||||
echo '<td>' . esc_html($st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td style="display:flex;gap:4px;">';
|
||||
if ($st === 'pending') {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_settle_rebate" />';
|
||||
echo '<input type="hidden" name="rebate_id" value="' . esc_attr((string) $id) . '" />';
|
||||
wp_nonce_field('aksrt_pay_settle_rebate_' . $id);
|
||||
submit_button(__('结算', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_cancel_rebate" />';
|
||||
echo '<input type="hidden" name="rebate_id" value="' . esc_attr((string) $id) . '" />';
|
||||
wp_nonce_field('aksrt_pay_cancel_rebate_' . $id);
|
||||
submit_button(__('取消', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
} else {
|
||||
echo '—';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
$this->render_pagination($pages, $page_num);
|
||||
$this->render_footer();
|
||||
}
|
||||
|
||||
public function handle_settle_rebate(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$id = isset($_POST['rebate_id']) ? (int) $_POST['rebate_id'] : 0;
|
||||
check_admin_referer('aksrt_pay_settle_rebate_' . $id);
|
||||
$ok = $id > 0 && class_exists('\AKSRT\Payment\Rebate') && \AKSRT\Payment\Rebate::get_instance()->settle($id);
|
||||
wp_safe_redirect($this->page_url('aksrt_rebate', ['aksrt_pay_msg' => $ok ? 'rebate_settled' : 'rebate_failed']));
|
||||
exit;
|
||||
}
|
||||
|
||||
public function handle_cancel_rebate(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$id = isset($_POST['rebate_id']) ? (int) $_POST['rebate_id'] : 0;
|
||||
check_admin_referer('aksrt_pay_cancel_rebate_' . $id);
|
||||
$ok = $id > 0 && class_exists('\AKSRT\Payment\Rebate') && \AKSRT\Payment\Rebate::get_instance()->cancel($id);
|
||||
wp_safe_redirect($this->page_url('aksrt_rebate', ['aksrt_pay_msg' => $ok ? 'rebate_cancelled' : 'rebate_failed']));
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function ensure_cap(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
|
||||
}
|
||||
}
|
||||
|
||||
private function render_header(string $title): void {
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html($title) . '</h1>';
|
||||
$this->render_flash();
|
||||
}
|
||||
|
||||
private function render_footer(): void {
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
private function render_flash(): void {
|
||||
if (empty($_GET['aksrt_pay_msg'])) {
|
||||
return;
|
||||
}
|
||||
$code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg']));
|
||||
$map = [
|
||||
'rebate_settled' => __('返佣已结算。', 'aksrt'),
|
||||
'rebate_cancelled' => __('返佣已取消。', 'aksrt'),
|
||||
'rebate_failed' => __('返佣操作失败。', 'aksrt'),
|
||||
];
|
||||
if (!isset($map[$code])) {
|
||||
return;
|
||||
}
|
||||
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
|
||||
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
|
||||
}
|
||||
|
||||
private function page_url(string $slug, array $args = []): string {
|
||||
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
|
||||
}
|
||||
|
||||
private function render_pagination(int $pages, int $page_num): void {
|
||||
if ($pages <= 1) {
|
||||
return;
|
||||
}
|
||||
echo '<div class="tablenav"><div class="tablenav-pages">';
|
||||
echo paginate_links([
|
||||
'base' => add_query_arg('paged', '%#%'),
|
||||
'format' => '',
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
'total' => $pages,
|
||||
'current' => $page_num,
|
||||
]);
|
||||
echo '</div></div>';
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ class PayWithdrawals {
|
||||
$per_page = 20;
|
||||
$svc = \AKSRT\Payment\Withdrawal::get_instance();
|
||||
$stats = $svc->get_stats();
|
||||
$retry_id = isset($_GET['retry_id']) ? max(0, (int) $_GET['retry_id']) : 0;
|
||||
$retry_key = isset($_GET['retry_key']) ? sanitize_text_field((string) wp_unslash($_GET['retry_key'])) : '';
|
||||
if (!wp_is_uuid($retry_key)) {
|
||||
$retry_key = '';
|
||||
}
|
||||
|
||||
echo '<p>' . esc_html(sprintf(
|
||||
/* translators: 1: total 2: pending 3: completed 4: paid */
|
||||
@@ -52,7 +57,7 @@ class PayWithdrawals {
|
||||
(int) ($stats['processing'] ?? 0),
|
||||
(int) ($stats['completed'] ?? 0),
|
||||
(int) ($stats['rejected'] ?? 0),
|
||||
number_format((float) ($stats['total_paid'] ?? 0), 2)
|
||||
(string) ($stats['total_paid'] ?? '0.00')
|
||||
)) . '</p>';
|
||||
|
||||
echo '<form method="get" style="margin:12px 0;">';
|
||||
@@ -106,17 +111,17 @@ class PayWithdrawals {
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$st = (string) ($row['status'] ?? '');
|
||||
$gross = (float) ($row['gross_amount'] ?? $row['amount'] ?? 0);
|
||||
$net = (float) ($row['amount'] ?? 0);
|
||||
$fee = (int) ($row['fee_rate'] ?? 0);
|
||||
$acct = (string) ($row['display_account'] ?? $row['account_info'] ?? '');
|
||||
$amount_txt = number_format($net, 2);
|
||||
if ($gross > 0 && abs($gross - $net) > 0.001) {
|
||||
$amount_txt = sprintf(
|
||||
$gross = (string) ($row['gross_amount'] ?? '0.00');
|
||||
$net = (string) ($row['amount'] ?? '0.00');
|
||||
$fee = (int) ($row['fee_rate'] ?? 0);
|
||||
$acct = (string) ($row['display_account'] ?? $row['account_info'] ?? '');
|
||||
$amount_txt = $net;
|
||||
if ($gross !== $net) {
|
||||
$amount_txt = sprintf(
|
||||
/* translators: 1: gross 2: net 3: fee percent */
|
||||
__('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'),
|
||||
number_format($gross, 2),
|
||||
number_format($net, 2),
|
||||
$gross,
|
||||
$net,
|
||||
$fee
|
||||
);
|
||||
}
|
||||
@@ -130,15 +135,22 @@ class PayWithdrawals {
|
||||
echo '<td>' . esc_html($status_options[$st] ?? $st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:flex;gap:4px;flex-wrap:wrap;align-items:center;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_update_withdrawal" />';
|
||||
echo '<input type="hidden" name="withdrawal_id" value="' . esc_attr((string) $id) . '" />';
|
||||
$next_statuses = $st === 'pending' ? ['processing', 'rejected'] : ($st === 'processing' ? ['completed', 'rejected'] : []);
|
||||
if ($next_statuses === []) {
|
||||
echo '—';
|
||||
echo '</td>';
|
||||
echo '</tr>';
|
||||
continue;
|
||||
}
|
||||
$request_key = $retry_id === $id && $retry_key !== '' ? $retry_key : wp_generate_uuid4();
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:flex;gap:4px;flex-wrap:wrap;align-items:center;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_update_withdrawal" />';
|
||||
echo '<input type="hidden" name="withdrawal_id" value="' . esc_attr((string) $id) . '" />';
|
||||
echo '<input type="hidden" name="idempotency_key" value="' . esc_attr($request_key) . '" />';
|
||||
wp_nonce_field('aksrt_pay_update_withdrawal_' . $id);
|
||||
echo '<select name="new_status">';
|
||||
foreach ($status_options as $key => $label) {
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($next_statuses as $key) {
|
||||
$label = $status_options[$key];
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr($key),
|
||||
@@ -180,26 +192,31 @@ class PayWithdrawals {
|
||||
$id = isset($_POST['withdrawal_id']) ? (int) $_POST['withdrawal_id'] : 0;
|
||||
$new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : '';
|
||||
$note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : '';
|
||||
$request_key = isset($_POST['idempotency_key']) ? sanitize_text_field((string) wp_unslash($_POST['idempotency_key'])) : '';
|
||||
check_admin_referer('aksrt_pay_update_withdrawal_' . $id);
|
||||
|
||||
$msg = 'withdraw_failed';
|
||||
if ($id > 0 && $new_status !== '' && class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note);
|
||||
if ($id > 0 && $new_status !== '' && wp_is_uuid($request_key) && class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('admin-withdraw-transition', [get_current_user_id(), strtolower($request_key)]);
|
||||
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note, $operation_key);
|
||||
if (!is_wp_error($result) && $result) {
|
||||
$msg = 'withdraw_updated';
|
||||
} elseif (is_wp_error($result) && $result->get_error_code() === 'aksrt_balance') {
|
||||
} elseif (is_wp_error($result) && $result->get_error_code() === 'aksrt_balance_insufficient') {
|
||||
$msg = 'withdraw_refund_failed';
|
||||
}
|
||||
}
|
||||
|
||||
wp_safe_redirect($this->page_url('aksrt_withdraw', [
|
||||
'aksrt_pay_msg' => $msg,
|
||||
]));
|
||||
$redirect_args = ['aksrt_pay_msg' => $msg];
|
||||
if ($msg !== 'withdraw_updated' && wp_is_uuid($request_key)) {
|
||||
$redirect_args['retry_id'] = $id;
|
||||
$redirect_args['retry_key'] = $request_key;
|
||||
}
|
||||
wp_safe_redirect($this->page_url('aksrt_withdraw', $redirect_args));
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Income / rebate
|
||||
* Income
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
|
||||
|
||||
+125
-260
@@ -42,17 +42,35 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_creator_income'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
||||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
|
||||
'status' => ['type' => 'string', 'enum' => ['', 'pending', 'settled']],
|
||||
'user_id' => ['type' => 'integer', 'minimum' => 0, 'default' => 0],
|
||||
'post_id' => ['type' => 'integer', 'minimum' => 0, 'default' => 0],
|
||||
'currency' => ['type' => 'string', 'enum' => ['', 'CNY', 'cny', 'points']],
|
||||
'order_status' => ['type' => 'string', 'sanitize_callback' => 'sanitize_key'],
|
||||
'order_number' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'date_from' => ['type' => 'string', 'format' => 'date'],
|
||||
'date_to' => ['type' => 'string', 'format' => 'date'],
|
||||
],
|
||||
]);
|
||||
register_rest_route(self::API_NAMESPACE, '/payment/creator-income/(?P<id>\d+)/settle', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'settle_creator_income'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/payment/withdrawals', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_withdrawals'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
||||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 20],
|
||||
'status' => ['type' => 'string', 'enum' => ['', 'pending', 'processing', 'completed', 'rejected']],
|
||||
],
|
||||
]);
|
||||
|
||||
// Withdrawal status update
|
||||
@@ -60,6 +78,11 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'update_withdrawal_status'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'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'],
|
||||
],
|
||||
]);
|
||||
|
||||
// ── Charge Card ──
|
||||
@@ -69,11 +92,20 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_charge_cards'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
||||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
|
||||
'status' => ['type' => 'string', 'enum' => ['', 'active', 'used', 'expired', 'disabled']],
|
||||
'keyword' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'date_from' => ['type' => 'string', 'format' => 'date'],
|
||||
'date_to' => ['type' => 'string', 'format' => 'date'],
|
||||
],
|
||||
]);
|
||||
register_rest_route(self::API_NAMESPACE, '/payment/charge-cards/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_charge_card_detail'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
|
||||
]);
|
||||
|
||||
// Charge card create
|
||||
@@ -81,6 +113,16 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_charge_card'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'type' => ['type' => 'string', 'required' => true, 'enum' => ['balance', 'vip', 'credits']],
|
||||
'password' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 80, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'amount' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'points' => ['type' => 'integer', 'minimum' => 1],
|
||||
'level' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 2],
|
||||
'days' => ['type' => 'integer', 'minimum' => 0],
|
||||
'expires_at' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'note' => ['type' => 'string', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Charge card batch create
|
||||
@@ -88,6 +130,18 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'batch_create_charge_cards'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'type' => ['type' => 'string', 'required' => true, 'enum' => ['balance', 'vip', 'credits']],
|
||||
'count' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'required' => true],
|
||||
'amount' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'points' => ['type' => 'integer', 'minimum' => 1],
|
||||
'level' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 2],
|
||||
'days' => ['type' => 'integer', 'minimum' => 0],
|
||||
'prefix' => ['type' => 'string', 'maxLength' => 20, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'length' => ['type' => 'integer', 'minimum' => 8, 'maximum' => 32, 'default' => 16],
|
||||
'expires_at' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'note' => ['type' => 'string', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Charge card disable
|
||||
@@ -95,6 +149,7 @@ class PaymentController extends ControllerBase {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'disable_charge_card'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
|
||||
]);
|
||||
|
||||
}
|
||||
@@ -129,14 +184,14 @@ class PaymentController extends ControllerBase {
|
||||
*/
|
||||
public function get_revenue(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$revenue_stats = [
|
||||
'cash' => ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0],
|
||||
'cash' => ['total' => '0.00', 'pending' => '0.00', 'settled' => '0.00', 'count' => 0],
|
||||
'points' => ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0],
|
||||
];
|
||||
if (class_exists('\AKSRT\Payment\Revenue')) {
|
||||
$revenue_stats = \AKSRT\Payment\Revenue::get_instance()->get_stats();
|
||||
}
|
||||
|
||||
$withdrawal_stats = ['total' => 0, 'pending' => 0, 'processing' => 0, 'completed' => 0, 'rejected' => 0, 'total_paid' => 0];
|
||||
$withdrawal_stats = ['total' => 0, 'pending' => 0, 'processing' => 0, 'completed' => 0, 'rejected' => 0, 'total_paid' => '0.00'];
|
||||
if (class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
$withdrawal_stats = \AKSRT\Payment\Withdrawal::get_instance()->get_stats();
|
||||
}
|
||||
@@ -201,9 +256,19 @@ class PaymentController extends ControllerBase {
|
||||
$series = ['cash' => [], 'points' => []];
|
||||
foreach ((array) $results as $row) {
|
||||
$key = (string) ($row->currency ?? '') === 'points' ? 'points' : 'cash';
|
||||
$series[$key][] = [
|
||||
$amount = (string) ($row->total_amount ?? '0');
|
||||
if ($key === 'points') {
|
||||
$amount = preg_match('/^(0|[1-9]\d*)(?:\.0+)?$/', $amount, $matches) ? (int) $matches[1] : 0;
|
||||
} else {
|
||||
try {
|
||||
$amount = \AKSRT\Ledger\AssetAmount::balance($amount)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$amount = '0.00';
|
||||
}
|
||||
}
|
||||
$series[$key][] = [
|
||||
'date' => (string) $row->period,
|
||||
'amount' => (float) $row->total_amount,
|
||||
'amount' => $amount,
|
||||
'count' => (int) $row->order_count,
|
||||
];
|
||||
}
|
||||
@@ -245,10 +310,29 @@ class PaymentController extends ControllerBase {
|
||||
return $this->error(__('创作分成模块未启用', 'aksrt'), 500);
|
||||
}
|
||||
$id = (int) $request->get_param('id');
|
||||
if ($id <= 0 || !\AKSRT\Payment\Revenue::get_instance()->settle($id, get_current_user_id())) {
|
||||
return $this->error(__('结算失败', 'aksrt'));
|
||||
}
|
||||
return $this->success(['message' => __('创作分成已结算', 'aksrt')]);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-revenue-settle', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $this->error($operation_key->get_error_message(), (int) ($operation_key->get_error_data()['status'] ?? 400));
|
||||
}
|
||||
$result = \AKSRT\Payment\Revenue::get_instance()->settle_result($id, get_current_user_id(), $operation_key);
|
||||
if (is_wp_error($result)) {
|
||||
$data = $result->get_error_data();
|
||||
return $this->error($result->get_error_message(), is_array($data) ? (int) ($data['status'] ?? 400) : 400);
|
||||
}
|
||||
$entry = (array) ($result->entries[0] ?? []);
|
||||
$asset = (string) ($entry['asset'] ?? \AKSRT\Ledger\AssetType::BALANCE->value);
|
||||
$author_id = (int) ($entry['user_id'] ?? 0);
|
||||
$balance_minor = $result->balance($author_id, $asset);
|
||||
$balance = $asset === \AKSRT\Ledger\AssetType::BALANCE->value
|
||||
? \AKSRT\Ledger\AssetAmount::decimal_from_minor($balance_minor)
|
||||
: $balance_minor;
|
||||
return $this->success([
|
||||
'message' => __('创作分成已结算', 'aksrt'),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'asset' => $asset,
|
||||
'balance' => $balance,
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_withdrawals(\WP_REST_Request $request): \WP_REST_Response {
|
||||
@@ -293,7 +377,7 @@ class PaymentController extends ControllerBase {
|
||||
$new_status = sanitize_key((string) ($params['status'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
|
||||
$valid_statuses = ['pending', 'processing', 'completed', 'rejected'];
|
||||
$valid_statuses = ['processing', 'completed', 'rejected'];
|
||||
if (!in_array($new_status, $valid_statuses, true)) {
|
||||
return $this->error('无效的提现状态');
|
||||
}
|
||||
@@ -302,13 +386,18 @@ class PaymentController extends ControllerBase {
|
||||
return $this->error('无效的提现 ID');
|
||||
}
|
||||
|
||||
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-withdraw-transition', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $this->error($operation_key->get_error_message(), (int) ($operation_key->get_error_data()['status'] ?? 400));
|
||||
}
|
||||
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note, $operation_key);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $this->error($result->get_error_message());
|
||||
$data = $result->get_error_data();
|
||||
return $this->error($result->get_error_message(), is_array($data) ? (int) ($data['status'] ?? 400) : 400);
|
||||
}
|
||||
if ($result) {
|
||||
return $this->success(['message' => __('提现状态已更新', 'aksrt')]);
|
||||
if (is_array($result)) {
|
||||
return $this->success(array_merge(['message' => __('提现状态已更新', 'aksrt')], $result));
|
||||
}
|
||||
return $this->error(__('更新失败', 'aksrt'));
|
||||
}
|
||||
@@ -359,7 +448,6 @@ class PaymentController extends ControllerBase {
|
||||
foreach ((array) $rows as $row) {
|
||||
$item = $this->build_charge_card_admin_item($row);
|
||||
if ($keyword !== '' && mb_stripos(implode(' ', array_filter([
|
||||
(string) ($item['code'] ?? ''),
|
||||
(string) ($item['password'] ?? ''),
|
||||
(string) ($item['card'] ?? ''),
|
||||
(string) ($item['type_label'] ?? ''),
|
||||
@@ -410,7 +498,7 @@ class PaymentController extends ControllerBase {
|
||||
if (!in_array($type, ['balance', 'vip', 'credits'], true)) {
|
||||
$type = 'balance';
|
||||
}
|
||||
$password = sanitize_text_field((string) ($params['password'] ?? $params['code'] ?? ''));
|
||||
$password = sanitize_text_field((string) ($params['password'] ?? ''));
|
||||
if ($password === '') {
|
||||
return $this->error('卡密不能为空');
|
||||
}
|
||||
@@ -449,15 +537,22 @@ class PaymentController extends ControllerBase {
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
$expires = sanitize_text_field((string) ($params['expires_at'] ?? ''));
|
||||
|
||||
$raw_amount = $params['amount'] ?? '';
|
||||
$meta_fields = [
|
||||
'amount' => (float) ($params['amount'] ?? 0),
|
||||
'amount' => is_string($raw_amount) ? $raw_amount : '',
|
||||
'level' => (int) ($params['level'] ?? 1),
|
||||
'days' => (int) ($params['days'] ?? $params['vip_days'] ?? 30),
|
||||
'points' => (int) ($params['points'] ?? $params['credits'] ?? 0),
|
||||
'days' => (int) ($params['days'] ?? 30),
|
||||
'points' => (int) ($params['points'] ?? 0),
|
||||
];
|
||||
|
||||
if ($type === 'balance' && $meta_fields['amount'] <= 0) {
|
||||
return $this->error('余额卡金额必须大于 0');
|
||||
if ($type === 'balance') {
|
||||
try {
|
||||
$card_amount = \AKSRT\Ledger\AssetAmount::balance($meta_fields['amount']);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error('余额卡金额格式无效');
|
||||
}
|
||||
if ($card_amount->minor() <= 0) return $this->error('余额卡金额必须大于 0');
|
||||
$meta_fields['amount'] = $card_amount->decimal();
|
||||
}
|
||||
if ($type === 'credits' && $meta_fields['points'] <= 0) {
|
||||
return $this->error('积分卡积分必须大于 0');
|
||||
@@ -522,171 +617,6 @@ class PaymentController extends ControllerBase {
|
||||
]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Rebate
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* GET /payment/rebate/log — 返佣记录列表
|
||||
*/
|
||||
public function get_rebate_list(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Rebate')) {
|
||||
return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'max_pages' => 1]]);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$table = \AKSRT\Core\DBMigrator::table('rebate_log');
|
||||
$page = max(1, (int) ($request->get_param('page') ?: 1));
|
||||
$per_page = max(1, min(100, (int) ($request->get_param('per_page') ?: 20)));
|
||||
$status = sanitize_key((string) $request->get_param('status'));
|
||||
$type = sanitize_key((string) $request->get_param('type'));
|
||||
$keyword = trim(sanitize_text_field((string) $request->get_param('keyword')));
|
||||
$user_id = (int) $request->get_param('user_id');
|
||||
$date_from = sanitize_text_field((string) $request->get_param('date_from'));
|
||||
$date_to = sanitize_text_field((string) $request->get_param('date_to'));
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($type !== '') {
|
||||
$where[] = 'type = %s';
|
||||
$params[] = $type;
|
||||
}
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
if ($date_from !== '') {
|
||||
$where[] = 'created_at >= %s';
|
||||
$params[] = $date_from . ' 00:00:00';
|
||||
}
|
||||
if ($date_to !== '') {
|
||||
$where[] = 'created_at <= %s';
|
||||
$params[] = $date_to . ' 23:59:59';
|
||||
}
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT 500";
|
||||
$rows = $params ? $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A);
|
||||
|
||||
$items = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$item = $this->build_rebate_admin_item($row);
|
||||
if ($keyword !== '' && mb_stripos(implode(' ', array_filter([
|
||||
(string) ($item['order_number'] ?? ''),
|
||||
(string) ($item['note'] ?? ''),
|
||||
(string) ($item['user']['display_name'] ?? ''),
|
||||
(string) ($item['source_user']['display_name'] ?? ''),
|
||||
])), $keyword) === false) {
|
||||
continue;
|
||||
}
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
$total = count($items);
|
||||
$offset = ($page - 1) * $per_page;
|
||||
$stats = \AKSRT\Payment\Rebate::get_instance()->get_stats();
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'items' => array_values(array_slice($items, $offset, $per_page)),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
'max_pages' => max(1, (int) ceil(max(1, $total) / $per_page)),
|
||||
'stats' => $stats,
|
||||
'options' => [
|
||||
'statuses' => [
|
||||
['label' => __('全部状态', 'aksrt'), 'value' => ''],
|
||||
['label' => __('待结算', 'aksrt'), 'value' => 'pending'],
|
||||
['label' => __('已结算', 'aksrt'), 'value' => 'settled'],
|
||||
['label' => __('已取消', 'aksrt'), 'value' => 'cancelled'],
|
||||
],
|
||||
'types' => [
|
||||
['label' => __('全部类型', 'aksrt'), 'value' => ''],
|
||||
['label' => __('订单返佣', 'aksrt'), 'value' => 'order'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment/rebate/{id}/settle — 结算返佣
|
||||
*/
|
||||
public function settle_rebate(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Rebate')) {
|
||||
return $this->error('返佣系统未启用', 500);
|
||||
}
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
if ($id <= 0) {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$success = \AKSRT\Payment\Rebate::get_instance()->settle($id);
|
||||
|
||||
if ($success) {
|
||||
$this->append_rebate_process_log($id, 'settled', $note);
|
||||
return $this->success(['message' => __('返佣已结算', 'aksrt')]);
|
||||
}
|
||||
return $this->error(__('结算失败', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment/rebate/{id}/cancel — 取消返佣
|
||||
*/
|
||||
public function cancel_rebate(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Rebate')) {
|
||||
return $this->error('返佣系统未启用', 500);
|
||||
}
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
if ($id <= 0) {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$success = \AKSRT\Payment\Rebate::get_instance()->cancel($id);
|
||||
|
||||
if ($success) {
|
||||
$this->append_rebate_process_log($id, 'cancelled', $note);
|
||||
return $this->success(['message' => __('返佣已取消', 'aksrt')]);
|
||||
}
|
||||
return $this->error(__('操作失败', 'aksrt'));
|
||||
}
|
||||
|
||||
public function get_rebate_detail(\WP_REST_Request $request): \WP_REST_Response {
|
||||
global $wpdb;
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
$table = \AKSRT\Core\DBMigrator::table('rebate_log');
|
||||
|
||||
if ($id <= 0 || !$table) {
|
||||
return $this->error(__('返佣记录不存在', 'aksrt'), 404);
|
||||
}
|
||||
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A);
|
||||
if (!$row) {
|
||||
return $this->error(__('返佣记录不存在', 'aksrt'), 404);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'record' => $this->build_rebate_admin_item($row),
|
||||
'history' => $this->get_rebate_process_log($id),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function build_charge_card_admin_item(array $row): array {
|
||||
$used_by = (int) ($row['used_by'] ?? 0);
|
||||
$user = $used_by > 0 ? get_userdata($used_by) : null;
|
||||
@@ -716,18 +646,17 @@ class PaymentController extends ControllerBase {
|
||||
}
|
||||
}
|
||||
|
||||
$password = (string) ($row['password'] ?? $row['code'] ?? '');
|
||||
$password = (string) ($row['password'] ?? '');
|
||||
$card_no = (string) ($row['card'] ?? '');
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'code' => $password,
|
||||
'password' => $password,
|
||||
'card' => $card_no,
|
||||
'type' => $type,
|
||||
'type_label' => $typeMap[$type] ?? $type,
|
||||
'amount' => (float) ($row['amount'] ?? 0),
|
||||
'balance' => (float) ($row['balance'] ?? 0),
|
||||
'amount' => $this->cash_decimal((string) ($meta['amount'] ?? $row['amount'] ?? '0')),
|
||||
'balance' => $this->cash_decimal((string) ($row['balance'] ?? '0')),
|
||||
'meta' => $meta,
|
||||
'level' => (int) ($meta['level'] ?? 0),
|
||||
'days' => (int) ($meta['days'] ?? 0),
|
||||
@@ -748,76 +677,12 @@ class PaymentController extends ControllerBase {
|
||||
];
|
||||
}
|
||||
|
||||
private function build_rebate_admin_item(array $row): array {
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
$source_user_id = (int) ($row['source_user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$source_user = $source_user_id > 0 ? get_userdata($source_user_id) : null;
|
||||
$status = sanitize_key((string) ($row['status'] ?? 'pending'));
|
||||
$type = sanitize_key((string) ($row['type'] ?? 'order'));
|
||||
private function cash_decimal(string $value): string {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
$statusMap = [
|
||||
'pending' => __('待结算', 'aksrt'),
|
||||
'settled' => __('已结算', 'aksrt'),
|
||||
'cancelled' => __('已取消', 'aksrt'),
|
||||
];
|
||||
$typeMap = [
|
||||
'order' => __('订单返佣', 'aksrt'),
|
||||
];
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'order_number' => (string) ($row['order_number'] ?? ''),
|
||||
'amount' => (float) ($row['amount'] ?? 0),
|
||||
'status' => $status,
|
||||
'status_label' => $statusMap[$status] ?? $status,
|
||||
'type' => $type,
|
||||
'type_label' => $typeMap[$type] ?? $type,
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'settled_at' => (string) ($row['settled_at'] ?? ''),
|
||||
'note' => (string) ($row['note'] ?? ''),
|
||||
'history_count' => count($this->get_rebate_process_log((int) ($row['id'] ?? 0))),
|
||||
'user' => $user instanceof \WP_User ? [
|
||||
'id' => (int) $user->ID,
|
||||
'display_name' => $user->display_name,
|
||||
'email' => $user->user_email,
|
||||
] : null,
|
||||
'source_user' => $source_user instanceof \WP_User ? [
|
||||
'id' => (int) $source_user->ID,
|
||||
'display_name' => $source_user->display_name,
|
||||
'email' => $source_user->user_email,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function append_rebate_process_log(int $rebate_id, string $event, string $note = ''): void {
|
||||
$log = get_option('aksrt_rebate_process_log', []);
|
||||
if (!is_array($log)) {
|
||||
$log = [];
|
||||
}
|
||||
|
||||
$rebate_id = (int) $rebate_id;
|
||||
if (!isset($log[$rebate_id]) || !is_array($log[$rebate_id])) {
|
||||
$log[$rebate_id] = [];
|
||||
}
|
||||
|
||||
$log[$rebate_id][] = [
|
||||
'event' => sanitize_key($event),
|
||||
'note' => sanitize_textarea_field($note),
|
||||
'operator' => get_current_user_id(),
|
||||
'operator_name' => ($user = wp_get_current_user()) ? $user->display_name : '',
|
||||
'time' => current_time('mysql'),
|
||||
];
|
||||
|
||||
update_option('aksrt_rebate_process_log', $log, false);
|
||||
}
|
||||
|
||||
private function get_rebate_process_log(int $rebate_id): array {
|
||||
$log = get_option('aksrt_rebate_process_log', []);
|
||||
if (!is_array($log) || !isset($log[$rebate_id]) || !is_array($log[$rebate_id])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values($log[$rebate_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ class SchemaPayment {
|
||||
['key' => 'basic', 'title' => '充值开关', 'fields' => [
|
||||
['key' => 'pay_balance_s', 'label' => '启用余额功能', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'pay_balance_desc', 'label' => '充值说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'pay_balance_s', 'value' => true]],
|
||||
['key' => 'pay_auto_to_balance', 'label' => '自动到账到余额', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_balance_s', 'value' => true]],
|
||||
]],
|
||||
['key' => 'packages', 'title' => '充值套餐', 'fields' => [
|
||||
['key' => 'pay_balance_product', 'label' => '套餐列表', 'type' => 'textarea', 'default' => "30|30|入门包\n98|108|推荐包", 'dependsOn' => ['key' => 'pay_balance_s', 'value' => true]],
|
||||
@@ -105,7 +104,7 @@ class SchemaPayment {
|
||||
['label' => '优惠立减', 'value' => 'reduce'],
|
||||
]],
|
||||
['key' => 'vip_renew_discount', 'label' => '续费折扣(折,如 8=八折)', 'type' => 'number', 'default' => 8, 'min' => 1, 'max' => 100, 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'discount']],
|
||||
['key' => 'vip_renew_reduce', 'label' => '续费减免金额', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 9999, 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'reduce']],
|
||||
['key' => 'vip_renew_reduce', 'label' => '续费减免金额', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '20.00', 'min' => 0, 'max' => 9999, 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'reduce']],
|
||||
]],
|
||||
['key' => 'upgrade', 'title' => '升级设置', 'fields' => [
|
||||
['key' => 'vip_upgrade', 'label' => '允许升级', 'type' => 'switch', 'default' => true],
|
||||
@@ -297,7 +296,7 @@ class SchemaPayment {
|
||||
['key' => 'redeem', 'title' => '兑换设置', 'fields' => [
|
||||
['key' => 'card_pass_max_redeem', 'label' => '单卡最大兑换次数', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 100, 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
['key' => 'card_pass_expiry', 'label' => '有效期(天)', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '0为永久有效', 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
['key' => 'card_pass_amount', 'label' => '面额(元)', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 9999, 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
['key' => 'card_pass_amount', 'label' => '面额(元)', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '10.00', 'min' => 1, 'max' => 9999, 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
['key' => 'card_pass_vip_days', 'label' => 'VIP天数', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 3650, 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
['key' => 'card_pass_credits', 'label' => '赠送积分', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999, 'dependsOn' => ['key' => 'card_pass_enabled', 'value' => true]],
|
||||
]],
|
||||
@@ -315,10 +314,10 @@ class SchemaPayment {
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'basic', 'title' => '提现设置', 'fields' => [
|
||||
['key' => 'affiliate_min_withdrawal', 'label' => '最低提现金额', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 99999],
|
||||
['key' => 'affiliate_min_withdrawal', 'label' => '最低提现金额', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '50.00', 'desc' => '仅接受最多两位小数的十进制字符串。'],
|
||||
['key' => 'affiliate_withdrawal_fee', 'label' => '提现手续费(%)', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 100],
|
||||
['key' => 'affiliate_daily_withdrawal_count', 'label' => '每日可提现次数', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 99999],
|
||||
['key' => 'affiliate_daily_withdrawal_limit', 'label' => '每日提现上限', 'type' => 'number', 'default' => 500, 'min' => 0, 'max' => 999999],
|
||||
['key' => 'affiliate_daily_withdrawal_limit', 'label' => '每日提现上限', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '500.00', 'desc' => '仅接受最多两位小数的十进制字符串。'],
|
||||
['key' => 'affiliate_withdrawal_reset_time', 'label' => '重置时间', 'type' => 'input', 'default' => '00:00', 'help' => '按站点 WordPress 时区重置,格式 HH:MM。'],
|
||||
['key' => 'affiliate_payout_channels', 'label' => '可用收款渠道', 'type' => 'checkbox', 'options' => [['value' => 'alipay', 'label' => '支付宝收款码'], ['value' => 'wechat', 'label' => '微信收款码']], 'default' => ['alipay', 'wechat']],
|
||||
]],
|
||||
|
||||
@@ -114,8 +114,8 @@ class SchemaPostList {
|
||||
['key' => 'post_rewards_text', 'label' => '打赏按钮文案', 'type' => 'input', 'default' => '赞赏'],
|
||||
['key' => 'post_rewards_amounts', 'label' => '快捷金额', 'type' => 'input', 'default' => '5,10,20,50,100', 'description' => '英文逗号分隔,赞赏直接进入作者站内余额'],
|
||||
['key' => 'post_rewards_custom', 'label' => '允许自定义金额', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'post_rewards_min', 'label' => '最低金额', 'type' => 'number', 'default' => 1, 'min' => 0.01, 'max' => 1000, 'step' => 0.01],
|
||||
['key' => 'post_rewards_max', 'label' => '最高金额', 'type' => 'number', 'default' => 1000, 'min' => 1, 'max' => 100000, 'step' => 0.01],
|
||||
['key' => 'post_rewards_min', 'label' => '最低金额', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '1.00', 'min' => 0.01, 'max' => 1000, 'step' => 0.01],
|
||||
['key' => 'post_rewards_max', 'label' => '最高金额', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '1000.00', 'min' => 1, 'max' => 100000, 'step' => 0.01],
|
||||
['key' => 'post_button_toptext', 'label' => '按钮上方文案', 'type' => 'input', 'default' => '喜欢就支持一下吧'],
|
||||
]],
|
||||
['key' => 'content', 'title' => '内容处理', 'fields' => [
|
||||
@@ -346,8 +346,8 @@ class SchemaPostList {
|
||||
]],
|
||||
['key' => 'paid', 'title' => '付费投稿权限', 'fields' => [
|
||||
['key' => 'post_article_pay_free_s', 'label' => '允许设置免费', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'post_article_pay_price_min', 'label' => '最低价格', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'help' => '投稿可设置的最低付费金额'],
|
||||
['key' => 'post_article_pay_price_max', 'label' => '最高价格', 'type' => 'number', 'default' => 100, 'min' => 0, 'max' => 9999, 'help' => '投稿可设置的最高付费金额'],
|
||||
['key' => 'post_article_pay_price_min', 'label' => '最低价格', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '0.00', 'min' => 0, 'max' => 9999, 'help' => '投稿可设置的最低付费金额'],
|
||||
['key' => 'post_article_pay_price_max', 'label' => '最高价格', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '100.00', 'min' => 0, 'max' => 9999, 'help' => '投稿可设置的最高付费金额'],
|
||||
['key' => 'post_article_pay_vip_price_s', 'label' => '允许设置会员价', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'post_article_pay_vip_1_discount', 'label' => 'VIP1 折扣(%)', 'type' => 'number', 'default' => 80, 'min' => 0, 'max' => 100, 'help' => 'VIP1 会员折扣百分比,80 即 8 折'],
|
||||
['key' => 'post_article_pay_vip_2_discount', 'label' => 'VIP2 折扣(%)', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 100, 'help' => 'VIP2 会员折扣百分比,50 即 5 折'],
|
||||
|
||||
@@ -514,9 +514,9 @@ class SchemaUsers {
|
||||
['key' => 'points_deduction_max', 'label' => '最大抵扣比例(%)', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 100, 'help' => '预留:最多可抵订单金额百分比。当前未参与下单。'],
|
||||
]],
|
||||
['key' => 'card_pass', 'title' => '积分卡密兑换', 'fields' => [
|
||||
['key' => 'points_pass_exchange_s', 'label' => '启用积分卡密兑换', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'points_pass_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'points_pass_exchange_s', 'value' => true]],
|
||||
['key' => 'points_pass_exchange_only_password', 'label' => '仅密码兑换', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'points_pass_exchange_s', 'value' => true]],
|
||||
['key' => 'asset_points_card_enabled', 'label' => '启用积分卡密兑换', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'asset_points_card_description', 'label' => '兑换说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'asset_points_card_enabled', 'value' => true]],
|
||||
['key' => 'asset_points_card_password_only', 'label' => '仅密码兑换', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'asset_points_card_enabled', 'value' => true]],
|
||||
]],
|
||||
['key' => 'display', 'title' => '展示设置', 'fields' => [
|
||||
['key' => 'points_show_balance', 'label' => '导航栏显示余额', 'type' => 'switch', 'default' => true],
|
||||
@@ -591,9 +591,9 @@ class SchemaUsers {
|
||||
['key' => 'apply', 'title' => '申请设置', 'fields' => [
|
||||
['key' => 'user_verify_allow_apply', 'label' => '允许用户前台申请', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'user_verify_disable_desc', 'label' => '停用申请说明', 'type' => 'textarea', 'default' => "由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员"],
|
||||
['key' => 'user_verify_personal_fee', 'label' => '个人认证每次扣余额(元)', 'type' => 'number', 'sanitize_type' => 'float', 'default' => 0, 'min' => 0, 'step' => 0.01],
|
||||
['key' => 'user_verify_personal_fee', 'label' => '个人认证每次扣余额(元)', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '0.00', 'desc' => '仅接受最多两位小数的十进制字符串。'],
|
||||
['key' => 'user_verify_personal_free_applications', 'label' => '个人认证赠送初始次数', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'desc' => '每位用户前 N 次个人认证申请免费,之后按单次费用扣余额。'],
|
||||
['key' => 'user_verify_enterprise_fee', 'label' => '企业认证每次扣余额(元)', 'type' => 'number', 'sanitize_type' => 'float', 'default' => 0, 'min' => 0, 'step' => 0.01],
|
||||
['key' => 'user_verify_enterprise_fee', 'label' => '企业认证每次扣余额(元)', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '0.00', 'desc' => '仅接受最多两位小数的十进制字符串。'],
|
||||
['key' => 'user_verify_enterprise_free_applications', 'label' => '企业认证赠送初始次数', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'desc' => '每位用户前 N 次企业认证申请免费,之后按单次费用扣余额。'],
|
||||
['key' => 'user_verify_desc', 'label' => '申请介绍', 'type' => 'textarea', 'default' => '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。'],
|
||||
]],
|
||||
|
||||
@@ -170,6 +170,17 @@ class SchemaProvider {
|
||||
$errors[] = sprintf('%s is required', $field['label'] ?? $key);
|
||||
continue;
|
||||
}
|
||||
if (($field['sanitize_type'] ?? '') === 'money_decimal') {
|
||||
try {
|
||||
if (!is_string($value)) {
|
||||
throw new \InvalidArgumentException('money_decimal_must_be_string');
|
||||
}
|
||||
\AKSRT\Ledger\AssetAmount::balance(trim($value));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$errors[] = sprintf('%s must be a decimal string with at most two fractional digits', $field['label'] ?? $key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$type = $field['type'] ?? 'input';
|
||||
if (($type === 'number' || $type === 'slider') && !is_numeric($value)) {
|
||||
@@ -259,13 +270,20 @@ class SchemaProvider {
|
||||
$stored = get_option('aksrt_settings', []);
|
||||
$merged = is_array($stored) ? array_merge($stored, $data) : $data;
|
||||
$count = $merged['affiliate_daily_withdrawal_count'] ?? 3;
|
||||
$min = (float) ($merged['affiliate_min_withdrawal'] ?? 50);
|
||||
$limit = (float) ($merged['affiliate_daily_withdrawal_limit'] ?? 500);
|
||||
$min_raw = $merged['affiliate_min_withdrawal'] ?? '50.00';
|
||||
$limit_raw = $merged['affiliate_daily_withdrawal_limit'] ?? '500.00';
|
||||
$time = (string) ($merged['affiliate_withdrawal_reset_time'] ?? '00:00');
|
||||
if (filter_var($count, FILTER_VALIDATE_INT) === false || (int) $count < 0) {
|
||||
$errors[] = __('每日可提现次数必须是非负整数', 'aksrt');
|
||||
}
|
||||
if ($limit < $min) {
|
||||
try {
|
||||
$min_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($min_raw) ? $min_raw : '')->minor();
|
||||
$limit_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($limit_raw) ? $limit_raw : '')->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$min_minor = 0;
|
||||
$limit_minor = 0;
|
||||
}
|
||||
if ($limit_minor < $min_minor) {
|
||||
$errors[] = __('每日提现上限不得低于最低提现金额', 'aksrt');
|
||||
}
|
||||
if (preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', $time) !== 1) {
|
||||
@@ -463,6 +481,15 @@ class SchemaProvider {
|
||||
return !empty($value);
|
||||
case 'float':
|
||||
return (float) $value;
|
||||
case 'money_decimal':
|
||||
if (!is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '';
|
||||
}
|
||||
case 'array':
|
||||
// 深层字符串净化,保留嵌套服务商配置(如 geetest.captcha_id)
|
||||
if (!is_array($value)) {
|
||||
|
||||
+139
-33
@@ -108,39 +108,60 @@ class UserController extends ControllerBase {
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Credits settings (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/credits/settings', [
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/settings', [
|
||||
'methods' => ['GET', 'POST'],
|
||||
'callback' => [$this, 'rest_handle_credit_settings'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Credits — current user balance (frontend)
|
||||
register_rest_route(self::API_NAMESPACE, '/credits/me', [
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/me', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_get_credits_me'],
|
||||
'permission_callback' => function () { return is_user_logged_in(); },
|
||||
'permission_callback' => [$this, 'check_asset_self_permission'],
|
||||
]);
|
||||
|
||||
// Credits — user list (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/credits/users', [
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/accounts', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_list_credit_users'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Credits — adjust user balance (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/credits/users/(?P<id>\d+)/adjust', [
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/accounts/(?P<id>\d+)/adjust', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_adjust_user_credits'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
|
||||
'asset' => ['type' => 'string', 'enum' => ['points', 'balance'], 'required' => true],
|
||||
'delta' => [
|
||||
'required' => true,
|
||||
'oneOf' => [
|
||||
['type' => 'integer'],
|
||||
['type' => 'string', 'pattern' => '^-?(?:0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
],
|
||||
],
|
||||
'remark' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Credits — transaction log (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/credits/log', [
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/transactions', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_get_credits_log'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'transaction_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'operation_key' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'user_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'actor_user_id' => ['type' => 'integer', 'minimum' => 1],
|
||||
'asset' => ['type' => 'string', 'enum' => ['points', 'balance']],
|
||||
'reason' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_key'],
|
||||
'ref_type' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_key'],
|
||||
'ref_key' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
||||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
|
||||
'date_from' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'date_to' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Report ticket list (admin)
|
||||
@@ -639,60 +660,145 @@ class UserController extends ControllerBase {
|
||||
$user_id = get_current_user_id();
|
||||
return $this->success(['data' => [
|
||||
'user_id' => $user_id,
|
||||
'balance' => class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::balance($user_id) : 0,
|
||||
'points' => \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
]]);
|
||||
}
|
||||
|
||||
public function check_asset_self_permission(\WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_auth_required', __('请先登录', '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;
|
||||
}
|
||||
|
||||
public function rest_list_credit_users(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$args = [
|
||||
'search' => (string) $request->get_param('search'),
|
||||
'page' => (int) $request->get_param('page'),
|
||||
'per_page' => (int) $request->get_param('per_page'),
|
||||
];
|
||||
$data = class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::list_users($args) : ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20];
|
||||
$asset = sanitize_key((string) $request->get_param('asset'));
|
||||
$asset = in_array($asset, ['points', 'balance'], true) ? $asset : 'points';
|
||||
$data = \AKSRT\Ledger\LedgerQuery::get_instance()->list_accounts($asset, $args);
|
||||
foreach ($data['items'] as &$item) {
|
||||
$item['asset'] = $asset;
|
||||
$item['balance'] = $asset === 'balance'
|
||||
? \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $item['balance_minor'])->decimal()
|
||||
: (int) $item['balance_minor'];
|
||||
unset($item['balance_minor']);
|
||||
}
|
||||
unset($item);
|
||||
return $this->success(['data' => $data]);
|
||||
}
|
||||
|
||||
public function rest_adjust_user_credits(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Credits\Credits')) {
|
||||
return $this->error(__('积分系统不可用', 'aksrt'), 500);
|
||||
}
|
||||
|
||||
$user_id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$delta = (int) ($params['delta'] ?? 0);
|
||||
$reason = sanitize_text_field((string) ($params['reason'] ?? 'admin'));
|
||||
|
||||
if ($user_id <= 0 || $delta === 0) {
|
||||
return $this->error(__('用户或积分变动值无效', 'aksrt'), 422);
|
||||
}
|
||||
|
||||
if ($delta > 0) {
|
||||
$result = \AKSRT\Credits\Credits::add($user_id, $delta, $reason, ['type' => 'user', 'id' => $user_id], 'Admin adjustment');
|
||||
} else {
|
||||
$result = \AKSRT\Credits\Credits::deduct($user_id, abs($delta), $reason, ['type' => 'user', 'id' => $user_id], 'Admin adjustment');
|
||||
}
|
||||
$asset = sanitize_key((string) ($params['asset'] ?? ''));
|
||||
$raw_delta = $params['delta'] ?? null;
|
||||
$remark = sanitize_text_field((string) ($params['remark'] ?? ''));
|
||||
if ($user_id <= 0 || !in_array($asset, ['points', 'balance'], true) || !get_userdata($user_id)) {
|
||||
return $this->error(__('用户或资产类型无效', 'aksrt'), 400);
|
||||
}
|
||||
if (!current_user_can('edit_user', $user_id)) {
|
||||
return $this->error(__('无权调整该用户资产', 'aksrt'), 403);
|
||||
}
|
||||
$negative = is_string($raw_delta) && str_starts_with($raw_delta, '-');
|
||||
$unsigned = $negative ? substr($raw_delta, 1) : $raw_delta;
|
||||
try {
|
||||
if ($asset === 'points') {
|
||||
if (!is_int($raw_delta) || $raw_delta === PHP_INT_MIN) throw new \InvalidArgumentException();
|
||||
$minor = abs($raw_delta);
|
||||
$negative = $raw_delta < 0;
|
||||
} else {
|
||||
if (!is_string($unsigned)) throw new \InvalidArgumentException();
|
||||
$minor = \AKSRT\Ledger\AssetAmount::balance($unsigned)->minor();
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error(__('资产变动金额格式无效', 'aksrt'), 400);
|
||||
}
|
||||
if ($minor <= 0) return $this->error(__('资产变动金额必须非零', 'aksrt'), 400);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-adjustment', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) return new \WP_REST_Response(['success' => false, 'message' => $operation_key->get_error_message()], 400);
|
||||
$service = \AKSRT\Ledger\LedgerService::get_instance();
|
||||
$result = $negative
|
||||
? $service->debit($user_id, $asset, $minor, $operation_key, 'admin_adjustment', ['type' => 'user', 'id' => $user_id], $remark, get_current_user_id())
|
||||
: $service->credit($user_id, $asset, $minor, $operation_key, 'admin_adjustment', ['type' => 'user', 'id' => $user_id], $remark, get_current_user_id());
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $this->error($result->get_error_message(), 422);
|
||||
$status = (int) (($result->get_error_data()['status'] ?? 400));
|
||||
return $this->error($result->get_error_message(), $status);
|
||||
}
|
||||
|
||||
return $this->success(['data' => [
|
||||
'user_id' => $user_id,
|
||||
'balance' => (int) $result,
|
||||
], 'message' => __('积分调整已保存', 'aksrt')]);
|
||||
'asset' => $asset,
|
||||
'balance' => $asset === 'balance'
|
||||
? \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, $asset))->decimal()
|
||||
: $result->balance($user_id, $asset),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
], 'message' => __('资产调整已保存', 'aksrt')]);
|
||||
}
|
||||
|
||||
public function rest_get_credits_log(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$args = [
|
||||
'transaction_id' => (int) $request->get_param('transaction_id'),
|
||||
'operation_key' => (string) $request->get_param('operation_key'),
|
||||
'user_id' => (int) $request->get_param('user_id'),
|
||||
'actor_user_id' => (int) $request->get_param('actor_user_id'),
|
||||
'reason' => (string) $request->get_param('reason'),
|
||||
'ref_type' => (string) $request->get_param('ref_type'),
|
||||
'ref_key' => (string) $request->get_param('ref_key'),
|
||||
'page' => (int) $request->get_param('page'),
|
||||
'per_page' => (int) $request->get_param('per_page'),
|
||||
'date_from' => (string) $request->get_param('date_from'),
|
||||
'date_to' => (string) $request->get_param('date_to'),
|
||||
];
|
||||
$data = class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::get_log($args) : ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20];
|
||||
$args['asset'] = sanitize_key((string) $request->get_param('asset'));
|
||||
$data = \AKSRT\Ledger\LedgerQuery::get_instance()->history($args);
|
||||
foreach ($data['items'] as &$item) {
|
||||
$assets = array_values(array_unique(array_map('strval', (array) ($item['assets'] ?? []))));
|
||||
$single_asset = count($assets) === 1 ? $assets[0] : '';
|
||||
if ($single_asset !== '') {
|
||||
$item['delta'] = $this->asset_minor_for_response($single_asset, (int) ($item['delta_minor'] ?? 0), true);
|
||||
$item['fee'] = $this->asset_minor_for_response($single_asset, (int) ($item['fee_minor'] ?? 0));
|
||||
$item['balance_after'] = $this->asset_minor_for_response($single_asset, (int) ($item['balance_after_minor'] ?? 0));
|
||||
} else {
|
||||
$item['delta'] = null;
|
||||
$item['fee'] = null;
|
||||
$item['balance_after'] = null;
|
||||
}
|
||||
unset($item['delta_minor'], $item['fee_minor'], $item['balance_after_minor']);
|
||||
foreach ($item['entries'] as &$entry) {
|
||||
$entry_asset = (string) ($entry['asset'] ?? '');
|
||||
$entry['delta'] = $this->asset_minor_for_response($entry_asset, (int) ($entry['delta_minor'] ?? 0), true);
|
||||
$entry['balance_after'] = $this->asset_minor_for_response($entry_asset, (int) ($entry['balance_after_minor'] ?? 0));
|
||||
unset($entry['delta_minor'], $entry['balance_after_minor']);
|
||||
}
|
||||
unset($entry);
|
||||
}
|
||||
unset($item);
|
||||
return $this->success(['data' => $data]);
|
||||
}
|
||||
|
||||
private function asset_minor_for_response(string $asset, int $minor, bool $signed = false) {
|
||||
if ($asset === \AKSRT\Ledger\AssetType::POINTS->value) {
|
||||
return $minor;
|
||||
}
|
||||
if ($asset !== \AKSRT\Ledger\AssetType::BALANCE->value) {
|
||||
return null;
|
||||
}
|
||||
$negative = $minor < 0;
|
||||
$value = \AKSRT\Ledger\AssetAmount::decimal_from_minor(abs($minor));
|
||||
if ($negative) {
|
||||
return '-' . $value;
|
||||
}
|
||||
return $signed && $minor > 0 ? '+' . $value : $value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,9 @@ class Download {
|
||||
$pay_modo = is_array($priced)
|
||||
? ((($priced['modo'] ?? '0') === 'points') ? 'points' : '0')
|
||||
: ((($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0');
|
||||
$display_price = is_array($priced) ? (float) ($priced['amount'] ?? 0) : (float) ($meta['price'] ?? 0);
|
||||
$display_price = is_array($priced)
|
||||
? ($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00'))
|
||||
: ($pay_modo === 'points' ? (int) ($meta['points_price'] ?? 0) : (string) ($meta['price'] ?? '0.00'));
|
||||
|
||||
$access = class_exists('AKSRT\\Content\\Visibility')
|
||||
? Visibility::get_post_access_state($post, $uid)
|
||||
@@ -135,14 +137,14 @@ class Download {
|
||||
return new \WP_REST_Response([
|
||||
'post_id' => $post_id,
|
||||
'price' => $display_price,
|
||||
'base_price' => is_array($priced) ? (float) ($priced['base'] ?? $display_price) : (float) ($meta['price'] ?? 0),
|
||||
'base_price' => is_array($priced) ? ($priced['base'] ?? $display_price) : $display_price,
|
||||
'pay_modo' => $pay_modo,
|
||||
'pay_limit' => (int) ($meta['pay_limit'] ?? 0),
|
||||
'cost' => $cost,
|
||||
'require_login' => !empty($access['need_login']),
|
||||
'require_captcha' => $need_captcha,
|
||||
'logged_in' => $uid > 0,
|
||||
'balance' => $uid > 0 && class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::balance($uid) : 0,
|
||||
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
|
||||
'has_paid_access' => !empty($access['has_paid_access']),
|
||||
'need_purchase' => !empty($access['need_purchase']),
|
||||
'need_vip' => !empty($access['need_vip']),
|
||||
@@ -242,7 +244,7 @@ class Download {
|
||||
'source_type' => $source['type'] ?? 'direct',
|
||||
'has_extract' => $extract_code !== '',
|
||||
'extract_code' => $extract_code,
|
||||
'balance' => $uid > 0 && class_exists('\AKSRT\Credits\Credits') ? \AKSRT\Credits\Credits::balance($uid) : 0,
|
||||
'balance' => $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0,
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class DownloadState {
|
||||
* @return array{
|
||||
* state:string,can_download:bool,message:string,action_label:string,
|
||||
* action_url:string,cost:int,balance:int,login_url:string,pay_modo:string,
|
||||
* amount:float,need_purchase:bool,need_vip:bool,has_paid_access:bool
|
||||
* amount:int|string,need_purchase:bool,need_vip:bool,has_paid_access:bool
|
||||
* }
|
||||
*/
|
||||
public function resolve(int $post_id): array {
|
||||
@@ -72,13 +72,14 @@ class DownloadState {
|
||||
$pay_modo = is_array($priced)
|
||||
? ((($priced['modo'] ?? '0') === 'points') ? 'points' : '0')
|
||||
: '0';
|
||||
$amount = is_array($priced) ? (float) ($priced['amount'] ?? 0) : 0.0;
|
||||
$amount = is_array($priced)
|
||||
? ($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00'))
|
||||
: ($pay_modo === 'points' ? 0 : '0.00');
|
||||
$amount_minor = $this->amount_minor($amount, $pay_modo);
|
||||
$cost = function_exists('aksrt_resource_download_cost')
|
||||
? (int) aksrt_resource_download_cost($post_id)
|
||||
: 0;
|
||||
$balance = ($user_id > 0 && class_exists('\AKSRT\Credits\Credits'))
|
||||
? \AKSRT\Credits\Credits::balance($user_id)
|
||||
: 0;
|
||||
$balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS) : 0;
|
||||
|
||||
$gate = function_exists('aksrt_can_download') ? aksrt_can_download($post_id, $user_id) : true;
|
||||
if (is_wp_error($gate)) {
|
||||
@@ -130,9 +131,9 @@ class DownloadState {
|
||||
}
|
||||
|
||||
// need_purchase / legacy insufficient credits → cashier.
|
||||
if (in_array($code, ['aksrt_need_purchase', 'aksrt_credits_insufficient'], true)) {
|
||||
if (in_array($code, ['aksrt_need_purchase', 'aksrt_points_insufficient'], true)) {
|
||||
$label = $pay_modo === 'points'
|
||||
? sprintf(__('购买(%s 积分)', 'aksrt'), (string) (int) ceil($amount > 0 ? $amount : $cost))
|
||||
? sprintf(__('购买(%s 积分)', 'aksrt'), (string) ($amount_minor > 0 ? $amount_minor : $cost))
|
||||
: __('去购买', 'aksrt');
|
||||
return $this->build_result(
|
||||
self::STATE_NEED_PURCHASE,
|
||||
@@ -145,7 +146,7 @@ class DownloadState {
|
||||
$login_url,
|
||||
[
|
||||
'pay_modo' => $pay_modo,
|
||||
'amount' => $amount > 0 ? $amount : (float) $cost,
|
||||
'amount' => $amount_minor > 0 ? $amount : ($pay_modo === 'points' ? $cost : '0.00'),
|
||||
'need_purchase' => true,
|
||||
'need_vip' => false,
|
||||
'has_paid_access' => false,
|
||||
@@ -174,8 +175,8 @@ class DownloadState {
|
||||
|
||||
// Paid / free unlock.
|
||||
$has_paid = !empty($access['has_paid_access']);
|
||||
if ($has_paid || $amount <= 0) {
|
||||
$owned = $has_paid && $amount > 0;
|
||||
if ($has_paid || $amount_minor <= 0) {
|
||||
$owned = $has_paid && $amount_minor > 0;
|
||||
return $this->build_result(
|
||||
$owned ? self::STATE_OWNED : self::STATE_AVAILABLE,
|
||||
true,
|
||||
@@ -189,7 +190,7 @@ class DownloadState {
|
||||
$login_url,
|
||||
[
|
||||
'pay_modo' => $pay_modo,
|
||||
'amount' => 0.0,
|
||||
'amount' => $pay_modo === 'points' ? 0 : '0.00',
|
||||
'need_purchase' => false,
|
||||
'need_vip' => false,
|
||||
'has_paid_access' => true,
|
||||
@@ -241,10 +242,24 @@ class DownloadState {
|
||||
'balance' => $balance,
|
||||
'login_url' => $login_url,
|
||||
'pay_modo' => '0',
|
||||
'amount' => 0.0,
|
||||
'amount' => '0.00',
|
||||
'need_purchase' => false,
|
||||
'need_vip' => false,
|
||||
'has_paid_access' => false,
|
||||
], $extra);
|
||||
}
|
||||
|
||||
private function amount_minor($value, string $pay_modo): int {
|
||||
if ($pay_modo === 'points') {
|
||||
return is_int($value) ? max(0, $value) : 0;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ class Pay {
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 15, 2);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+133
-93
@@ -31,34 +31,35 @@ trait PayHelpers {
|
||||
$uid = $user_id > 0 ? $user_id : get_current_user_id();
|
||||
$priced = function_exists('aksrt_get_user_pay_price')
|
||||
? aksrt_get_user_pay_price($post, $uid)
|
||||
: ['amount' => 0.0, 'base' => 0.0, 'modo' => '0', 'currency' => '¥', 'is_free' => true, 'pay_limit' => 0, 'vip_level' => 0, 'original' => 0.0, 'vip1' => 0.0, 'vip2' => 0.0];
|
||||
: ['amount' => '0.00', 'base' => '0.00', 'modo' => '0', 'currency' => '¥', 'is_free' => true, 'pay_limit' => 0, 'vip_level' => 0, 'original' => '0.00', 'vip1' => '0.00', 'vip2' => '0.00'];
|
||||
|
||||
$pay_modo = (($priced['modo'] ?? '0') === 'points') ? 'points' : '0';
|
||||
|
||||
// Optional overbuy override for preview display.
|
||||
if ($price_type === 'download_limit_over') {
|
||||
$over = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0));
|
||||
if ($over > 0) {
|
||||
$priced['amount'] = $over;
|
||||
$priced['base'] = $over;
|
||||
$over_minor = $this->parse_asset_minor($meta['download_limit_over_price'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo);
|
||||
if ($over_minor > 0) {
|
||||
$priced['amount'] = $this->asset_value($over_minor, $pay_modo);
|
||||
$priced['base'] = $this->asset_value($over_minor, $pay_modo);
|
||||
$priced['is_free'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
$pay_modo = (($priced['modo'] ?? '0') === 'points') ? 'points' : '0';
|
||||
$pay_limit = (int) ($priced['pay_limit'] ?? 0);
|
||||
$vip_level = (int) ($priced['vip_level'] ?? 0);
|
||||
$amount = (float) ($priced['amount'] ?? 0);
|
||||
$base = (float) ($priced['base'] ?? $amount);
|
||||
$over_price = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0));
|
||||
$amount_minor = $this->parse_asset_minor($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo);
|
||||
$base_minor = $this->parse_asset_minor($priced['base'] ?? $priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo);
|
||||
$over_minor = $this->parse_asset_minor($meta['download_limit_over_price'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo);
|
||||
|
||||
$list_amount = $amount;
|
||||
$rebate_discount = 0.0;
|
||||
$coupon_discount = 0.0;
|
||||
$list_minor = $amount_minor;
|
||||
$rebate_discount_minor = 0;
|
||||
$coupon_discount_minor = 0;
|
||||
$coupon_payload = null;
|
||||
$coupon_error = '';
|
||||
$referrer_active = false;
|
||||
$referrer_id = 0;
|
||||
$rebate_price = 0.0;
|
||||
$payable = $amount;
|
||||
$rebate_price_minor = 0;
|
||||
$payable_minor = $amount_minor;
|
||||
|
||||
$coupon_allowed = $pay_modo !== 'points' && $this->is_coupon_allowed($post_id, $meta);
|
||||
$coupon_desc = '';
|
||||
@@ -77,15 +78,15 @@ trait PayHelpers {
|
||||
$discounted = $this->apply_cash_discounts($priced, $post, $meta, $uid, '', true);
|
||||
}
|
||||
if (!is_wp_error($discounted)) {
|
||||
$list_amount = (float) ($discounted['list_amount'] ?? $amount);
|
||||
$rebate_discount = (float) ($discounted['rebate_discount'] ?? 0);
|
||||
$coupon_discount = (float) ($discounted['coupon_discount'] ?? 0);
|
||||
$list_minor = (int) ($discounted['list_minor'] ?? $amount_minor);
|
||||
$rebate_discount_minor = (int) ($discounted['rebate_discount_minor'] ?? 0);
|
||||
$coupon_discount_minor = (int) ($discounted['coupon_discount_minor'] ?? 0);
|
||||
$coupon_payload = $discounted['coupon'] ?? null;
|
||||
$payable = (float) ($discounted['payable'] ?? $amount);
|
||||
$payable_minor = (int) ($discounted['payable_minor'] ?? $amount_minor);
|
||||
$referrer_id = (int) ($discounted['referrer_id'] ?? 0);
|
||||
$referrer_active = $referrer_id > 0;
|
||||
$rebate_price = (float) ($discounted['rebate_price'] ?? 0);
|
||||
$amount = $payable;
|
||||
$rebate_price_minor = (int) ($discounted['rebate_price_minor'] ?? 0);
|
||||
$amount_minor = $payable_minor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,19 +122,16 @@ trait PayHelpers {
|
||||
: '¥'));
|
||||
|
||||
// Points cashier uses Credits; cash cashier uses wallet (元). Keep both for UI.
|
||||
$points_balance = ($uid > 0 && class_exists('\AKSRT\Credits\Credits'))
|
||||
? (int) \AKSRT\Credits\Credits::balance($uid)
|
||||
: 0;
|
||||
$wallet_balance = ($uid > 0 && class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled())
|
||||
? (float) \AKSRT\Credits\Balance::balance($uid)
|
||||
: 0.0;
|
||||
$points_balance = $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::POINTS) : 0;
|
||||
$wallet_minor = $uid > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($uid, \AKSRT\Ledger\AssetType::BALANCE) : 0;
|
||||
$wallet_balance = \AKSRT\Ledger\AssetAmount::decimal_from_minor($wallet_minor);
|
||||
$balance = $pay_modo === 'points' ? $points_balance : $wallet_balance;
|
||||
$methods = $this->available_payment_methods($pay_modo, $uid > 0);
|
||||
|
||||
$amount_label = $this->format_amount_label($amount, $pay_modo, $currency);
|
||||
$base_label = $this->format_amount_label($base, $pay_modo, $currency);
|
||||
$list_label = $this->format_amount_label($list_amount, $pay_modo, $currency);
|
||||
$over_label = $over_price > 0 ? $this->format_amount_label($over_price, $pay_modo, $currency) : '';
|
||||
$amount_label = $this->format_amount_label($amount_minor, $pay_modo, $currency);
|
||||
$base_label = $this->format_amount_label($base_minor, $pay_modo, $currency);
|
||||
$list_label = $this->format_amount_label($list_minor, $pay_modo, $currency);
|
||||
$over_label = $over_minor > 0 ? $this->format_amount_label($over_minor, $pay_modo, $currency) : '';
|
||||
|
||||
$vip_page = function_exists('aksrt_get_user_center_tab_url')
|
||||
? aksrt_get_user_center_tab_url('credits')
|
||||
@@ -147,10 +145,10 @@ trait PayHelpers {
|
||||
? aksrt_get_user_free_downloaded_number($uid) : 0;
|
||||
$quota_over = $free_limit > 0 && $free_used >= $free_limit;
|
||||
|
||||
$payable_amount = $pay_modo === 'points' ? (int) ceil($payable) : round($payable, 2);
|
||||
$payable_amount = $this->asset_value($payable_minor, $pay_modo);
|
||||
$balance_enough = $pay_modo === 'points'
|
||||
? ($points_balance >= (int) ceil($amount))
|
||||
: ($wallet_balance + 0.0001 >= (float) $payable_amount);
|
||||
? ($points_balance >= $payable_minor)
|
||||
: ($wallet_minor >= $payable_minor);
|
||||
|
||||
return [
|
||||
'post_id' => $post_id,
|
||||
@@ -161,36 +159,38 @@ trait PayHelpers {
|
||||
'pay_modo' => $pay_modo,
|
||||
'pay_limit' => $pay_limit,
|
||||
'vip_level' => $vip_level,
|
||||
'amount' => $pay_modo === 'points' ? (int) ceil($amount) : round($amount, 2),
|
||||
'base' => $pay_modo === 'points' ? (int) ceil($base) : round($base, 2),
|
||||
'list_amount' => $pay_modo === 'points' ? (int) ceil($list_amount) : round($list_amount, 2),
|
||||
'amount' => $this->asset_value($amount_minor, $pay_modo),
|
||||
'base' => $this->asset_value($base_minor, $pay_modo),
|
||||
'list_amount' => $this->asset_value($list_minor, $pay_modo),
|
||||
'list_amount_label' => $list_label,
|
||||
'payable' => $payable_amount,
|
||||
'original' => (float) ($priced['original'] ?? 0),
|
||||
'vip1' => (float) ($priced['vip1'] ?? 0),
|
||||
'vip2' => (float) ($priced['vip2'] ?? 0),
|
||||
'original' => $this->asset_value($this->parse_asset_minor($priced['original'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo), $pay_modo),
|
||||
'vip1' => $this->asset_value($this->parse_asset_minor($priced['vip1'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo), $pay_modo),
|
||||
'vip2' => $this->asset_value($this->parse_asset_minor($priced['vip2'] ?? ($pay_modo === 'points' ? 0 : '0'), $pay_modo), $pay_modo),
|
||||
'currency' => $currency,
|
||||
'amount_label' => $amount_label,
|
||||
'base_label' => $base_label,
|
||||
'is_free' => !empty($priced['is_free']) || $amount <= 0,
|
||||
'is_free' => !empty($priced['is_free']) || $amount_minor <= 0,
|
||||
'has_paid_access' => $has_paid,
|
||||
'paid_order' => $paid_order ? [
|
||||
'order_number' => (string) ($paid_order['order_number'] ?? ''),
|
||||
'paid_at' => (string) ($paid_order['paid_at'] ?? $paid_order['created_at'] ?? ''),
|
||||
'amount' => (float) ($paid_order['amount'] ?? 0),
|
||||
'amount' => (string) ($paid_order['currency'] ?? '') === 'points'
|
||||
? (int) ($paid_order['amount'] ?? 0)
|
||||
: (string) ($paid_order['amount'] ?? '0.00'),
|
||||
'currency' => (string) ($paid_order['currency'] ?? ''),
|
||||
'payment_method' => (string) ($paid_order['payment_method'] ?? ''),
|
||||
] : null,
|
||||
'pending_order' => $pending_order ? [
|
||||
'order_number' => (string) ($pending_order['order_number'] ?? ''),
|
||||
'amount' => (float) ($pending_order['amount'] ?? 0),
|
||||
'amount' => (string) ($pending_order['amount'] ?? '0.00'),
|
||||
'payment_method' => (string) ($pending_order['payment_method'] ?? ''),
|
||||
'created_at' => (string) ($pending_order['created_at'] ?? ''),
|
||||
] : null,
|
||||
'need_login' => $uid <= 0 && !$allow_guest,
|
||||
'allow_guest' => $allow_guest,
|
||||
'need_vip' => $need_vip,
|
||||
'need_purchase' => !$has_paid && !$need_vip && $amount > 0,
|
||||
'need_purchase' => !$has_paid && !$need_vip && $amount_minor > 0,
|
||||
'logged_in' => $uid > 0,
|
||||
'balance' => $balance,
|
||||
'wallet_balance' => $wallet_balance,
|
||||
@@ -200,9 +200,9 @@ trait PayHelpers {
|
||||
'login_url' => wp_login_url(get_permalink($post)),
|
||||
'vip_url' => $vip_page,
|
||||
'credits_url' => $vip_page,
|
||||
'download_limit_over_price' => $pay_modo === 'points' ? (int) ceil($over_price) : round($over_price, 2),
|
||||
'download_limit_over_price' => $this->asset_value($over_minor, $pay_modo),
|
||||
'download_limit_over_label' => $over_label,
|
||||
'allow_limit_over' => $over_price > 0,
|
||||
'allow_limit_over' => $over_minor > 0,
|
||||
'quota_over' => $quota_over,
|
||||
'free_limit' => $free_limit,
|
||||
'free_used' => $free_used,
|
||||
@@ -211,18 +211,18 @@ trait PayHelpers {
|
||||
'coupon_code' => $coupon_code,
|
||||
'coupon' => $coupon_payload,
|
||||
'coupon_error' => $coupon_error,
|
||||
'rebate_discount' => round($rebate_discount, 2),
|
||||
'rebate_discount_label' => $rebate_discount > 0 ? ('-' . $this->format_amount_label($rebate_discount, $pay_modo, $currency)) : '',
|
||||
'coupon_discount' => round($coupon_discount, 2),
|
||||
'coupon_discount_label' => $coupon_discount > 0 ? ('-' . $this->format_amount_label($coupon_discount, $pay_modo, $currency)) : '',
|
||||
'rebate_discount' => $this->asset_value($rebate_discount_minor, $pay_modo),
|
||||
'rebate_discount_label' => $rebate_discount_minor > 0 ? ('-' . $this->format_amount_label($rebate_discount_minor, $pay_modo, $currency)) : '',
|
||||
'coupon_discount' => $this->asset_value($coupon_discount_minor, $pay_modo),
|
||||
'coupon_discount_label' => $coupon_discount_minor > 0 ? ('-' . $this->format_amount_label($coupon_discount_minor, $pay_modo, $currency)) : '',
|
||||
'referrer_active' => $referrer_active,
|
||||
'discounts' => [
|
||||
'list_amount' => round($list_amount, 2),
|
||||
'rebate_discount' => round($rebate_discount, 2),
|
||||
'coupon_discount' => round($coupon_discount, 2),
|
||||
'total_discount' => round(max(0, $list_amount - $payable), 2),
|
||||
'payable' => round($payable, 2),
|
||||
'rebate_price' => round($rebate_price, 2),
|
||||
'list_amount' => $this->asset_value($list_minor, $pay_modo),
|
||||
'rebate_discount' => $this->asset_value($rebate_discount_minor, $pay_modo),
|
||||
'coupon_discount' => $this->asset_value($coupon_discount_minor, $pay_modo),
|
||||
'total_discount' => $this->asset_value(max(0, $list_minor - $payable_minor), $pay_modo),
|
||||
'payable' => $this->asset_value($payable_minor, $pay_modo),
|
||||
'rebate_price' => $this->asset_value($rebate_price_minor, $pay_modo),
|
||||
],
|
||||
// legacy: points_deduction runtime hard-off; card_pass not a content payment method.
|
||||
'points_deduction_allowed' => function_exists('aksrt_is_allow_points_deduction')
|
||||
@@ -269,18 +269,18 @@ trait PayHelpers {
|
||||
|
||||
$priced = function_exists('aksrt_get_user_pay_price')
|
||||
? aksrt_get_user_pay_price($post, $user_id)
|
||||
: ['amount' => 0.0, 'modo' => '0', 'pay_limit' => 0, 'vip_level' => 0, 'is_free' => true, 'base' => 0.0];
|
||||
: ['amount' => '0.00', 'modo' => '0', 'pay_limit' => 0, 'vip_level' => 0, 'is_free' => true, 'base' => '0.00'];
|
||||
|
||||
// Free daily quota overbuy: override amount, ignore VIP discount (legacy).
|
||||
if ($price_type === 'download_limit_over') {
|
||||
$over = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0));
|
||||
if ($over <= 0) {
|
||||
$over_minor = $this->parse_asset_minor($meta['download_limit_over_price'] ?? ($pay_modo_meta === 'points' ? 0 : '0'), $pay_modo_meta);
|
||||
if ($over_minor <= 0) {
|
||||
return new \WP_Error('aksrt_no_overbuy', __('未开放超额下载购买。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$priced['amount'] = $over;
|
||||
$priced['base'] = $over;
|
||||
$priced['amount'] = $this->asset_value($over_minor, $pay_modo_meta);
|
||||
$priced['base'] = $this->asset_value($over_minor, $pay_modo_meta);
|
||||
$priced['is_free'] = false;
|
||||
$priced['vip_amount'] = -1.0;
|
||||
$priced['vip_amount'] = null;
|
||||
}
|
||||
|
||||
$pay_limit = (int) ($priced['pay_limit'] ?? 0);
|
||||
@@ -365,10 +365,10 @@ trait PayHelpers {
|
||||
|
||||
|
||||
/**
|
||||
* @return array{id:int,ratio:float}
|
||||
* @return array{id:int,ratio:int}
|
||||
*/
|
||||
private function resolve_referrer_context(int $user_id): array {
|
||||
$empty = ['id' => 0, 'ratio' => 0.0];
|
||||
$empty = ['id' => 0, 'ratio' => 0];
|
||||
if (!class_exists('\AKSRT\Settings\Settings') || !method_exists('\AKSRT\Settings\Settings', 'get_payment_affiliate_settings')) {
|
||||
return $empty;
|
||||
}
|
||||
@@ -383,7 +383,7 @@ trait PayHelpers {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$ratio = (float) \AKSRT\Settings\Settings::affiliate_ratio_for_user($referrer_id);
|
||||
$ratio = (int) \AKSRT\Settings\Settings::affiliate_ratio_for_user($referrer_id);
|
||||
if ($ratio <= 0) {
|
||||
return $empty;
|
||||
}
|
||||
@@ -400,21 +400,21 @@ trait PayHelpers {
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
private function apply_cash_discounts(array $priced, \WP_Post $post, array $meta, int $user_id, string $coupon_code = '', bool $soft_coupon = false) {
|
||||
$list_amount = round(max(0.0, (float) ($priced['amount'] ?? 0)), 2);
|
||||
$pay = $list_amount;
|
||||
$rebate_discount = 0.0;
|
||||
$coupon_discount = 0.0;
|
||||
$list_minor = $this->parse_asset_minor($priced['amount'] ?? '0', '0');
|
||||
$pay_minor = $list_minor;
|
||||
$rebate_discount_minor = 0;
|
||||
$coupon_discount_minor = 0;
|
||||
$coupon_meta = [];
|
||||
|
||||
$ref = $this->resolve_referrer_context($user_id);
|
||||
$referrer_id = (int) ($ref['id'] ?? 0);
|
||||
$ratio = (float) ($ref['ratio'] ?? 0);
|
||||
$ratio = max(0, min(100, (int) ($ref['ratio'] ?? 0)));
|
||||
|
||||
// 1) Buyer-facing 推广折扣 (fixed).
|
||||
$fixed = max(0.0, (float) ($meta['pay_rebate_discount'] ?? 0));
|
||||
if ($referrer_id > 0 && $ratio > 0 && $fixed > 0 && $pay > 0) {
|
||||
$rebate_discount = round(min($pay, $fixed), 2);
|
||||
$pay = round(max(0.0, $pay - $rebate_discount), 2);
|
||||
$fixed_minor = $this->parse_asset_minor($meta['pay_rebate_discount'] ?? '0', '0');
|
||||
if ($referrer_id > 0 && $ratio > 0 && $fixed_minor > 0 && $pay_minor > 0) {
|
||||
$rebate_discount_minor = min($pay_minor, $fixed_minor);
|
||||
$pay_minor -= $rebate_discount_minor;
|
||||
}
|
||||
|
||||
// 2) Coupon after rebate discount.
|
||||
@@ -431,11 +431,15 @@ trait PayHelpers {
|
||||
return $err;
|
||||
}
|
||||
} else {
|
||||
$result = \AKSRT\Payment\Coupon::get_instance()->validate($coupon_code, $pay, [
|
||||
$result = \AKSRT\Payment\Coupon::get_instance()->validate(
|
||||
$coupon_code,
|
||||
\AKSRT\Ledger\AssetAmount::decimal_from_minor($pay_minor),
|
||||
[
|
||||
'user_id' => max(0, $user_id),
|
||||
'product_ids' => [(int) $post->ID],
|
||||
'context' => 'content',
|
||||
]);
|
||||
]
|
||||
);
|
||||
if (empty($result['valid'])) {
|
||||
$err = new \WP_Error(
|
||||
'aksrt_coupon_error',
|
||||
@@ -446,47 +450,50 @@ trait PayHelpers {
|
||||
return $err;
|
||||
}
|
||||
} else {
|
||||
$coupon_discount = round(max(0.0, (float) ($result['discount'] ?? 0)), 2);
|
||||
if ($coupon_discount > $pay) {
|
||||
$coupon_discount = $pay;
|
||||
}
|
||||
$pay = round(max(0.0, $pay - $coupon_discount), 2);
|
||||
$coupon_discount_minor = min($pay_minor, max(0, (int) ($result['discount_minor'] ?? 0)));
|
||||
$pay_minor -= $coupon_discount_minor;
|
||||
$c = is_array($result['coupon'] ?? null) ? $result['coupon'] : [];
|
||||
$coupon_meta = [
|
||||
'id' => (int) ($c['id'] ?? 0),
|
||||
'code' => (string) ($c['code'] ?? $coupon_code),
|
||||
'type' => (string) ($c['type'] ?? ''),
|
||||
'value' => (float) ($c['value'] ?? 0),
|
||||
'discount' => $coupon_discount,
|
||||
'value' => $c['value'] ?? 0,
|
||||
'discount' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($coupon_discount_minor),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$payable = round(max(0.0, $pay), 2);
|
||||
$rebate_price = 0.0;
|
||||
if ($referrer_id > 0 && $ratio > 0 && $payable > 0) {
|
||||
$rebate_price = round($payable * $ratio / 100, 2);
|
||||
$payable_minor = max(0, $pay_minor);
|
||||
$rebate_price_minor = 0;
|
||||
if ($referrer_id > 0 && $ratio > 0 && $payable_minor > 0) {
|
||||
$rebate_price_minor = \AKSRT\Ledger\AssetAmount::percentage($payable_minor, $ratio);
|
||||
}
|
||||
|
||||
return [
|
||||
'list_amount' => $list_amount,
|
||||
'rebate_discount' => $rebate_discount,
|
||||
'coupon_discount' => $coupon_discount,
|
||||
'list_amount' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($list_minor),
|
||||
'list_minor' => $list_minor,
|
||||
'rebate_discount' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($rebate_discount_minor),
|
||||
'rebate_discount_minor' => $rebate_discount_minor,
|
||||
'coupon_discount' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($coupon_discount_minor),
|
||||
'coupon_discount_minor' => $coupon_discount_minor,
|
||||
'coupon' => $coupon_meta,
|
||||
'payable' => $payable,
|
||||
'payable' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($payable_minor),
|
||||
'payable_minor' => $payable_minor,
|
||||
'referrer_id' => $referrer_id,
|
||||
'rebate_price' => $rebate_price,
|
||||
'rebate_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($rebate_price_minor),
|
||||
'rebate_price_minor' => $rebate_price_minor,
|
||||
'ratio' => $ratio,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function format_amount_label(float $amount, string $pay_modo, string $currency): string {
|
||||
if ($amount <= 0) {
|
||||
private function format_amount_label(int $minor, string $pay_modo, string $currency): string {
|
||||
if ($minor <= 0) {
|
||||
return (string) __('免费', 'aksrt');
|
||||
}
|
||||
$amount = $this->asset_value($minor, $pay_modo);
|
||||
$n = function_exists('aksrt_format_price') ? aksrt_format_price($amount) : (string) $amount;
|
||||
if ($pay_modo === 'points') {
|
||||
return $n . ' ' . $currency;
|
||||
@@ -494,4 +501,37 @@ trait PayHelpers {
|
||||
return $currency . $n;
|
||||
}
|
||||
|
||||
private function parse_asset_minor($value, string $pay_modo): int {
|
||||
if ($pay_modo === 'points') {
|
||||
if (is_int($value)) {
|
||||
return max(0, $value);
|
||||
}
|
||||
if (!is_string($value) || !preg_match('/^(0|[1-9]\d*)$/', trim($value))) {
|
||||
return 0;
|
||||
}
|
||||
$value = trim($value);
|
||||
if (strlen($value) > strlen((string) PHP_INT_MAX)
|
||||
|| (strlen($value) === strlen((string) PHP_INT_MAX) && strcmp($value, (string) PHP_INT_MAX) > 0)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return \AKSRT\Ledger\AssetAmount::points((int) $value)->minor();
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function asset_value(int $minor, string $pay_modo) {
|
||||
return $pay_modo === 'points'
|
||||
? $minor
|
||||
: \AKSRT\Ledger\AssetAmount::decimal_from_minor($minor);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+208
-108
@@ -17,19 +17,19 @@ trait PayOrders {
|
||||
* @param string $order_number
|
||||
* @param array $paid_order
|
||||
*/
|
||||
public function on_order_paid(string $order_number, array $paid_order): void {
|
||||
public function fulfill_paid_order(string $order_number, array $paid_order) {
|
||||
$product_type = sanitize_key((string) ($paid_order['product_type'] ?? ''));
|
||||
if ($product_type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
$post_id = (int) ($paid_order['product_id'] ?? 0);
|
||||
if ($post_id <= 0) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Only count normal posts for content purchases.
|
||||
$post = get_post($post_id);
|
||||
if (!($post instanceof \WP_Post) || $post->post_type !== 'post') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (function_exists('aksrt_bump_content_sales_volume')) {
|
||||
aksrt_bump_content_sales_volume($post_id, 1);
|
||||
@@ -53,21 +53,31 @@ trait PayOrders {
|
||||
}
|
||||
|
||||
// Consume coupon + write affiliate commission (cash content only; idempotent).
|
||||
$this->finalize_content_order_side_effects($order_number, $paid_order);
|
||||
$side_effects = $this->finalize_content_order_side_effects($order_number, $paid_order);
|
||||
if (is_wp_error($side_effects)) {
|
||||
return $side_effects;
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$completed = \AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
$order_number,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
['note' => __('内容权益已发放。', 'aksrt')]
|
||||
);
|
||||
if ($completed && class_exists('\AKSRT\Payment\CreatorRevenue')) {
|
||||
if (!$completed) {
|
||||
return new \WP_Error('aksrt_content_fulfillment_failed', __('内容权益发放失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\CreatorRevenue')) {
|
||||
$completed_order = array_merge($paid_order, [
|
||||
'order_number' => $order_number,
|
||||
'status' => \AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
]);
|
||||
\AKSRT\Payment\CreatorRevenue::get_instance()->create_from_paid_order($completed_order);
|
||||
}
|
||||
$revenue_id = \AKSRT\Payment\CreatorRevenue::get_instance()->create_from_paid_order($completed_order);
|
||||
if ($revenue_id < 0) {
|
||||
return new \WP_Error('aksrt_creator_revenue_conflict', __('同一订单的收益载荷不一致。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,25 +85,29 @@ trait PayOrders {
|
||||
/**
|
||||
* On paid: burn coupon (if any) and create affiliate rebate log.
|
||||
*/
|
||||
private function finalize_content_order_side_effects(string $order_number, array $order): void {
|
||||
private function finalize_content_order_side_effects(string $order_number, array $order) {
|
||||
$meta = $this->decode_order_note_meta($order);
|
||||
if (empty($meta['content_pay'])) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Points: no coupon burn / no cash commission.
|
||||
if (($meta['pay_modo'] ?? '') === 'points') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
$buyer_id = (int) ($order['user_id'] ?? 0);
|
||||
|
||||
// Coupon burn (shared helper; idempotent via coupon_usage.order_number).
|
||||
if (function_exists('aksrt_burn_order_coupon')) {
|
||||
aksrt_burn_order_coupon($order_number, $meta, $buyer_id);
|
||||
$coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : [];
|
||||
$coupon_code = (string) ($coupon['code'] ?? $meta['coupon_code'] ?? '');
|
||||
if ($coupon_code !== '' && !aksrt_burn_order_coupon($order_number, $meta, $buyer_id)) {
|
||||
return new \WP_Error('aksrt_coupon_consume_failed', __('优惠券核销失败。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
}
|
||||
|
||||
// Affiliate commission is recorded exactly once by CommissionLedger on
|
||||
// the canonical aksrt_order_paid event. No order-side legacy writes.
|
||||
// Affiliate commission is written by the order transition transaction.
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +135,7 @@ trait PayOrders {
|
||||
*
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function create_and_pay_points(int $post_id, int $user_id, string $price_type = '') {
|
||||
public function create_and_pay_points(int $post_id, int $user_id, string $price_type = '', string $operation_key = '') {
|
||||
$assert = $this->assert_purchasable($post_id, $user_id, $price_type);
|
||||
if (is_wp_error($assert)) {
|
||||
return $assert;
|
||||
@@ -132,7 +146,7 @@ trait PayOrders {
|
||||
return new \WP_Error('aksrt_wrong_modo', __('该内容不支持积分支付。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$amount = (int) ceil((float) ($priced['amount'] ?? 0));
|
||||
$amount = $this->parse_asset_minor($priced['amount'] ?? 0, 'points');
|
||||
if ($amount <= 0) {
|
||||
return [
|
||||
'success' => true,
|
||||
@@ -144,13 +158,14 @@ trait PayOrders {
|
||||
];
|
||||
}
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Order') || !class_exists('\AKSRT\Credits\Credits')) {
|
||||
if (!class_exists('\AKSRT\Payment\Order')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$balance = \AKSRT\Credits\Credits::balance($user_id);
|
||||
$ledger = \AKSRT\Ledger\LedgerService::get_instance();
|
||||
$balance = $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS);
|
||||
if ($balance < $amount) {
|
||||
return new \WP_Error('aksrt_credits_insufficient', __('积分不足,暂时无法支付。', 'aksrt'), [
|
||||
return new \WP_Error('aksrt_points_insufficient', __('积分不足,暂时无法支付。', 'aksrt'), [
|
||||
'status' => 402,
|
||||
'balance' => $balance,
|
||||
'cost' => $amount,
|
||||
@@ -163,69 +178,93 @@ trait PayOrders {
|
||||
'post_id' => $post_id,
|
||||
'price_type' => $price_type,
|
||||
'vip_level' => (int) ($priced['vip_level'] ?? 0),
|
||||
'base' => (float) ($priced['base'] ?? $amount),
|
||||
'base' => $this->parse_asset_minor($priced['base'] ?? $amount, 'points'),
|
||||
'amount' => $amount,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_id' => $post_id,
|
||||
'amount' => (float) $amount,
|
||||
'currency' => 'points',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => 'points',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
]);
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$existing = $operation_key !== '' ? $repo->get_by_request_key($operation_key) : null;
|
||||
$is_new_order = !is_array($existing);
|
||||
if (is_array($existing)) {
|
||||
$existing_meta = $this->decode_order_note_meta($existing);
|
||||
if ((int) ($existing['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
|
||||
|| (int) ($existing['product_id'] ?? 0) !== $post_id
|
||||
|| (string) ($existing['currency'] ?? '') !== 'points'
|
||||
|| (int) ($existing['amount'] ?? 0) !== $amount
|
||||
|| sanitize_key((string) ($existing['payment_method'] ?? '')) !== 'points'
|
||||
|| (string) ($existing_meta['price_type'] ?? '') !== $price_type
|
||||
) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他购买请求。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order_number = (string) $existing['order_number'];
|
||||
} else {
|
||||
$order_number = $repo->allocate_order_number();
|
||||
}
|
||||
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$guard = static function () use ($is_new_order, $order_number, $user_id, $post_id, $amount) {
|
||||
if ($is_new_order) {
|
||||
return true;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$current = $wpdb->get_row($wpdb->prepare("SELECT user_id, product_id, amount, currency, status FROM {$table} WHERE order_number = %s FOR UPDATE", $order_number), ARRAY_A);
|
||||
if (!is_array($current)
|
||||
|| (int) ($current['user_id'] ?? 0) !== $user_id
|
||||
|| (int) ($current['product_id'] ?? 0) !== $post_id
|
||||
|| (int) ($current['amount'] ?? 0) !== $amount
|
||||
|| (string) ($current['currency'] ?? '') !== 'points'
|
||||
|| !in_array((string) ($current['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_CREATED, \AKSRT\Payment\Order::STATUS_PENDING], true)
|
||||
) {
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单状态或金额已变化。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
|
||||
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
|
||||
: false;
|
||||
if ($lock === false) {
|
||||
return new \WP_Error('aksrt_pay_locked', __('该订单正在发起支付,请稍后重试。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
try {
|
||||
$deducted = \AKSRT\Credits\Credits::deduct(
|
||||
$user_id,
|
||||
$amount,
|
||||
'content_points_pay',
|
||||
['type' => 'post', 'id' => $post_id, 'order' => $order_number],
|
||||
sprintf(__('积分购买《%s》', 'aksrt'), get_the_title($post_id))
|
||||
);
|
||||
|
||||
if (is_wp_error($deducted) || $deducted === false) {
|
||||
\AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_CANCELLED, [
|
||||
'note' => __('积分扣除失败,自动取消', 'aksrt'),
|
||||
]);
|
||||
$msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败。', 'aksrt');
|
||||
return new \WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
$deducted = $ledger->debit(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$amount,
|
||||
$operation_key,
|
||||
'content_points_pay',
|
||||
['type' => 'order', 'key' => $order_number],
|
||||
sprintf(__('积分购买《%s》', 'aksrt'), get_the_title($post_id)),
|
||||
$user_id,
|
||||
['post_id' => $post_id],
|
||||
static function () use ($is_new_order, $repo, $order_number, $operation_key, $user_id, $post_id, $amount, $note) {
|
||||
if ($is_new_order) {
|
||||
$created = $repo->create([
|
||||
'order_number' => $order_number,
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_id' => $post_id,
|
||||
'amount' => $amount,
|
||||
'currency' => 'points',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => 'points',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
if ($created !== $order_number) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'points',
|
||||
'payment_transaction_id' => 'points_' . $order_number,
|
||||
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
|
||||
if (!$paid) {
|
||||
\AKSRT\Credits\Credits::add(
|
||||
$user_id,
|
||||
$amount,
|
||||
'content_points_pay_refund',
|
||||
['type' => 'post', 'id' => $post_id, 'order' => $order_number],
|
||||
sprintf(__('购买失败退款:%s', 'aksrt'), get_the_title($post_id))
|
||||
);
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败,已尝试退回积分。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
} finally {
|
||||
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
|
||||
]);
|
||||
return $paid ? true : new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]);
|
||||
},
|
||||
$guard
|
||||
);
|
||||
if (is_wp_error($deducted)) {
|
||||
return $deducted;
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -234,7 +273,9 @@ trait PayOrders {
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'pay_modo' => 'points',
|
||||
'balance' => \AKSRT\Credits\Credits::balance($user_id),
|
||||
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'transaction_id' => $deducted->transaction_id,
|
||||
'replayed' => $deducted->replayed,
|
||||
'message' => __('支付成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
@@ -248,7 +289,7 @@ trait PayOrders {
|
||||
*
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function create_cash_order(int $post_id, int $user_id, string $payment_method = 'balance', string $price_type = '', string $coupon_code = '') {
|
||||
public function create_cash_order(int $post_id, int $user_id, string $payment_method = 'balance', string $price_type = '', string $coupon_code = '', string $operation_key = '') {
|
||||
$assert = $this->assert_purchasable($post_id, $user_id, $price_type);
|
||||
if (is_wp_error($assert)) {
|
||||
return $assert;
|
||||
@@ -279,15 +320,20 @@ trait PayOrders {
|
||||
return $discounted;
|
||||
}
|
||||
|
||||
$amount = round((float) ($discounted['payable'] ?? 0), 2);
|
||||
$list_amount = round((float) ($discounted['list_amount'] ?? $amount), 2);
|
||||
$rebate_discount = round((float) ($discounted['rebate_discount'] ?? 0), 2);
|
||||
$coupon_discount = round((float) ($discounted['coupon_discount'] ?? 0), 2);
|
||||
$amount_minor = max(0, (int) ($discounted['payable_minor'] ?? 0));
|
||||
$list_minor = max(0, (int) ($discounted['list_minor'] ?? $amount_minor));
|
||||
$rebate_discount_minor = max(0, (int) ($discounted['rebate_discount_minor'] ?? 0));
|
||||
$coupon_discount_minor = max(0, (int) ($discounted['coupon_discount_minor'] ?? 0));
|
||||
$amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor($amount_minor);
|
||||
$list_amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor($list_minor);
|
||||
$rebate_discount = \AKSRT\Ledger\AssetAmount::decimal_from_minor($rebate_discount_minor);
|
||||
$coupon_discount = \AKSRT\Ledger\AssetAmount::decimal_from_minor($coupon_discount_minor);
|
||||
$coupon_meta = is_array($discounted['coupon'] ?? null) ? $discounted['coupon'] : [];
|
||||
$referrer_id = (int) ($discounted['referrer_id'] ?? 0);
|
||||
$rebate_price = round((float) ($discounted['rebate_price'] ?? 0), 2);
|
||||
$rebate_price_minor = max(0, (int) ($discounted['rebate_price_minor'] ?? 0));
|
||||
$rebate_price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($rebate_price_minor);
|
||||
|
||||
if ($amount <= 0) {
|
||||
if ($amount_minor <= 0) {
|
||||
// Free after discount (or free content): create 0-paid order for access + burn coupon.
|
||||
if (!class_exists('\AKSRT\Payment\Order') || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]);
|
||||
@@ -298,50 +344,71 @@ trait PayOrders {
|
||||
'post_id' => $post_id,
|
||||
'price_type' => $price_type,
|
||||
'vip_level' => (int) ($priced['vip_level'] ?? 0),
|
||||
'base' => (float) ($priced['base'] ?? $list_amount),
|
||||
'base' => $this->asset_value($this->parse_asset_minor($priced['base'] ?? $list_amount, '0'), '0'),
|
||||
'list_amount' => $list_amount,
|
||||
'rebate_discount' => $rebate_discount,
|
||||
'coupon_discount' => $coupon_discount,
|
||||
'discount' => $coupon_discount,
|
||||
'coupon' => $coupon_meta,
|
||||
'coupon_code' => (string) ($coupon_meta['code'] ?? $coupon_code),
|
||||
'amount' => 0,
|
||||
'payable' => 0,
|
||||
'amount' => '0.00',
|
||||
'payable' => '0.00',
|
||||
'referrer_id' => $referrer_id,
|
||||
'rebate_price' => 0,
|
||||
'rebate_price' => '0.00',
|
||||
'ip_address' => function_exists('aksrt_pay_client_ip') ? aksrt_pay_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
'guest' => $user_id <= 0 ? 1 : 0,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$existing = $operation_key !== '' ? $repo->get_by_request_key($operation_key) : null;
|
||||
$replayed = is_array($existing);
|
||||
if (is_array($existing)) {
|
||||
if ((int) ($existing['user_id'] ?? 0) !== max(0, $user_id)
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
|
||||
|| (int) ($existing['product_id'] ?? 0) !== $post_id
|
||||
|| sanitize_key((string) ($existing['payment_method'] ?? '')) !== 'free'
|
||||
|| (string) ($existing['amount'] ?? '') !== '0.00'
|
||||
) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他内容请求。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order_number = (string) $existing['order_number'];
|
||||
} else {
|
||||
$order_number = $repo->create([
|
||||
'user_id' => max(0, $user_id),
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_id' => $post_id,
|
||||
'amount' => 0.0,
|
||||
'amount' => '0.00',
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => 'free',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
]);
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
}
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'free',
|
||||
'payment_transaction_id' => 'free_' . $order_number,
|
||||
'note' => __('优惠后无需支付。', 'aksrt'),
|
||||
]);
|
||||
if (!$paid) {
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$status = is_array($existing) ? (string) ($existing['status'] ?? '') : '';
|
||||
if (!in_array($status, [\AKSRT\Payment\Order::STATUS_PAID, \AKSRT\Payment\Order::STATUS_DELIVERED, \AKSRT\Payment\Order::STATUS_COMPLETED], true)) {
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'free',
|
||||
'payment_transaction_id' => 'free_' . $order_number,
|
||||
'note' => __('优惠后无需支付。', 'aksrt'),
|
||||
]);
|
||||
if (!$paid) {
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => 0,
|
||||
'amount' => '0.00',
|
||||
'pay_modo' => '0',
|
||||
'free' => true,
|
||||
'transaction_id' => 'free_' . $order_number,
|
||||
'replayed' => $replayed,
|
||||
'message' => __('无需支付,已可访问。', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
@@ -358,21 +425,31 @@ trait PayOrders {
|
||||
if ($user_id <= 0 && in_array($payment_method, ['balance', 'points'], true)) {
|
||||
$payment_method = 'alipay';
|
||||
}
|
||||
$amount_decimal = $amount;
|
||||
|
||||
$coupon_code_norm = (string) ($coupon_meta['code'] ?? $coupon_code);
|
||||
|
||||
// Resume existing pending content order for this post when amount + coupon match (logged-in only).
|
||||
$pending = $user_id > 0 ? $this->find_pending_content_order($user_id, $post_id) : null;
|
||||
// A retry resolves only through its request key; a new operation never reuses another pending order.
|
||||
$pending = $operation_key !== '' ? \AKSRT\Payment\OrderRepository::get_instance()->get_by_request_key($operation_key) : null;
|
||||
$can_resume = false;
|
||||
if ($pending && abs((float) ($pending['amount'] ?? 0) - $amount) < 0.001) {
|
||||
if (is_array($pending)) {
|
||||
$pending_meta = $this->decode_order_note_meta($pending);
|
||||
$pending_coupon = (string) ($pending_meta['coupon_code'] ?? '');
|
||||
if (is_array($pending_meta['coupon'] ?? null)) {
|
||||
$pending_coupon = (string) ($pending_meta['coupon']['code'] ?? $pending_coupon);
|
||||
}
|
||||
if ($pending_coupon === $coupon_code_norm) {
|
||||
$can_resume = true;
|
||||
}
|
||||
$can_resume = (int) ($pending['user_id'] ?? 0) === max(0, $user_id)
|
||||
&& sanitize_key((string) ($pending['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
|
||||
&& (int) ($pending['product_id'] ?? 0) === $post_id
|
||||
&& (string) ($pending['amount'] ?? '') === $amount_decimal
|
||||
&& strtoupper((string) ($pending['currency'] ?? '')) === 'CNY'
|
||||
&& sanitize_key((string) ($pending['payment_method'] ?? '')) === $payment_method
|
||||
&& (string) ($pending_meta['price_type'] ?? '') === $price_type
|
||||
&& (int) ($pending_meta['post_id'] ?? 0) === $post_id
|
||||
&& $pending_coupon === $coupon_code_norm;
|
||||
if (!$can_resume) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他内容购买请求。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
}
|
||||
if ($can_resume) {
|
||||
$order_number = (string) ($pending['order_number'] ?? '');
|
||||
@@ -384,15 +461,15 @@ trait PayOrders {
|
||||
'post_id' => $post_id,
|
||||
'price_type' => $price_type,
|
||||
'vip_level' => (int) ($priced['vip_level'] ?? 0),
|
||||
'base' => (float) ($priced['base'] ?? $list_amount),
|
||||
'base' => $this->asset_value($this->parse_asset_minor($priced['base'] ?? $list_amount, '0'), '0'),
|
||||
'list_amount' => $list_amount,
|
||||
'rebate_discount' => $rebate_discount,
|
||||
'coupon_discount' => $coupon_discount,
|
||||
'discount' => $coupon_discount,
|
||||
'coupon' => $coupon_meta,
|
||||
'coupon_code' => $coupon_code_norm,
|
||||
'amount' => $amount,
|
||||
'payable' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'payable' => $amount_decimal,
|
||||
'referrer_id' => $referrer_id,
|
||||
'rebate_price' => $rebate_price,
|
||||
'ip_address' => function_exists('aksrt_pay_client_ip') ? aksrt_pay_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
@@ -403,12 +480,13 @@ trait PayOrders {
|
||||
'user_id' => max(0, $user_id),
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
|
||||
'product_id' => $post_id,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
|
||||
if ($order_number === '') {
|
||||
@@ -425,6 +503,23 @@ trait PayOrders {
|
||||
if ($payment_method === '') {
|
||||
$payment_method = $user_id > 0 ? 'balance' : 'alipay';
|
||||
}
|
||||
if ($can_resume && in_array((string) ($order['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_PAID, \AKSRT\Payment\Order::STATUS_DELIVERED, \AKSRT\Payment\Order::STATUS_COMPLETED], true)) {
|
||||
$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]);
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount_decimal,
|
||||
'pay_modo' => '0',
|
||||
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
|
||||
'replayed' => true,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('支付成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$order['subject'] = sprintf(
|
||||
/* translators: %s: post title */
|
||||
@@ -433,7 +528,7 @@ trait PayOrders {
|
||||
);
|
||||
$order['payment_method'] = $payment_method;
|
||||
// Ensure gateway uses discounted amount.
|
||||
$order['amount'] = $amount;
|
||||
$order['amount'] = $amount_decimal;
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Gateway')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用。', 'aksrt'), ['status' => 500]);
|
||||
@@ -455,7 +550,7 @@ trait PayOrders {
|
||||
return new \WP_Error(
|
||||
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
|
||||
(string) ($payment_result['message'] ?? __('支付失败。', 'aksrt')),
|
||||
['status' => 402, 'order_number' => $order_number]
|
||||
['status' => (int) ($payment_result['status'] ?? 400), 'order_number' => $order_number]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -467,11 +562,13 @@ trait PayOrders {
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'pay_modo' => '0',
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => false,
|
||||
'poll' => true,
|
||||
'transaction_id' => 0,
|
||||
'replayed' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -480,8 +577,11 @@ trait PayOrders {
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'pay_modo' => '0',
|
||||
'transaction_id' => (int) ($payment_result['ledger_transaction_id'] ?? 0),
|
||||
'replayed' => !empty($payment_result['replayed']),
|
||||
'balance' => $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : '0.00',
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => true,
|
||||
],
|
||||
|
||||
+31
-6
@@ -17,11 +17,23 @@ trait PayRest {
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_preview'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
'args' => [
|
||||
'id' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
|
||||
'price_type' => ['type' => 'string', 'enum' => ['', 'download_limit_over']],
|
||||
'coupon_code' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_purchase'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
'args' => [
|
||||
'id' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
|
||||
'mode' => ['type' => 'string', 'enum' => ['points', 'cash']],
|
||||
'payment_method' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_key'],
|
||||
'price_type' => ['type' => 'string', 'enum' => ['', 'download_limit_over']],
|
||||
'coupon_code' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -29,6 +41,15 @@ trait PayRest {
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_order_status'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
'args' => [
|
||||
'order_number' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'pattern' => '^[A-Za-z0-9_-]+$',
|
||||
'maxLength' => 64,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -60,7 +81,7 @@ trait PayRest {
|
||||
public function rest_preview(\WP_REST_Request $request) {
|
||||
$post_id = (int) $request['id'];
|
||||
$uid = get_current_user_id();
|
||||
$coupon_code = sanitize_text_field((string) ($request->get_param('coupon_code') ?? $request->get_param('coupon') ?? ''));
|
||||
$coupon_code = sanitize_text_field((string) $request->get_param('coupon_code'));
|
||||
$price_type = sanitize_key((string) ($request->get_param('price_type') ?? ''));
|
||||
if ($price_type !== 'download_limit_over') {
|
||||
$price_type = '';
|
||||
@@ -81,6 +102,10 @@ trait PayRest {
|
||||
public function rest_purchase(\WP_REST_Request $request) {
|
||||
$post_id = (int) $request['id'];
|
||||
$uid = get_current_user_id();
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'content-purchase', $uid);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
@@ -91,7 +116,7 @@ trait PayRest {
|
||||
if ($price_type !== 'download_limit_over') {
|
||||
$price_type = '';
|
||||
}
|
||||
$coupon_code = sanitize_text_field((string) ($params['coupon_code'] ?? $params['coupon'] ?? ''));
|
||||
$coupon_code = sanitize_text_field((string) ($params['coupon_code'] ?? ''));
|
||||
|
||||
// Guest allowed only for cash + pay_limit=0 + setting on.
|
||||
if ($uid <= 0) {
|
||||
@@ -121,8 +146,8 @@ trait PayRest {
|
||||
if ($uid <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('积分支付请先登录。', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
// Points path: ignore coupon (legacy parity).
|
||||
return $this->rest_wrap_result($this->create_and_pay_points($post_id, $uid, $price_type));
|
||||
// Point purchases do not support coupons.
|
||||
return $this->rest_wrap_result($this->create_and_pay_points($post_id, $uid, $price_type, $operation_key));
|
||||
}
|
||||
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'balance'));
|
||||
@@ -133,7 +158,7 @@ trait PayRest {
|
||||
if ($uid <= 0 && in_array($payment_method, ['balance', 'points'], true)) {
|
||||
$payment_method = 'alipay';
|
||||
}
|
||||
return $this->rest_wrap_result($this->create_cash_order($post_id, $uid, $payment_method, $price_type, $coupon_code));
|
||||
return $this->rest_wrap_result($this->create_cash_order($post_id, $uid, $payment_method, $price_type, $coupon_code, $operation_key));
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +202,7 @@ trait PayRest {
|
||||
'order_number' => $order_number,
|
||||
'status' => $status,
|
||||
'paid' => $paid,
|
||||
'amount' => (float) ($order['amount'] ?? 0),
|
||||
'amount' => (string) ($order['amount'] ?? '0.00'),
|
||||
'currency' => (string) ($order['currency'] ?? ''),
|
||||
'product_id' => (int) ($order['product_id'] ?? 0),
|
||||
'product_type' => (string) ($order['product_type'] ?? ''),
|
||||
|
||||
@@ -29,7 +29,6 @@ class VipService {
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2);
|
||||
if (class_exists(VipMembershipExpiry::class)) {
|
||||
VipMembershipExpiry::init();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ trait VipServicePackages {
|
||||
* Preferred: price|show_price|time|unit|tag|title
|
||||
* Legacy: price|days|title
|
||||
*
|
||||
* @return array<int, array{price:float,show_price:float,time:int,unit:string,tag:string,tag_class:string,title:string,days:int}>
|
||||
* @return array<int, array{price:string,show_price:string,time:int,unit:string,tag:string,tag_class:string,title:string,days:int}>
|
||||
*/
|
||||
public function parse_level_products(string $raw): array {
|
||||
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
|
||||
@@ -28,8 +28,8 @@ trait VipServicePackages {
|
||||
continue;
|
||||
}
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
$price = isset($parts[0]) ? (float) $parts[0] : 0.0;
|
||||
if ($price <= 0) {
|
||||
$price_minor = $this->parse_vip_money_minor($parts[0] ?? '');
|
||||
if ($price_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ trait VipServicePackages {
|
||||
// Or: price|show_price|time|unit|title (5)
|
||||
if ($n >= 4 && (in_array(strtolower((string) ($parts[3] ?? '')), ['day', 'month', 'd', 'm'], true)
|
||||
|| (isset($parts[3]) && $parts[3] !== '' && !is_numeric($parts[3])))) {
|
||||
$show_price = isset($parts[1]) ? (float) $parts[1] : 0.0;
|
||||
$show_minor = $this->parse_vip_money_minor($parts[1] ?? '0');
|
||||
$time = isset($parts[2]) ? (int) $parts[2] : 0;
|
||||
$unit_raw = strtolower((string) ($parts[3] ?? 'month'));
|
||||
$unit = ($unit_raw === 'day' || $unit_raw === 'd') ? 'day' : 'month';
|
||||
@@ -53,7 +53,7 @@ trait VipServicePackages {
|
||||
}
|
||||
} else {
|
||||
// Legacy: price|days|title
|
||||
$show_price = 0.0;
|
||||
$show_minor = 0;
|
||||
$days_legacy = isset($parts[1]) ? (int) $parts[1] : 0;
|
||||
$time = $days_legacy;
|
||||
$unit = 'day';
|
||||
@@ -74,8 +74,8 @@ trait VipServicePackages {
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'price' => round($price, 2),
|
||||
'show_price' => max(0.0, round($show_price, 2)),
|
||||
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
|
||||
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor),
|
||||
'time' => $time,
|
||||
'unit' => $unit,
|
||||
'tag' => $tag,
|
||||
@@ -103,14 +103,14 @@ trait VipServicePackages {
|
||||
/**
|
||||
* @deprecated Use parse_level_products — kept as thin adapter for upgrade fixed lines.
|
||||
*
|
||||
* @return array<int, array{price:float,days:int,title:string}>
|
||||
* @return array<int, array{price:string,days:int,title:string}>
|
||||
*/
|
||||
public function parse_packages(string $raw): array {
|
||||
$parsed = $this->parse_level_products($raw);
|
||||
$out = [];
|
||||
foreach ($parsed as $p) {
|
||||
$out[] = [
|
||||
'price' => (float) $p['price'],
|
||||
'price' => (string) $p['price'],
|
||||
'days' => (int) $p['days'],
|
||||
'title' => (string) $p['title'],
|
||||
];
|
||||
@@ -161,12 +161,16 @@ trait VipServicePackages {
|
||||
if ($is_permanent || $expires <= time() || $has_unit) {
|
||||
continue;
|
||||
}
|
||||
$daily = isset($parts[1]) ? (float) $parts[1] : 0.0;
|
||||
if ($daily <= 0) {
|
||||
$daily_minor = $this->parse_vip_money_minor($parts[1] ?? '');
|
||||
if ($daily_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
$remaining = (int) ceil(max(1, ($expires - time()) / DAY_IN_SECONDS));
|
||||
$price = round(max(0.01, $remaining * $daily), 2);
|
||||
if ($daily_minor > intdiv(PHP_INT_MAX, $remaining)) {
|
||||
continue;
|
||||
}
|
||||
$price_minor = $remaining * $daily_minor;
|
||||
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$title = sanitize_text_field((string) ($parts[2] ?? ''));
|
||||
if ($title === '') {
|
||||
$title = sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining);
|
||||
@@ -178,7 +182,7 @@ trait VipServicePackages {
|
||||
'action' => 'upgrade',
|
||||
'price' => $price,
|
||||
'base_price' => $price,
|
||||
'show_price' => 0.0,
|
||||
'show_price' => '0.00',
|
||||
'discount_tag' => __('剩余天数', 'aksrt'),
|
||||
'days' => $remaining,
|
||||
'title' => $title,
|
||||
@@ -198,28 +202,30 @@ trait VipServicePackages {
|
||||
if ($is_permanent || $has_jump) {
|
||||
continue;
|
||||
}
|
||||
$price = isset($parts[1]) ? (float) $parts[1] : 0.0;
|
||||
if ($price <= 0) {
|
||||
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
|
||||
if ($price_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$title = sanitize_text_field((string) ($parts[2] ?? ''));
|
||||
if ($title === '') {
|
||||
$title = __('升级为永久会员', 'aksrt');
|
||||
}
|
||||
$show = isset($parts[3]) ? (float) $parts[3] : 0.0;
|
||||
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
|
||||
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
|
||||
$items[] = [
|
||||
'package_id' => $this->encode_package_id(2, 903),
|
||||
'level' => 2,
|
||||
'index' => 903,
|
||||
'action' => 'upgrade',
|
||||
'price' => round($price, 2),
|
||||
'base_price' => round($price, 2),
|
||||
'show_price' => max(0.0, $show),
|
||||
'price' => $price,
|
||||
'base_price' => $price,
|
||||
'show_price' => $show,
|
||||
'discount_tag' => __('永久升级', 'aksrt'),
|
||||
'days' => 0,
|
||||
'title' => $title,
|
||||
'price_label' => $this->format_cash_label($price),
|
||||
'show_price_label' => $show > 0 ? $this->format_cash_label($show) : '',
|
||||
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
|
||||
'days_label' => __('永久', 'aksrt'),
|
||||
'cta_label' => __('立即升级', 'aksrt'),
|
||||
'is_upgrade_product' => true,
|
||||
@@ -234,28 +240,30 @@ trait VipServicePackages {
|
||||
if ($has_permanent) {
|
||||
continue;
|
||||
}
|
||||
$price = isset($parts[1]) ? (float) $parts[1] : 0.0;
|
||||
if ($price <= 0) {
|
||||
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
|
||||
if ($price_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$title = sanitize_text_field((string) ($parts[2] ?? ''));
|
||||
if ($title === '') {
|
||||
$title = __('永久会员升级', 'aksrt');
|
||||
}
|
||||
$show = isset($parts[3]) ? (float) $parts[3] : 0.0;
|
||||
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
|
||||
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
|
||||
$items[] = [
|
||||
'package_id' => $this->encode_package_id(2, 901),
|
||||
'level' => 2,
|
||||
'index' => 901,
|
||||
'action' => 'upgrade',
|
||||
'price' => round($price, 2),
|
||||
'base_price' => round($price, 2),
|
||||
'show_price' => max(0.0, $show),
|
||||
'price' => $price,
|
||||
'base_price' => $price,
|
||||
'show_price' => $show,
|
||||
'discount_tag' => __('升级专享', 'aksrt'),
|
||||
'days' => 0,
|
||||
'title' => $title,
|
||||
'price_label' => $this->format_cash_label($price),
|
||||
'show_price_label' => $show > 0 ? $this->format_cash_label($show) : '',
|
||||
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
|
||||
'days_label' => __('永久', 'aksrt'),
|
||||
'cta_label' => __('立即升级', 'aksrt'),
|
||||
'is_upgrade_product' => true,
|
||||
@@ -280,13 +288,13 @@ trait VipServicePackages {
|
||||
'level' => 2,
|
||||
'index' => 901,
|
||||
'action' => 'upgrade',
|
||||
'price' => (float) $pkg['price'],
|
||||
'base_price' => (float) $pkg['price'],
|
||||
'show_price' => 0.0,
|
||||
'price' => (string) $pkg['price'],
|
||||
'base_price' => (string) $pkg['price'],
|
||||
'show_price' => '0.00',
|
||||
'discount_tag' => __('升级专享', 'aksrt'),
|
||||
'days' => (int) $pkg['days'],
|
||||
'title' => (string) $pkg['title'],
|
||||
'price_label' => $this->format_cash_label((float) $pkg['price']),
|
||||
'price_label' => $this->format_cash_label((string) $pkg['price']),
|
||||
'show_price_label' => '',
|
||||
'days_label' => ((int) $pkg['days'] <= 0)
|
||||
? __('永久', 'aksrt')
|
||||
@@ -399,10 +407,10 @@ trait VipServicePackages {
|
||||
*
|
||||
* @param array<string,mixed> $renew
|
||||
* @param array<string,mixed> $upgrade
|
||||
* @return array{price:float,show_price:float,tag:string}|null
|
||||
* @return array{price:string,show_price:string,tag:string}|null
|
||||
*/
|
||||
private function apply_action_price(
|
||||
float $base_price,
|
||||
string $base_price,
|
||||
string $action,
|
||||
array $renew,
|
||||
array $upgrade,
|
||||
@@ -412,7 +420,8 @@ trait VipServicePackages {
|
||||
bool $is_permanent,
|
||||
int $days
|
||||
): ?array {
|
||||
if ($base_price <= 0) {
|
||||
$base_minor = $this->parse_vip_money_minor($base_price);
|
||||
if ($base_minor <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -422,57 +431,59 @@ trait VipServicePackages {
|
||||
|
||||
if ($action !== 'renew') {
|
||||
return [
|
||||
'price' => round($base_price, 2),
|
||||
'show_price' => 0.0,
|
||||
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($base_minor),
|
||||
'show_price' => '0.00',
|
||||
'tag' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$type = sanitize_key((string) ($renew['price_type'] ?? 'discount'));
|
||||
$price = $base_price;
|
||||
$show = $base_price;
|
||||
$price_minor = $base_minor;
|
||||
$show_minor = $base_minor;
|
||||
$tag = '';
|
||||
|
||||
if ($type === 'discount') {
|
||||
$discount = (float) ($renew['discount'] ?? 8);
|
||||
$discount = (int) ($renew['discount'] ?? 8);
|
||||
if ($discount <= 0) {
|
||||
$discount = 8;
|
||||
}
|
||||
if ($discount <= 10) {
|
||||
$price = round($base_price * ($discount / 10), 2);
|
||||
$percent = $discount * 10;
|
||||
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
|
||||
$tag = sprintf(
|
||||
/* translators: %s: discount like 8 */
|
||||
__('%s折续费', 'aksrt'),
|
||||
rtrim(rtrim(number_format($discount, 1, '.', ''), '0'), '.')
|
||||
(string) $discount
|
||||
);
|
||||
} else {
|
||||
$price = round($base_price * (min(100, $discount) / 100), 2);
|
||||
$percent = min(100, $discount);
|
||||
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
|
||||
$tag = sprintf(__('%s%% 续费价', 'aksrt'), (string) (int) $discount);
|
||||
}
|
||||
} elseif ($type === 'reduce') {
|
||||
$reduce = max(0, (float) ($renew['reduce'] ?? 20));
|
||||
$price = round(max(0.01, $base_price - $reduce), 2);
|
||||
$reduce_minor = $this->parse_vip_money_minor($renew['reduce'] ?? '20');
|
||||
$price_minor = max(1, $base_minor - $reduce_minor);
|
||||
$tag = sprintf(
|
||||
/* translators: %s: amount */
|
||||
__('续费立减%s', 'aksrt'),
|
||||
$this->format_cash_label($reduce)
|
||||
$this->format_cash_label(\AKSRT\Ledger\AssetAmount::decimal_from_minor($reduce_minor))
|
||||
);
|
||||
} else {
|
||||
$show = 0.0;
|
||||
$show_minor = 0;
|
||||
$tag = __('续费同价', 'aksrt');
|
||||
}
|
||||
|
||||
if ($price < 0.01) {
|
||||
$price = 0.01;
|
||||
if ($price_minor < 1) {
|
||||
$price_minor = 1;
|
||||
}
|
||||
if ($show > 0 && $price >= $show) {
|
||||
$show = 0.0;
|
||||
if ($show_minor > 0 && $price_minor >= $show_minor) {
|
||||
$show_minor = 0;
|
||||
$tag = __('续费', 'aksrt');
|
||||
}
|
||||
|
||||
return [
|
||||
'price' => $price,
|
||||
'show_price' => $show > $price ? $show : 0.0,
|
||||
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
|
||||
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor > $price_minor ? $show_minor : 0),
|
||||
'tag' => $tag,
|
||||
];
|
||||
}
|
||||
@@ -532,7 +543,7 @@ trait VipServicePackages {
|
||||
|
||||
|
||||
|
||||
private function format_cash_label(float $amount): string {
|
||||
private function format_cash_label(string $amount): string {
|
||||
$symbol = '¥';
|
||||
if (method_exists($this, 'get_paid_display_context')) {
|
||||
$paid = $this->get_paid_display_context();
|
||||
@@ -545,6 +556,17 @@ trait VipServicePackages {
|
||||
return $symbol . $n;
|
||||
}
|
||||
|
||||
private function parse_vip_money_minor($value): int {
|
||||
if (!is_string($value)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function format_expires_label(int $expires, int $level): string {
|
||||
|
||||
@@ -143,7 +143,7 @@ trait VipServicePurchase {
|
||||
? $this->parse_level_products((string) ($levels[$lv]['products_raw'] ?? ''))
|
||||
: [];
|
||||
foreach ($packages as $idx => $pkg) {
|
||||
$base_price = (float) $pkg['price'];
|
||||
$base_price = (string) $pkg['price'];
|
||||
$days = (int) $pkg['days'];
|
||||
$action = 'pay';
|
||||
if ($level > 0 && $lv === $level) {
|
||||
@@ -157,9 +157,11 @@ trait VipServicePurchase {
|
||||
continue;
|
||||
}
|
||||
|
||||
$show_price = (float) ($priced['show_price'] ?? 0);
|
||||
if ($show_price <= 0 && !empty($pkg['show_price']) && $action === 'pay') {
|
||||
$show_price = (float) $pkg['show_price'];
|
||||
$show_price = (string) ($priced['show_price'] ?? '0.00');
|
||||
$show_minor = $this->parse_vip_money_minor($show_price);
|
||||
if ($show_minor <= 0 && $this->parse_vip_money_minor($pkg['show_price'] ?? '0') > 0 && $action === 'pay') {
|
||||
$show_price = (string) $pkg['show_price'];
|
||||
$show_minor = $this->parse_vip_money_minor($show_price);
|
||||
}
|
||||
$discount_tag = (string) ($priced['tag'] ?? '');
|
||||
if ($discount_tag === '' && $action === 'pay' && !empty($pkg['tag'])) {
|
||||
@@ -178,7 +180,7 @@ trait VipServicePurchase {
|
||||
'level' => $lv,
|
||||
'index' => $idx + 1,
|
||||
'action' => $action,
|
||||
'price' => (float) $priced['price'],
|
||||
'price' => (string) $priced['price'],
|
||||
'base_price' => $base_price,
|
||||
'show_price' => $show_price,
|
||||
'discount_tag' => $discount_tag,
|
||||
@@ -186,8 +188,8 @@ trait VipServicePurchase {
|
||||
'time' => (int) ($pkg['time'] ?? 0),
|
||||
'unit' => (string) ($pkg['unit'] ?? 'day'),
|
||||
'title' => (string) $pkg['title'],
|
||||
'price_label' => $this->format_cash_label((float) $priced['price']),
|
||||
'show_price_label' => $show_price > 0
|
||||
'price_label' => $this->format_cash_label((string) $priced['price']),
|
||||
'show_price_label' => $show_minor > 0
|
||||
? $this->format_cash_label($show_price)
|
||||
: '',
|
||||
'days_label' => $days_label,
|
||||
@@ -230,12 +232,8 @@ trait VipServicePurchase {
|
||||
}
|
||||
}
|
||||
|
||||
$points_balance = ($user_id > 0 && class_exists('\AKSRT\Credits\Credits'))
|
||||
? (int) \AKSRT\Credits\Credits::balance($user_id)
|
||||
: 0;
|
||||
$wallet_balance = ($user_id > 0 && class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled())
|
||||
? (float) \AKSRT\Credits\Balance::balance($user_id)
|
||||
: 0.0;
|
||||
$points_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS) : 0;
|
||||
$wallet_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : '0.00';
|
||||
$methods = $this->available_payment_methods();
|
||||
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
|
||||
|
||||
@@ -304,7 +302,7 @@ trait VipServicePurchase {
|
||||
/**
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function purchase(int $user_id, int $package_id, string $payment_method = 'balance') {
|
||||
public function purchase(int $user_id, int $package_id, string $payment_method = 'balance', string $operation_key = '') {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录后再开通会员。', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
@@ -322,8 +320,9 @@ trait VipServicePurchase {
|
||||
$level = (int) ($pkg['level'] ?? 0);
|
||||
$index = (int) ($pkg['index'] ?? 0);
|
||||
$action = sanitize_key((string) ($pkg['action'] ?? 'pay'));
|
||||
$amount = round((float) ($pkg['price'] ?? 0), 2);
|
||||
$base_price = (float) ($pkg['base_price'] ?? $amount);
|
||||
$amount = is_string($pkg['price'] ?? null) ? (string) $pkg['price'] : '';
|
||||
$amount_minor = $this->parse_vip_money_minor($amount);
|
||||
$base_price = is_string($pkg['base_price'] ?? null) ? (string) $pkg['base_price'] : $amount;
|
||||
$days = (int) ($pkg['days'] ?? 0);
|
||||
$pkg_time = array_key_exists('time', $pkg) ? (int) $pkg['time'] : $days;
|
||||
$pkg_unit = sanitize_key((string) ($pkg['unit'] ?? 'day'));
|
||||
@@ -341,9 +340,10 @@ trait VipServicePurchase {
|
||||
return new \WP_Error('aksrt_invalid_package', __('套餐无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
if ($amount <= 0) {
|
||||
if ($amount_minor <= 0) {
|
||||
return new \WP_Error('aksrt_invalid_price', __('套餐价格无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$amount_decimal = \AKSRT\Ledger\AssetAmount::decimal_from_minor($amount_minor);
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Order') || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付系统不可用', 'aksrt'), ['status' => 500]);
|
||||
@@ -370,21 +370,63 @@ trait VipServicePurchase {
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$expires_at = \AKSRT\Settings\PaymentRuntime::get_order_expiry_at();
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$existing = $operation_key !== '' ? $repo->get_by_request_key($operation_key) : null;
|
||||
$is_new_order = !is_array($existing);
|
||||
if (is_array($existing)) {
|
||||
$existing_meta = json_decode((string) ($existing['note'] ?? ''), true);
|
||||
$existing_meta = is_array($existing_meta) ? $existing_meta : [];
|
||||
if ((int) ($existing['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP
|
||||
|| (int) ($existing['product_id'] ?? 0) !== $package_id
|
||||
|| (string) ($existing['amount'] ?? '') !== $amount_decimal
|
||||
|| strtoupper((string) ($existing['currency'] ?? '')) !== 'CNY'
|
||||
|| sanitize_key((string) ($existing['payment_method'] ?? '')) !== $payment_method
|
||||
|| (int) ($existing_meta['level'] ?? 0) !== $level
|
||||
|| (int) ($existing_meta['days'] ?? 0) !== $days
|
||||
|| (int) ($existing_meta['time'] ?? 0) !== $pkg_time
|
||||
|| sanitize_key((string) ($existing_meta['unit'] ?? '')) !== $pkg_unit
|
||||
|| sanitize_key((string) ($existing_meta['action'] ?? '')) !== $action
|
||||
) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他会员购买请求', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order_number = (string) $existing['order_number'];
|
||||
} else {
|
||||
$order_number = $repo->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
|
||||
'product_id' => $package_id,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => $expires_at,
|
||||
]);
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
}
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
if (is_array($existing) && in_array((string) ($existing['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_PAID, \AKSRT\Payment\Order::STATUS_DELIVERED, \AKSRT\Payment\Order::STATUS_COMPLETED], true)) {
|
||||
$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]);
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount_decimal,
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
'action' => $action,
|
||||
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
|
||||
'replayed' => true,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('会员购买已完成', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
// Build the request-local order context from the same server-derived
|
||||
// package used for validation. PaymentOrchestrator still reloads the
|
||||
// persisted row under its attempt lock before touching the gateway.
|
||||
@@ -395,7 +437,7 @@ trait VipServicePurchase {
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
|
||||
'product_id' => $package_id,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
@@ -430,7 +472,7 @@ trait VipServicePurchase {
|
||||
return new \WP_Error(
|
||||
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
|
||||
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
|
||||
['status' => 402, 'order_number' => $order_number]
|
||||
['status' => (int) ($payment_result['status'] ?? 400), 'order_number' => $order_number]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -442,10 +484,12 @@ trait VipServicePurchase {
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => false,
|
||||
'poll' => true,
|
||||
'transaction_id' => 0,
|
||||
'replayed' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -454,10 +498,13 @@ trait VipServicePurchase {
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'amount' => $amount_decimal,
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
'action' => $action,
|
||||
'transaction_id' => (int) ($payment_result['ledger_transaction_id'] ?? 0),
|
||||
'replayed' => !empty($payment_result['replayed']),
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => $action === 'renew'
|
||||
? __('会员续费成功', 'aksrt')
|
||||
: ($action === 'upgrade' ? __('会员升级成功', 'aksrt') : __('会员开通成功', 'aksrt')),
|
||||
@@ -474,16 +521,16 @@ trait VipServicePurchase {
|
||||
* @param string $order_number
|
||||
* @param array $paid_order
|
||||
*/
|
||||
public function on_order_paid(string $order_number, array $paid_order): void {
|
||||
public function fulfill_paid_order(string $order_number, array $paid_order) {
|
||||
if (sanitize_key((string) ($paid_order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (sanitize_key((string) ($paid_order['payment_method'] ?? '')) === 'card_pass') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
$user_id = (int) ($paid_order['user_id'] ?? 0);
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
$meta = [];
|
||||
@@ -497,7 +544,7 @@ trait VipServicePurchase {
|
||||
$meta = $paid_order['meta_payload'];
|
||||
}
|
||||
if (!empty($meta['card_pass']) || sanitize_key((string) ($meta['vip_pay_type'] ?? '')) === 'card_pass') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
$level = (int) ($meta['level'] ?? 0);
|
||||
@@ -561,13 +608,20 @@ trait VipServicePurchase {
|
||||
$fulfilled = (int) get_user_meta($user_id, 'aksrt_vip_level', true) >= max($previous_level, $level)
|
||||
&& ($new_expiry === 0 || $days <= 0 || $new_expiry >= max($previous_expiry, time()) + ($days * DAY_IN_SECONDS));
|
||||
}
|
||||
if ($fulfilled && class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
if (!$fulfilled) {
|
||||
return new \WP_Error('aksrt_vip_fulfillment_failed', __('会员权益发放失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$completed = \AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
$order_number,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
['note' => __('会员权益已发放。', 'aksrt')]
|
||||
);
|
||||
if (!$completed) {
|
||||
return new \WP_Error('aksrt_vip_fulfillment_failed', __('会员订单完成失败。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -673,16 +727,13 @@ trait VipServicePurchase {
|
||||
*
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function exchange_with_points(int $user_id, int $package_id) {
|
||||
public function exchange_with_points(int $user_id, int $package_id, string $operation_key = '') {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录后再兑换。', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
if (!class_exists('\AKSRT\Credits\Credits')) {
|
||||
return new \WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$catalog = $this->get_catalog($user_id);
|
||||
if (array_key_exists('credits_enabled', $catalog) && empty($catalog['credits_enabled'])) {
|
||||
return new \WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 400]);
|
||||
return new \WP_Error('aksrt_points_disabled', __('积分功能未启用', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$points_ex = is_array($catalog['points_exchange'] ?? null) ? $catalog['points_exchange'] : [];
|
||||
if (empty($points_ex['enabled'])) {
|
||||
@@ -719,7 +770,7 @@ trait VipServicePurchase {
|
||||
|
||||
$balance = (int) ($catalog['user_points'] ?? 0);
|
||||
if ($balance < $cost) {
|
||||
return new \WP_Error('aksrt_credits_insufficient', __('积分不足,暂时无法兑换', 'aksrt'), [
|
||||
return new \WP_Error('aksrt_points_insufficient', __('积分不足,暂时无法兑换', 'aksrt'), [
|
||||
'status' => 402,
|
||||
'balance' => $balance,
|
||||
'cost' => $cost,
|
||||
@@ -741,62 +792,91 @@ trait VipServicePurchase {
|
||||
'action' => 'points_exchange',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
|
||||
'product_id' => $package_id,
|
||||
'amount' => (float) $cost,
|
||||
'currency' => 'points',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => 'points',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
]);
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$existing = $operation_key !== '' ? $repo->get_by_request_key($operation_key) : null;
|
||||
$is_new_order = !is_array($existing);
|
||||
if (is_array($existing)) {
|
||||
$existing_meta = json_decode((string) ($existing['note'] ?? ''), true);
|
||||
$existing_meta = is_array($existing_meta) ? $existing_meta : [];
|
||||
if ((int) ($existing['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP
|
||||
|| (int) ($existing['product_id'] ?? 0) !== $package_id
|
||||
|| (string) ($existing['currency'] ?? '') !== 'points'
|
||||
|| (int) ($existing['amount'] ?? 0) !== $cost
|
||||
|| sanitize_key((string) ($existing['payment_method'] ?? '')) !== 'points'
|
||||
|| (int) ($existing_meta['level'] ?? 0) !== $level
|
||||
|| (int) ($existing_meta['days'] ?? 0) !== $days
|
||||
) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他会员兑换请求', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order_number = (string) $existing['order_number'];
|
||||
} else {
|
||||
$order_number = $repo->allocate_order_number();
|
||||
}
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$guard = static function () use ($is_new_order, $order_number, $user_id, $package_id, $cost) {
|
||||
if ($is_new_order) {
|
||||
return true;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$current = $wpdb->get_row($wpdb->prepare("SELECT user_id, product_id, amount, currency, status FROM {$table} WHERE order_number = %s FOR UPDATE", $order_number), ARRAY_A);
|
||||
if (!is_array($current)
|
||||
|| (int) ($current['user_id'] ?? 0) !== $user_id
|
||||
|| (int) ($current['product_id'] ?? 0) !== $package_id
|
||||
|| (int) ($current['amount'] ?? 0) !== $cost
|
||||
|| (string) ($current['currency'] ?? '') !== 'points'
|
||||
|| !in_array((string) ($current['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_CREATED, \AKSRT\Payment\Order::STATUS_PENDING], true)
|
||||
) {
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单状态或金额已变化', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
|
||||
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
|
||||
: false;
|
||||
if ($lock === false) {
|
||||
return new \WP_Error('aksrt_pay_locked', __('该订单正在发起支付,请稍后重试。', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
try {
|
||||
$deducted = \AKSRT\Credits\Credits::deduct(
|
||||
$user_id,
|
||||
$cost,
|
||||
'vip_points_exchange',
|
||||
['order' => $order_number, 'level' => $level, 'days' => $days],
|
||||
sprintf(__('积分兑换会员 · %s', 'aksrt'), (string) ($match['title'] ?? ''))
|
||||
);
|
||||
if (is_wp_error($deducted) || $deducted === false) {
|
||||
\AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_CANCELLED, [
|
||||
'note' => __('积分扣除失败,自动取消', 'aksrt'),
|
||||
$ledger = \AKSRT\Ledger\LedgerService::get_instance();
|
||||
$deducted = $ledger->debit(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$cost,
|
||||
$operation_key,
|
||||
'vip_points_exchange',
|
||||
['type' => 'order', 'key' => $order_number],
|
||||
sprintf(__('积分兑换会员 · %s', 'aksrt'), (string) ($match['title'] ?? '')),
|
||||
$user_id,
|
||||
['level' => $level, 'days' => $days, 'package_id' => $package_id],
|
||||
static function () use ($is_new_order, $repo, $order_number, $operation_key, $user_id, $package_id, $cost, $note) {
|
||||
if ($is_new_order) {
|
||||
$created = $repo->create([
|
||||
'order_number' => $order_number,
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
|
||||
'product_id' => $package_id,
|
||||
'amount' => $cost,
|
||||
'currency' => 'points',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => 'points',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
if ($created !== $order_number) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'points',
|
||||
'payment_transaction_id' => 'points_' . $order_number,
|
||||
'note' => sprintf(__('积分兑换会员 %d 积分。', 'aksrt'), $cost),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
$msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('积分扣除失败', 'aksrt');
|
||||
return new \WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'points',
|
||||
'payment_transaction_id' => 'points_' . $order_number,
|
||||
'note' => sprintf(__('积分兑换会员 %d 积分。', 'aksrt'), $cost),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
if (!$paid) {
|
||||
\AKSRT\Credits\Credits::add(
|
||||
$user_id,
|
||||
$cost,
|
||||
'vip_points_exchange_refund',
|
||||
['order' => $order_number],
|
||||
__('会员兑换失败退款', 'aksrt')
|
||||
);
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('兑换确认失败,已尝试退回积分', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
} finally {
|
||||
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
|
||||
return $paid ? true : new \WP_Error('aksrt_order_confirm_failed', __('兑换确认失败', 'aksrt'), ['status' => 500]);
|
||||
},
|
||||
$guard
|
||||
);
|
||||
if (is_wp_error($deducted)) {
|
||||
return $deducted;
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -806,7 +886,9 @@ trait VipServicePurchase {
|
||||
'points' => $cost,
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
'balance' => \AKSRT\Credits\Credits::balance($user_id),
|
||||
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'transaction_id' => $deducted->transaction_id,
|
||||
'replayed' => $deducted->replayed,
|
||||
'message' => __('兑换成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
],
|
||||
|
||||
@@ -22,6 +22,10 @@ trait VipServiceRest {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_purchase'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
'args' => [
|
||||
'package_id' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
|
||||
'payment_method' => ['type' => 'string', 'sanitize_callback' => 'sanitize_key'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -29,6 +33,7 @@ trait VipServiceRest {
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_points_exchange'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
'args' => ['package_id' => ['type' => 'integer', 'required' => true, 'minimum' => 1]],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/pay/vip/modal', [
|
||||
@@ -112,12 +117,16 @@ trait VipServiceRest {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$package_id = (int) ($params['package_id'] ?? 0);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'vip-purchase', $uid);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'balance'));
|
||||
if ($payment_method === '' || $payment_method === 'cash') {
|
||||
$payment_method = 'balance';
|
||||
}
|
||||
|
||||
$result = $this->purchase($uid, $package_id, $payment_method);
|
||||
$result = $this->purchase($uid, $package_id, $payment_method, $operation_key);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
@@ -147,7 +156,11 @@ trait VipServiceRest {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$package_id = (int) ($params['package_id'] ?? 0);
|
||||
$result = $this->exchange_with_points($uid, $package_id);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'vip-points-purchase', $uid);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
$result = $this->exchange_with_points($uid, $package_id, $operation_key);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
+34
-20
@@ -43,10 +43,11 @@ class Visibility {
|
||||
? ((($priced['modo'] ?? '0') === 'points') ? 'points' : '0')
|
||||
: ((($pay_meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0');
|
||||
$price = is_array($priced)
|
||||
? (float) ($priced['amount'] ?? 0)
|
||||
? ($priced['amount'] ?? ($pay_modo === 'points' ? 0 : '0.00'))
|
||||
: (($pay_modo === 'points')
|
||||
? (float) ($pay_meta['points_price'] ?? 0)
|
||||
: (float) ($pay_meta['price'] ?? 0));
|
||||
? (int) ($pay_meta['points_price'] ?? 0)
|
||||
: (string) ($pay_meta['price'] ?? '0.00'));
|
||||
$price_minor = self::asset_minor($price, $pay_modo);
|
||||
$state = [
|
||||
'post_id' => $resource_post instanceof \WP_Post ? (int) $resource_post->ID : 0,
|
||||
'pay_type' => $pay_type,
|
||||
@@ -75,7 +76,7 @@ class Visibility {
|
||||
return $state;
|
||||
}
|
||||
|
||||
$has_free_access = $price <= 0 && $pay_type !== \AKSRT\Core\DomainModel::PAY_TYPE_VIP;
|
||||
$has_free_access = $price_minor <= 0 && $pay_type !== \AKSRT\Core\DomainModel::PAY_TYPE_VIP;
|
||||
$pay_limit = (int) ($pay_meta['pay_limit'] ?? 0);
|
||||
if (!in_array($pay_limit, [0, 1, 2], true)) {
|
||||
$pay_limit = 0;
|
||||
@@ -120,19 +121,19 @@ class Visibility {
|
||||
if ($pay_type === \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD) {
|
||||
// Cash: order gate only. Points: cashier once → paid order zeros cost.
|
||||
$download_cost = 0;
|
||||
if ($pay_modo === 'points' && $price > 0 && !$has_paid_order) {
|
||||
$download_cost = (int) ceil($price);
|
||||
if ($pay_modo === 'points' && $price_minor > 0 && !$has_paid_order) {
|
||||
$download_cost = $price_minor;
|
||||
}
|
||||
if ($allowed_member && $price <= 0) {
|
||||
if ($allowed_member && $price_minor <= 0) {
|
||||
$download_cost = 0;
|
||||
}
|
||||
$can_download = function_exists('aksrt_user_can') && aksrt_user_can('resource_download', $uid, (int) $resource_post->ID);
|
||||
$state['download_cost'] = max(0, $download_cost);
|
||||
$state['need_login'] = !$can_download && $uid <= 0;
|
||||
$state['need_purchase'] = $allowed_member && $price > 0 && !$has_paid_order;
|
||||
$state['has_paid_access'] = $allowed_member && ($has_paid_order || $price <= 0);
|
||||
$state['need_purchase'] = $allowed_member && $price_minor > 0 && !$has_paid_order;
|
||||
$state['has_paid_access'] = $allowed_member && ($has_paid_order || $price_minor <= 0);
|
||||
$state['can_view'] = true;
|
||||
$state['can_download'] = $can_download && $allowed_member && ($has_paid_order || $price <= 0);
|
||||
$state['can_download'] = $can_download && $allowed_member && ($has_paid_order || $price_minor <= 0);
|
||||
if (!$allowed_member) {
|
||||
$state['can_download'] = false;
|
||||
$state['has_paid_access'] = false;
|
||||
@@ -144,7 +145,7 @@ class Visibility {
|
||||
|
||||
// reading/gallery/video with pay_limit
|
||||
if ($allowed_member) {
|
||||
$state['has_paid_access'] = $has_paid_order || $price <= 0;
|
||||
$state['has_paid_access'] = $has_paid_order || $price_minor <= 0;
|
||||
$state['need_purchase'] = !$state['has_paid_access'];
|
||||
$state['need_vip'] = false;
|
||||
} else {
|
||||
@@ -160,8 +161,8 @@ class Visibility {
|
||||
if ($pay_type === \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD) {
|
||||
// legacy: cash = order unlock; points = one-shot cashier order (not per-download deduct).
|
||||
$download_cost = 0;
|
||||
if ($pay_modo === 'points' && $price > 0 && !$has_paid_order) {
|
||||
$download_cost = (int) ceil($price);
|
||||
if ($pay_modo === 'points' && $price_minor > 0 && !$has_paid_order) {
|
||||
$download_cost = $price_minor;
|
||||
}
|
||||
$state['download_cost'] = max(0, $download_cost);
|
||||
|
||||
@@ -169,10 +170,10 @@ class Visibility {
|
||||
$state['need_login'] = !$can_download && $uid <= 0;
|
||||
$state['require_captcha'] = function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('download');
|
||||
|
||||
$state['need_purchase'] = !$has_paid_order && $price > 0;
|
||||
$state['has_paid_access'] = $has_paid_order || $price <= 0 || $has_free_access;
|
||||
$state['need_purchase'] = !$has_paid_order && $price_minor > 0;
|
||||
$state['has_paid_access'] = $has_paid_order || $price_minor <= 0 || $has_free_access;
|
||||
$state['can_view'] = true;
|
||||
$state['can_download'] = $can_download && ($has_paid_order || $price <= 0);
|
||||
$state['can_download'] = $can_download && ($has_paid_order || $price_minor <= 0);
|
||||
|
||||
return $state;
|
||||
}
|
||||
@@ -190,7 +191,7 @@ class Visibility {
|
||||
$state['need_purchase'] = !$has_free_access && !$has_paid_order;
|
||||
$state['has_paid_access'] = $has_free_access || $has_paid_order;
|
||||
// VIP free tier for reading etc. via amount.
|
||||
if (!$state['has_paid_access'] && $price <= 0 && $pay_type !== \AKSRT\Core\DomainModel::PAY_TYPE_VIP) {
|
||||
if (!$state['has_paid_access'] && $price_minor <= 0 && $pay_type !== \AKSRT\Core\DomainModel::PAY_TYPE_VIP) {
|
||||
$state['has_paid_access'] = true;
|
||||
$state['need_purchase'] = false;
|
||||
}
|
||||
@@ -253,11 +254,10 @@ class Visibility {
|
||||
// Cash or points: require cashier purchase (resource order) before download token.
|
||||
if (!empty($state['need_purchase']) && empty($state['has_paid_access'])) {
|
||||
$pay_modo = (string) ($state['pay_modo'] ?? '0');
|
||||
$price = (float) ($state['price'] ?? 0);
|
||||
return new \WP_Error('aksrt_need_purchase', __('请先购买后再下载。', 'aksrt'), [
|
||||
'status' => 402,
|
||||
'pay_modo' => $pay_modo,
|
||||
'amount' => $pay_modo === 'points' ? (int) ceil($price) : $price,
|
||||
'amount' => $state['price'] ?? ($pay_modo === 'points' ? 0 : '0.00'),
|
||||
'post_id' => (int) ($state['post_id'] ?? 0),
|
||||
]);
|
||||
}
|
||||
@@ -270,7 +270,7 @@ class Visibility {
|
||||
return $over;
|
||||
}
|
||||
$m = aksrt_get_pay_meta($resource_post);
|
||||
return max(0.0, (float) ($m['download_limit_over_price'] ?? 0));
|
||||
return $m['download_limit_over_price'] ?? 0;
|
||||
};
|
||||
add_filter('aksrt_download_limit_over_price', $over_cb, 10, 2);
|
||||
$quota = aksrt_check_free_download_quota($uid, $cost);
|
||||
@@ -340,6 +340,20 @@ class Visibility {
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function asset_minor($value, string $pay_modo): int {
|
||||
if ($pay_modo === 'points') {
|
||||
return is_int($value) ? max(0, $value) : 0;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function is_unlocked(string $mode, int $pid): bool {
|
||||
switch ($mode) {
|
||||
case 'comment':
|
||||
|
||||
@@ -14,7 +14,7 @@ class DBMigrator {
|
||||
use DBMigratorVip;
|
||||
|
||||
/** Current schema version; changing it re-runs dbDelta. */
|
||||
const SCHEMA_VERSION = '1.24.0';
|
||||
const SCHEMA_VERSION = '2.0.0';
|
||||
|
||||
const OPT_KEY = 'aksrt_db_schema_version';
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ trait DBMigratorIdentity {
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
application_uuid CHAR(36) NOT NULL,
|
||||
client_request_id CHAR(36) NOT NULL,
|
||||
request_payload_hash CHAR(64) NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
channel VARCHAR(20) NOT NULL DEFAULT 'cloud',
|
||||
@@ -59,7 +60,7 @@ trait DBMigratorIdentity {
|
||||
KEY idx_status_updated (status, updated_at),
|
||||
KEY idx_id_number_hash (id_number_hash),
|
||||
KEY idx_company_code_hash (company_code_hash)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($application);
|
||||
|
||||
$event = "CREATE TABLE {$prefix}aksrt_identity_event (
|
||||
@@ -81,7 +82,7 @@ trait DBMigratorIdentity {
|
||||
UNIQUE KEY uniq_dedupe_key (dedupe_key),
|
||||
KEY idx_application_created (application_id, created_at),
|
||||
KEY idx_source_external (source, external_event_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,43 +20,55 @@ trait DBMigratorPart2 {
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$prefix = $wpdb->prefix;
|
||||
|
||||
// ---------- 1. 积分流水 ----------
|
||||
$sql_credit_log = "CREATE TABLE {$prefix}aksrt_credit_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
// ---------- 1. 统一资产账户与不可变账本 ----------
|
||||
$sql_asset_account = "CREATE TABLE {$prefix}aksrt_asset_account (
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
balance_after INT NOT NULL,
|
||||
reason VARCHAR(64) NOT NULL,
|
||||
remark VARCHAR(255) DEFAULT NULL,
|
||||
ref_type VARCHAR(32) DEFAULT NULL,
|
||||
ref_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
ip VARCHAR(64) DEFAULT NULL,
|
||||
ua VARCHAR(255) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user_time (user_id, created_at),
|
||||
KEY idx_reason (reason)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_credit_log);
|
||||
asset VARCHAR(16) NOT NULL,
|
||||
balance_minor BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
version BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (user_id, asset),
|
||||
KEY idx_asset_balance (asset, balance_minor)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_asset_account);
|
||||
|
||||
// ---------- 1b. 现金余额流水(站内钱包,与积分分离) ----------
|
||||
$sql_balance_log = "CREATE TABLE {$prefix}aksrt_balance_log (
|
||||
$sql_asset_transaction = "CREATE TABLE {$prefix}aksrt_asset_transaction (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
delta DECIMAL(18,2) NOT NULL,
|
||||
balance_after DECIMAL(18,2) NOT NULL,
|
||||
operation_key VARCHAR(191) NOT NULL,
|
||||
payload_hash CHAR(64) NOT NULL,
|
||||
reason VARCHAR(64) NOT NULL,
|
||||
remark VARCHAR(255) DEFAULT NULL,
|
||||
ref_type VARCHAR(32) DEFAULT NULL,
|
||||
ref_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
ref_key VARCHAR(191) DEFAULT NULL,
|
||||
actor_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
metadata LONGTEXT DEFAULT NULL,
|
||||
ip VARCHAR(64) DEFAULT NULL,
|
||||
ua VARCHAR(255) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user_time (user_id, created_at),
|
||||
KEY idx_reason (reason)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_balance_log);
|
||||
UNIQUE KEY uniq_operation_key (operation_key),
|
||||
KEY idx_reason_time (reason, created_at),
|
||||
KEY idx_reference (ref_type, ref_key)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_asset_transaction);
|
||||
|
||||
$sql_asset_entry = "CREATE TABLE {$prefix}aksrt_asset_entry (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
transaction_id BIGINT UNSIGNED NOT NULL,
|
||||
line_no SMALLINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
asset VARCHAR(16) NOT NULL,
|
||||
entry_type VARCHAR(32) NOT NULL,
|
||||
delta_minor BIGINT NOT NULL,
|
||||
balance_after_minor BIGINT UNSIGNED NOT NULL,
|
||||
counterparty_user_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
remark VARCHAR(255) DEFAULT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_transaction_line (transaction_id, line_no),
|
||||
KEY idx_user_asset_time (user_id, asset, created_at, id),
|
||||
KEY idx_transaction (transaction_id)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_asset_entry);
|
||||
|
||||
// ---------- 2. 签到 ----------
|
||||
$sql_signin = "CREATE TABLE {$prefix}aksrt_signin_log (
|
||||
@@ -68,7 +80,7 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_user_date (user_id, signin_date),
|
||||
KEY idx_user (user_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_signin);
|
||||
|
||||
// ---------- 3. 下载 token ----------
|
||||
@@ -88,7 +100,7 @@ trait DBMigratorPart2 {
|
||||
KEY idx_expires (expires_at),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_post (post_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_dl_token);
|
||||
|
||||
// ---------- 5. 用户通知收件箱 ----------
|
||||
@@ -105,13 +117,14 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user_created (user_id, created_at),
|
||||
KEY idx_user_read (user_id, is_read)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_notification);
|
||||
|
||||
// ---------- 8. 订单表 ----------
|
||||
$sql_order = "CREATE TABLE {$prefix}aksrt_order (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_number VARCHAR(32) NOT NULL,
|
||||
request_key VARCHAR(191) DEFAULT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
product_type VARCHAR(32) NOT NULL DEFAULT '',
|
||||
product_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
@@ -131,12 +144,13 @@ trait DBMigratorPart2 {
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_order_number (order_number),
|
||||
UNIQUE KEY uniq_request_key (request_key),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_user_status_created (user_id, status, created_at),
|
||||
KEY idx_status (status),
|
||||
KEY idx_created (created_at),
|
||||
KEY idx_product (product_type, product_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_order);
|
||||
|
||||
// ---------- 9. 作者收益台账 ----------
|
||||
@@ -168,7 +182,7 @@ trait DBMigratorPart2 {
|
||||
KEY idx_currency (currency),
|
||||
KEY idx_status (status),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_revenue);
|
||||
|
||||
// ---------- 10. 提现台账 ----------
|
||||
@@ -180,15 +194,17 @@ trait DBMigratorPart2 {
|
||||
method VARCHAR(32) NOT NULL DEFAULT '',
|
||||
account_info TEXT DEFAULT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
ledger_transaction_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
processed_at DATETIME DEFAULT NULL,
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_ledger_transaction (ledger_transaction_id),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_user_created (user_id, created_at),
|
||||
KEY idx_status (status),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_withdrawal);
|
||||
|
||||
// ---------- 11. 举报工单 ----------
|
||||
@@ -208,7 +224,7 @@ trait DBMigratorPart2 {
|
||||
KEY idx_target (target_type, target_id),
|
||||
KEY idx_reporter (reporter_id),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_report);
|
||||
|
||||
// ---------- 12. 用户限制/封禁 ----------
|
||||
@@ -227,7 +243,7 @@ trait DBMigratorPart2 {
|
||||
KEY idx_scope (scope),
|
||||
KEY idx_expires (expires_at),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_restriction);
|
||||
|
||||
// ── follow ──
|
||||
@@ -240,7 +256,7 @@ trait DBMigratorPart2 {
|
||||
UNIQUE KEY idx_pair (follower_id, following_id),
|
||||
KEY idx_following (following_id),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_follow);
|
||||
|
||||
// ── coupon ──
|
||||
@@ -262,7 +278,7 @@ trait DBMigratorPart2 {
|
||||
UNIQUE KEY idx_code (code),
|
||||
KEY idx_status (status),
|
||||
KEY idx_expires (expires_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_coupon);
|
||||
|
||||
// ── coupon_usage (R68) ──
|
||||
@@ -276,11 +292,11 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_coupon (coupon_id),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_order (order_number)
|
||||
) {$charset_collate};";
|
||||
UNIQUE KEY uniq_coupon_usage_order (order_number)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_coupon_usage);
|
||||
|
||||
// ── charge_card (legacy-style typed card_pass) ──
|
||||
// ── typed charge cards ──
|
||||
$sql_charge_card = "CREATE TABLE {$prefix}aksrt_charge_card (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
card VARCHAR(60) NOT NULL DEFAULT '',
|
||||
@@ -304,31 +320,9 @@ trait DBMigratorPart2 {
|
||||
KEY idx_used_by (used_by),
|
||||
KEY idx_order_number (order_number),
|
||||
KEY idx_expires (expires_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_charge_card);
|
||||
|
||||
// ── rebate_log ──
|
||||
$sql_rebate_log = "CREATE TABLE {$prefix}aksrt_rebate_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
order_number VARCHAR(32) NOT NULL DEFAULT '',
|
||||
source_user_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
type VARCHAR(32) NOT NULL DEFAULT 'order',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
settled_at DATETIME DEFAULT NULL,
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_order (order_number),
|
||||
KEY idx_source (source_user_id),
|
||||
KEY idx_status (status),
|
||||
KEY idx_type (type),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_rebate_log);
|
||||
|
||||
// ── canonical Ref attribution and permanent user binding ──
|
||||
$sql_affiliate_relation = "CREATE TABLE {$prefix}aksrt_affiliate_relation (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -349,7 +343,7 @@ trait DBMigratorPart2 {
|
||||
KEY idx_status_touch (status, first_touch_at),
|
||||
KEY idx_reward_status (reward_status),
|
||||
KEY idx_reward_ip (registration_ip, rewarded_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_affiliate_relation);
|
||||
|
||||
// ── immutable commission ledger ──
|
||||
@@ -365,17 +359,20 @@ trait DBMigratorPart2 {
|
||||
adjustment_total DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
cancel_operation_key VARCHAR(191) DEFAULT NULL,
|
||||
cancel_payload_hash CHAR(64) DEFAULT NULL,
|
||||
available_at DATETIME NOT NULL,
|
||||
settled_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_commission_idempotency (idempotency_key),
|
||||
UNIQUE KEY uniq_commission_cancel_operation (cancel_operation_key),
|
||||
KEY idx_promoter_status (promoter_id, status),
|
||||
KEY idx_order_type (order_number, order_type),
|
||||
KEY idx_available (status, available_at),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_commission_ledger);
|
||||
|
||||
// ── immutable commission adjustments (refund/debt/manual) ──
|
||||
@@ -393,7 +390,7 @@ trait DBMigratorPart2 {
|
||||
UNIQUE KEY uniq_adjustment_source (source_key),
|
||||
KEY idx_ledger (ledger_id),
|
||||
KEY idx_promoter (promoter_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_commission_adjustment);
|
||||
|
||||
// ── payout account snapshots ──
|
||||
@@ -409,7 +406,7 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_user_channel (user_id, channel),
|
||||
KEY idx_user_status (user_id, status)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_payout_account);
|
||||
|
||||
// ── order_items (§6.5 多商品多商家) ──
|
||||
@@ -434,7 +431,7 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_order (order_number),
|
||||
KEY idx_product (product_id)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_order_items);
|
||||
|
||||
self::migrate_identity_tables($prefix, $charset_collate);
|
||||
|
||||
@@ -16,8 +16,9 @@ trait DBMigratorPart3 {
|
||||
public static function table(string $name): string {
|
||||
global $wpdb;
|
||||
$allowed = [
|
||||
'credit_log' => 'aksrt_credit_log',
|
||||
'balance_log' => 'aksrt_balance_log',
|
||||
'asset_account' => 'aksrt_asset_account',
|
||||
'asset_transaction' => 'aksrt_asset_transaction',
|
||||
'asset_entry' => 'aksrt_asset_entry',
|
||||
'signin_log' => 'aksrt_signin_log',
|
||||
'dl_token' => 'aksrt_dl_token',
|
||||
'notification' => 'aksrt_notification',
|
||||
@@ -29,7 +30,6 @@ trait DBMigratorPart3 {
|
||||
'follow' => 'aksrt_follow',
|
||||
'coupon' => 'aksrt_coupon',
|
||||
'charge_card' => 'aksrt_charge_card',
|
||||
'rebate_log' => 'aksrt_rebate_log',
|
||||
'affiliate_relation' => 'aksrt_affiliate_relation',
|
||||
'commission_ledger' => 'aksrt_commission_ledger',
|
||||
'commission_adjustment' => 'aksrt_commission_adjustment',
|
||||
|
||||
@@ -36,7 +36,7 @@ trait DBMigratorVip {
|
||||
KEY idx_batch (batch_uuid),
|
||||
KEY idx_status_created (status, created_at),
|
||||
KEY idx_operation_created (operation, created_at)
|
||||
) {$charset_collate};";
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($event);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-16
@@ -22,7 +22,6 @@ class DomainModel {
|
||||
public const RESOURCE_SALES_VOLUME_KEY = '_aksrt_sales_volume';
|
||||
public const POST_VIEW_COUNT_KEY = 'aksrt_post_views';
|
||||
|
||||
public const ORDER_MIGRATED_OPTION = 'aksrt_order_migrated_to_table';
|
||||
public const RESOURCE_PAY_META_MIGRATED = 'aksrt_resource_pay_meta_migrated';
|
||||
|
||||
public const ORDER_PRODUCT_RESOURCE = 'resource';
|
||||
@@ -116,11 +115,11 @@ class DomainModel {
|
||||
return [
|
||||
'pay_type' => self::PAY_TYPE_NO,
|
||||
'pay_modo' => '0', // '0' cash | 'points' credits (legacy)
|
||||
'price' => 0.0,
|
||||
'price' => '0.00',
|
||||
'points_price' => 0,
|
||||
'original_price' => 0.0,
|
||||
'vip1_price' => 0.0,
|
||||
'vip2_price' => 0.0,
|
||||
'original_price' => '0.00',
|
||||
'vip1_price' => '0.00',
|
||||
'vip2_price' => '0.00',
|
||||
'vip1_points' => 0,
|
||||
'vip2_points' => 0,
|
||||
'pay_limit' => 0, // 0 all | 1 VIP1+ | 2 VIP2 only (legacy)
|
||||
@@ -143,11 +142,11 @@ class DomainModel {
|
||||
'free_preview' => '',
|
||||
'show_sales' => true,
|
||||
// legacy: free daily quota overbuy price (cash or points by pay_modo).
|
||||
'download_limit_over_price' => 0.0,
|
||||
'download_limit_over_price' => '0.00',
|
||||
// Content cashier: coupon + buyer-facing 推广折扣 (legacy posts_aksrtpay).
|
||||
'coupon_s' => false,
|
||||
'coupon_desc' => '',
|
||||
'pay_rebate_discount' => 0.0,
|
||||
'pay_rebate_discount' => '0.00',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -319,13 +318,13 @@ class DomainModel {
|
||||
$meta['pay_type'] = self::normalize_pay_type((string) ($raw['pay_type'] ?? $meta['pay_type']));
|
||||
$pay_modo = sanitize_key((string) ($raw['pay_modo'] ?? '0'));
|
||||
$meta['pay_modo'] = $pay_modo === 'points' ? 'points' : '0';
|
||||
$meta['price'] = max(0, (float) ($raw['price'] ?? 0));
|
||||
$meta['points_price'] = max(0, (int) ($raw['points_price'] ?? $raw['price'] ?? 0));
|
||||
$meta['original_price'] = max(0, (float) ($raw['original_price'] ?? 0));
|
||||
$meta['vip1_price'] = max(0, (float) ($raw['vip1_price'] ?? 0));
|
||||
$meta['vip2_price'] = max(0, (float) ($raw['vip2_price'] ?? 0));
|
||||
$meta['vip1_points'] = max(0, (int) ($raw['vip1_points'] ?? $raw['vip1_price'] ?? 0));
|
||||
$meta['vip2_points'] = max(0, (int) ($raw['vip2_points'] ?? $raw['vip2_price'] ?? 0));
|
||||
$meta['price'] = self::sanitize_balance_decimal($raw['price'] ?? '0');
|
||||
$meta['points_price'] = self::sanitize_points($raw['points_price'] ?? 0);
|
||||
$meta['original_price'] = self::sanitize_balance_decimal($raw['original_price'] ?? '0');
|
||||
$meta['vip1_price'] = self::sanitize_balance_decimal($raw['vip1_price'] ?? '0');
|
||||
$meta['vip2_price'] = self::sanitize_balance_decimal($raw['vip2_price'] ?? '0');
|
||||
$meta['vip1_points'] = self::sanitize_points($raw['vip1_points'] ?? 0);
|
||||
$meta['vip2_points'] = self::sanitize_points($raw['vip2_points'] ?? 0);
|
||||
$pay_limit = (int) ($raw['pay_limit'] ?? 0);
|
||||
$meta['pay_limit'] = in_array($pay_limit, [0, 1, 2], true) ? $pay_limit : 0;
|
||||
$meta['version'] = sanitize_text_field((string) ($raw['version'] ?? ''));
|
||||
@@ -347,14 +346,43 @@ class DomainModel {
|
||||
$meta['purchase_limit'] = max(0, (int) ($raw['purchase_limit'] ?? 0));
|
||||
$meta['free_preview'] = wp_kses_post((string) ($raw['free_preview'] ?? ''));
|
||||
$meta['show_sales'] = !isset($raw['show_sales']) || !empty($raw['show_sales']);
|
||||
$meta['download_limit_over_price'] = max(0, (float) ($raw['download_limit_over_price'] ?? 0));
|
||||
$meta['download_limit_over_price'] = $meta['pay_modo'] === 'points'
|
||||
? self::sanitize_points($raw['download_limit_over_price'] ?? 0)
|
||||
: self::sanitize_balance_decimal($raw['download_limit_over_price'] ?? '0');
|
||||
$meta['coupon_s'] = !empty($raw['coupon_s']);
|
||||
$meta['coupon_desc'] = sanitize_textarea_field((string) ($raw['coupon_desc'] ?? ''));
|
||||
$meta['pay_rebate_discount'] = max(0, (float) ($raw['pay_rebate_discount'] ?? 0));
|
||||
$meta['pay_rebate_discount'] = self::sanitize_balance_decimal($raw['pay_rebate_discount'] ?? '0');
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
private static function sanitize_balance_decimal($value): string {
|
||||
if (!is_string($value)) {
|
||||
return '0.00';
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
private static function sanitize_points($value): int {
|
||||
if (is_int($value)) {
|
||||
return max(0, $value);
|
||||
}
|
||||
if (!is_string($value) || !preg_match('/^(0|[1-9]\d*)$/', trim($value))) {
|
||||
return 0;
|
||||
}
|
||||
$value = trim($value);
|
||||
if (strlen($value) > strlen((string) PHP_INT_MAX)
|
||||
|| (strlen($value) === strlen((string) PHP_INT_MAX) && strcmp($value, (string) PHP_INT_MAX) > 0)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
+78
-33
@@ -67,16 +67,16 @@ trait FrontSubmitPay {
|
||||
$meta['pay_type'] = $pay_type;
|
||||
$meta['pay_modo'] = $pay_modo;
|
||||
|
||||
$min = (float) ($settings['pay_price_min'] ?? 0);
|
||||
$max = (float) ($settings['pay_price_max'] ?? 0);
|
||||
$allow_free = !empty($settings['pay_free_s']);
|
||||
$vip_manual = !empty($settings['pay_vip_price_s']);
|
||||
$vip1_disc = (int) ($settings['pay_vip_1_discount'] ?? 100);
|
||||
$vip2_disc = (int) ($settings['pay_vip_2_discount'] ?? 100);
|
||||
|
||||
if ($pay_modo === 'points') {
|
||||
$points = isset($raw['points_price']) ? (int) $raw['points_price'] : (int) ($data['points_price'] ?? 0);
|
||||
if ($points < 0) {
|
||||
$points = $this->parse_front_points($raw['points_price'] ?? $data['points_price'] ?? null);
|
||||
$min = $this->parse_front_points_limit($settings['pay_price_min'] ?? '0.00');
|
||||
$max = $this->parse_front_points_limit($settings['pay_price_max'] ?? '0.00');
|
||||
if ($points === null) {
|
||||
return ['success' => false, 'message' => __('请设置正确的积分价格', 'aksrt')];
|
||||
}
|
||||
if (!$allow_free && $points <= 0) {
|
||||
@@ -90,59 +90,64 @@ trait FrontSubmitPay {
|
||||
}
|
||||
|
||||
if ($vip_manual) {
|
||||
$vip1 = isset($raw['vip1_points']) ? (int) $raw['vip1_points'] : (int) ($raw['vip_1_points'] ?? 0);
|
||||
$vip2 = isset($raw['vip2_points']) ? (int) $raw['vip2_points'] : (int) ($raw['vip_2_points'] ?? 0);
|
||||
$vip1 = $this->parse_front_points($raw['vip1_points'] ?? $raw['vip_1_points'] ?? null);
|
||||
$vip2 = $this->parse_front_points($raw['vip2_points'] ?? $raw['vip_2_points'] ?? null);
|
||||
if ($vip1 === null || $vip2 === null) {
|
||||
return ['success' => false, 'message' => __('请设置正确的会员积分价格', 'aksrt')];
|
||||
}
|
||||
} else {
|
||||
$vip1 = (int) floor($points * $vip1_disc / 100);
|
||||
$vip2 = (int) floor($points * $vip2_disc / 100);
|
||||
$vip1 = (intdiv($points, 100) * $vip1_disc) + intdiv(($points % 100) * $vip1_disc, 100);
|
||||
$vip2 = (intdiv($points, 100) * $vip2_disc) + intdiv(($points % 100) * $vip2_disc, 100);
|
||||
}
|
||||
$vip1 = max(0, min($points, $vip1));
|
||||
$vip2 = max(0, min($vip1, $vip2));
|
||||
|
||||
$meta['points_price'] = $points;
|
||||
$meta['price'] = 0.0;
|
||||
$meta['price'] = '0.00';
|
||||
$meta['vip1_points'] = $vip1;
|
||||
$meta['vip2_points'] = $vip2;
|
||||
$meta['vip1_price'] = 0.0;
|
||||
$meta['vip2_price'] = 0.0;
|
||||
$meta['vip1_price'] = '0.00';
|
||||
$meta['vip2_price'] = '0.00';
|
||||
} else {
|
||||
$price = isset($raw['price']) ? (float) $raw['price'] : (float) ($raw['pay_price'] ?? $data['pay_price'] ?? 0);
|
||||
$price = round($price, 2);
|
||||
if ($price < 0) {
|
||||
$price_minor = $this->parse_front_cash_minor($raw['price'] ?? $raw['pay_price'] ?? $data['pay_price'] ?? null);
|
||||
$min_minor = $this->parse_front_cash_minor($settings['pay_price_min'] ?? '0') ?? 0;
|
||||
$max_minor = $this->parse_front_cash_minor($settings['pay_price_max'] ?? '0') ?? 0;
|
||||
if ($price_minor === null) {
|
||||
return ['success' => false, 'message' => __('请设置正确的价格', 'aksrt')];
|
||||
}
|
||||
if (!$allow_free && $price <= 0) {
|
||||
if (!$allow_free && $price_minor <= 0) {
|
||||
return ['success' => false, 'message' => __('请设置付费内容的价格', 'aksrt')];
|
||||
}
|
||||
if ($min > 0 && $price < $min) {
|
||||
return ['success' => false, 'message' => sprintf(__('价格不能低于:%s', 'aksrt'), (string) $min)];
|
||||
if ($min_minor > 0 && $price_minor < $min_minor) {
|
||||
return ['success' => false, 'message' => sprintf(__('价格不能低于:%s', 'aksrt'), \AKSRT\Ledger\AssetAmount::decimal_from_minor($min_minor))];
|
||||
}
|
||||
if ($max > 0 && $price > $max) {
|
||||
return ['success' => false, 'message' => sprintf(__('价格不能高于:%s', 'aksrt'), (string) $max)];
|
||||
if ($max_minor > 0 && $price_minor > $max_minor) {
|
||||
return ['success' => false, 'message' => sprintf(__('价格不能高于:%s', 'aksrt'), \AKSRT\Ledger\AssetAmount::decimal_from_minor($max_minor))];
|
||||
}
|
||||
|
||||
if ($vip_manual) {
|
||||
$vip1 = isset($raw['vip1_price']) ? (float) $raw['vip1_price'] : (float) ($raw['vip_1_price'] ?? 0);
|
||||
$vip2 = isset($raw['vip2_price']) ? (float) $raw['vip2_price'] : (float) ($raw['vip_2_price'] ?? 0);
|
||||
$vip1 = round($vip1, 2);
|
||||
$vip2 = round($vip2, 2);
|
||||
if ($vip1 > $price) {
|
||||
$vip1_minor = $this->parse_front_cash_minor($raw['vip1_price'] ?? $raw['vip_1_price'] ?? null);
|
||||
$vip2_minor = $this->parse_front_cash_minor($raw['vip2_price'] ?? $raw['vip_2_price'] ?? null);
|
||||
if ($vip1_minor === null || $vip2_minor === null) {
|
||||
return ['success' => false, 'message' => __('请设置正确的会员价格', 'aksrt')];
|
||||
}
|
||||
if ($vip1_minor > $price_minor) {
|
||||
return ['success' => false, 'message' => __('会员价不能高于普通价', 'aksrt')];
|
||||
}
|
||||
if ($vip2 > $vip1) {
|
||||
if ($vip2_minor > $vip1_minor) {
|
||||
return ['success' => false, 'message' => __('更高等级会员价不能高于较低等级会员价', 'aksrt')];
|
||||
}
|
||||
} else {
|
||||
$vip1 = round($price * $vip1_disc / 100, 2);
|
||||
$vip2 = round($price * $vip2_disc / 100, 2);
|
||||
$vip1_minor = \AKSRT\Ledger\AssetAmount::percentage($price_minor, max(0, min(100, $vip1_disc)));
|
||||
$vip2_minor = \AKSRT\Ledger\AssetAmount::percentage($price_minor, max(0, min(100, $vip2_disc)));
|
||||
}
|
||||
$vip1 = max(0, min($price, $vip1));
|
||||
$vip2 = max(0, min($vip1, $vip2));
|
||||
$vip1_minor = max(0, min($price_minor, $vip1_minor));
|
||||
$vip2_minor = max(0, min($vip1_minor, $vip2_minor));
|
||||
|
||||
$meta['price'] = $price;
|
||||
$meta['price'] = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$meta['points_price'] = 0;
|
||||
$meta['vip1_price'] = $vip1;
|
||||
$meta['vip2_price'] = $vip2;
|
||||
$meta['vip1_price'] = \AKSRT\Ledger\AssetAmount::decimal_from_minor($vip1_minor);
|
||||
$meta['vip2_price'] = \AKSRT\Ledger\AssetAmount::decimal_from_minor($vip2_minor);
|
||||
$meta['vip1_points'] = 0;
|
||||
$meta['vip2_points'] = 0;
|
||||
}
|
||||
@@ -159,6 +164,40 @@ trait FrontSubmitPay {
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
private function parse_front_points($value): ?int {
|
||||
if (is_int($value)) {
|
||||
return $value >= 0 ? $value : null;
|
||||
}
|
||||
if (!is_string($value) || !preg_match('/^(0|[1-9]\d*)$/', trim($value))) {
|
||||
return null;
|
||||
}
|
||||
$value = trim($value);
|
||||
if (strlen($value) > strlen((string) PHP_INT_MAX)
|
||||
|| (strlen($value) === strlen((string) PHP_INT_MAX) && strcmp($value, (string) PHP_INT_MAX) > 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private function parse_front_cash_minor($value): ?int {
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function parse_front_points_limit($value): int {
|
||||
if (!is_string($value) || !preg_match('/^(0|[1-9]\d*)(?:\.00)?$/', trim($value), $matches)) {
|
||||
return 0;
|
||||
}
|
||||
return $this->parse_front_points((string) $matches[1]) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array<int, array<string, string>>
|
||||
@@ -210,7 +249,13 @@ trait FrontSubmitPay {
|
||||
$meta = DomainModel::sanitize_pay_meta($meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_META_KEY, $meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_TYPE_KEY, (string) ($meta['pay_type'] ?? DomainModel::PAY_TYPE_NO));
|
||||
update_post_meta($post_id, DomainModel::PAY_PRICE_KEY, (float) ($meta['price'] ?? 0));
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
DomainModel::PAY_PRICE_KEY,
|
||||
($meta['pay_modo'] ?? '0') === 'points'
|
||||
? (int) ($meta['points_price'] ?? 0)
|
||||
: (string) ($meta['price'] ?? '0.00')
|
||||
);
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
DomainModel::RESOURCE_MARKER_KEY,
|
||||
|
||||
+23
-10
@@ -473,13 +473,13 @@ class PostMetaBox {
|
||||
$type_label = $labels[$pay_type] ?? $pay_type;
|
||||
$modo = (($pay['pay_modo'] ?? '0') === 'points') ? 'points' : '0';
|
||||
if ($modo === 'points') {
|
||||
$price_text = sprintf(__('%d 积分', 'aksrt'), (int) ceil((float) ($pay['points_price'] ?? $pay['price'] ?? 0)));
|
||||
$price_text = sprintf(__('%d 积分', 'aksrt'), max(0, (int) ($pay['points_price'] ?? 0)));
|
||||
} else {
|
||||
$price_text = '¥' . number_format((float) ($pay['price'] ?? 0), 2);
|
||||
$price_text = '¥' . (string) ($pay['price'] ?? '0.00');
|
||||
}
|
||||
$meta_sales = (int) get_post_meta($post_id, DomainModel::RESOURCE_SALES_VOLUME_KEY, true);
|
||||
$db_sales = 0;
|
||||
$revenue = 0.0;
|
||||
$revenue = '0.00';
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$map = \AKSRT\Payment\OrderRepository::get_instance()->get_content_sales_map(
|
||||
[$post_id],
|
||||
@@ -487,7 +487,7 @@ class PostMetaBox {
|
||||
);
|
||||
if (isset($map[$post_id])) {
|
||||
$db_sales = (int) ($map[$post_id]['sales_count'] ?? 0);
|
||||
$revenue = (float) ($map[$post_id]['revenue'] ?? 0);
|
||||
$revenue = (string) ($map[$post_id]['revenue'] ?? '0.00');
|
||||
}
|
||||
}
|
||||
$orders_url = admin_url('admin.php?page=aksrt_orders&product_type=resource&product_id=' . $post_id);
|
||||
@@ -502,7 +502,7 @@ class PostMetaBox {
|
||||
'<br><a href="%s" title="%s">%s</a>',
|
||||
esc_url($orders_url),
|
||||
esc_attr__('查看销售明细', 'aksrt'),
|
||||
esc_html(sprintf(__('真实销量 %d · ¥%s', 'aksrt'), $db_sales, number_format($revenue, 2)))
|
||||
esc_html(sprintf(__('真实销量 %d · ¥%s', 'aksrt'), $db_sales, $revenue))
|
||||
);
|
||||
} else {
|
||||
echo '<br><span class="description">' . esc_html__('暂无订单', 'aksrt') . '</span>';
|
||||
@@ -682,11 +682,18 @@ class PostMetaBox {
|
||||
$modo = sanitize_key($modo_raw);
|
||||
$meta['pay_modo'] = ($modo === 'points') ? 'points' : '0';
|
||||
}
|
||||
if ($price_raw !== '' && is_numeric($price_raw)) {
|
||||
$meta['price'] = max(0, (float) $price_raw);
|
||||
if ($price_raw !== '') {
|
||||
try {
|
||||
$meta['price'] = \AKSRT\Ledger\AssetAmount::balance($price_raw)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ($points_raw !== '' && is_numeric($points_raw)) {
|
||||
$meta['points_price'] = max(0, (int) $points_raw);
|
||||
if ($points_raw !== '') {
|
||||
if (!preg_match('/^(0|[1-9]\d*)$/', $points_raw)) {
|
||||
return;
|
||||
}
|
||||
$meta['points_price'] = (int) $points_raw;
|
||||
}
|
||||
if ($virtual_raw !== '' && is_numeric($virtual_raw)) {
|
||||
$meta['virtual_sales'] = max(0, (int) $virtual_raw);
|
||||
@@ -695,7 +702,13 @@ class PostMetaBox {
|
||||
$meta = DomainModel::sanitize_pay_meta($meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_META_KEY, $meta);
|
||||
update_post_meta($post_id, DomainModel::PAY_TYPE_KEY, (string) ($meta['pay_type'] ?? DomainModel::PAY_TYPE_NO));
|
||||
update_post_meta($post_id, DomainModel::PAY_PRICE_KEY, (float) ($meta['price'] ?? 0));
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
DomainModel::PAY_PRICE_KEY,
|
||||
($meta['pay_modo'] ?? '0') === 'points'
|
||||
? (int) ($meta['points_price'] ?? 0)
|
||||
: (string) ($meta['price'] ?? '0.00')
|
||||
);
|
||||
if (class_exists('AKSRT\\Core\\DomainModel') && method_exists('AKSRT\\Core\\DomainModel', 'is_resource_pay_type')) {
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
|
||||
@@ -456,18 +456,11 @@ class ResourceMetaBox {
|
||||
if ($pay_modo !== 'points') {
|
||||
$pay_modo = '0';
|
||||
}
|
||||
$price = max(0, (float) ($_POST['aksrt_pay_price'] ?? 0));
|
||||
$price = trim((string) wp_unslash($_POST['aksrt_pay_price'] ?? '0'));
|
||||
$points_price = max(0, (int) ($_POST['aksrt_pay_points_price'] ?? 0));
|
||||
// Keep legacy single-price editors usable: if points field empty and modo points, mirror price.
|
||||
if ($pay_modo === 'points' && $points_price <= 0 && $price > 0) {
|
||||
$points_price = (int) ceil($price);
|
||||
}
|
||||
if ($pay_modo !== 'points' && $price <= 0 && $points_price > 0) {
|
||||
$price = (float) $points_price;
|
||||
}
|
||||
$original_price = max(0, (float) ($_POST['aksrt_pay_original_price'] ?? 0));
|
||||
$vip1_price = max(0, (float) ($_POST['aksrt_pay_vip1_price'] ?? 0));
|
||||
$vip2_price = max(0, (float) ($_POST['aksrt_pay_vip2_price'] ?? 0));
|
||||
$original_price = trim((string) wp_unslash($_POST['aksrt_pay_original_price'] ?? '0'));
|
||||
$vip1_price = trim((string) wp_unslash($_POST['aksrt_pay_vip1_price'] ?? '0'));
|
||||
$vip2_price = trim((string) wp_unslash($_POST['aksrt_pay_vip2_price'] ?? '0'));
|
||||
$vip1_points = max(0, (int) ($_POST['aksrt_pay_vip1_points'] ?? 0));
|
||||
$vip2_points = max(0, (int) ($_POST['aksrt_pay_vip2_points'] ?? 0));
|
||||
$pay_limit = (int) ($_POST['aksrt_pay_limit'] ?? 0);
|
||||
@@ -527,10 +520,10 @@ class ResourceMetaBox {
|
||||
'card_keys' => isset($_POST['aksrt_pay_card_keys']) ? array_filter(array_map('trim', explode("\n", (string) $_POST['aksrt_pay_card_keys']))) : [],
|
||||
'free_preview' => $_POST['aksrt_pay_free_preview'] ?? '',
|
||||
'show_sales' => !empty($_POST['aksrt_pay_show_sales']),
|
||||
'download_limit_over_price' => max(0, (float) ($_POST['aksrt_pay_limit_over_price'] ?? 0)),
|
||||
'download_limit_over_price' => trim((string) wp_unslash($_POST['aksrt_pay_limit_over_price'] ?? '0')),
|
||||
'coupon_s' => !empty($_POST['aksrt_pay_coupon_s']),
|
||||
'coupon_desc' => (string) ($_POST['aksrt_pay_coupon_desc'] ?? ''),
|
||||
'pay_rebate_discount' => max(0, (float) ($_POST['aksrt_pay_rebate_discount'] ?? 0)),
|
||||
'pay_rebate_discount' => trim((string) wp_unslash($_POST['aksrt_pay_rebate_discount'] ?? '0')),
|
||||
]);
|
||||
|
||||
update_post_meta($post_id, DomainModel::PAY_META_KEY, $pay_meta);
|
||||
@@ -538,8 +531,8 @@ class ResourceMetaBox {
|
||||
// 同步标量字段用于查询(模仿 legacy 的 aksrtpay_type 同步)
|
||||
update_post_meta($post_id, DomainModel::PAY_TYPE_KEY, (string) $pay_meta['pay_type']);
|
||||
$query_price = ($pay_meta['pay_modo'] === 'points')
|
||||
? (float) $pay_meta['points_price']
|
||||
: (float) $pay_meta['price'];
|
||||
? (int) $pay_meta['points_price']
|
||||
: (string) $pay_meta['price'];
|
||||
update_post_meta($post_id, DomainModel::PAY_PRICE_KEY, $query_price);
|
||||
update_post_meta($post_id, '_aksrt_pay_modo', (string) $pay_meta['pay_modo']);
|
||||
update_post_meta($post_id, '_aksrt_pay_limit', (int) $pay_meta['pay_limit']);
|
||||
|
||||
@@ -143,8 +143,7 @@ trait ScriptsPart1b {
|
||||
&& class_exists('\AKSRT\Settings\Settings')
|
||||
&& \AKSRT\Settings\Settings::credits_enabled();
|
||||
$balance_on = is_user_logged_in()
|
||||
&& class_exists('\AKSRT\Credits\Balance')
|
||||
&& \AKSRT\Credits\Balance::enabled();
|
||||
&& !empty(\AKSRT\Settings\Settings::get_payment_recharge_settings()['enabled']);
|
||||
$affiliate_on = is_user_logged_in()
|
||||
&& class_exists('\\AKSRT\\Settings\\Settings')
|
||||
&& !empty(\AKSRT\Settings\Settings::get_affiliate_settings()['enabled']);
|
||||
@@ -656,7 +655,7 @@ trait ScriptsPart1b {
|
||||
wp_enqueue_script(
|
||||
'aksrt-cms-download',
|
||||
$base_uri . '/assets/js/cms-download.js',
|
||||
['loader_js', 'aksrt-runtime'],
|
||||
['loader_js', 'aksrt-runtime', 'aksrt-core-request'],
|
||||
file_exists($cms_download_js_path) ? (string) filemtime($cms_download_js_path) : $version,
|
||||
true
|
||||
);
|
||||
@@ -709,7 +708,7 @@ trait ScriptsPart1b {
|
||||
wp_enqueue_script(
|
||||
'aksrt-credits',
|
||||
$base_uri . '/assets/js/credits.js',
|
||||
['loader_js', 'aksrt-lazy-image-attr', 'aksrt-core-modal-runtime', 'aksrt-vip-coordinator', 'aksrt-notyf'],
|
||||
['loader_js', 'aksrt-lazy-image-attr', 'aksrt-core-request', 'aksrt-core-modal-runtime', 'aksrt-vip-coordinator', 'aksrt-notyf'],
|
||||
file_exists($credits_js_path) ? (string) filemtime($credits_js_path) : $version,
|
||||
true
|
||||
);
|
||||
|
||||
@@ -334,7 +334,7 @@ trait ScriptsPart1c {
|
||||
wp_enqueue_script(
|
||||
'aksrt-reward',
|
||||
$base_uri . '/assets/js/reward.js',
|
||||
['loader_js'],
|
||||
['loader_js', 'aksrt-core-request'],
|
||||
(string) filemtime($reward_js),
|
||||
true
|
||||
);
|
||||
|
||||
+18
-8
@@ -161,11 +161,11 @@ class Setup {
|
||||
'enum' => array_keys(DomainModel::pay_type_labels()),
|
||||
],
|
||||
'pay_modo' => ['type' => 'string', 'enum' => ['0', 'points']],
|
||||
'price' => ['type' => 'number'],
|
||||
'price' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'points_price' => ['type' => 'integer'],
|
||||
'original_price' => ['type' => 'number'],
|
||||
'vip1_price' => ['type' => 'number'],
|
||||
'vip2_price' => ['type' => 'number'],
|
||||
'original_price' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'vip1_price' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'vip2_price' => ['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'vip1_points' => ['type' => 'integer'],
|
||||
'vip2_points' => ['type' => 'integer'],
|
||||
'pay_limit' => ['type' => 'integer', 'enum' => [0, 1, 2]],
|
||||
@@ -221,13 +221,23 @@ class Setup {
|
||||
]);
|
||||
|
||||
register_post_meta('post', DomainModel::PAY_PRICE_KEY, [
|
||||
'type' => 'number',
|
||||
'type' => 'string',
|
||||
'description' => 'Query-friendly paid content price.',
|
||||
'single' => true,
|
||||
'default' => 0,
|
||||
'default' => '0.00',
|
||||
'show_in_rest' => true,
|
||||
'sanitize_callback' => static function ($value) {
|
||||
return max(0, (float) $value);
|
||||
'sanitize_callback' => static function ($value): string {
|
||||
if (is_int($value)) {
|
||||
return (string) max(0, $value);
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return '0.00';
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '0.00';
|
||||
}
|
||||
},
|
||||
'auth_callback' => $post_edit_auth,
|
||||
]);
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Balance {
|
||||
|
||||
public const META_KEY = 'aksrt_balance';
|
||||
|
||||
/**
|
||||
* Current cash balance (yuan).
|
||||
*/
|
||||
public static function balance(int $user_id): float {
|
||||
if ($user_id <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
$val = get_user_meta($user_id, self::META_KEY, true);
|
||||
return is_numeric($val) ? round((float) $val, 2) : 0.0;
|
||||
}
|
||||
|
||||
public static function get_balance(int $user_id): float {
|
||||
return self::balance($user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit cash wallet.
|
||||
*
|
||||
* @param array<string,mixed> $ref
|
||||
* @return float|\WP_Error New balance.
|
||||
*/
|
||||
public static function add(int $user_id, float $delta, string $reason, array $ref = [], string $remark = '', bool $dispatch_hooks = true) {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_balance_user', __('用户无效', 'aksrt'));
|
||||
}
|
||||
$delta = round((float) $delta, 2);
|
||||
if ($delta <= 0) {
|
||||
return new \WP_Error('aksrt_balance_delta', __('增加余额必须为正数', 'aksrt'));
|
||||
}
|
||||
|
||||
$new = self::atomic_update($user_id, $delta, false);
|
||||
if ($new === null) {
|
||||
return new \WP_Error('aksrt_balance_db', __('余额写入失败', 'aksrt'));
|
||||
}
|
||||
self::log($user_id, $delta, $new, $reason, $ref, $remark);
|
||||
if ($dispatch_hooks) {
|
||||
do_action('aksrt_balance_added', $user_id, $delta, $reason, $new);
|
||||
}
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit cash wallet.
|
||||
*
|
||||
* @param array<string,mixed> $ref
|
||||
* @return float|\WP_Error New balance.
|
||||
*/
|
||||
public static function deduct(int $user_id, float $cost, string $reason, array $ref = [], string $remark = '') {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_balance_user', __('用户无效', 'aksrt'));
|
||||
}
|
||||
$cost = round((float) $cost, 2);
|
||||
if ($cost < 0) {
|
||||
return new \WP_Error('aksrt_balance_delta', __('扣减余额必须为非负数', 'aksrt'));
|
||||
}
|
||||
if ($cost === 0.0) {
|
||||
return self::balance($user_id);
|
||||
}
|
||||
|
||||
$new = self::atomic_update($user_id, $cost, true);
|
||||
if ($new === null) {
|
||||
$current = self::balance($user_id);
|
||||
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), [
|
||||
'balance' => $current,
|
||||
'cost' => $cost,
|
||||
]);
|
||||
}
|
||||
self::log($user_id, -$cost, $new, $reason, $ref, $remark);
|
||||
do_action('aksrt_balance_deducted', $user_id, $cost, $reason, $new);
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float|null
|
||||
*/
|
||||
private static function atomic_update(int $user_id, float $abs_delta, bool $is_deduct) {
|
||||
global $wpdb;
|
||||
|
||||
$abs_delta = round(abs($abs_delta), 2);
|
||||
$meta_key = self::META_KEY;
|
||||
$table = $wpdb->usermeta;
|
||||
|
||||
// Ensure meta row exists.
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT umeta_id FROM {$table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
|
||||
$user_id,
|
||||
$meta_key
|
||||
));
|
||||
if (!$exists) {
|
||||
add_user_meta($user_id, $meta_key, '0', true);
|
||||
}
|
||||
|
||||
if ($is_deduct) {
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table}
|
||||
SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(18,2)) - %f, 2) AS CHAR)
|
||||
WHERE user_id = %d AND meta_key = %s
|
||||
AND CAST(meta_value AS DECIMAL(18,2)) >= %f",
|
||||
$abs_delta,
|
||||
$user_id,
|
||||
$meta_key,
|
||||
$abs_delta
|
||||
));
|
||||
} else {
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table}
|
||||
SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(18,2)) + %f, 2) AS CHAR)
|
||||
WHERE user_id = %d AND meta_key = %s",
|
||||
$abs_delta,
|
||||
$user_id,
|
||||
$meta_key
|
||||
));
|
||||
}
|
||||
|
||||
if ($updated !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return self::balance($user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ref
|
||||
*/
|
||||
public static function log(int $user_id, float $delta, float $balance_after, string $reason, array $ref = [], string $remark = ''): void {
|
||||
global $wpdb;
|
||||
if (!class_exists('\AKSRT\Core\DBMigrator')) {
|
||||
return;
|
||||
}
|
||||
$table = \AKSRT\Core\DBMigrator::table('balance_log');
|
||||
if ($table === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = '';
|
||||
if (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
$ip = sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR']));
|
||||
}
|
||||
$ua = '';
|
||||
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
|
||||
$ua = substr(sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT'])), 0, 255);
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
[
|
||||
'user_id' => $user_id,
|
||||
'delta' => round($delta, 2),
|
||||
'balance_after' => round($balance_after, 2),
|
||||
'reason' => sanitize_key(substr($reason, 0, 64)),
|
||||
'remark' => $remark !== '' ? sanitize_text_field(substr($remark, 0, 255)) : null,
|
||||
'ref_type' => isset($ref['type']) ? sanitize_key((string) $ref['type']) : null,
|
||||
'ref_id' => isset($ref['id']) ? (int) $ref['id'] : (isset($ref['ref_id']) ? (int) $ref['ref_id'] : null),
|
||||
'ip' => $ip !== '' ? $ip : null,
|
||||
'ua' => $ua !== '' ? $ua : null,
|
||||
'created_at' => current_time('mysql', true),
|
||||
],
|
||||
['%d', '%f', '%f', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
* @return array{items:array<int,array<string,mixed>>,total:int}
|
||||
*/
|
||||
public static function get_log(array $args = []): array {
|
||||
global $wpdb;
|
||||
if (!class_exists('\AKSRT\Core\DBMigrator')) {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
$table = \AKSRT\Core\DBMigrator::table('balance_log');
|
||||
if ($table === '') {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$user_id = max(0, (int) ($args['user_id'] ?? 0));
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 10)));
|
||||
$offset = ($page - 1) * $per_page;
|
||||
|
||||
$where = '1=1';
|
||||
$params = [];
|
||||
if ($user_id > 0) {
|
||||
$where .= ' AND user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$total_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($total_sql, $params)) : $wpdb->get_var($total_sql));
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$list_sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d";
|
||||
$list_params = array_merge($params, [$per_page, $offset]);
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
|
||||
if (!is_array($rows)) {
|
||||
$rows = [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
$items[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'user_id' => (int) ($row['user_id'] ?? 0),
|
||||
'delta' => round((float) ($row['delta'] ?? 0), 2),
|
||||
'balance_after' => round((float) ($row['balance_after'] ?? 0), 2),
|
||||
'reason' => (string) ($row['reason'] ?? ''),
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return ['items' => $items, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether cash wallet feature is enabled (legacy pay_balance_s).
|
||||
*/
|
||||
public static function enabled(): bool {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return false;
|
||||
}
|
||||
if (method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
|
||||
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
return !empty($cfg['enabled']);
|
||||
}
|
||||
return !empty(\AKSRT\Settings\Settings::get_all()['pay_balance_s']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically transfer cash balance between two users.
|
||||
*
|
||||
* @param array<string,mixed> $ref
|
||||
* @return array{sender_balance:float,recipient_balance:float}|\WP_Error
|
||||
*/
|
||||
public static function transfer(int $sender_id, int $recipient_id, float $amount, string $reason_out = 'tip_sent', string $reason_in = 'tip_received', array $ref = [], string $remark = '') {
|
||||
global $wpdb;
|
||||
|
||||
$amount = round($amount, 2);
|
||||
if ($sender_id <= 0 || $recipient_id <= 0 || $sender_id === $recipient_id) {
|
||||
return new \WP_Error('aksrt_balance_transfer_user', __('转账用户无效', 'aksrt'));
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
return new \WP_Error('aksrt_balance_transfer_amount', __('转账金额必须大于零', 'aksrt'));
|
||||
}
|
||||
if (!class_exists('\AKSRT\Core\DBMigrator')) {
|
||||
return new \WP_Error('aksrt_balance_transfer_unavailable', __('余额服务不可用', 'aksrt'));
|
||||
}
|
||||
|
||||
foreach ([$sender_id, $recipient_id] as $user_id) {
|
||||
if (!metadata_exists('user', $user_id, self::META_KEY)) {
|
||||
add_user_meta($user_id, self::META_KEY, '0', true);
|
||||
}
|
||||
}
|
||||
|
||||
$meta_table = $wpdb->usermeta;
|
||||
$log_table = \AKSRT\Core\DBMigrator::table('balance_log');
|
||||
$locked_ids = [$sender_id, $recipient_id];
|
||||
sort($locked_ids, SORT_NUMERIC);
|
||||
|
||||
$wpdb->query('START TRANSACTION');
|
||||
try {
|
||||
foreach ($locked_ids as $user_id) {
|
||||
$wpdb->get_var($wpdb->prepare(
|
||||
"SELECT umeta_id FROM {$meta_table} WHERE user_id = %d AND meta_key = %s FOR UPDATE",
|
||||
$user_id,
|
||||
self::META_KEY
|
||||
));
|
||||
}
|
||||
|
||||
$sender_before = (float) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT meta_value FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
|
||||
$sender_id,
|
||||
self::META_KEY
|
||||
));
|
||||
if ($sender_before < $amount) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'));
|
||||
}
|
||||
|
||||
$sender_after = round($sender_before - $amount, 2);
|
||||
$recipient_before = (float) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT meta_value FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
|
||||
$recipient_id,
|
||||
self::META_KEY
|
||||
));
|
||||
$recipient_after = round($recipient_before + $amount, 2);
|
||||
|
||||
$sender_updated = $wpdb->update($meta_table, ['meta_value' => (string) $sender_after], ['user_id' => $sender_id, 'meta_key' => self::META_KEY], ['%s'], ['%d', '%s']);
|
||||
$recipient_updated = $wpdb->update($meta_table, ['meta_value' => (string) $recipient_after], ['user_id' => $recipient_id, 'meta_key' => self::META_KEY], ['%s'], ['%d', '%s']);
|
||||
if ($sender_updated !== 1 || $recipient_updated !== 1) {
|
||||
throw new \RuntimeException('balance_update_failed');
|
||||
}
|
||||
|
||||
$ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : 'tip';
|
||||
$ref_id = isset($ref['id']) ? (int) $ref['id'] : null;
|
||||
$created_at = current_time('mysql', true);
|
||||
$log_rows = [
|
||||
[$sender_id, -$amount, $sender_after, $reason_out],
|
||||
[$recipient_id, $amount, $recipient_after, $reason_in],
|
||||
];
|
||||
foreach ($log_rows as [$user_id, $delta, $balance_after, $reason]) {
|
||||
$inserted = $wpdb->insert($log_table, [
|
||||
'user_id' => $user_id,
|
||||
'delta' => $delta,
|
||||
'balance_after' => $balance_after,
|
||||
'reason' => sanitize_key($reason),
|
||||
'remark' => $remark !== '' ? sanitize_text_field(substr($remark, 0, 255)) : null,
|
||||
'ref_type' => $ref_type,
|
||||
'ref_id' => $ref_id,
|
||||
'created_at' => $created_at,
|
||||
], ['%d', '%f', '%f', '%s', '%s', '%s', '%d', '%s']);
|
||||
if ($inserted !== 1) {
|
||||
throw new \RuntimeException('balance_log_failed');
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
wp_cache_delete($sender_id, 'user_meta');
|
||||
wp_cache_delete($recipient_id, 'user_meta');
|
||||
do_action('aksrt_balance_transferred', $sender_id, $recipient_id, $amount, $ref);
|
||||
return ['sender_balance' => $sender_after, 'recipient_balance' => $recipient_after];
|
||||
} catch (\Throwable $e) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_balance_transfer_failed', __('打赏入账失败,请稍后重试', 'aksrt'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format yuan for display.
|
||||
*/
|
||||
public static function format(float $amount): string {
|
||||
return number_format_i18n(round($amount, 2), 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason label for ledger UI.
|
||||
*/
|
||||
public static function reason_label(string $reason): string {
|
||||
$map = [
|
||||
'card_pass_balance' => __('卡密充值', 'aksrt'),
|
||||
'order_payment' => __('订单支付', 'aksrt'),
|
||||
'order_refund' => __('订单退款', 'aksrt'),
|
||||
'recharge' => __('余额充值', 'aksrt'),
|
||||
'transfer_in' => __('转入', 'aksrt'),
|
||||
'transfer_out' => __('转出', 'aksrt'),
|
||||
'tip_sent' => __('打赏支出', 'aksrt'),
|
||||
'tip_received' => __('收到打赏', 'aksrt'),
|
||||
'admin' => __('管理员调整', 'aksrt'),
|
||||
'points_exchange_out' => __('余额兑换积分', 'aksrt'),
|
||||
'points_exchange_refund' => __('余额兑换积分退款', 'aksrt'),
|
||||
'creator_income' => __('创作收益结算', 'aksrt'),
|
||||
'rebate' => __('推广返佣结算', 'aksrt'),
|
||||
'withdraw_hold' => __('提现冻结', 'aksrt'),
|
||||
'withdraw_refund' => __('提现退回', 'aksrt'),
|
||||
];
|
||||
$key = sanitize_key($reason);
|
||||
return $map[$key] ?? ($reason !== '' ? $reason : __('余额变动', 'aksrt'));
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ class BalanceService {
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,93 +12,96 @@ trait BalanceServiceCharge {
|
||||
|
||||
|
||||
/**
|
||||
* When a recharge order is paid, credit the wallet.
|
||||
* Atomically confirms a recharge order and credits its cash account.
|
||||
*
|
||||
* @param string $order_number Order number.
|
||||
* @param array<string,mixed> $order Order row.
|
||||
* @param array<string,mixed> $order
|
||||
*/
|
||||
public function on_order_paid(string $order_number, array $order = []): void {
|
||||
if ($order === [] && class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number) ?: [];
|
||||
}
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
$type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
public function complete_recharge_payment(array $order, string $payment_method, string $provider_transaction_id, string $status_note = ''): bool {
|
||||
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
|
||||
return;
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Balance')) {
|
||||
return;
|
||||
}
|
||||
// Honor pay_auto_to_balance (default on). When off, leave order paid without wallet credit.
|
||||
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
|
||||
$recharge_cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
if (isset($recharge_cfg['auto_to_balance']) && empty($recharge_cfg['auto_to_balance'])) {
|
||||
return;
|
||||
}
|
||||
if ($order_number === '' || $user_id <= 0 || sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
|
||||
return false;
|
||||
}
|
||||
$note = json_decode((string) ($order['note'] ?? ''), true);
|
||||
if (!is_array($note)) {
|
||||
$note = [];
|
||||
$note = is_array($note) ? $note : [];
|
||||
if (!isset($note['credit_amount']) || !is_string($note['credit_amount'])) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($note['balance_credited'])) {
|
||||
return;
|
||||
$amount_raw = $note['credit_amount'];
|
||||
try {
|
||||
$credit = \AKSRT\Ledger\AssetAmount::balance($amount_raw);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return false;
|
||||
}
|
||||
$credit_amount = isset($note['credit_amount'])
|
||||
? round((float) $note['credit_amount'], 2)
|
||||
: round((float) ($order['amount'] ?? 0), 2);
|
||||
if ($credit_amount <= 0) {
|
||||
return;
|
||||
if ($credit->minor() <= 0) {
|
||||
return false;
|
||||
}
|
||||
global $wpdb;
|
||||
if ($wpdb->query('START TRANSACTION') === false) {
|
||||
return;
|
||||
}
|
||||
$result = Balance::add(
|
||||
$payment_method = sanitize_key($payment_method);
|
||||
$provider_transaction_id = sanitize_text_field($provider_transaction_id);
|
||||
$locked_status = '';
|
||||
$locked_note = [];
|
||||
$guard = function () use ($order_number, $user_id, &$locked_status, &$locked_note) {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE order_number = %s FOR UPDATE", $order_number), ARRAY_A);
|
||||
if (!is_array($current)
|
||||
|| (int) ($current['user_id'] ?? 0) !== $user_id
|
||||
|| sanitize_key((string) ($current['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
|| !in_array((string) ($current['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_CREATED, \AKSRT\Payment\Order::STATUS_PENDING], true)
|
||||
) {
|
||||
return new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$locked_status = (string) $current['status'];
|
||||
$decoded = json_decode((string) ($current['note'] ?? ''), true);
|
||||
$locked_note = is_array($decoded) ? $decoded : [];
|
||||
return true;
|
||||
};
|
||||
$mutation = function () use ($order_number, $payment_method, $provider_transaction_id, $status_note, $credit, &$locked_status, &$locked_note) {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('order');
|
||||
$locked_note['ledger_credited'] = 1;
|
||||
$locked_note['credited_amount'] = $credit->decimal();
|
||||
if ($status_note !== '') {
|
||||
$locked_note['status_note'] = sanitize_textarea_field($status_note);
|
||||
}
|
||||
$now = current_time('mysql');
|
||||
$updated = $wpdb->update($table, [
|
||||
'status' => \AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
'payment_method' => $payment_method,
|
||||
'payment_transaction_id' => $provider_transaction_id,
|
||||
'paid_at' => $now,
|
||||
'completed_at' => $now,
|
||||
'note' => wp_json_encode($locked_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
], ['order_number' => $order_number, 'status' => $locked_status], ['%s', '%s', '%s', '%s', '%s', '%s'], ['%s', '%s']);
|
||||
return $updated === 1 ? true : new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]);
|
||||
};
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
|
||||
$user_id,
|
||||
$credit_amount,
|
||||
\AKSRT\Ledger\AssetType::BALANCE,
|
||||
$credit->minor(),
|
||||
\AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]),
|
||||
'recharge',
|
||||
['type' => 'order', 'order' => $order_number],
|
||||
sprintf(__('余额充值订单 %s', 'aksrt'), $order_number),
|
||||
false
|
||||
$user_id,
|
||||
['payment_method' => $payment_method, 'provider_transaction_id' => $provider_transaction_id],
|
||||
$mutation,
|
||||
$guard
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
$note['balance_credited'] = 1;
|
||||
$note['credited_amount'] = $credit_amount;
|
||||
$updated = $wpdb->update(
|
||||
$wpdb->prefix . 'aksrt_order',
|
||||
['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)],
|
||||
['order_number' => $order_number],
|
||||
['%s'],
|
||||
['%s']
|
||||
);
|
||||
if ($updated === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return;
|
||||
}
|
||||
if ($wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
return;
|
||||
}
|
||||
do_action('aksrt_balance_added', $user_id, $credit_amount, 'recharge', (float) $result);
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
$order_number,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
['note' => __('充值金额已到账。', 'aksrt')]
|
||||
);
|
||||
if (!$result->replayed) {
|
||||
$paid_order = array_merge($order, [
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PAID,
|
||||
'payment_method' => $payment_method,
|
||||
'payment_transaction_id' => $provider_transaction_id,
|
||||
]);
|
||||
do_action('aksrt_order_status_changed', $order_number, \AKSRT\Payment\Order::STATUS_PAID, $paid_order);
|
||||
do_action('aksrt_order_paid', $order_number, $paid_order);
|
||||
do_action('aksrt_order_status_changed', $order_number, \AKSRT\Payment\Order::STATUS_COMPLETED, array_merge($paid_order, ['status' => \AKSRT\Payment\Order::STATUS_COMPLETED]));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +128,7 @@ trait BalanceServiceCharge {
|
||||
return [
|
||||
'enabled' => !empty($cfg['enabled']),
|
||||
'desc' => (string) ($cfg['desc'] ?? ''),
|
||||
'wallet' => class_exists('AKSRT\\Credits\\Balance') ? Balance::balance($user_id) : 0.0,
|
||||
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'packages' => $packages,
|
||||
'custom_s' => $custom_s,
|
||||
'custom_limit' => $custom_limit,
|
||||
@@ -143,7 +146,7 @@ trait BalanceServiceCharge {
|
||||
|
||||
|
||||
/**
|
||||
* @return array<int,array{key:string,price:float,pay_price:float,title:string,tag:string}>
|
||||
* @return array<int,array{key:string,price:string,pay_price:string,title:string,tag:string}>
|
||||
*/
|
||||
public function parse_packages(string $raw): array {
|
||||
$out = [];
|
||||
@@ -154,23 +157,25 @@ trait BalanceServiceCharge {
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
// pay|credit|title OR credit|pay|title — legacy uses price(credit)|pay_price|tag
|
||||
// Admin help: 支付金额|到账金额|标题
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
if (count($parts) < 2) {
|
||||
continue;
|
||||
}
|
||||
$pay = (float) $parts[0];
|
||||
$credit = (float) $parts[1];
|
||||
try {
|
||||
$pay = \AKSRT\Ledger\AssetAmount::balance((string) $parts[0]);
|
||||
$credit = \AKSRT\Ledger\AssetAmount::balance((string) $parts[1]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
continue;
|
||||
}
|
||||
$title = $parts[2] ?? '';
|
||||
if ($pay <= 0 || $credit <= 0) {
|
||||
if ($pay->minor() <= 0 || $credit->minor() <= 0) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'key' => (string) $i,
|
||||
'pay_price' => round($pay, 2),
|
||||
'price' => round($credit, 2),
|
||||
'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), (string) $credit),
|
||||
'pay_price' => $pay->decimal(),
|
||||
'price' => $credit->decimal(),
|
||||
'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), $credit->decimal()),
|
||||
'tag' => $title,
|
||||
];
|
||||
$i++;
|
||||
@@ -183,7 +188,7 @@ trait BalanceServiceCharge {
|
||||
/**
|
||||
* @return array<string,mixed>|\WP_Error
|
||||
*/
|
||||
public function create_charge_order(int $user_id, string $package_key, float $custom_price, string $payment_method) {
|
||||
public function create_charge_order(int $user_id, string $package_key, string $custom_price, string $payment_method, string $operation_key) {
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
@@ -191,23 +196,26 @@ trait BalanceServiceCharge {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$view = $this->get_charge_view($user_id);
|
||||
$credit_amount = 0.0;
|
||||
$pay_amount = 0.0;
|
||||
$credit_amount = null;
|
||||
$pay_amount = null;
|
||||
$title = __('余额充值', 'aksrt');
|
||||
|
||||
if ($package_key === 'custom' || $package_key === '') {
|
||||
if (empty($view['custom_s'])) {
|
||||
return new \WP_Error('aksrt_custom_closed', __('未开放自定义金额', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$custom_price = round($custom_price, 2);
|
||||
$min = 1.0;
|
||||
$max = (float) $view['custom_limit'];
|
||||
if ($custom_price < $min || $custom_price > $max) {
|
||||
return new \WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 %s ~ %s 之间', 'aksrt'), (string) $min, (string) $max), ['status' => 400]);
|
||||
try {
|
||||
$custom_amount = \AKSRT\Ledger\AssetAmount::balance($custom_price);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_invalid_price', __('金额格式无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$pay_amount = $custom_price;
|
||||
$credit_amount = $custom_price;
|
||||
$title = sprintf(__('自定义充值 %s 元', 'aksrt'), (string) $custom_price);
|
||||
$max = max(1, (int) $view['custom_limit']);
|
||||
if ($custom_amount->minor() < 100 || $custom_amount->minor() > $max * 100) {
|
||||
return new \WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 1 ~ %d 之间', 'aksrt'), $max), ['status' => 400]);
|
||||
}
|
||||
$pay_amount = $custom_amount;
|
||||
$credit_amount = $custom_amount;
|
||||
$title = sprintf(__('自定义充值 %s 元', 'aksrt'), $custom_amount->decimal());
|
||||
$package_key = 'custom';
|
||||
} else {
|
||||
$found = null;
|
||||
@@ -220,14 +228,24 @@ trait BalanceServiceCharge {
|
||||
if (!$found) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('充值套餐无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$pay_amount = (float) $found['pay_price'];
|
||||
$credit_amount = (float) $found['price'];
|
||||
try {
|
||||
$pay_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['pay_price']);
|
||||
$credit_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['price']);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('充值套餐金额无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$title = (string) $found['title'];
|
||||
}
|
||||
|
||||
if ($pay_amount <= 0 || $credit_amount <= 0) {
|
||||
if (!($pay_amount instanceof \AKSRT\Ledger\AssetAmount)
|
||||
|| !($credit_amount instanceof \AKSRT\Ledger\AssetAmount)
|
||||
|| $pay_amount->minor() <= 0
|
||||
|| $credit_amount->minor() <= 0
|
||||
) {
|
||||
return new \WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$pay_decimal = $pay_amount->decimal();
|
||||
$credit_decimal = $credit_amount->decimal();
|
||||
|
||||
$payment_method = sanitize_key($payment_method);
|
||||
if ($payment_method === '' || $payment_method === 'balance') {
|
||||
@@ -239,22 +257,46 @@ trait BalanceServiceCharge {
|
||||
$note = wp_json_encode([
|
||||
'balance_recharge' => 1,
|
||||
'package' => $package_key,
|
||||
'credit_amount' => $credit_amount,
|
||||
'pay_amount' => $pay_amount,
|
||||
'credit_amount' => $credit_decimal,
|
||||
'pay_amount' => $pay_decimal,
|
||||
'title' => $title,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$existing = $repo->get_by_request_key($operation_key);
|
||||
if (is_array($existing)) {
|
||||
$existing_note = json_decode((string) ($existing['note'] ?? ''), true);
|
||||
$existing_note = is_array($existing_note) ? $existing_note : [];
|
||||
$existing_credit = isset($existing_note['credit_amount']) && is_string($existing_note['credit_amount'])
|
||||
? $existing_note['credit_amount']
|
||||
: '';
|
||||
$existing_pay = isset($existing_note['pay_amount']) && is_string($existing_note['pay_amount'])
|
||||
? $existing_note['pay_amount']
|
||||
: '';
|
||||
$same = (int) ($existing['user_id'] ?? 0) === $user_id
|
||||
&& sanitize_key((string) ($existing['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
&& sanitize_key((string) ($existing['payment_method'] ?? '')) === $payment_method
|
||||
&& (string) ($existing_note['package'] ?? '') === $package_key
|
||||
&& hash_equals($existing_credit, $credit_decimal)
|
||||
&& hash_equals($existing_pay, $pay_decimal);
|
||||
if (!$same) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他充值请求', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$order_number = (string) $existing['order_number'];
|
||||
} else {
|
||||
$order_number = $repo->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
|
||||
'product_id' => 0,
|
||||
'amount' => $pay_amount,
|
||||
'amount' => $pay_decimal,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
|
||||
]);
|
||||
'request_key' => $operation_key,
|
||||
]);
|
||||
}
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
@@ -263,6 +305,28 @@ trait BalanceServiceCharge {
|
||||
if (!$order) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (is_array($existing) && in_array((string) ($order['status'] ?? ''), [
|
||||
\AKSRT\Payment\Order::STATUS_PAID,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
], true)) {
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
$transaction_id = (int) ($history['items'][0]['id'] ?? 0);
|
||||
if ($transaction_id <= 0) {
|
||||
return new \WP_Error('aksrt_recharge_state', __('充值订单与账本状态不一致', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return [
|
||||
'redirect' => false,
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_decimal,
|
||||
'credit_amount' => $credit_decimal,
|
||||
'transaction_id' => $transaction_id,
|
||||
'replayed' => true,
|
||||
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('充值成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
];
|
||||
}
|
||||
$order['subject'] = $title;
|
||||
$order['payment_method'] = $payment_method;
|
||||
|
||||
@@ -285,7 +349,7 @@ trait BalanceServiceCharge {
|
||||
return new \WP_Error(
|
||||
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
|
||||
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
|
||||
['status' => 402, 'order_number' => $order_number]
|
||||
['status' => (int) ($payment_result['status'] ?? 400), 'order_number' => $order_number]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,23 +359,29 @@ trait BalanceServiceCharge {
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_amount,
|
||||
'credit_amount' => $credit_amount,
|
||||
'amount' => $pay_decimal,
|
||||
'credit_amount' => $credit_decimal,
|
||||
'message' => $payment_result['message'],
|
||||
'reload' => false,
|
||||
'poll' => true,
|
||||
'transaction_id' => 0,
|
||||
'replayed' => false,
|
||||
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
];
|
||||
}
|
||||
|
||||
// Synchronous gateway: PaymentOrchestrator's paid transition already fired
|
||||
// aksrt_order_paid, so fulfillment has run through the single hook path.
|
||||
// The synchronous gateway has already committed the recharge and order state.
|
||||
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]);
|
||||
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
|
||||
|
||||
return [
|
||||
'redirect' => false,
|
||||
'order_number' => $order_number,
|
||||
'amount' => $pay_amount,
|
||||
'credit_amount' => $credit_amount,
|
||||
'wallet' => Balance::balance($user_id),
|
||||
'amount' => $pay_decimal,
|
||||
'credit_amount' => $credit_decimal,
|
||||
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
|
||||
'replayed' => false,
|
||||
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||||
'message' => __('充值成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
];
|
||||
|
||||
+164
-182
@@ -16,16 +16,17 @@ trait BalanceServiceRest {
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_charge_options'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_asset_rest'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_charge_create'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_asset_rest'],
|
||||
'args' => [
|
||||
'package' => ['type' => 'string', 'default' => 'custom', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'custom_price' => ['type' => 'string', 'default' => '0', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
'payment_method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -33,9 +34,18 @@ trait BalanceServiceRest {
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_transfer'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_asset_rest'],
|
||||
'args' => [
|
||||
'type' => ['type' => 'string', 'required' => true, 'enum' => ['balance', 'points']],
|
||||
'recipient' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
|
||||
'amount' => [
|
||||
'required' => true,
|
||||
'oneOf' => [
|
||||
['type' => 'integer', 'minimum' => 1],
|
||||
['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -43,9 +53,7 @@ trait BalanceServiceRest {
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_transfer_search'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_asset_rest'],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -53,21 +61,30 @@ trait BalanceServiceRest {
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_exchange_balance_for_points'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_asset_rest'],
|
||||
'args' => ['amount' => ['type' => 'string', 'required' => true, 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$']],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/pay/balance/withdraw', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'rest_balance_withdraw'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function check_asset_rest(\WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', '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;
|
||||
}
|
||||
|
||||
private function balance_feature_enabled(): bool {
|
||||
if (!class_exists('\\AKSRT\\Settings\\Settings')) {
|
||||
return false;
|
||||
}
|
||||
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
return !empty($cfg['enabled']);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +93,7 @@ trait BalanceServiceRest {
|
||||
* GET charge options (packages + methods + limits).
|
||||
*/
|
||||
public function rest_charge_options(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
if (!$this->balance_feature_enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
@@ -92,19 +109,23 @@ trait BalanceServiceRest {
|
||||
* POST create recharge order and initiate payment.
|
||||
*/
|
||||
public function rest_charge_create(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
if (!$this->balance_feature_enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'recharge-create', $user_id);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$package_key = sanitize_text_field((string) ($params['package'] ?? 'custom'));
|
||||
$custom_price = isset($params['custom_price']) ? (float) $params['custom_price'] : 0.0;
|
||||
$custom_price = isset($params['custom_price']) ? (string) $params['custom_price'] : '0';
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay'));
|
||||
|
||||
$result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method);
|
||||
$result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method, $operation_key);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
@@ -123,8 +144,12 @@ trait BalanceServiceRest {
|
||||
}
|
||||
$type = sanitize_key((string) ($params['type'] ?? 'balance'));
|
||||
$recipient = (int) ($params['recipient'] ?? 0);
|
||||
$amount_raw = $params['amount'] ?? $params['price'] ?? 0;
|
||||
$amount_raw = $params['amount'] ?? '';
|
||||
$from_id = get_current_user_id();
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'transfer', $from_id);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
|
||||
if (!in_array($type, ['balance', 'points'], true)) {
|
||||
return new \WP_Error('aksrt_transfer_type', __('转账类型无效', 'aksrt'), ['status' => 400]);
|
||||
@@ -147,45 +172,43 @@ trait BalanceServiceRest {
|
||||
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_transfer', $from_id)) {
|
||||
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账余额', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$amount = round((float) $amount_raw, 2);
|
||||
if ($amount <= 0) {
|
||||
try {
|
||||
$amount = \AKSRT\Ledger\AssetAmount::balance(is_string($amount_raw) ? $amount_raw : '');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Balance')) {
|
||||
return new \WP_Error('aksrt_balance_unavailable', __('余额系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$from_bal = Balance::balance($from_id);
|
||||
if ($from_bal < $amount) {
|
||||
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]);
|
||||
if ($amount->minor() <= 0) {
|
||||
return new \WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$fee_rate = max(0, min(50, (int) ($cfg['transfer']['service_charge'] ?? 0)));
|
||||
$credit = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount;
|
||||
$fee_minor = $this->percentage_minor($amount->minor(), $fee_rate);
|
||||
|
||||
$from_user = wp_get_current_user();
|
||||
$to_user = get_userdata($recipient);
|
||||
$deduct = Balance::deduct(
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->transfer(
|
||||
$from_id,
|
||||
$amount,
|
||||
'transfer_out',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient)
|
||||
);
|
||||
if (is_wp_error($deduct)) {
|
||||
return $deduct;
|
||||
}
|
||||
Balance::add(
|
||||
$recipient,
|
||||
$credit,
|
||||
'transfer_in',
|
||||
['type' => 'user', 'id' => $from_id],
|
||||
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id)
|
||||
\AKSRT\Ledger\AssetType::BALANCE,
|
||||
$amount->minor(),
|
||||
$fee_minor,
|
||||
$operation_key,
|
||||
'transfer',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient),
|
||||
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id),
|
||||
['fee_rate' => $fee_rate]
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => __('转账成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
'balance' => Balance::balance($from_id),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($from_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -201,57 +224,42 @@ trait BalanceServiceRest {
|
||||
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('points_transfer', $from_id)) {
|
||||
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账积分', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (filter_var($amount_raw, FILTER_VALIDATE_INT) === false || (int) $amount_raw <= 0) {
|
||||
if (!is_int($amount_raw) || $amount_raw <= 0) {
|
||||
return new \WP_Error('aksrt_transfer_amount', __('请输入有效积分', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$amount = (int) $amount_raw;
|
||||
$amount = $amount_raw;
|
||||
$min_amount = max(1, (int) ($points_settings['points_transfer_min'] ?? 1));
|
||||
if ($amount < $min_amount) {
|
||||
return new \WP_Error('aksrt_transfer_minimum', sprintf(__('单笔转账至少需要 %d 积分', 'aksrt'), $min_amount), ['status' => 400]);
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Credits')) {
|
||||
return new \WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$fee_rate = max(0, min(50, (int) ($points_settings['points_service_charge'] ?? 0)));
|
||||
$credit = $fee_rate > 0 ? (int) floor($amount * (100 - $fee_rate) / 100) : $amount;
|
||||
$fee = $this->percentage_minor($amount, $fee_rate);
|
||||
$from_user = wp_get_current_user();
|
||||
$to_user = get_userdata($recipient);
|
||||
$deduct = Credits::deduct(
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->transfer(
|
||||
$from_id,
|
||||
$amount,
|
||||
'transfer_out',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient)
|
||||
);
|
||||
if (is_wp_error($deduct)) {
|
||||
return $deduct;
|
||||
}
|
||||
$add = Credits::add(
|
||||
$recipient,
|
||||
$credit,
|
||||
'transfer_in',
|
||||
['type' => 'user', 'id' => $from_id],
|
||||
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id)
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$amount,
|
||||
$fee,
|
||||
$operation_key,
|
||||
'transfer',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient),
|
||||
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id),
|
||||
['fee_rate' => $fee_rate]
|
||||
);
|
||||
if (is_wp_error($add)) {
|
||||
$refund = Credits::add(
|
||||
$from_id,
|
||||
$amount,
|
||||
'transfer_refund',
|
||||
['type' => 'user', 'id' => $recipient],
|
||||
__('收款积分入账失败,已退回转账积分', 'aksrt')
|
||||
);
|
||||
if (is_wp_error($refund)) {
|
||||
return new \WP_Error('aksrt_transfer_recovery_failed', __('收款积分入账失败,转账积分退回失败,请联系管理员', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return $add;
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => __('转账成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
'balance' => Credits::balance($from_id),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'balance' => $result->balance($from_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -289,11 +297,11 @@ trait BalanceServiceRest {
|
||||
* Exchange integer cash-wallet balance for points.
|
||||
*/
|
||||
public function rest_exchange_balance_for_points(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
if (!$this->balance_feature_enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (!class_exists('AKSRT\\Credits\\Credits') || !class_exists('AKSRT\\Settings\\Settings') || !\AKSRT\Settings\Settings::credits_enabled()) {
|
||||
return new \WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]);
|
||||
if (!class_exists('AKSRT\\Settings\\Settings') || !\AKSRT\Settings\Settings::credits_enabled()) {
|
||||
return new \WP_Error('aksrt_points_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
$exchange = is_array($cfg['points_exchange'] ?? null) ? $cfg['points_exchange'] : [];
|
||||
@@ -304,121 +312,95 @@ trait BalanceServiceRest {
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$raw_amount = $params['amount'] ?? 0;
|
||||
if (filter_var($raw_amount, FILTER_VALIDATE_INT) === false || (int) $raw_amount <= 0) {
|
||||
return new \WP_Error('aksrt_exchange_amount', __('兑换余额必须为正整数', 'aksrt'), ['status' => 400]);
|
||||
$raw_amount = $params['amount'] ?? '';
|
||||
try {
|
||||
$amount = \AKSRT\Ledger\AssetAmount::balance(is_string($raw_amount) ? $raw_amount : '');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_exchange_amount', __('兑换余额格式无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($amount->minor() <= 0) {
|
||||
return new \WP_Error('aksrt_exchange_amount', __('兑换余额必须大于零', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$amount = (int) $raw_amount;
|
||||
$rate = max(1, (int) ($exchange['rate'] ?? 100));
|
||||
$daily_max = max(1, (int) ($exchange['daily_max_points'] ?? 100000));
|
||||
if ($amount > intdiv($daily_max, $rate)) {
|
||||
$scaled = $this->multiply_non_negative($amount->minor(), $rate);
|
||||
if (is_wp_error($scaled)) {
|
||||
return $scaled;
|
||||
}
|
||||
if ($scaled % 100 !== 0) {
|
||||
return new \WP_Error('aksrt_exchange_precision', __('该金额无法兑换为整数积分', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$points = intdiv($scaled, 100);
|
||||
if ($points > $daily_max) {
|
||||
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d', 'aksrt'), $daily_max), ['status' => 400]);
|
||||
}
|
||||
$points = $amount * $rate;
|
||||
$min_points = max(1, (int) ($exchange['min_points'] ?? 100));
|
||||
if ($points < $min_points) {
|
||||
return new \WP_Error('aksrt_exchange_min', sprintf(__('单笔至少购买 %d 积分', 'aksrt'), $min_points), ['status' => 400]);
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
$today_points = $this->today_points_exchange_total($user_id);
|
||||
if ($today_points + $points > $daily_max) {
|
||||
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d,当前还可购买 %d', 'aksrt'), $daily_max, max(0, $daily_max - $today_points)), ['status' => 400]);
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'points-exchange', $user_id);
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $operation_key;
|
||||
}
|
||||
try {
|
||||
$exchange_ref = random_int(1, PHP_INT_MAX);
|
||||
} catch (\Throwable $e) {
|
||||
$exchange_ref = (int) round(microtime(true) * 1000000);
|
||||
$site_now = current_datetime();
|
||||
$start = (clone $site_now)->setTime(0, 0, 0);
|
||||
$end = (clone $start)->modify('+1 day');
|
||||
$utc = new \DateTimeZone('UTC');
|
||||
$start_gmt = $start->setTimezone($utc)->format('Y-m-d H:i:s.u');
|
||||
$end_gmt = $end->setTimezone($utc)->format('Y-m-d H:i:s.u');
|
||||
$guard = static function () use ($user_id, $points, $daily_max, $start_gmt, $end_gmt) {
|
||||
$used = \AKSRT\Ledger\LedgerQuery::get_instance()->sum_credits($user_id, 'points_exchange', $start_gmt, $end_gmt);
|
||||
if ($used + $points > $daily_max) {
|
||||
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d,当前还可购买 %d', 'aksrt'), $daily_max, max(0, $daily_max - $used)), ['status' => 400]);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$business_mutation = static function () use ($user_id, $amount, $points, $operation_key) {
|
||||
if (class_exists('\\AKSRT\\Payment\\CommissionLedger')) {
|
||||
return \AKSRT\Payment\CommissionLedger::get_instance()->record_points_exchange($user_id, $amount->decimal(), $points, $operation_key);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->exchange(
|
||||
$user_id,
|
||||
$amount->minor(),
|
||||
$points,
|
||||
$operation_key,
|
||||
'points_exchange',
|
||||
['type' => 'points_exchange', 'key' => $operation_key],
|
||||
sprintf(__('余额兑换 %d 积分', 'aksrt'), $points),
|
||||
['rate' => $rate],
|
||||
$guard,
|
||||
$business_mutation
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
$ref = ['type' => 'points_exchange', 'id' => $exchange_ref];
|
||||
$deduct = Balance::deduct($user_id, (float) $amount, 'points_exchange_out', $ref, sprintf(__('余额兑换 %d 积分', 'aksrt'), $points));
|
||||
if (is_wp_error($deduct)) return $deduct;
|
||||
$add = Credits::add($user_id, $points, 'points_exchange_in', $ref, sprintf(__('余额兑换:扣除 %d 余额', 'aksrt'), $amount));
|
||||
if (is_wp_error($add)) {
|
||||
$refund = Balance::add($user_id, (float) $amount, 'points_exchange_refund', $ref, __('积分入账失败,已退回兑换余额', 'aksrt'));
|
||||
if (is_wp_error($refund)) return new \WP_Error('aksrt_exchange_recovery_failed', __('积分入账失败,余额退回失败,请联系管理员', 'aksrt'), ['status' => 500]);
|
||||
return $add;
|
||||
}
|
||||
do_action('aksrt_points_exchange_completed', $user_id, (float) $amount, $points, (string) $exchange_ref);
|
||||
return rest_ensure_response(['success' => true, 'data' => [
|
||||
'message' => sprintf(__('兑换成功,获得 %d 积分', 'aksrt'), $points),
|
||||
'wallet_balance' => Balance::balance($user_id),
|
||||
'credit_balance' => Credits::balance($user_id),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'replayed' => $result->replayed,
|
||||
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
|
||||
'credit_balance' => $result->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'points' => $points,
|
||||
'reload' => true,
|
||||
]]);
|
||||
}
|
||||
|
||||
private function today_points_exchange_total(int $user_id): int {
|
||||
if ($user_id <= 0 || !class_exists('AKSRT\\Core\\DBMigrator')) return 0;
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('credit_log');
|
||||
if ($table === '') return 0;
|
||||
$site_now = current_datetime();
|
||||
$start = (clone $site_now)->setTime(0, 0, 0);
|
||||
$end = (clone $start)->modify('+1 day');
|
||||
$utc = new \DateTimeZone('UTC');
|
||||
$sql = "SELECT COALESCE(SUM(delta), 0) FROM {$table} WHERE user_id = %d AND reason = %s AND created_at >= %s AND created_at < %s";
|
||||
$start_gmt = $start->setTimezone($utc)->format('Y-m-d H:i:s');
|
||||
$end_gmt = $end->setTimezone($utc)->format('Y-m-d H:i:s');
|
||||
return max(0, (int) $wpdb->get_var($wpdb->prepare($sql, $user_id, 'points_exchange_in', $start_gmt, $end_gmt)));
|
||||
private function percentage_minor(int $amount, int $rate): int {
|
||||
if ($amount <= 0 || $rate <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (intdiv($amount, 100) * $rate) + intdiv(($amount % 100) * $rate, 100);
|
||||
}
|
||||
|
||||
public function rest_balance_withdraw(\WP_REST_Request $request) {
|
||||
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
|
||||
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
|
||||
/** @return int|\WP_Error */
|
||||
private function multiply_non_negative(int $left, int $right) {
|
||||
if ($left < 0 || $right < 0 || ($right > 0 && $left > intdiv(PHP_INT_MAX, $right))) {
|
||||
return new \WP_Error('aksrt_ledger_overflow', __('资产金额超出可用范围', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
if (empty($settings['pay_balance_withdraw_s'])) {
|
||||
return new \WP_Error('aksrt_withdraw_closed', __('余额提现未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (!class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
return new \WP_Error('aksrt_withdraw_unavailable', __('提现系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$user_id = get_current_user_id();
|
||||
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_withdraw', $user_id)) {
|
||||
return new \WP_Error('aksrt_withdraw_forbidden', __('当前账号无权提现', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$raw_amount = $params['amount'] ?? 0;
|
||||
if (filter_var($raw_amount, FILTER_VALIDATE_INT) === false || (int) $raw_amount <= 0) {
|
||||
return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须为正整数', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$amount = (int) $raw_amount;
|
||||
$method = sanitize_key((string) ($params['method'] ?? 'alipay'));
|
||||
$method = $method !== '' ? $method : 'alipay';
|
||||
$account = sanitize_textarea_field((string) ($params['account_info'] ?? $params['account'] ?? ''));
|
||||
$withdraw_settings = class_exists('\\AKSRT\\Settings\\Settings')
|
||||
? \AKSRT\Settings\Settings::get_withdrawal_settings()
|
||||
: ['min_amount' => 50, 'service_charge_rate' => 5, 'daily_count' => 3, 'daily_limit' => 50, 'reset_time' => '00:00'];
|
||||
$lowest = (float) $withdraw_settings['min_amount'];
|
||||
if ($amount < $lowest) {
|
||||
return new \WP_Error('withdraw_min_amount', sprintf(__('提现金额低于最低提现门槛(%s 元)', 'aksrt'), (string) $lowest), ['status' => 400]);
|
||||
}
|
||||
if ($account === '') {
|
||||
return new \WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!in_array($method, (array) ($withdraw_settings['payout_channels'] ?? []), true)) {
|
||||
return new \WP_Error('aksrt_withdraw_channel', __('当前收款渠道不可用', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$result = \AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id, $amount, $method, $account, $withdraw_settings);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'message' => __('提现申请已提交,请等待处理', 'aksrt'),
|
||||
'withdrawal_id' => $result['withdrawal_id'],
|
||||
'amount' => $amount,
|
||||
'net_amount' => $result['net_amount'],
|
||||
'wallet_balance' => $result['wallet_balance'],
|
||||
'remaining_count' => $result['usage']['remaining_count'],
|
||||
'remaining_amount' => $result['usage']['remaining_amount'],
|
||||
],
|
||||
]);
|
||||
return $left * $right;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Credits;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Credits {
|
||||
|
||||
/** user_meta 键名 */
|
||||
const META_KEY = 'aksrt_credits';
|
||||
|
||||
/**
|
||||
* 取用户余额。
|
||||
*/
|
||||
public static function balance(int $user_id): int {
|
||||
if ($user_id <= 0) return 0;
|
||||
$val = get_user_meta($user_id, self::META_KEY, true);
|
||||
return is_numeric($val) ? (int) $val : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible alias for credit spending.
|
||||
*/
|
||||
public static function get_balance(int $user_id): int {
|
||||
return self::balance($user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加积分。
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param int $delta 必须 > 0
|
||||
* @param string $reason 例 register/signin/comment/referral/admin
|
||||
* @param array $ref ['type' => 'post|comment|user|referral', 'id' => int]
|
||||
* @return int|\WP_Error 新余额或错误
|
||||
*/
|
||||
public static function add(int $user_id, int $delta, string $reason, array $ref = [], string $remark = '', bool $dispatch_hooks = true) {
|
||||
if ($user_id <= 0) return new \WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
|
||||
if ($delta <= 0) return new \WP_Error('aksrt_credits_delta', __('增加积分必须为正数', 'aksrt'));
|
||||
|
||||
$new_balance = self::atomic_update($user_id, $delta, false);
|
||||
if ($new_balance === null) {
|
||||
return new \WP_Error('aksrt_credits_db', __('积分写入失败', 'aksrt'));
|
||||
}
|
||||
self::log($user_id, $delta, $new_balance, $reason, $ref, $remark);
|
||||
|
||||
if ($dispatch_hooks) {
|
||||
do_action('aksrt_credits_added', $user_id, $delta, $reason, $new_balance);
|
||||
}
|
||||
return $new_balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣减积分。
|
||||
*
|
||||
* @return int|\WP_Error 新余额或错误
|
||||
*/
|
||||
public static function deduct(int $user_id, int $cost, string $reason, array $ref = [], string $remark = '') {
|
||||
if ($user_id <= 0) return new \WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
|
||||
if ($cost < 0) return new \WP_Error('aksrt_credits_delta', __('扣减积分必须为非负数', 'aksrt'));
|
||||
if ($cost === 0) {
|
||||
return self::balance($user_id);
|
||||
}
|
||||
|
||||
$new_balance = self::atomic_update($user_id, $cost, true);
|
||||
if ($new_balance === null) {
|
||||
$current = self::balance($user_id);
|
||||
return new \WP_Error('aksrt_credits_insufficient', __('积分不足', 'aksrt'), [
|
||||
'balance' => $current,
|
||||
'cost' => $cost,
|
||||
]);
|
||||
}
|
||||
|
||||
self::log($user_id, -$cost, $new_balance, $reason, $ref, $remark);
|
||||
|
||||
do_action('aksrt_credits_deducted', $user_id, $cost, $reason, $new_balance);
|
||||
return $new_balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子加减:用单条 SQL UPDATE 修改 user_meta,避免 read-modify-write 竞态。
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param int $abs_delta 绝对值
|
||||
* @param bool $is_deduct true=扣减(带余额检查),false=增加
|
||||
* @return int|null 新余额;失败(余额不足或 DB 错误)返回 null
|
||||
*/
|
||||
private static function atomic_update(int $user_id, int $abs_delta, bool $is_deduct): ?int {
|
||||
global $wpdb;
|
||||
|
||||
// 确保 meta 行存在(首次扣费可能没有 row)
|
||||
$existing = get_user_meta($user_id, self::META_KEY, true);
|
||||
if ($existing === '' || $existing === false || $existing === null) {
|
||||
update_user_meta($user_id, self::META_KEY, 0);
|
||||
}
|
||||
|
||||
$meta_key = self::META_KEY;
|
||||
|
||||
if ($is_deduct) {
|
||||
// 扣减:仅当 meta_value >= cost 时才更新
|
||||
$sql = $wpdb->prepare(
|
||||
"UPDATE {$wpdb->usermeta}
|
||||
SET meta_value = CAST(meta_value AS SIGNED) - %d
|
||||
WHERE user_id = %d
|
||||
AND meta_key = %s
|
||||
AND CAST(meta_value AS SIGNED) >= %d",
|
||||
$abs_delta,
|
||||
$user_id,
|
||||
$meta_key,
|
||||
$abs_delta
|
||||
);
|
||||
} else {
|
||||
$sql = $wpdb->prepare(
|
||||
"UPDATE {$wpdb->usermeta}
|
||||
SET meta_value = CAST(meta_value AS SIGNED) + %d
|
||||
WHERE user_id = %d
|
||||
AND meta_key = %s",
|
||||
$abs_delta,
|
||||
$user_id,
|
||||
$meta_key
|
||||
);
|
||||
}
|
||||
|
||||
$affected = $wpdb->query($sql);
|
||||
if ($affected === false || $affected === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 失效 user meta cache,下次 get_user_meta 会重读
|
||||
wp_cache_delete($user_id, 'user_meta');
|
||||
|
||||
$new = (int) get_user_meta($user_id, self::META_KEY, true);
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写一条流水。
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param int $delta 本次变动(正/负/0)
|
||||
* @param int $balance_after 操作后余额
|
||||
* @param string $reason
|
||||
* @param array $ref
|
||||
*/
|
||||
public static function log(int $user_id, int $delta, int $balance_after, string $reason, array $ref = [], string $remark = ''): void {
|
||||
global $wpdb;
|
||||
|
||||
$table = \AKSRT\Core\DBMigrator::table('credit_log');
|
||||
if (!$table) return;
|
||||
|
||||
$ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : null;
|
||||
$ref_id = isset($ref['id']) ? (int) $ref['id'] : null;
|
||||
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
[
|
||||
'user_id' => $user_id,
|
||||
'delta' => $delta,
|
||||
'balance_after' => $balance_after,
|
||||
'reason' => substr($reason, 0, 64),
|
||||
'remark' => $remark !== '' ? substr($remark, 0, 255) : null,
|
||||
'ref_type' => $ref_type,
|
||||
'ref_id' => $ref_id,
|
||||
'ip' => self::client_ip(),
|
||||
'ua' => self::client_ua(),
|
||||
'created_at' => current_time('mysql', true),
|
||||
],
|
||||
['%d', '%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取流水(管理员页面用)。
|
||||
*
|
||||
* @param array $args { user_id, reason, page, per_page, date_from, date_to }
|
||||
* @return array { items, total, page, per_page }
|
||||
*/
|
||||
public static function get_log(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$table = \AKSRT\Core\DBMigrator::table('credit_log');
|
||||
if (!$table) {
|
||||
return ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20];
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
|
||||
$offset = ($page - 1) * $per_page;
|
||||
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
if (!empty($args['user_id'])) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = (int) $args['user_id'];
|
||||
}
|
||||
if (!empty($args['reason'])) {
|
||||
$where[] = 'reason = %s';
|
||||
$params[] = sanitize_key((string) $args['reason']);
|
||||
}
|
||||
if (!empty($args['date_from'])) {
|
||||
$where[] = 'created_at >= %s';
|
||||
$params[] = (string) $args['date_from'];
|
||||
}
|
||||
if (!empty($args['date_to'])) {
|
||||
$where[] = 'created_at <= %s';
|
||||
$params[] = (string) $args['date_to'];
|
||||
}
|
||||
|
||||
$where_sql = implode(' AND ', $where);
|
||||
|
||||
// total
|
||||
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||
$total = (int) ($params
|
||||
? $wpdb->get_var($wpdb->prepare($count_sql, $params))
|
||||
: $wpdb->get_var($count_sql));
|
||||
|
||||
// items
|
||||
$items_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d";
|
||||
$items_params = array_merge($params, [$per_page, $offset]);
|
||||
$items = $wpdb->get_results($wpdb->prepare($items_sql, $items_params), ARRAY_A);
|
||||
if (!is_array($items)) $items = [];
|
||||
|
||||
// user 名字预填
|
||||
$uid_set = array_unique(array_map(fn($r) => (int) $r['user_id'], $items));
|
||||
$names = [];
|
||||
foreach ($uid_set as $uid) {
|
||||
$u = get_userdata($uid);
|
||||
if ($u) $names[$uid] = $u->display_name;
|
||||
}
|
||||
foreach ($items as &$row) {
|
||||
$row['user_display_name'] = $names[(int) $row['user_id']] ?? '#' . $row['user_id'];
|
||||
$row['delta'] = (int) $row['delta'];
|
||||
$row['balance_after'] = (int) $row['balance_after'];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户余额列表(管理员)。
|
||||
*
|
||||
* @param array $args { search, page, per_page }
|
||||
* @return array
|
||||
*/
|
||||
public static function list_users(array $args = []): array {
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
|
||||
|
||||
$q = [
|
||||
'number' => $per_page,
|
||||
'paged' => $page,
|
||||
'orderby' => 'registered',
|
||||
'order' => 'DESC',
|
||||
];
|
||||
if (!empty($args['search'])) {
|
||||
$q['search'] = '*' . esc_attr((string) $args['search']) . '*';
|
||||
$q['search_columns'] = ['user_login', 'user_email', 'display_name', 'user_nicename'];
|
||||
}
|
||||
$user_query = new \WP_User_Query($q);
|
||||
$users = $user_query->get_results();
|
||||
$items = [];
|
||||
foreach ($users as $u) {
|
||||
$items[] = [
|
||||
'id' => (int) $u->ID,
|
||||
'login' => $u->user_login,
|
||||
'email' => $u->user_email,
|
||||
'display_name' => $u->display_name,
|
||||
'balance' => self::balance((int) $u->ID),
|
||||
'registered' => $u->user_registered,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => (int) $user_query->get_total(),
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
];
|
||||
}
|
||||
|
||||
// ---------- 工具方法 ----------
|
||||
|
||||
private static function client_ip(): string {
|
||||
if (function_exists('aksrt_get_client_ip')) {
|
||||
return (string) aksrt_get_client_ip();
|
||||
}
|
||||
if (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
return (string) $_SERVER['REMOTE_ADDR'];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function client_ua(): string {
|
||||
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
|
||||
return substr($ua, 0, 255);
|
||||
}
|
||||
}
|
||||
+18
-46
@@ -40,17 +40,10 @@ class PointsFree {
|
||||
*/
|
||||
public static function settings(): array {
|
||||
$all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
$credit = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_credit_settings() : [];
|
||||
|
||||
$tasks = [];
|
||||
foreach (self::catalog() as $key => $meta) {
|
||||
$flat_key = 'points_free_' . $key;
|
||||
if (array_key_exists($flat_key, $all)) {
|
||||
$tasks[$key] = max(0, (int) $all[$flat_key]);
|
||||
continue;
|
||||
}
|
||||
// Fallback mapping from legacy / parallel settings.
|
||||
$tasks[$key] = self::legacy_task_points($key, $all, $credit, (int) $meta[2]);
|
||||
$tasks[$key] = max(0, (int) ($all[$flat_key] ?? $meta[2]));
|
||||
}
|
||||
|
||||
$day_max = array_key_exists('points_earn_daily_limit', $all)
|
||||
@@ -75,7 +68,7 @@ class PointsFree {
|
||||
* Whether free points system can run (credits enabled).
|
||||
*/
|
||||
public static function enabled(): bool {
|
||||
return class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled() && class_exists('AKSRT\\Credits\\Credits');
|
||||
return class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,19 +136,31 @@ class PointsFree {
|
||||
}
|
||||
|
||||
$reason = 'free_' . $task_key;
|
||||
$result = Credits::add(
|
||||
$today = self::today_key();
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('points-free', [$user_id, $task_key, $today]);
|
||||
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$points,
|
||||
$operation_key,
|
||||
$reason,
|
||||
$ref,
|
||||
self::catalog()[$task_key][0] ?? $task_key
|
||||
self::catalog()[$task_key][0] ?? $task_key,
|
||||
0,
|
||||
['task' => $task_key, 'date' => $today],
|
||||
static function () use ($user_id, $points, $task_key) {
|
||||
if (self::remaining_today($user_id) < $points) {
|
||||
return new \WP_Error('aksrt_points_daily_limit', __('今日积分奖励已达上限', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
self::bump_detail($user_id, $points);
|
||||
self::mark_task_done_today($user_id, $task_key);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
self::bump_detail($user_id, $points);
|
||||
self::mark_task_done_today($user_id, $task_key);
|
||||
return $points;
|
||||
}
|
||||
|
||||
@@ -297,37 +302,4 @@ class PointsFree {
|
||||
update_user_meta($user_id, self::META_DETAIL, $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $all
|
||||
* @param array<string,mixed> $credit
|
||||
*/
|
||||
private static function legacy_task_points(string $key, array $all, array $credit, int $default): int {
|
||||
switch ($key) {
|
||||
case 'sign_up':
|
||||
return max(0, (int) ($credit['register_bonus'] ?? $default));
|
||||
case 'sign_in':
|
||||
$signin = is_array($credit['daily_signin'] ?? null) ? $credit['daily_signin'] : [];
|
||||
if (isset($signin['enabled']) && empty($signin['enabled'])) {
|
||||
return 0;
|
||||
}
|
||||
return max(0, (int) ($signin['base'] ?? $default));
|
||||
case 'login':
|
||||
return max(0, (int) ($all['points_earn_login'] ?? $default));
|
||||
case 'post_new':
|
||||
return max(0, (int) ($all['points_earn_post'] ?? $default));
|
||||
case 'post_like':
|
||||
return max(0, (int) ($all['points_earn_like_received'] ?? $default));
|
||||
case 'comment_new':
|
||||
if (isset($all['points_earn_comment'])) {
|
||||
return max(0, (int) $all['points_earn_comment']);
|
||||
}
|
||||
$cr = is_array($credit['comment_reward'] ?? null) ? $credit['comment_reward'] : [];
|
||||
if (isset($cr['enabled']) && empty($cr['enabled'])) {
|
||||
return 0;
|
||||
}
|
||||
return max(0, (int) ($cr['amount'] ?? $default));
|
||||
default:
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
-64
@@ -27,33 +27,36 @@ class Signin {
|
||||
register_rest_route('aksrt/v1', '/signin/today', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_today'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_rest_permission'],
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/signin', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_signin'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_rest_permission'],
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/signin/history', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_history'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_rest_permission'],
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/signin/details', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_details_html'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'permission_callback' => [$this, 'check_rest_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function check_rest_permission(\WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', '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;
|
||||
}
|
||||
|
||||
/** GET /signin/today */
|
||||
public function rest_today(\WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
@@ -99,10 +102,6 @@ class Signin {
|
||||
}
|
||||
|
||||
$today_str = $this->today_date();
|
||||
if ($this->get_record($user_id, $today_str)) {
|
||||
return new \WP_Error('aksrt_signin_dup', __('今日已签到', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
// 人机验证(若启用)
|
||||
if (function_exists('aksrt_require_captcha')) {
|
||||
$body = $request->get_json_params();
|
||||
@@ -116,59 +115,75 @@ class Signin {
|
||||
}
|
||||
}
|
||||
|
||||
$current_streak = $this->compute_current_streak($user_id, $today_str);
|
||||
$new_streak = $current_streak + 1;
|
||||
$reward = $this->calc_reward($new_streak, $signin_cfg);
|
||||
$existing_record = $this->get_record($user_id, $today_str);
|
||||
if (is_array($existing_record)) {
|
||||
$new_streak = max(1, (int) ($existing_record['streak'] ?? 1));
|
||||
$reward = max(0, (int) ($existing_record['reward'] ?? 0));
|
||||
} else {
|
||||
$current_streak = $this->compute_current_streak($user_id, $today_str);
|
||||
$new_streak = $current_streak + 1;
|
||||
$reward = $this->calc_reward($new_streak, $signin_cfg);
|
||||
}
|
||||
|
||||
$table = \AKSRT\Core\DBMigrator::table('signin_log');
|
||||
if (!$table) {
|
||||
return new \WP_Error('aksrt_signin_db', __('数据表不存在', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$inserted = $wpdb->insert(
|
||||
$table,
|
||||
[
|
||||
'user_id' => $user_id,
|
||||
'signin_date' => $today_str,
|
||||
'streak' => $new_streak,
|
||||
'reward' => $reward,
|
||||
],
|
||||
['%d', '%s', '%d', '%d']
|
||||
);
|
||||
if ($inserted === false) {
|
||||
return new \WP_Error('aksrt_signin_dup', __('签到失败', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('signin', [$user_id, $today_str]);
|
||||
$transaction_id = null;
|
||||
$replayed = false;
|
||||
if ($reward > 0) {
|
||||
$guard = static function () use ($wpdb, $table, $user_id, $today_str) {
|
||||
$existing = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE user_id = %d AND signin_date = %s FOR UPDATE",
|
||||
$user_id,
|
||||
$today_str
|
||||
));
|
||||
return $existing === null
|
||||
? true
|
||||
: new \WP_Error('aksrt_signin_dup', __('今日已签到', 'aksrt'), ['status' => 409]);
|
||||
};
|
||||
$mutation = static function () use ($wpdb, $table, $user_id, $today_str, $new_streak, $reward) {
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'user_id' => $user_id,
|
||||
'signin_date' => $today_str,
|
||||
'streak' => $new_streak,
|
||||
'reward' => $reward,
|
||||
], ['%d', '%s', '%d', '%d']);
|
||||
return $inserted === 1
|
||||
? true
|
||||
: new \WP_Error('aksrt_signin_db', __('签到记录写入失败', 'aksrt'), ['status' => 500]);
|
||||
};
|
||||
$ledger_result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$reward,
|
||||
$operation_key,
|
||||
'signin',
|
||||
['type' => 'signin', 'key' => $today_str],
|
||||
__('每日签到奖励', 'aksrt'),
|
||||
$user_id,
|
||||
['signin_date' => $today_str, 'streak' => $new_streak],
|
||||
$mutation,
|
||||
$guard
|
||||
);
|
||||
if (is_wp_error($ledger_result)) {
|
||||
return $ledger_result;
|
||||
}
|
||||
$transaction_id = $ledger_result->transaction_id;
|
||||
$replayed = $ledger_result->replayed;
|
||||
} else {
|
||||
$stored = $this->store_zero_reward_signin($user_id, $today_str, $new_streak);
|
||||
if (is_wp_error($stored)) {
|
||||
return $stored;
|
||||
}
|
||||
$replayed = $stored;
|
||||
}
|
||||
|
||||
do_action('aksrt_user_signed_in', $user_id, $reward);
|
||||
|
||||
if ($reward > 0) {
|
||||
if (class_exists('AKSRT\\Credits\\PointsFree')) {
|
||||
$granted = PointsFree::grant_amount($user_id, $reward, 'sign_in', ['type' => 'user', 'id' => $user_id]);
|
||||
if (is_wp_error($granted)) {
|
||||
if (class_exists('AKSRT\\Credits\\Credits')) {
|
||||
Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
|
||||
}
|
||||
} else {
|
||||
$reward = (int) $granted;
|
||||
}
|
||||
} elseif (class_exists('AKSRT\\Credits\\Credits')) {
|
||||
Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep lightweight meta for header/badge consumers (legacy checkin_all_day).
|
||||
update_user_meta($user_id, 'aksrt_signin_' . $today_str, 1);
|
||||
$all_day = (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true);
|
||||
if ($all_day <= 0) {
|
||||
// Fallback to legacy key if migrated users only have that meta.
|
||||
$all_day = (int) get_user_meta($user_id, 'checkin_all_day', true);
|
||||
}
|
||||
$all_day = $all_day + 1;
|
||||
update_user_meta($user_id, 'aksrt_checkin_all_day', $all_day);
|
||||
update_user_meta($user_id, 'checkin_all_day', $all_day); // legacy alias
|
||||
update_user_meta($user_id, 'aksrt_checkin_continuous_day', $new_streak);
|
||||
update_user_meta($user_id, 'checkin_continuous_day', $new_streak); // legacy alias
|
||||
do_action('aksrt_checkin_done', $user_id);
|
||||
if (!$replayed) {
|
||||
$this->after_signin_committed($user_id, $today_str, $new_streak, $reward);
|
||||
}
|
||||
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
@@ -180,10 +195,49 @@ class Signin {
|
||||
),
|
||||
'streak' => $new_streak,
|
||||
'reward' => $reward,
|
||||
'balance' => class_exists('AKSRT\Credits\Credits') ? Credits::balance($user_id) : 0,
|
||||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'transaction_id' => $transaction_id,
|
||||
'replayed' => $replayed,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/** @return bool|\WP_Error True means the existing row was replayed. */
|
||||
private function store_zero_reward_signin(int $user_id, string $date, int $streak) {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('signin_log');
|
||||
if ($wpdb->query('START TRANSACTION') === false) {
|
||||
return new \WP_Error('aksrt_signin_db', __('无法开启签到事务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$existing = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE user_id = %d AND signin_date = %s FOR UPDATE",
|
||||
$user_id,
|
||||
$date
|
||||
));
|
||||
if ($existing !== null) {
|
||||
$wpdb->query('COMMIT');
|
||||
return true;
|
||||
}
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'user_id' => $user_id,
|
||||
'signin_date' => $date,
|
||||
'streak' => $streak,
|
||||
'reward' => 0,
|
||||
], ['%d', '%s', '%d', '%d']);
|
||||
if ($inserted !== 1 || $wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_signin_db', __('签到记录写入失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function after_signin_committed(int $user_id, string $date, int $streak, int $reward): void {
|
||||
update_user_meta($user_id, 'aksrt_signin_' . $date, 1);
|
||||
update_user_meta($user_id, 'aksrt_checkin_all_day', $this->get_total_days($user_id));
|
||||
update_user_meta($user_id, 'aksrt_checkin_continuous_day', $streak);
|
||||
do_action('aksrt_user_signed_in', $user_id, $reward);
|
||||
do_action('aksrt_checkin_done', $user_id);
|
||||
}
|
||||
|
||||
/** GET /signin/history — last N days for calendar/modals. */
|
||||
public function rest_history(\WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
@@ -260,7 +314,7 @@ class Signin {
|
||||
$rec = $this->get_record($user_id, $today);
|
||||
$streak = $this->compute_current_streak($user_id, $today);
|
||||
$total = $this->get_total_days($user_id);
|
||||
$balance = class_exists('\AKSRT\Credits\Credits') ? (int) Credits::balance($user_id) : 0;
|
||||
$balance = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS);
|
||||
$currency = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_currency_name()
|
||||
: __('积分', 'aksrt');
|
||||
|
||||
@@ -48,6 +48,7 @@ final class ApplicationRepository {
|
||||
$row = wp_parse_args($data, [
|
||||
'application_uuid' => wp_generate_uuid4(),
|
||||
'client_request_id' => wp_generate_uuid4(),
|
||||
'request_payload_hash' => '',
|
||||
'user_id' => 0,
|
||||
'type' => 'personal',
|
||||
'channel' => 'cloud',
|
||||
@@ -55,7 +56,7 @@ final class ApplicationRepository {
|
||||
'status' => 'created',
|
||||
'public_name' => '',
|
||||
'public_desc' => '',
|
||||
'fee_amount' => 0,
|
||||
'fee_amount' => '0.00',
|
||||
'fee_status' => 'not_required',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
@@ -75,6 +76,13 @@ final class ApplicationRepository {
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find_by_id_for_update(int $id): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE id = %d FOR UPDATE", $id), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find_by_uuid(string $uuid): ?array {
|
||||
global $wpdb;
|
||||
@@ -164,12 +172,6 @@ final class ApplicationRepository {
|
||||
$formats[$index] = '%d';
|
||||
}
|
||||
}
|
||||
foreach (['fee_amount'] as $float_key) {
|
||||
$index = array_search($float_key, array_keys($fields), true);
|
||||
if ($index !== false) {
|
||||
$formats[$index] = '%f';
|
||||
}
|
||||
}
|
||||
$updated = $wpdb->update($this->table, $fields, ['id' => $id, 'status' => $expected], $formats, ['%d', '%s']) === 1;
|
||||
if ($updated) {
|
||||
self::clear_snapshot_cache();
|
||||
@@ -229,7 +231,11 @@ final class ApplicationRepository {
|
||||
$row[$nullable_key] = '';
|
||||
}
|
||||
}
|
||||
$row['fee_amount'] = round((float) ($row['fee_amount'] ?? 0), 2);
|
||||
try {
|
||||
$row['fee_amount'] = \AKSRT\Ledger\AssetAmount::balance((string) ($row['fee_amount'] ?? ''))->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$row['fee_amount'] = '';
|
||||
}
|
||||
$evidence = json_decode((string) ($row['evidence_ids'] ?? ''), true);
|
||||
$row['evidence_ids'] = is_array($evidence) ? array_values(array_map('intval', $evidence)) : [];
|
||||
return $row;
|
||||
|
||||
+284
-140
@@ -9,6 +9,11 @@ namespace AKSRT\Identity;
|
||||
|
||||
use AKSRT\Identity\Provider\ProviderFactory;
|
||||
use AKSRT\Identity\Provider\ProviderResult;
|
||||
use AKSRT\Ledger\AssetAmount;
|
||||
use AKSRT\Ledger\AssetType;
|
||||
use AKSRT\Ledger\LedgerResult;
|
||||
use AKSRT\Ledger\LedgerService;
|
||||
use AKSRT\Ledger\OperationKey;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
@@ -25,7 +30,7 @@ final class ApplicationService {
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function create(int $user_id, array $input) {
|
||||
public function create(int $user_id, array $input, string $request_id, string $operation_key) {
|
||||
$settings = SettingsRuntime::get();
|
||||
if (empty($settings['enabled']) || empty($settings['allow_apply'])) {
|
||||
return new \WP_Error('aksrt_identity_disabled', __('暂不能申请身份认证', 'aksrt'));
|
||||
@@ -33,25 +38,14 @@ final class ApplicationService {
|
||||
if (!is_ssl()) {
|
||||
return new \WP_Error('aksrt_identity_https', __('身份认证必须通过 HTTPS 发起', 'aksrt'));
|
||||
}
|
||||
if ($this->applications->verified_for_user($user_id)) {
|
||||
return new \WP_Error('aksrt_identity_verified', __('您已是认证用户', 'aksrt'));
|
||||
}
|
||||
if ($this->applications->active_for_user($user_id)) {
|
||||
return new \WP_Error('aksrt_identity_active', __('您已有进行中的认证申请', 'aksrt'));
|
||||
}
|
||||
|
||||
$type = sanitize_key((string) ($input['type'] ?? ''));
|
||||
if (!in_array($type, ['personal', 'enterprise'], true)) {
|
||||
return new \WP_Error('aksrt_identity_type', __('请选择有效的认证类型', 'aksrt'));
|
||||
}
|
||||
$request_id = sanitize_text_field((string) ($input['client_request_id'] ?? ''));
|
||||
if (!wp_is_uuid($request_id)) {
|
||||
$request_id = strtolower(trim($request_id));
|
||||
if (!wp_is_uuid($request_id) || $operation_key === '') {
|
||||
return new \WP_Error('aksrt_identity_request_id', __('请求标识无效', 'aksrt'));
|
||||
}
|
||||
$existing = $this->applications->find_by_client_request($user_id, $request_id);
|
||||
if ($existing) {
|
||||
return $this->view($existing);
|
||||
}
|
||||
|
||||
$person_name = sanitize_text_field((string) ($input['person_name'] ?? ''));
|
||||
$id_number = strtoupper(preg_replace('/\s+/', '', sanitize_text_field((string) ($input['id_number'] ?? ''))) ?: '');
|
||||
@@ -66,16 +60,41 @@ final class ApplicationService {
|
||||
if (empty($input['consent'])) {
|
||||
return new \WP_Error('aksrt_identity_consent', __('请先同意身份信息处理说明', 'aksrt'));
|
||||
}
|
||||
$request_payload_hash = $this->request_payload_hash($type, $person_name, $id_number, $company_name, $company_code);
|
||||
$existing = $this->applications->find_by_client_request($user_id, $request_id);
|
||||
if ($existing) {
|
||||
if (!hash_equals((string) ($existing['request_payload_hash'] ?? ''), $request_payload_hash)) {
|
||||
return new \WP_Error('aksrt_identity_idempotency_conflict', __('相同操作标识已用于其他认证申请', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if ((string) ($existing['status'] ?? '') !== 'created') {
|
||||
return $this->view($existing, LedgerService::get_instance()->result_for_operation($operation_key), true);
|
||||
}
|
||||
}
|
||||
if (!$existing && $this->applications->verified_for_user($user_id)) {
|
||||
return new \WP_Error('aksrt_identity_verified', __('您已是认证用户', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if (!$existing && $this->applications->active_for_user($user_id)) {
|
||||
return new \WP_Error('aksrt_identity_active', __('您已有进行中的认证申请', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$public_name = $type === 'enterprise' ? $this->truncate($company_name, 64) : __('个人认证', 'aksrt');
|
||||
$public_desc = '';
|
||||
|
||||
$type_settings = \AKSRT\User\AuthApply::type_settings($type);
|
||||
$attempt = $this->applications->count_user_type($user_id, $type);
|
||||
$free = max(0, (int) ($type_settings['free_applications'] ?? 0));
|
||||
$fee = $attempt < $free ? 0.0 : max(0, (float) ($type_settings['fee'] ?? 0));
|
||||
$application = $this->applications->create([
|
||||
if ($existing) {
|
||||
$application = $existing;
|
||||
} else {
|
||||
$type_settings = \AKSRT\User\AuthApply::type_settings($type);
|
||||
$attempt = $this->applications->count_user_type($user_id, $type);
|
||||
$free = max(0, (int) ($type_settings['free_applications'] ?? 0));
|
||||
try {
|
||||
$configured_fee = AssetAmount::balance(is_string($type_settings['fee'] ?? null) ? trim($type_settings['fee']) : '');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_identity_fee', __('认证费用配置无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$fee = $attempt < $free ? AssetAmount::balance('0.00') : $configured_fee;
|
||||
$application = $this->applications->create([
|
||||
'application_uuid' => wp_generate_uuid4(),
|
||||
'client_request_id' => $request_id,
|
||||
'request_payload_hash' => $request_payload_hash,
|
||||
'user_id' => $user_id,
|
||||
'type' => $type,
|
||||
'provider' => $settings['provider'],
|
||||
@@ -88,22 +107,27 @@ final class ApplicationService {
|
||||
'company_name' => $company_name,
|
||||
'company_code_masked' => $type === 'enterprise' ? SettingsRuntime::mask_id_number($company_code) : '',
|
||||
'company_code_hash' => $type === 'enterprise' ? SettingsRuntime::hmac($company_code) : '',
|
||||
'fee_amount' => $fee,
|
||||
'fee_status' => $fee > 0 ? 'pending' : 'not_required',
|
||||
'fee_amount' => $fee->decimal(),
|
||||
'fee_status' => $fee->minor() > 0 ? 'pending' : 'not_required',
|
||||
'attempt_ordinal' => $attempt + 1,
|
||||
]);
|
||||
]);
|
||||
}
|
||||
if (is_wp_error($application)) {
|
||||
return $application;
|
||||
}
|
||||
|
||||
$provider = ProviderFactory::make((string) $settings['provider']);
|
||||
$provider = ProviderFactory::make((string) $application['provider']);
|
||||
$valid = $provider->validate_config();
|
||||
if (is_wp_error($valid)) {
|
||||
$next = !empty($settings['allow_manual_fallback']) ? 'provider_unsupported' : 'cancelled';
|
||||
$failed = $this->transitions->transition($application, $next, [
|
||||
$failed = $this->transitions->transition($application, $next, [
|
||||
'provider_result_code' => $valid->get_error_code(),
|
||||
'provider_result_summary' => $valid->get_error_message(),
|
||||
], ['source' => 'system', 'event_type' => 'provider_preflight_failed']);
|
||||
], [
|
||||
'source' => 'system',
|
||||
'event_type' => 'provider_preflight_failed',
|
||||
'dedupe_key' => $this->event_dedupe_key($application, $operation_key, $next),
|
||||
]);
|
||||
return is_wp_error($failed) ? $failed : $this->view($failed);
|
||||
}
|
||||
|
||||
@@ -117,29 +141,52 @@ final class ApplicationService {
|
||||
$result = $type === 'personal'
|
||||
? $provider->start_personal($provider_request)
|
||||
: $provider->start_enterprise($provider_request);
|
||||
return $this->apply_provider_result($application, $result, true);
|
||||
return $this->apply_provider_result($application, $result, true, $operation_key);
|
||||
}
|
||||
|
||||
public function refresh(array $application) {
|
||||
public function refresh(array $application, string $operation_key = '') {
|
||||
if (!in_array((string) $application['status'], ['provider_pending', 'provider_processing'], true)) {
|
||||
return $this->view($application);
|
||||
}
|
||||
if (!empty($application['expires_at']) && strtotime((string) $application['expires_at'] . ' UTC') <= time()) {
|
||||
$settings = SettingsRuntime::get();
|
||||
$next = !empty($settings['allow_manual_fallback']) ? 'provider_expired' : 'cancelled';
|
||||
$expired = $this->transitions->transition($application, $next, [
|
||||
$effective_key = $operation_key !== ''
|
||||
? $operation_key
|
||||
: OperationKey::deterministic('identity-expired', [(int) $application['id'], (string) $application['expires_at']]);
|
||||
$expired = $this->transitions->transition($application, $next, [
|
||||
'provider_result_code' => 'session_expired',
|
||||
'provider_result_summary' => __('云认证会话已过期', 'aksrt'),
|
||||
], ['source' => 'system', 'event_type' => 'session_expired']);
|
||||
], [
|
||||
'source' => 'system',
|
||||
'event_type' => 'session_expired',
|
||||
'dedupe_key' => $this->event_dedupe_key($application, $effective_key, $next),
|
||||
]);
|
||||
return is_wp_error($expired) ? $expired : $this->view($expired);
|
||||
}
|
||||
$provider = ProviderFactory::make((string) $application['provider']);
|
||||
return $this->apply_provider_result($application, $provider->query($application), false);
|
||||
return $this->apply_provider_result($application, $provider->query($application), false, $operation_key);
|
||||
}
|
||||
|
||||
public function move_to_manual(array $application, $files) {
|
||||
public function move_to_manual(array $application, $files, string $operation_key) {
|
||||
$payload_hash = $this->evidence->fingerprint($files);
|
||||
if (is_wp_error($payload_hash)) {
|
||||
return $payload_hash;
|
||||
}
|
||||
$manual_dedupe = $this->event_dedupe_key($application, $operation_key, 'manual_pending');
|
||||
$existing_event = (new EventRepository())->find_by_dedupe($manual_dedupe);
|
||||
if (is_array($existing_event) && (int) $existing_event['application_id'] === (int) $application['id']) {
|
||||
$summary = json_decode((string) ($existing_event['summary_json'] ?? ''), true);
|
||||
if (!is_array($summary) || !hash_equals((string) ($summary['payload_hash'] ?? ''), $payload_hash)) {
|
||||
return new \WP_Error('aksrt_identity_idempotency_conflict', __('相同操作标识已用于其他认证材料', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$current = $this->applications->find_by_id((int) $application['id']);
|
||||
return is_array($current)
|
||||
? $this->view($current, LedgerService::get_instance()->result_for_operation($operation_key), true)
|
||||
: new \WP_Error('aksrt_identity_missing', __('认证申请不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!in_array((string) $application['status'], ['created', 'provider_failed', 'provider_unsupported', 'provider_expired'], true)) {
|
||||
return new \WP_Error('aksrt_identity_manual_state', __('该申请当前不能转人工审核', 'aksrt'));
|
||||
return new \WP_Error('aksrt_identity_manual_state', __('该申请当前不能转人工审核', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if (empty(SettingsRuntime::get()['allow_manual_fallback'])) {
|
||||
return new \WP_Error('aksrt_identity_manual_disabled', __('站点未开放人工审核回退', 'aksrt'));
|
||||
@@ -148,27 +195,32 @@ final class ApplicationService {
|
||||
if (is_wp_error($ids)) {
|
||||
return $ids;
|
||||
}
|
||||
$charge = $this->charge($application);
|
||||
if (is_wp_error($charge)) {
|
||||
$fields = [
|
||||
'channel' => 'manual',
|
||||
'manual_reason' => __('云认证未完成,用户转入人工审核', 'aksrt'),
|
||||
'evidence_ids' => wp_json_encode($ids),
|
||||
];
|
||||
$event = [
|
||||
'source' => 'user',
|
||||
'event_type' => 'manual_fallback',
|
||||
'actor_user_id' => (int) $application['user_id'],
|
||||
'dedupe_key' => $manual_dedupe,
|
||||
'summary' => ['payload_hash' => $payload_hash],
|
||||
];
|
||||
$changed = $this->transition_with_charge($application, 'manual_pending', $fields, $event, $operation_key, ['business_payload_hash' => $payload_hash]);
|
||||
if (is_wp_error($changed)) {
|
||||
$this->evidence->delete($ids);
|
||||
return $charge;
|
||||
return $changed;
|
||||
}
|
||||
$updated = $this->transitions->transition($application, 'manual_pending', [
|
||||
'channel' => 'manual',
|
||||
'manual_reason' => __('云认证未完成,用户转入人工审核', 'aksrt'),
|
||||
'evidence_ids' => wp_json_encode($ids),
|
||||
'fee_status' => $charge,
|
||||
], ['source' => 'user', 'event_type' => 'manual_fallback', 'actor_user_id' => (int) $application['user_id']]);
|
||||
if (is_wp_error($updated)) {
|
||||
$this->evidence->delete($ids);
|
||||
$this->refund($application);
|
||||
return $updated;
|
||||
}
|
||||
return $this->view($updated);
|
||||
$updated = $changed['application'];
|
||||
if ((array) ($updated['evidence_ids'] ?? []) !== array_values(array_map('intval', $ids))) {
|
||||
$this->evidence->delete($ids);
|
||||
}
|
||||
return $this->view($updated, $changed['ledger']);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function view(array $application): array {
|
||||
public function view(array $application, ?LedgerResult $ledger_result = null, bool $replayed = false): array {
|
||||
$stage = (string) $application['status'];
|
||||
$next = 'wait';
|
||||
if (in_array($stage, ['provider_pending', 'provider_processing'], true)) {
|
||||
@@ -178,7 +230,8 @@ final class ApplicationService {
|
||||
} elseif ($stage === 'verified') {
|
||||
$next = 'complete';
|
||||
}
|
||||
return [
|
||||
$balance_minor = LedgerService::get_instance()->balance((int) $application['user_id'], AssetType::BALANCE);
|
||||
return [
|
||||
'uuid' => (string) $application['application_uuid'],
|
||||
'type' => (string) $application['type'],
|
||||
'status' => TransitionService::public_status($stage),
|
||||
@@ -192,103 +245,194 @@ final class ApplicationService {
|
||||
'expires_at' => (string) ($application['expires_at'] ?? ''),
|
||||
'created_at' => (string) $application['created_at'],
|
||||
'poll_interval' => (int) SettingsRuntime::get()['poll_interval_seconds'],
|
||||
'transaction_id' => $ledger_result?->transaction_id ?? 0,
|
||||
'replayed' => $ledger_result?->replayed ?? $replayed,
|
||||
'wallet_balance' => AssetAmount::from_minor(AssetType::BALANCE, $balance_minor)->decimal(),
|
||||
];
|
||||
}
|
||||
|
||||
private function apply_provider_result(array $application, ProviderResult $result, bool $starting) {
|
||||
$data = $result->data;
|
||||
if ($result->decision === ProviderResult::PENDING) {
|
||||
$fee_status = (string) $application['fee_status'];
|
||||
if ($starting) {
|
||||
$charged = $this->charge($application);
|
||||
if (is_wp_error($charged)) {
|
||||
return $charged;
|
||||
}
|
||||
$fee_status = $charged;
|
||||
}
|
||||
$next = $starting ? 'provider_pending' : 'provider_processing';
|
||||
$fields = [
|
||||
'provider_session_id' => sanitize_text_field((string) ($data['provider_session_id'] ?? ($application['provider_session_id'] ?? ''))),
|
||||
'provider_request_id' => sanitize_text_field((string) ($data['request_id'] ?? ($application['provider_request_id'] ?? ''))),
|
||||
'redirect_url' => esc_url_raw((string) ($data['redirect_url'] ?? ($application['redirect_url'] ?? ''))),
|
||||
'qr_payload' => sanitize_text_field((string) ($data['qr_payload'] ?? ($application['qr_payload'] ?? ''))),
|
||||
'provider_started_at' => $starting ? current_time('mysql', true) : ($application['provider_started_at'] ?? ''),
|
||||
'expires_at' => (string) ($data['expires_at'] ?? ($application['expires_at'] ?? '')),
|
||||
'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? 'pending')),
|
||||
'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')),
|
||||
'fee_status' => $fee_status,
|
||||
];
|
||||
$updated = $this->transitions->transition($application, $next, $fields, [
|
||||
'source' => (string) $application['provider'],
|
||||
'event_type' => $starting ? 'session_started' : 'query_pending',
|
||||
'external_event_id' => (string) ($data['external_event_id'] ?? ''),
|
||||
]);
|
||||
if (is_wp_error($updated)) {
|
||||
if ($starting) {
|
||||
$this->refund(array_merge($application, ['fee_status' => $fee_status]));
|
||||
}
|
||||
return $updated;
|
||||
}
|
||||
$updated['redirect_url'] = (string) ($updated['redirect_url'] ?? '');
|
||||
$updated['qr_payload'] = (string) ($updated['qr_payload'] ?? '');
|
||||
return $this->view($updated);
|
||||
}
|
||||
private function apply_provider_result(array $application, ProviderResult $result, bool $starting, string $operation_key = '') {
|
||||
$data = $result->data;
|
||||
if ($result->decision === ProviderResult::PENDING) {
|
||||
$next = $starting ? 'provider_pending' : 'provider_processing';
|
||||
if (!$starting && (string) $application['status'] === $next) {
|
||||
return $this->view($application, null, true);
|
||||
}
|
||||
$effective_key = $this->provider_operation_key($application, $operation_key, $next, $data, $result->decision);
|
||||
$fields = [
|
||||
'provider_session_id' => sanitize_text_field((string) ($data['provider_session_id'] ?? ($application['provider_session_id'] ?? ''))),
|
||||
'provider_request_id' => sanitize_text_field((string) ($data['request_id'] ?? ($application['provider_request_id'] ?? ''))),
|
||||
'redirect_url' => esc_url_raw((string) ($data['redirect_url'] ?? ($application['redirect_url'] ?? ''))),
|
||||
'qr_payload' => sanitize_text_field((string) ($data['qr_payload'] ?? ($application['qr_payload'] ?? ''))),
|
||||
'provider_started_at' => $starting ? current_time('mysql', true) : ($application['provider_started_at'] ?? ''),
|
||||
'expires_at' => (string) ($data['expires_at'] ?? ($application['expires_at'] ?? '')),
|
||||
'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? 'pending')),
|
||||
'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')),
|
||||
];
|
||||
$event = [
|
||||
'source' => (string) $application['provider'],
|
||||
'event_type' => $starting ? 'session_started' : 'query_pending',
|
||||
'external_event_id' => (string) ($data['external_event_id'] ?? ''),
|
||||
'dedupe_key' => $this->event_dedupe_key($application, $effective_key, $next),
|
||||
];
|
||||
$changed = $starting
|
||||
? $this->transition_with_charge($application, $next, $fields, $event, $effective_key)
|
||||
: $this->transition_without_charge($application, $next, $fields, $event);
|
||||
if (is_wp_error($changed)) {
|
||||
return $changed;
|
||||
}
|
||||
$updated = $changed['application'];
|
||||
$updated['redirect_url'] = (string) ($updated['redirect_url'] ?? '');
|
||||
$updated['qr_payload'] = (string) ($updated['qr_payload'] ?? '');
|
||||
return $this->view($updated, $changed['ledger']);
|
||||
}
|
||||
|
||||
$map = [
|
||||
ProviderResult::PASSED => 'verified',
|
||||
ProviderResult::FAILED => 'provider_failed',
|
||||
ProviderResult::UNSUPPORTED => 'provider_unsupported',
|
||||
ProviderResult::EXPIRED => 'provider_expired',
|
||||
];
|
||||
$next = $map[$result->decision] ?? 'provider_failed';
|
||||
$settings = SettingsRuntime::get();
|
||||
if (in_array($next, ['provider_failed', 'provider_unsupported', 'provider_expired'], true) && empty($settings['allow_manual_fallback'])) {
|
||||
$next = 'cancelled';
|
||||
}
|
||||
$updated = $this->transitions->transition($application, $next, [
|
||||
'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? $result->decision)),
|
||||
'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')),
|
||||
], [
|
||||
'source' => (string) $application['provider'],
|
||||
'event_type' => 'provider_result',
|
||||
'external_event_id' => (string) ($data['external_event_id'] ?? ''),
|
||||
'result_code' => (string) ($data['result_code'] ?? $result->decision),
|
||||
]);
|
||||
return is_wp_error($updated) ? $updated : $this->view($updated);
|
||||
}
|
||||
$map = [
|
||||
ProviderResult::PASSED => 'verified',
|
||||
ProviderResult::FAILED => 'provider_failed',
|
||||
ProviderResult::UNSUPPORTED => 'provider_unsupported',
|
||||
ProviderResult::EXPIRED => 'provider_expired',
|
||||
];
|
||||
$next = $map[$result->decision] ?? 'provider_failed';
|
||||
$settings = SettingsRuntime::get();
|
||||
if (in_array($next, ['provider_failed', 'provider_unsupported', 'provider_expired'], true) && empty($settings['allow_manual_fallback'])) {
|
||||
$next = 'cancelled';
|
||||
}
|
||||
$effective_key = $this->provider_operation_key($application, $operation_key, $next, $data, $result->decision);
|
||||
$fields = [
|
||||
'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? $result->decision)),
|
||||
'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')),
|
||||
];
|
||||
$event = [
|
||||
'source' => (string) $application['provider'],
|
||||
'event_type' => 'provider_result',
|
||||
'external_event_id' => (string) ($data['external_event_id'] ?? ''),
|
||||
'result_code' => (string) ($data['result_code'] ?? $result->decision),
|
||||
'dedupe_key' => $this->event_dedupe_key($application, $effective_key, $next),
|
||||
];
|
||||
$changed = $starting && $next === 'verified'
|
||||
? $this->transition_with_charge($application, $next, $fields, $event, $effective_key)
|
||||
: $this->transition_without_charge($application, $next, $fields, $event);
|
||||
return is_wp_error($changed) ? $changed : $this->view($changed['application'], $changed['ledger']);
|
||||
}
|
||||
|
||||
/** @return string|\WP_Error */
|
||||
private function charge(array $application) {
|
||||
if (in_array((string) $application['fee_status'], ['charged', 'not_required'], true)) {
|
||||
return (string) $application['fee_status'];
|
||||
}
|
||||
$fee = (float) $application['fee_amount'];
|
||||
if ($fee <= 0) {
|
||||
return 'not_required';
|
||||
}
|
||||
$result = \AKSRT\Credits\Balance::deduct(
|
||||
(int) $application['user_id'],
|
||||
$fee,
|
||||
'verify_application',
|
||||
['type' => 'identity', 'id' => (int) $application['id']],
|
||||
sprintf(__('提交%s认证申请', 'aksrt'), (string) $application['type'])
|
||||
);
|
||||
return is_wp_error($result) ? $result : 'charged';
|
||||
}
|
||||
/**
|
||||
* @param array<string,mixed> $application
|
||||
* @param array<string,mixed> $fields
|
||||
* @param array<string,mixed> $event
|
||||
* @return array{application:array<string,mixed>,ledger:?LedgerResult}|\WP_Error
|
||||
*/
|
||||
private function transition_with_charge(array $application, string $next, array $fields, array $event, string $operation_key, array $ledger_metadata = []) {
|
||||
$fee_status = (string) ($application['fee_status'] ?? '');
|
||||
if (in_array($fee_status, ['charged', 'not_required'], true)) {
|
||||
$fields['fee_status'] = $fee_status;
|
||||
return $this->transition_without_charge($application, $next, $fields, $event);
|
||||
}
|
||||
try {
|
||||
$fee = AssetAmount::balance((string) ($application['fee_amount'] ?? ''));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_identity_fee', __('认证费用配置无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if ($fee->minor() <= 0) {
|
||||
$fields['fee_status'] = 'not_required';
|
||||
return $this->transition_without_charge($application, $next, $fields, $event);
|
||||
}
|
||||
|
||||
private function refund(array $application): void {
|
||||
if ((float) $application['fee_amount'] <= 0 || !in_array((string) $application['fee_status'], ['charged', 'pending'], true)) {
|
||||
return;
|
||||
}
|
||||
\AKSRT\Credits\Balance::add(
|
||||
(int) $application['user_id'],
|
||||
(float) $application['fee_amount'],
|
||||
'verify_application_refund',
|
||||
['type' => 'identity', 'id' => (int) $application['id']],
|
||||
__('认证会话创建失败退款', 'aksrt')
|
||||
);
|
||||
$this->applications->update((int) $application['id'], ['fee_status' => 'refunded']);
|
||||
}
|
||||
$application_id = (int) $application['id'];
|
||||
$user_id = (int) $application['user_id'];
|
||||
$expected_status = (string) $application['status'];
|
||||
$expected_minor = $fee->minor();
|
||||
$locked_application = null;
|
||||
$guard = function () use ($application_id, $user_id, $expected_status, $expected_minor, &$locked_application) {
|
||||
$locked_application = $this->applications->find_by_id_for_update($application_id);
|
||||
try {
|
||||
$current_minor = is_array($locked_application)
|
||||
? AssetAmount::balance((string) ($locked_application['fee_amount'] ?? ''))->minor()
|
||||
: -1;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$current_minor = -1;
|
||||
}
|
||||
if (
|
||||
!is_array($locked_application)
|
||||
|| (int) $locked_application['user_id'] !== $user_id
|
||||
|| (string) $locked_application['status'] !== $expected_status
|
||||
|| (string) $locked_application['fee_status'] !== 'pending'
|
||||
|| $current_minor !== $expected_minor
|
||||
) {
|
||||
return new \WP_Error('aksrt_identity_fee_state', __('认证费用或申请状态已变化', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$mutation_fields = $fields;
|
||||
$mutation_fields['fee_status'] = 'charged';
|
||||
$mutation = function () use (&$locked_application, $next, $mutation_fields, $event) {
|
||||
if (!is_array($locked_application)) {
|
||||
return new \WP_Error('aksrt_identity_fee_state', __('认证申请未锁定', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return $this->transitions->transition_locked($locked_application, $next, $mutation_fields, $event);
|
||||
};
|
||||
$ledger_metadata['identity_type'] = (string) $application['type'];
|
||||
$ledger_metadata['business_payload_hash'] ??= (string) ($application['request_payload_hash'] ?? '');
|
||||
$result = LedgerService::get_instance()->debit(
|
||||
$user_id,
|
||||
AssetType::BALANCE,
|
||||
$fee->minor(),
|
||||
$operation_key !== '' ? $operation_key : OperationKey::deterministic('identity-fee', [$application_id]),
|
||||
'verify_application',
|
||||
['type' => 'identity', 'id' => $application_id],
|
||||
sprintf(__('提交%s认证申请', 'aksrt'), (string) $application['type']),
|
||||
$user_id,
|
||||
$ledger_metadata,
|
||||
$mutation,
|
||||
$guard
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
$updated = $this->applications->find_by_id($application_id);
|
||||
if (!is_array($updated)) {
|
||||
return new \WP_Error('aksrt_identity_missing', __('认证申请不存在', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (!$result->replayed) {
|
||||
$this->transitions->after_commit(is_array($locked_application) ? $locked_application : $application, $next, $mutation_fields);
|
||||
}
|
||||
return ['application' => $updated, 'ledger' => $result];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $application
|
||||
* @param array<string,mixed> $fields
|
||||
* @param array<string,mixed> $event
|
||||
* @return array{application:array<string,mixed>,ledger:null}|\WP_Error
|
||||
*/
|
||||
private function transition_without_charge(array $application, string $next, array $fields, array $event) {
|
||||
$updated = $this->transitions->transition($application, $next, $fields, $event);
|
||||
return is_wp_error($updated) ? $updated : ['application' => $updated, 'ledger' => null];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $application @param array<string,mixed> $data */
|
||||
private function provider_operation_key(array $application, string $operation_key, string $next, array $data, string $decision): string {
|
||||
if ($operation_key !== '') {
|
||||
return $operation_key;
|
||||
}
|
||||
$external = (string) ($data['external_event_id'] ?? $data['request_id'] ?? $data['result_code'] ?? $decision);
|
||||
return OperationKey::deterministic('identity-provider', [(int) $application['id'], $next, $external]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $application */
|
||||
private function event_dedupe_key(array $application, string $operation_key, string $next): string {
|
||||
return hash('sha256', implode('|', ['identity', (int) $application['id'], $operation_key, sanitize_key($next)]));
|
||||
}
|
||||
|
||||
private function request_payload_hash(string $type, string $person_name, string $id_number, string $company_name, string $company_code): string {
|
||||
return hash('sha256', (string) wp_json_encode([
|
||||
'type' => $type,
|
||||
'person_name' => $person_name,
|
||||
'id_number_hmac' => SettingsRuntime::hmac($id_number),
|
||||
'company_name' => $company_name,
|
||||
'company_code_hmac' => $company_code !== '' ? SettingsRuntime::hmac($company_code) : '',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
private function length(string $value): int {
|
||||
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
|
||||
|
||||
@@ -39,9 +39,26 @@ final class EventRepository {
|
||||
if ($inserted === 1) {
|
||||
return true;
|
||||
}
|
||||
return str_contains(strtolower((string) $wpdb->last_error), 'duplicate');
|
||||
if (!str_contains(strtolower((string) $wpdb->last_error), 'duplicate')) {
|
||||
return false;
|
||||
}
|
||||
$existing_application_id = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT application_id FROM {$this->table} WHERE dedupe_key = %s LIMIT 1",
|
||||
$row['dedupe_key']
|
||||
));
|
||||
return (int) $existing_application_id === (int) $row['application_id'];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find_by_dedupe(string $dedupe_key): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE dedupe_key = %s LIMIT 1",
|
||||
$dedupe_key
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
public function for_application(int $application_id): array {
|
||||
global $wpdb;
|
||||
|
||||
@@ -18,6 +18,27 @@ final class EvidenceService {
|
||||
add_action('admin_post_aksrt_verify_evidence', [$this, 'serve']);
|
||||
}
|
||||
|
||||
/** @return string|\WP_Error */
|
||||
public function fingerprint($input) {
|
||||
$files = $this->normalize($input);
|
||||
if (is_wp_error($files)) {
|
||||
return $files;
|
||||
}
|
||||
$hashes = [];
|
||||
foreach ($files as $file) {
|
||||
$tmp = (string) ($file['tmp_name'] ?? '');
|
||||
if ((int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || $tmp === '' || !is_uploaded_file($tmp)) {
|
||||
return new \WP_Error('aksrt_identity_upload', __('证明材料上传失败', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$digest = hash_file('sha256', $tmp);
|
||||
if (!is_string($digest)) {
|
||||
return new \WP_Error('aksrt_identity_upload', __('证明材料读取失败', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$hashes[] = $digest;
|
||||
}
|
||||
return hash('sha256', implode('|', $hashes));
|
||||
}
|
||||
|
||||
/** @return array<int,int>|\WP_Error */
|
||||
public function upload(int $user_id, string $application_uuid, $input) {
|
||||
$files = $this->normalize($input);
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
namespace AKSRT\Identity;
|
||||
|
||||
use AKSRT\Ledger\OperationKey;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class RestController {
|
||||
@@ -25,49 +27,86 @@ final class RestController {
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
$uuid_args = [
|
||||
'uuid' => [
|
||||
'type' => 'string',
|
||||
'format' => 'uuid',
|
||||
'required' => true,
|
||||
'validate_callback' => static fn($value): bool => wp_is_uuid((string) $value),
|
||||
'sanitize_callback' => static fn($value): string => strtolower(sanitize_text_field((string) $value)),
|
||||
],
|
||||
];
|
||||
register_rest_route(self::NS, '/identity/applications', [
|
||||
'methods' => 'POST',
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create'],
|
||||
'permission_callback' => [$this, 'logged_in'],
|
||||
'args' => [
|
||||
'type' => ['type' => 'string', 'required' => true, 'enum' => ['personal', 'enterprise'], 'sanitize_callback' => 'sanitize_key'],
|
||||
'person_name' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 50, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'id_number' => ['type' => 'string', 'required' => true, 'minLength' => 15, 'maxLength' => 18, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'company_name' => ['type' => 'string', 'required' => false, 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'company_code' => ['type' => 'string', 'required' => false, 'maxLength' => 32, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'consent' => ['type' => 'boolean', 'required' => true],
|
||||
'meta_info' => ['type' => 'string', 'required' => false, 'maxLength' => 65535, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
]);
|
||||
register_rest_route(self::NS, '/identity/applications/current', [
|
||||
'methods' => 'GET',
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'current'],
|
||||
'permission_callback' => [$this, 'logged_in'],
|
||||
]);
|
||||
register_rest_route(self::NS, '/identity/applications/(?P<uuid>[a-f0-9-]{36})', [
|
||||
'methods' => 'GET',
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get'],
|
||||
'permission_callback' => [$this, 'owns_application'],
|
||||
'args' => $uuid_args,
|
||||
]);
|
||||
register_rest_route(self::NS, '/identity/applications/(?P<uuid>[a-f0-9-]{36})/refresh', [
|
||||
'methods' => 'POST',
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'refresh'],
|
||||
'permission_callback' => [$this, 'owns_application'],
|
||||
'args' => $uuid_args,
|
||||
]);
|
||||
register_rest_route(self::NS, '/identity/applications/(?P<uuid>[a-f0-9-]{36})/manual', [
|
||||
'methods' => 'POST',
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'manual'],
|
||||
'permission_callback' => [$this, 'owns_application'],
|
||||
'args' => $uuid_args,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logged_in(): bool {
|
||||
return is_user_logged_in();
|
||||
public function logged_in(\WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_identity_auth', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
$nonce = (string) $request->get_header('X-WP-Nonce');
|
||||
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return new \WP_Error('aksrt_identity_nonce', __('安全校验失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function owns_application(\WP_REST_Request $request): bool {
|
||||
if (!is_user_logged_in()) {
|
||||
return false;
|
||||
public function owns_application(\WP_REST_Request $request) {
|
||||
$authenticated = $this->logged_in($request);
|
||||
if (is_wp_error($authenticated)) {
|
||||
return $authenticated;
|
||||
}
|
||||
$application = $this->application_for_request($request);
|
||||
return is_array($application) && (int) $application['user_id'] === get_current_user_id();
|
||||
if (!is_array($application) || (int) $application['user_id'] !== get_current_user_id()) {
|
||||
return new \WP_Error('aksrt_identity_forbidden', __('无权访问该认证申请', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $request->get_json_params();
|
||||
$params = is_array($params) ? $params : [];
|
||||
$result = $this->service->create(get_current_user_id(), $params);
|
||||
$operation_key = OperationKey::from_request($request, 'identity-create', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $this->respond($operation_key);
|
||||
}
|
||||
$request_id = strtolower(trim((string) $request->get_header('Idempotency-Key')));
|
||||
$result = $this->service->create(get_current_user_id(), $params, $request_id, $operation_key);
|
||||
return $this->respond($result, 201);
|
||||
}
|
||||
|
||||
@@ -100,7 +139,11 @@ final class RestController {
|
||||
'message' => __('认证申请不存在', 'aksrt'),
|
||||
], 404));
|
||||
}
|
||||
return $this->respond($this->service->refresh($application));
|
||||
$operation_key = OperationKey::from_request($request, 'identity-refresh', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $this->respond($operation_key);
|
||||
}
|
||||
return $this->respond($this->service->refresh($application, $operation_key));
|
||||
}
|
||||
|
||||
public function manual(\WP_REST_Request $request): \WP_REST_Response {
|
||||
@@ -112,7 +155,12 @@ final class RestController {
|
||||
'message' => __('认证申请不存在', 'aksrt'),
|
||||
], 404));
|
||||
}
|
||||
return $this->respond($this->service->move_to_manual($application, $_FILES['evidence'] ?? null));
|
||||
$operation_key = OperationKey::from_request($request, 'identity-manual', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $this->respond($operation_key);
|
||||
}
|
||||
$files = $request->get_file_params();
|
||||
return $this->respond($this->service->move_to_manual($application, $files['evidence'] ?? null, $operation_key));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
@@ -127,7 +175,11 @@ final class RestController {
|
||||
|
||||
private function respond($result, int $success_status = 200): \WP_REST_Response {
|
||||
if (is_wp_error($result)) {
|
||||
$status = in_array($result->get_error_code(), ['aksrt_identity_active', 'aksrt_identity_verified', 'aksrt_identity_conflict'], true) ? 409 : 422;
|
||||
$data = $result->get_error_data();
|
||||
$status = is_array($data) ? (int) ($data['status'] ?? 0) : 0;
|
||||
if ($status < 400 || $status > 599) {
|
||||
$status = in_array($result->get_error_code(), ['aksrt_identity_active', 'aksrt_identity_verified', 'aksrt_identity_conflict', 'aksrt_identity_idempotency_conflict'], true) ? 409 : 400;
|
||||
}
|
||||
return $this->no_store(new \WP_REST_Response([
|
||||
'success' => false,
|
||||
'code' => $result->get_error_code(),
|
||||
|
||||
@@ -32,45 +32,112 @@ final class TransitionService {
|
||||
|
||||
/** @param array<string,mixed> $fields @param array<string,mixed> $event */
|
||||
public function transition(array $application, string $next, array $fields = [], array $event = []) {
|
||||
$current = sanitize_key((string) ($application['status'] ?? ''));
|
||||
$next = sanitize_key($next);
|
||||
if (!in_array($next, self::ALLOWED[$current] ?? [], true)) {
|
||||
return new \WP_Error('aksrt_identity_transition', __('该认证记录当前不可执行此操作', 'aksrt'));
|
||||
}
|
||||
if (in_array($next, ['verified', 'rejected', 'cancelled'], true)) {
|
||||
$fields['completed_at'] = current_time('mysql', true);
|
||||
$fields['sensitive_blob'] = null;
|
||||
}
|
||||
if (!$this->applications->transition((int) $application['id'], $current, $next, $fields)) {
|
||||
return new \WP_Error('aksrt_identity_conflict', __('认证状态已变化,请刷新后重试', 'aksrt'));
|
||||
}
|
||||
$next = sanitize_key($next);
|
||||
$dedupe = isset($event['dedupe_key']) ? (string) $event['dedupe_key'] : '';
|
||||
if ($dedupe !== '') {
|
||||
$existing_event = $this->events->find_by_dedupe($dedupe);
|
||||
if (is_array($existing_event) && (int) $existing_event['application_id'] === (int) $application['id']) {
|
||||
return $this->applications->find_by_id((int) $application['id']);
|
||||
}
|
||||
}
|
||||
|
||||
$dedupe = (string) ($event['dedupe_key'] ?? hash('sha256', implode('|', [
|
||||
(string) $application['application_uuid'], $current, $next, wp_generate_uuid4(),
|
||||
])));
|
||||
$this->events->record([
|
||||
'application_id' => (int) $application['id'],
|
||||
'source' => (string) ($event['source'] ?? 'system'),
|
||||
'event_type' => (string) ($event['event_type'] ?? 'status_changed'),
|
||||
'external_event_id' => (string) ($event['external_event_id'] ?? ''),
|
||||
'dedupe_key' => $dedupe,
|
||||
'from_status' => $current,
|
||||
'to_status' => $next,
|
||||
'result_code' => (string) ($event['result_code'] ?? ''),
|
||||
'summary' => is_array($event['summary'] ?? null) ? $event['summary'] : [],
|
||||
'actor_user_id' => (int) ($event['actor_user_id'] ?? 0),
|
||||
]);
|
||||
if (\AKSRT\Ledger\LedgerService::get_instance()->transaction_active()) {
|
||||
return new \WP_Error('aksrt_identity_nested_transaction', __('认证状态不能开启嵌套事务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
if ($next === 'verified') {
|
||||
$this->send_notification((int) $application['user_id'], 'verified', '');
|
||||
} elseif ($next === 'rejected' || $next === 'cancelled') {
|
||||
$this->send_notification((int) $application['user_id'], $next, (string) ($fields['review_note'] ?? ''));
|
||||
}
|
||||
do_action('aksrt_user_auth_reviewed', (int) $application['user_id'], self::public_status($next), (string) ($fields['review_note'] ?? ''), (int) ($fields['reviewer_id'] ?? 0));
|
||||
global $wpdb;
|
||||
if ($wpdb->query('START TRANSACTION') === false) {
|
||||
return new \WP_Error('aksrt_identity_db', __('认证状态更新失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$locked = $this->applications->find_by_id_for_update((int) $application['id']);
|
||||
if (!is_array($locked)) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_identity_missing', __('认证申请不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if ($dedupe !== '') {
|
||||
$existing_event = $this->events->find_by_dedupe($dedupe);
|
||||
if (is_array($existing_event) && (int) $existing_event['application_id'] === (int) $locked['id']) {
|
||||
$wpdb->query('COMMIT');
|
||||
return $locked;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->applications->find_by_id((int) $application['id']);
|
||||
$changed = $this->transition_locked($locked, $next, $fields, $event);
|
||||
if (is_wp_error($changed)) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return $changed;
|
||||
}
|
||||
$updated = $this->applications->find_by_id((int) $locked['id']);
|
||||
if (!is_array($updated) || $wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_identity_db', __('认证状态更新失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$this->after_commit($locked, $next, $fields);
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an identity transition inside an already-owned database transaction.
|
||||
* The caller must lock the application row before invoking this method.
|
||||
*
|
||||
* @param array<string,mixed> $application
|
||||
* @param array<string,mixed> $fields
|
||||
* @param array<string,mixed> $event
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public function transition_locked(array $application, string $next, array $fields = [], array $event = []) {
|
||||
$current = sanitize_key((string) ($application['status'] ?? ''));
|
||||
$next = sanitize_key($next);
|
||||
if (!in_array($next, self::ALLOWED[$current] ?? [], true)) {
|
||||
return new \WP_Error('aksrt_identity_transition', __('该认证记录当前不可执行此操作', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if (in_array($next, ['verified', 'rejected', 'cancelled'], true)) {
|
||||
$fields['completed_at'] = current_time('mysql', true);
|
||||
$fields['sensitive_blob'] = null;
|
||||
}
|
||||
if (!$this->applications->transition((int) $application['id'], $current, $next, $fields)) {
|
||||
return new \WP_Error('aksrt_identity_conflict', __('认证状态已变化,请刷新后重试', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
$dedupe = (string) ($event['dedupe_key'] ?? hash('sha256', implode('|', [
|
||||
(string) $application['application_uuid'],
|
||||
$current,
|
||||
$next,
|
||||
wp_generate_uuid4(),
|
||||
])));
|
||||
$recorded = $this->events->record([
|
||||
'application_id' => (int) $application['id'],
|
||||
'source' => (string) ($event['source'] ?? 'system'),
|
||||
'event_type' => (string) ($event['event_type'] ?? 'status_changed'),
|
||||
'external_event_id' => (string) ($event['external_event_id'] ?? ''),
|
||||
'dedupe_key' => $dedupe,
|
||||
'from_status' => $current,
|
||||
'to_status' => $next,
|
||||
'result_code' => (string) ($event['result_code'] ?? ''),
|
||||
'summary' => is_array($event['summary'] ?? null) ? $event['summary'] : [],
|
||||
'actor_user_id' => (int) ($event['actor_user_id'] ?? 0),
|
||||
]);
|
||||
return $recorded
|
||||
? true
|
||||
: new \WP_Error('aksrt_identity_event', __('认证事件写入失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $application @param array<string,mixed> $fields */
|
||||
public function after_commit(array $application, string $next, array $fields = []): void {
|
||||
try {
|
||||
if ($next === 'verified') {
|
||||
$this->send_notification((int) $application['user_id'], 'verified', '');
|
||||
} elseif ($next === 'rejected' || $next === 'cancelled') {
|
||||
$this->send_notification((int) $application['user_id'], $next, (string) ($fields['review_note'] ?? ''));
|
||||
}
|
||||
do_action('aksrt_user_auth_reviewed', (int) $application['user_id'], self::public_status($next), (string) ($fields['review_note'] ?? ''), (int) ($fields['reviewer_id'] ?? 0));
|
||||
} catch (\Throwable $error) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[AKSRT Identity Event] ' . $error->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function public_status(string $status): string {
|
||||
if ($status === 'verified') {
|
||||
return 'verified';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ledger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class AssetAmount {
|
||||
private function __construct(
|
||||
private readonly AssetType $asset,
|
||||
private readonly int $minor
|
||||
) {}
|
||||
|
||||
public static function points(int $points): self {
|
||||
if ($points < 0) {
|
||||
throw new \InvalidArgumentException('points_must_be_non_negative');
|
||||
}
|
||||
return new self(AssetType::POINTS, $points);
|
||||
}
|
||||
|
||||
public static function balance(string $decimal): self {
|
||||
$decimal = trim($decimal);
|
||||
if (!preg_match('/^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/', $decimal, $matches)) {
|
||||
throw new \InvalidArgumentException('balance_decimal_invalid');
|
||||
}
|
||||
$whole = (int) $matches[1];
|
||||
$fraction = str_pad((string) ($matches[2] ?? ''), 2, '0');
|
||||
if ($whole > intdiv(PHP_INT_MAX - (int) $fraction, 100)) {
|
||||
throw new \InvalidArgumentException('balance_decimal_overflow');
|
||||
}
|
||||
return new self(AssetType::BALANCE, ($whole * 100) + (int) $fraction);
|
||||
}
|
||||
|
||||
public static function balance_minor(string $decimal): int {
|
||||
return self::balance($decimal)->minor();
|
||||
}
|
||||
|
||||
public static function decimal_from_minor(int $minor): string {
|
||||
return self::from_minor(AssetType::BALANCE, $minor)->decimal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an integer percentage using half-up rounding without floats.
|
||||
*/
|
||||
public static function percentage(int $minor, int $percent): int {
|
||||
if ($minor < 0) {
|
||||
throw new \InvalidArgumentException('asset_minor_must_be_non_negative');
|
||||
}
|
||||
if ($percent < 0 || $percent > 100) {
|
||||
throw new \InvalidArgumentException('asset_percentage_invalid');
|
||||
}
|
||||
|
||||
$whole = intdiv($minor, 100) * $percent;
|
||||
$remainder = ($minor % 100) * $percent;
|
||||
return $whole + intdiv($remainder + 50, 100);
|
||||
}
|
||||
|
||||
public static function from_minor(AssetType|string $asset, int $minor): self {
|
||||
if ($minor < 0) {
|
||||
throw new \InvalidArgumentException('asset_minor_must_be_non_negative');
|
||||
}
|
||||
return new self(AssetType::from_value($asset), $minor);
|
||||
}
|
||||
|
||||
public function asset(): AssetType {
|
||||
return $this->asset;
|
||||
}
|
||||
|
||||
public function minor(): int {
|
||||
return $this->minor;
|
||||
}
|
||||
|
||||
public function decimal(): string {
|
||||
if ($this->asset === AssetType::POINTS) {
|
||||
return (string) $this->minor;
|
||||
}
|
||||
return sprintf('%d.%02d', intdiv($this->minor, 100), $this->minor % 100);
|
||||
}
|
||||
|
||||
public function formatted(): string {
|
||||
if ($this->asset === AssetType::POINTS) {
|
||||
return number_format_i18n($this->minor);
|
||||
}
|
||||
return $this->decimal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ledger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
enum AssetType: string {
|
||||
case POINTS = 'points';
|
||||
case BALANCE = 'balance';
|
||||
|
||||
public static function from_value(self|string $asset): self {
|
||||
return $asset instanceof self ? $asset : self::from($asset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ledger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class LedgerAbort extends \RuntimeException {
|
||||
public function __construct(public readonly \WP_Error $error) {
|
||||
parent::__construct($error->get_error_code());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ledger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class LedgerCommand {
|
||||
/**
|
||||
* @param array<int,array{user_id:int,asset:string,delta_minor:int,entry_type:string,counterparty_user_id?:int,remark?:string}> $entries
|
||||
* @param array<string,mixed> $metadata
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $operation_key,
|
||||
public readonly string $reason,
|
||||
public readonly array $entries,
|
||||
public readonly string $ref_type = '',
|
||||
public readonly string $ref_key = '',
|
||||
public readonly int $actor_user_id = 0,
|
||||
public readonly array $metadata = []
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $metadata
|
||||
*/
|
||||
public static function transfer(
|
||||
string $operation_key,
|
||||
string $reason,
|
||||
int $sender_id,
|
||||
int $recipient_id,
|
||||
AssetType|string $asset,
|
||||
int $gross_minor,
|
||||
int $fee_minor,
|
||||
string $ref_type = '',
|
||||
string $ref_key = '',
|
||||
string $sender_remark = '',
|
||||
string $recipient_remark = '',
|
||||
array $metadata = []
|
||||
): self {
|
||||
$asset = AssetType::from_value($asset);
|
||||
if ($sender_id <= 0 || $recipient_id <= 0 || $sender_id === $recipient_id || $gross_minor <= 0 || $fee_minor < 0 || $fee_minor >= $gross_minor) {
|
||||
throw new \InvalidArgumentException('ledger_transfer_invalid');
|
||||
}
|
||||
$net_minor = $gross_minor - $fee_minor;
|
||||
$entries = [[
|
||||
'user_id' => $sender_id,
|
||||
'asset' => $asset->value,
|
||||
'delta_minor' => -$net_minor,
|
||||
'entry_type' => 'transfer_out',
|
||||
'counterparty_user_id' => $recipient_id,
|
||||
'remark' => $sender_remark,
|
||||
]];
|
||||
if ($fee_minor > 0) {
|
||||
$entries[] = [
|
||||
'user_id' => $sender_id,
|
||||
'asset' => $asset->value,
|
||||
'delta_minor' => -$fee_minor,
|
||||
'entry_type' => 'fee',
|
||||
'counterparty_user_id' => $recipient_id,
|
||||
'remark' => __('转账手续费', 'aksrt'),
|
||||
];
|
||||
}
|
||||
$entries[] = [
|
||||
'user_id' => $recipient_id,
|
||||
'asset' => $asset->value,
|
||||
'delta_minor' => $net_minor,
|
||||
'entry_type' => 'transfer_in',
|
||||
'counterparty_user_id' => $sender_id,
|
||||
'remark' => $recipient_remark,
|
||||
];
|
||||
$metadata['gross_minor'] = $gross_minor;
|
||||
$metadata['fee_minor'] = $fee_minor;
|
||||
$metadata['net_minor'] = $net_minor;
|
||||
return new self($operation_key, $reason, $entries, $ref_type, $ref_key, $sender_id, $metadata);
|
||||
}
|
||||
|
||||
public function validate() {
|
||||
if ($this->operation_key === '' || strlen($this->operation_key) > 191 || !preg_match('/^[A-Za-z0-9:._-]+$/', $this->operation_key)) {
|
||||
return new \WP_Error('aksrt_ledger_operation_key', __('账务操作标识无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($this->reason === '' || strlen($this->reason) > 64 || sanitize_key($this->reason) !== $this->reason) {
|
||||
return new \WP_Error('aksrt_ledger_reason', __('账务原因无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($this->entries === []) {
|
||||
return new \WP_Error('aksrt_ledger_entries', __('账务明细不能为空', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($this->actor_user_id < 0) {
|
||||
return new \WP_Error('aksrt_ledger_actor', __('账务操作者无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
foreach ($this->entries as $entry) {
|
||||
try {
|
||||
AssetType::from((string) ($entry['asset'] ?? ''));
|
||||
} catch (\ValueError $e) {
|
||||
return new \WP_Error('aksrt_ledger_asset', __('资产类型无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$user_id = $entry['user_id'] ?? null;
|
||||
$delta_minor = $entry['delta_minor'] ?? null;
|
||||
$entry_type = $entry['entry_type'] ?? null;
|
||||
if (!is_int($user_id) || $user_id <= 0
|
||||
|| !is_int($delta_minor) || $delta_minor === 0 || $delta_minor === PHP_INT_MIN
|
||||
|| !is_string($entry_type) || $entry_type === '' || strlen($entry_type) > 32 || sanitize_key($entry_type) !== $entry_type
|
||||
) {
|
||||
return new \WP_Error('aksrt_ledger_entry', __('账务明细无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (isset($entry['counterparty_user_id']) && (!is_int($entry['counterparty_user_id']) || $entry['counterparty_user_id'] <= 0)) {
|
||||
return new \WP_Error('aksrt_ledger_entry', __('账务明细无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function payload_hash(): string {
|
||||
$payload = [
|
||||
'reason' => $this->reason,
|
||||
'entries' => $this->entries,
|
||||
'ref_type' => $this->ref_type,
|
||||
'ref_key' => $this->ref_key,
|
||||
'actor_user_id' => $this->actor_user_id,
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
self::sort_recursive($payload);
|
||||
return hash('sha256', (string) wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
private static function sort_recursive(array &$value): void {
|
||||
foreach ($value as &$item) {
|
||||
if (is_array($item)) {
|
||||
self::sort_recursive($item);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
if (!array_is_list($value)) {
|
||||
ksort($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ledger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class LedgerPresentation {
|
||||
public static function reason_label(string $reason): string {
|
||||
$labels = [
|
||||
'admin_adjustment' => __('管理员调整', 'aksrt'),
|
||||
'affiliate_commission' => __('推广佣金结算', 'aksrt'),
|
||||
'card_pass_balance' => __('卡密充值', 'aksrt'),
|
||||
'card_pass_credits' => __('卡密兑换积分', 'aksrt'),
|
||||
'content_points_pay' => __('积分购买内容', 'aksrt'),
|
||||
'creator_income' => __('创作收益结算', 'aksrt'),
|
||||
'free_comment_like' => __('评论被赞奖励', 'aksrt'),
|
||||
'free_comment_new' => __('发表评论奖励', 'aksrt'),
|
||||
'free_followed' => __('被关注奖励', 'aksrt'),
|
||||
'free_login' => __('每日登录奖励', 'aksrt'),
|
||||
'free_post_favorite' => __('文章被收藏奖励', 'aksrt'),
|
||||
'free_post_like' => __('文章被赞奖励', 'aksrt'),
|
||||
'free_post_new' => __('发布文章奖励', 'aksrt'),
|
||||
'free_sign_in' => __('签到任务奖励', 'aksrt'),
|
||||
'free_sign_up' => __('注册奖励', 'aksrt'),
|
||||
'order_payment' => __('订单支付', 'aksrt'),
|
||||
'order_refund' => __('订单退款', 'aksrt'),
|
||||
'points_exchange' => __('余额兑换积分', 'aksrt'),
|
||||
'referral' => __('推广奖励', 'aksrt'),
|
||||
'recharge' => __('余额充值', 'aksrt'),
|
||||
'signin' => __('每日签到', 'aksrt'),
|
||||
'transfer' => __('用户转账', 'aksrt'),
|
||||
'tip' => __('用户赞赏', 'aksrt'),
|
||||
'verify_application' => __('认证申请费用', 'aksrt'),
|
||||
'vip_points_exchange' => __('积分兑换会员', 'aksrt'),
|
||||
'withdraw_hold' => __('提现冻结', 'aksrt'),
|
||||
'withdraw_refund' => __('提现退回', 'aksrt'),
|
||||
];
|
||||
$key = sanitize_key($reason);
|
||||
return $labels[$key] ?? ($reason !== '' ? $reason : __('资产变动', 'aksrt'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ledger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class LedgerQuery {
|
||||
private static ?self $instance = null;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
* @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int}
|
||||
*/
|
||||
public function history(array $args): array {
|
||||
global $wpdb;
|
||||
$user_id = max(0, (int) ($args['user_id'] ?? 0));
|
||||
$asset = sanitize_key((string) ($args['asset'] ?? ''));
|
||||
$reason = sanitize_key((string) ($args['reason'] ?? ''));
|
||||
$transaction_id = max(0, (int) ($args['transaction_id'] ?? 0));
|
||||
$operation_key = trim(sanitize_text_field((string) ($args['operation_key'] ?? '')));
|
||||
$ref_type = sanitize_key((string) ($args['ref_type'] ?? ''));
|
||||
$ref_key = trim(sanitize_text_field((string) ($args['ref_key'] ?? '')));
|
||||
$actor_user_id = max(0, (int) ($args['actor_user_id'] ?? 0));
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
|
||||
$offset = ($page - 1) * $per_page;
|
||||
if ($asset !== '' && !in_array($asset, [AssetType::POINTS->value, AssetType::BALANCE->value], true)) {
|
||||
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $per_page];
|
||||
}
|
||||
|
||||
$entry_table = \AKSRT\Core\DBMigrator::table('asset_entry');
|
||||
$transaction_table = \AKSRT\Core\DBMigrator::table('asset_transaction');
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'e.user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
if ($asset !== '') {
|
||||
$where[] = 'e.asset = %s';
|
||||
$params[] = $asset;
|
||||
}
|
||||
if ($reason !== '') {
|
||||
$where[] = 't.reason = %s';
|
||||
$params[] = $reason;
|
||||
}
|
||||
if ($transaction_id > 0) {
|
||||
$where[] = 't.id = %d';
|
||||
$params[] = $transaction_id;
|
||||
}
|
||||
if ($operation_key !== '') {
|
||||
$where[] = 't.operation_key = %s';
|
||||
$params[] = $operation_key;
|
||||
}
|
||||
if ($ref_type !== '') {
|
||||
$where[] = 't.ref_type = %s';
|
||||
$params[] = $ref_type;
|
||||
}
|
||||
if ($ref_key !== '') {
|
||||
$where[] = 't.ref_key = %s';
|
||||
$params[] = $ref_key;
|
||||
}
|
||||
if ($actor_user_id > 0) {
|
||||
$where[] = 't.actor_user_id = %d';
|
||||
$params[] = $actor_user_id;
|
||||
}
|
||||
if (!empty($args['date_from'])) {
|
||||
$where[] = 't.created_at >= %s';
|
||||
$params[] = (string) $args['date_from'];
|
||||
}
|
||||
if (!empty($args['date_to'])) {
|
||||
$where[] = 't.created_at < %s';
|
||||
$params[] = (string) $args['date_to'];
|
||||
}
|
||||
$where_sql = implode(' AND ', $where);
|
||||
|
||||
$count_sql = "SELECT COUNT(DISTINCT e.transaction_id) FROM {$entry_table} e INNER JOIN {$transaction_table} t ON t.id = e.transaction_id WHERE {$where_sql}";
|
||||
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
|
||||
$id_sql = "SELECT e.transaction_id, MAX(e.id) newest_entry FROM {$entry_table} e INNER JOIN {$transaction_table} t ON t.id = e.transaction_id WHERE {$where_sql} GROUP BY e.transaction_id ORDER BY newest_entry DESC LIMIT %d OFFSET %d";
|
||||
$id_rows = $wpdb->get_results($wpdb->prepare($id_sql, array_merge($params, [$per_page, $offset])), ARRAY_A);
|
||||
$transaction_ids = array_values(array_filter(array_map(static fn($row) => (int) ($row['transaction_id'] ?? 0), (array) $id_rows)));
|
||||
if ($transaction_ids === []) {
|
||||
return ['items' => [], 'total' => $total, 'page' => $page, 'per_page' => $per_page];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($transaction_ids), '%d'));
|
||||
$entry_where = ["e.transaction_id IN ({$placeholders})"];
|
||||
$entry_params = $transaction_ids;
|
||||
if ($user_id > 0) {
|
||||
$entry_where[] = 'e.user_id = %d';
|
||||
$entry_params[] = $user_id;
|
||||
}
|
||||
if ($asset !== '') {
|
||||
$entry_where[] = 'e.asset = %s';
|
||||
$entry_params[] = $asset;
|
||||
}
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT e.*, t.operation_key, t.reason, t.ref_type, t.ref_key, t.actor_user_id, t.metadata, t.created_at transaction_created_at
|
||||
FROM {$entry_table} e INNER JOIN {$transaction_table} t ON t.id = e.transaction_id
|
||||
WHERE " . implode(' AND ', $entry_where) . ' ORDER BY e.transaction_id DESC, e.line_no ASC',
|
||||
$entry_params
|
||||
), ARRAY_A);
|
||||
|
||||
$groups = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$transaction_id = (int) $row['transaction_id'];
|
||||
if (!isset($groups[$transaction_id])) {
|
||||
$groups[$transaction_id] = [
|
||||
'id' => $transaction_id,
|
||||
'operation_key' => (string) $row['operation_key'],
|
||||
'reason' => (string) $row['reason'],
|
||||
'ref_type' => (string) ($row['ref_type'] ?? ''),
|
||||
'ref_key' => (string) ($row['ref_key'] ?? ''),
|
||||
'actor_user_id' => (int) ($row['actor_user_id'] ?? 0),
|
||||
'metadata' => json_decode((string) ($row['metadata'] ?? ''), true) ?: [],
|
||||
'created_at' => (string) $row['transaction_created_at'],
|
||||
'delta_minor' => 0,
|
||||
'fee_minor' => 0,
|
||||
'balance_after_minor' => 0,
|
||||
'counterparty_user_id' => 0,
|
||||
'remark' => '',
|
||||
'assets' => [],
|
||||
'entries' => [],
|
||||
];
|
||||
}
|
||||
$entry = [
|
||||
'id' => (int) $row['id'],
|
||||
'line_no' => (int) $row['line_no'],
|
||||
'user_id' => (int) $row['user_id'],
|
||||
'asset' => (string) $row['asset'],
|
||||
'entry_type' => (string) $row['entry_type'],
|
||||
'delta_minor' => (int) $row['delta_minor'],
|
||||
'balance_after_minor' => (int) $row['balance_after_minor'],
|
||||
'counterparty_user_id' => (int) ($row['counterparty_user_id'] ?? 0),
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
];
|
||||
$groups[$transaction_id]['entries'][] = $entry;
|
||||
$groups[$transaction_id]['assets'][$entry['asset']] = $entry['asset'];
|
||||
$groups[$transaction_id]['delta_minor'] += $entry['delta_minor'];
|
||||
$groups[$transaction_id]['balance_after_minor'] = $entry['balance_after_minor'];
|
||||
if ($entry['entry_type'] === 'fee') {
|
||||
$groups[$transaction_id]['fee_minor'] += abs($entry['delta_minor']);
|
||||
}
|
||||
if ($groups[$transaction_id]['counterparty_user_id'] === 0 && $entry['counterparty_user_id'] > 0) {
|
||||
$groups[$transaction_id]['counterparty_user_id'] = $entry['counterparty_user_id'];
|
||||
}
|
||||
if ($groups[$transaction_id]['remark'] === '' && $entry['remark'] !== '') {
|
||||
$groups[$transaction_id]['remark'] = $entry['remark'];
|
||||
}
|
||||
}
|
||||
$order = array_flip($transaction_ids);
|
||||
$items = array_values($groups);
|
||||
foreach ($items as &$item) {
|
||||
$item['assets'] = array_values($item['assets']);
|
||||
}
|
||||
unset($item);
|
||||
usort($items, static fn($a, $b) => ($order[$a['id']] ?? PHP_INT_MAX) <=> ($order[$b['id']] ?? PHP_INT_MAX));
|
||||
return ['items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller must hold the user's account locks while using this for a limit guard.
|
||||
*/
|
||||
public function sum_credits(int $user_id, string $reason, string $start_utc, string $end_utc): int {
|
||||
global $wpdb;
|
||||
$entry_table = \AKSRT\Core\DBMigrator::table('asset_entry');
|
||||
$transaction_table = \AKSRT\Core\DBMigrator::table('asset_transaction');
|
||||
return max(0, (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COALESCE(SUM(e.delta_minor), 0) FROM {$entry_table} e
|
||||
INNER JOIN {$transaction_table} t ON t.id = e.transaction_id
|
||||
WHERE e.user_id = %d AND e.asset = %s AND e.delta_minor > 0 AND t.reason = %s AND t.created_at >= %s AND t.created_at < %s",
|
||||
$user_id,
|
||||
AssetType::POINTS->value,
|
||||
$reason,
|
||||
$start_utc,
|
||||
$end_utc
|
||||
)));
|
||||
}
|
||||
|
||||
public function sum_positive(int $user_id, AssetType|string $asset, string $reason): int {
|
||||
global $wpdb;
|
||||
if ($user_id <= 0 || sanitize_key($reason) !== $reason || $reason === '') {
|
||||
return 0;
|
||||
}
|
||||
$asset = AssetType::from_value($asset);
|
||||
$entry_table = \AKSRT\Core\DBMigrator::table('asset_entry');
|
||||
$transaction_table = \AKSRT\Core\DBMigrator::table('asset_transaction');
|
||||
return max(0, (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COALESCE(SUM(e.delta_minor), 0) FROM {$entry_table} e
|
||||
INNER JOIN {$transaction_table} t ON t.id = e.transaction_id
|
||||
WHERE e.user_id = %d AND e.asset = %s AND e.delta_minor > 0 AND t.reason = %s",
|
||||
$user_id,
|
||||
$asset->value,
|
||||
$reason
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
* @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int}
|
||||
*/
|
||||
public function list_accounts(AssetType|string $asset, array $args = []): array {
|
||||
global $wpdb;
|
||||
$asset = AssetType::from_value($asset);
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
|
||||
$offset = ($page - 1) * $per_page;
|
||||
$search = trim((string) ($args['search'] ?? ''));
|
||||
$where = ['1=1'];
|
||||
$params = [$asset->value];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$where[] = '(u.user_login LIKE %s OR u.user_email LIKE %s OR u.display_name LIKE %s)';
|
||||
array_push($params, $like, $like, $like);
|
||||
}
|
||||
$where_sql = implode(' AND ', $where);
|
||||
$total_sql = "SELECT COUNT(*) FROM {$wpdb->users} u WHERE {$where_sql}";
|
||||
$total_params = array_slice($params, 1);
|
||||
$total = (int) ($total_params ? $wpdb->get_var($wpdb->prepare($total_sql, $total_params)) : $wpdb->get_var($total_sql));
|
||||
$account_table = \AKSRT\Core\DBMigrator::table('asset_account');
|
||||
$sql = "SELECT u.ID id, u.user_login login, u.user_email email, u.display_name, u.user_registered registered,
|
||||
COALESCE(a.balance_minor, 0) balance_minor
|
||||
FROM {$wpdb->users} u LEFT JOIN {$account_table} a ON a.user_id = u.ID AND a.asset = %s
|
||||
WHERE {$where_sql} ORDER BY u.user_registered DESC LIMIT %d OFFSET %d";
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset])), ARRAY_A);
|
||||
foreach ((array) $rows as &$row) {
|
||||
$row['id'] = (int) $row['id'];
|
||||
$row['balance_minor'] = (int) $row['balance_minor'];
|
||||
}
|
||||
unset($row);
|
||||
return ['items' => (array) $rows, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ledger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class LedgerResult {
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $entries
|
||||
* @param array<int,array<string,int>> $balances
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $transaction_id,
|
||||
public readonly bool $replayed,
|
||||
public readonly array $entries,
|
||||
public readonly array $balances
|
||||
) {}
|
||||
|
||||
public function balance(int $user_id, AssetType|string $asset): int {
|
||||
$key = AssetType::from_value($asset)->value;
|
||||
return (int) ($this->balances[$user_id][$key] ?? 0);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function to_array(): array {
|
||||
return [
|
||||
'transaction_id' => $this->transaction_id,
|
||||
'replayed' => $this->replayed,
|
||||
'entries' => $this->entries,
|
||||
'balances' => $this->balances,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ledger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class LedgerService {
|
||||
private const MAX_ATTEMPTS = 3;
|
||||
|
||||
private static ?self $instance = null;
|
||||
private bool $transaction_active = false;
|
||||
/** @var array<int,callable> */
|
||||
private array $after_commit_callbacks = [];
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function transaction_active(): bool {
|
||||
return $this->transaction_active;
|
||||
}
|
||||
|
||||
public function after_commit(callable $callback): bool {
|
||||
if (!$this->transaction_active) {
|
||||
return false;
|
||||
}
|
||||
$this->after_commit_callbacks[] = $callback;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function balance(int $user_id, AssetType|string $asset): int {
|
||||
global $wpdb;
|
||||
if ($user_id <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$asset = AssetType::from_value($asset);
|
||||
$table = $this->table('asset_account');
|
||||
return max(0, (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT balance_minor FROM {$table} WHERE user_id = %d AND asset = %s",
|
||||
$user_id,
|
||||
$asset->value
|
||||
)));
|
||||
}
|
||||
|
||||
public function balance_decimal(int $user_id): string {
|
||||
return AssetAmount::from_minor(AssetType::BALANCE, $this->balance($user_id, AssetType::BALANCE))->decimal();
|
||||
}
|
||||
|
||||
public function format_balance(int $minor): string {
|
||||
return AssetAmount::from_minor(AssetType::BALANCE, max(0, $minor))->formatted();
|
||||
}
|
||||
|
||||
public function result_for_operation(string $operation_key): ?LedgerResult {
|
||||
global $wpdb;
|
||||
if ($operation_key === '' || strlen($operation_key) > 191) {
|
||||
return null;
|
||||
}
|
||||
$transaction_id = (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$this->table('asset_transaction')} WHERE operation_key = %s LIMIT 1",
|
||||
$operation_key
|
||||
));
|
||||
return $transaction_id > 0 ? $this->load_result($transaction_id, true) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ref
|
||||
* @param array<string,mixed> $metadata
|
||||
*/
|
||||
public function credit(
|
||||
int $user_id,
|
||||
AssetType|string $asset,
|
||||
int $amount_minor,
|
||||
string $operation_key,
|
||||
string $reason,
|
||||
array $ref = [],
|
||||
string $remark = '',
|
||||
int $actor_user_id = 0,
|
||||
array $metadata = [],
|
||||
?callable $business_mutation = null,
|
||||
?callable $guard = null
|
||||
) {
|
||||
try {
|
||||
$asset = AssetType::from_value($asset);
|
||||
} catch (\ValueError $e) {
|
||||
return new \WP_Error('aksrt_ledger_asset', __('资产类型无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($amount_minor <= 0) {
|
||||
return new \WP_Error('aksrt_ledger_amount', __('入账金额必须大于零', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
return $this->post(new LedgerCommand(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
[[
|
||||
'user_id' => $user_id,
|
||||
'asset' => $asset->value,
|
||||
'delta_minor' => $amount_minor,
|
||||
'entry_type' => 'credit',
|
||||
'remark' => $remark,
|
||||
]],
|
||||
$this->ref_type($ref),
|
||||
$this->ref_key($ref),
|
||||
$actor_user_id,
|
||||
$metadata
|
||||
), $guard, $business_mutation);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ref
|
||||
* @param array<string,mixed> $metadata
|
||||
*/
|
||||
public function debit(
|
||||
int $user_id,
|
||||
AssetType|string $asset,
|
||||
int $amount_minor,
|
||||
string $operation_key,
|
||||
string $reason,
|
||||
array $ref = [],
|
||||
string $remark = '',
|
||||
int $actor_user_id = 0,
|
||||
array $metadata = [],
|
||||
?callable $business_mutation = null,
|
||||
?callable $guard = null
|
||||
) {
|
||||
try {
|
||||
$asset = AssetType::from_value($asset);
|
||||
} catch (\ValueError $e) {
|
||||
return new \WP_Error('aksrt_ledger_asset', __('资产类型无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($amount_minor <= 0) {
|
||||
return new \WP_Error('aksrt_ledger_amount', __('扣减金额必须大于零', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
return $this->post(new LedgerCommand(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
[[
|
||||
'user_id' => $user_id,
|
||||
'asset' => $asset->value,
|
||||
'delta_minor' => -$amount_minor,
|
||||
'entry_type' => 'debit',
|
||||
'remark' => $remark,
|
||||
]],
|
||||
$this->ref_type($ref),
|
||||
$this->ref_key($ref),
|
||||
$actor_user_id,
|
||||
$metadata
|
||||
), $guard, $business_mutation);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ref
|
||||
* @param array<string,mixed> $metadata
|
||||
*/
|
||||
public function transfer(
|
||||
int $sender_id,
|
||||
int $recipient_id,
|
||||
AssetType|string $asset,
|
||||
int $gross_minor,
|
||||
int $fee_minor,
|
||||
string $operation_key,
|
||||
string $reason,
|
||||
array $ref = [],
|
||||
string $sender_remark = '',
|
||||
string $recipient_remark = '',
|
||||
array $metadata = []
|
||||
) {
|
||||
try {
|
||||
$asset = AssetType::from_value($asset);
|
||||
} catch (\ValueError $e) {
|
||||
return new \WP_Error('aksrt_ledger_asset', __('资产类型无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($sender_id <= 0 || $recipient_id <= 0 || $sender_id === $recipient_id || $gross_minor <= 0 || $fee_minor < 0 || $fee_minor >= $gross_minor) {
|
||||
return new \WP_Error('aksrt_ledger_transfer', __('转账参数无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
return $this->post(LedgerCommand::transfer(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
$sender_id,
|
||||
$recipient_id,
|
||||
$asset,
|
||||
$gross_minor,
|
||||
$fee_minor,
|
||||
$this->ref_type($ref),
|
||||
$this->ref_key($ref),
|
||||
$sender_remark,
|
||||
$recipient_remark,
|
||||
$metadata
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ref
|
||||
* @param array<string,mixed> $metadata
|
||||
*/
|
||||
public function exchange(
|
||||
int $user_id,
|
||||
int $balance_minor,
|
||||
int $points,
|
||||
string $operation_key,
|
||||
string $reason,
|
||||
array $ref = [],
|
||||
string $remark = '',
|
||||
array $metadata = [],
|
||||
?callable $guard = null,
|
||||
?callable $business_mutation = null
|
||||
) {
|
||||
if ($user_id <= 0 || $balance_minor <= 0 || $points <= 0) {
|
||||
return new \WP_Error('aksrt_ledger_exchange', __('兑换参数无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
return $this->post(new LedgerCommand(
|
||||
$operation_key,
|
||||
sanitize_key($reason),
|
||||
[
|
||||
['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => -$balance_minor, 'entry_type' => 'exchange_out', 'remark' => $remark],
|
||||
['user_id' => $user_id, 'asset' => AssetType::POINTS->value, 'delta_minor' => $points, 'entry_type' => 'exchange_in', 'remark' => $remark],
|
||||
],
|
||||
$this->ref_type($ref),
|
||||
$this->ref_key($ref),
|
||||
$user_id,
|
||||
$metadata
|
||||
), $guard, $business_mutation);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|callable(int,array<int,array<string,int>>):(true|\WP_Error) $guard
|
||||
* @param null|callable(int,LedgerResult):(true|\WP_Error) $business_mutation
|
||||
* @return LedgerResult|\WP_Error
|
||||
*/
|
||||
public function post(LedgerCommand $command, ?callable $guard = null, ?callable $business_mutation = null) {
|
||||
if ($this->transaction_active) {
|
||||
return new \WP_Error('aksrt_ledger_nested', __('账务事务内禁止再次提交账务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$valid = $command->validate();
|
||||
if (is_wp_error($valid)) {
|
||||
return $valid;
|
||||
}
|
||||
|
||||
$payload_hash = $command->payload_hash();
|
||||
for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) {
|
||||
$result = $this->attempt_post($command, $payload_hash, $guard, $business_mutation);
|
||||
if (!is_wp_error($result) || $result->get_error_code() !== 'aksrt_ledger_retry') {
|
||||
return $result;
|
||||
}
|
||||
usleep(random_int(10000, 50000));
|
||||
}
|
||||
return new \WP_Error('aksrt_ledger_db', __('账务处理失败,请稍后重试', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
private function attempt_post(LedgerCommand $command, string $payload_hash, ?callable $guard, ?callable $business_mutation) {
|
||||
global $wpdb;
|
||||
$account_table = $this->table('asset_account');
|
||||
$transaction_table = $this->table('asset_transaction');
|
||||
$entry_table = $this->table('asset_entry');
|
||||
$created_at = self::utc_now();
|
||||
|
||||
if ($wpdb->query('START TRANSACTION') === false) {
|
||||
return new \WP_Error('aksrt_ledger_db', __('无法开启账务事务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$this->transaction_active = true;
|
||||
$this->after_commit_callbacks = [];
|
||||
|
||||
try {
|
||||
$inserted = $wpdb->insert($transaction_table, [
|
||||
'operation_key' => $command->operation_key,
|
||||
'payload_hash' => $payload_hash,
|
||||
'reason' => $command->reason,
|
||||
'ref_type' => $command->ref_type !== '' ? sanitize_key($command->ref_type) : null,
|
||||
'ref_key' => $command->ref_key !== '' ? substr(sanitize_text_field($command->ref_key), 0, 191) : null,
|
||||
'actor_user_id' => max(0, $command->actor_user_id),
|
||||
'metadata' => $command->metadata !== [] ? wp_json_encode($command->metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
'ip' => self::client_ip(),
|
||||
'ua' => self::client_ua(),
|
||||
'created_at' => $created_at,
|
||||
], ['%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s']);
|
||||
if ($inserted !== 1) {
|
||||
$errno = $this->last_db_errno();
|
||||
$wpdb->query('ROLLBACK');
|
||||
$this->transaction_active = false;
|
||||
$this->after_commit_callbacks = [];
|
||||
$existing = $this->replay($command->operation_key, $payload_hash);
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
return $this->db_error($errno);
|
||||
}
|
||||
$transaction_id = (int) $wpdb->insert_id;
|
||||
|
||||
$account_keys = [];
|
||||
foreach ($command->entries as $entry) {
|
||||
$key = (string) $entry['asset'] . ':' . (int) $entry['user_id'];
|
||||
$account_keys[$key] = ['user_id' => (int) $entry['user_id'], 'asset' => (string) $entry['asset']];
|
||||
}
|
||||
$account_keys = array_values($account_keys);
|
||||
usort($account_keys, static function (array $left, array $right): int {
|
||||
$asset_order = strcmp((string) $left['asset'], (string) $right['asset']);
|
||||
return $asset_order !== 0
|
||||
? $asset_order
|
||||
: ((int) $left['user_id'] <=> (int) $right['user_id']);
|
||||
});
|
||||
foreach ($account_keys as $account) {
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"INSERT IGNORE INTO {$account_table} (user_id, asset, balance_minor, version, updated_at) VALUES (%d, %s, 0, 0, %s)",
|
||||
$account['user_id'],
|
||||
$account['asset'],
|
||||
$created_at
|
||||
));
|
||||
}
|
||||
|
||||
$balances = [];
|
||||
foreach ($account_keys as $account) {
|
||||
$minor = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT balance_minor FROM {$account_table} WHERE user_id = %d AND asset = %s FOR UPDATE",
|
||||
$account['user_id'],
|
||||
$account['asset']
|
||||
));
|
||||
if ($minor === null) {
|
||||
throw new \RuntimeException('asset_account_lock_failed');
|
||||
}
|
||||
$balances[$account['user_id']][$account['asset']] = (int) $minor;
|
||||
}
|
||||
|
||||
if ($guard !== null) {
|
||||
$guard_result = $guard($transaction_id, $balances);
|
||||
if (is_wp_error($guard_result)) {
|
||||
if ($this->is_retryable_errno($this->last_db_errno())) {
|
||||
throw new \RuntimeException('ledger_guard_lock_failed');
|
||||
}
|
||||
throw new LedgerAbort($guard_result);
|
||||
}
|
||||
if ($guard_result !== true) {
|
||||
throw new \RuntimeException('ledger_guard_failed');
|
||||
}
|
||||
}
|
||||
|
||||
$projected_balances = $balances;
|
||||
$prepared_entries = [];
|
||||
foreach ($command->entries as $index => $entry) {
|
||||
$user_id = (int) $entry['user_id'];
|
||||
$asset = (string) $entry['asset'];
|
||||
$delta = (int) $entry['delta_minor'];
|
||||
$before = (int) $projected_balances[$user_id][$asset];
|
||||
if ($delta > 0 && $before > PHP_INT_MAX - $delta) {
|
||||
throw new LedgerAbort(new \WP_Error('aksrt_ledger_overflow', __('资产余额超出可用范围', 'aksrt'), ['status' => 400]));
|
||||
}
|
||||
$after = $before + $delta;
|
||||
if ($after < 0) {
|
||||
$code = $asset === AssetType::POINTS->value ? 'aksrt_points_insufficient' : 'aksrt_balance_insufficient';
|
||||
throw new LedgerAbort(new \WP_Error($code, $asset === AssetType::POINTS->value ? __('积分不足', 'aksrt') : __('余额不足', 'aksrt'), [
|
||||
'status' => 402,
|
||||
'balance_minor' => $before,
|
||||
'required_minor' => abs($delta),
|
||||
]));
|
||||
}
|
||||
$projected_balances[$user_id][$asset] = $after;
|
||||
$prepared_entries[] = [
|
||||
'transaction_id' => $transaction_id,
|
||||
'line_no' => $index + 1,
|
||||
'user_id' => $user_id,
|
||||
'asset' => $asset,
|
||||
'entry_type' => sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')),
|
||||
'delta_minor' => $delta,
|
||||
'balance_after_minor' => $after,
|
||||
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)) ?: null,
|
||||
'remark' => !empty($entry['remark']) ? substr(sanitize_text_field((string) $entry['remark']), 0, 255) : null,
|
||||
'created_at' => $created_at,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($account_keys as $account) {
|
||||
$user_id = (int) $account['user_id'];
|
||||
$asset = (string) $account['asset'];
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$account_table} SET balance_minor = %d, version = version + 1, updated_at = %s WHERE user_id = %d AND asset = %s",
|
||||
(int) $projected_balances[$user_id][$asset],
|
||||
$created_at,
|
||||
$user_id,
|
||||
$asset
|
||||
));
|
||||
if ($updated !== 1) {
|
||||
throw new \RuntimeException('asset_account_update_failed');
|
||||
}
|
||||
}
|
||||
|
||||
$written_entries = [];
|
||||
foreach ($prepared_entries as $entry_row) {
|
||||
if ($wpdb->insert($entry_table, $entry_row, ['%d', '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s']) !== 1) {
|
||||
throw new \RuntimeException('asset_entry_insert_failed');
|
||||
}
|
||||
$written_entries[] = array_merge(['id' => (int) $wpdb->insert_id], $entry_row);
|
||||
}
|
||||
|
||||
$result = new LedgerResult($transaction_id, false, $written_entries, $projected_balances);
|
||||
if ($business_mutation !== null) {
|
||||
$mutation_result = $business_mutation($transaction_id, $result);
|
||||
if (is_wp_error($mutation_result)) {
|
||||
if ($this->is_retryable_errno($this->last_db_errno())) {
|
||||
throw new \RuntimeException('ledger_business_lock_failed');
|
||||
}
|
||||
throw new LedgerAbort($mutation_result);
|
||||
}
|
||||
if ($mutation_result !== true) {
|
||||
throw new \RuntimeException('ledger_business_mutation_failed');
|
||||
}
|
||||
}
|
||||
if ($wpdb->query('COMMIT') === false) {
|
||||
throw new \RuntimeException('ledger_commit_failed');
|
||||
}
|
||||
$after_commit_callbacks = $this->after_commit_callbacks;
|
||||
$this->after_commit_callbacks = [];
|
||||
$this->transaction_active = false;
|
||||
try {
|
||||
do_action('aksrt_asset_transaction_committed', $result, $command);
|
||||
} catch (\Throwable $event_error) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[AKSRT Ledger Event] ' . $event_error->getMessage());
|
||||
}
|
||||
}
|
||||
foreach ($after_commit_callbacks as $callback) {
|
||||
try {
|
||||
$callback();
|
||||
} catch (\Throwable $event_error) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[AKSRT Ledger After Commit] ' . $event_error->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
} catch (LedgerAbort $e) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
$this->clean_rolled_back_user_caches($command);
|
||||
$this->transaction_active = false;
|
||||
$this->after_commit_callbacks = [];
|
||||
return $e->error;
|
||||
} catch (\Throwable $e) {
|
||||
$errno = $this->last_db_errno();
|
||||
$wpdb->query('ROLLBACK');
|
||||
$this->clean_rolled_back_user_caches($command);
|
||||
$this->transaction_active = false;
|
||||
$this->after_commit_callbacks = [];
|
||||
return $this->db_error($errno, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function replay(string $operation_key, string $payload_hash) {
|
||||
global $wpdb;
|
||||
$transaction_table = $this->table('asset_transaction');
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT id, payload_hash FROM {$transaction_table} WHERE operation_key = %s",
|
||||
$operation_key
|
||||
), ARRAY_A);
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
if (!hash_equals((string) $row['payload_hash'], $payload_hash)) {
|
||||
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他账务请求', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return $this->load_result((int) $row['id'], true);
|
||||
}
|
||||
|
||||
private function load_result(int $transaction_id, bool $replayed): LedgerResult {
|
||||
global $wpdb;
|
||||
$entry_table = $this->table('asset_entry');
|
||||
$entries = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$entry_table} WHERE transaction_id = %d ORDER BY line_no ASC",
|
||||
$transaction_id
|
||||
), ARRAY_A);
|
||||
$balances = [];
|
||||
foreach ((array) $entries as &$entry) {
|
||||
foreach (['id', 'transaction_id', 'line_no', 'user_id', 'delta_minor', 'balance_after_minor', 'counterparty_user_id'] as $field) {
|
||||
$entry[$field] = (int) ($entry[$field] ?? 0);
|
||||
}
|
||||
$balances[$entry['user_id']][(string) $entry['asset']] = $entry['balance_after_minor'];
|
||||
}
|
||||
unset($entry);
|
||||
return new LedgerResult($transaction_id, $replayed, (array) $entries, $balances);
|
||||
}
|
||||
|
||||
private function db_error(int $errno, string $detail = ''): \WP_Error {
|
||||
if ($this->is_retryable_errno($errno)) {
|
||||
return new \WP_Error('aksrt_ledger_retry', __('账务事务发生锁冲突', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if (defined('WP_DEBUG') && WP_DEBUG && $detail !== '') {
|
||||
error_log('[AKSRT Ledger] ' . $detail);
|
||||
}
|
||||
return new \WP_Error('aksrt_ledger_db', __('账务数据库写入失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
private function is_retryable_errno(int $errno): bool {
|
||||
return in_array($errno, [1205, 1213], true);
|
||||
}
|
||||
|
||||
private function clean_rolled_back_user_caches(LedgerCommand $command): void {
|
||||
if (!function_exists('clean_user_cache')) {
|
||||
return;
|
||||
}
|
||||
$user_ids = [];
|
||||
foreach ($command->entries as $entry) {
|
||||
$user_id = (int) ($entry['user_id'] ?? 0);
|
||||
if ($user_id > 0) {
|
||||
$user_ids[$user_id] = $user_id;
|
||||
}
|
||||
}
|
||||
foreach ($user_ids as $user_id) {
|
||||
clean_user_cache($user_id);
|
||||
}
|
||||
}
|
||||
|
||||
private function last_db_errno(): int {
|
||||
global $wpdb;
|
||||
if (isset($wpdb->dbh) && $wpdb->dbh instanceof \mysqli) {
|
||||
return mysqli_errno($wpdb->dbh);
|
||||
}
|
||||
if (isset($wpdb->last_errno)) {
|
||||
return (int) $wpdb->last_errno;
|
||||
}
|
||||
if (preg_match('/\\b(1205|1213)\\b/', (string) ($wpdb->last_error ?? ''), $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $ref */
|
||||
private function ref_type(array $ref): string {
|
||||
return sanitize_key((string) ($ref['type'] ?? ''));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $ref */
|
||||
private function ref_key(array $ref): string {
|
||||
foreach (['key', 'order', 'id', 'ref_id'] as $field) {
|
||||
if (isset($ref[$field]) && (string) $ref[$field] !== '') {
|
||||
return (string) $ref[$field];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function table(string $name): string {
|
||||
return \AKSRT\Core\DBMigrator::table($name);
|
||||
}
|
||||
|
||||
private static function utc_now(): string {
|
||||
return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.u');
|
||||
}
|
||||
|
||||
private static function client_ip(): ?string {
|
||||
$ip = function_exists('aksrt_get_client_ip') ? (string) aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$ip = substr(sanitize_text_field(wp_unslash($ip)), 0, 64);
|
||||
return $ip !== '' ? $ip : null;
|
||||
}
|
||||
|
||||
private static function client_ua(): ?string {
|
||||
$ua = substr(sanitize_text_field(wp_unslash((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''))), 0, 255);
|
||||
return $ua !== '' ? $ua : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ledger;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class OperationKey {
|
||||
/** @param array<int,string|int> $parts */
|
||||
public static function deterministic(string $scope, array $parts): string {
|
||||
$tokens = [self::token($scope)];
|
||||
foreach ($parts as $part) {
|
||||
$tokens[] = self::token((string) $part);
|
||||
}
|
||||
$key = implode(':', $tokens);
|
||||
if (strlen($key) > 191) {
|
||||
$key = self::token($scope) . ':' . hash('sha256', $key);
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/** @return string|\WP_Error */
|
||||
public static function from_request(\WP_REST_Request $request, string $scope, int $actor_user_id) {
|
||||
$request_id = trim((string) $request->get_header('Idempotency-Key'));
|
||||
if (!wp_is_uuid($request_id)) {
|
||||
return new \WP_Error('aksrt_idempotency_key', __('缺少或无效的 Idempotency-Key 请求头', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
return self::deterministic('client-' . $scope, [$actor_user_id, strtolower($request_id)]);
|
||||
}
|
||||
|
||||
private static function token(string $value): string {
|
||||
$value = strtolower(trim($value));
|
||||
$value = (string) preg_replace('/[^a-z0-9._-]+/', '-', $value);
|
||||
$value = trim($value, '-');
|
||||
return $value !== '' ? $value : 'none';
|
||||
}
|
||||
}
|
||||
+193
-54
@@ -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
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 获取网关显示名称
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 必需)。
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,8 +154,8 @@ trait CreditsPaymentGroups {
|
||||
}
|
||||
|
||||
public static function affiliate_enabled(): bool { return PaymentRuntime::affiliate_enabled(); }
|
||||
public static function affiliate_ratio_for_user(int $user_id = 0): float { return PaymentRuntime::affiliate_ratio_for_user($user_id > 0 ? $user_id : get_current_user_id()); }
|
||||
public static function affiliate_ratio_for_level(int $level): float { return PaymentRuntime::affiliate_ratio_for_level($level); }
|
||||
public static function affiliate_ratio_for_user(int $user_id = 0): int { return PaymentRuntime::affiliate_ratio_for_user($user_id > 0 ? $user_id : get_current_user_id()); }
|
||||
public static function affiliate_ratio_for_level(int $level): int { return PaymentRuntime::affiliate_ratio_for_level($level); }
|
||||
public static function get_affiliate_settings(): array { return PaymentRuntime::get_affiliate_settings(); }
|
||||
public static function get_settlement_settings(): array { return PaymentRuntime::get_settlement_settings(); }
|
||||
|
||||
@@ -178,7 +178,7 @@ trait CreditsPaymentGroups {
|
||||
/**
|
||||
* 获取充值卡设置
|
||||
*
|
||||
* @return array{enabled:bool,desc:string,type:string,prefix:string,length:int,batch_size:int,max_redeem:int,expiry_days:int,amount:int,vip_days:int,credits:int,redeem_page:string,success_msg:string,fail_msg:string}
|
||||
* @return array{enabled:bool,desc:string,type:string,prefix:string,length:int,batch_size:int,max_redeem:int,expiry_days:int,amount:string,vip_days:int,credits:int,redeem_page:string,success_msg:string,fail_msg:string}
|
||||
*/
|
||||
public static function get_card_pass_settings(): array {
|
||||
return PaymentRuntime::get_card_pass_settings();
|
||||
|
||||
@@ -286,8 +286,10 @@ class Integration {
|
||||
'comment_reply' => true,
|
||||
'comment_approved' => true,
|
||||
'download_ready' => true,
|
||||
'credits_added' => true,
|
||||
'credits_deducted' => true,
|
||||
'asset_points_increase' => true,
|
||||
'asset_points_decrease' => true,
|
||||
'asset_balance_increase' => true,
|
||||
'asset_balance_decrease' => true,
|
||||
'pending_comments' => true,
|
||||
'upload_moderation' => true,
|
||||
],
|
||||
@@ -301,7 +303,11 @@ class Integration {
|
||||
if (!is_array($saved)) {
|
||||
$saved = [];
|
||||
}
|
||||
if (isset($saved['enabled_types']) && is_array($saved['enabled_types'])) {
|
||||
$saved['enabled_types'] = array_intersect_key($saved['enabled_types'], $defaults['enabled_types']);
|
||||
}
|
||||
$merged = array_merge($defaults, $saved);
|
||||
$merged['enabled_types'] = array_replace($defaults['enabled_types'], (array) ($saved['enabled_types'] ?? []));
|
||||
$merged['read_retention_days'] = max(1, min(365, (int) $merged['read_retention_days']));
|
||||
$merged['unread_retention_days'] = max(7, min(3650, (int) $merged['unread_retention_days']));
|
||||
$merged['max_per_user'] = max(20, min(5000, (int) $merged['max_per_user']));
|
||||
|
||||
@@ -88,13 +88,12 @@ class PaymentRuntime {
|
||||
return [
|
||||
'enabled' => !empty($settings['pay_balance_s']),
|
||||
'desc' => $desc,
|
||||
'auto_to_balance' => !isset($settings['pay_auto_to_balance']) || !empty($settings['pay_auto_to_balance']),
|
||||
'packages_raw' => $packages,
|
||||
'package_count' => self::count_structured_lines($packages),
|
||||
'help_text' => $desc !== '' ? $desc : __('当前支持人工确认与测试联调;接入真实网关时复用同一组字段。', 'aksrt'),
|
||||
'product' => [
|
||||
'name' => sanitize_text_field((string) ($settings['pay_balance_product_name'] ?? '')),
|
||||
'price' => max(0, (float) ($settings['pay_balance_price'] ?? 0)),
|
||||
'price' => self::money_decimal($settings['pay_balance_price'] ?? '0.00', '0.00'),
|
||||
'tag' => sanitize_text_field((string) ($settings['pay_balance_tag'] ?? '元')),
|
||||
'tag_class' => sanitize_text_field((string) ($settings['pay_balance_tag_class'] ?? '')),
|
||||
'custom_s' => !isset($settings['pay_balance_product_custom_s']) || !empty($settings['pay_balance_product_custom_s']),
|
||||
@@ -234,7 +233,7 @@ class PaymentRuntime {
|
||||
'desc' => sanitize_textarea_field((string) ($settings['vip_renew_desc'] ?? '')),
|
||||
'price_type' => $renew_price_type,
|
||||
'discount' => max(1, min(100, (int) ($settings['vip_renew_discount'] ?? 8))),
|
||||
'reduce' => max(0, min(9999, (int) ($settings['vip_renew_reduce'] ?? 20))),
|
||||
'reduce' => self::money_decimal($settings['vip_renew_reduce'] ?? '20.00', '20.00', 999900),
|
||||
],
|
||||
'upgrade' => [
|
||||
'enabled' => !isset($settings['vip_upgrade']) || !empty($settings['vip_upgrade']),
|
||||
@@ -284,7 +283,7 @@ class PaymentRuntime {
|
||||
*/
|
||||
public static function get_payment_affiliate_settings(): array {
|
||||
$settings = Settings::get_all();
|
||||
$clamp_ratio = static fn ($value): float => max(0, min(100, (float) $value));
|
||||
$clamp_ratio = static fn($value): int => max(0, min(100, (int) $value));
|
||||
$types = (array) ($settings['affiliate_order_types'] ?? ['membership', 'content']);
|
||||
$types = array_values(array_intersect(['membership', 'content', 'credits'], array_map('sanitize_key', $types)));
|
||||
if (!$types) { $types = ['membership', 'content']; }
|
||||
@@ -303,14 +302,14 @@ class PaymentRuntime {
|
||||
}
|
||||
|
||||
public static function affiliate_enabled(): bool { return !empty(self::get_payment_affiliate_settings()['enabled']); }
|
||||
public static function affiliate_ratio_for_user(int $user_id): float {
|
||||
public static function affiliate_ratio_for_user(int $user_id): int {
|
||||
$level = function_exists('aksrt_get_user_vip_level') ? (int) aksrt_get_user_vip_level($user_id) : 0;
|
||||
return self::affiliate_ratio_for_level($level);
|
||||
}
|
||||
public static function affiliate_ratio_for_level(int $level): float {
|
||||
public static function affiliate_ratio_for_level(int $level): int {
|
||||
$settings = self::get_payment_affiliate_settings();
|
||||
$key = $level >= 2 ? 'vip_2' : ($level >= 1 ? 'vip_1' : 'user');
|
||||
return (float) ($settings['ratios'][$key] ?? 0);
|
||||
return (int) ($settings['ratios'][$key] ?? 0);
|
||||
}
|
||||
public static function get_affiliate_settings(): array { return self::get_payment_affiliate_settings(); }
|
||||
public static function get_settlement_settings(): array {
|
||||
@@ -433,7 +432,7 @@ class PaymentRuntime {
|
||||
'batch_size' => max(1, min(500, (int) ($s['card_pass_batch_size'] ?? 50))),
|
||||
'max_redeem' => max(1, min(100, (int) ($s['card_pass_max_redeem'] ?? 1))),
|
||||
'expiry_days' => max(0, (int) ($s['card_pass_expiry'] ?? 0)),
|
||||
'amount' => max(1, min(9999, (int) ($s['card_pass_amount'] ?? 10))),
|
||||
'amount' => self::money_decimal($s['card_pass_amount'] ?? '10.00', '10.00', 999900),
|
||||
'vip_days' => max(1, min(3650, (int) ($s['card_pass_vip_days'] ?? 30))),
|
||||
'credits' => max(0, min(99999, (int) ($s['card_pass_credits'] ?? 0))),
|
||||
'redeem_page' => esc_url_raw((string) ($s['card_pass_redeem_page'] ?? '')),
|
||||
@@ -442,16 +441,16 @@ class PaymentRuntime {
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{min_amount:float,service_charge_rate:int,daily_count:int,daily_limit:float,reset_time:string,payout_channels:string[]} */
|
||||
/** @return array{min_amount:string,service_charge_rate:int,daily_count:int,daily_limit:string,reset_time:string,payout_channels:string[]} */
|
||||
public static function get_withdrawal_settings(): array {
|
||||
$s = Settings::get_all();
|
||||
$channels = array_values(array_intersect(['alipay', 'wechat'], array_map('sanitize_key', (array) ($s['affiliate_payout_channels'] ?? ['alipay', 'wechat']))));
|
||||
if (!$channels) { $channels = ['alipay', 'wechat']; }
|
||||
return [
|
||||
'min_amount' => max(0, min(999999, (float) ($s['affiliate_min_withdrawal'] ?? 50))),
|
||||
'service_charge_rate' => (int) round(max(0, min(100, (float) ($s['affiliate_withdrawal_fee'] ?? 5)))),
|
||||
'min_amount' => self::money_decimal($s['affiliate_min_withdrawal'] ?? '50.00', '50.00', 99999900),
|
||||
'service_charge_rate' => max(0, min(100, (int) ($s['affiliate_withdrawal_fee'] ?? 5))),
|
||||
'daily_count' => max(0, min(99999, (int) ($s['affiliate_daily_withdrawal_count'] ?? 3))),
|
||||
'daily_limit' => max(0, min(999999, (float) ($s['affiliate_daily_withdrawal_limit'] ?? 500))),
|
||||
'daily_limit' => self::money_decimal($s['affiliate_daily_withdrawal_limit'] ?? '500.00', '500.00', 99999900),
|
||||
'reset_time' => preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', (string) ($s['affiliate_withdrawal_reset_time'] ?? '00:00'))
|
||||
? (string) $s['affiliate_withdrawal_reset_time']
|
||||
: '00:00',
|
||||
@@ -516,6 +515,18 @@ class PaymentRuntime {
|
||||
return $count;
|
||||
}
|
||||
|
||||
private static function money_decimal(mixed $value, string $default, int $max_minor = PHP_INT_MAX): string {
|
||||
try {
|
||||
if (!is_string($value)) {
|
||||
throw new \InvalidArgumentException('money_decimal_must_be_string');
|
||||
}
|
||||
$amount = \AKSRT\Ledger\AssetAmount::balance(trim($value));
|
||||
return $amount->minor() <= $max_minor ? $amount->decimal() : $default;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $products
|
||||
*/
|
||||
|
||||
@@ -152,14 +152,37 @@ class PostDisplay {
|
||||
if ($rewards_text === '') {
|
||||
$rewards_text = '赞赏';
|
||||
}
|
||||
$rewards_amounts = array_values(array_filter(array_map(static function ($value): float {
|
||||
return round((float) $value, 2);
|
||||
}, explode(',', (string) ($settings['post_rewards_amounts'] ?? '5,10,20,50,100'))), static fn(float $value): bool => $value > 0));
|
||||
if ($rewards_amounts === []) {
|
||||
$rewards_amounts = [5, 10, 20, 50, 100];
|
||||
}
|
||||
$rewards_min = max(0.01, round((float) ($settings['post_rewards_min'] ?? 1), 2));
|
||||
$rewards_max = max($rewards_min, round((float) ($settings['post_rewards_max'] ?? 1000), 2));
|
||||
$reward_amount = static function ($value, string $default): \AKSRT\Ledger\AssetAmount {
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance(is_string($value) ? trim($value) : '');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($default);
|
||||
}
|
||||
};
|
||||
$rewards_amounts = [];
|
||||
foreach (explode(',', (string) ($settings['post_rewards_amounts'] ?? '5.00,10.00,20.00,50.00,100.00')) as $value) {
|
||||
try {
|
||||
$parsed = \AKSRT\Ledger\AssetAmount::balance(trim($value));
|
||||
if ($parsed->minor() > 0) {
|
||||
$rewards_amounts[] = $parsed->decimal();
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ($rewards_amounts === []) {
|
||||
$rewards_amounts = ['5.00', '10.00', '20.00', '50.00', '100.00'];
|
||||
}
|
||||
$rewards_min_amount = $reward_amount($settings['post_rewards_min'] ?? '1.00', '1.00');
|
||||
if ($rewards_min_amount->minor() <= 0) {
|
||||
$rewards_min_amount = \AKSRT\Ledger\AssetAmount::balance('0.01');
|
||||
}
|
||||
$rewards_max_amount = $reward_amount($settings['post_rewards_max'] ?? '1000.00', '1000.00');
|
||||
if ($rewards_max_amount->minor() < $rewards_min_amount->minor()) {
|
||||
$rewards_max_amount = $rewards_min_amount;
|
||||
}
|
||||
$rewards_min = $rewards_min_amount->decimal();
|
||||
$rewards_max = $rewards_max_amount->decimal();
|
||||
$author_card_limit = max(0, min(12, (int) ($settings['post_author_card_limit'] ?? 6)));
|
||||
$author_card_orderby = sanitize_key((string) ($settings['post_author_card_orderby'] ?? 'views'));
|
||||
if (!in_array($author_card_orderby, ['views', 'date', 'modified', 'comment_count'], true)) {
|
||||
@@ -277,6 +300,17 @@ class PostDisplay {
|
||||
public static function get_submit_settings(): array {
|
||||
$settings = Settings::get_all();
|
||||
|
||||
$money_setting = static function ($value, string $default): string {
|
||||
if (!is_string($value)) {
|
||||
return $default;
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $default;
|
||||
}
|
||||
};
|
||||
|
||||
$title_min = max(1, min(100, (int) ($settings['post_article_title_strlen_limit_min'] ?? 5)));
|
||||
$title_max = max(5, min(200, (int) ($settings['post_article_title_strlen_limit_max'] ?? 30)));
|
||||
if ($title_max < $title_min) {
|
||||
@@ -317,8 +351,8 @@ class PostDisplay {
|
||||
'draft_save' => !isset($settings['front_submit_draft_save']) || !empty($settings['front_submit_draft_save']),
|
||||
'leave_warning' => !isset($settings['front_submit_leave_warning']) || !empty($settings['front_submit_leave_warning']),
|
||||
'pay_free_s' => !isset($settings['post_article_pay_free_s']) || !empty($settings['post_article_pay_free_s']),
|
||||
'pay_price_min' => max(0, (float) ($settings['post_article_pay_price_min'] ?? 0)),
|
||||
'pay_price_max' => max(0, (float) ($settings['post_article_pay_price_max'] ?? 100)),
|
||||
'pay_price_min' => $money_setting($settings['post_article_pay_price_min'] ?? '0.00', '0.00'),
|
||||
'pay_price_max' => $money_setting($settings['post_article_pay_price_max'] ?? '100.00', '100.00'),
|
||||
'pay_vip_price_s' => !isset($settings['post_article_pay_vip_price_s']) || !empty($settings['post_article_pay_vip_price_s']),
|
||||
'pay_vip_1_discount' => max(0, min(100, (int) ($settings['post_article_pay_vip_1_discount'] ?? 80))),
|
||||
'pay_vip_2_discount' => max(0, min(100, (int) ($settings['post_article_pay_vip_2_discount'] ?? 50))),
|
||||
|
||||
@@ -105,7 +105,7 @@ class UserRuntime {
|
||||
* User identity, level, medal, and report governance settings.
|
||||
*
|
||||
* @return array{
|
||||
* verify:array{enabled:bool,public_display:bool,allow_apply:bool,disable_desc:string,desc:string,types:array<string,array{slug:string,label:string,badge_text:string,fee:float,free_applications:int,require_real_name:bool}>},
|
||||
* verify:array{enabled:bool,public_display:bool,allow_apply:bool,disable_desc:string,desc:string,types:array<string,array{slug:string,label:string,badge_text:string,fee:string,free_applications:int,require_real_name:bool}>},
|
||||
* level:array{enabled:bool,public_display:bool,source:string,label_prefix:string,thresholds:array<int,int>},
|
||||
* medal:array{enabled:bool,public_display:bool,limit:int,categories:array<int,array{cat_name:string,items:array<int,array{slug:string,name:string,desc:string,icon:string,get_type:string,get_val:int}>}>,items_by_slug:array<string,array{slug:string,name:string,desc:string,icon:string,get_type:string,get_val:int,cat_name:string}>},
|
||||
* badge:array{enabled:bool,public_display:bool,limit:int,dictionary:array<int,array{slug:string,label:string,tone:string,desc:string,icon_url?:string}>},
|
||||
@@ -120,7 +120,7 @@ class UserRuntime {
|
||||
'slug' => 'personal',
|
||||
'label' => __('个人认证', 'aksrt'),
|
||||
'badge_text' => trim((string) ($settings['user_verify_personal_badge_text'] ?? '')) ?: __('个人认证', 'aksrt'),
|
||||
'fee' => max(0, round((float) ($settings['user_verify_personal_fee'] ?? 0), 2)),
|
||||
'fee' => self::identity_fee_setting($settings, 'user_verify_personal_fee'),
|
||||
'free_applications' => max(0, (int) ($settings['user_verify_personal_free_applications'] ?? 0)),
|
||||
'require_real_name' => true,
|
||||
],
|
||||
@@ -128,7 +128,7 @@ class UserRuntime {
|
||||
'slug' => 'enterprise',
|
||||
'label' => __('企业认证', 'aksrt'),
|
||||
'badge_text' => trim((string) ($settings['user_verify_enterprise_badge_text'] ?? '')) ?: __('企业认证', 'aksrt'),
|
||||
'fee' => max(0, round((float) ($settings['user_verify_enterprise_fee'] ?? 0), 2)),
|
||||
'fee' => self::identity_fee_setting($settings, 'user_verify_enterprise_fee'),
|
||||
'free_applications' => max(0, (int) ($settings['user_verify_enterprise_free_applications'] ?? 0)),
|
||||
'require_real_name' => true,
|
||||
],
|
||||
@@ -182,6 +182,19 @@ class UserRuntime {
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $settings */
|
||||
private static function identity_fee_setting(array $settings, string $key): string {
|
||||
$value = array_key_exists($key, $settings) ? $settings[$key] : '0.00';
|
||||
if (!is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁封设置
|
||||
*/
|
||||
@@ -245,8 +258,8 @@ class UserRuntime {
|
||||
'like',
|
||||
'comment_like',
|
||||
'views',
|
||||
'pay_income',
|
||||
'pay_rebate',
|
||||
'creator_income',
|
||||
'affiliate_commission',
|
||||
'manually_add',
|
||||
];
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ class AuthApply {
|
||||
'msg' => (string) ($application['manual_reason'] ?? ''),
|
||||
'type' => (string) ($application['type'] ?? ''),
|
||||
'time' => (string) ($application['created_at'] ?? ''),
|
||||
'fee' => (float) ($application['fee_amount'] ?? 0),
|
||||
'fee' => (string) ($application['fee_amount'] ?? '0.00'),
|
||||
'_application' => $application,
|
||||
];
|
||||
}
|
||||
|
||||
+19
-9
@@ -609,9 +609,25 @@ class Badges {
|
||||
if ($type === '' || $type === 'manually_add') {
|
||||
continue;
|
||||
}
|
||||
$need = max(0, (float) ($item['get_val'] ?? 0));
|
||||
$have = (float) self::metric_value($user_id, $type);
|
||||
if ($have >= $need) {
|
||||
if (in_array($type, ['creator_income', 'affiliate_commission'], true)) {
|
||||
try {
|
||||
$need_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($item['get_val'] ?? '0'))->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
continue;
|
||||
}
|
||||
$reason = $type;
|
||||
$have_minor = \AKSRT\Ledger\LedgerQuery::get_instance()->sum_positive(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::BALANCE,
|
||||
$reason
|
||||
);
|
||||
$qualified = $have_minor >= $need_minor;
|
||||
} else {
|
||||
$need = max(0, (float) ($item['get_val'] ?? 0));
|
||||
$have = (float) self::metric_value($user_id, $type);
|
||||
$qualified = $have >= $need;
|
||||
}
|
||||
if ($qualified) {
|
||||
self::grant($user_id, $slug, 0);
|
||||
$owned_map[$slug] = true;
|
||||
}
|
||||
@@ -678,12 +694,6 @@ class Badges {
|
||||
}
|
||||
return $sum;
|
||||
|
||||
case 'pay_income':
|
||||
return (float) get_user_meta($user_id, 'aksrt_pay_income_sum', true);
|
||||
|
||||
case 'pay_rebate':
|
||||
return (float) get_user_meta($user_id, 'aksrt_pay_rebate_sum', true);
|
||||
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
+45
-33
@@ -27,7 +27,7 @@ class BalanceAdmin {
|
||||
add_filter('manage_users_columns', [$this, 'add_column']);
|
||||
add_filter('manage_users_custom_column', [$this, 'render_column'], 10, 3);
|
||||
add_filter('manage_users_sortable_columns', [$this, 'sortable']);
|
||||
add_action('pre_get_users', [$this, 'sort_users']);
|
||||
add_action('pre_user_query', [$this, 'sort_users']);
|
||||
add_action('show_user_profile', [$this, 'render_section']);
|
||||
add_action('edit_user_profile', [$this, 'render_section']);
|
||||
add_action('personal_options_update', [$this, 'handle_update']);
|
||||
@@ -38,8 +38,13 @@ class BalanceAdmin {
|
||||
return current_user_can('manage_options');
|
||||
}
|
||||
|
||||
private function enabled(): bool {
|
||||
$cfg = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
|
||||
return !empty($cfg['enabled']);
|
||||
}
|
||||
|
||||
public function add_column(array $columns): array {
|
||||
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
|
||||
if (!$this->enabled()) {
|
||||
return $columns;
|
||||
}
|
||||
$columns['aksrt_wallet_balance'] = __('现金余额', 'aksrt');
|
||||
@@ -50,10 +55,7 @@ class BalanceAdmin {
|
||||
if ($column_name !== 'aksrt_wallet_balance') {
|
||||
return $output;
|
||||
}
|
||||
if (!class_exists('\AKSRT\Credits\Balance')) {
|
||||
return '—';
|
||||
}
|
||||
return '<strong>' . esc_html(\AKSRT\Credits\Balance::format(\AKSRT\Credits\Balance::balance($user_id))) . '</strong>';
|
||||
return '<strong>' . esc_html(\AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->formatted()) . '</strong>';
|
||||
}
|
||||
|
||||
public function sortable(array $columns): array {
|
||||
@@ -62,38 +64,42 @@ class BalanceAdmin {
|
||||
}
|
||||
|
||||
public function sort_users($query): void {
|
||||
if (!is_admin() || $query->get('orderby') !== 'aksrt_wallet_balance') {
|
||||
if (!is_admin() || ($query->query_vars['orderby'] ?? '') !== 'aksrt_wallet_balance') {
|
||||
return;
|
||||
}
|
||||
$query->set('meta_key', 'aksrt_balance');
|
||||
$query->set('orderby', 'meta_value_num');
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('asset_account');
|
||||
$query->query_from .= $wpdb->prepare(" LEFT JOIN {$table} aksrt_cash_account ON aksrt_cash_account.user_id = {$wpdb->users}.ID AND aksrt_cash_account.asset = %s", \AKSRT\Ledger\AssetType::BALANCE->value);
|
||||
$order = strtoupper((string) ($query->query_vars['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||
$query->query_orderby = "ORDER BY COALESCE(aksrt_cash_account.balance_minor, 0) {$order}";
|
||||
}
|
||||
|
||||
public function render_section(\WP_User $user): void {
|
||||
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
|
||||
if (!$this->enabled()) {
|
||||
return;
|
||||
}
|
||||
$balance = \AKSRT\Credits\Balance::balance((int) $user->ID);
|
||||
$balance_minor = \AKSRT\Ledger\LedgerService::get_instance()->balance((int) $user->ID, \AKSRT\Ledger\AssetType::BALANCE);
|
||||
$can = $this->can_manage();
|
||||
$log = \AKSRT\Credits\Balance::get_log(['user_id' => (int) $user->ID, 'page' => 1, 'per_page' => 8]);
|
||||
$log = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['user_id' => (int) $user->ID, 'asset' => 'balance', 'page' => 1, 'per_page' => 8]);
|
||||
?>
|
||||
<a id="aksrt-user-wallet"></a>
|
||||
<h2><?php esc_html_e('现金钱包(AKSRT)', 'aksrt'); ?></h2>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th><?php esc_html_e('当前余额', 'aksrt'); ?></th>
|
||||
<td><strong style="font-size:18px;"><?php echo esc_html(\AKSRT\Credits\Balance::format($balance)); ?></strong> <?php esc_html_e('元', 'aksrt'); ?></td>
|
||||
<td><strong style="font-size:18px;"><?php echo esc_html(\AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $balance_minor)->formatted()); ?></strong> <?php esc_html_e('元', 'aksrt'); ?></td>
|
||||
</tr>
|
||||
<?php if ($can) : ?>
|
||||
<tr>
|
||||
<th><?php esc_html_e('调整余额', 'aksrt'); ?></th>
|
||||
<td>
|
||||
<?php wp_nonce_field('aksrt_user_balance_adjust_' . (int) $user->ID, '_aksrt_balance_nonce'); ?>
|
||||
<label for="aksrt_balance_delta"><?php esc_html_e('变动金额(正数=增加,负数=扣减)', 'aksrt'); ?></label><br>
|
||||
<input type="number" step="0.01" name="aksrt_balance_delta" id="aksrt_balance_delta" value="" class="regular-text" />
|
||||
<?php wp_nonce_field('aksrt_user_cash_adjust_' . (int) $user->ID, '_aksrt_cash_nonce'); ?>
|
||||
<input type="hidden" name="aksrt_cash_operation_key" value="<?php echo esc_attr(wp_generate_uuid4()); ?>">
|
||||
<label for="aksrt_cash_delta"><?php esc_html_e('变动金额(正数=增加,负数=扣减)', 'aksrt'); ?></label><br>
|
||||
<input type="number" step="0.01" name="aksrt_cash_delta" id="aksrt_cash_delta" value="" class="regular-text" />
|
||||
<p>
|
||||
<label for="aksrt_balance_reason"><?php esc_html_e('备注', 'aksrt'); ?></label><br>
|
||||
<input type="text" name="aksrt_balance_reason" id="aksrt_balance_reason" class="regular-text" />
|
||||
<label for="aksrt_cash_reason"><?php esc_html_e('备注', 'aksrt'); ?></label><br>
|
||||
<input type="text" name="aksrt_cash_reason" id="aksrt_cash_reason" class="regular-text" />
|
||||
</p>
|
||||
<p class="description"><?php esc_html_e('保存用户资料时生效,并写入余额流水。', 'aksrt'); ?></p>
|
||||
</td>
|
||||
@@ -109,9 +115,9 @@ class BalanceAdmin {
|
||||
<?php foreach ($log['items'] as $row) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html(get_date_from_gmt((string) ($row['created_at'] ?? ''), 'Y-m-d H:i')); ?></td>
|
||||
<td><?php echo esc_html(($row['delta'] > 0 ? '+' : '') . \AKSRT\Credits\Balance::format((float) $row['delta'])); ?></td>
|
||||
<td><?php echo esc_html(\AKSRT\Credits\Balance::format((float) ($row['balance_after'] ?? 0))); ?></td>
|
||||
<td><?php echo esc_html(\AKSRT\Credits\Balance::reason_label((string) ($row['reason'] ?? ''))); ?></td>
|
||||
<td><?php echo esc_html(((int) $row['delta_minor'] > 0 ? '+' : '-') . \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs((int) $row['delta_minor']))->formatted()); ?></td>
|
||||
<td><?php echo esc_html(\AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) ($row['balance_after_minor'] ?? 0))->formatted()); ?></td>
|
||||
<td><?php echo esc_html(\AKSRT\Ledger\LedgerPresentation::reason_label((string) ($row['reason'] ?? ''))); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -126,27 +132,33 @@ class BalanceAdmin {
|
||||
}
|
||||
|
||||
public function handle_update(int $user_id): void {
|
||||
if (!$this->can_manage() || !class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
|
||||
if (!$this->can_manage() || !$this->enabled()) {
|
||||
return;
|
||||
}
|
||||
if (empty($_POST['_aksrt_balance_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash((string) $_POST['_aksrt_balance_nonce'])), 'aksrt_user_balance_adjust_' . $user_id)) {
|
||||
if (!current_user_can('edit_user', $user_id)) {
|
||||
return;
|
||||
}
|
||||
if (!isset($_POST['aksrt_balance_delta']) || $_POST['aksrt_balance_delta'] === '') {
|
||||
if (empty($_POST['_aksrt_cash_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash((string) $_POST['_aksrt_cash_nonce'])), 'aksrt_user_cash_adjust_' . $user_id)) {
|
||||
return;
|
||||
}
|
||||
$delta = round((float) wp_unslash($_POST['aksrt_balance_delta']), 2);
|
||||
if (abs($delta) < 0.0001) {
|
||||
if (!isset($_POST['aksrt_cash_delta']) || $_POST['aksrt_cash_delta'] === '') {
|
||||
return;
|
||||
}
|
||||
$reason = isset($_POST['aksrt_balance_reason'])
|
||||
? sanitize_text_field(wp_unslash((string) $_POST['aksrt_balance_reason']))
|
||||
$raw_delta = trim((string) wp_unslash($_POST['aksrt_cash_delta']));
|
||||
$negative = str_starts_with($raw_delta, '-');
|
||||
try { $amount = \AKSRT\Ledger\AssetAmount::balance($negative ? substr($raw_delta, 1) : $raw_delta); }
|
||||
catch (\InvalidArgumentException $e) { return; }
|
||||
if ($amount->minor() <= 0) return;
|
||||
$reason = isset($_POST['aksrt_cash_reason'])
|
||||
? sanitize_text_field(wp_unslash((string) $_POST['aksrt_cash_reason']))
|
||||
: __('管理员调整', 'aksrt');
|
||||
if ($delta > 0) {
|
||||
$result = \AKSRT\Credits\Balance::add($user_id, $delta, 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason);
|
||||
} else {
|
||||
$result = \AKSRT\Credits\Balance::deduct($user_id, abs($delta), 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason);
|
||||
}
|
||||
$request_key = isset($_POST['aksrt_cash_operation_key']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_cash_operation_key'])) : '';
|
||||
if (!wp_is_uuid($request_key)) return;
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('admin-balance-adjustment', [get_current_user_id(), $request_key]);
|
||||
$ledger = \AKSRT\Ledger\LedgerService::get_instance();
|
||||
$result = $negative
|
||||
? $ledger->debit($user_id, \AKSRT\Ledger\AssetType::BALANCE, $amount->minor(), $operation_key, 'admin_adjustment', ['type' => 'user', 'id' => $user_id], $reason, get_current_user_id())
|
||||
: $ledger->credit($user_id, \AKSRT\Ledger\AssetType::BALANCE, $amount->minor(), $operation_key, 'admin_adjustment', ['type' => 'user', 'id' => $user_id], $reason, get_current_user_id());
|
||||
if (is_wp_error($result)) {
|
||||
set_transient('aksrt_admin_notice_' . get_current_user_id(), [[
|
||||
'type' => 'error',
|
||||
|
||||
+2
-1
@@ -217,7 +217,8 @@ class Center {
|
||||
$level_on = class_exists('\AKSRT\User\Level')
|
||||
&& !empty(\AKSRT\User\Level::settings()['enabled']);
|
||||
$credits_on = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled();
|
||||
$balance_on = class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled();
|
||||
$recharge = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
|
||||
$balance_on = !empty($recharge['enabled']);
|
||||
$revenue_tab_on = class_exists('\AKSRT\Settings\Settings')
|
||||
&& \AKSRT\Settings\Settings::referral_enabled();
|
||||
$creator_tab_on = class_exists('\AKSRT\Settings\Settings')
|
||||
|
||||
+58
-70
@@ -22,7 +22,7 @@ class CreditsAdmin {
|
||||
add_filter('manage_users_columns', [$this, 'add_balance_column']);
|
||||
add_filter('manage_users_custom_column', [$this, 'render_balance_column'], 10, 3);
|
||||
add_filter('manage_users_sortable_columns', [$this, 'make_balance_sortable']);
|
||||
add_action('pre_get_users', [$this, 'sort_users_by_credits']);
|
||||
add_action('pre_user_query', [$this, 'sort_users_by_credits']);
|
||||
add_filter('user_row_actions', [$this, 'add_row_actions'], 30, 2);
|
||||
|
||||
add_action('show_user_profile', [$this, 'render_user_credits_section']);
|
||||
@@ -39,7 +39,7 @@ class CreditsAdmin {
|
||||
|
||||
public function add_balance_column(array $columns): array {
|
||||
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
|
||||
$columns['aksrt_credits_balance'] = sprintf(__('%s余额', 'aksrt'), $currency_name);
|
||||
$columns['aksrt_points_balance'] = sprintf(__('%s余额', 'aksrt'), $currency_name);
|
||||
$columns['aksrt_user_commerce'] = __('资产/分成', 'aksrt');
|
||||
return $columns;
|
||||
}
|
||||
@@ -48,20 +48,14 @@ class CreditsAdmin {
|
||||
if ($column_name === 'aksrt_user_commerce') {
|
||||
return $this->render_commerce_column($user_id);
|
||||
}
|
||||
if ($column_name !== 'aksrt_credits_balance') {
|
||||
if ($column_name !== 'aksrt_points_balance') {
|
||||
return $output;
|
||||
}
|
||||
if (!class_exists('\AKSRT\Credits\Credits')) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$balance = \AKSRT\Credits\Credits::balance($user_id);
|
||||
$balance = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS);
|
||||
return '<strong>' . esc_html((string) $balance) . '</strong>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact commerce snapshot: VIP, revenue, rebate, download logs link.
|
||||
*/
|
||||
/** Compact commerce snapshot: VIP, creator revenue, and operational links. */
|
||||
private function render_commerce_column(int $user_id): string {
|
||||
$bits = [];
|
||||
if (function_exists('aksrt_get_user_vip_level')) {
|
||||
@@ -74,34 +68,26 @@ class CreditsAdmin {
|
||||
$stats = \AKSRT\Payment\Revenue::get_instance()->get_stats($user_id);
|
||||
$cash = (array) ($stats['cash'] ?? []);
|
||||
$points = (array) ($stats['points'] ?? []);
|
||||
if ((float) ($cash['total'] ?? 0) > 0 || (float) ($cash['pending'] ?? 0) > 0) {
|
||||
if ((string) ($cash['total'] ?? '0.00') !== '0.00' || (string) ($cash['pending'] ?? '0.00') !== '0.00') {
|
||||
$bits[] = sprintf(
|
||||
/* translators: 1 total 2 pending */
|
||||
esc_html__('现金分成 ¥%1$s(待结 ¥%2$s)', 'aksrt'),
|
||||
number_format((float) ($cash['total'] ?? 0), 2),
|
||||
number_format((float) ($cash['pending'] ?? 0), 2)
|
||||
(string) ($cash['total'] ?? '0.00'),
|
||||
(string) ($cash['pending'] ?? '0.00')
|
||||
);
|
||||
}
|
||||
if ((float) ($points['total'] ?? 0) > 0 || (float) ($points['pending'] ?? 0) > 0) {
|
||||
if ((int) ($points['total'] ?? 0) > 0 || (int) ($points['pending'] ?? 0) > 0) {
|
||||
$bits[] = sprintf(
|
||||
/* translators: 1 total 2 pending */
|
||||
esc_html__('积分分成 %1$s(待结 %2$s)', 'aksrt'),
|
||||
number_format((float) ($points['total'] ?? 0), 0),
|
||||
number_format((float) ($points['pending'] ?? 0), 0)
|
||||
number_format_i18n((int) ($points['total'] ?? 0)),
|
||||
number_format_i18n((int) ($points['pending'] ?? 0))
|
||||
);
|
||||
}
|
||||
if ($bits) {
|
||||
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_income&user_id=' . $user_id)) . '">' . esc_html__('分成明细', 'aksrt') . '</a>';
|
||||
}
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\Rebate')) {
|
||||
$list = \AKSRT\Payment\Rebate::get_instance()->get_list(['user_id' => $user_id, 'limit' => 1]);
|
||||
$total_r = (int) ($list['total'] ?? 0);
|
||||
if ($total_r > 0) {
|
||||
$bits[] = sprintf(esc_html__('返佣 %d 笔', 'aksrt'), $total_r);
|
||||
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_rebate&user_id=' . $user_id)) . '">' . esc_html__('返佣', 'aksrt') . '</a>';
|
||||
}
|
||||
}
|
||||
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_download_logs&user_id=' . $user_id)) . '">' . esc_html__('下载记录', 'aksrt') . '</a>';
|
||||
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_orders&user_id=' . $user_id)) . '">' . esc_html__('订单', 'aksrt') . '</a>';
|
||||
|
||||
@@ -112,19 +98,19 @@ class CreditsAdmin {
|
||||
}
|
||||
|
||||
public function make_balance_sortable(array $columns): array {
|
||||
$columns['aksrt_credits_balance'] = 'aksrt_credits_balance';
|
||||
$columns['aksrt_points_balance'] = 'aksrt_points_balance';
|
||||
return $columns;
|
||||
}
|
||||
|
||||
public function sort_users_by_credits($query): void {
|
||||
if (!is_admin()) {
|
||||
if (!is_admin() || ($query->query_vars['orderby'] ?? '') !== 'aksrt_points_balance') {
|
||||
return;
|
||||
}
|
||||
if ($query->get('orderby') !== 'aksrt_credits_balance') {
|
||||
return;
|
||||
}
|
||||
$query->set('meta_key', 'aksrt_credits');
|
||||
$query->set('orderby', 'meta_value_num');
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('asset_account');
|
||||
$query->query_from .= $wpdb->prepare(" LEFT JOIN {$table} aksrt_points_account ON aksrt_points_account.user_id = {$wpdb->users}.ID AND aksrt_points_account.asset = %s", \AKSRT\Ledger\AssetType::POINTS->value);
|
||||
$order = strtoupper((string) ($query->query_vars['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||
$query->query_orderby = "ORDER BY COALESCE(aksrt_points_account.balance_minor, 0) {$order}";
|
||||
}
|
||||
|
||||
// ========== 用户列表:行操作 ==========
|
||||
@@ -155,11 +141,7 @@ class CreditsAdmin {
|
||||
// ========== 用户编辑页:积分管理区块 ==========
|
||||
|
||||
public function render_user_credits_section(\WP_User $user): void {
|
||||
if (!class_exists('\AKSRT\Credits\Credits')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$balance = \AKSRT\Credits\Credits::balance((int) $user->ID);
|
||||
$balance = \AKSRT\Ledger\LedgerService::get_instance()->balance((int) $user->ID, \AKSRT\Ledger\AssetType::POINTS);
|
||||
$can_manage = $this->can_manage_credits();
|
||||
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
|
||||
?>
|
||||
@@ -178,15 +160,16 @@ class CreditsAdmin {
|
||||
<tr>
|
||||
<th><?php echo esc_html(sprintf(__('调整%s', 'aksrt'), $currency_name)); ?></th>
|
||||
<td>
|
||||
<?php wp_nonce_field('aksrt_user_credits_adjust_' . (int) $user->ID, '_aksrt_credits_nonce'); ?>
|
||||
<?php wp_nonce_field('aksrt_user_points_adjust_' . (int) $user->ID, '_aksrt_points_nonce'); ?>
|
||||
<input type="hidden" name="aksrt_points_operation_key" value="<?php echo esc_attr(wp_generate_uuid4()); ?>">
|
||||
<fieldset>
|
||||
<label for="aksrt_credits_delta" style="display:block;margin-bottom:6px;">
|
||||
<label for="aksrt_points_delta" style="display:block;margin-bottom:6px;">
|
||||
<?php esc_html_e('变动数量(正数=增加,负数=扣减)', 'aksrt'); ?>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="aksrt_credits_delta"
|
||||
name="aksrt_credits_delta"
|
||||
id="aksrt_points_delta"
|
||||
name="aksrt_points_delta"
|
||||
class="regular-text"
|
||||
min="-100000"
|
||||
max="100000"
|
||||
@@ -197,16 +180,16 @@ class CreditsAdmin {
|
||||
<?php esc_html_e('填写 0 不会执行任何变更。', 'aksrt'); ?>
|
||||
</p>
|
||||
|
||||
<label for="aksrt_credits_reason" style="display:block;margin-bottom:6px;">
|
||||
<label for="aksrt_points_reason" style="display:block;margin-bottom:6px;">
|
||||
<?php esc_html_e('调整原因', 'aksrt'); ?>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="aksrt_credits_reason"
|
||||
name="aksrt_credits_reason"
|
||||
id="aksrt_points_reason"
|
||||
name="aksrt_points_reason"
|
||||
class="regular-text"
|
||||
maxlength="100"
|
||||
placeholder="<?php echo esc_attr__('例如:手工补偿 / 运营赠送 / 退款修正', 'aksrt'); ?>"
|
||||
placeholder="<?php echo esc_attr__('例如:运营赠送 / 活动奖励 / 人工核账', 'aksrt'); ?>"
|
||||
>
|
||||
<p class="description">
|
||||
<?php esc_html_e('勾选后点击页面底部"更新用户"保存即可生效。', 'aksrt'); ?>
|
||||
@@ -220,17 +203,8 @@ class CreditsAdmin {
|
||||
|
||||
<?php
|
||||
// ========== 近期积分流水 ==========
|
||||
$log = \AKSRT\Credits\Credits::get_log(['user_id' => (int) $user->ID, 'page' => 1, 'per_page' => 20]);
|
||||
$log = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['user_id' => (int) $user->ID, 'asset' => 'points', 'page' => 1, 'per_page' => 20]);
|
||||
$items = $log['items'] ?? [];
|
||||
$reason_labels = [
|
||||
'register' => __('注册赠送', 'aksrt'),
|
||||
'signin' => __('每日签到', 'aksrt'),
|
||||
'comment' => __('评论奖励', 'aksrt'),
|
||||
'referral' => __('推广注册奖励', 'aksrt'),
|
||||
'referral_new_user' => __('推广注册新用户奖励', 'aksrt'),
|
||||
'download' => __('下载消费', 'aksrt'),
|
||||
'admin' => __('管理员调整', 'aksrt'),
|
||||
];
|
||||
?>
|
||||
<h3 style="margin-top:24px;"><?php echo esc_html(sprintf(__('近期%s流水', 'aksrt'), $currency_name)); ?></h3>
|
||||
<?php if (!empty($items)) : ?>
|
||||
@@ -246,9 +220,9 @@ class CreditsAdmin {
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($items as $row) :
|
||||
$delta = (int) $row['delta'];
|
||||
$delta = (int) $row['delta_minor'];
|
||||
$reason_key = (string) ($row['reason'] ?? '');
|
||||
$label = $reason_labels[$reason_key] ?? $reason_key;
|
||||
$label = \AKSRT\Ledger\LedgerPresentation::reason_label($reason_key);
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo esc_html(get_date_from_gmt((string) $row['created_at'], 'Y-m-d H:i')); ?></td>
|
||||
@@ -257,7 +231,7 @@ class CreditsAdmin {
|
||||
<td style="color:<?php echo $delta >= 0 ? '#16a34a' : '#ef4444'; ?>; font-weight:600;">
|
||||
<?php echo esc_html(($delta > 0 ? '+' : '') . $delta); ?>
|
||||
</td>
|
||||
<td><?php echo esc_html((string) (int) $row['balance_after']); ?></td>
|
||||
<td><?php echo esc_html((string) (int) $row['balance_after_minor']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -270,23 +244,36 @@ class CreditsAdmin {
|
||||
// ========== 处理积分调整 ==========
|
||||
|
||||
public function handle_user_credits_update(int $user_id): void {
|
||||
if (!$this->can_manage_credits() || !class_exists('\AKSRT\Credits\Credits')) {
|
||||
if (!$this->can_manage_credits()) {
|
||||
return;
|
||||
}
|
||||
if (!current_user_can('edit_user', $user_id)) {
|
||||
return;
|
||||
}
|
||||
if (!isset($_POST['_aksrt_credits_nonce'])) {
|
||||
if (!isset($_POST['_aksrt_points_nonce'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nonce = sanitize_text_field((string) $_POST['_aksrt_credits_nonce']);
|
||||
if (!wp_verify_nonce($nonce, 'aksrt_user_credits_adjust_' . $user_id)) {
|
||||
$nonce = sanitize_text_field((string) $_POST['_aksrt_points_nonce']);
|
||||
if (!wp_verify_nonce($nonce, 'aksrt_user_points_adjust_' . $user_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$delta = isset($_POST['aksrt_credits_delta']) ? (int) $_POST['aksrt_credits_delta'] : 0;
|
||||
$reason = isset($_POST['aksrt_credits_reason']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_credits_reason'])) : '';
|
||||
$raw_delta = isset($_POST['aksrt_points_delta'])
|
||||
? trim((string) wp_unslash($_POST['aksrt_points_delta']))
|
||||
: '';
|
||||
$unsigned_delta = str_starts_with($raw_delta, '-') ? substr($raw_delta, 1) : $raw_delta;
|
||||
$max_integer = (string) PHP_INT_MAX;
|
||||
if (
|
||||
!preg_match('/^-?(?:0|[1-9]\d*)$/', $raw_delta)
|
||||
|| strlen($unsigned_delta) > strlen($max_integer)
|
||||
|| (strlen($unsigned_delta) === strlen($max_integer) && strcmp($unsigned_delta, $max_integer) > 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
$delta = (int) $raw_delta;
|
||||
$reason = isset($_POST['aksrt_points_reason']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_points_reason'])) : '';
|
||||
$request_key = isset($_POST['aksrt_points_operation_key']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_points_operation_key'])) : '';
|
||||
|
||||
if ($delta === 0) {
|
||||
return;
|
||||
@@ -300,11 +287,12 @@ class CreditsAdmin {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($delta > 0) {
|
||||
$result = \AKSRT\Credits\Credits::add($user_id, $delta, 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason);
|
||||
} else {
|
||||
$result = \AKSRT\Credits\Credits::deduct($user_id, abs($delta), 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason);
|
||||
}
|
||||
if (!wp_is_uuid($request_key)) return;
|
||||
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('admin-points-adjustment', [get_current_user_id(), $request_key]);
|
||||
$ledger = \AKSRT\Ledger\LedgerService::get_instance();
|
||||
$result = $delta > 0
|
||||
? $ledger->credit($user_id, \AKSRT\Ledger\AssetType::POINTS, $delta, $operation_key, 'admin_adjustment', ['type' => 'user', 'id' => $user_id], $reason, get_current_user_id())
|
||||
: $ledger->debit($user_id, \AKSRT\Ledger\AssetType::POINTS, abs($delta), $operation_key, 'admin_adjustment', ['type' => 'user', 'id' => $user_id], $reason, get_current_user_id());
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
set_transient('aksrt_admin_notice_' . get_current_user_id(), [[
|
||||
@@ -316,7 +304,7 @@ class CreditsAdmin {
|
||||
|
||||
set_transient('aksrt_admin_notice_' . get_current_user_id(), [[
|
||||
'type' => 'success',
|
||||
'msg' => sprintf(__('%s调整成功,当前余额:%d', 'aksrt'), $currency_name, (int) $result),
|
||||
'msg' => sprintf(__('%s调整成功,当前余额:%d', 'aksrt'), $currency_name, $result->balance($user_id, \AKSRT\Ledger\AssetType::POINTS)),
|
||||
]], 30);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-33
@@ -12,9 +12,6 @@ class Metrics {
|
||||
public const META_POST_LIKE_SUM = 'aksrt_posts_like_sum';
|
||||
public const META_COMMENT_LIKE_SUM = 'aksrt_comment_like_sum';
|
||||
public const META_POSTS_VIEWS_SUM = 'aksrt_posts_views_sum';
|
||||
public const META_PAY_INCOME = 'aksrt_pay_income_sum';
|
||||
public const META_PAY_INCOME_POINTS = 'aksrt_pay_income_points_sum';
|
||||
public const META_PAY_REBATE = 'aksrt_pay_rebate_sum';
|
||||
public const META_CHECKIN_ALL_DAY = 'aksrt_checkin_all_day';
|
||||
|
||||
/** @var self|null */
|
||||
@@ -34,8 +31,7 @@ class Metrics {
|
||||
add_action('aksrt_post_viewed', [$this, 'on_post_viewed'], 20, 2);
|
||||
|
||||
add_action('aksrt_user_signed_in', [$this, 'on_user_signed_in'], 20, 1);
|
||||
add_action('aksrt_creator_income_added', [$this, 'on_creator_income_added'], 20, 3);
|
||||
add_action('aksrt_pay_rebate_added', [$this, 'on_pay_rebate_added'], 20, 3);
|
||||
add_action('aksrt_asset_transaction_committed', [$this, 'on_asset_transaction_committed'], 20, 2);
|
||||
}
|
||||
|
||||
public static function bump_user_meta(int $user_id, string $meta_key, float $delta = 1.0): float {
|
||||
@@ -119,36 +115,23 @@ class Metrics {
|
||||
self::reevaluate_medals($user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $user_id
|
||||
* @param float $amount
|
||||
* @param array $context
|
||||
*/
|
||||
public function on_creator_income_added($user_id, $amount, $context = []): void {
|
||||
$currency = sanitize_key((string) ($context['currency'] ?? 'CNY'));
|
||||
$user_id = (int) $user_id;
|
||||
$amount = (float) $amount;
|
||||
if ($user_id <= 0 || $amount <= 0) {
|
||||
public function on_asset_transaction_committed($result, $command): void {
|
||||
if (!$result instanceof \AKSRT\Ledger\LedgerResult || !$command instanceof \AKSRT\Ledger\LedgerCommand) {
|
||||
return;
|
||||
}
|
||||
$meta_key = $currency === 'points' ? self::META_PAY_INCOME_POINTS : self::META_PAY_INCOME;
|
||||
self::bump_user_meta($user_id, $meta_key, $amount);
|
||||
self::reevaluate_medals($user_id);
|
||||
if (!in_array($command->reason, ['creator_income', 'affiliate_commission'], true)) {
|
||||
return;
|
||||
}
|
||||
$users = [];
|
||||
foreach ($result->entries as $entry) {
|
||||
$user_id = (int) ($entry['user_id'] ?? 0);
|
||||
if ($user_id > 0 && (int) ($entry['delta_minor'] ?? 0) > 0) {
|
||||
$users[$user_id] = true;
|
||||
}
|
||||
}
|
||||
foreach (array_keys($users) as $user_id) {
|
||||
self::reevaluate_medals((int) $user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $user_id
|
||||
* @param float $amount
|
||||
* @param array $context
|
||||
*/
|
||||
public function on_pay_rebate_added($user_id, $amount, $context = []): void {
|
||||
unset($context);
|
||||
$user_id = (int) $user_id;
|
||||
$amount = (float) $amount;
|
||||
if ($user_id <= 0 || $amount <= 0) {
|
||||
return;
|
||||
}
|
||||
self::bump_user_meta($user_id, self::META_PAY_REBATE, $amount);
|
||||
self::reevaluate_medals($user_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ final class UserRenderer {
|
||||
private static function metric(\WP_User $user, string $metric): int {
|
||||
$id = (int) $user->ID;
|
||||
if ($metric === 'points') {
|
||||
return function_exists('aksrt_user_credits') ? (int) aksrt_user_credits($id) : 0;
|
||||
return aksrt_user_points($id);
|
||||
}
|
||||
if ($metric === 'followers') {
|
||||
return class_exists('AKSRT\\User\\Follow') ? (int) \AKSRT\User\Follow::get_instance()->get_follower_count($id) : 0;
|
||||
|
||||
Reference in New Issue
Block a user