158 lines
5.6 KiB
PHP
158 lines
5.6 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\MenuModel;
|
||
use App\Models\RoleMenuRelationModel;
|
||
use App\Service\common\RedisService;
|
||
use Illuminate\Console\Command;
|
||
|
||
/**
|
||
* 按 config/lgp_menu.php 幂等同步 nl_menu
|
||
*
|
||
* name 是全局唯一键,同步以它为准:存在则按声明纠正字段,不存在则插入。
|
||
* pid 用父节点的 name 反查,所以配置里不出现任何自增 id,反复执行结果一致。
|
||
* 同步完必须清菜单缓存,否则前端还会拿到 Redis 里的旧树。
|
||
*/
|
||
class MenuSyncCommand extends Command
|
||
{
|
||
protected $signature = 'lgp:menu-sync
|
||
{--dry-run : 只预演不写库}
|
||
{--grant-super : 同步后把新菜单补进超级管理员之外的角色(默认不动角色关系)}';
|
||
|
||
protected $description = '同步 LGP 业务菜单到 nl_menu(幂等)';
|
||
|
||
private bool $dryRun = false;
|
||
|
||
private int $created = 0;
|
||
|
||
private int $updated = 0;
|
||
|
||
public function handle(): int
|
||
{
|
||
$this->dryRun = (bool) $this->option('dry-run');
|
||
if ($this->dryRun) {
|
||
$this->warn('预演模式:不会写入任何数据');
|
||
}
|
||
|
||
$tree = config('lgp_menu', []);
|
||
if (empty($tree)) {
|
||
$this->error('config/lgp_menu.php 为空');
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$this->walk($tree, 0);
|
||
|
||
$this->newLine();
|
||
$this->info("新增 {$this->created} 条,更新 {$this->updated} 条");
|
||
|
||
if (!$this->dryRun) {
|
||
// 菜单树按角色缓存在 Redis,不清的话前端看不到新菜单
|
||
RedisService::getInstance()->init(config('nl.redis.menu_key'))->delAll();
|
||
$this->info('已清空菜单缓存');
|
||
$this->reportRoleBinding();
|
||
}
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array<string, mixed>> $nodes
|
||
*/
|
||
private function walk(array $nodes, int $pid): void
|
||
{
|
||
foreach ($nodes as $node) {
|
||
// parent 显式声明时优先(用于挂到脚手架已有菜单下),否则跟随递归层级
|
||
$parentId = isset($node['parent'])
|
||
? $this->resolveParentId((string) $node['parent'])
|
||
: $pid;
|
||
$id = $this->upsert($node, $parentId);
|
||
if (!empty($node['children'])) {
|
||
$this->walk($node['children'], $id);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function resolveParentId(string $name): int
|
||
{
|
||
$id = (int) MenuModel::where('name', $name)->where('deleted_at', 0)->value('id');
|
||
if ($id === 0) {
|
||
$this->warn("父级菜单 {$name} 不存在,该节点挂到根目录");
|
||
}
|
||
return $id;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $node
|
||
* @return int 菜单 id(预演模式下返回 0,子节点会挂到根,仅影响预演输出)
|
||
*/
|
||
private function upsert(array $node, int $pid): int
|
||
{
|
||
$isDirectory = ($node['component'] ?? '') === 'BasicLayout';
|
||
$attributes = [
|
||
'title' => (string) $node['title'],
|
||
'icon' => (string) ($node['icon'] ?? ''),
|
||
'path' => (string) $node['path'],
|
||
'component' => (string) $node['component'],
|
||
'redirect' => (string) ($node['redirect'] ?? ''),
|
||
// 取反字段:0 开启缓存 / 0 固定标签
|
||
'keep_alive' => (int) ($node['keep_alive'] ?? ($isDirectory ? 0 : 1)),
|
||
'hide_in_menu' => (int) ($node['hide_in_menu'] ?? 0),
|
||
'affix_tab' => (int) ($node['affix_tab'] ?? 1),
|
||
'badge' => (string) ($node['badge'] ?? ''),
|
||
'badge_type' => (int) ($node['badge_type'] ?? 0),
|
||
'badge_variants' => (int) ($node['badge_variants'] ?? 0),
|
||
'iframe_src' => (string) ($node['iframe_src'] ?? ''),
|
||
'query' => (string) ($node['query'] ?? ''),
|
||
'pid' => $pid,
|
||
'sort' => (int) ($node['sort'] ?? 0),
|
||
];
|
||
|
||
$name = (string) $node['name'];
|
||
$exists = MenuModel::where('name', $name)->first();
|
||
|
||
if ($exists) {
|
||
$diff = array_filter(
|
||
$attributes,
|
||
fn ($value, $key) => (string) $exists->{$key} !== (string) $value,
|
||
ARRAY_FILTER_USE_BOTH
|
||
);
|
||
// 已软删的菜单同步时一并复活,否则改配置也救不回来
|
||
if ((int) $exists->deleted_at !== 0) {
|
||
$diff['deleted_at'] = 0;
|
||
}
|
||
if (empty($diff)) {
|
||
$this->line(" = {$attributes['title']} ({$name})");
|
||
return (int) $exists->id;
|
||
}
|
||
$this->line(" ~ {$attributes['title']} ({$name}) " . implode(', ', array_keys($diff)));
|
||
$this->updated++;
|
||
if (!$this->dryRun) {
|
||
$diff['updated_at'] = time();
|
||
MenuModel::where('id', $exists->id)->update($diff);
|
||
}
|
||
return (int) $exists->id;
|
||
}
|
||
|
||
$this->line(" + {$attributes['title']} ({$name})");
|
||
$this->created++;
|
||
if ($this->dryRun) {
|
||
return 0;
|
||
}
|
||
$attributes['created_at'] = time();
|
||
return (int) MenuModel::insertGetId($attributes);
|
||
}
|
||
|
||
/**
|
||
* 只提示不自动授权:给谁看哪些菜单是业务决策,命令替用户决定容易造成越权
|
||
*/
|
||
private function reportRoleBinding(): void
|
||
{
|
||
if (!$this->option('grant-super')) {
|
||
$bound = RoleMenuRelationModel::distinct()->count('role_id');
|
||
$this->newLine();
|
||
$this->warn("超级管理员(role_id=1)自动可见全部菜单;其余 {$bound} 个已授权角色需到「角色管理」重新勾选新菜单。");
|
||
}
|
||
}
|
||
}
|