331 lines
10 KiB
Python
331 lines
10 KiB
Python
"""
|
||
会话管理路由 - 对话 CRUD 和文件上传
|
||
|
||
与平台无关的通用功能,不涉及任何 LLM 平台细节。
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Dict
|
||
|
||
from fastapi import File, HTTPException, UploadFile
|
||
from fastapi.responses import FileResponse
|
||
|
||
import sys
|
||
sys.path.append(str(Path(__file__).parent.parent))
|
||
|
||
from database import get_db
|
||
from utils.helpers import generate_unique_id
|
||
from core import log_error, log_exception, log_info
|
||
|
||
# 配置上传目录
|
||
upload_dir = Path(__file__).parent.parent / "uploads"
|
||
upload_dir.mkdir(exist_ok=True)
|
||
|
||
|
||
# ── 会话管理 ─────────────────────────────────────────────────────
|
||
|
||
|
||
async def get_conversations_handler(user_id: str = "default"):
|
||
"""获取所有对话处理器"""
|
||
db = get_db()
|
||
return db.list_conversations(user_id)
|
||
|
||
|
||
async def get_conversation_handler(conversation_id: str):
|
||
"""获取特定对话处理器"""
|
||
db = get_db()
|
||
conversation = db.get_conversation(conversation_id)
|
||
if not conversation:
|
||
raise HTTPException(status_code=404, detail="对话不存在")
|
||
return conversation
|
||
|
||
|
||
async def save_conversation_handler(data: dict):
|
||
"""保存或更新对话处理器"""
|
||
try:
|
||
db = get_db()
|
||
conversation_id = data.get("id")
|
||
|
||
# 检查是否已存在
|
||
existing = db.get_conversation(conversation_id) if conversation_id else None
|
||
|
||
if existing:
|
||
# 更新现有会话
|
||
return db.update_conversation(conversation_id, data)
|
||
else:
|
||
# 创建新会话
|
||
return db.create_conversation(data)
|
||
|
||
except Exception as e:
|
||
log_error(f"Error saving conversation: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
async def delete_conversation_handler(conversation_id: str):
|
||
"""删除对话处理器(同时删除关联的 OSS 文件)"""
|
||
db = get_db()
|
||
|
||
# 先获取会话数据,提取 OSS 文件 URL
|
||
conversation = db.get_conversation(conversation_id)
|
||
if not conversation:
|
||
raise HTTPException(status_code=404, detail="对话不存在")
|
||
|
||
# 提取所有 OSS 文件 URL
|
||
oss_urls = _extract_oss_urls_from_conversation(conversation)
|
||
|
||
# 删除 OSS 文件
|
||
if oss_urls:
|
||
try:
|
||
from utils.oss_uploader import delete_files, extract_object_key_from_url
|
||
|
||
object_keys = []
|
||
for url in oss_urls:
|
||
key = extract_object_key_from_url(url)
|
||
if key:
|
||
object_keys.append(key)
|
||
|
||
if object_keys:
|
||
result = delete_files(object_keys)
|
||
log_info(f"[删除会话] OSS 文件清理结果: 删除 {len(result['deleted'])} 个, 失败 {len(result['failed'])} 个")
|
||
if result['failed']:
|
||
log_error(f"[删除会话] OSS 文件删除失败: {result['failed']}")
|
||
except Exception as e:
|
||
log_error(f"[删除会话] OSS 文件删除异常: {e}")
|
||
# 继续删除会话,即使 OSS 删除失败
|
||
|
||
# 删除数据库记录
|
||
success = db.delete_conversation(conversation_id)
|
||
if success:
|
||
return {"success": True, "message": "删除成功"}
|
||
else:
|
||
raise HTTPException(status_code=404, detail="对话不存在")
|
||
|
||
|
||
def _extract_oss_urls_from_conversation(conversation: dict) -> list:
|
||
"""
|
||
从会话消息中提取所有 OSS 文件 URL
|
||
|
||
消息结构:
|
||
- content.images: 图片附件列表
|
||
- content.files: 文件附件列表
|
||
每个附件包含 url 字段
|
||
"""
|
||
urls = []
|
||
messages = conversation.get("messages", [])
|
||
|
||
for message in messages:
|
||
content = message.get("content")
|
||
if not content:
|
||
continue
|
||
|
||
# content 可能是字符串(需要解析)或已解析的字典
|
||
if isinstance(content, str):
|
||
try:
|
||
content = json.loads(content)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
|
||
# 提取图片附件
|
||
images = content.get("images", [])
|
||
for img in images:
|
||
url = img.get("url")
|
||
if url and url not in urls:
|
||
urls.append(url)
|
||
|
||
# 提取文件附件
|
||
files = content.get("files", [])
|
||
for f in files:
|
||
url = f.get("url")
|
||
if url and url not in urls:
|
||
urls.append(url)
|
||
|
||
return urls
|
||
|
||
|
||
async def update_conversation_handler(conversation_id: str, data: dict):
|
||
"""部分更新对话处理器"""
|
||
db = get_db()
|
||
result = db.update_conversation(conversation_id, data)
|
||
if result:
|
||
return result
|
||
else:
|
||
raise HTTPException(status_code=404, detail="对话不存在")
|
||
|
||
|
||
# ── 消息管理 ─────────────────────────────────────────────────────
|
||
|
||
|
||
async def add_message_handler(conversation_id: str, message: dict):
|
||
"""添加消息到对话处理器"""
|
||
db = get_db()
|
||
# 检查对话是否存在
|
||
existing = db.get_conversation(conversation_id)
|
||
if not existing:
|
||
raise HTTPException(status_code=404, detail="对话不存在")
|
||
return db.add_message(conversation_id, message)
|
||
|
||
|
||
async def update_message_handler(conversation_id: str, message_id: str, data: dict):
|
||
"""更新消息处理器"""
|
||
db = get_db()
|
||
# 检查对话是否存在
|
||
existing = db.get_conversation(conversation_id)
|
||
if not existing:
|
||
raise HTTPException(status_code=404, detail="对话不存在")
|
||
|
||
result = db.update_message(message_id, data)
|
||
if result:
|
||
return result
|
||
else:
|
||
raise HTTPException(status_code=404, detail="消息不存在")
|
||
|
||
|
||
# ── 文件上传 ─────────────────────────────────────────────────────
|
||
|
||
|
||
async def upload_file_handler(file: UploadFile = File(...)):
|
||
"""文件上传处理器"""
|
||
try:
|
||
# 允许的 MIME 类型(宽松策略)
|
||
allowed_types = {
|
||
# 图片
|
||
"image/jpeg",
|
||
"image/png",
|
||
"image/gif",
|
||
"image/webp",
|
||
"image/bmp",
|
||
"image/svg+xml",
|
||
# 文本类
|
||
"text/plain",
|
||
"text/csv",
|
||
"text/markdown",
|
||
"text/html",
|
||
"text/xml",
|
||
"application/json",
|
||
"application/xml",
|
||
# PDF
|
||
"application/pdf",
|
||
# Office 文档
|
||
"application/msword",
|
||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
"application/vnd.ms-excel",
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
"application/vnd.ms-powerpoint",
|
||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||
}
|
||
|
||
# 允许的扩展名(兜底:MIME 类型可能被浏览器误判)
|
||
allowed_extensions = {
|
||
".jpg",
|
||
".jpeg",
|
||
".png",
|
||
".gif",
|
||
".webp",
|
||
".bmp",
|
||
".txt",
|
||
".md",
|
||
".csv",
|
||
".json",
|
||
".xml",
|
||
".yaml",
|
||
".yml",
|
||
".log",
|
||
".pdf",
|
||
".doc",
|
||
".docx",
|
||
".xls",
|
||
".xlsx",
|
||
".ppt",
|
||
".pptx",
|
||
".py",
|
||
".js",
|
||
".ts",
|
||
".html",
|
||
".css",
|
||
}
|
||
|
||
file_extension = Path(file.filename).suffix.lower()
|
||
|
||
if (
|
||
file.content_type not in allowed_types
|
||
and file_extension not in allowed_extensions
|
||
):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"不支持的文件类型: {file.content_type}({file_extension})",
|
||
)
|
||
|
||
# 生成唯一文件名
|
||
unique_filename = f"{int(datetime.utcnow().timestamp())}_{generate_unique_id()}{file_extension}"
|
||
file_path = upload_dir / unique_filename
|
||
|
||
# 保存文件到本地(临时缓存)
|
||
content = await file.read()
|
||
with open(file_path, "wb") as f:
|
||
f.write(content)
|
||
|
||
# 文件关闭后再上传到 OSS
|
||
from utils.oss_uploader import upload_file as oss_upload
|
||
|
||
oss_result = oss_upload(str(file_path))
|
||
file_url = oss_result["url"]
|
||
|
||
# 返回文件信息
|
||
result = {
|
||
"url": file_url,
|
||
"name": file.filename,
|
||
"size": len(content),
|
||
"mimeType": file.content_type,
|
||
}
|
||
|
||
log_info(f"File uploaded: {result}")
|
||
return result
|
||
|
||
except Exception as e:
|
||
log_error(f"Upload error: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}")
|
||
|
||
|
||
def serve_upload_handler(filename: str):
|
||
"""提供上传文件访问处理器"""
|
||
file_path = upload_dir / filename
|
||
if not file_path.exists():
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
return FileResponse(str(file_path))
|
||
|
||
|
||
# ── 停止生成 ─────────────────────────────────────────────────────
|
||
|
||
|
||
async def stop_generation_handler(message_id: str = None):
|
||
"""停止生成处理器"""
|
||
message = (
|
||
f"已发出停止指令,消息ID: {message_id}" if message_id else "已发出停止指令"
|
||
)
|
||
return {"success": True, "message": message}
|
||
|
||
|
||
async def delete_attachment_handler(url: str):
|
||
"""删除附件处理器 - 从 OSS 删除文件"""
|
||
try:
|
||
from utils.oss_uploader import delete_file, extract_object_key_from_url
|
||
|
||
object_key = extract_object_key_from_url(url)
|
||
if not object_key:
|
||
raise HTTPException(status_code=400, detail="无效的文件 URL")
|
||
|
||
success = delete_file(object_key)
|
||
if success:
|
||
return {"success": True, "message": "文件删除成功"}
|
||
else:
|
||
raise HTTPException(status_code=500, detail="文件删除失败")
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
log_error(f"删除附件失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}") |