Files

170 lines
6.2 KiB
PHP
Raw Permalink Normal View History

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简易 PUTAuthorization 签名 v5
*/
class QcloudStorageService extends BaseNotAuthService implements OssStorageInterface
{
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);
}
/**
* 列举 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
{
$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);
$host = $this->host();
2026-08-10 18:32:06 +08:00
$urlPath = '/' . ltrim($key, '/');
$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);
$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);
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'
. '&q-url-param-list=' . $paramList
2026-08-10 18:32:06 +08:00
. '&q-signature=' . $signature;
}
/**
* 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
}
}
}