Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_NaiveVue/zh/tools/upload.md
T
2026-10-08 17:50:29 +08:00

319 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```
:::