diff --git a/.gitignore b/.gitignore index 7b3eb20c..78c0ee49 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/assets/admin/src/schema/sections/payment/order.js b/assets/admin/src/schema/sections/payment/order.js index 0f343696..520d5639 100644 --- a/assets/admin/src/schema/sections/payment/order.js +++ b/assets/admin/src/schema/sections/payment/order.js @@ -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 }, diff --git a/assets/js/credits.js b/assets/js/credits.js index fe5e911a..d084297d 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -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'); diff --git a/assets/js/order-center.js b/assets/js/order-center.js index f3e4a0ee..e23c380c 100644 --- a/assets/js/order-center.js +++ b/assets/js/order-center.js @@ -86,7 +86,7 @@ html += '
' + escapeHtml(title) + ""; html += '' + escapeHtml(order.product_type_label || "") + "
"; html += '
'; - [["订单编号", 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 += "
" + escapeHtml(row[0]) + "
" + escapeHtml(row[1] || "—") + "
"; }); html += "
"; diff --git a/inc/helpers/user-center-renderers/credits-wallet.php b/inc/helpers/user-center-renderers/credits-wallet.php index cdc2b5cd..426111a8 100644 --- a/inc/helpers/user-center-renderers/credits-wallet.php +++ b/inc/helpers/user-center-renderers/credits-wallet.php @@ -213,6 +213,7 @@ endif; ?> formatted()); ?> +
minor() !== $net->minor()) : ?> diff --git a/src/Admin/Native/PayFlows.php b/src/Admin/Native/PayFlows.php new file mode 100644 index 00000000..f3b55732 --- /dev/null +++ b/src/Admin/Native/PayFlows.php @@ -0,0 +1,195 @@ +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 '

' . esc_html__('流水明细', 'aksrt') . '

'; + echo '

' . esc_html__('仅展示已经确认发生的现金、余额和积分变动;流水不可编辑。', 'aksrt') . '

'; + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + $this->render_select('asset', $asset, __('全部资产', 'aksrt'), ['cash' => __('现金', 'aksrt'), 'balance' => __('余额', 'aksrt'), 'points' => __('积分', 'aksrt')]); + $this->render_select('direction', $direction, __('全部方向', 'aksrt'), ['in' => __('收入', 'aksrt'), 'out' => __('支出', 'aksrt')]); + echo ''; + echo ''; + submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + if ($keyword !== '' || $event_type !== '' || $asset !== '' || $direction !== '' || $user_id > 0 || $date_from !== '' || $date_to !== '') { + echo '' . esc_html__('清除条件', 'aksrt') . ''; + } + echo '
'; + echo '

' . esc_html(sprintf(__('共 %d 条流水', 'aksrt'), (int) $result['total'])) . '

