Ver Fonte

feat: P1 单一入口与 URL 模式引擎(路由/错误处理/日志)

caesar há 1 mês atrás
pai
commit
4c2bd35386

+ 20 - 0
app/config/env.dev.php

@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+// 开发环境配置(WAMP/XAMPP 本机)
+return [
+    'app' => [
+        'env' => 'dev',
+        'debug' => true,
+        'timezone' => 'Asia/Shanghai',
+        'locale' => 'zh-CN',
+        'name' => 'Glacier',
+        'base_url' => 'http://127.0.0.1:8099',
+    ],
+    'storage' => [
+        'logs' => APP_ROOT . '/storage/logs',
+        'cache' => APP_ROOT . '/storage/cache',
+        'uploads' => APP_ROOT . '/storage/uploads',
+    ],
+];

+ 15 - 0
app/config/env.php

@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+// 环境配置加载器:APP_ENV 切换 dev / prod,缺省 dev。
+$appEnv = getenv('APP_ENV');
+if (!is_string($appEnv) || $appEnv === '') {
+    $appEnv = 'dev';
+}
+$file = __DIR__ . '/env.' . $appEnv . '.php';
+if (!is_file($file)) {
+    $appEnv = 'dev';
+    $file = __DIR__ . '/env.dev.php';
+}
+return require $file;

+ 21 - 0
app/config/env.prod.php

@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+// 生产环境配置(Debian + Caddy + PHP-FPM)
+// 部署时通过环境变量 APP_ENV=prod 切换;敏感值由部署注入,不落库不入库。
+return [
+    'app' => [
+        'env' => 'prod',
+        'debug' => false,
+        'timezone' => 'Asia/Shanghai',
+        'locale' => 'zh-CN',
+        'name' => 'Glacier',
+        'base_url' => 'https://glacier.example.com', // 部署时替换为正式域名
+    ],
+    'storage' => [
+        'logs' => APP_ROOT . '/storage/logs',
+        'cache' => APP_ROOT . '/storage/cache',
+        'uploads' => APP_ROOT . '/storage/uploads',
+    ],
+];

+ 18 - 0
app/config/routes.php

@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+use Glacier\Controllers\HomeController;
+
+/**
+ * URL 模式路由表(集中声明,URL 的唯一解释者)。
+ * 每条:method / pattern / handler / middleware(中间件 P5 启用)。
+ *
+ * pattern 语法:静态段 /about、参数段 /user/:id、可选段 /news[/:page]。
+ * 说明:/demo 两条为 P1 验证路由引擎用,后续随模块规划移除。
+ */
+return [
+    ['method' => 'GET', 'pattern' => '/', 'handler' => [HomeController::class, 'index']],
+    ['method' => 'GET', 'pattern' => '/demo/:name', 'handler' => [HomeController::class, 'demo']],
+    ['method' => 'GET', 'pattern' => '/demo-page[/:page]', 'handler' => [HomeController::class, 'demoPage']],
+];

+ 46 - 0
app/controllers/HomeController.php

@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Controllers;
+
+use Glacier\Core\Logger;
+use Glacier\Core\Response;
+
+/**
+ * 首页控制器(本期"开发中"占位;后期公司新闻 + 功能导航)。
+ * P1 暂用内联 HTML,P2 切换为 Twig 模板渲染。
+ */
+final class HomeController
+{
+    public function index(array $params = []): never
+    {
+        Response::html($this->page('Glacier · 开发中', '<h1>Glacier 公司管理系统</h1><p>开发中,敬请期待。</p>'));
+    }
+
+    /** 参数路由演示(P1 验证用,后续移除)。 */
+    public function demo(array $params = []): never
+    {
+        $name = $params['name'] ?? '';
+        Logger::info('demo route hit', ['name' => $name]);
+        Response::html($this->page('Demo · ' . $name, '<h1>参数路由演示</h1><p>参数 <code>name</code> = ' . e($name) . '</p>'));
+    }
+
+    /** 可选段演示(P1 验证用,后续移除)。 */
+    public function demoPage(array $params = []): never
+    {
+        $page = $params['page'] ?? '1';
+        Response::html($this->page('DemoPage · ' . $page, '<h1>可选段演示</h1><p>当前页 = ' . e($page) . '</p>'));
+    }
+
+    private function page(string $title, string $body): string
+    {
+        return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">'
+            . '<meta name="viewport" content="width=device-width, initial-scale=1">'
+            . '<title>' . e($title) . '</title>'
+            . '<style>body{font-family:system-ui,sans-serif;max-width:800px;margin:40px auto;padding:0 16px;color:#333}'
+            . 'h1{color:#0b7a5f;border-bottom:2px solid #0b7a5f;padding-bottom:8px}'
+            . 'code{background:#f0f0f0;padding:2px 6px;border-radius:4px}</style>'
+            . '</head><body>' . $body . '</body></html>';
+    }
+}

