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
|
文件上传 API
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
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.config import settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
@ -123,3 +123,48 @@ async def file_uploaded(
|
|||||||
file_size=request.file_size,
|
file_size=request.file_size,
|
||||||
file_type=request.file_type,
|
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}"
|
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:
|
def parse_file_key_from_url(url: str) -> str:
|
||||||
"""
|
"""
|
||||||
从完整 URL 解析出文件 key
|
从完整 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
|
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