feat(teacher-bff): 完整实现 teacher-bff GraphQL 聚合层

包含 clients/graphql/middleware、health probes、shared-ts contracts 等
This commit is contained in:
SpecialX
2026-07-10 19:10:07 +08:00
parent b82593aac2
commit 99155a5ea1
37 changed files with 2862 additions and 324 deletions

View File

@@ -1,70 +0,0 @@
import { Controller, Get, Param, Req } from "@nestjs/common";
import type { Request } from "express";
import { TeacherService } from "./teacher.service.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
interface SuccessResponse<T> {
success: true;
data: T;
}
@Controller("teacher")
export class TeacherController {
constructor(private readonly service: TeacherService) {}
@Get("dashboard")
async dashboard(@Req() req: Request): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.getDashboard(userId);
return { success: true as const, data };
}
@Get("viewports")
async viewports(@Req() req: Request): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.getViewports(userId);
return { success: true as const, data };
}
// 聚合班级下的考试列表core-edu
@Get("classes/:classId/exams")
async listExamsByClass(
@Req() req: Request,
@Param("classId") classId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listExamsByClass(userId, classId);
return { success: true as const, data };
}
// 聚合班级下的作业列表core-edu
@Get("classes/:classId/homework")
async listHomeworkByClass(
@Req() req: Request,
@Param("classId") classId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listHomeworkByClass(userId, classId);
return { success: true as const, data };
}
// 聚合考试下的成绩列表core-edu
@Get("exams/:examId/grades")
async listGradesByExam(
@Req() req: Request,
@Param("examId") examId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listGradesByExam(userId, examId);
return { success: true as const, data };
}
private extractUserId(req: Request): string {
const header = req.headers["x-user-id"];
const userId = typeof header === "string" ? header : undefined;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
return userId;
}
}

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { TeacherController } from './teacher.controller.js';
import { TeacherService } from './teacher.service.js';
// Teacher 模块B1 裁决P2 起 GraphQL不再使用 REST Controller
import { Module } from "@nestjs/common";
import { TeacherService } from "./teacher.service.js";
import { ClientsModule } from "../clients/clients.module.js";
@Module({
controllers: [TeacherController],
imports: [ClientsModule],
providers: [TeacherService],
exports: [TeacherService],
})
export class TeacherModule {}

View File

