# 题目 1：过去做过最困难的系统架构

> 作业题目：  
> 1. 如果有使用的语言、框架、技术栈是什么？  
> 2. 架构、使用的设计模式是什么？  
> 3. 尽可能涵盖全部范围，包含使用哪些服务、部署在哪、前后端沟通方式、数据结构等。

## AI 参考标注

本文的“表达结构、答辩顺序、部分架构总结措辞”参考了 AI 辅助整理；项目背景、技术栈、代码片段、接口路径、状态结构、缓存策略与部署方式均摘录并复核自 `private-domain-chat` 项目源码。

## 一句话概述

我过去做过最复杂的系统架构是一个“私域聚合聊天工作台”前端系统。它面向客服/运营人员，聚合多个企业微信机器人、私聊、群聊、会话分配、未读处理、消息收发、素材发送、联系人上下文、通知提醒和跨页面工具包能力。

这个项目难点不是单个页面复杂，而是它同时满足了几个高复杂度场景：

1. 实时性：Socket.IO 长连接持续接收会话与消息推送。
2. 高并发 UI：大量会话、大量消息、未读跳转、历史消息回填，需要虚拟列表和缓存。
3. 一致性：实时消息、历史接口、IndexedDB 缓存、当前会话状态之间要合并去重。
4. 多业务身份：客服、机器人、私聊客户、群聊成员、当前会话、置顶/免打扰/黑名单等状态交织。
5. 可恢复性：断网、切后台、重连、会话切换、请求取消、缓存过期，都要避免错误覆盖当前 UI。
6. 可集成性：系统不仅是一个聊天页面，还向外部 iframe / 工具页暴露可注册的聊天工具包。

如果用一句面试表达来说：这是一个“实时消息驱动 + 本地多级缓存 + Redux 领域建模 + Worker 隔离 Socket I/O + 虚拟列表渲染”的复杂前端应用。

## 1. 使用语言、框架、技术栈

### 1.1 核心语言与框架

项目使用 TypeScript + React 18 作为主要开发语言和 UI 框架，状态管理使用 Redux Toolkit，构建系统以 Vite 为主，同时保留 Webpack 构建能力。

代码证据来自 `package.json`：

```json
{
  "description": "基于React18和WebSocket的在线聊天应用",
  "scripts": {
    "start": "vite",
    "dev": "vite",
    "lint": "tslint -p tsconfig.json -c tslint.json",
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "build": "npm run build:prod",
    "build:prod": "VITE_BASE_URL=https://rpa.yunat.com/ NODE_ENV=production vite build --mode production",
    "build:webpack:prod": "VITE_BASE_URL=https://rpa.yunat.com/ webpack --config webpack.config.js"
  },
  "dependencies": {
    "@reduxjs/toolkit": "^2.8.2",
    "antd": "^5.26.4",
    "axios": "^1.6.0",
    "cloud-react": "^2.0.14",
    "idb": "^8.0.3",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-redux": "^9.2.0",
    "react-virtuoso": "^4.18.6",
    "socket.io-client": "^4.7.2",
    "uuid": "^11.1.0"
  },
  "devDependencies": {
    "typescript": "^5.4.5",
    "vite": "^4.4.0",
    "webpack": "^5.88.0"
  }
}
```

我在面试里会强调：这些选型不是堆技术，而是对应业务压力点。

- React 18：承载复杂工作台交互。
- Redux Toolkit：管理跨模块共享的领域状态，例如会话、消息、客服、机器人、通知、播放状态。
- Socket.IO：承载服务端主动推送的聊天消息、会话变更、通知。
- Web Worker：把 Socket 连接和消息队列从主线程隔离出去。
- IndexedDB / idb：缓存历史消息、会话上下文、群成员快照，减少切换会话时的白屏与接口压力。
- react-virtuoso：解决大量会话和大量消息的渲染性能问题。
- axios：统一 HTTP 请求、token 注入、客服身份参数、401 处理。
- Ant Design / cloud-react：搭建后台工作台 UI。
- Vite / Webpack：Vite 提升开发体验，Webpack/Vite 都保留构建适配能力。

### 1.2 前端工程化与构建

Vite 配置里做了别名分层、构建输出、chunk 拆分、资源分类和开发代理。

代码证据来自 `vite.config.mjs`：

```js
export default defineConfig(({ command }) => {
  const batch = command === "build";

  return {
    plugins: [
      react(),
      minifyPublicScripts([
        { from: "scripts/api.js", to: "scripts/toolkit.min.js" },
      ]),
    ],

    base: batch ? "/chat/" : "/",

    resolve: {
      alias: {
        "@global": path.resolve(__dirname, "src/global"),
        "@components": path.resolve(__dirname, "src/components"),
        "@styles": path.resolve(__dirname, "src/styles"),
        "@store": path.resolve(__dirname, "src/store"),
        "@features": path.resolve(__dirname, "src/features"),
        "@types": path.resolve(__dirname, "src/types"),
        "@common": path.resolve(__dirname, "src/common"),
      },
    },

    build: {
      outDir: "chat",
      minify: "terser",
      rollupOptions: {
        output: {
          manualChunks: {
            "react-vendor": ["react", "react-dom"],
            "redux-vendor": ["@reduxjs/toolkit", "react-redux"],
            "antd-vendor": ["antd", "@ant-design/icons"],
            "utils-vendor": ["axios", "dayjs", "tiny-pinyin", "uuid", "classnames"],
            "dnd-vendor": ["react-dnd", "react-dnd-html5-backend"],
            "cloud-vendor": ["cloud-react"],
          },
        },
      },
    },

    server: {
      host: "0.0.0.0",
      port: 3012,
      proxy: {
        "/pcrm-chat-manage": {
          target: "https://qa-rpa.yunat.com",
          changeOrigin: true,
          secure: false,
        },
        "/pcrm-chat-web": {
          target: "https://qa-rpa.yunat.com",
          changeOrigin: true,
          secure: false,
        },
        "/pcrm-chat-open-api": {
          target: "https://qa-rpa.yunat.com",
          changeOrigin: true,
          secure: false,
        },
      },
    },
  };
});
```

这里可以展开几个面试点：

- `base: "/chat/"` 说明项目不是部署在域名根路径，而是以子路径方式挂载。
- alias 体现了按领域分层，而不是所有代码堆在 `components`。
- manualChunks 把 React、Redux、Antd、工具库、拖拽库拆开，降低主包体积和缓存失效范围。
- 本地代理把多个后端路径统一转发到 QA 环境，便于本地联调。

### 1.3 页面入口与 UI 框架初始化

入口文件统一加载全局样式、Emoji、Debug 能力、Redux Provider、Antd ConfigProvider。

代码证据来自 `src/main.tsx`：

```tsx
dayjs.locale("zh-cn");

const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container);

Spin.setDefaultIndicator(<LoadingIndicator spin style={{ color: "var(--shuyunGrey6)" }} />);

loadEmojiList();
initDebugFeature(store);

root.render(
  <Provider store={store}>
    <ConfigProvider
      locale={locale}
      modal={{ centered: true }}
      theme={{
        token: {
          colorPrimary: "#0055cc",
          colorSuccess: "#21ba45",
          colorWarning: "#ffbb00",
          colorError: "#e74949",
        },
      }}
    >
      <Layout />
    </ConfigProvider>
  </Provider>,
);
```

这说明项目对 UI 统一性也做了系统级约束：主题色、弹窗行为、加载图标、中文 locale 都在入口集中管理，业务组件不需要各自重复处理。

