新增全局流水号

This commit is contained in:
2026-08-25 05:33:01 +08:00
parent 90fd59b647
commit 07c4e969c3
33 changed files with 1176 additions and 73 deletions
+1
View File
@@ -26,6 +26,7 @@
!/tests/widget-system/user-badges-contract.test.cjs !/tests/widget-system/user-badges-contract.test.cjs
!/tests/widget-system/user-features-contract.test.cjs !/tests/widget-system/user-features-contract.test.cjs
!/tests/widget-system/asset-ledger-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/
!/tests/asset-ledger/*.php !/tests/asset-ledger/*.php
!/tests/visual/docker-compose.zibll.yml !/tests/visual/docker-compose.zibll.yml
@@ -5,6 +5,8 @@ export const orderSchema = {
description: '配置订单前缀、有效期、展示字段和状态文案。', description: '配置订单前缀、有效期、展示字段和状态文案。',
defaults: { defaults: {
payment_order_prefix: 'AKSRT', payment_order_prefix: 'AKSRT',
payment_flow_prefix: 'AK',
payment_withdrawal_prefix: 'TX',
payment_order_expiry: 30, payment_order_expiry: 30,
pay_show_user: true, pay_show_user: true,
pay_show_paycount: true, pay_show_paycount: true,
@@ -38,6 +40,8 @@ export const orderSchema = {
icon: 'fa fas fa-cog', icon: 'fa fas fa-cog',
fields: [ fields: [
{ key: 'payment_order_prefix', label: '订单前缀', type: 'input', default: 'AKSRT' }, { 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: 'payment_order_expiry', label: '订单有效期(分钟)', type: 'number', default: 30, min: 5, max: 1440 },
{ key: 'pay_show_user', label: '显示用户信息', type: 'switch', default: true }, { key: 'pay_show_user', label: '显示用户信息', type: 'switch', default: true },
{ key: 'pay_show_paycount', label: '显示购买次数', type: 'switch', default: true }, { key: 'pay_show_paycount', label: '显示购买次数', type: 'switch', default: true },
+3 -1
View File
@@ -712,7 +712,9 @@
} }
var data = (r.json && r.json.data) ? r.json.data : r.json; var data = (r.json && r.json.data) ? r.json.data : r.json;
resetOperationKey(form); 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); closeModal(modal);
var panel = modal.closest ? modal.closest('.dashboard-panel') : null; var panel = modal.closest ? modal.closest('.dashboard-panel') : null;
panel = panel || document.getElementById('panel-credits'); panel = panel || document.getElementById('panel-credits');
+1 -1
View File
@@ -86,7 +86,7 @@
html += '<div><strong class="aksrt-order-detail__product-title">' + escapeHtml(title) + "</strong>"; 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 += '<span class="aksrt-order-detail__product-meta">' + escapeHtml(order.product_type_label || "") + "</span></div></div>";
html += '<dl class="aksrt-order-detail__list">'; 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 += "<div><dt>" + escapeHtml(row[0]) + "</dt><dd>" + escapeHtml(row[1] || "—") + "</dd></div>";
}); });
html += "</dl>"; 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> <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> <b class="em12"><?php echo esc_html($gross->formatted()); ?></b>
</div> </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"> <div class="muted-2-color em09 mt6">
<?php echo esc_html((string) ($wd['created_at'] ?? '')); ?> <?php echo esc_html((string) ($wd['created_at'] ?? '')); ?>
<?php if ($gross->minor() !== $net->minor()) : ?> <?php if ($gross->minor() !== $net->minor()) : ?>
+195
View File
@@ -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>' : '&mdash;') . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
if ($pages > 1) {
echo '<div class="tablenav"><div class="tablenav-pages">' . wp_kses_post((string) paginate_links([
'base' => add_query_arg('paged', '%#%'),
'format' => '',
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
'total' => $pages,
'current' => $page,
])) . '</div></div>';
}
echo '</div>';
}
/** @param array<string,string> $options */
private function render_select(string $name, string $value, string $empty_label, array $options): void {
echo '<select name="' . esc_attr($name) . '"><option value="">' . esc_html($empty_label) . '</option>';
foreach ($options as $key => $label) {
echo '<option value="' . esc_attr($key) . '"' . selected($value, $key, false) . '>' . esc_html($label) . '</option>';
}
echo '</select>';
}
/** @param array<int,array<string,mixed>> $entries */
private function entries_html(array $entries): string {
if ($entries === []) {
return '&mdash;';
}
$lines = [];
foreach ($entries as $entry) {
$user_id = (int) ($entry['user_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null;
$user_label = $user ? $user->display_name . ' (#' . $user_id . ')' : '#' . $user_id;
$asset = (string) ($entry['asset'] ?? '');
$delta = (int) ($entry['delta_minor'] ?? 0);
$amount = $asset === 'points'
? (string) abs($delta)
: \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs($delta))->decimal();
$asset_label = ['cash' => __('现金', 'aksrt'), 'balance' => __('余额', 'aksrt'), 'points' => __('积分', 'aksrt')][$asset] ?? $asset;
$lines[] = esc_html($user_label) . ' · ' . esc_html($asset_label) . ' <strong>' . ($delta > 0 ? '+' : '-') . esc_html($amount) . '</strong>';
}
return implode('<br>', $lines);
}
/** @param array<string,mixed> $flow */
private function reference_html(array $flow): string {
$lines = [];
if (!empty($flow['order_number'])) {
$url = add_query_arg(['page' => PayOrders::PAGE_SLUG, 'order' => (string) $flow['order_number']], admin_url('admin.php'));
$lines[] = esc_html__('订单', 'aksrt') . ' <a href="' . esc_url($url) . '"><code>' . esc_html((string) $flow['order_number']) . '</code></a>';
}
if (!empty($flow['withdrawal_number'])) {
$lines[] = esc_html__('提现', 'aksrt') . ' <code>' . esc_html((string) $flow['withdrawal_number']) . '</code>';
}
if ($lines === [] && !empty($flow['ref_key'])) {
$lines[] = esc_html((string) ($flow['ref_type'] ?? '')) . ' · ' . esc_html((string) $flow['ref_key']);
}
return $lines !== [] ? implode('<br>', $lines) : '&mdash;';
}
private function event_label(string $event_type): string {
$labels = [
'order_payment' => __('订单支付', 'aksrt'),
'order_refund' => __('订单退款', 'aksrt'),
'withdraw_hold' => __('提现冻结', 'aksrt'),
'withdraw_refund' => __('提现退回', 'aksrt'),
'withdraw_payout' => __('提现打款', 'aksrt'),
'creator_income' => __('创作收益', 'aksrt'),
'admin_adjustment' => __('后台调账', 'aksrt'),
'points_exchange' => __('积分兑换', 'aksrt'),
'transfer' => __('用户转账', 'aksrt'),
];
return $labels[$event_type] ?? $event_type;
}
private function sanitize_date(string $date): string {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : '';
}
private function date_bound_utc(string $date, bool $exclusive_end): string {
if ($date === '') {
return '';
}
$local = new \DateTimeImmutable($date . ' 00:00:00', wp_timezone());
if ($exclusive_end) {
$local = $local->modify('+1 day');
}
return $local->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
}
private function local_time(string $utc): string {
if ($utc === '') {
return '';
}
try {
return (new \DateTimeImmutable($utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone())->format('Y-m-d H:i:s');
} catch (\Exception $e) {
return $utc;
}
}
private function page_url(): string {
return add_query_arg(['page' => self::PAGE_SLUG], admin_url('admin.php'));
}
}
+7
View File
@@ -52,6 +52,13 @@ class PayMenus {
wp_die(esc_html__('订单模块未初始化。', 'aksrt')); wp_die(esc_html__('订单模块未初始化。', 'aksrt'));
}, },
], ],
'flows' => [
'slug' => PayFlows::PAGE_SLUG,
'title' => __('流水明细', 'aksrt'),
'callback' => static function (): void {
PayFlows::get_instance()->render_page();
},
],
'products' => [ 'products' => [
'slug' => 'aksrt_pay_products', 'slug' => 'aksrt_pay_products',
'title' => __('付费文章', 'aksrt'), 'title' => __('付费文章', 'aksrt'),
+37 -11
View File
@@ -79,6 +79,10 @@ class PayOrders {
} }
$items = $repo->get_orders_admin($args, $search); $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); $total = $repo->get_orders_count($args, $search);
$pages = max(1, (int) ceil($total / $per_page)); $pages = max(1, (int) ceil($total / $per_page));
$labels = class_exists('\AKSRT\Core\DomainModel') $labels = class_exists('\AKSRT\Core\DomainModel')
@@ -102,6 +106,7 @@ class PayOrders {
'user_id' => $user_id > 0 ? $user_id : null, 'user_id' => $user_id > 0 ? $user_id : null,
'payment_method' => $pay_method !== '' ? $pay_method : null, 'payment_method' => $pay_method !== '' ? $pay_method : null,
'status' => ($status !== '' && $status !== 'all') ? $status : null, 'status' => ($status !== '' && $status !== 'all') ? $status : null,
's' => $search !== '' ? $search : null,
])); ]));
$cls = ($product_type === $tkey) ? 'current' : ''; $cls = ($product_type === $tkey) ? 'current' : '';
$tab_bits[] = '<li><a class="' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($tlabel) . '</a></li>'; $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, 'user_id' => $user_id > 0 ? $user_id : null,
'payment_method' => $mkey !== '' ? $mkey : null, 'payment_method' => $mkey !== '' ? $mkey : null,
'status' => ($status !== '' && $status !== 'all') ? $status : null, 'status' => ($status !== '' && $status !== 'all') ? $status : null,
's' => $search !== '' ? $search : null,
])); ]));
$cls = ($pay_method === $mkey) ? 'current' : ''; $cls = ($pay_method === $mkey) ? 'current' : '';
$m_bits[] = '<li><a class="' . esc_attr($cls) . '" href="' . esc_url($url) . '">' . esc_html($mlabel) . '</a></li>'; $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 '</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="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); 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 !== '') {
$filter_bits = []; $filter_bits = [];
@@ -189,12 +195,12 @@ class PayOrders {
echo '<table class="wp-list-table widefat fixed striped">'; echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>'; 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 '<th>' . esc_html__('创建 / 支付时间', 'aksrt') . '</th>';
echo '<th>' . esc_html__('操作', 'aksrt') . '</th>'; echo '<th>' . esc_html__('操作', 'aksrt') . '</th>';
echo '</tr></thead><tbody>'; echo '</tr></thead><tbody>';
@@ -208,14 +214,22 @@ class PayOrders {
$user = $user_id > 0 ? get_userdata($user_id) : null; $user = $user_id > 0 ? get_userdata($user_id) : null;
$status_key = (string) ($row['status'] ?? ''); $status_key = (string) ($row['status'] ?? '');
$status_label = $labels[$status_key] ?? $status_key; $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 '<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>' : '&mdash;') . '</span></td>';
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</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><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>' . esc_html($this->cash_decimal((string) ($row['amount'] ?? '0'))) . ' ' . esc_html((string) ($row['currency'] ?? '')) . '</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($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 '<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>'; 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); $this->render_order_quick_status_buttons($order_number, $status_key);
@@ -262,6 +276,7 @@ class PayOrders {
? \AKSRT\Core\DomainModel::order_status_labels() ? \AKSRT\Core\DomainModel::order_status_labels()
: []; : [];
$context = $repo->get_admin_order_context($order_number); $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_id = (int) ($order['user_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null; $user = $user_id > 0 ? get_userdata($user_id) : null;
$status = (string) ($order['status'] ?? ''); $status = (string) ($order['status'] ?? '');
@@ -277,13 +292,14 @@ class PayOrders {
echo '<table class="widefat striped" style="max-width:960px;"><tbody>'; echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
$rows = [ $rows = [
__('订单号', 'aksrt') => $order_number, __('订单号', 'aksrt') => $order_number,
__('支付流水号', 'aksrt') => $flow_number !== '' ? $flow_number : '—',
__('用户', 'aksrt') => $user ? $user->display_name . ' (#' . $user_id . ')' : (string) $user_id, __('用户', 'aksrt') => $user ? $user->display_name . ' (#' . $user_id . ')' : (string) $user_id,
__('商品类型', 'aksrt') => (string) ($order['product_type'] ?? ''), __('商品类型', 'aksrt') => (string) ($order['product_type'] ?? ''),
__('商品 ID', 'aksrt') => (string) ($order['product_id'] ?? ''), __('商品 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') => $labels[$status] ?? $status,
__('支付方式', 'aksrt') => (string) ($order['payment_method'] ?? ''), __('支付方式', '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['created_at'] ?? ''),
__('支付时间', 'aksrt') => (string) ($order['paid_at'] ?? ''), __('支付时间', 'aksrt') => (string) ($order['paid_at'] ?? ''),
__('买家备注', 'aksrt') => $customer_note, __('买家备注', '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 { private function render_header(string $title): void {
echo '<div class="wrap">'; echo '<div class="wrap">';
echo '<h1>' . esc_html($title) . '</h1>'; echo '<h1>' . esc_html($title) . '</h1>';
+41 -21
View File
@@ -39,6 +39,10 @@ class PayWithdrawals {
} }
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; $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)); $page_num = max(1, (int) ($_GET['paged'] ?? 1));
$per_page = 20; $per_page = 20;
$svc = \AKSRT\Payment\Withdrawal::get_instance(); $svc = \AKSRT\Payment\Withdrawal::get_instance();
@@ -79,31 +83,47 @@ class PayWithdrawals {
); );
} }
echo '</select> '; 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); 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>'; echo '</form>';
$items = $svc->get_all([ $items = $svc->get_all([
'status' => $status, 'status' => $status,
'keyword' => $keyword,
'user_id' => $user_filter,
'date_from' => $date_from,
'date_to' => $date_to,
'limit' => $per_page, 'limit' => $per_page,
'offset' => ($page_num - 1) * $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)); $pages = max(1, (int) ceil($total / $per_page));
echo '<table class="wp-list-table widefat fixed striped">'; echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>'; 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 '<th>' . esc_html__('申请 / 处理时间', 'aksrt') . '</th>';
echo '<th>' . esc_html__('操作', 'aksrt') . '</th>'; echo '<th>' . esc_html__('操作', 'aksrt') . '</th>';
echo '</tr></thead><tbody>'; echo '</tr></thead><tbody>';
if (!$items) { 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) { foreach ($items as $row) {
@@ -115,25 +135,21 @@ class PayWithdrawals {
$net = (string) ($row['amount'] ?? '0.00'); $net = (string) ($row['amount'] ?? '0.00');
$fee = (int) ($row['fee_rate'] ?? 0); $fee = (int) ($row['fee_rate'] ?? 0);
$acct = (string) ($row['display_account'] ?? $row['account_info'] ?? ''); $acct = (string) ($row['display_account'] ?? $row['account_info'] ?? '');
$amount_txt = $net; $amount_txt = sprintf(
if ($gross !== $net) { /* translators: 1: gross 2: net 3: fee percent */
$amount_txt = sprintf( __('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'),
/* translators: 1: gross 2: net 3: fee percent */ $gross,
__('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'), $net,
$gross, $fee
$net, );
$fee
);
}
echo '<tr>'; 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>' . ($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($amount_txt) . '</td>';
echo '<td>' . esc_html((string) ($row['method'] ?? '')) . '</td>'; echo '<td><strong>' . esc_html((string) ($row['method'] ?? '')) . '</strong><br><span class="description">' . esc_html($acct) . '</span></td>';
echo '<td>' . esc_html($acct) . '</td>';
echo '<td>' . esc_html($status_options[$st] ?? $st) . '</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>'; echo '<td>';
$next_statuses = $st === 'pending' ? ['processing', 'rejected'] : ($st === 'processing' ? ['completed', 'rejected'] : []); $next_statuses = $st === 'pending' ? ['processing', 'rejected'] : ($st === 'processing' ? ['completed', 'rejected'] : []);
if ($next_statuses === []) { if ($next_statuses === []) {
@@ -258,6 +274,10 @@ class PayWithdrawals {
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php')); 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 { private function render_pagination(int $pages, int $page_num): void {
if ($pages <= 1) { if ($pages <= 1) {
return; return;
+2
View File
@@ -234,6 +234,8 @@ class SchemaPayment {
'groups' => [ 'groups' => [
['key' => 'basic', 'title' => '订单基础', 'fields' => [ ['key' => 'basic', 'title' => '订单基础', 'fields' => [
['key' => 'payment_order_prefix', 'label' => '订单前缀', 'type' => 'input', 'default' => 'AKSRT'], ['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' => 'payment_order_expiry', 'label' => '订单有效期(分钟)', 'type' => 'number', 'default' => 30, 'min' => 5, 'max' => 1440],
['key' => 'pay_show_user', 'label' => '显示用户信息', 'type' => 'switch', 'default' => true], ['key' => 'pay_show_user', 'label' => '显示用户信息', 'type' => 'switch', 'default' => true],
['key' => 'pay_show_paycount', 'label' => '显示购买次数', 'type' => 'switch', 'default' => true], ['key' => 'pay_show_paycount', 'label' => '显示购买次数', 'type' => 'switch', 'default' => true],
+3
View File
@@ -517,6 +517,9 @@ class SchemaProvider {
private function sanitize_field_value($value, array $field) { private function sanitize_field_value($value, array $field) {
$type = (string) ($field['type'] ?? 'text'); $type = (string) ($field['type'] ?? 'text');
$sanitize_type = (string) ($field['sanitize_type'] ?? ''); $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 !== '') { if ($sanitize_type !== '') {
return $this->sanitize_value($value, $sanitize_type); return $this->sanitize_value($value, $sanitize_type);
} }
+2
View File
@@ -265,6 +265,7 @@ trait PayOrders {
'pay_modo' => 'points', 'pay_modo' => 'points',
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS), 'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'transaction_id' => $deducted->transaction_id, 'transaction_id' => $deducted->transaction_id,
'flow_number' => $deducted->flow_number,
'replayed' => $deducted->replayed, 'replayed' => $deducted->replayed,
'message' => __('支付成功', 'aksrt'), 'message' => __('支付成功', 'aksrt'),
'reload' => true, 'reload' => true,
@@ -503,6 +504,7 @@ trait PayOrders {
'amount' => $amount_decimal, 'amount' => $amount_decimal,
'pay_modo' => '0', 'pay_modo' => '0',
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0), 'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
'replayed' => true, 'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), 'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('支付成功', 'aksrt'), 'message' => __('支付成功', 'aksrt'),
+2
View File
@@ -420,6 +420,7 @@ trait VipServicePurchase {
'days' => $days, 'days' => $days,
'action' => $action, 'action' => $action,
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0), 'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
'replayed' => true, 'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), 'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('会员购买已完成', 'aksrt'), 'message' => __('会员购买已完成', 'aksrt'),
@@ -878,6 +879,7 @@ trait VipServicePurchase {
'days' => $days, 'days' => $days,
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS), 'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'transaction_id' => $deducted->transaction_id, 'transaction_id' => $deducted->transaction_id,
'flow_number' => $deducted->flow_number,
'replayed' => $deducted->replayed, 'replayed' => $deducted->replayed,
'message' => __('兑换成功', 'aksrt'), 'message' => __('兑换成功', 'aksrt'),
'reload' => true, 'reload' => true,
+48
View File
@@ -70,6 +70,52 @@ trait DBMigratorPart2 {
) ENGINE=InnoDB {$charset_collate};"; ) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_asset_entry); 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. 签到 ---------- // ---------- 2. 签到 ----------
$sql_signin = "CREATE TABLE {$prefix}aksrt_signin_log ( $sql_signin = "CREATE TABLE {$prefix}aksrt_signin_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -188,6 +234,7 @@ trait DBMigratorPart2 {
// ---------- 10. 提现台账 ---------- // ---------- 10. 提现台账 ----------
$sql_withdrawal = "CREATE TABLE {$prefix}aksrt_withdrawal ( $sql_withdrawal = "CREATE TABLE {$prefix}aksrt_withdrawal (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
withdrawal_number VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL, user_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(10,2) NOT NULL DEFAULT 0, amount DECIMAL(10,2) NOT NULL DEFAULT 0,
gross_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, processed_at DATETIME DEFAULT NULL,
note TEXT DEFAULT NULL, note TEXT DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uniq_withdrawal_number (withdrawal_number),
UNIQUE KEY uniq_ledger_transaction (ledger_transaction_id), UNIQUE KEY uniq_ledger_transaction (ledger_transaction_id),
KEY idx_user (user_id), KEY idx_user (user_id),
KEY idx_user_created (user_id, created_at), KEY idx_user_created (user_id, created_at),
+2
View File
@@ -19,6 +19,8 @@ trait DBMigratorPart3 {
'asset_account' => 'aksrt_asset_account', 'asset_account' => 'aksrt_asset_account',
'asset_transaction' => 'aksrt_asset_transaction', 'asset_transaction' => 'aksrt_asset_transaction',
'asset_entry' => 'aksrt_asset_entry', 'asset_entry' => 'aksrt_asset_entry',
'financial_flow' => 'aksrt_financial_flow',
'financial_flow_entry' => 'aksrt_financial_flow_entry',
'signin_log' => 'aksrt_signin_log', 'signin_log' => 'aksrt_signin_log',
'dl_token' => 'aksrt_dl_token', 'dl_token' => 'aksrt_dl_token',
'notification' => 'aksrt_notification', 'notification' => 'aksrt_notification',
+36
View File
@@ -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);
}
}
+163
View File
@@ -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');
}
}
+400
View File
@@ -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');
}
}
+3 -1
View File
@@ -16,7 +16,8 @@ final class LedgerResult {
public readonly int $transaction_id, public readonly int $transaction_id,
public readonly bool $replayed, public readonly bool $replayed,
public readonly array $entries, 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 { public function balance(int $user_id, AssetType|string $asset): int {
@@ -31,6 +32,7 @@ final class LedgerResult {
'replayed' => $this->replayed, 'replayed' => $this->replayed,
'entries' => $this->entries, 'entries' => $this->entries,
'balances' => $this->balances, 'balances' => $this->balances,
'flow_number' => $this->flow_number,
]; ];
} }
} }
+12 -1
View File
@@ -409,6 +409,16 @@ final class LedgerService {
throw new \RuntimeException('ledger_business_mutation_failed'); 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) { if ($wpdb->query('COMMIT') === false) {
throw new \RuntimeException('ledger_commit_failed'); throw new \RuntimeException('ledger_commit_failed');
} }
@@ -479,7 +489,8 @@ final class LedgerService {
$balances[$entry['user_id']][(string) $entry['asset']] = $entry['balance_after_minor']; $balances[$entry['user_id']][(string) $entry['asset']] = $entry['balance_after_minor'];
} }
unset($entry); 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 { private function db_error(int $errno, string $detail = ''): \WP_Error {
+2
View File
@@ -112,6 +112,7 @@ class BalanceGateway implements GatewayInterface {
'success' => true, 'success' => true,
'transaction_id' => $transaction_code, 'transaction_id' => $transaction_code,
'ledger_transaction_id' => $result->transaction_id, 'ledger_transaction_id' => $result->transaction_id,
'flow_number' => $result->flow_number,
'replayed' => $result->replayed, 'replayed' => $result->replayed,
'order_committed' => true, 'order_committed' => true,
'message' => __('余额支付成功', 'aksrt'), 'message' => __('余额支付成功', 'aksrt'),
@@ -267,6 +268,7 @@ class BalanceGateway implements GatewayInterface {
'refund_id' => 'BAL_REF_' . $order_number, 'refund_id' => 'BAL_REF_' . $order_number,
'transaction_id' => $result->transaction_id, 'transaction_id' => $result->transaction_id,
'ledger_transaction_id' => $result->transaction_id, 'ledger_transaction_id' => $result->transaction_id,
'flow_number' => $result->flow_number,
'replayed' => $result->replayed, 'replayed' => $result->replayed,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), 'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('退款成功', 'aksrt'), 'message' => __('退款成功', 'aksrt'),
+7 -3
View File
@@ -376,20 +376,24 @@ trait OrderRepositoryAdmin {
} }
if ($search !== '') { 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)) { 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) . '%'; $like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like; $params[] = $like;
$params[] = $like; $params[] = $like;
$params[] = (int) $search; $params[] = (int) $search;
$params[] = (int) $search; $params[] = (int) $search;
$params[] = $like;
} else { } 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) . '%'; $like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like; $params[] = $like;
$params[] = $like; $params[] = $like;
$params[] = $like; $params[] = $like;
$params[] = $like;
} }
} }
+21 -1
View File
@@ -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; global $wpdb;
$ledger_active = class_exists('\AKSRT\Ledger\LedgerService')
&& \AKSRT\Ledger\LedgerService::get_instance()->transaction_active();
$valid = \AKSRT\Core\DomainModel::order_status_labels(); $valid = \AKSRT\Core\DomainModel::order_status_labels();
if (!isset($valid[$new_status])) return false; 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')) { if (class_exists('\AKSRT\Payment\CommissionLedger')) {
$commission = \AKSRT\Payment\CommissionLedger::get_instance()->record_order_transition( $commission = \AKSRT\Payment\CommissionLedger::get_instance()->record_order_transition(
$order_number, $order_number,
@@ -535,6 +544,17 @@ trait OrderRepositoryCore {
'args' => [$order_number, $paid_order], '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; return $result !== false;
+1
View File
@@ -131,6 +131,7 @@ class PaymentOrchestrator {
'redirect' => false, 'redirect' => false,
'transaction_id' => $transaction_id, 'transaction_id' => $transaction_id,
'ledger_transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0), 'ledger_transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
'flow_number' => (string) ($result['flow_number'] ?? ''),
'replayed' => !empty($result['replayed']), 'replayed' => !empty($result['replayed']),
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')), 'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
]; ];
+5
View File
@@ -226,6 +226,9 @@ class UserOrders {
])), ])),
]; ];
if ($detail) { 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['paid_at'] = (string) ($order['paid_at'] ?? '');
$view['expires_at'] = (string) ($order['expires_at'] ?? ''); $view['expires_at'] = (string) ($order['expires_at'] ?? '');
$view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false]; $view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false];
@@ -388,6 +391,7 @@ class UserOrders {
'order_number' => $order_number, 'order_number' => $order_number,
'order' => $this->get_order_detail_view($user_id, $order_number), 'order' => $this->get_order_detail_view($user_id, $order_number),
'transaction_id' => (int) $history['items'][0]['id'], 'transaction_id' => (int) $history['items'][0]['id'],
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
'replayed' => true, 'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), 'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('支付成功。', 'aksrt'), 'message' => __('支付成功。', 'aksrt'),
@@ -424,6 +428,7 @@ class UserOrders {
'order_number' => $order_number, 'order_number' => $order_number,
'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null, 'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null,
'transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0), 'transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
'flow_number' => (string) ($result['flow_number'] ?? ''),
'replayed' => !empty($result['replayed']), 'replayed' => !empty($result['replayed']),
'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null, 'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null,
'message' => (string) ($result['message'] ?? ''), 'message' => (string) ($result['message'] ?? ''),
+96 -31
View File
@@ -99,7 +99,7 @@ class Withdrawal {
/** /**
* Atomically validates quotas, freezes wallet funds, writes the ledger and creates an order. * 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) { public function create_balance_request(int $user_id, int $gross_minor, string $method, string $account, array $settings, string $operation_key) {
global $wpdb; global $wpdb;
@@ -115,6 +115,7 @@ class Withdrawal {
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00')); $window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
$usage = null; $usage = null;
$withdrawal_id = 0; $withdrawal_id = 0;
$withdrawal_number = '';
$guard = function () use ($user_id, $gross_minor, $settings, $window, &$usage) { $guard = function () use ($user_id, $gross_minor, $settings, $window, &$usage) {
global $wpdb; global $wpdb;
$wpdb->get_results($wpdb->prepare( $wpdb->get_results($wpdb->prepare(
@@ -137,7 +138,7 @@ class Withdrawal {
} }
return true; 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; global $wpdb;
$payload = wp_json_encode([ $payload = wp_json_encode([
'account' => $account, 'account' => $account,
@@ -146,16 +147,26 @@ class Withdrawal {
'source' => 'balance', 'source' => 'balance',
'operation_key' => $operation_key, 'operation_key' => $operation_key,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account; ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
$ok = $wpdb->insert($this->table(), [ $order_settings = \AKSRT\Settings\Settings::get_order_settings();
'user_id' => $user_id, $prefix = \AKSRT\Finance\BusinessNumber::normalize_prefix($order_settings['withdrawal_prefix'] ?? 'TX', 'TX');
'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(), $ok = false;
'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(), for ($attempt = 1; $attempt <= 5; $attempt++) {
'method' => sanitize_key($method), $withdrawal_number = \AKSRT\Finance\BusinessNumber::candidate($prefix);
'account_info' => $payload, $ok = $wpdb->insert($this->table(), [
'status' => self::STATUS_PENDING, 'withdrawal_number' => $withdrawal_number,
'ledger_transaction_id' => $transaction_id, 'user_id' => $user_id,
'created_at' => current_time('mysql'), 'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
], ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']); '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) { if ($ok !== 1) {
return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]); return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]);
} }
@@ -179,10 +190,12 @@ class Withdrawal {
return $result; return $result;
} }
if ($withdrawal_id <= 0) { if ($withdrawal_id <= 0) {
$withdrawal_id = (int) $wpdb->get_var($wpdb->prepare( $replayed_row = $wpdb->get_row($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE ledger_transaction_id = %d", "SELECT id, withdrawal_number FROM {$this->table()} WHERE ledger_transaction_id = %d",
$result->transaction_id $result->transaction_id
)); ), ARRAY_A);
$withdrawal_id = (int) ($replayed_row['id'] ?? 0);
$withdrawal_number = (string) ($replayed_row['withdrawal_number'] ?? '');
} }
if ($withdrawal_id <= 0) { if ($withdrawal_id <= 0) {
return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 500]); return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 500]);
@@ -197,9 +210,11 @@ class Withdrawal {
} }
return [ return [
'withdrawal_id' => $withdrawal_id, 'withdrawal_id' => $withdrawal_id,
'withdrawal_number' => $withdrawal_number,
'net_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(), '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(), '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, 'transaction_id' => $result->transaction_id,
'flow_number' => $result->flow_number,
'replayed' => $result->replayed, 'replayed' => $result->replayed,
'usage' => [ 'usage' => [
'count' => $usage['count'], 'count' => $usage['count'],
@@ -253,7 +268,7 @@ class Withdrawal {
* Status transition with balance side-effects. * Status transition with balance side-effects.
* Reject of balance-source holds refunds gross_amount once (idempotent). * 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 = '') { public function process_status_change(int $withdrawal_id, string $new_status, string $note = '', string $operation_key = '') {
$new_status = sanitize_key($new_status); $new_status = sanitize_key($new_status);
@@ -287,6 +302,8 @@ class Withdrawal {
} }
if (is_array($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'] = \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; return $preflight_replay;
} }
$transitions = [ $transitions = [
@@ -302,6 +319,7 @@ class Withdrawal {
$user_id = (int) ($row['user_id'] ?? 0); $user_id = (int) ($row['user_id'] ?? 0);
$ledger_result = null; $ledger_result = null;
$completed_flow_number = '';
$locked_row = null; $locked_row = null;
$locked_meta = []; $locked_meta = [];
$operation_replay = null; $operation_replay = null;
@@ -400,6 +418,19 @@ class Withdrawal {
} }
$locked = $lock_and_validate(); $locked = $lock_and_validate();
$updated = is_wp_error($locked) ? $locked : $update_locked(0); $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) { if (is_wp_error($updated) || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK'); $wpdb->query('ROLLBACK');
return is_wp_error($updated) ? $updated : new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]); return is_wp_error($updated) ? $updated : new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]);
@@ -429,6 +460,8 @@ class Withdrawal {
return [ return [
'transaction_id' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->transaction_id : 0, '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, 'replayed' => $replayed,
'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), 'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
]; ];
@@ -528,15 +561,7 @@ class Withdrawal {
*/ */
public function get_all(array $args = []): array { public function get_all(array $args = []): array {
global $wpdb; global $wpdb;
[$where, $params] = $this->build_admin_filters($args);
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0)); $offset = max(0, (int) ($args['offset'] ?? 0));
@@ -556,15 +581,55 @@ class Withdrawal {
public function get_count(array $args = []): int { public function get_count(array $args = []): int {
global $wpdb; 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'] ?? '')); $status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') { if (in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
return (int) $wpdb->get_var($wpdb->prepare( $where[] = 'status = %s';
"SELECT COUNT(*) FROM {$this->table()} WHERE status = %s", $params[] = $status;
$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 { public function get_stats(): array {
+1 -1
View File
@@ -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 { public static function get_order_settings(): array {
return PaymentRuntime::get_order_settings(); return PaymentRuntime::get_order_settings();
+2
View File
@@ -369,6 +369,8 @@ class PaymentRuntime {
return [ return [
'prefix' => sanitize_text_field((string) ($s['payment_order_prefix'] ?? 'AKSRT')), '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, 'expiry' => $expiry,
'show_user' => !isset($s['pay_show_user']) || !empty($s['pay_show_user']), 'show_user' => !isset($s['pay_show_user']) || !empty($s['pay_show_user']),
'show_paycount' => !isset($s['pay_show_paycount']) || !empty($s['pay_show_paycount']), 'show_paycount' => !isset($s['pay_show_paycount']) || !empty($s['pay_show_paycount']),
+19
View File
@@ -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));
}
}
+3 -1
View File
@@ -8,11 +8,13 @@ final class LedgerResultTest extends TestCase {
public function test_balance_reads_integer_minor_units(): void { public function test_balance_reads_integer_minor_units(): void {
$result = new LedgerResult(17, true, [], [ $result = new LedgerResult(17, true, [], [
4 => ['points' => 25, 'balance' => 1234], 4 => ['points' => 25, 'balance' => 1234],
]); ], 'AK20260825123456000001');
self::assertSame(25, $result->balance(4, AssetType::POINTS)); self::assertSame(25, $result->balance(4, AssetType::POINTS));
self::assertSame(1234, $result->balance(4, AssetType::BALANCE)); self::assertSame(1234, $result->balance(4, AssetType::BALANCE));
self::assertSame(0, $result->balance(99, AssetType::BALANCE)); self::assertSame(0, $result->balance(99, AssetType::BALANCE));
self::assertSame(17, $result->to_array()['transaction_id']); self::assertSame(17, $result->to_array()['transaction_id']);
self::assertTrue($result->to_array()['replayed']); self::assertTrue($result->to_array()['replayed']);
self::assertSame('AK20260825123456000001', $result->flow_number);
self::assertSame('AK20260825123456000001', $result->to_array()['flow_number']);
} }
} }
+11
View File
@@ -12,6 +12,16 @@ if (!function_exists('sanitize_key')) {
return strtolower((string) preg_replace('/[^a-z0-9_\-]/', '', $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')) { if (!function_exists('wp_json_encode')) {
function wp_json_encode($value, int $flags = 0) { function wp_json_encode($value, int $flags = 0) {
return json_encode($value, $flags); 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/LedgerCommand.php';
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerResult.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/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/)
})
+2
View File
@@ -24,6 +24,8 @@ global $wpdb;
// ── Drop custom tables ── // ── Drop custom tables ──
$tables = [ $tables = [
'aksrt_financial_flow_entry',
'aksrt_financial_flow',
'aksrt_asset_entry', 'aksrt_asset_entry',
'aksrt_asset_transaction', 'aksrt_asset_transaction',
'aksrt_asset_account', 'aksrt_asset_account',