# 房务「联系房务」面板真实化：真实在线态 + 抢单前发消息 + claimerId（已上线测试服·可对接）

> 变更类型：✅ 新增/修改接口（已部署测试服并 API 验证，可对接）
> 端类型：管理后台（订单详情·定制师侧「联系房务」聊天面板）
> 日期：2026-06-22 ｜ 工单：#4223 ｜ PR：#4247 ｜ 服务：hl-user-service（聊天/在线态）、hl-order-service-v3（订单详情/抢单）
> 关联：本条**取代**同日「需求澄清·实现中」版（那版的"先别删在线态"结论已落地为下方真实契约）。

---

## 背景
此前「联系房务」面板的房务专员/在线态是前端假数据。wx 拍板：**在线/离线要做成真的**（不是删功能），并加「抢单前也能发消息、抢单后一并送达房务」。后端已补真实能力，本条是正式对接契约。

## 三态总览（前端按此渲染）
| 抢单状态 | 房务专员 | 在线态 | 发消息 |
|---|---|---|---|
| **抢单前**（claimerId=null） | 显示「未知」 | 不显示 | **可发**，存订单房务会话，抢单后送达 |
| **抢单后**（claimerId 有值） | 真实接单人姓名 | **真实在线/离线**（peerOnline） | 正常聊 |
| **claimerId==当前登录人**（你既定制师又抢了本单房务） | 不显示「联系房务」 | — | 不能跟自己聊 |

## 1. 订单详情新增 `claimerId`
`GET /v3/admin/order/{id}/itinerary` 的住宿需求摘要 `hotelRequirementBrief` **新增** `claimerId`（接单房务 adminId，**String** 雪花；未抢单为 `null`）。
- `claimerId == null` → 前端房务专员显示「**未知**」、不显示在线点、「联系房务」可引导但开的是 pending 会话（见 §3）。
- `claimerId == 当前登录 adminId` → 前端**隐藏/置灰**「联系房务」（本单房务是你自己）。

## 2. 聊天出参新增 `peerOnline`（真实在线态）
`POST /admin/message/chat/open`、`/open-house`、`GET /admin/message/chat/conversations` 出参新增 `peerOnline`(Boolean)。
- 语义：对方（房务专员）**当前登录后台且有活跃实时(SSE)连接 = 在线**；离线靠 Redis TTL 反映（**near-live，最多约 45s 延迟**，本期不做在线态即时推送）。
- 前端：`peerOnline==true` 绿点在线 / `false` 灰点离线；抢单前（peer 为占位）恒 false、配合「未知」不显示状态即可。

## 3. 新端点 `POST /admin/message/chat/open-house`（HOUSE 会话订单维度）
**HOUSE 房务会话改为订单维度单会话**（键 `HOUSE:{orderId}`，一单一条；**「联系房务」与「联系定制师」收敛到同一条**，不再分叉）。FLEET/DIRECT 不变。

`POST /admin/message/chat/open-house`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| orderId | String | ✅ | 订单 id（房务会话维度） |
| peerAdminId | String | 否 | 对端 adminId：**抢单前定制师开聊不传**（pending）；抢单后传 `claimerId`（定制师→房务）或 `consultantId`（房务→定制师） |

出参（实测）：`{conversationKey, peerAdminId, peerName, peerRole, peerOnline, unreadCount, isNew}`。
- 抢单前不传 peerAdminId → 返 `peerAdminId:0, peerName:null, peerRole:"HOUSE", peerOnline:false`（占位「待接单房务」），定制师即可发消息（走现有 `POST /chat/{conversationKey}/messages`）。
- `peerAdminId==自己` → 281005（不能跟自己开会话）。

## 4. 抢单前发消息 · 抢单/转单后送达（后端自动，前端无需特殊处理）
- 抢单前定制师在 `HOUSE:{orderId}` 会话发的消息先暂存。
- 房务**抢单**后：后端自动把历史消息变成该房务的未读 + SSE `im-chat` 通知；定制师/房务双方此后正常聊。
- **转单**：会话历史跟随交给新房务（前端拉 `GET /chat/{conversationKey}/messages` 即见全历史）。
- 边界（定制师==房务本人抢单）：消息仅作历史、不自发未读。
- 前端只需：抢单前用 open-house(pending) 让定制师能发；抢单后用 `claimerId` open-house 正常聊。**送达逻辑后端全包**。