## 2. 架构与设计模式

### 2.1 总体架构图

```mermaid
flowchart LR
  User["客服 / 运营人员"] --> Browser["React 工作台"]
  Browser --> Redux["Redux Toolkit Store"]
  Browser --> HTTP["Axios HTTP 层"]
  Browser --> SocketClient["SocketClient 主线程封装"]
  SocketClient --> Worker["Socket Web Worker"]
  Worker --> SocketIO["Socket.IO 服务"]
  HTTP --> Backend["PCRM Chat API"]
  Redux --> CacheMemory["内存 LRU 缓存"]
  CacheMemory --> IndexedDB["IndexedDB 持久缓存"]
  Redux --> Virtuoso["虚拟列表渲染"]
  Browser --> Toolkit["postMessage 工具包"]
  Toolkit --> Iframe["外部工具页 / iframe"]
```

核心思路是：

- UI 层只关心“当前状态应该怎么展示”。
- Redux 负责领域状态和异步流程编排。
- HTTP 层统一处理请求身份和错误。
- Socket Worker 负责连接、重连、队列、可见性和网络状态。
- 缓存层负责会话切换性能与离线快照体验。
- 虚拟列表负责高频数据下的渲染稳定性。
- Toolkit 负责向外部工具页开放能力。

### 2.2 分层架构：features / store / common / types

项目不是按文件类型简单分层，而是同时保留领域层和基础设施层：

- `features/*`：页面和业务模块，例如会话列表、消息列表、通讯录、机器人。
- `store/*`：Redux slice、selectors、thunks、middlewares、socket 接入、缓存。
- `common/*`：HTTP、Socket、SDK、通知、音频播放等基础设施。
- `types/*`：会话、消息、机器人、客服等领域类型。
- `translations/*`：后端 API 消息结构到前端展示结构的转换。

状态注册代码来自 `src/store/slices/index.ts`：

```ts
import appSlice from "./appSlice";
import botSlice from "./botSlice";
import chatSlice from "./chatSlice";
import internalSlice from "./internalSlice";
import kefuSlice from "./kefuSlice";
import materialSlice from "./materialSlice";
import noticeSlice from "./noticeSlice";
import playbackSlice from "./playbackSlice";
import sessionSlice from "./sessionSlice";

export default {
  app: appSlice,
  kefu: kefuSlice,
  bot: botSlice,
  session: sessionSlice,
  chat: chatSlice,
  internal: internalSlice,
  playback: playbackSlice,
  notice: noticeSlice,
  material: materialSlice,
};
```

我的理解是：复杂聊天系统不能只有一个“大 chat store”。它至少要拆成会话列表状态、当前聊天窗口状态、客服身份、机器人身份、通知、素材、播放状态等多个领域，否则一旦加功能，状态耦合会非常严重。

### 2.3 Redux Toolkit 作为领域状态中枢

Store 配置中接入了多个中间件，并把当前运行上下文提供给调试/日志模块。

代码证据来自 `src/store/index.ts`：

```ts
export const store = configureStore({
  reducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(
      chatLogMiddleware,
      errorLoggerMiddleware,
      notificationMiddleware,
    ),
});

chatLogReporter.setContextProvider(() => {
  const state = store.getState();
  return {
    connected: state.app.connected,
    currentChatId: state.session.current?.chatId || null,
    historyDirty: state.chat.historyDirty,
    initialized: state.chat.initialized,
    kefuId: state.kefu.kefuId,
    sessionId: state.kefu.sid,
  };
});
```

这里体现的设计模式包括：

- 中央状态仓库：跨组件共享状态统一进入 Redux。
- Middleware 管道：日志、错误、通知和业务状态解耦。
- Context Provider 模式：调试模块不直接依赖 UI，而是通过函数获取当前上下文。

### 2.4 Socket 采用 Worker 隔离和事件分发

主线程里的 `SocketClient` 只暴露 `init/start/close/emit/on/off` 等方法，真正的 Socket.IO 连接在 Worker 中维护。

代码证据来自 `src/common/socket/index.ts`：

```ts
class SocketClient implements MySocketClient {
  private registeredCallbacks: SocketEventCallbacks = {};
  private worker: Worker | null = null;
  private connected: boolean = false;
  private initialized: boolean = false;

  constructor() {
    try {
      const worker = new Worker(new URL("./lib.ts", import.meta.url), {
        type: "module",
      });

      this.worker = worker;
    } catch (error) {
      console.error(`[${getTimestamp()}] 加载 Socket Worker 失败：`, error);
    }
  }

  init(callbacks: SocketEventCallbacks): void {
    if (callbacks) {
      this.register(callbacks);
    }

    if (this.initialized || !this.worker) {
      return;
    }

    this.initialized = true;

    this.worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
      const { type, event: eventName, data, id, status } = event.data;

      switch (type) {
        case "SOCKET_EVENT":
          if (eventName && this.registeredCallbacks[eventName]) {
            this.registeredCallbacks[eventName]!(...data);
          }
          if (this.worker && status === "pending") {
            this.worker.postMessage({
              type: "MESSAGE_PROCESSED",
              id,
              status: "success",
            });
          }
          break;
        case "SOCKET_STATUS_CHANGE":
          this.onStatusChange(data!);
          break;
        case "SOCKET_RESTART":
          this.onStatusChange(false);
          this.start();
          break;
      }
    };
  }

  emit(event: string, ...args: any[]): void {
    return this.worker?.postMessage?.({
      id: Math.random().toString(36).substr(2, 9) + Date.now().toString(36),
      type: "SOCKET_EMIT",
      payload: { event, args },
    });
  }
}
```

这个设计的价值：

- Socket 重连、断网、切后台、队列处理不会阻塞主线程 UI。
- 主线程只需要注册业务事件，不直接暴露 socket.io 细节。
- Worker 和主线程通过 ACK 机制保持消息串行处理，避免高频推送时乱序打入 Redux。

### 2.5 Worker 内部的连接管理、可见性管理和队列

Worker 中处理网络状态、页面可见性、重连策略和消息队列。

代码证据来自 `src/common/socket/lib.ts`：

```ts
let visible = true;
let online = true;
let paused = false;

self.onmessage = (event: MessageEvent<WorkerMessage>) => {
  const { type, payload, id, status } = event.data;
  switch (type) {
    case "SOCKET_START":
      initializeSocket({
        token: payload.token,
        baseUrl: payload.baseUrl,
      });
      break;

    case "NETWORK_STATUS_CHANGE":
      online = payload;
      paused = visible ? !online : true;
      if (paused) {
        stopSocket();
      } else {
        startSocket();
      }
      break;

    case "VISIBLE_CHANGE":
      visible = payload === "visible";
      paused = visible ? !online : true;
      if (!paused) {
        startSocket();
        return;
      }
      break;

    case "MESSAGE_PROCESSED":
      if (id === pendingEventId && status !== "pending") {
        pendingEventId = null;
        processMessageQueue();
      }
      break;
  }
};
```

Socket.IO 初始化：

```ts
socket = io(getSocketUrl(config), {
  path: "/pcrm-chat-socket/v1/im",
  query: {
    token: config.token,
  },
  autoConnect: false,
  transports: ["websocket"],
  timeout: 20000,
  reconnection: true,
  reconnectionAttempts: Infinity,
  reconnectionDelay: 5000,
  reconnectionDelayMax: 20000,
  randomizationFactor: 0.5,
  forceNew: false,
  upgrade: true,
  rememberUpgrade: false,
});
```

