Browse Source

feat: P3 数据层(PDO 封装/迁移机制/环境配置)

caesar 1 month ago
parent
commit
4d1dc7ad5e
8 changed files with 264 additions and 5 deletions
  1. 1 2
      .gitignore
  2. 49 0
      app/cli/migrate.php
  3. 9 1
      app/config/env.dev.php
  4. 3 0
      app/config/env.php
  5. 10 2
      app/config/env.prod.php
  6. 84 0
      app/core/Database.php
  7. 94 0
      app/core/Migrator.php
  8. 14 0
      database/0001_init.sql

+ 1 - 2
.gitignore

@@ -35,7 +35,6 @@ htmlcov/
 *.dylib
 *.test
 *.out
-/bin/
 /go/bin/
 # 覆盖率/分析产物
 coverage.out
@@ -73,4 +72,4 @@ Thumbs.db
 .idea/
 .vscode/
 *.swp
-*~
+*~

+ 49 - 0
app/cli/migrate.php

@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * 迁移 CLI:php app/cli/migrate.php [up|status]
+ */
+
+define('APP_ROOT', dirname(__DIR__, 2));
+
+require APP_ROOT . '/vendor/autoload.php';
+
+use Glacier\Core\Config;
+use Glacier\Core\Database;
+use Glacier\Core\Migrator;
+
+date_default_timezone_set('Asia/Shanghai');
+
+$config = require APP_ROOT . '/app/config/env.php';
+Config::load($config);
+
+$cmd = $argv[1] ?? 'up';
+
+try {
+    $migrator = new Migrator(Database::pdo(), APP_ROOT . '/database');
+    switch ($cmd) {
+        case 'up':
+            $executed = $migrator->up();
+            if ($executed === []) {
+                echo '无待执行迁移(已是最新)', PHP_EOL;
+            } else {
+                foreach ($executed as $name) {
+                    echo "已应用: {$name}", PHP_EOL;
+                }
+            }
+            break;
+        case 'status':
+            foreach ($migrator->status() as $row) {
+                printf("%-40s %s%s", $row['name'], $row['applied'] ? '已应用' : '待执行', PHP_EOL);
+            }
+            break;
+        default:
+            fwrite(STDERR, '用法: php app/cli/migrate.php [up|status]' . PHP_EOL);
+            exit(2);
+    }
+} catch (\Throwable $e) {
+    fwrite(STDERR, '迁移失败: ' . $e->getMessage() . PHP_EOL);
+    exit(1);
+}

+ 9 - 1
app/config/env.dev.php

