feat: 增加通知Provider和Jenkins自动同步
This commit is contained in:
@@ -17,6 +17,8 @@ REDIS_URL=redis://127.0.0.1:6379/0
|
|||||||
JENKINS_BASE_URL=https://jenkins.example.internal
|
JENKINS_BASE_URL=https://jenkins.example.internal
|
||||||
JENKINS_USERNAME=devops-bot
|
JENKINS_USERNAME=devops-bot
|
||||||
JENKINS_API_TOKEN=replace-with-secret-manager-value
|
JENKINS_API_TOKEN=replace-with-secret-manager-value
|
||||||
|
JENKINS_AUTO_SYNC_ENABLED=false
|
||||||
|
JENKINS_AUTO_SYNC_INTERVAL_MS=30000
|
||||||
|
|
||||||
# Gitea integration, server-side only.
|
# Gitea integration, server-side only.
|
||||||
GITEA_BASE_URL=https://gitea.example.internal
|
GITEA_BASE_URL=https://gitea.example.internal
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pnpm prisma:migrate:dev --name init
|
|||||||
- `GET /deploy-runs`: latest deploy runs.
|
- `GET /deploy-runs`: latest deploy runs.
|
||||||
- `POST /deploy-runs`: create a deploy run. Missing Jenkins config uses a local dry-run path; configured Jenkins calls `buildWithParameters` and returns `queued/running`.
|
- `POST /deploy-runs`: create a deploy run. Missing Jenkins config uses a local dry-run path; configured Jenkins calls `buildWithParameters` and returns `queued/running`.
|
||||||
- `POST /deploy-runs/:id/sync-jenkins`: synchronize Jenkins queue/build/log status and advance success or failure.
|
- `POST /deploy-runs/:id/sync-jenkins`: synchronize Jenkins queue/build/log status and advance success or failure.
|
||||||
|
- `POST /deploy-runs/sync-jenkins`: synchronize all queued/running runs that have Jenkins queue/build identifiers.
|
||||||
- `POST /deploy-runs/:id/cancel`: cancel a deploy run.
|
- `POST /deploy-runs/:id/cancel`: cancel a deploy run.
|
||||||
- `POST /deploy-runs/:id/retry`: create a retry deploy run.
|
- `POST /deploy-runs/:id/retry`: create a retry deploy run.
|
||||||
- `POST /webhooks/gitea`: accept Gitea push webhooks and trigger test deploy runs.
|
- `POST /webhooks/gitea`: accept Gitea push webhooks and trigger test deploy runs.
|
||||||
@@ -71,3 +72,24 @@ The Agent module is not a generic chat entrypoint. It can only support DevOps wo
|
|||||||
- incident review drafts.
|
- incident review drafts.
|
||||||
|
|
||||||
Agent calls are read-only. They must not trigger deploys, mutate Jenkins/Gitea, or expose raw secrets. When `LLM_BASE_URL`, `LLM_API_KEY`, and `LLM_MODEL` are configured, the backend proxies DevOps-scoped prompts to `/chat/completions`; otherwise it returns local mock results with the same API shape.
|
Agent calls are read-only. They must not trigger deploys, mutate Jenkins/Gitea, or expose raw secrets. When `LLM_BASE_URL`, `LLM_API_KEY`, and `LLM_MODEL` are configured, the backend proxies DevOps-scoped prompts to `/chat/completions`; otherwise it returns local mock results with the same API shape.
|
||||||
|
|
||||||
|
## Notification Providers
|
||||||
|
|
||||||
|
`DeployNotificationService` supports the first provider boundary for:
|
||||||
|
|
||||||
|
- `NOTIFICATION_PROVIDER=wecom` with `WECOM_WEBHOOK_URL`
|
||||||
|
- `NOTIFICATION_PROVIDER=feishu` with `FEISHU_WEBHOOK_URL`
|
||||||
|
- `NOTIFICATION_PROVIDER=generic` with `NOTIFICATION_WEBHOOK_URL`
|
||||||
|
|
||||||
|
Missing provider or webhook configuration returns a skipped notification result and does not block deploy status transitions.
|
||||||
|
|
||||||
|
## Jenkins Auto Sync
|
||||||
|
|
||||||
|
Manual sync is always available through `POST /deploy-runs/sync-jenkins`. To enable a lightweight in-process poller before Redis/BullMQ is introduced:
|
||||||
|
|
||||||
|
```env
|
||||||
|
JENKINS_AUTO_SYNC_ENABLED=true
|
||||||
|
JENKINS_AUTO_SYNC_INTERVAL_MS=30000
|
||||||
|
```
|
||||||
|
|
||||||
|
The poller only scans queued/running runs that already have a Jenkins queue id or build number.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
const SENSITIVE_KEY_PATTERN =
|
const SENSITIVE_KEY_PATTERN =
|
||||||
/(token|secret|password|passwd|authorization|api[-_]?key|webhook|cookie|credential|private[-_]?key)/i;
|
/(token|secret|password|passwd|authorization|api[-_]?key|webhook|cookie|credential|private[-_]?key)/i;
|
||||||
|
const INLINE_SECRET_PATTERN =
|
||||||
|
/\b(token|secret|password|passwd|authorization|api[-_]?key|webhook|cookie|credential|private[-_]?key)=([^\s&]+)/gi;
|
||||||
|
const INLINE_URL_WITH_SECRET_PATTERN =
|
||||||
|
/https?:\/\/[^\s]*(?:token|secret|password|passwd|api[-_]?key|webhook|key=)[^\s]*/gi;
|
||||||
|
|
||||||
export function redactSensitive<T>(value: T): T {
|
export function redactSensitive<T>(value: T): T {
|
||||||
return redactValue(value) as T;
|
return redactValue(value) as T;
|
||||||
@@ -19,8 +23,18 @@ function redactValue(value: unknown): unknown {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'string' && looksLikeSecret(value)) {
|
if (typeof value === 'string') {
|
||||||
return '[REDACTED]';
|
const redacted = value
|
||||||
|
.replace(INLINE_URL_WITH_SECRET_PATTERN, '[REDACTED_URL]')
|
||||||
|
.replace(INLINE_SECRET_PATTERN, '$1=[REDACTED]');
|
||||||
|
|
||||||
|
if (redacted !== value) {
|
||||||
|
return redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (looksLikeSecret(value)) {
|
||||||
|
return '[REDACTED]';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ const booleanFromEnv = z.preprocess((value) => {
|
|||||||
return false;
|
return false;
|
||||||
}, z.boolean().default(false));
|
}, z.boolean().default(false));
|
||||||
|
|
||||||
|
const positiveIntegerFromEnv = (defaultValue: number) =>
|
||||||
|
z.preprocess((value) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}, z.coerce.number().int().positive().default(defaultValue));
|
||||||
|
|
||||||
export const envSchema = z.object({
|
export const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
PORT: z.coerce.number().int().positive().default(4300),
|
PORT: z.coerce.number().int().positive().default(4300),
|
||||||
@@ -46,6 +55,8 @@ export const envSchema = z.object({
|
|||||||
JENKINS_BASE_URL: optionalUrl,
|
JENKINS_BASE_URL: optionalUrl,
|
||||||
JENKINS_USERNAME: optionalString,
|
JENKINS_USERNAME: optionalString,
|
||||||
JENKINS_API_TOKEN: optionalString,
|
JENKINS_API_TOKEN: optionalString,
|
||||||
|
JENKINS_AUTO_SYNC_ENABLED: booleanFromEnv,
|
||||||
|
JENKINS_AUTO_SYNC_INTERVAL_MS: positiveIntegerFromEnv(30000),
|
||||||
GITEA_BASE_URL: optionalUrl,
|
GITEA_BASE_URL: optionalUrl,
|
||||||
GITEA_TOKEN: optionalString,
|
GITEA_TOKEN: optionalString,
|
||||||
GITEA_WEBHOOK_SECRET: optionalString,
|
GITEA_WEBHOOK_SECRET: optionalString,
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ export class DeployExecutionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.auditService.record({
|
this.auditService.record({
|
||||||
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
|
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
actorName: run.operator,
|
actorName: run.operator,
|
||||||
@@ -501,7 +501,7 @@ export class DeployExecutionService {
|
|||||||
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
||||||
|
|
||||||
this.auditService.record({
|
this.auditService.record({
|
||||||
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
|
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
actorName: run.operator,
|
actorName: run.operator,
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import { DeployExecutionService } from './deploy-execution.service';
|
|||||||
import { DeployRunRepository } from './deploy-run.repository';
|
import { DeployRunRepository } from './deploy-run.repository';
|
||||||
import { DeployRunsController } from './deploy-runs.controller';
|
import { DeployRunsController } from './deploy-runs.controller';
|
||||||
import { DeployRunsService } from './deploy-runs.service';
|
import { DeployRunsService } from './deploy-runs.service';
|
||||||
|
import { JenkinsSyncSchedulerService } from './jenkins-sync-scheduler.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule],
|
imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule],
|
||||||
controllers: [DeployRunsController],
|
controllers: [DeployRunsController],
|
||||||
providers: [DeployExecutionService, DeployRunRepository, DeployRunsService],
|
providers: [
|
||||||
|
DeployExecutionService,
|
||||||
|
DeployRunRepository,
|
||||||
|
DeployRunsService,
|
||||||
|
JenkinsSyncSchedulerService,
|
||||||
|
],
|
||||||
exports: [DeployExecutionService, DeployRunRepository, DeployRunsService],
|
exports: [DeployExecutionService, DeployRunRepository, DeployRunsService],
|
||||||
})
|
})
|
||||||
export class DeployRunsModule {}
|
export class DeployRunsModule {}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export class DeployRunsService {
|
|||||||
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
||||||
|
|
||||||
this.auditService.record({
|
this.auditService.record({
|
||||||
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
|
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
after: {
|
after: {
|
||||||
@@ -193,6 +193,6 @@ export class DeployRunsService {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Jenkins sync failed';
|
error instanceof Error ? error.message : 'Jenkins sync failed';
|
||||||
|
|
||||||
return redactSensitive(message);
|
return redactSensitive({ message }).message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
import { DeployRunsService } from './deploy-runs.service';
|
||||||
|
import { JenkinsSyncSchedulerService } from './jenkins-sync-scheduler.service';
|
||||||
|
|
||||||
|
describe('JenkinsSyncSchedulerService', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start interval when auto sync is disabled', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const sync = {
|
||||||
|
syncJenkinsRuns: jest.fn(),
|
||||||
|
} satisfies Pick<DeployRunsService, 'syncJenkinsRuns'>;
|
||||||
|
const service = new JenkinsSyncSchedulerService(
|
||||||
|
createConfig({
|
||||||
|
JENKINS_AUTO_SYNC_ENABLED: false,
|
||||||
|
JENKINS_AUTO_SYNC_INTERVAL_MS: 1000,
|
||||||
|
}),
|
||||||
|
sync as unknown as DeployRunsService,
|
||||||
|
createAudit(),
|
||||||
|
);
|
||||||
|
|
||||||
|
service.onModuleInit();
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
|
||||||
|
expect(sync.syncJenkinsRuns).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs one sweep and records aggregate audit data', async () => {
|
||||||
|
const sync = {
|
||||||
|
syncJenkinsRuns: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
checkedAt: '2026-06-11T00:00:00.000Z',
|
||||||
|
totalCandidates: 2,
|
||||||
|
syncedRuns: [{} as never],
|
||||||
|
failedRuns: [{} as never],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
} satisfies Pick<DeployRunsService, 'syncJenkinsRuns'>;
|
||||||
|
const audit = createAudit();
|
||||||
|
const service = new JenkinsSyncSchedulerService(
|
||||||
|
createConfig({
|
||||||
|
JENKINS_AUTO_SYNC_ENABLED: true,
|
||||||
|
JENKINS_AUTO_SYNC_INTERVAL_MS: 1000,
|
||||||
|
}),
|
||||||
|
sync as unknown as DeployRunsService,
|
||||||
|
audit,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.runOnce();
|
||||||
|
|
||||||
|
expect(sync.syncJenkinsRuns).toHaveBeenCalledTimes(1);
|
||||||
|
expect(audit.recordMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'JENKINS_AUTO_SYNC_TICK_COMPLETED',
|
||||||
|
after: {
|
||||||
|
totalCandidates: 2,
|
||||||
|
syncedCount: 1,
|
||||||
|
failedCount: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records redacted failure audit when sweep fails', async () => {
|
||||||
|
const sync = {
|
||||||
|
syncJenkinsRuns: jest.fn(() =>
|
||||||
|
Promise.reject(
|
||||||
|
new Error('token=secret-value https://jenkins.internal failed'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
} satisfies Pick<DeployRunsService, 'syncJenkinsRuns'>;
|
||||||
|
const audit = createAudit();
|
||||||
|
const service = new JenkinsSyncSchedulerService(
|
||||||
|
createConfig({}),
|
||||||
|
sync as unknown as DeployRunsService,
|
||||||
|
audit,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.runOnce();
|
||||||
|
|
||||||
|
const payload = audit.recordMock.mock.calls[0][0];
|
||||||
|
expect(payload.action).toBe('JENKINS_AUTO_SYNC_TICK_FAILED');
|
||||||
|
expect(JSON.stringify(payload)).not.toContain('secret-value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createConfig(
|
||||||
|
values: Partial<Record<keyof EnvConfig, string | number | boolean | undefined>>,
|
||||||
|
): ConfigService<EnvConfig, true> {
|
||||||
|
return {
|
||||||
|
get: jest.fn((key: keyof EnvConfig) => values[key]),
|
||||||
|
} as unknown as ConfigService<EnvConfig, true>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAudit(): AuditService & { recordMock: jest.Mock } {
|
||||||
|
const recordMock = jest.fn();
|
||||||
|
|
||||||
|
return {
|
||||||
|
record: recordMock,
|
||||||
|
recordMock,
|
||||||
|
} as unknown as AuditService & { recordMock: jest.Mock };
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
import { DeployRunsService } from './deploy-runs.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轻量自动同步器,先复用内存发布单和现有 Jenkins 同步逻辑。
|
||||||
|
* 后续接 Redis/BullMQ 后,这里会迁移成队列 worker。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class JenkinsSyncSchedulerService
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
private timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService<EnvConfig, true>,
|
||||||
|
private readonly deployRunsService: DeployRunsService,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
if (!this.config.get('JENKINS_AUTO_SYNC_ENABLED', { infer: true })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMs = this.config.get('JENKINS_AUTO_SYNC_INTERVAL_MS', {
|
||||||
|
infer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void this.runOnce();
|
||||||
|
}, intervalMs);
|
||||||
|
void this.runOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runOnce(): Promise<void> {
|
||||||
|
if (this.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await this.deployRunsService.syncJenkinsRuns();
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'JENKINS_AUTO_SYNC_TICK_COMPLETED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
after: {
|
||||||
|
totalCandidates: summary.totalCandidates,
|
||||||
|
syncedCount: summary.syncedRuns.length,
|
||||||
|
failedCount: summary.failedRuns.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Jenkins auto sync failed';
|
||||||
|
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'JENKINS_AUTO_SYNC_TICK_FAILED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
after: {
|
||||||
|
message: redactSensitive({ message }).message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { LlmClient } from '../agent/llm.client';
|
|||||||
import { EnvConfig } from '../config/env.schema';
|
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 { WeComClient } from '../notifications/wecom/wecom.client';
|
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';
|
||||||
|
|
||||||
@@ -26,9 +26,9 @@ describe('HealthService', () => {
|
|||||||
const gitea = {
|
const gitea = {
|
||||||
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||||
} satisfies Pick<GiteaClient, 'healthSummary'>;
|
} satisfies Pick<GiteaClient, 'healthSummary'>;
|
||||||
const weCom = {
|
const notification = {
|
||||||
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||||
} satisfies Pick<WeComClient, 'healthSummary'>;
|
} satisfies Pick<DeployNotificationService, 'healthSummary'>;
|
||||||
const llm = {
|
const llm = {
|
||||||
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||||
} satisfies Pick<LlmClient, 'healthSummary'>;
|
} satisfies Pick<LlmClient, 'healthSummary'>;
|
||||||
@@ -37,7 +37,7 @@ describe('HealthService', () => {
|
|||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
jenkins as unknown as JenkinsClient,
|
jenkins as unknown as JenkinsClient,
|
||||||
gitea as unknown as GiteaClient,
|
gitea as unknown as GiteaClient,
|
||||||
weCom as unknown as WeComClient,
|
notification as unknown as DeployNotificationService,
|
||||||
llm as unknown as LlmClient,
|
llm as unknown as LlmClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -46,5 +46,6 @@ 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.jenkins.status).toBe('not_configured');
|
expect(health.dependencies.jenkins.status).toBe('not_configured');
|
||||||
|
expect(health.dependencies.notification.status).toBe('not_configured');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { LlmClient } from '../agent/llm.client';
|
|||||||
import { EnvConfig } from '../config/env.schema';
|
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 { WeComClient } from '../notifications/wecom/wecom.client';
|
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
type HealthStatus = 'ok' | 'not_configured' | 'unavailable';
|
type HealthStatus = 'ok' | 'not_configured' | 'unavailable';
|
||||||
@@ -16,7 +16,7 @@ export class HealthService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jenkins: JenkinsClient,
|
private readonly jenkins: JenkinsClient,
|
||||||
private readonly gitea: GiteaClient,
|
private readonly gitea: GiteaClient,
|
||||||
private readonly weCom: WeComClient,
|
private readonly notification: DeployNotificationService,
|
||||||
private readonly llm: LlmClient,
|
private readonly llm: LlmClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export class HealthService {
|
|||||||
: { status: 'not_configured' as const },
|
: { status: 'not_configured' as const },
|
||||||
jenkins: this.jenkins.healthSummary(),
|
jenkins: this.jenkins.healthSummary(),
|
||||||
gitea: this.gitea.healthSummary(),
|
gitea: this.gitea.healthSummary(),
|
||||||
wecom: this.weCom.healthSummary(),
|
notification: this.notification.healthSummary(),
|
||||||
llm: this.llm.healthSummary(),
|
llm: this.llm.healthSummary(),
|
||||||
};
|
};
|
||||||
const hasUnavailable = Object.values(dependencies).some(
|
const hasUnavailable = Object.values(dependencies).some(
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { EnvConfig } from '../../config/env.schema';
|
||||||
import { WeComClient } from './wecom.client';
|
import { WeComClient } from './wecom.client';
|
||||||
import { DeployNotificationService } from './deploy-notification.service';
|
import { DeployNotificationService } from './deploy-notification.service';
|
||||||
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
|
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 覆盖企微通知的安全边界:缺少 webhook 时跳过,配置后才发送。
|
* 覆盖通知 provider 的安全边界:缺少配置时跳过,配置后才发送。
|
||||||
*/
|
*/
|
||||||
describe('DeployNotificationService', () => {
|
describe('DeployNotificationService', () => {
|
||||||
const run: DeployRunSummary = {
|
const run: DeployRunSummary = {
|
||||||
@@ -18,7 +20,11 @@ describe('DeployNotificationService', () => {
|
|||||||
steps: [],
|
steps: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('skips notification when WeCom is not configured', async () => {
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips notification when provider is not configured', async () => {
|
||||||
const weCom = {
|
const weCom = {
|
||||||
healthSummary: jest.fn(() => ({
|
healthSummary: jest.fn(() => ({
|
||||||
status: 'not_configured' as const,
|
status: 'not_configured' as const,
|
||||||
@@ -27,27 +33,31 @@ describe('DeployNotificationService', () => {
|
|||||||
sendMarkdown: jest.fn(),
|
sendMarkdown: jest.fn(),
|
||||||
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||||
const service = new DeployNotificationService(
|
const service = new DeployNotificationService(
|
||||||
|
createConfig({}),
|
||||||
weCom as unknown as WeComClient,
|
weCom as unknown as WeComClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.sendDeployRunEvent(run, 'created');
|
const result = await service.sendDeployRunEvent(run, 'created');
|
||||||
|
|
||||||
expect(result.status).toBe('skipped');
|
expect(result.status).toBe('skipped');
|
||||||
|
expect(result.channel).toBe('none');
|
||||||
expect(weCom.sendMarkdown).not.toHaveBeenCalled();
|
expect(weCom.sendMarkdown).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends markdown when WeCom is configured', async () => {
|
it('sends markdown when WeCom provider is configured', async () => {
|
||||||
const weCom = {
|
const weCom = {
|
||||||
healthSummary: jest.fn(() => ({ status: 'ok' as const })),
|
healthSummary: jest.fn(() => ({ status: 'ok' as const })),
|
||||||
sendMarkdown: jest.fn(() => Promise.resolve({ accepted: true })),
|
sendMarkdown: jest.fn(() => Promise.resolve({ accepted: true })),
|
||||||
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||||
const service = new DeployNotificationService(
|
const service = new DeployNotificationService(
|
||||||
|
createConfig({ NOTIFICATION_PROVIDER: 'wecom' }),
|
||||||
weCom as unknown as WeComClient,
|
weCom as unknown as WeComClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.sendDeployRunEvent(run, 'created');
|
const result = await service.sendDeployRunEvent(run, 'created');
|
||||||
|
|
||||||
expect(result.status).toBe('sent');
|
expect(result.status).toBe('sent');
|
||||||
|
expect(result.channel).toBe('wecom');
|
||||||
expect(weCom.sendMarkdown).toHaveBeenCalledWith(
|
expect(weCom.sendMarkdown).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
title: 'DevOps 发布已创建',
|
title: 'DevOps 发布已创建',
|
||||||
@@ -55,4 +65,69 @@ describe('DeployNotificationService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends text payload when Feishu provider is configured', async () => {
|
||||||
|
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
} as Response);
|
||||||
|
const weCom = {
|
||||||
|
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||||
|
sendMarkdown: jest.fn(),
|
||||||
|
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||||
|
const service = new DeployNotificationService(
|
||||||
|
createConfig({
|
||||||
|
NOTIFICATION_PROVIDER: 'feishu',
|
||||||
|
FEISHU_WEBHOOK_URL: 'https://open.feishu.cn/open-apis/bot/v2/hook/test',
|
||||||
|
}),
|
||||||
|
weCom as unknown as WeComClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.sendDeployRunEvent(run, 'failed');
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'sent', channel: 'feishu' });
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'https://open.feishu.cn/open-apis/bot/v2/hook/test',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: expect.stringContaining('DevOps 发布失败'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends structured payload when generic provider is configured', async () => {
|
||||||
|
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
} as Response);
|
||||||
|
const weCom = {
|
||||||
|
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||||
|
sendMarkdown: jest.fn(),
|
||||||
|
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||||
|
const service = new DeployNotificationService(
|
||||||
|
createConfig({
|
||||||
|
NOTIFICATION_PROVIDER: 'generic',
|
||||||
|
NOTIFICATION_WEBHOOK_URL: 'https://notification.internal/webhook/test',
|
||||||
|
DEVOPS_PUBLIC_URL: 'https://devops.mrzhan.top',
|
||||||
|
}),
|
||||||
|
weCom as unknown as WeComClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.sendDeployRunEvent(run, 'succeeded');
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'sent', channel: 'generic' });
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'https://notification.internal/webhook/test',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: expect.stringContaining('devops.mrzhan.top'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createConfig(
|
||||||
|
values: Partial<Record<keyof EnvConfig, string | number | boolean | undefined>>,
|
||||||
|
): ConfigService<EnvConfig, true> {
|
||||||
|
return {
|
||||||
|
get: jest.fn((key: keyof EnvConfig) => values[key]),
|
||||||
|
} as unknown as ConfigService<EnvConfig, true>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { redactSensitive } from '../../common/security/redact-sensitive';
|
import { redactSensitive } from '../../common/security/redact-sensitive';
|
||||||
|
import { EnvConfig } from '../../config/env.schema';
|
||||||
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
|
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
|
||||||
import { WeComClient } from './wecom.client';
|
import { WeComClient } from './wecom.client';
|
||||||
|
|
||||||
@@ -13,50 +15,191 @@ export type DeployNotificationEvent =
|
|||||||
|
|
||||||
export type DeployNotificationResult = {
|
export type DeployNotificationResult = {
|
||||||
status: 'sent' | 'skipped' | 'failed';
|
status: 'sent' | 'skipped' | 'failed';
|
||||||
channel: 'wecom';
|
channel: DeployNotificationProvider | 'none';
|
||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeployNotificationProvider = 'wecom' | 'feishu' | 'generic';
|
||||||
|
|
||||||
|
type DeployNotificationHealth = {
|
||||||
|
status: 'ok' | 'not_configured' | 'unavailable';
|
||||||
|
provider?: DeployNotificationProvider;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一封装发布单企微通知,保证 webhook 只存在于后端配置边界内。
|
* 统一封装发布单通知,保证 webhook 只存在于后端配置边界内。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeployNotificationService {
|
export class DeployNotificationService {
|
||||||
constructor(private readonly weComClient: WeComClient) {}
|
constructor(
|
||||||
|
private readonly config: ConfigService<EnvConfig, true>,
|
||||||
|
private readonly weComClient: WeComClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
healthSummary(): DeployNotificationHealth {
|
||||||
|
const provider = this.resolveProvider();
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return {
|
||||||
|
status: 'not_configured',
|
||||||
|
message: 'Missing NOTIFICATION_PROVIDER',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'wecom') {
|
||||||
|
const health = this.weComClient.healthSummary();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...health,
|
||||||
|
provider,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookKey = this.webhookKeyFor(provider);
|
||||||
|
|
||||||
|
return this.config.get(webhookKey, { infer: true })
|
||||||
|
? { status: 'ok', provider }
|
||||||
|
: {
|
||||||
|
status: 'not_configured',
|
||||||
|
provider,
|
||||||
|
message: `Missing ${webhookKey}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async sendDeployRunEvent(
|
async sendDeployRunEvent(
|
||||||
run: DeployRunSummary,
|
run: DeployRunSummary,
|
||||||
event: DeployNotificationEvent,
|
event: DeployNotificationEvent,
|
||||||
): Promise<DeployNotificationResult> {
|
): Promise<DeployNotificationResult> {
|
||||||
const health = this.weComClient.healthSummary();
|
const health = this.healthSummary();
|
||||||
|
|
||||||
if (health.status === 'not_configured') {
|
if (health.status === 'not_configured') {
|
||||||
return {
|
return {
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
channel: 'wecom',
|
channel: health.provider ?? 'none',
|
||||||
reason: health.message ?? 'WeCom webhook is not configured',
|
reason: health.message ?? 'Notification provider is not configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health.status === 'unavailable' || !health.provider) {
|
||||||
|
return {
|
||||||
|
status: 'failed',
|
||||||
|
channel: health.provider ?? 'none',
|
||||||
|
reason: health.message ?? 'Notification provider is unavailable',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.weComClient.sendMarkdown({
|
await this.sendByProvider(health.provider, run, event);
|
||||||
title: this.titleFor(event),
|
|
||||||
markdown: this.markdownFor(run, event),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { status: 'sent', channel: 'wecom' };
|
return { status: 'sent', channel: health.provider };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
channel: 'wecom',
|
channel: health.provider,
|
||||||
reason:
|
reason:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? redactSensitive({ message: error.message }).message
|
? redactSensitive({ message: error.message }).message
|
||||||
: 'Unknown WeCom notification error',
|
: 'Unknown notification error',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveProvider(): DeployNotificationProvider | undefined {
|
||||||
|
const provider = this.config
|
||||||
|
.get('NOTIFICATION_PROVIDER', { infer: true })
|
||||||
|
?.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
provider === 'wecom' ||
|
||||||
|
provider === 'feishu' ||
|
||||||
|
provider === 'generic'
|
||||||
|
) {
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private webhookKeyFor(
|
||||||
|
provider: Exclude<DeployNotificationProvider, 'wecom'>,
|
||||||
|
): 'FEISHU_WEBHOOK_URL' | 'NOTIFICATION_WEBHOOK_URL' {
|
||||||
|
return provider === 'feishu'
|
||||||
|
? 'FEISHU_WEBHOOK_URL'
|
||||||
|
: 'NOTIFICATION_WEBHOOK_URL';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendByProvider(
|
||||||
|
provider: DeployNotificationProvider,
|
||||||
|
run: DeployRunSummary,
|
||||||
|
event: DeployNotificationEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
const title = this.titleFor(event);
|
||||||
|
const markdown = this.markdownFor(run, event);
|
||||||
|
|
||||||
|
if (provider === 'wecom') {
|
||||||
|
await this.weComClient.sendMarkdown({
|
||||||
|
title,
|
||||||
|
markdown,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'feishu') {
|
||||||
|
await this.postWebhook(
|
||||||
|
this.config.get('FEISHU_WEBHOOK_URL', { infer: true }) ?? '',
|
||||||
|
{
|
||||||
|
msg_type: 'text',
|
||||||
|
content: {
|
||||||
|
text: `${title}\n${this.plainTextFor(run, event)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.postWebhook(
|
||||||
|
this.config.get('NOTIFICATION_WEBHOOK_URL', { infer: true }) ?? '',
|
||||||
|
{
|
||||||
|
event: `deploy.${event}`,
|
||||||
|
title,
|
||||||
|
markdown,
|
||||||
|
run: {
|
||||||
|
id: run.id,
|
||||||
|
projectKey: run.projectKey,
|
||||||
|
environment: run.environment,
|
||||||
|
ref: run.ref,
|
||||||
|
status: run.status,
|
||||||
|
operator: run.operator,
|
||||||
|
jenkinsQueueId: run.jenkinsQueueId,
|
||||||
|
jenkinsBuildNumber: run.jenkinsBuildNumber,
|
||||||
|
failureSummary: run.failureSummary,
|
||||||
|
},
|
||||||
|
publicUrl: this.config.get('DEVOPS_PUBLIC_URL', { infer: true }),
|
||||||
|
},
|
||||||
|
provider,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postWebhook(
|
||||||
|
webhookUrl: string,
|
||||||
|
body: unknown,
|
||||||
|
provider: DeployNotificationProvider,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${provider} webhook request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private titleFor(event: DeployNotificationEvent): string {
|
private titleFor(event: DeployNotificationEvent): string {
|
||||||
if (event === 'canceled') {
|
if (event === 'canceled') {
|
||||||
return 'DevOps 发布已取消';
|
return 'DevOps 发布已取消';
|
||||||
@@ -100,4 +243,22 @@ export class DeployNotificationService {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private plainTextFor(
|
||||||
|
run: DeployRunSummary,
|
||||||
|
event: DeployNotificationEvent,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
`事件:${this.titleFor(event)}`,
|
||||||
|
`项目:${run.projectKey}`,
|
||||||
|
`环境:${run.environment}`,
|
||||||
|
`Ref:${run.ref}`,
|
||||||
|
`状态:${run.status}`,
|
||||||
|
`操作者:${run.operator}`,
|
||||||
|
`Run ID:${run.id}`,
|
||||||
|
run.failureSummary ? `失败摘要:${run.failureSummary}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [
|
|||||||
key: 'jenkins',
|
key: 'jenkins',
|
||||||
name: 'Jenkins',
|
name: 'Jenkins',
|
||||||
required: ['JENKINS_BASE_URL', 'JENKINS_USERNAME', 'JENKINS_API_TOKEN'],
|
required: ['JENKINS_BASE_URL', 'JENKINS_USERNAME', 'JENKINS_API_TOKEN'],
|
||||||
|
optional: ['JENKINS_AUTO_SYNC_ENABLED', 'JENKINS_AUTO_SYNC_INTERVAL_MS'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'gitea',
|
key: 'gitea',
|
||||||
@@ -40,7 +41,12 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [
|
|||||||
key: 'notification',
|
key: 'notification',
|
||||||
name: '通知平台',
|
name: '通知平台',
|
||||||
required: ['NOTIFICATION_PROVIDER'],
|
required: ['NOTIFICATION_PROVIDER'],
|
||||||
optional: ['WECOM_WEBHOOK_URL', 'FEISHU_WEBHOOK_URL', 'NOTIFICATION_WEBHOOK_URL'],
|
optional: [
|
||||||
|
'WECOM_WEBHOOK_URL',
|
||||||
|
'FEISHU_WEBHOOK_URL',
|
||||||
|
'NOTIFICATION_WEBHOOK_URL',
|
||||||
|
'DEVOPS_PUBLIC_URL',
|
||||||
|
],
|
||||||
note: '当前不强制配置机器人;后续可按 provider 接入企微、飞书或通用 webhook。',
|
note: '当前不强制配置机器人;后续可按 provider 接入企微、飞书或通用 webhook。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -87,6 +93,10 @@ export class SettingsService {
|
|||||||
private toIntegrationStatus(
|
private toIntegrationStatus(
|
||||||
definition: IntegrationDefinition,
|
definition: IntegrationDefinition,
|
||||||
): IntegrationConfigStatus {
|
): IntegrationConfigStatus {
|
||||||
|
if (definition.key === 'notification') {
|
||||||
|
return this.toNotificationIntegrationStatus(definition);
|
||||||
|
}
|
||||||
|
|
||||||
const configured = definition.required.filter((key) =>
|
const configured = definition.required.filter((key) =>
|
||||||
this.isConfiguredValue(this.getConfigValue(key)),
|
this.isConfiguredValue(this.getConfigValue(key)),
|
||||||
);
|
);
|
||||||
@@ -106,6 +116,54 @@ export class SettingsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toNotificationIntegrationStatus(
|
||||||
|
definition: IntegrationDefinition,
|
||||||
|
): IntegrationConfigStatus {
|
||||||
|
const provider = this.getConfigValue('NOTIFICATION_PROVIDER')?.toLowerCase();
|
||||||
|
const providerWebhook = this.notificationWebhookKey(provider);
|
||||||
|
const required: EnvStringKey[] = providerWebhook
|
||||||
|
? ['NOTIFICATION_PROVIDER', providerWebhook]
|
||||||
|
: ['NOTIFICATION_PROVIDER'];
|
||||||
|
const configured = required.filter((key) =>
|
||||||
|
this.isConfiguredValue(this.getConfigValue(key)),
|
||||||
|
);
|
||||||
|
const missing = required.filter(
|
||||||
|
(key) => !this.isConfiguredValue(this.getConfigValue(key)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: definition.key,
|
||||||
|
name: definition.name,
|
||||||
|
status: this.resolveStatus(configured.length, missing.length),
|
||||||
|
required,
|
||||||
|
configured,
|
||||||
|
missing,
|
||||||
|
...(definition.optional ? { optional: [...definition.optional] } : {}),
|
||||||
|
note:
|
||||||
|
provider && !providerWebhook
|
||||||
|
? `NOTIFICATION_PROVIDER=${provider} 暂不支持;可选 wecom、feishu、generic。`
|
||||||
|
: definition.note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private notificationWebhookKey(
|
||||||
|
provider: string | undefined,
|
||||||
|
): EnvStringKey | undefined {
|
||||||
|
if (provider === 'wecom') {
|
||||||
|
return 'WECOM_WEBHOOK_URL';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'feishu') {
|
||||||
|
return 'FEISHU_WEBHOOK_URL';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'generic') {
|
||||||
|
return 'NOTIFICATION_WEBHOOK_URL';
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private getConfigValue(key: EnvStringKey): string | undefined {
|
private getConfigValue(key: EnvStringKey): string | undefined {
|
||||||
const value = this.config.get(key, { infer: true });
|
const value = this.config.get(key, { infer: true });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user