207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
# =============================================================================
|
||
# 企微IT智能服务台 — 文件上传 API
|
||
# =============================================================================
|
||
# 说明:处理图片/文件上传,保存到服务器本地存储
|
||
# 1. POST /api/upload — 上传文件(图片/文件),返回文件URL
|
||
# 2. GET /api/media/{path} — 静态文件服务(开发环境)
|
||
# 文件存储路径:./uploads/YYYY/MM/DD/{uuid}.{ext}
|
||
# =============================================================================
|
||
|
||
import logging
|
||
import os
|
||
import uuid
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||
from fastapi.responses import FileResponse
|
||
|
||
from app.utils.response import success_response
|
||
from app.api.h5 import _get_current_employee
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 创建路由器
|
||
router = APIRouter()
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 文件存储配置
|
||
# --------------------------------------------------------------------------
|
||
# 上传文件的根目录(Docker 环境中映射为 Volume 持久化存储)
|
||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "./uploads"))
|
||
# 允许上传的文件扩展名(白名单,防止上传可执行文件等危险文件)
|
||
ALLOWED_EXTENSIONS = {
|
||
# 图片
|
||
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg",
|
||
# 文档
|
||
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
|
||
"txt", "csv", "md", "rtf",
|
||
# 压缩包
|
||
"zip", "rar", "7z", "tar", "gz",
|
||
# 其他
|
||
"log", "json", "xml", "yaml", "yml",
|
||
}
|
||
# 单文件最大大小(默认 20MB)
|
||
MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", str(20 * 1024 * 1024))) # 20MB
|
||
# 图片最大大小(默认 10MB)
|
||
MAX_IMAGE_SIZE = int(os.getenv("MAX_IMAGE_SIZE", str(10 * 1024 * 1024))) # 10MB
|
||
# 图片类型扩展名集合
|
||
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"}
|
||
|
||
|
||
def _get_file_extension(filename: str) -> str:
|
||
"""从文件名中提取小写扩展名。
|
||
|
||
Args:
|
||
filename: 原始文件名
|
||
|
||
Returns:
|
||
str: 小写扩展名(不含点号),如 "png"
|
||
"""
|
||
# os.path.splitext 返回 (root, ext),ext 含点号如 ".png"
|
||
ext = os.path.splitext(filename)[1].lower().lstrip(".")
|
||
return ext or "bin" # 无扩展名时默认 bin
|
||
|
||
|
||
def _generate_storage_path(extension: str) -> tuple[Path, str]:
|
||
"""生成文件存储路径(按日期分目录)。
|
||
|
||
目录结构:uploads/YYYY/MM/DD/{uuid}.{ext}
|
||
同时返回完整本地路径和用于API访问的相对URL路径。
|
||
|
||
Args:
|
||
extension: 文件扩展名(如 "png")
|
||
|
||
Returns:
|
||
tuple: (本地文件完整路径, API访问的URL路径)
|
||
"""
|
||
now = datetime.now()
|
||
# 按日期建子目录,方便按时间归档和清理
|
||
date_dir = UPLOAD_DIR / f"{now.year}" / f"{now.month:02d}" / f"{now.day:02d}"
|
||
# 确保目录存在(exist_ok=True 避免并发创建时报错)
|
||
date_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 使用 UUID 避免文件名冲突和安全风险(不使用原始文件名存储)
|
||
file_id = uuid.uuid4().hex[:12] # 12位足够短且唯一
|
||
filename = f"{file_id}.{extension}"
|
||
local_path = date_dir / filename
|
||
|
||
# URL 路径:/api/media/YYYY/MM/DD/{uuid}.{ext}
|
||
url_path = f"/api/media/{now.year}/{now.month:02d}/{now.day:02d}/{filename}"
|
||
|
||
return local_path, url_path
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# POST /api/upload — 上传文件
|
||
# --------------------------------------------------------------------------
|
||
@router.post("/upload")
|
||
async def upload_file(
|
||
file: UploadFile = File(..., description="上传的文件(图片或文档)"),
|
||
employee_id: str = Depends(_get_current_employee),
|
||
):
|
||
"""上传文件到服务器。
|
||
|
||
处理流程:
|
||
1. 校验文件扩展名(白名单)
|
||
2. 校验文件大小(图片10MB,其他20MB)
|
||
3. 按日期目录存储文件
|
||
4. 返回文件访问URL
|
||
|
||
Args:
|
||
file: FastAPI UploadFile 对象
|
||
|
||
Returns:
|
||
Dict: 统一响应格式,包含文件URL、文件名、文件大小、文件类型
|
||
"""
|
||
# 1. 提取并校验文件扩展名
|
||
ext = _get_file_extension(file.filename or "unknown")
|
||
if ext not in ALLOWED_EXTENSIONS:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"不支持的文件类型: .{ext},允许的类型: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
|
||
)
|
||
|
||
# 2. 读取文件内容并校验大小
|
||
content = await file.read()
|
||
file_size = len(content)
|
||
|
||
# 图片和普通文件分别校验大小
|
||
is_image = ext in IMAGE_EXTENSIONS
|
||
max_size = MAX_IMAGE_SIZE if is_image else MAX_FILE_SIZE
|
||
size_label = "10MB" if is_image else "20MB"
|
||
|
||
if file_size > max_size:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"文件大小 {file_size / 1024 / 1024:.1f}MB 超过限制({size_label})",
|
||
)
|
||
|
||
# 3. 生成存储路径并保存文件
|
||
local_path, url_path = _generate_storage_path(ext)
|
||
|
||
try:
|
||
# 以二进制模式写入文件
|
||
with open(local_path, "wb") as f:
|
||
f.write(content)
|
||
except OSError as e:
|
||
logger.error(f"文件保存失败: {e}")
|
||
raise HTTPException(status_code=500, detail="文件保存失败,请重试")
|
||
|
||
# 4. 返回文件信息
|
||
logger.info(f"文件上传成功: {url_path} ({file_size} bytes, {file.filename})")
|
||
|
||
return success_response(data={
|
||
"url": url_path, # 文件访问URL(前端用于展示/下载)
|
||
"filename": file.filename, # 原始文件名(显示用)
|
||
"file_size": file_size, # 文件大小(字节)
|
||
"msg_type": "image" if is_image else "file", # 消息类型(前端根据此字段区分展示)
|
||
"extension": ext, # 文件扩展名
|
||
})
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/media/{year}/{month}/{day}/{filename} — 静态文件服务
|
||
# --------------------------------------------------------------------------
|
||
# 注意:生产环境由 Nginx 直接提供静态文件服务(性能更好)
|
||
# 此接口仅用于开发环境,或 Nginx 未配置静态文件时的降级方案
|
||
@router.get("/media/{year}/{month}/{day}/{filename}")
|
||
async def serve_media_file(
|
||
year: str,
|
||
month: str,
|
||
day: str,
|
||
filename: str,
|
||
):
|
||
"""提供上传文件的静态访问。
|
||
|
||
开发环境使用 FastAPI 直接返回文件;
|
||
生产环境建议 Nginx 配置 location /api/media/ 直接代理到 uploads 目录。
|
||
|
||
Args:
|
||
year: 年份(路径参数)
|
||
month: 月份(路径参数)
|
||
day: 日期(路径参数)
|
||
filename: 文件名(路径参数)
|
||
|
||
Returns:
|
||
FileResponse: 文件响应
|
||
"""
|
||
file_path = UPLOAD_DIR / year / month / day / filename
|
||
|
||
# 安全检查:防止路径遍历攻击(如 ../../etc/passwd)
|
||
# resolve() 解析符号链接和 .. ,然后检查是否在 UPLOAD_DIR 内
|
||
try:
|
||
resolved = file_path.resolve()
|
||
upload_root = UPLOAD_DIR.resolve()
|
||
if not str(resolved).startswith(str(upload_root)):
|
||
raise HTTPException(status_code=403, detail="禁止访问")
|
||
except (ValueError, OSError):
|
||
raise HTTPException(status_code=403, detail="禁止访问")
|
||
|
||
if not file_path.exists():
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
# FileResponse 自动根据扩展名设置 Content-Type
|
||
return FileResponse(file_path)
|