Skip to content

文件存储

DuxLite 提供了统一的文件存储接口,支持本地存储和 S3 兼容存储(如 AWS S3、阿里云 OSS、腾讯云 COS 等)。存储系统提供了文件读写、URL 生成、签名上传等功能,可以轻松应对各种文件管理需求。

系统概述

存储架构

DuxLite 的存储系统采用统一接口、多驱动架构:

应用层 → StorageInterface → 存储驱动 → 存储后端(本地/S3)

核心组件

  • Storage:存储管理器,统一存储接口
  • LocalDriver:本地文件存储驱动
  • S3Driver:S3 兼容存储驱动
  • StorageInterface:标准化的存储操作接口

存储配置

配置文件设置

存储配置在 config/storage.toml 文件中:

toml
# 默认存储类型
type = "local"

# 本地存储驱动配置
[drivers.local]
type = "local"
# 存储根目录(相对于项目根目录)
root = "data/uploads"
# 公共访问域名
domain = "http://localhost:8000"
# URL 路径前缀
path = "uploads"

# S3 兼容存储驱动配置
[drivers.s3]
type = "s3"
# S3 存储桶名称
bucket = "my-bucket"
# 自定义访问域名(可选)
domain = "https://cdn.example.com"
# API 端点
endpoint = "s3.amazonaws.com"
# 区域
region = "us-east-1"
# 是否使用 SSL
ssl = true
# API 版本
version = "latest"
# 访问密钥
access_key = "your-access-key"
# 密钥
secret_key = "your-secret-key"
# 是否为不可变存储
immutable = false

# 阿里云 OSS 配置示例
[drivers.oss]
type = "s3"
bucket = "my-oss-bucket"
domain = "https://my-oss-bucket.oss-cn-hangzhou.aliyuncs.com"
endpoint = "oss-cn-hangzhou.aliyuncs.com"
region = "oss-cn-hangzhou"
ssl = true
version = "latest"
access_key = "your-access-key-id"
secret_key = "your-access-key-secret"

获取存储实例

php
use Core\App;

// 获取默认存储(从 storage.toml 读取类型)
$storage = App::storage();

// 获取指定类型的存储
$localStorage = App::storage('local');
$s3Storage = App::storage('s3');
$ossStorage = App::storage('oss');

基础文件操作

文件写入

php
use Core\App;

class FileService
{
    private $storage;

    public function __construct()
    {
        $this->storage = App::storage();
    }

    public function saveFile(string $path, string $content): bool
    {
        // 写入文件内容
        return $this->storage->write($path, $content);
    }

    public function saveFileFromUpload($uploadedFile, string $directory = 'uploads'): string
    {
        // 生成唯一文件名
        $extension = pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION);
        $filename = uniqid() . '.' . $extension;
        $path = $directory . '/' . date('Y/m/d') . '/' . $filename;

        // 获取文件流
        $stream = $uploadedFile->getStream();

        // 写入存储
        $this->storage->writeStream($path, $stream->detach());

        return $path;
    }

    public function saveFromStream(string $path, $resource): bool
    {
        // 从资源流写入文件
        return $this->storage->writeStream($path, $resource);
    }
}

文件读取

php
class FileService
{
    public function getFileContent(string $path): string
    {
        // 读取文件内容
        return $this->storage->read($path);
    }

    public function getFileStream(string $path)
    {
        // 获取文件流
        return $this->storage->readStream($path);
    }

    public function downloadFile(string $path, ResponseInterface $response): ResponseInterface
    {
        if (!$this->storage->exists($path)) {
            throw new ExceptionNotFound('文件不存在');
        }

        // 获取文件流
        $stream = $this->storage->readStream($path);

        // 获取文件信息
        $size = $this->storage->size($path);
        $filename = basename($path);

        // 创建流响应
        $body = new \Slim\Psr7\Stream($stream);

        return $response
            ->withBody($body)
            ->withHeader('Content-Type', 'application/octet-stream')
            ->withHeader('Content-Length', (string)$size)
            ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
    }
}

文件信息

