Skip to content

上传错误处理

概述

PHP 文件上传过程中可能发生多种错误,每种错误对应一个 UPLOAD_ERR_* 常量。正确处理这些错误并提供友好的错误信息是文件上传功能的重要部分。PHP 8.0+ 对上传错误处理提供了更好的类型支持。

适用场景

  • 文件上传表单错误提示
  • 大文件上传失败处理
  • 上传日志记录
  • 异常监控与告警

基础概念

UPLOAD_ERR_* 错误码

常量说明
UPLOAD_ERR_OK0上传成功
UPLOAD_ERR_INI_SIZE1超过 upload_max_filesize
UPLOAD_ERR_FORM_SIZE2超过 MAX_FILE_SIZE 表单隐藏域
UPLOAD_ERR_PARTIAL3文件只部分上传
UPLOAD_ERR_NO_FILE4没有文件被上传
UPLOAD_ERR_NO_TMP_DIR6找不到临时目录(PHP 5.0.3+)
UPLOAD_ERR_CANT_WRITE7写入磁盘失败(PHP 5.1.0+)
UPLOAD_ERR_EXTENSION8上传被 PHP 扩展阻止(PHP 5.2.1+)

注意

错误码 5 从未被定义,这是一个历史遗留的空缺。

语法与代码示例

基本错误检查

php
<?php

// 检查是否有文件上传
if (!isset($_FILES['avatar'])) {
    die('请选择要上传的文件');
}

$file = $_FILES['avatar'];

// 检查错误码
switch ($file['error']) {
    case UPLOAD_ERR_OK:
        // 上传成功,继续处理
        break;
    case UPLOAD_ERR_INI_SIZE:
        die('文件大小超过服务器限制(' . ini_get('upload_max_filesize') . ')');
    case UPLOAD_ERR_FORM_SIZE:
        die('文件大小超过表单限制');
    case UPLOAD_ERR_PARTIAL:
        die('文件上传不完整,请重试');
    case UPLOAD_ERR_NO_FILE:
        die('没有选择文件');
    case UPLOAD_ERR_NO_TMP_DIR:
        die('服务器配置错误:缺少临时目录');
    case UPLOAD_ERR_CANT_WRITE:
        die('服务器写入失败');
    case UPLOAD_ERR_EXTENSION:
        die('上传被服务器扩展阻止');
    default:
        die('未知上传错误');
}

错误消息映射类

php
<?php

declare(strict_types=1);

class UploadErrorHandler
{
    private const ERROR_MESSAGES = [
        UPLOAD_ERR_OK          => '上传成功',
        UPLOAD_ERR_INI_SIZE   => '文件超过服务器大小限制(最大 %s)',
        UPLOAD_ERR_FORM_SIZE  => '文件超过表单大小限制(最大 %s)',
        UPLOAD_ERR_PARTIAL    => '文件上传不完整,请重试',
        UPLOAD_ERR_NO_FILE    => '没有选择上传文件',
        UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录不存在',
        UPLOAD_ERR_CANT_WRITE => '服务器磁盘写入失败',
        UPLOAD_ERR_EXTENSION  => '上传被服务器扩展阻止',
    ];

    public static function getMessage(int $errorCode): string
    {
        $message = self::ERROR_MESSAGES[$errorCode] ?? '未知上传错误';

        // 替换占位符
        if (str_contains($message, '%s')) {
            $message = sprintf($message, ini_get('upload_max_filesize'));
        }

        return $message;
    }

    public static function hasError(array $file): bool
    {
        return ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK;
    }

    public static function check(array $file): void
    {
        $code = $file['error'] ?? UPLOAD_ERR_NO_FILE;

        if ($code !== UPLOAD_ERR_OK) {
            throw new UploadException($code, self::getMessage($code));
        }
    }
}

// 自定义上传异常
class UploadException extends RuntimeException
{
    public function __construct(int $errorCode, string $message, int $code = 0, ?Throwable $previous = null)
    {
        parent::__construct($message, $code, $previous);
        $this->code = $errorCode;
    }

    public function getErrorCode(): int
    {
        return $this->code;
    }

    public function isClientError(): bool
    {
        return in_array($this->code, [
            UPLOAD_ERR_INI_SIZE,
            UPLOAD_ERR_FORM_SIZE,
            UPLOAD_ERR_PARTIAL,
            UPLOAD_ERR_NO_FILE,
        ], true);
    }

    public function isServerError(): bool
    {
        return in_array($this->code, [
            UPLOAD_ERR_NO_TMP_DIR,
            UPLOAD_ERR_CANT_WRITE,
            UPLOAD_ERR_EXTENSION,
        ], true);
    }
}

批量上传错误处理

php
<?php

declare(strict_types=1);

class BatchUploadHandler
{
    private string $uploadDir;

    public function __construct(string $uploadDir)
    {
        $this->uploadDir = $uploadDir;
    }

