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

65
scripts/core-edu-init.sql Normal file
View File

@@ -0,0 +1,65 @@
-- CoreEdu 服务数据库初始化脚本
-- 表core_edu_exams / core_edu_homework / core_edu_grades / core_edu_outbox
CREATE TABLE IF NOT EXISTS core_edu_exams (
id CHAR(36) NOT NULL PRIMARY KEY,
class_id CHAR(36) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
exam_date DATETIME NOT NULL,
duration VARCHAR(50) NOT NULL,
total_score VARCHAR(10) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
created_by CHAR(36) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_exams_class_id (class_id),
INDEX idx_exams_status (status),
INDEX idx_exams_created_by (created_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS core_edu_homework (
id CHAR(36) NOT NULL PRIMARY KEY,
class_id CHAR(36) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
due_date DATETIME NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'assigned',
created_by CHAR(36) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_homework_class_id (class_id),
INDEX idx_homework_status (status),
INDEX idx_homework_created_by (created_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS core_edu_grades (
id CHAR(36) NOT NULL PRIMARY KEY,
student_id CHAR(36) NOT NULL,
exam_id CHAR(36),
homework_id CHAR(36),
score VARCHAR(10) NOT NULL,
feedback TEXT,
graded_by CHAR(36) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_grades_student_id (student_id),
INDEX idx_grades_exam_id (exam_id),
INDEX idx_grades_homework_id (homework_id),
INDEX idx_grades_graded_by (graded_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS core_edu_outbox (
id CHAR(36) NOT NULL PRIMARY KEY,
aggregate_id CHAR(36) NOT NULL,
aggregate_type VARCHAR(50) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
retry_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
INDEX idx_outbox_status (status),
INDEX idx_outbox_aggregate (aggregate_type, aggregate_id),
INDEX idx_outbox_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -1,15 +1,10 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ExamsModule } from './exams/exams.module.js';
import { HomeworkModule } from './homework/homework.module.js';
import { GradesModule } from './grades/grades.module.js';
import { ClassesModule } from './classes/classes.module.js';
import { AuthMiddleware } from './middleware/auth.middleware.js';
import { Module } from "@nestjs/common";
import { ExamsModule } from "./exams/exams.module.js";
import { HomeworkModule } from "./homework/homework.module.js";
import { GradesModule } from "./grades/grades.module.js";
import { HealthModule } from "./shared/health/health.module.js";
@Module({
imports: [ExamsModule, HomeworkModule, GradesModule, ClassesModule],
imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(AuthMiddleware).forRoutes('/api/*');
}
}
export class AppModule {}

View File

@@ -1,24 +1,16 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { env } from './env.js';
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null;
export function getDb() {
if (!pool) {
pool = mysql.createPool({
const pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
}
return drizzle(pool);
}
});
export const db = drizzle(pool);
export async function closeDb(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -1,15 +1,20 @@
import { z } from 'zod';
import { z } from "zod";
const envSchema = z.object({
PORT: z.string().default('3004'),
PORT: z.string().default("3004"),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string(),
JWT_ISSUER: z.string().default('next-edu-cloud'),
KAFKA_BROKERS: z.string().default('localhost:9092'),
JWT_SECRET: z.string().optional(),
JWT_ISSUER: z.string().default("next-edu-cloud"),
KAFKA_BROKERS: z.string().default("localhost:9092"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
LOG_LEVEL: z
.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>;
@@ -17,8 +22,11 @@ export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
throw new Error('Invalid environment configuration');
console.error(
"❌ Invalid environment variables:",
result.error.flatten().fieldErrors,
);
throw new Error("Invalid environment configuration");
}
return result.data;
}

View File

@@ -1,22 +1,29 @@
import { Kafka } from 'kafkajs';
import { env } from './env.js';
import { Kafka } from "kafkajs";
import { env } from "./env.js";
export const kafka = new Kafka({
brokers: env.KAFKA_BROKERS.split(','),
clientId: 'core-edu-service',
brokers: env.KAFKA_BROKERS.split(","),
clientId: "core-edu-service",
});
export const producer = kafka.producer({
idempotent: true,
transactionalId: 'core-edu-tx',
transactionalId: "core-edu-tx",
});
export const consumer = kafka.consumer({ groupId: 'core-edu-group' });
export const consumer = kafka.consumer({ groupId: "core-edu-group" });
export async function connectKafka(): Promise<void> {
try {
await producer.connect();
await consumer.connect();
console.log('Kafka connected');
console.log("Kafka connected");
} catch (err) {
console.warn(
"Kafka connect failed, running without Kafka:",
err instanceof Error ? err.message : String(err),
);
}
}
export async function disconnectKafka(): Promise<void> {

View File

@@ -6,52 +6,64 @@ import {
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { ExamsService, type CreateExamInput, type UpdateExamInput } from './exams.service.js';
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
Req,
} from "@nestjs/common";
import type { Request } from "express";
import {
ExamsService,
type CreateExamInput,
type UpdateExamInput,
} from "./exams.service.js";
interface SuccessResponse<T> {
data: T;
timestamp: string;
}
@Controller('api/v1/exams')
@Controller("exams")
export class ExamsController {
constructor(private readonly examsService: ExamsService) {}
@Post()
@UseGuards(new PermissionGuard(Permissions.EXAM_CREATE))
async create(@Body() body: CreateExamInput): Promise<SuccessResponse<{ id: string }>> {
const result = await this.examsService.createExam(body);
return { data: result, timestamp: new Date().toISOString() };
async create(
@Body() body: CreateExamInput,
@Req() req: Request,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string;
const result = await this.examsService.createExam({
...body,
createdBy: userId,
});
return { success: true, data: result };
}
@Get(':id')
@UseGuards(new PermissionGuard(Permissions.EXAM_READ))
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<ExamsService['getExam']>>>> {
@Get(":id")
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<ExamsService["getExam"]>>;
}> {
const data = await this.examsService.getExam(id);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Get('class/:classId')
@UseGuards(new PermissionGuard(Permissions.EXAM_READ))
async listByClass(@Param('classId') classId: string): Promise<SuccessResponse<Awaited<ReturnType<ExamsService['listExamsByClass']>>>> {
@Get("class/:classId")
async listByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<ExamsService["listExamsByClass"]>>;
}> {
const data = await this.examsService.listExamsByClass(classId);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Put(':id')
@UseGuards(new PermissionGuard(Permissions.EXAM_UPDATE))
async update(@Param('id') id: string, @Body() body: UpdateExamInput): Promise<SuccessResponse<{ success: true }>> {
@Put(":id")
async update(
@Param("id") id: string,
@Body() body: UpdateExamInput,
): Promise<{ success: true; data: { success: true } }> {
await this.examsService.updateExam(id, body);
return { data: { success: true }, timestamp: new Date().toISOString() };
return { success: true, data: { success: true } };
}
@Delete(':id')
@UseGuards(new PermissionGuard(Permissions.EXAM_DELETE))
async remove(@Param('id') id: string): Promise<SuccessResponse<{ success: true }>> {
@Delete(":id")
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.examsService.deleteExam(id);
return { data: { success: true }, timestamp: new Date().toISOString() };
return { success: true, data: { success: true } };
}
}

View File

@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import { db } from '../../config/database.js';
import { exams, type Exam, type NewExam } from './exams.schema.js';
import { eq } from "drizzle-orm";
import { db } from "../config/database.js";
import { exams, type Exam, type NewExam } from "./exams.schema.js";
export class ExamsRepository {
async findById(id: string): Promise<Exam | undefined> {

View File

@@ -1,18 +1,21 @@
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Injectable } from '@nestjs/common';
import { db } from '../../config/database.js';
import { exams } from './exams.schema.js';
import { examsRepository } from './exams.repository.js';
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
import type { Exam, NewExam } from './exams.schema.js';
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { exams } from "./exams.schema.js";
import { examsRepository } from "./exams.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import type { Exam, NewExam } from "./exams.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface CreateExamInput {
classId: string;
title: string;
description?: string;
examDate: Date;
examDate: Date | string;
duration: string;
totalScore: string;
createdBy: string;
@@ -31,7 +34,7 @@ export interface UpdateExamInput {
export class ExamsService {
async createExam(input: CreateExamInput): Promise<{ id: string }> {
if (!input.classId || !input.title || !input.createdBy) {
throw new ValidationError('classId, title, createdBy are required');
throw new ValidationError("classId, title, createdBy are required");
}
const id = randomUUID();
@@ -40,10 +43,15 @@ export class ExamsService {
classId: input.classId,
title: input.title,
description: input.description,
examDate: input.examDate,
// Drizzle datetime 列需要 Date 对象(调用 toISOString。HTTP 请求体里的
// examDate 是 ISO 字符串,这里统一转成 Date避免 "toISOString is not a function"。
examDate:
input.examDate instanceof Date
? input.examDate
: new Date(input.examDate),
duration: input.duration,
totalScore: input.totalScore,
status: 'draft',
status: "draft",
createdBy: input.createdBy,
};
@@ -53,14 +61,14 @@ export class ExamsService {
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'exam',
eventType: 'exam.created',
aggregateType: "exam",
eventType: "exam.created",
payload: JSON.stringify({
id,
classId: input.classId,
title: input.title,
}),
status: 'pending',
status: "pending",
},
tx,
);
@@ -93,10 +101,10 @@ export class ExamsService {
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'exam',
eventType: 'exam.updated',
aggregateType: "exam",
eventType: "exam.updated",
payload: JSON.stringify({ id, changes: data }),
status: 'pending',
status: "pending",
},
tx,
);
@@ -115,10 +123,10 @@ export class ExamsService {
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'exam',
eventType: 'exam.deleted',
aggregateType: "exam",
eventType: "exam.deleted",
payload: JSON.stringify({ id }),
status: 'pending',
status: "pending",
},
tx,
);

View File

@@ -1,55 +1,57 @@
import {
Body,
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { GradesService, type RecordGradeInput } from './grades.service.js';
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { GradesService, type RecordGradeInput } from "./grades.service.js";
interface SuccessResponse<T> {
data: T;
timestamp: string;
}
@Controller('api/v1/grades')
@Controller("grades")
export class GradesController {
constructor(private readonly gradesService: GradesService) {}
@Post()
@UseGuards(new PermissionGuard(Permissions.GRADE_CREATE))
async record(@Body() body: RecordGradeInput): Promise<SuccessResponse<{ id: string }>> {
const result = await this.gradesService.recordGrade(body);
return { data: result, timestamp: new Date().toISOString() };
async record(
@Body() body: RecordGradeInput,
@Req() req: Request,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string;
const result = await this.gradesService.recordGrade({
...body,
gradedBy: userId,
});
return { success: true, data: result };
}
@Get(':id')
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['getGrade']>>>> {
@Get(":id")
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["getGrade"]>>;
}> {
const data = await this.gradesService.getGrade(id);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Get('student/:studentId')
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
async listByStudent(@Param('studentId') studentId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByStudent']>>>> {
@Get("student/:studentId")
async listByStudent(@Param("studentId") studentId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByStudent"]>>;
}> {
const data = await this.gradesService.listByStudent(studentId);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Get('exam/:examId')
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
async listByExam(@Param('examId') examId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByExam']>>>> {
@Get("exam/:examId")
async listByExam(@Param("examId") examId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByExam"]>>;
}> {
const data = await this.gradesService.listByExam(examId);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Get('homework/:homeworkId')
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
async listByHomework(@Param('homeworkId') homeworkId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByHomework']>>>> {
@Get("homework/:homeworkId")
async listByHomework(@Param("homeworkId") homeworkId: string): Promise<{
success: true;
data: Awaited<ReturnType<GradesService["listByHomework"]>>;
}> {
const data = await this.gradesService.listByHomework(homeworkId);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
}

View File

@@ -1,11 +1,14 @@
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Injectable } from '@nestjs/common';
import { db } from '../../config/database.js';
import { grades } from './grades.schema.js';
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
import type { Grade, NewGrade } from './grades.schema.js';
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { grades } from "./grades.schema.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import type { Grade, NewGrade } from "./grades.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface RecordGradeInput {
studentId: string;
@@ -20,10 +23,10 @@ export interface RecordGradeInput {
export class GradesService {
async recordGrade(input: RecordGradeInput): Promise<{ id: string }> {
if (!input.studentId || !input.score || !input.gradedBy) {
throw new ValidationError('studentId, score, gradedBy are required');
throw new ValidationError("studentId, score, gradedBy are required");
}
if (!input.examId && !input.homeworkId) {
throw new ValidationError('Either examId or homeworkId must be provided');
throw new ValidationError("Either examId or homeworkId must be provided");
}
const id = randomUUID();
@@ -43,8 +46,8 @@ export class GradesService {
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'grade',
eventType: 'grade.recorded',
aggregateType: "grade",
eventType: "grade.recorded",
payload: JSON.stringify({
id,
studentId: input.studentId,
@@ -52,7 +55,7 @@ export class GradesService {
examId: input.examId,
homeworkId: input.homeworkId,
}),
status: 'pending',
status: "pending",
},
tx,
);

View File

@@ -1,48 +1,50 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import {
Body,
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { HomeworkService, type AssignHomeworkInput } from './homework.service.js';
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
HomeworkService,
type AssignHomeworkInput,
} from "./homework.service.js";
interface SuccessResponse<T> {
data: T;
timestamp: string;
}
@Controller('api/v1/homework')
@Controller("homework")
export class HomeworkController {
constructor(private readonly homeworkService: HomeworkService) {}
@Post()
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_CREATE))
async assign(@Body() body: AssignHomeworkInput): Promise<SuccessResponse<{ id: string }>> {
const result = await this.homeworkService.assignHomework(body);
return { data: result, timestamp: new Date().toISOString() };
async assign(
@Body() body: AssignHomeworkInput,
@Req() req: Request,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string;
const result = await this.homeworkService.assignHomework({
...body,
createdBy: userId,
});
return { success: true, data: result };
}
@Get(':id')
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_READ))
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<HomeworkService['getHomework']>>>> {
@Get(":id")
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<HomeworkService["getHomework"]>>;
}> {
const data = await this.homeworkService.getHomework(id);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Get('class/:classId')
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_READ))
async listByClass(@Param('classId') classId: string): Promise<SuccessResponse<Awaited<ReturnType<HomeworkService['listByClass']>>>> {
@Get("class/:classId")
async listByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<HomeworkService["listByClass"]>>;
}> {
const data = await this.homeworkService.listByClass(classId);
return { data, timestamp: new Date().toISOString() };
return { success: true, data };
}
@Post(':id/submit')
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_SUBMIT))
async submit(@Param('id') id: string): Promise<SuccessResponse<{ success: true }>> {
@Post(":id/submit")
async submit(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.homeworkService.submitHomework(id);
return { data: { success: true }, timestamp: new Date().toISOString() };
return { success: true, data: { success: true } };
}
}

View File

@@ -1,17 +1,20 @@
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Injectable } from '@nestjs/common';
import { db } from '../../config/database.js';
import { homework } from './homework.schema.js';
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
import type { Homework, NewHomework } from './homework.schema.js';
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { homework } from "./homework.schema.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import type { Homework, NewHomework } from "./homework.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface AssignHomeworkInput {
classId: string;
title: string;
description?: string;
dueDate: Date;
dueDate: Date | string;
createdBy: string;
}
@@ -19,7 +22,7 @@ export interface AssignHomeworkInput {
export class HomeworkService {
async assignHomework(input: AssignHomeworkInput): Promise<{ id: string }> {
if (!input.classId || !input.title || !input.createdBy) {
throw new ValidationError('classId, title, createdBy are required');
throw new ValidationError("classId, title, createdBy are required");
}
const id = randomUUID();
@@ -28,8 +31,10 @@ export class HomeworkService {
classId: input.classId,
title: input.title,
description: input.description,
dueDate: input.dueDate,
status: 'assigned',
// Drizzle datetime 列需要 Date 对象HTTP 请求体里 dueDate 是 ISO 字符串。
dueDate:
input.dueDate instanceof Date ? input.dueDate : new Date(input.dueDate),
status: "assigned",
createdBy: input.createdBy,
};
@@ -39,14 +44,14 @@ export class HomeworkService {
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'homework',
eventType: 'homework.assigned',
aggregateType: "homework",
eventType: "homework.assigned",
payload: JSON.stringify({
id,
classId: input.classId,
title: input.title,
}),
status: 'pending',
status: "pending",
},
tx,
);
@@ -73,23 +78,23 @@ export class HomeworkService {
async submitHomework(id: string): Promise<void> {
const existing = await this.getHomework(id);
if (existing.status === 'submitted') {
if (existing.status === "submitted") {
throw new ValidationError(`Homework ${id} already submitted`);
}
await db.transaction(async (tx) => {
await tx
.update(homework)
.set({ status: 'submitted' })
.set({ status: "submitted" })
.where(eq(homework.id, id));
await outboxRepository.create(
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'homework',
eventType: 'homework.submitted',
aggregateType: "homework",
eventType: "homework.submitted",
payload: JSON.stringify({ id }),
status: 'pending',
status: "pending",
},
tx,
);

View File

@@ -1,22 +1,23 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { env } from './config/env.js';
import { connectKafka, disconnectKafka } from './config/kafka.js';
import { outboxPublisher } from './shared/outbox/outbox.publisher.js';
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
import { logger } from './shared/observability/logger.js';
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";
import { env } from "./config/env.js";
import { connectKafka, disconnectKafka } from "./config/kafka.js";
import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { logger } from "./shared/observability/logger.js";
async function bootstrap(): Promise<void> {
initTracer();
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.setGlobalPrefix('api');
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// Connect Kafka producer/consumer before starting the outbox publisher
await connectKafka();
// Connect Kafka producer/consumer before starting the outbox publisher.
// Non-blocking: if Kafka is unavailable, service still starts; outbox
// publisher will retry sends and messages stay pending until Kafka recovers.
void connectKafka();
// Start the transactional outbox publisher - polls pending messages
// and publishes them to Kafka topics defined in TOPIC_MAP.
@@ -24,12 +25,12 @@ async function bootstrap(): Promise<void> {
await app.listen(env.PORT);
logger.info(
{ port: env.PORT, service: 'core-edu' },
'CoreEdu service is listening',
{ port: env.PORT, service: "core-edu" },
"CoreEdu service is listening",
);
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully...');
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully...");
await outboxPublisher.stop();
await disconnectKafka();
await shutdownTracer();
@@ -37,8 +38,8 @@ async function bootstrap(): Promise<void> {
process.exit(0);
});
process.on('SIGINT', async () => {
logger.info('SIGINT received, shutting down gracefully...');
process.on("SIGINT", async () => {
logger.info("SIGINT received, shutting down gracefully...");
await outboxPublisher.stop();
await disconnectKafka();
await shutdownTracer();

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)}`,
);
}
}