From 7b7abbb309b9dba5a76883732ee502e6d5701869 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:09:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(msg):=20=E5=AE=8C=E6=95=B4=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20msg=20=E6=B6=88=E6=81=AF=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现 --- packages/shared-proto/proto/msg.proto | 231 ++++++++- pnpm-lock.yaml | 239 +++++++++- scripts/msg-init.sql | 210 ++++++++- services/msg/README.md | 401 ++++++++++++++-- services/msg/package.json | 25 +- services/msg/src/app.module.ts | 30 +- .../channels/channel-dispatcher.service.ts | 154 ++++++ services/msg/src/channels/channel.types.ts | 53 +++ services/msg/src/channels/email.channel.ts | 33 ++ services/msg/src/channels/in-app.channel.ts | 52 +++ services/msg/src/channels/push.channel.ts | 40 ++ services/msg/src/channels/sms.channel.ts | 33 ++ services/msg/src/config/database.ts | 50 +- services/msg/src/config/elasticsearch.ts | 103 +++- services/msg/src/config/env.ts | 13 + services/msg/src/config/grpc.ts | 31 ++ services/msg/src/grpc/grpc.module.ts | 25 + .../src/grpc/notifications.grpc.controller.ts | 303 ++++++++++++ .../src/grpc/preferences.grpc.controller.ts | 84 ++++ .../msg/src/grpc/templates.grpc.controller.ts | 130 ++++++ services/msg/src/main.ts | 54 ++- .../notifications/notifications.controller.ts | 127 +++-- .../src/notifications/notifications.dto.ts | 98 +++- .../src/notifications/notifications.module.ts | 10 +- .../notifications/notifications.repository.ts | 166 +++++++ .../src/notifications/notifications.schema.ts | 177 ++++++- .../notifications/notifications.service.ts | 441 ++++++++++++------ .../src/preferences/preferences.controller.ts | 48 ++ .../msg/src/preferences/preferences.dto.ts | 36 ++ .../msg/src/preferences/preferences.module.ts | 10 + .../src/preferences/preferences.repository.ts | 84 ++++ .../src/preferences/preferences.service.ts | 34 ++ .../src/shared/errors/application-error.ts | 120 ++++- .../src/shared/health/health.controller.ts | 119 ++++- services/msg/src/shared/kafka/kafka.client.ts | 88 ++++ .../msg/src/shared/kafka/kafka.consumer.ts | 420 +++++++++++++++++ services/msg/src/shared/kafka/topic-map.ts | 62 +++ .../src/shared/lifecycle/lifecycle.service.ts | 42 +- .../msg/src/shared/observability/metrics.ts | 223 ++++++++- .../msg/src/shared/outbox/outbox.publisher.ts | 125 +++++ .../src/shared/outbox/outbox.repository.ts | 63 +++ .../msg/src/shared/outbox/outbox.schema.ts | 66 +++ .../msg/src/shared/outbox/outbox.service.ts | 75 +++ .../src/shared/push/push-gateway.client.ts | 99 ++++ .../msg/src/shared/redis/idempotency.guard.ts | 98 ++++ services/msg/src/shared/redis/redis.client.ts | 64 +++ .../msg/src/templates/templates.controller.ts | 103 ++++ services/msg/src/templates/templates.dto.ts | 55 +++ .../msg/src/templates/templates.module.ts | 10 + .../msg/src/templates/templates.repository.ts | 98 ++++ .../msg/src/templates/templates.service.ts | 120 +++++ 51 files changed, 5204 insertions(+), 371 deletions(-) create mode 100644 services/msg/src/channels/channel-dispatcher.service.ts create mode 100644 services/msg/src/channels/channel.types.ts create mode 100644 services/msg/src/channels/email.channel.ts create mode 100644 services/msg/src/channels/in-app.channel.ts create mode 100644 services/msg/src/channels/push.channel.ts create mode 100644 services/msg/src/channels/sms.channel.ts create mode 100644 services/msg/src/config/grpc.ts create mode 100644 services/msg/src/grpc/grpc.module.ts create mode 100644 services/msg/src/grpc/notifications.grpc.controller.ts create mode 100644 services/msg/src/grpc/preferences.grpc.controller.ts create mode 100644 services/msg/src/grpc/templates.grpc.controller.ts create mode 100644 services/msg/src/notifications/notifications.repository.ts create mode 100644 services/msg/src/preferences/preferences.controller.ts create mode 100644 services/msg/src/preferences/preferences.dto.ts create mode 100644 services/msg/src/preferences/preferences.module.ts create mode 100644 services/msg/src/preferences/preferences.repository.ts create mode 100644 services/msg/src/preferences/preferences.service.ts create mode 100644 services/msg/src/shared/kafka/kafka.client.ts create mode 100644 services/msg/src/shared/kafka/kafka.consumer.ts create mode 100644 services/msg/src/shared/kafka/topic-map.ts create mode 100644 services/msg/src/shared/outbox/outbox.publisher.ts create mode 100644 services/msg/src/shared/outbox/outbox.repository.ts create mode 100644 services/msg/src/shared/outbox/outbox.schema.ts create mode 100644 services/msg/src/shared/outbox/outbox.service.ts create mode 100644 services/msg/src/shared/push/push-gateway.client.ts create mode 100644 services/msg/src/shared/redis/idempotency.guard.ts create mode 100644 services/msg/src/shared/redis/redis.client.ts create mode 100644 services/msg/src/templates/templates.controller.ts create mode 100644 services/msg/src/templates/templates.dto.ts create mode 100644 services/msg/src/templates/templates.module.ts create mode 100644 services/msg/src/templates/templates.repository.ts create mode 100644 services/msg/src/templates/templates.service.ts diff --git a/packages/shared-proto/proto/msg.proto b/packages/shared-proto/proto/msg.proto index 7c23eb2..40f44c7 100644 --- a/packages/shared-proto/proto/msg.proto +++ b/packages/shared-proto/proto/msg.proto @@ -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 metadata = 18; } +// ============================================================ +// SendNotification +// ============================================================ message SendNotificationRequest { string user_id = 1; string type = 2; string title = 3; string content = 4; string channel = 5; + map 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 variables = 2; + string locale = 3; +} + +message RenderedNotification { + string title = 1; + string content = 2; +} + +// ============================================================ +// Empty +// ============================================================ message Empty {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef4a295..aa23533 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,8 +82,88 @@ importers: specifier: ^5.6.0 version: 5.9.3 + packages/hooks: + dependencies: + '@edu/ui-components': + specifier: workspace:* + version: link:../ui-components + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + packages/shared-proto: {} + packages/shared-ts: + dependencies: + '@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) + '@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) + kafkajs: + specifier: ^2.2.0 + version: 2.2.4 + pino: + specifier: ^9.4.0 + version: 9.14.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.0 + version: 7.8.2 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.0 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + + packages/ui-components: + dependencies: + '@edu/ui-tokens': + specifier: workspace:* + version: link:../ui-tokens + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + + packages/ui-tokens: {} + scripts/arch-scan: dependencies: better-sqlite3: @@ -107,7 +187,7 @@ importers: 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/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + 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/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) @@ -204,7 +284,7 @@ importers: 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/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + 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/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) @@ -271,7 +351,7 @@ importers: 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/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + 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/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) @@ -350,7 +430,7 @@ importers: 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/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + 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/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) @@ -427,15 +507,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/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + 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) @@ -451,9 +543,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 @@ -472,9 +573,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 @@ -488,9 +586,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 @@ -505,7 +600,7 @@ importers: 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/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + 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/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) @@ -1312,6 +1407,11 @@ packages: resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} engines: {node: '>=12.10.0'} + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + '@grpc/proto-loader@0.8.1': resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} engines: {node: '>=6'} @@ -1422,6 +1522,42 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/microservices@10.4.22': + resolution: {integrity: sha512-9Oxc0jQuppGLaQv5yaB2tVS2rAZzZ9NqDS1A4UlDLiYwJB7M6e89G6tmyOQjGjPwgoXPxQS4Vg2voSiKiED2gw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + '@nestjs/websockets': ^10.0.0 + amqp-connection-manager: '*' + amqplib: '*' + cache-manager: '*' + ioredis: '*' + kafkajs: '*' + mqtt: '*' + nats: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@nestjs/websockets': + optional: true + amqp-connection-manager: + optional: true + amqplib: + optional: true + cache-manager: + optional: true + ioredis: + optional: true + kafkajs: + optional: true + mqtt: + optional: true + nats: + optional: true + '@nestjs/platform-express@10.4.22': resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==} peerDependencies: @@ -1494,6 +1630,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2503,6 +2643,9 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2748,6 +2891,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==} @@ -2757,6 +2903,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==} @@ -4057,6 +4209,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'} @@ -4064,6 +4219,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + 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==} engines: {node: '>=8'} @@ -4413,6 +4573,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==} @@ -6404,6 +6567,13 @@ snapshots: '@grpc/proto-loader': 0.8.1 '@js-sdsl/ordered-map': 4.4.2 + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + '@grpc/proto-loader@0.8.1': dependencies: lodash.camelcase: 4.3.0 @@ -6563,7 +6733,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@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)': dependencies: '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxtjs/opencollective': 0.3.2 @@ -6575,14 +6745,28 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: + '@nestjs/microservices': 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': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) transitivePeerDependencies: - encoding + '@nestjs/microservices@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)': + dependencies: + '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 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) + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@grpc/grpc-js': 1.14.4 + ioredis: 5.11.1 + kafkajs: 2.2.4 + '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 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) body-parser: 1.20.4 cors: 2.8.5 express: 4.22.1 @@ -6642,6 +6826,8 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.33': optional: true + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8135,6 +8321,10 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -8331,6 +8521,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': {} @@ -8340,6 +8532,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 @@ -9829,10 +10027,21 @@ snapshots: google-logging-utils@0.0.2: {} + google-protobuf@3.21.4: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} + 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: {} has-own-prop@2.0.0: {} @@ -10188,6 +10397,8 @@ snapshots: lodash.once@4.1.1: {} + lodash.set@4.3.2: {} + lodash.snakecase@4.1.1: {} lodash.startcase@4.4.0: {} diff --git a/scripts/msg-init.sql b/scripts/msg-init.sql index cd2dcf2..3586e75 100644 --- a/scripts/msg-init.sql +++ b/scripts/msg-init.sql @@ -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'); diff --git a/services/msg/README.md b/services/msg/README.md index baf850f..a8d89bf 100644 --- a/services/msg/README.md +++ b/services/msg/README.md @@ -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) diff --git a/services/msg/package.json b/services/msg/package.json index 3e52592..178a115 100644 --- a/services/msg/package.json +++ b/services/msg/package.json @@ -1,6 +1,6 @@ { "name": "@edu/msg-service", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { @@ -12,20 +12,26 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@edu/shared-ts": "workspace:*", "@nestjs/common": "^10.4.0", "@nestjs/core": "^10.4.0", + "@nestjs/microservices": "^10.4.0", "@nestjs/platform-express": "^10.4.0", - "drizzle-orm": "^0.31.0", - "mysql2": "^3.11.0", - "kafkajs": "^2.2.0", + "@paralleldrive/cuid2": "^2.2.2", "@elastic/elasticsearch": "^8.15.0", - "pino": "^9.4.0", + "drizzle-orm": "^0.31.0", + "grpc-reflection-js": "^0.3.0", + "ioredis": "^5.4.0", + "kafkajs": "^2.2.0", + "mysql2": "^3.11.0", "prom-client": "^15.1.0", - "@opentelemetry/api": "^1.9.0", - "zod": "^3.23.0", - "uuid": "^10.0.0", + "pino": "^9.4.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.0", + "zod": "^3.23.0", + "@grpc/grpc-js": "^1.11.0", + "@grpc/proto-loader": "^0.7.13", + "@opentelemetry/api": "^1.9.0", "@opentelemetry/sdk-node": "^0.55.0", "@opentelemetry/exporter-trace-otlp-http": "^0.55.0", "@opentelemetry/auto-instrumentations-node": "^0.55.0" @@ -34,8 +40,7 @@ "@nestjs/cli": "^10.4.0", "@types/express": "^4.17.0", "@types/node": "^22.0.0", - "@types/uuid": "^10.0.0", "typescript": "^5.6.0", "vitest": "^2.1.0" } -} \ No newline at end of file +} diff --git a/services/msg/src/app.module.ts b/services/msg/src/app.module.ts index 5cf8c9c..15f7005 100644 --- a/services/msg/src/app.module.ts +++ b/services/msg/src/app.module.ts @@ -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 {} diff --git a/services/msg/src/channels/channel-dispatcher.service.ts b/services/msg/src/channels/channel-dispatcher.service.ts new file mode 100644 index 0000000..132dec8 --- /dev/null +++ b/services/msg/src/channels/channel-dispatcher.service.ts @@ -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 { + 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 { + // 确定要发送的渠道列表: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(enabledChannels); + set.add("in_app"); + return Array.from(set); + } + + private async sendToChannel( + ctx: ChannelSendContext, + channel: NotificationChannel, + ): Promise { + 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 { + 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)", + ); + } + } +} diff --git a/services/msg/src/channels/channel.types.ts b/services/msg/src/channels/channel.types.ts new file mode 100644 index 0000000..fb04338 --- /dev/null +++ b/services/msg/src/channels/channel.types.ts @@ -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 | 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; +} diff --git a/services/msg/src/channels/email.channel.ts b/services/msg/src/channels/email.channel.ts new file mode 100644 index 0000000..aa31b3d --- /dev/null +++ b/services/msg/src/channels/email.channel.ts @@ -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 { + // 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(); diff --git a/services/msg/src/channels/in-app.channel.ts b/services/msg/src/channels/in-app.channel.ts new file mode 100644 index 0000000..7c37b14 --- /dev/null +++ b/services/msg/src/channels/in-app.channel.ts @@ -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 { + // 站内信数据已落库,仅触发实时推送 + 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(); diff --git a/services/msg/src/channels/push.channel.ts b/services/msg/src/channels/push.channel.ts new file mode 100644 index 0000000..cd6f476 --- /dev/null +++ b/services/msg/src/channels/push.channel.ts @@ -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 { + 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(); diff --git a/services/msg/src/channels/sms.channel.ts b/services/msg/src/channels/sms.channel.ts new file mode 100644 index 0000000..7d5fa4f --- /dev/null +++ b/services/msg/src/channels/sms.channel.ts @@ -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 { + // 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(); diff --git a/services/msg/src/config/database.ts b/services/msg/src/config/database.ts index c040316..7a611c3 100644 --- a/services/msg/src/config/database.ts +++ b/services/msg/src/config/database.ts @@ -1,16 +1,48 @@ -import { drizzle } from "drizzle-orm/mysql2"; +import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; import mysql from "mysql2/promise"; import { env } from "./env.js"; -const pool = mysql.createPool({ - uri: env.DATABASE_URL, - waitForConnections: true, - connectionLimit: 10, - queueLimit: 0, -}); +/** + * msg 服务 MySQL 连接池。 + * + * 仲裁依据 G10:统一 getDb() 函数式(对齐 classes 黄金模板)。 + * + * 使用 lazy initialization:pool 在首次调用 getDb() 时创建, + * 避免模块导入副作用导致测试环境难以 mock。 + */ +let pool: mysql.Pool | null = null; +let dbInstance: MySql2Database> | null = null; -export const db = drizzle(pool); +function getPool(): mysql.Pool { + if (!pool) { + pool = mysql.createPool({ + uri: env.DATABASE_URL, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + }); + } + return pool; +} + +export function getDb(): MySql2Database> { + if (!dbInstance) { + dbInstance = drizzle(getPool()); + } + return dbInstance; +} export async function closeDb(): Promise { - await pool.end(); + if (pool) { + await pool.end(); + pool = null; + dbInstance = null; + } +} + +/** + * 健康检查用:直接访问底层 pool。 + */ +export function getPoolInstance(): mysql.Pool { + return getPool(); } diff --git a/services/msg/src/config/elasticsearch.ts b/services/msg/src/config/elasticsearch.ts index 8c68018..e716fed 100644 --- a/services/msg/src/config/elasticsearch.ts +++ b/services/msg/src/config/elasticsearch.ts @@ -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 { if (!esClient) { logger.info("Elasticsearch disabled (ES_URL not set)"); @@ -13,12 +59,32 @@ export async function checkEsConnection(): Promise { } 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 { + 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 { 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 { @@ -57,10 +124,17 @@ export async function safeIndex(params: IndexParams): Promise { export async function safeSearch( index: string, query: Record, + from: number = 0, + size: number = 20, ): Promise { - 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 hits.push({ _id: raw._id, _source: source as Record, }); } } - 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 { + if (!esClient) return false; + try { + await esClient.delete({ index, id }); + return true; + } catch (err) { + logger.error({ err, index, id }, "Elasticsearch delete failed"); + return false; } } diff --git a/services/msg/src/config/env.ts b/services/msg/src/config/env.ts index ce0755c..bacd900 100644 --- a/services/msg/src/config/env.ts +++ b/services/msg/src/config/env.ts @@ -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"]) diff --git a/services/msg/src/config/grpc.ts b/services/msg/src/config/grpc.ts new file mode 100644 index 0000000..f2d55cb --- /dev/null +++ b/services/msg/src/config/grpc.ts @@ -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}`, + }, +}; diff --git a/services/msg/src/grpc/grpc.module.ts b/services/msg/src/grpc/grpc.module.ts new file mode 100644 index 0000000..8c3496c --- /dev/null +++ b/services/msg/src/grpc/grpc.module.ts @@ -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 {} diff --git a/services/msg/src/grpc/notifications.grpc.controller.ts b/services/msg/src/grpc/notifications.grpc.controller.ts new file mode 100644 index 0000000..31ac057 --- /dev/null +++ b/services/msg/src/grpc/notifications.grpc.controller.ts @@ -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; +} + +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): 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, + }; +} + +// ============================================================ +// Controller +// ============================================================ + +@Controller() +export class NotificationsGrpcController { + constructor(private readonly service: NotificationsService) {} + + // ---------------------------------------------------------- + // SendNotification + // ---------------------------------------------------------- + @GrpcMethod("NotificationService", "SendNotification") + async sendNotification( + data: Record, + ): Promise { + 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): Promise<{ + ids: string[]; + failed: GrpcBatchSendFailure[]; + }> { + const items = Array.isArray(data.items) ? data.items : []; + const dto = sendNotificationBatchSchema.parse({ + items: items.map((item: Record) => ({ + 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): 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), + ), + total: result.total, + }; + } + + // ---------------------------------------------------------- + // GetUnreadCount + // ---------------------------------------------------------- + @GrpcMethod("NotificationService", "GetUnreadCount") + async getUnreadCount( + data: Record, + ): Promise<{ count: number }> { + const count = await this.service.getUnreadCount(String(data.userId ?? "")); + return { count }; + } + + // ---------------------------------------------------------- + // MarkAsRead + // ---------------------------------------------------------- + @GrpcMethod("NotificationService", "MarkAsRead") + async markAsRead( + data: Record, + ): Promise> { + await this.service.markAsRead( + String(data.id ?? ""), + String(data.userId ?? ""), + ); + return {}; + } + + // ---------------------------------------------------------- + // BatchMarkAsRead + // ---------------------------------------------------------- + @GrpcMethod("NotificationService", "BatchMarkAsRead") + async batchMarkAsRead( + data: Record, + ): Promise> { + 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, + ): Promise> { + await this.service.markAllAsRead( + String(data.userId ?? ""), + optNum(data.before), + ); + return {}; + } + + // ---------------------------------------------------------- + // SearchNotifications + // ---------------------------------------------------------- + @GrpcMethod("NotificationService", "SearchNotifications") + async searchNotifications(data: Record): 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): Promise<{ + recalledCount: number; + }> { + const recalledCount = await this.service.recall(String(data.groupId ?? "")); + return { recalledCount }; + } +} diff --git a/services/msg/src/grpc/preferences.grpc.controller.ts b/services/msg/src/grpc/preferences.grpc.controller.ts new file mode 100644 index 0000000..84ae25c --- /dev/null +++ b/services/msg/src/grpc/preferences.grpc.controller.ts @@ -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, + ): Promise<{ preferences: GrpcPreference[] }> { + const prefs = await this.service.getByUserId(String(data.userId ?? "")); + return { preferences: prefs.map(toGrpcPreference) }; + } + + @GrpcMethod("NotificationPreferenceService", "UpdatePreferences") + async updatePreferences( + data: Record, + ): Promise> { + const rawPrefs = Array.isArray(data.preferences) ? data.preferences : []; + + const dto = updatePreferencesSchema.parse({ + userId: data.userId, + preferences: rawPrefs.map((p: Record) => ({ + 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 {}; + } +} diff --git a/services/msg/src/grpc/templates.grpc.controller.ts b/services/msg/src/grpc/templates.grpc.controller.ts new file mode 100644 index 0000000..975ddfe --- /dev/null +++ b/services/msg/src/grpc/templates.grpc.controller.ts @@ -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): Promise { + 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): Promise { + const tpl = await this.service.getById(String(data.id ?? "")); + return toGrpcTemplate(tpl); + } + + @GrpcMethod("NotificationTemplateService", "ListTemplates") + async listTemplates(data: Record): 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): Promise { + 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, + ): Promise> { + await this.service.delete(String(data.id ?? "")); + return {}; + } + + @GrpcMethod("NotificationTemplateService", "RenderTemplate") + async renderTemplate(data: Record): 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 }; + } +} diff --git a/services/msg/src/main.ts b/services/msg/src/main.ts index 1207354..5dc4650 100644 --- a/services/msg/src/main.ts +++ b/services/msg/src/main.ts @@ -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 { initTracer(); @@ -23,32 +41,34 @@ async function bootstrap(): Promise { 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 => { + 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) => { diff --git a/services/msg/src/notifications/notifications.controller.ts b/services/msg/src/notifications/notifications.controller.ts index b47ac41..fde25d6 100644 --- a/services/msg/src/notifications/notifications.controller.ts +++ b/services/msg/src/notifications/notifications.controller.ts @@ -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 }; + } } diff --git a/services/msg/src/notifications/notifications.dto.ts b/services/msg/src/notifications/notifications.dto.ts index 4bc7922..1e7347c 100644 --- a/services/msg/src/notifications/notifications.dto.ts +++ b/services/msg/src/notifications/notifications.dto.ts @@ -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; + +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; + +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; + +// ============================================================ +// Mark As Read +// ============================================================ + +export const markAsReadSchema = z.object({ + id: z.string().min(1), + userId: z.string().min(1), +}); + +export type MarkAsReadDto = z.infer; + +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; + +export const markAllAsReadSchema = z.object({ + userId: z.string().min(1), + before: z.number().int().optional(), +}); + +export type MarkAllAsReadDto = z.infer; + +// ============================================================ +// Recall +// ============================================================ + +export const recallNotificationSchema = z.object({ + groupId: z.string().min(1), + reason: z.string().max(500).optional(), +}); + +export type RecallNotificationDto = z.infer; diff --git a/services/msg/src/notifications/notifications.module.ts b/services/msg/src/notifications/notifications.module.ts index a01d399..c2c8c21 100644 --- a/services/msg/src/notifications/notifications.module.ts +++ b/services/msg/src/notifications/notifications.module.ts @@ -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 {} diff --git a/services/msg/src/notifications/notifications.repository.ts b/services/msg/src/notifications/notifications.repository.ts new file mode 100644 index 0000000..6335090 --- /dev/null +++ b/services/msg/src/notifications/notifications.repository.ts @@ -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 { + const db = getDb(); + await db.insert(notifications).values(row); + return row as Notification; +} + +export async function insertNotifications( + rows: NewNotification[], +): Promise { + if (rows.length === 0) return; + const db = getDb(); + await db.insert(notifications).values(rows); +} + +export async function findById(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const db = getDb(); + await db.delete(notifications).where(eq(notifications.id, id)); +} + +/** 用于测试/重置 */ +export async function deleteAllByUserId(userId: string): Promise { + const db = getDb(); + await db.delete(notifications).where(eq(notifications.userId, userId)); +} diff --git a/services/msg/src/notifications/notifications.schema.ts b/services/msg/src/notifications/notifications.schema.ts index 55cf697..2acef6f 100644 --- a/services/msg/src/notifications/notifications.schema.ts +++ b/services/msg/src/notifications/notifications.schema.ts @@ -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(), + title: varchar("title", { length: 255 }).notNull(), + content: text("content").notNull(), + channel: varchar("channel", { length: 32 }) + .notNull() + .$type(), + isRead: boolean("is_read").notNull().default(false), + createdAt: timestamp("created_at").notNull().defaultNow(), + // 扩展字段(P5) + status: varchar("status", { length: 32 }) + .notNull() + .default("pending") + .$type(), + metadata: json("metadata").$type | 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(), + channels: json("channels").notNull().$type(), + 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(), + titleTemplate: varchar("title_template", { length: 255 }).notNull(), + contentTemplate: text("content_template").notNull(), + defaultChannels: json("default_channels") + .notNull() + .$type(), + variables: json("variables").notNull().$type(), + locale: varchar("locale", { length: 16 }).notNull().default("zh-CN"), + status: varchar("status", { length: 32 }) + .notNull() + .default("draft") + .$type(), + 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(), + status: varchar("status", { length: 32 }) + .notNull() + .default("pending") + .$type(), + 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; diff --git a/services/msg/src/notifications/notifications.service.ts b/services/msg/src/notifications/notifications.service.ts index c307c80..f6bd1b6 100644 --- a/services/msg/src/notifications/notifications.service.ts +++ b/services/msg/src/notifications/notifications.service.ts @@ -1,24 +1,34 @@ import { Injectable } from "@nestjs/common"; -import { randomUUID } from "node:crypto"; -import { eq, and, desc, sql } from "drizzle-orm"; -import { db } from "../config/database.js"; +import { createId } from "@paralleldrive/cuid2"; +import { and, eq } from "drizzle-orm"; +import { getDb } from "../config/database.js"; import { notifications, notificationPreferences, + type Notification, + type NotificationChannel, + type NotificationStatus, } from "./notifications.schema.js"; +import * as repo from "./notifications.repository.js"; import type { - Notification, - NotificationPreference, -} from "./notifications.schema.js"; -import type { SendNotificationDto } from "./notifications.dto.js"; -import { safeIndex, safeSearch } from "../config/elasticsearch.js"; -import { env } from "../config/env.js"; + SendNotificationDto, + SendNotificationBatchDto, +} from "./notifications.dto.js"; +import { ChannelDispatcherService } from "../channels/channel-dispatcher.service.js"; +import type { ChannelSendContext } from "../channels/channel.types.js"; +import { safeIndex, safeSearch, safeDelete } from "../config/elasticsearch.js"; +import { publish as outboxPublish } from "../shared/outbox/outbox.service.js"; import { logger } from "../shared/observability/logger.js"; export interface SendResult { id: string; - skipped: boolean; - pushed: boolean; + status: string; + channels: string[]; +} + +export interface BatchSendResult { + ids: string[]; + failed: { userId: string; error: string }[]; } export interface PaginatedResult { @@ -28,177 +38,320 @@ export interface PaginatedResult { pageSize: number; } +export interface SearchResult { + items: Record[]; + total: number; + page: number; + pageSize: number; +} + +/** + * 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 { - const id = randomUUID(); - const channel = dto.channel ?? "in_app"; - - const [pref] = await db - .select() - .from(notificationPreferences) - .where(eq(notificationPreferences.userId, dto.userId)); - - if (pref && !this.isChannelEnabled(pref, channel)) { - logger.info( - { userId: dto.userId, channel }, - "Notification skipped by preference", - ); - return { id, skipped: true, pushed: false }; + // 幂等:eventId 去重 + if (dto.eventId) { + const existing = await repo.findByEventId(dto.eventId); + if (existing) { + logger.info( + { eventId: dto.eventId, id: existing.id }, + "Notification already exists (idempotent skip)", + ); + return { + id: existing.id, + status: existing.status, + channels: [existing.channel], + }; + } } - await db.insert(notifications).values({ + const id = createId(); + const channel = (dto.channel ?? "in_app") as NotificationChannel; + + // 写入 MySQL + await repo.insertNotification({ id, userId: dto.userId, - type: dto.type, + type: dto.type as Notification["type"], title: dto.title, content: dto.content, channel, - metadata: dto.metadata, + isRead: false, + status: "pending", + metadata: dto.metadata ?? null, + relatedEntityType: dto.relatedEntityType ?? null, + relatedEntityId: dto.relatedEntityId ?? null, + groupId: dto.groupId ?? null, + senderId: dto.senderId ?? null, + templateId: dto.templateId ?? null, + eventId: dto.eventId ?? null, }); + // 写入 ES(降级安全) await safeIndex({ index: "notifications", id, document: { - userId: dto.userId, + id, + user_id: dto.userId, type: dto.type, title: dto.title, content: dto.content, channel, - createdAt: new Date().toISOString(), + status: "pending", + group_id: dto.groupId ?? null, + related_entity_type: dto.relatedEntityType ?? null, + related_entity_id: dto.relatedEntityId ?? null, + sender_id: dto.senderId ?? null, + is_read: false, + created_at: new Date().toISOString(), }, }); - const pushed = await this.notifyPushGateway(dto, channel); + // 查询用户偏好 + const enabledChannels = await this.getUserChannels(dto.userId, dto.type); - return { id, skipped: false, pushed }; + // 渠道分发 + const ctx: ChannelSendContext = { + notificationId: id, + userId: dto.userId, + title: dto.title, + content: dto.content, + type: dto.type, + metadata: dto.metadata ?? null, + relatedEntityType: dto.relatedEntityType, + relatedEntityId: dto.relatedEntityId, + }; + const results = await this.channelDispatcher.dispatch(ctx, enabledChannels); + + // 更新状态为 sent + const anySent = results.some((r) => r.sent); + await this.updateStatus(id, anySent ? "sent" : "failed"); + + // 写入 Outbox(发布 notification.sent 事件) + await outboxPublish( + "notification.sent", + { + notificationId: id, + userId: dto.userId, + type: dto.type, + channel, + channels: results.map((r) => r.channel), + }, + { + aggregateType: "Notification", + aggregateId: id, + metadata: { userId: dto.userId }, + }, + ); + + return { + id, + status: anySent ? "sent" : "failed", + channels: results.map((r) => r.channel), + }; } - async createBatch(dtos: SendNotificationDto[]): Promise { - const results: SendResult[] = []; - for (const dto of dtos) { - const result = await this.send(dto); - results.push(result); + async sendBatch(dto: SendNotificationBatchDto): Promise { + 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 { - 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 { - 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`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 { + async getUnreadCount(userId: string): Promise { + return repo.getUnreadCount(userId); + } + + async markAsRead(id: string, userId: string): Promise { + 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 { + 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 { + 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 { + const from = (options.page - 1) * options.pageSize; + const must: Record[] = [ + { 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 { + 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 { + await repo.deleteById(id); + await safeDelete("notifications", id); + } + + // ============================================================ + // 内部方法 + // ============================================================ + + private async getUserChannels( + userId: string, + type: string, + ): Promise { + 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 { + const db = getDb(); await db .update(notifications) - .set({ isRead: true }) + .set({ status }) .where(eq(notifications.id, id)); } - async search(userId: string, query: string): Promise { - 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 { - if (!env.PUSH_GATEWAY_URL) return false; - if (channel !== "push" && channel !== "in_app") return false; - - try { - const res = await fetch(`${env.PUSH_GATEWAY_URL}/internal/push`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - userId: dto.userId, - type: dto.type, - title: dto.title, - content: dto.content, - channel, - metadata: dto.metadata, - }), - }); - if (!res.ok) { - logger.warn( - { status: res.status }, - "Push gateway returned non-ok status", - ); - return false; - } - return true; - } catch (err) { - logger.warn({ err }, "Push gateway unavailable, skipping push"); - return false; - } + private async deleteEsByGroup(groupId: string): Promise { + // ES 删除按 group_id 查询后逐条删除(ES 无批量 delete by query 在 safeDelete 封装中) + // P5 简化:仅记录日志,实际清理走后台任务 + logger.info( + { groupId }, + "ES cleanup for recalled notifications (deferred)", + ); } } diff --git a/services/msg/src/preferences/preferences.controller.ts b/services/msg/src/preferences/preferences.controller.ts new file mode 100644 index 0000000..f8b98f2 --- /dev/null +++ b/services/msg/src/preferences/preferences.controller.ts @@ -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 = + typeof body === "object" && body !== null + ? (body as Record) + : {}; + const dto: UpdatePreferencesDto = updatePreferencesSchema.parse({ + ...obj, + userId, + }); + await this.service.update(dto); + return { success: true }; + } +} diff --git a/services/msg/src/preferences/preferences.dto.ts b/services/msg/src/preferences/preferences.dto.ts new file mode 100644 index 0000000..9e873d2 --- /dev/null +++ b/services/msg/src/preferences/preferences.dto.ts @@ -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; + +export const updatePreferencesSchema = z.object({ + userId: z.string().min(1), + preferences: z.array(preferenceSchema).min(1), +}); + +export type UpdatePreferencesDto = z.infer; diff --git a/services/msg/src/preferences/preferences.module.ts b/services/msg/src/preferences/preferences.module.ts new file mode 100644 index 0000000..33c09e9 --- /dev/null +++ b/services/msg/src/preferences/preferences.module.ts @@ -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 {} diff --git a/services/msg/src/preferences/preferences.repository.ts b/services/msg/src/preferences/preferences.repository.ts new file mode 100644 index 0000000..b3a5384 --- /dev/null +++ b/services/msg/src/preferences/preferences.repository.ts @@ -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 { + const db = getDb(); + return db + .select() + .from(notificationPreferences) + .where(eq(notificationPreferences.userId, userId)); +} + +export async function findByUserIdAndType( + userId: string, + type: string, +): Promise { + 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 { + 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 { + const db = getDb(); + await db + .delete(notificationPreferences) + .where(eq(notificationPreferences.userId, userId)); +} diff --git a/services/msg/src/preferences/preferences.service.ts b/services/msg/src/preferences/preferences.service.ts new file mode 100644 index 0000000..ee9bcdb --- /dev/null +++ b/services/msg/src/preferences/preferences.service.ts @@ -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 { + return repo.findByUserId(userId); + } + + async update(dto: UpdatePreferencesDto): Promise { + 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, + }); + } + } +} diff --git a/services/msg/src/shared/errors/application-error.ts b/services/msg/src/shared/errors/application-error.ts index 0e3b60f..d750a8b 100644 --- a/services/msg/src/shared/errors/application-error.ts +++ b/services/msg/src/shared/errors/application-error.ts @@ -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); } } diff --git a/services/msg/src/shared/health/health.controller.ts b/services/msg/src/shared/health/health.controller.ts index 7a9dcea..0cf2892 100644 --- a/services/msg/src/shared/health/health.controller.ts +++ b/services/msg/src/shared/health/health.controller.ts @@ -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; - }> { + async readiness(): Promise { + const checks: ReadyzResponse["checks"] = { + database: { status: "ok" }, + elasticsearch: { status: "disabled" }, + redis: { status: "disabled" }, + kafka: { status: "ok" }, + pushGateway: { status: "disabled" }, + }; + + // 1. DB(关键依赖) try { + const db = getDb(); await db.execute(sql`SELECT 1`); - return { - status: "ok", - service: SERVICE_NAME, - timestamp: new Date().toISOString(), - }; } catch (error) { + checks.database = { + status: "error", + error: error instanceof Error ? error.message : "database unreachable", + }; + } + + // 2. Elasticsearch(可选依赖) + if (esClient) { + try { + await esClient.ping(); + checks.elasticsearch = { status: "ok" }; + } catch (error) { + checks.elasticsearch = { + status: "error", + error: error instanceof Error ? error.message : "ES unreachable", + }; + } + } + + // 3. Redis(可选依赖) + const redis = getRedis(); + if (redis) { + try { + const pong = await redis.ping(); + checks.redis = { status: pong === "PONG" ? "ok" : "error" }; + } catch (error) { + checks.redis = { + status: "error", + error: error instanceof Error ? error.message : "Redis unreachable", + }; + } + } + + // 4. Kafka(非关键依赖,降级模式) + checks.kafka = { status: isKafkaHealthy() ? "ok" : "error" }; + + // 5. PushGateway(软失败,可选依赖) + try { + const pushOk = await checkPushGateway(); + checks.pushGateway = { status: pushOk ? "ok" : "error" }; + } catch { + checks.pushGateway = { + status: "error", + error: "PushGateway unreachable", + }; + } + + // DB 不可用 -> 503;其他依赖降级 -> 200 + degraded 标记 + const dbOk = checks.database.status === "ok"; + const allOk = dbOk && Object.values(checks).every((c) => c.status === "ok"); + + if (!dbOk) { throw new HttpException( { - status: "error", + status: "unavailable", service: SERVICE_NAME, timestamp: new Date().toISOString(), - error: - error instanceof Error ? error.message : "database unreachable", + checks, }, HttpStatus.SERVICE_UNAVAILABLE, ); } + + return { + status: allOk ? "ok" : "degraded", + service: SERVICE_NAME, + timestamp: new Date().toISOString(), + checks, + }; } } diff --git a/services/msg/src/shared/kafka/kafka.client.ts b/services/msg/src/shared/kafka/kafka.client.ts new file mode 100644 index 0000000..c8088c5 --- /dev/null +++ b/services/msg/src/shared/kafka/kafka.client.ts @@ -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 { + 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 { + 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)"); + } +} diff --git a/services/msg/src/shared/kafka/kafka.consumer.ts b/services/msg/src/shared/kafka/kafka.consumer.ts new file mode 100644 index 0000000..0f37cae --- /dev/null +++ b/services/msg/src/shared/kafka/kafka.consumer.ts @@ -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 { + await this.start(); + } + + async onModuleDestroy(): Promise { + await this.stop(); + } + + async start(): Promise { + 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 { + 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 { + 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 | 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; + } catch { + return null; + } + } + + /** + * 根据 topic 路由到具体的事件处理器。 + */ + private async routeEvent( + topic: string, + eventId: string, + payload: Record, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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" }, + }); + } +} diff --git a/services/msg/src/shared/kafka/topic-map.ts b/services/msg/src/shared/kafka/topic-map.ts new file mode 100644 index 0000000..fa88c37 --- /dev/null +++ b/services/msg/src/shared/kafka/topic-map.ts @@ -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 = { + "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 = { + "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; +} diff --git a/services/msg/src/shared/lifecycle/lifecycle.service.ts b/services/msg/src/shared/lifecycle/lifecycle.service.ts index 4b824e3..671ab18 100644 --- a/services/msg/src/shared/lifecycle/lifecycle.service.ts +++ b/services/msg/src/shared/lifecycle/lifecycle.service.ts @@ -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 { + // 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, + fn: () => Promise, ): Promise { try { await fn(); diff --git a/services/msg/src/shared/observability/metrics.ts b/services/msg/src/shared/observability/metrics.ts index 4eb7e4c..943d8c7 100644 --- a/services/msg/src/shared/observability/metrics.ts +++ b/services/msg/src/shared/observability/metrics.ts @@ -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; + +export const httpRequestDurationSeconds = registry.getSingleMetric( + "msg_http_request_duration_seconds", +) as promClient.Histogram; + +export const grpcRequestsTotal = registry.getSingleMetric( + "msg_grpc_requests_total", +) as promClient.Counter; + +export const notificationSentTotal = registry.getSingleMetric( + "msg_notification_sent_total", +) as promClient.Counter; + +export const notificationSendDurationSeconds = registry.getSingleMetric( + "msg_notification_send_duration_seconds", +) as promClient.Histogram; + +export const notificationFailedTotal = registry.getSingleMetric( + "msg_notification_failed_total", +) as promClient.Counter; + +export const notificationReadTotal = registry.getSingleMetric( + "msg_notification_read_total", +) as promClient.Counter; + +export const channelDispatchDurationSeconds = registry.getSingleMetric( + "msg_channel_dispatch_duration_seconds", +) as promClient.Histogram; + +export const kafkaConsumerLag = registry.getSingleMetric( + "msg_kafka_consumer_lag", +) as promClient.Gauge; + +export const kafkaConsumerProcessedTotal = registry.getSingleMetric( + "msg_kafka_consumer_processed_total", +) as promClient.Counter; + +export const idempotentDuplicateTotal = registry.getSingleMetric( + "msg_idempotent_duplicate_total", +) as promClient.Counter; + +export const outboxPendingCount = registry.getSingleMetric( + "msg_outbox_pending_count", +) as promClient.Gauge; + +export const pushGatewayCallTotal = registry.getSingleMetric( + "msg_push_gateway_call_total", +) as promClient.Counter; + +export const pushGatewayCallDurationSeconds = registry.getSingleMetric( + "msg_push_gateway_call_duration_seconds", +) as promClient.Histogram; + +export const unreadCount = registry.getSingleMetric( + "msg_unread_count", +) as promClient.Gauge; diff --git a/services/msg/src/shared/outbox/outbox.publisher.ts b/services/msg/src/shared/outbox/outbox.publisher.ts new file mode 100644 index 0000000..6778a97 --- /dev/null +++ b/services/msg/src/shared/outbox/outbox.publisher.ts @@ -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 | null = null; + private isPolling = false; + + async start(): Promise { + 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 { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + logger.info("OutboxPublisher stopped"); + } + + private async poll(): Promise { + 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 { + 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 { + const headers: Record = { + 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(); diff --git a/services/msg/src/shared/outbox/outbox.repository.ts b/services/msg/src/shared/outbox/outbox.repository.ts new file mode 100644 index 0000000..a05e8eb --- /dev/null +++ b/services/msg/src/shared/outbox/outbox.repository.ts @@ -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 { + 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 { + 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 { + 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 { + 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)); +} diff --git a/services/msg/src/shared/outbox/outbox.schema.ts b/services/msg/src/shared/outbox/outbox.schema.ts new file mode 100644 index 0000000..d439788 --- /dev/null +++ b/services/msg/src/shared/outbox/outbox.schema.ts @@ -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(), + 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 | 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; diff --git a/services/msg/src/shared/outbox/outbox.service.ts b/services/msg/src/shared/outbox/outbox.service.ts new file mode 100644 index 0000000..0324d9f --- /dev/null +++ b/services/msg/src/shared/outbox/outbox.service.ts @@ -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; + /** 延迟投递毫秒数 */ + 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 { + 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, + 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 }; +} diff --git a/services/msg/src/shared/push/push-gateway.client.ts b/services/msg/src/shared/push/push-gateway.client.ts new file mode 100644 index 0000000..f8710fc --- /dev/null +++ b/services/msg/src/shared/push/push-gateway.client.ts @@ -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; +} + +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 { + if (!env.PUSH_GATEWAY_URL) { + return { sent: false }; + } + + const url = `${env.PUSH_GATEWAY_URL}/internal/push`; + const headers: Record = { + "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 { + 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 { + 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; + } +} diff --git a/services/msg/src/shared/redis/idempotency.guard.ts b/services/msg/src/shared/redis/idempotency.guard.ts new file mode 100644 index 0000000..3ce5c44 --- /dev/null +++ b/services/msg/src/shared/redis/idempotency.guard.ts @@ -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 { + 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 { + 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(); +} diff --git a/services/msg/src/shared/redis/redis.client.ts b/services/msg/src/shared/redis/redis.client.ts new file mode 100644 index 0000000..5d07e35 --- /dev/null +++ b/services/msg/src/shared/redis/redis.client.ts @@ -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 { + 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 { + if (redisInstance) { + await redisInstance.quit(); + redisInstance = null; + connectAttempted = false; + logger.info("Redis disconnected"); + } +} diff --git a/services/msg/src/templates/templates.controller.ts b/services/msg/src/templates/templates.controller.ts new file mode 100644 index 0000000..a51b093 --- /dev/null +++ b/services/msg/src/templates/templates.controller.ts @@ -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 }; + } +} diff --git a/services/msg/src/templates/templates.dto.ts b/services/msg/src/templates/templates.dto.ts new file mode 100644 index 0000000..98774bd --- /dev/null +++ b/services/msg/src/templates/templates.dto.ts @@ -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; + +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; + +export const listTemplatesSchema = z.object({ + type: typeEnum.optional(), + status: statusEnum.optional(), +}); + +export type ListTemplatesDto = z.infer; + +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; diff --git a/services/msg/src/templates/templates.module.ts b/services/msg/src/templates/templates.module.ts new file mode 100644 index 0000000..5639c8f --- /dev/null +++ b/services/msg/src/templates/templates.module.ts @@ -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 {} diff --git a/services/msg/src/templates/templates.repository.ts b/services/msg/src/templates/templates.repository.ts new file mode 100644 index 0000000..4e7417a --- /dev/null +++ b/services/msg/src/templates/templates.repository.ts @@ -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, +): Promise { + 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 { + 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 { + 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 { + 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>, +): Promise { + const db = getDb(); + await db + .update(notificationTemplates) + .set(row) + .where(eq(notificationTemplates.id, id)); +} + +export async function remove(id: string): Promise { + const db = getDb(); + await db + .delete(notificationTemplates) + .where(eq(notificationTemplates.id, id)); +} diff --git a/services/msg/src/templates/templates.service.ts b/services/msg/src/templates/templates.service.ts new file mode 100644 index 0000000..9ceedb5 --- /dev/null +++ b/services/msg/src/templates/templates.service.ts @@ -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 { + 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 { + const tpl = await repo.findById(id); + if (!tpl) { + throw new NotFoundError("Template", id); + } + return tpl; + } + + async list(dto: ListTemplatesDto): Promise { + return repo.list(dto); + } + + async update( + id: string, + dto: UpdateTemplateDto, + ): Promise { + 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 { + await this.getById(id); // 确保存在 + await repo.remove(id); + } + + /** + * 渲染模板:将 {{variable}} 占位符替换为实际值。 + * + * 仲裁依据 02-architecture-design.md §4.2.3 RenderTemplate RPC。 + */ + async render(dto: RenderTemplateDto): Promise { + 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 { + return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => { + return variables[key] ?? match; + }); + } +}