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
+1
View File
@@ -103,6 +103,7 @@ pnpm prisma:seed
- `DATABASE_URL`MySQL 连接地址。
- `USE_DATABASE_READS`:是否启用 Prisma 数据读写。
- `PRISMA_CONNECT_ON_BOOT`:启动时是否主动连接数据库。
- `REDIS_URL`Redis 连接地址,`/health` 会执行真实 PING;后续 BullMQ worker 复用该连接边界。
- `JENKINS_BASE_URL``JENKINS_USERNAME``JENKINS_API_TOKEN`Jenkins 集成配置。
- `GITEA_BASE_URL``GITEA_TOKEN``GITEA_WEBHOOK_SECRET``GITEA_WEBHOOK_RELAY_BASE_URL`Gitea 集成与 Jenkins relay 诊断配置。
- `NOTIFICATION_PROVIDER``WECOM_WEBHOOK_URL``FEISHU_WEBHOOK_URL``NOTIFICATION_WEBHOOK_URL`:通知集成配置。
+1
View File
@@ -19,6 +19,7 @@
- `deploy-runs`:发布记录 API、状态模型和 BPMN 步骤摘要。
- `integrations/jenkins`Jenkins API 客户端边界。
- `integrations/gitea`Gitea API 客户端边界。
- `integrations/redis`:Redis PING 健康检查边界,后续 BullMQ worker 复用该模块。
- `notifications/wecom`:企微、飞书和通用 webhook 通知边界。
- `agent-config`:Agent 系统级配置、密钥加密和连接测试。
- `agent`:只面向运维工作流的 LLM 代理边界。
+1
View File
@@ -30,6 +30,7 @@
"@nestjs/platform-express": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"@prisma/client": "^6.14.0",
"ioredis": "^5.11.1",
"pino": "^10.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
+55
View File
@@ -26,6 +26,9 @@ importers:
'@prisma/client':
specifier: ^6.14.0
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
ioredis:
specifier: ^5.11.1
version: 5.11.1
pino:
specifier: ^10.3.1
version: 10.3.1
@@ -507,6 +510,9 @@ packages:
'@types/node':
optional: true
'@ioredis/commands@1.10.0':
resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -1377,6 +1383,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
cluster-key-slot@1.1.1:
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
engines: {node: '>=0.10.0'}
co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -1493,6 +1503,10 @@ packages:
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -1900,6 +1914,10 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ioredis@5.11.1:
resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
engines: {node: '>=12.22.0'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@@ -2549,6 +2567,14 @@ packages:
real-require@1.0.0:
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@@ -2687,6 +2713,9 @@ packages:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -3544,6 +3573,8 @@ snapshots:
optionalDependencies:
'@types/node': 24.13.2
'@ioredis/commands@1.10.0': {}
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -4581,6 +4612,8 @@ snapshots:
clone@1.0.4: {}
cluster-key-slot@1.1.1: {}
co@4.6.0: {}
collect-v8-coverage@1.0.3: {}
@@ -4665,6 +4698,8 @@ snapshots:
defu@6.1.7: {}
denque@2.1.0: {}
depd@2.0.0: {}
destr@2.0.5: {}
@@ -5113,6 +5148,18 @@ snapshots:
inherits@2.0.4: {}
ioredis@5.11.1:
dependencies:
'@ioredis/commands': 1.10.0
cluster-key-slot: 1.1.1
debug: 4.4.3
denque: 2.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ipaddr.js@1.9.1: {}
is-arrayish@0.2.1: {}
@@ -5885,6 +5932,12 @@ snapshots:
real-require@1.0.0: {}
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
reflect-metadata@0.2.2: {}
require-directory@2.1.1: {}
@@ -6038,6 +6091,8 @@ snapshots:
dependencies:
escape-string-regexp: 2.0.0
standard-as-callback@2.1.0: {}
statuses@2.0.2: {}
streamsearch@1.1.0: {}
+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 {}