文件上传处理

Viswoole 框架提供了完整的文件上传支持,包括单文件上传、多文件上传、文件类型验证、大小限制等功能。通过 #[InjectFile] 注解和 UploadedFile 对象,可以方便地处理各种文件上传场景。

基础概念

UploadedFile 对象

框架将每个上传的文件封装为 UploadedFile 对象,提供文件信息查询和操作方法:

php
namespace Viswoole\HttpServer\Message;

class UploadedFile
{
    // 只读属性
    public readonly string $tmp_path;  // 临时文件路径

    // 公共方法
    public function getClientFilename(): ?string;   // 客户端原始文件名
    public function getClientMediaType(): ?string;  // MIME 类型(客户端声明)
    public function getSize(): ?int;                // 文件大小(字节)
    public function getError(): int;                // 上传错误码(0=成功)
    public function isMoved(): bool;               // 是否已移动
    public function getStream(): FileStream;        // 获取文件流
    public function moveTo(string $targetPath): void; // 移动文件到目标位置
}

单文件上传

基础示例

php
<?php
declare(strict_types=1);

namespace App\Controller;

use Viswoole\HttpServer\AutoInject\InjectFile;
use Viswoole\HttpServer\Message\UploadedFile;
use Viswoole\HttpServer\Contract\ResponseInterface;
use Viswoole\Router\Annotation\AutoController;
use Viswoole\Router\Annotation\RouteMapping;
use App\Response;

#[AutoController]
class UploadController
{
    /**
     * 上传头像
     *
     * POST /upload/avatar
     * Form Data: avatar=(file)
     *
     * @param UploadedFile $file 上传的文件
     * @param Response $response 响应对象
     * @return ResponseInterface
     */
    #[RouteMapping(method: 'POST')]
    public static function avatar(
        #[InjectFile] UploadedFile $file,
        Response $response
    ): ResponseInterface {
        // 获取文件信息
        $originalName = $file->getClientFilename();   // "photo.jpg"
        $mimeType = $file->getClientMediaType();       // "image/jpeg"
        $size = $file->getSize();                      // 123456 (字节)

        // 生成新文件名(避免重名冲突)
        $extension = pathinfo($originalName, PATHINFO_EXTENSION);
        $newFileName = uniqid('avatar_') . '.' . $extension;

        // 移动文件到目标目录
        $targetPath = app()->getRootPath() . "/runtime/uploads/avatars/{$newFileName}";
        $file->moveTo($targetPath);

        return $response->json([
            'success' => true,
            'message' => '上传成功',
            'data' => [
                'original_name' => $originalName,
                'saved_path' => $targetPath,
                'url' => "/uploads/avatars/{$newFileName}",
                'size' => $size,
                'mime_type' => $mimeType
            ]
        ]);
    }
}

HTML 表单示例

html
<!-- 单文件上传表单 -->
<form action="/upload/avatar" method="POST" enctype="multipart/form-data">
  <label for="avatar">选择头像:</label>
  <input type="file" id="avatar" name="avatar" accept="image/*" />
  <button type="submit">上传</button>
</form>

重要: 表单必须设置 enctype="multipart/form-data"

文件验证规则

使用 FileRule 注解

通过 #[FileRule] 注解可以限制文件的类型、大小和数量:

php
/**
 * @param string $fileMime 允许的 MIME 类型,多个用 | 分隔,* 表示不限制
 * @param int $maxSize 最大字节数(0=不限制)
 * @param int $count 要求的文件数量(0=不限制)
 * @param string $message 自定义错误消息
 */
#[Attribute]
class FileRule
{
    public function __construct(
        public readonly string $fileMime = '*',
        public readonly int $maxSize = 0,
        public readonly int $count = 0,
        string $message = ''
    ) {}
}

验证示例

限制文件类型

php
/**
 * 上传图片(仅允许 PNG 和 JPEG)
 */
#[RouteMapping(method: 'POST')]
public static function image(
    #[FileRule('image/png|image/jpeg')] // 允许 PNG 和 JPEG
    #[InjectFile]
    UploadedFile $file,
    Response $response
): ResponseInterface {
    // 文件已验证通过,直接处理...
    return $this->saveImage($file, $response);
}

限制文件大小

php
/**
 * 上传文档(最大 5MB)
 * 5MB = 5 * 1024 * 1024 = 5242880 字节
 */
#[RouteMapping(method: 'POST')]
public static function document(
    #[FileRule(
        fileMime: 'application/pdf|application/msword',
        maxSize: 5242880  // 5MB
    )]
    #[InjectFile]
    UploadedFile $file,
    Response $response
): ResponseInterface {
    return $this->saveDocument($file, $response);
}

