# 【新增接口·管理后台】内部员工站内信收件箱 + SSE 实时推送（房务/车务可用）

> **PR**: #3424 #3425（工单 #3414）
> **服务**: hl-user-service + hl-gateway | **更新时间**: 2026-06-04
>
> **存放目录**: `changelogs-v2/2026-06/`
> **影响范围**: 管理后台「通知中心 / 顶部消息铃铛 / 自定义推送」
> **状态**: 已合并 dev-v3 + 测试服双实例部署 + API 全链路实测通过（含 SSE 实时推送过网关 9443）

---

## ⚠️ 关键说明

原站内信只能发给 **C 端微信客人**，**内部员工（管理后台账号 admin_user：房务管家、车务调度、运营等）收不到站内信、只能收企微**。本次补齐：

1. **内部员工有了自己的站内信收件箱**（`/admin/message/*`，与 C 端 `/mp/message` 完全独立）。
2. **SSE 实时推送**：前端用 `EventSource` 连一条长连接，后端产生新站内信时**实时下发**，刷新顶部铃铛角标（无需轮询）。
3. **自定义推送可选内部员工**：原「自定义推送」选人只能搜 C 端客人，现可**按角色 / 按人**选内部员工发站内信。

> 前端需要做三块：① 顶部消息**铃铛 + 未读角标** ② 站内信**收件箱页**（列表/分类/已读/删除）③ **EventSource 客户端**（连接 + 收到事件刷新角标）。下面给全套接口契约 + SSE 对接细节。

### 🔔 入口位置（重点，别漏）

当前管理后台「通知中心」只有 通知配置 / 消息分类管理 / 通知日志 / 自定义推送，**缺一个让登录员工看「发给自己」站内信的入口**。请加两个入口（指向同一套 `/admin/message/*` 接口）：

1. **全局顶部铃铛**（所有页面常驻）：红点未读数 = `unread-count`，由 SSE 实时刷新；点击下拉显示最近几条 + 「查看全部」。
2. **「我的消息」页**：**平铺收件箱**——分页列表（`list`）+ 单条/全部已读 + 删除（内部员工站内信统一 SYSTEM 分类，**不需要分类 tab**）。

⚠️ **菜单是 DB 驱动（`sys_menu` 表）**：本系统后台菜单存在 `sys_menu`，每行 `component` 指向前端组件（如「自定义推送」→ `notification/CustomPush`）。所以加「我的消息」要**两步一起做**：① 在 `sys_menu`「通知中心」(menu_id=950) 下加一行（`component` 指向新页面如 `notification/MyMessages`，可经 系统管理→菜单管理 加或随前端发版）② 实现该前端组件。**只加菜单行不建组件 = 点开空白页**，务必同步。

> 注意区分：现有「自定义推送」是**发**消息（运营发给员工/客人），本次新增的是**收**消息（员工看自己的站内信），是两个不同入口。

---

## 1. 接口清单

| # | 用途 | 方法 / 路径 |
|---|------|------------|
| 1 | **SSE 实时长连接** | GET `/ws/admin-msg/stream`（text/event-stream，见 §3） |
| 2 | 收件箱列表（分页，可按分类） | GET `/admin/message/list` |
| 3 | 未读总数（铃铛角标） | GET `/admin/message/unread-count` |
| 4 | 分类未读汇总 | GET `/admin/message/categories` |
| 5 | 单条标已读 | PUT `/admin/message/{id}/read` |
| 6 | 全部标已读 | PUT `/admin/message/read-all` |
| 7 | 某分类标已读 | PUT `/admin/message/category/{categoryCode}/read` |
| 8 | 删除单条 | DELETE `/admin/message/{id}` |
| 9 | 自定义推送选内部员工（搜人） | GET `/admin/notification/custom-push/admins` |
| 10 | 自定义推送（发内部员工站内信） | POST `/admin/notification/custom-push`（channel=ADMIN_INAPP，见 §5） |

> 全部走管理后台 admin token（顶部铃铛/收件箱用当前登录员工身份，后端从网关注入的 X-Admin-Id 取人，前端无需传 adminId）。

---

## 2. 背景

房务/车务等业务需要给「内部员工」实时通知（到岗/到人），而站内信此前是 C 端专属。本次给内部员工建独立收件箱 `admin_message`（按 adminId 维度），并用 SSE 做实时角标刷新。SSE 仅作「有新消息了」的轻量信令，正文仍走收件箱列表接口拉取，DB 为唯一真相源（即便 SSE 偶尔断线也不丢消息，重连后拉未读对账即可）。

---

## 3. SSE 实时推送对接（重点）

### 3.1 建立连接

前端用浏览器原生 `EventSource` 连接。**EventSource 不能自定义请求头**，所以 token 走 query 参数 `?token=`：

