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,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<string, unknown> =
typeof body === "object" && body !== null
? (body as Record<string, unknown>)
: {};
const dto: UpdatePreferencesDto = updatePreferencesSchema.parse({
...obj,
userId,
});
await this.service.update(dto);
return { success: true };
}
}

View File

@@ -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<typeof preferenceSchema>;
export const updatePreferencesSchema = z.object({
userId: z.string().min(1),
preferences: z.array(preferenceSchema).min(1),
});
export type UpdatePreferencesDto = z.infer<typeof updatePreferencesSchema>;

View File

@@ -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 {}

View File

@@ -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<NotificationPreference[]> {
const db = getDb();
return db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.userId, userId));
}
export async function findByUserIdAndType(
userId: string,
type: string,
): Promise<NotificationPreference | undefined> {
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<NotificationPreference> {
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<void> {
const db = getDb();
await db
.delete(notificationPreferences)
.where(eq(notificationPreferences.userId, userId));
}

View File

@@ -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<NotificationPreference[]> {
return repo.findByUserId(userId);
}
async update(dto: UpdatePreferencesDto): Promise<void> {
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,
});
}
}
}