diff --git a/assets/admin/src/pages/users/UsersBan.vue b/assets/admin/src/pages/users/UsersBan.vue
index 89c3caba..45e729a1 100644
--- a/assets/admin/src/pages/users/UsersBan.vue
+++ b/assets/admin/src/pages/users/UsersBan.vue
@@ -52,6 +52,9 @@
+
+
+
@@ -84,15 +87,22 @@ const stats = reactive({ total: 0, active: 0, banned: 0 })
const addForm = reactive({
user_id: null,
- type: 'muted',
+ type: 'mute',
+ scope: 'global',
reason: '',
duration: '7d',
})
const restrictionTypes = [
- { label: '禁言', value: 'muted' },
- { label: '限制', value: 'restricted' },
- { label: '封禁', value: 'banned' },
+ { label: '警告', value: 'warn' },
+ { label: '禁言', value: 'mute' },
+ { label: '停权', value: 'suspend' },
+ { label: '封禁', value: 'ban' },
+]
+
+const scopeOptions = [
+ { label: '全局', value: 'global' },
+ { label: '仅评论', value: 'comment' },
]
const durationOptions = [
@@ -126,15 +136,22 @@ const columns = [
]
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'
}
function getTypeLabel(type) {
- const map = { muted: '禁言', restricted: '限制', banned: '封禁' }
+ const map = { warn: '警告', mute: '禁言', suspend: '停权', ban: '封禁' }
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() {
try {
const res = await api.request('/users/restrictions/stats')
@@ -174,12 +191,13 @@ async function addRestriction() {
}
adding.value = true
try {
- await api.request(`/users/${addForm.user_id}/restrictions`, {
+ await api.request(`/users/${addForm.user_id}/restrict`, {
method: 'POST',
data: {
type: addForm.type,
+ scope: addForm.scope,
reason: addForm.reason,
- duration: addForm.duration,
+ expires_at: durationToExpiresAt(addForm.duration),
},
})
message.success('限制已添加')
diff --git a/functions.php b/functions.php
index 0a838461..bfd1abbe 100644
--- a/functions.php
+++ b/functions.php
@@ -252,9 +252,6 @@ if (class_exists('\AKSRT\Ai\AI')) {
if (class_exists('\AKSRT\Payment\WeChatNotify')) {
\AKSRT\Payment\WeChatNotify::get_instance()->init();
}
-if (class_exists('\AKSRT\Messages\ReportCenter')) {
- \AKSRT\Messages\ReportCenter::get_instance()->init();
-}
if (class_exists('\AKSRT\Core\CommentActions')) {
\AKSRT\Core\CommentActions::get_instance()->init();
}
diff --git a/inc/helpers/comments/item.php b/inc/helpers/comments/item.php
index 7b150e8a..f9497456 100644
--- a/inc/helpers/comments/item.php
+++ b/inc/helpers/comments/item.php
@@ -143,6 +143,36 @@ if (!function_exists('aksrt_get_comment_header_html')) :
}
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 '
';
+ }
+endif;
+
if (!function_exists('aksrt_get_comments_action_dropdown_html')) :
/**
* 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'])) {
$lists .= '';
}
+ $lists .= aksrt_get_comment_report_html($comment);
if ($lists === '') {
return '';
diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php
index e3e18470..09df64dc 100644
--- a/src/Admin/Native/UsersMenus.php
+++ b/src/Admin/Native/UsersMenus.php
@@ -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')) {
diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php
index 0f55f0c2..8eed1c46 100644
--- a/src/Admin/UserController.php
+++ b/src/Admin/UserController.php
@@ -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',
diff --git a/src/Admin/UserControllerReportOperations.php b/src/Admin/UserControllerReportOperations.php
index 0c024f09..045e94f0 100644
--- a/src/Admin/UserControllerReportOperations.php
+++ b/src/Admin/UserControllerReportOperations.php
@@ -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);
diff --git a/src/Messages/ReportCenter.php b/src/Messages/ReportCenter.php
index 01cf1827..c32db01c 100644
--- a/src/Messages/ReportCenter.php
+++ b/src/Messages/ReportCenter.php
@@ -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
+ ));
}
}
diff --git a/src/Settings/MailNotifications.php b/src/Settings/MailNotifications.php
index 391c073d..68b22318 100644
--- a/src/Settings/MailNotifications.php
+++ b/src/Settings/MailNotifications.php
@@ -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 {
diff --git a/src/User/Home.php b/src/User/Home.php
index b46a6307..56fa7d49 100644
--- a/src/User/Home.php
+++ b/src/User/Home.php
@@ -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 [];
}
diff --git a/src/User/Restriction.php b/src/User/Restriction.php
index aa3c0eda..cf797c00 100644
--- a/src/User/Restriction.php
+++ b/src/User/Restriction.php
@@ -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.
*/
diff --git a/tests/widget-system/report-ban-contract.test.cjs b/tests/widget-system/report-ban-contract.test.cjs
new file mode 100644
index 00000000..2c1022df
--- /dev/null
+++ b/tests/widget-system/report-ban-contract.test.cjs
@@ -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\\d\+\)'/)
+ assert.match(userController, /'\/users\/reports\/\(\?P\\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\\d\+\)\/restrictions'/)
+ assert.match(userController, /'\/users\/\(\?P\\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\(/)
+})
diff --git a/uninstall.php b/uninstall.php
index 5b09cce8..1d35aede 100644
--- a/uninstall.php
+++ b/uninstall.php
@@ -67,6 +67,7 @@ $meta_keys = [
'aksrt_unread_message_count',
'_aksrt_active_restrictions',
'_aksrt_is_banned',
+ 'aksrt_user_ban_state',
'_aksrt_favorite_posts',
'aksrt_wechat_openid',
'aksrt_wechat_subscriptions',