0d60820c93
- notifications: 支持失败 outbox 按配置 nextAttemptAt 重试并达到上限转 dead - settings: 暴露通知 outbox 重试相关环境变量说明
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { EnvConfig } from '../config/env.schema';
|
|
import {
|
|
IntegrationConfigKey,
|
|
IntegrationConfigState,
|
|
IntegrationConfigStatus,
|
|
IntegrationConfigStatusResponse,
|
|
IntegrationVariableHelp,
|
|
} from './settings.types';
|
|
|
|
type EnvStringKey = {
|
|
[Key in keyof EnvConfig]-?: Extract<EnvConfig[Key], string> extends never
|
|
? never
|
|
: Key;
|
|
}[keyof EnvConfig];
|
|
type EnvKey = keyof EnvConfig;
|
|
|
|
type IntegrationDefinition = {
|
|
key: IntegrationConfigKey;
|
|
name: string;
|
|
required: EnvStringKey[];
|
|
optional?: EnvKey[];
|
|
note?: string;
|
|
variableHelp?: Partial<Record<EnvKey, IntegrationVariableHelp>>;
|
|
};
|
|
|
|
const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [
|
|
{
|
|
key: 'jenkins',
|
|
name: 'Jenkins',
|
|
required: ['JENKINS_BASE_URL', 'JENKINS_USERNAME', 'JENKINS_API_TOKEN'],
|
|
optional: ['JENKINS_AUTO_SYNC_ENABLED', 'JENKINS_AUTO_SYNC_INTERVAL_MS'],
|
|
variableHelp: {
|
|
JENKINS_BASE_URL:
|
|
variableHelp('Jenkins 服务端 API 地址,只能由后端服务访问。', {
|
|
docUrl: 'https://www.jenkins.io/doc/book/using/remote-access-api/',
|
|
}),
|
|
JENKINS_USERNAME: variableHelp('用于调用 Jenkins Remote API 的服务账号。'),
|
|
JENKINS_API_TOKEN: variableHelp(
|
|
'Jenkins 服务账号 API token,只能保存在服务端环境变量或后续加密密钥表。',
|
|
),
|
|
JENKINS_AUTO_SYNC_ENABLED: variableHelp(
|
|
'是否启用轻量自动同步器,自动扫描 queued/running 发布单。',
|
|
{ example: 'true' },
|
|
),
|
|
JENKINS_AUTO_SYNC_INTERVAL_MS: variableHelp(
|
|
'自动同步 Jenkins 状态的轮询间隔,单位毫秒。',
|
|
{ example: '30000' },
|
|
),
|
|
},
|
|
},
|
|
{
|
|
key: 'gitea',
|
|
name: 'Gitea',
|
|
required: ['GITEA_BASE_URL', 'GITEA_TOKEN'],
|
|
optional: ['GITEA_WEBHOOK_SECRET'],
|
|
note: 'GITEA_WEBHOOK_SECRET 用于开启 webhook 签名校验。',
|
|
variableHelp: {
|
|
GITEA_BASE_URL: variableHelp('Gitea 服务端 API 地址,用于读取仓库、分支和 tag。'),
|
|
GITEA_TOKEN: variableHelp(
|
|
'Gitea API token,只能由后端持有,用于读取 refs 和后续仓库元数据。',
|
|
{ docUrl: 'https://docs.gitea.com/development/api-usage' },
|
|
),
|
|
GITEA_WEBHOOK_SECRET: variableHelp(
|
|
'Gitea push webhook 签名密钥;配置后后端会校验 webhook 来源。',
|
|
),
|
|
},
|
|
},
|
|
{
|
|
key: 'notification',
|
|
name: '通知平台',
|
|
required: ['NOTIFICATION_PROVIDER'],
|
|
optional: [
|
|
'WECOM_WEBHOOK_URL',
|
|
'FEISHU_WEBHOOK_URL',
|
|
'NOTIFICATION_WEBHOOK_URL',
|
|
'DEVOPS_PUBLIC_URL',
|
|
'NOTIFICATION_OUTBOX_RETRY_ENABLED',
|
|
'NOTIFICATION_OUTBOX_RETRY_INTERVAL_MS',
|
|
'NOTIFICATION_OUTBOX_RETRY_DELAY_MS',
|
|
'NOTIFICATION_OUTBOX_MAX_ATTEMPTS',
|
|
'NOTIFICATION_OUTBOX_BATCH_SIZE',
|
|
],
|
|
note: '当前不强制配置机器人;后续可按 provider 接入企微、飞书或通用 webhook。',
|
|
variableHelp: {
|
|
NOTIFICATION_PROVIDER: variableHelp(
|
|
'通知 provider,可选 wecom、feishu、generic;未配置时通知节点会 skipped。',
|
|
{ example: 'wecom' },
|
|
),
|
|
WECOM_WEBHOOK_URL: variableHelp(
|
|
'企业微信群机器人的服务端 webhook,只能保存在后端。',
|
|
{
|
|
docUrl: 'https://developer.work.weixin.qq.com/document/path/91770',
|
|
},
|
|
),
|
|
FEISHU_WEBHOOK_URL: variableHelp(
|
|
'飞书自定义机器人的服务端 webhook,只能保存在后端。',
|
|
{
|
|
docUrl:
|
|
'https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot',
|
|
},
|
|
),
|
|
NOTIFICATION_WEBHOOK_URL: variableHelp(
|
|
'内部通知网关或通用 webhook 的服务端地址,适合接自建消息中转服务。',
|
|
),
|
|
DEVOPS_PUBLIC_URL: variableHelp('通知消息中跳转回 DevOps 平台的公开访问地址。', {
|
|
example: 'https://devops.mrzhan.top',
|
|
}),
|
|
NOTIFICATION_OUTBOX_RETRY_ENABLED: variableHelp(
|
|
'是否启用轻量通知 outbox 重试器,失败通知会按 nextAttemptAt 自动重投。',
|
|
{ example: 'true' },
|
|
),
|
|
NOTIFICATION_OUTBOX_RETRY_INTERVAL_MS: variableHelp(
|
|
'通知 outbox 重试扫描间隔,单位毫秒。',
|
|
{ example: '30000' },
|
|
),
|
|
NOTIFICATION_OUTBOX_RETRY_DELAY_MS: variableHelp(
|
|
'单条通知投递失败后的下次重试延迟,单位毫秒。',
|
|
{ example: '60000' },
|
|
),
|
|
NOTIFICATION_OUTBOX_MAX_ATTEMPTS: variableHelp(
|
|
'单条通知最大投递次数,达到后标记为 dead,避免无限重试。',
|
|
{ example: '3' },
|
|
),
|
|
NOTIFICATION_OUTBOX_BATCH_SIZE: variableHelp(
|
|
'每轮 outbox 重试最多处理的消息数量。',
|
|
{ example: '10' },
|
|
),
|
|
},
|
|
},
|
|
{
|
|
key: 'llm',
|
|
name: 'LLM Agent',
|
|
required: ['LLM_BASE_URL', 'LLM_API_KEY', 'LLM_MODEL'],
|
|
variableHelp: {
|
|
LLM_BASE_URL: variableHelp('LLM 兼容接口地址,只允许后端代理调用。'),
|
|
LLM_API_KEY: variableHelp(
|
|
'LLM API key,只能保存在后端环境变量或后续加密密钥表。',
|
|
),
|
|
LLM_MODEL: variableHelp('DevOps Agent 使用的模型名称。'),
|
|
},
|
|
},
|
|
{
|
|
key: 'database',
|
|
name: 'MySQL / Prisma',
|
|
required: ['DATABASE_URL'],
|
|
optional: ['USE_DATABASE_READS', 'PRISMA_CONNECT_ON_BOOT'],
|
|
variableHelp: {
|
|
DATABASE_URL: variableHelp(
|
|
'Prisma 连接 MySQL 的服务端连接串,前端和审计日志不得暴露原文。',
|
|
{ docUrl: 'https://www.prisma.io/docs/orm/reference/connection-urls' },
|
|
),
|
|
USE_DATABASE_READS: variableHelp('是否启用 MySQL 持久化读写路径。', {
|
|
example: 'true',
|
|
}),
|
|
PRISMA_CONNECT_ON_BOOT: variableHelp(
|
|
'是否在服务启动时主动连接数据库,用于尽早发现连接问题。',
|
|
{ example: 'true' },
|
|
),
|
|
},
|
|
},
|
|
{
|
|
key: 'redis',
|
|
name: 'Redis / BullMQ',
|
|
required: ['REDIS_URL'],
|
|
variableHelp: {
|
|
REDIS_URL: variableHelp(
|
|
'Redis 连接串,后续用于 BullMQ 队列、通知重试和 Jenkins 日志异步任务。',
|
|
),
|
|
},
|
|
},
|
|
{
|
|
key: 'secrets',
|
|
name: '服务端密钥加密',
|
|
required: ['SECRET_ENCRYPTION_KEY'],
|
|
variableHelp: {
|
|
SECRET_ENCRYPTION_KEY: variableHelp(
|
|
'服务端加密密钥,用于后续加密保存 Jenkins/Gitea/通知/LLM 等敏感配置。',
|
|
),
|
|
},
|
|
},
|
|
];
|
|
|
|
const PLACEHOLDER_PATTERNS = [
|
|
/replace-with-/i,
|
|
/\.example\.internal(?:[/:]|$)/i,
|
|
];
|
|
|
|
@Injectable()
|
|
export class SettingsService {
|
|
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
|
|
|
|
getIntegrationConfigStatus(): IntegrationConfigStatusResponse {
|
|
return {
|
|
checkedAt: new Date().toISOString(),
|
|
integrations: INTEGRATION_DEFINITIONS.map((definition) =>
|
|
this.toIntegrationStatus(definition),
|
|
),
|
|
};
|
|
}
|
|
|
|
private toIntegrationStatus(
|
|
definition: IntegrationDefinition,
|
|
): IntegrationConfigStatus {
|
|
if (definition.key === 'notification') {
|
|
return this.toNotificationIntegrationStatus(definition);
|
|
}
|
|
|
|
const configured = definition.required.filter((key) =>
|
|
this.isConfiguredValue(this.getConfigValue(key)),
|
|
);
|
|
const missing = definition.required.filter(
|
|
(key) => !this.isConfiguredValue(this.getConfigValue(key)),
|
|
);
|
|
|
|
return {
|
|
key: definition.key,
|
|
name: definition.name,
|
|
status: this.resolveStatus(configured.length, missing.length),
|
|
required: [...definition.required],
|
|
configured,
|
|
missing,
|
|
...(definition.optional ? { optional: [...definition.optional] } : {}),
|
|
...(definition.note ? { note: definition.note } : {}),
|
|
...(definition.variableHelp
|
|
? { variableHelp: this.pickVariableHelp(definition) }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
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,
|
|
...(definition.variableHelp
|
|
? { variableHelp: this.pickVariableHelp(definition, required) }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
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 {
|
|
const value = this.config.get(key, { infer: true });
|
|
|
|
return typeof value === 'string' ? value.trim() : undefined;
|
|
}
|
|
|
|
private isConfiguredValue(value: string | undefined): boolean {
|
|
if (!value) {
|
|
return false;
|
|
}
|
|
|
|
return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value));
|
|
}
|
|
|
|
private resolveStatus(
|
|
configuredCount: number,
|
|
missingCount: number,
|
|
): IntegrationConfigState {
|
|
if (missingCount === 0) {
|
|
return 'configured';
|
|
}
|
|
|
|
return configuredCount > 0 ? 'partial' : 'missing';
|
|
}
|
|
|
|
private pickVariableHelp(
|
|
definition: IntegrationDefinition,
|
|
extraKeys: EnvKey[] = [],
|
|
): Record<string, IntegrationVariableHelp> {
|
|
const visibleKeys = new Set<EnvKey>([
|
|
...definition.required,
|
|
...(definition.optional ?? []),
|
|
...extraKeys,
|
|
]);
|
|
const helpEntries = Object.entries(definition.variableHelp ?? {}).filter(
|
|
([key]) => visibleKeys.has(key as EnvKey),
|
|
);
|
|
|
|
return Object.fromEntries(helpEntries);
|
|
}
|
|
}
|
|
|
|
function variableHelp(
|
|
description: string,
|
|
options: Pick<IntegrationVariableHelp, 'example' | 'docUrl'> = {},
|
|
): IntegrationVariableHelp {
|
|
return {
|
|
description,
|
|
...options,
|
|
};
|
|
}
|