feat(server,db_reader): add SSE real-time message push feature

This commit implements the full SSE real-time message push capability:
1. Add new `/api/messages/stream` SSE endpoint for real-time message delivery
2. Add MessageStreamer backend that polls DB with mtime awareness and broadcasts events
3. Add helper methods in DbReader: get_db_mtime and get_max_create_time
4. Optimize existing DB connections with `isolation_level=None`
5. Update bridge version to 1.5.0 and add `sse_push` to capability list
6. Add proper error handling and fallback logic for DB unavailability
This commit is contained in:
Kris 2026-07-07 20:17:06 +08:00
parent a2201d8be3
commit 5548f8d89f
3 changed files with 492 additions and 13 deletions

View File

@ -444,6 +444,30 @@ class DbReader:
"""DB 是否可读。""" """DB 是否可读。"""
return self.check_db_status() == "ok" return self.check_db_status() == "ok"
def get_db_mtime(self, rel_path: str) -> Optional[tuple[float, float]]:
"""返回指定 DB 及其 WAL 文件的 mtime用于增量轮询感知变化。
Args:
rel_path: 相对路径 message/message_0.db
Returns:
(db_mtime, wal_mtime)DB 不存在时返回 None
WAL 不存在时 wal_mtime 0.0mtime 读取失败记为 0.0
"""
db_path = self._get_db_abs_path(rel_path)
if db_path is None:
return None
try:
db_mtime = os.path.getmtime(db_path)
except OSError:
db_mtime = 0.0
wal_path = db_path + "-wal"
try:
wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0
except OSError:
wal_mtime = 0.0
return db_mtime, wal_mtime
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 辅助:表/列探测 # 辅助:表/列探测
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -511,7 +535,7 @@ class DbReader:
result["wxid"] = wxid or "" result["wxid"] = wxid or ""
try: try:
db_path = self._ensure_decrypted("contact/contact.db") db_path = self._ensure_decrypted("contact/contact.db")
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, isolation_level=None)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
table = self._find_contact_table(conn) table = self._find_contact_table(conn)
@ -573,7 +597,7 @@ class DbReader:
def get_messages_since(self, cursor: int, limit: int = 50) -> dict: def get_messages_since(self, cursor: int, limit: int = 50) -> dict:
"""读取 create_time > cursor 的增量消息。""" """读取 create_time > cursor 的增量消息。"""
db_path = self._ensure_decrypted("message/message_0.db") db_path = self._ensure_decrypted("message/message_0.db")
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, isolation_level=None)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
cur = conn.execute( cur = conn.execute(
@ -602,6 +626,31 @@ class DbReader:
"has_more": has_more, "has_more": has_more,
} }
def get_max_create_time(self) -> Optional[int]:
"""返回 message 表中最大的 create_time用于初始化推送游标。
DB 不可读或 message 表不存在时返回 None MessageStreamer 在启动时
把全局游标对齐到当前最新消息避免向新连接推送全量历史
"""
try:
db_path = self._ensure_decrypted("message/message_0.db")
except BridgeError:
return None
conn = sqlite3.connect(db_path, isolation_level=None)
try:
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='message'"
)
if cur.fetchone() is None:
return None
cur = conn.execute("SELECT MAX(create_time) FROM message")
row = cur.fetchone()
return int(row[0]) if row and row[0] is not None else None
except Exception:
return None
finally:
conn.close()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 联系人字段映射(公共) # 联系人字段映射(公共)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -692,7 +741,7 @@ class DbReader:
except BridgeError: except BridgeError:
return empty return empty
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, isolation_level=None)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
table = self._find_contact_table(conn) table = self._find_contact_table(conn)
@ -749,7 +798,7 @@ class DbReader:
except BridgeError: except BridgeError:
return None return None
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, isolation_level=None)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
table = self._find_contact_table(conn) table = self._find_contact_table(conn)
@ -783,7 +832,7 @@ class DbReader:
except BridgeError: except BridgeError:
return empty return empty
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, isolation_level=None)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
table = self._find_contact_table(conn) table = self._find_contact_table(conn)
@ -815,7 +864,7 @@ class DbReader:
except BridgeError: except BridgeError:
return empty return empty
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, isolation_level=None)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
table = self._find_table_by_name( table = self._find_table_by_name(
@ -923,7 +972,7 @@ class DbReader:
except BridgeError: except BridgeError:
return None return None
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, isolation_level=None)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
cols = self._table_columns(conn, "message") cols = self._table_columns(conn, "message")

322
bridge/message_streamer.py Normal file
View File

@ -0,0 +1,322 @@
"""消息流推送器:内部轮询 message DB 并通过 SSE 广播给订阅者。
微信本地 DB SQLite没有 pub/sub 机制bridge 只能轮询本模块把轮询
从客户端移到服务端后台 watcher 周期性检查 message_0.db mtime变化时
读取增量消息并广播给所有 SSE 订阅者
优化点
- 无订阅者时降频到 5s仍维持全局游标避免新连接被历史消息淹没
- mtime 感知DB/WAL mtime 未变化时跳过 SQL 查询
- 批量读取默认 200 消息洪峰时减少广播次数
- 全局游标对齐当前最新 create_time新连接只收"从现在起"的新消息
- 背压订阅队列满时丢弃旧事件并记日志避免单客户端拖垮全局
事件类型SSE
- sync连接建立时发送data.cursor 为当前全局游标
- messages一批新消息data messages/next_cursor/has_more
- statusDB 状态变化data db_accessible/db_error_code
- heartbeat30 秒心跳保活 NAT/代理连接
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass
from typing import Awaitable, Callable, Optional
logger = logging.getLogger("woc-bridge")
# 有订阅者时的轮询间隔(秒)
POLL_INTERVAL_ACTIVE = 1.0
# 无订阅者时的轮询间隔(秒,仅维持全局游标)
POLL_INTERVAL_IDLE = 5.0
# 单次读取上限
BATCH_LIMIT = 200
# 订阅队列容量(背压保护)
QUEUE_MAXSIZE = 100
# 心跳间隔(秒)
HEARTBEAT_INTERVAL = 30.0
# DB 状态解析器返回类型:(db_accessible, db_error_code)
DbStateResolver = Callable[[], Awaitable[tuple[bool, Optional[str]]]]
@dataclass
class StreamEvent:
"""SSE 事件。
Attributes:
event: 事件类型sync / messages / status / heartbeat
data: 事件数据序列化为 JSON
id: 可选事件 ID客户端断线重连时可用 Last-Event-ID 补全
"""
event: str
data: dict
id: Optional[str] = None
def format_sse(event: StreamEvent) -> str:
"""把 StreamEvent 格式化为 SSE 文本帧。
格式遵循 SSE 规范每行 `field: value`事件间用空行分隔
"""
lines: list[str] = []
if event.id:
lines.append(f"id: {event.id}")
if event.event:
lines.append(f"event: {event.event}")
lines.append(f"data: {json.dumps(event.data, ensure_ascii=False)}")
return "\n".join(lines) + "\n\n"
class MessageStreamer:
"""消息流推送器。
维护一个全局游标 global_cursor后台 watcher 轮询 DB把新消息以
StreamEvent 广播给所有订阅者每个订阅者对应一个 asyncio.Queue
SSE 路由从队列取事件推给客户端
使用
streamer = MessageStreamer(db_reader, lambda: _resolve_db_state_tuple())
await streamer.start() # lifespan 启动
q = streamer.subscribe() # SSE 路由订阅
streamer.unsubscribe(q) # SSE 路由断开
await streamer.stop() # lifespan 停止
"""
def __init__(
self,
db_reader: "DbReader",
resolve_db_state: DbStateResolver,
) -> None:
"""初始化推送器。
Args:
db_reader: DbReader 实例用于读消息与查 mtime
resolve_db_state: 异步回调返回 (db_accessible, db_error_code)
watcher 判断 DB 是否可读并广播状态变化
"""
self._db_reader = db_reader
self._resolve_db_state = resolve_db_state
self._subscribers: set[asyncio.Queue[StreamEvent]] = set()
self._global_cursor: int = 0
self._watcher: asyncio.Task | None = None
# DB mtime 缓存:变化时才查 SQL
self._last_db_mtime: float = 0.0
self._last_wal_mtime: float = 0.0
# 上次广播的 DB 状态码,变化时发 status 事件
self._last_db_error_code: Optional[str] = "__init__"
self._cursor_inited: bool = False
@property
def global_cursor(self) -> int:
"""当前全局游标(最近一次推送的 create_time"""
return self._global_cursor
def subscriber_count(self) -> int:
"""当前订阅者数量(供 /api/status 暴露给客户端)。"""
return len(self._subscribers)
def subscribe(self) -> asyncio.Queue[StreamEvent]:
"""订阅事件流。
订阅后立即向队列放入一个 sync 事件携带当前全局游标客户端据此
判断是否需要用 /api/messages/since 补全断线期间消息
Returns:
asyncio.Queue事件队列容量 QUEUE_MAXSIZE满时丢弃旧事件
"""
q: asyncio.Queue[StreamEvent] = asyncio.Queue(maxsize=QUEUE_MAXSIZE)
self._subscribers.add(q)
# 立即发送 sync 事件(只给这个订阅者)
try:
q.put_nowait(StreamEvent(
event="sync",
data={"cursor": self._global_cursor},
))
except asyncio.QueueFull:
pass
logger.info("SSE 订阅,当前订阅者 %dcursor=%d", self.subscriber_count(), self._global_cursor)
return q
def unsubscribe(self, q: asyncio.Queue[StreamEvent]) -> None:
"""取消订阅。"""
self._subscribers.discard(q)
logger.info("SSE 取消订阅,当前订阅者 %d", self.subscriber_count())
async def start(self) -> None:
"""启动 watcher 后台任务。"""
if self._watcher is None or self._watcher.done():
self._watcher = asyncio.create_task(self._watch_loop())
logger.info("MessageStreamer watcher 已启动")
async def stop(self) -> None:
"""停止 watcher 后台任务。"""
if self._watcher is not None and not self._watcher.done():
self._watcher.cancel()
try:
await self._watcher
except asyncio.CancelledError:
pass
self._watcher = None
logger.info("MessageStreamer watcher 已停止")
# ------------------------------------------------------------------
# 内部:广播与轮询
# ------------------------------------------------------------------
def _broadcast(self, event: StreamEvent) -> None:
"""广播事件给所有订阅者。
队列满时丢弃最旧事件再放入避免单客户端消费慢拖垮全局
"""
for q in list(self._subscribers):
try:
q.put_nowait(event)
except asyncio.QueueFull:
# 背压:丢掉最旧事件,腾出位置
try:
q.get_nowait()
except asyncio.QueueEmpty:
pass
try:
q.put_nowait(event)
except asyncio.QueueFull:
logger.warning("SSE 订阅队列持续满,丢弃事件 event=%s", event.event)
async def _init_cursor(self) -> bool:
"""把全局游标对齐到当前 DB 最大 create_time。
避免新连接收到全量历史消息DB 不可读时保持 0等可读后再对齐
Returns:
True 表示成功对齐游标或游标已对齐False 表示 DB 不可读
需要在后续轮询中重试
"""
try:
max_ct = await asyncio.to_thread(self._db_reader.get_max_create_time)
if max_ct and max_ct > 0:
self._global_cursor = max_ct
self._cursor_inited = True
logger.info("MessageStreamer 初始游标对齐到 %d", self._global_cursor)
return True
# DB 可读但 message 表为空或返回 None视为已对齐cursor=0 合理)
self._cursor_inited = True
return True
except Exception as e:
logger.warning("MessageStreamer 初始化游标失败: %s", e)
return False
async def _watch_loop(self) -> None:
"""watcher 主循环:轮询 DB 并广播新消息。
- 首次运行时对齐全局游标
- POLL_INTERVAL_ACTIVE/IDLE 间隔轮询
- DB 状态变化时发 status 事件
- DB mtime 变化时读增量消息并广播
- HEARTBEAT_INTERVAL 秒发一次心跳
"""
# 初始化游标DB 可能尚不可读,失败则后续轮询中重试)
await self._init_cursor()
last_heartbeat = time.monotonic()
while True:
try:
interval = (
POLL_INTERVAL_ACTIVE if self._subscribers else POLL_INTERVAL_IDLE
)
await asyncio.sleep(interval)
# 心跳(仅有订阅者时发,无订阅者发心跳无意义)
now = time.monotonic()
if self._subscribers and now - last_heartbeat >= HEARTBEAT_INTERVAL:
self._broadcast(StreamEvent(event="heartbeat", data={}))
last_heartbeat = now
await self._poll_once()
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("MessageStreamer watch loop 异常: %s", e)
await asyncio.sleep(2.0)
async def _poll_once(self) -> None:
"""单次轮询:检查 DB 状态 + mtime + 读增量消息。"""
# 1. 解析 DB 状态
try:
db_accessible, db_error_code = await self._resolve_db_state()
except Exception as e:
logger.warning("MessageStreamer: resolve_db_state 失败: %s", e)
return
# 2. 状态变化通知
if db_error_code != self._last_db_error_code:
self._last_db_error_code = db_error_code
self._broadcast(StreamEvent(
event="status",
data={
"db_accessible": db_accessible,
"db_error_code": db_error_code,
},
))
if not db_accessible:
return
# 3. 游标未初始化DB 刚恢复可读)时补对齐
if not self._cursor_inited:
await self._init_cursor()
return
# 4. mtime 感知DB/WAL 未变化则跳过 SQL
mtimes = await asyncio.to_thread(
self._db_reader.get_db_mtime, "message/message_0.db"
)
if mtimes is None:
return
db_mtime, wal_mtime = mtimes
if db_mtime == self._last_db_mtime and wal_mtime == self._last_wal_mtime:
return
self._last_db_mtime = db_mtime
self._last_wal_mtime = wal_mtime
# 5. 读取增量消息has_more=True 时循环读完剩余批次,不受 mtime 缓存影响)
while True:
try:
result = await asyncio.to_thread(
self._db_reader.get_messages_since, self._global_cursor, BATCH_LIMIT
)
except Exception as e:
logger.warning("MessageStreamer: get_messages_since 失败: %s", e)
return
messages = result.get("messages", [])
if not messages:
return
next_cursor = result.get("next_cursor", self._global_cursor)
has_more = result.get("has_more", False)
self._global_cursor = next_cursor
# 6. 广播
self._broadcast(StreamEvent(
event="messages",
id=str(next_cursor),
data={
"messages": messages,
"next_cursor": next_cursor,
"has_more": has_more,
},
))
logger.info(
"MessageStreamer 推送 %d 条消息, cursor=%d, 订阅者=%d",
len(messages), next_cursor, self.subscriber_count(),
)
if not has_more:
return

View File

@ -28,10 +28,16 @@
- 所有 assert 替换为显式 BridgeError兼容 python -O - 所有 assert 替换为显式 BridgeError兼容 python -O
- _send_file 文件校验改用 isfile+access - _send_file 文件校验改用 isfile+access
- 移除 _read_current_wxid 死代码 - 移除 _read_current_wxid 死代码
- 1.5.0
- 新增 GET /api/messages/stream SSE 实时消息推送
- MessageStreamer 内部轮询 DBmtime 感知+ 广播给 SSE 订阅者
- 替代客户端轮询 /api/messages/since后者保留作断线补全兜底
- BRIDGE_CAPABILITIES 追加 sse_push
路由总览20 路由总览21
- GET /api/status 状态聚合进程/窗口/登录态/DB/版本/能力 - GET /api/status 状态聚合进程/窗口/登录态/DB/版本/能力
- GET /api/messages/since 增量消息拉取 create_time 游标 - GET /api/messages/since 增量消息拉取 create_time 游标
- GET /api/messages/stream SSE 实时消息推送长连接主动推新消息
- POST /api/send/text 发送文本消息placeholder=false - POST /api/send/text 发送文本消息placeholder=false
- POST /api/send/image 发送图片消息占位实现placeholder=true - POST /api/send/image 发送图片消息占位实现placeholder=true
- POST /api/send/file 发送文件消息占位实现placeholder=true - POST /api/send/file 发送文件消息占位实现placeholder=true
@ -78,9 +84,10 @@ from typing import AsyncIterator, Callable, Optional
import uvicorn import uvicorn
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response, StreamingResponse
from db_reader import DbReader from db_reader import DbReader
from message_streamer import MessageStreamer, StreamEvent, format_sse
from models import ( from models import (
BridgeError, BridgeError,
ConnectivityResponse, ConnectivityResponse,
@ -138,17 +145,21 @@ logger.propagate = False
# 1.3.0 → 1.4.0_check_db_readable 非阻塞化_auto_extract_with_lock 公共加锁提取; # 1.3.0 → 1.4.0_check_db_readable 非阻塞化_auto_extract_with_lock 公共加锁提取;
# login_qr_start 非阻塞激活DB 状态 1s 缓存display_name LRU 缓存; # login_qr_start 非阻塞激活DB 状态 1s 缓存display_name LRU 缓存;
# assert → 显式 BridgeError_send_file isfile+access移除死代码 # assert → 显式 BridgeError_send_file isfile+access移除死代码
BRIDGE_VERSION = "1.4.0" # 1.4.0 → 1.5.0:新增 SSE 实时消息推送GET /api/messages/stream
# MessageStreamer 内部轮询 DB + 广播,替代客户端轮询 /api/messages/since
# 保留 /api/messages/since 作为断线补全兜底
BRIDGE_VERSION = "1.5.0"
# bridge 能力清单(供客户端通过 /api/status 协商)。 # bridge 能力清单(供客户端通过 /api/status 协商)。
# 当前已落地能力text_send文本发送真实可用、db_decryptSQLCipher DB 解密)。 # 当前已落地能力text_send文本发送真实可用、db_decryptSQLCipher DB 解密)、
# sse_pushSSE 实时消息推送)。
# 待落地能力(占位声明,未真正实现): # 待落地能力(占位声明,未真正实现):
# - image_sendW-02 完成后追加 # - image_sendW-02 完成后追加
# - file_sendW-02 完成后追加 # - file_sendW-02 完成后追加
# - long_pollW-03 完成后追加 # - long_pollW-03 完成后追加(已被 sse_push 替代,保留兼容声明)
# - avatarW-04 完成后追加 # - avatarW-04 完成后追加
# - group_adminW-06 完成后追加 # - group_adminW-06 完成后追加
BRIDGE_CAPABILITIES = ["text_send", "db_decrypt"] BRIDGE_CAPABILITIES = ["text_send", "db_decrypt", "sse_push"]
# 媒体发送是否已实现真实传输W-02 完成后改 True # 媒体发送是否已实现真实传输W-02 完成后改 True
# 客户端据此决定是否调 /api/send/image 与 /api/send/file。 # 客户端据此决定是否调 /api/send/image 与 /api/send/file。
@ -299,6 +310,7 @@ class AppState:
self.db_reader: DbReader | None = None self.db_reader: DbReader | None = None
self.send_queue: SendQueue | None = None self.send_queue: SendQueue | None = None
self.qr_capture: QrCapture | None = None self.qr_capture: QrCapture | None = None
self.message_streamer: MessageStreamer | None = None
self.start_time: float = 0.0 self.start_time: float = 0.0
# DB 解密密钥缓存env / api / auto_extract / file 注入) # DB 解密密钥缓存env / api / auto_extract / file 注入)
self.key_cache = KeyCache() self.key_cache = KeyCache()
@ -361,6 +373,26 @@ def _require_qr_capture() -> QrCapture:
return _state.qr_capture return _state.qr_capture
def _require_message_streamer() -> MessageStreamer:
"""返回消息流推送器,未初始化时抛 BridgeError。"""
if _state.message_streamer is None:
raise BridgeError(
code="BRIDGE_INTERNAL_ERROR",
message="message_streamer 未初始化",
)
return _state.message_streamer
async def _resolve_db_state_tuple() -> tuple[bool, Optional[str]]:
"""供 MessageStreamer 使用的 DB 状态解析适配器。
_resolve_db_state() DbState 降维为 (db_accessible, db_error_code)
MessageStreamer 解耦 server.py 内部数据结构
"""
state = await _resolve_db_state()
return (state.db_accessible, state.db_error_code)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# lifespan # lifespan
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -380,9 +412,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
_state.start_time = time.monotonic() _state.start_time = time.monotonic()
if _state.send_queue is not None: if _state.send_queue is not None:
await _state.send_queue.start() await _state.send_queue.start()
if _state.message_streamer is not None:
await _state.message_streamer.start()
try: try:
yield yield
finally: finally:
if _state.message_streamer is not None:
await _state.message_streamer.stop()
if _state.send_queue is not None: if _state.send_queue is not None:
await _state.send_queue.stop() await _state.send_queue.stop()
@ -1272,6 +1308,72 @@ async def get_messages_since(cursor: int = 0, limit: int = 50) -> MessagesRespon
return MessagesResponse(**result) return MessagesResponse(**result)
# ---------------------------------------------------------------------------
# 路由GET /api/messages/streamSSE 实时消息推送)
# ---------------------------------------------------------------------------
@app.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 为空对象
断线补偿
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 事件告知
"""
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={})
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,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 路由POST /api/send/text # 路由POST /api/send/text
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -2373,6 +2475,12 @@ def _init_state(cfg: BridgeConfig) -> None:
max_calls_per_sec=cfg.max_calls_per_sec, max_calls_per_sec=cfg.max_calls_per_sec,
) )
# 消息流推送器:内部轮询 DB + SSE 广播lifespan 中 start/stop
_state.message_streamer = MessageStreamer(
db_reader=_state.db_reader,
resolve_db_state=_resolve_db_state_tuple,
)
# 读取 WOC_DB_KEY 环境变量,注入 DbReader # 读取 WOC_DB_KEY 环境变量,注入 DbReader
# 用 set_default_key 而非 set_key保留 woc-keys.json 已持久化的多 salt 映射, # 用 set_default_key 而非 set_key保留 woc-keys.json 已持久化的多 salt 映射,
# env key 仅作为默认兜底,首次查询时按 salt 验证后入库 # env key 仅作为默认兜底,首次查询时按 salt 验证后入库