2026-06-14 16:49:18 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微IT智能服务台 — 文件上传 API
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 说明:处理图片/文件上传,保存到服务器本地存储
|
|
|
|
|
|
# 1. POST /api/upload — 上传文件(图片/文件),返回文件URL
|
|
|
|
|
|
# 2. GET /api/media/{path} — 静态文件服务(开发环境)
|
|
|
|
|
|
# 文件存储路径:./uploads/YYYY/MM/DD/{uuid}.{ext}
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
import json
|
2026-06-14 16:49:18 +08:00
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
import redis.asyncio as aioredis
|
|
|
|
|
|
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
|
2026-06-14 16:49:18 +08:00
|
|
|
|
from fastapi.responses import FileResponse
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
from app.utils.response import success_response, AppException, ERR_UNAUTHORIZED
|
2026-06-14 16:49:18 +08:00
|
|
|
|
from app.api.h5 import _get_current_employee
|
2026-07-11 23:13:10 +08:00
|
|
|
|
from app.dependencies import dep_redis
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
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"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 双路认证依赖:同时支持员工端(H5)和坐席端(Agent)Token
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 做什么:从 Authorization 头提取 token,依次尝试三种 Redis key 查找用户身份
|
|
|
|
|
|
# 为什么需要:上传端点同时被 H5 员工端和坐席端调用,两者的 Token 存储格式不同
|
|
|
|
|
|
# - H5 员工端:employee:token:{token} → employee_id(字符串)
|
|
|
|
|
|
# - 坐席端统一格式:user:token:{token} → JSON({employee_id, roles, ...})
|
|
|
|
|
|
# - 坐席端旧格式:agent:token:{token} → user_id(字符串)
|
|
|
|
|
|
# 修复记录:2026-07-11 坐席端粘贴/截图/拍照上传报"未授权",原因是原代码只查 employee:token
|
|
|
|
|
|
async def _get_current_user_id(
|
|
|
|
|
|
authorization: Optional[str] = Header(None, alias="Authorization"),
|
|
|
|
|
|
x_employee_id: Optional[str] = Header(None, alias="X-Employee-Id"),
|
|
|
|
|
|
redis_client: Optional[aioredis.Redis] = Depends(dep_redis),
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""双路认证:支持员工端和坐席端两种 Token 格式。
|
|
|
|
|
|
|
|
|
|
|
|
查找顺序:
|
|
|
|
|
|
1. employee:token:{token} — H5 员工端 Token(原逻辑)
|
|
|
|
|
|
2. user:token:{token} — 坐席端统一格式 Token(JSON,含 employee_id)
|
|
|
|
|
|
3. agent:token:{token} — 坐席端旧格式 Token(直接存储 user_id)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
authorization: 请求头 Authorization 字段(格式:Bearer token)
|
|
|
|
|
|
x_employee_id: 请求头 X-Employee-Id 字段(开发降级用)
|
|
|
|
|
|
redis_client: Redis 客户端(DI 注入)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
str: 用户 ID(企微 UserID 或坐席 user_id)
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
AppException(1002, "未授权"): 所有 Token 查找均失败
|
|
|
|
|
|
"""
|
|
|
|
|
|
# ---- 第1步:尝试员工端 Token(employee:token:{token})----
|
|
|
|
|
|
# _get_current_employee 内部已处理 Bearer 前缀、X-Employee-Id 降级等逻辑
|
|
|
|
|
|
# 如果成功则直接返回 employee_id;如果抛 AppException 则继续尝试坐席 Token
|
|
|
|
|
|
try:
|
|
|
|
|
|
employee_id = await _get_current_employee(authorization, x_employee_id, redis_client)
|
|
|
|
|
|
return employee_id
|
|
|
|
|
|
except AppException:
|
|
|
|
|
|
# 员工 Token 查不到,继续尝试坐席 Token
|
|
|
|
|
|
pass
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# 其他异常(如 Redis 连接问题)也继续尝试
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 第2步:提取 token(去除 Bearer 前缀)----
|
|
|
|
|
|
if not authorization:
|
|
|
|
|
|
raise ERR_UNAUTHORIZED
|
|
|
|
|
|
token = authorization.replace("Bearer ", "", 1) if authorization.startswith("Bearer ") else authorization
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
raise ERR_UNAUTHORIZED
|
|
|
|
|
|
|
|
|
|
|
|
# Redis 不可用时直接拒绝(无法验证坐席 Token)
|
|
|
|
|
|
if not redis_client:
|
|
|
|
|
|
logger.error("Redis 不可用,无法验证坐席端上传 Token")
|
|
|
|
|
|
raise ERR_UNAUTHORIZED
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 第3步:尝试坐席端统一格式 Token(user:token:{token})----
|
|
|
|
|
|
# 值是 JSON 字符串:{"employee_id": "xxx", "roles": [...], ...}
|
|
|
|
|
|
try:
|
|
|
|
|
|
unified_data = await redis_client.get(f"user:token:{token}")
|
|
|
|
|
|
if unified_data:
|
|
|
|
|
|
user_info = json.loads(unified_data)
|
|
|
|
|
|
employee_id = user_info.get("employee_id")
|
|
|
|
|
|
if employee_id:
|
|
|
|
|
|
return employee_id
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"查询 user:token 失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 第4步:尝试坐席端旧格式 Token(agent:token:{token})----
|
|
|
|
|
|
# 值直接是 user_id 字符串
|
|
|
|
|
|
try:
|
|
|
|
|
|
agent_user_id = await redis_client.get(f"agent:token:{token}")
|
|
|
|
|
|
if agent_user_id:
|
|
|
|
|
|
# Redis 返回 bytes,需要解码
|
|
|
|
|
|
return agent_user_id.decode("utf-8") if isinstance(agent_user_id, bytes) else agent_user_id
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"查询 agent:token 失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 所有 Token 查找均失败
|
|
|
|
|
|
raise ERR_UNAUTHORIZED
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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="上传的文件(图片或文档)"),
|
2026-07-11 23:13:10 +08:00
|
|
|
|
user_id: str = Depends(_get_current_user_id),
|
2026-06-14 16:49:18 +08:00
|
|
|
|
):
|
|
|
|
|
|
"""上传文件到服务器。
|
|
|
|
|
|
|
|
|
|
|
|
处理流程:
|
|
|
|
|
|
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)
|