# 7.1 文件上传全流程 :::tip 概述 文件处理分为两个阶段:上传到临时目录(upload_file)→ 入库时迁移到正式目录(save_file)。这种"两阶段"设计将文件上传与业务逻辑解耦,前端先上传获取临时 URL,提交表单时 Logic 层自动将临时文件迁移到正式目录。 ::: ## 完整流程图 ```text ┌─────────────────────────────────────────────────────────────────────┐ │ 第一阶段:上传 │ └─────────────────────────────────────────────────────────────────────┘ 前端 后端 │ │ │── POST /api/upload ────────►│ │ Content-Type: │ │ multipart/form-data │ │ file: <二进制> │ │ │ │ ┌──────┴──────┐ │ │upload_file()│ │ │ │ │ │ 1. 检查上传状态 │ │ 2. finfo 检测真实 MIME │ │ 3. 按类型校验扩展名 │ │ 4. 校验文件大小 │ │ 5. 图片额外 getimagesize 校验 │ │ 6. 保存到 temp/日期/随机文件名 │ └──────┬──────┘ │ │ │◄── { fileUrl, filePath, ────│ │ originalName, fileSize, │ │ fileExtension, fileType }│ │ │ │ 前端显示预览 │ │ 或暂存 URL │ ┌─────────────────────────────────────────────────────────────────────┐ │ 第二阶段:入库 │ └─────────────────────────────────────────────────────────────────────┘ 前端 后端 │ │ │── POST /api/example/add ──►│ │ { name: "测试", │ │ avatar: "https:// │ │ cdn.example.com/ │ │ temp/20260920/ │ │ abc123.jpg" } │ │ │ │ ┌──────┴──────┐ │ │ BaseLogic │ │ │ ::add() │ │ └──────┬──────┘ │ │ │ ┌──────┴──────┐ │ │ processFile │ │ │ FieldsOn │ │ │ Save() │ │ └──────┬──────┘ │ │ │ ┌──────┴──────┐ │ │ save_file() │ │ │ │ │ │ 1. 去掉域名得到相对路径 │ │ 2. 包含 temp/ → 移动到正式目录 │ │ 3. 外部URL → 下载保存 │ └──────┬──────┘ │ │ │ ┌──────┴──────┐ │ │ 数据库存储 │ │ │ /uploads/ │ │ │ example/ │ │ │ 20260920/ │ │ │ abc123.jpg │ │ └──────┬──────┘ │ │ │◄── { code:0, data:{id:1} }─│ ┌─────────────────────────────────────────────────────────────────────┐ │ 第三阶段:读取 │ └─────────────────────────────────────────────────────────────────────┘ 前端 后端 │ │ │── GET /api/example/detail/1►│ │ │ │ ┌──────┴──────┐ │ │processFile │ │ │FieldsOnRead │ │ │() │ │ └──────┬──────┘ │ │ │ ┌──────┴──────┐ │ │get_file_url │ │ │() │ │ │拼接域名 │ │ └──────┬──────┘ │ │ │◄── { avatar: "https://──────│ │ cdn.example.com/uploads/ │ │ example/20260920/ │ │ abc123.jpg" } │ ``` ## upload_file 函数 ```php // app/common.php /** * 上传文件到临时目录 * * 处理流程: * 1. 检查上传状态,细分错误码 * 2. 用 finfo 检测真实 MIME(防止伪造扩展名) * 3. 按类型校验扩展名白名单 * 4. 校验文件大小(config/file.php → max_size) * 5. 图片额外用 getimagesize 校验(防止非图片伪装) * 6. 在 temp/日期/ 下保存,随机文件名 * * @param string $formName 表单字段名 * @param string $type 文件类型:image|file * @return array 成功返回 [url, path, name, size, ext, mime];失败返回 [error] */ function upload_file(string $formName = 'file', string $type = 'file'): array { // ... 完整实现见 app/common.php } ``` ### 参数说明 | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `$formName` | string | `'file'` | 表单字段名 | | `$type` | string | `'file'` | 文件类型:`image`(仅图片)/ `file`(所有文件) | ### 返回值 **成功:** ```php [ 'url' => 'https://cdn.example.com/temp/20260920/abc123.jpg', // 完整 URL 'path' => 'temp/20260920/abc123.jpg', // 相对路径 'name' => 'photo.jpg', // 原始文件名 'size' => 102400, // 文件大小(字节) 'ext' => 'jpg', // 扩展名 'mime' => 'image/jpeg', // MIME 类型 ] ``` **失败:** ```php ['error' => '错误信息'] ``` ### 错误类型 | 错误信息 | 说明 | |---------|------| | 未选择文件 | 表单字段为空 | | 文件上传失败 | PHP 上传错误(代码 1-7) | | 不允许的文件类型 | 扩展名不在白名单中 | | 文件大小超出限制 | 超过 config/file.php 中的 max_size | | 图片文件不合法 | 图片类型校验失败(getimagesize 验证) | ## save_file 函数 ```php /** * 保存文件(临时→正式) * * 处理逻辑: * 1. 带域名 URL → 去掉域名得到相对路径 * 2. 包含 temp/ → 移动到正式目录 * 3. 外部 URL → 下载保存 * 4. 已在正式目录 → 直接返回 * * @param string $fileUrl 文件 URL(带域名)或相对路径 * @param string $saveDir 正式保存的子目录 * @return string|false 入库路径(不带域名),失败返回 false */ function save_file(string $fileUrl, string $saveDir = '') { // 1. 带域名URL → 去掉域名得到相对路径 // 2. 包含 temp/ → 移动到正式目录 // 3. 外部URL → 下载保存 // 4. 已在正式目录 → 直接返回 } ``` ### 处理逻辑 ```text 输入 URL: "https://cdn.example.com/temp/20260920/abc123.jpg" │ ▼ 去掉域名 相对路径: "temp/20260920/abc123.jpg" │ ▼ 包含 temp/ → 需要迁移 │ ▼ 移动到正式目录 目标路径: "/uploads/example/20260920/abc123.jpg" │ ▼ 返回入库路径 输出: "/uploads/example/20260920/abc123.jpg" ``` ## get_file_url 函数 ```php /** * 获取文件完整 URL * * 将数据库中的相对路径拼接为完整 URL: * - 已是完整 URL → 原样返回 * - 相对路径 → 拼接配置的域名 * - 空值 → 返回空字符串 * * @param string $path 数据库中的相对路径 * @return string 完整 URL */ function get_file_url(string $path): string { // 1. 空值 → 返回 '' // 2. 已是 http/https 开头 → 原样返回 // 3. 拼接 config/file.php 中的 domain_url } ``` ### 转换示例 ```text 数据库值: "/uploads/example/20260920/abc123.jpg" │ ▼ get_file_url() │ ▼ 拼接 domain_url 输出: "https://cdn.example.com/uploads/example/20260920/abc123.jpg" ``` ## 在 Logic 中配置 ```php class ExampleLogic extends BaseLogic { /** * 需要自动处理的文件上传字段 * * @var array */ protected array $fileFields = ['avatar', 'cover']; /** * 文件保存的子目录 * * @var string */ protected string $fileSaveDir = 'example'; } ``` ### 自动行为 | 操作 | 自动行为 | |------|---------| | `add()` | `processFileFieldsOnSave()` → 调用 `save_file()` 迁移临时文件 | | `update()` | `processFileFieldsOnSave()` → 调用 `save_file()` 迁移临时文件 | | `detail()` | `processFileFieldsOnRead()` → 调用 `get_file_url()` 补全域名 | | `pageList()` | `processFileFieldsOnReadList()` → 调用 `get_file_url()` 补全域名 | | `allList()` | `processFileFieldsOnReadList()` → 调用 `get_file_url()` 补全域名 | ### 多文件字段 ```php class UserLogic extends BaseTenantLogic { /** * 需要自动处理的文件上传字段 * * @var array */ protected array $fileFields = ['avatar']; /** * 文件保存的子目录 * * @var string */ protected string $fileSaveDir = 'user/avatar'; } class ArticleLogic extends BaseTenantLogic { /** * 需要自动处理的文件上传字段 * * @var array */ protected array $fileFields = ['cover', 'attachment']; /** * 文件保存的子目录 * * @var string */ protected string $fileSaveDir = 'article'; } ``` ## 前端上传接口 ### 上传文件 ```bash POST /api/upload/uploadFile Content-Type: multipart/form-data file: <二进制文件> ``` **响应:** ```json { "code": 0, "data": { "fileUrl": "https://cdn.example.com/temp/20260920/abc123.jpg", "filePath": "temp/20260920/abc123.jpg", "originalName": "photo.jpg", "fileSize": 102400, "fileExtension": "jpg", "fileType": "image/jpeg", "fileName": "abc123.jpg" } } ``` ### 上传图片(指定 type=image) ```bash POST /api/upload/uploadFile?type=image Content-Type: multipart/form-data file: <二进制图片> ``` ### 响应字段说明 | 字段 | 说明 | 示例 | |------|------|------| | `fileUrl` | 完整 URL(含域名) | `https://cdn.example.com/temp/...` | | `filePath` | 相对路径 | `temp/20260920/abc123.jpg` | | `originalName` | 原始文件名 | `photo.jpg` | | `fileSize` | 文件大小(字节) | `102400` | | `fileExtension` | 扩展名 | `jpg` | | `fileType` | MIME 类型 | `image/jpeg` | | `fileName` | 服务器文件名 | `abc123.jpg` | ## 前端集成 ### NaiveUI 上传组件 ```vue ``` ### 提交表单 ```typescript // 前端提交时,avatar 字段的值是上传接口返回的 fileUrl const formData = { name: '测试', avatar: 'https://cdn.example.com/temp/20260920/abc123.jpg', }; // 后端 Logic 层自动: // 1. 从 avatar 字段检测到临时文件 URL // 2. 调用 save_file() 迁移到正式目录 // 3. 去掉域名,只存相对路径入库 ``` ## 配置项 ```ini ; .env [FILE] ; 文件上传域名 DOMAIN_URL = https://cdn.example.com ; 最大文件大小(字节) MAX_SIZE = 10485760 ; 允许的图片扩展名 ALLOW_IMAGE_EXT = jpg,jpeg,png,gif,bmp,webp ; 允许的文件扩展名 ALLOW_FILE_EXT = jpg,jpeg,png,gif,bmp,webp,doc,docx,xls,xlsx,pdf,zip,rar ``` ```php // config/file.php return [ 'domain_url' => env('file.domain_url', ''), 'max_size' => env('file.max_size', 10485760), // 10MB 'allow_image_ext' => env('file.allow_image_ext', 'jpg,jpeg,png,gif,bmp,webp'), 'allow_file_ext' => env('file.allow_file_ext', 'jpg,jpeg,png,gif,bmp,webp,doc,docx,xls,xlsx,pdf,zip,rar'), ]; ``` ## 安全检查链 ```text 上传文件安全检查: │ ▼ 1. PHP 上传状态检查(UPLOAD_ERR_OK) │ ▼ 2. finfo 检测真实 MIME 类型(防止伪造扩展名) │ ▼ 3. 扩展名白名单校验 │ ▼ 4. 文件大小限制(config/file.php → max_size) │ ▼ 5. 图片额外 getimagesize 校验(防止非图片伪装) │ ▼ 6. 生成随机文件名(防止路径遍历攻击) │ ▼ 7. 存储到 Web 根目录之外(或 Nginx 禁止执行 PHP) ``` ```nginx # Nginx 配置:上传目录禁止执行 PHP location ~* /uploads/.*\.php$ { deny all; } ```