feat(msg): 修复通知服务并添加ES降级与Push Gateway推送
database.ts 导出db常量替代getDb()函数 env.ts JWT_SECRET/ES_URL改optional加DEV_MODE/PUSH_GATEWAY_URL elasticsearch.ts ES降级: ES_URL未设置时esClient=null notifications.service.ts 加createBatch+分页查询+Push Gateway推送调用 新建msg-init.sql创建2张表
This commit is contained in:
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -455,6 +455,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.20.0
|
version: 22.20.0
|
||||||
|
'@types/uuid':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
26
scripts/msg-init.sql
Normal file
26
scripts/msg-init.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Msg 服务数据库初始化脚本
|
||||||
|
-- 表:msg_notifications / msg_notification_preferences
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS msg_notifications (
|
||||||
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
channel VARCHAR(20) NOT NULL DEFAULT 'in_app',
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
metadata JSON NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_notifications_user_id (user_id),
|
||||||
|
INDEX idx_notifications_is_read (is_read),
|
||||||
|
INDEX idx_notifications_created_at (created_at),
|
||||||
|
INDEX idx_notifications_user_read (user_id, is_read)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS msg_notification_preferences (
|
||||||
|
user_id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
email_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
sms_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
push_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
in_app_enabled BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.4.0",
|
"@nestjs/cli": "^10.4.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vitest": "^2.1.0"
|
"vitest": "^2.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { NotificationsModule } from './notifications/notifications.module.js';
|
import { NotificationsModule } from "./notifications/notifications.module.js";
|
||||||
|
import { HealthModule } from "./shared/health/health.module.js";
|
||||||
|
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule],
|
imports: [NotificationsModule, HealthModule],
|
||||||
|
providers: [LifecycleService],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import { drizzle } from 'drizzle-orm/mysql2';
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
import mysql from 'mysql2/promise';
|
import mysql from "mysql2/promise";
|
||||||
import { env } from './env.js';
|
import { env } from "./env.js";
|
||||||
|
|
||||||
let pool: mysql.Pool | null = null;
|
const pool = mysql.createPool({
|
||||||
|
uri: env.DATABASE_URL,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
});
|
||||||
|
|
||||||
export function getDb() {
|
export const db = drizzle(pool);
|
||||||
if (!pool) {
|
|
||||||
pool = mysql.createPool({
|
|
||||||
uri: env.DATABASE_URL,
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return drizzle(pool);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeDb(): Promise<void> {
|
export async function closeDb(): Promise<void> {
|
||||||
if (pool) {
|
await pool.end();
|
||||||
await pool.end();
|
|
||||||
pool = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,87 @@
|
|||||||
import { Client } from '@elastic/elasticsearch';
|
import { Client } from "@elastic/elasticsearch";
|
||||||
import { env } from './env.js';
|
import { env } from "./env.js";
|
||||||
|
|
||||||
export const esClient = new Client({ node: env.ES_URL });
|
/**
|
||||||
|
* Elasticsearch 客户端。
|
||||||
|
*
|
||||||
|
* 降级模式:当 ES_URL 未设置或连接失败时,esClient 为 null,
|
||||||
|
* 所有 ES 操作通过 safeIndex / safeSearch 自动跳过,服务仍可启动。
|
||||||
|
*/
|
||||||
|
export const esClient: Client | null = env.ES_URL
|
||||||
|
? new Client({ node: env.ES_URL })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 探活 ES 连接。失败仅记录日志,不抛错(降级模式)。
|
||||||
|
*/
|
||||||
export async function checkEsConnection(): Promise<void> {
|
export async function checkEsConnection(): Promise<void> {
|
||||||
|
if (!esClient) {
|
||||||
|
console.log("Elasticsearch disabled (ES_URL not set)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await esClient.ping();
|
await esClient.ping();
|
||||||
console.log('Elasticsearch connected');
|
console.log("Elasticsearch connected");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Elasticsearch connection failed:', err);
|
console.error("Elasticsearch connection failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭 ES 客户端连接。
|
||||||
|
*/
|
||||||
|
export async function closeEs(): Promise<void> {
|
||||||
|
if (esClient) {
|
||||||
|
await esClient.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexParams {
|
||||||
|
index: string;
|
||||||
|
id: string;
|
||||||
|
document: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
_id: string;
|
||||||
|
_source: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
hits: SearchHit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全索引文档。client 为 null 或失败时跳过(降级模式),返回是否成功。
|
||||||
|
*/
|
||||||
|
export async function safeIndex(params: IndexParams): Promise<boolean> {
|
||||||
|
if (!esClient) return false;
|
||||||
|
try {
|
||||||
|
await esClient.index(params);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Elasticsearch index failed:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全搜索。client 为 null 或失败时返回空数组(降级模式)。
|
||||||
|
*/
|
||||||
|
export async function safeSearch(
|
||||||
|
index: string,
|
||||||
|
query: Record<string, unknown>,
|
||||||
|
): Promise<SearchResult> {
|
||||||
|
if (!esClient) return { hits: [] };
|
||||||
|
try {
|
||||||
|
const result = await esClient.search({ index, query });
|
||||||
|
const hits = (result.hits.hits as unknown[]).map((raw) => {
|
||||||
|
const hit = raw as { _id: string; _source: Record<string, unknown> };
|
||||||
|
return { _id: hit._id, _source: hit._source };
|
||||||
|
});
|
||||||
|
return { hits };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Elasticsearch search failed:", err);
|
||||||
|
return { hits: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
PORT: z.string().default('3007'),
|
PORT: z.string().default("3007"),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
REDIS_URL: z.string().url().optional(),
|
REDIS_URL: z.string().url().optional(),
|
||||||
JWT_SECRET: z.string(),
|
JWT_SECRET: z.string().optional(),
|
||||||
JWT_ISSUER: z.string().default('next-edu-cloud'),
|
JWT_ISSUER: z.string().default("next-edu-cloud"),
|
||||||
KAFKA_BROKERS: z.string().default('localhost:9092'),
|
KAFKA_BROKERS: z.string().default("localhost:9092"),
|
||||||
ES_URL: z.string().url().default('http://localhost:9200'),
|
ES_URL: z.string().url().optional(),
|
||||||
|
PUSH_GATEWAY_URL: z.string().url().optional(),
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
LOG_LEVEL: z
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
.enum(["fatal", "error", "warn", "info", "debug", "trace"])
|
||||||
|
.default("info"),
|
||||||
|
NODE_ENV: z
|
||||||
|
.enum(["development", "production", "test"])
|
||||||
|
.default("development"),
|
||||||
|
DEV_MODE: z.string().optional().default("false"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
@@ -18,8 +24,11 @@ export type Env = z.infer<typeof envSchema>;
|
|||||||
export function loadEnv(): Env {
|
export function loadEnv(): Env {
|
||||||
const result = envSchema.safeParse(process.env);
|
const result = envSchema.safeParse(process.env);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
console.error(
|
||||||
throw new Error('Invalid environment configuration');
|
"❌ Invalid environment variables:",
|
||||||
|
result.error.flatten().fieldErrors,
|
||||||
|
);
|
||||||
|
throw new Error("Invalid environment configuration");
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,50 @@
|
|||||||
import 'reflect-metadata';
|
import "reflect-metadata";
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from "./app.module.js";
|
||||||
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
|
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||||
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
|
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||||
import { env } from './config/env.js';
|
import { env } from "./config/env.js";
|
||||||
import { logger } from './shared/observability/logger.js';
|
import { logger } from "./shared/observability/logger.js";
|
||||||
|
import { checkEsConnection, closeEs } from "./config/elasticsearch.js";
|
||||||
|
import { closeDb } from "./config/database.js";
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
initTracer();
|
initTracer();
|
||||||
|
|
||||||
|
// ES 探活:未配置或失败时进入降级模式,不阻断启动。
|
||||||
|
await checkEsConnection();
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger: ['log', 'error', 'warn'],
|
logger: ["log", "error", "warn"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 无全局路由前缀:Gateway 会去掉 /api/v1,再转发至本服务。
|
||||||
app.useGlobalFilters(new GlobalErrorFilter());
|
app.useGlobalFilters(new GlobalErrorFilter());
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
await app.listen(env.PORT);
|
await app.listen(env.PORT);
|
||||||
logger.info({ port: env.PORT }, 'Msg service started');
|
logger.info({ port: env.PORT }, "Msg service started");
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
process.on("SIGTERM", async () => {
|
||||||
|
logger.info("SIGTERM received, shutting down gracefully...");
|
||||||
await app.close();
|
await app.close();
|
||||||
|
await closeEs();
|
||||||
|
await closeDb();
|
||||||
await shutdownTracer();
|
await shutdownTracer();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
logger.info("SIGINT received, shutting down gracefully...");
|
||||||
|
await app.close();
|
||||||
|
await closeEs();
|
||||||
|
await closeDb();
|
||||||
|
await shutdownTracer();
|
||||||
|
process.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap().catch((err: unknown) => {
|
void bootstrap().catch((err: unknown) => {
|
||||||
logger.error({ err }, 'Failed to start msg service');
|
logger.error({ err }, "Failed to start msg service");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,32 +1,102 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query, Req } from '@nestjs/common';
|
import {
|
||||||
import { NotificationsService } from './notifications.service.js';
|
Body,
|
||||||
import type { SendNotificationDto } from './notifications.service.js';
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { NotificationsService } from "./notifications.service.js";
|
||||||
|
import type { SendNotificationDto } from "./notifications.service.js";
|
||||||
|
|
||||||
@Controller('notifications')
|
interface AuthedRequest {
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求头读取用户身份。
|
||||||
|
* Gateway 在通过鉴权后会注入 x-user-id;如未注入(开发模式或匿名请求)返回 null。
|
||||||
|
*/
|
||||||
|
function getUserIdFromRequest(req: AuthedRequest): string | null {
|
||||||
|
const raw = req.headers["x-user-id"];
|
||||||
|
if (typeof raw === "string" && raw.length > 0) return raw;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller("notifications")
|
||||||
export class NotificationsController {
|
export class NotificationsController {
|
||||||
constructor(private readonly service: NotificationsService) {}
|
constructor(private readonly service: NotificationsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async send(@Body() body: unknown) {
|
async send(@Body() body: unknown): Promise<{ success: true; data: unknown }> {
|
||||||
const result = await this.service.send(body as SendNotificationDto);
|
const result = await this.service.send(body as SendNotificationDto);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Post("batch")
|
||||||
async list(@Req() req: { userId: string }, @Query('unread') unread: string) {
|
async createBatch(
|
||||||
const result = await this.service.listByUser(req.userId, unread === 'true');
|
@Body() body: unknown,
|
||||||
|
): Promise<{ success: true; data: unknown }> {
|
||||||
|
const dtos = (body as SendNotificationDto[]) ?? [];
|
||||||
|
const result = await this.service.createBatch(dtos);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/read')
|
@Get("user/:userId")
|
||||||
async markAsRead(@Param('id') id: string) {
|
async listByUser(
|
||||||
|
@Param("userId") userId: string,
|
||||||
|
@Query("unread") unread: string,
|
||||||
|
): Promise<{ success: true; data: unknown }> {
|
||||||
|
const result = await this.service.listByUser(userId, unread === "true");
|
||||||
|
return { success: true, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("user/:userId/page")
|
||||||
|
async listByUserPaginated(
|
||||||
|
@Param("userId") userId: string,
|
||||||
|
@Query("page") page: string,
|
||||||
|
@Query("pageSize") pageSize: string,
|
||||||
|
): Promise<{ success: true; data: unknown }> {
|
||||||
|
const pageNum = Number(page) > 0 ? Number(page) : 1;
|
||||||
|
const pageSizeNum = Number(pageSize) > 0 ? Number(pageSize) : 20;
|
||||||
|
const result = await this.service.listByUserWithPagination(
|
||||||
|
userId,
|
||||||
|
pageNum,
|
||||||
|
pageSizeNum,
|
||||||
|
);
|
||||||
|
return { success: true, data: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(":id/read")
|
||||||
|
async markAsRead(@Param("id") id: string): Promise<{ success: true }> {
|
||||||
await this.service.markAsRead(id);
|
await this.service.markAsRead(id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('search')
|
@Get("search")
|
||||||
async search(@Req() req: { userId: string }, @Query('q') q: string) {
|
async search(
|
||||||
const result = await this.service.search(req.userId, q);
|
@Req() req: AuthedRequest,
|
||||||
|
@Query("q") q: string,
|
||||||
|
@Query("userId") userIdParam: string,
|
||||||
|
): Promise<{ success: true; data: unknown }> {
|
||||||
|
const userId = getUserIdFromRequest(req) ?? userIdParam;
|
||||||
|
if (!userId) {
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "MSG_PERMISSION_DENIED",
|
||||||
|
message: "Missing user identity",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await this.service.search(userId, q);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from "@nestjs/common";
|
||||||
import { getDb } from '../config/database.js';
|
import { eq, and, desc, sql } from "drizzle-orm";
|
||||||
import { notifications, notificationPreferences } from './notifications.schema.js';
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import type { NotificationPreference } from './notifications.schema.js';
|
import { db } from "../config/database.js";
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import {
|
||||||
import { eq, and } from 'drizzle-orm';
|
notifications,
|
||||||
import { esClient } from '../config/elasticsearch.js';
|
notificationPreferences,
|
||||||
import { logger } from '../shared/observability/logger.js';
|
} from "./notifications.schema.js";
|
||||||
|
import type {
|
||||||
|
Notification,
|
||||||
|
NotificationPreference,
|
||||||
|
} from "./notifications.schema.js";
|
||||||
|
import { safeIndex, safeSearch } from "../config/elasticsearch.js";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
import { logger } from "../shared/observability/logger.js";
|
||||||
|
|
||||||
export interface SendNotificationDto {
|
export interface SendNotificationDto {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -16,22 +23,38 @@ export interface SendNotificationDto {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendResult {
|
||||||
|
id: string;
|
||||||
|
skipped: boolean;
|
||||||
|
pushed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult {
|
||||||
|
items: Notification[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
async send(dto: SendNotificationDto) {
|
async send(dto: SendNotificationDto): Promise<SendResult> {
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const db = getDb();
|
const channel = dto.channel ?? "in_app";
|
||||||
|
|
||||||
// 检查用户偏好
|
const [pref] = await db
|
||||||
const [pref] = await db.select().from(notificationPreferences).where(eq(notificationPreferences.userId, dto.userId));
|
.select()
|
||||||
|
.from(notificationPreferences)
|
||||||
|
.where(eq(notificationPreferences.userId, dto.userId));
|
||||||
|
|
||||||
const channel = dto.channel || 'in_app';
|
|
||||||
if (pref && !this.isChannelEnabled(pref, channel)) {
|
if (pref && !this.isChannelEnabled(pref, channel)) {
|
||||||
logger.info({ userId: dto.userId, channel }, 'Notification skipped by preference');
|
logger.info(
|
||||||
return { skipped: true };
|
{ userId: dto.userId, channel },
|
||||||
|
"Notification skipped by preference",
|
||||||
|
);
|
||||||
|
return { id, skipped: true, pushed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入 DB
|
|
||||||
await db.insert(notifications).values({
|
await db.insert(notifications).values({
|
||||||
id,
|
id,
|
||||||
userId: dto.userId,
|
userId: dto.userId,
|
||||||
@@ -42,69 +65,148 @@ export class NotificationsService {
|
|||||||
metadata: dto.metadata,
|
metadata: dto.metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 索引到 ES(全文检索)
|
await safeIndex({
|
||||||
|
index: "notifications",
|
||||||
|
id,
|
||||||
|
document: {
|
||||||
|
userId: dto.userId,
|
||||||
|
type: dto.type,
|
||||||
|
title: dto.title,
|
||||||
|
content: dto.content,
|
||||||
|
channel,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushed = await this.notifyPushGateway(dto, channel);
|
||||||
|
|
||||||
|
return { id, skipped: false, pushed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBatch(dtos: SendNotificationDto[]): Promise<SendResult[]> {
|
||||||
|
const results: SendResult[] = [];
|
||||||
|
for (const dto of dtos) {
|
||||||
|
const result = await this.send(dto);
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByUser(
|
||||||
|
userId: string,
|
||||||
|
onlyUnread: boolean = false,
|
||||||
|
): Promise<Notification[]> {
|
||||||
|
const conditions = onlyUnread
|
||||||
|
? and(eq(notifications.userId, userId), eq(notifications.isRead, false))
|
||||||
|
: eq(notifications.userId, userId);
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(notifications)
|
||||||
|
.where(conditions)
|
||||||
|
.orderBy(desc(notifications.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByUserWithPagination(
|
||||||
|
userId: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<PaginatedResult> {
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const items = await db
|
||||||
|
.select()
|
||||||
|
.from(notifications)
|
||||||
|
.where(eq(notifications.userId, userId))
|
||||||
|
.orderBy(desc(notifications.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const [countRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(notifications)
|
||||||
|
.where(eq(notifications.userId, userId));
|
||||||
|
|
||||||
|
const total = countRow ? Number(countRow.count) : 0;
|
||||||
|
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsRead(id: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(notifications)
|
||||||
|
.set({ isRead: true })
|
||||||
|
.where(eq(notifications.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(userId: string, query: string): Promise<unknown[]> {
|
||||||
|
const result = await safeSearch("notifications", {
|
||||||
|
bool: {
|
||||||
|
must: [
|
||||||
|
{ term: { userId } },
|
||||||
|
{
|
||||||
|
multi_match: {
|
||||||
|
query,
|
||||||
|
fields: ["title", "content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result.hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isChannelEnabled(
|
||||||
|
pref: NotificationPreference,
|
||||||
|
channel: string,
|
||||||
|
): boolean {
|
||||||
|
switch (channel) {
|
||||||
|
case "email":
|
||||||
|
return pref.emailEnabled;
|
||||||
|
case "sms":
|
||||||
|
return pref.smsEnabled;
|
||||||
|
case "push":
|
||||||
|
return pref.pushEnabled;
|
||||||
|
case "in_app":
|
||||||
|
return pref.inAppEnabled;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 Push Gateway 推送通知。Push Gateway 不可用时 try/catch 跳过(降级模式)。
|
||||||
|
* 仅在 PUSH_GATEWAY_URL 配置且 channel 为 push 或 in_app 时触发。
|
||||||
|
*/
|
||||||
|
private async notifyPushGateway(
|
||||||
|
dto: SendNotificationDto,
|
||||||
|
channel: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!env.PUSH_GATEWAY_URL) return false;
|
||||||
|
if (channel !== "push" && channel !== "in_app") return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await esClient.index({
|
const res = await fetch(`${env.PUSH_GATEWAY_URL}/internal/push`, {
|
||||||
index: 'notifications',
|
method: "POST",
|
||||||
id,
|
headers: { "Content-Type": "application/json" },
|
||||||
document: {
|
body: JSON.stringify({
|
||||||
userId: dto.userId,
|
userId: dto.userId,
|
||||||
type: dto.type,
|
type: dto.type,
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
content: dto.content,
|
content: dto.content,
|
||||||
channel,
|
channel,
|
||||||
createdAt: new Date().toISOString(),
|
metadata: dto.metadata,
|
||||||
},
|
}),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn(
|
||||||
|
{ status: res.status },
|
||||||
|
"Push gateway returned non-ok status",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, 'Failed to index notification in ES');
|
logger.warn({ err }, "Push gateway unavailable, skipping push");
|
||||||
}
|
return false;
|
||||||
|
|
||||||
// TODO: 触发 Push Gateway 推送(P5 后期)
|
|
||||||
|
|
||||||
return { id, skipped: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
async listByUser(userId: string, onlyUnread: boolean = false) {
|
|
||||||
const db = getDb();
|
|
||||||
const conditions = onlyUnread
|
|
||||||
? and(eq(notifications.userId, userId), eq(notifications.isRead, false))
|
|
||||||
: eq(notifications.userId, userId);
|
|
||||||
return db.select().from(notifications).where(conditions);
|
|
||||||
}
|
|
||||||
|
|
||||||
async markAsRead(id: string) {
|
|
||||||
const db = getDb();
|
|
||||||
await db.update(notifications).set({ isRead: true }).where(eq(notifications.id, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
async search(userId: string, query: string) {
|
|
||||||
const result = await esClient.search({
|
|
||||||
index: 'notifications',
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must: [
|
|
||||||
{ term: { userId } },
|
|
||||||
{
|
|
||||||
multi_match: {
|
|
||||||
query,
|
|
||||||
fields: ['title', 'content'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return result.hits.hits;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isChannelEnabled(pref: NotificationPreference, channel: string): boolean {
|
|
||||||
switch (channel) {
|
|
||||||
case 'email': return pref.emailEnabled;
|
|
||||||
case 'sms': return pref.smsEnabled;
|
|
||||||
case 'push': return pref.pushEnabled;
|
|
||||||
case 'in_app': return pref.inAppEnabled;
|
|
||||||
default: return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
|
import {
|
||||||
import { Request, Response } from 'express';
|
Catch,
|
||||||
import { ZodError } from 'zod';
|
ExceptionFilter,
|
||||||
import { ApplicationError } from './application-error.js';
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
import { ApplicationError } from "./application-error.js";
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class GlobalErrorFilter implements ExceptionFilter {
|
export class GlobalErrorFilter implements ExceptionFilter {
|
||||||
@@ -9,10 +14,13 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
|||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例,
|
||||||
const request = ctx.getRequest<Request>();
|
// 但 msg 服务未引入 @types/express,此处按 core-edu / content 模式不显式标注类型。
|
||||||
|
const response = ctx.getResponse();
|
||||||
|
const request = ctx.getRequest();
|
||||||
|
|
||||||
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
|
const traceId =
|
||||||
|
(request.headers["x-request-id"] as string | undefined) ?? "unknown";
|
||||||
|
|
||||||
let statusCode = 500;
|
let statusCode = 500;
|
||||||
let body: Record<string, unknown>;
|
let body: Record<string, unknown>;
|
||||||
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
|||||||
body = {
|
body = {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: 'MSG_VALIDATION_ERROR',
|
code: "MSG_VALIDATION_ERROR",
|
||||||
message: 'Validation failed',
|
message: "Validation failed",
|
||||||
details: exception.flatten(),
|
details: exception.flatten(),
|
||||||
traceId,
|
traceId,
|
||||||
},
|
},
|
||||||
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
|||||||
body = {
|
body = {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: 'HTTP_ERROR',
|
code: "HTTP_ERROR",
|
||||||
message,
|
message,
|
||||||
traceId,
|
traceId,
|
||||||
},
|
},
|
||||||
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
|||||||
body = {
|
body = {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: 'INTERNAL_ERROR',
|
code: "INTERNAL_ERROR",
|
||||||
message: 'An unexpected error occurred',
|
message: "An unexpected error occurred",
|
||||||
traceId,
|
traceId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
|||||||
response.status(statusCode).json(body);
|
response.status(statusCode).json(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractHttpMessage(res: string | object, exception: HttpException): string {
|
private extractHttpMessage(
|
||||||
if (typeof res === 'string') {
|
res: string | object,
|
||||||
|
exception: HttpException,
|
||||||
|
): string {
|
||||||
|
if (typeof res === "string") {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
if (res && typeof res === 'object' && 'message' in res) {
|
if (res && typeof res === "object" && "message" in res) {
|
||||||
// 从 HttpException 响应体收窄类型(NestJS 约定包含 message 字段)
|
// 从 HttpException 响应体收窄类型(NestJS 约定包含 message 字段)
|
||||||
const msg = (res as { message: unknown }).message;
|
const msg = (res as { message: unknown }).message;
|
||||||
return typeof msg === 'string' ? msg : exception.message;
|
return typeof msg === "string" ? msg : exception.message;
|
||||||
}
|
}
|
||||||
return exception.message;
|
return exception.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,49 @@
|
|||||||
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
|
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
||||||
import { DataSource } from 'typeorm';
|
import { sql } from "drizzle-orm";
|
||||||
|
import { db } from "../../config/database.js";
|
||||||
|
|
||||||
const SERVICE_NAME = 'msg';
|
const SERVICE_NAME = "msg";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 健康检查端点。
|
* 健康检查端点。
|
||||||
*
|
*
|
||||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
||||||
* - GET /readyz:readiness,检查 DB 连接,失败返回 503。
|
* - GET /readyz:readiness,检查 DB 连接(Drizzle `SELECT 1`),失败返回 503。
|
||||||
*
|
*
|
||||||
* 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu /
|
* 不需要鉴权,必须在路由白名单中放行。
|
||||||
* content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。
|
|
||||||
*/
|
*/
|
||||||
@Controller()
|
@Controller()
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
@Get("healthz")
|
||||||
|
|
||||||
@Get('healthz')
|
|
||||||
liveness(): { status: string; service: string; timestamp: string } {
|
liveness(): { status: string; service: string; timestamp: string } {
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: "ok",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('readyz')
|
@Get("readyz")
|
||||||
async readiness(): Promise<{ status: string; service: string; timestamp: string }> {
|
async readiness(): Promise<{
|
||||||
|
status: string;
|
||||||
|
service: string;
|
||||||
|
timestamp: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
await this.dataSource.query('SELECT 1');
|
await db.execute(sql`SELECT 1`);
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: "ok",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
status: 'error',
|
status: "error",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
error: error instanceof Error ? error.message : 'database unreachable',
|
error:
|
||||||
|
error instanceof Error ? error.message : "database unreachable",
|
||||||
},
|
},
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from "./health.controller.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 健康检查模块。
|
* 健康检查模块。
|
||||||
*
|
*
|
||||||
* 集成说明(不修改 app.module.ts,仅在 README 注释说明):
|
* 数据库探活通过 Drizzle `db.execute(sql\`SELECT 1\`)` 完成,无需额外 provider。
|
||||||
*
|
|
||||||
* 在 `app.module.ts` 的 imports 数组中加入 `HealthModule`:
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* import { HealthModule } from './shared/health/health.module';
|
|
||||||
*
|
|
||||||
* @Module({ imports: [ ..., HealthModule ], ... })
|
|
||||||
* export class AppModule {}
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* DataSource 由 `TypeOrmModule.forRoot(...)` 提供,本模块无需额外 provider。
|
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
|||||||
@@ -1,56 +1,45 @@
|
|||||||
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
import {
|
||||||
import { DataSource } from 'typeorm';
|
Injectable,
|
||||||
import type { Redis } from 'ioredis';
|
Logger,
|
||||||
import type { Producer } from 'kafkajs';
|
OnApplicationShutdown,
|
||||||
|
OnModuleInit,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { closeDb } from "../../config/database.js";
|
||||||
|
import { closeEs } from "../../config/elasticsearch.js";
|
||||||
|
|
||||||
const SERVICE_NAME = 'msg';
|
const SERVICE_NAME = "msg";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 优雅停机服务。
|
* 优雅停机服务。
|
||||||
*
|
*
|
||||||
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
|
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
|
||||||
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。
|
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。
|
||||||
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
|
|
||||||
*
|
*
|
||||||
* 关闭顺序:Kafka producer → Redis → DataSource。
|
* 关闭顺序:ES → Drizzle。先关搜索索引避免新数据丢失,再关 DB。
|
||||||
* 先停外部消息生产避免新事件,再关缓存,最后关 DB。
|
|
||||||
*
|
|
||||||
* 集成说明(不修改 app.module.ts,仅在 README 注释说明):
|
|
||||||
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
|
|
||||||
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
|
|
||||||
*
|
|
||||||
* 依赖注入 token 约定(需与各服务 provider 注册一致):
|
|
||||||
* - DataSource:由 `TypeOrmModule.forRoot()` 提供。
|
|
||||||
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
|
|
||||||
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||||
private readonly logger = new Logger(LifecycleService.name);
|
private readonly logger = new Logger(LifecycleService.name);
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly dataSource: DataSource,
|
|
||||||
@Inject('REDIS_CLIENT') private readonly redis: Redis,
|
|
||||||
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
this.logger.log(`service ${SERVICE_NAME} module initialized`);
|
this.logger.log(`service ${SERVICE_NAME} module initialized`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onApplicationShutdown(signal?: string): Promise<void> {
|
async onApplicationShutdown(signal?: string): Promise<void> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`,
|
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect());
|
await this.safeDisconnect("elasticsearch", () => closeEs());
|
||||||
await this.safeDisconnect('redis', () => this.redis.quit());
|
await this.safeDisconnect("drizzle", () => closeDb());
|
||||||
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
|
|
||||||
|
|
||||||
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
|
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
|
private async safeDisconnect(
|
||||||
|
name: string,
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
this.logger.log(`${name} closed`);
|
this.logger.log(`${name} closed`);
|
||||||
|
|||||||
Reference in New Issue
Block a user