## 5. curl 实测（2026-06-22 测试服，过网关 9443）
```bash
# A) 订单详情 claimerId
curl 'https://api.test.1814.love:9443/v3/admin/order/2068234602970828802/itinerary' -H 'Authorization: Bearer <token>'
#   → 200, hotelRequirementBrief.claimerId 字段存在（未抢单为 null）

# B) open-house（抢单前 pending）
curl -X POST 'https://api.test.1814.love:9443/admin/message/chat/open-house' \
  -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"orderId":"2068234602970828802"}'
#   → 200 {"conversationKey":"HOUSE:2068234602970828802","peerAdminId":0,"peerName":null,
#          "peerRole":"HOUSE","peerOnline":false,"unreadCount":0,"isNew":true}
```

## 6. 前端处理建议
- 房务专员名：`claimerId` 空→「未知」；非空→真实姓名（姓名走会话 `peerName`）。
- 在线点：读 `peerOnline`（抢单后才有意义）；抢单前/未知不显示。
- 「联系房务」：`claimerId==当前人`→隐藏；否则 open-house（抢单前不传 peerAdminId、抢单后传 claimerId）。
- 「联系定制师」（房务侧）：改走 open-house（传 consultantId），与「联系房务」同一条订单会话。
- 删除写死的「舒心 在线」假数据。

## 7. 影响 / 回滚
- 兼容性：均为加字段 / 加端点，旧前端不读新字段不受影响。
- **无 DB 迁移**（复用 admin_message / admin_conversation_member）。回滚 = revert PR #4247 重新部署 hl-user-service + hl-order-service-v3。

---

## 8. 补充裁决（2026-06-23 · 复核 + 答前端阻塞，本篇为唯一权威）

前端反馈本篇与同日另一篇 `23_定制师侧联系房务…管理后台前端.md` 契约打架（两个 open 端点 + 抢单前/后语义相反），无法切共享聊天基础设施。重新实测测试服后裁决：

**① 以本篇（22_4223 / open-house）为唯一权威。** 同日那篇 `23_…` **作废已删**——它基于**落后 90 个提交的 stale 代码**误写（写成 `POST /chat/open` + `bizModule/peerAdminId` + 「抢单后才能聊」），与真实部署不符。

**② 端点（实测 live，2026-06-23 过网关 9443）**：
`POST /admin/message/chat/open-house {"orderId":"…"}` → `200 {"conversationKey":"HOUSE:{orderId}","peerAdminId":0,"peerName":null,"peerRole":"HOUSE","peerOnline":false,"unreadCount":0,"isNew":…}`。
HOUSE 订单会话**只走 open-house**。通用 `/chat/open`（bizModule+peerAdminId，@NotNull）仍在，但用于 FLEET/DIRECT/显式 peer，**HOUSE 别用它**。

**③ 门控（更正「抢单后才能聊」的误解）**：定制师侧「联系房务」**抢单前也能发消息**——open-house 不传 peerAdminId → pending 占位「待接单房务」，消息暂存，房务抢单后后端自动送达。所以**抢单前按钮照常可点可发**，只是对端显「未知/待接单房务」、不显在线点；**不要禁用按钮**。仅 `claimerId == 当前登录人` 时隐藏（自己别跟自己聊）。
（注：与房务侧「房务管家」未抢单隐藏入口不冲突——房务在抢单池里没理由先聊；能抢单前留言的是**定制师**单向投递。）

**④ 在线态怎么拿（答「轮询还是 SSE」）**：读响应里的 `peerOnline`(Boolean)（open-house / conversations 都返）。它是**真实在线态**（后端 `adminPresenceService` Redis presence，对方有活跃后台连接=在线），**near-live、约 45s TTL 延迟**。刷新方式 = **轮询 / 重新拉**（re-open-house 或 re-conversations）。**没有专用在线态 SSE 推送**（本期不做）。消息实时到达走站内信 `im-chat` SSE（与在线态是两回事）。抢单前 peer 占位恒 `false`，配「未知」不显在线点即可。

