13 KiB
13 KiB
7.1 文件上传全流程
:::tip 概述
文件处理分为三个阶段:上传(uploadFile 接口校验后存入 temp/ 临时目录)→ 入库(Service 的 fileFields 经 save_file() 归一化:去域名、迁移临时文件到正式目录)→ 读取(get_file_url() 补全域名)。上传与业务逻辑解耦:前端先上传拿到 URL/路径,提交表单时框架自动完成字段处理。
:::
完整流程图
┌─────────────────────────────────────────────────────────────────────┐
│ 第一阶段:上传 │
└─────────────────────────────────────────────────────────────────────┘
前端 后端
│ │
│── 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/,可按周期清理。
:::
上传接口
上传文件
POST /api/upload/uploadFile
Content-Type: multipart/form-data
Authorization: Bearer <token>
file: <二进制文件>
参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
file |
File | 是 | 上传的文件 |
type |
string | 否 | image(校验图片后缀白名单)/ file(校验全量文件后缀白名单,默认) |
响应(来自项目实际代码):
{
"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/... |
存储规则
{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)创建
安全检查链
上传文件安全检查(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 配置文件字段后,保存/读取自动处理:
// 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 横切机制)
核心辅助函数(app/Helpers/helpers.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 上传组件
<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>
提交表单
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)
配置项
# .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 上传配置详解。
:::warning 同时调整 PHP 限制
业务层 FILE_MAX_SIZE=10 生效的前提是 PHP 层放行,确认 php.ini:
upload_max_filesize = 50M
post_max_size = 50M
max_execution_time = 300
:::