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

@@ -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,
);