初始化
缺陷:主题配色需要优化整体的同风格
This commit is contained in:
@@ -122,6 +122,44 @@ class JWTService
|
||||
|
||||
return $this->generateToken((array)$decoded->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析已过期但签名有效的 token
|
||||
*
|
||||
* 续签场景下 token 必然已经过期,正常 decode 会直接抛 ExpiredException。
|
||||
* 这里临时放宽 leeway 让 exp 校验通过,签名与 Redis 会话仍然照常校验,
|
||||
* 所以过期的 token 依旧不能凭空续签——会话被登出或超过宽限期就必须重新登录。
|
||||
*
|
||||
* @param int $leeway 允许的过期宽限秒数
|
||||
*/
|
||||
public function parseExpiringToken(int $leeway): ?object
|
||||
{
|
||||
$token = request()->bearerToken();
|
||||
if (empty($token)) {
|
||||
return null;
|
||||
}
|
||||
$origin = JWT::$leeway;
|
||||
JWT::$leeway = max(0, $leeway);
|
||||
try {
|
||||
return JWT::decode($token, new Key($this->secretKey, 'HS256'));
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
} finally {
|
||||
JWT::$leeway = $origin;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 作废某个用户的登录态:删掉 Redis 会话,手里的 token 立即失效
|
||||
* (getUserInfo 拿不到会话就会 notAuth,所以不需要维护黑名单)
|
||||
*/
|
||||
public function revoke(int $userId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
return RedisService::getInstance()->init(config('nl.redis.jwt'))->del($userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
100
app/Service/common/MediaUrlService.php
Normal file
100
app/Service/common/MediaUrlService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common;
|
||||
|
||||
/**
|
||||
* 媒体地址规整
|
||||
*
|
||||
* 老后台的约定是:入库存相对路径(/storage/xxx.jpg),出库用 asset() 拼成绝对地址;
|
||||
* 而 OSS 上传拿到的本来就是绝对地址。两种值混在同一列里,读写都得判断一次。
|
||||
* 这里把判断收在一处,业务 Service 只调 toPublic / toStorage。
|
||||
*/
|
||||
class MediaUrlService
|
||||
{
|
||||
private static mixed $_instance;
|
||||
|
||||
public static function getInstance(): null|static
|
||||
{
|
||||
$name = get_called_class();
|
||||
if (!isset(self::$_instance[$name])) {
|
||||
self::$_instance[$name] = new static();
|
||||
}
|
||||
return self::$_instance[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 出库:相对路径补上站点域名,绝对地址原样返回
|
||||
*/
|
||||
public function toPublic(?string $path): string
|
||||
{
|
||||
$path = trim((string) $path);
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
if ($this->isAbsolute($path)) {
|
||||
return $path;
|
||||
}
|
||||
return rtrim((string) config('app.url'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 入库:本站域名下的地址存成相对路径,避免换域名后历史数据全指向旧域名;
|
||||
* 第三方 OSS 地址保持原样
|
||||
*/
|
||||
public function toStorage(?string $url): string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
$appUrl = rtrim((string) config('app.url'), '/');
|
||||
if ($appUrl !== '' && str_starts_with($url, $appUrl)) {
|
||||
return substr($url, strlen($appUrl)) ?: '';
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量出库,给 list 用
|
||||
*/
|
||||
public function publicEach(array &$rows, array $fields): void
|
||||
{
|
||||
foreach ($rows as &$row) {
|
||||
foreach ($fields as $field) {
|
||||
if (array_key_exists($field, $row)) {
|
||||
$row[$field] = $this->toPublic($row[$field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端图片上传组件回传的是数组,取第一个;已是字符串则原样
|
||||
*/
|
||||
public function firstOf(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = $value[0] ?? '';
|
||||
}
|
||||
return $this->toStorage(is_string($value) ? $value : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉 OSS 处理参数(?imageView2/... 或 ?watermark/...),用于素材库按 path 做引用匹配
|
||||
*/
|
||||
public function stripProcessParams(?string $url): string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
$pos = strpos($url, '?');
|
||||
return $pos === false ? $url : substr($url, 0, $pos);
|
||||
}
|
||||
|
||||
private function isAbsolute(string $path): bool
|
||||
{
|
||||
return (bool) preg_match('#^(https?:)?//#i', $path);
|
||||
}
|
||||
}
|
||||
@@ -91,4 +91,29 @@ class RedisService
|
||||
{
|
||||
return $this->redis::del($this->prefix . $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空当前前缀下的全部键
|
||||
*
|
||||
* 菜单这类按角色分片缓存的数据,改了菜单树要一次性失效所有角色的副本。
|
||||
* KEYS 的返回值带着 redis 客户端自身的 prefix,直接回传给 del 会二次拼前缀,所以要先剥掉。
|
||||
* @return int 删除的键数量
|
||||
*/
|
||||
public function delAll(): int
|
||||
{
|
||||
$keys = $this->redis::keys($this->prefix . '*');
|
||||
if (empty($keys)) {
|
||||
return 0;
|
||||
}
|
||||
$clientPrefix = (string) config('database.redis.options.prefix');
|
||||
$count = 0;
|
||||
foreach ($keys as $key) {
|
||||
if ($clientPrefix !== '' && str_starts_with($key, $clientPrefix)) {
|
||||
$key = substr($key, strlen($clientPrefix));
|
||||
}
|
||||
$this->redis::del($key);
|
||||
$count++;
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,4 +71,31 @@ class UploadService extends BaseService
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文档(商品图册的 PDF、订单转账凭证的 PDF 等)
|
||||
*
|
||||
* 驱动层的 uploadVideo 就是通用的 put,图册 PDF 之前只能借 video 通道上传,
|
||||
* 结果 key 落在 spa/video 下,素材库按目录归类时全错位,故单独开一路。
|
||||
*
|
||||
* @param mixed $file 上传文件对象
|
||||
*/
|
||||
public function uploadDocument($file): array|bool
|
||||
{
|
||||
$ext = strtolower($file->getClientOriginalExtension());
|
||||
$allowed = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'zip'];
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
$this->utils->errorThrow('不支持的文件类型:' . $ext);
|
||||
}
|
||||
$key = 'spa/file/' . date('Ymd') . '/' . 'cc_upload_' . Str::random() . uniqid() . '.' . $ext;
|
||||
$result = $this->uploadService->uploadVideo($file, $key);
|
||||
if (!$result) {
|
||||
$this->utils->errorThrow('文件上传失败');
|
||||
}
|
||||
FileService::getInstance()->create([
|
||||
'user_id' => $this->userId,
|
||||
'url' => $result['url'],
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,14 +243,20 @@ class UtilsService
|
||||
{
|
||||
foreach ($class as $key => $value) {
|
||||
|
||||
$reflection = new ReflectionClass($value);
|
||||
// 获取控制器$value的所有方法
|
||||
$methods = (new ReflectionClass($value))->getMethods();
|
||||
$methods = $reflection->getMethods();
|
||||
// 子类声明的排除清单:继承来的 CRUD 也会被反射到,不该暴露的在这里拦掉
|
||||
$exceptRoute = (array) ($reflection->getDefaultProperties()['exceptRoute'] ?? []);
|
||||
|
||||
// 注册路由
|
||||
foreach ($methods as $method) {
|
||||
// 获取方法注释 @Method
|
||||
$docComment = $method->getDocComment();
|
||||
if ($method->name === '__construct' || preg_match('/@Method\s+(NO)\b/', $docComment, $matches)) {
|
||||
if ($method->name === '__construct'
|
||||
|| in_array($method->name, $exceptRoute, true)
|
||||
|| preg_match('/@Method\s+(NO)\b/', (string) $docComment, $matches)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\BaseApp\BaseService;
|
||||
use App\Models\oss\OssConfigModel;
|
||||
use App\Service\common\FieldEncryptService;
|
||||
use App\Service\SystemConfigService;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* OSS 运行时配置:解析当前启用的存储配置并解密密钥,供上传工厂使用
|
||||
@@ -74,6 +75,32 @@ class OssRuntimeConfigService extends BaseService
|
||||
'extra_json' => null,
|
||||
];
|
||||
}
|
||||
return $this->formatConfig($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 获取明文配置
|
||||
*
|
||||
* 素材库要对指定 bucket 做列举与删除,不能只认「当前启用」那一份:
|
||||
* 换过存储之后老素材仍然躺在旧配置的 bucket 里,回收时必须拿旧配置去删。
|
||||
* 也因此这里不校验 status —— 配置被禁用不代表里面的对象不用管了。
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getConfigById(int $id): array
|
||||
{
|
||||
$row = OssConfigModel::where('id', $id)->where('deleted_at', 0)->first();
|
||||
if (!$row) {
|
||||
$this->utils->notFound('存储配置不存在');
|
||||
}
|
||||
return $this->formatConfig($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 库行转明文配置数组
|
||||
*/
|
||||
private function formatConfig(OssConfigModel $row): array
|
||||
{
|
||||
$enc = FieldEncryptService::getInstance();
|
||||
$extra = $row->extra_json;
|
||||
if (is_string($extra) && $extra !== '') {
|
||||
|
||||
@@ -31,4 +31,24 @@ interface OssStorageInterface
|
||||
* @return array{key:string,url:string}|false
|
||||
*/
|
||||
public function uploadVideo($filePath, string $key): bool|array;
|
||||
|
||||
/**
|
||||
* 分页列举对象
|
||||
*
|
||||
* 素材库靠 marker 一页一页往回补:bucket 上万对象时一次拉全量必然打穿
|
||||
* PHP 的执行时限,所以约定「调用方拿着 next_marker 继续要下一页」。
|
||||
*
|
||||
* @param string $prefix 只列举该前缀,留空时退回配置里的 path_prefix
|
||||
* @param string $marker 上一页返回的 next_marker,首页传空串
|
||||
* @param int $limit 单页条数
|
||||
* @return array{items: array<int, array{key:string,size:int,hash:string,last_modified:int,url:string}>, next_marker: string, finished: bool}
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array;
|
||||
|
||||
/**
|
||||
* 删除对象
|
||||
*
|
||||
* @param string $key 对象键(缺 path_prefix 时由实现补齐)
|
||||
*/
|
||||
public function deleteObject(string $key): bool;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ use Illuminate\Support\Facades\Http;
|
||||
*/
|
||||
class AliyunStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
use ObjectListXmlTrait;
|
||||
|
||||
protected array $config = [];
|
||||
|
||||
public function withConfig(array $config): static
|
||||
@@ -30,19 +33,65 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
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
|
||||
{
|
||||
$accessKey = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
$endpoint = (string) ($this->config['endpoint'] ?? '');
|
||||
$this->assertConfig();
|
||||
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
||||
if ($accessKey === '' || $secretKey === '' || $bucket === '' || $endpoint === '') {
|
||||
UtilsService::getInstance()->errorThrow('阿里云 OSS 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)');
|
||||
}
|
||||
$bucket = (string) $this->config['bucket'];
|
||||
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
|
||||
$key = $prefix . '/' . ltrim($key, '/');
|
||||
@@ -53,19 +102,12 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
$content = file_get_contents($path);
|
||||
$contentType = 'application/octet-stream';
|
||||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||
$resource = '/' . $bucket . '/' . $key;
|
||||
$stringToSign = "PUT\n\n{$contentType}\n{$date}\n{$resource}";
|
||||
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $secretKey, true));
|
||||
$host = preg_replace('#^https?://#', '', rtrim($endpoint, '/'));
|
||||
// 支持传入 oss-cn-xxx.aliyuncs.com 或带 bucket 的域名
|
||||
if (!str_starts_with($host, $bucket . '.')) {
|
||||
$host = $bucket . '.' . $host;
|
||||
}
|
||||
$url = 'https://' . $host . '/' . $key;
|
||||
$signature = $this->signature('PUT', $contentType, $date, '/' . $bucket . '/' . $key);
|
||||
$url = 'https://' . $this->host() . '/' . $key;
|
||||
$response = Http::withHeaders([
|
||||
'Date' => $date,
|
||||
'Content-Type' => $contentType,
|
||||
'Authorization' => 'OSS ' . $accessKey . ':' . $signature,
|
||||
'Authorization' => 'OSS ' . $this->config['access_key'] . ':' . $signature,
|
||||
])->withBody($content, $contentType)->put($url);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow('阿里云上传失败:' . $response->body());
|
||||
@@ -73,4 +115,37 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
$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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
*/
|
||||
class LocalhostStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
|
||||
protected array $config = [
|
||||
'domain' => '',
|
||||
'path_prefix' => '',
|
||||
@@ -45,6 +47,56 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn
|
||||
return $this->deleteFile($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫本地磁盘目录
|
||||
*
|
||||
* 本地盘没有 marker 这种服务端游标,用「已列举条数」当偏移量模拟:
|
||||
* 先把文件名排序固定顺序,再按偏移切页,这样反复调用能稳定推进。
|
||||
* 代价是同步期间新增文件会让偏移错位,但素材同步是幂等的,下一轮就自愈。
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
||||
{
|
||||
$listPrefix = rtrim($this->scopedListPrefix($prefix), '/');
|
||||
$offset = max(0, (int) $marker);
|
||||
$files = Storage::allFiles($listPrefix);
|
||||
sort($files);
|
||||
$page = array_slice($files, $offset, $this->boundedLimit($limit));
|
||||
|
||||
$items = [];
|
||||
foreach ($page as $file) {
|
||||
$key = $this->normalizeObjectKey($file);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$realPath = Storage::path($file);
|
||||
$items[] = [
|
||||
'key' => $key,
|
||||
'size' => (int) Storage::size($file),
|
||||
// 本地盘没有服务端 ETag,用 md5 顶上,素材库靠它判重与校验
|
||||
'hash' => is_file($realPath) ? (string) md5_file($realPath) : '',
|
||||
'last_modified' => (int) Storage::lastModified($file),
|
||||
'url' => $this->publicUrlOf($key, asset('/storage/' . $key)),
|
||||
];
|
||||
}
|
||||
|
||||
$next = $offset + count($page);
|
||||
$finished = $next >= count($files);
|
||||
return [
|
||||
'items' => $items,
|
||||
'next_marker' => $finished ? '' : (string) $next,
|
||||
'finished' => $finished,
|
||||
];
|
||||
}
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
return $this->deleteFile($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入本地 storage;兼容 UploadedFile 与路径字符串
|
||||
*/
|
||||
|
||||
66
app/Service/common/upload/ObjectKeyNormalizeTrait.php
Normal file
66
app/Service/common/upload/ObjectKeyNormalizeTrait.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\upload;
|
||||
|
||||
/**
|
||||
* 对象键规整
|
||||
*
|
||||
* 五个驱动在列举 / 删除时对 key 的处理完全一致(补 path_prefix、压斜杠、拼 domain),
|
||||
* 抽出来免得同一段逻辑在五个文件里各写一遍、各改一半。
|
||||
* 使用方需要有 $this->config(由 OssRuntimeConfigService 注入,含 path_prefix / domain)。
|
||||
*/
|
||||
trait ObjectKeyNormalizeTrait
|
||||
{
|
||||
/**
|
||||
* 补上 path_prefix 并压平重复斜杠
|
||||
*
|
||||
* 为什么必须压平:uploads//spa/a.jpg 与 uploads/spa/a.jpg 在 OSS 上是两个对象,
|
||||
* 但拼出来的访问地址会被 CDN 归一成同一个。素材库按 path 建索引,
|
||||
* 不统一就会落成两条记录,回收删掉其中一条后另一条变成指向已删对象的幽灵。
|
||||
*/
|
||||
protected function normalizeObjectKey(string $key): string
|
||||
{
|
||||
$key = ltrim((string) preg_replace('#/{2,}#', '/', trim($key)), '/');
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
if ($prefix !== '' && $key !== $prefix && !str_starts_with($key, $prefix . '/')) {
|
||||
$key = $prefix . '/' . $key;
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举前缀:调用方没给就退回 path_prefix
|
||||
*
|
||||
* 同一个 bucket 常常被多个项目共用,不加这层兜底会把别人的对象也拉进素材库,
|
||||
* 之后走回收流程就等于跨项目删文件。
|
||||
*/
|
||||
protected function scopedListPrefix(string $prefix): string
|
||||
{
|
||||
$prefix = trim($prefix);
|
||||
if ($prefix !== '') {
|
||||
return $this->normalizeObjectKey($prefix);
|
||||
}
|
||||
$base = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
return $base === '' ? '' : $base . '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼公开访问地址;未配置 domain 时回落到调用方给的默认地址
|
||||
*/
|
||||
protected function publicUrlOf(string $key, string $fallback = ''): string
|
||||
{
|
||||
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
||||
return $domain !== '' ? $domain . '/' . $key : $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单页列举条数收口,避免上游把 limit 传成 0 或者十万
|
||||
*/
|
||||
protected function boundedLimit(int $limit): int
|
||||
{
|
||||
return max(1, min($limit, 1000));
|
||||
}
|
||||
}
|
||||
64
app/Service/common/upload/ObjectListXmlTrait.php
Normal file
64
app/Service/common/upload/ObjectListXmlTrait.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\common\upload;
|
||||
|
||||
use App\Service\common\UtilsService;
|
||||
|
||||
/**
|
||||
* ListBucketResult 解析
|
||||
*
|
||||
* 阿里云 GET Bucket、腾讯云 GET Bucket、S3 ListObjectsV2 回的都是同一套
|
||||
* <ListBucketResult><Contents>… 结构,只有续拉游标的节点名不同(NextMarker /
|
||||
* NextContinuationToken)。三个驱动共用本 trait,避免同一段 XML 遍历写三遍。
|
||||
* 使用方需要有 $this->config 以及 ObjectKeyNormalizeTrait 提供的 key 规整方法。
|
||||
*/
|
||||
trait ObjectListXmlTrait
|
||||
{
|
||||
/**
|
||||
* @param string $xml 响应体
|
||||
* @param string $urlBase 未配置 domain 时兜底拼地址用的主机前缀(不带尾斜杠)
|
||||
* @param string $tokenNode 续拉游标节点名
|
||||
* @return array{items: array<int, array{key:string,size:int,hash:string,last_modified:int,url:string}>, next_marker: string, finished: bool}
|
||||
*/
|
||||
protected function parseObjectListXml(string $xml, string $urlBase, string $tokenNode = 'NextMarker'): array
|
||||
{
|
||||
$doc = @simplexml_load_string($xml);
|
||||
if ($doc === false) {
|
||||
UtilsService::getInstance()->errorThrow('列举结果解析失败,返回内容不是合法 XML');
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$lastRawKey = '';
|
||||
$contents = isset($doc->Contents) ? $doc->Contents : [];
|
||||
foreach ($contents as $node) {
|
||||
$rawKey = (string) $node->Key;
|
||||
// 续拉游标必须用服务端原样返回的 key,不能用补过 prefix 的规整值
|
||||
$lastRawKey = $rawKey;
|
||||
$key = $this->normalizeObjectKey($rawKey);
|
||||
if ($key === '' || str_ends_with($key, '/')) {
|
||||
// 以 / 结尾的是控制台建目录留下的占位对象,不是素材
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'key' => $key,
|
||||
'size' => (int) $node->Size,
|
||||
'hash' => strtolower(trim((string) $node->ETag, '"')),
|
||||
'last_modified' => (int) strtotime((string) $node->LastModified),
|
||||
'url' => $this->publicUrlOf($key, $urlBase . '/' . $key),
|
||||
];
|
||||
}
|
||||
|
||||
$truncated = filter_var((string) ($doc->IsTruncated ?? 'false'), FILTER_VALIDATE_BOOLEAN);
|
||||
$next = isset($doc->{$tokenNode}) ? (string) $doc->{$tokenNode} : '';
|
||||
if ($truncated && $next === '' && $tokenNode === 'NextMarker') {
|
||||
// 部分兼容实现只给 IsTruncated 不给 NextMarker,按协议可用本页最后一个 key 续拉
|
||||
$next = $lastRawKey;
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'next_marker' => $truncated ? $next : '',
|
||||
'finished' => !$truncated || $next === '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ use Illuminate\Support\Facades\Http;
|
||||
*/
|
||||
class QcloudStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
use ObjectListXmlTrait;
|
||||
|
||||
protected array $config = [];
|
||||
|
||||
public function withConfig(array $config): static
|
||||
@@ -30,19 +33,60 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
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
|
||||
{
|
||||
$secretId = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
$region = (string) ($this->config['region'] ?? '');
|
||||
$this->assertConfig();
|
||||
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
||||
if ($secretId === '' || $secretKey === '' || $bucket === '' || $region === '') {
|
||||
UtilsService::getInstance()->errorThrow('腾讯云 COS 配置不完整(需要 SecretId/SecretKey/Bucket/Region)');
|
||||
}
|
||||
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
|
||||
$key = $prefix . '/' . ltrim($key, '/');
|
||||
@@ -51,25 +95,12 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
? $filePath->getRealPath()
|
||||
: (string) $filePath;
|
||||
$content = file_get_contents($path);
|
||||
$host = $bucket . '.cos.' . $region . '.myqcloud.com';
|
||||
$host = $this->host();
|
||||
$urlPath = '/' . ltrim($key, '/');
|
||||
$now = time();
|
||||
$keyTime = $now . ';' . ($now + 600);
|
||||
$signKey = hash_hmac('sha1', $keyTime, $secretKey);
|
||||
$httpString = strtolower('put') . "\n" . $urlPath . "\n\nhost=" . strtolower($host) . "\n";
|
||||
$stringToSign = "sha1\n{$keyTime}\n" . sha1($httpString) . "\n";
|
||||
$signature = hash_hmac('sha1', $stringToSign, $signKey);
|
||||
$authorization = 'q-sign-algorithm=sha1'
|
||||
. '&q-ak=' . $secretId
|
||||
. '&q-sign-time=' . $keyTime
|
||||
. '&q-key-time=' . $keyTime
|
||||
. '&q-header-list=host'
|
||||
. '&q-url-param-list='
|
||||
. '&q-signature=' . $signature;
|
||||
$url = 'https://' . $host . $urlPath;
|
||||
$response = Http::withHeaders([
|
||||
'Host' => $host,
|
||||
'Authorization' => $authorization,
|
||||
'Authorization' => $this->authorization('PUT', $urlPath, []),
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
])->withBody($content, 'application/octet-stream')->put($url);
|
||||
if (!$response->successful()) {
|
||||
@@ -78,4 +109,61 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
$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();
|
||||
$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<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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Service\common\UtilsService;
|
||||
*/
|
||||
class QiniuStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
|
||||
protected array $config = [
|
||||
'access_key' => '',
|
||||
'secret_key' => '',
|
||||
@@ -48,6 +50,54 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
return $this->deleteFile($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举空间对象(BucketManager::listFiles,marker 分页)
|
||||
*
|
||||
* 七牛只在还有下一页时才回 marker,所以 marker 为空即等于列完了。
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
||||
{
|
||||
$bucketMgr = $this->bucketManager();
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
[$ret, $err] = $bucketMgr->listFiles(
|
||||
$bucket,
|
||||
$this->scopedListPrefix($prefix),
|
||||
$marker !== '' ? $marker : null,
|
||||
$this->boundedLimit($limit)
|
||||
);
|
||||
if ($err !== null) {
|
||||
UtilsService::getInstance()->errorThrow('七牛云列举对象失败:' . $this->errorText($err));
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ((array) ($ret['items'] ?? []) as $row) {
|
||||
$key = $this->normalizeObjectKey((string) ($row['key'] ?? ''));
|
||||
if ($key === '' || str_ends_with($key, '/')) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'key' => $key,
|
||||
'size' => (int) ($row['fsize'] ?? 0),
|
||||
'hash' => (string) ($row['hash'] ?? ''),
|
||||
// putTime 的单位是 100 纳秒,当秒用会得到五亿年后的时间戳
|
||||
'last_modified' => intdiv((int) ($row['putTime'] ?? 0), 10000000),
|
||||
'url' => $this->publicUrlOf($key, $key),
|
||||
];
|
||||
}
|
||||
|
||||
$next = (string) ($ret['marker'] ?? '');
|
||||
return [
|
||||
'items' => $items,
|
||||
'next_marker' => $next,
|
||||
'finished' => $next === '',
|
||||
];
|
||||
}
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
return $this->deleteFile($this->normalizeObjectKey($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传到七牛;无 SDK 时抛业务异常引导安装或改本地
|
||||
*/
|
||||
@@ -93,4 +143,35 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
$err = $bucketMgr->delete($this->config['bucket'], $key);
|
||||
return $err === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 BucketManager,顺手把「没装 SDK / 没填配置」两种情况前置拦掉
|
||||
*/
|
||||
private function bucketManager(): \Qiniu\Storage\BucketManager
|
||||
{
|
||||
if (!class_exists(\Qiniu\Auth::class)) {
|
||||
UtilsService::getInstance()->errorThrow('未安装 qiniu/php-sdk,请改用本地存储或安装依赖');
|
||||
}
|
||||
$accessKey = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
if ($accessKey === '' || $secretKey === '' || $bucket === '') {
|
||||
UtilsService::getInstance()->errorThrow('七牛云配置不完整');
|
||||
}
|
||||
return new \Qiniu\Storage\BucketManager(new \Qiniu\Auth($accessKey, $secretKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK 的错误对象没有统一契约,转成人能看懂的一行字
|
||||
*/
|
||||
private function errorText(mixed $err): string
|
||||
{
|
||||
if (is_object($err) && method_exists($err, 'message')) {
|
||||
return (string) $err->message();
|
||||
}
|
||||
if (is_object($err) || is_array($err)) {
|
||||
return (string) json_encode($err, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
return (string) $err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Service\common\upload;
|
||||
use App\BaseApp\BaseNotAuthService;
|
||||
use App\Service\common\oss\OssStorageInterface;
|
||||
use App\Service\common\UtilsService;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,9 @@ use Illuminate\Support\Facades\Http;
|
||||
*/
|
||||
class S3CompatibleStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
use ObjectListXmlTrait;
|
||||
|
||||
protected array $config = [];
|
||||
|
||||
public function withConfig(array $config): static
|
||||
@@ -31,21 +35,51 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag
|
||||
return $this->uploadFile($filePath, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* ListObjectsV2;游标是 continuation-token,不是 V1 那种 marker
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
||||
{
|
||||
$this->assertConfig();
|
||||
$bucket = (string) $this->config['bucket'];
|
||||
$query = [
|
||||
'list-type' => '2',
|
||||
'max-keys' => (string) $this->boundedLimit($limit),
|
||||
];
|
||||
$listPrefix = $this->scopedListPrefix($prefix);
|
||||
if ($listPrefix !== '') {
|
||||
$query['prefix'] = $listPrefix;
|
||||
}
|
||||
if ($marker !== '') {
|
||||
$query['continuation-token'] = $marker;
|
||||
}
|
||||
$response = $this->signedRequest('GET', '/' . $bucket, $query);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow($this->driver() . ' 列举对象失败:' . $response->body());
|
||||
}
|
||||
$endpoint = rtrim((string) $this->config['endpoint'], '/');
|
||||
return $this->parseObjectListXml($response->body(), $endpoint . '/' . $bucket, 'NextContinuationToken');
|
||||
}
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$this->assertConfig();
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
$response = $this->signedRequest('DELETE', '/' . $this->config['bucket'] . '/' . $key);
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
* SigV4 PUT;endpoint 必填(如 https://s3.amazonaws.com 或 MinIO 地址)
|
||||
*/
|
||||
private function uploadFile($filePath, string $key): bool|array
|
||||
{
|
||||
$accessKey = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
$region = (string) ($this->config['region'] ?? 'us-east-1');
|
||||
$endpoint = rtrim((string) ($this->config['endpoint'] ?? ''), '/');
|
||||
$this->assertConfig();
|
||||
$bucket = (string) $this->config['bucket'];
|
||||
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
||||
$driver = (string) ($this->config['driver'] ?? 'aws');
|
||||
if ($accessKey === '' || $secretKey === '' || $bucket === '' || $endpoint === '') {
|
||||
UtilsService::getInstance()->errorThrow(strtoupper($driver) . ' 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)');
|
||||
}
|
||||
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
|
||||
$key = $prefix . '/' . ltrim($key, '/');
|
||||
@@ -54,37 +88,96 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag
|
||||
? $filePath->getRealPath()
|
||||
: (string) $filePath;
|
||||
$payload = file_get_contents($path);
|
||||
$response = $this->signedRequest('PUT', '/' . $bucket . '/' . $key, [], $payload);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow($this->driver() . ' 上传失败:' . $response->body());
|
||||
}
|
||||
$url = rtrim((string) $this->config['endpoint'], '/') . $this->canonicalUri('/' . $bucket . '/' . $key);
|
||||
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
|
||||
return ['key' => $key, 'url' => $publicUrl];
|
||||
}
|
||||
|
||||
/**
|
||||
* SigV4 签名并发起请求
|
||||
*
|
||||
* 上传、列举、删除三条路的差别只是 method / 路径 / 查询参数 / 请求体,
|
||||
* 签名步骤一模一样,所以收在这里;三份复制粘贴改一处漏两处是必然的。
|
||||
* 走 path-style(endpoint/bucket/key),多数 MinIO 与 OBS 都接受。
|
||||
*
|
||||
* @param string $path 未编码的路径,如 /bucket/dir/a.jpg
|
||||
* @param array<string, string> $query 参与 CanonicalQueryString 的查询参数
|
||||
*/
|
||||
private function signedRequest(string $method, string $path, array $query = [], string $payload = ''): Response
|
||||
{
|
||||
$accessKey = (string) $this->config['access_key'];
|
||||
$secretKey = (string) $this->config['secret_key'];
|
||||
$region = (string) ($this->config['region'] ?? 'us-east-1');
|
||||
$endpoint = rtrim((string) $this->config['endpoint'], '/');
|
||||
$host = parse_url($endpoint, PHP_URL_HOST) ?: preg_replace('#^https?://#', '', $endpoint);
|
||||
// path-style: endpoint/bucket/key
|
||||
$canonicalUri = '/' . rawurlencode($bucket) . '/' . str_replace('%2F', '/', rawurlencode($key));
|
||||
// 简化:多数 MinIO/OBS 接受 path-style
|
||||
$url = $endpoint . '/' . $bucket . '/' . $key;
|
||||
|
||||
$canonicalUri = $this->canonicalUri($path);
|
||||
ksort($query);
|
||||
$pairs = [];
|
||||
foreach ($query as $name => $value) {
|
||||
$pairs[] = rawurlencode((string) $name) . '=' . rawurlencode((string) $value);
|
||||
}
|
||||
$canonicalQuery = implode('&', $pairs);
|
||||
|
||||
$amzDate = gmdate('Ymd\THis\Z');
|
||||
$dateStamp = gmdate('Ymd');
|
||||
$payloadHash = hash('sha256', $payload);
|
||||
$canonicalHeaders = "host:{$host}\nx-amz-content-sha256:{$payloadHash}\nx-amz-date:{$amzDate}\n";
|
||||
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date';
|
||||
$canonicalRequest = "PUT\n{$canonicalUri}\n\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}";
|
||||
$service = $driver === 'huawei' ? 's3' : 's3';
|
||||
$credentialScope = "{$dateStamp}/{$region}/{$service}/aws4_request";
|
||||
$canonicalRequest = strtoupper($method) . "\n{$canonicalUri}\n{$canonicalQuery}\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}";
|
||||
$credentialScope = "{$dateStamp}/{$region}/s3/aws4_request";
|
||||
$stringToSign = "AWS4-HMAC-SHA256\n{$amzDate}\n{$credentialScope}\n" . hash('sha256', $canonicalRequest);
|
||||
$kDate = hash_hmac('sha256', $dateStamp, 'AWS4' . $secretKey, true);
|
||||
$kRegion = hash_hmac('sha256', $region, $kDate, true);
|
||||
$kService = hash_hmac('sha256', $service, $kRegion, true);
|
||||
$kService = hash_hmac('sha256', 's3', $kRegion, true);
|
||||
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
|
||||
$signature = hash_hmac('sha256', $stringToSign, $kSigning);
|
||||
$authorization = "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}";
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => $authorization,
|
||||
|
||||
$request = Http::withHeaders([
|
||||
'Authorization' => "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}",
|
||||
'x-amz-content-sha256' => $payloadHash,
|
||||
'x-amz-date' => $amzDate,
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Host' => $host,
|
||||
])->withBody($payload, 'application/octet-stream')->put($url);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow($driver . ' 上传失败:' . $response->body());
|
||||
]);
|
||||
$url = $endpoint . $canonicalUri . ($canonicalQuery !== '' ? '?' . $canonicalQuery : '');
|
||||
return match (strtoupper($method)) {
|
||||
'PUT' => $request->withBody($payload, 'application/octet-stream')->put($url),
|
||||
'DELETE' => $request->delete($url),
|
||||
default => $request->get($url),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐段编码路径:整段 rawurlencode 会把分隔符 / 也编掉,签名与实际路径就对不上了
|
||||
*/
|
||||
private function canonicalUri(string $path): string
|
||||
{
|
||||
$segments = array_map(
|
||||
static fn ($segment) => rawurlencode($segment),
|
||||
explode('/', ltrim($path, '/'))
|
||||
);
|
||||
return '/' . implode('/', $segments);
|
||||
}
|
||||
|
||||
private function driver(): string
|
||||
{
|
||||
return (string) ($this->config['driver'] ?? 'aws');
|
||||
}
|
||||
|
||||
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(
|
||||
strtoupper($this->driver()) . ' 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)'
|
||||
);
|
||||
}
|
||||
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
|
||||
return ['key' => $key, 'url' => $publicUrl];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user