feat(msg): 完整实现 msg 消息服务

包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
SpecialX
2026-07-10 19:09:52 +08:00
parent 21530dc7f6
commit 7b7abbb309
51 changed files with 5204 additions and 371 deletions

View File

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

View File

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

View File

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

View File

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