feat(users): enforce real bans from report processing and fix appeal lifting

- apply_report_action now writes aksrt_user_restriction via Restriction::restrict
  and lifts via new lift_user_restriction(), replacing the display-only
  aksrt_user_ban_state meta dual-track
- ban type restrictions destroy the target user's sessions on apply
- appeal approval lifts active ban/suspend/mute restrictions instead of
  updating a nonexistent status column
- UsersBan.vue posts to /users/{id}/restrict with valid types (warn/mute/
  suspend/ban), scope and expires_at
- add comment report entry to the comment actions dropdown
- add duplicate pending report guard on submit
- remove dead ReportCenter ajax handlers, option migration, REST alias
  routes and write-only restriction meta
- fix report admin mail link to the real aksrt_user_ban page
- add report-ban contract tests
This commit is contained in:
2026-09-19 21:38:56 +08:00
parent 4dafe96c4b
commit 835e0f983d
12 changed files with 291 additions and 214 deletions
+1 -15
View File
@@ -573,21 +573,7 @@ class UsersMenus {
update_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, array_slice($history, -30));
if ($decision === 'approve' && class_exists('\AKSRT\User\Restriction')) {
global $wpdb;
$table = class_exists('\AKSRT\Core\DBMigrator') ? \AKSRT\Core\DBMigrator::table('user_restriction') : '';
if ($table !== '') {
$wpdb->query($wpdb->prepare(
"UPDATE {$table} SET status = 'lifted', expires_at = %s WHERE user_id = %d AND type IN ('ban','suspend','mute')",
current_time('mysql'),
$user_id
));
}
$svc = \AKSRT\User\Restriction::get_instance();
if (method_exists($svc, 'clear_user_bans')) {
$svc->clear_user_bans($user_id);
} elseif (method_exists($svc, 'lift_ban')) {
$svc->lift_ban($user_id);
}
\AKSRT\User\Restriction::get_instance()->lift_user_restriction($user_id);
}
if (class_exists('\AKSRT\Messages\Message')) {
-19
View File
@@ -144,11 +144,6 @@ class UserController extends ControllerBase {
]);
// Report ticket list (admin)
register_rest_route(self::API_NAMESPACE, '/report/list', [
'methods' => 'GET',
'callback' => [$this, 'get_report_list'],
'permission_callback' => [$this, 'check_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/users/reports', [
'methods' => 'GET',
'callback' => [$this, 'get_report_list'],
@@ -165,20 +160,6 @@ class UserController extends ControllerBase {
'permission_callback' => [$this, 'check_permission'],
]);
// Report status update (admin)
register_rest_route(self::API_NAMESPACE, '/report/update-status', [
'methods' => 'POST',
'callback' => [$this, 'update_report_status'],
'permission_callback' => [$this, 'check_permission'],
]);
// Report counts (admin)
register_rest_route(self::API_NAMESPACE, '/report/counts', [
'methods' => 'GET',
'callback' => [$this, 'get_report_counts'],
'permission_callback' => [$this, 'check_permission'],
]);
// Report submit (logged-in users, front-end)
register_rest_route(self::API_NAMESPACE, '/report/submit', [
'methods' => 'POST',
+61 -33
View File
@@ -121,27 +121,6 @@ trait UserControllerReportOperations {
]);
}
public function update_report_status(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Messages\ReportCenter')) {
return $this->error(__('举报系统未启用', 'aksrt'));
}
$params = $this->get_json_params($request);
$report_id = (int) ($params['report_id'] ?? 0);
if ($report_id <= 0) {
return $this->error(__('参数无效', 'aksrt'));
}
return $this->handle_report_process($report_id, $params);
}
public function get_report_counts(): \WP_REST_Response {
if (!class_exists('\AKSRT\Messages\ReportCenter')) {
return $this->success(['pending' => 0, 'reviewing' => 0, 'resolved' => 0, 'dismissed' => 0, 'total' => 0]);
}
return $this->success(\AKSRT\Messages\ReportCenter::get_instance()->get_status_counts());
}
public function get_report_detail(\WP_REST_Request $request): \WP_REST_Response {
global $wpdb;
@@ -214,7 +193,8 @@ trait UserControllerReportOperations {
$effect = $this->apply_report_action(
sanitize_key((string) $row['target_type']),
(int) $row['target_id'],
$action_taken
$action_taken,
(string) ($row['reason'] ?? '')
);
$notes = json_decode((string) ($row['notes_json'] ?? '[]'), true);
@@ -346,27 +326,70 @@ trait UserControllerReportOperations {
$summary['display_name'] = $user->display_name;
$summary['title'] = $user->display_name;
$summary['url'] = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($target_id) : '';
$summary['ban_state'] = (string) get_user_meta($target_id, 'aksrt_user_ban_state', true);
$summary['ban_state'] = $this->get_user_restriction_state($target_id);
}
}
return $summary;
}
private function apply_report_action(string $target_type, int $target_id, string $action_taken): string {
/**
* 用户当前限制状态摘要(banned|restricted|muted|空),与 Restriction 表实时同步。
*/
private function get_user_restriction_state(int $user_id): string {
if ($user_id <= 0 || !class_exists('\AKSRT\User\Restriction')) {
return '';
}
$svc = \AKSRT\User\Restriction::get_instance();
if ($svc->is_banned($user_id)) {
return 'banned';
}
if ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_SUSPEND)) {
return 'restricted';
}
if ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_MUTE)) {
return 'muted';
}
return '';
}
private function apply_report_action(string $target_type, int $target_id, string $action_taken, string $reason = ''): string {
if ($target_id <= 0 || $action_taken === 'none') {
return '';
}
if ($target_type === 'user') {
if (in_array($action_taken, ['muted', 'restricted', 'banned'], true)) {
update_user_meta($target_id, 'aksrt_user_ban_state', $action_taken);
return sprintf(__('已同步用户限制状态:%s', 'aksrt'), $action_taken);
if (!class_exists('\AKSRT\User\Restriction')) {
return '';
}
$svc = \AKSRT\User\Restriction::get_instance();
if ($action_taken === 'clear') {
delete_user_meta($target_id, 'aksrt_user_ban_state');
return __('已清除用户限制状态', 'aksrt');
$removed = $svc->lift_user_restriction($target_id);
return $removed > 0
? sprintf(__('已解除 %d 条生效中的限制', 'aksrt'), $removed)
: __('该用户没有生效中的限制', 'aksrt');
}
if (in_array($action_taken, \AKSRT\User\Restriction::TYPES, true)) {
$labels = [
\AKSRT\User\Restriction::TYPE_WARN => __('警告', 'aksrt'),
\AKSRT\User\Restriction::TYPE_MUTE => __('禁言', 'aksrt'),
\AKSRT\User\Restriction::TYPE_SUSPEND => __('停权', 'aksrt'),
\AKSRT\User\Restriction::TYPE_BAN => __('封禁', 'aksrt'),
];
$result = $svc->restrict(
$target_id,
$action_taken,
$reason !== '' ? $reason : __('举报处理处罚', 'aksrt'),
\AKSRT\User\Restriction::SCOPE_GLOBAL,
'',
get_current_user_id()
);
if (!empty($result['success'])) {
return sprintf(__('已对用户执行限制:%s', 'aksrt'), $labels[$action_taken] ?? $action_taken);
}
return '';
}
return '';
@@ -413,10 +436,10 @@ trait UserControllerReportOperations {
if ($target_type === 'user' || $target_type === '') {
$common = array_merge($common, [
['label' => __('发送警告', 'aksrt'), 'value' => 'warned'],
['label' => __('禁言', 'aksrt'), 'value' => 'muted'],
['label' => __('限制功能', 'aksrt'), 'value' => 'restricted'],
['label' => __('封禁账号', 'aksrt'), 'value' => 'banned'],
['label' => __('发送警告', 'aksrt'), 'value' => 'warn'],
['label' => __('禁言', 'aksrt'), 'value' => 'mute'],
['label' => __('停权', 'aksrt'), 'value' => 'suspend'],
['label' => __('封禁账号', 'aksrt'), 'value' => 'ban'],
['label' => __('解除限制', 'aksrt'), 'value' => 'clear'],
]);
}
@@ -455,6 +478,11 @@ trait UserControllerReportOperations {
if ($reporter_id <= 0) {
return $this->error(__('请先登录', 'aksrt'), 401);
}
// 同一人对同一目标的待处理工单只允许存在一条
if (\AKSRT\Messages\ReportCenter::get_instance()->has_pending_report($reporter_id, $target_type, $target_id)) {
return $this->error(__('你已提交过相同的举报,请等待处理结果', 'aksrt'), 409);
}
if ($target_type === 'user') {
if ($target_id === $reporter_id) {
return $this->error(__('不能举报自己', 'aksrt'), 400);
+9 -120
View File
@@ -190,129 +190,18 @@ class ReportCenter {
}
/**
* 一次性迁移:把 option 中的举报数据搬到表中。
* 幂等 — 已存在的 ID 不会重复插入。
* 同一举报人对同一目标是否已有待处理工单(防止重复提交)。
*/
public function migrate_from_option(): int {
public function has_pending_report(int $reporter_id, string $target_type, int $target_id): bool {
$table = $this->table();
if ($table === '') return 0;
$reports = get_option('aksrt_reports', []);
if (!is_array($reports) || empty($reports)) return 0;
if ($table === '') return false;
global $wpdb;
$migrated = 0;
foreach ($reports as $old_id => $report) {
// 检查是否已迁移(通过 reporter_id + target_type + target_id + created_at 去重)
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE reporter_id = %d AND target_type = %s AND target_id = %d AND created_at = %s",
(int) ($report['reporter_id'] ?? 0),
sanitize_key((string) ($report['target_type'] ?? '')),
(int) ($report['target_id'] ?? 0),
(string) ($report['created_at'] ?? '')
));
if ($existing) continue;
$notes_json = '';
if (!empty($report['notes']) && is_array($report['notes'])) {
$notes_json = wp_json_encode($report['notes']);
}
$wpdb->insert($table, [
'reporter_id' => (int) ($report['reporter_id'] ?? 0),
'target_type' => sanitize_key((string) ($report['target_type'] ?? '')),
'target_id' => (int) ($report['target_id'] ?? 0),
'reason' => sanitize_textarea_field((string) ($report['reason'] ?? '')),
'category' => sanitize_key((string) ($report['category'] ?? '')),
'status' => sanitize_key((string) ($report['status'] ?? 'pending')),
'created_at' => (string) ($report['created_at'] ?? current_time('mysql')),
'updated_at' => (string) ($report['updated_at'] ?? ''),
'notes_json' => $notes_json,
], ['%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s']);
if ($wpdb->insert_id) $migrated++;
}
if ($migrated > 0) {
update_option('aksrt_report_migrated_to_table', time());
}
return $migrated;
}
/**
* Initialize AJAX hooks for admin operations.
*/
public function init(): void {
add_action('wp_ajax_aksrt_report_update_status', [$this, 'ajax_update_status']);
add_action('wp_ajax_aksrt_report_list', [$this, 'ajax_list']);
add_action('wp_ajax_aksrt_report_counts', [$this, 'ajax_counts']);
// 一次性迁移钩子
add_action('admin_init', [$this, 'maybe_migrate'], 20);
}
/**
* 自动迁移旧 option 数据(幂等)
*/
public function maybe_migrate(): void {
if (get_option('aksrt_report_migrated_to_table')) return;
$this->migrate_from_option();
}
/**
* AJAX: Update report status (admin only).
*/
public function ajax_update_status(): void {
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
check_ajax_referer('aksrt_report', '_nonce');
$report_id = (int) ($_POST['report_id'] ?? 0);
$new_status = sanitize_key((string) ($_POST['status'] ?? ''));
$note = sanitize_textarea_field((string) ($_POST['note'] ?? ''));
if ($report_id <= 0 || $new_status === '') {
wp_send_json_error(['message' => __('参数无效', 'aksrt')]);
}
$result = $this->update_status($report_id, $new_status, $note);
if ($result) {
wp_send_json_success(['message' => __('状态已更新', 'aksrt')]);
}
wp_send_json_error(['message' => __('更新失败', 'aksrt')]);
}
/**
* AJAX: Get report list (admin only).
*/
public function ajax_list(): void {
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
$status = sanitize_key((string) ($_GET['status'] ?? ''));
$limit = max(1, min(100, (int) ($_GET['limit'] ?? 20)));
$offset = max(0, (int) ($_GET['offset'] ?? 0));
$reports = $this->get_reports(['status' => $status, 'limit' => $limit, 'offset' => $offset]);
$counts = $this->get_status_counts();
wp_send_json_success(['reports' => $reports, 'counts' => $counts]);
}
/**
* AJAX: Get status counts (admin only).
*/
public function ajax_counts(): void {
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
wp_send_json_success($this->get_status_counts());
return (bool) $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE reporter_id = %d AND target_type = %s AND target_id = %d AND status = 'pending' LIMIT 1",
$reporter_id,
sanitize_key($target_type),
$target_id
));
}
}
+1 -1
View File
@@ -176,7 +176,7 @@ final class MailNotifications {
public static function report_submitted(int $report_id, array $report): void {
$reporter = get_userdata((int) ($report['reporter_id'] ?? 0));
self::send_admin('report_admin', ['username' => $reporter instanceof \WP_User ? $reporter->display_name : __('用户', 'aksrt'), 'title' => (string) ($report['target_type'] ?? ''), 'url' => admin_url('users.php?page=aksrt_reports')]);
self::send_admin('report_admin', ['username' => $reporter instanceof \WP_User ? $reporter->display_name : __('用户', 'aksrt'), 'title' => (string) ($report['target_type'] ?? ''), 'url' => admin_url('users.php?page=aksrt_user_ban&tab=reports')]);
}
public static function report_status_changed(int $report_id, int $reporter_id, string $status): void {
+12 -2
View File
@@ -355,8 +355,18 @@ class Home {
return [];
}
$state = sanitize_key((string) get_user_meta($user_id, 'aksrt_user_ban_state', true));
if (!in_array($state, ['muted', 'restricted', 'banned'], true)) {
$state = '';
if (class_exists('\AKSRT\User\Restriction')) {
$svc = \AKSRT\User\Restriction::get_instance();
if ($svc->is_banned($user_id)) {
$state = 'banned';
} elseif ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_SUSPEND)) {
$state = 'restricted';
} elseif ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_MUTE)) {
$state = 'muted';
}
}
if ($state === '') {
return [];
}
+33 -13
View File
@@ -75,8 +75,11 @@ class Restriction {
return ['success' => false, 'message' => 'Database error.'];
}
// Update user meta for quick lookup
$this->sync_user_restriction_meta($user_id);
// 封禁立即生效:销毁目标用户全部登录会话
if ($type === self::TYPE_BAN && $user_id > 0) {
\WP_Session_Tokens::get_instance($user_id)->destroy_all();
}
do_action('aksrt_user_restriction_changed', $user_id, $type, 'applied', (string) $data['reason']);
return ['success' => true, 'id' => $wpdb->insert_id];
@@ -92,12 +95,39 @@ class Restriction {
$result = $wpdb->delete($this->table(), ['id' => $restriction_id]);
if ($result) {
$this->sync_user_restriction_meta((int) $restriction['user_id']);
do_action('aksrt_user_restriction_changed', (int) $restriction['user_id'], (string) ($restriction['type'] ?? ''), 'removed', '');
}
return (bool) $result;
}
/**
* 解除用户生效中的限制(可选按类型过滤),用于申诉通过或举报工单"解除限制"。
*
* @param int $user_id 用户 ID
* @param array $types 要解除的类型,默认解除 ban/suspend/mute(保留 warn 警告记录)
* @return int 解除的记录数
*/
public function lift_user_restriction(int $user_id, array $types = [self::TYPE_BAN, self::TYPE_SUSPEND, self::TYPE_MUTE]): int {
global $wpdb;
$types = array_values(array_intersect($types, self::TYPES));
if ($user_id <= 0 || empty($types)) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($types), '%s'));
$deleted = $wpdb->query($wpdb->prepare(
"DELETE FROM {$this->table()} WHERE user_id = %d AND (expires_at IS NULL OR expires_at > %s) AND type IN ({$placeholders})",
array_merge([$user_id, current_time('mysql', true)], $types)
));
if ($deleted > 0) {
do_action('aksrt_user_restriction_changed', $user_id, implode(',', $types), 'lifted', '');
}
return (int) $deleted;
}
/**
* Get a single restriction by ID.
*/
@@ -194,16 +224,6 @@ class Restriction {
return $post_id;
}
/**
* Sync restriction meta for quick lookup.
*/
private function sync_user_restriction_meta(int $user_id): void {
$active = $this->get_user_restrictions($user_id, true);
$types = array_unique(array_column($active, 'type'));
update_user_meta($user_id, '_aksrt_active_restrictions', $types);
update_user_meta($user_id, '_aksrt_is_banned', in_array(self::TYPE_BAN, $types, true) ? 1 : 0);
}
/**
* Get restriction stats for admin overview.
*/