Files
wecom_it_smart_desk/backend/app/api/upload.py
Simon bea288e414 feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
2026-07-11 23:13:10 +08:00

293 lines
12 KiB
Python
Raw Permalink 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 json
import logging
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse
from app.utils.response import success_response, AppException, ERR_UNAUTHORIZED
from app.api.h5 import _get_current_employee
from app.dependencies import dep_redis
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"}
# --------------------------------------------------------------------------
# 双路认证依赖:同时支持员工端(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} — 坐席端统一格式 TokenJSON,含 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步:尝试员工端 Tokenemployee: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步:尝试坐席端统一格式 Tokenuser: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步:尝试坐席端旧格式 Tokenagent: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
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="上传的文件(图片或文档)"),
user_id: str = Depends(_get_current_user_id),
):
"""上传文件到服务器。
处理流程:
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)