Become a sponsor

概述
文件处理分为两个阶段:上传到临时目录(upload_file)→ 入库时迁移到正式目录(save_file)。这种"两阶段"设计将文件上传与业务逻辑解耦,前端先上传获取临时 URL,提交表单时 Logic 层自动将临时文件迁移到正式目录。
┌─────────────────────────────────────────────────────────────────────┐
│ 第一阶段:上传 │
└─────────────────────────────────────────────────────────────────────┘
前端 后端
│ │
│── 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" } │// 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(所有文件) |
成功:
[
'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 类型
]失败:
['error' => '错误信息']| 错误信息 | 说明 |
|---|---|
| 未选择文件 | 表单字段为空 |
| 文件上传失败 | PHP 上传错误(代码 1-7) |
| 不允许的文件类型 | 扩展名不在白名单中 |
| 文件大小超出限制 | 超过 config/file.php 中的 max_size |
| 图片文件不合法 | 图片类型校验失败(getimagesize 验证) |
/**
* 保存文件(临时→正式)
*
* 处理逻辑:
* 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. 已在正式目录 → 直接返回
}输入 URL: "https://cdn.example.com/temp/20260920/abc123.jpg"
│
▼ 去掉域名
相对路径: "temp/20260920/abc123.jpg"
│
▼ 包含 temp/ → 需要迁移
│
▼ 移动到正式目录
目标路径: "/uploads/example/20260920/abc123.jpg"
│
▼ 返回入库路径
输出: "/uploads/example/20260920/abc123.jpg"/**
* 获取文件完整 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
}数据库值: "/uploads/example/20260920/abc123.jpg"
│
▼ get_file_url()
│
▼ 拼接 domain_url
输出: "https://cdn.example.com/uploads/example/20260920/abc123.jpg"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() 补全域名 |
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';
}POST /api/upload/uploadFile
Content-Type: multipart/form-data
file: <二进制文件>响应:
{
"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"
}
}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 |
<template>
<el-upload
:action="'/api/upload/uploadFile'"
: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) => {
// response.data.fileUrl 是完整 URL
// 提交表单时使用此 URL
form.value.avatar = response.data.fileUrl;
};
</script>// 前端提交时,avatar 字段的值是上传接口返回的 fileUrl
const formData = {
name: '测试',
avatar: 'https://cdn.example.com/temp/20260920/abc123.jpg',
};
// 后端 Logic 层自动:
// 1. 从 avatar 字段检测到临时文件 URL
// 2. 调用 save_file() 迁移到正式目录
// 3. 去掉域名,只存相对路径入库; .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// 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'),
];上传文件安全检查:
│
▼ 1. PHP 上传状态检查(UPLOAD_ERR_OK)
│
▼ 2. finfo 检测真实 MIME 类型(防止伪造扩展名)
│
▼ 3. 扩展名白名单校验
│
▼ 4. 文件大小限制(config/file.php → max_size)
│
▼ 5. 图片额外 getimagesize 校验(防止非图片伪装)
│
▼ 6. 生成随机文件名(防止路径遍历攻击)
│
▼ 7. 存储到 Web 根目录之外(或 Nginx 禁止执行 PHP)# Nginx 配置:上传目录禁止执行 PHP
location ~* /uploads/.*\.php$ {
deny all;
}