The old 售后 flow was an order-refund appendage: tickets were hard-bound to an order number, users could only file them from 「我的订单」against a paid order, and the admin action bottomed out at 「标记订单已退款」. It could not carry account questions or feature requests that have nothing to do with an order. Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket: title + department + description are required, linking an order is optional (user's own paid orders only). Add departments, custom statuses, preset replies and internal admin notes as four configuration surfaces. Conversation history moves off the ticket row's JSON log into dedicated ticket_reply rows, which unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end moves from 支付中心 → 售后服务 to users.php → 工单管理. Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new ones, delete the old code. No dual paths, no data migration. The upstream plugin's upstream/downstream delivery and internal-ticket conversion are deliberately left out — both are multi-provider platform concerns with no meaning on a single WordPress site. Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply, ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION 2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status 「待接单」is flagged as non-editable. Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse UserController::rest_upload_comment_image with a ticket-specific permission callback, mirroring the existing /report/upload-image route rather than growing a second upload path. Notifications go through AKSRT\Messages\Message::send() and deep-link to the right surface; email/SMS stay out of scope since the theme's mail and SMS stacks are separate implementations. Refund gate opens up: marking an order refunded no longer requires an in-flight ticket, only that the order is paid. Also generalise a hardcoded special case in tools/audit-admin-contracts.php — schema domain inference now reads the parent directory instead of matching "PostList" by name, so new leaf schema files need no per-name exception. Verification: php tools/audit-admin-contracts.php exit 0, errors=0 node --test (widget-system + admin) 664 pass / 0 fail php -l over all new/changed files clean node --check over all JS clean npm run build + build:public clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
91 lines
5.0 KiB
JavaScript
91 lines
5.0 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
|
|
const root = path.resolve(__dirname, '..', '..');
|
|
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
|
|
|
test('comment REST admission is request-local and preprocess keeps native captcha enforcement', () => {
|
|
const controller = read('src/Core/CommentActions.php');
|
|
const moderation = read('src/Auth/Moderation.php');
|
|
|
|
assert.match(controller, /aksrt_require_captcha\('comment', \$params\)/);
|
|
assert.match(controller, /begin_captcha_verified_comment_request\(\)[\s\S]*?try \{[\s\S]*?wp_new_comment[\s\S]*?finally \{[\s\S]*?end_captcha_verified_comment_request\(\)/);
|
|
assert.match(moderation, /private \$captcha_verified_comment_request_depth = 0/);
|
|
assert.match(moderation, /if \(\$this->captcha_verified_comment_request_depth === 0\) \{/);
|
|
assert.doesNotMatch(controller + moderation, /function_exists\('aksrt_require_captcha'\)/);
|
|
assert.doesNotMatch(controller + moderation, /\$_(?:POST|REQUEST)\[[^\]]*captcha[^\]]*verified|set_transient\([^\n]*comment/i);
|
|
});
|
|
|
|
test('captcha business failures preserve WP_Error codes and never stringify opaque results', () => {
|
|
const sources = [
|
|
read('src/Core/CommentActions.php'),
|
|
read('src/Auth/Moderation.php'),
|
|
read('src/Admin/UserControllerAuthPhoneOperations.php'),
|
|
read('inc/modules/user-parts/account-ajax.php'),
|
|
read('src/Core/FrontSubmit.php'),
|
|
].join('\n');
|
|
|
|
assert.match(sources, /get_error_code\(\)/);
|
|
assert.match(sources, /get_error_message\(\)/);
|
|
assert.doesNotMatch(sources, /\(string\)\s*\$(?:captcha|verify|result)/);
|
|
});
|
|
|
|
test('password and email account flows verify captcha once per distinct HTTP request', () => {
|
|
const account = read('inc/modules/user-parts/account-ajax.php');
|
|
const methodBody = (action, nextAction = '') => {
|
|
const start = account.indexOf(`add_action('${action}'`);
|
|
const end = nextAction ? account.indexOf(`add_action('${nextAction}'`, start + 1) : account.length;
|
|
return account.slice(start, end === -1 ? account.length : end);
|
|
};
|
|
|
|
const send = methodBody('wp_ajax_aksrt_send_account_email_code', 'wp_ajax_aksrt_verify_account_email');
|
|
const password = methodBody('wp_ajax_aksrt_change_password', 'wp_ajax_aksrt_change_email');
|
|
const email = methodBody('wp_ajax_aksrt_change_email');
|
|
|
|
assert.equal((send.match(/aksrt_require_captcha\(/g) || []).length, 1);
|
|
assert.match(send, /aksrt_require_captcha\(\s*'change_email'/);
|
|
assert.equal((password.match(/aksrt_require_captcha\(/g) || []).length, 1);
|
|
assert.match(password, /aksrt_require_captcha\(\s*'change_password'/);
|
|
assert.equal((email.match(/aksrt_require_captcha\(/g) || []).length, 1);
|
|
assert.match(email, /aksrt_require_captcha\(\s*'change_email'/);
|
|
});
|
|
|
|
test('legacy public SMS-code AJAX surface is removed while SMS service remains', () => {
|
|
const sms = read('src/Sms/SMS.php');
|
|
|
|
assert.doesNotMatch(sms, /aksrt_send_sms_code/);
|
|
assert.doesNotMatch(sms, /function ajax_send_code\s*\(/);
|
|
assert.match(sms, /public function send_code\(string \$phone, string \$code\): array/);
|
|
});
|
|
|
|
test('ticket create and reply endpoints verify captcha once per request', () => {
|
|
const service = read('src/Ticket/TicketRest.php');
|
|
|
|
// 两个新场景已在场景白名单与后台「启用场景」勾选列表同步注册
|
|
const scenes = read('inc/modules/captcha.php');
|
|
assert.match(scenes, /'ticket_create'\s*=> __\('提交工单', 'aksrt'\)/);
|
|
assert.match(scenes, /'ticket_reply'\s*=> __\('回复工单', 'aksrt'\)/);
|
|
const permissions = read('src/Admin/Schema/SchemaPermissions.php');
|
|
assert.match(permissions, /'label' => '提交工单', 'value' => 'ticket_create'/);
|
|
assert.match(permissions, /'label' => '回复工单', 'value' => 'ticket_reply'/);
|
|
const adminSchema = read('assets/admin/src/schema/sections/permissions/index.js');
|
|
assert.match(adminSchema, /label: '提交工单', value: 'ticket_create'/);
|
|
assert.match(adminSchema, /label: '回复工单', value: 'ticket_reply'/);
|
|
|
|
// REST 新建 / 回复各过一次场景校验(统一入口 helper 内恰一次 aksrt_require_captcha);
|
|
// 场景未启用时 aksrt_require_captcha 直接放行
|
|
const submit = service.slice(service.indexOf('public function create_ticket'), service.indexOf('public function reply'));
|
|
const cancel = service.slice(service.indexOf('public function reply'), service.indexOf('public function urge'));
|
|
assert.match(submit, /require_captcha\(\$request, self::CAPTCHA_SCENE_CREATE\)/);
|
|
assert.match(cancel, /require_captcha\(\$request, self::CAPTCHA_SCENE_REPLY\)/);
|
|
assert.match(service, /const CAPTCHA_SCENE_CREATE = 'ticket_create'/);
|
|
assert.match(service, /const CAPTCHA_SCENE_REPLY = 'ticket_reply'/);
|
|
assert.equal((service.match(/require_captcha\(\$request, self::CAPTCHA_SCENE_/g) || []).length, 2);
|
|
assert.equal((service.match(/aksrt_require_captcha\(/g) || []).length, 1);
|
|
assert.match(service, /function_exists\('aksrt_require_captcha'\)/);
|
|
});
|