# MVC 框架层契约

## 职责边界

本层（`mvc/v2/`）只放**框架核心**，不放业务代码。
业务代码在 `pdc/`，见 `pdc/.claude/CLAUDE.md`。

## 核心文件

| 文件 | 职责 |
|------|------|
| `Mvc.php` | 路由解析、参数绑定、dispatch |
| `Action.php` | 控制器基类，JSON 响应、登录校验钩子 |
| `GridActions.php` | DevExtreme CustomStore 三端点 trait |
| `Model.php` | 数据模型基类，grid/save/remove 通用实现 |
| `View.php` | 视图渲染 |
| `Lang.php` | 多语言加载与取值 |
| `mvcFunction.php` | 全局函数：`dd()`, `dump()`, `lg()`, `viewSet()`, `viewLoad()`, `clientIp()`, `getSemiangle()`, `formatNum()` |
| `ExceptionHandler.php` | 全局异常处理：未捕获异常的统一出口 |

## 修改原则

- 改框架前先确认：业务层实现不了才改框架
- 框架方法签名变更必须同步检查所有 `pdc/` 里的调用点
- `Action::json()` / `success()` / `error()` 响应格式不得改动，业务层依赖此格式

## PHP 版本兼容

**框架层按 PHP 8.1 编写**，生产环境为 PHP 8+，8.0 及以下不再支持。

下限由 composer 依赖锁死：`phpoffice/phpspreadsheet` 要求 `^8.1`、`workerman/workerman` 要求 `>=8.1`。

可以放心使用：
- 构造函数属性提升（`public function __construct(private $x)`）
- 联合类型（`int|string`）、`mixed` 类型
- `match` 表达式
- Nullsafe operator `?->`
- Named arguments 调用
- `str_contains()` / `str_starts_with()` / `str_ends_with()`
- 枚举 `enum`、`readonly` 属性、`never` 返回类型（8.1）

不要使用 8.2 及以上的语法（`readonly class`、DNF 类型、8.3 类型化类常量、8.4 属性钩子等）。
动态属性必须显式声明在类体里（8.2 起已弃用）。

版本口径以根契约 [../../.claude/CLAUDE.md](../../.claude/CLAUDE.md) 为准，要放宽先改那里。

## 未捕获异常的出口（ExceptionHandler）

框架 `set_exception_handler` 注册的统一出口，异常一律先记日志，再按请求类型分两条路：

| 请求 | 输出 |
|------|------|
| ajax / JSON（带 `X-Requested-With: XMLHttpRequest`，或请求/接受 `application/json`） | 统一格式 JSON，`code=500`、`data=null` |
| 普通页面 | HTML（DEBUG 下是异常详情和调用栈，否则一行友好提示） |

`msg` / 页面内容详略由常量 `DEBUG` 决定：**DEBUG 时是异常原文 + `文件:行号`**（数据库报错的 SQLSTATE 就是这样透到前端的），
否则只有「系统出现异常错误」，不暴露表结构和文件路径。

- 常量 `DEBUG` 由各站点入口 `define()`，值从业务层的站点配置来（pdc 在 `config/admin.php` 的 `DEBUG`），框架不读业务配置
- ajax 必须回 JSON：回 HTML 的话前端只拿到一句解析失败，真正的原因（比如 SQL 报错）看不见
- `code=500` 只给未捕获异常用，业务错误码见 `pdc/.claude/contracts/API.md`

## 路由规则

URL 格式：`/{模块名}/{方法名}`

- PATH_INFO 解析：最后一段是方法名，倒数第二段是模块名
- 类名自动拼接 `Action` 后缀：`users` → `usersAction`
- 支持 `/v/值` 位置传参和 `/k/键/值` 键值传参
- 支持 `.html` 伪静态后缀

## Grid 协议（框架侧）

`GridActions` trait 的三个方法是固定协议，不得修改方法签名：
- `grid()` → 调 `$this->gridModel()->grid($this->request)`；请求带 `group`（列头筛选器取候选值、分组面板）时改调 `gridGroups($this->request)`
- `save()` → body 含 `key` 走 update，不含 key 走 insert
- `remove()` → body `{"key": 值}` 走 delete

