From 9b8b0e4cd530616b99785aefcd4e9ad674aced40 Mon Sep 17 00:00:00 2001 From: taiyi Date: Sun, 31 May 2026 15:10:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20OSS=20=E4=B8=8A=E4=BC=A0=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E6=94=B9=E4=B8=BA=20POST=20=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E7=9B=B4=E4=BC=A0=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=A1=AB=E5=8D=95=E6=8B=8D=E7=85=A7=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端按 OSS POST 表单直传方式编写(期望 policy/access_key_id/signature), 后端原返回 PUT 预签名 URL,字段不匹配导致上传失败。 改为生成 POST policy + HMAC-SHA1 签名,对齐前端已有的 FormData 组装逻辑。 Co-Authored-By: Claude Opus 4.7 --- backend/app/services/storage_service.py | 94 ++++++++++++++----------- 1 file changed, 51 insertions(+), 43 deletions(-) diff --git a/backend/app/services/storage_service.py b/backend/app/services/storage_service.py index 33456db..bf58f61 100644 --- a/backend/app/services/storage_service.py +++ b/backend/app/services/storage_service.py @@ -47,20 +47,20 @@ class StorageService: self.export_publish_root.mkdir(parents=True, exist_ok=True) def create_upload_token(self, biz_type: str, biz_id: int, file_name: str, file_size: int) -> dict: - """生成前端直传 OSS 的上传令牌。 + """生成前端直传 OSS 的 POST 表单上传令牌。 - 根据业务类型、业务 ID、文件名等信息,构建对象键并生成带签名的上传地址, - 供前端使用 PUT 方法直接上传文件到阿里云 OSS。 + 根据业务类型、业务 ID、文件名等信息,构建对象键并生成带签名的 POST policy, + 供前端使用 POST 表单方式直接上传文件到阿里云 OSS。 参数: - biz_type (str): 业务类型标识,用于构建对象键前缀(如 "file"、"smoke") + biz_type (str): 业务类型标识,用于构建对象键前缀 biz_id (int): 业务记录 ID,用于构建对象键中间段 file_name (str): 原始文件名,用于提取扩展名和构建安全文件名 - file_size (int): 文件大小上限(字节),返回给前端做校验 + file_size (int): 文件大小上限(字节) 返回: - dict: 包含 upload_url、file_url、headers、method、object_key、 - bucket_name、expire_at、max_file_size、content_type 等字段 + dict: 包含 upload_url、policy、access_key_id、signature、 + object_key、bucket_name、public_base_url、expire_at、content_type 等字段 被调用方: - FileService.create_upload_token() → API 文件上传令牌接口 /files/upload-token @@ -68,16 +68,21 @@ class StorageService: object_key = self.build_object_key(biz_type, biz_id, file_name) expire_at = datetime.now() + timedelta(seconds=self.settings.oss_signed_url_expire_seconds) content_type = self._guess_content_type(file_name) - signed_meta = self._build_signed_upload_url(object_key, expire_at, content_type) + policy_b64 = self._build_post_policy(object_key, expire_at, file_size) + signature = self._sign_post_policy(policy_b64) + upload_url = ( + f"https://{self.settings.aliyun_oss_bucket}" + f".{self.settings.aliyun_oss_endpoint}/" + ) return { - "upload_url": signed_meta["upload_url"], - "file_url": self.build_public_file_url(object_key), - "headers": signed_meta["headers"], - "method": "PUT", + "upload_url": upload_url, + "policy": policy_b64, + "access_key_id": self.settings.aliyun_oss_access_key_id, + "signature": signature, "object_key": object_key, "bucket_name": self.settings.aliyun_oss_bucket, + "public_base_url": self.settings.aliyun_oss_public_base_url, "expire_at": expire_at.strftime("%Y-%m-%d %H:%M:%S"), - "max_file_size": file_size, "content_type": content_type, } @@ -177,50 +182,53 @@ class StorageService: return file_url[len(prefix) :] return file_url.split("://", 1)[-1].split("/", 1)[-1] if "/" in file_url.split("://", 1)[-1] else file_url - def _build_signed_upload_url(self, object_key: str, expire_at: datetime, content_type: str) -> dict: - """构建阿里云 OSS V1 预签名上传地址。 + def _build_post_policy(self, object_key: str, expire_at: datetime, file_size: int) -> str: + """构建阿里云 OSS POST policy 并 base64 编码。 - 按照 OSS V1 签名规则,使用 HMAC-SHA1 对请求信息进行签名, - 生成带过期时间的上传 URL,前端可直接使用 PUT 方法上传文件。 + policy 包含过期时间、目标 bucket、文件 key 和 Content-Type 约束, + 供前端 POST 表单直传时携带,OSS 服务端会校验 policy 合法性。 参数: - object_key (str): OSS 对象键 - expire_at (datetime): 签名过期时间 - content_type (str): 文件 MIME 类型 + object_key: OSS 对象键 + expire_at: 过期时间 + file_size: 文件大小上限(字节) 返回: - dict: 包含 upload_url(签名后的完整上传地址)和 headers(请求头)字段 + base64 编码的 policy 字符串 被调用方: - create_upload_token():生成上传令牌时调用 """ - # 当前按 OSS V1 预签名 URL 规则生成上传地址,便于前端直接联调真实 PUT 上传口径。 - expires = str(int(expire_at.timestamp())) - encoded_key = quote(object_key.replace("\\", "/"), safe="/-_.*()") - canonical_headers = "x-oss-object-acl:private\n" - canonical_resource = f"/{self.settings.aliyun_oss_bucket}/{object_key}" - string_to_sign = f"PUT\n\n{content_type}\n{expires}\n{canonical_headers}{canonical_resource}" - signature = base64.b64encode( + import json as _json + policy = { + "expiration": expire_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + "conditions": [ + {"bucket": self.settings.aliyun_oss_bucket}, + ["eq", "$key", object_key], + ["content-length-range", 1, file_size], + ], + } + return base64.b64encode(_json.dumps(policy).encode("utf-8")).decode("utf-8") + + def _sign_post_policy(self, policy_b64: str) -> str: + """使用 access_key_secret 对 base64 编码的 policy 进行 HMAC-SHA1 签名。 + + 参数: + policy_b64: base64 编码的 policy 字符串 + + 返回: + base64 编码的签名字符串 + + 被调用方: + - create_upload_token():生成上传令牌时调用 + """ + return base64.b64encode( hmac.new( self.settings.aliyun_oss_access_key_secret.encode("utf-8"), - string_to_sign.encode("utf-8"), + policy_b64.encode("utf-8"), hashlib.sha1, ).digest() ).decode("utf-8") - query_signature = quote(signature, safe="") - upload_url = ( - f"https://{self.settings.aliyun_oss_bucket}.{self.settings.aliyun_oss_endpoint}/{encoded_key}" - f"?OSSAccessKeyId={quote(self.settings.aliyun_oss_access_key_id, safe='')}" - f"&Expires={expires}" - f"&Signature={query_signature}" - ) - return { - "upload_url": upload_url, - "headers": { - "Content-Type": content_type, - "x-oss-object-acl": "private", - }, - } def _guess_content_type(self, file_name: str) -> str: """根据文件扩展名猜测 MIME 类型。