434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
/**
|
||
* 钉钉集成服务 — 对齐 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));
|
||
}
|
||
}
|