feat(admin): rebuild the payment center overview with ECharts analytics

- Overview page: realtime today vs. same time yesterday, 6 KPI cards with
  period-over-period deltas, trend (paid / net / refund curves + order count
  line), category curves, payment method donut, top 10 paid content,
  conversion / close / refund rates, todos and shortcuts
- Date presets, custom range and category filter via
  GET aksrt/v1/admin/pay-overview (manage_options + wp_rest nonce)
- Metrics: paid amount by paid date including later-refunded orders, refunds
  by refund date, net = paid - refund; CNY only, points and recharge excluded,
  foreign-currency orders listed separately; integer cents internally
- Daily records cached per month in transients (today/yesterday live),
  invalidated on order status change and on purge
- ECharts 6.1.0 subset bundled to assets/js/runtime/libs/echarts-pay.min.js
  (npm run build:pay-charts), loaded only on the overview page
- Split payment-attempt methods out of OrderRepositoryCore into
  OrderRepositoryAttempts; remove dead get_stats(), cash_decimal(),
  Order::get_stats() and the old .aksrt-native-links styles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-11-01 00:22:44 +08:00
1 parent d98f8d7975
commit 9805a371d8
19 files changed
+2203 -405

No files matched your search

+1
View File
@@ -185,6 +185,7 @@ class Admin {
PermissionsController::class,
UserController::class,
PaymentController::class,
PayOverviewController::class,
ToolsController::class,
ImportExportController::class,
ExtensionController::class,
+29 -63
View File
@@ -27,23 +27,28 @@ class PayMenus {
}
NativeUi::boot();
PayOverview::get_instance()->init();
add_action('admin_menu', [$this, 'register_menus']);
add_action('admin_notices', [$this, 'pending_notices']);
// Domain admin_post handlers are registered by dedicated modules.
}
/**
* @return array<string, array{slug:string,title:string,callback:callable}>
* @return array<string, array{slug:string,title:string,icon:string,desc:string,callback:callable}>
*/
private function pages(): array {
return [
'overview' => [
'slug' => self::MENU_SLUG,
'icon' => 'dashicons-cart',
'desc' => '',
'title' => __('概览', 'aksrt'),
'callback' => [$this, 'render_overview_page'],
],
'orders' => [
'slug' => 'aksrt_orders',
'icon' => 'dashicons-list-view',
'desc' => __('收款确认、关闭与退款标记', 'aksrt'),
'title' => __('订单明细', 'aksrt'),
'callback' => static function (): void {
if (class_exists('AKSRT\Admin\Native\PayOrders')) {
@@ -55,6 +60,8 @@ class PayMenus {
],
'products' => [
'slug' => 'aksrt_pay_products',
'icon' => 'dashicons-media-document',
'desc' => __('付费文章定价与销量', 'aksrt'),
'title' => __('付费文章', 'aksrt'),
'callback' => static function (): void {
if (class_exists('AKSRT\\Admin\\Native\\PayProducts')) {
@@ -66,6 +73,8 @@ class PayMenus {
],
'coupons' => [
'slug' => 'aksrt_coupons',
'icon' => 'dashicons-tickets-alt',
'desc' => __('创建与批量生成优惠码', 'aksrt'),
'title' => __('优惠码', 'aksrt'),
'callback' => static function (): void {
if (class_exists('AKSRT\\Admin\\Native\\PayCoupons')) {
@@ -77,6 +86,8 @@ class PayMenus {
],
'charge_cards' => [
'slug' => 'aksrt_charge_cards',
'icon' => 'dashicons-id-alt',
'desc' => __('充值卡密生成与导出', 'aksrt'),
'title' => __('卡密管理', 'aksrt'),
'callback' => static function (): void {
if (class_exists('AKSRT\\Admin\\Native\\PayChargeCards')) {
@@ -88,6 +99,8 @@ class PayMenus {
],
'income' => [
'slug' => 'aksrt_income',
'icon' => 'dashicons-chart-pie',
'desc' => __('创作者分成结算', 'aksrt'),
'title' => __('创作分成', 'aksrt'),
'callback' => static function (): void {
if (class_exists('AKSRT\\Admin\\Native\\PayIncome')) {
@@ -99,6 +112,8 @@ class PayMenus {
],
'withdraw' => [
'slug' => 'aksrt_withdraw',
'icon' => 'dashicons-money-alt',
'desc' => __('提现申请审核与打款', 'aksrt'),
'title' => __('提现审核', 'aksrt'),
'callback' => static function (): void {
if (class_exists('AKSRT\\Admin\\Native\\PayWithdrawals')) {
@@ -164,72 +179,23 @@ class PayMenus {
}
}
private function ensure_cap(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
}
private function page_url(string $slug, array $args = []): string {
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
}
/** 快捷入口图标,键同 pages()。 */
private const PAGE_ICONS = [
'orders' => 'dashicons-list-view',
'products' => 'dashicons-media-document',
'coupons' => 'dashicons-tickets-alt',
'charge_cards' => 'dashicons-id-alt',
'income' => 'dashicons-chart-pie',
'withdraw' => 'dashicons-money-alt',
];
public function render_overview_page(): void {
$this->ensure_cap();
$order_stats = class_exists('\AKSRT\Payment\OrderRepository')
? \AKSRT\Payment\OrderRepository::get_instance()->get_stats()
: [];
$withdraw_stats = class_exists('\AKSRT\Payment\Withdrawal')
? \AKSRT\Payment\Withdrawal::get_instance()->get_stats()
: [];
$card_stats = class_exists('\AKSRT\Payment\ChargeCard')
? \AKSRT\Payment\ChargeCard::get_instance()->get_stats()
: [];
NativeUi::page_open();
NativeUi::header(
__('支付中心', 'aksrt'),
__('运营数据入口。配置项仍在 AKSRT 主题设置 SPA。', 'aksrt'),
'dashicons-cart',
[[
'label' => __('打开支付配置(SPA)', 'aksrt'),
'url' => admin_url('admin.php?page=aksrt-settings#/payment/gateway'),
'icon' => 'dashicons-admin-generic',
]]
);
NativeUi::stats([
['label' => __('订单总数', 'aksrt'), 'value' => (int) ($order_stats['total'] ?? 0), 'url' => $this->page_url('aksrt_orders'), 'icon' => 'dashicons-list-view', 'tone' => 'info'],
['label' => __('待支付', 'aksrt'), 'value' => (int) ($order_stats['pending'] ?? 0), 'url' => $this->page_url('aksrt_orders', ['status' => 'pending']), 'icon' => 'dashicons-clock', 'tone' => 'warning'],
['label' => __('已支付', 'aksrt'), 'value' => (int) ($order_stats['paid'] ?? 0), 'url' => $this->page_url('aksrt_orders', ['status' => 'paid']), 'icon' => 'dashicons-yes-alt', 'tone' => 'success'],
['label' => __('营收', 'aksrt'), 'value' => (string) ($order_stats['revenue'] ?? '0.00'), 'url' => $this->page_url('aksrt_orders'), 'icon' => 'dashicons-chart-line', 'tone' => 'success'],
['label' => __('待审提现', 'aksrt'), 'value' => (int) ($withdraw_stats['pending'] ?? 0), 'url' => $this->page_url('aksrt_withdraw', ['status' => 'pending']), 'icon' => 'dashicons-money-alt', 'tone' => 'danger'],
['label' => __('可用卡密', 'aksrt'), 'value' => (int) ($card_stats['active'] ?? 0), 'url' => $this->page_url('aksrt_charge_cards', ['status' => 'active']), 'icon' => 'dashicons-id-alt', 'tone' => 'info'],
]);
NativeUi::card_open(__('快捷入口', 'aksrt'));
echo '<ul class="aksrt-native-links">';
foreach ($this->pages() as $key => $page) {
/**
* 概览页快捷入口:除概览外的子页面。
*
* @return array<int, array{slug:string,title:string,icon:string,desc:string}>
*/
public function shortcuts(): array {
$items = [];
foreach ($this->pages() as $page) {
if ($page['slug'] === self::MENU_SLUG) {
continue;
}
$icon = self::PAGE_ICONS[$key] ?? 'dashicons-admin-links';
echo '<li><a href="' . esc_url($this->page_url($page['slug'])) . '"><span class="dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span>' . esc_html($page['title']) . '</a></li>';
$items[] = ['slug' => $page['slug'], 'title' => $page['title'], 'icon' => $page['icon'], 'desc' => $page['desc']];
}
echo '</ul>';
NativeUi::card_close();
return $items;
}
NativeUi::page_close();
public function render_overview_page(): void {
PayOverview::get_instance()->render_page();
}
}
+325
View File
@@ -0,0 +1,325 @@
<?php
/**
* 支付中心「概览」:今日实时、区间指标卡、成交趋势、分类成交、支付方式、转化与退款、热销排行、待办与快捷入口。
*
* 统计口径见 \AKSRT\Payment\OrderAnalytics。首屏报表(近 30 天)随页面输出为 JSON,
* 之后切换区间 / 分类走 REST(aksrt/v1/admin/pay-overview,见 \AKSRT\Admin\PayOverviewController)。
* 图表用按需打包的 ECharts(assets/js/runtime/libs/echarts-pay.min.js,tools/build-pay-charts.mjs 生成),
* 绘制逻辑在 assets/js/admin-pay-overview.js,样式在 assets/css/admin/pay-overview.css。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
use AKSRT\Payment\OrderAnalytics;
use AKSRT\Payment\OrderAnalyticsDaily;
defined('ABSPATH') || exit;
final class PayOverview {
private const DEFAULT_DAYS = 30;
private const DATA_ID = 'aksrt-pay-overview-data';
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
}
public function enqueue_assets(): void {
$page = isset($_GET['page']) ? sanitize_key((string) wp_unslash($_GET['page'])) : '';
if ($page !== PayMenus::MENU_SLUG) {
return;
}
$lib = '/assets/js/runtime/libs/echarts-pay.min.js';
$css = '/assets/css/admin/pay-overview.css';
$js = '/assets/js/admin-pay-overview.js';
wp_enqueue_style('aksrt-admin-pay-overview', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css));
wp_enqueue_script('aksrt-echarts-pay', AKSRT_URI . $lib, [], (string) filemtime(AKSRT_DIR . $lib), true);
wp_enqueue_script('aksrt-admin-pay-overview', AKSRT_URI . $js, ['aksrt-echarts-pay'], (string) filemtime(AKSRT_DIR . $js), true);
}
public function render_page(): void {
if (!current_user_can(PayMenus::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
$analytics = OrderAnalytics::get_instance();
$today = current_time('Y-m-d');
$from = OrderAnalyticsDaily::shift($today, -(self::DEFAULT_DAYS - 1));
$report = $analytics->report($from, $today, '');
$realtime = $analytics->today_vs_yesterday();
$symbol = $this->currency_symbol();
NativeUi::page_open('aksrt-po-page');
NativeUi::header(
__('支付中心', 'aksrt'),
__('成交走势、收入结构与待办一览。收款渠道、价格策略等配置在主题设置中修改。', 'aksrt'),
'dashicons-cart',
[
['label' => __('订单明细', 'aksrt'), 'url' => $this->page_url('aksrt_orders'), 'icon' => 'dashicons-list-view'],
['label' => __('支付配置', 'aksrt'), 'url' => admin_url('admin.php?page=aksrt-settings#/payment/gateway'), 'icon' => 'dashicons-admin-generic', 'primary' => true],
]
);
$this->render_realtime($realtime, $symbol);
$this->render_toolbar($from, $today);
echo '<div class="aksrt-po-notice" data-aksrt-po-excluded hidden></div>';
echo '<div class="aksrt-po-error notice notice-error" data-aksrt-po-error hidden><p></p></div>';
$this->render_kpis();
echo '<div class="aksrt-po-body" data-aksrt-po-body>';
echo '<div class="aksrt-po-grid aksrt-po-grid--main">';
$this->chart_card('trend', __('成交趋势', 'aksrt'), __('支付金额、净收入、退款金额曲线与支付订单数折线', 'aksrt'), 'lg');
$this->render_funnel_card();
echo '</div>';
echo '<div class="aksrt-po-grid aksrt-po-grid--main">';
$this->chart_card('groups', __('分类成交', 'aksrt'), __('按商品分类的支付金额曲线', 'aksrt'), 'md');
$this->chart_card('methods', __('支付方式', 'aksrt'), __('按支付金额的支付方式占比', 'aksrt'), 'md');
echo '</div>';
echo '<div class="aksrt-po-grid aksrt-po-grid--main">';
$this->render_top_card();
$this->render_todo_card();
echo '</div>';
echo '</div>';
$this->render_shortcuts_card();
$payload = [
'currency' => $symbol,
'today' => $today,
'maxDays' => OrderAnalytics::MAX_RANGE_DAYS,
'rest' => ['url' => rest_url('aksrt/v1/admin/pay-overview'), 'nonce' => wp_create_nonce('wp_rest')],
'initial' => ['from' => $from, 'to' => $today, 'group' => '', 'report' => $report],
'i18n' => [
'paid' => __('支付金额', 'aksrt'),
'net' => __('净收入', 'aksrt'),
'refund' => __('退款金额', 'aksrt'),
'count' => __('支付订单数', 'aksrt'),
'unit' => __('笔', 'aksrt'),
'people' => __('人', 'aksrt'),
'empty' => __('所选区间暂无成交', 'aksrt'),
'loadError' => __('统计数据加载失败,请稍后重试。', 'aksrt'),
'basis' => __('较上一周期', 'aksrt'),
'new' => __('新增', 'aksrt'),
'total' => __('合计', 'aksrt'),
'excluded' => __('以下外币订单未计入统计:', 'aksrt'),
'noTop' => __('所选区间暂无付费内容成交', 'aksrt'),
'badRange' => __('请选择有效的日期区间。', 'aksrt'),
],
];
echo '<script type="application/json" id="' . esc_attr(self::DATA_ID) . '">'
. wp_json_encode($payload, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE)
. '</script>';
NativeUi::page_close();
}
/* ------------------------------------------------------------------
* 今日实时(同时段对比,不随区间变化)
* ----------------------------------------------------------------*/
/** @param array{today:array{amount:float,count:int},yesterday:array{amount:float,count:int},until:string} $data */
private function render_realtime(array $data, string $symbol): void {
$amount = (float) $data['today']['amount'];
$prev = (float) $data['yesterday']['amount'];
echo '<section class="aksrt-po-live" aria-label="' . esc_attr__('今日实时', 'aksrt') . '">';
echo '<div class="aksrt-po-live__main"><span class="aksrt-po-live__dot" aria-hidden="true"></span>'
. '<span class="aksrt-po-live__label">' . esc_html__('今日支付金额', 'aksrt') . '</span>'
. '<strong class="aksrt-po-live__value">' . esc_html($symbol . number_format_i18n($amount, 2)) . '</strong>'
. $this->delta_html($amount, $prev) . '</div>';
echo '<div class="aksrt-po-live__meta">';
/* translators: %d order count */
echo '<span>' . esc_html(sprintf(__('今日 %d 笔', 'aksrt'), (int) $data['today']['count'])) . '</span>';
echo '<span>' . esc_html(sprintf(
/* translators: 1 time 2 amount 3 order count */
__('昨日 00:00–%1$s:%2$s,%3$d 笔', 'aksrt'),
$data['until'],
$symbol . number_format_i18n($prev, 2),
(int) $data['yesterday']['count']
)) . '</span>';
echo '</div></section>';
}
/** 同时段环比徽标(服务端渲染,仅今日实时使用;区间指标的环比由前端计算)。 */
private function delta_html(float $current, float $previous): string {
$basis = __('较昨日同时段', 'aksrt');
if ($previous <= 0) {
$text = $current > 0 ? __('新增', 'aksrt') : '—';
$class = $current > 0 ? 'is-up' : 'is-flat';
} else {
$delta = round(($current - $previous) / $previous * 100, 1);
$class = $delta > 0 ? 'is-up' : ($delta < 0 ? 'is-down' : 'is-flat');
$text = ($delta > 0 ? '↑' : ($delta < 0 ? '↓' : '')) . number_format_i18n(abs($delta), 1) . '%';
}
return '<span class="aksrt-po-delta ' . esc_attr($class) . '">' . esc_html($basis . ' ' . $text) . '</span>';
}
/* ------------------------------------------------------------------
* 筛选条 / 指标卡
* ----------------------------------------------------------------*/
private function render_toolbar(string $from, string $to): void {
$presets = [
'd7' => __('近 7 天', 'aksrt'),
'd30' => __('近 30 天', 'aksrt'),
'd90' => __('近 90 天', 'aksrt'),
'this_month' => __('本月', 'aksrt'),
'last_month' => __('上月', 'aksrt'),
'd365' => __('近一年', 'aksrt'),
];
echo '<div class="aksrt-po-toolbar">';
echo '<div class="aksrt-po-seg" role="group" data-aksrt-po-presets aria-label="' . esc_attr__('统计区间', 'aksrt') . '">';
foreach ($presets as $key => $label) {
echo '<button type="button" data-preset="' . esc_attr($key) . '" aria-pressed="' . ($key === 'd30' ? 'true' : 'false') . '">' . esc_html($label) . '</button>';
}
echo '</div>';
echo '<form class="aksrt-po-range" data-aksrt-po-range>';
echo '<label><span class="screen-reader-text">' . esc_html__('开始日期', 'aksrt') . '</span>'
. '<input type="date" name="from" value="' . esc_attr($from) . '" max="' . esc_attr($to) . '" required></label>';
echo '<span aria-hidden="true">–</span>';
echo '<label><span class="screen-reader-text">' . esc_html__('结束日期', 'aksrt') . '</span>'
. '<input type="date" name="to" value="' . esc_attr($to) . '" max="' . esc_attr($to) . '" required></label>';
echo '<label><span class="screen-reader-text">' . esc_html__('商品分类', 'aksrt') . '</span><select name="group">'
. '<option value="">' . esc_html__('全部分类', 'aksrt') . '</option>';
foreach (OrderAnalytics::group_labels() as $key => $label) {
echo '<option value="' . esc_attr($key) . '">' . esc_html($label) . '</option>';
}
echo '</select></label>';
echo '<button type="submit" class="button">' . esc_html__('查询', 'aksrt') . '</button>';
echo '</form>';
echo '<span class="aksrt-po-range-label" data-aksrt-po-range-label aria-live="polite"></span>';
echo '</div>';
}
private function render_kpis(): void {
$cards = [
['paid_amount', __('支付金额', 'aksrt'), 'success'],
['net_amount', __('净收入', 'aksrt'), 'info'],
['paid_count', __('支付订单数', 'aksrt'), 'primary'],
['buyers', __('支付人数', 'aksrt'), 'primary'],
['avg_amount', __('客单价', 'aksrt'), 'warning'],
['refund_amount', __('退款金额', 'aksrt'), 'danger'],
];
echo '<div class="aksrt-po-kpis" data-aksrt-po-kpis>';
foreach ($cards as [$key, $label, $tone]) {
echo '<div class="aksrt-po-kpi aksrt-po-kpi--' . esc_attr($tone) . '" data-kpi="' . esc_attr($key) . '">'
. '<span class="aksrt-po-kpi__label">' . esc_html($label) . '</span>'
. '<span class="aksrt-po-kpi__value" data-kpi-value>—</span>'
. '<span class="aksrt-po-kpi__foot"><span class="aksrt-po-delta is-flat" data-kpi-delta>—</span>'
. '<span class="aksrt-po-kpi__meta" data-kpi-meta></span></span>'
. '</div>';
}
echo '</div>';
}
/* ------------------------------------------------------------------
* 卡片
* ----------------------------------------------------------------*/
private function chart_card(string $key, string $title, string $label, string $size): void {
NativeUi::card_open($title, '', false, 'aksrt-po-card');
echo '<div class="aksrt-po-chart aksrt-po-chart--' . esc_attr($size) . '" data-aksrt-po-chart="' . esc_attr($key) . '" role="img" aria-label="' . esc_attr($label) . '"></div>';
NativeUi::card_close();
}
private function render_funnel_card(): void {
$rows = [
['created_count', __('下单数', 'aksrt'), __('区间内创建的订单', 'aksrt')],
['conversion', __('支付转化率', 'aksrt'), __('下单后完成支付的比例', 'aksrt')],
['close_rate', __('超时关闭率', 'aksrt'), __('未支付而关闭的比例', 'aksrt')],
['refund_rate', __('退款率', 'aksrt'), __('退款金额 ÷ 支付金额', 'aksrt')],
];
NativeUi::card_open(__('转化与退款', 'aksrt'), '', false, 'aksrt-po-card');
echo '<ul class="aksrt-po-funnel" data-aksrt-po-funnel>';
foreach ($rows as [$key, $label, $hint]) {
echo '<li data-metric="' . esc_attr($key) . '"><div class="aksrt-po-funnel__head">'
. '<span class="aksrt-po-funnel__label">' . esc_html($label) . '</span>'
. '<strong class="aksrt-po-funnel__value" data-value>—</strong></div>'
. '<span class="aksrt-po-funnel__bar" aria-hidden="true"><i data-bar></i></span>'
. '<span class="aksrt-po-funnel__hint">' . esc_html($hint) . ' <span data-delta></span></span></li>';
}
echo '</ul>';
NativeUi::card_close();
}
private function render_top_card(): void {
NativeUi::card_open(__('付费内容热销 TOP 10', 'aksrt'), '', true, 'aksrt-po-card');
echo '<div class="aksrt-native-table-wrap"><table class="widefat aksrt-po-top">'
. '<caption class="screen-reader-text">' . esc_html__('付费内容热销排行', 'aksrt') . '</caption>'
. '<thead><tr><th scope="col" class="column-rank">#</th><th scope="col">' . esc_html__('文章', 'aksrt') . '</th>'
. '<th scope="col" class="column-num">' . esc_html__('销量', 'aksrt') . '</th>'
. '<th scope="col" class="column-num">' . esc_html__('支付金额', 'aksrt') . '</th>'
. '<th scope="col" class="column-share">' . esc_html__('占比', 'aksrt') . '</th></tr></thead>'
. '<tbody data-aksrt-po-top></tbody></table></div>';
NativeUi::card_close();
}
private function render_todo_card(): void {
$orders = class_exists('\AKSRT\Payment\OrderRepository') ? \AKSRT\Payment\OrderRepository::get_instance()->get_orders_status_counts() : [];
$withdraw = class_exists('\AKSRT\Payment\Withdrawal') ? \AKSRT\Payment\Withdrawal::get_instance()->get_stats() : [];
$income = class_exists('\AKSRT\Payment\Revenue') ? \AKSRT\Payment\Revenue::get_instance()->get_stats() : [];
$cards = class_exists('\AKSRT\Payment\ChargeCard') ? \AKSRT\Payment\ChargeCard::get_instance()->get_stats() : [];
$items = [
[__('待审提现', 'aksrt'), (int) ($withdraw['pending'] ?? 0), $this->page_url('aksrt_withdraw', ['status' => 'pending']), 'dashicons-money-alt', 'danger'],
[__('打款中提现', 'aksrt'), (int) ($withdraw['processing'] ?? 0), $this->page_url('aksrt_withdraw', ['status' => 'processing']), 'dashicons-update', 'warning'],
[__('待结算分成', 'aksrt'), (string) ($income['cash']['pending'] ?? '0.00'), $this->page_url('aksrt_income', ['status' => 'pending']), 'dashicons-chart-pie', 'warning'],
[__('待支付订单', 'aksrt'), (int) ($orders['pending'] ?? 0), $this->page_url('aksrt_orders', ['status' => 'pending']), 'dashicons-clock', 'info'],
[__('可用卡密', 'aksrt'), (int) ($cards['active'] ?? 0), $this->page_url('aksrt_charge_cards', ['status' => 'active']), 'dashicons-id-alt', 'neutral'],
];
NativeUi::card_open(__('待办事项', 'aksrt'), '', false, 'aksrt-po-card');
echo '<ul class="aksrt-po-todo">';
foreach ($items as [$label, $value, $url, $icon, $tone]) {
$idle = (string) $value === '0' || (string) $value === '0.00';
echo '<li><a class="aksrt-po-todo__item aksrt-po-todo__item--' . esc_attr($idle ? 'neutral' : $tone) . '" href="' . esc_url($url) . '">'
. '<span class="aksrt-po-todo__icon dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span>'
. '<span class="aksrt-po-todo__label">' . esc_html($label) . '</span>'
. '<span class="aksrt-po-todo__value">' . esc_html(is_int($value) ? number_format_i18n($value) : $this->currency_symbol() . $value) . '</span>'
. '</a></li>';
}
echo '</ul>';
NativeUi::card_close();
}
private function render_shortcuts_card(): void {
NativeUi::card_open(__('快捷入口', 'aksrt'), '', false, 'aksrt-po-card');
echo '<ul class="aksrt-po-links">';
foreach (PayMenus::get_instance()->shortcuts() as $item) {
echo '<li><a href="' . esc_url($this->page_url($item['slug'])) . '">'
. '<span class="aksrt-po-links__icon dashicons ' . esc_attr($item['icon']) . '" aria-hidden="true"></span>'
. '<span class="aksrt-po-links__text"><strong>' . esc_html($item['title']) . '</strong>'
. ($item['desc'] !== '' ? '<span>' . esc_html($item['desc']) . '</span>' : '')
. '</span></a></li>';
}
echo '</ul>';
NativeUi::card_close();
}
/* ------------------------------------------------------------------
* 助手
* ----------------------------------------------------------------*/
private function currency_symbol(): string {
$paid = \AKSRT\Settings\Settings::get_paid_content_settings();
return (string) ($paid['display']['currency_symbol'] ?? '¥');
}
/** @param array<string, string> $args */
private function page_url(string $slug, array $args = []): string {
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* 支付中心概览的数据接口:GET aksrt/v1/admin/pay-overview?from=Y-m-d&to=Y-m-d&group=
*
* 仅管理员(manage_options + wp_rest nonce,见 ControllerBase::check_permission)。
* 统计口径见 \AKSRT\Payment\OrderAnalytics。
*
* @package AKSRT
*/
namespace AKSRT\Admin;
use AKSRT\Payment\OrderAnalytics;
use AKSRT\Payment\OrderAnalyticsDaily;
defined('ABSPATH') || exit;
class PayOverviewController extends ControllerBase {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function register_routes(): void {
register_rest_route(self::API_NAMESPACE, '/admin/pay-overview', [
'methods' => 'GET',
'callback' => [$this, 'get_overview'],
'permission_callback' => [$this, 'check_permission'],
'args' => [
'from' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'to' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'group' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_key'],
],
]);
}
public function get_overview(\WP_REST_Request $request): \WP_REST_Response {
$from = (string) $request->get_param('from');
$to = (string) $request->get_param('to');
$group = (string) $request->get_param('group');
$error = self::validate_range($from, $to);
if ($error !== '') {
return $this->error($error, 400);
}
if ($group !== '' && !isset(OrderAnalytics::group_labels()[$group])) {
return $this->error(__('商品分类无效。', 'aksrt'), 400);
}
return $this->success(['data' => OrderAnalytics::get_instance()->report($from, $to, $group)]);
}
/** 区间校验:合法日期、起止有序、不晚于今天、不超过上限天数。返回空串表示通过。 */
public static function validate_range(string $from, string $to): string {
if (!OrderAnalyticsDaily::is_date($from) || !OrderAnalyticsDaily::is_date($to)) {
return __('日期格式无效。', 'aksrt');
}
if ($from > $to) {
return __('开始日期不能晚于结束日期。', 'aksrt');
}
if ($to > current_time('Y-m-d')) {
return __('结束日期不能晚于今天。', 'aksrt');
}
if (count(OrderAnalyticsDaily::dates($from, $to)) > OrderAnalytics::MAX_RANGE_DAYS) {
/* translators: %d max days */
return sprintf(__('统计区间最长 %d 天。', 'aksrt'), OrderAnalytics::MAX_RANGE_DAYS);
}
return '';
}
}
-4
View File
@@ -60,10 +60,6 @@ class Order {
return $this->repo()->update_status($order_number, $new_status, $extra);
}
public function get_stats(int $user_id = 0): array {
return $this->repo()->get_stats($user_id);
}
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
return $this->repo()->has_paid_access($user_id, $product_type, $product_id);
}
+379
View File
@@ -0,0 +1,379 @@
<?php
/**
* 支付中心概览报表:在 OrderAnalyticsDaily 的按天记录上汇总区间指标、趋势序列与结构分析。
*
* 口径(详见 OrderAnalyticsDaily):
* - 支付金额按支付时间归日,包含后来退款的订单;退款金额按退款时间归日;净收入 = 支付 − 退款;
* - 只统计站点主币种现金订单,不含积分订单与余额充值;外币订单在 excluded 中单独列出;
* - 支付转化率 / 超时关闭率按下单时间归日:区间内下单的订单里已支付 / 已关闭的占比。
*
* 金额在内部一律用「分」(整数)累加,输出时才转成元。
*
* @package AKSRT
*/
namespace AKSRT\Payment;
use AKSRT\Core\DomainModel;
defined('ABSPATH') || exit;
final class OrderAnalytics {
public const GROUP_CONTENT = 'content';
public const GROUP_VIP = 'vip';
public const GROUP_POINTS = 'points';
public const GROUP_OTHER = 'other';
public const MAX_RANGE_DAYS = 366;
/** 区间超过该天数时趋势按月聚合。 */
private const MONTHLY_THRESHOLD = 92;
private const TOP_LIMIT = 10;
private static $instance = null;
/** @var array<string,string>|null */
private ?array $method_labels = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/** @return array<string, string> 分组键 => 名称(顺序即图例顺序) */
public static function group_labels(): array {
return [
self::GROUP_CONTENT => __('付费内容', 'aksrt'),
self::GROUP_VIP => __('会员', 'aksrt'),
self::GROUP_POINTS => __('购买积分', 'aksrt'),
self::GROUP_OTHER => __('其他', 'aksrt'),
];
}
public static function group_of(string $product_type): string {
if (DomainModel::is_resource_product_type($product_type)) {
return self::GROUP_CONTENT;
}
return match ($product_type) {
DomainModel::ORDER_PRODUCT_VIP => self::GROUP_VIP,
DomainModel::ORDER_PRODUCT_POINTS => self::GROUP_POINTS,
default => self::GROUP_OTHER,
};
}
/**
* 区间报表。$from / $to 为站点时区 Y-m-d(调用方已校验),$group 为空表示全部分类。
*
* @return array<string, mixed>
*/
public function report(string $from, string $to, string $group = ''): array {
$daily = OrderAnalyticsDaily::get_instance();
$records = $daily->records($from, $to);
$days = count($records);
$prev_to = OrderAnalyticsDaily::shift($from, -1);
$prev_from = OrderAnalyticsDaily::shift($from, -$days);
$previous = $daily->records($prev_from, $prev_to);
$granularity = $days > self::MONTHLY_THRESHOLD ? 'month' : 'day';
return [
'range' => ['from' => $from, 'to' => $to, 'days' => $days, 'granularity' => $granularity, 'group' => $group],
'previous_range' => ['from' => $prev_from, 'to' => $prev_to],
'totals' => $this->totals($records, $group) + ['buyers' => $this->buyers($from, $to, $group)],
'previous' => $this->totals($previous, $group) + ['buyers' => $this->buyers($prev_from, $prev_to, $group)],
'series' => $this->series($records, $group, $granularity),
'methods' => $this->methods($records, $group),
'top' => in_array($group, ['', self::GROUP_CONTENT], true) ? $this->top_products($from, $to) : [],
'excluded' => $this->excluded($from, $to),
];
}
/**
* 今日截至此刻 vs 昨日同一时段(支付侧)。
*
* @return array{today:array{amount:float,count:int},yesterday:array{amount:float,count:int},until:string}
*/
public function today_vs_yesterday(): array {
$today = current_time('Y-m-d');
$clock = current_time('H:i:s');
$yday = OrderAnalyticsDaily::shift($today, -1);
return [
'today' => $this->paid_window($today . ' 00:00:00', $today . ' ' . $clock),
'yesterday' => $this->paid_window($yday . ' 00:00:00', $yday . ' ' . $clock),
'until' => substr($clock, 0, 5),
];
}
/* ------------------------------------------------------------------
* 基于按天记录的汇总
* ----------------------------------------------------------------*/
/** @param array<string, array<string, array>> $records */
private function totals(array $records, string $group): array {
$paid = $paid_count = $refund = $refund_count = $created = $created_paid = $closed = 0;
foreach ($records as $record) {
foreach ($record['paid'] ?? [] as $type => $methods) {
if ($this->in_group((string) $type, $group)) {
foreach ($methods as $cell) {
$paid += (int) $cell[0];
$paid_count += (int) $cell[1];
}
}
}
foreach ($record['refund'] ?? [] as $type => $cell) {
if ($this->in_group((string) $type, $group)) {
$refund += (int) $cell[0];
$refund_count += (int) $cell[1];
}
}
foreach ($record['created'] ?? [] as $type => $cell) {
if ($this->in_group((string) $type, $group)) {
$created += (int) $cell[0];
$created_paid += (int) $cell[1];
$closed += (int) $cell[2];
}
}
}
return [
'paid_amount' => self::yuan($paid),
'paid_count' => $paid_count,
'refund_amount' => self::yuan($refund),
'refund_count' => $refund_count,
'net_amount' => self::yuan($paid - $refund),
'avg_amount' => $paid_count > 0 ? self::yuan((int) round($paid / $paid_count)) : 0.0,
'refund_rate' => $paid > 0 ? round($refund / $paid * 100, 1) : null,
'created_count' => $created,
'conversion' => $created > 0 ? round($created_paid / $created * 100, 1) : null,
'close_rate' => $created > 0 ? round($closed / $created * 100, 1) : null,
];
}
/**
* 趋势序列:支付金额、退款金额、净收入、支付订单数,以及按分类的支付金额。
*
* @param array<string, array<string, array>> $records
*/
private function series(array $records, string $group, string $granularity): array {
$buckets = [];
foreach ($records as $date => $record) {
$bucket = $granularity === 'month' ? substr((string) $date, 0, 7) : (string) $date;
$buckets[$bucket] ??= ['paid' => 0, 'refund' => 0, 'count' => 0, 'groups' => array_fill_keys(array_keys(self::group_labels()), 0)];
foreach ($record['paid'] ?? [] as $type => $methods) {
$type_group = self::group_of((string) $type);
foreach ($methods as $cell) {
$buckets[$bucket]['groups'][$type_group] += (int) $cell[0];
if ($this->in_group((string) $type, $group)) {
$buckets[$bucket]['paid'] += (int) $cell[0];
$buckets[$bucket]['count'] += (int) $cell[1];
}
}
}
foreach ($record['refund'] ?? [] as $type => $cell) {
if ($this->in_group((string) $type, $group)) {
$buckets[$bucket]['refund'] += (int) $cell[0];
}
}
}
$out = ['labels' => array_keys($buckets), 'paid' => [], 'refund' => [], 'net' => [], 'count' => [], 'groups' => []];
$group_data = array_fill_keys(array_keys(self::group_labels()), []);
foreach ($buckets as $bucket) {
$out['paid'][] = self::yuan($bucket['paid']);
$out['refund'][] = self::yuan($bucket['refund']);
$out['net'][] = self::yuan($bucket['paid'] - $bucket['refund']);
$out['count'][] = $bucket['count'];
foreach ($bucket['groups'] as $key => $minor) {
$group_data[$key][] = self::yuan($minor);
}
}
foreach (self::group_labels() as $key => $name) {
if (array_sum($group_data[$key]) > 0) {
$out['groups'][] = ['key' => $key, 'name' => $name, 'data' => $group_data[$key]];
}
}
return $out;
}
/**
* 支付方式占比(按支付金额降序)。
*
* @param array<string, array<string, array>> $records
* @return array<int, array{key:string,name:string,amount:float,count:int}>
*/
private function methods(array $records, string $group): array {
$sum = [];
foreach ($records as $record) {
foreach ($record['paid'] ?? [] as $type => $methods) {
if (!$this->in_group((string) $type, $group)) {
continue;
}
foreach ($methods as $method => $cell) {
$sum[$method] ??= [0, 0];
$sum[$method][0] += (int) $cell[0];
$sum[$method][1] += (int) $cell[1];
}
}
}
uasort($sum, static fn(array $a, array $b): int => $b[0] <=> $a[0]);
$out = [];
foreach ($sum as $method => $cell) {
$out[] = ['key' => (string) $method, 'name' => $this->method_label((string) $method), 'amount' => self::yuan($cell[0]), 'count' => $cell[1]];
}
return $out;
}
/* ------------------------------------------------------------------
* 需要直接查询的指标(跨天去重、明细排行、时段对比)
* ----------------------------------------------------------------*/
/** 区间内的支付人数(按用户去重;游客订单 user_id=0 不计)。 */
private function buyers(string $from, string $to, string $group): int {
global $wpdb;
[$where, $params] = $this->paid_window_where($from . ' 00:00:00', $to . ' 23:59:59', $group);
return (int) $wpdb->get_var($wpdb->prepare(
'SELECT COUNT(DISTINCT user_id) FROM ' . OrderAnalyticsDaily::table() . " WHERE {$where} AND user_id > 0",
$params
));
}
/** @return array{amount:float,count:int} */
private function paid_window(string $start, string $end): array {
global $wpdb;
[$where, $params] = $this->paid_window_where($start, $end, '');
$row = $wpdb->get_row($wpdb->prepare(
'SELECT COUNT(*) AS c, COALESCE(SUM(amount), 0) AS a FROM ' . OrderAnalyticsDaily::table() . " WHERE {$where}",
$params
), ARRAY_A);
return [
'amount' => self::yuan(OrderAnalyticsDaily::minor($row['a'] ?? 0)),
'count' => (int) ($row['c'] ?? 0),
];
}
/**
* 付费内容热销排行(按支付金额)。
*
* @return array<int, array{id:int,title:string,url:string,amount:float,count:int}>
*/
private function top_products(string $from, string $to): array {
global $wpdb;
[$where, $params] = $this->paid_window_where($from . ' 00:00:00', $to . ' 23:59:59', self::GROUP_CONTENT);
$params[] = self::TOP_LIMIT;
$rows = (array) $wpdb->get_results($wpdb->prepare(
'SELECT product_id AS id, COUNT(*) AS c, COALESCE(SUM(amount), 0) AS a FROM ' . OrderAnalyticsDaily::table()
. " WHERE {$where} AND product_id > 0 GROUP BY product_id ORDER BY a DESC, c DESC LIMIT %d",
$params
), ARRAY_A);
$ids = array_map(static fn(array $row): int => (int) $row['id'], $rows);
if ($ids !== []) {
_prime_post_caches($ids, false, false);
}
$out = [];
foreach ($rows as $row) {
$id = (int) $row['id'];
$title = (string) get_the_title($id);
$out[] = [
'id' => $id,
'title' => $title !== '' ? $title : sprintf(/* translators: %d post ID */ __('文章 #%d', 'aksrt'), $id),
'url' => (string) get_edit_post_link($id, 'raw'),
'amount' => self::yuan(OrderAnalyticsDaily::minor($row['a'] ?? 0)),
'count' => (int) ($row['c'] ?? 0),
];
}
return $out;
}
/**
* 未计入主口径的外币现金订单(按币种),供页面提示。
*
* @return array<int, array{currency:string,amount:float,count:int}>
*/
private function excluded(string $from, string $to): array {
global $wpdb;
$paid = OrderAnalyticsDaily::paid_statuses();
$in = implode(', ', array_fill(0, count($paid), '%s'));
$start = $from . ' 00:00:00';
$end = $to . ' 23:59:59';
$guard = OrderAnalyticsDaily::shift($from, -OrderAnalyticsDaily::CREATED_SLACK_DAYS) . ' 00:00:00';
$rows = (array) $wpdb->get_results($wpdb->prepare(
'SELECT UPPER(currency) AS cur, COUNT(*) AS c, COALESCE(SUM(amount), 0) AS a FROM ' . OrderAnalyticsDaily::table()
. " WHERE currency NOT IN (%s, 'points') AND payment_method <> 'points' AND product_type <> %s AND status IN ({$in})
AND created_at >= %s AND created_at <= %s AND COALESCE(paid_at, created_at) BETWEEN %s AND %s
GROUP BY cur ORDER BY c DESC",
array_merge([OrderAnalyticsDaily::CURRENCY, DomainModel::ORDER_PRODUCT_RECHARGE], $paid, [$guard, $end, $start, $end])
), ARRAY_A);
return array_map(static fn(array $row): array => [
'currency' => (string) $row['cur'],
'amount' => self::yuan(OrderAnalyticsDaily::minor($row['a'] ?? 0)),
'count' => (int) ($row['c'] ?? 0),
], $rows);
}
/**
* 按支付时间窗口的主口径 WHERE(含 created_at 索引下界)。
*
* @return array{0:string,1:array<int,mixed>}
*/
private function paid_window_where(string $start, string $end, string $group): array {
[$base, $params] = OrderAnalyticsDaily::base_where();
$paid = OrderAnalyticsDaily::paid_statuses();
$guard = OrderAnalyticsDaily::shift(substr($start, 0, 10), -OrderAnalyticsDaily::CREATED_SLACK_DAYS) . ' 00:00:00';
$where = $base . ' AND status IN (' . implode(', ', array_fill(0, count($paid), '%s')) . ')'
. ' AND created_at >= %s AND created_at <= %s AND COALESCE(paid_at, created_at) BETWEEN %s AND %s';
$params = array_merge($params, $paid, [$guard, $end, $start, $end]);
if ($group !== '') {
[$group_sql, $group_params] = $this->group_where($group);
$where .= ' AND ' . $group_sql;
$params = array_merge($params, $group_params);
}
return [$where, $params];
}
/** @return array{0:string,1:array<int,string>} */
private function group_where(string $group): array {
$content = DomainModel::resource_order_product_types();
$known = array_merge($content, [DomainModel::ORDER_PRODUCT_VIP, DomainModel::ORDER_PRODUCT_POINTS]);
$list = static fn(array $types): string => implode(', ', array_fill(0, count($types), '%s'));
return match ($group) {
self::GROUP_CONTENT => ['product_type IN (' . $list($content) . ')', $content],
self::GROUP_VIP => ['product_type = %s', [DomainModel::ORDER_PRODUCT_VIP]],
self::GROUP_POINTS => ['product_type = %s', [DomainModel::ORDER_PRODUCT_POINTS]],
default => ['product_type NOT IN (' . $list($known) . ')', $known],
};
}
/* ------------------------------------------------------------------
* 助手
* ----------------------------------------------------------------*/
private function in_group(string $product_type, string $group): bool {
return $group === '' || self::group_of($product_type) === $group;
}
private function method_label(string $method): string {
if ($this->method_labels === null) {
$this->method_labels = [
'card_pass' => __('卡密', 'aksrt'),
'free' => __('免费', 'aksrt'),
'' => __('未知', 'aksrt'),
];
if (class_exists('\AKSRT\Payment\Gateway')) {
foreach (Gateway::get_instance()->get_all() as $id => $gateway) {
$this->method_labels[(string) $id] = $gateway->get_name();
}
}
}
return $this->method_labels[$method] ?? $method;
}
private static function yuan(int $minor): float {
return round($minor / 100, 2);
}
}
+281
View File
@@ -0,0 +1,281 @@
<?php
/**
* 支付中心统计的按天明细与缓存。
*
* 每一天一条记录,金额一律用「分」(整数)保存,避免浮点累加误差:
* paid [product_type][payment_method] => [金额分, 笔数] 按支付时间归日
* refund [product_type] => [金额分, 笔数] 按退款时间归日
* created [product_type] => [下单数, 已支付数, 已关闭数] 按下单时间归日
*
* 统计口径(与 OrderAnalytics 一致):
* - 只统计站点主币种(CNY)的现金订单;积分订单与外币订单不进入主口径;
* - 不统计余额充值(预收款,消费时已计入对应订单);
* - 支付侧包含后来退款的订单(paid / refunding / refunded),退款单独记在退款当天,
* 所以历史支付金额不会因为后来的退款而改变。
*
* 缓存:今天和昨天始终实时查询;更早的日期按月存进 transient(一个月一条),
* 订单状态变化时删除受影响月份;清理 / 删除订单时整体换代。
*
* 性能:支付时间没有索引,查询额外加 created_at 下界(待支付订单最长 24 小时过期,
* 支付时间不会晚于下单 2 天),让 MySQL 走 idx_created;退款查询走 idx_status。
*
* @package AKSRT
*/
namespace AKSRT\Payment;
use AKSRT\Core\DomainModel;
defined('ABSPATH') || exit;
final class OrderAnalyticsDaily {
/** 主口径币种(订单表 currency 经 sanitize_key 存为小写)。 */
public const CURRENCY = 'cny';
/** 最近 N 天(含今天)不缓存:仍可能有回调、过期关闭等变化。 */
private const LIVE_DAYS = 2;
/** 支付时间与下单时间的最大间隔(天),用于 created_at 索引下界。 */
public const CREATED_SLACK_DAYS = 2;
private const GEN_OPTION = 'aksrt_pay_analytics_gen';
private const CACHE_TTL = 30 * DAY_IN_SECONDS;
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('aksrt_order_status_changed', [$this, 'on_status_changed'], 10, 3);
add_action('aksrt_orders_purged', [$this, 'flush']);
}
/**
* 订单状态变化:删除该订单下单日 / 支付日 / 退款日所在月份的缓存。
*
* @param mixed $order_number
* @param mixed $status
* @param mixed $order
*/
public function on_status_changed($order_number = '', $status = '', $order = []): void {
if (!is_array($order)) {
return;
}
$cutoff = $this->cache_cutoff();
$months = [];
foreach (['created_at', 'paid_at', 'refunded_at'] as $field) {
$date = substr((string) ($order[$field] ?? ''), 0, 10);
if (self::is_date($date) && $date <= $cutoff) {
$months[substr($date, 0, 7)] = true;
}
}
foreach (array_keys($months) as $month) {
delete_transient($this->key($month));
}
}
/** 整体失效:换代后旧 key 不再被读取,随 TTL 自然过期。 */
public function flush(): void {
update_option(self::GEN_OPTION, $this->generation() + 1, false);
}
public function generation(): int {
return (int) get_option(self::GEN_OPTION, 0);
}
/** 可缓存的最后一天(Y-m-d)。 */
public function cache_cutoff(): string {
return self::shift(current_time('Y-m-d'), -self::LIVE_DAYS);
}
/**
* 区间内每天的记录(含首尾),键为 Y-m-d,按日期升序。
*
* @return array<string, array<string, array>>
*/
public function records(string $from, string $to): array {
$cutoff = $this->cache_cutoff();
$out = [];
$missing = [];
$by_month = [];
foreach (self::dates($from, $to) as $date) {
$by_month[substr($date, 0, 7)][] = $date;
}
foreach ($by_month as $month => $dates) {
$cached = $dates[0] <= $cutoff ? get_transient($this->key($month)) : false;
$cached = is_array($cached) ? $cached : [];
foreach ($dates as $date) {
if ($date <= $cutoff && isset($cached[$date])) {
$out[$date] = $cached[$date];
} else {
$missing[] = $date;
}
}
}
if ($missing !== []) {
$fresh = $this->query(min($missing), max($missing));
$store = [];
foreach ($missing as $date) {
$out[$date] = $fresh[$date] ?? self::empty_record();
if ($date <= $cutoff) {
$store[substr($date, 0, 7)][$date] = $out[$date];
}
}
foreach ($store as $month => $days) {
$cached = get_transient($this->key($month));
set_transient($this->key($month), array_merge(is_array($cached) ? $cached : [], $days), self::CACHE_TTL);
}
}
ksort($out);
return $out;
}
/* ------------------------------------------------------------------
* 查询
* ----------------------------------------------------------------*/
/** @return array<string, array<string, array>> */
private function query(string $from, string $to): array {
global $wpdb;
$table = self::table();
$start = $from . ' 00:00:00';
$end = $to . ' 23:59:59';
$guard = self::shift($from, -self::CREATED_SLACK_DAYS) . ' 00:00:00';
[$base, $base_params] = self::base_where();
$paid = self::paid_statuses();
$paid_in = implode(', ', array_fill(0, count($paid), '%s'));
$records = [];
// 支付:按支付时间归日。
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT DATE(COALESCE(paid_at, created_at)) AS d, product_type AS t, payment_method AS m,
COUNT(*) AS c, COALESCE(SUM(amount), 0) AS a
FROM {$table}
WHERE {$base} AND status IN ({$paid_in})
AND created_at >= %s AND created_at <= %s
AND COALESCE(paid_at, created_at) BETWEEN %s AND %s
GROUP BY d, t, m",
array_merge($base_params, $paid, [$guard, $end, $start, $end])
), ARRAY_A);
foreach ((array) $rows as $row) {
$date = (string) ($row['d'] ?? '');
$type = sanitize_key((string) ($row['t'] ?? ''));
$method = sanitize_key((string) ($row['m'] ?? ''));
$records[$date] ??= self::empty_record();
$cell = $records[$date]['paid'][$type][$method] ?? [0, 0];
$records[$date]['paid'][$type][$method] = [$cell[0] + self::minor($row['a'] ?? 0), $cell[1] + (int) ($row['c'] ?? 0)];
}
// 退款:按退款时间归日(缺失时回落支付 / 下单时间)。
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT DATE(COALESCE(refunded_at, paid_at, created_at)) AS d, product_type AS t,
COUNT(*) AS c, COALESCE(SUM(amount), 0) AS a
FROM {$table}
WHERE {$base} AND status = %s
AND COALESCE(refunded_at, paid_at, created_at) BETWEEN %s AND %s
GROUP BY d, t",
array_merge($base_params, [DomainModel::ORDER_STATUS_REFUNDED, $start, $end])
), ARRAY_A);
foreach ((array) $rows as $row) {
$date = (string) ($row['d'] ?? '');
$type = sanitize_key((string) ($row['t'] ?? ''));
$records[$date] ??= self::empty_record();
$cell = $records[$date]['refund'][$type] ?? [0, 0];
$records[$date]['refund'][$type] = [$cell[0] + self::minor($row['a'] ?? 0), $cell[1] + (int) ($row['c'] ?? 0)];
}
// 下单:按下单时间归日,用于支付转化率与关闭率。
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT DATE(created_at) AS d, product_type AS t, COUNT(*) AS c,
SUM(CASE WHEN status IN ({$paid_in}) THEN 1 ELSE 0 END) AS p,
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS x
FROM {$table}
WHERE {$base} AND created_at BETWEEN %s AND %s
GROUP BY d, t",
array_merge($paid, [DomainModel::ORDER_STATUS_CLOSED], $base_params, [$start, $end])
), ARRAY_A);
foreach ((array) $rows as $row) {
$date = (string) ($row['d'] ?? '');
$type = sanitize_key((string) ($row['t'] ?? ''));
$records[$date] ??= self::empty_record();
$cell = $records[$date]['created'][$type] ?? [0, 0, 0];
$records[$date]['created'][$type] = [
$cell[0] + (int) ($row['c'] ?? 0),
$cell[1] + (int) ($row['p'] ?? 0),
$cell[2] + (int) ($row['x'] ?? 0),
];
}
return $records;
}
/* ------------------------------------------------------------------
* 共享口径(OrderAnalytics 的区间查询也用这些)
* ----------------------------------------------------------------*/
public static function table(): string {
return \AKSRT\Core\DBMigrator::table('order');
}
/** @return array{0:string,1:array<int,string>} 主口径 WHERE 片段(主币种、非积分、非充值)。 */
public static function base_where(): array {
return [
'currency = %s AND payment_method <> %s AND product_type <> %s',
[self::CURRENCY, 'points', DomainModel::ORDER_PRODUCT_RECHARGE],
];
}
/** @return string[] 曾经支付成功的状态:支付侧统计包含后来退款的订单。 */
public static function paid_statuses(): array {
return array_values(array_unique(array_merge(
DomainModel::paid_order_statuses(),
[DomainModel::ORDER_STATUS_REFUNDED]
)));
}
/** 数据库 DECIMAL 字符串 → 分。 */
public static function minor($value): int {
return (int) round(((float) $value) * 100);
}
/** @return array{paid:array,refund:array,created:array} */
public static function empty_record(): array {
return ['paid' => [], 'refund' => [], 'created' => []];
}
public static function is_date(string $date): bool {
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $m)) {
return false;
}
return checkdate((int) $m[2], (int) $m[3], (int) $m[1]);
}
/** 纯日期加减(UTC 计算,不受夏令时影响)。 */
public static function shift(string $date, int $days): string {
$base = new \DateTimeImmutable($date, new \DateTimeZone('UTC'));
return $base->modify(($days >= 0 ? '+' : '') . $days . ' days')->format('Y-m-d');
}
/** @return string[] 从 $from 到 $to 的连续日期。 */
public static function dates(string $from, string $to): array {
$dates = [];
for ($d = $from; $d <= $to; $d = self::shift($d, 1)) {
$dates[] = $d;
}
return $dates;
}
private function key(string $month): string {
return 'aksrt_pa_' . $this->generation() . '_' . str_replace('-', '', $month);
}
}
+1
View File
@@ -10,6 +10,7 @@ defined('ABSPATH') || exit;
class OrderRepository {
use OrderRepositoryCore;
use OrderRepositoryAdmin;
use OrderRepositoryAttempts;
private static $instance = null;
private int $status_transaction_depth = 0;
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* 订单与支付渠道交易号(out_trade_no)的映射:meta 合并、按交易号反查订单、重复支付时分配新交易号。
*
* 作为 flat sibling trait 被 OrderRepository 使用(PSR-4:trait 必须与宿主类同目录)。
*
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
trait OrderRepositoryAttempts {
/**
* Merge keys into the order note JSON payload (meta_payload).
*
* @param array<string,mixed> $patch
*/
public function merge_meta(string $order_number, array $patch): bool {
global $wpdb;
$order = $this->get($order_number);
if (!$order) {
return false;
}
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
foreach ($patch as $key => $value) {
$meta[(string) $key] = $value;
}
$encoded = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return false;
}
$updated = $wpdb->update(
$this->table(),
['note' => $encoded],
['order_number' => $order_number],
['%s'],
['%s']
);
return $updated !== false;
}
/**
* Resolve a local order from a provider out_trade_no (base or attempt-suffixed).
*
* The authoritative mapping is the order's own meta (provider_out_trade_no /
* provider_out_trade_nos); the prefix heuristic is only a fallback and must
* never be the sole resolver, otherwise a truncated attempt number would
* strand a real payment.
*/
public function resolve_by_out_trade_no(string $out_trade_no): ?array {
global $wpdb;
$out_trade_no = sanitize_text_field($out_trade_no);
if ($out_trade_no === '') {
return null;
}
$direct = $this->get($out_trade_no);
if ($direct) {
return $direct;
}
// Authoritative: the attempt number was recorded in the order note JSON.
$needle = '"provider_out_trade_no":"' . $out_trade_no . '"';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE note LIKE %s ORDER BY id DESC LIMIT 1",
'%' . $wpdb->esc_like($needle) . '%'
),
ARRAY_A
);
if (is_array($row)) {
return $this->expire_order_if_due($this->normalize_row($row));
}
// Superseded attempts stay in the history array.
$history_needle = '"' . $out_trade_no . '"';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table()}
WHERE note LIKE %s AND note LIKE %s
ORDER BY id DESC LIMIT 1",
'%' . $wpdb->esc_like('"provider_out_trade_nos"') . '%',
'%' . $wpdb->esc_like($history_needle) . '%'
),
ARRAY_A
);
if (is_array($row)) {
return $this->expire_order_if_due($this->normalize_row($row));
}
// Last resort: {order_number}P{n} prefix heuristic.
if (preg_match('/^(.+)P([0-9]{1,3})$/', $out_trade_no, $m)) {
$base = $this->get($m[1]);
if ($base) {
return $base;
}
}
return null;
}
/**
* Provider out_trade_no currently bound to this order (falls back to order_number).
*
* @param array<string,mixed> $order
*/
public static function provider_out_trade_no(array $order): string {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$out = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
if ($out !== '') {
return $out;
}
return sanitize_text_field((string) ($order['order_number'] ?? ''));
}
/**
* Allocate a new provider out_trade_no for a payment attempt.
* First attempt uses bare order_number; later attempts append Pn (WeChat limit 32).
*
* @return array{out_trade_no:string,attempt:int,previous:string}|null
*/
public function allocate_payment_attempt(string $order_number): ?array {
$order = $this->get($order_number);
if (!$order) {
return null;
}
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$previous = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
$history = is_array($meta['provider_out_trade_nos'] ?? null) ? $meta['provider_out_trade_nos'] : [];
// First provider submission keeps the bare order number.
// Re-pay (provider_out_trade_no already set) always mints a new suffix so
// WeChat/Alipay/Epay accept the order when amount/subject/notify changed.
if ($previous === '') {
$attempt = 1;
$out = $order_number;
} else {
$attempt = max(1, (int) ($meta['pay_attempt'] ?? 1)) + 1;
$suffix = 'P' . $attempt;
$base = $order_number;
if (strlen($base) + strlen($suffix) > 32) {
$base = substr($base, 0, 32 - strlen($suffix));
}
$out = $base . $suffix;
if ($previous !== '' && !in_array($previous, $history, true)) {
$history[] = $previous;
}
}
if (!in_array($out, $history, true)) {
$history[] = $out;
}
if (count($history) > 20) {
$history = array_slice($history, -20);
}
$ok = $this->merge_meta($order_number, [
'pay_attempt' => $attempt,
'provider_out_trade_no' => $out,
'provider_out_trade_nos' => array_values($history),
]);
if (!$ok) {
return null;
}
return [
'out_trade_no' => $out,
'attempt' => $attempt,
'previous' => $previous,
];
}
}
+12 -220
View File
@@ -641,7 +641,12 @@ trait OrderRepositoryCore {
AND cancelled_at < %s",
$cutoff
));
return $deleted === false ? 0 : (int) $deleted;
$deleted = $deleted === false ? 0 : (int) $deleted;
if ($deleted > 0) {
/** 订单被物理删除:统计缓存需整体失效。 */
do_action('aksrt_orders_purged');
}
return $deleted;
}
@@ -656,51 +661,15 @@ trait OrderRepositoryCore {
return false;
}
$ok = $wpdb->delete($this->table(), ['order_number' => $order_number], ['%s']);
return $ok !== false && $ok > 0;
}
/**
* 获取订单统计
*/
public function get_stats(int $user_id = 0): array {
global $wpdb;
$this->expire_pending_orders($user_id > 0 ? $user_id : null);
$where = "WHERE status IN ('pending', 'paid', 'refunding', 'refunded', 'closed')";
$params = [];
if ($user_id > 0) {
$where .= ' AND user_id = %d';
$params[] = $user_id;
}
$sql = "SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status IN ('paid','refunding') THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded,
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed,
SUM(CASE WHEN status IN ('paid','refunding') THEN amount ELSE 0 END) as revenue
FROM {$this->table()} {$where}";
if (!empty($params)) {
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
} else {
$row = $wpdb->get_row($sql, ARRAY_A);
$ok = $ok !== false && $ok > 0;
if ($ok) {
do_action('aksrt_orders_purged');
}
return [
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'closed' => (int) ($row['closed'] ?? 0),
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
];
return $ok;
}
public function dispatch_managed_status_events(): void {
$events = $this->status_after_commit_events;
$this->status_after_commit_events = [];
@@ -850,16 +819,6 @@ trait OrderRepositoryCore {
}
}
private function cash_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} catch (\InvalidArgumentException $e) {
return '0.00';
}
}
private function find_latest_success_order(int $user_id, string $product_type, int $product_id = 0, bool $ignore_product_id = false): ?array {
global $wpdb;
@@ -913,171 +872,4 @@ trait OrderRepositoryCore {
return \AKSRT\Finance\BusinessNumber::allocate();
}
/**
* Merge keys into the order note JSON payload (meta_payload).
*
* @param array<string,mixed> $patch
*/
public function merge_meta(string $order_number, array $patch): bool {
global $wpdb;
$order = $this->get($order_number);
if (!$order) {
return false;
}
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
foreach ($patch as $key => $value) {
$meta[(string) $key] = $value;
}
$encoded = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return false;
}
$updated = $wpdb->update(
$this->table(),
['note' => $encoded],
['order_number' => $order_number],
['%s'],
['%s']
);
return $updated !== false;
}
/**
* Resolve a local order from a provider out_trade_no (base or attempt-suffixed).
*
* The authoritative mapping is the order's own meta (provider_out_trade_no /
* provider_out_trade_nos); the prefix heuristic is only a fallback and must
* never be the sole resolver, otherwise a truncated attempt number would
* strand a real payment.
*/
public function resolve_by_out_trade_no(string $out_trade_no): ?array {
global $wpdb;
$out_trade_no = sanitize_text_field($out_trade_no);
if ($out_trade_no === '') {
return null;
}
$direct = $this->get($out_trade_no);
if ($direct) {
return $direct;
}
// Authoritative: the attempt number was recorded in the order note JSON.
$needle = '"provider_out_trade_no":"' . $out_trade_no . '"';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE note LIKE %s ORDER BY id DESC LIMIT 1",
'%' . $wpdb->esc_like($needle) . '%'
),
ARRAY_A
);
if (is_array($row)) {
return $this->expire_order_if_due($this->normalize_row($row));
}
// Superseded attempts stay in the history array.
$history_needle = '"' . $out_trade_no . '"';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table()}
WHERE note LIKE %s AND note LIKE %s
ORDER BY id DESC LIMIT 1",
'%' . $wpdb->esc_like('"provider_out_trade_nos"') . '%',
'%' . $wpdb->esc_like($history_needle) . '%'
),
ARRAY_A
);
if (is_array($row)) {
return $this->expire_order_if_due($this->normalize_row($row));
}
// Last resort: {order_number}P{n} prefix heuristic.
if (preg_match('/^(.+)P([0-9]{1,3})$/', $out_trade_no, $m)) {
$base = $this->get($m[1]);
if ($base) {
return $base;
}
}
return null;
}
/**
* Provider out_trade_no currently bound to this order (falls back to order_number).
*
* @param array<string,mixed> $order
*/
public static function provider_out_trade_no(array $order): string {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$out = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
if ($out !== '') {
return $out;
}
return sanitize_text_field((string) ($order['order_number'] ?? ''));
}
/**
* Allocate a new provider out_trade_no for a payment attempt.
* First attempt uses bare order_number; later attempts append Pn (WeChat limit 32).
*
* @return array{out_trade_no:string,attempt:int,previous:string}|null
*/
public function allocate_payment_attempt(string $order_number): ?array {
$order = $this->get($order_number);
if (!$order) {
return null;
}
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$previous = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
$history = is_array($meta['provider_out_trade_nos'] ?? null) ? $meta['provider_out_trade_nos'] : [];
// First provider submission keeps the bare order number.
// Re-pay (provider_out_trade_no already set) always mints a new suffix so
// WeChat/Alipay/Epay accept the order when amount/subject/notify changed.
if ($previous === '') {
$attempt = 1;
$out = $order_number;
} else {
$attempt = max(1, (int) ($meta['pay_attempt'] ?? 1)) + 1;
$suffix = 'P' . $attempt;
$base = $order_number;
if (strlen($base) + strlen($suffix) > 32) {
$base = substr($base, 0, 32 - strlen($suffix));
}
$out = $base . $suffix;
if ($previous !== '' && !in_array($previous, $history, true)) {
$history[] = $previous;
}
}
if (!in_array($out, $history, true)) {
$history[] = $out;
}
if (count($history) > 20) {
$history = array_slice($history, -20);
}
$ok = $this->merge_meta($order_number, [
'pay_attempt' => $attempt,
'provider_out_trade_no' => $out,
'provider_out_trade_nos' => array_values($history),
]);
if (!$ok) {
return null;
}
return [
'out_trade_no' => $out,
'attempt' => $attempt,
'previous' => $previous,
];
}
}