```javascript
const token = /* 当前登录 admin token */;
const es = new EventSource(`https://api.test.1814.love:9443/ws/admin-msg/stream?token=${token}`);
```

- 鉴权：网关校验 token 通过后注入身份，未带 token / 无效 token 返 401。
- 缓冲：后端已下发 `X-Accel-Buffering: no` 关闭 nginx 反代缓冲，前端无需处理。
- 保活：服务端每 25 秒发 `:ping` 心跳，浏览器自动忽略。
- 重连：`EventSource` 断线自动重连；**重连后建议主动调一次 `/admin/message/unread-count` 对账**（防断线期间漏推）。

### 3.2 事件类型

| event | data | 说明 |
|-------|------|------|
| `connected` | `ok` | 连接建立确认（可忽略，仅表示连上了） |
| `message` | JSON（见下） | **有新站内信**，按此刷新角标 |
| （`:ping` 注释行） | — | 心跳，浏览器自动忽略 |

`message` 事件的 data 是轻量信令：

```json
{ "adminId": 1001, "unreadCount": 2, "title": "realtime through nginx" }
```

- `unreadCount`：该员工当前未读总数 → **直接用它刷新铃铛角标**。
- `title`：新消息标题 → 可选弹个 toast 提示。
- 正文/列表：收到 `message` 后**调 `/admin/message/list` 拉最新一页**展示，不在 SSE 里传全文。

### 3.3 前端处理示例

```javascript
es.addEventListener('message', (e) => {
  const sig = JSON.parse(e.data);
  setBadge(sig.unreadCount);        // 刷新铃铛角标
  toast(`新消息：${sig.title}`);    // 可选
  if (inboxOpen) reloadInboxList(); // 收件箱打开着就刷新列表
});
es.onerror = () => { /* EventSource 会自动重连；重连后 fetchUnreadCount() 对账 */ };
```

---

## 4. 收件箱接口详情

### 4.1 列表 GET `/admin/message/list`

Query：`pageNo`(默认1) / `pageSize`(默认20，≤100) / `categoryCode`(可选，按分类过滤)

```bash
curl -H "Authorization: Bearer <adminToken>" \
  "https://api.test.1814.love:9443/admin/message/list?pageNo=1&pageSize=20"
```

响应 `data`：`{ records: AdminMessageRespVO[], total, ... }`（标准分页）。

### 4.2 未读数 / 分类汇总

```bash
# 铃铛角标
GET /admin/message/unread-count            -> data: 2 (整数)
# 分类未读汇总
GET /admin/message/categories              -> data: [{categoryCode, categoryName, unreadCount}, ...]
```

> ⚠️ **内部员工站内信分类统一为 SYSTEM**（不用 C 端 订单/行程/促销 分类）。所以 `categories` 只会返回一条 `SYSTEM`，**「我的消息」做平铺列表即可，不需要分类 tab**（`category/{code}/read` 也基本用不到，标已读直接用 `read-all` 或单条）。

### 4.3 标已读 / 删除

```bash
PUT    /admin/message/{id}/read                      # 单条已读
PUT    /admin/message/read-all                       # 全部已读
PUT    /admin/message/category/{categoryCode}/read   # 某分类已读
DELETE /admin/message/{id}                            # 删除（软删）
```

> 进入某条消息 / 某分类时调对应「已读」，未读数随即下降；越权防护：只能操作自己的消息（后端按 adminId 限定）。

---

## 5. 自定义推送选内部员工

### 5.1 搜人 GET `/admin/notification/custom-push/admins`

Query：`keyword`(姓名模糊，可空) / `roleKey`(按角色过滤，可空)

```bash
curl -H "Authorization: Bearer <adminToken>" \
  "https://api.test.1814.love:9443/admin/notification/custom-push/admins?keyword=张"