高频消息队列：

```ts
let pendingEventId: string | null = null;
const messageQueue: QueuedMessage[] = [];

function processMessageQueue(): void {
  if (pendingEventId || messageQueue.length === 0) {
    return;
  }

  const queuedMessage = messageQueue.shift();
  if (queuedMessage) {
    const response: WorkerResponse = {
      id: uuid(),
      type: "SOCKET_EVENT",
      event: queuedMessage.event,
      status: "pending",
      data: queuedMessage.data,
    };

    pendingEventId = response.id || null;
    self.postMessage(response);
  }
}

function queueMessage(event: string, data: any): void {
  const queuedMessage: QueuedMessage = {
    event,
    data,
    timestamp: Date.now(),
  };
  messageQueue.push(queuedMessage);
  processMessageQueue();
}
```

这是我认为这个项目最有系统设计含量的地方之一。它相当于把实时消息通道设计成一个“可暂停、可恢复、可串行消费”的事件源，而不是简单地 `socket.on("chat/msg", dispatch)`。

### 2.6 Socket 事件到 Redux 的适配器模式

Socket 事件最终在 `src/store/socket.ts` 中被映射成 Redux action。

```ts
const initSocketClient = (): MySocketClient => {
  socket.init({
    login: (data) => {
      store.dispatch(login(data)).then((action) => {
        if (login.fulfilled.match(action)) {
          store.dispatch(initializeFeatureData(data.kefuId));
        }
      });
      store.dispatch(setError(null));
    },
    change: (connected: boolean) => {
      store.dispatch(setConnected(connected));
    },
    "chat/update": (res: Session) => {
      if (!canConsumeSocketMessages()) {
        return;
      }
      if (res.chatStatus !== "over") {
        enqueueInboxSessionUpdate(store.dispatch, res);
      } else {
        cancelInboxSessionUpdate(res);
        store.dispatch(handleClosedSessionUpdate(res));
      }
    },
    "chat/msg": (res) => {
      if (!canConsumeSocketMessages()) {
        return;
      }
      if (res.chatInfo.chatStatus !== "over") {
        store.dispatch(syncMessage(res));
      }
    },
    "notice/messageBox": (res) => {
      store.dispatch(addNewNotice(res));
    },
  });
  store.dispatch(
    launch(() => {
      socket.start();
    }),
  );
  return socket;
};
```

这体现了适配器模式：Socket.IO 的事件协议被转换成前端领域动作，例如 `syncMessage`、`enqueueInboxSessionUpdate`、`addNewNotice`。这样 UI 组件不关心事件名，也不直接处理后端推送协议。

### 2.7 高频会话更新的聚合与批处理

会话列表是高频更新区域，尤其是客服同时接待多个私聊/群聊时，如果每条推送都触发一次完整 Redux 更新和列表重排，容易造成主线程抖动。因此项目增加了 inbox 聚合层。

代码证据来自 `src/store/inboxAggregation.ts`：

```ts
const DEFAULT_CONFIG: InboxAggregationConfig = {
  maxBatchSize: 3,
  maxFlushBudgetMs: 8,
  minItemsPerFlush: 1,
  notificationDebounceMs: 1000,
  sessionFlushMs: 0,
};

const pendingSessionUpdates = new Map<string, SessionUpdateInput>();

export const enqueueInboxSessionUpdate = (
  dispatch: DispatchLike,
  session: SessionUpdateInput,
) => {
  const sessionKey = getSessionIdentity(session);

  if (pendingSessionUpdates.has(sessionKey)) {
    // delete + set 会把同会话更新拖到队尾，保证后到覆盖先到且不会插队
    pendingSessionUpdates.delete(sessionKey);
  }

  pendingSessionUpdates.set(sessionKey, session);
  scheduleSessionFlush(dispatch);
};
```

批量 flush：

```ts
const flushSessionUpdates = async (dispatch: DispatchLike) => {
  if (!pendingSessionUpdates.size || sessionFlushInProgress) {
    return;
  }

  sessionFlushInProgress = true;

  try {
    const flushStartedAt = getNow();
    let processedItemCount = 0;
    const maxBatchSize = Math.max(1, Math.floor(currentConfig.maxBatchSize));
    const maxFlushBudgetMs = Math.max(0, currentConfig.maxFlushBudgetMs);

    while (pendingSessionUpdates.size) {
      const sessions: SessionUpdateInput[] = [];

      for (const [sessionKey, session] of pendingSessionUpdates) {
        sessions.push(session);
        pendingSessionUpdates.delete(sessionKey);

        if (sessions.length >= maxBatchSize) {
          break;
        }
      }

      processedItemCount += sessions.length;
      await dispatch(updateSessionBatch(sessions));

      const elapsedMs = getNow() - flushStartedAt;
      const reachedMinProgress = processedItemCount >= minItemsPerFlush;
      if (reachedMinProgress && elapsedMs >= maxFlushBudgetMs) {
        break;
      }
    }
  } finally {
    sessionFlushInProgress = false;

    if (pendingSessionUpdates.size) {
      scheduleSessionFlush(dispatch, 0);
    }
  }
};
```

这里可以和面试官展开：这个设计本质上是“微批处理 + 同 key 覆盖 + 时间预算控制”。它牺牲了极小的实时粒度，换来更稳定的 UI 更新节奏。

### 2.8 当前聊天窗口的数据一致性设计

聊天消息来源有三类：

1. 初始化时从历史接口拉取。
2. 切换会话时从本地缓存读取。
3. Socket 实时推送新消息。

因此 reducer 里不能简单 append，而要通过消息 id 合并去重并排序。

代码证据来自 `src/store/slices/chatSlice.ts`：

```ts
export interface ChatState {
  session?: Session;
  messages: (Message | Record)[];
  personInfo?: Person;
  roomInfo?: Room;
  botInfo?: Bot;
  loading: boolean;
  initialized: boolean;
  contextReady: boolean;
  roomInfoReady: boolean;
  memberListReady: boolean;
  historyDirty: boolean;
  historyLiveMessages: (Message | Record)[];
  reconnectRefreshing: boolean;
  backfillingHistory: boolean;
  loadingHistory: boolean;
  hasMoreHistory: boolean;
  currentData?: MessageData;
  lastSentAt: number;
  pendingInitSession?: Pick<Session, "chatId" | "botId">;
  pendingInitLiveMessages: (Message | Record)[];
}

const mergeMessages = (
  currentMessages: (Message | Record)[],
  nextMessages: (Message | Record)[],
) => {
  const merged = new Map<string, Message | Record>();

  currentMessages.forEach((message) => {
    merged.set(message.id, message);
  });

  nextMessages.forEach((message) => {
    merged.set(message.id, message);
  });

  return sortMessages(Array.from(merged.values()));
};
```

这个结构解决了几个实际问题：

- 当前会话还没初始化完成时，实时消息先进入 `pendingInitLiveMessages`。
- 历史消息刷新期间，实时消息进入 `historyLiveMessages`，刷新完成后再合并。
- 同一条消息可能先以 `sending` 状态出现，后续又收到服务端成功回执，通过 id upsert。
- 重连后补历史可能和实时推送重复，通过 Map 去重。

### 2.9 实时消息同步流程

代码证据来自 `src/store/thunks/chat/syncMessage.ts`：

