WechatOnCloud/bridge/woc_bridge/routes/export.py
Kris 2348a04163 feat: 完成微信4.0模板UI重构与批量群发、导出功能落地
本次提交包含多项核心更新:
1. 新增微信4.0浅色主题UI模板资源与说明文档,补充了联系人图标、新的朋友入口等四个UI元素的图像匹配模板
2. 重构UI驱动层,将原2375行的单文件拆分为5个职责清晰的子驱动类,提升代码可维护性
3. 新增批量群发消息与聊天记录导出的完整数据模型、路由与后台协程,支持幂等校验与风控配置
4. 为所有业务接口新增post_verify校验逻辑,补充了verified字段返回操作结果
5. 优化媒体文件解密逻辑,修复了导出功能中的数据库错误处理与分辨率硬编码问题
6. 更新版本配置,将媒体发送功能标记为已落地,调整了能力列表与配置项
2026-07-16 16:53:42 +08:00

1016 lines
37 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.

"""聊天记录导出路由:同步 + 异步两种模式。
同步端点GET /api/messages/export
limit ≤ 1000 的纯文本/HTML 导出,直接 StreamingResponse 返回。
支持 HTML / CSV / JSON / TXT 四种格式HTML 可选 media_inline 内联图片。
异步端点POST /api/messages/export + GET /{task_id}/status +
GET /{task_id}/download + DELETE /{task_id}
limit > 1000 或 include_media=true 时使用,后台 worker 流式生成,
完成后下载链接有效 2 小时expires_at。include_media=true 且
media_inline=false 时返回 zip主文件 + media/ 目录)。
约束:
- 全局同时只允许 1 个异步导出任务运行_MAX_CONCURRENT_EXPORT_TASKS
- 媒体抓取失败写 [媒体缺失] 占位,不中断导出
- 不引入新第三方依赖
"""
from __future__ import annotations
import asyncio
import base64
import csv
import html as _html
import io
import json
import logging
import os
import shutil
import time
import uuid
import zipfile
from datetime import datetime
from typing import AsyncIterator, Optional
from fastapi import APIRouter
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from woc_bridge.config import _require_db_reader, _state
from woc_bridge.db.coordinator import _check_db_readable, with_db_retry
from woc_bridge.models import BridgeError, ExportRequest, ExportTaskStatus
logger = logging.getLogger("woc-bridge")
router = APIRouter(prefix="/api/messages/export", tags=["messages-export"])
# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
# 同步导出阈值limit 超过此值必须走异步
_SYNC_EXPORT_LIMIT = 1000
# 异步任务并发限制:同时只允许 1 个任务运行
_MAX_CONCURRENT_EXPORT_TASKS = 1
# 异步任务分页大小(每批从 DB 拉取 200 条)
_EXPORT_BATCH_SIZE = 200
# 任务存储内存进程级。task_id → 任务状态 dict
# dict 结构与 ExportTaskStatus 对齐,外加内部字段:
# _task: asyncio.Task | None 后台 worker 句柄
# _file_path: str | None 生成的文件绝对路径
# _task_dir: str | None 任务目录zip 模式含 media/
# _cancelled: bool 是否被主动取消
_export_tasks: dict[str, dict] = {}
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _now_ts() -> int:
"""当前 Unix 时间戳(秒)。"""
return int(time.time())
def _format_time(ts: int) -> str:
"""时间戳格式化为 YYYY-MM-DD HH:MM:SS本地时区"""
if not ts:
return "未知时间"
try:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
except (OSError, ValueError, OverflowError):
return "未知时间"
def _format_date(ts: int) -> str:
"""时间戳格式化为 YYYY-MM-DD用于时间分隔条"""
if not ts:
return ""
try:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
except (OSError, ValueError, OverflowError):
return ""
def _ext_for_format(fmt: str) -> str:
"""格式 → 文件扩展名。"""
return {"html": "html", "csv": "csv", "json": "json", "txt": "txt"}.get(
fmt, "txt"
)
def _mime_for_format(fmt: str) -> str:
"""格式 → MIME 类型。"""
return {
"html": "text/html; charset=utf-8",
"csv": "text/csv; charset=utf-8",
"json": "application/json; charset=utf-8",
"txt": "text/plain; charset=utf-8",
"zip": "application/zip",
}.get(fmt, "text/plain; charset=utf-8")
def _safe_filename(talker: str) -> str:
"""把 wxid/chatroom id 转为文件名安全字符串。"""
safe = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" for c in talker)
return safe or "export"
def _count_active_tasks() -> int:
"""统计当前 pending/running 状态的任务数(用于并发限制)。"""
return sum(
1 for t in _export_tasks.values()
if t.get("status") in ("pending", "running")
)
def _lazy_cleanup_expired() -> None:
"""懒清理过期任务GET /status 时触发)。
P1 修复:过期判定扩展到 completed/failed/cancelled 三种终态。
running/pending 不清理(仍在执行中)。
清理动作:删除任务目录与文件,从 _export_tasks 移除。
"""
now = _now_ts()
expired_ids: list[str] = []
for tid, task in list(_export_tasks.items()):
status = task.get("status")
# P1 修复failed/cancelled 也需清理,否则内存泄漏
if status not in ("completed", "failed", "cancelled"):
continue
exp = task.get("expires_at")
if exp is not None and exp < now:
expired_ids.append(tid)
for tid in expired_ids:
task = _export_tasks.pop(tid, None)
if task is None:
continue
# 删除任务目录(含主文件与 media/
task_dir = task.get("_task_dir")
if task_dir and os.path.isdir(task_dir):
try:
shutil.rmtree(task_dir, ignore_errors=True)
except OSError:
pass
def _safe_remove(path: str) -> None:
"""安全删除文件,吞掉 OSError。"""
try:
if path and os.path.isfile(path):
os.remove(path)
except OSError:
pass
# ---------------------------------------------------------------------------
# 4 个格式生成器(同步版本,用于 StreamingResponse
# ---------------------------------------------------------------------------
def _html_head(talker: str, nickname: str) -> bytes:
"""生成 HTML 头部 + CSS仿微信气泡样式"""
display_name = nickname or talker
return (
"<!DOCTYPE html>\n"
'<html lang="zh-CN">\n<head>\n'
'<meta charset="UTF-8">\n'
f'<title>聊天记录 - {_html.escape(display_name)}</title>\n'
"<style>\n"
" body { background:#EDEDED; margin:0; padding:20px; "
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; "
"font-size:14px; color:#333; }\n"
" .header { text-align:center; padding:10px 0 20px; "
"font-size:18px; color:#111; font-weight:600; }\n"
" .msg { margin:6px 0; display:flex; }\n"
" .msg.self { justify-content:flex-end; }\n"
" .msg.other { justify-content:flex-start; }\n"
" .bubble { max-width:60%; padding:8px 12px; border-radius:6px; "
"word-break:break-word; box-shadow:0 1px 1px rgba(0,0,0,0.05); }\n"
" .self .bubble { background:#95EC69; }\n"
" .other .bubble { background:#FFFFFF; }\n"
" .system { text-align:center; margin:10px 0; }\n"
" .system span { background:#DADADA; color:#FFF; padding:2px 8px; "
"border-radius:3px; font-size:12px; }\n"
" .time-sep { text-align:center; margin:14px 0; }\n"
" .time-sep span { background:#CECECE; color:#FFF; padding:2px 8px; "
"border-radius:3px; font-size:12px; }\n"
" .bubble img { max-width:100%; height:auto; display:block; "
"border-radius:4px; }\n"
" .media-missing { color:#999; font-style:italic; }\n"
" .sender { font-size:12px; color:#888; margin-bottom:2px; }\n"
"</style>\n</head>\n<body>\n"
f'<div class="header">{_html.escape(display_name)}</div>\n'
).encode("utf-8")
def _html_tail() -> bytes:
"""HTML 尾部。"""
return b"</body>\n</html>\n"
def _render_media_html(msg: dict, media_inline: bool, db_reader=None) -> str:
"""渲染单条媒体消息的 HTML 内部内容。
media_inline=true 时图片转 base64 内联(经 db_reader 解密 .dat
否则用 media/<filename> 相对路径(由 _copy_media_files 写入解密后的文件)。
媒体不可读时返回 [媒体缺失] 占位。
P0 修复media_inline=true 时通过 db_reader.read_media_file_by_path 读取
并解密 .dat 文件,不再直接读取原始密文。
"""
media_path = msg.get("media_path")
render_type = msg.get("render_type", "")
if not media_path or not os.path.isfile(media_path) or not os.access(media_path, os.R_OK):
label = render_type or "媒体"
return f'<span class="media-missing">[{_html.escape(label)}缺失]</span>'
filename = os.path.basename(media_path)
if render_type == "image":
if media_inline:
# P0 修复:通过 db_reader 读取并解密 .dat不再直接读取原始密文
media_data = None
if db_reader is not None:
try:
result = db_reader.read_media_file_by_path(media_path)
if result is not None:
media_data, mime_from_db = result
except Exception:
pass
if media_data is None:
return '<span class="media-missing">[图片缺失]</span>'
b64 = base64.b64encode(media_data).decode("ascii")
ext = os.path.splitext(filename)[1].lower().lstrip(".")
mime = {
"jpg": "image/jpeg", "jpeg": "image/jpeg",
"png": "image/png", "gif": "image/gif",
"bmp": "image/bmp", "webp": "image/webp",
}.get(ext, "image/jpeg")
return f'<img src="data:{mime};base64,{b64}" alt="{_html.escape(filename)}">'
return f'<img src="media/{_html.escape(filename)}" alt="{_html.escape(filename)}">'
# 语音/视频/文件:给出下载链接
label = {
"voice": "语音", "video": "视频", "file": "文件",
}.get(render_type, "媒体")
return (
f'<a href="media/{_html.escape(filename)}" '
f'download="{_html.escape(filename)}">[{label}] {_html.escape(filename)}</a>'
)
def _render_html_messages(
messages: list[dict],
talker: str,
media_inline: bool,
last_ts: Optional[int],
db_reader=None,
) -> tuple[bytes, Optional[int]]:
"""渲染一批消息为 HTML 片段,返回 (bytes, new_last_ts)。
时间分隔条:相邻消息间隔 > 5 分钟插入。
"""
buf = io.StringIO()
for msg in messages:
ts = int(msg.get("create_time", 0) or 0)
if last_ts is not None and ts - last_ts > 300:
if _format_date(ts) != _format_date(last_ts):
sep_text = _format_date(ts)
else:
sep_text = _format_time(ts)
buf.write(
f'<div class="time-sep"><span>{_html.escape(sep_text)}</span></div>\n'
)
last_ts = ts
render_type = msg.get("render_type", "text")
is_self = bool(msg.get("is_sender"))
sender = msg.get("sender", "") or ""
content = msg.get("content", "") or ""
if render_type == "system":
sys_text = _html.escape(content) or "[系统消息]"
buf.write(f'<div class="system"><span>{sys_text}</span></div>\n')
continue
side = "self" if is_self else "other"
if render_type in ("image", "voice", "video", "file"):
bubble_inner = _render_media_html(msg, media_inline, db_reader)
else:
text = _html.escape(content)
bubble_inner = text.replace("\n", "<br>") if text else "[空消息]"
sender_html = ""
if msg.get("session_type") == "group" and not is_self and sender:
sender_html = f'<div class="sender">{_html.escape(sender)}</div>'
buf.write(
f'<div class="msg {side}">'
f'<div>{sender_html}<div class="bubble">{bubble_inner}</div></div>'
f"</div>\n"
)
return buf.getvalue().encode("utf-8"), last_ts
def _generate_html(
messages: list[dict],
talker: str,
nickname: str,
media_inline: bool = False,
db_reader=None,
) -> AsyncIterator[bytes]:
"""HTML 生成器(同步端点用):内联 CSS 气泡样式。
本人消息右对齐 #95EC69对方左对齐 #FFFFFF系统居中灰
相邻消息间隔 > 5 分钟插入时间分隔条,
媒体消息渲染 <img> 或 [媒体缺失] 占位。
"""
yield _html_head(talker, nickname)
last_ts: Optional[int] = None
chunk, last_ts = _render_html_messages(
messages, talker, media_inline, last_ts, db_reader
)
yield chunk
yield _html_tail()
def _generate_csv(messages: list[dict]) -> AsyncIterator[bytes]:
"""CSV 生成器msg_id, create_time, is_self, type, content 列。
content 中的换行符由 csv 模块自动处理(双引号包裹)。
首行写 UTF-8 BOM 让 Excel 正确识别编码。
"""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["msg_id", "create_time", "is_self", "type", "content"])
yield buf.getvalue().encode("utf-8-sig")
buf.seek(0)
buf.truncate(0)
for msg in messages:
writer.writerow([
msg.get("msg_id", ""),
msg.get("create_time", 0),
"true" if msg.get("is_sender") else "false",
msg.get("type", 0),
msg.get("content", "") or "",
])
if buf.tell() > 8192:
yield buf.getvalue().encode("utf-8")
buf.seek(0)
buf.truncate(0)
if buf.tell() > 0:
yield buf.getvalue().encode("utf-8")
def _make_json_item(msg: dict) -> dict:
"""构造单条消息的 JSON dict。"""
return {
"msg_id": msg.get("msg_id", ""),
"create_time": msg.get("create_time", 0),
"is_sender": bool(msg.get("is_sender")),
"type": msg.get("type", 0),
"render_type": msg.get("render_type", "text"),
"sender": msg.get("sender", ""),
"content": msg.get("content", "") or "",
"session_type": msg.get("session_type", ""),
"media_path": msg.get("media_path"),
}
def _generate_json(messages: list[dict], talker: str) -> AsyncIterator[bytes]:
"""JSON 生成器:结构化字段,含 media_path。
输出格式:{"talker": "...", "count": N, "messages": [...]}
流式输出head → 每条 item用逗号分隔→ tail。
"""
yield (
f'{{"talker": {json.dumps(talker, ensure_ascii=False)}, '
f'"count": {len(messages)}, "messages": [\n'
).encode("utf-8")
for i, msg in enumerate(messages):
item = _make_json_item(msg)
sep = "," if i < len(messages) - 1 else ""
yield f" {json.dumps(item, ensure_ascii=False)}{sep}\n".encode("utf-8")
yield b"]}\n"
def _generate_txt(messages: list[dict], nickname: str) -> AsyncIterator[bytes]:
"""TXT 生成器:[时间] 昵称: 内容 纯文本。
本人消息用 "" 标记,对方用 nickname 或 talker 显示。
群消息用 sender 字段(如果存在)。
"""
display_name = nickname or "对方"
for msg in messages:
ts = int(msg.get("create_time", 0) or 0)
time_str = _format_time(ts)
is_self = bool(msg.get("is_sender"))
sender = msg.get("sender", "") or ""
content = msg.get("content", "") or ""
render_type = msg.get("render_type", "text")
if render_type in ("image", "voice", "video", "file"):
label = {
"image": "图片", "voice": "语音",
"video": "视频", "file": "文件",
}.get(render_type, "媒体")
if not msg.get("media_path"):
content_text = f"[{label}缺失]"
else:
content_text = f"[{label}]"
else:
content_text = content
if is_self:
who = ""
elif sender and msg.get("session_type") == "group":
who = sender
else:
who = display_name
yield f"[{time_str}] {who}: {content_text}\n".encode("utf-8")
# ---------------------------------------------------------------------------
# 同步导出端点GET /api/messages/export
# ---------------------------------------------------------------------------
@router.get("")
@with_db_retry
async def sync_export(
talker: str,
format: str = "html",
limit: int = 1000,
include_media: bool = False,
media_inline: bool = False,
start_time: Optional[int] = None,
end_time: Optional[int] = None,
):
"""同步导出limit ≤ 1000
直接 StreamingResponse 返回生成内容Content-Disposition 触发浏览器下载。
HTML 格式可设 media_inline=true 将图片以 base64 内联(仅适合少量图片)。
Args:
talker: 会话 wxid 或群 id
format: html / csv / json / txt
limit: 1~1000
include_media: 同步端点忽略此参数(媒体通过 media_inline 内联或路径引用)
media_inline: 仅 HTML图片转 base64 内联
start_time: 起始时间戳(含)
end_time: 结束时间戳(含)
Raises:
BridgeError(INVALID_PARAMS): talker 为空、format 非法、limit 越界
BridgeError(RATE_LIMITED): 已有异步导出任务运行中
BridgeError(DB_NOT_FOUND / DB_ENCRYPTED): DB 不可读
"""
if not talker:
raise BridgeError(code="INVALID_PARAMS", message="talker 不能为空")
if format not in ("html", "csv", "json", "txt"):
raise BridgeError(
code="INVALID_PARAMS",
message=f"format 必须为 html/csv/json/txt收到 {format}",
)
if limit < 1 or limit > _SYNC_EXPORT_LIMIT:
raise BridgeError(
code="INVALID_PARAMS",
message=f"同步导出 limit 必须在 1~{_SYNC_EXPORT_LIMIT} 之间,收到 {limit}"
"如需导出更多请用 POST /api/messages/export 异步端点",
)
if start_time is not None and end_time is not None and start_time > end_time:
raise BridgeError(
code="INVALID_PARAMS",
message="start_time 不能大于 end_time",
)
# 并发限制:异步任务运行中时拒绝同步导出,避免 DB I/O 抢占
if _count_active_tasks() >= _MAX_CONCURRENT_EXPORT_TASKS:
raise BridgeError(
code="RATE_LIMITED",
message="已有异步导出任务运行中,请稍后重试或先取消该任务",
details={"retry_after": 30},
)
db_reader = _require_db_reader()
await _check_db_readable()
# 一次性查 DBlimit ≤ 1000内存可控
messages = await asyncio.to_thread(
db_reader.get_messages_for_export,
talker,
start_time,
end_time,
limit,
0,
)
if messages is None:
raise BridgeError(
code="DB_NOT_FOUND",
message="DB 不可读,无法导出消息",
)
# 取昵称HTML/TXT 用)
nickname: Optional[str] = ""
if format in ("html", "txt"):
try:
nickname = await asyncio.to_thread(db_reader.get_contact_nickname, talker)
except Exception:
nickname = None
filename = f"export_{_safe_filename(talker)}.{_ext_for_format(format)}"
media_type = _mime_for_format(format)
headers = {
"Content-Disposition": f'attachment; filename="{filename}"',
}
if format == "html":
gen = _generate_html(messages, talker, nickname or "", media_inline, db_reader)
elif format == "csv":
gen = _generate_csv(messages)
elif format == "json":
gen = _generate_json(messages, talker)
else: # txt
gen = _generate_txt(messages, nickname or "")
logger.info(
"export sync: talker=%s format=%s limit=%d%d",
talker, format, limit, len(messages),
)
return StreamingResponse(gen, media_type=media_type, headers=headers)
# ---------------------------------------------------------------------------
# 异步导出端点POST /api/messages/export
# ---------------------------------------------------------------------------
@router.post("")
async def submit_export_task(req: ExportRequest):
"""提交异步导出任务。
适用场景limit > 1000 或 include_media=true。
立即返回 task_id + status=pending后台 worker 流式生成文件。
全局同时只允许 1 个任务运行_MAX_CONCURRENT_EXPORT_TASKS
提交时即把状态置为 running避免并发提交绕过限制
Returns:
ExportTaskStatustask_id + status=pending + progress=0
Raises:
BridgeError(INVALID_PARAMS): start_time > end_time
BridgeError(RATE_LIMITED): 已有任务运行中
"""
if req.start_time is not None and req.end_time is not None and req.start_time > req.end_time:
raise BridgeError(
code="INVALID_PARAMS",
message="start_time 不能大于 end_time",
)
# 懒清理过期任务(顺带回收槽位)
_lazy_cleanup_expired()
# 并发限制check-then-set 在 asyncio 单线程内原子(无 await 间隙)
if _count_active_tasks() >= _MAX_CONCURRENT_EXPORT_TASKS:
raise BridgeError(
code="RATE_LIMITED",
message="已有导出任务运行中请稍后重试或先取消该任务DELETE /api/messages/export/{task_id}",
details={"retry_after": 30},
)
task_id = uuid.uuid4().hex
_export_tasks[task_id] = {
"task_id": task_id,
# 直接置 running避免下一并发提交绕过限制
"status": "running",
"progress": 0.0,
"total": 0,
"processed": 0,
"download_url": None,
"expires_at": None,
"error_message": None,
"_task": None,
"_file_path": None,
"_task_dir": None,
"_cancelled": False,
}
asyncio_task = asyncio.create_task(_run_export_task(task_id, req))
_export_tasks[task_id]["_task"] = asyncio_task
logger.info(
"export async submit: task_id=%s talker=%s format=%s limit=%d include_media=%s",
task_id, req.talker, req.format, req.limit, req.include_media,
)
return ExportTaskStatus(
task_id=task_id,
status="running",
progress=0.0,
)
# ---------------------------------------------------------------------------
# 异步导出GET /api/messages/export/{task_id}/status
# ---------------------------------------------------------------------------
@router.get("/{task_id}/status")
async def get_export_status(task_id: str):
"""查询导出任务状态。
顺带触发懒清理过期任务。
"""
_lazy_cleanup_expired()
task = _export_tasks.get(task_id)
if task is None:
raise BridgeError(
code="INVALID_PARAMS",
message=f"任务不存在或已过期: {task_id}",
)
return ExportTaskStatus(
task_id=task["task_id"],
status=task["status"],
progress=task["progress"],
total=task["total"],
processed=task["processed"],
download_url=task.get("download_url"),
expires_at=task.get("expires_at"),
error_message=task.get("error_message"),
)
# ---------------------------------------------------------------------------
# 异步导出GET /api/messages/export/{task_id}/download
# ---------------------------------------------------------------------------
@router.get("/{task_id}/download")
async def download_export(task_id: str):
"""下载导出文件。
- include_media=true 且 media_inline=false 时返回 zip主文件 + media/
- 其他情况返回单文件HTML/CSV/JSON/TXT
Raises:
BridgeError(INVALID_PARAMS): 任务不存在
BridgeError(STATE_DIRTY): 任务未完成或已过期
"""
_lazy_cleanup_expired()
task = _export_tasks.get(task_id)
if task is None:
raise BridgeError(
code="INVALID_PARAMS",
message=f"任务不存在或已过期: {task_id}",
)
if task["status"] != "completed":
raise BridgeError(
code="STATE_DIRTY",
message=f"任务未完成,当前状态: {task['status']}",
)
file_path = task.get("_file_path")
if not file_path or not os.path.isfile(file_path):
raise BridgeError(
code="BRIDGE_INTERNAL_ERROR",
message="导出文件丢失,请重新提交任务",
)
filename = os.path.basename(file_path)
# 按扩展名决定 MIME
if filename.endswith(".zip"):
media_type = _mime_for_format("zip")
elif filename.endswith(".html"):
media_type = _mime_for_format("html")
elif filename.endswith(".csv"):
media_type = _mime_for_format("csv")
elif filename.endswith(".json"):
media_type = _mime_for_format("json")
else:
media_type = _mime_for_format("txt")
return FileResponse(file_path, media_type=media_type, filename=filename)
# ---------------------------------------------------------------------------
# 异步导出DELETE /api/messages/export/{task_id}
# ---------------------------------------------------------------------------
@router.delete("/{task_id}")
async def cancel_export(task_id: str):
"""主动取消任务 + 清理文件。
- running 中:取消 asyncio.Task置 status=cancelled删除文件
- pending/failed直接清理
- completed删除文件并从内存移除提前回收
"""
task = _export_tasks.get(task_id)
if task is None:
raise BridgeError(
code="INVALID_PARAMS",
message=f"任务不存在: {task_id}",
)
asyncio_task = task.get("_task")
if asyncio_task is not None and not asyncio_task.done():
task["_cancelled"] = True
asyncio_task.cancel()
try:
await asyncio.wait_for(asyncio_task, timeout=5.0)
except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
pass
# 清理任务目录(含主文件与 media/
task_dir = task.get("_task_dir")
if task_dir and os.path.isdir(task_dir):
try:
shutil.rmtree(task_dir, ignore_errors=True)
except OSError:
pass
# 内存移除
_export_tasks.pop(task_id, None)
logger.info("export cancel: task_id=%s", task_id)
return JSONResponse({"success": True, "task_id": task_id, "status": "cancelled"})
# ---------------------------------------------------------------------------
# 后台 worker
# ---------------------------------------------------------------------------
async def _run_export_task(task_id: str, req: ExportRequest) -> None:
"""后台 worker分页查 DB每批 _EXPORT_BATCH_SIZE 条)流式写入。
流程:
1. 创建 <export_dir>/<task_id>/ 目录
2. 分页查 DB每批写入文件
3. include_media=true 且 media_inline=false 时拷贝媒体文件到 media/
4. 完成后:
- media_inline=false 且 include_media=true打包 zip
- 否则:保留单文件
5. 设置 download_url + expires_at默认 2 小时后status=completed
6. 异常status=failed + error_message
7. 取消_cancelled=truestatus=cancelled删文件
"""
task = _export_tasks.get(task_id)
if task is None:
return
# 导出目录(来自 BridgeConfig.export_dir
export_dir = (
_state.config.export_dir if _state.config else "/config/woc-export"
)
task_dir = os.path.join(export_dir, task_id)
media_dir = os.path.join(task_dir, "media")
try:
os.makedirs(task_dir, exist_ok=True)
if req.include_media and not req.media_inline:
os.makedirs(media_dir, exist_ok=True)
except OSError as e:
task["status"] = "failed"
task["error_message"] = f"创建任务目录失败: {e}"
# P0 修复failed 任务也设置 expires_at否则 _lazy_cleanup_expired 永不清理
task["expires_at"] = _now_ts() + 300
return
task["_task_dir"] = task_dir
# 主文件路径
ext = _ext_for_format(req.format)
main_filename = f"export_{_safe_filename(req.talker)}.{ext}"
main_path = os.path.join(task_dir, main_filename)
try:
# P1 修复_require_db_reader 移入 try 块DB 不可用时任务标 failed
# 而非永久卡在 running
db_reader = _require_db_reader()
except BridgeError as e:
task["status"] = "failed"
task["error_message"] = f"DB 不可读: {e.message}"
# P0 修复:设置 expires_at + 清理 task_dir
task["expires_at"] = _now_ts() + 300
shutil.rmtree(task_dir, ignore_errors=True)
return
nickname: Optional[str] = ""
if req.format in ("html", "txt"):
try:
nickname = await asyncio.to_thread(db_reader.get_contact_nickname, req.talker)
except Exception:
nickname = None
try:
# 流式写入主文件
with open(main_path, "wb") as f:
await _stream_write_main_file(
f, task, req, db_reader, nickname or "", media_dir
)
if task.get("_cancelled"):
task["status"] = "cancelled"
# P0 修复:设置 expires_at + 清理 task_dir
task["expires_at"] = _now_ts() + 300
shutil.rmtree(task_dir, ignore_errors=True)
return
# include_media=true 且 media_inline=false打包 zip
final_path = main_path
if (
req.include_media
and not req.media_inline
and os.path.isdir(media_dir)
and os.listdir(media_dir)
):
zip_path = os.path.join(task_dir, f"export_{_safe_filename(req.talker)}.zip")
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(main_path, main_filename)
for root, _, files in os.walk(media_dir):
for name in files:
full = os.path.join(root, name)
arc = os.path.relpath(full, task_dir)
zf.write(full, arc)
# zip 已生成删除主文件原拷贝zip 内已归档)
_safe_remove(main_path)
shutil.rmtree(media_dir, ignore_errors=True)
final_path = zip_path
# 完成
task["status"] = "completed"
task["progress"] = 1.0
task["_file_path"] = final_path
ttl = (
_state.config.export_task_ttl_sec if _state.config else 7200
)
task["expires_at"] = _now_ts() + ttl
task["download_url"] = f"/api/messages/export/{task_id}/download"
logger.info(
"export async completed: task_id=%s file=%s total=%d",
task_id, os.path.basename(final_path), task["total"],
)
except asyncio.CancelledError:
task["status"] = "cancelled"
# P0 修复:设置 expires_at + 清理 task_dir
task["expires_at"] = _now_ts() + 300
shutil.rmtree(task_dir, ignore_errors=True)
raise
except Exception as e:
task["status"] = "failed"
task["error_message"] = str(e)[:500]
# P0 修复:设置 expires_at + 清理 task_dir
task["expires_at"] = _now_ts() + 300
shutil.rmtree(task_dir, ignore_errors=True)
logger.exception("export async failed: task_id=%s", task_id)
async def _stream_write_main_file(
f,
task: dict,
req: ExportRequest,
db_reader,
nickname: str,
media_dir: str,
) -> None:
"""分页查 DB + 流式写入主文件 + 可选拷贝媒体。
每批 _EXPORT_BATCH_SIZE 条,调用 db_reader.get_messages_for_export。
JSON 用 "pending item" 模式处理跨批逗号分隔HTML 用 head/tail 包裹。
"""
fmt = req.format
limit = req.limit
batch_size = _EXPORT_BATCH_SIZE
fetched = 0
first_batch = True
last_ts: Optional[int] = None # HTML 跨批时间分隔条状态
json_pending: Optional[str] = None # JSON 跨批逗号分隔的 pending item
# 格式头部
if fmt == "json":
# P1 修复count 移到尾部(流式无法预知实际条数,写 limit 会误导)
f.write(
f'{{"talker": {json.dumps(req.talker, ensure_ascii=False)}, '
f'"messages": [\n'.encode("utf-8")
)
elif fmt == "html":
f.write(_html_head(req.talker, nickname))
elif fmt == "csv":
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["msg_id", "create_time", "is_self", "type", "content"])
f.write(buf.getvalue().encode("utf-8-sig"))
try:
while fetched < limit:
if task.get("_cancelled"):
return
batch_limit = min(batch_size, limit - fetched)
batch = await asyncio.to_thread(
db_reader.get_messages_for_export,
req.talker,
req.start_time,
req.end_time,
batch_limit,
fetched,
)
if batch is None:
raise BridgeError(code="DB_NOT_FOUND", message="DB 不可读,无法导出")
if not batch:
break
# 媒体拷贝include_media=true 且 media_inline=false
if req.include_media and not req.media_inline:
_copy_media_files(batch, media_dir, db_reader)
# 写入文件(按格式)
if fmt == "json":
for msg in batch:
item_json = f" {json.dumps(_make_json_item(msg), ensure_ascii=False)}"
if json_pending is not None:
f.write((json_pending + ",\n").encode("utf-8"))
json_pending = item_json
elif fmt == "html":
chunk, last_ts = _render_html_messages(
batch, req.talker, req.media_inline, last_ts, db_reader
)
f.write(chunk)
elif fmt == "csv":
buf = io.StringIO()
w = csv.writer(buf)
for msg in batch:
w.writerow([
msg.get("msg_id", ""),
msg.get("create_time", 0),
"true" if msg.get("is_sender") else "false",
msg.get("type", 0),
msg.get("content", "") or "",
])
f.write(buf.getvalue().encode("utf-8"))
else: # txt
for chunk in _generate_txt(batch, nickname):
f.write(chunk)
fetched += len(batch)
task["processed"] = fetched
task["total"] = max(task["total"], fetched)
task["progress"] = min(1.0, fetched / limit) if limit > 0 else 1.0
first_batch = False
# 不足一批说明已无更多消息
if len(batch) < batch_limit:
break
finally:
# 格式尾部
if fmt == "json":
if json_pending is not None:
f.write((json_pending + "\n").encode("utf-8"))
# P1 修复count 写实际条数fetched而非 limit
f.write(f'], "count": {fetched}}}\n'.encode("utf-8"))
elif fmt == "html":
f.write(_html_tail())
def _copy_media_files(messages: list[dict], media_dir: str, db_reader=None) -> None:
"""把消息中的媒体文件解密后写入 media_dir。
P0 修复:通过 db_reader.read_media_file_by_path 读取并解密 .dat 文件,
不再用 shutil.copyfile 直接拷贝原始密文。db_reader=None 时回退到直接拷贝
(兼容旧调用方,但 .dat 媒体将不可读)。
媒体缺失path=None 或文件不可读)时跳过,由 HTML 渲染时写 [媒体缺失] 占位。
写入失败(权限/磁盘)仅记日志,不中断导出。
"""
for msg in messages:
src = msg.get("media_path")
if not src or not os.path.isfile(src) or not os.access(src, os.R_OK):
continue
dst = os.path.join(media_dir, os.path.basename(src))
# 同名冲突:追加 msg_id 前缀
if os.path.exists(dst):
base, ext = os.path.splitext(os.path.basename(src))
dst = os.path.join(media_dir, f"{base}_{msg.get('msg_id', '')}{ext}")
try:
# P0 修复:优先通过 db_reader 读取并解密 .dat
if db_reader is not None:
result = db_reader.read_media_file_by_path(src)
if result is not None:
data, _mime = result
with open(dst, "wb") as f:
f.write(data)
continue
# 解密失败(返回 None跳过该文件
logger.warning("解密媒体失败,跳过 src=%s", src)
continue
# 回退:直接拷贝(.dat 媒体将不可读)
shutil.copyfile(src, dst)
except OSError as e:
logger.warning("拷贝媒体失败 src=%s: %s", src, e)
# ---------------------------------------------------------------------------
# lifespan 钩子:进程退出时取消所有运行中任务
# ---------------------------------------------------------------------------
async def cancel_all_export_tasks_on_shutdown() -> None:
"""lifespan finally 段调用:取消所有运行中导出任务。
避免进程退出时残留 asyncio.Task 触发未捕获异常。
"""
for tid, task in list(_export_tasks.items()):
asyncio_task = task.get("_task")
if asyncio_task is not None and not asyncio_task.done():
task["_cancelled"] = True
asyncio_task.cancel()
try:
await asyncio.wait_for(asyncio_task, timeout=2.0)
except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
pass
_export_tasks.clear()