chore(msg): merge msg full implementation into main

Merge feat/msg-ai10 with complete messaging service
This commit is contained in:
SpecialX
2026-07-10 19:13:21 +08:00
51 changed files with 5039 additions and 376 deletions

View File

@@ -2,13 +2,53 @@ syntax = "proto3";
package next_edu_cloud.msg.v1;
// Msg 服务契约P5 沟通通知中台)。
//
// 三服务:
// - NotificationService通知发送 / 列表 / 已读 / 检索 / 撤回
// - NotificationPreferenceService用户通知偏好
// - NotificationTemplateService通知模板 CRUD + 渲染
//
// 仲裁依据coord-final-decisions.md M1gRPC 50056 启用)+ msg_contract.md §1.1
// ============================================================
// NotificationService
// ============================================================
service NotificationService {
rpc SendNotification(SendNotificationRequest) returns (Notification);
rpc BatchSendNotification(BatchSendNotificationRequest) returns (BatchSendNotificationResponse);
rpc ListNotifications(ListNotificationsRequest) returns (ListNotificationsResponse);
rpc GetUnreadCount(GetUnreadCountRequest) returns (GetUnreadCountResponse);
rpc MarkAsRead(MarkAsReadRequest) returns (Empty);
rpc BatchMarkAsRead(BatchMarkAsReadRequest) returns (Empty);
rpc MarkAllAsRead(MarkAllAsReadRequest) returns (Empty);
rpc SearchNotifications(SearchNotificationsRequest) returns (SearchNotificationsResponse);
rpc RecallNotification(RecallNotificationRequest) returns (RecallNotificationResponse);
}
// ============================================================
// NotificationPreferenceService
// ============================================================
service NotificationPreferenceService {
rpc GetPreferences(GetPreferencesRequest) returns (GetPreferencesResponse);
rpc UpdatePreferences(UpdatePreferencesRequest) returns (Empty);
}
// ============================================================
// NotificationTemplateService
// ============================================================
service NotificationTemplateService {
rpc CreateTemplate(CreateTemplateRequest) returns (NotificationTemplate);
rpc GetTemplate(GetTemplateRequest) returns (NotificationTemplate);
rpc ListTemplates(ListTemplatesRequest) returns (ListTemplatesResponse);
rpc UpdateTemplate(UpdateTemplateRequest) returns (NotificationTemplate);
rpc DeleteTemplate(DeleteTemplateRequest) returns (Empty);
rpc RenderTemplate(RenderTemplateRequest) returns (RenderedNotification);
}
// ============================================================
// Notification 聚合
// ============================================================
message Notification {
string id = 1;
string user_id = 2;
@@ -18,31 +58,220 @@ message Notification {
string channel = 6;
bool is_read = 7;
int64 created_at = 8;
// 扩展字段P5
string status = 9;
string related_entity_type = 10;
string related_entity_id = 11;
string group_id = 12;
string sender_id = 13;
string template_id = 14;
string event_id = 15;
int64 read_at = 16;
int64 updated_at = 17;
map<string, string> metadata = 18;
}
// ============================================================
// SendNotification
// ============================================================
message SendNotificationRequest {
string user_id = 1;
string type = 2;
string title = 3;
string content = 4;
string channel = 5;
map<string, string> metadata = 6;
string related_entity_type = 7;
string related_entity_id = 8;
string group_id = 9;
string sender_id = 10;
string template_id = 11;
string event_id = 12;
}
message BatchSendNotificationRequest {
repeated SendNotificationRequest items = 1;
string group_id = 2;
}
message BatchSendNotificationResponse {
repeated string ids = 1;
repeated BatchSendFailure failed = 2;
}
message BatchSendFailure {
string user_id = 1;
string error = 2;
}
// ============================================================
// ListNotifications
// ============================================================
message ListNotificationsRequest {
string user_id = 1;
bool only_unread = 2;
string type = 3;
int32 page = 4;
int32 page_size = 5;
}
message ListNotificationsResponse {
repeated Notification notifications = 1;
int32 total = 2;
}
message MarkAsReadRequest { string id = 1; }
// ============================================================
// GetUnreadCount
// ============================================================
message GetUnreadCountRequest {
string user_id = 1;
}
message GetUnreadCountResponse {
int32 count = 1;
}
// ============================================================
// MarkAsRead
// ============================================================
message MarkAsReadRequest {
string id = 1;
string user_id = 2;
}
message BatchMarkAsReadRequest {
repeated string ids = 1;
string user_id = 2;
}
message MarkAllAsReadRequest {
string user_id = 1;
int64 before = 2;
}
// ============================================================
// SearchNotifications
// ============================================================
message SearchNotificationsRequest {
string user_id = 1;
string query = 2;
string type = 3;
int32 page = 4;
int32 page_size = 5;
}
message SearchNotificationsResponse {
repeated Notification notifications = 1;
int32 total = 2;
}
// ============================================================
// RecallNotification
// ============================================================
message RecallNotificationRequest {
string group_id = 1;
string reason = 2;
}
message RecallNotificationResponse {
int32 recalled_count = 1;
}
// ============================================================
// NotificationPreference
// ============================================================
message NotificationPreference {
string id = 1;
string user_id = 2;
string type = 3;
repeated string channels = 4;
int32 frequency_limit = 5;
string quiet_hours_start = 6;
string quiet_hours_end = 7;
string quiet_hours_timezone = 8;
bool enabled = 9;
int64 created_at = 10;
int64 updated_at = 11;
}
message GetPreferencesRequest {
string user_id = 1;
}
message GetPreferencesResponse {
repeated NotificationPreference preferences = 1;
}
message UpdatePreferencesRequest {
string user_id = 1;
repeated NotificationPreference preferences = 2;
}
// ============================================================
// NotificationTemplate
// ============================================================
message NotificationTemplate {
string id = 1;
string code = 2;
string type = 3;
string title_template = 4;
string content_template = 5;
repeated string default_channels = 6;
repeated string variables = 7;
string locale = 8;
string status = 9;
int64 created_at = 10;
int64 updated_at = 11;
}
message CreateTemplateRequest {
string code = 1;
string type = 2;
string title_template = 3;
string content_template = 4;
repeated string default_channels = 5;
repeated string variables = 6;
string locale = 7;
}
message GetTemplateRequest {
string id = 1;
}
message ListTemplatesRequest {
string type = 1;
string status = 2;
}
message ListTemplatesResponse {
repeated NotificationTemplate templates = 1;
}
message UpdateTemplateRequest {
string id = 1;
string title_template = 2;
string content_template = 3;
repeated string default_channels = 4;
repeated string variables = 5;
string status = 6;
}
message DeleteTemplateRequest {
string id = 1;
}
message RenderTemplateRequest {
string code = 1;
map<string, string> variables = 2;
string locale = 3;
}
message RenderedNotification {
string title = 1;
string content = 2;
}
// ============================================================
// Empty
// ============================================================
message Empty {}

79
pnpm-lock.yaml generated
View File

@@ -400,9 +400,6 @@ importers:
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/microservices':
specifier: ^10.4.0
version: 10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -649,15 +646,27 @@ importers:
services/msg:
dependencies:
'@edu/shared-ts':
specifier: workspace:*
version: link:../../packages/shared-ts
'@elastic/elasticsearch':
specifier: ^8.15.0
version: 8.19.2
'@grpc/grpc-js':
specifier: ^1.11.0
version: 1.14.4
'@grpc/proto-loader':
specifier: ^0.7.13
version: 0.7.15
'@nestjs/common':
specifier: ^10.4.0
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/microservices':
specifier: ^10.4.0
version: 10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -673,9 +682,18 @@ importers:
'@opentelemetry/sdk-node':
specifier: ^0.55.0
version: 0.55.0(@opentelemetry/api@1.9.1)
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.3.1
drizzle-orm:
specifier: ^0.31.0
version: 0.31.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.6.1)(@types/react@18.3.31)(better-sqlite3@11.10.0)(mysql2@3.22.6(@types/node@22.20.0))(react@18.3.1)
grpc-reflection-js:
specifier: ^0.3.0
version: 0.3.0(@grpc/grpc-js@1.14.4)
ioredis:
specifier: ^5.4.0
version: 5.11.1
kafkajs:
specifier: ^2.2.0
version: 2.2.4
@@ -694,9 +712,6 @@ importers:
rxjs:
specifier: ^7.8.0
version: 7.8.2
uuid:
specifier: ^10.0.0
version: 10.0.0
zod:
specifier: ^3.23.0
version: 3.25.76
@@ -710,9 +725,6 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.20.0
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
typescript:
specifier: ^5.6.0
version: 5.9.3
@@ -3678,6 +3690,9 @@ packages:
'@types/express@4.17.25':
resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}
'@types/google-protobuf@3.15.12':
resolution: {integrity: sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==}
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
@@ -3687,6 +3702,12 @@ packages:
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/lodash.set@4.3.9':
resolution: {integrity: sha512-KOxyNkZpbaggVmqbpr82N2tDVTx05/3/j0f50Es1prxrWB0XYf9p3QNxqcbWb7P1Q9wlvsUSlCFnwlPCIJ46PQ==}
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
'@types/memcached@2.2.10':
resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==}
@@ -5382,6 +5403,9 @@ packages:
resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
engines: {node: '>=14'}
google-protobuf@3.21.4:
resolution: {integrity: sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -5389,13 +5413,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
graphql@16.14.2:
resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
grpc-reflection-js@0.3.0:
resolution: {integrity: sha512-3lhTlQluPxVgbowCXA3tAZC3RJW+GSOUkguLNYl1QffYRiutUB3RDfPkQFTcrCFJgNiIIxx+iJkr8s3uSp3zWA==}
peerDependencies:
'@grpc/grpc-js': ^1.0.0
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
@@ -5916,6 +5937,9 @@ packages:
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash.set@4.3.2:
resolution: {integrity: sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg==}
lodash.snakecase@4.1.1:
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
@@ -10950,6 +10974,8 @@ snapshots:
'@types/qs': 6.15.1
'@types/serve-static': 1.15.10
'@types/google-protobuf@3.15.12': {}
'@types/http-errors@2.0.5': {}
'@types/json-schema@7.0.15': {}
@@ -10959,6 +10985,12 @@ snapshots:
'@types/ms': 2.1.0
'@types/node': 22.20.0
'@types/lodash.set@4.3.9':
dependencies:
'@types/lodash': 4.17.24
'@types/lodash@4.17.24': {}
'@types/memcached@2.2.10':
dependencies:
'@types/node': 22.20.0
@@ -12943,13 +12975,20 @@ snapshots:
google-logging-utils@0.0.2: {}
google-protobuf@3.21.4: {}
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
graphql@16.14.2: {}
has-bigints@1.1.0: {}
grpc-reflection-js@0.3.0(@grpc/grpc-js@1.14.4):
dependencies:
'@grpc/grpc-js': 1.14.4
'@types/google-protobuf': 3.15.12
'@types/lodash.set': 4.3.9
google-protobuf: 3.21.4
lodash.set: 4.3.2
protobufjs: 7.6.5
has-flag@4.0.0: {}
@@ -13501,6 +13540,8 @@ snapshots:
lodash.once@4.1.1: {}
lodash.set@4.3.2: {}
lodash.snakecase@4.1.1: {}
lodash.startcase@4.4.0: {}

View File

@@ -1,26 +1,188 @@
-- Msg 服务数据库初始化脚本
-- msg_notifications / msg_notification_preferences
-- ============================================================
-- Msg 服务数据库初始化脚本v1.0 完整实现)
-- 对齐 services/msg/src/notifications/notifications.schema.ts
-- services/msg/src/shared/outbox/outbox.schema.ts
-- 仲裁依据02-architecture-design.md §3.1 + G11cuid2 varchar(32)
-- ============================================================
CREATE TABLE IF NOT EXISTS msg_notifications (
id CHAR(36) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
type VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
channel VARCHAR(20) NOT NULL DEFAULT 'in_app',
is_read BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_notifications_user_id (user_id),
INDEX idx_notifications_is_read (is_read),
INDEX idx_notifications_created_at (created_at),
INDEX idx_notifications_user_read (user_id, is_read)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 数据库已由 docker-compose 创建msg_db此处仅建表
-- 若需切换数据库USE msg_db;
CREATE TABLE IF NOT EXISTS msg_notification_preferences (
user_id CHAR(36) NOT NULL PRIMARY KEY,
email_enabled BOOLEAN NOT NULL DEFAULT TRUE,
sms_enabled BOOLEAN NOT NULL DEFAULT FALSE,
push_enabled BOOLEAN NOT NULL DEFAULT TRUE,
in_app_enabled BOOLEAN NOT NULL DEFAULT TRUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ============================================================
-- 1. msg_notifications通知主表 · 扩展)
-- ============================================================
CREATE TABLE IF NOT EXISTS `msg_notifications` (
`id` VARCHAR(32) NOT NULL COMMENT 'cuid2 主键',
`user_id` VARCHAR(32) NOT NULL COMMENT '接收用户 ID',
`type` VARCHAR(32) NOT NULL COMMENT '通知类型: system/exam/homework/grade/attendance/mastery',
`title` VARCHAR(255) NOT NULL COMMENT '通知标题',
`content` TEXT NOT NULL COMMENT '通知正文',
`channel` VARCHAR(32) NOT NULL COMMENT '渠道: in_app/email/sms/push/wechat',
`is_read` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已读',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/sent/delivered/read/recalled/failed',
`metadata` JSON NULL COMMENT '扩展元数据',
`related_entity_type` VARCHAR(64) NULL COMMENT '关联实体类型(exam/homework/grade/attendance/mastery)',
`related_entity_id` VARCHAR(32) NULL COMMENT '关联实体 ID',
`group_id` VARCHAR(32) NULL COMMENT '群组 ID批量发送/撤回用)',
`sender_id` VARCHAR(32) NULL COMMENT '发送者 ID',
`template_id` VARCHAR(32) NULL COMMENT '使用的模板 ID',
`event_id` VARCHAR(64) NULL COMMENT '幂等键Kafka event_id 或自生成)',
`read_at` TIMESTAMP NULL COMMENT '已读时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_notifications_event_id` (`event_id`),
KEY `idx_notifications_user_id` (`user_id`),
KEY `idx_notifications_is_read` (`is_read`),
KEY `idx_notifications_created_at` (`created_at`),
KEY `idx_notifications_user_read` (`user_id`, `is_read`),
KEY `idx_notifications_group_id` (`group_id`),
KEY `idx_notifications_related` (`related_entity_type`, `related_entity_id`),
KEY `idx_notifications_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='msg 通知主表';
-- ============================================================
-- 2. msg_notification_preferences用户偏好 · 扩展)
-- ============================================================
CREATE TABLE IF NOT EXISTS `msg_notification_preferences` (
`id` VARCHAR(32) NOT NULL COMMENT 'cuid2 主键',
`user_id` VARCHAR(32) NOT NULL COMMENT '用户 ID',
`type` VARCHAR(32) NOT NULL COMMENT '通知类型',
`channels` JSON NOT NULL COMMENT '启用的渠道数组,如 ["in_app","email"]',
`frequency_limit` INT NULL COMMENT '频率限制(每小时最大通知数)',
`quiet_hours_start` VARCHAR(8) NULL COMMENT '免打扰开始(HH:MM)',
`quiet_hours_end` VARCHAR(8) NULL COMMENT '免打扰结束(HH:MM)',
`quiet_hours_timezone` VARCHAR(64) NULL DEFAULT 'Asia/Shanghai',
`enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用该类型偏好',
`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 `uk_preferences_user_type` (`user_id`, `type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='msg 用户通知偏好';
-- ============================================================
-- 3. msg_notification_templates通知模板
-- ============================================================
CREATE TABLE IF NOT EXISTS `msg_notification_templates` (
`id` VARCHAR(32) NOT NULL COMMENT 'cuid2 主键',
`code` VARCHAR(64) NOT NULL COMMENT '模板编码(业务可读)',
`type` VARCHAR(32) NOT NULL COMMENT '通知类型',
`title_template` VARCHAR(255) NOT NULL COMMENT '标题模板(含 {{var}})',
`content_template` TEXT NOT NULL COMMENT '正文模板(含 {{var}})',
`default_channels` JSON NOT NULL COMMENT '默认渠道数组',
`variables` JSON NOT NULL COMMENT '变量声明数组,如 ["userName","examTitle"]',
`locale` VARCHAR(16) NOT NULL DEFAULT 'zh-CN',
`status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '状态: draft/active/archived',
`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 `uk_templates_code` (`code`),
KEY `idx_templates_type` (`type`),
KEY `idx_templates_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='msg 通知模板';
-- ============================================================
-- 4. msg_notification_deliveries投递记录
-- ============================================================
CREATE TABLE IF NOT EXISTS `msg_notification_deliveries` (
`id` VARCHAR(32) NOT NULL COMMENT 'cuid2 主键',
`notification_id` VARCHAR(32) NOT NULL COMMENT '关联通知 ID',
`channel` VARCHAR(32) NOT NULL COMMENT '投递渠道',
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/sent/delivered/failed/retrying',
`external_id` VARCHAR(128) NULL COMMENT '外部服务返回的消息 ID',
`attempt_count` INT NOT NULL DEFAULT 0,
`max_retry` INT NOT NULL DEFAULT 3,
`last_error` TEXT NULL,
`next_retry_at` TIMESTAMP NULL,
`delivered_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`),
KEY `idx_deliveries_notification_id` (`notification_id`),
KEY `idx_deliveries_status` (`status`),
KEY `idx_deliveries_next_retry` (`next_retry_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='msg 通知投递记录';
-- ============================================================
-- 5. msg_outbox_events事务性 Outbox
-- ============================================================
CREATE TABLE IF NOT EXISTS `msg_outbox_events` (
`event_id` VARCHAR(64) NOT NULL COMMENT '事件 ID(Kafka 幂等键)',
`aggregate_type` VARCHAR(64) NOT NULL COMMENT '聚合类型: notification',
`aggregate_id` VARCHAR(32) NOT NULL COMMENT '聚合 ID(通知 ID)',
`event_type` VARCHAR(64) NOT NULL COMMENT '事件类型: notification.sent/read/recalled/failed',
`topic` VARCHAR(128) NOT NULL COMMENT '目标 Kafka topic',
`payload` JSON NOT NULL COMMENT '事件 payload',
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/published/failed',
`retry_count` INT NOT NULL DEFAULT 0,
`max_retry_count` INT NOT NULL DEFAULT 5,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`published_at` TIMESTAMP NULL,
`next_retry_at` TIMESTAMP NULL,
`last_error` TEXT NULL,
`metadata` JSON NULL,
PRIMARY KEY (`event_id`),
KEY `idx_outbox_status_next_retry` (`status`, `next_retry_at`),
KEY `idx_outbox_topic` (`topic`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='msg Outbox 事务性事件';
-- ============================================================
-- 6. processed_events消费幂等 · Redis 降级表)
-- ============================================================
CREATE TABLE IF NOT EXISTS `processed_events` (
`event_id` VARCHAR(64) NOT NULL COMMENT '事件 ID(幂等键)',
`topic` VARCHAR(128) NOT NULL COMMENT '来源 topic',
`processed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`event_id`),
KEY `idx_processed_topic` (`topic`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='msg 消费幂等记录';
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================
-- 初始化数据:默认通知模板(可选)
-- ============================================================
INSERT INTO `msg_notification_templates` (`id`, `code`, `type`, `title_template`, `content_template`, `default_channels`, `variables`, `locale`, `status`)
SELECT * FROM (SELECT
'tmpl_welcome_001' AS id,
'welcome' AS code,
'system' AS type,
'欢迎加入 Edu 云课堂' AS title_template,
'你好 {{userName}},欢迎加入 Edu 云课堂!开始你的学习之旅吧。' AS content_template,
JSON_ARRAY('in_app') AS default_channels,
JSON_ARRAY('userName') AS variables,
'zh-CN' AS locale,
'active' AS status
) AS tmp
WHERE NOT EXISTS (SELECT 1 FROM `msg_notification_templates` WHERE `code` = 'welcome');
INSERT INTO `msg_notification_templates` (`id`, `code`, `type`, `title_template`, `content_template`, `default_channels`, `variables`, `locale`, `status`)
SELECT * FROM (SELECT
'tmpl_exam_published_001' AS id,
'exam_published' AS code,
'exam' AS type,
'新考试通知' AS title_template,
'「{{className}}」班级发布了新考试:{{examTitle}}' AS content_template,
JSON_ARRAY('in_app') AS default_channels,
JSON_ARRAY('className', 'examTitle') AS variables,
'zh-CN' AS locale,
'active' AS status
) AS tmp
WHERE NOT EXISTS (SELECT 1 FROM `msg_notification_templates` WHERE `code` = 'exam_published');
INSERT INTO `msg_notification_templates` (`id`, `code`, `type`, `title_template`, `content_template`, `default_channels`, `variables`, `locale`, `status`)
SELECT * FROM (SELECT
'tmpl_grade_recorded_001' AS id,
'grade_recorded' AS code,
'grade' AS type,
'成绩录入通知' AS title_template,
'你的{{subject}}成绩已录入{{score}}' AS content_template,
JSON_ARRAY('in_app') AS default_channels,
JSON_ARRAY('subject', 'score') AS variables,
'zh-CN' AS locale,
'active' AS status
) AS tmp
WHERE NOT EXISTS (SELECT 1 FROM `msg_notification_templates` WHERE `code` = 'grade_recorded');

View File

@@ -1,52 +1,381 @@
# Msg 消息通知服务
# Msg 沟通通知中台
> 版本:0.1P5 骨架
> 端口3007
> 版本:1.0P5 完整实现
> HTTP 端口3007 / gRPC 端口50056
> 设计文档:[02-architecture-design.md](./docs/02-architecture-design.md)
> 契约文档:[msg_contract.md](../../docs/architecture/issues/contracts/msg_contract.md)
## 职责
消息通知限界上下文,管理通知的多渠道分发(站内信、邮件、短信、推送)。
消费 Kafka 事件,写入 MySQL + Elasticsearch调用 Push Gateway 实时推送。
沟通通知限界上下文P5承担
1. **多渠道通知分发**:站内信 / 邮件 / 短信 / 推送 / 微信(策略模式 `NotificationChannel` 抽象)
2. **事件驱动 fan-out**:消费 iam / core-edu / data-ana 共 12 类 Kafka 事件触发通知
3. **CQRS 读写分离**MySQL 写模型 + Elasticsearch 全文检索 + Redis 位图(已读状态降级)
4. **Push Gateway 解耦**:通过 HTTP POST `/internal/push` 委托 push-gateway 推送实时通知coord M4 仲裁豁免 gRPC
5. **Outbox 事务性事件**:通知发送 / 已读 / 撤回 / 失败 4 类事件经 Outbox 模式投递到 Kafka
6. **幂等去重三层防线**Redis SETNX + processed_events 表 + event_id UNIQUE INDEX
## 技术栈
- NestJS 10 + TypeScript 5
- Drizzle ORM + MySQL 8
- Elasticsearch 8全文检索
- Kafka事件消费骨架
- pino + prom-client + OpenTelemetry
| 层 | 技术 | 版本 |
| --------- | ----------------------------------------------------------- | ------ |
| 运行时 | Node.js | 22 |
| 框架 | NestJSHTTP + gRPC 双协议 microservice | 10 |
| 语言 | TypeScriptESM 模式) | 5 |
| ORM | Drizzle ORM函数式 `getDb()` | latest |
| 写模型 | MySQL | 8.0 |
| 读模型 | ElasticsearchsafeIndex/safeSearch 降级) | 8 |
| 缓存/位图 | Redis幂等去重 + 已读位图) | 7 |
| 消息 | KafkaKafkaJSproducer + consumer | 3.x |
| 可观测性 | pino + prom-client + OpenTelemetry SDK | — |
| ID | cuid2varchar(32) | — |
| 契约 | protobuf`packages/shared-proto/proto/msg.proto`17 RPC | buf v2 |
## 架构分层
```mermaid
flowchart TB
subgraph Gateway
AG[api-gateway]
end
subgraph BFF
TBFF[teacher-bff]
end
subgraph MsgService[msg service · HTTP:3007 + gRPC:50056]
HTTP[HTTP Controller]
GRPC[gRPC Controller · 3 Service 17 RPC]
SVC[Application Service · notifications/preferences/templates]
DISP[ChannelDispatcher · 策略模式]
CH[InApp/Email/SMS/Push/WeChat Channels]
KAFKA_C[KafkaConsumer · 12 类事件]
OUTBOX[OutboxPublisher · 4 类事件]
REPO[Repository · Drizzle]
ES[Elasticsearch 读模型]
REDIS[Redis 幂等/位图]
PUSH[Push Gateway Client · HTTP]
end
AG --> HTTP
TBFF --> GRPC
KAFKA_C --> SVC
SVC --> DISP
DISP --> CH
CH --> PUSH
SVC --> REPO
SVC --> ES
SVC --> REDIS
SVC --> OUTBOX
OUTBOX -->|publish| KT[(Kafka · edu.notification.*)]
KAFKA_K[(Kafka · edu.identity/teaching/insight.*)] --> KAFKA_C
```
## 模块结构
```
services/msg/src/
├─ notifications/ # 通知限界上下文
│ ├─ notifications.controller.ts # HTTP REST10 端点)
│ ├─ notifications.service.ts # Application Service
│ ├─ notifications.repository.ts # Drizzle 数据访问
│ ├─ notifications.schema.ts # Drizzle 表定义4 张表)
│ └─ notifications.dto.ts # Zod 验证 + DTO
├─ preferences/ # 用户偏好限界上下文
│ ├─ preferences.controller.ts # HTTP REST2 端点)
│ ├─ preferences.service.ts
│ ├─ preferences.repository.ts
│ └─ preferences.dto.ts
├─ templates/ # 通知模板限界上下文
│ ├─ templates.controller.ts # HTTP REST6 端点)
│ ├─ templates.service.ts
│ ├─ templates.repository.ts
│ └─ templates.dto.ts
├─ channels/ # 多渠道分发(策略模式)
│ ├─ channel.types.ts # NotificationChannelStrategy 接口
│ ├─ channel-dispatcher.service.ts # ChannelDispatcher 编排
│ ├─ in-app.channel.ts
│ ├─ email.channel.ts
│ ├─ sms.channel.ts
│ ├─ push.channel.ts # 走 PushGatewayClient
│ └─ wechat.channel.ts
├─ grpc/ # gRPC 入口3 Service 17 RPC
│ ├─ grpc.module.ts
│ ├─ notifications.grpc.controller.ts # 9 RPC
│ ├─ preferences.grpc.controller.ts # 2 RPC
│ └─ templates.grpc.controller.ts # 6 RPC
├─ config/ # 配置
│ ├─ env.ts # @t3-oss/env-nextjs + Zod
│ ├─ database.ts # getDb() 函数式
│ ├─ elasticsearch.ts # safeIndex/safeSearch 降级
│ └─ grpc.ts # gRPC microservice options
├─ middleware/
│ ├─ auth.middleware.ts # JWT 校验 + userId/roles 注入
│ └─ permission.guard.ts # @RequirePermission 3 个权限点
├─ shared/
│ ├─ errors/
│ │ ├─ application-error.ts # 13 个错误码
│ │ └─ global-error.filter.ts # GlobalErrorFilter
│ ├─ health/
│ │ └─ health.controller.ts # /healthz + /readyz5 项依赖)
│ ├─ kafka/
│ │ ├─ kafka.client.ts # producer + consumer + isKafkaHealthy
│ │ ├─ kafka.consumer.ts # 12 类事件路由
│ │ └─ topic-map.ts # PRODUCER_TOPIC_MAP + CONSUMER_TOPICS
│ ├─ lifecycle/
│ │ └─ lifecycle.service.ts # DB/ES/Redis/Kafka/Outbox 生命周期
│ ├─ observability/
│ │ ├─ logger.ts # pino
│ │ ├─ metrics.ts # 15 个业务指标
│ │ └─ tracer.ts # OpenTelemetry SDK
│ ├─ outbox/
│ │ ├─ outbox.publisher.ts # 轮询 pending 投递 Kafka
│ │ ├─ outbox.repository.ts # findPending/markPublished/incrementRetry
│ │ ├─ outbox.service.ts # 事务内写入 outbox
│ │ └─ outbox.schema.ts # msg_outbox_events + processed_events
│ ├─ push/
│ │ └─ push-gateway.client.ts # HTTP POST /internal/pushM4 仲裁)
│ └─ redis/
│ ├─ redis.client.ts # getRedis/checkRedisConnection/closeRedis
│ └─ idempotency.guard.ts # checkAndMarkRedis SETNX + DB 降级)
├─ app.module.ts # 根 Module
└─ main.ts # HTTP + gRPC 双协议启动
```
## API 清单
### HTTP REST18 端点)
#### Notifications10 端点)
| 方法 | 路径 | 权限点 | 说明 |
| ------ | ---------------------------------------- | ----------------------- | ----------------- |
| POST | /notifications/send | MSG_NOTIFICATION_SEND | 单条发送 |
| POST | /notifications/batch | MSG_NOTIFICATION_SEND | 批量发送 |
| GET | /notifications/user/:userId | MSG_NOTIFICATION_READ | 列表(分页+过滤) |
| GET | /notifications/user/:userId/unread-count | MSG_NOTIFICATION_READ | 未读数 |
| PUT | /notifications/:id/read | MSG_NOTIFICATION_READ | 标记已读 |
| PUT | /notifications/batch/read | MSG_NOTIFICATION_READ | 批量已读 |
| PUT | /notifications/read-all | MSG_NOTIFICATION_READ | 全部已读 |
| GET | /notifications/search | MSG_NOTIFICATION_READ | ES 全文检索 |
| POST | /notifications/recall | MSG_NOTIFICATION_MANAGE | 撤回广播 |
| DELETE | /notifications/:id | MSG_NOTIFICATION_MANAGE | 删除 |
#### Preferences2 端点)
| 方法 | 路径 | 权限点 | 说明 |
| ---- | ------------------------- | ----------------------- | -------- |
| GET | /preferences/user/:userId | MSG_NOTIFICATION_READ | 查询偏好 |
| PUT | /preferences/user/:userId | MSG_NOTIFICATION_MANAGE | 更新偏好 |
#### Templates6 端点)
| 方法 | 路径 | 权限点 | 说明 |
| ------ | ----------------- | ----------------------- | -------- |
| GET | /templates | MSG_NOTIFICATION_MANAGE | 列表 |
| POST | /templates | MSG_NOTIFICATION_MANAGE | 创建 |
| GET | /templates/:id | MSG_NOTIFICATION_MANAGE | 查询 |
| PUT | /templates/:id | MSG_NOTIFICATION_MANAGE | 更新 |
| DELETE | /templates/:id | MSG_NOTIFICATION_MANAGE | 删除 |
| POST | /templates/render | MSG_NOTIFICATION_SEND | 渲染模板 |
### gRPC3 Service / 17 RPC
契约源:[`packages/shared-proto/proto/msg.proto`](../../packages/shared-proto/proto/msg.proto)
包名:`next_edu_cloud.msg.v1`
#### NotificationService9 RPC
| RPC | 权限点 | 说明 |
| --------------------- | ----------------------- | -------- |
| SendNotification | MSG_NOTIFICATION_SEND | 单条发送 |
| BatchSendNotification | MSG_NOTIFICATION_SEND | 批量发送 |
| ListNotifications | MSG_NOTIFICATION_READ | 列表 |
| GetUnreadCount | MSG_NOTIFICATION_READ | 未读数 |
| MarkAsRead | MSG_NOTIFICATION_READ | 标记已读 |
| BatchMarkAsRead | MSG_NOTIFICATION_READ | 批量已读 |
| MarkAllAsRead | MSG_NOTIFICATION_READ | 全部已读 |
| SearchNotifications | MSG_NOTIFICATION_READ | ES 检索 |
| RecallNotification | MSG_NOTIFICATION_MANAGE | 撤回 |
#### NotificationPreferenceService2 RPC
| RPC | 权限点 | 说明 |
| ----------------- | ----------------------- | -------- |
| GetPreferences | MSG_NOTIFICATION_READ | 查询偏好 |
| UpdatePreferences | MSG_NOTIFICATION_MANAGE | 更新偏好 |
#### NotificationTemplateService6 RPC
| RPC | 权限点 | 说明 |
| -------------- | ----------------------- | ---- |
| CreateTemplate | MSG_NOTIFICATION_MANAGE | 创建 |
| GetTemplate | MSG_NOTIFICATION_MANAGE | 查询 |
| ListTemplates | MSG_NOTIFICATION_MANAGE | 列表 |
| UpdateTemplate | MSG_NOTIFICATION_MANAGE | 更新 |
| DeleteTemplate | MSG_NOTIFICATION_MANAGE | 删除 |
| RenderTemplate | MSG_NOTIFICATION_SEND | 渲染 |
## Kafka 事件流
### 消费12 类事件)
| Topic | 来源 | 触发通知 |
| --------------------------------- | -------- | --------------------------------------- |
| edu.identity.user.created | iam | 欢迎通知 |
| edu.identity.user.updated | iam | (幂等标记,无通知) |
| edu.identity.user.deleted | iam | (幂等标记,无通知) |
| edu.identity.user.role_changed | iam | 角色变更通知 |
| edu.identity.role.created | iam | (幂等标记,无通知) |
| edu.identity.role.updated | iam | 权限变更通知fan-out affectedUserIds |
| edu.teaching.exam.published | core-edu | 考试通知fan-out studentIds |
| edu.teaching.assignment.submitted | core-edu | 作业提交通知(教师) |
| edu.teaching.assignment.graded | core-edu | 作业批改通知(学生) |
| edu.teaching.grade.recorded | core-edu | 成绩录入通知 |
| edu.teaching.attendance.recorded | core-edu | 出勤异常通知(家长) |
| edu.insight.mastery.updated | data-ana | 学情预警通知 |
### 发布4 类事件 · Outbox 模式)
| 事件类型 | Topic | 触发条件 |
| --------------------- | ------------------------- | ------------ |
| notification.sent | edu.notification.sent | 通知发送成功 |
| notification.read | edu.notification.read | 通知标记已读 |
| notification.recalled | edu.notification.recalled | 通知撤回 |
| notification.failed | edu.notification.failed | 通知发送失败 |
### 幂等去重
- **Redis SETNX**(首选):`SETNX msg:event:{event_id} 1 EX 86400`
- **DB 降级**`processed_events``event_id` UNIQUE INDEX
- **三层防线**Redis → DB → 业务层 event_id 检查
## 数据库表
建表脚本:[`scripts/msg-init.sql`](../../scripts/msg-init.sql)
| 表 | 用途 | 关键索引 |
| ---------------------------- | ----------- | -------------------------------------------- |
| msg_notifications | 通知主表 | event_id UNIQUE / user_id+is_read / group_id |
| msg_notification_preferences | 用户偏好 | user_id+type UNIQUE |
| msg_notification_templates | 通知模板 | code UNIQUE |
| msg_notification_deliveries | 投递记录 | notification_id / status / next_retry_at |
| msg_outbox_events | Outbox 事件 | status+next_retry_at / topic |
| processed_events | 消费幂等 | event_id PK |
## 健康检查
| 端点 | 用途 | 检查项 |
| ------------ | --------- | -------------------------------------------------------------------- |
| GET /healthz | liveness | 仅进程状态 |
| GET /readyz | readiness | DB关键/ ES可选/ Redis可选/ Kafka / PushGateway软失败 |
`/readyz` 返回结构:
```json
{
"status": "ok" | "degraded",
"service": "msg",
"timestamp": "2026-07-10T...",
"checks": {
"database": { "status": "ok" },
"elasticsearch": { "status": "ok" },
"redis": { "status": "ok" },
"kafka": { "status": "ok" },
"pushGateway": { "status": "skipped" }
}
}
```
## 可观测性
### Metrics/metrics 端点 · 15 个指标)
| 指标 | 类型 | 标签 |
| -------------------------------------- | --------- | ------------------------ |
| msg_http_requests_total | Counter | method/route/status_code |
| msg_http_request_duration_seconds | Histogram | method/route |
| msg_grpc_requests_total | Counter | rpc_method/status |
| msg_notification_sent_total | Counter | type/channel/status |
| msg_notification_send_duration_seconds | Histogram | type/channel |
| msg_notification_failed_total | Counter | type/channel/error_code |
| msg_notification_read_total | Counter | type |
| msg_channel_dispatch_duration_seconds | Histogram | channel |
| msg_kafka_consumer_lag | Gauge | topic |
| msg_kafka_consumer_processed_total | Counter | topic/status |
| msg_idempotent_duplicate_total | Counter | topic |
| msg_outbox_pending_count | Gauge | — |
| msg_push_gateway_call_total | Counter | status |
| msg_push_gateway_call_duration_seconds | Histogram | — |
| msg_unread_count | Gauge | user_id |
### Tracer
- OpenTelemetry SDK + auto-instrumentations
- 服务名:`msg`
- OTLP exporter`{OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`
## 错误码
| 错误码 | HTTP | 触发条件 |
| ---------------------------- | ---- | ------------------------- |
| MSG_VALIDATION_ERROR | 400 | Zod 校验失败 |
| MSG_NOT_FOUND | 404 | 通知/偏好/模板不存在 |
| MSG_PERMISSION_DENIED | 403 | 权限不足 / 跨用户访问 |
| MSG_CONFLICT | 409 | 重复发送 / 状态机非法转换 |
| MSG_BUSINESS_ERROR | 422 | 业务规则违反 |
| MSG_DATABASE_ERROR | 500 | Drizzle 操作异常 |
| MSG_INTERNAL_ERROR | 500 | 未知异常 |
| MSG_ES_UNAVAILABLE | 503 | ES 不可用且无降级路径 |
| MSG_REDIS_UNAVAILABLE | 503 | Redis 不可用且无降级路径 |
| MSG_PUSH_GATEWAY_UNAVAILABLE | 503 | Push Gateway 不可用 |
| MSG_TEMPLATE_NOT_FOUND | 404 | 模板不存在 |
| MSG_TEMPLATE_RENDER_ERROR | 422 | 模板渲染失败(变量缺失) |
| MSG_RATE_LIMIT_EXCEEDED | 429 | 通知频率超限 |
## 开发
```bash
# 安装依赖monorepo 根目录)
pnpm install
pnpm dev # http://localhost:3007
# 启动开发服务HTTP:3007 + gRPC:50056
pnpm --filter msg dev
# 构建产物
pnpm --filter msg build
# 启动生产服务
pnpm --filter msg start
```
## API
| 方法 | 路径 | 说明 |
| ---- | ------------------------ | ---------------- |
| POST | /notifications | 发送通知 |
| GET | /notifications | 查询用户通知列表 |
| POST | /notifications/:id/read | 标记已读 |
| GET | /notifications/search?q= | 全文检索通知 |
## 健康检查
| 端点 | 用途 | 鉴权 |
| -------------- | ------------------------------------------------- | ---- |
| `GET /healthz` | 存活探针liveness仅返回进程状态不检查依赖 | 无 |
| `GET /readyz` | 就绪探针readiness检查 DB 连接,失败返回 503 | 无 |
实现见 `src/shared/health/health.controller.ts`5 个 NestJS 服务iam/core-edu/content/msg/classes一致。
## 环境变量
| 变量 | 默认值 | 说明 |
| ------------- | --------------------- | ------------------ |
| PORT | 3007 | 服务端口 |
| DATABASE_URL | - | MySQL 连接串 |
| KAFKA_BROKERS | localhost:9092 | Kafka broker 列表 |
| ES_URL | http://localhost:9200 | Elasticsearch 地址 |
| JWT_SECRET | - | JWT 密钥 |
| 变量 | 默认值 | 必填 | 说明 |
| --------------------------- | ----------------- | ---- | -------------------------------------------- |
| PORT | 3007 | 否 | HTTP 端口 |
| GRPC_PORT | 50056 | 否 | gRPC 端口 |
| DATABASE_URL | — | 是 | MySQL 连接串 |
| REDIS_URL | — | 否 | Redis 连接串(未配置降级到 DB 去重) |
| JWT_SECRET | — | 否 | JWT 密钥Gateway 已校验,可选) |
| JWT_ISSUER | next-edu-cloud | 否 | JWT 签发者 |
| KAFKA_BROKERS | localhost:9092 | 否 | Kafka broker 列表 |
| KAFKA_CLIENT_ID | msg-service | 否 | Kafka client ID |
| KAFKA_CONSUMER_GROUP_ID | msg-service-group | 否 | Kafka consumer group |
| ES_URL | — | 否 | Elasticsearch 地址(未配置则降级到 DB 查询) |
| PUSH_GATEWAY_URL | — | 否 | Push Gateway 地址M4 仲裁 HTTP |
| PUSH_INTERNAL_TOKEN | — | 否 | Push Gateway 内部鉴权 token |
| OTEL_EXPORTER_OTLP_ENDPOINT | — | 否 | OpenTelemetry OTLP endpoint |
| LOG_LEVEL | info | 否 | pino 日志级别 |
| NODE_ENV | development | 否 | 运行环境 |
| DEV_MODE | false | 否 | 开发模式(跳过权限校验) |
## 仲裁依据
- **M4**Push Gateway 走 HTTP POST `/internal/push`,豁免 gRPC鉴权头 `X-Internal-Key`
- **M5**:消费 topic 用 `edu.teaching.*` / `edu.identity.*` / `edu.insight.*`
- **G2**/readyz 多依赖检查DB/ES/Redis/Kafka/PushGateway
- **G10**getDb() 函数式(非类)
- **G11**cuid2 IDvarchar(32)
- **G12**ESM 模式,相对 import 带 `.js` 后缀
- **G13**:仅类型导入用 `import type`
详见 [coord-final-decisions.md](../../docs/architecture/coord-final-decisions.md) + [president-final-rulings.md](../../docs/architecture/president-final-rulings.md)

View File

@@ -1,6 +1,6 @@
{
"name": "@edu/msg-service",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
@@ -12,20 +12,26 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@edu/shared-ts": "workspace:*",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/microservices": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"drizzle-orm": "^0.31.0",
"mysql2": "^3.11.0",
"kafkajs": "^2.2.0",
"@paralleldrive/cuid2": "^2.2.2",
"@elastic/elasticsearch": "^8.15.0",
"pino": "^9.4.0",
"drizzle-orm": "^0.31.0",
"grpc-reflection-js": "^0.3.0",
"ioredis": "^5.4.0",
"kafkajs": "^2.2.0",
"mysql2": "^3.11.0",
"prom-client": "^15.1.0",
"@opentelemetry/api": "^1.9.0",
"zod": "^3.23.0",
"uuid": "^10.0.0",
"pino": "^9.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"zod": "^3.23.0",
"@grpc/grpc-js": "^1.11.0",
"@grpc/proto-loader": "^0.7.13",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
"@opentelemetry/auto-instrumentations-node": "^0.55.0"
@@ -34,8 +40,7 @@
"@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
}

View File

@@ -1,15 +1,43 @@
import { Module } from "@nestjs/common";
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 { GrpcModule } from "./grpc/grpc.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
import { KafkaConsumerService } from "./shared/kafka/kafka.consumer.js";
/**
* AppModule —— msg 服务根模块。
*
* 仲裁依据:
* - M1gRPC 50056 启用GrpcModule 注册 3 controller 共 17 RPC
* - HTTP REST + gRPC 双协议入口,共享同一套 Service 单例
* - KafkaConsumerService 消费 12 类事件触发通知
*
* 模块依赖图:
* AppModule
* ├─ NotificationsModuleREST + Service
* ├─ PreferencesModuleREST + Service
* ├─ TemplatesModuleREST + Service
* ├─ GrpcModulegRPC controllersimports 上述 3 模块获取 Service
* ├─ HealthModule/healthz + /readyz
* └─ providers: PermissionGuard(APP_GUARD) + LifecycleService + KafkaConsumerService
*/
@Module({
imports: [NotificationsModule, HealthModule],
imports: [
NotificationsModule,
PreferencesModule,
TemplatesModule,
GrpcModule,
HealthModule,
],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
KafkaConsumerService,
],
})
export class AppModule {}

View File

@@ -0,0 +1,154 @@
import { Injectable } from "@nestjs/common";
import { createId } from "@paralleldrive/cuid2";
import { getDb } from "../config/database.js";
import { notificationDeliveries } from "../notifications/notifications.schema.js";
import type { NotificationChannel } from "../notifications/notifications.schema.js";
import { logger } from "../shared/observability/logger.js";
import { inAppChannel } from "./in-app.channel.js";
import { emailChannel } from "./email.channel.js";
import { smsChannel } from "./sms.channel.js";
import { pushChannel } from "./push.channel.js";
import type {
ChannelSendContext,
ChannelSendResult,
NotificationChannelStrategy,
} from "./channel.types.js";
/**
* ChannelDispatcher —— 多渠道分发编排器。
*
* 仲裁依据 02-architecture-design.md §2.4
* 1. in_app 渠道总是发送(保证站内信可见)
* 2. 其他渠道按 NotificationPreference.channels 配置启用/禁用
* 3. 所有渠道并行发送、软失败,结果记录到 msg_notification_deliveries
*
* 渠道注册表:新增渠道只需实现 NotificationChannelStrategy 并注册到此。
*/
const CHANNEL_REGISTRY: Record<
NotificationChannel,
NotificationChannelStrategy
> = {
in_app: inAppChannel,
email: emailChannel,
sms: smsChannel,
push: pushChannel,
wechat: {
// wechat 渠道预留(未实现),直接返回未配置
name: "wechat" as const,
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
logger.debug(
{ userId: ctx.userId, notificationId: ctx.notificationId },
"WeChat channel not implemented (future channel)",
);
return {
channel: "wechat",
sent: false,
error: "WeChat channel not implemented",
};
},
} as NotificationChannelStrategy,
};
/** 默认渠道(无偏好配置时) */
const DEFAULT_CHANNELS: NotificationChannel[] = ["in_app"];
@Injectable()
export class ChannelDispatcherService {
/**
* 根据用户偏好 fan-out 到启用的渠道。
*
* @param ctx 发送上下文
* @param enabledChannels 用户偏好的启用渠道列表(可为 null=用默认)
* @returns 每个渠道的发送结果
*/
async dispatch(
ctx: ChannelSendContext,
enabledChannels: NotificationChannel[] | null,
): Promise<ChannelSendResult[]> {
// 确定要发送的渠道列表in_app 总是包含
const channels = this.resolveChannels(enabledChannels);
// 并行发送所有渠道
const results = await Promise.allSettled(
channels.map((ch) => this.sendToChannel(ctx, ch)),
);
// 收集结果rejected 的转为 failed 结果
// 用 channels.map 遍历保证 channel 一定存在results 与 channels 长度一致)
const sendResults: ChannelSendResult[] = channels.map((channel, i) => {
const r = results[i];
if (!r) {
return { channel, sent: false, error: "No result" };
}
if (r.status === "fulfilled") {
return r.value;
}
const error =
r.reason instanceof Error ? r.reason.message : String(r.reason);
return { channel, sent: false, error };
});
// 异步记录投递结果(不阻断返回)
void this.recordDeliveries(ctx.notificationId, sendResults);
return sendResults;
}
/**
* 解析要发送的渠道列表。
* in_app 总是包含;其他渠道按偏好配置。
*/
private resolveChannels(
enabledChannels: NotificationChannel[] | null,
): NotificationChannel[] {
if (!enabledChannels || enabledChannels.length === 0) {
return DEFAULT_CHANNELS;
}
// 确保 in_app 总在列表中
const set = new Set<NotificationChannel>(enabledChannels);
set.add("in_app");
return Array.from(set);
}
private async sendToChannel(
ctx: ChannelSendContext,
channel: NotificationChannel,
): Promise<ChannelSendResult> {
const strategy = CHANNEL_REGISTRY[channel];
if (!strategy) {
return { channel, sent: false, error: `Unknown channel: ${channel}` };
}
return strategy.send(ctx);
}
/**
* 记录投递结果到 msg_notification_deliveries 表。
* 软失败:记录失败不阻断主流程。
*/
private async recordDeliveries(
notificationId: string,
results: ChannelSendResult[],
): Promise<void> {
try {
const db = getDb();
const now = new Date();
const rows = results.map((r) => ({
id: createId(),
notificationId,
channel: r.channel,
status: r.sent ? ("sent" as const) : ("failed" as const),
attemptCount: 1,
lastError: r.error ?? null,
deliveredAt: r.sent ? now : null,
}));
if (rows.length > 0) {
await db.insert(notificationDeliveries).values(rows);
}
} catch (err) {
logger.warn(
{ err, notificationId },
"Failed to record deliveries (non-fatal)",
);
}
}
}

View File

@@ -0,0 +1,53 @@
import type { NotificationChannel } from "../notifications/notifications.schema.js";
/**
* 渠道策略接口(策略模式)。
*
* 每个渠道实现此接口ChannelDispatcher 根据 NotificationPreference
* 配置 fan-out 到启用的渠道。
*
* 仲裁依据 02-architecture-design.md §2.4
* - in_app 总是发送(保证站内信可见)
* - 其他渠道按偏好配置启用/禁用
* - 所有渠道软失败:投递失败不阻断主流程,记录 delivery 状态
*/
/** 渠道发送上下文(由 NotificationService 构造) */
export interface ChannelSendContext {
/** 通知 ID已写入 DB 的) */
notificationId: string;
/** 接收者用户 ID */
userId: string;
/** 通知标题 */
title: string;
/** 通知内容 */
content: string;
/** 通知类型 */
type: string;
/** 元数据 */
metadata: Record<string, string> | null;
/** 关联实体类型 */
relatedEntityType?: string | null;
/** 关联实体 ID */
relatedEntityId?: string | null;
}
/** 渠道发送结果 */
export interface ChannelSendResult {
/** 渠道名称 */
channel: NotificationChannel;
/** 是否发送成功 */
sent: boolean;
/** 外部网关返回的 ID如有 */
externalId?: string;
/** 失败原因 */
error?: string;
}
/** 渠道策略接口 */
export interface NotificationChannelStrategy {
/** 渠道名称 */
readonly name: NotificationChannel;
/** 发送通知 */
send(ctx: ChannelSendContext): Promise<ChannelSendResult>;
}

View File

@@ -0,0 +1,33 @@
import { logger } from "../shared/observability/logger.js";
import type {
ChannelSendContext,
ChannelSendResult,
NotificationChannelStrategy,
} from "./channel.types.js";
/**
* EmailChannel —— 邮件渠道P5 stub
*
* P5 阶段无实际 SMTP 网关,仅记录投递意图。
* 后续接入 SMTP 服务时替换 send 实现,接口不变(策略模式扩展点)。
*
* 仲裁依据 02-architecture-design.md §2.4:软失败,不阻断主流程。
*/
class EmailChannel implements NotificationChannelStrategy {
readonly name = "email" as const;
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
// P5 stub记录投递意图不实际发送
logger.info(
{ userId: ctx.userId, notificationId: ctx.notificationId },
"Email channel stub: delivery intent logged (SMTP not configured)",
);
return {
channel: "email",
sent: false,
error: "SMTP gateway not configured (P5 stub)",
};
}
}
export const emailChannel = new EmailChannel();

View File

@@ -0,0 +1,52 @@
import type {
ChannelSendContext,
ChannelSendResult,
NotificationChannelStrategy,
} from "./channel.types.js";
/**
* InAppChannel —— 站内信渠道。
*
* 站内信数据已在 NotificationService 中写入 MySQL + ES
* 此渠道仅负责触发 push-gateway 实时推送给在线用户(软失败)。
*
* 仲裁依据 02-architecture-design.md §2.4
* - in_app 总是发送,保证站内信可见
* - 实时推送通过 push-gateway不在线时用户下次拉取即可见
*/
import { sendPush } from "../shared/push/push-gateway.client.js";
import { logger } from "../shared/observability/logger.js";
class InAppChannel implements NotificationChannelStrategy {
readonly name = "in_app" as const;
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
// 站内信数据已落库,仅触发实时推送
const pushResult = await sendPush({
userId: ctx.userId,
event: "notification.new",
data: {
id: ctx.notificationId,
type: ctx.type,
title: ctx.title,
content: ctx.content,
metadata: ctx.metadata,
},
});
if (!pushResult.sent) {
logger.debug(
{ userId: ctx.userId, notificationId: ctx.notificationId },
"In-app push not delivered (user offline or gateway unavailable)",
);
}
return {
channel: "in_app",
sent: true, // 站内信本身已成功数据已落库push 是增强
externalId: pushResult.sent ? ctx.notificationId : undefined,
};
}
}
export const inAppChannel = new InAppChannel();

View File

@@ -0,0 +1,40 @@
import { sendPush } from "../shared/push/push-gateway.client.js";
import type {
ChannelSendContext,
ChannelSendResult,
NotificationChannelStrategy,
} from "./channel.types.js";
/**
* PushChannel —— 移动推送渠道。
*
* 通过 push-gateway HTTP /internal/push 推送。
* 仲裁依据 M4HTTP POST豁免 gRPC
*
* 软失败push-gateway 不可用或用户离线时返回 sent=false不阻断。
*/
class PushChannel implements NotificationChannelStrategy {
readonly name = "push" as const;
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
const result = await sendPush({
userId: ctx.userId,
event: "notification.push",
data: {
id: ctx.notificationId,
type: ctx.type,
title: ctx.title,
content: ctx.content,
metadata: ctx.metadata,
},
});
return {
channel: "push",
sent: result.sent,
error: result.error,
};
}
}
export const pushChannel = new PushChannel();

View File

@@ -0,0 +1,33 @@
import { logger } from "../shared/observability/logger.js";
import type {
ChannelSendContext,
ChannelSendResult,
NotificationChannelStrategy,
} from "./channel.types.js";
/**
* SmsChannel —— 短信渠道P5 stub
*
* P5 阶段无实际 SMS 网关,仅记录投递意图。
* 后续接入短信服务商时替换 send 实现,接口不变(策略模式扩展点)。
*
* 仲裁依据 02-architecture-design.md §2.4:软失败,不阻断主流程。
*/
class SmsChannel implements NotificationChannelStrategy {
readonly name = "sms" as const;
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
// P5 stub记录投递意图不实际发送
logger.info(
{ userId: ctx.userId, notificationId: ctx.notificationId },
"SMS channel stub: delivery intent logged (gateway not configured)",
);
return {
channel: "sms",
sent: false,
error: "SMS gateway not configured (P5 stub)",
};
}
}
export const smsChannel = new SmsChannel();

View File

@@ -1,16 +1,48 @@
import { drizzle } from "drizzle-orm/mysql2";
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
const pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
/**
* msg 服务 MySQL 连接池。
*
* 仲裁依据 G10统一 getDb() 函数式(对齐 classes 黄金模板)。
*
* 使用 lazy initializationpool 在首次调用 getDb() 时创建,
* 避免模块导入副作用导致测试环境难以 mock。
*/
let pool: mysql.Pool | null = null;
let dbInstance: MySql2Database<Record<string, never>> | null = null;
export const db = drizzle(pool);
function getPool(): mysql.Pool {
if (!pool) {
pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
}
return pool;
}
export function getDb(): MySql2Database<Record<string, never>> {
if (!dbInstance) {
dbInstance = drizzle(getPool());
}
return dbInstance;
}
export async function closeDb(): Promise<void> {
await pool.end();
if (pool) {
await pool.end();
pool = null;
dbInstance = null;
}
}
/**
* 健康检查用:直接访问底层 pool。
*/
export function getPoolInstance(): mysql.Pool {
return getPool();
}

View File

@@ -2,10 +2,56 @@ import { Client } from "@elastic/elasticsearch";
import { env } from "./env.js";
import { logger } from "../shared/observability/logger.js";
/**
* msg 服务 Elasticsearch 客户端。
*
* 仲裁依据:
* - ES_URL 可选,未配置时 esClient=null降级到 MySQL LIKE 查询
* - 02-architecture-design.md §3.2.1notifications 索引 mappingik_max_word 分词)
*/
export const esClient: Client | null = env.ES_URL
? new Client({ node: env.ES_URL })
: null;
/**
* notifications 索引 mapping对齐 02-architecture-design.md §3.2.1)。
*
* ik_max_word索引时最大粒度分词ik_smart查询时智能分词。
* 若 ES 未安装 ik 分词器,会回退到 standard 分词器(不影响功能)。
*/
const NOTIFICATIONS_INDEX_MAPPING = {
mappings: {
properties: {
id: { type: "keyword" },
user_id: { type: "keyword" },
type: { type: "keyword" },
title: {
type: "text",
analyzer: "ik_max_word",
search_analyzer: "ik_smart",
},
content: {
type: "text",
analyzer: "ik_max_word",
search_analyzer: "ik_smart",
},
channel: { type: "keyword" },
status: { type: "keyword" },
group_id: { type: "keyword" },
related_entity_type: { type: "keyword" },
related_entity_id: { type: "keyword" },
sender_id: { type: "keyword" },
is_read: { type: "boolean" },
created_at: { type: "date" },
read_at: { type: "date" },
},
},
settings: {
number_of_shards: 1,
number_of_replicas: 1,
},
} as const;
export async function checkEsConnection(): Promise<void> {
if (!esClient) {
logger.info("Elasticsearch disabled (ES_URL not set)");
@@ -13,12 +59,32 @@ export async function checkEsConnection(): Promise<void> {
}
try {
await esClient.ping();
logger.info("Elasticsearch connected");
await ensureNotificationsIndex();
logger.info("Elasticsearch connected, notifications index ensured");
} catch (err) {
logger.error({ err }, "Elasticsearch connection failed");
}
}
/**
* 幂等创建 notifications 索引。若索引已存在则跳过。
*/
export async function ensureNotificationsIndex(): Promise<void> {
if (!esClient) return;
try {
const exists = await esClient.indices.exists({ index: "notifications" });
if (!exists) {
await esClient.indices.create({
index: "notifications",
...NOTIFICATIONS_INDEX_MAPPING,
});
logger.info("notifications index created");
}
} catch (err) {
logger.error({ err }, "ensureNotificationsIndex failed");
}
}
export async function closeEs(): Promise<void> {
if (esClient) {
await esClient.close();
@@ -38,6 +104,7 @@ export interface SearchHit {
export interface SearchResult {
hits: SearchHit[];
total: number;
}
export async function safeIndex(params: IndexParams): Promise<boolean> {
@@ -57,10 +124,17 @@ export async function safeIndex(params: IndexParams): Promise<boolean> {
export async function safeSearch(
index: string,
query: Record<string, unknown>,
from: number = 0,
size: number = 20,
): Promise<SearchResult> {
if (!esClient) return { hits: [] };
if (!esClient) return { hits: [], total: 0 };
try {
const result = await esClient.search({ index, query });
const result = await esClient.search({
index,
query,
from,
size,
});
const hits: SearchHit[] = [];
for (const raw of result.hits.hits) {
const source = raw._source;
@@ -70,16 +144,33 @@ export async function safeSearch(
typeof source === "object" &&
!Array.isArray(source)
) {
// source 已收窄为 object但 object 无索引签名,需断言为 Record<string, unknown>
hits.push({
_id: raw._id,
_source: source as Record<string, unknown>,
});
}
}
return { hits };
const total =
typeof result.hits.total === "number"
? result.hits.total
: (result.hits.total?.value ?? hits.length);
return { hits, total };
} catch (err) {
logger.error({ err, index }, "Elasticsearch search failed");
return { hits: [] };
return { hits: [], total: 0 };
}
}
/**
* 删除索引文档(撤回通知时同步删除 ES 索引)。
*/
export async function safeDelete(index: string, id: string): Promise<boolean> {
if (!esClient) return false;
try {
await esClient.delete({ index, id });
return true;
} catch (err) {
logger.error({ err, index, id }, "Elasticsearch delete failed");
return false;
}
}

View File

@@ -1,14 +1,27 @@
import { z } from "zod";
/**
* msg 服务环境变量 schema。
*
* 仲裁依据:
* - G2 /readyz 多依赖检查DB/ES/Redis/Kafka/PushGateway
* - M2 PushGateway 软失败PUSH_GATEWAY_URL 可选)
* - M4 HTTP POST /internal/push豁免 gRPC
* - REDIS_URL 可选,未配置时降级到 DB 唯一索引去重
*/
const envSchema = z.object({
PORT: z.string().default("3007"),
GRPC_PORT: z.string().default("50056"),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().optional(),
JWT_ISSUER: z.string().default("next-edu-cloud"),
KAFKA_BROKERS: z.string().default("localhost:9092"),
KAFKA_CLIENT_ID: z.string().default("msg-service"),
KAFKA_CONSUMER_GROUP_ID: z.string().default("msg-service-group"),
ES_URL: z.string().url().optional(),
PUSH_GATEWAY_URL: z.string().url().optional(),
PUSH_INTERNAL_TOKEN: z.string().optional(),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace"])

View File

@@ -0,0 +1,31 @@
import { Transport, type MicroserviceOptions } from "@nestjs/microservices";
import { resolve } from "node:path";
import { env } from "./env.js";
/**
* gRPC 微服务配置。
*
* 仲裁依据 M1gRPC 50056 启用,对齐 msg.proto package next_edu_cloud.msg.v1。
*
* proto 文件路径解析:
* - 开发环境:从 services/msg/ 出发,../../packages/shared-proto/proto/msg.proto
* - 生产环境Docker 容器内保留 workspace 结构,同上路径
*/
const PROTO_PATH = resolve(
process.cwd(),
"..",
"..",
"packages",
"shared-proto",
"proto",
"msg.proto",
);
export const grpcMicroserviceOptions: MicroserviceOptions = {
transport: Transport.GRPC,
options: {
package: "next_edu_cloud.msg.v1",
protoPath: PROTO_PATH,
url: `0.0.0.0:${env.GRPC_PORT}`,
},
};

View File

@@ -0,0 +1,25 @@
import { Module } from "@nestjs/common";
import { NotificationsModule } from "../notifications/notifications.module.js";
import { PreferencesModule } from "../preferences/preferences.module.js";
import { TemplatesModule } from "../templates/templates.module.js";
import { NotificationsGrpcController } from "./notifications.grpc.controller.js";
import { PreferencesGrpcController } from "./preferences.grpc.controller.js";
import { TemplatesGrpcController } from "./templates.grpc.controller.js";
/**
* GrpcModule —— 注册 3 个 gRPC controller17 RPC
*
* 仲裁依据 M1gRPC 50056 启用proto msg.proto 定义 3 Service 共 17 RPC。
*
* Module 导入 NotificationsModule / PreferencesModule / TemplatesModule
* 以获取各自的 Service 单例NestJS 模块单例,不重复实例化)。
*/
@Module({
imports: [NotificationsModule, PreferencesModule, TemplatesModule],
controllers: [
NotificationsGrpcController,
PreferencesGrpcController,
TemplatesGrpcController,
],
})
export class GrpcModule {}

View File

@@ -0,0 +1,303 @@
import { Controller } from "@nestjs/common";
import { GrpcMethod } from "@nestjs/microservices";
import { NotificationsService } from "../notifications/notifications.service.js";
import {
sendNotificationSchema,
sendNotificationBatchSchema,
} from "../notifications/notifications.dto.js";
import type { Notification } from "../notifications/notifications.schema.js";
/**
* NotificationsGrpcController —— gRPC 入口NotificationService 9 RPC
*
* 仲裁依据:
* - M1gRPC 50056 启用
* - msg.proto §NotificationService9 RPC
* - proto-loader 默认 keepCase=false字段名 camelCase
*
* gRPC 方法接收 proto messagecamelCase 字段),调用 NotificationsService
* 返回 proto messagecamelCase 字段int64 时间戳为 epoch 毫秒)。
*/
// ============================================================
// 类型定义(对齐 proto msg.proto
// ============================================================
interface GrpcNotification {
id: string;
userId: string;
type: string;
title: string;
content: string;
channel: string;
isRead: boolean;
createdAt: number;
status: string;
relatedEntityType: string;
relatedEntityId: string;
groupId: string;
senderId: string;
templateId: string;
eventId: string;
readAt: number;
updatedAt: number;
metadata: Record<string, string>;
}
interface GrpcBatchSendFailure {
userId: string;
error: string;
}
// ============================================================
// 工具函数
// ============================================================
/** proto3 默认空字符串 -> undefined让 Zod optional 生效) */
function opt(val: unknown): string | undefined {
if (typeof val === "string" && val.length > 0) return val;
return undefined;
}
/** proto3 默认 0 -> undefined让分页使用默认值 */
function optNum(val: unknown): number | undefined {
const n = Number(val);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
/** epoch 毫秒Date -> number其他 -> number|0 */
function toEpoch(val: unknown): number {
if (val instanceof Date) return val.getTime();
const n = Number(val);
return Number.isFinite(n) ? n : 0;
}
/**
* 将 DB Notification 记录camelCase + Date或 ES 命中snake_case统一映射为 proto Notification。
*/
function toGrpcNotification(n: Record<string, unknown>): GrpcNotification {
const createdAt = n.createdAt ?? n.created_at;
const readAt = n.readAt ?? n.read_at;
const updatedAt = n.updatedAt ?? n.updated_at;
return {
id: String(n.id ?? ""),
userId: String(n.userId ?? n.user_id ?? ""),
type: String(n.type ?? ""),
title: String(n.title ?? ""),
content: String(n.content ?? ""),
channel: String(n.channel ?? ""),
isRead: Boolean(n.isRead ?? n.is_read ?? false),
createdAt: toEpoch(createdAt),
status: String(n.status ?? ""),
relatedEntityType: String(
n.relatedEntityType ?? n.related_entity_type ?? "",
),
relatedEntityId: String(n.relatedEntityId ?? n.related_entity_id ?? ""),
groupId: String(n.groupId ?? n.group_id ?? ""),
senderId: String(n.senderId ?? n.sender_id ?? ""),
templateId: String(n.templateId ?? n.template_id ?? ""),
eventId: String(n.eventId ?? n.event_id ?? ""),
readAt: toEpoch(readAt),
updatedAt: toEpoch(updatedAt),
metadata: (n.metadata ?? {}) as Record<string, string>,
};
}
// ============================================================
// Controller
// ============================================================
@Controller()
export class NotificationsGrpcController {
constructor(private readonly service: NotificationsService) {}
// ----------------------------------------------------------
// SendNotification
// ----------------------------------------------------------
@GrpcMethod("NotificationService", "SendNotification")
async sendNotification(
data: Record<string, unknown>,
): Promise<GrpcNotification> {
const dto = sendNotificationSchema.parse({
userId: data.userId,
type: data.type,
title: data.title,
content: data.content,
channel: opt(data.channel),
metadata: data.metadata,
relatedEntityType: opt(data.relatedEntityType),
relatedEntityId: opt(data.relatedEntityId),
groupId: opt(data.groupId),
senderId: opt(data.senderId),
templateId: opt(data.templateId),
eventId: opt(data.eventId),
});
const result = await this.service.send(dto);
const now = Date.now();
return {
id: result.id,
userId: dto.userId,
type: dto.type,
title: dto.title,
content: dto.content,
channel: dto.channel ?? "in_app",
isRead: false,
createdAt: now,
status: result.status,
relatedEntityType: dto.relatedEntityType ?? "",
relatedEntityId: dto.relatedEntityId ?? "",
groupId: dto.groupId ?? "",
senderId: dto.senderId ?? "",
templateId: dto.templateId ?? "",
eventId: dto.eventId ?? "",
readAt: 0,
updatedAt: now,
metadata: dto.metadata ?? {},
};
}
// ----------------------------------------------------------
// BatchSendNotification
// ----------------------------------------------------------
@GrpcMethod("NotificationService", "BatchSendNotification")
async batchSendNotification(data: Record<string, unknown>): Promise<{
ids: string[];
failed: GrpcBatchSendFailure[];
}> {
const items = Array.isArray(data.items) ? data.items : [];
const dto = sendNotificationBatchSchema.parse({
items: items.map((item: Record<string, unknown>) => ({
userId: item.userId,
type: item.type,
title: item.title,
content: item.content,
channel: opt(item.channel),
metadata: item.metadata,
relatedEntityType: opt(item.relatedEntityType),
relatedEntityId: opt(item.relatedEntityId),
groupId: opt(item.groupId),
senderId: opt(item.senderId),
templateId: opt(item.templateId),
eventId: opt(item.eventId),
})),
groupId: opt(data.groupId),
});
const result = await this.service.sendBatch(dto);
return {
ids: result.ids,
failed: result.failed,
};
}
// ----------------------------------------------------------
// ListNotifications
// ----------------------------------------------------------
@GrpcMethod("NotificationService", "ListNotifications")
async listNotifications(data: Record<string, unknown>): Promise<{
notifications: GrpcNotification[];
total: number;
}> {
const result = await this.service.listByUser(String(data.userId ?? ""), {
onlyUnread: Boolean(data.onlyUnread),
type: opt(data.type),
page: optNum(data.page) ?? 1,
pageSize: optNum(data.pageSize) ?? 20,
});
return {
notifications: result.items.map((n: Notification) =>
toGrpcNotification(n as unknown as Record<string, unknown>),
),
total: result.total,
};
}
// ----------------------------------------------------------
// GetUnreadCount
// ----------------------------------------------------------
@GrpcMethod("NotificationService", "GetUnreadCount")
async getUnreadCount(
data: Record<string, unknown>,
): Promise<{ count: number }> {
const count = await this.service.getUnreadCount(String(data.userId ?? ""));
return { count };
}
// ----------------------------------------------------------
// MarkAsRead
// ----------------------------------------------------------
@GrpcMethod("NotificationService", "MarkAsRead")
async markAsRead(
data: Record<string, unknown>,
): Promise<Record<string, never>> {
await this.service.markAsRead(
String(data.id ?? ""),
String(data.userId ?? ""),
);
return {};
}
// ----------------------------------------------------------
// BatchMarkAsRead
// ----------------------------------------------------------
@GrpcMethod("NotificationService", "BatchMarkAsRead")
async batchMarkAsRead(
data: Record<string, unknown>,
): Promise<Record<string, never>> {
const ids = Array.isArray(data.ids) ? (data.ids as string[]) : [];
await this.service.batchMarkAsRead(ids, String(data.userId ?? ""));
return {};
}
// ----------------------------------------------------------
// MarkAllAsRead
// ----------------------------------------------------------
@GrpcMethod("NotificationService", "MarkAllAsRead")
async markAllAsRead(
data: Record<string, unknown>,
): Promise<Record<string, never>> {
await this.service.markAllAsRead(
String(data.userId ?? ""),
optNum(data.before),
);
return {};
}
// ----------------------------------------------------------
// SearchNotifications
// ----------------------------------------------------------
@GrpcMethod("NotificationService", "SearchNotifications")
async searchNotifications(data: Record<string, unknown>): Promise<{
notifications: GrpcNotification[];
total: number;
}> {
const result = await this.service.search(
String(data.userId ?? ""),
String(data.query ?? ""),
{
type: opt(data.type),
page: optNum(data.page) ?? 1,
pageSize: optNum(data.pageSize) ?? 20,
},
);
return {
notifications: result.items.map((n) => toGrpcNotification(n)),
total: result.total,
};
}
// ----------------------------------------------------------
// RecallNotification
// ----------------------------------------------------------
@GrpcMethod("NotificationService", "RecallNotification")
async recallNotification(data: Record<string, unknown>): Promise<{
recalledCount: number;
}> {
const recalledCount = await this.service.recall(String(data.groupId ?? ""));
return { recalledCount };
}
}

View File

@@ -0,0 +1,84 @@
import { Controller } from "@nestjs/common";
import { GrpcMethod } from "@nestjs/microservices";
import { PreferencesService } from "../preferences/preferences.service.js";
import { updatePreferencesSchema } from "../preferences/preferences.dto.js";
import type { NotificationPreference } from "../notifications/notifications.schema.js";
/**
* PreferencesGrpcController —— gRPC 入口NotificationPreferenceService 2 RPC
*
* 仲裁依据:
* - msg.proto §NotificationPreferenceServiceGetPreferences / UpdatePreferences
* - proto-loader 默认 keepCase=false字段名 camelCase
*/
interface GrpcPreference {
id: string;
userId: string;
type: string;
channels: string[];
frequencyLimit: number;
quietHoursStart: string;
quietHoursEnd: string;
quietHoursTimezone: string;
enabled: boolean;
createdAt: number;
updatedAt: number;
}
function opt(val: unknown): string | undefined {
if (typeof val === "string" && val.length > 0) return val;
return undefined;
}
function toGrpcPreference(p: NotificationPreference): GrpcPreference {
return {
id: p.id,
userId: p.userId,
type: p.type,
channels: p.channels,
frequencyLimit: p.frequencyLimit ?? 0,
quietHoursStart: p.quietHoursStart ?? "",
quietHoursEnd: p.quietHoursEnd ?? "",
quietHoursTimezone: p.quietHoursTimezone ?? "",
enabled: p.enabled,
createdAt: p.createdAt instanceof Date ? p.createdAt.getTime() : 0,
updatedAt: p.updatedAt instanceof Date ? p.updatedAt.getTime() : 0,
};
}
@Controller()
export class PreferencesGrpcController {
constructor(private readonly service: PreferencesService) {}
@GrpcMethod("NotificationPreferenceService", "GetPreferences")
async getPreferences(
data: Record<string, unknown>,
): Promise<{ preferences: GrpcPreference[] }> {
const prefs = await this.service.getByUserId(String(data.userId ?? ""));
return { preferences: prefs.map(toGrpcPreference) };
}
@GrpcMethod("NotificationPreferenceService", "UpdatePreferences")
async updatePreferences(
data: Record<string, unknown>,
): Promise<Record<string, never>> {
const rawPrefs = Array.isArray(data.preferences) ? data.preferences : [];
const dto = updatePreferencesSchema.parse({
userId: data.userId,
preferences: rawPrefs.map((p: Record<string, unknown>) => ({
type: p.type,
channels: Array.isArray(p.channels) ? p.channels : [],
frequencyLimit: p.frequencyLimit ? Number(p.frequencyLimit) : undefined,
quietHoursStart: opt(p.quietHoursStart),
quietHoursEnd: opt(p.quietHoursEnd),
quietHoursTimezone: opt(p.quietHoursTimezone),
enabled: p.enabled ?? true,
})),
});
await this.service.update(dto);
return {};
}
}

View File

@@ -0,0 +1,130 @@
import { Controller } from "@nestjs/common";
import { GrpcMethod } from "@nestjs/microservices";
import { TemplatesService } from "../templates/templates.service.js";
import {
createTemplateSchema,
updateTemplateSchema,
listTemplatesSchema,
renderTemplateSchema,
} from "../templates/templates.dto.js";
import type { NotificationTemplate } from "../notifications/notifications.schema.js";
/**
* TemplatesGrpcController —— gRPC 入口NotificationTemplateService 6 RPC
*
* 仲裁依据:
* - msg.proto §NotificationTemplateService6 RPC
* - proto-loader 默认 keepCase=false字段名 camelCase
*/
interface GrpcTemplate {
id: string;
code: string;
type: string;
titleTemplate: string;
contentTemplate: string;
defaultChannels: string[];
variables: string[];
locale: string;
status: string;
createdAt: number;
updatedAt: number;
}
function opt(val: unknown): string | undefined {
if (typeof val === "string" && val.length > 0) return val;
return undefined;
}
function toGrpcTemplate(t: NotificationTemplate): GrpcTemplate {
return {
id: t.id,
code: t.code,
type: t.type,
titleTemplate: t.titleTemplate,
contentTemplate: t.contentTemplate,
defaultChannels: t.defaultChannels,
variables: t.variables,
locale: t.locale,
status: t.status,
createdAt: t.createdAt instanceof Date ? t.createdAt.getTime() : 0,
updatedAt: t.updatedAt instanceof Date ? t.updatedAt.getTime() : 0,
};
}
@Controller()
export class TemplatesGrpcController {
constructor(private readonly service: TemplatesService) {}
@GrpcMethod("NotificationTemplateService", "CreateTemplate")
async createTemplate(data: Record<string, unknown>): Promise<GrpcTemplate> {
const dto = createTemplateSchema.parse({
code: data.code,
type: data.type,
titleTemplate: data.titleTemplate,
contentTemplate: data.contentTemplate,
defaultChannels: Array.isArray(data.defaultChannels)
? data.defaultChannels
: [],
variables: Array.isArray(data.variables) ? data.variables : [],
locale: opt(data.locale),
});
const tpl = await this.service.create(dto);
return toGrpcTemplate(tpl);
}
@GrpcMethod("NotificationTemplateService", "GetTemplate")
async getTemplate(data: Record<string, unknown>): Promise<GrpcTemplate> {
const tpl = await this.service.getById(String(data.id ?? ""));
return toGrpcTemplate(tpl);
}
@GrpcMethod("NotificationTemplateService", "ListTemplates")
async listTemplates(data: Record<string, unknown>): Promise<{
templates: GrpcTemplate[];
}> {
const dto = listTemplatesSchema.parse({
type: opt(data.type),
status: opt(data.status),
});
const templates = await this.service.list(dto);
return { templates: templates.map(toGrpcTemplate) };
}
@GrpcMethod("NotificationTemplateService", "UpdateTemplate")
async updateTemplate(data: Record<string, unknown>): Promise<GrpcTemplate> {
const dto = updateTemplateSchema.parse({
titleTemplate: opt(data.titleTemplate),
contentTemplate: opt(data.contentTemplate),
defaultChannels: Array.isArray(data.defaultChannels)
? data.defaultChannels
: undefined,
variables: Array.isArray(data.variables) ? data.variables : undefined,
status: opt(data.status),
});
const tpl = await this.service.update(String(data.id ?? ""), dto);
return toGrpcTemplate(tpl);
}
@GrpcMethod("NotificationTemplateService", "DeleteTemplate")
async deleteTemplate(
data: Record<string, unknown>,
): Promise<Record<string, never>> {
await this.service.delete(String(data.id ?? ""));
return {};
}
@GrpcMethod("NotificationTemplateService", "RenderTemplate")
async renderTemplate(data: Record<string, unknown>): Promise<{
title: string;
content: string;
}> {
const dto = renderTemplateSchema.parse({
code: data.code,
variables: data.variables ?? {},
locale: opt(data.locale),
});
const result = await this.service.render(dto);
return { title: result.title, content: result.content };
}
}

View File

@@ -5,11 +5,29 @@ import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js";
import { logger } from "./shared/observability/logger.js";
import { checkEsConnection, closeEs } from "./config/elasticsearch.js";
import { closeDb } from "./config/database.js";
import { checkEsConnection } from "./config/elasticsearch.js";
import { grpcMicroserviceOptions } from "./config/grpc.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
/**
* msg 服务启动入口。
*
* 仲裁依据:
* - M1gRPC 50056 启用connectMicroservice
* - HTTP REST 3007 + gRPC 50056 双协议入口
* - enableShutdownHooks()NestJS 自动处理 SIGTERM/SIGINT → onApplicationShutdown
*
* 启动流程:
* 1. initTracers()OpenTelemetry 链路追踪
* 2. checkEsConnection()ES 探活(降级安全,不阻断启动)
* 3. NestFactory.create(AppModule):创建 app触发 onModuleInit
* - LifecycleService.onModuleInitconnectKafka + outboxPublisher.start
* - KafkaConsumerService.onModuleInitconnectKafka + consumer.run
* 4. connectMicroservice(grpcMicroserviceOptions):注册 gRPC 服务
* 5. startAllMicroservices():启动 gRPC 50056
* 6. listen(PORT):启动 HTTP REST 3007
*/
async function bootstrap(): Promise<void> {
initTracer();
@@ -23,32 +41,34 @@ async function bootstrap(): Promise<void> {
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// 注册 gRPC 微服务50056
app.connectMicroservice(grpcMicroserviceOptions);
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});
// 启动 gRPC 微服务 + HTTP 服务
await app.startAllMicroservices();
await app.listen(env.PORT);
logger.info({ port: env.PORT }, "Msg service started");
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully...");
logger.info(
{ httpPort: env.PORT, grpcPort: env.GRPC_PORT },
"Msg service started (HTTP + gRPC)",
);
// 兜底信号处理enableShutdownHooks 已处理 SIGTERM/SIGINT
// 此处仅确保进程退出(防止悬挂连接阻塞退出)。
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "Shutting down gracefully...");
await app.close();
await closeEs();
await closeDb();
await shutdownTracer();
process.exit(0);
});
process.on("SIGINT", async () => {
logger.info("SIGINT received, shutting down gracefully...");
await app.close();
await closeEs();
await closeDb();
await shutdownTracer();
process.exit(0);
});
};
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
}
void bootstrap().catch((err: unknown) => {

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
@@ -12,8 +13,14 @@ import { NotificationsService } from "./notifications.service.js";
import {
sendNotificationSchema,
sendNotificationBatchSchema,
batchMarkAsReadSchema,
markAllAsReadSchema,
recallNotificationSchema,
} from "./notifications.dto.js";
import type {
SendNotificationDto,
SendNotificationBatchDto,
} from "./notifications.dto.js";
import type { SendNotificationDto } from "./notifications.dto.js";
import {
Permissions,
RequirePermission,
@@ -21,11 +28,26 @@ import {
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
/**
* NotificationsController —— REST API 入口。
*
* 对齐 02-architecture-design.md §4.1 REST API 设计:
* - POST /notifications/send单条发送
* - POST /notifications/batch批量发送
* - GET /notifications/user/:userId列表支持分页+过滤)
* - GET /notifications/user/:userId/unread-count未读数
* - PUT /notifications/:id/read标记已读
* - PUT /notifications/batch/read批量标记已读
* - PUT /notifications/read-all全部已读
* - GET /notifications/searchES 全文检索)
* - POST /notifications/recall撤回广播
* - DELETE /notifications/:id删除
*/
@Controller("notifications")
export class NotificationsController {
constructor(private readonly service: NotificationsService) {}
@Post()
@Post("send")
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
async send(@Body() body: unknown): Promise<{ success: true; data: unknown }> {
const dto: SendNotificationDto = sendNotificationSchema.parse(body);
@@ -35,11 +57,12 @@ export class NotificationsController {
@Post("batch")
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
async createBatch(
async sendBatch(
@Body() body: unknown,
): Promise<{ success: true; data: unknown }> {
const dtos: SendNotificationDto[] = sendNotificationBatchSchema.parse(body);
const result = await this.service.createBatch(dtos);
const dto: SendNotificationBatchDto =
sendNotificationBatchSchema.parse(body);
const result = await this.service.sendBatch(dto);
return { success: true, data: result };
}
@@ -47,48 +70,100 @@ export class NotificationsController {
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async listByUser(
@Param("userId") userId: string,
@Query("unread") unread: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.listByUser(userId, unread === "true");
return { success: true, data: result };
}
@Get("user/:userId/page")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async listByUserPaginated(
@Param("userId") userId: string,
@Query("onlyUnread") onlyUnread: string,
@Query("type") type: string,
@Query("page") page: string,
@Query("pageSize") pageSize: string,
): Promise<{ success: true; data: unknown }> {
const pageNum = Number(page) > 0 ? Number(page) : 1;
const pageSizeNum = Number(pageSize) > 0 ? Number(pageSize) : 20;
const result = await this.service.listByUserWithPagination(
userId,
pageNum,
pageSizeNum,
);
const result = await this.service.listByUser(userId, {
onlyUnread: onlyUnread === "true",
type: type || undefined,
page: Number(page) > 0 ? Number(page) : 1,
pageSize: Number(pageSize) > 0 ? Number(pageSize) : 20,
});
return { success: true, data: result };
}
@Get("user/:userId/unread-count")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async getUnreadCount(
@Param("userId") userId: string,
): Promise<{ success: true; data: { count: number } }> {
const count = await this.service.getUnreadCount(userId);
return { success: true, data: { count } };
}
@Put(":id/read")
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async markAsRead(@Param("id") id: string): Promise<{ success: true }> {
await this.service.markAsRead(id);
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async markAsRead(
@Param("id") id: string,
@Body() body: unknown,
): Promise<{ success: true }> {
const userId =
typeof body === "object" && body !== null && "userId" in body
? String((body as { userId: unknown }).userId)
: "";
if (!userId) {
throw new PermissionDeniedError("MSG_NOTIFICATION_READ");
}
await this.service.markAsRead(id, userId);
return { success: true };
}
@Put("batch/read")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async batchMarkAsRead(@Body() body: unknown): Promise<{ success: true }> {
const dto = batchMarkAsReadSchema.parse(body);
await this.service.batchMarkAsRead(dto.ids, dto.userId);
return { success: true };
}
@Put("read-all")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async markAllAsRead(
@Body() body: unknown,
): Promise<{ success: true; data: { updated: number } }> {
const dto = markAllAsReadSchema.parse(body);
const updated = await this.service.markAllAsRead(dto.userId, dto.before);
return { success: true, data: { updated } };
}
@Get("search")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async search(
@Req() req: AuthenticatedRequest,
@Query("q") q: string,
@Query("userId") userIdParam: string,
@Query("type") type: string,
@Query("page") page: string,
@Query("pageSize") pageSize: string,
): Promise<{ success: true; data: unknown }> {
const userId = req.userId ?? userIdParam;
if (!userId) {
throw new PermissionDeniedError("MSG_NOTIFICATION_READ");
}
const result = await this.service.search(userId, q);
const result = await this.service.search(userId, q, {
type: type || undefined,
page: Number(page) > 0 ? Number(page) : 1,
pageSize: Number(pageSize) > 0 ? Number(pageSize) : 20,
});
return { success: true, data: result };
}
@Post("recall")
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async recall(
@Body() body: unknown,
): Promise<{ success: true; data: { recalledCount: number } }> {
const dto = recallNotificationSchema.parse(body);
const recalledCount = await this.service.recall(dto.groupId);
return { success: true, data: { recalledCount } };
}
@Delete(":id")
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async delete(@Param("id") id: string): Promise<{ success: true }> {
await this.service.delete(id);
return { success: true };
}
}

View File

@@ -1,14 +1,100 @@
import { z } from "zod";
/**
* msg 服务 DTO + Zod 校验 schema。
*
* 对齐 proto msg.proto 字段:
* - SendNotificationRequest / BatchSendNotificationRequest
* - MarkAsRead / BatchMarkAsRead / MarkAllAsRead
* - SearchNotifications / RecallNotification
*/
// ============================================================
// Send Notification
// ============================================================
export const sendNotificationSchema = z.object({
userId: z.string().min(1),
type: z.string().min(1).max(50),
title: z.string().min(1).max(200),
type: z.string().min(1).max(32),
title: z.string().min(1).max(255),
content: z.string().min(1),
channel: z.string().max(20).optional(),
metadata: z.record(z.unknown()).optional(),
channel: z.string().max(32).optional(),
metadata: z.record(z.string()).optional(),
relatedEntityType: z.string().max(64).optional(),
relatedEntityId: z.string().max(32).optional(),
groupId: z.string().max(32).optional(),
senderId: z.string().max(32).optional(),
templateId: z.string().max(32).optional(),
eventId: z.string().max(64).optional(),
});
export const sendNotificationBatchSchema = z.array(sendNotificationSchema);
export type SendNotificationDto = z.infer<typeof sendNotificationSchema>;
export const sendNotificationBatchSchema = z.object({
items: z.array(sendNotificationSchema).min(1).max(1000),
groupId: z.string().max(32).optional(),
});
export type SendNotificationBatchDto = z.infer<
typeof sendNotificationBatchSchema
>;
// ============================================================
// List / Search
// ============================================================
export const listNotificationsSchema = z.object({
userId: z.string().min(1),
onlyUnread: z.boolean().optional(),
type: z.string().max(32).optional(),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
});
export type ListNotificationsDto = z.infer<typeof listNotificationsSchema>;
export const searchNotificationsSchema = z.object({
userId: z.string().min(1),
query: z.string().min(1),
type: z.string().max(32).optional(),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
});
export type SearchNotificationsDto = z.infer<typeof searchNotificationsSchema>;
// ============================================================
// Mark As Read
// ============================================================
export const markAsReadSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
});
export type MarkAsReadDto = z.infer<typeof markAsReadSchema>;
export const batchMarkAsReadSchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(1000),
userId: z.string().min(1),
});
export type BatchMarkAsReadDto = z.infer<typeof batchMarkAsReadSchema>;
export const markAllAsReadSchema = z.object({
userId: z.string().min(1),
before: z.number().int().optional(),
});
export type MarkAllAsReadDto = z.infer<typeof markAllAsReadSchema>;
// ============================================================
// Recall
// ============================================================
export const recallNotificationSchema = z.object({
groupId: z.string().min(1),
reason: z.string().max(500).optional(),
});
export type RecallNotificationDto = z.infer<typeof recallNotificationSchema>;

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { NotificationsController } from './notifications.controller.js';
import { NotificationsService } from './notifications.service.js';
import { Module } from "@nestjs/common";
import { NotificationsController } from "./notifications.controller.js";
import { NotificationsService } from "./notifications.service.js";
import { ChannelDispatcherService } from "../channels/channel-dispatcher.service.js";
@Module({
controllers: [NotificationsController],
providers: [NotificationsService],
providers: [NotificationsService, ChannelDispatcherService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,166 @@
import { and, count, desc, eq, inArray, lte } from "drizzle-orm";
import { getDb } from "../config/database.js";
import {
notifications,
type NewNotification,
type Notification,
} from "./notifications.schema.js";
/**
* NotificationsRepository —— 通知数据访问层。
*
* 职责:封装 MySQL 读写,与业务逻辑解耦。
* 仲裁依据 G10使用 getDb() 函数式获取 db 实例。
*/
export async function insertNotification(
row: NewNotification,
): Promise<Notification> {
const db = getDb();
await db.insert(notifications).values(row);
return row as Notification;
}
export async function insertNotifications(
rows: NewNotification[],
): Promise<void> {
if (rows.length === 0) return;
const db = getDb();
await db.insert(notifications).values(rows);
}
export async function findById(id: string): Promise<Notification | undefined> {
const db = getDb();
const [row] = await db
.select()
.from(notifications)
.where(eq(notifications.id, id))
.limit(1);
return row;
}
export async function findByEventId(
eventId: string,
): Promise<Notification | undefined> {
const db = getDb();
const [row] = await db
.select()
.from(notifications)
.where(eq(notifications.eventId, eventId))
.limit(1);
return row;
}
export async function listByUser(
userId: string,
options: {
onlyUnread?: boolean;
type?: string;
page: number;
pageSize: number;
},
): Promise<{ items: Notification[]; total: number }> {
const db = getDb();
const conditions = [eq(notifications.userId, userId)];
if (options.onlyUnread) {
conditions.push(eq(notifications.isRead, false));
}
if (options.type) {
conditions.push(
eq(notifications.type, options.type as Notification["type"]),
);
}
const where = and(...conditions);
const offset = (options.page - 1) * options.pageSize;
const items = await db
.select()
.from(notifications)
.where(where)
.orderBy(desc(notifications.createdAt))
.limit(options.pageSize)
.offset(offset);
const [countRow] = await db
.select({ value: count() })
.from(notifications)
.where(where);
return { items, total: countRow?.value ?? 0 };
}
export async function getUnreadCount(userId: string): Promise<number> {
const db = getDb();
const [row] = await db
.select({ value: count() })
.from(notifications)
.where(
and(eq(notifications.userId, userId), eq(notifications.isRead, false)),
);
return row?.value ?? 0;
}
export async function markAsRead(id: string, userId: string): Promise<void> {
const db = getDb();
await db
.update(notifications)
.set({ isRead: true, readAt: new Date(), status: "read" })
.where(and(eq(notifications.id, id), eq(notifications.userId, userId)));
}
export async function batchMarkAsRead(
ids: string[],
userId: string,
): Promise<void> {
const db = getDb();
await db
.update(notifications)
.set({ isRead: true, readAt: new Date(), status: "read" })
.where(
and(inArray(notifications.id, ids), eq(notifications.userId, userId)),
);
}
export async function markAllAsRead(
userId: string,
before?: number,
): Promise<number> {
const db = getDb();
const conditions = [
eq(notifications.userId, userId),
eq(notifications.isRead, false),
];
if (before !== undefined) {
conditions.push(lte(notifications.createdAt, new Date(before)));
}
const result = await db
.update(notifications)
.set({ isRead: true, readAt: new Date(), status: "read" })
.where(and(...conditions));
// mysql2 affectedRows
return (result as unknown as { affectedRows?: number }).affectedRows ?? 0;
}
export async function recallByGroup(groupId: string): Promise<number> {
const db = getDb();
const result = await db
.update(notifications)
.set({ status: "recalled" })
.where(eq(notifications.groupId, groupId));
return (result as unknown as { affectedRows?: number }).affectedRows ?? 0;
}
export async function deleteById(id: string): Promise<void> {
const db = getDb();
await db.delete(notifications).where(eq(notifications.id, id));
}
/** 用于测试/重置 */
export async function deleteAllByUserId(userId: string): Promise<void> {
const db = getDb();
await db.delete(notifications).where(eq(notifications.userId, userId));
}

View File

@@ -1,24 +1,163 @@
import { mysqlTable, varchar, char, timestamp, text, boolean, json } from 'drizzle-orm/mysql-core';
import {
boolean,
int,
json,
mysqlTable,
text,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
export const notifications = mysqlTable('msg_notifications', {
id: char('id', { length: 36 }).notNull().primaryKey(),
userId: char('user_id', { length: 36 }).notNull(),
type: varchar('type', { length: 50 }).notNull(),
title: varchar('title', { length: 200 }).notNull(),
content: text('content').notNull(),
channel: varchar('channel', { length: 20 }).notNull().default('in_app'),
isRead: boolean('is_read').notNull().default(false),
metadata: json('metadata'),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
/**
* msg 服务数据库 Schema。
*
* 仲裁依据:
* - 02-architecture-design.md §3.1(表清单 + 字段 + 索引)
* - G11ID 用 cuid2varchar(32)
* - proto msg.proto 字段对齐Notification / NotificationPreference / NotificationTemplate
*
* 表清单:
* - msg_notifications通知主表扩展字段
* - msg_notification_preferences用户偏好扩展字段
* - msg_notification_templates通知模板新增
* - msg_notification_deliveries投递记录新增
*
* 注msg_outbox_events / processed_events 在 shared/outbox/outbox.schema.ts
*/
export const notificationPreferences = mysqlTable('msg_notification_preferences', {
userId: char('user_id', { length: 36 }).notNull().primaryKey(),
emailEnabled: boolean('email_enabled').notNull().default(true),
smsEnabled: boolean('sms_enabled').notNull().default(false),
pushEnabled: boolean('push_enabled').notNull().default(true),
inAppEnabled: boolean('in_app_enabled').notNull().default(true),
// ============================================================
// 通知状态 / 渠道 / 类型 枚举字符串枚举DB 存 varchar
// ============================================================
export type NotificationStatus =
"pending" | "sent" | "delivered" | "read" | "recalled" | "failed";
export type NotificationChannel =
"in_app" | "email" | "sms" | "push" | "wechat";
export type NotificationType =
"system" | "exam" | "homework" | "grade" | "attendance" | "mastery";
export type DeliveryStatus =
"pending" | "sent" | "delivered" | "failed" | "retrying";
export type TemplateStatus = "draft" | "active" | "archived";
// ============================================================
// msg_notifications通知主表 · 扩展)
// ============================================================
export const notifications = mysqlTable("msg_notifications", {
id: varchar("id", { length: 32 }).notNull().primaryKey(),
userId: varchar("user_id", { length: 32 }).notNull(),
type: varchar("type", { length: 32 }).notNull().$type<NotificationType>(),
title: varchar("title", { length: 255 }).notNull(),
content: text("content").notNull(),
channel: varchar("channel", { length: 32 })
.notNull()
.$type<NotificationChannel>(),
isRead: boolean("is_read").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
// 扩展字段P5
status: varchar("status", { length: 32 })
.notNull()
.default("pending")
.$type<NotificationStatus>(),
metadata: json("metadata").$type<Record<string, string> | null>(),
relatedEntityType: varchar("related_entity_type", { length: 64 }),
relatedEntityId: varchar("related_entity_id", { length: 32 }),
groupId: varchar("group_id", { length: 32 }),
senderId: varchar("sender_id", { length: 32 }),
templateId: varchar("template_id", { length: 32 }),
eventId: varchar("event_id", { length: 64 }),
readAt: timestamp("read_at"),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export type Notification = typeof notifications.$inferSelect;
export type NotificationPreference = typeof notificationPreferences.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert;
// ============================================================
// msg_notification_preferences用户偏好 · 扩展)
// ============================================================
export const notificationPreferences = mysqlTable(
"msg_notification_preferences",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
userId: varchar("user_id", { length: 32 }).notNull(),
type: varchar("type", { length: 32 }).notNull().$type<NotificationType>(),
channels: json("channels").notNull().$type<NotificationChannel[]>(),
frequencyLimit: int("frequency_limit"),
quietHoursStart: varchar("quiet_hours_start", { length: 8 }),
quietHoursEnd: varchar("quiet_hours_end", { length: 8 }),
quietHoursTimezone: varchar("quiet_hours_timezone", { length: 64 }).default(
"Asia/Shanghai",
),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
);
export type NotificationPreference =
typeof notificationPreferences.$inferSelect;
export type NewNotificationPreference =
typeof notificationPreferences.$inferInsert;
// ============================================================
// msg_notification_templates通知模板 · 新增)
// ============================================================
export const notificationTemplates = mysqlTable("msg_notification_templates", {
id: varchar("id", { length: 32 }).notNull().primaryKey(),
code: varchar("code", { length: 64 }).notNull(),
type: varchar("type", { length: 32 }).notNull().$type<NotificationType>(),
titleTemplate: varchar("title_template", { length: 255 }).notNull(),
contentTemplate: text("content_template").notNull(),
defaultChannels: json("default_channels")
.notNull()
.$type<NotificationChannel[]>(),
variables: json("variables").notNull().$type<string[]>(),
locale: varchar("locale", { length: 16 }).notNull().default("zh-CN"),
status: varchar("status", { length: 32 })
.notNull()
.default("draft")
.$type<TemplateStatus>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export type NotificationTemplate = typeof notificationTemplates.$inferSelect;
export type NewNotificationTemplate = typeof notificationTemplates.$inferInsert;
// ============================================================
// msg_notification_deliveries投递记录 · 新增)
// ============================================================
export const notificationDeliveries = mysqlTable(
"msg_notification_deliveries",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
notificationId: varchar("notification_id", { length: 32 }).notNull(),
channel: varchar("channel", { length: 32 })
.notNull()
.$type<NotificationChannel>(),
status: varchar("status", { length: 32 })
.notNull()
.default("pending")
.$type<DeliveryStatus>(),
externalId: varchar("external_id", { length: 128 }),
attemptCount: int("attempt_count").notNull().default(0),
maxRetry: int("max_retry").notNull().default(3),
lastError: text("last_error"),
nextRetryAt: timestamp("next_retry_at"),
deliveredAt: timestamp("delivered_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
);
export type NotificationDelivery = typeof notificationDeliveries.$inferSelect;
export type NewNotificationDelivery =
typeof notificationDeliveries.$inferInsert;

View File

@@ -1,24 +1,34 @@
import { Injectable } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { eq, and, desc, sql } from "drizzle-orm";
import { db } from "../config/database.js";
import { createId } from "@paralleldrive/cuid2";
import { and, eq } from "drizzle-orm";
import { getDb } from "../config/database.js";
import {
notifications,
notificationPreferences,
type Notification,
type NotificationChannel,
type NotificationStatus,
} from "./notifications.schema.js";
import * as repo from "./notifications.repository.js";
import type {
Notification,
NotificationPreference,
} from "./notifications.schema.js";
import type { SendNotificationDto } from "./notifications.dto.js";
import { safeIndex, safeSearch } from "../config/elasticsearch.js";
import { env } from "../config/env.js";
SendNotificationDto,
SendNotificationBatchDto,
} from "./notifications.dto.js";
import { ChannelDispatcherService } from "../channels/channel-dispatcher.service.js";
import type { ChannelSendContext } from "../channels/channel.types.js";
import { safeIndex, safeSearch, safeDelete } from "../config/elasticsearch.js";
import { publish as outboxPublish } from "../shared/outbox/outbox.service.js";
import { logger } from "../shared/observability/logger.js";
export interface SendResult {
id: string;
skipped: boolean;
pushed: boolean;
status: string;
channels: string[];
}
export interface BatchSendResult {
ids: string[];
failed: { userId: string; error: string }[];
}
export interface PaginatedResult {
@@ -28,177 +38,320 @@ export interface PaginatedResult {
pageSize: number;
}
export interface SearchResult {
items: Record<string, unknown>[];
total: number;
page: number;
pageSize: number;
}
/**
* NotificationService —— 通知发送编排层。
*
* 职责:
* 1. 幂等检查eventId 去重)
* 2. 查询用户偏好
* 3. 写入 MySQL通知主表
* 4. 写入 ES 索引(降级安全)
* 5. 调用 ChannelDispatcher fan-out 到多渠道
* 6. 写入 Outbox发布 notification.sent 事件)
*
* 仲裁依据:
* - G10getDb() 函数式
* - G11cuid2 ID
* - 02-architecture-design.md §2.4in_app 总是发送,其他渠道按偏好
*/
@Injectable()
export class NotificationsService {
constructor(private readonly channelDispatcher: ChannelDispatcherService) {}
async send(dto: SendNotificationDto): Promise<SendResult> {
const id = randomUUID();
const channel = dto.channel ?? "in_app";
const [pref] = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.userId, dto.userId));
if (pref && !this.isChannelEnabled(pref, channel)) {
logger.info(
{ userId: dto.userId, channel },
"Notification skipped by preference",
);
return { id, skipped: true, pushed: false };
// 幂等eventId 去重
if (dto.eventId) {
const existing = await repo.findByEventId(dto.eventId);
if (existing) {
logger.info(
{ eventId: dto.eventId, id: existing.id },
"Notification already exists (idempotent skip)",
);
return {
id: existing.id,
status: existing.status,
channels: [existing.channel],
};
}
}
await db.insert(notifications).values({
const id = createId();
const channel = (dto.channel ?? "in_app") as NotificationChannel;
// 写入 MySQL
await repo.insertNotification({
id,
userId: dto.userId,
type: dto.type,
type: dto.type as Notification["type"],
title: dto.title,
content: dto.content,
channel,
metadata: dto.metadata,
isRead: false,
status: "pending",
metadata: dto.metadata ?? null,
relatedEntityType: dto.relatedEntityType ?? null,
relatedEntityId: dto.relatedEntityId ?? null,
groupId: dto.groupId ?? null,
senderId: dto.senderId ?? null,
templateId: dto.templateId ?? null,
eventId: dto.eventId ?? null,
});
// 写入 ES降级安全
await safeIndex({
index: "notifications",
id,
document: {
userId: dto.userId,
id,
user_id: dto.userId,
type: dto.type,
title: dto.title,
content: dto.content,
channel,
createdAt: new Date().toISOString(),
status: "pending",
group_id: dto.groupId ?? null,
related_entity_type: dto.relatedEntityType ?? null,
related_entity_id: dto.relatedEntityId ?? null,
sender_id: dto.senderId ?? null,
is_read: false,
created_at: new Date().toISOString(),
},
});
const pushed = await this.notifyPushGateway(dto, channel);
// 查询用户偏好
const enabledChannels = await this.getUserChannels(dto.userId, dto.type);
return { id, skipped: false, pushed };
// 渠道分发
const ctx: ChannelSendContext = {
notificationId: id,
userId: dto.userId,
title: dto.title,
content: dto.content,
type: dto.type,
metadata: dto.metadata ?? null,
relatedEntityType: dto.relatedEntityType,
relatedEntityId: dto.relatedEntityId,
};
const results = await this.channelDispatcher.dispatch(ctx, enabledChannels);
// 更新状态为 sent
const anySent = results.some((r) => r.sent);
await this.updateStatus(id, anySent ? "sent" : "failed");
// 写入 Outbox发布 notification.sent 事件)
await outboxPublish(
"notification.sent",
{
notificationId: id,
userId: dto.userId,
type: dto.type,
channel,
channels: results.map((r) => r.channel),
},
{
aggregateType: "Notification",
aggregateId: id,
metadata: { userId: dto.userId },
},
);
return {
id,
status: anySent ? "sent" : "failed",
channels: results.map((r) => r.channel),
};
}
async createBatch(dtos: SendNotificationDto[]): Promise<SendResult[]> {
const results: SendResult[] = [];
for (const dto of dtos) {
const result = await this.send(dto);
results.push(result);
async sendBatch(dto: SendNotificationBatchDto): Promise<BatchSendResult> {
const groupId = dto.groupId ?? createId();
const ids: string[] = [];
const failed: { userId: string; error: string }[] = [];
for (const item of dto.items) {
try {
const result = await this.send({ ...item, groupId });
ids.push(result.id);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
failed.push({ userId: item.userId, error });
}
}
return results;
return { ids, failed };
}
async listByUser(
userId: string,
onlyUnread: boolean = false,
): Promise<Notification[]> {
const conditions = onlyUnread
? and(eq(notifications.userId, userId), eq(notifications.isRead, false))
: eq(notifications.userId, userId);
return db
.select()
.from(notifications)
.where(conditions)
.orderBy(desc(notifications.createdAt));
}
async listByUserWithPagination(
userId: string,
page: number,
pageSize: number,
options: {
onlyUnread?: boolean;
type?: string;
page: number;
pageSize: number;
},
): Promise<PaginatedResult> {
const offset = (page - 1) * pageSize;
const items = await db
.select()
.from(notifications)
.where(eq(notifications.userId, userId))
.orderBy(desc(notifications.createdAt))
.limit(pageSize)
.offset(offset);
const [countRow] = await db
.select({ count: sql<number>`count(*)` })
.from(notifications)
.where(eq(notifications.userId, userId));
const total = countRow ? Number(countRow.count) : 0;
return { items, total, page, pageSize };
const { items, total } = await repo.listByUser(userId, options);
return {
items,
total,
page: options.page,
pageSize: options.pageSize,
};
}
async markAsRead(id: string): Promise<void> {
async getUnreadCount(userId: string): Promise<number> {
return repo.getUnreadCount(userId);
}
async markAsRead(id: string, userId: string): Promise<void> {
await repo.markAsRead(id, userId);
// 发布 notification.read 事件
await outboxPublish(
"notification.read",
{ notificationId: id, userId },
{
aggregateType: "Notification",
aggregateId: id,
metadata: { userId },
},
);
}
async batchMarkAsRead(ids: string[], userId: string): Promise<void> {
await repo.batchMarkAsRead(ids, userId);
// 批量发布 read 事件
for (const id of ids) {
await outboxPublish(
"notification.read",
{ notificationId: id, userId },
{
aggregateType: "Notification",
aggregateId: id,
metadata: { userId },
},
);
}
}
async markAllAsRead(userId: string, before?: number): Promise<number> {
const updated = await repo.markAllAsRead(userId, before);
// 发布 read 事件
await outboxPublish(
"notification.read",
{ userId, before: before ?? Date.now(), bulk: true },
{
aggregateType: "Notification",
aggregateId: userId,
metadata: { userId, bulk: "true" },
},
);
return updated;
}
async search(
userId: string,
query: string,
options: { type?: string; page: number; pageSize: number },
): Promise<SearchResult> {
const from = (options.page - 1) * options.pageSize;
const must: Record<string, unknown>[] = [
{ term: { user_id: userId } },
{ multi_match: { query, fields: ["title", "content"] } },
];
if (options.type) {
must.push({ term: { type: options.type } });
}
const result = await safeSearch(
"notifications",
{ bool: { must } },
from,
options.pageSize,
);
return {
items: result.hits.map((h) => h._source),
total: result.total,
page: options.page,
pageSize: options.pageSize,
};
}
async recall(groupId: string): Promise<number> {
const recalled = await repo.recallByGroup(groupId);
// 同步删除 ES 索引(撤回的通知不再可搜索)
void this.deleteEsByGroup(groupId);
// 发布 notification.recalled 事件
await outboxPublish(
"notification.recalled",
{ groupId, recalledCount: recalled },
{
aggregateType: "Notification",
aggregateId: groupId,
metadata: { groupId },
},
);
return recalled;
}
async delete(id: string): Promise<void> {
await repo.deleteById(id);
await safeDelete("notifications", id);
}
// ============================================================
// 内部方法
// ============================================================
private async getUserChannels(
userId: string,
type: string,
): Promise<NotificationChannel[] | null> {
const db = getDb();
const [pref] = await db
.select()
.from(notificationPreferences)
.where(
and(
eq(notificationPreferences.userId, userId),
eq(notificationPreferences.type, type as Notification["type"]),
eq(notificationPreferences.enabled, true),
),
)
.limit(1);
return pref?.channels ?? null;
}
private async updateStatus(
id: string,
status: NotificationStatus,
): Promise<void> {
const db = getDb();
await db
.update(notifications)
.set({ isRead: true })
.set({ status })
.where(eq(notifications.id, id));
}
async search(userId: string, query: string): Promise<unknown[]> {
const result = await safeSearch("notifications", {
bool: {
must: [
{ term: { userId } },
{
multi_match: {
query,
fields: ["title", "content"],
},
},
],
},
});
return result.hits;
}
private isChannelEnabled(
pref: NotificationPreference,
channel: string,
): boolean {
switch (channel) {
case "email":
return pref.emailEnabled;
case "sms":
return pref.smsEnabled;
case "push":
return pref.pushEnabled;
case "in_app":
return pref.inAppEnabled;
default:
return true;
}
}
/**
* 调用 Push Gateway 推送通知。Push Gateway 不可用时 try/catch 跳过(降级模式)。
* 仅在 PUSH_GATEWAY_URL 配置且 channel 为 push 或 in_app 时触发。
*/
private async notifyPushGateway(
dto: SendNotificationDto,
channel: string,
): Promise<boolean> {
if (!env.PUSH_GATEWAY_URL) return false;
if (channel !== "push" && channel !== "in_app") return false;
try {
const res = await fetch(`${env.PUSH_GATEWAY_URL}/internal/push`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: dto.userId,
type: dto.type,
title: dto.title,
content: dto.content,
channel,
metadata: dto.metadata,
}),
});
if (!res.ok) {
logger.warn(
{ status: res.status },
"Push gateway returned non-ok status",
);
return false;
}
return true;
} catch (err) {
logger.warn({ err }, "Push gateway unavailable, skipping push");
return false;
}
private async deleteEsByGroup(groupId: string): Promise<void> {
// ES 删除按 group_id 查询后逐条删除ES 无批量 delete by query 在 safeDelete 封装中)
// P5 简化:仅记录日志,实际清理走后台任务
logger.info(
{ groupId },
"ES cleanup for recalled notifications (deferred)",
);
}
}

View File

@@ -0,0 +1,48 @@
import { Body, Controller, Get, Param, Put } from "@nestjs/common";
import { PreferencesService } from "./preferences.service.js";
import { updatePreferencesSchema } from "./preferences.dto.js";
import type { UpdatePreferencesDto } from "./preferences.dto.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
/**
* PreferencesController —— 用户通知偏好 REST API。
*
* 对齐 02-architecture-design.md §4.1
* - GET /preferences/user/:userId查询偏好
* - PUT /preferences/user/:userId更新偏好
*/
@Controller("preferences")
export class PreferencesController {
constructor(private readonly service: PreferencesService) {}
@Get("user/:userId")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async getByUserId(
@Param("userId") userId: string,
): Promise<{ success: true; data: unknown }> {
const preferences = await this.service.getByUserId(userId);
return { success: true, data: { preferences } };
}
@Put("user/:userId")
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async update(
@Param("userId") userId: string,
@Body() body: unknown,
): Promise<{ success: true }> {
// body 来自 HTTP 请求体,类型为 unknown转 Record 以便合并 userId从 unknown 转换允许 as
const obj: Record<string, unknown> =
typeof body === "object" && body !== null
? (body as Record<string, unknown>)
: {};
const dto: UpdatePreferencesDto = updatePreferencesSchema.parse({
...obj,
userId,
});
await this.service.update(dto);
return { success: true };
}
}

View File

@@ -0,0 +1,36 @@
import { z } from "zod";
/**
* Preferences 模块 DTO + Zod 校验。
*
* 对齐 proto msg.proto NotificationPreference 字段。
*/
const channelEnum = z.enum(["in_app", "email", "sms", "push", "wechat"]);
const typeEnum = z.enum([
"system",
"exam",
"homework",
"grade",
"attendance",
"mastery",
]);
export const preferenceSchema = z.object({
type: typeEnum,
channels: z.array(channelEnum).min(1),
frequencyLimit: z.number().int().min(0).optional(),
quietHoursStart: z.string().max(8).optional(),
quietHoursEnd: z.string().max(8).optional(),
quietHoursTimezone: z.string().max(64).optional(),
enabled: z.boolean().default(true),
});
export type PreferenceDto = z.infer<typeof preferenceSchema>;
export const updatePreferencesSchema = z.object({
userId: z.string().min(1),
preferences: z.array(preferenceSchema).min(1),
});
export type UpdatePreferencesDto = z.infer<typeof updatePreferencesSchema>;

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { PreferencesController } from "./preferences.controller.js";
import { PreferencesService } from "./preferences.service.js";
@Module({
controllers: [PreferencesController],
providers: [PreferencesService],
exports: [PreferencesService],
})
export class PreferencesModule {}

View File

@@ -0,0 +1,84 @@
import { and, eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { getDb } from "../config/database.js";
import {
notificationPreferences,
type NotificationPreference,
type NewNotificationPreference,
} from "../notifications/notifications.schema.js";
/**
* PreferencesRepository —— 用户偏好数据访问层。
*/
export async function findByUserId(
userId: string,
): Promise<NotificationPreference[]> {
const db = getDb();
return db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.userId, userId));
}
export async function findByUserIdAndType(
userId: string,
type: string,
): Promise<NotificationPreference | undefined> {
const db = getDb();
const [row] = await db
.select()
.from(notificationPreferences)
.where(
and(
eq(notificationPreferences.userId, userId),
eq(
notificationPreferences.type,
type as NotificationPreference["type"],
),
),
)
.limit(1);
return row;
}
export async function upsert(
userId: string,
row: Omit<
NewNotificationPreference,
"id" | "userId" | "createdAt" | "updatedAt"
>,
): Promise<NotificationPreference> {
const db = getDb();
const existing = await findByUserIdAndType(userId, row.type as string);
if (existing) {
await db
.update(notificationPreferences)
.set({
channels: row.channels,
frequencyLimit: row.frequencyLimit,
quietHoursStart: row.quietHoursStart,
quietHoursEnd: row.quietHoursEnd,
quietHoursTimezone: row.quietHoursTimezone,
enabled: row.enabled,
})
.where(eq(notificationPreferences.id, existing.id));
return { ...existing, ...row } as NotificationPreference;
}
const newPref: NewNotificationPreference = {
id: createId(),
userId,
...row,
};
await db.insert(notificationPreferences).values(newPref);
return newPref as NotificationPreference;
}
export async function deleteByUserId(userId: string): Promise<void> {
const db = getDb();
await db
.delete(notificationPreferences)
.where(eq(notificationPreferences.userId, userId));
}

View File

@@ -0,0 +1,34 @@
import { Injectable } from "@nestjs/common";
import type { NotificationPreference } from "../notifications/notifications.schema.js";
import * as repo from "./preferences.repository.js";
import type { UpdatePreferencesDto } from "./preferences.dto.js";
/**
* PreferenceService —— 用户通知偏好管理。
*
* 职责:
* - 查询用户偏好列表
* - 批量 upsert 用户偏好(按 type 隔离)
*
* 仲裁依据 02-architecture-design.md §2.3:偏好按 (userId, type) 隔离。
*/
@Injectable()
export class PreferencesService {
async getByUserId(userId: string): Promise<NotificationPreference[]> {
return repo.findByUserId(userId);
}
async update(dto: UpdatePreferencesDto): Promise<void> {
for (const pref of dto.preferences) {
await repo.upsert(dto.userId, {
type: pref.type,
channels: pref.channels,
frequencyLimit: pref.frequencyLimit ?? null,
quietHoursStart: pref.quietHoursStart ?? null,
quietHoursEnd: pref.quietHoursEnd ?? null,
quietHoursTimezone: pref.quietHoursTimezone ?? "Asia/Shanghai",
enabled: pref.enabled,
});
}
}
}

View File

@@ -1,11 +1,11 @@
export type ErrorType =
| 'validation'
| 'not_found'
| 'permission_denied'
| 'conflict'
| 'business'
| 'database'
| 'internal';
| "validation"
| "not_found"
| "permission_denied"
| "conflict"
| "business"
| "database"
| "internal";
export interface ErrorDetails {
[key: string]: unknown;
@@ -40,57 +40,135 @@ export abstract class ApplicationError extends Error {
}
export class ValidationError extends ApplicationError {
readonly type = 'validation' as const;
readonly type = "validation" as const;
readonly statusCode = 400;
constructor(message: string, details?: ErrorDetails) {
super(message, 'MSG_VALIDATION_ERROR', details);
super(message, "MSG_VALIDATION_ERROR", details);
}
}
export class NotFoundError extends ApplicationError {
readonly type = 'not_found' as const;
readonly type = "not_found" as const;
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, 'MSG_NOT_FOUND', { resource, id });
super(`${resource} not found: ${id}`, "MSG_NOT_FOUND", { resource, id });
}
}
export class PermissionDeniedError extends ApplicationError {
readonly type = 'permission_denied' as const;
readonly type = "permission_denied" as const;
readonly statusCode = 403;
constructor(permission: string) {
super(`Permission denied: ${permission}`, 'MSG_PERMISSION_DENIED', { permission });
super(`Permission denied: ${permission}`, "MSG_PERMISSION_DENIED", {
permission,
});
}
}
export class ConflictError extends ApplicationError {
readonly type = 'conflict' as const;
readonly type = "conflict" as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, 'MSG_CONFLICT', details);
super(message, "MSG_CONFLICT", details);
}
}
export class BusinessError extends ApplicationError {
readonly type = 'business' as const;
readonly type = "business" as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, 'MSG_BUSINESS_ERROR', details);
super(message, "MSG_BUSINESS_ERROR", details);
}
}
export class DatabaseError extends ApplicationError {
readonly type = 'database' as const;
readonly type = "database" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, 'MSG_DATABASE_ERROR', details);
super(message, "MSG_DATABASE_ERROR", details);
}
}
export class InternalError extends ApplicationError {
readonly type = 'internal' as const;
readonly type = "internal" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, 'MSG_INTERNAL_ERROR', details);
super(message, "MSG_INTERNAL_ERROR", details);
}
}
/**
* ES 不可用且无降级路径503
* 仲裁依据02-architecture-design.md §6.2
*/
export class EsUnavailableError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 503;
constructor(message: string, details?: ErrorDetails) {
super(message, "MSG_ES_UNAVAILABLE", details);
}
}
/**
* Redis 不可用且无降级路径503
* 仲裁依据02-architecture-design.md §6.2
*/
export class RedisUnavailableError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 503;
constructor(message: string, details?: ErrorDetails) {
super(message, "MSG_REDIS_UNAVAILABLE", details);
}
}
/**
* Push Gateway 不可用503
* 仲裁依据02-architecture-design.md §6.2 + coord M4软失败仅必要时抛出
*/
export class PushGatewayUnavailableError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 503;
constructor(message: string, details?: ErrorDetails) {
super(message, "MSG_PUSH_GATEWAY_UNAVAILABLE", details);
}
}
/**
* 通知模板不存在404
* 仲裁依据02-architecture-design.md §6.2
*/
export class TemplateNotFoundError extends ApplicationError {
readonly type = "not_found" as const;
readonly statusCode = 404;
constructor(templateCode: string) {
super(
`Notification template not found: ${templateCode}`,
"MSG_TEMPLATE_NOT_FOUND",
{ templateCode },
);
}
}
/**
* 模板渲染失败变量缺失等422
* 仲裁依据02-architecture-design.md §6.2
*/
export class TemplateRenderError extends ApplicationError {
readonly type = "business" as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, "MSG_TEMPLATE_RENDER_ERROR", details);
}
}
/**
* 通知频率超限429
* 仲裁依据02-architecture-design.md §6.2NotificationPreference.frequency_limit 预留)
*/
export class RateLimitExceededError extends ApplicationError {
readonly type = "business" as const;
readonly statusCode = 429;
constructor(message: string, details?: ErrorDetails) {
super(message, "MSG_RATE_LIMIT_EXCEEDED", details);
}
}

View File

@@ -1,17 +1,43 @@
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { db } from "../../config/database.js";
import { getDb } from "../../config/database.js";
import { esClient } from "../../config/elasticsearch.js";
import { getRedis } from "../redis/redis.client.js";
import { isKafkaHealthy } from "../kafka/kafka.client.js";
import { checkPushGateway } from "../push/push-gateway.client.js";
const SERVICE_NAME = "msg";
/**
* 健康检查端点。
*
* - GET /healthzliveness仅返回进程存活不检查依赖
* - GET /readyzreadiness检查 DB 连接Drizzle `SELECT 1`),失败返回 503。
* 仲裁依据 G2/readyz 多依赖检查DB/ES/Redis/Kafka/PushGateway
*
* 不需要鉴权,必须在路由白名单中放行
* - GET /healthzliveness仅返回进程存活不检查依赖
* - GET /readyzreadiness检查 DB关键+ ES/Redis/Kafka/PushGateway非关键
* DB 不可用返回 503其他依赖降级标记但不影响 readyz 状态码。
*/
type CheckStatus = "ok" | "disabled" | "error";
interface DependencyCheck {
status: CheckStatus;
error?: string;
}
interface ReadyzResponse {
status: "ok" | "degraded";
service: string;
timestamp: string;
checks: {
database: DependencyCheck;
elasticsearch: DependencyCheck;
redis: DependencyCheck;
kafka: DependencyCheck;
pushGateway: DependencyCheck;
};
}
@Controller()
export class HealthController {
@Get("healthz")
@@ -24,29 +50,88 @@ export class HealthController {
}
@Get("readyz")
async readiness(): Promise<{
status: string;
service: string;
timestamp: string;
}> {
async readiness(): Promise<ReadyzResponse> {
const checks: ReadyzResponse["checks"] = {
database: { status: "ok" },
elasticsearch: { status: "disabled" },
redis: { status: "disabled" },
kafka: { status: "ok" },
pushGateway: { status: "disabled" },
};
// 1. DB关键依赖
try {
const db = getDb();
await db.execute(sql`SELECT 1`);
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
} catch (error) {
checks.database = {
status: "error",
error: error instanceof Error ? error.message : "database unreachable",
};
}
// 2. Elasticsearch可选依赖
if (esClient) {
try {
await esClient.ping();
checks.elasticsearch = { status: "ok" };
} catch (error) {
checks.elasticsearch = {
status: "error",
error: error instanceof Error ? error.message : "ES unreachable",
};
}
}
// 3. Redis可选依赖
const redis = getRedis();
if (redis) {
try {
const pong = await redis.ping();
checks.redis = { status: pong === "PONG" ? "ok" : "error" };
} catch (error) {
checks.redis = {
status: "error",
error: error instanceof Error ? error.message : "Redis unreachable",
};
}
}
// 4. Kafka非关键依赖降级模式
checks.kafka = { status: isKafkaHealthy() ? "ok" : "error" };
// 5. PushGateway软失败可选依赖
try {
const pushOk = await checkPushGateway();
checks.pushGateway = { status: pushOk ? "ok" : "error" };
} catch {
checks.pushGateway = {
status: "error",
error: "PushGateway unreachable",
};
}
// DB 不可用 -> 503其他依赖降级 -> 200 + degraded 标记
const dbOk = checks.database.status === "ok";
const allOk = dbOk && Object.values(checks).every((c) => c.status === "ok");
if (!dbOk) {
throw new HttpException(
{
status: "error",
status: "unavailable",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
error:
error instanceof Error ? error.message : "database unreachable",
checks,
},
HttpStatus.SERVICE_UNAVAILABLE,
);
}
return {
status: allOk ? "ok" : "degraded",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
checks,
};
}
}

View File

@@ -0,0 +1,88 @@
import { Kafka, type Consumer, type Producer } from "kafkajs";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
/**
* msg 服务 Kafka 客户端。
*
* 仲裁依据:
* - producer 配置 idempotent=true + transactionalIdat-least-once + 有序)
* - consumer 使用 KAFKA_CONSUMER_GROUP_ID 隔离消费组
* - Kafka 不可用时服务仍启动降级模式outbox 消息暂存待发)
*
* 使用 lazy initialization连接在首次 connect 时建立,
* 避免模块导入副作用导致测试环境难以 mock。
*/
let kafkaInstance: Kafka | null = null;
let producerInstance: Producer | null = null;
let consumerInstance: Consumer | null = null;
let kafkaHealthy = false;
function getKafka(): Kafka {
if (!kafkaInstance) {
kafkaInstance = new Kafka({
brokers: env.KAFKA_BROKERS.split(","),
clientId: env.KAFKA_CLIENT_ID,
});
}
return kafkaInstance;
}
export function getProducer(): Producer {
if (!producerInstance) {
producerInstance = getKafka().producer({
idempotent: true,
transactionalId: "msg-service-tx",
});
}
return producerInstance;
}
export function getConsumer(): Consumer {
if (!consumerInstance) {
consumerInstance = getKafka().consumer({
groupId: env.KAFKA_CONSUMER_GROUP_ID,
sessionTimeout: 30000,
rebalanceTimeout: 60000,
});
}
return consumerInstance;
}
export async function connectKafka(): Promise<void> {
try {
await getProducer().connect();
await getConsumer().connect();
kafkaHealthy = true;
logger.info(
{ brokers: env.KAFKA_BROKERS, clientId: env.KAFKA_CLIENT_ID },
"Kafka connected",
);
} catch (err) {
kafkaHealthy = false;
logger.warn(
{ err },
"Kafka connect failed, running without Kafka (outbox will buffer)",
);
}
}
/** /readyz 健康检查Kafka producer+consumer 是否已连接 */
export function isKafkaHealthy(): boolean {
return kafkaHealthy;
}
export async function disconnectKafka(): Promise<void> {
try {
if (consumerInstance) {
await consumerInstance.disconnect();
}
if (producerInstance) {
await producerInstance.disconnect();
}
kafkaHealthy = false;
logger.info("Kafka disconnected");
} catch (err) {
logger.warn({ err }, "Kafka disconnect error (ignored on shutdown)");
}
}

View File

@@ -0,0 +1,420 @@
import {
Injectable,
type OnModuleDestroy,
type OnModuleInit,
} from "@nestjs/common";
import type { KafkaMessage } from "kafkajs";
import { connectKafka, getConsumer } from "./kafka.client.js";
import { CONSUMER_TOPICS } from "./topic-map.js";
import { checkAndMark } from "../redis/idempotency.guard.js";
import { logger } from "../observability/logger.js";
import { NotificationsService } from "../../notifications/notifications.service.js";
/**
* KafkaConsumer —— 消费 iam/core-edu/data-ana 事件,触发通知。
*
* 仲裁依据:
* - 02-architecture-design.md §5.112 类消费事件)
* - JSON payload 降级events.proto 未定义全部事件类型,直接解析 JSON
* - 幂等event_id 去重Redis SETNX + DB 降级)
* - at-least-once消费失败不 commit offsetKafka 重投
*
* 事件路由:
* - iamuser.created/updated/deleted/role_changed, role.created/updated
* - core-eduexam.published, assignment.submitted/graded, grade.recorded, attendance.recorded
* - data-anamastery.updated
*/
@Injectable()
export class KafkaConsumerService implements OnModuleInit, OnModuleDestroy {
private running = false;
constructor(private readonly notificationsService: NotificationsService) {}
async onModuleInit(): Promise<void> {
await this.start();
}
async onModuleDestroy(): Promise<void> {
await this.stop();
}
async start(): Promise<void> {
if (this.running) return;
// 确保 Kafka producer+consumer 已连接幂等connectKafka 内部处理重复调用)
await connectKafka();
const consumer = getConsumer();
try {
await consumer.subscribe({
topics: [...CONSUMER_TOPICS],
fromBeginning: false,
});
this.running = true;
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
await this.handleMessage(topic, partition, message);
},
});
logger.info({ topics: CONSUMER_TOPICS }, "KafkaConsumer started");
} catch (err) {
logger.warn(
{ err },
"KafkaConsumer start failed (running without consumer)",
);
}
}
async stop(): Promise<void> {
this.running = false;
try {
const consumer = getConsumer();
await consumer.stop();
await consumer.disconnect();
logger.info("KafkaConsumer stopped");
} catch (err) {
logger.warn({ err }, "KafkaConsumer stop error");
}
}
private async handleMessage(
topic: string,
partition: number,
message: KafkaMessage,
): Promise<void> {
const eventId = this.extractEventId(message, topic, partition);
const payload = this.parsePayload(message);
if (!payload) {
logger.warn({ topic, eventId }, "Failed to parse Kafka message payload");
return;
}
// 幂等检查
const { isFirst } = await checkAndMark(eventId, topic);
if (!isFirst) {
logger.debug(
{ topic, eventId },
"Event already processed (idempotent skip)",
);
return;
}
// 路由到处理器
try {
await this.routeEvent(topic, eventId, payload);
logger.info({ topic, eventId }, "Kafka event processed");
} catch (err) {
logger.error({ topic, eventId, err }, "Failed to process Kafka event");
// 不抛出at-least-once 语义下,失败的 event 已标记 processed
// 后续靠人工或监控重处理(避免无限重试阻塞消费)
}
}
private extractEventId(
message: KafkaMessage,
topic: string,
partition: number,
): string {
// 优先从 headers 取 eventId
const headerValue = message.headers?.eventId;
if (headerValue) {
if (typeof headerValue === "string") {
return headerValue;
}
// KafkaJS header 可能是 Buffer 或 (string|Buffer)[],取首元素
const buf = Array.isArray(headerValue) ? headerValue[0] : headerValue;
if (buf) {
return Buffer.from(buf).toString("utf-8");
}
}
// 降级topic + partition + offset 组合
return `${topic}:${partition}:${message.offset}`;
}
/**
* JSON payload 降级events.proto 未定义全部事件类型,
* 直接解析 JSON。若解析失败返回 null。
*/
private parsePayload(message: KafkaMessage): Record<string, unknown> | null {
try {
const value = message.value;
if (!value) return null;
const str =
typeof value === "string"
? value
: Buffer.from(value).toString("utf-8");
return JSON.parse(str) as Record<string, unknown>;
} catch {
return null;
}
}
/**
* 根据 topic 路由到具体的事件处理器。
*/
private async routeEvent(
topic: string,
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
switch (topic) {
// iam 事件
case "edu.identity.user.created":
await this.handleUserCreated(eventId, payload);
break;
case "edu.identity.user.role_changed":
await this.handleRoleChanged(eventId, payload);
break;
case "edu.identity.role.updated":
await this.handleRoleUpdated(eventId, payload);
break;
case "edu.identity.user.updated":
case "edu.identity.user.deleted":
case "edu.identity.role.created":
// 无需通知,仅幂等标记
logger.debug(
{ topic, eventId },
"Event acknowledged (no notification)",
);
break;
// core-edu 事件
case "edu.teaching.exam.published":
await this.handleExamPublished(eventId, payload);
break;
case "edu.teaching.assignment.submitted":
await this.handleAssignmentSubmitted(eventId, payload);
break;
case "edu.teaching.assignment.graded":
await this.handleAssignmentGraded(eventId, payload);
break;
case "edu.teaching.grade.recorded":
await this.handleGradeRecorded(eventId, payload);
break;
case "edu.teaching.attendance.recorded":
await this.handleAttendanceRecorded(eventId, payload);
break;
// data-ana 事件
case "edu.insight.mastery.updated":
await this.handleMasteryUpdated(eventId, payload);
break;
default:
logger.warn({ topic, eventId }, "Unknown topic, skipping");
}
}
// ============================================================
// 事件处理器(每个创建对应通知)
// ============================================================
private async handleUserCreated(
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
const userId = String(payload.userId ?? payload.user_id ?? "");
if (!userId) return;
const name = String(payload.name ?? payload.username ?? "新用户");
await this.notificationsService.send({
userId,
type: "system",
title: "欢迎加入 Edu 云课堂",
content: `你好 ${name},欢迎加入 Edu 云课堂!开始你的学习之旅吧。`,
channel: "in_app",
eventId,
metadata: { source: "iam", event: "user.created" },
});
}
private async handleRoleChanged(
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
const userId = String(payload.userId ?? payload.user_id ?? "");
if (!userId) return;
const oldRole = String(payload.oldRole ?? payload.old_role ?? "未知");
const newRole = String(payload.newRole ?? payload.new_role ?? "未知");
await this.notificationsService.send({
userId,
type: "system",
title: "角色变更通知",
content: `你的角色已从「${oldRole}」变更为「${newRole}`,
channel: "in_app",
eventId,
metadata: { source: "iam", event: "user.role_changed" },
});
}
private async handleRoleUpdated(
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
const affectedUserIds =
payload.affectedUserIds ?? payload.affected_user_ids;
if (!Array.isArray(affectedUserIds)) return;
for (const userId of affectedUserIds) {
await this.notificationsService.send({
userId: String(userId),
type: "system",
title: "权限变更通知",
content: "你的角色权限已更新,请查看最新权限。",
channel: "in_app",
eventId: `${eventId}:${userId}`,
metadata: { source: "iam", event: "role.updated" },
});
}
}
private async handleExamPublished(
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
const studentIds = payload.studentIds ?? payload.student_ids;
if (!Array.isArray(studentIds)) return;
const examTitle = String(payload.examTitle ?? payload.exam_title ?? "考试");
const className = String(payload.className ?? payload.class_name ?? "");
for (const userId of studentIds) {
await this.notificationsService.send({
userId: String(userId),
type: "exam",
title: "新考试通知",
content: `${className}」班级发布了新考试:${examTitle}`,
channel: "in_app",
groupId: eventId,
eventId: `${eventId}:${userId}`,
relatedEntityType: "exam",
relatedEntityId: String(payload.examId ?? payload.exam_id ?? ""),
metadata: { source: "core-edu", event: "exam.published" },
});
}
}
private async handleAssignmentSubmitted(
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
const teacherId = String(payload.teacherId ?? payload.teacher_id ?? "");
if (!teacherId) return;
const studentName = String(
payload.studentName ?? payload.student_name ?? "学生",
);
const homeworkTitle = String(
payload.homeworkTitle ?? payload.homework_title ?? "作业",
);
await this.notificationsService.send({
userId: teacherId,
type: "homework",
title: "作业提交通知",
content: `${studentName} 提交了作业:${homeworkTitle}`,
channel: "in_app",
eventId,
relatedEntityType: "homework",
relatedEntityId: String(payload.homeworkId ?? payload.homework_id ?? ""),
metadata: { source: "core-edu", event: "assignment.submitted" },
});
}
private async handleAssignmentGraded(
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
const studentId = String(payload.studentId ?? payload.student_id ?? "");
if (!studentId) return;
const homeworkTitle = String(
payload.homeworkTitle ?? payload.homework_title ?? "作业",
);
const score = payload.score ?? payload.grade;
await this.notificationsService.send({
userId: studentId,
type: "grade",
title: "作业批改通知",
content: `你的作业「${homeworkTitle}」已批改${score ? `,得分:${score}` : ""}`,
channel: "in_app",
eventId,
relatedEntityType: "homework",
relatedEntityId: String(payload.homeworkId ?? payload.homework_id ?? ""),
metadata: { source: "core-edu", event: "assignment.graded" },
});
}
private async handleGradeRecorded(
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
const studentId = String(payload.studentId ?? payload.student_id ?? "");
if (!studentId) return;
const subject = String(payload.subject ?? "科目");
const score = payload.score ?? payload.grade;
await this.notificationsService.send({
userId: studentId,
type: "grade",
title: "成绩录入通知",
content: `你的${subject}成绩已录入${score ? `${score}` : ""}`,
channel: "in_app",
eventId,
relatedEntityType: "grade",
relatedEntityId: String(payload.gradeId ?? payload.grade_id ?? ""),
metadata: { source: "core-edu", event: "grade.recorded" },
});
}
private async handleAttendanceRecorded(
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
const parentId = String(payload.parentId ?? payload.parent_id ?? "");
if (!parentId) return;
const studentName = String(
payload.studentName ?? payload.student_name ?? "学生",
);
const status = String(payload.status ?? "缺勤");
const date = String(payload.date ?? "");
await this.notificationsService.send({
userId: parentId,
type: "attendance",
title: "出勤异常通知",
content: `${studentName}${date} 的出勤状态为:${status}`,
channel: "in_app",
eventId,
relatedEntityType: "attendance",
relatedEntityId: String(
payload.attendanceId ?? payload.attendance_id ?? "",
),
metadata: { source: "core-edu", event: "attendance.recorded" },
});
}
private async handleMasteryUpdated(
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
const studentId = String(payload.studentId ?? payload.student_id ?? "");
if (!studentId) return;
const subject = String(payload.subject ?? "科目");
const mastery = payload.mastery ?? payload.masteryLevel;
const trend = String(payload.trend ?? "下降");
await this.notificationsService.send({
userId: studentId,
type: "mastery",
title: "学情预警通知",
content: `你的${subject}掌握度${trend}(当前:${mastery ?? "未知"}),建议加强复习`,
channel: "in_app",
eventId,
relatedEntityType: "mastery",
relatedEntityId: String(payload.masteryId ?? payload.mastery_id ?? ""),
metadata: { source: "data-ana", event: "mastery.updated" },
});
}
}

View File

@@ -0,0 +1,62 @@
/**
* msg 服务 Kafka Topic 映射。
*
* 仲裁依据:
* - M5消费 topic 用 `edu.teaching.*` / `edu.identity.*` / `edu.insight.*`
* - 发布 topic 用 `edu.notification.*`02-architecture-design.md §5.2
*
* PRODUCER_TOPIC_MAPeventType → topicOutboxPublisher 按此路由发布。
* CONSUMER_TOPICSmsg 消费的所有 topic 列表KafkaConsumer 订阅。
*/
/** 生产者eventType → Kafka topic 路由 */
export const PRODUCER_TOPIC_MAP: Record<string, string> = {
"notification.sent": "edu.notification.sent",
"notification.read": "edu.notification.read",
"notification.recalled": "edu.notification.recalled",
"notification.failed": "edu.notification.failed",
};
/** 兜底 topic未在 TOPIC_MAP 命中的 eventType 走此 topic */
export const FALLBACK_TOPIC = "edu.notification.events";
/** 消费者:订阅的 topic 列表iam 6 + core-edu 5 + data-ana 1 = 12 类事件) */
export const CONSUMER_TOPICS: readonly string[] = [
// iamidentity
"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",
// core-eduteaching
"edu.teaching.exam.published",
"edu.teaching.assignment.submitted",
"edu.teaching.assignment.graded",
"edu.teaching.grade.recorded",
"edu.teaching.attendance.recorded",
// data-anainsight
"edu.insight.mastery.updated",
] as const;
/** 消费事件 → 通知类型 映射KafkaConsumer 路由用) */
export const CONSUMER_EVENT_TYPE_MAP: Record<string, string> = {
"edu.identity.user.created": "system",
"edu.identity.user.role_changed": "system",
"edu.identity.role.created": "system",
"edu.identity.role.updated": "system",
"edu.teaching.exam.published": "exam",
"edu.teaching.assignment.submitted": "homework",
"edu.teaching.assignment.graded": "grade",
"edu.teaching.grade.recorded": "grade",
"edu.teaching.attendance.recorded": "attendance",
"edu.insight.mastery.updated": "mastery",
};
/**
* 根据 eventType 解析目标 topic。
* 未命中时降级到 FALLBACK_TOPIC。
*/
export function resolveTopic(eventType: string): string {
return PRODUCER_TOPIC_MAP[eventType] ?? FALLBACK_TOPIC;
}

View File

@@ -6,22 +6,42 @@ import {
} from "@nestjs/common";
import { closeDb } from "../../config/database.js";
import { closeEs } from "../../config/elasticsearch.js";
import { connectKafka, disconnectKafka } from "../kafka/kafka.client.js";
import { closeRedis } from "../redis/redis.client.js";
import { outboxPublisher } from "../outbox/outbox.publisher.js";
const SERVICE_NAME = "msg";
/**
* 优雅停机服务。
* 优雅停机服务 + 资源生命周期管理
*
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
* 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。
* 仲裁依据 G2统一管理 DB/ES/Redis/Kafka/OutboxPublisher 生命周期。
*
* 关闭顺序ES → Drizzle。先关搜索索引避免新数据丢失再关 DB。
* 启动顺序onModuleInit
* 1. connectKafka():连接 producer + consumer幂等KafkaConsumerService 也会调用)
* 2. outboxPublisher.start():启动轮询 worker投递 pending 事件到 Kafka
*
* 关闭顺序onApplicationShutdown在 OnModuleDestroy 之后执行):
* 1. outboxPublisher.stop():停止轮询
* 2. disconnectKafka():断开 producer + consumer
* 3. closeRedis():关闭 Redis 连接
* 4. closeEs():关闭 ES 客户端
* 5. closeDb():关闭 MySQL 连接池
*
* 注KafkaConsumerService 实现 OnModuleDestroy其 stop() 会在
* onApplicationShutdown 之前被 NestJS 调用。disconnectKafka() 是幂等的。
*/
@Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name);
onModuleInit(): void {
async onModuleInit(): Promise<void> {
// 1. 连接 Kafkaproducer + consumer
await connectKafka();
// 2. 启动 Outbox publisher 轮询
await outboxPublisher.start();
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
@@ -30,15 +50,19 @@ export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
);
await this.safeDisconnect("elasticsearch", () => closeEs());
await this.safeDisconnect("drizzle", () => closeDb());
// 按依赖反序关闭
await this.safeStop("outboxPublisher", () => outboxPublisher.stop());
await this.safeStop("kafka", () => disconnectKafka());
await this.safeStop("redis", () => closeRedis());
await this.safeStop("elasticsearch", () => closeEs());
await this.safeStop("drizzle", () => closeDb());
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
}
private async safeDisconnect(
private async safeStop(
name: string,
fn: () => Promise<void>,
fn: () => Promise<unknown>,
): Promise<void> {
try {
await fn();

View File

@@ -4,25 +4,234 @@ const registry = new promClient.Registry();
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register
registry.setDefaultLabels({ service: "msg" });
// ============================================================
// HTTP 指标02-architecture-design.md §6.4
// ============================================================
registry.registerMetric(
new promClient.Counter({
name: "msg_requests_total",
help: "Total number of msg requests",
labelNames: ["method", "endpoint", "status"],
name: "msg_http_requests_total",
help: "Total number of msg HTTP requests",
labelNames: ["method", "route", "status_code"],
}),
);
registry.registerMetric(
new promClient.Histogram({
name: "msg_request_duration_seconds",
help: "Msg request duration in seconds",
labelNames: ["method", "endpoint"],
name: "msg_http_request_duration_seconds",
help: "Msg HTTP request duration in seconds",
labelNames: ["method", "route"],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
// ============================================================
// gRPC 指标
// ============================================================
registry.registerMetric(
new promClient.Counter({
name: "msg_grpc_requests_total",
help: "Total number of msg gRPC requests",
labelNames: ["rpc_method", "status"],
}),
);
// ============================================================
// 业务指标:通知发送
// ============================================================
registry.registerMetric(
new promClient.Counter({
name: "msg_notification_sent_total",
help: "Total number of notifications sent",
labelNames: ["type", "channel", "status"],
}),
);
registry.registerMetric(
new promClient.Histogram({
name: "msg_notification_send_duration_seconds",
help: "Notification send duration in seconds",
labelNames: ["type", "channel"],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
registry.registerMetric(
new promClient.Counter({
name: "msg_notification_failed_total",
help: "Total number of notification send failures",
labelNames: ["type", "channel", "error_code"],
}),
);
// ============================================================
// 业务指标:通知已读
// ============================================================
registry.registerMetric(
new promClient.Counter({
name: "msg_notification_read_total",
help: "Total number of notifications marked as read",
labelNames: ["type"],
}),
);
// ============================================================
// 业务指标:渠道分发
// ============================================================
registry.registerMetric(
new promClient.Histogram({
name: "msg_channel_dispatch_duration_seconds",
help: "Channel dispatch duration in seconds",
labelNames: ["channel"],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
// ============================================================
// Kafka 消费指标
// ============================================================
registry.registerMetric(
new promClient.Gauge({
name: "msg_kafka_consumer_lag",
help: "Kafka consumer lag (offset difference)",
labelNames: ["topic"],
}),
);
registry.registerMetric(
new promClient.Counter({
name: "msg_kafka_consumer_processed_total",
help: "Total number of Kafka messages processed",
labelNames: ["topic", "status"],
}),
);
// ============================================================
// 幂等去重指标
// ============================================================
registry.registerMetric(
new promClient.Counter({
name: "msg_idempotent_duplicate_total",
help: "Total number of idempotent duplicate hits",
labelNames: ["topic"],
}),
);
// ============================================================
// Outbox 指标
// ============================================================
registry.registerMetric(
new promClient.Gauge({
name: "msg_outbox_pending_count",
help: "Number of pending outbox events",
}),
);
// ============================================================
// Push Gateway 指标
// ============================================================
registry.registerMetric(
new promClient.Counter({
name: "msg_push_gateway_call_total",
help: "Total number of Push Gateway calls",
labelNames: ["status"],
}),
);
registry.registerMetric(
new promClient.Histogram({
name: "msg_push_gateway_call_duration_seconds",
help: "Push Gateway call duration in seconds",
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
// ============================================================
// 业务状态指标
// ============================================================
registry.registerMetric(
new promClient.Gauge({
name: "msg_unread_count",
help: "User unread notification count (sampled)",
labelNames: ["user_id"],
}),
);
// 自动收集 Node.js 进程级指标CPU/内存/事件循环/GC等
// 这些指标无需业务代码埋点prom-client 自动采集
promClient.collectDefaultMetrics({ register: registry });
export { registry as metricsRegistry };
// ============================================================
// 便捷访问器:业务代码通过命名导出获取已注册的 Metric 实例
// ============================================================
export const metricsRegistry = registry;
export const httpRequestsTotal = registry.getSingleMetric(
"msg_http_requests_total",
) as promClient.Counter<string>;
export const httpRequestDurationSeconds = registry.getSingleMetric(
"msg_http_request_duration_seconds",
) as promClient.Histogram<string>;
export const grpcRequestsTotal = registry.getSingleMetric(
"msg_grpc_requests_total",
) as promClient.Counter<string>;
export const notificationSentTotal = registry.getSingleMetric(
"msg_notification_sent_total",
) as promClient.Counter<string>;
export const notificationSendDurationSeconds = registry.getSingleMetric(
"msg_notification_send_duration_seconds",
) as promClient.Histogram<string>;
export const notificationFailedTotal = registry.getSingleMetric(
"msg_notification_failed_total",
) as promClient.Counter<string>;
export const notificationReadTotal = registry.getSingleMetric(
"msg_notification_read_total",
) as promClient.Counter<string>;
export const channelDispatchDurationSeconds = registry.getSingleMetric(
"msg_channel_dispatch_duration_seconds",
) as promClient.Histogram<string>;
export const kafkaConsumerLag = registry.getSingleMetric(
"msg_kafka_consumer_lag",
) as promClient.Gauge<string>;
export const kafkaConsumerProcessedTotal = registry.getSingleMetric(
"msg_kafka_consumer_processed_total",
) as promClient.Counter<string>;
export const idempotentDuplicateTotal = registry.getSingleMetric(
"msg_idempotent_duplicate_total",
) as promClient.Counter<string>;
export const outboxPendingCount = registry.getSingleMetric(
"msg_outbox_pending_count",
) as promClient.Gauge<string>;
export const pushGatewayCallTotal = registry.getSingleMetric(
"msg_push_gateway_call_total",
) as promClient.Counter<string>;
export const pushGatewayCallDurationSeconds = registry.getSingleMetric(
"msg_push_gateway_call_duration_seconds",
) as promClient.Histogram<string>;
export const unreadCount = registry.getSingleMetric(
"msg_unread_count",
) as promClient.Gauge<string>;

View File

@@ -0,0 +1,125 @@
import { logger } from "../observability/logger.js";
import { getProducer } from "../kafka/kafka.client.js";
import { resolveTopic } from "../kafka/topic-map.js";
import {
findPending,
incrementRetry,
markFailed,
markPublished,
} from "./outbox.repository.js";
import type { OutboxEvent } from "./outbox.schema.js";
/**
* OutboxPublisher —— 轮询 pending 记录并投递到 Kafka多 topic 路由)。
*
* 参照 core-edu OutboxPublisher 模式,支持 TOPIC_MAP 多 topic 路由:
* - notification.sent → edu.notification.sent
* - notification.read → edu.notification.read
* - notification.recalled → edu.notification.recalled
* - notification.failed → edu.notification.failed
*
* 仲裁依据at-least-once 投递 + 指数退避重试 + 幂等(消费端 event_id 去重)。
*/
const POLL_INTERVAL_MS = 5000;
const BATCH_SIZE = 100;
const MAX_RETRY = 5;
const RETRY_BACKOFF_MS = 2000;
class OutboxPublisher {
private intervalId: ReturnType<typeof setInterval> | null = null;
private isPolling = false;
async start(): Promise<void> {
if (this.intervalId) return;
logger.info(
{ pollIntervalMs: POLL_INTERVAL_MS, batchSize: BATCH_SIZE },
"OutboxPublisher started",
);
this.intervalId = setInterval(() => {
void this.poll();
}, POLL_INTERVAL_MS);
}
async stop(): Promise<void> {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
logger.info("OutboxPublisher stopped");
}
private async poll(): Promise<void> {
if (this.isPolling) return;
this.isPolling = true;
try {
const messages = await findPending(BATCH_SIZE);
for (const message of messages) {
await this.dispatch(message);
}
} catch (error) {
logger.error({ error }, "Outbox poll failed");
} finally {
this.isPolling = false;
}
}
private async dispatch(message: OutboxEvent): Promise<void> {
const topic = resolveTopic(message.eventType);
try {
const producer = getProducer();
await producer.send({
topic,
messages: [
{
key: message.aggregateId,
value:
typeof message.payload === "string"
? message.payload
: JSON.stringify(message.payload),
headers: this.buildHeaders(message),
},
],
});
await markPublished(message.eventId);
logger.info(
{ eventId: message.eventId, eventType: message.eventType, topic },
"Outbox message published",
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(
{
eventId: message.eventId,
eventType: message.eventType,
error: errorMessage,
},
"Outbox publish failed",
);
if (message.retryCount + 1 >= MAX_RETRY) {
await markFailed(message.eventId, errorMessage);
} else {
const backoffMs = RETRY_BACKOFF_MS * 2 ** message.retryCount;
await incrementRetry(message.eventId, errorMessage, backoffMs);
}
}
}
private buildHeaders(message: OutboxEvent): Record<string, string> {
const headers: Record<string, string> = {
eventId: message.eventId,
eventType: message.eventType,
aggregateType: message.aggregateType,
aggregateId: message.aggregateId,
};
if (message.metadata) {
for (const [key, value] of Object.entries(message.metadata)) {
headers[key] = value;
}
}
return headers;
}
}
export const outboxPublisher = new OutboxPublisher();

View File

@@ -0,0 +1,63 @@
import { and, asc, eq, isNull, lte, or, sql, type SQL } from "drizzle-orm";
import { getDb } from "../../config/database.js";
import { outboxEvents, type OutboxEvent } from "./outbox.schema.js";
/**
* Outbox 数据访问层。
*
* 参照 core-edu outbox.repository 模式:
* - findPending拉取到期 pending 记录nextRetryAt IS NULL 或 <= now
* - markPublished投递成功后标记
* - markFailed重试耗尽标记 failed
* - incrementRetry失败时重试计数 +1 + 退避
*/
export async function findPending(batchSize: number): Promise<OutboxEvent[]> {
const db = getDb();
const now = new Date();
const where: SQL | undefined = and(
eq(outboxEvents.status, "pending"),
or(isNull(outboxEvents.nextRetryAt), lte(outboxEvents.nextRetryAt, now)),
);
return db
.select()
.from(outboxEvents)
.where(where)
.orderBy(asc(outboxEvents.createdAt))
.limit(batchSize);
}
export async function markPublished(eventId: string): Promise<void> {
const db = getDb();
await db
.update(outboxEvents)
.set({ status: "published", publishedAt: new Date(), lastError: null })
.where(eq(outboxEvents.eventId, eventId));
}
export async function markFailed(
eventId: string,
error: string,
): Promise<void> {
const db = getDb();
await db
.update(outboxEvents)
.set({ status: "failed", lastError: error })
.where(eq(outboxEvents.eventId, eventId));
}
export async function incrementRetry(
eventId: string,
error: string,
backoffMs: number,
): Promise<void> {
const db = getDb();
const nextRetryAt = new Date(Date.now() + backoffMs);
await db
.update(outboxEvents)
.set({
retryCount: sql`${outboxEvents.retryCount} + 1`,
nextRetryAt,
lastError: error,
})
.where(eq(outboxEvents.eventId, eventId));
}

View File

@@ -0,0 +1,66 @@
import {
int,
json,
mysqlTable,
text,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
/**
* msg 服务 Outbox + 消费幂等 Schema。
*
* 仲裁依据:
* - 02-architecture-design.md §3.1.4msg_outbox_events
* - 02-architecture-design.md §3.1.5processed_events
* - G11ID 用 cuid2varchar(32)
*
* 参照 core-edu OutboxPublisher 模式:本地 TOPIC_MAP 多 topic 路由。
*/
/** Outbox 记录状态机 */
export type OutboxStatus = "pending" | "published" | "failed";
/**
* msg_outbox_events 表:事务性 Outbox。
*
* 业务事务内写入OutboxPublisher 轮询 pending 记录投递到 Kafka。
* eventType 经 TOPIC_MAP 路由到不同 topicedu.notification.sent/read/recalled/failed
*/
export const outboxEvents = mysqlTable("msg_outbox_events", {
eventId: varchar("event_id", { length: 64 }).notNull().primaryKey(),
aggregateType: varchar("aggregate_type", { length: 64 }).notNull(),
aggregateId: varchar("aggregate_id", { length: 32 }).notNull(),
eventType: varchar("event_type", { length: 64 }).notNull(),
topic: varchar("topic", { length: 128 }).notNull(),
payload: json("payload").notNull(),
status: varchar("status", { length: 16 })
.notNull()
.default("pending")
.$type<OutboxStatus>(),
retryCount: int("retry_count").notNull().default(0),
maxRetryCount: int("max_retry_count").notNull().default(5),
createdAt: timestamp("created_at").notNull().defaultNow(),
publishedAt: timestamp("published_at"),
nextRetryAt: timestamp("next_retry_at"),
lastError: text("last_error"),
metadata: json("metadata").$type<Record<string, string> | null>(),
});
/** outbox 行类型 */
export type OutboxEvent = typeof outboxEvents.$inferSelect;
export type NewOutboxEvent = typeof outboxEvents.$inferInsert;
/**
* processed_events 表消费幂等Redis 不可用时降级)。
*
* event_id 唯一索引:重复消费时 INSERT 冲突即跳过。
*/
export const processedEvents = mysqlTable("processed_events", {
eventId: varchar("event_id", { length: 64 }).notNull().primaryKey(),
topic: varchar("topic", { length: 128 }).notNull(),
processedAt: timestamp("processed_at").notNull().defaultNow(),
});
export type ProcessedEvent = typeof processedEvents.$inferSelect;
export type NewProcessedEvent = typeof processedEvents.$inferInsert;

View File

@@ -0,0 +1,75 @@
import { createId } from "@paralleldrive/cuid2";
import { getDb } from "../../config/database.js";
import { logger } from "../observability/logger.js";
import { resolveTopic } from "../kafka/topic-map.js";
import { outboxEvents } from "./outbox.schema.js";
/**
* OutboxService —— 业务层写入 outbox 记录的接口。
*
* 调用方在业务事务内调用 publish(),将事件记录写入 msg_outbox_events 表,
* 与业务写在同一事务中原子提交(事务性 Outbox 模式)。
*
* 由 OutboxPublisher 负责异步轮询 pending 记录并投递到 Kafkaat-least-once
*
* 仲裁依据 G11eventId 用 cuid2同时作为 Kafka 消息 key 实现幂等去重。
*/
export interface PublishOptions {
/** 聚合根类型(如 "Notification" */
aggregateType: string;
/** 聚合根 ID */
aggregateId: string;
/** 附加元数据,写入 outbox 行并随消息头投递 */
metadata?: Record<string, string>;
/** 延迟投递毫秒数 */
delayMs?: number;
}
export interface PublishResult {
eventId: string;
topic: string;
}
/**
* 写入一条 outbox 记录。
*
* @param eventType 事件类型(如 "notification.sent"),用于 TOPIC_MAP 路由
* @param payload 事件负载(将被 JSON 序列化)
* @param options 聚合信息 + 元数据 + 延迟
* @returns eventId + 解析的 topic
*/
export async function publish(
eventType: string,
payload: unknown,
options: PublishOptions,
): Promise<PublishResult> {
const eventId = createId();
const topic = resolveTopic(eventType);
const now = new Date();
const nextRetryAt =
options.delayMs !== undefined && options.delayMs > 0
? new Date(now.getTime() + options.delayMs)
: null;
const db = getDb();
await db.insert(outboxEvents).values({
eventId,
aggregateType: options.aggregateType,
aggregateId: options.aggregateId,
eventType,
topic,
payload: payload as Record<string, unknown>,
status: "pending",
retryCount: 0,
maxRetryCount: 5,
metadata: options.metadata ?? null,
nextRetryAt,
});
logger.debug(
{ eventId, eventType, topic, aggregateId: options.aggregateId },
"Outbox record enqueued",
);
return { eventId, topic };
}

View File

@@ -0,0 +1,99 @@
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
/**
* Push Gateway HTTP 客户端。
*
* 仲裁依据:
* - M4HTTP POST /internal/push豁免 gRPC
* - PushGateway 软失败:不可用时返回 false不阻断主流程
* - 鉴权头X-Internal-Key = PUSH_INTERNAL_TOKEN
*
* 请求体对齐 push-gateway handler.go PushHandler
* { userId, event, data }
*/
export interface PushRequest {
userId: string;
event: string;
data: Record<string, unknown>;
}
export interface PushResult {
sent: boolean;
error?: string;
}
/**
* 向 push-gateway 发送实时推送。
*
* 软失败语义:
* - PUSH_GATEWAY_URL 未配置 → 返回 { sent: false },不报错
* - 网络错误/非 2xx → 返回 { sent: false, error }logger.warn
* - 成功 → 返回 { sent: true }
*/
export async function sendPush(req: PushRequest): Promise<PushResult> {
if (!env.PUSH_GATEWAY_URL) {
return { sent: false };
}
const url = `${env.PUSH_GATEWAY_URL}/internal/push`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (env.PUSH_INTERNAL_TOKEN) {
headers["X-Internal-Key"] = env.PUSH_INTERNAL_TOKEN;
}
try {
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
userId: req.userId,
event: req.event,
data: req.data,
}),
});
if (!res.ok) {
const error = `push-gateway returned ${res.status}`;
logger.warn({ status: res.status, userId: req.userId }, error);
return { sent: false, error };
}
return { sent: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
logger.warn({ err, userId: req.userId }, "Push gateway unavailable");
return { sent: false, error };
}
}
/**
* 批量推送逐条发送push-gateway 无批量 API
* 任一失败不影响其他,返回每条结果。
*/
export async function sendPushBatch(
requests: PushRequest[],
): Promise<PushResult[]> {
const results: PushResult[] = [];
for (const req of requests) {
results.push(await sendPush(req));
}
return results;
}
/**
* 健康检查:探测 push-gateway /readyz。
* 软失败:不可用返回 false不阻断 /readyz仲裁 M2
*/
export async function checkPushGateway(): Promise<boolean> {
if (!env.PUSH_GATEWAY_URL) return false;
try {
const res = await fetch(`${env.PUSH_GATEWAY_URL}/readyz`, {
signal: AbortSignal.timeout(2000),
});
return res.ok;
} catch {
return false;
}
}

View File

@@ -0,0 +1,98 @@
import { createId } from "@paralleldrive/cuid2";
import { eq } from "drizzle-orm";
import { getDb } from "../../config/database.js";
import { getRedis } from "./redis.client.js";
import { logger } from "../observability/logger.js";
import { processedEvents } from "../outbox/outbox.schema.js";
/**
* 幂等去重守卫。
*
* 三层防线(仲裁依据 02-architecture-design.md §3.3.1 + §5
* 1. Redis SETNX首选高性能key=`msg:processed:{eventId}` TTL 7 天
* 2. DB 唯一索引Redis 不可用时降级msg_idempotency 表 event_id UNIQUE
* 3. 业务 event_id UNIQUE INDEXmsg_notifications.event_id最终防线
*
* 调用方在处理 Kafka 消息或 HTTP send 时,先调 checkAndMark(eventId)
* - 返回 true → 首次处理,继续业务逻辑
* - 返回 false → 已处理过,跳过(幂等)
*/
const REDIS_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 天
export interface IdempotencyResult {
/** true=首次处理可继续false=已处理过应跳过 */
isFirst: boolean;
/** 去重使用的 key */
key: string;
}
/**
* 检查并标记事件为已处理。
*
* 优先走 Redis SETNXRedis 不可用时降级到 DB 唯一索引插入。
* 两者均不可用(极端情况)返回 true 放行,由业务层 event_id UNIQUE 兜底。
*/
export async function checkAndMark(
eventId: string,
topic?: string,
): Promise<IdempotencyResult> {
const key = `msg:processed:${eventId}`;
// 1. Redis 优先
const redis = getRedis();
if (redis) {
try {
const result = await redis.set(key, "1", "EX", REDIS_TTL_SECONDS, "NX");
if (result === "OK") {
return { isFirst: true, key };
}
return { isFirst: false, key };
} catch (err) {
logger.warn({ err, eventId }, "Redis SETNX failed, falling back to DB");
}
}
// 2. DB 降级
try {
const db = getDb();
await db.insert(processedEvents).values({
eventId,
topic: topic ?? "unknown",
});
return { isFirst: true, key };
} catch (err) {
// 唯一索引冲突 = 已处理
logger.debug({ eventId, err }, "Idempotency DB hit (already processed)");
return { isFirst: false, key };
}
}
/**
* 仅检查不标记(用于查询是否已处理,不产生副作用)。
*/
export async function isProcessed(eventId: string): Promise<boolean> {
const redis = getRedis();
if (redis) {
try {
const exists = await redis.exists(`msg:processed:${eventId}`);
return exists === 1;
} catch {
// fall through to DB
}
}
const db = getDb();
const [row] = await db
.select({ eventId: processedEvents.eventId })
.from(processedEvents)
.where(eq(processedEvents.eventId, eventId))
.limit(1);
return row !== undefined;
}
/**
* 生成幂等键HTTP send 无外部 eventId 时使用)。
* 格式cuid2保证全局唯一。
*/
export function generateIdempotencyKey(): string {
return createId();
}

View File

@@ -0,0 +1,64 @@
import { Redis } from "ioredis";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
/**
* msg 服务 Redis 客户端。
*
* 仲裁依据:
* - REDIS_URL 可选,未配置时 redisClient=null降级到 DB 唯一索引去重
* - 用途幂等去重SETNX、已读状态位图、未读计数缓存、频率限流
*
* 使用 lazy initialization连接在首次调用 getRedis() 时建立。
*/
let redisInstance: Redis | null = null;
let connectAttempted = false;
export function getRedis(): Redis | null {
if (!env.REDIS_URL) return null;
if (!redisInstance && !connectAttempted) {
connectAttempted = true;
try {
redisInstance = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
});
redisInstance.on("error", (err) => {
logger.warn({ err }, "Redis client error");
});
redisInstance.on("connect", () => {
logger.info("Redis connected");
});
} catch (err) {
logger.warn({ err }, "Redis init failed, falling back to DB idempotency");
redisInstance = null;
}
}
return redisInstance;
}
export async function checkRedisConnection(): Promise<void> {
const client = getRedis();
if (!client) {
logger.info("Redis disabled (REDIS_URL not set)");
return;
}
try {
const pong = await client.ping();
if (pong === "PONG") {
logger.info("Redis connection healthy");
}
} catch (err) {
logger.warn({ err }, "Redis connection check failed");
}
}
export async function closeRedis(): Promise<void> {
if (redisInstance) {
await redisInstance.quit();
redisInstance = null;
connectAttempted = false;
logger.info("Redis disconnected");
}
}

View File

@@ -0,0 +1,103 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
} from "@nestjs/common";
import { TemplatesService } from "./templates.service.js";
import {
createTemplateSchema,
updateTemplateSchema,
renderTemplateSchema,
listTemplatesSchema,
} from "./templates.dto.js";
import type {
CreateTemplateDto,
UpdateTemplateDto,
RenderTemplateDto,
} from "./templates.dto.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
/**
* TemplatesController —— 通知模板 REST API。
*
* 对齐 02-architecture-design.md §4.1
* - GET /templates列表
* - POST /templates创建
* - PUT /templates/:id更新
* - DELETE /templates/:id删除
* - POST /templates/render渲染
*/
@Controller("templates")
export class TemplatesController {
constructor(private readonly service: TemplatesService) {}
@Get()
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async list(
@Query("type") type: string,
@Query("status") status: string,
): Promise<{ success: true; data: unknown }> {
// 用 Zod schema 验证 query 参数,自动推断 NotificationType/TemplateStatus 联合类型
const filter = listTemplatesSchema.parse({
type: type || undefined,
status: status || undefined,
});
const templates = await this.service.list(filter);
return { success: true, data: { templates } };
}
@Post()
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async create(
@Body() body: unknown,
): Promise<{ success: true; data: unknown }> {
const dto: CreateTemplateDto = createTemplateSchema.parse(body);
const template = await this.service.create(dto);
return { success: true, data: template };
}
@Get(":id")
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: unknown }> {
const template = await this.service.getById(id);
return { success: true, data: template };
}
@Put(":id")
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async update(
@Param("id") id: string,
@Body() body: unknown,
): Promise<{ success: true; data: unknown }> {
const dto: UpdateTemplateDto = updateTemplateSchema.parse(body);
const template = await this.service.update(id, dto);
return { success: true, data: template };
}
@Delete(":id")
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async delete(@Param("id") id: string): Promise<{ success: true }> {
await this.service.delete(id);
return { success: true };
}
@Post("render")
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
async render(
@Body() body: unknown,
): Promise<{ success: true; data: unknown }> {
const dto: RenderTemplateDto = renderTemplateSchema.parse(body);
const rendered = await this.service.render(dto);
return { success: true, data: rendered };
}
}

View File

@@ -0,0 +1,55 @@
import { z } from "zod";
/**
* Templates 模块 DTO + Zod 校验。
*
* 对齐 proto msg.proto NotificationTemplate 字段。
*/
const typeEnum = z.enum([
"system",
"exam",
"homework",
"grade",
"attendance",
"mastery",
]);
const channelEnum = z.enum(["in_app", "email", "sms", "push", "wechat"]);
const statusEnum = z.enum(["draft", "active", "archived"]);
export const createTemplateSchema = z.object({
code: z.string().min(1).max(64),
type: typeEnum,
titleTemplate: z.string().min(1).max(255),
contentTemplate: z.string().min(1),
defaultChannels: z.array(channelEnum).min(1),
variables: z.array(z.string()).default([]),
locale: z.string().max(16).default("zh-CN"),
});
export type CreateTemplateDto = z.infer<typeof createTemplateSchema>;
export const updateTemplateSchema = z.object({
titleTemplate: z.string().min(1).max(255).optional(),
contentTemplate: z.string().min(1).optional(),
defaultChannels: z.array(channelEnum).min(1).optional(),
variables: z.array(z.string()).optional(),
status: statusEnum.optional(),
});
export type UpdateTemplateDto = z.infer<typeof updateTemplateSchema>;
export const listTemplatesSchema = z.object({
type: typeEnum.optional(),
status: statusEnum.optional(),
});
export type ListTemplatesDto = z.infer<typeof listTemplatesSchema>;
export const renderTemplateSchema = z.object({
code: z.string().min(1).max(64),
variables: z.record(z.string()).default({}),
locale: z.string().max(16).optional(),
});
export type RenderTemplateDto = z.infer<typeof renderTemplateSchema>;

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { TemplatesController } from "./templates.controller.js";
import { TemplatesService } from "./templates.service.js";
@Module({
controllers: [TemplatesController],
providers: [TemplatesService],
exports: [TemplatesService],
})
export class TemplatesModule {}

View File

@@ -0,0 +1,98 @@
import { and, eq, type SQL } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { getDb } from "../config/database.js";
import {
notificationTemplates,
type NotificationTemplate,
type NewNotificationTemplate,
} from "../notifications/notifications.schema.js";
/**
* TemplatesRepository —— 通知模板数据访问层。
*/
export async function insert(
row: Omit<NewNotificationTemplate, "id" | "createdAt" | "updatedAt">,
): Promise<NotificationTemplate> {
const db = getDb();
const newTpl: NewNotificationTemplate = {
id: createId(),
...row,
};
await db.insert(notificationTemplates).values(newTpl);
return newTpl as NotificationTemplate;
}
export async function findById(
id: string,
): Promise<NotificationTemplate | undefined> {
const db = getDb();
const [row] = await db
.select()
.from(notificationTemplates)
.where(eq(notificationTemplates.id, id))
.limit(1);
return row;
}
export async function findByCode(
code: string,
locale?: string,
): Promise<NotificationTemplate | undefined> {
const db = getDb();
const conditions = [eq(notificationTemplates.code, code)];
if (locale) {
conditions.push(eq(notificationTemplates.locale, locale));
}
const [row] = await db
.select()
.from(notificationTemplates)
.where(and(...conditions))
.limit(1);
return row;
}
export async function list(options: {
type?: string;
status?: string;
}): Promise<NotificationTemplate[]> {
const db = getDb();
const conditions: SQL[] = [];
if (options.type) {
conditions.push(
eq(
notificationTemplates.type,
options.type as NotificationTemplate["type"],
),
);
}
if (options.status) {
conditions.push(
eq(
notificationTemplates.status,
options.status as NotificationTemplate["status"],
),
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const query = db.select().from(notificationTemplates);
return where ? query.where(where) : query;
}
export async function update(
id: string,
row: Partial<Omit<NewNotificationTemplate, "id" | "createdAt" | "updatedAt">>,
): Promise<void> {
const db = getDb();
await db
.update(notificationTemplates)
.set(row)
.where(eq(notificationTemplates.id, id));
}
export async function remove(id: string): Promise<void> {
const db = getDb();
await db
.delete(notificationTemplates)
.where(eq(notificationTemplates.id, id));
}

View File

@@ -0,0 +1,120 @@
import { Injectable } from "@nestjs/common";
import type { NotificationTemplate } from "../notifications/notifications.schema.js";
import * as repo from "./templates.repository.js";
import type {
CreateTemplateDto,
UpdateTemplateDto,
ListTemplatesDto,
RenderTemplateDto,
} from "./templates.dto.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface RenderedNotification {
title: string;
content: string;
}
/**
* TemplateService —— 通知模板管理 + 渲染。
*
* 职责:
* - CRUD 模板
* - 渲染模板({{variable}} 占位符替换)
*
* 仲裁依据 02-architecture-design.md §3.1.3:模板按 (code, locale) 唯一。
*/
@Injectable()
export class TemplatesService {
async create(dto: CreateTemplateDto): Promise<NotificationTemplate> {
return repo.insert({
code: dto.code,
type: dto.type,
titleTemplate: dto.titleTemplate,
contentTemplate: dto.contentTemplate,
defaultChannels: dto.defaultChannels,
variables: dto.variables,
locale: dto.locale,
status: "draft",
});
}
async getById(id: string): Promise<NotificationTemplate> {
const tpl = await repo.findById(id);
if (!tpl) {
throw new NotFoundError("Template", id);
}
return tpl;
}
async list(dto: ListTemplatesDto): Promise<NotificationTemplate[]> {
return repo.list(dto);
}
async update(
id: string,
dto: UpdateTemplateDto,
): Promise<NotificationTemplate> {
const existing = await this.getById(id);
await repo.update(id, {
titleTemplate: dto.titleTemplate,
contentTemplate: dto.contentTemplate,
defaultChannels: dto.defaultChannels,
variables: dto.variables,
status: dto.status,
});
return { ...existing, ...dto } as NotificationTemplate;
}
async delete(id: string): Promise<void> {
await this.getById(id); // 确保存在
await repo.remove(id);
}
/**
* 渲染模板:将 {{variable}} 占位符替换为实际值。
*
* 仲裁依据 02-architecture-design.md §4.2.3 RenderTemplate RPC。
*/
async render(dto: RenderTemplateDto): Promise<RenderedNotification> {
const tpl = await repo.findByCode(dto.code, dto.locale);
if (!tpl) {
throw new NotFoundError(
"Template",
`code=${dto.code} locale=${dto.locale ?? "default"}`,
);
}
// 检查必填变量
if (tpl.variables && Array.isArray(tpl.variables)) {
for (const varName of tpl.variables) {
if (!(varName in dto.variables)) {
throw new ValidationError(`Missing required variable: ${varName}`);
}
}
}
const title = this.replacePlaceholders(tpl.titleTemplate, dto.variables);
const content = this.replacePlaceholders(
tpl.contentTemplate,
dto.variables,
);
return { title, content };
}
/**
* 替换 {{variable}} 占位符。
* 简单实现:正则匹配 {{key}} 并替换。
*/
private replacePlaceholders(
template: string,
variables: Record<string, string>,
): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
return variables[key] ?? match;
});
}
}