+ 36 - 0
app/core/Config.php

@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Core;
+
+/**
+ * 全局配置(点号路径访问,如 app.debug / storage.logs)。
+ */
+final class Config
+{
+    private static array $items = [];
+
+    public static function load(array $items): void
+    {
+        self::$items = $items;
+    }
+
+    public static function get(string $key, mixed $default = null): mixed
+    {
+        $current = self::$items;
+        foreach (explode('.', $key) as $segment) {
+            if (!is_array($current) || !array_key_exists($segment, $current)) {
+                return $default;
+            }
+            $current = $current[$segment];
+        }
+        return $current;
+    }
+
+    /** 全部配置(调试/诊断用)。 */
+    public static function all(): array
+    {
+        return self::$items;
+    }
+}

+ 52 - 0
app/core/Logger.php

@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Core;
+
+/**
+ * 极简文件日志:storage/logs/app-YYYYMMDD.log,JSON 行格式。
+ * 生产环境只记日志、不输出详情(错误页由入口按 debug 开关决定)。
+ */
+final class Logger
+{
+    private static ?string $dir = null;
+
+    public static function init(string $dir): void
+    {
+        self::$dir = rtrim($dir, '/\\');
+        if (!is_dir(self::$dir)) {
+            @mkdir(self::$dir, 0775, true);
+        }
+    }
+
+    public static function info(string $message, array $context = []): void
+    {
+        self::write('INFO', $message, $context);
+    }
+
+    public static function warning(string $message, array $context = []): void
+    {
+        self::write('WARN', $message, $context);
+    }
+
+    public static function error(string $message, array $context = []): void
+    {
+        self::write('ERROR', $message, $context);
+    }
+
+    private static function write(string $level, string $message, array $context): void
+    {
+        if (self::$dir === null) {
+            return;
+        }
+        $file = self::$dir . '/app-' . date('Y-m-d') . '.log';
+        $line = json_encode([
+            'ts' => date('Y-m-d H:i:s'),
+            'level' => $level,
+            'message' => $message,
+            'context' => $context,
+        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        @file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
+    }
+}

+ 46 - 0
app/core/Response.php

@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Core;
+
+/**
+ * 统一响应出口:HTML / JSON / 文本 / 重定向。
+ * 输出后立即终止(never)。
+ */
+final class Response
+{
+    public static function html(string $body, int $status = 200, array $headers = []): never
+    {
+        http_response_code($status);
+        header('Content-Type: text/html; charset=utf-8');
+        foreach ($headers as $name => $value) {
+            header($name . ': ' . $value);
+        }
+        echo $body;
+        exit;
+    }
+
+    public static function json(mixed $data, int $status = 200): never
+    {
+        http_response_code($status);
+        header('Content-Type: application/json; charset=utf-8');
+        echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        exit;
+    }
+
+    public static function text(string $body, int $status = 200): never
+    {
+        http_response_code($status);
+        header('Content-Type: text/plain; charset=utf-8');
+        echo $body;
+        exit;
+    }
+
+    public static function redirect(string $location, int $status = 302): never
+    {
+        http_response_code($status);
+        header('Location: ' . $location);
+        exit;
+    }
+}

+ 141 - 0
app/core/Router.php

@@ -0,0 +1,141 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Core;
+
+/**
+ * URL 模式引擎(自研,零依赖)。
+ *
+ * 核心原则:URL 与物理路径完全解耦 —— 本类只消费 URL 模式与参数,
+ * 从不把 URL 段拼进文件系统路径。
+ *
+ * 模式语法:
+ *   - 静态段:  /about
+ *   - 参数段:  /user/:id   (:name 为命名参数)
+ *   - 可选段:  /news[/:page]
+ *   - 方法:    GET / POST / '*'(任意)
+ */
+final class Router
+{
+    /** @var list<array{method:string, pattern:string, regex:string, handler:callable, middleware:list<string>}> */
+    private array $routes = [];
+
+    public function add(string $method, string $pattern, array|callable|string $handler, array $middleware = []): void
+    {
+        $this->routes[] = [
+            'method' => strtoupper($method),
+            'pattern' => $pattern,
+            'regex' => $this->compile($pattern),
+            'handler' => $this->normalizeHandler($handler, $pattern),
+            'middleware' => $middleware,
+        ];
+    }
+
+    public function get(string $pattern, array|callable|string $handler, array $middleware = []): void
+    {
+        $this->add('GET', $pattern, $handler, $middleware);
+    }
+
+    public function post(string $pattern, array|callable|string $handler, array $middleware = []): void
+    {
+        $this->add('POST', $pattern, $handler, $middleware);
+    }
+
+    public function any(string $pattern, array|callable|string $handler, array $middleware = []): void
+    {
+        $this->add('*', $pattern, $handler, $middleware);
+    }
+
+    /**
+     * 把声明式处理器规范化为闭包。
+     * PHP 8 起 ['类名', '非静态方法'] 不能经类名字符串直接调用(不再自动实例化),
+     * 这里统一包装为实例调用,路由表保持简洁声明。
+     */
+    private function normalizeHandler(array|callable|string $handler, string $pattern): callable
+    {
+        if (is_array($handler) && isset($handler[0], $handler[1]) && is_string($handler[0]) && is_string($handler[1])) {
+            [$class, $method] = $handler;
+            return static fn(array $params = []) => (new $class())->{$method}($params);
+        }
+        if (!is_callable($handler)) {
+            throw new \InvalidArgumentException("路由 {$pattern} 的处理器不是可调用项");
+        }
+        return $handler;
+    }
+
+    /**
+     * 按注册顺序匹配 URL 路径。
+     * @return array{handler:callable, params:array<string,string>, middleware:list<string>}|null
+     */
+    public function match(string $path, string $method): ?array
+    {
+        $method = strtoupper($method);
+        foreach ($this->routes as $route) {
+            if ($route['method'] !== '*' && $route['method'] !== $method) {
+                continue;
+            }
+            if (preg_match($route['regex'], $path, $matches)) {
+                $params = [];
+                foreach ($matches as $key => $value) {
+                    if (is_string($key)) {
+                        $params[$key] = $value;
+                    }
+                }
+                return [
+                    'handler' => $route['handler'],
+                    'params' => $params,
+                    'middleware' => $route['middleware'],
+                ];
+            }
+        }
+        return null;
+    }
+
+    /** 编译 URL 模式为正则(支持静态段、:param、[可选段],可选段不可嵌套)。 */
+    private function compile(string $pattern): string
+    {
+        $out = '';
+        $rest = $pattern;
+        while ($rest !== '') {
+            $pos = strpos($rest, '[');
+            if ($pos === false) {
+                $out .= $this->compileSegment($rest);
+                break;
+            }
+            $out .= $this->compileSegment(substr($rest, 0, $pos));
+            $end = strpos($rest, ']', $pos);
+            if ($end === false) {
+                throw new \InvalidArgumentException("路由模式存在未闭合的 '[': {$pattern}");
+            }
+            $inner = substr($rest, $pos + 1, $end - $pos - 1);
+            $out .= '(?:' . $this->compileSegment($inner) . ')?';
+            $rest = substr($rest, $end + 1);
+        }
+        return '~^' . $out . '$~';
+    }
+
+    /** 编译不含可选段的一段模式:按 / 分段,:name → 命名捕获组;前导/尾部空段忽略。 */
+    private function compileSegment(string $segment): string
+    {
+        if ($segment === '') {
+            return '';
+        }
+        $parts = [];
+        foreach (explode('/', $segment) as $piece) {
+            if ($piece === '') {
+                continue; // 忽略前导/重复/尾部斜杠
+            }
+            if ($piece[0] === ':') {
+                $name = substr($piece, 1);
+                if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name)) {
+                    throw new \InvalidArgumentException("非法路由参数名: {$name}");
+                }
+                $parts[] = '/(?P<' . $name . '>[^/]+)';
+            } else {
+                $parts[] = '/' . preg_quote($piece, '~');
+            }
+        }
+        return $parts === [] ? '/' : implode('', $parts);
+    }
+}

