ThinkPHP6/8 文件上传与安全防护实战
Orion K Lv6

文件上传是Web应用中的常见功能,但同时也是安全风险的重灾区。本文将详细介绍如何在ThinkPHP6/8中实现安全可靠的文件上传功能,包括单文件上传、多文件上传、图片处理以及各种安全防护措施。

文件上传基础

环境配置

首先安装文件系统扩展(ThinkPHP 6.1+需要):

1
composer require topthink/think-filesystem

PHP配置检查

确保PHP配置支持文件上传:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 检查PHP文件上传配置
* @return array
*/
function checkUploadConfig(): array
{
return [
'file_uploads' => ini_get('file_uploads') ? '开启' : '关闭',
'upload_max_filesize' => ini_get('upload_max_filesize'),
'post_max_size' => ini_get('post_max_size'),
'max_file_uploads' => ini_get('max_file_uploads'),
'max_execution_time' => ini_get('max_execution_time') . '秒',
'memory_limit' => ini_get('memory_limit')
];
}

文件系统配置

配置 config/filesystem.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php
return [
'default' => 'local',
'disks' => [
'local' => [
'type' => 'local',
'root' => app()->getRuntimePath() . 'storage',
],
'public' => [
'type' => 'local',
'root' => app()->getRootPath() . 'public/storage',
'url' => '/storage',
'visibility' => 'public',
],
// 七牛云存储
'qiniu' => [
'type' => 'qiniu',
'access_key' => env('qiniu.access_key'),
'secret_key' => env('qiniu.secret_key'),
'bucket' => env('qiniu.bucket'),
'domain' => env('qiniu.domain'),
],
// 阿里云OSS
'oss' => [
'type' => 'oss',
'access_id' => env('oss.access_id'),
'access_secret' => env('oss.access_secret'),
'bucket' => env('oss.bucket'),
'endpoint' => env('oss.endpoint'),
]
],
];

单文件上传实现

基础上传控制器

创建 app/controller/Upload.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
<?php
namespace app\controller;

use think\Request;
use think\Response;
use think\facade\Filesystem;
use think\exception\ValidateException;
use app\validate\UploadValidate;