`save()` / `remove()` 会捕获 Model 抛出的 `MvcException`，转成 `code=1`、`msg=异常信息`（响应格式不变）：
Model 里的业务校验失败就抛 `MvcException('给用户看的原因')`。只捕获 `MvcException`，数据库异常仍走全局异常处理。

`Model::grid()` 返回固定结构 `["data" => [...], "totalCount" => N]`。
`Model::gridGroups()` 返回 `["data" => [["key" => 值, "items" => 下一级或 null, "count" => N], ...], "totalCount"?, "groupCount"?]`；分组键走 `DxFilter::column()`，和过滤同一套表达式。
子类要强制附加过滤或拒绝某些字段时覆盖 `gridLoadOptions()`（两个入口都会调），不要只写在 `grid()` 覆盖里，否则分组请求会绕过去。

---

# 设计约束与踩坑记录

> 2026-09-14 从原 `NOTES.md`（v2 重构草稿）整理迁入，只保留仍然有效的规则；过程性的历史描述已丢弃。
> 字段名按统一命名后的新名称书写（规则见 `pdc/.claude/contracts/DB.md`）。

## 命名与代码风格

- 命名遵循 PSR-1，排版遵循 PSR-12（4 空格缩进、可见性显式声明）
- 类文件：文件名 = 类名，PascalCase（`Mvc.php`、`ExceptionHandler.php`）
- 方法 / 函数 / 变量：camelCase；常量全大写下划线分隔（`DEBUG`、`HOST`）
- 纯函数库文件（无类）用 camelCase 文件名，和类文件区分（`mvcFunction.php`）

## 自动加载与目录

- `lib/` 目前是**扁平查找** `lib/{类名}.php`；以后 `lib/` 按功能分子目录时，要同步改 `Mvc.php` 里的 `spl_autoload_register`
- `pdc/model/` 同样按「类名 = 文件名」自动加载，路径取 `Mvc::$cfg['projectDir']`
- composer 的 autoload 由 `Mvc::init()` 按 `$cfg['composerDir']` 加载，不在 `Mvc.php` 顶部写死路径
- 各人本机路径差异放 `pdc/config/common.local.php`（不提交），`index.php` 加载后 `array_merge` 覆盖 `common.php` 默认值
- 入口 `index.php` / `.htaccess` 放在子站点根目录（如 `pdc/admin/`），URL 里不出现 `action` 段；控制器文件仍在 `action/` 子目录

## 数据库层（Db / Model）

- `Db extends Medoo\Medoo`，业务代码直接 `Db::instance($name = 'default')`，首次调用自动加载 `config/database.php` 并按库名缓存连接单例
- 常规查询用 Medoo 数组语法；`rawQuery()` / `rawExecute()` 只用于 Medoo 表达不了的场景
- 事务用 `begin()` / `end()`，支持嵌套计数：计数 0→1 才真正开启、减到 0 才提交；`end()` 多于 `begin()` 抛 `MvcException`；
  脚本结束时计数没归零（忘了 `end()`）会抛 `RuntimeException`，由全局异常处理捕获
- **Medoo 不会自动解码 JSON 列**，读出来是原始字符串；需要对象的地方在 Model 里 `json_decode` 后再返回
  （`Permission::tree()`、`AttrValue::grid()` / `treeByType()` 就是这样做的，前端对字符串调 `Object.keys()` 会按字符遍历）
- 联表展示字段（`admin_user_depa_names` 这类）不是真实列，`gridInsert()` / `gridUpdate()` 里写库前必须 `unset`
- 多对多用 `GROUP_CONCAT` 拼 ID/名称字符串、PHP 端再切数组；**同一查询联了两个多对多关系时必须加 `DISTINCT`**，否则两边做笛卡尔积会重复
  （用户表同时联部门和角色）
- 外键列不能和被引用表的主键同名：曾经 `admin_user.admin_depa_id` 与 `admin_depa.admin_depa_id` 同名，dxDataGrid 过滤时报 `Column is ambiguous`；
  现统一用 `{本表}_lnk_{被引用完整主键}`，建表时发现同名要提醒