php
class FileService
{
    public function getFileInfo(string $path): array
    {
        if (!$this->storage->exists($path)) {
            throw new ExceptionNotFound('文件不存在');
        }

        return [
            'path' => $path,
            'size' => $this->storage->size($path),
            'exists' => true,
            'public_url' => $this->storage->publicUrl($path),
            'is_local' => $this->storage->isLocal()
        ];
    }

    public function deleteFile(string $path): bool
    {
        return $this->storage->delete($path);
    }

    public function fileExists(string $path): bool
    {
        return $this->storage->exists($path);
    }
}

文件上传处理

表单文件上传

php
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;

class UploadController
{
    private $storage;

    public function __construct()
    {
        $this->storage = App::storage();
    }

    public function uploadAvatar(
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $args
    ): ResponseInterface {
        $uploadedFiles = $request->getUploadedFiles();

        if (!isset($uploadedFiles['avatar'])) {
            throw new ExceptionBusiness('请选择头像文件', 400);
        }

        $uploadedFile = $uploadedFiles['avatar'];

        // 验证上传错误
        if ($uploadedFile->getError() !== UPLOAD_ERR_OK) {
            throw new ExceptionBusiness('文件上传失败', 400);
        }

        // 验证文件类型
        $allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
        $mediaType = $uploadedFile->getClientMediaType();

        if (!in_array($mediaType, $allowedTypes)) {
            throw new ExceptionBusiness('不支持的文件类型', 400);
        }

        // 验证文件大小(5MB)
        $maxSize = 5 * 1024 * 1024;
        if ($uploadedFile->getSize() > $maxSize) {
            throw new ExceptionBusiness('文件大小超过限制', 400);
        }

        // 生成存储路径
        $extension = pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION);
        $filename = uniqid('avatar_') . '.' . $extension;
        $path = 'avatars/' . date('Y/m') . '/' . $filename;

        // 保存文件
        $stream = $uploadedFile->getStream();
        $this->storage->writeStream($path, $stream->detach());

        // 返回文件信息
        return send($response, '上传成功', [
            'path' => $path,
            'url' => $this->storage->publicUrl($path),
            'size' => $uploadedFile->getSize(),
            'original_name' => $uploadedFile->getClientFilename()
        ]);
    }

    public function uploadMultiple(
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $args
    ): ResponseInterface {
        $uploadedFiles = $request->getUploadedFiles();

        if (!isset($uploadedFiles['documents']) || !is_array($uploadedFiles['documents'])) {
            throw new ExceptionBusiness('请选择文件', 400);
        }

        $results = [];

        foreach ($uploadedFiles['documents'] as $uploadedFile) {
            if ($uploadedFile->getError() === UPLOAD_ERR_OK) {
                // 验证和保存文件
                $path = $this->saveUploadedFile($uploadedFile, 'documents');

                $results[] = [
                    'path' => $path,
                    'url' => $this->storage->publicUrl($path),
                    'size' => $uploadedFile->getSize(),
                    'original_name' => $uploadedFile->getClientFilename()
                ];
            }
        }

        return send($response, '批量上传成功', $results);
    }

    private function saveUploadedFile($uploadedFile, string $directory): string
    {
        $extension = pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION);
        $filename = uniqid() . '.' . $extension;
        $path = $directory . '/' . date('Y/m/d') . '/' . $filename;

        $stream = $uploadedFile->getStream();
        $this->storage->writeStream($path, $stream->detach());

        return $path;
    }
}

图片处理上传

