308 lines
9.5 KiB
Markdown
308 lines
9.5 KiB
Markdown
# 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 <token>`,且角色需分配该权限节点。
|
||
:::
|
||
|
||
## 请求参数
|
||
|
||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||
|------|------|------|--------|------|
|
||
| `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 <token>` |
|
||
| `无访问权限: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
|
||
<template>
|
||
<!-- 上传文件(默认 type=file) -->
|
||
<el-upload
|
||
:action="'/api/upload/uploadFile'"
|
||
:headers="{ Authorization: `Bearer ${token}` }"
|
||
:on-success="handleUploadSuccess"
|
||
:before-upload="beforeUpload"
|
||
>
|
||
<el-button type="primary">上传文件</el-button>
|
||
</el-upload>
|
||
|
||
<!-- 上传图片(指定 type=image) -->
|
||
<el-upload
|
||
:action="'/api/upload/uploadFile?type=image'"
|
||
:data="{ type: 'image' }"
|
||
:headers="{ Authorization: `Bearer ${token}` }"
|
||
:on-success="handleImageSuccess"
|
||
accept="image/*"
|
||
>
|
||
<el-button type="primary">上传图片</el-button>
|
||
</el-upload>
|
||
</template>
|
||
|
||
<script setup>
|
||
const token = localStorage.getItem('access_token');
|
||
|
||
const beforeUpload = (file) => {
|
||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||
if (!isLt10M) {
|
||
ElMessage.error('文件大小不能超过 10MB');
|
||
}
|
||
return isLt10M;
|
||
};
|
||
|
||
const handleUploadSuccess = (response) => {
|
||
if (response.code === 0) {
|
||
form.value.file = response.data.filePath; // 建议存相对路径
|
||
}
|
||
};
|
||
|
||
const handleImageSuccess = (response) => {
|
||
if (response.code === 0) {
|
||
form.value.avatar = response.data.filePath;
|
||
}
|
||
};
|
||
</script>
|
||
```
|
||
|
||
### 手动上传
|
||
|
||
```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
|
||
<template>
|
||
<div class="avatar-upload">
|
||
<el-avatar :src="form.avatar" :size="80" />
|
||
<el-upload
|
||
:action="'/api/upload/uploadFile?type=image'"
|
||
:headers="{ Authorization: `Bearer ${token}` }"
|
||
:show-file-list="false"
|
||
:on-success="handleAvatarSuccess"
|
||
accept="image/*"
|
||
>
|
||
<el-button size="small">更换头像</el-button>
|
||
</el-upload>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
const form = ref({ avatar: '' });
|
||
|
||
const handleAvatarSuccess = (response) => {
|
||
if (response.code === 0) {
|
||
form.value.avatar = response.data.filePath;
|
||
}
|
||
};
|
||
</script>
|
||
```
|
||
|
||
## 上传后处理流程
|
||
|
||
```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 <token>`。
|
||
|
||
### 问题 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`(业务层限制)。
|