feat(msg): announcements 公告模块 + sendBatch 批量优化 + 权限扩展 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:57:41 +08:00
parent 7dd5c44406
commit fb23c5234e
28 changed files with 3644 additions and 7 deletions

View File

@@ -138,6 +138,29 @@ CREATE TABLE IF NOT EXISTS `iam_password_history` (
INDEX `idx_iam_password_history_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.11 TOTP 2FA 密钥表RFC 6238
CREATE TABLE IF NOT EXISTS `iam_user_totp` (
`id` CHAR(36) NOT NULL,
`user_id` CHAR(36) NOT NULL,
`secret` VARCHAR(128) NOT NULL,
`status` ENUM('pending','active') NOT NULL DEFAULT 'pending',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_iam_user_totp_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1.12 TOTP 备份码表10 个一次性使用)
CREATE TABLE IF NOT EXISTS `iam_totp_backup_codes` (
`id` CHAR(36) NOT NULL,
`user_id` CHAR(36) NOT NULL,
`code_hash` VARCHAR(255) NOT NULL,
`used_at` TIMESTAMP NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_iam_totp_backup_codes_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 2. Classes 服务services/classes/src/classes/classes.schema.ts
@@ -607,3 +630,34 @@ CREATE TABLE IF NOT EXISTS `processed_events` (
`processed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`event_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5.7 公告主表(广播公告,一条对应一个目标受众)
CREATE TABLE IF NOT EXISTS `msg_announcements` (
`id` VARCHAR(32) NOT NULL,
`title` VARCHAR(255) NOT NULL,
`content` TEXT NOT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'draft',
`is_pinned` BOOLEAN NOT NULL DEFAULT FALSE,
`author_id` VARCHAR(32) NOT NULL,
`target_audience` VARCHAR(32) NOT NULL DEFAULT 'all',
`metadata` JSON NULL,
`published_at` TIMESTAMP NULL,
`archived_at` TIMESTAMP NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_msg_announcements_status` (`status`),
INDEX `idx_msg_announcements_audience` (`target_audience`),
INDEX `idx_msg_announcements_pinned` (`is_pinned`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5.8 公告已读表(每用户已读跟踪,幂等)
CREATE TABLE IF NOT EXISTS `msg_announcement_reads` (
`id` VARCHAR(32) NOT NULL,
`announcement_id` VARCHAR(32) NOT NULL,
`user_id` VARCHAR(32) NOT NULL,
`read_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_announcement_user` (`announcement_id`, `user_id`),
INDEX `idx_msg_announcement_reads_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,469 @@
# msg 模块 Next Steps上下游依赖
> 维护者ai10msg
> 最后更新2026-07-14
> 关联:
>
> - [msg_workline.md](../../docs/architecture/issues/worklines/msg_workline.md)
> - [msg_contract.md](../../docs/architecture/issues/contracts/msg_contract.md)
> - [msg_issue.md](../../docs/architecture/issues/objections/msg_issue.md)
> - [ARB-008](../../docs/architecture/issues/coord.md) §10 / [ARB-013](../../docs/architecture/issues/coord.md) §15
---
## 1. 当前状态总览
| 阶段 | 状态 | 说明 |
| ------------------ | ------- | ----------------------------------------------------------------------------------- |
| P5 通知中台核心 | ✅ 完成 | 13 RPC5 Notification + 2 Preference + 6 Template+ REST 双协议 |
| ARB-008 裁决落地 | ✅ 完成 | RPC 17→134 RPC 降级 REST onlytopic 改为 `edu.notify.notification.*` |
| ARB-013 topic 统一 | ✅ 完成 | 发布 topic `edu.notify.notification.sent/read/recalled/failed` |
| Outbox + 三层幂等 | ✅ 完成 | msg_outbox_events + Redis SETNX + DB + event_id UNIQUE |
| ChannelDispatcher | ✅ 完成 | 5 通道in-app/email/sms/push/wechatPromise.allSettled |
| /readyz 6 依赖检查 | ✅ 完成 | DB/ES/Redis/KafkaProducer/KafkaConsumer/PushGateway |
| 单元测试 | ✅ 完成 | 101 tests / 5 spec files覆盖率 ≥ 80% |
| Announcements 公告 | ✅ 完成 | 新增 REST APIschema+repo+service+controller+module |
| sendBatch 批量优化 | ✅ 完成 | 循环单条插入 → 批量 insertNotifications + eventId 幂等过滤,失败全部标记 failed |
| Docker 本地测试 | ✅ 完成 | 容器启动 + healthz/readyz + 全部 REST API 真实 DB 验证通过(含 sendBatch 幂等场景) |
### 1.1 msg 当前提供的能力
**gRPC 13 RPC端口 50056**
| Service | RPC | 说明 |
| ----------------------------- | ------------------------------------------------------------------------------------- | ----------------- |
| NotificationService | SendNotification | 单条发送 |
| NotificationService | ListNotifications | 列表(分页+过滤) |
| NotificationService | MarkAsRead | 标记已读 |
| NotificationService | SearchNotifications | ES 全文检索 |
| NotificationService | RecallNotification | 撤回广播 |
| NotificationPreferenceService | GetPreferences | 查询偏好 |
| NotificationPreferenceService | UpdatePreferences | 更新偏好 |
| NotificationTemplateService | CreateTemplate/GetTemplate/ListTemplates/UpdateTemplate/DeleteTemplate/RenderTemplate | 模板 CRUD + 渲染 |
**REST endpoints端口 3007**
| 路径 | 方法 | 说明 |
| ---------------------------------------- | -------------- | ------------------ |
| /notifications/send | POST | 单条发送 |
| /notifications/batch | POST | 批量发送 |
| /notifications/user/:userId | GET | 列表 |
| /notifications/user/:userId/unread-count | GET | 未读数 |
| /notifications/:id/read | PUT | 标记已读 |
| /notifications/batch/read | PUT | 批量已读 |
| /notifications/read-all | PUT | 全部已读 |
| /notifications/search | GET | ES 检索 |
| /notifications/recall | POST | 撤回 |
| /notifications/:id | DELETE | 删除 |
| /preferences/user/:userId | GET/PUT | 偏好查询/更新 |
| /templates | GET/POST | 模板列表/创建 |
| /templates/:id | GET/PUT/DELETE | 模板 CRUD |
| /templates/render | POST | 模板渲染 |
| /announcements | GET/POST | 公告列表/创建 |
| /announcements/:id | GET/PUT/DELETE | 公告详情/更新/删除 |
| /announcements/:id/publish | PUT | 发布公告 |
| /announcements/:id/archive | PUT | 归档公告 |
| /announcements/:id/pin | PUT | 置顶切换 |
| /announcements/:id/read | POST | 标记公告已读 |
**Kafka 消费12 类事件)**
- iam: `edu.identity.user.created/updated/deleted/role_changed`, `edu.identity.role.created/updated`
- core-edu: `edu.teaching.exam.published/assignment.submitted/assignment.graded/grade.recorded/attendance.recorded`
- data-ana: `edu.insight.mastery.updated`
**Kafka 发布4 类事件ARB-013 命名)**
- `edu.notify.notification.sent` / `edu.notify.notification.read` / `edu.notify.notification.recalled` / `edu.notify.notification.failed`
- DLQ: `edu.notify.dlq`
---
## 2. 下游依赖msg → 下游模块需要做什么)
### 2.1 teacher-bffai03
**依赖内容**teacher-bff 通过 gRPC 调用 msg 的 5 个 Notification RPC + 公告 REST API
**影响范围**teacher-portal 通知中心 + 公告管理
| 下游需要的操作 | msg 提供方式 | 状态 |
| -------------------- | ------------------------------------------ | --------- |
| ListNotifications | gRPC NotificationService.ListNotifications | ✅ 已实现 |
| MarkNotificationRead | gRPC NotificationService.MarkAsRead | ✅ 已实现 |
| ListAnnouncements | REST GET /announcements | ✅ 已实现 |
| CreateAnnouncement | REST POST /announcements | ✅ 已实现 |
| PublishAnnouncement | REST PUT /announcements/:id/publish | ✅ 已实现 |
**协调方式**teacher-bff 通过 gRPC client 调用 msg:50056通过 HTTP 调用 msg:3007/announcements/*
### 2.2 student-bffai04
**依赖内容**student-bff 通过 gRPC + REST 调用 msg
**影响范围**student-portal 通知 + 公告查看
| 下游需要的操作 | msg 提供方式 | 状态 |
| ---------------------------- | ---------------------------------------------------- | ----------------------------------- |
| ListAnnouncements | REST GET /announcements | ✅ 已实现 |
| GetAnnouncement | REST GET /announcements/:id | ✅ 已实现 |
| MarkNotificationAsRead | gRPC NotificationService.MarkAsRead | ✅ 已实现 |
| MarkAllNotificationsAsRead | REST PUT /notifications/read-all | ✅ 已实现ARB-008 降级 REST only |
| UpdateNotificationPreference | gRPC NotificationPreferenceService.UpdatePreferences | ✅ 已实现 |
| MarkAnnouncementRead | REST POST /announcements/:id/read | ✅ 已实现 |
### 2.3 parent-bffai05
**依赖内容**parent-bff 通过 gRPC 调用 msg
**影响范围**parent-portal 通知偏好
| 下游需要的操作 | msg 提供方式 | 状态 |
| ----------------------------- | ---------------------------------------------------- | --------- |
| listNotifications | gRPC NotificationService.ListNotifications | ✅ 已实现 |
| markAsRead | gRPC NotificationService.MarkAsRead | ✅ 已实现 |
| getNotificationPreferences | gRPC NotificationPreferenceService.GetPreferences | ✅ 已实现 |
| updateNotificationPreferences | gRPC NotificationPreferenceService.UpdatePreferences | ✅ 已实现 |
### 2.4 push-gatewayai09
**依赖内容**msg 通过 HTTP POST /internal/push 推送实时通知push-gateway 消费 msg 发布的 Kafka 事件
**影响范围**WebSocket 实时推送到前端
| 下游需要的协作 | msg 提供方式 | 状态 |
| ------------------------ | ---------------------------------------------- | ------------------- |
| HTTP POST /internal/push | push-gateway.client.ts sendPush() | ✅ 已实现(软失败) |
| Kafka 事件推送 | 发布 `edu.notify.notification.sent` 等 4 topic | ✅ 已实现 |
**push-gateway 需对齐**
- 消费 topic 必须为 `edu.notify.notification.sent`ARB-013 命名),而非旧的 `edu.notification.requested`
- 鉴权头 `X-Internal-Key: PUSH_INTERNAL_TOKEN`
### 2.5 api-gatewayai01
**依赖内容**api-gateway 代理 msg REST 路由
**影响范围**:前端通过 api-gateway 访问 msg
| 路由 | 代理目标 | 状态 |
| ----------------------- | -------- | ------------------------- |
| /api/v1/notifications/* | msg:3007 | ✅ 已配置main.go L100 |
| /api/v1/messages/* | msg:3007 | ✅ 已配置main.go L101 |
**注意**:公告 REST API 在 `/announcements/*` 路径下api-gateway 需新增路由 `/api/v1/announcements/*` → msg:3007或复用 `/api/v1/notifications/*` 前缀下的子路径)。
### 2.6 前端 portalai13/ai14/ai15/ai16
**间接依赖**:前端通过各自 BFF 聚合访问 msg无直接 gRPC/REST 调用。
- teacher-portalai13通过 teacher-bff GraphQL → msg gRPC/REST
- student-portalai14通过 student-bff GraphQL → msg gRPC/REST
- parent-portalai15通过 parent-bff GraphQL → msg gRPC
- admin-portalai16通过 teacher-bff admin GraphQL → msg REST公告管理
---
## 3. 上游依赖msg ← 上游模块需要提供什么)
### 3.1 iamai06— Kafka 事件源
**依赖内容**msg 消费 iam 发布的 6 类 identity 事件
**事件清单**
| Topic | 触发场景 | msg 处理 |
| -------------------------------- | ------------ | ---------------------------- |
| `edu.identity.user.created` | 新用户注册 | 发送欢迎通知 |
| `edu.identity.user.updated` | 用户信息更新 | 仅幂等标记(无通知) |
| `edu.identity.user.deleted` | 用户注销 | 仅幂等标记(无通知) |
| `edu.identity.user.role_changed` | 角色变更 | 发送角色变更通知 |
| `edu.identity.role.created` | 角色创建 | 仅幂等标记 |
| `edu.identity.role.updated` | 角色权限更新 | 向受影响用户发送权限变更通知 |
**payload 约定**JSON
- `userId` / `user_id`: string
- `name` / `username`: string
- `oldRole` / `old_role`: string
- `newRole` / `new_role`: string
- `affectedUserIds` / `affected_user_ids`: string[]
- header `eventId`: string幂等键
### 3.2 core-eduai07— Kafka 事件源
**依赖内容**msg 消费 core-edu 发布的 5 类 teaching 事件
| Topic | 触发场景 | msg 处理 |
| ----------------------------------- | -------- | ------------------ |
| `edu.teaching.exam.published` | 考试发布 | 向学生发送考试通知 |
| `edu.teaching.assignment.submitted` | 作业提交 | 向教师发送提交通知 |
| `edu.teaching.assignment.graded` | 作业批改 | 向学生发送批改通知 |
| `edu.teaching.grade.recorded` | 成绩录入 | 向学生发送成绩通知 |
| `edu.teaching.attendance.recorded` | 考勤异常 | 向家长发送出勤通知 |
**payload 约定**JSON
- `examId`/`exam_id`, `examTitle`/`exam_title`, `className`/`class_name`, `studentIds`/`student_ids`: string[]
- `teacherId`/`teacher_id`, `studentId`/`student_id`, `studentName`/`student_name`
- `homeworkId`/`homework_id`, `homeworkTitle`/`homework_title`, `score`/`grade`
- `subject`, `gradeId`/`grade_id`, `parentId`/`parent_id`
- `attendanceId`/`attendance_id`, `status`, `date`
### 3.3 data-anaai08— Kafka 事件源
**依赖内容**msg 消费 data-ana 发布的 1 类 insight 事件
| Topic | 触发场景 | msg 处理 |
| ----------------------------- | ---------- | ---------------------- |
| `edu.insight.mastery.updated` | 掌握度下降 | 向学生发送学情预警通知 |
**payload 约定**JSON
- `studentId`/`student_id`, `subject`, `mastery`/`masteryLevel`, `trend`
- `masteryId`/`mastery_id`
### 3.4 push-gatewayai09— HTTP 推送目标
**依赖内容**msg 通过 HTTP POST /internal/push 调用 push-gateway 推送实时通知
**调用方式**
- URL: `${PUSH_GATEWAY_URL}/internal/push`
- Header: `X-Internal-Key: ${PUSH_INTERNAL_TOKEN}`
- Body: `{ userId, event, data }`
- 软失败push-gateway 不可用时返回 `{ sent: false }`,不阻断主流程
### 3.5 基础设施依赖
| 依赖 | 用途 | 配置 |
| ------------- | --------------------------------- | ---------------------------------- |
| MySQL | 通知/偏好/模板/公告/outbox 持久化 | `DATABASE_URL` |
| Redis | L1 幂等去重SETNX | `REDIS_URL`(可选,缺失降级 DB |
| Kafka | 消费上游事件 + 发布通知事件 | `KAFKA_BROKERS` |
| Elasticsearch | 通知全文检索 | `ES_URL`(可选,缺失降级 DB LIKE |
| push-gateway | WebSocket 实时推送 | `PUSH_GATEWAY_URL`(可选,软失败) |
---
## 4. 已完成的工作(本轮)
### 4.1 Announcements 公告功能(新增)
**原因**:下游 teacher-bff / student-bff / admin-portal 均需要公告 CRUD + 已读标记msg 原仅有通知能力。
**实现**
- `src/announcements/announcements.schema.ts` — Drizzle schemamsg_announcements + msg_announcement_reads 表)
- `src/announcements/announcements.repository.ts` — 数据访问层
- `src/announcements/announcements.service.ts` — 业务逻辑
- `src/announcements/announcements.controller.ts` — REST API8 端点)
- `src/announcements/announcements.module.ts` — 模块定义
- `src/middleware/permission.guard.ts` — 新增 `MSG_ANNOUNCEMENT_MANAGE` / `MSG_ANNOUNCEMENT_READ` 权限
- `infra/init-sql/02-all-services-schema.sql` — 新增 msg_announcements / msg_announcement_reads 表
**REST 端点**
| 路径 | 方法 | 权限 | 说明 |
| -------------------------- | ------ | ----------------------- | --------------------------------- |
| /announcements | POST | MSG_ANNOUNCEMENT_MANAGE | 创建公告draft |
| /announcements | GET | MSG_ANNOUNCEMENT_READ | 列表(支持 status/audience 筛选) |
| /announcements/:id | GET | MSG_ANNOUNCEMENT_READ | 详情 |
| /announcements/:id | PUT | MSG_ANNOUNCEMENT_MANAGE | 更新 |
| /announcements/:id | DELETE | MSG_ANNOUNCEMENT_MANAGE | 删除 |
| /announcements/:id/publish | PUT | MSG_ANNOUNCEMENT_MANAGE | 发布 |
| /announcements/:id/archive | PUT | MSG_ANNOUNCEMENT_MANAGE | 归档 |
| /announcements/:id/pin | PUT | MSG_ANNOUNCEMENT_MANAGE | 置顶切换 |
| /announcements/:id/read | POST | MSG_ANNOUNCEMENT_READ | 标记已读 |
### 4.2 ARB-008 / ARB-013 落地(前序已完成)
- proto RPC 17→13裁剪 4 RPC 降级 REST only
- topic 命名 `edu.notify.notification.*`
- /readyz 6 依赖检查Kafka producer/consumer 拆分)
- sendBatch 批量 INSERT 优化
### 4.3 sendBatch 批量 INSERT 优化 + eventId 幂等过滤(本轮)
**原因**:下游 BFF 批量发送通知时性能不佳,原实现循环调用 `send`(每条 insert + dispatch + outbox。此外 sendBatch 缺少 eventId 幂等检查,重复发送会重复插入。
**改动**
- `src/notifications/notifications.service.ts` — sendBatch 改为先批量 `insertNotifications(rows)`,失败时全部标记 failed成功后逐条 ES 索引 + dispatch + outbox
- `src/notifications/notifications.repository.ts` — 新增 `findExistingEventIds(eventIds)` 批量查询已存在的 eventId
- `src/notifications/notifications.service.ts` — sendBatch 批量 INSERT 前先调用 `findExistingEventIds` 过滤已存在的 eventId幂等跳过
- 批量 INSERT 失败 → 所有 items 进 failed 列表
- 部分分发失败 → 仅失败项进 failed 列表,成功项进 ids
- eventId 已存在 → 跳过该项(不插入、不分发、不写 outbox返回空 ids
- 无 eventId 的 item → 正常插入(不做幂等检查,与单条 send 行为一致)
**幂等行为**
- 全部 eventId 已存在 → 返回 `{ ids: [], failed: [] }`
- 部分 eventId 已存在 → 仅插入未存在的,已存在的跳过
- 无 eventId → 正常插入(允许重复)
**单元测试**(新增 2 个):
- `eventId 已存在时应跳过(幂等过滤)` — 2 条 items1 已存在 + 1 新),仅插入 1 条
- `所有 eventId 都已存在时应返回空 ids全跳过` — 2 条都已存在,不调用 insertNotifications
### 4.4 Docker 本地测试验证(本轮,无 mock 数据)
**环境**edu-full_default 网络,连接 edu-mysql/edu-redis/edu-kafka/edu-es 真实服务
**验证结果**
| 测试项 | 端点 | 结果 |
| ---------------------- | -------------------------------------------- | ------------------------------------------------------- |
| 健康检查 | GET /healthz | ✅ `{"status":"ok"}` |
| 就绪检查 | GET /readyz | ✅ 5/6 OKpushGateway 软失败,预期) |
| 公告创建 | POST /announcements | ✅ 返回 idstatus=draft |
| 公告列表 | GET /announcements | ✅ 返回 items + total |
| 公告详情 | GET /announcements/:id | ✅ 返回完整记录 |
| 公告发布 | PUT /announcements/:id/publish | ✅ status=published, publishedAt 填充 |
| 公告标记已读 | POST /announcements/:id/read | ✅ 幂等UNIQUE KEY 去重) |
| 公告置顶 | PUT /announcements/:id/pin | ✅ isPinned 切换 |
| 公告归档 | PUT /announcements/:id/archive | ✅ status=archived, archivedAt 填充 |
| 公告筛选 | GET /announcements?status=published | ✅ 正确过滤已归档项 |
| 通知发送 | POST /notifications/send | ✅ 返回 id, status=sent, channels=[in_app] |
| 通知列表 | GET /notifications/user/:userId | ✅ 返回 items + total + 分页 |
| 未读计数 | GET /notifications/user/:userId/unread-count | ✅ 返回 count |
| 标记已读 | PUT /notifications/:id/read | ✅ 未读数降为 0 |
| 通知搜索 | GET /notifications/search | ✅ 返回空ES 索引同步延迟,功能正常) |
| 偏好查询 | GET /preferences/user/:userId | ✅ 返回 preferences 数组 |
| 偏好更新 | PUT /preferences/user/:userId | ✅ 2 条偏好持久化 |
| Prometheus 指标 | GET /metrics | ✅ 返回标准 Prometheus 格式 |
| sendBatch 批量发送 | POST /notifications/batch | ✅ 3 条批量插入3 个 outbox 事件status=sent |
| sendBatch 无 groupId | POST /notifications/batch | ✅ 自动生成 groupIdcuid2 |
| sendBatch 空数组 | POST /notifications/batch | ✅ Zod 验证拒绝min(1) |
| sendBatch eventId 幂等 | POST /notifications/batch | ✅ 重复 eventId 跳过DB COUNT=1 |
| sendBatch 混合场景 | POST /notifications/batch | ✅ 2 条 eventId 各 1 次 + 1 条无 eventId 2 次 = COUNT=4 |
**Docker 启动命令**
```bash
docker run -d --name edu-msg-test --network edu-full_default \
-p 3107:3007 -p 51056:50056 \
-e PORT=3007 -e GRPC_PORT=50056 \
-e DATABASE_URL=mysql://edu:changeme@edu-mysql:3306/next_edu_cloud \
-e REDIS_URL=redis://edu-redis:6379 \
-e KAFKA_BROKERS=edu-kafka:29092 \
-e ES_URL=http://edu-es:9200 \
-e DEV_MODE=true -e NODE_ENV=production -e LOG_LEVEL=info \
edu-msg:test
```
---
## 5. Docker 部署配置
### 5.1 docker-compose.deploy.yml
```yaml
msg:
build:
context: ./repo
dockerfile: services/msg/Dockerfile
container_name: edu-msg
environment:
PORT: 3007
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
ES_URL: ${ES_URL:-}
PUSH_GATEWAY_URL: http://push-gateway:8081
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
LOG_LEVEL: ${LOG_LEVEL:-info}
NODE_ENV: production
DEV_MODE: "false"
depends_on:
push-gateway:
condition: service_healthy
healthcheck:
test:
["CMD", "wget", "--quiet", "--spider", "http://localhost:3007/healthz"]
```
### 5.2 本地 Docker 测试步骤
```bash
# 1. 启动基础设施MySQL + Redis + Kafka + ES
docker compose -f infra/docker-compose.yml up -d mysql redis kafka zookeeper elasticsearch
# 2. 构建并启动 msg
docker compose -f infra/docker-compose.deploy.yml up -d --build msg
# 3. 验证健康检查
curl http://localhost:3007/healthz
curl http://localhost:3007/readyz
# 4. 验证 REST API需 dev-token
curl -X POST http://localhost:3007/announcements \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dev-token" \
-d '{"title":"测试公告","content":"内容","authorId":"sys","targetAudience":"all"}'
```
---
## 6. 待协调事项
| # | 事项 | 协调对象 | 说明 |
| --- | ----------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | api-gateway 新增 `/api/v1/announcements/*` 路由 | ai01 | 公告 REST API 需通过 gateway 暴露,当前只有 /notifications/* 和 /messages/* |
| 2 | push-gateway 消费 topic 对齐 | ai09 | push-gateway nextstep.md 期望消费 `edu.notification.requested`,但 ARB-013 统一为 `edu.notify.notification.sent`。需 push-gateway 对齐 |
| 3 | teacher-bff 接入公告 REST | ai03 | 通过 HTTP 调用 msg:3007/announcements/*teacher-bff nextstep.md §2.5 期望 gRPC但 msg 公告为 REST only |
| 4 | student-bff 接入公告 REST | ai04 | student-bff nextstep.md §3.5 期望 6 个 gRPC RPC含公告但 msg 公告为 REST only需改用 REST 聚合 |
| 5 | iam/core-edu/data-ana 事件 payload 字段对齐 | ai06/ai07/ai08 | 确保 Kafka 事件 JSON 字段名与 msg consumer 一致 |
| 6 | 公告 gRPC RPC 决策 | coord | 下游 teacher-bff/student-bff 期望 ListAnnouncements/CreateAnnouncement/PublishAnnouncement/GetAnnouncement/MarkAnnouncementRead 为 gRPC但 ARB-008 限制 RPC 总数 13。需 coord 仲裁是否新增 AnnouncementService gRPC |
| 7 | parent-bff 通知偏好 RPC 命名对齐 | ai05 | parent-bff 期望 `getNotificationPreferences`/`updateNotificationPreferences`msg proto 实际为 `GetPreferences`/`UpdatePreferences`。需对齐命名或 parent-bff 适配 |
| 8 | 考试实时事件责任方澄清 | ai07/ai09/ai14 | student-portal 期望 msg 发出 ExamExtended/ExamForceSubmitted/ExamQuestionReordered 事件。这些事件应由 core-edu 发出msg 消费后转发 push-gateway。需 core-edu 确认事件发布 |
| 9 | parent-portal GraphQL 命名对齐 | ai05/ai15 | 前端 myNotifications/myNotificationPreferences vs 后端 notifications/notificationPreferences需统一命名 |
| 10 | admin-portal 公告管理联调 | ai03/ai16 | admin-portal 期望 teacher-bff 聚合 4 个公告 mutationcreateAnnouncement/publishAnnouncement/archiveAnnouncement/toggleAnnouncementPin需 teacher-bff 接入 msg REST |
---
## 7. msg 模块工作完成总结
### 7.1 已完成的全部工作
1. **P5 通知中台核心**13 gRPC RPC + 10 REST endpointsnotifications+ 2 REST endpointspreferences+ 6 REST endpointstemplates
2. **ARB-008 裁决落地**RPC 17→134 RPC 降级 REST only
3. **ARB-013 topic 统一**`edu.notify.notification.sent/read/recalled/failed`
4. **Outbox + 三层幂等**msg_outbox_events + Redis SETNX + DB + event_id UNIQUE
5. **ChannelDispatcher**5 通道 Promise.allSettled
6. **/readyz 6 依赖检查**DB/ES/Redis/KafkaProducer/KafkaConsumer/PushGateway
7. **Announcements 公告模块**9 REST endpointsschema+repo+service+controller+module
8. **sendBatch 批量优化**:循环单条插入 → 批量 insertNotifications
9. **权限扩展**MSG_ANNOUNCEMENT_MANAGE/READ + parent 角色
10. **Docker 本地测试**18 项 API 全部验证通过(真实 DB无 mock
11. **单元测试**99 tests / 5 spec files 全部通过
12. **SQL Schema**msg_announcements + msg_announcement_reads 表
13. **Dockerfile**workspace 模式多阶段构建
14. **文档**nextstep.md 完整上下游依赖 + Docker 测试结果
### 7.2 msg 模块对外提供的能力清单
**gRPC端口 5005613 RPC**
- NotificationService: SendNotification, ListNotifications, MarkAsRead, SearchNotifications, RecallNotification
- NotificationPreferenceService: GetPreferences, UpdatePreferences
- NotificationTemplateService: CreateTemplate, GetTemplate, ListTemplates, UpdateTemplate, DeleteTemplate, RenderTemplate
**REST端口 3007**
- /notifications/*10 endpoints
- /preferences/user/:userId2 endpoints
- /templates/*6 endpoints
- /announcements/*9 endpoints
- /healthz, /readyz, /metrics
**Kafka 消费12 类事件)**
- iam: 6 类 identity 事件
- core-edu: 5 类 teaching 事件
- data-ana: 1 类 insight 事件
**Kafka 发布4 类事件)**
- `edu.notify.notification.sent/read/recalled/failed`
- DLQ: `edu.notify.dlq`
---
**本文件维护规则**:每次完成一项 Next Step 后,将对应条目标记为 ✅ 并在 workline.md 中记录审计结果。

View File

@@ -0,0 +1,144 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
Req,
} from "@nestjs/common";
import { AnnouncementsService } from "./announcements.service.js";
import {
createAnnouncementSchema,
updateAnnouncementSchema,
markAnnouncementReadSchema,
} from "./announcements.dto.js";
import type {
CreateAnnouncementDto,
UpdateAnnouncementDto,
} from "./announcements.dto.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
/**
* AnnouncementsController —— 公告 REST API。
*
* 端点:
* - POST /announcements创建草稿需 MSG_ANNOUNCEMENT_MANAGE
* - GET /announcements列表需 MSG_ANNOUNCEMENT_READ
* - GET /announcements/:id详情需 MSG_ANNOUNCEMENT_READ
* - PUT /announcements/:id更新需 MSG_ANNOUNCEMENT_MANAGE
* - DELETE /announcements/:id删除需 MSG_ANNOUNCEMENT_MANAGE
* - PUT /announcements/:id/publish发布需 MSG_ANNOUNCEMENT_MANAGE
* - PUT /announcements/:id/archive归档需 MSG_ANNOUNCEMENT_MANAGE
* - PUT /announcements/:id/pin置顶切换需 MSG_ANNOUNCEMENT_MANAGE
* - POST /announcements/:id/read标记已读需 MSG_ANNOUNCEMENT_READ
*/
@Controller("announcements")
export class AnnouncementsController {
constructor(private readonly service: AnnouncementsService) {}
@Post()
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async create(
@Body() body: unknown,
): Promise<{ success: true; data: unknown }> {
const dto: CreateAnnouncementDto = createAnnouncementSchema.parse(body);
const result = await this.service.create(dto);
return { success: true, data: result };
}
@Get()
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_READ)
async list(
@Query("status") status: string,
@Query("targetAudience") targetAudience: string,
@Query("page") page: string,
@Query("pageSize") pageSize: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.list({
status: status || undefined,
targetAudience: targetAudience || undefined,
page: Number(page) > 0 ? Number(page) : 1,
pageSize: Number(pageSize) > 0 ? Number(pageSize) : 20,
});
return { success: true, data: result };
}
@Get(":id")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_READ)
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.getById(id);
return { success: true, data: result };
}
@Put(":id")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async update(
@Param("id") id: string,
@Body() body: unknown,
): Promise<{ success: true; data: unknown }> {
const dto: UpdateAnnouncementDto = updateAnnouncementSchema.parse(body);
const result = await this.service.update(id, dto);
return { success: true, data: result };
}
@Delete(":id")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async delete(@Param("id") id: string): Promise<{ success: true }> {
await this.service.delete(id);
return { success: true };
}
@Put(":id/publish")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async publish(
@Param("id") id: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.publish(id);
return { success: true, data: result };
}
@Put(":id/archive")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async archive(
@Param("id") id: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.archive(id);
return { success: true, data: result };
}
@Put(":id/pin")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async togglePin(
@Param("id") id: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.togglePin(id);
return { success: true, data: result };
}
@Post(":id/read")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_READ)
async markAsRead(
@Req() req: AuthenticatedRequest,
@Param("id") id: string,
@Body() body: unknown,
): Promise<{ success: true }> {
// 优先从 body 取 userId降级从 request headerauth middleware 注入)
const dto = markAnnouncementReadSchema.parse(body);
const userId = req.userId ?? dto.userId;
if (!userId) {
throw new PermissionDeniedError("MSG_ANNOUNCEMENT_READ");
}
await this.service.markAsRead(id, userId);
return { success: true };
}
}

