feat(core-edu): admin/dashboard/leave-requests 模块 + gRPC + 状态机测试 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:56:10 +08:00
parent d260df864c
commit 7dd5c44406
23 changed files with 3344 additions and 2 deletions

View File

@@ -0,0 +1,973 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import type { INestApplicationContext } from "@nestjs/common";
import { logger } from "../shared/observability/logger.js";
import { env } from "../config/env.js";
import { ExamsService } from "../exams/exams.service.js";
import { HomeworkService } from "../homework/homework.service.js";
import { GradesService } from "../grades/grades.service.js";
import { ClassesService } from "../classes/classes.service.js";
import { AttendanceService } from "../attendance/attendance.service.js";
import { SchedulingService } from "../scheduling/scheduling.service.js";
import { LeaveRequestsService } from "../leave-requests/leave-requests.service.js";
import { DashboardService } from "../dashboard/dashboard.service.js";
import { AdminService } from "../admin/admin.service.js";
import {
ApplicationError,
NotFoundError,
ValidationError,
ConflictError,
} from "../shared/errors/application-error.js";
import type { Exam } from "../exams/exams.schema.js";
import type { Homework } from "../homework/homework.schema.js";
import type { Grade } from "../grades/grades.schema.js";
import type { Class } from "../classes/classes.schema.js";
import type { Attendance } from "../attendance/attendance.schema.js";
// proto 包名(与 core_edu.proto 中 package 声明一致)
const PROTO_PACKAGE = "next_edu_cloud.core_edu.v1";
// protoLoader 加载选项keepCase 保留 snake_case 字段名
const PROTO_OPTIONS: protoLoader.Options = {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
};
let grpcServer: grpc.Server | null = null;
// ----------------------------------------------------------------------------
// proto 文件路径解析
// ----------------------------------------------------------------------------
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* 解析 core_edu.proto 文件路径。
* 按优先级尝试多个候选路径,兼容本地开发与 Docker 运行时:
* 1. 相对于源文件位置devsrc/grpcproddist/grpc目录层级一致
* 2. 相对于 cwd 的 packages/shared-proto/protocwd = repo 根)
* 3. Docker 中 COPY 到 /app/proto 的副本
*/
function resolveProtoPath(): string {
const candidates = [
path.resolve(
__dirname,
"../../../../packages/shared-proto/proto/core_edu.proto",
),
path.resolve(process.cwd(), "packages/shared-proto/proto/core_edu.proto"),
path.resolve(process.cwd(), "proto/core_edu.proto"),
"/app/proto/core_edu.proto",
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
// 全部缺失时回退到首选路径,让 proto-loader 抛出明确的文件不存在错误
const fallback = candidates[0];
return fallback ?? candidates[1] ?? "";
}
/**
* 沿 next_edu_cloud.core_edu.v1 导航 proto 包定义,返回目标包 GrpcObject。
* proto-loader 的 GrpcObject 索引签名包含 ProtobufTypeDefinition
* 包层级在运行时一定是 GrpcObject经 unknown 取出以避开联合类型。
*/
function getCoreEduPackage(protoDescriptor: grpc.GrpcObject): grpc.GrpcObject {
const root = protoDescriptor as unknown as Record<string, unknown>;
const segment = root["next_edu_cloud"];
const coreEdu = (segment as Record<string, unknown> | undefined)?.[
"core_edu"
];
const v1 = (coreEdu as Record<string, unknown> | undefined)?.["v1"];
if (!v1 || typeof v1 !== "object") {
throw new Error(`gRPC package "${PROTO_PACKAGE}" not found in proto`);
}
return v1 as unknown as grpc.GrpcObject;
}
/**
* 从 proto 包中取出服务的 ServiceDefinition。
* proto-loader 生成的服务构造器上有静态 .service 属性(方法定义表),
* 该属性不在 grpc.Client 基类类型声明中,需要经 unknown 断言取出。
*/
function getServiceDefinition(
pkg: grpc.GrpcObject,
serviceName: string,
): grpc.ServiceDefinition<grpc.UntypedServiceImplementation> {
const service = pkg[serviceName];
if (service === undefined || typeof service !== "function") {
throw new Error(`gRPC service "${serviceName}" not found in proto package`);
}
const ctor = service as unknown as {
service: grpc.ServiceDefinition<grpc.UntypedServiceImplementation>;
};
if (!ctor.service) {
throw new Error(
`Service definition missing on "${serviceName}" constructor`,
);
}
return ctor.service;
}
// ----------------------------------------------------------------------------
// 错误处理:将 ApplicationError 映射为 gRPC status code
// ----------------------------------------------------------------------------
/**
* gRPC 服务端错误,实现 grpc.ServiceError 接口以便 callback 直接使用。
*/
class GrpcServiceError extends Error implements grpc.ServiceError {
readonly code: grpc.status;
readonly details: string;
readonly metadata: grpc.Metadata;
constructor(code: grpc.status, message: string) {
super(message);
this.name = "GrpcServiceError";
this.code = code;
this.details = message;
this.metadata = new grpc.Metadata();
}
}
function toGrpcError(err: unknown): grpc.ServiceError {
if (err instanceof NotFoundError) {
return new GrpcServiceError(grpc.status.NOT_FOUND, err.message);
}
if (err instanceof ValidationError) {
return new GrpcServiceError(grpc.status.INVALID_ARGUMENT, err.message);
}
if (err instanceof ConflictError) {
return new GrpcServiceError(grpc.status.FAILED_PRECONDITION, err.message);
}
if (err instanceof ApplicationError) {
// 按业务错误状态码补充映射
if (err.statusCode === 401) {
return new GrpcServiceError(grpc.status.UNAUTHENTICATED, err.message);
}
if (err.statusCode === 403) {
return new GrpcServiceError(grpc.status.PERMISSION_DENIED, err.message);
}
return new GrpcServiceError(grpc.status.INTERNAL, err.message);
}
const message = err instanceof Error ? err.message : "Internal server error";
return new GrpcServiceError(grpc.status.INTERNAL, message);
}
// ----------------------------------------------------------------------------
// handler 包装:把 async (req) => Promise<resp> 转为 grpc handleUnaryCall
// ----------------------------------------------------------------------------
type ProtoRequest = Record<string, unknown>;
type ProtoResponse = Record<string, unknown>;
function wrapHandler(
handler: (req: ProtoRequest) => Promise<ProtoResponse>,
): grpc.handleUnaryCall<unknown, unknown> {
return (call, callback) => {
// call.request 类型为 unknownaddService 的实现签名),此处从 unknown 转换为记录
const request = call.request as ProtoRequest;
Promise.resolve()
.then(() => handler(request))
.then((result) => callback(null, result))
.catch((err: unknown) => callback(toGrpcError(err)));
};
}
// ----------------------------------------------------------------------------
// 字段转换工具Date -> ISO stringnull -> ""
// ----------------------------------------------------------------------------
function toIso(value: Date | string | null | undefined): string {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value;
}
return value.toISOString();
}
function str(value: string | null | undefined): string {
return value ?? "";
}
// ----------------------------------------------------------------------------
// 实体 -> proto message 映射camelCase -> snake_case + ISO 日期)
// ----------------------------------------------------------------------------
function toExamProto(exam: Exam): ProtoResponse {
return {
id: exam.id,
class_id: exam.classId,
subject_id: exam.subjectId,
title: exam.title,
description: str(exam.description),
exam_date: toIso(exam.examDate),
duration: exam.duration,
total_score: exam.totalScore,
status: exam.status,
status_changed_at: toIso(exam.statusChangedAt),
status_changed_by: str(exam.statusChangedBy),
school_id: exam.schoolId,
created_by: exam.createdBy,
archived_at: toIso(exam.archivedAt),
created_at: toIso(exam.createdAt),
updated_at: toIso(exam.updatedAt),
};
}
function toHomeworkProto(hw: Homework): ProtoResponse {
return {
id: hw.id,
class_id: hw.classId,
subject_id: hw.subjectId,
title: hw.title,
description: str(hw.description),
due_date: toIso(hw.dueDate),
grace_period: hw.gracePeriod,
status: hw.status,
school_id: hw.schoolId,
created_by: hw.createdBy,
created_at: toIso(hw.createdAt),
updated_at: toIso(hw.updatedAt),
};
}
function toGradeProto(grade: Grade): ProtoResponse {
return {
id: grade.id,
student_id: grade.studentId,
exam_id: str(grade.examId),
homework_id: str(grade.homeworkId),
score: grade.score,
total_score: grade.totalScore,
feedback: str(grade.feedback),
graded_by: grade.gradedBy,
school_id: grade.schoolId,
idempotency_key: str(grade.idempotencyKey),
created_at: toIso(grade.createdAt),
updated_at: toIso(grade.updatedAt),
};
}
function toClassProto(cls: Class): ProtoResponse {
return {
id: cls.id,
name: cls.name,
grade_id: cls.gradeId,
head_teacher_id: str(cls.headTeacherId),
description: str(cls.description),
created_at: toIso(cls.createdAt),
updated_at: toIso(cls.updatedAt),
};
}
function toAttendanceProto(att: Attendance): ProtoResponse {
return {
id: att.id,
schedule_id: att.scheduleId,
student_id: att.studentId,
status: att.status,
remark: str(att.remark),
recorded_by: att.recordedBy,
school_id: att.schoolId,
created_at: toIso(att.createdAt),
updated_at: toIso(att.updatedAt),
};
}
// ----------------------------------------------------------------------------
// proto 请求字段读取工具(从 snake_case 请求中安全取值)
// ----------------------------------------------------------------------------
function reqStr(req: ProtoRequest, key: string): string {
const v = req[key];
return typeof v === "string" ? v : "";
}
function reqNum(req: ProtoRequest, key: string): number {
const v = req[key];
return typeof v === "number" ? v : 0;
}
function reqStrArr(req: ProtoRequest, key: string): string[] {
const v = req[key];
if (!Array.isArray(v)) {
return [];
}
// Array.isArray 将 unknown 收窄为 any[],先经 unknown[] 再用类型守卫过滤
const arr = v as unknown[];
return arr.filter((x): x is string => typeof x === "string");
}
function reqObjArr(req: ProtoRequest, key: string): ProtoRequest[] {
const v = req[key];
if (!Array.isArray(v)) {
return [];
}
const arr = v as unknown[];
return arr.map((item) =>
item !== null && typeof item === "object" ? (item as ProtoRequest) : {},
);
}
// ----------------------------------------------------------------------------
// ExamService handlers8 RPC
// ----------------------------------------------------------------------------
function buildExamHandlers(
service: ExamsService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
CreateExam: wrapHandler(async (req) => {
const result = await service.createExam({
classId: reqStr(req, "class_id"),
subjectId: reqStr(req, "subject_id"),
title: reqStr(req, "title"),
description: reqStr(req, "description") || undefined,
examDate: reqStr(req, "exam_date"),
duration: reqNum(req, "duration"),
totalScore: reqStr(req, "total_score"),
schoolId: reqStr(req, "school_id"),
createdBy: reqStr(req, "created_by"),
});
return { id: result.id };
}),
GetExam: wrapHandler(async (req) => {
const exam = await service.getExam(reqStr(req, "id"));
return toExamProto(exam);
}),
ListExamsByClass: wrapHandler(async (req) => {
const exams = await service.listExamsByClass(reqStr(req, "class_id"));
return { exams: exams.map(toExamProto) };
}),
UpdateExam: wrapHandler(async (req) => {
await service.updateExam(reqStr(req, "id"), {
title: reqStr(req, "title") || undefined,
description: reqStr(req, "description") || undefined,
examDate: reqStr(req, "exam_date")
? new Date(reqStr(req, "exam_date"))
: undefined,
duration: reqNum(req, "duration") || undefined,
totalScore: reqStr(req, "total_score") || undefined,
});
return { success: true };
}),
DeleteExam: wrapHandler(async (req) => {
await service.deleteExam(reqStr(req, "id"));
return { success: true };
}),
PublishExam: wrapHandler(async (req) => {
await service.publishExam(reqStr(req, "id"), reqStr(req, "published_by"));
return { success: true };
}),
SubmitExam: wrapHandler(async (req) => {
const answers = reqObjArr(req, "answers").map((a) => ({
questionId: reqStr(a, "question_id"),
answer: reqStr(a, "answer"),
}));
const result = await service.submitExam(
reqStr(req, "exam_id"),
reqStr(req, "student_id"),
answers,
);
return { submission_id: result.submissionId };
}),
GradeExam: wrapHandler(async (req) => {
const scores = reqObjArr(req, "scores").map((s) => ({
questionId: reqStr(s, "question_id"),
score: reqStr(s, "score"),
teacherComment: reqStr(s, "teacher_comment") || undefined,
}));
const result = await service.gradeExam(
reqStr(req, "exam_id"),
reqStr(req, "submission_id"),
scores,
reqStr(req, "graded_by"),
);
return { success: true, total_score: result.totalScore };
}),
// P3.13 新增
SaveExamDraft: wrapHandler(async (req) => {
const answers = reqObjArr(req, "answers").map((a) => ({
questionId: reqStr(a, "question_id"),
answer: reqStr(a, "answer"),
}));
const result = await service.saveExamDraft(
reqStr(req, "exam_id"),
reqStr(req, "student_id"),
answers,
);
return { draft_id: result.draftId };
}),
RecordExamViolation: wrapHandler(async (req) => {
const result = await service.recordExamViolation(
reqStr(req, "exam_id"),
reqStr(req, "student_id"),
reqStr(req, "violation_type"),
reqStr(req, "detail"),
reqNum(req, "severity"),
);
return { violation_id: result.violationId };
}),
};
}
// ----------------------------------------------------------------------------
// HomeworkService handlers5 RPC
// ----------------------------------------------------------------------------
function buildHomeworkHandlers(
service: HomeworkService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
AssignHomework: wrapHandler(async (req) => {
const result = await service.assignHomework({
classId: reqStr(req, "class_id"),
subjectId: reqStr(req, "subject_id"),
title: reqStr(req, "title"),
description: reqStr(req, "description") || undefined,
dueDate: reqStr(req, "due_date"),
gracePeriod: reqNum(req, "grace_period"),
schoolId: reqStr(req, "school_id"),
createdBy: reqStr(req, "created_by"),
});
return { id: result.id };
}),
GetHomework: wrapHandler(async (req) => {
const hw = await service.getHomework(reqStr(req, "id"));
return toHomeworkProto(hw);
}),
ListHomeworkByClass: wrapHandler(async (req) => {
const list = await service.listByClass(reqStr(req, "class_id"));
return { homework: list.map(toHomeworkProto) };
}),
SubmitHomework: wrapHandler(async (req) => {
const answers = reqObjArr(req, "answers").map((a) => ({
questionId: reqStr(a, "question_id"),
answer: reqStr(a, "answer"),
}));
const result = await service.submitHomework(
reqStr(req, "homework_id"),
reqStr(req, "student_id"),
answers,
);
return { submission_id: result.submissionId };
}),
GradeHomework: wrapHandler(async (req) => {
const scores = reqObjArr(req, "scores").map((s) => ({
questionId: reqStr(s, "question_id"),
score: reqStr(s, "score"),
teacherComment: reqStr(s, "teacher_comment") || undefined,
}));
const result = await service.gradeHomework(
reqStr(req, "homework_id"),
reqStr(req, "submission_id"),
scores,
reqStr(req, "feedback") || undefined,
reqStr(req, "graded_by"),
);
return { success: true, total_score: result.totalScore };
}),
};
}
// ----------------------------------------------------------------------------
// GradeService handlers6 RPC
// ----------------------------------------------------------------------------
function buildGradeHandlers(
service: GradesService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
RecordGrade: wrapHandler(async (req) => {
const result = await service.recordGrade({
studentId: reqStr(req, "student_id"),
examId: reqStr(req, "exam_id") || undefined,
homeworkId: reqStr(req, "homework_id") || undefined,
score: reqStr(req, "score"),
totalScore: reqStr(req, "total_score"),
feedback: reqStr(req, "feedback") || undefined,
gradedBy: reqStr(req, "graded_by"),
schoolId: reqStr(req, "school_id"),
idempotencyKey: reqStr(req, "idempotency_key") || undefined,
});
return { id: result.id };
}),
GetGrade: wrapHandler(async (req) => {
const grade = await service.getGrade(reqStr(req, "id"));
return toGradeProto(grade);
}),
ListGradesByStudent: wrapHandler(async (req) => {
const grades = await service.listByStudent(reqStr(req, "student_id"));
return { grades: grades.map(toGradeProto) };
}),
ListGradesByExam: wrapHandler(async (req) => {
const grades = await service.listByExam(reqStr(req, "exam_id"));
return { grades: grades.map(toGradeProto) };
}),
ListGradesByHomework: wrapHandler(async (req) => {
const grades = await service.listByHomework(reqStr(req, "homework_id"));
return { grades: grades.map(toGradeProto) };
}),
UpdateGrade: wrapHandler(async (req) => {
await service.updateGrade(
reqStr(req, "id"),
{
score: reqStr(req, "score") || undefined,
feedback: reqStr(req, "feedback") || undefined,
},
reqStr(req, "updated_by"),
);
return { success: true };
}),
// P3.13 新增
GetReportCard: wrapHandler(async (req) => {
const reportCard = await service.getReportCard(
reqStr(req, "student_id"),
reqStr(req, "term_id") || undefined,
);
return {
student_id: reportCard.studentId,
term_id: reportCard.termId,
entries: reportCard.entries.map((e) => ({
subject_id: e.subjectId,
subject_name: e.subjectName,
exam_score: e.examScore,
exam_total: e.examTotal,
homework_score: e.homeworkScore,
homework_total: e.homeworkTotal,
final_score: e.finalScore,
grade_level: e.gradeLevel,
teacher_comment: e.teacherComment,
})),
overall_grade: reportCard.overallGrade,
class_rank: reportCard.classRank,
created_at: reportCard.createdAt,
};
}),
};
}
// ----------------------------------------------------------------------------
// ClassService handlers4 RPC
// ----------------------------------------------------------------------------
function buildClassHandlers(
service: ClassesService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
GetClass: wrapHandler(async (req) => {
const cls = await service.getClass(reqStr(req, "id"));
return toClassProto(cls);
}),
GetClassesByTeacher: wrapHandler(async (req) => {
const list = await service.getClassesByTeacher(reqStr(req, "teacher_id"));
return { classes: list.map(toClassProto) };
}),
BatchGetClasses: wrapHandler(async (req) => {
const list = await service.batchGetClasses(reqStrArr(req, "ids"));
return { classes: list.map(toClassProto) };
}),
ListStudentsByClass: wrapHandler(async (req) => {
const students = await service.listStudentsByClass(
reqStr(req, "class_id"),
);
return {
students: students.map((s) => ({
id: s.id,
name: s.name,
class_id: s.classId,
})),
};
}),
};
}
// ----------------------------------------------------------------------------
// AttendanceService handlers4 RPC
// ----------------------------------------------------------------------------
function buildAttendanceHandlers(
service: AttendanceService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
RecordAttendance: wrapHandler(async (req) => {
const result = await service.recordAttendance({
scheduleId: reqStr(req, "schedule_id"),
studentId: reqStr(req, "student_id"),
status: reqStr(req, "status"),
remark: reqStr(req, "remark") || undefined,
recordedBy: reqStr(req, "recorded_by"),
schoolId: reqStr(req, "school_id"),
});
return { id: result.id };
}),
GetAttendance: wrapHandler(async (req) => {
const att = await service.getAttendance(reqStr(req, "id"));
return toAttendanceProto(att);
}),
ListAttendanceByStudent: wrapHandler(async (req) => {
const list = await service.listByStudent(reqStr(req, "student_id"));
return { attendance: list.map(toAttendanceProto) };
}),
ListAttendanceByClass: wrapHandler(async (req) => {
const list = await service.listByClass(reqStr(req, "class_id"));
return { attendance: list.map(toAttendanceProto) };
}),
};
}
// ----------------------------------------------------------------------------
// ScheduleService handlersP3.13 新增1 RPC
// ----------------------------------------------------------------------------
function buildScheduleHandlers(
service: SchedulingService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
GetScheduleByStudent: wrapHandler(async (req) => {
const slots = await service.getScheduleByStudent(
reqStr(req, "student_id"),
reqStr(req, "week_start") || undefined,
);
return {
slots: slots.map((s) => ({
id: s.id,
course_id: s.courseId,
course_name: s.courseName,
teacher_id: s.teacherId,
class_id: s.classId,
room_id: s.roomId,
start_time: s.startTime,
end_time: s.endTime,
subject_id: s.subjectId,
})),
};
}),
};
}
// ----------------------------------------------------------------------------
// LeaveRequestService handlersP3.13 新增3 RPC
// ----------------------------------------------------------------------------
function buildLeaveRequestHandlers(
service: LeaveRequestsService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
ListLeaveRequestsByStudent: wrapHandler(async (req) => {
const list = await service.listByStudent(
reqStr(req, "student_id"),
reqStr(req, "status") || undefined,
);
return {
leave_requests: list.map((lr) => ({
id: lr.id,
student_id: lr.studentId,
class_id: lr.classId,
leave_type: lr.leaveType,
start_date:
lr.startDate instanceof Date
? lr.startDate.toISOString().slice(0, 10)
: lr.startDate,
end_date:
lr.endDate instanceof Date
? lr.endDate.toISOString().slice(0, 10)
: lr.endDate,
reason: lr.reason,
status: lr.status,
submitted_by: lr.submittedBy,
reviewed_by: str(lr.reviewedBy),
review_comment: str(lr.reviewComment),
school_id: lr.schoolId,
created_at: toIso(lr.createdAt),
updated_at: toIso(lr.updatedAt),
})),
};
}),
CreateLeaveRequest: wrapHandler(async (req) => {
const result = await service.create({
studentId: reqStr(req, "student_id"),
classId: reqStr(req, "class_id"),
leaveType: reqStr(req, "leave_type"),
startDate: reqStr(req, "start_date"),
endDate: reqStr(req, "end_date"),
reason: reqStr(req, "reason"),
submittedBy: reqStr(req, "submitted_by"),
schoolId: reqStr(req, "school_id"),
});
return { id: result.id };
}),
CancelLeaveRequest: wrapHandler(async (req) => {
await service.cancel(reqStr(req, "id"), reqStr(req, "cancelled_by"));
return { success: true };
}),
};
}
// ----------------------------------------------------------------------------
// DashboardService handlersP3.13 新增2 RPC
// ----------------------------------------------------------------------------
function buildDashboardHandlers(
service: DashboardService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
GetDashboard: wrapHandler(async (req) => {
const data = await service.getDashboard(reqStr(req, "teacher_id"));
return {
teacher_id: data.teacherId,
total_classes: data.totalClasses,
total_students: data.totalStudents,
pending_homework: data.pendingHomework,
upcoming_exams: data.upcomingExams,
ungraded_submissions: data.ungradedSubmissions,
classes: data.classes.map((c) => ({
class_id: c.classId,
class_name: c.className,
student_count: c.studentCount,
})),
upcoming_exam_list: data.upcomingExamList.map((e) => ({
exam_id: e.examId,
title: e.title,
exam_date: e.examDate,
class_id: e.classId,
class_name: e.className,
})),
generated_at: data.generatedAt,
};
}),
GetClassPerformance: wrapHandler(async (req) => {
const perf = await service.getClassPerformance(
reqStr(req, "class_id"),
reqStr(req, "subject_id") || undefined,
);
return {
class_id: perf.classId,
class_name: perf.className,
student_count: perf.studentCount,
average_score: perf.averageScore,
highest_score: perf.highestScore,
lowest_score: perf.lowestScore,
median_score: perf.medianScore,
subjects: perf.subjects.map((s) => ({
subject_id: s.subjectId,
subject_name: s.subjectName,
average_score: s.averageScore,
student_count: s.studentCount,
})),
generated_at: perf.generatedAt,
};
}),
};
}
// ----------------------------------------------------------------------------
// AdminService handlersP3.13 新增4 RPC - aggregation stubs
// ----------------------------------------------------------------------------
function buildAdminHandlers(
service: AdminService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
ListSchools: wrapHandler(async () => {
const schools = await service.listSchools();
return {
schools: schools.map((s) => ({
id: s.id,
name: s.name,
address: s.address,
principal_id: s.principalId,
created_at: s.createdAt,
})),
};
}),
ListGradeLevels: wrapHandler(async (req) => {
const levels = await service.listGradeLevels(reqStr(req, "school_id"));
return {
grade_levels: levels.map((l) => ({
id: l.id,
name: l.name,
school_id: l.schoolId,
order: l.order,
})),
};
}),
ListDepartments: wrapHandler(async (req) => {
const depts = await service.listDepartments(reqStr(req, "school_id"));
return {
departments: depts.map((d) => ({
id: d.id,
name: d.name,
school_id: d.schoolId,
head_id: d.headId,
created_at: d.createdAt,
})),
};
}),
ListAcademicYears: wrapHandler(async (req) => {
const years = await service.listAcademicYears(
reqStr(req, "school_id") || undefined,
);
return {
academic_years: years.map((y) => ({
id: y.id,
name: y.name,
school_id: y.schoolId,
start_date: y.startDate,
end_date: y.endDate,
is_current: y.isCurrent,
})),
};
}),
};
}
// ----------------------------------------------------------------------------
// 启动 / 停止
// ----------------------------------------------------------------------------
/**
* 启动 gRPC server监听 env.GRPC_PORT默认 50053
* 通过 NestJS application context 获取各 Service 实例并注册 handler。
*/
export async function startGrpcServer(
app: INestApplicationContext,
): Promise<void> {
const protoPath = resolveProtoPath();
const packageDefinition = protoLoader.loadSync(protoPath, PROTO_OPTIONS);
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
const pkg = getCoreEduPackage(protoDescriptor);
// 从 NestJS 容器获取 service 实例
const examsService = app.get(ExamsService);
const homeworkService = app.get(HomeworkService);
const gradesService = app.get(GradesService);
const classesService = app.get(ClassesService);
const attendanceService = app.get(AttendanceService);
const schedulingService = app.get(SchedulingService);
const leaveRequestsService = app.get(LeaveRequestsService);
const dashboardService = app.get(DashboardService);
const adminService = app.get(AdminService);
const server = new grpc.Server();
server.addService(
getServiceDefinition(pkg, "ExamService"),
buildExamHandlers(examsService),
);
server.addService(
getServiceDefinition(pkg, "HomeworkService"),
buildHomeworkHandlers(homeworkService),
);
server.addService(
getServiceDefinition(pkg, "GradeService"),
buildGradeHandlers(gradesService),
);
server.addService(
getServiceDefinition(pkg, "ClassService"),
buildClassHandlers(classesService),
);
server.addService(
getServiceDefinition(pkg, "AttendanceService"),
buildAttendanceHandlers(attendanceService),
);
// P3.13 新增 4 个服务
server.addService(
getServiceDefinition(pkg, "ScheduleService"),
buildScheduleHandlers(schedulingService),
);
server.addService(
getServiceDefinition(pkg, "LeaveRequestService"),
buildLeaveRequestHandlers(leaveRequestsService),
);
server.addService(
getServiceDefinition(pkg, "DashboardService"),
buildDashboardHandlers(dashboardService),
);
server.addService(
getServiceDefinition(pkg, "AdminService"),
buildAdminHandlers(adminService),
);
const address = `0.0.0.0:${env.GRPC_PORT}`;
await new Promise<void>((resolve, reject) => {
server.bindAsync(
address,
grpc.ServerCredentials.createInsecure(),
(err) => {
if (err) {
reject(err);
} else {
resolve();
}
},
);
});
grpcServer = server;
logger.info(
{ port: env.GRPC_PORT, protoPath, service: "core-edu" },
"gRPC server is listening",
);
}
/**
* 优雅停止 gRPC server。
*/
export async function stopGrpcServer(): Promise<void> {
const server = grpcServer;
if (!server) {
return;
}
await new Promise<void>((resolve, reject) => {
server.tryShutdown((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
grpcServer = null;
logger.info({ service: "core-edu" }, "gRPC server stopped");
}