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
parent 2f24dcfd34
commit 58aed5f201
4 changed files with 250 additions and 2 deletions

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,
)

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

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
}

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
}
// ==================== 视频审核 ====================
/**