feat(users): add native VIP management
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ defined('ABSPATH') || exit;
|
||||
class UsersMenus {
|
||||
|
||||
use UsersMenusVerify;
|
||||
use UsersMenusVip;
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
|
||||
@@ -38,6 +39,7 @@ class UsersMenus {
|
||||
add_action('admin_post_aksrt_users_process_appeal', [$this, 'handle_process_appeal']);
|
||||
add_action('admin_post_aksrt_users_batch_invite_codes', [$this, 'handle_batch_invite_codes']);
|
||||
add_action('admin_post_aksrt_users_disable_invite_code', [$this, 'handle_disable_invite_code']);
|
||||
add_action('admin_post_aksrt_users_mutate_vip', [$this, 'handle_mutate_vip']);
|
||||
}
|
||||
|
||||
public function register_menus(): void {
|
||||
@@ -61,6 +63,14 @@ class UsersMenus {
|
||||
'aksrt_invite_codes',
|
||||
[$this, 'render_invite_codes_page']
|
||||
);
|
||||
add_submenu_page(
|
||||
'users.php',
|
||||
__('用户会员', 'aksrt'),
|
||||
__('用户会员', 'aksrt'),
|
||||
self::CAPABILITY,
|
||||
'aksrt_user_vip',
|
||||
[$this, 'render_vip_page']
|
||||
);
|
||||
add_submenu_page(
|
||||
'users.php',
|
||||
__('身份认证', 'aksrt'),
|
||||
@@ -158,6 +168,9 @@ class UsersMenus {
|
||||
'invite_batch_failed' => __('邀请码批量生成失败。', 'aksrt'),
|
||||
'invite_disabled' => __('邀请码已禁用。', 'aksrt'),
|
||||
'invite_failed' => __('邀请码操作失败。', 'aksrt'),
|
||||
'vip_updated' => __('会员状态已更新。', 'aksrt'),
|
||||
'vip_partial' => __('部分用户的会员状态已更新。', 'aksrt'),
|
||||
'vip_failed' => __('会员操作失败。', 'aksrt'),
|
||||
];
|
||||
if (!isset($map[$code])) {
|
||||
return;
|
||||
@@ -167,6 +180,17 @@ class UsersMenus {
|
||||
if ($code === 'verify_failed' && isset($_GET['aksrt_verify_error'])) {
|
||||
$message = sanitize_text_field((string) wp_unslash($_GET['aksrt_verify_error']));
|
||||
}
|
||||
if ($code === 'vip_failed' && isset($_GET['aksrt_vip_error'])) {
|
||||
$message = sanitize_text_field((string) wp_unslash($_GET['aksrt_vip_error']));
|
||||
}
|
||||
if (in_array($code, ['vip_updated', 'vip_partial'], true)) {
|
||||
$message .= ' ' . sprintf(
|
||||
__('成功:%1$d;失败:%2$d;跳过:%3$d。', 'aksrt'),
|
||||
absint($_GET['aksrt_vip_ok'] ?? 0),
|
||||
absint($_GET['aksrt_vip_failed_count'] ?? 0),
|
||||
absint($_GET['aksrt_vip_skipped'] ?? 0)
|
||||
);
|
||||
}
|
||||
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
/**
|
||||
* Native WordPress VIP membership operations.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusVip {
|
||||
|
||||
public function render_vip_page(): void {
|
||||
$this->ensure_cap();
|
||||
$tab = $this->vip_tab();
|
||||
$page = max(1, absint($_GET['paged'] ?? 1));
|
||||
echo '<div class="wrap"><h1>' . esc_html__('用户会员', 'aksrt') . '</h1>';
|
||||
$this->flash();
|
||||
$this->render_vip_tabs($tab);
|
||||
if ($tab === 'history') {
|
||||
$this->render_vip_history($page);
|
||||
} else {
|
||||
$filters = $this->vip_filters();
|
||||
$result = $this->query_vip_users($filters, $page, 20);
|
||||
$this->render_vip_filters($filters);
|
||||
$this->render_vip_bulk_form($filters);
|
||||
$this->render_vip_table($result['items'], $filters);
|
||||
$this->pagination($result['pages'], $page);
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
public function handle_mutate_vip(): void {
|
||||
$this->ensure_cap();
|
||||
check_admin_referer('aksrt_users_mutate_vip');
|
||||
$ids = isset($_POST['user_ids']) ? wp_unslash($_POST['user_ids']) : [];
|
||||
$ids = is_array($ids) ? array_values(array_unique(array_filter(array_map('absint', $ids)))) : [];
|
||||
if ($ids === [] || count($ids) > 100) {
|
||||
$this->redirect_vip_result('vip_failed', ['aksrt_vip_error' => __('请选择 1 至 100 名用户。', 'aksrt')]);
|
||||
}
|
||||
|
||||
$operation = sanitize_key((string) wp_unslash($_POST['vip_operation'] ?? ''));
|
||||
$reason = sanitize_text_field((string) wp_unslash($_POST['reason'] ?? ''));
|
||||
$payload = $this->vip_mutation_payload($operation);
|
||||
if (is_wp_error($payload)) {
|
||||
$this->redirect_vip_result('vip_failed', ['aksrt_vip_error' => $payload->get_error_message()]);
|
||||
}
|
||||
|
||||
$batch = count($ids) > 1 ? wp_generate_uuid4() : '';
|
||||
$ok = $failed = $skipped = 0;
|
||||
foreach ($ids as $user_id) {
|
||||
if (!get_userdata($user_id) || !current_user_can('edit_user', $user_id)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$result = class_exists('\AKSRT\Content\VipService')
|
||||
? \AKSRT\Content\VipService::get_instance()->mutate_membership($user_id, $operation, $payload, [
|
||||
'actor_user_id' => get_current_user_id(),
|
||||
'source' => 'admin',
|
||||
'reason' => $reason,
|
||||
'batch_uuid' => $batch,
|
||||
])
|
||||
: new \WP_Error('aksrt_vip_unavailable', __('会员服务不可用。', 'aksrt'));
|
||||
is_wp_error($result) ? $failed++ : $ok++;
|
||||
}
|
||||
$message = $ok > 0 && ($failed > 0 || $skipped > 0) ? 'vip_partial' : ($ok > 0 ? 'vip_updated' : 'vip_failed');
|
||||
$this->redirect_vip_result($message, [
|
||||
'aksrt_vip_ok' => $ok,
|
||||
'aksrt_vip_failed_count' => $failed,
|
||||
'aksrt_vip_skipped' => $skipped,
|
||||
]);
|
||||
}
|
||||
|
||||
private function vip_tab(): string {
|
||||
return (isset($_GET['tab']) && sanitize_key((string) wp_unslash($_GET['tab'])) === 'history') ? 'history' : 'members';
|
||||
}
|
||||
|
||||
/** @return array{user_id:int,level:string,state:string,date_from:string,date_to:string,orderby:string,order:string} */
|
||||
private function vip_filters(): array {
|
||||
$level = isset($_GET['level']) ? sanitize_key((string) wp_unslash($_GET['level'])) : 'all';
|
||||
$state = isset($_GET['state']) ? sanitize_key((string) wp_unslash($_GET['state'])) : 'all';
|
||||
return [
|
||||
'user_id' => absint($_GET['user_id'] ?? 0),
|
||||
'level' => in_array($level, ['all', '0', '1', '2'], true) ? $level : 'all',
|
||||
'state' => in_array($state, ['all', 'active', 'permanent', 'expired', 'none'], true) ? $state : 'all',
|
||||
'date_from' => $this->vip_date($_GET['date_from'] ?? ''),
|
||||
'date_to' => $this->vip_date($_GET['date_to'] ?? ''),
|
||||
'orderby' => (isset($_GET['orderby']) && sanitize_key((string) wp_unslash($_GET['orderby'])) === 'expires') ? 'expires' : 'id',
|
||||
'order' => (isset($_GET['order']) && strtoupper((string) wp_unslash($_GET['order'])) === 'ASC') ? 'ASC' : 'DESC',
|
||||
];
|
||||
}
|
||||
|
||||
private function vip_date($value): string {
|
||||
$value = sanitize_text_field((string) wp_unslash($value));
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value, wp_timezone());
|
||||
return $date && $date->format('Y-m-d') === $value ? $value : '';
|
||||
}
|
||||
|
||||
/** @return array{items:array<int,array<string,mixed>>,pages:int} */
|
||||
private function query_vip_users(array $filters, int $page, int $per_page): array {
|
||||
global $wpdb;
|
||||
$meta = "(SELECT user_id,
|
||||
MAX(CASE WHEN meta_key = 'aksrt_vip_level' THEN meta_value END) level,
|
||||
MAX(CASE WHEN meta_key = 'aksrt_vip_expires' THEN meta_value END) expires,
|
||||
MAX(CASE WHEN meta_key = 'aksrt_vip_base_level' THEN meta_value END) base_level,
|
||||
MAX(CASE WHEN meta_key = 'aksrt_vip_base_expires' THEN meta_value END) base_expires,
|
||||
MAX(CASE WHEN meta_key = 'aksrt_vip_level_expired' THEN meta_value END) expired_level
|
||||
FROM {$wpdb->usermeta}
|
||||
WHERE meta_key IN ('aksrt_vip_level', 'aksrt_vip_expires', 'aksrt_vip_base_level', 'aksrt_vip_base_expires', 'aksrt_vip_level_expired')
|
||||
GROUP BY user_id) m";
|
||||
$raw_level = 'CAST(COALESCE(m.level, 0) AS UNSIGNED)';
|
||||
$raw_expires = 'CAST(COALESCE(m.expires, 0) AS UNSIGNED)';
|
||||
$base_level = 'CAST(COALESCE(m.base_level, 0) AS UNSIGNED)';
|
||||
$base_expires = 'CAST(COALESCE(m.base_expires, 0) AS UNSIGNED)';
|
||||
$expired_level = 'CAST(COALESCE(m.expired_level, 0) AS UNSIGNED)';
|
||||
$now = time();
|
||||
$level = "CASE WHEN {$raw_level} > 0 AND {$raw_expires} > 0 AND {$raw_expires} < {$now} AND {$base_level} > 0 THEN {$base_level} ELSE {$raw_level} END";
|
||||
$expires = "CASE WHEN {$raw_level} > 0 AND {$raw_expires} > 0 AND {$raw_expires} < {$now} AND {$base_level} > 0 THEN {$base_expires} ELSE {$raw_expires} END";
|
||||
$is_expired = "({$raw_level} > 0 AND {$raw_expires} > 0 AND {$raw_expires} < {$now} AND {$base_level} = 0) OR ({$raw_level} = 0 AND {$expired_level} > 0)";
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
if ($filters['user_id']) {
|
||||
$where[] = 'u.ID = %d';
|
||||
$params[] = $filters['user_id'];
|
||||
}
|
||||
if ($filters['level'] !== 'all') {
|
||||
$where[] = "{$level} = %d";
|
||||
$params[] = (int) $filters['level'];
|
||||
}
|
||||
if ($filters['state'] === 'active') {
|
||||
$where[] = "{$level} > 0 AND {$expires} >= %d";
|
||||
$params[] = $now;
|
||||
} elseif ($filters['state'] === 'permanent') {
|
||||
$where[] = "{$level} > 0 AND {$expires} = 0";
|
||||
} elseif ($filters['state'] === 'expired') {
|
||||
$where[] = $is_expired;
|
||||
} elseif ($filters['state'] === 'none') {
|
||||
$where[] = "{$level} = 0 AND NOT {$is_expired}";
|
||||
}
|
||||
foreach (['date_from' => '00:00:00', 'date_to' => '23:59:59'] as $key => $time_of_day) {
|
||||
if ($filters[$key] !== '') {
|
||||
$date = new \DateTimeImmutable($filters[$key] . ' ' . $time_of_day, wp_timezone());
|
||||
$where[] = "{$expires} " . ($key === 'date_from' ? '>=' : '<=') . ' %d';
|
||||
$params[] = $date->getTimestamp();
|
||||
}
|
||||
}
|
||||
$from = "FROM {$wpdb->users} u LEFT JOIN {$meta} ON m.user_id = u.ID WHERE " . implode(' AND ', $where);
|
||||
$count_sql = "SELECT COUNT(*) {$from}";
|
||||
$total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql);
|
||||
$sort = $filters['orderby'] === 'expires'
|
||||
? "CASE WHEN {$expires} = 0 THEN 1 ELSE 0 END {$filters['order']}, {$expires} {$filters['order']}, u.ID DESC"
|
||||
: "u.ID {$filters['order']}";
|
||||
$query = "SELECT u.ID, u.display_name, u.user_login, {$level} level, {$expires} expires,
|
||||
{$raw_level} raw_level, {$raw_expires} raw_expires, {$base_level} base_level, {$base_expires} base_expires,
|
||||
{$expired_level} expired_level, {$is_expired} is_expired {$from} ORDER BY {$sort} LIMIT %d OFFSET %d";
|
||||
$items = $wpdb->get_results($wpdb->prepare($query, array_merge($params, [$per_page, ($page - 1) * $per_page])), ARRAY_A);
|
||||
return ['items' => is_array($items) ? $items : [], 'pages' => max(1, (int) ceil(max(1, $total) / $per_page))];
|
||||
}
|
||||
|
||||
private function render_vip_tabs(string $tab): void {
|
||||
$base = admin_url('users.php?page=aksrt_user_vip');
|
||||
echo '<h2 class="nav-tab-wrapper"><a class="nav-tab ' . ($tab === 'members' ? 'nav-tab-active' : '') . '" href="' . esc_url($base) . '">' . esc_html__('会员列表', 'aksrt') . '</a><a class="nav-tab ' . ($tab === 'history' ? 'nav-tab-active' : '') . '" href="' . esc_url(add_query_arg('tab', 'history', $base)) . '">' . esc_html__('变更记录', 'aksrt') . '</a></h2>';
|
||||
}
|
||||
|
||||
private function render_vip_filters(array $filters): void {
|
||||
$names = $this->vip_level_names();
|
||||
echo '<form method="get" style="margin:12px 0;display:flex;gap:8px;align-items:end;flex-wrap:wrap"><input type="hidden" name="page" value="aksrt_user_vip">';
|
||||
echo '<label>UID<br><input type="number" min="1" name="user_id" value="' . esc_attr((string) $filters['user_id']) . '"></label><label>' . esc_html__('档位', 'aksrt') . '<br><select name="level">';
|
||||
foreach (['all' => __('全部', 'aksrt'), '0' => __('普通用户', 'aksrt'), '1' => $names[1], '2' => $names[2]] as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($filters['level'], $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select></label><label>' . esc_html__('状态', 'aksrt') . '<br><select name="state">';
|
||||
foreach (['all' => __('全部', 'aksrt'), 'active' => __('有效', 'aksrt'), 'permanent' => __('永久', 'aksrt'), 'expired' => __('已过期', 'aksrt'), 'none' => __('无会员', 'aksrt')] as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($filters['state'], $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select></label><label>' . esc_html__('到期从', 'aksrt') . '<br><input type="date" name="date_from" value="' . esc_attr($filters['date_from']) . '"></label><label>' . esc_html__('到期至', 'aksrt') . '<br><input type="date" name="date_to" value="' . esc_attr($filters['date_to']) . '"></label>';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
private function render_vip_bulk_form(array $filters): void {
|
||||
$names = $this->vip_level_names();
|
||||
echo '<form id="aksrt-vip-bulk" method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin:14px 0;padding:12px;background:#fff;border:1px solid #ccd0d4">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_users_mutate_vip">';
|
||||
wp_nonce_field('aksrt_users_mutate_vip');
|
||||
$this->render_vip_return_fields($filters);
|
||||
echo '<strong>' . esc_html__('批量操作', 'aksrt') . '</strong> ';
|
||||
$this->render_vip_operation_fields($names);
|
||||
echo ' <label>' . esc_html__('原因', 'aksrt') . ' <input required name="reason" class="regular-text"></label> ';
|
||||
submit_button(__('应用到选中用户', 'aksrt'), 'primary', '', false);
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
private function render_vip_table(array $items, array $filters): void {
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr><th><input type="checkbox" aria-label="' . esc_attr__('选择全部用户', 'aksrt') . '" onclick="document.querySelectorAll(\'.aksrt-vip-check\').forEach((element) => element.checked = this.checked)"></th><th>UID</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('当前会员', 'aksrt') . '</th><th>' . esc_html__('到期时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th></tr></thead><tbody>';
|
||||
if ($items === []) {
|
||||
echo '<tr><td colspan="6">' . esc_html__('暂无用户', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
foreach ($items as $row) {
|
||||
$this->render_vip_row($row, $filters);
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
|
||||
private function render_vip_row(array $row, array $filters): void {
|
||||
$id = (int) $row['ID'];
|
||||
$names = $this->vip_level_names();
|
||||
$level = (int) $row['level'];
|
||||
$expires = (int) $row['expires'];
|
||||
$label = $level > 0 ? ($names[$level] ?? ('VIP' . $level)) : __('普通用户', 'aksrt');
|
||||
$expiry = !empty($row['is_expired'])
|
||||
? __('已过期', 'aksrt')
|
||||
: ($level > 0 ? ($expires === 0 ? __('永久有效', 'aksrt') : wp_date('Y-m-d H:i', $expires, wp_timezone())) : '—');
|
||||
if ((int) $row['raw_level'] !== $level || (int) $row['raw_expires'] !== $expires) {
|
||||
$label .= ' (' . __('基础档位恢复后', 'aksrt') . ')';
|
||||
}
|
||||
echo '<tr><td><input class="aksrt-vip-check" form="aksrt-vip-bulk" type="checkbox" name="user_ids[]" value="' . $id . '"></td><td>' . $id . '</td><td><a href="' . esc_url(get_edit_user_link($id)) . '">' . esc_html((string) $row['display_name']) . '</a><div class="description">@' . esc_html((string) $row['user_login']) . '</div></td><td>' . esc_html($label) . '</td><td>' . esc_html($expiry) . '</td><td><details><summary>' . esc_html__('调整', 'aksrt') . '</summary><form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-top:8px"><input type="hidden" name="action" value="aksrt_users_mutate_vip"><input type="hidden" name="user_ids[]" value="' . $id . '">';
|
||||
wp_nonce_field('aksrt_users_mutate_vip');
|
||||
$this->render_vip_return_fields($filters);
|
||||
$this->render_vip_operation_fields($names);
|
||||
echo '<p><label>' . esc_html__('原因', 'aksrt') . '<br><input required name="reason" class="regular-text"></label></p>';
|
||||
submit_button(__('保存', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form></details></td></tr>';
|
||||
}
|
||||
|
||||
private function render_vip_operation_fields(array $names): void {
|
||||
echo '<select name="vip_operation"><option value="set">' . esc_html__('设置会员', 'aksrt') . '</option><option value="extend">' . esc_html__('延长有效期', 'aksrt') . '</option><option value="disable">' . esc_html__('停用会员', 'aksrt') . '</option></select> ';
|
||||
echo '<label>' . esc_html__('档位', 'aksrt') . ' <select name="level"><option value="1">' . esc_html($names[1]) . '</option><option value="2">' . esc_html($names[2]) . '</option></select></label> ';
|
||||
echo '<label><input type="checkbox" name="permanent" value="1">' . esc_html__('永久', 'aksrt') . '</label> <label>' . esc_html__('绝对到期日', 'aksrt') . ' <input type="date" name="expires_date"></label> ';
|
||||
echo '<label>' . esc_html__('延长', 'aksrt') . ' <input type="number" name="amount" min="1" max="3650" value="1" style="width:72px"><select name="unit"><option value="day">' . esc_html__('天', 'aksrt') . '</option><option value="month">' . esc_html__('月', 'aksrt') . '</option></select></label>';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|\WP_Error */
|
||||
private function vip_mutation_payload(string $operation): array|\WP_Error {
|
||||
if (!in_array($operation, ['set', 'extend', 'disable'], true)) {
|
||||
return new \WP_Error('aksrt_vip_operation_invalid', __('会员操作无效。', 'aksrt'));
|
||||
}
|
||||
if ($operation === 'disable') {
|
||||
return [];
|
||||
}
|
||||
if ($operation === 'extend') {
|
||||
return [
|
||||
'amount' => absint($_POST['amount'] ?? 0),
|
||||
'unit' => sanitize_key((string) wp_unslash($_POST['unit'] ?? 'day')),
|
||||
];
|
||||
}
|
||||
if (!empty($_POST['permanent'])) {
|
||||
return ['level' => absint($_POST['level'] ?? 0), 'expires' => 0];
|
||||
}
|
||||
$date = $this->vip_date($_POST['expires_date'] ?? '');
|
||||
if ($date === '') {
|
||||
return new \WP_Error('aksrt_vip_expiry_required', __('请设置永久会员或有效的到期日期。', 'aksrt'));
|
||||
}
|
||||
$expires = (new \DateTimeImmutable($date . ' 23:59:59', wp_timezone()))->getTimestamp();
|
||||
return ['level' => absint($_POST['level'] ?? 0), 'expires' => $expires];
|
||||
}
|
||||
|
||||
private function render_vip_history(int $page): void {
|
||||
$result = (new \AKSRT\Content\VipMembershipEventRepository())->query($page, 20);
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th><th>' . esc_html__('操作人', 'aksrt') . '</th><th>' . esc_html__('原因', 'aksrt') . '</th><th>' . esc_html__('结果', 'aksrt') . '</th></tr></thead><tbody>';
|
||||
if ($result['items'] === []) {
|
||||
echo '<tr><td colspan="6">' . esc_html__('暂无变更记录', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
foreach ($result['items'] as $event) {
|
||||
$user = (string) ($event['display_name'] ?: $event['user_login']);
|
||||
echo '<tr><td>' . esc_html((string) $event['created_at']) . '</td><td>' . esc_html($user) . ' (#' . (int) $event['user_id'] . ')</td><td>' . esc_html((string) $event['operation']) . '</td><td>' . esc_html((string) ($event['actor_name'] ?: '—')) . '</td><td>' . esc_html((string) $event['reason']) . '</td><td>' . esc_html((string) $event['status']) . '</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
$this->pagination($result['pages'], $page);
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
private function vip_level_names(): array {
|
||||
$levels = class_exists('\AKSRT\Settings\Settings') ? (array) (\AKSRT\Settings\Settings::get_payment_vip_settings()['levels'] ?? []) : [];
|
||||
return [
|
||||
1 => (string) ($levels[1]['name'] ?? __('一级会员', 'aksrt')),
|
||||
2 => (string) ($levels[2]['name'] ?? __('二级会员', 'aksrt')),
|
||||
];
|
||||
}
|
||||
|
||||
private function render_vip_return_fields(array $filters): void {
|
||||
foreach (['user_id', 'level', 'state', 'date_from', 'date_to', 'orderby', 'order'] as $key) {
|
||||
echo '<input type="hidden" name="return_' . esc_attr($key) . '" value="' . esc_attr((string) $filters[$key]) . '">';
|
||||
}
|
||||
}
|
||||
|
||||
private function redirect_vip_result(string $message, array $extra = []): void {
|
||||
$filters = [];
|
||||
foreach (['user_id', 'level', 'state', 'date_from', 'date_to', 'orderby', 'order'] as $key) {
|
||||
if (isset($_POST['return_' . $key])) {
|
||||
$filters[$key] = sanitize_text_field((string) wp_unslash($_POST['return_' . $key]));
|
||||
}
|
||||
}
|
||||
$this->redirect('aksrt_user_vip', $message, array_merge($filters, $extra));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/**
|
||||
* VIP membership event persistence.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Content;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class VipMembershipEventRepository {
|
||||
|
||||
private string $table;
|
||||
|
||||
public function __construct() {
|
||||
$this->table = \AKSRT\Core\DBMigrator::table('vip_membership_event');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $event */
|
||||
public function begin(array $event): string|\WP_Error {
|
||||
global $wpdb;
|
||||
if ($this->table === '') {
|
||||
return new \WP_Error('aksrt_vip_audit_unavailable', __('会员变更审计表不可用。', 'aksrt'));
|
||||
}
|
||||
$uuid = wp_generate_uuid4();
|
||||
$inserted = $wpdb->insert($this->table, [
|
||||
'event_uuid' => $uuid,
|
||||
'batch_uuid' => sanitize_text_field((string) ($event['batch_uuid'] ?? '')),
|
||||
'user_id' => (int) ($event['user_id'] ?? 0),
|
||||
'actor_user_id' => !empty($event['actor_user_id']) ? (int) $event['actor_user_id'] : null,
|
||||
'operation' => sanitize_key((string) ($event['operation'] ?? '')),
|
||||
'source' => sanitize_key((string) ($event['source'] ?? 'admin')),
|
||||
'reason' => sanitize_text_field((string) ($event['reason'] ?? '')),
|
||||
'request_json' => wp_json_encode((array) ($event['request'] ?? [])),
|
||||
'before_json' => wp_json_encode((array) ($event['before'] ?? [])),
|
||||
'status' => 'pending',
|
||||
'created_at' => current_time('mysql', true),
|
||||
]);
|
||||
return $inserted === 1 ? $uuid : new \WP_Error('aksrt_vip_audit_begin_failed', __('无法创建会员变更审计记录。', 'aksrt'));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $after */
|
||||
public function complete(string $uuid, string $status, array $after = [], string $error_code = '', string $error_message = ''): bool {
|
||||
global $wpdb;
|
||||
return $wpdb->update($this->table, [
|
||||
'status' => in_array($status, ['succeeded', 'failed'], true) ? $status : 'failed',
|
||||
'after_json' => wp_json_encode($after),
|
||||
'error_code' => sanitize_key($error_code),
|
||||
'error_message' => sanitize_text_field($error_message),
|
||||
'completed_at' => current_time('mysql', true),
|
||||
], ['event_uuid' => $uuid]) !== false;
|
||||
}
|
||||
|
||||
/** @return array{items:array<int,array<string,mixed>>,total:int,pages:int} */
|
||||
public function query(int $page, int $per_page): array {
|
||||
global $wpdb;
|
||||
if ($this->table === '') {
|
||||
return ['items' => [], 'total' => 0, 'pages' => 1];
|
||||
}
|
||||
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table}");
|
||||
$items = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT e.*, u.display_name, u.user_login, a.display_name AS actor_name FROM {$this->table} e LEFT JOIN {$wpdb->users} u ON u.ID = e.user_id LEFT JOIN {$wpdb->users} a ON a.ID = e.actor_user_id ORDER BY e.id DESC LIMIT %d OFFSET %d",
|
||||
$per_page,
|
||||
max(0, $page - 1) * $per_page
|
||||
), ARRAY_A);
|
||||
return ['items' => is_array($items) ? $items : [], 'total' => $total, 'pages' => max(1, (int) ceil(max(1, $total) / $per_page))];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class VipService {
|
||||
use VipServicePurchase;
|
||||
use VipServicePackages;
|
||||
use VipServiceModal;
|
||||
use VipServiceMembership;
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
/**
|
||||
* Administrator membership mutations.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Content;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait VipServiceMembership {
|
||||
|
||||
/** @return array{level:int,expires:int,base_level:int,base_expires:int,expired_level:int} */
|
||||
public function get_membership_snapshot(int $user_id): array {
|
||||
return [
|
||||
'level' => max(0, min(2, (int) get_user_meta($user_id, 'aksrt_vip_level', true))),
|
||||
'expires' => max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true)),
|
||||
'base_level' => max(0, min(2, (int) get_user_meta($user_id, 'aksrt_vip_base_level', true))),
|
||||
'base_expires' => max(0, (int) get_user_meta($user_id, 'aksrt_vip_base_expires', true)),
|
||||
'expired_level' => max(0, min(2, (int) get_user_meta($user_id, 'aksrt_vip_level_expired', true))),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload @param array<string,mixed> $context */
|
||||
public function mutate_membership(int $user_id, string $operation, array $payload, array $context = []): array|\WP_Error {
|
||||
if ($user_id <= 0 || !get_userdata($user_id)) {
|
||||
return new \WP_Error('aksrt_vip_user_missing', __('用户不存在。', 'aksrt'));
|
||||
}
|
||||
if (!in_array($operation, ['set', 'extend', 'disable'], true)) {
|
||||
return new \WP_Error('aksrt_vip_operation_invalid', __('会员操作无效。', 'aksrt'));
|
||||
}
|
||||
$reason = sanitize_text_field((string) ($context['reason'] ?? ''));
|
||||
if ($reason === '') {
|
||||
return new \WP_Error('aksrt_vip_reason_required', __('请填写变更原因。', 'aksrt'));
|
||||
}
|
||||
|
||||
$before = $this->get_membership_snapshot($user_id);
|
||||
$repository = new VipMembershipEventRepository();
|
||||
$event_uuid = $repository->begin([
|
||||
'batch_uuid' => $context['batch_uuid'] ?? '',
|
||||
'user_id' => $user_id,
|
||||
'actor_user_id' => (int) ($context['actor_user_id'] ?? 0),
|
||||
'operation' => $operation,
|
||||
'source' => $context['source'] ?? 'admin',
|
||||
'reason' => $reason,
|
||||
'request' => $payload,
|
||||
'before' => $before,
|
||||
]);
|
||||
if (is_wp_error($event_uuid)) {
|
||||
return $event_uuid;
|
||||
}
|
||||
|
||||
$target = $this->membership_target($before, $operation, $payload);
|
||||
if (is_wp_error($target)) {
|
||||
$repository->complete($event_uuid, 'failed', $before, $target->get_error_code(), $target->get_error_message());
|
||||
return $target;
|
||||
}
|
||||
|
||||
delete_user_meta($user_id, 'aksrt_vip_base_level');
|
||||
delete_user_meta($user_id, 'aksrt_vip_base_expires');
|
||||
delete_user_meta($user_id, 'aksrt_vip_level_expired');
|
||||
update_user_meta($user_id, 'aksrt_vip_level', $target['level']);
|
||||
update_user_meta($user_id, 'aksrt_vip_expires', $target['expires']);
|
||||
$after = $this->get_membership_snapshot($user_id);
|
||||
if ($after['level'] !== $target['level'] || $after['expires'] !== $target['expires'] || $after['base_level'] !== 0 || $after['base_expires'] !== 0 || $after['expired_level'] !== 0) {
|
||||
$this->restore_membership_snapshot($user_id, $before);
|
||||
$after = $this->get_membership_snapshot($user_id);
|
||||
$error = new \WP_Error('aksrt_vip_write_failed', __('会员状态保存失败。', 'aksrt'));
|
||||
$repository->complete($event_uuid, 'failed', $after, $error->get_error_code(), $error->get_error_message());
|
||||
return $error;
|
||||
}
|
||||
|
||||
unset($this->vip_member_cache[$user_id], $this->vip_catalog_cache[$user_id]);
|
||||
$repository->complete($event_uuid, 'succeeded', $after);
|
||||
do_action('aksrt_vip_membership_mutated', $user_id, $operation, $before, $after, $context, $event_uuid);
|
||||
return ['event_uuid' => $event_uuid, 'before' => $before, 'after' => $after];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $snapshot */
|
||||
private function restore_membership_snapshot(int $user_id, array $snapshot): void {
|
||||
foreach ([
|
||||
'aksrt_vip_level' => 'level',
|
||||
'aksrt_vip_expires' => 'expires',
|
||||
'aksrt_vip_base_level' => 'base_level',
|
||||
'aksrt_vip_base_expires' => 'base_expires',
|
||||
'aksrt_vip_level_expired' => 'expired_level',
|
||||
] as $meta_key => $snapshot_key) {
|
||||
$value = (int) ($snapshot[$snapshot_key] ?? 0);
|
||||
if ($value > 0 || $meta_key === 'aksrt_vip_level' || ($meta_key === 'aksrt_vip_expires' && (int) ($snapshot['level'] ?? 0) > 0)) {
|
||||
update_user_meta($user_id, $meta_key, $value);
|
||||
} else {
|
||||
delete_user_meta($user_id, $meta_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $before @param array<string,mixed> $payload @return array{level:int,expires:int}|\WP_Error */
|
||||
private function membership_target(array $before, string $operation, array $payload): array|\WP_Error {
|
||||
if ($operation === 'disable') {
|
||||
return ['level' => 0, 'expires' => 0];
|
||||
}
|
||||
if ($operation === 'set') {
|
||||
$level = (int) ($payload['level'] ?? 0);
|
||||
if (!in_array($level, [1, 2], true)) {
|
||||
return new \WP_Error('aksrt_vip_level_invalid', __('会员档位无效。', 'aksrt'));
|
||||
}
|
||||
$expires = (int) ($payload['expires'] ?? -1);
|
||||
if ($expires < 0 || ($expires > 0 && $expires < time())) {
|
||||
return new \WP_Error('aksrt_vip_expiry_invalid', __('到期时间无效或已过期。', 'aksrt'));
|
||||
}
|
||||
return ['level' => $level, 'expires' => $expires];
|
||||
}
|
||||
$amount = (int) ($payload['amount'] ?? 0);
|
||||
$unit = sanitize_key((string) ($payload['unit'] ?? 'day'));
|
||||
if ($before['level'] < 1) {
|
||||
return new \WP_Error('aksrt_vip_extend_unavailable', __('无有效会员,无法延长。', 'aksrt'));
|
||||
}
|
||||
if ($before['expires'] <= 0) {
|
||||
return new \WP_Error('aksrt_vip_extend_permanent', __('永久会员无需延长。', 'aksrt'));
|
||||
}
|
||||
if ($amount < 1 || $amount > 3650 || !in_array($unit, ['day', 'month'], true)) {
|
||||
return new \WP_Error('aksrt_vip_duration_invalid', __('延长时长无效。', 'aksrt'));
|
||||
}
|
||||
$base = max(time(), $before['expires']);
|
||||
$date = (new \DateTimeImmutable('@' . $base))->setTimezone(wp_timezone());
|
||||
$next = $date->modify('+' . $amount . ' ' . ($unit === 'month' ? 'months' : 'days'));
|
||||
if (!$next) {
|
||||
return new \WP_Error('aksrt_vip_duration_invalid', __('无法计算新的到期时间。', 'aksrt'));
|
||||
}
|
||||
return ['level' => $before['level'], 'expires' => $next->getTimestamp()];
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,10 @@ class DBMigrator {
|
||||
use DBMigratorPart2;
|
||||
use DBMigratorPart3;
|
||||
use DBMigratorIdentity;
|
||||
use DBMigratorVip;
|
||||
|
||||
/** Current schema version; changing it re-runs dbDelta. */
|
||||
const SCHEMA_VERSION = '1.20.0';
|
||||
const SCHEMA_VERSION = '1.21.0';
|
||||
|
||||
const OPT_KEY = 'aksrt_db_schema_version';
|
||||
|
||||
|
||||
@@ -401,5 +401,6 @@ trait DBMigratorPart2 {
|
||||
dbDelta($sql_order_items);
|
||||
|
||||
self::migrate_identity_tables($prefix, $charset_collate);
|
||||
self::migrate_vip_tables($prefix, $charset_collate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ trait DBMigratorPart3 {
|
||||
'user_restriction' => 'aksrt_user_restriction',
|
||||
'identity_application' => 'aksrt_identity_application',
|
||||
'identity_event' => 'aksrt_identity_event',
|
||||
'vip_membership_event' => 'aksrt_vip_membership_event',
|
||||
];
|
||||
if (!isset($allowed[$name])) {
|
||||
return '';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* VIP membership audit table.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait DBMigratorVip {
|
||||
|
||||
private static function migrate_vip_tables(string $prefix, string $charset_collate): void {
|
||||
$event = "CREATE TABLE {$prefix}aksrt_vip_membership_event (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
event_uuid CHAR(36) NOT NULL,
|
||||
batch_uuid CHAR(36) NOT NULL DEFAULT '',
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
actor_user_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
operation VARCHAR(16) NOT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'admin',
|
||||
reason VARCHAR(255) NOT NULL DEFAULT '',
|
||||
request_json LONGTEXT DEFAULT NULL,
|
||||
before_json LONGTEXT DEFAULT NULL,
|
||||
after_json LONGTEXT DEFAULT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
error_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
error_message VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
completed_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_event_uuid (event_uuid),
|
||||
KEY idx_user_created (user_id, created_at),
|
||||
KEY idx_actor_created (actor_user_id, created_at),
|
||||
KEY idx_batch (batch_uuid),
|
||||
KEY idx_status_created (status, created_at),
|
||||
KEY idx_operation_created (operation, created_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta($event);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user