From 58aed5f201972c171d62c9f82552f2151ac358fc Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 10 Feb 2026 14:54:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=81=E6=9C=89=E6=A1=B6=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=20URL=20=E6=94=AF=E6=8C=81=20+=20TOS=20=E5=87=AD?= =?UTF-8?q?=E8=AF=81=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 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 --- backend/app/api/upload.py | 49 +++++++++++++++- backend/app/services/oss.py | 91 +++++++++++++++++++++++++++++ frontend/hooks/useSignedUrl.ts | 101 +++++++++++++++++++++++++++++++++ frontend/lib/api.ts | 11 ++++ 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 frontend/hooks/useSignedUrl.ts diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 5309ad9..6017081 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -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, + ) diff --git a/backend/app/services/oss.py b/backend/app/services/oss.py index a592631..cd27c79 100644 --- a/backend/app/services/oss.py +++ b/backend/app/services/oss.py @@ -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 diff --git a/frontend/hooks/useSignedUrl.ts b/frontend/hooks/useSignedUrl.ts new file mode 100644 index 0000000..8fced61 --- /dev/null +++ b/frontend/hooks/useSignedUrl.ts @@ -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() + +export function useSignedUrl(originalUrl: string | undefined | null) { + const [signedUrl, setSignedUrl] = useState(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> { + const result = new Map() + + 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 +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index d947108..ce507ba 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -453,6 +453,17 @@ class ApiClient { return response.data } + /** + * 获取私有桶文件的预签名访问 URL + */ + async getSignedUrl(url: string, expire: number = 3600): Promise { + const response = await this.client.get<{ signed_url: string; expire_seconds: number }>( + '/upload/sign-url', + { params: { url, expire } } + ) + return response.data.signed_url + } + // ==================== 视频审核 ==================== /**