From 9adadaa57d0ad2b3b859ed29d45012fa0d48728d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 11:13:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=AE=9E=E7=8E=B0=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E4=B8=8B=E7=BA=BF=E9=80=9A=E7=9F=A5=E4=B8=8E=E9=A1=B6?= =?UTF-8?q?=E5=8F=B7=E4=BA=92=E8=B8=A2=E7=9A=84=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E6=84=9F=E7=9F=A5=EF=BC=88Ctrl=20205=20evicted=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按docs/强制下线通知与顶号互踢-后端接口文档.md §2实现。该文档§2.4声称 "SDK侧已内置:收到后关闭autoreconnect并回调onKick(reason, ctrl)"——读 实际安装的tinode-sdk(0.25.3)真实源码确认这个说法不成立:SDK对 code:205/text:"evicted"只有topic级的内部处理(topic._resetSub()),账 号级(topic为空)完全没有特殊逻辑,也没有onKick这个回调。 改为用SDK真实存在、此前项目里完全没用过的onCtrlMessage钩子(每条 {ctrl}包都会触发,含账号级的)在connection.ts里自行判定 "code===205 && text==='evicted' && !topic"。检测到后立即:清空 sessionStorage的token、清空myUserId、调用resetTinode()——其内部的 disconnect()会把SDK自己的#boffClosed标记置真,这是唯一能真正阻止 backoff计时器安排下一次重连的方式(读真实bundle的Connection#init_ws 确认,不存在另外一个"关闭autoreconnect"的方法)。只有面向用户的弹窗 和跳转延后到用户点确定,逻辑上是"会话立刻终止,通知是补充说明",不 是"通知完才终止"。 新增KickedNoticeRoot(挂载在App.tsx顶层,和ConfirmDialogRoot/ GlobalToastRoot同一层级,保证任意页面被踢都能弹出),文案原样照抄文 档§2.4的表格(reason→标题/正文),单一"确定"按钮(不是取消/确定二 选一——已经被踢了,没有"取消"这个选项,给出会误导)。确认后用 window.location.href硬跳转/login,和client.ts的forceReauth()同一个 理由:保证内存里的每一处状态(TanStack Query缓存等)都从头重置,不用 指望每个组件各自正确响应"你已经被登出了"。 topic非空的205(群内移出等topic级事件)不受影响,走原有的SDK内部 处理,不弹这个全局提示。 Co-Authored-By: Claude Sonnet 5 --- web/src/App.tsx | 2 ++ web/src/app/KickedNoticeRoot.tsx | 42 +++++++++++++++++++++++++++++ web/src/lib/tinode/connection.ts | 43 +++++++++++++++++++++++++++++- web/src/lib/tinode/errors.ts | 33 +++++++++++++++++++++++ web/src/lib/tinode/hooks.ts | 17 +++++++++++- web/src/lib/tinode/tinode-sdk.d.ts | 21 +++++++++++++++ 6 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 web/src/app/KickedNoticeRoot.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index dc4f2aba..bd7c99e1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom' import { AppShell } from '@/app/AppShell'; import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot'; import { GlobalToastRoot } from '@/app/GlobalToastRoot'; +import { KickedNoticeRoot } from '@/app/KickedNoticeRoot'; import { RequireAuth } from '@/app/RequireAuth'; import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage'; import { LoginPage } from '@/features/auth/LoginPage'; @@ -104,6 +105,7 @@ export default function App() { tenant" confirmation), not just the logged-in app tree. */} + ); } diff --git a/web/src/app/KickedNoticeRoot.tsx b/web/src/app/KickedNoticeRoot.tsx new file mode 100644 index 00000000..efffee3c --- /dev/null +++ b/web/src/app/KickedNoticeRoot.tsx @@ -0,0 +1,42 @@ +import { clearKickedInfo } from '@/lib/tinode/connection'; +import { kickedNoticeText } from '@/lib/tinode/errors'; +import { useKickedInfo } from '@/lib/tinode/hooks'; + +/** + * Mounted once at the app root (App.tsx), same placement as ConfirmDialogRoot/GlobalToastRoot -- + * an eviction can land on any authenticated page, not just ones that happen to render their own + * connection-state UI. By the time this renders, connection.ts's onCtrlMessage handler has already + * torn down the session (token cleared, Tinode singleton disconnected+reset) -- this component's + * only job is showing the user WHY before sending them to /login, per 强制下线通知与顶号互踢-后端 + * 接口文档.md §2.4's "弹出中文提示后跳转登录页". + * + * A single acknowledgment button, not ConfirmDialogRoot's 取消/确定 pair -- there is no "cancel" for + * an eviction that already happened, offering one would be misleading. Hard `window.location.href` + * navigation (not an in-SPA `navigate()`), matching client.ts's forceReauth() for the identical + * reason: every piece of in-memory state (TanStack Query cache, any component's local state) should + * reset from scratch, not rely on each one separately noticing "you're logged out now". + */ +export function KickedNoticeRoot() { + const kicked = useKickedInfo(); + if (!kicked) return null; + const { title, message } = kickedNoticeText(kicked.reason); + + const acknowledge = () => { + clearKickedInfo(); + window.location.href = '/login'; + }; + + return ( +
+
+ {title} +

