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 连接地址。 - `DATABASE_URL`MySQL 连接地址。
- `USE_DATABASE_READS`:是否启用 Prisma 数据读写。 - `USE_DATABASE_READS`:是否启用 Prisma 数据读写。
- `PRISMA_CONNECT_ON_BOOT`:启动时是否主动连接数据库。 - `PRISMA_CONNECT_ON_BOOT`:启动时是否主动连接数据库。
- `REDIS_URL`Redis 连接地址,`/health` 会执行真实 PING;后续 BullMQ worker 复用该连接边界。
- `JENKINS_BASE_URL``JENKINS_USERNAME``JENKINS_API_TOKEN`Jenkins 集成配置。 - `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 诊断配置。 - `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`:通知集成配置。 - `NOTIFICATION_PROVIDER``WECOM_WEBHOOK_URL``FEISHU_WEBHOOK_URL``NOTIFICATION_WEBHOOK_URL`:通知集成配置。
+1
View File
@@ -19,6 +19,7 @@
- `deploy-runs`:发布记录 API、状态模型和 BPMN 步骤摘要。 - `deploy-runs`:发布记录 API、状态模型和 BPMN 步骤摘要。
- `integrations/jenkins`Jenkins API 客户端边界。 - `integrations/jenkins`Jenkins API 客户端边界。
- `integrations/gitea`Gitea API 客户端边界。 - `integrations/gitea`Gitea API 客户端边界。
- `integrations/redis`:Redis PING 健康检查边界,后续 BullMQ worker 复用该模块。
- `notifications/wecom`:企微、飞书和通用 webhook 通知边界。 - `notifications/wecom`:企微、飞书和通用 webhook 通知边界。
- `agent-config`:Agent 系统级配置、密钥加密和连接测试。 - `agent-config`:Agent 系统级配置、密钥加密和连接测试。
- `agent`:只面向运维工作流的 LLM 代理边界。 - `agent`:只面向运维工作流的 LLM 代理边界。
+1
View File
@@ -30,6 +30,7 @@
"@nestjs/platform-express": "^11.1.6", "@nestjs/platform-express": "^11.1.6",
"@nestjs/swagger": "^11.2.0", "@nestjs/swagger": "^11.2.0",
"@prisma/client": "^6.14.0", "@prisma/client": "^6.14.0",
"ioredis": "^5.11.1",
"pino": "^10.3.1", "pino": "^10.3.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
+55
View File
@@ -26,6 +26,9 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^6.14.0 specifier: ^6.14.0
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) 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: pino:
specifier: ^10.3.1 specifier: ^10.3.1
version: 10.3.1 version: 10.3.1
@@ -507,6 +510,9 @@ packages:
'@types/node': '@types/node':
optional: true optional: true
'@ioredis/commands@1.10.0':
resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1377,6 +1383,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'} 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: co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -1493,6 +1503,10 @@ packages:
defu@6.1.7: defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} 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: depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1900,6 +1914,10 @@ packages:
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} 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: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -2549,6 +2567,14 @@ packages:
real-require@1.0.0: real-require@1.0.0:
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} 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: reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@@ -2687,6 +2713,9 @@ packages:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
statuses@2.0.2: statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -3544,6 +3573,8 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 24.13.2 '@types/node': 24.13.2
'@ioredis/commands@1.10.0': {}
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
dependencies: dependencies:
string-width: 5.1.2 string-width: 5.1.2
@@ -4581,6 +4612,8 @@ snapshots:
clone@1.0.4: {} clone@1.0.4: {}
cluster-key-slot@1.1.1: {}
co@4.6.0: {} co@4.6.0: {}
collect-v8-coverage@1.0.3: {} collect-v8-coverage@1.0.3: {}
@@ -4665,6 +4698,8 @@ snapshots:
defu@6.1.7: {} defu@6.1.7: {}
denque@2.1.0: {}
depd@2.0.0: {} depd@2.0.0: {}
destr@2.0.5: {} destr@2.0.5: {}
@@ -5113,6 +5148,18 @@ snapshots:
inherits@2.0.4: {} 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: {} ipaddr.js@1.9.1: {}
is-arrayish@0.2.1: {} is-arrayish@0.2.1: {}
@@ -5885,6 +5932,12 @@ snapshots:
real-require@1.0.0: {} 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: {} reflect-metadata@0.2.2: {}
require-directory@2.1.1: {} require-directory@2.1.1: {}
@@ -6038,6 +6091,8 @@ snapshots:
dependencies: dependencies:
escape-string-regexp: 2.0.0 escape-string-regexp: 2.0.0
standard-as-callback@2.1.0: {}
statuses@2.0.2: {} statuses@2.0.2: {}
streamsearch@1.1.0: {} streamsearch@1.1.0: {}
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { AgentModule } from '../agent/agent.module'; import { AgentModule } from '../agent/agent.module';
import { GiteaModule } from '../integrations/gitea/gitea.module'; import { GiteaModule } from '../integrations/gitea/gitea.module';
import { JenkinsModule } from '../integrations/jenkins/jenkins.module'; import { JenkinsModule } from '../integrations/jenkins/jenkins.module';
import { RedisModule } from '../integrations/redis/redis.module';
import { WeComModule } from '../notifications/wecom/wecom.module'; import { WeComModule } from '../notifications/wecom/wecom.module';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
import { HealthService } from './health.service'; import { HealthService } from './health.service';
@Module({ @Module({
imports: [AgentModule, GiteaModule, JenkinsModule, WeComModule], imports: [AgentModule, GiteaModule, JenkinsModule, RedisModule, WeComModule],
controllers: [HealthController], controllers: [HealthController],
providers: [HealthService], providers: [HealthService],
}) })
+8 -6
View File
@@ -1,8 +1,7 @@
import { ConfigService } from '@nestjs/config';
import { LlmClient } from '../agent/llm.client'; import { LlmClient } from '../agent/llm.client';
import { EnvConfig } from '../config/env.schema';
import { GiteaClient } from '../integrations/gitea/gitea.client'; import { GiteaClient } from '../integrations/gitea/gitea.client';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { RedisClient } from '../integrations/redis/redis.client';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service'; import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { HealthService } from './health.service'; import { HealthService } from './health.service';
@@ -12,14 +11,16 @@ import { HealthService } from './health.service';
*/ */
describe('HealthService', () => { describe('HealthService', () => {
it('returns ok when optional integrations are not configured but available services respond', async () => { 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 = { const prisma = {
databaseHealth: jest.fn(() => databaseHealth: jest.fn(() =>
Promise.resolve({ status: 'not_configured' as const }), Promise.resolve({ status: 'not_configured' as const }),
), ),
} satisfies Pick<PrismaService, 'databaseHealth'>; } satisfies Pick<PrismaService, 'databaseHealth'>;
const redis = {
healthSummary: jest.fn(() =>
Promise.resolve({ status: 'not_configured' as const }),
),
} satisfies Pick<RedisClient, 'healthSummary'>;
const jenkins = { const jenkins = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
} satisfies Pick<JenkinsClient, 'healthSummary'>; } satisfies Pick<JenkinsClient, 'healthSummary'>;
@@ -35,8 +36,8 @@ describe('HealthService', () => {
), ),
} satisfies Pick<LlmClient, 'healthSummary'>; } satisfies Pick<LlmClient, 'healthSummary'>;
const service = new HealthService( const service = new HealthService(
config as unknown as ConfigService<EnvConfig, true>,
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
redis as unknown as RedisClient,
jenkins as unknown as JenkinsClient, jenkins as unknown as JenkinsClient,
gitea as unknown as GiteaClient, gitea as unknown as GiteaClient,
notification as unknown as DeployNotificationService, notification as unknown as DeployNotificationService,
@@ -47,6 +48,7 @@ describe('HealthService', () => {
expect(health.status).toBe('ok'); expect(health.status).toBe('ok');
expect(health.dependencies.database.status).toBe('not_configured'); 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.jenkins.status).toBe('not_configured');
expect(health.dependencies.notification.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 { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LlmClient } from '../agent/llm.client'; import { LlmClient } from '../agent/llm.client';
import { EnvConfig } from '../config/env.schema';
import { GiteaClient } from '../integrations/gitea/gitea.client'; import { GiteaClient } from '../integrations/gitea/gitea.client';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { RedisClient } from '../integrations/redis/redis.client';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service'; import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -12,8 +11,8 @@ type HealthStatus = 'ok' | 'not_configured' | 'unavailable';
@Injectable() @Injectable()
export class HealthService { export class HealthService {
constructor( constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly redis: RedisClient,
private readonly jenkins: JenkinsClient, private readonly jenkins: JenkinsClient,
private readonly gitea: GiteaClient, private readonly gitea: GiteaClient,
private readonly notification: DeployNotificationService, private readonly notification: DeployNotificationService,
@@ -27,9 +26,7 @@ export class HealthService {
}> { }> {
const dependencies = { const dependencies = {
database: await this.prisma.databaseHealth(), database: await this.prisma.databaseHealth(),
redis: this.config.get('REDIS_URL', { infer: true }) redis: await this.redis.healthSummary(),
? { status: 'ok' as const }
: { status: 'not_configured' as const },
jenkins: this.jenkins.healthSummary(), jenkins: this.jenkins.healthSummary(),
gitea: this.gitea.healthSummary(), gitea: this.gitea.healthSummary(),
notification: this.notification.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 {}