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');