@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-// 开发环境配置(WAMP/XAMPP 本机)
+// 开发环境配置(本机 XAMPP/MariaDB)
 return [
     'app' => [
         'env' => 'dev',
@@ -12,6 +12,14 @@ return [
         'name' => 'Glacier',
         'base_url' => 'http://127.0.0.1:8099',
     ],
+    'db' => [
+        'host' => '127.0.0.1',
+        'port' => 3306,
+        'name' => 'glacier',
+        'user' => 'root',
+        'pass' => '', // 本机开发库 root 无密码
+        'charset' => 'utf8mb4',
+    ],
     'storage' => [
         'logs' => APP_ROOT . '/storage/logs',
         'cache' => APP_ROOT . '/storage/cache',

+ 3 - 0
app/config/env.php

@@ -2,6 +2,9 @@
 
 declare(strict_types=1);
 
+// 应用根目录兜底:未定义(CLI/测试等独立入口)时按目录推断。
+defined('APP_ROOT') or define('APP_ROOT', dirname(__DIR__, 2));
+
 // 环境配置加载器:APP_ENV 切换 dev / prod,缺省 dev。
 $appEnv = getenv('APP_ENV');
 if (!is_string($appEnv) || $appEnv === '') {

+ 10 - 2
app/config/env.prod.php

@@ -2,8 +2,8 @@
 
 declare(strict_types=1);
 
-// 生产环境配置(Debian + Caddy + PHP-FPM)
-// 部署时通过环境变量 APP_ENV=prod 切换;敏感值由部署注入,不落库不入库。
+// 生产环境配置(Debian + Caddy + PHP-FPM + MariaDB)
+// 敏感值(DB 密码等)经环境变量注入,不落库不入库。
 return [
     'app' => [
         'env' => 'prod',
@@ -13,6 +13,14 @@ return [
         'name' => 'Glacier',
         'base_url' => 'https://glacier.example.com', // 部署时替换为正式域名
     ],
+    'db' => [
+        'host' => '127.0.0.1',
+        'port' => 3306,
+        'name' => 'glacier',
+        'user' => getenv('DB_USER') ?: 'glacier',
+        'pass' => getenv('DB_PASS') ?: '', // 生产由 DB_PASS 环境变量注入
+        'charset' => 'utf8mb4',
+    ],
     'storage' => [
         'logs' => APP_ROOT . '/storage/logs',
         'cache' => APP_ROOT . '/storage/cache',

+ 84 - 0
app/core/Database.php

@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Core;
+
+use PDO;
+
+/**
+ * PDO 数据访问封装(单库多表,架构约定见 docs/architecture.md §3.5)。
+ * - 一律 prepared statement(防注入)
+ * - utf8mb4 + 会话时区 UTC+8(三层统一约定)
+ * - 异常模式(ERRMODE_EXCEPTION)
+ */
+final class Database
+{
+    private static ?PDO $pdo = null;
+
+    public static function pdo(): PDO
+    {
+        if (self::$pdo === null) {
+            $host = (string) Config::get('db.host', '127.0.0.1');
+            $port = (int) Config::get('db.port', 3306);
+            $name = (string) Config::get('db.name', 'glacier');
+            $charset = (string) Config::get('db.charset', 'utf8mb4');
+            $user = (string) Config::get('db.user', 'root');
+            $pass = (string) Config::get('db.pass', '');
+
+            $dsn = "mysql:host={$host};port={$port};dbname={$name};charset={$charset}";
+            self::$pdo = new PDO($dsn, $user, $pass, [
+                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+                PDO::ATTR_EMULATE_PREPARES => false,
+                PDO::ATTR_TIMEOUT => 5,
+            ]);
+            self::$pdo->exec("SET time_zone = '+08:00'");
+        }
+        return self::$pdo;
+    }
+
+    /** 执行 prepared statement(唯一写库/查询路径)。 */
+    public static function run(string $sql, array $params = []): \PDOStatement
+    {
+        $stmt = self::pdo()->prepare($sql);
+        $stmt->execute($params);
+        return $stmt;
+    }
+
+    public static function fetchAll(string $sql, array $params = []): array
+    {
+        return self::run($sql, $params)->fetchAll();
+    }
+
+    public static function fetchOne(string $sql, array $params = []): ?array
+    {
+        $row = self::run($sql, $params)->fetch();
+        return $row === false ? null : $row;
+    }
+
+    public static function fetchValue(string $sql, array $params = []): mixed
+    {
+        $value = self::run($sql, $params)->fetchColumn();
+        return $value === false ? null : $value;
+    }
+
+    /** 插入一行(表名白名单校验),返回自增 ID。 */
+    public static function insert(string $table, array $data): int
+    {
+        if (!preg_match('/^[a-z][a-z0-9_]*$/', $table)) {
+            throw new \InvalidArgumentException("非法表名: {$table}");
+        }
+        $q = chr(96); // 反引号字符,MySQL 标识符引用
+        $columns = array_keys($data);
+        $quoted = array_map(static fn(string $c) => $q . $c . $q, $columns);
+        $sql = sprintf(
+            'INSERT INTO %s (%s) VALUES (%s)',
+            $table,
+            implode(', ', $quoted),
+            implode(', ', array_map(static fn(string $c) => ':' . $c, $columns))
+        );
+        self::run($sql, $data);
+        return (int) self::pdo()->lastInsertId();
+    }
+}

+ 94 - 0
app/core/Migrator.php

@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Core;
+
+use PDO;
+
+/**
+ * SQL 迁移执行器:database/ 下 0001_xxx.sql 顺序执行(单库多表)。
+ * 执行记录写入 g_migrations 表,重复执行幂等(事务内原子应用)。
+ */
+final class Migrator
+{
+    private PDO $pdo;
+    private string $dir;
+
+    public function __construct(PDO $pdo, string $dir)
+    {
+        $this->pdo = $pdo;
+        $this->dir = rtrim($dir, '/\\');
+    }
+
+    /**
+     * 应用所有未执行的迁移,返回本次执行的迁移名列表。
+     * 注意:MySQL/MariaDB 的 DDL(CREATE/ALTER)会隐式提交,事务包裹无意义,
+     * 故直接顺序执行;迁移 SQL 遵循幂等约定(IF NOT EXISTS / ON DUPLICATE KEY 等),
+     * 失败重跑安全。
+     */
+    public function up(): array
+    {
+        $this->ensureTable();
+        $applied = $this->applied();
+        $executed = [];
+        foreach ($this->files() as $name => $sql) {
+            if (isset($applied[$name])) {
+                continue;
+            }
+            try {
+                $this->pdo->exec($sql);
+                $stmt = $this->pdo->prepare('INSERT INTO g_migrations (name) VALUES (?)');
+                $stmt->execute([$name]);
+                $executed[] = $name;
+            } catch (\Throwable $e) {
+                throw new \RuntimeException("迁移 {$name} 失败: " . $e->getMessage(), 0, $e);
+            }
+        }
+        return $executed;
+    }
+
+    /** 全部迁移的应用状态。 */
+    public function status(): array
+    {
+        $this->ensureTable();
+        $applied = $this->applied();
+        $result = [];
+        foreach ($this->files() as $name => $sql) {
+            $result[] = ['name' => $name, 'applied' => isset($applied[$name])];
+        }
+        return $result;
+    }
+
+    /** @return array<string,string> 按编号排序的迁移 SQL。 */
+    private function files(): array
+    {
+        $files = [];
+        foreach (glob($this->dir . '/*.sql') ?: [] as $file) {
+            if (str_ends_with($file, '.down.sql')) {
+                continue; // 回滚文件由 down 流程单独处理(本期未启用)
+            }
+            $files[basename($file)] = (string) file_get_contents($file);
+        }
+        ksort($files);
+        return $files;
+    }
+
+    /** @return array<string,bool> 已应用迁移名集合。 */
+    private function applied(): array
+    {
+        $rows = $this->pdo->query('SELECT name FROM g_migrations')->fetchAll(PDO::FETCH_COLUMN);
+        return array_fill_keys($rows, true);
+    }
+
+    private function ensureTable(): void
+    {
+        $this->pdo->exec(
+            "CREATE TABLE IF NOT EXISTS g_migrations (
+                id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+                name VARCHAR(255) NOT NULL UNIQUE,
+                applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
+        );
+    }
+}

+ 14 - 0
database/0001_init.sql

@@ -0,0 +1,14 @@
+-- 0001: 系统基础表
+-- 系统核心表前缀 g_(架构约定 docs/architecture.md §3.5)
+
+CREATE TABLE IF NOT EXISTS g_settings (
+    name VARCHAR(64) NOT NULL PRIMARY KEY,
+    value TEXT NULL,
+    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT INTO g_settings (name, value) VALUES
+    ('site.name', 'Glacier'),
+    ('site.locale', 'zh-CN'),
+    ('site.timezone', 'Asia/Shanghai')
+ON DUPLICATE KEY UPDATE value = VALUES(value);