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>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-31 00:57:13 +08:00
1 parent b2811cc248
commit b45fd161f0
16 files changed
+797 -75

No files matched your search

+11 -7
View File
@@ -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.
+2 -1
View File
@@ -123,10 +123,11 @@ trait UsersMenusBan {
echo '</td><td class="column-summary">';
if ($report) {
echo '<strong>' . $this->ban_target_link((string) ($payload['target_type'] ?? ''), (int) ($payload['target_id'] ?? 0)) . '</strong>';
echo '<div class="aksrt-ban-summary">' . esc_html((string) ($payload['category'] ?? '')) . '</div>';
} else {
echo '<strong>' . esc_html__('申请解除禁封', 'aksrt') . '</strong>';
}
echo '<div class="aksrt-ban-summary">' . esc_html((string) ($payload['reason'] ?? '')) . '</div>';
if ((string) ($payload['reason'] ?? '') !== '') echo '<div class="aksrt-ban-summary">' . esc_html((string) $payload['reason']) . '</div>';
$this->render_ban_detail_template($row, $filters, $page);
echo '</td><td class="column-status">' . $this->ban_status_badge($row) . '</td>';
echo '<td class="column-time">' . esc_html($this->ban_time((string) $row['time'])) . '</td>';
+1 -1
View File
@@ -93,7 +93,7 @@ trait UsersMenusBanDialogs {
$type = (string) ($payload['target_type'] ?? '');
$id = (int) ($payload['target_id'] ?? 0);
echo '<section class="aksrt-ban-section"><h3>' . esc_html__('举报对象', 'aksrt') . '</h3><div class="aksrt-ban-target">' . $this->ban_target_link($type, $id) . '</div></section>';
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);
+14
View File
@@ -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)
+22 -10
View File
@@ -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('无效的举报类型');
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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,
+15 -10
View File
@@ -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) {
+5 -27
View File
@@ -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];
}
}
@@ -82,6 +82,23 @@ test('PHP templates escape all appeal data and report evidence while preserving
assert.doesNotMatch(html, /<script>alert\(/i);
});
test('report details display readable categories separately from optional content and evidence', () => {
const { html } = phpFixture();
const detail = html.match(/<template id="aksrt-ban-detail-report-1">([\s\S]*?)<\/template>/)?.[1] || '';
const category = detail.match(/<h3>举报分类<\/h3><div class="aksrt-ban-copy">([\s\S]*?)<\/div>/)?.[1];
const content = detail.match(/<h3>举报内容与举证<\/h3><div class="aksrt-ban-copy">([\s\S]*?)<\/div>/)?.[1] || '';
assert.equal(category, '版权投诉');
assert.match(content, /Evidence/);
assert.match(content, /https:\/\/evidence\.example\.test\/proof\.jpg/);
assert.doesNotMatch(content, /版权投诉/);
assert.match(html, /class="aksrt-ban-summary">版权投诉<\/div>/);
const categoryOnly = html.match(/<template id="aksrt-ban-detail-report-2">([\s\S]*?)<\/template>/)?.[1] || '';
assert.match(categoryOnly, /<h3>举报分类<\/h3><div class="aksrt-ban-copy">人身攻击<\/div>/);
assert.match(categoryOnly, /<h3>举报内容与举证<\/h3><div class="aksrt-ban-copy">未填写<\/div>/);
assert.match(html, /class="aksrt-ban-summary">人身攻击<\/div>/);
});
test('dedicated ban assets enqueue only on the matching native page', () => {
const { assets } = phpFixture();
+4 -1
View File
@@ -223,7 +223,7 @@ namespace {
'target_type' => 'post',
'target_id' => 44,
'reason' => $reason !== '' ? $reason : 'report reason ' . $id,
'category' => 'other',
'category' => '垃圾信息',
'status' => $status,
'created_at' => $time,
'updated_at' => $time,
@@ -240,6 +240,9 @@ namespace {
$time = $id === 1 ? '2026-10-30 12:00:00' : ($id === 2 ? '2026-10-29 11:00:00' : $time);
$GLOBALS['aksrt_test_reports'][$id] = fixture_report($id, $id === 1 ? 7 : 20 + $id, $status, $time, $reason);
}
$GLOBALS['aksrt_test_reports'][1]['category'] = '版权投诉';
$GLOBALS['aksrt_test_reports'][2]['category'] = '人身攻击';
$GLOBALS['aksrt_test_reports'][2]['reason'] = '';
$GLOBALS['aksrt_test_reports'][9001] = fixture_report(9001, 7, 'pending', '2026-09-01 09:00:00', 'needle action report');
$GLOBALS['aksrt_test_reports'][9002] = fixture_report(9002, 7, 'pending', '2026-09-02 09:00:00', 'needle stale report');
@@ -54,6 +54,14 @@ test('report REST surface is canonical: submit + users/reports only', () => {
assert.ok(!reportOps.includes('public function get_report_counts'), 'dead get_report_counts method')
})
test('report submit validates required category text and optional content before sanitizing', () => {
const start = userController.indexOf("'/report/submit'")
const route = userController.slice(start, userController.indexOf(']);', start) + 3)
assert.match(route, /'category'\s*=>\s*\[\s*'type' => 'string', 'required' => true/)
assert.match(route, /'reason'\s*=>\s*\[\s*'type' => 'string', 'default' => ''/)
assert.equal((route.match(/'validate_callback' => 'rest_validate_request_arg'/g) || []).length, 2)
})
test('report center has no legacy ajax handlers or option migration', () => {
assert.ok(!reportCenter.includes('ajax_update_status'))
assert.ok(!reportCenter.includes('ajax_list'))
@@ -124,7 +132,6 @@ test('ban/report settings page mirrors the reference theme schema', () => {
assert.match(schemaJs, /添加原因/)
assert.match(schemaJs, /添加申诉资料/)
// 运行时读取兼容 group 数组 [{t}] 并保留旧格式回退
const runtime = read('src/Settings/UserRuntime.php')
assert.match(runtime, /ban_settings\(\): array/)
assert.match(runtime, /report_settings\(\): array/)
@@ -132,10 +139,8 @@ test('ban/report settings page mirrors the reference theme schema', () => {
// 缺键回退 schema 默认值(参考主题 _pz 语义):运行时经 read_domain 读取,
// 裸 get_all() 会让未保存过分区的站点拿不到 ban_preset_reason 默认值。
assert.match(runtime, /read_domain\('users', 'report'\)/)
// 预置原因 slug 不得由 sanitize_key(标签) 直接派生:中文标签会被清洗成
// 空串,前端按 slug 非空过滤后整张原因列表会被丢弃。ASCII 键优先,
// 否则用标签内容的稳定 md5 短哈希。
assert.match(runtime, /md5\(\$text\)/)
assert.match(runtime, /\$reasons\[\] = \['name' => \$text\]/)
assert.doesNotMatch(runtime, /reason_slug|md5\(\$text\)/)
// 死链清理:user_report_categories 从不是真实存储键,解析器必须删除。
assert.ok(!runtime.includes('parse_report_categories'), 'dead parse_report_categories must stay removed')
assert.ok(!runtime.includes('user_report_categories'), 'dead user_report_categories key must stay removed')
@@ -404,7 +409,11 @@ test('report modal uses accessible reasons, automatic target association and opt
}
assert.doesNotMatch(reportJs, /report_url|违规链接|data-aksrt-report-reason=|bindAutoHeight/)
for (const source of [reportJs, commentItem, authorHero]) assert.doesNotMatch(source, /target_avatar/)
assert.match(reportJs, /description\) reason \+= '\\n详细描述:\\n' \+ description/)
assert.match(reportJs, /description\) content\.push\(description\)/)
assert.doesNotMatch(reportJs, /category\.slug|reason = category/)
assert.doesNotMatch(reportOps, /sanitize_key\(\(string\) (?:\$request->get_param\('category'\)|\$row\['category'\])/)
assert.match(reportCenter, /'category'\s*=>\s*\$category/)
assert.doesNotMatch(reportCenter, /sanitize_key\(\$category\)/)
assert.match(reportJs, /report\/upload-image/)
assert.match(reportJs, /window\.AksrtModalRuntime\.getActive\(\) === shell/)
assert.match(userController, /'\/report\/upload-image'/)
@@ -65,8 +65,8 @@ class MiniFormData {
}
function createRuntime({ imgEnabled = true, categories = [
{ slug: 'spam', name: '垃圾信息' },
{ slug: 'abuse', name: '人身攻击' },
{ name: '垃圾信息' },
{ name: '人身攻击' },
] } = {}) {
const document = new ReportDocument();
const modals = [];
@@ -192,7 +192,7 @@ async function flushPromises() {
}
test('opens from an article button, associates the target, omits URL input, and escapes rendered names', () => {
const runtime = createRuntime({ categories: [{ slug: 'unsafe', name: '<svg onload="run()">reason</svg>' }] });
const runtime = createRuntime({ categories: [{ name: '<svg onload="run()">reason</svg>' }] });
const targetName = '<img src=x onerror="run()"> &';
const modal = runtime.openReport({ target_type: 'post', target_id: 73, target_name: targetName });
const target = modal.shell.querySelector('.aksrt-report-target__name');
@@ -249,8 +249,8 @@ test('requires a radio reason and other text, then sends canonical fields with d
assert.deepEqual(payload, {
target_type: 'post',
target_id: 42,
category: '',
reason: '自定义原因\n详细描述:\n补充背景',
category: '其他原因',
reason: '自定义原因\n\n补充背景',
});
call.resolve({ success: true, message: '已提交' });
await flushPromises();
@@ -273,8 +273,34 @@ test('preset reason ignores hidden custom text while including description in re
assert.deepEqual(payload, {
target_type: 'post',
target_id: 42,
category: 'abuse',
reason: '人身攻击\n详细描述:\nMore context',
category: '人身攻击',
reason: 'More context',
});
});
test('allows a preset-only report without copying the category into content', async () => {
const runtime = createRuntime();
const modal = runtime.openReport();
selectReason(modal, 0);
submit(modal);
const request = callsFor(runtime, '/report/submit')[0];
assert.deepEqual(payloadFor(request), {
target_type: 'post', target_id: 42, category: '垃圾信息', reason: '',
});
request.resolve({ success: true });
await flushPromises();
});
test('keeps a single other option and preserves explicitly entered category words in content', () => {
const runtime = createRuntime({ categories: [{ name: '其他原因' }, { name: '垃圾信息' }] });
const modal = runtime.openReport();
const radios = modal.shell.querySelectorAll('[name="report_reason"]');
assert.equal(radios.length, 2);
selectReason(modal, 0);
modal.shell.querySelector('[name="report_desc"]').value = '垃圾信息中包含了违规内容';
submit(modal);
assert.deepEqual(payloadFor(callsFor(runtime, '/report/submit')[0]), {
target_type: 'post', target_id: 42, category: '垃圾信息', reason: '垃圾信息中包含了违规内容',
});
});
@@ -381,7 +407,7 @@ test('server failure preserves values, reenables controls, and allows a retry',
submit(modal);
const retry = callsFor(runtime, '/report/submit')[1];
assert.equal(payloadFor(retry).reason, 'A custom report reason\n详细描述:\nAdditional context');
assert.equal(payloadFor(retry).reason, 'A custom report reason\n\nAdditional context');
retry.resolve({ success: true });
await flushPromises();
});
@@ -0,0 +1,471 @@
<?php
/**
* Exercise report submission, storage, notifications, and admin filtering without WordPress.
*
* @package AKSRT
*/
namespace AKSRT\Core {
final class DBMigrator {
public static function table(string $name): string { return 'wp_aksrt_' . $name; }
}
}
namespace AKSRT\Settings {
final class Settings {
public static array $values = [];
public static function get_all(): array { return self::$values; }
public static function read_domain(string $domain, string $section): array {
unset($domain, $section);
return self::$values;
}
}
}
namespace AKSRT\Messages {
final class Message {
public static function get_instance(): self { return new self(); }
public function send(int $user_id, string $type, string $content, array $args = []): void {
$GLOBALS['aksrt_test_messages'][] = compact('user_id', 'type', 'content', 'args');
}
}
}
namespace AKSRT\Attachment {
final class PendingUploads {
public static function claim_text(string $text, int $user_id): void {
$GLOBALS['aksrt_test_claims'][] = [$text, $user_id];
}
}
}
namespace AKSRT\User {
final class Restriction {
public const TYPE_WARN = 'warn';
public const TYPE_MUTE = 'mute';
public const TYPE_SUSPEND = 'suspend';
public const TYPE_BAN = 'ban';
public const TYPES = [self::TYPE_WARN, self::TYPE_MUTE, self::TYPE_SUSPEND, self::TYPE_BAN];
public const SCOPE_GLOBAL = 'global';
public static array $calls = [];
public static function get_instance(): self { return new self(); }
public function restrict(int $target_id, string $type, string $reason, string $scope, string $expires_at, int $actor_id): array {
self::$calls[] = compact('target_id', 'type', 'reason', 'scope', 'expires_at', 'actor_id');
return ['success' => true];
}
}
}
namespace {
define('ABSPATH', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR);
define('ARRAY_A', 'ARRAY_A');
class WP_User {
public function __construct(public int $ID, public string $display_name) {}
}
class WP_User_Query {}
class WP_Post {
public string $post_status = 'publish';
public function __construct(public int $ID) {}
}
class WP_REST_Request {
public function __construct(private array $params = []) {}
public function get_json_params(): array { return $this->params; }
public function get_param(string $key) { return $this->params[$key] ?? null; }
}
class WP_REST_Response {
public function __construct(private array $data = [], private int $status = 200) {}
public function get_data(): array { return $this->data; }
public function get_status(): int { return $this->status; }
}
require_once ABSPATH . 'src/Messages/ReportCenter.php';
require_once ABSPATH . 'src/Settings/UserRuntime.php';
require_once ABSPATH . 'src/Admin/UserControllerReportOperations.php';
final class TestWpdb {
public string $prefix = 'wp_';
public int $insert_id = 0;
public string $last_error = '';
public bool $fail_insert = false;
public array $rows = [];
public array $insert_calls = [];
private int $next_id = 1;
private array $last_args = [];
public function prepare(string $query, ...$args): string {
if (count($args) === 1 && is_array($args[0])) {
$args = $args[0];
}
$this->last_args = $args;
return $query;
}
public function insert(string $table, array $data, array $format = []) {
$this->insert_calls[] = [$table, $data, $format];
if ($this->fail_insert) {
return false;
}
$id = $this->next_id++;
$this->insert_id = $id;
$this->rows[] = array_merge(['id' => $id], $data, ['notes_json' => '[]']);
return 1;
}
public function get_var(string $query) {
if (str_contains($query, 'reporter_id = %d')) {
[$reporter_id, $target_type, $target_id] = array_pad($this->last_args, 3, null);
foreach ($this->rows as $row) {
if ((int) $row['reporter_id'] === (int) $reporter_id
&& (string) $row['target_type'] === (string) $target_type
&& (int) $row['target_id'] === (int) $target_id
&& (string) $row['status'] === 'pending'
) {
return (int) $row['id'];
}
}
return null;
}
if (str_contains($query, 'COUNT(*)') && str_contains($query, 'target_type = %s')) {
[$target_type, $target_id] = array_pad($this->last_args, 2, null);
return count(array_filter($this->rows, static fn (array $row): bool =>
(string) $row['target_type'] === (string) $target_type
&& (int) $row['target_id'] === (int) $target_id
));
}
return 0;
}
public function get_results(string $query, $output = null): array {
if (str_contains($query, 'GROUP BY status')) {
$counts = [];
foreach ($this->rows as $row) {
$status = (string) ($row['status'] ?? '');
$counts[$status] = ($counts[$status] ?? 0) + 1;
}
$results = [];
foreach ($counts as $status => $count) {
$results[] = ['status' => $status, 'cnt' => $count];
}
return $results;
}
$rows = $this->rows;
$index = 0;
foreach (['status', 'category', 'target_type'] as $field) {
if (str_contains($query, $field . ' = %s')) {
$value = (string) ($this->last_args[$index++] ?? '');
$rows = array_values(array_filter($rows, static fn (array $row): bool =>
(string) ($row[$field] ?? '') === $value
));
}
}
return $rows;
}
public function get_row(string $query, $output = null) {
$id = (int) ($this->last_args[0] ?? 0);
foreach ($this->rows as $row) {
if ((int) $row['id'] === $id) {
return $output === ARRAY_A ? $row : (object) $row;
}
}
return null;
}
public function update(string $table, array $data, array $where, array $format = [], array $where_format = []) {
unset($table, $format, $where_format);
foreach ($this->rows as &$row) {
if ((int) ($row['id'] ?? 0) === (int) ($where['id'] ?? 0)) {
$row = array_merge($row, $data);
unset($row);
return 1;
}
}
unset($row);
return false;
}
}
final class ReportFixtureController {
use \AKSRT\Admin\UserControllerReportOperations;
protected function success($data = [], int $status = 200): WP_REST_Response {
return new WP_REST_Response(array_merge(['success' => true], $data), $status);
}
protected function error(string $message, int $status = 400): WP_REST_Response {
return new WP_REST_Response(['success' => false, 'message' => $message], $status);
}
protected function get_json_params(WP_REST_Request $request): array {
return $request->get_json_params();
}
private function build_admin_user_summary(int $user_id, $user): array {
return [
'id' => $user_id,
'display_name' => $user ? $user->display_name : '',
'email' => '',
];
}
}
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function sanitize_text_field($value): string {
return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? '');
}
function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); }
function esc_url_raw($url): string { return (string) $url; }
function wp_kses_post($value): string { return strip_tags((string) $value); }
function current_time(string $type): string { unset($type); return '2026-10-30 12:00:00'; }
function wp_json_encode($value): string { return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '[]'; }
function get_current_user_id(): int { return (int) ($GLOBALS['aksrt_test_user_id'] ?? 0); }
function get_userdata(int $user_id) { return $user_id > 0 ? new WP_User($user_id, 'Reporter ' . $user_id) : false; }
function get_users(array $args = []): array { unset($args); return [901]; }
function admin_url(string $path = ''): string { return 'https://example.test/wp-admin/' . ltrim($path, '/'); }
function get_post(int $id) { return $id > 0 ? new WP_Post($id) : false; }
function get_the_title($post): string { return 'Article #' . (is_object($post) ? $post->ID : (int) $post); }
function get_permalink($post): string { return 'https://example.test/post/' . (is_object($post) ? $post->ID : (int) $post); }
function do_action(string $hook, ...$args): void {
if ($hook === 'aksrt_report_submitted') {
$GLOBALS['aksrt_test_actions'][] = $args;
}
}
if (!function_exists('mb_substr')) {
function mb_substr(string $text, int $start, ?int $length = null): string {
return $length === null ? substr($text, $start) : substr($text, $start, $length);
}
}
function reset_report_fixture(array $preset_names = ['垃圾信息', '人身攻击']): void {
$GLOBALS['wpdb'] = new TestWpdb();
$GLOBALS['aksrt_test_user_id'] = 42;
$GLOBALS['aksrt_test_actions'] = [];
$GLOBALS['aksrt_test_messages'] = [];
$GLOBALS['aksrt_test_claims'] = [];
\AKSRT\User\Restriction::$calls = [];
\AKSRT\Settings\Settings::$values = [
'ban_preset_reason' => array_map(static fn (string $name): array => ['t' => $name], $preset_names),
'user_report_img_s' => true,
];
}
function submit_report_case(array $payload): WP_REST_Response {
return (new ReportFixtureController())->submit_report(new WP_REST_Request($payload));
}
function snapshot_response(WP_REST_Response $response): array {
$wpdb = $GLOBALS['wpdb'];
return [
'response' => $response->get_data(),
'status' => $response->get_status(),
'rows' => $wpdb->rows,
'insert_calls' => $wpdb->insert_calls,
'actions' => $GLOBALS['aksrt_test_actions'],
'messages' => $GLOBALS['aksrt_test_messages'],
'claims' => $GLOBALS['aksrt_test_claims'],
];
}
$results = [];
reset_report_fixture(['垃圾信息', '人身攻击']);
$results['runtime_reasons'] = \AKSRT\Settings\UserRuntime::ban_settings()['reasons'];
reset_report_fixture();
$results['preset_with_evidence'] = snapshot_response(submit_report_case([
'target_type' => 'post',
'target_id' => 73,
'category' => '垃圾信息',
'reason' => '补充详情 https://example.test/uploads/proof.png',
]));
reset_report_fixture();
$results['preset_label_only'] = snapshot_response(submit_report_case([
'target_type' => 'comment',
'target_id' => 88,
'category' => '人身攻击',
'reason' => '',
]));
reset_report_fixture(['0']);
$results['falsey_text'] = snapshot_response(submit_report_case([
'target_type' => 'post',
'target_id' => 90,
'category' => '0',
'reason' => '0',
]));
$long_label = '非常长的预置举报分类名称超过数据库原先四十字符限制并且应完整保留在工单及后台列表当中';
reset_report_fixture([$long_label]);
$results['long_preset_label'] = snapshot_response(submit_report_case([
'target_type' => 'post',
'target_id' => 19,
'category' => $long_label,
'reason' => '',
]));
reset_report_fixture();
$results['other_with_details'] = snapshot_response(submit_report_case([
'target_type' => 'comment',
'target_id' => 99,
'category' => '其他原因',
'reason' => '请核查相关资料',
]));
reset_report_fixture();
$results['sanitized_snapshot'] = snapshot_response(submit_report_case([
'target_type' => 'post',
'target_id' => 12,
'category' => ' <b>垃圾信息</b> ',
'reason' => '<strong>补充详情</strong>',
]));
reset_report_fixture();
$results['blank_category'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => '', 'reason' => '详情',
]));
reset_report_fixture();
$results['missing_other_details'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => '其他原因', 'reason' => ' ',
]));
reset_report_fixture();
$results['unknown_category'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => '未配置的分类', 'reason' => '详情',
]));
reset_report_fixture();
$results['hash_category'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => 'ab73c91f054e', 'reason' => '详情',
]));
reset_report_fixture();
$GLOBALS['aksrt_test_user_id'] = 0;
$results['unauthenticated'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => '垃圾信息', 'reason' => '',
]));
reset_report_fixture();
$results['invalid_target'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 0, 'category' => '垃圾信息', 'reason' => '',
]));
reset_report_fixture();
$GLOBALS['wpdb']->fail_insert = true;
$GLOBALS['wpdb']->insert_id = 777;
$results['insert_failure'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 5, 'category' => '垃圾信息', 'reason' => '含有举证',
]));
reset_report_fixture();
$GLOBALS['wpdb']->rows[] = [
'id' => 51,
'reporter_id' => 42,
'target_type' => 'post',
'target_id' => 73,
'reason' => '既有详情',
'category' => '垃圾信息',
'status' => 'pending',
'created_at' => '2026-10-30 11:00:00',
'updated_at' => '',
'notes_json' => '[]',
];
$results['duplicate'] = snapshot_response(submit_report_case([
'target_type' => 'post', 'target_id' => 73, 'category' => '垃圾信息', 'reason' => '重复提交',
]));
reset_report_fixture(['涉嫌侵权']);
$admin_controller = new ReportFixtureController();
$submit_response = submit_report_case([
'target_type' => 'post', 'target_id' => 31, 'category' => '涉嫌侵权', 'reason' => '',
]);
$list_response = $admin_controller->get_report_list(new WP_REST_Request(['category' => '涉嫌侵权']));
$list_data = $list_response->get_data()['data'] ?? [];
$detail_response = $admin_controller->get_report_detail(new WP_REST_Request(['id' => 1]));
$detail_data = $detail_response->get_data()['data'] ?? [];
$results['admin_category'] = [
'submit_success' => !empty($submit_response->get_data()['success']),
'list_items' => $list_data['items'] ?? [],
'list_options' => $list_data['options']['categories'] ?? [],
'detail_report' => $detail_data['report'] ?? [],
];
reset_report_fixture();
$service_id = \AKSRT\Messages\ReportCenter::get_instance()->submit(42, 'post', 6, '详情', '');
$results['service_blank_category'] = [
'id' => $service_id,
'rows' => $GLOBALS['wpdb']->rows,
'insert_calls' => $GLOBALS['wpdb']->insert_calls,
];
reset_report_fixture(['提交时的分类', '待删除的分类']);
$first_snapshot_submit = submit_report_case([
'target_type' => 'post', 'target_id' => 61, 'category' => '提交时的分类', 'reason' => '',
]);
$second_snapshot_submit = submit_report_case([
'target_type' => 'post', 'target_id' => 62, 'category' => '待删除的分类', 'reason' => '',
]);
$admin_controller = new ReportFixtureController();
\AKSRT\Settings\Settings::$values['ban_preset_reason'] = [['t' => '当前新分类']];
$changed_list = $admin_controller->get_report_list(new WP_REST_Request())->get_data()['data'] ?? [];
\AKSRT\Settings\Settings::$values['ban_preset_reason'] = [];
$removed_list = $admin_controller->get_report_list(new WP_REST_Request())->get_data()['data'] ?? [];
$first_detail = $admin_controller->get_report_detail(new WP_REST_Request(['id' => 1]))->get_data()['data']['report'] ?? [];
$second_detail = $admin_controller->get_report_detail(new WP_REST_Request(['id' => 2]))->get_data()['data']['report'] ?? [];
$results['category_snapshot'] = [
'submit_success' => !empty($first_snapshot_submit->get_data()['success']) && !empty($second_snapshot_submit->get_data()['success']),
'changed_list_categories' => array_column($changed_list['items'] ?? [], 'category'),
'changed_list_options' => array_column($changed_list['options']['categories'] ?? [], 'value'),
'removed_list_categories' => array_column($removed_list['items'] ?? [], 'category'),
'details' => [
(string) ($first_detail['category'] ?? ''),
(string) ($second_detail['category'] ?? ''),
],
];
reset_report_fixture(['人身攻击']);
$GLOBALS['wpdb']->rows[] = [
'id' => 101, 'reporter_id' => 42, 'target_type' => 'user', 'target_id' => 701,
'reason' => '', 'category' => '人身攻击', 'status' => 'pending',
'created_at' => '2026-10-30 11:00:00', 'updated_at' => null, 'notes_json' => '[]',
];
$preset_process = (new ReportFixtureController())->process_report(new WP_REST_Request([
'id' => 101, 'status' => 'resolved', 'action_taken' => 'warn', 'note' => '',
]));
$preset_row = $GLOBALS['wpdb']->rows[0];
$results['process_preset_only'] = [
'success' => !empty($preset_process->get_data()['success']),
'restriction' => \AKSRT\User\Restriction::$calls[0] ?? [],
'report_reason' => $preset_row['reason'],
'report_category' => $preset_row['category'],
];
reset_report_fixture();
$custom_reason = '自定义说明不能丢';
$GLOBALS['wpdb']->rows[] = [
'id' => 102, 'reporter_id' => 42, 'target_type' => 'user', 'target_id' => 702,
'reason' => $custom_reason, 'category' => '其他原因', 'status' => 'pending',
'created_at' => '2026-10-30 11:00:00', 'updated_at' => null, 'notes_json' => '[]',
];
$other_process = (new ReportFixtureController())->process_report(new WP_REST_Request([
'id' => 102, 'status' => 'resolved', 'action_taken' => 'warn', 'note' => '',
]));
$other_row = $GLOBALS['wpdb']->rows[0];
$results['process_other_with_details'] = [
'success' => !empty($other_process->get_data()['success']),
'restriction' => \AKSRT\User\Restriction::$calls[0] ?? [],
'report_reason' => $other_row['reason'],
'report_category' => $other_row['category'],
];
echo json_encode($results, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
@@ -0,0 +1,182 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const harness = path.join(__dirname, 'report-submit-harness.php');
let cachedResults;
function results() {
if (cachedResults) return cachedResults;
const run = spawnSync(process.env.PHP_BINARY || 'php', [harness], {
cwd: root,
encoding: 'utf8',
});
assert.ifError(run.error);
assert.equal(run.status, 0, `PHP report harness failed:\n${run.stderr}\n${run.stdout}`);
cachedResults = JSON.parse(run.stdout);
return cachedResults;
}
test('stores readable preset category separately from details and evidence', () => {
const result = results().preset_with_evidence;
const reason = '补充详情 https://example.test/uploads/proof.png';
assert.equal(result.response.success, true);
assert.equal(result.rows.length, 1);
assert.equal(result.rows[0].category, '垃圾信息');
assert.equal(result.rows[0].reason, reason);
assert.doesNotMatch(result.rows[0].reason, /^垃圾信息/);
assert.deepEqual(result.claims, [[reason, 42]]);
const hook = result.actions[0][1];
assert.equal(hook.category, '垃圾信息');
assert.equal(hook.reason, reason);
});
test('accepts preset-only reports and includes their category in admin notifications', () => {
const result = results().preset_label_only;
assert.equal(result.response.success, true);
assert.equal(result.rows[0].category, '人身攻击');
assert.equal(result.rows[0].reason, '');
assert.equal(result.messages.length, 1);
assert.match(result.messages[0].content, /人身攻击/);
});
test('accepts the reserved other category only when details are present', () => {
const result = results().other_with_details;
assert.equal(result.response.success, true);
assert.equal(result.rows[0].category, '其他原因');
assert.equal(result.rows[0].reason, '请核查相关资料');
});
test('preserves long configured category labels without truncating them', () => {
const result = results().long_preset_label;
const label = '非常长的预置举报分类名称超过数据库原先四十字符限制并且应完整保留在工单及后台列表当中';
assert.ok(label.length > 40);
assert.equal(result.response.success, true);
assert.equal(result.rows[0].category, label);
});
test('uses sanitized category and reason consistently in storage and the submit hook', () => {
const result = results().sanitized_snapshot;
assert.equal(result.response.success, true);
assert.equal(result.rows[0].category, '垃圾信息');
assert.equal(result.rows[0].reason, '补充详情');
assert.deepEqual(result.actions[0][1], {
reporter_id: 42,
target_type: 'post',
target_id: 12,
reason: '补充详情',
category: '垃圾信息',
});
});
test('rejects blank, unknown, and hash-like categories before inserting', () => {
for (const name of ['blank_category', 'unknown_category', 'hash_category']) {
const result = results()[name];
assert.equal(result.response.success, false, name);
assert.equal(result.insert_calls.length, 0, name);
assert.equal(result.rows.length, 0, name);
}
});
test('requires details for other, while authentication and target validation still apply', () => {
const missingOtherDetails = results().missing_other_details;
assert.equal(missingOtherDetails.response.success, false);
assert.equal(missingOtherDetails.insert_calls.length, 0);
const unauthenticated = results().unauthenticated;
assert.equal(unauthenticated.response.success, false);
assert.equal(unauthenticated.status, 401);
assert.equal(unauthenticated.insert_calls.length, 0);
const invalidTarget = results().invalid_target;
assert.equal(invalidTarget.response.success, false);
assert.equal(invalidTarget.insert_calls.length, 0);
});
test('prevents duplicate pending reports without claiming evidence again', () => {
const result = results().duplicate;
assert.equal(result.response.success, false);
assert.equal(result.status, 409);
assert.equal(result.rows.length, 1);
assert.equal(result.insert_calls.length, 0);
assert.deepEqual(result.claims, []);
});
test('does not notify, fire hooks, or claim evidence after an insert failure with stale insert_id', () => {
const result = results().insert_failure;
assert.equal(result.response.success, false);
assert.equal(result.insert_calls.length, 1);
assert.equal(result.rows.length, 0);
assert.deepEqual(result.messages, []);
assert.deepEqual(result.actions, []);
assert.deepEqual(result.claims, []);
});
test('rejects a blank category at the storage service boundary', () => {
const result = results().service_blank_category;
assert.equal(result.id, 0);
assert.equal(result.rows.length, 0);
assert.equal(result.insert_calls.length, 0);
});
test('preserves readable labels through the admin category filter, list, and detail', () => {
const result = results().admin_category;
assert.equal(result.submit_success, true);
assert.equal(result.list_items.length, 1);
assert.equal(result.list_items[0].category, '涉嫌侵权');
assert.ok(result.list_options.some((option) => option.label === '涉嫌侵权' && option.value === '涉嫌侵权'));
assert.equal(result.detail_report.category, '涉嫌侵权');
});
test('stores report categories as text and uses schema version 3.2.0', () => {
const migrator = fs.readFileSync(path.join(root, 'src/Core/DBMigrator.php'), 'utf8');
const tableDefinitions = fs.readFileSync(path.join(root, 'src/Core/DBMigratorPart2.php'), 'utf8');
const reportTable = tableDefinitions.match(/\$sql_report\s*=\s*"([\s\S]*?)";/);
assert.match(migrator, /const SCHEMA_VERSION\s*=\s*'3\.2\.0';/);
assert.ok(reportTable, 'report table definition should exist');
assert.match(reportTable[1], /\bcategory\s+TEXT\s+NOT NULL\b/);
});
test('preserves submitted category snapshots after preset settings change or disappear', () => {
const result = results().category_snapshot;
const expected = ['提交时的分类', '待删除的分类'];
assert.equal(result.submit_success, true);
assert.deepEqual([...result.changed_list_categories].sort(), [...expected].sort());
assert.deepEqual([...result.changed_list_options].sort(), [...expected].sort());
assert.deepEqual([...result.removed_list_categories].sort(), [...expected].sort());
assert.deepEqual(result.details, expected);
});
test('preserves literal zero text and exposes preset names without slugs', () => {
const data = results();
const result = data.falsey_text;
assert.equal(result.response.success, true);
assert.equal(result.rows[0].category, '0');
assert.equal(result.rows[0].reason, '0');
assert.deepEqual(data.runtime_reasons, [{ name: '垃圾信息' }, { name: '人身攻击' }]);
});
test('passes category and details to restriction actions without rewriting report content', () => {
const data = results();
const preset = data.process_preset_only;
assert.equal(preset.success, true);
assert.equal(preset.restriction.type, 'warn');
assert.equal(preset.restriction.reason, '人身攻击');
assert.equal(preset.report_category, '人身攻击');
assert.equal(preset.report_reason, '');
const other = data.process_other_with_details;
assert.equal(other.success, true);
assert.equal(other.restriction.type, 'warn');
assert.equal(other.restriction.reason, '其他原因\n\n自定义说明不能丢');
assert.equal(other.report_category, '其他原因');
assert.equal(other.report_reason, '自定义说明不能丢');
assert.doesNotMatch(other.report_reason, /^其他原因/);
});
@@ -3,7 +3,7 @@
/**
* 工单表结构契约:
* 1) 6 张 aksrt_ticket* 表全部由 DBMigratorPart2 建出,且被 migrate_ticket_tables() 调用;
* 2) SCHEMA_VERSION 已升到 3.1.0(dbDelta 只在版本变化时重跑);
* 2) SCHEMA_VERSION 已升到 3.2.0(dbDelta 只在版本变化时重跑);
* 3) aksrt_order_aftersale 已从 schema 与表名映射中彻底移除;
* 4) 状态与部门 seeds 幂等,默认状态被标记为不可改删。
*/
@@ -27,7 +27,7 @@ const TABLES = [
test('schema version is bumped so dbDelta re-runs', () => {
const migrator = read('src/Core/DBMigrator.php');
assert.match(migrator, /const SCHEMA_VERSION = '3\.1\.0';/, 'SCHEMA_VERSION must be 3.1.0');
assert.match(migrator, /const SCHEMA_VERSION = '3\.2\.0';/, 'SCHEMA_VERSION must be 3.2.0');
assert.ok(migrator.includes('use DBMigratorTicket;'), 'DBMigratorTicket trait not composed');
// 版本注释要说明每次升版做了什么,否则后人看不懂升版原因。
assert.match(migrator, /3\.0\.0: 售后(order_aftersale)整体替换为工单系统/);