Bläddra i källkod

feat: P4 模块机制(子空间挂载/模块声明/命名空间模板)

caesar 1 månad sedan
förälder
incheckning
7f7a58924a

+ 84 - 0
app/core/ModuleLoader.php

@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Core;
+
+/**
+ * 模块加载器(子空间机制,架构约定 docs/architecture.md §4)。
+ *
+ * 扫描 app/modules/<name>/,每个目录即一个独立子空间(/<name> 前缀 URL):
+ *   - module.php      模块声明(名称/描述/访问要求预留)
+ *   - routes.php      模块内路由(相对模式,如 '/'、'/item/:id')
+ *   - controllers/    模块控制器(手动 require 加载,规避 PSR-4 目录大小写陷阱)
+ *   - views/          模块模板(注册进 Twig)
+ *
+ * 安全:模块名白名单 [a-z0-9_-](小写),杜绝路径穿越;URL 与物理路径解耦。
+ */
+final class ModuleLoader
+{
+    /** @var list<array{name:string, declaration:array}> */
+    private array $modules = [];
+
+    /**
+     * 注册所有模块路由到主 Router(在根路由表之后调用)。
+     * @return list<array{name:string, declaration:array}> 已加载模块清单
+     */
+    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(命名空间 = 模块名,模板用 @<name>/xxx 引用)
+        $viewsDir = $dir . '/views';
+        if (is_dir($viewsDir)) {
+            View::addPath($viewsDir, $name);
+        }
+        // 模块内路由(相对模式)拼上 /<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 : [];
+    }
+}

+ 12 - 0
app/core/View.php

@@ -23,6 +23,18 @@ final class View
         return self::twig()->render($template, $data);
     }
 
+    /**
+     * 追加模板目录(模块视图:app/modules/<name>/views)。
+     * 带命名空间注册后以 @<namespace>/<template> 引用,避免多模块同名模板冲突。
+     */
+    public static function addPath(string $path, ?string $namespace = null): void
+    {
+        $loader = self::twig()->getLoader();
+        if ($loader instanceof FilesystemLoader) {
+            $loader->addPath(rtrim($path, '/\\'), $namespace);
+        }
+    }
+
     public static function twig(): Environment
     {
         if (self::$twig === null) {

+ 29 - 0
app/modules/news/controllers/HomeController.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Modules\News;
+
+use Glacier\Core\Response;
+use Glacier\Core\View;
+
+/**
+ * 公司新闻模块控制器(P4 示例;P6 接入数据)。
+ */
+final class HomeController
+{
+    public function index(array $params = []): never
+    {
+        Response::html(View::render('@news/index.html.twig', [
+            'module_name' => '公司新闻',
+            'message' => '模块机制运行正常(P4 示例)',
+        ]));
+    }
+
+    public function item(array $params = []): never
+    {
+        Response::html(View::render('@news/item.html.twig', [
+            'id' => $params['id'] ?? '',
+        ]));
+    }
+}

+ 13 - 0
app/modules/news/module.php

@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * 模块声明:news(公司新闻)。
+ * P4 为示例模块;P6 实现真实功能。
+ * 访问要求(auth/权限)预留,P5 中间件启用后按需填写。
+ */
+return [
+    'name' => '公司新闻',
+    'description' => '公司新闻与公告',
+];

+ 13 - 0
app/modules/news/routes.php

@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+use Glacier\Modules\News\HomeController;
+
+/**
+ * 模块内路由(相对模式;入口统一挂载到 /news 前缀)。
+ */
+return [
+    ['method' => 'GET', 'pattern' => '/', 'handler' => [HomeController::class, 'index']],
+    ['method' => 'GET', 'pattern' => '/item/:id', 'handler' => [HomeController::class, 'item']],
+];

+ 11 - 0
app/modules/news/views/index.html.twig

@@ -0,0 +1,11 @@
+{% extends 'layouts/base.html.twig' %}
+
+{% block title %}{{ module_name }}{% endblock %}
+
+{% block content %}
+<section class="hero">
+    <h1>{{ module_name }}</h1>
+    <p class="muted">{{ message }}</p>
+    <p><a href="{{ base_url }}/news/item/1">查看示例条目 #1</a></p>
+</section>
+{% endblock %}

+ 11 - 0
app/modules/news/views/item.html.twig

@@ -0,0 +1,11 @@
+{% extends 'layouts/base.html.twig' %}
+
+{% block title %}新闻条目 #{{ id }}{% endblock %}
+
+{% block content %}
+<section class="hero">
+    <h1>新闻条目 #{{ id }}</h1>
+    <p class="muted">内容将在 P6 接入数据库后提供。</p>
+    <p><a href="{{ base_url }}/news">返回新闻列表</a></p>
+</section>
+{% endblock %}

+ 3 - 1
public/index.php

@@ -13,6 +13,7 @@ require APP_ROOT . '/vendor/autoload.php';
 
 use Glacier\Core\Config;
 use Glacier\Core\Logger;
+use Glacier\Core\ModuleLoader;
 use Glacier\Core\Response;
 use Glacier\Core\Router;
 
@@ -63,11 +64,12 @@ try {
 
     Logger::info('request', ['method' => $method, 'path' => $path]);
 
-    // 注册路由(声明式路由表)
+    // 注册路由:先根路由表,再挂载业务模块(子空间 /<name>)
     $router = new Router();
     foreach ((array) require APP_ROOT . '/app/config/routes.php' as $route) {
         $router->add($route['method'], $route['pattern'], $route['handler'], $route['middleware'] ?? []);
     }
+    (new ModuleLoader())->load($router);
 
     $match = $router->match($path, $method);
     if ($match === null) {