299 lines
8.7 KiB
Markdown
299 lines
8.7 KiB
Markdown
# 文件上传完整流程
|
||
|
||
本章从配置到使用,完整说明文件上传功能的实现方式,包括后端接口、前端组件、安全机制和最佳实践。
|
||
|
||
## 上传架构
|
||
|
||
```
|
||
前端 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.naivevue` | 文件访问域名 |
|
||
| `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>
|
||
<n-upload
|
||
:action="uploadUrl"
|
||
:headers="headers"
|
||
:on-success="handleSuccess"
|
||
:before-upload="beforeUpload"
|
||
>
|
||
<n-button type="primary">上传文件</n-button>
|
||
</n-upload>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref } from 'vue';
|
||
import { useMessage } from 'naive-ui';
|
||
import { useUserStore } from '@/store/modules/user';
|
||
|
||
const message = useMessage();
|
||
const uploadUrl = '/api/v1/upload/uploadFile';
|
||
const userStore = useUserStore();
|
||
const headers = {
|
||
Authorization: `Bearer ${userStore.token}`,
|
||
};
|
||
|
||
function beforeUpload(file) {
|
||
const isLt10M = file.file.size / 1024 / 1024 < 10;
|
||
if (!isLt10M) {
|
||
message.error('文件大小不能超过 10MB');
|
||
}
|
||
return isLt10M;
|
||
}
|
||
|
||
function handleSuccess(response) {
|
||
if (response.code === 0) {
|
||
// 保存 fileUrl 到业务字段
|
||
form.value.cover = response.data.fileUrl;
|
||
}
|
||
}
|
||
</script>
|
||
```
|
||
|
||
### 图片预览
|
||
|
||
```vue
|
||
<template>
|
||
<n-image
|
||
v-if="form.cover"
|
||
:src="getFileUrl(form.cover)"
|
||
:preview-src="getFileUrl(form.cover)"
|
||
object-fit="cover"
|
||
width="100"
|
||
height="100"
|
||
/>
|
||
</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 补全。
|