给 CRMEB V8 接一个 S3 兼容存储驱动

给 CRMEB 接一个 S3 兼容存储驱动:目录、字典、类名三处要对齐

客户把商城放进了私有云,对象存储只给了 MinIO 的 S3 兼容端点。后台「存储管理」里能选的只有本地、阿里云 OSS、腾讯云 COS、七牛、华为 OBS、京东云、天翼云七种,没有 S3 兼容这一项。于是很自然地想到:照着 driver/upload/Oss 抄一份,改改类名和 endpoint 就行。

照抄之后大概率会遇到三种现象:后台列表里根本看不到新驱动;看到了但点「开启」报错;开启成功但一上传就抛 Driver [minio] not supported.。这三个现象分别对应三处没有对齐的地方------目录、驱动字典、类名解析。本文按真实代码把这条链路走一遍。

一、先看清 v8 的驱动是三层结构

v7 及更早的版本里,上传驱动的配置分散在 sys_config 的 upload_type 和 config/upload.php 的 stores 数组里,加驱动要改配置文件、加后台表单项、改前端下拉。v8 换了一套模型,理解这三层是动手的前提:

层 位置 作用 谁写
清单层 driver/upload/{Dir}/plugin.json 声明驱动元数据、配置字段、存储空间字段 开发者手写
字典层 eb_driver 表 运行时唯一真相,含 namespace、字段定义、配置值 插件安装时写入
类解析层 crmeb\services\upload\Upload 按 driver_key 反推驱动类名 运行时自动

关键在第二层。DriverRegistry::scan() 的实现是直接查库:

php 复制代码
// crmeb/services/driver/DriverRegistry.php
$rows = Db::name('driver')
    ->where('driver_type', $type)
    ->order('sort', 'desc')
    ->order('id', 'asc')
    ->select()
    ->toArray();

注释写得很直白:"plugin.json 只在插件安装阶段读取,运行时不再扫描目录。"所以把目录拷进去只是"备料",不装插件,字典里没有行,后台就什么都没有。

还有一个隐含条件:scan() 在遍历时对每一行都做了归属过滤:

php 复制代码
$pluginId = (string) ($row['owner_plugin_id'] ?? '');
if ($pluginId === '' || !ModuleStateChecker::isDriverActive($pluginId)) {
    continue;
}

owner_plugin_id 为空的旧记录会被直接跳过。也就是说手工往 eb_driver 里 INSERT 一行,如果归属字段没写对,一样白搭。

二、清单里的 driver 子对象决定了后台表单长什么样

先建目录。这里有个必须提前定的东西:目录名。它不自由,后面第三节会解释为什么。

text 复制代码
backend/driver/upload/Minio/
├── plugin.json
├── Driver.php
└── extend/
    └── minio/
        └── Client.php     # 对象存储客户端封装,参照 Cos/extend/cos/

plugin.json 顶层字段会被 PluginManifest::fromFile() 校验:key、name、type、version、path 五项缺一不可,path 还要过正则并且 realpath 必须等于实际目录。driver 子对象才是驱动的全量描述:

json 复制代码
{
    "key": "minio",
    "name": "S3 兼容存储",
    "description": "MinIO / Ceph RGW 等 S3 协议对象存储",
    "version": "1.0.0",
    "author": "your-name",
    "type": "driver",
    "path": "driver/upload/Minio",
    "default_install": false,
    "require": {
        "crmeb": "^8.0",
        "php": "^8.0"
    },
    "driver": {
        "name": "S3 兼容存储",
        "driver_key": "minio",
        "class_name": "Driver",
        "icon": "icon-storage",
        "is_builtin": false,
        "sort": 60,
        "domain_template": "https://{name}.{region}.example.com",
        "capabilities": ["upload", "delete", "stream", "thumb", "water"],
        "config_fields": [
            {
                "field": "accessKey",
                "label": "AccessKey",
                "type": "text",
                "required": true,
                "group": "密钥配置",
                "tip": "对象存储的 Access Key"
            },
            {
                "field": "secretKey",
                "label": "SecretKey",
                "type": "password",
                "required": true,
                "group": "密钥配置"
            },
            {
                "field": "endpoint",
                "label": "Endpoint",
                "type": "text",
                "required": true,
                "group": "服务配置",
                "tip": "形如 https://minio.example.com,不要带 bucket"
            },
            {
                "field": "pathStyle",
                "label": "路径风格访问",
                "type": "switch",
                "required": false,
                "group": "服务配置",
                "tip": "MinIO 通常需要开启(bucket 放在 path 里)"
            }
        ],
        "bucket_fields": [
            {"field": "name", "label": "空间名称", "type": "text", "required": true},
            {"field": "region", "label": "区域", "type": "text", "required": false}
        ],
        "help_doc": {
            "title": "S3 兼容存储接入说明",
            "steps": [
                "在对象存储控制台创建 Bucket,读写权限设为公共读",
                "把 Endpoint、AccessKey、SecretKey 填入本页",
                "保存后在「空间配置」里添加该 Bucket 并填写访问域名"
            ]
        }
    }
}