**⑤ FLEET 车务聊天：未切真（deferred）。** 代码实证 `ConversationMemberService` 注「fleet 接入 deferred」——FLEET 会话的订单摘要 / 真实对端整合**没做**。通用 `/chat/open` 虽接受 `bizModule=FLEET`，但**无真实车务数据对接**。**前端 FLEET 聊天先别做切真**，等车务接入工单（wx「车务用了再加」）。mock 里没有 SSE/在线态属正常——FLEET 整条都还没切真。

---

## 9. 切真细节契约（2026-06-23 实测 origin/dev-v3 + LIVE，答前端 Q1/Q2/Q3）

### Q1 · peerAdminId 强不强制？后端解析对端吗？
- **HOUSE 用 `POST /admin/message/chat/open-house`，`peerAdminId` 选填**（不传 = 抢单前 pending 占位「待接单房务」）。**别用通用 `/chat/open`**（它 `peerAdminId` `@NotNull` 强制，只给 FLEET/DIRECT/显式 peer）。
- **后端不反查 order-v3 解析对端**（`ChatManager.openHouse` javadoc 明示「前端传 peer，不反查 order-v3」）。对端 id **前端传**，且前端数据里都有：
  - 定制师→房务：`peerAdminId = claimerId`（来自 `hotelRequirementBrief.claimerId`；抢单前为 null → 不传 = pending）。
  - 房务→定制师：`peerAdminId = consultantId`（订单/抢单数据里就有）。
- 「两端收敛同一条会话」靠键 `HOUSE:{orderId}`（与 peer 无关）；但建双方成员行需开方各自传对端 id（抢单前定制师不传 → 房务抢单后 order-v3 afterCommit 自动补房务成员行 + 送达历史，前端无需管）。
- **FLEET**：无 open-fleet，通用 open 强制 `peerAdminId`，且 FLEET deferred → **先别做**。
- 可选增强：若要前端只传 orderId、后端按 claim/consultant 自动解析对端 → 需后端加 Feign 反查 order-v3，**独立工单待 wx 定**（当前不做，前端传 id 即可跑通）。

### Q2 · 响应字段（实测对齐 #4020，别凭注释）
- 线程 `GET /admin/message/chat/{conversationKey}/messages` → `{conversationKey, hasMore, nextCursor, list[]}`。**是 `list` 不是 `records`**；`list` 按**时间正序**（旧→新，从上往下渲染）；游标 `nextCursor` = 本页最小 messageId，作下次 `beforeId` 上翻历史。
- 消息项 `list[]`：`{messageId, senderAdminId, senderName, senderRole, msgType(TEXT/IMAGE/SYSTEM), priority(NORMAL/URGENT), content, isMine, sentAt}`。
  - **`isMine`** = Boolean，后端按 `senderAdminId == 当前登录人` 算（左右气泡布局用）。
  - **`sentAt`** = **String，格式 `yyyy-MM-dd HH:mm:ss`**（如 `2026-06-16 11:02:31`）——**不是毫秒、不是 ISO-8601**，本地时间到秒、无时区无毫秒。前端按此格式 parse，别当 epoch/ISO。

### Q3 · SSE 实时（**已有且 LIVE，别再轮询**）
- **SSE 端点 `GET /ws/admin-msg/stream`（`text/event-stream`）实测 LIVE（200 + 正确 Content-Type）**。
- 发消息：后端 `send` 事务 afterCommit 经 Redis Pub/Sub 广播 **CHAT 信令**给收件方 → SSE 实时冒泡（带 senderName / 预览 / priority + conversationKey）。
- **前端应订阅 `/ws/admin-msg/stream`，收到 CHAT 信令即 live-append / 刷新对应会话**，替换「发送后短轮询 6 次(~8s)」的兜底。「没有 SSE」= 没接，不是没有。

