Files
2026-07-22 10:18:59 +08:00

230 lines
8.1 KiB
PHP

<?php
namespace app\controller\api;
use app\model\UploadFile;
use app\service\PermissionService;
use think\exception\HttpResponseException;
class Upload extends BaseApi
{
public function upload()
{
$user = $this->authUser();
$file = $this->request->file('file');
if (!$file) {
return $this->error('请选择文件');
}
try {
$ext = strtolower($file->extension() ?: pathinfo($file->getOriginalName(), PATHINFO_EXTENSION));
$ext = $ext ?: 'bin';
$mime = $this->resolveMime($file, $ext);
$fileType = $this->detectFileType($mime, $ext);
if (!empty($user['is_guest']) && $fileType === 'image') {
return $this->error('游客模式不支持发送图片,请登录后再上传', 403);
}
if ($fileType === 'document' && !in_array($mime, config('upload.allowed_documents'), true)) {
$mime = self::DOCUMENT_MIME_BY_EXT[$ext] ?? $mime;
}
if (!PermissionService::canUpload($user, $fileType)) {
return $this->error('您没有权限上传此类型文件或功能未开启', 403);
}
$maxSize = PermissionService::getMaxUploadSizeMb($user) * 1024 * 1024;
if ($file->getSize() > $maxSize) {
return $this->error('文件大小超出限制');
}
if (!$this->isAllowedMime($mime, $fileType, $ext)) {
return $this->error('不支持的文件类型: ' . $mime);
}
$originalName = $file->getOriginalName();
$fileSize = $file->getSize();
$uploadPath = rtrim(config('upload.path'), '/\\');
$this->ensureUploadDir($uploadPath);
$subdir = date('Y/m/d');
$storedBase = uniqid('', true) . '.' . $ext;
$storedName = $subdir . '/' . $storedBase;
$fullDir = $uploadPath . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subdir);
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
return $this->error('无法创建上传目录,请检查 uploads 权限', 500);
}
if (!is_writable($fullDir)) {
return $this->error('上传目录不可写,请执行: chmod -R 775 uploads && chown -R www-data:www-data uploads', 500);
}
$moved = $file->move($fullDir, $storedBase);
if (!$moved) {
return $this->error('文件保存失败: ' . ($file->getError() ?: '未知错误'), 500);
}
$record = UploadFile::create([
'user_id' => $user['id'],
'original_name' => $originalName,
'stored_name' => $storedBase,
'file_path' => $storedName,
'mime_type' => $mime,
'file_size' => $fileSize,
'file_type' => $fileType,
]);
return $this->success([
'id' => $record->id,
'url' => '/api/uploads/' . rawurlencode($storedBase),
'name' => $originalName,
'type' => $fileType,
'mime' => $mime,
'size' => $fileSize,
]);
} catch (\think\exception\FileException $e) {
return $this->error('文件保存失败: ' . $e->getMessage(), 500);
} catch (\Throwable $e) {
return $this->error('上传失败: ' . $e->getMessage(), 500);
}
}
public function serve($filename)
{
$filename = basename($filename);
$upload = UploadFile::where('stored_name', $filename)
->whereOr('file_path', 'like', '%/' . $filename)
->find();
if (!$upload) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '文件不存在',
'data' => null,
], 404));
}
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $upload->file_path);
if (!is_file($path)) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '文件不存在',
'data' => null,
], 404));
}
$mime = $upload->mime_type ?: (@mime_content_type($path) ?: 'application/octet-stream');
return response(file_get_contents($path), 200, [
'Content-Type' => $mime,
'Content-Length' => (string) filesize($path),
'Cache-Control' => 'public, max-age=604800',
]);
}
/**
* 文档扩展名 -> 常见但不同系统上可能检测出不一致的 MIME 兜底表。
* .doc/.docx 等 Office 文档在不同服务器 fileinfo 版本下识别出的 MIME 差异很大,
* 仅靠 MIME 白名单很容易误判为“不支持的文件类型”,因此这里用扩展名兜底放行。
*/
private const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'];
private const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov'];
private const DOCUMENT_EXTENSIONS = ['pdf', 'doc', 'docx', 'txt', 'md'];
private const IMAGE_MIME_BY_EXT = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'bmp' => 'image/bmp',
];
private const DOCUMENT_MIME_BY_EXT = [
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'txt' => 'text/plain',
'md' => 'text/markdown',
];
private function resolveMime($file, string $ext): string
{
$mime = $file->getMime();
if (!$mime && is_file($file->getPathname())) {
$mime = @mime_content_type($file->getPathname()) ?: '';
}
$mime = strtolower(trim((string) $mime));
if ($mime === 'image/jpg') {
$mime = 'image/jpeg';
}
if ($mime === '' || $mime === 'application/octet-stream') {
$mime = self::IMAGE_MIME_BY_EXT[$ext] ?? self::DOCUMENT_MIME_BY_EXT[$ext] ?? $mime;
}
return $mime ?: 'application/octet-stream';
}
private function ensureUploadDir(string $uploadPath): void
{
if (is_dir($uploadPath)) {
return;
}
if (!mkdir($uploadPath, 0755, true) && !is_dir($uploadPath)) {
throw new \RuntimeException('无法创建 uploads 目录: ' . $uploadPath);
}
}
private function detectFileType(string $mime, string $ext = ''): string
{
$ext = strtolower($ext);
if (in_array($ext, self::IMAGE_EXTENSIONS, true)) {
return 'image';
}
if (in_array($ext, self::VIDEO_EXTENSIONS, true)) {
return 'video';
}
if (str_starts_with($mime, 'image/')) {
return 'image';
}
if (str_starts_with($mime, 'video/')) {
return 'video';
}
if (str_starts_with($mime, 'audio/')) {
return 'audio';
}
if (in_array($mime, config('upload.allowed_documents'), true)) {
return 'document';
}
if (in_array(strtolower($ext), self::DOCUMENT_EXTENSIONS, true)) {
return 'document';
}
return 'other';
}
private function isAllowedMime(string $mime, string $fileType, string $ext = ''): bool
{
$config = config('upload');
return match ($fileType) {
'image' => in_array($mime, $config['allowed_images'], true)
|| in_array(strtolower($ext), self::IMAGE_EXTENSIONS, true),
'video' => in_array($mime, $config['allowed_videos'], true)
|| in_array(strtolower($ext), self::VIDEO_EXTENSIONS, true),
'audio' => in_array($mime, $config['allowed_audios'], true),
'document' => in_array($mime, $config['allowed_documents'], true)
|| in_array(strtolower($ext), self::DOCUMENT_EXTENSIONS, true),
default => false,
};
}
}