/**
* 文件上传控制器
*/
class Upload
{
/**
* 单文件上传
* @param Request $request
* @return Response
*/
public function single(Request $request): Response
{
try {
// 获取上传文件
$file = $request->file('file');

if (!$file) {
return json(['code' => 400, 'message' => '请选择要上传的文件']);
}

// 文件验证
$this->validateFile($file);

// 获取文件类型
$type = $request->param('type', 'common');

// 上传文件
$saveName = Filesystem::disk('public')->putFile($type, $file);

// 生成访问URL
$url = '/storage/' . $saveName;

// 保存上传记录
$this->saveUploadRecord($file, $saveName, $url);

return json([
'code' => 200,
'message' => '上传成功',
'data' => [
'url' => $url,
'path' => $saveName,
'size' => $file->getSize(),
'name' => $file->getOriginalName(),
'ext' => $file->extension()
]
]);

} catch (ValidateException $e) {
return json(['code' => 422, 'message' => $e->getError()]);
} catch (\Exception $e) {
return json(['code' => 500, 'message' => '上传失败:' . $e->getMessage()]);
}
}

/**
* 图片上传(带缩略图生成)
* @param Request $request
* @return Response
*/
public function image(Request $request): Response
{
try {
$file = $request->file('image');

if (!$file) {
return json(['code' => 400, 'message' => '请选择要上传的图片']);
}

// 图片验证
$this->validateImage($file);

$type = $request->param('type', 'images');

// 上传原图
$saveName = Filesystem::disk('public')->putFile($type, $file);
$fullPath = app()->getRootPath() . 'public/storage/' . $saveName;

// 生成缩略图
$thumbnails = $this->generateThumbnails($fullPath, $type);

// 添加水印(可选)
if ($request->param('watermark', false)) {
$this->addWatermark($fullPath);
}

$url = '/storage/' . $saveName;

return json([
'code' => 200,
'message' => '图片上传成功',
'data' => [
'url' => $url,
'path' => $saveName,
'thumbnails' => $thumbnails,
'size' => $file->getSize(),
'name' => $file->getOriginalName(),
'dimensions' => getimagesize($fullPath)
]
]);

} catch (\Exception $e) {
return json(['code' => 500, 'message' => '图片上传失败:' . $e->getMessage()]);
}
}

/**
* 文件验证
* @param \think\File $file
* @throws ValidateException
*/
private function validateFile($file): void
{
$validate = new UploadValidate();

if (!$validate->check(['file' => $file])) {
throw new ValidateException($validate->getError());
}
}

/**
* 图片验证
* @param \think\File $file
* @throws ValidateException
*/
private function validateImage($file): void
{
$validate = new UploadValidate();

if (!$validate->scene('image')->check(['image' => $file])) {
throw new ValidateException($validate->getError());
}
}

/**
* 生成缩略图
* @param string $imagePath 原图路径
* @param string $type 图片类型
* @return array
*/
private function generateThumbnails(string $imagePath, string $type): array
{
$thumbnails = [];
$sizes = [
'small' => [150, 150],
'medium' => [300, 300],
'large' => [600, 600]
];

foreach ($sizes as $sizeName => $size) {
$thumbnailPath = $this->createThumbnail($imagePath, $size[0], $size[1], $sizeName);
if ($thumbnailPath) {
$thumbnails[$sizeName] = str_replace(app()->getRootPath() . 'public', '', $thumbnailPath);
}
}

return $thumbnails;
}

/**
* 创建缩略图
* @param string $sourcePath 源图片路径
* @param int $width 宽度
* @param int $height 高度
* @param string $suffix 后缀
* @return string|false
*/
private function createThumbnail(string $sourcePath, int $width, int $height, string $suffix)
{
$imageInfo = getimagesize($sourcePath);
if (!$imageInfo) {
return false;
}

$sourceWidth = $imageInfo[0];
$sourceHeight = $imageInfo[1];
$mimeType = $imageInfo['mime'];

// 计算缩放比例
$ratio = min($width / $sourceWidth, $height / $sourceHeight);
$newWidth = intval($sourceWidth * $ratio);
$newHeight = intval($sourceHeight * $ratio);

// 创建画布
$canvas = imagecreatetruecolor($newWidth, $newHeight);

// 创建源图像资源
switch ($mimeType) {
case 'image/jpeg':
$source = imagecreatefromjpeg($sourcePath);
break;
case 'image/png':
$source = imagecreatefrompng($sourcePath);
// 保持PNG透明度
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
break;
case 'image/gif':
$source = imagecreatefromgif($sourcePath);
break;
default:
return false;
}

// 缩放图像
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $newWidth, $newHeight, $sourceWidth, $sourceHeight);

// 生成缩略图路径
$pathInfo = pathinfo($sourcePath);
$thumbnailPath = $pathInfo['dirname'] . '/' . $pathInfo['filename'] . '_' . $suffix . '.' . $pathInfo['extension'];

// 保存缩略图
switch ($mimeType) {
case 'image/jpeg':
imagejpeg($canvas, $thumbnailPath, 90);
break;
case 'image/png':
imagepng($canvas, $thumbnailPath, 9);
break;
case 'image/gif':
imagegif($canvas, $thumbnailPath);
break;
}

// 释放资源
imagedestroy($source);
imagedestroy($canvas);

return $thumbnailPath;
}

