feat(config-service): split config-service from iam for plugin/layout config
- new NestJS service on port 3011/gRPC 50059 (ADR-026) - owns 6 config_ tables (plugin/role-mapping/role-layout/layout-tpl/user-override/outbox) - GraphQL Federation 2 subgraph with DataLoader + RouterAuthGuard - gRPC ConfigService + admin REST CRUD + user REST API - three-layer merge: registry.defaultProps + roleMapping.widget_props + userOverride.props - Redis cache with 5min TTL - registered in apollo-router supergraph + docker-compose + port-allocation Implements M3 of v2.1 migration plan.
This commit is contained in:
103
services/config-service/src/graphql/dataloader.service.ts
Normal file
103
services/config-service/src/graphql/dataloader.service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* config-service DataLoader 服务(v2.1 M3 / ADR-035)
|
||||
*
|
||||
* 强制约束:@key Reference Resolver 必须使用 DataLoader 请求合并。
|
||||
*
|
||||
* 每个 GraphQL 请求独立 DataLoader 实例(请求级缓存)。
|
||||
* 通过 REQUEST scope 注入,确保不同请求不共享缓存。
|
||||
*/
|
||||
import { Injectable, Scope } from "@nestjs/common";
|
||||
import DataLoader from "dataloader";
|
||||
import { ConfigRepository } from "../config-config/config.repository.js";
|
||||
|
||||
/**
|
||||
* PluginRegistry Entity(GraphQL 输出类型)
|
||||
* 对应 config_plugin_registry 表
|
||||
*/
|
||||
export interface PluginRegistryEntity {
|
||||
pluginId: string;
|
||||
category: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
isBuiltin: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserLayoutOverride Entity(GraphQL 输出类型)
|
||||
* 对应 config_user_layout_override 表
|
||||
*/
|
||||
export interface UserLayoutOverrideEntity {
|
||||
userId: string;
|
||||
activeLayout: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class DataLoaderService {
|
||||
private pluginLoaderInstance: DataLoader<
|
||||
string,
|
||||
PluginRegistryEntity | null
|
||||
> | null = null;
|
||||
private userLayoutLoaderInstance: DataLoader<
|
||||
string,
|
||||
UserLayoutOverrideEntity | null
|
||||
> | null = null;
|
||||
|
||||
constructor(private readonly repository: ConfigRepository) {}
|
||||
|
||||
/** PluginRegistry @key 解析器 DataLoader */
|
||||
get pluginLoader(): DataLoader<string, PluginRegistryEntity | null> {
|
||||
if (!this.pluginLoaderInstance) {
|
||||
this.pluginLoaderInstance = new DataLoader<
|
||||
string,
|
||||
PluginRegistryEntity | null
|
||||
>(async (pluginIds) => {
|
||||
const plugins = await this.repository.batchFindPlugins([...pluginIds]);
|
||||
const map = new Map(
|
||||
plugins.map((p) => [
|
||||
p.pluginId,
|
||||
{
|
||||
pluginId: p.pluginId,
|
||||
category: p.category,
|
||||
version: p.version ?? "",
|
||||
displayName: p.displayName,
|
||||
description: p.description ?? "",
|
||||
isBuiltin: p.isBuiltin,
|
||||
isActive: p.isActive,
|
||||
} satisfies PluginRegistryEntity,
|
||||
]),
|
||||
);
|
||||
return pluginIds.map((id) => map.get(id) ?? null);
|
||||
});
|
||||
}
|
||||
return this.pluginLoaderInstance;
|
||||
}
|
||||
|
||||
/** UserLayoutOverride @key 解析器 DataLoader */
|
||||
get userLayoutLoader(): DataLoader<string, UserLayoutOverrideEntity | null> {
|
||||
if (!this.userLayoutLoaderInstance) {
|
||||
this.userLayoutLoaderInstance = new DataLoader<
|
||||
string,
|
||||
UserLayoutOverrideEntity | null
|
||||
>(async (userIds) => {
|
||||
const overrides = await this.repository.batchFindUserLayoutOverrides([
|
||||
...userIds,
|
||||
]);
|
||||
const map = new Map(
|
||||
overrides.map((o) => [
|
||||
o.userId,
|
||||
{
|
||||
userId: o.userId,
|
||||
activeLayout: o.activeLayout ?? "",
|
||||
updatedAt: o.updatedAt.toISOString(),
|
||||
} satisfies UserLayoutOverrideEntity,
|
||||
]),
|
||||
);
|
||||
return userIds.map((id) => map.get(id) ?? null);
|
||||
});
|
||||
}
|
||||
return this.userLayoutLoaderInstance;
|
||||
}
|
||||
}
|
||||
57
services/config-service/src/graphql/graphql.module.ts
Normal file
57
services/config-service/src/graphql/graphql.module.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* config-service GraphQL 子图模块(v2.1 M3)
|
||||
*
|
||||
* Apollo Federation 2 子图,提供 4 类 Entity 查询:
|
||||
* - PluginRegistry(@key(fields: "pluginId"))
|
||||
* - RolePluginMapping
|
||||
* - LayoutTemplate
|
||||
* - UserLayoutOverride(@key(fields: "userId"))
|
||||
*
|
||||
* 强制约束:
|
||||
* - @key 解析器必须使用 DataLoader(ADR-035)
|
||||
* - RouterAuthGuard 校验 Router-Authorization Header(ADR-036)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { GraphQLModule } from "@nestjs/graphql";
|
||||
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
|
||||
import { join } from "node:path";
|
||||
import { GraphqlContext } from "@edu/shared-ts/federation";
|
||||
import { ConfigModule } from "../config-config/config.module.js";
|
||||
import { PluginResolver } from "./resolvers/plugin.resolver.js";
|
||||
import { LayoutTemplateResolver } from "./resolvers/layout-template.resolver.js";
|
||||
import { UserLayoutResolver } from "./resolvers/user-layout.resolver.js";
|
||||
import { DataLoaderService } from "./dataloader.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
// Federation 2 子图
|
||||
autoSchemaFile: {
|
||||
path: join(process.cwd(), "src/graphql/generated/schema.graphql"),
|
||||
federation: 2,
|
||||
},
|
||||
// /graphql 端点(Apollo Router 访问入口)
|
||||
path: "/graphql",
|
||||
// 禁用 playground(生产环境通过 Router 访问)
|
||||
playground: process.env.NODE_ENV === "development",
|
||||
introspection: process.env.NODE_ENV === "development",
|
||||
// Context 从 HTTP headers 构造
|
||||
context: (ctx: {
|
||||
req: { headers: Record<string, string | undefined> };
|
||||
}) => ({
|
||||
req: ctx.req,
|
||||
graphqlContext: GraphqlContext.fromHeaders(ctx.req.headers),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
PluginResolver,
|
||||
LayoutTemplateResolver,
|
||||
UserLayoutResolver,
|
||||
DataLoaderService,
|
||||
],
|
||||
exports: [DataLoaderService],
|
||||
})
|
||||
export class GraphqlModule {}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* LayoutTemplate Resolver(v2.1 M3)
|
||||
*
|
||||
* Apollo Federation 子图:LayoutTemplate Entity(无 @key,仅 Query)
|
||||
* - layoutTemplates: [LayoutTemplate!]!
|
||||
*/
|
||||
import { Resolver, Query, ObjectType, Field, ID } from "@nestjs/graphql";
|
||||
import { ConfigService } from "../../config-config/config.service.js";
|
||||
|
||||
@ObjectType()
|
||||
export class LayoutTemplateGql {
|
||||
@Field(() => ID)
|
||||
layoutId!: string;
|
||||
|
||||
@Field()
|
||||
displayName!: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
description: string | null = null;
|
||||
|
||||
@Field()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
@Resolver(() => LayoutTemplateGql)
|
||||
export class LayoutTemplateResolver {
|
||||
constructor(private readonly service: ConfigService) {}
|
||||
|
||||
@Query(() => [LayoutTemplateGql])
|
||||
async layoutTemplates(): Promise<LayoutTemplateGql[]> {
|
||||
const templates = await this.service.listLayoutTemplates();
|
||||
return templates.map((t) => ({
|
||||
layoutId: t.layoutId,
|
||||
displayName: t.displayName,
|
||||
description: t.description,
|
||||
isActive: t.isActive,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* PluginRegistry Resolver(v2.1 M3)
|
||||
*
|
||||
* Apollo Federation 子图:PluginRegistry Entity
|
||||
* - @key(fields: "pluginId") 支持跨子图引用
|
||||
* - @ResolveReference 使用 DataLoader 批量加载(ADR-035)
|
||||
* - Query 入口供 Apollo Router 直接查询
|
||||
*/
|
||||
import {
|
||||
Resolver,
|
||||
Query,
|
||||
Args,
|
||||
ID,
|
||||
ResolveReference,
|
||||
ObjectType,
|
||||
Field,
|
||||
Directive,
|
||||
} from "@nestjs/graphql";
|
||||
import {
|
||||
DataLoaderService,
|
||||
type PluginRegistryEntity,
|
||||
} from "../dataloader.service.js";
|
||||
import { ConfigService } from "../../config-config/config.service.js";
|
||||
|
||||
@ObjectType()
|
||||
@Directive(`@key(fields: "pluginId")`)
|
||||
export class PluginRegistry {
|
||||
@Field(() => ID)
|
||||
pluginId!: string;
|
||||
|
||||
@Field()
|
||||
category!: string;
|
||||
|
||||
@Field()
|
||||
version!: string;
|
||||
|
||||
@Field()
|
||||
displayName!: string;
|
||||
|
||||
@Field()
|
||||
description!: string;
|
||||
|
||||
@Field()
|
||||
isBuiltin!: boolean;
|
||||
|
||||
@Field()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
@Resolver(() => PluginRegistry)
|
||||
export class PluginResolver {
|
||||
constructor(
|
||||
private readonly loader: DataLoaderService,
|
||||
private readonly service: ConfigService,
|
||||
) {}
|
||||
|
||||
@ResolveReference()
|
||||
async resolveReference(ref: {
|
||||
pluginId: string;
|
||||
}): Promise<PluginRegistryEntity | null> {
|
||||
return this.loader.pluginLoader.load(ref.pluginId);
|
||||
}
|
||||
|
||||
@Query(() => PluginRegistry, { nullable: true })
|
||||
async plugin(
|
||||
@Args("pluginId", { type: () => ID }) pluginId: string,
|
||||
): Promise<PluginRegistryEntity | null> {
|
||||
return this.loader.pluginLoader.load(pluginId);
|
||||
}
|
||||
|
||||
@Query(() => [PluginRegistry])
|
||||
async plugins(): Promise<PluginRegistryEntity[]> {
|
||||
const list = await this.service.listPlugins({ isActive: true });
|
||||
return list.map((p) => ({
|
||||
pluginId: p.pluginId,
|
||||
category: p.category,
|
||||
version: p.version ?? "",
|
||||
displayName: p.displayName,
|
||||
description: p.description ?? "",
|
||||
isBuiltin: p.isBuiltin,
|
||||
isActive: p.isActive,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* UserLayoutOverride Resolver(v2.1 M3)
|
||||
*
|
||||
* Apollo Federation 子图:UserLayoutOverride Entity
|
||||
* - @key(fields: "userId") 支持跨子图引用
|
||||
* - @ResolveReference 使用 DataLoader 批量加载(ADR-035)
|
||||
* - Query 入口供 Apollo Router 直接查询
|
||||
*/
|
||||
import {
|
||||
Resolver,
|
||||
Query,
|
||||
Args,
|
||||
ID,
|
||||
ResolveReference,
|
||||
ObjectType,
|
||||
Field,
|
||||
Directive,
|
||||
} from "@nestjs/graphql";
|
||||
import {
|
||||
DataLoaderService,
|
||||
type UserLayoutOverrideEntity,
|
||||
} from "../dataloader.service.js";
|
||||
|
||||
@ObjectType()
|
||||
@Directive(`@key(fields: "userId")`)
|
||||
export class UserLayoutOverrideGql {
|
||||
@Field(() => ID)
|
||||
userId!: string;
|
||||
|
||||
@Field()
|
||||
activeLayout!: string;
|
||||
|
||||
@Field()
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
@Resolver(() => UserLayoutOverrideGql)
|
||||
export class UserLayoutResolver {
|
||||
constructor(private readonly loader: DataLoaderService) {}
|
||||
|
||||
@ResolveReference()
|
||||
async resolveReference(ref: {
|
||||
userId: string;
|
||||
}): Promise<UserLayoutOverrideEntity | null> {
|
||||
return this.loader.userLayoutLoader.load(ref.userId);
|
||||
}
|
||||
|
||||
@Query(() => UserLayoutOverrideGql, { nullable: true })
|
||||
async userLayoutOverride(
|
||||
@Args("userId", { type: () => ID }) userId: string,
|
||||
): Promise<UserLayoutOverrideEntity | null> {
|
||||
return this.loader.userLayoutLoader.load(userId);
|
||||
}
|
||||
}
|
||||
34
services/config-service/src/graphql/router-auth.guard.ts
Normal file
34
services/config-service/src/graphql/router-auth.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* config-service RouterAuthGuard 包装(v2.1 ADR-036)
|
||||
*
|
||||
* 仅作用于 /graphql 端点,REST 路由放行(已有 PermissionGuard)。
|
||||
*/
|
||||
import { Injectable, ExecutionContext } from "@nestjs/common";
|
||||
import {
|
||||
RouterAuthGuard as BaseRouterAuthGuard,
|
||||
type RouterAuthConfig,
|
||||
} from "@edu/shared-ts/federation";
|
||||
|
||||
@Injectable()
|
||||
export class RouterAuthGuard extends BaseRouterAuthGuard {
|
||||
constructor() {
|
||||
const config: RouterAuthConfig = {
|
||||
secret: process.env.ROUTER_AUTH_SECRET ?? "",
|
||||
devMode: process.env.DEV_MODE === "true",
|
||||
};
|
||||
super(config);
|
||||
}
|
||||
|
||||
override canActivate(ctx: ExecutionContext): boolean {
|
||||
const req = ctx.switchToHttp().getRequest<{
|
||||
url: string;
|
||||
}>();
|
||||
|
||||
// 仅 GraphQL 端点需要校验,REST 路由放行
|
||||
if (!req.url?.startsWith("/graphql")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(ctx);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user