feat(vip): implement layered membership expiry flow

This commit is contained in:
2026-08-11 16:58:01 +08:00
parent ddc762bfbd
commit 80283df6b2
8 changed files with 251 additions and 47 deletions
+6 -3
View File
@@ -225,21 +225,24 @@ trait UsersMenusVip {
}
private function render_vip_operation_fields(array $names): void {
echo '<select class="aksrt-vip-operation" 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 '<select class="aksrt-vip-operation" name="vip_operation"><option value="set">' . esc_html__('设置会员', 'aksrt') . '</option><option value="extend">' . esc_html__('延长有效期', 'aksrt') . '</option><option value="restore">' . esc_html__('取消限时升级/恢复回退会员', 'aksrt') . '</option><option value="disable">' . esc_html__('停用会员', 'aksrt') . '</option></select> ';
echo '<span class="aksrt-vip-set-fields"><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></span> ';
echo '<span class="aksrt-vip-extend-fields"><label>' . esc_html__('延长', 'aksrt') . ' <input class="aksrt-vip-amount" 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></span>';
echo '<script>document.querySelectorAll(".aksrt-vip-operation").forEach(function(select){function sync(){var form=select.closest("form");if(!form)return;var set=form.querySelector(".aksrt-vip-set-fields"),extend=form.querySelector(".aksrt-vip-extend-fields"),danger=select.value==="disable";if(set)set.hidden=select.value!=="set";if(extend)extend.hidden=select.value!=="extend";if(danger&&!form.dataset.confirmBound){form.addEventListener("submit",function(e){if(select.value==="disable"&&!window.confirm("停用后将立即撤销该用户的前端会员权益,并清理恢复状态,确定继续吗?"))e.preventDefault();});form.dataset.confirmBound="1";}}sync();select.addEventListener("change",sync);});</script>';
echo '<script>document.querySelectorAll(".aksrt-vip-operation").forEach(function(select){function sync(){var form=select.closest("form");if(!form)return;var set=form.querySelector(".aksrt-vip-set-fields"),extend=form.querySelector(".aksrt-vip-extend-fields"),danger=select.value==="disable"||select.value==="restore";if(set)set.hidden=select.value!=="set";if(extend)extend.hidden=select.value!=="extend";if(danger&&!form.dataset.confirmBound){form.addEventListener("submit",function(e){if((select.value==="disable"&&!window.confirm("停用后将立即撤销该用户的前端会员权益,并清理恢复状态,确定继续吗?"))||(select.value==="restore"&&!window.confirm("将恢复回退会员并取消当前限时升级,确定继续吗?")))e.preventDefault();});form.dataset.confirmBound="1";}}sync();select.addEventListener("change",sync);});</script>';
}
/** @return array<string,mixed>|\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),
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* VIP layered membership expiry and recovery service.
*
* @package AKSRT
*/
namespace AKSRT\Content;
defined('ABSPATH') || exit;
final class VipMembershipExpiry {
public const CRON_HOOK = 'aksrt_vip_membership_expiry';
private const BATCH_SIZE = 100;
public static function init(): void {
add_action(self::CRON_HOOK, [self::class, 'run_batch']);
add_action('init', [self::class, 'maybe_schedule'], 20);
}
public static function maybe_schedule(): void {
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time() + 300, 'hourly', self::CRON_HOOK);
}
}
public static function run_batch(): void {
global $wpdb;
$now = time();
$rows = $wpdb->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<string,int> $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);
}
}
}
}
+3
View File
@@ -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();
}
}
}
+9 -1
View File
@@ -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<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 === '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];
}
+34 -6
View File
@@ -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');