// +---------------------------------------------------------------------- declare(strict_types=1); namespace app\backend\library; use think\facade\Db; use ywxapp\model\BackendPower; /** * 核心后台「新增菜单」注册表(请求级自愈) * * 为什么需要它:核心后台的菜单就是 backend_power 里的一条记录, * 如果只在「访问某个页面时」才补录节点,那菜单本身不存在 ⇒ 点不进去 ⇒ 永远补不上, * 新功能上线后管理员根本看不到入口。 * * 因此与 appmall 侧栏菜单同构(AppmallMenu::ensure()),由 * BackendSchemaMiddleware 在每个 /admin/* 请求上幂等补录:菜单随请求出现, * 老库升级也不用手工去权限管理里加节点。 * * 控制器侧的 EnsuresPower trait 调的是同一个入口,属双保险(进程内 memo,不重复查库)。 * * @author ywxapp */ class MenuRegistry { /** * 进程内只补录一次 * @var bool */ protected static $checked = false; /** * 进程内只迁移一次 * @var bool */ protected static $migrated = false; /** * 补录全部新增菜单(幂等) * @return void */ public static function ensure(): void { if (self::$checked) { return; } // 先标记:即使中途失败也不在本次请求内反复重试 self::$checked = true; // 先做结构调整(顶层目录拆分),再补录新菜单 self::migrate(); foreach (self::menus() as $menu) { try { // 菜单按所属分组挂:配置类进「系统管理」,运维类进「数据运维」 $pid = self::groupPid((string) ($menu['group'] ?? 'system')); self::ensureMenu($menu, $pid); } catch (\Throwable $e) { // 单个菜单失败不影响其它菜单 } } // 只补按钮的菜单(菜单节点在 install.sql 已有种子,但按钮缺失) self::ensureScoreButtons(); } /** * 补录「积分规则」的按钮权限节点(幂等) * * install.sql 早期只种了 score:index,缺 score:add / edit / del / restore / destroy, * 结果后台新增、编辑、删除按钮被前端权限过滤隐藏,规则根本没法维护。 * 老库不会重跑 install.sql,故在此自愈,节点定义与 install.sql 种子保持一致。 * * @return void */ protected static function ensureScoreButtons(): void { try { $menu = Db::name('backend_power')->where('name', 'score:index')->find(); if (! $menu) { return; } $pid = (int) $menu['id']; $buttons = [ 'add' => ['title' => '新增规则', 'route' => 'score/save'], 'edit' => ['title' => '编辑规则', 'route' => 'score/edit'], 'del' => ['title' => '删除规则', 'route' => 'score/delete'], 'restore' => ['title' => '恢复规则', 'route' => 'score/restore'], 'destroy' => ['title' => '彻底删除规则', 'route' => 'score/delete'], ]; $now = time(); $newIds = []; foreach ($buttons as $act => $cfg) { $node = 'score:' . $act; if (Db::name('backend_power')->where('name', $node)->find()) { continue; } $newIds[] = (int) Db::name('backend_power')->insertGetId([ 'pid' => $pid, 'title' => $cfg['title'], 'name' => $node, 'type' => 3, 'route' => $cfg['route'], 'icon' => '', 'sort' => 0, 'addon' => null, 'status' => 1, 'create_at' => $now, 'update_at' => $now, 'delete_at' => 0, ]); } if ($newIds) { self::grantToAllRoles($newIds); } } catch (\Throwable $e) { // 静默:补录失败不应让后台打不开 } } /* ============================ 结构调整(一次性迁移) ============================ */ /** * 顶层目录拆分:「营运」→「内容运营」+「交易营销」(幂等、只跑一次) * * 为什么要在这里做而不是只改 install.sql: * install.sql 只影响全新安装,老库不会重跑,必须有一条自愈路径才能生效。 * * 两个关键约束: * 1. 「营运」节点是原地改名成「内容运营」的,id 不变 ⇒ 角色既有的授权不失效; * 2. 新建的「交易营销」目录 id 是新的,角色授权表里没有它, * 非超管账号会因为父节点不在授权列表里而整个分支都看不到, * 所以迁移末尾要把新节点补授权给所有角色(见 grantToAllRoles())。 * * @return void */ public static function migrate(): void { if (self::$migrated) { return; } self::$migrated = true; try { // 完成标记:分组标题存在即视为已拆分。 // 之后管理员手工调顺序 / 改名不再被本方法覆盖。 if (Db::name('backend_power')->where('name', 'trade.group.goods')->value('id')) { return; } $now = time(); // 1) 内容运营:优先复用原「营运」节点(保住 id 与既有角色授权) $contentId = (int) Db::name('backend_power')->where('name', 'content')->value('id'); if ($contentId <= 0) { $operate = Db::name('backend_power')->where('name', 'operate')->find(); if ($operate) { Db::name('backend_power')->where('id', (int) $operate['id'])->update([ 'title' => '内容运营', 'name' => 'content', 'route' => '/content', 'icon' => 'layui-icon layui-icon-speaker', 'sort' => 4, 'update_at' => $now, ]); $contentId = (int) $operate['id']; } else { $contentId = (int) Db::name('backend_power')->insertGetId([ 'pid' => 0, 'title' => '内容运营', 'name' => 'content', 'type' => 1, 'route' => '/content', 'icon' => 'layui-icon layui-icon-speaker', 'sort' => 4, 'addon' => null, 'status' => 1, 'create_at' => $now, 'update_at' => $now, 'delete_at' => 0, ]); } } // 2) 交易营销:新顶层目录 $tradeId = (int) Db::name('backend_power')->where('name', 'trade')->value('id'); if ($tradeId <= 0) { $tradeId = (int) Db::name('backend_power')->insertGetId([ 'pid' => 0, 'title' => '交易营销', 'name' => 'trade', 'type' => 1, 'route' => '/trade', 'icon' => 'layui-icon layui-icon-cart-simple', 'sort' => 5, 'addon' => null, 'status' => 1, 'create_at' => $now, 'update_at' => $now, 'delete_at' => 0, ]); } // 3) 菜单重挂到新目录并重排 sort $plan = self::splitPlan(); foreach ($plan['content'] as $name => $sort) { Db::name('backend_power')->where('name', $name) ->update(['pid' => $contentId, 'sort' => $sort, 'update_at' => $now]); } foreach ($plan['trade'] as $name => $sort) { Db::name('backend_power')->where('name', $name) ->update(['pid' => $tradeId, 'sort' => $sort, 'update_at' => $now]); } // 4) 交易营销内的分组标题(type=5,只做视觉分组,不可点击) $newIds = []; foreach (self::groupTitles() as $name => $cfg) { $id = (int) Db::name('backend_power')->where('name', $name)->value('id'); if ($id > 0) { continue; } $newIds[] = (int) Db::name('backend_power')->insertGetId([ 'pid' => $tradeId, 'title' => $cfg['title'], 'name' => $name, 'type' => BackendPower::TYPE_GROUP_TITLE, 'route' => '', 'icon' => '', 'sort' => $cfg['sort'], 'addon' => null, 'status' => 1, 'create_at' => $now, 'update_at' => $now, 'delete_at' => 0, ]); } // 5) 新节点补授权给所有角色 self::grantToAllRoles(array_merge([$tradeId], $newIds)); } catch (\Throwable $e) { // 结构调整失败不应让后台打不开:保持静默 } } /** * 拆分方案:[菜单 name => 新 sort] * * 与 public/install/install.sql 的 backend_power 种子保持一致, * 保证「全新安装」与「老库自愈」两条路径结果相同。 * * @return array */ protected static function splitPlan(): array { return [ // 站点内容与展示类 'content' => [ 'links:index' => 1, 'notice:index' => 2, 'ad:index' => 3, 'help:index' => 4, 'spider:index' => 5, ], // 交易、激励与服务类(内部再用分组标题切成三组) 'trade' => [ 'card:index' => 2, 'score:index' => 3, 'task:index' => 11, 'prop:index' => 12, 'medal:index' => 13, 'sms:index' => 21, ], ]; } /** * 目录内分组标题定义(name => [标题, sort]) * * 目前只在「交易营销」内使用:7 个菜单跨越交易 / 激励 / 服务三类, * 直接用标题分组比再建一层目录更轻(layui 侧边栏不支持三级展开)。 * * @return array */ protected static function groupTitles(): array { return [ 'trade.group.goods' => ['title' => '交易中心', 'sort' => 0], 'trade.group.mission' => ['title' => '互动激励', 'sort' => 10], 'trade.group.service' => ['title' => '基础服务', 'sort' => 20], ]; } /** * 把新增节点补授权给所有角色(幂等) * * 菜单是按授权 id 取树的:父目录不在授权列表里,整条分支都会丢, * 所以新建顶层目录 / 分组标题必须同步授权,否则非超管账号会「看不见但确实存在」。 * * @param array $powerIds * @return void */ protected static function grantToAllRoles(array $powerIds): void { $powerIds = array_values(array_unique(array_filter(array_map('intval', $powerIds)))); if (! $powerIds) { return; } $now = time(); $roles = Db::name('backend_role')->column('id'); foreach ($roles as $roleId) { $owned = Db::name('backend_role_power') ->where('role_id', (int) $roleId) ->whereIn('power_id', $powerIds) ->column('power_id'); $missing = array_diff($powerIds, array_map('intval', $owned)); foreach ($missing as $powerId) { Db::name('backend_role_power')->insert([ 'role_id' => (int) $roleId, 'power_id' => (int) $powerId, 'create_at' => $now, ]); } } } /** * 顶层分组定义(分组名 => 目录节点) * * 与 public/install/install.sql 的 backend_power 种子保持一致: * name/sort/route/icon 三处对齐,保证全新安装与老库自愈结果相同。 * * @return array */ public static function groups(): array { return [ 'system' => [ 'title' => '系统管理', 'name' => 'system', 'route' => '/system', 'icon' => 'layui-icon layui-icon-set', 'sort' => 6, ], 'tools' => [ 'title' => '数据运维', 'name' => 'tools', 'route' => '/tools', 'icon' => 'layui-icon layui-icon-engine', 'sort' => 7, ], ]; } /** * 取分组目录节点 ID(不存在则补建,幂等) */ protected static function groupPid(string $group): int { try { $cfg = self::groups()[$group] ?? null; if (! $cfg) { return 0; } $id = Db::name('backend_power')->where('name', $cfg['name'])->value('id'); if ($id) { return (int) $id; } $now = time(); return (int) Db::name('backend_power')->insertGetId([ 'pid' => 0, 'title' => $cfg['title'], 'name' => $cfg['name'], 'type' => 1, 'route' => $cfg['route'], 'icon' => $cfg['icon'], 'sort' => $cfg['sort'], 'addon' => null, 'status' => 1, 'create_at' => $now, 'update_at' => $now, 'delete_at' => 0, ]); } catch (\Throwable $e) { return 0; // 权限表不可用:静默 } } /** * 新增菜单定义(权限前缀 => 配置) * * @return array */ public static function menus(): array { return [ 'database' => [ 'title' => '数据库备份', 'route' => 'database/index', 'icon' => 'layui-icon layui-icon-set', 'group' => 'tools', // 与 public/install/install.sql 的 backend_power 种子 sort 一致, // 保证「全新安装」与「老库自愈」两条路径的菜单顺序相同 'sort' => 1, 'buttons' => [ 'backup' => '备份', 'recovery' => '还原', 'delete' => '删除', 'optimize' => '优化', ], ], 'datatable' => [ 'title' => '数据表管理', 'route' => 'datatable/index', 'icon' => 'layui-icon layui-icon-template-1', 'group' => 'tools', 'sort' => 2, 'buttons' => [ 'create' => '新建', 'structure' => '结构', 'optimize' => '优化', 'export' => '导出安装SQL', 'drop' => '删除', ], ], 'adminlog' => [ 'title' => '操作日志', 'route' => 'adminlog/index', 'icon' => 'layui-icon layui-icon-log', 'group' => 'tools', 'sort' => 4, 'buttons' => [ 'delete' => '删除', 'clean' => '清理', ], ], 'attachment' => [ 'title' => '附件管理', 'route' => 'attachment/index', 'icon' => 'layui-icon layui-icon-picture', 'group' => 'tools', 'sort' => 3, 'buttons' => [ 'delete' => '删除', ], ], 'hook' => [ 'title' => '模板钩子', 'route' => 'hook/index', 'icon' => 'layui-icon layui-icon-template-1', 'group' => 'tools', // 与 install.sql 种子 sort 一致(数据运维下第 6 项) 'sort' => 6, // 只读诊断页,没有按钮权限 'buttons' => [], ], 'framework' => [ 'title' => '框架升级', 'route' => 'framework/index', 'icon' => 'layui-icon layui-icon-refresh', 'group' => 'system', // 与 install.sql 种子 sort 一致(系统管理下第 6 项) 'sort' => 6, // 控制台页也用这两个动作(检查更新 / 立即升级), // 老库菜单已存在 ⇒ 这里只补按钮权限节点。 'buttons' => [ 'check' => '检查更新', 'upgrade' => '执行升级', ], ], ]; } /** * 补录单个菜单 + 其按钮节点 * * @param array $menu * @param int $pid 「系统管理」目录节点 ID * @return void */ protected static function ensureMenu(array $menu, int $pid): void { $name = $menu['route']; $name = explode('/', $name)[0] . ':index'; $prefix = explode(':', $name)[0]; $exists = Db::name('backend_power')->where('name', $name)->find(); if ($exists) { // 菜单已存在(含手工调整过的排序 / 改名):菜单本身不动, // 但按钮节点要继续补,否则后来新增的按钮(如导出)在老库上永远没有权限节点。 self::ensureButtons((int) $exists['id'], $prefix, $menu['buttons']); return; } $now = time(); $id = (int) Db::name('backend_power')->insertGetId([ 'pid' => $pid, 'title' => $menu['title'], 'name' => $name, 'type' => 2, 'route' => $menu['route'], 'icon' => $menu['icon'], 'sort' => (int) ($menu['sort'] ?? 100), 'addon' => null, 'status' => 1, 'create_at' => $now, 'update_at' => $now, 'delete_at' => 0, ]); self::ensureButtons($id, $prefix, $menu['buttons'], $now); } /** * 补录按钮节点(幂等:只补缺失的,已存在的不动) * * @param int $pid 菜单节点 ID * @param string $prefix 权限前缀,如 datatable * @param array $buttons [动作 => 中文名] */ protected static function ensureButtons(int $pid, string $prefix, array $buttons, int $now = 0): void { if ($pid <= 0 || ! $buttons) { return; } $now = $now ?: time(); foreach ($buttons as $act => $label) { $node = $prefix . ':' . $act; if (Db::name('backend_power')->where('name', $node)->find()) { continue; // 已存在(可能手工改过名),不再覆盖 } Db::name('backend_power')->insert([ 'pid' => $pid, 'title' => $label, 'name' => $node, 'type' => 3, 'route' => $prefix . '/' . $act, 'icon' => '', 'sort' => 1, 'addon' => null, 'status' => 1, 'create_at' => $now, 'update_at' => $now, 'delete_at' => 0, ]); } } }