# 7.1 文件上传全流程 :::tip 概述 文件处理分为三个阶段:**上传**(`uploadFile` 接口校验后存入 `temp/` 临时目录)→ **入库**(Service 的 `fileFields` 经 `save_file()` 归一化:去域名、迁移临时文件到正式目录)→ **读取**(`get_file_url()` 补全域名)。上传与业务逻辑解耦:前端先上传拿到 URL/路径,提交表单时框架自动完成字段处理。 ::: ## 完整流程图 ```text ┌─────────────────────────────────────────────────────────────────────┐ │ 第一阶段:上传 │ └─────────────────────────────────────────────────────────────────────┘ 前端 后端 │ │ │── POST /api/upload/ ───────►│ (auth.jwt 组内,需 sys:upload:file 权限) │ uploadFile │ │ Content-Type: │ │ multipart/form-data │ │ file: <二进制> │ │ type: image | file │ │ │ │ ┌──────┴───────────┐ │ │ UploadController │ │ │ ::uploadFile() │ │ └──────┬───────────┘ │ │ │ 1. 校验 type 合法性(image / file) │ 2. 文件存在且上传有效(isValid) │ 3. 按类型读取扩展名白名单(config/file.php) │ 4. 校验文件大小(file.max_size,默认 10MB) │ 5. 生成路径:temp/Ymd/His_随机串.扩展名 │ 6. 采集元数据:原始名 / MIME(finfo)/ 大小 │ 7. mkdir + move 保存 │ │ │◄── { fileUrl, filePath, ────│ │ originalName, fileSize, │ │ fileExtension, fileType, │ │ fileName } │ │ │ │ 前端暂存 URL/路径 │ ┌─────────────────────────────────────────────────────────────────────┐ │ 第二阶段:入库 │ └─────────────────────────────────────────────────────────────────────┘ 前端 后端 │ │ │── POST /api/example/add ──►│ │ { name: "测试", │ │ avatar: "temp/ │ │ 20261002/123456_ab.jpg" }│ │ │ │ ┌──────┴──────────────┐ │ │ ExampleService::add │ │ │ (use HasFileProcessing)│ │ └──────┬──────────────┘ │ │ │ ┌──────┴──────────────┐ │ │ processFileFieldsOnSave │ │ │ → save_file() │ │ │ 1. 去掉域名得相对路径 │ │ │ 2. temp/ 开头 → 迁移 │ │ │ 3. 外部URL → 下载保存 │ │ │ 4. 正式路径 → 直接返回│ │ └──────┬──────────────┘ │ │ │◄── { code:0, data:{id:1} }──│ ┌─────────────────────────────────────────────────────────────────────┐ │ 第三阶段:读取 │ └─────────────────────────────────────────────────────────────────────┘ 前端 后端 │ │ │── GET /api/example/detail/1►│ │ │ │ ┌──────┴───────────────┐ │ │ processFileFieldsOnRead │ │ │ → get_file_url() │ │ │ 拼接 FILE_DOMAIN_URL │ │ └──────┬───────────────┘ │ │ │◄── { avatar: "https://──────│ │ cdn.example.com/uploads/ │ │ example/20261002/..." } │ ``` :::tip 临时目录中转 上传接口把文件写入 `temp/` 临时目录,**不直接落正式目录**;提交表单时 `save_file()` 依据 `temp/` 前缀把文件迁移到 Service 的 `fileSaveDir` 子目录(`user/`、`article/`、`example/` …),正式目录与业务模块一一对应。上传后未被表单引用的文件停留在 `temp/`,可按周期清理。 ::: ## 上传接口 ### 上传文件 ```bash POST /api/upload/uploadFile Content-Type: multipart/form-data Authorization: Bearer file: <二进制文件> ``` **参数:** | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `file` | File | 是 | 上传的文件 | | `type` | string | 否 | `image`(校验图片后缀白名单)/ `file`(校验全量文件后缀白名单,默认) | **响应(来自项目实际代码):** ```json { "code": 0, "ok": true, "msg": "上传成功", "data": { "originalName": "photo.jpg", "fileExtension": "jpg", "fileType": "image/jpeg", "fileSize": 102400, "fileName": "123456_a1b2c3d4e5f6.jpg", "filePath": "temp/20261002/123456_a1b2c3d4e5f6.jpg", "fileUrl": "http://cdn.example.com/temp/20261002/123456_a1b2c3d4e5f6.jpg" } } ``` **响应字段说明:** | 字段 | 说明 | 示例 | |------|------|------| | `originalName` | 原始文件名 | `photo.jpg` | | `fileExtension` | 扩展名 | `jpg` | | `fileType` | MIME 类型(`getMimeType()`) | `image/jpeg` | | `fileSize` | 文件大小(字节) | `102400` | | `fileName` | 服务器文件名(时间+随机串) | `123456_a1b2c3d4e5f6.jpg` | | `filePath` | 相对存储路径(**提交表单建议用此字段**) | `temp/20261002/...` | | `fileUrl` | 可访问 URL(`FILE_DOMAIN_URL` 为空时等于相对路径) | `http://cdn.example.com/...` | ### 存储规则 ```text {upload_dir}/ ← config/file.php → upload_dir(默认 uploads) └── temp/ ← 所有类型统一存入(config/file.php → temp_dir) └── 20261002/ ← Ymd 分目录 └── 123456_a1b2c3d4e5f6.jpg ← His_ + 随机8字节hex + 扩展名 {upload_dir}/ ← 正式目录,落库时由 save_file() 迁移生成 ├── user/ article/ example/ … ← Service 的 fileSaveDir └── 20261002/ └── 143052_a1b2c3d4e5f6.jpg ``` - `type` 只影响后缀白名单,存储目录始终是 `temp/` - 随机文件名避免重名与**可预测性攻击**(时间戳 + `random_bytes(8)`) - 目录不存在时自动 `mkdir(0755, true)` 创建 ### 安全检查链 ```text 上传文件安全检查(UploadController::uploadFile): │ ▼ 1. 权限校验(auth.jwt + #[Permission('sys:upload:file')]) │ ▼ 2. type 参数合法性(仅 image / file) │ ▼ 3. 文件存在且上传有效(UploadedFile::isValid,捕获 PHP 上传错误) │ ▼ 4. 扩展名白名单(image → file.image_ext;file → file.file_ext) │ ▼ 5. 文件大小限制(config/file.php → max_size,默认 10MB) │ ▼ 6. 服务端生成随机路径(防路径遍历) │ ▼ 7. Nginx 仅允许入口 index.php 执行 —— 上传的 .php 一律 404 ``` :::warning svg 已移除 `FILE_IMAGE_EXT` 默认白名单**不含 svg**——svg 可内嵌 JavaScript,上传后直接作为静态资源访问存在 XSS 风险。如业务必须使用,请做内容清洗或以 attachment 方式返回。 ::: ## 入库处理(HasFileProcessing) Service 配置文件字段后,保存/读取自动处理: ```php // app/Services/ExampleService.php(形态示例) class ExampleService extends BaseService { use HasFileProcessing, /* 其他 Trait */; // 需要自动处理的文件字段 protected array $fileFields = ['avatar']; // 多文件字段(逗号分隔的多个 URL) protected array $multiFileFields = ['images']; // 文件保存子目录(未指定时按模型类名小写推导) protected string $fileSaveDir = 'example'; } ``` ### 自动行为 | 操作 | 自动行为 | |------|---------| | `add()` / `update()` | `processFileFieldsOnSave()` → `save_file()` 归一化(去域名、迁移 temp、下载外部 URL) | | `detail()` | `processFileFieldsOnRead()` → `get_file_url()` 补全域名 | | `pageList()` / `allList()` | `processFileFieldsOnReadList()` 逐条补全域名 | **处理细节(来自 `HasFileProcessing` 源码):** - 空值跳过;`save_file()` 返回 `false` 时**保留原值**(避免误清空) - 多文件按逗号拆分逐个处理,单个失败保留原 URL - 未 `use HasFileProcessing` 时占位方法直接透传(见 [4.7 横切机制](/zh/architecture/cross-cutting.html)) ## 核心辅助函数(app/Helpers/helpers.php) ```php /** * 保存文件(归一化入库路径) * * 处理逻辑: * 1. 带域名 URL → 去掉域名得到相对路径 * 2. 包含 temp/ → 移动到正式目录(fileSaveDir) * 3. 外部 URL → save_remote_file() 下载保存 * 4. 已在正式目录 → 直接返回 */ function save_file(string $fileUrl, string $saveDir = ''): string|false /** * 获取文件完整 URL * - 空值 → '';已是 http(s) 开头 → 原样返回 * - 相对路径 → 拼接 config/file.php 的 domain_url */ function get_file_url(string $path): string /** 生成存储路径:{saveDir}/Ymd/random.扩展名 */ function create_file_path(string $saveDir = '', string $ext = ''): string /** 下载外部 URL 并保存(配合 is_internal_ip 做内网地址拦截,防 SSRF) */ function save_remote_file(string $url, string $saveDir = ''): string|false ``` ## 前端集成 ### Element Plus 上传组件 ```vue ``` ### 提交表单 ```typescript const formData = { name: '测试', avatar: 'temp/20261002/123456_a1b2c3d4e5f6.jpg', }; // 后端 Service 层自动: // 1. 识别 avatar 为 fileFields 字段 // 2. save_file() 归一化(去域名 / 迁移 temp 到 fileSaveDir / 下载外部 URL) // 3. 正式目录相对路径入库(如 example/20261002/123456_a1b2c3d4e5f6.jpg) ``` ## 配置项 ```ini # .env(config/file.php 读取) FILE_UPLOAD_DIR=uploads # 上传根目录(相对 public/) FILE_DOMAIN_URL= # 文件访问域名(空则返回相对路径) FILE_MAX_SIZE=10 # 单文件大小上限(MB) FILE_IMAGE_EXT=gif,jpg,jpeg,png,bmp,webp # 图片后缀白名单(svg 已移除) FILE_EXT=gif,jpg,jpeg,png,bmp,webp,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv ``` 详见 [7.2 上传配置详解](/zh/tools/upload-config.html)。 :::warning 同时调整 PHP 限制 业务层 `FILE_MAX_SIZE=10` 生效的前提是 PHP 层放行,确认 `php.ini`: ```ini upload_max_filesize = 50M post_max_size = 50M max_execution_time = 300 ``` :::