chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
# =============================================================================
|
||||
# 企微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)
|
||||
Reference in New Issue
Block a user