feat(teacher-bff): 完整实现 teacher-bff GraphQL 聚合层
包含 clients/graphql/middleware、health probes、shared-ts contracts 等
This commit is contained in:
@@ -1,158 +1,122 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { env } from "../config/env.js";
|
||||
// TeacherService — GraphQL Resolver 的业务逻辑层(B1 裁决:P2 起 GraphQL)
|
||||
// 通过 IamClient 调下游 iam gRPC(B2 裁决:首次实现即 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 mock,P3+ core-edu 真实数据) */
|
||||
export interface ClassInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
gradeId: string;
|
||||
}
|
||||
|
||||
/** P2 mock 班级数据(president §3.5:P2 班级列表来自 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.8:P2 仅调 iam gRPC)
|
||||
* P2: user + viewports 有数据,classes 返回 mock,stats 返回 null + warning
|
||||
* P3+: classes 来自 core-edu,stats 来自 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 返回 null(P4+ 替换为 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: mock;P3+: 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: mock;P3+: 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 };
|
||||
|
||||
Reference in New Issue
Block a user