From 80283df6b2d99f10b65d8a6902235e50243562b5 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 11 Aug 2026 16:58:01 +0800 Subject: [PATCH] feat(vip): implement layered membership expiry flow --- functions.php | 2 +- inc/helpers/aksrt-vip.php | 98 ++++++++----- inc/helpers/user-center-renderers/vip-tab.php | 6 +- src/Admin/Native/UsersMenusVip.php | 9 +- src/Content/VipMembershipExpiry.php | 130 ++++++++++++++++++ src/Content/VipService.php | 3 + src/Content/VipServiceMembership.php | 10 +- src/Content/VipServicePurchase.php | 40 +++++- 8 files changed, 251 insertions(+), 47 deletions(-) create mode 100644 src/Content/VipMembershipExpiry.php diff --git a/functions.php b/functions.php index a97eb615..82e179ac 100644 --- a/functions.php +++ b/functions.php @@ -111,7 +111,7 @@ add_filter('query_vars', function ($vars) { // 主题切换离开时:清理本主题注册的 cron 任务 add_action('switch_theme', function () { - $hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup', 'aksrt_creator_income_settlement']; + $hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup', 'aksrt_creator_income_settlement', 'aksrt_vip_membership_expiry']; foreach ($hooks as $hook) { $next = wp_next_scheduled($hook); while ($next) { diff --git a/inc/helpers/aksrt-vip.php b/inc/helpers/aksrt-vip.php index e9fce308..f5ea4c8a 100644 --- a/inc/helpers/aksrt-vip.php +++ b/inc/helpers/aksrt-vip.php @@ -13,7 +13,7 @@ defined('ABSPATH') || exit; * Explicit membership meta is authoritative, including an explicit level 0. * Order fallback is only for users without a canonical membership level. * - * @return array{level:int,expires:int,state:string,expired_level:int,source:string,has_explicit_state:bool,is_permanent:bool} + * @return array{level:int,expires:int,state:string,expired_level:int,source:string,has_explicit_state:bool,is_permanent:bool,current_level:int,current_expires:int,current_state:string,fallback_level:int,fallback_expires:int,fallback_state:string,has_fallback:bool,is_layered_upgrade:bool} */ function aksrt_get_user_vip_state(int $user_id = 0): array { $empty = [ @@ -24,6 +24,14 @@ function aksrt_get_user_vip_state(int $user_id = 0): array { 'source' => 'none', 'has_explicit_state' => false, 'is_permanent' => false, + 'current_level' => 0, + 'current_expires' => 0, + 'current_state' => 'unopened', + 'fallback_level' => 0, + 'fallback_expires' => 0, + 'fallback_state' => 'none', + 'has_fallback' => false, + 'is_layered_upgrade' => false, ]; if ($user_id <= 0) { $user_id = get_current_user_id(); @@ -44,58 +52,79 @@ function aksrt_get_user_vip_state(int $user_id = 0): array { $expired_level = min(2, max(0, (int) get_user_meta($user_id, 'aksrt_vip_level_expired', true))); if ($has_explicit) { - if ($level > 0 && $expires > 0 && $expires < time()) { - $base_level = min(2, max(0, (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)); - if ($base_level > 0 && ($base_expires === 0 || $base_expires >= time())) { - update_user_meta($user_id, 'aksrt_vip_level', $base_level); - update_user_meta($user_id, 'aksrt_vip_expires', $base_expires); - delete_user_meta($user_id, 'aksrt_vip_base_level'); - delete_user_meta($user_id, 'aksrt_vip_base_expires'); - $level = $base_level; - $expires = $base_expires; - $expired_level = 0; + if ($level > 0 && $expires > 0 && $expires <= time()) { + if (class_exists('\\AKSRT\\Content\\VipMembershipExpiry')) { + \AKSRT\Content\VipMembershipExpiry::process_user($user_id, 'resolver'); + $level = min(2, max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true))); + $expires = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true)); } else { - $expired_level = max($expired_level, $level); - update_user_meta($user_id, 'aksrt_vip_level_expired', $expired_level); - update_user_meta($user_id, 'aksrt_vip_level', 0); - $level = 0; - $expires = 0; + $base_level = min(2, max(0, (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)); + if ($base_level > 0 && ($base_expires === 0 || $base_expires > time())) { + update_user_meta($user_id, 'aksrt_vip_level', $base_level); + update_user_meta($user_id, 'aksrt_vip_expires', $base_expires); + delete_user_meta($user_id, 'aksrt_vip_base_level'); + delete_user_meta($user_id, 'aksrt_vip_base_expires'); + $level = $base_level; + $expires = $base_expires; + $expired_level = 0; + } else { + $expired_level = max($expired_level, $level); + update_user_meta($user_id, 'aksrt_vip_level_expired', $expired_level); + update_user_meta($user_id, 'aksrt_vip_level', 0); + update_user_meta($user_id, 'aksrt_vip_expires', 0); + delete_user_meta($user_id, 'aksrt_vip_base_level'); + delete_user_meta($user_id, 'aksrt_vip_base_expires'); + $level = 0; + $expires = 0; + } } } + $fallback_level = min(2, max(0, (int) get_user_meta($user_id, 'aksrt_vip_base_level', true))); + $fallback_expires = max(0, (int) get_user_meta($user_id, 'aksrt_vip_base_expires', true)); if ($level > 0) { $level = $level >= 2 && $vip2_enabled ? 2 : ($vip1_enabled ? 1 : 0); + $current_state = $expires > 0 ? 'active' : 'permanent'; + $fallback_state = $fallback_level > 0 ? ($fallback_expires > 0 ? 'active' : 'permanent') : 'none'; return [ 'level' => $level, 'expires' => $expires, - 'state' => $expires > 0 ? 'active' : 'permanent', + 'state' => $current_state, 'expired_level' => 0, 'source' => 'meta', 'has_explicit_state' => true, 'is_permanent' => $expires <= 0, + 'current_level' => $level, + 'current_expires' => $expires, + 'current_state' => $current_state, + 'fallback_level' => $fallback_level, + 'fallback_expires' => $fallback_expires, + 'fallback_state' => $fallback_state, + 'has_fallback' => $fallback_level > 0, + 'is_layered_upgrade' => $fallback_level > 0 && $fallback_level < $level, ]; } - return [ - 'level' => 0, - 'expires' => 0, + return array_merge($empty, [ 'state' => $expired_level > 0 ? 'expired' : 'unopened', 'expired_level' => $expired_level, 'source' => 'meta', 'has_explicit_state' => true, - 'is_permanent' => false, - ]; + 'current_state' => $expired_level > 0 ? 'expired' : 'unopened', + ]); } $paid = \AKSRT\Settings\Settings::get_paid_content_settings(); $user = get_userdata($user_id); if ($user && array_intersect((array) $user->roles, (array) ($paid['vip_exempt_roles'] ?? []))) { $level = $vip2_enabled ? 2 : ($vip1_enabled ? 1 : 0); - return [ - 'level' => $level, 'expires' => 0, + return array_merge($empty, [ + 'level' => $level, 'state' => $level > 0 ? 'permanent' : 'unopened', - 'expired_level' => 0, 'source' => 'exempt', - 'has_explicit_state' => false, 'is_permanent' => $level > 0, - ]; + 'source' => 'exempt', + 'is_permanent' => $level > 0, + 'current_level' => $level, + 'current_state' => $level > 0 ? 'permanent' : 'unopened', + ]); } if (class_exists('\AKSRT\Payment\Order')) { @@ -115,12 +144,15 @@ function aksrt_get_user_vip_state(int $user_id = 0): array { $level = min(2, max(1, $level)); $level = $level >= 2 && $vip2_enabled ? 2 : ($vip1_enabled ? 1 : 0); $order_expires = max(0, (int) ($order_meta['expires'] ?? $order_meta['vip_expires'] ?? 0)); - return [ - 'level' => $level, 'expires' => $order_expires, + return array_merge($empty, [ + 'level' => $level, + 'expires' => $order_expires, 'state' => $level > 0 ? 'active' : 'unopened', - 'expired_level' => 0, 'source' => 'order', - 'has_explicit_state' => false, 'is_permanent' => false, - ]; + 'source' => 'order', + 'current_level' => $level, + 'current_expires' => $order_expires, + 'current_state' => $level > 0 ? 'active' : 'unopened', + ]); } } return $empty; diff --git a/inc/helpers/user-center-renderers/vip-tab.php b/inc/helpers/user-center-renderers/vip-tab.php index 86b45124..b93fa589 100644 --- a/inc/helpers/user-center-renderers/vip-tab.php +++ b/inc/helpers/user-center-renderers/vip-tab.php @@ -30,9 +30,9 @@ if (class_exists('\\AKSRT\\Content\\VipService')) { $user_points = (int) ($vip_catalog['user_points'] ?? 0); $currency_name = (string) ($vip_catalog['currency_name'] ?? $currency_name); } -$expires = max(0, (int) ($vip_state['expires'] ?? 0)); -$base_level = 0; -$base_expires = 0; +$expires = max(0, (int) ($vip_state['current_expires'] ?? $vip_state['expires'] ?? 0)); +$base_level = min(2, max(0, (int) ($vip_state['fallback_level'] ?? 0))); +$base_expires = max(0, (int) ($vip_state['fallback_expires'] ?? 0)); $expired_level = min(2, max(0, (int) ($vip_state['expired_level'] ?? 0))); $vip_state_name = (string) ($vip_state['state'] ?? 'unopened'); $vip_state_source = (string) ($vip_state['source'] ?? 'none'); diff --git a/src/Admin/Native/UsersMenusVip.php b/src/Admin/Native/UsersMenusVip.php index 4e8a9f61..86efe7ab 100644 --- a/src/Admin/Native/UsersMenusVip.php +++ b/src/Admin/Native/UsersMenusVip.php @@ -225,21 +225,24 @@ trait UsersMenusVip { } private function render_vip_operation_fields(array $names): void { - echo ' '; + echo ' '; echo ' '; 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)) { + if (!in_array($operation, ['set', 'extend', 'disable', 'restore'], true)) { return new \WP_Error('aksrt_vip_operation_invalid', __('会员操作无效。', 'aksrt')); } if ($operation === 'disable') { return []; } + if ($operation === 'restore') { + return []; + } if ($operation === 'extend') { return [ 'amount' => absint($_POST['amount'] ?? 0), diff --git a/src/Content/VipMembershipExpiry.php b/src/Content/VipMembershipExpiry.php new file mode 100644 index 00000000..60727f8c --- /dev/null +++ b/src/Content/VipMembershipExpiry.php @@ -0,0 +1,130 @@ +get_col($wpdb->prepare( + "SELECT DISTINCT um.user_id FROM {$wpdb->usermeta} um + WHERE um.meta_key = 'aksrt_vip_level' + AND CAST(um.meta_value AS UNSIGNED) > 0 + AND EXISTS ( + SELECT 1 FROM {$wpdb->usermeta} expiry + WHERE expiry.user_id = um.user_id + AND expiry.meta_key = 'aksrt_vip_expires' + AND CAST(expiry.meta_value AS UNSIGNED) > 0 + AND CAST(expiry.meta_value AS UNSIGNED) <= %d + ) + ORDER BY user_id ASC LIMIT %d", + $now, + self::BATCH_SIZE + )); + foreach ((array) $rows as $user_id) { + self::process_user((int) $user_id, 'cron'); + } + } + + /** + * Resolve an expired current layer exactly once. + * + * @return array{changed:bool,before:array,after:array}|\WP_Error + */ + public static function process_user(int $user_id, string $source = 'resolver', string $reason = ''): array|\WP_Error { + if ($user_id <= 0 || !get_userdata($user_id)) { + return new \WP_Error('aksrt_vip_user_missing', __('用户不存在。', 'aksrt')); + } + $before = self::snapshot($user_id); + if ($before['level'] <= 0 || $before['expires'] <= 0 || $before['expires'] > time()) { + return ['changed' => false, 'before' => $before, 'after' => $before]; + } + + $repository = new VipMembershipEventRepository(); + $event_uuid = $repository->begin([ + 'user_id' => $user_id, + 'actor_user_id' => 0, + 'operation' => 'expire_recover', + 'source' => $source, + 'reason' => $reason !== '' ? $reason : __('会员当前层到期处理', 'aksrt'), + 'request' => ['now' => time()], + 'before' => $before, + ]); + if (is_wp_error($event_uuid)) { + return $event_uuid; + } + + $fallback_valid = $before['base_level'] > 0 + && ($before['base_expires'] === 0 || $before['base_expires'] > time()); + if ($fallback_valid) { + update_user_meta($user_id, 'aksrt_vip_level', $before['base_level']); + update_user_meta($user_id, 'aksrt_vip_expires', $before['base_expires']); + } else { + update_user_meta($user_id, 'aksrt_vip_level_expired', max($before['expired_level'], $before['level'])); + update_user_meta($user_id, 'aksrt_vip_level', 0); + update_user_meta($user_id, 'aksrt_vip_expires', 0); + } + delete_user_meta($user_id, 'aksrt_vip_base_level'); + delete_user_meta($user_id, 'aksrt_vip_base_expires'); + + $after = self::snapshot($user_id); + $expected_level = $fallback_valid ? $before['base_level'] : 0; + $expected_expires = $fallback_valid ? $before['base_expires'] : 0; + if ($after['level'] !== $expected_level || $after['expires'] !== $expected_expires || $after['base_level'] !== 0 || $after['base_expires'] !== 0) { + self::restore($user_id, $before); + $after = self::snapshot($user_id); + $error = new \WP_Error('aksrt_vip_expiry_write_failed', __('会员到期状态保存失败。', 'aksrt')); + $repository->complete($event_uuid, 'failed', $after, $error->get_error_code(), $error->get_error_message()); + return $error; + } + + $repository->complete($event_uuid, 'succeeded', $after); + do_action('aksrt_vip_membership_expired', $user_id, $before, $after, $source, $event_uuid); + return ['changed' => true, 'before' => $before, 'after' => $after]; + } + + /** @return array{level:int,expires:int,base_level:int,base_expires:int,expired_level:int} */ + public static function 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 $snapshot */ + private static function restore(int $user_id, array $snapshot): void { + foreach (['level' => 'aksrt_vip_level', 'expires' => 'aksrt_vip_expires', 'base_level' => 'aksrt_vip_base_level', 'base_expires' => 'aksrt_vip_base_expires', 'expired_level' => 'aksrt_vip_level_expired'] as $key => $meta_key) { + $value = (int) ($snapshot[$key] ?? 0); + if ($value > 0 || in_array($key, ['level', 'expires'], true)) { + update_user_meta($user_id, $meta_key, $value); + } else { + delete_user_meta($user_id, $meta_key); + } + } + } +} diff --git a/src/Content/VipService.php b/src/Content/VipService.php index f056905b..e80385c4 100644 --- a/src/Content/VipService.php +++ b/src/Content/VipService.php @@ -30,6 +30,9 @@ class VipService { public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2); + if (class_exists(VipMembershipExpiry::class)) { + VipMembershipExpiry::init(); + } } } diff --git a/src/Content/VipServiceMembership.php b/src/Content/VipServiceMembership.php index a23fa719..d4a9e725 100644 --- a/src/Content/VipServiceMembership.php +++ b/src/Content/VipServiceMembership.php @@ -27,7 +27,7 @@ trait VipServiceMembership { 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)) { + if (!in_array($operation, ['set', 'extend', 'disable', 'restore'], true)) { return new \WP_Error('aksrt_vip_operation_invalid', __('会员操作无效。', 'aksrt')); } $reason = sanitize_text_field((string) ($context['reason'] ?? '')); @@ -97,6 +97,14 @@ trait VipServiceMembership { /** @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 === 'restore') { + $level = max(0, min(2, (int) $before['base_level'])); + $expires = max(0, (int) $before['base_expires']); + if ($level <= 0 || ($expires > 0 && $expires <= time())) { + return new \WP_Error('aksrt_vip_restore_unavailable', __('没有有效的回退会员可恢复。', 'aksrt')); + } + return ['level' => $level, 'expires' => $expires]; + } if ($operation === 'disable') { return ['level' => 0, 'expires' => 0]; } diff --git a/src/Content/VipServicePurchase.php b/src/Content/VipServicePurchase.php index 037483a9..76e6650b 100644 --- a/src/Content/VipServicePurchase.php +++ b/src/Content/VipServicePurchase.php @@ -478,6 +478,9 @@ trait VipServicePurchase { if (sanitize_key((string) ($paid_order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) { return; } + if (sanitize_key((string) ($paid_order['payment_method'] ?? '')) === 'card_pass') { + return; + } $user_id = (int) ($paid_order['user_id'] ?? 0); if ($user_id <= 0) { return; @@ -493,6 +496,9 @@ trait VipServicePurchase { if (empty($meta) && is_array($paid_order['meta_payload'] ?? null)) { $meta = $paid_order['meta_payload']; } + if (!empty($meta['card_pass']) || sanitize_key((string) ($meta['vip_pay_type'] ?? '')) === 'card_pass') { + return; + } $level = (int) ($meta['level'] ?? 0); $days = (int) ($meta['days'] ?? 0); @@ -517,13 +523,17 @@ trait VipServicePurchase { $previous_level = (int) get_user_meta($user_id, 'aksrt_vip_level', true); $previous_expiry = (int) get_user_meta($user_id, 'aksrt_vip_expires', true); + $previous_base_level = (int) get_user_meta($user_id, 'aksrt_vip_base_level', true); + $previous_base_expiry = (int) get_user_meta($user_id, 'aksrt_vip_base_expires', true); $finite_duration = $time > 0 || $days > 0; - if ($previous_level > 0 && $previous_expiry === 0 && $level > $previous_level && $finite_duration) { - // Preserve a permanent lower tier while a finite higher-tier upgrade is active. - update_user_meta($user_id, 'aksrt_vip_base_level', $previous_level); - update_user_meta($user_id, 'aksrt_vip_base_expires', 0); - } else { - // Only a finite higher-tier upgrade may keep a layered base membership. + if ($previous_level > 0 && $level > $previous_level && $finite_duration) { + // Preserve the original lower tier while a finite higher-tier upgrade is active. + if ($previous_base_level <= 0 || ($previous_base_expiry > 0 && $previous_base_expiry < time())) { + update_user_meta($user_id, 'aksrt_vip_base_level', $previous_level); + update_user_meta($user_id, 'aksrt_vip_base_expires', max(0, $previous_expiry)); + } + } elseif (!$finite_duration || $level < $previous_level) { + // Permanent grants and lower-tier grants do not retain a temporary overlay. delete_user_meta($user_id, 'aksrt_vip_base_level'); delete_user_meta($user_id, 'aksrt_vip_base_expires'); } @@ -574,6 +584,15 @@ trait VipServicePurchase { } $level = max(1, min(2, $level)); $current_level = (int) get_user_meta($user_id, 'aksrt_vip_level', true); + $current_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true)); + if (!$keep_expiry && $days > 0 && $current_level > 0 && $level > $current_level) { + $base_level = (int) get_user_meta($user_id, 'aksrt_vip_base_level', true); + $base_expires = (int) get_user_meta($user_id, 'aksrt_vip_base_expires', true); + if ($base_level <= 0 || ($base_expires > 0 && $base_expires < time())) { + update_user_meta($user_id, 'aksrt_vip_base_level', $current_level); + update_user_meta($user_id, 'aksrt_vip_base_expires', $current_expiry); + } + } $new_level = max($current_level, $level); update_user_meta($user_id, 'aksrt_vip_level', $new_level); // Clear expired memory when re-granting. @@ -611,6 +630,15 @@ trait VipServicePurchase { $level = max(1, min(2, $level)); $unit = $unit === 'month' ? 'month' : 'day'; $current_level = (int) get_user_meta($user_id, 'aksrt_vip_level', true); + $current_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true)); + if ($time > 0 && $current_level > 0 && $level > $current_level) { + $base_level = (int) get_user_meta($user_id, 'aksrt_vip_base_level', true); + $base_expires = (int) get_user_meta($user_id, 'aksrt_vip_base_expires', true); + if ($base_level <= 0 || ($base_expires > 0 && $base_expires < time())) { + update_user_meta($user_id, 'aksrt_vip_base_level', $current_level); + update_user_meta($user_id, 'aksrt_vip_base_expires', $current_expiry); + } + } $new_level = max($current_level, $level); update_user_meta($user_id, 'aksrt_vip_level', $new_level); delete_user_meta($user_id, 'aksrt_vip_level_expired');