perf(modal): unify cancellable frontend modal flows
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+124
-262
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* VIP modal + card HTML (zibll pay-vip modal / vip-card parity).
|
||||
* VIP modal view model + card HTML (zibll pay-vip modal / vip-card parity).
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
@@ -12,32 +12,133 @@ defined('ABSPATH') || exit;
|
||||
trait VipServiceModal {
|
||||
|
||||
/**
|
||||
* Build pay / renew / upgrade modal HTML for a user.
|
||||
* Build the minimal JSON view model consumed by pay-vip.js.
|
||||
*
|
||||
* The catalog, member state, package prices, payment methods, and allowed
|
||||
* actions are all calculated server-side. No payment gateway response or
|
||||
* server-rendered modal HTML is included in this payload.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function render_modal_html(int $user_id, int $prefer_level = 1): string {
|
||||
if ($user_id <= 0) {
|
||||
return '<div class="box-body payvip-modal text-center padding-20">'
|
||||
. esc_html__('请先登录后再开通会员。', 'aksrt')
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
$vip = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_payment_vip_settings()
|
||||
: ['enabled' => false];
|
||||
if (empty($vip['enabled'])) {
|
||||
return '<div class="box-body payvip-modal text-center padding-20">'
|
||||
. esc_html__('会员功能未启用。', 'aksrt')
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
public function get_modal_view_model(int $user_id, int $prefer_level = 1): array {
|
||||
$catalog = $this->get_catalog($user_id);
|
||||
$user_level = (int) ($catalog['user_level'] ?? 0);
|
||||
$vip = $this->get_vip_settings_context();
|
||||
$member = $this->get_vip_member_context($user_id);
|
||||
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
|
||||
$prefer_level = max(1, min(2, $prefer_level));
|
||||
|
||||
if ($user_level > 0) {
|
||||
return $this->render_modal_viped($user_id, $catalog, $vip, $prefer_level);
|
||||
$level_view = [];
|
||||
foreach ([1, 2] as $level) {
|
||||
if (empty($levels[$level]['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
$equity = (string) ($levels[$level]['equity'] ?? '');
|
||||
$equity = function_exists('aksrt_format_vip_equity_html')
|
||||
? (string) aksrt_format_vip_equity_html($equity)
|
||||
: wp_kses_post($equity);
|
||||
$level_view[] = [
|
||||
'level' => $level,
|
||||
'name' => (string) ($levels[$level]['name'] ?? ('VIP' . $level)),
|
||||
'desc' => (string) ($levels[$level]['desc'] ?? ''),
|
||||
'equity' => $equity,
|
||||
];
|
||||
}
|
||||
return $this->render_modal_pay($user_id, $catalog, $vip, $prefer_level);
|
||||
|
||||
$package_ids = [];
|
||||
$has_renew = false;
|
||||
$has_upgrade = false;
|
||||
$groups = is_array($catalog['level_packages'] ?? null) ? $catalog['level_packages'] : [];
|
||||
foreach ($groups as $group) {
|
||||
if (!is_array($group)) {
|
||||
continue;
|
||||
}
|
||||
$action = sanitize_key((string) ($group['action'] ?? 'pay'));
|
||||
$has_renew = $has_renew || $action === 'renew';
|
||||
$has_upgrade = $has_upgrade || $action === 'upgrade';
|
||||
foreach ((array) ($group['items'] ?? []) as $item) {
|
||||
if (is_array($item) && (int) ($item['package_id'] ?? 0) > 0) {
|
||||
$package_ids[] = (int) $item['package_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
$points_exchange = is_array($catalog['points_exchange'] ?? null) ? $catalog['points_exchange'] : [];
|
||||
$methods = is_array($catalog['payment_methods'] ?? null) ? $catalog['payment_methods'] : [];
|
||||
$no_payment_method = $methods === [];
|
||||
$action_reasons = [
|
||||
'purchase' => $no_payment_method
|
||||
? 'no_payment_method'
|
||||
: ($package_ids === [] ? 'no_package' : ''),
|
||||
'renew' => $no_payment_method
|
||||
? 'no_payment_method'
|
||||
: ($has_renew ? '' : 'not_available'),
|
||||
'upgrade' => $no_payment_method
|
||||
? 'no_payment_method'
|
||||
: ($has_upgrade ? '' : 'not_available'),
|
||||
'points_exchange' => !empty($points_exchange['enabled']) && !empty($points_exchange['packages'])
|
||||
? ''
|
||||
: 'not_available',
|
||||
];
|
||||
|
||||
$member_state = (string) ($member['state'] ?? 'unopened');
|
||||
$member_version = substr(hash('sha256', (string) wp_json_encode([
|
||||
'level' => (int) ($member['level'] ?? 0),
|
||||
'expires' => (int) ($member['expires'] ?? 0),
|
||||
'state' => $member_state,
|
||||
])), 0, 16);
|
||||
$catalog_version = substr(hash('sha256', (string) wp_json_encode([
|
||||
'packages' => $groups,
|
||||
'points' => $points_exchange,
|
||||
'methods' => $methods,
|
||||
'levels' => $level_view,
|
||||
])), 0, 16);
|
||||
|
||||
$user = get_userdata($user_id);
|
||||
$avatar_url = function_exists('get_avatar_url')
|
||||
? (string) get_avatar_url($user_id, ['size' => 42])
|
||||
: '';
|
||||
|
||||
return [
|
||||
'enabled' => !empty($catalog['enabled']),
|
||||
'prefer_level' => $prefer_level,
|
||||
'catalog' => [
|
||||
'desc' => (string) ($catalog['desc'] ?? ''),
|
||||
'help_text' => (string) ($catalog['help_text'] ?? ''),
|
||||
'more' => (string) ($vip['more'] ?? ''),
|
||||
'currency_name' => (string) ($catalog['currency_name'] ?? ''),
|
||||
'level_packages' => $groups,
|
||||
'points_exchange' => $points_exchange,
|
||||
'vip_url' => (string) ($catalog['vip_url'] ?? ''),
|
||||
],
|
||||
'member' => [
|
||||
'id' => $user_id,
|
||||
'name' => $user ? (string) $user->display_name : '',
|
||||
'avatar_url' => $avatar_url,
|
||||
'level' => (int) ($member['level'] ?? 0),
|
||||
'expires' => (int) ($member['expires'] ?? 0),
|
||||
'expires_label' => (string) ($member['expires_label'] ?? ''),
|
||||
'state' => (string) ($member['state'] ?? 'unopened'),
|
||||
'is_permanent' => !empty($member['is_permanent']),
|
||||
'points' => (int) ($catalog['user_points'] ?? 0),
|
||||
'logged_in' => $user_id > 0,
|
||||
],
|
||||
'levels' => $level_view,
|
||||
'payment_methods' => $methods,
|
||||
'allowed_actions' => [
|
||||
'purchase' => $package_ids !== [] && $methods !== [],
|
||||
'renew' => $has_renew && $methods !== [],
|
||||
'upgrade' => $has_upgrade && $methods !== [],
|
||||
'points_exchange' => !empty($points_exchange['enabled']) && !empty($points_exchange['packages']),
|
||||
],
|
||||
'action_reasons' => $action_reasons,
|
||||
'version' => [
|
||||
'schema' => 1,
|
||||
'member' => $member_version,
|
||||
'catalog' => $catalog_version,
|
||||
// Stable revision token; do not use request time and bust short cache.
|
||||
'updated_at' => $member_version . ':' . $catalog_version,
|
||||
'tags' => ['vip', 'orders'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,9 +146,7 @@ trait VipServiceModal {
|
||||
*/
|
||||
public function render_vip_card(int $level, int $user_id = 0): string {
|
||||
$level = max(1, min(2, $level));
|
||||
$vip = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_payment_vip_settings()
|
||||
: [];
|
||||
$vip = $this->get_vip_settings_context();
|
||||
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
|
||||
if (empty($levels[$level]['enabled'])) {
|
||||
return '';
|
||||
@@ -78,241 +177,4 @@ trait VipServiceModal {
|
||||
$html .= '</div></div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function render_modal_pay(int $user_id, array $catalog, array $vip, int $prefer_level): string {
|
||||
$user = get_userdata($user_id);
|
||||
$name = $user ? $user->display_name : '';
|
||||
$avatar = get_avatar($user_id, 42);
|
||||
$desc = (string) ($vip['desc'] ?? $catalog['desc'] ?? '');
|
||||
$more = (string) ($vip['more'] ?? '');
|
||||
$groups = is_array($catalog['level_packages'] ?? null) ? $catalog['level_packages'] : [];
|
||||
$methods = is_array($catalog['payment_methods'] ?? null) ? $catalog['payment_methods'] : [];
|
||||
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
|
||||
|
||||
$active_lv = !empty($levels[$prefer_level]['enabled']) ? $prefer_level : 0;
|
||||
if ($active_lv <= 0) {
|
||||
foreach ([1, 2] as $lv) {
|
||||
if (!empty($levels[$lv]['enabled'])) {
|
||||
$active_lv = $lv;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tabs = '';
|
||||
$panes = '';
|
||||
foreach ([1, 2] as $lv) {
|
||||
if (empty($levels[$lv]['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
$is_active = ($lv === $active_lv);
|
||||
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
|
||||
$tabs .= '<li class="relative' . ($is_active ? ' active' : '') . '">'
|
||||
. '<a class="aksrt-vip-tab" href="javascript:;" data-vip-tab="tab-payvip-' . $lv . '">'
|
||||
. esc_html($lv_name)
|
||||
. '</a></li>';
|
||||
|
||||
$group_items = [];
|
||||
foreach ($groups as $g) {
|
||||
if ((int) ($g['level'] ?? 0) === $lv) {
|
||||
$group_items = is_array($g['items'] ?? null) ? $g['items'] : [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$products = $this->render_product_radios($group_items);
|
||||
$equity = (string) ($levels[$lv]['equity'] ?? '');
|
||||
$lv_desc = (string) ($levels[$lv]['desc'] ?? '');
|
||||
|
||||
$panes .= '<div class="tab-pane aksrt-vip-pane' . ($is_active ? ' active' : '') . '" id="tab-payvip-' . $lv . '"' . ($is_active ? '' : ' hidden') . '>';
|
||||
$panes .= '<div class="pay-vip-form-row">';
|
||||
$panes .= '<div class="pay-vip-info level-' . $lv . '">';
|
||||
$panes .= '<div class="v-name font-bold">' . esc_html($lv_name) . '</div>';
|
||||
if ($lv_desc !== '') {
|
||||
$panes .= '<div class="muted-2-color em09 mb6">' . esc_html($lv_desc) . '</div>';
|
||||
}
|
||||
if ($equity !== '') {
|
||||
$panes .= function_exists('aksrt_format_vip_equity_html')
|
||||
? aksrt_format_vip_equity_html($equity)
|
||||
: '<div class="v-equity">' . wp_kses_post($equity) . '</div>';
|
||||
}
|
||||
$panes .= '</div><div class="pay-vip-products">';
|
||||
$panes .= $products;
|
||||
$panes .= $this->render_pay_methods($methods);
|
||||
$panes .= '<button type="button" class="but jb-red btn-block padding-lg radius aksrt-vip-submit" data-vip-submit>'
|
||||
. esc_html__('立即开通', 'aksrt') . '</button>';
|
||||
$panes .= '<p class="aksrt-vip-modal-tip muted-2-color em09 mt10" data-aksrt-vip-tip></p>';
|
||||
$panes .= '</div></div></div>';
|
||||
}
|
||||
|
||||
$points = '';
|
||||
if (!empty($catalog['points_exchange']['enabled']) && !empty($catalog['points_exchange']['packages'])) {
|
||||
$points = '<div class="mt10"><a class="but c-green btn-block" href="'
|
||||
. esc_url(function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('vip') : '#')
|
||||
. '">' . esc_html__('积分兑换会员', 'aksrt') . '</a></div>';
|
||||
}
|
||||
|
||||
$html = '<div class="box-body payvip-modal" data-aksrt-vip-modal>';
|
||||
$html .= '<button type="button" class="aksrt-modal-close" data-aksrt-modal-close aria-label="Close">×</button>';
|
||||
$html .= '<ul class="list-inline user-box flex ac mb10"><li class="avatar-img">' . $avatar . '</li>';
|
||||
$html .= '<li><b class="em12">' . esc_html($name) . '</b>';
|
||||
if ($desc !== '') {
|
||||
$html .= '<div class="muted-2-color em09">' . esc_html($desc) . '</div>';
|
||||
}
|
||||
$html .= '</li></ul>';
|
||||
$html .= '<ul class="flex jsb mt10 mb20 vip-cardminis gutters-5 nav">' . $tabs . '</ul>';
|
||||
$html .= '<div class="tab-content mt10">' . $panes . '</div>';
|
||||
if ($more !== '') {
|
||||
$html .= '<div class="muted-2-color mb10 em09 muted-box padding-10 mini-radius">' . $more . '</div>';
|
||||
}
|
||||
$html .= $points;
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $catalog
|
||||
* @param array<string,mixed> $vip
|
||||
*/
|
||||
private function render_modal_viped(int $user_id, array $catalog, array $vip, int $prefer_level): string {
|
||||
$user = get_userdata($user_id);
|
||||
$name = $user ? $user->display_name : '';
|
||||
$avatar = get_avatar($user_id, 42);
|
||||
$exp_label = (string) ($catalog['user_expires_label'] ?? '');
|
||||
$groups = is_array($catalog['level_packages'] ?? null) ? $catalog['level_packages'] : [];
|
||||
$methods = is_array($catalog['payment_methods'] ?? null) ? $catalog['payment_methods'] : [];
|
||||
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
|
||||
$more = (string) ($vip['more'] ?? '');
|
||||
|
||||
$tabs = '';
|
||||
$panes = '';
|
||||
$active_set = false;
|
||||
foreach ($groups as $g) {
|
||||
$lv = (int) ($g['level'] ?? 0);
|
||||
$action = (string) ($g['action'] ?? 'renew');
|
||||
$items = is_array($g['items'] ?? null) ? $g['items'] : [];
|
||||
if ($lv <= 0 || $items === []) {
|
||||
continue;
|
||||
}
|
||||
$is_active = !$active_set;
|
||||
if ($is_active) {
|
||||
$active_set = true;
|
||||
}
|
||||
$lv_name = (string) ($g['name'] ?? ($levels[$lv]['name'] ?? ('VIP' . $lv)));
|
||||
$badge = $action === 'upgrade' ? __('升级', 'aksrt') : __('续费', 'aksrt');
|
||||
$tab_id = 'tab-payvip-' . $lv . '-' . $action;
|
||||
$tabs .= '<li class="relative' . ($is_active ? ' active' : '') . '">'
|
||||
. '<a class="aksrt-vip-tab" href="javascript:;" data-vip-tab="' . esc_attr($tab_id) . '">'
|
||||
. esc_html($badge . $lv_name)
|
||||
. '</a></li>';
|
||||
$products = $this->render_product_radios($items);
|
||||
$equity = (string) ($levels[$lv]['equity'] ?? '');
|
||||
$panes .= '<div class="tab-pane aksrt-vip-pane' . ($is_active ? ' active' : '') . '" id="' . esc_attr($tab_id) . '"' . ($is_active ? '' : ' hidden') . '>';
|
||||
$panes .= '<div class="pay-vip-form-row">';
|
||||
$panes .= '<div class="pay-vip-info level-' . $lv . '">';
|
||||
$panes .= '<div class="v-name font-bold">' . esc_html($lv_name) . '</div>';
|
||||
if ($equity !== '') {
|
||||
$panes .= function_exists('aksrt_format_vip_equity_html')
|
||||
? aksrt_format_vip_equity_html($equity)
|
||||
: '<div class="v-equity">' . wp_kses_post($equity) . '</div>';
|
||||
}
|
||||
$panes .= '</div><div class="pay-vip-products">';
|
||||
$panes .= $products;
|
||||
$panes .= $this->render_pay_methods($methods);
|
||||
$cta = $action === 'upgrade' ? __('立即升级', 'aksrt') : __('立即续费', 'aksrt');
|
||||
$panes .= '<button type="button" class="but jb-red btn-block padding-lg radius aksrt-vip-submit" data-vip-submit>'
|
||||
. esc_html($cta) . '</button>';
|
||||
$panes .= '<p class="aksrt-vip-modal-tip muted-2-color em09 mt10" data-aksrt-vip-tip></p>';
|
||||
$panes .= '</div></div></div>';
|
||||
}
|
||||
|
||||
if ($tabs === '') {
|
||||
return '<div class="box-body payvip-modal text-center padding-20">'
|
||||
. esc_html__('当前没有可续费或升级的套餐。', 'aksrt')
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
$html = '<div class="box-body payvip-modal" data-aksrt-vip-modal>';
|
||||
$html .= '<button type="button" class="aksrt-modal-close" data-aksrt-modal-close aria-label="Close">×</button>';
|
||||
$html .= '<ul class="list-inline user-box flex ac mb10"><li class="avatar-img">' . $avatar . '</li>';
|
||||
$html .= '<li><b class="em12">' . esc_html($name) . '</b>';
|
||||
$html .= '<div class="muted-2-color em09"><span class="badg c-yellow vip-expdate-tag">'
|
||||
. esc_html($exp_label) . '</span></div></li></ul>';
|
||||
$html .= '<ul class="flex jsb mt10 mb20 vip-cardminis gutters-5 nav">' . $tabs . '</ul>';
|
||||
$html .= '<div class="tab-content mt10">' . $panes . '</div>';
|
||||
if ($more !== '') {
|
||||
$html .= '<div class="muted-2-color mb10 em09 muted-box padding-10 mini-radius">' . $more . '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $items
|
||||
*/
|
||||
private function render_product_radios(array $items): string {
|
||||
if ($items === []) {
|
||||
return '<div class="muted-2-color text-center padding-10">'
|
||||
. esc_html__('暂无可用套餐', 'aksrt') . '</div>';
|
||||
}
|
||||
$html = '<div class="mb10 label-box charge-box aksrt-vip-products">';
|
||||
$i = 0;
|
||||
foreach ($items as $item) {
|
||||
$pid = (int) ($item['package_id'] ?? 0);
|
||||
if ($pid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$i++;
|
||||
$price = (string) ($item['price_label'] ?? '');
|
||||
$show = (string) ($item['show_price_label'] ?? '');
|
||||
$tag = (string) ($item['discount_tag'] ?? '');
|
||||
$time = (string) ($item['days_label'] ?? '');
|
||||
$title = (string) ($item['title'] ?? '');
|
||||
$checked = $i === 1 ? ' checked' : '';
|
||||
$html .= '<label class="aksrt-vip-product-label">';
|
||||
$html .= '<input class="hide vip-product-input" type="radio" name="vip_product_id" value="'
|
||||
. esc_attr((string) $pid) . '"' . $checked . '>';
|
||||
$html .= '<div class="zib-widget vip-product relative text-center product-box">';
|
||||
if ($tag !== '') {
|
||||
$html .= '<div class="abs-right vip-tag badg jb-yellow">' . esc_html($tag) . '</div>';
|
||||
}
|
||||
if ($title !== '') {
|
||||
$html .= '<div class="em09 mb3">' . esc_html($title) . '</div>';
|
||||
}
|
||||
$html .= '<div class="flex abl gap6 jc"><div class="product-price c-red">' . esc_html($price) . '</div>';
|
||||
if ($show !== '') {
|
||||
$html .= '<div class="original-price relative muted-2-color"><s>' . esc_html($show) . '</s></div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
if ($time !== '') {
|
||||
$html .= '<div class="muted-2-color c-blue em09 p2-10 badg radius">' . esc_html($time) . '</div>';
|
||||
}
|
||||
$html .= '</div></label>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{id?:string,name?:string}> $methods
|
||||
*/
|
||||
private function render_pay_methods(array $methods): string {
|
||||
if ($methods === []) {
|
||||
return '<input type="hidden" name="aksrt_vip_pay_method" value="balance" data-aksrt-vip-method-fixed>';
|
||||
}
|
||||
if (count($methods) === 1) {
|
||||
$id = (string) ($methods[0]['id'] ?? 'balance');
|
||||
return '<input type="hidden" name="aksrt_vip_pay_method" value="' . esc_attr($id) . '" data-aksrt-vip-method-fixed>';
|
||||
}
|
||||
$html = '<div class="uc-vip-shop__methods mb10" data-aksrt-vip-methods>';
|
||||
$html .= '<span class="uc-vip-shop__methods-label">' . esc_html__('支付方式', 'aksrt') . '</span>';
|
||||
foreach ($methods as $i => $m) {
|
||||
$mid = (string) ($m['id'] ?? '');
|
||||
$mname = (string) ($m['name'] ?? $mid);
|
||||
$html .= '<label class="uc-vip-shop__method"><input type="radio" name="aksrt_vip_pay_method" value="'
|
||||
. esc_attr($mid) . '"' . ($i === 0 ? ' checked' : '') . '> <span>'
|
||||
. esc_html($mname) . '</span></label>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,7 +534,10 @@ trait VipServicePackages {
|
||||
|
||||
private function format_cash_label(float $amount): string {
|
||||
$symbol = '¥';
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
if (method_exists($this, 'get_paid_display_context')) {
|
||||
$paid = $this->get_paid_display_context();
|
||||
$symbol = (string) ($paid['display']['currency_symbol'] ?? '¥');
|
||||
} elseif (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$paid = \AKSRT\Settings\Settings::get_paid_content_settings();
|
||||
$symbol = (string) ($paid['display']['currency_symbol'] ?? '¥');
|
||||
}
|
||||
|
||||
+186
-132
@@ -9,27 +9,114 @@ defined('ABSPATH') || exit;
|
||||
|
||||
trait VipServicePurchase {
|
||||
|
||||
/** @var array<string,mixed>|null */
|
||||
private $vip_settings_cache = null;
|
||||
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
private $vip_member_cache = [];
|
||||
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
private $vip_catalog_cache = [];
|
||||
|
||||
/** @var array<string,mixed>|null */
|
||||
private $vip_paid_display_cache = null;
|
||||
|
||||
/**
|
||||
* Read payment settings once per request. The cache is kept on the
|
||||
* singleton instance and is never shared between users or requests.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function get_vip_settings_context(): array {
|
||||
if (is_array($this->vip_settings_cache)) {
|
||||
return $this->vip_settings_cache;
|
||||
}
|
||||
|
||||
$settings = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_payment_vip_settings()
|
||||
: [];
|
||||
$this->vip_settings_cache = is_array($settings)
|
||||
? $settings
|
||||
: ['enabled' => false, 'levels' => [], 'renew' => [], 'upgrade' => [], 'points_exchange' => []];
|
||||
return $this->vip_settings_cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current member state once per request and user.
|
||||
*
|
||||
* @return array{level:int,expires:int,is_permanent:bool,state:string,expires_label:string}
|
||||
*/
|
||||
private function get_vip_member_context(int $user_id): array {
|
||||
$user_id = max(0, $user_id);
|
||||
if (array_key_exists($user_id, $this->vip_member_cache)) {
|
||||
return $this->vip_member_cache[$user_id];
|
||||
}
|
||||
|
||||
$level = $user_id > 0 && function_exists('aksrt_get_user_vip_level')
|
||||
? (int) aksrt_get_user_vip_level($user_id)
|
||||
: 0;
|
||||
$expires = $user_id > 0 ? (int) get_user_meta($user_id, 'aksrt_vip_expires', true) : 0;
|
||||
$member_state = 'unopened';
|
||||
if ($level > 0) {
|
||||
$member_state = $expires <= 0
|
||||
? 'permanent'
|
||||
: ($expires < time() ? 'expired' : 'active');
|
||||
} elseif ($expires > 0 && $expires < time()) {
|
||||
// aksrt_get_user_vip_level() lazily clears expired levels; retain
|
||||
// the observable expired state for this response without reviving it.
|
||||
$member_state = 'expired';
|
||||
}
|
||||
$state = [
|
||||
'level' => max(0, $level),
|
||||
'expires' => $expires,
|
||||
'is_permanent' => $level > 0 && $expires <= 0,
|
||||
'state' => $member_state,
|
||||
'expires_label' => $member_state === 'expired'
|
||||
? __('已过期', 'aksrt')
|
||||
: $this->format_expires_label($expires, $level),
|
||||
];
|
||||
$this->vip_member_cache[$user_id] = $state;
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache paid-content display settings alongside the VIP request context.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function get_paid_display_context(): array {
|
||||
if (is_array($this->vip_paid_display_cache)) {
|
||||
return $this->vip_paid_display_cache;
|
||||
}
|
||||
$settings = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_paid_content_settings()
|
||||
: [];
|
||||
$this->vip_paid_display_cache = is_array($settings) ? $settings : [];
|
||||
return $this->vip_paid_display_cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function get_catalog(int $user_id = 0): array {
|
||||
$vip = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_payment_vip_settings()
|
||||
: ['enabled' => false, 'levels' => [], 'renew' => [], 'upgrade' => [], 'points_exchange' => []];
|
||||
$user_id = max(0, $user_id);
|
||||
if (array_key_exists($user_id, $this->vip_catalog_cache)) {
|
||||
return $this->vip_catalog_cache[$user_id];
|
||||
}
|
||||
|
||||
$level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0;
|
||||
$expires = $user_id > 0 ? (int) get_user_meta($user_id, 'aksrt_vip_expires', true) : 0;
|
||||
$is_permanent = ($level > 0 && $expires <= 0);
|
||||
$vip = $this->get_vip_settings_context();
|
||||
$member = $this->get_vip_member_context($user_id);
|
||||
$level = (int) $member['level'];
|
||||
$expires = (int) $member['expires'];
|
||||
$is_permanent = !empty($member['is_permanent']);
|
||||
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
|
||||
$renew = is_array($vip['renew'] ?? null) ? $vip['renew'] : [];
|
||||
$upgrade = is_array($vip['upgrade'] ?? null) ? $vip['upgrade'] : [];
|
||||
$points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : [];
|
||||
$renew_enabled = !isset($renew['enabled']) || !empty($renew['enabled']);
|
||||
$upgrade_enabled = !isset($upgrade['enabled']) || !empty($upgrade['enabled']);
|
||||
$points_enabled = !empty($points_ex['enabled'])
|
||||
&& (!class_exists('\AKSRT\Settings\Settings') || \AKSRT\Settings\Settings::credits_enabled());
|
||||
$credits_enabled = !class_exists('\AKSRT\Settings\Settings') || \AKSRT\Settings\Settings::credits_enabled();
|
||||
$points_enabled = !empty($points_ex['enabled']) && $credits_enabled;
|
||||
|
||||
$level_packages = [];
|
||||
foreach ([1, 2] as $lv) {
|
||||
@@ -141,19 +228,20 @@ trait VipServicePurchase {
|
||||
$methods = $this->available_payment_methods();
|
||||
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
|
||||
|
||||
return [
|
||||
$catalog = [
|
||||
'enabled' => !empty($vip['enabled']),
|
||||
'desc' => (string) ($vip['desc'] ?? ''),
|
||||
'help_text' => (string) ($vip['help_text'] ?? ''),
|
||||
'user_level' => $level,
|
||||
'user_expires' => $expires,
|
||||
'user_is_permanent' => $is_permanent,
|
||||
'user_expires_label' => $this->format_expires_label($expires, $level),
|
||||
'user_expires_label' => (string) ($member['expires_label'] ?? ''),
|
||||
'user_points' => $points_balance,
|
||||
'wallet_balance' => $wallet_balance,
|
||||
'currency_name' => $currency_name,
|
||||
'renew_enabled' => $renew_enabled,
|
||||
'upgrade_enabled' => $upgrade_enabled,
|
||||
'credits_enabled' => $credits_enabled,
|
||||
'renew_desc' => (string) ($renew['desc'] ?? ''),
|
||||
'upgrade_desc' => (string) ($upgrade['desc'] ?? ''),
|
||||
'level_packages' => $level_packages,
|
||||
@@ -168,6 +256,36 @@ trait VipServicePurchase {
|
||||
: (function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/')),
|
||||
'logged_in' => $user_id > 0,
|
||||
];
|
||||
$this->vip_catalog_cache[$user_id] = $catalog;
|
||||
return $catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a package in the server-derived catalog.
|
||||
*
|
||||
* The returned group name is request-local metadata used only for the
|
||||
* order subject; price and entitlement data remain server-derived.
|
||||
*
|
||||
* @param array<string,mixed> $catalog
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function find_catalog_package(array $catalog, int $package_id): ?array {
|
||||
$groups = is_array($catalog['level_packages'] ?? null) ? $catalog['level_packages'] : [];
|
||||
foreach ($groups as $group) {
|
||||
if (!is_array($group)) {
|
||||
continue;
|
||||
}
|
||||
$group_name = (string) ($group['name'] ?? '');
|
||||
$items = is_array($group['items'] ?? null) ? $group['items'] : [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item) || (int) ($item['package_id'] ?? 0) !== $package_id) {
|
||||
continue;
|
||||
}
|
||||
$item['_level_name'] = $group_name !== '' ? $group_name : ('VIP' . (int) ($item['level'] ?? 1));
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -180,106 +298,36 @@ trait VipServicePurchase {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录后再开通会员。', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
|
||||
$decoded = $this->decode_package_id($package_id);
|
||||
if (!$decoded) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('套餐无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$level = (int) $decoded['level'];
|
||||
$index = (int) $decoded['index'];
|
||||
|
||||
$vip = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_payment_vip_settings()
|
||||
: ['enabled' => false, 'levels' => [], 'renew' => [], 'upgrade' => []];
|
||||
if (empty($vip['enabled'])) {
|
||||
$catalog = $this->get_catalog($user_id);
|
||||
if (empty($catalog['enabled'])) {
|
||||
return new \WP_Error('aksrt_vip_disabled', __('会员功能未启用', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
|
||||
if (empty($levels[$level]['enabled'])) {
|
||||
return new \WP_Error('aksrt_vip_level_disabled', __('该会员等级未启用', 'aksrt'), ['status' => 400]);
|
||||
|
||||
$pkg = $this->find_catalog_package($catalog, $package_id);
|
||||
if (!$pkg) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('套餐不存在或当前不可购买', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$user_level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0;
|
||||
$expires = (int) get_user_meta($user_id, 'aksrt_vip_expires', true);
|
||||
$is_permanent = ($user_level > 0 && $expires <= 0);
|
||||
$renew = is_array($vip['renew'] ?? null) ? $vip['renew'] : [];
|
||||
$upgrade = is_array($vip['upgrade'] ?? null) ? $vip['upgrade'] : [];
|
||||
$renew_enabled = !isset($renew['enabled']) || !empty($renew['enabled']);
|
||||
$upgrade_enabled = !isset($upgrade['enabled']) || !empty($upgrade['enabled']);
|
||||
|
||||
if ($is_permanent && $level <= $user_level) {
|
||||
return new \WP_Error('aksrt_vip_permanent', __('您已是永久会员,无需续费。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($user_level > 0 && $level === $user_level && !$renew_enabled) {
|
||||
return new \WP_Error('aksrt_vip_renew_disabled', __('续费功能未开启。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($user_level > 0 && $level > $user_level && !$upgrade_enabled) {
|
||||
return new \WP_Error('aksrt_vip_upgrade_disabled', __('升级功能未开启。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($user_level > 0 && $level < $user_level) {
|
||||
return new \WP_Error('aksrt_vip_downgrade', __('不支持降级购买。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$action = 'pay';
|
||||
if ($user_level > 0 && $level === $user_level) {
|
||||
$action = 'renew';
|
||||
} elseif ($user_level > 0 && $level > $user_level) {
|
||||
$action = 'upgrade';
|
||||
}
|
||||
|
||||
// Dedicated upgrade product:
|
||||
// 901 permanent/fixed, 902 unit remaining days, 903 jump permanent.
|
||||
$upgrade_mode = '';
|
||||
$pkg_time = 0;
|
||||
$pkg_unit = 'day';
|
||||
if (in_array($index, [901, 902, 903], true)) {
|
||||
$upgrade_items = $this->build_upgrade_items($user_id, $expires, $is_permanent, (string) ($upgrade['product'] ?? ''));
|
||||
$match = null;
|
||||
foreach ($upgrade_items as $ui) {
|
||||
if ((int) ($ui['package_id'] ?? 0) === $package_id) {
|
||||
$match = $ui;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$match || $level !== 2) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('升级套餐不存在', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$pkg = [
|
||||
'price' => (float) $match['price'],
|
||||
'days' => (int) $match['days'],
|
||||
'title' => (string) $match['title'],
|
||||
];
|
||||
$amount = round((float) $match['price'], 2);
|
||||
$days = (int) $match['days'];
|
||||
$pkg_time = $days; // unit upgrade keeps days as remaining; jump/permanent use 0
|
||||
$level = (int) ($pkg['level'] ?? 0);
|
||||
$index = (int) ($pkg['index'] ?? 0);
|
||||
$action = sanitize_key((string) ($pkg['action'] ?? 'pay'));
|
||||
$amount = round((float) ($pkg['price'] ?? 0), 2);
|
||||
$base_price = (float) ($pkg['base_price'] ?? $amount);
|
||||
$days = (int) ($pkg['days'] ?? 0);
|
||||
$pkg_time = array_key_exists('time', $pkg) ? (int) $pkg['time'] : $days;
|
||||
$pkg_unit = sanitize_key((string) ($pkg['unit'] ?? 'day'));
|
||||
if (!in_array($pkg_unit, ['day', 'month'], true)) {
|
||||
$pkg_unit = 'day';
|
||||
$base_price = (float) ($match['base_price'] ?? $amount);
|
||||
$discount_tag = (string) ($match['discount_tag'] ?? __('升级专享', 'aksrt'));
|
||||
$upgrade_mode = sanitize_key((string) ($match['upgrade_mode'] ?? ''));
|
||||
$action = 'upgrade';
|
||||
if ($upgrade_mode === 'jump' || $upgrade_mode === 'permanent') {
|
||||
$pkg_time = 0;
|
||||
$days = 0;
|
||||
}
|
||||
} else {
|
||||
$level_cfg = is_array($levels[$level] ?? null) ? $levels[$level] : [];
|
||||
if (isset($level_cfg['product_s']) && empty($level_cfg['product_s'])) {
|
||||
return new \WP_Error('aksrt_vip_product_disabled', __('该等级暂不支持购买', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$packages = $this->parse_level_products((string) ($level_cfg['products_raw'] ?? ''));
|
||||
$pkg = $packages[$index - 1] ?? null;
|
||||
if (!$pkg) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('套餐不存在', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$base_price = (float) $pkg['price'];
|
||||
$days = (int) $pkg['days'];
|
||||
$pkg_time = (int) ($pkg['time'] ?? 0);
|
||||
$pkg_unit = (string) ($pkg['unit'] ?? 'day');
|
||||
$priced = $this->apply_action_price($base_price, $action, $renew, $upgrade, $level, $user_level, $expires, $is_permanent, $days);
|
||||
if ($priced === null) {
|
||||
return new \WP_Error('aksrt_invalid_package', __('当前无法购买该套餐', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$amount = round((float) $priced['price'], 2);
|
||||
$discount_tag = (string) ($priced['tag'] ?? '');
|
||||
}
|
||||
$discount_tag = (string) ($pkg['discount_tag'] ?? '');
|
||||
$upgrade_mode = sanitize_key((string) ($pkg['upgrade_mode'] ?? ''));
|
||||
if (in_array($upgrade_mode, ['jump', 'permanent'], true)) {
|
||||
$pkg_time = 0;
|
||||
$days = 0;
|
||||
}
|
||||
|
||||
if ($level < 1 || $level > 2 || $index < 1 || $action === '') {
|
||||
return new \WP_Error('aksrt_invalid_package', __('套餐无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
if ($amount <= 0) {
|
||||
@@ -310,6 +358,7 @@ trait VipServicePurchase {
|
||||
'discount_tag' => $discount_tag,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$expires_at = gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS);
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
|
||||
@@ -319,27 +368,36 @@ trait VipServicePurchase {
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
|
||||
'expires_at' => $expires_at,
|
||||
]);
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
|
||||
if (!$order) {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$level_name = (string) ($levels[$level]['name'] ?? ('VIP' . $level));
|
||||
// Build the request-local order context from the same server-derived
|
||||
// package used for validation. PaymentOrchestrator still reloads the
|
||||
// persisted row under its attempt lock before touching the gateway.
|
||||
$level_name = (string) ($pkg['_level_name'] ?? ('VIP' . $level));
|
||||
$action_label = $action === 'renew' ? __('续费', 'aksrt') : ($action === 'upgrade' ? __('升级', 'aksrt') : __('开通', 'aksrt'));
|
||||
$order['subject'] = sprintf(
|
||||
/* translators: 1: action 2: vip name 3: package title */
|
||||
__('%1$s%2$s · %3$s', 'aksrt'),
|
||||
$action_label,
|
||||
$level_name,
|
||||
(string) ($pkg['title'] ?? '')
|
||||
);
|
||||
$order['payment_method'] = $payment_method;
|
||||
$order = [
|
||||
'order_number' => $order_number,
|
||||
'user_id' => $user_id,
|
||||
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP,
|
||||
'product_id' => $package_id,
|
||||
'amount' => $amount,
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => $expires_at,
|
||||
'subject' => sprintf(
|
||||
/* translators: 1: action 2: vip name 3: package title */
|
||||
__('%1$s%2$s · %3$s', 'aksrt'),
|
||||
$action_label,
|
||||
$level_name,
|
||||
(string) ($pkg['title'] ?? '')
|
||||
),
|
||||
];
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Gateway')) {
|
||||
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]);
|
||||
@@ -355,6 +413,7 @@ trait VipServicePurchase {
|
||||
|
||||
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
|
||||
'paid_note' => __('用户完成会员开通支付。', 'aksrt'),
|
||||
'order_context' => $order,
|
||||
]);
|
||||
if (empty($payment_result['success'])) {
|
||||
return new \WP_Error(
|
||||
@@ -568,28 +627,23 @@ trait VipServicePurchase {
|
||||
if (!class_exists('\AKSRT\Credits\Credits')) {
|
||||
return new \WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::credits_enabled()) {
|
||||
$catalog = $this->get_catalog($user_id);
|
||||
if (array_key_exists('credits_enabled', $catalog) && empty($catalog['credits_enabled'])) {
|
||||
return new \WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$vip = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_payment_vip_settings()
|
||||
: [];
|
||||
$points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : [];
|
||||
$points_ex = is_array($catalog['points_exchange'] ?? null) ? $catalog['points_exchange'] : [];
|
||||
if (empty($points_ex['enabled'])) {
|
||||
return new \WP_Error('aksrt_points_exchange_disabled', __('积分兑换会员未启用', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$user_level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0;
|
||||
if ($user_level > 0) {
|
||||
if ((int) ($catalog['user_level'] ?? 0) > 0) {
|
||||
return new \WP_Error('aksrt_already_vip', __('您已是会员,无法使用积分兑换。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
|
||||
$packages = $this->get_points_exchange_packages($vip, $levels);
|
||||
$match = null;
|
||||
$packages = is_array($points_ex['packages'] ?? null) ? $points_ex['packages'] : [];
|
||||
foreach ($packages as $pkg) {
|
||||
if ((int) ($pkg['package_id'] ?? 0) === $package_id) {
|
||||
if (is_array($pkg) && (int) ($pkg['package_id'] ?? 0) === $package_id) {
|
||||
$match = $pkg;
|
||||
break;
|
||||
}
|
||||
@@ -605,7 +659,7 @@ trait VipServicePurchase {
|
||||
return new \WP_Error('aksrt_invalid_price', __('兑换积分无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$balance = \AKSRT\Credits\Credits::balance($user_id);
|
||||
$balance = (int) ($catalog['user_points'] ?? 0);
|
||||
if ($balance < $cost) {
|
||||
return new \WP_Error('aksrt_credits_insufficient', __('积分不足,暂时无法兑换', 'aksrt'), [
|
||||
'status' => 402,
|
||||
|
||||
@@ -59,11 +59,10 @@ trait VipServiceRest {
|
||||
if ($level < 1 || $level > 2) {
|
||||
$level = 1;
|
||||
}
|
||||
$html = $this->render_modal_html($uid, $level);
|
||||
return new \WP_REST_Response([
|
||||
'html' => $html,
|
||||
'user_level' => function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($uid) : 0,
|
||||
], 200);
|
||||
return $this->no_store(new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $this->get_modal_view_model($uid, $level),
|
||||
], 200));
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +89,7 @@ trait VipServiceRest {
|
||||
*/
|
||||
public function rest_catalog(\WP_REST_Request $request) {
|
||||
$uid = get_current_user_id();
|
||||
return new \WP_REST_Response($this->get_catalog($uid), 200);
|
||||
return $this->no_store(new \WP_REST_Response($this->get_catalog($uid), 200));
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +128,7 @@ trait VipServiceRest {
|
||||
['status' => (int) ($result['status'] ?? 400)]
|
||||
);
|
||||
}
|
||||
return new \WP_REST_Response($result, 200);
|
||||
return $this->no_store(new \WP_REST_Response($result, 200));
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +151,15 @@ trait VipServiceRest {
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return new \WP_REST_Response($result, 200);
|
||||
return $this->no_store(new \WP_REST_Response($result, 200));
|
||||
}
|
||||
|
||||
/**
|
||||
* Private member/catalog payloads must not be stored by intermediaries.
|
||||
*/
|
||||
private function no_store(\WP_REST_Response $response): \WP_REST_Response {
|
||||
$response->header('Cache-Control', 'private, no-store, max-age=0');
|
||||
return $response;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class DBMigrator {
|
||||
use DBMigratorIdentity;
|
||||
|
||||
/** Current schema version; changing it re-runs dbDelta. */
|
||||
const SCHEMA_VERSION = '1.17.0';
|
||||
const SCHEMA_VERSION = '1.18.0';
|
||||
|
||||
const OPT_KEY = 'aksrt_db_schema_version';
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_order_number (order_number),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_user_status_created (user_id, status, created_at),
|
||||
KEY idx_status (status),
|
||||
KEY idx_created (created_at),
|
||||
KEY idx_product (product_type, product_id)
|
||||
|
||||
@@ -209,9 +209,24 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js';
|
||||
);
|
||||
}
|
||||
|
||||
// VIP modal (zibll .pay-vip)
|
||||
// Shared VIP purchase/poll coordinator for modal and credits surfaces.
|
||||
$vip_on = class_exists('\AKSRT\Settings\Settings')
|
||||
&& !empty(\AKSRT\Settings\Settings::get_payment_vip_settings()['enabled']);
|
||||
$credits_on = is_user_logged_in()
|
||||
&& class_exists('\AKSRT\Settings\Settings')
|
||||
&& \AKSRT\Settings\Settings::credits_enabled();
|
||||
$vip_coordinator = get_template_directory() . '/assets/js/vip-purchase-coordinator.js';
|
||||
if (($vip_on || $credits_on) && file_exists($vip_coordinator)) {
|
||||
wp_enqueue_script(
|
||||
'aksrt-vip-coordinator',
|
||||
$base_uri . '/assets/js/vip-purchase-coordinator.js',
|
||||
['aksrt-core-request'],
|
||||
(string) filemtime($vip_coordinator),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// VIP modal (zibll .pay-vip)
|
||||
if ($vip_on) {
|
||||
$vip_css = get_template_directory() . '/assets/css/frontend/vip-modal.css';
|
||||
if (file_exists($vip_css)) {
|
||||
@@ -227,7 +242,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js';
|
||||
wp_enqueue_script(
|
||||
'aksrt-pay-vip',
|
||||
$base_uri . '/assets/js/pay-vip.js',
|
||||
['aksrt-script', 'aksrt-core-request', 'aksrt-core-modal-runtime'],
|
||||
['aksrt-script', 'aksrt-core-request', 'aksrt-core-modal-runtime', 'aksrt-vip-coordinator'],
|
||||
(string) filemtime($vip_js),
|
||||
true
|
||||
);
|
||||
@@ -449,7 +464,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js';
|
||||
wp_enqueue_script(
|
||||
'aksrt-route-user-center-auth',
|
||||
$base_uri . '/assets/js/routes/user-center-auth.js',
|
||||
['aksrt-script'],
|
||||
['aksrt-script', 'aksrt-core-request', 'aksrt-core-modal-runtime'],
|
||||
(string) filemtime($identity_js),
|
||||
true
|
||||
);
|
||||
@@ -875,7 +890,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js';
|
||||
}
|
||||
|
||||
// 积分相关:已登录 + 启用积分功能时全站加载
|
||||
if (is_user_logged_in() && class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled()) {
|
||||
if ($credits_on) {
|
||||
$enqueue_module('credits');
|
||||
|
||||
// profile 页:加载 credits.js(签到 + 邀请交互)
|
||||
@@ -884,7 +899,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js';
|
||||
wp_enqueue_script(
|
||||
'aksrt-credits',
|
||||
$base_uri . '/assets/js/credits.js',
|
||||
['aksrt-script'],
|
||||
['aksrt-script', 'aksrt-core-modal-runtime', 'aksrt-vip-coordinator'],
|
||||
file_exists($credits_js_path) ? (string) filemtime($credits_js_path) : $version,
|
||||
true
|
||||
);
|
||||
|
||||
@@ -17,10 +17,27 @@ final class ApplicationRepository {
|
||||
|
||||
private string $table;
|
||||
|
||||
/** @var array<int,array{latest:array<string,mixed>|null,active:array<string,mixed>|null,verified:array<string,mixed>|null}> */
|
||||
private static array $snapshot_cache = [];
|
||||
|
||||
public function __construct() {
|
||||
$this->table = \AKSRT\Core\DBMigrator::table('identity_application');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the request-local application snapshot cache.
|
||||
*
|
||||
* Writes can change the active/latest/verified selection, so callers must
|
||||
* invalidate before reading the next view in the same request.
|
||||
*/
|
||||
public static function clear_snapshot_cache(int $user_id = 0): void {
|
||||
if ($user_id > 0) {
|
||||
unset(self::$snapshot_cache[$user_id]);
|
||||
return;
|
||||
}
|
||||
self::$snapshot_cache = [];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function create(array $data) {
|
||||
global $wpdb;
|
||||
@@ -47,6 +64,7 @@ final class ApplicationRepository {
|
||||
if ($inserted !== 1) {
|
||||
return new \WP_Error('aksrt_identity_create', __('认证申请创建失败', 'aksrt'));
|
||||
}
|
||||
self::clear_snapshot_cache((int) $row['user_id']);
|
||||
return $this->find_by_id((int) $wpdb->insert_id);
|
||||
}
|
||||
|
||||
@@ -75,36 +93,63 @@ final class ApplicationRepository {
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function latest_for_user(int $user_id): ?array {
|
||||
/**
|
||||
* Load the latest, active, and verified applications in one request-local
|
||||
* context. The three views are selected from one ordered result set so
|
||||
* facade callers cannot repeat the same application SELECT.
|
||||
*
|
||||
* @return array{latest:array<string,mixed>|null,active:array<string,mixed>|null,verified:array<string,mixed>|null}
|
||||
*/
|
||||
public function snapshot_for_user(int $user_id): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['latest' => null, 'active' => null, 'verified' => null];
|
||||
}
|
||||
if (array_key_exists($user_id, self::$snapshot_cache)) {
|
||||
return self::$snapshot_cache[$user_id];
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE user_id = %d ORDER BY id DESC LIMIT 1",
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE user_id = %d ORDER BY id DESC",
|
||||
$user_id
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
$snapshot = ['latest' => null, 'active' => null, 'verified' => null];
|
||||
foreach ((array) $rows as $raw) {
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
$record = $this->normalize($raw);
|
||||
if ($snapshot['latest'] === null) {
|
||||
$snapshot['latest'] = $record;
|
||||
}
|
||||
if ($snapshot['active'] === null && in_array((string) $record['status'], self::ACTIVE_STATUSES, true)) {
|
||||
$snapshot['active'] = $record;
|
||||
}
|
||||
if ((string) $record['status'] === 'verified') {
|
||||
$current = $snapshot['verified'];
|
||||
if ($current === null || $this->verified_is_newer($record, $current)) {
|
||||
$snapshot['verified'] = $record;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$snapshot_cache[$user_id] = $snapshot;
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function latest_for_user(int $user_id): ?array {
|
||||
return $this->snapshot_for_user($user_id)['latest'];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function active_for_user(int $user_id): ?array {
|
||||
global $wpdb;
|
||||
$placeholders = implode(',', array_fill(0, count(self::ACTIVE_STATUSES), '%s'));
|
||||
$params = array_merge([$user_id], self::ACTIVE_STATUSES);
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE user_id = %d AND status IN ({$placeholders}) ORDER BY id DESC LIMIT 1",
|
||||
$params
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
return $this->snapshot_for_user($user_id)['active'];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function verified_for_user(int $user_id): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE user_id = %d AND status = 'verified' ORDER BY completed_at DESC, id DESC LIMIT 1",
|
||||
$user_id
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
return $this->snapshot_for_user($user_id)['verified'];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
@@ -125,7 +170,11 @@ final class ApplicationRepository {
|
||||
$formats[$index] = '%f';
|
||||
}
|
||||
}
|
||||
return $wpdb->update($this->table, $fields, ['id' => $id, 'status' => $expected], $formats, ['%d', '%s']) === 1;
|
||||
$updated = $wpdb->update($this->table, $fields, ['id' => $id, 'status' => $expected], $formats, ['%d', '%s']) === 1;
|
||||
if ($updated) {
|
||||
self::clear_snapshot_cache();
|
||||
}
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
@@ -136,6 +185,9 @@ final class ApplicationRepository {
|
||||
}
|
||||
$fields['updated_at'] = current_time('mysql', true);
|
||||
$updated = $wpdb->update($this->table, $fields, ['id' => $id]);
|
||||
if ($updated !== false) {
|
||||
self::clear_snapshot_cache();
|
||||
}
|
||||
return $updated !== false;
|
||||
}
|
||||
|
||||
@@ -150,7 +202,21 @@ final class ApplicationRepository {
|
||||
|
||||
public function delete(int $id): bool {
|
||||
global $wpdb;
|
||||
return $wpdb->delete($this->table, ['id' => $id], ['%d']) === 1;
|
||||
$deleted = $wpdb->delete($this->table, ['id' => $id], ['%d']) === 1;
|
||||
if ($deleted) {
|
||||
self::clear_snapshot_cache();
|
||||
}
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $record @param array<string,mixed> $current */
|
||||
private function verified_is_newer(array $record, array $current): bool {
|
||||
$record_time = strtotime((string) ($record['completed_at'] ?? '')) ?: 0;
|
||||
$current_time = strtotime((string) ($current['completed_at'] ?? '')) ?: 0;
|
||||
if ($record_time !== $current_time) {
|
||||
return $record_time > $current_time;
|
||||
}
|
||||
return (int) ($record['id'] ?? 0) > (int) ($current['id'] ?? 0);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
|
||||
@@ -189,7 +189,6 @@ final class ApplicationService {
|
||||
'result_code' => (string) $application['provider_result_code'],
|
||||
'message' => (string) $application['provider_result_summary'],
|
||||
'redirect_url' => esc_url_raw((string) ($application['redirect_url'] ?? '')),
|
||||
'qr_payload' => sanitize_text_field((string) ($application['qr_payload'] ?? '')),
|
||||
'expires_at' => (string) ($application['expires_at'] ?? ''),
|
||||
'created_at' => (string) $application['created_at'],
|
||||
'poll_interval' => (int) SettingsRuntime::get()['poll_interval_seconds'],
|
||||
|
||||
@@ -13,6 +13,9 @@ final class RestController {
|
||||
|
||||
private const NS = 'aksrt/v1';
|
||||
|
||||
private ?array $request_application = null;
|
||||
private string $request_uuid = '';
|
||||
|
||||
public function __construct(
|
||||
private ?ApplicationRepository $applications = null,
|
||||
private ?ApplicationService $service = null
|
||||
@@ -57,7 +60,7 @@ final class RestController {
|
||||
if (!is_user_logged_in()) {
|
||||
return false;
|
||||
}
|
||||
$application = $this->applications->find_by_uuid((string) $request['uuid']);
|
||||
$application = $this->application_for_request($request);
|
||||
return is_array($application) && (int) $application['user_id'] === get_current_user_id();
|
||||
}
|
||||
|
||||
@@ -69,34 +72,74 @@ final class RestController {
|
||||
}
|
||||
|
||||
public function current(): \WP_REST_Response {
|
||||
$application = $this->applications->latest_for_user(get_current_user_id());
|
||||
return new \WP_REST_Response(['success' => true, 'data' => $application ? $this->service->view($application) : null]);
|
||||
$user_id = get_current_user_id();
|
||||
$data = class_exists('\\AKSRT\\User\\AuthApply')
|
||||
? \AKSRT\User\AuthApply::snapshot_view($user_id)
|
||||
: ['user_id' => $user_id, 'current' => null, 'active' => null, 'latest' => null];
|
||||
return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $data]));
|
||||
}
|
||||
|
||||
public function get(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$application = $this->applications->find_by_uuid((string) $request['uuid']);
|
||||
return new \WP_REST_Response(['success' => true, 'data' => $this->service->view($application)]);
|
||||
$application = $this->application_for_request($request);
|
||||
if (!$application) {
|
||||
return $this->no_store(new \WP_REST_Response([
|
||||
'success' => false,
|
||||
'code' => 'aksrt_identity_missing',
|
||||
'message' => __('认证申请不存在', 'aksrt'),
|
||||
], 404));
|
||||
}
|
||||
return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $this->service->view($application)]));
|
||||
}
|
||||
|
||||
public function refresh(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$application = $this->applications->find_by_uuid((string) $request['uuid']);
|
||||
$application = $this->application_for_request($request);
|
||||
if (!$application) {
|
||||
return $this->no_store(new \WP_REST_Response([
|
||||
'success' => false,
|
||||
'code' => 'aksrt_identity_missing',
|
||||
'message' => __('认证申请不存在', 'aksrt'),
|
||||
], 404));
|
||||
}
|
||||
return $this->respond($this->service->refresh($application));
|
||||
}
|
||||
|
||||
public function manual(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$application = $this->applications->find_by_uuid((string) $request['uuid']);
|
||||
$application = $this->application_for_request($request);
|
||||
if (!$application) {
|
||||
return $this->no_store(new \WP_REST_Response([
|
||||
'success' => false,
|
||||
'code' => 'aksrt_identity_missing',
|
||||
'message' => __('认证申请不存在', 'aksrt'),
|
||||
], 404));
|
||||
}
|
||||
return $this->respond($this->service->move_to_manual($application, $_FILES['evidence'] ?? null));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function application_for_request(\WP_REST_Request $request): ?array {
|
||||
$uuid = (string) $request['uuid'];
|
||||
if ($uuid !== $this->request_uuid) {
|
||||
$this->request_uuid = $uuid;
|
||||
$this->request_application = $this->applications->find_by_uuid($uuid);
|
||||
}
|
||||
return is_array($this->request_application) ? $this->request_application : null;
|
||||
}
|
||||
|
||||
private function respond($result, int $success_status = 200): \WP_REST_Response {
|
||||
if (is_wp_error($result)) {
|
||||
$status = in_array($result->get_error_code(), ['aksrt_identity_active', 'aksrt_identity_verified', 'aksrt_identity_conflict'], true) ? 409 : 422;
|
||||
return new \WP_REST_Response([
|
||||
return $this->no_store(new \WP_REST_Response([
|
||||
'success' => false,
|
||||
'code' => $result->get_error_code(),
|
||||
'message' => $result->get_error_message(),
|
||||
], $status);
|
||||
], $status));
|
||||
}
|
||||
return new \WP_REST_Response(['success' => true, 'data' => $result], $success_status);
|
||||
return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $result], $success_status));
|
||||
}
|
||||
|
||||
private function no_store(\WP_REST_Response $response): \WP_REST_Response {
|
||||
$response->header('Cache-Control', 'no-store, private');
|
||||
$response->header('Pragma', 'no-cache');
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
+30
-9
@@ -243,6 +243,18 @@ class Gateway {
|
||||
return sanitize_text_field((string) ($order['order_number'] ?? $provider_ref));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pending order can be reconciled against an external provider.
|
||||
*
|
||||
* This is the single gate used by the order view and the actual reconcile
|
||||
* operation, so ordinary detail reads never trigger provider I/O.
|
||||
*
|
||||
* @param array<string,mixed>|null $order
|
||||
*/
|
||||
public function needs_reconciliation(?array $order): bool {
|
||||
return null !== $this->reconciliation_gateway($order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a pending external order against the configured provider.
|
||||
*
|
||||
@@ -254,18 +266,11 @@ class Gateway {
|
||||
}
|
||||
$repo = OrderRepository::get_instance();
|
||||
$order = is_array($order) ? $order : $repo->get($order_number);
|
||||
if (!$order || !in_array((string) ($order['status'] ?? ''), [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
|
||||
$gateway = $this->reconciliation_gateway($order);
|
||||
if (!$gateway) {
|
||||
return $order;
|
||||
}
|
||||
|
||||
$gateway_id = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
if ($gateway_id === '' || in_array($gateway_id, ['balance', 'points', 'manual_review'], true)) {
|
||||
return $order;
|
||||
}
|
||||
$gateway = $this->get_gateway($gateway_id);
|
||||
if (!$gateway || !$gateway->is_configured()) {
|
||||
return $order;
|
||||
}
|
||||
|
||||
$throttle_key = 'aksrt_reconcile_' . hash('sha256', $order_number);
|
||||
if (get_transient($throttle_key)) {
|
||||
@@ -297,6 +302,22 @@ class Gateway {
|
||||
return $repo->get($order_number) ?: $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $order
|
||||
*/
|
||||
private function reconciliation_gateway(?array $order): ?GatewayInterface {
|
||||
if (!is_array($order) || !in_array((string) ($order['status'] ?? ''), [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$gateway_id = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
if ($gateway_id === '' || in_array($gateway_id, ['balance', 'points', 'manual_review'], true)) {
|
||||
return null;
|
||||
}
|
||||
$gateway = $this->get_gateway($gateway_id);
|
||||
return $gateway && $gateway->is_configured() ? $gateway : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等、带金额校验的订单完成。仅由验签通过的回调调用。
|
||||
*
|
||||
|
||||
@@ -61,6 +61,12 @@ class OrderCenterRest {
|
||||
'permission_callback' => [$this, 'require_logged_in'],
|
||||
'args' => $route_args,
|
||||
]);
|
||||
register_rest_route($namespace, '/orders/' . $order_number . '/refresh', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'refresh'],
|
||||
'permission_callback' => [$this, 'require_logged_in'],
|
||||
'args' => $route_args,
|
||||
]);
|
||||
register_rest_route($namespace, '/orders/' . $order_number, [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_order'],
|
||||
@@ -108,6 +114,14 @@ class OrderCenterRest {
|
||||
: $this->error((string) ($result['message'] ?? __('关闭订单失败', 'aksrt')), 400);
|
||||
}
|
||||
|
||||
public function refresh(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$order = UserOrders::get_instance()->refresh_order(
|
||||
get_current_user_id(),
|
||||
sanitize_text_field((string) $request->get_param('order_number'))
|
||||
);
|
||||
return $order ? $this->success(['data' => $order]) : $this->error(__('订单不存在', 'aksrt'), 404);
|
||||
}
|
||||
|
||||
public function get_order(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$order = UserOrders::get_instance()->get_order_detail_view(
|
||||
get_current_user_id(),
|
||||
|
||||
@@ -100,46 +100,14 @@ trait OrderRepositoryCore {
|
||||
public function get_user_orders(int $user_id, array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
$statuses = array_values(array_filter(array_map('sanitize_key', (array) ($args['status_in'] ?? []))));
|
||||
if ($statuses !== []) {
|
||||
$where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')';
|
||||
array_push($params, ...$statuses);
|
||||
} else {
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? '');
|
||||
if ($order_number_prefix !== '') {
|
||||
$where[] = 'order_number LIKE %s';
|
||||
$params[] = $wpdb->esc_like($order_number_prefix) . '%';
|
||||
}
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$parts = $this->get_user_order_query_parts($user_id, $args);
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params = $parts['params'];
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} {$parts['where']} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
return array_map([$this, 'normalize_row'], (array) $rows);
|
||||
@@ -175,37 +143,60 @@ trait OrderRepositoryCore {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$where = ['user_id = %d'];
|
||||
$params = [$user_id];
|
||||
$parts = $this->get_user_order_query_parts($user_id, $args);
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->table()} {$parts['where']}",
|
||||
$parts['params']
|
||||
));
|
||||
}
|
||||
|
||||
$statuses = array_values(array_filter(array_map('sanitize_key', (array) ($args['status_in'] ?? []))));
|
||||
if ($statuses !== []) {
|
||||
$where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')';
|
||||
array_push($params, ...$statuses);
|
||||
} else {
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
/**
|
||||
* Get the filtered list total and the user's order-center counters in one query.
|
||||
*
|
||||
* The filtered total mirrors the list query while the counters intentionally keep
|
||||
* their existing user-wide semantics, so changing a filter does not change the
|
||||
* summary cards. The subquery avoids four independent COUNT statements.
|
||||
*
|
||||
* @return array{total:int,stats:array{total:int,pending:int,completed:int}}
|
||||
*/
|
||||
public function get_user_order_center_summary(int $user_id, array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
if ($user_id <= 0) {
|
||||
return [
|
||||
'total' => 0,
|
||||
'stats' => ['total' => 0, 'pending' => 0, 'completed' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
$parts = $this->get_user_order_query_parts($user_id, $args);
|
||||
$pending_statuses = [Order::STATUS_CREATED, Order::STATUS_PENDING];
|
||||
$paid_status = Order::STATUS_PAID;
|
||||
$known_statuses = OrderCenterQuery::from_array([])->database_statuses();
|
||||
$known_placeholders = implode(', ', array_fill(0, count($known_statuses), '%s'));
|
||||
$sql = "SELECT
|
||||
(SELECT COUNT(*) FROM {$this->table()} {$parts['where']}) AS filtered_total,
|
||||
COUNT(*) AS known_total,
|
||||
SUM(CASE WHEN status IN (%s, %s) THEN 1 ELSE 0 END) AS pending,
|
||||
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS completed
|
||||
FROM {$this->table()}
|
||||
WHERE user_id = %d AND status IN ({$known_placeholders})";
|
||||
$params = array_merge(
|
||||
$parts['params'],
|
||||
$pending_statuses,
|
||||
[$paid_status, $user_id],
|
||||
$known_statuses
|
||||
);
|
||||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
$order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? '');
|
||||
if ($order_number_prefix !== '') {
|
||||
$where[] = 'order_number LIKE %s';
|
||||
$params[] = $wpdb->esc_like($order_number_prefix) . '%';
|
||||
}
|
||||
|
||||
$where_sql = 'WHERE ' . implode(' AND ', $where);
|
||||
$sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
|
||||
return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
|
||||
return [
|
||||
'total' => (int) ($row['filtered_total'] ?? 0),
|
||||
'stats' => [
|
||||
'total' => (int) ($row['known_total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'completed' => (int) ($row['completed'] ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -515,6 +506,51 @@ trait OrderRepositoryCore {
|
||||
* Internal helpers
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Build the shared list/count predicate without changing caller semantics.
|
||||
*
|
||||
* @return array{where:string,params:array<int,mixed>}
|
||||
*/
|
||||
private function get_user_order_query_parts(int $user_id, array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
$statuses = array_values(array_filter(array_map('sanitize_key', (array) ($args['status_in'] ?? []))));
|
||||
if ($statuses !== []) {
|
||||
$where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')';
|
||||
array_push($params, ...$statuses);
|
||||
} else {
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? '');
|
||||
if ($order_number_prefix !== '') {
|
||||
$where[] = 'order_number LIKE %s';
|
||||
$params[] = $wpdb->esc_like($order_number_prefix) . '%';
|
||||
}
|
||||
|
||||
return [
|
||||
'where' => $where ? 'WHERE ' . implode(' AND ', $where) : '',
|
||||
'params' => $params,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalize_row(array $row): array {
|
||||
$row['id'] = (int) $row['id'];
|
||||
$row['user_id'] = (int) $row['user_id'];
|
||||
|
||||
@@ -23,7 +23,7 @@ class PaymentOrchestrator {
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $order Order row plus subject/amount overrides.
|
||||
* @param array<string,mixed> $opts paid_note: string
|
||||
* @param array<string,mixed> $opts paid_note: string, order_context: array
|
||||
*
|
||||
* @return array{
|
||||
* success:bool,
|
||||
@@ -58,7 +58,16 @@ class PaymentOrchestrator {
|
||||
// A pending order remains bound to the gateway selected at creation.
|
||||
// Switching it would make a late callback from the previous gateway look
|
||||
// invalid after that provider may already have collected the money.
|
||||
// The caller may pass request-local create context so metadata such as
|
||||
// subject is not reconstructed or read a second time. The persisted row
|
||||
// remains authoritative for status, ownership, and payment method.
|
||||
$stored = $repo->get($order_number);
|
||||
if (is_array($opts['order_context'] ?? null)) {
|
||||
$context = $opts['order_context'];
|
||||
if ((string) ($context['order_number'] ?? '') === $order_number && is_array($stored)) {
|
||||
$stored = array_merge($context, $stored);
|
||||
}
|
||||
}
|
||||
$stored_status = sanitize_key((string) ($stored['status'] ?? ''));
|
||||
if (!in_array($stored_status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
|
||||
return self::fail('aksrt_order_not_payable', __('当前订单状态不可支付。', 'aksrt'));
|
||||
|
||||
+170
-39
@@ -15,6 +15,18 @@ class UserOrders {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
/** @var array<string,string> */
|
||||
private array $post_title_cache = [];
|
||||
|
||||
/** @var array<string,string> */
|
||||
private array $post_image_cache = [];
|
||||
|
||||
/** @var array<string,string> */
|
||||
private array $post_permalink_cache = [];
|
||||
|
||||
/** @var array<string,string> */
|
||||
private array $type_labels_cache = [];
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
@@ -36,7 +48,8 @@ class UserOrders {
|
||||
$repo_args = $query->repository_args();
|
||||
$repo = OrderRepository::get_instance();
|
||||
$orders = $repo->get_user_orders($user_id, $repo_args);
|
||||
$total = $repo->count_user_orders($user_id, $repo_args);
|
||||
$summary = $repo->get_user_order_center_summary($user_id, $repo_args);
|
||||
$total = (int) ($summary['total'] ?? 0);
|
||||
$total_pages = max(1, (int) ceil($total / $query->per_page()));
|
||||
$page = min($query->page(), $total_pages);
|
||||
|
||||
@@ -46,18 +59,19 @@ class UserOrders {
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => array_map([$this, 'decorate_order_view'], $orders),
|
||||
'items' => $this->decorate_order_views($orders),
|
||||
'total' => $total,
|
||||
'total_pages' => $total_pages,
|
||||
'page' => $page,
|
||||
'query' => array_merge($query->url_values(), ['order_page' => $page]),
|
||||
'stats' => $this->get_user_order_stats($user_id),
|
||||
'stats' => is_array($summary['stats'] ?? null) ? $summary['stats'] : ['total' => 0, 'pending' => 0, 'completed' => 0],
|
||||
'invalidation_tags' => ['orders'],
|
||||
'product_types' => $this->product_type_filter_options(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function get_order_detail_view(int $user_id, string $order_number): ?array {
|
||||
public function get_order_detail_view(int $user_id, string $order_number, bool $reconcile = false): ?array {
|
||||
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
|
||||
return null;
|
||||
}
|
||||
@@ -67,11 +81,29 @@ class UserOrders {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reconciliation = [
|
||||
'state' => 'not_needed',
|
||||
'required' => false,
|
||||
];
|
||||
if (class_exists(Gateway::class)) {
|
||||
$order = Gateway::get_instance()->reconcile_order($order_number, $order) ?: $order;
|
||||
$gateway = Gateway::get_instance();
|
||||
$required = $gateway->needs_reconciliation($order);
|
||||
if ($required && $reconcile) {
|
||||
$order = $gateway->reconcile_order($order_number, $order) ?: $order;
|
||||
$required = $gateway->needs_reconciliation($order);
|
||||
$reconciliation['state'] = $required ? 'refresh_available' : 'checked';
|
||||
} elseif ($required) {
|
||||
$reconciliation['state'] = 'refresh_available';
|
||||
}
|
||||
$reconciliation['required'] = $required;
|
||||
}
|
||||
|
||||
return $this->decorate_order_view($order);
|
||||
return $this->decorate_order_view($order, true, $reconciliation);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function refresh_order(int $user_id, string $order_number): ?array {
|
||||
return $this->get_order_detail_view($user_id, $order_number, true);
|
||||
}
|
||||
|
||||
/** @return array<string, int> */
|
||||
@@ -80,30 +112,67 @@ class UserOrders {
|
||||
return ['total' => 0, 'pending' => 0, 'completed' => 0];
|
||||
}
|
||||
|
||||
$repo = OrderRepository::get_instance();
|
||||
return [
|
||||
'total' => $repo->count_user_orders($user_id, [
|
||||
'status_in' => OrderCenterQuery::from_array([])->database_statuses(),
|
||||
]),
|
||||
'pending' => $repo->count_user_orders($user_id, [
|
||||
'status_in' => [Order::STATUS_CREATED, Order::STATUS_PENDING],
|
||||
]),
|
||||
'completed' => $repo->count_user_orders($user_id, [
|
||||
'status_in' => [Order::STATUS_PAID],
|
||||
]),
|
||||
];
|
||||
$summary = OrderRepository::get_instance()->get_user_order_center_summary($user_id);
|
||||
return is_array($summary['stats'] ?? null)
|
||||
? $summary['stats']
|
||||
: ['total' => 0, 'pending' => 0, 'completed' => 0];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function product_type_filter_options(): array {
|
||||
return array_merge(['' => __('全部类型', 'aksrt')], DomainModel::order_product_type_labels());
|
||||
if ($this->type_labels_cache === []) {
|
||||
$this->type_labels_cache = DomainModel::order_product_type_labels();
|
||||
}
|
||||
return array_merge(['' => __('全部类型', 'aksrt')], $this->type_labels_cache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prime post and post-meta caches for all resource rows before decoration.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $orders
|
||||
*/
|
||||
private function prime_resource_caches(array $orders): void {
|
||||
$post_ids = [];
|
||||
foreach ($orders as $order) {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
foreach ([(int) ($order['product_id'] ?? 0), (int) ($meta['post_id'] ?? 0)] as $post_id) {
|
||||
if ($post_id > 0) {
|
||||
$post_ids[$post_id] = $post_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($post_ids === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
_prime_post_caches(array_values($post_ids), false, true);
|
||||
$thumbnail_ids = [];
|
||||
foreach ($post_ids as $post_id) {
|
||||
$thumbnail_id = (int) get_post_thumbnail_id($post_id);
|
||||
if ($thumbnail_id > 0) {
|
||||
$thumbnail_ids[$thumbnail_id] = $thumbnail_id;
|
||||
}
|
||||
}
|
||||
if ($thumbnail_ids !== []) {
|
||||
_prime_post_caches(array_values($thumbnail_ids), false, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $orders
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function decorate_order_views(array $orders): array {
|
||||
$this->prime_resource_caches($orders);
|
||||
return array_map(fn(array $order): array => $this->decorate_order_view($order, false), $orders);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed>|null $reconciliation
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function decorate_order_view(array $order): array {
|
||||
public function decorate_order_view(array $order, bool $detail = true, ?array $reconciliation = null): array {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$product_id = (int) ($order['product_id'] ?? 0);
|
||||
@@ -112,8 +181,10 @@ class UserOrders {
|
||||
$is_points = $currency === 'points'
|
||||
|| sanitize_key((string) ($order['payment_method'] ?? '')) === 'points'
|
||||
|| sanitize_key((string) ($meta['pay_modo'] ?? '')) === 'points';
|
||||
$type_labels = DomainModel::order_product_type_labels();
|
||||
$type_label = $type_labels[$product_type] ?? __('订单', 'aksrt');
|
||||
if ($this->type_labels_cache === []) {
|
||||
$this->type_labels_cache = DomainModel::order_product_type_labels();
|
||||
}
|
||||
$type_label = $this->type_labels_cache[$product_type] ?? __('订单', 'aksrt');
|
||||
$title = $this->resolve_title($meta, $product_type, $product_id, $type_label);
|
||||
$image = $this->resolve_image($meta, $product_id);
|
||||
$permalink = $this->resolve_entitlement_url($product_type, $product_id, $meta);
|
||||
@@ -122,10 +193,12 @@ class UserOrders {
|
||||
$can_pay = $group === OrderCenterQuery::STATUS_PENDING && $payment_method !== 'manual_review';
|
||||
$can_cancel = $group === OrderCenterQuery::STATUS_PENDING;
|
||||
$can_open = $group === OrderCenterQuery::STATUS_COMPLETED && $permalink !== '';
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
$updated_at = $this->resolve_updated_at($order);
|
||||
|
||||
return [
|
||||
$view = [
|
||||
'id' => (int) ($order['id'] ?? 0),
|
||||
'order_number' => (string) ($order['order_number'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'amount' => (float) ($order['amount'] ?? 0),
|
||||
'currency' => $currency,
|
||||
'currency_symbol' => $is_points
|
||||
@@ -142,20 +215,30 @@ class UserOrders {
|
||||
'image' => $image,
|
||||
'permalink' => $permalink,
|
||||
'created_at' => (string) ($order['created_at'] ?? ''),
|
||||
'paid_at' => (string) ($order['paid_at'] ?? ''),
|
||||
'expires_at' => (string) ($order['expires_at'] ?? ''),
|
||||
'payment_method' => [
|
||||
'value' => $payment_method,
|
||||
'label' => $this->payment_method_label($payment_method),
|
||||
],
|
||||
'actions' => [
|
||||
'allowed_actions' => [
|
||||
'can_pay' => $can_pay,
|
||||
'can_cancel' => $can_cancel,
|
||||
'can_open' => $can_open,
|
||||
],
|
||||
'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status),
|
||||
'entitlement_label' => $this->entitlement_label($product_type),
|
||||
'updated_at' => $updated_at,
|
||||
'status_version' => $this->resolve_status_version($order, $updated_at),
|
||||
'invalidation_tags' => array_values(array_filter([
|
||||
'orders',
|
||||
$order_number !== '' ? 'order:' . sanitize_key($order_number) : '',
|
||||
])),
|
||||
];
|
||||
if ($detail) {
|
||||
$view['paid_at'] = (string) ($order['paid_at'] ?? '');
|
||||
$view['expires_at'] = (string) ($order['expires_at'] ?? '');
|
||||
$view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false];
|
||||
}
|
||||
return $view;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
@@ -167,10 +250,39 @@ class UserOrders {
|
||||
'page' => 1,
|
||||
'query' => $query->url_values(),
|
||||
'stats' => ['total' => 0, 'pending' => 0, 'completed' => 0],
|
||||
'invalidation_tags' => ['orders'],
|
||||
'product_types' => $this->product_type_filter_options(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
private function resolve_updated_at(array $order): string {
|
||||
$latest = '';
|
||||
$latest_timestamp = 0;
|
||||
foreach (['created_at', 'paid_at', 'delivered_at', 'completed_at', 'cancelled_at', 'refunded_at'] as $field) {
|
||||
$value = trim((string) ($order[$field] ?? ''));
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp !== false && $timestamp >= $latest_timestamp) {
|
||||
$latest_timestamp = $timestamp;
|
||||
$latest = $value;
|
||||
}
|
||||
}
|
||||
return $latest;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $order */
|
||||
private function resolve_status_version(array $order, string $updated_at): string {
|
||||
return substr(hash('sha256', implode('|', [
|
||||
(int) ($order['id'] ?? 0),
|
||||
(string) ($order['order_number'] ?? ''),
|
||||
sanitize_key((string) ($order['status'] ?? '')),
|
||||
$updated_at,
|
||||
])), 0, 16);
|
||||
}
|
||||
|
||||
private function status_group(string $status): string {
|
||||
return match ($status) {
|
||||
Order::STATUS_CREATED, Order::STATUS_PENDING => OrderCenterQuery::STATUS_PENDING,
|
||||
@@ -205,9 +317,13 @@ class UserOrders {
|
||||
return __('购买积分', 'aksrt');
|
||||
}
|
||||
if ($product_id > 0) {
|
||||
$title = get_the_title($product_id);
|
||||
if (is_string($title) && $title !== '') {
|
||||
return $title;
|
||||
$cache_key = (string) $product_id;
|
||||
if (!array_key_exists($cache_key, $this->post_title_cache)) {
|
||||
$title = get_the_title($product_id);
|
||||
$this->post_title_cache[$cache_key] = is_string($title) ? $title : '';
|
||||
}
|
||||
if ($this->post_title_cache[$cache_key] !== '') {
|
||||
return $this->post_title_cache[$cache_key];
|
||||
}
|
||||
}
|
||||
return $fallback;
|
||||
@@ -218,22 +334,37 @@ class UserOrders {
|
||||
if (!empty($meta['thumb']) && is_string($meta['thumb'])) {
|
||||
return esc_url_raw($meta['thumb']);
|
||||
}
|
||||
if ($product_id > 0 && has_post_thumbnail($product_id)) {
|
||||
return (string) get_the_post_thumbnail_url($product_id, 'thumbnail');
|
||||
if ($product_id > 0) {
|
||||
$cache_key = (string) $product_id;
|
||||
if (!array_key_exists($cache_key, $this->post_image_cache)) {
|
||||
$image = has_post_thumbnail($product_id)
|
||||
? get_the_post_thumbnail_url($product_id, 'thumbnail')
|
||||
: '';
|
||||
$this->post_image_cache[$cache_key] = is_string($image) ? $image : '';
|
||||
}
|
||||
return $this->post_image_cache[$cache_key];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $meta */
|
||||
private function resolve_entitlement_url(string $type, int $product_id, array $meta): string {
|
||||
if ($type === DomainModel::ORDER_PRODUCT_VIP) {
|
||||
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('vip') : '';
|
||||
}
|
||||
if (in_array($type, [DomainModel::ORDER_PRODUCT_RECHARGE, DomainModel::ORDER_PRODUCT_POINTS_BUY], true)) {
|
||||
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('credits') : '';
|
||||
}
|
||||
$post_id = $product_id ?: (int) ($meta['post_id'] ?? 0);
|
||||
return $post_id > 0 ? (string) get_permalink($post_id) : '';
|
||||
$cache_key = $type . ':' . $post_id;
|
||||
if (array_key_exists($cache_key, $this->post_permalink_cache)) {
|
||||
return $this->post_permalink_cache[$cache_key];
|
||||
}
|
||||
|
||||
if ($type === DomainModel::ORDER_PRODUCT_VIP) {
|
||||
$url = function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('vip') : '';
|
||||
} elseif (in_array($type, [DomainModel::ORDER_PRODUCT_RECHARGE, DomainModel::ORDER_PRODUCT_POINTS_BUY], true)) {
|
||||
$url = function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('credits') : '';
|
||||
} else {
|
||||
$url = $post_id > 0 ? get_permalink($post_id) : '';
|
||||
}
|
||||
|
||||
$this->post_permalink_cache[$cache_key] = is_string($url) ? $url : '';
|
||||
return $this->post_permalink_cache[$cache_key];
|
||||
}
|
||||
|
||||
private function entitlement_label(string $type): string {
|
||||
|
||||
+141
-27
@@ -11,6 +11,7 @@ use AKSRT\Identity\ApplicationRepository;
|
||||
use AKSRT\Identity\EvidenceService;
|
||||
use AKSRT\Identity\EventRepository;
|
||||
use AKSRT\Identity\TransitionService;
|
||||
use AKSRT\Identity\SettingsRuntime;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
@@ -44,49 +45,162 @@ class AuthApply {
|
||||
return is_array($types[$type] ?? null) ? $types[$type] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current user's identity state from one repository context.
|
||||
*
|
||||
* The repository memoizes the latest/active/verified records for the
|
||||
* request, while this typed facade keeps all presentation callers on the
|
||||
* same application selection and next-action contract.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function snapshot(int $user_id): array {
|
||||
$user_id = absint($user_id);
|
||||
$settings = self::settings();
|
||||
$records = (new ApplicationRepository())->snapshot_for_user($user_id);
|
||||
$verified = is_array($records['verified'] ?? null) ? $records['verified'] : null;
|
||||
$latest = is_array($records['latest'] ?? null) ? $records['latest'] : null;
|
||||
$active = is_array($records['active'] ?? null) ? $records['active'] : null;
|
||||
$status_record = $verified ?: $latest;
|
||||
$status = $status_record
|
||||
? TransitionService::public_status((string) ($status_record['status'] ?? ''))
|
||||
: '';
|
||||
$current = $active ?: $latest;
|
||||
$stage = $active
|
||||
? (string) ($active['status'] ?? '')
|
||||
: (string) ($latest['status'] ?? '');
|
||||
$next_action = self::next_action($stage);
|
||||
$runtime_settings = class_exists('\\AKSRT\\Settings\\Settings') ? SettingsRuntime::get() : [];
|
||||
$poll_interval = max(5, (int) ($runtime_settings['poll_interval_seconds'] ?? 5));
|
||||
$can_apply = !empty($settings['enabled']) && !empty($settings['allow_apply']) && $status !== 'verified';
|
||||
|
||||
return [
|
||||
'user_id' => $user_id,
|
||||
'verified' => $verified !== null,
|
||||
'status' => $status,
|
||||
'stage' => $stage,
|
||||
'next_action' => $next_action,
|
||||
'poll_interval' => $poll_interval,
|
||||
'can_apply' => $can_apply,
|
||||
'latest' => $latest,
|
||||
'active' => $active,
|
||||
'current' => $current,
|
||||
'info' => $verified ? self::info_from_application($verified) : [],
|
||||
'apply' => self::payload_from_application($latest),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe browser-facing snapshot; raw application rows never cross this
|
||||
* boundary.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function snapshot_view(int $user_id): array {
|
||||
$snapshot = self::snapshot($user_id);
|
||||
$latest = is_array($snapshot['latest'] ?? null) ? $snapshot['latest'] : null;
|
||||
$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : null;
|
||||
$current = is_array($snapshot['current'] ?? null) ? $snapshot['current'] : null;
|
||||
|
||||
return [
|
||||
'user_id' => (int) ($snapshot['user_id'] ?? 0),
|
||||
'verified' => !empty($snapshot['verified']),
|
||||
'status' => (string) ($snapshot['status'] ?? ''),
|
||||
'stage' => (string) ($snapshot['stage'] ?? ''),
|
||||
'next_action' => (string) ($snapshot['next_action'] ?? 'wait'),
|
||||
'poll_interval' => (int) ($snapshot['poll_interval'] ?? 5),
|
||||
'can_apply' => !empty($snapshot['can_apply']),
|
||||
'info' => is_array($snapshot['info'] ?? null) ? $snapshot['info'] : [],
|
||||
'current' => $current ? self::application_view_model($current, (int) ($snapshot['poll_interval'] ?? 5)) : null,
|
||||
'active' => $active ? self::application_view_model($active, (int) ($snapshot['poll_interval'] ?? 5)) : null,
|
||||
'latest' => $latest ? self::application_view_model($latest, (int) ($snapshot['poll_interval'] ?? 5)) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function next_action(string $stage): string {
|
||||
if (in_array($stage, ['provider_pending', 'provider_processing'], true)) {
|
||||
return 'poll';
|
||||
}
|
||||
if (in_array($stage, ['provider_failed', 'provider_unsupported', 'provider_expired'], true)) {
|
||||
return 'manual_or_retry';
|
||||
}
|
||||
if ($stage === 'verified') {
|
||||
return 'complete';
|
||||
}
|
||||
return 'wait';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function info_from_application(array $application): array {
|
||||
return [
|
||||
'name' => (string) ($application['public_name'] ?? ''),
|
||||
'desc' => (string) ($application['public_desc'] ?? ''),
|
||||
'type' => (string) ($application['type'] ?? ''),
|
||||
'time' => (string) (($application['completed_at'] ?? '') ?: ($application['updated_at'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function payload_from_application(?array $application): array {
|
||||
if (!$application) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'application_uuid' => (string) ($application['application_uuid'] ?? ''),
|
||||
'user_id' => (int) ($application['user_id'] ?? 0),
|
||||
'name' => (string) ($application['public_name'] ?? ''),
|
||||
'real_name' => (string) ($application['person_name_masked'] ?? ''),
|
||||
'desc' => (string) ($application['public_desc'] ?? ''),
|
||||
'msg' => (string) ($application['manual_reason'] ?? ''),
|
||||
'type' => (string) ($application['type'] ?? ''),
|
||||
'time' => (string) ($application['created_at'] ?? ''),
|
||||
'fee' => (float) ($application['fee_amount'] ?? 0),
|
||||
'_application' => $application,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function application_view_model(array $application, int $poll_interval): array {
|
||||
$stage = (string) ($application['status'] ?? '');
|
||||
return [
|
||||
'uuid' => (string) ($application['application_uuid'] ?? ''),
|
||||
'type' => (string) ($application['type'] ?? ''),
|
||||
'status' => TransitionService::public_status($stage),
|
||||
'stage' => $stage,
|
||||
'next_action' => self::next_action($stage),
|
||||
'public_name' => (string) ($application['public_name'] ?? ''),
|
||||
'public_desc' => (string) ($application['public_desc'] ?? ''),
|
||||
'result_code' => (string) ($application['provider_result_code'] ?? ''),
|
||||
'message' => (string) ($application['provider_result_summary'] ?? ''),
|
||||
'redirect_url' => esc_url_raw((string) ($application['redirect_url'] ?? '')),
|
||||
'expires_at' => (string) ($application['expires_at'] ?? ''),
|
||||
'created_at' => (string) ($application['created_at'] ?? ''),
|
||||
'poll_interval' => $poll_interval,
|
||||
];
|
||||
}
|
||||
|
||||
public static function application_count(int $user_id, string $type): int {
|
||||
return (new ApplicationRepository())->count_user_type($user_id, sanitize_key($type));
|
||||
}
|
||||
|
||||
public static function get_status(int $user_id): string {
|
||||
$repo = new ApplicationRepository();
|
||||
$application = $repo->verified_for_user($user_id) ?: $repo->latest_for_user($user_id);
|
||||
return $application ? TransitionService::public_status((string) $application['status']) : '';
|
||||
return (string) (self::snapshot($user_id)['status'] ?? '');
|
||||
}
|
||||
|
||||
public static function is_verified(int $user_id): bool {
|
||||
return (new ApplicationRepository())->verified_for_user($user_id) !== null;
|
||||
return !empty(self::snapshot($user_id)['verified']);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function get_auth_info(int $user_id): array {
|
||||
$application = (new ApplicationRepository())->verified_for_user($user_id);
|
||||
return $application ? [
|
||||
'name' => (string) $application['public_name'],
|
||||
'desc' => (string) $application['public_desc'],
|
||||
'type' => (string) $application['type'],
|
||||
'time' => (string) ($application['completed_at'] ?: $application['updated_at']),
|
||||
] : [];
|
||||
$info = self::snapshot($user_id)['info'] ?? [];
|
||||
return is_array($info) ? $info : [];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function get_apply_payload(int $user_id): array {
|
||||
$application = (new ApplicationRepository())->latest_for_user($user_id);
|
||||
if (!$application) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'application_uuid' => (string) $application['application_uuid'],
|
||||
'user_id' => (int) $application['user_id'],
|
||||
'name' => (string) $application['public_name'],
|
||||
'real_name' => (string) $application['person_name_masked'],
|
||||
'desc' => (string) $application['public_desc'],
|
||||
'msg' => (string) ($application['manual_reason'] ?? ''),
|
||||
'type' => (string) $application['type'],
|
||||
'time' => (string) $application['created_at'],
|
||||
'fee' => (float) $application['fee_amount'],
|
||||
'_application' => $application,
|
||||
];
|
||||
$payload = self::snapshot($user_id)['apply'] ?? [];
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
|
||||
/** @return array<int,array{id:int,url:string,thumbnail_url:string}> */
|
||||
|
||||
+121
-296
@@ -17,6 +17,11 @@ class Badges {
|
||||
public const META_WEAR = 'aksrt_wear_badge';
|
||||
|
||||
private static $instance = null;
|
||||
private static ?array $dictionary_cache = null;
|
||||
/** @var array<int,array{owned_map:array<string,string>,owned:list<string>,wear:string}> */
|
||||
private static array $user_context_cache = [];
|
||||
/** @var array<string,array<string,mixed>> */
|
||||
private static array $list_cache = [];
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
@@ -32,6 +37,25 @@ class Badges {
|
||||
add_action('aksrt_badge_granted', [$this, 'on_badge_granted_notify'], 10, 3);
|
||||
}
|
||||
|
||||
private static function clear_context(int $user_id = 0): void {
|
||||
if ($user_id > 0) {
|
||||
unset(self::$user_context_cache[$user_id]);
|
||||
foreach (array_keys(self::$list_cache) as $key) {
|
||||
if (strpos($key, $user_id . ':') === 0) {
|
||||
unset(self::$list_cache[$key]);
|
||||
}
|
||||
}
|
||||
do_action('aksrt_badges_changed', $user_id);
|
||||
return;
|
||||
}
|
||||
self::$user_context_cache = [];
|
||||
self::$list_cache = [];
|
||||
}
|
||||
|
||||
public static function invalidate_cache(int $user_id = 0): void {
|
||||
self::clear_context($user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify user when a badge is granted (zibll user_add_medal message).
|
||||
*/
|
||||
@@ -66,220 +90,8 @@ class Badges {
|
||||
}
|
||||
|
||||
/**
|
||||
* REST response for the badge list modal.
|
||||
* Register the canonical JSON badge routes.
|
||||
*/
|
||||
public function rest_modal_list(\WP_REST_Request $request) {
|
||||
$user_id = absint($request->get_param('id'));
|
||||
if ($user_id <= 0 || !get_userdata($user_id)) {
|
||||
return new \WP_Error('aksrt_badge_user_missing', __('用户不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
return new \WP_REST_Response(['html' => self::render_info_modal_html($user_id)], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* REST response for a single badge modal.
|
||||
*/
|
||||
public function rest_modal_single(\WP_REST_Request $request) {
|
||||
$slug = sanitize_key((string) $request->get_param('slug'));
|
||||
$user_id = absint($request->get_param('id'));
|
||||
if ($slug === '') {
|
||||
return new \WP_Error('aksrt_badge_missing', __('徽章不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
return new \WP_REST_Response(['html' => self::render_single_modal_html($slug, $user_id)], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge list modal HTML.
|
||||
*
|
||||
* Head (avatar + owner + worn medal) and category-grouped medal card grid.
|
||||
*/
|
||||
public static function render_info_modal_html(int $user_id): string {
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user) {
|
||||
return '<div class="medal-modal"><p class="medal-modal__empty">' . esc_html__('用户不存在', 'aksrt') . '</p></div>';
|
||||
}
|
||||
$current = get_current_user_id();
|
||||
$is_self = $current > 0 && $current === $user_id;
|
||||
$display = $is_self ? __('我', 'aksrt') : $user->display_name;
|
||||
$avatar = function_exists('aksrt_get_avatar_url')
|
||||
? aksrt_get_avatar_url($user_id, ['size' => 80])
|
||||
: get_avatar_url($user_id, ['size' => 80]);
|
||||
$data = self::list_for_user($user_id, $is_self || self::can_manually_set($current));
|
||||
$categories = is_array($data['categories'] ?? null) ? $data['categories'] : [];
|
||||
$wear = (string) ($data['wear'] ?? '');
|
||||
$map = self::get_dictionary_map();
|
||||
|
||||
// Head slot: worn medal icon with tooltip, or an empty-state tag (zibll).
|
||||
$wear_slot = '';
|
||||
if ($wear !== '' && isset($map[$wear])) {
|
||||
$wear_label = (string) ($map[$wear]['label'] ?? $wear);
|
||||
$wear_icon = (string) ($map[$wear]['icon_url'] ?? '');
|
||||
if ($wear_icon !== '') {
|
||||
$wear_slot = '<img class="medal-icon img-icon ml3" src="' . esc_url($wear_icon) . '" title="' . esc_attr(sprintf(__('当前佩戴:%s', 'aksrt'), $wear_label)) . '" alt="' . esc_attr($wear_label) . '" />';
|
||||
}
|
||||
} elseif ((int) ($data['count'] ?? 0) === 0) {
|
||||
$wear_slot = '<span class="badg badg-sm c-red ml6">' . esc_html__('暂无徽章', 'aksrt') . '</span>';
|
||||
}
|
||||
|
||||
$html = '<div class="medal-modal">';
|
||||
$html .= '<button type="button" class="aksrt-modal-runtime__close" data-aksrt-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">×</button>';
|
||||
$html .= '<div class="medal-modal__head">';
|
||||
$html .= '<img class="medal-modal__avatar" src="' . esc_url((string) $avatar) . '" alt="" width="42" height="42" />';
|
||||
$html .= '<div class="medal-modal__title"><span class="em09">' . esc_html(sprintf(__('%s的徽章', 'aksrt'), $display)) . '</span>' . $wear_slot . '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<div class="medal-modal__scroll mini-scrollbar">';
|
||||
if (!$categories) {
|
||||
$html .= '<div class="empty-state empty-state--sm">'
|
||||
. '<div class="empty-state__icon"><i class="fas fa-award" aria-hidden="true"></i></div>'
|
||||
. '<div class="empty-state__title">' . esc_html__('暂无徽章', 'aksrt') . '</div>'
|
||||
. '<div class="empty-state__desc">' . esc_html__('积极参与社区互动即可获得徽章', 'aksrt') . '</div>'
|
||||
. '</div>';
|
||||
} else {
|
||||
foreach ($categories as $cat) {
|
||||
$html .= '<div class="medal-cat-box">';
|
||||
$html .= '<div class="medal-cat-head"><b class="medal-cat-name">' . esc_html((string) ($cat['cat_name'] ?? '')) . '</b></div>';
|
||||
$html .= '<div class="medal-list-box">';
|
||||
foreach ((array) ($cat['items'] ?? []) as $item) {
|
||||
$html .= self::render_card_link($user_id, is_array($item) ? $item : []);
|
||||
}
|
||||
$html .= '</div></div>';
|
||||
}
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
// Footer: quick jump to my own badges while browsing someone else (zibll).
|
||||
if (!$is_self && $current > 0) {
|
||||
$my_url = rest_url('aksrt/v1/users/' . $current . '/badges/modal');
|
||||
$html .= '<div class="medal-modal__buts">';
|
||||
$html .= '<a class="but c-blue" href="' . esc_url($my_url) . '" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($my_url) . '" data-aksrt-modal-class="full-sm" data-aksrt-modal-mobile-bottom="true"><i class="fas fa-medal" aria-hidden="true"></i> ' . esc_html__('查看我的徽章', 'aksrt') . '</a>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single medal card inside the list grid; deep-links to the detail modal.
|
||||
*
|
||||
* @param array{slug?:string,label?:string,icon_url?:string,owned?:bool,wear?:bool} $item
|
||||
*/
|
||||
protected static function render_card_link(int $user_id, array $item): string {
|
||||
$slug = (string) ($item['slug'] ?? '');
|
||||
$label = (string) ($item['label'] ?? $slug);
|
||||
$icon = (string) ($item['icon_url'] ?? '');
|
||||
$owned = !empty($item['owned']);
|
||||
$wear = !empty($item['wear']);
|
||||
if ($slug === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$icon_html = $icon !== ''
|
||||
? '<img class="medal-icon img-icon" src="' . esc_url($icon) . '" alt="' . esc_attr($label) . '" loading="lazy" decoding="async" />'
|
||||
: '<span class="medal-icon medal-icon--fa"><i class="fas fa-award" aria-hidden="true"></i></span>';
|
||||
|
||||
$card = '<div class="medal-card' . ($owned ? ' is-has' : ' no-has') . ($wear ? ' is-wear' : '') . '">';
|
||||
$card .= $icon_html;
|
||||
$card .= '<div class="medal-card__name">' . esc_html($label) . '</div>';
|
||||
if ($wear) {
|
||||
$card .= '<div class="medal-card__wear">' . esc_html__('佩戴中', 'aksrt') . '</div>';
|
||||
}
|
||||
$card .= '</div>';
|
||||
|
||||
$url = rest_url('aksrt/v1/users/' . $user_id . '/badges/' . rawurlencode($slug) . '/modal');
|
||||
|
||||
return '<a class="medal-list" href="' . esc_url($url) . '" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($url) . '" data-aksrt-modal-class="modal-mini" data-aksrt-modal-mobile-bottom="true">' . $card . '</a>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Single badge detail modal (zibll zib_get_single_medal_info_modal parity).
|
||||
*
|
||||
* medal-background.svg backdrop + large icon + name + desc + obtain time,
|
||||
* with wear action for the owner and a back link into the list modal.
|
||||
*/
|
||||
public static function render_single_modal_html(string $slug, int $user_id = 0): string {
|
||||
$map = self::get_dictionary_map();
|
||||
$slug = sanitize_key($slug);
|
||||
if ($slug === '' || !isset($map[$slug])) {
|
||||
return '<div class="medal-modal medal-modal--single"><p class="medal-modal__empty">' . esc_html__('徽章不存在', 'aksrt') . '</p></div>';
|
||||
}
|
||||
$item = $map[$slug];
|
||||
$current = get_current_user_id();
|
||||
$is_self = $user_id > 0 && $current === $user_id;
|
||||
$owned_map = $user_id > 0 ? self::get_owned_map($user_id) : [];
|
||||
$has = isset($owned_map[$slug]);
|
||||
$is_wear = $has && self::get_wear($user_id) === $slug;
|
||||
|
||||
$label = (string) ($item['label'] ?? $slug);
|
||||
$desc = (string) ($item['desc'] ?? '');
|
||||
$icon = (string) ($item['icon_url'] ?? '');
|
||||
$bg = get_template_directory_uri() . '/assets/img/medal/medal-background.svg';
|
||||
|
||||
$icon_html = $icon !== ''
|
||||
? '<img class="medal-icon img-icon" src="' . esc_url($icon) . '" alt="' . esc_attr($label) . '" loading="lazy" decoding="async" />'
|
||||
: '<span class="medal-icon medal-icon--fa lg"><i class="fas fa-award" aria-hidden="true"></i></span>';
|
||||
|
||||
// Obtain state line (self only, zibll).
|
||||
$state = '';
|
||||
if ($is_self) {
|
||||
$state = $has
|
||||
? '<div class="medal-single-state c-blue">' . esc_html(sprintf(__('%s 获得', 'aksrt'), (string) $owned_map[$slug])) . '</div>'
|
||||
: '<div class="medal-single-state c-yellow">' . esc_html__('暂未获得', 'aksrt') . '</div>';
|
||||
}
|
||||
|
||||
$html = '<div class="medal-modal medal-modal--single">';
|
||||
$html .= '<button type="button" class="aksrt-modal-runtime__close" data-aksrt-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">×</button>';
|
||||
$html .= '<div class="medal-single-card">';
|
||||
$html .= '<div class="medal-single-bg"><img class="fit-cover" src="' . esc_url($bg) . '" alt="" /></div>';
|
||||
$html .= '<div class="medal-single-main">' . $icon_html;
|
||||
$html .= '<div class="medal-single-name">' . esc_html($label) . '</div>';
|
||||
if ($desc !== '') {
|
||||
$html .= '<div class="medal-single-desc muted-2-color">' . esc_html($desc) . '</div>';
|
||||
}
|
||||
$html .= $state . '</div></div>';
|
||||
|
||||
if ($is_self && $has && !$is_wear) {
|
||||
$html .= '<div class="medal-modal__actions"><button type="button" class="but jb-blue btn-block radius" data-aksrt-badge-wear="' . esc_attr($slug) . '">' . esc_html__('佩戴此徽章', 'aksrt') . '</button></div>';
|
||||
} elseif ($is_self && $is_wear) {
|
||||
$html .= '<div class="medal-modal__actions"><span class="badg btn-block c-blue">' . esc_html__('当前佩戴此徽章', 'aksrt') . '</span></div>';
|
||||
}
|
||||
|
||||
// Back to the full badge list modal.
|
||||
if ($user_id > 0) {
|
||||
$back_url = rest_url('aksrt/v1/users/' . $user_id . '/badges/modal');
|
||||
$html .= '<a class="medal-modal__back muted-2-color em09" href="' . esc_url($back_url) . '" data-aksrt-modal="remote" data-aksrt-modal-url="' . esc_url($back_url) . '" data-aksrt-modal-class="full-sm" data-aksrt-modal-mobile-bottom="true"><i class="fas fa-angle-left" aria-hidden="true"></i> ' . esc_html__('返回徽章列表', 'aksrt') . '</a>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarative modal runtime trigger for a user's badge list.
|
||||
*/
|
||||
public static function get_info_link(int $user_id, string $class = '', ?string $text = null): string {
|
||||
if ($user_id <= 0 || !self::enabled()) {
|
||||
return '';
|
||||
}
|
||||
if ($text === null) {
|
||||
$text = __('徽章明细', 'aksrt');
|
||||
}
|
||||
if (!function_exists('aksrt_get_modal_link')) {
|
||||
return '';
|
||||
}
|
||||
$url = rest_url('aksrt/v1/users/' . $user_id . '/badges/modal');
|
||||
return aksrt_get_modal_link([
|
||||
'tag' => 'a',
|
||||
'class' => trim('user-medal-info ' . $class),
|
||||
'text' => $text,
|
||||
'url' => $url,
|
||||
'data_class' => 'full-sm',
|
||||
'height' => 240,
|
||||
'mobile_bottom' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route('aksrt/v1', '/users/(?P<id>\d+)/badges', [
|
||||
'methods' => 'GET',
|
||||
@@ -294,29 +106,14 @@ class Badges {
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/users/(?P<id>\d+)/badges/modal', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_modal_list'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'id' => [
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/users/(?P<id>\d+)/badges/(?P<slug>[a-z0-9_-]+)/modal', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_modal_single'],
|
||||
'permission_callback' => '__return_true',
|
||||
register_rest_route('aksrt/v1', '/users/me/badges/wear', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_wear'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
'args' => [
|
||||
'id' => [
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
'slug' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
@@ -325,19 +122,16 @@ class Badges {
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/users/me/badges/wear', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_wear'],
|
||||
'permission_callback' => static function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/users/(?P<id>\d+)/badges/grant', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_grant'],
|
||||
'permission_callback' => [$this, 'rest_can_manually_set'],
|
||||
'args' => [
|
||||
'slug' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_key',
|
||||
],
|
||||
'id' => [
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
@@ -351,6 +145,11 @@ class Badges {
|
||||
'callback' => [$this, 'rest_revoke'],
|
||||
'permission_callback' => [$this, 'rest_can_manually_set'],
|
||||
'args' => [
|
||||
'slug' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_key',
|
||||
],
|
||||
'id' => [
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
@@ -384,8 +183,11 @@ class Badges {
|
||||
* @return list<array{slug:string,label:string,tone:string,desc:string,icon_url:string}>
|
||||
*/
|
||||
public static function get_dictionary(): array {
|
||||
if (self::$dictionary_cache !== null) {
|
||||
return self::$dictionary_cache;
|
||||
}
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return [];
|
||||
return self::$dictionary_cache = [];
|
||||
}
|
||||
$settings = \AKSRT\Settings\Settings::get_user_identity_settings();
|
||||
$dict = is_array($settings['badge']['dictionary'] ?? null) ? $settings['badge']['dictionary'] : [];
|
||||
@@ -403,13 +205,17 @@ class Badges {
|
||||
'icon_url' => (string) ($item['icon_url'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
return self::$dictionary_cache = $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array{slug:string,label:string,tone:string,desc:string,icon_url:string}>
|
||||
*/
|
||||
public static function get_dictionary_map(): array {
|
||||
static $map = null;
|
||||
if ($map !== null) {
|
||||
return $map;
|
||||
}
|
||||
$map = [];
|
||||
foreach (self::get_dictionary() as $item) {
|
||||
$map[$item['slug']] = $item;
|
||||
@@ -417,29 +223,48 @@ class Badges {
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** @return array{owned_map:array<string,string>,owned:list<string>,wear:string} */
|
||||
private static function user_context(int $user_id): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['owned_map' => [], 'owned' => [], 'wear' => ''];
|
||||
}
|
||||
if (isset(self::$user_context_cache[$user_id])) {
|
||||
return self::$user_context_cache[$user_id];
|
||||
}
|
||||
|
||||
$raw = get_user_meta($user_id, self::META_OWNED, true);
|
||||
$raw = is_array($raw) ? $raw : [];
|
||||
$map = self::get_dictionary_map();
|
||||
$owned_map = [];
|
||||
foreach ($raw as $slug => $time) {
|
||||
$slug = sanitize_key((string) $slug);
|
||||
if ($slug === '' || !isset($map[$slug])) {
|
||||
continue;
|
||||
}
|
||||
$owned_map[$slug] = is_string($time) ? $time : '';
|
||||
}
|
||||
$owned = array_keys($owned_map);
|
||||
$wear = sanitize_key((string) get_user_meta($user_id, self::META_WEAR, true));
|
||||
if ($wear === '') {
|
||||
$wear = $owned[0] ?? '';
|
||||
} elseif (!in_array($wear, $owned, true)) {
|
||||
$wear = '';
|
||||
}
|
||||
|
||||
return self::$user_context_cache[$user_id] = [
|
||||
'owned_map' => $owned_map,
|
||||
'owned' => $owned,
|
||||
'wear' => $wear,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Owned badge map: slug => grant time (`Y-m-d H:i:s`), newest first.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function get_owned_map(int $user_id): array {
|
||||
if ($user_id <= 0) {
|
||||
return [];
|
||||
}
|
||||
$raw = get_user_meta($user_id, self::META_OWNED, true);
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
$map = self::get_dictionary_map();
|
||||
$out = [];
|
||||
foreach ($raw as $slug => $time) {
|
||||
$slug = sanitize_key((string) $slug);
|
||||
if ($slug === '' || !isset($map[$slug])) {
|
||||
continue;
|
||||
}
|
||||
$out[$slug] = is_string($time) ? $time : '';
|
||||
}
|
||||
return $out;
|
||||
return self::user_context($user_id)['owned_map'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -448,7 +273,7 @@ class Badges {
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function get_owned(int $user_id): array {
|
||||
return array_keys(self::get_owned_map($user_id));
|
||||
return self::user_context($user_id)['owned'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -478,23 +303,11 @@ class Badges {
|
||||
if ($wear !== '' && !isset($clean[$wear])) {
|
||||
delete_user_meta($user_id, self::META_WEAR);
|
||||
}
|
||||
self::clear_context($user_id);
|
||||
}
|
||||
|
||||
public static function get_wear(int $user_id): string {
|
||||
if ($user_id <= 0) {
|
||||
return '';
|
||||
}
|
||||
$wear = sanitize_key((string) get_user_meta($user_id, self::META_WEAR, true));
|
||||
if ($wear === '') {
|
||||
// zibll alias.
|
||||
$wear = sanitize_key((string) get_user_meta($user_id, 'wear_medal', true));
|
||||
}
|
||||
$owned = self::get_owned($user_id);
|
||||
if ($wear === '') {
|
||||
// Default to newest/first owned badge when unset (zibll behavior).
|
||||
return $owned[0] ?? '';
|
||||
}
|
||||
return in_array($wear, $owned, true) ? $wear : '';
|
||||
return self::user_context($user_id)['wear'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -517,6 +330,7 @@ class Badges {
|
||||
return new \WP_Error('aksrt_badge_not_owned', __('尚未获得该徽章', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
update_user_meta($user_id, self::META_WEAR, $slug);
|
||||
self::clear_context($user_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -600,13 +414,15 @@ class Badges {
|
||||
$public = self::public_display();
|
||||
$viewer = get_current_user_id();
|
||||
$is_self = $viewer > 0 && $viewer === $user_id;
|
||||
$can_see = $include_private
|
||||
|| $public
|
||||
|| $is_self
|
||||
|| self::can_manually_set($viewer);
|
||||
$is_manager = self::can_manually_set($viewer);
|
||||
$cache_key = $user_id . ':' . ($include_private ? 'private' : 'public') . ':' . $viewer . ':' . (int) $enabled . ':' . (int) $public;
|
||||
if (array_key_exists($cache_key, self::$list_cache)) {
|
||||
return self::$list_cache[$cache_key];
|
||||
}
|
||||
$can_see = $include_private || $public || $is_self || $is_manager;
|
||||
|
||||
if (!$enabled || !$can_see || $user_id <= 0) {
|
||||
return [
|
||||
return self::$list_cache[$cache_key] = [
|
||||
'enabled' => $enabled,
|
||||
'public_display' => $public,
|
||||
'is_self' => $is_self,
|
||||
@@ -623,9 +439,11 @@ class Badges {
|
||||
$medal = is_array($settings['medal'] ?? null) ? $settings['medal'] : [];
|
||||
$catalog = is_array($medal['categories'] ?? null) ? $medal['categories'] : [];
|
||||
|
||||
$owned_map = self::get_owned_map($user_id);
|
||||
$wear = self::get_wear($user_id);
|
||||
$show_unearned = $include_private || $is_self || self::can_manually_set($viewer);
|
||||
$context = self::user_context($user_id);
|
||||
$owned_map = $context['owned_map'];
|
||||
$wear = $context['wear'];
|
||||
$show_private = $include_private || $is_self || $is_manager;
|
||||
$show_unearned = $show_private;
|
||||
|
||||
$categories = [];
|
||||
foreach ($catalog as $cat) {
|
||||
@@ -652,7 +470,7 @@ class Badges {
|
||||
'icon_url' => (string) ($row['icon'] ?? ''),
|
||||
'owned' => $has,
|
||||
'wear' => $has && $slug === $wear,
|
||||
'time' => $has ? (string) $owned_map[$slug] : '',
|
||||
'time' => $has && $show_private ? (string) $owned_map[$slug] : '',
|
||||
];
|
||||
// Owned first within each category (zibll is-has / no-has order).
|
||||
if ($has) {
|
||||
@@ -671,7 +489,7 @@ class Badges {
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
return self::$list_cache[$cache_key] = [
|
||||
'enabled' => true,
|
||||
'public_display' => $public,
|
||||
'is_self' => $is_self,
|
||||
@@ -690,7 +508,8 @@ class Badges {
|
||||
return new \WP_Error('aksrt_badge_user', __('用户不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$data = self::list_for_user($user_id);
|
||||
return rest_ensure_response(['success' => true, 'data' => $data]);
|
||||
$response = rest_ensure_response(['success' => true, 'data' => $data]);
|
||||
return self::rest_no_store($response);
|
||||
}
|
||||
|
||||
public function rest_wear(\WP_REST_Request $request) {
|
||||
@@ -698,16 +517,16 @@ class Badges {
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$slug = sanitize_key((string) ($params['slug'] ?? $params['name'] ?? ''));
|
||||
$slug = sanitize_key((string) ($params['slug'] ?? ''));
|
||||
$result = self::wear(get_current_user_id(), $slug);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response([
|
||||
return self::rest_no_store(rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => self::list_for_user(get_current_user_id(), true),
|
||||
'message' => __('已佩戴徽章', 'aksrt'),
|
||||
]);
|
||||
]));
|
||||
}
|
||||
|
||||
public function rest_grant(\WP_REST_Request $request) {
|
||||
@@ -719,16 +538,16 @@ class Badges {
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$slug = sanitize_key((string) ($params['slug'] ?? $params['name'] ?? ''));
|
||||
$slug = sanitize_key((string) ($params['slug'] ?? ''));
|
||||
$result = self::grant($user_id, $slug, get_current_user_id());
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response([
|
||||
return self::rest_no_store(rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => self::list_for_user($user_id, true),
|
||||
'message' => __('已授予徽章', 'aksrt'),
|
||||
]);
|
||||
]));
|
||||
}
|
||||
|
||||
public function rest_revoke(\WP_REST_Request $request) {
|
||||
@@ -740,16 +559,22 @@ class Badges {
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$slug = sanitize_key((string) ($params['slug'] ?? $params['name'] ?? ''));
|
||||
$slug = sanitize_key((string) ($params['slug'] ?? ''));
|
||||
$result = self::revoke($user_id, $slug, get_current_user_id());
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return rest_ensure_response([
|
||||
return self::rest_no_store(rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => self::list_for_user($user_id, true),
|
||||
'message' => __('已收回徽章', 'aksrt'),
|
||||
]);
|
||||
]));
|
||||
}
|
||||
|
||||
private static function rest_no_store(\WP_REST_Response $response): \WP_REST_Response {
|
||||
$response->header('Cache-Control', 'no-store, private');
|
||||
$response->header('Pragma', 'no-cache');
|
||||
return $response;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -119,5 +119,6 @@ class BadgesAdmin {
|
||||
delete_user_meta($user_id, Badges::META_WEAR);
|
||||
}
|
||||
}
|
||||
Badges::invalidate_cache($user_id);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-37
@@ -164,7 +164,7 @@ class Home {
|
||||
if (!empty($settings['badge']['enabled']) && !empty($settings['badge']['public_display'])) {
|
||||
$limit = max(1, (int) ($settings['badge']['limit'] ?? 3));
|
||||
if (class_exists('AKSRT\\User\\Badges')) {
|
||||
$list = Badges::list_for_user($user_id, true);
|
||||
$list = Badges::list_for_user($user_id);
|
||||
$badge_count = 0;
|
||||
$done = false;
|
||||
foreach ((array) ($list['categories'] ?? []) as $cat) {
|
||||
@@ -191,42 +191,6 @@ class Home {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$badge_map = [];
|
||||
foreach ((array) ($settings['badge']['dictionary'] ?? []) as $item) {
|
||||
if (!empty($item['slug'])) {
|
||||
$badge_map[(string) $item['slug']] = $item;
|
||||
}
|
||||
}
|
||||
$assigned = get_user_meta($user_id, 'aksrt_user_badges', true);
|
||||
// Storage shape: associative map slug => grant time.
|
||||
$assigned = is_array($assigned) ? array_keys($assigned) : [];
|
||||
$wear = sanitize_key((string) get_user_meta($user_id, 'aksrt_wear_badge', true));
|
||||
$assigned = array_values(array_filter(array_map(static function ($slug) {
|
||||
return sanitize_key((string) $slug);
|
||||
}, (array) $assigned)));
|
||||
if ($wear !== '' && in_array($wear, $assigned, true) && isset($badge_map[$wear])) {
|
||||
$assigned = array_values(array_unique(array_merge([$wear], $assigned)));
|
||||
}
|
||||
$badge_count = 0;
|
||||
foreach ($assigned as $slug) {
|
||||
if ($slug === '' || !isset($badge_map[$slug])) {
|
||||
continue;
|
||||
}
|
||||
$signals[] = [
|
||||
'type' => 'badge',
|
||||
'slug' => $slug,
|
||||
'label' => (string) $badge_map[$slug]['label'],
|
||||
'icon' => 'award',
|
||||
'tone' => (string) ($badge_map[$slug]['tone'] ?? 'neutral'),
|
||||
'icon_url' => (string) ($badge_map[$slug]['icon_url'] ?? ''),
|
||||
'wear' => ($wear !== '' && $slug === $wear),
|
||||
];
|
||||
$badge_count++;
|
||||
if ($badge_count >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,9 +115,6 @@ class WidgetAuthorCard extends \WP_Widget {
|
||||
$show_badge = !isset($instance['show_badge']) || !empty($instance['show_badge']);
|
||||
if ($show_badge && class_exists('\AKSRT\User\Badges') && \AKSRT\User\Badges::public_display()) {
|
||||
$wear = \AKSRT\User\Badges::get_wear($author_id);
|
||||
if ($wear === '') {
|
||||
$wear = sanitize_key((string) get_user_meta($author_id, 'wear_medal', true));
|
||||
}
|
||||
if ($wear !== '') {
|
||||
$map = \AKSRT\User\Badges::get_dictionary_map();
|
||||
if (isset($map[$wear])) {
|
||||
|
||||
Reference in New Issue
Block a user