### 在线/离线态走 SSE（wx 2026-06-23 新要求 → 后端工单 **#4273**）
- 现状：`peerOnline` 是**轮询字段**（Redis presence key TTL 45s + SSE 心跳续期），**在线态变更不推送**。
- wx 拍：改为 **SSE 实时推在线/离线**。已立后端工单 **#4273**（hl-user-service）：SSE 连接建/断 → 标在线/离线 → 向会话对端推 `presence` 事件。**前端届时订阅 presence 事件、停 peerOnline 轮询**（保留字段做首屏兜底）。**✅ 已上线测试服，契约见 §12。**

---

## 12. ✅ 在线/离线实时 SSE 已上线测试服（2026-06-23 · #4273 · PR #4299 · 已部署实测）

`peerOnline` 轮询升级为 **SSE 实时推**。前端订阅后**在线点变更实时刷新，停掉 peerOnline 轮询**（`peerOnline` 仅留作首屏兜底）。

- **SSE 事件**：`presence`（与 `im-chat`/`im-chat-read` 同一条流 `GET /ws/admin-msg/stream`，实测 200 `text/event-stream`）。
- **payload**：`{ "type":"PRESENCE", "presenceAdminId":<Long 在线态翻转的人>, "online":<Boolean> }`（另带 `adminId`=收件方路由，前端忽略）。
- **语义**：当**与你有活跃会话的某对端**（`presenceAdminId`）上线/下线，你的流就收到一条 `presence` → 把会话列表 / 打开的聊天里**那个人的在线点**实时刷成 `online`。只推「与你有会话关系的人」，不会广播无关员工。
- **前端**：订阅 `presence` 事件 → `presenceAdminId` 找到对应会话/对端 → 设其在线点=`online`。首屏仍用 `peerOnline`（open-house / conversations 返）兜底，之后全靠 `presence` 事件维持，**不再轮询**。
- **时延**：上线≈即时（建连即推）；下线干净断开≈即时、异常掉线≤25s（心跳探测补推）、45s TTL 兜底。
- **实测**：部署后 `GET /ws/admin-msg/stream` 200 + `text/event-stream`，服务正常启动（新 presence 广播 bean 无循环依赖）。两端实时翻态属前端联调验。

---

## 10.「已读」是前端写死的 bug + 抢单前应显「留言」（2026-06-23 wx 评审，🐛 前端改，后端无需动）

**现象**（wx 评审，定制师侧「联系房务」面板）：订单还在抢单池（无房务接单，对端显「待接单房务」），定制师发「你好 测试消息」，气泡下却显示 `14:05 **已读**`。**没有任何房务接单、对端是占位 0，谁来读？「已读」是错的。**

**根因（已对 origin/dev-v3 核实）**：**后端从不返回任何「已读/送达」状态**。线程消息 VO 就 §9 Q2 那 9 个字段 `{messageId, senderAdminId, senderName, senderRole, msgType, priority, content, isMine, sentAt}`——**没有 `isRead`/`readAt`/`delivered`/`status`**。`14:05 已读`是**前端在「我方」(`isMine=true`) 气泡下无条件硬拼的**，后端给不了这个信号，所以它永远是假的。

**正确显示（前端按 claim 状态自行渲染，后端已有的字段够用）**：
| 我方消息所处状态 | 判据（前端已有） | 气泡下应显示 |
|---|---|---|
| **抢单前**（pending） | `claimerId == null`（或 open-house 返 `peerAdminId:0`/`peerName:null`） | 「**留言**」或「留言·待房务接单」——**不显已读** |
| **抢单后** | `claimerId` 有值、对端真实 | 「**已送达**」或**什么都不显**——**不显「已读」** |
| 对端发来的（`isMine=false`） | — | 不需要已读标 |

> 一句话：**把写死的「已读」删掉**。抢单前我方消息本质是**留言**（标「留言」）；抢单后后端保证已送达接单房务（可标「已送达」）。**后端无读回执，别再伪造「已读」。**