## DxFilter（DevExtreme filter → SQL）

- 支持 15 个操作符：`=` `<>` `>` `>=` `<` `<=` `startswith` `endswith` `contains` `notcontains` `between` `anyof` `noneof` `isblank` `isnotblank`
- `and` / `or` 按数组顺序左折叠并逐层加括号，不依赖 SQL 的 AND/OR 优先级
- `contains` 等 LIKE 类操作对 `%` `_` `\` 转义，防止用户输入的通配符破坏匹配
- `noneof` 转 `NOT IN` 时追加 `OR 字段 IS NULL`，避免列中有 NULL 时整条判定为 UNKNOWN
- `= null` → `IS NULL`，`<> null` → `IS NOT NULL`
- 字段名来自前端，过滤和排序（`Model::buildOrderSql()`）一律经 `DxFilter::column()`：命中计算列换成表达式，
  其余必须是 `字段` 或 `别名.字段`（字母、数字、下划线），否则抛 `MvcException`（信息里不带字段名，DEBUG 错误页会原样输出）
- **计算列**：Model 覆盖 `gridComputedColumns()` 返回 `列名 => SQL 表达式`，`grid()` 传给过滤和排序，前端 dataField 直接写列名
  - 表达式引用的表在 `gridFrom()` 里 JOIN；count 查询复用 `gridFrom()` 和同一个 WHERE，总数（含 GROUP BY 的 `COUNT(DISTINCT)`）自动生效
  - 表达式由代码写死，不拼用户输入、不含 `?` 占位符；只能是行级表达式——聚合函数进不了 WHERE，`admin_user_depa_names` 这类 GROUP_CONCAT 列仍不能过滤
  - 不会自动进 SELECT，要返回就在 `gridSelect()` 里拼 `gridComputedSelect()`；列名区分大小写精确匹配，不要和真实列重名
  - 库的 `sql_mode` 含 `ANSI_QUOTES`，表达式里的字符串字面量只能用单引号（双引号会被当成列名）

## 全文检索（lib/FullText）

- Medoo 和 DxFilter 都不支持 `MATCH ... AGAINST`，全文条件一律用 `FullText::condition($列, $关键词)` / `FullText::relevance()` 生成 `[SQL 片段, 参数]`，不要手写 AGAINST 拼关键词
- 关键词处理：BOOLEAN MODE 操作符 `+ - > < ( ) ~ * " @ : & |` 和控制字符换成空白 → 按空白（含全角空格）拆词 → 每个词 `+"词"`（ngram 下短语即「包含这串字符」，可以查词中间的片段）；最多 10 个词、100 个字符；关键词只走绑定参数
- 词长短于 `ngram_token_size`（常量 `FullText::TOKEN_SIZE = 2`，服务器改了要传参）的改用 `LIKE '%词%'`（转义 `%` `_` `\`）；有 LIKE 回退时按组 OR，LIKE 部分要扫描行
- 关键词传数组表示多种写法（如号码原文 + `formatNum()` 结果），任意一组全部命中即可；全是长词时合成一个 `MATCH`（`(+"AB" +"12") (+"AB12")`），能走全文索引
- 与 Grid 组合：`Model::gridWhere()` 返回附加条件（与前端 filter 用 AND，列表和总数都生效），`Model::gridDefaultOrder()` 返回前端没指定排序时的排序（相关度表达式后拼 ` DESC`，再加一个稳定的第二排序键）；两处的参数顺序由 `Model::grid()` 处理
- 相关度按词频和逆文档频率算：只有一行数据、或所有行都含该词时分数为 0
- InnoDB 全文索引的变更在**事务提交后**才能查到：事务里写完马上 MATCH 查不到，自测不能用「事务内执行后回滚」，要造带标记的临时数据、测完删除
- ngram 解析器不受 `innodb_ft_min_token_size` / `ft_min_word_len` 影响，但会丢弃**包含停用词**的 n 元组：内置英文停用词表含 `a`、`i`，含字母 A / I 的二元组全部不进索引也不参与查询（实测 `pad`、`AI` 查不到）。
  `innodb_ft_enable_stopword` 在建索引时生效，全文索引要在 `SET SESSION innodb_ft_enable_stopword = OFF` 之后创建（见 `pdc/sql/part_item_search_ngram_stopword.sql`）
- 空白和换行会切断 n 元组，派生的检索内容按「一段一行」拼，避免不同字段首尾相接凑出假命中

## GridActions::export()（Excel 导出）

- 走服务端 PhpSpreadsheet，不用 DevExtreme 前端导出（前端导出要额外装 exceljs/jszip，且只能导出已加载到表格的数据）
- 前端用隐藏 `<form>` **表单 POST**（为了触发浏览器原生下载），`columns` / `keys` / `filter` 是 JSON 字符串字段，
  `Mvc::buildParams()` 只在 `Content-Type: application/json` 时整体解析 body，所以 `export()` 里要手动 `json_decode`
- 「导出已选」把 `keys` 转成 `[主键, 'anyof', keys]`；「导出全部」沿用前端 `getCombinedFilter()` 的当前过滤条件
- 可见列以 `dataGrid.option('columns')` 的**实时状态**为准（含列选择器里手动隐藏的列），不要用初始化时的 `options.columns`
- lookup 列由 `ExcelExporter::applyLookups()` 按前端传来的 `dataSource/valueExpr/displayExpr` 把原始值换成显示名称，对所有 lookup 列通用

## 视图

- 模板是原生 PHP（`extract` + `require`），不做危险函数扫描
- **不能用 `<?` 短标签**（依赖 `short_open_tag`）；输出用 `<?=...?>`，控制结构一律 `<?php ... ?>`
- `View::put()` 不传路径时按 `Mvc` 当前路由推导模板，不用 `debug_backtrace`
- PHP 端用常量 `HOST`，前端用 `window.APP.HOST`，两者同值；`window.APP` 由 `public/header.view.php` 注入（`HOST` / `STATIC_URL` / `LOCALE` / `GRID_TEXT`），
  依赖环境的值必须 PHP 动态生成，不要写成静态 JS 文件

## 多语言

- `lg()` 只能传**字面量字符串**，传变量扫描工具找不到；配置里的菜单文案在 `config/admin.php` 定义时就 `lg('...')`，视图里不再二次包 `lg()`
- 扫描工具 `LangScanner::run()` 会**删除**代码里已不再引用的 key，所以 `langTool` 的 `SCAN_DIRS` 必须覆盖所有写了 `lg()` 的目录
  （`admin/view`、`admin/action`、`admin/static/js/lib`、`model`、`config`），漏一个目录就会把那里的文案当废弃 key 删掉
- 找不到 key 时 `lg()` 原样返回 key，不报错
- **启用的语言由业务层提供**：`Mvc::init()` 的 `locales` 传 `[编码 => 自称]` 数组或返回它的闭包（闭包在 composer、projectDir 就绪后调用，可以查库；pdc 取 `sys_language`），
  结果存 `Mvc::$cfg['locales']`，切换菜单直接遍历它。框架不直接读业务表；不提供时只有默认语言；默认 `zh-CN` 始终可用（不在列表里会补到最前）
- 语言编码会拼成语言包文件名，一律先过 `Lang::isValidCode()`（`Lang::load()` 遇到不合法编码抛 `MvcException`）
- 语言切换：URL `?lang=en` 写入 `$_SESSION['lang']`，之后沿用；都没有时默认 `zh-CN`。
  GET、SESSION 每一级都用 `Lang::match()` 校验在启用列表内（大小写不敏感，统一成列表里的写法，`?lang=EN` → `en`）；不合法或已停用的值丢弃、继续往下回退，SESSION 里失效的值清掉
- `LangScanner` 按启用语言工作，`run()` / `editableLangFiles()` / `loadFileRows()` / `updateFileValue()` 都要传启用语言编码列表：
  - 源语言 `zh-CN` 始终同步；启用语言的文件不存在时自动新建，条目用中文原文占位待翻译
  - **停用语言的文件保留**：不读不写不删，`run()` 返回的 `kept` 里列出；重新启用后下次扫描再补齐新 key、删废弃 key，已有译文不丢
  - 只允许读写启用语言的文件（不含 `zh-CN.php`），停用语言的文件在 langTool 里不可编辑
- 业务数据多语言（主表 `{表}_cn_xxx` / `{表}_en_xxx` + `{表}_lang` 翻译表，规则见 `pdc/.claude/contracts/DB.md`）用
  `Lang::translation($table, $alias, $fields, $locale = null)` 生成 `['join' => ..., 'columns' => [字段 => 表达式]]`，
  join 拼到 `gridFrom()` 末尾、表达式放进 `gridComputedColumns()`，显示名就能过滤、排序：
  - zh-CN 取 cn 列；en `COALESCE(NULLIF(en,''), cn)`；其它语言 LEFT JOIN 翻译表、语言条件放 ON 里，`COALESCE(NULLIF(译文,''), NULLIF(en,''), cn)`
  - 语言编码来自网址参数，只有过了 `Lang::isValidCode()` 才当字面量拼进 ON；不合法时不 JOIN、走 en → cn
  - 主表与翻译表文本列排序规则必须一致，否则 COALESCE 报 1267 Illegal mix of collations
- `Lang::pickField(['cn','en'])` 只选一列、不逐行回退，只留给没有翻译表的中英列旧表（如 `admin_depa_cn_name`）：
  `zh-CN` 映射 `cn`，其他取 locale 的 `-` 前半段；没有对应列回退 `en`，再没有取第一个；查询里统一别名成固定字段名（如 `admin_depa_name`）
- DevExtreme 语言包文件名全小写（`dx.messages.zh-cn.js`），但 `DevExpress.localization.locale('zh-CN')` 要传原始大小写
- DevExtreme 只自带部分语言的消息包：`header.view.php` 用 `Lang::fallback($locale, 实际存在的消息包)` 选文件（精确匹配 → 语言段 `pt-BR`→`pt` → `en`），
  `locale()` 仍传真实编码——消息按 DevExtreme 自身的回退显示英文，日期/数字格式仍按该语言（实测 `ko`：消息 `Yes` / `No data`，日期 `2026년 9월 15일 화요일`）

## 登录与权限（lib/Auth、PermissionResolver、LoginThrottle、Captcha、PasswordPolicy）

- 登录态在 `$_SESSION['user']`，快照键名与 `admin_user` 字段一致：`admin_user_id` / `admin_user_username` / `admin_user_name`
- 内置超级管理员不入库，`admin_user_id = 0`（自增主键从 1 开始，0 不会冲突）；`Auth::isSuperAdmin()` 判断 `Auth::id() === 0`，
  需要落库的功能（表格布局、资料修改）遇到超级管理员要跳过
- 「记住我」用 selector/validator：cookie 存明文 `selector:validator`，库里只存 validator 的 sha256，比较用 `hash_equals`；
  每次自动登录后轮换 validator；validator 不匹配立即吊销该记录；退出登录同时吊销，保证「退出就是彻底退出」
- `Action::callBefore()` 做登录拦截：Ajax（`X-Requested-With: XMLHttpRequest`）返回 401 JSON，普通页面 302 到登录页
- 登录表单用**普通 form POST**，不用 ajax——ajax 提交时大多数浏览器不弹「保存密码」
- 验证码一次性：校验后立即清除答案；首次登录不要求，密码错一次后才要求，直到下次登录成功
- IP 限流按 IP 不按用户名：连续 6 次密码错误锁 1 小时，锁定期间任何账号（含超级管理员）都拒绝；验证码错误不计数
- `PasswordPolicy`（>8 位且含大写/小写/数字/特殊符号）只用于找回密码和自助改密码；管理员在用户 grid 里改别人密码不受限
- 密码 90 天过期：只有**真正改密码成功**才刷新 `admin_user_password_update_time`，仅弹出提示不刷新
- 权限快照登录时算一次存 `$_SESSION['permissions']`，角色/部门变更要重新登录才生效；
  合并规则：有效角色 = 直接绑定 ∪ 所属各部门绑定；types 取并集，dataScope 取较大值，extra 数值取大、布尔取真
- 会员不能绑 `admin` 站点的角色，`Role::bindToMember()` 应用层校验

## 前端封装（pdc/admin/static/js/lib/dxGrid.js）

- 表格布局保存直接用 dxDataGrid 的 `instance.state()`（不开 `stateStoring` 自动持久化），按 `Auth::id()` + `gridName` 存 `sys_dxdatagrid`；
  grid 创建后自动加载 `default` 方案。**state JSON 里存的是列名，表字段改名后旧布局必须清空**
- 工具条通用文案取 `APP.GRID_TEXT`（header 里用字面量 `lg()` 生成），不在 JS 里写死中文
- `dxCustom.css` 里的 `.dx-editable-header` 等 class 不是 DevExtreme 自动加的，列定义里要自己写进 `cssClass`

## 通用属性（attr_type / attr_value）

`attr_type_ext_schema` 定义扩展字段，JSON 数组套分组套 items：

```json
[{"group": "finance", "caption": "财务属性", "items": [
  {"type": "number", "caption": "对人民币汇率", "field": "cny_rate", "required": 1, "min": 0}
]}]
```

| item 字段 | 说明 | 默认 |
|-----------|------|------|
| `type` | `integer` / `number` / `text` / `checkbox`，旧值 `input` 按 `number` 处理 | `text` |
| `caption` | 显示名称 | 取 `field` |
| `field` | `attr_value_ext_values` 里的 key | 必填 |
| `required` | 1 = 必填 | 0 |
| `min` / `max` | 仅 `integer` / `number` | 不限 |
| `minLength` / `maxLength` | 仅 `text` | 0 / 100 |

`attr_value_ext_values` 存 `{"field": 值}`；前端 `flattenExt()` 打平成 `ext__field` 供表单读写，保存时 `collectExt()` 收回。
树型类型用 dxTreeList，切换类型时先 `dispose()` 旧组件再重建（列结构随 `use_code` 和 schema 变化）。

## 文件上传（lib/UploadedFile、FileUploader、FileStorage、LocalFileStorage、UploadException）

- `$_FILES` 只在 `UploadedFile::fromGlobals()` 里读；Model 只拿 `FileUploader::inspect()` 返回的元数据，不碰超全局变量
- multipart 请求的普通字段 PHP 已放进 `$_POST`、`php://input` 为空，`Mvc::buildParams()` 不需要改；文件不并入 `Mvc::$req`
- 字段名 `file`，兼容 dxFileUploader 默认的 `files[]`（多于一个直接拒绝）；大文件走分片上传，见下一节
- 校验只信内容：扩展名必须在配置 `types` 白名单里，MIME 用 `finfo` 读临时文件、且必须在该扩展名允许的列表里；位图再过一遍 `getimagesize()`；客户端报的 `type` 完全不看
- `FileUploader::BLOCKED_EXTS` / `BLOCKED_MIMES` 写死在框架里：配置 `types` 里出现脚本 / 可执行 / html / svg 扩展名直接抛配置错误；内容识别为 PHP、HTML、可执行文件、脚本的一律拒绝，不管扩展名
- 大小上限**只看配置**：`FileUploader::maxSize()` 就是配置 `maxSize`，分片上传按它校验，与 php.ini 无关；
  `directMaxSize()` = min(配置, `upload_max_filesize`, `post_max_size`)，只用于不分片的 `file/upload`；两个值前端都从 `file/options` 取
