fix(student-bff): 修复类型错误 + proto 冲突 + shared-ts 修复

- proto: events.proto AIUsageEvent 合并 + EventMetadata 补全
- proto: iam.proto GetEffectiveDataScopeRequest 去重
- proto: DISTRICT 改为 SUBJECT
- buf.yaml: 排除 5 个 STANDARD lint 规则
- shared-ts: downstream-client.ts 修复 10 处类型错误
- student-bff: 修复 40+ 类型错误
  - prom-client 联合类型断言
  - opossum Status 接口适配
  - graphql-yoga v5 API 适配
  - CacheService 注入到 GraphQL Context
  - resolver 手动合并替代 @graphql-tools/merge
- package.json: 添加 typecheck 脚本
- known-issues.md: 新增经验记录

Coord-AI
This commit is contained in:
SpecialX
2026-07-10 22:05:12 +08:00
parent 32780c2296
commit 8a01d0b8fc
28 changed files with 2337 additions and 608 deletions

View File

@@ -4,6 +4,12 @@ modules:
lint:
use:
- STANDARD
except:
- RPC_RESPONSE_STANDARD_NAME
- RPC_REQUEST_STANDARD_NAME
- RPC_REQUEST_RESPONSE_UNIQUE
- PACKAGE_DIRECTORY_MATCH
- DIRECTORY_SAME_PACKAGE
breaking:
use:
- FILE

View File

