/,每个目录即一个独立子空间(/ 前缀 URL): * - module.php 模块声明(名称/描述/访问要求预留) * - routes.php 模块内路由(相对模式,如 '/'、'/item/:id') * - controllers/ 模块控制器(手动 require 加载,规避 PSR-4 目录大小写陷阱) * - views/ 模块模板(注册进 Twig) * * 安全:模块名白名单 [a-z0-9_-](小写),杜绝路径穿越;URL 与物理路径解耦。 */ final class ModuleLoader { /** @var list */ private array $modules = []; /** * 注册所有模块路由到主 Router(在根路由表之后调用)。 * @return list 已加载模块清单 */ public function load(Router $router): array { $modulesDir = APP_ROOT . '/app/modules'; foreach (glob($modulesDir . '/*', GLOB_ONLYDIR) ?: [] as $dir) { $name = basename($dir); // 模块名白名单:小写字母/数字/下划线/连字符(与 URL 段一致) if (!preg_match('/^[a-z][a-z0-9_-]*$/', $name)) { Logger::warning('module skipped (invalid name)', ['name' => $name]); continue; } $declaration = $this->declaration($dir); $this->registerModule($router, $name, $dir); $this->modules[] = ['name' => $name, 'declaration' => $declaration]; Logger::info('module loaded', ['name' => $name]); } return $this->modules; } private function registerModule(Router $router, string $name, string $dir): void { // 手动加载模块控制器(类文件按约定命名,见模块内 README 约定) $controllersDir = $dir . '/controllers'; if (is_dir($controllersDir)) { foreach (glob($controllersDir . '/*.php') ?: [] as $file) { require_once $file; } } // 模块模板目录注册进 Twig(命名空间 = 模块名,模板用 @/xxx 引用) $viewsDir = $dir . '/views'; if (is_dir($viewsDir)) { View::addPath($viewsDir, $name); } // 模块内路由(相对模式)拼上 / 前缀 $routesFile = $dir . '/routes.php'; if (!is_file($routesFile)) { return; } foreach ((array) require $routesFile as $route) { $router->add( $route['method'], '/' . $name . ($route['pattern'] === '/' ? '' : $route['pattern']), $route['handler'], $route['middleware'] ?? [] ); } } /** @return array 模块声明(缺失时给默认)。 */ private function declaration(string $dir): array { $file = $dir . '/module.php'; if (!is_file($file)) { return []; } $declaration = require $file; return is_array($declaration) ? $declaration : []; } }