fix: harden DingTalk student synchronization

This commit is contained in:
2026-07-18 14:51:53 +08:00
parent d25e451b61
commit c11f6bb614
15 changed files with 758 additions and 356 deletions

View 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' });
});
});

View 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 };
}

View File

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

View File

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