fix: 文件下载乱码 — 签名 URL 增加 Content-Disposition: attachment

- generate_presigned_url 支持 download 参数,添加 response-content-disposition
- sign-url API 新增 download 查询参数
- 前端 getSignedUrl 支持 download 模式
- 下载时传 download=true,浏览器触发文件保存而非显示乱码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-02-10 19:18:28 +08:00
parent 0ab58b7e6e
commit 9a0e7b356b
4 changed files with 25 additions and 30 deletions

View File

@ -135,6 +135,7 @@ class SignedUrlResponse(BaseModel):
async def get_signed_url( async def get_signed_url(
url: str = Query(..., description="文件的原始 URL 或 file_key"), url: str = Query(..., description="文件的原始 URL 或 file_key"),
expire: int = Query(3600, ge=60, le=43200, description="有效期默认1小时最长12小时"), 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), current_user: User = Depends(get_current_user),
): ):
""" """
@ -157,7 +158,7 @@ async def get_signed_url(
) )
try: 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: except ValueError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@ -142,6 +142,8 @@ def get_file_url(file_key: str) -> str:
def generate_presigned_url( def generate_presigned_url(
file_key: str, file_key: str,
expire_seconds: int = 3600, expire_seconds: int = 3600,
download: bool = False,
filename: str | None = None,
) -> str: ) -> str:
""" """
为私有桶中的文件生成预签名访问 URL (TOS V4 Query String Auth) 为私有桶中的文件生成预签名访问 URL (TOS V4 Query String Auth)
@ -177,15 +179,22 @@ def generate_presigned_url(
# 对 file_key 中的路径段分别编码 # 对 file_key 中的路径段分别编码
encoded_key = "/".join(quote(seg, safe="") for seg in file_key.split("/")) encoded_key = "/".join(quote(seg, safe="") for seg in file_key.split("/"))
# 查询参数(按字母序排列) # 查询参数按字母序排列TOS V4 签名要求严格字母序)
query_params = ( params_dict: dict[str, str] = {
f"X-Tos-Algorithm=TOS4-HMAC-SHA256" "X-Tos-Algorithm": "TOS4-HMAC-SHA256",
f"&X-Tos-Credential={quote(credential, safe='')}" "X-Tos-Credential": quote(credential, safe=''),
f"&X-Tos-Date={tos_date}" "X-Tos-Date": tos_date,
f"&X-Tos-Expires={expire_seconds}" "X-Tos-Expires": str(expire_seconds),
f"&X-Tos-SignedHeaders=host" "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 # CanonicalRequest
canonical_request = ( canonical_request = (
f"GET\n" f"GET\n"

View File

@ -417,16 +417,8 @@ export default function BriefConfigPage() {
return return
} }
try { try {
const signedUrl = await api.getSignedUrl(file.url) const signedUrl = await api.getSignedUrl(file.url, 3600, true)
// 使用 a 标签触发下载 window.open(signedUrl, '_blank')
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)
} catch { } catch {
toast.error('获取下载链接失败') toast.error('获取下载链接失败')
} }
@ -668,15 +660,8 @@ export default function BriefConfigPage() {
return return
} }
try { try {
const signedUrl = await api.getSignedUrl(file.url) const signedUrl = await api.getSignedUrl(file.url, 3600, true)
const a = document.createElement('a') window.open(signedUrl, '_blank')
a.href = signedUrl
a.target = '_blank'
a.rel = 'noopener noreferrer'
a.download = file.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch { } catch {
toast.error('获取下载链接失败') toast.error('获取下载链接失败')
} }

View File

@ -473,10 +473,10 @@ class ApiClient {
/** /**
* 访 URL * 访 URL
*/ */
async getSignedUrl(url: string, expire: number = 3600): Promise<string> { async getSignedUrl(url: string, expire: number = 3600, download: boolean = false): Promise<string> {
const response = await this.client.get<{ signed_url: string; expire_seconds: number }>( const response = await this.client.get<{ signed_url: string; expire_seconds: number }>(
'/upload/sign-url', '/upload/sign-url',
{ params: { url, expire } } { params: { url, expire, download } }
) )
return response.data.signed_url return response.data.signed_url
} }