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)
|