## 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 两版)。
117 lines
4.4 KiB
TypeScript
117 lines
4.4 KiB
TypeScript
import { useUIStore, type ViewMode } from '../stores/uiStore';
|
|
import { useMailStore } from '../stores/mailStore';
|
|
import { useContactStore } from '../stores/contactStore';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
import {
|
|
InboxIcon,
|
|
SentIcon,
|
|
ContactsIcon,
|
|
ComposeIcon,
|
|
UsersIcon,
|
|
LogoutIcon
|
|
} from './icons';
|
|
import { ConnectionIndicator } from './ConnectionIndicator';
|
|
|
|
const navItems: {
|
|
short: string;
|
|
title: string;
|
|
mode: ViewMode;
|
|
Icon: (p: { className?: string }) => JSX.Element;
|
|
adminOnly?: boolean;
|
|
}[] = [
|
|
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon },
|
|
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon },
|
|
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon },
|
|
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
|
|
];
|
|
|
|
export default function Sidebar() {
|
|
const viewMode = useUIStore(s => s.viewMode);
|
|
const setViewMode = useUIStore(s => s.setViewMode);
|
|
const composing = useUIStore(s => s.composing);
|
|
const startCompose = useUIStore(s => s.startCompose);
|
|
|
|
const inbox = useMailStore(s => s.inbox);
|
|
const unread = inbox.filter(m => m.status === 'unread').length;
|
|
const contacts = useContactStore(s => s.contacts);
|
|
|
|
const user = useAuthStore(s => s.user);
|
|
const logout = useAuthStore(s => s.logout);
|
|
const isAdmin = user?.role === 'admin';
|
|
|
|
return (
|
|
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900"
|
|
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))' }}
|
|
>
|
|
{navItems
|
|
.filter(n => !n.adminOnly || isAdmin)
|
|
.map(({ short, title, mode, Icon }) => {
|
|
const active = viewMode === mode && !composing;
|
|
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
|
|
return (
|
|
<button
|
|
key={mode}
|
|
onClick={() => setViewMode(mode)}
|
|
title={title}
|
|
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
|
active
|
|
? 'bg-slate-700 text-white'
|
|
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
|
}`}
|
|
>
|
|
<Icon />
|
|
<span className="text-[9px] leading-none">{short}</span>
|
|
{badge > 0 && (
|
|
<span
|
|
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
|
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
|
|
}`}
|
|
>
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
<div className="flex-1" />
|
|
|
|
<button
|
|
onClick={() => startCompose()}
|
|
title="新建邮件"
|
|
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
|
|
composing ? 'bg-blue-500 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-500'
|
|
}`}
|
|
>
|
|
<ComposeIcon />
|
|
<span className="text-[9px] leading-none">新建</span>
|
|
</button>
|
|
|
|
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-slate-700">
|
|
<button
|
|
onClick={() => setViewMode('account')}
|
|
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
|
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}
|
|
title="退出登录"
|
|
className="w-9 h-7 rounded flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-800"
|
|
>
|
|
<LogoutIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|