+ 29 - 0
app/core/helpers.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+use Glacier\Core\Config;
+
+/** HTML 转义(输出安全)。 */
+if (!function_exists('e')) {
+    function e(mixed $value): string
+    {
+        return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+    }
+}
+
+/** 读取配置(点号路径)。 */
+if (!function_exists('config')) {
+    function config(string $key, mixed $default = null): mixed
+    {
+        return Config::get($key, $default);
+    }
+}
+
+/** 应用根目录(绝对路径)。 */
+if (!function_exists('app_root')) {
+    function app_root(): string
+    {
+        return APP_ROOT;
+    }
+}

+ 10 - 2
composer.json

@@ -10,8 +10,16 @@
     },
     "autoload": {
         "psr-4": {
-            "Glacier\\": "app/"
-        }
+            "Glacier\\Core\\": "app/core/",
+            "Glacier\\Controllers\\": "app/controllers/",
+            "Glacier\\Models\\": "app/models/",
+            "Glacier\\Middleware\\": "app/middleware/",
+            "Glacier\\Modules\\": "app/modules/",
+            "Glacier\\Config\\": "app/config/"
+        },
+        "files": [
+            "app/core/helpers.php"
+        ]
     },
     "config": {
         "optimize-autoloader": true,

+ 14 - 0
deploy/Caddyfile

@@ -0,0 +1,14 @@
+# Glacier 生产 Web 配置骨架(P8 部署时完善域名与 TLS)
+# 语义与开发 .htaccess 一致:真实文件直接服务,其余交 PHP-FPM 入口。
+# 占位域名,部署时替换为正式域名(Caddy 将自动签发 HTTPS)。
+
+glacier.example.com {
+    root * /var/www/glacier/public
+
+    # 静态资源直接服务;其余交 PHP-FPM 的单一入口
+    php_fastcgi 127.0.0.1:9000
+    try_files {path} /index.php?{query}
+
+    encode gzip
+    file_server
+}

+ 13 - 0
public/.htaccess

@@ -0,0 +1,13 @@
+# Glacier 开发环境 rewrite(Apache)
+# 语义:真实文件/目录(assets 等)直接服务;其余全部进入单一入口。
+Options -Indexes
+
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+
+    RewriteCond %{REQUEST_FILENAME} -f [OR]
+    RewriteCond %{REQUEST_FILENAME} -d
+    RewriteRule ^ - [L]
+
+    RewriteRule ^ index.php [L]
+</IfModule>

+ 70 - 0
public/index.php

@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Glacier 单一入口(前端控制器)。
+ * 所有动态请求都经此进入 —— URL 与物理路径完全解耦,见 docs/architecture.md §3.1。
+ */
+
+define('APP_ROOT', dirname(__DIR__));
+
+require APP_ROOT . '/vendor/autoload.php';
+
+use Glacier\Core\Config;
+use Glacier\Core\Logger;
+use Glacier\Core\Response;
+use Glacier\Core\Router;
+
+date_default_timezone_set('Asia/Shanghai'); // 架构约定 UTC+8
+
+$config = require APP_ROOT . '/app/config/env.php';
+Config::load($config);
+Logger::init(Config::get('storage.logs', APP_ROOT . '/storage/logs'));
+
+try {
+    // 解析请求路径(去 query,规范化尾部斜杠)
+    $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
+    if (strlen($path) > 1) {
+        $path = rtrim($path, '/');
+    }
+    $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+
+    Logger::info('request', ['method' => $method, 'path' => $path]);
+
+    // 注册路由(声明式路由表)
+    $router = new Router();
+    foreach ((array) require APP_ROOT . '/app/config/routes.php' as $route) {
+        $router->add($route['method'], $route['pattern'], $route['handler'], $route['middleware'] ?? []);
+    }
+
+    $match = $router->match($path, $method);
+    if ($match === null) {
+        Logger::warning('route not found', ['method' => $method, 'path' => $path]);
+        Response::html(
+            '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>404</title>'
+            . '<style>body{font-family:system-ui;text-align:center;padding-top:80px;color:#666}'
+            . 'h1{font-size:48px;color:#c33;margin-bottom:8px}</style></head>'
+            . '<body><h1>404</h1><p>页面不存在。</p></body></html>',
+            404
+        );
+    }
+
+    call_user_func($match['handler'], $match['params']);
+} catch (\Throwable $e) {
+    Logger::error('uncaught exception', [
+        'message' => $e->getMessage(),
+        'file' => $e->getFile(),
+        'line' => $e->getLine(),
+    ]);
+    $detail = Config::get('app.debug', false)
+        ? '<pre>' . e($e->getMessage()) . PHP_EOL . e($e->getFile() . ':' . $e->getLine()) . '</pre>'
+        : '<p>服务器开小差了,请稍后再试。</p>';
+    Response::html(
+        '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>500</title>'
+        . '<style>body{font-family:system-ui;text-align:center;padding-top:80px;color:#666}'
+        . 'h1{font-size:48px;color:#c33}</style></head>'
+        . '<body><h1>500</h1>' . $detail . '</body></html>',
+        500
+    );
+}