这份 JSON 不是文档,是运行时的表单定义。后台的驱动配置页 frontend/admin/src/pages/driver/uploadDriver/edit.vue 只做了一件事:读 config_fields,按 type 渲染 el-input / el-select:

html 复制代码
<el-input
  v-if="field.type === 'text'"
  v-model="field.value"
  :placeholder="field.placeholder || '请输入' + field.label"
/>
<el-input
  v-else-if="field.type === 'password'"
  v-model="field.value"
  type="password"
  show-password
/>

也就是说,新增一个存储驱动不需要写任何前端页面 。加一个配置项,改 JSON 就够了。这是这套设计最大的收益,也是下一节那个坑的来源:表单跟着 JSON 走,但保存到库里的 config_fields 是安装那一刻的快照。

三、Driver.php:四个抽象方法,加一条赋值顺序

BaseUpload 是抽象类,crmeb/services/upload/BaseUpload.php 里声明了四个抽象方法:

php 复制代码
abstract public function upload(string $filePath, string $key);
abstract public function delete(string $key);
abstract public function getFilePath(string $key);
abstract public function getFileInfo();

move() 并不在抽象列表里,但所有现有驱动都实现了它,因为上传入口调用的就是它。最小可用的驱动骨架:

php 复制代码
<?php
namespace driver\upload\Minio;

use crmeb\services\upload\BaseUpload;
use crmeb\exceptions\UploadException;

class Driver extends BaseUpload
{
    protected $accessKey;
    protected $secretKey;
    protected $endpoint;
    protected $pathStyle;
    protected $client;
    /** @var string 空间域名 */
    protected $uploadUrl;
    /** @var string 空间名 */
    protected $storageName;
    protected $storageRegion;
    protected $cdn;

    public function initialize(array $config)
    {
        parent::initialize($config);
        $this->accessKey     = $config['accessKey'] ?? null;
        $this->secretKey     = $config['secretKey'] ?? null;
        $this->endpoint      = rtrim((string)($config['endpoint'] ?? ''), '/');
        $this->pathStyle     = (bool)($config['pathStyle'] ?? false);
        // uploadUrl / storageName / storageRegion / cdn 由 UploadService 注入
        $this->uploadUrl     = $this->checkUploadUrl($config['uploadUrl'] ?? '');
        $this->storageName   = $config['storageName'] ?? null;
        $this->storageRegion = $config['storageRegion'] ?? null;
        $this->cdn           = $config['cdn'] ?? null;
    }

    public function to(string $path)
    {
        $this->path = $path;
        return $this;
    }

    public function validate(?array $rules = null)
    {
        $this->validate = $rules ?: [
            'fileExt'  => config('upload.fileExt'),
            'fileMime' => config('upload.fileMime'),
            'filesize' => config('upload.filesize'),
        ];
        return $this;
    }

    public function move(string $file = 'file', $realName = false)
    {
        return $this->handleUpload($file);
    }

