feat(payment): rebuild order details and asset records

This commit is contained in:
kate522 committed 2026-10-05 16:42:25 +08:00
1 parent 476e2f961d
commit 7b55cfb190
57 files changed
+2080 -1294

No files matched your search

-195
View File
@@ -1,195 +0,0 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
final class PayFlows {
public const CAPABILITY = 'manage_options';
public const PAGE_SLUG = 'aksrt_flows';
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;
}
public function render_page(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
$keyword = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
$event_type = isset($_GET['event_type']) ? sanitize_key((string) wp_unslash($_GET['event_type'])) : '';
$asset = isset($_GET['asset']) ? sanitize_key((string) wp_unslash($_GET['asset'])) : '';
$direction = isset($_GET['direction']) ? sanitize_key((string) wp_unslash($_GET['direction'])) : '';
$user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0;
$date_from = isset($_GET['date_from']) ? $this->sanitize_date((string) wp_unslash($_GET['date_from'])) : '';
$date_to = isset($_GET['date_to']) ? $this->sanitize_date((string) wp_unslash($_GET['date_to'])) : '';
$page = max(1, (int) ($_GET['paged'] ?? 1));
$query = \AKSRT\Finance\FinancialFlowQuery::get_instance();
$result = $query->search([
'keyword' => $keyword,
'event_type' => $event_type,
'asset' => $asset,
'direction' => $direction,
'user_id' => $user_id,
'date_from' => $this->date_bound_utc($date_from, false),
'date_to' => $this->date_bound_utc($date_to, true),
'page' => $page,
'per_page' => 20,
]);
$pages = max(1, (int) ceil($result['total'] / $result['per_page']));
echo '<div class="wrap"><h1>' . esc_html__('流水明细', 'aksrt') . '</h1>';
echo '<p class="description">' . esc_html__('仅展示已经确认发生的现金、余额和积分变动;流水不可编辑。', 'aksrt') . '</p>';
echo '<form method="get" style="margin:12px 0;display:flex;gap:6px;align-items:center;flex-wrap:wrap;">';
echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '">';
echo '<input type="search" name="s" value="' . esc_attr($keyword) . '" placeholder="' . esc_attr__('账本流水号 / 流水号 / 渠道交易号', 'aksrt') . '" style="min-width:280px;">';
echo '<input type="number" min="1" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" class="small-text">';
echo '<select name="event_type"><option value="">' . esc_html__('全部事件', 'aksrt') . '</option>';
foreach ($query->event_types() as $key => $label) {
echo '<option value="' . esc_attr($key) . '"' . selected($event_type, $key, false) . '>' . esc_html($this->event_label($label)) . '</option>';
}
echo '</select>';
$this->render_select('asset', $asset, __('全部资产', 'aksrt'), ['cash' => __('现金', 'aksrt'), 'balance' => __('余额', 'aksrt'), 'points' => __('积分', 'aksrt')]);
$this->render_select('direction', $direction, __('全部方向', 'aksrt'), ['in' => __('收入', 'aksrt'), 'out' => __('支出', 'aksrt')]);
echo '<label>' . esc_html__('从', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '"></label>';
echo '<label>' . esc_html__('至', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '"></label>';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
if ($keyword !== '' || $event_type !== '' || $asset !== '' || $direction !== '' || $user_id > 0 || $date_from !== '' || $date_to !== '') {
echo '<a class="button" href="' . esc_url($this->page_url()) . '">' . esc_html__('清除条件', 'aksrt') . '</a>';
}
echo '</form>';
echo '<p>' . esc_html(sprintf(__('共 %d 条流水', 'aksrt'), (int) $result['total'])) . '</p>';
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
echo '<th style="width:180px">' . esc_html__('账本流水号 / 时间', 'aksrt') . '</th>';
echo '<th style="width:130px">' . esc_html__('事件', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户与分录', 'aksrt') . '</th>';
echo '<th>' . esc_html__('关联业务', 'aksrt') . '</th>';
echo '<th>' . esc_html__('渠道交易号', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if ($result['items'] === []) {
echo '<tr><td colspan="5">' . esc_html__('暂无匹配流水', 'aksrt') . '</td></tr>';
}
foreach ($result['items'] as $flow) {
echo '<tr>';
echo '<td><code>' . esc_html((string) $flow['flow_number']) . '</code><br><span class="description">' . esc_html($this->local_time((string) $flow['created_at'])) . '</span></td>';
echo '<td><strong>' . esc_html($this->event_label((string) $flow['event_type'])) . '</strong><br><code>' . esc_html((string) $flow['event_type']) . '</code></td>';
echo '<td>' . $this->entries_html((array) $flow['entries']) . '</td>';
echo '<td>' . $this->reference_html($flow) . '</td>';
echo '<td>' . ((string) ($flow['provider_transaction_id'] ?? '') !== '' ? '<code>' . esc_html((string) $flow['provider_transaction_id']) . '</code>' : '&mdash;') . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
if ($pages > 1) {
echo '<div class="tablenav"><div class="tablenav-pages">' . wp_kses_post((string) paginate_links([
'base' => add_query_arg('paged', '%#%'),
'format' => '',
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
'total' => $pages,
'current' => $page,
])) . '</div></div>';
}
echo '</div>';
}
/** @param array<string,string> $options */
private function render_select(string $name, string $value, string $empty_label, array $options): void {
echo '<select name="' . esc_attr($name) . '"><option value="">' . esc_html($empty_label) . '</option>';
foreach ($options as $key => $label) {
echo '<option value="' . esc_attr($key) . '"' . selected($value, $key, false) . '>' . esc_html($label) . '</option>';
}
echo '</select>';
}
/** @param array<int,array<string,mixed>> $entries */
private function entries_html(array $entries): string {
if ($entries === []) {
return '&mdash;';
}
$lines = [];
foreach ($entries as $entry) {
$user_id = (int) ($entry['user_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null;
$user_label = $user ? $user->display_name . ' (#' . $user_id . ')' : '#' . $user_id;
$asset = (string) ($entry['asset'] ?? '');
$delta = (int) ($entry['delta_minor'] ?? 0);
$amount = $asset === 'points'
? (string) abs($delta)
: \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs($delta))->decimal();
$asset_label = ['cash' => __('现金', 'aksrt'), 'balance' => __('余额', 'aksrt'), 'points' => __('积分', 'aksrt')][$asset] ?? $asset;
$lines[] = esc_html($user_label) . ' · ' . esc_html($asset_label) . ' <strong>' . ($delta > 0 ? '+' : '-') . esc_html($amount) . '</strong>';
}
return implode('<br>', $lines);
}
/** @param array<string,mixed> $flow */
private function reference_html(array $flow): string {
$lines = [];
if (!empty($flow['order_number'])) {
$url = add_query_arg(['page' => PayOrders::PAGE_SLUG, 'order' => (string) $flow['order_number']], admin_url('admin.php'));
$lines[] = esc_html__('订单', 'aksrt') . ' <a href="' . esc_url($url) . '"><code>' . esc_html((string) $flow['order_number']) . '</code></a>';
}
if (!empty($flow['withdrawal_number'])) {
$lines[] = esc_html__('提现', 'aksrt') . ' <code>' . esc_html((string) $flow['withdrawal_number']) . '</code>';
}
if ($lines === [] && !empty($flow['ref_key'])) {
$lines[] = esc_html((string) ($flow['ref_type'] ?? '')) . ' · ' . esc_html((string) $flow['ref_key']);
}
return $lines !== [] ? implode('<br>', $lines) : '&mdash;';
}
private function event_label(string $event_type): string {
$labels = [
'order_payment' => __('订单支付', 'aksrt'),
'order_refund' => __('订单退款', 'aksrt'),
'withdraw_hold' => __('提现冻结', 'aksrt'),
'withdraw_refund' => __('提现退回', 'aksrt'),
'withdraw_payout' => __('提现打款', 'aksrt'),
'creator_income' => __('创作收益', 'aksrt'),
'admin_adjustment' => __('后台调账', 'aksrt'),
'points_exchange' => __('积分兑换', 'aksrt'),
'transfer' => __('用户转账', 'aksrt'),
];
return $labels[$event_type] ?? $event_type;
}
private function sanitize_date(string $date): string {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : '';
}
private function date_bound_utc(string $date, bool $exclusive_end): string {
if ($date === '') {
return '';
}
$local = new \DateTimeImmutable($date . ' 00:00:00', wp_timezone());
if ($exclusive_end) {
$local = $local->modify('+1 day');
}
return $local->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
}
private function local_time(string $utc): string {
if ($utc === '') {
return '';
}
try {
return (new \DateTimeImmutable($utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone())->format('Y-m-d H:i:s');
} catch (\Exception $e) {
return $utc;
}
}
private function page_url(): string {
return add_query_arg(['page' => self::PAGE_SLUG], admin_url('admin.php'));
}
}
+1 -8
View File
@@ -43,7 +43,7 @@ class PayMenus {
],
'orders' => [
'slug' => 'aksrt_orders',
'title' => __('订单', 'aksrt'),
'title' => __('订单明细', 'aksrt'),
'callback' => static function (): void {
if (class_exists('AKSRT\Admin\Native\PayOrders')) {
PayOrders::get_instance()->render_orders_page();
@@ -52,13 +52,6 @@ class PayMenus {
wp_die(esc_html__('订单模块未初始化。', 'aksrt'));
},
],
'flows' => [
'slug' => PayFlows::PAGE_SLUG,
'title' => __('流水明细', 'aksrt'),
'callback' => static function (): void {
PayFlows::get_instance()->render_page();
},
],
'products' => [
'slug' => 'aksrt_pay_products',
'title' => __('付费文章', 'aksrt'),
+158 -29
View File
@@ -29,11 +29,12 @@ class PayOrders {
add_action('admin_post_aksrt_pay_update_order_note', [$this, 'handle_update_order_note']);
add_action('admin_post_aksrt_pay_clear_closed_orders', [$this, 'handle_clear_closed_orders']);
add_action('admin_post_aksrt_pay_delete_order', [$this, 'handle_delete_order']);
add_action('admin_post_aksrt_after_sale_review', [$this, 'handle_after_sale_review']);
}
public function render_orders_page(): void {
$this->ensure_cap();
$this->render_header(__('订单', 'aksrt'));
$this->render_header(__('订单明细', 'aksrt'));
if (!class_exists('\AKSRT\Payment\OrderRepository')) {
echo '<div class="notice notice-error"><p>' . esc_html__('订单服务不可用。', 'aksrt') . '</p></div>';
@@ -41,7 +42,21 @@ class PayOrders {
return;
}
$detail_order = isset($_GET['order']) ? sanitize_text_field((string) wp_unslash($_GET['order'])) : '';
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : 'orders';
if (!in_array($tab, ['stats', 'orders', 'after-sales'], true)) $tab = 'orders';
$this->render_workbench_tabs($tab);
if ($tab === 'stats') {
$this->render_statistics_page();
$this->render_footer();
return;
}
if ($tab === 'after-sales') {
$this->render_after_sales_page();
$this->render_footer();
return;
}
$detail_order = isset($_GET['order']) ? sanitize_text_field((string) wp_unslash($_GET['order'])) : '';
if ($detail_order !== '') {
$this->render_order_detail_page($detail_order);
$this->render_footer();
@@ -50,10 +65,13 @@ class PayOrders {
$type_tabs = [
'' => __('全部类型', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE => __('内容付费', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP => __('VIP', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE => __('充值', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS => __('购买积分', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => __('付费阅读', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => __('付费下载', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => __('付费图片', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => __('付费视频', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP => __('购买会员', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE => __('余额充值', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS => __('购买积分', 'aksrt'),
];
$method_tabs = [
'' => __('支付方式', 'aksrt'),
@@ -68,6 +86,8 @@ class PayOrders {
$product_id = isset($_GET['product_id']) ? max(0, (int) $_GET['product_id']) : 0;
$user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0;
$pay_method = isset($_GET['payment_method']) ? sanitize_key((string) wp_unslash($_GET['payment_method'])) : '';
$date_from = isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '';
$date_to = isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '';
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
$per_page = 20;
@@ -82,6 +102,8 @@ class PayOrders {
$args = [
'limit' => $per_page,
'offset' => ($page_num - 1) * $per_page,
'date_from' => $date_from,
'date_to' => $date_to,
];
if ($status !== '' && $status !== 'all') {
$args['status'] = $status;
@@ -118,6 +140,8 @@ class PayOrders {
'user_id' => $user_id > 0 ? $user_id : null,
'payment_method' => $pay_method !== '' ? $pay_method : null,
'status' => ($status !== '' && $status !== 'all') ? $status : null,
'date_from' => $date_from !== '' ? $date_from : null,
'date_to' => $date_to !== '' ? $date_to : null,
's' => $search !== '' ? $search : null,
]));
$cls = ($product_type === $tkey) ? 'current' : '';
@@ -136,6 +160,8 @@ class PayOrders {
'user_id' => $user_id > 0 ? $user_id : null,
'payment_method' => $mkey !== '' ? $mkey : null,
'status' => ($status !== '' && $status !== 'all') ? $status : null,
'date_from' => $date_from !== '' ? $date_from : null,
'date_to' => $date_to !== '' ? $date_to : null,
's' => $search !== '' ? $search : null,
]));
$cls = ($pay_method === $mkey) ? 'current' : '';
@@ -155,6 +181,8 @@ class PayOrders {
if ($pay_method !== '') {
echo '<input type="hidden" name="payment_method" value="' . esc_attr($pay_method) . '" />';
}
echo '<label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> ';
echo '<label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
echo '<select name="status">';
echo '<option value="">' . esc_html__('全部状态', 'aksrt') . '</option>';
foreach ($labels as $key => $label) {
@@ -167,9 +195,9 @@ class PayOrders {
}
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" /> ';
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('流水号 / 账本流水号 / 类型 / 用户', 'aksrt') . '" /> ';
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('订单号 / 用户 / 商品', 'aksrt') . '" /> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
if ($product_id > 0 || $product_type !== '' || $user_id > 0 || $pay_method !== '') {
if ($product_id > 0 || $product_type !== '' || $user_id > 0 || $pay_method !== '' || $date_from !== '' || $date_to !== '') {
$filter_bits = [];
if ($product_type !== '') {
$filter_bits[] = sprintf(__('类型=%s', 'aksrt'), $product_type);
@@ -183,6 +211,8 @@ class PayOrders {
if ($pay_method !== '') {
$filter_bits[] = sprintf(__('支付方式=%s', 'aksrt'), $method_tabs[$pay_method]);
}
if ($date_from !== '') $filter_bits[] = sprintf(__('开始日期=%s', 'aksrt'), $date_from);
if ($date_to !== '') $filter_bits[] = sprintf(__('结束日期=%s', 'aksrt'), $date_to);
echo ' <span class="description">' . esc_html(implode(' · ', $filter_bits)) . '</span>';
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders')) . '">' . esc_html__('清除过滤', 'aksrt') . '</a>';
}
@@ -200,7 +230,7 @@ class PayOrders {
echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>';
echo '<th>' . esc_html__('流水号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('订单号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th>' . esc_html__('商品信息', 'aksrt') . '</th>';
echo '<th>' . esc_html__('金额 / 支付方式', 'aksrt') . '</th>';
@@ -221,6 +251,7 @@ class PayOrders {
$status_label = $labels[$status_key] ?? $status_key;
$product_id = (int) ($row['product_id'] ?? 0);
$product_type = sanitize_key((string) ($row['product_type'] ?? ''));
$type_labels = \AKSRT\Core\DomainModel::order_product_type_labels();
$meta = is_array($row['meta_payload'] ?? null) ? $row['meta_payload'] : [];
$product_title = (string) ($meta['product_title'] ?? $meta['title'] ?? $meta['package_title'] ?? '');
if ($product_title === '' && $product_id > 0 && $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
@@ -230,7 +261,7 @@ class PayOrders {
echo '<tr>';
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><strong>' . esc_html($product_title !== '' ? $product_title : $product_type) . '</strong><br><span class="description">' . esc_html($product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '</span></td>';
echo '<td><strong>' . esc_html($product_title !== '' ? $product_title : ($type_labels[$product_type] ?? $product_type)) . '</strong><br><span class="description">' . esc_html($type_labels[$product_type] ?? $product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '</span></td>';
echo '<td><strong>' . esc_html($this->order_amount($row)) . '</strong><br><span class="description">' . esc_html((string) ($row['payment_method'] ?? '')) . '</span></td>';
echo '<td>' . esc_html($status_label) . '</td>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="description">' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '</span></td>';
@@ -280,7 +311,6 @@ class PayOrders {
? \AKSRT\Core\DomainModel::order_status_labels()
: [];
$context = $repo->get_admin_order_context($order_number);
$flow_number = \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number);
$user_id = (int) ($order['user_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null;
$status = (string) ($order['status'] ?? '');
@@ -295,10 +325,9 @@ class PayOrders {
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
$rows = [
__('流水号', 'aksrt') => $order_number,
__('账本流水号', 'aksrt') => $flow_number !== '' ? $flow_number : '—',
__('订单号', 'aksrt') => $order_number,
__('用户', 'aksrt') => $user ? $user->display_name . ' (#' . $user_id . ')' : (string) $user_id,
__('商品类型', 'aksrt') => (string) ($order['product_type'] ?? ''),
__('商品类型', 'aksrt') => \AKSRT\Core\DomainModel::order_product_type_labels()[(string) ($order['product_type'] ?? '')] ?? (string) ($order['product_type'] ?? ''),
__('商品 ID', 'aksrt') => (string) ($order['product_id'] ?? ''),
__('金额', 'aksrt') => $this->order_amount($order),
__('状态', 'aksrt') => $labels[$status] ?? $status,
@@ -311,6 +340,18 @@ class PayOrders {
foreach ($rows as $label => $value) {
echo '<tr><th style="width:160px;">' . esc_html($label) . '</th><td>' . esc_html((string) $value) . '</td></tr>';
}
$after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number);
if ($after_sales) {
echo '<tr><th>' . esc_html__('售后记录', 'aksrt') . '</th><td>';
$status_labels = ['pending' => '待审核', 'processing' => '处理中', 'approved' => '已通过', 'rejected' => '已拒绝', 'cancelled' => '已取消', 'completed' => '已完成'];
foreach ($after_sales as $application) {
echo '<p><strong>' . esc_html($status_labels[(string) ($application['status'] ?? '')] ?? (string) ($application['status'] ?? '')) . '</strong> · ' . esc_html((string) ($application['refund_amount'] ?? '')) . '<br>' . esc_html((string) ($application['reason'] ?? ''));
if (!empty($application['admin_note'])) echo '<br>' . esc_html__('处理备注:', 'aksrt') . esc_html((string) $application['admin_note']);
if (!empty($application['gateway_result'])) echo '<br><code>' . esc_html((string) $application['gateway_result']) . '</code>';
echo '</p>';
}
echo '</td></tr>';
}
if (!empty($context)) {
echo '<tr><th>' . esc_html__('附加上下文', 'aksrt') . '</th><td><pre style="white-space:pre-wrap;margin:0;">' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . '</pre></td></tr>';
}
@@ -331,6 +372,92 @@ class PayOrders {
echo '</div>';
}
private function render_workbench_tabs(string $active): void {
echo '<nav class="nav-tab-wrapper" style="margin:12px 0 18px;">';
foreach (['stats' => __('统计', 'aksrt'), 'orders' => __('订单', 'aksrt'), 'after-sales' => __('售后', 'aksrt')] as $key => $label) {
$url = $this->page_url(self::PAGE_SLUG, ['tab' => $key]);
echo '<a class="nav-tab ' . ($active === $key ? 'nav-tab-active' : '') . '" href="' . esc_url($url) . '">' . esc_html($label) . '</a>';
}
echo '</nav>';
}
private function render_statistics_page(): void {
$stats = \AKSRT\Payment\OrderRepository::get_instance()->get_admin_order_stats([
'date_from' => isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '',
'date_to' => isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '',
]);
echo '<form method="get" style="margin-bottom:16px;"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" /><input type="hidden" name="tab" value="stats" />';
echo '<label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr((string) ($_GET['date_from'] ?? '')) . '" /></label> ';
echo '<label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr((string) ($_GET['date_to'] ?? '')) . '" /></label> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form><div style="display:flex;flex-wrap:wrap;gap:12px;">';
foreach (['total' => __('订单总数', 'aksrt'), 'pending' => __('待支付', 'aksrt'), 'paid' => __('已支付', 'aksrt'), 'refunded' => __('已退款', 'aksrt'), 'closed' => __('已关闭', 'aksrt'), 'revenue' => __('实收金额', 'aksrt'), 'after_sale_pending' => __('售后待审核', 'aksrt')] as $key => $label) {
$this->stat_card($label, (string) ($stats[$key] ?? 0), $this->page_url(self::PAGE_SLUG, ['tab' => 'orders']));
}
echo '</div><h2>' . esc_html__('按商品类型统计', 'aksrt') . '</h2><table class="widefat striped" style="max-width:640px;"><tbody>';
foreach ((array) ($stats['by_type'] ?? []) as $type => $count) echo '<tr><th>' . esc_html(\AKSRT\Core\DomainModel::order_product_type_labels()[$type] ?? $type) . '</th><td>' . esc_html((string) $count) . '</td></tr>';
echo '</tbody></table>';
}
private function render_after_sales_page(): void {
$service = \AKSRT\Payment\AfterSaleService::get_instance();
$status = isset($_GET['after_sale_status']) ? sanitize_key((string) wp_unslash($_GET['after_sale_status'])) : '';
$date_from = isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '';
$date_to = isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '';
$date_from = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from) ? $date_from : '';
$date_to = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to) ? $date_to : '';
$filter_args = ['status' => $status, 'date_from' => $date_from, 'date_to' => $date_to];
$items = $service->admin_list($filter_args + ['limit' => 100]);
echo '<form method="get"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" /><input type="hidden" name="tab" value="after-sales" />';
echo '<select name="after_sale_status"><option value="">' . esc_html__('全部售后状态', 'aksrt') . '</option>';
foreach ([\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_APPROVED => '已通过', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成'] as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
echo '</select> <label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> <label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form><table class="widefat striped"><thead><tr><th>ID</th><th>订单号</th><th>用户</th><th>原因</th><th>退款金额</th><th>状态</th><th>操作 / 处理记录</th></tr></thead><tbody>';
if (!$items) echo '<tr><td colspan="7">' . esc_html__('暂无售后申请', 'aksrt') . '</td></tr>';
foreach ($items as $item) {
$id = (int) $item['id']; $number = (string) $item['order_number']; $uid = (int) $item['user_id'];
$user = $uid > 0 ? get_userdata($uid) : null;
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($number);
$type = $order ? \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])) : '';
$type_label = \AKSRT\Core\DomainModel::order_product_type_labels()[$type] ?? $type;
$status_labels = [\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_APPROVED => '已通过', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成'];
$refund_amount = (string) ($item['refund_amount'] ?? '0');
if ($order && (string) ($order['currency'] ?? '') === 'points') $refund_amount .= ' ' . __('积分', 'aksrt'); else $refund_amount = '¥' . $refund_amount;
echo '<tr><td>' . esc_html((string) $id) . '<br><small>' . esc_html((string) ($item['created_at'] ?? '')) . '</small></td><td><a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['tab' => 'orders', 'order' => $number])) . '"><code>' . esc_html($number) . '</code></a><br><span class="description">' . esc_html($type_label) . ($order ? ' · ' . esc_html(\AKSRT\Payment\UserOrders::get_instance()->decorate_order_view($order, false)['product_name'] ?? '') : '') . '</span></td><td>' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid) . '</td><td>' . esc_html((string) $item['reason']) . '</td><td>' . esc_html($refund_amount) . '</td><td>' . esc_html($status_labels[(string) $item['status']] ?? (string) $item['status']) . '</td><td>';
if ((string) $item['status'] === 'pending') foreach (['approve' => '通过', 'reject' => '拒绝'] as $decision => $label) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;vertical-align:top;margin-right:4px;"><input type="hidden" name="action" value="aksrt_after_sale_review" /><input type="hidden" name="after_sale_id" value="' . esc_attr((string) $id) . '" /><input type="hidden" name="decision" value="' . esc_attr($decision) . '" />'; wp_nonce_field('aksrt_after_sale_review_' . $id); echo '<textarea name="admin_note" rows="2" placeholder="' . esc_attr__('管理员备注', 'aksrt') . '"></textarea><br><button class="button button-small" type="submit">' . esc_html($label) . '</button></form>';
}
if ((string) $item['status'] === 'processing') {
$gateway_result = json_decode((string) ($item['gateway_result'] ?? ''), true);
$manual_refund = is_array($gateway_result) && sanitize_key((string) ($gateway_result['status'] ?? '')) === 'manual';
foreach (($manual_refund ? ['complete' => '确认线下退款完成', 'reject' => '确认未退款并拒绝'] : ['approve' => '同步退款状态']) as $decision => $label) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;margin-right:4px;"><input type="hidden" name="action" value="aksrt_after_sale_review" /><input type="hidden" name="after_sale_id" value="' . esc_attr((string) $id) . '" /><input type="hidden" name="decision" value="' . esc_attr($decision) . '" />'; wp_nonce_field('aksrt_after_sale_review_' . $id); echo '<button class="button button-small" type="submit">' . esc_html__($label, 'aksrt') . '</button></form>';
}
}
if (!empty($item['admin_note'])) echo '<small>' . esc_html__('备注:', 'aksrt') . esc_html((string) $item['admin_note']) . '</small><br>';
if (!empty($item['gateway_result'])) {
$result_data = json_decode((string) $item['gateway_result'], true);
$result_text = is_array($result_data) ? implode(' · ', array_filter([(string) ($result_data['status'] ?? ''), (string) ($result_data['message'] ?? ''), !empty($result_data['refund_id']) ? '退款单号 ' . (string) $result_data['refund_id'] : ''])) : (string) $item['gateway_result'];
echo '<small>' . esc_html__('渠道结果:', 'aksrt') . ' ' . esc_html($result_text) . '</small>';
}
echo '</td></tr>';
}
echo '</tbody></table>';
}
public function handle_after_sale_review(): void {
if (!current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
$id = isset($_POST['after_sale_id']) ? absint($_POST['after_sale_id']) : 0;
check_admin_referer('aksrt_after_sale_review_' . $id);
$decision = isset($_POST['decision']) ? sanitize_key((string) wp_unslash($_POST['decision'])) : '';
$note = isset($_POST['admin_note']) ? sanitize_textarea_field((string) wp_unslash($_POST['admin_note'])) : '';
$result = \AKSRT\Payment\AfterSaleService::get_instance()->review($id, $decision, $note);
$args = ['tab' => 'after-sales', 'aksrt_pay_msg' => is_wp_error($result) ? 'after_sale_review_failed' : 'after_sale_reviewed'];
if (is_wp_error($result)) $args['after_sale_error'] = substr($result->get_error_message(), 0, 240);
wp_safe_redirect($this->page_url(self::PAGE_SLUG, $args)); exit;
}
public function handle_update_order_status(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
@@ -417,17 +544,14 @@ class PayOrders {
}
private function render_order_quick_status_buttons(string $order_number, string $current_status, bool $redirect_detail = false): void {
$labels = [
'paid' => __('确认收款', 'aksrt'),
'refunding' => __('发起退款', 'aksrt'),
'refunded' => __('确认退款', 'aksrt'),
'closed' => __('关闭订单', 'aksrt'),
];
$labels = [
'paid' => __('确认收款', 'aksrt'),
'closed' => __('关闭订单', 'aksrt'),
];
$transitions = \AKSRT\Core\DomainModel::order_transitions();
foreach ((array) ($transitions[$current_status] ?? []) as $status) {
$label = $status === 'paid' && $current_status === 'refunding'
? __('取消退款', 'aksrt')
: ($labels[$status] ?? $status);
if ($status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) continue;
$label = $labels[$status] ?? $status;
$confirm = sprintf(__('确认将订单状态改为“%s”?', 'aksrt'), $label);
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="aksrt_pay_update_order_status" />';
@@ -513,13 +637,18 @@ class PayOrders {
'shipping_created' => __('发货记录已创建。', 'aksrt'),
'shipping_create_failed' => __('发货记录创建失败。', 'aksrt'),
'after_sale_created' => __('售后工单已创建。', 'aksrt'),
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
'after_sale_reviewed' => __('售后审核已处理。', 'aksrt'),
'after_sale_review_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>';
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>';
if ($code === 'after_sale_review_failed' && isset($_GET['after_sale_error'])) {
echo '<div class="notice notice-error"><p>' . esc_html(sanitize_text_field((string) wp_unslash($_GET['after_sale_error']))) . '</p></div>';
}
}
private function page_url(string $slug, array $args = []): string {
-1
View File
@@ -68,7 +68,6 @@ class SchemaPayment {
['key' => 'status', 'title' => '状态设置', 'fields' => [
['key' => 'payment_order_status_pending', 'label' => '待支付状态文案', 'type' => 'input', 'default' => '待支付'],
['key' => 'payment_order_status_paid', 'label' => '已支付状态文案', 'type' => 'input', 'default' => '已支付'],
['key' => 'payment_order_status_refunding', 'label' => '退款中状态文案', 'type' => 'input', 'default' => '退款中'],
['key' => 'payment_order_status_refunded', 'label' => '已退款状态文案', 'type' => 'input', 'default' => '已退款'],
['key' => 'payment_order_status_closed', 'label' => '已关闭状态文案', 'type' => 'input', 'default' => '已关闭'],
]],
+76 -74
View File
@@ -140,16 +140,10 @@ class UserController extends ControllerBase {
'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'],
'user_id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
'asset' => ['type' => 'string', 'enum' => ['points', 'balance'], 'required' => true],
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 10],
'date_from' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
'date_to' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
],
@@ -589,22 +583,36 @@ class UserController extends ControllerBase {
}
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'),
];
$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']);
global $wpdb;
$page = max(1, (int) $request->get_param('page'));
$per_page = min(100, max(1, (int) $request->get_param('per_page')));
$search = trim(sanitize_text_field((string) $request->get_param('search')));
$where = [];
$params = [];
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' . (ctype_digit($search) ? ' OR u.ID = %d' : '') . ')';
array_push($params, $like, $like, $like);
if (ctype_digit($search)) $params[] = (int) $search;
}
unset($item);
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$total_sql = "SELECT COUNT(*) FROM {$wpdb->users} u {$where_sql}";
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($total_sql, $params)) : $wpdb->get_var($total_sql));
$sql = "SELECT u.ID AS id, u.user_login AS login, u.user_email AS email, u.display_name, u.user_registered AS registered,
(SELECT m.meta_value FROM {$wpdb->usermeta} m WHERE m.user_id = u.ID AND m.meta_key = 'balance' ORDER BY m.umeta_id ASC LIMIT 1) AS balance_value,
(SELECT m.meta_value FROM {$wpdb->usermeta} m WHERE m.user_id = u.ID AND m.meta_key = 'points' ORDER BY m.umeta_id ASC LIMIT 1) AS points_value
FROM {$wpdb->users} u {$where_sql} ORDER BY u.user_registered DESC, u.ID DESC LIMIT %d OFFSET %d";
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, ($page - 1) * $per_page])), ARRAY_A);
$items = [];
foreach ((array) $rows as $row) {
try { $balance = \AKSRT\Ledger\AssetAmount::balance((string) ($row['balance_value'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { $balance = '0.00'; }
$items[] = [
'id' => (int) $row['id'], 'login' => (string) $row['login'], 'email' => (string) $row['email'],
'display_name' => (string) $row['display_name'], 'registered' => (string) $row['registered'],
'balance' => $balance, 'points' => max(0, (int) ($row['points_value'] ?? 0)),
];
}
$data = ['items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
return $this->success(['data' => $data]);
}
@@ -653,64 +661,58 @@ class UserController extends ControllerBase {
'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'),
];
$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);
$user_id = max(0, (int) $request->get_param('user_id'));
$asset = sanitize_key((string) $request->get_param('asset'));
if ($user_id <= 0 || !get_userdata($user_id) || !in_array($asset, ['points', 'balance'], true)) {
return $this->error(__('用户或资产类型无效', 'aksrt'), 400);
}
unset($item);
$meta_key = $asset === 'points' ? 'points_record' : 'balance_record';
$records = get_user_meta($user_id, $meta_key, true);
$records = is_array($records) ? array_slice($records, 0, 50) : [];
$date_from = sanitize_text_field((string) $request->get_param('date_from'));
$date_to = sanitize_text_field((string) $request->get_param('date_to'));
$records = array_values(array_filter($records, static function ($record) use ($date_from, $date_to): bool {
if (!is_array($record)) return false;
$day = substr((string) ($record['time'] ?? ''), 0, 10);
return ($date_from === '' || $day >= $date_from) && ($date_to === '' || $day <= $date_to);
}));
$page = max(1, (int) $request->get_param('page'));
$per_page = min(50, max(1, (int) $request->get_param('per_page')));
$items = [];
foreach (array_slice($records, ($page - 1) * $per_page, $per_page) as $index => $record) {
$value = $asset === 'points' ? (int) ($record['value'] ?? 0) : $this->asset_value_to_minor((string) ($record['value'] ?? '0'));
$after = $asset === 'points' ? (int) ($record['points'] ?? 0) : $this->asset_value_to_minor((string) ($record['balance'] ?? '0'));
$items[] = [
'id' => (($page - 1) * $per_page) + $index + 1,
'operation_key' => (string) ($record['operation_key'] ?? ''),
'reason' => (string) ($record['type'] ?? ''),
'ref_type' => !empty($record['order_num']) ? 'order' : '',
'ref_key' => (string) ($record['order_num'] ?? ''),
'actor_user_id' => 0,
'assets' => [$asset],
'created_at' => (string) ($record['time'] ?? ''),
'remark' => (string) ($record['desc'] ?? ''),
'entries' => [[
'user_id' => $user_id, 'asset' => $asset, 'entry_type' => $value < 0 ? 'debit' : 'credit',
'delta' => $value, 'balance_after' => $after, 'remark' => (string) ($record['desc'] ?? ''),
]],
];
}
$data = ['items' => $items, 'total' => count($records), 'page' => $page, 'per_page' => $per_page];
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;
private function asset_value_to_minor(string $value): int {
$value = trim($value);
$negative = str_starts_with($value, '-');
if ($negative) $value = substr($value, 1);
try { $minor = \AKSRT\Ledger\AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; }
return $negative ? -$minor : $minor;
}
}
+18 -23
View File
@@ -19,7 +19,7 @@ trait PayOrders {
*/
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) {
if (!\AKSRT\Core\DomainModel::is_resource_product_type($product_type)) {
return true;
}
$post_id = (int) ($paid_order['product_id'] ?? 0);
@@ -131,7 +131,8 @@ trait PayOrders {
return $assert;
}
$priced = $assert['priced'];
$priced = $assert['priced'];
$content_type = \AKSRT\Core\DomainModel::resolve_order_product_type(\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, ['pay_type' => $assert['meta']['pay_type'] ?? '']);
if ((($priced['modo'] ?? '0') !== 'points')) {
return new \WP_Error('aksrt_wrong_modo', __('该内容不支持积分支付。', 'aksrt'), ['status' => 400]);
}
@@ -164,6 +165,7 @@ trait PayOrders {
$note = wp_json_encode([
'content_pay' => 1,
'pay_type' => (string) ($assert['meta']['pay_type'] ?? ''),
'pay_modo' => 'points',
'post_id' => $post_id,
'price_type' => $price_type,
@@ -178,7 +180,7 @@ trait PayOrders {
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
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== $content_type
|| (int) ($existing['product_id'] ?? 0) !== $post_id
|| (string) ($existing['currency'] ?? '') !== 'points'
|| (int) ($existing['amount'] ?? 0) !== $amount
@@ -224,12 +226,12 @@ trait PayOrders {
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) {
static function () use ($is_new_order, $repo, $order_number, $operation_key, $user_id, $post_id, $amount, $note, $content_type) {
if ($is_new_order) {
$created = $repo->create([
'order_number' => $order_number,
'user_id' => $user_id,
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
'product_type' => $content_type,
'product_id' => $post_id,
'amount' => $amount,
'currency' => 'points',
@@ -245,7 +247,6 @@ trait PayOrders {
}
$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,
]);
@@ -264,9 +265,7 @@ trait PayOrders {
'amount' => $amount,
'pay_modo' => 'points',
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'transaction_id' => $deducted->transaction_id,
'flow_number' => $deducted->flow_number,
'replayed' => $deducted->replayed,
'replayed' => $deducted->replayed,
'message' => __('支付成功', 'aksrt'),
'reload' => true,
],
@@ -289,6 +288,7 @@ trait PayOrders {
$post = $assert['post'];
$meta = $assert['meta'];
$priced = $assert['priced'];
$content_type = \AKSRT\Core\DomainModel::resolve_order_product_type(\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, ['pay_type' => $meta['pay_type'] ?? '']);
if ((($priced['modo'] ?? '0') === 'points')) {
return new \WP_Error('aksrt_wrong_modo', __('该内容仅支持积分支付。', 'aksrt'), ['status' => 400]);
}
@@ -331,6 +331,7 @@ trait PayOrders {
}
$note = wp_json_encode([
'content_pay' => 1,
'pay_type' => (string) ($meta['pay_type'] ?? ''),
'pay_modo' => '0',
'post_id' => $post_id,
'price_type' => $price_type,
@@ -355,7 +356,7 @@ trait PayOrders {
$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
|| sanitize_key((string) ($existing['product_type'] ?? '')) !== $content_type
|| (int) ($existing['product_id'] ?? 0) !== $post_id
|| sanitize_key((string) ($existing['payment_method'] ?? '')) !== 'free'
|| (string) ($existing['amount'] ?? '') !== '0.00'
@@ -366,7 +367,7 @@ trait PayOrders {
} else {
$order_number = $repo->create([
'user_id' => max(0, $user_id),
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
'product_type' => $content_type,
'product_id' => $post_id,
'amount' => '0.00',
'currency' => 'CNY',
@@ -384,7 +385,7 @@ trait PayOrders {
if ($status !== \AKSRT\Payment\Order::STATUS_PAID) {
$paid = \AKSRT\Payment\Order::get_instance()->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => 'free',
'payment_transaction_id' => 'free_' . $order_number,
'payment_transaction_id' => '',
'note' => __('优惠后无需支付。', 'aksrt'),
]);
if (!$paid) {
@@ -398,7 +399,7 @@ trait PayOrders {
'amount' => '0.00',
'pay_modo' => '0',
'free' => true,
'transaction_id' => 'free_' . $order_number,
'transaction_id' => '',
'replayed' => $replayed,
'message' => __('无需支付,已可访问。', 'aksrt'),
'reload' => true,
@@ -430,7 +431,7 @@ trait PayOrders {
$pending_coupon = (string) ($pending_meta['coupon']['code'] ?? $pending_coupon);
}
$can_resume = (int) ($pending['user_id'] ?? 0) === max(0, $user_id)
&& sanitize_key((string) ($pending['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE
&& sanitize_key((string) ($pending['product_type'] ?? '')) === $content_type
&& (int) ($pending['product_id'] ?? 0) === $post_id
&& (string) ($pending['amount'] ?? '') === $amount_decimal
&& strtoupper((string) ($pending['currency'] ?? '')) === 'CNY'
@@ -448,6 +449,7 @@ trait PayOrders {
} else {
$note = wp_json_encode([
'content_pay' => 1,
'pay_type' => (string) ($meta['pay_type'] ?? ''),
'pay_modo' => '0',
'post_id' => $post_id,
'price_type' => $price_type,
@@ -469,7 +471,7 @@ trait PayOrders {
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
'user_id' => max(0, $user_id),
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
'product_type' => $content_type,
'product_id' => $post_id,
'amount' => $amount_decimal,
'currency' => 'CNY',
@@ -495,16 +497,12 @@ trait PayOrders {
$payment_method = $user_id > 0 ? 'balance' : 'alipay';
}
if ($can_resume && (string) ($order['status'] ?? '') === \AKSRT\Payment\Order::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]);
return [
'success' => true,
'data' => [
'order_number' => $order_number,
'amount' => $amount_decimal,
'pay_modo' => '0',
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('支付成功', 'aksrt'),
@@ -559,7 +557,6 @@ trait PayOrders {
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
'transaction_id' => 0,
'replayed' => false,
],
];
@@ -571,7 +568,6 @@ trait PayOrders {
'order_number' => $order_number,
'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'],
@@ -590,13 +586,12 @@ trait PayOrders {
return null;
}
$orders = \AKSRT\Payment\OrderRepository::get_instance()->get_user_orders($user_id, [
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
'status' => \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING,
'limit' => 20,
'offset' => 0,
]);
foreach ($orders as $order) {
if ((int) ($order['product_id'] ?? 0) === $post_id) {
if ((int) ($order['product_id'] ?? 0) === $post_id && \AKSRT\Core\DomainModel::is_resource_product_type((string) ($order['product_type'] ?? ''))) {
return $order;
}
}
+1 -3
View File
@@ -179,9 +179,7 @@ trait PayRest {
$order = $repo->get($order_number);
// Guest may only poll own guest cash orders (user_id=0): content resource.
$ptype = sanitize_key((string) ($order['product_type'] ?? ''));
$guest_types = [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
];
$guest_types = \AKSRT\Core\DomainModel::resource_order_product_types();
if ($order && ((int) ($order['user_id'] ?? -1) !== 0 || !in_array($ptype, $guest_types, true))) {
$order = null;
}
+10 -11
View File
@@ -411,8 +411,6 @@ trait VipServicePurchase {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
if (is_array($existing) && (string) ($existing['status'] ?? '') === \AKSRT\Payment\Order::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]);
return [
'success' => true,
'data' => [
@@ -421,8 +419,6 @@ trait VipServicePurchase {
'level' => $level,
'days' => $days,
'action' => $action,
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('会员购买已完成', 'aksrt'),
@@ -491,7 +487,6 @@ trait VipServicePurchase {
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
'transaction_id' => 0,
'replayed' => false,
],
];
@@ -505,7 +500,6 @@ trait VipServicePurchase {
'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'
@@ -549,6 +543,7 @@ trait VipServicePurchase {
if (!empty($meta['card_pass']) || sanitize_key((string) ($meta['vip_pay_type'] ?? '')) === 'card_pass') {
return true;
}
$vip_before = $this->get_membership_snapshot($user_id);
$level = (int) ($meta['level'] ?? 0);
$days = (int) ($meta['days'] ?? 0);
@@ -611,9 +606,16 @@ 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) {
if (!$fulfilled) {
return new \WP_Error('aksrt_vip_fulfillment_failed', __('会员权益发放失败。', 'aksrt'), ['status' => 500]);
}
$vip_after = $this->get_membership_snapshot($user_id);
if (!\AKSRT\Payment\OrderRepository::get_instance()->merge_meta($order_number, [
'vip_before' => $vip_before,
'vip_after' => $vip_after,
])) {
return new \WP_Error('aksrt_vip_snapshot_failed', __('会员订单快照保存失败。', 'aksrt'), ['status' => 500]);
}
return true;
}
@@ -860,7 +862,6 @@ trait VipServicePurchase {
}
$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,
]);
@@ -880,9 +881,7 @@ trait VipServicePurchase {
'level' => $level,
'days' => $days,
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'transaction_id' => $deducted->transaction_id,
'flow_number' => $deducted->flow_number,
'replayed' => $deducted->replayed,
'replayed' => $deducted->replayed,
'message' => __('兑换成功', 'aksrt'),
'reload' => true,
],
+1 -1
View File
@@ -14,7 +14,7 @@ class DBMigrator {
use DBMigratorVip;
/** Current schema version; changing it re-runs dbDelta. */
const SCHEMA_VERSION = '2.1.0';
const SCHEMA_VERSION = '2.5.0';
const OPT_KEY = 'aksrt_db_schema_version';
+25
View File
@@ -204,6 +204,29 @@ trait DBMigratorPart2 {
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_order);
// ---------- order after-sales ----------
$sql_order_aftersale = "CREATE TABLE {$prefix}aksrt_order_aftersale (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_number VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
aftersale_type VARCHAR(24) NOT NULL DEFAULT 'refund',
reason TEXT NOT NULL,
refund_amount VARCHAR(32) NOT NULL DEFAULT '0',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
admin_note TEXT DEFAULT NULL,
gateway_request_id VARCHAR(128) DEFAULT NULL,
gateway_refund_id VARCHAR(128) DEFAULT NULL,
gateway_result LONGTEXT DEFAULT NULL,
created_at DATETIME NOT NULL,
reviewed_at DATETIME DEFAULT NULL,
completed_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_order_status (order_number, status),
KEY idx_user_created (user_id, created_at),
KEY idx_status_created (status, created_at)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_order_aftersale);
// ---------- 9. 作者收益台账 ----------
$sql_revenue = "CREATE TABLE {$prefix}aksrt_revenue (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -246,12 +269,14 @@ trait DBMigratorPart2 {
method VARCHAR(32) NOT NULL DEFAULT '',
account_info TEXT DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
operation_key VARCHAR(191) DEFAULT NULL,
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_withdrawal_number (withdrawal_number),
UNIQUE KEY uniq_withdrawal_operation (operation_key),
UNIQUE KEY uniq_ledger_transaction (ledger_transaction_id),
KEY idx_user (user_id),
KEY idx_user_created (user_id, created_at),
+1
View File
@@ -25,6 +25,7 @@ trait DBMigratorPart3 {
'dl_token' => 'aksrt_dl_token',
'notification' => 'aksrt_notification',
'order' => 'aksrt_order',
'order_aftersale' => 'aksrt_order_aftersale',
'order_item' => 'aksrt_order_item',
'revenue' => 'aksrt_revenue',
'withdrawal' => 'aksrt_withdrawal',
+49 -9
View File
@@ -40,6 +40,10 @@ class DomainModel {
];
public const ORDER_PRODUCT_RESOURCE = 'resource';
public const ORDER_PRODUCT_READING = 'reading';
public const ORDER_PRODUCT_DOWNLOAD = 'download';
public const ORDER_PRODUCT_IMAGE = 'image';
public const ORDER_PRODUCT_VIDEO = 'video';
public const ORDER_PRODUCT_VIP = 'vip';
public const ORDER_PRODUCT_RECHARGE = 'recharge';
public const ORDER_PRODUCT_POINTS = 'points';
@@ -51,13 +55,53 @@ class DomainModel {
*/
public static function order_product_type_labels(): array {
return [
self::ORDER_PRODUCT_RESOURCE => __('付费内容', 'aksrt'),
self::ORDER_PRODUCT_VIP => __('VIP 会员', 'aksrt'),
self::ORDER_PRODUCT_READING => __('付费阅读', 'aksrt'),
self::ORDER_PRODUCT_DOWNLOAD => __('付费下载', 'aksrt'),
self::ORDER_PRODUCT_IMAGE => __('付费图片', 'aksrt'),
self::ORDER_PRODUCT_VIDEO => __('付费视频', 'aksrt'),
self::ORDER_PRODUCT_VIP => __('购买会员', 'aksrt'),
self::ORDER_PRODUCT_RECHARGE => __('余额充值', 'aksrt'),
self::ORDER_PRODUCT_POINTS => __('积分购买', 'aksrt'),
self::ORDER_PRODUCT_POINTS => __('购买积分', 'aksrt'),
];
}
public static function is_resource_product_type(string $type): bool {
return in_array(sanitize_key($type), [
self::ORDER_PRODUCT_RESOURCE,
self::ORDER_PRODUCT_READING,
self::ORDER_PRODUCT_DOWNLOAD,
self::ORDER_PRODUCT_IMAGE,
self::ORDER_PRODUCT_VIDEO,
], true);
}
/** Resolve new and historical paid-content order types from stored order meta. */
public static function resolve_order_product_type(string $stored_type, array $meta = []): string {
$stored_type = sanitize_key($stored_type);
if ($stored_type !== self::ORDER_PRODUCT_RESOURCE) {
return isset(self::order_product_type_labels()[$stored_type]) ? $stored_type : $stored_type;
}
$pay_type = sanitize_key((string) ($meta['pay_type'] ?? $meta['content_pay_type'] ?? ''));
return match ($pay_type) {
self::PAY_TYPE_READING => self::ORDER_PRODUCT_READING,
self::PAY_TYPE_DOWNLOAD => self::ORDER_PRODUCT_DOWNLOAD,
self::PAY_TYPE_GALLERY => self::ORDER_PRODUCT_IMAGE,
self::PAY_TYPE_VIDEO => self::ORDER_PRODUCT_VIDEO,
default => self::ORDER_PRODUCT_READING,
};
}
/** @return string[] */
public static function resource_order_product_types(): array {
return [
self::ORDER_PRODUCT_RESOURCE,
self::ORDER_PRODUCT_READING,
self::ORDER_PRODUCT_DOWNLOAD,
self::ORDER_PRODUCT_IMAGE,
self::ORDER_PRODUCT_VIDEO,
];
}
public const PAY_TYPE_NO = 'no';
public const PAY_TYPE_READING = 'reading';
public const PAY_TYPE_DOWNLOAD = 'download';
@@ -67,7 +111,6 @@ class DomainModel {
public const ORDER_STATUS_PENDING = 'pending';
public const ORDER_STATUS_PAID = 'paid';
public const ORDER_STATUS_REFUNDING = 'refunding';
public const ORDER_STATUS_REFUNDED = 'refunded';
public const ORDER_STATUS_CLOSED = 'closed';
@@ -454,7 +497,6 @@ class DomainModel {
return [
self::ORDER_STATUS_PENDING => __('待支付', 'aksrt'),
self::ORDER_STATUS_PAID => __('已支付', 'aksrt'),
self::ORDER_STATUS_REFUNDING => __('退款中', 'aksrt'),
self::ORDER_STATUS_REFUNDED => __('已退款', 'aksrt'),
self::ORDER_STATUS_CLOSED => __('已关闭', 'aksrt'),
];
@@ -470,11 +512,7 @@ class DomainModel {
self::ORDER_STATUS_CLOSED,
],
self::ORDER_STATUS_PAID => [
self::ORDER_STATUS_REFUNDING,
],
self::ORDER_STATUS_REFUNDING => [
self::ORDER_STATUS_REFUNDED,
self::ORDER_STATUS_PAID,
],
self::ORDER_STATUS_REFUNDED => [],
self::ORDER_STATUS_CLOSED => [],
@@ -500,6 +538,8 @@ class DomainModel {
public static function paid_order_statuses(): array {
return [
self::ORDER_STATUS_PAID,
// Read-only compatibility: normalize_row exposes legacy in-flight refunds as paid.
'refunding',
];
}
-11
View File
@@ -388,18 +388,11 @@ trait BalanceServiceCharge {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
if (is_array($existing) && (string) ($order['status'] ?? '') === \AKSRT\Payment\Order::STATUS_PAID) {
$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'),
@@ -450,15 +443,11 @@ trait BalanceServiceCharge {
}
// 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_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'),
+10 -12
View File
@@ -230,7 +230,6 @@ trait BalanceServiceRest {
'data' => [
'message' => __('转账成功', 'aksrt'),
'reload' => true,
'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(),
],
@@ -281,7 +280,6 @@ trait BalanceServiceRest {
'data' => [
'message' => __('转账成功', 'aksrt'),
'reload' => true,
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'balance' => $result->balance($from_id, \AKSRT\Ledger\AssetType::POINTS),
],
@@ -392,14 +390,15 @@ trait BalanceServiceRest {
if (is_wp_error($operation_key)) {
return $operation_key;
}
$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);
$guard = static function () use ($user_id, $points, $daily_max) {
$used = 0;
$records = get_user_meta($user_id, 'points_record', true);
$today = current_time('Y-m-d');
foreach (is_array($records) ? $records : [] as $record) {
if (substr((string) ($record['time'] ?? ''), 0, 10) !== $today) continue;
if (sanitize_key((string) ($record['type'] ?? '')) !== 'points_exchange') continue;
$used += max(0, (int) ($record['value'] ?? 0));
}
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]);
}
@@ -451,7 +450,7 @@ trait BalanceServiceRest {
'currency' => 'CNY',
'status' => \AKSRT\Payment\Order::STATUS_PAID,
'payment_method' => 'balance',
'payment_transaction_id' => 'ledger_' . $transaction_id,
'payment_transaction_id' => '',
'paid_at' => current_time('mysql'),
'note' => is_string($note) ? $note : '',
'request_key' => $operation_key,
@@ -481,7 +480,6 @@ trait BalanceServiceRest {
}
return rest_ensure_response(['success' => true, 'data' => [
'message' => sprintf(__('购买成功,获得 %d 积分', 'aksrt'), $points),
'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),
+348
View File
@@ -0,0 +1,348 @@
<?php
/**
* User-meta backed wallet and points balances.
*
* Commission remains in the immutable asset ledger. Balance and points are
* deliberately kept on user meta, with a bounded recent-history record.
*
* @package AKSRT
*/
namespace AKSRT\Credits;
use AKSRT\Ledger\AssetAmount;
use AKSRT\Ledger\AssetType;
use AKSRT\Ledger\LedgerAbort;
use AKSRT\Ledger\LedgerCommand;
use AKSRT\Ledger\LedgerResult;
defined('ABSPATH') || exit;
final class MetaAssetService {
private const MAX_RECORDS = 50;
private static ?self $instance = null;
private function __construct() {}
public static function get_instance(): self {
return self::$instance ??= new self();
}
public function balance_points(int $user_id): int {
return max(0, (int) get_user_meta($user_id, 'points', true));
}
public function balance_minor(int $user_id): int {
if ($user_id <= 0) {
return 0;
}
try {
return AssetAmount::balance((string) get_user_meta($user_id, 'balance', true))->minor();
} catch (\InvalidArgumentException $e) {
return 0;
}
}
public function balance_decimal(int $user_id): string {
return AssetAmount::from_minor(AssetType::BALANCE, $this->balance_minor($user_id))->decimal();
}
/**
* Commit a balance/points LedgerCommand against user meta. This adapter keeps
* existing business callbacks and result DTOs while never writing Ledger or
* FinancialFlow rows for these two assets.
*
* @return LedgerResult|\WP_Error
*/
public function post_command(LedgerCommand $command, ?callable $guard = null, ?callable $business_mutation = null) {
global $wpdb;
$entries = $command->entries;
if ($entries === []) {
return new \WP_Error('aksrt_asset_entries', __('资产明细不能为空', 'aksrt'), ['status' => 400]);
}
$valid = $command->validate();
if (is_wp_error($valid)) {
return $valid;
}
foreach ($entries as $entry) {
if (!in_array((string) ($entry['asset'] ?? ''), [AssetType::BALANCE->value, AssetType::POINTS->value], true)) {
return new \WP_Error('aksrt_asset_mixed_pool', __('余额/积分不能与佣金资金池混合操作', 'aksrt'), ['status' => 400]);
}
}
$lock_names = [];
$lock_names[] = 'aksrt_meta_op_' . substr(hash('sha256', $command->operation_key), 0, 48);
foreach ($entries as $entry) {
$lock_names[] = 'aksrt_meta_asset_' . (int) $entry['user_id'] . '_' . sanitize_key((string) $entry['asset']);
}
$lock_names = array_values(array_unique($lock_names));
sort($lock_names, SORT_STRING);
$user_ids = array_values(array_unique(array_map(static fn(array $entry): int => (int) $entry['user_id'], $entries)));
$held = [];
foreach ($lock_names as $lock_name) {
$got = (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock_name));
if ($got !== 1) {
$this->release_locks($held);
return new \WP_Error('aksrt_asset_busy', __('资产正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
}
$held[] = $lock_name;
}
$owns_transaction = $wpdb->query('START TRANSACTION') !== false;
if (!$owns_transaction) {
$this->release_locks($held);
return new \WP_Error('aksrt_asset_db', __('资产更新失败,请稍后重试', 'aksrt'), ['status' => 500]);
}
try {
$payload_hash = $command->payload_hash();
$replay = $this->find_operation($entries, $command->operation_key, $payload_hash);
if (is_wp_error($replay)) {
throw new LedgerAbort($replay);
}
if (is_array($replay)) {
$wpdb->query('COMMIT');
return new LedgerResult(0, true, [], $replay, '');
}
$before = [];
$totals = [];
foreach ($entries as $entry) {
$user_id = (int) $entry['user_id'];
$asset = (string) $entry['asset'];
$key = $user_id . ':' . $asset;
if (!isset($before[$key])) {
$before[$key] = $this->read_locked_balance($user_id, $asset);
$totals[$key] = 0;
}
$delta = (int) $entry['delta_minor'];
if (($delta > 0 && $totals[$key] > PHP_INT_MAX - $delta) || ($delta < 0 && $totals[$key] < PHP_INT_MIN - $delta)) {
throw new LedgerAbort(new \WP_Error('aksrt_asset_overflow', __('资产金额超出范围', 'aksrt'), ['status' => 400]));
}
$totals[$key] += $delta;
}
$balances = [];
foreach ($before as $key => $old) {
[$user_id_raw, $asset] = explode(':', $key, 2);
$user_id = (int) $user_id_raw;
$delta = $totals[$key];
if ($delta === 0) {
$balances[$user_id][$asset] = $old;
continue;
}
if ($delta < 0 && $old < abs($delta)) {
$is_points = $asset === AssetType::POINTS->value;
$code = $is_points ? 'aksrt_points_insufficient' : 'aksrt_balance_insufficient';
throw new LedgerAbort(new \WP_Error($code, $is_points ? __('积分不足', 'aksrt') : __('余额不足', 'aksrt'), [
'status' => 402,
'balance_minor' => $old,
'required_minor' => abs($delta),
]));
}
if ($delta > 0 && $old > PHP_INT_MAX - $delta) {
throw new LedgerAbort(new \WP_Error('aksrt_asset_overflow', __('资产金额超出范围', 'aksrt'), ['status' => 400]));
}
$new = $old + $delta;
$this->write_balance($user_id, $asset, $delta, $new);
$balances[$user_id][$asset] = $new;
}
if ($guard !== null) {
$guard_result = $guard(0, $balances);
if (is_wp_error($guard_result)) {
throw new LedgerAbort($guard_result);
}
if ($guard_result !== true) {
throw new LedgerAbort(new \WP_Error('aksrt_asset_guard', __('资产操作校验失败', 'aksrt'), ['status' => 409]));
}
}
$written = [];
$running = $before;
foreach ($entries as $entry) {
$user_id = (int) $entry['user_id'];
$asset = (string) $entry['asset'];
$key = $user_id . ':' . $asset;
$delta = (int) $entry['delta_minor'];
$old = (int) ($running[$key] ?? 0) + $delta;
$running[$key] = $old;
$order_number = trim((string) ($command->metadata['order_number'] ?? ''));
if ($order_number === '' && $command->ref_type === 'order') {
$order_number = trim($command->ref_key);
}
$record = [
'order_num' => $order_number,
'value' => $asset === AssetType::BALANCE->value ? AssetAmount::from_minor(AssetType::BALANCE, abs($delta))->decimal() : $delta,
'type' => in_array(sanitize_key((string) ($entry['entry_type'] ?? '')), ['credit', 'debit'], true)
? sanitize_key($command->reason)
: sanitize_key((string) ($entry['entry_type'] ?? $command->reason)),
'desc' => sanitize_text_field((string) ($entry['remark'] ?? $command->reason)),
'time' => current_time('Y-m-d H:i'),
];
if ($asset === AssetType::BALANCE->value) {
$record['value'] = $delta < 0 ? '-' . $record['value'] : $record['value'];
$record['balance'] = AssetAmount::from_minor(AssetType::BALANCE, max(0, $old))->decimal();
$meta_key = 'balance_record';
} else {
$record['value'] = $delta;
$record['points'] = max(0, $old);
$meta_key = 'points_record';
}
wp_cache_delete($user_id, 'user_meta');
$history = get_user_meta($user_id, $meta_key, true);
$history = is_array($history) ? $history : [];
$history = array_slice($history, 0, self::MAX_RECORDS - 1);
$history = array_merge([$record], $history);
update_user_meta($user_id, $meta_key, $history);
$written[] = [
'user_id' => $user_id,
'asset' => $asset,
'delta_minor' => $delta,
'balance_after_minor' => $old,
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)),
'entry_type' => sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')),
'remark' => sanitize_text_field((string) ($entry['remark'] ?? '')),
];
}
if ($business_mutation !== null) {
$mutation_result = $business_mutation(0, new LedgerResult(0, false, $written, $balances));
if (is_wp_error($mutation_result)) {
throw new LedgerAbort($mutation_result);
}
if ($mutation_result !== true) {
throw new LedgerAbort(new \WP_Error('aksrt_asset_mutation', __('资产关联操作失败', 'aksrt'), ['status' => 409]));
}
}
$this->write_operation_markers($entries, $command->operation_key, $payload_hash);
if ($wpdb->query('COMMIT') === false) {
throw new \RuntimeException('meta_asset_commit_failed');
}
foreach ($user_ids as $user_id) {
clean_user_cache((int) $user_id);
}
return new LedgerResult(0, false, $written, $balances, '');
} catch (LedgerAbort $e) {
$wpdb->query('ROLLBACK');
foreach ($user_ids as $user_id) clean_user_cache((int) $user_id);
return $e->error;
} catch (\Throwable $e) {
$wpdb->query('ROLLBACK');
foreach ($user_ids as $user_id) clean_user_cache((int) $user_id);
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT Meta Assets] ' . $e->getMessage());
}
return new \WP_Error('aksrt_asset_db', __('资产更新失败,请稍后重试', 'aksrt'), ['status' => 500]);
} finally {
$this->release_locks($held);
}
}
/** @return array<string,int>|\WP_Error|null */
private function find_operation(array $entries, string $operation_key, string $payload_hash): array|\WP_Error|null {
$balances = [];
$users = [];
foreach ($entries as $entry) {
$users[(int) $entry['user_id']] = true;
}
$marker_key = 'aksrt_asset_op_' . hash('sha256', $operation_key);
$found = 0;
foreach (array_keys($users) as $user_id) {
$stored_hash = (string) get_user_meta((int) $user_id, $marker_key, true);
if ($stored_hash === '') {
continue;
}
if (!hash_equals($stored_hash, $payload_hash)) {
return new \WP_Error('aksrt_asset_idempotency_conflict', __('相同资产操作标识已用于其他请求', 'aksrt'), ['status' => 409]);
}
$found++;
}
if ($found > 0 && $found !== count($users)) {
return new \WP_Error('aksrt_asset_idempotency_conflict', __('资产操作记录不完整,请联系管理员核查', 'aksrt'), ['status' => 409]);
}
if ($found === count($users)) {
foreach ($entries as $entry) {
$user_id = (int) $entry['user_id'];
$asset = (string) $entry['asset'];
$balances[$user_id][$asset] = $asset === AssetType::POINTS->value
? $this->balance_points($user_id)
: $this->balance_minor($user_id);
}
}
return $balances !== [] ? $balances : null;
}
/** @param array<int,array<string,mixed>> $entries */
private function write_operation_markers(array $entries, string $operation_key, string $payload_hash): void {
$marker_key = 'aksrt_asset_op_' . hash('sha256', $operation_key);
foreach (array_unique(array_map(static fn(array $entry): int => (int) $entry['user_id'], $entries)) as $user_id) {
update_user_meta($user_id, $marker_key, $payload_hash);
}
}
private function read_locked_balance(int $user_id, string $asset): int {
global $wpdb;
$key = $asset === AssetType::POINTS->value ? 'points' : 'balance';
$row = $wpdb->get_row($wpdb->prepare(
"SELECT umeta_id, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s ORDER BY umeta_id ASC LIMIT 1 FOR UPDATE",
$user_id,
$key
), ARRAY_A);
if (!is_array($row)) {
return 0;
}
try {
return $asset === AssetType::POINTS->value
? max(0, (int) $row['meta_value'])
: AssetAmount::balance((string) $row['meta_value'])->minor();
} catch (\InvalidArgumentException $e) {
return 0;
}
}
private function write_balance(int $user_id, string $asset, int $delta, int $minor): void {
global $wpdb;
$key = $asset === AssetType::POINTS->value ? 'points' : 'balance';
$value = $asset === AssetType::POINTS->value
? (string) $minor
: AssetAmount::from_minor(AssetType::BALANCE, $minor)->decimal();
$row = $wpdb->get_row($wpdb->prepare(
"SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s ORDER BY umeta_id ASC LIMIT 1 FOR UPDATE",
$user_id,
$key
), ARRAY_A);
if (is_array($row)) {
$meta_id = (int) $row['umeta_id'];
if ($asset === AssetType::POINTS->value) {
$sql = "UPDATE {$wpdb->usermeta} SET meta_value = CAST(meta_value AS SIGNED) + %d WHERE umeta_id = %d AND (%d >= 0 OR CAST(meta_value AS SIGNED) >= %d)";
$updated = $wpdb->query($wpdb->prepare($sql, $delta, $meta_id, $delta, abs($delta)));
} else {
$delta_decimal = AssetAmount::from_minor(AssetType::BALANCE, abs($delta))->decimal();
if ($delta < 0) {
$delta_decimal = '-' . $delta_decimal;
}
$minimum = AssetAmount::from_minor(AssetType::BALANCE, abs($delta))->decimal();
$sql = "UPDATE {$wpdb->usermeta} SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(20,2)) + CAST(%s AS DECIMAL(20,2)), 2) AS CHAR) WHERE umeta_id = %d AND (%s >= '0.00' OR CAST(meta_value AS DECIMAL(20,2)) >= CAST(%s AS DECIMAL(20,2)))";
$updated = $wpdb->query($wpdb->prepare($sql, $delta_decimal, $meta_id, $delta_decimal, $minimum));
}
if ($updated !== 1) {
throw new \RuntimeException('meta_asset_update_failed');
}
wp_cache_delete($user_id, 'user_meta');
return;
}
if ($minor < 0 || $wpdb->insert($wpdb->usermeta, ['user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value], ['%d', '%s', '%s']) !== 1) {
throw new \RuntimeException('meta_asset_insert_failed');
}
wp_cache_delete($user_id, 'user_meta');
}
private function release_locks(array $locks): void {
global $wpdb;
foreach (array_reverse($locks) as $lock_name) {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name));
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ defined('ABSPATH') || exit;
class PointsFree {
public const META_DETAIL = 'aksrt_free_points_detail';
public const META_DETAIL = 'free_points_detail';
public const META_LOGIN = 'aksrt_free_points_login_date';
public const META_SIGNIN = 'aksrt_free_points_signin_date';
public const META_TASKS = 'aksrt_free_points_task_dates';
-3
View File
@@ -124,7 +124,6 @@ class Signin {
}
$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) {
@@ -164,7 +163,6 @@ class Signin {
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);
@@ -189,7 +187,6 @@ class Signin {
'streak' => $new_streak,
'reward' => $reward,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'transaction_id' => $transaction_id,
'replayed' => $replayed,
], 200);
}
-198
View File
@@ -1,198 +0,0 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Finance;
defined('ABSPATH') || exit;
final class FinancialFlowQuery {
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;
}
public function order_payment_number(string $order_number): string {
global $wpdb;
$order_number = substr(sanitize_text_field($order_number), 0, 32);
if ($order_number === '') {
return '';
}
return (string) $wpdb->get_var($wpdb->prepare(
"SELECT flow_number FROM {$this->flow_table()} WHERE order_number = %s AND event_type = 'order_payment' ORDER BY id ASC LIMIT 1",
$order_number
));
}
/** @param array<int,string> $order_numbers @return array<string,string> */
public function order_payment_number_map(array $order_numbers): array {
global $wpdb;
$order_numbers = array_values(array_unique(array_filter(array_map(static function ($number): string {
return substr(sanitize_text_field((string) $number), 0, 32);
}, $order_numbers))));
if ($order_numbers === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($order_numbers), '%s'));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT order_number, flow_number FROM {$this->flow_table()} WHERE event_type = 'order_payment' AND order_number IN ({$placeholders}) ORDER BY id ASC",
$order_numbers
), ARRAY_A);
$map = [];
foreach ((array) $rows as $row) {
$number = (string) ($row['order_number'] ?? '');
if ($number !== '' && !isset($map[$number])) {
$map[$number] = (string) ($row['flow_number'] ?? '');
}
}
return $map;
}
/**
* @param array<string,mixed> $args
* @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int}
*/
public function search(array $args = []): array {
global $wpdb;
$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 = [];
$keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? '')));
if ($keyword !== '') {
if ($this->keyword_hits_identifier($keyword)) {
// 命中完整标识符:走等值查询。各列都有索引,避免 6 路 LIKE '%kw%' 全表扫描。
$where[] = '(f.flow_number = %s OR f.operation_key = %s OR f.order_number = %s OR f.withdrawal_number = %s OR f.provider_transaction_id = %s OR f.ref_key = %s)';
array_push($params, $keyword, $keyword, $keyword, $keyword, $keyword, $keyword);
} else {
// 未精确命中(含前缀、部分匹配、含中文或短关键字):退回模糊匹配。
$like = '%' . $wpdb->esc_like($keyword) . '%';
$where[] = '(f.flow_number LIKE %s OR f.operation_key LIKE %s OR f.order_number LIKE %s OR f.withdrawal_number LIKE %s OR f.provider_transaction_id LIKE %s OR f.ref_key LIKE %s)';
array_push($params, $like, $like, $like, $like, $like, $like);
}
}
$event_type = sanitize_key((string) ($args['event_type'] ?? ''));
if ($event_type !== '') {
$where[] = 'f.event_type = %s';
$params[] = $event_type;
}
$user_id = max(0, (int) ($args['user_id'] ?? 0));
if ($user_id > 0) {
$where[] = "EXISTS (SELECT 1 FROM {$this->entry_table()} eu WHERE eu.flow_id = f.id AND (eu.user_id = %d OR eu.counterparty_user_id = %d))";
array_push($params, $user_id, $user_id);
}
$asset = sanitize_key((string) ($args['asset'] ?? ''));
if (in_array($asset, ['cash', 'balance', 'points'], true)) {
$where[] = "EXISTS (SELECT 1 FROM {$this->entry_table()} ea WHERE ea.flow_id = f.id AND ea.asset = %s)";
$params[] = $asset;
}
$direction = sanitize_key((string) ($args['direction'] ?? ''));
if (in_array($direction, ['in', 'out'], true)) {
$where[] = "EXISTS (SELECT 1 FROM {$this->entry_table()} ed WHERE ed.flow_id = f.id AND ed.direction = %s)";
$params[] = $direction;
}
if (!empty($args['date_from'])) {
$where[] = 'f.created_at >= %s';
$params[] = (string) $args['date_from'];
}
if (!empty($args['date_to'])) {
$where[] = 'f.created_at < %s';
$params[] = (string) $args['date_to'];
}
$where_sql = implode(' AND ', $where);
$count_sql = "SELECT COUNT(*) FROM {$this->flow_table()} f WHERE {$where_sql}";
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
$item_sql = "SELECT f.* FROM {$this->flow_table()} f WHERE {$where_sql} ORDER BY f.created_at DESC, f.id DESC LIMIT %d OFFSET %d";
$rows = (array) $wpdb->get_results($wpdb->prepare($item_sql, array_merge($params, [$per_page, $offset])), ARRAY_A);
$ids = array_values(array_filter(array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $rows)));
$entry_map = $this->entry_map($ids);
foreach ($rows as &$row) {
$row['id'] = (int) ($row['id'] ?? 0);
$row['ledger_transaction_id'] = (int) ($row['ledger_transaction_id'] ?? 0);
$row['metadata'] = json_decode((string) ($row['metadata'] ?? ''), true) ?: [];
$row['entries'] = $entry_map[$row['id']] ?? [];
}
unset($row);
return ['items' => $rows, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
}
/** @return array<string,string> */
public function event_types(): array {
global $wpdb;
$values = (array) $wpdb->get_col("SELECT DISTINCT event_type FROM {$this->flow_table()} ORDER BY event_type ASC");
$out = [];
foreach ($values as $value) {
$key = sanitize_key((string) $value);
if ($key !== '') {
$out[$key] = $key;
}
}
return $out;
}
/** @param array<int,int> $flow_ids @return array<int,array<int,array<string,mixed>>> */
private function entry_map(array $flow_ids): array {
global $wpdb;
if ($flow_ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($flow_ids), '%d'));
$rows = (array) $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->entry_table()} WHERE flow_id IN ({$placeholders}) ORDER BY flow_id ASC, line_no ASC",
$flow_ids
), ARRAY_A);
$map = [];
foreach ($rows as $row) {
foreach (['id', 'flow_id', 'line_no', 'user_id', 'delta_minor', 'balance_after_minor', 'counterparty_user_id'] as $field) {
$row[$field] = (int) ($row[$field] ?? 0);
}
$map[$row['flow_id']][] = $row;
}
return $map;
}
/**
* 关键字是否精确命中某个标识符列。
*
* 业务流水号统一为 {YmdHis}{8 位随机数},运营通常是从截图或邮件里整段复制,
* 此时等值查询可以走索引。若没有精确命中,则退回 LIKE 模糊匹配,保证
* 「只输入前缀也能搜到」的既有行为不变。
*/
private function keyword_hits_identifier(string $keyword): bool {
global $wpdb;
// 只对像标识符的输入启用:纯 ASCII 字母数字、长度 8-191。
if (!preg_match('/^[A-Za-z0-9_-]{8,191}$/', $keyword)) {
return false;
}
$found = $wpdb->get_var($wpdb->prepare(
"SELECT 1 FROM {$this->flow_table()}
WHERE flow_number = %s OR operation_key = %s OR order_number = %s
OR withdrawal_number = %s OR provider_transaction_id = %s OR ref_key = %s
LIMIT 1",
$keyword,
$keyword,
$keyword,
$keyword,
$keyword,
$keyword
));
return $found !== null;
}
private function flow_table(): string {
return \AKSRT\Core\DBMigrator::table('financial_flow');
}
private function entry_table(): string {
return \AKSRT\Core\DBMigrator::table('financial_flow_entry');
}
}
+1 -216
View File
@@ -31,12 +31,11 @@ final class FinancialFlowService {
*/
public function record_ledger(LedgerCommand $command, int $transaction_id, array $entries, string $created_at) {
$order_number = $command->ref_type === 'order' ? $command->ref_key : (string) ($command->metadata['order_number'] ?? '');
$order = $order_number !== '' ? $this->resolve_order($order_number) : [];
$withdrawal_number = '';
if ($command->ref_type === 'withdraw') {
$withdrawal_number = $this->resolve_withdrawal_number($command, $transaction_id);
}
$provider_transaction_id = (string) ($order['payment_transaction_id'] ?? '');
$provider_transaction_id = '';
$flow_entries = [];
foreach ($entries as $entry) {
@@ -54,26 +53,6 @@ final class FinancialFlowService {
}
$event_type = $command->reason;
$metadata = $command->metadata;
if ($order !== [] && $this->is_successful_order_payment($order, $flow_entries)) {
$event_type = 'order_payment';
$metadata['ledger_reason'] = $command->reason;
if ($this->is_external_recharge($order, $flow_entries)) {
try {
$cash_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? ''))->minor();
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_flow_amount', __('充值支付流水金额无效', 'aksrt'), ['status' => 500]);
}
$flow_entries[] = [
'user_id' => max(0, (int) ($order['user_id'] ?? 0)),
'asset' => 'cash',
'direction' => 'out',
'entry_type' => 'payment',
'delta_minor' => -$cash_minor,
'counterparty_user_id' => 0,
'remark' => __('充值订单支付', 'aksrt'),
];
}
}
return $this->record([
'operation_key' => $command->operation_key,
@@ -90,78 +69,6 @@ final class FinancialFlowService {
}
/** @return string|\WP_Error */
public function record_order_payment(array $order, bool $caller_transaction = false) {
return $this->record_order_event($order, 'order_payment', -1, $caller_transaction);
}
/** @return string|\WP_Error */
public function record_order_refund(array $order, bool $caller_transaction = false) {
return $this->record_order_event($order, 'order_refund', 1, $caller_transaction);
}
/**
* 记录卡密核销产生的订单支付流水。
*
* 卡密是平台自发的预付凭证:核销那一刻既没有第三方现金流入,也不产生账本资产分录,
* 订单金额还可能为 0(会员卡)或以积分计价(积分卡)。因此这里只落一条与订单关联的
* order_payment 流水,不生成任何现金/资产分录,让订单在详情页具备可追溯的「账本流水号」。
*
* 余额卡与积分卡的核销走账本(LedgerService::credit),其流水由 record_ledger() 落库;
* 本方法供会员卡这类无账本参与的卡种调用。
*
* @param array<string,mixed> $order
* @return string|\WP_Error
*/
public function record_card_pass_payment(array $order, bool $caller_transaction = false) {
return $this->record_order_event($order, 'order_payment', -1, $caller_transaction, true);
}
/**
* 把账本流水挂到卡密核销订单上(订单号 + 渠道交易号 + 事件类型归一)。
*
* 卡密核销的资产入账走账本,但订单号是随机生成的、不能进 LedgerCommand
* (payload_hash 覆盖 metadata,塞入随机号会破坏账本幂等重放)。因此账本
* 提交之后再调用本方法补挂关联;同时把 event_type 归一为 order_payment,
* 让订单详情页的「账本流水号」能反查到它。
*
* @return bool 是否发生了更新
*/
public function attach_order_to_ledger_flow(int $ledger_transaction_id, string $order_number, string $provider_transaction_id = ''): bool {
global $wpdb;
if ($ledger_transaction_id <= 0 || $order_number === '') {
return false;
}
$order_number = substr(sanitize_text_field($order_number), 0, 32);
$order = $wpdb->get_row($wpdb->prepare(
"SELECT status, payment_method, payment_transaction_id FROM {$this->order_table()} WHERE order_number = %s LIMIT 1",
$order_number
), ARRAY_A);
if (!is_array($order)
|| sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID
|| sanitize_key((string) ($order['payment_method'] ?? '')) !== 'card_pass'
) {
return false;
}
if ($provider_transaction_id === '') {
$provider_transaction_id = (string) ($order['payment_transaction_id'] ?? '');
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->flow_table()}
SET order_number = %s,
provider_transaction_id = CASE WHEN provider_transaction_id IS NULL OR provider_transaction_id = '' THEN %s ELSE provider_transaction_id END,
event_type = 'order_payment'
WHERE ledger_transaction_id = %d AND (order_number IS NULL OR order_number = '')",
$order_number,
substr(sanitize_text_field($provider_transaction_id), 0, 128),
$ledger_transaction_id
));
return $updated === 1;
}
private function order_table(): string {
return \AKSRT\Core\DBMigrator::table('order');
}
/** @return string|\WP_Error */
public function record_withdrawal_payout(array $withdrawal, string $operation_key, string $created_at, bool $caller_transaction = false) {
$user_id = max(0, (int) ($withdrawal['user_id'] ?? 0));
@@ -334,79 +241,6 @@ final class FinancialFlowService {
));
}
/**
* @param array<string,mixed> $order
* @param bool $voucher_settled 凭证结算(卡密核销):订单可能为 0 元或以积分计价,
* 且没有任何现金/资产分录,故跳过金额与资产口径校验。
*/
private function record_order_event(array $order, string $event_type, int $sign, bool $caller_transaction, bool $voucher_settled = false) {
$order_number = substr(sanitize_text_field((string) ($order['order_number'] ?? '')), 0, 32);
$user_id = max(0, (int) ($order['user_id'] ?? 0));
if ($order_number === '') {
return new \WP_Error('aksrt_flow_order', __('订单流水参数无效', 'aksrt'), ['status' => 500]);
}
$existing = $this->number_for_order_event($order_number, $event_type);
if ($existing !== '') {
return $existing;
}
$payment_method = sanitize_key((string) ($order['payment_method'] ?? ''));
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
$metadata = [
'payment_method' => $payment_method,
'currency' => $currency,
];
$entries = [];
if ($voucher_settled) {
// 卡密核销:凭证本身即支付凭据,订单金额可能为 0、也可能以积分计价,
// 因此不做金额校验、也不生成现金分录;流水行即该订单的支付凭证
// (订单详情页经 FinancialFlowQuery::order_payment_number() 反查得到)。
$metadata['voucher'] = 'card_pass';
} else {
if (in_array($payment_method, ['balance', 'points'], true) || $currency === 'points') {
return new \WP_Error('aksrt_flow_order_asset', __('余额或积分订单必须通过资产账本确认', 'aksrt'), ['status' => 500]);
}
try {
$amount_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? ''))->minor();
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
}
if ($amount_minor <= 0) {
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
}
$entries[] = [
'user_id' => $user_id,
'asset' => 'cash',
'direction' => $sign > 0 ? 'in' : 'out',
'entry_type' => $event_type === 'order_payment' ? 'payment' : 'refund',
'delta_minor' => $sign * $amount_minor,
'remark' => $event_type === 'order_payment' ? __('订单支付', 'aksrt') : __('订单退款', 'aksrt'),
];
}
$operation_key = \AKSRT\Ledger\OperationKey::deterministic(
$event_type === 'order_payment' ? 'order-payment' : 'order-refund',
[$order_number]
);
return $this->record([
'operation_key' => $operation_key,
'event_type' => $event_type,
'ref_type' => 'order',
'ref_key' => $order_number,
'order_number' => $order_number,
'provider_transaction_id' => (string) ($order['payment_transaction_id'] ?? ''),
'metadata' => $metadata,
'created_at' => self::utc_now(),
], $entries, $caller_transaction, $voucher_settled);
}
private function number_for_order_event(string $order_number, string $event_type): string {
global $wpdb;
return (string) $wpdb->get_var($wpdb->prepare(
"SELECT flow_number FROM {$this->flow_table()} WHERE order_number = %s AND event_type = %s ORDER BY id ASC LIMIT 1",
$order_number,
$event_type
));
}
private function resolve_withdrawal_number(LedgerCommand $command, int $transaction_id): string {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('withdrawal');
@@ -420,55 +254,6 @@ final class FinancialFlowService {
return (string) $wpdb->get_var($wpdb->prepare("SELECT withdrawal_number FROM {$table} WHERE ledger_transaction_id = %d LIMIT 1", $transaction_id));
}
/** @return array<string,mixed> */
private function resolve_order(string $order_number): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('order');
$row = $wpdb->get_row($wpdb->prepare(
"SELECT order_number, user_id, product_type, amount, currency, status, payment_method, payment_transaction_id FROM {$table} WHERE order_number = %s LIMIT 1",
$order_number
), ARRAY_A);
return is_array($row) ? $row : [];
}
/** @param array<string,mixed> $order @param array<int,array<string,mixed>> $entries */
private function is_successful_order_payment(array $order, array $entries): bool {
if (sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID) {
return false;
}
// card_pass:卡密即支付凭据,订单已支付就说明凭证已结算,不依赖现金或资产分录为凭。
// 余额卡/积分卡的核销因此也能落成 order_payment 流水,与普通订单同口径。
if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'card_pass') {
return true;
}
$buyer_id = max(0, (int) ($order['user_id'] ?? 0));
foreach ($entries as $entry) {
if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (int) ($entry['delta_minor'] ?? 0) < 0) {
return true;
}
}
return $this->is_external_recharge($order, $entries);
}
/** @param array<string,mixed> $order @param array<int,array<string,mixed>> $entries */
private function is_external_recharge(array $order, array $entries): bool {
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
// 这些方式都在平台内部结算,核销当下没有第三方现金流入:balance / points 以已有资产支付,
// card_pass 以平台自发凭证支付(现金在售卡时已确认),故都不生成现金分录。
if (sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|| in_array($method, ['', 'balance', 'points', 'card_pass'], true)
) {
return false;
}
$buyer_id = max(0, (int) ($order['user_id'] ?? 0));
foreach ($entries as $entry) {
if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (string) ($entry['asset'] ?? '') === 'balance' && (int) ($entry['delta_minor'] ?? 0) > 0) {
return true;
}
}
return false;
}
private function flow_table(): string {
return \AKSRT\Core\DBMigrator::table('financial_flow');
}
+20
View File
@@ -196,6 +196,26 @@ final class LedgerQuery {
return 0;
}
$asset = AssetType::from_value($asset);
if (in_array($asset, [AssetType::BALANCE, AssetType::POINTS], true) && class_exists('AKSRT\\Credits\\MetaAssetService')) {
$meta_key = $asset === AssetType::POINTS ? 'points_record' : 'balance_record';
$records = get_user_meta($user_id, $meta_key, true);
$total = 0;
foreach (is_array($records) ? $records : [] as $record) {
if (!is_array($record) || sanitize_key((string) ($record['type'] ?? '')) !== $reason) continue;
$value = $record['value'] ?? 0;
if ($asset === AssetType::POINTS) {
$delta = is_int($value) ? $value : (preg_match('/^\d+$/', (string) $value) ? (int) $value : 0);
} else {
$raw = (string) $value;
if (str_starts_with($raw, '-')) continue;
try { $delta = AssetAmount::balance($raw)->minor(); } catch (\InvalidArgumentException $e) { $delta = 0; }
}
if ($delta > 0) {
$total = $total > PHP_INT_MAX - $delta ? PHP_INT_MAX : $total + $delta;
}
}
return $total;
}
$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(
+110 -3
View File
@@ -37,6 +37,12 @@ final class LedgerService {
}
public function balance(int $user_id, AssetType|string $asset): int {
$asset = AssetType::from_value($asset);
if (in_array($asset, [AssetType::BALANCE, AssetType::POINTS], true) && class_exists('\AKSRT\Credits\MetaAssetService')) {
return $asset === AssetType::POINTS
? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points($user_id)
: \AKSRT\Credits\MetaAssetService::get_instance()->balance_minor($user_id);
}
global $wpdb;
if ($user_id <= 0) {
return 0;
@@ -51,6 +57,9 @@ final class LedgerService {
}
public function balance_decimal(int $user_id): string {
if (class_exists('\AKSRT\Credits\MetaAssetService')) {
return \AKSRT\Credits\MetaAssetService::get_instance()->balance_decimal($user_id);
}
return AssetAmount::from_minor(AssetType::BALANCE, $this->balance($user_id, AssetType::BALANCE))->decimal();
}
@@ -95,6 +104,24 @@ final class LedgerService {
if ($amount_minor <= 0) {
return new \WP_Error('aksrt_ledger_amount', __('入账金额必须大于零', 'aksrt'), ['status' => 400]);
}
if (in_array($asset, [AssetType::BALANCE, AssetType::POINTS], true) && class_exists('\AKSRT\Credits\MetaAssetService')) {
$command = 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,
array_merge($metadata, ['order_number' => $metadata['order_number'] ?? ($ref['key'] ?? '')])
);
return $this->post_meta_command($command, $guard, $business_mutation);
}
return $this->post(new LedgerCommand(
$operation_key,
sanitize_key($reason),
@@ -137,6 +164,24 @@ final class LedgerService {
if ($amount_minor <= 0) {
return new \WP_Error('aksrt_ledger_amount', __('扣减金额必须大于零', 'aksrt'), ['status' => 400]);
}
if (in_array($asset, [AssetType::BALANCE, AssetType::POINTS], true) && class_exists('\AKSRT\Credits\MetaAssetService')) {
$command = 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,
array_merge($metadata, ['order_number' => $metadata['order_number'] ?? ($ref['key'] ?? '')])
);
return $this->post_meta_command($command, $guard, $business_mutation);
}
return $this->post(new LedgerCommand(
$operation_key,
sanitize_key($reason),
@@ -179,6 +224,11 @@ final class LedgerService {
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]);
}
if (in_array($asset, [AssetType::BALANCE, AssetType::POINTS], true) && class_exists('\AKSRT\Credits\MetaAssetService')) {
return $this->post_meta_command(
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, array_merge($metadata, ['order_number' => $ref['key'] ?? '']))
);
}
return $this->post(LedgerCommand::transfer(
$operation_key,
sanitize_key($reason),
@@ -214,12 +264,26 @@ final class LedgerService {
if ($user_id <= 0 || $balance_minor <= 0 || $points <= 0) {
return new \WP_Error('aksrt_ledger_exchange', __('兑换参数无效', 'aksrt'), ['status' => 400]);
}
if (class_exists('\AKSRT\Credits\MetaAssetService')) {
return $this->post_meta_command(new LedgerCommand(
$operation_key,
sanitize_key($reason),
[
['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => -$balance_minor, 'entry_type' => 'debit', 'remark' => $remark],
['user_id' => $user_id, 'asset' => AssetType::POINTS->value, 'delta_minor' => $points, 'entry_type' => 'credit', 'remark' => $remark],
],
$this->ref_type($ref),
$this->ref_key($ref),
$user_id,
array_merge($metadata, ['order_number' => $ref['key'] ?? ''])
), $guard, $business_mutation);
}
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],
['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => -$balance_minor, 'entry_type' => 'debit', 'remark' => $remark],
['user_id' => $user_id, 'asset' => AssetType::POINTS->value, 'delta_minor' => $points, 'entry_type' => 'credit', 'remark' => $remark],
],
$this->ref_type($ref),
$this->ref_key($ref),
@@ -234,6 +298,10 @@ final class LedgerService {
* @return LedgerResult|\WP_Error
*/
public function post(LedgerCommand $command, ?callable $guard = null, ?callable $business_mutation = null) {
$assets = array_values(array_unique(array_map(static fn(array $entry): string => (string) ($entry['asset'] ?? ''), $command->entries)));
if ($assets !== [] && count(array_diff($assets, [AssetType::BALANCE->value, AssetType::POINTS->value])) === 0 && class_exists('\AKSRT\Credits\MetaAssetService')) {
return $this->post_meta_command($command, $guard, $business_mutation);
}
if ($this->transaction_active) {
return new \WP_Error('aksrt_ledger_nested', __('账务事务内禁止再次提交账务', 'aksrt'), ['status' => 500]);
}
@@ -253,6 +321,45 @@ final class LedgerService {
return new \WP_Error('aksrt_ledger_db', __('账务处理失败,请稍后重试', 'aksrt'), ['status' => 500]);
}
/** @return LedgerResult|\WP_Error */
private function post_meta_command(LedgerCommand $command, ?callable $guard = null, ?callable $business_mutation = null) {
if ($this->transaction_active) {
return new \WP_Error('aksrt_ledger_nested', __('账务事务内禁止再次提交账务', 'aksrt'), ['status' => 500]);
}
$this->transaction_active = true;
$this->after_commit_callbacks = [];
try {
$result = \AKSRT\Credits\MetaAssetService::get_instance()->post_command($command, $guard, $business_mutation);
$callbacks = is_wp_error($result) ? [] : $this->after_commit_callbacks;
$this->after_commit_callbacks = [];
if (!is_wp_error($result)) {
$this->transaction_active = false;
if (!$result->replayed) {
try {
do_action('aksrt_asset_transaction_committed', $result, $command);
} catch (\Throwable $error) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT Meta Asset Event] ' . $error->getMessage());
}
}
}
foreach ($callbacks as $callback) {
try {
$callback();
} catch (\Throwable $error) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT Meta Asset After Commit] ' . $error->getMessage());
}
}
}
}
return $result;
} finally {
$this->transaction_active = false;
$this->after_commit_callbacks = [];
}
}
private function attempt_post(LedgerCommand $command, string $payload_hash, ?callable $guard, ?callable $business_mutation) {
global $wpdb;
$account_table = $this->table('asset_account');
@@ -531,7 +638,7 @@ final class LedgerService {
if (isset($wpdb->last_errno)) {
return (int) $wpdb->last_errno;
}
if (preg_match('/\\b(1205|1213)\\b/', (string) ($wpdb->last_error ?? ''), $matches)) {
if (preg_match('/\b(1205|1213)\b/', (string) ($wpdb->last_error ?? ''), $matches)) {
return (int) $matches[1];
}
return 0;
+554
View File
@@ -0,0 +1,554 @@
<?php
/** User applications and administrator review for full order refunds. */
namespace AKSRT\Payment;
use AKSRT\Core\DomainModel;
use AKSRT\Ledger\AssetAmount;
use AKSRT\Ledger\AssetType;
use AKSRT\Ledger\LedgerCommand;
use AKSRT\Ledger\LedgerService;
defined('ABSPATH') || exit;
final class AfterSaleService {
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
private static ?self $instance = null;
public static function get_instance(): self { return self::$instance ??= new self(); }
public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); }
public function register_routes(): void {
$number = '(?P<order_number>[0-9]{20}|[0-9]{22})';
register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale', [
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_submit'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'reason' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
]],
['methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
]],
['methods' => 'DELETE', 'callback' => [$this, 'rest_cancel'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
]],
]);
}
public function require_user(\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');
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest') ? true : new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
public function rest_submit(\WP_REST_Request $request) {
$result = $this->create(get_current_user_id(), (string) $request->get_param('order_number'), (string) $request->get_param('reason'));
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
}
public function rest_list(\WP_REST_Request $request) {
$number = sanitize_text_field((string) $request->get_param('order_number'));
if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]);
return rest_ensure_response(['success' => true, 'data' => $this->list_for_order($number)]);
}
public function rest_cancel(\WP_REST_Request $request) {
$result = $this->cancel_for_user(get_current_user_id(), (string) $request->get_param('order_number'));
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
}
public function create(int $user_id, string $order_number, string $reason) {
$reason = sanitize_textarea_field($reason);
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number) || $reason === '' || mb_strlen($reason) > 2000) {
return new \WP_Error('aksrt_after_sale_invalid', __('请填写有效的售后原因', 'aksrt'), ['status' => 400]);
}
global $wpdb;
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]);
$active = $this->find_active($order_number);
if ($active) {
if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) {
return $active;
}
return new \WP_Error('aksrt_after_sale_exists', __('该订单已有未结束售后申请', 'aksrt'), ['status' => 409]);
}
$amount = $this->refund_amount($order);
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
$inserted = $wpdb->insert($this->table(), [
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
'reason' => $reason, 'refund_amount' => $amount, 'status' => self::STATUS_PENDING,
'created_at' => current_time('mysql'),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s']);
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
public function cancel_for_user(int $user_id, string $order_number) {
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number)) return new \WP_Error('aksrt_after_sale_invalid', __('售后申请无效', 'aksrt'), ['status' => 400]);
global $wpdb;
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$row = $this->find_active($order_number);
if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]);
if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]);
if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]);
return $this->get((int) $row['id']);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/** @return list<array<string,mixed>> */
public function list_for_order(string $order_number): array {
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC", $order_number), ARRAY_A);
return array_map([$this, 'normalize'], (array) $rows);
}
/** @return list<array<string,mixed>> */
public function admin_list(array $args = []): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
$valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED, self::STATUS_COMPLETED];
if ($status !== '' && in_array($status, $valid, true)) { $where[] = 'status = %s'; $params[] = $status; }
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$sql = "SELECT * FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' ORDER BY id DESC LIMIT %d OFFSET %d';
$params[] = $limit; $params[] = $offset;
return array_map([$this, 'normalize'], (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A));
}
public function count(array $args = []): int {
global $wpdb;
$status = sanitize_key((string) ($args['status'] ?? ''));
$where = [];
$params = [];
if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; }
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
$sql = "SELECT COUNT(*) FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '');
return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
}
public function count_user_open(int $user_id): int {
if ($user_id <= 0) return 0;
global $wpdb;
return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing','approved')", $user_id));
}
/** A refund status transition is valid only for its own active application. */
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
if ($after_sale_id <= 0 || $order_number === '') return false;
$row = $this->get($after_sale_id);
return is_array($row)
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED], true);
}
public function review(int $id, string $decision, string $note = '') {
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
$decision = sanitize_key($decision);
if (!in_array($decision, ['approve', 'reject', 'complete'], true)) return new \WP_Error('aksrt_after_sale_decision', __('审核动作无效', 'aksrt'), ['status' => 400]);
global $wpdb;
$initial = $this->get($id);
if (!$initial) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]);
$order_number = (string) ($initial['order_number'] ?? '');
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$row = $this->get($id);
if (!$row || !hash_equals($order_number, (string) ($row['order_number'] ?? '')) || !in_array((string) $row['status'], [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
if ((string) $row['status'] === self::STATUS_PROCESSING) {
if ($decision === 'complete') {
$gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true);
if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') {
return new \WP_Error('aksrt_after_sale_manual_only', __('该申请不是待人工确认的退款', 'aksrt'), ['status' => 409]);
}
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]);
$finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? ''));
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
}
if ($decision === 'reject') {
$gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true);
if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') {
return new \WP_Error('aksrt_after_sale_processing', __('退款处理中,不能拒绝;请同步退款结果', 'aksrt'), ['status' => 409]);
}
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]);
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
$revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? []));
if (is_wp_error($revoke)) return $revoke;
if ($revoke !== []) {
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
if (is_wp_error($compensated)) return $compensated;
}
if (!$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('管理员确认未执行渠道退款,已恢复冻结资产', 'aksrt')])) {
return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]);
}
return $this->get((int) $row['id']);
}
return $this->sync_gateway_refund($row, $note);
}
if ($decision === 'complete') return new \WP_Error('aksrt_after_sale_manual_only', __('只有待人工确认的退款可以直接完成', 'aksrt'), ['status' => 409]);
if ($decision === 'reject') {
if (!$this->update($id, ['status' => self::STATUS_REJECTED, 'admin_note' => $note, 'reviewed_at' => current_time('mysql')])) {
return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]);
}
return $this->get($id);
}
return $this->approve($row, $note);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
private function approve(array $row, string $note) {
$order_number = (string) $row['order_number'];
$order = OrderRepository::get_instance()->get($order_number);
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('订单已不是可退款状态', 'aksrt'), ['status' => 409]);
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($type === DomainModel::ORDER_PRODUCT_VIP && !$this->vip_reversal_is_safe($order, $meta)) return new \WP_Error('aksrt_after_sale_vip_snapshot', __('会员权益已变化或缺少快照,不能安全撤销', 'aksrt'), ['status' => 409]);
$revoke = $this->assets_to_revoke($order, $type, $meta);
if (is_wp_error($revoke)) return $revoke;
if ($method === 'card_pass') {
if (!$this->asset_entries_available($revoke)) return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能撤销卡密权益', 'aksrt'), ['status' => 409]);
return $this->commit_status_refund($row, $order, $note, $revoke);
}
if ($method === 'free') {
return $this->commit_status_refund($row, $order, $note, $revoke);
}
$refund_local = in_array($method, ['balance', 'points'], true);
if ($refund_local) {
$refund_asset = $method === 'points' ? AssetType::POINTS : AssetType::BALANCE;
$refund_minor = $refund_asset === AssetType::POINTS ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($row['refund_amount'] ?? '0'));
if ($refund_minor <= 0) return new \WP_Error('aksrt_after_sale_amount', __('退款金额无效', 'aksrt'), ['status' => 400]);
$entries = $revoke;
// A points-purchase reversal already includes the original balance payment
// in assets_to_revoke(); do not return that payment twice.
if (!($type === DomainModel::ORDER_PRODUCT_POINTS && $method === 'balance')) {
$entries[] = ['user_id' => (int) $order['user_id'], 'asset' => $refund_asset->value, 'delta_minor' => $refund_minor, 'entry_type' => 'credit', 'remark' => sprintf(__('订单 %s 售后退款', 'aksrt'), $order_number)];
}
return $this->commit_local_refund($row, $order, $entries, $note);
}
$gateway = Gateway::get_instance()->get_gateway($method);
if (!$gateway || !method_exists($gateway, 'refund')) return new \WP_Error('aksrt_after_sale_gateway', __('支付渠道不可用,无法退款', 'aksrt'), ['status' => 409]);
$request_id = (string) ($row['gateway_request_id'] ?? '') ?: 'AS_' . (int) $row['id'];
if (!$this->asset_entries_available($revoke)) {
return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能审核通过退款', 'aksrt'), ['status' => 409]);
}
if (!$this->update((int) $row['id'], ['status' => self::STATUS_PROCESSING, 'admin_note' => $note, 'gateway_request_id' => $request_id, 'reviewed_at' => current_time('mysql')])) {
return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败,未发起退款', 'aksrt'), ['status' => 500]);
}
if ($revoke !== []) {
$held = $this->post_asset_entries($row, $order, $revoke, 'after-sale-revoke', 'after_sale_revoke', $note);
if (is_wp_error($held)) {
$this->update((int) $row['id'], ['status' => self::STATUS_PENDING, 'gateway_request_id' => null, 'reviewed_at' => null]);
return $held;
}
}
try {
$result = $gateway->refund($order_number, (string) $row['refund_amount'], $request_id);
} catch (\Throwable $error) {
$result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()];
}
if (!is_array($result)) {
$result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')];
}
$this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? ''))]);
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
if ($status === 'failed') {
if ($revoke !== []) {
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
if (is_wp_error($compensated)) {
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]);
return $this->get((int) $row['id']);
}
}
$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => sanitize_textarea_field((string) ($result['message'] ?? $note))]);
return $this->get((int) $row['id']);
}
// Missing, unknown, and pending provider outcomes are ambiguous. Keep the
// application processing and the asset clawback in place until query_refund
// explicitly confirms success or failure.
if (empty($result['success']) || $status !== 'succeeded') {
$this->update((int) $row['id'], ['admin_note' => sanitize_textarea_field((string) ($result['message'] ?? __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')))]);
return $this->get((int) $row['id']);
}
$finished = $this->finish_refund($row, $order, $note);
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
}
private function commit_local_refund(array $row, array $order, array $entries, string $note) {
$number = (string) $order['order_number'];
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
if (is_wp_error($commission)) return $commission;
$guard = static fn() => CommissionLedger::get_instance()->guard_full_refund($commission);
$mutation = function () use ($row, $order, $note, $commission) {
$commission_guard = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($commission_guard) || $commission_guard !== true) return is_wp_error($commission_guard) ? $commission_guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
$finished = $this->finish_refund($row, $order, $note, false);
if ($finished === true) {
LedgerService::get_instance()->after_commit(static function (): void {
OrderRepository::get_instance()->dispatch_managed_status_events();
});
}
return $finished;
};
$result = LedgerService::get_instance()->post(new LedgerCommand(
\AKSRT\Ledger\OperationKey::deterministic('after-sale-refund', [(int) $row['id']]), 'order_refund', $entries,
'order', $number, get_current_user_id(), ['order_number' => $number]
), $guard, $mutation);
if (is_wp_error($result)) return $result;
CommissionLedger::get_instance()->clawback_after_refund($commission);
return $this->get((int) $row['id']);
}
private function post_asset_entries(array $row, array $order, array $entries, string $prefix, string $reason, string $note) {
return LedgerService::get_instance()->post(new LedgerCommand(
\AKSRT\Ledger\OperationKey::deterministic($prefix, [(int) $row['id']]), $reason, $entries,
'order', (string) $order['order_number'], get_current_user_id(), ['order_number' => (string) $order['order_number'], 'after_sale_id' => (int) $row['id']]
));
}
private function commit_status_refund(array $row, array $order, string $note, array $entries = []) {
global $wpdb;
$number = (string) $order['order_number'];
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
if (is_wp_error($commission)) return $commission;
if ($entries !== []) {
$mutation = function () use ($row, $order, $note, $commission) {
$guard = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($guard) || $guard !== true) return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
$finished = $this->finish_refund($row, $order, $note, false);
if ($finished === true) {
LedgerService::get_instance()->after_commit(static function (): void { OrderRepository::get_instance()->dispatch_managed_status_events(); });
}
return $finished;
};
$result = LedgerService::get_instance()->post(new LedgerCommand(
\AKSRT\Ledger\OperationKey::deterministic('after-sale-card-refund', [(int) $row['id']]), 'order_refund', $entries,
'order', $number, get_current_user_id(), ['order_number' => $number]
), static fn() => CommissionLedger::get_instance()->guard_full_refund($commission), $mutation);
if (is_wp_error($result)) return $result;
CommissionLedger::get_instance()->clawback_after_refund($commission);
return $this->get((int) $row['id']);
}
if ($wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]);
$guard = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($guard) || $guard !== true) { $wpdb->query('ROLLBACK'); return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); }
$finished = $this->finish_order_and_application($row, $order, $note);
if (is_wp_error($finished) || !$finished) { $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
$applied = CommissionLedger::get_instance()->apply_full_refund($commission);
if (is_wp_error($applied) || $wpdb->query('COMMIT') === false) { $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
OrderRepository::get_instance()->dispatch_managed_status_events();
CommissionLedger::get_instance()->clawback_after_refund($commission);
return $this->get((int) $row['id']);
}
private function sync_gateway_refund(array $row, string $note) {
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
$gateway = Gateway::get_instance()->get_gateway($method);
$request_id = (string) ($row['gateway_request_id'] ?? '');
if (!$order || !$gateway || !method_exists($gateway, 'query_refund') || $request_id === '') return new \WP_Error('aksrt_after_sale_sync_unavailable', __('该支付渠道暂不支持自动查询退款状态', 'aksrt'), ['status' => 409]);
try {
$result = $gateway->query_refund($request_id, (string) $row['order_number']);
} catch (\Throwable $error) {
$result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()];
}
if (!is_array($result)) {
$result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')];
}
$this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $row['gateway_refund_id'] ?? ''))]);
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
if ($status === 'succeeded' && !empty($result['success'])) {
$finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? ''));
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
}
if ($status === 'failed' && !empty($result['success'])) {
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
$revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? []));
if (is_wp_error($revoke)) {
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但无法确认资产补偿内容;申请保持处理中,请人工核对。', 'aksrt') . ' ' . $revoke->get_error_message()]);
return $this->get((int) $row['id']);
}
if ($revoke !== []) {
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
if (is_wp_error($compensated)) {
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]);
return $this->get((int) $row['id']);
}
}
$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('支付渠道退款失败,已恢复撤销的资产', 'aksrt')]);
} elseif ($status !== 'succeeded') {
$this->update((int) $row['id'], ['admin_note' => $note !== '' ? $note : __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')]);
}
return $this->get((int) $row['id']);
}
private function compensate_revoke(array $row, array $order, array $entries, string $note): bool|\WP_Error {
$compensate = array_map(static function (array $entry): array { $entry['delta_minor'] = -(int) $entry['delta_minor']; $entry['entry_type'] = 'credit'; $entry['remark'] = __('退款渠道失败,恢复已扣回资产', 'aksrt'); return $entry; }, $entries);
$result = $this->post_asset_entries($row, $order, $compensate, 'after-sale-revoke-compensate', 'after_sale_revoke_compensate', $note);
return is_wp_error($result) ? $result : true;
}
private function assets_to_revoke(array $order, string $type, array $meta): array|\WP_Error {
$user_id = (int) ($order['user_id'] ?? 0);
if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) {
$card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : [];
try { $minor = AssetAmount::balance((string) ($meta['credited_amount'] ?? $meta['credit_amount'] ?? $card_meta['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_asset', __('充值到账金额缺失,不能退款', 'aksrt'), ['status' => 409]); }
return $this->debit_entry($user_id, AssetType::BALANCE, $minor, __('撤销充值余额', 'aksrt'));
}
if ($type === DomainModel::ORDER_PRODUCT_POINTS) {
$card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : [];
$points = max(0, (int) ($meta['points'] ?? $card_meta['points'] ?? 0));
if ($points <= 0) return new \WP_Error('aksrt_after_sale_asset', __('购买积分数量缺失,不能退款', 'aksrt'), ['status' => 409]);
$entries = $this->debit_entry($user_id, AssetType::POINTS, $points, __('撤销订单发放积分', 'aksrt'));
if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'balance') {
try { $paid = AssetAmount::balance((string) ($order['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_amount', __('积分订单支付金额无效', 'aksrt'), ['status' => 409]); }
$entries[] = ['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => $paid, 'entry_type' => 'credit', 'remark' => __('退回购买积分使用的余额', 'aksrt')];
}
return $entries;
}
return [];
}
private function debit_entry(int $user_id, AssetType $asset, int $minor, string $remark): array|\WP_Error {
if ($user_id <= 0 || $minor <= 0) return new \WP_Error('aksrt_after_sale_asset', __('待撤销资产金额无效', 'aksrt'), ['status' => 409]);
return [['user_id' => $user_id, 'asset' => $asset->value, 'delta_minor' => -$minor, 'entry_type' => 'debit', 'remark' => $remark]];
}
private function finish_refund(array $row, array $order, string $note, bool $manage_transaction = true) {
global $wpdb;
$number = (string) $order['order_number'];
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
if (is_wp_error($commission)) return $commission;
if ($manage_transaction && $wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]);
$guarded = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($guarded) || $guarded !== true) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($guarded) ? $guarded : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); }
$finished = $this->finish_order_and_application($row, $order, $note);
if (is_wp_error($finished) || !$finished) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款成功但本地订单更新失败,请重试同步', 'aksrt'), ['status' => 500]); }
$applied = CommissionLedger::get_instance()->apply_full_refund($commission);
if (is_wp_error($applied) || ($manage_transaction && $wpdb->query('COMMIT') === false)) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
if ($manage_transaction) {
OrderRepository::get_instance()->dispatch_managed_status_events();
CommissionLedger::get_instance()->clawback_after_refund($commission);
}
return true;
}
private function finish_order_and_application(array $row, array $order, string $note): bool|\WP_Error {
$number = (string) $order['order_number'];
if (!$this->revoke_vip_if_needed($order)) return new \WP_Error('aksrt_after_sale_vip', __('会员权益撤销失败', 'aksrt'), ['status' => 409]);
if (!OrderRepository::get_instance()->update_status($number, Order::STATUS_REFUNDED, ['allow_refund' => true, 'after_sale_id' => (int) $row['id'], 'transaction_managed' => true, 'note' => $note !== '' ? $note : __('售后退款完成', 'aksrt')])) return new \WP_Error('aksrt_after_sale_order', __('订单退款状态更新失败', 'aksrt'), ['status' => 409]);
return $this->update((int) $row['id'], ['status' => self::STATUS_COMPLETED, 'admin_note' => $note, 'completed_at' => current_time('mysql'), 'reviewed_at' => current_time('mysql')]);
}
private function vip_reversal_is_safe(array $order, array $meta): bool {
$before = is_array($meta['vip_before'] ?? null) ? $meta['vip_before'] : null;
$after = is_array($meta['vip_after'] ?? null) ? $meta['vip_after'] : null;
if (!$before || !$after || !class_exists('\\AKSRT\\Content\\VipService')) return false;
$current = \AKSRT\Content\VipService::get_instance()->get_membership_snapshot((int) $order['user_id']);
foreach (['level', 'expires', 'base_level', 'base_expires', 'expired_level'] as $key) {
if ((int) ($current[$key] ?? 0) !== (int) ($after[$key] ?? 0)) return false;
}
return true;
}
private function revoke_vip_if_needed(array $order): bool {
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
if ($type !== DomainModel::ORDER_PRODUCT_VIP) return true;
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
if (!$this->vip_reversal_is_safe($order, $meta)) return false;
$before = $meta['vip_before'];
$service = \AKSRT\Content\VipService::get_instance();
if ((int) ($before['level'] ?? 0) <= 0) {
$result = $service->mutate_membership((int) $order['user_id'], 'disable', [], ['reason' => 'order_refund', 'source' => 'after_sale']);
} elseif ((int) ($before['base_level'] ?? 0) === 0 && (int) ($before['base_expires'] ?? 0) === 0 && (int) ($before['expired_level'] ?? 0) === 0) {
$result = $service->mutate_membership((int) $order['user_id'], 'set', ['level' => (int) $before['level'], 'expires' => (int) $before['expires']], ['reason' => 'order_refund', 'source' => 'after_sale']);
} else {
return false;
}
return !is_wp_error($result);
}
private function refund_amount(array $order): ?string {
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
try { return AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
}
private function cash_minor(string $value): int { try { return AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; } }
/** @param array<int,array<string,mixed>> $entries */
private function asset_entries_available(array $entries): bool {
$required = [];
foreach ($entries as $entry) {
$delta = (int) ($entry['delta_minor'] ?? 0);
if ($delta < 0) {
$key = (int) ($entry['user_id'] ?? 0) . ':' . (string) ($entry['asset'] ?? '');
$required[$key] = ($required[$key] ?? 0) + abs($delta);
}
}
foreach ($required as $key => $amount) {
[$user_id, $asset] = explode(':', $key, 2);
$balance = (string) $asset === AssetType::POINTS->value
? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points((int) $user_id)
: \AKSRT\Credits\MetaAssetService::get_instance()->balance_minor((int) $user_id);
if ($balance < (int) $amount) return false;
}
return true;
}
/** @param array<int,array<string,mixed>> $entries */
private function find_active(string $number): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing','approved') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
private function get(int $id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $id), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
private function update(int $id, array $data): bool {
global $wpdb;
$formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($data));
return $wpdb->update($this->table(), $data, ['id' => $id], $formats, ['%d']) !== false;
}
private function table(): string { return \AKSRT\Core\DBMigrator::table('order_aftersale'); }
/** @param array<string,mixed> $row */
private function normalize(array $row): array { $row['id'] = (int) ($row['id'] ?? 0); $row['user_id'] = (int) ($row['user_id'] ?? 0); return $row; }
}
+5 -142
View File
@@ -10,7 +10,7 @@ defined('ABSPATH') || exit;
/**
* 站内余额支付网关(内置实现)。
*
* 仅扣统一资产账本中的现金余额。
* 余额由 user meta 管理,LedgerService 在统一入口路由到资产服务。
*/
class BalanceGateway implements GatewayInterface {
@@ -54,7 +54,6 @@ class BalanceGateway implements GatewayInterface {
} catch (\InvalidArgumentException $e) {
return ['success' => false, 'message' => __('支付金额无效', 'aksrt')];
}
$transaction_code = 'BAL_' . $order_number;
$guard = static function () use ($order_number, $user_id, $amount_minor) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('order');
@@ -88,10 +87,9 @@ class BalanceGateway implements GatewayInterface {
sprintf(__('订单 %s 支付', 'aksrt'), $order_number),
$user_id,
['order_number' => $order_number],
static function () use ($order_number, $transaction_code, $order) {
static function () use ($order_number, $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,
]);
@@ -110,9 +108,7 @@ class BalanceGateway implements GatewayInterface {
}
return [
'success' => true,
'transaction_id' => $transaction_code,
'ledger_transaction_id' => $result->transaction_id,
'flow_number' => $result->flow_number,
'transaction_id' => '',
'replayed' => $result->replayed,
'order_committed' => true,
'message' => __('余额支付成功', 'aksrt'),
@@ -140,141 +136,8 @@ class BalanceGateway implements GatewayInterface {
}
public function refund(string $order_number, string $amount, string $request_id = ''): array {
$order_repo = OrderRepository::get_instance();
$order = $order_repo->get($order_number);
if (!$order) {
return ['success' => false, 'message' => __('订单不存在', 'aksrt')];
}
$user_id = (int) $order['user_id'];
if (!$this->balance_enabled()) {
return ['success' => false, 'message' => __('余额功能未启用', 'aksrt')];
}
$request_id = sanitize_text_field($request_id);
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_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);
};
// 只退买家余额。佣金收回不再作为分录混入本事务:即时入账模型下佣金在
// 独立 COMMISSION 池,收回可能超出池余额(转为欠款),若作为分录会因池
// 不足而中止买家退款。故 apply_full_refund 仅登记佣金明细账的冲正,真正
// 的池收回 + 欠款挂账由 clawback_after_refund 在本事务提交后独立执行。
$entries = [[
'user_id' => $user_id,
'asset' => \AKSRT\Ledger\AssetType::BALANCE->value,
'delta_minor' => $amount_minor,
'entry_type' => 'refund',
'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)) {
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
}
// 退款事务已提交:从佣金池收回本单佣金,不足部分挂账为欠款(非关键路径)。
if (!$result->replayed) {
$commission->clawback_after_refund($commission_plan);
}
return [
'success' => true,
'refund_id' => 'BAL_REF_' . $order_number,
'transaction_id' => $result->transaction_id,
'ledger_transaction_id' => $result->transaction_id,
'flow_number' => $result->flow_number,
'replayed' => $result->replayed,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('退款成功', 'aksrt'),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
return ['success' => false, 'status' => 'failed', 'message' => __('余额订单售后请通过用户中心申请并由管理员审核', 'aksrt')];
}
private function balance_enabled(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) {
+12 -44
View File
@@ -403,7 +403,6 @@ class ChargeCard {
if (is_wp_error($order_number)) {
return $order_number;
}
$transaction_number = $this->card_pass_transaction_number();
$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);
@@ -424,9 +423,9 @@ class ChargeCard {
}
return true;
};
$mutation = function () use ($user_id, $type, $meta, $card_id, $order_number, $operation_key, $transaction_number) {
$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, $transaction_number);
$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]);
}
@@ -464,21 +463,12 @@ class ChargeCard {
if (is_array($existing_order) && (string) ($existing_order['order_number'] ?? '') !== '') {
$order_number = (string) $existing_order['order_number'];
}
} else {
// 账本命令必须保持确定性(payload_hash 含 metadata,塞入随机订单号会破坏幂等重放),
// 因此在账本提交之后再把流水行挂到订单上。
\AKSRT\Finance\FinancialFlowService::get_instance()->attach_order_to_ledger_flow(
(int) $result->transaction_id,
$order_number,
$transaction_number
);
}
$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())
@@ -516,8 +506,6 @@ class ChargeCard {
if (is_wp_error($order_number)) {
return $order_number;
}
$transaction_number = $this->card_pass_transaction_number();
for ($attempt = 1; $attempt <= 3; $attempt++) {
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_card_db', __('无法开启卡密兑换事务', 'aksrt'), ['status' => 500]);
@@ -566,6 +554,8 @@ class ChargeCard {
$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());
$vip_service = \AKSRT\Content\VipService::get_instance();
$vip_before = $vip_service->get_membership_snapshot($user_id);
$created = $this->create_redeem_order(
$user_id,
self::TYPE_VIP,
@@ -574,29 +564,13 @@ class ChargeCard {
(string) ($current['password'] ?? ''),
$operation_key,
$order_number,
Order::STATUS_PAID,
$transaction_number
Order::STATUS_PAID
);
if ($created !== $order_number) {
throw new \RuntimeException('vip_card_order_create_failed');
}
// 会员卡没有账本参与(入账的是 usermeta 而非账本资产),流水要单独补一条,
// 且它不产生任何现金/资产分录(0 元凭证)。同一事务内写入,失败即整体回滚。
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_card_pass_payment([
'order_number' => $order_number,
'user_id' => $user_id,
'amount' => '0.00',
'currency' => 'CNY',
'status' => Order::STATUS_PAID,
'payment_method' => 'card_pass',
'payment_transaction_id' => $transaction_number,
], true);
if (is_wp_error($flow_number)) {
throw new \RuntimeException('vip_card_flow_failed');
}
\AKSRT\Content\VipService::get_instance()->activate_vip($user_id, $level, $days);
$vip_service->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;
@@ -606,6 +580,10 @@ class ChargeCard {
if ($actual_level < max($current_level, $level) || !$expiry_valid) {
throw new \RuntimeException('vip_card_fulfillment_failed');
}
$vip_after = $vip_service->get_membership_snapshot($user_id);
if (!OrderRepository::get_instance()->merge_meta($order_number, ['vip_before' => $vip_before, 'vip_after' => $vip_after])) {
throw new \RuntimeException('vip_card_snapshot_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",
@@ -639,7 +617,6 @@ class ChargeCard {
'success' => true,
'type' => self::TYPE_VIP,
'order_number' => $order_number,
'transaction_id' => 0,
'replayed' => false,
'message' => $message,
'level' => $level,
@@ -699,7 +676,6 @@ class ChargeCard {
'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,
@@ -742,18 +718,10 @@ class ChargeCard {
return $order_number;
}
/**
* 卡密核销订单的渠道交易号:与资金流水号同源,统一走流水号分配器,
* 不再消费任何后台前缀配置。
*/
private function card_pass_transaction_number(): string {
return \AKSRT\Finance\BusinessNumber::allocate();
}
/**
* @param array<string,mixed> $meta
*/
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 $transaction_number = ''): 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 '';
}
@@ -791,7 +759,7 @@ class ChargeCard {
'currency' => $currency,
'status' => $order_status,
'payment_method' => 'card_pass',
'payment_transaction_id' => $order_status === Order::STATUS_PAID ? $transaction_number : '',
'payment_transaction_id' => '',
'paid_at' => $order_status === Order::STATUS_PAID ? current_time('mysql') : null,
'note' => is_string($note) ? $note : '',
'request_key' => $request_key,
+9 -1
View File
@@ -10,6 +10,7 @@ final class CommissionLedger {
private function __construct() {}
public function init(): void {
add_action('rest_api_init', [$this, 'register_routes']);
add_action('aksrt_order_commission', [$this, 'record_order_transition']);
// 佣金已改为即时入账,历史遗留的结算 cron 需主动清理(无兼容层)。
$timestamp = wp_next_scheduled('aksrt_affiliate_settle_due');
if ($timestamp) {
@@ -45,7 +46,14 @@ final class CommissionLedger {
if ($currency === \AKSRT\Ledger\AssetType::POINTS->value) return 0;
if (!\AKSRT\Settings\Settings::affiliate_enabled()) return 0;
$type = sanitize_key((string) ($order['product_type'] ?? ''));
$map = ['vip' => 'membership', 'resource' => 'content'];
$map = [
'vip' => 'membership',
'resource' => 'content',
'reading' => 'content',
'download' => 'content',
'image' => 'content',
'video' => 'content',
];
$order_type = $map[$type] ?? $type;
$settings = \AKSRT\Settings\Settings::get_affiliate_settings();
$order_no = sanitize_text_field((string) ($order['order_number'] ?? ''));
+1 -1
View File
@@ -250,7 +250,7 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
public function query_refund(string $request_id, string $order_number = ''): array {
if (!$this->is_configured() || $request_id === '') {
return ['success' => false, 'status' => 'failed', 'message' => __('支付宝退款查询参数无效', 'aksrt')];
return ['success' => false, 'status' => 'unknown', 'message' => __('支付宝退款查询不可用,结果待确认', 'aksrt')];
}
try {
+1 -1
View File
@@ -225,7 +225,7 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
public function refund(string $order_number, string $amount, string $request_id = ''): array {
return [
'success' => false,
'status' => 'failed',
'status' => 'manual',
'message' => __('易支付退款请在商户后台人工处理', 'aksrt'),
];
}
+1
View File
@@ -148,6 +148,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
public function refund(string $order_number, string $amount, string $request_id = ''): array {
return [
'success' => false,
'status' => 'manual',
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
];
}
+1 -1
View File
@@ -275,7 +275,7 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
public function query_refund(string $request_id, string $order_number = ''): array {
if (!$this->is_configured() || $request_id === '') {
return ['success' => false, 'status' => 'failed', 'message' => __('Stripe 退款查询参数无效', 'aksrt')];
return ['success' => false, 'status' => 'unknown', 'message' => __('Stripe 退款查询不可用,结果待确认', 'aksrt')];
}
try {
+1 -1
View File
@@ -344,7 +344,7 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
public function query_refund(string $request_id, string $order_number = ''): array {
if (!$this->is_configured() || $request_id === '') {
return ['success' => false, 'status' => 'failed', 'message' => __('微信退款查询参数无效', 'aksrt')];
return ['success' => false, 'status' => 'unknown', 'message' => __('微信退款查询不可用,结果待确认', 'aksrt')];
}
try {
-1
View File
@@ -11,7 +11,6 @@ class Order {
public const STATUS_PENDING = \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING;
public const STATUS_PAID = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
public const STATUS_REFUNDING = \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING;
public const STATUS_REFUNDED = \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
public const STATUS_CLOSED = \AKSRT\Core\DomainModel::ORDER_STATUS_CLOSED;
+7 -3
View File
@@ -16,7 +16,6 @@ final class OrderCenterQuery {
public const STATUS_ALL = 'all';
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_REFUNDING = 'refunding';
public const STATUS_REFUNDED = 'refunded';
public const STATUS_CLOSED = 'closed';
@@ -24,7 +23,6 @@ final class OrderCenterQuery {
self::STATUS_ALL,
self::STATUS_PENDING,
self::STATUS_PAID,
self::STATUS_REFUNDING,
self::STATUS_REFUNDED,
self::STATUS_CLOSED,
];
@@ -44,6 +42,9 @@ final class OrderCenterQuery {
*/
public static function from_array(array $raw, int $default_per_page = 20): self {
$status = sanitize_key((string) ($raw['status'] ?? self::STATUS_ALL));
if ($status === 'refunding') {
$status = self::STATUS_PAID;
}
if (!in_array($status, self::STATUS_GROUPS, true)) {
$status = self::STATUS_ALL;
}
@@ -92,7 +93,10 @@ final class OrderCenterQuery {
/** @return string[] */
public function database_statuses(): array {
if ($this->status() === self::STATUS_ALL) {
return array_keys(DomainModel::order_status_labels());
return array_merge(array_keys(DomainModel::order_status_labels()), ['refunding']);
}
if ($this->status() === self::STATUS_PAID) {
return [self::STATUS_PAID, 'refunding'];
}
return [$this->status()];
}
+115 -33
View File
@@ -193,8 +193,31 @@ trait OrderRepositoryAdmin {
return ['items' => [], 'total' => 0];
}
$where = ['product_type = %s', 'product_id > 0'];
$params = [$product_type];
$content_types = [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO,
];
$legacy_pay_types = [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
];
$where = ['product_id > 0'];
$params = [];
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($content_types), '%s')) . ')';
array_push($params, ...$content_types);
} elseif (isset($legacy_pay_types[$product_type])) {
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
} else {
$where[] = 'product_type = %s';
$params[] = $product_type;
}
$status_placeholders = implode(', ', array_fill(0, count($status_in), '%s'));
$where[] = "status IN ({$status_placeholders})";
@@ -236,14 +259,14 @@ trait OrderRepositoryAdmin {
$offset = max(0, (int) ($args['offset'] ?? 0));
$list_sql = "SELECT product_id,
product_type,
MIN(product_type) AS 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, currency
GROUP BY product_id, currency
ORDER BY {$orderby_col} {$order_dir}
LIMIT %d OFFSET %d";
$list_params = $params;
@@ -252,6 +275,7 @@ trait OrderRepositoryAdmin {
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
$items = [];
$aggregate = [];
foreach ((array) $rows as $row) {
$sales_count = max(1, (int) ($row['sales_count'] ?? 0));
$is_points = (string) ($row['currency'] ?? '') === 'points';
@@ -269,12 +293,37 @@ trait OrderRepositoryAdmin {
intdiv($revenue_minor + intdiv($sales_count, 2), $sales_count)
);
}
$items[] = [
$pid = (int) ($row['product_id'] ?? 0);
if (!isset($aggregate[$pid])) {
$aggregate[$pid] = [
'product_id' => (int) ($row['product_id'] ?? 0),
'product_type' => (string) ($row['product_type'] ?? $product_type),
'sales_count' => (int) ($row['sales_count'] ?? 0),
'avg_amount' => $avg_amount,
'revenue' => $revenue,
'product_type' => $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE ? $product_type : (string) ($row['product_type'] ?? $product_type),
'sales_count' => 0,
'avg_amount' => 0,
'revenue_minor' => 0,
'currency' => (string) ($row['currency'] ?? 'CNY'),
];
}
$aggregate[$pid]['sales_count'] += (int) ($row['sales_count'] ?? 0);
if ((string) ($row['currency'] ?? '') === 'points') {
$aggregate[$pid]['revenue_minor'] += (int) $revenue;
} else {
$aggregate[$pid]['revenue_minor'] += \AKSRT\Ledger\AssetAmount::balance_minor((string) $revenue);
}
}
foreach ($aggregate as $item) {
$count = max(1, (int) $item['sales_count']);
$is_points = $item['currency'] === 'points';
$revenue = $is_points
? (int) $item['revenue_minor']
: \AKSRT\Ledger\AssetAmount::decimal_from_minor((int) $item['revenue_minor']);
$average_minor = intdiv((int) $item['revenue_minor'] + intdiv($count, 2), $count);
$items[] = [
'product_id' => (int) $item['product_id'],
'product_type' => (string) $item['product_type'],
'sales_count' => (int) $item['sales_count'],
'avg_amount' => $is_points ? $average_minor : \AKSRT\Ledger\AssetAmount::decimal_from_minor($average_minor),
'revenue' => $revenue,
];
}
@@ -344,17 +393,39 @@ trait OrderRepositoryAdmin {
$status = sanitize_key((string) ($args['status'] ?? ''));
$valid_statuses = array_keys(\AKSRT\Core\DomainModel::order_status_labels());
if ($status !== '' && $status !== 'all' && in_array($status, $valid_statuses, true)) {
$where[] = 'status = %s';
$params[] = $status;
if ($status === 'paid') {
$where[] = "status IN (%s, 'refunding')";
$params[] = $status;
} else {
$where[] = 'status = %s';
$params[] = $status;
}
} else {
$where[] = 'status IN (' . implode(', ', array_fill(0, count($valid_statuses), '%s')) . ')';
array_push($params, ...$valid_statuses);
$where[] = "status IN (" . implode(', ', array_fill(0, count($valid_statuses), '%s')) . ", 'refunding')";
array_push($params, ...$valid_statuses);
}
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
if ($product_type !== '') {
$where[] = 'product_type = %s';
$params[] = $product_type;
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')';
array_push($params, ...$types);
} else {
$legacy_pay_types = [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
];
if (isset($legacy_pay_types[$product_type])) {
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
} else {
$where[] = 'product_type = %s';
$params[] = $product_type;
}
}
}
$product_id = (int) ($args['product_id'] ?? 0);
@@ -383,24 +454,35 @@ trait OrderRepositoryAdmin {
}
if ($search !== '') {
$flow_table = \AKSRT\Core\DBMigrator::table('financial_flow');
$flow_match = "EXISTS (SELECT 1 FROM {$flow_table} ff WHERE ff.order_number = " . $this->table() . '.order_number AND ff.flow_number LIKE %s)';
// Numeric search may mean user_id or product_id; also match order or flow number / type.
if (ctype_digit($search)) {
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d OR {$flow_match})";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = (int) $search;
$params[] = (int) $search;
$params[] = $like;
} else {
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s OR {$flow_match})";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
$params[] = $like;
$user_table = $wpdb->users;
$user_match = "EXISTS (SELECT 1 FROM {$user_table} u WHERE u.ID = " . $this->table() . '.user_id AND (u.user_login LIKE %s OR u.user_email LIKE %s OR u.display_name LIKE %s))';
$post_table = $wpdb->posts;
$product_match = "EXISTS (SELECT 1 FROM {$post_table} p WHERE p.ID = " . $this->table() . '.product_id AND p.post_title LIKE %s)';
$meta_match = "note LIKE %s";
// Search matches the public order number, account, product title, and stored product label.
if (ctype_digit($search)) {
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d OR {$user_match} OR {$product_match} OR {$meta_match})";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = (int) $search;
$params[] = (int) $search;
array_push($params, $like, $like, $like, $like, $like);
} else {
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s OR {$user_match} OR {$product_match} OR {$meta_match})";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
array_push($params, $like, $like, $like, $like, $like);
}
}
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
+139 -53
View File
@@ -243,7 +243,7 @@ trait OrderRepositoryCore {
* their existing user-wide semantics, so changing a filter does not change the
* summary cards. The subquery avoids four independent COUNT statements.
*
* @return array{total:int,stats:array{total:int,pending:int,paid:int}}
* @return array{total:int,stats:array<string,int>}
*/
public function get_user_order_center_summary(int $user_id, array $args = []): array {
global $wpdb;
@@ -251,7 +251,7 @@ trait OrderRepositoryCore {
if ($user_id <= 0) {
return [
'total' => 0,
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0],
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0],
];
}
@@ -264,12 +264,15 @@ trait OrderRepositoryCore {
(SELECT COUNT(*) FROM {$this->table()} {$parts['where']}) AS filtered_total,
COUNT(*) AS known_total,
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS paid
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) AS paid_legacy,
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS refunded,
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS closed
FROM {$this->table()}
WHERE user_id = %d AND status IN ({$known_placeholders})";
$params = array_merge(
$parts['params'],
[Order::STATUS_PENDING, Order::STATUS_PAID],
[Order::STATUS_PENDING, Order::STATUS_PAID, Order::STATUS_REFUNDED, Order::STATUS_CLOSED],
[$user_id],
$known_statuses
);
@@ -280,7 +283,10 @@ trait OrderRepositoryCore {
'stats' => [
'total' => (int) ($row['known_total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0) + (int) ($row['paid_legacy'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'closed' => (int) ($row['closed'] ?? 0),
'after_sale_pending' => class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->count_user_open($user_id) : 0,
],
];
}
@@ -340,8 +346,16 @@ trait OrderRepositoryCore {
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
global $wpdb;
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED && empty($extra['allow_refund'])) {
return false;
}
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
&& (!class_exists(AfterSaleService::class) || !AfterSaleService::get_instance()->can_finalize_order_refund((int) ($extra['after_sale_id'] ?? 0), $order_number))
) {
return false;
}
$preflight = $this->get($order_number);
if (!$preflight) {
@@ -349,7 +363,7 @@ trait OrderRepositoryCore {
}
if (
$new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID
&& (string) ($preflight['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING
&& (string) ($preflight['status'] ?? '') !== 'refunding'
&& sanitize_key((string) ($preflight['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
&& class_exists('\AKSRT\Credits\BalanceService')
) {
@@ -367,7 +381,8 @@ trait OrderRepositoryCore {
if ($root_call) {
$this->status_after_commit_events = [];
}
$owns_transaction = $root_call && !$ledger_active;
$transaction_managed = !empty($extra['transaction_managed']);
$owns_transaction = $root_call && !$ledger_active && !$transaction_managed;
if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
return false;
}
@@ -398,6 +413,8 @@ trait OrderRepositoryCore {
\AKSRT\Ledger\LedgerService::get_instance()->after_commit(function () use ($events): void {
$this->dispatch_status_events($events);
});
} elseif ($transaction_managed) {
$this->status_after_commit_events = $events;
}
}
return true;
@@ -425,14 +442,18 @@ trait OrderRepositoryCore {
$valid = \AKSRT\Core\DomainModel::order_status_labels();
if (!isset($valid[$new_status])) return false;
$order_row = $wpdb->get_row($wpdb->prepare(
$order_row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
$order_number
), ARRAY_A);
$database_status = is_array($order_row) ? sanitize_key((string) ($order_row['status'] ?? '')) : '';
$order = is_array($order_row) ? $this->normalize_row($order_row) : null;
if (!$order) return false;
$current_status = (string) ($order['status'] ?? '');
$current_status = (string) ($order['status'] ?? '');
if ($database_status === 'refunding' && $new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) {
$current_status = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
}
if (!\AKSRT\Core\DomainModel::can_transition_order_status($current_status, $new_status)) {
return false;
@@ -457,12 +478,7 @@ trait OrderRepositoryCore {
}
}
if (
in_array($new_status, [
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING,
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED,
], true)
) {
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) {
$existing_paid = is_string($order['paid_at'] ?? null) ? trim((string) $order['paid_at']) : '';
if ($existing_paid === '' || str_starts_with($existing_paid, '0000-00-00')) {
if (!isset($update_data['paid_at'])) {
@@ -502,7 +518,7 @@ trait OrderRepositoryCore {
$result = $wpdb->update(
$this->table(),
$update_data,
['order_number' => $order_number, 'status' => $current_status],
['order_number' => $order_number, 'status' => $database_status],
$format,
['%s', '%s']
);
@@ -550,26 +566,13 @@ trait OrderRepositoryCore {
}
}
if (!$ledger_active) {
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_order_payment($paid_order, true);
if (is_wp_error($flow_number)) {
return false;
}
}
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;
}
}
$this->status_after_commit_events[] = [
'hook' => 'aksrt_order_commission',
'args' => [$order_number, $new_status, $paid_order],
];
$product_type = sanitize_key((string) ($paid_order['product_type'] ?? ''));
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE && class_exists('\AKSRT\Content\Pay')) {
if (\AKSRT\Core\DomainModel::is_resource_product_type($product_type) && 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;
@@ -594,16 +597,6 @@ trait OrderRepositoryCore {
];
}
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
&& $current_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING
&& !$ledger_active
) {
$refunded_order = array_merge($order, $update_data, ['status' => $new_status]);
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_order_refund($refunded_order, true);
if (is_wp_error($flow_number)) {
return false;
}
}
}
return $result !== false;
@@ -681,11 +674,10 @@ trait OrderRepositoryCore {
$sql = "SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) as refunding,
SUM(CASE WHEN status IN ('paid','refunding') THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded,
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as revenue
SUM(CASE WHEN status IN ('paid','refunding') THEN amount ELSE 0 END) as revenue
FROM {$this->table()} {$where}";
if (!empty($params)) {
@@ -698,13 +690,66 @@ trait OrderRepositoryCore {
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0),
'refunding' => (int) ($row['refunding'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'closed' => (int) ($row['closed'] ?? 0),
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
];
}
/** @return array<string,mixed> */
public function get_admin_order_stats(array $args = []): array {
global $wpdb;
$this->expire_pending_orders();
$where = [];
$params = [];
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT COUNT(*) AS total,
SUM(status = 'pending') AS pending,
SUM(status IN ('paid','refunding')) AS paid,
SUM(status = 'refunded') AS refunded,
SUM(status = 'closed') AS closed,
SUM(CASE WHEN status IN ('paid','refunding') AND currency <> 'points' THEN amount ELSE 0 END) AS revenue
FROM {$this->table()} {$where_sql}";
$row = $params ? $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_row($sql, ARRAY_A);
$type_sql = "SELECT product_type, note, COUNT(*) AS total FROM {$this->table()} {$where_sql} GROUP BY product_type, note";
$type_rows = $params ? $wpdb->get_results($wpdb->prepare($type_sql, $params), ARRAY_A) : $wpdb->get_results($type_sql, ARRAY_A);
$by_type = array_fill_keys(array_keys(\AKSRT\Core\DomainModel::order_product_type_labels()), 0);
foreach ((array) $type_rows as $type_row) {
$meta = json_decode((string) ($type_row['note'] ?? ''), true);
$type = \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($type_row['product_type'] ?? ''), is_array($meta) ? $meta : []);
if (isset($by_type[$type])) {
$by_type[$type] += (int) ($type_row['total'] ?? 0);
}
}
return [
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'closed' => (int) ($row['closed'] ?? 0),
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
'by_type' => $by_type,
'after_sale_pending' => class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->count([
'status' => AfterSaleService::STATUS_PENDING,
'date_from' => $args['date_from'] ?? '',
'date_to' => $args['date_to'] ?? '',
]) : 0,
];
}
public function dispatch_managed_status_events(): void {
$events = $this->status_after_commit_events;
$this->status_after_commit_events = [];
$this->dispatch_status_events($events);
}
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
@@ -712,7 +757,10 @@ trait OrderRepositoryCore {
return false;
}
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
if (\AKSRT\Core\DomainModel::is_resource_product_type($product_type)) {
return null !== $this->find_latest_success_content_order($user_id, $product_id);
}
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
}
@@ -756,8 +804,25 @@ trait OrderRepositoryCore {
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
if ($product_type !== '') {
$where[] = 'product_type = %s';
$params[] = $product_type;
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')';
array_push($params, ...$types);
} else {
$legacy_pay_types = [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
];
if (isset($legacy_pay_types[$product_type])) {
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
} else {
$where[] = 'product_type = %s';
$params[] = $product_type;
}
}
}
$order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? '');
@@ -780,7 +845,10 @@ trait OrderRepositoryCore {
if ($row['amount'] === null) {
$row['amount'] = (string) ($row['currency'] ?? '') === 'points' ? 0 : '0.00';
}
$row['status'] = sanitize_key((string) ($row['status'] ?? ''));
$row['status'] = sanitize_key((string) ($row['status'] ?? ''));
if ($row['status'] === 'refunding') {
$row['status'] = Order::STATUS_PAID;
}
$row['meta_payload'] = [];
if (!empty($row['note']) && is_string($row['note'])) {
@@ -794,6 +862,10 @@ trait OrderRepositoryCore {
$row['note_text'] = $row['note'];
}
}
$row['product_type'] = \AKSRT\Core\DomainModel::resolve_order_product_type(
sanitize_key((string) ($row['product_type'] ?? '')),
$row['meta_payload']
);
return $row;
}
@@ -860,6 +932,20 @@ trait OrderRepositoryCore {
return is_array($row) ? $this->normalize_row($row) : null;
}
private function find_latest_success_content_order(int $user_id, int $product_id): ?array {
global $wpdb;
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
$placeholders = implode(', ', array_fill(0, count($types), '%s'));
$statuses = \AKSRT\Core\DomainModel::paid_order_statuses();
$status_placeholders = implode(', ', array_fill(0, count($statuses), '%s'));
$params = array_merge([$user_id, $product_id], $types, $statuses);
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE user_id = %d AND product_id = %d AND product_type IN ({$placeholders}) AND status IN ({$status_placeholders}) ORDER BY COALESCE(paid_at, created_at) DESC, id DESC LIMIT 1",
$params
), ARRAY_A);
return is_array($row) ? $this->normalize_row($row) : null;
}
/**
-2
View File
@@ -130,8 +130,6 @@ class PaymentOrchestrator {
'success' => true,
'redirect' => false,
'transaction_id' => $transaction_id,
'ledger_transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
'flow_number' => (string) ($result['flow_number'] ?? ''),
'replayed' => !empty($result['replayed']),
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
];
+1 -1
View File
@@ -39,7 +39,7 @@ class Revenue {
return 0;
}
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
if ($product_type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
if (!\AKSRT\Core\DomainModel::is_resource_product_type($product_type)) {
return 0;
}
+7 -11
View File
@@ -171,7 +171,7 @@ class UserOrders {
*/
public function decorate_order_view(array $order, bool $detail = true, ?array $reconciliation = null): array {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
$product_type = DomainModel::resolve_order_product_type(sanitize_key((string) ($order['product_type'] ?? '')), $meta);
$product_id = (int) ($order['product_id'] ?? 0);
$status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING));
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
@@ -195,6 +195,8 @@ class UserOrders {
$can_pay = $group === OrderCenterQuery::STATUS_PENDING;
$can_cancel = $group === OrderCenterQuery::STATUS_PENDING;
$order_number = (string) ($order['order_number'] ?? '');
$after_sales = $detail && class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->list_for_order($order_number) : [];
$has_open_after_sale = (bool) array_filter($after_sales, static fn(array $item): bool => in_array((string) ($item['status'] ?? ''), ['pending', 'processing', 'approved'], true));
$updated_at = $this->resolve_updated_at($order);
$view = [
@@ -222,6 +224,7 @@ class UserOrders {
'allowed_actions' => [
'can_pay' => $can_pay,
'can_cancel' => $can_cancel,
'can_after_sale' => $group === OrderCenterQuery::STATUS_PAID && !$has_open_after_sale,
],
'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status),
'updated_at' => $updated_at,
@@ -237,6 +240,7 @@ class UserOrders {
$view['paid_at'] = (string) ($order['paid_at'] ?? '');
$view['expires_at'] = (string) ($order['expires_at'] ?? '');
$view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false];
$view['after_sales'] = $after_sales;
}
return $view;
}
@@ -249,7 +253,7 @@ class UserOrders {
'total_pages' => 1,
'page' => 1,
'query' => $query->url_values(),
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0],
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0],
'invalidation_tags' => ['orders'],
'product_types' => $this->product_type_filter_options(),
];
@@ -431,21 +435,15 @@ class UserOrders {
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' => [
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'],
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
'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')];
@@ -476,8 +474,6 @@ class UserOrders {
'payment_url' => (string) ($result['payment_url'] ?? ''),
'order_number' => $order_number,
'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null,
'transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
'flow_number' => (string) ($result['flow_number'] ?? ''),
'replayed' => !empty($result['replayed']),
'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null,
'message' => (string) ($result['message'] ?? ''),
+62 -7
View File
@@ -133,6 +133,46 @@ class Withdrawal {
if ($net_minor < 1) {
return new \WP_Error('aksrt_withdraw_net', __('扣除手续费后到账金额必须至少为 0.01 元', 'aksrt'), ['status' => 400]);
}
$request_hash = hash('sha256', (string) wp_json_encode([
'user_id' => $user_id,
'gross_minor' => $gross_minor,
'net_minor' => $net_minor,
'fee_rate' => $fee_rate,
'method' => sanitize_key($method),
'account' => sanitize_text_field($account),
'source' => $source,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$existing_request = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE operation_key = %s LIMIT 1",
$operation_key
), ARRAY_A);
if (is_array($existing_request)) {
$existing_meta = $this->parse_account_meta((string) ($existing_request['account_info'] ?? ''));
if ((int) ($existing_request['user_id'] ?? 0) !== $user_id
|| !hash_equals((string) ($existing_meta['request_hash'] ?? ''), $request_hash)) {
return new \WP_Error('aksrt_withdraw_idempotency_conflict', __('相同提现操作标识已用于其他请求', 'aksrt'), ['status' => 409]);
}
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, false, $source);
return [
'withdrawal_id' => (int) $existing_request['id'],
'withdrawal_number' => (string) ($existing_request['withdrawal_number'] ?? ''),
'net_amount' => $this->database_balance_decimal((string) ($existing_request['amount'] ?? '0.00')),
'source' => $source,
'wallet_balance' => $this->source_balance_decimal($user_id, $source),
'transaction_id' => max(0, (int) ($existing_request['ledger_transaction_id'] ?? 0)),
'flow_number' => $source === self::SOURCE_COMMISSION
? \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_operation($operation_key)
: '',
'replayed' => true,
'usage' => [
'count' => $usage['count'],
'total' => $this->balance_decimal($usage['total_minor']),
'remaining_count' => $usage['remaining_count'],
'remaining_amount' => $this->balance_decimal($usage['remaining_minor']),
],
];
}
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
$usage = null;
$withdrawal_id = 0;
@@ -163,7 +203,7 @@ class Withdrawal {
}
return true;
};
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, $source, &$withdrawal_id, &$withdrawal_number) {
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, $request_hash, $source, &$withdrawal_id, &$withdrawal_number) {
global $wpdb;
$payload = wp_json_encode([
'account' => $account,
@@ -171,11 +211,12 @@ class Withdrawal {
'fee_rate' => $fee_rate,
'source' => $source,
'operation_key' => $operation_key,
'request_hash' => $request_hash,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
$ok = false;
for ($attempt = 1; $attempt <= 5; $attempt++) {
$withdrawal_number = \AKSRT\Finance\BusinessNumber::allocate();
$ok = $wpdb->insert($this->table(), [
$insert_data = [
'withdrawal_number' => $withdrawal_number,
'user_id' => $user_id,
'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
@@ -183,9 +224,15 @@ class Withdrawal {
'method' => sanitize_key($method),
'account_info' => $payload,
'status' => self::STATUS_PENDING,
'ledger_transaction_id' => $transaction_id,
'operation_key' => $operation_key,
'created_at' => current_time('mysql'),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
];
$insert_formats = ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
if ($transaction_id > 0) {
$insert_data['ledger_transaction_id'] = $transaction_id;
$insert_formats[] = '%d';
}
$ok = $wpdb->insert($this->table(), $insert_data, $insert_formats);
if ($ok === 1) {
break;
}
@@ -214,7 +261,9 @@ class Withdrawal {
}
if ($withdrawal_id <= 0) {
$replayed_row = $wpdb->get_row($wpdb->prepare(
"SELECT id, withdrawal_number FROM {$this->table()} WHERE ledger_transaction_id = %d",
"SELECT id, withdrawal_number FROM {$this->table()} WHERE operation_key = %s OR (%d > 0 AND ledger_transaction_id = %d) ORDER BY id DESC LIMIT 1",
$operation_key,
$result->transaction_id,
$result->transaction_id
), ARRAY_A);
$withdrawal_id = (int) ($replayed_row['id'] ?? 0);
@@ -329,7 +378,7 @@ class Withdrawal {
return $preflight_replay;
}
if (is_array($preflight_replay)) {
$preflight_replay['wallet_balance'] = \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal((int) ($row['user_id'] ?? 0));
$preflight_replay['wallet_balance'] = $this->source_balance_decimal((int) ($row['user_id'] ?? 0), $this->normalize_source((string) ($meta['source'] ?? self::SOURCE_BALANCE)));
$preflight_replay['withdrawal_number'] = (string) ($row['withdrawal_number'] ?? '');
$preflight_replay['flow_number'] = \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_operation($operation_key);
return $preflight_replay;
@@ -497,10 +546,16 @@ class Withdrawal {
'flow_number' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->flow_number : $completed_flow_number,
'withdrawal_number' => (string) ($row['withdrawal_number'] ?? ''),
'replayed' => $replayed,
'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'wallet_balance' => $this->source_balance_decimal($user_id, $refund_source),
];
}
private function source_balance_decimal(int $user_id, string $source): string {
$asset = $this->source_asset($source);
$minor = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, $asset);
return \AKSRT\Ledger\AssetAmount::from_minor($asset, $minor)->decimal();
}
/**
* Whether user has open (pending/processing) withdrawals.
*/
+2 -2
View File
@@ -448,8 +448,8 @@ class PaymentRuntime {
$box_position = 'top';
}
$status_keys = ['pending', 'paid', 'refunding', 'refunded', 'closed'];
$status_defaults = ['待支付', '已支付', '退款中', '已退款', '已关闭'];
$status_keys = ['pending', 'paid', 'refunded', 'closed'];
$status_defaults = ['待支付', '已支付', '已退款', '已关闭'];
$status_texts = [];
foreach ($status_keys as $i => $key) {
$val = trim((string) ($s["payment_order_status_{$key}"] ?? ''));
+12 -4
View File
@@ -609,17 +609,25 @@ class Badges {
if ($type === '' || $type === 'manually_add') {
continue;
}
if (in_array($type, ['creator_income', 'affiliate_commission'], true)) {
if ($type === 'creator_income') {
try {
$need_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($item['get_val'] ?? '0'))->minor();
} catch (\InvalidArgumentException $e) {
continue;
}
$revenue_stats = \AKSRT\Payment\Revenue::get_instance()->get_stats($user_id);
$have_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($revenue_stats['cash']['settled'] ?? '0'))->minor();
$qualified = $have_minor >= $need_minor;
} elseif ($type === 'affiliate_commission') {
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
\AKSRT\Ledger\AssetType::COMMISSION,
$type
);
$qualified = $have_minor >= $need_minor;
} else {
+3 -3
View File
@@ -68,10 +68,10 @@ class BalanceAdmin {
return;
}
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);
$meta_table = $wpdb->usermeta;
$query->query_from .= $wpdb->prepare(" LEFT JOIN {$meta_table} aksrt_cash_meta ON aksrt_cash_meta.user_id = {$wpdb->users}.ID AND aksrt_cash_meta.meta_key = %s", 'balance');
$order = strtoupper((string) ($query->query_vars['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$query->query_orderby = "ORDER BY COALESCE(aksrt_cash_account.balance_minor, 0) {$order}";
$query->query_orderby = "ORDER BY CAST(COALESCE(aksrt_cash_meta.meta_value, '0') AS DECIMAL(20,2)) {$order}";
}
public function render_section(\WP_User $user): void {
+3 -3
View File
@@ -107,10 +107,10 @@ class CreditsAdmin {
return;
}
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);
$meta_table = $wpdb->usermeta;
$query->query_from .= $wpdb->prepare(" LEFT JOIN {$meta_table} aksrt_points_meta ON aksrt_points_meta.user_id = {$wpdb->users}.ID AND aksrt_points_meta.meta_key = %s", 'points');
$order = strtoupper((string) ($query->query_vars['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$query->query_orderby = "ORDER BY COALESCE(aksrt_points_account.balance_minor, 0) {$order}";
$query->query_orderby = "ORDER BY CAST(COALESCE(aksrt_points_meta.meta_value, '0') AS SIGNED) {$order}";
}
// ========== 用户列表:行操作 ==========