Files
ulthon_admin/extend/base/admin/service/MenuAlignServiceBase.php
augushong e54c772868 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>
2026-09-09 14:37:56 +00:00

132 lines
5.2 KiB
PHP
Raw 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
/**
* @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);
}
}
}