fix(OSS上传): 支持 Signature V4 区域配置,管理端直传改走 V4 表单字段

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
吴昊天 2026-10-27 13:09:31 +08:00
parent 5f342bb505
commit f8ed83aa9a
3 changed files with 161 additions and 28 deletions

View File

@ -14,6 +14,7 @@ use crmeb\services\upload\BaseUpload;
use crmeb\exceptions\AdminException;
use crmeb\exceptions\UploadException;
use OSS\Core\OssException;
use OSS\Credentials\StaticCredentialsProvider;
use OSS\Model\CorsConfig;
use OSS\Model\CorsRule;
use OSS\OssClient;
@ -62,6 +63,12 @@ class Driver extends BaseUpload
*/
protected $storageRegion;
/**
* OSS 区域 ID(V4 签名必填,如 cn-hangzhou)
* @var string
*/
protected $regionId = '';
/**
* 水印位置
* @var string[]
@ -96,7 +103,13 @@ class Driver extends BaseUpload
$this->uploadUrl = rtrim(trim($config['uploadUrl'] ?? ''), '/');
$this->storageName = trim($config['storageName'] ?? '');
$this->cdn = rtrim(trim($config['cdn'] ?? ''), '/');
$this->storageRegion = $this->normalizeEndpoint($config['storageRegion'] ?? '');
// region:驱动配置里显式选择的区域;storageRegion:当前启用空间的区域/端点
$configRegion = trim((string)($config['region'] ?? ''));
$rawRegion = trim((string)($config['storageRegion'] ?? ''));
$this->regionId = $this->resolveRegionId($configRegion) ?: $this->resolveRegionId($rawRegion);
// 端点优先用空间区域;空间未填时回退驱动配置区域
$endpointSource = $rawRegion !== '' ? $rawRegion : $configRegion;
$this->storageRegion = $this->normalizeEndpoint($endpointSource);
// 未配置自定义域名时,使用当前空间的公共访问域名。
if (!$this->uploadUrl && $this->storageName && $this->storageRegion) {
$this->uploadUrl = 'https://' . $this->storageName . '.' . parse_url($this->storageRegion, PHP_URL_HOST);
@ -188,13 +201,48 @@ class Driver extends BaseUpload
}
/**
* 实例化 SDK 客户端
* 从区域配置或 endpoint 解析 V4 所需 regionId(如 cn-hangzhou)
* @param string $regionOrEndpoint 区域短名 / oss-cn-xxx / 完整 endpoint
* @return string
*/
protected function resolveRegionId(string $regionOrEndpoint): string
{
$raw = trim($regionOrEndpoint);
if ($raw === '') {
return '';
}
if (preg_match('/^oss-([a-z0-9-]+)$/i', $raw, $m)) {
return strtolower($m[1]);
}
if (preg_match('/^([a-z0-9-]+)$/i', $raw) && !str_contains($raw, '.')) {
// 已是区域短名(cn-hangzhou)
return strtolower($raw);
}
$host = parse_url(strpos($raw, '://') === false ? ('https://' . $raw) : $raw, PHP_URL_HOST) ?: '';
if (preg_match('/^oss-([a-z0-9-]+?)(?:-internal)?\.aliyuncs\.com$/i', (string)$host, $m)) {
return strtolower($m[1]);
}
return '';
}
/**
* 实例化 SDK 客户端(强制 Signature V4:新 UID/新 Bucket 已禁用 V1)
* @param string $endpoint 完整服务端点
* @return OssClient
*/
protected function newOssClient(string $endpoint): OssClient
{
return new OssClient($this->accessKey, $this->secretKey, $endpoint);
$regionId = $this->regionId ?: $this->resolveRegionId($endpoint);
if ($regionId === '') {
throw new UploadException('OSS:无法解析存储区域,请配置形如 oss-cn-hangzhou 的区域');
}
$this->regionId = $regionId;
return new OssClient([
'provider' => new StaticCredentialsProvider($this->accessKey, $this->secretKey),
'endpoint' => $endpoint,
'signatureVersion' => OssClient::OSS_SIGNATURE_VERSION_V4,
'region' => $regionId,
]);
}
/**
@ -600,7 +648,8 @@ class Driver extends BaseUpload
}
/**
* 获取OSS上传密钥
* 获取OSS直传凭证(PostObject Signature V4)
* 新 UID / 新 Bucket 已禁用 V1 表单签名,统一下发 OSS4-HMAC-SHA256 字段
* @return mixed|void
*/
public function getTempKeys($callbackUrl = '', $dir = '')
@ -609,23 +658,47 @@ class Driver extends BaseUpload
if (!$this->accessKey || !$this->secretKey || !$this->uploadUrl) {
return $this->setError('请先配置accessKey、secretKey和空间域名');
}
$expire = time() + 30;
$regionId = $this->regionId ?: $this->resolveRegionId((string)$this->storageRegion);
if ($regionId === '') {
return $this->setError('请先配置有效的存储区域(如 oss-cn-hangzhou)');
}
$expire = time() + 1800;
$utcNow = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$date = $utcNow->format('Ymd');
$ossDate = $utcNow->format('Ymd\THis\Z');
$credential = sprintf('%s/%s/%s/oss/aliyun_v4_request', $this->accessKey, $date, $regionId);
$base64CallbackBody = base64_encode(json_encode([
'callbackUrl' => $callbackUrl,
'callbackBody' => 'filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}',
'callbackBodyType' => "application/x-www-form-urlencoded"
]));
$conditions = [
['content-length-range', 0, 1048576000],
['eq', '$x-oss-signature-version', 'OSS4-HMAC-SHA256'],
['eq', '$x-oss-credential', $credential],
['eq', '$x-oss-date', $ossDate],
];
if ($dir !== '') {
$conditions[] = ['starts-with', '$key', $dir];
}
if ($this->storageName !== '') {
$conditions[] = ['eq', '$bucket', $this->storageName];
}
$policy = json_encode([
'expiration' => $this->gmtIso8601($expire),
'conditions' =>
[
[0 => 'content-length-range', 1 => 0, 2 => 1048576000],
[0 => 'starts-with', 1 => '$key', 2 => $dir]
]
]);
'conditions' => $conditions,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$base64Policy = base64_encode($policy);
$signature = base64_encode(hash_hmac('sha1', $base64Policy, $this->secretKey, true));
// V4 Post 签名:对 Base64(policy) 做派生密钥 HMAC-SHA256,结果为十六进制
$kDate = hash_hmac('sha256', $date, 'aliyun_v4' . $this->secretKey, true);
$kRegion = hash_hmac('sha256', $regionId, $kDate, true);
$kService = hash_hmac('sha256', 'oss', $kRegion, true);
$kSigning = hash_hmac('sha256', 'aliyun_v4_request', $kService, true);
$signature = hash_hmac('sha256', $base64Policy, $kSigning);
return [
'accessid' => $this->accessKey,
'host' => $this->uploadUrl,
@ -634,7 +707,12 @@ class Driver extends BaseUpload
'signature' => $signature,
'expire' => $expire,
'callback' => $base64CallbackBody,
'type' => 'OSS'
'type' => 'OSS',
// PostObject V4 表单字段(前端直传必填)
'signature_version' => 'OSS4-HMAC-SHA256',
'x_oss_credential' => $credential,
'x_oss_date' => $ossDate,
'region' => $regionId,
];
}

