# 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` |
## 前端集成
### Element Plus 上传组件
```vue
上传文件
```
### 提交表单
```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;
}
```