// +---------------------------------------------------------------------- declare(strict_types=1); namespace app\appmall\library; use ywxapp\model\BackendPower; use ywxapp\model\MemberRule; /** * 应用市场「后台/前台菜单兜底」。 * * 背景:appmall 已从插件(addon/appmall)迁移为普通应用(app/appmall), * AddonService::createMenu() 读的是 ADDON_PATH 下的 menu.json,install.sql * 里也没有 appmall 菜单种子 —— 两头都断了,后台侧边栏永远不会出现 * 「应用市场」菜单。本类把菜单注入改为**内置清单 + 幂等探测式兜底**, * 随请求自动补齐,不再依赖安装命令与外部 menu.json 文件。 * * 设计要点(与 AppmallSchema 的表结构兜底同一套哲学): * - 事实源内置:菜单清单见本类常量(迁移自 menu.json,该文件已删除, * 改菜单直接改常量即可); * - 幂等:按菜单 name 逐项探测(appmall:backend: / appmall:), * 已存在的跳过,常量后续新增条目也会被自动补上; * - 只在中心站注入:客户机(is_market_client())上 /appmall/* 整体 404, * 注入中心运营菜单只会产生死链,故整组跳过; * - 绝不阻断业务:DB 异常一律静默吞掉,由后台页面正常暴露错误; * - 语义对齐 AddonService::saveMenus:后台菜单 name 前缀「appmall:backend:」 * 并写 addon='appmall'(侧边栏按 addon 判定走完整路径直出),前台/会员 * 菜单写 member_rule.module 区分;目录型节点路由置 '#',叶子节点按 * name 推导(冒号转斜线),显式 route 优先。 * * 触发点: * - app/appmall/middleware/AppmallSchemaMiddleware → 任何 /appmall/* 请求; * - app/backend/middleware/BackendSchemaMiddleware → 任何 /admin/* 请求 * (管理员不看 /appmall 前台也能在侧边栏看到菜单)。 * * @author ywxapp */ class AppmallMenu { /** 应用标识(菜单归属列 addon / name 前缀) */ private const APP = 'appmall'; /** * 后台菜单根分组(挂到「应用」(name=app) 目录下,不存在则自成一级)。 */ private const BACKEND_ROOT = [ 'title' => '应用市场', 'icon' => 'fa fa-shopping-cart', 'sort' => 5, ]; /** * 后台菜单叶子项(挂在根分组下;支持 'child' 嵌套目录)。 * 路由须与 app/appmall/route/app.php 注册地址一一对应。 */ private const BACKEND_ITEMS = [ ['name' => 'addonreview', 'title' => '插件审核', 'route' => '/appmall/backend/addonreview/index'], ['name' => 'developer', 'title' => '开发者管理', 'route' => '/appmall/backend/developer/index'], ['name' => 'revenuelist', 'title' => '收益账本', 'route' => '/appmall/backend/revenue/index'], ['name' => 'withdrawallist', 'title' => '提现管理', 'route' => '/appmall/backend/revenue/withdrawals'], ['name' => 'frameworklist', 'title' => '框架管理', 'icon' => 'fa fa-cloud-download', 'route' => '/appmall/backend/framework/index'], ]; /** 会员中心菜单(name 前缀 appmall:,module=member) */ private const MEMBER_ITEMS = []; /** 前台菜单(name 前缀 appmall:,module=frontend) */ private const FRONTEND_ITEMS = [ ['name' => 'frameworkdisplay', 'title' => '框架下载', 'route' => '/appmall/frontend/framework/index'], ]; /** 进程内已完成兜底标记:每请求最多探测一轮,幂等且极轻 */ private static bool $done = false; /** * 确保应用市场菜单已注入(幂等)。 */ public static function ensure(): void { if (self::$done) { return; } self::$done = true; // 先置位防重入 try { // 客户机:/appmall/* 整体 404,不注入任何 appmall 菜单 if (function_exists('is_market_client') && is_market_client()) { return; } self::ensureBackend(self::BACKEND_ITEMS); self::ensureRule(self::FRONTEND_ITEMS, 'frontend'); self::ensureRule(self::MEMBER_ITEMS, 'member'); } catch (\Throwable $e) { // 菜单兜底失败不影响本次请求,由后台页面暴露 } } /** * 后台菜单 → backend_power(name 前缀 appmall:backend:,写 addon 列)。 * 顶级挂载策略与 AddonService::createMenu 默认一致:优先挂「应用」 * (name=app)顶级目录下,不存在则自成一级菜单。 */ private static function ensureBackend(array $items): void { if (empty($items)) { return; } // 根分组节点:appmall:backend(存在则复用,幂等) $rootName = self::APP . ':backend'; $root = BackendPower::where('name', $rootName)->find(); if (!$root || empty($root->id)) { $app = BackendPower::where('name', 'app')->find(); $appPid = ($app && !empty($app->id)) ? (int) $app->id : 0; $root = BackendPower::create([ 'pid' => $appPid, 'title' => self::BACKEND_ROOT['title'], 'name' => $rootName, 'icon' => (string) self::BACKEND_ROOT['icon'], 'status' => 1, 'type' => 1, 'sort' => (int) (self::BACKEND_ROOT['sort'] ?? 1), 'route' => '', 'addon' => self::APP, ]); } $pid = !empty($root->id) ? (int) $root->id : 0; foreach ($items as $item) { if (is_array($item)) { self::saveBackendItem($item, $pid); } } } /** * 递归保存单条后台菜单(已存在同名节点则跳过整棵子树)。 */ private static function saveBackendItem(array $item, int $pid): void { // centerOnly:仅中心站显示;ensure() 已在客户机整组拦截,这里再兜一层 if (!empty($item['centerOnly']) && function_exists('is_market_client') && is_market_client()) { return; } $name = (string) ($item['name'] ?? ''); if ($name === '') { return; } $fullName = self::APP . ':backend:' . $name; $childList = $item['child'] ?? $item['sublist'] ?? []; $hasChild = !empty($childList) && is_array($childList); $type = (int) ($item['type'] ?? ($hasChild ? 1 : 2)); $route = (string) ($item['route'] ?? ''); if ($route === '') { // 目录型节点是纯容器,统一置 '#';叶子菜单按 name 推导(冒号转斜线) $route = $type === 1 ? '#' : str_replace(':', '/', $fullName); } $model = BackendPower::where('name', $fullName)->find(); if (!$model || empty($model->id)) { $model = BackendPower::create([ 'pid' => $pid, 'title' => (string) ($item['title'] ?? ''), 'name' => $fullName, 'icon' => (string) ($item['icon'] ?? ''), 'status' => (int) ($item['status'] ?? 1), 'type' => $type, 'sort' => (int) ($item['sort'] ?? 1), 'route' => $route, 'addon' => self::APP, ]); } $childPid = !empty($model->id) ? (int) $model->id : $pid; if ($hasChild) { foreach ($childList as $child) { if (is_array($child)) { self::saveBackendItem($child, $childPid); } } } } /** * 前台 / 会员菜单 → member_rule(name 前缀 appmall:,module 区分端)。 */ private static function ensureRule(array $items, string $module): void { foreach ($items as $item) { if (!is_array($item)) { continue; } $name = (string) ($item['name'] ?? ''); if ($name === '') { continue; } if (!empty($item['centerOnly']) && function_exists('is_market_client') && is_market_client()) { continue; } $fullName = self::APP . ':' . $name; $childList = $item['child'] ?? $item['sublist'] ?? []; $hasChild = !empty($childList) && is_array($childList); $type = (int) ($item['type'] ?? ($hasChild ? 1 : 2)); $route = (string) ($item['route'] ?? ''); if ($route === '') { $route = $type === 1 ? '#' : str_replace(':', '/', $fullName); } $model = MemberRule::where('name', $fullName)->where('module', $module)->find(); if (!$model || empty($model->id)) { $model = MemberRule::create([ 'pid' => 0, 'title' => (string) ($item['title'] ?? ''), 'name' => $fullName, 'icon' => (string) ($item['icon'] ?? ''), 'status' => (int) ($item['status'] ?? 1), 'type' => $type, 'sort' => (int) ($item['sort'] ?? 1), 'route' => $route, 'module' => $module, ]); } $childPid = !empty($model->id) ? (int) $model->id : 0; if ($hasChild) { foreach ($childList as $child) { if (is_array($child)) { self::saveRuleItem($child, $childPid, $module); } } } } } /** * 递归保存 member_rule 子菜单。 */ private static function saveRuleItem(array $item, int $pid, string $module): void { $name = (string) ($item['name'] ?? ''); if ($name === '') { return; } if (!empty($item['centerOnly']) && function_exists('is_market_client') && is_market_client()) { return; } $fullName = self::APP . ':' . $name; $childList = $item['child'] ?? $item['sublist'] ?? []; $hasChild = !empty($childList) && is_array($childList); $type = (int) ($item['type'] ?? ($hasChild ? 1 : 2)); $route = (string) ($item['route'] ?? ''); if ($route === '') { $route = $type === 1 ? '#' : str_replace(':', '/', $fullName); } $model = MemberRule::where('name', $fullName)->where('module', $module)->find(); if (!$model || empty($model->id)) { $model = MemberRule::create([ 'pid' => $pid, 'title' => (string) ($item['title'] ?? ''), 'name' => $fullName, 'icon' => (string) ($item['icon'] ?? ''), 'status' => (int) ($item['status'] ?? 1), 'type' => $type, 'sort' => (int) ($item['sort'] ?? 1), 'route' => $route, 'module' => $module, ]); } if ($hasChild && !empty($model->id)) { foreach ($childList as $child) { if (is_array($child)) { self::saveRuleItem($child, (int) $model->id, $module); } } } } }