- `inspect($file, $maxSize = null)` 第二个参数临时换上限（zip 包传业务配置的 `zipMaxSize`），不传取配置 `maxSize`
- 超过 `post_max_size` 时 PHP 丢掉整个 body，`UploadedFile` 按 `CONTENT_LENGTH` 判成 `TOO_LARGE`；但 PHP 会先输出一条启动期 Warning（`display_errors=0` 挡不住，看 `display_startup_errors`），JSON 前面会多一段 HTML，生产必须关掉
- 落盘路径由 `FileUploader::newPath()` 生成：`年/月/日/32位随机十六进制.扩展名`，**不用原文件名**；原文件名只入库，只用于下载时的 `Content-Disposition`（`filename*=UTF-8''`）
- `LocalFileStorage` 相对路径先过白名单正则（只允许 `[A-Za-z0-9_-]` 目录段 + 扩展名），已存在的文件再用 `realpath` 确认没逃出存储根；`put()` 不覆盖已存在路径
- 去重：按 crc32 + 大小 + 存储名找候选，还要 `sameContent()`（sha256 比对实际文件）一致才复用，crc32 碰撞不会串文件；复用时记录里是**第一次上传的原文件名**
- 错误分两类：用户可纠正的抛 `UploadException`（代码取类常量），框架层不写 `lg()`（langTool 不扫 `mvc/v2`），业务 Action 按代码映射成 `lg('...')` 字面量；写盘失败等环境问题抛 `RuntimeException` 走全局异常处理
- 访问不走静态直出：存储目录禁止网址访问（默认目录靠 `pdc/storage/.htaccess`，Nginx 要在站点配置里 deny，或把 `uploadDir` 配到 Web 根外），统一经 `file/view` / `file/download` 登录后输出
- `LocalFileStorage::output()` 输出前 `session_write_close()`（大文件不占 session 锁），并去掉 `session_start()` 发的 `Pragma` / `Expires`，否则和 `Cache-Control` 冲突；内联打开的图片加 `Content-Security-Policy: sandbox`，PDF 不加
- OSS 预留：实现 `FileStorage` 接口并在 `FileUploader::storage()` 的 `match` 里挂上；`put()` 返回版本 ID，`output()` 302 到短时效签名地址

