feat: 接入 Redis 健康检查

This commit is contained in:
湛兮
2026-06-12 06:38:39 +08:00
parent 8502c93b71
commit 9292d4f12c
9 changed files with 168 additions and 13 deletions
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { AgentModule } from '../agent/agent.module';
import { GiteaModule } from '../integrations/gitea/gitea.module';
import { JenkinsModule } from '../integrations/jenkins/jenkins.module';
import { RedisModule } from '../integrations/redis/redis.module';
import { WeComModule } from '../notifications/wecom/wecom.module';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
imports: [AgentModule, GiteaModule, JenkinsModule, WeComModule],
imports: [AgentModule, GiteaModule, JenkinsModule, RedisModule, WeComModule],
controllers: [HealthController],
providers: [HealthService],
})
+8 -6
View File
@@ -1,8 +1,7 @@
import { ConfigService } from '@nestjs/config';
import { LlmClient } from '../agent/llm.client';
import { EnvConfig } from '../config/env.schema';
import { GiteaClient } from '../integrations/gitea/gitea.client';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { RedisClient } from '../integrations/redis/redis.client';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { PrismaService } from '../prisma/prisma.service';
import { HealthService } from './health.service';
@@ -12,14 +11,16 @@ import { HealthService } from './health.service';
*/
describe('HealthService', () => {
it('returns ok when optional integrations are not configured but available services respond', async () => {
const config = {
get: jest.fn((key: keyof EnvConfig) => (key === 'REDIS_URL' ? undefined : undefined)),
} satisfies Pick<ConfigService<EnvConfig, true>, 'get'>;
const prisma = {
databaseHealth: jest.fn(() =>
Promise.resolve({ status: 'not_configured' as const }),
),
} satisfies Pick<PrismaService, 'databaseHealth'>;
const redis = {
healthSummary: jest.fn(() =>
Promise.resolve({ status: 'not_configured' as const }),
),
} satisfies Pick<RedisClient, 'healthSummary'>;
const jenkins = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
} satisfies Pick<JenkinsClient, 'healthSummary'>;
@@ -35,8 +36,8 @@ describe('HealthService', () => {
),
} satisfies Pick<LlmClient, 'healthSummary'>;
const service = new HealthService(
config as unknown as ConfigService<EnvConfig, true>,
prisma as unknown as PrismaService,
redis as unknown as RedisClient,
jenkins as unknown as JenkinsClient,
gitea as unknown as GiteaClient,
notification as unknown as DeployNotificationService,
@@ -47,6 +48,7 @@ describe('HealthService', () => {
expect(health.status).toBe('ok');
expect(health.dependencies.database.status).toBe('not_configured');
expect(health.dependencies.redis.status).toBe('not_configured');
expect(health.dependencies.jenkins.status).toBe('not_configured');
expect(health.dependencies.notification.status).toBe('not_configured');
});
+3 -6
View File
@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LlmClient } from '../agent/llm.client';
import { EnvConfig } from '../config/env.schema';
import { GiteaClient } from '../integrations/gitea/gitea.client';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { RedisClient } from '../integrations/redis/redis.client';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -12,8 +11,8 @@ type HealthStatus = 'ok' | 'not_configured' | 'unavailable';
@Injectable()
export class HealthService {
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly prisma: PrismaService,
private readonly redis: RedisClient,
private readonly jenkins: JenkinsClient,
private readonly gitea: GiteaClient,
private readonly notification: DeployNotificationService,
@@ -27,9 +26,7 @@ export class HealthService {
}> {
const dependencies = {
database: await this.prisma.databaseHealth(),
redis: this.config.get('REDIS_URL', { infer: true })
? { status: 'ok' as const }
: { status: 'not_configured' as const },
redis: await this.redis.healthSummary(),
jenkins: this.jenkins.healthSummary(),
gitea: this.gitea.healthSummary(),
notification: this.notification.healthSummary(),
+89
View File
@@ -0,0 +1,89 @@
/**
* Redis 客户端封装真实 PING 健康检查,后续 BullMQ worker 复用同一连接边界。
*/
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { redactSensitive } from '../../common/security/redact-sensitive';
import { EnvConfig } from '../../config/env.schema';
export type RedisHealthSummary = {
status: 'ok' | 'not_configured' | 'unavailable';
message?: string;
};
const redisPingTimeoutMs = 1500;
@Injectable()
export class RedisClient implements OnModuleDestroy {
private client?: Redis;
private clientUrl?: string;
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
async healthSummary(): Promise<RedisHealthSummary> {
const redisUrl = this.config.get('REDIS_URL', { infer: true });
if (!redisUrl) {
return { status: 'not_configured' };
}
try {
const pong = String(
await this.withTimeout(this.getClient(redisUrl).ping()),
);
return pong === 'PONG'
? { status: 'ok' }
: { status: 'unavailable', message: `Redis PING 返回异常:${pong}` };
} catch (error) {
return {
status: 'unavailable',
message: this.safeErrorMessage(error),
};
}
}
onModuleDestroy(): void {
if (this.client) {
this.client.disconnect();
this.client = undefined;
this.clientUrl = undefined;
}
}
private getClient(redisUrl: string): Redis {
if (this.client && this.clientUrl === redisUrl) {
return this.client;
}
this.client?.disconnect();
this.clientUrl = redisUrl;
this.client = new Redis(redisUrl, {
connectTimeout: redisPingTimeoutMs,
enableOfflineQueue: false,
lazyConnect: false,
maxRetriesPerRequest: 1,
});
this.client.on('error', () => undefined);
return this.client;
}
private withTimeout<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_resolve, reject) => {
setTimeout(
() => reject(new Error('Redis PING 超时')),
redisPingTimeoutMs,
);
}),
]);
}
private safeErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : 'Redis 连接失败';
return redactSensitive({ message }).message;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { RedisClient } from './redis.client';
@Module({
providers: [RedisClient],
exports: [RedisClient],
})
export class RedisModule {}