/**
* 添加水印
* @param string $imagePath 图片路径
* @return bool
*/
private function addWatermark(string $imagePath): bool
{
$watermarkPath = app()->getRootPath() . 'public/static/watermark.png';

if (!file_exists($watermarkPath)) {
return false;
}

$imageInfo = getimagesize($imagePath);
$watermarkInfo = getimagesize($watermarkPath);

if (!$imageInfo || !$watermarkInfo) {
return false;
}

// 创建图像资源
$image = imagecreatefromstring(file_get_contents($imagePath));
$watermark = imagecreatefrompng($watermarkPath);

// 计算水印位置(右下角)
$imageWidth = $imageInfo[0];
$imageHeight = $imageInfo[1];
$watermarkWidth = $watermarkInfo[0];
$watermarkHeight = $watermarkInfo[1];

$destX = $imageWidth - $watermarkWidth - 10;
$destY = $imageHeight - $watermarkHeight - 10;

// 合并图像
imagecopy($image, $watermark, $destX, $destY, 0, 0, $watermarkWidth, $watermarkHeight);

// 保存图像
switch ($imageInfo['mime']) {
case 'image/jpeg':
imagejpeg($image, $imagePath, 90);
break;
case 'image/png':
imagepng($image, $imagePath, 9);
break;
case 'image/gif':
imagegif($image, $imagePath);
break;
}

// 释放资源
imagedestroy($image);
imagedestroy($watermark);

return true;
}

/**
* 保存上传记录
* @param \think\File $file
* @param string $saveName
* @param string $url
*/
private function saveUploadRecord($file, string $saveName, string $url): void
{
// 这里可以保存到数据库
// UploadRecord::create([
// 'original_name' => $file->getOriginalName(),
// 'save_name' => $saveName,
// 'url' => $url,
// 'size' => $file->getSize(),
// 'ext' => $file->extension(),
// 'mime_type' => $file->getMime(),
// 'upload_time' => date('Y-m-d H:i:s')
// ]);
}
}

多文件上传实现

批量上传控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
/**
* 多文件上传
* @param Request $request
* @return Response
*/
public function multiple(Request $request): Response
{
try {
$files = $request->file('files');

if (!$files || !is_array($files)) {
return json(['code' => 400, 'message' => '请选择要上传的文件']);
}

$type = $request->param('type', 'common');
$successFiles = [];
$errorFiles = [];

foreach ($files as $file) {
try {
// 验证单个文件
$this->validateFile($file);

// 上传文件
$saveName = Filesystem::disk('public')->putFile($type, $file);
$url = '/storage/' . $saveName;

$successFiles[] = [
'original_name' => $file->getOriginalName(),
'url' => $url,
'path' => $saveName,
'size' => $file->getSize(),
'ext' => $file->extension()
];

} catch (\Exception $e) {
$errorFiles[] = [
'name' => $file->getOriginalName(),
'error' => $e->getMessage()
];
}
}

return json([
'code' => 200,
'message' => '批量上传完成',
'data' => [
'success' => $successFiles,
'error' => $errorFiles,
'success_count' => count($successFiles),
'error_count' => count($errorFiles)
]
]);

} catch (\Exception $e) {
return json(['code' => 500, 'message' => '批量上传失败:' . $e->getMessage()]);
}
}

/**
* 图片批量上传
* @param Request $request
* @return Response
*/
public function images(Request $request): Response
{
try {
$files = $request->file('images');

if (!$files || !is_array($files)) {
return json(['code' => 400, 'message' => '请选择要上传的图片']);
}

$type = $request->param('type', 'images');
$fileSize = 1024 * 1024 * 2; // 2MB限制

$successFiles = [];
$errorFiles = [];

foreach ($files as $file) {
try {
// 验证图片
validate(['image' => 'fileSize:' . $fileSize . '|fileExt:jpg,png,gif|image:1920,1080'])
->check(['image' => $file]);

// 上传图片
$saveName = Filesystem::disk('public')->putFile($type, $file);
$fullPath = app()->getRootPath() . 'public/storage/' . $saveName;

// 生成缩略图
$thumbnails = $this->generateThumbnails($fullPath, $type);

$url = '/storage/' . $saveName;

$successFiles[] = [
'original_name' => $file->getOriginalName(),
'url' => $url,
'path' => $saveName,
'thumbnails' => $thumbnails,
'size' => $file->getSize(),
'dimensions' => getimagesize($fullPath)
];

} catch (\Exception $e) {
$errorFiles[] = [
'name' => $file->getOriginalName(),
'error' => $e->getMessage()
];
}
}

return json([
'code' => 200,
'message' => '图片批量上传完成',
'data' => [
'success' => $successFiles,
'error' => $errorFiles,
'success_count' => count($successFiles),
'error_count' => count($errorFiles)
]
]);

} catch (\Exception $e) {
return json(['code' => 500, 'message' => '图片批量上传失败:' . $e->getMessage()]);
}
}

