# MVC v2 重构想法

> 随便写，不用拘泥格式，想到哪写到哪，我们之后再一起梳理成可执行的方案。

## 1. 为什么要重写（不满意 v1.0 的地方）

-

## 2. 核心理念 / 设计原则

- 整体代码以 **PHP 8** 版本编写（`declare(strict_types=1)`、类型声明等新语法可用）
- 命名规则统一遵循 **PSR-1**：
  - **类文件**：文件名 = 类名，PascalCase 首字母大写，如 `Mvc.php`、`ExceptionHandler.php`
  - **类名**：PascalCase，如 `ExceptionHandler`、`DemoAction`
  - **方法名 / 函数名**：camelCase 首字母小写，如 `bindParams()`、`parseRequest()`、`dump()`
  - **变量名**：camelCase 首字母小写，如 `$classBase`、`$uriValues`
  - **常量**：全大写，下划线分隔，如 `DEBUG`
  - **非类的过程式函数库文件**（纯函数集合，无类）：camelCase 首字母小写，如 `mvcFunction.php`，用于和类文件区分
- 代码排版格式遵循 **PSR-12**（4空格缩进、大括号位置、可见性修饰符显式声明等）

现有文件 `Mvc.php`、`ExceptionHandler.php`、`mvcFunction.php` 均已符合上述规则，无需改动。

## 2.1 已落地的框架文件

- [mvcFunction.php](mvcFunction.php) — 全局函数库，目前有 `dump()`（打印不中断）、`dd()`（打印后中断）
- [ExceptionHandler.php](ExceptionHandler.php) — 全局异常处理：
  - `MvcException` 作为框架统一异常基类（后续可派生 `RouteNotFoundException` 等子类）
  - `ExceptionHandler::register()` 注册为 PHP 的 `set_exception_handler`
  - DEBUG 模式下用 `dump()` 打印异常详情；非 DEBUG 模式记录日志 + 输出友好提示
  - 当前类/方法找不到时，先统一 `throw` 全局异常兜底，后续再细化异常类体系
- [Mvc.php](Mvc.php) — 路由核心调度类
  - `init($cfg)`：保存配置、注册全局异常处理、解析当前请求
  - `parseRequest()` / `splitPathInfo()`：解析 PATH_INFO，支持 `/v/`（纯值）`/k/`（键值）传参风格、`.html` 伪静态后缀
  - 路由映射：除最后两段外都是目录，倒数第二段是文件/类名（自动加 `Action` 后缀），最后一段是方法名；空路径默认 `index`
  - `buildParams()`：合并 GET / POST / php://input(JSON) / `/k/` 传参为统一 `Mvc::$req`
  - `dispatch()`：require 路由文件 → 实例化类 → 反射绑定参数（按方法形参名匹配 `$req`，找不到时按 `/v/` 位置兜底，再找不到用默认值，否则抛 `MvcException`）→ 调用方法
  - 已验证：`pdc/admin/action/demo.php` 的 `demoAction::list($a,$b)`，访问 `admin/action/demo/list?a=12&b=all`
- [Action.php](Action.php) — 控制器基类
  - `protected array $request`：当前请求参数，由 `Mvc::dispatch()` 通过 `setRequest()` 注入（不再直接读全局静态属性，方便测试）
  - `success($data, $msg)` / `error($code, $msg)` / `json($code, $data, $msg)`：统一响应结构 `{code, data, msg}`，相比 v1.0 去掉了和分页强绑定的 page/total/records 字段，分页数据放进 `data` 里即可
  - `callBefore(string $method, array $params)` / `callEnd(string $method, array $params)`：可选钩子约定，子类按需实现为 **public** 方法，`Mvc::dispatch()` 用 `method_exists` 检测并调用
