feat(chat): 新增聊天记录离线导出接口

- 支持批量范围(选中/全部/群聊/单聊)与时间筛选
- 支持 json/txt 导出并打包为 zip,附带 manifest/report
- 可选打包离线媒体与头像;隐私模式下隐藏字段并禁用媒体/头像
- 提供任务查询/取消/下载与 SSE 进度事件流
- 修复导出路径名清理的正则误伤
This commit is contained in:
2977094657
2025-12-23 20:25:55 +08:00
parent 36f5067730
commit 0445889b9b
3 changed files with 1964 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from .logging_config import setup_logging, get_logger from .logging_config import setup_logging, get_logger
from .path_fix import PathFixRoute from .path_fix import PathFixRoute
from .routers.chat import router as _chat_router from .routers.chat import router as _chat_router
from .routers.chat_export import router as _chat_export_router
from .routers.chat_media import router as _chat_media_router from .routers.chat_media import router as _chat_media_router
from .routers.decrypt import router as _decrypt_router from .routers.decrypt import router as _decrypt_router
from .routers.health import router as _health_router from .routers.health import router as _health_router
@@ -39,6 +40,7 @@ app.include_router(_wechat_detection_router)
app.include_router(_decrypt_router) app.include_router(_decrypt_router)
app.include_router(_media_router) app.include_router(_media_router)
app.include_router(_chat_router) app.include_router(_chat_router)
app.include_router(_chat_export_router)
app.include_router(_chat_media_router) app.include_router(_chat_media_router)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
import asyncio
import json
import time
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field
from ..chat_export_service import CHAT_EXPORT_MANAGER
from ..path_fix import PathFixRoute
router = APIRouter(route_class=PathFixRoute)
ExportFormat = Literal["json", "txt"]
ExportScope = Literal["selected", "all", "groups", "singles"]
MediaKind = Literal["image", "emoji", "video", "video_thumb", "voice", "file"]
class ChatExportCreateRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
scope: ExportScope = Field("selected", description="导出范围selected=指定会话all=全部groups=仅群聊singles=仅单聊")
usernames: list[str] = Field(default_factory=list, description="会话 username 列表scope=selected 时使用)")
format: ExportFormat = Field("json", description="导出格式json 或 txtzip 内每个会话一个文件)")
start_time: Optional[int] = Field(None, description="起始时间Unix 秒,含)")
end_time: Optional[int] = Field(None, description="结束时间Unix 秒,含)")
include_hidden: bool = Field(False, description="是否包含隐藏会话scope!=selected 时)")
include_official: bool = Field(False, description="是否包含公众号/官方账号会话scope!=selected 时)")
include_media: bool = Field(True, description="是否打包离线媒体(图片/表情/视频/语音/文件)")
media_kinds: list[MediaKind] = Field(
default_factory=lambda: ["image", "emoji", "video", "video_thumb", "voice", "file"],
description="打包的媒体类型",
)
allow_process_key_extract: bool = Field(
False,
description="是否允许尝试从微信进程提取媒体密钥(预留;当前仅使用已存在的本地文件)",
)
privacy_mode: bool = Field(
False,
description="隐私模式导出:隐藏会话/用户名/内容,不打包头像与媒体",
)
file_name: Optional[str] = Field(None, description="导出 zip 文件名(可选,不含/含 .zip 都可)")
@router.post("/api/chat/exports", summary="创建聊天记录导出任务(离线 zip")
async def create_chat_export(req: ChatExportCreateRequest):
job = CHAT_EXPORT_MANAGER.create_job(
account=req.account,
scope=req.scope,
usernames=req.usernames,
export_format=req.format,
start_time=req.start_time,
end_time=req.end_time,
include_hidden=req.include_hidden,
include_official=req.include_official,
include_media=req.include_media,
media_kinds=req.media_kinds,
allow_process_key_extract=req.allow_process_key_extract,
privacy_mode=req.privacy_mode,
file_name=req.file_name,
)
return {"status": "success", "job": job.to_public_dict()}
@router.get("/api/chat/exports", summary="列出导出任务(内存)")
async def list_chat_exports():
jobs = [j.to_public_dict() for j in CHAT_EXPORT_MANAGER.list_jobs()]
jobs.sort(key=lambda x: int(x.get("createdAt") or 0), reverse=True)
return {"status": "success", "jobs": jobs}
@router.get("/api/chat/exports/{export_id}", summary="获取导出任务状态")
async def get_chat_export(export_id: str):
job = CHAT_EXPORT_MANAGER.get_job(str(export_id or "").strip())
if not job:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success", "job": job.to_public_dict()}
@router.get("/api/chat/exports/{export_id}/download", summary="下载导出 zip")
async def download_chat_export(export_id: str):
job = CHAT_EXPORT_MANAGER.get_job(str(export_id or "").strip())
if not job:
raise HTTPException(status_code=404, detail="Export not found.")
if not job.zip_path or (not job.zip_path.exists()):
raise HTTPException(status_code=409, detail="Export not ready.")
return FileResponse(
str(job.zip_path),
media_type="application/zip",
filename=job.zip_path.name,
)
@router.get("/api/chat/exports/{export_id}/events", summary="导出任务进度 SSE")
async def stream_chat_export_events(export_id: str, request: Request):
export_id = str(export_id or "").strip()
job0 = CHAT_EXPORT_MANAGER.get_job(export_id)
if not job0:
raise HTTPException(status_code=404, detail="Export not found.")
async def gen():
last_payload = ""
last_heartbeat = 0.0
while True:
if await request.is_disconnected():
break
job = CHAT_EXPORT_MANAGER.get_job(export_id)
if not job:
yield "event: error\ndata: " + json.dumps({"error": "Export not found."}, ensure_ascii=False) + "\n\n"
break
payload = json.dumps(job.to_public_dict(), ensure_ascii=False)
if payload != last_payload:
last_payload = payload
yield f"data: {payload}\n\n"
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
yield ": ping\n\n"
if job.status in {"done", "error", "cancelled"}:
break
await asyncio.sleep(0.6)
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
@router.delete("/api/chat/exports/{export_id}", summary="取消导出任务")
async def cancel_chat_export(export_id: str):
ok = CHAT_EXPORT_MANAGER.cancel_job(str(export_id or "").strip())
if not ok:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success"}