项目:nestjs-admin | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本文选取三个核心文件做"带码走读":不是概述它们做什么(那是《07-模块详解》的任务),而是逐段贴出真实源码、解释每一层在做什么、为什么这么写、注释背后藏着什么历史。三个走读对象各代表一类能力:gateway/src/consul/consul.service.ts 代表服务治理(发现、竞速、自愈),user/src/app.controller.ts 的用户管理段配合网关两跳代表业务全链路(HTTP 控制器→客户端持有器→gRPC→Prisma→异常翻译),vue-admin/src/utils/http/index.ts 与权限指令代表前端基建(无感刷新队列与按钮级权限的消费端)。走读约定:代码块均为仓库原文摘录(省略号表示截断),行号以当前仓库为准;每段代码后的解说才是正文重点。建议对照源码并行阅读,走读过程中任何"看起来怪"的写法都在文中给出动机——若某处解说与你的阅读直觉冲突,先怀疑直觉,再怀疑代码,这是本仓库的常态(大量"怪"是有注释背书的决策,见《16-决策记录ADR》)。
文件开头的第一段非 import 代码就确立了整个文件的设计核心:
/**
* 跨 Consul 实例共享的 gRPC 客户端注册表。
* 每条记录 = (consul 实例, 远端服务) 的一个可订阅客户端槽位;
* 消费者订阅任意一条,恢复时 updateGrpcClient 会通知所有同服务条目。
*/
const grpcClientRegistry: GrpcClientInterface[] = [];
/** 仅供测试复位注册表 */
export function resetGrpcClientRegistry() {
grpcClientRegistry.length = 0;
}注意两个细节。第一,grpcClientRegistry 是模块级数组而不是 ConsulService 的实例字段——因为 app.module.ts 会 forRoot 出 consul1、consul2 两个 ConsulService 实例,而客户端槽位必须跨实例共享(同一远端服务可能从任一注册中心发现),放实例字段会导致两个实例各建一套客户端互不感知。第二,配套的 resetGrpcClientRegistry 导出函数是为 consul.service.spec.ts 服务的测试钩子,模块级状态无法靠 Nest 的 DI 复位,只能手动暴露清理入口——单测注解里出现"仅供测试"的导出,是这个设计模式的常见伴随物。
async initGrpcClient(options: ConsulServiceOptions, _serviceInfo?: ServiceInfo) {
const { serviceName, packageName, protoName } = options;
let item: GrpcClientInterface;
if (!_serviceInfo) {
let existing = grpcClientRegistry.find(
(c) => c.consulName === this.name && c.serviceName === serviceName,
);
if (!existing) {
existing = {
client: new BehaviorSubject<ClientGrpc | null>(null),
consulName: this.name,
consulClient: this.consulClient,
serviceName,
consulService: this,
};
grpcClientRegistry.push(existing);
}
item = existing;
} else if (_serviceInfo.client) {
item = _serviceInfo.client;
}
// ...
const serviceInfo = _serviceInfo || (await this.getServiceInfo(serviceName));
if (serviceInfo) {
const tlsEnabled = process.env.GRPC_TLS === 'on';
const client = ClientProxyFactory.create({
transport: Transport.GRPC,
options: {
url: `${serviceInfo.address}:${serviceInfo.port}`,
package: packageName,
packageDefinition: loadProto(protoName || packageName),
...(tlsEnabled
? { credentials: ChannelCredentials.createSsl(loadGrpcTls().rootCerts) }
: {}),
},
});
item.client.next(client);
} else {
// Node 对未处理的 Promise 拒绝会直接崩溃进程,发现失败时必须兜底并重试
const retryUpdate = () => {
this.updateGrpcClient(serviceName).catch((err: Error) => {
this.logger.warn(`updateGrpcClient failed: ${err.message}, retrying in 5s`);
setTimeout(retryUpdate, 5000);
});
};
retryUpdate();
}
return item;
}这段方法有三个值得逐句咀嚼的地方。其一,方法有两个入口:_serviceInfo 缺省时走"首次发现"路径(注册新槽位并查询 Consul),传入时走"重建"路径(updateGrpcClient 已经查到了新地址,直接复用传入的槽位重建客户端)——一个方法服务两条生命周期,靠可选参数区分,这是理解本文件的关键钥匙。其二,item.client.next(client) 是整个机制的"发令枪":BehaviorSubject 推送新客户端后,所有订阅过这个槽位的消费者(AuthService、AuthorizationService、SystemClientService 等)在下一个事件循环里同步拿到新实例,业务代码完全无感——这就是"客户端热更新"的全部实现,没有任何魔法。其三,else 分支的注释直白交代了为什么发现失败不能简单抛出:Node 对未处理的 Promise 拒绝默认崩进程,而本模块在构造函数里被同步调用(initClients() 无 await),任何抛出都会变成 unhandled rejection;于是失败被翻译成"5 秒后重试的自愈链",直到远端服务在 Consul 转绿。这个分支与《16-决策记录ADR》ADR-003 的"永不崩进程"取向互为印证。
async getServiceInfo(serviceName: string, client?: ConsulHttpClient) {
const consulClient = client || this.consulClient;
return firstValueFrom(
from(
consulClient.health.service({ service: serviceName, passing: true }) as Promise<any[]>,
).pipe(
mergeMap((healthServices) => {
if (!healthServices.length) {
return throwError(() => new Error('no service found'));
}
const service = healthServices[Math.floor(Math.random() * healthServices.length)];
return of({
address: service.Service.Address,
port: service.Service.Port,
serviceName,
});
}),
retry({
count: 3,
delay: (retryCount) => {
const delayDuration = Math.pow(2, retryCount) * 1000;
return timer(delayDuration);
},
}),
catchError((err) => {
this.logger.warn(`Error fetching service info: ${err.message}`);
return of(null);
}),
),
);
}这是单注册中心视角的发现原语,三层管道各司其职:health.service({passing: true}) 调 Consul 的 /v1/health/service/:name?passing=true 只取健康实例(proto-pkg/src/utils/consul-http.ts 的薄封装);mergeMap 里 Math.floor(Math.random() * length) 完成客户端侧随机负载均衡——多实例扩容时网关零配置生效的全部秘密就在这一行;retry({count:3, delay}) 以 2s/4s/8s 指数退避容忍瞬时抖动(Consul agent 重启窗口约 1-2 秒,三次重试足以跨过)。最后一个 catchError 把彻底失败压平为 of(null) 而不是抛出——返回 null 是本文件内部约定俗成的"本轮没找到"信号,由调用方决定下一步(initGrpcClient 走重试链,retryGetServiceInfo 过滤掉)。把错误语义收敛为"null 值"而不是异常栈,让两条调用路径可以用同一套管道处理,这是流式代码里很实用的一致性技巧。
// 并行在多个consulClient上调用 -> 其中有一个serviceInfo响应回来 -> 中止,返回该serviceInfo
// mergeMap -> 并行 concatMap -> 串行
async retryGetServiceInfo(serviceName: string) {
const serviceInfoObservable = from(this.grpcClients).pipe(
mergeMap((client) =>
from(this.getServiceInfo(serviceName, client.consulClient)).pipe(
filter((result) => result !== null),
map((result) => ({ ...result, client })),
),
),
take(1),
);
const serviceInfo = await firstValueFrom(serviceInfoObservable).catch(
(err: Error) => {
// 所有 Consul 实例均未命中时 firstValueFrom 抛 EmptyError,
// 转成带上下文的常规错误,避免调用侧出现不可诊断的裸异常
if (err.message === 'no elements in sequence') {
throw new Error(
`[${this.name}] no passing instance found for ${serviceName} in any consul`,
);
}
throw err;
},
);
// ...
}首行注释把设计的灵魂写在脸上:mergeMap 并行、concatMap 串行——一字之差决定了这是"竞速"还是"接力"。当前实现里所有注册中心的查询同时发出,take(1) 取第一个非 null 结果后立即取消其余订阅(rxjs 的 take 语义自带取消传播),任一 Consul 可用即可用,故障容灾的延迟代价趋近于零。代码下方那个 EmptyError 翻译分支则是实战教育的产物:当所有实例都返回 null 时(被 filter 全部滤掉),流以空序列完结,firstValueFrom 抛出的 EmptyError("no elements in sequence") 对调用方完全不可诊断,于是这里把裸异常翻译成带注册中心名与服务名的常规 Error——HANDOFF 陷阱清单里"no passing instance found"这个排障关键词就是从这里打进日志的(《12-FAQ与故障排查》§3 决策树一的第一匹配项)。
// 更新grpcClient
async updateGrpcClient(serviceName: string) {
const grpcOptions = Array.isArray(this.services)
? this.services.find((opt) => opt.serviceName === serviceName)
: this.services;
if (!grpcOptions) {
throw new Error(`service ${serviceName} not configured on ${this.name}`);
}
const serviceInfo = await this.retryGetServiceInfo(serviceName);
// 多 Consul 实例下订阅方可能挂在任意一条客户端条目上,
// 恢复时必须通知所有同服务条目,否则订阅在"落选"条目上的消费者永远收不到新 client
const targets = grpcClientRegistry.filter((c) => c.serviceName === serviceName);
await Promise.all(
targets.map((item) =>
this.initGrpcClient(grpcOptions, { ...serviceInfo, client: item }),
),
);
return serviceInfo.client;
}自愈的触发链是:gRPC 调用失败 → promisifyServiceMethods 挂的错误回调(proto-pkg/src/utils/grpc-utils.ts)→ 本方法。方法内 targets 的 filter 是这段代码最容易被误删的一行:删掉它、只重建自己那条槽位,程序在单 Consul 下依然工作,但在双 Consul 部署下会出现"订阅挂在 consul1 槽位的 AuthService 永远收不到新客户端、而订阅在 consul2 槽位的 SystemClientService 一切正常"的幽灵故障——注释里"落选条目"四个字是有人在真环境踩过之后才补上的。Promise.all 并行重建所有同服务条目,每个条目复用同一份 serviceInfo(同一次发现的地址),最后返回 serviceInfo.client(即"发现结果附带的槽位",注意它指向 grpcClientRegistry 中的一条记录,而不是 ClientGrpc 实例本身——字段名略有迷惑性,调用方如 AuthService 拿到后会重新走 initGrpcClient(newClient) 订阅新客户端)。整个文件 267 行,没有一行日志驱动的状态机、没有引入任何定时器依赖,自愈完全是"失败触发、按需重建"的反应式设计——这是它能在 26 个网关单测里被充分覆盖的原因(consul.service.spec.ts 针对竞速、广播、注册表复位各有用例)。
@ApiTags('system')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'), PoliciesGuard)
@Controller('system/users')
export class UserController {
// ...
@CheckPolicies((ability) => ability.can('system:user:create', 'perm'))
@Post()
create(
@Body() payload: {
username: string; password: string; nickname?: string;
phone?: string; email?: string; deptId?: number; roleIds?: number[];
},
) {
return this.userClient.createUser(payload);
}类装饰器链的阅读顺序是自下而上的流水线声明:@Controller('system/users') 定路由前缀,@UseGuards(AuthGuard('jwt'), PoliciesGuard) 声明守卫顺序(先认证后授权,顺序错了 PoliciesGuard 读不到 req.user 会 403"缺少用户身份"),方法上 @CheckPolicies 把判定闭包写进路由元数据。注意 create 方法体只有一行透传、没有任何校验——入参合法性交给全局 ValidationPipe(白名单剥离)与下游服务;对比同文件的 update/remove 里有内联的"不能停用/删除自己"校验,可以看到薄网关的边界落点:与会话上下文相关的兜底留在网关,纯业务校验下沉(ADR-011)。@Body() payload 没有挂 DTO 类,因此 ValidationPipe 在此端点只做"剥未知属性"不做结构校验——这是本仓库 controller 层的普遍写法(个别端点如 signin 用了 SigninUserDto),深究其原因属路径依赖而非决策,新代码建议带 DTO(⚠️ 待确认为何未统一)。
// gateway/src/system/user-client.service.ts
createUser(payload: {
username: string; password: string; nickname?: string;
phone?: string; email?: string; deptId?: number; roleIds?: number[];
}) {
return this.requireClient().createUser(payload);
}
private requireClient(): UserDeptApi {
if (!this.userService) {
throw new Error('user service unavailable');
}
return this.userService;
}客户端持有器的职责链在《07-模块详解》§1.3 已有总述,走读关注两个易漏点。第一,UserDeptApi 接口里的注释"运行时 keepCase:角色列表键为 UserRole"——这是 HANDOFF 陷阱 4 在代码里的活化石:loadProto 的 keepCase 让 gRPC 运行时对象保持 proto 原名(UserRole),而 ts-proto 生成的类型是 camelCase(userRole),所以接口签名必须手写对齐运行时形状而非生成类型,authorization.service.ts:148-150 对同一事实有第二处注释。第二,requireClient 的同步抛错设计:客户端未就绪时不返回被拒绝的 Promise 而是直接 throw,这个差异在正常请求里无感,但在"响应 finish 回调里调用"的场景(日志中间件)是生死攸关的——同步 throw 可以被 try/catch 捕获,而一个在 void 上下文里被 await 的 Promise 拒绝会变成 unhandled rejection 打崩进程(陷阱 16 的血泪史)。createUser 参数类型与 proto 的 CreateUserRequest 字段一一对应,repeated uint32 roleIds 映射为数组、未传时 undefined,这个 undefined 会原样透给 gRPC 并在 proto3 语义下变成缺省——理解这一点才能理解 user 服务端"未传 roleIds 回落默认角色"的判断写法。
@GrpcMethod('UserService', 'CreateUser')
async createUser(@Payload() payload: { /* 同上 */ }) {
try {
const created = await this.prismaClient.$transaction(async (prisma) => {
const roleIds = payload.roleIds?.length
? payload.roleIds
: [Number(this.configService.get('ROLE_ID')) || 1];
const count = await prisma.role.count({ where: { id: { in: roleIds } } });
if (count !== roleIds.length) {
throw new RpcException({ code: status.NOT_FOUND, message: '部分角色不存在' });
}
return prisma.user.create({
data: {
username: payload.username,
password: await import('argon2').then(a => a.hash(payload.password, { type: a.argon2id })),
// ...
UserRole: { createMany: { data: roleIds.map(roleId => ({ roleId })) } },
},
include: { UserRole: true, dept: true },
});
});
return { /* UserData 形状 */ };
} catch (err: any) {
if (err?.code === 'P2002') {
throw new RpcException({ code: status.ALREADY_EXISTS, message: '用户名已存在' });
}
throw err;
}
}四个走读点。其一,$transaction(async (prisma) => {...}) 是交互式事务:角色存在性校验与 user.create 在同一事务里,保证"校验通过的角色集合"与"实际写入的 user_roles"之间没有并发窗口。其二,角色缺失抛的是 RpcException({code: status.NOT_FOUND}) 而非普通 Error——gRPC 错误码就是这里的"业务异常枚举",网关的 AllExceptionsFilter 按 GRPC_TO_HTTP 映射表把 5 翻译成 404(gateway/src/common/filters/all-exceptions.filter.ts),错误语义因此跨进程不衰减。其三,P2002 捕获在事务外层:Prisma 的唯一约束冲突在 commit 时才暴露,catch 里把 Prisma 内部错误码翻译成 ALREADY_EXISTS(6)→HTTP 409,注释"并发注册撞唯一约束:返回规范的 gRPC 状态码"说明这与网关 signup 的"不嗅探错误文本"设计(auth.service.ts 注释)是一对配合。其四,返回对象手工拼装为 proto 的 UserData 形状(deptId 空值补 0、roleNames 此处为空数组等),这是"服务端只返回安全字段"约定的机制化——password 永不出现在返回形状里,与《13-技术债务》§1.6 讨论的 Serialize 白名单互为双保险。
把链路合上:user 服务抛的 RpcException 经 gRPC 传输到达网关时是带 code 的错误对象,若控制器挂了 GrpcExceptionInterceptor(auth 前缀有、system 前缀没有)会先转一次 HttpException,否则直接落进全局 AllExceptionsFilter 的映射表分支——两条路殊途同归到 {statusCode:409, message:'用户名已存在', error:'Conflict'}。前端 api/system.ts 的 call() 包装捕获错误响应、把 message 抽出来展示。一条"用户名已存在"从 PostgreSQL 约束到浏览器 toast 要经过 P2002→RpcException(6)→(可能的拦截器转换)→过滤器映射 409→前端 join 文案五层翻译,每一层都有唯一实现点、可独立替换——这正是"错误即契约"(《03-接口设计》§5)在代码层的落点。排障时按状态码回溯这五层即可定位"信息在哪一层被改写"。
/** 请求白名单,放置一些不需要`token`的接口(防止`token`过期后再请求造成的死循环问题) */
const whiteList = ["/auth/signin", "/auth/refresh"];
return whiteList.some(url => config.url.endsWith(url))
? config
: new Promise(resolve => {
const data = getToken();
if (data) {
const now = new Date().getTime();
const expired = parseInt(data.expires) - now <= 0;
if (expired) {
if (!PureHttp.isRefreshing) {
PureHttp.isRefreshing = true;
useUserStoreHook()
.handRefreshToken({ refreshToken: data.refreshToken })
.then(res => {
const token = res.data.accessToken;
config.headers["Authorization"] = formatToken(token);
PureHttp.requests.forEach(cb => cb(token));
PureHttp.requests = [];
})
.catch(_err => {
PureHttp.requests = [];
useUserStoreHook().logOut();
message("登录已过期,请重新登录", { type: "warning" });
})
.finally(() => { PureHttp.isRefreshing = false; });
}
resolve(PureHttp.retryOriginalRequest(config));
} else {
config.headers["Authorization"] = formatToken(data.accessToken);
resolve(config);
}
} else {
resolve(config);
}
});逐块解读。白名单用 endsWith 而非全等,是因为请求 URL 带 /api 前缀而白名单写的是控制器段——/api/auth/signin 以 /auth/signin 结尾即放行;这也意味着任何以这两个后缀结尾的自定义路径都会被放行,修改路由时注意别撞上。isRefreshing 单飞标志与 requests 队列构成经典的"token 刷新竞态"解法:第一个发现过期的请求触发刷新,其余请求进 retryOriginalRequest 返回的挂起 Promise(回调推入 requests 数组),新 token 下发后队列统一重放;刷新失败则清队列 + 登出——注意 catch 里 PureHttp.requests = [] 只清数组不重放,挂起的 Promise 永远不 resolve,调用方表现为请求悬挂而非报错,好在随后的 401/登出会把页面带走。过期判断用本地时间 data.expires - now <= 0,expires 是登录时后端 expires_in: 7200 换算的时间戳(src/utils/auth.ts setToken),因此前端时钟漂移会影响刷新时机——这是一个可接受的简化(漂移通常以秒计,2 小时窗口富余极大),但排障时"提前刷新/迟迟不刷新"可以先查本机时钟。与后端的配合见《05-核心流程》流程一延长线:网关侧 rotateAccessToken 换会话键,两端合起来才是完整的无感续期闭环。
(error: PureHttpError) => {
const $error = error;
$error.isCancelRequest = Axios.isCancel($error);
// 网关返回 401:会话过期,清理本地登录态并跳回登录页
if ($error?.response?.status === 401) {
clearLoginState();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
return Promise.reject($error);
}响应侧刻意保持"薄":只处理 401 一种状态码(清 cookie/localStorage 并跳登录),其余错误一律 reject 给调用方适配层处理——所以 api/system.ts 的 call() 包装才有机会把 403/400/409 翻译成不同的用户提示。clearLoginState(src/utils/auth.ts)一次性清掉 authorized-token cookie、multiple-tabs cookie 与 user-info localStorage 三处登录态,401 时即便本地残留 refresh_token 也无济于事——网关侧会话已删(rotateAccessToken 返回 false→401),这是"强退即时生效"在前端的最后一环(流程五)。跳转用 window.location.href 硬跳而不是 router.push,宁可整页重载也不保留任何内存态——history 路由模式下配合 nginx 的 SPA 回退(admin-vue-deploy/nginx-admin.conf 的 try_files),这行代码的解释权在《12-FAQ与故障排查》的"深链接 404"条目。
// src/utils/auth.ts
export const hasPerms = (value: string | Array<string>): boolean => {
if (!value) return false;
const allPerms = "*:*:*";
const { permissions } = useUserStoreHook();
if (!permissions) return false;
if (permissions.length === 1 && permissions[0] === allPerms) return true;
const isAuths = isString(value)
? permissions.includes(value)
: isIncludeAllChildren(value, permissions);
return isAuths ? true : false;
};// src/directives/perms/index.ts
export const perms: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string | Array<string>>) {
const { value } = binding;
if (value) {
!hasPerms(value) && el.parentNode?.removeChild(el);
} else {
throw new Error("[Directive: perms]: need perms! Like v-perms=\"['btn.add','btn.edit']\"");
}
}
};两个小函数里藏着权限消费端的全部语义。hasPerms 的通配判断只在"权限数组恰好只有一个 *:*:*"时直通——如果某用户的权限集合是 ['*:*:*', 'system:user:list'](理论上不该出现,seed 只发一个通配码),通配判断会失灵,这是实现细节而非契约,别在调用侧依赖它;数组入参走 isIncludeAllChildren(@pureadmin/utils),语义是"列出的码全部具备",没有"任一具备"的变体,需要 OR 语义时要拆成多个 v-perms 或自行组合。v-perms 指令只在 mounted 钩子移除 DOM、没有 updated 钩子——这意味着权限码在组件挂载后发生变化(如重新拉 my-menus 后 SET_PERMS)不会反向恢复已被移除的元素,页面必须整体重建(刷新或路由重进)才能让新权限生效,这与《11-扩展开发指南》SOP-5 的"前端权限码在下次拉 my-menus 时刷新、当前会话不自动更新"的表述完全一致。与它并列的 v-auth 指令(基于路由 meta.auths)面向"页面级"显隐,v-perms 面向"按钮级",两者数据源不同(前者路由元数据、后者 user store),误用会表现为"本地明明有码按钮却不显示"。permissions 的数据源头是登录后并行拉取的 /api/system/my-menus(src/api/routes.ts 里 menus.filter(m => m.type === 2 && m.permission) 后 SET_PERMS)——所以按钮不显示的排查终点永远在菜单数据链,这与后端 403 的排查终点(getPermissionCodes 的同一批菜单记录)在数据上同源,在机制上独立,构成"前端显隐 + 后端守卫"双保险的完整拼图。
三个走读对象合起来覆盖了一条完整的读写路径:前端 http 封装发起请求(走读三)→ 网关守卫与透传(走读二前两跳)→ 服务发现选路(走读一)→ 业务服务事务与异常翻译(走读二后两跳)→ 错误沿原路翻译回浏览器。贯穿三段代码的四个共性值得刻进手感:注释都在解释"为什么"而不是"是什么"(每条奇怪写法的注释都能对应 HANDOFF 一条陷阱);错误与失败的路径比成功路径写得考究(竞速的 EmptyError 翻译、requireClient 的同步抛错约定、刷新失败的队列清理);状态尽量收敛到单一机制(客户端热更新只有 BehaviorSubject 一种、按钮权限只有 hasPerms 一个入口);每一层都为可替换留了缝(薄网关、客户端槽位、独立指令)。读其他文件时带着这四把尺子,大概率能迅速分辨"哪些写法是刻意设计、哪些是待清理的惯性"——这正是代码走读训练的最终目的。