feat: 接入 Redis 健康检查
This commit is contained in:
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RedisClient } from './redis.client';
|
||||
|
||||
@Module({
|
||||
providers: [RedisClient],
|
||||
exports: [RedisClient],
|
||||
})
|
||||
export class RedisModule {}
|
||||
Reference in New Issue
Block a user