@@ -0,0 +1,362 @@
// GraphQL ResolversB1 裁决P2 起 GraphQL
// P2 核心 5 Querydashboard / viewports / me / classes / class
// P3+ Query/Mutation占位返回 null/空 + extensions.warning
// admin 命名空间:占位返回 null/空P6 实现)
import type { IResolvers } from "@graphql-tools/utils";
import { TeacherService } from "./teacher.service.js";
import type { GraphQLOperationContext } from "../graphql/context.js";
import { BusinessError } from "../shared/errors/application-error.js";
/**
* 构建 GraphQL Resolvers
* 依赖 TeacherService通过闭包注入避免 NestJS DI 与 Yoga 的集成复杂度)
*/
export function buildResolvers(service: TeacherService): IResolvers {
return {
Query: {
// ===== P2 核心 5 Query =====
dashboard: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getDashboard(ctx.callCtx);
},
viewports: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getViewports(ctx.callCtx);
},
me: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getCurrentUser(ctx.callCtx);
},
classes: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getClasses(ctx.callCtx);
},
class: async (
_parent: unknown,
args: { id: string },
ctx: GraphQLOperationContext,
) => {
return service.getClass(ctx.callCtx, args.id);
},
// ===== P3+ Query 占位(返回空 + warning跨阶段扩展例外=====
exams: async (
_parent: unknown,
_args: { classId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"exams query not available in P2 (core-edu gRPC not ready, P3+)",
{ phase: "P2", field: "exams", warning: "field_unavailable_in_p2" },
);
},
homework: async (
_parent: unknown,
_args: { classId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"homework query not available in P2 (core-edu gRPC not ready, P3+)",
{
phase: "P2",
field: "homework",
warning: "field_unavailable_in_p2",
},
);
},
grades: async (
_parent: unknown,
_args: { examId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"grades query not available in P2 (core-edu gRPC not ready, P3+)",
{ phase: "P2", field: "grades", warning: "field_unavailable_in_p2" },
);
},
// ===== P4+ Query 占位 =====
knowledgePath: async (
_parent: unknown,
_args: { knowledgePointId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"knowledgePath query not available in P2 (content gRPC not ready, P4+)",
{
phase: "P2",
field: "knowledgePath",
warning: "field_unavailable_in_p2",
},
);
},
classPerformance: async (
_parent: unknown,
_args: { classId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"classPerformance query not available in P2 (data-ana gRPC not ready, P4+)",
{
phase: "P2",
field: "classPerformance",
warning: "field_unavailable_in_p2",
},
);
},
studentWeakness: async (
_parent: unknown,
_args: { studentId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"studentWeakness query not available in P2 (data-ana gRPC not ready, P4+)",
{
phase: "P2",
field: "studentWeakness",
warning: "field_unavailable_in_p2",
},
);
},
learningTrend: async (
_parent: unknown,
_args: { studentId: string; dateRange?: unknown },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"learningTrend query not available in P2 (data-ana gRPC not ready, P4+)",
{
phase: "P2",
field: "learningTrend",
warning: "field_unavailable_in_p2",
},
);
},
// ===== P5+ Query 占位 =====
notifications: async (
_parent: unknown,
_args: { unreadOnly?: boolean },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"notifications query not available in P2 (msg gRPC not ready, P5+)",
{
phase: "P2",
field: "notifications",
warning: "field_unavailable_in_p2",
},
);
},
// ===== admin 命名空间president §5.1P2 预留P6 实现)=====
admin: () => {
// 返回空对象admin 子字段 resolver 返回 null/空
return {};
},
},
Mutation: {
// ===== P3+ Mutation 占位 =====
createExam: async (
_parent: unknown,
_args: unknown,
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"createExam mutation not available in P2 (core-edu gRPC not ready, P3+)",
{
phase: "P2",
field: "createExam",
warning: "field_unavailable_in_p2",
},
);
},
assignHomework: async (
_parent: unknown,
_args: unknown,
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"assignHomework mutation not available in P2 (core-edu gRPC not ready, P3+)",
{
phase: "P2",
field: "assignHomework",
warning: "field_unavailable_in_p2",
},
);
},
recordGrade: async (
_parent: unknown,
_args: unknown,
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"recordGrade mutation not available in P2 (core-edu gRPC not ready, P3+)",
{
phase: "P2",
field: "recordGrade",
warning: "field_unavailable_in_p2",
},
);
},
// ===== P5+ Mutation 占位 =====
generateQuestion: async (
_parent: unknown,
_args: unknown,
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"generateQuestion mutation not available in P2 (ai gRPC not ready, P5+)",
{
phase: "P2",
field: "generateQuestion",
warning: "field_unavailable_in_p2",
},
);
},
markNotificationAsRead: async (
_parent: unknown,
_args: { id: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"markNotificationAsRead mutation not available in P2 (msg gRPC not ready, P5+)",
{
phase: "P2",
field: "markNotificationAsRead",
warning: "field_unavailable_in_p2",
},
);
},
// ===== admin 命名空间 MutationP2 预留P6 实现)=====
admin: () => {
return {};
},
},
// ===== admin 命名空间子字段 ResolverP2 全部返回 null/空P6 实现)=====
AdminQuery: {
users: () => null,
user: () => null,
roles: () => [],
role: () => null,
school: () => null,
organizations: () => [],
auditLogs: () => null,
},
AdminMutation: {
createUser: () => {
throw new BusinessError(
"admin.createUser not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.createUser" },
);
},
updateUser: () => {
throw new BusinessError(
"admin.updateUser not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.updateUser" },
);
},
deleteUser: () => {
throw new BusinessError(
"admin.deleteUser not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.deleteUser" },
);
},
createRole: () => {
throw new BusinessError(
"admin.createRole not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.createRole" },
);
},
updateRole: () => {
throw new BusinessError(
"admin.updateRole not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.updateRole" },
);
},
deleteRole: () => {
throw new BusinessError(
"admin.deleteRole not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.deleteRole" },
);
},
updateSchool: () => {
throw new BusinessError(
"admin.updateSchool not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.updateSchool" },
);
},
createOrganization: () => {
throw new BusinessError(
"admin.createOrganization not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.createOrganization" },
);
},
updateOrganization: () => {
throw new BusinessError(
"admin.updateOrganization not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.updateOrganization" },
);
},
deleteOrganization: () => {
throw new BusinessError(
"admin.deleteOrganization not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.deleteOrganization" },
);
},
},
// ===== 类型字段 ResolverClass.exams / Class.homework / Exam.grades 延迟加载)=====
Class: {
exams: () => [], // P3+ core-edu gRPC
homework: () => [], // P3+ core-edu gRPC
},
Exam: {
grades: () => [], // P3+ core-edu gRPC
},
Homework: {
submissions: () => [], // P3+ core-edu gRPC
},
};
}

