2025-05-12 00:19:35 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Service\common\upload;
|
|
|
|
|
|
|
2026-08-10 18:32:06 +08:00
|
|
|
|
use App\BaseApp\BaseNotAuthService;
|
|
|
|
|
|
use App\Service\common\oss\OssStorageInterface;
|
|
|
|
|
|
use App\Service\common\UtilsService;
|
2025-05-12 00:19:35 +08:00
|
|
|
|
|
2026-08-10 18:32:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 七牛云上传;优先使用官方 SDK,未安装时给出明确提示
|
|
|
|
|
|
*/
|
|
|
|
|
|
class QiniuStorageService extends BaseNotAuthService implements OssStorageInterface
|
2025-05-12 00:19:35 +08:00
|
|
|
|
{
|
2026-08-14 23:21:21 +08:00
|
|
|
|
use ObjectKeyNormalizeTrait;
|
|
|
|
|
|
|
2026-08-10 18:32:06 +08:00
|
|
|
|
protected array $config = [
|
|
|
|
|
|
'access_key' => '',
|
|
|
|
|
|
'secret_key' => '',
|
|
|
|
|
|
'bucket' => '',
|
|
|
|
|
|
'domain' => '',
|
|
|
|
|
|
'path_prefix' => '',
|
|
|
|
|
|
];
|
2025-05-12 00:19:35 +08:00
|
|
|
|
|
2026-08-19 08:16:49 +08:00
|
|
|
|
/** 同一次请求里复用,避免每页列举都去 UC 查一遍区域 */
|
|
|
|
|
|
private ?\Qiniu\Storage\BucketManager $cachedBucketMgr = null;
|
|
|
|
|
|
|
|
|
|
|
|
private string $cachedBucketMgrKey = '';
|
|
|
|
|
|
|
2025-05-12 00:19:35 +08:00
|
|
|
|
/**
|
2026-08-10 18:32:06 +08:00
|
|
|
|
* 注入解密后的运行时配置
|
2025-05-12 00:19:35 +08:00
|
|
|
|
*/
|
2026-08-10 18:32:06 +08:00
|
|
|
|
public function withConfig(array $config): static
|
2025-05-12 00:19:35 +08:00
|
|
|
|
{
|
2026-08-10 18:32:06 +08:00
|
|
|
|
$this->config = array_merge($this->config, $config);
|
2026-08-19 08:16:49 +08:00
|
|
|
|
$this->cachedBucketMgr = null;
|
|
|
|
|
|
$this->cachedBucketMgrKey = '';
|
2026-08-10 18:32:06 +08:00
|
|
|
|
return $this;
|
2025-05-12 00:19:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 18:32:06 +08:00
|
|
|
|
public function uploadImage($filePath, string $key): bool|array
|
2025-05-12 00:19:35 +08:00
|
|
|
|
{
|
|
|
|
|
|
return $this->uploadFile($filePath, $key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 18:32:06 +08:00
|
|
|
|
public function uploadVideo($filePath, string $key): bool|array
|
2025-05-12 00:19:35 +08:00
|
|
|
|
{
|
|
|
|
|
|
return $this->uploadFile($filePath, $key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function deleteImage($key): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
return $this->deleteFile($key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function deleteVideo($key): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
return $this->deleteFile($key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 23:21:21 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 列举空间对象(BucketManager::listFiles,marker 分页)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 七牛只在还有下一页时才回 marker,所以 marker 为空即等于列完了。
|
2026-08-19 08:16:49 +08:00
|
|
|
|
* 列举结果的 key 必须用服务端原值,不能再套 path_prefix,
|
|
|
|
|
|
* 否则根目录历史文件会被改写成 uploads/b_xxx.jpg。
|
2026-08-14 23:21:21 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$bucketMgr = $this->bucketManager();
|
|
|
|
|
|
$bucket = (string) ($this->config['bucket'] ?? '');
|
2026-08-19 08:16:49 +08:00
|
|
|
|
$listPrefix = $this->scopedListPrefix($prefix);
|
2026-08-14 23:21:21 +08:00
|
|
|
|
[$ret, $err] = $bucketMgr->listFiles(
|
|
|
|
|
|
$bucket,
|
2026-08-19 08:16:49 +08:00
|
|
|
|
$listPrefix !== '' ? $listPrefix : null,
|
2026-08-14 23:21:21 +08:00
|
|
|
|
$marker !== '' ? $marker : null,
|
|
|
|
|
|
$this->boundedLimit($limit)
|
|
|
|
|
|
);
|
|
|
|
|
|
if ($err !== null) {
|
|
|
|
|
|
UtilsService::getInstance()->errorThrow('七牛云列举对象失败:' . $this->errorText($err));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 08:16:49 +08:00
|
|
|
|
$ret = is_array($ret) ? $ret : [];
|
|
|
|
|
|
$rawItems = $ret['items'] ?? $ret['Items'] ?? [];
|
|
|
|
|
|
// 个别 SDK 版本成功时直接回文件数组,而不是 {items: [...]}
|
|
|
|
|
|
if ($rawItems === [] && isset($ret[0]) && is_array($ret[0])) {
|
|
|
|
|
|
$rawItems = $ret;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 23:21:21 +08:00
|
|
|
|
$items = [];
|
2026-08-19 08:16:49 +08:00
|
|
|
|
foreach ((array) $rawItems as $row) {
|
|
|
|
|
|
if (!is_array($row)) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$key = $this->normalizeListedKey((string) ($row['key'] ?? ''));
|
2026-08-14 23:21:21 +08:00
|
|
|
|
if ($key === '' || str_ends_with($key, '/')) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$items[] = [
|
|
|
|
|
|
'key' => $key,
|
|
|
|
|
|
'size' => (int) ($row['fsize'] ?? 0),
|
|
|
|
|
|
'hash' => (string) ($row['hash'] ?? ''),
|
|
|
|
|
|
// putTime 的单位是 100 纳秒,当秒用会得到五亿年后的时间戳
|
|
|
|
|
|
'last_modified' => intdiv((int) ($row['putTime'] ?? 0), 10000000),
|
2026-08-19 08:16:49 +08:00
|
|
|
|
'url' => $this->publicUrlOf($key),
|
2026-08-14 23:21:21 +08:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$next = (string) ($ret['marker'] ?? '');
|
|
|
|
|
|
return [
|
|
|
|
|
|
'items' => $items,
|
|
|
|
|
|
'next_marker' => $next,
|
|
|
|
|
|
'finished' => $next === '',
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function deleteObject(string $key): bool
|
|
|
|
|
|
{
|
2026-08-19 08:16:49 +08:00
|
|
|
|
return $this->deleteFile($this->normalizeListedKey($key));
|
2026-08-14 23:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-12 00:19:35 +08:00
|
|
|
|
/**
|
2026-08-10 18:32:06 +08:00
|
|
|
|
* 上传到七牛;无 SDK 时抛业务异常引导安装或改本地
|
2025-05-12 00:19:35 +08:00
|
|
|
|
*/
|
2026-08-10 18:32:06 +08:00
|
|
|
|
private function uploadFile($filePath, string $key): bool|array
|
2025-05-12 00:19:35 +08:00
|
|
|
|
{
|
2026-08-10 18:32:06 +08:00
|
|
|
|
if (!class_exists(\Qiniu\Auth::class)) {
|
|
|
|
|
|
UtilsService::getInstance()->errorThrow('未安装 qiniu/php-sdk,请改用本地存储或安装依赖');
|
|
|
|
|
|
}
|
|
|
|
|
|
$accessKey = (string) ($this->config['access_key'] ?? '');
|
|
|
|
|
|
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
|
|
|
|
|
$bucket = (string) ($this->config['bucket'] ?? '');
|
|
|
|
|
|
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
|
|
|
|
|
if ($accessKey === '' || $secretKey === '' || $bucket === '') {
|
|
|
|
|
|
UtilsService::getInstance()->errorThrow('七牛云配置不完整');
|
|
|
|
|
|
}
|
|
|
|
|
|
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
|
|
|
|
|
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
|
|
|
|
|
|
$key = $prefix . '/' . ltrim($key, '/');
|
|
|
|
|
|
}
|
|
|
|
|
|
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
|
|
|
|
|
|
? $filePath->getRealPath()
|
|
|
|
|
|
: (string) $filePath;
|
|
|
|
|
|
$auth = new \Qiniu\Auth($accessKey, $secretKey);
|
|
|
|
|
|
$token = $auth->uploadToken($bucket);
|
|
|
|
|
|
$uploadMgr = new \Qiniu\Storage\UploadManager();
|
|
|
|
|
|
list($ret, $err) = $uploadMgr->putFile($token, $key, $path);
|
2025-05-12 00:19:35 +08:00
|
|
|
|
if ($err !== null) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-08-10 18:32:06 +08:00
|
|
|
|
return [
|
|
|
|
|
|
'key' => $ret['key'],
|
|
|
|
|
|
'url' => $domain . '/' . $ret['key'],
|
|
|
|
|
|
];
|
2025-05-12 00:19:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function deleteFile(string $key): bool
|
|
|
|
|
|
{
|
2026-08-19 08:16:49 +08:00
|
|
|
|
$err = $this->bucketManager()->delete((string) $this->config['bucket'], $key);
|
2025-05-12 00:19:35 +08:00
|
|
|
|
return $err === null;
|
|
|
|
|
|
}
|
2026-08-14 23:21:21 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 构造 BucketManager,顺手把「没装 SDK / 没填配置」两种情况前置拦掉
|
2026-08-19 08:16:49 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 必须走 HTTPS:SDK 默认 HTTP,部分机房出网只放行 443,列举会直接空失败。
|
2026-08-14 23:21:21 +08:00
|
|
|
|
*/
|
|
|
|
|
|
private function bucketManager(): \Qiniu\Storage\BucketManager
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!class_exists(\Qiniu\Auth::class)) {
|
|
|
|
|
|
UtilsService::getInstance()->errorThrow('未安装 qiniu/php-sdk,请改用本地存储或安装依赖');
|
|
|
|
|
|
}
|
2026-08-19 08:16:49 +08:00
|
|
|
|
$accessKey = trim((string) ($this->config['access_key'] ?? ''));
|
|
|
|
|
|
$secretKey = trim((string) ($this->config['secret_key'] ?? ''));
|
|
|
|
|
|
$bucket = trim((string) ($this->config['bucket'] ?? ''));
|
|
|
|
|
|
$missing = [];
|
|
|
|
|
|
if ($accessKey === '') {
|
|
|
|
|
|
$missing[] = 'AccessKey';
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($secretKey === '') {
|
|
|
|
|
|
$missing[] = 'SecretKey';
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($bucket === '') {
|
|
|
|
|
|
$missing[] = 'Bucket';
|
2026-08-14 23:21:21 +08:00
|
|
|
|
}
|
2026-08-19 08:16:49 +08:00
|
|
|
|
if ($missing !== []) {
|
|
|
|
|
|
UtilsService::getInstance()->errorThrow(
|
|
|
|
|
|
'七牛云配置不完整,缺少:' . implode('、', $missing) . '。请到「系统配置 → 存储」重新填写后保存'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
$cacheKey = $accessKey . "\0" . $bucket;
|
|
|
|
|
|
if ($this->cachedBucketMgr !== null && $this->cachedBucketMgrKey === $cacheKey) {
|
|
|
|
|
|
return $this->cachedBucketMgr;
|
|
|
|
|
|
}
|
|
|
|
|
|
$sdkConfig = new \Qiniu\Config();
|
|
|
|
|
|
$sdkConfig->useHTTPS = true;
|
|
|
|
|
|
$this->cachedBucketMgr = new \Qiniu\Storage\BucketManager(
|
|
|
|
|
|
new \Qiniu\Auth($accessKey, $secretKey),
|
|
|
|
|
|
$sdkConfig
|
|
|
|
|
|
);
|
|
|
|
|
|
$this->cachedBucketMgrKey = $cacheKey;
|
|
|
|
|
|
return $this->cachedBucketMgr;
|
2026-08-14 23:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* SDK 的错误对象没有统一契约,转成人能看懂的一行字
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function errorText(mixed $err): string
|
|
|
|
|
|
{
|
|
|
|
|
|
if (is_object($err) && method_exists($err, 'message')) {
|
2026-08-19 08:16:49 +08:00
|
|
|
|
$text = trim((string) $err->message());
|
|
|
|
|
|
if ($text !== '') {
|
|
|
|
|
|
return $text;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (is_object($err) && method_exists($err, 'code')) {
|
|
|
|
|
|
return 'HTTP ' . $err->code();
|
2026-08-14 23:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (is_object($err) || is_array($err)) {
|
|
|
|
|
|
return (string) json_encode($err, JSON_UNESCAPED_UNICODE);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (string) $err;
|
|
|
|
|
|
}
|
2025-05-12 00:19:35 +08:00
|
|
|
|
}
|