```

响应 `data`：`AdminPushTargetVO[]`（`adminId`/`name`/`username`/`roleKey`/`roleName`），供选人下拉。

> 🔴 **2026-06-05 前端接线 bug（mmg 必看）**：测试环境「自定义推送」页选「站内信(内部员工)」+「指定员工」后，**「选择员工」下拉显示「无数据」**。查测试服 nginx 访问日志实证：该页面浏览器**只调了 `/admin/role/all`（给「按角色」用），对本接口 `/admin/notification/custom-push/admins` 零调用**。即下拉 UI 建了但**没接搜索接口**。后端实测 200、返 20 个员工，**后端没问题**。
> - **修法**：把「指定员工」的选择员工下拉接成**远程搜索**——输入时调 `GET /admin/notification/custom-push/admins?keyword={输入值}`，用返回的 `adminId` 作 option value、`name`(无则 username) 作 label，最终提交 §5.2 的 `adminIds`。
> - ⚠️ **字段名和 C 端不一样别照搬**：C 端选人接口 `/custom-push/users` 返 `id`/`realName`/`phone`，本员工接口返的是 **`adminId`/`name`/`username`**。若克隆 C 端组件，必须改字段映射（`id→adminId`、`realName→name`），否则即便接上了也会因取不到字段而「无数据」。

### 5.2 发内部员工站内信 POST `/admin/notification/custom-push`

在原「自定义推送」请求上**新增**：`channel="ADMIN_INAPP"` + 两种目标类型：

```bash
# 按指定员工
curl -X POST -H "Authorization: Bearer <adminToken>" -H 'Content-Type: application/json' \
  -d '{"channel":"ADMIN_INAPP","targetType":"ADMIN_SPECIFIC","adminIds":[1001,1002],
       "title":"系统维护通知","content":"今晚 22:00 维护","categoryCode":"SYSTEM"}' \
  "https://api.test.1814.love:9443/admin/notification/custom-push"

# 按角色（roleKeys 如 house_lead/house_keeper）
#  body: {"channel":"ADMIN_INAPP","targetType":"ADMIN_BY_ROLE","roleKeys":["house_lead"], "title":..,"content":..}
```

响应：`{ totalCount, successCount, failCount }`。被推的员工立即在 SSE 收到 `message` 事件、收件箱多一条。

> 说明 1：**消息分类对内部员工固定 SYSTEM**，后端强制写 SYSTEM，前端**「消息分类」下拉对内部员工可隐藏/不传**（传了也会被忽略为 SYSTEM）。
> 说明 2：按角色（ADMIN_BY_ROLE）当前覆盖**已绑企微的同角色活跃员工**；要覆盖未绑企微的，用 ADMIN_SPECIFIC 指定人。

---

## 6. 数据结构

### 6.1 AdminMessageRespVO（收件箱条目）

| 字段 | 类型 | 说明 |
|---|---|---|
| `messageId` | string | 消息 ID（雪花，String 透传防精度丢失） |
| `categoryCode` | string | 分类编码（SYSTEM/ORDER/TRIP/PROMO …） |
| `title` | string | 标题 |
| `content` | string | 正文 |
| `link` | string | 跳转链接（可空） |
| `bizId` / `bizType` | string | 业务关联（可空） |
| `isRead` | int | **0=未读 1=已读**（数字，非布尔） |
| `createTime` | long | 毫秒时间戳 |

### 6.2 AdminPushTargetVO（选人）

`adminId`(string) / `name`(企微真名优先，回退用户名) / `username` / `roleKey` / `roleName`

### 6.3 SSE message 信令

`adminId`(number) / `unreadCount`(number) / `title`(string)

---

## 9. 业务边界

- ✅ **与 C 端站内信完全隔离**：内部员工走 `admin_message`，C 端客人走 `user_message`，互不影响。
- ✅ **DB 为真相源，SSE 仅信令**：SSE 丢一条不丢数据（重连拉未读对账）。
- ✅ **多实例安全**：经 Redis Pub/Sub 跨实例广播，员工连任意实例都能实时收到。
- ✅ **越权防护**：收件箱所有读写按当前 adminId 限定，只能看/操作自己的消息。
- ✅ **房务/车务事件**：内部员工类事件（房务组长/房务组/认领人等）开站内信后也会落员工收件箱（与企微并行）。

---

## 11. 影响评估

- **破坏性**：无（全为新增接口 + 新增 channel/targetType，C 端站内信与原自定义推送 C 端路径零改动）。
- **前端是否必须同步**：是（铃铛 + 收件箱页 + EventSource 客户端 + 自定义推送选人加「内部员工」选项）。
- **存量数据**：无影响（新表新建）。

---

## 12. 注意事项

1. **SSE token 走 query**（`?token=`），EventSource 不能带 Authorization 头；后端已处理 nginx 缓冲，前端正常用 `EventSource` 即可。
2. **SSE 是信令不是正文**：收到 `message` 用 `unreadCount` 刷角标，正文调 `/admin/message/list` 拉。
3. **重连对账**：EventSource 自动重连，重连后调一次 `unread-count` 防漏推。
4. **isRead 是数字 0/1**（不是布尔），`messageId`/`adminId` 出参注意是 String 透传防精度。
5. **未读角标双来源**：登录/刷新页用 `unread-count` 拉一次初值，之后由 SSE `message` 事件增量刷新。

---

## 13. 关联

- 工单 #3414；PR #3424（主功能）+ #3425（SSE 过 nginx 缓冲修复）
- 后端负责人：@wx
- 前端对接（管理后台）：mmg