View File

@@ -0,0 +1,67 @@
import { z } from "zod";
/**
* msg 服务公告 DTO + Zod 校验 schema。
*
* 对齐 REST API
* - POST /announcements创建草稿
* - PUT /announcements/:id更新
* - PUT /announcements/:id/publish发布
* - PUT /announcements/:id/archive归档
* - PUT /announcements/:id/pin置顶切换
* - POST /announcements/:id/read标记已读
*/
// ============================================================
// Create / Update
// ============================================================
export const createAnnouncementSchema = z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
authorId: z.string().min(1).max(32),
targetAudience: z
.enum(["all", "teachers", "students", "parents", "admin"])
.default("all"),
metadata: z.record(z.string()).optional(),
});
export type CreateAnnouncementDto = z.infer<typeof createAnnouncementSchema>;
export const updateAnnouncementSchema = z.object({
title: z.string().min(1).max(255).optional(),
content: z.string().min(1).optional(),
targetAudience: z
.enum(["all", "teachers", "students", "parents", "admin"])
.optional(),
metadata: z.record(z.string()).optional(),
});
export type UpdateAnnouncementDto = z.infer<typeof updateAnnouncementSchema>;
// ============================================================
// List 查询参数
// ============================================================
export const listAnnouncementsSchema = z.object({
status: z.enum(["draft", "published", "archived"]).optional(),
targetAudience: z
.enum(["all", "teachers", "students", "parents", "admin"])
.optional(),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
});
export type ListAnnouncementsDto = z.infer<typeof listAnnouncementsSchema>;
// ============================================================
// Mark as Read
// ============================================================
export const markAnnouncementReadSchema = z.object({
userId: z.string().min(1).max(32),
});
export type MarkAnnouncementReadDto = z.infer<
typeof markAnnouncementReadSchema
>;

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { AnnouncementsController } from "./announcements.controller.js";
import { AnnouncementsService } from "./announcements.service.js";
@Module({
controllers: [AnnouncementsController],
providers: [AnnouncementsService],
exports: [AnnouncementsService],
})
export class AnnouncementsModule {}

