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

View File

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

View File

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

View File

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

View File

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