文件验证器

上传验证器

创建 app/validate/UploadValidate.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
<?php
namespace app\validate;

use think\Validate;

/**
* 文件上传验证器
*/
class UploadValidate extends Validate
{
protected $rule = [
'file' => 'require|file|fileSize:10485760|fileExt:jpg,png,gif,pdf,doc,docx,xls,xlsx,zip,rar',
'image' => 'require|file|fileSize:5242880|fileExt:jpg,png,gif|image:1920,1080',
'avatar' => 'require|file|fileSize:2097152|fileExt:jpg,png|image:500,500',
'document' => 'require|file|fileSize:20971520|fileExt:pdf,doc,docx,xls,xlsx,ppt,pptx',
'video' => 'require|file|fileSize:104857600|fileExt:mp4,avi,mov,wmv',
'audio' => 'require|file|fileSize:52428800|fileExt:mp3,wav,flac,aac'
];

protected $message = [
'file.require' => '请选择要上传的文件',
'file.file' => '上传的不是有效文件',
'file.fileSize' => '文件大小不能超过10MB',
'file.fileExt' => '文件格式不支持',

'image.require' => '请选择要上传的图片',
'image.file' => '上传的不是有效图片文件',
'image.fileSize' => '图片大小不能超过5MB',
'image.fileExt' => '图片格式只支持jpg、png、gif',
'image.image' => '图片尺寸不能超过1920x1080',

'avatar.require' => '请选择头像图片',
'avatar.file' => '上传的不是有效图片文件',
'avatar.fileSize' => '头像大小不能超过2MB',
'avatar.fileExt' => '头像格式只支持jpg、png',
'avatar.image' => '头像尺寸不能超过500x500',

'document.require' => '请选择要上传的文档',
'document.file' => '上传的不是有效文档文件',
'document.fileSize' => '文档大小不能超过20MB',
'document.fileExt' => '文档格式不支持',

'video.require' => '请选择要上传的视频',
'video.file' => '上传的不是有效视频文件',
'video.fileSize' => '视频大小不能超过100MB',
'video.fileExt' => '视频格式不支持',

'audio.require' => '请选择要上传的音频',
'audio.file' => '上传的不是有效音频文件',
'audio.fileSize' => '音频大小不能超过50MB',
'audio.fileExt' => '音频格式不支持'
];

protected $scene = [
'image' => ['image'],
'avatar' => ['avatar'],
'document' => ['document'],
'video' => ['video'],
'audio' => ['audio']
];

/**
* 自定义文件类型验证
* @param mixed $value
* @param mixed $rule
* @param array $data
* @return bool|string
*/
protected function checkFileType($value, $rule, array $data = [])
{
if (!$value instanceof \think\File) {
return '上传的不是有效文件';
}

$allowedTypes = [
'image/jpeg', 'image/png', 'image/gif',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];

if (!in_array($value->getMime(), $allowedTypes)) {
return '文件类型不被允许';
}

return true;
}

/**
* 自定义文件安全检查
* @param mixed $value
* @param mixed $rule
* @param array $data
* @return bool|string
*/
protected function checkFileSecurity($value, $rule, array $data = [])
{
if (!$value instanceof \think\File) {
return '上传的不是有效文件';
}

// 检查文件内容是否包含恶意代码
$content = file_get_contents($value->getPathname());

// 检查PHP代码
if (strpos($content, '<?php') !== false || strpos($content, '<?=') !== false) {
return '文件包含不安全内容';
}

// 检查JavaScript代码
if (strpos($content, '<script') !== false) {
return '文件包含不安全脚本';
}

return true;
}
}

安全防护措施

XSS防护

