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:
@@ -1,7 +1,12 @@
|
||||
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import { ApplicationError } from './application-error.js';
|
||||
import {
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ZodError } from "zod";
|
||||
import { ApplicationError } from "./application-error.js";
|
||||
|
||||
@Catch()
|
||||
export class GlobalErrorFilter implements ExceptionFilter {
|
||||
@@ -9,10 +14,13 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例,
|
||||
// 但 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 body: Record<string, unknown>;
|
||||
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MSG_VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
code: "MSG_VALIDATION_ERROR",
|
||||
message: "Validation failed",
|
||||
details: exception.flatten(),
|
||||
traceId,
|
||||
},
|
||||
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'HTTP_ERROR',
|
||||
code: "HTTP_ERROR",
|
||||
message,
|
||||
traceId,
|
||||
},
|
||||
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An unexpected error occurred',
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "An unexpected error occurred",
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
response.status(statusCode).json(body);
|
||||
}
|
||||
|
||||
private extractHttpMessage(res: string | object, exception: HttpException): string {
|
||||
if (typeof res === 'string') {
|
||||
private extractHttpMessage(
|
||||
res: string | object,
|
||||
exception: HttpException,
|
||||
): string {
|
||||
if (typeof res === "string") {
|
||||
return res;
|
||||
}
|
||||
if (res && typeof res === 'object' && 'message' in res) {
|
||||
if (res && typeof res === "object" && "message" in res) {
|
||||
// 从 HttpException 响应体收窄类型(NestJS 约定包含 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;
|
||||
}
|
||||
|
||||
@@ -1,46 +1,49 @@
|
||||
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "../../config/database.js";
|
||||
|
||||
const SERVICE_NAME = 'msg';
|
||||
const SERVICE_NAME = "msg";
|
||||
|
||||
/**
|
||||
* 健康检查端点。
|
||||
*
|
||||
* - 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()
|
||||
export class HealthController {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
@Get('healthz')
|
||||
@Get("healthz")
|
||||
liveness(): { status: string; service: string; timestamp: string } {
|
||||
return {
|
||||
status: 'ok',
|
||||
status: "ok",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('readyz')
|
||||
async readiness(): Promise<{ status: string; service: string; timestamp: string }> {
|
||||
@Get("readyz")
|
||||
async readiness(): Promise<{
|
||||
status: string;
|
||||
service: string;
|
||||
timestamp: string;
|
||||
}> {
|
||||
try {
|
||||
await this.dataSource.query('SELECT 1');
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return {
|
||||
status: 'ok',
|
||||
status: "ok",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
status: "error",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'database unreachable',
|
||||
error:
|
||||
error instanceof Error ? error.message : "database unreachable",
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
|
||||
/**
|
||||
* 健康检查模块。
|
||||
*
|
||||
* 集成说明(不修改 app.module.ts,仅在 README 注释说明):
|
||||
*
|
||||
* 在 `app.module.ts` 的 imports 数组中加入 `HealthModule`:
|
||||
*
|
||||
* ```ts
|
||||
* import { HealthModule } from './shared/health/health.module';
|
||||
*
|
||||
* @Module({ imports: [ ..., HealthModule ], ... })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
*
|
||||
* DataSource 由 `TypeOrmModule.forRoot(...)` 提供,本模块无需额外 provider。
|
||||
* 数据库探活通过 Drizzle `db.execute(sql\`SELECT 1\`)` 完成,无需额外 provider。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
|
||||
@@ -1,56 +1,45 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { Redis } from 'ioredis';
|
||||
import type { Producer } from 'kafkajs';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
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()`
|
||||
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。
|
||||
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
|
||||
*
|
||||
* 关闭顺序:Kafka producer → Redis → DataSource。
|
||||
* 先停外部消息生产避免新事件,再关缓存,最后关 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: ... }`。
|
||||
* 关闭顺序:ES → Drizzle。先关搜索索引避免新数据丢失,再关 DB。
|
||||
*/
|
||||
@Injectable()
|
||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||
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 {
|
||||
this.logger.log(`service ${SERVICE_NAME} module initialized`);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal?: string): Promise<void> {
|
||||
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('redis', () => this.redis.quit());
|
||||
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
|
||||
await this.safeDisconnect("elasticsearch", () => closeEs());
|
||||
await this.safeDisconnect("drizzle", () => closeDb());
|
||||
|
||||
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 {
|
||||
await fn();
|
||||
this.logger.log(`${name} closed`);
|
||||
|
||||
Reference in New Issue
Block a user