```ts
export const syncMessage = createAsyncThunk(
  "chat/syncMessage",
  async (
    syncMsg: (API.Record | API.Message) & { chatInfo: any },
    { getState, dispatch }
  ) => {
    await prepareChatCacheNamespace();
    const rootState = getState() as RootState;
    const {
      session: currentSession,
      initialized,
      messages,
    } = selectChatState(rootState);
    const activeSession = selectCurrentConversation(rootState);

    const incomingSession = syncMsg.chatInfo as Session;
    const transformedMessage = transformMessage(syncMsg);

    const isSelectedSession = isSameSession(activeSession, incomingSession);
    const isInitializedCurrentSession =
      initialized && isSameSession(currentSession, incomingSession);

    if (syncMsg.msgType !== "SYSTEM") {
      enqueueInboxSessionUpdate(dispatch, {
        ...syncMsg.chatInfo,
        lift: true,
      });
    }

    if (!isInitializedCurrentSession) {
      if (isSelectedSession) {
        dispatch(
          bufferInitLiveMessage({
            session: {
              chatId: incomingSession.chatId,
              botId: incomingSession.botId,
            },
            message: transformedMessage,
            chatInfo: incomingSession,
          }),
        );
        return null;
      }
      upsertChatCacheMessage(
        {
          chatId: syncMsg.chatInfo.chatId,
          maxSeq: syncMsg.chatInfo.maxSeq || transformedMessage.seq || 0,
        },
        transformedMessage,
      );
      return null;
    }

    dispatch(addMessage(transformedMessage));
    upsertChatCacheMessage(
      {
        chatId: currentSession.chatId,
        maxSeq:
          syncMsg.chatInfo.maxSeq || transformedMessage.seq || currentSession.maxSeq,
      },
      transformedMessage,
      { createIfMissing: true },
    );

    await socket.emit(
      "chat/wake",
      JSON.stringify({
        ...currentSession,
        unreadCnt: 0,
        maxSeq: transformedMessage.seq || currentSession.maxSeq,
        readLastSeq:
          transformedMessage.seq || currentSession.maxSeq || currentSession.readLastSeq,
      }),
    );
  }
);
```

这里的关键不是“收到消息就加到列表”，而是区分：

- 当前会话且已初始化：直接展示并写缓存。
- 当前会话但初始化中：先 buffer，避免历史接口回来后覆盖。
- 非当前会话：只更新缓存和会话列表，不污染当前聊天窗口。
- 非系统消息：更新 inbox 会话排序和未读。
- 收到当前会话消息后向后端 `chat/wake`，同步已读/唤醒状态。

### 2.10 会话列表的数据结构与批量更新

会话列表状态不只是一个数组，而是多个视图和索引的组合。

代码证据来自 `src/store/slices/sessionSlice.ts`：

```ts
interface SessionState {
  allBotAssignedSessionMaps: Record<
    string | number, // botId
    Record<string, number> // chatId: unreadCnt
  >;
  topInfos: ChatTopInfo[];
  sessionList: Session[];
  assignedSessions: Session[];
  current: Session | null;
  currentGroupKey: string;
  currentGroupType: string;
  currentCustomGroupIds: string[];
  selectedSessionInUnreadGroup: string | null;
  chatGroupMenus: MenuGroupItem[];
  chatGroupMenusLoading: boolean;
  loading: boolean;
  error: string | null;
  drafts: Record<string, MessageData>;
}
```

批量更新时，代码用 Map 建索引，避免对数组反复 `findIndex`。

```ts
const applyPreparedSessionUpdatesBatch = (
  state: SessionState,
  payloads: PreparedSessionUpdatePayload[],
) => {
  const sessionIndexMap = new Map(
    state.sessionList.map((session, index) => [getSessionIdentity(session), index]),
  );
  const assignedIndexMap = new Map(
    state.assignedSessions.map((session, index) => [
      getSessionIdentity(session),
      index,
    ]),
  );
  const liftedSessionMap = new Map<string, Session>();
  const liftedAssignedMap = new Map<string, Session>();
  const removedSessionKeys = new Set<string>();
  const removedAssignedKeys = new Set<string>();

  payloads.forEach((payload) => {
    const { lift, session, currentKefuId, currentBotId } = payload;
    const sessionKey = getSessionIdentity(session);
    const visibleInSessionList =
      session.botId === currentBotId || currentBotId === "all";
    const sessionIndex = sessionIndexMap.get(sessionKey);

    if (sessionIndex !== undefined) {
      const updatedSession = {
        ...state.sessionList[sessionIndex],
        ...session,
      };

      if (lift) {
        removedSessionKeys.add(sessionKey);
        liftedSessionMap.delete(sessionKey);
        liftedSessionMap.set(sessionKey, updatedSession);
      } else {
        state.sessionList[sessionIndex] = { ...updatedSession };
      }
    } else if (visibleInSessionList) {
      liftedSessionMap.delete(sessionKey);
      liftedSessionMap.set(sessionKey, session);
    }
  });
};
```

面试时我会说：这里的关键设计是把 `botId + chatId` 作为会话身份，而不是只用数组下标。这样在会话被置顶、转移、结束、重新分配、跨机器人切换时，状态更新才是稳定的。

### 2.11 本地多级缓存：内存 LRU + IndexedDB

缓存层分为内存缓存和持久化缓存。内存缓存控制当前使用的 20 个会话，IndexedDB 控制持久化的 50 个会话和最多 1500 条消息。

缓存类型来自 `src/store/thunks/chat/cache/types.ts`：

```ts
export const MAX_CHAT_CACHE_SESSIONS = 20;

export interface ChatCacheEntry {
  botInfo?: Bot;
  contextReady: boolean;
  hasMoreHistory: boolean;
  memberListReady: boolean;
  messages: (Message | Record)[];
  personInfo?: Person;
  roomInfo?: Room;
  roomInfoReady: boolean;
  sessionMaxSeq: number;
  updatedAt: number;
}
```

IndexedDB 表结构来自 `src/store/thunks/chat/cache/db.ts`：

```ts
export const MAX_PERSISTED_CHAT_SESSIONS = 50;
export const MAX_PERSISTED_CHAT_MESSAGES = 1500;

const CHAT_CACHE_DB_NAME = "chat-cache";
const CHAT_CACHE_DB_VERSION = 1;
const CHAT_SESSIONS_STORE = "chat_sessions";
const CHAT_MESSAGES_STORE = "chat_messages";
const CHAT_ROOM_MEMBERS_STORE = "chat_room_members";

const openChatCacheDb = () =>
  openDB(CHAT_CACHE_DB_NAME, CHAT_CACHE_DB_VERSION, {
    upgrade(db) {
      if (!db.objectStoreNames.contains(CHAT_SESSIONS_STORE)) {
        const sessionsStore = db.createObjectStore(CHAT_SESSIONS_STORE, {
          keyPath: "id",
        });

        sessionsStore.createIndex("by_namespace_updated_at", [
          "namespace",
          "updatedAt",
        ]);
      }

      if (!db.objectStoreNames.contains(CHAT_MESSAGES_STORE)) {
        const messagesStore = db.createObjectStore(CHAT_MESSAGES_STORE, {
          keyPath: "id",
        });

        messagesStore.createIndex("by_namespace_chat_id", ["namespace", "chatId"]);
        messagesStore.createIndex("by_namespace_chat_seq", [
          "namespace",
          "chatId",
          "seq",
        ]);
      }

      if (!db.objectStoreNames.contains(CHAT_ROOM_MEMBERS_STORE)) {
        db.createObjectStore(CHAT_ROOM_MEMBERS_STORE, {
          keyPath: "id",
        });
      }
    },
  });
```

