新增全局流水号
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
!/tests/widget-system/user-badges-contract.test.cjs
|
||||
!/tests/widget-system/user-features-contract.test.cjs
|
||||
!/tests/widget-system/asset-ledger-contract.test.cjs
|
||||
!/tests/widget-system/financial-flow-contract.test.cjs
|
||||
!/tests/asset-ledger/
|
||||
!/tests/asset-ledger/*.php
|
||||
!/tests/visual/docker-compose.zibll.yml
|
||||
|
||||
@@ -5,6 +5,8 @@ export const orderSchema = {
|
||||
description: '配置订单前缀、有效期、展示字段和状态文案。',
|
||||
defaults: {
|
||||
payment_order_prefix: 'AKSRT',
|
||||
payment_flow_prefix: 'AK',
|
||||
payment_withdrawal_prefix: 'TX',
|
||||
payment_order_expiry: 30,
|
||||
pay_show_user: true,
|
||||
pay_show_paycount: true,
|
||||
@@ -38,6 +40,8 @@ export const orderSchema = {
|
||||
icon: 'fa fas fa-cog',
|
||||
fields: [
|
||||
{ key: 'payment_order_prefix', label: '订单前缀', type: 'input', default: 'AKSRT' },
|
||||
{ key: 'payment_flow_prefix', label: '流水号前缀', type: 'input', sanitize_type: 'business_prefix', default: 'AK', maxlength: 12, help: '仅允许字母、数字、下划线和短横线,最多 12 位。' },
|
||||
{ key: 'payment_withdrawal_prefix', label: '提现单号前缀', type: 'input', sanitize_type: 'business_prefix', default: 'TX', maxlength: 12, help: '仅允许字母、数字、下划线和短横线,最多 12 位。' },
|
||||
{ key: 'payment_order_expiry', label: '订单有效期(分钟)', type: 'number', default: 30, min: 5, max: 1440 },
|
||||
{ key: 'pay_show_user', label: '显示用户信息', type: 'switch', default: true },
|
||||
{ key: 'pay_show_paycount', label: '显示购买次数', type: 'switch', default: true },
|
||||
|
||||
@@ -712,7 +712,9 @@
|
||||
}
|
||||
var data = (r.json && r.json.data) ? r.json.data : r.json;
|
||||
resetOperationKey(form);
|
||||
notyf((data && data.message) || '申请已提交', 'success', 5, 'aksrt-balance-withdraw-toast');
|
||||
var successMessage = (data && data.message) || '申请已提交';
|
||||
if (data && data.withdrawal_number) successMessage += '(' + data.withdrawal_number + ')';
|
||||
notyf(successMessage, 'success', 5, 'aksrt-balance-withdraw-toast');
|
||||
closeModal(modal);
|
||||
var panel = modal.closest ? modal.closest('.dashboard-panel') : null;
|
||||
panel = panel || document.getElementById('panel-credits');
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
html += '<div><strong class="aksrt-order-detail__product-title">' + escapeHtml(title) + "</strong>";
|
||||
html += '<span class="aksrt-order-detail__product-meta">' + escapeHtml(order.product_type_label || "") + "</span></div></div>";
|
||||
html += '<dl class="aksrt-order-detail__list">';
|
||||
[["订单编号", order.order_number], ["创建时间", order.created_at], ["支付时间", order.paid_at || "—"]].forEach(function (row) {
|
||||
[["订单编号", order.order_number], ["流水号", order.flow_number || "—"], ["创建时间", order.created_at], ["支付时间", order.paid_at || "—"]].forEach(function (row) {
|
||||
html += "<div><dt>" + escapeHtml(row[0]) + "</dt><dd>" + escapeHtml(row[1] || "—") + "</dd></div>";
|
||||
});
|
||||
html += "</dl>";
|
||||
|
||||
@@ -213,6 +213,7 @@ endif; ?>
|
||||
<span class="badg badg-sm <?php echo esc_attr($st_class); ?>"><?php echo esc_html($status_labels[$st] ?? $st); ?></span>
|
||||
<b class="em12"><?php echo esc_html($gross->formatted()); ?></b>
|
||||
</div>
|
||||
<div class="muted-2-color em09 mt6"><?php esc_html_e('提现单号:', 'aksrt'); ?><code><?php echo esc_html((string) ($wd['withdrawal_number'] ?? '')); ?></code></div>
|
||||
<div class="muted-2-color em09 mt6">
|
||||
<?php echo esc_html((string) ($wd['created_at'] ?? '')); ?>
|
||||
<?php if ($gross->minor() !== $net->minor()) : ?>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,13 @@ 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'),
|
||||
|
||||
@@ -79,6 +79,10 @@ class PayOrders {
|
||||
}
|
||||
|
||||
$items = $repo->get_orders_admin($args, $search);
|
||||
$flow_numbers = \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number_map(array_map(
|
||||
static fn(array $row): string => (string) ($row['order_number'] ?? ''),
|
||||
$items
|
||||
));
|
||||
$total = $repo->get_orders_count($args, $search);
|
||||
$pages = max(1, (int) ceil($total / $per_page));
|
||||
$labels = class_exists('\AKSRT\Core\DomainModel')
|
||||
@@ -102,6 +106,7 @@ class PayOrders {
|
||||
'user_id' => $user_id > 0 ? $user_id : null,
|
||||
'payment_method' => $pay_method !== '' ? $pay_method : null,
|
||||
'status' => ($status !== '' && $status !== 'all') ? $status : null,
|
||||
's' => $search !== '' ? $search : null,
|
||||
]));
|
||||
$cls = ($product_type === $tkey) ? 'current' : '';
|
||||
$tab_bits[] = '<li><a class="' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($tlabel) . '</a></li>';
|
||||
@@ -126,6 +131,7 @@ class PayOrders {
|
||||
'user_id' => $user_id > 0 ? $user_id : null,
|
||||
'payment_method' => $mkey !== '' ? $mkey : null,
|
||||
'status' => ($status !== '' && $status !== 'all') ? $status : null,
|
||||
's' => $search !== '' ? $search : null,
|
||||
]));
|
||||
$cls = ($pay_method === $mkey) ? 'current' : '';
|
||||
$m_bits[] = '<li><a class="' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($mlabel) . '</a></li>';
|
||||
@@ -156,7 +162,7 @@ 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 !== '') {
|
||||
$filter_bits = [];
|
||||
@@ -189,12 +195,12 @@ 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>';
|
||||
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>';
|
||||
echo '<th>' . esc_html__('操作', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
@@ -208,14 +214,22 @@ class PayOrders {
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$status_key = (string) ($row['status'] ?? '');
|
||||
$status_label = $labels[$status_key] ?? $status_key;
|
||||
$flow_number = (string) ($flow_numbers[$order_number] ?? '');
|
||||
$product_id = (int) ($row['product_id'] ?? 0);
|
||||
$product_type = sanitize_key((string) ($row['product_type'] ?? ''));
|
||||
$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 && in_array($product_type, ['resource', 'shop_product'], true)) {
|
||||
$product_title = (string) get_the_title($product_id);
|
||||
}
|
||||
|
||||
echo '<tr>';
|
||||
echo '<td><code>' . esc_html($order_number) . '</code></td>';
|
||||
echo '<td><code>' . esc_html($order_number) . '</code><br><span class="description">' . esc_html__('流水:', 'aksrt') . ($flow_number !== '' ? '<code>' . esc_html($flow_number) . '</code>' : '—') . '</span></td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['product_type'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($this->cash_decimal((string) ($row['amount'] ?? '0'))) . ' ' . esc_html((string) ($row['currency'] ?? '')) . '</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($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'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="description">' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '</span></td>';
|
||||
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">';
|
||||
echo '<a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders', ['order' => $order_number])) . '">' . esc_html__('详情', 'aksrt') . '</a>';
|
||||
$this->render_order_quick_status_buttons($order_number, $status_key);
|
||||
@@ -262,6 +276,7 @@ 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'] ?? '');
|
||||
@@ -277,13 +292,14 @@ class PayOrders {
|
||||
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
|
||||
$rows = [
|
||||
__('订单号', 'aksrt') => $order_number,
|
||||
__('支付流水号', 'aksrt') => $flow_number !== '' ? $flow_number : '—',
|
||||
__('用户', 'aksrt') => $user ? $user->display_name . ' (#' . $user_id . ')' : (string) $user_id,
|
||||
__('商品类型', 'aksrt') => (string) ($order['product_type'] ?? ''),
|
||||
__('商品 ID', 'aksrt') => (string) ($order['product_id'] ?? ''),
|
||||
__('金额', 'aksrt') => $this->cash_decimal((string) ($order['amount'] ?? '0')) . ' ' . (string) ($order['currency'] ?? ''),
|
||||
__('金额', 'aksrt') => $this->order_amount($order),
|
||||
__('状态', 'aksrt') => $labels[$status] ?? $status,
|
||||
__('支付方式', 'aksrt') => (string) ($order['payment_method'] ?? ''),
|
||||
__('交易号', 'aksrt') => (string) ($order['payment_transaction_id'] ?? ''),
|
||||
__('渠道交易号', 'aksrt') => (string) ($order['payment_transaction_id'] ?? ''),
|
||||
__('创建时间', 'aksrt') => (string) ($order['created_at'] ?? ''),
|
||||
__('支付时间', 'aksrt') => (string) ($order['paid_at'] ?? ''),
|
||||
__('买家备注', 'aksrt') => $customer_note,
|
||||
@@ -440,6 +456,16 @@ class PayOrders {
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
private function order_amount(array $order): string {
|
||||
$currency = sanitize_key((string) ($order['currency'] ?? ''));
|
||||
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
if ($currency === 'points' || $method === 'points') {
|
||||
return max(0, (int) ($order['amount'] ?? 0)) . ' ' . __('积分', 'aksrt');
|
||||
}
|
||||
return $this->cash_decimal((string) ($order['amount'] ?? '0')) . ' ' . strtoupper((string) ($order['currency'] ?? 'CNY'));
|
||||
}
|
||||
|
||||
private function render_header(string $title): void {
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html($title) . '</h1>';
|
||||
|
||||
@@ -39,6 +39,10 @@ class PayWithdrawals {
|
||||
}
|
||||
|
||||
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
|
||||
$keyword = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
|
||||
$user_filter = 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_num = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$per_page = 20;
|
||||
$svc = \AKSRT\Payment\Withdrawal::get_instance();
|
||||
@@ -79,31 +83,47 @@ class PayWithdrawals {
|
||||
);
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<input type="search" name="s" value="' . esc_attr($keyword) . '" placeholder="' . esc_attr__('提现单号 / 用户 ID', 'aksrt') . '" /> ';
|
||||
echo '<input type="number" min="1" name="user_id" value="' . esc_attr($user_filter > 0 ? (string) $user_filter : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" class="small-text" /> ';
|
||||
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 ($status !== '' || $keyword !== '' || $user_filter > 0 || $date_from !== '' || $date_to !== '') {
|
||||
echo ' <a class="button" href="' . esc_url($this->page_url(self::PAGE_SLUG)) . '">' . esc_html__('清除条件', 'aksrt') . '</a>';
|
||||
}
|
||||
echo '</form>';
|
||||
|
||||
$items = $svc->get_all([
|
||||
'status' => $status,
|
||||
'keyword' => $keyword,
|
||||
'user_id' => $user_filter,
|
||||
'date_from' => $date_from,
|
||||
'date_to' => $date_to,
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page_num - 1) * $per_page,
|
||||
]);
|
||||
$total = $svc->get_count(['status' => $status]);
|
||||
$total = $svc->get_count([
|
||||
'status' => $status,
|
||||
'keyword' => $keyword,
|
||||
'user_id' => $user_filter,
|
||||
'date_from' => $date_from,
|
||||
'date_to' => $date_to,
|
||||
]);
|
||||
$pages = max(1, (int) ceil($total / $per_page));
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped">';
|
||||
echo '<thead><tr>';
|
||||
echo '<th>ID</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>';
|
||||
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>';
|
||||
echo '<th>' . esc_html__('申请 / 处理时间', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('操作', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
if (!$items) {
|
||||
echo '<tr><td colspan="8">' . esc_html__('暂无提现记录', 'aksrt') . '</td></tr>';
|
||||
echo '<tr><td colspan="7">' . esc_html__('暂无提现记录', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
|
||||
foreach ($items as $row) {
|
||||
@@ -115,25 +135,21 @@ class PayWithdrawals {
|
||||
$net = (string) ($row['amount'] ?? '0.00');
|
||||
$fee = (int) ($row['fee_rate'] ?? 0);
|
||||
$acct = (string) ($row['display_account'] ?? $row['account_info'] ?? '');
|
||||
$amount_txt = $net;
|
||||
if ($gross !== $net) {
|
||||
$amount_txt = sprintf(
|
||||
/* translators: 1: gross 2: net 3: fee percent */
|
||||
__('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'),
|
||||
$gross,
|
||||
$net,
|
||||
$fee
|
||||
);
|
||||
}
|
||||
$amount_txt = sprintf(
|
||||
/* translators: 1: gross 2: net 3: fee percent */
|
||||
__('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'),
|
||||
$gross,
|
||||
$net,
|
||||
$fee
|
||||
);
|
||||
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html((string) $id) . '</td>';
|
||||
echo '<td><code>' . esc_html((string) ($row['withdrawal_number'] ?? '')) . '</code></td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td>' . esc_html($amount_txt) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['method'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($acct) . '</td>';
|
||||
echo '<td><strong>' . esc_html((string) ($row['method'] ?? '')) . '</strong><br><span class="description">' . esc_html($acct) . '</span></td>';
|
||||
echo '<td>' . esc_html($status_options[$st] ?? $st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="description">' . esc_html((string) ($row['processed_at'] ?? '') ?: '—') . '</span></td>';
|
||||
echo '<td>';
|
||||
$next_statuses = $st === 'pending' ? ['processing', 'rejected'] : ($st === 'processing' ? ['completed', 'rejected'] : []);
|
||||
if ($next_statuses === []) {
|
||||
@@ -258,6 +274,10 @@ class PayWithdrawals {
|
||||
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
|
||||
}
|
||||
|
||||
private function sanitize_date(string $date): string {
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : '';
|
||||
}
|
||||
|
||||
private function render_pagination(int $pages, int $page_num): void {
|
||||
if ($pages <= 1) {
|
||||
return;
|
||||
|
||||
@@ -234,6 +234,8 @@ class SchemaPayment {
|
||||
'groups' => [
|
||||
['key' => 'basic', 'title' => '订单基础', 'fields' => [
|
||||
['key' => 'payment_order_prefix', 'label' => '订单前缀', 'type' => 'input', 'default' => 'AKSRT'],
|
||||
['key' => 'payment_flow_prefix', 'label' => '流水号前缀', 'type' => 'input', 'sanitize_type' => 'business_prefix', 'default' => 'AK', 'maxlength' => 12, 'help' => '仅允许字母、数字、下划线和短横线,最多 12 位。'],
|
||||
['key' => 'payment_withdrawal_prefix', 'label' => '提现单号前缀', 'type' => 'input', 'sanitize_type' => 'business_prefix', 'default' => 'TX', 'maxlength' => 12, 'help' => '仅允许字母、数字、下划线和短横线,最多 12 位。'],
|
||||
['key' => 'payment_order_expiry', 'label' => '订单有效期(分钟)', 'type' => 'number', 'default' => 30, 'min' => 5, 'max' => 1440],
|
||||
['key' => 'pay_show_user', 'label' => '显示用户信息', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'pay_show_paycount', 'label' => '显示购买次数', 'type' => 'switch', 'default' => true],
|
||||
|
||||
@@ -517,6 +517,9 @@ class SchemaProvider {
|
||||
private function sanitize_field_value($value, array $field) {
|
||||
$type = (string) ($field['type'] ?? 'text');
|
||||
$sanitize_type = (string) ($field['sanitize_type'] ?? '');
|
||||
if ($sanitize_type === 'business_prefix') {
|
||||
return \AKSRT\Finance\BusinessNumber::normalize_prefix($value, (string) ($field['default'] ?? 'AK'));
|
||||
}
|
||||
if ($sanitize_type !== '') {
|
||||
return $this->sanitize_value($value, $sanitize_type);
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ trait PayOrders {
|
||||
'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,
|
||||
'message' => __('支付成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
@@ -503,6 +504,7 @@ trait PayOrders {
|
||||
'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'),
|
||||
|
||||
@@ -420,6 +420,7 @@ trait VipServicePurchase {
|
||||
'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'),
|
||||
@@ -878,6 +879,7 @@ trait VipServicePurchase {
|
||||
'days' => $days,
|
||||
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||||
'transaction_id' => $deducted->transaction_id,
|
||||
'flow_number' => $deducted->flow_number,
|
||||
'replayed' => $deducted->replayed,
|
||||
'message' => __('兑换成功', 'aksrt'),
|
||||
'reload' => true,
|
||||
|
||||
@@ -70,6 +70,52 @@ trait DBMigratorPart2 {
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_asset_entry);
|
||||
|
||||
$sql_financial_flow = "CREATE TABLE {$prefix}aksrt_financial_flow (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
flow_number VARCHAR(32) NOT NULL,
|
||||
operation_key VARCHAR(191) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
ref_type VARCHAR(32) DEFAULT NULL,
|
||||
ref_key VARCHAR(191) DEFAULT NULL,
|
||||
order_number VARCHAR(32) DEFAULT NULL,
|
||||
withdrawal_number VARCHAR(32) DEFAULT NULL,
|
||||
ledger_transaction_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
provider_transaction_id VARCHAR(128) DEFAULT NULL,
|
||||
metadata LONGTEXT DEFAULT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_flow_number (flow_number),
|
||||
UNIQUE KEY uniq_flow_operation (operation_key),
|
||||
UNIQUE KEY uniq_flow_ledger_transaction (ledger_transaction_id),
|
||||
KEY idx_flow_event_time (event_type, created_at),
|
||||
KEY idx_flow_order (order_number),
|
||||
KEY idx_flow_withdrawal (withdrawal_number),
|
||||
KEY idx_flow_reference (ref_type, ref_key),
|
||||
KEY idx_flow_provider_transaction (provider_transaction_id)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_financial_flow);
|
||||
|
||||
$sql_financial_flow_entry = "CREATE TABLE {$prefix}aksrt_financial_flow_entry (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
flow_id BIGINT UNSIGNED NOT NULL,
|
||||
line_no SMALLINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
asset VARCHAR(16) NOT NULL,
|
||||
direction VARCHAR(8) NOT NULL,
|
||||
entry_type VARCHAR(32) NOT NULL,
|
||||
delta_minor BIGINT NOT NULL,
|
||||
balance_after_minor BIGINT UNSIGNED DEFAULT NULL,
|
||||
counterparty_user_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
remark VARCHAR(255) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_flow_line (flow_id, line_no),
|
||||
KEY idx_flow_entry_user (user_id, flow_id),
|
||||
KEY idx_flow_entry_asset (asset, flow_id),
|
||||
KEY idx_flow_entry_direction (direction, flow_id),
|
||||
KEY idx_flow_entry_counterparty (counterparty_user_id, flow_id)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_financial_flow_entry);
|
||||
|
||||
// ---------- 2. 签到 ----------
|
||||
$sql_signin = "CREATE TABLE {$prefix}aksrt_signin_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -188,6 +234,7 @@ trait DBMigratorPart2 {
|
||||
// ---------- 10. 提现台账 ----------
|
||||
$sql_withdrawal = "CREATE TABLE {$prefix}aksrt_withdrawal (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
withdrawal_number VARCHAR(32) NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
gross_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
@@ -199,6 +246,7 @@ trait DBMigratorPart2 {
|
||||
processed_at DATETIME DEFAULT NULL,
|
||||
note TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_withdrawal_number (withdrawal_number),
|
||||
UNIQUE KEY uniq_ledger_transaction (ledger_transaction_id),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_user_created (user_id, created_at),
|
||||
|
||||
@@ -19,6 +19,8 @@ trait DBMigratorPart3 {
|
||||
'asset_account' => 'aksrt_asset_account',
|
||||
'asset_transaction' => 'aksrt_asset_transaction',
|
||||
'asset_entry' => 'aksrt_asset_entry',
|
||||
'financial_flow' => 'aksrt_financial_flow',
|
||||
'financial_flow_entry' => 'aksrt_financial_flow_entry',
|
||||
'signin_log' => 'aksrt_signin_log',
|
||||
'dl_token' => 'aksrt_dl_token',
|
||||
'notification' => 'aksrt_notification',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Finance;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class BusinessNumber {
|
||||
|
||||
public const MAX_PREFIX_LENGTH = 12;
|
||||
|
||||
public static function normalize_prefix(mixed $value, string $fallback): string {
|
||||
$prefix = strtoupper(trim(sanitize_text_field(is_scalar($value) ? (string) $value : '')));
|
||||
$prefix = (string) preg_replace('/[^A-Z0-9_-]/', '', $prefix);
|
||||
$prefix = substr($prefix, 0, self::MAX_PREFIX_LENGTH);
|
||||
if ($prefix !== '') {
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
$fallback = strtoupper((string) preg_replace('/[^A-Z0-9_-]/', '', $fallback));
|
||||
return substr($fallback !== '' ? $fallback : 'AK', 0, self::MAX_PREFIX_LENGTH);
|
||||
}
|
||||
|
||||
public static function candidate(string $prefix, ?\DateTimeInterface $now = null, ?int $random = null): string {
|
||||
$prefix = self::normalize_prefix($prefix, 'AK');
|
||||
if ($now === null) {
|
||||
$now = new \DateTimeImmutable('now', wp_timezone());
|
||||
}
|
||||
$random = $random ?? random_int(0, 999999);
|
||||
$random = max(0, min(999999, $random));
|
||||
|
||||
return $prefix . $now->format('YmdHis') . str_pad((string) $random, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?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 !== '') {
|
||||
$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;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Finance;
|
||||
|
||||
use AKSRT\Ledger\LedgerCommand;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class FinancialFlowService {
|
||||
|
||||
private const MAX_NUMBER_ATTEMPTS = 5;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an asset-ledger event inside the caller-owned transaction.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $entries
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
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'] ?? '');
|
||||
|
||||
$flow_entries = [];
|
||||
foreach ($entries as $entry) {
|
||||
$delta = (int) ($entry['delta_minor'] ?? 0);
|
||||
$flow_entries[] = [
|
||||
'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
|
||||
'asset' => sanitize_key((string) ($entry['asset'] ?? '')),
|
||||
'direction' => $delta > 0 ? 'in' : 'out',
|
||||
'entry_type' => sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')),
|
||||
'delta_minor' => $delta,
|
||||
'balance_after_minor' => max(0, (int) ($entry['balance_after_minor'] ?? 0)),
|
||||
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)),
|
||||
'remark' => (string) ($entry['remark'] ?? ''),
|
||||
];
|
||||
}
|
||||
$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,
|
||||
'event_type' => $event_type,
|
||||
'ref_type' => $command->ref_type,
|
||||
'ref_key' => $command->ref_key,
|
||||
'order_number' => $order_number,
|
||||
'withdrawal_number' => $withdrawal_number,
|
||||
'ledger_transaction_id' => $transaction_id,
|
||||
'provider_transaction_id' => $provider_transaction_id,
|
||||
'metadata' => $metadata,
|
||||
'created_at' => $created_at,
|
||||
], $flow_entries, true);
|
||||
}
|
||||
|
||||
/** @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);
|
||||
}
|
||||
|
||||
/** @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));
|
||||
$number = sanitize_text_field((string) ($withdrawal['withdrawal_number'] ?? ''));
|
||||
try {
|
||||
$amount_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($withdrawal['amount'] ?? ''))->minor();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new \WP_Error('aksrt_flow_amount', __('提现流水金额无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return $this->record([
|
||||
'operation_key' => $operation_key,
|
||||
'event_type' => 'withdraw_payout',
|
||||
'ref_type' => 'withdraw',
|
||||
'ref_key' => $number,
|
||||
'withdrawal_number' => $number,
|
||||
'metadata' => ['method' => sanitize_key((string) ($withdrawal['method'] ?? ''))],
|
||||
'created_at' => $created_at,
|
||||
], [[
|
||||
'user_id' => $user_id,
|
||||
'asset' => 'cash',
|
||||
'direction' => 'in',
|
||||
'entry_type' => 'payout',
|
||||
'delta_minor' => $amount_minor,
|
||||
'remark' => __('提现打款完成', 'aksrt'),
|
||||
]], $caller_transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $flow
|
||||
* @param array<int,array<string,mixed>> $entries
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
private function record(array $flow, array $entries, bool $caller_transaction) {
|
||||
global $wpdb;
|
||||
$operation_key = substr(sanitize_text_field((string) ($flow['operation_key'] ?? '')), 0, 191);
|
||||
$event_type = sanitize_key((string) ($flow['event_type'] ?? ''));
|
||||
if ($operation_key === '' || $event_type === '' || $entries === []) {
|
||||
return new \WP_Error('aksrt_flow_invalid', __('资金流水参数无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$prepared_entries = $this->prepare_entries($entries);
|
||||
if (is_wp_error($prepared_entries)) {
|
||||
return $prepared_entries;
|
||||
}
|
||||
|
||||
$existing = $this->number_for_operation($operation_key);
|
||||
if ($existing !== '') {
|
||||
return $existing;
|
||||
}
|
||||
$owns_transaction = !$caller_transaction;
|
||||
if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
|
||||
return new \WP_Error('aksrt_flow_db', __('无法开启资金流水事务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$settings = \AKSRT\Settings\Settings::get_order_settings();
|
||||
$prefix = BusinessNumber::normalize_prefix($settings['flow_prefix'] ?? 'AK', 'AK');
|
||||
$flow_id = 0;
|
||||
$flow_number = '';
|
||||
for ($attempt = 1; $attempt <= self::MAX_NUMBER_ATTEMPTS; $attempt++) {
|
||||
$flow_number = BusinessNumber::candidate($prefix);
|
||||
$metadata = is_array($flow['metadata'] ?? null) ? $flow['metadata'] : [];
|
||||
$inserted = $wpdb->insert($this->flow_table(), [
|
||||
'flow_number' => $flow_number,
|
||||
'operation_key' => $operation_key,
|
||||
'event_type' => $event_type,
|
||||
'ref_type' => ($flow['ref_type'] ?? '') !== '' ? sanitize_key((string) $flow['ref_type']) : null,
|
||||
'ref_key' => ($flow['ref_key'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['ref_key']), 0, 191) : null,
|
||||
'order_number' => ($flow['order_number'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['order_number']), 0, 32) : null,
|
||||
'withdrawal_number' => ($flow['withdrawal_number'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['withdrawal_number']), 0, 32) : null,
|
||||
'ledger_transaction_id' => max(0, (int) ($flow['ledger_transaction_id'] ?? 0)) ?: null,
|
||||
'provider_transaction_id' => ($flow['provider_transaction_id'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['provider_transaction_id']), 0, 128) : null,
|
||||
'metadata' => $metadata !== [] ? wp_json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
'created_at' => (string) ($flow['created_at'] ?? self::utc_now()),
|
||||
], ['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s']);
|
||||
if ($inserted === 1) {
|
||||
$flow_id = (int) $wpdb->insert_id;
|
||||
break;
|
||||
}
|
||||
$existing = $this->number_for_operation($operation_key);
|
||||
if ($existing !== '') {
|
||||
if ($owns_transaction) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
}
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
if ($flow_id <= 0) {
|
||||
if ($owns_transaction) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
}
|
||||
return new \WP_Error('aksrt_flow_number', __('无法生成唯一流水号', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
foreach ($prepared_entries as $index => $entry) {
|
||||
$inserted = $wpdb->insert($this->entry_table(), [
|
||||
'flow_id' => $flow_id,
|
||||
'line_no' => $index + 1,
|
||||
'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
|
||||
'asset' => $entry['asset'],
|
||||
'direction' => $entry['direction'],
|
||||
'entry_type' => substr(sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')), 0, 32),
|
||||
'delta_minor' => $entry['delta_minor'],
|
||||
'balance_after_minor' => $entry['balance_after_minor'],
|
||||
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)) ?: null,
|
||||
'remark' => ($entry['remark'] ?? '') !== '' ? substr(sanitize_text_field((string) $entry['remark']), 0, 255) : null,
|
||||
], ['%d', '%d', '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%s']);
|
||||
if ($inserted !== 1) {
|
||||
if ($owns_transaction) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
}
|
||||
return new \WP_Error('aksrt_flow_entry', __('资金流水分录写入失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
if ($owns_transaction && $wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('aksrt_flow_db', __('资金流水提交失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return $flow_number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate every entry before the flow head is inserted.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $entries
|
||||
* @return array<int,array<string,mixed>>|\WP_Error
|
||||
*/
|
||||
private function prepare_entries(array $entries) {
|
||||
$prepared = [];
|
||||
foreach (array_values($entries) as $entry) {
|
||||
$delta = (int) ($entry['delta_minor'] ?? 0);
|
||||
$asset = sanitize_key((string) ($entry['asset'] ?? ''));
|
||||
$direction = sanitize_key((string) ($entry['direction'] ?? ($delta > 0 ? 'in' : 'out')));
|
||||
if (!in_array($asset, ['cash', 'balance', 'points'], true) || $delta === 0) {
|
||||
return new \WP_Error('aksrt_flow_entry', __('资金流水分录无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (!in_array($direction, ['in', 'out'], true) || ($delta > 0) !== ($direction === 'in')) {
|
||||
return new \WP_Error('aksrt_flow_entry', __('资金流水方向无效', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$prepared[] = [
|
||||
'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
|
||||
'asset' => $asset,
|
||||
'direction' => $direction,
|
||||
'entry_type' => substr(sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')), 0, 32),
|
||||
'delta_minor' => $delta,
|
||||
'balance_after_minor' => array_key_exists('balance_after_minor', $entry) ? max(0, (int) $entry['balance_after_minor']) : null,
|
||||
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)) ?: null,
|
||||
'remark' => ($entry['remark'] ?? '') !== '' ? substr(sanitize_text_field((string) $entry['remark']), 0, 255) : null,
|
||||
];
|
||||
}
|
||||
return $prepared;
|
||||
}
|
||||
|
||||
public function number_for_operation(string $operation_key): string {
|
||||
global $wpdb;
|
||||
return (string) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT flow_number FROM {$this->flow_table()} WHERE operation_key = %s LIMIT 1",
|
||||
$operation_key
|
||||
));
|
||||
}
|
||||
|
||||
public function number_for_ledger_transaction(int $transaction_id): string {
|
||||
global $wpdb;
|
||||
if ($transaction_id <= 0) {
|
||||
return '';
|
||||
}
|
||||
return (string) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT flow_number FROM {$this->flow_table()} WHERE ledger_transaction_id = %d LIMIT 1",
|
||||
$transaction_id
|
||||
));
|
||||
}
|
||||
|
||||
private function record_order_event(array $order, string $event_type, int $sign, bool $caller_transaction) {
|
||||
$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'));
|
||||
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]);
|
||||
}
|
||||
$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' => [
|
||||
'payment_method' => $payment_method,
|
||||
'currency' => $currency,
|
||||
],
|
||||
'created_at' => self::utc_now(),
|
||||
], [[
|
||||
'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'),
|
||||
]], $caller_transaction);
|
||||
}
|
||||
|
||||
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');
|
||||
$withdrawal_id = max(0, (int) ($command->metadata['withdrawal_id'] ?? 0));
|
||||
if ($withdrawal_id <= 0 && ctype_digit($command->ref_key)) {
|
||||
$withdrawal_id = (int) $command->ref_key;
|
||||
}
|
||||
if ($withdrawal_id > 0) {
|
||||
return (string) $wpdb->get_var($wpdb->prepare("SELECT withdrawal_number FROM {$table} WHERE id = %d LIMIT 1", $withdrawal_id));
|
||||
}
|
||||
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;
|
||||
}
|
||||
$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'] ?? ''));
|
||||
if (sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
|| in_array($method, ['', 'balance', 'points'], 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');
|
||||
}
|
||||
|
||||
private function entry_table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('financial_flow_entry');
|
||||
}
|
||||
|
||||
private static function utc_now(): string {
|
||||
return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.u');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,7 +16,8 @@ final class LedgerResult {
|
||||
public readonly int $transaction_id,
|
||||
public readonly bool $replayed,
|
||||
public readonly array $entries,
|
||||
public readonly array $balances
|
||||
public readonly array $balances,
|
||||
public readonly string $flow_number = ''
|
||||
) {}
|
||||
|
||||
public function balance(int $user_id, AssetType|string $asset): int {
|
||||
@@ -31,6 +32,7 @@ final class LedgerResult {
|
||||
'replayed' => $this->replayed,
|
||||
'entries' => $this->entries,
|
||||
'balances' => $this->balances,
|
||||
'flow_number' => $this->flow_number,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +409,16 @@ final class LedgerService {
|
||||
throw new \RuntimeException('ledger_business_mutation_failed');
|
||||
}
|
||||
}
|
||||
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_ledger(
|
||||
$command,
|
||||
$transaction_id,
|
||||
$written_entries,
|
||||
$created_at
|
||||
);
|
||||
if (is_wp_error($flow_number)) {
|
||||
throw new LedgerAbort($flow_number);
|
||||
}
|
||||
$result = new LedgerResult($transaction_id, false, $written_entries, $projected_balances, $flow_number);
|
||||
if ($wpdb->query('COMMIT') === false) {
|
||||
throw new \RuntimeException('ledger_commit_failed');
|
||||
}
|
||||
@@ -479,7 +489,8 @@ final class LedgerService {
|
||||
$balances[$entry['user_id']][(string) $entry['asset']] = $entry['balance_after_minor'];
|
||||
}
|
||||
unset($entry);
|
||||
return new LedgerResult($transaction_id, $replayed, (array) $entries, $balances);
|
||||
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_ledger_transaction($transaction_id);
|
||||
return new LedgerResult($transaction_id, $replayed, (array) $entries, $balances, $flow_number);
|
||||
}
|
||||
|
||||
private function db_error(int $errno, string $detail = ''): \WP_Error {
|
||||
|
||||
@@ -112,6 +112,7 @@ class BalanceGateway implements GatewayInterface {
|
||||
'success' => true,
|
||||
'transaction_id' => $transaction_code,
|
||||
'ledger_transaction_id' => $result->transaction_id,
|
||||
'flow_number' => $result->flow_number,
|
||||
'replayed' => $result->replayed,
|
||||
'order_committed' => true,
|
||||
'message' => __('余额支付成功', 'aksrt'),
|
||||
@@ -267,6 +268,7 @@ class BalanceGateway implements GatewayInterface {
|
||||
'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'),
|
||||
|
||||
@@ -376,20 +376,24 @@ trait OrderRepositoryAdmin {
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
// Numeric search may mean user_id or product_id; also match order number / type.
|
||||
$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)';
|
||||
$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)';
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -368,8 +368,10 @@ trait OrderRepositoryCore {
|
||||
}
|
||||
}
|
||||
|
||||
private function update_status_in_transaction(string $order_number, string $new_status, array $extra = []): bool {
|
||||
private function update_status_in_transaction(string $order_number, string $new_status, array $extra = []): bool {
|
||||
global $wpdb;
|
||||
$ledger_active = class_exists('\AKSRT\Ledger\LedgerService')
|
||||
&& \AKSRT\Ledger\LedgerService::get_instance()->transaction_active();
|
||||
|
||||
$valid = \AKSRT\Core\DomainModel::order_status_labels();
|
||||
if (!isset($valid[$new_status])) return false;
|
||||
@@ -499,6 +501,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,
|
||||
@@ -535,6 +544,17 @@ trait OrderRepositoryCore {
|
||||
'args' => [$order_number, $paid_order],
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -131,6 +131,7 @@ class PaymentOrchestrator {
|
||||
'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')),
|
||||
];
|
||||
|
||||
@@ -226,6 +226,9 @@ class UserOrders {
|
||||
])),
|
||||
];
|
||||
if ($detail) {
|
||||
$view['flow_number'] = $order_number !== '' && $group !== OrderCenterQuery::STATUS_PENDING
|
||||
? \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number)
|
||||
: '';
|
||||
$view['paid_at'] = (string) ($order['paid_at'] ?? '');
|
||||
$view['expires_at'] = (string) ($order['expires_at'] ?? '');
|
||||
$view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false];
|
||||
@@ -388,6 +391,7 @@ class UserOrders {
|
||||
'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'),
|
||||
@@ -424,6 +428,7 @@ class UserOrders {
|
||||
'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'] ?? ''),
|
||||
|
||||
+96
-31
@@ -99,7 +99,7 @@ class Withdrawal {
|
||||
|
||||
/**
|
||||
* Atomically validates quotas, freezes wallet funds, writes the ledger and creates an order.
|
||||
* @return array{withdrawal_id:int,net_amount:string,wallet_balance:string,transaction_id:int,replayed:bool,usage:array}|\WP_Error
|
||||
* @return array{withdrawal_id:int,withdrawal_number:string,net_amount:string,wallet_balance:string,transaction_id:int,flow_number:string,replayed:bool,usage:array}|\WP_Error
|
||||
*/
|
||||
public function create_balance_request(int $user_id, int $gross_minor, string $method, string $account, array $settings, string $operation_key) {
|
||||
global $wpdb;
|
||||
@@ -115,6 +115,7 @@ class Withdrawal {
|
||||
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
|
||||
$usage = null;
|
||||
$withdrawal_id = 0;
|
||||
$withdrawal_number = '';
|
||||
$guard = function () use ($user_id, $gross_minor, $settings, $window, &$usage) {
|
||||
global $wpdb;
|
||||
$wpdb->get_results($wpdb->prepare(
|
||||
@@ -137,7 +138,7 @@ class Withdrawal {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id) {
|
||||
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id, &$withdrawal_number) {
|
||||
global $wpdb;
|
||||
$payload = wp_json_encode([
|
||||
'account' => $account,
|
||||
@@ -146,16 +147,26 @@ class Withdrawal {
|
||||
'source' => 'balance',
|
||||
'operation_key' => $operation_key,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
|
||||
$ok = $wpdb->insert($this->table(), [
|
||||
'user_id' => $user_id,
|
||||
'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
|
||||
'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(),
|
||||
'method' => sanitize_key($method),
|
||||
'account_info' => $payload,
|
||||
'status' => self::STATUS_PENDING,
|
||||
'ledger_transaction_id' => $transaction_id,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
|
||||
$order_settings = \AKSRT\Settings\Settings::get_order_settings();
|
||||
$prefix = \AKSRT\Finance\BusinessNumber::normalize_prefix($order_settings['withdrawal_prefix'] ?? 'TX', 'TX');
|
||||
$ok = false;
|
||||
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
||||
$withdrawal_number = \AKSRT\Finance\BusinessNumber::candidate($prefix);
|
||||
$ok = $wpdb->insert($this->table(), [
|
||||
'withdrawal_number' => $withdrawal_number,
|
||||
'user_id' => $user_id,
|
||||
'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
|
||||
'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(),
|
||||
'method' => sanitize_key($method),
|
||||
'account_info' => $payload,
|
||||
'status' => self::STATUS_PENDING,
|
||||
'ledger_transaction_id' => $transaction_id,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
|
||||
if ($ok === 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($ok !== 1) {
|
||||
return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
@@ -179,10 +190,12 @@ class Withdrawal {
|
||||
return $result;
|
||||
}
|
||||
if ($withdrawal_id <= 0) {
|
||||
$withdrawal_id = (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$this->table()} WHERE ledger_transaction_id = %d",
|
||||
$replayed_row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT id, withdrawal_number FROM {$this->table()} WHERE ledger_transaction_id = %d",
|
||||
$result->transaction_id
|
||||
));
|
||||
), ARRAY_A);
|
||||
$withdrawal_id = (int) ($replayed_row['id'] ?? 0);
|
||||
$withdrawal_number = (string) ($replayed_row['withdrawal_number'] ?? '');
|
||||
}
|
||||
if ($withdrawal_id <= 0) {
|
||||
return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 500]);
|
||||
@@ -197,9 +210,11 @@ class Withdrawal {
|
||||
}
|
||||
return [
|
||||
'withdrawal_id' => $withdrawal_id,
|
||||
'withdrawal_number' => $withdrawal_number,
|
||||
'net_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
|
||||
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
|
||||
'transaction_id' => $result->transaction_id,
|
||||
'flow_number' => $result->flow_number,
|
||||
'replayed' => $result->replayed,
|
||||
'usage' => [
|
||||
'count' => $usage['count'],
|
||||
@@ -253,7 +268,7 @@ class Withdrawal {
|
||||
* Status transition with balance side-effects.
|
||||
* Reject of balance-source holds refunds gross_amount once (idempotent).
|
||||
*
|
||||
* @return array{transaction_id:int,replayed:bool,wallet_balance:string}|\WP_Error
|
||||
* @return array{transaction_id:int,flow_number:string,withdrawal_number:string,replayed:bool,wallet_balance:string}|\WP_Error
|
||||
*/
|
||||
public function process_status_change(int $withdrawal_id, string $new_status, string $note = '', string $operation_key = '') {
|
||||
$new_status = sanitize_key($new_status);
|
||||
@@ -287,6 +302,8 @@ class Withdrawal {
|
||||
}
|
||||
if (is_array($preflight_replay)) {
|
||||
$preflight_replay['wallet_balance'] = \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal((int) ($row['user_id'] ?? 0));
|
||||
$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;
|
||||
}
|
||||
$transitions = [
|
||||
@@ -302,6 +319,7 @@ class Withdrawal {
|
||||
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
$ledger_result = null;
|
||||
$completed_flow_number = '';
|
||||
$locked_row = null;
|
||||
$locked_meta = [];
|
||||
$operation_replay = null;
|
||||
@@ -400,6 +418,19 @@ class Withdrawal {
|
||||
}
|
||||
$locked = $lock_and_validate();
|
||||
$updated = is_wp_error($locked) ? $locked : $update_locked(0);
|
||||
if (!is_wp_error($updated) && $new_status === self::STATUS_COMPLETED && !is_array($operation_replay)) {
|
||||
$flow = \AKSRT\Finance\FinancialFlowService::get_instance()->record_withdrawal_payout(
|
||||
array_merge((array) $locked_row, ['status' => $new_status]),
|
||||
$operation_key,
|
||||
(new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.u'),
|
||||
true
|
||||
);
|
||||
if (is_wp_error($flow)) {
|
||||
$updated = $flow;
|
||||
} else {
|
||||
$completed_flow_number = $flow;
|
||||
}
|
||||
}
|
||||
if (is_wp_error($updated) || $wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return is_wp_error($updated) ? $updated : new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]);
|
||||
@@ -429,6 +460,8 @@ class Withdrawal {
|
||||
|
||||
return [
|
||||
'transaction_id' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->transaction_id : 0,
|
||||
'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),
|
||||
];
|
||||
@@ -528,15 +561,7 @@ class Withdrawal {
|
||||
*/
|
||||
public function get_all(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
[$where, $params] = $this->build_admin_filters($args);
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
@@ -556,15 +581,55 @@ class Withdrawal {
|
||||
|
||||
public function get_count(array $args = []): int {
|
||||
global $wpdb;
|
||||
[$where, $params] = $this->build_admin_filters($args);
|
||||
$where_sql = $where ? ' WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT COUNT(*) FROM {$this->table()}{$where_sql}";
|
||||
return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
|
||||
}
|
||||
|
||||
/** @return array{0:array<int,string>,1:array<int,mixed>} */
|
||||
private function build_admin_filters(array $args): array {
|
||||
global $wpdb;
|
||||
$where = [];
|
||||
$params = [];
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->table()} WHERE status = %s",
|
||||
$status
|
||||
));
|
||||
if (in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}");
|
||||
$user_id = max(0, (int) ($args['user_id'] ?? 0));
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
$keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? '')));
|
||||
if ($keyword !== '') {
|
||||
$like = '%' . $wpdb->esc_like($keyword) . '%';
|
||||
if (ctype_digit($keyword)) {
|
||||
$where[] = '(withdrawal_number LIKE %s OR user_id = %d)';
|
||||
$params[] = $like;
|
||||
$params[] = (int) $keyword;
|
||||
} else {
|
||||
$where[] = 'withdrawal_number LIKE %s';
|
||||
$params[] = $like;
|
||||
}
|
||||
}
|
||||
if (!empty($args['date_from'])) {
|
||||
$where[] = 'created_at >= %s';
|
||||
$params[] = (string) $args['date_from'] . ' 00:00:00';
|
||||
}
|
||||
if (!empty($args['date_to'])) {
|
||||
try {
|
||||
$end = (new \DateTimeImmutable((string) $args['date_to'] . ' 00:00:00', wp_timezone()))->modify('+1 day')->format('Y-m-d H:i:s');
|
||||
} catch (\Exception $e) {
|
||||
$end = '';
|
||||
}
|
||||
if ($end !== '') {
|
||||
$where[] = 'created_at < %s';
|
||||
$params[] = $end;
|
||||
}
|
||||
}
|
||||
return [$where, $params];
|
||||
}
|
||||
|
||||
public function get_stats(): array {
|
||||
|
||||
@@ -169,7 +169,7 @@ trait CreditsPaymentGroups {
|
||||
/**
|
||||
* 获取订单设置
|
||||
*
|
||||
* @return array{prefix:string,expiry:int,show_user:bool,show_paycount:bool,no_logged_in:bool,cookie_day:int,no_logged_remind:bool,wait_float:bool,order_max_minutes:int,mark:string,box_position:string,show_ip:bool,show_device:bool,show_order_no:bool,show_create_time:bool,show_pay_time:bool,status_texts:array,service:array}
|
||||
* @return array{prefix:string,flow_prefix:string,withdrawal_prefix:string,expiry:int,show_user:bool,show_paycount:bool,no_logged_in:bool,cookie_day:int,no_logged_remind:bool,wait_float:bool,order_max_minutes:int,mark:string,box_position:string,show_ip:bool,show_device:bool,show_order_no:bool,show_create_time:bool,show_pay_time:bool,status_texts:array,service:array}
|
||||
*/
|
||||
public static function get_order_settings(): array {
|
||||
return PaymentRuntime::get_order_settings();
|
||||
|
||||
@@ -369,6 +369,8 @@ class PaymentRuntime {
|
||||
|
||||
return [
|
||||
'prefix' => sanitize_text_field((string) ($s['payment_order_prefix'] ?? 'AKSRT')),
|
||||
'flow_prefix' => \AKSRT\Finance\BusinessNumber::normalize_prefix($s['payment_flow_prefix'] ?? 'AK', 'AK'),
|
||||
'withdrawal_prefix' => \AKSRT\Finance\BusinessNumber::normalize_prefix($s['payment_withdrawal_prefix'] ?? 'TX', 'TX'),
|
||||
'expiry' => $expiry,
|
||||
'show_user' => !isset($s['pay_show_user']) || !empty($s['pay_show_user']),
|
||||
'show_paycount' => !isset($s['pay_show_paycount']) || !empty($s['pay_show_paycount']),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use AKSRT\Finance\BusinessNumber;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class BusinessNumberTest extends TestCase {
|
||||
public function test_prefix_is_uppercased_filtered_and_limited(): void {
|
||||
self::assertSame('AK', BusinessNumber::normalize_prefix('', 'AK'));
|
||||
self::assertSame('TX_TEST-9', BusinessNumber::normalize_prefix(' tx_测test-9 ', 'TX'));
|
||||
self::assertSame('ABCDEFGHIJKL', BusinessNumber::normalize_prefix('abcdefghijklmnop', 'AK'));
|
||||
self::assertSame('TX', BusinessNumber::normalize_prefix('中文', 'TX'));
|
||||
}
|
||||
|
||||
public function test_candidate_uses_supplied_site_time_and_six_digit_random_suffix(): void {
|
||||
$time = new DateTimeImmutable('2026-08-25 13:14:15', new DateTimeZone('Asia/Shanghai'));
|
||||
self::assertSame('AK20260825131415000007', BusinessNumber::candidate('ak', $time, 7));
|
||||
self::assertMatchesRegularExpression('/^TX\d{14}\d{6}$/', BusinessNumber::candidate('TX', $time, 999999));
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ final class LedgerResultTest extends TestCase {
|
||||
public function test_balance_reads_integer_minor_units(): void {
|
||||
$result = new LedgerResult(17, true, [], [
|
||||
4 => ['points' => 25, 'balance' => 1234],
|
||||
]);
|
||||
], 'AK20260825123456000001');
|
||||
self::assertSame(25, $result->balance(4, AssetType::POINTS));
|
||||
self::assertSame(1234, $result->balance(4, AssetType::BALANCE));
|
||||
self::assertSame(0, $result->balance(99, AssetType::BALANCE));
|
||||
self::assertSame(17, $result->to_array()['transaction_id']);
|
||||
self::assertTrue($result->to_array()['replayed']);
|
||||
self::assertSame('AK20260825123456000001', $result->flow_number);
|
||||
self::assertSame('AK20260825123456000001', $result->to_array()['flow_number']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@ if (!function_exists('sanitize_key')) {
|
||||
return strtolower((string) preg_replace('/[^a-z0-9_\-]/', '', $key));
|
||||
}
|
||||
}
|
||||
if (!function_exists('sanitize_text_field')) {
|
||||
function sanitize_text_field($value): string {
|
||||
return trim(strip_tags((string) $value));
|
||||
}
|
||||
}
|
||||
if (!function_exists('wp_timezone')) {
|
||||
function wp_timezone(): DateTimeZone {
|
||||
return new DateTimeZone('UTC');
|
||||
}
|
||||
}
|
||||
if (!function_exists('wp_json_encode')) {
|
||||
function wp_json_encode($value, int $flags = 0) {
|
||||
return json_encode($value, $flags);
|
||||
@@ -59,3 +69,4 @@ require_once dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerCommand.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerResult.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/OperationKey.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Finance/BusinessNumber.php';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '../..')
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8')
|
||||
|
||||
test('financial flow and withdrawal numbers use the new-only schema', () => {
|
||||
const schema = read('src/Core/DBMigratorPart2.php')
|
||||
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_financial_flow \([\s\S]*?UNIQUE KEY uniq_flow_number \(flow_number\)/)
|
||||
assert.match(schema, /UNIQUE KEY uniq_flow_operation \(operation_key\)/)
|
||||
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_financial_flow_entry \([\s\S]*?UNIQUE KEY uniq_flow_line \(flow_id, line_no\)/)
|
||||
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_withdrawal \([\s\S]*?withdrawal_number VARCHAR\(32\) NOT NULL[\s\S]*?UNIQUE KEY uniq_withdrawal_number \(withdrawal_number\)/)
|
||||
})
|
||||
|
||||
test('order settings expose strict flow and withdrawal prefixes', () => {
|
||||
const schema = read('assets/admin/src/schema/sections/payment/order.js')
|
||||
const provider = read('src/Admin/SchemaProvider.php')
|
||||
assert.match(schema, /key:\s*'payment_flow_prefix'[^{\n]*sanitize_type:\s*'business_prefix'[^{\n]*default:\s*'AK'/)
|
||||
assert.match(schema, /key:\s*'payment_withdrawal_prefix'[^{\n]*sanitize_type:\s*'business_prefix'[^{\n]*default:\s*'TX'/)
|
||||
assert.match(provider, /business_prefix[\s\S]*?BusinessNumber::normalize_prefix/)
|
||||
})
|
||||
|
||||
test('ledger and business state write their flow before commit', () => {
|
||||
const ledger = read('src/Ledger/LedgerService.php')
|
||||
const order = read('src/Payment/OrderRepositoryCore.php')
|
||||
const withdrawal = read('src/Payment/Withdrawal.php')
|
||||
assert.ok(ledger.indexOf('record_ledger(') < ledger.indexOf("query('COMMIT')"))
|
||||
assert.match(order, /record_order_payment\(\$paid_order, true\)/)
|
||||
assert.match(order, /record_order_refund\(\$refunded_order, true\)/)
|
||||
assert.match(withdrawal, /record_withdrawal_payout\([\s\S]*?true[\s\S]*?query\('COMMIT'\)/)
|
||||
})
|
||||
|
||||
test('native and front-end surfaces expose immutable business numbers', () => {
|
||||
assert.match(read('src/Admin/Native/PayMenus.php'), /PayFlows::PAGE_SLUG/)
|
||||
assert.match(read('src/Admin/Native/PayFlows.php'), /流水明细/)
|
||||
assert.match(read('src/Admin/Native/PayOrders.php'), /支付流水号/)
|
||||
assert.match(read('src/Admin/Native/PayWithdrawals.php'), /提现单号/)
|
||||
assert.match(read('src/Payment/UserOrders.php'), /\$view\['flow_number'\]/)
|
||||
assert.match(read('assets/js/order-center.js'), /\["流水号", order\.flow_number \|\| "—"\]/)
|
||||
assert.match(read('inc/helpers/user-center-renderers/credits-wallet.php'), /withdrawal_number/)
|
||||
})
|
||||
@@ -24,6 +24,8 @@ global $wpdb;
|
||||
|
||||
// ── Drop custom tables ──
|
||||
$tables = [
|
||||
'aksrt_financial_flow_entry',
|
||||
'aksrt_financial_flow',
|
||||
'aksrt_asset_entry',
|
||||
'aksrt_asset_transaction',
|
||||
'aksrt_asset_account',
|
||||
|
||||
Reference in New Issue
Block a user