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(); } /** * COS 对象上传(Sign Algorithm=sha1) */ private function uploadFile($filePath, string $key): bool|array { $this->assertConfig(); $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(); $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 $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(); $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"; $stringToSign = "sha1\n{$keyTime}\n" . sha1($httpString) . "\n"; $signature = hash_hmac('sha1', $stringToSign, $signKey); return 'q-sign-algorithm=sha1' . '&q-ak=' . $secretId . '&q-sign-time=' . $keyTime . '&q-key-time=' . $keyTime . '&q-header-list=host' . '&q-url-param-list=' . $paramList . '&q-signature=' . $signature; } /** * 用 rawurlencode 拼查询串:COS 要求 RFC3986 编码,http_build_query 会把空格编成 + * * @param array $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)'); } } }