要求特定数量的文件

php
/**
 * 批量上传图片(必须上传 3 张)
 */
#[RouteMapping(method: 'POST')]
public static function batchImages(
    #[FileRule(
        fileMime: 'image/*',
        count: 3  // 必须恰好 3 个文件
    )]
    #[InjectFile]
    array $files,  // 多文件返回数组
    Response $response
): ResponseInterface {
    $savedPaths = [];
    foreach ($files as $index => $file) {
        $path = $this->saveImageFile($file, "image_{$index}");
        $savedPaths[] = $path;
    }

    return $response->json([
        'success' => true,
        'count' => count($savedPaths),
        'paths' => $savedPaths
    ]);
}

自定义错误消息

php
/**
 * 上传身份证照片
 */
#[RouteMapping(method: 'POST')]
public static function idCard(
    #[FileRule(
        fileMime: 'image/jpeg,image/png',
        maxSize: 2097152,  // 2MB
        message: '请上传 JPG 或 PNG 格式的身份证照片,大小不超过 2MB'
    )]
    #[InjectFile]
    UploadedFile $file,
    Response $response
): ResponseInterface {
    // ...
}

支持的 MIME 类型

常用文件类型的 MIME 类型:

文件类型MIME 类型
PNG 图片image/png
JPEG 图片image/jpeg
GIF 图片image/gif
WebP 图片image/webp
所有图片image/*
PDF 文档application/pdf
Word 文档application/msword
Excel 表格application/vnd.ms-excel
纯文本text/plain
JSON 数据application/json
XML 数据application/xml
任意类型*

注意: 可以使用 | 分隔多种类型,或使用通配符 * 匹配一类文件。

多文件上传

HTML 表单配置

html
<!-- 多文件上传 -->
<form action="/upload/batch" method="POST" enctype="multipart/form-data">
  <label for="photos">选择多张图片:</label>
  <!-- 添加 multiple 属性支持多选 -->
  <input type="file" id="photos" name="photos[]" multiple accept="image/*" />
  <button type="submit">批量上传</button>
</form>

关键点:

  • input 的 name 属性必须以 [] 结尾:name="photos[]"
  • 添加 multiple 属性允许选择多个文件

控制器处理

php
/**
 * 批量上传图片
 *
 * POST /upload/batch
 * Form Data: photos[]=(file1), photos[]=(file2), photos[]=(file3)
 */
#[RouteMapping(method: 'POST')]
public static function batchUpload(
    #[FileRule('image/*')]  // 验证所有文件都是图片
    #[InjectFile]
    array $files,           // 自动识别为数组
    Response $response
): ResponseInterface {
    $results = [];
    $errors = [];

    foreach ($files as $index => $file) {
        try {
            // 检查上传是否有错误
            if ($file->getError() !== UPLOAD_ERR_OK) {
                throw new \RuntimeException($this->getUploadErrorMessage($file->getError()));
            }

            // 检查是否已移动
            if ($file->isMoved()) {
                throw new \RuntimeException('文件已被移动');
            }

            // 保存文件
            $originalName = $file->getClientFilename();
            $extension = pathinfo($originalName, PATHINFO_EXTENSION);
            $newName = uniqid('img_') . '_' . ($index + 1) . '.' . $extension;
            $targetPath = app()->getRootPath() . "/runtime/uploads/images/{$newName}";

            $file->moveTo($targetPath);

            $results[] = [
                'original_name' => $originalName,
                'saved_path' => $targetPath,
                'size' => $file->getSize(),
                'status' => 'success'
            ];
        } catch (\Exception $e) {
            $errors[] = [
                'index' => $index,
                'error' => $e->getMessage()
            ];
        }
    }

    return $response->json([
        'success' => empty($errors),
        'uploaded' => count($results),
        'failed' => count($errors),
        'data' => $results,
        'errors' => $errors
    ]);
}

/**
 * 获取上传错误码对应的中文消息
 */
private static function getUploadErrorMessage(int $errorCode): string
{
    return match($errorCode) {
        UPLOAD_ERR_INI_SIZE => '文件超过 server.ini 中 upload_max_filesize 限制',
        UPLOAD_ERR_FORM_SIZE => '文件超过表单 MAX_FILE_SIZE 限制',
        UPLOAD_ERR_PARTIAL => '文件只有部分被上传',
        UPLOAD_ERR_NO_FILE => '没有文件被上传',
        UPLOAD_ERR_NO_TMP_DIR => '缺少临时文件夹',
        UPLOAD_ERR_CANT_WRITE => '文件写入磁盘失败',
        UPLOAD_ERR_EXTENSION => 'PHP 扩展阻止了文件上传',
        default => '未知上传错误'
    };
}

