86 lines
5.4 KiB
PHP
86 lines
5.4 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/** Independent source-only tree expectations; no app, session, or token bootstrap. */
|
|
function treeCellTrim(string $value): string
|
|
{
|
|
$space = '[\x{0009}-\x{000D}\x{0020}\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]';
|
|
return preg_replace('/^' . $space . '+|' . $space . '+$/u', '', $value) ?? '';
|
|
}
|
|
|
|
try {
|
|
$root = dirname(__DIR__, 2);
|
|
$environment = @parse_ini_file($root . '/server/.env', true, INI_SCANNER_RAW);
|
|
if (!is_array($environment)) { throw new RuntimeException('Source configuration unavailable'); }
|
|
$database = array_change_key_case($environment['database'] ?? [], CASE_LOWER);
|
|
$prefix = (string) ($database['prefix'] ?? '');
|
|
if (!preg_match('/^[a-zA-Z0-9_]*$/D', $prefix)) { throw new RuntimeException('Invalid prefix'); }
|
|
$pdo = new PDO('mysql:host=' . $database['hostname'] . ';port=' . ($database['hostport'] ?? '3306')
|
|
. ';dbname=' . $database['database'] . ';charset=utf8mb4', $database['username'], $database['password'],
|
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5, PDO::ATTR_EMULATE_PREPARES => false]);
|
|
unset($environment, $database);
|
|
$table = static fn (string $name): string => '`' . $prefix . $name . '`';
|
|
$pdo->exec('START TRANSACTION READ ONLY');
|
|
$generatedAt = gmdate('c');
|
|
$menus = $pdo->query('SELECT id,pid FROM ' . $table('system_menu') . ' ORDER BY id ASC')->fetchAll(PDO::FETCH_ASSOC);
|
|
$departments = $pdo->query('SELECT id,pid,name FROM ' . $table('dept') . ' WHERE delete_time IS NULL ORDER BY id ASC')->fetchAll(PDO::FETCH_ASSOC);
|
|
$pdo->rollBack();
|
|
$pdo = null;
|
|
|
|
foreach ($menus as &$menu) { $menu = ['id' => (int) $menu['id'], 'pid' => (int) $menu['pid']]; }
|
|
unset($menu);
|
|
$parentById = $nameById = [];
|
|
foreach ($departments as $department) {
|
|
$parentById[(int) $department['id']] = (int) $department['pid'];
|
|
$nameById[(int) $department['id']] = treeCellTrim((string) $department['name']);
|
|
}
|
|
$rootPid = $parentById === [] ? 0 : min(array_values($parentById));
|
|
if ($rootPid !== 0) { throw new RuntimeException('Source department tree root changed'); }
|
|
$depthById = [];
|
|
$resolveDepth = static function (int $id, array $seen = []) use (&$resolveDepth, &$depthById, $parentById): int {
|
|
if (isset($depthById[$id])) { return $depthById[$id]; }
|
|
if (!array_key_exists($id, $parentById) || isset($seen[$id])) { throw new RuntimeException('Source department tree is incomplete or cyclic'); }
|
|
$seen[$id] = true;
|
|
$parentId = $parentById[$id];
|
|
return $depthById[$id] = $parentId === 0 ? 0 : $resolveDepth($parentId, $seen) + 1;
|
|
};
|
|
$departmentHashes = [];
|
|
foreach ($departments as $department) {
|
|
$id = (int) $department['id'];
|
|
$depth = $resolveDepth($id);
|
|
$ancestors = [];
|
|
$ancestorId = $parentById[$id];
|
|
while ($ancestorId !== 0) {
|
|
$ancestors[] = $nameById[$ancestorId];
|
|
$ancestorId = $parentById[$ancestorId];
|
|
}
|
|
$cells = [$nameById[$id], (string) $depth, ...array_reverse($ancestors)];
|
|
$json = json_encode($cells, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS | JSON_THROW_ON_ERROR);
|
|
$departmentHashes[] = ['hash' => hash('sha256', $json)];
|
|
}
|
|
unset($departments, $department, $nameById, $ancestors, $cells, $json);
|
|
usort($departmentHashes, static fn (array $a, array $b): int => strcmp($a['hash'], $b['hash']));
|
|
$output = ['schema' => ['version' => 1, 'generated_at_utc' => $generatedAt,
|
|
'source' => 'original tables, independent SELECT statements inside a read-only transaction',
|
|
'menus' => 'all source menu rows as {id,pid}, sorted numerically by id',
|
|
'departments' => 'all non-soft-deleted departments as {hash}; multiset sorted by hash, retaining duplicates',
|
|
'department_hash' => 'SHA256(UTF8(JSON.stringify([name.trim(), String(depth), ...ancestorNamesRootToParent.map(name => name.trim())])))',
|
|
'department_depth' => 'root=0; follow pid to parent; source DeptLogic tree starts at min(pid)=0',
|
|
'trim' => 'JavaScript String.trim() Unicode whitespace semantics',
|
|
'php_json_flags' => ['JSON_UNESCAPED_UNICODE', 'JSON_UNESCAPED_SLASHES', 'JSON_UNESCAPED_LINE_TERMINATORS'],
|
|
'privacy' => 'No source business text, account, phone, password, session or token saved'],
|
|
'counts' => ['menus' => count($menus), 'departments' => count($departmentHashes)],
|
|
'menus' => $menus, 'departments' => $departmentHashes];
|
|
$encoded = json_encode($output, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
|
if (file_put_contents(__DIR__ . '/tree-expected-hashes.json', $encoded . PHP_EOL) === false) {
|
|
throw new RuntimeException('Tree expectation output failed');
|
|
}
|
|
echo json_encode(['ok' => true, 'counts' => $output['counts'], 'department_root_pid' => $rootPid,
|
|
'department_max_depth' => $depthById === [] ? 0 : max($depthById)], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL;
|
|
} catch (Throwable $error) {
|
|
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) { $pdo->rollBack(); }
|
|
echo json_encode(['ok' => false, 'error_code' => (string) $error->getCode(),
|
|
'message' => 'Tree expectation failed; source text and credentials were not printed.']) . PHP_EOL;
|
|
exit(1);
|
|
}
|