php
class ImageUploadService
{
    private $storage;
    private $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];

    public function __construct()
    {
        $this->storage = App::storage();
    }

    public function uploadAndResize($uploadedFile, int $maxWidth = 800, int $maxHeight = 600): array
    {
        // 验证图片
        $this->validateImage($uploadedFile);

        // 创建图片资源
        $imageData = $uploadedFile->getStream()->getContents();
        $image = imagecreatefromstring($imageData);

        if (!$image) {
            throw new ExceptionBusiness('无法处理图片文件', 400);
        }

        // 获取原始尺寸
        $originalWidth = imagesx($image);
        $originalHeight = imagesy($image);

        // 计算新尺寸
        $ratio = min($maxWidth / $originalWidth, $maxHeight / $originalHeight);
        $newWidth = (int)($originalWidth * $ratio);
        $newHeight = (int)($originalHeight * $ratio);

        // 创建新图片
        $resizedImage = imagecreatetruecolor($newWidth, $newHeight);

        // 保持透明度(PNG/GIF)
        if (in_array($uploadedFile->getClientMediaType(), ['image/png', 'image/gif'])) {
            imagealphablending($resizedImage, false);
            imagesavealpha($resizedImage, true);
        }

        // 缩放图片
        imagecopyresampled(
            $resizedImage, $image,
            0, 0, 0, 0,
            $newWidth, $newHeight,
            $originalWidth, $originalHeight
        );

        // 生成存储路径
        $extension = pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION);
        $filename = uniqid('img_') . '.' . $extension;
        $path = 'images/' . date('Y/m') . '/' . $filename;

        // 输出到缓冲区
        ob_start();
        switch ($uploadedFile->getClientMediaType()) {
            case 'image/jpeg':
                imagejpeg($resizedImage, null, 90);
                break;
            case 'image/png':
                imagepng($resizedImage);
                break;
            case 'image/gif':
                imagegif($resizedImage);
                break;
            case 'image/webp':
                imagewebp($resizedImage, null, 90);
                break;
        }
        $processedImageData = ob_get_clean();

        // 清理资源
        imagedestroy($image);
        imagedestroy($resizedImage);

        // 保存处理后的图片
        $this->storage->write($path, $processedImageData);

        return [
            'path' => $path,
            'url' => $this->storage->publicUrl($path),
            'width' => $newWidth,
            'height' => $newHeight,
            'size' => strlen($processedImageData),
            'original_size' => $uploadedFile->getSize()
        ];
    }

    public function generateThumbnails($uploadedFile, array $sizes = []): array
    {
        if (empty($sizes)) {
            $sizes = [
                'thumb' => [150, 150],
                'medium' => [300, 300],
                'large' => [800, 600]
            ];
        }

        $this->validateImage($uploadedFile);

        $imageData = $uploadedFile->getStream()->getContents();
        $image = imagecreatefromstring($imageData);

        $results = [];

        foreach ($sizes as $sizeKey => [$width, $height]) {
            $thumbnailData = $this->resizeImage($image, $width, $height, $uploadedFile->getClientMediaType());

            $filename = uniqid($sizeKey . '_') . '.jpg';
            $path = 'thumbnails/' . date('Y/m') . '/' . $filename;

            $this->storage->write($path, $thumbnailData);

            $results[$sizeKey] = [
                'path' => $path,
                'url' => $this->storage->publicUrl($path),
                'width' => $width,
                'height' => $height
            ];
        }

        imagedestroy($image);

        return $results;
    }

    private function validateImage($uploadedFile): void
    {
        if ($uploadedFile->getError() !== UPLOAD_ERR_OK) {
            throw new ExceptionBusiness('文件上传失败', 400);
        }

        if (!in_array($uploadedFile->getClientMediaType(), $this->allowedTypes)) {
            throw new ExceptionBusiness('不支持的图片格式', 400);
        }

        if ($uploadedFile->getSize() > 10 * 1024 * 1024) { // 10MB
            throw new ExceptionBusiness('图片文件过大', 400);
        }
    }

    private function resizeImage($image, int $width, int $height, string $mimeType): string
    {
        $originalWidth = imagesx($image);
        $originalHeight = imagesy($image);

        $ratio = min($width / $originalWidth, $height / $originalHeight);
        $newWidth = (int)($originalWidth * $ratio);
        $newHeight = (int)($originalHeight * $ratio);

        $resizedImage = imagecreatetruecolor($newWidth, $newHeight);
        imagecopyresampled(
            $resizedImage, $image,
            0, 0, 0, 0,
            $newWidth, $newHeight,
            $originalWidth, $originalHeight
        );

        ob_start();
        imagejpeg($resizedImage, null, 85);
        $imageData = ob_get_clean();

        imagedestroy($resizedImage);

        return $imageData;
    }
}

URL 生成和访问

公共 URL

php
class FileUrlService
{
    private $storage;

    public function __construct()
    {
        $this->storage = App::storage();
    }

