feat(teacher-bff): 完整实现 teacher-bff GraphQL 聚合层
包含 clients/graphql/middleware、health probes、shared-ts contracts 等
This commit is contained in:
@@ -2,18 +2,46 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY tsconfig.json nest-cli.json ./
|
||||
COPY src ./src
|
||||
|
||||
# 复制 workspace 配置
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
|
||||
|
||||
# 复制 shared-proto(gRPC proto-loader 运行时加载)
|
||||
COPY packages/shared-proto ./packages/shared-proto
|
||||
|
||||
# 复制 shared-ts(GraphQL schema 文件)
|
||||
COPY packages/shared-ts ./packages/shared-ts
|
||||
|
||||
# 复制 teacher-bff 源码
|
||||
COPY services/teacher-bff ./services/teacher-bff
|
||||
|
||||
# 安装依赖(仅 teacher-bff + shared-ts)
|
||||
RUN cd services/teacher-bff && pnpm install --frozen-lockfile
|
||||
|
||||
# 构建
|
||||
WORKDIR /app/services/teacher-bff
|
||||
RUN pnpm build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# 复制 workspace 配置
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
|
||||
|
||||
# 复制 shared-proto + shared-ts(运行时需要)
|
||||
COPY packages/shared-proto ./packages/shared-proto
|
||||
COPY packages/shared-ts ./packages/shared-ts
|
||||
|
||||
# 复制 teacher-bff
|
||||
COPY services/teacher-bff ./services/teacher-bff
|
||||
|
||||
WORKDIR /app/services/teacher-bff
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/services/teacher-bff/dist ./dist
|
||||
|
||||
EXPOSE 3003
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@@ -189,16 +189,16 @@ graph LR
|
||||
|
||||
**目标**:在不改变对外 REST 契约的前提下,补齐黄金模板差距,为后续 gRPC/GraphQL 切换铺路。
|
||||
|
||||
| 工作项 | 详情 | 优先级 |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| 引入 Client 抽象层 | 新增 `src/clients/` 目录,定义 `IamClient`/`ClassesClient`/`CoreEduClient` 接口,当前实现为 REST fetch adapter,P3 替换为 gRPC adapter | P0 |
|
||||
| 错误码迁移 | `TEACHER_BFF_*` → `BFF_TEACHER_*`(全量替换 [application-error.ts](../src/shared/errors/application-error.ts)) | P0 |
|
||||
| Zod 输入验证 | Controller 层引入 Zod schema 解析 path/body(classId/examId 等) | P0 |
|
||||
| `/readyz` 下游探针 | 并行 ping iam + classes + core-edu 的 `/healthz`,全部 ok 才返回 ok | P0 |
|
||||
| Vitest 测试框架 | 引入 Vitest,覆盖 Service 聚合逻辑 + Controller + 错误处理,目标 ≥ 80% | P1 |
|
||||
| env 重构 | `IamServiceUrl` → `IamServiceTarget`(兼容 REST URL 和 gRPC target),为 P3 切换铺路 | P1 |
|
||||
| DataLoader 骨架 | 新增 `src/dataloader/` 目录,定义 DataLoader 注册器骨架(P2 不启用,P4 接入 GraphQL) | P2 |
|
||||
| Redis 客户端引入 | 新增 `src/shared/cache/redis.client.ts`,P2 仅用于会话/权限缓存,P3 接入聚合缓存 | P2 |
|
||||
| 工作项 | 详情 | 优先级 |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
|
||||
| 引入 Client 抽象层 | 新增 `src/clients/` 目录,定义 `IamClient`/`ClassesClient`/`CoreEduClient` 接口,当前实现为 REST fetch adapter,P3 替换为 gRPC adapter | P0 |
|
||||
| 错误码迁移 | `TEACHER_BFF_*` → `BFF_TEACHER_*`(已完成,见 [application-error.ts](../src/shared/errors/application-error.ts))+ 3 类越权错误码(president §2.7) | P0 |
|
||||
| Zod 输入验证 | Controller 层引入 Zod schema 解析 path/body(classId/examId 等) | P0 |
|
||||
| `/readyz` 下游探针 | 并行 ping iam + classes + core-edu 的 `/healthz`,全部 ok 才返回 ok | P0 |
|
||||
| Vitest 测试框架 | 引入 Vitest,覆盖 Service 聚合逻辑 + Controller + 错误处理,目标 ≥ 80% | P1 |
|
||||
| env 重构 | `IamServiceUrl` → `IamServiceTarget`(兼容 REST URL 和 gRPC target),为 P3 切换铺路 | P1 |
|
||||
| DataLoader 骨架 | 新增 `src/dataloader/` 目录,定义 DataLoader 注册器骨架(P2 不启用,P4 接入 GraphQL) | P2 |
|
||||
| Redis 客户端引入 | 新增 `src/shared/cache/redis.client.ts`,P2 仅用于会话/权限缓存,P3 接入聚合缓存 | P2 |
|
||||
|
||||
**P2 退出标准**:lint + typecheck 零错误;测试覆盖率 ≥ 80%;`/readyz` 真实探针;错误码全部 `BFF_TEACHER_*`;Zod 校验全 Controller 覆盖。
|
||||
|
||||
@@ -228,7 +228,7 @@ graph LR
|
||||
| Schema 设计 | 按教学场景域组织 Query/Mutation(见 §5) |
|
||||
| DataLoader 接入 | Resolver 内调用 DataLoader,覆盖全部 N+1 风险点(见 §6) |
|
||||
| ContentClient gRPC adapter | 调用 content:50054(KnowledgeGraphService + ChapterService/QuestionService 待 ai09 补) |
|
||||
| DataAnaClient gRPC adapter | 调用 data-ana:50055(AnalyticsService + GetTeacherDashboardStats 待 ai11 补) |
|
||||
| DataAnaClient gRPC adapter | 调用 data-ana:50055(AnalyticsService + GetTeacherDashboard 待 ai11 补) |
|
||||
| 双轨读支持 | data-ana 学情查询支持实时查主库 + 聚合查 ClickHouse 宽表 |
|
||||
| REST 兼容期 | REST 端点保留(deprecation header),前端逐步迁移到 GraphQL |
|
||||
| 查询复杂度限制 | GraphQL depth limit + query cost analysis(防恶意查询) |
|
||||
@@ -469,13 +469,13 @@ services/teacher-bff/src/
|
||||
|
||||
### 4.3 P3 REST 扩展
|
||||
|
||||
| method | path | 说明 |
|
||||
| ------ | ------------------------------------ | ----------------------------------------------------------------- |
|
||||
| POST | /teacher/exams | 创建考试(透传 core-edu CreateExam) |
|
||||
| POST | /teacher/homework | 布置作业(透传 core-edu AssignHomework) |
|
||||
| POST | /teacher/grades | 录入成绩(透传 core-edu RecordGrade) |
|
||||
| GET | /teacher/classes/:classId/attendance | 出勤列表(core-edu AttendanceService,待 ai08 补 proto) |
|
||||
| GET | /teacher/dashboard/stats | 仪表盘统计(data-ana GetTeacherDashboardStats,待 ai11 补 proto) |
|
||||
| method | path | 说明 |
|
||||
| ------ | ------------------------------------ | ------------------------------------------------------------ |
|
||||
| POST | /teacher/exams | 创建考试(透传 core-edu CreateExam) |
|
||||
| POST | /teacher/homework | 布置作业(透传 core-edu AssignHomework) |
|
||||
| POST | /teacher/grades | 录入成绩(透传 core-edu RecordGrade) |
|
||||
| GET | /teacher/classes/:classId/attendance | 出勤列表(core-edu AttendanceService,待 ai08 补 proto) |
|
||||
| GET | /teacher/dashboard/stats | 仪表盘统计(data-ana GetTeacherDashboard,待 ai11 补 proto) |
|
||||
|
||||
### 4.4 P4 GraphQL API(目标态)
|
||||
|
||||
@@ -1168,18 +1168,22 @@ BFF 是聚合层,**不发布领域事件**(无业务状态变更)。BFF
|
||||
| /graphql(P4) | 校验 x-user-id + 注入 context | 同上 |
|
||||
| /ai/chat/stream(P5) | 校验 x-user-id | 同上 |
|
||||
|
||||
### 15.2 错误码清单(迁移为 `BFF_TEACHER_*`)
|
||||
### 15.2 错误码清单(`BFF_TEACHER_*` 前缀,B5 + G14 裁决)
|
||||
|
||||
| 错误码 | HTTP | 触发条件 | 当前状态 |
|
||||
| ------------------------------- | ---- | ------------------------ | --------------------------------------------- |
|
||||
| `BFF_TEACHER_VALIDATION_ERROR` | 400 | Zod 校验失败 | 需迁移(当前 `TEACHER_BFF_VALIDATION_ERROR`) |
|
||||
| `BFF_TEACHER_UNAUTHORIZED` | 401 | 缺 x-user-id | 需迁移 |
|
||||
| `BFF_TEACHER_PERMISSION_DENIED` | 403 | (保留,BFF 一般不触发) | 需迁移 |
|
||||
| `BFF_TEACHER_NOT_FOUND` | 404 | 资源不存在 | 需迁移 |
|
||||
| `BFF_TEACHER_CONFLICT` | 409 | 并发冲突 | 需迁移 |
|
||||
| `BFF_TEACHER_BUSINESS_ERROR` | 422 | 业务规则违反 | 需迁移 |
|
||||
| `BFF_TEACHER_BAD_GATEWAY` | 502 | 下游不可达 | 需迁移 |
|
||||
| `BFF_TEACHER_INTERNAL_ERROR` | 500 | 未知异常 | 需迁移 |
|
||||
> 越权防御 3 类错误码(president §2.7):UNAUTHORIZED / FORBIDDEN_RESOURCE / IDENTITY_MISMATCH
|
||||
|
||||
| 错误码 | HTTP | 触发条件 | 状态 |
|
||||
| ---------------------------------- | ---- | ------------------------------------------------------ | --------- |
|
||||
| `BFF_TEACHER_VALIDATION_FAILED` | 400 | Zod 校验失败 | ✅ 已实现 |
|
||||
| `BFF_TEACHER_UNAUTHORIZED` | 401 | x-user-id 缺失或无效(president §2.7) | ✅ 已实现 |
|
||||
| `BFF_TEACHER_FORBIDDEN_RESOURCE` | 403 | teacherId 与资源无归属关系(场景 A,§2.7) | ✅ 已实现 |
|
||||
| `BFF_TEACHER_IDENTITY_MISMATCH` | 403 | JWT teacherId 与 body teacherId 不一致(场景 B,§2.7) | ✅ 已实现 |
|
||||
| `BFF_TEACHER_NOT_FOUND` | 404 | 资源不存在 | ✅ 已实现 |
|
||||
| `BFF_TEACHER_CONFLICT` | 409 | 并发冲突 | ✅ 已实现 |
|
||||
| `BFF_TEACHER_BUSINESS_ERROR` | 422 | 业务规则违反(含 P2 未就绪字段) | ✅ 已实现 |
|
||||
| `BFF_TEACHER_UPSTREAM_UNAVAILABLE` | 502 | 下游 gRPC 不可达 | ✅ 已实现 |
|
||||
| `BFF_TEACHER_AGGREGATION_FAILED` | 500 | 聚合多下游部分失败且无降级数据(president §2.6) | ✅ 已实现 |
|
||||
| `BFF_TEACHER_INTERNAL_ERROR` | 500 | 未知异常 | ✅ 已实现 |
|
||||
|
||||
**下游业务错误透传**:iam 抛 `IAM_*`、core-edu 抛 `CORE_EDU_*`、classes 抛 `CLASSES_*`,BFF 不改写错误码,直接透传给前端。
|
||||
|
||||
@@ -1269,7 +1273,7 @@ async readiness() {
|
||||
| 调用 | content | gRPC 50054(P4+) | KnowledgeGraphService.GetPrerequisites / GetLearningPath | 知识图谱 | P4 | ✅ 已有 |
|
||||
| 调用 | content | gRPC 50054(P4+) | _*ChapterService.* / QuestionService._** | 章节/题库 | P4 | ❌ 待 ai09 补 |
|
||||
| 调用 | data-ana | gRPC 50055(P4+) | AnalyticsService.GetClassPerformance / GetStudentWeakness / GetLearningTrend | 学情诊断 | P4 | ✅ 已有 |
|
||||
| 调用 | data-ana | gRPC 50055(P4+) | **GetTeacherDashboardStats**(建议新增) | 仪表盘统计 | P4 | ❌ 待 coord 仲裁 |
|
||||
| 调用 | data-ana | gRPC 50055(P4+) | **GetTeacherDashboard**(建议新增) | 仪表盘统计 | P4 | ❌ 待 coord 仲裁 |
|
||||
| 调用 | ai | gRPC 50057(P5+) | AiService.GenerateQuestion / Chat / OptimizeExpression | AI 辅助 | P5 | ✅ 已有 |
|
||||
| 调用 | ai | gRPC 50057 streaming(P5+) | **AiService.StreamChat** | SSE 流式对话 | P5 | ✅ 已有 |
|
||||
| 调用 | msg | gRPC 50056(P5+) | NotificationService.ListNotifications / SearchNotifications / MarkAsRead | 通知聚合 | P5 | ✅ 已有 |
|
||||
@@ -1463,7 +1467,7 @@ Redis 单实例 256MB 足够,无需集群。
|
||||
| 3 | **iam 补 4 个 RPC** | GetViewports / GetEffectivePermissions / Logout / GetPublicKey | 强烈建议 P2 补全(teacher-bff 直接依赖) | P2 |
|
||||
| 4 | **core-edu 补 AttendanceService** | service 级新增 | 建议 P3 补全 | P3 |
|
||||
| 5 | **core-edu 补 GetClassesByTeacher RPC** | 按教师拉班级 | 建议新增(当前 ListClasses 仅按 gradeId) | P3 |
|
||||
| 6 | **data-ana 补 GetTeacherDashboardStats RPC** | 仪表盘统计聚合 | 建议新增 | P4 |
|
||||
| 6 | **data-ana 补 GetTeacherDashboard RPC** | 仪表盘统计聚合 | 建议新增 | P4 |
|
||||
| 7 | **iam 补 BatchGetUsers RPC** | DataLoader 批量化 | 建议新增(否则 DataLoader 退化为一对一) | P4 |
|
||||
| 8 | **core-edu 补 BatchGetClasses RPC** | DataLoader 批量化 | 建议新增 | P4 |
|
||||
| 9 | **Kafka consumer 引入时机** | A. P5 引入精确失效 / B. 全程短 TTL 兜底 | **P5 评估**(取决于权限变更频率) | P5 |
|
||||
|
||||
@@ -12,15 +12,22 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.12.0",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@graphql-tools/schema": "^10.0.6",
|
||||
"@graphql-tools/utils": "^10.5.6",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"pino": "^9.4.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
|
||||
"@opentelemetry/sdk-node": "^0.53.0",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-yoga": "^5.7.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"pino": "^9.4.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"zod": "^3.23.0"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// 根模块(B1 裁决:P2 起 GraphQL Yoga)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TeacherModule } from "./teacher/teacher.module.js";
|
||||
import { GraphQLModule } from "./graphql/graphql.module.js";
|
||||
import { HealthModule } from "./shared/health/health.module.js";
|
||||
import { MiddlewareModule } from "./middleware/middleware.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [TeacherModule, HealthModule],
|
||||
imports: [GraphQLModule, HealthModule, MiddlewareModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
159
services/teacher-bff/src/clients/base.client.ts
Normal file
159
services/teacher-bff/src/clients/base.client.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// DownstreamClient 抽象基类(B8 裁决:3 个 BFF 统一使用)
|
||||
// 提供统一的错误映射 + 结构化日志 + metrics 记录
|
||||
import type { Logger } from "pino";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
import {
|
||||
BadGatewayError,
|
||||
AggregationFailedError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
import type {
|
||||
CallContext,
|
||||
DownstreamError,
|
||||
DownstreamResult,
|
||||
DownstreamServiceName,
|
||||
GrpcMetadata,
|
||||
} from "./types.js";
|
||||
|
||||
/** gRPC 状态码 → HTTP 语义映射(@grpc/grpc-js status codes) */
|
||||
const GRPC_STATUS = {
|
||||
OK: 0,
|
||||
CANCELLED: 1,
|
||||
UNKNOWN: 2,
|
||||
INVALID_ARGUMENT: 3,
|
||||
DEADLINE_EXCEEDED: 4,
|
||||
NOT_FOUND: 5,
|
||||
ALREADY_EXISTS: 6,
|
||||
PERMISSION_DENIED: 7,
|
||||
RESOURCE_EXHAUSTED: 8,
|
||||
FAILED_PRECONDITION: 9,
|
||||
ABORTED: 10,
|
||||
OUT_OF_RANGE: 11,
|
||||
UNIMPLEMENTED: 12,
|
||||
INTERNAL: 13,
|
||||
UNAVAILABLE: 14,
|
||||
DATA_LOSS: 15,
|
||||
UNAUTHENTICATED: 16,
|
||||
} as const;
|
||||
|
||||
export abstract class BaseDownstreamClient {
|
||||
protected log: Logger;
|
||||
abstract readonly serviceName: DownstreamServiceName;
|
||||
|
||||
constructor() {
|
||||
// abstract property 在子类构造后才可用,延迟初始化 logger
|
||||
this.log = logger.child({ downstream: "downstream" });
|
||||
}
|
||||
|
||||
/** 子类在构造函数中调用以初始化 logger(带正确 serviceName) */
|
||||
protected initLogger(): void {
|
||||
this.log = logger.child({ downstream: this.serviceName });
|
||||
}
|
||||
|
||||
/** 构建 gRPC metadata(注入 x-user-id + x-request-id) */
|
||||
protected buildMetadata(ctx: CallContext): GrpcMetadata {
|
||||
const meta: GrpcMetadata = { "x-user-id": ctx.userId };
|
||||
if (ctx.traceId) {
|
||||
meta["x-request-id"] = ctx.traceId;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
/** 将 gRPC 错误映射为 BadGatewayError(502) */
|
||||
protected mapGrpcError(err: unknown, rpc: string): BadGatewayError {
|
||||
const grpcErr = err as {
|
||||
code?: number;
|
||||
message?: string;
|
||||
details?: string;
|
||||
};
|
||||
const code = grpcErr.code ?? GRPC_STATUS.UNKNOWN;
|
||||
const message = grpcErr.message ?? "Unknown gRPC error";
|
||||
|
||||
this.log.warn(
|
||||
{ rpc, grpcCode: code, message, err },
|
||||
"Downstream gRPC call failed",
|
||||
);
|
||||
|
||||
// UNAVAILABLE → 502 Bad Gateway
|
||||
if (code === GRPC_STATUS.UNAVAILABLE) {
|
||||
return new BadGatewayError(
|
||||
`Downstream ${this.serviceName}.${rpc} unavailable`,
|
||||
{ service: this.serviceName, rpc, grpcCode: code },
|
||||
);
|
||||
}
|
||||
|
||||
// UNAUTHENTICATED → 映射为 BadGateway(BFF 自身做身份校验,下游不应返回 UNAUTHENTICATED)
|
||||
if (code === GRPC_STATUS.UNAUTHENTICATED) {
|
||||
return new BadGatewayError(
|
||||
`Downstream ${this.serviceName}.${rpc} returned UNAUTHENTICATED`,
|
||||
{ service: this.serviceName, rpc, grpcCode: code },
|
||||
);
|
||||
}
|
||||
|
||||
// 其他错误统一映射为 BadGateway
|
||||
return new BadGatewayError(
|
||||
`Downstream ${this.serviceName}.${rpc} failed: ${message}`,
|
||||
{
|
||||
service: this.serviceName,
|
||||
rpc,
|
||||
grpcCode: code,
|
||||
details: grpcErr.details,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 执行 gRPC 调用并统一错误处理 + 超时控制 */
|
||||
protected async callGrpc<T>(
|
||||
rpc: string,
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs = 3000,
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
fn(),
|
||||
this.createTimeout(timeoutMs, rpc),
|
||||
]);
|
||||
const duration = Date.now() - start;
|
||||
this.log.debug(
|
||||
{ rpc, durationMs: duration },
|
||||
"Downstream gRPC call succeeded",
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
throw this.mapGrpcError(err, rpc);
|
||||
}
|
||||
}
|
||||
|
||||
private createTimeout(ms: number, rpc: string): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(
|
||||
new BadGatewayError(
|
||||
`Downstream ${this.serviceName}.${rpc} timed out after ${ms}ms`,
|
||||
{ service: this.serviceName, rpc, timeoutMs: ms },
|
||||
),
|
||||
);
|
||||
}, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/** 降级模式 B:部分失败时返回 success=true + degraded=true + warning(president §2.6) */
|
||||
protected degraded<T>(
|
||||
data: T | null,
|
||||
warning: string,
|
||||
rpc: string,
|
||||
): DownstreamResult<T> {
|
||||
this.log.warn({ rpc, warning }, "Downstream call degraded");
|
||||
return { success: true, data, degraded: true, warning };
|
||||
}
|
||||
|
||||
/** 聚合失败(多下游部分失败且无降级数据,president §2.6) */
|
||||
protected aggregationFailed(
|
||||
errors: DownstreamError[],
|
||||
): AggregationFailedError {
|
||||
return new AggregationFailedError(
|
||||
`Aggregation failed for ${this.serviceName}: ${errors.length} downstream(s) failed`,
|
||||
{ service: this.serviceName, errors },
|
||||
);
|
||||
}
|
||||
}
|
||||
10
services/teacher-bff/src/clients/clients.module.ts
Normal file
10
services/teacher-bff/src/clients/clients.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// DownstreamClient 聚合模块(B8 裁决:3 个 BFF 统一使用)
|
||||
// P2 仅注册 IamModule,P3+ 扩展 CoreEduModule / ContentModule / DataAnaModule / MsgModule / AiModule
|
||||
import { Module } from "@nestjs/common";
|
||||
import { IamModule } from "./iam/iam.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
exports: [IamModule],
|
||||
})
|
||||
export class ClientsModule {}
|
||||
256
services/teacher-bff/src/clients/grpc/grpc.factory.ts
Normal file
256
services/teacher-bff/src/clients/grpc/grpc.factory.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// gRPC Channel + Client 工厂(B2 裁决:首次实现即 gRPC)
|
||||
// 使用 @grpc/grpc-js + @grpc/proto-loader 动态加载 proto(无生成代码依赖)
|
||||
// 上游就绪后可切换为 @bufbuild/protobuf 生成代码(B8 裁决)
|
||||
import * as grpc from "@grpc/grpc-js";
|
||||
import * as protoLoader from "@grpc/proto-loader";
|
||||
import path from "node:path";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import type { DownstreamServiceName } from "../types.js";
|
||||
|
||||
/** proto 文件根目录(monorepo 相对路径) */
|
||||
const PROTO_ROOT = path.resolve(
|
||||
process.cwd(),
|
||||
"../../packages/shared-proto/proto",
|
||||
);
|
||||
|
||||
/** 已加载的 proto 定义缓存(按文件名缓存) */
|
||||
const packageCache = new Map<string, protoLoader.PackageDefinition>();
|
||||
|
||||
/** 已创建的 gRPC Client 缓存(按 service+target 缓存) */
|
||||
const clientCache = new Map<string, grpc.Client>();
|
||||
|
||||
/** 各下游服务对应的 proto 文件 + 包名 + service 名 */
|
||||
interface ServiceProtoConfig {
|
||||
protoFile: string;
|
||||
packageName: string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
const SERVICE_PROTO_MAP: Record<DownstreamServiceName, ServiceProtoConfig> = {
|
||||
iam: {
|
||||
protoFile: "iam.proto",
|
||||
packageName: "next_edu_cloud.iam.v1",
|
||||
serviceName: "IamService",
|
||||
},
|
||||
"core-edu": {
|
||||
protoFile: "core_edu.proto",
|
||||
packageName: "next_edu_cloud.core_edu.v1",
|
||||
serviceName: "", // core-edu 多 service,按需指定
|
||||
},
|
||||
content: {
|
||||
protoFile: "content.proto",
|
||||
packageName: "next_edu_cloud.content.v1",
|
||||
serviceName: "",
|
||||
},
|
||||
"data-ana": {
|
||||
protoFile: "analytics.proto",
|
||||
packageName: "next_edu_cloud.analytics.v1",
|
||||
serviceName: "",
|
||||
},
|
||||
msg: {
|
||||
protoFile: "msg.proto",
|
||||
packageName: "next_edu_cloud.msg.v1",
|
||||
serviceName: "",
|
||||
},
|
||||
ai: {
|
||||
protoFile: "ai.proto",
|
||||
packageName: "next_edu_cloud.ai.v1",
|
||||
serviceName: "",
|
||||
},
|
||||
};
|
||||
|
||||
/** 加载 proto 定义(带缓存) */
|
||||
function loadPackageDefinition(
|
||||
protoFile: string,
|
||||
): protoLoader.PackageDefinition {
|
||||
const cached = packageCache.get(protoFile);
|
||||
if (cached) return cached;
|
||||
|
||||
const fullPath = path.join(PROTO_ROOT, protoFile);
|
||||
const def = protoLoader.loadSync(fullPath, {
|
||||
keepCase: false,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
includeDirs: [PROTO_ROOT],
|
||||
});
|
||||
packageCache.set(protoFile, def);
|
||||
return def;
|
||||
}
|
||||
|
||||
/** 获取下游服务的 gRPC target(从环境变量) */
|
||||
export function getGrpcTarget(service: DownstreamServiceName): string {
|
||||
const map: Record<DownstreamServiceName, string> = {
|
||||
iam: env.IAM_GRPC_TARGET,
|
||||
"core-edu": env.CORE_EDU_GRPC_TARGET,
|
||||
content: env.CONTENT_GRPC_TARGET,
|
||||
"data-ana": env.DATA_ANA_GRPC_TARGET,
|
||||
msg: env.MSG_GRPC_TARGET,
|
||||
ai: env.AI_GRPC_TARGET,
|
||||
};
|
||||
return map[service];
|
||||
}
|
||||
|
||||
/** 判断下游服务是否已配置 target(未配置则走 mock) */
|
||||
export function isServiceConfigured(service: DownstreamServiceName): boolean {
|
||||
const target = getGrpcTarget(service);
|
||||
return target.length > 0;
|
||||
}
|
||||
|
||||
/** 创建或获取 gRPC Client(带缓存) */
|
||||
export function getGrpcClient(
|
||||
service: DownstreamServiceName,
|
||||
serviceName?: string,
|
||||
): grpc.Client {
|
||||
const target = getGrpcTarget(service);
|
||||
const config = SERVICE_PROTO_MAP[service];
|
||||
const svcName = serviceName ?? config.serviceName;
|
||||
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`gRPC target not configured for service: ${service} (set ${service.toUpperCase().replace("-", "_")}_GRPC_TARGET)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!svcName) {
|
||||
throw new Error(
|
||||
`Service name not specified for ${service}, pass serviceName explicitly`,
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `${service}:${svcName}:${target}`;
|
||||
const cached = clientCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const def = loadPackageDefinition(config.protoFile);
|
||||
const proto = grpc.loadPackageDefinition(def) as unknown as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
|
||||
// 按包路径查找 Service 构造器
|
||||
const pkgParts = config.packageName.split(".");
|
||||
let pkg: Record<string, unknown> = proto;
|
||||
for (const part of pkgParts) {
|
||||
pkg = pkg[part] as Record<string, unknown>;
|
||||
if (!pkg) {
|
||||
throw new Error(
|
||||
`Package ${config.packageName} not found in ${config.protoFile}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ServiceConstructor = pkg[svcName] as unknown as {
|
||||
new (target: string, credentials: grpc.ChannelCredentials): grpc.Client;
|
||||
};
|
||||
if (!ServiceConstructor) {
|
||||
throw new Error(
|
||||
`Service ${svcName} not found in package ${config.packageName}`,
|
||||
);
|
||||
}
|
||||
|
||||
// P2-P2 阶段使用 insecure(内网,mTLS 在 P6 硬化阶段启用)
|
||||
const client = new ServiceConstructor(
|
||||
target,
|
||||
grpc.credentials.createInsecure(),
|
||||
);
|
||||
|
||||
clientCache.set(cacheKey, client);
|
||||
logger.info({ service, serviceName: svcName, target }, "gRPC client created");
|
||||
return client;
|
||||
}
|
||||
|
||||
/** 创建 gRPC metadata(注入 x-user-id + x-request-id) */
|
||||
export function createGrpcMetadata(
|
||||
userId: string,
|
||||
traceId?: string,
|
||||
): grpc.Metadata {
|
||||
const meta = new grpc.Metadata();
|
||||
meta.add("x-user-id", userId);
|
||||
if (traceId) {
|
||||
meta.add("x-request-id", traceId);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
/** 关闭所有 gRPC Client(优雅关闭时调用) */
|
||||
export function closeAllGrpcClients(): void {
|
||||
for (const [key, client] of clientCache) {
|
||||
try {
|
||||
client.close();
|
||||
logger.info({ client: key }, "gRPC client closed");
|
||||
} catch (err) {
|
||||
logger.warn({ client: key, err }, "Failed to close gRPC client");
|
||||
}
|
||||
}
|
||||
clientCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 gRPC 健康检查(grpc.health.v1.Health/Check)
|
||||
* 用于 /readyz 探针(president §2.4)
|
||||
*
|
||||
* 使用标准 grpc.health.v1.Health 服务,通过 makeUnaryRequest 直接调用。
|
||||
* 超时 2s,返回 SERVING / NOT_SERVING / UNREACHABLE。
|
||||
*/
|
||||
export async function checkGrpcHealth(
|
||||
service: DownstreamServiceName,
|
||||
): Promise<{ serving: boolean; latencyMs: number; error?: string }> {
|
||||
const target = getGrpcTarget(service);
|
||||
if (!target) {
|
||||
return { serving: false, latencyMs: 0, error: "target not configured" };
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const client = new grpc.Client(target, grpc.credentials.createInsecure(), {
|
||||
"grpc.enable_retries": 0,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.close();
|
||||
resolve({
|
||||
serving: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: "health check timed out (2s)",
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
client.makeUnaryRequest(
|
||||
"grpc.health.v1.Health/Check",
|
||||
// serialize: HealthCheckRequest → Buffer(空消息体)
|
||||
() => Buffer.alloc(0),
|
||||
// deserialize: Buffer → { status: string }
|
||||
(buf: Buffer) => {
|
||||
// proto HealthCheckResponse { enum ServingStatus { SERVING = 1; } int32 status = 1; }
|
||||
// 简化解析:直接读取第一个 varint 字段(field 1 = status)
|
||||
if (buf.length < 2) return { status: "UNKNOWN" };
|
||||
const statusByte: number = buf[1] ?? 0;
|
||||
const statusMap: Record<number, string> = {
|
||||
0: "UNKNOWN",
|
||||
1: "SERVING",
|
||||
2: "NOT_SERVING",
|
||||
3: "SERVICE_UNKNOWN",
|
||||
};
|
||||
return { status: statusMap[statusByte] ?? "UNKNOWN" };
|
||||
},
|
||||
{},
|
||||
new grpc.Metadata(),
|
||||
{},
|
||||
(err: grpc.ServiceError | null, value: unknown) => {
|
||||
clearTimeout(timeout);
|
||||
const latencyMs = Date.now() - start;
|
||||
client.close();
|
||||
if (err) {
|
||||
resolve({ serving: false, latencyMs, error: err.message });
|
||||
} else {
|
||||
const status = (value as { status?: string } | null | undefined)
|
||||
?.status;
|
||||
resolve({ serving: status === "SERVING", latencyMs });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
31
services/teacher-bff/src/clients/iam/iam-client.interface.ts
Normal file
31
services/teacher-bff/src/clients/iam/iam-client.interface.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// IamClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||
// 接口定义所有 P2+ 需要的 iam RPC,实现分 gRPC + mock 两种
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC(未就绪 RPC 降级 mock + warning)
|
||||
import type { CallContext } from "../types.js";
|
||||
import type {
|
||||
UserInfo,
|
||||
ViewportItem,
|
||||
EffectivePermissions,
|
||||
} from "./iam.types.js";
|
||||
|
||||
/** IamClient DI token(NestJS 注入用) */
|
||||
export const IAM_CLIENT = Symbol("IAM_CLIENT");
|
||||
|
||||
/** IamClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
|
||||
export interface IamClient {
|
||||
/** 获取用户信息(iam.proto GetUserInfo,✅ 已就绪) */
|
||||
getUserInfo(ctx: CallContext): Promise<UserInfo>;
|
||||
|
||||
/** 获取教师导航菜单(iam.proto GetViewports,❌ 待 coord 补全) */
|
||||
getViewports(ctx: CallContext): Promise<ViewportItem[]>;
|
||||
|
||||
/** 获取有效权限集(iam.proto GetEffectivePermissions,❌ 待 coord 补全) */
|
||||
getEffectivePermissions(ctx: CallContext): Promise<EffectivePermissions>;
|
||||
|
||||
/** 健康检查(/readyz 探针用) */
|
||||
checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
79
services/teacher-bff/src/clients/iam/iam-grpc.client.ts
Normal file
79
services/teacher-bff/src/clients/iam/iam-grpc.client.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// IamClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||
// iam.proto 现状仅 GetUserInfo,GetViewports/GetEffectivePermissions 待 coord 补全
|
||||
// 未就绪 RPC 降级 mock + warning 日志(president §2.6 降级模式 B)
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type * as grpc from "@grpc/grpc-js";
|
||||
import { BaseDownstreamClient } from "../base.client.js";
|
||||
import {
|
||||
createGrpcMetadata,
|
||||
getGrpcClient,
|
||||
checkGrpcHealth,
|
||||
} from "../grpc/grpc.factory.js";
|
||||
import type { CallContext } from "../types.js";
|
||||
import type { IamClient } from "./iam-client.interface.js";
|
||||
import type {
|
||||
UserInfo,
|
||||
ViewportItem,
|
||||
EffectivePermissions,
|
||||
} from "./iam.types.js";
|
||||
import { IamMockClient } from "./iam-mock.client.js";
|
||||
|
||||
@Injectable()
|
||||
export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
|
||||
readonly serviceName = "iam" as const;
|
||||
private readonly mock: IamMockClient;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
// mock 用于未就绪 RPC 的降级(proto 未定义的 RPC 走 mock + warning)
|
||||
this.mock = new IamMockClient();
|
||||
}
|
||||
|
||||
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
|
||||
return this.callGrpc("GetUserInfo", async () => {
|
||||
const client = getGrpcClient("iam") as unknown as {
|
||||
getUserInfo(
|
||||
req: { userId: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (err: grpc.ServiceError | null, res: UserInfo) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<UserInfo>((resolve, reject) => {
|
||||
client.getUserInfo({ userId: ctx.userId }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(res);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
|
||||
// iam.proto 暂无 GetViewports RPC,降级 mock + warning(待 coord 补全)
|
||||
this.log.warn(
|
||||
{ rpc: "GetViewports", reason: "RPC not in iam.proto yet" },
|
||||
"Downstream RPC not ready, falling back to mock",
|
||||
);
|
||||
return this.mock.getViewports(ctx);
|
||||
}
|
||||
|
||||
async getEffectivePermissions(
|
||||
ctx: CallContext,
|
||||
): Promise<EffectivePermissions> {
|
||||
// iam.proto 暂无 GetEffectivePermissions RPC,降级 mock + warning
|
||||
this.log.warn(
|
||||
{ rpc: "GetEffectivePermissions", reason: "RPC not in iam.proto yet" },
|
||||
"Downstream RPC not ready, falling back to mock",
|
||||
);
|
||||
return this.mock.getEffectivePermissions(ctx);
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return checkGrpcHealth("iam");
|
||||
}
|
||||
}
|
||||
125
services/teacher-bff/src/clients/iam/iam-mock.client.ts
Normal file
125
services/teacher-bff/src/clients/iam/iam-mock.client.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// IamClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||
// DEV_MODE=true 时全部走 mock;DEV_MODE=false 时仅未就绪 RPC 走 mock
|
||||
// mock 数据对齐 contract §4.2 mock 策略
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { BaseDownstreamClient } from "../base.client.js";
|
||||
import type { CallContext } from "../types.js";
|
||||
import type { IamClient } from "./iam-client.interface.js";
|
||||
import type {
|
||||
UserInfo,
|
||||
ViewportItem,
|
||||
EffectivePermissions,
|
||||
} from "./iam.types.js";
|
||||
|
||||
/** 教师默认 mock 用户(contract §4.1 mock 策略) */
|
||||
const MOCK_TEACHER: UserInfo = {
|
||||
id: "teacher-001",
|
||||
email: "teacher@edu.test",
|
||||
name: "张老师",
|
||||
roles: ["teacher"],
|
||||
permissions: [
|
||||
"exam:read",
|
||||
"exam:write",
|
||||
"homework:read",
|
||||
"homework:write",
|
||||
"grade:read",
|
||||
"grade:write",
|
||||
"class:read",
|
||||
"student:read",
|
||||
],
|
||||
};
|
||||
|
||||
/** 教师默认 mock 视口(L1 导航) */
|
||||
const MOCK_VIEWPORTS: ViewportItem[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "工作台",
|
||||
route: "/dashboard",
|
||||
icon: null,
|
||||
sortOrder: 1,
|
||||
requiredPermission: null,
|
||||
},
|
||||
{
|
||||
key: "classes",
|
||||
label: "我的班级",
|
||||
route: "/classes",
|
||||
icon: null,
|
||||
sortOrder: 2,
|
||||
requiredPermission: "class:read",
|
||||
},
|
||||
{
|
||||
key: "exams",
|
||||
label: "考试管理",
|
||||
route: "/exams",
|
||||
icon: null,
|
||||
sortOrder: 3,
|
||||
requiredPermission: "exam:read",
|
||||
},
|
||||
{
|
||||
key: "homework",
|
||||
label: "作业管理",
|
||||
route: "/homework",
|
||||
icon: null,
|
||||
sortOrder: 4,
|
||||
requiredPermission: "homework:read",
|
||||
},
|
||||
{
|
||||
key: "grades",
|
||||
label: "成绩管理",
|
||||
route: "/grades",
|
||||
icon: null,
|
||||
sortOrder: 5,
|
||||
requiredPermission: "grade:read",
|
||||
},
|
||||
{
|
||||
key: "analytics",
|
||||
label: "学情分析",
|
||||
route: "/analytics",
|
||||
icon: null,
|
||||
sortOrder: 6,
|
||||
requiredPermission: "student:read",
|
||||
},
|
||||
];
|
||||
|
||||
/** 教师默认 mock 权限(全权限 + OWN 数据范围) */
|
||||
const MOCK_PERMISSIONS: EffectivePermissions = {
|
||||
permissions: MOCK_TEACHER.permissions,
|
||||
dataScope: "OWN",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class IamMockClient extends BaseDownstreamClient implements IamClient {
|
||||
readonly serviceName = "iam" as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
}
|
||||
|
||||
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
|
||||
this.log.debug({ userId: ctx.userId }, "Mock getUserInfo");
|
||||
// 返回 mock 教师,但 id 用请求中的 userId(保持一致性)
|
||||
return { ...MOCK_TEACHER, id: ctx.userId };
|
||||
}
|
||||
|
||||
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
|
||||
this.log.debug({ userId: ctx.userId }, "Mock getViewports");
|
||||
return MOCK_VIEWPORTS;
|
||||
}
|
||||
|
||||
async getEffectivePermissions(
|
||||
ctx: CallContext,
|
||||
): Promise<EffectivePermissions> {
|
||||
this.log.debug({ userId: ctx.userId }, "Mock getEffectivePermissions");
|
||||
return MOCK_PERMISSIONS;
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
// mock 模式下健康检查总是返回 serving=true
|
||||
return { serving: true, latencyMs: 0 };
|
||||
}
|
||||
}
|
||||
31
services/teacher-bff/src/clients/iam/iam.module.ts
Normal file
31
services/teacher-bff/src/clients/iam/iam.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// iam 模块(B8 裁决:按 DEV_MODE 选择 mock 或 gRPC 实现)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { IAM_CLIENT } from "./iam-client.interface.js";
|
||||
import { IamGrpcClient } from "./iam-grpc.client.js";
|
||||
import { IamMockClient } from "./iam-mock.client.js";
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: IAM_CLIENT,
|
||||
useFactory: () => {
|
||||
if (env.TEACHER_BFF_DEV_MODE) {
|
||||
logger.warn(
|
||||
{ devMode: true },
|
||||
"IamClient using mock (TEACHER_BFF_DEV_MODE=true)",
|
||||
);
|
||||
return new IamMockClient();
|
||||
}
|
||||
logger.info(
|
||||
{ devMode: false, target: env.IAM_GRPC_TARGET },
|
||||
"IamClient using gRPC",
|
||||
);
|
||||
return new IamGrpcClient();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [IAM_CLIENT],
|
||||
})
|
||||
export class IamModule {}
|
||||
44
services/teacher-bff/src/clients/iam/iam.types.ts
Normal file
44
services/teacher-bff/src/clients/iam/iam.types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC)
|
||||
// iam.proto 现状:4 RPC(Register/Login/RefreshToken/GetUserInfo)
|
||||
// 待 coord 补全:GetViewports / GetEffectivePermissions / GetEffectiveAccess / BatchGetUsers / GetChildrenByParent
|
||||
|
||||
/** 用户信息(对齐 iam.proto UserInfo message) */
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/** 视口项(L1 导航菜单,待 coord 补 GetViewports RPC 的 response message) */
|
||||
export interface ViewportItem {
|
||||
key: string;
|
||||
label: string;
|
||||
route: string;
|
||||
icon: string | null;
|
||||
sortOrder: number;
|
||||
requiredPermission: string | null;
|
||||
}
|
||||
|
||||
/** 有效权限集(待 coord 补 GetEffectivePermissions RPC 的 response message) */
|
||||
export interface EffectivePermissions {
|
||||
permissions: string[];
|
||||
/** 数据范围(OWN / GRADE / SCHOOL / ALL) */
|
||||
dataScope: string;
|
||||
}
|
||||
|
||||
/** GetUserInfo 请求 */
|
||||
export interface GetUserInfoRequest {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** GetViewports 请求 */
|
||||
export interface GetViewportsRequest {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** GetEffectivePermissions 请求 */
|
||||
export interface GetEffectivePermissionsRequest {
|
||||
userId: string;
|
||||
}
|
||||
46
services/teacher-bff/src/clients/types.ts
Normal file
46
services/teacher-bff/src/clients/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// DownstreamClient 抽象层共享类型(B8 裁决:3 个 BFF 统一使用)
|
||||
// 所有下游 gRPC 调用通过此抽象层,统一 trace 上下文注入 + 错误映射 + metrics
|
||||
|
||||
/** 下游调用上下文(per-request,由 GraphQL Resolver 注入) */
|
||||
export interface CallContext {
|
||||
/** 当前用户 ID(从 JWT 或 x-user-id header 提取) */
|
||||
userId: string;
|
||||
/** 请求追踪 ID(X-Request-Id,用于日志关联) */
|
||||
traceId?: string;
|
||||
/** 用户角色(可选,用于下游权限校验) */
|
||||
roles?: readonly string[];
|
||||
}
|
||||
|
||||
/** 下游调用结果(降级模式 B 支持:success + degraded 标记) */
|
||||
export type DownstreamResult<T> =
|
||||
| { success: true; data: T; degraded?: false }
|
||||
| { success: true; data: T | null; degraded: true; warning: string }
|
||||
| { success: false; error: DownstreamError };
|
||||
|
||||
export interface DownstreamError {
|
||||
code: string;
|
||||
message: string;
|
||||
service: string;
|
||||
rpc?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 下游服务名(用于 metrics 标签 + 日志字段) */
|
||||
export type DownstreamServiceName =
|
||||
"iam" | "core-edu" | "content" | "data-ana" | "msg" | "ai";
|
||||
|
||||
/** gRPC 健康检查结果(/readyz 探针用) */
|
||||
export interface GrpcHealthStatus {
|
||||
service: DownstreamServiceName;
|
||||
target: string;
|
||||
status: "SERVING" | "NOT_SERVING" | "UNKNOWN" | "UNREACHABLE";
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 下游 gRPC 调用 metadata(注入 trace context + x-user-id) */
|
||||
export interface GrpcMetadata {
|
||||
"x-user-id": string;
|
||||
"x-request-id"?: string;
|
||||
traceparent?: string;
|
||||
}
|
||||
@@ -1,26 +1,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// 环境变量定义(B2 裁决:gRPC 下游通信;B8 裁决:DownstreamClient 抽象)
|
||||
// 端口分配见 docs/architecture/port-allocation.md §5:
|
||||
// iam 50052 / core-edu 50053 / content 50054 / data-ana 50055 / msg 50056 / ai 50058
|
||||
// president §2.9:TEACHER_BFF_DEV_MODE 控制越权防御 Guard(P2 放行 / 生产拒绝)
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default("3003"),
|
||||
IamServiceUrl: z.string().url().default("http://localhost:3002"),
|
||||
ClassesServiceUrl: z.string().url().default("http://localhost:3001"),
|
||||
CoreEduServiceUrl: z.string().url().default("http://localhost:3004"),
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
NODE_ENV: z.string().default("development"),
|
||||
PORT: z.string().default("3003"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
|
||||
// GraphQL endpoint 路径(B1 裁决:P2 起直接 GraphQL)
|
||||
GRAPHQL_PATH: z.string().default("/graphql"),
|
||||
|
||||
// 越权防御开关(president §2.9:P2 DEV_MODE 放行 + 日志告警,生产拒绝)
|
||||
// 生产环境必须显式设为 false
|
||||
TEACHER_BFF_DEV_MODE: z
|
||||
.string()
|
||||
.default("true")
|
||||
.transform((v) => v === "true")
|
||||
.pipe(z.boolean()),
|
||||
|
||||
// Redis(/readyz 探针 + 后续 DataLoader 缓存)
|
||||
REDIS_URL: z.string().default("redis://localhost:6379"),
|
||||
|
||||
// 下游 gRPC targets(按阶段启用,未启用者可留空字符串)
|
||||
IAM_GRPC_TARGET: z.string().default("localhost:50052"),
|
||||
CORE_EDU_GRPC_TARGET: z.string().default(""),
|
||||
CONTENT_GRPC_TARGET: z.string().default(""),
|
||||
DATA_ANA_GRPC_TARGET: z.string().default(""),
|
||||
MSG_GRPC_TARGET: z.string().default(""),
|
||||
AI_GRPC_TARGET: z.string().default(""),
|
||||
|
||||
// 可观测性(OTEL_EXPORTER_OTLP_ENDPOINT 缺省则不启用 tracer)
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
OTEL_SERVICE_NAME: z.string().default("teacher-bff"),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
const result = envSchema.safeParse({
|
||||
...process.env,
|
||||
IamServiceUrl: process.env.IAM_SERVICE_URL || "http://localhost:3002",
|
||||
ClassesServiceUrl:
|
||||
process.env.CLASSES_SERVICE_URL || "http://localhost:3001",
|
||||
CoreEduServiceUrl:
|
||||
process.env.CORE_EDU_SERVICE_URL || "http://localhost:3004",
|
||||
});
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
throw new Error("Invalid env: " + JSON.stringify(result.error.flatten()));
|
||||
}
|
||||
|
||||
37
services/teacher-bff/src/graphql/context.ts
Normal file
37
services/teacher-bff/src/graphql/context.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// GraphQL 上下文构建(B1 裁决:P2 起直接 GraphQL)
|
||||
// 从 Express 请求提取 userId / traceId,注入下游 CallContext
|
||||
import type { Request } from "express";
|
||||
import type { CallContext } from "../clients/types.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
|
||||
export interface GraphQLOperationContext {
|
||||
/** 当前请求的 CallContext(注入下游 gRPC 调用) */
|
||||
callCtx: CallContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Express 请求构建 GraphQL 上下文
|
||||
* P2: 从 x-user-id header 提取 userId(api-gateway 注入)
|
||||
* P3+: 从 JWT 提取(api-gateway 校验后注入 x-user-id + x-user-roles)
|
||||
*/
|
||||
export function buildGraphQLContext(req: Request): GraphQLOperationContext {
|
||||
const userIdHeader = req.headers["x-user-id"];
|
||||
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
|
||||
const requestIdHeader = req.headers["x-request-id"];
|
||||
const traceId =
|
||||
typeof requestIdHeader === "string" ? requestIdHeader : undefined;
|
||||
|
||||
const rolesHeader = req.headers["x-user-roles"];
|
||||
const roles =
|
||||
typeof rolesHeader === "string"
|
||||
? rolesHeader.split(",").filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
callCtx: { userId, traceId, roles },
|
||||
};
|
||||
}
|
||||
37
services/teacher-bff/src/graphql/error-formatter.ts
Normal file
37
services/teacher-bff/src/graphql/error-formatter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// GraphQL 错误格式化(G8 裁决:ActionState 信封 + BFF_TEACHER_ 错误码)
|
||||
// Yoga 自身错误处理,extensions.code = BFF_TEACHER_* + extensions.traceId
|
||||
import { ApplicationError } from "../shared/errors/application-error.js";
|
||||
|
||||
/**
|
||||
* 格式化 GraphQL 错误(president §2.6 降级模式 B + G8 ActionState 信封)
|
||||
* 错误 extensions 包含:code(BFF_TEACHER_*)、traceId、details
|
||||
*/
|
||||
export function formatGraphQLError(
|
||||
error: unknown,
|
||||
traceId?: string,
|
||||
): { message: string; extensions: Record<string, unknown> } {
|
||||
// ApplicationError → 映射 code + details
|
||||
if (error instanceof ApplicationError) {
|
||||
return {
|
||||
message: error.message,
|
||||
extensions: {
|
||||
code: error.code,
|
||||
traceId: traceId ?? error.traceId ?? "unknown",
|
||||
details: error.details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 原生 GraphQL Error(语法错误、校验错误等)
|
||||
const err = error as {
|
||||
message?: string;
|
||||
extensions?: Record<string, unknown>;
|
||||
};
|
||||
return {
|
||||
message: err.message ?? "Unknown GraphQL error",
|
||||
extensions: {
|
||||
code: (err.extensions?.code as string) ?? "BFF_TEACHER_INTERNAL_ERROR",
|
||||
traceId: traceId ?? "unknown",
|
||||
},
|
||||
};
|
||||
}
|
||||
65
services/teacher-bff/src/graphql/graphql.controller.ts
Normal file
65
services/teacher-bff/src/graphql/graphql.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// GraphQL Yoga endpoint(B1 裁决:P2 起直接 GraphQL)
|
||||
// POST /graphql — GraphQL query/mutation endpoint
|
||||
// GET /graphql — GraphQL Playground(开发环境)/ schema introspection
|
||||
import { Controller, Post, Get, Req, Res, Inject } from "@nestjs/common";
|
||||
import { createYoga } from "graphql-yoga";
|
||||
import type { Request, Response } from "express";
|
||||
import { env } from "../config/env.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
import { loadSchema } from "./schema-loader.js";
|
||||
import { buildGraphQLContext } from "./context.js";
|
||||
import { TeacherService } from "../teacher/teacher.service.js";
|
||||
import { buildResolvers } from "../teacher/teacher.resolver.js";
|
||||
|
||||
@Controller("graphql")
|
||||
export class GraphQLController {
|
||||
// Yoga 实例类型复杂,用内联类型避免泛型不匹配
|
||||
private readonly yoga: (req: Request, res: Response) => Promise<void>;
|
||||
|
||||
constructor(@Inject(TeacherService) service: TeacherService) {
|
||||
const resolvers = buildResolvers(service);
|
||||
const schema = loadSchema(resolvers);
|
||||
|
||||
const yoga = createYoga({
|
||||
schema,
|
||||
graphqlEndpoint: env.GRAPHQL_PATH,
|
||||
context: ({ request }) => {
|
||||
return buildGraphQLContext(request as unknown as Request);
|
||||
},
|
||||
logging: {
|
||||
debug: (msg: unknown) => logger.debug({ yoga: msg }, "GraphQL Yoga"),
|
||||
info: (msg: unknown) => logger.info({ yoga: msg }, "GraphQL Yoga"),
|
||||
warn: (msg: unknown) => logger.warn({ yoga: msg }, "GraphQL Yoga"),
|
||||
error: (msg: unknown) => logger.error({ yoga: msg }, "GraphQL Yoga"),
|
||||
},
|
||||
// GraphQL Playground(开发环境启用)
|
||||
graphiql: env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
this.yoga = yoga as unknown as (
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => Promise<void>;
|
||||
|
||||
logger.info(
|
||||
{ graphqlEndpoint: env.GRAPHQL_PATH },
|
||||
"GraphQL Yoga endpoint initialized",
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async handleGraphQL(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.yoga(req, res);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async handleGraphiQL(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.yoga(req, res);
|
||||
}
|
||||
}
|
||||
10
services/teacher-bff/src/graphql/graphql.module.ts
Normal file
10
services/teacher-bff/src/graphql/graphql.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// GraphQL 模块(B1 裁决:P2 起直接 GraphQL Yoga)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { GraphQLController } from "./graphql.controller.js";
|
||||
import { TeacherModule } from "../teacher/teacher.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [TeacherModule],
|
||||
controllers: [GraphQLController],
|
||||
})
|
||||
export class GraphQLModule {}
|
||||
22
services/teacher-bff/src/graphql/schema-loader.ts
Normal file
22
services/teacher-bff/src/graphql/schema-loader.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// GraphQL Schema 加载器(从 .graphql 文件加载 SDL,B1 裁决)
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||
import type { IResolvers } from "@graphql-tools/utils";
|
||||
|
||||
/** schema 文件路径(monorepo 相对路径) */
|
||||
const SCHEMA_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
"../../packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql",
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载 GraphQL schema(从 SDL 文件 + resolvers 构建可执行 schema)
|
||||
* schema 文件位于 packages/shared-ts/contracts/graphql/(ARB-001 仲裁产物)
|
||||
*/
|
||||
export function loadSchema(
|
||||
resolvers: IResolvers,
|
||||
): ReturnType<typeof makeExecutableSchema> {
|
||||
const typeDefs = readFileSync(SCHEMA_PATH, "utf-8");
|
||||
return makeExecutableSchema({ typeDefs, resolvers });
|
||||
}
|
||||
@@ -1,36 +1,62 @@
|
||||
// teacher-bff 入口(B1 裁决:P2 起 GraphQL Yoga)
|
||||
// 优雅关闭顺序(G9):HTTP → Redis → gRPC → Tracer
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import type { Application, Request, Response } from "express";
|
||||
import { AppModule } from "./app.module.js";
|
||||
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { env } from "./config/env.js";
|
||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { closeAllGrpcClients } from "./clients/grpc/grpc.factory.js";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: ["log", "error", "warn"],
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取
|
||||
// 直接获取 Express 实例注册路由,避免 HttpAdapter 泛型类型不兼容
|
||||
const expressApp = app.getHttpAdapter().getInstance() as Application;
|
||||
expressApp.get("/metrics", async (_req: Request, res: Response) => {
|
||||
res.set("Content-Type", metricsRegistry.contentType);
|
||||
res.end(await metricsRegistry.metrics());
|
||||
});
|
||||
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, "Teacher BFF started");
|
||||
logger.info(
|
||||
{
|
||||
port: env.PORT,
|
||||
graphql: env.GRAPHQL_PATH,
|
||||
devMode: env.TEACHER_BFF_DEV_MODE,
|
||||
iamTarget: env.IAM_GRPC_TARGET,
|
||||
},
|
||||
"Teacher BFF started (GraphQL + gRPC downstream)",
|
||||
);
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await app.close();
|
||||
await shutdownTracer();
|
||||
});
|
||||
// 优雅关闭(G9:HTTP → Redis → gRPC → Tracer)
|
||||
const shutdown = async (signal: string) => {
|
||||
logger.info({ signal }, "Shutting down Teacher BFF");
|
||||
try {
|
||||
await app.close(); // 触发 OnModuleDestroy(Redis quit)
|
||||
closeAllGrpcClients(); // 关闭所有 gRPC channel
|
||||
await shutdownTracer();
|
||||
logger.info("Teacher BFF shutdown complete");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Error during shutdown");
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
}
|
||||
|
||||
bootstrap().catch((err: unknown) => {
|
||||
|
||||
123
services/teacher-bff/src/middleware/authorization.guard.ts
Normal file
123
services/teacher-bff/src/middleware/authorization.guard.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// AuthorizationGuard 越权防御(B4 + president §2.7/§2.9)
|
||||
// P2 实现:DEV_MODE 放行 + 日志告警;生产拒绝(保守策略)
|
||||
// P3+ 替换为真实 gRPC 校验(core-edu.GetClassesByTeacher 比对 teacherId)
|
||||
//
|
||||
// 3 类越权错误码(president §2.7):
|
||||
// - BFF_TEACHER_UNAUTHORIZED (401): x-user-id 缺失或无效
|
||||
// - BFF_TEACHER_FORBIDDEN_RESOURCE (403): teacherId 与资源无归属关系(场景 A)
|
||||
// - BFF_TEACHER_IDENTITY_MISMATCH (403): JWT teacherId 与 body teacherId 不一致(场景 B)
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { env } from "../config/env.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
import {
|
||||
ForbiddenResourceError,
|
||||
IdentityMismatchError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
import { IAM_CLIENT } from "../clients/iam/iam-client.interface.js";
|
||||
import type { IamClient } from "../clients/iam/iam-client.interface.js";
|
||||
|
||||
/**
|
||||
* AuthorizationGuard 接口(president §2.9)
|
||||
* P2: DEV_MODE 放行;P3+: 真实 gRPC 校验
|
||||
*/
|
||||
export interface AuthorizationGuard {
|
||||
/** 校验教师是否有权访问指定班级(场景 A:teacherId 与资源归属) */
|
||||
canAccessClass(userId: string, classId: string): Promise<boolean>;
|
||||
|
||||
/** 校验教师是否有权访问指定考试 */
|
||||
canAccessExam(userId: string, examId: string): Promise<boolean>;
|
||||
|
||||
/** 校验教师是否有权访问指定作业 */
|
||||
canAccessHomework(userId: string, homeworkId: string): Promise<boolean>;
|
||||
|
||||
/** 校验身份一致性(场景 B:JWT teacherId 与 body teacherId) */
|
||||
assertIdentityMatch(jwtTeacherId: string, bodyTeacherId: string): void;
|
||||
}
|
||||
|
||||
export const AUTHORIZATION_GUARD = Symbol("AUTHORIZATION_GUARD");
|
||||
|
||||
/**
|
||||
* P2 实现:DEV_MODE 放行 + 日志告警
|
||||
* 生产环境(DEV_MODE=false)对所有资源校验返回 false(保守拒绝)
|
||||
* P3+ 替换为 AuthorizationGuardGrpcImpl(接入 core-edu gRPC 真实校验)
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthorizationGuardImpl implements AuthorizationGuard {
|
||||
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
|
||||
|
||||
async canAccessClass(userId: string, classId: string): Promise<boolean> {
|
||||
if (env.TEACHER_BFF_DEV_MODE) {
|
||||
logger.warn(
|
||||
{ userId, classId, devMode: true },
|
||||
"AuthorizationGuard DEV_MODE: access allowed without real check",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// P2 生产模式:保守拒绝(P3+ 接入 core-edu.GetClassesByTeacher 真实校验)
|
||||
logger.error(
|
||||
{ userId, classId, devMode: false },
|
||||
"AuthorizationGuard production mode not implemented yet (P3+)",
|
||||
);
|
||||
throw new ForbiddenResourceError(
|
||||
`User ${userId} has no access to class ${classId}`,
|
||||
{ userId, classId, reason: "production_check_not_implemented" },
|
||||
);
|
||||
}
|
||||
|
||||
async canAccessExam(userId: string, examId: string): Promise<boolean> {
|
||||
if (env.TEACHER_BFF_DEV_MODE) {
|
||||
logger.warn(
|
||||
{ userId, examId, devMode: true },
|
||||
"AuthorizationGuard DEV_MODE: access allowed without real check",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
logger.error(
|
||||
{ userId, examId, devMode: false },
|
||||
"AuthorizationGuard production mode not implemented yet (P3+)",
|
||||
);
|
||||
throw new ForbiddenResourceError(
|
||||
`User ${userId} has no access to exam ${examId}`,
|
||||
{ userId, examId, reason: "production_check_not_implemented" },
|
||||
);
|
||||
}
|
||||
|
||||
async canAccessHomework(
|
||||
userId: string,
|
||||
homeworkId: string,
|
||||
): Promise<boolean> {
|
||||
if (env.TEACHER_BFF_DEV_MODE) {
|
||||
logger.warn(
|
||||
{ userId, homeworkId, devMode: true },
|
||||
"AuthorizationGuard DEV_MODE: access allowed without real check",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
logger.error(
|
||||
{ userId, homeworkId, devMode: false },
|
||||
"AuthorizationGuard production mode not implemented yet (P3+)",
|
||||
);
|
||||
throw new ForbiddenResourceError(
|
||||
`User ${userId} has no access to homework ${homeworkId}`,
|
||||
{ userId, homeworkId, reason: "production_check_not_implemented" },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验身份一致性(场景 B,president §2.7)
|
||||
* JWT 中的 teacherId 必须与请求 body 中的 teacherId 一致
|
||||
* 此校验在所有模式(DEV_MODE / 生产)下都强制执行
|
||||
*/
|
||||
assertIdentityMatch(jwtTeacherId: string, bodyTeacherId: string): void {
|
||||
if (jwtTeacherId !== bodyTeacherId) {
|
||||
logger.error(
|
||||
{ jwtTeacherId, bodyTeacherId },
|
||||
"Identity mismatch: JWT teacherId != body teacherId",
|
||||
);
|
||||
throw new IdentityMismatchError(
|
||||
`JWT teacherId (${jwtTeacherId}) does not match body teacherId (${bodyTeacherId})`,
|
||||
{ jwtTeacherId, bodyTeacherId },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
services/teacher-bff/src/middleware/middleware.module.ts
Normal file
19
services/teacher-bff/src/middleware/middleware.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// 中间件模块(AuthorizationGuard 越权防御,B4 + president §2.9)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ClientsModule } from "../clients/clients.module.js";
|
||||
import {
|
||||
AUTHORIZATION_GUARD,
|
||||
AuthorizationGuardImpl,
|
||||
} from "./authorization.guard.js";
|
||||
|
||||
@Module({
|
||||
imports: [ClientsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: AUTHORIZATION_GUARD,
|
||||
useClass: AuthorizationGuardImpl,
|
||||
},
|
||||
],
|
||||
exports: [AUTHORIZATION_GUARD],
|
||||
})
|
||||
export class MiddlewareModule {}
|
||||
@@ -1,12 +1,20 @@
|
||||
// BFF_TEACHER_ 错误码(B5 + G14 裁决,统一 BFF_ 前缀)
|
||||
// 越权防御 3 类错误码(president §2.7 裁决):
|
||||
// - BFF_TEACHER_UNAUTHORIZED (401): x-user-id 缺失或无效
|
||||
// - BFF_TEACHER_FORBIDDEN_RESOURCE (403): teacherId 与资源无归属关系(场景 A)
|
||||
// - BFF_TEACHER_IDENTITY_MISMATCH (403): JWT teacherId 与 body teacherId 不一致(场景 B)
|
||||
// 其他聚合层错误码(contract §1.5)
|
||||
|
||||
export type ErrorType =
|
||||
| "validation"
|
||||
| "not_found"
|
||||
| "permission_denied"
|
||||
| "unauthorized"
|
||||
| "forbidden_resource"
|
||||
| "identity_mismatch"
|
||||
| "conflict"
|
||||
| "business"
|
||||
| "database"
|
||||
| "bad_gateway"
|
||||
| "aggregation_failed"
|
||||
| "internal";
|
||||
|
||||
export interface ErrorDetails {
|
||||
@@ -40,79 +48,95 @@ export abstract class ApplicationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** 输入参数校验失败(400) */
|
||||
export class ValidationError extends ApplicationError {
|
||||
readonly type = "validation" as const;
|
||||
readonly statusCode = 400;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "TEACHER_BFF_VALIDATION_ERROR", details);
|
||||
super(message, "BFF_TEACHER_VALIDATION_FAILED", details);
|
||||
}
|
||||
}
|
||||
|
||||
/** 资源不存在(404) */
|
||||
export class NotFoundError extends ApplicationError {
|
||||
readonly type = "not_found" as const;
|
||||
readonly statusCode = 404;
|
||||
constructor(resource: string, id: string) {
|
||||
super(`${resource} not found: ${id}`, "TEACHER_BFF_NOT_FOUND", {
|
||||
super(`${resource} not found: ${id}`, "BFF_TEACHER_NOT_FOUND", {
|
||||
resource,
|
||||
id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionDeniedError extends ApplicationError {
|
||||
readonly type = "permission_denied" as const;
|
||||
readonly statusCode = 403;
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`, "TEACHER_BFF_PERMISSION_DENIED", {
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** x-user-id 缺失或无效(401,president §2.7) */
|
||||
export class UnauthorizedError extends ApplicationError {
|
||||
readonly type = "unauthorized" as const;
|
||||
readonly statusCode = 401;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "TEACHER_BFF_UNAUTHORIZED", details);
|
||||
super(message, "BFF_TEACHER_UNAUTHORIZED", details);
|
||||
}
|
||||
}
|
||||
|
||||
/** teacherId 与资源无归属关系(403 场景 A,president §2.7) */
|
||||
export class ForbiddenResourceError extends ApplicationError {
|
||||
readonly type = "forbidden_resource" as const;
|
||||
readonly statusCode = 403;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_TEACHER_FORBIDDEN_RESOURCE", details);
|
||||
}
|
||||
}
|
||||
|
||||
/** JWT teacherId 与请求 body teacherId 不一致(403 场景 B,president §2.7) */
|
||||
export class IdentityMismatchError extends ApplicationError {
|
||||
readonly type = "identity_mismatch" as const;
|
||||
readonly statusCode = 403;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_TEACHER_IDENTITY_MISMATCH", details);
|
||||
}
|
||||
}
|
||||
|
||||
/** 并发冲突(409) */
|
||||
export class ConflictError extends ApplicationError {
|
||||
readonly type = "conflict" as const;
|
||||
readonly statusCode = 409;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "TEACHER_BFF_CONFLICT", details);
|
||||
super(message, "BFF_TEACHER_CONFLICT", details);
|
||||
}
|
||||
}
|
||||
|
||||
/** 业务规则违反(422) */
|
||||
export class BusinessError extends ApplicationError {
|
||||
readonly type = "business" as const;
|
||||
readonly statusCode = 422;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "TEACHER_BFF_BUSINESS_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends ApplicationError {
|
||||
readonly type = "database" as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "TEACHER_BFF_DATABASE_ERROR", details);
|
||||
super(message, "BFF_TEACHER_BUSINESS_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
/** 下游 gRPC 不可达(502) */
|
||||
export class BadGatewayError extends ApplicationError {
|
||||
readonly type = "bad_gateway" as const;
|
||||
readonly statusCode = 502;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "TEACHER_BFF_BAD_GATEWAY", details);
|
||||
super(message, "BFF_TEACHER_UPSTREAM_UNAVAILABLE", details);
|
||||
}
|
||||
}
|
||||
|
||||
/** 聚合多下游时部分失败且无降级数据(500) */
|
||||
export class AggregationFailedError extends ApplicationError {
|
||||
readonly type = "aggregation_failed" as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_TEACHER_AGGREGATION_FAILED", details);
|
||||
}
|
||||
}
|
||||
|
||||
/** 兜底内部错误(500) */
|
||||
export class InternalError extends ApplicationError {
|
||||
readonly type = "internal" as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "TEACHER_BFF_INTERNAL_ERROR", details);
|
||||
super(message, "BFF_TEACHER_INTERNAL_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ import type { Request, Response } from "express";
|
||||
import { ZodError } from "zod";
|
||||
import { ApplicationError } from "./application-error.js";
|
||||
|
||||
/**
|
||||
* GlobalErrorFilter — 统一错误兜底(G8 裁决:首次实现即注册 + ActionState 信封)。
|
||||
*
|
||||
* REST 端点:响应 ActionState 信封 { success: false, error: { code, message, details, traceId } }
|
||||
* GraphQL 端点:由 Yoga 自身错误处理,错误 extensions.code = BFF_TEACHER_*(见 graphql/error-formatter)
|
||||
*
|
||||
* 错误码统一 BFF_TEACHER_ 前缀(B5 + G14 裁决)。
|
||||
*/
|
||||
@Catch()
|
||||
export class GlobalErrorFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalErrorFilter.name);
|
||||
@@ -34,7 +42,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "TEACHER_BFF_VALIDATION_ERROR",
|
||||
code: "BFF_TEACHER_VALIDATION_FAILED",
|
||||
message: "Validation failed",
|
||||
details: exception.flatten(),
|
||||
traceId,
|
||||
@@ -47,7 +55,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "HTTP_ERROR",
|
||||
code: "BFF_TEACHER_INTERNAL_ERROR",
|
||||
message,
|
||||
traceId,
|
||||
},
|
||||
@@ -60,7 +68,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
code: "BFF_TEACHER_INTERNAL_ERROR",
|
||||
message: "An unexpected error occurred",
|
||||
traceId,
|
||||
},
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
// 健康检查端点(G3 + president §2.4)
|
||||
// - GET /healthz:liveness,仅返回进程存活(不检查下游)
|
||||
// - GET /readyz:readiness,DownstreamHealthCheck 注册表(P2: Redis + iam gRPC)
|
||||
//
|
||||
// 软失败规则(contract §3.3):
|
||||
// - status=ok → 200
|
||||
// - status=degraded → 200(可选依赖失败,告警但不阻断)
|
||||
// - status=unavailable → 503(必需依赖失败)
|
||||
import { Controller, Get, Res, HttpStatus } from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { healthRegistry } from "./readiness.probe.js";
|
||||
|
||||
const SERVICE_NAME = "teacher-bff";
|
||||
|
||||
/**
|
||||
* 健康检查端点。
|
||||
*
|
||||
* - GET /healthz:liveness,仅返回进程存活。
|
||||
* - GET /readyz:readiness,BFF 不直接访问 DB,可直接返回 ok。
|
||||
*
|
||||
* 不需要鉴权,必须在路由白名单中放行。
|
||||
*/
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Get("healthz")
|
||||
@@ -22,11 +24,12 @@ export class HealthController {
|
||||
}
|
||||
|
||||
@Get("readyz")
|
||||
readiness(): { status: string; service: string; timestamp: string } {
|
||||
return {
|
||||
status: "ok",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
async readiness(@Res() res: Response): Promise<void> {
|
||||
const report = await healthRegistry.runAll();
|
||||
const statusCode =
|
||||
report.status === "unavailable"
|
||||
? HttpStatus.SERVICE_UNAVAILABLE
|
||||
: HttpStatus.OK;
|
||||
res.status(statusCode).json(report);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,49 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
// 健康检查模块(G3 + president §2.4)
|
||||
// P2 注册 2 项探针:Redis + iam gRPC
|
||||
// P3+ 扩展:core-edu / content / data-ana / ai / msg(按阶段新增)
|
||||
import { Module, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
import { healthRegistry } from "./readiness.probe.js";
|
||||
import { RedisHealthProbe } from "./redis.probe.js";
|
||||
import { IamGrpcHealthProbe } from "./iam-grpc.probe.js";
|
||||
import { ClientsModule } from "../../clients/clients.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [ClientsModule],
|
||||
controllers: [HealthController],
|
||||
providers: [IamGrpcHealthProbe],
|
||||
})
|
||||
export class HealthModule {}
|
||||
export class HealthModule implements OnModuleInit, OnModuleDestroy {
|
||||
private redis: Redis | null = null;
|
||||
|
||||
constructor(private readonly iamProbe: IamGrpcHealthProbe) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 注册 Redis 探针(必需依赖)
|
||||
this.redis = new Redis(env.REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
lazyConnect: false,
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
const redisProbe = new RedisHealthProbe(this.redis);
|
||||
healthRegistry.register(redisProbe);
|
||||
logger.info({ probe: "redis", required: true }, "Health probe registered");
|
||||
|
||||
// 注册 iam gRPC 探针(必需依赖)
|
||||
healthRegistry.register(this.iamProbe);
|
||||
logger.info(
|
||||
{ probe: "iam-grpc", required: true, target: env.IAM_GRPC_TARGET },
|
||||
"Health probe registered",
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.redis) {
|
||||
await this.redis.quit();
|
||||
logger.info("Redis connection closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
services/teacher-bff/src/shared/health/iam-grpc.probe.ts
Normal file
27
services/teacher-bff/src/shared/health/iam-grpc.probe.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// iam gRPC 健康探针(/readyz P2 必需依赖)
|
||||
// 调用 grpc.health.v1.Health/Check 检查 iam:50052 是否 SERVING
|
||||
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 { HealthProbe } from "./readiness.probe.js";
|
||||
|
||||
@Injectable()
|
||||
export class IamGrpcHealthProbe implements HealthProbe {
|
||||
readonly name = "iam-grpc";
|
||||
readonly required = true;
|
||||
|
||||
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
|
||||
|
||||
async check(): Promise<{
|
||||
status: "up" | "down";
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await this.iam.checkHealth();
|
||||
return {
|
||||
status: result.serving ? "up" : "down",
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
113
services/teacher-bff/src/shared/health/readiness.probe.ts
Normal file
113
services/teacher-bff/src/shared/health/readiness.probe.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// /readyz 探针注册表(president §2.4 + G2)
|
||||
// DownstreamHealthCheck 注册表模式:每个下游注册独立探针,按阶段启用
|
||||
// P2: Redis PING + iam gRPC 50052(2 项)
|
||||
// P3+: + core-edu / content / data-ana / ai / msg(按阶段扩展)
|
||||
//
|
||||
// 软失败规则(contract §3.3):
|
||||
// - 必需依赖(Redis / 已启用 gRPC 下游)失败返 503
|
||||
// - 可选依赖(未启用 gRPC 下游)失败仅告警返 200 + degraded: true
|
||||
|
||||
/** 单个健康检查结果 */
|
||||
export interface HealthCheckResult {
|
||||
name: string;
|
||||
status: "up" | "down" | "degraded";
|
||||
latencyMs: number;
|
||||
required: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** /readyz 聚合结果 */
|
||||
export interface ReadinessReport {
|
||||
status: "ok" | "degraded" | "unavailable";
|
||||
service: string;
|
||||
timestamp: string;
|
||||
checks: HealthCheckResult[];
|
||||
degraded?: boolean;
|
||||
}
|
||||
|
||||
/** 健康检查探针接口(每个下游注册一个) */
|
||||
export interface HealthProbe {
|
||||
/** 探针名称(如 "redis" / "iam-grpc") */
|
||||
readonly name: string;
|
||||
/** 是否为必需依赖(必需失败返 503,可选失败返 200 + degraded) */
|
||||
readonly required: boolean;
|
||||
/** 执行健康检查 */
|
||||
check(): Promise<{
|
||||
status: "up" | "down";
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查注册表(DownstreamHealthCheck 模式)
|
||||
* 按阶段注册探针,/readyz 调用时遍历所有已注册探针
|
||||
*/
|
||||
export class HealthCheckRegistry {
|
||||
private readonly probes = new Map<string, HealthProbe>();
|
||||
|
||||
register(probe: HealthProbe): void {
|
||||
this.probes.set(probe.name, probe);
|
||||
}
|
||||
|
||||
unregister(name: string): void {
|
||||
this.probes.delete(name);
|
||||
}
|
||||
|
||||
async runAll(): Promise<ReadinessReport> {
|
||||
const checks: HealthCheckResult[] = [];
|
||||
let hasRequiredDown = false;
|
||||
let hasOptionalDown = false;
|
||||
|
||||
for (const probe of this.probes.values()) {
|
||||
try {
|
||||
const result = await probe.check();
|
||||
checks.push({
|
||||
name: probe.name,
|
||||
status: result.status,
|
||||
latencyMs: result.latencyMs,
|
||||
required: probe.required,
|
||||
error: result.error,
|
||||
});
|
||||
if (result.status === "down" && probe.required) {
|
||||
hasRequiredDown = true;
|
||||
} else if (result.status === "down" && !probe.required) {
|
||||
hasOptionalDown = true;
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
name: probe.name,
|
||||
status: "down",
|
||||
latencyMs: 0,
|
||||
required: probe.required,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (probe.required) {
|
||||
hasRequiredDown = true;
|
||||
} else {
|
||||
hasOptionalDown = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status: ReadinessReport["status"];
|
||||
if (hasRequiredDown) {
|
||||
status = "unavailable";
|
||||
} else if (hasOptionalDown) {
|
||||
status = "degraded";
|
||||
} else {
|
||||
status = "ok";
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
service: "teacher-bff",
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
degraded: hasOptionalDown && !hasRequiredDown,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局注册表实例(单例) */
|
||||
export const healthRegistry = new HealthCheckRegistry();
|
||||
37
services/teacher-bff/src/shared/health/redis.probe.ts
Normal file
37
services/teacher-bff/src/shared/health/redis.probe.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Redis 健康探针(/readyz P2 必需依赖)
|
||||
// PING 命令检查 Redis 连通性,超时 2s
|
||||
import type { Redis } from "ioredis";
|
||||
import type { HealthProbe } from "./readiness.probe.js";
|
||||
|
||||
export class RedisHealthProbe implements HealthProbe {
|
||||
readonly name = "redis";
|
||||
readonly required = true;
|
||||
|
||||
constructor(private readonly redis: Redis) {}
|
||||
|
||||
async check(): Promise<{
|
||||
status: "up" | "down";
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await this.redis.ping();
|
||||
const latencyMs = Date.now() - start;
|
||||
if (result === "PONG") {
|
||||
return { status: "up", latencyMs };
|
||||
}
|
||||
return {
|
||||
status: "down",
|
||||
latencyMs,
|
||||
error: `Unexpected PING response: ${result}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "down",
|
||||
latencyMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export function initTracer(): void {
|
||||
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
||||
|
||||
sdk = new NodeSDK({
|
||||
serviceName: "teacher-bff",
|
||||
serviceName: env.OTEL_SERVICE_NAME,
|
||||
traceExporter: new OTLPTraceExporter({
|
||||
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
||||
}),
|
||||
@@ -17,11 +17,12 @@ export function initTracer(): void {
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
console.log("Tracer initialized with auto-instrumentations");
|
||||
console.log(`Tracer initialized (service: ${env.OTEL_SERVICE_NAME})`);
|
||||
}
|
||||
|
||||
export async function shutdownTracer(): Promise<void> {
|
||||
if (sdk) {
|
||||
await sdk.shutdown();
|
||||
sdk = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
362
services/teacher-bff/src/teacher/teacher.resolver.ts
Normal file
362
services/teacher-bff/src/teacher/teacher.resolver.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
// GraphQL Resolvers(B1 裁决:P2 起 GraphQL)
|
||||
// P2 核心 5 Query:dashboard / 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.1:P2 预留,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 命名空间 Mutation(P2 预留,P6 实现)=====
|
||||
|
||||
admin: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
|
||||
// ===== admin 命名空间子字段 Resolver(P2 全部返回 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" },
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// ===== 类型字段 Resolver(Class.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
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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