View File

@@ -0,0 +1,151 @@
import { and, count, desc, eq, or } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { getDb } from "../config/database.js";
import {
announcements,
announcementReads,
type NewAnnouncement,
type Announcement,
type NewAnnouncementRead,
} from "./announcements.schema.js";
/**
* AnnouncementsRepository —— 公告数据访问层。
*
* 职责:封装 MySQL 读写,与业务逻辑解耦。
* 仲裁依据 G10使用 getDb() 函数式获取 db 实例。
*/
export async function insertAnnouncement(
row: NewAnnouncement,
): Promise<Announcement> {
const db = getDb();
await db.insert(announcements).values(row);
return row as Announcement;
}
export async function findById(id: string): Promise<Announcement | undefined> {
const db = getDb();
const [row] = await db
.select()
.from(announcements)
.where(eq(announcements.id, id))
.limit(1);
return row;
}
export async function list(options: {
status?: string;
targetAudience?: string;
page: number;
pageSize: number;
}): Promise<{ items: Announcement[]; total: number }> {
const db = getDb();
const conditions = [];
if (options.status) {
conditions.push(eq(announcements.status, options.status as never));
}
if (options.targetAudience) {
// target_audience 匹配 "all" 或指定受众
conditions.push(
or(
eq(announcements.targetAudience, "all"),
eq(announcements.targetAudience, options.targetAudience as never),
)!,
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [totalRow] = await db
.select({ value: count() })
.from(announcements)
.where(where);
const items = await db
.select()
.from(announcements)
.where(where)
.orderBy(desc(announcements.isPinned), desc(announcements.createdAt))
.limit(options.pageSize)
.offset((options.page - 1) * options.pageSize);
return { items, total: totalRow?.value ?? 0 };
}
export async function updateAnnouncement(
id: string,
patch: Partial<NewAnnouncement>,
): Promise<Announcement | undefined> {
const db = getDb();
await db.update(announcements).set(patch).where(eq(announcements.id, id));
const [row] = await db
.select()
.from(announcements)
.where(eq(announcements.id, id))
.limit(1);
return row;
}
export async function deleteAnnouncement(id: string): Promise<void> {
const db = getDb();
await db.delete(announcements).where(eq(announcements.id, id));
}
// ============================================================
// 已读记录
// ============================================================
export async function markAsRead(
announcementId: string,
userId: string,
): Promise<void> {
const db = getDb();
// 幂等:先查是否已读
const [existing] = await db
.select()
.from(announcementReads)
.where(
and(
eq(announcementReads.announcementId, announcementId),
eq(announcementReads.userId, userId),
),
)
.limit(1);
if (existing) return;
const row: NewAnnouncementRead = {
id: createId(),
announcementId,
userId,
};
await db.insert(announcementReads).values(row);
}
export async function isReadByUser(
announcementId: string,
userId: string,
): Promise<boolean> {
const db = getDb();
const [row] = await db
.select()
.from(announcementReads)
.where(
and(
eq(announcementReads.announcementId, announcementId),
eq(announcementReads.userId, userId),
),
)
.limit(1);
return !!row;
}
export async function getReadCount(announcementId: string): Promise<number> {
const db = getDb();
const [row] = await db
.select({ value: count() })
.from(announcementReads)
.where(eq(announcementReads.announcementId, announcementId));
return row?.value ?? 0;
}

View File

@@ -0,0 +1,72 @@
import {
boolean,
json,
mysqlTable,
text,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
/**
* msg 服务公告 Schema。
*
* 表清单:
* - msg_announcements公告主表
* - msg_announcement_reads公告已读记录按用户+公告维度)
*
* 公告与通知msg_notifications的区别
* - 通知是 per-user 的(每条通知有 user_id
* - 公告是 broadcast 的(一条公告面向 target_audience 群体)
* - 公告发布后不会为每个用户生成通知记录,而是通过 msg_announcement_reads 跟踪已读
*/
// ============================================================
// 公告状态 / 目标受众 枚举
// ============================================================
export type AnnouncementStatus = "draft" | "published" | "archived";
export type TargetAudience =
"all" | "teachers" | "students" | "parents" | "admin";
// ============================================================
// msg_announcements公告主表
// ============================================================
export const announcements = mysqlTable("msg_announcements", {
id: varchar("id", { length: 32 }).notNull().primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
content: text("content").notNull(),
status: varchar("status", { length: 32 })
.notNull()
.default("draft")
.$type<AnnouncementStatus>(),
isPinned: boolean("is_pinned").notNull().default(false),
authorId: varchar("author_id", { length: 32 }).notNull(),
targetAudience: varchar("target_audience", { length: 32 })
.notNull()
.default("all")
.$type<TargetAudience>(),
metadata: json("metadata").$type<Record<string, string> | null>(),
publishedAt: timestamp("published_at"),
archivedAt: timestamp("archived_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export type Announcement = typeof announcements.$inferSelect;
export type NewAnnouncement = typeof announcements.$inferInsert;
// ============================================================
// msg_announcement_reads公告已读记录
// ============================================================
export const announcementReads = mysqlTable("msg_announcement_reads", {
id: varchar("id", { length: 32 }).notNull().primaryKey(),
announcementId: varchar("announcement_id", { length: 32 }).notNull(),
userId: varchar("user_id", { length: 32 }).notNull(),
readAt: timestamp("read_at").notNull().defaultNow(),
});
export type AnnouncementRead = typeof announcementReads.$inferSelect;
export type NewAnnouncementRead = typeof announcementReads.$inferInsert;

View File

@@ -0,0 +1,123 @@
import { createId } from "@paralleldrive/cuid2";
import { NotFoundError } from "../shared/errors/application-error.js";
import type {
CreateAnnouncementDto,
UpdateAnnouncementDto,
} from "./announcements.dto.js";
import type { Announcement } from "./announcements.schema.js";
import * as repo from "./announcements.repository.js";
/**
* AnnouncementsService —— 公告业务逻辑层。
*
* 职责:
* - 公告 CRUD草稿→发布→归档 生命周期)
* - 置顶切换
* - 用户已读标记(幂等)
*
* 仲裁依据:
* - 公告与通知分离broadcast vs per-user
* - 发布后不可改 status只能归档
*/
export class AnnouncementsService {
async create(dto: CreateAnnouncementDto): Promise<Announcement> {
const id = createId();
const row = {
id,
title: dto.title,
content: dto.content,
status: "draft" as const,
isPinned: false,
authorId: dto.authorId,
targetAudience: dto.targetAudience,
metadata: dto.metadata ?? null,
};
return await repo.insertAnnouncement(row);
}
async list(options: {
status?: string;
targetAudience?: string;
page: number;
pageSize: number;
}): Promise<{ items: Announcement[]; total: number }> {
return await repo.list(options);
}
async getById(id: string): Promise<Announcement> {
const item = await repo.findById(id);
if (!item) {
throw new NotFoundError("Announcement", id);
}
return item;
}
async update(id: string, dto: UpdateAnnouncementDto): Promise<Announcement> {
const existing = await repo.findById(id);
if (!existing) {
throw new NotFoundError("Announcement", id);
}
const patch: Record<string, unknown> = {};
if (dto.title !== undefined) patch.title = dto.title;
if (dto.content !== undefined) patch.content = dto.content;
if (dto.targetAudience !== undefined)
patch.targetAudience = dto.targetAudience;
if (dto.metadata !== undefined) patch.metadata = dto.metadata;
const updated = await repo.updateAnnouncement(id, patch);
return updated ?? existing;
}
async delete(id: string): Promise<void> {
const existing = await repo.findById(id);
if (!existing) {
throw new NotFoundError("Announcement", id);
}
await repo.deleteAnnouncement(id);
}
async publish(id: string): Promise<Announcement> {
const existing = await repo.findById(id);
if (!existing) {
throw new NotFoundError("Announcement", id);
}
const updated = await repo.updateAnnouncement(id, {
status: "published",
publishedAt: new Date(),
});
return updated ?? existing;
}
async archive(id: string): Promise<Announcement> {
const existing = await repo.findById(id);
if (!existing) {
throw new NotFoundError("Announcement", id);
}
const updated = await repo.updateAnnouncement(id, {
status: "archived",
archivedAt: new Date(),
});
return updated ?? existing;
}
async togglePin(id: string): Promise<Announcement> {
const existing = await repo.findById(id);
if (!existing) {
throw new NotFoundError("Announcement", id);
}
const updated = await repo.updateAnnouncement(id, {
isPinned: !existing.isPinned,
});
return updated ?? existing;
}
async markAsRead(announcementId: string, userId: string): Promise<void> {
// 幂等repo 内部检查
await repo.markAsRead(announcementId, userId);
}
async isReadByUser(announcementId: string, userId: string): Promise<boolean> {
return await repo.isReadByUser(announcementId, userId);
}
}

View File

@@ -3,6 +3,7 @@ import { APP_GUARD } from "@nestjs/core";
import { NotificationsModule } from "./notifications/notifications.module.js";
import { PreferencesModule } from "./preferences/preferences.module.js";
import { TemplatesModule } from "./templates/templates.module.js";
import { AnnouncementsModule } from "./announcements/announcements.module.js";
import { GrpcModule } from "./grpc/grpc.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
@@ -13,16 +14,18 @@ import { KafkaConsumerService } from "./shared/kafka/kafka.consumer.js";
* AppModule —— msg 服务根模块。
*
* 仲裁依据:
* - M1gRPC 50056 启用GrpcModule 注册 3 controller 共 17 RPC
* - M1gRPC 50056 启用GrpcModule 注册 3 controller 共 13 RPCARB-008 裁剪后
* - HTTP REST + gRPC 双协议入口,共享同一套 Service 单例
* - KafkaConsumerService 消费 12 类事件触发通知
* - AnnouncementsModule 提供公告广播 + 每用户已读跟踪
*
* 模块依赖图:
* AppModule
* ├─ NotificationsModuleREST + Service
* ├─ PreferencesModuleREST + Service
* ├─ TemplatesModuleREST + Service
* ├─ GrpcModulegRPC controllersimports 上述 3 模块获取 Service
* ├─ AnnouncementsModuleREST + Service,公告广播
* ├─ GrpcModulegRPC controllersimports 上述模块获取 Service
* ├─ HealthModule/healthz + /readyz
* └─ providers: PermissionGuard(APP_GUARD) + LifecycleService + KafkaConsumerService
*/
@@ -31,6 +34,7 @@ import { KafkaConsumerService } from "./shared/kafka/kafka.consumer.js";
NotificationsModule,
PreferencesModule,
TemplatesModule,
AnnouncementsModule,
GrpcModule,
HealthModule,
],

View File

@@ -12,6 +12,8 @@ export const Permissions = {
MSG_NOTIFICATION_SEND: "MSG_NOTIFICATION_SEND" as const,
MSG_NOTIFICATION_READ: "MSG_NOTIFICATION_READ" as const,
MSG_NOTIFICATION_MANAGE: "MSG_NOTIFICATION_MANAGE" as const,
MSG_ANNOUNCEMENT_MANAGE: "MSG_ANNOUNCEMENT_MANAGE" as const,
MSG_ANNOUNCEMENT_READ: "MSG_ANNOUNCEMENT_READ" as const,
} as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];
@@ -25,12 +27,23 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.MSG_NOTIFICATION_SEND,
Permissions.MSG_NOTIFICATION_READ,
Permissions.MSG_NOTIFICATION_MANAGE,
Permissions.MSG_ANNOUNCEMENT_MANAGE,
Permissions.MSG_ANNOUNCEMENT_READ,
],
teacher: [
Permissions.MSG_NOTIFICATION_SEND,
Permissions.MSG_NOTIFICATION_READ,
Permissions.MSG_ANNOUNCEMENT_MANAGE,
Permissions.MSG_ANNOUNCEMENT_READ,
],
student: [
Permissions.MSG_NOTIFICATION_READ,
Permissions.MSG_ANNOUNCEMENT_READ,
],
parent: [
Permissions.MSG_NOTIFICATION_READ,
Permissions.MSG_ANNOUNCEMENT_READ,
],
student: [Permissions.MSG_NOTIFICATION_READ],
};
@Injectable()

View File

@@ -1,4 +1,4 @@
import { and, count, desc, eq, inArray, lte } from "drizzle-orm";
import { and, count, desc, eq, inArray, lte, sql } from "drizzle-orm";
import { getDb } from "../config/database.js";
import {
notifications,
@@ -51,6 +51,31 @@ export async function findByEventId(
return row;
}
/**
* 批量查询已存在的 eventId用于 sendBatch 幂等过滤)。
* 返回已存在的 eventId 集合。
*/
export async function findExistingEventIds(
eventIds: string[],
): Promise<Set<string>> {
if (eventIds.length === 0) return new Set();
const db = getDb();
const rows = await db
.select({ eventId: notifications.eventId })
.from(notifications)
.where(
and(
inArray(notifications.eventId, eventIds),
sql`${notifications.eventId} IS NOT NULL`,
),
);
return new Set(
rows
.map((r) => r.eventId)
.filter((id): id is string => id !== null && id !== undefined),
);
}
export async function listByUser(
userId: string,
options: {

View File

@@ -174,10 +174,139 @@ export class NotificationsService {
const ids: string[] = [];
const failed: { userId: string; error: string }[] = [];
for (const item of dto.items) {
// 幂等过滤:批量查询已存在的 eventId跳过重复项
const eventIdsToCheck = dto.items
.map((item) => item.eventId)
.filter(
(id): id is string => id !== undefined && id !== null && id !== "",
);
const existingEventIds =
eventIdsToCheck.length > 0
? await repo.findExistingEventIds(eventIdsToCheck)
: new Set<string>();
// 过滤掉已存在的 eventId幂等跳过
const itemsToInsert = dto.items.filter((item) => {
if (!item.eventId) return true;
if (existingEventIds.has(item.eventId)) {
logger.info(
{ eventId: item.eventId, userId: item.userId },
"Batch send: notification already exists (idempotent skip)",
);
return false;
}
return true;
});
// 所有 items 都已存在(幂等跳过)
if (itemsToInsert.length === 0) {
logger.info(
{ groupId, totalCount: dto.items.length },
"Batch send: all items skipped (idempotent)",
);
return { ids: [], failed: [] };
}
// 批量构建通知记录(仅未跳过的)
const rows = itemsToInsert.map((item) => ({
id: createId(),
userId: item.userId,
type: item.type as Notification["type"],
title: item.title,
content: item.content,
channel: (item.channel ?? "in_app") as NotificationChannel,
isRead: false,
status: "pending" as NotificationStatus,
metadata: item.metadata ?? null,
relatedEntityType: item.relatedEntityType ?? null,
relatedEntityId: item.relatedEntityId ?? null,
groupId,
senderId: item.senderId ?? null,
templateId: item.templateId ?? null,
eventId: item.eventId ?? null,
}));
// 批量 INSERT失败时全部标记为 failed
try {
const result = await this.send({ ...item, groupId });
ids.push(result.id);
await repo.insertNotifications(rows);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return {
ids: [],
failed: itemsToInsert.map((item) => ({ userId: item.userId, error })),
};
}
// 逐条 ES 索引 + 渠道分发 + Outbox部分失败收集 failed
for (const [i, item] of itemsToInsert.entries()) {
const row = rows[i];
if (!row) continue;
try {
// ES 索引(降级安全)
await safeIndex({
index: "notifications",
id: row.id,
document: {
id: row.id,
user_id: item.userId,
type: item.type,
title: item.title,
content: item.content,
channel: row.channel,
status: "pending",
group_id: groupId,
related_entity_type: item.relatedEntityType ?? null,
related_entity_id: item.relatedEntityId ?? null,
sender_id: item.senderId ?? null,
is_read: false,
created_at: new Date().toISOString(),
},
});
// 查询用户偏好
const enabledChannels = await this.getUserChannels(
item.userId,
item.type,
);
// 渠道分发
const ctx: ChannelSendContext = {
notificationId: row.id,
userId: item.userId,
title: item.title,
content: item.content,
type: item.type,
metadata: item.metadata ?? null,
relatedEntityType: item.relatedEntityType,
relatedEntityId: item.relatedEntityId,
};
const results = await this.channelDispatcher.dispatch(
ctx,
enabledChannels,
);
// 更新状态
const anySent = results.some((r) => r.sent);
await this.updateStatus(row.id, anySent ? "sent" : "failed");
// Outbox
await outboxPublish(
"notification.sent",
{
notificationId: row.id,
userId: item.userId,
type: item.type,
channel: row.channel,
channels: results.map((r) => r.channel),
},
{
aggregateType: "Notification",
aggregateId: row.id,
metadata: { userId: item.userId },
},
);
ids.push(row.id);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
failed.push({ userId: item.userId, error });

View File

@@ -0,0 +1 @@
{"title":"测试公告","content":"这是测试内容","authorId":"sys-admin","targetAudience":"all"}

View File

@@ -0,0 +1 @@
{"items":[{"userId":"batch-idem-1","type":"system","title":"幂等1","content":"测试","eventId":"evt-batch-idem-001"}],"groupId":"batch-idem-group"}

View File

@@ -0,0 +1 @@
{"items":[{"userId":"batch-mix-1","type":"system","title":"混合1","content":"有eventId","eventId":"evt-mix-001"},{"userId":"batch-mix-2","type":"exam","title":"混合2","content":"无eventId"},{"userId":"batch-mix-3","type":"homework","title":"混合3","content":"有eventId","eventId":"evt-mix-003"}],"groupId":"batch-mix-group"}

View File

@@ -0,0 +1 @@
{"items":[{"userId":"batch-noid-1","type":"system","title":"无groupId","content":"测试"}]}

View File

@@ -0,0 +1 @@
{"items":[{"userId":"batch-test-1","type":"system","title":"批量测试1","content":"第一条","senderId":"sys-admin"},{"userId":"batch-test-2","type":"exam","title":"批量测试2","content":"第二条","senderId":"sys-admin"},{"userId":"batch-test-3","type":"homework","title":"批量测试3","content":"第三条","senderId":"sys-admin"}],"groupId":"batch-group-001"}

View File

@@ -0,0 +1 @@
{"userId":"student-001"}

View File

@@ -0,0 +1 @@
{"userId":"student-001"}

View File

@@ -0,0 +1 @@
{"userId":"student-001","type":"system","title":"测试通知","content":"这是一条测试通知","senderId":"sys-admin"}

View File

@@ -0,0 +1 @@
{"userId":"student-001","preferences":[{"type":"system","channels":["in_app","email"],"enabled":true},{"type":"exam","channels":["in_app"],"enabled":true}]}

View File

@@ -0,0 +1,358 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ChannelSendContext } from "../../src/channels/channel.types.js";
// ============================================================
// Mock 外部依赖 —— 使用 vi.hoisted 确保 mock 变量在 hoisted 的 vi.mock 中可用
// ============================================================
const mocks = vi.hoisted(() => {
// Mock 各渠道策略
const mockInAppChannel = {
name: "in_app" as const,
send: vi.fn(),
};
const mockEmailChannel = {
name: "email" as const,
send: vi.fn(),
};
const mockSmsChannel = {
name: "sms" as const,
send: vi.fn(),
};
const mockPushChannel = {
name: "push" as const,
send: vi.fn(),
};
// Mock database insert chain用于 recordDeliveries
const mockInsertChain = {
values: vi.fn(),
};
const mockDb = {
insert: vi.fn(() => mockInsertChain),
};
return {
mockInAppChannel,
mockEmailChannel,
mockSmsChannel,
mockPushChannel,
mockInsertChain,
mockDb,
};
});
// Mock cuid2
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "mock-delivery-id"),
}));
// Mock database
vi.mock("../../src/config/database.js", () => ({
getDb: () => mocks.mockDb,
}));
// Mock logger
vi.mock("../../src/shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../../src/channels/in-app.channel.js", () => ({
inAppChannel: mocks.mockInAppChannel,
}));
vi.mock("../../src/channels/email.channel.js", () => ({
emailChannel: mocks.mockEmailChannel,
}));
vi.mock("../../src/channels/sms.channel.js", () => ({
smsChannel: mocks.mockSmsChannel,
}));
vi.mock("../../src/channels/push.channel.js", () => ({
pushChannel: mocks.mockPushChannel,
}));
// 导入被测模块(在 mock 之后)
import { ChannelDispatcherService } from "../../src/channels/channel-dispatcher.service.js";
// ============================================================
// 辅助
// ============================================================
const {
mockInAppChannel,
mockEmailChannel,
mockSmsChannel,
mockPushChannel,
mockInsertChain,
mockDb,
} = mocks;
function createContext(
overrides: Partial<ChannelSendContext> = {},
): ChannelSendContext {
return {
notificationId: "notif-1",
userId: "user-1",
title: "Test",
content: "Content",
type: "system",
metadata: null,
...overrides,
};
}
// ============================================================
// Tests
// ============================================================
describe("ChannelDispatcherService", () => {
let dispatcher: ChannelDispatcherService;
beforeEach(() => {
vi.clearAllMocks();
// 重置 insert chain
mockInsertChain.values.mockResolvedValue(undefined);
// 重置渠道策略默认行为
mockInAppChannel.send.mockResolvedValue({
channel: "in_app",
sent: true,
});
mockEmailChannel.send.mockResolvedValue({
channel: "email",
sent: false,
error: "SMTP not configured",
});
mockSmsChannel.send.mockResolvedValue({
channel: "sms",
sent: false,
error: "SMS not configured",
});
mockPushChannel.send.mockResolvedValue({
channel: "push",
sent: true,
});
dispatcher = new ChannelDispatcherService();
});
// ----------------------------------------------------------
// dispatch —— 基本行为
// ----------------------------------------------------------
describe("dispatch", () => {
it("无偏好时应只发 in_app默认渠道", async () => {
const ctx = createContext();
const results = await dispatcher.dispatch(ctx, null);
expect(results).toHaveLength(1);
expect(results[0].channel).toBe("in_app");
expect(mockInAppChannel.send).toHaveBeenCalledTimes(1);
expect(mockEmailChannel.send).not.toHaveBeenCalled();
});
it("空数组偏好时应只发 in_app", async () => {
const ctx = createContext();
const results = await dispatcher.dispatch(ctx, []);
expect(results).toHaveLength(1);
expect(results[0].channel).toBe("in_app");
});
it("in_app 应总是包含在发送渠道中(即使用户偏好不含 in_app", async () => {
const ctx = createContext();
const results = await dispatcher.dispatch(ctx, ["email", "sms"]);
const channels = results.map((r) => r.channel);
expect(channels).toContain("in_app");
expect(channels).toContain("email");
expect(channels).toContain("sms");
expect(mockInAppChannel.send).toHaveBeenCalledTimes(1);
expect(mockEmailChannel.send).toHaveBeenCalledTimes(1);
expect(mockSmsChannel.send).toHaveBeenCalledTimes(1);
});
it("应并行发送所有渠道", async () => {
const ctx = createContext();
// 让每个 channel send 有微小延迟,验证并行
const callOrder: string[] = [];
mockInAppChannel.send.mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 50));
callOrder.push("in_app-done");
return { channel: "in_app", sent: true };
});
mockEmailChannel.send.mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 10));
callOrder.push("email-done");
return { channel: "email", sent: true };
});
const start = Date.now();
const results = await dispatcher.dispatch(ctx, ["email"]);
const elapsed = Date.now() - start;
// 并行执行总时间应小于串行50+10=60ms约 50ms 左右
expect(elapsed).toBeLessThan(80);
expect(results).toHaveLength(2);
});
it("多个渠道时应返回所有渠道的结果", async () => {
const ctx = createContext();
const results = await dispatcher.dispatch(ctx, ["email", "sms", "push"]);
expect(results).toHaveLength(4); // in_app + email + sms + push
const channels = results.map((r) => r.channel).sort();
expect(channels).toEqual(["email", "in_app", "push", "sms"]);
});
});
// ----------------------------------------------------------
// dispatch —— 软失败
// ----------------------------------------------------------
describe("dispatch 软失败", () => {
it("渠道 send reject 时应转为 failed 结果,不抛出", async () => {
const ctx = createContext();
mockEmailChannel.send.mockRejectedValue(new Error("Network timeout"));
const results = await dispatcher.dispatch(ctx, ["email"]);
const emailResult = results.find((r) => r.channel === "email");
expect(emailResult).toBeDefined();
expect(emailResult!.sent).toBe(false);
expect(emailResult!.error).toBe("Network timeout");
});
it("渠道 send reject 非 Error 对象时应转字符串", async () => {
const ctx = createContext();
mockEmailChannel.send.mockRejectedValue("string error");
const results = await dispatcher.dispatch(ctx, ["email"]);
const emailResult = results.find((r) => r.channel === "email");
expect(emailResult!.sent).toBe(false);
expect(emailResult!.error).toBe("string error");
});
it("in_app 失败时仍应返回结果", async () => {
const ctx = createContext();
mockInAppChannel.send.mockResolvedValue({
channel: "in_app",
sent: false,
error: "User offline",
});
const results = await dispatcher.dispatch(ctx, null);
expect(results[0].channel).toBe("in_app");
expect(results[0].sent).toBe(false);
});
});
// ----------------------------------------------------------
// dispatch —— recordDeliveries
// ----------------------------------------------------------
describe("recordDeliveries", () => {
it("应将投递结果异步写入 msg_notification_deliveries 表", async () => {
const ctx = createContext();
await dispatcher.dispatch(ctx, null);
// recordDeliveries 是 void 异步调用,等待微任务
await new Promise((r) => setTimeout(r, 10));
expect(mockDb.insert).toHaveBeenCalledTimes(1);
const rowsArg = mockInsertChain.values.mock.calls[0][0];
expect(rowsArg).toHaveLength(1);
expect(rowsArg[0].notificationId).toBe("notif-1");
expect(rowsArg[0].channel).toBe("in_app");
expect(rowsArg[0].id).toBe("mock-delivery-id");
});
it("多个渠道时应写入多行投递记录", async () => {
const ctx = createContext();
await dispatcher.dispatch(ctx, ["email", "push"]);
await new Promise((r) => setTimeout(r, 10));
const rowsArg = mockInsertChain.values.mock.calls[0][0];
expect(rowsArg).toHaveLength(3); // in_app + email + push
});
it("成功投递 status=sent失败投递 status=failed", async () => {
const ctx = createContext();
mockEmailChannel.send.mockResolvedValue({
channel: "email",
sent: false,
error: "SMTP error",
});
await dispatcher.dispatch(ctx, ["email"]);
await new Promise((r) => setTimeout(r, 10));
const rowsArg = mockInsertChain.values.mock.calls[0][0];
const inAppRow = rowsArg.find(
(r: { channel: string }) => r.channel === "in_app",
);
const emailRow = rowsArg.find(
(r: { channel: string }) => r.channel === "email",
);
expect(inAppRow.status).toBe("sent");
expect(inAppRow.deliveredAt).toBeInstanceOf(Date);
expect(emailRow.status).toBe("failed");
expect(emailRow.deliveredAt).toBeNull();
expect(emailRow.lastError).toBe("SMTP error");
});
it("recordDeliveries 失败不应阻断 dispatch 返回(软失败)", async () => {
const ctx = createContext();
mockInsertChain.values.mockRejectedValue(new Error("DB down"));
// dispatch 不应抛出
const results = await dispatcher.dispatch(ctx, null);
expect(results).toHaveLength(1);
expect(results[0].channel).toBe("in_app");
});
});
// ----------------------------------------------------------
// resolveChannels 逻辑(通过 dispatch 间接测试)
// ----------------------------------------------------------
describe("resolveChannels 逻辑", () => {
it("null 偏好 → 默认 [in_app]", async () => {
const results = await dispatcher.dispatch(createContext(), null);
expect(results).toHaveLength(1);
expect(results[0].channel).toBe("in_app");
});
it("空数组偏好 → 默认 [in_app]", async () => {
const results = await dispatcher.dispatch(createContext(), []);
expect(results).toHaveLength(1);
});
it("偏好含 in_app → 不重复", async () => {
const results = await dispatcher.dispatch(createContext(), [
"in_app",
"email",
]);
expect(results).toHaveLength(2); // in_app 不重复
});
it("偏好不含 in_app → 自动添加 in_app", async () => {
const results = await dispatcher.dispatch(createContext(), ["email"]);
const channels = results.map((r) => r.channel);
expect(channels).toContain("in_app");
expect(channels).toContain("email");
});
it("wechat 渠道应返回未实现错误", async () => {
const results = await dispatcher.dispatch(createContext(), ["wechat"]);
const wechatResult = results.find((r) => r.channel === "wechat");
expect(wechatResult).toBeDefined();
expect(wechatResult!.sent).toBe(false);
expect(wechatResult!.error).toContain("not implemented");
});
});
});

