签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
+183 -329
View File
@@ -1,7 +1,7 @@
# 7.1 文件上传全流程
:::tip 概述
文件处理分为两个阶段:上传到临时目录(upload_file)→ 入库时迁移到正式目录(save_file)。这种"两阶段"设计将文件上传与业务逻辑解耦,前端先上传获取临时 URL,提交表单时 Logic 层自动将临时文件迁移到正式目录。
文件处理分为三个阶段:**上传**(`uploadFile` 接口校验后存入 `temp/` 临时目录)→ **入库**(Service 的 `fileFields` 经 `save_file()` 归一化:去域名、迁移临时文件到正式目录)→ **读取**(`get_file_url()` 补全域名)。上传与业务逻辑解耦:前端先上传拿到 URL/路径,提交表单时框架自动完成字段处理。
:::
## 完整流程图
@@ -13,28 +13,32 @@
前端 后端
│ │
│── POST /api/upload ────────►│
│── POST /api/upload/ ───────►│ (auth.jwt 组内,需 sys:upload:file 权限)
│ uploadFile │
│ Content-Type: │
│ multipart/form-data │
│ file: <二进制> │
│ type: image | file │
│ │
│ ┌──────┴──────┐
│ │upload_file()│
│ │ │
│ │ 1. 检查上传状态
│ │ 2. finfo 检测真实 MIME
│ │ 3. 按类型校验扩展名
│ │ 4. 校验文件大小
│ │ 5. 图片额外 getimagesize 校验
│ │ 6. 保存到 temp/日期/随机文件名
│ └──────┬──────┘
│ ┌──────┴───────────┐
│ │ 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 }│
│ fileExtension, fileType, │
│ fileName } │
│ │
│ 前端显示预览 │
│ 或暂存 URL │
│ 前端暂存 URL/路径 │
┌─────────────────────────────────────────────────────────────────────┐
│ 第二阶段:入库 │
@@ -44,39 +48,24 @@
│ │
│── POST /api/example/add ──►│
│ { name: "测试", │
│ avatar: "https:// │
│ cdn.example.com/ │
│ temp/20260920/ │
│ abc123.jpg" } │
│ avatar: "temp/ │
│ 20261002/123456_ab.jpg" }│
│ │
│ ┌──────┴──────┐
│ │ BaseLogic │
│ │ ::add() │
│ └──────┬──────┘
│ ┌──────┴──────────────┐
│ │ ExampleService::add │
│ │ (use HasFileProcessing)│
│ └──────┬──────────────┘
│ │
│ ┌──────┴──────┐
│ │ processFile │
│ │ FieldsOn │
│ │ Save() │
│ └──────┬──────┘
│ ┌──────┴──────────────┐
│ │ processFileFieldsOnSave │
│ │ → save_file() │
│ │ 1. 去掉域名得相对路径 │
│ │ 2. temp/ 开头 → 迁移 │
│ │ 3. 外部URL → 下载保存 │
│ │ 4. 正式路径 → 直接返回│
│ └──────┬──────────────┘
│ │
│ ┌──────┴──────┐
│ │ save_file() │
│ │ │
│ │ 1. 去掉域名得到相对路径
│ │ 2. 包含 temp/ → 移动到正式目录
│ │ 3. 外部URL → 下载保存
│ └──────┬──────┘
│ │
│ ┌──────┴──────┐
│ │ 数据库存储 │
│ │ /uploads/ │
│ │ example/ │
│ │ 20260920/ │
│ │ abc123.jpg │
│ └──────┬──────┘
│ │
│◄── { code:0, data:{id:1} }─│
│◄── { code:0, data:{id:1} }──│
┌─────────────────────────────────────────────────────────────────────┐
│ 第三阶段:读取 │
@@ -86,180 +75,130 @@
│ │
│── GET /api/example/detail/1►│
│ │
│ ┌──────┴──────┐
│ │processFile │
│ │FieldsOnRead │
│ │() │
│ └──────┬──────┘
│ │
│ ┌──────┴──────┐
│ │get_file_url │
│ │() │
│ │拼接域名 │
│ └──────┬──────┘
│ ┌──────┴───────────────┐
│ │ processFileFieldsOnRead │
│ │ → get_file_url() │
│ │ 拼接 FILE_DOMAIN_URL │
│ └──────┬───────────────┘
│ │
│◄── { avatar: "https://──────│
│ cdn.example.com/uploads/ │
│ example/20260920/ │
│ abc123.jpg" } │
│ example/20261002/..." } │
```
## upload_file 函数
:::tip 临时目录中转
上传接口把文件写入 `temp/` 临时目录,**不直接落正式目录**;提交表单时 `save_file()` 依据 `temp/` 前缀把文件迁移到 Service 的 `fileSaveDir` 子目录(`user/`、`article/`、`example/` …),正式目录与业务模块一一对应。上传后未被表单引用的文件停留在 `temp/`,可按周期清理。
:::
```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
### 上传文件
```bash
POST /api/upload/uploadFile
Content-Type: multipart/form-data
Authorization: Bearer <token>
file: <二进制文件>
```
**参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `file` | File | 是 | 上传的文件 |
| `type` | string | 否 | `image`(校验图片后缀白名单)/ `file`(校验全量文件后缀白名单,默认) |
**响应(来自项目实际代码):**
```json
{
// ... 完整实现见 app/common.php
"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"
}
}
```
### 参数说明
**响应字段说明:**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `$formName` | string | `'file'` | 表单字段名 |
| `$type` | string | `'file'` | 文件类型:`image`(仅图片)/ `file`(所有文件) |
| 字段 | 说明 | 示例 |
|------|------|------|
| `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/...` |
### 返回值
**成功:**
```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"
{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
```
## get_file_url 函数
- `type` 只影响后缀白名单,存储目录始终是 `temp/`
- 随机文件名避免重名与**可预测性攻击**(时间戳 + `random_bytes(8)`)
- 目录不存在时自动 `mkdir(0755, true)` 创建
```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"
上传文件安全检查(UploadController::uploadFile):
│
▼ get_file_url()
▼ 1. 权限校验(auth.jwt + #[Permission('sys:upload:file')])
│
▼ 拼接 domain_url
输出: "https://cdn.example.com/uploads/example/20260920/abc123.jpg"
▼ 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
```
## 在 Logic 中配置
:::warning svg 已移除
`FILE_IMAGE_EXT` 默认白名单**不含 svg**——svg 可内嵌 JavaScript,上传后直接作为静态资源访问存在 XSS 风险。如业务必须使用,请做内容清洗或以 attachment 方式返回。
:::
## 入库处理(HasFileProcessing)
Service 配置文件字段后,保存/读取自动处理:
```php
class ExampleLogic extends BaseLogic
// app/Services/ExampleService.php(形态示例)
class ExampleService extends BaseService
{
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['avatar', 'cover'];
use HasFileProcessing, /* 其他 Trait */;
/**
* 文件保存的子目录
*
* @var string
*/
// 需要自动处理的文件字段
protected array $fileFields = ['avatar'];
// 多文件字段(逗号分隔的多个 URL)
protected array $multiFileFields = ['images'];
// 文件保存子目录(未指定时按模型类名小写推导)
protected string $fileSaveDir = 'example';
}
```
@@ -268,99 +207,44 @@ class ExampleLogic extends BaseLogic
| 操作 | 自动行为 |
|------|---------|
| `add()` | `processFileFieldsOnSave()` → 调用 `save_file()` 迁移临时文件 |
| `update()` | `processFileFieldsOnSave()` → 调用 `save_file()` 迁移临时文件 |
| `detail()` | `processFileFieldsOnRead()` → 调用 `get_file_url()` 补全域名 |
| `pageList()` | `processFileFieldsOnReadList()` → 调用 `get_file_url()` 补全域名 |
| `allList()` | `processFileFieldsOnReadList()` → 调用 `get_file_url()` 补全域名 |
| `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
class UserLogic extends BaseTenantLogic
{
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['avatar'];
/**
* 保存文件(归一化入库路径)
*
* 处理逻辑:
* 1. 带域名 URL → 去掉域名得到相对路径
* 2. 包含 temp/ → 移动到正式目录(fileSaveDir)
* 3. 外部 URL → save_remote_file() 下载保存
* 4. 已在正式目录 → 直接返回
*/
function save_file(string $fileUrl, string $saveDir = ''): string|false
/**
* 文件保存的子目录
*
* @var string
*/
protected string $fileSaveDir = 'user/avatar';
}
/**
* 获取文件完整 URL
* - 空值 → '';已是 http(s) 开头 → 原样返回
* - 相对路径 → 拼接 config/file.php 的 domain_url
*/
function get_file_url(string $path): string
class ArticleLogic extends BaseTenantLogic
{
/**
* 需要自动处理的文件上传字段
*
* @var array
*/
protected array $fileFields = ['cover', 'attachment'];
/** 生成存储路径:{saveDir}/Ymd/random.扩展名 */
function create_file_path(string $saveDir = '', string $ext = ''): string
/**
* 文件保存的子目录
*
* @var string
*/
protected string $fileSaveDir = 'article';
}
/** 下载外部 URL 并保存(配合 is_internal_ip 做内网地址拦截,防 SSRF) */
function save_remote_file(string $url, string $saveDir = ''): string|false
```
## 前端上传接口
### 上传文件
```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 上传组件
@@ -369,11 +253,12 @@ file: <二进制图片>
<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-button type="primary">上传图片</el-button>
</el-upload>
</template>
@@ -389,9 +274,8 @@ const beforeUpload = (file) => {
};
const handleUploadSuccess = (response) => {
// response.data.fileUrl 是完整 URL
// 提交表单时使用此 URL
form.value.avatar = response.data.fileUrl;
// 提交表单时保存相对路径(filePath)最稳妥
form.value.avatar = response.data.filePath;
};
</script>
```
@@ -399,66 +283,36 @@ const handleUploadSuccess = (response) => {
### 提交表单
```typescript
// 前端提交时,avatar 字段的值是上传接口返回的 fileUrl
const formData = {
name: '测试',
avatar: 'https://cdn.example.com/temp/20260920/abc123.jpg',
avatar: 'temp/20261002/123456_a1b2c3d4e5f6.jpg',
};
// 后端 Logic 层自动:
// 1. 从 avatar 字段检测到临时文件 URL
// 2. 调用 save_file() 迁移到正式目录
// 3. 去掉域名,只存相对路径入库
// 后端 Service 层自动:
// 1. 识别 avatar 为 fileFields 字段
// 2. save_file() 归一化(去域名 / 迁移 temp 到 fileSaveDir / 下载外部 URL)
// 3. 正式目录相对路径入库(如 example/20261002/123456_a1b2c3d4e5f6.jpg)
```
## 配置项
```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
# .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
```
```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'),
];
```
详见 [7.2 上传配置详解](/zh/tools/upload-config.html)。
## 安全检查链
:::warning 同时调整 PHP 限制
业务层 `FILE_MAX_SIZE=10` 生效的前提是 PHP 层放行,确认 `php.ini`:
```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;
}
```ini
upload_max_filesize = 50M
post_max_size = 50M
max_execution_time = 300
```
:::