docs(changelog): #7328 团期房务整团会话 GROUP_HOUSE(三端点)
changelog-filename-gate / validate (push) Successful in 2s

会话键是 GROUP_HOUSE:{groupBatchId},按团期聚合主键建键而不是子订单 id——
这是与既有 GROUP:{orderId} 的关键差异,前端拿错主键空间会静默查空
(不报错、只是永远没数据)。

三处前端按直觉写会踩的地方,都写进「关键变化」:

1. 团未被认领时的不对称:管理员侧能开会话(消息落团队池,认领后自动改判
   给认领人),房务侧开不了(281002)。

2. peerAdminId 在两个接口上语义不同——这条是部署实测才发现的。
   open-group-house 的响应直接回显 DB 占位值 0(ChatManager.buildFullResp
   无归一化);把「无认领人 → null」做归一化的是会话列表那条路
   (fillOrderSummaries + fillGroupBatchSummaries),而且只对有消息的会话
   生效——selectMyConversations 用 isNotNull(last_message_at) 把无消息的
   占位会话过滤掉了。前端照单接口响应判 null 会渲染出「管理员 #0」。

3. currentClaimerId 刻意没有 @NotNull:RELEASE 事件本来就没有新认领人,
   加了会让释放事件在参数校验阶段被拒、会话永远停在旧认领人身上。

