Files
gongxue-base/apps/server/src/integration/config/integration-config.service.ts
wangziqi 1e3d6ee20f refactor(server): 收敛类型边界工具,aislop AI Slop 10 → 1
- 新增 common/buffer.ts bufferToArrayBuffer:9 处
  'as unknown as ArrayBuffer' 收敛为精确切片(含 byteOffset),
  消除潜在 Buffer 池偏移隐患,类型断言集中到单一实现
- 新增 common/stringify.ts:4 处重复的 stringify 助手收敛为共享实现
- aislop: AI Slop 10→1(仅剩 1 处有理由的 stringify 薄包装,
  eslint no-base-to-string 绕过所需);Code Quality 剩余
  4 重复块(声明式 SQL 配置)+ 2 文件过大(既有规模)均保留
- 测试 142 套件/1065 用例通过
2026-08-08 09:48:03 +08:00

252 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { stringify } from '../../common/stringify';
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DINGTALK_OAUTH_TOKEN_URL } from '../endpoints';
import { IntegrationConfig, IntegrationConfigDetail } from '../entities/integration-config.entity';
import {
ThirdConfigBaseDTO,
DingTalkThirdConfigDto,
WeComThirdConfigDto,
IntegrationType,
SaveIntegrationConfigDto,
} from './dto/config.dto';
/** 第三方配置在 content JSON 中的存储结构。 */
interface StoredConfigShape {
config?: unknown;
appSecret?: unknown;
}
@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>,
) {}
/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */
/** 解析 content JSON 并取 config 段(无 config 时回退整个对象)。 */
private parseStoredConfig(content: string): Record<string, unknown> {
const parsed = JSON.parse(content) as StoredConfigShape;
const rawCfg = parsed.config || parsed;
return rawCfg && typeof rawCfg === 'object' ? (rawCfg as Record<string, unknown>) : {};
}
/** 获取或创建主配置(全局单例) */
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 oldCfg = this.parseStoredConfig(existingDetail.content);
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, unknown> | 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 {
return this.parseStoredConfig(detail.content);
} 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 = stringify(config.agentId || '');
const appSecret = stringify(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(DINGTALK_OAUTH_TOKEN_URL, {
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 source = this.parseStoredConfig(content);
if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
const { appSecret: _appSecret, ...masked } = source;
return masked;
} catch {
return {};
}
}
}