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>
145 lines
6.8 KiB
JavaScript
145 lines
6.8 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 工单 REST 权限契约:
|
||
* 1) 每条路由都带 permission_callback,没有裸路由;
|
||
* 2) 用户侧只能读写自己的工单(find_for_user 命中 user_id);
|
||
* 3) 关联订单必须属于本人且为已支付;
|
||
* 4) 管理侧全部要求 manage_options;
|
||
* 5) 附件上传复用评论图片上传,但权限回调是工单专属的。
|
||
*/
|
||
|
||
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');
|
||
|
||
/** 从 register_routes() 源码里抽出所有 register_rest_route 调用的路由路径。 */
|
||
function routes(source) {
|
||
const out = [];
|
||
const re = /register_rest_route\(\s*\$namespace\s*,\s*([^,]+),\s*\[/g;
|
||
let m;
|
||
while ((m = re.exec(source)) !== null) out.push(m[1].trim());
|
||
return out;
|
||
}
|
||
|
||
test('every user-side ticket route declares a permission callback', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const body = rest.slice(rest.indexOf('public function register_routes()'), rest.indexOf('private function list_args()'));
|
||
|
||
const paths = routes(body);
|
||
assert.ok(paths.length >= 9, `expected at least 9 routes, found ${paths.length}`);
|
||
|
||
// 每个 register_rest_route 调用体内都必须出现 permission_callback。
|
||
const calls = body.match(/register_rest_route\([\s\S]*?\n\s*\]\);?/g) || [];
|
||
assert.equal(calls.length, paths.length, 'route call count mismatch');
|
||
for (const call of calls) {
|
||
assert.ok(call.includes('permission_callback'), `route without permission_callback:\n${call.slice(0, 160)}`);
|
||
}
|
||
|
||
// 用户侧统一走 require_user(登录 + nonce)。
|
||
assert.match(body, /'permission_callback' => \[\$this, 'require_user'\]/);
|
||
assert.match(rest, /is_user_logged_in\(\)/);
|
||
assert.match(rest, /wp_verify_nonce\(\$nonce, 'wp_rest'\)/);
|
||
// 附件上传用的是工单专属回调,不是 require_user。
|
||
assert.match(rest, /'permission_callback' => \[\$this, 'require_attachment_upload'\]/);
|
||
});
|
||
|
||
test('attachment upload permission is gated on the ticket attachment switch', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const body = rest.slice(rest.indexOf('public function require_attachment_upload'));
|
||
|
||
assert.match(body, /TicketSettings::enabled\(\)/);
|
||
assert.match(body, /TicketSettings::attachment_enabled\(\)/);
|
||
// 复用评论图片上传 handler,不自建上传链路。
|
||
assert.match(rest, /callback' => \[\\AKSRT\\Admin\\UserController::class, 'rest_upload_comment_image'\]/);
|
||
// 上传路由挂在 /ticket/upload-attachment(与 /report/upload-image 同构)。
|
||
assert.match(rest, /'\/ticket\/upload-attachment'/);
|
||
});
|
||
|
||
test('user reads and writes are scoped to the owner', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const repo = read('src/Ticket/TicketRepository.php');
|
||
|
||
// 详情 / 回复 / 催单 / 关闭全部经过 find_for_user。
|
||
assert.match(repo, /public function find_for_user\(int \$id, int \$user_id\): \?array/);
|
||
assert.match(repo, /if \(\$row === null \|\| \(int\) \$row\['user_id'\] !== \$user_id\) \{\s*\n\s*return null;/);
|
||
|
||
for (const method of ['get_ticket', 'reply', 'urge', 'close']) {
|
||
const body = rest.slice(rest.indexOf(`public function ${method}(`));
|
||
const next = body.indexOf('public function', 10);
|
||
const slice = next === -1 ? body : body.slice(0, next);
|
||
assert.match(slice, /find_for_user\(/, `${method} must scope by owner`);
|
||
}
|
||
|
||
// 列表与统计也带 user_id。
|
||
assert.match(rest, /'user_id' => get_current_user_id\(\),/);
|
||
});
|
||
|
||
test('linked orders must belong to the caller and be paid', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const body = rest.slice(rest.indexOf('private function owns_paid_order'));
|
||
|
||
assert.match(body, /get_user_order\(get_current_user_id\(\), \$order_number\)/);
|
||
assert.match(body, /ORDER_STATUS_PAID/);
|
||
// 新建时关联订单非法必须拒掉。
|
||
assert.match(rest, /if \(\$order_number !== '' && !\$this->owns_paid_order\(\$order_number\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_order'/);
|
||
});
|
||
|
||
test('admin routes all require manage_options', () => {
|
||
const admin = read('src/Ticket/TicketRestAdmin.php');
|
||
const body = admin.slice(admin.indexOf('public function register_routes()'));
|
||
|
||
const calls = body.match(/register_rest_route\([\s\S]*?\n\s*\]\);?/g) || [];
|
||
assert.ok(calls.length >= 20, `expected a full admin surface, found ${calls.length}`);
|
||
for (const call of calls) {
|
||
assert.ok(call.includes("'permission_callback' => [\$this, 'require_admin']"),
|
||
`admin route without require_admin:\n${call.slice(0, 160)}`);
|
||
}
|
||
|
||
assert.match(admin, /current_user_can\('manage_options'\)/);
|
||
// 管理侧命名空间与用户侧隔离。
|
||
assert.match(body, /\$namespace = 'aksrt\/v1\/admin';/);
|
||
});
|
||
|
||
test('closed tickets refuse replies and cannot be re-closed', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const admin = read('src/Ticket/TicketRestAdmin.php');
|
||
|
||
// 用户侧
|
||
assert.match(rest, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_closed', __\('工单已关闭,无法回复'/);
|
||
assert.match(rest, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_closed', __\('工单已关闭,无法催单'/);
|
||
// 管理侧
|
||
assert.match(admin, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_closed'/);
|
||
|
||
// close_ticket 自身也挡已完结(双保险,避免前端绕过 UI 直接 POST)。
|
||
const status = read('src/Ticket/TicketRepository-traits/TicketStatus.php');
|
||
assert.match(status, /if \(TicketStatusService::is_closed\(\(string\) \(\$ticket\['status'\] \?\? ''\)\)\) \{\s*\n\s*return false;/);
|
||
});
|
||
|
||
test('urge has a cooldown driven by settings', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const body = rest.slice(rest.indexOf('public function urge('));
|
||
|
||
assert.match(body, /get_transient\('aksrt_ticket_urge_' \. \$id\)/);
|
||
assert.match(body, /set_transient\('aksrt_ticket_urge_' \. \$id, time\(\)/);
|
||
assert.match(body, /TicketSettings::urge_interval\(\) \* MINUTE_IN_SECONDS/);
|
||
});
|
||
|
||
test('quoted replies must belong to the same ticket', () => {
|
||
for (const file of ['src/Ticket/TicketRest.php', 'src/Ticket/TicketRestAdmin.php']) {
|
||
const src = read(file);
|
||
assert.match(src, /if \(\$quoted === null \|\| \(int\) \$quoted\['ticket_id'\] !== \$id\) \{\s*\n\s*\$quote_id = 0;/,
|
||
`${file} must drop cross-ticket quotes`);
|
||
}
|
||
|
||
const repo = read('src/Ticket/TicketRepository.php');
|
||
// 自引用同样要挡掉,否则时间线会出现循环引用。
|
||
assert.match(repo, /if \(\$quote_id === \$self_id\) \{\s*\n\s*return null;/);
|
||
// 被引用的回复已删除时返回 is_deleted 标记,界面显示占位而不是空白。
|
||
assert.match(repo, /'is_deleted' => true,/);
|
||
});
|