feat(msg): 完整实现 msg 消息服务
包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
103
services/msg/src/templates/templates.controller.ts
Normal file
103
services/msg/src/templates/templates.controller.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { TemplatesService } from "./templates.service.js";
|
||||
import {
|
||||
createTemplateSchema,
|
||||
updateTemplateSchema,
|
||||
renderTemplateSchema,
|
||||
listTemplatesSchema,
|
||||
} from "./templates.dto.js";
|
||||
import type {
|
||||
CreateTemplateDto,
|
||||
UpdateTemplateDto,
|
||||
RenderTemplateDto,
|
||||
} from "./templates.dto.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
|
||||
/**
|
||||
* TemplatesController —— 通知模板 REST API。
|
||||
*
|
||||
* 对齐 02-architecture-design.md §4.1:
|
||||
* - GET /templates(列表)
|
||||
* - POST /templates(创建)
|
||||
* - PUT /templates/:id(更新)
|
||||
* - DELETE /templates/:id(删除)
|
||||
* - POST /templates/render(渲染)
|
||||
*/
|
||||
@Controller("templates")
|
||||
export class TemplatesController {
|
||||
constructor(private readonly service: TemplatesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
|
||||
async list(
|
||||
@Query("type") type: string,
|
||||
@Query("status") status: string,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
// 用 Zod schema 验证 query 参数,自动推断 NotificationType/TemplateStatus 联合类型
|
||||
const filter = listTemplatesSchema.parse({
|
||||
type: type || undefined,
|
||||
status: status || undefined,
|
||||
});
|
||||
const templates = await this.service.list(filter);
|
||||
return { success: true, data: { templates } };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
|
||||
async create(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dto: CreateTemplateDto = createTemplateSchema.parse(body);
|
||||
const template = await this.service.create(dto);
|
||||
return { success: true, data: template };
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
|
||||
async getById(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const template = await this.service.getById(id);
|
||||
return { success: true, data: template };
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dto: UpdateTemplateDto = updateTemplateSchema.parse(body);
|
||||
const template = await this.service.update(id, dto);
|
||||
return { success: true, data: template };
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
|
||||
async delete(@Param("id") id: string): Promise<{ success: true }> {
|
||||
await this.service.delete(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post("render")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
|
||||
async render(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dto: RenderTemplateDto = renderTemplateSchema.parse(body);
|
||||
const rendered = await this.service.render(dto);
|
||||
return { success: true, data: rendered };
|
||||
}
|
||||
}
|
||||
55
services/msg/src/templates/templates.dto.ts
Normal file
55
services/msg/src/templates/templates.dto.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Templates 模块 DTO + Zod 校验。
|
||||
*
|
||||
* 对齐 proto msg.proto NotificationTemplate 字段。
|
||||
*/
|
||||
|
||||
const typeEnum = z.enum([
|
||||
"system",
|
||||
"exam",
|
||||
"homework",
|
||||
"grade",
|
||||
"attendance",
|
||||
"mastery",
|
||||
]);
|
||||
const channelEnum = z.enum(["in_app", "email", "sms", "push", "wechat"]);
|
||||
const statusEnum = z.enum(["draft", "active", "archived"]);
|
||||
|
||||
export const createTemplateSchema = z.object({
|
||||
code: z.string().min(1).max(64),
|
||||
type: typeEnum,
|
||||
titleTemplate: z.string().min(1).max(255),
|
||||
contentTemplate: z.string().min(1),
|
||||
defaultChannels: z.array(channelEnum).min(1),
|
||||
variables: z.array(z.string()).default([]),
|
||||
locale: z.string().max(16).default("zh-CN"),
|
||||
});
|
||||
|
||||
export type CreateTemplateDto = z.infer<typeof createTemplateSchema>;
|
||||
|
||||
export const updateTemplateSchema = z.object({
|
||||
titleTemplate: z.string().min(1).max(255).optional(),
|
||||
contentTemplate: z.string().min(1).optional(),
|
||||
defaultChannels: z.array(channelEnum).min(1).optional(),
|
||||
variables: z.array(z.string()).optional(),
|
||||
status: statusEnum.optional(),
|
||||
});
|
||||
|
||||
export type UpdateTemplateDto = z.infer<typeof updateTemplateSchema>;
|
||||
|
||||
export const listTemplatesSchema = z.object({
|
||||
type: typeEnum.optional(),
|
||||
status: statusEnum.optional(),
|
||||
});
|
||||
|
||||
export type ListTemplatesDto = z.infer<typeof listTemplatesSchema>;
|
||||
|
||||
export const renderTemplateSchema = z.object({
|
||||
code: z.string().min(1).max(64),
|
||||
variables: z.record(z.string()).default({}),
|
||||
locale: z.string().max(16).optional(),
|
||||
});
|
||||
|
||||
export type RenderTemplateDto = z.infer<typeof renderTemplateSchema>;
|
||||
10
services/msg/src/templates/templates.module.ts
Normal file
10
services/msg/src/templates/templates.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TemplatesController } from "./templates.controller.js";
|
||||
import { TemplatesService } from "./templates.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [TemplatesController],
|
||||
providers: [TemplatesService],
|
||||
exports: [TemplatesService],
|
||||
})
|
||||
export class TemplatesModule {}
|
||||
98
services/msg/src/templates/templates.repository.ts
Normal file
98
services/msg/src/templates/templates.repository.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { and, eq, type SQL } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
notificationTemplates,
|
||||
type NotificationTemplate,
|
||||
type NewNotificationTemplate,
|
||||
} from "../notifications/notifications.schema.js";
|
||||
|
||||
/**
|
||||
* TemplatesRepository —— 通知模板数据访问层。
|
||||
*/
|
||||
|
||||
export async function insert(
|
||||
row: Omit<NewNotificationTemplate, "id" | "createdAt" | "updatedAt">,
|
||||
): Promise<NotificationTemplate> {
|
||||
const db = getDb();
|
||||
const newTpl: NewNotificationTemplate = {
|
||||
id: createId(),
|
||||
...row,
|
||||
};
|
||||
await db.insert(notificationTemplates).values(newTpl);
|
||||
return newTpl as NotificationTemplate;
|
||||
}
|
||||
|
||||
export async function findById(
|
||||
id: string,
|
||||
): Promise<NotificationTemplate | undefined> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(notificationTemplates)
|
||||
.where(eq(notificationTemplates.id, id))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function findByCode(
|
||||
code: string,
|
||||
locale?: string,
|
||||
): Promise<NotificationTemplate | undefined> {
|
||||
const db = getDb();
|
||||
const conditions = [eq(notificationTemplates.code, code)];
|
||||
if (locale) {
|
||||
conditions.push(eq(notificationTemplates.locale, locale));
|
||||
}
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(notificationTemplates)
|
||||
.where(and(...conditions))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function list(options: {
|
||||
type?: string;
|
||||
status?: string;
|
||||
}): Promise<NotificationTemplate[]> {
|
||||
const db = getDb();
|
||||
const conditions: SQL[] = [];
|
||||
if (options.type) {
|
||||
conditions.push(
|
||||
eq(
|
||||
notificationTemplates.type,
|
||||
options.type as NotificationTemplate["type"],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (options.status) {
|
||||
conditions.push(
|
||||
eq(
|
||||
notificationTemplates.status,
|
||||
options.status as NotificationTemplate["status"],
|
||||
),
|
||||
);
|
||||
}
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const query = db.select().from(notificationTemplates);
|
||||
return where ? query.where(where) : query;
|
||||
}
|
||||
|
||||
export async function update(
|
||||
id: string,
|
||||
row: Partial<Omit<NewNotificationTemplate, "id" | "createdAt" | "updatedAt">>,
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.update(notificationTemplates)
|
||||
.set(row)
|
||||
.where(eq(notificationTemplates.id, id));
|
||||
}
|
||||
|
||||
export async function remove(id: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(notificationTemplates)
|
||||
.where(eq(notificationTemplates.id, id));
|
||||
}
|
||||
120
services/msg/src/templates/templates.service.ts
Normal file
120
services/msg/src/templates/templates.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { NotificationTemplate } from "../notifications/notifications.schema.js";
|
||||
import * as repo from "./templates.repository.js";
|
||||
import type {
|
||||
CreateTemplateDto,
|
||||
UpdateTemplateDto,
|
||||
ListTemplatesDto,
|
||||
RenderTemplateDto,
|
||||
} from "./templates.dto.js";
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
|
||||
export interface RenderedNotification {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TemplateService —— 通知模板管理 + 渲染。
|
||||
*
|
||||
* 职责:
|
||||
* - CRUD 模板
|
||||
* - 渲染模板({{variable}} 占位符替换)
|
||||
*
|
||||
* 仲裁依据 02-architecture-design.md §3.1.3:模板按 (code, locale) 唯一。
|
||||
*/
|
||||
@Injectable()
|
||||
export class TemplatesService {
|
||||
async create(dto: CreateTemplateDto): Promise<NotificationTemplate> {
|
||||
return repo.insert({
|
||||
code: dto.code,
|
||||
type: dto.type,
|
||||
titleTemplate: dto.titleTemplate,
|
||||
contentTemplate: dto.contentTemplate,
|
||||
defaultChannels: dto.defaultChannels,
|
||||
variables: dto.variables,
|
||||
locale: dto.locale,
|
||||
status: "draft",
|
||||
});
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<NotificationTemplate> {
|
||||
const tpl = await repo.findById(id);
|
||||
if (!tpl) {
|
||||
throw new NotFoundError("Template", id);
|
||||
}
|
||||
return tpl;
|
||||
}
|
||||
|
||||
async list(dto: ListTemplatesDto): Promise<NotificationTemplate[]> {
|
||||
return repo.list(dto);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateTemplateDto,
|
||||
): Promise<NotificationTemplate> {
|
||||
const existing = await this.getById(id);
|
||||
await repo.update(id, {
|
||||
titleTemplate: dto.titleTemplate,
|
||||
contentTemplate: dto.contentTemplate,
|
||||
defaultChannels: dto.defaultChannels,
|
||||
variables: dto.variables,
|
||||
status: dto.status,
|
||||
});
|
||||
return { ...existing, ...dto } as NotificationTemplate;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getById(id); // 确保存在
|
||||
await repo.remove(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板:将 {{variable}} 占位符替换为实际值。
|
||||
*
|
||||
* 仲裁依据 02-architecture-design.md §4.2.3 RenderTemplate RPC。
|
||||
*/
|
||||
async render(dto: RenderTemplateDto): Promise<RenderedNotification> {
|
||||
const tpl = await repo.findByCode(dto.code, dto.locale);
|
||||
if (!tpl) {
|
||||
throw new NotFoundError(
|
||||
"Template",
|
||||
`code=${dto.code} locale=${dto.locale ?? "default"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 检查必填变量
|
||||
if (tpl.variables && Array.isArray(tpl.variables)) {
|
||||
for (const varName of tpl.variables) {
|
||||
if (!(varName in dto.variables)) {
|
||||
throw new ValidationError(`Missing required variable: ${varName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const title = this.replacePlaceholders(tpl.titleTemplate, dto.variables);
|
||||
const content = this.replacePlaceholders(
|
||||
tpl.contentTemplate,
|
||||
dto.variables,
|
||||
);
|
||||
|
||||
return { title, content };
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换 {{variable}} 占位符。
|
||||
* 简单实现:正则匹配 {{key}} 并替换。
|
||||
*/
|
||||
private replacePlaceholders(
|
||||
template: string,
|
||||
variables: Record<string, string>,
|
||||
): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
|
||||
return variables[key] ?? match;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user