diff --git a/backend/driver/upload/Oss/Driver.php b/backend/driver/upload/Oss/Driver.php index 2d18b1fc8..d36fcb0d0 100644 --- a/backend/driver/upload/Oss/Driver.php +++ b/backend/driver/upload/Oss/Driver.php @@ -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, ]; } diff --git a/backend/driver/upload/Oss/plugin.json b/backend/driver/upload/Oss/plugin.json index 894d009af..8b926d856 100644 --- a/backend/driver/upload/Oss/plugin.json +++ b/backend/driver/upload/Oss/plugin.json @@ -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": { diff --git a/frontend/admin/src/utils/videoCloud.js b/frontend/admin/src/utils/videoCloud.js index 19100de92..f2a4d8e5c 100644 --- a/frontend/admin/src/utils/videoCloud.js +++ b/frontend/admin/src/utils/videoCloud.js @@ -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 }); }); }); },