feat(ticket): replace the after-sale flow with a general ticket system

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>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-10-25 23:29:51 +08:00
1 parent 761c84d70f
commit dbb7de41e9
81 files changed
+5812 -4179

No files matched your search

+3 -29
View File
@@ -11,7 +11,6 @@
* 的那份从未被调用(死代码),两页各自又内联了一份 paginate_links。
* 3) 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉
* 且分页条不输出,只能手改 URL 退出。现在每页回退到最后一页重查一次。
* 4) 「售后服务」原本写死 limit => 100,完全没有分页。
*
* 本契约固化:
* - 七个页面全部改走 AdminListPagination::render(),不再有各自的翻页实现;
@@ -60,7 +59,7 @@ const emptyStates = {
chargeCards: '未找到对应内容',
};
test('七个页面全部改走共享分页助手', () => {
test('六个页面全部改走共享分页助手', () => {
for (const [key, src] of Object.entries(pages)) {
assert.ok(
src.includes('AdminListPagination::render('),
@@ -68,10 +67,8 @@ test('七个页面全部改走共享分页助手', () => {
);
}
// 订单明细 + 售后服务在同一个文件里,各有一处。
assert.equal(count(pages.orders, /AdminListPagination::render\(/g), 2, '订单明细与售后服务应各有一处');
// 每个页面恰好一处;售后页已整体移出支付中心(工单后台在 users.php)。
for (const key of Object.keys(pages)) {
if (key === 'orders') continue;
assert.equal(count(pages[key], /AdminListPagination::render\(/g), 1, `${labels[key]} 只应有一处`);
}
});
@@ -123,7 +120,7 @@ test('每页都在查询前夹取页码,越界时重查一次', () => {
);
const requeries = {
orders: [/\$items\s*=\s*\$repo->get_orders_admin\(/g, /\$items\s*=\s*\$service->admin_list\(/g],
orders: [/\$items\s*=\s*\$repo->get_orders_admin\(/g],
products: [/\$query\s*=\s*\$this->query_paid_posts\(/g],
coupons: [/\$svc->get_list\(/g],
income: [/\$svc->get_list\(/g],
@@ -138,33 +135,10 @@ test('每页都在查询前夹取页码,越界时重查一次', () => {
}
});
test('售后服务改为真正的 limit/offset 分页', () => {
assert.doesNotMatch(pages.orders, /limit => 100/, '售后服务仍写死 limit => 100');
assert.match(pages.orders, /\$total = \$service->count\(\$filter_args\);/, '售后服务未用 AfterSaleService::count() 取总数');
// count() 不像 admin_list() 那样校验状态,查询前必须先归一,否则分页条会说谎。
assert.match(pages.orders, /if \(!in_array\(\$status, \$this->after_sale_statuses\(\), true\)\)/);
assert.match(pages.orders, /private function after_sale_statuses\(\): array \{/);
});
test('分页条晚于空态分支渲染', () => {
for (const [key, src] of Object.entries(pages)) {
const marker = emptyStates[key];
assert.ok(src.includes(marker), `${labels[key]} 找不到空态标记 ${marker}`);
if (key === 'orders') {
// 订单明细是第一处,售后服务是最后一处。
assert.ok(
src.indexOf('AdminListPagination::render(') > src.indexOf(marker),
'订单明细的分页条必须晚于空态分支'
);
assert.ok(
src.lastIndexOf('AdminListPagination::render(') > src.indexOf('暂无售后申请'),
'售后服务的分页条必须晚于空态分支'
);
continue;
}
assert.ok(
src.indexOf('AdminListPagination::render(') > src.indexOf(marker),
`${labels[key]} 的分页条必须晚于空态分支`
-331
View File
@@ -1,331 +0,0 @@
<?php
// Dev harness: render the REAL PayOrders pages with WP stubs, and drive the refund
// handlers through their scenarios.
// Usage:
// php render-after-sales-harness.php -> after-sales page -> after-sales-rendered.html
// php render-after-sales-harness.php orders -> orders list page -> orders-rendered.html
// php render-after-sales-harness.php <scenario> -> run one handler scenario, print JSON log
namespace {
define('ABSPATH', __DIR__ . '/');
final class HarnessLog {
/** @var list<array<string,mixed>> */
public static array $events = [];
public static function add(string $what, array $data = []): void {
self::$events[] = ['what' => $what] + $data;
}
}
final class WP_Error {
public function __construct(private string $code, private string $message, private array $data = []) {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data(): array { return $this->data; }
}
// ---------- WP function stubs ----------
function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
function esc_attr($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
function esc_url($s) { return (string) $s; }
function esc_url_raw($s) { return (string) $s; }
function esc_js($s) { return addslashes((string) $s); }
function esc_textarea($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
function esc_html__($s, $d = null) { return esc_html($s); }
function esc_attr__($s, $d = null) { return esc_attr($s); }
function esc_html_e($s, $d = null) { echo esc_html($s); }
function __($s, $d = null) { return $s; }
function wp_unslash($v) { return $v; }
function sanitize_key($s) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $s)); }
function sanitize_text_field($s) { return trim(strip_tags((string) $s)); }
function sanitize_textarea_field($s) { return trim(strip_tags((string) $s)); }
function selected($a, $b, $echo = true) { $r = ((string) $a === (string) $b) ? " selected='selected'" : ''; if ($echo) echo $r; return $r; }
function submit_button($text = null, $type = 'primary', $name = 'submit', $wrap = true, $other = []) { echo '<button type="submit" class="' . esc_attr($type) . '">' . esc_html($text ?: 'Submit') . '</button>'; }
function wp_nonce_field($a = -1, $n = '_wpnonce', $r = true, $e = true) { if ($e) echo '<input type="hidden" name="' . esc_attr($n) . '" value="stubnonce" />'; }
function wp_create_nonce($a = -1) { return 'stubnonce' . md5((string) $a); }
function admin_url($p = '') { return 'https://example.test/wp-admin/' . $p; }
function add_query_arg($k, $v = null, $u = null) { if (is_array($k)) { $u = $v ?: ''; $q = http_build_query($k); return $u . (str_contains($u, '?') ? '&' : '?') . $q; } return ($u ?: '') . (str_contains(($u ?: ''), '?') ? '&' : '?') . $k . '=' . $v; }
function wp_json_encode($v, $f = 0) { return json_encode($v, $f); }
function current_user_can($c) { return true; }
function current_time($type = 'mysql') { return '2026-10-08 12:00:00'; }
function is_wp_error($t) { return $t instanceof WP_Error; }
function absint($v) { return abs((int) $v); }
function check_admin_referer($a = -1, $n = '_wpnonce') { \HarnessLog::add('check_admin_referer', ['action' => (string) $a]); }
function wp_safe_redirect($url) { \HarnessLog::add('wp_safe_redirect', ['url' => (string) $url]); }
function wp_die($m) { throw new Exception('wp_die: ' . $m); }
function get_userdata($id) { return $id > 0 ? (object) ['display_name' => 'User' . $id] : null; }
function get_the_title($id) { return 'Product' . $id; }
function is_admin() { return true; }
}
namespace AKSRT\Ledger {
final class AssetAmount {
public static function balance($value): self { return new self(); }
public function decimal(): string { return '0.00'; }
}
}
namespace AKSRT\Payment {
class Gateway {
private static $i;
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function get_all(): array { return ['balance' => (object) [], 'points' => (object) []]; }
}
class AfterSaleService {
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
public const SOURCE_ADMIN = 'admin';
public const SOURCE_USER = 'user';
private static $i;
/** @var array<int,array<string,mixed>> keyed by after-sale id */
public static array $rows = [];
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function admin_list(array $args = []): array { return array_values(self::$rows); }
public function list_for_order(string $order_number): array {
return array_values(array_filter(self::$rows, static fn($row): bool => (string) ($row['order_number'] ?? '') === $order_number));
}
public function count(array $args = []): int { return count(self::$rows); }
public function get(int $id): ?array { return self::$rows[$id] ?? null; }
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
$row = self::$rows[$after_sale_id] ?? null;
$ok = is_array($row)
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true);
\HarnessLog::add('can_finalize_order_refund', ['id' => $after_sale_id, 'order' => $order_number, 'ticket_status' => is_array($row) ? (string) ($row['status'] ?? '') : null, 'ok' => $ok]);
return $ok;
}
public function find_active(string $order_number): ?array {
$found = null;
foreach (self::$rows as $row) {
if ((string) ($row['order_number'] ?? '') !== $order_number) continue;
if (!in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) continue;
if ($found === null || (int) ($row['id'] ?? 0) > (int) ($found['id'] ?? 0)) $found = $row;
}
\HarnessLog::add('find_active', ['order' => $order_number, 'ticket' => $found === null ? null : (int) ($found['id'] ?? 0)]);
return $found;
}
public function set_status(int $id, string $status, string $note = '') {
\HarnessLog::add('set_status', ['id' => $id, 'status' => $status, 'note' => $note]);
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) {
return new \WP_Error('aksrt_after_sale_status', '售后状态无效', ['status' => 400]);
}
if (!isset(self::$rows[$id])) return new \WP_Error('aksrt_after_sale_state', '售后申请不存在', ['status' => 409]);
self::$rows[$id]['status'] = $status;
self::$rows[$id]['handling_log'][] = ['status' => $status, 'note' => $note, 'source' => 'admin', 'created_at' => current_time('mysql')];
return self::$rows[$id];
}
}
class OrderRepository {
private static $i;
/** @var array<string,array<string,mixed>> keyed by order number */
public static array $orders = [];
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function get($n): ?array { return self::$orders[(string) $n] ?? null; }
public function get_admin_order_context(string $order_number): array { return ['customer_note' => '买家备注示例']; }
public function get_orders_admin(array $args = [], string $search = ''): array { return array_values(self::$orders); }
public function get_orders_count(array $args = [], string $search = ''): int { return count(self::$orders); }
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
\HarnessLog::add('update_status', ['order' => $order_number, 'status' => $new_status, 'extra' => $extra]);
// Mirrors OrderRepository::update_status(): refund needs allow_refund, an active
// after-sale id, and a valid paid -> refunded transition.
if ($new_status !== 'refunded') return true;
if (empty($extra['allow_refund'])) return false;
$order = self::$orders[$order_number] ?? null;
if (!is_array($order) || (string) ($order['status'] ?? '') !== 'paid') return false;
self::$orders[$order_number]['status'] = $new_status;
return true;
}
}
class UserOrders {
private static $i;
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function decorate_order_view(array $o, bool $x): array { return ['product_name' => '测试商品']; }
}
}
namespace AKSRT\Core {
class DomainModel {
public const ORDER_STATUS_PENDING = 'pending';
public const ORDER_STATUS_PAID = 'paid';
public const ORDER_STATUS_REFUNDED = 'refunded';
public const ORDER_STATUS_CLOSED = 'closed';
public const ORDER_PRODUCT_RESOURCE = 'resource';
public static function resolve_order_product_type(string $t, array $m): string { return $t; }
public const ORDER_PRODUCT_READING = 'reading';
public const ORDER_PRODUCT_DOWNLOAD = 'download';
public const ORDER_PRODUCT_IMAGE = 'image';
public const ORDER_PRODUCT_VIDEO = 'video';
public const ORDER_PRODUCT_VIP = 'vip';
public const ORDER_PRODUCT_RECHARGE = 'recharge';
public const ORDER_PRODUCT_POINTS = 'points';
public static function order_product_type_labels(): array {
return [
self::ORDER_PRODUCT_READING => '付费阅读',
self::ORDER_PRODUCT_DOWNLOAD => '付费下载',
self::ORDER_PRODUCT_IMAGE => '付费图片',
self::ORDER_PRODUCT_VIDEO => '付费视频',
self::ORDER_PRODUCT_VIP => '购买会员',
self::ORDER_PRODUCT_RECHARGE => '余额充值',
self::ORDER_PRODUCT_POINTS => '购买积分',
];
}
public static function order_status_labels(): array {
return [
self::ORDER_STATUS_PENDING => '待支付',
self::ORDER_STATUS_PAID => '已支付',
self::ORDER_STATUS_REFUNDED => '已退款',
self::ORDER_STATUS_CLOSED => '已关闭',
];
}
public static function order_transitions(): array {
return [
self::ORDER_STATUS_PENDING => [self::ORDER_STATUS_PAID, self::ORDER_STATUS_CLOSED],
self::ORDER_STATUS_PAID => [self::ORDER_STATUS_REFUNDED],
self::ORDER_STATUS_REFUNDED => [],
self::ORDER_STATUS_CLOSED => [],
];
}
}
}
namespace AKSRT\Settings {
class PaymentRuntime {
public static function get_after_sale_settings(): array {
return ['enabled' => true, 'time_limit' => 7, 'reasons' => [['name' => '原因A']], 'materials' => ['截图']];
}
}
}
namespace {
require __DIR__ . '/../../src/Admin/Native/AdminListPagination.php';
require 'C:/Users/Xing/Desktop/AKSRT/AKSRT-theme/src/Admin/Native/PayOrders.php';
$scenario = $argv[1] ?? '';
// Seed data shared by the render pass and every handler scenario.
\AKSRT\Payment\AfterSaleService::$rows = [
5 => ['id' => 5, 'order_number' => 'ORD100', 'user_id' => 3, 'reason' => 'legacy-reason',
'apply_form' => ['reason_label' => '商品与描述不符', 'materials' => [['label' => '联系方式', 'value' => 'wx-abc']], 'description' => '说明文字'],
'images' => ['https://example.test/img1.png'], 'refund_amount' => '12.50', 'status' => 'pending',
'handling_log' => [
['status' => 'pending', 'note' => '', 'source' => 'user', 'created_at' => '2026-10-01 10:00:00'],
['status' => 'processing', 'note' => '已联系仓库核实库存', 'source' => 'admin', 'created_at' => '2026-10-01 14:20:00'],
],
'created_at' => '2026-10-01 10:00:00'],
6 => ['id' => 6, 'order_number' => 'ORD101', 'user_id' => 4, 'reason' => 'r2',
'apply_form' => null, 'images' => [], 'refund_amount' => '8.00', 'status' => 'processing',
'handling_log' => [
['status' => 'pending', 'note' => '', 'source' => 'user', 'created_at' => '2026-10-02 11:00:00'],
['status' => 'processing', 'note' => '资料已补齐,进入处理', 'source' => 'admin', 'created_at' => '2026-10-02 15:40:00'],
['status' => 'rejected', 'note' => '超出七天无理由范围,已驳回', 'source' => 'admin', 'created_at' => '2026-10-03 09:00:00'],
],
'created_at' => '2026-10-02 11:00:00'],
];
\AKSRT\Payment\OrderRepository::$orders = [
'ORD100' => ['order_number' => 'ORD100', 'user_id' => 3, 'product_id' => 9, 'product_type' => 'reading', 'meta_payload' => ['product_title' => '测试商品'], 'currency' => 'CNY', 'amount' => '12.50', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-01 09:00:00', 'paid_at' => '2026-10-01 09:05:00'],
'ORD101' => ['order_number' => 'ORD101', 'user_id' => 4, 'product_id' => 10, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '8.00', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-02 09:00:00', 'paid_at' => '2026-10-02 09:05:00'],
'ORD102' => ['order_number' => 'ORD102', 'user_id' => 5, 'product_id' => 11, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '20.00', 'payment_method' => 'balance', 'status' => 'refunded', 'created_at' => '2026-10-03 09:00:00', 'paid_at' => '2026-10-03 09:05:00'],
'ORD103' => ['order_number' => 'ORD103', 'user_id' => 6, 'product_id' => 12, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '5.00', 'payment_method' => 'balance', 'status' => 'pending', 'created_at' => '2026-10-04 09:00:00', 'paid_at' => ''],
'ORD104' => ['order_number' => 'ORD104', 'user_id' => 7, 'product_id' => 13, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '7.00', 'payment_method' => 'balance', 'status' => 'closed', 'created_at' => '2026-10-05 09:00:00', 'paid_at' => '2026-10-05 09:05:00'],
];
if ($scenario === 'orders') {
$_GET = ['page' => 'aksrt_orders'];
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
ob_start();
$pay->render_orders_page();
file_put_contents(__DIR__ . '/orders-rendered.html', ob_get_clean());
exit(0);
}
// 订单明细页:售后记录单元格按工单渲染完整处理记录。
if ($scenario === 'order-detail') {
$_GET = ['page' => 'aksrt_orders', 'order' => 'ORD100'];
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
ob_start();
$pay->render_orders_page();
file_put_contents(__DIR__ . '/order-detail-rendered.html', ob_get_clean());
exit(0);
}
if ($scenario !== '') {
// The handler ends in exit(), so the call log is flushed on shutdown.
register_shutdown_function(static function (): void {
echo json_encode(\HarnessLog::$events, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
echo 'rows=' . json_encode(array_map(static fn($r): string => (string) ($r['status'] ?? ''), \AKSRT\Payment\AfterSaleService::$rows), JSON_UNESCAPED_UNICODE) . "\n";
echo 'orders=' . json_encode(array_map(static fn($o): string => (string) ($o['status'] ?? ''), \AKSRT\Payment\OrderRepository::$orders), JSON_UNESCAPED_UNICODE) . "\n";
});
switch ($scenario) {
case 'unchecked':
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'admin_note' => '已退款'];
break;
case 'checked_paid':
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'mark_order_refunded' => '1', 'admin_note' => ''];
break;
case 'checked_already_refunded':
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'refunded';
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'mark_order_refunded' => '1', 'admin_note' => ''];
break;
case 'checked_unpaid_order':
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'pending';
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'mark_order_refunded' => '1', 'admin_note' => ''];
break;
case 'checked_closed_ticket':
\AKSRT\Payment\AfterSaleService::$rows[5]['status'] = 'completed';
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'cancelled', 'mark_order_refunded' => '1', 'admin_note' => ''];
break;
case 'bad_status':
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'bogus', 'admin_note' => ''];
break;
case 'refund_paid':
$_POST = ['order_number' => 'ORD100'];
break;
case 'refund_no_ticket':
unset(\AKSRT\Payment\AfterSaleService::$rows[5]);
$_POST = ['order_number' => 'ORD100'];
break;
case 'refund_inactive_ticket':
\AKSRT\Payment\AfterSaleService::$rows[5]['status'] = 'completed';
$_POST = ['order_number' => 'ORD100'];
break;
case 'refund_not_paid':
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'pending';
$_POST = ['order_number' => 'ORD100'];
break;
case 'refund_already_refunded':
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'refunded';
$_POST = ['order_number' => 'ORD100'];
break;
case 'refund_missing':
$_POST = ['order_number' => 'ORDNOPE'];
break;
default:
fwrite(STDERR, "unknown scenario: {$scenario}\n");
exit(2);
}
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
if (str_starts_with($scenario, 'refund')) {
$pay->handle_refund_order();
} else {
$pay->handle_after_sale_status();
}
// Unreachable: the handler ends in exit().
}
$_GET = ['page' => 'aksrt_after_sales'];
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
$ref = new ReflectionMethod($pay, 'render_after_sales_page');
ob_start();
$ref->invoke($pay);
$html = ob_get_clean();
file_put_contents(__DIR__ . '/after-sales-rendered.html', $html);
echo 'bytes=' . strlen($html) . "\n";
}
@@ -1,257 +0,0 @@
<?php
/**
* After-sale handling-log harness.
*
* Drives the REAL AfterSaleService against an in-memory $wpdb stand-in so the
* handling-log contract is covered end to end: every state submission appends one
* 「status + note + source + created_at」 entry, the log stays chronological, it is
* capped, and malformed rows normalize instead of fataling.
*
* Usage: php after-sale-log-harness.php -> prints a JSON result object.
*/
namespace {
define('ARRAY_A', 'ARRAY_A');
final class WP_Error {
public function __construct(private string $code, private string $message, private array $data = []) {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data(): array { return $this->data; }
}
define('ABSPATH', __DIR__ . '/');
// ---------- WP function stubs ----------
function __($s, $d = null) { return (string) $s; }
function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
function esc_url_raw($s) { return (string) $s; }
function sanitize_key($s) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $s)) ?? ''; }
function sanitize_text_field($s) { return trim(strip_tags((string) $s)); }
function sanitize_textarea_field($s) { return trim(strip_tags((string) $s)); }
function wp_json_encode($v, $f = 0) { return (string) json_encode($v, $f); }
function current_user_can($c) { return true; }
// Monotonic clock: every append must land on a strictly later timestamp so the
// chronological ordering assertion is meaningful.
$GLOBALS['aksrt_log_now'] = strtotime('2026-10-01 10:00:00');
function current_time($type = 'mysql', $gmt = false) {
$GLOBALS['aksrt_log_now'] += 1;
return gmdate('Y-m-d H:i:s', $GLOBALS['aksrt_log_now']);
}
// ---------- In-memory $wpdb ----------
final class FakeWpdb {
public string $prefix = 'wp_';
/** @var array<int,array<string,mixed>> keyed by after-sale id */
public array $rows = [];
public int $insert_id = 0;
private int $next_id = 100;
public function prepare(string $sql, ...$args) {
if (count($args) === 1 && is_array($args[0])) $args = $args[0];
$i = 0;
return preg_replace_callback('/%[sd]/', static function ($m) use (&$i, $args): string {
if ($i >= count($args)) return $m[0];
$value = $args[$i++];
return $m[0][1] === 'd' ? (string) (int) $value : "'" . str_replace("'", "''", (string) $value) . "'";
}, $sql) ?? $sql;
}
public function get_var(string $sql) {
if (preg_match("/GET_LOCK\('/", $sql)) return 1;
if (preg_match("/RELEASE_LOCK\('/", $sql)) return 1;
if (str_starts_with($sql, 'SELECT COUNT(*)')) return count($this->rows);
throw new RuntimeException('unsupported get_var: ' . $sql);
}
public function get_row(string $sql, $output = null) {
if (preg_match("/WHERE id = (\d+)$/", $sql, $m)) return $this->rows[(int) $m[1]] ?? null;
if (preg_match("/WHERE order_number = '([^']*)'/", $sql, $m)) {
$statuses = preg_match("/status IN \(([^)]*)\)/", $sql, $s) ? explode(',', str_replace("'", '', $s[1])) : null;
$found = null;
foreach ($this->rows as $row) {
if ((string) ($row['order_number'] ?? '') !== $m[1]) continue;
if ($statuses !== null && !in_array((string) ($row['status'] ?? ''), array_map('trim', $statuses), true)) continue;
if ($found === null || (int) $row['id'] > (int) $found['id']) $found = $row;
}
return $found;
}
throw new RuntimeException('unsupported get_row: ' . $sql);
}
public function get_results(string $sql, $output = null) {
if (preg_match("/WHERE order_number = '([^']*)'/", $sql, $m)) {
$rows = array_values(array_filter($this->rows, static fn($row): bool => (string) ($row['order_number'] ?? '') === $m[1]));
usort($rows, static fn($a, $b): int => (int) $b['id'] <=> (int) $a['id']);
return $rows;
}
throw new RuntimeException('unsupported get_results: ' . $sql);
}
public function insert(string $table, array $data, array $formats = []) {
$id = $this->next_id++;
$this->rows[$id] = $data + ['id' => $id];
$this->insert_id = $id;
return 1;
}
public function update(string $table, array $data, array $where, array $formats = [], array $where_formats = []) {
$id = (int) ($where['id'] ?? 0);
if (!isset($this->rows[$id])) return false;
$this->rows[$id] = array_merge($this->rows[$id], $data);
return 1;
}
}
// ---------- Domain stubs the service collaborates with ----------
require __DIR__ . '/../../src/Ledger/AssetType.php';
require __DIR__ . '/../../src/Ledger/AssetAmount.php';
}
namespace AKSRT\Core {
final class DBMigrator {
public static function table(string $name): string { return 'wp_aksrt_' . $name; }
}
}
namespace AKSRT\Payment {
final class Order {
public const STATUS_PAID = 'paid';
}
final class OrderRepository {
private static $i;
/** @var array<string,array<string,mixed>> keyed by order number */
public static array $orders = [];
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function get_user_order(int $user_id, string $order_number): ?array {
return self::$orders[$order_number] ?? null;
}
}
require __DIR__ . '/../../src/Payment/AfterSaleService.php';
}
namespace {
$GLOBALS['aksrt_wpdb'] = new FakeWpdb();
// The service reaches for the real global $wpdb, so the stand-in has to live there.
$GLOBALS['wpdb'] = $GLOBALS['aksrt_wpdb'];
/** @return list<string> */
function run(): array {
$wpdb = $GLOBALS['aksrt_wpdb'];
$service = \AKSRT\Payment\AfterSaleService::get_instance();
$order = ['order_number' => '20261001100000000001', 'user_id' => 7, 'status' => 'paid', 'currency' => 'CNY', 'amount' => '12.50'];
\AKSRT\Payment\OrderRepository::$orders = [$order['order_number'] => $order];
// 1) create() seeds the log with the user's own submission.
$wpdb->rows = [];
$created = $service->create(7, $order['order_number'], '商品与描述不符');
if ($created instanceof \WP_Error) return ['error' => 'create failed: ' . $created->get_error_message()];
$log = $created['handling_log'];
$results = [];
$results[] = ['name' => 'create_seeds_single_user_entry', 'ok' => count($log) === 1
&& $log[0]['status'] === 'pending'
&& $log[0]['source'] === 'user'
&& $log[0]['note'] === ''
&& $log[0]['created_at'] === $created['created_at'],
'detail' => $log];
// 2) Every admin submission appends, even with an empty note; order is chronological.
$service->set_status((int) $created['id'], 'processing', '已联系仓库核实');
$service->set_status((int) $created['id'], 'completed', '已原路退款');
$service->set_status((int) $created['id'], 'rejected', '');
$log = $service->get((int) $created['id'])['handling_log'];
$results[] = ['name' => 'status_changes_append_even_without_note', 'ok' => count($log) === 4
&& $log[1] === ['status' => 'processing', 'note' => '已联系仓库核实', 'source' => 'admin', 'created_at' => $log[1]['created_at']]
&& $log[2]['status'] === 'completed' && $log[2]['note'] === '已原路退款'
&& $log[3]['status'] === 'rejected' && $log[3]['note'] === ''
&& $log[0]['created_at'] < $log[1]['created_at']
&& $log[1]['created_at'] < $log[2]['created_at']
&& $log[2]['created_at'] < $log[3]['created_at'],
'detail' => $log];
// 3) A user-side cancel is logged as a user entry.
$wpdb->rows = [];
$second = $service->create(7, $order['order_number'], '拍错了');
$service->cancel_for_user(7, $order['order_number']);
$log = $service->get((int) $second['id'])['handling_log'];
$last = end($log);
$results[] = ['name' => 'cancel_appends_user_entry', 'ok' => count($log) === 2
&& $last['status'] === 'cancelled' && $last['source'] === 'user' && $last['note'] === '',
'detail' => $log];
// 4) The log is capped, keeping the most recent entries.
$wpdb->rows = [];
$seeded = [];
for ($i = 1; $i <= 50; $i++) {
$seeded[] = ['status' => 'processing', 'note' => 'seed-' . $i, 'source' => 'admin', 'created_at' => sprintf('2026-10-01 10:%02d:00', $i)];
}
$wpdb->rows[900] = ['id' => 900, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'processing', 'handling_log' => wp_json_encode($seeded), 'created_at' => '2026-10-01 10:01:00'];
$service->set_status(900, 'completed', '第五十条');
$log = $service->get(900)['handling_log'];
$results[] = ['name' => 'log_is_capped_at_50', 'ok' => count($log) === 50
&& $log[0]['note'] === 'seed-2'
&& end($log)['note'] === '第五十条',
'detail' => ['count' => count($log), 'first' => $log[0]['note'], 'last' => end($log)['note']]];
// 5) Malformed rows normalize instead of fataling.
$wpdb->rows[901] = ['id' => 901, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'processing',
'handling_log' => wp_json_encode([
['status' => 'pending', 'note' => 'ok', 'source' => 'user', 'created_at' => '2026-10-01 10:00:00'],
['status' => '', 'note' => 'no status', 'source' => 'admin', 'created_at' => '2026-10-01 10:05:00'],
['note' => 'not an array value', 'source' => 'admin', 'created_at' => '2026-10-01 10:06:00'],
'garbage',
['status' => 'completed', 'source' => 'mystery', 'created_at' => '2026-10-01 10:07:00'],
])];
$wpdb->rows[902] = ['id' => 902, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'pending', 'handling_log' => 'not-json'];
$normalized = $service->get(901)['handling_log'];
$broken = $service->get(902)['handling_log'];
$results[] = ['name' => 'normalize_drops_malformed_entries', 'ok' => count($normalized) === 2
&& $normalized[0]['status'] === 'pending'
&& $normalized[1] === ['status' => 'completed', 'note' => '', 'source' => 'admin', 'created_at' => '2026-10-01 10:07:00']
&& $broken === [],
'detail' => ['normalized' => $normalized, 'broken_json' => $broken]];
// 6) 再次申请的闸门只看「进行中」的工单:completed / rejected / cancelled 都放行,
// 订单有没有另标已退款不影响这条(订单已退款时 create() 的 STATUS_PAID 校验先挡)。
$gate = static function (string $status) use ($service, $wpdb, $order) {
$wpdb->rows = [];
$wpdb->rows[950] = ['id' => 950, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => $status,
'reason' => '旧申请', 'handling_log' => wp_json_encode([['status' => $status, 'note' => '', 'source' => 'admin', 'created_at' => '2026-10-01 10:00:00']]),
'created_at' => '2026-10-01 10:00:00'];
return $service->create(7, $order['order_number'], '新的申请原因');
};
$completed = $gate('completed');
$rejected = $gate('rejected');
$cancelled = $gate('cancelled');
$results[] = ['name' => 'settled_ticket_still_allows_reapply', 'ok' => !$completed instanceof \WP_Error
&& !$rejected instanceof \WP_Error
&& !$cancelled instanceof \WP_Error,
'detail' => [
'completed' => $completed instanceof \WP_Error ? $completed->get_error_code() : 'created',
'rejected' => $rejected instanceof \WP_Error ? $rejected->get_error_code() : 'created',
'cancelled' => $cancelled instanceof \WP_Error ? $cancelled->get_error_code() : 'created',
]];
// 待审核:同人同因幂等返回原工单,换原因则挡住
$wpdb->rows = [];
$wpdb->rows[951] = ['id' => 951, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'pending',
'reason' => '同一个原因', 'handling_log' => '[]', 'created_at' => '2026-10-01 10:00:00'];
$same = $service->create(7, $order['order_number'], '同一个原因');
$other = $service->create(7, $order['order_number'], '换了个原因');
$results[] = ['name' => 'open_ticket_blocks_duplicate_apply', 'ok' => is_array($same)
&& (int) $same['id'] === 951
&& $other instanceof \WP_Error
&& $other->get_error_code() === 'aksrt_after_sale_exists',
'detail' => ['same' => is_array($same) ? (int) $same['id'] : 'error', 'other' => $other instanceof \WP_Error ? $other->get_error_code() : 'created']];
return $results;
}
$results = run();
$failed = array_values(array_filter($results, static fn($r): bool => empty($r['ok'])));
echo json_encode(['passed' => count($results) - count($failed), 'failed' => count($failed), 'results' => $results], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
exit($failed ? 1 : 0);
}
@@ -1,574 +0,0 @@
'use strict'
/**
* 「售后策略」功能契约:
* 1) 后台 schema(PHP SchemaPayment after_sale 分区)与 SPA JS schema 的键、顺序、
* 类型、默认值 1:1 对齐,且 schema 与侧边页均已注册(/payment/after-sale 可达);
* 2) 运行时(PaymentRuntime)缺键回退 schema 默认、HTML 走 kses、时效边界正确;
* 3) 售后工单:images 列、create 图片清洗与上限、上传路由权限、latest_for_order;
* 4) 订单视图模型输出 after_sale 状态(按钮矩阵的唯一数据源);
* 5) 前台:申请/超时效/售后详细三个独立弹窗、modal-mini + 蓝色炫彩头、placeholder 与
* 图片上传配置化、不可再申请提示原文、订单详情弹窗不再内嵌售后表单(死代码清理)。
*/
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 read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
const FIELD_ORDER = [
'after_sale_s',
'after_sale_time_limit',
'after_sale_expired_text',
'after_sale_reasons',
'after_sale_desc',
'after_sale_materials',
'after_sale_reason_placeholder',
'after_sale_img_s',
]
/** Extract the balanced [...] block assigned to a PHP array key inside a section body. */
function phpSection(src, sectionKey) {
const start = src.indexOf(`'${sectionKey}' => [`)
assert.notEqual(start, -1, `PHP schema has a ${sectionKey} section`)
const open = src.indexOf('[', start)
let depth = 0
for (let i = open; i < src.length; i++) {
const ch = src[i]
if (ch === "'" || ch === '"') {
const quote = ch
i++
while (i < src.length) {
if (src[i] === '\\') { i += 2; continue }
if (src[i] === quote) break
i++
}
continue
}
if (ch === '[') depth++
else if (ch === ']') {
depth--
if (depth === 0) return src.slice(open, i + 1)
}
}
throw new Error(`unbalanced PHP section: ${sectionKey}`)
}
test('after-sale schema is 1:1 between PHP and SPA', () => {
const php = phpSection(read('src/Admin/Schema/SchemaPayment.php'), 'after_sale')
const js = read('assets/admin/src/schema/sections/payment/afterSale.js')
assert.match(php, /'key'\s*=>\s*'after_sale'/)
assert.match(php, /'title'\s*=>\s*'售后策略'/)
assert.match(php, /'option_key'\s*=>\s*'aksrt_settings'/)
// 字段键、顺序、类型一致(JS 侧 defaults 与 groups 同序,按首次出现去重)
const phpKeys = [...php.matchAll(/'key'\s*=>\s*'([a-z0-9_]+)'/g)].map((m) => m[1])
const jsKeys = [...js.matchAll(/key:\s*'([a-z0-9_]+)'/g)].map((m) => m[1])
const sectionKeys = (keys) => [...new Set(keys.filter((key) => FIELD_ORDER.includes(key)))]
assert.deepEqual(sectionKeys(phpKeys), FIELD_ORDER)
assert.deepEqual(sectionKeys(jsKeys), FIELD_ORDER)
for (const [phpType, jsType] of [
['switch', 'switch'],
['number', 'number'],
['textarea', 'textarea'],
['group', 'group'],
['input', 'input'],
]) {
assert.ok(php.includes(`'type' => '${phpType}'`), `missing PHP field type ${phpType}`)
assert.ok(js.includes(`type: '${jsType}'`), `missing JS field type ${jsType}`)
}
// 默认值一致(开关/时效/占位文案/预置原因/所需材料)
assert.match(php, /'after_sale_s', 'label' => '售后功能', 'type' => 'switch', 'default' => true/)
assert.match(js, /after_sale_s:\s*true/)
assert.match(js, /key: 'after_sale_s', label: '售后功能', type: 'switch', default: true/)
assert.match(php, /'after_sale_time_limit', 'label' => '申请售后时效\(天\)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 3650/)
assert.match(js, /key: 'after_sale_time_limit', label: '申请售后时效\(天\)', type: 'number', default: 7, min: 0, max: 3650/)
assert.match(php, /'after_sale_reason_placeholder', 'label' => '申请说明占位文案', 'type' => 'input', 'default' => '必填,请您详细填写申请原因'/)
assert.match(js, /key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因'/)
assert.match(php, /'after_sale_img_s', 'label' => '申请售后可上传图片', 'type' => 'switch', 'default' => true/)
assert.match(js, /key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true/)
for (const reason of ['质量问题', '假冒品牌', '其他原因']) {
assert.ok(php.includes(`'t' => '${reason}'`), `missing PHP preset reason ${reason}`)
assert.ok(js.includes(`{ t: '${reason}' }`), `missing JS preset reason ${reason}`)
}
for (const material of ['联系邮箱', '联系电话']) {
assert.ok(php.includes(`'t' => '${material}'`), `missing PHP material ${material}`)
assert.ok(js.includes(`{ t: '${material}' }`), `missing JS material ${material}`)
}
// HTML 配置项(超时效提示 / 申请说明)走 kses 通道
assert.match(php, /'after_sale_expired_text', 'label' => '超过时效提示', 'type' => 'textarea', 'sanitize_type' => 'html'/)
assert.match(php, /'after_sale_desc', 'label' => '申请售后说明', 'type' => 'textarea', 'sanitize_type' => 'html'/)
assert.match(js, /key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html'/)
assert.match(js, /key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html'/)
})
test('after-sale schema and sidebar page are registered under 支付&付费', () => {
const schemaIndex = read('assets/admin/src/schema/sections/payment/index.js')
assert.match(schemaIndex, /import \{ afterSaleSchema \} from '\.\/afterSale\.js'/)
assert.match(schemaIndex, /after_sale:\s*afterSaleSchema/)
const section = read('assets/admin/src/constants/sections/payment.js')
assert.match(section, /key: 'payment-after-sale'/)
assert.match(section, /name: 'PaymentAfterSale'/)
assert.match(section, /label: '售后策略'/)
assert.match(section, /route: '\/payment\/after-sale'/)
assert.match(section, /canonicalDomain: 'payment'/)
assert.match(section, /section: 'after_sale'/)
// 侧栏条目位于提现策略之后(支付&付费末位)
assert.ok(section.indexOf("key: 'payment-after-sale'") > section.indexOf("key: 'payment-withdrawal'"))
const schemaRegistry = read('assets/admin/src/schema/index.js')
assert.match(schemaRegistry, /paymentSchemas/)
})
test('after-sale runtime resolves defaults, overrides and the expiry boundary matrix', () => {
const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-policy-harness.php')], {
cwd: root,
encoding: 'utf8',
})
assert.equal(result.status, 0, result.stderr || result.stdout)
const payload = JSON.parse(result.stdout)
assert.deepEqual(payload.defaults, {
enabled: true,
time_limit: 7,
expired_text: '<div>抱歉,您的订单已超过售后申请时效</div>',
reasons: [
{ slug: 'c440cdf71825', name: '质量问题' },
{ slug: '2ead35a456a0', name: '假冒品牌' },
{ slug: 'fba26eab6b2b', name: '其他原因' },
],
materials: ['联系邮箱', '联系电话'],
desc: '',
img_enabled: true,
reason_placeholder: '必填,请您详细填写申请原因',
})
assert.equal(payload.overridden.enabled, false)
assert.equal(payload.overridden.time_limit, 30)
assert.equal(payload.overridden.img_enabled, false)
assert.equal(payload.overridden.reason_placeholder, '请描述问题')
assert.deepEqual(payload.overridden.reasons, [
{ slug: 'c440cdf71825', name: '质量问题' },
{ slug: '327e0ab68567', name: '发错货' },
])
assert.deepEqual(payload.overridden.materials, ['联系邮箱'])
// HTML 配置项剥离 script,保留 div
assert.equal(payload.overridden.expired_text, '<div>已超过时效</div>')
assert.equal(payload.overridden.desc, '<div>请保持商品完好</div>')
assert.deepEqual(payload.expired_ok, {
within: true,
exact_boundary: true,
just_expired: true,
long_expired: true,
no_limit: true,
missing_paid: true,
})
})
test('after-sale tickets persist evidence images and expose the latest row', () => {
const schema = read('src/Core/DBMigratorPart2.php')
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?images LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/)
// images 列随 schema 2.5.1 引入:dbDelta 只在版本变化时重跑,列变更必须同步升版本,
// 否则老安装不会 ALTER TABLE,写入直接报 Unknown column 'images'。
const migrator = read('src/Core/DBMigrator.php')
const version = migrator.match(/const SCHEMA_VERSION = '(\d+)\.(\d+)\.(\d+)'/)
assert.ok(version, 'DBMigrator exposes a numeric SCHEMA_VERSION')
const [major, minor, patch] = version.slice(1).map(Number)
assert.ok(
major > 2 || (major === 2 && minor > 5) || (major === 2 && minor === 5 && patch >= 1),
`SCHEMA_VERSION must be at least 2.5.1 after adding order_aftersale.images, got ${major}.${minor}.${patch}`
)
const service = read('src/Payment/AfterSaleService.php')
assert.match(service, /public function create\(int \$user_id, string \$order_number, string \$reason, array \$images = \[\], array \$apply_form = \[\]\)/)
// 结构化申请表单:预置原因 / 申请资料 / 申请说明独立入库(reason 融合串保持原样)
assert.match(service, /\$apply_form = \$this->sanitize_apply_form\(\$apply_form\);/)
assert.match(service, /private function sanitize_apply_form\(array \$form\): array/)
assert.match(service, /'apply_form' => \$apply_form === \[\] \? null : wp_json_encode\(\$apply_form/)
assert.match(service, /\$images = \$this->sanitize_images\(\$images\);/)
assert.match(service, /'images' => wp_json_encode\(\$images\)/)
assert.match(service, /private function sanitize_images\(array \$images\): array \{[\s\S]*?count\(\$clean\) >= 3[\s\S]*?esc_url_raw/)
assert.match(service, /public function latest_for_order\(string \$order_number\): \?array \{[\s\S]*?ORDER BY id DESC LIMIT 1/)
// normalize 输出 images 数组(坏 JSON / 空串回落空数组)
assert.match(service, /json_decode\(\(string\) \(\$row\['images'\] \?\? ''\), true\)/)
// 图片上传路由:登录 + nonce + 售后/图片开关 + 订单归属;回调委托评论图片上传
assert.match(service, /\/orders\/' \. \$number \. '\/after-sale\/upload-image'/)
assert.match(service, /public function require_after_sale_image\(\\WP_REST_Request \$request\)/)
assert.match(service, /get_after_sale_settings\(\)[\s\S]*?empty\(\$settings\['enabled'\]\)[\s\S]*?empty\(\$settings\['img_enabled'\]\)/)
assert.match(service, /get_user_order\(get_current_user_id\(\), \$number\)/)
assert.match(service, /UserController::get_instance\(\)->rest_upload_comment_image\(\$request\)/)
assert.match(service, /'images' => \['required' => false, 'type' => 'array'[\s\S]*?count\(\$value\) <= 3/)
// 提交 REST 透传 images
// rest_submit 同步上报结构化申请表单(reason_label / materials / description)
assert.match(service, /'reason_label' => \(string\) \$request->get_param\('reason_label'\),/)
assert.match(service, /'materials' +\=> is_array\(\$materials\) \? \$materials : \[\],/)
assert.match(service, /'description' +\=> \(string\) \$request->get_param\('description'\),/)
// 既有售后资产契约(锁 / 退款 / 完成)不受影响
assert.match(service, /GET_LOCK\(%s, 5\)/)
assert.match(service, /Order::STATUS_PAID/)
})
test('order view model exposes the after-sale button matrix', () => {
const userOrders = read('src/Payment/UserOrders.php')
assert.match(userOrders, /private function after_sale_state\(string \$group, string \$order_number, string \$paid_at, \?AfterSaleService \$service, array \$records = \[\]\): array \{/)
assert.match(userOrders, /'after_sale' => \$after_sale_state,/)
assert.match(userOrders, /'enabled'\s*=> \$enabled,/)
assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/)
assert.match(userOrders, /'has_application' => \$has_application,/)
assert.match(userOrders, /'open'\s*=> \$open,/)
// can_apply 只问「有没有进行中的工单」:completed 不挡,能否再售后由订单是否已退款决定
assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/)
// 可再次申请售后按工单状态推理:rejected / cancelled / completed 都允许再次申请;
// pending / processing / approved(流转中)不允许。订单已退款时 enabled 直接为 false,
// 上面那条自然挡死,不需要工单状态再判一次。
assert.match(userOrders, /\$reapplyable_statuses = \[\s*AfterSaleService::STATUS_REJECTED,\s*AfterSaleService::STATUS_CANCELLED,\s*AfterSaleService::STATUS_COMPLETED,\s*\];/)
assert.ok(!userOrders.includes('$reapplyable ='), 'can_apply must not re-check the ticket status')
assert.match(userOrders, /'can_reapply'\s*=> \$enabled && !\$expired && \$has_application && in_array\(\$latest_status, \$reapplyable_statuses, true\),/)
assert.match(userOrders, /'latest'\s*=> \$has_application \? \[/)
assert.match(userOrders, /'apply_form'\s*=> is_array\(\$latest\['apply_form'\] \?\? null\) \? \$latest\['apply_form'\] : null,/)
// 处理记录取代 reviewed_at / completed_at / admin_note 三个单点字段
assert.match(userOrders, /'handling_log'\s*=> array_values\(\(array\) \(\$latest\['handling_log'\] \?\? \[\]\)\),/)
// 仅已支付订单开放;开关关闭整体不处理售后
assert.match(userOrders, /\$enabled = \$group === OrderCenterQuery::STATUS_PAID && !empty\(\$settings\['enabled'\]\);/)
// 时效以支付时间为准
assert.match(userOrders, /\$this->after_sale_state\(\$group, \$order_number, \(string\) \(\$order\['paid_at'\] \?\? ''\), \$after_sale_service, \$after_sales\)/)
// 列表模式单行查询,详情模式复用全量工单
assert.match(userOrders, /\$after_sales = \$detail && \$after_sale_service \? \$after_sale_service->list_for_order\(\$order_number\) : \[\];/)
assert.match(userOrders, /\$latest = \$service->latest_for_order\(\$order_number\);/)
assert.match(userOrders, /\$view\['after_sales'\] = \$after_sales;/)
// 旧 can_after_sale 双路已移除
assert.ok(!userOrders.includes('can_after_sale'), 'can_after_sale must be replaced by the after_sale state')
})
test('frontend after-sale modals are standalone and config-driven', () => {
const js = read('assets/js/order-after-sale.js')
// 四个独立弹窗 + modal-mini 几何 + 蓝色炫彩标题区
assert.match(js, /className: 'aksrt-after-sale-apply-modal'/)
assert.match(js, /className: 'aksrt-after-sale-detail-modal'/)
assert.match(js, /className: 'aksrt-after-sale-expired-modal'/)
assert.match(js, /className: 'aksrt-after-sale-withdraw-modal'/)
assert.match(js, /modal-colorful-header colorful-bg jb-blue/)
assert.match(js, /AksrtModalRuntime\.open\(\{/)
// 滚动容器与禁封申诉弹窗同口径:no-scrollbar 完全隐藏滚动条(含细滚动条),
// 避免 modal-mini 弹窗边缘出现可见滚动栏。
assert.match(js, /'<div class="scroll-y no-scrollbar max-vh5">'/)
assert.match(js, /'<div class="scroll-y no-scrollbar max-vh5 aksrt-after-sale-detail__body">'/)
assert.ok(!js.includes('mini-scrollbar'), 'after-sale modals must not render a visible scrollbar')
// 申请表单:预置原因徽章、所需资料、必填申请说明(placeholder 取后台配置)
assert.match(js, /reasonOptions\(\)/)
assert.match(js, /materialOptions\(\)/)
assert.match(js, /data-aksrt-after-sale-reason=/)
assert.match(js, /data-aksrt-after-sale-material=/)
assert.match(js, /var placeholder = String\(cfg\.reasonPlaceholder \|\| '必填,请您详细填写申请原因'\)/)
assert.match(js, /name="reason"/)
assert.match(js, /notyf\('请填写申请说明', 'danger'\)/)
assert.match(js, /notyf\('请选择售后原因', 'danger'\)/)
// 图片上传:开关控制渲染,最多 3 张,上传/删除/提交
assert.match(js, /if \(cfg\.imgEnabled\) \{/)
assert.match(js, /MAX_IMAGES = 3/)
assert.match(js, /\/after-sale\/upload-image'/)
assert.match(js, /data-aksrt-after-sale-image-remove/)
assert.match(js, /images: collectImages\(shell\)/)
assert.match(js, /reason_label: selectedReasonLabel\(shell\)/)
assert.match(js, /materials: collectMaterials\(shell\)/)
assert.match(js, /description: reasonText,/)
// 申请说明(HTML)+ 超时效提示(HTML)由后台配置直出
assert.match(js, /var desc = String\(cfg\.desc \|\| ''\)/)
assert.match(js, /var text = String\(settings\(\)\.expiredText \|\| '抱歉,您的订单已超过售后申请时效'\)/)
// 售后详细:状态徽章 / 明细行 / 图片缩略图;不可再申请提示原文 + 我知道了
// 申请表单分行展示:原因 / 申请资料 / 申请说明各自独立成行(与申请时间同格式);
// 老数据无 apply_form 时兜底展示融合串
assert.match(js, /detailRow\('原因', String\(applyForm\.reason_label \|\| ''\)\)/)
assert.match(js, /detailRow\('申请说明', String\(applyForm\.description \|\| ''\)\)/)
assert.match(js, /STATUS_LABELS = \{[\s\S]*?pending: '待审核'[\s\S]*?completed: '已完成',/)
// 不可再申请:不再展示阻断文案,仅保留关闭入口;可再申请:仅保留单个主按钮(无「关闭」)
assert.ok(!js.includes('暂不支持再次申请售后'), 'blocked notice copy must be removed')
assert.ok(!js.includes('REAPPLY_BLOCKED_TEXT'), 'blocked text constant must be removed')
assert.ok(!js.includes('>关闭</button>'), 'detail modal must not render a 关闭 button')
assert.match(js, /data-aksrt-modal-close>我知道了</)
assert.match(js, /data-aksrt-after-sale-reapply data-aksrt-after-sale-payload="/)
// 再次申请售后:can_reapply 必然伴随 has_application=true,入口点击须清除标记,
// 否则 openApplyFlow 的 has_application 分流会把用户弹回「售后详细」打转
assert.match(js, /payload\.after_sale\.has_application = false[\s\S]{0,120}openApplyFlow\(payload\)/)
// footer 平铺按钮:相邻按钮分界线(legacy .but + .but 分隔在家族内还原,四个售后弹窗同款)
// + 悬停反馈(参考 Element 卡片 footer:整块区域浅灰底 + 文字转主色,主按钮 hover 略加深)
const modalCss = read('assets/css/frontend/modal-runtime.css')
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts\s*\{[^}]*padding:\s*0;/)
// footer 不留垂直内边距:上下 12px 并入按钮(min-height 62 = 12+38+12),按钮盒=整个底部区域
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but,[\s\S]{0,260}min-height:\s*62px/)
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but \+ \.but\s*\{[^}]*border-left:\s*1px solid var\(--modal-line-soft/)
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but:hover\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--modal-ink/)
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but\.c-blue:hover\s*\{[^}]*color:\s*color-mix/)
// 撤销售后:入口仅随待审核状态渲染,点击先过二次确认弹窗,确认后 DELETE 工单
assert.match(js, /status === 'pending'[\s\S]*?data-aksrt-after-sale-withdraw="' \+ escapeHtml\(\(order && order\.order_number\) \|\| ''\)/)
assert.match(js, /buildWithdrawHtml\(orderNumber\)/)
assert.match(js, /className: 'aksrt-after-sale-withdraw-modal'/)
assert.match(js, /data-aksrt-after-sale-withdraw-confirm="' \+ escapeHtml\(orderNumber\) \+ '"/)
assert.match(js, /method: 'DELETE'/)
assert.match(js, /AksrtModalRuntime\.close\('withdraw'\)/)
assert.match(js, /notyf\('售后申请已撤销', 'success'\)/)
// 人机验证:提交与撤销前先过验证(场景未启用时挂载点缺失,run 直接放行)
assert.match(js, /function runCaptcha\(scene\)/)
assert.match(js, /runCaptcha\('after_sale_apply'\)/)
assert.match(js, /runCaptcha\('after_sale_cancel'\)/)
assert.match(js, /AKSRTCaptcha\.run\(scene\)/)
// 提交后关闭弹窗并刷新订单列表
assert.match(js, /AKSRTOrderCenter\.refresh\(orderNumber\)/)
assert.match(js, /window\.AksrtModalRuntime\.close\('submit'\)/)
// 入口委托:申请 / 售后详细
assert.match(js, /data-aksrt-after-sale-apply/)
assert.match(js, /data-aksrt-after-sale-detail/)
assert.match(js, /auto_fun\.aksrt-after-sale/)
})
test('order center drops the embedded after-sale form and exposes refresh()', () => {
const js = read('assets/js/order-center.js')
// 内嵌售后表单与两个 submit 处理器已彻底移除(死代码清理)
assert.ok(!js.includes('data-aksrt-after-sale-form'), 'embedded after-sale form must be removed')
assert.ok(!js.includes('提交售后申请'), 'embedded after-sale submit copy must be removed')
assert.ok(!js.includes('onDocumentSubmit'), 'document submit handler for the after-sale form must be removed')
// 列表卡片与订单详情弹窗底部共用新的售后按钮
assert.match(js, /function afterSaleButtons\(order\)/)
assert.match(js, /function afterSalePayload\(order\)/)
assert.match(js, /data-aksrt-after-sale-detail="' \+ payload \+ '"/)
assert.match(js, /data-aksrt-after-sale-apply="' \+ payload \+ '"/)
assert.match(js, /if \(afterSale\.enabled\) html \+= afterSaleButtons\(order\);/)
assert.ok(!js.includes('\n html += afterSaleButtons(order);\n'), 'detail modal footer must not render the shared after-sale buttons')
// 底部按钮与禁封申诉弹窗同式(but-average modal-buts);「售后详细」入口收拢到记录卡片
assert.match(js, /aksrt-order-detail__actions but-average modal-buts/)
assert.match(js, /data-aksrt-after-sale-apply="' \+ afterSalePayload\(order\) \+ '"/)
// 底部「关闭」与售后详细「我知道了」同款(but c-blue,平铺观感由 orders.css 呈现)
assert.match(js, /class="but c-blue" data-aksrt-modal-close>关闭</)
assert.ok(js.includes('查看详细</button>'), 'record card has a view-detail entry')
assert.match(js, /data-aksrt-after-sale-detail="' \+ recordPayload \+ '"/)
// 记录卡片:原因 / 申请理由分行(新数据走 apply_form,老数据兜底融合串)
assert.ok(js.includes('aksrt-order-detail__after-sale-field">原因:'), 'record card shows 原因:')
assert.ok(js.includes('aksrt-order-detail__after-sale-field">申请理由:'), 'record card shows 申请理由:')
assert.match(js, /data-aksrt-after-sale-detail="/)
assert.match(js, /data-aksrt-after-sale-apply="/)
// 订单详情弹窗 CSS:记录卡片「查看详细」foot 右对齐;底部按钮与售后弹窗家族
// 同款平铺观感(modal-runtime 同款规则镜像到吸底 footer),单按钮占满整行。
const ordersCss = read('assets/css/routes/orders.css')
assert.match(ordersCss, /\.aksrt-order-detail__after-sale-foot\s*\{[^}]*justify-content:\s*flex-end/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\s*\{[^}]*background:\s*transparent/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\.c-blue\s*\{[^}]*color:\s*var\(--modal-primary/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but:only-child\s*\{[^}]*grid-column:\s*1 \/ -1/)
// 分界线与悬停阴影与售后弹窗家族同款(分隔色用本弹窗 footer 同源的 --detail-line);
// footer 零垂直内边距、按钮满高(min-height 64 = 13+38+13),按钮盒=整个底部区域,安全区留 footer 底部
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\s*\{[^}]*padding:\s*0 0 env\(safe-area-inset-bottom/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\s*\{[^}]*min-height:\s*64px/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but \+ \.but\s*\{[^}]*border-left:\s*1px solid var\(--detail-line\)/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but:hover\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--modal-ink/)
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\.c-blue:hover\s*\{[^}]*color:\s*color-mix/)
// 售后记录区保留在订单详情弹窗:记录卡片带申请时间,「查看详细」逐条入口;
// 取消申请按钮与处理器已移除(撤销入口移入售后详细弹窗)。
assert.match(js, /售后记录/)
assert.match(js, /<time>" \+ escapeHtml\(item\.created_at\)/)
assert.ok(!js.includes('data-aksrt-after-sale-cancel'), 'cancel entry moved into the after-sale detail modal')
assert.ok(!js.includes('取消申请'), 'order detail must not render the legacy cancel button')
// refresh 供售后弹窗提交后刷新列表与统计
assert.match(js, /function refresh\(orderNumber\) \{/)
assert.match(js, /window\.AKSRTOrderCenter = \{ init: init, destroy: destroy, refresh: refresh \}/)
assert.match(js, /liveStates\.forEach\(function \(state\) \{/)
})
test('after-sale assets, template buttons and runtime data are wired', () => {
const scripts = read('src/Core/ScriptsPart1b.php')
assert.match(scripts, /'aksrt-order-after-sale'/)
assert.match(scripts, /\/assets\/js\/order-after-sale\.js'/)
const scripts1c = read('src/Core/ScriptsPart1c.php')
assert.match(scripts1c, /'afterSale'\s*=> \(function \(\) \{/)
assert.match(scripts1c, /PaymentRuntime::get_after_sale_settings\(\)/)
assert.match(scripts1c, /'maxImages'\s*=> 3/)
const template = read('inc/helpers/user-center-renderers/profile-orders.php')
assert.match(template, /\$after_sale = is_array\(\$order\['after_sale'\] \?\? null\) \? \$order\['after_sale'\] : \[\];/)
// 载荷随按钮渲染(order-after_sale.js 同步消费),点击即弹窗、无前置请求
assert.match(template, /\$after_sale_payload = wp_json_encode\(\[/)
assert.match(template, /'after_sale'\s*=> \$after_sale,/)
assert.match(template, /data-aksrt-after-sale-detail="<\?php echo esc_attr\(\$after_sale_payload\);/)
assert.match(template, /data-aksrt-after-sale-apply="<\?php echo esc_attr\(\$after_sale_payload\);/)
assert.match(template, /esc_html_e\('售后详细', 'aksrt'\)/)
assert.ok(!template.includes('can_after_sale'), 'template must not use the removed can_after_sale flag')
// 售后人机验证挂载点(申请 / 撤销):场景未启用时 aksrt_render_captcha 无输出
assert.match(template, /function_exists\('aksrt_render_captcha'\)/)
assert.match(template, /aksrt_render_captcha\('after_sale_apply'\)/)
assert.match(template, /aksrt_render_captcha\('after_sale_cancel'\)/)
const css = read('assets/css/frontend/modal-runtime.css')
assert.match(css, /\.aksrt-after-sale-apply-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /\.aksrt-after-sale-detail-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /\.aksrt-after-sale-expired-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /\.aksrt-after-sale-withdraw-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /width: min\(400px, calc\(100vw - 32px\)\)/)
assert.match(css, /\.aksrt-after-sale-apply-modal \.modal-colorful-header/)
assert.match(css, /\.aksrt-after-sale-apply-modal \.colorful-bg > \.colorful-make/)
// 弹窗底部按钮:四个售后弹窗统一全宽平铺贴底,子按钮去独立胶囊观感(覆盖全局按钮网格)
for (const modal of ['apply', 'detail', 'expired', 'withdraw']) {
assert.match(css, new RegExp(`\\.aksrt-after-sale-${modal}-modal \\.but-average\\.modal-buts[\\s\\S]*?margin: 16px -18px -16px`))
assert.match(css, new RegExp(`\\.aksrt-after-sale-${modal}-modal \\.but-average\\.modal-buts > \\.but[\\s\\S]*?border-radius: 0`))
}
assert.match(css, /\.but:only-child \{ grid-column: 1 \/ -1; \}/)
// 撤销售后入口:待审核状态行内的文字式入口按钮
assert.match(css, /\.aksrt-after-sale-detail__statusCell/)
assert.match(css, /\.aksrt-after-sale-withdraw \{[^}]*background: transparent/)
const field = read('assets/admin/src/components/form/AksField.vue')
assert.match(field, /import draggable from 'vuedraggable'/)
assert.match(field, /v-model="repeaterDragList"/)
assert.match(field, /handle="\.aksrt-field__repeater-handle"/)
assert.match(field, /function repeaterDragKey\(item\)/)
})
test('every after-sale submission is logged with a status, note, source and time', () => {
const service = read('src/Payment/AfterSaleService.php')
// 记录落在工单行内,按时间正序追加,超过上限只保留最近的一段
assert.match(service, /private const MAX_LOG_ENTRIES = 50;/)
assert.match(service, /private function append_log\(int \$id, array \$current, string \$status, string \$note, string \$source\): void \{/)
assert.match(service, /array_slice\(\$log, -self::MAX_LOG_ENTRIES\)/)
assert.match(service, /JSON_UNESCAPED_UNICODE \| JSON_UNESCAPED_SLASHES/)
assert.match(service, /'handling_log' => \$this->encode_log\(\[\$this->log_entry\(self::STATUS_PENDING, '', self::SOURCE_USER, \$now\)\]\),/)
assert.match(service, /public const SOURCE_ADMIN = 'admin';/)
assert.match(service, /public const SOURCE_USER = 'user';/)
// 三条写入路径都必须留痕:用户申请 / 用户撤销 / 后台改状态
assert.match(service, /create\(int \$user_id[\s\S]*?'handling_log'/)
assert.match(service, /cancel_for_user\([\s\S]*?append_log\(\(int\) \$row\['id'\], \$row, self::STATUS_CANCELLED, '', self::SOURCE_USER\)/)
assert.match(service, /set_status\([\s\S]*?append_log\(\$id, \$row, \$status, \$note, self::SOURCE_ADMIN\)/)
// set_status 现在是「读-改-写」JSON,必须与 create/cancel 共用订单锁
assert.match(service, /set_status\(int \$id, string \$status, string \$note = ''\)[\s\S]*?GET_LOCK\(%s, 5\)[\s\S]*?RELEASE_LOCK\(%s\)/)
// 单点备注 / 单点时间戳 / 从未写入的渠道字段全部移除
for (const dead of ['admin_note', 'reviewed_at', 'completed_at', 'gateway_result', 'gateway_request_id', 'gateway_refund_id']) {
assert.ok(!service.includes(dead), `${dead} must be removed from AfterSaleService`)
}
// 规范化:畸形条目丢弃,未知 source 归 admin,非数组整体视为空
assert.match(service, /private function normalize_log\(\$log\): array \{/)
assert.match(service, /\$row\['handling_log'\] = \$this->normalize_log\(json_decode\(\(string\) \(\$row\['handling_log'\] \?\? ''\), true\)\);/)
// 退款金额工具在 create() 里被调用,不能再次缺失(88669f69 曾误删导致 fatal)
assert.match(service, /private function refund_amount\(array \$order\): \?string \{/)
})
test('handling-log behaviour is covered against the real service', () => {
const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-log-harness.php')], {
cwd: root,
encoding: 'utf8',
})
assert.equal(result.status, 0, result.stderr || result.stdout)
const payload = JSON.parse(result.stdout)
assert.equal(payload.failed, 0, JSON.stringify(payload.results))
const names = payload.results.map((entry) => entry.name)
for (const name of [
'create_seeds_single_user_entry',
'status_changes_append_even_without_note',
'cancel_appends_user_entry',
'log_is_capped_at_50',
'normalize_drops_malformed_entries',
'settled_ticket_still_allows_reapply',
'open_ticket_blocks_duplicate_apply',
]) {
assert.ok(names.includes(name), `missing handling-log scenario: ${name}`)
}
})
test('frontend renders the handling log as a rope-knot timeline', () => {
const js = read('assets/js/order-after-sale.js')
const center = read('assets/js/order-center.js')
// 售后详细弹窗:处理记录时间线取代单行的「处理备注 / 完成时间」
assert.ok(!js.includes("detailRow('处理备注'"), 'single admin-note row must be removed')
assert.ok(!js.includes("detailRow('完成时间'"), 'single completed-at row must be removed')
assert.match(js, /function handlingEntryTitle\(entry\)/)
assert.match(js, /function handlingLogHtml\(log\)/)
assert.match(js, /handlingLogHtml\(latest\.handling_log\)/)
assert.match(js, /status === 'cancelled' \? '用户撤销售后' : '用户提交申请'/)
assert.match(js, /'<ol class="aksrt-after-sale-timeline">'/)
assert.match(js, /aksrt-after-sale-timeline__knot/)
assert.match(js, /aksrt-after-sale-timeline__status/)
assert.match(js, /aksrt-after-sale-timeline__time/)
assert.match(js, /aksrt-after-sale-timeline__note/)
// [节点][处理状态][时间] 同一行:节点独占一列,状态与时间包在 head 里
assert.match(js, /'<span class="aksrt-after-sale-timeline__knot"><\/span>'/)
assert.match(js, /'<div class="aksrt-after-sale-timeline__head">' \+/)
assert.match(js, /'<div class="aksrt-after-sale-timeline__body">';/)
// 退款金额不展示:并非每次售后都真的退款,工单被标完成也不代表把钱退回去了
assert.ok(!js.includes("detailRow('退款金额'"), 'after-sale detail modal must not show 退款金额')
assert.ok(!js.includes('aftersaleAmountText'), 'dead aftersaleAmountText must be removed')
assert.match(js, /detailRow\('申请时间', String\(latest\.created_at \|\| ''\)\)/)
// 订单详情弹窗:不再渲染售后状态节点进程与退款金额,只保留状态徽章 + 原因 + 「查看详细」;
// 完整处理记录由点开的售后详细弹窗呈现,载荷仍带上 handling_log
assert.ok(!center.includes('gatewayResultText'), 'dead gateway-result renderer must be removed')
assert.ok(!center.includes('<dt>渠道结果</dt>'), 'dead 渠道结果 row must be removed')
assert.ok(!center.includes('<dt>处理备注</dt>'), 'single 处理备注 row must be removed')
assert.ok(!center.includes('<dt>退款金额</dt>'), 'order detail card must not show 退款金额')
assert.ok(!center.includes('afterSaleAmount'), 'dead afterSaleAmount must be removed')
assert.ok(!center.includes('aksrt-order-detail__after-sale-meta'), 'empty meta dl must be removed')
assert.ok(!center.includes('orderDetailHandlingLogHtml'), 'order detail modal must not render the timeline')
assert.ok(!center.includes('AFTER_SALE_STATUS_LABELS'), 'order detail modal must not carry timeline labels')
assert.ok(!center.includes('handlingEntryTitle'), 'order detail modal must not carry the timeline title helper')
assert.ok(!center.includes('aksrt-after-sale-timeline'), 'order detail modal must not emit timeline markup')
assert.match(center, /handling_log: Array\.isArray\(item\.handling_log\) \? item\.handling_log : \[\],/)
// 「查看详细」入口保留:处理记录从这里点开
assert.match(center, /data-aksrt-after-sale-detail="' \+ recordPayload \+ '"/)
// 时间线样式:竖线穿节点、末项不收口,左列固定宽度、右列自适应
const css = read('assets/css/frontend/modal-runtime.css')
assert.match(css, /\.aksrt-after-sale-timeline__item \{ --as-accent: var\(--modal-muted\); position: relative; display: grid; grid-template-columns: 10px minmax\(0, 1fr\); gap: 10px; padding: 0 0 14px; \}/)
assert.match(css, /\.aksrt-after-sale-timeline__item::before \{ content: ""; position: absolute; top: 14px; bottom: -2px; left: 4px/)
assert.match(css, /\.aksrt-after-sale-timeline__item:last-child::before \{ display: none; \}/)
assert.match(css, /\.aksrt-after-sale-timeline__head \{ display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; \}/)
assert.match(css, /\.aksrt-after-sale-timeline__knot/)
assert.match(css, /\.aksrt-after-sale-timeline__note/)
const ordersCss = read('assets/css/routes/orders.css')
assert.ok(!ordersCss.includes('aksrt-order-detail__after-sale-meta'), 'dead after-sale-meta rules must be removed')
// 旧的两列结构(aside / main)已移除
assert.ok(!css.includes('aksrt-after-sale-timeline__aside'), 'left-column aside must be removed')
assert.ok(!css.includes('aksrt-after-sale-timeline__main'), 'right-column main must be removed')
// 打包产物同步:public.css / public.min.css 都含时间线规则
assert.ok(read('assets/css/build/public.css').includes('.aksrt-after-sale-timeline__knot'))
assert.ok(read('assets/css/build/public.min.css').includes('.aksrt-after-sale-timeline__knot'))
})
@@ -1,117 +0,0 @@
<?php
/**
* After-sale policy runtime harness.
*
* Covers the 「支付&付费 → 售后策略」 runtime resolution: schema defaults when the
* section was never saved, stored overrides, HTML sanitisation, preset-reason
* slugs, and the after_sale_expired boundary matrix.
*/
namespace {
define('ABSPATH', __DIR__);
define('DAY_IN_SECONDS', 86400);
$GLOBALS['aksrt_test_options'] = [];
$GLOBALS['aksrt_test_now'] = 0;
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 wp_kses_post($value): string {
$value = preg_replace('#<script\b[^>]*>.*?</script>#is', '', (string) $value);
return strip_tags($value, '<a><b><strong><em><i><span><div><p><br><ul><ol><li>');
}
function esc_url_raw($value): string {
return (string) $value;
}
function wp_json_encode($value): string {
return (string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
function get_option(string $name, $default = false) {
return $GLOBALS['aksrt_test_options'][$name] ?? $default;
}
function update_option(string $name, $value): bool {
$GLOBALS['aksrt_test_options'][$name] = $value;
return true;
}
function current_time(string $type, $gmt = false) {
return $GLOBALS['aksrt_test_now'];
}
}
namespace AKSRT\Settings {
class Settings {
public static function get_all(): array {
$stored = get_option('aksrt_settings', []);
return is_array($stored) ? $stored : [];
}
public static function read_domain(string $domain, string $section = ''): array {
return \AKSRT\Admin\SchemaProvider::get_instance()->read_settings($domain, $section);
}
public static function flush_settings_cache(): void {}
}
}
namespace {
require_once dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.php';
require_once dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
require_once dirname(__DIR__, 2) . '/src/Settings/PaymentRuntime.php';
$GLOBALS['aksrt_test_now'] = strtotime('2026-03-01 12:00:00');
// 1) Section never saved: the runtime still resolves schema defaults.
$defaults = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
// 2) Stored values override defaults; HTML fields are kses'ed.
update_option('aksrt_settings', [
'after_sale_s' => false,
'after_sale_time_limit' => 30,
'after_sale_expired_text' => '<div>已超过时效</div><script>alert(1)</script>',
'after_sale_reasons' => [['t' => '质量问题'], ['t' => '发错货']],
'after_sale_desc' => '<div>请保持商品完好</div><script>alert(2)</script>',
'after_sale_materials' => [['t' => '联系邮箱']],
'after_sale_reason_placeholder' => '请描述问题',
'after_sale_img_s' => false,
]);
$overridden = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
// 3) Expiry boundary matrix: measured from paid_at, strictly beyond the limit.
$matrix = [];
$boundary_cases = [
'within' => ['2026-02-25 00:00:00', 7, false],
'exact_boundary' => ['2026-02-22 12:00:00', 7, false],
'just_expired' => ['2026-02-22 11:59:59', 7, true],
'long_expired' => ['2026-01-01 00:00:00', 7, true],
'no_limit' => ['2020-01-01 00:00:00', 0, false],
'missing_paid' => ['', 7, false],
];
$stored = get_option('aksrt_settings', []);
foreach ($boundary_cases as $name => $case) {
[$paid_at, $limit, $expected] = $case;
$stored['after_sale_time_limit'] = $limit;
update_option('aksrt_settings', $stored);
$matrix[$name] = \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at) === $expected;
}
echo json_encode([
'defaults' => $defaults,
'overridden' => $overridden,
'expired_ok' => $matrix,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
@@ -62,27 +62,29 @@ test('legacy public SMS-code AJAX surface is removed while SMS service remains',
assert.match(sms, /public function send_code\(string \$phone, string \$code\): array/);
});
test('after-sale submit and withdraw endpoints verify captcha once per request', () => {
const service = read('src/Payment/AfterSaleService.php');
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, /'after_sale_apply'\s*=> __\('申请售后', 'aksrt'\)/);
assert.match(scenes, /'after_sale_cancel'\s*=> __\('撤销售后', 'aksrt'\)/);
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' => 'after_sale_apply'/);
assert.match(permissions, /'label' => '撤销售后', 'value' => 'after_sale_cancel'/);
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: 'after_sale_apply'/);
assert.match(adminSchema, /label: '撤销售后', value: 'after_sale_cancel'/);
assert.match(adminSchema, /label: '提交工单', value: 'ticket_create'/);
assert.match(adminSchema, /label: '回复工单', value: 'ticket_reply'/);
// REST 提交 / 撤销各过一次场景校验(统一入口 helper 内恰一次 aksrt_require_captcha);
// REST 新建 / 回复各过一次场景校验(统一入口 helper 内恰一次 aksrt_require_captcha);
// 场景未启用时 aksrt_require_captcha 直接放行
const submit = service.slice(service.indexOf('public function rest_submit'), service.indexOf('public function rest_list'));
const cancel = service.slice(service.indexOf('public function rest_cancel'), service.indexOf('public function create'));
assert.match(submit, /require_captcha\(\$request, 'after_sale_apply'\)/);
assert.match(cancel, /require_captcha\(\$request, 'after_sale_cancel'\)/);
assert.equal((service.match(/require_captcha\(\$request, '/g) || []).length, 2);
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'\)/);
});
@@ -124,99 +124,58 @@ test('flow retention and archive were deliberately removed', () => {
assert.doesNotMatch(read('functions.php'), /FinancialFlowRetention/)
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowRetention.php')), false)
})
test('order detail page drops tabs; after-sales becomes a standalone submenu', () => {
test('order detail page drops tabs; ticket center replaces the after-sale submenu', () => {
const page = read('src/Admin/Native/PayOrders.php')
const menus = read('src/Admin/Native/PayMenus.php')
const repository = read('src/Payment/OrderRepositoryCore.php')
// 订单明细页不再有 Tab 分页:统计 Tab 与页面整体删除,售后 Tab 移出
// 订单明细页不再有 Tab 分页:统计 Tab 与页面整体删除
assert.ok(!page.includes('render_workbench_tabs'), 'workbench tabs must be removed')
assert.ok(!page.includes("$_GET['tab']"), 'tab query param must not be read')
assert.ok(!page.includes('render_statistics_page'), 'statistics page must be removed')
assert.ok(!page.includes('stat_card'), 'stat cards must be removed')
assert.ok(!repository.includes('get_admin_order_stats'), 'dead stats query must be removed')
// 售后独立为子菜单「售后服务」(aksrt_after_sales),无兼容跳转
assert.ok(menus.includes("'slug' => 'aksrt_after_sales'"), 'after-sales submenu slug')
assert.ok(page.includes('render_after_sales_admin_page'), 'after-sales admin page entry')
assert.ok(page.includes("PAGE_AFTER_SALES_SLUG = 'aksrt_after_sales'"), 'after-sales slug constant')
assert.ok(!page.includes('after-sales'), 'old tab slug must not remain')
// 售后表格列对齐售后策略的申请表单配置项:原因 / 申请资料 / 申请说明
assert.ok(page.includes('<th>申请资料</th>'), 'materials column')
assert.ok(page.includes('<th>申请说明</th>'), 'description column')
assert.ok(!page.includes('<th>举证图片</th>'), 'evidence images must not be a standalone column')
assert.ok(page.includes('aksrt-as-desc-thumbs'), 'evidence thumbnails render inside the description cell')
// 操作列:点击「操作」以手风琴展开整条记录详情(含完整字段与状态面板),经 JS 以 POST 提交
assert.ok(page.includes('aksrt-toggle-row-actions'), 'toggle to expand record accordion')
assert.ok(page.includes('aksrt-as-detail-row'), 'accordion detail row per after-sale record')
assert.ok(page.includes('aksrt-as-detail-grid'), 'accordion panel renders full record fields')
assert.ok(page.includes('aksrt-review-note'), 'review note textarea inside expanded panel')
assert.ok(page.includes('aksrt-as-review-actions'), 'review actions row inside expanded panel')
assert.ok(page.includes('aksrt-status-select'), 'status select inside expanded panel')
assert.ok(page.includes('aksrt-after-sale-confirm'), 'confirm button posts the selected status')
assert.ok(page.includes('aksrt-after-sale-cancel'), 'cancel button only collapses the panel')
assert.ok(page.includes('aksrt_after_sale_update'), 'status update handler registered')
assert.ok(page.includes('aksrt_after_sale_status_'), 'per-row nonce covers the status update')
assert.ok(page.includes('aksrt-as-field--wide'), 'description renders as a full-width row')
assert.ok(page.includes('justify-content:flex-end'), 'confirm/cancel buttons right-aligned')
// 「标记订单已退款」:勾选后确认时把关联订单推到已退款
assert.ok(page.includes('aksrt-mark-order-refunded'), 'refund checkbox inside expanded panel')
assert.ok(page.includes('标记订单已退款'), 'refund checkbox label')
assert.ok(page.includes('mark_order_refunded:markRefund?"1":""'), 'refund flag posted only when checked')
assert.ok(page.includes('var markRefund=!!(refundEl&&refundEl.checked&&!refundEl.disabled);'),
'a disabled (already refunded) checkbox submits nothing')
assert.ok(page.includes('aksrt-as-refund-flag'), 'refund checkbox keeps its own row in the panel')
// 订单退款必须早于售后状态更新:工单一离开 pending/processing,OrderRepository 的退款闸门就不再放行
assert.ok(page.indexOf('mark_related_order_refunded($id)') < page.indexOf('set_status($id'),
'order refund runs before the after-sale status update')
assert.ok(page.includes('DomainModel::ORDER_STATUS_REFUNDED'), 'refund marking targets the refunded order status')
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
assert.ok(page.includes("'after_sale_id' => $after_sale_id"), 'refund is bound to this after-sale application')
assert.ok(page.includes('after_sale_order_refund_failed'), 'refund failure is reported to the admin')
assert.ok(page.includes('after_sale_updated_order_refunded'), 'refund success is reported to the admin')
const service = read('src/Payment/AfterSaleService.php')
assert.match(service, /public function get\(int \$id\)/, 'after-sale lookup is exposed for the admin handler')
assert.ok(page.includes('.aksrt-as-detail-row[hidden]'), 'hidden attribute guard keeps accordion closed')
assert.ok(page.includes('document.addEventListener("click"'), 'delegated click handler survives row re-render')
assert.ok(!page.includes('class="row-actions"'), 'no hover-only row-actions')
assert.ok(!page.includes('aksrt_after_sale_bulk'), 'bulk review handler removed')
assert.ok(!page.includes('批量操作'), 'bulk actions dropdown removed')
assert.ok(!page.includes('after_sale_ids[]'), 'row checkboxes removed')
assert.ok(page.includes('$materials_cell'), 'materials cell rendered from apply_form')
assert.ok(page.includes('$desc_cell'), 'description cell rendered from apply_form')
// 页头展示售后策略摘要(功能开关 / 时效 / 预置原因 / 申请材料)
assert.ok(page.includes('get_after_sale_settings()'), 'policy summary reads settings')
assert.ok(page.includes('预置原因'), 'policy summary shows reasons')
assert.ok(page.includes('申请所需材料'), 'policy summary shows materials')
// 售后整体移除:工单改走 users.php 的「工单管理」子菜单,支付中心不再承载售后
assert.ok(!menus.includes('aksrt_after_sales'), 'after-sales submenu slug must be gone')
assert.ok(!page.includes('render_after_sales_admin_page'), 'after-sales admin page entry must be gone')
assert.ok(!page.includes('PAGE_AFTER_SALES_SLUG'), 'after-sales slug constant must be gone')
assert.ok(!page.includes('after_sale'), 'no after-sale handler may remain on the orders page')
const usersMenus = read('src/Admin/Native/UsersMenus.php')
assert.ok(usersMenus.includes("'aksrt_tickets'"), 'ticket submenu slug registered under users.php')
assert.ok(usersMenus.includes('render_tickets_page'), 'ticket admin page entry registered')
const ticketTrait = read('src/Admin/Native/UsersMenusTicketsCenter.php')
assert.ok(ticketTrait.includes('use UsersMenusTicketsList;'), 'ticket list renderer trait composed')
assert.ok(ticketTrait.includes('use UsersMenusTicketsConfig;'), 'ticket config renderer trait composed')
const domain = read('src/Core/DomainModel.php')
for (const status of ['pending', 'paid', 'refunded', 'closed']) assert.ok(domain.includes(`ORDER_STATUS_${status.toUpperCase()}`))
assert.match(page, /STATUS_PENDING[\s\S]*?STATUS_PROCESSING[\s\S]*?STATUS_COMPLETED[\s\S]*?STATUS_REJECTED[\s\S]*?STATUS_CANCELLED/)
assert.ok(!page.includes('STATUS_APPROVED'), 'approved status removed')
assert.match(page, /ORDER_PRODUCT_READING[\s\S]*?付费阅读[\s\S]*?ORDER_PRODUCT_DOWNLOAD[\s\S]*?付费下载[\s\S]*?ORDER_PRODUCT_IMAGE[\s\S]*?付费图片[\s\S]*?ORDER_PRODUCT_VIDEO[\s\S]*?付费视频[\s\S]*?ORDER_PRODUCT_VIP[\s\S]*?购买会员[\s\S]*?ORDER_PRODUCT_RECHARGE[\s\S]*?余额充值[\s\S]*?ORDER_PRODUCT_POINTS[\s\S]*?购买积分/)
})
test('paid orders expose a refund button next to the detail link', () => {
const page = read('src/Admin/Native/PayOrders.php')
const service = read('src/Payment/AfterSaleService.php')
// 「退款」紧跟在「详情」右侧,仅已支付订单可见——已退款是已支付的唯一后继状态
assert.match(page, /详情', 'aksrt'\) \. '<\/a>';\s*\n\s*\/\/ 已支付是已退款的唯一前置状态[\s\S]*?aksrt_pay_refund_order/)
assert.ok(page.includes("if (\$status_key === \\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID) {"),
assert.ok(page.includes("if ($status_key === \\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID) {"),
'refund button only renders for paid orders')
assert.ok(page.includes('aksrt_pay_refund_order_'), 'per-order nonce covers the refund')
assert.ok(page.includes("add_action('admin_post_aksrt_pay_refund_order', [\$this, 'handle_refund_order'])"),
assert.ok(page.includes("add_action('admin_post_aksrt_pay_refund_order', [$this, 'handle_refund_order'])"),
'refund action registered')
assert.ok(page.includes('handle_refund_order'), 'refund handler exists')
// 工单号由服务端按订单号反查,客户端只能传订单号
assert.ok(page.includes("AfterSaleService::get_instance()->find_active(\$order_number)"),
'active after-sale application is resolved server-side')
assert.ok(!page.includes('after_sale_id\' => absint'), 'after-sale id is never taken from the request')
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
// 退款闸门只剩「订单为已支付」:工单系统与订单退款已解耦,不再要求先有进行中的工单
assert.ok(!page.includes('AfterSaleService'), 'no after-sale lookup in the refund path')
assert.ok(!page.includes('after_sale_id'), 'refund is no longer bound to an after-sale application')
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
const repository = read('src/Payment/OrderRepositoryCore.php')
assert.ok(!repository.includes('can_finalize_order_refund'),
'OrderRepository no longer gates refunds on an after-sale application')
// 各种失败都要给出可操作的提示,而不是静默失败
for (const code of ['order_refunded', 'order_refund_failed', 'order_refund_missing', 'order_refund_not_paid', 'order_refund_no_ticket']) {
for (const code of ['order_refunded', 'order_refund_failed', 'order_refund_missing', 'order_refund_not_paid']) {
assert.ok(page.includes(`'${code}'`), `flash code ${code} is mapped`)
}
assert.match(service, /public function find_active\(string \$number\)/, 'active application lookup is exposed')
assert.ok(!page.includes('order_refund_no_ticket'), 'the no-ticket refund failure is gone')
})
test('order creation retries only on order-number unique key collisions', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
assert.match(repository, /private const ORDER_NUMBER_MAX_ATTEMPTS = \d+;/)
@@ -89,6 +89,7 @@ namespace {
require dirname(__DIR__, 2) . '/src/Auth/OAuthProxyProtocol.php';
require dirname(__DIR__, 2) . '/src/Auth/OAuth.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaUsers.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/Users/SchemaUsersTicket.php';
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
function check(bool $condition, string $message): void {
@@ -1,91 +0,0 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '../..')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
test('orders expose four states, seven product categories and retain 22-digit IDs', () => {
const domain = read('src/Core/DomainModel.php')
const repository = read('src/Payment/OrderRepositoryCore.php')
const order = read('src/Payment/Order.php')
for (const status of ['pending', 'paid', 'refunded', 'closed']) {
assert.match(domain, new RegExp(`ORDER_STATUS_${status.toUpperCase()}\\s*=\\s*'${status}'`))
}
assert.ok(repository.includes("ORDER_NUMBER_PATTERN = '/^\\d{20}$|^\\d{22}$/';"))
for (const label of ['付费阅读', '付费下载', '付费图片', '付费视频', '购买会员', '余额充值', '购买积分']) {
assert.ok(domain.includes(label), `missing product category ${label}`)
}
assert.match(repository, /\$row\['status'\] === 'refunding'[\s\S]*?Order::STATUS_PAID/)
assert.doesNotMatch(order, /STATUS_REFUNDING\s*=/)
})
test('balance and points mutations use user meta, guard debits in SQL and cap history at 50', () => {
const assets = read('src/Credits/MetaAssetService.php')
const ledger = read('src/Ledger/LedgerService.php')
assert.match(assets, /MAX_RECORDS\s*=\s*50/)
assert.match(assets, /SELECT GET_LOCK\(%s, 5\)/)
assert.match(assets, /FOR UPDATE/)
assert.match(assets, /CAST\(meta_value AS SIGNED\) >= %d/)
assert.match(assets, /CAST\(meta_value AS DECIMAL\(20,2\)\) >= CAST\(%s AS DECIMAL\(20,2\)\)/)
assert.match(assets, /array_slice\(\$history, 0, self::MAX_RECORDS - 1\)/)
assert.match(assets, /'balance_record'/)
assert.match(assets, /'points_record'/)
assert.match(ledger, /post_meta_command\(\$command, \$guard, \$business_mutation\)/)
assert.match(ledger, /AssetType::BALANCE, AssetType::POINTS/)
})
test('free points keep a bounded daily summary alongside the recent points history', () => {
const freePoints = read('src/Credits/PointsFree.php')
const assets = read('src/Credits/MetaAssetService.php')
assert.match(freePoints, /META_DETAIL\s*=\s*'free_points_detail'/)
assert.match(freePoints, /remaining_today\(\$user_id\) < \$points/)
assert.match(freePoints, /array_slice\(\$detail, 0, 50, true\)/)
assert.match(freePoints, /LedgerService::get_instance\(\)->credit\([\s\S]*?self::bump_detail\(\$user_id, \$points\)/)
assert.match(assets, /'points_record'/)
})
test('after-sale submissions are order-locked and admins set status directly', () => {
const afterSale = read('src/Payment/AfterSaleService.php')
const schema = read('src/Core/DBMigratorPart2.php')
const repository = read('src/Payment/OrderRepositoryCore.php')
// 处理记录落在工单行内:每次状态提交一条「状态 + 备注 + 来源 + 时间」。
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?handling_log LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/)
for (const dead of ['admin_note', 'gateway_request_id', 'gateway_refund_id', 'gateway_result', 'reviewed_at', 'completed_at']) {
// 只截到本表 CREATE 语句结束,避免误判其它表里同名的 completed_at
const block = schema.slice(schema.indexOf('CREATE TABLE {$prefix}aksrt_order_aftersale'))
const column = block.slice(0, block.indexOf('ENGINE=InnoDB'))
assert.ok(!column.includes(dead), `${dead} column must be removed from aksrt_order_aftersale`)
}
// create() 的重复申请闸门看「最新一条工单」而不是只看进行中的:已完结同样要挡
// 只挡仍在流转的工单:已完结(completed)不挡,能否再售后由订单是否已退款决定
assert.match(afterSale, /GET_LOCK\(%s, 5\)[\s\S]*?get_user_order\(\$user_id, \$order_number\)[\s\S]*?find_active\(\$order_number\)/)
// 待审核 / 处理中:同人同因幂等返回原工单
assert.match(afterSale, /hash_equals\(\(string\) \(\$active\['reason'\] \?\? ''\), \$reason\)/)
// 订单已退款走 STATUS_PAID 校验挡掉,工单 completed 不参与这道闸门
assert.ok(!afterSale.includes('该订单的售后申请已完结,不能再次申请'), 'completed-block copy must be removed')
assert.match(afterSale, /Order::STATUS_PAID/)
assert.match(afterSale, /public function set_status\(int \$id, string \$status, string \$note = ''\)/)
assert.match(afterSale, /in_array\(\$status, \[self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED\], true\)/)
assert.ok(!afterSale.includes('STATUS_APPROVED'), 'approved status removed')
assert.ok(!afterSale.includes('function review('), 'decision-based review removed')
assert.ok(!afterSale.includes('approve('), 'refund pipeline removed')
assert.match(repository, /can_finalize_order_refund\(\(int\) \(\$extra\['after_sale_id'\] \?\? 0\), \$order_number\)/)
})
test('withdrawal holds and returns use the selected user-meta or commission pool', () => {
const withdrawal = read('src/Payment/Withdrawal.php')
const assets = read('src/Credits/MetaAssetService.php')
assert.match(withdrawal, /source_balance_decimal\(\$user_id, \$source\)/)
assert.match(withdrawal, /private function source_balance_decimal\(int \$user_id, string \$source\)/)
assert.match(withdrawal, /\$this->source_asset\(\$source\)/)
assert.match(withdrawal, /\$this->source_balance_decimal\(\$user_id, \$refund_source\)/)
assert.match(assets, /AssetType::BALANCE->value, AssetType::POINTS->value/)
assert.match(assets, /function write_balance\([\s\S]*?\$wpdb->usermeta/)
})
@@ -0,0 +1,72 @@
'use strict';
/**
* 工单人机验证契约:ticket_create / ticket_reply 两个场景必须在三处对齐
* (PHP 场景白名单 / 后台「启用场景」勾选列表的 PHP schema / SPA schema),
* 且旧的 after_sale_* 场景已彻底移除。
*/
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');
const SCENES = [
{ key: 'ticket_create', label: '提交工单' },
{ key: 'ticket_reply', label: '回复工单' },
];
test('ticket captcha scenes are registered in all three places', () => {
const captcha = read('inc/modules/captcha.php');
const permissions = read('src/Admin/Schema/SchemaPermissions.php');
const adminSchema = read('assets/admin/src/schema/sections/permissions/index.js');
for (const scene of SCENES) {
assert.match(captcha, new RegExp(`'${scene.key}'\\s*=> __\\('${scene.label}', 'aksrt'\\)`),
`captcha scene ${scene.key} missing`);
assert.ok(permissions.includes(`'label' => '${scene.label}', 'value' => '${scene.key}'`),
`PHP permissions schema missing ${scene.key}`);
assert.ok(adminSchema.includes(`{ label: '${scene.label}', value: '${scene.key}' }`),
`SPA permissions schema missing ${scene.key}`);
}
});
test('the after-sale captcha scenes are gone', () => {
for (const file of [
'inc/modules/captcha.php',
'src/Admin/Schema/SchemaPermissions.php',
'assets/admin/src/schema/sections/permissions/index.js',
]) {
const src = read(file);
assert.ok(!src.includes('after_sale_apply'), `${file} still declares after_sale_apply`);
assert.ok(!src.includes('after_sale_cancel'), `${file} still declares after_sale_cancel`);
assert.ok(!src.includes('申请售后'), `${file} still carries the 申请售后 label`);
assert.ok(!src.includes('撤销售后'), `${file} still carries the 撤销售后 label`);
}
});
test('the ticket REST layer consumes both scenes exactly once', () => {
const rest = read('src/Ticket/TicketRest.php');
assert.match(rest, /const CAPTCHA_SCENE_CREATE = 'ticket_create';/);
assert.match(rest, /const CAPTCHA_SCENE_REPLY = 'ticket_reply';/);
assert.equal((rest.match(/require_captcha\(\$request, self::CAPTCHA_SCENE_/g) || []).length, 2,
'create and reply must each verify captcha exactly once');
assert.equal((rest.match(/aksrt_require_captcha\(/g) || []).length, 1,
'captcha verification must funnel through one helper');
});
test('the ticket panel mounts both captcha scenes', () => {
const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php');
assert.ok(renderer.includes("aksrt_render_captcha('ticket_create')"), 'create mount missing');
assert.ok(renderer.includes("aksrt_render_captcha('ticket_reply')"), 'reply mount missing');
assert.ok(!renderer.includes('after_sale'), 'renderer must not mount after-sale scenes');
const js = read('assets/js/ticket-center.js');
assert.match(js, /runCaptcha\('ticket_create'\)/);
assert.match(js, /runCaptcha\('ticket_reply'\)/);
// 取消验证时中止提交,不静默放行。
assert.match(js, /return payload === null \? Promise\.reject\(\{ cancelled: true \}\)/);
});
@@ -0,0 +1,123 @@
'use strict';
/**
* 工单状态机契约:
* 1) 迁移规则集中在 TicketStatusService::can_transition(),上层不各自判合法性;
* 2) 回复会把工单推到「已回复 / 待回复」,管理员「接收」只能从待接单出发;
* 3) 已完结状态不可再流转,也不可从未接单一步跳到关闭;
* 4) 催单冷却用 transient,关闭走通知;
* 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');
test('transition rules live in one place', () => {
const service = read('src/Ticket/TicketStatusService.php');
const body = service.slice(service.indexOf('public static function can_transition'));
// 目标必须存在;同状态幂等;已完结不可流转;待接单不可直接关闭。
assert.match(body, /if \(\$to === '' \|\| !self::exists\(\$to\)\) \{\s*\n\s*return false;/);
assert.match(body, /if \(\$from === \$to\) \{\s*\n\s*return true;/);
assert.match(body, /if \(self::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
assert.match(body, /if \(\$from === self::KEY_PENDING && \$to === self::KEY_CLOSED\) \{\s*\n\s*return false;/);
// 上层只调用 can_transition,不复制合法性判断。
for (const file of ['src/Ticket/TicketRepository-traits/TicketStatus.php', 'src/Ticket/TicketRestAdmin.php', 'src/Admin/Native/UsersMenusTicketsCenter.php']) {
const src = read(file);
assert.ok(src.includes('can_transition'), `${file} must delegate to can_transition`);
}
});
test('reply and receive drive the documented state changes', () => {
const status = read('src/Ticket/TicketRepository-traits/TicketStatus.php');
// 管理员回复 → 已回复;用户回复 → 待回复。
assert.match(status, /\$target = \$author_type === 'admin'\s*\n\s*\? TicketStatusService::KEY_ADMIN_REPLIED\s*\n\s*: TicketStatusService::KEY_USER_REPLIED;/);
// 同状态不重复迁移;已完结工单上的回复不改变状态。
assert.match(status, /if \(\$from === \$target\) \{\s*\n\s*return true;/);
assert.match(status, /if \(TicketStatusService::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
// 「接收」只允许从未接单转处理中。
assert.match(status, /if \(\(string\) \(\$ticket\['status'\] \?\? ''\) !== TicketStatusService::KEY_PENDING\) \{\s*\n\s*return false;/);
assert.match(status, /return \$this->transition_ticket\(\$ticket_id, \$ticket, TicketStatusService::KEY_HANDLING, 'admin', get_current_user_id\(\)\);/);
// 「已解决」落到已关闭。
assert.match(status, /private function resolve_ticket\(int \$ticket_id, array \$ticket\): bool \{\s*\n\s*return \$this->transition_ticket\(\$ticket_id, \$ticket, TicketStatusService::KEY_CLOSED, 'admin', get_current_user_id\(\)\);/);
});
test('closing notifies the counterparty exactly once', () => {
const status = read('src/Ticket/TicketRepository-traits/TicketStatus.php');
assert.match(status, /if \(\$to === TicketStatusService::KEY_CLOSED\) \{\s*\n\s*TicketNotifier::closed\(\$ticket, \$actor\);/);
const notifier = read('src/Ticket/TicketNotifier.php');
// 关单双分支:管理员关 → 通知用户;用户关 → 通知跟进人。
assert.match(notifier, /if \(\$closer === 'admin'\) \{/);
assert.match(notifier, /self::send\(\$user_id, 'ticket_closed'/);
assert.match(notifier, /self::send\(\$admin_id, 'ticket_closed'/);
});
test('default status cannot be edited or deleted in the admin surface', () => {
// REST 侧的状态 CRUD 在 TicketConfigRest trait 里,由 TicketRestAdmin 组合。
const config = read('src/Ticket/TicketConfigRest.php');
assert.equal((config.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2,
'both update and delete must honour is_default');
assert.match(config, /'aksrt_ticket_status_default'/);
// 原生后台页同样要挡。
const native = read('src/Admin/Native/UsersMenusTicketsCenter.php');
assert.equal((native.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2,
'native admin must honour is_default in both paths');
// 删除前还要确认没有工单挂在该状态/部门下(守卫在 TicketConfigRest trait 中)。
assert.match(config, /count_by_status\(\(string\) \$row\['status_key'\]\) > 0/);
assert.match(config, /count_by_type\(\$id\) > 0/);
assert.match(config, /'aksrt_ticket_status_in_use'/);
assert.match(config, /'aksrt_ticket_type_in_use'/);
const repo = read('src/Ticket/TicketRepository.php');
assert.match(repo, /public function count_by_status\(string \$status_key\): int/);
assert.match(repo, /public function count_by_type\(int \$type_id\): int/);
});
test('prereply reorder requires the full id set', () => {
const service = read('src/Ticket/TicketPrereplyService.php');
const body = service.slice(service.indexOf('public static function reorder'));
// 数量不符或含未知 ID 一律拒绝,避免漏排导致顺序错乱。
assert.match(body, /count\(\$ids\) !== count\(\$existing\) \|\| array_diff\(\$ids, \$existing\) !== \[\]/);
assert.match(body, /return false;/);
// 新建时追加到末尾。
assert.match(service, /return \(int\) \$max \+ 1;/);
});
test('ticket numbers are unique and settings-driven', () => {
const number = read('src/Ticket/TicketNumber.php');
assert.match(number, /TicketSettings::number_prefix\(\)/);
assert.match(number, /current_time\('Ymd'\)/);
assert.match(number, /wp_rand\(100000, 999999\)/);
assert.match(number, /if \(!self::exists\(\$candidate\)\) \{\s*\n\s*return \$candidate;/);
// 冲突重试有上限,极端情况追加随机后缀兜底。
assert.match(number, /private const MAX_ATTEMPTS = 12;/);
const settings = read('src/Ticket/TicketSettings.php');
assert.match(settings, /strtoupper\(\$raw\)/);
assert.match(settings, /sanitize_key/);
});
test('notifications go through the message inbox with deep links', () => {
const notifier = read('src/Ticket/TicketNotifier.php');
// 所有通知都走 Message::send(),持久化到 aksrt_notification。
assert.match(notifier, /\\AKSRT\\Messages\\Message::get_instance\(\)->send\(/);
assert.match(notifier, /class_exists\('\\AKSRT\\Messages\\Message'\)/);
// 新建工单通知全体管理员,催单只通知跟进人。
assert.match(notifier, /get_users\(\['role' => 'administrator', 'fields' => 'ID', 'number' => 20\]\)/);
assert.match(notifier, /\$admin_id = \(int\) \(\$ticket\['admin_id'\] \?\? 0\);/);
// 深链分别指向后台工单页与用户中心工单位。
assert.match(notifier, /admin_url\('users\.php\?page=aksrt_tickets'\)/);
assert.match(notifier, /aksrt_get_user_center_url\('tickets'\)/);
});
@@ -0,0 +1,159 @@
'use strict';
/**
* 工单后台(users.php → aksrt_tickets)契约:
* 1) 子菜单已注册,trait 已组合,写操作走 admin_post 且每个都带 nonce;
* 2) 分页用共享 AdminListPagination,不写死 limit;
* 3) 默认状态与「仍有工单」两类删除被拒;
* 4) SPA 深链 redirect 指向 users.php?page=aksrt_tickets;
* 5) 前端 JS 与 PHP 的 data-* 挂载点对齐。
*/
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('ticket submenu is registered under users.php', () => {
const menus = read('src/Admin/Native/UsersMenus.php');
assert.ok(menus.includes("use UsersMenusTicketsCenter;"), 'trait not composed');
assert.ok(menus.includes("'aksrt_tickets'"), 'submenu slug missing');
assert.ok(menus.includes("'users.php'"), 'submenu must hang off users.php');
assert.ok(menus.includes('[$this, \'render_tickets_page\']'), 'render callback missing');
assert.ok(menus.includes("add_action('admin_post_aksrt_ticket_update', [\$this, 'handle_ticket_update'])"),
'write handler not registered');
// 后台待接单提示。
assert.match(menus, /count_pending_tickets\(\)/);
assert.match(menus, /TicketSettings::enabled\(\)/);
});
test('write actions are nonce-protected and dispatched by action name', () => {
const center = read('src/Admin/Native/UsersMenusTicketsCenter.php');
assert.match(center, /check_admin_referer\('aksrt_ticket_' \. \$action \. '_' \. \$id\);/);
assert.ok(center.includes('current_user_can(self::CAPABILITY)'), 'capability check missing');
// 每个动作分支都要有对应处理器。
for (const action of ['status', 'reply', 'type', 'status_def', 'prereply', 'prereply_delete', 'type_delete', 'status_delete', 'note', 'create']) {
assert.ok(center.includes(`case '${action}':`), `dispatch case ${action} missing`);
}
// 表单里 nonce 以 action + id 为单位,删除类操作独立小表单 + 二次确认。
assert.match(center, /wp_nonce_field\('aksrt_ticket_' \. \$action \. '_' \. \$id, '_wpnonce', true, false\)/);
assert.match(center, /onsubmit="return confirm\(/);
});
test('the ticket list paginates through the shared helper', () => {
const list = read('src/Admin/Native/UsersMenusTicketsList.php');
assert.ok(list.includes('AdminListPagination::render('), 'shared pagination helper not used');
assert.ok(!/limit => 100/.test(list), 'hard-coded limit must not come back');
// 分页条必须晚于空态分支。
assert.ok(
list.indexOf('AdminListPagination::render(') > list.indexOf('暂无工单'),
'pagination must render after the empty-state branch'
);
// 每页条数来自常量(常量定义在父 trait UsersMenusTicketsCenter 中)。
const center = read('src/Admin/Native/UsersMenusTicketsCenter.php');
assert.match(center, /private const TICKET_PER_PAGE = 20;/);
assert.match(list, /'limit' => self::TICKET_PER_PAGE,/);
});
test('config tab edits departments, statuses and prereplies', () => {
const config = read('src/Admin/Native/UsersMenusTicketsConfig.php');
assert.match(config, /render_ticket_types_panel/);
assert.match(config, /render_ticket_statuses_panel/);
assert.match(config, /render_ticket_prereplies_panel/);
// 默认状态行不给编辑/删除入口。
assert.match(config, /empty\(\$row\['is_default'\]\)/);
// 删除按钮带 confirm,且走独立小表单。
assert.match(config, /ticket_delete_button\('status_delete', 'status_id'/);
assert.match(config, /ticket_delete_button\('type_delete', 'type_id'/);
assert.match(config, /ticket_delete_button\('prereply_delete', 'prereply_id'/);
// 状态徽章颜色来自状态表,不写死。
assert.match(config, /--aksrt-ticket-color:%s/);
});
test('detail panel renders replies, quote blocks and internal notes', () => {
const list = read('src/Admin/Native/UsersMenusTicketsList.php');
const body = list.slice(list.indexOf('private function render_ticket_detail_panel'));
assert.match(body, /TicketNoteService::for_ticket/);
assert.match(body, /repo->replies\(\$id\)/);
// 引用块:原回复已删除时显示占位。
assert.match(body, /!empty\(\$quote\['is_deleted'\]\) \? __\('原回复已删除', 'aksrt'\)/);
// 回复与备注各自独立表单 + nonce。
assert.match(body, /wp_nonce_field\('aksrt_ticket_reply_' \. \$id, '_wpnonce', true, false\)/);
assert.match(body, /wp_nonce_field\('aksrt_ticket_note_' \. \$id, '_wpnonce', true, false\)/);
// 已关闭工单仍可查看,但不再展示迁移表单之外的写入口由前端按钮控制。
assert.match(body, /aksrt-ticket-detail__closed/);
});
test('SPA deep-links the native ticket page instead of hosting it', () => {
const router = read('assets/admin/src/router/index.js');
assert.ok(router.includes("{ path: '/users/tickets', title: '工单管理', nativeAdminPath: 'users.php?page=aksrt_tickets' }"),
'NATIVE_OPS_REDIRECTS must point at the native ticket page');
});
test('frontend hooks match the renderer and the JS module', () => {
const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php');
const js = read('assets/js/ticket-center.js');
for (const hook of [
'data-aksrt-ticket-center',
'data-aksrt-ticket-create',
'data-aksrt-ticket-stats',
'data-aksrt-ticket-stat=',
'data-aksrt-ticket-list',
'data-aksrt-ticket-pagination',
'data-aksrt-tickets-search',
'data-aksrt-ticket-filter=',
'data-aksrt-ticket-config',
]) {
assert.ok(renderer.includes(hook), `renderer missing ${hook}`);
}
// JS 侧的挂载点与 REST 路径必须和 PHP 对齐。
for (const hook of [
'data-aksrt-ticket-center',
'data-aksrt-ticket-create',
'data-aksrt-ticket-stat="',
'data-aksrt-ticket-list',
'data-aksrt-ticket-pagination',
'data-aksrt-tickets-search',
'[data-aksrt-ticket-filter]',
'data-aksrt-ticket-view=',
'data-aksrt-ticket-page=',
'data-aksrt-ticket-quote=',
'data-aksrt-ticket-images',
]) {
assert.ok(js.includes(hook), `ticket-center.js missing ${hook}`);
}
assert.ok(js.includes("'/tickets'"), 'list/create path missing');
assert.ok(js.includes("'/tickets/statistic'"), 'statistic path missing');
assert.ok(js.includes("'/tickets/meta'"), 'meta path missing');
assert.ok(js.includes("'/tickets/' + ticketId"), 'detail path missing');
assert.ok(js.includes("'/tickets/' + ticket.id + '/reply'"), 'reply path missing');
assert.ok(js.includes("'/tickets/' + ticket.id + '/urge'"), 'urge path missing');
assert.ok(js.includes("'/tickets/' + ticket.id + '/close'"), 'close path missing');
assert.ok(js.includes("'/ticket/upload-attachment'"), 'upload path missing');
// 人机验证在提交与回复前各过一次。
assert.match(js, /runCaptcha\('ticket_create'\)/);
assert.match(js, /runCaptcha\('ticket_reply'\)/);
});
test('ticket CSS ships as its own route bundle', () => {
const css = read('assets/css/routes/tickets.css');
assert.ok(css.includes('.aksrt-tickets-page'), 'page root rule missing');
assert.ok(css.includes('.aksrt-ticket-badge'), 'status badge rule missing');
assert.ok(css.includes('.aksrt-ticket-detail'), 'detail rule missing');
// 徽章颜色由状态行内变量驱动,不写死。
assert.ok(css.includes('--aksrt-ticket-color'), 'badge color variable missing');
});
@@ -0,0 +1,144 @@
'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,/);
});
@@ -0,0 +1,160 @@
'use strict';
/**
* 「工单中心」配置契约:
* 1) PHP SchemaUsersTicket 分区与 SPA JS schema 的键、顺序、类型、默认值 1:1 对齐;
* 2) SPA 页面已注册(/users/ticket 可达),且挂的是 schemaPage 而非自定义页;
* 3) 运行时 TicketSettings 缺键回退 schema 默认,数值边界被夹取;
* 4) 前台入口三处均已注册:Center.php 面板定义 + sidebar-nav.php 侧栏项 +
* aksrt-user-center.php 的 built_in_tabs 与 view map。
*/
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');
const FIELD_ORDER = [
'ticket_s',
'ticket_num_prefix',
'ticket_notice_open',
'ticket_notice_text',
'ticket_attachment_s',
'ticket_attachment_max',
'ticket_order_link_s',
'ticket_urge_interval',
];
const GROUPS = [
{ key: 'main', title: '工单功能' },
{ key: 'form', title: '提交设置' },
];
test('ticket schema keys match between PHP and SPA JS', () => {
const php = read('src/Admin/Schema/Users/SchemaUsersTicket.php');
const js = read('assets/admin/src/schema/sections/users/ticket.js');
for (const key of FIELD_ORDER) {
assert.ok(php.includes(`'key' => '${key}'`), `PHP schema missing ${key}`);
assert.ok(js.includes(`key: '${key}'`), `JS schema missing ${key}`);
}
// 顺序:两组各自的字段顺序必须一致(前端分组渲染依赖它)。
for (const group of GROUPS) {
const phpGroup = php.slice(php.indexOf(`'key' => '${group.key}'`));
const jsGroup = js.slice(js.indexOf(`key: '${group.key}'`));
const phpKeys = [...phpGroup.matchAll(/'key' => '([a-z_]+)'/g)].map((m) => m[1]);
const jsKeys = [...jsGroup.matchAll(/key: '([a-z_]+)'/g)].map((m) => m[1]);
assert.deepEqual(jsKeys, phpKeys, `group ${group.key} field order drifted`);
}
// 类型逐字段对齐。PHP 里 textarea 带 sanitize_type,取值要跳过它,
// 因此显式匹配 'type' => '...'(前后都是引号边界,不会撞上 sanitize_type)。
for (const key of FIELD_ORDER) {
const phpType = new RegExp(`'key' => '${key}'[^\\]]*'type' => '([a-z]+)'`).exec(php);
const jsType = new RegExp(`key: '${key}'[^}]*?\\btype: '([a-z]+)'`).exec(js);
assert.ok(phpType && jsType, `type not found for ${key}`);
assert.equal(jsType[1], phpType[1], `${key} type drifted`);
}
// sanitize_type 也必须一致:公告内容允许 HTML,两侧都要声明。
assert.ok(php.includes("'key' => 'ticket_notice_text', 'label' => '公告内容', 'type' => 'textarea', 'sanitize_type' => 'html'"),
'PHP notice text must declare sanitize_type html');
assert.ok(js.includes("key: 'ticket_notice_text', label: '公告内容', type: 'textarea', sanitize_type: 'html'"),
'JS notice text must declare sanitize_type html');
});
test('ticket schema is registered as a schema page under users', () => {
const js = read('assets/admin/src/schema/sections/users/ticket.js');
assert.equal(js.includes("key: 'users.ticket'"), true, 'schema key must be users.ticket');
const index = read('assets/admin/src/schema/sections/users/index.js');
assert.ok(index.includes("import { ticketSchema } from './ticket.js'"), 'schema not imported');
assert.ok(/ticket:\s*ticketSchema/.test(index), 'schema not exported in usersSchemas');
const constants = read('assets/admin/src/constants/sections/users.js');
assert.ok(constants.includes("key: 'users-ticket'"), 'menu page key missing');
assert.ok(constants.includes("route: '/users/ticket'"), 'menu route missing');
assert.ok(constants.includes("section: 'ticket'"), 'menu section missing');
assert.ok(constants.includes("canonicalDomain: 'users'"), 'menu canonical domain missing');
// PHP 侧:分区由 SchemaUsers 组装,SchemaUsersTicket 提供内容。
const users = read('src/Admin/Schema/SchemaUsers.php');
assert.ok(users.includes("'ticket' => Users\\SchemaUsersTicket::build()"), 'PHP section not composed');
const ticket = read('src/Admin/Schema/Users/SchemaUsersTicket.php');
assert.ok(ticket.includes("'key' => 'ticket'"), 'PHP section key missing');
assert.ok(ticket.includes("'option_key' => 'aksrt_settings'"), 'PHP option key missing');
});
test('TicketSettings falls back to schema defaults and clamps numerics', () => {
const settings = read('src/Ticket/TicketSettings.php');
// 只取方法体,避免 docblock 与注释里的同形文字参与断言。
const body = settings.slice(settings.indexOf('public static function all()'));
// 缺键回退:enabled 只在显式关闭时为 false(与 schema default true 对齐)。
assert.match(body, /!isset\(\$s\['ticket_s'\]\) \|\| !empty\(\$s\['ticket_s'\]\)/);
assert.match(body, /self::all\(\)\['ticket_num_prefix'\] \?\? 'T'/);
assert.match(body, /self::all\(\)\['ticket_notice_text'\] \?\? ''\)/);
assert.match(body, /self::all\(\)\['ticket_attachment_max'\] \?\? 5\)/);
assert.match(body, /self::all\(\)\['ticket_urge_interval'\] \?\? 15\)/);
// 数值夹取:附件上限 1..9,催单冷却 1..1440。
assert.match(body, /max\(1, min\(9, \(int\)/);
assert.match(body, /max\(1, min\(1440, \(int\)/);
// 公告文案走 kses(后台允许 HTML)。
assert.match(body, /wp_kses_post/);
// 分区名与 schema key 一致(常量在 all() 之前声明,故对整份文件断言)。
assert.match(settings, /const SECTION = 'ticket'/);
assert.match(body, /read_domain\('users', self::SECTION\)/);
});
test('ticket panel is registered across the user-center wiring', () => {
const center = read('src/User/Center.php');
assert.ok(center.includes("'tickets' => ["), 'panel definition missing');
assert.ok(center.includes("'label' => __('工单中心', 'aksrt')"), 'panel label missing');
assert.match(center, /'tickets'\s*=>\s*\[[\s\S]*?'priority' => 49/, 'panel priority should sit next to orders (48)');
// 开关关闭时整块移除。
assert.match(center, /\$ticket_on = class_exists\('\\AKSRT\\Ticket\\TicketSettings'\)/);
assert.match(center, /if \(!\$ticket_on\) \{\s*\n\s*\$order = array_values\(array_filter\(\$order, static fn\(string \$key\): bool => \$key !== 'tickets'\)\);/);
assert.match(center, /if \(\$key === 'tickets' && !\$ticket_on\) \{\s*\n\s*continue;/);
const sidebar = read('inc/helpers/user-center-renderers/sidebar-nav.php');
assert.ok(sidebar.includes("'label' => __('工单中心', 'aksrt')"), 'sidebar label missing');
assert.ok(sidebar.includes("'icon' => 'manual-service'"), 'sidebar icon should reuse the existing headset sprite');
assert.ok(sidebar.includes("'tab' => 'tickets'"), 'sidebar tab key missing');
const helper = read('inc/helpers/aksrt-user-center.php');
assert.ok(helper.includes("'tickets'"), 'built_in_tabs missing tickets');
assert.match(helper, /'tickets'\s*=> 'tickets',/, 'view map missing tickets => tickets');
const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php');
assert.ok(renderer.includes('data-aksrt-ticket-center'), 'panel root hook missing');
assert.ok(renderer.includes('data-aksrt-ticket-create'), 'create button hook missing');
assert.ok(renderer.includes("aksrt_render_captcha('ticket_create')"), 'create captcha mount missing');
assert.ok(renderer.includes("aksrt_render_captcha('ticket_reply')"), 'reply captcha mount missing');
});
test('ticket frontend assets are enqueued only when the panel exists', () => {
const scripts = read('src/Core/ScriptsPart1b.php');
assert.match(scripts, /\$has_tickets_panel = false;/, 'panel flag not initialised');
assert.match(scripts, /\$has_tickets_panel = isset\(\$dashboard_tabs\['tickets'\]\);/, 'panel flag not derived');
assert.ok(scripts.includes("'aksrt-tickets'"), 'ticket CSS handle missing');
assert.ok(scripts.includes("'aksrt-ticket-center'"), 'ticket JS handle missing');
assert.ok(scripts.includes('/assets/js/ticket-center.js'), 'ticket JS path missing');
assert.ok(scripts.includes('/assets/css/routes/tickets.css'), 'ticket CSS path missing');
// 两个入队都必须在 $has_tickets_panel 条件内。
const cssIndex = scripts.indexOf("'aksrt-tickets'");
const jsIndex = scripts.indexOf("'aksrt-ticket-center'");
assert.ok(scripts.lastIndexOf('if ($has_tickets_panel) {', cssIndex) > scripts.indexOf("'aksrt-orders'"),
'ticket CSS must be gated on its own panel flag');
assert.ok(jsIndex > cssIndex, 'ticket JS should be enqueued alongside its CSS');
const localize = read('src/Core/ScriptsPart1c.php');
assert.ok(localize.includes("'ticket' => (function () {"), 'localize key missing');
assert.ok(!localize.includes("'afterSale'"), 'stale afterSale localize must be gone');
});
@@ -0,0 +1,120 @@
'use strict';
/**
* 工单表结构契约:
* 1) 6 张 aksrt_ticket* 表全部由 DBMigratorPart2 建出,且被 migrate_ticket_tables() 调用;
* 2) SCHEMA_VERSION 已升到 3.0.0(dbDelta 只在版本变化时重跑);
* 3) aksrt_order_aftersale 已从 schema 与表名映射中彻底移除;
* 4) 状态与部门 seeds 幂等,默认状态被标记为不可改删。
*/
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');
const TABLES = [
'aksrt_ticket',
'aksrt_ticket_reply',
'aksrt_ticket_status',
'aksrt_ticket_type',
'aksrt_ticket_prereply',
'aksrt_ticket_note',
];
test('schema version is bumped so dbDelta re-runs', () => {
const migrator = read('src/Core/DBMigrator.php');
assert.match(migrator, /const SCHEMA_VERSION = '3\.0\.0';/, 'SCHEMA_VERSION must be 3.0.0');
assert.ok(migrator.includes('use DBMigratorTicket;'), 'DBMigratorTicket trait not composed');
// 版本注释要说明 3.0.0 做了什么,否则后人看不懂这次升版的原因。
assert.match(migrator, /3\.0\.0: 售后(order_aftersale)整体替换为工单系统/);
});
test('order_aftersale is gone from schema and the table map', () => {
const part2 = read('src/Core/DBMigratorPart2.php');
const part3 = read('src/Core/DBMigratorPart3.php');
assert.ok(!part2.includes('aksrt_order_aftersale'), 'order_aftersale CREATE must be removed');
assert.ok(!part3.includes("'order_aftersale'"), 'order_aftersale table map entry must be removed');
assert.ok(!part3.includes('aksrt_order_aftersale'), 'order_aftersale table name must be removed');
});
test('six ticket tables are declared and the migrator is invoked', () => {
const part2 = read('src/Core/DBMigratorPart2.php');
// Part2 只负责调用;DDL 集中在 DBMigratorTicket,避免 run() 继续膨胀。
assert.ok(part2.includes('self::migrate_ticket_tables($prefix, $charset_collate);'),
'migrate_ticket_tables() must be called from run()');
assert.ok(!part2.includes('aksrt_order_aftersale'), 'Part2 must not carry the old table');
const ticket = read('src/Core/DBMigratorTicket.php');
for (const table of TABLES) {
assert.ok(ticket.includes(`CREATE TABLE {$prefix}${table} (`), `${table} DDL missing in trait`);
}
assert.ok(ticket.includes('self::seed_ticket_statuses($prefix);'), 'status seeds not invoked');
assert.ok(ticket.includes('self::seed_ticket_types($prefix);'), 'type seeds not invoked');
});
test('ticket table map exposes all six names', () => {
const part3 = read('src/Core/DBMigratorPart3.php');
for (const name of ['ticket', 'ticket_reply', 'ticket_status', 'ticket_type', 'ticket_prereply', 'ticket_note']) {
assert.ok(part3.includes(`'${name}'`), `table map missing ${name}`);
assert.ok(part3.includes(`'aksrt_${name}'`), `table map missing aksrt_${name}`);
}
});
test('ticket columns cover the state machine and optional order link', () => {
const ticket = read('src/Core/DBMigratorTicket.php');
// 工单号对外唯一;状态是可自定义的 key;订单关联可空(默认空串)。
assert.match(ticket, /UNIQUE KEY uniq_ticket_num \(ticket_num\)/);
assert.match(ticket, /status VARCHAR\(24\) NOT NULL DEFAULT 'pending'/);
assert.match(ticket, /order_number VARCHAR\(32\) NOT NULL DEFAULT ''/);
assert.match(ticket, /KEY idx_order \(order_number\)/);
// 沟通记录独立成行,支持引用回复。
assert.match(ticket, /author_type VARCHAR\(16\) NOT NULL DEFAULT 'user'/);
assert.match(ticket, /quote_reply_id BIGINT UNSIGNED NOT NULL DEFAULT 0/);
// 状态表带完结/默认两个开关,默认状态不可改删由服务层兜住。
assert.match(ticket, /status_key VARCHAR\(24\) NOT NULL/);
assert.match(ticket, /UNIQUE KEY uniq_status_key \(status_key\)/);
assert.match(ticket, /is_closed TINYINT\(1\) NOT NULL DEFAULT 0/);
assert.match(ticket, /is_default TINYINT\(1\) NOT NULL DEFAULT 0/);
});
test('status and department seeds are idempotent', () => {
const ticket = read('src/Core/DBMigratorTicket.php');
// 按 key / name 查重后跳过,重复执行不会写重复行。
assert.match(ticket, /\$exists = \$wpdb->get_col\("SELECT status_key FROM \{\$table\}"\)/);
assert.match(ticket, /if \(in_array\(\$key, \$exists, true\)\) \{\s*\n\s*continue;/);
assert.match(ticket, /\$exists = \$wpdb->get_col\("SELECT name FROM \{\$table\}"\)/);
assert.match(ticket, /if \(in_array\(\$label, \$exists, true\)\) \{\s*\n\s*continue;/);
// 五个内置状态与五个部门。
for (const key of ['pending', 'handling', 'user_replied', 'admin_replied', 'closed']) {
assert.ok(ticket.includes(`['${key}',`), `status seed ${key} missing`);
}
for (const label of ['技术支持', '账号问题', '财务问题', '产品建议', '其他']) {
assert.ok(ticket.includes(`'${label}'`), `department seed ${label} missing`);
}
// 默认状态是「待接单」,且是完结态的补集。
assert.match(ticket, /\['pending', '待接单', '#f59e0b', 0, 1\]/);
assert.match(ticket, /\['closed', '已关闭', '#6b7280', 1, 0\]/);
});
test('TicketStatusService seeds mirror the migrator and guard the default row', () => {
const service = read('src/Ticket/TicketStatusService.php');
for (const key of ['pending', 'handling', 'user_replied', 'admin_replied', 'closed']) {
assert.ok(service.includes(`KEY_${key.toUpperCase()}`), `KEY_${key.toUpperCase()} missing`);
}
// 状态机只挡确定非法的组合,其余交给「目标状态必须存在」。
assert.match(service, /if \(\$from === self::KEY_PENDING && \$to === self::KEY_CLOSED\) \{\s*\n\s*return false;/);
assert.match(service, /if \(self::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
// 颜色只接受 #rgb / #rrggbb。
assert.match(service, /'#6b7280'/);
assert.match(service, /preg_match\('\/\^#\(\?:\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$\/'/);
});