diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 7ab1d46..47bcac4 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -135,6 +135,7 @@ class SignedUrlResponse(BaseModel): async def get_signed_url( url: str = Query(..., description="文件的原始 URL 或 file_key"), expire: int = Query(3600, ge=60, le=43200, description="有效期(秒),默认1小时,最长12小时"), + download: bool = Query(False, description="是否强制下载(添加 Content-Disposition: attachment)"), current_user: User = Depends(get_current_user), ): """ @@ -157,7 +158,7 @@ async def get_signed_url( ) try: - signed_url = generate_presigned_url(file_key, expire_seconds=expire) + signed_url = generate_presigned_url(file_key, expire_seconds=expire, download=download) except ValueError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/backend/app/services/oss.py b/backend/app/services/oss.py index cd27c79..8f59b68 100644 --- a/backend/app/services/oss.py +++ b/backend/app/services/oss.py @@ -142,6 +142,8 @@ def get_file_url(file_key: str) -> str: def generate_presigned_url( file_key: str, expire_seconds: int = 3600, + download: bool = False, + filename: str | None = None, ) -> str: """ 为私有桶中的文件生成预签名访问 URL (TOS V4 Query String Auth) @@ -177,14 +179,21 @@ def generate_presigned_url( # 对 file_key 中的路径段分别编码 encoded_key = "/".join(quote(seg, safe="") for seg in file_key.split("/")) - # 查询参数(按字母序排列) - query_params = ( - f"X-Tos-Algorithm=TOS4-HMAC-SHA256" - f"&X-Tos-Credential={quote(credential, safe='')}" - f"&X-Tos-Date={tos_date}" - f"&X-Tos-Expires={expire_seconds}" - f"&X-Tos-SignedHeaders=host" - ) + # 查询参数(按字母序排列,TOS V4 签名要求严格字母序) + params_dict: dict[str, str] = { + "X-Tos-Algorithm": "TOS4-HMAC-SHA256", + "X-Tos-Credential": quote(credential, safe=''), + "X-Tos-Date": tos_date, + "X-Tos-Expires": str(expire_seconds), + "X-Tos-SignedHeaders": "host", + } + if download: + dl_name = filename or file_key.split("/")[-1] + params_dict["response-content-disposition"] = quote( + f'attachment; filename="{dl_name}"', safe='' + ) + + query_params = "&".join(f"{k}={v}" for k, v in sorted(params_dict.items())) # CanonicalRequest canonical_request = ( diff --git a/frontend/app/agency/briefs/[id]/page.tsx b/frontend/app/agency/briefs/[id]/page.tsx index e25659c..44fd2b7 100644 --- a/frontend/app/agency/briefs/[id]/page.tsx +++ b/frontend/app/agency/briefs/[id]/page.tsx @@ -417,16 +417,8 @@ export default function BriefConfigPage() { return } try { - const signedUrl = await api.getSignedUrl(file.url) - // 使用 a 标签触发下载 - const a = document.createElement('a') - a.href = signedUrl - a.target = '_blank' - a.rel = 'noopener noreferrer' - a.download = file.name - document.body.appendChild(a) - a.click() - document.body.removeChild(a) + const signedUrl = await api.getSignedUrl(file.url, 3600, true) + window.open(signedUrl, '_blank') } catch { toast.error('获取下载链接失败') } @@ -668,15 +660,8 @@ export default function BriefConfigPage() { return } try { - const signedUrl = await api.getSignedUrl(file.url) - const a = document.createElement('a') - a.href = signedUrl - a.target = '_blank' - a.rel = 'noopener noreferrer' - a.download = file.name - document.body.appendChild(a) - a.click() - document.body.removeChild(a) + const signedUrl = await api.getSignedUrl(file.url, 3600, true) + window.open(signedUrl, '_blank') } catch { toast.error('获取下载链接失败') } diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 7c3af7a..384fa51 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -473,10 +473,10 @@ class ApiClient { /** * 获取私有桶文件的预签名访问 URL */ - async getSignedUrl(url: string, expire: number = 3600): Promise { + async getSignedUrl(url: string, expire: number = 3600, download: boolean = false): Promise { const response = await this.client.get<{ signed_url: string; expire_seconds: number }>( '/upload/sign-url', - { params: { url, expire } } + { params: { url, expire, download } } ) return response.data.signed_url }