{message}

+
+ +
+
+
+ ); +} diff --git a/web/src/lib/tinode/connection.ts b/web/src/lib/tinode/connection.ts index 095e038b..04dbb057 100644 --- a/web/src/lib/tinode/connection.ts +++ b/web/src/lib/tinode/connection.ts @@ -7,7 +7,7 @@ // silently stomp on each other. This module claims all the SDK's callback slots exactly once and // fans them out to any number of subscribers via its own Set, which is what hooks.ts's // useSyncExternalStore-based hooks actually subscribe to. -import { Tinode, type LoginCtrl } from 'tinode-sdk'; +import { Tinode, type LoginCtrl, type TinodeCtrl } from 'tinode-sdk'; import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config'; import { isTenantMismatch } from '@/lib/tinode/errors'; @@ -109,6 +109,32 @@ if (typeof window !== 'undefined') { window.addEventListener('offline', () => setReconnectState({ networkOnline: false })); } +/** + * 强制下线通知与顶号互踢-后端接口文档.md §2: an account-level eviction is a `{ctrl}` with + * `code:205, text:"evicted"` and NO `topic` (a topic-scoped one -- e.g. plain removal from a group -- + * carries a topic name instead and is left to whatever already handles that; not this module's + * concern). `reason` drives which notice text KickedNoticeRoot shows. + */ +let kickedInfo: { reason: string | undefined } | null = null; +const kickedListeners = new Set<() => void>(); + +export function getKickedInfo(): { reason: string | undefined } | null { + return kickedInfo; +} + +export function subscribeKicked(callback: () => void): () => void { + kickedListeners.add(callback); + return () => kickedListeners.delete(callback); +} + +/** KickedNoticeRoot calls this once the user has acknowledged the notice, right before navigating + * to /login -- clears the flag so a later fresh login doesn't leave a stale notice around to + * reappear (e.g. StrictMode remount, or the user landing back on some cached page via history). */ +export function clearKickedInfo() { + kickedInfo = null; + kickedListeners.forEach((cb) => cb()); +} + /** * The server extends the stock {hi} packet with a `tenant` field the vendored JS SDK has no API for * (resolved via POST /v1/im/tenants/resolve; see server/datamodel.go). The packet builder is private, @@ -205,6 +231,21 @@ function createTinode(): Tinode { if (next <= 0) clearCountdown(); }, 1000); }; + t.onCtrlMessage = (ctrl: TinodeCtrl) => { + if (ctrl.code !== 205 || ctrl.text !== 'evicted' || ctrl.topic) return; + // Tear down the session THE MOMENT eviction is detected, not when the user dismisses the + // notice -- the doc requires reconnect to stop immediately, and resetTinode()'s disconnect() + // sets the SDK's own #boffClosed flag before this handler returns, which is what actually + // prevents the backoff timer from ever scheduling another attempt (confirmed by reading the + // real bundle's Connection#init_ws -- disconnect() is the only thing that does this; there is + // no separate "stop autoreconnect" method to call instead). Only the user-facing navigation to + // /login is deferred, so they get a chance to actually read why. + sessionStorage.removeItem('im-session-token'); + useSessionStore.getState().setMyUserId(null); + resetTinode(); + kickedInfo = { reason: typeof ctrl.params?.reason === 'string' ? ctrl.params.reason : undefined }; + kickedListeners.forEach((cb) => cb()); + }; return t; } diff --git a/web/src/lib/tinode/errors.ts b/web/src/lib/tinode/errors.ts index d29792f9..fb80a207 100644 --- a/web/src/lib/tinode/errors.ts +++ b/web/src/lib/tinode/errors.ts @@ -87,3 +87,36 @@ export function validateCredentialsMessage(params: ValidateCredentialsParams | u const reason = params?.reason; return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试'; } + +/** 强制下线通知与顶号互踢-后端接口文档.md §2.4's own worked copy, verbatim -- not our own wording, + * so this stays a straight lookup rather than something to rephrase later without checking the doc + * again. `admin_user_evict`/`admin_device_kick` intentionally share one entry (doc's own table + * merges them into a single row). */ +const KICKED_REASON_TEXT_CN: Record = { + replaced_by_new_login: { + title: '账号已在其他设备登录', + message: '您的账号已在另一台设备上登录,当前设备已退出。如非本人操作,请及时修改密码。', + }, + admin_user_evict: { + title: '账号已被管理员下线', + message: '您的账号已被管理员强制下线,如有疑问请联系管理员。', + }, + admin_device_kick: { + title: '账号已被管理员下线', + message: '您的账号已被管理员强制下线,如有疑问请联系管理员。', + }, + user_deleted: { + title: '账号已注销', + message: '您的账号已注销,当前会话已退出。', + }, + user_state_changed: { + title: '账号已被限制登录', + message: '您的账号已被管理员限制登录,如有疑问请联系管理员。', + }, +}; + +const KICKED_REASON_FALLBACK = { title: '账号已被强制下线', message: '当前会话已被终止,请重新登录。' }; + +export function kickedNoticeText(reason: string | undefined): { title: string; message: string } { + return (reason && KICKED_REASON_TEXT_CN[reason]) || KICKED_REASON_FALLBACK; +} diff --git a/web/src/lib/tinode/hooks.ts b/web/src/lib/tinode/hooks.ts index c67b4b05..fb40af60 100644 --- a/web/src/lib/tinode/hooks.ts +++ b/web/src/lib/tinode/hooks.ts @@ -2,7 +2,16 @@ import { useCallback, useSyncExternalStore } from 'react'; import type { Contact, StoredMessage } from 'tinode-sdk'; -import { getConnectionStatus, getReconnectState, getTinode, subscribeConnectionStatus, subscribeReconnectState, type ReconnectState } from './connection'; +import { + getConnectionStatus, + getKickedInfo, + getReconnectState, + getTinode, + subscribeConnectionStatus, + subscribeKicked, + subscribeReconnectState, + type ReconnectState, +} from './connection'; import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicChanges } from './topics'; /** The current WS connection status, reactive via useSyncExternalStore. */ @@ -15,6 +24,12 @@ export function useReconnectState(): ReconnectState { return useSyncExternalStore(subscribeReconnectState, getReconnectState); } +/** Non-null once the server has forcibly evicted this session (顶号/管理端踢出/封禁/删号/重置密码 -- + * see 强制下线通知与顶号互踢-后端接口文档.md §2); KickedNoticeRoot is the sole consumer. */ +export function useKickedInfo() { + return useSyncExternalStore(subscribeKicked, getKickedInfo); +} + /** The singleton Tinode client instance (stable reference, not itself reactive). */ export function useTinode() { return getTinode(); diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts index c00e2b53..89544a37 100644 --- a/web/src/lib/tinode/tinode-sdk.d.ts +++ b/web/src/lib/tinode/tinode-sdk.d.ts @@ -348,6 +348,27 @@ declare module 'tinode-sdk' { * has stopped entirely (e.g. an explicit disconnect). */ onAutoreconnectIteration?: (timeoutMs: number, promise?: Promise) => void; + /** + * Fires for every incoming `{ctrl}` packet, account-level ones included (verified + * tinode.dev.js:6564-6566: `if (this.onCtrlMessage) this.onCtrlMessage(pkt.ctrl)`, unconditional, + * runs before the SDK's own internal `code===205` handling). This is the ONLY hook the actual + * installed SDK (0.25.3) offers for a server-forced eviction (`code:205, text:"evicted"`) -- + * there is no `onKick` callback in this version despite 强制下线通知与顶号互踢-后端接口文档.md's + * §2.4 claiming the SDK auto-stops reconnect and calls one; read the real bundle to confirm + * before relying on that doc's description of client-side behavior again. + */ + onCtrlMessage?: (ctrl: TinodeCtrl) => void; + } + + export interface TinodeCtrl { + code: number; + text: string; + /** Empty/absent for an account-level `{ctrl}` (e.g. forced eviction); a topic-scoped event + * (e.g. a plain "removed from this group") carries the topic name here instead. */ + topic?: string; + params?: Record; + ts?: string; + id?: string; } export const Drafty: {