内存缓存 LRU 逻辑来自 `src/store/thunks/chat/cache/memory.ts`：

```ts
const trimCache = () => {
  while (chatCacheState.cache.size > MAX_CHAT_CACHE_SESSIONS) {
    const oldestKey = chatCacheState.cache.keys().next().value;

    if (!oldestKey) {
      break;
    }

    const removedEntry = chatCacheState.cache.get(oldestKey);
    const namespaceKey = ensureNamespaceState();

    if (removedEntry && namespaceKey) {
      void persistEntryNow(namespaceKey, oldestKey, cloneEntry(removedEntry));
    }

    chatCacheState.cache.delete(oldestKey);
  }
};

export const readChatCache = (session: Pick<Session, "chatId">) => {
  ensureNamespaceState();
  const cacheKey = getCacheKey(session);
  const entry = chatCacheState.cache.get(cacheKey);

  if (!entry) {
    return undefined;
  }

  chatCacheState.cache.delete(cacheKey);
  chatCacheState.cache.set(cacheKey, entry);

  return cloneEntry(entry);
};
```

这段能解释为什么用 Map：Map 的插入顺序天然适合 LRU。每次读取时 delete + set，把最近访问的会话移到队尾；超出数量时淘汰最老 key，并持久化到 IndexedDB。

### 2.12 会话预加载策略

为了让客服切换会话时尽量秒开，系统会在拿到会话列表后预加载一部分会话历史。

代码证据来自 `src/store/thunks/session/fetchSessionList.ts`：

```ts
export const fetchSessionList = createAsyncThunk(
  "session/fetchSessionList",
  async (botId: string, { dispatch, getState }) => {
    const res = await fetchSessionListData(botId, dispatch, getState);
    await pruneChatCacheByBotSessions(botId, res);
    const kefu = selectKefuState(getState() as RootState);
    void dispatch(
      preloadSessions({
        sessions: res,
        currentKefuId: kefu.kefuId,
        currentSession: null,
      }),
    );
    return {
      currentKefuId: kefu.kefuId,
      sessionList: res,
    };
  }
);
```

预加载代码来自 `src/store/thunks/chat/cache/preloadSessions.ts`：

```ts
const PRELOAD_ASSIGNED_GLOBAL_COUNT = 12;
const PRELOAD_CONCURRENCY = 6;
let activePreloadRunId = 0;

const pendingPreloadTasks = new Map<string, Promise<void>>();
const pendingPreloadControllers = new Map<string, AbortController>();

const abortPendingPreloads = () => {
  pendingPreloadControllers.forEach((controller) => controller.abort());
  pendingPreloadControllers.clear();
  pendingPreloadTasks.clear();
};

const buildPrioritizedSessions = (
  sessions: Session[],
  currentKefuId: number | null | undefined,
  currentSession?: Session | null,
) => {
  const assignedSessions = currentKefuId
    ? sessions.filter((session) => session.kefuId === currentKefuId)
    : [];
  const prioritizedSessions = dedupeSessions([
    ...assignedSessions.slice(0, PRELOAD_ASSIGNED_GLOBAL_COUNT),
    ...assignedSessions,
    ...sessions,
  ]);

  return prioritizedSessions.filter(
    (session) =>
      !currentSession ||
      session.chatId !== currentSession.chatId ||
      session.botId !== currentSession.botId,
  );
};
```

并发控制：

```ts
for (let index = 0; index < targets.length; index += PRELOAD_CONCURRENCY) {
  if (runId !== activePreloadRunId) {
    break;
  }

  const batch = targets.slice(index, index + PRELOAD_CONCURRENCY);

  await Promise.all(
    batch.map(async (session) => {
      try {
        await preloadSessionHistory(session, runId);
      } catch {}
    }),
  );
}
```

这体现了几个工程取舍：

- 优先预加载“分配给我的会话”，因为客服最可能打开这些会话。
- 控制并发为 6，避免瞬间打爆接口。
- 使用 `activePreloadRunId` 取消旧任务，防止切换机器人后旧预加载污染新上下文。
- 使用 `AbortController` 主动取消请求，减少无意义流量。

### 2.13 初始化聊天时的缓存命中与回填

聊天初始化不是简单请求历史接口，而是先准备缓存命名空间，再读取缓存，并根据 `sessionMaxSeq` 判断是否可直接复用、后台补齐或快照回填。

代码证据来自 `src/store/thunks/chat/init.ts`：

```ts
const unreadCount = session.unreadCnt || 0;
const loadCount = unreadCount + INITIAL_MESSAGE_COUNT;

const namespaceKey = await prepareChatCacheNamespace();
const cacheEntry = await loadChatCache(session);

const cacheLoadableCount = cacheEntry
  ? getLoadableMessageCount(cacheEntry.messages)
  : 0;
const cacheSeqMatched = !!cacheEntry && cacheEntry.sessionMaxSeq === session.maxSeq;
const cacheSeqOutdated = !!cacheEntry && cacheEntry.sessionMaxSeq !== session.maxSeq;
const canReuseCacheDirectly =
  !!cacheEntry && cacheSeqMatched && cacheLoadableCount >= loadCount;
const shouldBackfillFromCache =
  !!cacheEntry &&
  cacheSeqMatched &&
  cacheEntry.messages.length > 0 &&
  cacheLoadableCount < loadCount;
const shouldUseCacheSnapshot =
  !!cacheEntry && cacheEntry.messages.length > 0 && cacheSeqOutdated;

if (canReuseCacheDirectly && cacheEntry) {
  records = cacheEntry.messages;
  hasMoreHistory =
    cacheEntry.hasMoreHistory || cacheLoadableCount >= loadCount;
  historyLoadedFromCache = true;
} else if (shouldBackfillFromCache && cacheEntry) {
  records = cacheEntry.messages;
  hasMoreHistory = true;
  historyLoadedFromCache = true;
  historyBackfillNeeded = true;
} else if (shouldUseCacheSnapshot && cacheEntry) {
  records = cacheEntry.messages;
  hasMoreHistory = true;
  historyLoadedFromCache = true;
  historyRefreshNeeded = true;
} else {
  const history = await fetchHistory(session, loadCount);
}
```

这段是很好的架构答辩材料，因为它体现了“性能”和“一致性”的平衡：

- 命中且 seq 匹配：直接展示，减少等待。
- 命中但数量不足：先展示，再后台补齐。
- 命中但 seq 过期：先用快照撑住 UI，再刷新。
- 未命中：走接口拉取。

### 2.14 大列表渲染与交互稳定性

会话列表使用 `react-virtuoso`，并且做了搜索去重、置顶集合、免打扰集合、黑名单集合等派生数据缓存。

代码证据来自 `src/features/inbox/ui/SessionList/index.tsx`：

```tsx
const pinnedChatIds = useMemo(
  () => new Set(topInfos.map((item) => item.chatId)),
  [topInfos],
);
const notDisturbChatIds = useMemo(
  () => new Set(notDisturbInfos.map((item: { chatId: string }) => item.chatId)),
  [notDisturbInfos],
);
const blacklistExtUserIdSet = useMemo(
  () => new Set(blacklistExtUserIds),
  [blacklistExtUserIds],
);

const uniqueSessionList = useMemo(() => {
  if (error) return [];

  const trimmedKeyword = searchKeyword.trim();
  const hasKeyword = !!trimmedKeyword;

  if (!hasKeyword) return filteredSessionList;

  const seenChatIds = new Set<string>();
  const result: Session[] = [];

  filteredSessionList.forEach((session: Session) => {
    if (seenChatIds.has(session.chatId)) {
      return;
    }

    const extUserNameMatch =
      !!session.extUserName &&
      isSessionItemTextMatch(session.extUserName, trimmedKeyword);

    if (!extUserNameMatch) {
      return;
    }

    seenChatIds.add(session.chatId);
    result.push(session);
  });

  return result;
}, [error, filteredSessionList, searchKeyword]);
```