创建XSS过滤函数 app/common.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
<?php

if (!function_exists('remove_xss')) {
/**
* 使用HTMLPurifier防范XSS攻击
* @param string $string 待过滤的字符串
* @return string
*/
function remove_xss(string $string): string
{
// 引入HTMLPurifier
require_once '../vendor/ezyang/htmlpurifier/library/HTMLPurifier.auto.php';

$config = \HTMLPurifier_Config::createDefault();
$config->set('Core.Encoding', 'UTF-8');

// 允许的HTML标签和属性
$config->set('HTML.Allowed', 'div,b,strong,i,em,a[href|title],ul,ol,li,br,p[style],span[style],img[width|height|alt|src]');

// 允许的CSS属性
$config->set('CSS.AllowedProperties', 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align');

// 链接在新窗口打开
$config->set('HTML.TargetBlank', true);

$purifier = new \HTMLPurifier($config);

return $purifier->purify($string);
}
}

if (!function_exists('filter_input_data')) {
/**
* 过滤输入数据
* @param mixed $data 输入数据
* @return mixed
*/
function filter_input_data($data)
{
if (is_array($data)) {
return array_map('filter_input_data', $data);
}

if (is_string($data)) {
// 移除HTML标签
$data = strip_tags($data);

// 转义特殊字符
$data = htmlspecialchars($data, ENT_QUOTES, 'UTF-8');

// 移除多余空白
$data = trim($data);
}

return $data;
}
}

if (!function_exists('check_file_security')) {
/**
* 检查文件安全性
* @param string $filePath 文件路径
* @return bool
*/
function check_file_security(string $filePath): bool
{
// 检查文件是否存在
if (!file_exists($filePath)) {
return false;
}

// 获取文件信息
$fileInfo = pathinfo($filePath);
$extension = strtolower($fileInfo['extension'] ?? '');

// 危险文件扩展名黑名单
$dangerousExts = [
'php', 'php3', 'php4', 'php5', 'phtml', 'pht',
'asp', 'aspx', 'jsp', 'js', 'vbs', 'bat', 'cmd',
'exe', 'com', 'scr', 'msi', 'dll'
];

if (in_array($extension, $dangerousExts)) {
return false;
}

// 检查文件内容
$content = file_get_contents($filePath, false, null, 0, 1024); // 只读取前1KB

// 检查是否包含恶意代码
$maliciousPatterns = [
'/<\?php/i',
'/<\?=/i',
'/<script/i',
'/eval\s*\(/i',
'/exec\s*\(/i',
'/system\s*\(/i',
'/shell_exec\s*\(/i'
];

foreach ($maliciousPatterns as $pattern) {
if (preg_match($pattern, $content)) {
return false;
}
}

return true;
}
}

文件上传安全中间件

创建 app/middleware/UploadSecurity.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<?php
namespace app\middleware;

use Closure;
use think\Request;
use think\Response;

/**
* 文件上传安全中间件
*/
class UploadSecurity
{
/**
* 处理请求
* @param Request $request
* @param Closure $next
* @return Response
*/
public function handle(Request $request, Closure $next)
{
// 检查请求方法
if (!in_array($request->method(), ['POST', 'PUT', 'PATCH'])) {
return json(['code' => 405, 'message' => '请求方法不允许']);
}

// 检查Content-Type
$contentType = $request->header('content-type');
if (strpos($contentType, 'multipart/form-data') === false) {
return json(['code' => 400, 'message' => '请求格式错误']);
}

// 检查文件大小限制
$maxSize = $this->getMaxUploadSize();
if ($request->header('content-length') > $maxSize) {
return json(['code' => 413, 'message' => '请求体过大']);
}

// 检查上传频率限制
if (!$this->checkUploadRate($request)) {
return json(['code' => 429, 'message' => '上传过于频繁,请稍后再试']);
}

return $next($request);
}

/**
* 获取最大上传大小
* @return int
*/
private function getMaxUploadSize(): int
{
$maxUpload = $this->parseSize(ini_get('upload_max_filesize'));
$maxPost = $this->parseSize(ini_get('post_max_size'));
$memoryLimit = $this->parseSize(ini_get('memory_limit'));

return min($maxUpload, $maxPost, $memoryLimit);
}

/**
* 解析大小字符串
* @param string $size
* @return int
*/
private function parseSize(string $size): int
{
$unit = strtolower(substr($size, -1));
$value = intval($size);

switch ($unit) {
case 'g':
$value *= 1024;
case 'm':
$value *= 1024;
case 'k':
$value *= 1024;
}

return $value;
}

/**
* 检查上传频率
* @param Request $request
* @return bool
*/
private function checkUploadRate(Request $request): bool
{
$ip = $request->ip();
$key = 'upload_rate:' . $ip;

$count = cache($key, 0);

// 每分钟最多上传10次
if ($count >= 10) {
return false;
}

cache($key, $count + 1, 60);

return true;
}
}

云存储集成

七牛云上传服务

创建 app/service/QiniuUploadService.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<?php
namespace app\service;

use Qiniu\Auth;
use Qiniu\Storage\UploadManager;
use Qiniu\Storage\BucketManager;

/**
* 七牛云上传服务
*/
class QiniuUploadService
{
private $auth;
private $bucket;
private $domain;

public function __construct()
{
$accessKey = config('filesystem.disks.qiniu.access_key');
$secretKey = config('filesystem.disks.qiniu.secret_key');
$this->bucket = config('filesystem.disks.qiniu.bucket');
$this->domain = config('filesystem.disks.qiniu.domain');

$this->auth = new Auth($accessKey, $secretKey);
}

/**
* 上传文件
* @param string $filePath 本地文件路径
* @param string $key 存储键名
* @return array
*/
public function upload(string $filePath, string $key = null): array
{
try {
$token = $this->auth->uploadToken($this->bucket);
$uploadMgr = new UploadManager();

if (!$key) {
$key = $this->generateKey($filePath);
}

list($ret, $err) = $uploadMgr->putFile($token, $key, $filePath);

if ($err !== null) {
throw new \Exception('上传失败:' . $err->message());
}

return [
'success' => true,
'key' => $ret['key'],
'url' => $this->domain . '/' . $ret['key'],
'hash' => $ret['hash']
];

} catch (\Exception $e) {
return [
'success' => false,
'message' => $e->getMessage()
];
}
}

/**
* 删除文件
* @param string $key 文件键名
* @return bool
*/
public function delete(string $key): bool
{
try {
$bucketMgr = new BucketManager($this->auth);
$err = $bucketMgr->delete($this->bucket, $key);

return $err === null;

} catch (\Exception $e) {
return false;
}
}

/**
* 生成存储键名
* @param string $filePath
* @return string
*/
private function generateKey(string $filePath): string
{
$pathInfo = pathinfo($filePath);
$extension = $pathInfo['extension'] ?? '';

return date('Y/m/d/') . uniqid() . '.' . $extension;
}

/**
* 获取上传Token
* @param array $policy 上传策略
* @return string
*/
public function getUploadToken(array $policy = []): string
{
return $this->auth->uploadToken($this->bucket, null, 3600, $policy);
}
}

前端集成示例

HTML表单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
<!DOCTYPE html>
<html>
<head>
<title>文件上传示例</title>
<style>
.upload-area {
border: 2px dashed #ccc;
border-radius: 10px;
width: 400px;
height: 200px;
text-align: center;
line-height: 200px;
margin: 20px auto;
cursor: pointer;
}
.upload-area.dragover {
border-color: #007cba;
background-color: #f0f8ff;
}
.file-list {
margin: 20px auto;
width: 400px;
}
.file-item {
padding: 10px;
border: 1px solid #ddd;
margin: 5px 0;
border-radius: 5px;
}
.progress {
width: 100%;
height: 20px;
background-color: #f0f0f0;
border-radius: 10px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: #007cba;
width: 0%;
transition: width 0.3s;
}
</style>
</head>
<body>
<h1>文件上传示例</h1>

<!-- 单文件上传 -->
<h2>单文件上传</h2>
<form id="singleUploadForm" enctype="multipart/form-data">
<input type="file" id="singleFile" name="file" accept="image/*">
<button type="submit">上传</button>
</form>

<!-- 多文件上传 -->
<h2>多文件上传</h2>
<div class="upload-area" id="uploadArea">
点击或拖拽文件到此处上传
<input type="file" id="multipleFiles" name="files[]" multiple accept="image/*" style="display: none;">
</div>

<div class="file-list" id="fileList"></div>

<script>
// 单文件上传
document.getElementById('singleUploadForm').addEventListener('submit', function(e) {
e.preventDefault();

const formData = new FormData();
const fileInput = document.getElementById('singleFile');

if (fileInput.files.length === 0) {
alert('请选择文件');
return;
}

formData.append('file', fileInput.files[0]);
formData.append('type', 'images');

fetch('/upload/single', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.code === 200) {
alert('上传成功');
console.log(data.data);
} else {
alert('上传失败:' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('上传出错');
});
});

// 多文件上传
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('multipleFiles');
const fileList = document.getElementById('fileList');

uploadArea.addEventListener('click', () => {
fileInput.click();
});

uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});

uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});

uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');

const files = e.dataTransfer.files;
handleFiles(files);
});

fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
});

function handleFiles(files) {
fileList.innerHTML = '';

Array.from(files).forEach((file, index) => {
const fileItem = createFileItem(file, index);
fileList.appendChild(fileItem);
uploadFile(file, index);
});
}

function createFileItem(file, index) {
const div = document.createElement('div');
div.className = 'file-item';
div.innerHTML = `
<div>文件名:${file.name}</div>
<div>大小:${formatFileSize(file.size)}</div>
<div class="progress">
<div class="progress-bar" id="progress-${index}"></div>
</div>
<div id="status-${index}">准备上传...</div>
`;
return div;
}

function uploadFile(file, index) {
const formData = new FormData();
formData.append('files[]', file);
formData.append('type', 'images');

const xhr = new XMLHttpRequest();

xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
document.getElementById(`progress-${index}`).style.width = percentComplete + '%';
}
});

xhr.addEventListener('load', () => {
const response = JSON.parse(xhr.responseText);
const statusElement = document.getElementById(`status-${index}`);

if (response.code === 200) {
statusElement.textContent = '上传成功';
statusElement.style.color = 'green';
} else {
statusElement.textContent = '上传失败:' + response.message;
statusElement.style.color = 'red';
}
});

