feat: 登录设备管理与编辑器图片上传
- 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话 - Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal) - 实时总线、账号守卫与用户事件适配
This commit is contained in:
@@ -660,6 +660,19 @@ let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null;
|
||||
// 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示
|
||||
let bannedNotified = false;
|
||||
|
||||
// 登录态被顶/被剔除通知去重(其他浏览器登录、安全设置剔除设备等)。
|
||||
// sessionSeen 标记本标签页曾处于登录态:游客触发 401 不应弹"登录已失效"
|
||||
let sessionEndedNotified = false;
|
||||
let sessionSeen = false;
|
||||
|
||||
// 曾登录、经探测确认已游客化 → 派发 session_replaced 强制下线事件
|
||||
function notifySessionEnded(): void {
|
||||
if (sessionEndedNotified || !sessionSeen) return;
|
||||
sessionEndedNotified = true;
|
||||
sessionSeen = false;
|
||||
emitForceLogout("session_replaced");
|
||||
}
|
||||
|
||||
// 识别响应体中的封禁 code;body 只能读一次,故 clone 探测,原响应照常交给调用方
|
||||
function detectBannedBody(res: Response): void {
|
||||
if (res.status !== 403 || bannedNotified) return;
|
||||
@@ -717,6 +730,10 @@ async function fetchWithRefresh(url: string, init: RequestInit): Promise<Respons
|
||||
} else if (result.banned && !bannedNotified) {
|
||||
bannedNotified = true;
|
||||
emitForceLogout("banned");
|
||||
} else if (!result.banned) {
|
||||
// refresh 失败且非封禁:登录态已失效(被顶/被剔除/过期),
|
||||
// 曾登录的标签页统一告知,避免界面仍显示已登录
|
||||
notifySessionEnded();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
@@ -747,6 +764,22 @@ export async function fetchPosts(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 编辑器站内链接:按关键词搜帖子(公开接口;keyword 留空返回最近发布)
|
||||
export async function apiSearchPosts(
|
||||
keyword = "",
|
||||
size = 8
|
||||
): Promise<{ posts?: PostListItem[]; error?: string }> {
|
||||
const params = new URLSearchParams({ page: "1", size: String(size), sort: "latest" });
|
||||
if (keyword) params.set("keyword", keyword);
|
||||
const res = await fetchWithRefresh(`/api/posts?${params}`, {
|
||||
method: "GET",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) return { error: "搜索帖子失败" };
|
||||
const data = (await res.json().catch(() => ({}))) as Partial<PostsResponse>;
|
||||
return { posts: data.posts || [] };
|
||||
}
|
||||
|
||||
// SSR 首页聚合数据;失败时抛错,由首页调用方 try/catch 降级为无侧栏模块
|
||||
export async function fetchOverview(cookieHeader?: string): Promise<OverviewResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/overview`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
@@ -1302,10 +1335,15 @@ export async function apiMe(): Promise<MeResponse> {
|
||||
cache: "no-store",
|
||||
});
|
||||
const data: MeResponse = await res.json();
|
||||
if (data.user) sessionSeen = true;
|
||||
// /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径)
|
||||
if (data.banned === true && !bannedNotified) {
|
||||
bannedNotified = true;
|
||||
emitForceLogout("banned");
|
||||
} else if (!data.user && res.ok) {
|
||||
// 曾登录、现在明确是游客:登录态在别处被顶/被剔除(其他浏览器登录、
|
||||
// 设备剔除等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定
|
||||
notifySessionEnded();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -1318,8 +1356,12 @@ export async function apiLogin(username: string, password: string) {
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
// 新登录态开始:复位封禁通知去重(解封后再次被封仍可提示)
|
||||
if (res.ok && data?.user) bannedNotified = false;
|
||||
// 新登录态开始:复位封禁/被顶通知去重(解封后再次被封、再次被顶仍可提示)
|
||||
if (res.ok && data?.user) {
|
||||
bannedNotified = false;
|
||||
sessionEndedNotified = false;
|
||||
sessionSeen = true;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
// - 单条 WebSocket(浏览器握手自动携带 HttpOnly cookie 鉴权),后续群聊/通知/
|
||||
// 审核角标都复用本连接,按 message type 路由。
|
||||
// - 连接状态机 + 指数退避重连;应用层心跳(协议层 Ping 由浏览器自动应答)。
|
||||
// - 鉴权失效关闭码 4401 不自动重连(封禁/强制下线由 AccountGuard 统一处理)。
|
||||
// - 鉴权失效关闭码 4401 不自动重连;转一次 /api/me 探测(封禁走现有弹窗链路,
|
||||
// 被顶下线由 AccountGuard 弹"登录已失效",见 lib/api 的统一判定)。
|
||||
// - 在线集合:hello 帧给 staff 全量快照,之后 presence:update 增量维护。
|
||||
// - 群聊:进入群详情页调用 joinRoom(roomID) 订阅 chat:{id} 房间,
|
||||
// 重连后会自动重订当前已订阅集合;离开页面/退出群调用 leaveRoom。
|
||||
// - HTTP 仅作回前台 / 进页校准;实时靠 WS(通知、待审、主题、群聊未读)。
|
||||
|
||||
import { apiMe } from "./api";
|
||||
|
||||
export const RT_HELLO = "hello";
|
||||
export const RT_PONG = "pong";
|
||||
export const RT_SETTINGS_CHANGED = "settings:changed";
|
||||
@@ -20,6 +23,8 @@ export const RT_CHAT_UNREAD = "chat:unread";
|
||||
export const RT_NOTIFICATION_NEW = "notification:new";
|
||||
export const RT_FEED_CHANGED = "feed:changed";
|
||||
export const RT_MODERATION_CHANGED = "moderation:changed";
|
||||
// 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备),推 user:{id}
|
||||
export const RT_SESSION_REPLACED = "session:replaced";
|
||||
|
||||
export interface RtEnvelope<T = unknown> {
|
||||
type: string;
|
||||
@@ -323,10 +328,13 @@ class RealtimeClient {
|
||||
this.clearHeartbeat();
|
||||
if (this.ws === ws) this.ws = null;
|
||||
this.state = "closed";
|
||||
// 鉴权失效:账号封禁/角色变更导致 token_version 失效,
|
||||
// 不重连(强制下线/重新登录由现有 HTTP 拦截链路处理)
|
||||
// 鉴权失效:账号封禁/角色变更/会话被顶导致 claims 校验失效,
|
||||
// 不重连;探测一次登录态(封禁→现有弹窗链路;被顶→"登录已失效"提示)
|
||||
if (!this.wanted || ev.code === CLOSE_AUTH_INVALID) {
|
||||
this.wanted = false;
|
||||
if (ev.code === CLOSE_AUTH_INVALID) {
|
||||
void apiMe().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
|
||||
@@ -32,12 +32,14 @@ export function onUserUpdate(handler: (patch: Partial<User>) => void): () => voi
|
||||
* 强制下线事件。
|
||||
*
|
||||
* 触发源统一收敛在 lib/api.ts:任何客户端请求收到后端的
|
||||
* code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发,
|
||||
* Header 负责复用登出清理链路(清 cookie、游客化、router.refresh),
|
||||
* AccountGuard 负责弹出封禁告知——状态清理与告知 UI 解耦,
|
||||
* 避免各请求调用方各自处理导致残留(参考强制下线单一入口原则)。
|
||||
* code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发;
|
||||
* 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备)经探测确认后
|
||||
* 以 reason=session_replaced 派发。Header 负责复用登出清理链路
|
||||
* (清 cookie、游客化、router.refresh),AccountGuard 负责弹出对应告知——
|
||||
* 状态清理与告知 UI 解耦,避免各请求调用方各自处理导致残留
|
||||
* (参考强制下线单一入口原则)。
|
||||
*/
|
||||
export type ForceLogoutReason = "banned";
|
||||
export type ForceLogoutReason = "banned" | "session_replaced";
|
||||
|
||||
export const FORCE_LOGOUT_EVENT = "j13:force-logout";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user