    /**
     * 批量处理上传文件
     * @return array{success: array, failed: array}
     */
    public function handleMultiple(array $files): array
    {
        $result = ['success' => [], 'failed' => []];

        // 重新组织 $_FILES 数组
        $organized = $this->organizeFiles($files);

        foreach ($organized as $index => $file) {
            try {
                UploadErrorHandler::check($file);
                $path = $this->processFile($file);
                $result['success'][] = [
                    'index'    => $index,
                    'name'     => $file['name'],
                    'path'     => $path,
                    'size'     => $file['size'],
                ];
            } catch (UploadException $e) {
                $result['failed'][] = [
                    'index'    => $index,
                    'name'     => $file['name'] ?? 'unknown',
                    'error'    => $e->getMessage(),
                    'code'     => $e->getErrorCode(),
                ];
            }
        }

        return $result;
    }

    private function processFile(array $file): string
    {
        $ext = pathinfo($file['name'], PATHINFO_EXTENSION);
        $newName = bin2hex(random_bytes(8)) . '.' . $ext;
        $destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $newName;

        if (!move_uploaded_file($file['tmp_name'], $destPath)) {
            throw new RuntimeException('文件保存失败');
        }

        return $destPath;
    }

    private function organizeFiles(array $files): array
    {
        $organized = [];
        $count = count($files['name']);

        for ($i = 0; $i < $count; $i++) {
            $organized[$i] = [
                'name'     => $files['name'][$i],
                'tmp_name' => $files['tmp_name'][$i],
                'type'     => $files['type'][$i],
                'error'    => $files['error'][$i],
                'size'     => $files['size'][$i],
            ];
        }

        return $organized;
    }
}

// 使用示例
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $handler = new BatchUploadHandler('/var/www/html/uploads');
    $result = $handler->handleMultiple($_FILES['files']);

    echo "成功: " . count($result['success']) . " 个文件\n";
    foreach ($result['failed'] as $failed) {
        echo "失败: {$failed['name']} - {$failed['error']}\n";
    }
}

实战示例

返回 JSON 格式错误

php
<?php

declare(strict_types=1);

header('Content-Type: application/json; charset=utf-8');

function jsonResponse(array $data, int $code = 200): void
{
    http_response_code($code);
    echo json_encode($data, JSON_UNESCAPED_UNICODE);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    jsonResponse(['error' => 'Method Not Allowed'], 405);
}

if (!isset($_FILES['document'])) {
    jsonResponse(['error' => '请选择文件'], 400);
}

$file = $_FILES['document'];

// 错误检查
if ($file['error'] !== UPLOAD_ERR_OK) {
    $messages = [
        UPLOAD_ERR_INI_SIZE  => '文件过大,最大允许 ' . ini_get('upload_max_filesize'),
        UPLOAD_ERR_FORM_SIZE => '文件过大,超过表单限制',
        UPLOAD_ERR_PARTIAL   => '文件上传不完整',
        UPLOAD_ERR_NO_FILE   => '没有选择文件',
        UPLOAD_ERR_NO_TMP_DIR => '服务器配置错误',
        UPLOAD_ERR_CANT_WRITE => '服务器写入失败',
        UPLOAD_ERR_EXTENSION  => '上传被阻止',
    ];

    $message = $messages[$file['error']] ?? '未知错误';
    $httpCode = in_array($file['error'], [6, 7, 8], true) ? 500 : 400;
    jsonResponse(['error' => $message, 'code' => $file['error']], $httpCode);
}

// 验证大小(额外检查)
$maxSize = 5 * 1024 * 1024; // 5MB
if ($file['size'] > $maxSize) {
    jsonResponse(['error' => '文件超过 5MB 限制'], 400);
}

// 验证 MIME
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowed = ['application/pdf', 'image/jpeg', 'image/png'];

if (!in_array($mime, $allowed, true)) {
    jsonResponse(['error' => '不支持的文件类型', 'mime' => $mime], 400);
}

// 保存文件
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$newName = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$destDir = '/var/www/html/uploads/' . date('Y/m/d');

if (!is_dir($destDir)) {
    mkdir($destDir, 0755, true);
}

$destPath = $destDir . DIRECTORY_SEPARATOR . $newName;

if (move_uploaded_file($file['tmp_name'], $destPath)) {
    jsonResponse([
        'success'  => true,
        'filename' => $newName,
        'size'     => $file['size'],
        'mime'     => $mime,
    ]);
} else {
    jsonResponse(['error' => '文件保存失败'], 500);
}

上传错误日志记录

php
<?php

declare(strict_types=1);

class UploadLogger
{
    private string $logFile;

    public function __construct(string $logDir)
    {
        $this->logFile = $logDir . DIRECTORY_SEPARATOR . 'upload_errors.log';
    }

