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:
1 parent
d98f8d7975
commit
9805a371d8
19 files changed
+2203
-405
No files matched your search
@@ -185,6 +185,7 @@ class Admin {
|
||||
PermissionsController::class,
|
||||
UserController::class,
|
||||
PaymentController::class,
|
||||
PayOverviewController::class,
|
||||
ToolsController::class,
|
||||
ImportExportController::class,
|
||||
ExtensionController::class,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user