diff --git a/changelogs-v2/2026-09/10_7328_团期房务整团会话GROUP_HOUSE-新增接口-管理后台.md b/changelogs-v2/2026-09/10_7328_团期房务整团会话GROUP_HOUSE-新增接口-管理后台.md new file mode 100644 index 00000000..016ca916 --- /dev/null +++ b/changelogs-v2/2026-09/10_7328_团期房务整团会话GROUP_HOUSE-新增接口-管理后台.md @@ -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` + +(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` 继承 `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` + +(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` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `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` + +(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` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `summaryMap` | `Map` | 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":"接口不存在: "}`(`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