@@ -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 < Student > ,
@InjectRepository ( StudentDingMapping )
private readonly studentDingMappingRepo : Repository < StudentDingMapping > ,
private readonly integrationConfigService? : IntegrationConfigService ,
) { }
private get configured ( ) : boolean {
return ! ! ( process . env . DINGTALK_APP_KEY && process . env . DINGTALK_APP_SECRET ) ;
private async getCredentials ( ) : Promise < DingTalkCredentials | null > {
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 < boolean > {
return ! ! ( await this . getCredentials ( ) ) ;
}
// ═══════════════════════════════════════════
@@ -191,16 +216,24 @@ export class DingTalkService {
// ═══════════════════════════════════════════
private async getAccessToken ( ) : Promise < string > {
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 . c onfigured) {
if ( ! ( await this . isC onfigured( ) ) ) {
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 < OrgDeptNode [ ] > {
if ( ! this . c onfigured) return [ ] ;
if ( ! ( await this . isC onfigured( ) ) ) 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 < OrgDeptNodeWithUsers [ ] > {
if ( ! this . c onfigured) return [ ] ;
if ( ! ( await this . isC onfigured( ) ) ) 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 < DingTalkAttendanceResult [ ] > {
if ( ! this . c onfigured) throw new Error ( 'DingTalk not configured' ) ;
if ( ! ( await this . isC onfigured( ) ) ) 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 < number > {
if ( ! this . c onfigured) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
if ( ! ( await this . isC onfigured( ) ) ) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
const token = await this . getAccessToken ( ) ;
const body : Record < string , unknown > = {
@@ -547,7 +581,7 @@ export class DingTalkService {
/** 查询所有班次摘要( 每页最多200条) */
async queryShifts ( opUserId = 'manager' ) : Promise < DingTalkShiftSummary [ ] > {
if ( ! this . c onfigured) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
if ( ! ( await this . isC onfigured( ) ) ) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
const token = await this . getAccessToken ( ) ;
const all : DingTalkShiftSummary [ ] = [ ] ;
@@ -598,7 +632,7 @@ export class DingTalkService {
/** 创建排班制考勤组 */
async createAttendanceGroup ( params : DingTalkGroupParams ) : Promise < number > {
if ( ! this . c onfigured) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
if ( ! ( await this . isC onfigured( ) ) ) 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 < void > {
if ( ! this . c onfigured) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
if ( ! ( await this . isC onfigured( ) ) ) 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 < DingTalkGroupSummary [ ] > {
if ( ! this . c onfigured) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
if ( ! ( await this . isC onfigured( ) ) ) 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 < void > {
if ( ! this . c onfigured) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
if ( ! ( await this . isC onfigured( ) ) ) 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 < void > {
if ( ! this . c onfigured) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
if ( ! ( await this . isC onfigured( ) ) ) 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 < DingTalkScheduleResult [ ] > {
if ( ! this . c onfigured) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
if ( ! ( await this . isC onfigured( ) ) ) throw new ServiceUnavailableException ( '钉钉未配置' ) ;
const token = await this . getAccessToken ( ) ;
await this . rateLimit ( ) ;