## 分片上传与 zip 批处理（lib/ChunkUpload、ZipSafeReader、ZipTask、UploadTempDir）

- 协议就用 dxFileUploader 自带的 `chunkSize` 分片协议，**不自写拆分 JS**：每片一个 multipart 请求，文件字段 + `chunkMetadata`（`FileGuid` / `FileName` / `Index` / `TotalCount` / `FileSize` / `FileType`）
- 上传 ID 取 `FileGuid`（小写十六进制 8-4-4-4-12），过 `ChunkUpload::isValidId()` 才拼目录（防穿越）；首片把 owner 写进 `meta.json`，之后续传、查询、完成都必须同一个 owner，别人拿到 ID 也用不了
- 目录 `{tempDir}/chunk/{上传 ID}/`：`meta.json`（owner、文件信息、已收分片、完成结果）+ `{序号}.part`；同一 ID 的并发请求靠 `meta.json` 上的 flock 串行
- 分片核对：非最后一片大小一致、总片数正好等于 `ceil(总大小 ÷ 分片大小)`、最后一片是余数；合并前再核对片数与总大小，对不上抛 `CHUNK_INCOMPLETE`
- 完成后删分片和合并文件，结果留在 `meta.json`：最后一片的响应丢了重发、或查询状态时拿到同一结果，**不会重复入库**（前端 `chunkStatus` 据此实现断点续传）
- `receive()` 的 `$onComplete` 回调抛 `UploadException` = 内容不合格，整个上传目录删除；抛其它异常（写盘、数据库）时分片保留，重发最后一片可以重试
- 单片大小 = `ChunkUpload::effectiveChunkSize(配置 chunkSize)`，取配置、`upload_max_filesize`、`post_max_size - 64KB` 的最小值：**只有单片受 php.ini 限制**，文件总大小不受
- zip 安全：`ZipSafeReader::scan()` 只读中央目录出清单（相对路径、大小），整包超限（条目总数、文件数、解压总大小）直接抛异常，单个条目不合格进 `rejected`
  - zip slip：条目名只进清单，落盘文件名由调用方给（`ZipTask` 用 `work/{序号}.tmp`），**永远不拿条目名拼磁盘路径**；另外 `../`、以 `/` 开头、含冒号（盘符 / NTFS 数据流）、含控制字符的条目判为不安全
  - 压缩炸弹：登记大小可以伪造，`extractTo()` 按登记大小封顶读取，多出一个字节就判 `ZIP_RATIO` 并删掉半截文件；1MB 以上的条目还要看压缩比（默认 100 倍）
  - 忽略（不进清单，只计数）：目录项、符号链接（Unix 外部属性 `S_IFLNK`）、`__MACOSX`、以 `.` 开头的路径段、`Thumbs.db` / `desktop.ini`；加密条目和包内重复路径（不区分大小写）记为失败
  - 文件名不是合法 UTF-8 时按 CP936(GBK) 转码（中文 Windows 打的包），转不过来判为不安全；少数 GBK 字节串恰好也是合法 UTF-8，zip 格式本身标不准编码，只能这样猜
  - 取流：PHP 8.2+ 用 `getStreamIndex()`，8.1 回退 `getStream(statIndex 名称)`，两者实测结果一致（含 GBK 条目）
