feat(parent-bff): graphql schema 扩展 + extended-resolvers + grpc factory + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 16:02:49 +08:00
parent 422b55f901
commit 5a88c8b45d
15 changed files with 2527 additions and 53 deletions

View File

@@ -11,6 +11,7 @@ import type {
} from "../clients/dtos.js";
import type {
ChildAnalyticsType,
ChildBriefType,
ChildType,
ClassInfoType,
ExamType,
@@ -41,7 +42,23 @@ export function mapParent(dto: UserInfoDto): ParentType {
name: dto.name,
avatar: null,
roles: dto.roles,
permissions: dto.permissions,
dataScope: "CHILDREN",
schoolId: null,
};
}
/**
* ChildDto → ChildBriefType扁平结构用于 myChildren / childSummary 等扩展查询)。
*/
export function mapChildBrief(dto: ChildDto): ChildBriefType {
return {
id: dto.id,
name: dto.name,
grade: dto.grade,
classId: dto.classId,
className: dto.className,
avatar: null,
};
}

View File

@@ -1,5 +1,4 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { resolve } from "node:path";
import {
credentials,
loadPackageDefinition,
@@ -8,12 +7,14 @@ import {
import protoLoader from "@grpc/proto-loader";
import { logger } from "../../shared/observability/logger.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROTO_ROOT = resolve(
__dirname,
"../../../../packages/shared-proto/proto",
);
/**
* 使用 process.cwd() 解析 proto 路径兼容开发模式cwd=services/parent-bff
* 和生产模式cwd=/app/services/parent-bff
*
* 开发模式services/parent-bff → ../../packages/shared-proto/proto
* 生产模式:/app/services/parent-bff → ../../packages/shared-proto/proto → /app/packages/shared-proto/proto
*/
const PROTO_ROOT = resolve(process.cwd(), "../../packages/shared-proto/proto");
const LOADER_OPTIONS: protoLoader.Options = {
keepCase: false,

View File

@@ -10,7 +10,7 @@ import type { YogaInstance } from "../graphql/yoga.js";
*
* graphql-yoga v5Yoga 实例本身是 callable handler直接调用 yoga(req, res)。
*/
@Controller("graphql")
@Controller("v1/graphql")
export class GraphqlController {
private readonly yoga: YogaInstance;

View File

@@ -63,6 +63,6 @@ import { buildResolvers, type ResolverDeps } from "./resolvers/index.js";
})
export class GraphqlModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(contextMiddleware).forRoutes("graphql");
consumer.apply(contextMiddleware).forRoutes("v1/graphql");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,42 @@ import {
buildNotificationPreferencesQueryResolver,
buildUpdateNotificationPreferencesMutationResolver,
} from "./notification-preference.resolver.js";
import {
buildAcademicYearsQueryResolver,
buildChildAttendanceQueryResolver,
buildChildClassesQueryResolver,
buildChildCoursePlanDetailQueryResolver,
buildChildCoursePlansQueryResolver,
buildChildDetailQueryResolver,
buildChildDiagnosticReportsQueryResolver,
buildChildElectiveQueryResolver,
buildChildErrorBookStatsQueryResolver,
buildChildExamResultQueryResolver,
buildChildGrowthArchiveQueryResolver,
buildChildLeaveRequestsQueryResolver,
buildChildLearningPathQueryResolver,
buildChildLessonPlanDetailQueryResolver,
buildChildLessonPlansQueryResolver,
buildChildMasterySummaryQueryResolver,
buildChildPracticeSessionsQueryResolver,
buildChildPracticeStatsQueryResolver,
buildChildReportCardQueryResolver,
buildChildSummaryQueryResolver,
buildChildTopWrongQuestionsQueryResolver,
buildChildTrendQueryResolver,
buildChildWeakKpsQueryResolver,
buildChildWeaknessQueryResolver,
buildCreateLeaveRequestMutationResolver,
buildCurrentUserQueryResolver,
buildExportChildGradesMutationResolver,
buildMarkAllAsReadMutationResolver,
buildMarkAsReadMutationResolver,
buildMyChildrenQueryResolver,
buildMyNotificationPreferencesQueryResolver,
buildMyNotificationsQueryResolver,
buildSwitchChildMutationResolver,
buildUpdateMyNotificationPreferencesMutationResolver,
} from "./extended-resolvers.js";
/**
* Resolver 依赖注入容器。
@@ -96,6 +132,47 @@ const DateTimeScalar = new GraphQLScalarType({
},
});
/**
* JSON 标量:任意 JSON 值(用于 NotificationPreferences.preferences 等动态结构)。
*/
const JSONScalar = new GraphQLScalarType({
name: "JSON",
description: "Arbitrary JSON value",
serialize(value: unknown): unknown {
return value;
},
parseValue(value: unknown): unknown {
return value;
},
parseLiteral(ast): unknown {
switch (ast.kind) {
case Kind.STRING:
case Kind.BOOLEAN:
return ast.value;
case Kind.INT:
return Number(ast.value);
case Kind.FLOAT:
return Number(ast.value);
case Kind.LIST:
return ast.values.map((v) => JSONScalar.parseLiteral(v));
case Kind.OBJECT: {
const obj: Record<string, unknown> = {};
for (const field of ast.fields) {
obj[field.name.value] = JSONScalar.parseLiteral(field.value);
}
return obj;
}
case Kind.NULL:
return null;
default:
return null;
}
},
});
/**
* 构建 GraphQL resolvers对齐 02-architecture-design.md §4.2 SDL
*
@@ -111,8 +188,10 @@ const DateTimeScalar = new GraphQLScalarType({
export function buildResolvers(deps: ResolverDeps): IResolvers {
return {
DateTime: DateTimeScalar,
JSON: JSONScalar,
Query: {
// Legacy11 个)
dashboard: buildDashboardResolver(deps),
viewports: buildViewportsQueryResolver(deps),
me: buildMeQueryResolver(deps),
@@ -122,19 +201,56 @@ export function buildResolvers(deps: ResolverDeps): IResolvers {
childHomework: buildChildHomeworkQueryResolver(deps),
childExams: buildChildExamsQueryResolver(deps),
childAnalytics: buildChildAnalyticsQueryResolver(deps),
// P5 通知 resolvers
notifications: buildNotificationsQueryResolver(deps),
notificationPreferences: buildNotificationPreferencesQueryResolver(deps),
// Extended21 个)
currentUser: buildCurrentUserQueryResolver(deps),
myChildren: buildMyChildrenQueryResolver(deps),
childSummary: buildChildSummaryQueryResolver(deps),
childDetail: buildChildDetailQueryResolver(deps),
childAttendance: buildChildAttendanceQueryResolver(deps),
childExamResult: buildChildExamResultQueryResolver(deps),
childClasses: buildChildClassesQueryResolver(deps),
childReportCard: buildChildReportCardQueryResolver(deps),
childGrowthArchive: buildChildGrowthArchiveQueryResolver(deps),
childWeakness: buildChildWeaknessQueryResolver(deps),
childTrend: buildChildTrendQueryResolver(deps),
childLearningPath: buildChildLearningPathQueryResolver(deps),
childErrorBookStats: buildChildErrorBookStatsQueryResolver(deps),
childTopWrongQuestions: buildChildTopWrongQuestionsQueryResolver(deps),
childWeakKps: buildChildWeakKpsQueryResolver(deps),
childMasterySummary: buildChildMasterySummaryQueryResolver(deps),
childDiagnosticReports: buildChildDiagnosticReportsQueryResolver(deps),
childPracticeStats: buildChildPracticeStatsQueryResolver(deps),
childPracticeSessions: buildChildPracticeSessionsQueryResolver(deps),
childCoursePlans: buildChildCoursePlansQueryResolver(deps),
childCoursePlanDetail: buildChildCoursePlanDetailQueryResolver(deps),
childLessonPlans: buildChildLessonPlansQueryResolver(deps),
childLessonPlanDetail: buildChildLessonPlanDetailQueryResolver(deps),
childElective: buildChildElectiveQueryResolver(deps),
childLeaveRequests: buildChildLeaveRequestsQueryResolver(deps),
academicYears: buildAcademicYearsQueryResolver(deps),
myNotifications: buildMyNotificationsQueryResolver(deps),
myNotificationPreferences:
buildMyNotificationPreferencesQueryResolver(deps),
},
Mutation: {
// Legacy3 个)
selectChild: buildSelectChildMutationResolver(deps),
// P5 通知 mutations
markNotificationRead: buildMarkNotificationReadMutationResolver(deps),
updateNotificationPreferences:
buildUpdateNotificationPreferencesMutationResolver(deps),
// Extended6 个)
markAsRead: buildMarkAsReadMutationResolver(deps),
markAllAsRead: buildMarkAllAsReadMutationResolver(deps),
switchChild: buildSwitchChildMutationResolver(deps),
updateMyNotificationPreferences:
buildUpdateMyNotificationPreferencesMutationResolver(deps),
createLeaveRequest: buildCreateLeaveRequestMutationResolver(deps),
exportChildGrades: buildExportChildGradesMutationResolver(deps),
},
Child: {

View File

@@ -26,13 +26,33 @@ const CHANNEL_MAP: Record<string, NotificationChannel> = {
/**
* DTO → GraphQL Type 映射。
*
* 旧版 schema 使用 channels/eventTypes 顶层字段,
* 新版 schema 使用 preferences/defaults 嵌套结构。
* 此处同时填充两组字段以兼容 legacy NotificationPreferences 和新版 MyNotificationPreferences。
*/
function mapPreferences(
dto: NotificationPreferencesDto,
parentId: string,
): NotificationPreferencesType {
const channels = dto.channels.map((c) => CHANNEL_MAP[c] ?? "APP");
const eventTypes = { ...dto.eventTypes };
return {
channels: dto.channels.map((c) => CHANNEL_MAP[c] ?? "APP"),
eventTypes: { ...dto.eventTypes },
parentId,
preferences: { channels, eventTypes },
defaults: {
channels: ["APP" as NotificationChannel],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
},
updatedAt: null,
channels,
eventTypes,
};
}
@@ -63,7 +83,29 @@ export function buildNotificationPreferencesQueryResolver(deps: {
if (!dto) {
// 下游失败且无缓存,返回默认偏好
return {
channels: ["APP"],
parentId,
preferences: {
channels: ["APP" as NotificationChannel],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
},
defaults: {
channels: ["APP" as NotificationChannel],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
},
updatedAt: null,
channels: ["APP" as NotificationChannel],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
@@ -73,7 +115,7 @@ export function buildNotificationPreferencesQueryResolver(deps: {
},
};
}
return mapPreferences(dto);
return mapPreferences(dto, parentId);
};
}
@@ -139,6 +181,6 @@ export function buildUpdateNotificationPreferencesMutationResolver(deps: {
"Notification preferences updated",
);
return mapPreferences(updated);
return mapPreferences(updated, parentId);
};
}

View File

@@ -1,6 +1,6 @@
import type { MsgClient } from "../../clients/msg.client.js";
import type { NotificationDto } from "../../clients/dtos.js";
import type { NotificationItem, NotificationType } from "../types.js";
import type { NotificationItem } from "../types.js";
import type { GraphqlContext } from "../context.js";
import { CacheKeys } from "../../shared/cache/cache-key.builder.js";
import {
@@ -10,29 +10,26 @@ import {
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
const NOTIFICATION_TYPE_MAP: Record<string, NotificationType> = {
SYSTEM: "SYSTEM",
EXAM: "EXAM",
HOMEWORK: "HOMEWORK",
GRADE: "GRADE",
ATTENDANCE: "ATTENDANCE",
ANNOUNCEMENT: "ANNOUNCEMENT",
};
/**
* DTO → GraphQL Type 映射。
*
* createdAt: proto int64epoch ms→ ISO 8601 字符串DateTime scalar
* 旧版 schema 使用 type/content新版 schema 使用 eventType/body
* 此处同时填充两组字段以兼容 legacy Notification type 和新版 MyNotification。
*/
function mapNotification(dto: NotificationDto): NotificationItem {
return {
id: dto.id,
type: NOTIFICATION_TYPE_MAP[dto.type] ?? "SYSTEM",
type: dto.type || "SYSTEM",
eventType: dto.type || "SYSTEM",
title: dto.title,
content: dto.content,
body: dto.content,
read: dto.isRead,
childId: dto.childId ?? null,
createdAt: new Date(dto.createdAt).toISOString(),
actionUrl: null,
pinned: false,
};
}
@@ -113,11 +110,15 @@ export function buildMarkNotificationReadMutationResolver(deps: {
return {
id: args.notificationId,
type: "SYSTEM",
eventType: "SYSTEM",
title: "",
content: "",
body: "",
read: true,
childId: null,
createdAt: new Date().toISOString(),
actionUrl: null,
pinned: false,
};
}
return mapNotification(updated);

View File

@@ -1,6 +1,5 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { resolve } from "node:path";
import { makeExecutableSchema } from "@graphql-tools/schema";
import type { IResolvers } from "@graphql-tools/utils";
@@ -9,11 +8,13 @@ import type { IResolvers } from "@graphql-tools/utils";
*
* 文件位置packages/shared-ts/contracts/graphql/parent-bff.graphql
* 该文件是 parent-bff GraphQL 端点的契约唯一源,前后端共享。
*
* 使用 process.cwd() 解析路径兼容开发模式cwd=services/parent-bff
* 和生产模式cwd=/app/services/parent-bff
*/
const __dirname = dirname(fileURLToPath(import.meta.url));
const SDL_PATH = resolve(
__dirname,
"../../../../packages/shared-ts/contracts/graphql/parent-bff.graphql",
process.cwd(),
"../../packages/shared-ts/contracts/graphql/parent-bff.graphql",
);
const typeDefs = readFileSync(SDL_PATH, "utf-8");

View File

@@ -8,13 +8,17 @@
export type DataScope =
"SELF" | "CHILDREN" | "CLASS" | "GRADE" | "SCHOOL" | "DISTRICT" | "ALL";
// ============ Parent / Viewport ============
export interface ParentType {
id: string;
email: string;
name: string;
avatar?: string | null;
roles: string[];
permissions?: string[];
dataScope: DataScope;
schoolId?: string | null;
}
export interface ViewportItemType {
@@ -26,6 +30,8 @@ export interface ViewportItemType {
requiredPermission?: string | null;
}
// ============ Legacy Child / Grade / Homework / Exam ============
export interface ClassInfoType {
id: string;
name: string;
@@ -98,6 +104,7 @@ export interface ChildAnalyticsType {
classAverage?: number | null;
}
/** 旧版 Child 类型(含嵌套 class 对象,用于 legacy Child field resolver */
export interface ChildType {
id: string;
name: string;
@@ -110,17 +117,373 @@ export interface ChildType {
analytics?: ChildAnalyticsType;
}
export type NotificationType =
"SYSTEM" | "EXAM" | "HOMEWORK" | "GRADE" | "ATTENDANCE" | "ANNOUNCEMENT";
// ============ Extended: Child Brief / Summary / Detail ============
export interface ChildBriefType {
id: string;
name: string;
grade: string;
classId: string | null;
className: string | null;
avatar?: string | null;
}
export interface ChildSummaryType {
childId: string;
avgScore: number | null;
classRank: number | null;
classSize: number | null;
attendanceRate: number | null;
pendingHomeworkCount: number;
recentGradeTrend: number | null;
recentScores: ChildGradeType[];
upcomingEvents: Array<{
id: string;
type: string;
title: string;
dueDate: string;
}>;
}
export interface ChildDetailType {
childId: string;
basicInfo: {
name: string;
avatar: string | null;
grade: string;
className: string;
schoolName: string;
relation: string;
};
todaySchedule: unknown[];
weeklySchedule: unknown[];
homeworkSummary: {
pendingCount: number;
overdueCount: number;
submittedCount: number;
gradedCount: number;
};
gradeSummary: {
avgScore: number;
classRank: number | null;
classSize: number | null;
trend: number | null;
};
examResults: {
upcoming: number;
completed: number;
avgScore: number | null;
};
}
// ============ Extended: Child Grade / Homework / Exam ============
export interface ChildGradeType {
examId: string;
examName: string;
examDate: string;
subject: string;
studentScore: number;
classAverage: number | null;
classMax: number | null;
classMin: number | null;
gradeLevel: string | null;
}
export interface ChildHomeworkType {
id: string;
title: string;
subject: string;
className: string;
assignedDate: string;
dueDate: string;
status: string;
score: number | null;
maxScore: number | null;
feedback: string | null;
}
export interface ChildExamType {
id: string;
name: string;
subject: string;
status: string;
startsAt: string;
expiresAt: string | null;
durationSeconds: number | null;
questionCount: number | null;
totalScore: number | null;
submittedAt: string | null;
}
export interface AttendanceRecordType {
id: string;
date: string;
status: string;
checkInTime: string | null;
checkOutTime: string | null;
note: string | null;
}
export interface ExamResultType {
examId: string;
childId: string;
score: number | null;
rank: number | null;
subjectScores: Array<{
subject: string;
score: number;
fullScore: number;
}>;
feedback: string | null;
}
export interface ChildClassType {
id: string;
name: string;
homeroomTeacher: string | null;
studentCount: number | null;
grade: string;
year: string | null;
}
export interface ReportCardType {
childId: string;
academicYearId: string;
semester: number;
subjects: Array<{
subject: string;
score: number;
grade: string;
teacherComment: string | null;
}>;
overallComment: string | null;
classRank: number | null;
}
export interface GrowthArchiveType {
childId: string;
subject: string | null;
dataPoints: Array<{
date: string;
category: string;
title: string;
description: string;
evidence: string | null;
}>;
}
// ============ Extended: Analytics ============
export interface WeaknessItemType {
id: string;
knowledgePoint: string;
masteryLevel: number;
subject: string;
recommendation: string | null;
}
export type TrendPeriod = "WEEK" | "MONTH" | "SEMESTER" | "YEAR";
export interface ChildTrendType {
childId: string;
period: TrendPeriod;
dataPoints: Array<{
date: string;
score: number;
subject: string | null;
}>;
}
export interface LearningPathItemType {
id: string;
title: string;
subject: string;
order: number;
masteryLevel: number;
resources: string[];
}
export interface ErrorBookStatsType {
childId: string;
totalCount: number;
newCount: number;
learningCount: number;
masteredCount: number;
dueReviewCount: number;
masteredRate: number;
}
export interface WrongQuestionType {
id: string;
questionId: string;
subject: string;
content: string;
wrongAnswer: string;
correctAnswer: string;
addedAt: string;
status: string;
}
export interface WeakKpType {
id: string;
knowledgePoint: string;
subject: string;
masteryLevel: number;
recommendation: string | null;
}
export interface MasterySummaryType {
childId: string;
overallMastery: number;
subjectMastery: Array<{
subject: string;
mastery: number;
totalKps: number;
masteredKps: number;
}>;
totalKps: number;
masteredKps: number;
}
export interface DiagnosticReportType {
id: string;
childId: string;
subject: string;
reportDate: string;
summary: string;
recommendations: string[];
}
export interface PracticeStatsType {
childId: string;
totalSessions: number;
completedSessions: number;
totalQuestionsAnswered: number;
overallAccuracy: number;
}
export interface PracticeSessionType {
id: string;
childId: string;
subject: string;
startedAt: string;
completedAt: string | null;
questionCount: number;
correctCount: number;
accuracy: number;
}
// ============ Extended: Course / Lesson / Elective ============
export interface CoursePlanType {
id: string;
childId: string;
subject: string;
title: string;
startDate: string;
endDate: string;
progress: number;
}
export interface CoursePlanDetailType {
id: string;
childId: string;
subject: string;
title: string;
startDate: string;
endDate: string;
progress: number;
lessons: Array<{
id: string;
title: string;
date: string;
completed: boolean;
}>;
}
export interface LessonPlanType {
id: string;
childId: string;
subject: string;
title: string;
date: string;
teacherName: string;
}
export interface LessonPlanDetailType {
id: string;
childId: string;
subject: string;
title: string;
date: string;
teacherName: string;
objectives: string[];
content: string;
homework: string | null;
}
export interface ElectiveCourseType {
id: string;
childId: string;
name: string;
subject: string;
teacher: string;
schedule: string;
selected: boolean;
}
// ============ Extended: Leave Request ============
export interface LeaveRequestInput {
childId: string;
type: string;
startDate: string;
endDate: string;
reason: string;
}
export interface LeaveRequestItem {
id: string;
childId: string;
childName: string | null;
className: string | null;
type: string;
startDate: string;
endDate: string;
reason: string;
status: string;
submittedAt: string;
reviewedAt: string | null;
reviewerName: string | null;
reviewComment: string | null;
}
// ============ Extended: Academic Year ============
export interface AcademicYearType {
id: string;
name: string;
startDate: string;
endDate: string;
isCurrent: boolean;
}
// ============ Extended: Notification ============
export interface NotificationItem {
id: string;
type: NotificationType;
childId: string | null;
eventType: string;
title: string;
content: string;
body: string;
read: boolean;
childId?: string | null;
createdAt: string;
actionUrl: string | null;
pinned: boolean;
/** 旧版字段legacy schema 用) */
type?: string;
content?: string;
}
export type NotificationChannel = "APP" | "SMS" | "EMAIL" | "WECHAT";
@@ -134,10 +497,50 @@ export interface NotificationEventTypes {
}
export interface NotificationPreferencesType {
channels: NotificationChannel[];
eventTypes: NotificationEventTypes;
parentId: string;
preferences: {
channels?: NotificationChannel[];
eventTypes?: Partial<NotificationEventTypes>;
};
defaults: {
channels: NotificationChannel[];
eventTypes: NotificationEventTypes;
};
updatedAt: string | null;
/** 旧版字段legacy schema 用) */
channels?: NotificationChannel[];
eventTypes?: NotificationEventTypes;
}
export interface UpdateNotificationPreferencesResultType {
parentId: string;
updatedAt: string;
}
export interface MarkAsReadResultType {
id: string;
read: boolean;
}
export interface MarkAllAsReadResultType {
count: number;
}
// ============ Extended: Mutation Results ============
export interface SwitchChildResultType {
childId: string;
childName: string;
selectedAt: string;
}
export interface ExportChildGradesResultType {
downloadUrl: string;
expiresAt: string;
}
// ============ Legacy Types保留旧版兼容 ============
export interface DashboardDataType {
parent: ParentType | null;
children: ChildType[];

View File

@@ -74,7 +74,7 @@ export function createYogaInstance(
return createYoga({
schema,
graphqlEndpoint: "/graphql",
graphqlEndpoint: "/v1/graphql",
graphiql:
env.NODE_ENV === "development" && env.GRAPHQL_INTROSPECTION_ENABLED,

View File

@@ -1,4 +1,4 @@
import pino from "pino";
import { pino } from "pino";
import { env } from "../../config/env.js";
/**