docs(changelog): #8182 HOUSE 内部员工站内信 link 由小程序路径订正为管理后台路由,并明确 bizId 不是路由键(修复-管理后台)
changelog-filename-gate / validate (push) Failing after 2s

三个内部员工事件码(HOUSE_INVENTORY_CHECKED / HOUSE_INQUIRY_TIMEOUT /
HOUSE_INQUIRY_ESCALATED)的 inapp_link_template 由 /pages/house/* 改为
/housekeeper/*;字段结构不变,变的是值。

本条 frontend_status=pending 而非 not_required:22_8155 写过「前端 jumpToBiz
从不读 row.link,无需前端改动」,那句话仅在 REQUIREMENT_REJECTED 上成立——
核房/团期事件的 bizId 根本不是订单,按 bizId 反推跳转必然打开不存在的订单。
正文已点名 22_8155 并写清适用范围。

Refs #8182

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
API Changelog Bot
2026-09-22 17:38:41 +08:00
共同撰写人 Claude Opus 5
父节点 a2120cf1ec
当前提交 2c9b21f796
@@ -0,0 +1,353 @@
---
schema: "hl-changelog/v2"
ticket: "8182"
title: "通知中心: HOUSE 内部员工站内信 link 由小程序路径订正为管理后台路由,并明确 bizId 不是路由键"
consumer: "admin"
author: "wx(GIT)"
change_type: "修复"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "本条不改变 AdminMessageRespVO / AdminMessagePageReqVO 的字段结构,只修正 notification_event_config.inapp_link_template 中三个内部员工事件码的路由域(小程序 /pages/* → 管理后台 /housekeeper/*),并在 HouseNotificationPublisher 类注释上固化「(bizType, bizId) 不是路由键」这一约束。gateway_status=not_required:零新增路由,受影响的 GET /admin/message/list 与 GET /admin/message/{id} 是既有端点。frontend_status=pending:本条与 22_8155 不同,它需要前端改动——hl-ui 的 jumpToBiz(origin/v2.1 590c1155,src/views/notification/MyMessages/index.vue:268-286)目前只读 bizId/bizModule/bizType/peerRole,对 HOUSE 核房通知会落进 getOrderReadableRoute(bizId) 分支,把 hotelId 当 orderId 打开订单详情页;正确做法是改读 link 字段(该字段早已存在于 AdminMessageRespVO:31,不是本次新增)。backend_status=deployed:hl-user-service 与 hl-order-service-v3 均已滚动到测试服 776c0023d,Flyway 20260922.210 success=1(installed_on 2026-09-22 17:18:51),并在自建酒店夹具上走完「核房提交 → 站内信产出 → GET /admin/message/list 读回」全链路,实测读数见正文第八节。⚠️ admin_message 是快照表:link 在 createMessage 落库那一刻按当时的模板一次性渲染写入行内,不是按需渲染;因此本修复只影响生效后新产生的消息,测试库里此前已产出的 213 条 HOUSE_INVENTORY_CHECKED 历史消息 link 仍是旧的小程序路径,不做批量回刷——order-v3 / fleet 尚未上生产,不涉及生产数据。"
updated_at: "2026-09-22"
base: "dev-v3"
---
# 通知中心: HOUSE 内部员工站内信 link 由小程序路径订正为管理后台路由,并明确 bizId 不是路由键
> **存放目录**: 二期(order-v3)→ `changelogs-v2/2026-09/`
>
> **服务**: hl-user-service(通知分发 / 站内信收件箱 / 配置表)+ hl-order-service-v3(通知发布方,本次仅注释)
> **PR**: [#8189](https://git.1814.love:8443/wx/HL/pulls/8189)(迁移 + 注释 + 迁移测试)、[#8190](https://git.1814.love:8443/wx/HL/pulls/8190)(补注释漏枚举)
> **Issue**: [#8182](https://git.1814.love:8443/wx/HL/issues/8182)
> **日期**: 2026-09-22
> **影响范围**: 管理后台站内信中心里 HOUSE 类通知的「跳转」行为
---
## ⚠️ 关键变化
1. **三个内部员工事件码的 `link` 换了路由域**:`HOUSE_INVENTORY_CHECKED` / `HOUSE_INQUIRY_TIMEOUT` / `HOUSE_INQUIRY_ESCALATED` 的 `link` 由小程序路径 `/pages/house/...` 改为管理后台路由 `/housekeeper/calendar` 与 `/housekeeper/todos`。字段名、类型、位置都没变,**变的是值**。
2. **「跳转」必须改读 `link`,不能再从 `(bizType, bizId)` 反推。** 这是本条与 `22_8155` 的关键差别:`22_8155` 的正文写过「前端 `jumpToBiz` 只读 `row.bizId/bizModule/bizType/peerRole`,从不读 `row.link`,无需任何前端代码改动」——**那句话在 `REQUIREMENT_REJECTED` 这一个事件上成立,不能推广到 HOUSE 全域**。`REQUIREMENT_REJECTED` 的 `bizId` 修好之后确实就是 `orderId`,按订单跳是对的;但核房、团期两类事件的 `bizId` **根本不是订单**(见第四节 bizId 三义表),任何「拿 bizId 当订单 id 跳」的写法在它们身上必然打开一个不存在的订单。因此本条的 `frontend_status` 是 `pending`,不是 `not_required`。
3. **`link` 为空、或其路径不在管理后台路由表内时,不要渲染「跳转」入口**。存量历史消息的 `link` 仍是旧的小程序路径,它们在后台是打不开的。
---
## 一、背景
### 缺陷形态
缺陷**不是**「`link` 为空」,而是「`link` 非空、但写的是另一个端的路由」——这两者在「`link` 是否非空」这个判据下完全同形,所以此前没人发现。
```sql
-- notification_event_config 里 category_code='HOUSE' 的 6 行,inapp_link_template 全部非空
-- 且全部形如 /pages/house/...(小程序路由)
-- 管理后台路由的权威源里,没有任何一条 /pages/* 路径
SELECT COUNT(*) FROM sys_menu WHERE path LIKE '/pages/%' AND status='ACTIVE';
-> 0
-- 测试库里已产出的 HOUSE 站内信,link 100% 落在小程序路由域
SELECT IFNULL(event_code,'<NULL>') ec, kind, COUNT(*) cnt,
SUM(link IS NULL) null_link, SUM(link LIKE '/pages/%') pages_link
FROM admin_message WHERE biz_type='HOUSE' GROUP BY 1,2;
-> HOUSE_INVENTORY_CHECKED NOTIFY 213 0 213
-> <NULL> CHAT 107 107 NULL
```
(那 107 行 `link IS NULL` 的是房务 IM 聊天消息,`kind='CHAT'`、`event_code IS NULL`,不经通知中心分发,本就不该有 `link`,与本缺陷无关。)
### 触发路径
管理后台 → 消息中心 → 一条「核房记录已更新」通知 → 操作列「更多」→「跳转」。
前端侧当前实现(hl-ui `origin/v2.1` @ `590c1155`,只读查证、未改动):
- `src/views/notification/MyMessages/index.vue:477` — `if (row.bizId) more.push({ text: '跳转', ... onClick: jumpToBiz })`,**只要 `bizId` 非空就渲染「跳转」**,NOTIFY 行同样渲染。
- `src/views/notification/MyMessages/index.vue:268-286` — `jumpToBiz(row)` 依次判断 `mod === 'FLEET'` → 车务看板;`peer === 'CUSTOMIZER' && (mod === 'HOUSE' || mod === 'HOUSE_LEAD')` → `/housekeeper/orders?orderId=<bizId>`;**否则 → `getOrderReadableRoute(bizId, userStore)`**。
- `src/utils/orderAccess.js:48-54` — `getOrderReadableRoute` 对受限房务角色返回 `/housekeeper/orders?orderId=<bizId>`,其余返回 `/order-v2/detail/<bizId>`。
核房通知是 `kind='NOTIFY'`、没有 `peerRole`,于是落进最后那个 `else` 分支 ⇒ 用 **hotelId** 去打开 `/order-v2/detail/<hotelId>`。雪花 ID 形态一致,页面不会报「参数非法」,只会报「订单不存在」或空白——**失败是静默的**。
### 数据落地路径(已核实的完整链路)
| 环节 | 位置 |
|---|---|
| 业务入口 | `POST /v3/admin/house/hotels/{hotelId}/check-log` — `HouseHotelAdminController.java:63-70` |
| 组装 extras | `HouseInventoryCheckService.java:153-164`,放入 `hotelId` / `checkDate` / `roomTypeCount` / `operatorId` / `operatorName` / `hotelName` |
| 发布事件 | `HouseNotificationPublisher.publishNotificationEvent("HOUSE_INVENTORY_CHECKED", hotelId, notifyExtras)` — `HouseNotificationPublisher.java:111-139`,**bizId 实参就是 hotelId** |
| MQ | `HL_NOTIFICATION_EVENT_TOPIC` → `NotificationEventConsumer` |
| 渲染 link | `NotificationDispatcher` 按 `notification_event_config.inapp_link_template` 用 extras 渲染占位符 |
| 落库 | `admin_message`(快照表,`link` 与 `biz_id` 在 `createMessage` 那一刻写死在行内) |
| 读回 | `GET /admin/message/list` — `AdminMessageController.java:44-50` |
---
## 二、变更接口清单
| 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|
| GET | `/admin/message/list` | 出参**取值**变化 | `link` 字段的值域由 `/pages/house/*` 改为 `/housekeeper/*`;字段结构不变 |
| GET | `/admin/message/{id}` | 出参**取值**变化 | 同上 |
**没有**新增 / 删除 / 改名任何字段,也没有新增任何路由。
---
## 三、接口详情
### 1. 站内信分页列表 `GET /admin/message/list`
**使用场景**:管理后台消息中心列表。本条只影响其中 `bizType='HOUSE'` 且 `kind='NOTIFY'` 的行。
**入参**:无变化。
**出参**:`AdminMessageRespVO` 结构无变化。与本条相关的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| `messageId` | String | 消息 ID(雪花,字符串透传) |
| `categoryCode` | String | **`SYSTEM`**,不是 `HOUSE`——见第六节「边界行为」第 1 条 |
| `title` | String | 如「核房记录已更新」 |
| `link` | String | **跳转目标。本条改的就是它。** 字段早已存在(`AdminMessageRespVO.java:31`),不是本次新增 |
| `bizId` | String | 关联业务 ID,**语义随事件码变化,不是路由键**,见第四节 |
| `bizType` | String | HOUSE 域内恒为 `"HOUSE"` |
| `kind` | String | `NOTIFY`=系统通知 / `CHAT`=聊天 |
**实测响应片段**(自建夹具,完整取证见第八节):
```json
{
"messageId": "2102328540089466882",
"categoryCode": "SYSTEM",
"title": "核房记录已更新",
"link": "/housekeeper/calendar?hotelId=2102328465699270657&date=2026-09-23",
"bizId": "2102328465699270657",
"bizType": "HOUSE",
"kind": "NOTIFY"
}
```
**错误码**:无新增。
### 2. 站内信详情 `GET /admin/message/{id}`
同上,返回结构与 `link` 取值口径一致。
---
## 四、契约约束与正确调用方式
### 「跳转」的唯一正确来源是 `link`
```js
// ✅ 正确
function jumpToBiz(row) {
const link = String(row.link ?? '')
if (!link || !isKnownAdminRoute(link)) {
// 不渲染 / 不跳转,见下方「兜底要求」
return
}
router.push(link)
}
// ❌ 错误:从 (bizType, bizId) 反推页面
router.push(`/order-v2/detail/${row.bizId}`) // bizId 可能是 hotelId / groupBatchId
```
### `bizId` 三义表(HOUSE 域,截至 #8182 的全部生产调用点)
`bizType` 在 HOUSE 域恒为 `"HOUSE"`,而 `bizId` 承载三种互不相同的业务实体,且三者都是雪花 ID、**单看值分辨不出是哪一类**:
| 事件码 | `bizId` 实际是 | 发布位置 |
|---|---|---|
| `HOUSE_HOTEL_SWAPPED_OLD_HOTEL` / `_NEW_HOTEL` / `_CUSTOMER` | **orderId** | `HouseAssignmentService.java:1941/1942/1943` |
| `GROUP_BATCH_HOUSE_CLAIMED` / `GROUP_BATCH_HOUSE_RELEASED` | **groupBatchId** | `HouseGroupGrabService.java:731` |
| `GROUP_BATCH_REQUIREMENT_CONFIRMED` | **groupBatchId** | `GroupBatchRequirementService.java:1286` |
| `HOUSE_INVENTORY_CHECKED` | **hotelId** | `HouseInventoryCheckService.java:164` |
合计 6 条 publish 语句 / 4 个发起方法(换店三条同在一个方法内)。该约束已固化在 `HouseNotificationPublisher` 的类注释里(PR #8189 写入、#8190 补齐漏枚举),新增调用点时要一并维护。
**为什么不把 `bizType` 拆细**:核房是「某酒店某天」的房态盘点,团期抢单是「某个团期班期」的归属变更,它们**在业务上根本没有订单维度**,强行编一个 orderId 只会制造假关联。`bizId` 传各自的真实主键是对的语义,代价就是它不能再兼任路由键。
### 兜底要求
`link` 为空、或 `link` 的路径部分不在管理后台已知路由表内时,**不渲染「跳转」入口**(而不是渲染出来再跳到 404)。理由见第五节:`admin_message` 是快照表,历史行里存着的就是旧路由域的字符串。
### 前端需要知道的取值边界
- `link` 是**相对路径 + query**,不含域名,可直接交给 `router.push`。
- query 里的 ID 一律按**字符串**处理,禁 `Number()`——雪花 ID 超 `Number.MAX_SAFE_INTEGER`。
- `${占位}` 取不到值时会被渲染成空串,形如 `?inquiryId=`。把这种情况按「参数缺失」处理,降级为打开列表页即可。
---
## 五、数据库行为
### `notification_event_config`(Flyway `V20260922_210`,hl-user-service)
纯 UPDATE 无 DDL,按 `event_code`(唯一键 `uk_event_code`)定位,可重复执行:
| event_code | 改前 | 改后 |
|---|---|---|
| `HOUSE_INVENTORY_CHECKED` | `/pages/house/inventory?hotelId=${hotelId}&date=${checkDate}` | `/housekeeper/calendar?hotelId=${hotelId}&date=${checkDate}` |
| `HOUSE_INQUIRY_TIMEOUT` | `/pages/house/inquiry?inquiryId=${inquiryId}` | `/housekeeper/todos?inquiryId=${inquiryId}` |
| `HOUSE_INQUIRY_ESCALATED` | `/pages/house/inquiry?inquiryId=${inquiryId}` | `/housekeeper/todos?inquiryId=${inquiryId}` |
改与不改的判据是**收件人域**:这三条的收件人(`HOUSE_TEAM` / `INQUIRY_CLAIMER` / `HOUSE_LEAD`)都是内部员工,点开只会落在管理后台;而换酒店三事件的收件人是酒店联系人与 C 端客户,本就不属于后台路由域,**一个字节未动**。
两个目标路由在后台菜单表里都存在:
```sql
SELECT path, menu_name, status FROM sys_menu WHERE path IN ('/housekeeper/calendar','/housekeeper/todos');
-> /housekeeper/calendar 日历视图 ACTIVE
-> /housekeeper/todos 待处理 ACTIVE
```
### `admin_message`(快照表)
`link` 与 `biz_id` 在 `createMessage` 落库那一刻一次性写入行内,**不是按需渲染**。所以:
- 生效后**新产生**的消息,`link` 是新路由;
- 此前已产出的 **213 条** `HOUSE_INVENTORY_CHECKED` 历史消息,`link` 仍是旧的小程序路径,**不做批量回刷**(order-v3 / fleet 尚未上生产,测试库脏数据不值得回刷,且回刷会掩盖兜底逻辑缺失)。前端按上面的「兜底要求」处理即可。
---
## 六、边界行为
1. **`categoryCode` 是 `SYSTEM` 而不是 `HOUSE`。** 核房通知虽然 `bizType='HOUSE'`,但落库时 `category_code='SYSTEM'`(`AdminMessageRespVO.java:20-22` 的注释即写明「系统通知为 SYSTEM」)。**用 `categoryCode=HOUSE` 过滤会得到 0 条**,这是实测踩到的坑,按 `bizType` 或不过滤来取。
2. **换酒店三事件当前 5 个渠道零产出。** 它们的 `wework_receiver_type`(`OLD_HOTEL_CONTACT` / `NEW_HOTEL_CONTACT` / `ORDER_CUSTOMER`)不在 `NotificationDispatcher.INTERNAL_INAPP_RECEIVER_TYPES`(`NotificationDispatcher.java:100-103`)内 ⇒ 站内信不落 `admin_message`;`HouseNotificationPublisher` 构造消息时不设 `userId` ⇒ C 端 `user_message` 也不落;其余渠道开关均为 0。所以前端在消息中心里**看不到**这三类消息——这是它们自身的既有状态,与本次改动无关。
3. **三个 `GROUP_BATCH_*` 事件码在配置表里没有行。** 实测 `notification_event_config` 全表 62 行,`event_code LIKE 'GROUP%'` 零命中(同一条 SQL 查 `HOUSE_INVENTORY_CHECKED` 可返回,作阳性对照)。`NotificationDispatcher` 查不到 config 只记日志 ⇒ 团期房务抢单 / 释放 / 需求整体确认**一条通知都不产生**,即 `bizId` 三义中的 `groupBatchId` 那一义对任何消费方目前都不可见。已另立单跟进配置补齐。
4. **`HOUSE_LEAD` 不在站内信白名单内。** 所以 `HOUSE_INQUIRY_ESCALATED` 即便将来接上发布方,站内信也不会落 `admin_message`——它缺的是扩白名单,不是改 `link`。本次对它的订正是「把路由域写对」,当前实际效果为零。同理 `HOUSE_INQUIRY_TIMEOUT` / `_ESCALATED` 在 Java 主代码里目前没有发布方。
5. **聊天消息(`kind='CHAT'`)不受影响**:`link` 本就是 `null`,`event_code` 也是 `null`,不经通知中心分发。
---
## 六.5、枚举 / 数据字典
`bizType`(`AdminMessageRespVO.bizType`)HOUSE 域取值恒为 `"HOUSE"`,**不具备区分事件类型的能力**,不要用它做路由分支。事件类型看 `event_code`(列表接口不返回该字段),跳转看 `link`。
`kind`:`NOTIFY`=系统通知(有 `link`)/ `CHAT`=会话消息(`link` 为 `null`)。
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| `AdminMessageRespVO.link`(核房事件) | `/pages/house/inventory?hotelId=...&date=...` | `/housekeeper/calendar?hotelId=...&date=...` |
| `AdminMessageRespVO.bizId`(核房事件) | hotelId | hotelId(**不变**,本次未动 bizId) |
| 其余字段 | — | 无变化 |
### 行为级对比
| 场景 | 改前 | 改后 |
|---|---|---|
| 后端渲染出的 link 落在哪个路由域 | 小程序(后台不存在该路径) | 管理后台(`sys_menu` 内可查到) |
| 前端按 `bizId` 反推跳转 | 打开 `/order-v2/detail/<hotelId>`,订单不存在 | **仍然错**——所以前端必须改读 `link` |
| 前端按 `link` 跳转 | 跳到后台不存在的 `/pages/*` | 正确落到核房日历页 |
⚠️ 这张表的第二行是重点:**只改后端不改前端,「跳转」依然是坏的**。后端这次把 `link` 修对了,把它变成一个可用的跳转来源;真正让用户点对页面的动作在前端侧。
---
## 六.7、影响评估
- **前端是否必须同步上线**:**是**。`jumpToBiz` 需改读 `link` 并补兜底判断,否则核房类通知的「跳转」仍落空。
- **兼容性**:纯取值变化,老前端不会报错(只是跳转依旧不对),不会因为本条出现新的异常或白屏。
- **数据**:仅 `notification_event_config` 三行 UPDATE,无 DDL、无数据迁移、无回刷。
---
## 七、不影响范围
- 不影响任何接口的字段结构、必填性、类型。
- 不影响 C 端小程序站内信:小程序读的是 `user_message`,本次改的三个事件码收件人都是内部员工,不落 `user_message`。
- 不影响换酒店三事件(`link` 一字节未改)。
- 不影响聊天消息与会话聚合。
- 不影响 `bizId` 的取值——本次没有动任何 `bizId` 实参。
- 不影响 `22_8155` 的 `REQUIREMENT_REJECTED` 结论:那条改的是 `bizId`,本条改的是 `link`,两者互不覆盖。
---
## 八、测试环境已验证
**部署基线**:`hl-user-service` 与 `hl-order-service-v3` 均已滚动到测试服 `776c0023d`(`deploy-status.sh` 读数:两者 `BEHIND=0/N`)。
**Flyway**:
```
version success installed_on
20260922.210 1 2026-09-22 17:18:51
```
**自建夹具**(全部自建,未复用他人数据):
| 项 | 值 |
|---|---|
| 自建酒店 | `hotelId=2102328465699270657`(`8182验收自建酒店-1790069137`,`status=0` 下架) |
| 自建核房记录 | `checkLogId=2102328538344615937`,`checkDate=2026-09-23` |
| 产生的站内信 | `messageId=2102328540089466882` |
**链路实测**:`POST /v3/admin/house/hotels/2102328465699270657/check-log` → `GET /admin/message/list` 读回该条,`link` 为:
```
/housekeeper/calendar?hotelId=2102328465699270657&date=2026-09-23
```
三条断言逐条成立:① 以 `/housekeeper/calendar` 开头;② `hotelId=` 后为自建酒店 ID,逐字相同;③ `date=` 后为自建核房日期 `2026-09-23`,与请求体逐字相同。
**库侧独立复核**(不依赖接口返回):
```
id : 2102328540089466882
category_code : SYSTEM
event_code : HOUSE_INVENTORY_CHECKED
kind : NOTIFY
biz_type : HOUSE
biz_id : 2102328465699270657
link : /housekeeper/calendar?hotelId=2102328465699270657&date=2026-09-23
title : 核房记录已更新
```
`biz_id` 与 `link` 里的 `hotelId` 逐字一致 ⇒ 证明 `bizId` 语义未被本次改动影响。
**存量未回刷的对照**:同一次响应里的历史消息 `messageId=2100088394469044226`(迁移执行前产生)`link` 仍为 `/pages/house/inventory?hotelId=...`,与第五节「不回刷」一致。
**迁移单测**:`HouseInappLinkMigrationMysqlTest`(Testcontainers 真 MySQL 8.0.33 跑迁移)`Tests run: 5, Failures: 0, Errors: 0, Skipped: 0`,含阴性对照(换店三事件 `link` 零字节变化)与幂等复跑。
**改前阴性基线**:`SELECT COUNT(*) FROM sys_menu WHERE path LIKE '/pages/%' AND status='ACTIVE'` → `0`,即改动前没有任何一条 HOUSE link 能在后台路由表里查到——这条读数同时说明旧验收口径「`link` 非空」对本缺陷**没有分辨力**(改动前 6 条就全非空)。
---
## 十、相关文档
- `22_8155_需求驳回站内信bizId改为orderId点跳转落空-修复-管理后台.md` — 同一收件箱、相邻缺陷。⚠️ 其中「前端从不读 `row.link`,无需任何前端代码改动」一句的适用范围**仅限 `REQUIREMENT_REJECTED`**,不适用于本条覆盖的三个事件码,理由见「关键变化」第 2 条。
- `HouseNotificationPublisher` 类注释(hl-order-service-v3)— `bizId` 三义的权威清单,新增调用点时同步维护。
- Flyway `V20260922_210__fix_house_inapp_link_to_admin_routes.sql`(hl-user-service)— 脚本头注写明了「按收件人域取舍」以及为什么不能改用白名单当判据。
---
## 关联 / 联系人
### 链接
- Issue: [#8182](https://git.1814.love:8443/wx/HL/issues/8182)
- PR: [#8189](https://git.1814.love:8443/wx/HL/pulls/8189) / [#8190](https://git.1814.love:8443/wx/HL/pulls/8190)
### 联系人
- 后端: wx
- 前端: 待认领(`frontend_status: pending`)