diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 3e01989..a08cf4e 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -10,6 +10,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; +import { IntegrationConfigService } from './config/integration-config.service'; // ── Types ── @@ -18,6 +19,11 @@ interface DingTalkTokenResponse { expireIn: number; } +interface DingTalkCredentials { + appKey: string; + appSecret: string; +} + interface DingTalkUserListResponse { errcode: number; errmsg: string; @@ -168,6 +174,7 @@ export interface DingTalkScheduleResult { export class DingTalkService { private readonly logger = new Logger(DingTalkService.name); private accessToken: string | null = null; + private accessTokenCredentialKey: string | null = null; private tokenExpiresAt = 0; private apiRequestCount = 0; @@ -180,10 +187,28 @@ export class DingTalkService { private readonly studentRepo: Repository, @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, + private readonly integrationConfigService?: IntegrationConfigService, ) {} - private get configured(): boolean { - return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET); + private async getCredentials(): Promise { + const rawConfig = await this.integrationConfigService?.getRawConfig('DINGTALK'); + const dbAppKey = typeof rawConfig?.agentId === 'string' ? rawConfig.agentId.trim() : ''; + const dbAppSecret = typeof rawConfig?.appSecret === 'string' ? rawConfig.appSecret.trim() : ''; + if (dbAppKey && dbAppSecret) { + return { appKey: dbAppKey, appSecret: dbAppSecret }; + } + + const envAppKey = process.env.DINGTALK_APP_KEY?.trim(); + const envAppSecret = process.env.DINGTALK_APP_SECRET?.trim(); + if (envAppKey && envAppSecret) { + return { appKey: envAppKey, appSecret: envAppSecret }; + } + + return null; + } + + private async isConfigured(): Promise { + return !!(await this.getCredentials()); } // ═══════════════════════════════════════════ @@ -191,16 +216,24 @@ export class DingTalkService { // ═══════════════════════════════════════════ private async getAccessToken(): Promise { - if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { + const credentials = await this.getCredentials(); + if (!credentials) { + throw new Error('DingTalk not configured'); + } + + const credentialKey = `${credentials.appKey}:${credentials.appSecret}`; + if ( + this.accessToken && + this.accessTokenCredentialKey === credentialKey && + Date.now() < this.tokenExpiresAt - 60_000 + ) { return this.accessToken; } - const appKey = process.env.DINGTALK_APP_KEY!; - const appSecret = process.env.DINGTALK_APP_SECRET!; const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ appKey, appSecret }), + body: JSON.stringify(credentials), }); const body: DingTalkTokenResponse = await res.json(); @@ -209,6 +242,7 @@ export class DingTalkService { } this.accessToken = body.accessToken; + this.accessTokenCredentialKey = credentialKey; this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000; this.logger.log('钉钉 access_token 获取成功'); return this.accessToken; @@ -259,7 +293,7 @@ export class DingTalkService { // ═══════════════════════════════════════════ async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> { - if (!this.configured) { + if (!(await this.isConfigured())) { this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步'); return { deptCount: 0, userCount: 0 }; } @@ -333,7 +367,7 @@ export class DingTalkService { /** 获取钉钉组织部门树(只含部门) */ async fetchOrgTree(rootDeptId = 1): Promise { - if (!this.configured) return []; + if (!(await this.isConfigured())) return []; const token = await this.getAccessToken(); const rootInfo = await this.getDeptInfo(token, rootDeptId); if (!rootInfo) return []; @@ -343,7 +377,7 @@ export class DingTalkService { /** 获取钉钉组织部门树(含用户) */ async fetchOrgTreeWithUsers(rootDeptId = 1): Promise { - if (!this.configured) return []; + if (!(await this.isConfigured())) return []; const token = await this.getAccessToken(); const rootInfo = await this.getDeptInfo(token, rootDeptId); if (!rootInfo) return []; @@ -441,7 +475,7 @@ export class DingTalkService { endDate: string; userIds?: string[]; }): Promise { - if (!this.configured) throw new Error('DingTalk not configured'); + if (!(await this.isConfigured())) throw new Error('DingTalk not configured'); if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空'); if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人'); const token = await this.getAccessToken(); @@ -496,7 +530,7 @@ export class DingTalkService { /** 创建或修改班次。id 不传=创建,传了=修改 */ async upsertShift(params: DingTalkShiftParams): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const body: Record = { @@ -547,7 +581,7 @@ export class DingTalkService { /** 查询所有班次摘要(每页最多200条) */ async queryShifts(opUserId = 'manager'): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const all: DingTalkShiftSummary[] = []; @@ -598,7 +632,7 @@ export class DingTalkService { /** 创建排班制考勤组 */ async createAttendanceGroup(params: DingTalkGroupParams): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const topGroup = this.buildAttendanceGroupBody(params); @@ -627,7 +661,7 @@ export class DingTalkService { /** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */ async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id }; @@ -687,7 +721,7 @@ export class DingTalkService { /** 查询所有考勤组摘要(分页,每页10条) */ async queryAttendanceGroups(opUserId = 'manager'): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const all: DingTalkGroupSummary[] = []; @@ -729,7 +763,7 @@ export class DingTalkService { } async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); await this.rateLimit(); @@ -778,7 +812,7 @@ export class DingTalkService { async scheduleUsers( groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager', ): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); if (schedules.length === 0) return; if (schedules.length > 200) { throw new Error(`排班单次最多200条,当前 ${schedules.length} 条`); @@ -818,7 +852,7 @@ export class DingTalkService { async queryScheduleByUsers( userIds: string[], fromDate: number, toDate: number, opUserId = 'manager', ): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); await this.rateLimit(); diff --git a/apps/server/src/integration/integration.module.ts b/apps/server/src/integration/integration.module.ts index 6c98c9e..e003536 100644 --- a/apps/server/src/integration/integration.module.ts +++ b/apps/server/src/integration/integration.module.ts @@ -3,9 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User, Student, StudentDingMapping, Class } from '../entities'; import { DingTalkService } from './dingtalk.service'; import { WeComService } from './wecom.service'; +import { IntegrationConfigModule } from './config/config.module'; @Module({ - imports: [TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class])], + imports: [ + TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class]), + IntegrationConfigModule, + ], providers: [DingTalkService, WeComService], exports: [DingTalkService, WeComService], })