// +---------------------------------------------------------------------- namespace ywxapp\service; use think\Exception; use think\facade\Config; use think\facade\Event; use think\facade\Log; use think\file\UploadedFile; use ywxapp\model\CommonAttachment; use ywxapp\utils\storage\StorageInterface; /** * FileStorageService 类 * * @author ywxapp */ class FileStorageService { // 驱动常量 const DRIVER_LOCAL = 'local'; const DRIVER_ALIOSS = 'alioss'; const DRIVER_QCOS = 'qcos'; protected $config; protected $storage; /** * 构造函数,读取配置并初始化驱动 */ public function __construct() { // 读取配置文件 $this->config = Config::get('filesystem', []); // 获取当前配置的驱动名称 $driverName = $this->config['default'] ?? self::DRIVER_LOCAL; // 实例化对应的驱动 $this->initDriver($driverName); } /** * 初始化存储驱动 */ protected function initDriver(string $driverName): void { $drivers = $this->config['disks'] ?? []; if (! isset($drivers[$driverName])) { throw new Exception("存储驱动配置不存在: {$driverName}"); } $config = $drivers[$driverName]; $class = $config['class']; if (! class_exists($class)) { throw new Exception("存储驱动类不存在: {$class}"); } $this->storage = new $class($config); if (! $this->storage instanceof StorageInterface) { throw new Exception("存储驱动必须实现 StorageInterface 接口"); } } /** * 上传文件(对外调用的方法) * * 提供两个事件扩展点: * - FileUpload 前置分发事件:插件监听并返回结果数组即接管上传(OSS / FTP / 自定义存储) * - FileUploaded 后置通知事件:上传成功后触发,供缩略图、水印、审计等扩展 * * @param UploadedFile $file 上传的文件对象 * @param string $path 存储路径,留空自动生成 * @param array $options ['module' => 模块标识, 'rules' => 自定义校验规则(空数组=不校验)] * @return array */ public function upload(UploadedFile $file, string $path = '', array $options = []): array { // 1. 基础验证(文件大小、类型,可用 options.rules 自定义) $this->validateFile($file, $options); // 2. 未指定路径时生成默认路径 if (empty($path)) { $path = $this->generatePath($file); } // 3. 前置事件 FileUpload:插件可接管上传,返回结果数组即接管成功 $handled = Event::trigger('FileUpload', [ 'file' => $file, 'path' => $path, 'options' => $options, ], true); if ($handled) { $data = $this->buildMeta($file, $handled, $options); return $this->finalize($data, $options); } // 4. 调用内置存储驱动上传 list($name, $path, $url) = $this->storage->upload($file, $path, $options); $data = $this->buildMeta($file, [ 'name' => $name, 'path' => $path, 'url' => $url, 'storage' => $this->config['default'] ?? self::DRIVER_LOCAL, ], $options); // 5. 写入附件记录 + 后置事件 FileUploaded return $this->finalize($data, $options); } /** * 上传收尾:写入附件表记录、触发 FileUploaded 事件、返回带 id 的数据 */ protected function finalize(array $data, array $options): array { $data = $this->recordAttachment($data, $options); Event::trigger('FileUploaded', $data); return $data; } /** * 写入附件表记录(落库失败不影响上传主流程) */ protected function recordAttachment(array $data, array $options): array { try { $row = CommonAttachment::create([ 'uid' => (int) ($options['uid'] ?? 0), 'module' => (string) ($data['module'] ?? 'index'), 'path' => (string) ($data['path'] ?? ''), 'url' => (string) ($data['url'] ?? ''), 'original' => (string) ($data['original'] ?? ''), 'name' => (string) ($data['name'] ?? ''), 'size' => (int) ($data['size'] ?? 0), 'ext' => (string) ($data['ext'] ?? ''), 'mime' => (string) ($data['mime'] ?? ''), 'storage' => (string) ($data['storage'] ?? self::DRIVER_LOCAL), 'driver_info' => $data['driver_info'] ?? [], 'is_image' => (int) ($data['is_image'] ?? 0), 'width' => (int) ($data['width'] ?? 0), 'height' => (int) ($data['height'] ?? 0), 'upload_ip' => (string) ($data['upload_ip'] ?? ''), ]); $data['id'] = (int) $row->id; } catch (\Throwable $e) { Log::error('附件落库失败: ' . $e->getMessage()); } return $data; } /** * 组装上传元数据 * * @param UploadedFile $file 上传的文件对象 * @param array $extra 附加数据(name / path / url / storage 等),覆盖默认元数据 * @param array $options 上传选项 * @return array */ protected function buildMeta(UploadedFile $file, array $extra, array $options): array { $data = [ 'module' => $options['module'] ?? 'index', // 模块标识 'original' => $file->getOriginalName(), 'size' => $file->getSize(), 'ext' => $file->extension(), 'mime' => $file->getMime(), 'is_image' => (int) str_starts_with($file->getMime(), 'image/'), 'upload_ip' => request()->ip(), ]; // 图片时尝试获取尺寸 if ($data['is_image'] && function_exists('getimagesize')) { // 注意:如果是云存储,这里可能需要从临时文件获取尺寸 $tempPath = $file->getRealPath(); if (file_exists($tempPath)) { $imgInfo = getimagesize($tempPath); $data['width'] = $imgInfo[0] ?? 0; $data['height'] = $imgInfo[1] ?? 0; } } return array_merge($data, $extra); } /** * 删除文件 * * 前置事件 FileDelete:插件监听并返回 true 即接管物理删除(OSS / FTP 删远端文件); * 无监听时走内置存储驱动。删除成功触发 FileDeleted 事件。 */ public function delete(string $path, array $options = []): bool { $handled = Event::trigger('FileDelete', [ 'path' => $path, 'options' => $options, ], true); if ($handled) { Event::trigger('FileDeleted', ['path' => $path, 'handled' => true]); return true; } $ok = $this->storage->delete($path); if ($ok) { Event::trigger('FileDeleted', ['path' => $path, 'handled' => false]); } return $ok; } /** * 获取URL */ public function getUrl(string $path): string { return $this->storage->getUrl($path); } /** * 基础文件验证,可用 options.rules 自定义规则,空数组表示不校验 */ protected function validateFile(UploadedFile $file, array $options): void { $rules = $options['rules'] ?? [ // 限制文件大小(单位b),这里限制为4M 'fileSize' => 4 * 1024 * 1024, // 限制文件后缀,多个后缀以英文逗号分割 'fileExt' => 'gif,jpg,png', ]; if (empty($rules)) { return; } validate( ['file' => $rules], [ 'file.fileSize' => '文件太大', 'file.fileExt' => '不支持的文件后缀', ] )->check(['file' => $file]); } /** * 生成唯一文件路径 */ protected function generatePath(UploadedFile $file): string { $ext = $file->getClientOriginalExtension(); $date = date('Y/m/d'); $filename = date('His') . '_' . uniqid() . ".{$ext}"; return "{$date}/{$filename}"; } /** * 魔术方法,允许直接调用驱动的其他特有方法 */ public function __call($method, $parameters) { if (method_exists($this->storage, $method)) { return call_user_func_array([$this->storage, $method], $parameters); } throw new \BadMethodCallException("Method {$method} does not exist."); } }