文件操作详解

moveTo() - 移动文件

将上传的临时文件移动到目标位置:

php
/**
 * @param string $targetPath 目标路径(包含文件名)
 * @throws InvalidArgumentException 路径为空时抛出
 * @throws RuntimeException 目录创建失败、移动失败、文件已移动等异常
 */
public function moveTo(string $targetPath): void

特性:

  • CLI 模式下使用 rename()(性能更好)
  • 非 CLI 模式下使用 move_uploaded_file()
  • 目标目录不存在时会自动递归创建
  • 文件只能移动一次,再次调用会抛出异常

示例:

php
// 移动到指定目录
$file->moveTo('/var/www/uploads/photo.jpg');

// 移动并重命名
$newName = date('Y/m/d/') . uniqid() . '.jpg';
$filePath = app()->getRootPath() . '/storage/uploads/' . $newName;
$file->moveTo($filePath);

getStream() - 获取文件流

获取文件的流对象,用于读取文件内容或进行流式处理:

php
/**
 * @return FileStream 文件流实例(延迟初始化)
 */
public function getStream(): FileStream

示例 - 读取文件内容:

php
$stream = $file->getStream();
$content = $stream->getContents(); // 读取全部内容

// 或逐行读取
while (!$stream->eof()) {
    $line = $stream->read(1024); // 每次读取 1KB
    // 处理数据...
}
$stream->close();

示例 - 复制到其他位置:

php
// 将上传文件复制到云存储
$stream = $file->getStream();
CloudStorage::put('remote/path/file.jpg', $stream);

文件信息查询

php
$file = Request::files('document');

// 原始文件名(可能包含特殊字符,需谨慎使用)
$filename = $file->getClientFilename();      // "报告.docx"

// 客户端声明的 MIME 类型(不可完全信任,需服务端验证)
$mimeType = $file->getClientMediaType();     // "application/vnd.openxmlformats-officedocument.wordprocessingml.document"

// 文件大小(字节)
$size = $file->getSize();                   // 1234567

// 上传错误码(UPLOAD_ERR_OK = 0 表示成功)
$error = $file->getError();                 // 0

// 是否已被移动
$moved = $file->isMoved();                  // false

// 临时文件路径
$tmpPath = $file->tmp_path;                 // /tmp/swoole.xxx

完整示例:带完整验证的上传系统

php
<?php
declare(strict_types=1);

namespace App\Controller;

use Viswoole\HttpServer\AutoInject\InjectFile;
use Viswoole\HttpServer\Message\UploadedFile;
use Viswoole\HttpServer\Contract\ResponseInterface;
use Viswoole\Router\Annotation\AutoController;
use Viswoole\Router\Annotation\RouteMapping;
use App\Response;

/**
 * 文件管理控制器
 * 演示完整的文件上传功能
 */
