feat: SSE Last-Event-ID 补投 + 连接状态指示 + 限速器 DB 化
## SSE Last-Event-ID 补投 EventSource 断线重连时自带 Last-Event-ID 头,但服务端直接忽略了—— 所有断线期间的邮件通知都丢失。用户刷新页面也会错过已推的事件。 改为 per-user 事件环形缓冲区(500 条,~100KB/用户,20 在线 ≈ 2MB): 每次 Broadcast/SendToUser/SendToAgent 同时写入对应用户的缓冲区; AddClient 时取 Last-Event-ID 头,找到该 ID 的位置后从下一条回放。 找不到 ID 说明事件已被覆盖(缓冲区溢出),从头回放全部。 事件 ID 用全局递增序列号(非 UUID),EventSource 的 Last-Event-ID 就是靠这个 ID 记住断点的。 新增测试:缓冲区回放、溢出行为、并发安全(10 goroutine × 200 次 push)、 端到端重连验证(SendToUser → 带 Last-Event-ID 的 AddClient → 补投)。 ## 连接状态指示器 Sidebar 用户头像右下角的小圆点:绿=已连接,黄=连接中,橙=重连中,红=断开。 NarrowNav 底栏也有(移动端)。 SSE 模块新增 onSSEStatus/getSSEStatus 接口,onerror/onopen 驱动状态变化。 状态点用 absolute 定位在头像边缘,不遮挡文字。 ## 限速器 DB 化(解决多实例部署时的计数漂移) 原实现:LoginLimiter 与 sessionRateLimiter 都是进程内内存计数器。 多实例部署时各自独立计数,等效上限变成 N 倍。 改为 rate_limits 表(bucket + ts),两个限速器共享同一套基础设施: - LoginLimiter:bucket="login:<username>",COUNT(*) >= 5 → 锁定 5 分钟 - sessionRateLimiter:bucket="session:<agent_name>",COUNT(*) >= 20/h → 拒绝 判断与写入在同一个 BEGIN IMMEDIATE 事务里——SQLite 的 IMMEDIATE 在事务开始时获取 RESERVED 锁,防并发写事务同时进入 COMMIT 阶段。 实测 80 并发下恰好放行 20 次(旧内存版同样通过,但 DB 版才能多实例共享)。 DB 不可用时放行(宁可放开限速也不能让用户完全无法使用)。 新建 rate_limits 表迁移(SQLite + PG 两版)。
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
import { API_BASE, withToken } from './config';
|
||||
|
||||
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => void;
|
||||
export type SSEStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
||||
|
||||
const EVENTS = [
|
||||
'new_mail',
|
||||
@ -14,6 +15,27 @@ let es: EventSource | null = null;
|
||||
let handlers: SSEEventHandler[] = [];
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let backoff = 1000;
|
||||
let _status: SSEStatus = 'disconnected';
|
||||
let statusHandlers: Array<(s: SSEStatus) => void> = [];
|
||||
|
||||
/** 监听 SSE 连接状态变化 */
|
||||
export function onSSEStatus(handler: (s: SSEStatus) => void): () => void {
|
||||
statusHandlers.push(handler);
|
||||
return () => {
|
||||
statusHandlers = statusHandlers.filter(h => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前 SSE 连接状态 */
|
||||
export function getSSEStatus(): SSEStatus {
|
||||
return _status;
|
||||
}
|
||||
|
||||
function setStatus(s: SSEStatus) {
|
||||
if (_status === s) return;
|
||||
_status = s;
|
||||
statusHandlers.forEach(h => h(s));
|
||||
}
|
||||
|
||||
export function connectSSE(onEvent: SSEEventHandler): () => void {
|
||||
handlers.push(onEvent);
|
||||
@ -27,12 +49,21 @@ export function connectSSE(onEvent: SSEEventHandler): () => void {
|
||||
|
||||
function open() {
|
||||
close(false);
|
||||
setStatus('connecting');
|
||||
// EventSource 无法设置请求头:Cookie 模式靠同源 Cookie,
|
||||
// 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。
|
||||
es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true });
|
||||
|
||||
// EventSource 会自动重连,但它的 readyState 在网络断开时
|
||||
// 不一定及时反映状态。用 onopen 判断实际连上了。
|
||||
es.onopen = () => {
|
||||
backoff = 1000;
|
||||
setStatus('connected');
|
||||
};
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
backoff = 1000;
|
||||
setStatus('connected');
|
||||
});
|
||||
|
||||
for (const name of EVENTS) {
|
||||
@ -51,6 +82,7 @@ function open() {
|
||||
close(false);
|
||||
if (handlers.length === 0) return;
|
||||
if (retryTimer) return;
|
||||
setStatus('reconnecting');
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
backoff = Math.min(backoff * 2, 15000);
|
||||
@ -69,6 +101,7 @@ function close(clearHandlers = true) {
|
||||
es = null;
|
||||
}
|
||||
if (clearHandlers) handlers = [];
|
||||
setStatus('disconnected');
|
||||
}
|
||||
|
||||
export function disconnectSSE() {
|
||||
|
||||
37
web/src/components/ConnectionIndicator.tsx
Normal file
37
web/src/components/ConnectionIndicator.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { onSSEStatus, type SSEStatus } from '../api/sse';
|
||||
|
||||
/**
|
||||
* SSE 连接状态指示器。
|
||||
*
|
||||
* 实时性是 Agent 协作的核心体验:断线后用户以为系统正常,实际上通知已经停了。
|
||||
* 一个小小的绿/黄/红点就能避免「Agent 没在动」的误判。
|
||||
*
|
||||
* 不做成弹窗或横幅 —— 那会打断正在进行的对话。一个点足够了:
|
||||
* 会看它的人自然会看,不会看的人不需要被打扰。
|
||||
*/
|
||||
export function ConnectionIndicator() {
|
||||
const [status, setStatus] = useState<SSEStatus>('connecting');
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onSSEStatus(setStatus);
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const map: Record<SSEStatus, { color: string; title: string }> = {
|
||||
connecting: { color: 'bg-yellow-400', title: '正在连接…' },
|
||||
connected: { color: 'bg-green-500', title: '已连接' },
|
||||
reconnecting: { color: 'bg-orange-400', title: '重连中…' },
|
||||
disconnected: { color: 'bg-red-400', title: '已断开' },
|
||||
};
|
||||
|
||||
const { color, title } = map[status] || map.disconnected;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${color} shrink-0 transition-colors duration-300`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { useMailStore } from '../stores/mailStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { InboxIcon, SentIcon, ContactsIcon, ComposeIcon, UsersIcon, PersonIcon } from './icons';
|
||||
import { ConnectionIndicator } from './ConnectionIndicator';
|
||||
|
||||
/**
|
||||
* 窄屏底部导航。
|
||||
@ -95,7 +96,12 @@ export default function NarrowNav() {
|
||||
: 'text-slate-400 active:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<PersonIcon />
|
||||
<div className="relative">
|
||||
<PersonIcon />
|
||||
<span className="absolute -top-0.5 -right-1.5">
|
||||
<ConnectionIndicator />
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] leading-none">我的</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
UsersIcon,
|
||||
LogoutIcon
|
||||
} from './icons';
|
||||
import { ConnectionIndicator } from './ConnectionIndicator';
|
||||
|
||||
const navItems: {
|
||||
short: string;
|
||||
@ -90,13 +91,17 @@ export default function Sidebar() {
|
||||
<button
|
||||
onClick={() => setViewMode('account')}
|
||||
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
|
||||
className={`relative w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{(user?.display_name || user?.username || '?').slice(0, 2)}
|
||||
{/* 连接状态点:不遮挡文字,贴在右下角 */}
|
||||
<span className="absolute -bottom-0.5 -right-0.5">
|
||||
<ConnectionIndicator />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
Reference in New Issue
Block a user