xhr.addEventListener('error', () => {
document.getElementById(`status-${index}`).textContent = '上传出错';
document.getElementById(`status-${index}`).style.color = 'red';
});

xhr.open('POST', '/upload/multiple');
xhr.send(formData);
}

function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';

const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));

return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
</script>
</body>
</html>

最佳实践总结

1. 安全防护

  • 文件类型验证:严格限制允许上传的文件类型
  • 文件大小限制:设置合理的文件大小上限
  • 文件内容检查:扫描文件内容是否包含恶意代码
  • 文件名过滤:防止目录遍历攻击
  • 上传频率限制:防止恶意大量上传

2. 性能优化

  • 异步上传:使用Ajax实现无刷新上传
  • 分片上传:大文件分片上传,支持断点续传
  • 压缩处理:自动压缩图片,减少存储空间
  • CDN加速:使用云存储和CDN加速文件访问

3. 用户体验

  • 进度显示:实时显示上传进度
  • 拖拽上传:支持拖拽文件上传
  • 预览功能:图片上传后立即预览
  • 错误提示:友好的错误信息提示

4. 运维监控

  • 上传日志:记录所有上传操作
  • 存储监控:监控存储空间使用情况
  • 性能监控:监控上传接口性能
  • 安全审计:定期审计上传的文件

通过以上完整的文件上传解决方案,可以在ThinkPHP6/8中构建安全、高效、用户友好的文件上传功能,满足各种业务场景的需求。

本站由 提供部署服务