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 extends never ? never : Key; }[keyof EnvConfig]; type EnvKey = keyof EnvConfig; type IntegrationDefinition = { key: IntegrationConfigKey; name: string; required: EnvStringKey[]; optional?: EnvKey[]; note?: string; variableHelp?: Partial>; }; 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) {} 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 { const visibleKeys = new Set([ ...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 { return { description, ...options, }; }