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:
parent
0ab58b7e6e
commit
9a0e7b356b
@ -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,
|
||||||
|
|||||||
@ -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,14 +179,21 @@ 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 = (
|
||||||
|
|||||||
@ -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('获取下载链接失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user