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

434 lines
15 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 } 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 { 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;
workDate: string;
timeResult: string;
locationResult: string;
planCheckTime: string;
actualCheckTime: string;
checkId: string;
checkType: string;
}
@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>,
) {}
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): Promise<number[]> {
const ids: number[] = [];
const queue: number[] = [1];
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(): 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);
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 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 };
}
// ═══════════════════════════════════════════
// 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) {
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 columnIdList = ['1','2','3','4','5','6','8','9'];
const body: Record<string, unknown> = {
column_id_list: columnIdList.join(','),
from_date: params.startDate,
to_date: params.endDate,
offset: params.offset ?? 0,
limit: params.limit ?? 50,
};
if (params.userIds?.length) body.userid_list = params.userIds.join(',');
const res = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/getcolumnval?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: { hasMore: boolean; column_vals?: Array<{ column_vals: string[] }> };
};
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
return (data.result?.column_vals ?? []).map((col) => {
const v = col.column_vals ?? [];
return { userId: v[0]??'', workDate: v[1]??'', timeResult: v[2]??'', locationResult: v[3]??'', planCheckTime: v[4]??'', actualCheckTime: v[5]??'', checkId: v[7]??'', checkType: v[8]??'' };
});
}
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));
}
}