feat(student-bff): extended queries/mutations resolvers + Dockerfile + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 16:01:20 +08:00
parent 895a060491
commit 422b55f901
6 changed files with 2547 additions and 18 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,792 @@
/**
* Extended Queries Resolver - student-bff 扩展查询 (P3-P5).
*
* 仲裁依据:
* - student-bff.schema.graphql 扩展 Query (examDetail/homeworkDetail/serverTime 等)
* - coord-final-decisions §2 B2 (gRPC 调用下游)
* - coord-final-decisions §2 B4 (强制自我越权防御)
* - coord-final-decisions §2 B6 (Redis 短缓存)
* - president-final-rulings §2.6 (降级模式方案 B)
*
* 实现: 22 个新 Query, 涵盖 P3 扩展/P4 学习/P5 校园生活.
* 响应严格对齐 schema 类型 (Connection/Payload), 降级时返回空数据.
*/
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js";
/**
* 空分页响应 (降级模式).
*/
function emptyConnection<T>(): {
edges: T[];
pageInfo: {
hasNextPage: false;
hasPreviousPage: false;
startCursor: null;
endCursor: null;
};
totalCount: 0;
} {
return {
edges: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
totalCount: 0,
};
}
/**
* 降级 Payload (无核心数据).
*/
function degradedPayload<T extends object>(
emptyData: T,
reason: string,
degradedFields: string[],
): T & {
degraded: true;
degradedReason: string;
degradedFields: string[];
} {
return {
...emptyData,
degraded: true,
degradedReason: reason,
degradedFields,
};
}
export const extendedQueriesResolvers = {
Query: {
/**
* myAttendance: 我的考勤记录 (core-edu AttendanceService.ListAttendanceByStudent).
* B4 强制 userId = studentId (DataScope SELF).
*/
async myAttendance(
_parent: unknown,
args: {
after?: string;
first?: number;
before?: string;
last?: number;
startDate?: string;
endDate?: string;
},
ctx: StudentBffContext,
): Promise<{
edges: unknown[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
totalCount: number;
}> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"ListAttendanceByStudent",
{
studentId: ctx.userId,
startDate: args.startDate,
endDate: args.endDate,
first: args.first ?? 20,
after: args.after,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as {
edges?: Array<{ node?: unknown; cursor?: string }>;
pageInfo?: {
hasNextPage?: boolean;
hasPreviousPage?: boolean;
startCursor?: string | null;
endCursor?: string | null;
};
totalCount?: number;
};
return {
edges: result?.edges ?? [],
pageInfo: {
hasNextPage: result?.pageInfo?.hasNextPage ?? false,
hasPreviousPage: result?.pageInfo?.hasPreviousPage ?? false,
startCursor: result?.pageInfo?.startCursor ?? null,
endCursor: result?.pageInfo?.endCursor ?? null,
},
totalCount: result?.totalCount ?? 0,
};
} catch {
return emptyConnection<unknown>();
}
},
/** examDetail: 考试详情 (含题目). */
async examDetail(
_parent: unknown,
args: { examId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"GetExam",
{ examId: args.examId, studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as {
exam?: unknown;
questions?: unknown[];
mySubmission?: unknown;
};
return {
exam: result?.exam ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ exam: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["exam"],
);
}
},
/** homeworkDetail: 作业详情. */
async homeworkDetail(
_parent: unknown,
args: { homeworkId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"GetHomework",
{ homeworkId: args.homeworkId, studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { homework?: unknown };
return {
homework: result?.homework ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ homework: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["homework"],
);
}
},
/** serverTime: 服务器时间 (BFF 本地, 无下游). */
async serverTime(
_parent: unknown,
_args: unknown,
_ctx: StudentBffContext,
): Promise<unknown> {
const now = new Date();
return {
serverTime: now.toISOString(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
timestamp: now.getTime(),
};
},
/** mySchedule: 我的课表 (周视图). */
async mySchedule(
_parent: unknown,
args: { weekStart?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"GetScheduleByStudent",
{ studentId: ctx.userId, weekStart: args.weekStart },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { items?: unknown[]; weekStart?: string };
return {
items: result?.items ?? [],
weekStart: result?.weekStart ?? args.weekStart ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ items: [], weekStart: args.weekStart ?? null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["items"],
);
}
},
/** studentGrowth: 学生成长曲线. */
async studentGrowth(
_parent: unknown,
args: { subjectId?: string; startDate?: string; endDate?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"GetStudentGrowth",
{
studentId: ctx.userId,
subjectId: args.subjectId,
startDate: args.startDate,
endDate: args.endDate,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as {
points?: unknown[];
averageScore?: number;
classAverage?: number;
growthRate?: number;
};
return {
studentId: ctx.userId,
subjectId: args.subjectId ?? null,
points: result?.points ?? [],
averageScore: result?.averageScore ?? null,
classAverage: result?.classAverage ?? null,
growthRate: result?.growthRate ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{
studentId: ctx.userId,
subjectId: args.subjectId ?? null,
points: [],
averageScore: null,
classAverage: null,
growthRate: null,
},
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["points", "averageScore", "classAverage", "growthRate"],
);
}
},
/** assignmentAnalysis: 作业分析. */
async assignmentAnalysis(
_parent: unknown,
args: { homeworkId?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"GetAssignmentAnalysis",
{
studentId: ctx.userId,
homeworkId: args.homeworkId,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { analysis?: unknown };
return {
analysis: result?.analysis ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ analysis: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["analysis"],
);
}
},
/** myProfile: 个人资料. */
async myProfile(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"iam",
"GetUserProfile",
{ userId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { profile?: unknown };
return {
profile: result?.profile ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ profile: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["profile"],
);
}
},
/** myMasterySummary: 掌握度概览. */
async myMasterySummary(
_parent: unknown,
args: { subjectId?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"GetMasterySummary",
{ studentId: ctx.userId, subjectId: args.subjectId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { summary?: unknown };
return {
summary: result?.summary ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ summary: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["summary"],
);
}
},
/** myDiagnosticReports: 诊断报告列表. */
async myDiagnosticReports(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"ListDiagnosticReports",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { reports?: unknown[]; totalCount?: number };
return {
reports: result?.reports ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ reports: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["reports"],
);
}
},
/** myErrorBook: 错题本. */
async myErrorBook(
_parent: unknown,
args: {
subjectId?: string;
mastered?: boolean;
page?: number;
pageSize?: number;
},
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"ListErrorBookItems",
{
studentId: ctx.userId,
subjectId: args.subjectId,
mastered: args.mastered,
page: args.page ?? 1,
pageSize: args.pageSize ?? 20,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as {
items?: unknown[];
totalCount?: number;
subjectStats?: unknown[];
};
return {
items: result?.items ?? [],
totalCount: result?.totalCount ?? 0,
subjectStats: result?.subjectStats ?? [],
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ items: [], totalCount: 0, subjectStats: [] },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["items", "subjectStats"],
);
}
},
/** announcements: 公告列表. */
async announcements(
_parent: unknown,
args: { first?: number; category?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"msg",
"ListAnnouncements",
{
userId: ctx.userId,
first: args.first ?? 20,
category: args.category,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { edges?: unknown[]; totalCount?: number };
return {
edges: result?.edges ?? [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
totalCount: result?.totalCount ?? 0,
};
} catch {
return emptyConnection();
}
},
/** announcementDetail: 公告详情. */
async announcementDetail(
_parent: unknown,
args: { announcementId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"msg",
"GetAnnouncement",
{
announcementId: args.announcementId,
userId: ctx.userId,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { announcement?: unknown };
return {
announcement: result?.announcement ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ announcement: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["announcement"],
);
}
},
/** myLeaveRequests: 我的请假记录. */
async myLeaveRequests(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"ListLeaveRequestsByStudent",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as {
requests?: unknown[];
totalCount?: number;
pendingCount?: number;
};
return {
requests: result?.requests ?? [],
totalCount: result?.totalCount ?? 0,
pendingCount: result?.pendingCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ requests: [], totalCount: 0, pendingCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["requests"],
);
}
},
/** myElectiveSelections: 我的选课. */
async myElectiveSelections(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"ListElectiveSelectionsByStudent",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { selections?: unknown[]; totalCount?: number };
return {
selections: result?.selections ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ selections: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["selections"],
);
}
},
/** availableElectiveCourses: 可选课程. */
async availableElectiveCourses(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"ListAvailableElectiveCourses",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { courses?: unknown[]; totalCount?: number };
return {
courses: result?.courses ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ courses: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["courses"],
);
}
},
/** myLessonPlans: 我的课案. */
async myLessonPlans(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"ListLessonPlansByStudent",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { plans?: unknown[]; totalCount?: number };
return {
plans: result?.plans ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ plans: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["plans"],
);
}
},
/** lessonPlanDetail: 课案详情. */
async lessonPlanDetail(
_parent: unknown,
args: { lessonPlanId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"GetLessonPlan",
{ lessonPlanId: args.lessonPlanId, studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { plan?: unknown };
return {
plan: result?.plan ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ plan: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["plan"],
);
}
},
/** myCoursePlans: 我的课程计划. */
async myCoursePlans(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"ListCoursePlansByStudent",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { plans?: unknown[]; totalCount?: number };
return {
plans: result?.plans ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ plans: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["plans"],
);
}
},
/** coursePlanDetail: 课程计划详情. */
async coursePlanDetail(
_parent: unknown,
args: { coursePlanId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"GetCoursePlan",
{ coursePlanId: args.coursePlanId, studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { plan?: unknown };
return {
plan: result?.plan ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ plan: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["plan"],
);
}
},
/** myReportCard: 成绩报告卡. */
async myReportCard(
_parent: unknown,
args: { examId?: string; term?: string; academicYear?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"GetReportCard",
{
studentId: ctx.userId,
examId: args.examId,
term: args.term,
academicYear: args.academicYear,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { reportCard?: unknown };
return {
reportCard: result?.reportCard ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ reportCard: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["reportCard"],
);
}
},
/** myPracticeSessions: 我的练习会话. */
async myPracticeSessions(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"ListPracticeSessionsByStudent",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { sessions?: unknown[]; totalCount?: number };
return {
sessions: result?.sessions ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ sessions: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["sessions"],
);
}
},
},
};

View File

@@ -4,8 +4,8 @@
* 将所有 Resolver 合并为一个 GraphQL Resolver 映射表,
* 供 GraphQL Yoga makeExecutableSchema 使用.
*
* Resolver 清单 (按 schema 第一版):
* Query:
* Resolver 清单 (v2 完整版):
* Query (35):
* - currentUser (auth)
* - studentDashboard (dashboard)
* - myHomework (homework)
@@ -16,10 +16,25 @@
* - myWeakness / myTrend (analytics, P4)
* - myNotifications / myNotificationUnreadCount (notifications, P5)
* - aiChat (ai, P5)
* Mutation:
* - myAttendance (attendance, P3)
* - examDetail / homeworkDetail / serverTime / mySchedule / studentGrowth
* - assignmentAnalysis / myProfile / myMasterySummary / myDiagnosticReports
* - myErrorBook / announcements / announcementDetail / myLeaveRequests
* - myElectiveSelections / availableElectiveCourses / myLessonPlans
* - lessonPlanDetail / myCoursePlans / coursePlanDetail / myReportCard
* - myPracticeSessions
* Mutation (23):
* - submitHomework (homework)
* - markNotificationAsRead (notifications, P5)
* Subscription:
* - markAsRead / markAllAsRead / updateNotificationPreference
* - submitExam / saveExamDraft / recordExamViolation / recordPasteEvent
* - updateProfile / changePassword / requestExtension
* - joinClass / leaveClass
* - addErrorBookItem / updateErrorBookItem / deleteErrorBookItem
* - markAnnouncementRead / createLeaveRequest / cancelLeaveRequest
* - selectElectiveCourse / dropElectiveCourse
* - startPracticeSession / submitPracticeAnswer
* Subscription (1):
* - aiStreamChat (ai-stream, P5 SSE)
*/
import { authResolvers } from "./auth.resolver.js";
@@ -33,6 +48,8 @@ import { analyticsResolvers } from "./analytics.resolver.js";
import { notificationsResolvers } from "./notifications.resolver.js";
import { aiResolvers } from "./ai.resolver.js";
import { aiStreamResolvers } from "./ai-stream.resolver.js";
import { extendedQueriesResolvers } from "./extended-queries.resolver.js";
import { extendedMutationsResolvers } from "./extended-mutations.resolver.js";
/**
* 全部 Resolver 手动合并 (spread 运算符).
@@ -52,10 +69,12 @@ export const studentBffResolvers = {
...(analyticsResolvers.Query ?? {}),
...(notificationsResolvers.Query ?? {}),
...(aiResolvers.Query ?? {}),
...(extendedQueriesResolvers.Query ?? {}),
},
Mutation: {
...(homeworkResolvers.Mutation ?? {}),
...(notificationsResolvers.Mutation ?? {}),
...(extendedMutationsResolvers.Mutation ?? {}),
},
Subscription: {
...(aiStreamResolvers.Subscription ?? {}),