View File

@ -37,9 +37,10 @@
"link": "https://www.aliyun.com/product/oss",
"steps": [
"第一步:注册阿里云账号,开通对象存储OSS服务",
"第二步:进入OSS控制台,创建Bucket(空间名称不能重复)",
"第三步(必选):在Bucket列表中,设置读写权限为【公共读】",
"第四步(可选):使用自定义域名时,在Bucket详情中绑定域名并进行CNAME解析"
"第二步:进入OSS控制台,创建Bucket(空间名称不能重复),并记下所在区域",
"第三步(必选):在下方「区域配置」中选择与控制台一致的存储区域(Signature V4 必填)",
"第四步(必选):在Bucket列表中,设置读写权限为【公共读】",
"第五步(可选):使用自定义域名时,在Bucket详情中绑定域名并进行CNAME解析"
]
},
"config_fields": [
@ -58,6 +59,56 @@
"required": true,
"group": "密钥配置",
"tip": "阿里云 SecretKey"
},
{
"field": "region",
"label": "存储区域",
"type": "select",
"required": true,
"group": "区域配置",
"tip": "Bucket 所在区域,Signature V4 必填;须与控制台区域一致(如华东1选杭州)",
"options": [
{
"value": "oss-cn-hangzhou",
"label": "华东1(杭州)"
},
{
"value": "oss-cn-shanghai",
"label": "华东2(上海)"
},
{
"value": "oss-cn-qingdao",
"label": "华北1(青岛)"
},
{
"value": "oss-cn-beijing",
"label": "华北2(北京)"
},
{
"value": "oss-cn-zhangjiakou",
"label": "华北3(张家口)"
},
{
"value": "oss-cn-huhehaote",
"label": "华北5(呼和浩特)"
},
{
"value": "oss-cn-shenzhen",
"label": "华南1(深圳)"
},
{
"value": "oss-cn-hongkong",
"label": "中国香港"
},
{
"value": "oss-us-west-1",
"label": "美国(硅谷)"
},
{
"value": "oss-ap-southeast-1",
"label": "新加坡"
}
]
}
],
"region_map": {

View File

@ -139,7 +139,6 @@ export default {
});
},
ossHttp(evfile, res, videoIng) {
let that = this;
let fileObject = evfile.target.files[0];
let file = fileObject.name;
let pos = file.lastIndexOf('.');
@ -150,28 +149,33 @@ export default {
let filename = this.getVideoName(suffix);
let formData = new FormData();
let data = res.data;
// 注意formData里append添加的键的大小写
formData.append('key', filename); // 存储在oss的文件路径
formData.append('OSSAccessKeyId', data.accessid); // accessKeyId
formData.append('policy', data.policy); // policy
formData.append('Signature', data.signature); // 签名
// 如果是base64文件,那么直接把base64字符串转成blob对象进行上传就可以了
// PostObject Signature V4(新 Bucket 已禁用 V1:OSSAccessKeyId + Signature)
formData.append('key', filename);
formData.append('policy', data.policy);
formData.append('success_action_status', 200);
formData.append('x-oss-signature-version', data.signature_version || 'OSS4-HMAC-SHA256');
formData.append('x-oss-credential', data.x_oss_credential || '');
formData.append('x-oss-date', data.x_oss_date || '');
formData.append('x-oss-signature', data.signature);
// 兼容旧凭证(若后端仍返回 V1 字段)
if (!data.x_oss_credential && data.accessid) {
formData.append('OSSAccessKeyId', data.accessid);
formData.append('Signature', data.signature);
}
formData.append('file', fileObject);
formData.append('success_action_status', 200); // 成功后返回的操作码
let url = data.host;
let fileUrl = url + '/' + filename;
let fileUrl = (data.cdn || url) + '/' + filename;
videoIng(true, 100);
return new Promise((resolve, reject) => {
axios.defaults.withCredentials = false;
axios
.post(url, formData)
.then(() => {
// that.progress = 0;
videoIng(false, 0);
resolve({ url: fileUrl });
})
.catch((res) => {
reject({ msg: res });
.catch((err) => {
reject({ msg: err });
});
});
},