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:
@@ -52,6 +52,9 @@
|
||||
<n-form-item label="限制类型">
|
||||
<n-select v-model:value="addForm.type" :options="restrictionTypes" style="width:200px;" />
|
||||
</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-input v-model:value="addForm.reason" type="textarea" :rows="2" placeholder="限制原因(必填)" />
|
||||
</n-form-item>
|
||||
@@ -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('限制已添加')
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 '<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')) :
|
||||
/**
|
||||
* 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 .= '<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 === '') {
|
||||
return '';
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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\(/)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user