forked from wangziqi/gongxue-base
fix: harden DingTalk student synchronization
This commit is contained in:
96
apps/server/src/integration/dingtalk-student-sync.spec.ts
Normal file
96
apps/server/src/integration/dingtalk-student-sync.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
import { Student, StudentDingMapping } from '../entities';
|
||||
import { syncDingTalkStudents } from './dingtalk-student-sync';
|
||||
|
||||
function managerFixture(options?: {
|
||||
mappings?: StudentDingMapping[];
|
||||
students?: Student[];
|
||||
occupiedPhones?: Student[];
|
||||
failMappingSave?: boolean;
|
||||
}) {
|
||||
const saves: Array<{ entity: unknown; values: unknown }> = [];
|
||||
let nextId = 100;
|
||||
const manager = {
|
||||
find: jest.fn().mockImplementation(async (entity: unknown, findOptions?: unknown) => {
|
||||
if (entity === StudentDingMapping) return options?.mappings ?? [];
|
||||
if (entity === Student) {
|
||||
const where = findOptions && typeof findOptions === 'object' && 'where' in findOptions
|
||||
? findOptions.where
|
||||
: undefined;
|
||||
if (where && typeof where === 'object' && 'phone' in where) {
|
||||
return options?.occupiedPhones ?? [];
|
||||
}
|
||||
return options?.students ?? [];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 7, isHost: true, status: 'active' }),
|
||||
create: jest.fn().mockImplementation((_entity: unknown, value: object) => ({ ...value })),
|
||||
save: jest.fn().mockImplementation(async (entity: unknown, values: unknown) => {
|
||||
saves.push({ entity, values });
|
||||
if (entity === StudentDingMapping && options?.failMappingSave) throw new Error('mapping failed');
|
||||
if (entity === Student && Array.isArray(values)) {
|
||||
return values.map((value) => ({ ...value, id: 'id' in value ? value.id : nextId++ }));
|
||||
}
|
||||
return values;
|
||||
}),
|
||||
};
|
||||
return { manager: manager as unknown as EntityManager, saves };
|
||||
}
|
||||
|
||||
describe('syncDingTalkStudents', () => {
|
||||
it('deduplicates DingTalk users and creates a host-owned student and mapping once', async () => {
|
||||
const { manager, saves } = managerFixture();
|
||||
|
||||
const result = await syncDingTalkStudents(manager, [
|
||||
{ dingUserId: 'u1', name: '张三', mobile: '13800000000' },
|
||||
{ dingUserId: 'u1', name: '重复项', mobile: '13800000000' },
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({ created: 1, updated: 0, conflicts: [] });
|
||||
expect(saves).toContainEqual({
|
||||
entity: Student,
|
||||
values: [expect.objectContaining({ name: '张三', organizationId: 7 })],
|
||||
});
|
||||
expect(saves).toContainEqual({
|
||||
entity: StudentDingMapping,
|
||||
values: [expect.objectContaining({ dingUserId: 'u1', studentId: 100 })],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a phone conflict without creating a duplicate student', async () => {
|
||||
const occupied = { id: 5, phone: '13800000000' } as Student;
|
||||
const { manager, saves } = managerFixture({ occupiedPhones: [occupied] });
|
||||
|
||||
const result = await syncDingTalkStudents(manager, [
|
||||
{ dingUserId: 'u2', name: '李四', mobile: occupied.phone },
|
||||
]);
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(result.conflicts).toEqual([
|
||||
expect.objectContaining({ dingUserId: 'u2', reason: expect.stringContaining('人工绑定') }),
|
||||
]);
|
||||
expect(saves).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces mapping persistence failure so the surrounding transaction can roll back', async () => {
|
||||
const { manager } = managerFixture({ failMappingSave: true });
|
||||
|
||||
await expect(
|
||||
syncDingTalkStudents(manager, [{ dingUserId: 'u3', name: '王五' }]),
|
||||
).rejects.toThrow('mapping failed');
|
||||
});
|
||||
|
||||
it('keeps an archived mapped student archived while refreshing profile data', async () => {
|
||||
const archived = { id: 8, name: '旧名', phone: null, status: 'archived' } as unknown as Student;
|
||||
const mapping = { dingUserId: 'u8', studentId: 8 } as StudentDingMapping;
|
||||
const { manager } = managerFixture({ mappings: [mapping], students: [archived] });
|
||||
|
||||
const result = await syncDingTalkStudents(manager, [
|
||||
{ dingUserId: 'u8', name: '新名', mobile: '13900000000' },
|
||||
]);
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
expect(archived).toMatchObject({ name: '新名', phone: '13900000000', status: 'archived' });
|
||||
});
|
||||
});
|
||||
124
apps/server/src/integration/dingtalk-student-sync.ts
Normal file
124
apps/server/src/integration/dingtalk-student-sync.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { EntityManager, In } from 'typeorm';
|
||||
import { Organization, Student, StudentDingMapping } from '../entities';
|
||||
|
||||
export interface DingTalkStudentInput {
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile?: string;
|
||||
}
|
||||
|
||||
export interface DingTalkStudentConflict {
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DingTalkStudentSyncResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
studentIds: Map<string, number>;
|
||||
conflicts: DingTalkStudentConflict[];
|
||||
}
|
||||
|
||||
export async function syncDingTalkStudents(
|
||||
manager: EntityManager,
|
||||
inputs: DingTalkStudentInput[],
|
||||
): Promise<DingTalkStudentSyncResult> {
|
||||
const users = new Map<string, DingTalkStudentInput>();
|
||||
const conflicts: DingTalkStudentConflict[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
const dingUserId = input.dingUserId?.trim();
|
||||
const name = input.name?.trim();
|
||||
const mobile = input.mobile?.trim() || undefined;
|
||||
if (!dingUserId || dingUserId.length > 100 || !name || name.length > 50) {
|
||||
conflicts.push({ dingUserId: dingUserId || '', name: name || '', reason: '钉钉用户ID或姓名无效' });
|
||||
continue;
|
||||
}
|
||||
if (mobile && mobile.length > 20) {
|
||||
conflicts.push({ dingUserId, name, reason: '手机号超过20个字符' });
|
||||
continue;
|
||||
}
|
||||
if (!users.has(dingUserId)) users.set(dingUserId, { dingUserId, name, mobile });
|
||||
}
|
||||
|
||||
if (users.size === 0) {
|
||||
return { created: 0, updated: 0, studentIds: new Map(), conflicts };
|
||||
}
|
||||
|
||||
const dingUserIds = [...users.keys()];
|
||||
const mappings = await manager.find(StudentDingMapping, {
|
||||
where: { dingUserId: In(dingUserIds) },
|
||||
});
|
||||
const mappingByDingId = new Map(mappings.map((mapping) => [mapping.dingUserId, mapping]));
|
||||
const mappedStudentIds = mappings.map((mapping) => mapping.studentId);
|
||||
const mappedStudents = mappedStudentIds.length
|
||||
? await manager.find(Student, { where: { id: In(mappedStudentIds) } })
|
||||
: [];
|
||||
const studentById = new Map(mappedStudents.map((student) => [student.id, student]));
|
||||
const studentIds = new Map<string, number>();
|
||||
const updates: Student[] = [];
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const input = users.get(mapping.dingUserId);
|
||||
const student = studentById.get(mapping.studentId);
|
||||
if (!input || !student) {
|
||||
conflicts.push({
|
||||
dingUserId: mapping.dingUserId,
|
||||
name: input?.name || '',
|
||||
reason: '钉钉映射对应的学生不存在',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
studentIds.set(mapping.dingUserId, student.id);
|
||||
student.name = input.name;
|
||||
if (input.mobile) student.phone = input.mobile;
|
||||
updates.push(student);
|
||||
}
|
||||
|
||||
const newUsers = [...users.values()].filter((user) => !mappingByDingId.has(user.dingUserId));
|
||||
const mobiles = [...new Set(newUsers.map((user) => user.mobile).filter((mobile): mobile is string => !!mobile))];
|
||||
const occupiedPhones = mobiles.length
|
||||
? await manager.find(Student, { where: { phone: In(mobiles) } })
|
||||
: [];
|
||||
const studentByPhone = new Map(occupiedPhones.map((student) => [student.phone, student]));
|
||||
const creatable = newUsers.filter((user) => {
|
||||
if (!user.mobile || !studentByPhone.has(user.mobile)) return true;
|
||||
conflicts.push({ dingUserId: user.dingUserId, name: user.name, reason: '手机号已属于其他学生,请人工绑定' });
|
||||
return false;
|
||||
});
|
||||
|
||||
const host = creatable.length
|
||||
? await manager.findOne(Organization, { where: { isHost: true, status: 'active' } })
|
||||
: null;
|
||||
if (creatable.length && !host) throw new Error('尚未配置本机构');
|
||||
|
||||
if (updates.length) await manager.save(Student, updates);
|
||||
const createdStudents = creatable.length
|
||||
? await manager.save(
|
||||
Student,
|
||||
creatable.map((user) =>
|
||||
manager.create(Student, {
|
||||
name: user.name,
|
||||
phone: user.mobile,
|
||||
status: 'active',
|
||||
organizationId: host!.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (createdStudents.length) {
|
||||
await manager.save(
|
||||
StudentDingMapping,
|
||||
createdStudents.map((student, index) =>
|
||||
manager.create(StudentDingMapping, {
|
||||
dingUserId: creatable[index].dingUserId,
|
||||
studentId: student.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
createdStudents.forEach((student, index) => studentIds.set(creatable[index].dingUserId, student.id));
|
||||
}
|
||||
|
||||
return { created: createdStudents.length, updated: updates.length, studentIds, conflicts };
|
||||
}
|
||||
@@ -176,14 +176,18 @@ describe('DingTalkService — attendance machine only group', () => {
|
||||
});
|
||||
|
||||
describe('DingTalkService — department user pagination boundaries', () => {
|
||||
type PrivateDingTalkService = {
|
||||
getDeptUsers(token: string, deptId: number): Promise<unknown[]>;
|
||||
};
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
global.fetch = undefined as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
it('stops when DingTalk says there is another page but omits the next cursor', async () => {
|
||||
it('fails when DingTalk says there is another page but omits the next cursor', async () => {
|
||||
const service = new DingTalkService({} as never, {} as never);
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
@@ -194,13 +198,14 @@ describe('DingTalkService — department user pagination boundaries', () => {
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect((service as any).getDeptUsers('token', 1)).resolves.toHaveLength(1);
|
||||
await expect((service as unknown as PrivateDingTalkService).getDeptUsers('token', 1)).rejects.toThrow('分页游标未前进');
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops when the next cursor repeats the current cursor', async () => {
|
||||
it('fails when the next cursor repeats the current cursor', async () => {
|
||||
const service = new DingTalkService({} as never, {} as never);
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
@@ -208,7 +213,56 @@ describe('DingTalkService — department user pagination boundaries', () => {
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect((service as any).getDeptUsers('token', 1)).resolves.toEqual([]);
|
||||
await expect((service as unknown as PrivateDingTalkService).getDeptUsers('token', 1)).rejects.toThrow('分页游标未前进');
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails on a DingTalk API error instead of returning a partial user list', async () => {
|
||||
const service = new DingTalkService({} as never, {} as never);
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ errcode: 40035, errmsg: 'invalid department' }),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect((service as unknown as PrivateDingTalkService).getDeptUsers('token', 9)).rejects.toThrow('invalid department');
|
||||
});
|
||||
|
||||
it('keeps a multi-department user visible in the selected subtree', async () => {
|
||||
const service = new DingTalkService({} as never, {} as never);
|
||||
const privateService = service as unknown as {
|
||||
isConfigured(): Promise<boolean>;
|
||||
getAccessToken(): Promise<string>;
|
||||
getDeptInfo(token: string, deptId: number): Promise<{ name: string; parent_id: number }>;
|
||||
buildDeptNode(token: string, deptId: number, name: string, parentId: number): Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: [];
|
||||
}>;
|
||||
getDeptUsers(token: string, deptId: number): Promise<Array<{
|
||||
userid: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
dept_id_list: number[];
|
||||
}>>;
|
||||
};
|
||||
jest.spyOn(privateService, 'isConfigured').mockResolvedValue(true);
|
||||
jest.spyOn(privateService, 'getAccessToken').mockResolvedValue('token');
|
||||
jest.spyOn(privateService, 'getDeptInfo').mockResolvedValue({ name: '子部门', parent_id: 1 });
|
||||
jest.spyOn(privateService, 'buildDeptNode').mockResolvedValue({
|
||||
id: 2,
|
||||
name: '子部门',
|
||||
parentId: 1,
|
||||
children: [],
|
||||
});
|
||||
jest.spyOn(privateService, 'getDeptUsers').mockResolvedValue([
|
||||
{ userid: 'u1', name: 'Alice', mobile: '', dept_id_list: [2, 99] },
|
||||
]);
|
||||
|
||||
const tree = await service.fetchOrgTreeWithUsers(2);
|
||||
|
||||
expect(tree[0].users).toEqual([
|
||||
expect.objectContaining({ userid: 'u1', deptIds: [2, 99] }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,17 +7,14 @@
|
||||
*/
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
|
||||
import { syncDingTalkStudents } from './dingtalk-student-sync';
|
||||
import { IntegrationConfigService } from './config/integration-config.service';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface DingTalkTokenResponse {
|
||||
accessToken: string;
|
||||
expireIn: number;
|
||||
}
|
||||
|
||||
interface DingTalkCredentials {
|
||||
appKey: string;
|
||||
@@ -40,6 +37,31 @@ interface DingTalkUserListResponse {
|
||||
}
|
||||
|
||||
|
||||
function isDingTalkUserListResponse(value: unknown): value is DingTalkUserListResponse {
|
||||
if (!value || typeof value !== 'object' || !('errcode' in value)) return false;
|
||||
if (typeof value.errcode !== 'number') return false;
|
||||
if ('errmsg' in value && typeof value.errmsg !== 'string') return false;
|
||||
if (!('result' in value) || !value.result || typeof value.result !== 'object') {
|
||||
return value.errcode !== 0;
|
||||
}
|
||||
if (!('has_more' in value.result) || typeof value.result.has_more !== 'boolean') return false;
|
||||
if (!('list' in value.result) || !Array.isArray(value.result.list)) return false;
|
||||
return value.result.list.every(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
'userid' in item &&
|
||||
typeof item.userid === 'string' &&
|
||||
'name' in item &&
|
||||
typeof item.name === 'string' &&
|
||||
'mobile' in item &&
|
||||
typeof item.mobile === 'string' &&
|
||||
'dept_id_list' in item &&
|
||||
Array.isArray(item.dept_id_list) &&
|
||||
item.dept_id_list.every((id) => typeof id === 'number'),
|
||||
);
|
||||
}
|
||||
|
||||
/** 钉钉打卡结果 — 对齐 dws attendance check result */
|
||||
export interface DingTalkAttendanceResult {
|
||||
userId: string;
|
||||
@@ -193,6 +215,7 @@ export class DingTalkService {
|
||||
@InjectRepository(StudentDingMapping)
|
||||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
private readonly integrationConfigService?: IntegrationConfigService,
|
||||
private readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
private async getCredentials(): Promise<DingTalkCredentials | null> {
|
||||
@@ -240,15 +263,20 @@ export class DingTalkService {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
const body: DingTalkTokenResponse = await res.json();
|
||||
|
||||
if (!body.accessToken) {
|
||||
const body: unknown = await res.json();
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== 'object' ||
|
||||
!('accessToken' in body) ||
|
||||
typeof body.accessToken !== 'string'
|
||||
) {
|
||||
throw new Error(`钉钉 access_token 获取失败: ${JSON.stringify(body)}`);
|
||||
}
|
||||
const expireIn = 'expireIn' in body && typeof body.expireIn === 'number' ? body.expireIn : 7200;
|
||||
|
||||
this.accessToken = body.accessToken;
|
||||
this.accessTokenCredentialKey = credentialKey;
|
||||
this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000;
|
||||
this.tokenExpiresAt = Date.now() + expireIn * 1000;
|
||||
this.logger.log('钉钉 access_token 获取成功');
|
||||
return this.accessToken;
|
||||
}
|
||||
@@ -266,6 +294,8 @@ export class DingTalkService {
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
await this.rateLimit();
|
||||
let body: DingTalkUserListResponse;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`,
|
||||
@@ -275,24 +305,27 @@ export class DingTalkService {
|
||||
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) {
|
||||
if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) {
|
||||
this.logger.error(`获取部门 ${deptId} 用户失败: 分页游标未前进`);
|
||||
hasMore = false;
|
||||
} else {
|
||||
cursor = body.result.next_cursor;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasMore = false;
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const rawBody: unknown = await res.json();
|
||||
if (!isDingTalkUserListResponse(rawBody)) throw new Error('钉钉返回了无效的用户列表');
|
||||
body = rawBody;
|
||||
} catch (error) {
|
||||
throw new ServiceUnavailableException(
|
||||
`获取部门 ${deptId} 用户失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (body.errcode !== 0 || !body.result) {
|
||||
throw new ServiceUnavailableException(
|
||||
`获取部门 ${deptId} 用户失败: ${body.errmsg || `errcode=${body.errcode}`}`,
|
||||
);
|
||||
}
|
||||
all.push(...body.result.list);
|
||||
hasMore = body.result.has_more;
|
||||
if (hasMore) {
|
||||
if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) {
|
||||
throw new ServiceUnavailableException(`获取部门 ${deptId} 用户失败: 分页游标未前进`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`获取部门 ${deptId} 用户失败: ${(e as Error).message}`);
|
||||
hasMore = false;
|
||||
cursor = body.result.next_cursor;
|
||||
}
|
||||
}
|
||||
return all;
|
||||
@@ -302,44 +335,58 @@ export class DingTalkService {
|
||||
// Sync all — 主入口
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> {
|
||||
async syncAll(rootDeptId = 1): Promise<{
|
||||
deptCount: number;
|
||||
userCount: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
conflicts: Array<{ dingUserId: string; name: string; reason: string }>;
|
||||
}> {
|
||||
if (!(await this.isConfigured())) {
|
||||
this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步');
|
||||
return { deptCount: 0, userCount: 0 };
|
||||
throw new ServiceUnavailableException('钉钉未配置');
|
||||
}
|
||||
if (!this.dataSource) throw new ServiceUnavailableException('数据库未初始化');
|
||||
|
||||
const t0 = Date.now();
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// 递归收集所有部门 ID
|
||||
const visitedDeptIds = new Set<number>();
|
||||
const collectDeptIds = async (deptId: number): Promise<number[]> => {
|
||||
if (visitedDeptIds.has(deptId)) return [];
|
||||
visitedDeptIds.add(deptId);
|
||||
const ids: number[] = [deptId];
|
||||
const subs = await this.getSubDepts(token, deptId);
|
||||
for (const sd of subs) {
|
||||
ids.push(...(await collectDeptIds(sd.dept_id)));
|
||||
for (const sub of await this.getSubDepts(token, deptId)) {
|
||||
ids.push(...(await collectDeptIds(sub.dept_id)));
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
const allDeptIds = await collectDeptIds(rootDeptId);
|
||||
|
||||
let userCount = 0;
|
||||
const seenUserIds = new Set<string>();
|
||||
|
||||
for (const did of allDeptIds) {
|
||||
const dingUsers = await this.getDeptUsers(token, did);
|
||||
for (const du of dingUsers) {
|
||||
if (seenUserIds.has(du.userid)) continue;
|
||||
seenUserIds.add(du.userid);
|
||||
|
||||
await this.syncOneUser(du);
|
||||
userCount++;
|
||||
const users = new Map<string, { dingUserId: string; name: string; mobile?: string }>();
|
||||
for (const deptId of allDeptIds) {
|
||||
for (const user of await this.getDeptUsers(token, deptId)) {
|
||||
if (!users.has(user.userid)) {
|
||||
users.set(user.userid, {
|
||||
dingUserId: user.userid,
|
||||
name: user.name,
|
||||
mobile: user.mobile || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`钉钉同步完成: ${userCount} 个用户, ${allDeptIds.length} 个部门, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
|
||||
const result = await this.dataSource.transaction((manager) =>
|
||||
syncDingTalkStudents(manager, [...users.values()]),
|
||||
);
|
||||
return { deptCount: allDeptIds.length, userCount };
|
||||
this.logger.log(
|
||||
`钉钉同步完成: ${users.size} 个用户, ${allDeptIds.length} 个部门, ` +
|
||||
`${result.created} 个新增, ${result.updated} 个更新, ${result.conflicts.length} 个冲突, ` +
|
||||
`API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
|
||||
);
|
||||
return {
|
||||
deptCount: allDeptIds.length,
|
||||
userCount: users.size,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
conflicts: result.conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -351,19 +398,23 @@ export class DingTalkService {
|
||||
`https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dept_id: deptId }) },
|
||||
);
|
||||
if (!res.ok) throw new ServiceUnavailableException(`获取部门 ${deptId} 子部门失败: HTTP ${res.status}`);
|
||||
const body = await res.json() as DingTalkDeptListResponse;
|
||||
return body.errcode === 0 ? (body.result ?? []) : [];
|
||||
if (body.errcode !== 0) throw new ServiceUnavailableException(`获取部门 ${deptId} 子部门失败: errcode=${body.errcode}`);
|
||||
return body.result ?? [];
|
||||
}
|
||||
|
||||
/** 获取单个部门详情 */
|
||||
private async getDeptInfo(token: string, deptId: number): Promise<{ name: string; parent_id: number } | null> {
|
||||
private async getDeptInfo(token: string, deptId: number): Promise<{ name: string; parent_id: number }> {
|
||||
await this.rateLimit();
|
||||
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 }) },
|
||||
);
|
||||
if (!res.ok) throw new ServiceUnavailableException(`获取部门 ${deptId} 失败: HTTP ${res.status}`);
|
||||
const body = await res.json() as DingTalkDeptGetResponse;
|
||||
return body.errcode === 0 && body.result ? body.result : null;
|
||||
if (body.errcode !== 0 || !body.result) throw new ServiceUnavailableException(`钉钉部门 ${deptId} 不存在或不可访问`);
|
||||
return body.result;
|
||||
}
|
||||
|
||||
/** 递归构建部门树节点 */
|
||||
@@ -377,49 +428,42 @@ export class DingTalkService {
|
||||
|
||||
/** 获取钉钉组织部门树(只含部门) */
|
||||
async fetchOrgTree(rootDeptId = 1): Promise<OrgDeptNode[]> {
|
||||
if (!(await this.isConfigured())) return [];
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
const rootInfo = await this.getDeptInfo(token, rootDeptId);
|
||||
if (!rootInfo) return [];
|
||||
const node = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id);
|
||||
return [node];
|
||||
}
|
||||
|
||||
/** 获取钉钉组织部门树(含用户) */
|
||||
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<OrgDeptNodeWithUsers[]> {
|
||||
if (!(await this.isConfigured())) return [];
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
const rootInfo = await this.getDeptInfo(token, rootDeptId);
|
||||
if (!rootInfo) return [];
|
||||
|
||||
// 1. 先建部门树(复用 buildDeptNode)
|
||||
const deptTree = await this.buildDeptNode(token, rootDeptId, rootInfo.name, rootInfo.parent_id);
|
||||
|
||||
// 2. 收集所有部门 ID
|
||||
const allDeptIds: number[] = [];
|
||||
const collectIds = (node: OrgDeptNode) => {
|
||||
allDeptIds.push(node.id);
|
||||
for (const c of node.children) collectIds(c);
|
||||
for (const child of node.children) collectIds(child);
|
||||
};
|
||||
collectIds(deptTree);
|
||||
|
||||
// 3. 从每个部门拉用户,每人只挂到一个部门(dept_id_list 最后一个)
|
||||
const allDeptIdsSet = new Set(allDeptIds);
|
||||
const usersByDept = new Map<number, Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>>();
|
||||
const placedUsers = new Set<string>();
|
||||
for (const did of allDeptIds) {
|
||||
const deptUsers = await this.getDeptUsers(token, did);
|
||||
for (const u of deptUsers) {
|
||||
if (placedUsers.has(u.userid)) continue;
|
||||
const targetDept = u.dept_id_list[u.dept_id_list.length - 1];
|
||||
if (allDeptIdsSet.has(targetDept)) {
|
||||
placedUsers.add(u.userid);
|
||||
if (!usersByDept.has(targetDept)) usersByDept.set(targetDept, []);
|
||||
usersByDept.get(targetDept)!.push({ userid: u.userid, name: u.name, mobile: u.mobile, deptIds: u.dept_id_list });
|
||||
}
|
||||
for (const deptId of allDeptIds) {
|
||||
for (const user of await this.getDeptUsers(token, deptId)) {
|
||||
if (placedUsers.has(user.userid)) continue;
|
||||
placedUsers.add(user.userid);
|
||||
if (!usersByDept.has(deptId)) usersByDept.set(deptId, []);
|
||||
usersByDept.get(deptId)!.push({
|
||||
userid: user.userid,
|
||||
name: user.name,
|
||||
mobile: user.mobile,
|
||||
deptIds: user.dept_id_list,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 4. 递归挂用户到树节点
|
||||
const attachUsers = (node: OrgDeptNode): OrgDeptNodeWithUsers => ({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
@@ -427,45 +471,9 @@ export class DingTalkService {
|
||||
children: node.children.map(attachUsers),
|
||||
users: usersByDept.get(node.id) ?? [],
|
||||
});
|
||||
|
||||
return [attachUsers(deptTree)];
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Sync one user (with mapping)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private async syncOneUser(du: {
|
||||
userid: string; name: string; mobile: string;
|
||||
}): Promise<void> {
|
||||
let mapping = await this.studentDingMappingRepo.findOne({
|
||||
where: { dingUserId: du.userid },
|
||||
});
|
||||
if (mapping) {
|
||||
const student = await this.studentRepo.findOne({
|
||||
where: { id: mapping.studentId },
|
||||
});
|
||||
if (student) {
|
||||
student.name = du.name;
|
||||
if (du.mobile) student.phone = du.mobile;
|
||||
await this.studentRepo.save(student);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const student = this.studentRepo.create({
|
||||
name: du.name,
|
||||
phone: du.mobile || undefined,
|
||||
status: 'active',
|
||||
});
|
||||
await this.studentRepo.save(student);
|
||||
|
||||
mapping = this.studentDingMappingRepo.create({
|
||||
dingUserId: du.userid,
|
||||
studentId: student.id,
|
||||
});
|
||||
await this.studentDingMappingRepo.save(mapping);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Rate limiting — 对齐 gongxue-dorm-sys
|
||||
@@ -735,7 +743,7 @@ export class DingTalkService {
|
||||
}
|
||||
|
||||
/** 查询所有考勤组摘要(分页,每页10条) */
|
||||
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
|
||||
async queryAttendanceGroups(_opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
|
||||
if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user