给 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);
三个细节值得记:
namespace取自目录名 (basename($manifest->basePath)),不是driver_key。status默认是is_builtin ? 1 : 0。第三方驱动装完是关闭状态,要手动启用。- 用的是
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,看字典行对不对,再动后台。能省掉大半排查时间。