974 lines
32 KiB
TypeScript
974 lines
32 KiB
TypeScript
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. 相对于源文件位置(dev:src/grpc;prod:dist/grpc,目录层级一致)
|
||
* 2. 相对于 cwd 的 packages/shared-proto/proto(cwd = 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 类型为 unknown(addService 的实现签名),此处从 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 string,null -> ""
|
||
// ----------------------------------------------------------------------------
|
||
|
||
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 handlers(8 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 handlers(5 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 handlers(6 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 handlers(4 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 handlers(4 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 handlers(P3.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 handlers(P3.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 handlers(P3.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 handlers(P3.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");
|
||
}
|