WechatOnCloud/bridge/woc_bridge/routes/messages.py

314 lines
13 KiB
Python
Raw Normal View History

from __future__ import annotations
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from woc_bridge.config import _require_db_reader, _require_message_streamer
from woc_bridge.db.coordinator import with_db_retry, _check_db_readable
from woc_bridge.models import (
MessagesResponse,
MessagesBySessionResponse,
MessageSearchResponse,
BridgeError,
)
from woc_bridge.messaging import StreamEvent, format_sse
logger = logging.getLogger("woc-bridge")
router = APIRouter()
@router.get("/api/messages/since", response_model=MessagesResponse)
@with_db_retry
async def get_messages_since(
cursor: int = 0,
limit: int = 50,
is_sender: Optional[bool] = Query(
None,
description="按发送方向过滤True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
),
) -> MessagesResponse:
"""增量消息拉取(按 create_time 游标)。
channels PullerAdapter 的核心接口调用方维护本地 cursor首次为 0
每次拉取 create_time > cursor 的消息把响应中 next_cursor 存下来作为
下次的 cursorhas_more=true 时应立即继续拉取否则按 poll_interval_ms 轮询
Args:
cursor: 上次拉取的最大 create_time首次传 0拉全量
limit: 最多返回条数1~200默认 50建议用 /api/status 返回的
max_batch_size 作为上限参考
is_sender: 按发送方向过滤True=仅本人发送 / False=仅对方发送 /
None=不过滤默认SQL 层过滤无法定位本人时返回空
Returns:
MessagesResponse messages 列表 / next_cursor / has_more
Raises:
BridgeError(INVALID_PARAMS): cursor<0 limit 越界HTTP 400
BridgeError(DB_NOT_FOUND): 未找到微信消息 DBHTTP 500
BridgeError(DB_ENCRYPTED): DB 已加密 SQLCipher 密钥HTTP 503
Notes:
- DB 读取通过 cp 快照避免锁定原文件sqlite3 同步阻塞故用
asyncio.to_thread 包装避免阻塞 event loop
- 群消息 content 形如 "wxid:\\n正文"DbReader 会拆出 sender 字段
- has_more=true 当且仅当返回条数 >= limit可能还有更多未拉取
"""
# 参数校验
if cursor < 0:
raise BridgeError(
code="INVALID_PARAMS",
message=f"cursor 必须 >= 0收到 {cursor}",
)
if limit < 1 or limit > 200:
raise BridgeError(
code="INVALID_PARAMS",
message=f"limit 必须在 1~200 之间,收到 {limit}",
)
db_reader = _require_db_reader()
# DB 加密时返回 DB_ENCRYPTED而非空数据
await _check_db_readable()
# DB 读取是同步阻塞操作,用 to_thread 包装
result = await asyncio.to_thread(
db_reader.get_messages_since, cursor, limit, is_sender
)
msg_count = len(result.get("messages", []))
logger.info(
"messages/since: cursor=%d limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s has_more=%s",
cursor, limit, is_sender, msg_count, result.get("next_cursor"), result.get("has_more"),
)
return MessagesResponse(**result)
# ---------------------------------------------------------------------------
# 路由GET /api/messages/streamSSE 实时消息推送)
# ---------------------------------------------------------------------------
@router.get("/api/messages/stream")
async def stream_messages(request: Request) -> StreamingResponse:
"""SSE 实时消息推送长连接。
建立 text/event-stream 连接后服务端主动推送新消息事件替代客户端
轮询 /api/messages/since内部由 MessageStreamer 周期性轮询 message DB
有订阅者 1s无订阅者 5sDB mtime 变化时读增量消息并广播
事件类型
- sync连接建立时立即发送data.cursor 为当前全局游标客户端据此
判断是否需要用 /api/messages/since 补全断线期间消息
- messages一批新消息data messages / next_cursor / has_more
结构与 /api/messages/since 响应体一致事件 id next_cursor
- statusDB 状态变化时发送data db_accessible / db_error_code
DB 加密/退出/恢复可读时触发客户端据此决定是否降级到轮询
- heartbeat 30 秒心跳保活 NAT/代理连接data 为空对象
- kicked订阅者被服务端剔除超过 MAX_SUBSCRIBERS 上限data.reason
给出原因客户端收到后应关闭连接并按需重连
断线补偿
SSE 不保证 100% 投递客户端断线重连后应
1. 连接本接口 sync 事件中的 cursor
2. max(本地cursor, sync.cursor) /api/messages/since 补全
3. 补全后继续消费本接口事件流
Returns:
StreamingResponsemedia_type=text/event-stream持续推送 SSE
Notes:
- DB 加密时不关闭连接改发 status 事件通知客户端key 提取成功后
自动恢复推送
- 响应头设置 X-Accel-Buffering: no 关闭 nginx 缓冲避免事件被攒批
- 客户端断开时自动取消订阅释放队列
- 不抛业务错误即使 DB 不可读也建立连接通过 status 事件告知
- 订阅者上限由 MessageStreamer.MAX_SUBSCRIBERS 控制超限时最早
订阅者会被剔除并收到 kicked 事件
"""
streamer = _require_message_streamer()
queue = streamer.subscribe()
async def event_generator():
try:
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(queue.get(), timeout=30.0)
except asyncio.TimeoutError:
# 队列 30s 无事件,发心跳保活
event = StreamEvent(event="heartbeat", data={})
# kicked 事件:被服务端剔除(订阅者超上限),发送后立即退出
if event.event == "kicked":
yield format_sse(event)
break
yield format_sse(event)
finally:
streamer.unsubscribe(queue)
headers = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # nginx 关闭缓冲,确保实时推送
"Connection": "keep-alive",
}
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers=headers,
)
# ---------------------------------------------------------------------------
# 路由GET /api/messages/by_session
# ---------------------------------------------------------------------------
@router.get("/api/messages/by_session", response_model=MessagesBySessionResponse)
@with_db_retry
async def get_messages_by_session(
talker: str,
cursor: int = 0,
limit: int = 50,
direction: str = "before",
is_sender: Optional[bool] = Query(
None,
description="按发送方向过滤True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
),
) -> MessagesBySessionResponse:
"""按会话拉取历史消息单会话O(1) 定位分片表)。
通过 talker 计算 Msg_<MD5(talker)> 表名直接查单表避免遍历所有分片
用于客户端聊天窗口的历史消息展示与上滑加载
Args:
talker: 会话对方 wxid群消息为 chatroom id
cursor: 游标首次传 0before 模式取该时间之前的旧消息
after 模式取该时间之后的新消息
limit: 最多返回条数1~200默认 50
direction: before默认往前翻历史/ after往后拉新消息
is_sender: 按发送方向过滤True=仅本人发送 / False=仅对方发送 /
None=不过滤默认SQL 层过滤无法定位本人时返回空
Returns:
MessagesBySessionResponse messages / next_cursor / has_more / talker
Raises:
BridgeError(INVALID_PARAMS): talker 为空limit 越界direction 非法HTTP 400
BridgeError(DB_NOT_FOUND): 未找到微信消息 DBHTTP 500
BridgeError(DB_ENCRYPTED): DB 已加密HTTP 503
Notes:
- before 模式返回升序消息旧在前新在后便于客户端追加到列表头部
- cursor=0 + before 取最新 limit cursor=某消息时间 + before 取更旧的消息
- has_more=true 时用 next_cursor 继续翻页
- 表不存在时返回空列表而非报错会话可能从未有过消息
"""
if not talker:
raise BridgeError(code="INVALID_PARAMS", message="talker 不能为空")
if limit < 1 or limit > 200:
raise BridgeError(
code="INVALID_PARAMS",
message=f"limit 必须在 1~200 之间,收到 {limit}",
)
if direction not in ("before", "after"):
raise BridgeError(
code="INVALID_PARAMS",
message=f"direction 必须为 before 或 after收到 {direction}",
)
if cursor < 0:
raise BridgeError(
code="INVALID_PARAMS",
message=f"cursor 必须 >= 0收到 {cursor}",
)
db_reader = _require_db_reader()
await _check_db_readable()
result = await asyncio.to_thread(
db_reader.get_messages_by_session, talker, cursor, limit, direction, is_sender
)
msg_count = len(result.get("messages", []))
logger.info(
"messages/by_session: talker=%s cursor=%d direction=%s limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s has_more=%s",
talker, cursor, direction, limit, is_sender, msg_count,
result.get("next_cursor"), result.get("has_more"),
)
return MessagesBySessionResponse(**result)
# ---------------------------------------------------------------------------
# 路由GET /api/messages/search
# ---------------------------------------------------------------------------
@router.get("/api/messages/search", response_model=MessageSearchResponse)
@with_db_retry
async def search_messages(
keyword: str,
talker: str = "",
start_time: int = 0,
end_time: int = 0,
limit: int = 50,
is_sender: Optional[bool] = Query(
None,
description="按发送方向过滤True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
),
) -> MessageSearchResponse:
"""按关键词搜索历史消息。
遍历所有 Msg_* 分片表或指定 talker 的单表 message_content
LIKE 模糊匹配合并后按 create_time 降序返回最新匹配在前
Args:
keyword: 搜索关键词非空
talker: 可选限定在指定会话内搜索
start_time: 可选起始时间戳0 表示不限制
end_time: 可选结束时间戳0 表示不限制
limit: 最多返回条数1~200默认 50
is_sender: 按发送方向过滤True=仅本人发送 / False=仅对方发送 /
None=不过滤默认SQL 层过滤无法定位本人时返回空
Returns:
MessageSearchResponse messages / total
Raises:
BridgeError(INVALID_PARAMS): keyword 为空或 limit 越界HTTP 400
BridgeError(DB_NOT_FOUND): 未找到微信消息 DBHTTP 500
BridgeError(DB_ENCRYPTED): DB 已加密HTTP 503
Notes:
- 搜索基于 SQL LIKE性能取决于 DB 大小与索引大库可能较慢
- 限制无法搜索 zstd 压缩消息WCDB_CT_message_content==4 时存储
压缩 BLOBLIKE 不匹配二进制压缩消息会被静默漏掉
- keyword 中的 % _ 已转义为字面量按子串匹配
- total 为合并后返回的条数受每表 LIMIT 截断可能小于实际命中数
- 群消息的 sender 前缀"wxid:\\n"会先被剥离再匹配
"""
if not keyword:
raise BridgeError(code="INVALID_PARAMS", message="keyword 不能为空")
if limit < 1 or limit > 200:
raise BridgeError(
code="INVALID_PARAMS",
message=f"limit 必须在 1~200 之间,收到 {limit}",
)
db_reader = _require_db_reader()
await _check_db_readable()
result = await asyncio.to_thread(
db_reader.search_messages,
keyword,
talker if talker else None,
start_time if start_time > 0 else None,
end_time if end_time > 0 else None,
limit,
is_sender,
)
msg_count = len(result.get("messages", []))
logger.info(
"messages/search: keyword=%r talker=%s is_sender=%s → 命中 %d 条(返回 %d 条)",
keyword, talker or "(全部)", is_sender, result.get("total", 0), msg_count,
)
return MessageSearchResponse(**result)