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:
parent
2f24dcfd34
commit
58aed5f201
@ -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,
|
||||
)
|
||||
|
||||
@ -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
frontend/hooks/useSignedUrl.ts
Normal file
101
frontend/hooks/useSignedUrl.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
// ==================== 视频审核 ====================
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user