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;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 阿里云 OSS:REST PutObject(Authorization 签名)
|
|
|
|
|
|
*/
|
|
|
|
|
|
class AliyunStorageService 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,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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 18:32:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 使用 OSS V1 签名上传对象
|
|
|
|
|
|
*/
|
|
|
|
|
|
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'] ?? ''), '/');
|
2026-08-14 23:21:21 +08:00
|
|
|
|
$bucket = (string) $this->config['bucket'];
|
2026-08-10 18:32:06 +08:00
|
|
|
|
$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');
|
2026-08-14 23:21:21 +08:00
|
|
|
|
$signature = $this->signature('PUT', $contentType, $date, '/' . $bucket . '/' . $key);
|
|
|
|
|
|
$url = 'https://' . $this->host() . '/' . $key;
|
2026-08-10 18:32:06 +08:00
|
|
|
|
$response = Http::withHeaders([
|
|
|
|
|
|
'Date' => $date,
|
|
|
|
|
|
'Content-Type' => $contentType,
|
2026-08-14 23:21:21 +08:00
|
|
|
|
'Authorization' => 'OSS ' . $this->config['access_key'] . ':' . $signature,
|
2026-08-10 18:32:06 +08:00
|
|
|
|
])->withBody($content, $contentType)->put($url);
|
|
|
|
|
|
if (!$response->successful()) {
|
|
|
|
|
|
UtilsService::getInstance()->errorThrow('阿里云上传失败:' . $response->body());
|
|
|
|
|
|
}
|
|
|
|
|
|
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
|
|
|
|
|
|
return ['key' => $key, 'url' => $publicUrl];
|
|
|
|
|
|
}
|
2026-08-14 23:21:21 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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)');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-10 18:32:06 +08:00
|
|
|
|
}
|