- 批处理（`ZipTask`）是**前端驱动分批**：建任务时只扫中央目录，每批用到哪个文件才解压哪个——2GB 的包一次性解压会超 `max_execution_time`，还要占双倍磁盘
  - 每个解压出来的文件**必须重新过 `FileUploader::inspect()`**（真实 MIME、扩展名白名单、禁止类型、大小），再交给业务处理器；处理器返回后立即删掉临时文件（要留存就在处理器里复制）
  - 处理器返回 `['status' => 'ok'|'skipped', 'message', 'data']`；业务判为失败时抛 `MvcException`（信息直接给用户看），其它异常不捕获，游标停在出错的那个文件上，下次从它继续
  - 每处理完一个文件就写回游标；同一任务同时只允许一批在跑（flock 非阻塞，拿不到锁抛 `TASK_BUSY`）
  - 任务 ID 服务端随机生成（32 位十六进制）并绑定 owner，别人不能继续处理或删除
- 清理：`{tempDir}/chunk`、`{tempDir}/task` 下超过 `tempExpire`（默认 24 小时）没有动静的目录由 `UploadTempDir::sweep()` 删除；
  **触发方式是业务在分片、批处理请求里顺手调**（目录下 `.last_sweep` 节流，每小时最多真扫一次），不依赖 cron；只删名字匹配 ID 格式的目录，不跟随符号链接
