import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { IntegrationConfig, IntegrationConfigDetail } from '../entities/integration-config.entity'; import { ThirdConfigBaseDTO, DingTalkThirdConfigDto, WeComThirdConfigDto, IntegrationType, SaveIntegrationConfigDto, } from './dto/config.dto'; @Injectable() export class IntegrationConfigService { private readonly logger = new Logger(IntegrationConfigService.name); constructor( @InjectRepository(IntegrationConfig) private readonly configRepo: Repository, @InjectRepository(IntegrationConfigDetail) private readonly detailRepo: Repository, ) {} /** 获取或创建主配置(全局单例) */ private async ensureConfig(): Promise { let config = await this.configRepo.findOne({ where: { type: 'THIRD' } }); if (!config) { config = this.configRepo.create({ type: 'THIRD', isSync: false }); await this.configRepo.save(config); } return config; } /** DINGTALK -> DINGTALK_SYNC, WECOM -> WECOM_SYNC */ private getDetailType(type: string): string { switch (type.toUpperCase()) { case 'WECOM': return 'WECOM_SYNC'; case 'DINGTALK': return 'DINGTALK_SYNC'; default: throw new BadRequestException(`不支持的第三方类型: ${type}`); } } /** 获取所有配置(脱敏,不返回 appSecret) */ async getThirdConfig(): Promise { const config = await this.ensureConfig(); const details = await this.detailRepo.find({ where: { configId: config.id } }); return details.map((detail) => ({ type: detail.type.includes('WECOM') ? 'WECOM' : detail.type.includes('DINGTALK') ? 'DINGTALK' : detail.type, verify: detail.enable, config: this.parseAndMaskConfig(detail.content), })); } /** 按类型获取单个配置(脱敏) */ async getConfigByType(type: string): Promise { const all = await this.getThirdConfig(); return all.find((c) => c.type === type.toUpperCase()) || null; } /** 保存/更新配置 */ async saveConfig(request: SaveIntegrationConfigDto): Promise { const config = await this.ensureConfig(); const detailType = this.getDetailType(request.type); let existingDetail = await this.detailRepo.findOne({ where: { configId: config.id, type: detailType }, }); const finalConfig = { ...request.config } as Record; // 更新时若前端未传 appSecret,则保留旧值 if (existingDetail && existingDetail.content) { if (!finalConfig.appSecret) { try { const oldParsed = JSON.parse(existingDetail.content); const oldCfg = oldParsed.config || oldParsed; if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret; } catch { // ignore } } } else if (!finalConfig.appSecret) { throw new BadRequestException('首次配置必须提供 AppSecret'); } // 连通性验证 const token = await this.getTokenForTest(request.type, finalConfig); const verified = !!token; const content = JSON.stringify({ type: request.type, verify: verified, config: finalConfig, }); if (existingDetail) { existingDetail.content = content; existingDetail.enable = verified; await this.detailRepo.save(existingDetail); } else { existingDetail = this.detailRepo.create({ configId: config.id, name: '第三方设置', type: detailType, content, enable: verified, }); await this.detailRepo.save(existingDetail); } this.logger.log(`第三方配置已保存: ${request.type}, 验证: ${verified}`); } /** 测试连接 */ async testConnection( type: IntegrationType, config: DingTalkThirdConfigDto | WeComThirdConfigDto, ): Promise { try { const finalConfig = { ...config } as Record; if (!finalConfig.appSecret) { const savedConfig = await this.getRawConfig(type); if (savedConfig?.appSecret) finalConfig.appSecret = savedConfig.appSecret; } const token = await this.getTokenForTest(type, finalConfig); return !!token; } catch (e) { this.logger.error(`连接测试失败: ${(e as Error).message}`); return false; } } /** 读同步状态:某类型是否已同步过 */ async getSyncStatus(type: string): Promise { const config = await this.configRepo.findOne({ where: { type: 'THIRD' } }); if (!config || !config.isSync) return false; return config.syncResource === type.toUpperCase(); } /** 写同步状态 */ async setSyncStatus(syncing: boolean, type?: string): Promise { const config = await this.ensureConfig(); config.isSync = syncing; if (type) config.syncResource = type.toUpperCase(); await this.configRepo.save(config); } /** * 供同步逻辑使用:读原始(未脱敏)配置。 * 返回 { agentId, appSecret, corpId, appId? } 或 null。 */ async getRawConfig(type: string): Promise | null> { const config = await this.ensureConfig(); const detailType = this.getDetailType(type); const detail = await this.detailRepo.findOne({ where: { configId: config.id, type: detailType }, }); if (!detail || !detail.content) return null; try { const parsed = JSON.parse(detail.content); return parsed.config || parsed; } catch { return null; } } /** * 供同步逻辑使用:拿一个可用 access_token(未脱敏配置直接用)。 * 目前仅实现钉钉。 */ async getAccessToken(type: string): Promise { const config = await this.getRawConfig(type); if (!config) throw new NotFoundException(`未配置 ${type} 平台信息`); const token = await this.getTokenForTest(type, config); if (!token) throw new BadRequestException(`获取 ${type} access_token 失败`); return token; } // ── 私有工具 ── /** 用给定配置获取 token(钉钉真实调用,企微暂返回 null) */ private async getTokenForTest( type: string, config: Record, ): Promise { try { if (type.toUpperCase() === 'DINGTALK') { const appKey = String(config.agentId || ''); const appSecret = String(config.appSecret || ''); if (!appKey || !appSecret) return null; return await this.fetchDingTalkToken(appKey, appSecret); } // 企微暂不实现,返回 null return null; } catch { return null; } } /** 调钉钉新版接口拿 access_token */ private async fetchDingTalkToken(appKey: string, appSecret: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appKey, appSecret }), signal: controller.signal, }); if (!res.ok) return null; const body = (await res.json()) as { accessToken?: string; expireIn?: number }; return body.accessToken || null; } finally { clearTimeout(timeout); } } /** 解析并脱敏:删掉 appSecret 后返回 config 对象 */ private parseAndMaskConfig(content: string | null): unknown { if (!content) return {}; try { const parsed = JSON.parse(content); const source = parsed.config || parsed; if (!source || typeof source !== 'object' || Array.isArray(source)) return {}; const { appSecret: _appSecret, ...masked } = source as Record; return masked; } catch { return {}; } } }