feat(core-edu): admin/dashboard/leave-requests 模块 + gRPC + 状态机测试 + nextstep 文档
This commit is contained in:
973
services/core-edu/src/grpc/grpc.server.ts
Normal file
973
services/core-edu/src/grpc/grpc.server.ts
Normal 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. 相对于源文件位置(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");
|
||||
}
|
||||
Reference in New Issue
Block a user