- [Model.php](Model.php) — 模型基类
  - 构造函数 `__construct(?Db $db = null)`：支持注入指定的 `Db` 连接（便于测试替换 mock 或读写分离场景下传 read/write 实例）；不传时 `$this->db ??= Db::instance()`，自动用 default 配置的单例连接，已验证
  - `paging()`：固定签名（不再像 v1.0 用 variadic 参数猜测重载），支持可选 `$join`/`$column` 关联查询场景与 `GROUP BY` 分组计数场景
  - 是全站共享的基类，具体业务 Model（如 `UserModel`）放在 `pdc/model/`，继承此类
- `Mvc::dispatch()` 已接入：实例化 Action 后自动 `setRequest()`，方法调用前后自动触发 `callBefore`/`callEnd`（如果子类定义了）
- 已用 CLI 模拟请求验证：`callBefore` 钩子 → 参数绑定 → `$this->request` 注入 → `success()` 统一 JSON 输出，全链路打通

## 7. 已生成的站点结构（pdc/admin 为示例站点）

```
pdc/
├── config/
│   ├── database.php   # 占位
│   └── admin.php      # 占位
├── model/              # 空，待补充
├── cache/               # 空，待补充
├── log/                 # 空，待补充
└── admin/
    ├── action/
    │   ├── .htaccess    # 重写规则，全部转发到 index.php
    │   ├── index.php    # 入口，引入 Mvc.php 并 init+dispatch
    │   └── demo.php     # 示例控制器 demoAction::list($a,$b)
    └── view/            # 空，待补充
```

mvc/v2 下新增 `lib/`（空目录，占位，后续按功能分子目录存放类库）。

**待验证**：需要实际起一个支持 .htaccess（mod_rewrite）的 PHP 服务器/虚拟主机，访问
`http://127.0.0.1/.../admin/action/demo/list?a=12&b=all` 确认路由与参数绑定按预期工作。

## 3. 目录结构设想

两大部分分离：MVC 框架本身 与 站点应用，物理路径都已确定。

### MVC 框架本身 — `E:\www\mdm\mvc\v2`

```
mvc/v2/
├── lib/              # 所有类库都放这里（后续新增类库统一进此目录）
└── (框架文件)         # 框架本身的文件放在 v2 主目录下
```

### 站点应用 — `E:\www\mdm\pdc`

应用下有多个子站点：`admin`（后台）、`home`（前台）、`homeen`（英文版前台）。
`view` 模板按子站点单独存放（每个子站点有自己的 view）。

```
pdc/
├── config/
│   ├── database.php   # 共享配置（多站点通用的放这一类，文件名不带站点前缀）
│   ├── admin.php       # admin 子站点专属配置
│   ├── home.php        # home 子站点专属配置
│   └── homeen.php      # homeen 子站点专属配置
├── model/              # 全站共享一份，不分子站点
├── cache/
├── log/
├── admin/              # 子站点：自己的 action + view
│   ├── action/
│   └── view/
├── home/               # 子站点：自己的 action + view
│   ├── action/
│   └── view/
└── homeen/             # 子站点：自己的 action + view
    ├── action/
    └── view/
```

规则总结：
- **model**：全站共享一份，放顶层 `model/`，不区分子站点
- **action**：每个子站点各自独立，放 `[子站点]/action/`
- **view**：每个子站点各自独立，放 `[子站点]/view/`
- **config**：共享配置用通用文件名（如 `database.php`），子站点专属配置以子站点名命名（`admin.php` / `home.php` / `homeen.php`）
- **cache / log**：全站共享一份，不按子站点拆分

### lib 目录（框架内，按功能分子目录）

稍后处理。

## 4. 关键模块想法

### 路由 / 调度

**入口方式**：每个子站点（如 `admin`）的 `action/` 目录下放一个 `index.php`，同目录放 `.htaccess`，把所有找不到实际文件/目录的请求都交给 `index.php` 处理：

```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L]
```

`index.php` 里 include 主框架文件（mvc.php），由框架接管解析 PATH_INFO 并分发。

**路由映射规则**（示例）：

