2026-08-10 18:32:06 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Service\common\upload;
|
|
|
|
|
|
|
|
|
|
|
|
use App\BaseApp\BaseNotAuthService;
|
|
|
|
|
|
use App\Service\common\oss\OssStorageInterface;
|
|
|
|
|
|
use App\Service\common\UtilsService;
|
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 腾讯云 COS:简易 PUT(Authorization 签名 v5)
|
|
|
|
|
|
*/
|
|
|
|
|
|
class QcloudStorageService extends BaseNotAuthService implements OssStorageInterface
|
|
|
|
|
|
{
|
2026-08-14 23:21:21 +08:00
|
|
|
|
use ObjectKeyNormalizeTrait;
|
|
|
|
|
|
use ObjectListXmlTrait;
|
|
|
|
|
|
|
2026-08-10 18:32:06 +08:00
|
|
|
|
protected array $config = [];
|
|
|
|
|
|
|
|
|
|
|
|
public function withConfig(array $config): static
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->config = $config;
|
|
|
|
|
|
return $this;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function uploadImage($filePath, string $key): bool|array
|
|
|
|
|
|
{
|
|
|
|
|
|
return $this->uploadFile($filePath, $key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function uploadVideo($filePath, string $key): bool|array
|
|
|
|
|
|
{
|
|
|
|
|
|
return $this->uploadFile($filePath, $key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 23:21:21 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 列举 bucket 对象(GET Bucket)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 与上传不同,列举带查询参数,而 COS 签名 v5 把查询串算进 HttpString,
|
|
|
|
|
|
* 且 q-url-param-list 必须和实际发出去的参数一一对应;因此这里自己拼查询串,
|
|
|
|
|
|
* 不能交给 Http::get($url, $query) 去编码,否则签名和请求会对不上。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->assertConfig();
|
|
|
|
|
|
$params = ['max-keys' => (string) $this->boundedLimit($limit)];
|
|
|
|
|
|
$listPrefix = $this->scopedListPrefix($prefix);
|
|
|
|
|
|
if ($listPrefix !== '') {
|
|
|
|
|
|
$params['prefix'] = $listPrefix;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($marker !== '') {
|
|
|
|
|
|
$params['marker'] = $marker;
|
|
|
|
|
|
}
|
|
|
|
|
|
ksort($params);
|
|
|
|
|
|
$host = $this->host();
|
|
|
|
|
|
$query = $this->buildQuery($params);
|
|
|
|
|
|
$response = Http::withHeaders([
|
|
|
|
|
|
'Host' => $host,
|
|
|
|
|
|
'Authorization' => $this->authorization('GET', '/', $params),
|
|
|
|
|
|
])->get('https://' . $host . '/?' . $query);
|
|
|
|
|
|
if (!$response->successful()) {
|
|
|
|
|
|
UtilsService::getInstance()->errorThrow('腾讯云列举对象失败:' . $response->body());
|
|
|
|
|
|
}
|
|
|
|
|
|
return $this->parseObjectListXml($response->body(), 'https://' . $host);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function deleteObject(string $key): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->assertConfig();
|
|
|
|
|
|
$key = $this->normalizeObjectKey($key);
|
|
|
|
|
|
if ($key === '') {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
$host = $this->host();
|
|
|
|
|
|
$urlPath = '/' . $key;
|
|
|
|
|
|
$response = Http::withHeaders([
|
|
|
|
|
|
'Host' => $host,
|
|
|
|
|
|
'Authorization' => $this->authorization('DELETE', $urlPath, []),
|
|
|
|
|
|
])->delete('https://' . $host . $urlPath);
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 18:32:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* COS 对象上传(Sign Algorithm=sha1)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function uploadFile($filePath, string $key): bool|array
|
|
|
|
|
|
{
|
2026-08-14 23:21:21 +08:00
|
|
|
|
$this->assertConfig();
|
2026-08-10 18:32:06 +08:00
|
|
|
|
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
|
|
|
|
|
$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;
|
|
|
|
|
|
$content = file_get_contents($path);
|
2026-08-14 23:21:21 +08:00
|
|
|
|
$host = $this->host();
|
2026-08-10 18:32:06 +08:00
|
|
|
|
$urlPath = '/' . ltrim($key, '/');
|
2026-08-14 23:21:21 +08:00
|
|
|
|
$url = 'https://' . $host . $urlPath;
|
|
|
|
|
|
$response = Http::withHeaders([
|
|
|
|
|
|
'Host' => $host,
|
|
|
|
|
|
'Authorization' => $this->authorization('PUT', $urlPath, []),
|
|
|
|
|
|
'Content-Type' => 'application/octet-stream',
|
|
|
|
|
|
])->withBody($content, 'application/octet-stream')->put($url);
|
|
|
|
|
|
if (!$response->successful()) {
|
|
|
|
|
|
UtilsService::getInstance()->errorThrow('腾讯云上传失败:' . $response->body());
|
|
|
|
|
|
}
|
|
|
|
|
|
$publicUrl = $domain !== '' ? ($domain . $urlPath) : $url;
|
|
|
|
|
|
return ['key' => $key, 'url' => $publicUrl];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 签名 v5;上传、列举、删除共用,差别只在 method / 路径 / 查询参数
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array<string, string> $params 参与签名的查询参数(键需已排序)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function authorization(string $method, string $urlPath, array $params): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$secretId = (string) $this->config['access_key'];
|
|
|
|
|
|
$secretKey = (string) $this->config['secret_key'];
|
|
|
|
|
|
$host = $this->host();
|
2026-08-10 18:32:06 +08:00
|
|
|
|
$now = time();
|
|
|
|
|
|
$keyTime = $now . ';' . ($now + 600);
|
|
|
|
|
|
$signKey = hash_hmac('sha1', $keyTime, $secretKey);
|
2026-08-14 23:21:21 +08:00
|
|
|
|
$paramList = implode(';', array_map('strtolower', array_keys($params)));
|
|
|
|
|
|
$httpString = strtolower($method) . "\n" . $urlPath . "\n" . $this->buildQuery($params)
|
|
|
|
|
|
. "\nhost=" . strtolower($host) . "\n";
|
2026-08-10 18:32:06 +08:00
|
|
|
|
$stringToSign = "sha1\n{$keyTime}\n" . sha1($httpString) . "\n";
|
|
|
|
|
|
$signature = hash_hmac('sha1', $stringToSign, $signKey);
|
2026-08-14 23:21:21 +08:00
|
|
|
|
return 'q-sign-algorithm=sha1'
|
2026-08-10 18:32:06 +08:00
|
|
|
|
. '&q-ak=' . $secretId
|
|
|
|
|
|
. '&q-sign-time=' . $keyTime
|
|
|
|
|
|
. '&q-key-time=' . $keyTime
|
|
|
|
|
|
. '&q-header-list=host'
|
2026-08-14 23:21:21 +08:00
|
|
|
|
. '&q-url-param-list=' . $paramList
|
2026-08-10 18:32:06 +08:00
|
|
|
|
. '&q-signature=' . $signature;
|
2026-08-14 23:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 用 rawurlencode 拼查询串:COS 要求 RFC3986 编码,http_build_query 会把空格编成 +
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array<string, string> $params
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function buildQuery(array $params): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$pairs = [];
|
|
|
|
|
|
foreach ($params as $name => $value) {
|
|
|
|
|
|
$pairs[] = strtolower(rawurlencode((string) $name)) . '=' . rawurlencode((string) $value);
|
|
|
|
|
|
}
|
|
|
|
|
|
return implode('&', $pairs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function host(): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return $this->config['bucket'] . '.cos.' . $this->config['region'] . '.myqcloud.com';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function assertConfig(): void
|
|
|
|
|
|
{
|
|
|
|
|
|
if (trim((string) ($this->config['access_key'] ?? '')) === ''
|
|
|
|
|
|
|| trim((string) ($this->config['secret_key'] ?? '')) === ''
|
|
|
|
|
|
|| trim((string) ($this->config['bucket'] ?? '')) === ''
|
|
|
|
|
|
|| trim((string) ($this->config['region'] ?? '')) === ''
|
|
|
|
|
|
) {
|
|
|
|
|
|
UtilsService::getInstance()->errorThrow('腾讯云 COS 配置不完整(需要 SecretId/SecretKey/Bucket/Region)');
|
2026-08-10 18:32:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|