'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + if ($result['items'] === []) { + echo ''; + } + foreach ($result['items'] as $flow) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
' . esc_html__('流水号 / 时间', 'aksrt') . '' . esc_html__('事件', 'aksrt') . '' . esc_html__('用户与分录', 'aksrt') . '' . esc_html__('关联业务', 'aksrt') . '' . esc_html__('渠道交易号', 'aksrt') . '
' . esc_html__('暂无匹配流水', 'aksrt') . '
' . esc_html((string) $flow['flow_number']) . '
' . esc_html($this->local_time((string) $flow['created_at'])) . '
' . esc_html($this->event_label((string) $flow['event_type'])) . '
' . esc_html((string) $flow['event_type']) . '
' . $this->entries_html((array) $flow['entries']) . '' . $this->reference_html($flow) . '' . ((string) ($flow['provider_transaction_id'] ?? '') !== '' ? '' . esc_html((string) $flow['provider_transaction_id']) . '' : '—') . '
'; + if ($pages > 1) { + echo '
' . wp_kses_post((string) paginate_links([ + 'base' => add_query_arg('paged', '%#%'), + 'format' => '', + 'prev_text' => '«', + 'next_text' => '»', + 'total' => $pages, + 'current' => $page, + ])) . '
'; + } + echo '
'; + } + + /** @param array $options */ + private function render_select(string $name, string $value, string $empty_label, array $options): void { + echo ''; + } + + /** @param array> $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) . ' ' . ($delta > 0 ? '+' : '-') . esc_html($amount) . ''; + } + return implode('
', $lines); + } + + /** @param array $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') . ' ' . esc_html((string) $flow['order_number']) . ''; + } + if (!empty($flow['withdrawal_number'])) { + $lines[] = esc_html__('提现', 'aksrt') . ' ' . esc_html((string) $flow['withdrawal_number']) . ''; + } + if ($lines === [] && !empty($flow['ref_key'])) { + $lines[] = esc_html((string) ($flow['ref_type'] ?? '')) . ' · ' . esc_html((string) $flow['ref_key']); + } + return $lines !== [] ? implode('
', $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')); + } +} diff --git a/src/Admin/Native/PayMenus.php b/src/Admin/Native/PayMenus.php index 8ef71572..ae565855 100644 --- a/src/Admin/Native/PayMenus.php +++ b/src/Admin/Native/PayMenus.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'), diff --git a/src/Admin/Native/PayOrders.php b/src/Admin/Native/PayOrders.php index b17b173b..99cea72f 100644 --- a/src/Admin/Native/PayOrders.php +++ b/src/Admin/Native/PayOrders.php @@ -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[] = '
  • ' . esc_html($tlabel) . '
  • '; @@ -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[] = '
  • ' . esc_html($mlabel) . '
  • '; @@ -156,7 +162,7 @@ class PayOrders { } echo ' '; echo ' '; - echo ' '; + echo ' '; 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 ''; echo ''; - echo ''; + echo ''; echo ''; - echo ''; - echo ''; + echo ''; + echo ''; echo ''; - echo ''; + echo ''; echo ''; echo ''; @@ -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 ''; - echo ''; + echo ''; echo ''; - echo ''; - echo ''; + echo ''; + echo ''; echo ''; - echo ''; + echo ''; echo '
    ' . esc_html__('订单号', 'aksrt') . '' . esc_html__('订单号 / 支付流水号', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('类型', 'aksrt') . '' . esc_html__('金额', 'aksrt') . '' . esc_html__('商品信息', 'aksrt') . '' . esc_html__('金额 / 支付方式', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('时间', 'aksrt') . '' . esc_html__('创建 / 支付时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
    ' . esc_html($order_number) . '' . esc_html($order_number) . '
    ' . esc_html__('流水:', 'aksrt') . ($flow_number !== '' ? '' . esc_html($flow_number) . '' : '—') . '
    ' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '' . esc_html((string) ($row['product_type'] ?? '')) . '' . esc_html($this->cash_decimal((string) ($row['amount'] ?? '0'))) . ' ' . esc_html((string) ($row['currency'] ?? '')) . '' . esc_html($product_title !== '' ? $product_title : $product_type) . '
    ' . esc_html($product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '
    ' . esc_html($this->order_amount($row)) . '
    ' . esc_html((string) ($row['payment_method'] ?? '')) . '
    ' . esc_html($status_label) . '' . esc_html((string) ($row['created_at'] ?? '')) . '' . esc_html((string) ($row['created_at'] ?? '')) . '
    ' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '
    '; echo '' . esc_html__('详情', 'aksrt') . ''; $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 ''; $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 $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 '
    '; echo '

    ' . esc_html($title) . '

    '; diff --git a/src/Admin/Native/PayWithdrawals.php b/src/Admin/Native/PayWithdrawals.php index e1d391df..4cd08eb2 100644 --- a/src/Admin/Native/PayWithdrawals.php +++ b/src/Admin/Native/PayWithdrawals.php @@ -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 ' '; + echo ' '; + echo ' '; + echo ' '; + echo ' '; submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + if ($status !== '' || $keyword !== '' || $user_filter > 0 || $date_from !== '' || $date_to !== '') { + echo ' ' . esc_html__('清除条件', 'aksrt') . ''; + } echo ''; $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 '
    '; echo ''; - echo ''; + echo ''; echo ''; - echo ''; - echo ''; - echo ''; + echo ''; + echo ''; echo ''; - echo ''; + echo ''; echo ''; echo ''; if (!$items) { - echo ''; + echo ''; } 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 ''; - echo ''; + echo ''; echo ''; echo ''; - echo ''; - echo ''; + echo ''; echo ''; - echo ''; + echo ''; echo '
    ID' . esc_html__('提现单号', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('金额', 'aksrt') . '' . esc_html__('方式', 'aksrt') . '' . esc_html__('账户', 'aksrt') . '' . esc_html__('金额明细', 'aksrt') . '' . esc_html__('渠道 / 账户', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('申请时间', 'aksrt') . '' . esc_html__('申请 / 处理时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
    ' . esc_html__('暂无提现记录', 'aksrt') . '
    ' . esc_html__('暂无提现记录', 'aksrt') . '
    ' . esc_html((string) $id) . '' . esc_html((string) ($row['withdrawal_number'] ?? '')) . '' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '' . esc_html($amount_txt) . '' . esc_html((string) ($row['method'] ?? '')) . '' . esc_html($acct) . '' . esc_html((string) ($row['method'] ?? '')) . '
    ' . esc_html($acct) . '
    ' . esc_html($status_options[$st] ?? $st) . '' . esc_html((string) ($row['created_at'] ?? '')) . '' . esc_html((string) ($row['created_at'] ?? '')) . '
    ' . esc_html((string) ($row['processed_at'] ?? '') ?: '—') . '
    '; $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; diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php index 6bdc0b6a..807afe92 100644 --- a/src/Admin/Schema/SchemaPayment.php +++ b/src/Admin/Schema/SchemaPayment.php @@ -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], diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php index 16eaf507..09586a7e 100644 --- a/src/Admin/SchemaProvider.php +++ b/src/Admin/SchemaProvider.php @@ -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); } diff --git a/src/Content/PayOrders.php b/src/Content/PayOrders.php index ccbf2430..f0d0e4f8 100644 --- a/src/Content/PayOrders.php +++ b/src/Content/PayOrders.php @@ -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'), diff --git a/src/Content/VipServicePurchase.php b/src/Content/VipServicePurchase.php index ec5ee5e1..6cac71d0 100644 --- a/src/Content/VipServicePurchase.php +++ b/src/Content/VipServicePurchase.php @@ -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, diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php index 11fe5fd5..60911fbf 100644 --- a/src/Core/DBMigratorPart2.php +++ b/src/Core/DBMigratorPart2.php @@ -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), diff --git a/src/Core/DBMigratorPart3.php b/src/Core/DBMigratorPart3.php index 3ffbd9de..31738354 100644 --- a/src/Core/DBMigratorPart3.php +++ b/src/Core/DBMigratorPart3.php @@ -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', diff --git a/src/Finance/BusinessNumber.php b/src/Finance/BusinessNumber.php new file mode 100644 index 00000000..5d6fda3a --- /dev/null +++ b/src/Finance/BusinessNumber.php @@ -0,0 +1,36 @@ +format('YmdHis') . str_pad((string) $random, 6, '0', STR_PAD_LEFT); + } +} diff --git a/src/Finance/FinancialFlowQuery.php b/src/Finance/FinancialFlowQuery.php new file mode 100644 index 00000000..b580f699 --- /dev/null +++ b/src/Finance/FinancialFlowQuery.php @@ -0,0 +1,163 @@ +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 $order_numbers @return array */ + 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 $args + * @return array{items:array>,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 */ + 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 $flow_ids @return array>> */ + 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'); + } +} diff --git a/src/Finance/FinancialFlowService.php b/src/Finance/FinancialFlowService.php new file mode 100644 index 00000000..8d919e75 --- /dev/null +++ b/src/Finance/FinancialFlowService.php @@ -0,0 +1,400 @@ +> $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 $flow + * @param array> $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> $entries + * @return array>|\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 */ + 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 $order @param array> $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 $order @param array> $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'); + } + +} diff --git a/src/Ledger/LedgerResult.php b/src/Ledger/LedgerResult.php index 7c98908c..c5c4a35d 100644 --- a/src/Ledger/LedgerResult.php +++ b/src/Ledger/LedgerResult.php @@ -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, ]; } } diff --git a/src/Ledger/LedgerService.php b/src/Ledger/LedgerService.php index 3fc09793..7ef09b6b 100644 --- a/src/Ledger/LedgerService.php +++ b/src/Ledger/LedgerService.php @@ -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 { diff --git a/src/Payment/BalanceGateway.php b/src/Payment/BalanceGateway.php index f7479f60..5cf87d4e 100644 --- a/src/Payment/BalanceGateway.php +++ b/src/Payment/BalanceGateway.php @@ -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'), diff --git a/src/Payment/OrderRepositoryAdmin.php b/src/Payment/OrderRepositoryAdmin.php index 5b6f8d47..c4800ce0 100644 --- a/src/Payment/OrderRepositoryAdmin.php +++ b/src/Payment/OrderRepositoryAdmin.php @@ -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; } } diff --git a/src/Payment/OrderRepositoryCore.php b/src/Payment/OrderRepositoryCore.php index c217eca7..9cc08cd8 100644 --- a/src/Payment/OrderRepositoryCore.php +++ b/src/Payment/OrderRepositoryCore.php @@ -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; diff --git a/src/Payment/PaymentOrchestrator.php b/src/Payment/PaymentOrchestrator.php index c0aec1f0..fdde7276 100644 --- a/src/Payment/PaymentOrchestrator.php +++ b/src/Payment/PaymentOrchestrator.php @@ -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')), ]; diff --git a/src/Payment/UserOrders.php b/src/Payment/UserOrders.php index 9debc361..b4211273 100644 --- a/src/Payment/UserOrders.php +++ b/src/Payment/UserOrders.php @@ -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'] ?? ''), diff --git a/src/Payment/Withdrawal.php b/src/Payment/Withdrawal.php index 03211d09..5181b1be 100644 --- a/src/Payment/Withdrawal.php +++ b/src/Payment/Withdrawal.php @@ -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,1:array} */ + 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 { diff --git a/src/Settings/CreditsPaymentGroups.php b/src/Settings/CreditsPaymentGroups.php index b704789b..2e193edb 100644 --- a/src/Settings/CreditsPaymentGroups.php +++ b/src/Settings/CreditsPaymentGroups.php @@ -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(); diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index 0e0f1e1d..1961dc81 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -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']), diff --git a/tests/asset-ledger/BusinessNumberTest.php b/tests/asset-ledger/BusinessNumberTest.php new file mode 100644 index 00000000..f67ada85 --- /dev/null +++ b/tests/asset-ledger/BusinessNumberTest.php @@ -0,0 +1,19 @@ + ['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']); } } diff --git a/tests/asset-ledger/bootstrap.php b/tests/asset-ledger/bootstrap.php index 9b2db01f..3d8a6668 100644 --- a/tests/asset-ledger/bootstrap.php +++ b/tests/asset-ledger/bootstrap.php @@ -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'; diff --git a/tests/widget-system/financial-flow-contract.test.cjs b/tests/widget-system/financial-flow-contract.test.cjs new file mode 100644 index 00000000..d2166683 --- /dev/null +++ b/tests/widget-system/financial-flow-contract.test.cjs @@ -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/) +}) diff --git a/uninstall.php b/uninstall.php index 8abf6a69..5a2660db 100644 --- a/uninstall.php +++ b/uninstall.php @@ -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',