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

912 lines
31 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 + 缓存)
* - BFS 遍历所有部门 + 用户(带限流)
* - 用户同步(自动建 User + Student + UserDingMapping
*/
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { Department } from '../entities/department.entity';
import { User } from '../entities/user.entity';
import { Student } from '../entities/student.entity';
import { Class } from '../entities/class.entity';
import { UserDingMapping } from '../entities/user-ding-mapping.entity';
// ── Types ──
interface DingTalkTokenResponse {
accessToken: string;
expireIn: number;
}
interface SubDeptIdListResponse {
errcode: number;
errmsg: string;
result: { dept_id_list: number[] };
}
interface DepartmentDetailResponse {
errcode: number;
errmsg: string;
result: { dept_id: number; name: string; parent_id: number };
}
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;
}
/** 钉钉部门树节点,供前端选择器使用 */
export interface DingOrgTreeNode {
id: number;
name: string;
parentId: number;
children: DingOrgTreeNode[];
}
// ── 考勤排班 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;
}
/** 考勤组摘要(查询返回) */
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 tokenExpiresAt = 0;
private apiRequestCount = 0;
/** 钉钉 API 限流:每秒最多 20 次 */
private static readonly RATE_LIMIT = 20;
private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT;
constructor(
@InjectRepository(Department)
private readonly deptRepo: Repository<Department>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(Student)
private readonly studentRepo: Repository<Student>,
@InjectRepository(UserDingMapping)
private readonly mappingRepo: Repository<UserDingMapping>,
@InjectRepository(Class)
private readonly classRepo: Repository<Class>,
) {}
private get configured(): boolean {
return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET);
}
// ═══════════════════════════════════════════
// Token — 对齐 gongxue-dorm-sys getAccessToken
// ═══════════════════════════════════════════
private async getAccessToken(): Promise<string> {
if (this.accessToken && 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 }),
});
const body: DingTalkTokenResponse = await res.json();
if (!body.accessToken) {
throw new Error(`钉钉 access_token 获取失败: ${JSON.stringify(body)}`);
}
this.accessToken = body.accessToken;
this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000;
this.logger.log('钉钉 access_token 获取成功');
return this.accessToken;
}
// ═══════════════════════════════════════════
// Department BFS — 对齐 gongxue-dorm-sys getAllSubDepartmentIds
// ═══════════════════════════════════════════
private async getAllDeptIds(token: string, rootDeptId = 1): Promise<number[]> {
const ids: number[] = [];
const queue: number[] = [rootDeptId];
while (queue.length > 0) {
const deptId = queue.shift()!;
ids.push(deptId);
try {
await this.rateLimit();
const res = await fetch(
`https://oapi.dingtalk.com/topapi/v2/department/listsubid?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dept_id: deptId }),
},
);
const body: SubDeptIdListResponse = await res.json();
if (body.errcode === 0 && body.result?.dept_id_list) {
queue.push(...body.result.dept_id_list);
}
} catch (e) {
this.logger.error(`获取部门 ${deptId} 子部门失败: ${(e as Error).message}`);
}
}
return ids;
}
// ═══════════════════════════════════════════
// Department detail
// ═══════════════════════════════════════════
private async getDeptDetail(
token: string,
deptId: number,
): Promise<{ dept_id: number; name: string; parent_id: number } | null> {
try {
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, language: 'zh_CN' }),
},
);
const body: DepartmentDetailResponse = await res.json();
return body.errcode === 0 ? body.result : null;
} catch (e) {
this.logger.error(`获取部门 ${deptId} 详情失败: ${(e as Error).message}`);
return null;
}
}
// ═══════════════════════════════════════════
// 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 && body.result.next_cursor !== undefined) {
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 (!this.configured) {
this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步');
return { deptCount: 0, userCount: 0 };
}
const t0 = Date.now();
const token = await this.getAccessToken();
// ── Step 1: BFS traverse all departments ──
this.logger.log('开始 BFS 遍历钉钉部门...');
const deptIds = await this.getAllDeptIds(token, rootDeptId);
this.logger.log(`共发现 ${deptIds.length} 个部门`);
// ── Step 2: Sync departments ──
let deptCount = 0;
for (let i = 0; i < deptIds.length; i++) {
const deptId = deptIds[i];
if (i > 0) await this.delay(i);
const detail = await this.getDeptDetail(token, deptId);
if (!detail) continue;
const sourceId = String(detail.dept_id);
let dept = await this.deptRepo.findOne({ where: { source: 'dingtalk', sourceId } });
if (dept) {
dept.name = detail.name;
if (detail.parent_id) dept.parentSourceId = String(detail.parent_id);
} else {
dept = this.deptRepo.create({
name: detail.name,
source: 'dingtalk',
sourceId,
type: 'department',
} as Department);
if (detail.parent_id) dept.parentSourceId = String(detail.parent_id);
deptCount++;
}
await this.deptRepo.save(dept);
}
// Set parent relationships
const syncedDepts = await this.deptRepo.find({ where: { source: 'dingtalk' } });
const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id]));
for (const dept of syncedDepts) {
if (dept.parentSourceId && idMap.has(dept.parentSourceId)) {
dept.parentId = idMap.get(dept.parentSourceId)!;
} else if (dept.parentSourceId === '1' || dept.parentSourceId === '0') {
dept.parentId = undefined as any;
}
}
await this.deptRepo.save(syncedDepts);
// ── Step 2.5: Auto-create Class for leaf departments ──
const parentIds = new Set(syncedDepts.map((d) => d.parentSourceId));
const leafDepts = syncedDepts.filter((d) => !parentIds.has(d.sourceId));
let classCreated = 0;
for (const leaf of leafDepts) {
const code = `DT_${leaf.sourceId}`;
const exists = await this.classRepo.findOne({ where: { code } });
if (!exists) {
const cls = this.classRepo.create({
name: leaf.name,
code,
departmentId: leaf.id,
classType: 'culture',
status: 'enrolling',
});
await this.classRepo.save(cls);
classCreated++;
}
}
if (classCreated > 0) this.logger.log(`从钉钉叶子部门自动创建 ${classCreated} 个班级`);
// ── Step 3: Sync users per department ──
let userCount = 0;
const seenUserIds = new Set<string>();
for (let i = 0; i < deptIds.length; i++) {
const deptId = deptIds[i];
const dingUsers = await this.getDeptUsers(token, deptId);
for (const du of dingUsers) {
if (seenUserIds.has(du.userid)) continue;
seenUserIds.add(du.userid);
await this.syncOneUser(du);
userCount++;
}
}
this.logger.log(
`钉钉同步完成: ${deptCount} 个新部门, ${userCount} 个用户, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
);
return { deptCount, userCount };
}
/**
* 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。
* 返回从指定 rootDeptId 开始的树;默认根部门 1。
*/
async fetchOrgTree(rootDeptId = 1): Promise<DingOrgTreeNode[]> {
if (!this.configured) {
throw new ServiceUnavailableException('钉钉未配置');
}
const token = await this.getAccessToken();
const deptIds = await this.getAllDeptIds(token, rootDeptId);
// 拉每个部门详情
const nodes: DingOrgTreeNode[] = [];
for (let i = 0; i < deptIds.length; i++) {
if (i > 0) await this.delay(i);
const detail = await this.getDeptDetail(token, deptIds[i]);
if (detail) {
nodes.push({
id: detail.dept_id,
name: detail.name,
parentId: detail.parent_id,
children: [],
});
}
}
// 组装成树
const map = new Map<number, DingOrgTreeNode>();
nodes.forEach((n) => map.set(n.id, n));
const roots: DingOrgTreeNode[] = [];
for (const node of nodes) {
const parent = map.get(node.parentId);
if (parent && node.id !== rootDeptId) {
parent.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
// ═══════════════════════════════════════════
// Sync one user (with mapping)
// ═══════════════════════════════════════════
private async syncOneUser(du: {
userid: string;
name: string;
mobile: string;
}): Promise<void> {
// Look up by mapping first
let mapping = await this.mappingRepo.findOne({ where: { dingUserId: du.userid } });
let user: User | null = null;
if (mapping) {
user = await this.userRepo.findOne({ where: { id: mapping.userId } });
if (user) {
user.name = du.name;
await this.userRepo.save(user);
}
mapping.dingName = du.name;
mapping.dingMobile = du.mobile;
await this.mappingRepo.save(mapping);
return;
}
// No mapping → find or create
const username = du.mobile || `dd_${du.userid}`;
user = await this.userRepo.findOne({ where: { username } });
if (!user) {
const passwordHash = await bcrypt.hash('123456', 10);
user = this.userRepo.create({
username,
name: du.name,
passwordHash,
isActive: true,
});
await this.userRepo.save(user);
const student = this.studentRepo.create({
name: du.name,
phone: du.mobile || undefined,
userId: user.id,
status: 'active',
});
await this.studentRepo.save(student);
} else {
// Update existing user
user.name = du.name;
await this.userRepo.save(user);
// Ensure Student record exists (backfill for users synced before this logic)
const existingStudent = await this.studentRepo.findOne({ where: { userId: user.id } });
if (existingStudent && existingStudent.status !== 'active') {
// Student was manually marked as staff/graduated/withdrawn — do not overwrite
this.logger.debug(`User ${user.id} has non-active Student (${existingStudent.status}), skipping backfill`);
} else if (!existingStudent) {
const student = this.studentRepo.create({
name: du.name,
phone: du.mobile || undefined,
userId: user.id,
status: 'active',
});
await this.studentRepo.save(student);
}
}
// Create mapping
mapping = this.mappingRepo.create({
dingUserId: du.userid,
userId: user.id,
dingName: du.name,
dingMobile: du.mobile,
});
await this.mappingRepo.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[];
offset?: number;
limit?: number;
}): Promise<DingTalkAttendanceResult[]> {
if (!this.configured) throw new Error('DingTalk not configured');
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,
};
if (params.userIds?.length) body.userIds = params.userIds;
if (params.offset !== undefined) body.offset = params.offset;
if (params.limit !== undefined) body.limit = params.limit;
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;
}>;
};
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
return (data.recordresult ?? []).map((r) => ({
userId: r.userId,
userName: '',
workDate: new Date(r.workDate).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 ?? r.sourceType ?? '',
}));
}
// ═══════════════════════════════════════════
// 考勤排班 — 班次管理
// ═══════════════════════════════════════════
/** 创建或修改班次。id 不传=创建,传了=修改 */
async upsertShift(params: DingTalkShiftParams): Promise<number> {
if (!this.configured) 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;
}
/** 查询所有班次摘要 */
async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
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 }),
},
);
const data = (await res.json()) as {
errcode: number; errmsg: string;
result?: Array<{ id: number; name: string }>;
};
if (data.errcode !== 0) {
throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`);
}
return (data.result ?? []).map((s) => ({ id: s.id, name: s.name }));
}
/** 按名称查找班次,不存在则创建 */
async findOrCreateShift(name: string, startTime: string, endTime: string, opUserId = 'manager'): Promise<number> {
const existing = await this.queryShifts(opUserId);
const found = existing.find((s) => s.name === name);
if (found) return found.id;
return this.upsertShift({
name,
owner: opUserId,
sections: [{
times: [
{ check_type: 'OnDuty', across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
{ check_type: 'OffDuty', across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
],
}],
setting: { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: -1 },
});
}
// ═══════════════════════════════════════════
// 考勤排班 — 考勤组管理
// ═══════════════════════════════════════════
/** 创建排班制考勤组 */
async createAttendanceGroup(params: DingTalkGroupParams): Promise<number> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
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: params.enable_emp_select_class ?? true,
disable_check_without_schedule: 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 }));
}
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;
}
/** 查询所有考勤组摘要分页每页10条 */
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
if (!this.configured) 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 findOrCreateAttendanceGroup(
name: string, ownerUserId: string, memberUserIds: string[], shiftIds: number[],
): Promise<number> {
const existing = await this.queryAttendanceGroups(ownerUserId);
const found = existing.find((g) => g.group_name === name);
if (found) return found.group_id;
return this.createAttendanceGroup({
name,
type: 'TURN',
owner: ownerUserId,
members: memberUserIds.map((uid) => ({
role: 'Attendance',
type: 'StaffMember',
user_id: uid,
})),
shift_ids: shiftIds,
enable_emp_select_class: true,
disable_check_without_schedule: false,
disable_check_when_rest: true,
});
}
// ═══════════════════════════════════════════
// 考勤排班 — 排班分配
// ═══════════════════════════════════════════
/** 批量排班单次最多200条 */
async scheduleUsers(
groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager',
): Promise<void> {
if (!this.configured) 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 (!this.configured) 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));
}
}