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,70 @@
# 初始化数据变更与迁移铁律
> 来源:框架内置(ulthon-)
> 作用域:`extend/base/admin/service/adminInitData/` 下全部初始化快照(SystemMenu、SystemConfig、SystemAdmin 等)及其配套数据迁移
> 触发条件:新增/修改/重组任何初始化数据、快照或相关迁移时加载
## 背景:迁移是唯一安装命令
`php think migrate:run` 是唯一的安装命令,一条命令完成建表 + 全部初始数据(超管、角色、权限节点、配置、快捷入口、商城演示、菜单)。种子机制已退役:框架自身零种子,`seed:run` 成为空操作,仅保留给下游业务项目放自己的种子。
| 路径 | 机制 |
|------|------|
| 全新安装 | `migrate:run` 建表并写入全部初始数据(phinxlog 记账,幂等) |
| 存量库 | 数据型 migration 经 `migrate:run` 对齐(admin:update 检测到 migrations 变更会提示) |
历史教训:定时器/主机菜单(2026-05 c169646)、XHProf 菜单与运行管理重组(2026-08 8cbe62a)都曾只改快照不带 migration,导致存量库永久缺菜单。这就是「快照变更必须配套迁移」铁律的由来。
## 铁律
1. **已发布迁移永不编辑**:`database/migrations/` 下已发布(进过提交/已在库中执行)的迁移文件永不编辑,任何变更只能新增迁移。
2. **数据迁移必须幂等**:不得依赖「恰好执行一次」。存在性检查 + 原生查询兜底:auth/quick/admin 等含 `delete_time` 的表,判存必须用 `Db::name` 原生查询(或 `withTrashed`)绕过软删除作用域——否则软删行被误判「缺失」导致重放时主键冲突或数据复活。参照 `MenuAlignServiceBase` 已验证模式。
3. **对齐只增不删**:快照行删除或 id 复用属破坏性变更,必须显式编写删除迁移 + 人工确认。既定语义:快照文案/icon 变更与 config value 修改**不自动传播**到已装库,不视为缺陷。
## 快照数据源
`extend/base/admin/service/adminInitData/*.php` 是唯一初始数据源(9 个文件:SystemAdmin、SystemAuth、SystemAuthNode、SystemConfig、SystemQuick、SystemMenu、MallCate、MallGoods、MallTag)。
新增初始数据 = 新增快照 + 新增迁移(或纳入对齐服务),migration 不得硬编码快照数据副本,必须复用快照文件或调用对齐服务(如 `MenuAlignServiceBase`、`InitDataAlignServiceBase`),避免两份数据漂移。
## migration 模板
```php
<?php
use app\admin\service\MenuAlignService;
use think\console\Output;
use think\migration\Migrator;
/**
* 菜单快照对齐 - <本次变更说明>
*/
class SystemMenuAlign extends Migrator
{
public function up()
{
(new MenuAlignService(new Output()))->align('SystemMenu');
}
public function down()
{
// 数据对齐迁移不提供对称回滚,见规则 ulthon-init-data-migration.md
}
}
```
新增菜单时同步操作:`adminInitData/SystemMenu.php` 追加条目(显式 id 接续现有序列)+ 按模板新增迁移(时间戳接续)。其他类型快照需要类似对齐能力时,参照 `MenuAlignServiceBase` 扩展对应服务,不硬编码数据。
## 相关文件
- `extend/base/admin/service/adminInitData/` - 初始化数据快照目录(唯一数据源)
- `extend/base/admin/service/InitDataAlignServiceBase.php` - 六域初始数据对齐编排(超管/角色/配置/快捷入口/商城演示/菜单)
- `app/admin/service/InitDataAlignService.php` - app 层入口(可覆盖)
- `extend/base/admin/service/MenuAlignServiceBase.php` - 菜单对齐服务
- `app/admin/service/MenuAlignService.php` - app 层入口(可覆盖)
- `database/migrations/20260909120000_system_menu_align.php` - 菜单对齐迁移(范本)
- `database/migrations/20260909130000_system_init_data.php` - 全量初始数据迁移
## 相关规则
- [ulthon-file-override-mechanism.md](./ulthon-file-override-mechanism.md) - app 层覆盖 extend/base 的三类文件

View File

