# 站内信升级聊天（1:1 会话）P1 — 6 个管理端接口

- **变更类型**：新增接口
- **端类型**：管理后台
- **日期**：2026-06-19
- **Issue**：[#4020](https://git.1814.love:8443/wx/HL/issues/4020)
- **PR**：[#4028](https://git.1814.love:8443/wx/HL/pulls/4028)（+ hotfix [#4030](https://git.1814.love:8443/wx/HL/pulls/4030) / [#4031](https://git.1814.love:8443/wx/HL/pulls/4031)）
- **后端负责人**：王骁

---

## 🛑 前端暂不需要接入（2026-06-19 更新，最高优先级）

**本次仅「后端 P1」就绪，前端聊天 UI 集成暂缓——收到本文先不用动工。** 本文档是后端接口的**提前留档/参考**，**不是开工通知**；待产品排期、正式通知前端接入时再据此对接。下方接口说明仅供后端联调与未来参考。

---

## ⚠️ 关键说明（前端正式接入时再读）

1. **本期 P1 没有实时推送（SSE）**。聊天靠「发消息 + 拉线程 + 轮询未读」的请求/响应模式工作；实时角标冒泡（顶部铃铛实时 +1、打开的会话 live append 新气泡）是 **P2** 再上。前端正式接入时可用**轮询**（如进入会话时拉线程、定时拉 `unread-total`）先把界面跑起来。
2. **能力归属 user-service，复用现有站内信**。聊天行和系统通知行同表（`admin_message`），顶部**合并未读角标**（聊天+通知合计）继续走现有 `GET /admin/message/unread-count`，本次**不变**；本期新增的 `GET /admin/message/chat/unread-total` 只给「聊天 Tab」单独展示聊天未读数。
3. **一个订单 × 一对人 = 一个对话框**（不是整单一个群）。会话靠 `conversationKey` 唯一定位，订单 ID 已编入键；订单详情页「围绕这个订单的全部对话」= 调会话列表接口按 `bizModule=HOUSE&bizId=订单ID` 过滤。
4. **前端不传 adminId / 姓名**。当前操作人由网关注入的 token 解析；发件人姓名后端快照写入，直接展示。

---

## 1. 背景

房务（定制师↔房务·配房沟通）、车务（车管↔定制师·派车沟通）都需要内部员工之间的 **1:1 实时聊天**。后端把现有「单向系统通知站内信」升级为「通知 + 1:1 聊天」统一消息中心：同一套收件箱 / 未读 / （未来）实时通道，新增「谁发的 + 成线程 + 双向」三要素。

本期 **P1** 交付 **6 个管理端接口**（admin 后台用），是聊天的核心读写能力。SSE 实时（P2）、跨服务接入与业务列表红点透传（P3 房务 / P4 车务）后续迭代。

---

## 2. 变更清单

| 序号 | 接口 | 说明 | 变更类型 |
|------|------|------|----------|
| 1 | `POST /admin/message/chat/open` | 打开 / 找回 1:1 会话（幂等） | ✨ 新增接口 |
| 2 | `GET /admin/message/chat/conversations` | 我的会话列表（支持按订单聚合） | ✨ 新增接口 |
| 3 | `GET /admin/message/chat/{conversationKey}/messages` | 拉线程消息（游标分页） | ✨ 新增接口 |
| 4 | `POST /admin/message/chat/{conversationKey}/messages` | 发一条消息 | ✨ 新增接口 |
| 5 | `POST /admin/message/chat/{conversationKey}/read` | 标记已读到最新 | ✨ 新增接口 |
| 6 | `GET /admin/message/chat/unread-total` | 我的聊天未读总数 | ✨ 新增接口 |

- **认证**：全部 Bearer JWT（管理后台 Token），经网关 `https://api.test.1814.love:9443`。
- **网关路由**：复用现有 `/admin/message/**` 路由，无新增。
- **DDL**：`admin_message` 扩 8 列 + 新表 `admin_conversation_member`（前端无感）。
- **顶部合并角标**：仍走 `GET /admin/message/unread-count`，本次不变。

---

## 3. 核心概念：conversationKey（会话键）

```
conversationKey = {bizModule}:{bizId}:{minAdminId}:{maxAdminId}
例： HOUSE:70123:1001:2046065465288949761
```

- `bizModule`：`HOUSE`（房务）/ `FLEET`（车务）/ `DIRECT`（纯私聊，无业务对象）。
- `bizId`：业务对象 id（房务=订单ID，车务=派车单/车辆ID）；`DIRECT` 时后端按 `0` 处理。
- 两人 adminId **小的在前**（后端规范化），保证 A↔B 双向命中**同一会话**，不分叉。
- **前端拿到 `conversationKey` 后，作为路径参数透传**（含冒号，直接放进 URL path，无需手动编码也可；若用 encodeURIComponent 编码同样可用）。

---

## 4. 接口详情

### 4.1 `POST /admin/message/chat/open` — 打开 / 找回会话

幂等：同两人同业务对象重复调用返回**同一个** `conversationKey`（`isNew=false`），不会重复建会话。

**入参（RequestBody）**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `bizModule` | String | 是 | `HOUSE` / `FLEET` / `DIRECT` |
| `bizId` | String | 否 | 业务对象 id（HOUSE=订单ID；DIRECT 留空） |
| `peerAdminId` | String | 是 | 对端员工 adminId（雪花，字符串透传） |

**出参 `data`**

| 字段 | 类型 | 说明 |
|------|------|------|
| `conversationKey` | String | 规范化会话键 |
| `peerAdminId` | String | 对方 adminId |
| `peerName` | String | 对方姓名快照 |
| `peerRole` | String | 对方角色（P1 admin 端开会话暂为 null，P3 业务侧接入后带值） |
| `unreadCount` | Integer | 我在该会话的未读数 |
| `isNew` | Boolean | true=新建，false=找回已存在 |

### 4.2 `GET /admin/message/chat/conversations` — 我的会话列表

| 查询参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `bizModule` | String | 否 | 过滤业务分类，空=全部 |
| `bizId` | Long | 否 | 过滤业务对象。**传 `bizModule=HOUSE&bizId=订单ID` 即「围绕这个订单的全部对话」** |
| `pageNo` | Integer | 否 | 页码，默认 1 |
| `pageSize` | Integer | 否 | 每页，默认 20 |

返回分页 `data.records[]`，每项：`conversationKey` / `bizModule` / `bizId` / `peerAdminId` / `peerName` / `peerRole` / `unreadCount` / `lastMessagePreview` / `lastMessageAt` / `status`，按 `lastMessageAt` 倒序。

### 4.3 `GET /admin/message/chat/{conversationKey}/messages` — 拉线程（游标分页）

| 参数 | 位置 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| `conversationKey` | Path | String | 是 | 会话键 |
| `beforeId` | Query | String | 否 | 游标：拉该 messageId 之前的历史（首屏不传=拉最新一页） |
| `pageSize` | Query | Integer | 否 | 每页，默认 20 |

返回 `data`：`conversationKey` / `hasMore` / `nextCursor`（下次 `beforeId`）/ `list[]`。每条消息：`messageId` / `senderAdminId` / `senderName` / `senderRole` / `msgType` / `priority` / `content` / `isMine`（是否我发的，用于左右气泡）/ `sentAt`。列表按时间正序（从旧到新）。

### 4.4 `POST /admin/message/chat/{conversationKey}/messages` — 发一条

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `content` | String | 是 | 文本内容（≤ 1000 字；命中本地敏感词后端替换为 `*`，不报错） |
| `priority` | String | 否 | `NORMAL`（默认）/ `URGENT`（加急；P1 不触发实时强提示，P2 起生效） |
| `msgType` | String | 否 | `TEXT`（默认）/ `IMAGE`（content 存图 URL） |

返回 `data`：`messageId` / `conversationKey` / `sentAt`。**前端不传发件人/收件人**，后端按会话键解析。

### 4.5 `POST /admin/message/chat/{conversationKey}/read` — 标记已读到最新

无请求体。把该会话标记为已读到最新：该会话未读清零、已读水位推进、对应站内信行置已读（顶部合并角标相应回落）。返回 `data`：`conversationKey` / `unreadCount`（=0）/ `lastReadMessageId`。

### 4.6 `GET /admin/message/chat/unread-total` — 我的聊天未读总数

无参数。返回 `data.chatUnreadTotal`（仅 kind=CHAT 的未读数，供「聊天 Tab」单独展示）。

> 顶部**合并角标**（聊天+通知）请继续用 `GET /admin/message/unread-count`，本端点不替代它。

---

## 5. 错误码

| 错误码 | 说明 | 触发 |
|--------|------|------|
| `281002` | 无权访问该会话 | 操作人不是该会话成员（越权） |
| `281005` | 不能与自己创建会话 | open 时 peerAdminId == 当前登录人 |
| `281010` | 目标员工不存在或已离职 | open 的 peerAdminId 无效 |
| `281011` | 消息内容不合法 | content 为空或超 1000 字 |
| `281004` | 发送过于频繁，请稍后再试 | 发消息超限流（每人 20 条/分钟） |

> HTTP 恒 200，业务结果看 `code`（成功 `code=200`、`success=true`）。

---

## 6. 典型调用流程（示例）

```http
# 1) 打开会话（房务给订单 70123 的定制师留言）
POST /admin/message/chat/open
Authorization: Bearer <admin-token>
{ "bizModule": "HOUSE", "bizId": "70123", "peerAdminId": "2046065465288949761" }
--> data: { "conversationKey": "HOUSE:70123:1001:2046065465288949761", "peerName": "cz", "isNew": true, "unreadCount": 0 }

# 2) 发消息
POST /admin/message/chat/HOUSE:70123:1001:2046065465288949761/messages
{ "content": "标间超预算了，客人要不要升一档？", "priority": "NORMAL" }
--> data: { "messageId": "2067795811529314306", "sentAt": "2026-06-19 10:25:13" }

# 3) 拉线程
GET /admin/message/chat/HOUSE:70123:1001:2046065465288949761/messages?pageSize=20
--> data.list[]: 含双方消息，每条带 senderName / isMine

# 4) 标记已读
POST /admin/message/chat/HOUSE:70123:1001:2046065465288949761/read
--> data: { "unreadCount": 0, "lastReadMessageId": "2067795811529314306" }
```

---

## 7. 本期范围与后续

| 阶段 | 内容 | 状态 |
|------|------|------|
| **P1** | 6 个 admin 端点（open / 列表 / 线程 / 发消息 / 已读 / 聊天未读） | ✅ 已上测试服 |
| **P2** | SSE 实时推送（CHAT 走新增 `im-chat` 事件，含 URGENT priority）+ 敏感词 DFA（Hutool） | ✅ 已上测试服（后端就绪，前端接入时用，见下「SSE 实时契约」） |
| **P3** | 房务（order-v3）接入：房务「我的接单」列表 + 订单详情的 `unreadMessageCount` 接真聊天未读（经 internal Feign 透传，原占位恒 0） | ✅ 已上测试服（后端透传就绪，见下「P3 房务红点」） |
| P4 | 车务（fleet）接入 | ⏳ 待开发 |

### P3 房务红点（后端已就绪）

房务「我的接单」列表项 `HouseMyOrderItemRespVO.unreadMessageCount` 与订单详情 `tabCounts.unreadMessageCount` **从此返真聊天未读**（此前恒 0 占位）：order-v3 服务端组装列表/详情时经 Feign 调 user-service `POST /internal/message/chat/biz-unread-batch` 批量取「当前登录房务在该订单(bizModule=HOUSE)上的会话未读」。前端无需改调用——**字段同名，值从 0 变真**；可据 `unreadMessageCount > 0` 显红点、按它排序（列表 `sortBy=unreadMessageCount,desc` 已支持）。user 不可达时降级回 0，不阻断列表。**仍待前端排期接入展示。**

### SSE 实时契约（P2 后端已就绪，前端接入时用）

复用现有 SSE 长连接 `GET /ws/admin-msg/stream`（EventSource，网关注入鉴权），**不新增连接**。一条连接上按 event 名分发：

- **`message` 事件（现有系统通知，行为不变）**：前端原有监听**不用动**。
- **`im-chat` 事件（P2 新增，聊天专用）**：对端在线时实时收到，payload：

```json
{
  "type": "CHAT",
  "adminId": "收件方adminId",
  "unreadCount": 9,                  // 合并未读(聊天+通知)，直接刷顶部角标
  "conversationKey": "HOUSE:70123:101:205",
  "senderName": "房务·小呼",
  "preview": "标间超预算了…",
  "priority": "URGENT"               // URGENT 时前端做强提示(弹窗/声音)
}
```

前端用法：监听 `im-chat` → `unreadCount` 刷顶部角标；`conversationKey` 命中当前打开的会话则 live append 新气泡；`priority=URGENT` 强提示。**P2 是后端能力，前端接入仍待排期通知。**

---

## 8. 注意事项

1. **P1 无实时**：前端先用轮询（拉线程 + 定时拉 `unread-total`）做交互；不要等 SSE，SSE 在 P2。
2. **会话键透传**：`conversationKey` 含冒号，作为 path 参数直接用即可（encodeURIComponent 也兼容）。
3. **雪花 id 用字符串**：`peerAdminId` / `messageId` / `conversationKey` 内嵌的 adminId 均以字符串处理，禁做数值运算（防精度丢失）。
4. **顶部角标不变**：继续用 `GET /admin/message/unread-count`（合并聊天+通知）；本期 `unread-total` 仅细分聊天数。
5. **重复 open 安全**：同两人同对象重复 open 返回同一会话（`isNew=false`），可放心在进入会话时每次调用。

---

## 9. 关联 / 联系人

| 项目 | 链接 |
|------|------|
| Issue | https://git.1814.love:8443/wx/HL/issues/4020 |
| PR（P1） | https://git.1814.love:8443/wx/HL/pulls/4028 |
| PR（hotfix bean 名冲突） | https://git.1814.love:8443/wx/HL/pulls/4030 |
| PR（hotfix open 幂等语义） | https://git.1814.love:8443/wx/HL/pulls/4031 |
| 后端负责人 | 王骁 |
