465 lines
14 KiB
Markdown
465 lines
14 KiB
Markdown
# 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` |
|
||
|
||
## 前端集成
|
||
|
||
### ElementPlus 上传组件
|
||
|
||
```vue
|
||
<template>
|
||
<el-upload
|
||
:action="'/api/upload/uploadFile'"
|
||
:headers="{ Authorization: `Bearer ${token}` }"
|
||
:on-success="handleUploadSuccess"
|
||
:before-upload="beforeUpload"
|
||
>
|
||
<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) => {
|
||
// response.data.fileUrl 是完整 URL
|
||
// 提交表单时使用此 URL
|
||
form.value.avatar = response.data.fileUrl;
|
||
};
|
||
</script>
|
||
```
|
||
|
||
### 提交表单
|
||
|
||
```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;
|
||
}
|
||
```
|