Files
gongxue-base/apps/server/src/integration/dingtalk.service.ts

919 lines
32 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.

/**
* 钉钉集成服务 — 对齐 gongxue-dorm-sys
*
* 提供:
* - OAuth2 access_token新版 API + 缓存)
* - 用户同步(自动建 Student + StudentDingMapping
*/
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
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 ──
interface DingTalkTokenResponse {
accessToken: string;
expireIn: number;
}
interface DingTalkCredentials {
appKey: string;
appSecret: string;
}
interface DingTalkUserListResponse {
errcode: number;
errmsg: string;
result: {
has_more: boolean;
next_cursor?: number;
list: Array<{
userid: string;
name: string;
mobile: string;
dept_id_list: number[];
}>;
};
}
/** 钉钉打卡结果 — 对齐 dws attendance check result */
export interface DingTalkAttendanceResult {
userId: string;
userName: string;
workDate: string;
timeResult: string;
locationResult: string;
planCheckTime: string;
actualCheckTime: string;
checkId: string;
checkType: string;
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
sourceType: string;
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
deviceName?: string;
deviceId?: string;
}
// ── 组织架构 API 类型 ──
interface DingTalkDeptListResponse {
errcode: number;
result?: Array<{ dept_id: number; name: string; parent_id: number }>;
}
interface DingTalkDeptGetResponse {
errcode: number;
result?: { name: string; parent_id: number };
}
export interface OrgDeptNode {
id: number;
name: string;
parentId: number;
children: OrgDeptNode[];
}
export interface OrgDeptNodeWithUsers extends OrgDeptNode {
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
}
// ── 考勤排班 API 类型 ──
/** 班次卡段打卡时间 */
export interface DingTalkShiftTime {
check_type: 'OnDuty' | 'OffDuty';
across: number;
check_time: string;
begin_min?: number;
end_min?: number;
free_check?: boolean;
}
/** 班次卡段 */
export interface DingTalkShiftSection {
times: DingTalkShiftTime[];
}
/** 班次配置 */
export interface DingTalkShiftSetting {
is_flexible?: boolean;
serious_late_minutes?: number;
absenteeism_late_minutes?: number;
}
/** 创建/修改班次参数 */
export interface DingTalkShiftParams {
id?: number;
name: string;
owner?: string;
sections: DingTalkShiftSection[];
setting?: DingTalkShiftSetting;
}
/** 班次摘要(查询返回) */
export interface DingTalkShiftSummary {
id: number;
name: string;
}
/** 考勤组成员 */
export interface DingTalkGroupMember {
role: string;
type: 'StaffMember' | 'DeptMember';
user_id: string;
}
/** 创建考勤组参数 */
export interface DingTalkGroupParams {
name: string;
type: 'TURN';
owner: string;
members: DingTalkGroupMember[];
shift_ids?: number[];
enable_emp_select_class?: boolean;
disable_check_without_schedule?: boolean;
disable_check_when_rest?: boolean;
/** 关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,仅保留考勤机打卡入口 */
attendance_machine_only?: boolean;
}
/** 修改考勤组参数 */
export interface DingTalkGroupUpdateParams extends DingTalkGroupParams {
id: number;
}
/** 考勤组摘要(查询返回) */
export interface DingTalkGroupSummary {
group_id: number;
group_name: string;
type: string;
member_count: number;
}
/** 排班参数(单条) */
export interface DingTalkScheduleItem {
userid: string;
work_date: number;
shift_id: number;
is_rest?: boolean;
}
/** 排班查询结果 */
export interface DingTalkScheduleResult {
userid: string;
work_date: string;
shift_id: number;
is_rest: string;
check_type: string;
plan_check_time: string;
group_id: number;
id: number;
}
@Injectable()
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;
/** 钉钉 API 限流:每秒最多 20 次 */
private static readonly RATE_LIMIT = 20;
private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT;
constructor(
@InjectRepository(Student)
private readonly studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping)
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
private readonly integrationConfigService?: IntegrationConfigService,
) {}
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());
}
// ═══════════════════════════════════════════
// Token — 对齐 gongxue-dorm-sys getAccessToken
// ═══════════════════════════════════════════
private async getAccessToken(): Promise<string> {
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 res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
const body: DingTalkTokenResponse = await res.json();
if (!body.accessToken) {
throw new Error(`钉钉 access_token 获取失败: ${JSON.stringify(body)}`);
}
this.accessToken = body.accessToken;
this.accessTokenCredentialKey = credentialKey;
this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000;
this.logger.log('钉钉 access_token 获取成功');
return this.accessToken;
}
// ═══════════════════════════════════════════
// Users by department — 对齐 gongxue-dorm-sys getUsersByDepartment
// ═══════════════════════════════════════════
private async getDeptUsers(
token: string,
deptId: number,
): Promise<Array<{ userid: string; name: string; mobile: string; dept_id_list: number[] }>> {
const all: Array<{ userid: string; name: string; mobile: string; dept_id_list: number[] }> = [];
let cursor = 0;
let hasMore = true;
while (hasMore) {
try {
const res = await fetch(
`https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dept_id: deptId, cursor, size: 100 }),
},
);
const body: DingTalkUserListResponse = await res.json();
if (body.errcode === 0 && body.result) {
all.push(...body.result.list);
hasMore = body.result.has_more;
if (hasMore) {
if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) {
this.logger.error(`获取部门 ${deptId} 用户失败: 分页游标未前进`);
hasMore = false;
} else {
cursor = body.result.next_cursor;
}
}
} else {
hasMore = false;
}
} catch (e) {
this.logger.error(`获取部门 ${deptId} 用户失败: ${(e as Error).message}`);
hasMore = false;
}
}
return all;
}
// ═══════════════════════════════════════════
// Sync all — 主入口
// ═══════════════════════════════════════════
async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> {
if (!(await this.isConfigured())) {
this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步');
return { deptCount: 0, userCount: 0 };
}
const t0 = Date.now();
const token = await this.getAccessToken();
// 递归收集所有部门 ID
const collectDeptIds = async (deptId: number): Promise<number[]> => {
const ids: number[] = [deptId];
const subs = await this.getSubDepts(token, deptId);
for (const sd of subs) {
ids.push(...(await collectDeptIds(sd.dept_id)));
}
return ids;
};
const allDeptIds = await collectDeptIds(rootDeptId);
let userCount = 0;
const seenUserIds = new Set<string>();
for (const did of allDeptIds) {
const dingUsers = await this.getDeptUsers(token, did);
for (const du of dingUsers) {
if (seenUserIds.has(du.userid)) continue;
seenUserIds.add(du.userid);
await this.syncOneUser(du);
userCount++;
}
}
this.logger.log(
`钉钉同步完成: ${userCount} 个用户, ${allDeptIds.length} 个部门, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
);
return { deptCount: allDeptIds.length, userCount };
}
// ═══════════════════════════════════════════
/** 递归获取子部门列表(含名称) */
private async getSubDepts(token: string, deptId: number): Promise<Array<{ dept_id: number; name: string; parent_id: number }>> {
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId }) },
);
const body = await res.json() as DingTalkDeptListResponse;
return body.errcode === 0 ? (body.result ?? []) : [];
}
/** 获取单个部门详情 */
private async getDeptInfo(token: string, deptId: number): Promise<{ name: string; parent_id: number } | null> {
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${token}`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId }) },
);
const body = await res.json() as DingTalkDeptGetResponse;
return body.errcode === 0 && body.result ? body.result : null;
}
/** 递归构建部门树节点 */
private async buildDeptNode(token: string, deptId: number, name: string, parentId: number): Promise<OrgDeptNode> {
const subDepts = await this.getSubDepts(token, deptId);
const children = await Promise.all(
subDepts.map(sd => this.buildDeptNode(token, sd.dept_id, sd.name, sd.parent_id)),
);
return { id: deptId, name, parentId, children };
}
/** 获取钉钉组织部门树(只含部门) */
async fetchOrgTree(rootDeptId = 1): Promise<OrgDeptNode[]> {
if (!(await this.isConfigured())) return [];
const token = await this.getAccessToken();
const rootInfo = await this.getDeptInfo(token, rootDeptId);
if (!rootInfo) return [];
const node = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id);
return [node];
}
/** 获取钉钉组织部门树(含用户) */
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<OrgDeptNodeWithUsers[]> {
if (!(await this.isConfigured())) return [];
const token = await this.getAccessToken();
const rootInfo = await this.getDeptInfo(token, rootDeptId);
if (!rootInfo) return [];
// 1. 先建部门树(复用 buildDeptNode
const deptTree = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id);
// 2. 收集所有部门 ID
const allDeptIds: number[] = [];
const collectIds = (node: OrgDeptNode) => {
allDeptIds.push(node.id);
for (const c of node.children) collectIds(c);
};
collectIds(deptTree);
// 3. 从每个部门拉用户每人只挂到一个部门dept_id_list 最后一个)
const allDeptIdsSet = new Set(allDeptIds);
const usersByDept = new Map<number, Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>>();
const placedUsers = new Set<string>();
for (const did of allDeptIds) {
const deptUsers = await this.getDeptUsers(token, did);
for (const u of deptUsers) {
if (placedUsers.has(u.userid)) continue;
const targetDept = u.dept_id_list[u.dept_id_list.length - 1];
if (allDeptIdsSet.has(targetDept)) {
placedUsers.add(u.userid);
if (!usersByDept.has(targetDept)) usersByDept.set(targetDept, []);
usersByDept.get(targetDept)!.push({ userid: u.userid, name: u.name, mobile: u.mobile, deptIds: u.dept_id_list });
}
}
}
// 4. 递归挂用户到树节点
const attachUsers = (node: OrgDeptNode): OrgDeptNodeWithUsers => ({
id: node.id,
name: node.name,
parentId: node.parentId,
children: node.children.map(attachUsers),
users: usersByDept.get(node.id) ?? [],
});
return [attachUsers(deptTree)];
}
// ═══════════════════════════════════════════
// Sync one user (with mapping)
// ═══════════════════════════════════════════
private async syncOneUser(du: {
userid: string; name: string; mobile: string;
}): Promise<void> {
let mapping = await this.studentDingMappingRepo.findOne({
where: { dingUserId: du.userid },
});
if (mapping) {
const student = await this.studentRepo.findOne({
where: { id: mapping.studentId },
});
if (student) {
student.name = du.name;
if (du.mobile) student.phone = du.mobile;
await this.studentRepo.save(student);
}
return;
}
const student = this.studentRepo.create({
name: du.name,
phone: du.mobile || undefined,
status: 'active',
});
await this.studentRepo.save(student);
mapping = this.studentDingMappingRepo.create({
dingUserId: du.userid,
studentId: student.id,
});
await this.studentDingMappingRepo.save(mapping);
}
// ═══════════════════════════════════════════
// Rate limiting — 对齐 gongxue-dorm-sys
// ═══════════════════════════════════════════
private async rateLimit(): Promise<void> {
await this.sleep(DingTalkService.MIN_INTERVAL);
this.apiRequestCount++;
}
// ═══════════════════════════════════════════
// 考勤打卡结果 — 对齐 dws attendance check result
// ═══════════════════════════════════════════
async fetchAttendanceResults(params: {
startDate: string;
endDate: string;
userIds?: string[];
}): Promise<DingTalkAttendanceResult[]> {
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();
const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`;
const dateTo = params.endDate.includes(' ') ? params.endDate : `${params.endDate} 23:59:59`;
const body: Record<string, unknown> = {
checkDateFrom: dateFrom,
checkDateTo: dateTo,
};
body.userIds = params.userIds;
const res = await fetch(
`https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = await res.json() as {
errcode: number; errmsg: string;
recordresult?: Array<{
id: number; userId: string; workDate: number;
userCheckTime: number; sourceType: string;
checkType?: string; timeResult?: string;
locationResult?: string; locationMethod?: string;
userAddress?: string; userLongitude?: number; userLatitude?: number;
deviceName?: string; deviceId?: string | number;
attendanceMachineName?: string; attendanceMachineId?: string | number;
}>;
};
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
const records = data.recordresult ?? [];
return records.map((r) => ({
userId: r.userId,
userName: '',
workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10),
timeResult: r.timeResult ?? r.sourceType ?? '',
locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '',
planCheckTime: '',
actualCheckTime: new Date(r.userCheckTime).toISOString(),
checkId: String(r.id),
checkType: r.checkType ?? '',
sourceType: r.sourceType ?? '',
deviceName: r.deviceName ?? r.attendanceMachineName,
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? '') || undefined,
}));
}
// ═══════════════════════════════════════════
// 考勤排班 — 班次管理
// ═══════════════════════════════════════════
/** 创建或修改班次。id 不传=创建,传了=修改 */
async upsertShift(params: DingTalkShiftParams): Promise<number> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const body: Record<string, unknown> = {
op_user_id: params.owner || 'manager',
shift: {
name: params.name,
owner: params.owner,
sections: params.sections.map((s) => ({
times: s.times.map((t) => ({
check_type: t.check_type,
across: t.across,
check_time: t.check_time,
begin_min: t.begin_min ?? -1,
end_min: t.end_min ?? -1,
free_check: t.free_check ?? false,
})),
})),
setting: params.setting
? {
is_flexible: params.setting.is_flexible ?? false,
serious_late_minutes: params.setting.serious_late_minutes ?? -1,
absenteeism_late_minutes: params.setting.absenteeism_late_minutes ?? -1,
}
: undefined,
},
};
if (params.id) (body.shift as Record<string, unknown>).id = params.id;
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/shift/add?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: { id: number; name: string };
};
if (data.errcode !== 0) {
throw new Error(`钉钉班次操作失败: ${data.errmsg} (code=${data.errcode})`);
}
this.logger.log(`钉钉班次 ${params.id ? '更新' : '创建'} 成功: ${data.result?.name} (id=${data.result?.id})`);
return data.result!.id;
}
/** 查询所有班次摘要每页最多200条 */
async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const all: DingTalkShiftSummary[] = [];
let cursor = 0;
let hasMore = true;
while (hasMore) {
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: opUserId, cursor }),
},
);
const data = (await res.json()) as {
errcode: number;
errmsg: string;
result?: {
cursor?: number;
has_more?: boolean;
result?: Array<{ id: number; name: string }>;
};
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`);
}
const page = data.result;
all.push(...(page?.result ?? []).map((s) => ({ id: s.id, name: s.name })));
hasMore = page?.has_more ?? false;
if (hasMore) {
if (page?.cursor === undefined || page.cursor === cursor) {
throw new Error('钉钉查询班次失败: 分页游标无效');
}
cursor = page.cursor;
}
}
return all;
}
// ═══════════════════════════════════════════
// 考勤排班 — 考勤组管理
// ═══════════════════════════════════════════
/** 创建排班制考勤组 */
async createAttendanceGroup(params: DingTalkGroupParams): Promise<number> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const topGroup = this.buildAttendanceGroupBody(params);
const body = { op_user_id: params.owner, top_group: topGroup };
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/add?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: { id: number };
};
if (data.errcode !== 0) {
throw new Error(`钉钉创建考勤组失败: ${data.errmsg} (code=${data.errcode})`);
}
this.logger.log(`钉钉考勤组创建成功: ${params.name} (id=${data.result?.id})`);
return data.result!.id;
}
/** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */
async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id };
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: params.owner, top_group: topGroup }),
},
);
const data = (await res.json()) as {
errcode?: number;
errmsg?: string;
success?: boolean;
message?: string;
};
const succeeded = data.success === true || data.errcode === 0;
if (!succeeded) {
throw new Error(
`钉钉更新考勤组失败: ${data.message || data.errmsg || '未知错误'} ` +
`(code=${data.errcode ?? 'unknown'})`,
);
}
this.logger.log(`钉钉考勤组更新成功: ${params.name} (id=${params.id})`);
}
private buildAttendanceGroupBody(params: DingTalkGroupParams): Record<string, unknown> {
const machineOnly = params.attendance_machine_only ?? false;
const topGroup: Record<string, unknown> = {
name: params.name,
type: params.type,
owner: params.owner,
members: params.members.map((m) => ({
role: m.role,
type: m.type,
user_id: m.user_id,
})),
enable_emp_select_class: machineOnly ? false : (params.enable_emp_select_class ?? true),
disable_check_without_schedule: machineOnly ? true : (params.disable_check_without_schedule ?? false),
disable_check_when_rest: params.disable_check_when_rest ?? true,
};
if (params.shift_ids?.length) {
topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id }));
}
if (machineOnly) {
Object.assign(topGroup, {
enable_outside_check: false,
enable_position_ble: false,
positions: [],
wifis: [],
});
}
return topGroup;
}
/** 查询所有考勤组摘要分页每页10条 */
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
const all: DingTalkGroupSummary[] = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/getsimplegroups?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ offset, size: 10 }),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: {
has_more: boolean;
groups: Array<{ group_id: number; group_name: string; type: string; member_count: number }>;
};
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询考勤组失败: ${data.errmsg} (code=${data.errcode})`);
}
if (data.result?.groups) {
all.push(...data.result.groups.map((g) => ({
group_id: g.group_id,
group_name: g.group_name,
type: g.type,
member_count: g.member_count,
})));
}
hasMore = data.result?.has_more ?? false;
offset += 10;
}
return all;
}
async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
await this.rateLimit();
const keyResponse = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }),
},
);
const keyData = await keyResponse.json() as {
errcode: number;
errmsg: string;
result?: string;
};
if (keyData.errcode !== 0 || !keyData.result) {
throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`);
}
await this.rateLimit();
const deleteResponse = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }),
},
);
const deleteData = await deleteResponse.json() as {
errcode: number;
errmsg: string;
success?: boolean;
};
if (deleteData.errcode !== 0 || deleteData.success !== true) {
throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`);
}
}
// ═══════════════════════════════════════════
// 考勤排班 — 排班分配
// ═══════════════════════════════════════════
/** 批量排班单次最多200条 */
async scheduleUsers(
groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager',
): Promise<void> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
if (schedules.length === 0) return;
if (schedules.length > 200) {
throw new Error(`排班单次最多200条当前 ${schedules.length}`);
}
const token = await this.getAccessToken();
const body = {
op_user_id: opUserId,
group_id: groupId,
schedules: schedules.map((s) => ({
userid: s.userid,
work_date: s.work_date,
shift_id: s.shift_id,
is_rest: s.is_rest ?? false,
})),
};
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
};
if (data.errcode !== 0) {
throw new Error(`钉钉排班失败: ${data.errmsg} (code=${data.errcode})`);
}
this.logger.log(`钉钉排班成功: groupId=${groupId}, ${schedules.length}`);
}
/** 查询指定用户的排班信息7天内最多50人 */
async queryScheduleByUsers(
userIds: string[], fromDate: number, toDate: number, opUserId = 'manager',
): Promise<DingTalkScheduleResult[]> {
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/schedule/listbyusers?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
op_user_id: opUserId,
userids: userIds.join(','),
from_date_time: fromDate,
to_date_time: toDate,
}),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: Array<{
userid: string; work_date: string; shift_id: number;
is_rest: string; check_type: string; plan_check_time: string;
group_id: number; id: number;
}>;
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询排班失败: ${data.errmsg} (code=${data.errcode})`);
}
return (data.result ?? []).map((r) => ({
userid: r.userid,
work_date: r.work_date,
shift_id: r.shift_id,
is_rest: r.is_rest,
check_type: r.check_type,
plan_check_time: r.plan_check_time,
group_id: r.group_id,
id: r.id,
}));
}
private delay(requestIndex: number): Promise<void> {
const ms = requestIndex % 10 === 0 ? 200 : DingTalkService.MIN_INTERVAL;
return this.sleep(ms);
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}