2026-07-08 23:25:58 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
2026-07-10 22:15:45 +08:00
|
|
|
|
from typing import Optional
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
2026-07-10 22:15:45 +08:00
|
|
|
|
from fastapi import APIRouter, Query, Request
|
2026-07-08 23:25:58 +08:00
|
|
|
|
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
|
2026-07-10 22:15:45 +08:00
|
|
|
|
async def get_messages_since(
|
|
|
|
|
|
cursor: int = 0,
|
|
|
|
|
|
limit: int = 50,
|
|
|
|
|
|
is_sender: Optional[bool] = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="按发送方向过滤:True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
|
|
|
|
|
|
),
|
|
|
|
|
|
) -> MessagesResponse:
|
2026-07-08 23:25:58 +08:00
|
|
|
|
"""增量消息拉取(按 create_time 游标)。
|
|
|
|
|
|
|
|
|
|
|
|
channels PullerAdapter 的核心接口:调用方维护本地 cursor(首次为 0),
|
|
|
|
|
|
每次拉取 create_time > cursor 的消息,把响应中 next_cursor 存下来作为
|
|
|
|
|
|
下次的 cursor。has_more=true 时应立即继续拉取,否则按 poll_interval_ms 轮询。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
cursor: 上次拉取的最大 create_time,首次传 0(拉全量)
|
|
|
|
|
|
limit: 最多返回条数,1~200,默认 50。建议用 /api/status 返回的
|
|
|
|
|
|
max_batch_size 作为上限参考
|
2026-07-10 22:15:45 +08:00
|
|
|
|
is_sender: 按发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
|
|
|
|
|
None=不过滤(默认)。SQL 层过滤,无法定位本人时返回空
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
MessagesResponse:含 messages 列表 / next_cursor / has_more
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
BridgeError(INVALID_PARAMS): cursor<0 或 limit 越界(HTTP 400)
|
|
|
|
|
|
BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 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 包装
|
2026-07-10 22:15:45 +08:00
|
|
|
|
result = await asyncio.to_thread(
|
|
|
|
|
|
db_reader.get_messages_since, cursor, limit, is_sender
|
|
|
|
|
|
)
|
2026-07-08 23:25:58 +08:00
|
|
|
|
msg_count = len(result.get("messages", []))
|
|
|
|
|
|
logger.info(
|
2026-07-10 22:15:45 +08:00
|
|
|
|
"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"),
|
2026-07-08 23:25:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
return MessagesResponse(**result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 路由:GET /api/messages/stream(SSE 实时消息推送)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/messages/stream")
|
|
|
|
|
|
async def stream_messages(request: Request) -> StreamingResponse:
|
|
|
|
|
|
"""SSE 实时消息推送长连接。
|
|
|
|
|
|
|
|
|
|
|
|
建立 text/event-stream 连接后,服务端主动推送新消息事件,替代客户端
|
|
|
|
|
|
轮询 /api/messages/since。内部由 MessageStreamer 周期性轮询 message DB
|
|
|
|
|
|
(有订阅者 1s、无订阅者 5s),DB mtime 变化时读增量消息并广播。
|
|
|
|
|
|
|
|
|
|
|
|
事件类型:
|
|
|
|
|
|
- sync:连接建立时立即发送,data.cursor 为当前全局游标。客户端据此
|
|
|
|
|
|
判断是否需要用 /api/messages/since 补全断线期间消息。
|
|
|
|
|
|
- messages:一批新消息,data 含 messages / next_cursor / has_more,
|
|
|
|
|
|
结构与 /api/messages/since 响应体一致。事件 id 为 next_cursor。
|
|
|
|
|
|
- status:DB 状态变化时发送,data 含 db_accessible / db_error_code。
|
|
|
|
|
|
DB 加密/退出/恢复可读时触发,客户端据此决定是否降级到轮询。
|
|
|
|
|
|
- heartbeat:每 30 秒心跳,保活 NAT/代理连接,data 为空对象。
|
2026-07-09 19:59:50 +08:00
|
|
|
|
- kicked:订阅者被服务端剔除(超过 MAX_SUBSCRIBERS 上限),data.reason
|
|
|
|
|
|
给出原因。客户端收到后应关闭连接并按需重连。
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
断线补偿:
|
|
|
|
|
|
SSE 不保证 100% 投递。客户端断线重连后应:
|
|
|
|
|
|
1. 连接本接口,拿 sync 事件中的 cursor
|
|
|
|
|
|
2. 用 max(本地cursor, sync.cursor) 调 /api/messages/since 补全
|
|
|
|
|
|
3. 补全后继续消费本接口事件流
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
StreamingResponse:media_type=text/event-stream,持续推送 SSE 帧
|
|
|
|
|
|
|
|
|
|
|
|
Notes:
|
|
|
|
|
|
- DB 加密时不关闭连接,改发 status 事件通知客户端;key 提取成功后
|
|
|
|
|
|
自动恢复推送
|
|
|
|
|
|
- 响应头设置 X-Accel-Buffering: no 关闭 nginx 缓冲,避免事件被攒批
|
|
|
|
|
|
- 客户端断开时自动取消订阅,释放队列
|
|
|
|
|
|
- 不抛业务错误:即使 DB 不可读也建立连接,通过 status 事件告知
|
2026-07-09 19:59:50 +08:00
|
|
|
|
- 订阅者上限由 MessageStreamer.MAX_SUBSCRIBERS 控制,超限时最早
|
|
|
|
|
|
订阅者会被剔除并收到 kicked 事件
|
2026-07-08 23:25:58 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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={})
|
2026-07-09 19:59:50 +08:00
|
|
|
|
# kicked 事件:被服务端剔除(订阅者超上限),发送后立即退出
|
|
|
|
|
|
if event.event == "kicked":
|
|
|
|
|
|
yield format_sse(event)
|
|
|
|
|
|
break
|
2026-07-08 23:25:58 +08:00
|
|
|
|
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",
|
2026-07-10 22:15:45 +08:00
|
|
|
|
is_sender: Optional[bool] = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="按发送方向过滤:True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
|
|
|
|
|
|
),
|
2026-07-08 23:25:58 +08:00
|
|
|
|
) -> MessagesBySessionResponse:
|
|
|
|
|
|
"""按会话拉取历史消息(单会话,O(1) 定位分片表)。
|
|
|
|
|
|
|
|
|
|
|
|
通过 talker 计算 Msg_<MD5(talker)> 表名直接查单表,避免遍历所有分片。
|
|
|
|
|
|
用于客户端聊天窗口的历史消息展示与上滑加载。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
talker: 会话对方 wxid(群消息为 chatroom id)
|
|
|
|
|
|
cursor: 游标,首次传 0;before 模式取该时间之前的旧消息,
|
|
|
|
|
|
after 模式取该时间之后的新消息
|
|
|
|
|
|
limit: 最多返回条数,1~200,默认 50
|
|
|
|
|
|
direction: before(默认,往前翻历史)/ after(往后拉新消息)
|
2026-07-10 22:15:45 +08:00
|
|
|
|
is_sender: 按发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
|
|
|
|
|
None=不过滤(默认)。SQL 层过滤,无法定位本人时返回空
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
MessagesBySessionResponse:含 messages / next_cursor / has_more / talker
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
BridgeError(INVALID_PARAMS): talker 为空、limit 越界、direction 非法(HTTP 400)
|
|
|
|
|
|
BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 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(
|
2026-07-10 22:15:45 +08:00
|
|
|
|
db_reader.get_messages_by_session, talker, cursor, limit, direction, is_sender
|
2026-07-08 23:25:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
msg_count = len(result.get("messages", []))
|
|
|
|
|
|
logger.info(
|
2026-07-10 22:15:45 +08:00
|
|
|
|
"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,
|
2026-07-08 23:25:58 +08:00
|
|
|
|
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,
|
2026-07-10 22:15:45 +08:00
|
|
|
|
is_sender: Optional[bool] = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
description="按发送方向过滤:True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
|
|
|
|
|
|
),
|
2026-07-08 23:25:58 +08:00
|
|
|
|
) -> MessageSearchResponse:
|
|
|
|
|
|
"""按关键词搜索历史消息。
|
|
|
|
|
|
|
|
|
|
|
|
遍历所有 Msg_* 分片表(或指定 talker 的单表),对 message_content
|
|
|
|
|
|
做 LIKE 模糊匹配,合并后按 create_time 降序返回(最新匹配在前)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
keyword: 搜索关键词(非空)
|
|
|
|
|
|
talker: 可选,限定在指定会话内搜索
|
|
|
|
|
|
start_time: 可选,起始时间戳(含),0 表示不限制
|
|
|
|
|
|
end_time: 可选,结束时间戳(含),0 表示不限制
|
|
|
|
|
|
limit: 最多返回条数,1~200,默认 50
|
2026-07-10 22:15:45 +08:00
|
|
|
|
is_sender: 按发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
|
|
|
|
|
None=不过滤(默认)。SQL 层过滤,无法定位本人时返回空
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
MessageSearchResponse:含 messages / total
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
BridgeError(INVALID_PARAMS): keyword 为空或 limit 越界(HTTP 400)
|
|
|
|
|
|
BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500)
|
|
|
|
|
|
BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503)
|
|
|
|
|
|
|
|
|
|
|
|
Notes:
|
|
|
|
|
|
- 搜索基于 SQL LIKE,性能取决于 DB 大小与索引;大库可能较慢
|
|
|
|
|
|
- 限制:无法搜索 zstd 压缩消息(WCDB_CT_message_content==4 时存储
|
|
|
|
|
|
压缩 BLOB,LIKE 不匹配二进制);压缩消息会被静默漏掉
|
|
|
|
|
|
- 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,
|
2026-07-10 22:15:45 +08:00
|
|
|
|
is_sender,
|
2026-07-08 23:25:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
msg_count = len(result.get("messages", []))
|
|
|
|
|
|
logger.info(
|
2026-07-10 22:15:45 +08:00
|
|
|
|
"messages/search: keyword=%r talker=%s is_sender=%s → 命中 %d 条(返回 %d 条)",
|
|
|
|
|
|
keyword, talker or "(全部)", is_sender, result.get("total", 0), msg_count,
|
2026-07-08 23:25:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
return MessageSearchResponse(**result)
|