Files
wecom_it_smart_desk/backend/app/api/upload.py
T

207 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微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)