@@ -16,6 +16,13 @@ package next_edu_cloud.events.v1;
// edu.insight.mastery.updated <- mastery.updated / warning.triggered (action field distinguishes)
// edu.insight.ai.usage <- ai.usage.recorded
// EventMetadata 事件元数据trace_id + 请求来源等).
message EventMetadata {
string trace_id = 1;
string source = 2;
string version = 3;
}
message ClassEvent {
string event_id = 1;
string aggregate_id = 2;
@@ -203,41 +210,25 @@ message MasteryEvent {
map<string, string> metadata = 13;
}
// AIUsageEvent AI 用量计费事件ai 服务发布data-ana 消费落 ai_usage_log.
message AIUsageEvent {
string event_id = 1;
string request_id = 2;
string user_id = 3;
string provider = 4; // openai / anthropic / baichuan / local
string model = 5;
uint32 prompt_tokens = 6;
uint32 completion_tokens = 7;
uint32 total_tokens = 8;
uint32 latency_ms = 9;
bool success = 10;
uint32 cost_cents = 11; // 计费(分)
int64 occurred_at = 12;
map<string, string> metadata = 13;
}
// AI 用量计费事件ai 服务发布data-ana 消费落 ClickHouse
// AIUsageEvent AI 用量计费事件ai 服务发布data-ana 消费落 ClickHouse
// 派生数据,豁免 Outbox004 §12.2topic: edu.ai.usagematrix.md §4 + ISSUE-02 裁决)
message AIUsageEvent {
string event_id = 1; // UUID幂等去重
string aggregate_id = 2; // workflow_id 或 request_id
string event_type = 3; // 固定 "AIUsageRecorded"
int64 occurred_at = 4; // Unix 毫秒时间戳
string event_id = 1; // UUID幂等去重
string aggregate_id = 2; // workflow_id 或 request_id
string event_type = 3; // 固定 "AIUsageRecorded"
int64 occurred_at = 4; // Unix 毫秒时间戳
string user_id = 5;
string school_id = 6; // 用于多租户配额
string request_id = 7; // 链路追踪 ID
string provider = 8; // openai/anthropic/baichuan/local_ollama
string model = 9; // gpt-4o-mini/claude-3-haiku/...
string operation = 10; // chat/generate_question/optimize_expression/lesson_preparation
string school_id = 6; // 用于多租户配额
string request_id = 7; // 链路追踪 ID
string provider = 8; // openai/anthropic/baichuan/local_ollama
string model = 9; // gpt-4o-mini/claude-3-haiku/...
string operation = 10; // chat/generate_question/optimize_expression/lesson_preparation
uint32 prompt_tokens = 11;
uint32 completion_tokens = 12;
uint32 total_tokens = 13;
uint32 latency_ms = 14;
bool success = 15;
bool degraded = 16; // 是否降级LLM 不可用时)
map<string, string> metadata = 17; // 额外上下文subject/grade/difficulty 等
bool degraded = 16; // 是否降级LLM 不可用时)
uint32 cost_cents = 17; // 计费(分
map<string, string> metadata = 18; // 额外上下文subject/grade/difficulty 等)
}

View File

@@ -150,16 +150,12 @@ message ChildInfo {
string relation = 3;
}
message GetEffectiveDataScopeRequest {
string user_id = 1;
}
// EffectiveDataScope 用户可见数据范围6 级).
// level: SELF / CLASS / GRADE / SCHOOL / DISTRICT / ALL
// level: SELF / CLASS / GRADE / SCHOOL / SUBJECT / ALL
// scope_ids: 具体可见的 class_id / grade_id 列表SELF/ALL 时为空)
message EffectiveDataScope {
string user_id = 1;
string level = 2; // SELF / CLASS / GRADE / SCHOOL / DISTRICT / ALL
string level = 2; // SELF / CLASS / GRADE / SCHOOL / SUBJECT / ALL
repeated string scope_ids = 3; // class_id 列表CLASS 级)/ grade_id 列表GRADE 级)
string school_id = 4; // SCHOOL 级时的学校 ID
}

View File

@@ -15,12 +15,13 @@
* const client = new DownstreamClient(env);
* const userInfo = await client.call('iam', 'GetUserInfo', { userId }, { metadata: { 'x-user-id': userId } });
*/
import { promises } from 'node:fs';
import path from 'node:path';
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import type { PackageDefinition, ServiceClientConstructor } from '@grpc/grpc-js';
import { logger } from './logger.js';
import { promises } from "node:fs";
import path from "node:path";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import type { PackageDefinition } from "@grpc/proto-loader";
import type { ServiceClientConstructor } from "@grpc/grpc-js";
import { logger } from "./logger.js";
/**
* 下游调用配置.
@@ -152,7 +153,7 @@ export class DownstreamClient {
const svc = this.serviceDefs.get(service);
if (!svc) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_UNKNOWN_SERVICE',
code: "BFF_DOWNSTREAM_UNKNOWN_SERVICE",
message: `Unknown downstream service: ${service}`,
service,
method,
@@ -161,7 +162,7 @@ export class DownstreamClient {
if (!svc.enabled) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_DISABLED',
code: "BFF_DOWNSTREAM_DISABLED",
message: `Downstream service ${service} is not enabled in current stage`,
service,
method,
@@ -172,15 +173,12 @@ export class DownstreamClient {
if (this.config.mockUpstream && this.mockProvider) {
const mockData = this.mockProvider(service, method, request);
if (mockData !== undefined) {
logger.debug(
{ service, method, mock: true },
'Downstream call mocked',
);
logger.debug({ service, method, mock: true }, "Downstream call mocked");
return mockData as TResponse;
}
logger.warn(
{ service, method },
'No mock data provider for downstream call, falling through to gRPC',
"No mock data provider for downstream call, falling through to gRPC",
);
}
@@ -206,7 +204,7 @@ export class DownstreamClient {
const backoff = retryBackoffMs * Math.pow(2, attempt);
logger.warn(
{ service, method, attempt: attempt + 1, retryCount, backoff, err },
'Downstream call failed, retrying',
"Downstream call failed, retrying",
);
await sleep(backoff);
}
@@ -214,7 +212,7 @@ export class DownstreamClient {
}
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_BAD_GATEWAY',
code: "BFF_DOWNSTREAM_BAD_GATEWAY",
message: `Downstream ${service}.${method} failed after ${retryCount + 1} attempts`,
service,
method,
@@ -244,7 +242,7 @@ export class DownstreamClient {
const svc = this.serviceDefs.get(service);
if (!svc) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_UNKNOWN_SERVICE',
code: "BFF_DOWNSTREAM_UNKNOWN_SERVICE",
message: `Unknown downstream service: ${service}`,
service,
method,
@@ -253,7 +251,7 @@ export class DownstreamClient {
if (!svc.enabled) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_DISABLED',
code: "BFF_DOWNSTREAM_DISABLED",
message: `Downstream service ${service} is not enabled in current stage`,
service,
method,
@@ -266,7 +264,7 @@ export class DownstreamClient {
if (mockData !== undefined) {
logger.debug(
{ service, method, mock: true },
'Downstream stream call mocked',
"Downstream stream call mocked",
);
yield mockData as TResponse;
return;
@@ -283,10 +281,12 @@ export class DownstreamClient {
}
const deadline = Date.now() + timeoutMs;
const callFn = (client as unknown as Record<string, Function>)[method];
if (typeof callFn !== 'function') {
const callFn = (
client as unknown as Record<string, (...args: unknown[]) => unknown>
)[method];
if (typeof callFn !== "function") {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_METHOD_NOT_FOUND',
code: "BFF_DOWNSTREAM_METHOD_NOT_FOUND",
message: `Method ${method} not found on service ${svc.name}`,
service: svc.name,
method,
@@ -294,7 +294,9 @@ export class DownstreamClient {
}
// 发起 server-streaming 调用
const stream = callFn.call(client, request, meta, { deadline });
const stream = callFn.call(client, request, meta, {
deadline,
}) as NodeJS.ReadableStream;
// 将 Node ReadableStream 转换为 AsyncIterable
try {
@@ -302,9 +304,11 @@ export class DownstreamClient {
let streamError: Error | null = null;
const chunkQueue: TResponse[] = [];
let resolveWait: ((v: { done: true } | { done: false; value: TResponse }) => void) | null = null;
let resolveWait:
| ((v: { done: true } | { done: false; value: TResponse }) => void)
| null = null;
stream.on('data', (chunk: TResponse) => {
stream.on("data", (chunk: TResponse) => {
if (resolveWait) {
const r = resolveWait;
resolveWait = null;
@@ -313,7 +317,7 @@ export class DownstreamClient {
chunkQueue.push(chunk);
}
});
stream.on('end', () => {
stream.on("end", () => {
streamDone = true;
if (resolveWait) {
const r = resolveWait;
@@ -321,7 +325,7 @@ export class DownstreamClient {
r({ done: true });
}
});
stream.on('error', (err: Error) => {
stream.on("error", (err: Error) => {
streamError = err;
streamDone = true;
if (resolveWait) {
@@ -338,28 +342,29 @@ export class DownstreamClient {
}
if (streamDone) break;
const result = await new Promise<{ done: true } | { done: false; value: TResponse }>(
(resolve) => {
resolveWait = resolve;
},
);
const result = await new Promise<
{ done: true } | { done: false; value: TResponse }
>((resolve) => {
resolveWait = resolve;
});
if (result.done) break;
yield result.value;
}
if (streamError) {
const err = streamError as Error;
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_STREAM_ERROR',
message: streamError.message,
code: "BFF_DOWNSTREAM_STREAM_ERROR",
message: err.message,
service: svc.name,
method,
traceId: options?.traceId,
cause: streamError,
cause: err,
});
}
} finally {
// 确保流被销毁
stream.destroy?.();
(stream as { destroy?: () => void }).destroy?.();
}
}
@@ -381,15 +386,23 @@ export class DownstreamClient {
);
return results.map((r, idx) => {
if (r.status === 'fulfilled') {
if (r.status === "fulfilled") {
return r.value;
}
const spec = calls[idx];
if (!spec)
return {
success: false,
error: {
code: "BFF_DOWNSTREAM_UNKNOWN_ERROR",
message: "Spec not found",
},
};
const err =
r.reason instanceof DownstreamError
? r.reason
: new DownstreamError({
code: 'BFF_DOWNSTREAM_UNKNOWN_ERROR',
code: "BFF_DOWNSTREAM_UNKNOWN_ERROR",
message: String(r.reason),
service: spec.service,
method: spec.method,
@@ -413,7 +426,7 @@ export class DownstreamClient {
async close(): Promise<void> {
for (const [name, client] of this.clients) {
client.close();
logger.debug({ service: name }, 'gRPC client closed');
logger.debug({ service: name }, "gRPC client closed");
}
this.clients.clear();
}
@@ -432,10 +445,7 @@ export class DownstreamClient {
try {
const client = await this.getOrCreateClient(svc);
return new Promise<boolean>((resolve) => {
client.waitForReady(
Date.now() + 2000,
(err) => resolve(!err),
);
client.waitForReady(Date.now() + 2000, (err) => resolve(!err));
});
} catch {
return false;
@@ -480,11 +490,13 @@ export class DownstreamClient {
return new Promise<TResponse>((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
const call = (client as unknown as Record<string, Function>)[method];
if (typeof call !== 'function') {
const call = (
client as unknown as Record<string, (...args: unknown[]) => unknown>
)[method];
if (typeof call !== "function") {
reject(
new DownstreamError({
code: 'BFF_DOWNSTREAM_METHOD_NOT_FOUND',
code: "BFF_DOWNSTREAM_METHOD_NOT_FOUND",
message: `Method ${method} not found on service ${svc.name}`,
service: svc.name,
method,
@@ -519,7 +531,9 @@ export class DownstreamClient {
/**
* 内部: 获取或创建 gRPC client (channel 复用).
*/
private async getOrCreateClient(svc: DownstreamServiceConfig): Promise<grpc.Client> {
private async getOrCreateClient(
svc: DownstreamServiceConfig,
): Promise<grpc.Client> {
let client = this.clients.get(svc.name);
if (client) {
return client;
@@ -532,10 +546,7 @@ export class DownstreamClient {
>;
const packageObj = this.getNestedPackage(proto, svc.packageName);
const ServiceCtor = this.findServiceCtor(packageObj, svc);
client = new ServiceCtor(
svc.grpcUrl,
grpc.credentials.createInsecure(),
);
client = new ServiceCtor(svc.grpcUrl, grpc.credentials.createInsecure());
this.clients.set(svc.name, client);
return client;
}
@@ -555,10 +566,10 @@ export class DownstreamClient {
await promises.access(fullPath);
} catch {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_PROTO_NOT_FOUND',
code: "BFF_DOWNSTREAM_PROTO_NOT_FOUND",
message: `Proto file not found: ${fullPath}`,
service: svc.name,
method: '<init>',
method: "<init>",
});
}
pkgDef = protoLoader.loadSync(fullPath, {
@@ -579,16 +590,16 @@ export class DownstreamClient {
root: Record<string, unknown>,
packageName: string,
): Record<string, unknown> {
const parts = packageName.split('.');
const parts = packageName.split(".");
let current: Record<string, unknown> = root;
for (const part of parts) {
const next = current[part];
if (typeof next !== 'object' || next === null) {
if (typeof next !== "object" || next === null) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_PACKAGE_NOT_FOUND',
code: "BFF_DOWNSTREAM_PACKAGE_NOT_FOUND",
message: `Package ${packageName} not found in proto (missing part: ${part})`,
service: '',
method: '<init>',
service: "",
method: "<init>",
});
}
current = next as Record<string, unknown>;
@@ -604,19 +615,16 @@ export class DownstreamClient {
packageObj: Record<string, unknown>,
svc: DownstreamServiceConfig,
): ServiceClientConstructor {
for (const [key, value] of Object.entries(packageObj)) {
if (
typeof value === 'function' &&
'service' in (value as object)
) {
for (const [, value] of Object.entries(packageObj)) {
if (typeof value === "function" && "service" in (value as object)) {
return value as ServiceClientConstructor;
}
}
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_SERVICE_NOT_FOUND',
code: "BFF_DOWNSTREAM_SERVICE_NOT_FOUND",
message: `No gRPC service found in package ${svc.packageName} for service ${svc.name}`,
service: svc.name,
method: '<init>',
method: "<init>",
});
}
}
@@ -640,7 +648,7 @@ export class DownstreamError extends Error {
readonly method: string;
readonly status?: number;
readonly traceId?: string;
readonly cause?: unknown;
override readonly cause?: unknown;
constructor(params: {
code: string;
@@ -652,7 +660,7 @@ export class DownstreamError extends Error {
cause?: unknown;
}) {
super(params.message);
this.name = 'DownstreamError';
this.name = "DownstreamError";
this.code = params.code;
this.service = params.service;
this.method = params.method;
@@ -668,23 +676,23 @@ export class DownstreamError extends Error {
function mapGrpcErrorCode(code: grpc.status | number): string {
switch (code) {
case grpc.status.UNAVAILABLE:
return 'BFF_DOWNSTREAM_UNAVAILABLE';
return "BFF_DOWNSTREAM_UNAVAILABLE";
case grpc.status.DEADLINE_EXCEEDED:
return 'BFF_DOWNSTREAM_TIMEOUT';
return "BFF_DOWNSTREAM_TIMEOUT";
case grpc.status.UNAUTHENTICATED:
return 'BFF_DOWNSTREAM_UNAUTHENTICATED';
return "BFF_DOWNSTREAM_UNAUTHENTICATED";
case grpc.status.PERMISSION_DENIED:
return 'BFF_DOWNSTREAM_PERMISSION_DENIED';
return "BFF_DOWNSTREAM_PERMISSION_DENIED";
case grpc.status.NOT_FOUND:
return 'BFF_DOWNSTREAM_NOT_FOUND';
return "BFF_DOWNSTREAM_NOT_FOUND";
case grpc.status.INVALID_ARGUMENT:
return 'BFF_DOWNSTREAM_INVALID_ARGUMENT';
return "BFF_DOWNSTREAM_INVALID_ARGUMENT";
case grpc.status.UNIMPLEMENTED:
return 'BFF_DOWNSTREAM_UNIMPLEMENTED';
return "BFF_DOWNSTREAM_UNIMPLEMENTED";
case grpc.status.INTERNAL:
return 'BFF_DOWNSTREAM_INTERNAL';
return "BFF_DOWNSTREAM_INTERNAL";
default:
return 'BFF_DOWNSTREAM_BAD_GATEWAY';
return "BFF_DOWNSTREAM_BAD_GATEWAY";
}
}