一级菜单名 * @var array */ private const CHANNELS = [ 'app' => 'APP', 'wechat_mp' => '公众号', 'wechat_mini' => '小程序', ]; protected function configure() { $this->setName('channel:menu-check') ->setDescription('模拟渠道模块全新安装,校验一级菜单是否挂载到「应用」菜单下(事务内执行,自动回滚)'); } /** * 执行校验 * @param Input $input 输入对象 * @param Output $output 输出对象 * @return int 0=通过,1=存在失败项或异常 */ protected function execute(Input $input, Output $output) { $prefix = (string) config('database.connections.mysql.prefix', 'eb_'); $output->writeln('模拟渠道模块全新安装 → 校验菜单挂载「应用」菜单'); $output->writeln(str_repeat('-', 72)); Db::startTrans(); try { // 1. 模拟"全新安装":删除各渠道现有菜单树(事务内删除,回滚可恢复) $deleted = $this->deleteChannelMenusTree($prefix); $output->writeln("清理现有渠道菜单 {$deleted} 条(模拟全新安装环境)"); // 2. 按渠道逐个执行安装(insertMenus + seed,均为 DML) foreach (self::CHANNELS as $key => $menuName) { $class = "\\modules\\channel\\{$key}\\Installer"; if (!class_exists($class)) { throw new \RuntimeException("渠道模块 Installer 不存在:{$class}"); } $installer = new $class(); $menuCount = $this->callProtected($installer, 'insertMenus'); $seedCount = $this->callProtected($installer, 'seed'); $output->writeln(" ✓ {$key}:insertMenus={$menuCount} / seed={$seedCount}"); } // 3. 校验各渠道一级菜单是否挂载到「应用」菜单下 $appMenuId = (int) Db::table($prefix . 'system_menus') ->where('menu_name', '应用') ->where('is_del', 0) ->value('id'); if (!$appMenuId) { throw new \RuntimeException('未找到系统「应用」菜单'); } $output->writeln('「应用」菜单 id = ' . $appMenuId); $output->writeln(str_repeat('-', 72)); $failures = []; foreach (self::CHANNELS as $key => $menuName) { $menu = Db::table($prefix . 'system_menus') ->where('menu_name', $menuName) ->where('is_del', 0) ->find(); // 校验点 1:一级菜单必须已创建 if (!$menu) { $failures[] = "[{$key}] 一级菜单「{$menuName}」未创建"; continue; } // 校验点 2:pid 必须等于「应用」菜单 id if ((int) $menu['pid'] !== $appMenuId) { $failures[] = "[{$key}] 「{$menuName}」(id={$menu['id']}) pid={$menu['pid']}," . "应为 {$appMenuId}(应用)"; } // 校验点 3:一级菜单下必须有直接子菜单(完整菜单树) $children = (int) Db::table($prefix . 'system_menus') ->where('pid', $menu['id']) ->where('is_del', 0) ->count(); if ($children === 0) { $failures[] = "[{$key}] 「{$menuName}」(id={$menu['id']}) 下无子菜单"; } $output->writeln(sprintf(' %-10s %-8s pid=%-6d 子菜单=%d', $key, $menuName, (int) $menu['pid'], $children)); } // 校验点 4:无孤儿菜单(pid 指向不存在的菜单)——以「应用」子树为范围 $orphans = Db::query( "SELECT COUNT(*) AS c FROM {$prefix}system_menus m " . "LEFT JOIN {$prefix}system_menus p ON m.pid = p.id " . "WHERE m.pid <> 0 AND p.id IS NULL AND m.is_del = 0" ); $orphanCount = (int) ($orphans[0]['c'] ?? 0); if ($orphanCount > 0) { $failures[] = "存在 {$orphanCount} 条孤儿菜单(pid 指向不存在的菜单)"; } $output->writeln('孤儿菜单:' . $orphanCount . ' 条'); Db::rollback(); // 恢复现场 } catch (\Throwable $e) { Db::rollback(); $output->error('校验异常(已回滚):' . $e->getMessage()); return 1; } // 4. 汇总结果 if (!empty($failures)) { foreach ($failures as $msg) { $output->writeln(' ❌ ' . $msg); } $output->error('校验未通过:渠道菜单未正确挂载到「应用」菜单下'); $output->writeln('(测试数据已回滚,不影响现有数据)'); return 1; } $output->writeln('✅ 各渠道菜单均正确挂载到「应用」菜单下'); $output->writeln('(测试数据已回滚,不影响现有数据)'); return 0; } /** * 递归删除各渠道的一级菜单及其全部子孙菜单 * @param string $prefix 表前缀 * @return int 删除条数 */ private function deleteChannelMenusTree(string $prefix): int { // 收集各渠道一级菜单 id $rootIds = Db::table($prefix . 'system_menus') ->whereIn('menu_name', array_values(self::CHANNELS)) ->where('is_del', 0) ->column('id'); // 递归收集全部子孙 id $ids = $rootIds; $pending = $rootIds; while (!empty($pending)) { $children = Db::table($prefix . 'system_menus') ->whereIn('pid', $pending) ->column('id'); $pending = $children; $ids = array_merge($ids, $children); } if (empty($ids)) { return 0; } return Db::table($prefix . 'system_menus')->whereIn('id', $ids)->delete(); } /** * 反射调用 Installer 的 protected 方法(insertMenus / seed) * @param object $obj 安装器实例 * @param string $method 方法名 * @return int 方法返回的计数 */ private function callProtected(object $obj, string $method): int { $ref = new \ReflectionMethod($obj, $method); $ref->setAccessible(true); return (int) $ref->invoke($obj); } }