文件 `admin/action/pic.php` 定义类 `picAction`，类中有方法 `function list($a, $b)`。

访问：
```
http://127.0.0.1/mdm/pdc/admin/action/pic/list?a=12&b=all
```

映射关系：
- URL 路径 `pic` → 文件 `pic.php` → 类名 `picAction`
- URL 路径 `list` → 调用方法 `list($a, $b)`
- 查询字符串 `?a=12&b=all` → 自动绑定到方法形参 `$a` `$b`（沿用 v1.0 反射绑定参数的思路）

**多级目录映射规则**（已采纳）：URL 路径中，除最后两段外都是目录路径，倒数第二段是文件名/类名，最后一段是方法名。

```
admin/action/pic/category/list
              └─ 目录 pic/ ─┘  └文件 category.php / 类 categoryAction┘ └方法 list┘
```

单层情况（`pic/list`）等价于目录深度为 0：文件 `pic.php`，类 `picAction`，方法 `list`。

**异常处理**：方法/类找不到时，先统一抛全局异常，后续再设计专门的异常类体系。

**URL 传参风格**：保留 v1.0 的 `/v/`（纯值）与 `/k/`（键值对）风格，与 query string 并存。

**伪静态**：保留 `.html` 后缀路由支持（对应 v1.0 的单例模式 `_CallHtml`）。

### 控制器 (Action)

-

### 模型 (Model) / 数据库层