虚拟列表渲染：

```tsx
<Virtuoso
  ref={virtuosoRef}
  style={{ flex: 1 }}
  data={uniqueSessionList}
  fixedItemHeight={66}
  computeItemKey={(_, item) => item.chatId}
  itemContent={renderSessionItem}
  increaseViewportBy={{ top: 660, bottom: 660 }}
/>
```

消息列表也使用 Virtuoso，并且为未读导航、@ 导航、历史加载、滚动定位维护了大量 ref 状态。

代码证据来自 `src/features/chat/ui/MessageList/index.tsx`：

```tsx
const AUTO_STICK_TO_BOTTOM_THRESHOLD = 1200;
const AT_BOTTOM_STATE_THRESHOLD = 4;
const INITIAL_ITEM_INDEX = 100_000;

const VIRTUOSO_COMPONENTS = {
  Header: VirtuosoHeader,
  Footer: VirtuosoFooter,
} as const;

const MessageList = forwardRef<MessageListRef, MessageListProps>(
  (props, ref) => {
    const virtuosoRef = useRef<VirtuosoHandle>(null);
    const containerRef = useRef<HTMLElement | null>(null);
    const messages = useSelector(selectMessages);
    const loadingHistory = useSelector(selectLoadingHistory);
    const hasMoreHistory = useSelector(selectHasMoreHistory);
    const liveIncomingTick = useSelector(selectLiveIncomingTick);

    const messageElementRefMap = useRef<Map<string, HTMLElement>>(new Map());
    const messageElementRefCallbackMap = useRef<
      Map<string, (node: HTMLElement | null) => void>
    >(new Map());
    const messageMapRef = useRef<Map<string, Message | Record>>(new Map());
    const groupedArrayIndexMapRef = useRef<Map<string, number>>(new Map());
  }
);
```

这里可以解释：消息列表和普通 feed 不一样，它既要支持向上加载历史，又要支持新消息贴底、未读跳转、引用跳转、@ 跳转、系统消息折叠和选择转发。因此使用大量 ref 保存滚动锚点，避免每次滚动都触发 React 重渲染。

### 2.15 HTTP 层统一身份注入和错误处理

代码证据来自 `src/common/http.ts`：

```ts
const http = axios.create({
  baseURL: API_BASE_URL.replace(/\/$/, ""),
  timeout: 60 * 1000 * 5,
  headers: {
    "Content-Type": "application/json",
  },
});

http.interceptors.request.use(
  (config) => {
    const authInfo = getStorageItem("authInfo");
    const kefuInfo = getStorageItem("kefuInfo");
    if (authInfo?.id) {
      config.headers["X-TOKEN"] = authInfo.id;
    }
    if (config.data instanceof FormData) {
      delete config.headers["Content-Type"];
    }
    if (config.params) {
      config.params.kefuId = kefuInfo?.kefuId;
    } else {
      config.params = {
        kefuId: kefuInfo?.kefuId,
      };
    }
    return config;
  }
);

http.interceptors.response.use(
  (response) => {
    const res = response.data;
    if (res && typeof res.success !== "undefined") {
      if (res.success) {
        return res.data;
      } else {
        message.error(res.msg || "请求失败");
        return Promise.reject(new Error(res.msg || "请求失败"));
      }
    }
    return res;
  },
  (error) => {
    if (error.response?.status === 401) {
      socket.close(error.response.data?.msg || "登录已过期，请重新登录");
    }
    return Promise.reject(error);
  }
);
```

这里体现的设计是“请求适配层”：业务 thunk 不需要每次手动传 token 和 kefuId，也不需要每个页面单独判断 401。对于客服工作台来说，身份一致性非常关键，否则同一个接口在不同组件里会出现行为差异。

### 2.16 跨页面工具包：postMessage SDK

系统还支持外部工具页/iframe 调用当前聊天上下文，比如获取当前客户/群上下文、发送消息。

代码证据来自 `src/common/services/sdk.ts`：

```ts
interface ToolkitMessage {
  id: string;
  type: 'request' | 'response';
  method: string;
  params?: any;
  result?: any;
  error?: string;
}

interface RegisteredClient {
  origin: string;
  window: Window;
  registeredAt: number;
}

class SyChatToolkitManager {
  private registeredClients: Map<string, RegisteredClient> = new Map();
  private allowedOrigins: string[] = [];
  private isInitialized = false;

  private init() {
    if (this.isInitialized) return;
    window.addEventListener('message', this.handleIncomingMessage.bind(this));
    this.isInitialized = true;
  }

  private async handleIncomingMessage(event: MessageEvent) {
    const { origin, data } = event;

    if (!data || typeof data !== 'object' || !data.type || !data.method) {
      return;
    }

    const message: ToolkitMessage = data;

    if (message.method === 'register') {
      await this.handleRegistration(origin, message, event);
      return;
    }

    if (!this.isClientRegistered(origin)) {
      this.sendError(origin, message.id, 'Client not registered');
      return;
    }

    if (message.type === 'request') {
      await this.handleRequest(origin, message);
    }
  }
}
```

这个设计可以作为“系统边界”来讲：聊天工作台不是封闭页面，它还能作为宿主，把当前会话上下文安全地开放给外部工具页。

### 2.17 常用设计模式总结

我会在面试里把设计模式总结成以下几类：

1. 分层架构：`features` 负责 UI，`store` 负责状态和流程，`common` 负责基础设施，`types` 负责领域模型。
2. 适配器模式：HTTP adapter、Socket event adapter、API message transform，把后端协议转换成前端领域模型。
3. 发布订阅 / 事件驱动：Socket.IO 推送、Worker `postMessage`、浏览器 `message` 事件、Redux action 都是事件驱动链路。
4. 微批处理：`inboxAggregation` 把高频会话更新合并成批量 Redux 更新。
5. LRU 缓存：内存 Map 通过 delete + set 维护访问顺序，淘汰时持久化到 IndexedDB。
6. 策略模式：聊天初始化根据缓存状态选择直接复用、后台补齐、快照回填或接口拉取。
7. 门面模式：`SocketClient` 对外只暴露简单方法，隐藏 Worker 和 socket.io 细节。
8. 观察者模式：SDK 通过 `store.subscribe` 等待上下文 ready，通知外部工具调用方。
9. 虚拟化渲染模式：会话列表和消息列表通过 Virtuoso 降低 DOM 数量。

## 3. 服务、部署、前后端通信方式、数据结构

### 3.1 使用到的服务

从源码可确认前端至少对接以下服务或后端路径：

- `/pcrm-chat-web`：聊天主业务接口，例如会话列表、历史消息、群信息、置顶、免打扰等。
- `/pcrm-chat-manage`：聊天管理相关接口。
- `/pcrm-chat-open-api`：开放 API。
- `/pcrm-chat-socket/v1/im`：Socket.IO WebSocket 通道。
- `/pcrm-tag`：标签相关接口。
- 静态资源和品牌图资源：用于头像、空状态图、图标等。
- IndexedDB：浏览器侧本地持久缓存。
- postMessage SDK：与外部工具页面通信。

