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:
2026-09-02 14:33:41 +08:00
parent 07e6b789b2
commit 9d4718a412
18 changed files with 760 additions and 243 deletions

View File

@ -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() {

View 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}
/>
);
}

View File

@ -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>

View File

@ -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}