admin_oss_router.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. """
  2. 管理员OSS文件管理路由
  3. 提供管理员上传文件到OSS的接口
  4. """
  5. from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, Query
  6. from typing import List
  7. import oss2
  8. from app.dependencies.admin_auth import get_current_admin
  9. from app.models.admin import AdminUser
  10. from app.services.oss_service import get_oss_service, OSSService
  11. router = APIRouter(prefix="/api/admin/oss", tags=["管理员OSS文件管理"])
  12. # 文件大小限制配置
  13. MAX_FILE_SIZE_DEFAULT = 20 * 1024 * 1024 # 20MB
  14. MAX_FILE_SIZE_COURSE = 2 * 1024 * 1024 * 1024 # 2GB
  15. def _get_file_size(upload_file: UploadFile) -> int:
  16. file_obj = upload_file.file
  17. try:
  18. current_pos = file_obj.tell()
  19. file_obj.seek(0, 2)
  20. size = file_obj.tell()
  21. file_obj.seek(current_pos)
  22. return size
  23. except Exception:
  24. return 0
  25. @router.post("/upload")
  26. async def upload_file(
  27. file: UploadFile = File(...),
  28. prefix: str = Query(default="uploads", description="存储路径前缀"),
  29. current_admin: AdminUser = Depends(get_current_admin),
  30. oss_service: OSSService = Depends(get_oss_service)
  31. ):
  32. """上传文件到OSS(管理员)"""
  33. file_size = _get_file_size(file)
  34. max_size = MAX_FILE_SIZE_COURSE if prefix.startswith("courses") else MAX_FILE_SIZE_DEFAULT
  35. max_size_mb = max_size / (1024 * 1024)
  36. if file_size and file_size > max_size:
  37. raise HTTPException(
  38. status_code=400,
  39. detail=f"文件大小超过{max_size_mb:.0f}MB限制"
  40. )
  41. try:
  42. file.file.seek(0)
  43. url = oss_service.upload_file_stream(file.file, prefix, file.filename)
  44. return {
  45. "url": url,
  46. "filename": file.filename,
  47. "size": file_size
  48. }
  49. except RuntimeError as e:
  50. raise HTTPException(status_code=500, detail=str(e))
  51. @router.get("/presigned-url")
  52. async def get_presigned_url(
  53. prefix: str = Query(default="uploads", description="存储路径前缀"),
  54. filename: str = Query(default=None, description="原始文件名(用于保留扩展名)"),
  55. content_type: str = Query(default=None, description="文件 Content-Type"),
  56. current_admin: AdminUser = Depends(get_current_admin),
  57. oss_service: OSSService = Depends(get_oss_service)
  58. ):
  59. """生成预签名 PUT 上传 URL,供前端直传 OSS
  60. 前端先请求此接口获取 presigned_url,然后直接 PUT 到 OSS。
  61. 上传完成后,将返回的 object_key 和 public_url 用于业务记录。
  62. """
  63. max_size = MAX_FILE_SIZE_COURSE if prefix.startswith("courses") else MAX_FILE_SIZE_DEFAULT
  64. max_size_mb = max_size / (1024 * 1024)
  65. try:
  66. result = oss_service.generate_presigned_put_url(
  67. prefix=prefix,
  68. original_filename=filename,
  69. expires=3600, # 1小时有效期
  70. content_type=content_type,
  71. )
  72. result["max_size_mb"] = max_size_mb
  73. return result
  74. except RuntimeError as e:
  75. raise HTTPException(status_code=500, detail=str(e))
  76. @router.get("/list-videos")
  77. async def list_oss_videos(
  78. prefix: str = Query(default="courses/videos", description="OSS路径前缀"),
  79. current_admin: AdminUser = Depends(get_current_admin),
  80. oss_service: OSSService = Depends(get_oss_service)
  81. ):
  82. """列出OSS中指定前缀下的视频文件
  83. 优化:先用 ObjectIterator(fetch_stats=True) 快速列出所有文件,
  84. 过滤出视频后,批量并发 head_object 获取元数据,避免逐个串行请求。
  85. """
  86. if not oss_service.bucket:
  87. raise HTTPException(status_code=500, detail="OSS服务未正确配置")
  88. VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.wmv', '.m4v'}
  89. video_keys = []
  90. try:
  91. for obj in oss2.ObjectIterator(oss_service.bucket, prefix=prefix):
  92. key = obj.key
  93. ext = '.' + key.rsplit('.', 1)[-1].lower() if '.' in key else ''
  94. if ext not in VIDEO_EXTS:
  95. continue
  96. video_keys.append({
  97. "key": key,
  98. "size": obj.size,
  99. "last_modified": obj.last_modified,
  100. "default_filename": key.rsplit('/', 1)[-1],
  101. })
  102. except Exception as e:
  103. raise HTTPException(status_code=500, detail=f"列举OSS文件失败: {str(e)}")
  104. if not video_keys:
  105. return {"items": [], "total": 0}
  106. # 批量并发获取元数据(避免逐个串行请求)
  107. import asyncio
  108. async def _fetch_meta(item: dict) -> dict:
  109. try:
  110. head_result = await asyncio.to_thread(
  111. oss_service.bucket.head_object, item["key"]
  112. )
  113. filename = oss_service.get_original_filename_from_headers(
  114. getattr(head_result, 'headers', None),
  115. fallback=item["default_filename"],
  116. )
  117. except Exception:
  118. filename = item["default_filename"]
  119. return {
  120. "key": item["key"],
  121. "filename": filename,
  122. "url": f"https://{oss_service.bucket_domain}/{item['key']}",
  123. "size": item["size"],
  124. "last_modified": item["last_modified"],
  125. }
  126. try:
  127. results = await asyncio.gather(*[_fetch_meta(item) for item in video_keys])
  128. except Exception as e:
  129. raise HTTPException(status_code=500, detail=f"获取文件元数据失败: {str(e)}")
  130. results = sorted(results, key=lambda x: x["last_modified"], reverse=True)
  131. return {"items": results, "total": len(results)}