- 临时目录也禁止网址访问（默认在 `pdc/storage/temp`，靠 `pdc/storage/.htaccess`）

## 本地测试踩坑

- CLI SAPI 读不到 `php://input`，测 JSON 接口要用 `php -S` 内置服务器 + 模拟 `.htaccess` 的 router
- Windows / Git Bash 下 `curl -d` 直接传中文会被截断乱码（`json_decode` 失败后 `save()` 会误判成新增空数据）；
  改用 `--data-binary @文件` 或 `--data-urlencode field@文件`
- 腾讯企业邮箱 SMTP 报 `535 authentication failed` 时，通常是要用后台生成的「SMTP 授权码」而不是登录密码，并需在管理后台开启 SMTP 服务
- Git Bash 下测上传，`curl -F "files[]=@..."`、带 `;type=` / `;filename=` 的参数会被 MSYS 路径转换弄坏（curl 报 26 读不到文件）：
  `export MSYS_NO_PATHCONV=1` 并用 `pwd -W` 取 Windows 路径；中文文件名写进 `-K` 配置文件（`form = "file=@路径;filename=中文名.png"`），磁盘上的文件用 ASCII 名
- `php -S` 的 router 路径要写成相对启动目录的 `router.php`（先 `cd` 到 router 所在目录），传绝对短路径（`ADMINI~1`）时报找不到 router；
  后台启动时 `cd` 不一定生效，改用绝对长路径并带 `-t`：`php -S 127.0.0.1:端口 -t 目录 目录/router.php`
- PHP 的 curl 扩展发 multipart 时，`CURLOPT_POST` 必须在 `CURLOPT_POSTFIELDS` **之前**设置（POSTFIELDS 传数组时根本不用设 `CURLOPT_POST`）：
  顺序反了会把已构造好的 multipart 重置成 `application/x-www-form-urlencoded` 空表单，服务端 `$_POST` / `$_FILES` 全空，很容易误判成上传代码的问题
