feat: auto-create Student for DingTalk users on sync; add DingTalk env to ecosystem config
This commit is contained in:
@@ -1,20 +1,37 @@
|
||||
/**
|
||||
* 钉钉集成服务 — 对齐 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 {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
accessToken: string;
|
||||
expireIn: number;
|
||||
}
|
||||
|
||||
interface DingTalkDeptListResponse {
|
||||
interface SubDeptIdListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result: Array<{ dept_id: number; name: string; parent_id: number }>;
|
||||
result: { dept_id_list: number[] };
|
||||
}
|
||||
|
||||
interface DepartmentDetailResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result: { dept_id: number; name: string; parent_id: number };
|
||||
}
|
||||
|
||||
interface DingTalkUserListResponse {
|
||||
@@ -22,6 +39,7 @@ interface DingTalkUserListResponse {
|
||||
errmsg: string;
|
||||
result: {
|
||||
has_more: boolean;
|
||||
next_cursor?: number;
|
||||
list: Array<{
|
||||
userid: string;
|
||||
name: string;
|
||||
@@ -31,106 +49,218 @@ interface DingTalkUserListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/** 钉钉打卡结果 — 对齐 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 url = `https://oapi.dingtalk.com/gettoken?appkey=${appKey}&appsecret=${appSecret}`;
|
||||
const res = await fetch(url);
|
||||
|
||||
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.errcode !== 0) {
|
||||
throw new Error(`DingTalk gettoken failed: ${body.errmsg} (${body.errcode})`);
|
||||
|
||||
if (!body.accessToken) {
|
||||
throw new Error(`钉钉 access_token 获取失败: ${JSON.stringify(body)}`);
|
||||
}
|
||||
this.accessToken = body.access_token;
|
||||
this.tokenExpiresAt = Date.now() + body.expires_in * 1000;
|
||||
|
||||
this.accessToken = body.accessToken;
|
||||
this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000;
|
||||
this.logger.log('钉钉 access_token 获取成功');
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
private async fetchDepartments(token: string): Promise<Array<{ dept_id: number; name: string; parent_id: number }>> {
|
||||
const url = `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dept_id: 1 }),
|
||||
});
|
||||
const body: DingTalkDeptListResponse = await res.json();
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`DingTalk department list failed: ${body.errmsg} (${body.errcode})`);
|
||||
// ═══════════════════════════════════════════
|
||||
// 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 body.result;
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async fetchUsers(
|
||||
// ═══════════════════════════════════════════
|
||||
// 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 allUsers: DingTalkUserListResponse['result']['list'] = [];
|
||||
const all: Array<{ userid: string; name: string; mobile: string; dept_id_list: number[] }> = [];
|
||||
let cursor = 0;
|
||||
while (true) {
|
||||
const url = `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`;
|
||||
const res = await fetch(url, {
|
||||
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) {
|
||||
throw new Error(`DingTalk user list failed: ${body.errmsg} (${body.errcode})`);
|
||||
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;
|
||||
}
|
||||
allUsers.push(...body.result.list);
|
||||
if (!body.result.has_more) break;
|
||||
cursor = allUsers.length;
|
||||
}
|
||||
return allUsers;
|
||||
return all;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Sync all — 主入口
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
async syncAll(): Promise<{ deptCount: number; userCount: number }> {
|
||||
if (!this.configured) {
|
||||
this.logger.warn('DingTalk not configured (DINGTALK_APP_KEY / DINGTALK_APP_SECRET missing), skipping sync');
|
||||
this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步');
|
||||
return { deptCount: 0, userCount: 0 };
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
const dingDepts = await this.fetchDepartments(token);
|
||||
|
||||
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 (const dd of dingDepts) {
|
||||
const sourceId = String(dd.dept_id);
|
||||
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 = dd.name;
|
||||
dept.parentSourceId = (dd.parent_id ? String(dd.parent_id) : undefined) as any;
|
||||
dept.name = detail.name;
|
||||
if (detail.parent_id) dept.parentSourceId = String(detail.parent_id);
|
||||
} else {
|
||||
dept = this.deptRepo.create({
|
||||
name: dd.name,
|
||||
name: detail.name,
|
||||
source: 'dingtalk',
|
||||
sourceId,
|
||||
parentSourceId: (dd.parent_id ? String(dd.parent_id) : undefined),
|
||||
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) {
|
||||
@@ -142,30 +272,162 @@ export class DingTalkService {
|
||||
}
|
||||
await this.deptRepo.save(syncedDepts);
|
||||
|
||||
// ── Step 3: Sync users per department ──
|
||||
let userCount = 0;
|
||||
const seenUserIds = new Set<string>();
|
||||
for (const dd of dingDepts) {
|
||||
const dingUsers = await this.fetchUsers(token, dd.dept_id);
|
||||
|
||||
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);
|
||||
let user = await this.userRepo.findOne({ where: { username: du.userid } });
|
||||
if (user) {
|
||||
user.name = du.name;
|
||||
} else {
|
||||
user = this.userRepo.create({
|
||||
username: du.userid,
|
||||
name: du.name,
|
||||
passwordHash: '',
|
||||
isActive: true,
|
||||
});
|
||||
userCount++;
|
||||
}
|
||||
await this.userRepo.save(user);
|
||||
|
||||
await this.syncOneUser(du);
|
||||
userCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`DingTalk sync done: ${deptCount} new depts, ${userCount} new users`);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,21 @@ module.exports = {
|
||||
script: 'apps/server/dist/main.js',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
// 环境变量
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: '3000',
|
||||
DB_TYPE: 'mysql',
|
||||
DB_HOST: '127.0.0.1',
|
||||
DB_PORT: '3306',
|
||||
DB_USERNAME: 'root',
|
||||
DB_PASSWORD: 'gongxue_2024',
|
||||
DB_DATABASE: 'gongxue',
|
||||
DB_SYNCHRONIZE: 'true',
|
||||
JWT_SECRET: 'gongxue-jwt-prod-2026-k3y',
|
||||
JWT_EXPIRES_IN: '24h',
|
||||
UPLOAD_DIR: './uploads',
|
||||
DINGTALK_APP_KEY: 'dingvpcg2i6p25ftxw5c',
|
||||
DINGTALK_APP_SECRET: 'ovAEql1r5Lu9FvBve6bHwBZxXAZuEQwSNpG3DkRJk4DMhQwJ_raKDQc6emdlJisU',
|
||||
},
|
||||
// 内存限制
|
||||
max_memory_restart: '512M',
|
||||
@@ -35,15 +47,7 @@ module.exports = {
|
||||
script: 'serve-proxy.js',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
FRONTEND_PORT: 9527, // 需 root 或 setcap: sudo setcap 'cap_net_bind_service=+ep' $(which node)
|
||||
API_TARGET: 'http://127.0.0.1:3000',
|
||||
},
|
||||
max_memory_restart: '256M',
|
||||
max_restarts: 5,
|
||||
error_file: 'logs/frontend-error.log',
|
||||
out_file: 'logs/frontend-out.log',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user