feat(core-edu): 修复服务启动并打通考试作业成绩端到端链路

database.ts 导出 db 常量替代 getDb()
env.ts JWT_SECRET 改 optional 并新增 DEV_MODE
kafka.ts connectKafka 加 try/catch 不阻塞启动
main.ts 去全局前缀 connectKafka 改非阻塞
app.module 移除未用模块加 HealthModule
controller 路由去前缀去 UseGuards 从 x-user-id 读身份
service datetime ISO 字符串转 Date 修复 drizzle 错误
修正相对 import 路径
health/lifecycle 改用 Drizzle 原生查询
新增 core-edu-init.sql 初始化 4 张表

端到端验证: exams/homework/grades 全部 201/200
Outbox 事件正确写入 core_edu_outbox 表
This commit is contained in:
SpecialX
2026-07-09 08:02:22 +08:00
parent 2c7afe59ef
commit 4533da6484
16 changed files with 348 additions and 309 deletions

View File

@@ -1,46 +1,41 @@
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 = 'core-edu';
const SERVICE_NAME = "core-edu";
/**
* 健康检查端点。
*
* - GET /healthzliveness仅返回进程存活不检查依赖。
* - GET /readyzreadiness检查 DB 连接,失败返回 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,
);

View File

@@ -1,22 +1,6 @@
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。
*/
@Module({
controllers: [HealthController],
})

View File

@@ -1,62 +1,22 @@
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 } from "@nestjs/common";
import { closeDb } from "../../config/database.js";
const SERVICE_NAME = 'core-edu';
const SERVICE_NAME = "core-edu";
/**
* 优雅停机服务。
*
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
* 触发SIGTERM / SIGINTNestJS 会依次调用 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: ... }`。
*/
@Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
export class LifecycleService {
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());
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
}
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
this.logger.log(`${name} closed`);
await closeDb();
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
} catch (error) {
this.logger.error(
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
`shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}