feat: auto-create Student for DingTalk users on sync; add DingTalk env to ecosystem config

This commit is contained in:
2026-07-08 14:34:49 +08:00
parent edaf3670ee
commit 166d0b2694
2 changed files with 340 additions and 74 deletions

View File

@@ -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));
}
}

View File

@@ -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,
},
],
};