    public function getPublicUrl(string $path): string
    {
        // 获取公共访问 URL
        return $this->storage->publicUrl($path);
    }

    public function getImageUrl(string $path, array $params = []): string
    {
        $url = $this->storage->publicUrl($path);

        // 添加图片处理参数(如果支持)
        if (!empty($params)) {
            $queryString = http_build_query($params);
            $separator = strpos($url, '?') !== false ? '&' : '?';
            $url .= $separator . $queryString;
        }

        return $url;
    }

    public function generateImageUrls(string $basePath): array
    {
        $baseUrl = $this->storage->publicUrl($basePath);

        return [
            'original' => $baseUrl,
            'thumb' => $this->getImageUrl($basePath, ['w' => 150, 'h' => 150]),
            'medium' => $this->getImageUrl($basePath, ['w' => 300, 'h' => 300]),
            'large' => $this->getImageUrl($basePath, ['w' => 800, 'h' => 600])
        ];
    }
}

私有 URL 和签名访问

php
class SecureFileService
{
    private $storage;

    public function __construct()
    {
        $this->storage = App::storage();
    }

    public function getPrivateUrl(string $path, int $expires = 3600): string
    {
        // 生成有时效的私有访问 URL
        return $this->storage->privateUrl($path, $expires);
    }

    public function generateDownloadLink(string $path, string $filename = null): string
    {
        $filename = $filename ?: basename($path);

        // 生成 1 小时有效的下载链接
        $url = $this->storage->privateUrl($path, 3600);

        // 添加下载文件名参数
        $separator = strpos($url, '?') !== false ? '&' : '?';
        return $url . $separator . 'response-content-disposition=attachment;filename=' . urlencode($filename);
    }

    public function getSignedUploadUrl(string $path): array
    {
        // 获取签名上传 URL(用于前端直传)
        return $this->storage->signPostUrl($path);
    }

    public function getSignedPutUrl(string $path): string
    {
        // 获取 PUT 方式的签名上传 URL
        return $this->storage->signPutUrl($path);
    }
}

前端直传

POST 方式直传

php
class DirectUploadController
{
    public function getUploadToken(
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $args
    ): ResponseInterface {
        $storage = App::storage();

        // 生成上传路径
        $userId = $request->getAttribute('user_id');
        $path = 'uploads/' . $userId . '/' . uniqid() . '.tmp';

        // 获取签名上传信息
        $uploadInfo = $storage->signPostUrl($path);

        return send($response, '获取上传令牌成功', [
            'upload_url' => $uploadInfo['url'],
            'form_data' => $uploadInfo['params'],
            'path' => $path,
            'expires_in' => 3600
        ]);
    }

    public function confirmUpload(
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $args
    ): ResponseInterface {
        $data = $request->getParsedBody();
        $path = $data['path'] ?? '';

        if (!$path) {
            throw new ExceptionBusiness('缺少文件路径', 400);
        }

        $storage = App::storage();

        // 验证文件是否上传成功
        if (!$storage->exists($path)) {
            throw new ExceptionBusiness('文件上传失败', 400);
        }

        // 移动到正式目录
        $content = $storage->read($path);
        $storage->delete($path); // 删除临时文件

        $finalPath = str_replace('.tmp', '.dat', $path);
        $storage->write($finalPath, $content);

        return send($response, '确认上传成功', [
            'path' => $finalPath,
            'url' => $storage->publicUrl($finalPath),
            'size' => strlen($content)
        ]);
    }
}

PUT 方式直传

php
class PutUploadController
{
    public function getPutUploadUrl(
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $args
    ): ResponseInterface {
        $storage = App::storage();

        $data = $request->getParsedBody();
        $filename = $data['filename'] ?? 'upload';
        $contentType = $data['content_type'] ?? 'application/octet-stream';

        // 生成上传路径
        $path = 'uploads/' . date('Y/m/d') . '/' . uniqid() . '_' . $filename;

        // 获取 PUT 上传 URL
        $uploadUrl = $storage->signPutUrl($path);

        return send($response, '获取上传URL成功', [
            'upload_url' => $uploadUrl,
            'path' => $path,
            'method' => 'PUT',
            'headers' => [
                'Content-Type' => $contentType
            ]
        ]);
    }
}