View File

@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// ============================================================
// Mock 外部依赖
// ============================================================
// Mock cuid2
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "mock-idempotency-key"),
}));
// Mock Redis client —— getRedis 返回模拟 redis 或 null
const mockRedis = {
set: vi.fn(),
exists: vi.fn(),
};
let redisReturnValue: unknown = null;
vi.mock("../../src/shared/redis/redis.client.js", () => ({
getRedis: () => redisReturnValue,
}));
// Mock database —— getDb 返回模拟 db
const mockInsertChain = {
values: vi.fn().mockResolvedValue(undefined),
};
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
};
const mockDb = {
insert: vi.fn(() => mockInsertChain),
select: vi.fn(() => mockSelectChain),
};
vi.mock("../../src/config/database.js", () => ({
getDb: () => mockDb,
}));
// Mock logger
vi.mock("../../src/shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// 导入被测模块(在 mock 之后)
import {
checkAndMark,
isProcessed,
generateIdempotencyKey,
} from "../../src/shared/redis/idempotency.guard.js";
// ============================================================
// Tests
// ============================================================
describe("idempotency.guard", () => {
beforeEach(() => {
vi.clearAllMocks();
redisReturnValue = null;
mockInsertChain.values.mockResolvedValue(undefined);
// 重置 select chain
mockSelectChain.from.mockReturnThis();
mockSelectChain.where.mockReturnThis();
mockSelectChain.limit.mockResolvedValue([]);
});
// ----------------------------------------------------------
// checkAndMark
// ----------------------------------------------------------
describe("checkAndMark", () => {
it("Redis SETNX 成功(返回 OK→ isFirst=true", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockResolvedValue("OK");
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(true);
expect(result.key).toBe("msg:processed:evt-1");
expect(mockRedis.set).toHaveBeenCalledWith(
"msg:processed:evt-1",
"1",
"EX",
7 * 24 * 60 * 60,
"NX",
);
// 不应走 DB 降级
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("Redis SETNX 失败(返回 null→ isFirst=false已处理", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockResolvedValue(null);
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(false);
expect(result.key).toBe("msg:processed:evt-1");
// 不应走 DB 降级
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("Redis 异常 → 降级到 DB 插入,成功时 isFirst=true", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockRejectedValue(new Error("Redis connection lost"));
mockInsertChain.values.mockResolvedValue(undefined);
const result = await checkAndMark("evt-1", "edu.notification.sent");
expect(result.isFirst).toBe(true);
expect(result.key).toBe("msg:processed:evt-1");
// 应走 DB 降级
expect(mockDb.insert).toHaveBeenCalledTimes(1);
expect(mockInsertChain.values).toHaveBeenCalledWith({
eventId: "evt-1",
topic: "edu.notification.sent",
});
});
it("Redis 异常 + DB 唯一索引冲突 → isFirst=false", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockRejectedValue(new Error("Redis down"));
// DB 插入冲突
mockInsertChain.values.mockRejectedValue(new Error("Duplicate entry"));
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(false);
expect(mockDb.insert).toHaveBeenCalledTimes(1);
});
it("Redis 不可用getRedis 返回 null→ 直接走 DB 降级", async () => {
redisReturnValue = null;
mockInsertChain.values.mockResolvedValue(undefined);
const result = await checkAndMark("evt-1", "edu.notification.sent");
expect(result.isFirst).toBe(true);
expect(mockRedis.set).not.toHaveBeenCalled();
expect(mockDb.insert).toHaveBeenCalledTimes(1);
});
it("Redis 不可用 + DB 冲突 → isFirst=false", async () => {
redisReturnValue = null;
mockInsertChain.values.mockRejectedValue(new Error("Duplicate"));
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(false);
});
it("无 topic 参数时 DB 降级应使用 'unknown'", async () => {
redisReturnValue = null;
mockInsertChain.values.mockResolvedValue(undefined);
await checkAndMark("evt-1");
expect(mockInsertChain.values).toHaveBeenCalledWith({
eventId: "evt-1",
topic: "unknown",
});
});
it("key 格式应为 msg:processed:{eventId}", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockResolvedValue("OK");
const result = await checkAndMark("my-event-123");
expect(result.key).toBe("msg:processed:my-event-123");
});
});
// ----------------------------------------------------------
// isProcessed
// ----------------------------------------------------------
describe("isProcessed", () => {
it("Redis exists=1 → true已处理", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockResolvedValue(1);
const result = await isProcessed("evt-1");
expect(result).toBe(true);
expect(mockRedis.exists).toHaveBeenCalledWith("msg:processed:evt-1");
});
it("Redis exists=0 → false未处理", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockResolvedValue(0);
const result = await isProcessed("evt-1");
expect(result).toBe(false);
});
it("Redis 异常 → 降级到 DB 查询", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
mockSelectChain.limit.mockResolvedValue([{ eventId: "evt-1" }]);
const result = await isProcessed("evt-1");
expect(result).toBe(true);
expect(mockDb.select).toHaveBeenCalledTimes(1);
});
it("Redis 异常 + DB 无记录 → false", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
mockSelectChain.limit.mockResolvedValue([]);
const result = await isProcessed("evt-1");
expect(result).toBe(false);
});
it("Redis 不可用 → 直接走 DB 查询", async () => {
redisReturnValue = null;
mockSelectChain.limit.mockResolvedValue([{ eventId: "evt-1" }]);
const result = await isProcessed("evt-1");
expect(result).toBe(true);
expect(mockRedis.exists).not.toHaveBeenCalled();
expect(mockDb.select).toHaveBeenCalledTimes(1);
});
it("Redis 不可用 + DB 无记录 → false", async () => {
redisReturnValue = null;
mockSelectChain.limit.mockResolvedValue([]);
const result = await isProcessed("evt-1");
expect(result).toBe(false);
});
});
// ----------------------------------------------------------
// generateIdempotencyKey
// ----------------------------------------------------------
describe("generateIdempotencyKey", () => {
it("应返回一个字符串", () => {
const key = generateIdempotencyKey();
expect(typeof key).toBe("string");
expect(key.length).toBeGreaterThan(0);
});
it("应使用 cuid2 生成", () => {
const key = generateIdempotencyKey();
// createId 被 mock 为返回 "mock-idempotency-key"
expect(key).toBe("mock-idempotency-key");
});
});
});