    public function logError(array $file, string $clientIp, string $userId = ''): void
    {
        $entry = [
            'timestamp' => date('Y-m-d H:i:s'),
            'ip'        => $clientIp,
            'user_id'   => $userId,
            'error_code'=> $file['error'],
            'file_name' => $file['name'] ?? '',
            'file_size' => $file['size'] ?? 0,
            'mime_type' => $file['type'] ?? '',
            'message'   => $this->codeToMessage($file['error']),
        ];

        $line = json_encode($entry, JSON_UNESCAPED_UNICODE) . PHP_EOL;
        file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
    }

    private function codeToMessage(int $code): string
    {
        $map = [
            UPLOAD_ERR_INI_SIZE   => 'EXCEEDED_UPLOAD_MAX_FILESIZE',
            UPLOAD_ERR_FORM_SIZE  => 'EXCEEDED_FORM_MAX_FILE_SIZE',
            UPLOAD_ERR_PARTIAL    => 'PARTIAL_UPLOAD',
            UPLOAD_ERR_NO_FILE    => 'NO_FILE_UPLOADED',
            UPLOAD_ERR_NO_TMP_DIR => 'MISSING_TEMP_DIR',
            UPLOAD_ERR_CANT_WRITE => 'DISK_WRITE_FAILED',
            UPLOAD_ERR_EXTENSION  => 'EXTENSION_BLOCKED',
        ];
        return $map[$code] ?? "UNKNOWN_ERROR({$code})";
    }
}

// 使用
$logger = new UploadLogger('/var/www/html/logs');
if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
    $logger->logError($_FILES['avatar'], $_SERVER['REMOTE_ADDR'], 'user_123');
}

注意事项

UPLOAD_ERR_NO_FILE 的特殊情况

php
<?php

// 用户提交空表单时 error 为 UPLOAD_ERR_NO_FILE
// 但 $_FILES 仍然可能被设置

// HTML 中 input[type=file] 是可选的
// 即使用户没有选文件,submit 后 $_FILES['name']['error'] 仍为 4

if (!isset($_FILES['avatar']) || $_FILES['avatar']['error'] === UPLOAD_ERR_NO_FILE) {
    // 用户没有上传文件(这不是错误)
    echo "未上传文件\n";
}

PHP 配置导致的大小限制

php
<?php

// 常见错误:upload_max_filesize > post_max_size
// 导致文件还没开始上传就被 POST 大小限制拦截

// 检查配置是否合理
function checkUploadConfig(): array
{
    $uploadMax = parseSize(ini_get('upload_max_filesize'));
    $postMax = parseSize(ini_get('post_max_size'));

    $warnings = [];

    if ($uploadMax > $postMax) {
        $warnings[] = "upload_max_filesize ({$uploadMax}) 大于 post_max_size ({$postMax})";
    }

    if ($postMax > parseSize(ini_get('memory_limit'))) {
        $warnings[] = "post_max_size 大于 memory_limit";
    }

    return $warnings;
}

function parseSize(string $size): int
{
    $unit = strtolower($size[-1]);
    $value = (int)$size;
    return match ($unit) {
        'g' => $value * 1073741824,
        'm' => $value * 1048576,
        'k' => $value * 1024,
        default => $value,
    };
}

最佳实践

1. 分层错误处理

php
<?php

function uploadWithErrorHandling(array $file): ?string
{
    try {
        // 验证层
        validateUploadedFile($file);

        // 安全部
        sanitizeUploadedFile($file);

        // 存储层
        return storeUploadedFile($file);

    } catch (UploadException $e) {
        if ($e->isClientError()) {
            // 用户可修复的错误
            error_log("Upload client error: {$e->getMessage()}");
            return null;
        }

        // 服务器错误
        error_log("Upload server error: {$e->getMessage()}");
        throw $e;
    }
}

2. 友好的前端错误提示

php
<?php

// 返回结构化错误信息
function formatUploadError(int $code): array
{
    return [
        'success' => false,
        'error' => [
            'code'    => $code,
            'type'    => match ($code) {
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'file_too_large',
                UPLOAD_ERR_PARTIAL, UPLOAD_ERR_NO_FILE => 'upload_failed',
                default => 'server_error',
            },
            'message' => UploadErrorHandler::getMessage($code),
            'max_size' => ini_get('upload_max_filesize'),
        ],
    ];
}

3. 监控上传错误率

php
<?php

// 使用 Redis 记录上传错误率
function trackUploadError(int $errorCode): void
{
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);

    $dateKey = 'upload:errors:' . date('Y-m-d');
    $codeKey = "upload:errors:" . date('Y-m-d') . ":code:{$errorCode}";

    $redis->incr($dateKey);
    $redis->incr($codeKey);
    $redis->expire($dateKey, 86400 * 7);
    $redis->expire($codeKey, 86400 * 7);
}

参考链接