文件管理功能

文件分类管理

php
class FileManagerService
{
    private $storage;

    public function __construct()
    {
        $this->storage = App::storage();
    }

    public function createFileCategory(string $category): bool
    {
        $categoryPath = 'categories/' . $category . '/.gitkeep';
        return $this->storage->write($categoryPath, '');
    }

    public function moveFileToCategory(string $filePath, string $category): string
    {
        if (!$this->storage->exists($filePath)) {
            throw new ExceptionNotFound('文件不存在');
        }

        // 读取原文件
        $content = $this->storage->read($filePath);

        // 生成新路径
        $filename = basename($filePath);
        $newPath = 'categories/' . $category . '/' . $filename;

        // 写入新位置
        $this->storage->write($newPath, $content);

        // 删除原文件
        $this->storage->delete($filePath);

        return $newPath;
    }

    public function copyFile(string $sourcePath, string $destinationPath): bool
    {
        if (!$this->storage->exists($sourcePath)) {
            throw new ExceptionNotFound('源文件不存在');
        }

        $content = $this->storage->read($sourcePath);
        return $this->storage->write($destinationPath, $content);
    }

    public function getFilesByPattern(string $pattern): array
    {
        // 这个功能需要根据具体存储实现
        // 本地存储可以使用 glob,云存储需要使用 API 搜索
        if ($this->storage->isLocal()) {
            return $this->searchLocalFiles($pattern);
        } else {
            return $this->searchCloudFiles($pattern);
        }
    }

    private function searchLocalFiles(string $pattern): array
    {
        // 本地文件搜索实现
        return [];
    }

    private function searchCloudFiles(string $pattern): array
    {
        // 云存储文件搜索实现
        return [];
    }
}

文件清理和维护

php
use Core\Scheduler\Attribute\Scheduler;

class FileMaintenanceTasks
{
    private $storage;

    public function __construct()
    {
        $this->storage = App::storage();
    }

    #[Scheduler('0 2 * * *')]  // 每天凌晨2点
    public function cleanTempFiles(): void
    {
        $tempPath = 'temp/';
        $cutoffTime = time() - (24 * 60 * 60); // 24小时前

        // 这里需要根据存储类型实现文件清理
        $this->cleanFilesByAge($tempPath, $cutoffTime);

        error_log("临时文件清理完成: " . date('Y-m-d H:i:s'));
    }

    #[Scheduler('0 3 * * 0')]  // 每周日凌晨3点
    public function optimizeStorage(): void
    {
        // 清理孤儿文件(数据库中不存在记录的文件)
        $this->cleanOrphanFiles();

        // 压缩旧文件
        $this->compressOldFiles();

        error_log("存储优化完成: " . date('Y-m-d H:i:s'));
    }

    private function cleanFilesByAge(string $path, int $cutoffTime): void
    {
        // 根据存储类型实现文件清理逻辑
        if ($this->storage->isLocal()) {
            $this->cleanLocalFilesByAge($path, $cutoffTime);
        } else {
            $this->cleanCloudFilesByAge($path, $cutoffTime);
        }
    }

    private function cleanLocalFilesByAge(string $path, int $cutoffTime): void
    {
        // 本地文件清理实现
        $fullPath = $this->getLocalStoragePath() . '/' . $path;

        if (!is_dir($fullPath)) {
            return;
        }

        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($fullPath)
        );

        foreach ($iterator as $file) {
            if ($file->isFile() && $file->getMTime() < $cutoffTime) {
                unlink($file->getPathname());
            }
        }
    }

    private function cleanCloudFilesByAge(string $path, int $cutoffTime): void
    {
        // 云存储文件清理实现
        // 需要使用云存储 API 查询和删除文件
    }

    private function cleanOrphanFiles(): void
    {
        // 获取数据库中的文件记录
        $dbFiles = DB::table('files')->pluck('path')->toArray();

        // 与存储中的文件对比,删除孤儿文件
        // 具体实现需要根据存储类型和业务需求
    }

    private function compressOldFiles(): void
    {
        // 压缩30天前的文件
        $cutoffTime = time() - (30 * 24 * 60 * 60);

        // 实现文件压缩逻辑
    }

    private function getLocalStoragePath(): string
    {
        // 获取本地存储路径
        return base_path('data/uploads');
    }
}

