229 lines
8.3 KiB
PHP
229 lines
8.3 KiB
PHP
<?php
|
|
/**
|
|
* AKSRT Email Rollback
|
|
*
|
|
* 用户修改邮箱后会写入两条 user_meta:
|
|
* - aksrt_email_previous 旧邮箱
|
|
* - aksrt_email_changed_at 时间戳
|
|
*
|
|
* 14 天内管理员可执行回滚:把账号邮箱改回旧值并清除回滚 meta。
|
|
*
|
|
* 入口:
|
|
* - 后台用户列表新增"邮箱回滚"行操作
|
|
* - REST: GET /aksrt/v1/email-rollback/users 列出窗口内可回滚的用户
|
|
* - REST: POST /aksrt/v1/email-rollback/users/{id}/rollback 执行回滚
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class AKSRT_Email_Rollback {
|
|
|
|
const META_PREVIOUS = 'aksrt_email_previous';
|
|
const META_CHANGED_AT = 'aksrt_email_changed_at';
|
|
const WINDOW_SECONDS = 1209600; // 14 days
|
|
|
|
private static $instance = null;
|
|
|
|
private function __construct() {}
|
|
|
|
public static function get_instance(): self {
|
|
if (null === self::$instance) self::$instance = new self();
|
|
return self::$instance;
|
|
}
|
|
|
|
public function init(): void {
|
|
add_filter('user_row_actions', [$this, 'add_row_action'], 20, 2);
|
|
add_action('admin_post_aksrt_email_rollback', [$this, 'handle_admin_rollback']);
|
|
// cron 清理过期 meta
|
|
add_action('aksrt_email_rollback_cleanup', [self::class, 'cron_cleanup']);
|
|
add_action('wp', [self::class, 'maybe_schedule_cleanup']);
|
|
}
|
|
|
|
public static function maybe_schedule_cleanup(): void {
|
|
if (!wp_next_scheduled('aksrt_email_rollback_cleanup')) {
|
|
wp_schedule_event(time() + DAY_IN_SECONDS, 'daily', 'aksrt_email_rollback_cleanup');
|
|
}
|
|
}
|
|
|
|
public static function cron_cleanup(): void {
|
|
global $wpdb;
|
|
$cutoff = time() - self::WINDOW_SECONDS;
|
|
$rows = $wpdb->get_col($wpdb->prepare(
|
|
"SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND CAST(meta_value AS UNSIGNED) < %d",
|
|
self::META_CHANGED_AT,
|
|
$cutoff
|
|
));
|
|
if (empty($rows)) return;
|
|
foreach ($rows as $uid) {
|
|
delete_user_meta((int) $uid, self::META_PREVIOUS);
|
|
delete_user_meta((int) $uid, self::META_CHANGED_AT);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 在 14 天窗口内?
|
|
*/
|
|
public static function is_within_window(int $user_id): bool {
|
|
$changed_at = (int) get_user_meta($user_id, self::META_CHANGED_AT, true);
|
|
if ($changed_at <= 0) return false;
|
|
return (time() - $changed_at) <= self::WINDOW_SECONDS;
|
|
}
|
|
|
|
/**
|
|
* 列出窗口内可回滚的用户。
|
|
*/
|
|
public static function list_pending(int $page = 1, int $per_page = 20): array {
|
|
global $wpdb;
|
|
$page = max(1, $page);
|
|
$per_page = min(100, max(1, $per_page));
|
|
$offset = ($page - 1) * $per_page;
|
|
$cutoff = time() - self::WINDOW_SECONDS;
|
|
|
|
$sql_count = $wpdb->prepare(
|
|
"SELECT COUNT(DISTINCT um1.user_id)
|
|
FROM {$wpdb->usermeta} um1
|
|
INNER JOIN {$wpdb->usermeta} um2 ON um2.user_id = um1.user_id AND um2.meta_key = %s
|
|
WHERE um1.meta_key = %s AND CAST(um1.meta_value AS UNSIGNED) >= %d",
|
|
self::META_PREVIOUS,
|
|
self::META_CHANGED_AT,
|
|
$cutoff
|
|
);
|
|
$total = (int) $wpdb->get_var($sql_count);
|
|
|
|
$sql = $wpdb->prepare(
|
|
"SELECT um1.user_id, um1.meta_value AS changed_at, um2.meta_value AS previous_email
|
|
FROM {$wpdb->usermeta} um1
|
|
INNER JOIN {$wpdb->usermeta} um2 ON um2.user_id = um1.user_id AND um2.meta_key = %s
|
|
WHERE um1.meta_key = %s AND CAST(um1.meta_value AS UNSIGNED) >= %d
|
|
ORDER BY CAST(um1.meta_value AS UNSIGNED) DESC
|
|
LIMIT %d OFFSET %d",
|
|
self::META_PREVIOUS,
|
|
self::META_CHANGED_AT,
|
|
$cutoff,
|
|
$per_page,
|
|
$offset
|
|
);
|
|
$rows = $wpdb->get_results($sql, ARRAY_A);
|
|
|
|
$items = [];
|
|
foreach ((array) $rows as $r) {
|
|
$uid = (int) $r['user_id'];
|
|
$u = get_userdata($uid);
|
|
if (!$u) continue;
|
|
$items[] = [
|
|
'user_id' => $uid,
|
|
'login' => $u->user_login,
|
|
'display_name' => $u->display_name,
|
|
'current_email' => $u->user_email,
|
|
'previous_email' => (string) $r['previous_email'],
|
|
'changed_at' => (int) $r['changed_at'],
|
|
'expires_in' => max(0, (int) $r['changed_at'] + self::WINDOW_SECONDS - time()),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'items' => $items,
|
|
'total' => $total,
|
|
'page' => $page,
|
|
'per_page' => $per_page,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 执行回滚。
|
|
*
|
|
* @return array{success:bool, message?:string, error?:string}
|
|
*/
|
|
public static function rollback(int $user_id, int $by_admin_id = 0): array {
|
|
if ($user_id <= 0 || !current_user_can('edit_user', $user_id)) {
|
|
return ['success' => false, 'error' => __('权限不足', 'aksrt')];
|
|
}
|
|
if (!self::is_within_window($user_id)) {
|
|
return ['success' => false, 'error' => __('回滚窗口已过期或当前账号未发起过邮箱变更', 'aksrt')];
|
|
}
|
|
|
|
$previous = (string) get_user_meta($user_id, self::META_PREVIOUS, true);
|
|
if ($previous === '' || !is_email($previous)) {
|
|
return ['success' => false, 'error' => __('旧邮箱无效', 'aksrt')];
|
|
}
|
|
|
|
// 检查旧邮箱是否被其他用户占用(理论上极少见)
|
|
$occupier = email_exists($previous);
|
|
if ($occupier && (int) $occupier !== $user_id) {
|
|
return ['success' => false, 'error' => __('旧邮箱已被其他账号占用,无法回滚', 'aksrt')];
|
|
}
|
|
|
|
$update = wp_update_user([
|
|
'ID' => $user_id,
|
|
'user_email' => $previous,
|
|
]);
|
|
if (is_wp_error($update)) {
|
|
return ['success' => false, 'error' => $update->get_error_message()];
|
|
}
|
|
|
|
// 清空回滚 meta(一次性)
|
|
delete_user_meta($user_id, self::META_PREVIOUS);
|
|
delete_user_meta($user_id, self::META_CHANGED_AT);
|
|
|
|
// 记录管理员操作日志
|
|
$log = get_user_meta($user_id, 'aksrt_email_verification_admin_log', true);
|
|
if (!is_array($log)) $log = [];
|
|
$log[] = [
|
|
't' => time(),
|
|
'action' => 'email_rollback',
|
|
'by' => $by_admin_id ?: get_current_user_id(),
|
|
];
|
|
if (count($log) > 10) $log = array_slice($log, -10);
|
|
update_user_meta($user_id, 'aksrt_email_verification_admin_log', $log);
|
|
|
|
if (class_exists('AKSRT_Auth_Service')) {
|
|
AKSRT_Auth_Service::log_event('admin.email_rollback', [
|
|
'uid' => $user_id,
|
|
'by' => $by_admin_id ?: get_current_user_id(),
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => sprintf(__('已将用户邮箱回滚为 %s', 'aksrt'), $previous),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 在用户列表行操作中加入"回滚邮箱"。
|
|
*/
|
|
public function add_row_action(array $actions, WP_User $user): array {
|
|
if (!current_user_can('edit_user', $user->ID)) return $actions;
|
|
if (!self::is_within_window((int) $user->ID)) return $actions;
|
|
|
|
$url = wp_nonce_url(
|
|
admin_url('admin-post.php?action=aksrt_email_rollback&user_id=' . (int) $user->ID),
|
|
'aksrt_email_rollback_' . (int) $user->ID
|
|
);
|
|
$actions['aksrt_email_rollback'] = sprintf(
|
|
'<a href="%s">%s</a>',
|
|
esc_url($url),
|
|
esc_html__('回滚邮箱', 'aksrt')
|
|
);
|
|
return $actions;
|
|
}
|
|
|
|
public function handle_admin_rollback(): void {
|
|
$user_id = isset($_GET['user_id']) ? absint($_GET['user_id']) : 0;
|
|
if ($user_id <= 0 || !current_user_can('edit_user', $user_id)) {
|
|
wp_die(esc_html__('权限不足', 'aksrt'));
|
|
}
|
|
check_admin_referer('aksrt_email_rollback_' . $user_id);
|
|
|
|
$r = self::rollback($user_id, get_current_user_id());
|
|
$type = !empty($r['success']) ? 'success' : 'error';
|
|
$msg = $r['message'] ?? $r['error'] ?? '';
|
|
set_transient('aksrt_admin_notice_' . get_current_user_id(), [['type' => $type, 'msg' => $msg]], 30);
|
|
|
|
wp_safe_redirect(wp_get_referer() ?: admin_url('users.php'));
|
|
exit;
|
|
}
|
|
}
|