    protected function handleUpload(?string $file = null, bool $isStream = false, ?string $fileContent = null)
    {
        if (!$isStream) {
            $fileHandle = app()->request->file($file);
            if (!$fileHandle) {
                return $this->setError('上传的文件不存在');
            }
            if ($this->validate) {
                if (!in_array(strtolower(pathinfo($fileHandle->getOriginalName(), PATHINFO_EXTENSION)), $this->validate['fileExt'])) {
                    return $this->setError('不合法的文件后缀');
                }
                if (filesize($fileHandle) > $this->validate['filesize']) {
                    return $this->setError('文件过大');
                }
                if (!in_array($fileHandle->getOriginalMime(), $this->validate['fileMime'])) {
                    return $this->setError('不合法的文件类型');
                }
            }
            $key  = $this->saveFileName($fileHandle->getRealPath(), $fileHandle->getOriginalExtension());
            $body = (string)file_get_contents($fileHandle->getRealPath());
        } else {
            $key  = $file;
            $body = $fileContent;
        }

        try {
            $key = $this->getUploadPath($key);
            $this->fileInfo->uploadInfo = $this->client()->putObject($this->storageName, $key, $body);
            $origin = rtrim((string)($this->cdn ?: $this->uploadUrl), '/') . '/' . ltrim($key, '/');
            $this->fileInfo->filePath  = $origin;
            $this->fileInfo->realName  = isset($fileHandle) ? $fileHandle->getOriginalName() : $key;
            $this->fileInfo->fileName  = $key;
            $this->authThumb && $this->thumb($origin);
            $this->fileInfo->filePathWater = $this->water($origin);
            if (($this->waterConfig['image_watermark_status'] ?? 0)) {
                $this->fileInfo->filePath = $this->fileInfo->filePathWater;
            }
            return $this->fileInfo;
        } catch (UploadException $e) {
            return $this->setError($e->getMessage());
        }
    }

    public function stream($fileContent, ?string $key = null)
    {
        if (!$key) {
            $key = $this->saveFileName();
        }
        return $this->handleUpload($key, true, $fileContent);
    }

    public function delete(string $filePath)
    {
        $key = ltrim(parse_url($filePath, PHP_URL_PATH) ?: '', '/');
        return $this->client()->deleteObject($this->storageName, $key);
    }

    public function upload(string $filePath, string $key)
    {
        return $this->client()->putObject($this->storageName, $key, (string)file_get_contents($filePath));
    }

    public function getFilePath(string $key): string
    {
        return rtrim((string)($this->cdn ?: $this->uploadUrl), '/') . '/' . ltrim($key, '/');
    }

    public function getFileInfo()
    {
        return $this->fileInfo;
    }

    protected function client()
    {
        return $this->client ?: ($this->client = new \minio\Client(
            $this->endpoint, $this->accessKey, $this->secretKey, $this->pathStyle
        ));
    }
}

extend/minio/Client.php 负责 SigV4 签名和 HTTP 调用,写法可参照 driver/upload/Cos/extend/cos/Client.php 的结构(自建封装,不引第三方 SDK,私有云环境少一个依赖)。本文不展开签名细节。

这段代码里真正容易错的不是签名,是 $this->fileInfo 的赋值。入库字段全部由 BaseUpload::getUploadInfo() 从这些属性上取:

php 复制代码
return [
    'name'       => $this->fileInfo->fileName ?? '',
    'real_name'  => $this->fileInfo->realName ?? '',
    'dir'        => $this->fileInfo->filePath ?? '',
    'thumb_path' => $this->fileInfo->filePathMid ?? $this->fileInfo->filePath,
    'size'       => $size,
    'type'       => $type,
    'time'       => time(),
];

fileName 是落库的存储路径,realName 是用户看到的原始文件名,filePath 是完整访问地址。少赋一个 realName,后台素材列表的"文件名"列就会变成一串 md5。thumb_path 在没生成缩略图时回落到 filePath,所以不实现 thumb() 也不会写出空值------但列表里的缩略图会变成原图,页面加载量会明显上去。

顺带说一个真实差异:Cos 驱动的 thumb() 不是生成文件,而是拼 imageMogr2/thumbnail/{w}x{h} 这样的 URL 参数;Local 才是真的用 GD 落盘。S3 兼容存储没有这套图片处理服务,如果你不打算在前面挂 CDN 的图片处理,thumb() 直接返回原图地址,并把 filePathMid 显式赋成 filePath 更稳妥。

