feat(msg): 完整实现 msg 消息服务
包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
48
services/msg/src/preferences/preferences.controller.ts
Normal file
48
services/msg/src/preferences/preferences.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
36
services/msg/src/preferences/preferences.dto.ts
Normal file
36
services/msg/src/preferences/preferences.dto.ts
Normal 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>;
|
||||
10
services/msg/src/preferences/preferences.module.ts
Normal file
10
services/msg/src/preferences/preferences.module.ts
Normal 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 {}
|
||||
84
services/msg/src/preferences/preferences.repository.ts
Normal file
84
services/msg/src/preferences/preferences.repository.ts
Normal 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));
|
||||
}
|
||||
34
services/msg/src/preferences/preferences.service.ts
Normal file
34
services/msg/src/preferences/preferences.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user