Files
wordpress-theme-aksrt/tools/audit-admin-contracts.php
T
kate522andClaude Opus 5 dbb7de41e9 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>
2026-10-25 23:29:51 +08:00

538 lines
19 KiB
PHP

<?php
/**
* Admin contract audit — menu ↔ PHP schema ↔ REST $this callbacks ↔ build integrity.
*
* Usage (from theme root):
* php tools/audit-admin-contracts.php
*
* Exit 1 on hard errors.
*/
declare(strict_types=1);
$themeRoot = dirname(__DIR__);
$errors = [];
$warnings = [];
$info = [];
function rel(string $path, string $root): string {
return str_replace('\\', '/', ltrim(str_replace($root, '', $path), '\\/'));
}
/**
* Return the source of the first balanced [ ... ] array assigned to $needle,
* brackets included; null when absent or unbalanced.
*/
function extract_balanced_block(string $src, string $needle): ?string {
$pos = strpos($src, $needle);
if ($pos === false) {
return null;
}
$open = strpos($src, '[', $pos);
if ($open === false) {
return null;
}
$depth = 0;
$len = strlen($src);
for ($i = $open; $i < $len; $i++) {
$ch = $src[$i];
if ($ch === "'" || $ch === '"') {
$quote = $ch;
$i++;
while ($i < $len) {
if ($src[$i] === '\\') {
$i += 2;
continue;
}
if ($src[$i] === $quote) {
break;
}
$i++;
}
continue;
}
if ($ch === '[') {
$depth++;
} elseif ($ch === ']') {
$depth--;
if ($depth === 0) {
return substr($src, $open, $i - $open + 1);
}
}
}
return null;
}
/**
* String keys written directly inside an outer [ ... ] block (bracket depth 1),
* regardless of whether their value is an inline array, Class::build() or
* self::helper(). Nested 'k => v' pairs deeper down are ignored.
*
* @return string[]
*/
function top_level_array_keys(string $block): array {
$keys = [];
$depth = 0;
$len = strlen($block);
for ($i = 0; $i < $len; $i++) {
$ch = $block[$i];
if ($ch === "'" || $ch === '"') {
$start = $i;
$quote = $ch;
$i++;
while ($i < $len) {
if ($block[$i] === '\\') {
$i += 2;
continue;
}
if ($block[$i] === $quote) {
break;
}
$i++;
}
$j = $i + 1;
while ($j < $len && ctype_space($block[$j])) {
$j++;
}
if ($depth === 1 && substr($block, $j, 2) === '=>') {
$keys[] = substr($block, $start + 1, $i - $start - 1);
}
continue;
}
if ($ch === '[') {
$depth++;
} elseif ($ch === ']') {
$depth--;
}
}
return $keys;
}
/**
* Extract section keys from composite schema builders (including delegated leaf builders).
*
* @return array<string, array{sections: string[], files: string[]}>
*/
function collect_php_schema_sections(string $themeRoot): array {
$map = [];
$register = static function (string $domain, string $section, string $file) use (&$map, $themeRoot): void {
if ($domain === '' || $section === '') {
return;
}
if (!isset($map[$domain])) {
$map[$domain] = ['sections' => [], 'files' => []];
}
$map[$domain]['sections'][$section] = true;
$fileRel = rel($file, $themeRoot);
if (!in_array($fileRel, $map[$domain]['files'], true)) {
$map[$domain]['files'][] = $fileRel;
}
};
// Primary domain builders.
$primary = [
'global' => $themeRoot . '/src/Admin/Schema/GlobalSchema.php',
'page_display' => $themeRoot . '/src/Admin/Schema/SchemaPageDisplay.php',
'post_list' => $themeRoot . '/src/Admin/Schema/SchemaPostList.php',
'permissions' => $themeRoot . '/src/Admin/Schema/SchemaPermissions.php',
'users' => $themeRoot . '/src/Admin/Schema/SchemaUsers.php',
'payment' => $themeRoot . '/src/Admin/Schema/SchemaPayment.php',
'extensions' => $themeRoot . '/src/Admin/Schema/SchemaExtensions.php',
'tools' => $themeRoot . '/src/Admin/Schema/SchemaTools.php',
'shop' => $themeRoot . '/src/Admin/Schema/SchemaShop.php',
];
foreach ($primary as $domain => $file) {
if (!is_file($file)) {
continue;
}
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Only keys at the first level of the 'sections' block are sections;
// whole-file regexes also match nested field/group arrays ('all' => [,
// 'fields' => [, ...) and miss self::helper() delegates.
$block = extract_balanced_block($src, "'sections'");
if ($block === null) {
continue;
}
foreach (top_level_array_keys($block) as $section) {
if (in_array($section, ['domain', 'title', 'version', 'option_key', 'key', 'sections', 'groups', 'fields'], true)) {
continue;
}
$register($domain, $section, $file);
}
}
// Leaf builders that return a single section with 'key' => 'section-id'
$leafFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
foreach ($leafFiles as $file) {
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Skip pure composites that only delegate.
$isComposite = (bool) preg_match("/'sections'\\s*=>\\s*\\[/", $src);
if ($isComposite) {
continue;
}
if (!preg_match("/'key'\\s*=>\\s*'([a-z0-9_-]+)'/", $src, $km)) {
continue;
}
$section = $km[1];
// Infer domain from filename: class-aksrt-admin-schema-{domain}-{section}.php
$base = basename($file, '.php');
$base = preg_replace('/^(class-aksrt-admin-schema-|Schema)/', '', $base);
$parts = explode('-', $base);
// domain may be multi-token (page_display is page-display in menus but page_display in PHP).
// Prefer matching known domains by prefix.
$domain = null;
foreach (array_keys($primary) as $candidate) {
$candidateDash = str_replace('_', '-', $candidate);
if ($base === $candidateDash || str_starts_with($base, $candidateDash . '-')) {
$domain = $candidate;
break;
}
}
if ($domain === null) {
// Fallback: 叶子文件放在 Schema/{Domain}/ 子目录时,用目录名推断域,
// 覆盖 SchemaUsersTicket / SchemaPostListList 这类「域+分区」短类名。
$dir = basename(dirname($file));
if ($dir !== '' && strcasecmp($dir, 'Schema') !== 0) {
foreach (array_keys($primary) as $candidate) {
if (strcasecmp(str_replace('_', '-', $candidate), $dir) === 0) {
$domain = $candidate;
break;
}
}
}
}
if ($domain === null) {
$domain = str_replace('-', '_', $parts[0] ?? '');
}
// Leaf under Schema/PostList/ with key list → post_list/list
if ($domain === 'post_list' && $section === 'list') {
$register($domain, $section, $file);
continue;
}
$register($domain, $section, $file);
}
foreach ($map as $domain => $payload) {
$map[$domain]['sections'] = array_keys($payload['sections']);
sort($map[$domain]['sections']);
}
return $map;
}
function collect_menu_pages(string $themeRoot): array {
$dir = $themeRoot . '/assets/admin/src/constants/sections';
$pages = [];
foreach (glob($dir . '/*.js') ?: [] as $file) {
$base = basename($file);
if (in_array($base, ['helpers.js', 'index.js'], true)) {
continue;
}
$src = file_get_contents($file);
if ($src === false) {
continue;
}
foreach (['schemaPage' => 'schemaPage', 'customPage' => 'customPage', 'deferredPage' => 'deferredPage'] as $fn => $kind) {
if (!preg_match_all('/' . $fn . '\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)/', $src, $blocks)) {
continue;
}
foreach ($blocks[1] as $block) {
$page = ['file' => rel($file, $themeRoot), 'kind' => $kind];
foreach (['key', 'canonicalDomain', 'section', 'route', 'component', 'name'] as $prop) {
if (preg_match('/' . $prop . ":\\s*'([^']*)'/", $block, $m)) {
$page[$prop === 'canonicalDomain' ? 'domain' : $prop] = $m[1];
}
}
$pages[] = $page;
}
}
}
return $pages;
}
/**
* Collect enum options for fields with balanced-enough heuristics:
* only consider field objects that include both type radio|select and an options array
* on nearby lines, keyed by the field key property.
*
* @return array{php: array<string, string[]>, js: array<string, string[]>}
*/
function collect_enum_maps(string $themeRoot): array {
$php = [];
$js = [];
$phpFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
foreach ($phpFiles as $file) {
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Split roughly by field array starts: ['key' => '...'
if (!preg_match_all("/\\['key'\\s*=>\\s*'([^']+)'([\\s\\S]*?)(?=\\['key'\\s*=>|\\]\\s*,\\s*\\[\\s*'key'|\\]\\s*\\]\\s*,\\s*\\[\\s*'key'|$)/", $src, $chunks, PREG_SET_ORDER)) {
continue;
}
foreach ($chunks as $chunk) {
$key = $chunk[1];
$body = $chunk[2];
if (!preg_match("/'type'\\s*=>\\s*'(radio|select)'/", $body)) {
continue;
}
if (!preg_match("/'options'\\s*=>\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
continue;
}
$values = [];
if (preg_match_all("/'value'\\s*=>\\s*'([^']*)'/", $om[1], $vm)) {
$values = $vm[1];
}
// Keep first definition; warn later if duplicates differ.
if (!isset($php[$key])) {
$php[$key] = $values;
}
}
}
$jsRoot = $themeRoot . '/assets/admin/src/schema/sections';
if (is_dir($jsRoot)) {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($jsRoot, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->getExtension() !== 'js') {
continue;
}
$src = file_get_contents($file->getPathname());
if ($src === false) {
continue;
}
if (!preg_match_all("/\\{\\s*key:\\s*'([^']+)'([\\s\\S]*?)(?=\\{\\s*key:\\s*'|$)/", $src, $chunks, PREG_SET_ORDER)) {
continue;
}
foreach ($chunks as $chunk) {
$key = $chunk[1];
$body = $chunk[2];
// Stop body at next field-ish boundary if options not closed well.
if (!preg_match("/type:\\s*'(radio|select)'/", $body)) {
continue;
}
if (!preg_match("/options:\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
continue;
}
$values = [];
if (preg_match_all("/value:\\s*'([^']*)'/", $om[1], $vm)) {
$values = $vm[1];
}
if (!isset($js[$key])) {
$js[$key] = $values;
}
}
}
}
return ['php' => $php, 'js' => $js];
}
function collect_missing_this_callbacks(string $themeRoot): array {
$issues = [];
$adminRoot = $themeRoot . '/src/Admin';
$methodIndex = [];
if (!is_dir($adminRoot)) {
return $issues;
}
$allPhp = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($adminRoot, FilesystemIterator::SKIP_DOTS));
foreach ($allPhp as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$src = file_get_contents($file->getPathname());
if ($src === false) {
continue;
}
if (preg_match_all('/function\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(/', $src, $mm)) {
foreach ($mm[1] as $method) {
$methodIndex[$method] = true;
}
}
}
foreach ($allPhp as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$path = $file->getPathname();
$src = file_get_contents($path);
if ($src === false) {
continue;
}
if (!preg_match_all("/'callback'\\s*=>\\s*\\[\\s*\\\$this\\s*,\\s*'([^']+)'\\s*\\]/", $src, $matches)) {
continue;
}
foreach ($matches[1] as $method) {
if (empty($methodIndex[$method])) {
$issues[] = [
'file' => rel($path, $themeRoot),
'method' => $method,
];
}
}
}
return $issues;
}
function audit_build_manifest(string $themeRoot): array {
$buildDir = $themeRoot . '/assets/admin/build';
$result = [
'present' => is_dir($buildDir),
'entry' => false,
'missing_chunks' => [],
'chunks' => [],
];
if (!$result['present']) {
return $result;
}
$entry = $buildDir . '/index.js';
$result['entry'] = is_file($entry);
if (!$result['entry']) {
return $result;
}
$src = file_get_contents($entry);
if ($src === false) {
return $result;
}
if (preg_match_all('/\\.\\/([A-Za-z0-9_.-]+\\.js)/', $src, $m)) {
$result['chunks'] = array_values(array_unique($m[1]));
foreach ($result['chunks'] as $chunk) {
if (!is_file($buildDir . '/' . $chunk)) {
$result['missing_chunks'][] = $chunk;
}
}
}
return $result;
}
$phpSchemas = collect_php_schema_sections($themeRoot);
$menuPages = collect_menu_pages($themeRoot);
$enums = collect_enum_maps($themeRoot);
$restIssues = collect_missing_this_callbacks($themeRoot);
$build = audit_build_manifest($themeRoot);
$info[] = 'PHP schema domains: ' . implode(', ', array_keys($phpSchemas));
$info[] = 'Menu pages discovered: ' . count($menuPages);
foreach ($menuPages as $page) {
if (($page['kind'] ?? '') !== 'schemaPage') {
continue;
}
$domain = $page['domain'] ?? '';
$section = $page['section'] ?? '';
$key = $page['key'] ?? '(unknown)';
if ($domain === '' || $section === '') {
$errors[] = "schemaPage {$key} missing canonicalDomain/section in {$page['file']}";
continue;
}
if (!isset($phpSchemas[$domain])) {
$errors[] = "schemaPage {$key} domain '{$domain}' has no PHP schema builder";
continue;
}
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
$errors[] = "schemaPage {$key} section '{$domain}/{$section}' missing in PHP schema; known=[" . implode(',', $phpSchemas[$domain]['sections']) . '] files=' . implode(',', $phpSchemas[$domain]['files']);
}
}
foreach ($menuPages as $page) {
if (($page['kind'] ?? '') !== 'customPage') {
continue;
}
$domain = $page['domain'] ?? '';
$section = $page['section'] ?? '';
if ($domain === '' || $section === '') {
continue;
}
if (!isset($phpSchemas[$domain])) {
$warnings[] = "customPage {$page['key']} references domain '{$domain}' without PHP schema map entry";
continue;
}
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
$warnings[] = "customPage {$page['key']} section '{$domain}/{$section}' not in PHP schema (ok if pure operational page)";
}
}
// Enum mismatches only for shared keys with non-empty option sets on both sides.
// Heuristic parsers often capture only the default option from dense PHP arrays;
// treat "PHP is a non-empty proper subset of JS" as a parser warning, not a hard error.
// Hard error when sets are disjoint or PHP introduces values JS does not have.
$shared = array_intersect(array_keys($enums['php']), array_keys($enums['js']));
sort($shared);
foreach ($shared as $key) {
$phpValues = $enums['php'][$key];
$jsValues = $enums['js'][$key];
if ($phpValues === [] && $jsValues === []) {
continue;
}
$phpSorted = $phpValues;
$jsSorted = $jsValues;
sort($phpSorted);
sort($jsSorted);
if ($phpSorted === $jsSorted) {
continue;
}
$phpOnly = array_values(array_diff($phpValues, $jsValues));
$jsOnly = array_values(array_diff($jsValues, $phpValues));
if ($phpOnly === [] && $jsOnly !== [] && $phpValues !== []) {
$warnings[] = "enum incomplete parse for '{$key}' (php subset of js): php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . ']';
continue;
}
$errors[] = "enum mismatch for '{$key}': php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . '] php_only=[' . implode(',', $phpOnly) . '] js_only=[' . implode(',', $jsOnly) . ']';
}
foreach ($restIssues as $issue) {
$errors[] = "REST \$this callback missing: {$issue['method']} in {$issue['file']}";
}
if (!$build['present']) {
$warnings[] = 'assets/admin/build/ missing — run: cd assets/admin && npm run build';
} elseif (!$build['entry']) {
$errors[] = 'assets/admin/build/index.js missing';
} elseif ($build['missing_chunks']) {
$errors[] = 'admin build missing chunks: ' . implode(', ', $build['missing_chunks']);
} else {
$info[] = 'admin build ok; chunks=' . count($build['chunks']);
}
echo "AKSRT admin contract audit\n";
echo str_repeat('=', 48) . "\n";
foreach ($info as $line) {
echo "[info] {$line}\n";
}
foreach ($warnings as $line) {
echo "[warn] {$line}\n";
}
foreach ($errors as $line) {
echo "[error] {$line}\n";
}
echo str_repeat('-', 48) . "\n";
echo 'errors=' . count($errors) . ' warnings=' . count($warnings) . "\n";
$summaryPath = $themeRoot . '/tools/admin-contract-report.json';
file_put_contents($summaryPath, json_encode([
'generated_at' => gmdate('c'),
'errors' => $errors,
'warnings' => $warnings,
'info' => $info,
'domains' => $phpSchemas,
'page_count' => count($menuPages),
'build' => $build,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
echo "wrote tools/admin-contract-report.json\n";
// Ignore report in git by default? Keep it for CI artifact; do not commit noise if huge.
exit(count($errors) > 0 ? 1 : 0);