Skip to content

Commit 8e57a49

Browse files
committed
feat: 支持S3预签名上传
1 parent 287d9ae commit 8e57a49

25 files changed

+825
-336
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from tortoise import connections
2+
3+
4+
async def create_presign_upload_session_table():
5+
conn = connections.get("default")
6+
await conn.execute_script(
7+
"""
8+
CREATE TABLE IF NOT EXISTS presignuploadsession (
9+
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
10+
upload_id VARCHAR(36) NOT NULL UNIQUE,
11+
file_name VARCHAR(255) NOT NULL,
12+
file_size BIGINT NOT NULL,
13+
save_path VARCHAR(512) NOT NULL,
14+
mode VARCHAR(10) NOT NULL,
15+
expire_value INT NOT NULL DEFAULT 1,
16+
expire_style VARCHAR(20) NOT NULL DEFAULT 'day',
17+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
18+
expires_at TIMESTAMP NOT NULL
19+
);
20+
CREATE INDEX IF NOT EXISTS idx_presignuploadsession_upload_id ON presignuploadsession (upload_id);
21+
"""
22+
)
23+
24+
25+
async def migrate():
26+
await create_presign_upload_session_table()

apps/base/models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,25 @@ class KeyValue(Model):
6464
)
6565

6666

67+
class PresignUploadSession(models.Model):
68+
"""预签名上传会话模型"""
69+
id = fields.IntField(pk=True)
70+
upload_id = fields.CharField(max_length=36, unique=True, index=True)
71+
file_name = fields.CharField(max_length=255)
72+
file_size = fields.BigIntField()
73+
save_path = fields.CharField(max_length=512)
74+
mode = fields.CharField(max_length=10) # "direct" 或 "proxy"
75+
expire_value = fields.IntField(default=1)
76+
expire_style = fields.CharField(max_length=20, default="day")
77+
created_at = fields.DatetimeField(auto_now_add=True)
78+
expires_at = fields.DatetimeField() # 会话过期时间
79+
80+
async def is_expired(self):
81+
"""检查会话是否已过期"""
82+
return self.expires_at < await get_now()
83+
84+
6785
file_codes_pydantic = pydantic_model_creator(FileCodes, name="FileCodes")
6886
upload_chunk_pydantic = pydantic_model_creator(UploadChunk, name="UploadChunk")
6987
key_value_pydantic = pydantic_model_creator(KeyValue, name="KeyValue")
88+
presign_upload_session_pydantic = pydantic_model_creator(PresignUploadSession, name="PresignUploadSession")

apps/base/schemas.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pydantic import BaseModel
2+
from typing import Optional
23

34

45
class SelectFileModel(BaseModel):
@@ -15,3 +16,21 @@ class InitChunkUploadModel(BaseModel):
1516
class CompleteUploadModel(BaseModel):
1617
expire_value: int
1718
expire_style: str
19+
20+
21+
# 预签名上传相关模型
22+
class PresignUploadInitRequest(BaseModel):
23+
"""预签名上传初始化请求"""
24+
file_name: str
25+
file_size: int
26+
expire_value: int = 1
27+
expire_style: str = "day"
28+
29+
30+
class PresignUploadInitResponse(BaseModel):
31+
"""预签名上传初始化响应"""
32+
upload_id: str
33+
upload_url: str
34+
mode: str # "direct" 或 "proxy"
35+
save_path: str
36+
expires_in: int # URL过期时间(秒)

apps/base/views.py

Lines changed: 193 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,70 @@
1+
import datetime
12
import hashlib
3+
import os
24
import uuid
5+
from datetime import timedelta
6+
from urllib.parse import unquote
37

48
from fastapi import APIRouter, Form, UploadFile, File, Depends, HTTPException
59
from starlette import status
610

711
from apps.admin.dependencies import share_required_login
8-
from apps.base.models import FileCodes, UploadChunk
9-
from apps.base.schemas import SelectFileModel, InitChunkUploadModel, CompleteUploadModel
12+
from apps.base.models import FileCodes, UploadChunk, PresignUploadSession
13+
from apps.base.schemas import SelectFileModel, InitChunkUploadModel, CompleteUploadModel, PresignUploadInitRequest
1014
from apps.base.utils import get_expire_info, get_file_path_name, ip_limit, get_chunk_file_path_name
1115
from core.response import APIResponse
1216
from core.settings import settings
1317
from core.storage import storages, FileStorageInterface
14-
from core.utils import get_select_token
18+
from core.utils import get_select_token, get_now, sanitize_filename
1519

