|
| 1 | +import datetime |
1 | 2 | import hashlib |
| 3 | +import os |
2 | 4 | import uuid |
| 5 | +from datetime import timedelta |
| 6 | +from urllib.parse import unquote |
3 | 7 |
|
4 | 8 | from fastapi import APIRouter, Form, UploadFile, File, Depends, HTTPException |
5 | 9 | from starlette import status |
6 | 10 |
|
7 | 11 | 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 |
10 | 14 | from apps.base.utils import get_expire_info, get_file_path_name, ip_limit, get_chunk_file_path_name |
11 | 15 | from core.response import APIResponse |
12 | 16 | from core.settings import settings |
13 | 17 | 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 |
15 | 19 |
|
16 | 20 | share_api = APIRouter(prefix="/share", tags=["分享"]) |
17 | 21 |
|
18 | 22 |
|
| 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 | + |
19 | 68 | async def validate_file_size(file: UploadFile, max_size: int) -> int: |
20 | 69 | size = file.size |
21 | 70 | if size is None: |
@@ -425,3 +474,144 @@ async def complete_upload(upload_id: str, data: CompleteUploadModel, ip: str = D |
425 | 474 | except Exception: |
426 | 475 | pass |
427 | 476 | 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