开发代理证据来自 `vite.config.mjs`：

```js
proxy: {
  "/pcrm-chat-manage": {
    target: "https://qa-rpa.yunat.com",
    changeOrigin: true,
    secure: false,
  },
  "/pcrm-chat-web": {
    target: "https://qa-rpa.yunat.com",
    changeOrigin: true,
    secure: false,
  },
  "/pcrm-chat-open-api": {
    target: "https://qa-rpa.yunat.com",
    changeOrigin: true,
    secure: false,
  },
  "/pcrm-tag": {
    target: "https://qa-ual.shuyun.com",
    changeOrigin: true,
    secure: false,
  },
}
```

环境变量入口来自 `src/constants/index.ts`：

```ts
export const API_BASE_URL =
  (typeof import.meta !== "undefined" &&
    import.meta.env &&
    import.meta.env.VITE_BASE_URL) ||
  (typeof process !== "undefined" &&
    process.env &&
    process.env.VITE_BASE_URL) ||
  "/";

export const ALLOWED_ORIGINS = [
  ...(process.env.NODE_ENV === "production" ? [] : ["http://localhost:3012"]),
  "https://qa-rpa.yunat.com",
  "https://qa-syyj-workbench.shuyun.com",
  "https://rpa.yunat.com",
  "https://syyj-workbench.shuyun.com",
  "https://syyj-customer.shuyuncrm.com",
];
```

### 3.2 部署在哪里、如何部署

项目构建后输出到 `chat` 目录，Dockerfile 使用 nginx 镜像，把 `chat` 目录复制到 `/usr/share/nginx/html/chat`。

代码证据来自 `Dockerfile`：

```dockerfile
FROM hub.shuyun.com/base/nginx:1.12-alpine
MAINTAINER "<jcss@shuyun.com>"

COPY chat /usr/share/nginx/html/chat
COPY nginx.conf /etc/nginx/conf.d/

EXPOSE 8080
```

nginx 配置来自 `nginx.conf`：

```nginx
server {
    listen       8080;
    server_name  localhost;

     location /chat/ {
        root   /usr/share/nginx/html/;
        add_header Cache-Control "no-cache, no-store";
        index  index.html index.htm;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}
```

我会这样回答部署：

- 前端产物作为静态资源部署在 nginx 容器中。
- 应用路径是 `/chat/`，不是根路径。
- 运行时通过 `VITE_BASE_URL` 指向 QA 或生产后端。
- 静态入口 `Cache-Control: no-cache, no-store`，避免 HTML 或入口文件缓存导致版本错乱。
- JS/CSS chunk 使用 hash 文件名，适合长缓存；入口文件不缓存，适合灰度和回滚。

如果面试官追问“为什么入口不缓存”，我会说：聊天工作台属于强业务系统，入口文件缓存过久容易导致前端和后端协议不一致；真正适合缓存的是 hash 资源。

### 3.3 前后端通信方式

前后端通信分为三类：

1. HTTP 请求：用于登录态数据、会话列表、历史消息、群信息、设置、素材等。
2. WebSocket / Socket.IO：用于会话更新、消息推送、通知、机器人离线等实时事件。
3. postMessage：用于当前页面与外部工具页/iframe 通信。

HTTP 请求例子：历史消息接口来自 `src/store/thunks/chat/init.ts`：

```ts
const fetchHistory = async (session: Session, limit: number) => {
  const res = await http.post<API.Record[]>(
    "/pcrm-chat-web/v1/chat/msg/history",
    {
      chatId: session.chatId,
      seq: undefined,
      limit,
    },
  );
  const records = res ? (res.map(transformMessage) as Record[]) : [];

  return {
    hasMoreHistory: records.length >= limit,
    records,
  };
};
```

Socket 请求例子：Worker 建立 WebSocket 通道来自 `src/common/socket/lib.ts`：

```ts
socket = io(getSocketUrl(config), {
  path: "/pcrm-chat-socket/v1/im",
  query: {
    token: config.token,
  },
  autoConnect: false,
  transports: ["websocket"],
  timeout: 20000,
  reconnection: true,
  reconnectionAttempts: Infinity,
  reconnectionDelay: 5000,
  reconnectionDelayMax: 20000,
});
```

发送消息链路来自 `src/store/thunks/chat/sendMessage.ts`：

```ts
export const sendMessage = createAsyncThunk(
  "chat/sendMessage",
  async (message: Message, { getState, dispatch }) => {
    const rootState = getState() as RootState;
    const session = selectSession(rootState);
    const botInfo = selectBotInfo(rootState);
    const kefuId = selectKefuId(rootState);
    const kefuName = selectKefuName(rootState);

    if (!session) {
      throw new Error("Session not available");
    }

    if (!botInfo) {
      throw new Error("Bot info not available");
    }

    const newMessage: Message = {
      ...message,
      id: generateMessageId(),
      timestamp: Date.now(),
      status: "sending",
      failedReason: undefined,
      kefu: {
        kefuId: kefuId as number,
        name: kefuName || "",
      },
    };

    await dispatch(emitMessage({ chatInfo: session, message: newMessage }));

    dispatch(addMessage(newMessage));
    upsertChatCacheMessage(session, newMessage, { createIfMissing: true });
  }
);
```

这段可以说明前端采用了乐观更新：先生成本地消息 id 和 `sending` 状态，发出 socket/接口后立即加入 UI 和缓存，后续再根据服务端回执更新状态。

### 3.4 核心数据结构

#### 3.4.1 Bot / Kefu

代码证据来自 `src/types/role.ts`：

```ts
export enum ServiceType {
  MANUAL_SERVICE = "MANUAL_SERVICE",
  AI_SERVICE = "AI_SERVICE",
}

export interface Bot {
  botId: string;
  nickName: string;
  avatar: string;
  unreadCnt: number;
  status: "online" | "offline";
  rpaCorpId: string;
  region: string;
  realName?: string;
  serviceType?: ServiceType;
  isSupportInnerChat?: boolean;
}

export interface Kefu {
  kefuId: number;
  name: string;
  groupName?: string;
  avatar?: string;
  receptionNum?: number;
  currentNum?: number;
  loginStatus?: "ONLINE" | "OFFLINE";
  status?: "WORKING" | "BUSY" | "DISTURB" | "OFF_WORK" | "UNKNOWN";
  isSupportInnerChat?: boolean;
}
```

说明：系统同时支持人工客服和 AI 服务号，客服身份和机器人身份是两个独立实体。

#### 3.4.2 Session

代码证据来自 `src/types/conversation.ts`：

```ts
export interface Session extends Pick<Bot, "botId" | "rpaCorpId" | "serviceType"> {
  activeLastSeq: number;
  chatId: string;
  chatSource?: "INTERNAL" | "EXTERNAL";
  kefuId?: number;
  mentionMe: number;
  chatStatus: "serve" | "waiting" | "over";
  chatType: 0 | 1;
  extUserAvatar?: string;
  extUserId?: string;
  extUserName?: string;
  nickName?: string;
  alias?: string;
  remark?: string;
  roomId?: string;
  roomTopic?: string;
  roomAvatar?: string;
  roomOwner?: boolean;
  lastMsgAbstracts: string;
  lastMsgTime?: string;
  maxSeq: number;
  readLastSeq: number;
  replied: number;
  unreadCnt: number;
  extUserCorpName?: string;
  draft?: boolean;
}
```

我会重点解释：

