feat: 私有桶签名 URL 支持 + TOS 凭证配置

后端 oss.py 新增 generate_presigned_url (TOS V4 Query String Auth),
upload.py 新增 GET /upload/sign-url 端点。前端 api.ts 添加 getSignedUrl
方法,新增 useSignedUrl hook 支持自动缓存和过期刷新。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-10 14:54:16 +08:00
co-authored by Claude Opus 4.6
parent 2f24dcfd34
commit 58aed5f201
4 changed files with 250 additions and 2 deletions
+47 -2
View File
@@ -1,12 +1,12 @@
"""
文件上传 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.services.oss import generate_upload_policy, get_file_url
from app.services.oss import generate_upload_policy, get_file_url, generate_presigned_url
from app.config import settings
from app.models.user import User
from app.api.deps import get_current_user
@@ -123,3 +123,48 @@ async def file_uploaded(
file_size=request.file_size,
file_type=request.file_type,
)
class SignedUrlResponse(BaseModel):
"""签名 URL 响应"""
signed_url: str
expire_seconds: int
@router.get("/sign-url", response_model=SignedUrlResponse)
async def get_signed_url(
url: str = Query(..., description="文件的原始 URL 或 file_key"),
expire: int = Query(3600, ge=60, le=43200, description="有效期(秒),默认1小时,最长12小时"),
current_user: User = Depends(get_current_user),
):
"""
获取私有桶文件的预签名访问 URL
前端在展示/下载文件前调用此接口,获取带签名的临时访问链接。
支持传入完整 URL 或 file_key。
"""
from app.services.oss import parse_file_key_from_url
# 如果传入的是完整 URL,先解析出 file_key
file_key = url
if url.startswith("http"):
file_key = parse_file_key_from_url(url)
if not file_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的文件路径",
)
try:
signed_url = generate_presigned_url(file_key, expire_seconds=expire)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
return SignedUrlResponse(
signed_url=signed_url,
expire_seconds=expire,
)
+91
View File
@@ -139,6 +139,97 @@ def get_file_url(file_key: str) -> str:
return f"{host}/{file_key}"
def generate_presigned_url(
file_key: str,
expire_seconds: int = 3600,
) -> str:
"""
为私有桶中的文件生成预签名访问 URL (TOS V4 Query String Auth)
签名流程:
1. 构建 CanonicalRequest
2. 构建 StringToSign
3. 用派生密钥签名
4. 拼接查询参数
Args:
file_key: 文件在 TOS 中的 key
expire_seconds: URL 有效期(秒),默认 1 小时
Returns:
预签名 URL
"""
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
raise ValueError("TOS 配置未设置")
from urllib.parse import quote
region = settings.TOS_REGION
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
bucket = settings.TOS_BUCKET_NAME
host = f"{bucket}.{endpoint}"
now_utc = datetime.now(timezone.utc)
date_stamp = now_utc.strftime("%Y%m%d")
tos_date = now_utc.strftime("%Y%m%dT%H%M%SZ")
credential = f"{settings.TOS_ACCESS_KEY_ID}/{date_stamp}/{region}/tos/request"
# 对 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"
)
# CanonicalRequest
canonical_request = (
f"GET\n"
f"/{encoded_key}\n"
f"{query_params}\n"
f"host:{host}\n"
f"\n"
f"host\n"
f"UNSIGNED-PAYLOAD"
)
# StringToSign
canonical_request_hash = hashlib.sha256(canonical_request.encode()).hexdigest()
string_to_sign = (
f"TOS4-HMAC-SHA256\n"
f"{tos_date}\n"
f"{date_stamp}/{region}/tos/request\n"
f"{canonical_request_hash}"
)
# 派生签名密钥
k_date = hmac.new(
f"TOS4{settings.TOS_SECRET_ACCESS_KEY}".encode(),
date_stamp.encode(),
hashlib.sha256,
).digest()
k_region = hmac.new(k_date, region.encode(), hashlib.sha256).digest()
k_service = hmac.new(k_region, b"tos", hashlib.sha256).digest()
k_signing = hmac.new(k_service, b"request", hashlib.sha256).digest()
# 计算签名
signature = hmac.new(
k_signing,
string_to_sign.encode(),
hashlib.sha256,
).hexdigest()
return (
f"https://{host}/{encoded_key}"
f"?{query_params}"
f"&X-Tos-Signature={signature}"
)
def parse_file_key_from_url(url: str) -> str:
"""
从完整 URL 解析出文件 key
+101
View File
@@ -0,0 +1,101 @@
/**
* 获取私有桶文件的预签名访问 URL
*
* 用于展示/下载 TOS 私有桶中的文件。
* 自动缓存签名 URL,过期前 5 分钟刷新。
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
const urlCache = new Map<string, { signedUrl: string; expireAt: number }>()
export function useSignedUrl(originalUrl: string | undefined | null) {
const [signedUrl, setSignedUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => { mountedRef.current = false }
}, [])
const fetchSignedUrl = useCallback(async () => {
if (!originalUrl) {
setSignedUrl(null)
return
}
// Mock 模式直接返回原始 URL
if (USE_MOCK) {
setSignedUrl(originalUrl)
return
}
// 非 TOS URL(如外部链接)直接返回
if (!originalUrl.includes('tos-cn-') && !originalUrl.includes('volces.com') && !originalUrl.startsWith('uploads/')) {
setSignedUrl(originalUrl)
return
}
// 检查缓存(提前 5 分钟过期)
const cached = urlCache.get(originalUrl)
if (cached && cached.expireAt > Date.now() + 5 * 60 * 1000) {
setSignedUrl(cached.signedUrl)
return
}
setLoading(true)
try {
const expireSeconds = 3600
const url = await api.getSignedUrl(originalUrl, expireSeconds)
if (mountedRef.current) {
setSignedUrl(url)
urlCache.set(originalUrl, {
signedUrl: url,
expireAt: Date.now() + expireSeconds * 1000,
})
}
} catch {
// 签名失败时回退到原始 URL
if (mountedRef.current) {
setSignedUrl(originalUrl)
}
} finally {
if (mountedRef.current) {
setLoading(false)
}
}
}, [originalUrl])
useEffect(() => {
fetchSignedUrl()
}, [fetchSignedUrl])
return { signedUrl, loading, refresh: fetchSignedUrl }
}
/**
* 批量获取签名 URL 的工具函数
*/
export async function getSignedUrls(urls: string[]): Promise<Map<string, string>> {
const result = new Map<string, string>()
if (USE_MOCK) {
urls.forEach(u => result.set(u, u))
return result
}
await Promise.all(
urls.map(async (url) => {
try {
const signed = await api.getSignedUrl(url)
result.set(url, signed)
} catch {
result.set(url, url)
}
})
)
return result
}
+11
View File
@@ -453,6 +453,17 @@ class ApiClient {
return response.data
}
/**
* 获取私有桶文件的预签名访问 URL
*/
async getSignedUrl(url: string, expire: number = 3600): Promise<string> {
const response = await this.client.get<{ signed_url: string; expire_seconds: number }>(
'/upload/sign-url',
{ params: { url, expire } }
)
return response.data.signed_url
}
// ==================== 视频审核 ====================
/**