签入版本
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
# 文件上传完整流程
|
||||
|
||||
本章从配置到使用,完整说明文件上传功能的实现方式,包括后端接口、前端组件、安全机制和最佳实践。
|
||||
|
||||
## 上传架构
|
||||
|
||||
```
|
||||
前端 Vue3 后端 FastAPI 文件系统
|
||||
┌──────────┐ ┌──────────────┐ ┌──────────┐
|
||||
│ Upload │ multipart │ 安全校验 │ 写入 │ uploads/ │
|
||||
│ 组件 │ ─────────> │ ─ 扩展名白名单│ ───────> │ 2026/ │
|
||||
│ │ │ ─ 魔术字节 │ │ 09/ │
|
||||
│ │ │ ─ 大小限制 │ │ 05/ │
|
||||
│ │ │ ─ 路径遍历 │ │ xxx.jpg│
|
||||
└──────────┘ └──────────────┘ └──────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ 返回 fileUrl │
|
||||
│ 前端存入字段 │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|:----|:----|:----|
|
||||
| `UPLOAD_ALLOWED_EXTS` | `.jpg,.jpeg,.png,.gif,...,.zip` | 允许上传的文件扩展名(逗号分隔,带点号) |
|
||||
| `UPLOAD_MAX_SIZE_MB` | `10` | 上传文件大小上限(MB) |
|
||||
| `FASTAPI_FILE_URL` | `http://file.fastapi.elevue` | 文件访问域名 |
|
||||
| `FASTAPI_UPLOAD_DIR` | `uploads` | 上传文件存储目录 |
|
||||
|
||||
## 上传接口
|
||||
|
||||
系统提供统一的文件上传入口,支持图片、文档、压缩包等各类文件:
|
||||
|
||||
### 上传文件
|
||||
|
||||
```
|
||||
POST /api/v1/upload/uploadFile
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer <token>
|
||||
|
||||
file: <文件>
|
||||
```
|
||||
|
||||
支持格式由 `UPLOAD_ALLOWED_EXTS` 配置控制,默认包含:
|
||||
|
||||
```
|
||||
图片:.jpg, .jpeg, .png, .gif, .bmp, .ico
|
||||
文档:.pdf, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .txt
|
||||
音视频:.mp3, .mp4
|
||||
压缩:.zip, .rar, .7z
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "上传成功",
|
||||
"data": {
|
||||
"originalName": "photo.jpg",
|
||||
"fileExtension": "jpg",
|
||||
"fileType": "image/jpeg",
|
||||
"fileSize": 306995,
|
||||
"fileName": "2025030614302512345.jpg",
|
||||
"filePath": "/20250306/2025030614302512345.jpg",
|
||||
"fileUrl": "http://127.0.0.1:8031/api/file/20250306/2025030614302512345.jpg"
|
||||
},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 富文本编辑器集成
|
||||
富文本编辑器(如 TinyMCE、CKEditor、WangEditor 等)的图片上传同样使用此接口。编辑器配置上传地址为 `/api/v1/upload/uploadFile`,上传成功后从 `response.data.fileUrl` 获取图片 URL 即可。
|
||||
:::
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 扩展名白名单
|
||||
|
||||
只允许 `UPLOAD_ALLOWED_EXTS` 中配置的扩展名。配置的扩展名会与内置安全集合取交集,确保不会绕过安全校验。留空时回落到内置智能集合。
|
||||
|
||||
禁止上传的扩展名(硬编码黑名单,无论配置如何一律拒绝):
|
||||
|
||||
```
|
||||
.exe, .bat, .cmd, .sh, .ps1, .php, .asp, .aspx, .jsp, .py, .rb, .js, .html, .htm
|
||||
.svg(无魔数签名,且存在存储型 XSS 风险)
|
||||
.webp(魔数仅 RIFF 前缀,无可靠二进制签名)
|
||||
```
|
||||
|
||||
### 魔术字节校验
|
||||
|
||||
不仅检查扩展名,还读取文件头部魔术字节验证真实类型:
|
||||
|
||||
| 类型 | 魔术字节 |
|
||||
|:----|:----|
|
||||
| JPEG | `FF D8 FF` |
|
||||
| PNG | `89 50 4E 47` |
|
||||
| GIF | `47 49 46 38` |
|
||||
| PDF | `25 50 44 46` |
|
||||
| ZIP | `50 4B 03 04` |
|
||||
|
||||
防止攻击者通过修改扩展名上传恶意文件。
|
||||
|
||||
### 路径遍历防护
|
||||
|
||||
文件名经过安全校验,拒绝包含 `..` 或路径分隔符的文件名:
|
||||
|
||||
```python
|
||||
def _is_safe_filename(filename: str) -> bool:
|
||||
normalized = os.path.normpath(filename)
|
||||
if '..' in normalized.split(os.sep):
|
||||
return False
|
||||
basename = os.path.basename(normalized)
|
||||
return basename == normalized
|
||||
```
|
||||
|
||||
### 大小限制
|
||||
|
||||
上传文件通过流式读取校验大小,超过 `UPLOAD_MAX_SIZE_MB` 限制立即中断,避免整文件读入内存:
|
||||
|
||||
```python
|
||||
async def _read_upload_limited(file, max_size_bytes):
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_size_bytes:
|
||||
return None, R.failed(msg="文件大小超过限制")
|
||||
chunks.append(chunk)
|
||||
return b''.join(chunks), None
|
||||
```
|
||||
|
||||
## 文件存储策略
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
uploads/
|
||||
├── 2026/
|
||||
│ ├── 09/
|
||||
│ │ ├── 05/
|
||||
│ │ │ ├── 2026090514302512345.jpg
|
||||
│ │ │ ├── 2026090515011278901.pdf
|
||||
│ │ │ └── ...
|
||||
│ │ └── 06/
|
||||
│ └── 10/
|
||||
└── 2025/
|
||||
```
|
||||
|
||||
- 按日期分目录存储(`{UPLOAD_DIR}/{YYYYMMDD}/`),避免单目录文件过多
|
||||
- 文件名使用 `年月日时分秒 + 5位随机数` 重命名,避免中文文件名和冲突
|
||||
- 保留原始扩展名
|
||||
|
||||
### 文件访问 URL
|
||||
|
||||
上传成功后返回的 `fileUrl` 是拼接了 `FASTAPI_FILE_URL` 前缀的完整 URL:
|
||||
|
||||
```
|
||||
http://127.0.0.1:8031/api/file/20250306/2025030614302512345.jpg
|
||||
```
|
||||
|
||||
Service 层的 `BaseService._enrich_file_urls()` 会自动为文件字段补此前缀。
|
||||
|
||||
## 前端集成
|
||||
|
||||
### 文件上传组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-upload
|
||||
:action="uploadUrl"
|
||||
:headers="headers"
|
||||
:on-success="handleSuccess"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<el-button type="primary">上传文件</el-button>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const uploadUrl = '/api/v1/upload/uploadFile';
|
||||
const userStore = useUserStore();
|
||||
const headers = {
|
||||
Authorization: `Bearer ${userStore.token}`,
|
||||
};
|
||||
|
||||
function beforeUpload(file) {
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isLt10M) {
|
||||
ElMessage.error('文件大小不能超过 10MB');
|
||||
}
|
||||
return isLt10M;
|
||||
}
|
||||
|
||||
function handleSuccess(response) {
|
||||
if (response.code === 0) {
|
||||
// 保存 fileUrl 到业务字段
|
||||
form.value.cover = response.data.fileUrl;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 图片预览
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-image
|
||||
v-if="form.cover"
|
||||
:src="getFileUrl(form.cover)"
|
||||
:preview-src-list="[getFileUrl(form.cover)]"
|
||||
fit="cover"
|
||||
style="width: 100px; height: 100px"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function getFileUrl(path) {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
return import.meta.env.VITE_FILE_URL + path;
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 业务模块中的文件字段
|
||||
|
||||
在 Service 中声明 `file_fields`,基类自动处理文件迁移和 URL 补全:
|
||||
|
||||
```python
|
||||
class ArticleService(BaseService[Article]):
|
||||
# 文件字段:add/update 时自动迁移临时文件,列表/详情自动补全 URL
|
||||
file_fields = ('cover', 'attachment')
|
||||
```
|
||||
|
||||
### 自动处理逻辑
|
||||
|
||||
1. **新增/更新时**:检测文件字段值是否为临时路径,如果是则迁移到正式目录
|
||||
2. **列表/详情时**:自动为文件字段补全 `FASTAPI_FILE_URL` 前缀
|
||||
|
||||
## 富文本中的图片
|
||||
|
||||
在 Service 中声明 `rich_text_fields`,基类自动处理:
|
||||
|
||||
```python
|
||||
class ArticleService(BaseService[Article]):
|
||||
# 富文本字段:自动迁移嵌入图片 + XSS 清洗
|
||||
rich_text_fields = ('content',)
|
||||
```
|
||||
|
||||
### 自动处理逻辑
|
||||
|
||||
1. **新增/更新时**:解析富文本中的 `<img src="...">` 标签
|
||||
2. 将临时路径的图片迁移到正式目录
|
||||
3. 替换富文本中的图片路径
|
||||
4. 进行 XSS 清洗(使用 bleach 库)
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 上传报 413 Request Entity Too Large
|
||||
|
||||
**原因**:文件大小超过 `UPLOAD_MAX_SIZE_MB` 限制。
|
||||
|
||||
**解决**:修改 `.env` 中的 `UPLOAD_MAX_SIZE_MB` 值。
|
||||
|
||||
### 上传报 403 Forbidden
|
||||
|
||||
**原因**:文件扩展名不在白名单中。
|
||||
|
||||
**解决**:检查 `UPLOAD_ALLOWED_EXTS` 配置,添加所需扩展名。
|
||||
|
||||
### 图片上传成功但不显示
|
||||
|
||||
**原因**:`FASTAPI_FILE_URL` 配置错误或前端未拼接域名。
|
||||
|
||||
**解决**:检查 `.env` 中的 `FASTAPI_FILE_URL` 配置,确保前端正确拼接。
|
||||
|
||||
### 上传报演示环境无权限
|
||||
|
||||
**原因**:演示模式下写操作被拦截。
|
||||
|
||||
**解决**:将 `.env` 中的 `FASTAPI_DEMO` 设为 `False`。
|
||||
|
||||
## 总结
|
||||
|
||||
文件上传功能通过扩展名白名单 + 魔术字节校验 + 路径遍历防护 + 大小限制四层安全机制保障安全性。文件按日期分目录存储,使用时间戳 + 随机数重命名。统一的 `uploadFile` 接口支持图片和各类文件上传。Service 层通过 `file_fields` 和 `rich_text_fields` 声明即可自动处理文件迁移和 URL 补全。
|
||||
Reference in New Issue
Block a user