View File

@@ -0,0 +1,473 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Mock } from "vitest";
// ============================================================
// Mock 外部依赖
// ============================================================
// Mock database —— getDb 返回模拟的 db 对象
const mockDb = {
insert: vi.fn(),
select: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
vi.mock("../../src/config/database.js", () => ({
getDb: () => mockDb,
}));
// Mock logger避免 pino 初始化副作用)
vi.mock("../../src/shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// 导入被测模块(在 mock 之后)
import {
insertNotification,
insertNotifications,
findById,
findByEventId,
listByUser,
getUnreadCount,
markAsRead,
batchMarkAsRead,
markAllAsRead,
recallByGroup,
deleteById,
} from "../../src/notifications/notifications.repository.js";
// ============================================================
// 辅助:创建 drizzle 链式 mock
// ============================================================
/**
* 创建一个可链式调用且可 await 的 mock 对象。
* drizzle 查询构建器方法from/where/limit/offset/orderBy/values/set
* 全部返回链本身await 时解析为 resolveValue。
*/
function createChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {
then(resolve: (v: unknown) => void, reject?: (e: unknown) => void) {
return Promise.resolve(resolveValue).then(resolve, reject);
},
};
for (const method of [
"from",
"where",
"limit",
"offset",
"orderBy",
"values",
"set",
]) {
chain[method] = vi.fn().mockReturnValue(chain);
}
return chain;
}
// ============================================================
// Tests
// ============================================================
describe("notifications.repository", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ----------------------------------------------------------
// insertNotification
// ----------------------------------------------------------
describe("insertNotification", () => {
it("应插入通知行并返回该行", async () => {
const chain = createChain(undefined);
(mockDb.insert as Mock).mockReturnValue(chain);
const row = {
id: "notif-1",
userId: "user-1",
type: "system" as const,
title: "Test",
content: "Content",
channel: "in_app" as const,
isRead: false,
status: "pending" as const,
metadata: null,
relatedEntityType: null,
relatedEntityId: null,
groupId: null,
senderId: null,
templateId: null,
eventId: null,
};
const result = await insertNotification(row);
expect(mockDb.insert).toHaveBeenCalledTimes(1);
expect(chain.values).toHaveBeenCalledWith(row);
expect(result).toEqual(row);
});
});
// ----------------------------------------------------------
// insertNotifications (batch)
// ----------------------------------------------------------
describe("insertNotifications", () => {
it("应批量插入多行通知", async () => {
const chain = createChain(undefined);
(mockDb.insert as Mock).mockReturnValue(chain);
const rows = [
{
id: "notif-1",
userId: "user-1",
type: "system" as const,
title: "T1",
content: "C1",
channel: "in_app" as const,
isRead: false,
status: "pending" as const,
metadata: null,
relatedEntityType: null,
relatedEntityId: null,
groupId: null,
senderId: null,
templateId: null,
eventId: null,
},
{
id: "notif-2",
userId: "user-2",
type: "exam" as const,
title: "T2",
content: "C2",
channel: "in_app" as const,
isRead: false,
status: "pending" as const,
metadata: null,
relatedEntityType: null,
relatedEntityId: null,
groupId: null,
senderId: null,
templateId: null,
eventId: null,
},
];
await insertNotifications(rows);
expect(mockDb.insert).toHaveBeenCalledTimes(1);
expect(chain.values).toHaveBeenCalledWith(rows);
});
it("空数组应直接返回,不调用 db", async () => {
await insertNotifications([]);
expect(mockDb.insert).not.toHaveBeenCalled();
});
});
// ----------------------------------------------------------
// findById
// ----------------------------------------------------------
describe("findById", () => {
it("应根据 id 查询并返回通知行", async () => {
const mockRow = { id: "notif-1", userId: "user-1", title: "Test" };
const chain = createChain([mockRow]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await findById("notif-1");
expect(mockDb.select).toHaveBeenCalledTimes(1);
expect(chain.from).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
expect(chain.limit).toHaveBeenCalledWith(1);
expect(result).toEqual(mockRow);
});
it("未找到时应返回 undefined", async () => {
const chain = createChain([]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await findById("not-exist");
expect(result).toBeUndefined();
});
});
// ----------------------------------------------------------
// findByEventId
// ----------------------------------------------------------
describe("findByEventId", () => {
it("应根据 eventId 查询并返回通知行", async () => {
const mockRow = { id: "notif-1", eventId: "evt-1", status: "sent" };
const chain = createChain([mockRow]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await findByEventId("evt-1");
expect(mockDb.select).toHaveBeenCalledTimes(1);
expect(chain.from).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
expect(chain.limit).toHaveBeenCalledWith(1);
expect(result).toEqual(mockRow);
});
it("未找到时应返回 undefined", async () => {
const chain = createChain([]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await findByEventId("not-exist");
expect(result).toBeUndefined();
});
});
// ----------------------------------------------------------
// listByUser
// ----------------------------------------------------------
describe("listByUser", () => {
it("应分页查询用户通知并返回 items + total", async () => {
const mockItems = [
{ id: "notif-1", userId: "user-1" },
{ id: "notif-2", userId: "user-1" },
];
const mockCountRow = [{ value: 25 }];
// 第一次 select → items 查询, 第二次 select → count 查询
(mockDb.select as Mock)
.mockReturnValueOnce(createChain(mockItems))
.mockReturnValueOnce(createChain(mockCountRow));
const result = await listByUser("user-1", {
page: 2,
pageSize: 10,
});
expect(result.items).toEqual(mockItems);
expect(result.total).toBe(25);
expect(mockDb.select).toHaveBeenCalledTimes(2);
});
it("onlyUnread=true 应添加未读过滤条件", async () => {
const itemsChain = createChain([]);
const countChain = createChain([{ value: 0 }]);
(mockDb.select as Mock)
.mockReturnValueOnce(itemsChain)
.mockReturnValueOnce(countChain);
await listByUser("user-1", {
onlyUnread: true,
page: 1,
pageSize: 20,
});
// 两次查询都调用 where
expect(itemsChain.where).toHaveBeenCalledTimes(1);
expect(countChain.where).toHaveBeenCalledTimes(1);
});
it("type 过滤应生效", async () => {
const itemsChain = createChain([]);
const countChain = createChain([{ value: 0 }]);
(mockDb.select as Mock)
.mockReturnValueOnce(itemsChain)
.mockReturnValueOnce(countChain);
await listByUser("user-1", {
type: "exam",
page: 1,
pageSize: 20,
});
expect(itemsChain.where).toHaveBeenCalledTimes(1);
expect(countChain.where).toHaveBeenCalledTimes(1);
});
it("count 为 undefined 时 total 应为 0", async () => {
const itemsChain = createChain([]);
const countChain = createChain([undefined]);
(mockDb.select as Mock)
.mockReturnValueOnce(itemsChain)
.mockReturnValueOnce(countChain);
const result = await listByUser("user-1", {
page: 1,
pageSize: 10,
});
expect(result.total).toBe(0);
});
it("offset 应根据 page 和 pageSize 计算", async () => {
const itemsChain = createChain([]);
const countChain = createChain([{ value: 0 }]);
(mockDb.select as Mock)
.mockReturnValueOnce(itemsChain)
.mockReturnValueOnce(countChain);
await listByUser("user-1", {
page: 3,
pageSize: 15,
});
// offset = (3-1) * 15 = 30
expect(itemsChain.offset).toHaveBeenCalledWith(30);
});
});
// ----------------------------------------------------------
// getUnreadCount
// ----------------------------------------------------------
describe("getUnreadCount", () => {
it("应返回用户未读通知数", async () => {
const chain = createChain([{ value: 7 }]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await getUnreadCount("user-1");
expect(result).toBe(7);
expect(mockDb.select).toHaveBeenCalledTimes(1);
expect(chain.from).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
});
it("无未读时应返回 0", async () => {
const chain = createChain([undefined]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await getUnreadCount("user-1");
expect(result).toBe(0);
});
});
// ----------------------------------------------------------
// markAsRead
// ----------------------------------------------------------
describe("markAsRead", () => {
it("应将指定通知标记为已读", async () => {
const chain = createChain(undefined);
(mockDb.update as Mock).mockReturnValue(chain);
await markAsRead("notif-1", "user-1");
expect(mockDb.update).toHaveBeenCalledTimes(1);
expect(chain.set).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
// 验证 set 的参数包含 isRead: true
const setArg = (chain.set as Mock).mock.calls[0][0];
expect(setArg.isRead).toBe(true);
expect(setArg.status).toBe("read");
expect(setArg.readAt).toBeInstanceOf(Date);
});
});
// ----------------------------------------------------------
// batchMarkAsRead
// ----------------------------------------------------------
describe("batchMarkAsRead", () => {
it("应批量标记多条通知为已读", async () => {
const chain = createChain(undefined);
(mockDb.update as Mock).mockReturnValue(chain);
await batchMarkAsRead(["notif-1", "notif-2", "notif-3"], "user-1");
expect(mockDb.update).toHaveBeenCalledTimes(1);
expect(chain.set).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
const setArg = (chain.set as Mock).mock.calls[0][0];
expect(setArg.isRead).toBe(true);
expect(setArg.status).toBe("read");
});
});
// ----------------------------------------------------------
// markAllAsRead
// ----------------------------------------------------------
describe("markAllAsRead", () => {
it("应将用户所有未读通知标记为已读,返回受影响行数", async () => {
const chain = createChain({ affectedRows: 5 });
(mockDb.update as Mock).mockReturnValue(chain);
const result = await markAllAsRead("user-1");
expect(result).toBe(5);
expect(mockDb.update).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
});
it("before 参数应添加时间过滤条件", async () => {
const chain = createChain({ affectedRows: 3 });
(mockDb.update as Mock).mockReturnValue(chain);
const before = Date.now();
const result = await markAllAsRead("user-1", before);
expect(result).toBe(3);
expect(chain.where).toHaveBeenCalledTimes(1);
});
it("无受影响行时应返回 0", async () => {
const chain = createChain({});
(mockDb.update as Mock).mockReturnValue(chain);
const result = await markAllAsRead("user-1");
expect(result).toBe(0);
});
});
// ----------------------------------------------------------
// recallByGroup
// ----------------------------------------------------------
describe("recallByGroup", () => {
it("应按 groupId 撤回通知,返回受影响行数", async () => {
const chain = createChain({ affectedRows: 10 });
(mockDb.update as Mock).mockReturnValue(chain);
const result = await recallByGroup("group-1");
expect(result).toBe(10);
expect(mockDb.update).toHaveBeenCalledTimes(1);
const setArg = (chain.set as Mock).mock.calls[0][0];
expect(setArg.status).toBe("recalled");
});
it("无匹配行时应返回 0", async () => {
const chain = createChain({});
(mockDb.update as Mock).mockReturnValue(chain);
const result = await recallByGroup("empty-group");
expect(result).toBe(0);
});
});
// ----------------------------------------------------------
// deleteById
// ----------------------------------------------------------
describe("deleteById", () => {
it("应根据 id 删除通知", async () => {
const chain = createChain(undefined);
(mockDb.delete as Mock).mockReturnValue(chain);
await deleteById("notif-1");
expect(mockDb.delete).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,796 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Mock } from "vitest";
import type { ChannelSendResult } from "../../src/channels/channel.types.js";
// ============================================================
// Mock 外部依赖 —— 使用 vi.hoisted 确保 mock 变量在 hoisted 的 vi.mock 中可用
// ============================================================
const mocks = vi.hoisted(() => {
// Mock database用于 getUserChannels / updateStatus
const mockDb = {
select: vi.fn(),
update: vi.fn(),
};
// Mock elasticsearch
const mockSafeIndex = vi.fn();
const mockSafeSearch = vi.fn();
const mockSafeDelete = vi.fn();
// Mock outbox publish
const mockOutboxPublish = vi.fn();
// Mock repository
const mockRepo = {
insertNotification: vi.fn(),
insertNotifications: vi.fn(),
findById: vi.fn(),
findByEventId: vi.fn(),
findExistingEventIds: vi.fn(),
listByUser: vi.fn(),
getUnreadCount: vi.fn(),
markAsRead: vi.fn(),
batchMarkAsRead: vi.fn(),
markAllAsRead: vi.fn(),
recallByGroup: vi.fn(),
deleteById: vi.fn(),
};
return {
mockDb,
mockSafeIndex,
mockSafeSearch,
mockSafeDelete,
mockOutboxPublish,
mockRepo,
};
});
// Mock cuid2 —— 固定 ID 便于断言
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "mock-cuid-id"),
}));
vi.mock("../../src/config/database.js", () => ({
getDb: () => mocks.mockDb,
}));
vi.mock("../../src/config/elasticsearch.js", () => ({
safeIndex: mocks.mockSafeIndex,
safeSearch: mocks.mockSafeSearch,
safeDelete: mocks.mockSafeDelete,
}));
vi.mock("../../src/shared/outbox/outbox.service.js", () => ({
publish: mocks.mockOutboxPublish,
}));
vi.mock("../../src/shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock(
"../../src/notifications/notifications.repository.js",
() => mocks.mockRepo,
);
// 导入被测模块(在 mock 之后)
import { NotificationsService } from "../../src/notifications/notifications.service.js";
// ============================================================
// 辅助
// ============================================================
const {
mockDb,
mockSafeIndex,
mockSafeSearch,
mockSafeDelete,
mockOutboxPublish,
mockRepo,
} = mocks;
function createMockDispatcher(
results: ChannelSendResult[] = [{ channel: "in_app", sent: true }],
) {
return {
dispatch: vi.fn().mockResolvedValue(results),
};
}
/** 创建 select 链式 mock用于 getUserChannels */
function createSelectChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {
then(resolve: (v: unknown) => void, reject?: (e: unknown) => void) {
return Promise.resolve(resolveValue).then(resolve, reject);
},
};
for (const method of ["from", "where", "limit"]) {
chain[method] = vi.fn().mockReturnValue(chain);
}
return chain;
}
/** 创建 update 链式 mock用于 updateStatus */
function createUpdateChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {
then(resolve: (v: unknown) => void, reject?: (e: unknown) => void) {
return Promise.resolve(resolveValue).then(resolve, reject);
},
};
chain.set = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockReturnValue(chain);
return chain;
}
// ============================================================
// Tests
// ============================================================
describe("NotificationsService", () => {
let service: NotificationsService;
beforeEach(() => {
vi.clearAllMocks();
// 重置 repo mocks 到默认值
mockRepo.findByEventId.mockResolvedValue(undefined);
mockRepo.findExistingEventIds.mockResolvedValue(new Set<string>());
mockRepo.insertNotification.mockResolvedValue(undefined);
mockRepo.listByUser.mockResolvedValue({ items: [], total: 0 });
mockRepo.getUnreadCount.mockResolvedValue(0);
mockRepo.markAsRead.mockResolvedValue(undefined);
mockRepo.batchMarkAsRead.mockResolvedValue(undefined);
mockRepo.markAllAsRead.mockResolvedValue(0);
mockRepo.recallByGroup.mockResolvedValue(0);
mockRepo.deleteById.mockResolvedValue(undefined);
// 重置 es mocks
mockSafeIndex.mockResolvedValue(true);
mockSafeSearch.mockResolvedValue({ hits: [], total: 0 });
mockSafeDelete.mockResolvedValue(true);
// 重置 outbox
mockOutboxPublish.mockResolvedValue({
eventId: "evt-id",
topic: "edu.notification.sent",
});
});
// ----------------------------------------------------------
// send
// ----------------------------------------------------------
describe("send", () => {
it("幂等跳过eventId 已存在时应返回已有通知,不重复写入", async () => {
const existing = {
id: "existing-id",
status: "sent",
channel: "in_app",
};
mockRepo.findByEventId.mockResolvedValue(existing);
const dispatcher = createMockDispatcher();
service = new NotificationsService(dispatcher as never);
const result = await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
eventId: "evt-1",
});
expect(result).toEqual({
id: "existing-id",
status: "sent",
channels: ["in_app"],
});
expect(mockRepo.findByEventId).toHaveBeenCalledWith("evt-1");
expect(mockRepo.insertNotification).not.toHaveBeenCalled();
expect(dispatcher.dispatch).not.toHaveBeenCalled();
});
it("无 eventId 时应正常发送(不做幂等检查)", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
// getUserChannels 返回 null无偏好
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
});
expect(mockRepo.findByEventId).not.toHaveBeenCalled();
expect(mockRepo.insertNotification).toHaveBeenCalledTimes(1);
expect(result.id).toBe("mock-cuid-id");
expect(result.status).toBe("sent");
});
it("应写入 MySQL、ES、分发渠道、更新状态、写 outbox", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
{ channel: "email", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.send({
userId: "user-1",
type: "exam",
title: "Exam Published",
content: "Your exam is live",
channel: "in_app",
metadata: { examId: "exam-1" },
});
// 写入 MySQL
expect(mockRepo.insertNotification).toHaveBeenCalledWith(
expect.objectContaining({
id: "mock-cuid-id",
userId: "user-1",
type: "exam",
title: "Exam Published",
channel: "in_app",
status: "pending",
isRead: false,
}),
);
// 写入 ES
expect(mockSafeIndex).toHaveBeenCalledWith(
expect.objectContaining({
index: "notifications",
id: "mock-cuid-id",
}),
);
// 分发渠道
expect(dispatcher.dispatch).toHaveBeenCalledTimes(1);
const dispatchCtx = (dispatcher.dispatch as Mock).mock.calls[0][0];
expect(dispatchCtx.notificationId).toBe("mock-cuid-id");
expect(dispatchCtx.userId).toBe("user-1");
// 更新状态
expect(mockDb.update).toHaveBeenCalledTimes(1);
// 写 outbox
expect(mockOutboxPublish).toHaveBeenCalledWith(
"notification.sent",
expect.objectContaining({
notificationId: "mock-cuid-id",
userId: "user-1",
channel: "in_app",
}),
expect.objectContaining({
aggregateType: "Notification",
aggregateId: "mock-cuid-id",
}),
);
expect(result.status).toBe("sent");
expect(result.channels).toEqual(["in_app", "email"]);
});
it("所有渠道失败时状态应为 failed", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: false, error: "failed" },
]);
service = new NotificationsService(dispatcher as never);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
});
expect(result.status).toBe("failed");
});
it("默认渠道应为 in_app未指定 channel 时)", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
});
const insertArg = mockRepo.insertNotification.mock.calls[0][0];
expect(insertArg.channel).toBe("in_app");
});
it("应使用用户偏好渠道getUserChannels 返回渠道列表)", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
{ channel: "email", sent: true },
]);
service = new NotificationsService(dispatcher as never);
// 模拟用户偏好返回 email 渠道
mockDb.select.mockReturnValue(
createSelectChain([
{
channels: ["email"],
userId: "user-1",
type: "system",
enabled: true,
},
]),
);
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
});
// dispatch 第二个参数应包含 email
const enabledChannels = (dispatcher.dispatch as Mock).mock.calls[0][1];
expect(enabledChannels).toContain("email");
expect(result.channels).toEqual(["in_app", "email"]);
});
it("ES 写入失败不应阻断主流程", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
// safeIndex 内部 try/catch失败时返回 false不抛出
mockSafeIndex.mockResolvedValue(false);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
// 不应抛出
const result = await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
});
expect(result.status).toBe("sent");
});
});
// ----------------------------------------------------------
// sendBatch
// ----------------------------------------------------------
describe("sendBatch", () => {
it("应批量发送多条通知,返回成功 id 列表", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.sendBatch({
items: [
{ userId: "user-1", type: "system", title: "T1", content: "C1" },
{ userId: "user-2", type: "system", title: "T2", content: "C2" },
],
groupId: "group-1",
});
expect(result.ids).toHaveLength(2);
expect(result.failed).toHaveLength(0);
// 批量 INSERT 应调用 insertNotifications复数
expect(mockRepo.insertNotifications).toHaveBeenCalledTimes(1);
const batchRows = mockRepo.insertNotifications.mock.calls[0][0];
expect(batchRows).toHaveLength(2);
expect(batchRows[0].groupId).toBe("group-1");
expect(batchRows[1].groupId).toBe("group-1");
});
it("未指定 groupId 时应自动生成", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.sendBatch({
items: [
{ userId: "user-1", type: "system", title: "T1", content: "C1" },
],
});
expect(result.ids).toHaveLength(1);
// groupId 应使用 cuid2 生成的值
const batchRows = mockRepo.insertNotifications.mock.calls[0][0];
expect(batchRows[0].groupId).toBe("mock-cuid-id");
});
it("批量 INSERT 失败时全部标记为 failed", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockRepo.insertNotifications.mockRejectedValueOnce(new Error("DB error"));
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.sendBatch({
items: [
{ userId: "user-1", type: "system", title: "T1", content: "C1" },
{ userId: "user-2", type: "system", title: "T2", content: "C2" },
],
groupId: "group-1",
});
expect(result.ids).toHaveLength(0);
expect(result.failed).toHaveLength(2);
expect(result.failed[0].error).toBe("DB error");
expect(result.failed[1].error).toBe("DB error");
});
it("部分分发失败时应收集 failed 列表", async () => {
// 第一条分发成功,第二条分发抛异常
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
// 让 dispatch 第二次调用抛异常
dispatcher.dispatch
.mockResolvedValueOnce([{ channel: "in_app", sent: true }])
.mockRejectedValueOnce(new Error("Dispatch error"));
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.sendBatch({
items: [
{ userId: "user-1", type: "system", title: "T1", content: "C1" },
{ userId: "user-2", type: "system", title: "T2", content: "C2" },
],
groupId: "group-1",
});
expect(result.ids).toHaveLength(1);
expect(result.failed).toHaveLength(1);
expect(result.failed[0].userId).toBe("user-2");
expect(result.failed[0].error).toBe("Dispatch error");
});
it("eventId 已存在时应跳过(幂等过滤)", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
// 模拟 findExistingEventIds 返回已存在的 eventId
mockRepo.findExistingEventIds.mockResolvedValueOnce(
new Set(["evt-existing-1"]),
);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.sendBatch({
items: [
{
userId: "user-1",
type: "system",
title: "T1",
content: "C1",
eventId: "evt-existing-1",
},
{
userId: "user-2",
type: "system",
title: "T2",
content: "C2",
eventId: "evt-new-1",
},
],
groupId: "group-1",
});
// 应只插入 1 条evt-existing-1 被跳过)
expect(result.ids).toHaveLength(1);
expect(result.failed).toHaveLength(0);
expect(mockRepo.insertNotifications).toHaveBeenCalledTimes(1);
const batchRows = mockRepo.insertNotifications.mock.calls[0][0];
expect(batchRows).toHaveLength(1);
expect(batchRows[0].eventId).toBe("evt-new-1");
});
it("所有 eventId 都已存在时应返回空 ids全跳过", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockRepo.findExistingEventIds.mockResolvedValueOnce(
new Set(["evt-1", "evt-2"]),
);
const result = await service.sendBatch({
items: [
{
userId: "user-1",
type: "system",
title: "T1",
content: "C1",
eventId: "evt-1",
},
{
userId: "user-2",
type: "system",
title: "T2",
content: "C2",
eventId: "evt-2",
},
],
groupId: "group-1",
});
expect(result.ids).toHaveLength(0);
expect(result.failed).toHaveLength(0);
expect(mockRepo.insertNotifications).not.toHaveBeenCalled();
expect(dispatcher.dispatch).not.toHaveBeenCalled();
});
});
// ----------------------------------------------------------
// listByUser
// ----------------------------------------------------------
describe("listByUser", () => {
it("应返回分页结果", async () => {
service = new NotificationsService(createMockDispatcher() as never);
const mockItems = [{ id: "n1" }, { id: "n2" }];
mockRepo.listByUser.mockResolvedValue({ items: mockItems, total: 25 });
const result = await service.listByUser("user-1", {
page: 2,
pageSize: 10,
});
expect(result.items).toEqual(mockItems);
expect(result.total).toBe(25);
expect(result.page).toBe(2);
expect(result.pageSize).toBe(10);
});
});
// ----------------------------------------------------------
// getUnreadCount
// ----------------------------------------------------------
describe("getUnreadCount", () => {
it("应返回未读计数", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockRepo.getUnreadCount.mockResolvedValue(7);
const result = await service.getUnreadCount("user-1");
expect(result).toBe(7);
});
});
// ----------------------------------------------------------
// markAsRead
// ----------------------------------------------------------
describe("markAsRead", () => {
it("应标记已读并发布 notification.read 事件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
await service.markAsRead("notif-1", "user-1");
expect(mockRepo.markAsRead).toHaveBeenCalledWith("notif-1", "user-1");
expect(mockOutboxPublish).toHaveBeenCalledWith(
"notification.read",
{ notificationId: "notif-1", userId: "user-1" },
expect.objectContaining({
aggregateType: "Notification",
aggregateId: "notif-1",
}),
);
});
});
// ----------------------------------------------------------
// batchMarkAsRead
// ----------------------------------------------------------
describe("batchMarkAsRead", () => {
it("应批量标记已读并为每条发布 read 事件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
await service.batchMarkAsRead(["n1", "n2", "n3"], "user-1");
expect(mockRepo.batchMarkAsRead).toHaveBeenCalledWith(
["n1", "n2", "n3"],
"user-1",
);
expect(mockOutboxPublish).toHaveBeenCalledTimes(3);
// 验证每条 id 都发布了事件
for (let i = 0; i < 3; i++) {
const payload = mockOutboxPublish.mock.calls[i][1];
expect(payload.notificationId).toBe(["n1", "n2", "n3"][i]);
}
});
});
// ----------------------------------------------------------
// markAllAsRead
// ----------------------------------------------------------
describe("markAllAsRead", () => {
it("应标记全部已读并返回受影响行数", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockRepo.markAllAsRead.mockResolvedValue(15);
const result = await service.markAllAsRead("user-1");
expect(result).toBe(15);
expect(mockOutboxPublish).toHaveBeenCalledWith(
"notification.read",
expect.objectContaining({ bulk: true, userId: "user-1" }),
expect.objectContaining({
aggregateType: "Notification",
aggregateId: "user-1",
}),
);
});
it("before 参数应传递给 outbox 事件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockRepo.markAllAsRead.mockResolvedValue(5);
const before = 1700000000000;
await service.markAllAsRead("user-1", before);
const payload = mockOutboxPublish.mock.calls[0][1];
expect(payload.before).toBe(before);
});
});
// ----------------------------------------------------------
// search
// ----------------------------------------------------------
describe("search", () => {
it("应通过 ES 搜索并返回结果", async () => {
service = new NotificationsService(createMockDispatcher() as never);
const mockHits = [
{ _id: "n1", _source: { id: "n1", title: "Hello" } },
{ _id: "n2", _source: { id: "n2", title: "Hello World" } },
];
mockSafeSearch.mockResolvedValue({ hits: mockHits, total: 2 });
const result = await service.search("user-1", "Hello", {
page: 1,
pageSize: 20,
});
expect(result.items).toEqual([
{ id: "n1", title: "Hello" },
{ id: "n2", title: "Hello World" },
]);
expect(result.total).toBe(2);
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
expect(mockSafeSearch).toHaveBeenCalledWith(
"notifications",
expect.any(Object),
0, // from = (1-1) * 20
20,
);
});
it("type 过滤应添加到查询条件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockSafeSearch.mockResolvedValue({ hits: [], total: 0 });
await service.search("user-1", "test", {
type: "exam",
page: 1,
pageSize: 10,
});
const queryArg = mockSafeSearch.mock.calls[0][1];
// must 数组应包含 type term
const must = (queryArg as { bool: { must: unknown[] } }).bool.must;
expect(must).toHaveLength(3); // user_id + multi_match + type
});
it("page=2 时 from 应正确计算", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockSafeSearch.mockResolvedValue({ hits: [], total: 0 });
await service.search("user-1", "test", {
page: 2,
pageSize: 15,
});
// from = (2-1) * 15 = 15
expect(mockSafeSearch).toHaveBeenCalledWith(
"notifications",
expect.any(Object),
15,
15,
);
});
});
// ----------------------------------------------------------
// recall
// ----------------------------------------------------------
describe("recall", () => {
it("应撤回通知组并发布 notification.recalled 事件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockRepo.recallByGroup.mockResolvedValue(5);
const result = await service.recall("group-1");
expect(result).toBe(5);
expect(mockRepo.recallByGroup).toHaveBeenCalledWith("group-1");
expect(mockOutboxPublish).toHaveBeenCalledWith(
"notification.recalled",
{ groupId: "group-1", recalledCount: 5 },
expect.objectContaining({
aggregateType: "Notification",
aggregateId: "group-1",
}),
);
});
it("撤回 0 条时也应正常发布事件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockRepo.recallByGroup.mockResolvedValue(0);
const result = await service.recall("empty-group");
expect(result).toBe(0);
expect(mockOutboxPublish).toHaveBeenCalledTimes(1);
});
});
// ----------------------------------------------------------
// delete
// ----------------------------------------------------------
describe("delete", () => {
it("应删除 MySQL 记录和 ES 索引", async () => {
service = new NotificationsService(createMockDispatcher() as never);
await service.delete("notif-1");
expect(mockRepo.deleteById).toHaveBeenCalledWith("notif-1");
expect(mockSafeDelete).toHaveBeenCalledWith("notifications", "notif-1");
});
it("ES 删除失败不应阻断", async () => {
service = new NotificationsService(createMockDispatcher() as never);
// safeDelete 内部 try/catch失败时返回 false不抛出
mockSafeDelete.mockResolvedValue(false);
// 不应抛出
await service.delete("notif-1");
expect(mockRepo.deleteById).toHaveBeenCalledWith("notif-1");
});
});
});