1620
share_api = APIRouter(prefix="/share", tags=["分享"])
1721

1822

23+
# ============ 公共服务层 ============
24+
class FileUploadService:
25+
"""统一的文件上传服务"""
26+
27+
@staticmethod
28+
async def generate_file_path(file_name: str, upload_id: str = None) -> tuple[str, str, str, str, str]:
29+
"""统一的路径生成"""
30+
today = datetime.datetime.now()
31+
storage_path = settings.storage_path.strip("/")
32+
file_uuid = upload_id or uuid.uuid4().hex
33+
filename = await sanitize_filename(unquote(file_name))
34+
base_path = f"share/data/{today.strftime('%Y/%m/%d')}/{file_uuid}"
35+
path = f"{storage_path}/{base_path}" if storage_path else base_path
36+
prefix, suffix = os.path.splitext(filename)
37+
save_path = f"{path}/{filename}"
38+
return path, suffix, prefix, filename, save_path
39+
40+
@staticmethod
41+
async def create_file_record(
42+
file_name: str,
43+
file_size: int,
44+
file_path: str,
45+
expire_value: int,
46+
expire_style: str,
47+
**extra_fields
48+
) -> str:
49+
"""统一创建FileCodes记录,返回code"""
50+
expired_at, expired_count, used_count, code = await get_expire_info(expire_value, expire_style)
51+
prefix, suffix = os.path.splitext(file_name)
52+
53+
await FileCodes.create(
54+
code=code,
55+
prefix=prefix,
56+
suffix=suffix,
57+
uuid_file_name=file_name,
58+
file_path=file_path,
59+
size=file_size,
60+
expired_at=expired_at,
61+
expired_count=expired_count,
62+
used_count=used_count,
63+
**extra_fields
64+
)
65+
return code
66+
67+
1968
async def validate_file_size(file: UploadFile, max_size: int) -> int:
2069
size = file.size
2170
if size is None:
@@ -425,3 +474,144 @@ async def complete_upload(upload_id: str, data: CompleteUploadModel, ip: str = D
425474
except Exception:
426475
pass
427476
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"文件合并失败: {str(e)}")
477+
478+
479+
# ============ 预签名上传API ============
480+
presign_api = APIRouter(prefix="/presign", tags=["预签名上传"])
481+
482+
PRESIGN_SESSION_EXPIRES = 900 # 15分钟
483+
484+
485+
async def _get_valid_session(upload_id: str, expected_mode: str = None) -> PresignUploadSession:
486+
"""获取并验证会话"""
487+
session = await PresignUploadSession.filter(upload_id=upload_id).first()
488+
if not session:
489+
raise HTTPException(404, "上传会话不存在")
490+
if await session.is_expired():
491+
await session.delete()
492+
raise HTTPException(404, "上传会话已过期")
493+
if expected_mode and session.mode != expected_mode:
494+
raise HTTPException(400, f"此会话不支持{expected_mode}模式")
495+
return session
496+
497+
498+
@presign_api.post("/upload/init", dependencies=[Depends(share_required_login)])
499+
async def presign_upload_init(data: PresignUploadInitRequest, ip: str = Depends(ip_limit["upload"])):
500+
"""初始化预签名上传,S3返回直传URL,其他存储返回代理URL"""
501+
if data.file_size > settings.uploadSize:
502+
raise HTTPException(403, f"文件大小超过限制,最大为 {settings.uploadSize / (1024*1024):.2f} MB")
503+
if data.expire_style not in settings.expireStyle:
504+
raise HTTPException(400, "过期时间类型错误")
505+
506+
upload_id = uuid.uuid4().hex
507+
path, _, _, filename, save_path = await FileUploadService.generate_file_path(data.file_name, upload_id)
508+
509+
storage: FileStorageInterface = storages[settings.file_storage]()
510+
presigned_url = await storage.generate_presigned_upload_url(save_path, PRESIGN_SESSION_EXPIRES)
511+
512+
mode = "direct" if presigned_url else "proxy"
513+
upload_url = presigned_url or f"/api/presign/upload/proxy/{upload_id}"
514+
515+
await PresignUploadSession.create(
516+
upload_id=upload_id,
517+
file_name=filename,
518+
file_size=data.file_size,
519+
save_path=save_path,
520+
mode=mode,
521+
expire_value=data.expire_value,
522+
expire_style=data.expire_style,
523+
expires_at=await get_now() + timedelta(seconds=PRESIGN_SESSION_EXPIRES),
524+
)
525+
526+
ip_limit["upload"].add_ip(ip)
527+
return APIResponse(detail={
528+
"upload_id": upload_id,
529+
"upload_url": upload_url,
530+
"mode": mode,
531+
"expires_in": PRESIGN_SESSION_EXPIRES,
532+
})
533+
534+
535+
@presign_api.put("/upload/proxy/{upload_id}", dependencies=[Depends(share_required_login)])
536+
async def presign_upload_proxy(upload_id: str, file: UploadFile = File(...), ip: str = Depends(ip_limit["upload"])):
537+
"""代理模式上传,服务器转存到存储后端"""
538+
session = await _get_valid_session(upload_id, expected_mode="proxy")
539+
540+
file_size = await validate_file_size(file, settings.uploadSize)
541+
if abs(file_size - session.file_size) > 1024:
542+
raise HTTPException(400, "文件大小与声明不符")
543+
544+
storage: FileStorageInterface = storages[settings.file_storage]()
545+
try:
546+
await storage.save_file(file, session.save_path)
547+
except Exception as e:
548+
raise HTTPException(500, f"文件保存失败: {str(e)}")
549+
550+
code = await FileUploadService.create_file_record(
551+
session.file_name, file_size, os.path.dirname(session.save_path),
552+
session.expire_value, session.expire_style
553+
)
554+
555+
await session.delete()
556+
ip_limit["upload"].add_ip(ip)
557+
return APIResponse(detail={"code": code, "name": session.file_name})
558+
559+
560+
@presign_api.post("/upload/confirm/{upload_id}", dependencies=[Depends(share_required_login)])
561+
async def presign_upload_confirm(upload_id: str, ip: str = Depends(ip_limit["upload"])):
562+
"""直传确认,客户端完成S3直传后调用获取分享码"""
563+
session = await _get_valid_session(upload_id, expected_mode="direct")
564+
565+
storage: FileStorageInterface = storages[settings.file_storage]()
566+
if not await storage.file_exists(session.save_path):
567+
raise HTTPException(404, "文件未上传或上传失败")
568+
569+
code = await FileUploadService.create_file_record(
570+
session.file_name, session.file_size, os.path.dirname(session.save_path),
571+
session.expire_value, session.expire_style
572+
)
573+
574+
await session.delete()
575+
ip_limit["upload"].add_ip(ip)
576+
return APIResponse(detail={"code": code, "name": session.file_name})
577+
578+
579+
@presign_api.get("/upload/status/{upload_id}", dependencies=[Depends(share_required_login)])
580+
async def presign_upload_status(upload_id: str):
581+
"""查询上传会话状态"""
582+
session = await PresignUploadSession.filter(upload_id=upload_id).first()
583+
if not session:
584+
raise HTTPException(404, "上传会话不存在")
585+
586+
return APIResponse(detail={
587+
"upload_id": session.upload_id,
588+
"file_name": session.file_name,
589+
"file_size": session.file_size,
590+
"mode": session.mode,
591+
"created_at": session.created_at.isoformat(),
592+
"expires_at": session.expires_at.isoformat(),
593+
"is_expired": await session.is_expired(),
594+
})
595+
596+
597+
@presign_api.delete("/upload/{upload_id}", dependencies=[Depends(share_required_login)])
598+
async def presign_upload_cancel(upload_id: str):
599+
"""取消上传会话"""
600+
session = await PresignUploadSession.filter(upload_id=upload_id).first()
601+
if not session:
602+
raise HTTPException(404, "上传会话不存在")
603+
604+
if session.mode == "direct":
605+
storage: FileStorageInterface = storages[settings.file_storage]()
606+
try:
607+
if await storage.file_exists(session.save_path):
608+
temp_file_code = FileCodes(
609+
file_path=os.path.dirname(session.save_path),
610+
uuid_file_name=os.path.basename(session.save_path),
611+
)
612+
await storage.delete_file(temp_file_code)
613+
except Exception:
614+
pass
615+
616+
await session.delete()
617+
return APIResponse(detail={"message": "上传会话已取消"})

0 commit comments

Comments
 (0)