与其他系统集成

与数据库模型集成

php
trait HasFiles
{
    public function files()
    {
        return $this->morphMany(File::class, 'fileable');
    }

    public function addFile(string $path, array $metadata = []): File
    {
        $storage = App::storage();

        if (!$storage->exists($path)) {
            throw new ExceptionNotFound('文件不存在');
        }

        return $this->files()->create([
            'path' => $path,
            'filename' => basename($path),
            'size' => $storage->size($path),
            'url' => $storage->publicUrl($path),
            'metadata' => $metadata
        ]);
    }

    public function getFileUrl(string $type = 'default'): ?string
    {
        $file = $this->files()->where('type', $type)->first();
        return $file ? $file->url : null;
    }

    public function deleteFiles(): void
    {
        $storage = App::storage();

        foreach ($this->files as $file) {
            if ($storage->exists($file->path)) {
                $storage->delete($file->path);
            }
            $file->delete();
        }
    }
}

class File extends Model
{
    protected $fillable = [
        'fileable_type',
        'fileable_id',
        'path',
        'filename',
        'size',
        'url',
        'type',
        'metadata'
    ];

    protected $casts = [
        'metadata' => 'array'
    ];

    public function fileable()
    {
        return $this->morphTo();
    }

    public function getFormattedSizeAttribute(): string
    {
        $bytes = $this->size;
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];

        for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
            $bytes /= 1024;
        }

        return round($bytes, 2) . ' ' . $units[$i];
    }
}

// 使用示例
class User extends Model
{
    use HasFiles;

    public function setAvatar($uploadedFile): void
    {
        $service = new ImageUploadService();
        $result = $service->uploadAndResize($uploadedFile, 300, 300);

        // 删除旧头像
        $this->deleteFiles();

        // 添加新头像
        $this->addFile($result['path'], [
            'type' => 'avatar',
            'width' => $result['width'],
            'height' => $result['height']
        ]);
    }
}

与缓存系统集成

php
class CachedFileService
{
    private $storage;
    private $cache;

    public function __construct()
    {
        $this->storage = App::storage();
        $this->cache = App::cache();
    }

    public function getCachedFileContent(string $path): string
    {
        $cacheKey = 'file_content:' . md5($path);

        $content = $this->cache->get($cacheKey);

        if ($content === null) {
            $content = $this->storage->read($path);
            $this->cache->set($cacheKey, $content, 3600); // 缓存1小时
        }

        return $content;
    }

    public function getCachedFileInfo(string $path): array
    {
        $cacheKey = 'file_info:' . md5($path);

        $info = $this->cache->get($cacheKey);

        if ($info === null) {
            $info = [
                'exists' => $this->storage->exists($path),
                'size' => $this->storage->exists($path) ? $this->storage->size($path) : 0,
                'url' => $this->storage->publicUrl($path)
            ];

            $this->cache->set($cacheKey, $info, 1800); // 缓存30分钟
        }

        return $info;
    }

    public function invalidateFileCache(string $path): void
    {
        $contentKey = 'file_content:' . md5($path);
        $infoKey = 'file_info:' . md5($path);

        $this->cache->deleteMultiple([$contentKey, $infoKey]);
    }
}

最佳实践

文件命名策略

php
class FileNamingService
{
    public function generateSecurePath(string $originalFilename, string $category = 'uploads'): string
    {
        $extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
        $extension = strtolower($extension);

        // 生成安全的文件名
        $filename = uniqid() . '_' . time() . '.' . $extension;

        // 按日期分目录
        $datePath = date('Y/m/d');

        return $category . '/' . $datePath . '/' . $filename;
    }

    public function generateContentHashPath(string $content, string $extension): string
    {
        $hash = hash('sha256', $content);

        // 使用内容哈希避免重复存储
        return 'content/' . substr($hash, 0, 2) . '/' . substr($hash, 2, 2) . '/' . $hash . '.' . $extension;
    }

