feat(teacher-bff): admin 命名空间 + 5 个 gRPC client + health probes + merge-resolvers + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 16:00:46 +08:00
parent 7b790f1276
commit 895a060491
50 changed files with 9739 additions and 334 deletions

View File

@@ -0,0 +1,50 @@
// AiClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P5 需要的 ai RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC
// 裁决依据B2首次实现即 gRPC+ B8DownstreamClient 抽象)
// 端口ai gRPC 50058contract §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 tokenNestJS 注入用) */
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;
}>;
}

View File

@@ -0,0 +1,256 @@
// AiClient gRPC 实现B2 裁决:首次实现即 gRPC
// ai.proto 现状 4 RPCAiService.Chat / StreamChat / GenerateQuestion / OptimizeExpression
// StreamChat 为 server streaming RPC返回 AsyncIterable<StreamChatChunk>
// proto 字段是 snake_casegRPC 返回需转 camelCase
// 裁决依据B2首次实现即 gRPC+ B8DownstreamClient 抽象)
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 Usagesnake_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);
}
}
}

View File

@@ -0,0 +1,124 @@
// AiClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mockDEV_MODE=false 时走 gRPC
// mock 数据对齐 contract §4.2 mock 策略
// 裁决依据B2首次实现即 gRPC+ B8DownstreamClient 抽象)
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 流式聊天 chunkcontract §4.2:流式 mock3 个 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;
}
}
}

View File

@@ -0,0 +1,34 @@
// ai 模块B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
// 端口ai gRPC 50058contract §2.1.6 + port-allocation.md §5
// 裁决依据B2首次实现即 gRPC+ B8DownstreamClient 抽象)
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 {}

View File

@@ -0,0 +1,80 @@
// ai 下游服务类型定义(对齐 ai.proto
// ai.proto 现状4 RPCAiService.Chat / StreamChat / GenerateQuestion / OptimizeExpression
// 待 coord 补全GenerateLessonPlan + StreamGenerateQuestionA4 裁决)
// 裁决依据B2首次实现即 gRPC+ B8DownstreamClient 抽象)
/** 聊天消息(对齐 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 messageStreamChat 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;
}

View File

@@ -1,10 +1,30 @@
// DownstreamClient 聚合模块B8 裁决3 个 BFF 统一使用)
// P2 仅注册 IamModuleP3+ 扩展 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 {}

View File

@@ -0,0 +1,67 @@
// ContentClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P4+ 需要的 content RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → 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 tokenNestJS 注入用) */
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;
}>;
}

View File

@@ -0,0 +1,301 @@
// ContentClient gRPC 实现B2 裁决:首次实现即 gRPC
// content.proto 现状TextbookService 3 + KnowledgeGraphService 2 已就绪
// 未就绪 RPCChapterService.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_casegRPC 返回需转 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");
}
}

View File

@@ -0,0 +1,267 @@
// ContentClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mockDEV_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 };
}
}

View 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 {}

View File

@@ -0,0 +1,139 @@
// content 下游服务类型定义(对齐 content.proto
// content.proto 现状4 ServiceTextbookService 5 + ChapterService 5 + KnowledgeGraphService 4 + QuestionService 8
// 待 coord 补全ChapterServiceN5 裁决)+ QuestionServiceN3 裁决)服务端实现
/** 教材(对齐 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;
}

View File

@@ -0,0 +1,95 @@
// CoreEduClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P3+ 需要的 core-edu RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → 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 tokenNestJS 注入用) */
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;
}>;
}

View File

@@ -0,0 +1,561 @@
// CoreEduClient gRPC 实现B2 裁决:首次实现即 gRPC
// core_edu.proto 现状 14 RPCExamService 5 + HomeworkService 4 + GradeService 5
// 未就绪 RPCClassService.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_casegRPC 返回需转 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) });
}
},
);
});
});
}
// ===== ClassServiceproto 未定义,降级 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");
}
}

View File

@@ -0,0 +1,336 @@
// CoreEduClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mockDEV_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 };
}
}

View 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 {}

View File