四、第三步:安装,让字典里有这一行

驱动不需要自己写 Installer。ModuleInstaller::registerFromManifest() 在找不到 Installer.php 时,对 driver 类型会兜底挂一个通用安装器:

php 复制代码
// 纯配置型驱动没有独立表和菜单,仍通过通用 Installer 接入完整生命周期。
if ($manifest->identity->kind === 'driver') {
    $this->registerInstaller($manifest, new ManifestDriverInstaller($manifest));
}

ManifestDriverInstaller 继承 BaseModuleInstaller,把 runMigrate()、getMenuConfig() 都空实现掉,只保留生命周期骨架。安装时 BaseModuleInstaller::install() 会走到 installDriverRecord(),把清单写进字典:

php 复制代码
$namespace = 'driver\\' . $manifest->identity->category . '\\' . basename($manifest->basePath);
$row = [
    'driver_type' => $manifest->identity->category,   // upload
    'driver_key'  => (string) $driver['driver_key'],  // minio
    'namespace'   => $namespace,                      // driver\upload\Minio
    'owner_plugin_id' => $manifest->identity->canonicalId(), // driver:upload:minio
    'status'      => !empty($driver['is_builtin']) ? 1 : 0,
    // ...
];
Db::table($this->table('driver'))->duplicate([...])->insert($row);

三个细节值得记:

  1. namespace 取自目录名 (basename($manifest->basePath)),不是 driver_key。
  2. status 默认是 is_builtin ? 1 : 0。第三方驱动装完是关闭状态,要手动启用。
  3. 用的是 duplicate(),重复安装不会报唯一键冲突,而是覆盖元数据。

安装入口在后台,不在命令行。php think 下有 plugin:manifest-check、registry:check、arch:check 这些校验命令,但 app/command/PluginInstall.php 目前只是个占位实现。触发安装走接口:

bash 复制代码
# module_key 支持复合标识、path、legacy key 三种写法
curl -X POST 'http://127.0.0.1:8011/adminapi/module/install' \
  -H 'Authori-zation: <admin token>' \
  -d 'module_key=driver:upload:minio'

ModuleInstaller::resolveKey() 会依次匹配 installers[canonicalId]、identifierMap,把 driver/upload/Minio、minio、driver:upload:minio 都归一到同一个复合标识上。

装完先看一眼库,比翻后台快:

sql 复制代码
SELECT driver_type, driver_key, namespace, owner_plugin_id, status, sort
FROM eb_driver WHERE driver_type = 'upload' ORDER BY sort DESC;

期望看到 driver_type=upload、driver_key=minio、namespace=driver\upload\Minio、owner_plugin_id=driver:upload:minio。

五、第四步:配置、空间、启用,顺序不能反

三件事有严格的前后关系。

先存配置。 后台保存走 save_config,落到 eb_driver.config_fields 这个 JSON 字段里,每个字段多出一个 value:

php 复制代码
$driver->config_fields = $this->validateDriverConfig($driver->config_fields ?: [], $configValues);

validateDriverConfig() 是先白名单后校验:array_diff_key($values, array_column($fields, null, 'field')) 只要出现清单里没声明的字段,直接抛"不支持的驱动配置字段"。所以前端传什么,完全由 config_fields 决定。

再加空间。 云存储的 bucket 信息不在 eb_driver,在 eb_upload_storage:

