From fb23c5234e30bdbe4564d0d31464640e131d777f Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:57:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(msg):=20announcements=20=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=20+=20sendBatch=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20+=20=E6=9D=83=E9=99=90=E6=89=A9=E5=B1=95?= =?UTF-8?q?=20+=20nextstep=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/init-sql/02-all-services-schema.sql | 54 ++ services/msg/docs/nextstep.md | 469 +++++++++++ .../announcements/announcements.controller.ts | 144 ++++ .../src/announcements/announcements.dto.ts | 67 ++ .../src/announcements/announcements.module.ts | 10 + .../announcements/announcements.repository.ts | 151 ++++ .../src/announcements/announcements.schema.ts | 72 ++ .../announcements/announcements.service.ts | 123 +++ services/msg/src/app.module.ts | 8 +- .../msg/src/middleware/permission.guard.ts | 15 +- .../notifications/notifications.repository.ts | 27 +- .../notifications/notifications.service.ts | 135 ++- .../msg/test/docker-test-announcement.json | 1 + services/msg/test/docker-test-batch-idem.json | 1 + .../msg/test/docker-test-batch-mixed.json | 1 + services/msg/test/docker-test-batch-noid.json | 1 + services/msg/test/docker-test-batch.json | 1 + .../docker-test-markread-notification.json | 1 + services/msg/test/docker-test-markread.json | 1 + .../msg/test/docker-test-notification.json | 1 + .../msg/test/docker-test-preferences.json | 1 + .../unit/channel-dispatcher.service.spec.ts | 358 ++++++++ .../msg/test/unit/idempotency.guard.spec.ts | 260 ++++++ .../unit/notifications.repository.spec.ts | 473 +++++++++++ .../test/unit/notifications.service.spec.ts | 796 ++++++++++++++++++ .../msg/test/unit/outbox.publisher.spec.ts | 458 ++++++++++ services/msg/test/unit/setup.ts | 12 + services/msg/vitest.config.ts | 10 + 28 files changed, 3644 insertions(+), 7 deletions(-) create mode 100644 services/msg/docs/nextstep.md create mode 100644 services/msg/src/announcements/announcements.controller.ts create mode 100644 services/msg/src/announcements/announcements.dto.ts create mode 100644 services/msg/src/announcements/announcements.module.ts create mode 100644 services/msg/src/announcements/announcements.repository.ts create mode 100644 services/msg/src/announcements/announcements.schema.ts create mode 100644 services/msg/src/announcements/announcements.service.ts create mode 100644 services/msg/test/docker-test-announcement.json create mode 100644 services/msg/test/docker-test-batch-idem.json create mode 100644 services/msg/test/docker-test-batch-mixed.json create mode 100644 services/msg/test/docker-test-batch-noid.json create mode 100644 services/msg/test/docker-test-batch.json create mode 100644 services/msg/test/docker-test-markread-notification.json create mode 100644 services/msg/test/docker-test-markread.json create mode 100644 services/msg/test/docker-test-notification.json create mode 100644 services/msg/test/docker-test-preferences.json create mode 100644 services/msg/test/unit/channel-dispatcher.service.spec.ts create mode 100644 services/msg/test/unit/idempotency.guard.spec.ts create mode 100644 services/msg/test/unit/notifications.repository.spec.ts create mode 100644 services/msg/test/unit/notifications.service.spec.ts create mode 100644 services/msg/test/unit/outbox.publisher.spec.ts create mode 100644 services/msg/test/unit/setup.ts create mode 100644 services/msg/vitest.config.ts diff --git a/infra/init-sql/02-all-services-schema.sql b/infra/init-sql/02-all-services-schema.sql index bf408db..65a998e 100644 --- a/infra/init-sql/02-all-services-schema.sql +++ b/infra/init-sql/02-all-services-schema.sql @@ -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; diff --git a/services/msg/docs/nextstep.md b/services/msg/docs/nextstep.md new file mode 100644 index 0000000..240758f --- /dev/null +++ b/services/msg/docs/nextstep.md @@ -0,0 +1,469 @@ +# msg 模块 Next Steps(上下游依赖) + +> 维护者:ai10(msg) +> 最后更新: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 RPC(5 Notification + 2 Preference + 6 Template)+ REST 双协议 | +| ARB-008 裁决落地 | ✅ 完成 | RPC 17→13,4 RPC 降级 REST only,topic 改为 `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/wechat)Promise.allSettled | +| /readyz 6 依赖检查 | ✅ 完成 | DB/ES/Redis/KafkaProducer/KafkaConsumer/PushGateway | +| 单元测试 | ✅ 完成 | 101 tests / 5 spec files,覆盖率 ≥ 80% | +| Announcements 公告 | ✅ 完成 | 新增 REST API(schema+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-bff(ai03) + +**依赖内容**: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-bff(ai04) + +**依赖内容**: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-bff(ai05) + +**依赖内容**: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-gateway(ai09) + +**依赖内容**: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-gateway(ai01) + +**依赖内容**: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 前端 portal(ai13/ai14/ai15/ai16) + +**间接依赖**:前端通过各自 BFF 聚合访问 msg,无直接 gRPC/REST 调用。 + +- teacher-portal(ai13):通过 teacher-bff GraphQL → msg gRPC/REST +- student-portal(ai14):通过 student-bff GraphQL → msg gRPC/REST +- parent-portal(ai15):通过 parent-bff GraphQL → msg gRPC +- admin-portal(ai16):通过 teacher-bff admin GraphQL → msg REST(公告管理) + +--- + +## 3. 上游依赖(msg ← 上游模块需要提供什么) + +### 3.1 iam(ai06)— 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-edu(ai07)— 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-ana(ai08)— 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-gateway(ai09)— 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 schema(msg_announcements + msg_announcement_reads 表) +- `src/announcements/announcements.repository.ts` — 数据访问层 +- `src/announcements/announcements.service.ts` — 业务逻辑 +- `src/announcements/announcements.controller.ts` — REST API(8 端点) +- `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 条 items(1 已存在 + 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 OK(pushGateway 软失败,预期) | +| 公告创建 | POST /announcements | ✅ 返回 id,status=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 | ✅ 自动生成 groupId(cuid2) | +| 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 个公告 mutation(createAnnouncement/publishAnnouncement/archiveAnnouncement/toggleAnnouncementPin),需 teacher-bff 接入 msg REST | + +--- + +## 7. msg 模块工作完成总结 + +### 7.1 已完成的全部工作 + +1. **P5 通知中台核心**:13 gRPC RPC + 10 REST endpoints(notifications)+ 2 REST endpoints(preferences)+ 6 REST endpoints(templates) +2. **ARB-008 裁决落地**:RPC 17→13,4 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 endpoints(schema+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(端口 50056,13 RPC)**: + +- NotificationService: SendNotification, ListNotifications, MarkAsRead, SearchNotifications, RecallNotification +- NotificationPreferenceService: GetPreferences, UpdatePreferences +- NotificationTemplateService: CreateTemplate, GetTemplate, ListTemplates, UpdateTemplate, DeleteTemplate, RenderTemplate + +**REST(端口 3007)**: + +- /notifications/*(10 endpoints) +- /preferences/user/:userId(2 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 中记录审计结果。 diff --git a/services/msg/src/announcements/announcements.controller.ts b/services/msg/src/announcements/announcements.controller.ts new file mode 100644 index 0000000..c268716 --- /dev/null +++ b/services/msg/src/announcements/announcements.controller.ts @@ -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 header(auth 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 }; + } +} diff --git a/services/msg/src/announcements/announcements.dto.ts b/services/msg/src/announcements/announcements.dto.ts new file mode 100644 index 0000000..ded67c5 --- /dev/null +++ b/services/msg/src/announcements/announcements.dto.ts @@ -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; + +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; + +// ============================================================ +// 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; + +// ============================================================ +// Mark as Read +// ============================================================ + +export const markAnnouncementReadSchema = z.object({ + userId: z.string().min(1).max(32), +}); + +export type MarkAnnouncementReadDto = z.infer< + typeof markAnnouncementReadSchema +>; diff --git a/services/msg/src/announcements/announcements.module.ts b/services/msg/src/announcements/announcements.module.ts new file mode 100644 index 0000000..f251f46 --- /dev/null +++ b/services/msg/src/announcements/announcements.module.ts @@ -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 {} diff --git a/services/msg/src/announcements/announcements.repository.ts b/services/msg/src/announcements/announcements.repository.ts new file mode 100644 index 0000000..68d156d --- /dev/null +++ b/services/msg/src/announcements/announcements.repository.ts @@ -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 { + const db = getDb(); + await db.insert(announcements).values(row); + return row as Announcement; +} + +export async function findById(id: string): Promise { + 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, +): Promise { + 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 { + const db = getDb(); + await db.delete(announcements).where(eq(announcements.id, id)); +} + +// ============================================================ +// 已读记录 +// ============================================================ + +export async function markAsRead( + announcementId: string, + userId: string, +): Promise { + 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 { + 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 { + const db = getDb(); + const [row] = await db + .select({ value: count() }) + .from(announcementReads) + .where(eq(announcementReads.announcementId, announcementId)); + return row?.value ?? 0; +} diff --git a/services/msg/src/announcements/announcements.schema.ts b/services/msg/src/announcements/announcements.schema.ts new file mode 100644 index 0000000..c1b2766 --- /dev/null +++ b/services/msg/src/announcements/announcements.schema.ts @@ -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(), + isPinned: boolean("is_pinned").notNull().default(false), + authorId: varchar("author_id", { length: 32 }).notNull(), + targetAudience: varchar("target_audience", { length: 32 }) + .notNull() + .default("all") + .$type(), + metadata: json("metadata").$type | 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; diff --git a/services/msg/src/announcements/announcements.service.ts b/services/msg/src/announcements/announcements.service.ts new file mode 100644 index 0000000..d91696b --- /dev/null +++ b/services/msg/src/announcements/announcements.service.ts @@ -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 { + 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 { + const item = await repo.findById(id); + if (!item) { + throw new NotFoundError("Announcement", id); + } + return item; + } + + async update(id: string, dto: UpdateAnnouncementDto): Promise { + const existing = await repo.findById(id); + if (!existing) { + throw new NotFoundError("Announcement", id); + } + const patch: Record = {}; + 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 { + const existing = await repo.findById(id); + if (!existing) { + throw new NotFoundError("Announcement", id); + } + await repo.deleteAnnouncement(id); + } + + async publish(id: string): Promise { + 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 { + 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 { + 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 { + // 幂等:repo 内部检查 + await repo.markAsRead(announcementId, userId); + } + + async isReadByUser(announcementId: string, userId: string): Promise { + return await repo.isReadByUser(announcementId, userId); + } +} diff --git a/services/msg/src/app.module.ts b/services/msg/src/app.module.ts index 15f7005..d00846f 100644 --- a/services/msg/src/app.module.ts +++ b/services/msg/src/app.module.ts @@ -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 服务根模块。 * * 仲裁依据: - * - M1:gRPC 50056 启用(GrpcModule 注册 3 controller 共 17 RPC) + * - M1:gRPC 50056 启用(GrpcModule 注册 3 controller 共 13 RPC,ARB-008 裁剪后) * - HTTP REST + gRPC 双协议入口,共享同一套 Service 单例 * - KafkaConsumerService 消费 12 类事件触发通知 + * - AnnouncementsModule 提供公告广播 + 每用户已读跟踪 * * 模块依赖图: * AppModule * ├─ NotificationsModule(REST + Service) * ├─ PreferencesModule(REST + Service) * ├─ TemplatesModule(REST + Service) - * ├─ GrpcModule(gRPC controllers,imports 上述 3 模块获取 Service) + * ├─ AnnouncementsModule(REST + Service,公告广播) + * ├─ GrpcModule(gRPC controllers,imports 上述模块获取 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, ], diff --git a/services/msg/src/middleware/permission.guard.ts b/services/msg/src/middleware/permission.guard.ts index 799fd2d..b4d0355 100644 --- a/services/msg/src/middleware/permission.guard.ts +++ b/services/msg/src/middleware/permission.guard.ts @@ -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 = { 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() diff --git a/services/msg/src/notifications/notifications.repository.ts b/services/msg/src/notifications/notifications.repository.ts index 6335090..8271bfc 100644 --- a/services/msg/src/notifications/notifications.repository.ts +++ b/services/msg/src/notifications/notifications.repository.ts @@ -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> { + 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: { diff --git a/services/msg/src/notifications/notifications.service.ts b/services/msg/src/notifications/notifications.service.ts index f6bd1b6..760b2c3 100644 --- a/services/msg/src/notifications/notifications.service.ts +++ b/services/msg/src/notifications/notifications.service.ts @@ -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(); + + // 过滤掉已存在的 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 { + 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 { - const result = await this.send({ ...item, groupId }); - ids.push(result.id); + // 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 }); diff --git a/services/msg/test/docker-test-announcement.json b/services/msg/test/docker-test-announcement.json new file mode 100644 index 0000000..6f7bb53 --- /dev/null +++ b/services/msg/test/docker-test-announcement.json @@ -0,0 +1 @@ +{"title":"测试公告","content":"这是测试内容","authorId":"sys-admin","targetAudience":"all"} diff --git a/services/msg/test/docker-test-batch-idem.json b/services/msg/test/docker-test-batch-idem.json new file mode 100644 index 0000000..3b1a314 --- /dev/null +++ b/services/msg/test/docker-test-batch-idem.json @@ -0,0 +1 @@ +{"items":[{"userId":"batch-idem-1","type":"system","title":"幂等1","content":"测试","eventId":"evt-batch-idem-001"}],"groupId":"batch-idem-group"} diff --git a/services/msg/test/docker-test-batch-mixed.json b/services/msg/test/docker-test-batch-mixed.json new file mode 100644 index 0000000..4c28476 --- /dev/null +++ b/services/msg/test/docker-test-batch-mixed.json @@ -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"} diff --git a/services/msg/test/docker-test-batch-noid.json b/services/msg/test/docker-test-batch-noid.json new file mode 100644 index 0000000..11b05d0 --- /dev/null +++ b/services/msg/test/docker-test-batch-noid.json @@ -0,0 +1 @@ +{"items":[{"userId":"batch-noid-1","type":"system","title":"无groupId","content":"测试"}]} diff --git a/services/msg/test/docker-test-batch.json b/services/msg/test/docker-test-batch.json new file mode 100644 index 0000000..0826100 --- /dev/null +++ b/services/msg/test/docker-test-batch.json @@ -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"} diff --git a/services/msg/test/docker-test-markread-notification.json b/services/msg/test/docker-test-markread-notification.json new file mode 100644 index 0000000..acf0015 --- /dev/null +++ b/services/msg/test/docker-test-markread-notification.json @@ -0,0 +1 @@ +{"userId":"student-001"} diff --git a/services/msg/test/docker-test-markread.json b/services/msg/test/docker-test-markread.json new file mode 100644 index 0000000..acf0015 --- /dev/null +++ b/services/msg/test/docker-test-markread.json @@ -0,0 +1 @@ +{"userId":"student-001"} diff --git a/services/msg/test/docker-test-notification.json b/services/msg/test/docker-test-notification.json new file mode 100644 index 0000000..ccbe71c --- /dev/null +++ b/services/msg/test/docker-test-notification.json @@ -0,0 +1 @@ +{"userId":"student-001","type":"system","title":"测试通知","content":"这是一条测试通知","senderId":"sys-admin"} diff --git a/services/msg/test/docker-test-preferences.json b/services/msg/test/docker-test-preferences.json new file mode 100644 index 0000000..9c7e806 --- /dev/null +++ b/services/msg/test/docker-test-preferences.json @@ -0,0 +1 @@ +{"userId":"student-001","preferences":[{"type":"system","channels":["in_app","email"],"enabled":true},{"type":"exam","channels":["in_app"],"enabled":true}]} diff --git a/services/msg/test/unit/channel-dispatcher.service.spec.ts b/services/msg/test/unit/channel-dispatcher.service.spec.ts new file mode 100644 index 0000000..19c5673 --- /dev/null +++ b/services/msg/test/unit/channel-dispatcher.service.spec.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/services/msg/test/unit/idempotency.guard.spec.ts b/services/msg/test/unit/idempotency.guard.spec.ts new file mode 100644 index 0000000..b471e40 --- /dev/null +++ b/services/msg/test/unit/idempotency.guard.spec.ts @@ -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"); + }); + }); +}); diff --git a/services/msg/test/unit/notifications.repository.spec.ts b/services/msg/test/unit/notifications.repository.spec.ts new file mode 100644 index 0000000..604c135 --- /dev/null +++ b/services/msg/test/unit/notifications.repository.spec.ts @@ -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 = { + 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); + }); + }); +}); diff --git a/services/msg/test/unit/notifications.service.spec.ts b/services/msg/test/unit/notifications.service.spec.ts new file mode 100644 index 0000000..636098c --- /dev/null +++ b/services/msg/test/unit/notifications.service.spec.ts @@ -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 = { + 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 = { + 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()); + 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"); + }); + }); +}); diff --git a/services/msg/test/unit/outbox.publisher.spec.ts b/services/msg/test/unit/outbox.publisher.spec.ts new file mode 100644 index 0000000..6dcabfe --- /dev/null +++ b/services/msg/test/unit/outbox.publisher.spec.ts @@ -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 = { + "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 { + 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 { + return (outboxPublisher as unknown as { poll: () => Promise }).poll(); +} + +function dispatch(message: OutboxEvent): Promise { + return ( + outboxPublisher as unknown as { + dispatch: (m: OutboxEvent) => Promise; + } + ).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); + + // 只有一个 interval,poll 应只被调用一次 + 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, + ); + }); + }); +}); diff --git a/services/msg/test/unit/setup.ts b/services/msg/test/unit/setup.ts new file mode 100644 index 0000000..9e79e28 --- /dev/null +++ b/services/msg/test/unit/setup.ts @@ -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"; diff --git a/services/msg/vitest.config.ts b/services/msg/vitest.config.ts new file mode 100644 index 0000000..448e730 --- /dev/null +++ b/services/msg/vitest.config.ts @@ -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"], + }, +});