Files
zyt/identity-admin/.test-runtime/consistency-baseline.php
2026-11-11 14:22:02 +08:00

252 lines
19 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Source-only, credential-safe consistency baseline. Never bootstraps the app,
* reads a session, signs in, or writes business data. Only the report is written.
*/
use app\service\shared\SourceMapper;
require dirname(__DIR__) . '/server/app/exception/ApiError.php';
require dirname(__DIR__) . '/server/app/service/shared/SourceMapper.php';
function canonicalHash(array $rows): string
{
return hash('sha256', json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
}
function numericRows(array $rows, array $stringFields = []): array
{
foreach ($rows as &$row) {
foreach ($row as $field => &$value) {
if (!in_array($field, $stringFields, true) && $value !== null) { $value = (int) $value; }
}
unset($value);
}
unset($row);
return $rows;
}
function treeStats(array $rows): array
{
$parents = [];
foreach ($rows as $row) { $parents[(int) $row['id']] = (int) $row['pid']; }
$orphans = 0;
$roots = 0;
$selfParents = 0;
foreach ($parents as $id => $pid) {
if ($pid === 0) { $roots++; }
elseif (!isset($parents[$pid])) { $orphans++; }
if ($pid === $id) { $selfParents++; }
}
$cycles = [];
$cycleNodes = [];
$rootReachable = 0;
$maxDepth = 0;
foreach ($parents as $start => $unused) {
$path = [];
$id = $start;
while ($id !== 0 && isset($parents[$id]) && !isset($path[$id])) {
$path[$id] = count($path);
$id = $parents[$id];
}
if ($id === 0) { $rootReachable++; $maxDepth = max($maxDepth, count($path)); }
elseif (isset($path[$id])) {
$nodes = array_slice(array_keys($path), $path[$id]);
sort($nodes, SORT_NUMERIC);
$cycles[implode(',', $nodes)] = true;
foreach ($nodes as $node) { $cycleNodes[$node] = true; }
}
}
return ['roots' => $roots, 'orphans' => $orphans, 'cycles' => count($cycles),
'cycle_nodes' => count($cycleNodes), 'self_parents' => $selfParents,
'root_reachable_nodes' => $rootReachable, 'max_root_depth' => $maxDepth];
}
function relationStats(array $rows, string $leftKey, string $rightKey, array $leftLive, array $rightLive, array $leftAll, array $rightAll): array
{
$pairs = [];
$missing = $deleted = $valid = 0;
foreach ($rows as $row) {
$left = (int) $row[$leftKey];
$right = (int) $row[$rightKey];
$pairs[$left . ':' . $right] = ($pairs[$left . ':' . $right] ?? 0) + 1;
if (!isset($leftAll[$left]) || !isset($rightAll[$right])) { $missing++; }
elseif (!isset($leftLive[$left]) || !isset($rightLive[$right])) { $deleted++; }
else { $valid++; }
}
return ['count' => count($rows), 'valid_live_endpoints' => $valid,
'invalid_missing_endpoint' => $missing, 'invalid_soft_deleted_endpoint_only' => $deleted,
'invalid_total' => $missing + $deleted,
'duplicate_pair_extra_rows' => array_sum(array_map(static fn (int $n): int => max(0, $n - 1), $pairs)),
'sha256' => canonicalHash($rows)];
}
try {
$root = dirname(__DIR__, 2);
$environment = parse_ini_file($root . '/server/.env', true, INI_SCANNER_RAW);
if (!is_array($environment)) { throw new RuntimeException('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);
// Read-only transaction applies to all following data reads. No app runtime is loaded.
$pdo->exec('START TRANSACTION READ ONLY');
$began = gmdate('c');
$columns = [];
$table = static fn (string $name): string => '`' . $prefix . $name . '`';
foreach (['admin', 'system_role', 'system_menu', 'dept', 'admin_role', 'admin_dept', 'system_role_menu'] as $name) {
$columns[$name] = $pdo->query('SHOW COLUMNS FROM ' . $table($name))->fetchAll(PDO::FETCH_COLUMN);
}
$fields = [
'admin' => ['id', 'root', 'disable', 'create_time'],
'system_role' => ['id', 'sort', 'data_scope', 'create_time'],
'system_menu' => ['id', 'pid', 'type', 'sort', 'is_disable', 'is_show', 'is_cache'],
'dept' => ['id', 'pid', 'sort', 'status'],
];
$live = $allIds = $counts = [];
foreach ($fields as $name => $selected) {
if (array_diff($selected, $columns[$name]) !== []) { throw new RuntimeException('Required source column absent'); }
$hasDelete = in_array('delete_time', $columns[$name], true);
$where = $hasDelete ? ' WHERE delete_time IS NULL' : '';
$sqlFields = implode(',', array_map(static fn (string $field): string => '`' . $field . '`', $selected));
$live[$name] = numericRows($pdo->query('SELECT ' . $sqlFields . ' FROM ' . $table($name) . $where . ' ORDER BY id ASC')->fetchAll(PDO::FETCH_ASSOC), ['type']);
$allIds[$name] = array_fill_keys(array_map('intval', $pdo->query('SELECT id FROM ' . $table($name))->fetchAll(PDO::FETCH_COLUMN)), true);
$stateField = match ($name) { 'admin' => 'disable', 'system_menu' => 'is_disable', 'dept' => 'status', default => null };
$enabled = $disabled = $unexpected = 0;
foreach ($live[$name] as $row) {
if ($stateField === null) { continue; }
$status = $row[$stateField];
if (!in_array($status, [0, 1], true)) { $unexpected++; }
elseif ($status === ($name === 'dept' ? 1 : 0)) { $enabled++; }
else { $disabled++; }
}
$counts[$name] = ['live' => count($live[$name]), 'soft_deleted' => count($allIds[$name]) - count($live[$name]),
'has_soft_delete_column' => $hasDelete, 'enabled' => $stateField === null ? null : $enabled,
'disabled' => $stateField === null ? null : $disabled, 'unexpected_status' => $unexpected,
'sha256' => canonicalHash($live[$name])];
}
$liveIds = [];
foreach ($live as $name => $rows) { $liveIds[$name] = array_fill_keys(array_column($rows, 'id'), true); }
$relations = $relationMetrics = [];
$relationDefinitions = [
'admin_role' => ['admin_id', 'role_id', 'admin', 'system_role'],
'admin_dept' => ['admin_id', 'dept_id', 'admin', 'dept'],
'system_role_menu' => ['role_id', 'menu_id', 'system_role', 'system_menu'],
];
foreach ($relationDefinitions as $name => [$left, $right, $leftTable, $rightTable]) {
$relations[$name] = numericRows($pdo->query('SELECT `' . $left . '`,`' . $right . '` FROM ' . $table($name)
. ' ORDER BY `' . $left . '` ASC,`' . $right . '` ASC')->fetchAll(PDO::FETCH_ASSOC));
$relationMetrics[$name] = relationStats($relations[$name], $left, $right, $liveIds[$leftTable], $liveIds[$rightTable], $allIds[$leftTable], $allIds[$rightTable]);
}
$pdo->rollBack();
$pdo = null;
$byAdminRoles = $byAdminDepts = $byRoleMenus = $roleUsers = $deptDirect = [];
foreach ($relations['admin_role'] as $r) { $byAdminRoles[$r['admin_id']][] = $r['role_id']; $roleUsers[$r['role_id']] = ($roleUsers[$r['role_id']] ?? 0) + 1; }
foreach ($relations['admin_dept'] as $r) {
$byAdminDepts[$r['admin_id']][] = $r['dept_id'];
if (isset($liveIds['admin'][$r['admin_id']]) && isset($liveIds['dept'][$r['dept_id']])) { $deptDirect[$r['dept_id']] = ($deptDirect[$r['dept_id']] ?? 0) + 1; }
}
foreach ($relations['system_role_menu'] as $r) { $byRoleMenus[$r['role_id']][] = $r['menu_id']; }
$trees = ['system_menu' => treeStats($live['system_menu']), 'dept' => treeStats($live['dept'])];
$deptChildren = [];
foreach ($live['dept'] as $row) { $deptChildren[$row['pid']][] = $row['id']; }
$deptTotal = static function (int $id, array $seen = []) use (&$deptTotal, $deptChildren, $deptDirect): int {
if (isset($seen[$id])) { throw new RuntimeException('Cannot roll up cyclic department tree'); }
$seen[$id] = true;
$n = $deptDirect[$id] ?? 0;
foreach ($deptChildren[$id] ?? [] as $child) { $n += $deptTotal($child, $seen); }
return $n;
};
$mapping = [];
$recordComparison = static function (string $name, array $source, array $mapped) use (&$mapping): void {
$mapping[$name] ??= ['compared' => 0, 'mismatches' => 0];
$mapping[$name]['compared']++;
if ($source !== $mapped) { $mapping[$name]['mismatches']++; }
};
foreach ($live['admin'] as $row) {
// Strings are synthetic: no personal name, account, phone, or secret is selected.
$source = $row + ['name' => 'synthetic', 'account' => 'synthetic', 'role_id' => $byAdminRoles[$row['id']] ?? [], 'dept_id' => $byAdminDepts[$row['id']] ?? []];
$mapped = SourceMapper::user($source);
$recordComparison('admin_fields', $row, ['id' => $mapped['id'], 'root' => $mapped['is_root'], 'disable' => $mapped['disable'], 'create_time' => $mapped['create_time']]);
$recordComparison('admin_role_ids', $source['role_id'], $mapped['role_id']);
$recordComparison('admin_dept_ids', $source['dept_id'], $mapped['dept_id']);
}
foreach ($live['system_role'] as $row) {
$source = $row + ['name' => 'synthetic', 'menu_id' => $byRoleMenus[$row['id']] ?? [], 'num' => $roleUsers[$row['id']] ?? 0];
$mapped = SourceMapper::role($source);
$recordComparison('role_fields', $row, ['id' => $mapped['id'], 'sort' => $mapped['sort'], 'data_scope' => $mapped['data_scope'], 'create_time' => $mapped['create_time']]);
$recordComparison('role_menu_ids', $source['menu_id'], $mapped['menu_id']);
$recordComparison('role_user_count', ['num' => $source['num']], ['num' => $mapped['num']]);
}
foreach ($live['system_menu'] as $row) {
$mapped = SourceMapper::menu($row + ['name' => 'synthetic']);
$recordComparison('menu_fields', $row, ['id' => $mapped['id'], 'pid' => $mapped['pid'], 'type' => $mapped['type'], 'sort' => $mapped['sort'], 'is_disable' => $mapped['disable'], 'is_show' => $mapped['is_show'], 'is_cache' => $mapped['is_cache']]);
}
foreach ($live['dept'] as $row) {
$source = $row + ['name' => 'synthetic', 'admin_count' => $trees['dept']['cycles'] === 0 ? $deptTotal($row['id']) : 0];
$mapped = SourceMapper::dept($source);
$recordComparison('dept_fields', $row, ['id' => $mapped['id'], 'pid' => $mapped['pid'], 'sort' => $mapped['sort'], 'status' => 1 - $mapped['disable']]);
$recordComparison('dept_rollup_count', ['admin_count' => $source['admin_count']], ['admin_count' => $mapped['admin_count']]);
}
$report = [
'# 原库四类数据只读基线', '',
'采集时间(UTC):' . $began, '',
'**范围:此报告是原数据库基线及 SourceMapper 静态/进程内映射检查,不代表已完成实际账号登录、页面或 API 对比。**', '',
'脚本在 PHP 进程内读取原项目数据库配置,使用 `START TRANSACTION READ ONLY`,事务内仅执行 `SHOW COLUMNS` 和 `SELECT`,最后回滚。没有启动原应用,没有读取任何会话/token,没有登录或借用管理员身份,没有写入业务数据。', '',
'不查询、不保存个人姓名、账号、手机号、部门联系人、密码/hash、会话或 token。表的姓名/账号等字符串由合成占位值用于映射结构检查;因此这些实际值未被验证。', '',
'## 数量与状态', '',
'| 表 | 未软删 | 已软删 | 启用 | 停用 | 非0/1状态 |',
'|---|---:|---:|---:|---:|---:|',
];
foreach ($counts as $name => $c) {
$report[] = '| ' . $name . ' | ' . $c['live'] . ' | ' . $c['soft_deleted'] . ' | ' . ($c['enabled'] ?? '不适用') . ' | ' . ($c['disabled'] ?? '不适用') . ' | ' . $c['unexpected_status'] . ' |';
}
array_push($report, '', '启用口径:`admin.disable=0`;`system_menu.is_disable=0`;`dept.status=1`。`system_role` 原模型/列表无停用字段,SourceMapper 固定补 `disable=0`,不能解释为原库存在可停用角色。菜单模型不使用软删除;若表无 delete_time,未软删数量即全表数量。', '', '## 可重复的规范摘要', '',
'JSON 用固定字段顺序,整型字段转 PHP int,按 id 升序后 SHA-256。规范字段排除所有真实姓名、账号、手机号及自由文本;只输出摘要,不保存逐行原值。', '', '| 表 | 规范字段(固定顺序) | SHA-256 |', '|---|---|---|');
foreach ($counts as $name => $c) { $report[] = '| ' . $name . ' | `' . implode(',', $fields[$name]) . '` | `' . $c['sha256'] . '` |'; }
array_push($report, '', '## 关系完整性', '',
'检查涉及四类实体的三种关系;关系按左ID、右ID升序,重复行保留。缺失端点优先计入「端点不存在」,仅两端实际存在但至少一端软删计入「仅端点软删」,二者互斥。', '',
'| 关系表 | 总行数 | 两端未删有效 | 端点不存在 | 仅端点软删 | 无效总数 | 重复对额外行 |', '|---|---:|---:|---:|---:|---:|---:|');
foreach ($relationMetrics as $name => $m) { $report[] = '| ' . $name . ' | ' . $m['count'] . ' | ' . $m['valid_live_endpoints'] . ' | ' . $m['invalid_missing_endpoint'] . ' | ' . $m['invalid_soft_deleted_endpoint_only'] . ' | ' . $m['invalid_total'] . ' | ' . $m['duplicate_pair_extra_rows'] . ' |'; }
$report[] = '';
foreach ($relationMetrics as $name => $m) { $report[] = '- `' . $name . '(' . $relationDefinitions[$name][0] . ',' . $relationDefinitions[$name][1] . ')` SHA-256:`' . $m['sha256'] . '`'; }
array_push($report, '', '原 `AdminLists` 的 role_id/dept_id 访问器直接读关联表,不自动排除软删目标。原 `RoleLists.num` 直接计算 admin_role 行数,也不排除软删账号。原部门 admin_count 则排除软删账号,并累加直属和全部下级的关联行数;同一账号跨部门出现可能多次计数,不能改作全子树 DISTINCT 人数后再宣称相等。', '', '## 树完整性', '',
'| 表 | pid=0根 | 父节点缺失的孤儿 | 环数 | 环上节点 | 自指 | 可达根节点数 | 最大根路径深度 |', '|---|---:|---:|---:|---:|---:|---:|---:|');
foreach ($trees as $name => $m) { $report[] = '| ' . $name . ' | ' . implode(' | ', array_values($m)) . ' |'; }
array_push($report, '', '孤儿按「pid非0且父ID不在未软删实体集合」统计;环按同一组环节点去重,包含自环。深度包含节点自身。原部门树以最小 pid 为起点,菜单树通常以 pid=0 为起点;只有可达节点才能在实际树响应出现,故数据库总行数不能直接等同页面可见数。', '', '## SourceMapper 进程内比较', '',
'从本次只读快照构造原列表的非敏感字段/关系ID,再调用实际 SourceMapper 并反向规范化字段。这里没有请求 API。', '', '| 比较项 | 已比较记录 | 不一致记录 |', '|---|---:|---:|');
foreach ($mapping as $name => $m) { $report[] = '| ' . $name . ' | ' . $m['compared'] . ' | ' . $m['mismatches'] . ' |'; }
array_push($report, '', '字段映射规则:`root → is_root`、`is_disable → disable`、`status → 1-disable`;角色原字段保留,补充的 disable 是固定值。角色/部门展示串由 names() 保留完整字符串,避免以斜线拆分引入名称歧义。', '',
'菜单管理的 tree() 保留原节点 ID、父ID与子树结构。导航 navigation() 会有意重写入口名称、父ID、路由、组件并把 dept.dept/lists 别名映射为 auth.dept/lists,且筛选四个管理入口,不能拿导航列表作为全菜单一致性证据。', '',
'当前映射只覆盖独立后台需要的字段,并非原 API 全字段无损镜像:AdminLists 还提供 jobs_id/jobs_name、login_time/login_ip、multipoint_login、avatar 与医生资料等;用户映射省略这些;部门映射省略原时间戳、status_desc、level(保留树 children);菜单映射省略原时间戳;原角色字段均保留并额外补状态。全字段比较必须先明确预期字段集合,不能把这些有意投影差异报告成数据丢失。', '',
'未验证实际字符串值:真实姓名、账号、角色名称/说明、菜单名称/路由/权限字符串、部门联系人/手机号未参与本次查询或摘要。SourceMapper 对字符串主要透传/类型转换的检查来自源码阅读,不能代替实际 API 比较。', '', '## 必须处理与后续验证', '');
$mismatchCount = array_sum(array_column($mapping, 'mismatches'));
$cycleCount = array_sum(array_column($trees, 'cycles'));
$duplicateCount = array_sum(array_column($relationMetrics, 'duplicate_pair_extra_rows'));
$report[] = '- 本次非敏感字段/关系映射不一致数:' . $mismatchCount . '。' . ($mismatchCount === 0 ? '在本次已验证投影范围内未发现必须修复的 SourceMapper 字段映射问题。' : '存在不一致,需在宣称一致前处理,详见比较表。');
$report[] = '- 树环数:' . $cycleCount . ';关系重复额外行数:' . $duplicateCount . '。' . ($duplicateCount ? 'SourceMapper::ids 会去重,遇到重复关联时无法无损保留原关联行多重性。' : '当前关系数据未触发 ids() 去重导致的多重性损失。');
$report[] = '- 历史无效引用应与原 API 的实际呈现口径核对;不能为得到相同数量而擅自清理、迁移或修补业务数据。';
$report[] = '- 必须继续使用用户实际提供的原项目账号正常登录,逐页读取原/独立后台的四类 API 并按 ID、状态、字段及关系对比,核验权限导致的可见范围、分页和树可达性;本报告没有执行该步骤。';
$report[] = '';
$report[] = '源码依据:`server/app/adminapi/lists/auth/AdminLists.php`、`RoleLists.php`、`MenuLists.php`;`server/app/adminapi/logic/dept/DeptLogic.php`;相关原模型;`identity-admin/server/app/service/shared/SourceMapper.php`、`SharedRecordsService.php`。';
$report[] = '';
$reportPath = $root . '/artifacts/iam-extraction/data-consistency-baseline.md';
if (!is_dir(dirname($reportPath))) { throw new RuntimeException('Report directory missing'); }
if (file_put_contents($reportPath, implode(PHP_EOL, $report)) === false) { throw new RuntimeException('Report write failed'); }
echo json_encode(['ok' => true, 'report' => 'artifacts/iam-extraction/data-consistency-baseline.md',
'table_counts' => array_map(static fn (array $m): int => $m['live'], $counts),
'mapping_mismatches' => $mismatchCount, 'tree_cycles' => $cycleCount,
'invalid_relation_rows' => array_sum(array_column($relationMetrics, 'invalid_total'))], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . PHP_EOL;
} catch (Throwable $error) {
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) { $pdo->rollBack(); }
// Never print connection errors, SQL contents, credentials, or raw source rows.
echo json_encode(['ok' => false, 'error_code' => (string) $error->getCode(), 'message' => 'Read-only baseline failed; no credentials or source records printed.']) . PHP_EOL;
exit(1);
}