319 lines
13 KiB
Markdown
319 lines
13 KiB
Markdown
# 7.1 文件上传全流程
|
||
|
||
:::tip 概述
|
||
文件处理分为三个阶段:**上传**(`uploadFile` 接口校验后存入 `temp/` 临时目录)→ **入库**(Service 的 `fileFields` 经 `save_file()` 归一化:去域名、迁移临时文件到正式目录)→ **读取**(`get_file_url()` 补全域名)。上传与业务逻辑解耦:前端先上传拿到 URL/路径,提交表单时框架自动完成字段处理。
|
||
:::
|
||
|
||
## 完整流程图
|
||
|
||
```text
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ 第一阶段:上传 │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
|
||
前端 后端
|
||
│ │
|
||
│── POST /api/upload/ ───────►│ (auth.jwt 组内,需 sys:upload:file 权限)
|
||
│ uploadFile │
|
||
│ Content-Type: │
|
||
│ multipart/form-data │
|
||
│ file: <二进制> │
|
||
│ type: image | file │
|
||
│ │
|
||
│ ┌──────┴───────────┐
|
||
│ │ UploadController │
|
||
│ │ ::uploadFile() │
|
||
│ └──────┬───────────┘
|
||
│ │
|
||
│ 1. 校验 type 合法性(image / file)
|
||
│ 2. 文件存在且上传有效(isValid)
|
||
│ 3. 按类型读取扩展名白名单(config/file.php)
|
||
│ 4. 校验文件大小(file.max_size,默认 10MB)
|
||
│ 5. 生成路径:temp/Ymd/His_随机串.扩展名
|
||
│ 6. 采集元数据:原始名 / MIME(finfo)/ 大小
|
||
│ 7. mkdir + move 保存
|
||
│ │
|
||
│◄── { fileUrl, filePath, ────│
|
||
│ originalName, fileSize, │
|
||
│ fileExtension, fileType, │
|
||
│ fileName } │
|
||
│ │
|
||
│ 前端暂存 URL/路径 │
|
||
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ 第二阶段:入库 │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
|
||
前端 后端
|
||
│ │
|
||
│── POST /api/example/add ──►│
|
||
│ { name: "测试", │
|
||
│ avatar: "temp/ │
|
||
│ 20261002/123456_ab.jpg" }│
|
||
│ │
|
||
│ ┌──────┴──────────────┐
|
||
│ │ ExampleService::add │
|
||
│ │ (use HasFileProcessing)│
|
||
│ └──────┬──────────────┘
|
||
│ │
|
||
│ ┌──────┴──────────────┐
|
||
│ │ processFileFieldsOnSave │
|
||
│ │ → save_file() │
|
||
│ │ 1. 去掉域名得相对路径 │
|
||
│ │ 2. temp/ 开头 → 迁移 │
|
||
│ │ 3. 外部URL → 下载保存 │
|
||
│ │ 4. 正式路径 → 直接返回│
|
||
│ └──────┬──────────────┘
|
||
│ │
|
||
│◄── { code:0, data:{id:1} }──│
|
||
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ 第三阶段:读取 │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
|
||
前端 后端
|
||
│ │
|
||
│── GET /api/example/detail/1►│
|
||
│ │
|
||
│ ┌──────┴───────────────┐
|
||
│ │ processFileFieldsOnRead │
|
||
│ │ → get_file_url() │
|
||
│ │ 拼接 FILE_DOMAIN_URL │
|
||
│ └──────┬───────────────┘
|
||
│ │
|
||
│◄── { avatar: "https://──────│
|
||
│ cdn.example.com/uploads/ │
|
||
│ example/20261002/..." } │
|
||
```
|
||
|
||
:::tip 临时目录中转
|
||
上传接口把文件写入 `temp/` 临时目录,**不直接落正式目录**;提交表单时 `save_file()` 依据 `temp/` 前缀把文件迁移到 Service 的 `fileSaveDir` 子目录(`user/`、`article/`、`example/` …),正式目录与业务模块一一对应。上传后未被表单引用的文件停留在 `temp/`,可按周期清理。
|
||
:::
|
||
|
||
## 上传接口
|
||
|
||
### 上传文件
|
||
|
||
```bash
|
||
POST /api/upload/uploadFile
|
||
Content-Type: multipart/form-data
|
||
Authorization: Bearer <token>
|
||
|
||
file: <二进制文件>
|
||
```
|
||
|
||
**参数:**
|
||
|
||
| 参数 | 类型 | 必填 | 说明 |
|
||
|------|------|------|------|
|
||
| `file` | File | 是 | 上传的文件 |
|
||
| `type` | string | 否 | `image`(校验图片后缀白名单)/ `file`(校验全量文件后缀白名单,默认) |
|
||
|
||
**响应(来自项目实际代码):**
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"ok": true,
|
||
"msg": "上传成功",
|
||
"data": {
|
||
"originalName": "photo.jpg",
|
||
"fileExtension": "jpg",
|
||
"fileType": "image/jpeg",
|
||
"fileSize": 102400,
|
||
"fileName": "123456_a1b2c3d4e5f6.jpg",
|
||
"filePath": "temp/20261002/123456_a1b2c3d4e5f6.jpg",
|
||
"fileUrl": "http://cdn.example.com/temp/20261002/123456_a1b2c3d4e5f6.jpg"
|
||
}
|
||
}
|
||
```
|
||
|
||
**响应字段说明:**
|
||
|
||
| 字段 | 说明 | 示例 |
|
||
|------|------|------|
|
||
| `originalName` | 原始文件名 | `photo.jpg` |
|
||
| `fileExtension` | 扩展名 | `jpg` |
|
||
| `fileType` | MIME 类型(`getMimeType()`) | `image/jpeg` |
|
||
| `fileSize` | 文件大小(字节) | `102400` |
|
||
| `fileName` | 服务器文件名(时间+随机串) | `123456_a1b2c3d4e5f6.jpg` |
|
||
| `filePath` | 相对存储路径(**提交表单建议用此字段**) | `temp/20261002/...` |
|
||
| `fileUrl` | 可访问 URL(`FILE_DOMAIN_URL` 为空时等于相对路径) | `http://cdn.example.com/...` |
|
||
|
||
### 存储规则
|
||
|
||
```text
|
||
{upload_dir}/ ← config/file.php → upload_dir(默认 uploads)
|
||
└── temp/ ← 所有类型统一存入(config/file.php → temp_dir)
|
||
└── 20261002/ ← Ymd 分目录
|
||
└── 123456_a1b2c3d4e5f6.jpg ← His_ + 随机8字节hex + 扩展名
|
||
|
||
{upload_dir}/ ← 正式目录,落库时由 save_file() 迁移生成
|
||
├── user/ article/ example/ … ← Service 的 fileSaveDir
|
||
└── 20261002/
|
||
└── 143052_a1b2c3d4e5f6.jpg
|
||
```
|
||
|
||
- `type` 只影响后缀白名单,存储目录始终是 `temp/`
|
||
- 随机文件名避免重名与**可预测性攻击**(时间戳 + `random_bytes(8)`)
|
||
- 目录不存在时自动 `mkdir(0755, true)` 创建
|
||
|
||
### 安全检查链
|
||
|
||
```text
|
||
上传文件安全检查(UploadController::uploadFile):
|
||
│
|
||
▼ 1. 权限校验(auth.jwt + #[Permission('sys:upload:file')])
|
||
│
|
||
▼ 2. type 参数合法性(仅 image / file)
|
||
│
|
||
▼ 3. 文件存在且上传有效(UploadedFile::isValid,捕获 PHP 上传错误)
|
||
│
|
||
▼ 4. 扩展名白名单(image → file.image_ext;file → file.file_ext)
|
||
│
|
||
▼ 5. 文件大小限制(config/file.php → max_size,默认 10MB)
|
||
│
|
||
▼ 6. 服务端生成随机路径(防路径遍历)
|
||
│
|
||
▼ 7. Nginx 仅允许入口 index.php 执行 —— 上传的 .php 一律 404
|
||
```
|
||
|
||
:::warning svg 已移除
|
||
`FILE_IMAGE_EXT` 默认白名单**不含 svg**——svg 可内嵌 JavaScript,上传后直接作为静态资源访问存在 XSS 风险。如业务必须使用,请做内容清洗或以 attachment 方式返回。
|
||
:::
|
||
|
||
## 入库处理(HasFileProcessing)
|
||
|
||
Service 配置文件字段后,保存/读取自动处理:
|
||
|
||
```php
|
||
// app/Services/ExampleService.php(形态示例)
|
||
class ExampleService extends BaseService
|
||
{
|
||
use HasFileProcessing, /* 其他 Trait */;
|
||
|
||
// 需要自动处理的文件字段
|
||
protected array $fileFields = ['avatar'];
|
||
|
||
// 多文件字段(逗号分隔的多个 URL)
|
||
protected array $multiFileFields = ['images'];
|
||
|
||
// 文件保存子目录(未指定时按模型类名小写推导)
|
||
protected string $fileSaveDir = 'example';
|
||
}
|
||
```
|
||
|
||
### 自动行为
|
||
|
||
| 操作 | 自动行为 |
|
||
|------|---------|
|
||
| `add()` / `update()` | `processFileFieldsOnSave()` → `save_file()` 归一化(去域名、迁移 temp、下载外部 URL) |
|
||
| `detail()` | `processFileFieldsOnRead()` → `get_file_url()` 补全域名 |
|
||
| `pageList()` / `allList()` | `processFileFieldsOnReadList()` 逐条补全域名 |
|
||
|
||
**处理细节(来自 `HasFileProcessing` 源码):**
|
||
|
||
- 空值跳过;`save_file()` 返回 `false` 时**保留原值**(避免误清空)
|
||
- 多文件按逗号拆分逐个处理,单个失败保留原 URL
|
||
- 未 `use HasFileProcessing` 时占位方法直接透传(见 [4.7 横切机制](/zh/architecture/cross-cutting.html))
|
||
|
||
## 核心辅助函数(app/Helpers/helpers.php)
|
||
|
||
```php
|
||
/**
|
||
* 保存文件(归一化入库路径)
|
||
*
|
||
* 处理逻辑:
|
||
* 1. 带域名 URL → 去掉域名得到相对路径
|
||
* 2. 包含 temp/ → 移动到正式目录(fileSaveDir)
|
||
* 3. 外部 URL → save_remote_file() 下载保存
|
||
* 4. 已在正式目录 → 直接返回
|
||
*/
|
||
function save_file(string $fileUrl, string $saveDir = ''): string|false
|
||
|
||
/**
|
||
* 获取文件完整 URL
|
||
* - 空值 → '';已是 http(s) 开头 → 原样返回
|
||
* - 相对路径 → 拼接 config/file.php 的 domain_url
|
||
*/
|
||
function get_file_url(string $path): string
|
||
|
||
/** 生成存储路径:{saveDir}/Ymd/random.扩展名 */
|
||
function create_file_path(string $saveDir = '', string $ext = ''): string
|
||
|
||
/** 下载外部 URL 并保存(配合 is_internal_ip 做内网地址拦截,防 SSRF) */
|
||
function save_remote_file(string $url, string $saveDir = ''): string|false
|
||
```
|
||
|
||
## 前端集成
|
||
|
||
### Element Plus 上传组件
|
||
|
||
```vue
|
||
<template>
|
||
<el-upload
|
||
:action="'/api/upload/uploadFile'"
|
||
:data="{ type: 'image' }"
|
||
: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) => {
|
||
// 提交表单时保存相对路径(filePath)最稳妥
|
||
form.value.avatar = response.data.filePath;
|
||
};
|
||
</script>
|
||
```
|
||
|
||
### 提交表单
|
||
|
||
```typescript
|
||
const formData = {
|
||
name: '测试',
|
||
avatar: 'temp/20261002/123456_a1b2c3d4e5f6.jpg',
|
||
};
|
||
|
||
// 后端 Service 层自动:
|
||
// 1. 识别 avatar 为 fileFields 字段
|
||
// 2. save_file() 归一化(去域名 / 迁移 temp 到 fileSaveDir / 下载外部 URL)
|
||
// 3. 正式目录相对路径入库(如 example/20261002/123456_a1b2c3d4e5f6.jpg)
|
||
```
|
||
|
||
## 配置项
|
||
|
||
```ini
|
||
# .env(config/file.php 读取)
|
||
FILE_UPLOAD_DIR=uploads # 上传根目录(相对 public/)
|
||
FILE_DOMAIN_URL= # 文件访问域名(空则返回相对路径)
|
||
FILE_MAX_SIZE=10 # 单文件大小上限(MB)
|
||
FILE_IMAGE_EXT=gif,jpg,jpeg,png,bmp,webp # 图片后缀白名单(svg 已移除)
|
||
FILE_EXT=gif,jpg,jpeg,png,bmp,webp,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv
|
||
```
|
||
|
||
详见 [7.2 上传配置详解](/zh/tools/upload-config.html)。
|
||
|
||
:::warning 同时调整 PHP 限制
|
||
业务层 `FILE_MAX_SIZE=10` 生效的前提是 PHP 层放行,确认 `php.ini`:
|
||
|
||
```ini
|
||
upload_max_filesize = 50M
|
||
post_max_size = 50M
|
||
max_execution_time = 300
|
||
```
|
||
:::
|