@@ -0,0 +1,136 @@
// core-edu 下游服务类型定义(对齐 core_edu.proto
// core_edu.proto 现状14 RPCExamService 5 + HomeworkService 4 + GradeService 5
// 待 coord 补全ClassServiceGetClassesByTeacher / 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[];
}

View File

@@ -0,0 +1,45 @@
// DataAnaClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P4 需要的 data-ana RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC
import type { CallContext } from "../types.js";
import type {
ClassPerformance,
StudentWeakness,
LearningTrend,
TeacherDashboardStats,
GetClassPerformanceRequest,
GetStudentWeaknessRequest,
GetLearningTrendRequest,
GetTeacherDashboardRequest,
} from "./data-ana.types.js";
/** DataAnaClient DI tokenNestJS 注入用) */
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;
}>;
}

View File

@@ -0,0 +1,257 @@
// DataAnaClient gRPC 实现B2 裁决:首次实现即 gRPC
// analytics.proto 现状 12 RPCAnalyticsServiceBFF 消费 4 个
// proto 字段是 snake_casegRPC 返回需转 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_casegRPC 返回需转 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 → todayHomeworktotalExams/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");
}
}

View File

@@ -0,0 +1,124 @@
// DataAnaClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mockDEV_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 };
}
}

View 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 {}

View File

@@ -0,0 +1,81 @@
// data-ana 下游服务类型定义(对齐 analytics.proto
// analytics.proto 现状12 RPCAnalyticsServiceISSUE-027 已补全 GetTeacherDashboard
// BFF 消费 4 个 RPCGetClassPerformance / GetStudentWeakness / GetLearningTrend / GetTeacherDashboard
// 裁决依据B8DownstreamClient 抽象)+ B2gRPC 首次实现)
/** 学生单科成绩(对齐 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 messageint64 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;
}

View File

@@ -1,11 +1,23 @@
// IamClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P2+ 需要的 iam RPC,实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC(未就绪 RPC 降级 mock + warning
// 接口定义所有 P2+ 需要的 iam RPC + REST 端点
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC/REST 真实调用
// 实现策略getUserInfo 走 gRPC其余走 RESTiam.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 tokenNestJS 注入用) */
@@ -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;

View File

@@ -1,6 +1,8 @@
// IamClient gRPC 实现B2 裁决:首次实现即 gRPC
// iam.proto 现状仅 GetUserInfoGetViewports/GetEffectivePermissions 待 coord 补全
// 未就绪 RPC 降级 mock + warning 日志president §2.6 降级模式 B
// IamClient gRPC + REST 实现B2 裁决:首次实现即 gRPCadmin 域补充 REST
// iam.proto 6 RPCRegister/Login/RefreshToken/Logout/GetUserInfo/GetEffectiveDataScope
// admin 域需要 roles/permissions/audit/users 等端点iam.proto 未声明,走 REST
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → 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;

View File

@@ -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;

View File

@@ -1,30 +1,36 @@
// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC
// iam.proto 现状:4 RPCRegister/Login/RefreshToken/GetUserInfo
// 待 coord 补全GetViewports / GetEffectivePermissions / GetEffectiveAccess / BatchGetUsers / GetChildrenByParent
// iam 下游服务类型定义(对齐 iam.proto + REST CRUD 端点
// iam.proto 现状:6 RPCRegister/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 字段对齐 protosnake_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;
/** 事件 IDUUID */
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[];
}

View File

@@ -0,0 +1,39 @@
// MsgClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P5+ 需要的 msg RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC
import type { CallContext } from "../types.js";
import type {
ListNotificationsRequest,
ListNotificationsResponse,
SearchNotificationsRequest,
SearchNotificationsResponse,
MarkAsReadRequest,
MarkAsReadResponse,
} from "./msg.types.js";
/** MsgClient DI tokenNestJS 注入用) */
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;
}>;
}

View File

@@ -0,0 +1,186 @@
// MsgClient gRPC 实现B2 裁决:首次实现即 gRPC
// msg.proto 现状 NotificationService 9 RPCcontract §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_casegRPC 返回需转 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");
}
}

View 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 };
}
}

View 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 {}

View File

@@ -0,0 +1,64 @@
// msg 下游服务类型定义(对齐 msg.proto
// msg.proto 现状NotificationService 9 RPCP5 启用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;
}