feat(admin): 菜单快照对齐迁移,存量库自动补齐运行管理与XHProf菜单

对齐迁移是菜单快照进入数据库的唯一途径(新装与存量库同路径);缺失行按显式 id 补插,框架结构行仅对齐 pid+sort,软删/用户分组跳过;新增规则文件与索引;清理 status 模块孤儿占位文件。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
augushong
2026-09-09 14:37:56 +00:00
parent 95c9126410
commit e54c772868
7 changed files with 274 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
<?php
/**
* @internal-framework
*
* 此文件为框架核心服务
*
* 用途:菜单快照对齐服务(存量库菜单补齐与结构对齐)
* 维护者:框架维护者
*
* 背景:菜单快照数据由对齐迁移在 migrate:run 时写入数据库,新装与存量库走同一条
* 安装/更新路径(phinxlog 保证幂等)。本服务把快照对齐到当前库,供数据型 migration
* 调用,对齐只增不删。
*
* 对齐语义(保守,尊重用户自定义):
* - 行不存在(按 id) → 按快照 INSERT(显式 id)
* - 行存在且挂在框架结构上 → 仅对齐 pid + sort(不动 title/icon/status/href)
* - 行存在但挂在用户自建分组(pid 在框架 id 集合与 {0, 99999999} 之外)→ 跳过不动
* - 行存在但已被软删除(用户隐藏) → 跳过不复活
* - id 被占但 href 与快照不符 → 跳过并告警(疑似用户行占用快照 id,人工介入)
*/
namespace base\admin\service;
use think\console\Output;
use think\facade\Db;
class MenuAlignServiceBase
{
/**
* 快照中「欢迎页」挂载的虚拟 pid(MenuConstant::HOME_PID),不算用户分组.
*/
protected const HOME_PID = 99999999;
/**
* @var Output|null 命令行输出(migration 场景传入,便于追踪对齐过程)
*/
protected $output = null;
public function __construct($output = null)
{
$this->output = $output;
}
/**
* 把 adminInitData/SystemMenu.php 快照对齐到当前库.
*
* @param string $snapshotName 快照文件名(adminInitData/ 目录下,不含扩展名)
*
* @return array{inserted: int, aligned: int, skipped_exists: int, skipped_user: int, warnings: array<int, string>}
*/
public function align(string $snapshotName = 'SystemMenu'): array
{
$snapshot = include __DIR__ . '/adminInitData/' . $snapshotName . '.php';
// 框架合法 pid 集合:快照内全部 id + 顶级 0 + 欢迎页虚拟 pid
$frameworkPids = array_merge(array_column($snapshot, 'id'), [0, self::HOME_PID]);
$stats = [
'inserted' => 0,
'aligned' => 0,
'skipped_exists' => 0,
'skipped_user' => 0,
'warnings' => [],
];
$now = time();
foreach ($snapshot as $row) {
// 注意:每次操作都取全新 Query 对象,复用同一对象会残留 where 条件导致误判。
// 原生查询不过滤软删除:软删行仍占用主键,必须先识别,避免 INSERT 冲突或复活。
$exists = Db::name('system_menu')->where('id', $row['id'])->find();
if (empty($exists)) {
$data = $row;
$data['create_time'] = $now;
$data['update_time'] = $now;
$data['delete_time'] = 0;
Db::name('system_menu')->insert($data);
++$stats['inserted'];
$this->writeln(" - 新增菜单 id={$row['id']} {$row['title']} (pid={$row['pid']})");
continue;
}
// 用户主动软删除(隐藏)的框架菜单:不复活
if ((int) ($exists['delete_time'] ?? 0) !== 0) {
++$stats['skipped_user'];
$this->writeln(" - 菜单 id={$row['id']} {$row['title']} 已被软删除(用户隐藏),跳过");
continue;
}
// id 被非快照行占用:href 不一致(分组行 href 均为空串,仅叶子行可比对)
$snapshotHref = $row['href'] ?? '';
if ($snapshotHref !== '' && ($exists['href'] ?? '') !== $snapshotHref) {
++$stats['skipped_user'];
$stats['warnings'][] = "菜单 id={$row['id']} 已被其他记录占用(href={$exists['href']}),快照行 {$snapshotHref} 未插入,请人工核对";
continue;
}
// 用户已把该行挪进自建分组:不视为框架结构,跳过
if (!in_array((int) $exists['pid'], $frameworkPids, true)) {
++$stats['skipped_user'];
$this->writeln(" - 菜单 id={$row['id']} {$row['title']} 已挂用户分组(pid={$exists['pid']}),跳过");
continue;
}
// 结构对齐:仅 pid + sort,不动文案与状态
if ((int) $exists['pid'] !== (int) $row['pid'] || (int) $exists['sort'] !== (int) $row['sort']) {
Db::name('system_menu')->where('id', $row['id'])->update([
'pid' => $row['pid'],
'sort' => $row['sort'],
'update_time' => $now,
]);
++$stats['aligned'];
$this->writeln(" - 对齐菜单 id={$row['id']} {$row['title']} (pid {$exists['pid']}->{$row['pid']}, sort {$exists['sort']}->{$row['sort']})");
continue;
}
++$stats['skipped_exists'];
}
return $stats;
}
protected function writeln(string $message): void
{
if ($this->output !== null) {
$this->output->writeln($message);
}
}
}