forked from wangziqi/gongxue-base
1051 lines
35 KiB
TypeScript
1051 lines
35 KiB
TypeScript
/**
|
||
* 钉钉集成服务 — 对齐 gongxue-dorm-sys
|
||
*
|
||
* 提供:
|
||
* - OAuth2 access_token(新版 API + 缓存)
|
||
* - BFS 遍历所有部门 + 用户(带限流)
|
||
* - 用户同步(自动建 User + Student + StudentDingMapping)
|
||
*/
|
||
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 { StudentDingMapping } from '../entities/student-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[];
|
||
}
|
||
|
||
/** 钉钉部门树节点(含用户),供同步用户选择器使用 */
|
||
export interface DingOrgTreeNodeWithUsers {
|
||
id: number;
|
||
name: string;
|
||
parentId: number;
|
||
children: DingOrgTreeNodeWithUsers[];
|
||
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;
|
||
}
|
||
|
||
/** 考勤组摘要(查询返回) */
|
||
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(StudentDingMapping)
|
||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||
@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;
|
||
}
|
||
|
||
/**
|
||
* BFS from rootDeptId=1, returns depth of every department.
|
||
* Depth is 1-based (root=1). Uses listsubid API only, no detail fetches.
|
||
*/
|
||
private async getDeptDepthMap(token: string): Promise<Map<number, number>> {
|
||
const depthMap = new Map<number, number>();
|
||
const queue: Array<{ id: number; depth: number }> = [{ id: 1, depth: 1 }];
|
||
|
||
while (queue.length > 0) {
|
||
const { id, depth } = queue.shift()!;
|
||
depthMap.set(id, depth);
|
||
|
||
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: id }),
|
||
},
|
||
);
|
||
const body: SubDeptIdListResponse = await res.json();
|
||
if (body.errcode === 0 && body.result?.dept_id_list) {
|
||
for (const childId of body.result.dept_id_list) {
|
||
queue.push({ id: childId, depth: depth + 1 });
|
||
}
|
||
}
|
||
} catch (e) {
|
||
this.logger.error(`getDeptDepthMap 获取部门 ${id} 子部门失败: ${(e as Error).message}`);
|
||
}
|
||
}
|
||
|
||
return depthMap;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 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;
|
||
}
|
||
|
||
/**
|
||
* 获取钉钉组织部门树(含用户),供前端同步用户选择器使用。
|
||
* 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。
|
||
*/
|
||
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<DingOrgTreeNodeWithUsers[]> {
|
||
if (!this.configured) {
|
||
throw new ServiceUnavailableException('钉钉未配置');
|
||
}
|
||
const token = await this.getAccessToken();
|
||
|
||
// Compute global depth map (always from rootDeptId=1)
|
||
const depthMap = await this.getDeptDepthMap(token);
|
||
const maxDepth = Math.max(...depthMap.values());
|
||
|
||
// Get subtree IDs for the user's selected root
|
||
const subtreeIds = await this.getAllDeptIds(token, rootDeptId);
|
||
|
||
// Filter: only keep departments at the deepest 2 levels of the global tree
|
||
const filteredIds = subtreeIds.filter((id) => {
|
||
const d = depthMap.get(id) ?? -1;
|
||
return d === maxDepth || d === maxDepth - 1;
|
||
});
|
||
|
||
if (filteredIds.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
// 拉每个部门详情
|
||
const nodes: DingOrgTreeNodeWithUsers[] = [];
|
||
// Before dedup: collect deptIds per user
|
||
const userDeptMap = new Map<string, number[]>();
|
||
|
||
for (let i = 0; i < filteredIds.length; i++) {
|
||
if (i > 0) await this.delay(i);
|
||
const detail = await this.getDeptDetail(token, filteredIds[i]);
|
||
if (!detail) continue;
|
||
|
||
// 拉该部门下的用户
|
||
const dingUsers = await this.getDeptUsers(token, filteredIds[i]);
|
||
this.logger.log(`[dingtalk] dept ${filteredIds[i]} (${detail.name}): ${dingUsers.length} users`);
|
||
|
||
nodes.push({
|
||
id: detail.dept_id,
|
||
name: detail.name,
|
||
parentId: detail.parent_id,
|
||
children: [],
|
||
users: dingUsers.map((u) => ({
|
||
userid: u.userid,
|
||
name: u.name,
|
||
mobile: u.mobile,
|
||
deptIds: [],
|
||
})),
|
||
});
|
||
|
||
// Record which departments each user belongs to
|
||
for (const u of dingUsers) {
|
||
if (!userDeptMap.has(u.userid)) {
|
||
userDeptMap.set(u.userid, []);
|
||
}
|
||
userDeptMap.get(u.userid)!.push(detail.dept_id);
|
||
}
|
||
}
|
||
|
||
// 全局去重:同一个 dingUserId 可能在多个部门出现
|
||
const seenUserIds = new Set<string>();
|
||
for (const node of nodes) {
|
||
node.users = node.users
|
||
.filter((u) => {
|
||
if (seenUserIds.has(u.userid)) return false;
|
||
seenUserIds.add(u.userid);
|
||
return true;
|
||
})
|
||
.map((u) => ({
|
||
...u,
|
||
deptIds: userDeptMap.get(u.userid) || [],
|
||
}));
|
||
}
|
||
|
||
// 组装成树(父节点可能已被过滤,缺失的父节点 → 节点提升为根)
|
||
const map = new Map<number, DingOrgTreeNodeWithUsers>();
|
||
nodes.forEach((n) => map.set(n.id, n));
|
||
const roots: DingOrgTreeNodeWithUsers[] = [];
|
||
for (const node of nodes) {
|
||
const parent = map.get(node.parentId);
|
||
if (parent) {
|
||
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.studentDingMappingRepo.findOne({ where: { dingUserId: du.userid } });
|
||
let user: User | null = null;
|
||
|
||
if (mapping) {
|
||
user = await this.userRepo.findOne({ where: { id: mapping.studentId } });
|
||
if (user) {
|
||
user.name = du.name;
|
||
await this.userRepo.save(user);
|
||
}
|
||
await this.studentDingMappingRepo.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.studentDingMappingRepo.create({
|
||
dingUserId: du.userid,
|
||
studentId: user.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[];
|
||
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));
|
||
}
|
||
}
|