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

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