diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index 7d3d9f6b..2112a5a6 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -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 '

' . esc_html($message) . '

'; } diff --git a/src/Admin/Native/UsersMenusVip.php b/src/Admin/Native/UsersMenusVip.php new file mode 100644 index 00000000..1e3e70f8 --- /dev/null +++ b/src/Admin/Native/UsersMenusVip.php @@ -0,0 +1,297 @@ +ensure_cap(); + $tab = $this->vip_tab(); + $page = max(1, absint($_GET['paged'] ?? 1)); + echo '

' . esc_html__('用户会员', 'aksrt') . '

'; + $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 '
'; + } + + 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>,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 ''; + } + + private function render_vip_filters(array $filters): void { + $names = $this->vip_level_names(); + echo '
'; + echo ''; + submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + echo '
'; + } + + private function render_vip_bulk_form(array $filters): void { + $names = $this->vip_level_names(); + echo '
'; + echo ''; + wp_nonce_field('aksrt_users_mutate_vip'); + $this->render_vip_return_fields($filters); + echo '' . esc_html__('批量操作', 'aksrt') . ' '; + $this->render_vip_operation_fields($names); + echo ' '; + submit_button(__('应用到选中用户', 'aksrt'), 'primary', '', false); + echo '
'; + } + + private function render_vip_table(array $items, array $filters): void { + echo ''; + if ($items === []) { + echo ''; + } + foreach ($items as $row) { + $this->render_vip_row($row, $filters); + } + echo '
UID' . esc_html__('用户', 'aksrt') . '' . esc_html__('当前会员', 'aksrt') . '' . esc_html__('到期时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html__('暂无用户', 'aksrt') . '
'; + } + + 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 '' . $id . '' . esc_html((string) $row['display_name']) . '
@' . esc_html((string) $row['user_login']) . '
' . esc_html($label) . '' . esc_html($expiry) . '
' . esc_html__('调整', 'aksrt') . '
'; + wp_nonce_field('aksrt_users_mutate_vip'); + $this->render_vip_return_fields($filters); + $this->render_vip_operation_fields($names); + echo '

'; + submit_button(__('保存', 'aksrt'), 'secondary', '', false); + echo '
'; + } + + private function render_vip_operation_fields(array $names): void { + echo ' '; + echo ' '; + echo ' '; + echo ''; + } + + /** @return array|\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 ''; + if ($result['items'] === []) { + echo ''; + } + foreach ($result['items'] as $event) { + $user = (string) ($event['display_name'] ?: $event['user_login']); + echo ''; + } + echo '
' . esc_html__('时间', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '' . esc_html__('操作人', 'aksrt') . '' . esc_html__('原因', 'aksrt') . '' . esc_html__('结果', 'aksrt') . '
' . esc_html__('暂无变更记录', 'aksrt') . '
' . esc_html((string) $event['created_at']) . '' . esc_html($user) . ' (#' . (int) $event['user_id'] . ')' . esc_html((string) $event['operation']) . '' . esc_html((string) ($event['actor_name'] ?: '—')) . '' . esc_html((string) $event['reason']) . '' . esc_html((string) $event['status']) . '
'; + $this->pagination($result['pages'], $page); + } + + /** @return array */ + 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 ''; + } + } + + 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)); + } +} diff --git a/src/Content/VipMembershipEventRepository.php b/src/Content/VipMembershipEventRepository.php new file mode 100644 index 00000000..11c8988b --- /dev/null +++ b/src/Content/VipMembershipEventRepository.php @@ -0,0 +1,69 @@ +table = \AKSRT\Core\DBMigrator::table('vip_membership_event'); + } + + /** @param array $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 $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>,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))]; + } +} diff --git a/src/Content/VipService.php b/src/Content/VipService.php index 028fe826..f056905b 100644 --- a/src/Content/VipService.php +++ b/src/Content/VipService.php @@ -12,6 +12,7 @@ class VipService { use VipServicePurchase; use VipServicePackages; use VipServiceModal; + use VipServiceMembership; private static $instance = null; diff --git a/src/Content/VipServiceMembership.php b/src/Content/VipServiceMembership.php new file mode 100644 index 00000000..a23fa719 --- /dev/null +++ b/src/Content/VipServiceMembership.php @@ -0,0 +1,133 @@ + 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 $payload @param array $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 $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 $before @param array $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()]; + } +} diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php index 4483a928..96d76aad 100644 --- a/src/Core/DBMigrator.php +++ b/src/Core/DBMigrator.php @@ -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'; diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php index 9dce63a6..57ddc21e 100644 --- a/src/Core/DBMigratorPart2.php +++ b/src/Core/DBMigratorPart2.php @@ -401,5 +401,6 @@ trait DBMigratorPart2 { dbDelta($sql_order_items); self::migrate_identity_tables($prefix, $charset_collate); + self::migrate_vip_tables($prefix, $charset_collate); } } diff --git a/src/Core/DBMigratorPart3.php b/src/Core/DBMigratorPart3.php index 45efddfc..10ad66e8 100644 --- a/src/Core/DBMigratorPart3.php +++ b/src/Core/DBMigratorPart3.php @@ -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 ''; diff --git a/src/Core/DBMigratorVip.php b/src/Core/DBMigratorVip.php new file mode 100644 index 00000000..15f58a01 --- /dev/null +++ b/src/Core/DBMigratorVip.php @@ -0,0 +1,42 @@ +