{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-web/dr-card-web/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-web/dr-card-web/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/ec4406835bc4e25a78ff4e42c7047156dc6d6db7",
        "commitHash": "ec4406835bc4e25a78ff4e42c7047156dc6d6db7",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/262",
        "prTitle": "fix(chat): connect the documented Socket.IO namespace",
        "prBaseHash": "6fb237b7195e0b02b20ca3f554b99489c3e26e86",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/40054211718"
      },
      "gitCommit": {
        "shortHash": "ec44068",
        "hash": "ec4406835bc4e25a78ff4e42c7047156dc6d6db7",
        "subject": "Merge 30c629264ecbf6cb65884e54dc0bdad031ac9a40 into 6fb237b7195e0b02b20ca3f554b99489c3e26e86",
        "body": "Merge 30c629264ecbf6cb65884e54dc0bdad031ac9a40 into 6fb237b7195e0b02b20ca3f554b99489c3e26e86\n",
        "author": {
          "name": "SINGING-AKIBA",
          "email": "980325684@qq.com",
          "time": 1792808822000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1792808822000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/docs/api/chat-service-contract-20261009.md b/docs/api/chat-service-contract-20261009.md\nindex a0c0e25..3826fe3 100644\n--- a/docs/api/chat-service-contract-20261009.md\n+++ b/docs/api/chat-service-contract-20261009.md\n@@ -1,28 +1,102 @@\n-# 客服接口最新合同：2026-10-09\n+# H5 客服接口与实时消息合同\n \n-> **权威性与版本**：本文件依据后台同事当前交付的源码核对，不是根据旧 Swagger 猜测。证据文件位于项目工作区的 `API/` 仓库，核对日期 2026-10-09。旧快照 `docs/api/dearseed-openapi.json` 抓取于 **2026-09-16**，其 `ChatMessagesSave` 属已下线的 CRUD 形式，**不能作为当前用户端客服的入参定义**。当前后台若继续调整，需要重新核对源码和联调。\n+> **更新日期：2026-10-24。** 本文记录 H5 当前接入应遵守的移动端接口合同，重点补充 SSE 错误处理、转人工前的 Socket 连接顺序，以及 Socket.IO 命名空间与 Engine.IO transport path 的区别。若后端部署版本与本文不一致，应由前后端联调确认并同步修订文档，不要仅凭旧 OpenAPI 快照推断运行时行为。\n \n-## 后端源码证据\n+## 1. 通用认证与身份安全\n \n-- `API/src/app/api/validate/ChatMessages.ts`：`ChatMessagesAdd` 只定义 **必填 `content`（1–5000 字）**、可选 `msg_type`（1–6、8；默认 1），不接受客户端 `user_id` 和 `sender_id`；`ChatMessagesTransfer` 的 `content` 可选。\n-- `API/src/app/api/controller/ChatMessages.ts`：`POST /api/chatmessages/add` 从 `service.userId()` 取得用户身份，存储用户消息，然后返回 SSE `text/human/done/error`；`POST /api/chatmessages/transfer` 存真实转人工状态，正常走 JSON envelope；`GET /api/chatmessages/index` 提供历史。\n-- `API/src/app/api/socket/ChatSocket.ts`：默认命名空间 `/`，`auth.token` 握手，服务端解析登录用户并加入自己的 room；鉴权不通过推 `error` 并断开。\n-- `API/src/app/common/service/Chat.ts`：人工坐席推送事件 `chat:message`。推送仅收不发，客户端发送仍走 HTTP。\n-- `API/docs/api-service-chat.md`：后端提供的综合客服 API/Socket.IO/SSE 联调文档。\n+- 所有以下 HTTP 接口均使用 `Authorization: Bearer <accessToken>`。\n+- Socket.IO 握手通过 `auth: { token: accessToken }` 传递 token；不要把 token 放在 URL 或 query string。\n+- H5 不传 `user_id` 或 `sender_id`，用户身份由服务端根据 token 确定。\n+- Socket 鉴权失败时，服务端可能先发送 `error` 再断开；前端应进入未授权状态并停止无限重连。token 更新后，使用新 token 重建 socket。\n \n-## 用户端接口定义\n+## 2. 发送消息：SSE\n \n-| 接口 | 请求 | 认证 | 成功 |\n-|---|---|---|---|\n-| `GET /api/chatmessages/index?page=1&pageSize=30` | 查询参数 `page, pageSize` | Bearer token | `{code:0,data:{data:[],current_page,per_page,total,last_page}}` |\n-| `POST /api/chatmessages/add` | `{content:string,msg_type?:number}` | Bearer token | `text/event-stream`；`text` 增量、`human` 模式、`done.message_id`、`error.message` |\n-| `POST /api/chatmessages/transfer` | `{content?:string}` | Bearer token | `{code:0,data:{mode:\"human\"}}` |\n-| `Socket.IO /` | `auth:{token:accessToken}` | 握手令牌 | `chat:message` 推送完整存储记录 |\n+`POST /api/chatmessages/add`\n \n-安全约束：**不允许由 H5 决定用户身份**；`user_id/sender_id` 服务端依据 token 写入。旧 OpenAPI 里 `ChatMessagesSave.required=[\"user_id\",\"sender_id\",\"msg_type\"]` 是 9 月旧 CRUD 合同，不符合当前控制器 DTO，**优先当前后台源码定义**。不要为通过旧文档的验证而传入可伪造身份。\n+请求头：\n \n-Socket `chat:message` 返回 `id, user_id, sender_id, content, msg_type, create_time`；时间戳为秒级数字，历史接口则可能返回 SQL 字符串。均以服务端 ID 排序去重。重连后 `GET index` 补消息，推送本身不补发。\n+```http\n+Authorization: Bearer <accessToken>\n+Accept: text/event-stream\n+Content-Type: application/json\n+```\n \n-## 验收边界\n+请求体示例：\n \n-本文件证明**当前交付的后端源码存在这套合同**，不证明阿里云 / 测试环境已部署相同版本、Redis 服务与真实人工坐席正在运行。正式完成要用授权账号在目标测试环境完成 `/add`、`/transfer`、后台 `/admin/chatmessages/send` → H5 Socket `chat:message`，以及 Android/iOS WebView 断线重连验收。人工客服未在线不应伪造排队人数、等待时长或姓名。\n+```json\n+{ \"content\": \"我想咨询一下订单\" }\n+```\n+\n+- 使用 `fetch` 发起 POST 并读取 SSE 流；不要使用只适合 GET 的原生 `EventSource`。\n+- 不需要提交对话历史、`user_id` 或 `sender_id`。\n+- 识别以 `data:` 开头的 SSE 行；忽略注释/心跳行（例如 `: ok`）以及其他非 `data:` 行。\n+- 流内事件：\n+  - `text`：AI 回复的增量文本。\n+  - `human`：对话已进入人工服务模式。\n+  - `done`：本次生成结束；AI 消息的 `message_id` 可能存在。\n+  - `error`：流内业务错误，应解析并向用户显示可理解的错误信息。\n+- 在 SSE 流开始前，认证或参数校验失败可能返回普通 JSON，而非 `text/event-stream`。客户端必须先检查 HTTP 状态及响应内容类型，并兼容解析普通 JSON 错误，不能将其误判为 SSE 数据。\n+\n+## 3. 转人工\n+\n+`POST /api/chatmessages/transfer`\n+\n+请求体（可选）：\n+\n+```json\n+{ \"content\": \"最近一条用户消息（可选）\" }\n+```\n+\n+- 调用转人工接口前，先确保 Socket.IO 已连接；连接未成功时不要先提交转人工请求。\n+- 成功响应的业务数据应满足 `data.mode === \"human\"`。\n+- 转人工接口负责切换服务模式；人工坐席的后续回复通过 Socket.IO 的 `chat:message` 事件接收。用户发送消息仍走 HTTP，不通过 Socket.IO 发送。\n+\n+## 4. 接收人工消息：Socket.IO\n+\n+### 4.1 Namespace 与 transport path 是两个概念\n+\n+客户端必须连接 **`/socket.io` namespace**：\n+\n+```ts\n+io('https://api.example.com/socket.io', {\n+  auth: { token: accessToken },\n+  // Engine.IO transport path；它与上面的 namespace URL 不同\n+  path: '/socket.io/',\n+})\n+```\n+\n+- `io('https://api.example.com')` 连接的是默认 `/` namespace，不符合本 H5 接口合同。\n+- URL 中的 `/socket.io` 是 Socket.IO namespace。\n+- 配置项 `path` 是 Engine.IO HTTP/WebSocket transport path，默认常见值为 `/socket.io/`；若部署环境配置了反向代理路径，应按环境配置单独设置，不能用它替代 namespace。\n+- 只监听 `chat:message` 接收人工坐席消息；客户端发送消息仍使用 HTTP API。\n+\n+### 4.2 消息格式、排序与去重\n+\n+`chat:message` 推送记录包含服务端消息标识 `id`、`user_id`、`sender_id`、`content`、`msg_type`、`create_time` 等字段。\n+\n+- Socket 推送中的 `create_time` 是秒级时间戳；历史接口中的 `create_time` 可能是字符串。\n+- 消息以服务端 `id` 进行排序和去重；合并历史记录与实时推送时不能重复展示同一条消息。\n+- Socket 断线期间的消息不会自动重放。每次成功重连后，应重新调用历史接口补齐离线期间遗漏的消息。\n+- token 失效时不要无限重试；刷新 token 后应销毁旧连接并用新 token 创建连接。\n+\n+## 5. 拉取历史消息\n+\n+`GET /api/chatmessages/index?page=1&pageSize=15`\n+\n+- 使用 Bearer token。\n+- 消息列表位于 `data.data`；接口按 `id` 倒序返回，前端合并后按消息 ID 排序显示。\n+- 历史数据包含用户、客服及系统消息；与 Socket 推送按 `id` 去重。\n+- 建立连接后的首次加载，以及每次成功重连后，都应拉取历史以补齐可能遗漏的消息。\n+\n+## 6. 推荐流程\n+\n+1. 页面加载后获取历史消息，并建立 Socket.IO `/socket.io` namespace 连接。\n+2. 用户发送内容时，调用 `POST /api/chatmessages/add` 并消费 SSE。\n+3. 当用户选择转人工时，先确认 Socket 已连接，再调用 `POST /api/chatmessages/transfer`。\n+4. 收到 `chat:message` 后按消息 `id` 去重并更新会话列表。\n+5. Socket 成功重连后重新拉取 `GET /api/chatmessages/index`，补齐断线期间未收到的消息。\n+6. 对 SSE 流开始前的 JSON 错误、流内 `error` 事件及 Socket 鉴权失败分别处理，避免把连接成功误认为业务消息链路正常。\n+\n+## 7. 验收边界\n+\n+自动化测试可验证 URL namespace、transport path、token 传递、消息去重和错误处理逻辑；它不能证明目标测试环境已经部署匹配的后端，也不能替代真实坐席联调。上线/验收前应在目标环境使用授权账号完成 AI SSE 回复、转人工、坐席回复推送、断线重连补历史及 Android/iOS WebView 验证。\n\\ No newline at end of file\ndiff --git a/src/services/chatHuman.test.ts b/src/services/chatHuman.test.ts\nindex 90eee81..bb20b1c 100644\n--- a/src/services/chatHuman.test.ts\n+++ b/src/services/chatHuman.test.ts\n@@ -44,10 +44,10 @@ beforeEach(() => {\n })\n \n describe('#138 Socket.IO client contract', () => {\n-  it('connects same-origin default namespace using auth.token, never URL/query credentials', () => {\n+  it('connects the /socket.io namespace using auth.token, never URL/query credentials', () => {\n     const status = vi.fn(), onMessage = vi.fn(), onConnected = vi.fn()\n     const handle = connectChatSocket('secret-token', { onStatus: status, onMessage, onConnected })\n-    expect(mocks.io).toHaveBeenCalledWith('https://api.example.test', expect.objectContaining({\n+    expect(mocks.io).toHaveBeenCalledWith('https://api.example.test/socket.io', expect.objectContaining({\n       auth: { token: 'secret-token' }, path: '/socket.io/', autoConnect: false, reconnection: true,\n     }))\n     expect(mocks.socket.connect).toHaveBeenCalledOnce()\n@@ -75,7 +75,7 @@ describe('#138 Socket.IO client contract', () => {\n     mocks.runtime.socketBaseUrl = 'https://socket.example.test'\n     mocks.runtime.socketPath = '/realtime/socket.io/'\n     connectChatSocket('secret-token', { onStatus: vi.fn(), onMessage: vi.fn(), onConnected: vi.fn() })\n-    expect(mocks.io).toHaveBeenCalledWith('https://socket.example.test', expect.objectContaining({\n+    expect(mocks.io).toHaveBeenCalledWith('https://socket.example.test/socket.io', expect.objectContaining({\n       path: '/realtime/socket.io/',\n       auth: { token: 'secret-token' },\n     }))\ndiff --git a/src/services/chatHuman.ts b/src/services/chatHuman.ts\nindex 7c1aa6a..cb0a9c0 100644\n--- a/src/services/chatHuman.ts\n+++ b/src/services/chatHuman.ts\n@@ -6,6 +6,7 @@ import { AppError, createBusinessError, createContractError } from './http'\n import { signCardApiUrl } from './http/apiSignature'\n \n export const CHAT_TRANSFER_PATH = '/api/chatmessages/transfer'\n+export const CHAT_SOCKET_NAMESPACE = '/socket.io'\n const transferResultSchema = { mode: 'human' } as const\n \n export type ChatSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'unauthorized'\n@@ -22,7 +23,7 @@ export function connectChatSocket(token: string, callbacks: ChatSocketEvents): C\n   if (!token) throw new AppError({ kind: 'http', status: 401, message: '请先登录' })\n   const base = runtimePolicy.apiBaseUrl\n   if (!base) throw new AppError({ kind: 'configuration', message: '客服连接地址尚未配置' })\n-  const socket: Socket = io(runtimePolicy.socketBaseUrl ?? new URL(base).origin, {\n+  const socket: Socket = io(`${runtimePolicy.socketBaseUrl ?? new URL(base).origin}${CHAT_SOCKET_NAMESPACE}`, {\n     path: runtimePolicy.socketPath ?? '/socket.io',\n     auth: { token },\n     autoConnect: false,",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-web/dr-card-web/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/ec4406835bc4e25a78ff4e42c7047156dc6d6db7",
            "commitHash": "ec4406835bc4e25a78ff4e42c7047156dc6d6db7",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/262",
            "prTitle": "fix(chat): connect the documented Socket.IO namespace",
            "prBaseHash": "6fb237b7195e0b02b20ca3f554b99489c3e26e86",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/40054211718"
          },
          "gitCommit": {
            "shortHash": "ec44068",
            "hash": "ec4406835bc4e25a78ff4e42c7047156dc6d6db7",
            "subject": "Merge 30c629264ecbf6cb65884e54dc0bdad031ac9a40 into 6fb237b7195e0b02b20ca3f554b99489c3e26e86",
            "body": "Merge 30c629264ecbf6cb65884e54dc0bdad031ac9a40 into 6fb237b7195e0b02b20ca3f554b99489c3e26e86\n",
            "author": {
              "name": "SINGING-AKIBA",
              "email": "980325684@qq.com",
              "time": 1792808822000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1792808822000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/docs/api/chat-service-contract-20261009.md b/docs/api/chat-service-contract-20261009.md\nindex a0c0e25..3826fe3 100644\n--- a/docs/api/chat-service-contract-20261009.md\n+++ b/docs/api/chat-service-contract-20261009.md\n@@ -1,28 +1,102 @@\n-# 客服接口最新合同：2026-10-09\n+# H5 客服接口与实时消息合同\n \n-> **权威性与版本**：本文件依据后台同事当前交付的源码核对，不是根据旧 Swagger 猜测。证据文件位于项目工作区的 `API/` 仓库，核对日期 2026-10-09。旧快照 `docs/api/dearseed-openapi.json` 抓取于 **2026-09-16**，其 `ChatMessagesSave` 属已下线的 CRUD 形式，**不能作为当前用户端客服的入参定义**。当前后台若继续调整，需要重新核对源码和联调。\n+> **更新日期：2026-10-24。** 本文记录 H5 当前接入应遵守的移动端接口合同，重点补充 SSE 错误处理、转人工前的 Socket 连接顺序，以及 Socket.IO 命名空间与 Engine.IO transport path 的区别。若后端部署版本与本文不一致，应由前后端联调确认并同步修订文档，不要仅凭旧 OpenAPI 快照推断运行时行为。\n \n-## 后端源码证据\n+## 1. 通用认证与身份安全\n \n-- `API/src/app/api/validate/ChatMessages.ts`：`ChatMessagesAdd` 只定义 **必填 `content`（1–5000 字）**、可选 `msg_type`（1–6、8；默认 1），不接受客户端 `user_id` 和 `sender_id`；`ChatMessagesTransfer` 的 `content` 可选。\n-- `API/src/app/api/controller/ChatMessages.ts`：`POST /api/chatmessages/add` 从 `service.userId()` 取得用户身份，存储用户消息，然后返回 SSE `text/human/done/error`；`POST /api/chatmessages/transfer` 存真实转人工状态，正常走 JSON envelope；`GET /api/chatmessages/index` 提供历史。\n-- `API/src/app/api/socket/ChatSocket.ts`：默认命名空间 `/`，`auth.token` 握手，服务端解析登录用户并加入自己的 room；鉴权不通过推 `error` 并断开。\n-- `API/src/app/common/service/Chat.ts`：人工坐席推送事件 `chat:message`。推送仅收不发，客户端发送仍走 HTTP。\n-- `API/docs/api-service-chat.md`：后端提供的综合客服 API/Socket.IO/SSE 联调文档。\n+- 所有以下 HTTP 接口均使用 `Authorization: Bearer <accessToken>`。\n+- Socket.IO 握手通过 `auth: { token: accessToken }` 传递 token；不要把 token 放在 URL 或 query string。\n+- H5 不传 `user_id` 或 `sender_id`，用户身份由服务端根据 token 确定。\n+- Socket 鉴权失败时，服务端可能先发送 `error` 再断开；前端应进入未授权状态并停止无限重连。token 更新后，使用新 token 重建 socket。\n \n-## 用户端接口定义\n+## 2. 发送消息：SSE\n \n-| 接口 | 请求 | 认证 | 成功 |\n-|---|---|---|---|\n-| `GET /api/chatmessages/index?page=1&pageSize=30` | 查询参数 `page, pageSize` | Bearer token | `{code:0,data:{data:[],current_page,per_page,total,last_page}}` |\n-| `POST /api/chatmessages/add` | `{content:string,msg_type?:number}` | Bearer token | `text/event-stream`；`text` 增量、`human` 模式、`done.message_id`、`error.message` |\n-| `POST /api/chatmessages/transfer` | `{content?:string}` | Bearer token | `{code:0,data:{mode:\"human\"}}` |\n-| `Socket.IO /` | `auth:{token:accessToken}` | 握手令牌 | `chat:message` 推送完整存储记录 |\n+`POST /api/chatmessages/add`\n \n-安全约束：**不允许由 H5 决定用户身份**；`user_id/sender_id` 服务端依据 token 写入。旧 OpenAPI 里 `ChatMessagesSave.required=[\"user_id\",\"sender_id\",\"msg_type\"]` 是 9 月旧 CRUD 合同，不符合当前控制器 DTO，**优先当前后台源码定义**。不要为通过旧文档的验证而传入可伪造身份。\n+请求头：\n \n-Socket `chat:message` 返回 `id, user_id, sender_id, content, msg_type, create_time`；时间戳为秒级数字，历史接口则可能返回 SQL 字符串。均以服务端 ID 排序去重。重连后 `GET index` 补消息，推送本身不补发。\n+```http\n+Authorization: Bearer <accessToken>\n+Accept: text/event-stream\n+Content-Type: application/json\n+```\n \n-## 验收边界\n+请求体示例：\n \n-本文件证明**当前交付的后端源码存在这套合同**，不证明阿里云 / 测试环境已部署相同版本、Redis 服务与真实人工坐席正在运行。正式完成要用授权账号在目标测试环境完成 `/add`、`/transfer`、后台 `/admin/chatmessages/send` → H5 Socket `chat:message`，以及 Android/iOS WebView 断线重连验收。人工客服未在线不应伪造排队人数、等待时长或姓名。\n+```json\n+{ \"content\": \"我想咨询一下订单\" }\n+```\n+\n+- 使用 `fetch` 发起 POST 并读取 SSE 流；不要使用只适合 GET 的原生 `EventSource`。\n+- 不需要提交对话历史、`user_id` 或 `sender_id`。\n+- 识别以 `data:` 开头的 SSE 行；忽略注释/心跳行（例如 `: ok`）以及其他非 `data:` 行。\n+- 流内事件：\n+  - `text`：AI 回复的增量文本。\n+  - `human`：对话已进入人工服务模式。\n+  - `done`：本次生成结束；AI 消息的 `message_id` 可能存在。\n+  - `error`：流内业务错误，应解析并向用户显示可理解的错误信息。\n+- 在 SSE 流开始前，认证或参数校验失败可能返回普通 JSON，而非 `text/event-stream`。客户端必须先检查 HTTP 状态及响应内容类型，并兼容解析普通 JSON 错误，不能将其误判为 SSE 数据。\n+\n+## 3. 转人工\n+\n+`POST /api/chatmessages/transfer`\n+\n+请求体（可选）：\n+\n+```json\n+{ \"content\": \"最近一条用户消息（可选）\" }\n+```\n+\n+- 调用转人工接口前，先确保 Socket.IO 已连接；连接未成功时不要先提交转人工请求。\n+- 成功响应的业务数据应满足 `data.mode === \"human\"`。\n+- 转人工接口负责切换服务模式；人工坐席的后续回复通过 Socket.IO 的 `chat:message` 事件接收。用户发送消息仍走 HTTP，不通过 Socket.IO 发送。\n+\n+## 4. 接收人工消息：Socket.IO\n+\n+### 4.1 Namespace 与 transport path 是两个概念\n+\n+客户端必须连接 **`/socket.io` namespace**：\n+\n+```ts\n+io('https://api.example.com/socket.io', {\n+  auth: { token: accessToken },\n+  // Engine.IO transport path；它与上面的 namespace URL 不同\n+  path: '/socket.io/',\n+})\n+```\n+\n+- `io('https://api.example.com')` 连接的是默认 `/` namespace，不符合本 H5 接口合同。\n+- URL 中的 `/socket.io` 是 Socket.IO namespace。\n+- 配置项 `path` 是 Engine.IO HTTP/WebSocket transport path，默认常见值为 `/socket.io/`；若部署环境配置了反向代理路径，应按环境配置单独设置，不能用它替代 namespace。\n+- 只监听 `chat:message` 接收人工坐席消息；客户端发送消息仍使用 HTTP API。\n+\n+### 4.2 消息格式、排序与去重\n+\n+`chat:message` 推送记录包含服务端消息标识 `id`、`user_id`、`sender_id`、`content`、`msg_type`、`create_time` 等字段。\n+\n+- Socket 推送中的 `create_time` 是秒级时间戳；历史接口中的 `create_time` 可能是字符串。\n+- 消息以服务端 `id` 进行排序和去重；合并历史记录与实时推送时不能重复展示同一条消息。\n+- Socket 断线期间的消息不会自动重放。每次成功重连后，应重新调用历史接口补齐离线期间遗漏的消息。\n+- token 失效时不要无限重试；刷新 token 后应销毁旧连接并用新 token 创建连接。\n+\n+## 5. 拉取历史消息\n+\n+`GET /api/chatmessages/index?page=1&pageSize=15`\n+\n+- 使用 Bearer token。\n+- 消息列表位于 `data.data`；接口按 `id` 倒序返回，前端合并后按消息 ID 排序显示。\n+- 历史数据包含用户、客服及系统消息；与 Socket 推送按 `id` 去重。\n+- 建立连接后的首次加载，以及每次成功重连后，都应拉取历史以补齐可能遗漏的消息。\n+\n+## 6. 推荐流程\n+\n+1. 页面加载后获取历史消息，并建立 Socket.IO `/socket.io` namespace 连接。\n+2. 用户发送内容时，调用 `POST /api/chatmessages/add` 并消费 SSE。\n+3. 当用户选择转人工时，先确认 Socket 已连接，再调用 `POST /api/chatmessages/transfer`。\n+4. 收到 `chat:message` 后按消息 `id` 去重并更新会话列表。\n+5. Socket 成功重连后重新拉取 `GET /api/chatmessages/index`，补齐断线期间未收到的消息。\n+6. 对 SSE 流开始前的 JSON 错误、流内 `error` 事件及 Socket 鉴权失败分别处理，避免把连接成功误认为业务消息链路正常。\n+\n+## 7. 验收边界\n+\n+自动化测试可验证 URL namespace、transport path、token 传递、消息去重和错误处理逻辑；它不能证明目标测试环境已经部署匹配的后端，也不能替代真实坐席联调。上线/验收前应在目标环境使用授权账号完成 AI SSE 回复、转人工、坐席回复推送、断线重连补历史及 Android/iOS WebView 验证。\n\\ No newline at end of file\ndiff --git a/src/services/chatHuman.test.ts b/src/services/chatHuman.test.ts\nindex 90eee81..bb20b1c 100644\n--- a/src/services/chatHuman.test.ts\n+++ b/src/services/chatHuman.test.ts\n@@ -44,10 +44,10 @@ beforeEach(() => {\n })\n \n describe('#138 Socket.IO client contract', () => {\n-  it('connects same-origin default namespace using auth.token, never URL/query credentials', () => {\n+  it('connects the /socket.io namespace using auth.token, never URL/query credentials', () => {\n     const status = vi.fn(), onMessage = vi.fn(), onConnected = vi.fn()\n     const handle = connectChatSocket('secret-token', { onStatus: status, onMessage, onConnected })\n-    expect(mocks.io).toHaveBeenCalledWith('https://api.example.test', expect.objectContaining({\n+    expect(mocks.io).toHaveBeenCalledWith('https://api.example.test/socket.io', expect.objectContaining({\n       auth: { token: 'secret-token' }, path: '/socket.io/', autoConnect: false, reconnection: true,\n     }))\n     expect(mocks.socket.connect).toHaveBeenCalledOnce()\n@@ -75,7 +75,7 @@ describe('#138 Socket.IO client contract', () => {\n     mocks.runtime.socketBaseUrl = 'https://socket.example.test'\n     mocks.runtime.socketPath = '/realtime/socket.io/'\n     connectChatSocket('secret-token', { onStatus: vi.fn(), onMessage: vi.fn(), onConnected: vi.fn() })\n-    expect(mocks.io).toHaveBeenCalledWith('https://socket.example.test', expect.objectContaining({\n+    expect(mocks.io).toHaveBeenCalledWith('https://socket.example.test/socket.io', expect.objectContaining({\n       path: '/realtime/socket.io/',\n       auth: { token: 'secret-token' },\n     }))\ndiff --git a/src/services/chatHuman.ts b/src/services/chatHuman.ts\nindex 7c1aa6a..cb0a9c0 100644\n--- a/src/services/chatHuman.ts\n+++ b/src/services/chatHuman.ts\n@@ -6,6 +6,7 @@ import { AppError, createBusinessError, createContractError } from './http'\n import { signCardApiUrl } from './http/apiSignature'\n \n export const CHAT_TRANSFER_PATH = '/api/chatmessages/transfer'\n+export const CHAT_SOCKET_NAMESPACE = '/socket.io'\n const transferResultSchema = { mode: 'human' } as const\n \n export type ChatSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'unauthorized'\n@@ -22,7 +23,7 @@ export function connectChatSocket(token: string, callbacks: ChatSocketEvents): C\n   if (!token) throw new AppError({ kind: 'http', status: 401, message: '请先登录' })\n   const base = runtimePolicy.apiBaseUrl\n   if (!base) throw new AppError({ kind: 'configuration', message: '客服连接地址尚未配置' })\n-  const socket: Socket = io(runtimePolicy.socketBaseUrl ?? new URL(base).origin, {\n+  const socket: Socket = io(`${runtimePolicy.socketBaseUrl ?? new URL(base).origin}${CHAT_SOCKET_NAMESPACE}`, {\n     path: runtimePolicy.socketPath ?? '/socket.io',\n     auth: { token },\n     autoConnect: false,",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "line",
        null
      ],
      [
        "html",
        {
          "outputFolder": "playwright-report",
          "open": "never"
        }
      ],
      [
        "json",
        {
          "outputFile": "test-results/playwright-results.json"
        }
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.63.0",
    "workers": 2,
    "webServer": null
  },
  "suites": [
    {
      "title": "test-gate.spec.ts",
      "file": "test-gate.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "@test-gate build identity matches the production-like test policy",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 58,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-24T02:28:06.637Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-c3b6e0ce5d8c5d8734e5",
          "file": "test-gate.spec.ts",
          "line": 31,
          "column": 1
        },
        {
          "title": "@test-gate browser entry does not start the app router",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1163,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-24T02:28:06.653Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8ed37c8041402a2a6b34",
          "file": "test-gate.spec.ts",
          "line": 40,
          "column": 1
        },
        {
          "title": "@test-gate / shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1159,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-24T02:28:06.769Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-b030d57053896c93a550",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /profile shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1033,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-24T02:28:08.421Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8605535a2807e17fcccc",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /settings shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1019,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-24T02:28:08.443Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-4a2fdc9884c1725de4f8",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate gated browser entry issues no business API request",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1048,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-24T02:28:09.474Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-09f38a2b8ed735e192d8",
          "file": "test-gate.spec.ts",
          "line": 65,
          "column": 1
        },
        {
          "title": "@test-gate injected native host bypasses the notice and starts the app",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1111,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-24T02:28:09.481Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-24T02:28:05.783Z",
    "duration": 4874.155,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}