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,V1 签名) * * prefix / marker / max-keys 都不是 OSS V1 的 sub-resource,不参与签名, * 所以 CanonicalizedResource 仍然只有 /bucket/,别照着 V4 的写法往里塞查询串。 */ public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array { $this->assertConfig(); $bucket = (string) $this->config['bucket']; $host = $this->host(); $date = gmdate('D, d M Y H:i:s \G\M\T'); $query = ['max-keys' => $this->boundedLimit($limit)]; $listPrefix = $this->scopedListPrefix($prefix); if ($listPrefix !== '') { $query['prefix'] = $listPrefix; } if ($marker !== '') { $query['marker'] = $marker; } $response = Http::withHeaders([ 'Date' => $date, 'Authorization' => 'OSS ' . $this->config['access_key'] . ':' . $this->signature('GET', '', $date, '/' . $bucket . '/'), ])->get('https://' . $host . '/', $query); if (!$response->successful()) { UtilsService::getInstance()->errorThrow('阿里云列举对象失败:' . $response->body()); } return $this->parseObjectListXml($response->body(), 'https://' . $host); } /** * 删除对象;OSS 对不存在的键也回 204,这里把它当成功处理 */ public function deleteObject(string $key): bool { $this->assertConfig(); $key = $this->normalizeObjectKey($key); if ($key === '') { return false; } $date = gmdate('D, d M Y H:i:s \G\M\T'); $resource = '/' . $this->config['bucket'] . '/' . $key; $response = Http::withHeaders([ 'Date' => $date, 'Authorization' => 'OSS ' . $this->config['access_key'] . ':' . $this->signature('DELETE', '', $date, $resource), ])->delete('https://' . $this->host() . '/' . $key); return $response->successful(); } /** * 使用 OSS V1 签名上传对象 */ private function uploadFile($filePath, string $key): bool|array { $this->assertConfig(); $domain = rtrim((string) ($this->config['domain'] ?? ''), '/'); $bucket = (string) $this->config['bucket']; $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); $contentType = 'application/octet-stream'; $date = gmdate('D, d M Y H:i:s \G\M\T'); $signature = $this->signature('PUT', $contentType, $date, '/' . $bucket . '/' . $key); $url = 'https://' . $this->host() . '/' . $key; $response = Http::withHeaders([ 'Date' => $date, 'Content-Type' => $contentType, 'Authorization' => 'OSS ' . $this->config['access_key'] . ':' . $signature, ])->withBody($content, $contentType)->put($url); if (!$response->successful()) { UtilsService::getInstance()->errorThrow('阿里云上传失败:' . $response->body()); } $publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url; return ['key' => $key, 'url' => $publicUrl]; } /** * OSS V1 签名:上传、列举、删除共用同一套 StringToSign 拼法 */ private function signature(string $method, string $contentType, string $date, string $resource): string { $stringToSign = "{$method}\n\n{$contentType}\n{$date}\n{$resource}"; return base64_encode(hash_hmac('sha1', $stringToSign, (string) $this->config['secret_key'], true)); } /** * 请求主机名;支持配置里填 oss-cn-xxx.aliyuncs.com 或已带 bucket 的域名 */ private function host(): string { $bucket = (string) ($this->config['bucket'] ?? ''); $host = (string) preg_replace('#^https?://#', '', rtrim((string) ($this->config['endpoint'] ?? ''), '/')); if (!str_starts_with($host, $bucket . '.')) { $host = $bucket . '.' . $host; } return $host; } 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['endpoint'] ?? '')) === '' ) { UtilsService::getInstance()->errorThrow('阿里云 OSS 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)'); } } }