- `chatId` 是会话 id。
- `botId` 区分不同机器人账号。
- `chatType` 区分私聊和群聊。
- `maxSeq/readLastSeq/unreadCnt` 是消息同步和未读计算的关键。
- `chatStatus` 决定会话在“已分配/排队中/已结束”中的流转。

#### 3.4.3 Message / Record

代码证据来自 `src/types/chat.ts`：

```ts
export interface Message {
  id: string;
  timestamp: number;
  status?: "sending" | "success" | "failed";
  failedReason?: string;
  seq?: number;
  kefu: Kefu;
  sender: RoomMember;
  body: MessageBody;
  source?: Source;
}

export interface Record {
  id: string;
  serverId?: number;
  type: "customer" | "service" | "system" | "virtual";
  revoked?: boolean;
  contentType?: MessageType;
  content: {
    text?: string;
    image?: ImageData;
    file?: FileData;
    voice?: VoiceData;
    video?: VideoData;
    weapp?: WeappData;
    link?: LinkData;
    emotion?: EmotionData;
    location?: LocationData;
    businessCard?: BusinessCardData;
    videoApp?: VideoAppData;
    redEnvelopes?: RedEnvelopesData;
    chatRecord?: ChatRecordsData;
    virtual?: VirtualCardData;
    atList?: RoomMember[];
    hasQuoting?: boolean;
    quotedMessage?: Record;
    notice?: string;
  };
  timestamp: number;
  source?: Source;
  status?: "sending" | "success" | "failed";
  seq: number;
  kefu?: Kefu;
  sender: RoomMember;
}
```

这里说明消息模型很复杂，不只是文本。它覆盖图片、文件、语音、视频、小程序、链接、表情、位置、名片、视频号、红包、聊天记录、虚拟卡片、引用消息和 @ 列表。

### 3.5 页面结构与核心工作流

页面布局由 `Layout -> Header/Main -> BotList/List/Chat` 组成。

代码证据来自 `src/features/layout/ui/index.tsx`：

```tsx
const Layout: React.FC = () => {
  const { connected, launched, error } = useSelector(selectAppStatus);

  useEffect(() => {
    const socket = initSocketClient();

    const unlock = notificationService.setupAudioUnlockOnFirstInteraction();

    return () => {
      socket.reset();
      unlock();
    };
  }, []);

  return (
    <>
      <UnreadTitleEffect />
      <div className={styles.app}>
        <Header />
        {launched ? <Main /> : null}
        {renderStatus()}
      </div>
    </>
  );
};
```

主工作区来自 `src/features/layout/ui/Main/index.tsx`：

```tsx
const Main: React.FC = () => {
  const { activeTab, kefuId, kefuName, serveMode, currentSession } =
    useSelector(selectMainState);

  const renderChat = () =>
    currentSession ? (
      <Chat session={currentSession} />
    ) : (
      <div className={styles.welcome}>
        <div className={styles.banner}>
          <img src="images/welcome.svg" alt="welcome" />
          <span>
            {getGreeting()}，{displayedKefuName}，开始今天的工作吧~
          </span>
        </div>
      </div>
    );

  return (
    <div className={styles.container}>
      {kefuId && serveMode !== undefined && (
        <BotList serveMode={serveMode} onChangeAccount={() => handleAccountChange()} />
      )}
      <div className={styles.main}>
        <List ref={listRef} onSelectContactItem={setContactsDetails} />
        <div className={styles.content}>
          {activeTab === "3" ? renderContactContent() : renderChat()}
        </div>
      </div>
    </div>
  );
};
```

这对应用户使用路径：

1. 页面加载，初始化 socket。
2. 登录成功后初始化客服、机器人、会话列表等特性数据。
3. 左侧选择机器人和会话分组。
4. 中间会话列表用虚拟列表展示。
5. 右侧聊天窗口初始化历史、上下文、群成员。
6. Socket 推送实时进入会话列表和当前聊天窗口。
7. 本地缓存持续写入，支撑下次快速打开。

## 4. 如果面试官继续追问，我会补充的取舍

### 4.1 为什么不用纯 REST 轮询？

客服聊天系统需要实时性，轮询会带来延迟和无效请求。Socket.IO 能让会话更新、消息、通知被服务端主动推送。但 Socket 不能替代 HTTP：历史消息、群详情、配置类接口仍然更适合 HTTP。因此项目采用 HTTP + WebSocket 的混合架构。

### 4.2 为什么 Socket 要放 Worker？

因为消息推送是高频事件源，如果连接管理、重连、队列、可见性处理都在主线程做，容易和 React 渲染、输入框、滚动产生竞争。Worker 隔离后，主线程只消费序列化后的业务事件。

### 4.3 为什么需要 IndexedDB？

localStorage 容量小、同步阻塞主线程，不适合大量聊天消息。IndexedDB 异步、容量更大，适合保存多会话历史、群成员和上下文快照。

### 4.4 为什么缓存要分内存和持久化两层？

内存缓存读取最快，适合当前工作集；IndexedDB 适合跨刷新和长时间保留。只用 IndexedDB 会增加频繁异步读写成本；只用内存缓存刷新后丢失，用户体验差。

### 4.5 为什么要做消息合并去重？

同一条消息可能来自乐观发送、Socket 回执、历史接口、重连补偿、缓存恢复。如果没有 id-based merge，容易出现重复消息、顺序错乱或状态回退。

### 4.6 这个系统最难的点是什么？

我认为最难的是“实时性、性能、一致性”的三角平衡：

- 实时性要求 Socket 推送马上响应。
- 性能要求不能每条推送都触发大范围重渲染。
- 一致性要求历史、实时、缓存、重连后的数据不能互相覆盖。

所以系统才需要 Worker 队列、inbox 聚合、消息去重、缓存分层、初始化策略和虚拟列表等一整套设计。

## 5. 可用于面试分享的收束版本

如果面试现场需要压缩到 2-3 分钟，我会这样讲：

我做过最复杂的系统是一个私域聚合聊天工作台。技术栈是 React 18、TypeScript、Redux Toolkit、Vite/Webpack、Socket.IO、Web Worker、IndexedDB、react-virtuoso、axios、Ant Design。它的复杂点在于实时消息、高并发会话列表、多机器人/多客服身份、历史消息缓存、未读跳转和外部工具页集成。

架构上我把它拆成 UI 层、Redux 领域状态层、HTTP/Socket 基础设施层、本地缓存层和外部 SDK 层。Socket 不直接在组件里消费，而是封装成 `SocketClient`，真正连接放到 Worker 里；Worker 负责断网、切后台、重连和消息队列，主线程通过事件表把推送转换成 Redux action。会话更新再经过 `inboxAggregation` 微批处理，降低高频推送对列表渲染的影响。

数据结构上，核心实体是 `Bot`、`Kefu`、`Session`、`Message/Record`、`ChatCacheEntry`。会话用 `botId + chatId` 做身份，消息用 `id + seq` 做合并和排序。缓存上采用内存 LRU + IndexedDB 两层，初始化会话时根据 `sessionMaxSeq` 判断直接复用、后台补齐、快照回填还是接口拉取。部署上构建产物输出到 `/chat/`，通过 nginx 容器托管，运行时用 `VITE_BASE_URL` 区分 QA 和生产环境。

这套系统的核心价值是：在大量实时消息下，既保证客服能及时看到消息，又通过 Worker、批处理、缓存和虚拟列表保证 UI 稳定、切换会话快、重连后数据不乱。

