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
+26 -8
View File
@@ -52,6 +52,9 @@
<n-form-item label="限制类型"> <n-form-item label="限制类型">
<n-select v-model:value="addForm.type" :options="restrictionTypes" style="width:200px;" /> <n-select v-model:value="addForm.type" :options="restrictionTypes" style="width:200px;" />
</n-form-item> </n-form-item>
<n-form-item label="限制范围">
<n-select v-model:value="addForm.scope" :options="scopeOptions" style="width:200px;" />
</n-form-item>
<n-form-item label="原因"> <n-form-item label="原因">
<n-input v-model:value="addForm.reason" type="textarea" :rows="2" placeholder="限制原因(必填)" /> <n-input v-model:value="addForm.reason" type="textarea" :rows="2" placeholder="限制原因(必填)" />
</n-form-item> </n-form-item>
@@ -84,15 +87,22 @@ const stats = reactive({ total: 0, active: 0, banned: 0 })
const addForm = reactive({ const addForm = reactive({
user_id: null, user_id: null,
type: 'muted', type: 'mute',
scope: 'global',
reason: '', reason: '',
duration: '7d', duration: '7d',
}) })
const restrictionTypes = [ const restrictionTypes = [
{ label: '禁言', value: 'muted' }, { label: '警告', value: 'warn' },
{ label: '限制', value: 'restricted' }, { label: '禁言', value: 'mute' },
{ label: '封禁', value: 'banned' }, { label: '停权', value: 'suspend' },
{ label: '封禁', value: 'ban' },
]
const scopeOptions = [
{ label: '全局', value: 'global' },
{ label: '仅评论', value: 'comment' },
] ]
const durationOptions = [ const durationOptions = [
@@ -126,15 +136,22 @@ const columns = [
] ]
function getTypeTag(type) { function getTypeTag(type) {
const map = { muted: 'warning', restricted: 'error', banned: 'error' } const map = { warn: 'info', mute: 'warning', suspend: 'warning', ban: 'error' }
return map[type] || 'default' return map[type] || 'default'
} }
function getTypeLabel(type) { function getTypeLabel(type) {
const map = { muted: '禁言', restricted: '限制', banned: '封禁' } const map = { warn: '警告', mute: '禁言', suspend: '停权', ban: '封禁' }
return map[type] || type return map[type] || type
} }
function durationToExpiresAt(duration) {
if (!duration || duration === 'permanent') return ''
const days = parseInt(duration, 10)
if (!Number.isFinite(days) || days <= 0) return ''
return new Date(Date.now() + days * 86400000).toISOString()
}
async function loadStats() { async function loadStats() {
try { try {
const res = await api.request('/users/restrictions/stats') const res = await api.request('/users/restrictions/stats')
@@ -174,12 +191,13 @@ async function addRestriction() {
} }
adding.value = true adding.value = true
try { try {
await api.request(`/users/${addForm.user_id}/restrictions`, { await api.request(`/users/${addForm.user_id}/restrict`, {
method: 'POST', method: 'POST',
data: { data: {
type: addForm.type, type: addForm.type,
scope: addForm.scope,
reason: addForm.reason, reason: addForm.reason,
duration: addForm.duration, expires_at: durationToExpiresAt(addForm.duration),
}, },
}) })
message.success('限制已添加') message.success('限制已添加')
-3
View File
@@ -252,9 +252,6 @@ if (class_exists('\AKSRT\Ai\AI')) {
if (class_exists('\AKSRT\Payment\WeChatNotify')) { if (class_exists('\AKSRT\Payment\WeChatNotify')) {
\AKSRT\Payment\WeChatNotify::get_instance()->init(); \AKSRT\Payment\WeChatNotify::get_instance()->init();
} }
if (class_exists('\AKSRT\Messages\ReportCenter')) {
\AKSRT\Messages\ReportCenter::get_instance()->init();
}
if (class_exists('\AKSRT\Core\CommentActions')) { if (class_exists('\AKSRT\Core\CommentActions')) {
\AKSRT\Core\CommentActions::get_instance()->init(); \AKSRT\Core\CommentActions::get_instance()->init();
} }
+31
View File
@@ -143,6 +143,36 @@ if (!function_exists('aksrt_get_comment_header_html')) :
} }
endif; endif;
if (!function_exists('aksrt_get_comment_report_html')) :
/**
* 评论举报入口 — 登录用户可举报他人评论(前台走 report.js 的 data-aksrt-report 契约)。
*
* @param WP_Comment $comment Comment.
* @return string
*/
function aksrt_get_comment_report_html(WP_Comment $comment): string {
if (!is_user_logged_in() || !class_exists('\AKSRT\Settings\Settings')) {
return '';
}
$author_id = (int) $comment->user_id;
$reporter_id = get_current_user_id();
if ($author_id > 0 && $author_id === $reporter_id) {
return '';
}
$settings = \AKSRT\Settings\Settings::get_user_identity_settings();
if (empty($settings['report']['enabled'])) {
return '';
}
if (function_exists('aksrt_user_can') && !aksrt_user_can('user_report', $reporter_id, $author_id)) {
return '';
}
return '<li><a class="comment-report-link" href="javascript:;" data-aksrt-report=\'{"target_type":"comment","target_id":' . (int) $comment->comment_ID . '}\'><i class="fa fas fa-flag mr10 fa-fw" aria-hidden="true"></i><text>' . esc_html__('举报', 'aksrt') . '</text></a></li>';
}
endif;
if (!function_exists('aksrt_get_comments_action_dropdown_html')) : if (!function_exists('aksrt_get_comments_action_dropdown_html')) :
/** /**
* More-actions menu (legacy: legacy) — pin / edit / approve / delete. * More-actions menu (legacy: legacy) — pin / edit / approve / delete.
@@ -178,6 +208,7 @@ if (!function_exists('aksrt_get_comments_action_dropdown_html')) :
if (!empty($perms['can_delete'])) { if (!empty($perms['can_delete'])) {
$lists .= '<li><a class="comment-trash-link c-red" data-comment-action="delete" data-commentid="' . (int) $comment->comment_ID . '" data-postid="' . (int) $comment->comment_post_ID . '" href="javascript:;"><i class="fa far fa-trash-alt mr10 fa-fw" aria-hidden="true"></i>' . esc_html__('删除', 'aksrt') . '</a></li>'; $lists .= '<li><a class="comment-trash-link c-red" data-comment-action="delete" data-commentid="' . (int) $comment->comment_ID . '" data-postid="' . (int) $comment->comment_post_ID . '" href="javascript:;"><i class="fa far fa-trash-alt mr10 fa-fw" aria-hidden="true"></i>' . esc_html__('删除', 'aksrt') . '</a></li>';
} }
$lists .= aksrt_get_comment_report_html($comment);
if ($lists === '') { if ($lists === '') {
return ''; return '';
+1 -15
View File
@@ -573,21 +573,7 @@ class UsersMenus {
update_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, array_slice($history, -30)); update_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, array_slice($history, -30));
if ($decision === 'approve' && class_exists('\AKSRT\User\Restriction')) { if ($decision === 'approve' && class_exists('\AKSRT\User\Restriction')) {
global $wpdb; \AKSRT\User\Restriction::get_instance()->lift_user_restriction($user_id);
$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);
}
} }
if (class_exists('\AKSRT\Messages\Message')) { if (class_exists('\AKSRT\Messages\Message')) {
-19
View File
@@ -144,11 +144,6 @@ class UserController extends ControllerBase {
]); ]);
// Report ticket list (admin) // 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', [ register_rest_route(self::API_NAMESPACE, '/users/reports', [
'methods' => 'GET', 'methods' => 'GET',
'callback' => [$this, 'get_report_list'], 'callback' => [$this, 'get_report_list'],
@@ -165,20 +160,6 @@ class UserController extends ControllerBase {
'permission_callback' => [$this, 'check_permission'], '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) // Report submit (logged-in users, front-end)
register_rest_route(self::API_NAMESPACE, '/report/submit', [ register_rest_route(self::API_NAMESPACE, '/report/submit', [
'methods' => 'POST', '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 { public function get_report_detail(\WP_REST_Request $request): \WP_REST_Response {
global $wpdb; global $wpdb;
@@ -214,7 +193,8 @@ trait UserControllerReportOperations {
$effect = $this->apply_report_action( $effect = $this->apply_report_action(
sanitize_key((string) $row['target_type']), sanitize_key((string) $row['target_type']),
(int) $row['target_id'], (int) $row['target_id'],
$action_taken $action_taken,
(string) ($row['reason'] ?? '')
); );
$notes = json_decode((string) ($row['notes_json'] ?? '[]'), true); $notes = json_decode((string) ($row['notes_json'] ?? '[]'), true);
@@ -346,27 +326,70 @@ trait UserControllerReportOperations {
$summary['display_name'] = $user->display_name; $summary['display_name'] = $user->display_name;
$summary['title'] = $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['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; 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') { if ($target_id <= 0 || $action_taken === 'none') {
return ''; return '';
} }
if ($target_type === 'user') { if ($target_type === 'user') {
if (in_array($action_taken, ['muted', 'restricted', 'banned'], true)) { if (!class_exists('\AKSRT\User\Restriction')) {
update_user_meta($target_id, 'aksrt_user_ban_state', $action_taken); return '';
return sprintf(__('已同步用户限制状态:%s', 'aksrt'), $action_taken);
} }
$svc = \AKSRT\User\Restriction::get_instance();
if ($action_taken === 'clear') { if ($action_taken === 'clear') {
delete_user_meta($target_id, 'aksrt_user_ban_state'); $removed = $svc->lift_user_restriction($target_id);
return __('已清除用户限制状态', 'aksrt'); 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 ''; return '';
@@ -413,10 +436,10 @@ trait UserControllerReportOperations {
if ($target_type === 'user' || $target_type === '') { if ($target_type === 'user' || $target_type === '') {
$common = array_merge($common, [ $common = array_merge($common, [
['label' => __('发送警告', 'aksrt'), 'value' => 'warned'], ['label' => __('发送警告', 'aksrt'), 'value' => 'warn'],
['label' => __('禁言', 'aksrt'), 'value' => 'muted'], ['label' => __('禁言', 'aksrt'), 'value' => 'mute'],
['label' => __('限制功能', 'aksrt'), 'value' => 'restricted'], ['label' => __('停权', 'aksrt'), 'value' => 'suspend'],
['label' => __('封禁账号', 'aksrt'), 'value' => 'banned'], ['label' => __('封禁账号', 'aksrt'), 'value' => 'ban'],
['label' => __('解除限制', 'aksrt'), 'value' => 'clear'], ['label' => __('解除限制', 'aksrt'), 'value' => 'clear'],
]); ]);
} }
@@ -455,6 +478,11 @@ trait UserControllerReportOperations {
if ($reporter_id <= 0) { if ($reporter_id <= 0) {
return $this->error(__('请先登录', 'aksrt'), 401); 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_type === 'user') {
if ($target_id === $reporter_id) { if ($target_id === $reporter_id) {
return $this->error(__('不能举报自己', 'aksrt'), 400); 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(); $table = $this->table();
if ($table === '') return 0; if ($table === '') return false;
$reports = get_option('aksrt_reports', []);
if (!is_array($reports) || empty($reports)) return 0;
global $wpdb; global $wpdb;
$migrated = 0; 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",
foreach ($reports as $old_id => $report) { $reporter_id,
// 检查是否已迁移(通过 reporter_id + target_type + target_id + created_at 去重) sanitize_key($target_type),
$existing = $wpdb->get_var($wpdb->prepare( $target_id
"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());
} }
} }
+1 -1
View File
@@ -176,7 +176,7 @@ final class MailNotifications {
public static function report_submitted(int $report_id, array $report): void { public static function report_submitted(int $report_id, array $report): void {
$reporter = get_userdata((int) ($report['reporter_id'] ?? 0)); $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 { 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 []; return [];
} }
$state = sanitize_key((string) get_user_meta($user_id, 'aksrt_user_ban_state', true)); $state = '';
if (!in_array($state, ['muted', 'restricted', 'banned'], true)) { 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 []; return [];
} }
+33 -13
View File
@@ -75,8 +75,11 @@ class Restriction {
return ['success' => false, 'message' => 'Database error.']; 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']); do_action('aksrt_user_restriction_changed', $user_id, $type, 'applied', (string) $data['reason']);
return ['success' => true, 'id' => $wpdb->insert_id]; return ['success' => true, 'id' => $wpdb->insert_id];
@@ -92,12 +95,39 @@ class Restriction {
$result = $wpdb->delete($this->table(), ['id' => $restriction_id]); $result = $wpdb->delete($this->table(), ['id' => $restriction_id]);
if ($result) { 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', ''); do_action('aksrt_user_restriction_changed', (int) $restriction['user_id'], (string) ($restriction['type'] ?? ''), 'removed', '');
} }
return (bool) $result; 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. * Get a single restriction by ID.
*/ */
@@ -194,16 +224,6 @@ class Restriction {
return $post_id; 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. * Get restriction stats for admin overview.
*/ */
@@ -0,0 +1,116 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..', '..')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
const userController = read('src/Admin/UserController.php')
const reportOps = read('src/Admin/UserControllerReportOperations.php')
const restrictionOps = read('src/Admin/UserControllerRestrictionOperations.php')
const restriction = read('src/User/Restriction.php')
const reportCenter = read('src/Messages/ReportCenter.php')
const usersMenus = read('src/Admin/Native/UsersMenus.php')
const mailNotifications = read('src/Settings/MailNotifications.php')
const home = read('src/User/Home.php')
const commentItem = read('inc/helpers/comments/item.php')
const usersBan = read('assets/admin/src/pages/users/UsersBan.vue')
const functions = read('functions.php')
const uninstall = read('uninstall.php')
test('report REST surface is canonical: submit + users/reports only', () => {
assert.match(userController, /'\/report\/submit'/)
assert.match(userController, /'\/users\/reports'/)
assert.match(userController, /'\/users\/reports\/\(\?P<id>\\d\+\)'/)
assert.match(userController, /'\/users\/reports\/\(\?P<id>\\d\+\)\/process'/)
for (const dead of ["'/report/list'", "'/report/update-status'", "'/report/counts'"]) {
assert.ok(!userController.includes(dead), `alias route should be removed: ${dead}`)
}
assert.ok(!reportOps.includes('public function update_report_status'), 'dead update_report_status method')
assert.ok(!reportOps.includes('public function get_report_counts'), 'dead get_report_counts method')
})
test('report center has no legacy ajax handlers or option migration', () => {
assert.ok(!reportCenter.includes('ajax_update_status'))
assert.ok(!reportCenter.includes('ajax_list'))
assert.ok(!reportCenter.includes('ajax_counts'))
assert.ok(!reportCenter.includes('migrate_from_option'))
assert.ok(!reportCenter.includes('maybe_migrate'))
assert.ok(!reportCenter.includes("get_option('aksrt_reports'"))
assert.ok(!functions.includes('ReportCenter::get_instance()->init()'), 'ReportCenter init() is gone')
assert.match(reportCenter, /public function has_pending_report/)
assert.match(reportOps, /has_pending_report\(\$reporter_id, \$target_type, \$target_id\)/)
})
test('report processing writes real restrictions, not a display-only meta', () => {
assert.match(reportOps, /->restrict\(\s*\$target_id,\s*\$action_taken,/)
assert.match(reportOps, /lift_user_restriction\(\$target_id\)/)
assert.match(reportOps, /in_array\(\$action_taken, \\AKSRT\\User\\Restriction::TYPES, true\)/)
assert.ok(!reportOps.includes('aksrt_user_ban_state'), 'dual-track ban state meta must not be written or read')
for (const deadAction of ["'value' => 'warned'", "'value' => 'muted'", "'value' => 'restricted'", "'value' => 'banned'"]) {
assert.ok(!reportOps.includes(deadAction), `action value must map to Restriction types: ${deadAction}`)
}
for (const action of ["'value' => 'warn'", "'value' => 'mute'", "'value' => 'suspend'", "'value' => 'ban'", "'value' => 'clear'"]) {
assert.ok(reportOps.includes(action), `missing action option: ${action}`)
}
assert.match(reportOps, /get_user_restriction_state/)
})
test('restriction class supports lifting and kicks sessions on ban', () => {
assert.match(restriction, /public function lift_user_restriction\(int \$user_id/)
assert.match(restriction, /public function lift_user_restriction\(int \$user_id, array \$types = \[self::TYPE_BAN, self::TYPE_SUSPEND, self::TYPE_MUTE\]\): int/)
assert.match(restriction, /WP_Session_Tokens::get_instance\(\$user_id\)->destroy_all\(\)/)
assert.ok(!restriction.includes('sync_user_restriction_meta'), 'write-only meta sync is dead code')
assert.ok(!restriction.includes('_aksrt_is_banned'))
})
test('ban admin REST endpoints stay in sync with the SPA client', () => {
assert.match(userController, /'\/users\/\(\?P<user_id>\\d\+\)\/restrictions'/)
assert.match(userController, /'\/users\/\(\?P<user_id>\\d\+\)\/restrict'/)
assert.match(usersBan, /\/users\/\$\{addForm\.user_id\}\/restrict`/)
assert.ok(!usersBan.includes('/restrictions`, {'), 'SPA must not POST to the unregistered /restrictions endpoint')
assert.match(usersBan, /expires_at: durationToExpiresAt\(addForm\.duration\)/)
for (const t of ["value: 'warn'", "value: 'mute'", "value: 'suspend'", "value: 'ban'"]) {
assert.ok(usersBan.includes(t), `SPA restriction type missing: ${t}`)
}
for (const dead of ["value: 'muted'", "value: 'restricted'", "value: 'banned'"]) {
assert.ok(!usersBan.includes(dead), `invalid SPA restriction type: ${dead}`)
}
})
test('ban appeal approval actually lifts restrictions', () => {
assert.match(usersMenus, /lift_user_restriction\(\$user_id\)/)
assert.ok(!usersMenus.includes("SET status = 'lifted'"), 'restriction table has no status column')
assert.ok(!usersMenus.includes('clear_user_bans'))
assert.ok(!usersMenus.includes('lift_ban'))
})
test('profile governance notice reads from the restriction table', () => {
assert.match(home, /->is_banned\(\$user_id\)/)
assert.match(home, /\\AKSRT\\User\\Restriction::get_instance\(\)/)
assert.ok(!home.includes('aksrt_user_ban_state'))
})
test('admin report notification links to the real page', () => {
assert.match(mailNotifications, /users\.php\?page=aksrt_user_ban&tab=reports/)
assert.ok(!mailNotifications.includes('page=aksrt_reports&'), 'dead admin page link')
})
test('comments expose a report entry for other users when reporting is enabled', () => {
assert.match(commentItem, /data-aksrt-report=\\?'\{"target_type":"comment","target_id":/)
assert.match(commentItem, /function aksrt_get_comment_report_html/)
assert.match(commentItem, /report'\]\['enabled'\]/)
assert.match(commentItem, /\$author_id === \$reporter_id/)
})
test('uninstall cleans up every ban/report meta key written by the theme', () => {
for (const key of ['_aksrt_active_restrictions', '_aksrt_is_banned', 'aksrt_user_ban_state']) {
assert.ok(uninstall.includes(`'${key}'`), `missing uninstall cleanup: ${key}`)
}
})
test('restriction REST operations pass expiry through to the service', () => {
assert.match(restrictionOps, /\$params\['expires_at'\] \?\? ''/)
assert.match(restrictionOps, /Restriction::get_instance\(\)->restrict\(/)
})
+1
View File
@@ -67,6 +67,7 @@ $meta_keys = [
'aksrt_unread_message_count', 'aksrt_unread_message_count',
'_aksrt_active_restrictions', '_aksrt_active_restrictions',
'_aksrt_is_banned', '_aksrt_is_banned',
'aksrt_user_ban_state',
'_aksrt_favorite_posts', '_aksrt_favorite_posts',
'aksrt_wechat_openid', 'aksrt_wechat_openid',
'aksrt_wechat_subscriptions', 'aksrt_wechat_subscriptions',