View File

@@ -1,158 +1,122 @@
import { Injectable } from "@nestjs/common";
import { env } from "../config/env.js";
// TeacherService — GraphQL Resolver 的业务逻辑层B1 裁决P2 起 GraphQL
// 通过 IamClient 调下游 iam gRPCB2 裁决:首次实现即 gRPC
// P2: 仅 iam 数据P3+ 扩展 core-edu / content / data-ana / msg / ai
import { Injectable, Inject } from "@nestjs/common";
import { IAM_CLIENT } from "../clients/iam/iam-client.interface.js";
import type { IamClient } from "../clients/iam/iam-client.interface.js";
import type { CallContext } from "../clients/types.js";
import type {
UserInfo,
ViewportItem,
EffectivePermissions,
} from "../clients/iam/iam.types.js";
import { logger } from "../shared/observability/logger.js";
import { BadGatewayError } from "../shared/errors/application-error.js";
export interface ViewportItem {
key: string;
label: string;
route: string;
icon: string | null;
sortOrder: string;
requiredPermission: string | null;
/** GraphQL User 类型(聚合 UserInfo + EffectivePermissions */
export interface GraphQLUser {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
dataScope: string;
}
interface DownstreamEnvelope<T> {
success: boolean;
data?: T;
/** GraphQL DashboardData 类型 */
export interface DashboardData {
user: GraphQLUser | null;
classes: unknown[];
viewports: ViewportItem[];
stats: {
totalExams: number;
pendingGrading: number;
todayHomework: number;
} | null;
}
interface DashboardData {
user: unknown;
classes: unknown;
/** GraphQL Class 类型P2 mockP3+ core-edu 真实数据) */
export interface ClassInfo {
id: string;
name: string;
gradeId: string;
}
/** P2 mock 班级数据president §3.5P2 班级列表来自 iam 数据P3+ core-edu */
const MOCK_CLASSES: ClassInfo[] = [
{ id: "class-001", name: "三年级1班", gradeId: "grade-3" },
{ id: "class-002", name: "三年级2班", gradeId: "grade-3" },
{ id: "class-003", name: "三年级3班", gradeId: "grade-3" },
];
@Injectable()
export class TeacherService {
// 聚合 IAM + classes 服务的数据
async getDashboard(userId: string): Promise<DashboardData> {
const [iamRes, classesRes] = await Promise.allSettled([
fetch(`${env.IamServiceUrl}/iam/me`, {
headers: { "x-user-id": userId },
}),
fetch(`${env.ClassesServiceUrl}/classes`, {
headers: { "x-user-id": userId },
}),
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
/** 获取当前用户信息(聚合 iam.GetUserInfo + GetEffectivePermissions */
async getCurrentUser(ctx: CallContext): Promise<GraphQLUser> {
const [userInfo, perms] = await Promise.all([
this.iam.getUserInfo(ctx),
this.iam.getEffectivePermissions(ctx),
]);
return {
id: userInfo.id,
email: userInfo.email,
name: userInfo.name,
roles: userInfo.roles,
permissions: perms.permissions,
dataScope: perms.dataScope,
};
}
/** 获取视口配置iam.GetViewports */
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
return this.iam.getViewports(ctx);
}
/**
* 获取 Dashboard 聚合数据president §2.8P2 仅调 iam gRPC
* P2: user + viewports 有数据classes 返回 mockstats 返回 null + warning
* P3+: classes 来自 core-edustats 来自 data-ana.GetTeacherDashboard
*/
async getDashboard(ctx: CallContext): Promise<DashboardData> {
const [user, viewports] = await Promise.all([
this.getCurrentUser(ctx),
this.iam.getViewports(ctx),
]);
let user: unknown = null;
let classes: unknown = null;
if (iamRes.status === "fulfilled") {
if (iamRes.value.ok) {
user = await iamRes.value.json();
} else {
logger.warn(
{ status: iamRes.value.status, url: iamRes.value.url },
"Downstream IAM service call failed",
);
}
} else {
logger.warn(
{ err: iamRes.reason, service: "iam" },
"Downstream IAM service call rejected",
);
}
if (classesRes.status === "fulfilled") {
if (classesRes.value.ok) {
classes = await classesRes.value.json();
} else {
logger.warn(
{ status: classesRes.value.status, url: classesRes.value.url },
"Downstream classes service call failed",
);
}
} else {
logger.warn(
{ err: classesRes.reason, service: "classes" },
"Downstream classes service call rejected",
);
}
return { user, classes };
}
// 聚合 IAM 视口配置L1 导航)
async getViewports(userId: string): Promise<ViewportItem[]> {
const res = await fetch(`${env.IamServiceUrl}/iam/viewports`, {
headers: { "x-user-id": userId },
});
if (!res.ok) {
logger.warn(
{ status: res.status, url: res.url },
"Downstream IAM service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "iam",
endpoint: "viewports",
status: res.status,
});
}
const json = (await res.json()) as DownstreamEnvelope<ViewportItem[]>;
return json.data ?? [];
}
// 聚合班级下的考试列表core-edu
async listExamsByClass(userId: string, classId: string): Promise<unknown> {
const res = await fetch(
`${env.CoreEduServiceUrl}/exams/class/${encodeURIComponent(classId)}`,
{ headers: { "x-user-id": userId } },
// P2: classes 返回 mock 数据P3+ 替换为 core-edu.GetClassesByTeacher
// P2: stats 返回 nullP4+ 替换为 data-ana.GetTeacherDashboard
logger.warn(
{ userId: ctx.userId, phase: "P2" },
"Dashboard P2: classes using mock, stats unavailable (field_unavailable_in_p2)",
);
if (!res.ok) {
logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "exams-by-class",
status: res.status,
});
}
const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? [];
return {
user,
viewports,
classes: MOCK_CLASSES,
stats: null,
};
}
// 聚合班级下的作业列表core-edu
async listHomeworkByClass(userId: string, classId: string): Promise<unknown> {
const res = await fetch(
`${env.CoreEduServiceUrl}/homework/class/${encodeURIComponent(classId)}`,
{ headers: { "x-user-id": userId } },
/** 获取教师班级列表P2: mockP3+: core-edu.GetClassesByTeacher */
async getClasses(ctx: CallContext): Promise<ClassInfo[]> {
logger.warn(
{ userId: ctx.userId, phase: "P2" },
"Classes P2: using mock data (core-edu not ready, field_unavailable_in_p2)",
);
if (!res.ok) {
logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "homework-by-class",
status: res.status,
});
}
const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? [];
return MOCK_CLASSES;
}
// 聚合考试下的成绩列表(core-edu
async listGradesByExam(userId: string, examId: string): Promise<unknown> {
const res = await fetch(
`${env.CoreEduServiceUrl}/grades/exam/${encodeURIComponent(examId)}`,
{ headers: { "x-user-id": userId } },
/** 获取单个班级详情P2: mockP3+: core-edu */
async getClass(ctx: CallContext, classId: string): Promise<ClassInfo | null> {
logger.warn(
{ userId: ctx.userId, classId, phase: "P2" },
"Class P2: using mock data (core-edu not ready, field_unavailable_in_p2)",
);
if (!res.ok) {
logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "grades-by-exam",
status: res.status,
});
}
const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? [];
return MOCK_CLASSES.find((c) => c.id === classId) ?? null;
}
}
/** 导出 EffectivePermissions 类型供外部使用 */
export type { EffectivePermissions, UserInfo };