@@ -4,6 +4,13 @@
## 项目信息 ## 项目信息
- 项目概述:ulthon_admin 框架本身(框架作者开发模式,非下游业务项目)
- 运行方式:使用框架内置 docker-dev 栈(`source/stack/docker-dev/`)。启动:`cd source/stack/docker-dev && docker compose up -d`
- 命令执行方式:`php think` 一律在容器内执行,不在宿主机直接执行。模式目录内:`docker compose exec ulthon_admin php think ...`;仓库根目录:`docker compose -f source/stack/docker-dev/docker-compose.yaml exec ulthon_admin php think ...`
- 端口:应用 8000、MySQL 13306(容器内 3306,root/root,库名 ulthon)、Redis 16379、phpMyAdmin 18888
- 访问地址:<http://localhost:8000/admin>(后台,默认账号 admin / 123456)
- 注意:宿主机 `docker compose exec` 默认以 root 执行,产生的 runtime 文件 www-data 不可写;遇到 runtime 权限问题时 `chmod -R a+rwX runtime`(模式目录内 exec 可加 `--user www-data` 规避)
<!-- 由开发者维护:写任何智能体必须先知道的项目特有信息,格式不限、按需增删,不限于建议项。 <!-- 由开发者维护:写任何智能体必须先知道的项目特有信息,格式不限、按需增删,不限于建议项。
建议涵盖: 建议涵盖:
- 项目概述(做什么的、服务于谁) - 项目概述(做什么的、服务于谁)
@@ -53,6 +60,7 @@
| [ulthon-source-directory.md](./.agents/rules/ulthon-source-directory.md) | source/ 目录约定与安全要求 | | [ulthon-source-directory.md](./.agents/rules/ulthon-source-directory.md) | source/ 目录约定与安全要求 |
| [ulthon-file-override-mechanism.md](./.agents/rules/ulthon-file-override-mechanism.md) | app/ 覆盖 extend/base/ 的三类文件 | | [ulthon-file-override-mechanism.md](./.agents/rules/ulthon-file-override-mechanism.md) | app/ 覆盖 extend/base/ 的三类文件 |
| [ulthon-system-config.md](./.agents/rules/ulthon-system-config.md) | sysconfig 的存储/读取/扩展 | | [ulthon-system-config.md](./.agents/rules/ulthon-system-config.md) | sysconfig 的存储/读取/扩展 |
| [ulthon-init-data-migration.md](./.agents/rules/ulthon-init-data-migration.md) | 初始化数据变更必须同提交配套数据型 migration |
## 标准开发流程(Scheme + CURD) ## 标准开发流程(Scheme + CURD)

View File

@@ -0,0 +1,16 @@
<?php
namespace app\admin\service;
use base\admin\service\MenuAlignServiceBase;
/**
* 菜单快照对齐服务(app 层入口).
*
* 存量库菜单补齐与结构对齐:由数据型 migration 调用,把
* extend/base/admin/service/adminInitData/SystemMenu.php 快照对齐到当前库。
* 需要定制对齐行为时覆盖父类方法,保持方法签名一致。
*/
class MenuAlignService extends MenuAlignServiceBase
{
}

View File

@@ -0,0 +1,37 @@
<?php
use app\admin\service\MenuAlignService;
use think\console\Output;
use think\migration\Migrator;
/**
* 系统菜单快照对齐 - 存量库补齐与「运行管理」结构重组
*
* 背景:本迁移是菜单快照数据进入数据库的唯一途径——新装与存量库均通过 migrate:run
* 执行。快照在 2026-08-15(8cbe62a)重组出「运行管理」分组(309/310/311)并补录
* XHProf 四条菜单(304~307),彼时该批变更仅对新装生效、存量库无升级脚本覆盖,
* 本迁移补上这一缺口。
*
* 本迁移调用 MenuAlignService 把当前快照整体对齐到库:
* - 缺失行按显式 id 补插(含 304~307/309~311 与后续快照新增的 314 日志读取位置)
* - 挂在框架结构上的存量行仅对齐 pid + sort(运行管理重组的 pid 迁移)
* - 用户自定义(软删除 / 自建分组 / id 占用)一律跳过,详见服务类注释
*
* 幂等:phinxlog 按文件记账保证只执行一次;重放由服务内的存在性检查兜底。
* down:数据对齐迁移不提供对称回滚(无法精确还原对齐前的用户态结构),
* 如需回退参考 adminInitData/SystemMenu.php 快照手工处理。
*/
class SystemMenuAlign extends Migrator
{
public function up()
{
$service = new MenuAlignService(new Output());
$service->align('SystemMenu');
}
public function down()
{
// 有意留空:见类注释 down 说明
}
}

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);
}
}
}

View File

@@ -231,6 +231,18 @@ $ul_system_menu = array(
"sort" => 0, "sort" => 0,
"status" => 1, "status" => 1,
), ),
array(
"id" => 314,
"pid" => 311,
"title" => "日志读取位置",
"icon" => "fa fa-map-marker",
"href" => "nginx.log_position/index",
"params" => "",
"target" => "_self",
"auth_node" => "nginx.log_position/index",
"sort" => 0,
"status" => 1,
),
array( array(
"id" => 304, "id" => 304,
"pid" => 309, "pid" => 309,