顺带订正一条长期口口相传的错误说法:网关对 /internal/** 并非「一律 403」。
gateway 只给 /v3/internal/** 与 /internal/fleet/** 配了 Path predicate,
/internal/message/** 与 /internal/house/** 根本没有路由,请求在到达
JwtAuthFilter 之前就被路由层拒掉,返回的是 HTTP 200 + 业务体
{"code":404,"message":"接口不存在"}。净效果一致(网关不可达),呈现不同。

backend_status=deployed:两服务已滚到 dev-v3 tip 0f62fb072(PR #7490),
实例 LISTEN + Nacos healthy,三端点各打通,281002 负向用例四个角色全命中,
落库 conversation_key 已用 SQL 证实,测试数据已清理。

Refs #7328
这个提交包含在:
API Changelog Bot
2026-09-10 21:59:00 +08:00
父节点 d9f30eb9bf
当前提交 70dbb833e0
@@ -0,0 +1,523 @@
---
schema: "hl-changelog/v2"
ticket: "7328"
title: "团期房务整团会话 GROUP_HOUSE:新增 open-group-house + 两个跨服务 internal 端点(认领收敛/团期摘要);会话键按团期聚合主键建(非子订单 id),未认领团仅管理员可开、房务侧 281002"
consumer: "admin"
author: "wx(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: ""
target_release: ""
verified_at: "2026-09-10"
status_note: "2026-09-10 squash 0f62fb072(PR #7490)已合并 dev-v3 并部署测试服:hl-user-service 与 hl-order-service-v3 均已滚到当前 tip 0f62fb072,两实例均 LISTEN、Nacos healthy=true enabled=true。三个端点均实测打通:open-group-house 未认领态不对称行为已验证——管理员侧 200,fwzz_pure01/shuxin/fwzz_lead01/test_admin 等非当前认领人角色全部 281002;真实 conversation_key=GROUP_HOUSE:2097500233511362561(按团期聚合主键建键);GET /v3/admin/order/group-batch/{groupBatchId} 响应确认带 houseChatUnreadCount。测试数据已清理(admin_message / admin_conversation_member 均 COUNT=0)。⚠️ 与工单转述核对,本次发现并订正三处(第①②处 2026-09-10 首次发布时已订正,第③④处为部署后复核新增):① ChatConversationRespVO(会话列表项)本次新增的是 groupBatchId/groupBatchNo/groupBatchName 三个字段,并没有 departDate(departDate 只在 ChatOrderCardVO/打开会话响应的团期卡上,会话列表接口读不到);② 281016(缺 groupBatchId)在标准 HTTP 请求路径下不可达,ChatOpenGroupHouseReqVO.groupBatchId 有 @NotNull,@Valid 会先在参数绑定阶段返回 400,281016 只是 ChatManager 内部方法的防御性兜底(源码注释原话如此);③ open-group-house 响应对「团未认领」的 peerAdminId 不是 null 而是占位常量 0(ChatManager.buildFullResp 直接回显 DB 成员行原值,不做归一化),只有会话列表接口(ConversationMemberService.fillGroupBatchSummaries)才会归一化成 null,且列表还受 last_message_at IS NOT NULL 过滤,没发过消息的会话根本不出现在列表里,2026-09-10 实测发现原口径「两处均为 null」有误,已在「关键变化」第 2 条订正;④ 网关对未路由的 /internal/** 路径(本单两个 internal 端点均未配 Path 路由)返回的是 HTTP 200 + 业务体 {"code":404,"message":"接口不存在: ..."}(GlobalErrorWebExceptionHandler 路由未命中分支),不是 403——JwtAuthFilter.isInternalPath() 的 403 分支存在但轮不到执行(请求在路由层已被拒),原口径「网关对 /internal/** 一律 403」不准确,已在「七、不影响范围」订正。此外发现一处工单未提及但同一 squash 里的关联变更:既有团期详情接口 GET /v3/admin/order/group-batch/{groupBatchId}(A2)顺带新增只读字段 houseChatUnreadCount,纯新增不影响既有契约,一并写在「关键变化」供前端知悉。"
updated_at: "2026-09-10"
base: "dev-v3"
---
# 团期房务整团会话 GROUP_HOUSE
> **服务**: `hl-user-service`(open-group-house / reconcile-group-house-claimer)+ `hl-order-service-v3`(group-batch-chat-summary-batch,二者互为 internal 消费方)
> **PR**: #7490
> **Issue**: #7328
> **日期**: 2026-09-10
> **影响范围**: 管理后台「房务↔团期管理员」整团聊天新入口(全新会话模块 GROUP_HOUSE,前端需接 1 个 admin 端点);另 2 个 internal 端点供 order-v3/user-service 互调,前端不接
---
## ⚠️ 关键变化
**这是一批全新端点,没有存量调用方,但有几处容易踩坑、需要前端与联调方明确知道的地方:**
### 1. 会话键是 `GROUP_HOUSE:{groupBatchId}`,按团期聚合主键建键,不是子订单 id
与已有的 `GROUP:{orderId}`(#7211,定制师↔团期管理员)不同,GROUP_HOUSE 的键维度是**团期聚合主键**(`order_group_batch.group_batch_id`),不是任何一个子订单的 id。前端如果把子订单 id 或团号当成 `groupBatchId` 传给 `open-group-house`,不会报错——只会拿到一个查不到数据/或查到另一个团的会话,属于**静默查空**,排障线索很少,务必用团期详情页/看板页里的团期聚合主键(雪花 id)作为入参。
### 2. 团未被认领时的不对称,且 `peerAdminId` 在「打开会话」与「会话列表」两个接口上语义不同(2026-09-10 实测订正)
`open-group-house` 的准入是「token 当前角色持权限码 `group-batch:demand:confirm`(超管天然通过)」**或**「当前角色为 `ROOM_MANAGER` 且本人即该团当前整团认领人」。团尚未被任何房务认领时:
- 管理员侧(持权限码的角色)可以正常打开,消息落「团队共享池」,认领发生后由收敛逻辑自动把池里的历史消息改判给新认领人;
- 房务侧任何人打开都会返回 **281002**(没有人满足 `adminId == claimerId`;实测 `fwzz_pure01`/`shuxin`/`fwzz_lead01`/`test_admin` 等非当前认领人角色全部命中 281002)。
**⚠️ `peerAdminId`/`peerName` 在两个接口上的归一化程度不一样,前端不能用同一套判空逻辑处理待认领态:**
- `open-group-house`(管理员打开一个尚未被认领的团):响应体**直接回显 DB 成员行原始 `peer_admin_id`**(`ChatManager.buildFullResp`,无归一化),未认领时是占位常量 `0`(`ChatConstants.PENDING_PEER_ADMIN_ID`)。实测:`"peerAdminId": 0`、`"peerName": null`、`"peerRole": "HOUSE"`。**不是 `null`**——前端若按 `peerAdminId == null` 判「待认领」会判失败,实测拿到的是数字/字符串 `0`,按 id 直接渲染可能出现「管理员 #0」一类脏文案,正确判法是 `peerAdminId === 0`(或字符串 `"0"`)。
- `GET /admin/message/chat/conversations?bizModule=GROUP_HOUSE&...`(我的会话列表):`ConversationMemberService.fillOrderSummaries`/`fillGroupBatchSummaries` 会把无认领人的情形**显式归一化为 `"peerAdminId": null`、`"peerName": null`**(`ConversationMemberService.java:1173-1182`)——只有这条路径才会拿到真正的 `null`。
- 且列表接口还有一层前提:**没发过消息的会话根本不出现在列表里**(`selectMyConversations` 固定过滤 `last_message_at IS NOT NULL`,`AdminConversationMemberMapper.java:108-109`),团刚被认领/尚未认领但还没人发过消息时,这条会话在列表接口里查不到任何一行,不是「查得到但 `peerAdminId` 为 `null`」。
前端待认领占位样式的正确写法:`open-group-house` 响应判 `peerAdminId === 0`;会话列表响应判 `peerAdminId == null`——两套判断不能混用,也不能假设「没发过消息=会在列表里显示为待认领行」。
### 3. `GroupHouseClaimerReconcileReqVO.currentClaimerId` 没有 `@NotNull` 是刻意设计(internal,前端不接,但排障/联调需知道)
`RELEASE` 事件本来就没有新认领人,若给这个字段加上 `@NotNull` 会让释放事件在参数校验阶段就被拒,会话会永远停在旧认领人身上。这条口径写在下方「四、契约约束与正确调用方式」,防止后人以为这是遗漏而「顺手补全校验」。
### 4. 会话卡字段在「打开会话」和「会话列表」两个响应体里不对称,列表项没有 `departDate`
打开会话(`open-group-house`)返回的团期卡 `ChatOrderCardVO` 本次新增 `groupBatchName` + `departDate` 两个字段(`groupBatchId`/`groupBatchNo` 是 #7211 已有字段,本次复用);但「我的会话列表」`GET /admin/message/chat/conversations` 的会话列表项 `ChatConversationRespVO` 本次只新增了 `groupBatchId`/`groupBatchNo`/`groupBatchName` 三个字段,**没有 `departDate`**。前端如果要在会话列表页也展示出发日,不能假设该字段在列表接口里就有,需要另外从团期详情/打开会话响应取。
### 5. 顺带变更:既有团期详情接口新增只读未读角标字段(非本单新增接口,纯新增字段)
同一 squash 里,既有的 `GET /v3/admin/order/group-batch/{groupBatchId}`(A2 团期详情,非本单新增端点)响应体 `GroupBatchDetailRespVO` 新增了一个只读字段 `houseChatUnreadCount`(Integer):GROUP_HOUSE 会话的团队共享未读数,口径是 TEAM(任一持 `group-batch:demand:confirm` 的管理员读过即对全员清零),user-service 不可达或尚无会话时恒为 `0`(软依赖,不阻断详情主数据,事务外回填)。这是对既有响应体的纯新增字段,向后兼容,不改变该接口任何既有字段/错误码,该接口本身不在本单「新增接口」范围内、不占用下方逐接口详情编号,如需展示「联系房务」按钮的未读角标,直接读取详情响应里的这个字段即可。
---
## 一、背景
`#7328` 是「团期房务」整体方案(`#7323` 定案)的延续单:在 `#7322`(团期整团抢单/抢单池)与 `#7211`(定制师↔团期管理员 GROUP 会话)之上,补上第三方会话——**整团认领房务 ↔ 团期管理员团队**,与 `#7324`(房务团期看板与按日订房计划)配套,让房务在配房过程中能直接在系统内联系团期管理员,管理员也能主动联系已认领的房务。
三个新端点分工:`POST /admin/message/chat/open-group-house`(`hl-user-service`,管理后台直接调用,打开/找回会话);`POST /internal/message/chat/reconcile-group-house-claimer`(`hl-user-service` 提供,`hl-order-service-v3` 消费,整团认领/接管/释放后收敛会话归属);`POST /internal/house/group-batch-chat-summary-batch`(`hl-order-service-v3` 提供,`hl-user-service` 消费,批量取团期的授权与展示摘要)。三者与既有 FLEET(#4937/#4689)、GROUP(#7211)两个「个人↔团队」会话模块共用同一套 `TeamChatModule`/`TeamChatAccessContext`/`TeamChatAuthorizationService` 框架(CODE_RULES §15.7 禁镜像重复),只在键维度、团队/个人侧角色、前置错误码等处取值不同。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 打开/找回团期房务整团会话 | POST | `/admin/message/chat/open-group-house` | 新增接口 | 会话键 `GROUP_HOUSE:{groupBatchId}`;未认领团仅管理员侧可开,房务侧 281002 |
| 2 | 收敛团期房务会话认领人归属 | POST | `/internal/message/chat/reconcile-group-house-claimer` | 新增接口 | order-v3 整团 CAS 成功后 afterCommit 调用,internal,前端不接 |
| 3 | 批量取团期聊天摘要 | POST | `/internal/house/group-batch-chat-summary-batch` | 新增接口 | user-service 授权与会话卡数据源,internal,前端不接,单次最多 200 个团期 |
---
## 三、接口详情
### 1. 打开/找回团期房务整团会话 `POST /admin/message/chat/open-group-house`
**VO**: `ChatOpenGroupHouseReqVO → Result<ChatOpenFullRespVO>`
(Controller:`ChatMessageController.java:170-176`;编排:`ChatManager.openGroupHouse`/`openTeamInternal`,`:404-454`;授权:`TeamChatAuthorizationService.assertAccess`)
#### 使用场景
房务「我的团」看板(`#7324` H1/H2)里点「联系团期管理员」、管理后台团期详情(A2,`GET /v3/admin/order/group-batch/{groupBatchId}`)里点「联系房务」时调用,一次性拿到会话元信息 + 团期卡 + 首屏消息 + 合并未读并标记本会话已读。
#### 请求头
`@Lock4j`:锁名 `fleet-order-chat`(与 FLEET/GROUP 共用,key=`'GROUP_HOUSE:' + groupBatchId` 各自区分),租约 120 秒,获取超时 3 秒;未加 `@Idempotent`(与 open/open-house/open-fleet/open-group 同口径,重复调用天然幂等找回)。
#### 入参字段表(`ChatOpenGroupHouseReqVO.java`)
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `groupBatchId` | Body | Long | 是 | `@NotNull` | 团期聚合主键(雪花 id);两侧对端都由后端解析,**不接受** `peerAdminId` 字段 |
#### 出参字段表 `Result<ChatOpenFullRespVO>`
`ChatOpenFullRespVO` 继承 `ChatOpenRespVO`:
| 字段 | 类型 | 说明 |
|---|---|---|
| `conversationKey` | String | 会话键,形如 `GROUP_HOUSE:1930000000000000001` |
| `peerAdminId` | Long | 直接回显 DB 成员行原始值,**不做归一化**;未认领团为占位常量 `0`(不是 `null`,见「关键变化」第 2 条),已认领后为真实认领房务 adminId |
| `peerName` | String | 对方姓名快照;团队占位为「团期管理员」/「房务」,真实对端为姓名 |
| `peerRole` | String | 对方角色码,`GROUP_ADMIN`(团队占位/团队侧视角)或 `HOUSE`(房务视角) |
| `peerRoleLabel` | String | 对方角色中文 label,如「团期管理员」「房务」 |
| `peerOnline` | Boolean | 对方是否在线(团队席位按该模块团队侧是否有人在线判定) |
| `unreadCount` | Integer | 本会话未读数,打开即已读,恒为 `0` |
| `isNew` | Boolean | `true`=本次新建会话 `false`=找回已有 |
| `order` | `ChatOrderCardVO` | 团期卡(无订单号、无联系人),见下表;授权摘要不可达/团期未找到时上游已 fail-closed,不会出现 order 为 null 但整体 200 的情况 |
| `thread` | `ChatThreadRespVO` | 首屏消息,最新一页 `pageSize=20`;上滑加载更早历史走既有 `GET /admin/message/chat/{conversationKey}/messages` |
| `unreadTotal` | Integer | 标记本会话已读后,我的合并未读总数(NOTIFY+CHAT) |
`ChatOrderCardVO`(团期卡,仅 GROUP_HOUSE 相关字段,其余订单维度字段如 `orderNo`/`customerName`/`destination`/`tripDays`/`adultCount`/`childCount`/`requirementId` 恒为 `null`):
| 字段 | 类型 | 说明 |
|---|---|---|
| `productName` | String | 产品名 |
| `groupBatchId` | String(雪花,`ToStringSerializer`) | 团期聚合主键 |
| `groupBatchNo` | String | 团号;团期软删/不存在时为 `null` |
| `groupBatchName` | String | 班期名(本次新增) |
| `bizStatusLabel` | String | 团期状态中文(复用该字段承载团期状态名,未知状态码为 `null`) |
| `departDate` | String(`yyyy-MM-dd`) | 出发日(本次新增) |
#### 请求示例
```json
POST /admin/message/chat/open-group-house
{ "groupBatchId": "1930000000000000001" }
```
#### 响应示例(认领房务本人打开:对端=团队占位)
```json
{
"code": 200, "success": true,
"data": {
"conversationKey": "GROUP_HOUSE:1930000000000000001",
"peerAdminId": "0", "peerName": "团期管理员", "peerRole": "GROUP_ADMIN",
"peerRoleLabel": "团期管理员", "peerOnline": false, "unreadCount": 0, "isNew": true,
"order": {
"productName": "游牧的森林-短途版", "groupBatchId": "1930000000000000001",
"groupBatchNo": "T2026-0801-01", "groupBatchName": "国庆一期",
"bizStatusLabel": "资源准备中", "departDate": "2026-10-01"
},
"thread": { "conversationKey": "GROUP_HOUSE:1930000000000000001", "hasMore": false, "nextCursor": null, "list": [] },
"unreadTotal": 0
}
}
```
#### 响应示例(管理员打开,团已被认领:对端=真实认领房务)
```json
{
"code": 200, "success": true,
"data": {
"conversationKey": "GROUP_HOUSE:1930000000000000001",
"peerAdminId": "30001", "peerName": "房务·小呼", "peerRole": "HOUSE",
"peerRoleLabel": "房务", "peerOnline": true, "unreadCount": 0, "isNew": false,
"order": {
"productName": "游牧的森林-短途版", "groupBatchId": "1930000000000000001",
"groupBatchNo": "T2026-0801-01", "groupBatchName": "国庆一期",
"bizStatusLabel": "资源准备中", "departDate": "2026-10-01"
},
"thread": { "conversationKey": "GROUP_HOUSE:1930000000000000001", "hasMore": true, "nextCursor": 900001,
"list": [{ "messageId": 900002, "senderAdminId": "30001", "senderName": "房务·小呼", "senderRole": "HOUSE",
"msgType": "TEXT", "priority": "NORMAL", "content": "这个团标间还差 2 间", "isMine": false,
"readByPeer": false, "sentAt": "2026-09-10 10:00:00" }] },
"unreadTotal": 1
}
}
```
#### 响应示例(管理员打开,团尚未被认领:`peerAdminId` 为占位 `0`,不是 `null`,2026-09-10 实测)
```json
{
"code": 200, "success": true,
"data": {
"conversationKey": "GROUP_HOUSE:2097500233511362561",
"peerAdminId": 0, "peerName": null, "peerRole": "HOUSE",
"peerRoleLabel": "房务", "peerOnline": false, "unreadCount": 0, "isNew": true,
"order": {
"productName": "游牧的森林-短途版", "groupBatchId": "2097500233511362561",
"groupBatchNo": "T2026-0801-01", "groupBatchName": "国庆一期",
"bizStatusLabel": "资源准备中", "departDate": "2026-10-01"
},
"thread": { "conversationKey": "GROUP_HOUSE:2097500233511362561", "hasMore": false, "nextCursor": null, "list": [] },
"unreadTotal": 0
}
}
```
#### 空数据 / 降级响应
无空态:授权通过后一定能拿到会话元信息与团期卡(团期不存在/已软删/已结算/已取消在授权阶段已被 281017 拦截,不会走到组装响应这一步);`thread.list` 在新建会话/无历史消息时为空数组 `[]`,属正常状态,不是异常。
#### 错误响应
```json
{ "code": 400, "message": "团期id不能为空", "success": false, "data": null }
```
```json
{ "code": 281002, "message": "无权访问该会话", "success": false, "data": null }
```
```json
{ "code": 281017, "message": "团期不存在、已结算或已取消,无法发起房务会话", "success": false, "data": null }
```
(`281016`「缺少团期上下文」理论上仍是 `ChatManager.openGroupHouse` 的防御性兜底码,但标准 HTTP 请求会先被 `ChatOpenGroupHouseReqVO.groupBatchId` 上的 `@NotNull` 拦成上面的 400,实际走不到 281016,源码注释原话如此——见 `ChatErrorCode.java:69-76`。)
#### 业务边界
- 准入两条路:① token 当前角色为 `ROOM_MANAGER` 且 `adminId` 等于该团当前整团认领人(摘要 `claimerId`,未认领时任何 `ROOM_MANAGER` 都不满足);② token 当前角色持权限码 `group-batch:demand:confirm`(超管天然通过)。定制师在本模块既非团队侧也非个人侧,一律 281002。
- 摘要(`group-batch-chat-summary-batch`)不可达或返回空结果时 fail-closed 为 281002,不会降级放行——因为摘要同时承载「个人侧是谁」与「团队侧准入范围」,拿不到就无法判断请求人是否有权访问。
- `281017` 只作用于 `open`/`send`:会话一旦建立,`messages`(GET)/`read`/`conversations` 仍然放行,团期终态后历史仍可审计读取,不因团期结算/取消而不可读。
- 团队占位对端(`peerAdminId="0"`)不指向团队里任何一个具体人,多个管理员先后打开各自建自己一行(对端=真实认领房务或占位待认领),认领房务侧的团队行只建一次(幂等补建)。
- `peerAdminId=0` 的占位语义只在本端点(`open-group-house`)成立;会话列表接口(`GET /admin/message/chat/conversations`)对同一场景归一化为 `null`,两个接口不能共用一套「待认领」判空逻辑(见「关键变化」第 2 条)。
---
### 2. 收敛团期房务会话认领人归属 `POST /internal/message/chat/reconcile-group-house-claimer`
**VO**: `GroupHouseClaimerReconcileReqVO → Result<Boolean>`
(Controller:`ChatInternalController.java:80-91`;编排:`TeamChatReconciliationService.reconcilePersonal`,`:44-69`;调用方:`hl-order-service-v3` 的 `HouseGroupBatchChatBindNotifier` 经 `ChatBindFeignClient.reconcileGroupHouseClaimer`)
#### 使用场景
`hl-order-service-v3` 的整团认领(CLAIM)/接管(TAKEOVER)/释放(RELEASE)三条写口在 CAS 成功、主事务 `afterCommit` 后调用,把 `GROUP_HOUSE:{groupBatchId}` 会话的历史未读、成员行与团队侧对端快照收敛到当次事件后的权威认领人;**前端不直接调用**。
#### 请求头
`@Lock4j`:锁名 `fleet-order-chat`,key=`module.name() + ':' + groupBatchId`(即 `GROUP_HOUSE:{groupBatchId}`),与 `open-group-house`/发消息共用同一把锁,保证收敛与聊天读写串行。
#### 入参字段表(`GroupHouseClaimerReconcileReqVO.java`)
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `groupBatchId` | Body | Long | 是 | `@NotNull` | 团期聚合主键 |
| `previousClaimerId` | Body | Long | 否 | — | 事件快照里的旧认领人 adminId,仅日志/审计用,不参与判断 |
| `currentClaimerId` | Body | Long | **否**(刻意不加 `@NotNull`) | — | 事件快照里的新认领人 adminId;`RELEASE` 事件传 `null`;与服务端重读的权威摘要不一致时以摘要为准,见「四、契约约束」 |
| `eventType` | Body | String | 是 | `@NotNull`;取值 `CLAIM`/`TAKEOVER`/`RELEASE` | 仅用于日志与幂等语义标注,不参与收敛分支判断——真正结果完全由服务端重读的 order-v3 权威摘要决定 |
#### 出参字段表 `Result<Boolean>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data` | Boolean | `true`=该团确有既有会话并已收敛;`false`=该团尚无既有会话(不制造空成员行,正常业务态,不是错误) |
#### 请求示例
```json
POST /internal/message/chat/reconcile-group-house-claimer
{ "groupBatchId": "1930000000000000001", "previousClaimerId": null, "currentClaimerId": "30001", "eventType": "CLAIM" }
```
#### 响应示例(已收敛)
```json
{ "code": 200, "success": true, "data": true }
```
#### 空数据 / 降级响应
该团尚无既有会话(还没有人打开过 `open-group-house`)时返回 `{ "code": 200, "success": true, "data": false }`,这是正常业务态,不代表异常;调用方(`HouseGroupBatchChatBindNotifier`)据此只记 `warn` 日志,不阻断认领/接管/释放的主流程。
#### 错误响应
```json
{ "code": 400, "message": "groupBatchId 不能为空; eventType 不能为空", "success": false, "data": null }
```
```json
{ "code": 281002, "message": "无权访问该会话", "success": false, "data": null }
```
(`281002` 触发条件:本端点执行期间重读 `hl-order-service-v3` 的团期摘要(`group-batch-chat-summary-batch`)失败或返回空结果,与端点 1 的 fail-closed 口径同一个错误码;调用方 `hl-order-service-v3` 侧不区分本端点返回的业务失败与 `data:false`,`ChatBindFeignClient` 只判 `!resp.isSuccess()` 记 warn,两者在调用方视角都被当作「本次未收敛,下轮反熵/下次打开会话再补」处理。)
#### 业务边界
- 软依赖:`hl-order-service-v3` 侧的 `ChatBindFeignFallbackFactory` 在本端点(或 `hl-user-service` 整体)不可达时熔断降级为 `Result.success(false)`,不抛异常、不阻断认领/接管/释放主事务;失败只记 warn 日志(`HouseGroupBatchChatBindNotifier.doReconcile`)。
- 乱序到达的多个事件不会把归属回退:服务端每次都在会话锁内重新读取 order-v3 权威摘要作为收敛依据,事件体里的 `previousClaimerId`/`currentClaimerId` 只是快照,仅用于日志。
- `currentClaimerId` 与服务端重读的权威摘要不一致时,以摘要为准,同时记一条 `info` 级日志(不是错误)。
- 释放(`RELEASE`)收敛:归档旧认领人成员行、团队侧各行对端回退占位,消息与历史不动;认领(`CLAIM`)收敛:把管理员在无人认领期间发进团队池的消息改判给新认领人;接管(`TAKEOVER`)收敛:归档旧认领人成员行,会话键与历史不变。
---
### 3. 批量取团期聊天摘要 `POST /internal/house/group-batch-chat-summary-batch`
**VO**: `GroupBatchChatSummaryBatchReqVO → Result<GroupBatchChatSummaryBatchRespVO>`
(Controller:`HouseChatSummaryInternalController.java:60-65`;编排:`HouseGroupBatchChatSummaryService.summaryBatch`;调用方:`hl-user-service` 的 `TeamChatAuthorizationService.fetchGroupBatchSummary`)
#### 使用场景
`hl-user-service` 的 `TeamChatAuthorizationService` 在 GROUP_HOUSE 会话「开会话/发消息/拉线程/已读/我的会话列表」等全部授权路径上,批量读取团期的权威事实(认领人是谁、能不能联系、会话卡展示什么字段);**前端不直接调用**。
#### 入参字段表(`GroupBatchChatSummaryBatchReqVO.java`)
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `groupBatchIds` | Body | `Long[]` | 是 | `@NotEmpty` + `@Size(max=200)` | 团期聚合主键列表;上限 200 与 `hl-user-service` 侧授权批的分片大小一致,超限即视为调用方写错了 |
#### 出参字段表 `Result<GroupBatchChatSummaryBatchRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `summaryMap` | `Map<String, GroupBatchChatSummaryItemVO>` | key=团期 id 的 String 形态(防雪花 id 在 JS 丢精度);**入参每个 id 都会建键**,即便团期不存在也建键(`batchFound=false`),调用方无需判断键缺失 |
`GroupBatchChatSummaryItemVO`(14 字段):
| 字段 | 类型 | 说明 |
|---|---|---|
| `groupBatchId` | String(雪花,`ToStringSerializer`) | 回显入参 |
| `batchFound` | Boolean | `order_group_batch` 活跃行是否存在,不存在或已软删为 `false` |
| `batchNo` | String | 团号;`!batchFound` 时为 `null` |
| `batchName` | String | 班期名;`!batchFound` 时为 `null` |
| `batchLabel` | String | 第 N 期快照标签,可空 |
| `productName` | String | 产品名快照 |
| `batchStatus` | String | 团期状态码(九态原样) |
| `batchStatusName` | String | 团期状态中文;未知码为 `null` |
| `departDate` | LocalDate | 出发日,可空 |
| `endDate` | LocalDate | 返团日,可空 |
| `requirementConfirmed` | Boolean | 需求整体确认标记 |
| `hotelReady` | Boolean | 配房完成标志 |
| `contactable` | Boolean | 是否可发起/继续会话:`batchFound` 且状态 不在 `{SETTLED, CANCELLED}` 中 |
| `claimerId` | String(雪花,`ToStringSerializer`) | 当前整团认领房务 adminId;未认领为 `null`;**走含软删的读契约**,团期归档后原认领房务仍可能非空(供历史会话可读) |
#### 请求示例
```json
POST /internal/house/group-batch-chat-summary-batch
{ "groupBatchIds": ["1930000000000000001", "1930000000000000099"] }
```
#### 响应示例
```json
{
"code": 200, "success": true,
"data": {
"summaryMap": {
"1930000000000000001": {
"groupBatchId": "1930000000000000001", "batchFound": true, "batchNo": "T2026-0801-01",
"batchName": "国庆一期", "batchLabel": "第 1 期", "productName": "游牧的森林-短途版",
"batchStatus": "RESOURCE_PREPARING", "batchStatusName": "资源准备中",
"departDate": "2026-10-01", "endDate": "2026-10-05",
"requirementConfirmed": true, "hotelReady": false,
"contactable": true, "claimerId": "30001"
},
"1930000000000000099": {
"groupBatchId": "1930000000000000099", "batchFound": false, "batchNo": null, "batchName": null,
"batchLabel": null, "productName": null, "batchStatus": null, "batchStatusName": null,
"departDate": null, "endDate": null, "requirementConfirmed": null, "hotelReady": null,
"contactable": false, "claimerId": null
}
}
}
}
```
#### 空数据 / 降级响应
无空态:`groupBatchIds` 非空是入参硬约束(`@NotEmpty`),空数组会被 Bean Validation 拒在参数绑定阶段;查不到的团期不是「缺键」而是体现为该 id 键值的 `batchFound=false`(见响应示例第二项)。
#### 错误响应
```json
{ "code": 400, "message": "团期 ID 列表不能为空", "success": false, "data": null }
```
```json
{ "code": 400, "message": "单次最多查询 200 个团期", "success": false, "data": null }
```
(本端点没有自定义业务错误码:全程只读、不做鉴权判断,查不到的团期直接体现为 `batchFound=false`,不抛错。)
#### 业务边界
- `claimerId` 走「含软删」的认领人读契约(`listHouseClaimersIncludeDeleted`),与 `batchFound`(活跃行判定,`@TableLogic` 已过滤软删)是两套独立读契约:团期已软删时 `batchFound=false` 但 `claimerId` 仍可能非空。
- `contactable` = `batchFound` 且状态不在 `{SETTLED, CANCELLED}` 中;遇到未知状态码按「不可联系」处理(保守侧,宁可拦住发起,也不因识别不出状态而放行到已结算的团上),同时记 `warn` 日志。
- 全程只读、不开事务,不含任何同步 Feign/MQ。
---
## 四、契约约束与正确调用方式
### 正确 / 错误 payload 对照
| 场景 | payload |
|---|---|
| 正确:端点 1 只传 `groupBatchId` | `{ "groupBatchId": "1930000000000000001" }` |
| 错误:端点 1 传 `peerAdminId` | 会被忽略——两侧对端一律由后端解析摘要,传了也不采信,不是 400 也不是生效 |
| 正确:前端用 `GROUP_HOUSE:{groupBatchId}`(团期聚合主键)作为本地会话主键 | — |
| 错误:复用 `GROUP:{orderId}` 或任何子订单 id 拼 GROUP_HOUSE 的键 | 键空间不同,拿错主键会静默查空(见「关键变化」第 1 条),不会报错提示 |
| 正确:(`order-v3` 侧)`RELEASE` 事件调端点 2 时 `currentClaimerId` 留空/传 `null` | `{ "groupBatchId": "...", "currentClaimerId": null, "eventType": "RELEASE" }` |
| 错误:给 `GroupHouseClaimerReconcileReqVO.currentClaimerId` 补 `@NotNull` | 会让 `RELEASE` 事件在参数校验阶段被拒,会话永远停在旧认领人身上——这是刻意的空值语义,不是遗漏(见 `GroupHouseClaimerReconcileReqVO.java:12-15` 类注释) |
| 正确:判断端点 1 返回的团队占位对端 | `peerAdminId="0"` 且 `peerRole` 为 `GROUP_ADMIN`/`HOUSE` 时是团队占位,不是具体人 |
| 错误:把 `peerAdminId="0"` 当真实员工 id 查姓名/头像 | 占位行没有对应的 `admin_user` 记录 |
### 切换状态时的必要动作
- 团期从「未认领」变为「已认领」(房务抢单成功)后,`hl-order-service-v3` 会在 `afterCommit` 调端点 2 完成收敛;前端下一次调端点 1(重新 open)就能拿到刷新后的 `order`/`peerAdminId`,不需要也不应该在本地把旧的「待认领」占位数据继续展示或本地拼接猜测认领人信息。
---
## 五、数据库行为
- **零 Flyway / 零 DDL**:本单未新增/修改任何表结构。GROUP_HOUSE 会话复用既有的 `admin_conversation_member`(成员/水位行)与 `admin_message`(消息行)两张表,只是新增了 `biz_module=GROUP_HOUSE` 取值的行,与 FLEET(#4937)/GROUP(#7211)同构。
- **团队共享未读水位**沿用既有机制:`admin_id=0` 的成员行承载团队共享已读水位,任一持权限码的管理员读过即对全员清零,未引入新的水位承载方式。
- 收敛端点(端点 2)与开会话/发消息共用同一把 `Lock4j` 分布式锁(锁名 `fleet-order-chat`,key=`GROUP_HOUSE:{groupBatchId}`),保证「收敛认领人归属」与「聊天读写」在同一团期上严格串行,不产生半途状态的成员行。
- `hl-order-service-v3` 侧既有 `GroupBatchDetailRespVO`(A2 团期详情响应)顺带新增只读展示字段 `houseChatUnreadCount`,纯运行时聚合(事务外同步 Feign 查询 `hl-user-service` 未读数),不涉及任何新表/新列。
---
## 六、边界行为
- 端点 1:团尚未被认领时,管理员侧可打开(消息落团队共享池),房务侧任何人打开都返 281002;认领发生后,管理员再次打开会自动把 `order`/`peerAdminId` 切到认领人本人的信息,不需要额外操作。
- 端点 1:`281017`(团期不可联系)只作用于 `open`/`send`——会话一旦建立,历史消息仍可通过既有 `messages`(GET) 分页读取,团期结算/取消后依然可审计读取历史,不会因此把已有会话「锁死」到无法查看。
- 端点 2:同一批 `CLAIM`/`TAKEOVER`/`RELEASE` 事件即便乱序到达,最终收敛结果都以服务端当次重读的权威摘要为准,不会被过期/乱序事件覆盖回退。
- 端点 2:该团尚无既有会话(没人打开过端点 1)时返回 `data:false`,**不会**为了「记录一下」而制造一条空的会话成员行。
- 端点 3:`groupBatchIds` 传超过 200 个直接 400,不落任何库查询;查不到的团期只体现为 `batchFound=false`,不是错误,也不会缺键。
---
## 六.5、枚举 / 数据字典
### `eventType`(端点 2 入参,`GroupHouseClaimerReconcileReqVO.eventType`)
**所属字段**: `eventType` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `CLAIM` | 认领 | 整团认领成功后触发,把团队池里的历史消息改判给新认领人 |
| `TAKEOVER` | 接管 | 团级接管成功后触发(组长/超管指派新房务),归档旧认领人成员行 |
| `RELEASE` | 释放 | 整团释放后触发,`currentClaimerId` 恒为 `null`,团队侧各行对端回退占位 |
(仅用于日志与幂等语义标注,不参与收敛分支判断——真正结果完全由服务端重读的 order-v3 权威摘要决定。)
### `batchStatus` / `batchStatusName`(端点 3 出参,`GroupBatchStatus`,九态,与 `#7324` 同一枚举)
**所属字段**: `batchStatus`(码)/ `batchStatusName`(中文) | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `RECRUITING` | 招募中 | — |
| `RESOURCE_PREPARING` | 资源准备中 | — |
| `MATERIAL_PREPARING` | 物料准备中 | — |
| `PENDING_DEPARTURE` | 待出发 | — |
| `TRAVELLING` | 出行中 | — |
| `TRIP_FINISHED` | 出行完毕 | — |
| `REVIEWING` | 核单中 | — |
| `SETTLED` | 已结算 | `contactable=false`,端点 1 的 `open`/`send` 返 281017 |
| `CANCELLED` | 已取消 | `contactable=false`,端点 1 的 `open`/`send` 返 281017 |
### 错误码新增清单(`ChatErrorCode.java`,段位 281000-281099 内本单实际新增 2 个常量)
| Code | 常量名 | Message | module | 触发条件 |
|---|---|---|---|---|
| 281016 | `CHAT_GROUP_HOUSE_BATCH_REQUIRED` | 缺少团期上下文 | user | 仅 `ChatManager.openGroupHouse` 内部/单测直调可达;标准 HTTP 请求因 `ChatOpenGroupHouseReqVO.groupBatchId` 的 `@NotNull` 会先被 Bean Validation 拦成 400,实际走不到本码 |
| 281017 | `CHAT_GROUP_HOUSE_NOT_CONTACTABLE` | 团期不存在、已结算或已取消,无法发起房务会话 | user | 端点 1 授权通过后,前置条件 `contactable=false`(团期不存在/已软删/`SETTLED`/`CANCELLED`),只作用于 `open`/`send` |
(复用的既有错误码,非本单新增:**281002**「无权访问该会话」(`CHAT_NOT_MEMBER`)本次被端点 1「非会话成员/团队-个人侧准入不满足/摘要不可用 fail-closed」与端点 2「收敛时重读团期摘要失败」两处复用。)
---
## 七、不影响范围
- 既有会话模块 `DIRECT`/`HOUSE`/`HOUSE_LEAD`/`FLEET`(#4937/#4689)/`GROUP`(#7211)的开会话、发消息、拉线程、已读、未读端点**零改动**;`ChatConversationRespVO`/`ChatOrderCardVO` 新增的 GROUP_HOUSE 专属字段(`groupBatchName`/`departDate` 等)对非 GROUP_HOUSE 会话恒为 `null`,不影响既有渲染逻辑。
- 网关路由**零改动**:`open-group-house` 挂在既有 `ChatMessageController`(`@RequestMapping("/admin/message/chat")`)下,落在 `hl-gateway` 现有 `Path=/admin/message/**` 规则内;两个 internal 端点均不经网关(Feign LB 直连)。
- 两个 internal 端点**不经网关路由匹配**(`hl-gateway/src/main/resources/application.yml` 未给 `/internal/message/**`、`/internal/house/**` 配任何 `Path` predicate,只有 `/v3/internal/**`、`/internal/fleet/**` 两段专属前缀被路由,本单两个端点都不在其中),消费方走 Feign LB 直连。**若误经网关域名访问会得到 HTTP 200 + 业务体 `{"code":404,"message":"接口不存在: <path>"}`(`GlobalErrorWebExceptionHandler`,路由未命中 `NotFoundException` 分支,HTTP 状态码固定 200),不是 403,也不是网络层直接拒绝**——`JwtAuthFilter.isInternalPath()` 里确实有 403(`"接口不可访问"`)分支,但请求在路由层就未命中,根本轮不到这个 filter 执行;2026-09-10 之前的口径「网关对 `/internal/**` 一律 403」不准确,已按实测订正。
- **零 Flyway / 零 DDL**,无需同步任何 H2 schema。
- `hl-order-service-v3` 既有团期详情接口(A2)新增的 `houseChatUnreadCount` 是纯新增只读字段,不改变该接口既有任何字段/错误码语义。
- 团期需求(H09 系列)、团期抢单池/我的团/接管(`#7322`)、团期核单/结算、房务团期看板与按日订房计划(`#7324`)现有端点**零改动**。
---
## 八、测试环境已验证
- 单元/契约测试已全绿(squash `0f62fb072`,PR #7490,已合并 dev-v3):`ChatManagerGroupHouseTest`、`ChatMessageServiceGroupHouseTest`、`ConversationMemberServiceGroupHouseTest`、`TeamChatAuthorizationServiceTest`、`HouseChatSummaryInternalControllerTest`、`GroupBatchChatSummaryBatchReqVOValidationTest`、`GroupBatchChatSummaryFeignContractTest`(钉住 order-v3/user-service 两侧 `GroupBatchChatSummaryItemVO`/`GroupBatchChatSummaryFeignItemVO` 字段对齐,含 `departDate`/`endDate` 均为 `LocalDate`)、`GroupHouseClaimerReconcileContractTest`、`ChatBindFeignFallbackFactoryTest`、`ChatErrorCodeTest` 等。
- **已部署测试服并实测通过**(2026-09-10):`hl-user-service`、`hl-order-service-v3` 均已滚动部署到 `dev-v3` 当前 tip `0f62fb072`(PR #7490),两实例均 LISTEN,Nacos `healthy=true enabled=true`。
- `POST /admin/message/chat/open-group-house`:团未认领时管理员侧打开返 200(`peerAdminId=0`,不是 `null`,见「关键变化」第 2 条);`fwzz_pure01`/`shuxin`/`fwzz_lead01`/`test_admin` 等非当前认领人角色全部命中 **281002**;真实 `conversationKey=GROUP_HOUSE:2097500233511362561`,按团期聚合主键建键(非子订单 id)与设计一致。
- `POST /internal/message/chat/reconcile-group-house-claimer`、`POST /internal/house/group-batch-chat-summary-batch`:两个 internal 端点均经 Feign LB 直连打通;`GET /v3/admin/order/group-batch/{groupBatchId}` 响应确认带 `houseChatUnreadCount` 字段(见「关键变化」第 5 条)。
- 网关侧复核:两个 internal 端点未配路由,经网关域名访问返 HTTP 200 + `{"code":404,"message":"接口不存在: ..."}`,不是 403(见「七、不影响范围」订正说明)。
- 测试数据已清理:`admin_message`、`admin_conversation_member` 两张表实测数据均 `COUNT=0`,不残留测试脏数据。
- 兼容性结论:全部为新增端点,无存量调用方,无向后兼容负担;`hl-order-service-v3` A2 团期详情接口新增的 `houseChatUnreadCount` 字段是非破坏性纯新增。
---
## 十、相关文档
- `#7323`《团期房务实现方案 v1.0》(错误码段位、模块划分定案)
- `#7322` 团期整团抢单与抢单池分流(本单「当前整团认领人」判定的前置依赖)
- `#7211` 定制师↔团期管理员 GROUP 会话(本单复用的「个人↔团队」会话框架源头)
- `#7324` 房务团期看板与整团按日订房计划 CRUD(本单联调入口所在页面)
---
## 关联 / 联系人
### 链接
- **Issue**: [#7328](https://git.1814.love:8443/wx/HL/issues/7328)
- **PR**: [#7490](https://git.1814.love:8443/wx/HL/pulls/7490)
- **Merge commit**: [0f62fb072](https://git.1814.love:8443/wx/HL/commit/0f62fb0722837e496c2b9b16cb3d720ea16cb366)
### 联系人
- **后端负责人**: @wx