#[AutoController(prefix: 'api/v1/files')]
class FileController
{
    /**
     * 上传单个图片
     *
     * POST /api/v1/files/upload-image
     *
     * 限制:
     * - 格式:PNG、JPEG、GIF、WebP
     * - 大小:最大 10MB
     *
     * @param UploadedFile $file 图片文件
     * @return ResponseInterface
     */
    #[RouteMapping(method: 'POST')]
    public static function uploadImage(
        #[FileRule(
            fileMime: 'image/png|image/jpeg|image/gif|image/webp',
            maxSize: 10485760,  // 10MB
            message: '请上传 PNG/JPEG/GIF/WebP 格式的图片,大小不超过 10MB'
        )]
        #[InjectFile]
        UploadedFile $file,
        Response $response
    ): ResponseInterface {
        try {
            // 1. 验证上传状态
            self::validateUploadStatus($file);

            // 2. 安全性检查:重新检测实际 MIME 类型
            $actualMimeType = self::detectMimeType($file);
            $allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
            if (!in_array($actualMimeType, $allowedTypes)) {
                throw new \RuntimeException('文件类型验证失败');
            }

            // 3. 生成安全的文件名
            $originalName = $file->getClientFilename();
            $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
            $safeName = self::generateSafeFileName($extension);

            // 4. 按日期组织目录结构
            $datePath = date('Y/m/d');
            $relativePath = "images/{$datePath}/{$safeName}";
            $fullPath = app()->getRootPath() . "/runtime/uploads/{$relativePath}";

            // 5. 移动文件
            $file->moveTo($fullPath);

            // 6. 返回成功响应
            return $response->json([
                'code' => 200,
                'message' => '上传成功',
                'data' => [
                    'filename' => $safeName,
                    'original_name' => $originalName,
                    'path' => $relativePath,
                    'url' => "/uploads/{$relativePath}",
                    'size' => $file->getSize(),
                    'mime_type' => $actualMimeType,
                    'uploaded_at' => date('c')
                ]
            ]);

        } catch (\RuntimeException $e) {
            return $response
                ->status(400)
                ->json([
                    'code' => 400,
                    'message' => $e->getMessage(),
                    'error' => 'UPLOAD_ERROR'
                ]);
        }
    }

    /**
     * 批量上传文件
     *
     * POST /api/v1/files/batch-upload
     *
     * 支持 PDF、Word、Excel、图片
     * 单个文件最大 20MB,最多 10 个文件
     */
    #[RouteMapping(method: 'POST')]
    public static function batchUpload(
        #[FileRule(
            fileMime: 'image/*|application/pdf|application/msword|application/vnd.ms-excel',
            maxSize: 20971520,  // 20MB
            count: 10          // 最多 10 个文件
        )]
        #[InjectFile]
        array $files,
        Response $response
    ): ResponseInterface {
        $successCount = 0;
        $failCount = 0;
        $results = [];

        foreach ($files as $index => $file) {
            try {
                self::validateUploadStatus($file);

                $originalName = $file->getClientFilename();
                $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
                $safeName = self::generateSafeFileName($extension);

                $datePath = date('Y/m/d');
                $relativePath = "documents/{$datePath}/{$safeName}";
                $fullPath = app()->getRootPath() . "/runtime/uploads/{$relativePath}";

                $file->moveTo($fullPath);

                $results[] = [
                    'index' => $index,
                    'status' => 'success',
                    'filename' => $safeName,
                    'original_name' => $originalName,
                    'path' => $relativePath,
                    'size' => $file->getSize()
                ];
                $successCount++;

            } catch (\Exception $e) {
                $results[] = [
                    'index' => $index,
                    'status' => 'failed',
                    'error' => $e->getMessage()
                ];
                $failCount++;
            }
        }

        return $response->json([
            'code' => $failCount > 0 ? 206 : 200,  // Partial Content 或 OK
            'message' => $failCount > 0 ? '部分文件上传失败' : '全部上传成功',
            'summary' => [
                'total' => count($files),
                'success' => $successCount,
                'failed' => $failCount
            ],
            'data' => $results
        ]);
    }

    /**
     * 上传并返回文件流(用于即时预览或处理)
     *
     * POST /api/v1/files/upload-stream
     */
    #[RouteMapping(method: 'POST')]
    public static function uploadStream(
        #[FileRule('text/csv,application/json')]
        #[InjectFile]
        UploadedFile $file,
        Response $response
    ): ResponseInterface {
        self::validateUploadStatus($file);

        // 获取文件流并读取内容
        $stream = $file->getStream();
        $content = $stream->getContents();
        $stream->close();

        // 解析 CSV 内容
        $lines = explode("\n", $content);
        $headers = str_getcsv(array_shift($lines));
        $data = array_map(fn($line) => array_combine($headers, str_getcsv($line)), $lines);

        return $response->json([
            'code' => 200,
            'filename' => $file->getClientFilename(),
            'rows' => count($data),
            'preview' => array_slice($data, 0, 10)  // 仅返回前 10 行预览
        ]);
    }

    /**
     * 验证上传状态
     */
    private static function validateUploadStatus(UploadedFile $file): void
    {
        $error = $file->getError();
        if ($error !== UPLOAD_ERR_OK) {
            throw new \RuntimeException(self::getUploadErrorMessage($error));
        }

        if ($file->isMoved()) {
            throw new \RuntimeException('文件已被移动,无法重复操作');
        }
    }

    /**
     * 检测文件真实 MIME 类型(防止伪造扩展名攻击)
     */
    private static function detectMimeType(UploadedFile $file): string
    {
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        if ($finfo === false) {
            return $file->getClientMediaType();
        }

        // 从临时文件路径检测
        $mimeType = finfo_file($finfo, $file->tmp_path);
        finfo_close($finfo);

        return $mimeType ?: $file->getClientMediaType();
    }

    /**
     * 生成安全的文件名
     */
    private static function generateSafeFileName(string $extension): string
    {
        return sprintf(
            '%s_%s.%s',
            date('YmdHis'),
            bin2hex(random_bytes(8)),
            $extension
        );
    }

    /**
     * 获取上传错误码对应的消息
     */
    private static function getUploadErrorMessage(int $code): string
    {
        return match($code) {
            UPLOAD_ERR_INI_SIZE => '文件超出系统允许的最大大小',
            UPLOAD_ERR_FORM_SIZE => '文件超出表单允许的最大大小',
            UPLOAD_ERR_PARTIAL => '文件只有部分被上传',
            UPLOAD_ERR_NO_FILE => '没有选择要上传的文件',
            UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录缺失',
            UPLOAD_ERR_CANT_WRITE => '文件写入磁盘失败',
            UPLOAD_ERR_EXTENSION => '文件上传被 PHP 扩展阻止',
            default => '未知上传错误'
        };
    }
}