sql 复制代码
CREATE TABLE IF NOT EXISTS `eb_upload_storage` (
  `driver_key` varchar(64) NOT NULL DEFAULT '' COMMENT '存储驱动标识',
  `name` varchar(100) NOT NULL DEFAULT '' COMMENT '空间名',
  `region` varchar(100) NOT NULL DEFAULT '' COMMENT '地域',
  `domain` varchar(100) NOT NULL DEFAULT '' COMMENT '空间域名',
  `cdn` varchar(255) NOT NULL DEFAULT '' COMMENT 'CDN加速域名',
  `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态',
  `is_delete` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除'
)

domain 就是 UploadService::loadDriverConfig() 里的 uploadUrl,是最终图片地址的前缀。这里填错,数据库里的 att_dir 会是一批打不开的链接------而且已经写进去的不会自动修正。

最后启用。 启用接口是 PUT /adminapi/driver/upload_driver/set_status/:id,UploadDriverServices::update() 设了两道闸:

php 复制代码
if (($data['status'] ?? 0) === 1 && !DriverRegistry::get('upload', (string)$driver->driver_key)) {
    throw new AdminException('请先启用对应的驱动插件');
}
// 非 local 驱动,必须有正在使用的空间
if (($data['status'] ?? 0) === 1 && $driver->driver_key !== 'local'
    && !app()->make(UploadStorageDao::class)->count(['driver_key' => $driver->driver_key, 'status' => 1, 'is_delete' => 0])) {
    // ...
    throw new AdminException('未有正在使用的存储空间,请先在「空间配置」中添加并开启存储空间');
}

第一条要求的"启用插件",指的是插件在 eb_module_install_log 里的状态,ModuleStateChecker::isDriverActive() 读的就是它。如果只是把目录拷进去、没走安装流程,这里必然报错。

另外,update() 成功时会调 disableAllExcept($id):存储驱动是全局互斥的,同时只有一个开启。想按业务分流(主图走 OSS、用户上传走本地)在当前模型下做不到,那是另一层改造。

六、第五步:验证

上传入口是 POST /adminapi/file/upload/[:upload_type],upload_type 传 0 表示用当前启用的驱动:

bash 复制代码
curl -X POST 'http://127.0.0.1:8011/adminapi/file/upload/0' \
  -H 'Authori-zation: <admin token>' \
  -F 'file=@./test.png' \
  -F 'pid=0'

返回的 data.src 是文件地址。拿这个地址和库里的记录对一下:

sql 复制代码
SELECT att_dir, satt_dir, real_name, att_size, image_type
FROM eb_system_attachment ORDER BY time DESC LIMIT 5;

att_dir 必须能直接在浏览器打开。image_type 是 upload_type() 算出来的整数,它来自 getTypeDriverKeyMap():

php 复制代码
$drivers = $this->dao->getList([], ['id', 'driver_key'], 0, 0, 'id asc');
foreach ($drivers as $index => $driver) {
    $map[$index + 1] = $driver['driver_key'];
}

type 是按 id 升序的序号 ,不是固定枚举。新增一个驱动会让它后面所有驱动的 type 整体后移;而存量素材的 image_type 不会跟着改,同一批历史图片就被标成了别的存储来源。

这不是一个纯展示字段。用户端生成海报、二维码时,会拿它判断要不要补站点域名:

php 复制代码
// app/api/controller/v1/user/UserBillController.php
if ($imageInfo['image_type'] == 1) $urlCode = $siteUrl . $urlCode;

== 1 背后隐含的假设是"1 号即本地存储"------本地的 att_dir 是不带域名的相对路径,需要拼 site_url;云存储的 att_dir 已经是完整 URL,不能再拼。这个语义完全依赖 eb_driver 里 local 那一行的 id 最小,一旦有人重装或迁移过驱动表、把序号打乱,海报和二维码的地址就会错。

所以接新驱动时,除了第五节那三件事,还要确认 local 仍然是 type=1。要按存储来源做清理或迁移,不要用 image_type 当稳定标识,应该拿 att_dir 的域名去匹配 eb_upload_storage.domain------UploadService::getOssInit() 就是这么做的,而且它在域名匹配到多个空间时会直接抛"文件地址匹配多个存储空间,无法安全操作",而不是随便挑一个。

七、踩坑清单

1. 目录名必须等于 Str::studly(driver_key)。

Upload 重写了类解析:

php 复制代码
$studly = Str::studly($type);
$class  = $this->namespace . $studly . '\\Driver';

$type 传的是 driver_key。实测对照:minio → Minio,s3_minio → S3Minio,my-oss → MyOss。所以 driver_key 写成 s3_minio、目录叫 S3Minio 才配得上;写成 s3-minio 目录叫 S3Minio 也一致。但目录叫 Minio、driver_key 写 s3_minio,就会在启用后第一次上传时抛 Driver [s3_minio] not supported.------后台一切正常,只有上传失败。

2. 只放目录不装插件,后台永远看不到。

syncDrivers() 里有一句很容易忽略的注释:

php 复制代码
// Registry 只提供已安装且启用的插件;新增/删除字典行必须由插件生命周期管理。
if (!$current) {
    continue;
}

后台那个「同步存储驱动」按钮只更新已有行,不会新增。想靠点它把新驱动刷出来,是行不通的。

3. 改了 config_fields 要重新安装或升级。

字典里的字段定义是安装时的快照。你在 plugin.json 里加了个 endpoint 字段,但库里 config_fields 还是旧的,前端表单不会出现这一项;就算手工传了,validateDriverConfig() 也会因为"未声明"把它挡掉。

4. 敏感字段的脱敏是按名字猜的。

isSensitiveConfigField() 的判定是:type === 'password',或者字段名匹配 /(?:access.?key|secret|token|password|private.?key)/i。字段命名成 sk、ak 这种缩写,既不会被脱敏,也会在列表接口里明文回显。要么用 password 类型,要么老老实实叫 secretKey。

5. move() 的第二个参数不一定生效。

UploadService 调用的是 move($file, $realName),但 Cos 驱动的实现是 move(string $file = 'file', $realName = false) { return $this->handleUpload($file); },$realName 被直接丢弃;Jdoss 的签名干脆只有 move(string $file = 'file')。如果你需要"保留原始文件名作为存储 key",得自己把 $realName 的语义补进 handleUpload(),不能指望基类。

6. 卸载会删字典,但不会删空间记录。

removeDriverRecord() 只对 eb_driver 做删除:

php 复制代码
$count = (int) Db::table($this->table('driver'))
    ->where('owner_plugin_id', $this->getOwnerPluginId())
    ->delete();

eb_upload_storage 里该驱动的空间行会留着。如果这个驱动当时是启用状态,UploadService::getEnabledDriverKey() 读到的 driver_key 在字典里已经不存在,上传会在类解析阶段直接抛异常。卸载前先把驱动切回本地,再清空间。

八、收尾

把这条链路压缩成一句话:目录名决定类名,plugin.json 决定表单,安装动作决定字典行,空间记录决定访问域名。 四者任意一处对不上,表现出来的症状都是"上传失败",但错误信息完全不同------Driver [x] not supported. 是类名,请先启用对应的驱动插件 是字典行,未有正在使用的存储空间 是空间,图片 404 是 domain。

这也是这套驱动模型的特点:它把"写代码"压缩到了 Driver.php 一个文件,代价是把配置的正确性从编译期推到了运行期。养成一个习惯------接新驱动时先跑一遍第四节那条 SQL,看字典行对不对,再动后台。能省掉大半排查时间。

相关推荐
北京中科新远科技5 小时前
高速交换机端口预算怎么做?下行、上联、拆分和冗余计算框架
开发语言·php
其实防守也摸鱼6 小时前
信创安全竞赛知识总结与题库
大数据·网络·人工智能·算法·安全·php
可涵不会debug13 小时前
PHP2Shell 爆发一周后:CVE-2026-109618/109619 自查、修复与防护实录
安全·php·云安全·waf·phpshell
一支蜉蝣13 小时前
Day26 XD安全学习笔记--PHP 应用&模版引用&Smarty渲染&MVC 模型&数据联动&RCE 安全
笔记·网络安全·html·php
vipxieliang13 小时前
PHP 中的设计模式:工厂模式与单例
php
vipxieliang14 小时前
ThinkPHP 8.0 注解路由实战:PHP 8 原生 Attribute 方案
php·thinkphp
evoxwht15 小时前
给 CRMEB v8 商品中台加一个「课程」商品类型
php
evoxwht15 小时前
给 CRMEB V8 给 MCP 服务加一个分销工具
php
IT召唤狮1 天前
开源企业级零信任项目Pangolin,零信任能挡住PHP核弹漏洞吗?
开发语言·开源·php