已落地基类见 [2.1 已落地的框架文件](#21-已落地的框架文件) 中的 `Model.php`。

**数据库层选型**：不自己造轮子，复用第三方查询构造器 **Medoo**（API 风格与数组化 where/join 和 v1.0 接近，迁移成本低），在 `Model` 之上包一层自己的接口，方便以后替换底层实现。

- 已用 composer 在 `mvc/v2/composer/` 下安装：`catfan/medoo`、`workerman/workerman`、`textalk/websocket`（composer.json 见 `mvc/v2/composer/composer.json`，vendor 内部代码不分析）
- composer 自动加载已接入框架：`Mvc.php` 顶部新增 `require_once __DIR__ . '/composer/vendor/autoload.php'`，已验证 `Medoo\Medoo`、`Workerman\Worker`、`WebSocket\Client` 均可正常 autoload，且不影响现有路由分发流程
- [lib/Db.php](lib/Db.php)：`Db extends Medoo\Medoo`，补充：
  - `rawQuery(string $sql, array $params = []): array` — 原生 SQL 查询（SELECT），走 `$this->pdo`，返回结果集
  - `rawExecute(string $sql, array $params = []): int` — 原生 SQL 操作（INSERT/UPDATE/DELETE），返回受影响行数
  - `Db::instance(string $name = 'default'): self` — **免传配置**，首次调用时自动 `require [projectDir]/config/database.php` 并缓存到 `self::$config`，之后调用直接复用；按库名 `$name` 缓存各自的连接单例，业务代码里直接 `Db::instance()` 即可，不用每次手动 require 配置文件
  - 常规查询默认走 **Medoo 原生查询语法**（`select()`/`insert()`/`update()` 等数组风格），`rawQuery`/`rawExecute` 仅用于 Medoo 语法表达不了的场景
  - `begin()` / `end()`：支持**嵌套调用**的事务计数。`begin()` 计数 +1，从 0→1 时真正开启事务；`end()` 计数 -1，减到 0 时真正 `commit()`；`end()` 调用次数多于 `begin()` 时抛 `MvcException`
  - 构造函数里 `register_shutdown_function` 注册 `assertTransactionClosed()`：程序结束时检查事务计数是否归零，不归零（忘记调用 `end()`）抛 `RuntimeException`，会被全局 `ExceptionHandler` 捕获处理，已验证
- [pdc/config/database.php](../../pdc/config/database.php)：全站共享数据库配置（Medoo 连接选项），已用真实环境验证可连接成功
  - `host=192.168.1.42, port=3306, database=pdc, username=gspuser, charset=utf8mb4, collation=utf8mb4_unicode_ci`
  - `ENGINE=InnoDB` 是建表选项，不属于 Medoo 连接配置，已在文件注释里记录供建表时参考
  - database.php 改为**多库结构**：返回数组按库名分组（如 `'default' => [...]`），为后续多库场景预留；`Db::instance()` 同步改为 `instance(array $options, string $name = 'default')`，按库名缓存单例（每个库名各自一个连接实例）

## 8. 已建的业务表（pdc 库）

- [pdc/sql/admin_depa.sql](../../pdc/sql/admin_depa.sql) — 部门表，前缀 `admin_depa_`：`id/cn_name/en_name/remark/create_time/update_time`
- [pdc/sql/admin_user.sql](../../pdc/sql/admin_user.sql) — 系统管理用户表，前缀 `admin_`：`username(唯一)/password/name/depa_id(关联admin_depa,带索引)/email/mobile/remark/allow/create_time/update_time`
- 均为 `ENGINE=InnoDB, CHARSET=utf8mb4, COLLATE=utf8mb4_unicode_ci`，字段与表都带中文 COMMENT
- 已通过 `Db::rawExecute`/`pdo->exec` 在真实库 `pdc` 上创建成功并验证字段注释，顺带验证了 `Db.rawQuery()` 在真实环境可用
- `admin_user.admin_depa_id` 已加外键 `fk_admin_user_depa` 关联 `admin_depa.admin_depa_id`：**ON UPDATE CASCADE（级联更新）/ ON DELETE RESTRICT（限制删除）**——部门下还有用户时不可删除该部门；sql 文件已同步更新外键定义
- `lib/` 目录已接入自动加载：`Mvc.php` 中 `spl_autoload_register`，按类名查找 `lib/{类名}.php`（**注意**：目前是扁平查找，等 `lib` 按功能分子目录后需要改成递归/映射查找，见 [3. 目录结构设想 - lib 目录](#lib-目录框架内按功能分子目录)）
- 待做：`Model` 基类如何拿到 `Db` 实例（构造注入的具体来源/单例管理，配合 `pdc/config/database.php` 的连接配置）

### 视图 (View)

已落地 [View.php](View.php)：

- **原生 PHP 模板**，渲染方式是 `extract(变量) + require 模板文件`，直接交给 PHP 引擎执行，不做 v1.0 那种危险函数黑名单扫描——以性能为先（按用户明确要求去掉这层开销）
- `View::set($key, $value)` / `viewSet()`：给模板赋值，支持单个键值或数组批量
- `View::put($view = '')` ：输出视图；`$view` 为空时，按当前路由（`Mvc::$class` 去掉 `Action` 后缀 + `Mvc::$cfg['routeDir']` + `Mvc::$method`）推导默认模板路径，**不用 `debug_backtrace`**（v1.0 是用回溯取调用者类名/方法名，这里直接读 `Mvc` 当前路由状态，更快）
- `View::load($view)` / `viewLoad()`：加载子模板，供模板内部 `<?=viewLoad('public/header')?>` 这样调用
- 模板路径规则：`[site]/view/[routeDir][classBase]/[method].view.php`，与 action 文件的目录结构一一对应
- 站点配置新增 `viewDir`（如 `pdc/admin/view`），在各站点 `index.php` 的 `Mvc::init()` 里传入
- 已验证：`demoAction::page($a,$b)` 渲染 `pdc/admin/view/demo/page.view.php`，变量正确传入，且与 `callBefore`/`callEnd` 钩子协同正常
- 已验证：`demoAction::users()` 改为调用 `pdc/model/Users.php`（`Users extends Model`）的 `listWithDepa()` 方法（内部用 `$this->db->select()` Medoo 原生 join 语法），渲染 `pdc/admin/view/demo/users.view.php` 输出 HTML 表格，4 个用户及部门中文名正确显示
- `Mvc.php` 新增 `pdc/model/` 目录的自动加载（`spl_autoload_register`，类名 = 文件名，路径取自 `Mvc::$cfg['projectDir']`），与 `lib/` 自动加载并存
- **重要发现**：模板里不能依赖 `<?`/`<?...?>` 短标签（需要 php.ini 开 `short_open_tag` 才能解析），只有 `<?=...?>` 短回显标签是 PHP 始终内置支持的。模板里的控制结构（`foreach`/`if` 等）统一用标准 `<?php ... ?>` 标签，不依赖服务器配置

### 依赖注入 / 容器

-

### 多语言

已落地 [Lang.php](Lang.php)：

- 语言包是**纯 PHP 数组文件**（`key => 译文`），不用 gettext，与"性能优先、PHP原生"的取向一致：`pdc/lang/zh-CN.php`、`pdc/lang/en.php`
- `Lang::load($locale, $langDir)`：加载语言包到内部静态字典，`Mvc::init($cfg)` 里如果传了 `locale`/`langDir` 会自动调用
- `Lang::get($key)` / 全局函数 `lg($key)`：取译文，**找不到 key 时原样返回 key 本身**兜底，不会因漏翻译而报错或空白
- 各站点在自己的 `index.php` 的 `Mvc::init()` 里指定 `'locale' => 'zh-CN'`（或 `'en'`）和 `'langDir' => .../pdc/lang`，目前 `admin` 站点固定 `zh-CN`；`homeen` 之类的英文站点以后固定传 `'en'` 即可，简单场景不需要 session 级别的语言切换
- 已验证：`demo/users.view.php` 用 `lg('用户列表')` 等包文案，切换 `locale` 为 `zh-CN`/`en` 输出正确切换
- **网址传参 `lang=` 切换语言**（已落地于 `Mvc::resolveLocale()`）：
  - 访问时带 `?lang=en`：写入 `$_SESSION['lang']`，本次请求立即生效
  - 之后的请求不带 `lang` 参数：取 `$_SESSION['lang']` 上次记录的值，直到再次传 `lang` 改变它
  - 都没有（首次访问、无 session 记录）：默认 `zh-CN`（`Mvc::DEFAULT_LOCALE`）
  - `index.php` 里不再固定写 `'locale' => 'zh-CN'`，改成由 `resolveLocale()` 动态决定，只需配置 `langDir`
  - 浏览器实测通过（CLI 模拟测试中因 Windows 环境下 PHP CLI 的 session 文件行为异常，出现误判的"未持久化"现象，已确认是测试环境artifact，非代码问题，以浏览器实测为准）

### 配置管理

已落地 **通用配置 `pdc/config/common.php`**：

- 用途：不同开发人员本地路径不一定相同（比如 `mvc` 框架目录、composer 目录位置），不应该写死在 `index.php` 里
- `common.php` 提供默认值（`mvcDir`、`composerDir`），**会提交到版本库**
- 个人本地路径覆盖：建 `pdc/config/common.local.php`（**不提交版本库**，已提供 `common.local.php.example` 模板），各站点 `index.php` 加载时如果存在该文件就 `array_merge` 覆盖默认值
- 改造影响：`Mvc.php` 顶部不再硬编码 `require_once __DIR__.'/composer/vendor/autoload.php'`，改成 `Mvc::init($cfg)` 内部按 `$cfg['composerDir']` 动态 require（没传时回退到 `__DIR__.'/composer'` 默认值）
- 各站点 `index.php` 改造模式：先 require `common.php`（+ 可选的 `common.local.php` 覆盖）→ 用其中的 `mvcDir` 来 require 框架的 `Mvc.php` → `Mvc::init()` 时把 `composerDir` 也传进去
- 已在 `pdc/admin/action/index.php` 落地并回归测试通过

## 5. 明确不要的东西（反面想法，从 v1.0 里要去掉的）

-

## 6. 暂时不确定 / 待讨论

-
