From b45fd161f01425b8c890421a9fab78b47450fe0f Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 31 Oct 2026 00:57:13 +0800 Subject: [PATCH] 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 --- assets/js/report.js | 18 +- src/Admin/Native/UsersMenusBan.php | 3 +- src/Admin/Native/UsersMenusBanDialogs.php | 2 +- src/Admin/UserController.php | 14 + src/Admin/UserControllerReportOperations.php | 32 +- src/Core/DBMigrator.php | 2 +- src/Core/DBMigratorPart2.php | 2 +- src/Messages/ReportCenter.php | 25 +- src/Settings/UserRuntime.php | 32 +- .../widget-system/ban-admin-contract.test.cjs | 17 + tests/widget-system/ban-admin-harness.php | 5 +- .../report-ban-contract.test.cjs | 21 +- .../report-modal-runtime.test.cjs | 42 +- tests/widget-system/report-submit-harness.php | 471 ++++++++++++++++++ .../report-submit-runtime.test.cjs | 182 +++++++ .../ticket-schema-version.test.cjs | 4 +- 16 files changed, 797 insertions(+), 75 deletions(-) create mode 100644 tests/widget-system/report-submit-harness.php create mode 100644 tests/widget-system/report-submit-runtime.test.cjs diff --git a/assets/js/report.js b/assets/js/report.js index e27ab4bb..0005ee89 100644 --- a/assets/js/report.js +++ b/assets/js/report.js @@ -24,7 +24,7 @@ function getReportCategories() { return (Array.isArray(config.reportCategories) ? config.reportCategories : []) - .filter(function (cat) { return cat && cat.name; }); + .filter(function (cat) { return cat && cat.name && cat.name !== '其他原因'; }); } function buildReportModalHtml(data, categories) { @@ -226,9 +226,10 @@ form.querySelector('[name="report_reason"]').focus(); return; } - var category = selected.value === 'other' ? null : categories[Number(selected.value)]; - var reason = category ? String(category.name) : otherInput.value.trim(); - if (!reason) { + var other = selected.value === 'other'; + var category = other ? '其他原因' : String(categories[Number(selected.value)].name); + var explanation = other ? otherInput.value.trim() : ''; + if (other && !explanation) { showError(reasonError, '请填写举报原因'); otherInput.setAttribute('aria-invalid', 'true'); otherInput.focus(); @@ -238,9 +239,12 @@ showError(error, '请等待图片上传完成,或重试、删除上传失败的图片'); return; } + var content = []; + if (explanation) content.push(explanation); var description = descInput.value.trim(); - if (description) reason += '\n详细描述:\n' + description; - if (images.length) reason += '\n图片举证:\n' + images.map(function (image) { return image.url; }).join('\n'); + if (description) content.push(description); + if (images.length) content.push('图片举证:\n' + images.map(function (image) { return image.url; }).join('\n')); + var reason = content.join('\n\n'); submitting = true; syncState(); var toastId = form.id + '-submit-' + (++submitAttempts); @@ -249,7 +253,7 @@ window.fetch(API_ROOT + '/report/submit', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE }, - body: JSON.stringify({ target_type: data.target_type, target_id: data.target_id, category: category ? category.slug : '', reason: reason }), + body: JSON.stringify({ target_type: data.target_type, target_id: data.target_id, category: category, reason: reason }), }).then(function (response) { return response.json(); }).then(function (result) { if (!result || !result.success) throw new Error((result && result.message) || '举报提交失败,请重试'); // Submitted evidence belongs to the report even if its dialog has already closed. diff --git a/src/Admin/Native/UsersMenusBan.php b/src/Admin/Native/UsersMenusBan.php index 112920be..5dfbf45d 100644 --- a/src/Admin/Native/UsersMenusBan.php +++ b/src/Admin/Native/UsersMenusBan.php @@ -123,10 +123,11 @@ trait UsersMenusBan { echo ''; if ($report) { echo '' . $this->ban_target_link((string) ($payload['target_type'] ?? ''), (int) ($payload['target_id'] ?? 0)) . ''; + echo '
' . esc_html((string) ($payload['category'] ?? '')) . '
'; } else { echo '' . esc_html__('申请解除禁封', 'aksrt') . ''; } - echo '
' . esc_html((string) ($payload['reason'] ?? '')) . '
'; + if ((string) ($payload['reason'] ?? '') !== '') echo '
' . esc_html((string) $payload['reason']) . '
'; $this->render_ban_detail_template($row, $filters, $page); echo '' . $this->ban_status_badge($row) . ''; echo '' . esc_html($this->ban_time((string) $row['time'])) . ''; diff --git a/src/Admin/Native/UsersMenusBanDialogs.php b/src/Admin/Native/UsersMenusBanDialogs.php index d88e644c..ca9780c0 100644 --- a/src/Admin/Native/UsersMenusBanDialogs.php +++ b/src/Admin/Native/UsersMenusBanDialogs.php @@ -93,7 +93,7 @@ trait UsersMenusBanDialogs { $type = (string) ($payload['target_type'] ?? ''); $id = (int) ($payload['target_id'] ?? 0); echo '

' . esc_html__('举报对象', 'aksrt') . '

' . $this->ban_target_link($type, $id) . '
'; - if (!empty($payload['category'])) $this->render_ban_text_section(__('举报分类', 'aksrt'), (string) $payload['category']); + if ((string) ($payload['category'] ?? '') !== '') $this->render_ban_text_section(__('举报分类', 'aksrt'), (string) $payload['category']); $this->render_ban_text_section(__('举报内容与举证', 'aksrt'), (string) ($payload['reason'] ?? '')); if ($type === 'user') { $this->render_ban_restriction_section($id); diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php index c544f4da..620b735a 100644 --- a/src/Admin/UserController.php +++ b/src/Admin/UserController.php @@ -171,6 +171,20 @@ class UserController extends ControllerBase { 'methods' => 'POST', 'callback' => [$this, 'submit_report'], 'permission_callback' => function () { return is_user_logged_in(); }, + 'args' => [ + 'target_type' => ['type' => 'string', 'required' => true, 'enum' => ['post', 'comment', 'user']], + 'target_id' => ['type' => 'integer', 'required' => true, 'minimum' => 1], + 'category' => [ + 'type' => 'string', 'required' => true, + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'reason' => [ + 'type' => 'string', 'default' => '', + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'sanitize_textarea_field', + ], + ], ]); // Follow toggle (logged-in users) diff --git a/src/Admin/UserControllerReportOperations.php b/src/Admin/UserControllerReportOperations.php index d4528e20..3b266cef 100644 --- a/src/Admin/UserControllerReportOperations.php +++ b/src/Admin/UserControllerReportOperations.php @@ -46,7 +46,7 @@ trait UserControllerReportOperations { $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_key((string) $request->get_param('category')); + $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')); @@ -95,7 +95,7 @@ trait UserControllerReportOperations { $counts = \AKSRT\Messages\ReportCenter::get_instance()->get_status_counts(); $categories = []; foreach ((array) $rows as $row) { - $cat = sanitize_key((string) ($row['category'] ?? '')); + $cat = sanitize_text_field((string) ($row['category'] ?? '')); if ($cat !== '') { $categories[$cat] = $cat; } @@ -190,11 +190,14 @@ trait UserControllerReportOperations { 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, - (string) ($row['reason'] ?? '') + $action_reason ); $notes = json_decode((string) ($row['notes_json'] ?? '[]'), true); @@ -260,7 +263,7 @@ trait UserControllerReportOperations { 'target_type' => sanitize_key((string) ($row['target_type'] ?? '')), 'target_id' => (int) ($row['target_id'] ?? 0), 'reason' => (string) ($row['reason'] ?? ''), - 'category' => sanitize_key((string) ($row['category'] ?? '')), + 'category' => (string) ($row['category'] ?? ''), 'status' => sanitize_key((string) ($row['status'] ?? 'pending')), 'created_at' => (string) ($row['created_at'] ?? ''), 'updated_at' => (string) ($row['updated_at'] ?? ''), @@ -408,16 +411,25 @@ trait UserControllerReportOperations { return $this->error('举报系统未启用', 500); } - $params = $this->get_json_params($request); - $target_type = sanitize_key((string) ($params['target_type'] ?? '')); - $target_id = (int) ($params['target_id'] ?? 0); - $reason = sanitize_textarea_field((string) ($params['reason'] ?? '')); - $category = sanitize_key((string) ($params['category'] ?? '')); + $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 || $reason === '') { + 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('无效的举报类型'); diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php index 61f5a49b..abb7f291 100644 --- a/src/Core/DBMigrator.php +++ b/src/Core/DBMigrator.php @@ -26,7 +26,7 @@ class DBMigrator { // 沟通记录从工单上的 JSON 日志升级为独立的 ticket_reply 行(支持引用回复)。 // 3.1.0: aksrt_ticket 新增 urge_count / last_urge_time(催单次数与最近催单时间), // 催单冷却改由 last_urge_time 判定,不再依赖 transient;催单同时写入 system 类型的 ticket_reply。 - const SCHEMA_VERSION = '3.1.0'; + const SCHEMA_VERSION = '3.2.0'; const OPT_KEY = 'aksrt_db_schema_version'; diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php index 780a1c2c..c733d235 100644 --- a/src/Core/DBMigratorPart2.php +++ b/src/Core/DBMigratorPart2.php @@ -269,7 +269,7 @@ trait DBMigratorPart2 { target_type VARCHAR(20) NOT NULL, target_id BIGINT UNSIGNED NOT NULL, reason TEXT NOT NULL, - category VARCHAR(40) NOT NULL DEFAULT '', + category TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, diff --git a/src/Messages/ReportCenter.php b/src/Messages/ReportCenter.php index 634a2ce9..aae57680 100644 --- a/src/Messages/ReportCenter.php +++ b/src/Messages/ReportCenter.php @@ -26,33 +26,37 @@ class ReportCenter { * @param int $reporter_id 举报人 ID * @param string $target_type 举报目标类型 (post/comment/user) * @param int $target_id 目标 ID - * @param string $reason 举报原因 - * @param string $category 举报分类 + * @param string $reason 补充说明与举证 + * @param string $category 举报分类名称 * @return int 举报 ID(0 表示失败) */ - public function submit(int $reporter_id, string $target_type, int $target_id, string $reason, string $category = ''): int { + public function submit(int $reporter_id, string $target_type, int $target_id, string $reason, string $category): int { if ($reporter_id <= 0 || $target_id <= 0) return 0; $valid_types = ['post', 'comment', 'user']; if (!in_array($target_type, $valid_types, true)) return 0; + $category = sanitize_text_field($category); + $reason = sanitize_textarea_field($reason); + if ($category === '') return 0; $table = $this->table(); if ($table === '') return 0; global $wpdb; - $wpdb->insert($table, [ + $inserted = $wpdb->insert($table, [ 'reporter_id' => $reporter_id, 'target_type' => sanitize_key($target_type), 'target_id' => $target_id, - 'reason' => sanitize_textarea_field($reason), - 'category' => sanitize_key($category), + 'reason' => $reason, + 'category' => $category, 'status' => 'pending', 'created_at' => current_time('mysql'), ], ['%d', '%s', '%d', '%s', '%s', '%s', '%s']); + if ($inserted === false) return 0; $id = $wpdb->insert_id ? (int) $wpdb->insert_id : 0; if ($id > 0) { - $this->notify_admins_new_report($reporter_id, $target_type, $target_id, sanitize_textarea_field($reason)); + $this->notify_admins_new_report($reporter_id, $target_type, $target_id, $category, $reason); do_action('aksrt_report_submitted', $id, [ 'reporter_id' => $reporter_id, 'target_type' => $target_type, @@ -67,17 +71,18 @@ class ReportCenter { /** * 举报提交 → 管理员站内信(与禁封申诉同模式:直达工单列表)。 */ - private function notify_admins_new_report(int $reporter_id, string $target_type, int $target_id, string $reason): void { + private function notify_admins_new_report(int $reporter_id, string $target_type, int $target_id, string $category, string $reason): void { if (!class_exists('\AKSRT\Messages\Message') || !class_exists('WP_User_Query')) return; $type_labels = ['post' => __('文章', 'aksrt'), 'comment' => __('评论', 'aksrt'), 'user' => __('用户', 'aksrt')]; $reporter = get_userdata($reporter_id); + $summary = $category . ($reason !== '' ? ':' . $reason : ''); $content = sprintf( - /* translators: 1: reporter name 2: target type 3: target id 4: reason */ + /* translators: 1: reporter name 2: target type 3: target id 4: report summary */ __('用户 %1$s 举报了%2$s #%3$d:%4$s', 'aksrt'), $reporter instanceof \WP_User ? $reporter->display_name : __('匿名用户', 'aksrt'), $type_labels[$target_type] ?? $target_type, $target_id, - mb_substr($reason, 0, 80) + mb_substr($summary, 0, 80) ); $admins = get_users(['role' => 'administrator', 'fields' => 'ID', 'number' => 20]); foreach ($admins as $admin_id) { diff --git a/src/Settings/UserRuntime.php b/src/Settings/UserRuntime.php index b4e79c76..f99de90b 100644 --- a/src/Settings/UserRuntime.php +++ b/src/Settings/UserRuntime.php @@ -175,34 +175,12 @@ class UserRuntime { public static function ban_settings(): array { $settings = self::read_report_section(); - // 预置原因:group 数组 [{t:...}](对齐参考主题)或旧版每行一条文本。 - // slug 不能用 sanitize_key(标签) 派生:中文标签会被清洗成空串, - // 导致前端按 slug 非空过滤后整列表被丢弃。改用标签内容的稳定短哈希 - // (跨请求一致、可通过服务端 sanitize_key、ASCII 标签同样稳定)。 - $reasons_raw = $settings['ban_preset_reason'] ?? null; + $reasons_raw = $settings['ban_preset_reason'] ?? []; $reasons = []; - $reason_slug = static function (string $text): string { - $key = sanitize_key($text); - return $key !== '' ? $key : substr(md5($text), 0, 12); - }; - if (is_array($reasons_raw)) { - foreach ($reasons_raw as $row) { - $text = trim((string) ($row['t'] ?? '')); - if ($text !== '') { - $reasons[] = ['slug' => $reason_slug($text), 'name' => sanitize_text_field($text)]; - } - } - } else { - foreach (preg_split('/\r\n|\r|\n/', (string) $reasons_raw) as $line) { - $line = trim((string) $line); - if ($line === '') { - continue; - } - $parts = explode('|', $line, 2); - $reasons[] = [ - 'slug' => $reason_slug((string) ($parts[1] ?? $parts[0])), - 'name' => sanitize_text_field($parts[1] ?? $parts[0]), - ]; + foreach (is_array($reasons_raw) ? $reasons_raw : [] as $row) { + $text = is_array($row) ? sanitize_text_field((string) ($row['t'] ?? '')) : ''; + if ($text !== '') { + $reasons[] = ['name' => $text]; } } diff --git a/tests/widget-system/ban-admin-contract.test.cjs b/tests/widget-system/ban-admin-contract.test.cjs index 6eec8849..0faed3e3 100644 --- a/tests/widget-system/ban-admin-contract.test.cjs +++ b/tests/widget-system/ban-admin-contract.test.cjs @@ -82,6 +82,23 @@ test('PHP templates escape all appeal data and report evidence while preserving assert.doesNotMatch(html, /