feat(teacher-bff): admin 命名空间 + 5 个 gRPC client + health probes + merge-resolvers + nextstep 文档
This commit is contained in:
50
services/teacher-bff/src/clients/ai/ai-client.interface.ts
Normal file
50
services/teacher-bff/src/clients/ai/ai-client.interface.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// AiClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||
// 接口定义所有 P5 需要的 ai RPC,实现分 gRPC + mock 两种
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC
|
||||
// 裁决依据:B2(首次实现即 gRPC)+ B8(DownstreamClient 抽象)
|
||||
// 端口:ai gRPC 50058(contract §2.1.6 + port-allocation.md §5)
|
||||
import type { CallContext } from "../types.js";
|
||||
import type {
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
StreamChatChunk,
|
||||
GenerateQuestionRequest,
|
||||
GeneratedQuestion,
|
||||
OptimizeExpressionRequest,
|
||||
OptimizedExpression,
|
||||
} from "./ai.types.js";
|
||||
|
||||
/** AiClient DI token(NestJS 注入用) */
|
||||
export const AI_CLIENT = Symbol("AI_CLIENT");
|
||||
|
||||
/** AiClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
|
||||
export interface AiClient {
|
||||
// ===== AiService =====
|
||||
/** 非流式聊天 */
|
||||
chat(ctx: CallContext, req: ChatRequest): Promise<ChatResponse>;
|
||||
|
||||
/** 流式聊天(SSE over gRPC,返回 AsyncIterable) */
|
||||
streamChat(
|
||||
ctx: CallContext,
|
||||
req: ChatRequest,
|
||||
): Promise<AsyncIterable<StreamChatChunk>>;
|
||||
|
||||
/** 生成题目 */
|
||||
generateQuestion(
|
||||
ctx: CallContext,
|
||||
req: GenerateQuestionRequest,
|
||||
): Promise<GeneratedQuestion>;
|
||||
|
||||
/** 优化表达 */
|
||||
optimizeExpression(
|
||||
ctx: CallContext,
|
||||
req: OptimizeExpressionRequest,
|
||||
): Promise<OptimizedExpression>;
|
||||
|
||||
// ===== 健康检查 =====
|
||||
checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
256
services/teacher-bff/src/clients/ai/ai-grpc.client.ts
Normal file
256
services/teacher-bff/src/clients/ai/ai-grpc.client.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// AiClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||
// ai.proto 现状 4 RPC(AiService.Chat / StreamChat / GenerateQuestion / OptimizeExpression)
|
||||
// StreamChat 为 server streaming RPC,返回 AsyncIterable<StreamChatChunk>
|
||||
// proto 字段是 snake_case,gRPC 返回需转 camelCase
|
||||
// 裁决依据:B2(首次实现即 gRPC)+ B8(DownstreamClient 抽象)
|
||||
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 { AiClient } from "./ai-client.interface.js";
|
||||
import type {
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
StreamChatChunk,
|
||||
GenerateQuestionRequest,
|
||||
GeneratedQuestion,
|
||||
OptimizeExpressionRequest,
|
||||
OptimizedExpression,
|
||||
} from "./ai.types.js";
|
||||
|
||||
/** proto Usage message → BFF Usage(snake_case → camelCase) */
|
||||
function mapUsage(raw: Record<string, unknown>): ChatResponse["usage"] {
|
||||
return {
|
||||
promptTokens: Number(raw.prompt_tokens ?? 0),
|
||||
completionTokens: Number(raw.completion_tokens ?? 0),
|
||||
totalTokens: Number(raw.total_tokens ?? 0),
|
||||
latencyMs: Number(raw.latency_ms ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** proto ChatResponse message → BFF ChatResponse */
|
||||
function mapChatResponse(raw: Record<string, unknown>): ChatResponse {
|
||||
const usageRaw = (raw.usage ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
content: String(raw.content ?? ""),
|
||||
model: String(raw.model ?? ""),
|
||||
usage: mapUsage(usageRaw),
|
||||
degraded: Boolean(raw.degraded),
|
||||
degradedReason: String(raw.degraded_reason ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** proto ChatChunk message → BFF StreamChatChunk */
|
||||
function mapStreamChatChunk(raw: Record<string, unknown>): StreamChatChunk {
|
||||
return {
|
||||
content: String(raw.content ?? ""),
|
||||
done: Boolean(raw.done),
|
||||
};
|
||||
}
|
||||
|
||||
/** proto GeneratedQuestion message → BFF GeneratedQuestion */
|
||||
function mapGeneratedQuestion(raw: Record<string, unknown>): GeneratedQuestion {
|
||||
return {
|
||||
question: String(raw.question ?? ""),
|
||||
answer: String(raw.answer ?? ""),
|
||||
explanation: String(raw.explanation ?? ""),
|
||||
questionType: String(raw.question_type ?? ""),
|
||||
difficulty: String(raw.difficulty ?? ""),
|
||||
knowledgePointIds: (raw.knowledge_point_ids ?? []) as string[],
|
||||
evaluationScore:
|
||||
raw.evaluation_score != null ? Number(raw.evaluation_score) : undefined,
|
||||
degraded: Boolean(raw.degraded),
|
||||
degradedReason: String(raw.degraded_reason ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** proto OptimizedExpression message → BFF OptimizedExpression */
|
||||
function mapOptimizedExpression(
|
||||
raw: Record<string, unknown>,
|
||||
): OptimizedExpression {
|
||||
return {
|
||||
optimized: String(raw.optimized ?? ""),
|
||||
suggestions: (raw.suggestions ?? []) as string[],
|
||||
degraded: Boolean(raw.degraded),
|
||||
degradedReason: String(raw.degraded_reason ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiGrpcClient extends BaseDownstreamClient implements AiClient {
|
||||
readonly serviceName = "ai" as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
}
|
||||
|
||||
// ===== AiService =====
|
||||
|
||||
async chat(ctx: CallContext, req: ChatRequest): Promise<ChatResponse> {
|
||||
return this.callGrpc("Chat", async () => {
|
||||
const client = getGrpcClient("ai", "AiService") as unknown as {
|
||||
chat(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<ChatResponse>((resolve, reject) => {
|
||||
client.chat(
|
||||
{
|
||||
messages: req.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
model: req.model,
|
||||
temperature: req.temperature,
|
||||
user_id: req.userId ?? "",
|
||||
session_id: req.sessionId ?? "",
|
||||
data_scope: req.dataScope ?? "",
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapChatResponse(res));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async streamChat(
|
||||
ctx: CallContext,
|
||||
req: ChatRequest,
|
||||
): Promise<AsyncIterable<StreamChatChunk>> {
|
||||
const client = getGrpcClient("ai", "AiService") as unknown as {
|
||||
streamChat(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
): grpc.ClientReadableStream<Record<string, unknown>>;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
const stream = client.streamChat(
|
||||
{
|
||||
messages: req.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
model: req.model,
|
||||
temperature: req.temperature,
|
||||
user_id: req.userId ?? "",
|
||||
session_id: req.sessionId ?? "",
|
||||
data_scope: req.dataScope ?? "",
|
||||
},
|
||||
meta,
|
||||
);
|
||||
this.log.debug(
|
||||
{ rpc: "StreamChat", userId: ctx.userId, model: req.model },
|
||||
"Downstream gRPC stream opened",
|
||||
);
|
||||
return this.wrapStream(stream, "StreamChat");
|
||||
}
|
||||
|
||||
async generateQuestion(
|
||||
ctx: CallContext,
|
||||
req: GenerateQuestionRequest,
|
||||
): Promise<GeneratedQuestion> {
|
||||
return this.callGrpc("GenerateQuestion", async () => {
|
||||
const client = getGrpcClient("ai", "AiService") as unknown as {
|
||||
generateQuestion(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<GeneratedQuestion>((resolve, reject) => {
|
||||
client.generateQuestion(
|
||||
{
|
||||
prompt: req.prompt,
|
||||
subject: req.subject,
|
||||
difficulty: req.difficulty,
|
||||
grade: req.grade ?? "",
|
||||
knowledge_point_ids: req.knowledgePointIds,
|
||||
question_type: req.questionType ?? "",
|
||||
count: req.count ?? 1,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapGeneratedQuestion(res));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async optimizeExpression(
|
||||
ctx: CallContext,
|
||||
req: OptimizeExpressionRequest,
|
||||
): Promise<OptimizedExpression> {
|
||||
return this.callGrpc("OptimizeExpression", async () => {
|
||||
const client = getGrpcClient("ai", "AiService") as unknown as {
|
||||
optimizeExpression(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<OptimizedExpression>((resolve, reject) => {
|
||||
client.optimizeExpression(
|
||||
{
|
||||
text: req.text,
|
||||
context: req.context,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapOptimizedExpression(res));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return checkGrpcHealth("ai");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 gRPC server streaming ClientReadableStream 包装为 AsyncIterable
|
||||
* 错误统一映射为 BadGatewayError(通过 mapGrpcError)
|
||||
*/
|
||||
private async *wrapStream(
|
||||
stream: grpc.ClientReadableStream<Record<string, unknown>>,
|
||||
rpc: string,
|
||||
): AsyncIterable<StreamChatChunk> {
|
||||
try {
|
||||
for await (const raw of stream) {
|
||||
yield mapStreamChatChunk(raw);
|
||||
}
|
||||
} catch (err) {
|
||||
throw this.mapGrpcError(err, rpc);
|
||||
}
|
||||
}
|
||||
}
|
||||
124
services/teacher-bff/src/clients/ai/ai-mock.client.ts
Normal file
124
services/teacher-bff/src/clients/ai/ai-mock.client.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// AiClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||
// DEV_MODE=true 时全部走 mock;DEV_MODE=false 时走 gRPC
|
||||
// mock 数据对齐 contract §4.2 mock 策略
|
||||
// 裁决依据:B2(首次实现即 gRPC)+ B8(DownstreamClient 抽象)
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { BaseDownstreamClient } from "../base.client.js";
|
||||
import type { CallContext } from "../types.js";
|
||||
import type { AiClient } from "./ai-client.interface.js";
|
||||
import type {
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
StreamChatChunk,
|
||||
GenerateQuestionRequest,
|
||||
GeneratedQuestion,
|
||||
OptimizeExpressionRequest,
|
||||
OptimizedExpression,
|
||||
} from "./ai.types.js";
|
||||
|
||||
/** mock 聊天响应(contract §4.2:返回固定回复) */
|
||||
const MOCK_CHAT_RESPONSE: ChatResponse = {
|
||||
content: "您好,我是 AI 助教,可以帮您解答教学相关问题。",
|
||||
model: "mock-llm-v1",
|
||||
usage: {
|
||||
promptTokens: 32,
|
||||
completionTokens: 18,
|
||||
totalTokens: 50,
|
||||
latencyMs: 120,
|
||||
},
|
||||
degraded: false,
|
||||
degradedReason: "",
|
||||
};
|
||||
|
||||
/** mock 流式聊天 chunk(contract §4.2:流式 mock,3 个 chunk) */
|
||||
const MOCK_STREAM_CHUNKS: StreamChatChunk[] = [
|
||||
{ content: "您好,", done: false },
|
||||
{ content: "我是 AI 助教,", done: false },
|
||||
{ content: "可以帮您解答教学相关问题。", done: true },
|
||||
];
|
||||
|
||||
/** mock 生成的题目(contract §4.2:返回固定题目) */
|
||||
const MOCK_GENERATED_QUESTION: GeneratedQuestion = {
|
||||
question:
|
||||
"下列哪个选项是分数加减法的结果?\n1/2 + 1/3 = ?\nA. 2/5 B. 5/6 C. 1/6 D. 2/6",
|
||||
answer: "B. 5/6",
|
||||
explanation: "1/2 + 1/3 = 3/6 + 2/6 = 5/6,分数加减需先通分再加减。",
|
||||
questionType: "single_choice",
|
||||
difficulty: "medium",
|
||||
knowledgePointIds: ["kp-001"],
|
||||
evaluationScore: 0.92,
|
||||
degraded: false,
|
||||
degradedReason: "",
|
||||
};
|
||||
|
||||
/** mock 优化后的表达(contract §4.2:返回固定优化结果) */
|
||||
const MOCK_OPTIMIZED_EXPRESSION: OptimizedExpression = {
|
||||
optimized: "请同学们思考:1/2 与 1/3 相加时,为什么需要先通分?",
|
||||
suggestions: [
|
||||
"使用开放式提问激发学生思考",
|
||||
"将抽象概念与学生已有经验关联",
|
||||
'增加"为什么"引导深度理解',
|
||||
],
|
||||
degraded: false,
|
||||
degradedReason: "",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AiMockClient extends BaseDownstreamClient implements AiClient {
|
||||
readonly serviceName = "ai" as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
}
|
||||
|
||||
async chat(ctx: CallContext, req: ChatRequest): Promise<ChatResponse> {
|
||||
this.log.debug({ userId: ctx.userId, model: req.model }, "Mock chat");
|
||||
return MOCK_CHAT_RESPONSE;
|
||||
}
|
||||
|
||||
async streamChat(
|
||||
ctx: CallContext,
|
||||
req: ChatRequest,
|
||||
): Promise<AsyncIterable<StreamChatChunk>> {
|
||||
this.log.debug({ userId: ctx.userId, model: req.model }, "Mock streamChat");
|
||||
return this.mockStream();
|
||||
}
|
||||
|
||||
async generateQuestion(
|
||||
ctx: CallContext,
|
||||
req: GenerateQuestionRequest,
|
||||
): Promise<GeneratedQuestion> {
|
||||
this.log.debug(
|
||||
{ userId: ctx.userId, subject: req.subject, difficulty: req.difficulty },
|
||||
"Mock generateQuestion",
|
||||
);
|
||||
return MOCK_GENERATED_QUESTION;
|
||||
}
|
||||
|
||||
async optimizeExpression(
|
||||
ctx: CallContext,
|
||||
req: OptimizeExpressionRequest,
|
||||
): Promise<OptimizedExpression> {
|
||||
this.log.debug(
|
||||
{ userId: ctx.userId, text: req.text },
|
||||
"Mock optimizeExpression",
|
||||
);
|
||||
return MOCK_OPTIMIZED_EXPRESSION;
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return { serving: true, latencyMs: 0 };
|
||||
}
|
||||
|
||||
/** mock 流式生成器(yield 3 个 chunk,模拟 SSE) */
|
||||
private async *mockStream(): AsyncIterable<StreamChatChunk> {
|
||||
for (const chunk of MOCK_STREAM_CHUNKS) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
services/teacher-bff/src/clients/ai/ai.module.ts
Normal file
34
services/teacher-bff/src/clients/ai/ai.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// ai 模块(B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
|
||||
// 端口:ai gRPC 50058(contract §2.1.6 + port-allocation.md §5)
|
||||
// 裁决依据:B2(首次实现即 gRPC)+ B8(DownstreamClient 抽象)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { AI_CLIENT } from "./ai-client.interface.js";
|
||||
import { AiGrpcClient } from "./ai-grpc.client.js";
|
||||
import { AiMockClient } from "./ai-mock.client.js";
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: AI_CLIENT,
|
||||
useFactory: () => {
|
||||
// DEV_MODE=true 或 AI_GRPC_TARGET 未配置 → 使用 mock
|
||||
if (env.TEACHER_BFF_DEV_MODE || !env.AI_GRPC_TARGET) {
|
||||
logger.warn(
|
||||
{ devMode: env.TEACHER_BFF_DEV_MODE, target: env.AI_GRPC_TARGET },
|
||||
"AiClient using mock (DEV_MODE or target not configured)",
|
||||
);
|
||||
return new AiMockClient();
|
||||
}
|
||||
logger.info(
|
||||
{ devMode: false, target: env.AI_GRPC_TARGET },
|
||||
"AiClient using gRPC",
|
||||
);
|
||||
return new AiGrpcClient();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AI_CLIENT],
|
||||
})
|
||||
export class AiModule {}
|
||||
80
services/teacher-bff/src/clients/ai/ai.types.ts
Normal file
80
services/teacher-bff/src/clients/ai/ai.types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// ai 下游服务类型定义(对齐 ai.proto)
|
||||
// ai.proto 现状:4 RPC(AiService.Chat / StreamChat / GenerateQuestion / OptimizeExpression)
|
||||
// 待 coord 补全:GenerateLessonPlan + StreamGenerateQuestion(A4 裁决)
|
||||
// 裁决依据:B2(首次实现即 gRPC)+ B8(DownstreamClient 抽象)
|
||||
|
||||
/** 聊天消息(对齐 ai.proto ChatMessage message) */
|
||||
export interface ChatMessage {
|
||||
role: string; // system / user / assistant
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Token 用量(对齐 ai.proto Usage message) */
|
||||
export interface Usage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
/** 聊天响应(对齐 ai.proto ChatResponse message) */
|
||||
export interface ChatResponse {
|
||||
content: string;
|
||||
model: string;
|
||||
usage: Usage;
|
||||
degraded: boolean;
|
||||
degradedReason: string;
|
||||
}
|
||||
|
||||
/** 流式聊天 chunk(对齐 ai.proto ChatChunk message,StreamChat RPC 返回) */
|
||||
export interface StreamChatChunk {
|
||||
content: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/** 生成的题目(对齐 ai.proto GeneratedQuestion message) */
|
||||
export interface GeneratedQuestion {
|
||||
question: string;
|
||||
answer: string;
|
||||
explanation: string;
|
||||
questionType: string;
|
||||
difficulty: string;
|
||||
knowledgePointIds: string[];
|
||||
evaluationScore?: number;
|
||||
degraded: boolean;
|
||||
degradedReason: string;
|
||||
}
|
||||
|
||||
/** 优化后的表达(对齐 ai.proto OptimizedExpression message) */
|
||||
export interface OptimizedExpression {
|
||||
optimized: string;
|
||||
suggestions: string[];
|
||||
degraded: boolean;
|
||||
degradedReason: string;
|
||||
}
|
||||
|
||||
// ===== Request 类型 =====
|
||||
|
||||
export interface ChatRequest {
|
||||
messages: ChatMessage[];
|
||||
model: string;
|
||||
temperature: number;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
dataScope?: string;
|
||||
}
|
||||
|
||||
export interface GenerateQuestionRequest {
|
||||
prompt: string;
|
||||
subject: string;
|
||||
difficulty: string; // easy / medium / hard
|
||||
grade?: string;
|
||||
knowledgePointIds: string[];
|
||||
questionType?: string; // single_choice / multi_choice / fill_blank / short_answer / essay
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface OptimizeExpressionRequest {
|
||||
text: string;
|
||||
context: string;
|
||||
}
|
||||
@@ -1,10 +1,30 @@
|
||||
// DownstreamClient 聚合模块(B8 裁决:3 个 BFF 统一使用)
|
||||
// P2 仅注册 IamModule,P3+ 扩展 CoreEduModule / ContentModule / DataAnaModule / MsgModule / AiModule
|
||||
// 注册全部 6 个客户端模块:iam + core-edu + content + data-ana + msg + ai
|
||||
// 未配置 gRPC target 的客户端自动降级为 mock(降级模式 B)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { IamModule } from "./iam/iam.module.js";
|
||||
import { CoreEduModule } from "./core-edu/core-edu.module.js";
|
||||
import { ContentModule } from "./content/content.module.js";
|
||||
import { DataAnaModule } from "./data-ana/data-ana.module.js";
|
||||
import { MsgModule } from "./msg/msg.module.js";
|
||||
import { AiModule } from "./ai/ai.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
exports: [IamModule],
|
||||
imports: [
|
||||
IamModule,
|
||||
CoreEduModule,
|
||||
ContentModule,
|
||||
DataAnaModule,
|
||||
MsgModule,
|
||||
AiModule,
|
||||
],
|
||||
exports: [
|
||||
IamModule,
|
||||
CoreEduModule,
|
||||
ContentModule,
|
||||
DataAnaModule,
|
||||
MsgModule,
|
||||
AiModule,
|
||||
],
|
||||
})
|
||||
export class ClientsModule {}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// ContentClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||
// 接口定义所有 P4+ 需要的 content RPC,实现分 gRPC + mock 两种
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC(未就绪 RPC 降级 mock + warning)
|
||||
import type { CallContext } from "../types.js";
|
||||
import type {
|
||||
Textbook,
|
||||
Chapter,
|
||||
LearningPath,
|
||||
ListTextbooksRequest,
|
||||
ListTextbooksResponse,
|
||||
CreateTextbookRequest,
|
||||
CreateTextbookResponse,
|
||||
GetPrerequisitesRequest,
|
||||
GetPrerequisitesResponse,
|
||||
GetLearningPathRequest,
|
||||
ListChaptersRequest,
|
||||
ListChaptersResponse,
|
||||
SearchQuestionsRequest,
|
||||
SearchQuestionsResponse,
|
||||
} from "./content.types.js";
|
||||
|
||||
/** ContentClient DI token(NestJS 注入用) */
|
||||
export const CONTENT_CLIENT = Symbol("CONTENT_CLIENT");
|
||||
|
||||
/** ContentClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
|
||||
export interface ContentClient {
|
||||
// ===== TextbookService(✅ 已就绪) =====
|
||||
listTextbooks(
|
||||
ctx: CallContext,
|
||||
req: ListTextbooksRequest,
|
||||
): Promise<ListTextbooksResponse>;
|
||||
getTextbook(ctx: CallContext, id: string): Promise<Textbook>;
|
||||
createTextbook(
|
||||
ctx: CallContext,
|
||||
req: CreateTextbookRequest,
|
||||
): Promise<CreateTextbookResponse>;
|
||||
|
||||
// ===== KnowledgeGraphService(✅ 已就绪) =====
|
||||
getPrerequisites(
|
||||
ctx: CallContext,
|
||||
req: GetPrerequisitesRequest,
|
||||
): Promise<GetPrerequisitesResponse>;
|
||||
getLearningPath(
|
||||
ctx: CallContext,
|
||||
req: GetLearningPathRequest,
|
||||
): Promise<LearningPath>;
|
||||
|
||||
// ===== ChapterService(❌ 未就绪,待 coord 补 N5) =====
|
||||
listChapters(
|
||||
ctx: CallContext,
|
||||
req: ListChaptersRequest,
|
||||
): Promise<ListChaptersResponse>;
|
||||
getChapter(ctx: CallContext, id: string): Promise<Chapter>;
|
||||
|
||||
// ===== QuestionService(❌ 未就绪,待 coord 补 N3) =====
|
||||
searchQuestions(
|
||||
ctx: CallContext,
|
||||
req: SearchQuestionsRequest,
|
||||
): Promise<SearchQuestionsResponse>;
|
||||
|
||||
// ===== 健康检查 =====
|
||||
checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
301
services/teacher-bff/src/clients/content/content-grpc.client.ts
Normal file
301
services/teacher-bff/src/clients/content/content-grpc.client.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
// ContentClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||
// content.proto 现状:TextbookService 3 + KnowledgeGraphService 2 已就绪
|
||||
// 未就绪 RPC(ChapterService.ListChapters / GetChapter + QuestionService.SearchQuestions)降级 mock + warning
|
||||
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 { ContentClient } from "./content-client.interface.js";
|
||||
import type {
|
||||
Textbook,
|
||||
Chapter,
|
||||
KnowledgePoint,
|
||||
LearningPath,
|
||||
ListTextbooksRequest,
|
||||
ListTextbooksResponse,
|
||||
CreateTextbookRequest,
|
||||
CreateTextbookResponse,
|
||||
GetPrerequisitesRequest,
|
||||
GetPrerequisitesResponse,
|
||||
GetLearningPathRequest,
|
||||
ListChaptersRequest,
|
||||
ListChaptersResponse,
|
||||
SearchQuestionsRequest,
|
||||
SearchQuestionsResponse,
|
||||
} from "./content.types.js";
|
||||
import { ContentMockClient } from "./content-mock.client.js";
|
||||
|
||||
/** proto message 字段是 snake_case,gRPC 返回需转 camelCase */
|
||||
function mapTextbook(raw: Record<string, unknown>): Textbook {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
title: String(raw.title ?? ""),
|
||||
subjectId: String(raw.subject_id ?? ""),
|
||||
gradeId: String(raw.grade_id ?? ""),
|
||||
version: String(raw.version ?? ""),
|
||||
status: String(raw.status ?? ""),
|
||||
tenantId: String(raw.tenant_id ?? ""),
|
||||
createdAt: String(raw.created_at ?? ""),
|
||||
updatedAt: String(raw.updated_at ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function mapKnowledgePoint(raw: Record<string, unknown>): KnowledgePoint {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
chapterId: String(raw.chapter_id ?? ""),
|
||||
title: String(raw.title ?? ""),
|
||||
description: String(raw.description ?? ""),
|
||||
difficulty: Number(raw.difficulty ?? 0),
|
||||
createdAt: String(raw.created_at ?? ""),
|
||||
updatedAt: String(raw.updated_at ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentGrpcClient
|
||||
extends BaseDownstreamClient
|
||||
implements ContentClient
|
||||
{
|
||||
readonly serviceName = "content" as const;
|
||||
private readonly mock: ContentMockClient;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
this.mock = new ContentMockClient();
|
||||
}
|
||||
|
||||
// ===== TextbookService(✅ 已就绪) =====
|
||||
|
||||
async listTextbooks(
|
||||
ctx: CallContext,
|
||||
req: ListTextbooksRequest,
|
||||
): Promise<ListTextbooksResponse> {
|
||||
return this.callGrpc("ListTextbooks", async () => {
|
||||
const client = getGrpcClient("content", "TextbookService") as unknown as {
|
||||
listTextbooks(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { textbooks?: unknown[]; next_page_token?: string },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<ListTextbooksResponse>((resolve, reject) => {
|
||||
client.listTextbooks(
|
||||
{
|
||||
subject_id: req.subjectId ?? "",
|
||||
grade_id: req.gradeId ?? "",
|
||||
page_token: req.pageToken ?? "",
|
||||
page_size: req.pageSize ?? 0,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const textbooks = (res.textbooks ?? []) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
resolve({
|
||||
textbooks: textbooks.map(mapTextbook),
|
||||
nextPageToken: String(res.next_page_token ?? ""),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getTextbook(ctx: CallContext, id: string): Promise<Textbook> {
|
||||
return this.callGrpc("GetTextbook", async () => {
|
||||
const client = getGrpcClient("content", "TextbookService") as unknown as {
|
||||
getTextbook(
|
||||
req: { id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<Textbook>((resolve, reject) => {
|
||||
client.getTextbook({ id }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapTextbook(res));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async createTextbook(
|
||||
ctx: CallContext,
|
||||
req: CreateTextbookRequest,
|
||||
): Promise<CreateTextbookResponse> {
|
||||
return this.callGrpc("CreateTextbook", async () => {
|
||||
const client = getGrpcClient("content", "TextbookService") as unknown as {
|
||||
createTextbook(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<CreateTextbookResponse>((resolve, reject) => {
|
||||
client.createTextbook(
|
||||
{
|
||||
title: req.title,
|
||||
subject_id: req.subjectId,
|
||||
grade_id: req.gradeId,
|
||||
version: req.version,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: String(res.id ?? "") });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== KnowledgeGraphService(✅ 已就绪) =====
|
||||
|
||||
async getPrerequisites(
|
||||
ctx: CallContext,
|
||||
req: GetPrerequisitesRequest,
|
||||
): Promise<GetPrerequisitesResponse> {
|
||||
return this.callGrpc("GetPrerequisites", async () => {
|
||||
const client = getGrpcClient(
|
||||
"content",
|
||||
"KnowledgeGraphService",
|
||||
) as unknown as {
|
||||
getPrerequisites(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { points?: unknown[] },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<GetPrerequisitesResponse>((resolve, reject) => {
|
||||
client.getPrerequisites(
|
||||
{
|
||||
knowledge_point_id: req.knowledgePointId,
|
||||
depth: req.depth ?? 1,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const points = (res.points ?? []) as Record<string, unknown>[];
|
||||
resolve({ points: points.map(mapKnowledgePoint) });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getLearningPath(
|
||||
ctx: CallContext,
|
||||
req: GetLearningPathRequest,
|
||||
): Promise<LearningPath> {
|
||||
return this.callGrpc("GetLearningPath", async () => {
|
||||
const client = getGrpcClient(
|
||||
"content",
|
||||
"KnowledgeGraphService",
|
||||
) as unknown as {
|
||||
getLearningPath(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { points?: unknown[]; recommended_order?: unknown[] },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<LearningPath>((resolve, reject) => {
|
||||
client.getLearningPath(
|
||||
{
|
||||
student_id: req.studentId,
|
||||
subject_id: req.subjectId,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const points = (res.points ?? []) as Record<string, unknown>[];
|
||||
const order = (res.recommended_order ?? []) as string[];
|
||||
resolve({
|
||||
points: points.map(mapKnowledgePoint),
|
||||
recommendedOrder: order,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== ChapterService(❌ 未就绪,待 coord 补 N5,降级 mock + warning) =====
|
||||
|
||||
async listChapters(
|
||||
ctx: CallContext,
|
||||
req: ListChaptersRequest,
|
||||
): Promise<ListChaptersResponse> {
|
||||
this.log.warn(
|
||||
{ rpc: "ListChapters", reason: "ChapterService not ready (N5 pending)" },
|
||||
"Downstream RPC not ready, falling back to mock",
|
||||
);
|
||||
return this.mock.listChapters(ctx, req);
|
||||
}
|
||||
|
||||
async getChapter(ctx: CallContext, id: string): Promise<Chapter> {
|
||||
this.log.warn(
|
||||
{ rpc: "GetChapter", reason: "ChapterService not ready (N5 pending)" },
|
||||
"Downstream RPC not ready, falling back to mock",
|
||||
);
|
||||
return this.mock.getChapter(ctx, id);
|
||||
}
|
||||
|
||||
// ===== QuestionService(❌ 未就绪,待 coord 补 N3,降级 mock + warning) =====
|
||||
|
||||
async searchQuestions(
|
||||
ctx: CallContext,
|
||||
req: SearchQuestionsRequest,
|
||||
): Promise<SearchQuestionsResponse> {
|
||||
this.log.warn(
|
||||
{
|
||||
rpc: "SearchQuestions",
|
||||
reason: "QuestionService not ready (N3 pending)",
|
||||
},
|
||||
"Downstream RPC not ready, falling back to mock",
|
||||
);
|
||||
return this.mock.searchQuestions(ctx, req);
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return checkGrpcHealth("content");
|
||||
}
|
||||
}
|
||||
267
services/teacher-bff/src/clients/content/content-mock.client.ts
Normal file
267
services/teacher-bff/src/clients/content/content-mock.client.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
// ContentClient 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 { ContentClient } from "./content-client.interface.js";
|
||||
import type {
|
||||
Textbook,
|
||||
Chapter,
|
||||
KnowledgePoint,
|
||||
LearningPath,
|
||||
ListTextbooksRequest,
|
||||
ListTextbooksResponse,
|
||||
CreateTextbookRequest,
|
||||
CreateTextbookResponse,
|
||||
GetPrerequisitesRequest,
|
||||
GetPrerequisitesResponse,
|
||||
GetLearningPathRequest,
|
||||
ListChaptersRequest,
|
||||
ListChaptersResponse,
|
||||
SearchQuestionsRequest,
|
||||
SearchQuestionsResponse,
|
||||
} from "./content.types.js";
|
||||
|
||||
/** mock 教材数据(contract §4.2:返回固定 5 个教材) */
|
||||
const MOCK_TEXTBOOKS: Textbook[] = [
|
||||
{
|
||||
id: "tb-001",
|
||||
title: "语文三年级上册",
|
||||
subjectId: "subject-chinese",
|
||||
gradeId: "grade-3",
|
||||
version: "v1",
|
||||
status: "PUBLISHED",
|
||||
tenantId: "tenant-001",
|
||||
createdAt: "2026-07-01T00:00:00Z",
|
||||
updatedAt: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tb-002",
|
||||
title: "数学三年级上册",
|
||||
subjectId: "subject-math",
|
||||
gradeId: "grade-3",
|
||||
version: "v1",
|
||||
status: "PUBLISHED",
|
||||
tenantId: "tenant-001",
|
||||
createdAt: "2026-07-02T00:00:00Z",
|
||||
updatedAt: "2026-07-02T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tb-003",
|
||||
title: "英语三年级上册",
|
||||
subjectId: "subject-english",
|
||||
gradeId: "grade-3",
|
||||
version: "v1",
|
||||
status: "PUBLISHED",
|
||||
tenantId: "tenant-001",
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tb-004",
|
||||
title: "科学三年级上册",
|
||||
subjectId: "subject-science",
|
||||
gradeId: "grade-3",
|
||||
version: "v1",
|
||||
status: "DRAFT",
|
||||
tenantId: "tenant-001",
|
||||
createdAt: "2026-07-04T00:00:00Z",
|
||||
updatedAt: "2026-07-04T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tb-005",
|
||||
title: "道德与法治三年级上册",
|
||||
subjectId: "subject-moral",
|
||||
gradeId: "grade-3",
|
||||
version: "v1",
|
||||
status: "PUBLISHED",
|
||||
tenantId: "tenant-001",
|
||||
createdAt: "2026-07-05T00:00:00Z",
|
||||
updatedAt: "2026-07-05T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** mock 知识点数据(contract §4.2:返回固定知识点依赖) */
|
||||
const MOCK_KNOWLEDGE_POINTS: KnowledgePoint[] = [
|
||||
{
|
||||
id: "kp-001",
|
||||
chapterId: "ch-001",
|
||||
title: "加法运算",
|
||||
description: "三位数加法",
|
||||
difficulty: 1,
|
||||
createdAt: "2026-07-01T00:00:00Z",
|
||||
updatedAt: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "kp-002",
|
||||
chapterId: "ch-001",
|
||||
title: "减法运算",
|
||||
description: "三位数减法",
|
||||
difficulty: 1,
|
||||
createdAt: "2026-07-02T00:00:00Z",
|
||||
updatedAt: "2026-07-02T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "kp-003",
|
||||
chapterId: "ch-002",
|
||||
title: "乘法口诀",
|
||||
description: "九九乘法表",
|
||||
difficulty: 2,
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "kp-004",
|
||||
chapterId: "ch-002",
|
||||
title: "除法运算",
|
||||
description: "表内除法",
|
||||
difficulty: 2,
|
||||
createdAt: "2026-07-04T00:00:00Z",
|
||||
updatedAt: "2026-07-04T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "kp-005",
|
||||
chapterId: "ch-003",
|
||||
title: "分数初步",
|
||||
description: "认识分数",
|
||||
difficulty: 3,
|
||||
createdAt: "2026-07-05T00:00:00Z",
|
||||
updatedAt: "2026-07-05T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** mock 学习路径(contract §4.2:返回固定学习路径) */
|
||||
const MOCK_LEARNING_PATH: LearningPath = {
|
||||
points: MOCK_KNOWLEDGE_POINTS,
|
||||
recommendedOrder: ["kp-001", "kp-002", "kp-003", "kp-004", "kp-005"],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ContentMockClient
|
||||
extends BaseDownstreamClient
|
||||
implements ContentClient
|
||||
{
|
||||
readonly serviceName = "content" as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
}
|
||||
|
||||
// ===== TextbookService(✅ 已就绪) =====
|
||||
|
||||
async listTextbooks(
|
||||
ctx: CallContext,
|
||||
req: ListTextbooksRequest,
|
||||
): Promise<ListTextbooksResponse> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock listTextbooks");
|
||||
let textbooks = MOCK_TEXTBOOKS;
|
||||
if (req.subjectId) {
|
||||
textbooks = textbooks.filter((t) => t.subjectId === req.subjectId);
|
||||
}
|
||||
if (req.gradeId) {
|
||||
textbooks = textbooks.filter((t) => t.gradeId === req.gradeId);
|
||||
}
|
||||
return { textbooks, nextPageToken: "" };
|
||||
}
|
||||
|
||||
async getTextbook(ctx: CallContext, id: string): Promise<Textbook> {
|
||||
this.log.debug({ userId: ctx.userId, id }, "Mock getTextbook");
|
||||
return MOCK_TEXTBOOKS[0]!;
|
||||
}
|
||||
|
||||
async createTextbook(
|
||||
_ctx: CallContext,
|
||||
req: CreateTextbookRequest,
|
||||
): Promise<CreateTextbookResponse> {
|
||||
this.log.debug({ req }, "Mock createTextbook");
|
||||
return { id: `tb-${Date.now()}` };
|
||||
}
|
||||
|
||||
// ===== KnowledgeGraphService(✅ 已就绪) =====
|
||||
|
||||
async getPrerequisites(
|
||||
ctx: CallContext,
|
||||
req: GetPrerequisitesRequest,
|
||||
): Promise<GetPrerequisitesResponse> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock getPrerequisites");
|
||||
return { points: MOCK_KNOWLEDGE_POINTS };
|
||||
}
|
||||
|
||||
async getLearningPath(
|
||||
ctx: CallContext,
|
||||
req: GetLearningPathRequest,
|
||||
): Promise<LearningPath> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock getLearningPath");
|
||||
return MOCK_LEARNING_PATH;
|
||||
}
|
||||
|
||||
// ===== ChapterService(❌ 未就绪,待 coord 补 N5,返回空数组 + warning) =====
|
||||
|
||||
async listChapters(
|
||||
ctx: CallContext,
|
||||
req: ListChaptersRequest,
|
||||
): Promise<ListChaptersResponse> {
|
||||
this.log.warn(
|
||||
{
|
||||
userId: ctx.userId,
|
||||
req,
|
||||
rpc: "ListChapters",
|
||||
reason: "ChapterService not ready (N5 pending)",
|
||||
},
|
||||
"Mock listChapters: RPC not ready, returning empty",
|
||||
);
|
||||
return { chapters: [] };
|
||||
}
|
||||
|
||||
async getChapter(ctx: CallContext, id: string): Promise<Chapter> {
|
||||
this.log.warn(
|
||||
{
|
||||
userId: ctx.userId,
|
||||
id,
|
||||
rpc: "GetChapter",
|
||||
reason: "ChapterService not ready (N5 pending)",
|
||||
},
|
||||
"Mock getChapter: RPC not ready, returning empty",
|
||||
);
|
||||
return {
|
||||
id: "",
|
||||
textbookId: "",
|
||||
title: "",
|
||||
order: 0,
|
||||
parentId: "",
|
||||
status: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
// ===== QuestionService(❌ 未就绪,待 coord 补 N3,返回空数组 + warning) =====
|
||||
|
||||
async searchQuestions(
|
||||
ctx: CallContext,
|
||||
req: SearchQuestionsRequest,
|
||||
): Promise<SearchQuestionsResponse> {
|
||||
this.log.warn(
|
||||
{
|
||||
userId: ctx.userId,
|
||||
req,
|
||||
rpc: "SearchQuestions",
|
||||
reason: "QuestionService not ready (N3 pending)",
|
||||
},
|
||||
"Mock searchQuestions: RPC not ready, returning empty",
|
||||
);
|
||||
return { questions: [], total: 0, nextPageToken: "" };
|
||||
}
|
||||
|
||||
// ===== 健康检查 =====
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return { serving: true, latencyMs: 0 };
|
||||
}
|
||||
}
|
||||
35
services/teacher-bff/src/clients/content/content.module.ts
Normal file
35
services/teacher-bff/src/clients/content/content.module.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// content 模块(B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { CONTENT_CLIENT } from "./content-client.interface.js";
|
||||
import { ContentGrpcClient } from "./content-grpc.client.js";
|
||||
import { ContentMockClient } from "./content-mock.client.js";
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: CONTENT_CLIENT,
|
||||
useFactory: () => {
|
||||
// DEV_MODE=true 或 CONTENT_GRPC_TARGET 未配置 → 使用 mock
|
||||
if (env.TEACHER_BFF_DEV_MODE || !env.CONTENT_GRPC_TARGET) {
|
||||
logger.warn(
|
||||
{
|
||||
devMode: env.TEACHER_BFF_DEV_MODE,
|
||||
target: env.CONTENT_GRPC_TARGET,
|
||||
},
|
||||
"ContentClient using mock (DEV_MODE or target not configured)",
|
||||
);
|
||||
return new ContentMockClient();
|
||||
}
|
||||
logger.info(
|
||||
{ devMode: false, target: env.CONTENT_GRPC_TARGET },
|
||||
"ContentClient using gRPC",
|
||||
);
|
||||
return new ContentGrpcClient();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [CONTENT_CLIENT],
|
||||
})
|
||||
export class ContentModule {}
|
||||
139
services/teacher-bff/src/clients/content/content.types.ts
Normal file
139
services/teacher-bff/src/clients/content/content.types.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// content 下游服务类型定义(对齐 content.proto)
|
||||
// content.proto 现状:4 Service(TextbookService 5 + ChapterService 5 + KnowledgeGraphService 4 + QuestionService 8)
|
||||
// 待 coord 补全:ChapterService(N5 裁决)+ QuestionService(N3 裁决)服务端实现
|
||||
|
||||
/** 教材(对齐 content.proto Textbook message) */
|
||||
export interface Textbook {
|
||||
id: string;
|
||||
title: string;
|
||||
subjectId: string;
|
||||
gradeId: string;
|
||||
version: string;
|
||||
status: string;
|
||||
tenantId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 章节(对齐 content.proto Chapter message) */
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
textbookId: string;
|
||||
title: string;
|
||||
order: number;
|
||||
parentId: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 知识点(对齐 content.proto KnowledgePoint message) */
|
||||
export interface KnowledgePoint {
|
||||
id: string;
|
||||
chapterId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
difficulty: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 题目(对齐 content.proto Question message) */
|
||||
export interface Question {
|
||||
id: string;
|
||||
knowledgePointId: string;
|
||||
type: string;
|
||||
content: string;
|
||||
options?: Record<string, unknown>;
|
||||
answer: string;
|
||||
explanation: string;
|
||||
difficulty: number;
|
||||
status: string;
|
||||
source: string;
|
||||
createdBy: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 学习路径(对齐 content.proto LearningPath message) */
|
||||
export interface LearningPath {
|
||||
points: KnowledgePoint[];
|
||||
recommendedOrder: string[];
|
||||
}
|
||||
|
||||
// ===== Request 类型 =====
|
||||
|
||||
export interface ListTextbooksRequest {
|
||||
subjectId?: string;
|
||||
gradeId?: string;
|
||||
pageToken?: string;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface GetTextbookRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface CreateTextbookRequest {
|
||||
title: string;
|
||||
subjectId: string;
|
||||
gradeId: string;
|
||||
version: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GetPrerequisitesRequest {
|
||||
knowledgePointId: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export interface GetLearningPathRequest {
|
||||
studentId: string;
|
||||
subjectId: string;
|
||||
}
|
||||
|
||||
export interface ListChaptersRequest {
|
||||
textbookId: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface GetChapterRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SearchQuestionsRequest {
|
||||
q: string;
|
||||
type?: string;
|
||||
difficulty?: number;
|
||||
knowledgePointId?: string;
|
||||
pageToken?: string;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
// ===== Response 类型 =====
|
||||
|
||||
export interface ListTextbooksResponse {
|
||||
textbooks: Textbook[];
|
||||
nextPageToken: string;
|
||||
}
|
||||
|
||||
export interface CreateTextbookResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface GetPrerequisitesResponse {
|
||||
points: KnowledgePoint[];
|
||||
}
|
||||
|
||||
export interface ListChaptersResponse {
|
||||
chapters: Chapter[];
|
||||
}
|
||||
|
||||
export interface SearchQuestionsResponse {
|
||||
questions: Question[];
|
||||
total: number;
|
||||
nextPageToken: string;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// CoreEduClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||
// 接口定义所有 P3+ 需要的 core-edu RPC,实现分 gRPC + mock 两种
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC(未就绪 RPC 降级 mock + warning)
|
||||
import type { CallContext } from "../types.js";
|
||||
import type {
|
||||
Exam,
|
||||
Homework,
|
||||
Grade,
|
||||
ClassInfo,
|
||||
StudentInfo,
|
||||
CreateExamRequest,
|
||||
CreateExamResponse,
|
||||
UpdateExamRequest,
|
||||
UpdateExamResponse,
|
||||
DeleteExamResponse,
|
||||
AssignHomeworkRequest,
|
||||
AssignHomeworkResponse,
|
||||
SubmitHomeworkResponse,
|
||||
RecordGradeRequest,
|
||||
RecordGradeResponse,
|
||||
ListExamsResponse,
|
||||
ListHomeworkResponse,
|
||||
ListGradesResponse,
|
||||
} from "./core-edu.types.js";
|
||||
|
||||
/** CoreEduClient DI token(NestJS 注入用) */
|
||||
export const CORE_EDU_CLIENT = Symbol("CORE_EDU_CLIENT");
|
||||
|
||||
/** CoreEduClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
|
||||
export interface CoreEduClient {
|
||||
// ===== ExamService =====
|
||||
createExam(
|
||||
ctx: CallContext,
|
||||
req: CreateExamRequest,
|
||||
): Promise<CreateExamResponse>;
|
||||
getExam(ctx: CallContext, id: string): Promise<Exam>;
|
||||
listExamsByClass(
|
||||
ctx: CallContext,
|
||||
classId: string,
|
||||
): Promise<ListExamsResponse>;
|
||||
updateExam(
|
||||
ctx: CallContext,
|
||||
req: UpdateExamRequest,
|
||||
): Promise<UpdateExamResponse>;
|
||||
deleteExam(ctx: CallContext, id: string): Promise<DeleteExamResponse>;
|
||||
|
||||
// ===== HomeworkService =====
|
||||
assignHomework(
|
||||
ctx: CallContext,
|
||||
req: AssignHomeworkRequest,
|
||||
): Promise<AssignHomeworkResponse>;
|
||||
getHomework(ctx: CallContext, id: string): Promise<Homework>;
|
||||
listHomeworkByClass(
|
||||
ctx: CallContext,
|
||||
classId: string,
|
||||
): Promise<ListHomeworkResponse>;
|
||||
submitHomework(ctx: CallContext, id: string): Promise<SubmitHomeworkResponse>;
|
||||
|
||||
// ===== GradeService =====
|
||||
recordGrade(
|
||||
ctx: CallContext,
|
||||
req: RecordGradeRequest,
|
||||
): Promise<RecordGradeResponse>;
|
||||
getGrade(ctx: CallContext, id: string): Promise<Grade>;
|
||||
listGradesByStudent(
|
||||
ctx: CallContext,
|
||||
studentId: string,
|
||||
): Promise<ListGradesResponse>;
|
||||
listGradesByExam(
|
||||
ctx: CallContext,
|
||||
examId: string,
|
||||
): Promise<ListGradesResponse>;
|
||||
listGradesByHomework(
|
||||
ctx: CallContext,
|
||||
homeworkId: string,
|
||||
): Promise<ListGradesResponse>;
|
||||
|
||||
// ===== ClassService(待 coord 补全,未就绪) =====
|
||||
getClassesByTeacher(
|
||||
ctx: CallContext,
|
||||
teacherId: string,
|
||||
): Promise<ClassInfo[]>;
|
||||
listStudentsByClass(
|
||||
ctx: CallContext,
|
||||
classId: string,
|
||||
): Promise<StudentInfo[]>;
|
||||
batchGetClasses(ctx: CallContext, classIds: string[]): Promise<ClassInfo[]>;
|
||||
|
||||
// ===== 健康检查 =====
|
||||
checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
// CoreEduClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||
// core_edu.proto 现状 14 RPC(ExamService 5 + HomeworkService 4 + GradeService 5)
|
||||
// 未就绪 RPC(ClassService.GetClassesByTeacher 等)降级 mock + warning
|
||||
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 { CoreEduClient } from "./core-edu-client.interface.js";
|
||||
import type {
|
||||
Exam,
|
||||
Homework,
|
||||
Grade,
|
||||
ClassInfo,
|
||||
StudentInfo,
|
||||
CreateExamRequest,
|
||||
CreateExamResponse,
|
||||
UpdateExamRequest,
|
||||
UpdateExamResponse,
|
||||
DeleteExamResponse,
|
||||
AssignHomeworkRequest,
|
||||
AssignHomeworkResponse,
|
||||
SubmitHomeworkResponse,
|
||||
RecordGradeRequest,
|
||||
RecordGradeResponse,
|
||||
ListExamsResponse,
|
||||
ListHomeworkResponse,
|
||||
ListGradesResponse,
|
||||
} from "./core-edu.types.js";
|
||||
import { CoreEduMockClient } from "./core-edu-mock.client.js";
|
||||
|
||||
/** proto message 字段是 snake_case,gRPC 返回需转 camelCase */
|
||||
function mapExam(raw: Record<string, unknown>): Exam {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
classId: String(raw.class_id ?? ""),
|
||||
title: String(raw.title ?? ""),
|
||||
description: String(raw.description ?? ""),
|
||||
examDate: String(raw.exam_date ?? ""),
|
||||
duration: String(raw.duration ?? ""),
|
||||
totalScore: String(raw.total_score ?? ""),
|
||||
status: String(raw.status ?? ""),
|
||||
createdBy: String(raw.created_by ?? ""),
|
||||
createdAt: String(raw.created_at ?? ""),
|
||||
updatedAt: String(raw.updated_at ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function mapHomework(raw: Record<string, unknown>): Homework {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
classId: String(raw.class_id ?? ""),
|
||||
title: String(raw.title ?? ""),
|
||||
description: String(raw.description ?? ""),
|
||||
dueDate: String(raw.due_date ?? ""),
|
||||
status: String(raw.status ?? ""),
|
||||
createdBy: String(raw.created_by ?? ""),
|
||||
createdAt: String(raw.created_at ?? ""),
|
||||
updatedAt: String(raw.updated_at ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function mapGrade(raw: Record<string, unknown>): Grade {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
studentId: String(raw.student_id ?? ""),
|
||||
examId: String(raw.exam_id ?? ""),
|
||||
homeworkId: String(raw.homework_id ?? ""),
|
||||
score: String(raw.score ?? ""),
|
||||
feedback: String(raw.feedback ?? ""),
|
||||
gradedBy: String(raw.graded_by ?? ""),
|
||||
createdAt: String(raw.created_at ?? ""),
|
||||
updatedAt: String(raw.updated_at ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CoreEduGrpcClient
|
||||
extends BaseDownstreamClient
|
||||
implements CoreEduClient
|
||||
{
|
||||
readonly serviceName = "core-edu" as const;
|
||||
private readonly mock: CoreEduMockClient;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
this.mock = new CoreEduMockClient();
|
||||
}
|
||||
|
||||
// ===== ExamService =====
|
||||
|
||||
async createExam(
|
||||
ctx: CallContext,
|
||||
req: CreateExamRequest,
|
||||
): Promise<CreateExamResponse> {
|
||||
return this.callGrpc("CreateExam", async () => {
|
||||
const client = getGrpcClient("core-edu", "ExamService") as unknown as {
|
||||
createExam(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (err: grpc.ServiceError | null, res: { id: string }) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<CreateExamResponse>((resolve, reject) => {
|
||||
client.createExam(
|
||||
{
|
||||
class_id: req.classId,
|
||||
title: req.title,
|
||||
description: req.description,
|
||||
exam_date: req.examDate,
|
||||
duration: req.duration,
|
||||
total_score: req.totalScore,
|
||||
created_by: req.createdBy,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: res.id });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getExam(ctx: CallContext, id: string): Promise<Exam> {
|
||||
return this.callGrpc("GetExam", async () => {
|
||||
const client = getGrpcClient("core-edu", "ExamService") as unknown as {
|
||||
getExam(
|
||||
req: { id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<Exam>((resolve, reject) => {
|
||||
client.getExam({ id }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapExam(res));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async listExamsByClass(
|
||||
ctx: CallContext,
|
||||
classId: string,
|
||||
): Promise<ListExamsResponse> {
|
||||
return this.callGrpc("ListExamsByClass", async () => {
|
||||
const client = getGrpcClient("core-edu", "ExamService") as unknown as {
|
||||
listExamsByClass(
|
||||
req: { class_id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { exams?: unknown[] },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<ListExamsResponse>((resolve, reject) => {
|
||||
client.listExamsByClass({ class_id: classId }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const exams = (res.exams ?? []) as Record<string, unknown>[];
|
||||
resolve({ exams: exams.map(mapExam) });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateExam(
|
||||
ctx: CallContext,
|
||||
req: UpdateExamRequest,
|
||||
): Promise<UpdateExamResponse> {
|
||||
return this.callGrpc("UpdateExam", async () => {
|
||||
const client = getGrpcClient("core-edu", "ExamService") as unknown as {
|
||||
updateExam(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { success: boolean },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<UpdateExamResponse>((resolve, reject) => {
|
||||
client.updateExam(
|
||||
{
|
||||
id: req.id,
|
||||
title: req.title ?? "",
|
||||
description: req.description ?? "",
|
||||
exam_date: req.examDate ?? "",
|
||||
duration: req.duration ?? "",
|
||||
total_score: req.totalScore ?? "",
|
||||
status: req.status ?? "",
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ success: res.success });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async deleteExam(ctx: CallContext, id: string): Promise<DeleteExamResponse> {
|
||||
return this.callGrpc("DeleteExam", async () => {
|
||||
const client = getGrpcClient("core-edu", "ExamService") as unknown as {
|
||||
deleteExam(
|
||||
req: { id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { success: boolean },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<DeleteExamResponse>((resolve, reject) => {
|
||||
client.deleteExam({ id }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ success: res.success });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== HomeworkService =====
|
||||
|
||||
async assignHomework(
|
||||
ctx: CallContext,
|
||||
req: AssignHomeworkRequest,
|
||||
): Promise<AssignHomeworkResponse> {
|
||||
return this.callGrpc("AssignHomework", async () => {
|
||||
const client = getGrpcClient(
|
||||
"core-edu",
|
||||
"HomeworkService",
|
||||
) as unknown as {
|
||||
assignHomework(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (err: grpc.ServiceError | null, res: { id: string }) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<AssignHomeworkResponse>((resolve, reject) => {
|
||||
client.assignHomework(
|
||||
{
|
||||
class_id: req.classId,
|
||||
title: req.title,
|
||||
description: req.description,
|
||||
due_date: req.dueDate,
|
||||
created_by: req.createdBy,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: res.id });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getHomework(ctx: CallContext, id: string): Promise<Homework> {
|
||||
return this.callGrpc("GetHomework", async () => {
|
||||
const client = getGrpcClient(
|
||||
"core-edu",
|
||||
"HomeworkService",
|
||||
) as unknown as {
|
||||
getHomework(
|
||||
req: { id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<Homework>((resolve, reject) => {
|
||||
client.getHomework({ id }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapHomework(res));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async listHomeworkByClass(
|
||||
ctx: CallContext,
|
||||
classId: string,
|
||||
): Promise<ListHomeworkResponse> {
|
||||
return this.callGrpc("ListHomeworkByClass", async () => {
|
||||
const client = getGrpcClient(
|
||||
"core-edu",
|
||||
"HomeworkService",
|
||||
) as unknown as {
|
||||
listHomeworkByClass(
|
||||
req: { class_id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { homework?: unknown[] },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<ListHomeworkResponse>((resolve, reject) => {
|
||||
client.listHomeworkByClass({ class_id: classId }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const homework = (res.homework ?? []) as Record<string, unknown>[];
|
||||
resolve({ homework: homework.map(mapHomework) });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async submitHomework(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
): Promise<SubmitHomeworkResponse> {
|
||||
return this.callGrpc("SubmitHomework", async () => {
|
||||
const client = getGrpcClient(
|
||||
"core-edu",
|
||||
"HomeworkService",
|
||||
) as unknown as {
|
||||
submitHomework(
|
||||
req: { id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { success: boolean },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<SubmitHomeworkResponse>((resolve, reject) => {
|
||||
client.submitHomework({ id }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ success: res.success });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== GradeService =====
|
||||
|
||||
async recordGrade(
|
||||
ctx: CallContext,
|
||||
req: RecordGradeRequest,
|
||||
): Promise<RecordGradeResponse> {
|
||||
return this.callGrpc("RecordGrade", async () => {
|
||||
const client = getGrpcClient("core-edu", "GradeService") as unknown as {
|
||||
recordGrade(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (err: grpc.ServiceError | null, res: { id: string }) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<RecordGradeResponse>((resolve, reject) => {
|
||||
client.recordGrade(
|
||||
{
|
||||
student_id: req.studentId,
|
||||
exam_id: req.examId ?? "",
|
||||
homework_id: req.homeworkId ?? "",
|
||||
score: req.score,
|
||||
feedback: req.feedback ?? "",
|
||||
graded_by: req.gradedBy,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: res.id });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getGrade(ctx: CallContext, id: string): Promise<Grade> {
|
||||
return this.callGrpc("GetGrade", async () => {
|
||||
const client = getGrpcClient("core-edu", "GradeService") as unknown as {
|
||||
getGrade(
|
||||
req: { id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<Grade>((resolve, reject) => {
|
||||
client.getGrade({ id }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapGrade(res));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async listGradesByStudent(
|
||||
ctx: CallContext,
|
||||
studentId: string,
|
||||
): Promise<ListGradesResponse> {
|
||||
return this.callGrpc("ListGradesByStudent", async () => {
|
||||
const client = getGrpcClient("core-edu", "GradeService") as unknown as {
|
||||
listGradesByStudent(
|
||||
req: { student_id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { grades?: unknown[] },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<ListGradesResponse>((resolve, reject) => {
|
||||
client.listGradesByStudent(
|
||||
{ student_id: studentId },
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const grades = (res.grades ?? []) as Record<string, unknown>[];
|
||||
resolve({ grades: grades.map(mapGrade) });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async listGradesByExam(
|
||||
ctx: CallContext,
|
||||
examId: string,
|
||||
): Promise<ListGradesResponse> {
|
||||
return this.callGrpc("ListGradesByExam", async () => {
|
||||
const client = getGrpcClient("core-edu", "GradeService") as unknown as {
|
||||
listGradesByExam(
|
||||
req: { exam_id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { grades?: unknown[] },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<ListGradesResponse>((resolve, reject) => {
|
||||
client.listGradesByExam({ exam_id: examId }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const grades = (res.grades ?? []) as Record<string, unknown>[];
|
||||
resolve({ grades: grades.map(mapGrade) });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async listGradesByHomework(
|
||||
ctx: CallContext,
|
||||
homeworkId: string,
|
||||
): Promise<ListGradesResponse> {
|
||||
return this.callGrpc("ListGradesByHomework", async () => {
|
||||
const client = getGrpcClient("core-edu", "GradeService") as unknown as {
|
||||
listGradesByHomework(
|
||||
req: { homework_id: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { grades?: unknown[] },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<ListGradesResponse>((resolve, reject) => {
|
||||
client.listGradesByHomework(
|
||||
{ homework_id: homeworkId },
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const grades = (res.grades ?? []) as Record<string, unknown>[];
|
||||
resolve({ grades: grades.map(mapGrade) });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== ClassService(proto 未定义,降级 mock + warning) =====
|
||||
|
||||
async getClassesByTeacher(
|
||||
ctx: CallContext,
|
||||
teacherId: string,
|
||||
): Promise<ClassInfo[]> {
|
||||
this.log.warn(
|
||||
{
|
||||
rpc: "GetClassesByTeacher",
|
||||
reason: "ClassService not in core_edu.proto yet",
|
||||
},
|
||||
"Downstream RPC not ready, falling back to mock",
|
||||
);
|
||||
return this.mock.getClassesByTeacher(ctx, teacherId);
|
||||
}
|
||||
|
||||
async listStudentsByClass(
|
||||
ctx: CallContext,
|
||||
classId: string,
|
||||
): Promise<StudentInfo[]> {
|
||||
this.log.warn(
|
||||
{
|
||||
rpc: "ListStudentsByClass",
|
||||
reason: "ClassService not in core_edu.proto yet",
|
||||
},
|
||||
"Downstream RPC not ready, falling back to mock",
|
||||
);
|
||||
return this.mock.listStudentsByClass(ctx, classId);
|
||||
}
|
||||
|
||||
async batchGetClasses(
|
||||
ctx: CallContext,
|
||||
classIds: string[],
|
||||
): Promise<ClassInfo[]> {
|
||||
this.log.warn(
|
||||
{
|
||||
rpc: "BatchGetClasses",
|
||||
reason: "ClassService not in core_edu.proto yet",
|
||||
},
|
||||
"Downstream RPC not ready, falling back to mock",
|
||||
);
|
||||
return this.mock.batchGetClasses(ctx, classIds);
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return checkGrpcHealth("core-edu");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// CoreEduClient 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 { CoreEduClient } from "./core-edu-client.interface.js";
|
||||
import type {
|
||||
Exam,
|
||||
Homework,
|
||||
Grade,
|
||||
ClassInfo,
|
||||
StudentInfo,
|
||||
CreateExamRequest,
|
||||
CreateExamResponse,
|
||||
UpdateExamRequest,
|
||||
UpdateExamResponse,
|
||||
DeleteExamResponse,
|
||||
AssignHomeworkRequest,
|
||||
AssignHomeworkResponse,
|
||||
SubmitHomeworkResponse,
|
||||
RecordGradeRequest,
|
||||
RecordGradeResponse,
|
||||
ListExamsResponse,
|
||||
ListHomeworkResponse,
|
||||
ListGradesResponse,
|
||||
} from "./core-edu.types.js";
|
||||
|
||||
/** mock 考试数据(contract §4.2:返回固定 5 场考试) */
|
||||
const MOCK_EXAMS: Exam[] = [
|
||||
{
|
||||
id: "exam-001",
|
||||
classId: "class-001",
|
||||
title: "语文月考",
|
||||
description: "三年级1班语文月考",
|
||||
examDate: "2026-07-15",
|
||||
duration: "90",
|
||||
totalScore: "100",
|
||||
status: "PUBLISHED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-07-01T00:00:00Z",
|
||||
updatedAt: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "exam-002",
|
||||
classId: "class-001",
|
||||
title: "数学单元测验",
|
||||
description: "三年级1班数学单元测验",
|
||||
examDate: "2026-07-18",
|
||||
duration: "60",
|
||||
totalScore: "100",
|
||||
status: "DRAFT",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-07-02T00:00:00Z",
|
||||
updatedAt: "2026-07-02T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "exam-003",
|
||||
classId: "class-002",
|
||||
title: "英语期末考试",
|
||||
description: "三年级2班英语期末考试",
|
||||
examDate: "2026-07-20",
|
||||
duration: "120",
|
||||
totalScore: "100",
|
||||
status: "PUBLISHED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "exam-004",
|
||||
classId: "class-002",
|
||||
title: "科学测验",
|
||||
description: "三年级2班科学测验",
|
||||
examDate: "2026-07-22",
|
||||
duration: "45",
|
||||
totalScore: "50",
|
||||
status: "DRAFT",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-07-04T00:00:00Z",
|
||||
updatedAt: "2026-07-04T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "exam-005",
|
||||
classId: "class-003",
|
||||
title: "综合测试",
|
||||
description: "三年级3班综合测试",
|
||||
examDate: "2026-07-25",
|
||||
duration: "90",
|
||||
totalScore: "100",
|
||||
status: "PUBLISHED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-07-05T00:00:00Z",
|
||||
updatedAt: "2026-07-05T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** mock 作业数据(contract §4.2:返回固定 5 份作业) */
|
||||
const MOCK_HOMEWORK: Homework[] = [
|
||||
{
|
||||
id: "hw-001",
|
||||
classId: "class-001",
|
||||
title: "语文作业1",
|
||||
description: "阅读理解练习",
|
||||
dueDate: "2026-07-16",
|
||||
status: "PUBLISHED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-07-01T00:00:00Z",
|
||||
updatedAt: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "hw-002",
|
||||
classId: "class-001",
|
||||
title: "数学作业1",
|
||||
description: "加减法练习",
|
||||
dueDate: "2026-07-17",
|
||||
status: "PUBLISHED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-07-02T00:00:00Z",
|
||||
updatedAt: "2026-07-02T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "hw-003",
|
||||
classId: "class-002",
|
||||
title: "英语作业1",
|
||||
description: "单词默写",
|
||||
dueDate: "2026-07-18",
|
||||
status: "PUBLISHED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "hw-004",
|
||||
classId: "class-002",
|
||||
title: "科学作业1",
|
||||
description: "观察记录",
|
||||
dueDate: "2026-07-19",
|
||||
status: "PUBLISHED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-07-04T00:00:00Z",
|
||||
updatedAt: "2026-07-04T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "hw-005",
|
||||
classId: "class-003",
|
||||
title: "综合作业1",
|
||||
description: "期末复习",
|
||||
dueDate: "2026-07-20",
|
||||
status: "PUBLISHED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-07-05T00:00:00Z",
|
||||
updatedAt: "2026-07-05T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** mock 成绩数据(contract §4.2:返回固定 10 条成绩) */
|
||||
const MOCK_GRADES: Grade[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `grade-${String(i + 1).padStart(3, "0")}`,
|
||||
studentId: `student-${String(i + 1).padStart(3, "0")}`,
|
||||
examId: i < 5 ? "exam-001" : "exam-003",
|
||||
homeworkId: "",
|
||||
score: String(80 + (i % 20)),
|
||||
feedback: i % 2 === 0 ? "表现良好" : "需要努力",
|
||||
gradedBy: "teacher-001",
|
||||
createdAt: "2026-07-10T00:00:00Z",
|
||||
updatedAt: "2026-07-10T00:00:00Z",
|
||||
}));
|
||||
|
||||
/** mock 班级数据(contract §4.2:返回固定 3 个 ClassInfo) */
|
||||
const MOCK_CLASSES: ClassInfo[] = [
|
||||
{ id: "class-001", name: "三年级1班", gradeId: "grade-3", studentCount: 30 },
|
||||
{ id: "class-002", name: "三年级2班", gradeId: "grade-3", studentCount: 28 },
|
||||
{ id: "class-003", name: "三年级3班", gradeId: "grade-3", studentCount: 32 },
|
||||
];
|
||||
|
||||
/** mock 学生数据(contract §4.2:返回固定 30 个 StudentInfo) */
|
||||
const MOCK_STUDENTS: StudentInfo[] = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: `student-${String(i + 1).padStart(3, "0")}`,
|
||||
name: `学生${String(i + 1).padStart(2, "0")}`,
|
||||
classId: "class-001",
|
||||
}));
|
||||
|
||||
@Injectable()
|
||||
export class CoreEduMockClient
|
||||
extends BaseDownstreamClient
|
||||
implements CoreEduClient
|
||||
{
|
||||
readonly serviceName = "core-edu" as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
}
|
||||
|
||||
async createExam(
|
||||
_ctx: CallContext,
|
||||
req: CreateExamRequest,
|
||||
): Promise<CreateExamResponse> {
|
||||
this.log.debug({ req }, "Mock createExam");
|
||||
return { id: `exam-${Date.now()}` };
|
||||
}
|
||||
|
||||
async getExam(ctx: CallContext, id: string): Promise<Exam> {
|
||||
this.log.debug({ userId: ctx.userId, id }, "Mock getExam");
|
||||
return MOCK_EXAMS[0]!;
|
||||
}
|
||||
|
||||
async listExamsByClass(
|
||||
ctx: CallContext,
|
||||
classId: string,
|
||||
): Promise<ListExamsResponse> {
|
||||
this.log.debug({ userId: ctx.userId, classId }, "Mock listExamsByClass");
|
||||
return { exams: MOCK_EXAMS.filter((e) => e.classId === classId) };
|
||||
}
|
||||
|
||||
async updateExam(
|
||||
_ctx: CallContext,
|
||||
_req: UpdateExamRequest,
|
||||
): Promise<UpdateExamResponse> {
|
||||
this.log.debug({ req: _req }, "Mock updateExam");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async deleteExam(ctx: CallContext, id: string): Promise<DeleteExamResponse> {
|
||||
this.log.debug({ userId: ctx.userId, id }, "Mock deleteExam");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async assignHomework(
|
||||
_ctx: CallContext,
|
||||
req: AssignHomeworkRequest,
|
||||
): Promise<AssignHomeworkResponse> {
|
||||
this.log.debug({ req }, "Mock assignHomework");
|
||||
return { id: `hw-${Date.now()}` };
|
||||
}
|
||||
|
||||
async getHomework(ctx: CallContext, id: string): Promise<Homework> {
|
||||
this.log.debug({ userId: ctx.userId, id }, "Mock getHomework");
|
||||
return MOCK_HOMEWORK[0]!;
|
||||
}
|
||||
|
||||
async listHomeworkByClass(
|
||||
ctx: CallContext,
|
||||
classId: string,
|
||||
): Promise<ListHomeworkResponse> {
|
||||
this.log.debug({ userId: ctx.userId, classId }, "Mock listHomeworkByClass");
|
||||
return { homework: MOCK_HOMEWORK.filter((h) => h.classId === classId) };
|
||||
}
|
||||
|
||||
async submitHomework(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
): Promise<SubmitHomeworkResponse> {
|
||||
this.log.debug({ userId: ctx.userId, id }, "Mock submitHomework");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async recordGrade(
|
||||
_ctx: CallContext,
|
||||
req: RecordGradeRequest,
|
||||
): Promise<RecordGradeResponse> {
|
||||
this.log.debug({ req }, "Mock recordGrade");
|
||||
return { id: `grade-${Date.now()}` };
|
||||
}
|
||||
|
||||
async getGrade(ctx: CallContext, id: string): Promise<Grade> {
|
||||
this.log.debug({ userId: ctx.userId, id }, "Mock getGrade");
|
||||
return MOCK_GRADES[0]!;
|
||||
}
|
||||
|
||||
async listGradesByStudent(
|
||||
ctx: CallContext,
|
||||
studentId: string,
|
||||
): Promise<ListGradesResponse> {
|
||||
this.log.debug(
|
||||
{ userId: ctx.userId, studentId },
|
||||
"Mock listGradesByStudent",
|
||||
);
|
||||
return { grades: MOCK_GRADES.filter((g) => g.studentId === studentId) };
|
||||
}
|
||||
|
||||
async listGradesByExam(
|
||||
ctx: CallContext,
|
||||
examId: string,
|
||||
): Promise<ListGradesResponse> {
|
||||
this.log.debug({ userId: ctx.userId, examId }, "Mock listGradesByExam");
|
||||
return { grades: MOCK_GRADES.filter((g) => g.examId === examId) };
|
||||
}
|
||||
|
||||
async listGradesByHomework(
|
||||
ctx: CallContext,
|
||||
homeworkId: string,
|
||||
): Promise<ListGradesResponse> {
|
||||
this.log.debug(
|
||||
{ userId: ctx.userId, homeworkId },
|
||||
"Mock listGradesByHomework",
|
||||
);
|
||||
return { grades: MOCK_GRADES.filter((g) => g.homeworkId === homeworkId) };
|
||||
}
|
||||
|
||||
async getClassesByTeacher(
|
||||
ctx: CallContext,
|
||||
teacherId: string,
|
||||
): Promise<ClassInfo[]> {
|
||||
this.log.debug(
|
||||
{ userId: ctx.userId, teacherId },
|
||||
"Mock getClassesByTeacher",
|
||||
);
|
||||
return MOCK_CLASSES;
|
||||
}
|
||||
|
||||
async listStudentsByClass(
|
||||
ctx: CallContext,
|
||||
classId: string,
|
||||
): Promise<StudentInfo[]> {
|
||||
this.log.debug({ userId: ctx.userId, classId }, "Mock listStudentsByClass");
|
||||
return MOCK_STUDENTS.filter((s) => s.classId === classId);
|
||||
}
|
||||
|
||||
async batchGetClasses(
|
||||
ctx: CallContext,
|
||||
classIds: string[],
|
||||
): Promise<ClassInfo[]> {
|
||||
this.log.debug({ userId: ctx.userId, classIds }, "Mock batchGetClasses");
|
||||
return MOCK_CLASSES.filter((c) => classIds.includes(c.id));
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return { serving: true, latencyMs: 0 };
|
||||
}
|
||||
}
|
||||
35
services/teacher-bff/src/clients/core-edu/core-edu.module.ts
Normal file
35
services/teacher-bff/src/clients/core-edu/core-edu.module.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// core-edu 模块(B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { CORE_EDU_CLIENT } from "./core-edu-client.interface.js";
|
||||
import { CoreEduGrpcClient } from "./core-edu-grpc.client.js";
|
||||
import { CoreEduMockClient } from "./core-edu-mock.client.js";
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: CORE_EDU_CLIENT,
|
||||
useFactory: () => {
|
||||
// DEV_MODE=true 或 CORE_EDU_GRPC_TARGET 未配置 → 使用 mock
|
||||
if (env.TEACHER_BFF_DEV_MODE || !env.CORE_EDU_GRPC_TARGET) {
|
||||
logger.warn(
|
||||
{
|
||||
devMode: env.TEACHER_BFF_DEV_MODE,
|
||||
target: env.CORE_EDU_GRPC_TARGET,
|
||||
},
|
||||
"CoreEduClient using mock (DEV_MODE or target not configured)",
|
||||
);
|
||||
return new CoreEduMockClient();
|
||||
}
|
||||
logger.info(
|
||||
{ devMode: false, target: env.CORE_EDU_GRPC_TARGET },
|
||||
"CoreEduClient using gRPC",
|
||||
);
|
||||
return new CoreEduGrpcClient();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [CORE_EDU_CLIENT],
|
||||
})
|
||||
export class CoreEduModule {}
|
||||
136
services/teacher-bff/src/clients/core-edu/core-edu.types.ts
Normal file
136
services/teacher-bff/src/clients/core-edu/core-edu.types.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// core-edu 下游服务类型定义(对齐 core_edu.proto)
|
||||
// core_edu.proto 现状:14 RPC(ExamService 5 + HomeworkService 4 + GradeService 5)
|
||||
// 待 coord 补全:ClassService(GetClassesByTeacher / ListStudentsByClass / BatchGetClasses)+ AttendanceService
|
||||
|
||||
/** 考试(对齐 core_edu.proto Exam message) */
|
||||
export interface Exam {
|
||||
id: string;
|
||||
classId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
examDate: string;
|
||||
duration: string;
|
||||
totalScore: string;
|
||||
status: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 作业(对齐 core_edu.proto Homework message) */
|
||||
export interface Homework {
|
||||
id: string;
|
||||
classId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
dueDate: string;
|
||||
status: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 成绩(对齐 core_edu.proto Grade message) */
|
||||
export interface Grade {
|
||||
id: string;
|
||||
studentId: string;
|
||||
examId: string;
|
||||
homeworkId: string;
|
||||
score: string;
|
||||
feedback: string;
|
||||
gradedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 班级信息(待 coord 补 ClassService.GetClassesByTeacher RPC) */
|
||||
export interface ClassInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
gradeId: string;
|
||||
studentCount: number;
|
||||
}
|
||||
|
||||
/** 学生信息(待 coord 补 ClassService.ListStudentsByClass RPC) */
|
||||
export interface StudentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
classId: string;
|
||||
}
|
||||
|
||||
// ===== Request 类型 =====
|
||||
|
||||
export interface CreateExamRequest {
|
||||
classId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
examDate: string;
|
||||
duration: string;
|
||||
totalScore: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface UpdateExamRequest {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
examDate?: string;
|
||||
duration?: string;
|
||||
totalScore?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface AssignHomeworkRequest {
|
||||
classId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
dueDate: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface RecordGradeRequest {
|
||||
studentId: string;
|
||||
examId?: string;
|
||||
homeworkId?: string;
|
||||
score: string;
|
||||
feedback?: string;
|
||||
gradedBy: string;
|
||||
}
|
||||
|
||||
// ===== Response 类型 =====
|
||||
|
||||
export interface CreateExamResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface UpdateExamResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteExamResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface AssignHomeworkResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SubmitHomeworkResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface RecordGradeResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ListExamsResponse {
|
||||
exams: Exam[];
|
||||
}
|
||||
|
||||
export interface ListHomeworkResponse {
|
||||
homework: Homework[];
|
||||
}
|
||||
|
||||
export interface ListGradesResponse {
|
||||
grades: Grade[];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// DataAnaClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||
// 接口定义所有 P4 需要的 data-ana RPC,实现分 gRPC + mock 两种
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC
|
||||
import type { CallContext } from "../types.js";
|
||||
import type {
|
||||
ClassPerformance,
|
||||
StudentWeakness,
|
||||
LearningTrend,
|
||||
TeacherDashboardStats,
|
||||
GetClassPerformanceRequest,
|
||||
GetStudentWeaknessRequest,
|
||||
GetLearningTrendRequest,
|
||||
GetTeacherDashboardRequest,
|
||||
} from "./data-ana.types.js";
|
||||
|
||||
/** DataAnaClient DI token(NestJS 注入用) */
|
||||
export const DATA_ANA_CLIENT = Symbol("DATA_ANA_CLIENT");
|
||||
|
||||
/** DataAnaClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
|
||||
export interface DataAnaClient {
|
||||
// ===== AnalyticsService =====
|
||||
getClassPerformance(
|
||||
ctx: CallContext,
|
||||
req: GetClassPerformanceRequest,
|
||||
): Promise<ClassPerformance>;
|
||||
getStudentWeakness(
|
||||
ctx: CallContext,
|
||||
req: GetStudentWeaknessRequest,
|
||||
): Promise<StudentWeakness>;
|
||||
getLearningTrend(
|
||||
ctx: CallContext,
|
||||
req: GetLearningTrendRequest,
|
||||
): Promise<LearningTrend>;
|
||||
getTeacherDashboard(
|
||||
ctx: CallContext,
|
||||
req: GetTeacherDashboardRequest,
|
||||
): Promise<TeacherDashboardStats>;
|
||||
|
||||
// ===== 健康检查 =====
|
||||
checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// DataAnaClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||
// analytics.proto 现状 12 RPC(AnalyticsService),BFF 消费 4 个
|
||||
// proto 字段是 snake_case,gRPC 返回需转 camelCase
|
||||
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 { DataAnaClient } from "./data-ana-client.interface.js";
|
||||
import type {
|
||||
ClassPerformance,
|
||||
StudentWeakness,
|
||||
LearningTrend,
|
||||
TeacherDashboardStats,
|
||||
StudentScore,
|
||||
WeakPoint,
|
||||
TrendPoint,
|
||||
GetClassPerformanceRequest,
|
||||
GetStudentWeaknessRequest,
|
||||
GetLearningTrendRequest,
|
||||
GetTeacherDashboardRequest,
|
||||
} from "./data-ana.types.js";
|
||||
|
||||
/** proto message 字段是 snake_case,gRPC 返回需转 camelCase */
|
||||
function mapStudentScore(raw: Record<string, unknown>): StudentScore {
|
||||
return {
|
||||
studentId: String(raw.student_id ?? ""),
|
||||
score: Number(raw.score ?? 0),
|
||||
grade: String(raw.grade ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function mapClassPerformance(raw: Record<string, unknown>): ClassPerformance {
|
||||
const scores = (raw.scores ?? []) as Record<string, unknown>[];
|
||||
return {
|
||||
classId: String(raw.class_id ?? ""),
|
||||
averageScore: Number(raw.average_score ?? 0),
|
||||
passRate: Number(raw.pass_rate ?? 0),
|
||||
totalStudents: Number(raw.total_students ?? 0),
|
||||
scores: scores.map(mapStudentScore),
|
||||
};
|
||||
}
|
||||
|
||||
function mapWeakPoint(raw: Record<string, unknown>): WeakPoint {
|
||||
return {
|
||||
knowledgePointId: String(raw.knowledge_point_id ?? ""),
|
||||
title: String(raw.title ?? ""),
|
||||
mastery: Number(raw.mastery ?? 0),
|
||||
errorCount: Number(raw.error_count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function mapStudentWeakness(raw: Record<string, unknown>): StudentWeakness {
|
||||
const weakPoints = (raw.weak_points ?? []) as Record<string, unknown>[];
|
||||
return {
|
||||
studentId: String(raw.student_id ?? ""),
|
||||
weakPoints: weakPoints.map(mapWeakPoint),
|
||||
};
|
||||
}
|
||||
|
||||
function mapTrendPoint(raw: Record<string, unknown>): TrendPoint {
|
||||
return {
|
||||
date: String(raw.date ?? ""),
|
||||
score: Number(raw.score ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function mapLearningTrend(raw: Record<string, unknown>): LearningTrend {
|
||||
const points = (raw.points ?? []) as Record<string, unknown>[];
|
||||
return {
|
||||
studentId: String(raw.student_id ?? ""),
|
||||
points: points.map(mapTrendPoint),
|
||||
};
|
||||
}
|
||||
|
||||
/** proto TeacherDashboard → BFF TeacherDashboardStats(简化视图)
|
||||
* proto pending_homework_count → todayHomework;totalExams/pendingGrading 不在 data-ana proto,需 BFF 跨服务聚合,此处返 0 */
|
||||
function mapTeacherDashboardStats(
|
||||
raw: Record<string, unknown>,
|
||||
): TeacherDashboardStats {
|
||||
return {
|
||||
totalExams: 0,
|
||||
pendingGrading: 0,
|
||||
todayHomework: Number(raw.pending_homework_count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DataAnaGrpcClient
|
||||
extends BaseDownstreamClient
|
||||
implements DataAnaClient
|
||||
{
|
||||
readonly serviceName = "data-ana" as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
}
|
||||
|
||||
// ===== AnalyticsService =====
|
||||
|
||||
async getClassPerformance(
|
||||
ctx: CallContext,
|
||||
req: GetClassPerformanceRequest,
|
||||
): Promise<ClassPerformance> {
|
||||
return this.callGrpc("GetClassPerformance", async () => {
|
||||
const client = getGrpcClient(
|
||||
"data-ana",
|
||||
"AnalyticsService",
|
||||
) as unknown as {
|
||||
getClassPerformance(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<ClassPerformance>((resolve, reject) => {
|
||||
client.getClassPerformance(
|
||||
{
|
||||
class_id: req.classId,
|
||||
subject_id: req.subjectId,
|
||||
start_date: req.startDate,
|
||||
end_date: req.endDate,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapClassPerformance(res));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getStudentWeakness(
|
||||
ctx: CallContext,
|
||||
req: GetStudentWeaknessRequest,
|
||||
): Promise<StudentWeakness> {
|
||||
return this.callGrpc("GetStudentWeakness", async () => {
|
||||
const client = getGrpcClient(
|
||||
"data-ana",
|
||||
"AnalyticsService",
|
||||
) as unknown as {
|
||||
getStudentWeakness(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<StudentWeakness>((resolve, reject) => {
|
||||
client.getStudentWeakness(
|
||||
{
|
||||
student_id: req.studentId,
|
||||
subject_id: req.subjectId,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapStudentWeakness(res));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getLearningTrend(
|
||||
ctx: CallContext,
|
||||
req: GetLearningTrendRequest,
|
||||
): Promise<LearningTrend> {
|
||||
return this.callGrpc("GetLearningTrend", async () => {
|
||||
const client = getGrpcClient(
|
||||
"data-ana",
|
||||
"AnalyticsService",
|
||||
) as unknown as {
|
||||
getLearningTrend(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<LearningTrend>((resolve, reject) => {
|
||||
client.getLearningTrend(
|
||||
{
|
||||
student_id: req.studentId,
|
||||
start_date: req.startDate,
|
||||
end_date: req.endDate,
|
||||
subject_id: req.subjectId,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapLearningTrend(res));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getTeacherDashboard(
|
||||
ctx: CallContext,
|
||||
req: GetTeacherDashboardRequest,
|
||||
): Promise<TeacherDashboardStats> {
|
||||
return this.callGrpc("GetTeacherDashboard", async () => {
|
||||
const client = getGrpcClient(
|
||||
"data-ana",
|
||||
"AnalyticsService",
|
||||
) as unknown as {
|
||||
getTeacherDashboard(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<TeacherDashboardStats>((resolve, reject) => {
|
||||
client.getTeacherDashboard(
|
||||
{
|
||||
user_id: req.userId,
|
||||
class_id: req.classId ?? "",
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(mapTeacherDashboardStats(res));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return checkGrpcHealth("data-ana");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// DataAnaClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||
// DEV_MODE=true 时全部走 mock;DEV_MODE=false 时走 gRPC
|
||||
// mock 数据对齐 contract §4.2 mock 策略
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { BaseDownstreamClient } from "../base.client.js";
|
||||
import type { CallContext } from "../types.js";
|
||||
import type { DataAnaClient } from "./data-ana-client.interface.js";
|
||||
import type {
|
||||
ClassPerformance,
|
||||
StudentWeakness,
|
||||
LearningTrend,
|
||||
TeacherDashboardStats,
|
||||
GetClassPerformanceRequest,
|
||||
GetStudentWeaknessRequest,
|
||||
GetLearningTrendRequest,
|
||||
GetTeacherDashboardRequest,
|
||||
} from "./data-ana.types.js";
|
||||
|
||||
/** mock 班级成绩分析(contract §4.2:返回固定分析数据) */
|
||||
const MOCK_CLASS_PERFORMANCE: ClassPerformance = {
|
||||
classId: "class-001",
|
||||
averageScore: 82.5,
|
||||
passRate: 0.85,
|
||||
totalStudents: 30,
|
||||
scores: Array.from({ length: 30 }, (_, i) => ({
|
||||
studentId: `student-${String(i + 1).padStart(3, "0")}`,
|
||||
score: 70 + ((i * 7) % 30),
|
||||
grade: i % 3 === 0 ? "A" : i % 3 === 1 ? "B" : "C",
|
||||
})),
|
||||
};
|
||||
|
||||
/** mock 学生薄弱点(contract §4.2:返回固定 3 个薄弱知识点) */
|
||||
const MOCK_STUDENT_WEAKNESS: StudentWeakness = {
|
||||
studentId: "student-001",
|
||||
weakPoints: [
|
||||
{
|
||||
knowledgePointId: "kp-001",
|
||||
title: "分数加减法",
|
||||
mastery: 0.35,
|
||||
errorCount: 8,
|
||||
},
|
||||
{
|
||||
knowledgePointId: "kp-002",
|
||||
title: "几何图形面积",
|
||||
mastery: 0.42,
|
||||
errorCount: 6,
|
||||
},
|
||||
{
|
||||
knowledgePointId: "kp-003",
|
||||
title: "应用题理解",
|
||||
mastery: 0.5,
|
||||
errorCount: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** mock 学习趋势(contract §4.2:返回固定 12 个月趋势) */
|
||||
const MOCK_LEARNING_TREND: LearningTrend = {
|
||||
studentId: "student-001",
|
||||
points: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: String(Date.now() - (11 - i) * 30 * 24 * 60 * 60),
|
||||
score: 70 + i * 1.5,
|
||||
})),
|
||||
};
|
||||
|
||||
/** mock 教师仪表盘(contract §4.2:返回固定仪表盘数据) */
|
||||
const MOCK_TEACHER_DASHBOARD: TeacherDashboardStats = {
|
||||
totalExams: 5,
|
||||
pendingGrading: 3,
|
||||
todayHomework: 2,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DataAnaMockClient
|
||||
extends BaseDownstreamClient
|
||||
implements DataAnaClient
|
||||
{
|
||||
readonly serviceName = "data-ana" as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
}
|
||||
|
||||
async getClassPerformance(
|
||||
ctx: CallContext,
|
||||
req: GetClassPerformanceRequest,
|
||||
): Promise<ClassPerformance> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock getClassPerformance");
|
||||
return MOCK_CLASS_PERFORMANCE;
|
||||
}
|
||||
|
||||
async getStudentWeakness(
|
||||
ctx: CallContext,
|
||||
req: GetStudentWeaknessRequest,
|
||||
): Promise<StudentWeakness> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock getStudentWeakness");
|
||||
return MOCK_STUDENT_WEAKNESS;
|
||||
}
|
||||
|
||||
async getLearningTrend(
|
||||
ctx: CallContext,
|
||||
req: GetLearningTrendRequest,
|
||||
): Promise<LearningTrend> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock getLearningTrend");
|
||||
return MOCK_LEARNING_TREND;
|
||||
}
|
||||
|
||||
async getTeacherDashboard(
|
||||
ctx: CallContext,
|
||||
req: GetTeacherDashboardRequest,
|
||||
): Promise<TeacherDashboardStats> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock getTeacherDashboard");
|
||||
return MOCK_TEACHER_DASHBOARD;
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return { serving: true, latencyMs: 0 };
|
||||
}
|
||||
}
|
||||
35
services/teacher-bff/src/clients/data-ana/data-ana.module.ts
Normal file
35
services/teacher-bff/src/clients/data-ana/data-ana.module.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// data-ana 模块(B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { DATA_ANA_CLIENT } from "./data-ana-client.interface.js";
|
||||
import { DataAnaGrpcClient } from "./data-ana-grpc.client.js";
|
||||
import { DataAnaMockClient } from "./data-ana-mock.client.js";
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATA_ANA_CLIENT,
|
||||
useFactory: () => {
|
||||
// DEV_MODE=true 或 DATA_ANA_GRPC_TARGET 未配置 → 使用 mock
|
||||
if (env.TEACHER_BFF_DEV_MODE || !env.DATA_ANA_GRPC_TARGET) {
|
||||
logger.warn(
|
||||
{
|
||||
devMode: env.TEACHER_BFF_DEV_MODE,
|
||||
target: env.DATA_ANA_GRPC_TARGET,
|
||||
},
|
||||
"DataAnaClient using mock (DEV_MODE or target not configured)",
|
||||
);
|
||||
return new DataAnaMockClient();
|
||||
}
|
||||
logger.info(
|
||||
{ devMode: false, target: env.DATA_ANA_GRPC_TARGET },
|
||||
"DataAnaClient using gRPC",
|
||||
);
|
||||
return new DataAnaGrpcClient();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [DATA_ANA_CLIENT],
|
||||
})
|
||||
export class DataAnaModule {}
|
||||
81
services/teacher-bff/src/clients/data-ana/data-ana.types.ts
Normal file
81
services/teacher-bff/src/clients/data-ana/data-ana.types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// data-ana 下游服务类型定义(对齐 analytics.proto)
|
||||
// analytics.proto 现状:12 RPC(AnalyticsService),ISSUE-027 已补全 GetTeacherDashboard
|
||||
// BFF 消费 4 个 RPC:GetClassPerformance / GetStudentWeakness / GetLearningTrend / GetTeacherDashboard
|
||||
// 裁决依据:B8(DownstreamClient 抽象)+ B2(gRPC 首次实现)
|
||||
|
||||
/** 学生单科成绩(对齐 analytics.proto StudentScore message) */
|
||||
export interface StudentScore {
|
||||
studentId: string;
|
||||
score: number;
|
||||
grade: string;
|
||||
}
|
||||
|
||||
/** 班级成绩分析(对齐 analytics.proto ClassPerformance message) */
|
||||
export interface ClassPerformance {
|
||||
classId: string;
|
||||
averageScore: number;
|
||||
passRate: number;
|
||||
totalStudents: number;
|
||||
scores: StudentScore[];
|
||||
}
|
||||
|
||||
/** 薄弱知识点(对齐 analytics.proto WeakPoint message) */
|
||||
export interface WeakPoint {
|
||||
knowledgePointId: string;
|
||||
title: string;
|
||||
mastery: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
/** 学生薄弱点(对齐 analytics.proto StudentWeakness message) */
|
||||
export interface StudentWeakness {
|
||||
studentId: string;
|
||||
weakPoints: WeakPoint[];
|
||||
}
|
||||
|
||||
/** 趋势数据点(对齐 analytics.proto TrendPoint message,int64 date 经 proto-loader 转为 string) */
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** 学习趋势(对齐 analytics.proto LearningTrend message) */
|
||||
export interface LearningTrend {
|
||||
studentId: string;
|
||||
points: TrendPoint[];
|
||||
}
|
||||
|
||||
/** 教师仪表盘统计(BFF 简化视图)
|
||||
* proto TeacherDashboard 含 total_classes/total_students/class_avg_score/pending_homework_count 等字段,
|
||||
* BFF 仪表盘仅需 exam/grading/homework 三项快速统计(contract §4.2 mock 策略) */
|
||||
export interface TeacherDashboardStats {
|
||||
totalExams: number;
|
||||
pendingGrading: number;
|
||||
todayHomework: number;
|
||||
}
|
||||
|
||||
// ===== Request 类型 =====
|
||||
|
||||
export interface GetClassPerformanceRequest {
|
||||
classId: string;
|
||||
subjectId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export interface GetStudentWeaknessRequest {
|
||||
studentId: string;
|
||||
subjectId: string;
|
||||
}
|
||||
|
||||
export interface GetLearningTrendRequest {
|
||||
studentId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
subjectId: string;
|
||||
}
|
||||
|
||||
export interface GetTeacherDashboardRequest {
|
||||
userId: string;
|
||||
classId?: string;
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
// IamClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||
// 接口定义所有 P2+ 需要的 iam RPC,实现分 gRPC + mock 两种
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC(未就绪 RPC 降级 mock + warning)
|
||||
// 接口定义所有 P2+ 需要的 iam RPC + REST 端点
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC/REST 真实调用
|
||||
// 实现策略:getUserInfo 走 gRPC,其余走 REST(iam.proto 仅 6 RPC,无 roles/permissions/audit)
|
||||
import type { CallContext } from "../types.js";
|
||||
import type {
|
||||
UserInfo,
|
||||
ViewportItem,
|
||||
EffectivePermissions,
|
||||
IamRole,
|
||||
IamPermission,
|
||||
IamAuditLog,
|
||||
IamUser,
|
||||
IamUserPage,
|
||||
ListUsersRequest,
|
||||
UpdateUserRequest,
|
||||
QueryAuditLogRequest,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
UpdateRolePermissionsRequest,
|
||||
} from "./iam.types.js";
|
||||
|
||||
/** IamClient DI token(NestJS 注入用) */
|
||||
@@ -13,15 +25,67 @@ export const IAM_CLIENT = Symbol("IAM_CLIENT");
|
||||
|
||||
/** IamClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
|
||||
export interface IamClient {
|
||||
// ===== gRPC 方法(iam.proto 已声明) =====
|
||||
|
||||
/** 获取用户信息(iam.proto GetUserInfo,✅ 已就绪) */
|
||||
getUserInfo(ctx: CallContext): Promise<UserInfo>;
|
||||
|
||||
/** 获取教师导航菜单(iam.proto GetViewports,❌ 待 coord 补全) */
|
||||
/** 获取教师导航菜单(iam REST GET /v1/iam/viewports,✅ 已就绪) */
|
||||
getViewports(ctx: CallContext): Promise<ViewportItem[]>;
|
||||
|
||||
/** 获取有效权限集(iam.proto GetEffectivePermissions,❌ 待 coord 补全) */
|
||||
/** 获取有效权限集(iam REST GET /v1/iam/permissions/effective,✅ 已就绪) */
|
||||
getEffectivePermissions(ctx: CallContext): Promise<EffectivePermissions>;
|
||||
|
||||
// ===== REST 方法(admin 域需要) =====
|
||||
|
||||
/** 获取用户列表(iam REST GET /v1/iam/users,✅ 已就绪) */
|
||||
listUsers(ctx: CallContext, req: ListUsersRequest): Promise<IamUserPage>;
|
||||
|
||||
/** 更新用户(iam REST PATCH /v1/iam/users/:id,✅ 已就绪) */
|
||||
updateUser(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
input: UpdateUserRequest,
|
||||
): Promise<IamUser>;
|
||||
|
||||
/** 切换用户状态(iam REST PATCH /v1/iam/users/:id/status,✅ 已就绪) */
|
||||
toggleUserStatus(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
status: string,
|
||||
): Promise<IamUser>;
|
||||
|
||||
/** 获取全部角色(iam REST GET /v1/iam/roles,✅ 已就绪) */
|
||||
getAllRoles(ctx: CallContext): Promise<IamRole[]>;
|
||||
|
||||
/** 创建角色(iam REST POST /v1/iam/roles,✅ 已就绪) */
|
||||
createRole(ctx: CallContext, input: CreateRoleRequest): Promise<IamRole>;
|
||||
|
||||
/** 更新角色(iam REST PATCH /v1/iam/roles/:id,✅ 已就绪) */
|
||||
updateRole(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
input: UpdateRoleRequest,
|
||||
): Promise<IamRole>;
|
||||
|
||||
/** 更新角色权限(iam REST PATCH /v1/iam/roles/:id/permissions,✅ 已就绪) */
|
||||
updateRolePermissions(
|
||||
ctx: CallContext,
|
||||
roleId: string,
|
||||
req: UpdateRolePermissionsRequest,
|
||||
): Promise<IamRole>;
|
||||
|
||||
/** 获取全部权限点(iam REST GET /v1/iam/permissions,✅ 已就绪) */
|
||||
getAllPermissions(ctx: CallContext): Promise<IamPermission[]>;
|
||||
|
||||
/** 查询审计日志(iam REST GET /v1/iam/audit,✅ 已就绪) */
|
||||
queryAuditLog(
|
||||
ctx: CallContext,
|
||||
req: QueryAuditLogRequest,
|
||||
): Promise<IamAuditLog[]>;
|
||||
|
||||
// ===== 健康检查 =====
|
||||
|
||||
/** 健康检查(/readyz 探针用) */
|
||||
checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// IamClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||
// iam.proto 现状仅 GetUserInfo,GetViewports/GetEffectivePermissions 待 coord 补全
|
||||
// 未就绪 RPC 降级 mock + warning 日志(president §2.6 降级模式 B)
|
||||
// IamClient gRPC + REST 实现(B2 裁决:首次实现即 gRPC;admin 域补充 REST)
|
||||
// iam.proto 6 RPC(Register/Login/RefreshToken/Logout/GetUserInfo/GetEffectiveDataScope)
|
||||
// admin 域需要 roles/permissions/audit/users 等端点,iam.proto 未声明,走 REST
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock;false → gRPC/REST 真实调用
|
||||
// 未就绪端点降级 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";
|
||||
@@ -8,16 +10,125 @@ import {
|
||||
createGrpcMetadata,
|
||||
getGrpcClient,
|
||||
checkGrpcHealth,
|
||||
isServiceConfigured,
|
||||
} from "../grpc/grpc.factory.js";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import type { CallContext } from "../types.js";
|
||||
import type { IamClient } from "./iam-client.interface.js";
|
||||
import type {
|
||||
UserInfo,
|
||||
ViewportItem,
|
||||
EffectivePermissions,
|
||||
IamRole,
|
||||
IamPermission,
|
||||
IamAuditLog,
|
||||
IamUser,
|
||||
IamUserPage,
|
||||
ListUsersRequest,
|
||||
UpdateUserRequest,
|
||||
QueryAuditLogRequest,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
UpdateRolePermissionsRequest,
|
||||
} from "./iam.types.js";
|
||||
import { IamMockClient } from "./iam-mock.client.js";
|
||||
|
||||
/** iam REST API 响应体通用结构(iam 返回 { data: T } 或直接 T) */
|
||||
async function fetchIamRest<T>(
|
||||
path: string,
|
||||
init: RequestInit & { ctx?: CallContext } = {},
|
||||
): Promise<T> {
|
||||
const baseUrl = env.IAM_SERVICE_URL;
|
||||
const url = `${baseUrl}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
};
|
||||
if (init.ctx) {
|
||||
if (init.ctx.userId) headers["x-user-id"] = init.ctx.userId;
|
||||
if (init.ctx.traceId) headers["x-request-id"] = init.ctx.traceId;
|
||||
}
|
||||
|
||||
const resp = await fetch(url, { ...init, headers });
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new Error(`iam REST ${path} failed: ${resp.status} ${text}`);
|
||||
}
|
||||
const body = (await resp.json()) as T;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 将 iam REST 返回的 user 字段映射为 IamUser */
|
||||
function mapIamUser(raw: Record<string, unknown>): IamUser {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
email: String(raw.email ?? ""),
|
||||
name: String(raw.name ?? ""),
|
||||
status: String(raw.status ?? "active"),
|
||||
roles: (raw.roles ?? []) as string[],
|
||||
createdAt: String(raw.createdAt ?? raw.created_at ?? ""),
|
||||
updatedAt: String(raw.updatedAt ?? raw.updated_at ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** 将 iam REST 返回的 role 字段映射为 IamRole */
|
||||
function mapIamRole(raw: Record<string, unknown>): IamRole {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
name: String(raw.name ?? ""),
|
||||
description: raw.description == null ? null : String(raw.description),
|
||||
roleType: String(raw.roleType ?? raw.role_type ?? "organization"),
|
||||
createdAt: String(raw.createdAt ?? raw.created_at ?? ""),
|
||||
updatedAt: String(raw.updatedAt ?? raw.updated_at ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** 将 iam REST 返回的 permission 字段映射为 IamPermission */
|
||||
function mapIamPermission(raw: Record<string, unknown>): IamPermission {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
code: String(raw.code ?? ""),
|
||||
name: String(raw.name ?? ""),
|
||||
description: raw.description == null ? null : String(raw.description),
|
||||
resource: String(raw.resource ?? ""),
|
||||
action: String(raw.action ?? ""),
|
||||
isSystem: Boolean(raw.isSystem ?? raw.is_system ?? false),
|
||||
createdAt: String(raw.createdAt ?? raw.created_at ?? ""),
|
||||
updatedAt: String(raw.updatedAt ?? raw.updated_at ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** 将 iam REST 返回的 audit log 字段映射为 IamAuditLog */
|
||||
function mapIamAuditLog(raw: Record<string, unknown>): IamAuditLog {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
eventId: String(raw.eventId ?? raw.event_id ?? raw.id ?? ""),
|
||||
createdAt: String(raw.createdAt ?? raw.created_at ?? ""),
|
||||
actorUserId: String(raw.actorUserId ?? raw.actor_user_id ?? ""),
|
||||
action: String(raw.action ?? ""),
|
||||
resourceType: String(raw.resourceType ?? raw.resource_type ?? ""),
|
||||
resourceId:
|
||||
raw.resourceId == null ? null : String(raw.resourceId ?? raw.resource_id),
|
||||
ip: raw.ip == null ? null : String(raw.ip),
|
||||
userAgent:
|
||||
raw.userAgent == null ? null : String(raw.userAgent ?? raw.user_agent),
|
||||
beforeState:
|
||||
raw.beforeState == null
|
||||
? null
|
||||
: typeof raw.beforeState === "string"
|
||||
? raw.beforeState
|
||||
: JSON.stringify(raw.beforeState),
|
||||
afterState:
|
||||
raw.afterState == null
|
||||
? null
|
||||
: typeof raw.afterState === "string"
|
||||
? raw.afterState
|
||||
: JSON.stringify(raw.afterState),
|
||||
traceId: raw.traceId == null ? null : String(raw.traceId ?? raw.trace_id),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
|
||||
readonly serviceName = "iam" as const;
|
||||
@@ -30,7 +141,16 @@ export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
|
||||
this.mock = new IamMockClient();
|
||||
}
|
||||
|
||||
// ===== gRPC 方法 =====
|
||||
|
||||
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
|
||||
if (!isServiceConfigured("iam")) {
|
||||
this.log.warn(
|
||||
{ rpc: "GetUserInfo", reason: "IAM_GRPC_TARGET not configured" },
|
||||
"Downstream gRPC target not configured, falling back to mock",
|
||||
);
|
||||
return this.mock.getUserInfo(ctx);
|
||||
}
|
||||
return this.callGrpc("GetUserInfo", async () => {
|
||||
const client = getGrpcClient("iam") as unknown as {
|
||||
getUserInfo(
|
||||
@@ -50,23 +170,265 @@ export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
|
||||
}
|
||||
|
||||
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);
|
||||
// iam.proto 暂无 GetViewports RPC,走 REST GET /v1/iam/viewports
|
||||
try {
|
||||
const raw = await fetchIamRest<unknown[]>("/v1/iam/viewports", { ctx });
|
||||
const arr = Array.isArray(raw)
|
||||
? raw
|
||||
: ((raw as { data?: unknown[] }).data ?? []);
|
||||
return arr.map((r): ViewportItem => {
|
||||
const item = r as Record<string, unknown>;
|
||||
return {
|
||||
key: String(item.key ?? ""),
|
||||
label: String(item.label ?? ""),
|
||||
route: String(item.route ?? ""),
|
||||
icon: item.icon == null ? null : String(item.icon),
|
||||
sortOrder: String(item.sortOrder ?? item.sort_order ?? "0"),
|
||||
requiredPermission:
|
||||
item.requiredPermission == null
|
||||
? null
|
||||
: String(
|
||||
item.requiredPermission ?? item.required_permission ?? "",
|
||||
),
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.warn(
|
||||
{
|
||||
err: (err as Error).message,
|
||||
reason: "iam REST /v1/iam/viewports unavailable",
|
||||
},
|
||||
"getViewports 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",
|
||||
// iam.proto 暂无 GetEffectivePermissions RPC,走 REST GET /v1/iam/permissions/effective
|
||||
try {
|
||||
const raw = await fetchIamRest<Record<string, unknown>>(
|
||||
"/v1/iam/permissions/effective",
|
||||
{ ctx },
|
||||
);
|
||||
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
|
||||
const permissions = (body.permissions ?? []) as string[];
|
||||
const dataScope = String(body.dataScope ?? body.data_scope ?? "self");
|
||||
return {
|
||||
permissions,
|
||||
// dataScope 转大写(GraphQL enum 要求大写,iam DB 存小写)
|
||||
dataScope: dataScope.toUpperCase(),
|
||||
};
|
||||
} catch (err) {
|
||||
this.log.warn(
|
||||
{
|
||||
err: (err as Error).message,
|
||||
reason: "iam REST /v1/iam/permissions/effective unavailable",
|
||||
},
|
||||
"getEffectivePermissions falling back to mock",
|
||||
);
|
||||
return this.mock.getEffectivePermissions(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== REST 方法(admin 域需要) =====
|
||||
|
||||
async listUsers(
|
||||
ctx: CallContext,
|
||||
req: ListUsersRequest,
|
||||
): Promise<IamUserPage> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (req.keyword) params.set("keyword", req.keyword);
|
||||
if (req.status) params.set("status", req.status);
|
||||
if (req.roleId) params.set("roleId", req.roleId);
|
||||
params.set("page", String(req.page ?? 1));
|
||||
params.set("pageSize", String(req.pageSize ?? 20));
|
||||
const raw = await fetchIamRest<Record<string, unknown>>(
|
||||
`/v1/iam/users?${params.toString()}`,
|
||||
{ ctx },
|
||||
);
|
||||
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
|
||||
const items = ((body.items ?? body.users ?? []) as unknown[]).map(
|
||||
(r): IamUser => mapIamUser(r as Record<string, unknown>),
|
||||
);
|
||||
return {
|
||||
items,
|
||||
total: Number(body.total ?? items.length),
|
||||
};
|
||||
} catch (err) {
|
||||
this.log.warn(
|
||||
{
|
||||
err: (err as Error).message,
|
||||
reason: "iam REST /v1/iam/users unavailable",
|
||||
},
|
||||
"listUsers returning empty",
|
||||
);
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
input: UpdateUserRequest,
|
||||
): Promise<IamUser> {
|
||||
const raw = await fetchIamRest<Record<string, unknown>>(
|
||||
`/v1/iam/users/${id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
ctx,
|
||||
},
|
||||
);
|
||||
return this.mock.getEffectivePermissions(ctx);
|
||||
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
|
||||
return mapIamUser(body);
|
||||
}
|
||||
|
||||
async toggleUserStatus(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
status: string,
|
||||
): Promise<IamUser> {
|
||||
const raw = await fetchIamRest<Record<string, unknown>>(
|
||||
`/v1/iam/users/${id}/status`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status }),
|
||||
ctx,
|
||||
},
|
||||
);
|
||||
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
|
||||
return mapIamUser(body);
|
||||
}
|
||||
|
||||
async getAllRoles(ctx: CallContext): Promise<IamRole[]> {
|
||||
try {
|
||||
const raw = await fetchIamRest<unknown[]>("/v1/iam/roles", { ctx });
|
||||
const arr = Array.isArray(raw)
|
||||
? raw
|
||||
: ((raw as { data?: unknown[] }).data ?? []);
|
||||
return arr.map((r): IamRole => mapIamRole(r as Record<string, unknown>));
|
||||
} catch (err) {
|
||||
this.log.warn(
|
||||
{
|
||||
err: (err as Error).message,
|
||||
reason: "iam REST /v1/iam/roles unavailable",
|
||||
},
|
||||
"getAllRoles returning empty",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async createRole(
|
||||
ctx: CallContext,
|
||||
input: CreateRoleRequest,
|
||||
): Promise<IamRole> {
|
||||
const raw = await fetchIamRest<Record<string, unknown>>("/v1/iam/roles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
ctx,
|
||||
});
|
||||
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
|
||||
return mapIamRole(body);
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
input: UpdateRoleRequest,
|
||||
): Promise<IamRole> {
|
||||
const raw = await fetchIamRest<Record<string, unknown>>(
|
||||
`/v1/iam/roles/${id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
ctx,
|
||||
},
|
||||
);
|
||||
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
|
||||
return mapIamRole(body);
|
||||
}
|
||||
|
||||
async updateRolePermissions(
|
||||
ctx: CallContext,
|
||||
roleId: string,
|
||||
req: UpdateRolePermissionsRequest,
|
||||
): Promise<IamRole> {
|
||||
const raw = await fetchIamRest<Record<string, unknown>>(
|
||||
`/v1/iam/roles/${roleId}/permissions`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(req),
|
||||
ctx,
|
||||
},
|
||||
);
|
||||
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
|
||||
return mapIamRole(body);
|
||||
}
|
||||
|
||||
async getAllPermissions(ctx: CallContext): Promise<IamPermission[]> {
|
||||
try {
|
||||
const raw = await fetchIamRest<unknown[]>("/v1/iam/permissions", { ctx });
|
||||
const arr = Array.isArray(raw)
|
||||
? raw
|
||||
: ((raw as { data?: unknown[] }).data ?? []);
|
||||
return arr.map((r): IamPermission =>
|
||||
mapIamPermission(r as Record<string, unknown>),
|
||||
);
|
||||
} catch (err) {
|
||||
this.log.warn(
|
||||
{
|
||||
err: (err as Error).message,
|
||||
reason: "iam REST /v1/iam/permissions unavailable",
|
||||
},
|
||||
"getAllPermissions returning empty",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async queryAuditLog(
|
||||
ctx: CallContext,
|
||||
req: QueryAuditLogRequest,
|
||||
): Promise<IamAuditLog[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (req.actorUserId) params.set("actorUserId", req.actorUserId);
|
||||
if (req.action) params.set("action", req.action);
|
||||
if (req.resourceType) params.set("resourceType", req.resourceType);
|
||||
if (req.resourceId) params.set("resourceId", req.resourceId);
|
||||
if (req.startDate) params.set("startDate", req.startDate);
|
||||
if (req.endDate) params.set("endDate", req.endDate);
|
||||
if (req.limit) params.set("limit", String(req.limit));
|
||||
if (req.offset) params.set("offset", String(req.offset));
|
||||
const raw = await fetchIamRest<unknown>(
|
||||
`/v1/iam/audit?${params.toString()}`,
|
||||
{
|
||||
ctx,
|
||||
},
|
||||
);
|
||||
const arr = Array.isArray(raw)
|
||||
? raw
|
||||
: ((raw as { data?: unknown[] }).data ??
|
||||
(raw as { items?: unknown[] }).items ??
|
||||
[]);
|
||||
return arr.map((r): IamAuditLog =>
|
||||
mapIamAuditLog(r as Record<string, unknown>),
|
||||
);
|
||||
} catch (err) {
|
||||
this.log.warn(
|
||||
{
|
||||
err: (err as Error).message,
|
||||
reason: "iam REST /v1/iam/audit unavailable",
|
||||
},
|
||||
"queryAuditLog returning empty",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
@@ -77,3 +439,6 @@ export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
|
||||
return checkGrpcHealth("iam");
|
||||
}
|
||||
}
|
||||
|
||||
// 避免未使用警告
|
||||
void logger;
|
||||
|
||||
@@ -9,6 +9,17 @@ import type {
|
||||
UserInfo,
|
||||
ViewportItem,
|
||||
EffectivePermissions,
|
||||
IamRole,
|
||||
IamPermission,
|
||||
IamAuditLog,
|
||||
IamUser,
|
||||
IamUserPage,
|
||||
ListUsersRequest,
|
||||
UpdateUserRequest,
|
||||
QueryAuditLogRequest,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
UpdateRolePermissionsRequest,
|
||||
} from "./iam.types.js";
|
||||
|
||||
/** 教师默认 mock 用户(contract §4.1 mock 策略) */
|
||||
@@ -27,6 +38,8 @@ const MOCK_TEACHER: UserInfo = {
|
||||
"class:read",
|
||||
"student:read",
|
||||
],
|
||||
dataScope: "SELF",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
/** 教师默认 mock 视口(L1 导航) */
|
||||
@@ -36,7 +49,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
|
||||
label: "工作台",
|
||||
route: "/dashboard",
|
||||
icon: null,
|
||||
sortOrder: 1,
|
||||
sortOrder: "1",
|
||||
requiredPermission: null,
|
||||
},
|
||||
{
|
||||
@@ -44,7 +57,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
|
||||
label: "我的班级",
|
||||
route: "/classes",
|
||||
icon: null,
|
||||
sortOrder: 2,
|
||||
sortOrder: "2",
|
||||
requiredPermission: "class:read",
|
||||
},
|
||||
{
|
||||
@@ -52,7 +65,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
|
||||
label: "考试管理",
|
||||
route: "/exams",
|
||||
icon: null,
|
||||
sortOrder: 3,
|
||||
sortOrder: "3",
|
||||
requiredPermission: "exam:read",
|
||||
},
|
||||
{
|
||||
@@ -60,7 +73,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
|
||||
label: "作业管理",
|
||||
route: "/homework",
|
||||
icon: null,
|
||||
sortOrder: 4,
|
||||
sortOrder: "4",
|
||||
requiredPermission: "homework:read",
|
||||
},
|
||||
{
|
||||
@@ -68,7 +81,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
|
||||
label: "成绩管理",
|
||||
route: "/grades",
|
||||
icon: null,
|
||||
sortOrder: 5,
|
||||
sortOrder: "5",
|
||||
requiredPermission: "grade:read",
|
||||
},
|
||||
{
|
||||
@@ -76,7 +89,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
|
||||
label: "学情分析",
|
||||
route: "/analytics",
|
||||
icon: null,
|
||||
sortOrder: 6,
|
||||
sortOrder: "6",
|
||||
requiredPermission: "student:read",
|
||||
},
|
||||
];
|
||||
@@ -87,6 +100,52 @@ const MOCK_PERMISSIONS: EffectivePermissions = {
|
||||
dataScope: "SELF",
|
||||
};
|
||||
|
||||
/** mock 角色 */
|
||||
const MOCK_ROLES: IamRole[] = [
|
||||
{
|
||||
id: "role-001",
|
||||
name: "teacher",
|
||||
description: "教师角色",
|
||||
roleType: "system",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "role-002",
|
||||
name: "admin",
|
||||
description: "管理员角色",
|
||||
roleType: "system",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** mock 权限点 */
|
||||
const MOCK_PERMISSIONS_LIST: IamPermission[] = [
|
||||
{
|
||||
id: "perm-001",
|
||||
code: "exam:read",
|
||||
name: "查看考试",
|
||||
description: "查看考试列表和详情",
|
||||
resource: "exam",
|
||||
action: "read",
|
||||
isSystem: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "perm-002",
|
||||
code: "exam:write",
|
||||
name: "创建考试",
|
||||
description: "创建和编辑考试",
|
||||
resource: "exam",
|
||||
action: "write",
|
||||
isSystem: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class IamMockClient extends BaseDownstreamClient implements IamClient {
|
||||
readonly serviceName = "iam" as const;
|
||||
@@ -114,6 +173,132 @@ export class IamMockClient extends BaseDownstreamClient implements IamClient {
|
||||
return MOCK_PERMISSIONS;
|
||||
}
|
||||
|
||||
async listUsers(
|
||||
ctx: CallContext,
|
||||
req: ListUsersRequest,
|
||||
): Promise<IamUserPage> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock listUsers");
|
||||
const page = req.page ?? 1;
|
||||
const pageSize = req.pageSize ?? 20;
|
||||
const items: IamUser[] = [
|
||||
{
|
||||
id: "user-001",
|
||||
email: "teacher@edu.test",
|
||||
name: "张老师",
|
||||
status: "active",
|
||||
roles: ["teacher"],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
return {
|
||||
items: items.slice((page - 1) * pageSize, page * pageSize),
|
||||
total: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
input: UpdateUserRequest,
|
||||
): Promise<IamUser> {
|
||||
this.log.debug({ userId: ctx.userId, id, input }, "Mock updateUser");
|
||||
return {
|
||||
id,
|
||||
email: input.email ?? "updated@edu.test",
|
||||
name: input.name ?? "Updated User",
|
||||
status: input.status ?? "active",
|
||||
roles: input.roleIds ?? [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async toggleUserStatus(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
status: string,
|
||||
): Promise<IamUser> {
|
||||
this.log.debug({ userId: ctx.userId, id, status }, "Mock toggleUserStatus");
|
||||
return {
|
||||
id,
|
||||
email: "user@edu.test",
|
||||
name: "User",
|
||||
status,
|
||||
roles: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getAllRoles(ctx: CallContext): Promise<IamRole[]> {
|
||||
this.log.debug({ userId: ctx.userId }, "Mock getAllRoles");
|
||||
return MOCK_ROLES;
|
||||
}
|
||||
|
||||
async createRole(
|
||||
ctx: CallContext,
|
||||
input: CreateRoleRequest,
|
||||
): Promise<IamRole> {
|
||||
this.log.debug({ userId: ctx.userId, input }, "Mock createRole");
|
||||
return {
|
||||
id: `role-${Date.now()}`,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
roleType: input.roleType ?? "organization",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
ctx: CallContext,
|
||||
id: string,
|
||||
input: UpdateRoleRequest,
|
||||
): Promise<IamRole> {
|
||||
this.log.debug({ userId: ctx.userId, id, input }, "Mock updateRole");
|
||||
return {
|
||||
id,
|
||||
name: input.name ?? "Updated Role",
|
||||
description: input.description ?? null,
|
||||
roleType: "organization",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async updateRolePermissions(
|
||||
ctx: CallContext,
|
||||
roleId: string,
|
||||
req: UpdateRolePermissionsRequest,
|
||||
): Promise<IamRole> {
|
||||
this.log.debug(
|
||||
{ userId: ctx.userId, roleId, req },
|
||||
"Mock updateRolePermissions",
|
||||
);
|
||||
return {
|
||||
id: roleId,
|
||||
name: "Updated Role",
|
||||
description: null,
|
||||
roleType: "organization",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getAllPermissions(ctx: CallContext): Promise<IamPermission[]> {
|
||||
this.log.debug({ userId: ctx.userId }, "Mock getAllPermissions");
|
||||
return MOCK_PERMISSIONS_LIST;
|
||||
}
|
||||
|
||||
async queryAuditLog(
|
||||
ctx: CallContext,
|
||||
req: QueryAuditLogRequest,
|
||||
): Promise<IamAuditLog[]> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock queryAuditLog");
|
||||
return [];
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC)
|
||||
// iam.proto 现状:4 RPC(Register/Login/RefreshToken/GetUserInfo)
|
||||
// 待 coord 补全:GetViewports / GetEffectivePermissions / GetEffectiveAccess / BatchGetUsers / GetChildrenByParent
|
||||
// iam 下游服务类型定义(对齐 iam.proto + REST CRUD 端点)
|
||||
// iam.proto 现状:6 RPC(Register/Login/RefreshToken/Logout/GetUserInfo/GetEffectiveDataScope)
|
||||
// REST 端点:users / roles / permissions / viewports / audit 全部 CRUD
|
||||
// BFF 通过 IamClient 接口统一访问:gRPC 优先(仅 GetUserInfo),其余走 REST
|
||||
|
||||
/** 用户信息(对齐 iam.proto UserInfo message) */
|
||||
/** 用户信息(对齐 iam.proto UserInfo message)
|
||||
* data_scope / status 字段对齐 proto(snake_case → camelCase 由 proto-loader 转换) */
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
/** 数据范围(self/subject/class/grade/school/all,对齐 GraphQL enum DataScope 大写) */
|
||||
dataScope?: string;
|
||||
/** 用户状态(active/disabled) */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** 视口项(L1 导航菜单,待 coord 补 GetViewports RPC 的 response message) */
|
||||
/** 视口项(L1 导航菜单,对齐 iam REST GET /v1/iam/viewports) */
|
||||
export interface ViewportItem {
|
||||
key: string;
|
||||
label: string;
|
||||
route: string;
|
||||
icon: string | null;
|
||||
sortOrder: number;
|
||||
sortOrder: string;
|
||||
requiredPermission: string | null;
|
||||
}
|
||||
|
||||
/** 有效权限集(待 coord 补 GetEffectivePermissions RPC 的 response message) */
|
||||
/** 有效权限集(对齐 iam REST GET /v1/iam/permissions/effective) */
|
||||
export interface EffectivePermissions {
|
||||
permissions: string[];
|
||||
/** 数据范围(SELF / CLASS / GRADE / SCHOOL / DISTRICT / ALL,对齐 GraphQL enum DataScope) */
|
||||
/** 数据范围(self/class/grade/school/all) */
|
||||
dataScope: string;
|
||||
}
|
||||
|
||||
@@ -42,3 +48,117 @@ export interface GetViewportsRequest {
|
||||
export interface GetEffectivePermissionsRequest {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// ===== REST 端点返回类型 =====
|
||||
|
||||
/** 角色(对齐 iam REST GET /v1/iam/roles) */
|
||||
export interface IamRole {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
/** 角色类型(system/organization/temporary) */
|
||||
roleType: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 权限点(对齐 iam REST GET /v1/iam/permissions) */
|
||||
export interface IamPermission {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
resource: string;
|
||||
action: string;
|
||||
isSystem: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 审计日志(对齐 iam REST GET /v1/iam/audit) */
|
||||
export interface IamAuditLog {
|
||||
id: string;
|
||||
/** 事件 ID(UUID) */
|
||||
eventId: string;
|
||||
/** 操作时间 ISO8601 */
|
||||
createdAt: string;
|
||||
/** 操作者用户 ID */
|
||||
actorUserId: string;
|
||||
/** 操作动作(login/logout/create/update/delete/...) */
|
||||
action: string;
|
||||
/** 资源类型(user/role/permission/viewport/...) */
|
||||
resourceType: string;
|
||||
/** 资源 ID */
|
||||
resourceId: string | null;
|
||||
/** IP 地址 */
|
||||
ip: string | null;
|
||||
/** User-Agent */
|
||||
userAgent: string | null;
|
||||
/** 操作前状态(JSON 字符串) */
|
||||
beforeState: string | null;
|
||||
/** 操作后状态(JSON 字符串) */
|
||||
afterState: string | null;
|
||||
/** traceId */
|
||||
traceId: string | null;
|
||||
}
|
||||
|
||||
/** 用户列表项(对齐 iam REST GET /v1/iam/users) */
|
||||
export interface IamUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
status: string;
|
||||
roles: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 用户列表响应(对齐 iam REST GET /v1/iam/users 分页结构) */
|
||||
export interface IamUserPage {
|
||||
items: IamUser[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== Request 类型 =====
|
||||
|
||||
export interface ListUsersRequest {
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
roleId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
name?: string;
|
||||
email?: string;
|
||||
status?: string;
|
||||
roleIds?: string[];
|
||||
}
|
||||
|
||||
export interface QueryAuditLogRequest {
|
||||
actorUserId?: string;
|
||||
action?: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
roleType?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRolePermissionsRequest {
|
||||
permissionCodes: string[];
|
||||
}
|
||||
|
||||
39
services/teacher-bff/src/clients/msg/msg-client.interface.ts
Normal file
39
services/teacher-bff/src/clients/msg/msg-client.interface.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// MsgClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||
// 接口定义所有 P5+ 需要的 msg RPC,实现分 gRPC + mock 两种
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC
|
||||
import type { CallContext } from "../types.js";
|
||||
import type {
|
||||
ListNotificationsRequest,
|
||||
ListNotificationsResponse,
|
||||
SearchNotificationsRequest,
|
||||
SearchNotificationsResponse,
|
||||
MarkAsReadRequest,
|
||||
MarkAsReadResponse,
|
||||
} from "./msg.types.js";
|
||||
|
||||
/** MsgClient DI token(NestJS 注入用) */
|
||||
export const MSG_CLIENT = Symbol("MSG_CLIENT");
|
||||
|
||||
/** MsgClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
|
||||
export interface MsgClient {
|
||||
// ===== NotificationService =====
|
||||
listNotifications(
|
||||
ctx: CallContext,
|
||||
req: ListNotificationsRequest,
|
||||
): Promise<ListNotificationsResponse>;
|
||||
searchNotifications(
|
||||
ctx: CallContext,
|
||||
req: SearchNotificationsRequest,
|
||||
): Promise<SearchNotificationsResponse>;
|
||||
markAsRead(
|
||||
ctx: CallContext,
|
||||
req: MarkAsReadRequest,
|
||||
): Promise<MarkAsReadResponse>;
|
||||
|
||||
// ===== 健康检查 =====
|
||||
checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
186
services/teacher-bff/src/clients/msg/msg-grpc.client.ts
Normal file
186
services/teacher-bff/src/clients/msg/msg-grpc.client.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
// MsgClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||
// msg.proto 现状 NotificationService 9 RPC(contract §2.1.5 标 3 RPC 已就绪)
|
||||
// proto 字段 snake_case 转 camelCase
|
||||
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 { MsgClient } from "./msg-client.interface.js";
|
||||
import type {
|
||||
Notification,
|
||||
ListNotificationsRequest,
|
||||
ListNotificationsResponse,
|
||||
SearchNotificationsRequest,
|
||||
SearchNotificationsResponse,
|
||||
MarkAsReadRequest,
|
||||
MarkAsReadResponse,
|
||||
} from "./msg.types.js";
|
||||
|
||||
/** proto message 字段是 snake_case,gRPC 返回需转 camelCase */
|
||||
function mapNotification(raw: Record<string, unknown>): Notification {
|
||||
return {
|
||||
id: String(raw.id ?? ""),
|
||||
userId: String(raw.user_id ?? ""),
|
||||
type: String(raw.type ?? ""),
|
||||
title: String(raw.title ?? ""),
|
||||
content: String(raw.content ?? ""),
|
||||
channel: String(raw.channel ?? ""),
|
||||
isRead: Boolean(raw.is_read ?? false),
|
||||
createdAt: String(raw.created_at ?? ""),
|
||||
status: String(raw.status ?? ""),
|
||||
relatedEntityType: String(raw.related_entity_type ?? ""),
|
||||
relatedEntityId: String(raw.related_entity_id ?? ""),
|
||||
groupId: String(raw.group_id ?? ""),
|
||||
senderId: String(raw.sender_id ?? ""),
|
||||
templateId: String(raw.template_id ?? ""),
|
||||
eventId: String(raw.event_id ?? ""),
|
||||
readAt: String(raw.read_at ?? ""),
|
||||
updatedAt: String(raw.updated_at ?? ""),
|
||||
metadata: (raw.metadata as Record<string, string> | undefined) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MsgGrpcClient extends BaseDownstreamClient implements MsgClient {
|
||||
readonly serviceName = "msg" as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
}
|
||||
|
||||
// ===== NotificationService =====
|
||||
|
||||
async listNotifications(
|
||||
ctx: CallContext,
|
||||
req: ListNotificationsRequest,
|
||||
): Promise<ListNotificationsResponse> {
|
||||
return this.callGrpc("ListNotifications", async () => {
|
||||
const client = getGrpcClient("msg", "NotificationService") as unknown as {
|
||||
listNotifications(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { notifications?: unknown[]; total?: number },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<ListNotificationsResponse>((resolve, reject) => {
|
||||
client.listNotifications(
|
||||
{
|
||||
user_id: req.userId,
|
||||
only_unread: req.onlyUnread,
|
||||
type: req.type,
|
||||
page: req.page,
|
||||
page_size: req.pageSize,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const notifications = (res.notifications ?? []) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
resolve({
|
||||
notifications: notifications.map(mapNotification),
|
||||
total: res.total ?? 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async searchNotifications(
|
||||
ctx: CallContext,
|
||||
req: SearchNotificationsRequest,
|
||||
): Promise<SearchNotificationsResponse> {
|
||||
return this.callGrpc("SearchNotifications", async () => {
|
||||
const client = getGrpcClient("msg", "NotificationService") as unknown as {
|
||||
searchNotifications(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: { notifications?: unknown[]; total?: number },
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<SearchNotificationsResponse>((resolve, reject) => {
|
||||
client.searchNotifications(
|
||||
{
|
||||
user_id: req.userId,
|
||||
query: req.query,
|
||||
type: req.type,
|
||||
page: req.page,
|
||||
page_size: req.pageSize,
|
||||
},
|
||||
meta,
|
||||
(err, res) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const notifications = (res.notifications ?? []) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
resolve({
|
||||
notifications: notifications.map(mapNotification),
|
||||
total: res.total ?? 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async markAsRead(
|
||||
ctx: CallContext,
|
||||
req: MarkAsReadRequest,
|
||||
): Promise<MarkAsReadResponse> {
|
||||
return this.callGrpc("MarkAsRead", async () => {
|
||||
const client = getGrpcClient("msg", "NotificationService") as unknown as {
|
||||
markAsRead(
|
||||
req: Record<string, unknown>,
|
||||
meta: grpc.Metadata,
|
||||
cb: (
|
||||
err: grpc.ServiceError | null,
|
||||
res: Record<string, unknown>,
|
||||
) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<MarkAsReadResponse>((resolve, reject) => {
|
||||
client.markAsRead(
|
||||
{
|
||||
id: req.id,
|
||||
user_id: req.userId,
|
||||
},
|
||||
meta,
|
||||
(err, _res) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ success: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return checkGrpcHealth("msg");
|
||||
}
|
||||
}
|
||||
178
services/teacher-bff/src/clients/msg/msg-mock.client.ts
Normal file
178
services/teacher-bff/src/clients/msg/msg-mock.client.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// MsgClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||
// DEV_MODE=true 时全部走 mock
|
||||
// mock 数据对齐 contract §2.1.5 mock 策略
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { BaseDownstreamClient } from "../base.client.js";
|
||||
import type { CallContext } from "../types.js";
|
||||
import type { MsgClient } from "./msg-client.interface.js";
|
||||
import type {
|
||||
Notification,
|
||||
ListNotificationsRequest,
|
||||
ListNotificationsResponse,
|
||||
SearchNotificationsRequest,
|
||||
SearchNotificationsResponse,
|
||||
MarkAsReadRequest,
|
||||
MarkAsReadResponse,
|
||||
} from "./msg.types.js";
|
||||
|
||||
/** mock 通知数据(contract §2.1.5:返回固定 5 条通知) */
|
||||
const MOCK_NOTIFICATIONS: Notification[] = [
|
||||
{
|
||||
id: "notif-001",
|
||||
userId: "teacher-001",
|
||||
type: "SYSTEM",
|
||||
title: "欢迎使用教学平台",
|
||||
content: "您的账号已开通,请尽快完善个人信息。",
|
||||
channel: "IN_APP",
|
||||
isRead: false,
|
||||
createdAt: "1782864000000",
|
||||
status: "SENT",
|
||||
relatedEntityType: "",
|
||||
relatedEntityId: "",
|
||||
groupId: "",
|
||||
senderId: "system",
|
||||
templateId: "tpl-system-welcome",
|
||||
eventId: "",
|
||||
readAt: "",
|
||||
updatedAt: "1782864000000",
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "notif-002",
|
||||
userId: "teacher-001",
|
||||
type: "HOMEWORK",
|
||||
title: "新作业提交提醒",
|
||||
content: "三年级1班共有 5 名学生提交了数学作业1,请及时批改。",
|
||||
channel: "IN_APP",
|
||||
isRead: false,
|
||||
createdAt: "1782950400000",
|
||||
status: "SENT",
|
||||
relatedEntityType: "homework",
|
||||
relatedEntityId: "hw-001",
|
||||
groupId: "",
|
||||
senderId: "core-edu",
|
||||
templateId: "tpl-homework-submit",
|
||||
eventId: "HomeworkSubmitted",
|
||||
readAt: "",
|
||||
updatedAt: "1782950400000",
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "notif-003",
|
||||
userId: "teacher-001",
|
||||
type: "EXAM",
|
||||
title: "考试成绩已发布",
|
||||
content: "语文月考成绩已发布,请查看详细分析报告。",
|
||||
channel: "IN_APP",
|
||||
isRead: true,
|
||||
createdAt: "1783036800000",
|
||||
status: "READ",
|
||||
relatedEntityType: "exam",
|
||||
relatedEntityId: "exam-001",
|
||||
groupId: "",
|
||||
senderId: "core-edu",
|
||||
templateId: "tpl-exam-grade",
|
||||
eventId: "GradeReleased",
|
||||
readAt: "1783123200000",
|
||||
updatedAt: "1783123200000",
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "notif-004",
|
||||
userId: "teacher-001",
|
||||
type: "ATTENDANCE",
|
||||
title: "考勤异常提醒",
|
||||
content: "三年级2班今日有 3 名学生缺勤,请关注。",
|
||||
channel: "IN_APP",
|
||||
isRead: true,
|
||||
createdAt: "1783123200000",
|
||||
status: "READ",
|
||||
relatedEntityType: "attendance",
|
||||
relatedEntityId: "att-2026-07-04",
|
||||
groupId: "",
|
||||
senderId: "core-edu",
|
||||
templateId: "tpl-attendance-alert",
|
||||
eventId: "AttendanceAnomaly",
|
||||
readAt: "1783209600000",
|
||||
updatedAt: "1783209600000",
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "notif-005",
|
||||
userId: "teacher-001",
|
||||
type: "SYSTEM",
|
||||
title: "系统维护通知",
|
||||
content: "平台将于本周日 02:00-04:00 进行系统维护,请提前保存工作。",
|
||||
channel: "IN_APP",
|
||||
isRead: false,
|
||||
createdAt: "1783209600000",
|
||||
status: "SENT",
|
||||
relatedEntityType: "",
|
||||
relatedEntityId: "",
|
||||
groupId: "",
|
||||
senderId: "system",
|
||||
templateId: "tpl-system-maintenance",
|
||||
eventId: "",
|
||||
readAt: "",
|
||||
updatedAt: "1783209600000",
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class MsgMockClient extends BaseDownstreamClient implements MsgClient {
|
||||
readonly serviceName = "msg" as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
}
|
||||
|
||||
async listNotifications(
|
||||
ctx: CallContext,
|
||||
req: ListNotificationsRequest,
|
||||
): Promise<ListNotificationsResponse> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock listNotifications");
|
||||
let list = MOCK_NOTIFICATIONS.filter((n) => n.userId === req.userId);
|
||||
if (req.onlyUnread) {
|
||||
list = list.filter((n) => !n.isRead);
|
||||
}
|
||||
if (req.type) {
|
||||
list = list.filter((n) => n.type === req.type);
|
||||
}
|
||||
return { notifications: list, total: list.length };
|
||||
}
|
||||
|
||||
async searchNotifications(
|
||||
ctx: CallContext,
|
||||
req: SearchNotificationsRequest,
|
||||
): Promise<SearchNotificationsResponse> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock searchNotifications");
|
||||
let list = MOCK_NOTIFICATIONS.filter((n) => n.userId === req.userId);
|
||||
if (req.query) {
|
||||
list = list.filter(
|
||||
(n) => n.title.includes(req.query) || n.content.includes(req.query),
|
||||
);
|
||||
}
|
||||
if (req.type) {
|
||||
list = list.filter((n) => n.type === req.type);
|
||||
}
|
||||
return { notifications: list, total: list.length };
|
||||
}
|
||||
|
||||
async markAsRead(
|
||||
ctx: CallContext,
|
||||
req: MarkAsReadRequest,
|
||||
): Promise<MarkAsReadResponse> {
|
||||
this.log.debug({ userId: ctx.userId, req }, "Mock markAsRead");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return { serving: true, latencyMs: 0 };
|
||||
}
|
||||
}
|
||||
32
services/teacher-bff/src/clients/msg/msg.module.ts
Normal file
32
services/teacher-bff/src/clients/msg/msg.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// msg 模块(B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { MSG_CLIENT } from "./msg-client.interface.js";
|
||||
import { MsgGrpcClient } from "./msg-grpc.client.js";
|
||||
import { MsgMockClient } from "./msg-mock.client.js";
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: MSG_CLIENT,
|
||||
useFactory: () => {
|
||||
// DEV_MODE=true 或 MSG_GRPC_TARGET 未配置 → 使用 mock
|
||||
if (env.TEACHER_BFF_DEV_MODE || !env.MSG_GRPC_TARGET) {
|
||||
logger.warn(
|
||||
{ devMode: env.TEACHER_BFF_DEV_MODE, target: env.MSG_GRPC_TARGET },
|
||||
"MsgClient using mock (DEV_MODE or target not configured)",
|
||||
);
|
||||
return new MsgMockClient();
|
||||
}
|
||||
logger.info(
|
||||
{ devMode: false, target: env.MSG_GRPC_TARGET },
|
||||
"MsgClient using gRPC",
|
||||
);
|
||||
return new MsgGrpcClient();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [MSG_CLIENT],
|
||||
})
|
||||
export class MsgModule {}
|
||||
64
services/teacher-bff/src/clients/msg/msg.types.ts
Normal file
64
services/teacher-bff/src/clients/msg/msg.types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// msg 下游服务类型定义(对齐 msg.proto)
|
||||
// msg.proto 现状:NotificationService 9 RPC(P5 启用,contract §2.1.5 标 3 RPC 已就绪)
|
||||
// BFF 当前消费:ListNotifications / SearchNotifications / MarkAsRead
|
||||
|
||||
/** 通知(对齐 msg.proto Notification message) */
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
channel: string;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
relatedEntityType: string;
|
||||
relatedEntityId: string;
|
||||
groupId: string;
|
||||
senderId: string;
|
||||
templateId: string;
|
||||
eventId: string;
|
||||
readAt: string;
|
||||
updatedAt: string;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
// ===== Request 类型 =====
|
||||
|
||||
export interface ListNotificationsRequest {
|
||||
userId: string;
|
||||
onlyUnread: boolean;
|
||||
type: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface SearchNotificationsRequest {
|
||||
userId: string;
|
||||
query: string;
|
||||
type: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface MarkAsReadRequest {
|
||||
id: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// ===== Response 类型 =====
|
||||
|
||||
export interface ListNotificationsResponse {
|
||||
notifications: Notification[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SearchNotificationsResponse {
|
||||
notifications: Notification[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MarkAsReadResponse {
|
||||
success: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user