Files
wordpress-theme-aksrt/src/Admin/UserControllerReportOperations.php
T
kate522andClaude Fable 5 b45fd161f0 fix(reports): separate readable categories from report content
Store readable category snapshots separately from details and evidence, validate report categories, and preserve context in admin displays and moderation actions. Widen report categories to TEXT and bump the schema to 3.2.0 without legacy data conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 00:57:13 +08:00

476 lines
19 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin;
defined('ABSPATH') || exit;
trait UserControllerReportOperations {
public function get_report_list(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Messages\ReportCenter')) {
return $this->success([
'data' => [
'items' => [],
'reports' => [],
'counts' => ['pending' => 0, 'reviewing' => 0, 'resolved' => 0, 'dismissed' => 0, 'total' => 0],
'total' => 0,
'page' => 1,
'options' => [
'statuses' => $this->get_report_status_options(),
],
],
]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('report');
if (!$table) {
return $this->success([
'data' => [
'items' => [],
'reports' => [],
'counts' => ['pending' => 0, 'reviewing' => 0, 'resolved' => 0, 'dismissed' => 0, 'total' => 0],
'total' => 0,
'page' => 1,
'options' => [
'statuses' => $this->get_report_status_options(),
],
],
]);
}
$page = max(1, (int) ($request->get_param('page') ?: 1));
$per_page = max(1, min(100, (int) ($request->get_param('per_page') ?: $request->get_param('limit') ?: 20)));
$status = sanitize_key((string) $request->get_param('status'));
$category = sanitize_text_field((string) $request->get_param('category'));
$target_type = sanitize_key((string) $request->get_param('target_type'));
$keyword = trim(sanitize_text_field((string) $request->get_param('keyword')));
$date_from = sanitize_text_field((string) $request->get_param('date_from'));
$date_to = sanitize_text_field((string) $request->get_param('date_to'));
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
if ($category !== '') {
$where[] = 'category = %s';
$params[] = $category;
}
if ($target_type !== '') {
$where[] = 'target_type = %s';
$params[] = $target_type;
}
if ($date_from !== '') {
$where[] = 'created_at >= %s';
$params[] = $date_from . ' 00:00:00';
}
if ($date_to !== '') {
$where[] = 'created_at <= %s';
$params[] = $date_to . ' 23:59:59';
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT 500";
$rows = $params ? $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A);
$items = [];
foreach ((array) $rows as $row) {
$item = $this->build_report_admin_item($row);
if ($keyword !== '' && !$this->report_matches_keyword($item, $keyword)) {
continue;
}
$items[] = $item;
}
$total = count($items);
$offset = ($page - 1) * $per_page;
$paged = array_slice($items, $offset, $per_page);
$counts = \AKSRT\Messages\ReportCenter::get_instance()->get_status_counts();
$categories = [];
foreach ((array) $rows as $row) {
$cat = sanitize_text_field((string) ($row['category'] ?? ''));
if ($cat !== '') {
$categories[$cat] = $cat;
}
}
return $this->success([
'data' => [
'items' => array_values($paged),
'reports' => array_values($paged),
'total' => $total,
'page' => $page,
'per_page' => $per_page,
'counts' => $counts,
'options' => [
'statuses' => $this->get_report_status_options(),
'actions' => $this->get_report_action_options(),
'targets' => $this->get_report_target_type_options(),
'categories' => array_map(static function ($item) {
return ['label' => $item, 'value' => $item];
}, array_values($categories)),
],
],
]);
}
public function get_report_detail(\WP_REST_Request $request): \WP_REST_Response {
global $wpdb;
$report_id = (int) $request->get_param('id');
$table = \AKSRT\Core\DBMigrator::table('report');
if ($report_id <= 0 || !$table) {
return $this->error(__('举报工单不存在', 'aksrt'), 404);
}
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $report_id), ARRAY_A);
if (!$row) {
return $this->error(__('举报工单不存在', 'aksrt'), 404);
}
$related = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE target_type = %s AND target_id = %d",
sanitize_key((string) $row['target_type']),
(int) $row['target_id']
));
return $this->success([
'data' => [
'report' => $this->build_report_admin_item($row),
'related' => ['same_target_count' => $related],
'options' => [
'statuses' => $this->get_report_status_options(),
'actions' => $this->get_report_action_options(sanitize_key((string) $row['target_type'])),
],
],
]);
}
public function process_report(\WP_REST_Request $request): \WP_REST_Response {
$report_id = (int) $request->get_param('id');
if ($report_id <= 0) {
return $this->error(__('参数无效', 'aksrt'));
}
return $this->handle_report_process($report_id, $this->get_json_params($request));
}
private function handle_report_process(int $report_id, array $payload): \WP_REST_Response {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('report');
if (!$table) {
return $this->error(__('举报系统未启用', 'aksrt'));
}
$status = sanitize_key((string) ($payload['status'] ?? ''));
$action_taken = sanitize_key((string) ($payload['action_taken'] ?? 'none'));
$note = sanitize_textarea_field((string) ($payload['note'] ?? ''));
$valid_statuses = array_column($this->get_report_status_options(), 'value');
$valid_actions = array_column($this->get_report_action_options(), 'value');
if ($status === '' || !in_array($status, $valid_statuses, true)) {
return $this->error(__('处理状态无效', 'aksrt'));
}
if (!in_array($action_taken, $valid_actions, true)) {
$action_taken = 'none';
}
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $report_id), ARRAY_A);
if (!$row) {
return $this->error(__('举报工单不存在', 'aksrt'), 404);
}
$action_reason = (string) ($row['category'] ?? '');
$details = (string) ($row['reason'] ?? '');
if ($details !== '') $action_reason .= "\n\n" . $details;
$effect = $this->apply_report_action(
sanitize_key((string) $row['target_type']),
(int) $row['target_id'],
$action_taken,
$action_reason
);
$notes = json_decode((string) ($row['notes_json'] ?? '[]'), true);
if (!is_array($notes)) {
$notes = [];
}
$notes[] = [
'note' => $note,
'status' => $status,
'action_taken' => $action_taken,
'operator' => get_current_user_id(),
'timestamp' => current_time('mysql'),
'effect' => $effect,
];
$updated = $wpdb->update(
$table,
[
'status' => $status,
'updated_at' => current_time('mysql'),
'notes_json' => wp_json_encode($notes),
],
['id' => $report_id],
['%s', '%s', '%s'],
['%d']
);
if ($updated === false) {
return $this->error(__('处理失败', 'aksrt'));
}
return $this->success([
'message' => __('举报工单已处理', 'aksrt'),
]);
}
private function build_report_admin_item(array $row): array {
$reporter = get_userdata((int) ($row['reporter_id'] ?? 0));
$notes = json_decode((string) ($row['notes_json'] ?? '[]'), true);
if (!is_array($notes)) {
$notes = [];
}
$normalized_notes = [];
foreach ($notes as $entry) {
$operator_id = (int) ($entry['operator'] ?? 0);
$operator_user = $operator_id > 0 ? get_userdata($operator_id) : null;
$normalized_notes[] = [
'note' => (string) ($entry['note'] ?? ''),
'status' => sanitize_key((string) ($entry['status'] ?? '')),
'action_taken' => sanitize_key((string) ($entry['action_taken'] ?? 'none')),
'operator' => $operator_id,
'operator_name' => $operator_user ? $operator_user->display_name : '',
'timestamp' => (string) ($entry['timestamp'] ?? ''),
'effect' => (string) ($entry['effect'] ?? ''),
];
}
$last_note = !empty($normalized_notes) ? $normalized_notes[count($normalized_notes) - 1] : null;
return [
'id' => (int) ($row['id'] ?? 0),
'target_type' => sanitize_key((string) ($row['target_type'] ?? '')),
'target_id' => (int) ($row['target_id'] ?? 0),
'reason' => (string) ($row['reason'] ?? ''),
'category' => (string) ($row['category'] ?? ''),
'status' => sanitize_key((string) ($row['status'] ?? 'pending')),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
'reporter' => $this->build_admin_user_summary($reporter ? (int) $reporter->ID : 0, $reporter),
'target' => $this->build_report_target_summary(
sanitize_key((string) ($row['target_type'] ?? '')),
(int) ($row['target_id'] ?? 0)
),
'notes' => $normalized_notes,
'note_count' => count($normalized_notes),
'last_note' => $last_note,
];
}
private function report_matches_keyword(array $item, string $keyword): bool {
$haystack = implode(' ', array_filter([
(string) ($item['reason'] ?? ''),
(string) ($item['category'] ?? ''),
(string) ($item['reporter']['display_name'] ?? ''),
(string) ($item['reporter']['email'] ?? ''),
(string) ($item['target']['title'] ?? ''),
(string) ($item['target']['display_name'] ?? ''),
(string) ($item['target']['excerpt'] ?? ''),
(string) ($item['target_id'] ?? ''),
]));
return mb_stripos($haystack, $keyword) !== false;
}
private function build_report_target_summary(string $target_type, int $target_id): array {
$summary = [
'type' => $target_type,
'id' => $target_id,
'title' => '',
'excerpt' => '',
'display_name' => '',
'status' => '',
'url' => '',
'ban_state' => '',
];
if ($target_type === 'post') {
$post = get_post($target_id);
if ($post instanceof \WP_Post) {
$summary['title'] = get_the_title($post);
$summary['status'] = (string) $post->post_status;
$summary['url'] = get_permalink($post);
}
} elseif ($target_type === 'comment') {
$comment = get_comment($target_id);
if ($comment instanceof \WP_Comment) {
$summary['display_name'] = (string) $comment->comment_author;
$summary['excerpt'] = wp_trim_words(wp_strip_all_tags((string) $comment->comment_content), 24);
$summary['status'] = $comment->comment_approved === '1' ? 'approved' : (string) $comment->comment_approved;
$summary['url'] = get_comment_link($comment);
if (!empty($comment->comment_post_ID)) {
$summary['title'] = get_the_title((int) $comment->comment_post_ID);
}
}
} elseif ($target_type === 'user') {
$user = get_userdata($target_id);
if ($user instanceof \WP_User) {
$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'] = $this->get_user_restriction_state($target_id);
}
}
return $summary;
}
/**
* 用户当前限制状态摘要(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 (!class_exists('\AKSRT\Messages\ReportCenter')) {
return '';
}
return \AKSRT\Messages\ReportCenter::get_instance()->apply_action($target_type, $target_id, $action_taken, $reason);
}
private function get_report_status_options(): array {
return [
['label' => __('待处理', 'aksrt'), 'value' => 'pending'],
['label' => __('处理中', 'aksrt'), 'value' => 'reviewing'],
['label' => __('已解决', 'aksrt'), 'value' => 'resolved'],
['label' => __('已驳回', 'aksrt'), 'value' => 'dismissed'],
];
}
private function get_report_target_type_options(): array {
return [
['label' => __('文章', 'aksrt'), 'value' => 'post'],
['label' => __('评论', 'aksrt'), 'value' => 'comment'],
['label' => __('用户', 'aksrt'), 'value' => 'user'],
];
}
private function get_report_action_options(string $target_type = ''): array {
$common = [
['label' => __('仅更新状态', 'aksrt'), 'value' => 'none'],
['label' => __('升级人工处理', 'aksrt'), 'value' => 'escalated'],
];
if ($target_type === 'user' || $target_type === '') {
$common = array_merge($common, [
['label' => __('发送警告', 'aksrt'), 'value' => 'warn'],
['label' => __('禁言', 'aksrt'), 'value' => 'mute'],
['label' => __('停权', 'aksrt'), 'value' => 'suspend'],
['label' => __('封禁账号', 'aksrt'), 'value' => 'ban'],
['label' => __('解除限制', 'aksrt'), 'value' => 'clear'],
]);
}
if ($target_type === 'post' || $target_type === 'comment' || $target_type === '') {
$common[] = ['label' => __('转为待审内容', 'aksrt'), 'value' => 'content_removed'];
}
return $common;
}
/**
* POST /report/submit — 前台举报提交(登录用户)
*/
public function submit_report(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Messages\ReportCenter')) {
return $this->error('举报系统未启用', 500);
}
$target_type = sanitize_key((string) $request->get_param('target_type'));
$target_id = (int) $request->get_param('target_id');
$reason = sanitize_textarea_field((string) $request->get_param('reason'));
$category = sanitize_text_field((string) $request->get_param('category'));
if ($target_type === '' || $target_id <= 0 || $category === '') {
return $this->error('请填写完整的举报信息');
}
$other_category = '其他原因';
$categories = array_column(\AKSRT\Settings\UserRuntime::ban_settings()['reasons'], 'name');
$categories[] = $other_category;
if (!in_array($category, $categories, true)) {
return $this->error(__('举报原因无效或已更新,请刷新页面后重新选择', 'aksrt'));
}
if ($category === $other_category && $reason === '') {
return $this->error(__('请填写举报原因的具体说明', 'aksrt'));
}
$valid_types = ['post', 'comment', 'user'];
if (!in_array($target_type, $valid_types, true)) {
return $this->error('无效的举报类型');
}
$reporter_id = get_current_user_id();
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);
}
if (!function_exists('aksrt_user_can') || !aksrt_user_can('user_report', $reporter_id, $target_id)) {
return $this->error(__('当前账号没有举报用户的权限', 'aksrt'), 403);
}
}
$report_id = \AKSRT\Messages\ReportCenter::get_instance()->submit(
$reporter_id,
$target_type,
$target_id,
$reason,
$category
);
if ($report_id > 0) {
// 举证图片以 URL 拼入举报内容,提交成功即认领。
\AKSRT\Attachment\PendingUploads::claim_text($reason, $reporter_id);
return $this->success([
'message' => __('举报已提交,我们会尽快处理', 'aksrt'),
'data' => ['report_id' => $report_id],
]);
}
return $this->error(__('举报提交失败', 'aksrt'));
}
}