**「留言→抢单后送达」后端全包，无需前端管（§4 重申 + 核实）**：
- 抢单前定制师发的消息 → 后端存进 pending 桶（收件方=占位 0，**不累加任何人未读、不推 SSE**），就是「留言」。
- 房务**抢单**那一刻 → 后端 `bindHouseClaimer` 自动把这些 pending 留言**整批改判给抢到的房务** + 置未读 + SSE 通知。**这就是你说的「发的消息抢单后一起把消息发过去」，已经实现。**
- 所以你测试时「房务管家侧看不到留言」(Image #27) 是**对的**——那单**还在抢单池、没人抢**，留言正确地还压在 pending、尚未送达；**等真有房务点「抢这一单」，留言会连同未读角标一起送到 ta 那边**。

**房务管家侧（Image #27）抢单池显「抢单后可与定制师沟通」= 设计内门控**（见 `22_4237` item 1：房务未抢单时隐藏聊天入口）。**保持不变**，与上面定制师侧抢单前能留言不冲突——能抢单前**单向留言**的只有定制师；房务在抢单池里没接单、无身份，自然先不聊。抢单后双方正常互聊。

**两件待 wx 拍的「可选增强」（前端不必等，先按上表修假已读）**：
1. **真·已读回执**（抢单后对方真读了才显「已读」）——需后端把 per-message 已读态（基于会话成员 `lastReadMessageId` 水位）暴露进线程 VO，是独立后端工单。当前最小修 = 前端按上表把假已读改成「留言/已送达」即可。
2. **抢单池给房务「该单有 N 条定制师留言」预览做诱饵**——需后端在抢单池 VO 补 pending 留言计数，独立后端工单。当前抢单后才送达可见。

---

## 11. ✅ 真已读回执 + 房务侧留言可见 已上线测试服（2026-06-23 · #4288/#4289 · PR #4294 · 已 API 实测）

§10 标的两项 wx 已拍并实现上线，**取代 §10 的「待定/显已送达」口径**：

### 11.1 真实「已读/未读」回执（#4289）
- 线程消息出参（`GET /admin/message/chat/{conversationKey}/messages` 的 `list[]`）**新增 `readByPeer`(Boolean)**：仅我方消息(`isMine=true`)有意义 = 对端已读到本条（对端成员已读水位 >= 本消息 id）。抢单前无对端恒 false；对端发来的消息(isMine=false)此字段无意义。
- **气泡显示口径（升级版，取代 §10）**：

  | 我方消息状态 | 气泡下显示 |
  |---|---|
  | 抢单前 | 「留言」 |
  | 抢单后 · `readByPeer=false` | 「已送达」 |
  | 抢单后 · `readByPeer=true` | 「已读」 |
  | 对端发来的(`isMine=false`) | 不标 |

- **实时刷已读**：对端调 `POST /admin/message/chat/{conversationKey}/read` 后，后端经 SSE 推 **`im-chat-read`** 事件给我方（payload `{conversationKey, readerAdminId, lastReadMessageId}`）→ 前端把本会话 `id <= lastReadMessageId` 的我方气泡刷「已读」。**订阅 `im-chat-read`**（与 `im-chat` 同一条 SSE 流 `/ws/admin-msg/stream`；NOTIFY/CHAT 事件不变向后兼容）。
- 实测：线程出参已含 `readByPeer`（字段顺序 `…, isMine, readByPeer, sentAt`）。

### 11.2 房务侧「内部留言框」显示抢单前留言（#4288）
- 详见 `22_4237`：房务订单详情 `GET /admin/house/orders/{orderId}` **新增 `messages` 列表** + `tabCounts.messageCount = 1(定制师需求) + N(留言)`。**抢单池/未抢单也返**（房务在池里浏览即见定制师留言）。
- 实测（订单 2068234602970828802，抢单池态）：`tabCounts.messageCount=3`、`messages`=[「你好 测试消息」2026-06-23 14:05、「确认单发我一下」14:32]。
- `messages[].senderRole` 抢单前留言可能为 `null`（抢单前留言恒来自定制师，前端按「定制师」渲染即可）；`senderName` 已是真实姓名。