最佳实践

1. 始终验证文件类型

不要信任客户端声明的 MIME 类型,使用服务端检测:

php
// ✅ 推荐:使用 finfo 检测真实类型
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$realType = finfo_file($finfo, $file->tmp_path);
finfo_close($finfo);

// ❌ 危险:仅检查扩展名
$extension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);

2. 使用随机文件名避免冲突

php
// ✅ 推荐:使用时间戳+随机字符串
$fileName = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.jpg';

// ❌ 避免:直接使用用户提供的文件名
$fileName = $_FILES['file']['name']; // 可能包含 ../ 等危险字符

3. 合理组织存储目录

按日期或类型分目录存储,避免单目录文件过多:

text
uploads/
├── images/
│   ├── 2024/
│   │   ├── 01/
│   │   │   ├── 15/
│   │   │   └── 16/
│   │   └── 02/
│   └── ...
├── documents/
│   └── ...
└── temp/

4. 设置合理的文件大小限制

在 PHP 配置和业务逻辑中双重限制:

php
// php.ini
upload_max_filesize = 20M
post_max_size = 25M

// 业务代码中的二次验证
#[FileRule(maxSize: 20971520)]  // 20MB
#[InjectFile]
UploadedFile $file

5. 及时清理临时文件

Swoole 会在请求结束后自动清理临时文件,但如果手动处理:

php
try {
    $file->moveTo($destination);
} catch (\Exception $e) {
    // 移动失败时,临时文件会在请求结束后自动清理
    // 无需手动删除 tmp_path
    Log::error('文件移动失败: ' . $e->getMessage());
}

6. 记录上传日志

对于重要的文件上传操作,记录详细日志:

php
Log::info('文件上传', [
    'user_id' => Auth::id(),
    'original_name' => $file->getClientFilename(),
    'size' => $file->getSize(),
    'mime_type' => $file->getClientMediaType(),
    'saved_path' => $targetPath,
    'ip' => Request::ip()
]);

常见问题与陷阱

Q: 为什么上传大文件时报错?

A: 检查以下配置:

  • php.ini: upload_max_filesize, post_max_size, memory_limit
  • Swoole 配置: package_max_length
  • Nginx(如果使用反向代理): client_max_body_size

Q: 如何支持断点续传?

A: 当前版本不支持原生的断点续传。如需此功能,建议:

  • 使用分片上传:前端将大文件分割为小块逐个上传
  • 服务端接收后合并文件
  • 记录已上传的分片信息,支持恢复

Q: 如何防止恶意文件上传?

A:

  1. 验证 MIME 类型:使用 finfo 检测真实类型
  2. 限制扩展名:只允许白名单中的扩展名
  3. 文件内容检查:对图片可尝试加载验证其合法性
  4. 隔离存储:上传文件存放在非 Web 根目录或专用域名
  5. 权限控制:上传目录禁止执行 PHP 脚本

Q: 多文件上传时如何区分字段?

A: HTML 表单中使用数组命名:

html
<input type="file" name="photos[]" multiple /> <input type="file" name="documents[]" multiple />

控制器中分别注入:

php
public static function uploadMixed(
    #[InjectFile] array $photos,      // photos[]
    #[InjectFile] array $documents,    // documents[]
    Response $response
): ResponseInterface { ... }

Q: UploadedFile 对象可以序列化吗?

A: 不可以。UploadedFile 包含临时文件路径引用,序列化无意义。如需持久化存储文件信息,应保存文件路径到数据库。

Q: 如何实现文件预览?

A: 上传成功后返回可访问的 URL,配合 Web 服务器配置:

php
// 返回 URL
$url = '/uploads/images/2024/01/15/xxx.jpg';

// 前端使用
<img src="{{ $url }}" alt="预览">

确保 Web 服务器正确配置了静态文件服务。