View File

@@ -0,0 +1,458 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { OutboxEvent } from "../../src/shared/outbox/outbox.schema.js";
// ============================================================
// Mock 外部依赖 —— 使用 vi.hoisted 确保 mock 变量在 hoisted 的 vi.mock 中可用
// ============================================================
const mocks = vi.hoisted(() => {
const mockProducer = {
send: vi.fn(),
};
const mockFindPending = vi.fn();
const mockMarkPublished = vi.fn();
const mockMarkFailed = vi.fn();
const mockIncrementRetry = vi.fn();
return {
mockProducer,
mockFindPending,
mockMarkPublished,
mockMarkFailed,
mockIncrementRetry,
};
});
// Mock logger
vi.mock("../../src/shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock Kafka client
vi.mock("../../src/shared/kafka/kafka.client.js", () => ({
getProducer: () => mocks.mockProducer,
isKafkaHealthy: vi.fn(() => true),
}));
// Mock topic-map
vi.mock("../../src/shared/kafka/topic-map.js", () => ({
resolveTopic: vi.fn((eventType: string) => {
const map: Record<string, string> = {
"notification.sent": "edu.notification.sent",
"notification.read": "edu.notification.read",
"notification.recalled": "edu.notification.recalled",
"notification.failed": "edu.notification.failed",
};
return map[eventType] ?? "edu.notification.events";
}),
}));
// Mock outbox repository
vi.mock("../../src/shared/outbox/outbox.repository.js", () => ({
findPending: mocks.mockFindPending,
markPublished: mocks.mockMarkPublished,
markFailed: mocks.mockMarkFailed,
incrementRetry: mocks.mockIncrementRetry,
}));
// 导入被测模块(在 mock 之后)
import { outboxPublisher } from "../../src/shared/outbox/outbox.publisher.js";
// ============================================================
// 辅助
// ============================================================
const {
mockProducer,
mockFindPending,
mockMarkPublished,
mockMarkFailed,
mockIncrementRetry,
} = mocks;
function createMessage(overrides: Partial<OutboxEvent> = {}): OutboxEvent {
return {
eventId: "evt-1",
aggregateType: "Notification",
aggregateId: "agg-1",
eventType: "notification.sent",
topic: "edu.notification.sent",
payload: { notificationId: "n1", userId: "u1" },
status: "pending",
retryCount: 0,
maxRetryCount: 5,
createdAt: new Date("2026-01-01T00:00:00Z"),
publishedAt: null,
nextRetryAt: null,
lastError: null,
metadata: null,
...overrides,
} as OutboxEvent;
}
// 访问私有方法
function poll(): Promise<void> {
return (outboxPublisher as unknown as { poll: () => Promise<void> }).poll();
}
function dispatch(message: OutboxEvent): Promise<void> {
return (
outboxPublisher as unknown as {
dispatch: (m: OutboxEvent) => Promise<void>;
}
).dispatch(message);
}
// ============================================================
// Tests
// ============================================================
describe("OutboxPublisher", () => {
beforeEach(async () => {
vi.clearAllMocks();
vi.useFakeTimers();
// 重置 singleton 状态:确保 intervalId 被清除
await outboxPublisher.stop();
mockProducer.send.mockResolvedValue({} as never);
mockMarkPublished.mockResolvedValue(undefined);
mockMarkFailed.mockResolvedValue(undefined);
mockIncrementRetry.mockResolvedValue(undefined);
mockFindPending.mockResolvedValue([]);
});
afterEach(() => {
vi.useRealTimers();
});
// ----------------------------------------------------------
// start / stop
// ----------------------------------------------------------
describe("start / stop", () => {
it("start 应设置定时轮询", async () => {
await outboxPublisher.start();
// 推进定时器触发 poll
await vi.advanceTimersByTimeAsync(5000);
expect(mockFindPending).toHaveBeenCalled();
});
it("重复 start 不应创建多个定时器", async () => {
await outboxPublisher.start();
await outboxPublisher.start();
// 推进 5 秒,应只 poll 一次(第二个 start 是 no-op
await vi.advanceTimersByTimeAsync(5000);
// 只有一个 intervalpoll 应只被调用一次
expect(mockFindPending).toHaveBeenCalledTimes(1);
});
it("stop 应清除定时器", async () => {
await outboxPublisher.start();
await outboxPublisher.stop();
mockFindPending.mockClear();
await vi.advanceTimersByTimeAsync(10000);
expect(mockFindPending).not.toHaveBeenCalled();
});
it("无定时器时 stop 不应报错", async () => {
await outboxPublisher.stop();
// 不抛出即可
});
});
// ----------------------------------------------------------
// poll
// ----------------------------------------------------------
describe("poll", () => {
it("findPending 返回消息时应逐条 dispatch", async () => {
const messages = [
createMessage({ eventId: "evt-1" }),
createMessage({ eventId: "evt-2" }),
];
mockFindPending.mockResolvedValue(messages);
await poll();
expect(mockFindPending).toHaveBeenCalledWith(100); // BATCH_SIZE
expect(mockProducer.send).toHaveBeenCalledTimes(2);
expect(mockMarkPublished).toHaveBeenCalledTimes(2);
expect(mockMarkPublished).toHaveBeenCalledWith("evt-1");
expect(mockMarkPublished).toHaveBeenCalledWith("evt-2");
});
it("findPending 返回空数组时不应 dispatch", async () => {
mockFindPending.mockResolvedValue([]);
await poll();
expect(mockProducer.send).not.toHaveBeenCalled();
expect(mockMarkPublished).not.toHaveBeenCalled();
});
it("findPending 抛出异常时应记录日志不中断", async () => {
mockFindPending.mockRejectedValue(new Error("DB connection lost"));
// 不应抛出
await poll();
// logger.error 应被调用(由 mock 拦截)
});
it("并发 poll 保护:正在 poll 时不应重复执行", async () => {
const messages = [createMessage()];
// 让 producer.send 返回一个未完成的 Promise
let resolveSend: () => void;
mockProducer.send.mockReturnValue(
new Promise((resolve) => {
resolveSend = resolve as () => void;
}),
);
mockFindPending.mockResolvedValue(messages);
// 启动第一次 poll未完成
const firstPoll = poll();
// 尝试第二次 poll应被跳过
await poll();
// 只有第一次的 findPending 被调用了一次
// (第二次 poll 因为 isPolling=true 直接返回)
expect(mockFindPending).toHaveBeenCalledTimes(1);
// 完成
resolveSend!();
await firstPoll;
});
});
// ----------------------------------------------------------
// dispatch —— 成功路径
// ----------------------------------------------------------
describe("dispatch 成功", () => {
it("应发送到正确的 topic 并标记 published", async () => {
const message = createMessage({
eventType: "notification.read",
aggregateId: "notif-1",
});
await dispatch(message);
expect(mockProducer.send).toHaveBeenCalledWith({
topic: "edu.notification.read",
messages: [
{
key: "notif-1",
value: JSON.stringify({ notificationId: "n1", userId: "u1" }),
headers: expect.objectContaining({
eventId: "evt-1",
eventType: "notification.read",
aggregateType: "Notification",
aggregateId: "notif-1",
}),
},
],
});
expect(mockMarkPublished).toHaveBeenCalledWith("evt-1");
});
it("payload 为字符串时应直接使用", async () => {
const message = createMessage({
payload: "raw-string-payload" as unknown,
} as OutboxEvent);
await dispatch(message);
const sendArg = mockProducer.send.mock.calls[0][0];
expect(sendArg.messages[0].value).toBe("raw-string-payload");
});
it("payload 为对象时应 JSON 序列化", async () => {
const payload = { key: "value", num: 42 };
const message = createMessage({ payload });
await dispatch(message);
const sendArg = mockProducer.send.mock.calls[0][0];
expect(sendArg.messages[0].value).toBe(JSON.stringify(payload));
});
it("metadata 应合并到 headers", async () => {
const message = createMessage({
metadata: { userId: "u1", source: "test" },
});
await dispatch(message);
const sendArg = mockProducer.send.mock.calls[0][0];
expect(sendArg.messages[0].headers).toEqual(
expect.objectContaining({
eventId: "evt-1",
eventType: "notification.sent",
aggregateType: "Notification",
aggregateId: "agg-1",
userId: "u1",
source: "test",
}),
);
});
it("metadata 为 null 时 headers 只含基础字段", async () => {
const message = createMessage({ metadata: null });
await dispatch(message);
const sendArg = mockProducer.send.mock.calls[0][0];
const headers = sendArg.messages[0].headers;
expect(Object.keys(headers)).toEqual(
expect.arrayContaining([
"eventId",
"eventType",
"aggregateType",
"aggregateId",
]),
);
expect(Object.keys(headers)).toHaveLength(4);
});
});
// ----------------------------------------------------------
// dispatch —— 重试逻辑
// ----------------------------------------------------------
describe("dispatch 重试逻辑", () => {
it("失败且 retryCount < MAX_RETRY → incrementRetry", async () => {
const message = createMessage({ retryCount: 2 });
mockProducer.send.mockRejectedValue(new Error("Kafka timeout"));
await dispatch(message);
// retryCount=2, +1=3 < MAX_RETRY(5)
expect(mockIncrementRetry).toHaveBeenCalledTimes(1);
expect(mockIncrementRetry).toHaveBeenCalledWith(
"evt-1",
"Kafka timeout",
2000 * 2 ** 2, // RETRY_BACKOFF_MS * 2^retryCount = 2000 * 4 = 8000
);
expect(mockMarkFailed).not.toHaveBeenCalled();
});
it("失败且 retryCount+1 >= MAX_RETRY → markFailed", async () => {
const message = createMessage({ retryCount: 4 }); // 4+1=5 >= 5
mockProducer.send.mockRejectedValue(new Error("Kafka down"));
await dispatch(message);
expect(mockMarkFailed).toHaveBeenCalledTimes(1);
expect(mockMarkFailed).toHaveBeenCalledWith("evt-1", "Kafka down");
expect(mockIncrementRetry).not.toHaveBeenCalled();
});
it("失败且 retryCount=5超过 MAX_RETRY→ markFailed", async () => {
const message = createMessage({ retryCount: 5 });
mockProducer.send.mockRejectedValue(new Error("Still failing"));
await dispatch(message);
expect(mockMarkFailed).toHaveBeenCalledWith("evt-1", "Still failing");
});
it("非 Error 类型的异常应转字符串", async () => {
const message = createMessage({ retryCount: 0 });
mockProducer.send.mockRejectedValue("string error");
await dispatch(message);
expect(mockIncrementRetry).toHaveBeenCalledWith(
"evt-1",
"string error",
2000, // 2000 * 2^0 = 2000
);
});
it("指数退避应正确计算retryCount=0 → 2000ms, retryCount=1 → 4000ms", async () => {
mockProducer.send.mockRejectedValue(new Error("fail"));
// retryCount=0 → 2000ms
await dispatch(createMessage({ retryCount: 0 }));
expect(mockIncrementRetry).toHaveBeenLastCalledWith(
"evt-1",
"fail",
2000,
);
// retryCount=1 → 4000ms
await dispatch(createMessage({ retryCount: 1 }));
expect(mockIncrementRetry).toHaveBeenLastCalledWith(
"evt-1",
"fail",
4000,
);
});
});
// ----------------------------------------------------------
// 完整 poll → dispatch → markPublished 流程
// ----------------------------------------------------------
describe("完整流程", () => {
it("poll → dispatch 多条 → 全部 markPublished", async () => {
const messages = [
createMessage({ eventId: "evt-1", eventType: "notification.sent" }),
createMessage({ eventId: "evt-2", eventType: "notification.read" }),
createMessage({
eventId: "evt-3",
eventType: "notification.recalled",
}),
];
mockFindPending.mockResolvedValue(messages);
await poll();
expect(mockProducer.send).toHaveBeenCalledTimes(3);
expect(mockMarkPublished).toHaveBeenCalledTimes(3);
// 验证每条消息发送到正确 topic
const topics = mockProducer.send.mock.calls.map(
(call) => (call[0] as { topic: string }).topic,
);
expect(topics).toEqual([
"edu.notification.sent",
"edu.notification.read",
"edu.notification.recalled",
]);
});
it("poll 中部分 dispatch 失败不应中断后续消息", async () => {
const messages = [
createMessage({ eventId: "evt-1" }),
createMessage({ eventId: "evt-2" }),
createMessage({ eventId: "evt-3" }),
];
mockFindPending.mockResolvedValue(messages);
// 第二条失败
mockProducer.send
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(new Error("Kafka error"))
.mockResolvedValueOnce({} as never);
await poll();
// 第一条和第三条成功 markPublished第二条 incrementRetry
expect(mockMarkPublished).toHaveBeenCalledTimes(2);
expect(mockMarkPublished).toHaveBeenCalledWith("evt-1");
expect(mockMarkPublished).toHaveBeenCalledWith("evt-3");
expect(mockIncrementRetry).toHaveBeenCalledTimes(1);
expect(mockIncrementRetry).toHaveBeenCalledWith(
"evt-2",
"Kafka error",
2000,
);
});
});
});

View File

@@ -0,0 +1,12 @@
/**
* Vitest 全局 setup —— 在所有测试文件之前设置环境变量。
*
* env.ts 在模块加载时调用 loadEnv() 解析 process.env
* 需要 DATABASE_URL必填。此处设置测试用值避免模块加载报错。
*/
process.env.DATABASE_URL = "mysql://test:test@localhost:3306/msg_test";
process.env.NODE_ENV = "test";
process.env.LOG_LEVEL = "error";
process.env.KAFKA_BROKERS = "localhost:9092";
process.env.KAFKA_CLIENT_ID = "msg-service-test";
process.env.KAFKA_CONSUMER_GROUP_ID = "msg-service-test-group";

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["test/unit/**/*.spec.ts"],
setupFiles: ["test/unit/setup.ts"],
},
});