# 8.4 文件上传接口 :::tip 概述 文件上传接口将文件保存到 `temp/` 临时目录,返回文件相对路径与 URL。前端拿到 `filePath` 后提交表单,Service 的 `fileFields` 经 `save_file()` 把临时文件迁移到正式目录并入库,读取时自动补全域名。通过 `type` 参数区分后缀白名单(文件/图片)。 ::: ## 接口信息 | 项目 | 说明 | |------|------| | URL | `POST /api/upload/uploadFile` | | Content-Type | `multipart/form-data` | | 权限 | **需认证**(`auth.jwt` 组内)+ `sys:upload:file` 权限 | | 日志 | `#[OperationLog]` TYPE_UPLOAD(上传记录进操作日志) | :::warning 不是公开接口 上传接口位于认证中间件组内且标注 `#[Permission('sys:upload:file', '上传文件')]`——请求头必须携带 `Authorization: Bearer `,且角色需分配该权限节点。 ::: ## 请求参数 | 参数 | 类型 | 必填 | 默认值 | 说明 | |------|------|------|--------|------| | `file` | File | 是 | - | 文件二进制数据(FormData 字段名必须为 `file`) | | `type` | string | 否 | `file` | 上传类型:`file`(所有合法文件)/ `image`(仅图片) | ### 成功响应 ```json { "code": 0, "ok": true, "msg": "上传成功", "data": { "fileUrl": "http://localhost:8000/uploads/temp/20261002/123456_a1b2c3d4e5f6.jpg", "filePath": "temp/20261002/123456_a1b2c3d4e5f6.jpg", "originalName": "photo.jpg", "fileSize": 102400, "fileExtension": "jpg", "fileType": "image/jpeg", "fileName": "123456_a1b2c3d4e5f6.jpg" } } ``` ### 响应字段 | 字段 | 类型 | 说明 | 示例 | |------|------|------|------| | `fileUrl` | string | 可访问 URL(`FILE_DOMAIN_URL` 为空时等于相对路径) | `http://localhost:8000/uploads/temp/...` | | `filePath` | string | 相对存储路径(**提交表单建议用此字段**) | `temp/20261002/123456_a1b2....jpg` | | `originalName` | string | 原始文件名 | `photo.jpg` | | `fileSize` | number | 文件大小(字节) | `102400` | | `fileExtension` | string | 扩展名 | `jpg` | | `fileType` | string | MIME 类型(`getMimeType()`) | `image/jpeg` | | `fileName` | string | 服务器文件名(时间+随机串) | `123456_a1b2c3d4e5f6.jpg` | ### type 参数说明 | type 值 | 说明 | 允许的后缀 | 存储目录 | |---------|------|-----------|---------| | `file`(默认) | 所有合法文件 | 由 `config/file.php` 的 `file_ext` 控制 | `uploads/temp/Ymd/` | | `image` | 仅图片 | 由 `config/file.php` 的 `image_ext` 控制 | `uploads/temp/Ymd/` | 两种 `type` 的存储目录相同,均为 `temp/` 临时目录;正式目录在表单提交时由 `save_file()` 按 Service 的 `fileSaveDir` 生成。 **file_ext 默认值:** ```text gif, jpg, jpeg, png, bmp, webp, doc, docx, xls, xlsx, ppt, pptx, pdf, zip, rar, txt, csv ``` **image_ext 默认值(无 svg——内嵌 JS 有 XSS 风险):** ```text gif, jpg, jpeg, png, bmp, webp ``` ## 失败响应 ```json { "code": 1, "ok": false, "msg": "不支持的文件格式,允许:gif, jpg, jpeg, png, bmp, webp", "data": null } ``` ## 常见错误(真实文案) | 错误信息 | 原因 | 解决方案 | |---------|------|----------| | `未提供认证令牌`(code 401) | 请求头未携带 Token | 携带 `Authorization: Bearer ` | | `无访问权限:sys:upload:file`(HTTP 403) | 角色无上传权限 | 分配权限节点 | | `请选择文件` | 表单字段名不是 `file` 或上传无效 | 检查 FormData 的 key 是否为 `file` | | `不支持的上传类型,允许:image,file` | type 参数非法 | 传 `image` 或 `file` | | `不支持的文件格式,允许:...` | 后缀不在白名单 | 转换格式或调整 `FILE_IMAGE_EXT`/`FILE_EXT` | | `文件大小不能超过 NMB` | 超过 `FILE_MAX_SIZE` | 压缩文件或调大限制 | ## 前端使用 ### Element Plus Upload 组件 ```vue ``` ### 手动上传 ```typescript // 上传文件 const formData = new FormData(); formData.append('file', file); const res = await http.request({ url: '/upload/uploadFile', method: 'POST', data: formData, headers: { 'Content-Type': 'multipart/form-data' }, }); // res.filePath = "temp/20261002/123456_a1b2....jpg" // res.fileUrl = "http://localhost:8000/uploads/temp/20261002/..." // 上传图片(指定 type=image) const res2 = await http.request({ url: '/upload/uploadFile?type=image', method: 'POST', data: formData, headers: { 'Content-Type': 'multipart/form-data' }, }); ``` ### 提交表单时传路径 ```typescript // 推荐提交 filePath(相对路径),Service 层归一化后入库 await exampleAdd({ name: '测试', avatar: 'temp/20261002/123456_a1b2c3d4e5f6.jpg', }); // 后端 ExampleService(use HasFileProcessing)自动: // 1. 识别 avatar 为 fileFields 字段 // 2. save_file() 归一化(去域名 / 迁移 temp 到 fileSaveDir / 下载外部 URL) // 3. 正式目录相对路径入库(如 example/20261002/...) // 读取时 processFileFieldsOnRead 补全 FILE_DOMAIN_URL 域名 ``` ### 头像上传示例 ```vue ``` ## 上传后处理流程 ```text 前端上传 → public/uploads/temp/Ymd/xxx.jpg(临时目录) │ ▼ 提交表单(携带 filePath) ExampleService::add() │ ├─ processFileFieldsOnSave() │ └─ save_file() → temp/ 文件迁移到 example/,并归一化(去域名 / 外部 URL 下载) │ └─ 数据库存储正式目录相对路径(example/Ymd/xxx.jpg) │ ▼ 读取时 processFileFieldsOnRead() └─ get_file_url() → 拼接 FILE_DOMAIN_URL 返回完整 URL ``` 详见 [7.1 文件上传全流程](/zh/tools/upload.html)。 ## 配置项 ```ini # .env(config/file.php 读取) FILE_UPLOAD_DIR=uploads # 上传根目录(相对 public/) FILE_DOMAIN_URL=https://cdn.example.com # 文件访问域名 FILE_MAX_SIZE=10 # 单文件最大 10MB FILE_IMAGE_EXT=gif,jpg,jpeg,png,bmp,webp FILE_EXT=gif,jpg,jpeg,png,bmp,webp,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv ``` ## 安全设计 | 安全措施 | 说明 | |---------|------| | 认证 + 权限 | `auth.jwt` 组内 + `#[Permission('sys:upload:file')]` | | 上传有效性校验 | `UploadedFile::isValid()` 捕获 PHP 上传错误 | | 文件类型白名单 | 按 `type` 从 `config/file.php` 读取后缀白名单 | | 文件大小限制 | `FILE_MAX_SIZE`(默认 10MB),且受 `php.ini` 双层限制 | | 随机文件名 | `His + bin2hex(random_bytes(8))` 防冲突与路径遍历 | | 移除高危后缀 | `image_ext` 不含 svg(内嵌 JS 的 XSS 风险) | | Nginx 防护 | 仅入口 `index.php` 可执行,上传的 `.php` 一律 404 | | 操作审计 | `#[OperationLog(TYPE_UPLOAD)]` 记录上传行为 | ## 常见问题 ### 问题 1:上传返回 401 **原因:** 上传接口需要认证,但请求头未携带 Token。 **解决:** 确保请求头包含 `Authorization: Bearer `。 ### 问题 2:上传返回"不允许的文件类型" **原因:** 文件扩展名不在白名单中。 **解决:** 检查 `.env` 中 `FILE_EXT` / `FILE_IMAGE_EXT` 配置(改后 `php artisan config:clear`)。 ### 问题 3:上传后图片不显示 **原因:** `FILE_DOMAIN_URL` 配置错误或与前端不同源。 **解决:** 检查 `.env` 中 `FILE_DOMAIN_URL` 是否为前端可访问地址;留空时用相对路径(同源/代理)。 ### 问题 4:大文件上传失败 **原因:** PHP 或 Nginx 限制了上传大小。 **解决:** ```ini ; php.ini(修改后重启) upload_max_filesize = 50M post_max_size = 50M ``` ```nginx # nginx.conf(生产按需调整) client_max_body_size 50M; ``` 同时调大 `.env` 的 `FILE_MAX_SIZE`(业务层限制)。