修复涵盖 6 大类问题: 1. api-gateway - 路径前缀剥离 /api 而非 /api/v1,保留下游 /v1/ controller 前缀 - JWKS URL 默认值修复 - publicPaths 白名单对齐 /v1/iam/* 2. iam - iam.module.ts exports 补充 PermissionCacheService 和 IamRepository - main.ts resolveProtoPath() 多路径探测 proto 文件 3. core-edu - app.module.ts AuthMiddleware 全局注册 4. BFF 层 GraphQL 端点(teacher-bff / parent-bff / student-bff) - teacher-bff: mock dataScope OWN→SELF 对齐 GraphQL enum;WHATWG Request header .get() 提取 - parent-bff: handleNodeRequestAndResponse 不存在 → 直接 yoga(req,res);WHATWG Request header .get() 提取 - student-bff: auth.resolver 移除 ActionState 信封返回扁平对象;WHATWG Request header .get() 提取 5. ai - Kafka 事务降级 + 10s 超时 - gRPC 拦截器降级 - dev mode 禁用事务模式 6. 前端 + 共享包 - teacher-portal: MF 插件条件实例化 + transpilePackages + extensionAlias - ui-components: error-boundary.tsx 添加 use client - ui-tokens: tailwind-theme.css 移除 @layer base - shared-ts: 导出从源码改为 dist 编译产物;OutboxModule global:true 7. infra - .gitignore 补充 keys/ *.pem *.key secrets/ 排除规则 - infra/init-sql/02-all-services-schema.sql 36 张表 DDL 验证结果: - TS typecheck: 19 个 workspace 项目全部通过 - Go vet + Ruff: 通过 - 15 服务全部启动成功 - 3 个 BFF GraphQL 端点 + 4 个前端页面全部 200 - Gateway → iam → core-edu 端到端链路验证通过 AI identity: trae-main(集成测试修复会话)
139 lines
3.8 KiB
TypeScript
139 lines
3.8 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
||
import { dirname, resolve } from "node:path";
|
||
import {
|
||
credentials,
|
||
loadPackageDefinition,
|
||
type ChannelCredentials,
|
||
} from "@grpc/grpc-js";
|
||
import protoLoader from "@grpc/proto-loader";
|
||
import { logger } from "../../shared/observability/logger.js";
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
||
const PROTO_ROOT = resolve(
|
||
__dirname,
|
||
"../../../../packages/shared-proto/proto",
|
||
);
|
||
|
||
const LOADER_OPTIONS: protoLoader.Options = {
|
||
keepCase: false,
|
||
longs: String,
|
||
enums: String,
|
||
defaults: true,
|
||
oneofs: true,
|
||
};
|
||
|
||
/**
|
||
* gRPC client 工厂(对齐 02-architecture-design.md §1.4 clients/grpc/)。
|
||
*
|
||
* 使用 @grpc/proto-loader 动态加载 proto 文件,
|
||
* 通过 @grpc/grpc-js 创建 Channel + Client 实例。
|
||
*
|
||
* proto-loader keepCase: false → snake_case → camelCase
|
||
* longs: String → int64 → string
|
||
* enums: String → enum → string
|
||
*
|
||
* 返回的 client 为动态类型(proto-loader 不生成静态 TS 类型),
|
||
* 调用方通过 `as unknown as SpecificClient` 断言为具体接口。
|
||
*/
|
||
export function createGrpcClient<TService>(options: {
|
||
protoFile: string;
|
||
packageName: string;
|
||
serviceName: string;
|
||
target: string;
|
||
}): TService {
|
||
const protoPath = resolve(PROTO_ROOT, options.protoFile);
|
||
|
||
logger.info(
|
||
{
|
||
protoFile: options.protoFile,
|
||
packageName: options.packageName,
|
||
serviceName: options.serviceName,
|
||
target: options.target,
|
||
},
|
||
"Creating gRPC client",
|
||
);
|
||
|
||
const packageDefinition = protoLoader.loadSync(protoPath, LOADER_OPTIONS);
|
||
const protoDescriptor = loadPackageDefinition(packageDefinition);
|
||
|
||
// Navigate: protoDescriptor.next_edu_cloud.iam.v1.IamService
|
||
const packages = options.packageName.split(".");
|
||
let current: unknown = protoDescriptor;
|
||
for (const pkg of packages) {
|
||
current = (current as Record<string, unknown>)[pkg];
|
||
if (current === undefined) {
|
||
throw new Error(
|
||
`gRPC package "${options.packageName}" not found in proto ${options.protoFile}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const ServiceConstructor = (current as Record<string, unknown>)[
|
||
options.serviceName
|
||
] as {
|
||
new (target: string, creds: ChannelCredentials): TService;
|
||
};
|
||
|
||
if (!ServiceConstructor) {
|
||
throw new Error(
|
||
`gRPC service "${options.serviceName}" not found in package "${options.packageName}"`,
|
||
);
|
||
}
|
||
|
||
// proto-loader 动态加载的 client 构造函数类型未知,
|
||
// 从 unknown 断言为目标接口(项目规则允许从 unknown 转换)
|
||
return new ServiceConstructor(
|
||
options.target,
|
||
credentials.createInsecure(),
|
||
) as unknown as TService;
|
||
}
|
||
|
||
/**
|
||
* 将 gRPC callback 风格调用转为 Promise,同时记录 metrics。
|
||
*
|
||
* proto-loader 加载的 client 方法签名为:
|
||
* client.Method(request, metadata?, callback)
|
||
*
|
||
* 此辅助函数省略 metadata 参数,仅处理 request → callback → Promise。
|
||
* 需要 metadata 的调用(如传递 trace context)应直接操作 raw client。
|
||
*/
|
||
export function callGrpc<TReq, TRes>(
|
||
client: unknown,
|
||
method: string,
|
||
request: TReq,
|
||
serviceName: string,
|
||
timeoutMs = 5000,
|
||
): Promise<TRes> {
|
||
const rawClient = client as Record<string, unknown>;
|
||
const fn = rawClient[method] as (
|
||
req: TReq,
|
||
callback: (err: Error | null, res: TRes) => void,
|
||
) => void;
|
||
|
||
if (typeof fn !== "function") {
|
||
return Promise.reject(
|
||
new Error(`gRPC method "${method}" not found on ${serviceName} client`),
|
||
);
|
||
}
|
||
|
||
return new Promise<TRes>((resolvePromise, reject) => {
|
||
const timer = setTimeout(() => {
|
||
reject(
|
||
new Error(
|
||
`gRPC call ${serviceName}.${method} timed out after ${timeoutMs}ms`,
|
||
),
|
||
);
|
||
}, timeoutMs);
|
||
|
||
fn.call(rawClient, request, (err, res) => {
|
||
clearTimeout(timer);
|
||
if (err) {
|
||
reject(err);
|
||
} else {
|
||
resolvePromise(res);
|
||
}
|
||
});
|
||
});
|
||
}
|