feat(msg): 完整实现 msg 消息服务
包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
25
services/msg/src/grpc/grpc.module.ts
Normal file
25
services/msg/src/grpc/grpc.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NotificationsModule } from "../notifications/notifications.module.js";
|
||||
import { PreferencesModule } from "../preferences/preferences.module.js";
|
||||
import { TemplatesModule } from "../templates/templates.module.js";
|
||||
import { NotificationsGrpcController } from "./notifications.grpc.controller.js";
|
||||
import { PreferencesGrpcController } from "./preferences.grpc.controller.js";
|
||||
import { TemplatesGrpcController } from "./templates.grpc.controller.js";
|
||||
|
||||
/**
|
||||
* GrpcModule —— 注册 3 个 gRPC controller(17 RPC)。
|
||||
*
|
||||
* 仲裁依据 M1:gRPC 50056 启用,proto msg.proto 定义 3 Service 共 17 RPC。
|
||||
*
|
||||
* Module 导入 NotificationsModule / PreferencesModule / TemplatesModule
|
||||
* 以获取各自的 Service 单例(NestJS 模块单例,不重复实例化)。
|
||||
*/
|
||||
@Module({
|
||||
imports: [NotificationsModule, PreferencesModule, TemplatesModule],
|
||||
controllers: [
|
||||
NotificationsGrpcController,
|
||||
PreferencesGrpcController,
|
||||
TemplatesGrpcController,
|
||||
],
|
||||
})
|
||||
export class GrpcModule {}
|
||||
303
services/msg/src/grpc/notifications.grpc.controller.ts
Normal file
303
services/msg/src/grpc/notifications.grpc.controller.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { NotificationsService } from "../notifications/notifications.service.js";
|
||||
import {
|
||||
sendNotificationSchema,
|
||||
sendNotificationBatchSchema,
|
||||
} from "../notifications/notifications.dto.js";
|
||||
import type { Notification } from "../notifications/notifications.schema.js";
|
||||
|
||||
/**
|
||||
* NotificationsGrpcController —— gRPC 入口(NotificationService 9 RPC)。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - M1:gRPC 50056 启用
|
||||
* - msg.proto §NotificationService(9 RPC)
|
||||
* - proto-loader 默认 keepCase=false,字段名 camelCase
|
||||
*
|
||||
* gRPC 方法接收 proto message(camelCase 字段),调用 NotificationsService,
|
||||
* 返回 proto message(camelCase 字段,int64 时间戳为 epoch 毫秒)。
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 类型定义(对齐 proto msg.proto)
|
||||
// ============================================================
|
||||
|
||||
interface GrpcNotification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
channel: string;
|
||||
isRead: boolean;
|
||||
createdAt: number;
|
||||
status: string;
|
||||
relatedEntityType: string;
|
||||
relatedEntityId: string;
|
||||
groupId: string;
|
||||
senderId: string;
|
||||
templateId: string;
|
||||
eventId: string;
|
||||
readAt: number;
|
||||
updatedAt: number;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
interface GrpcBatchSendFailure {
|
||||
userId: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
/** proto3 默认空字符串 -> undefined(让 Zod optional 生效) */
|
||||
function opt(val: unknown): string | undefined {
|
||||
if (typeof val === "string" && val.length > 0) return val;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** proto3 默认 0 -> undefined(让分页使用默认值) */
|
||||
function optNum(val: unknown): number | undefined {
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
}
|
||||
|
||||
/** epoch 毫秒:Date -> number,其他 -> number|0 */
|
||||
function toEpoch(val: unknown): number {
|
||||
if (val instanceof Date) return val.getTime();
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 DB Notification 记录(camelCase + Date)或 ES 命中(snake_case)统一映射为 proto Notification。
|
||||
*/
|
||||
function toGrpcNotification(n: Record<string, unknown>): GrpcNotification {
|
||||
const createdAt = n.createdAt ?? n.created_at;
|
||||
const readAt = n.readAt ?? n.read_at;
|
||||
const updatedAt = n.updatedAt ?? n.updated_at;
|
||||
return {
|
||||
id: String(n.id ?? ""),
|
||||
userId: String(n.userId ?? n.user_id ?? ""),
|
||||
type: String(n.type ?? ""),
|
||||
title: String(n.title ?? ""),
|
||||
content: String(n.content ?? ""),
|
||||
channel: String(n.channel ?? ""),
|
||||
isRead: Boolean(n.isRead ?? n.is_read ?? false),
|
||||
createdAt: toEpoch(createdAt),
|
||||
status: String(n.status ?? ""),
|
||||
relatedEntityType: String(
|
||||
n.relatedEntityType ?? n.related_entity_type ?? "",
|
||||
),
|
||||
relatedEntityId: String(n.relatedEntityId ?? n.related_entity_id ?? ""),
|
||||
groupId: String(n.groupId ?? n.group_id ?? ""),
|
||||
senderId: String(n.senderId ?? n.sender_id ?? ""),
|
||||
templateId: String(n.templateId ?? n.template_id ?? ""),
|
||||
eventId: String(n.eventId ?? n.event_id ?? ""),
|
||||
readAt: toEpoch(readAt),
|
||||
updatedAt: toEpoch(updatedAt),
|
||||
metadata: (n.metadata ?? {}) as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Controller
|
||||
// ============================================================
|
||||
|
||||
@Controller()
|
||||
export class NotificationsGrpcController {
|
||||
constructor(private readonly service: NotificationsService) {}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SendNotification
|
||||
// ----------------------------------------------------------
|
||||
@GrpcMethod("NotificationService", "SendNotification")
|
||||
async sendNotification(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<GrpcNotification> {
|
||||
const dto = sendNotificationSchema.parse({
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
channel: opt(data.channel),
|
||||
metadata: data.metadata,
|
||||
relatedEntityType: opt(data.relatedEntityType),
|
||||
relatedEntityId: opt(data.relatedEntityId),
|
||||
groupId: opt(data.groupId),
|
||||
senderId: opt(data.senderId),
|
||||
templateId: opt(data.templateId),
|
||||
eventId: opt(data.eventId),
|
||||
});
|
||||
|
||||
const result = await this.service.send(dto);
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
userId: dto.userId,
|
||||
type: dto.type,
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
channel: dto.channel ?? "in_app",
|
||||
isRead: false,
|
||||
createdAt: now,
|
||||
status: result.status,
|
||||
relatedEntityType: dto.relatedEntityType ?? "",
|
||||
relatedEntityId: dto.relatedEntityId ?? "",
|
||||
groupId: dto.groupId ?? "",
|
||||
senderId: dto.senderId ?? "",
|
||||
templateId: dto.templateId ?? "",
|
||||
eventId: dto.eventId ?? "",
|
||||
readAt: 0,
|
||||
updatedAt: now,
|
||||
metadata: dto.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// BatchSendNotification
|
||||
// ----------------------------------------------------------
|
||||
@GrpcMethod("NotificationService", "BatchSendNotification")
|
||||
async batchSendNotification(data: Record<string, unknown>): Promise<{
|
||||
ids: string[];
|
||||
failed: GrpcBatchSendFailure[];
|
||||
}> {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
const dto = sendNotificationBatchSchema.parse({
|
||||
items: items.map((item: Record<string, unknown>) => ({
|
||||
userId: item.userId,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
channel: opt(item.channel),
|
||||
metadata: item.metadata,
|
||||
relatedEntityType: opt(item.relatedEntityType),
|
||||
relatedEntityId: opt(item.relatedEntityId),
|
||||
groupId: opt(item.groupId),
|
||||
senderId: opt(item.senderId),
|
||||
templateId: opt(item.templateId),
|
||||
eventId: opt(item.eventId),
|
||||
})),
|
||||
groupId: opt(data.groupId),
|
||||
});
|
||||
|
||||
const result = await this.service.sendBatch(dto);
|
||||
return {
|
||||
ids: result.ids,
|
||||
failed: result.failed,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ListNotifications
|
||||
// ----------------------------------------------------------
|
||||
@GrpcMethod("NotificationService", "ListNotifications")
|
||||
async listNotifications(data: Record<string, unknown>): Promise<{
|
||||
notifications: GrpcNotification[];
|
||||
total: number;
|
||||
}> {
|
||||
const result = await this.service.listByUser(String(data.userId ?? ""), {
|
||||
onlyUnread: Boolean(data.onlyUnread),
|
||||
type: opt(data.type),
|
||||
page: optNum(data.page) ?? 1,
|
||||
pageSize: optNum(data.pageSize) ?? 20,
|
||||
});
|
||||
|
||||
return {
|
||||
notifications: result.items.map((n: Notification) =>
|
||||
toGrpcNotification(n as unknown as Record<string, unknown>),
|
||||
),
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// GetUnreadCount
|
||||
// ----------------------------------------------------------
|
||||
@GrpcMethod("NotificationService", "GetUnreadCount")
|
||||
async getUnreadCount(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<{ count: number }> {
|
||||
const count = await this.service.getUnreadCount(String(data.userId ?? ""));
|
||||
return { count };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MarkAsRead
|
||||
// ----------------------------------------------------------
|
||||
@GrpcMethod("NotificationService", "MarkAsRead")
|
||||
async markAsRead(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<Record<string, never>> {
|
||||
await this.service.markAsRead(
|
||||
String(data.id ?? ""),
|
||||
String(data.userId ?? ""),
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// BatchMarkAsRead
|
||||
// ----------------------------------------------------------
|
||||
@GrpcMethod("NotificationService", "BatchMarkAsRead")
|
||||
async batchMarkAsRead(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<Record<string, never>> {
|
||||
const ids = Array.isArray(data.ids) ? (data.ids as string[]) : [];
|
||||
await this.service.batchMarkAsRead(ids, String(data.userId ?? ""));
|
||||
return {};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MarkAllAsRead
|
||||
// ----------------------------------------------------------
|
||||
@GrpcMethod("NotificationService", "MarkAllAsRead")
|
||||
async markAllAsRead(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<Record<string, never>> {
|
||||
await this.service.markAllAsRead(
|
||||
String(data.userId ?? ""),
|
||||
optNum(data.before),
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// SearchNotifications
|
||||
// ----------------------------------------------------------
|
||||
@GrpcMethod("NotificationService", "SearchNotifications")
|
||||
async searchNotifications(data: Record<string, unknown>): Promise<{
|
||||
notifications: GrpcNotification[];
|
||||
total: number;
|
||||
}> {
|
||||
const result = await this.service.search(
|
||||
String(data.userId ?? ""),
|
||||
String(data.query ?? ""),
|
||||
{
|
||||
type: opt(data.type),
|
||||
page: optNum(data.page) ?? 1,
|
||||
pageSize: optNum(data.pageSize) ?? 20,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
notifications: result.items.map((n) => toGrpcNotification(n)),
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RecallNotification
|
||||
// ----------------------------------------------------------
|
||||
@GrpcMethod("NotificationService", "RecallNotification")
|
||||
async recallNotification(data: Record<string, unknown>): Promise<{
|
||||
recalledCount: number;
|
||||
}> {
|
||||
const recalledCount = await this.service.recall(String(data.groupId ?? ""));
|
||||
return { recalledCount };
|
||||
}
|
||||
}
|
||||
84
services/msg/src/grpc/preferences.grpc.controller.ts
Normal file
84
services/msg/src/grpc/preferences.grpc.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { PreferencesService } from "../preferences/preferences.service.js";
|
||||
import { updatePreferencesSchema } from "../preferences/preferences.dto.js";
|
||||
import type { NotificationPreference } from "../notifications/notifications.schema.js";
|
||||
|
||||
/**
|
||||
* PreferencesGrpcController —— gRPC 入口(NotificationPreferenceService 2 RPC)。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - msg.proto §NotificationPreferenceService(GetPreferences / UpdatePreferences)
|
||||
* - proto-loader 默认 keepCase=false,字段名 camelCase
|
||||
*/
|
||||
|
||||
interface GrpcPreference {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
channels: string[];
|
||||
frequencyLimit: number;
|
||||
quietHoursStart: string;
|
||||
quietHoursEnd: string;
|
||||
quietHoursTimezone: string;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function opt(val: unknown): string | undefined {
|
||||
if (typeof val === "string" && val.length > 0) return val;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toGrpcPreference(p: NotificationPreference): GrpcPreference {
|
||||
return {
|
||||
id: p.id,
|
||||
userId: p.userId,
|
||||
type: p.type,
|
||||
channels: p.channels,
|
||||
frequencyLimit: p.frequencyLimit ?? 0,
|
||||
quietHoursStart: p.quietHoursStart ?? "",
|
||||
quietHoursEnd: p.quietHoursEnd ?? "",
|
||||
quietHoursTimezone: p.quietHoursTimezone ?? "",
|
||||
enabled: p.enabled,
|
||||
createdAt: p.createdAt instanceof Date ? p.createdAt.getTime() : 0,
|
||||
updatedAt: p.updatedAt instanceof Date ? p.updatedAt.getTime() : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class PreferencesGrpcController {
|
||||
constructor(private readonly service: PreferencesService) {}
|
||||
|
||||
@GrpcMethod("NotificationPreferenceService", "GetPreferences")
|
||||
async getPreferences(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<{ preferences: GrpcPreference[] }> {
|
||||
const prefs = await this.service.getByUserId(String(data.userId ?? ""));
|
||||
return { preferences: prefs.map(toGrpcPreference) };
|
||||
}
|
||||
|
||||
@GrpcMethod("NotificationPreferenceService", "UpdatePreferences")
|
||||
async updatePreferences(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<Record<string, never>> {
|
||||
const rawPrefs = Array.isArray(data.preferences) ? data.preferences : [];
|
||||
|
||||
const dto = updatePreferencesSchema.parse({
|
||||
userId: data.userId,
|
||||
preferences: rawPrefs.map((p: Record<string, unknown>) => ({
|
||||
type: p.type,
|
||||
channels: Array.isArray(p.channels) ? p.channels : [],
|
||||
frequencyLimit: p.frequencyLimit ? Number(p.frequencyLimit) : undefined,
|
||||
quietHoursStart: opt(p.quietHoursStart),
|
||||
quietHoursEnd: opt(p.quietHoursEnd),
|
||||
quietHoursTimezone: opt(p.quietHoursTimezone),
|
||||
enabled: p.enabled ?? true,
|
||||
})),
|
||||
});
|
||||
|
||||
await this.service.update(dto);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
130
services/msg/src/grpc/templates.grpc.controller.ts
Normal file
130
services/msg/src/grpc/templates.grpc.controller.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { TemplatesService } from "../templates/templates.service.js";
|
||||
import {
|
||||
createTemplateSchema,
|
||||
updateTemplateSchema,
|
||||
listTemplatesSchema,
|
||||
renderTemplateSchema,
|
||||
} from "../templates/templates.dto.js";
|
||||
import type { NotificationTemplate } from "../notifications/notifications.schema.js";
|
||||
|
||||
/**
|
||||
* TemplatesGrpcController —— gRPC 入口(NotificationTemplateService 6 RPC)。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - msg.proto §NotificationTemplateService(6 RPC)
|
||||
* - proto-loader 默认 keepCase=false,字段名 camelCase
|
||||
*/
|
||||
|
||||
interface GrpcTemplate {
|
||||
id: string;
|
||||
code: string;
|
||||
type: string;
|
||||
titleTemplate: string;
|
||||
contentTemplate: string;
|
||||
defaultChannels: string[];
|
||||
variables: string[];
|
||||
locale: string;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function opt(val: unknown): string | undefined {
|
||||
if (typeof val === "string" && val.length > 0) return val;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toGrpcTemplate(t: NotificationTemplate): GrpcTemplate {
|
||||
return {
|
||||
id: t.id,
|
||||
code: t.code,
|
||||
type: t.type,
|
||||
titleTemplate: t.titleTemplate,
|
||||
contentTemplate: t.contentTemplate,
|
||||
defaultChannels: t.defaultChannels,
|
||||
variables: t.variables,
|
||||
locale: t.locale,
|
||||
status: t.status,
|
||||
createdAt: t.createdAt instanceof Date ? t.createdAt.getTime() : 0,
|
||||
updatedAt: t.updatedAt instanceof Date ? t.updatedAt.getTime() : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class TemplatesGrpcController {
|
||||
constructor(private readonly service: TemplatesService) {}
|
||||
|
||||
@GrpcMethod("NotificationTemplateService", "CreateTemplate")
|
||||
async createTemplate(data: Record<string, unknown>): Promise<GrpcTemplate> {
|
||||
const dto = createTemplateSchema.parse({
|
||||
code: data.code,
|
||||
type: data.type,
|
||||
titleTemplate: data.titleTemplate,
|
||||
contentTemplate: data.contentTemplate,
|
||||
defaultChannels: Array.isArray(data.defaultChannels)
|
||||
? data.defaultChannels
|
||||
: [],
|
||||
variables: Array.isArray(data.variables) ? data.variables : [],
|
||||
locale: opt(data.locale),
|
||||
});
|
||||
const tpl = await this.service.create(dto);
|
||||
return toGrpcTemplate(tpl);
|
||||
}
|
||||
|
||||
@GrpcMethod("NotificationTemplateService", "GetTemplate")
|
||||
async getTemplate(data: Record<string, unknown>): Promise<GrpcTemplate> {
|
||||
const tpl = await this.service.getById(String(data.id ?? ""));
|
||||
return toGrpcTemplate(tpl);
|
||||
}
|
||||
|
||||
@GrpcMethod("NotificationTemplateService", "ListTemplates")
|
||||
async listTemplates(data: Record<string, unknown>): Promise<{
|
||||
templates: GrpcTemplate[];
|
||||
}> {
|
||||
const dto = listTemplatesSchema.parse({
|
||||
type: opt(data.type),
|
||||
status: opt(data.status),
|
||||
});
|
||||
const templates = await this.service.list(dto);
|
||||
return { templates: templates.map(toGrpcTemplate) };
|
||||
}
|
||||
|
||||
@GrpcMethod("NotificationTemplateService", "UpdateTemplate")
|
||||
async updateTemplate(data: Record<string, unknown>): Promise<GrpcTemplate> {
|
||||
const dto = updateTemplateSchema.parse({
|
||||
titleTemplate: opt(data.titleTemplate),
|
||||
contentTemplate: opt(data.contentTemplate),
|
||||
defaultChannels: Array.isArray(data.defaultChannels)
|
||||
? data.defaultChannels
|
||||
: undefined,
|
||||
variables: Array.isArray(data.variables) ? data.variables : undefined,
|
||||
status: opt(data.status),
|
||||
});
|
||||
const tpl = await this.service.update(String(data.id ?? ""), dto);
|
||||
return toGrpcTemplate(tpl);
|
||||
}
|
||||
|
||||
@GrpcMethod("NotificationTemplateService", "DeleteTemplate")
|
||||
async deleteTemplate(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<Record<string, never>> {
|
||||
await this.service.delete(String(data.id ?? ""));
|
||||
return {};
|
||||
}
|
||||
|
||||
@GrpcMethod("NotificationTemplateService", "RenderTemplate")
|
||||
async renderTemplate(data: Record<string, unknown>): Promise<{
|
||||
title: string;
|
||||
content: string;
|
||||
}> {
|
||||
const dto = renderTemplateSchema.parse({
|
||||
code: data.code,
|
||||
variables: data.variables ?? {},
|
||||
locale: opt(data.locale),
|
||||
});
|
||||
const result = await this.service.render(dto);
|
||||
return { title: result.title, content: result.content };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user