    public function generateUserPath(int $userId, string $filename): string
    {
        $extension = pathinfo($filename, PATHINFO_EXTENSION);
        $safeFilename = preg_replace('/[^a-zA-Z0-9._-]/', '_', pathinfo($filename, PATHINFO_FILENAME));

        return 'users/' . $userId . '/' . $safeFilename . '.' . $extension;
    }
}

文件安全检查

php
class FileSecurityService
{
    private array $allowedExtensions = [
        'image' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
        'document' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'],
        'archive' => ['zip', 'rar', '7z'],
        'text' => ['txt', 'csv']
    ];

    private array $dangerousExtensions = [
        'php', 'php3', 'php4', 'php5', 'phtml',
        'exe', 'com', 'bat', 'cmd',
        'js', 'vbs', 'jar'
    ];

    public function validateFile($uploadedFile, string $category = 'general'): void
    {
        // 检查上传错误
        if ($uploadedFile->getError() !== UPLOAD_ERR_OK) {
            throw new ExceptionBusiness('文件上传失败', 400);
        }

        // 检查文件大小
        $maxSize = $this->getMaxSizeForCategory($category);
        if ($uploadedFile->getSize() > $maxSize) {
            throw new ExceptionBusiness('文件大小超过限制', 400);
        }

        // 检查文件扩展名
        $filename = $uploadedFile->getClientFilename();
        $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

        if (in_array($extension, $this->dangerousExtensions)) {
            throw new ExceptionBusiness('不允许上传此类型文件', 400);
        }

        if (isset($this->allowedExtensions[$category])) {
            if (!in_array($extension, $this->allowedExtensions[$category])) {
                throw new ExceptionBusiness('不支持的文件类型', 400);
            }
        }

        // 检查 MIME 类型
        $this->validateMimeType($uploadedFile, $extension);

        // 检查文件内容
        $this->scanFileContent($uploadedFile);
    }

    private function validateMimeType($uploadedFile, string $extension): void
    {
        $mimeType = $uploadedFile->getClientMediaType();
        $expectedMimes = $this->getExpectedMimeTypes($extension);

        if (!empty($expectedMimes) && !in_array($mimeType, $expectedMimes)) {
            throw new ExceptionBusiness('文件类型与扩展名不匹配', 400);
        }
    }

    private function scanFileContent($uploadedFile): void
    {
        $content = $uploadedFile->getStream()->getContents();

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

        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $content)) {
                throw new ExceptionBusiness('文件包含恶意内容', 400);
            }
        }
    }

    private function getMaxSizeForCategory(string $category): int
    {
        return match ($category) {
            'image' => 10 * 1024 * 1024,      // 10MB
            'document' => 50 * 1024 * 1024,   // 50MB
            'archive' => 100 * 1024 * 1024,   // 100MB
            default => 20 * 1024 * 1024       // 20MB
        };
    }

    private function getExpectedMimeTypes(string $extension): array
    {
        return match ($extension) {
            'jpg', 'jpeg' => ['image/jpeg'],
            'png' => ['image/png'],
            'gif' => ['image/gif'],
            'pdf' => ['application/pdf'],
            'zip' => ['application/zip'],
            default => []
        };
    }
}

故障排除

常见问题诊断

1. 存储连接问题

bash
# 测试本地存储
ls -la data/uploads/

# 测试 S3 存储连接
aws s3 ls s3://your-bucket/ --region your-region

# 测试存储配置
php -r "
$storage = \Core\App::storage();
var_dump($storage->isLocal());
"

2. 文件上传问题

php
// 检查 PHP 上传配置
function checkUploadConfig(): array
{
    return [
        'upload_max_filesize' => ini_get('upload_max_filesize'),
        'post_max_size' => ini_get('post_max_size'),
        'max_execution_time' => ini_get('max_execution_time'),
        'memory_limit' => ini_get('memory_limit')
    ];
}

3. 权限问题

bash
# 检查本地存储目录权限
ls -la data/uploads/
chmod -R 755 data/uploads/
chown -R www-data:www-data data/uploads/

DuxLite 的文件存储系统为应用程序提供了强大的文件管理能力,支持多种存储后端,可以满足从简单文件上传到复杂文件处理的各种需求。

基于 MIT 许可证发布