feat(teacher-bff): admin 命名空间 + 5 个 gRPC client + health probes + merge-resolvers + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 16:00:46 +08:00
parent 7b790f1276
commit 895a060491
50 changed files with 9739 additions and 334 deletions

View File

@@ -0,0 +1,26 @@
// ai gRPC 健康探针(/readyz P5 必需依赖)
import { Injectable, Inject } from "@nestjs/common";
import { AI_CLIENT } from "../../clients/ai/ai-client.interface.js";
import type { AiClient } from "../../clients/ai/ai-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class AiGrpcHealthProbe implements HealthProbe {
readonly name = "ai-grpc";
readonly required = true;
constructor(@Inject(AI_CLIENT) private readonly ai: AiClient) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.ai.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

@@ -0,0 +1,28 @@
// content gRPC 健康探针(/readyz P4 必需依赖)
import { Injectable, Inject } from "@nestjs/common";
import { CONTENT_CLIENT } from "../../clients/content/content-client.interface.js";
import type { ContentClient } from "../../clients/content/content-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class ContentGrpcHealthProbe implements HealthProbe {
readonly name = "content-grpc";
readonly required = true;
constructor(
@Inject(CONTENT_CLIENT) private readonly content: ContentClient,
) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.content.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

@@ -0,0 +1,29 @@
// core-edu gRPC 健康探针(/readyz P3 必需依赖)
// 调用 grpc.health.v1.Health/Check 检查 core-edu:50053 是否 SERVING
import { Injectable, Inject } from "@nestjs/common";
import { CORE_EDU_CLIENT } from "../../clients/core-edu/core-edu-client.interface.js";
import type { CoreEduClient } from "../../clients/core-edu/core-edu-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class CoreEduGrpcHealthProbe implements HealthProbe {
readonly name = "core-edu-grpc";
readonly required = true;
constructor(
@Inject(CORE_EDU_CLIENT) private readonly coreEdu: CoreEduClient,
) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.coreEdu.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

@@ -0,0 +1,28 @@
// data-ana gRPC 健康探针(/readyz P4 必需依赖)
import { Injectable, Inject } from "@nestjs/common";
import { DATA_ANA_CLIENT } from "../../clients/data-ana/data-ana-client.interface.js";
import type { DataAnaClient } from "../../clients/data-ana/data-ana-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class DataAnaGrpcHealthProbe implements HealthProbe {
readonly name = "data-ana-grpc";
readonly required = true;
constructor(
@Inject(DATA_ANA_CLIENT) private readonly dataAna: DataAnaClient,
) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.dataAna.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

@@ -0,0 +1,26 @@
// msg gRPC 健康探针(/readyz P5 必需依赖)
import { Injectable, Inject } from "@nestjs/common";
import { MSG_CLIENT } from "../../clients/msg/msg-client.interface.js";
import type { MsgClient } from "../../clients/msg/msg-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class MsgGrpcHealthProbe implements HealthProbe {
readonly name = "msg-grpc";
readonly required = true;
constructor(@Inject(MSG_CLIENT) private readonly msg: MsgClient) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.msg.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

@@ -1,4 +1,4 @@
import pino from "pino";
import { pino } from "pino";
import { env } from "../../config/env.js";
export const logger = pino({
@@ -16,4 +16,4 @@ export const logger = pino({
: undefined,
});
export type Logger = typeof logger;
export type Logger = ReturnType<typeof pino>;

View File

@@ -0,0 +1,122 @@
// Zod 验证 schemasP6 Nice to Have 补全:全 Controller 覆盖)
// 对齐 teacher-bff.schema.graphql Input 类型
import { z } from "zod";
/** 创建考试 Input 验证 */
export const createExamInputSchema = z.object({
classId: z.string().min(1, "classId is required"),
title: z.string().min(1, "title is required").max(200, "title too long"),
description: z.string().max(1000, "description too long").default(""),
examDate: z.string().min(1, "examDate is required"),
duration: z.string().min(1, "duration is required"),
totalScore: z.string().min(1, "totalScore is required"),
});
/** 布置作业 Input 验证 */
export const assignHomeworkInputSchema = z.object({
classId: z.string().min(1, "classId is required"),
title: z.string().min(1, "title is required").max(200, "title too long"),
description: z.string().max(1000, "description too long").default(""),
dueDate: z.string().min(1, "dueDate is required"),
});
/** 录入成绩 Input 验证 */
export const recordGradeInputSchema = z.object({
studentId: z.string().min(1, "studentId is required"),
examId: z.string().optional(),
homeworkId: z.string().optional(),
score: z.string().min(1, "score is required"),
feedback: z.string().max(500, "feedback too long").optional(),
});
/** AI 出题 Input 验证 */
export const generateQuestionInputSchema = z.object({
prompt: z.string().min(1, "prompt is required").max(2000, "prompt too long"),
subject: z.string().min(1, "subject is required"),
difficulty: z.enum(["easy", "medium", "hard"]),
knowledgePointIds: z.array(z.string()).default([]),
questionType: z
.enum([
"single_choice",
"multi_choice",
"fill_blank",
"short_answer",
"essay",
])
.default("short_answer"),
count: z.number().int().min(1).max(10).default(1),
});
/** admin 创建用户 Input 验证 */
export const adminCreateUserInputSchema = z.object({
email: z.string().email("invalid email"),
name: z.string().min(1, "name is required").max(50, "name too long"),
roles: z.array(z.string()).min(1, "at least one role required"),
password: z
.string()
.min(8, "password must be at least 8 characters")
.max(128, "password too long"),
});
/** admin 更新用户 Input 验证 */
export const adminUpdateUserInputSchema = z.object({
name: z.string().min(1).max(50).optional(),
roles: z.array(z.string()).optional(),
status: z.enum(["ACTIVE", "INACTIVE", "SUSPENDED"]).optional(),
});
/** admin 创建角色 Input 验证 */
export const adminCreateRoleInputSchema = z.object({
name: z.string().min(1, "name is required").max(50, "name too long"),
description: z
.string()
.max(200, "description too long")
.optional()
.default(""),
permissions: z.array(z.string()).min(1, "at least one permission required"),
});
/** admin 更新学校 Input 验证 */
export const adminUpdateSchoolInputSchema = z.object({
name: z.string().min(1, "name is required").max(100, "name too long"),
address: z.string().max(200, "address too long").optional().default(""),
phone: z.string().max(20, "phone too long").optional().default(""),
email: z.string().email("invalid email").optional().or(z.literal("")),
});
/** admin 创建组织 Input 验证 */
export const adminCreateOrganizationInputSchema = z.object({
name: z.string().min(1, "name is required").max(100, "name too long"),
type: z.enum(["DEPARTMENT", "TEACHING_GROUP", "GRADE", "CLASS"]),
parentId: z.string().optional().default(""),
});
/** 日期范围 Input 验证 */
export const dateRangeInputSchema = z.object({
start: z.string().min(1, "start date is required"),
end: z.string().min(1, "end date is required"),
});
/** 验证失败错误类型 */
export interface ValidationError {
field: string;
message: string;
}
/** 执行验证并返回结构化错误data 类型为 schema 的 output 类型) */
export function validateInput<S extends z.ZodSchema>(
schema: S,
input: unknown,
):
| { success: true; data: z.output<S> }
| { success: false; errors: ValidationError[] } {
const result = schema.safeParse(input);
if (result.success) {
return { success: true, data: result.data };
}
const errors: ValidationError[] = result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
}));
return { success: false, errors };
}