252 lines
19 KiB
PHP
252 lines
19 KiB
PHP
<?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);
|
||
}
|