chore(msg): merge msg full implementation into main
Merge feat/msg-ai10 with complete messaging service
This commit is contained in:
@@ -2,13 +2,53 @@ syntax = "proto3";
|
||||
|
||||
package next_edu_cloud.msg.v1;
|
||||
|
||||
// Msg 服务契约(P5 沟通通知中台)。
|
||||
//
|
||||
// 三服务:
|
||||
// - NotificationService:通知发送 / 列表 / 已读 / 检索 / 撤回
|
||||
// - NotificationPreferenceService:用户通知偏好
|
||||
// - NotificationTemplateService:通知模板 CRUD + 渲染
|
||||
//
|
||||
// 仲裁依据:coord-final-decisions.md M1(gRPC 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
79
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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 + G11(cuid2 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');
|
||||
|
||||
@@ -1,52 +1,381 @@
|
||||
# Msg 消息通知服务
|
||||
# Msg 沟通通知中台
|
||||
|
||||
> 版本:0.1(P5 骨架)
|
||||
> 端口:3007
|
||||
> 版本:1.0(P5 完整实现)
|
||||
> 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 |
|
||||
| 框架 | NestJS(HTTP + gRPC 双协议 microservice) | 10 |
|
||||
| 语言 | TypeScript(ESM 模式) | 5 |
|
||||
| ORM | Drizzle ORM(函数式 `getDb()`) | latest |
|
||||
| 写模型 | MySQL | 8.0 |
|
||||
| 读模型 | Elasticsearch(safeIndex/safeSearch 降级) | 8 |
|
||||
| 缓存/位图 | Redis(幂等去重 + 已读位图) | 7 |
|
||||
| 消息 | Kafka(KafkaJS,producer + consumer) | 3.x |
|
||||
| 可观测性 | pino + prom-client + OpenTelemetry SDK | — |
|
||||
| ID | cuid2(varchar(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 REST(10 端点)
|
||||
│ ├─ notifications.service.ts # Application Service
|
||||
│ ├─ notifications.repository.ts # Drizzle 数据访问
|
||||
│ ├─ notifications.schema.ts # Drizzle 表定义(4 张表)
|
||||
│ └─ notifications.dto.ts # Zod 验证 + DTO
|
||||
├─ preferences/ # 用户偏好限界上下文
|
||||
│ ├─ preferences.controller.ts # HTTP REST(2 端点)
|
||||
│ ├─ preferences.service.ts
|
||||
│ ├─ preferences.repository.ts
|
||||
│ └─ preferences.dto.ts
|
||||
├─ templates/ # 通知模板限界上下文
|
||||
│ ├─ templates.controller.ts # HTTP REST(6 端点)
|
||||
│ ├─ 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 + /readyz(5 项依赖)
|
||||
│ ├─ 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/push(M4 仲裁)
|
||||
│ └─ redis/
|
||||
│ ├─ redis.client.ts # getRedis/checkRedisConnection/closeRedis
|
||||
│ └─ idempotency.guard.ts # checkAndMark(Redis SETNX + DB 降级)
|
||||
├─ app.module.ts # 根 Module
|
||||
└─ main.ts # HTTP + gRPC 双协议启动
|
||||
```
|
||||
|
||||
## API 清单
|
||||
|
||||
### HTTP REST(18 端点)
|
||||
|
||||
#### Notifications(10 端点)
|
||||
|
||||
| 方法 | 路径 | 权限点 | 说明 |
|
||||
| ------ | ---------------------------------------- | ----------------------- | ----------------- |
|
||||
| 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 | 删除 |
|
||||
|
||||
#### Preferences(2 端点)
|
||||
|
||||
| 方法 | 路径 | 权限点 | 说明 |
|
||||
| ---- | ------------------------- | ----------------------- | -------- |
|
||||
| GET | /preferences/user/:userId | MSG_NOTIFICATION_READ | 查询偏好 |
|
||||
| PUT | /preferences/user/:userId | MSG_NOTIFICATION_MANAGE | 更新偏好 |
|
||||
|
||||
#### Templates(6 端点)
|
||||
|
||||
| 方法 | 路径 | 权限点 | 说明 |
|
||||
| ------ | ----------------- | ----------------------- | -------- |
|
||||
| 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 | 渲染模板 |
|
||||
|
||||
### gRPC(3 Service / 17 RPC)
|
||||
|
||||
契约源:[`packages/shared-proto/proto/msg.proto`](../../packages/shared-proto/proto/msg.proto)
|
||||
包名:`next_edu_cloud.msg.v1`
|
||||
|
||||
#### NotificationService(9 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 | 撤回 |
|
||||
|
||||
#### NotificationPreferenceService(2 RPC)
|
||||
|
||||
| RPC | 权限点 | 说明 |
|
||||
| ----------------- | ----------------------- | -------- |
|
||||
| GetPreferences | MSG_NOTIFICATION_READ | 查询偏好 |
|
||||
| UpdatePreferences | MSG_NOTIFICATION_MANAGE | 更新偏好 |
|
||||
|
||||
#### NotificationTemplateService(6 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 ID(varchar(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)
|
||||
|
||||
@@ -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,7 +40,6 @@
|
||||
"@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"
|
||||
}
|
||||
|
||||
@@ -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 服务根模块。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - M1:gRPC 50056 启用(GrpcModule 注册 3 controller 共 17 RPC)
|
||||
* - HTTP REST + gRPC 双协议入口,共享同一套 Service 单例
|
||||
* - KafkaConsumerService 消费 12 类事件触发通知
|
||||
*
|
||||
* 模块依赖图:
|
||||
* AppModule
|
||||
* ├─ NotificationsModule(REST + Service)
|
||||
* ├─ PreferencesModule(REST + Service)
|
||||
* ├─ TemplatesModule(REST + Service)
|
||||
* ├─ GrpcModule(gRPC controllers,imports 上述 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 {}
|
||||
|
||||
154
services/msg/src/channels/channel-dispatcher.service.ts
Normal file
154
services/msg/src/channels/channel-dispatcher.service.ts
Normal 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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
services/msg/src/channels/channel.types.ts
Normal file
53
services/msg/src/channels/channel.types.ts
Normal 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>;
|
||||
}
|
||||
33
services/msg/src/channels/email.channel.ts
Normal file
33
services/msg/src/channels/email.channel.ts
Normal 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();
|
||||
52
services/msg/src/channels/in-app.channel.ts
Normal file
52
services/msg/src/channels/in-app.channel.ts
Normal 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();
|
||||
40
services/msg/src/channels/push.channel.ts
Normal file
40
services/msg/src/channels/push.channel.ts
Normal 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 推送。
|
||||
* 仲裁依据 M4:HTTP 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();
|
||||
33
services/msg/src/channels/sms.channel.ts
Normal file
33
services/msg/src/channels/sms.channel.ts
Normal 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();
|
||||
@@ -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({
|
||||
/**
|
||||
* msg 服务 MySQL 连接池。
|
||||
*
|
||||
* 仲裁依据 G10:统一 getDb() 函数式(对齐 classes 黄金模板)。
|
||||
*
|
||||
* 使用 lazy initialization:pool 在首次调用 getDb() 时创建,
|
||||
* 避免模块导入副作用导致测试环境难以 mock。
|
||||
*/
|
||||
let pool: mysql.Pool | null = null;
|
||||
let dbInstance: MySql2Database<Record<string, never>> | null = null;
|
||||
|
||||
function getPool(): mysql.Pool {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
uri: env.DATABASE_URL,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export const db = drizzle(pool);
|
||||
export function getDb(): MySql2Database<Record<string, never>> {
|
||||
if (!dbInstance) {
|
||||
dbInstance = drizzle(getPool());
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查用:直接访问底层 pool。
|
||||
*/
|
||||
export function getPoolInstance(): mysql.Pool {
|
||||
return getPool();
|
||||
}
|
||||
|
||||
@@ -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.1:notifications 索引 mapping(ik_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
31
services/msg/src/config/grpc.ts
Normal file
31
services/msg/src/config/grpc.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Transport, type MicroserviceOptions } from "@nestjs/microservices";
|
||||
import { resolve } from "node:path";
|
||||
import { env } from "./env.js";
|
||||
|
||||
/**
|
||||
* gRPC 微服务配置。
|
||||
*
|
||||
* 仲裁依据 M1:gRPC 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}`,
|
||||
},
|
||||
};
|
||||
25
services/msg/src/grpc/grpc.module.ts
Normal file
25
services/msg/src/grpc/grpc.module.ts
Normal 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 controller(17 RPC)。
|
||||
*
|
||||
* 仲裁依据 M1:gRPC 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 {}
|
||||
303
services/msg/src/grpc/notifications.grpc.controller.ts
Normal file
303
services/msg/src/grpc/notifications.grpc.controller.ts
Normal 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)。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - M1:gRPC 50056 启用
|
||||
* - msg.proto §NotificationService(9 RPC)
|
||||
* - proto-loader 默认 keepCase=false,字段名 camelCase
|
||||
*
|
||||
* gRPC 方法接收 proto message(camelCase 字段),调用 NotificationsService,
|
||||
* 返回 proto message(camelCase 字段,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 };
|
||||
}
|
||||
}
|
||||
84
services/msg/src/grpc/preferences.grpc.controller.ts
Normal file
84
services/msg/src/grpc/preferences.grpc.controller.ts
Normal 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 §NotificationPreferenceService(GetPreferences / 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 {};
|
||||
}
|
||||
}
|
||||
130
services/msg/src/grpc/templates.grpc.controller.ts
Normal file
130
services/msg/src/grpc/templates.grpc.controller.ts
Normal 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 §NotificationTemplateService(6 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 };
|
||||
}
|
||||
}
|
||||
@@ -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 服务启动入口。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - M1:gRPC 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.onModuleInit:connectKafka + outboxPublisher.start
|
||||
* - KafkaConsumerService.onModuleInit:connectKafka + 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) => {
|
||||
|
||||
@@ -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/search(ES 全文检索)
|
||||
* - 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
166
services/msg/src/notifications/notifications.repository.ts
Normal file
166
services/msg/src/notifications/notifications.repository.ts
Normal 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));
|
||||
}
|
||||
@@ -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(表清单 + 字段 + 索引)
|
||||
* - G11:ID 用 cuid2(varchar(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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
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 };
|
||||
export interface SearchResult {
|
||||
items: Record<string, unknown>[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
await db.insert(notifications).values({
|
||||
/**
|
||||
* NotificationService —— 通知发送编排层。
|
||||
*
|
||||
* 职责:
|
||||
* 1. 幂等检查(eventId 去重)
|
||||
* 2. 查询用户偏好
|
||||
* 3. 写入 MySQL(通知主表)
|
||||
* 4. 写入 ES 索引(降级安全)
|
||||
* 5. 调用 ChannelDispatcher fan-out 到多渠道
|
||||
* 6. 写入 Outbox(发布 notification.sent 事件)
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - G10:getDb() 函数式
|
||||
* - G11:cuid2 ID
|
||||
* - 02-architecture-design.md §2.4:in_app 总是发送,其他渠道按偏好
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(private readonly channelDispatcher: ChannelDispatcherService) {}
|
||||
|
||||
async send(dto: SendNotificationDto): Promise<SendResult> {
|
||||
// 幂等: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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
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)",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Push gateway unavailable, skipping push");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
48
services/msg/src/preferences/preferences.controller.ts
Normal file
48
services/msg/src/preferences/preferences.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
36
services/msg/src/preferences/preferences.dto.ts
Normal file
36
services/msg/src/preferences/preferences.dto.ts
Normal 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>;
|
||||
10
services/msg/src/preferences/preferences.module.ts
Normal file
10
services/msg/src/preferences/preferences.module.ts
Normal 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 {}
|
||||
84
services/msg/src/preferences/preferences.repository.ts
Normal file
84
services/msg/src/preferences/preferences.repository.ts
Normal 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));
|
||||
}
|
||||
34
services/msg/src/preferences/preferences.service.ts
Normal file
34
services/msg/src/preferences/preferences.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.2(NotificationPreference.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 /healthz:liveness,仅返回进程存活,不检查依赖。
|
||||
* - GET /readyz:readiness,检查 DB 连接(Drizzle `SELECT 1`),失败返回 503。
|
||||
* 仲裁依据 G2:/readyz 多依赖检查(DB/ES/Redis/Kafka/PushGateway)。
|
||||
*
|
||||
* 不需要鉴权,必须在路由白名单中放行。
|
||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
||||
* - GET /readyz:readiness,检查 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;
|
||||
}> {
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return {
|
||||
status: "ok",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
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`);
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
88
services/msg/src/shared/kafka/kafka.client.ts
Normal file
88
services/msg/src/shared/kafka/kafka.client.ts
Normal 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 + transactionalId(at-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)");
|
||||
}
|
||||
}
|
||||
420
services/msg/src/shared/kafka/kafka.consumer.ts
Normal file
420
services/msg/src/shared/kafka/kafka.consumer.ts
Normal 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.1(12 类消费事件)
|
||||
* - JSON payload 降级:events.proto 未定义全部事件类型,直接解析 JSON
|
||||
* - 幂等:event_id 去重(Redis SETNX + DB 降级)
|
||||
* - at-least-once:消费失败不 commit offset,Kafka 重投
|
||||
*
|
||||
* 事件路由:
|
||||
* - iam:user.created/updated/deleted/role_changed, role.created/updated
|
||||
* - core-edu:exam.published, assignment.submitted/graded, grade.recorded, attendance.recorded
|
||||
* - data-ana:mastery.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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
62
services/msg/src/shared/kafka/topic-map.ts
Normal file
62
services/msg/src/shared/kafka/topic-map.ts
Normal 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_MAP:eventType → topic,OutboxPublisher 按此路由发布。
|
||||
* CONSUMER_TOPICS:msg 消费的所有 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[] = [
|
||||
// iam(identity)
|
||||
"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-edu(teaching)
|
||||
"edu.teaching.exam.published",
|
||||
"edu.teaching.assignment.submitted",
|
||||
"edu.teaching.assignment.graded",
|
||||
"edu.teaching.grade.recorded",
|
||||
"edu.teaching.attendance.recorded",
|
||||
// data-ana(insight)
|
||||
"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;
|
||||
}
|
||||
@@ -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 / SIGINT),NestJS 会依次调用 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. 连接 Kafka(producer + 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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
125
services/msg/src/shared/outbox/outbox.publisher.ts
Normal file
125
services/msg/src/shared/outbox/outbox.publisher.ts
Normal 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();
|
||||
63
services/msg/src/shared/outbox/outbox.repository.ts
Normal file
63
services/msg/src/shared/outbox/outbox.repository.ts
Normal 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));
|
||||
}
|
||||
66
services/msg/src/shared/outbox/outbox.schema.ts
Normal file
66
services/msg/src/shared/outbox/outbox.schema.ts
Normal 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.4(msg_outbox_events)
|
||||
* - 02-architecture-design.md §3.1.5(processed_events)
|
||||
* - G11:ID 用 cuid2(varchar(32))
|
||||
*
|
||||
* 参照 core-edu OutboxPublisher 模式:本地 TOPIC_MAP 多 topic 路由。
|
||||
*/
|
||||
|
||||
/** Outbox 记录状态机 */
|
||||
export type OutboxStatus = "pending" | "published" | "failed";
|
||||
|
||||
/**
|
||||
* msg_outbox_events 表:事务性 Outbox。
|
||||
*
|
||||
* 业务事务内写入,OutboxPublisher 轮询 pending 记录投递到 Kafka。
|
||||
* eventType 经 TOPIC_MAP 路由到不同 topic(edu.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;
|
||||
75
services/msg/src/shared/outbox/outbox.service.ts
Normal file
75
services/msg/src/shared/outbox/outbox.service.ts
Normal 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 记录并投递到 Kafka(at-least-once)。
|
||||
*
|
||||
* 仲裁依据 G11:eventId 用 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 };
|
||||
}
|
||||
99
services/msg/src/shared/push/push-gateway.client.ts
Normal file
99
services/msg/src/shared/push/push-gateway.client.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
|
||||
/**
|
||||
* Push Gateway HTTP 客户端。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - M4:HTTP 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;
|
||||
}
|
||||
}
|
||||
98
services/msg/src/shared/redis/idempotency.guard.ts
Normal file
98
services/msg/src/shared/redis/idempotency.guard.ts
Normal 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 INDEX(msg_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 SETNX;Redis 不可用时降级到 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();
|
||||
}
|
||||
64
services/msg/src/shared/redis/redis.client.ts
Normal file
64
services/msg/src/shared/redis/redis.client.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
103
services/msg/src/templates/templates.controller.ts
Normal file
103
services/msg/src/templates/templates.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
55
services/msg/src/templates/templates.dto.ts
Normal file
55
services/msg/src/templates/templates.dto.ts
Normal 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>;
|
||||
10
services/msg/src/templates/templates.module.ts
Normal file
10
services/msg/src/templates/templates.module.ts
Normal 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 {}
|
||||
98
services/msg/src/templates/templates.repository.ts
Normal file
98
services/msg/src/templates/templates.repository.ts
Normal 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));
|
||||
}
|
||||
120
services/msg/src/templates/templates.service.ts
Normal file
120
services/msg/src/templates/templates.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user