From 9292d4f12c1ad7c829f813ed091ae38b9e55192f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 06:38:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20Redis=20=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + RTK.md | 1 + package.json | 1 + pnpm-lock.yaml | 55 ++++++++++++++++ src/health/health.module.ts | 3 +- src/health/health.service.spec.ts | 14 ++-- src/health/health.service.ts | 9 +-- src/integrations/redis/redis.client.ts | 89 ++++++++++++++++++++++++++ src/integrations/redis/redis.module.ts | 8 +++ 9 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 src/integrations/redis/redis.client.ts create mode 100644 src/integrations/redis/redis.module.ts diff --git a/README.md b/README.md index eed64a8..ea2d3cd 100644 --- a/README.md +++ b/README.md @@ -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`:通知集成配置。 diff --git a/RTK.md b/RTK.md index ed4c520..28b03d5 100644 --- a/RTK.md +++ b/RTK.md @@ -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 代理边界。 diff --git a/package.json b/package.json index 8109710..de693e8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8032854..423f3f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/health/health.module.ts b/src/health/health.module.ts index f605e02..18aa515 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -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], }) diff --git a/src/health/health.service.spec.ts b/src/health/health.service.spec.ts index 09706af..1c8dbb0 100644 --- a/src/health/health.service.spec.ts +++ b/src/health/health.service.spec.ts @@ -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, 'get'>; const prisma = { databaseHealth: jest.fn(() => Promise.resolve({ status: 'not_configured' as const }), ), } satisfies Pick; + const redis = { + healthSummary: jest.fn(() => + Promise.resolve({ status: 'not_configured' as const }), + ), + } satisfies Pick; const jenkins = { healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), } satisfies Pick; @@ -35,8 +36,8 @@ describe('HealthService', () => { ), } satisfies Pick; const service = new HealthService( - config as unknown as ConfigService, 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'); }); diff --git a/src/health/health.service.ts b/src/health/health.service.ts index 95988c4..f69c2bc 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -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, 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(), diff --git a/src/integrations/redis/redis.client.ts b/src/integrations/redis/redis.client.ts new file mode 100644 index 0000000..44a22a2 --- /dev/null +++ b/src/integrations/redis/redis.client.ts @@ -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) {} + + async healthSummary(): Promise { + 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(promise: Promise): Promise { + return Promise.race([ + promise, + new Promise((_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; + } +} diff --git a/src/integrations/redis/redis.module.ts b/src/integrations/redis/redis.module.ts new file mode 100644 index 0000000..d00b614 --- /dev/null +++ b/src/integrations/redis/redis.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { RedisClient } from './redis.client'; + +@Module({ + providers: [RedisClient], + exports: [RedisClient], +}) +export class RedisModule {}