239 lines
8.0 KiB
TypeScript
239 lines
8.0 KiB
TypeScript
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<IntegrationConfig>,
|
||
@InjectRepository(IntegrationConfigDetail)
|
||
private readonly detailRepo: Repository<IntegrationConfigDetail>,
|
||
) {}
|
||
|
||
/** 获取或创建主配置(全局单例) */
|
||
private async ensureConfig(): Promise<IntegrationConfig> {
|
||
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<ThirdConfigBaseDTO[]> {
|
||
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<ThirdConfigBaseDTO | null> {
|
||
const all = await this.getThirdConfig();
|
||
return all.find((c) => c.type === type.toUpperCase()) || null;
|
||
}
|
||
|
||
/** 保存/更新配置 */
|
||
async saveConfig(request: SaveIntegrationConfigDto): Promise<void> {
|
||
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<string, unknown>;
|
||
|
||
// 更新时若前端未传 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<boolean> {
|
||
try {
|
||
const finalConfig = { ...config } as Record<string, unknown>;
|
||
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<boolean> {
|
||
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<void> {
|
||
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<Record<string, any> | 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<string> {
|
||
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<string, unknown>,
|
||
): Promise<string | null> {
|
||
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<string | null> {
|
||
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<string, unknown>;
|
||
return masked;
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
}
|