feat(payment): rebuild order details and asset records
This commit is contained in:
1 parent
476e2f961d
commit
7b55cfb190
57 files changed
+2080
-1294
No files matched your search
@@ -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>' : '—') . '</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' => '«',
|
||||
'next_text' => '»',
|
||||
'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 '—';
|
||||
}
|
||||
$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) : '—';
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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' => '已关闭'],
|
||||
]],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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'] ?? ''));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')),
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] ?? ''),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
|
||||
// ========== 用户列表:行操作 ==========
|
||||
|
||||
Reference in new issue
Block a user