From 72b0eed36eb50f6508aff6342229ac78600eb3b4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 6 Jul 2026 15:18:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20TS=20strict=20type=20errors=20?= =?UTF-8?q?in=20sync=20services=20(null=E2=86=92undefined)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/integration/dingtalk.service.ts | 171 ++++++++++++++++++ apps/server/src/integration/wecom.service.ts | 165 +++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 apps/server/src/integration/dingtalk.service.ts create mode 100644 apps/server/src/integration/wecom.service.ts diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts new file mode 100644 index 0000000..8574fff --- /dev/null +++ b/apps/server/src/integration/dingtalk.service.ts @@ -0,0 +1,171 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Department } from '../entities/department.entity'; +import { User } from '../entities/user.entity'; + +interface DingTalkTokenResponse { + errcode: number; + errmsg: string; + access_token: string; + expires_in: number; +} + +interface DingTalkDeptListResponse { + errcode: number; + errmsg: string; + result: Array<{ dept_id: number; name: string; parent_id: number }>; +} + +interface DingTalkUserListResponse { + errcode: number; + errmsg: string; + result: { + has_more: boolean; + list: Array<{ + userid: string; + name: string; + mobile: string; + dept_id_list: number[]; + }>; + }; +} + +@Injectable() +export class DingTalkService { + private readonly logger = new Logger(DingTalkService.name); + private accessToken: string | null = null; + private tokenExpiresAt = 0; + + constructor( + @InjectRepository(Department) + private readonly deptRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + private get configured(): boolean { + return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET); + } + + private async getAccessToken(): Promise { + 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 body: DingTalkTokenResponse = await res.json(); + if (body.errcode !== 0) { + throw new Error(`DingTalk gettoken failed: ${body.errmsg} (${body.errcode})`); + } + this.accessToken = body.access_token; + this.tokenExpiresAt = Date.now() + body.expires_in * 1000; + return this.accessToken; + } + + private async fetchDepartments(token: string): Promise> { + 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})`); + } + return body.result; + } + + private async fetchUsers( + token: string, + deptId: number, + ): Promise> { + const allUsers: DingTalkUserListResponse['result']['list'] = []; + 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})`); + } + allUsers.push(...body.result.list); + if (!body.result.has_more) break; + cursor = allUsers.length; + } + return allUsers; + } + + 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'); + return { deptCount: 0, userCount: 0 }; + } + const token = await this.getAccessToken(); + const dingDepts = await this.fetchDepartments(token); + + let deptCount = 0; + for (const dd of dingDepts) { + const sourceId = String(dd.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; + } else { + dept = this.deptRepo.create({ + name: dd.name, + source: 'dingtalk', + sourceId, + parentSourceId: (dd.parent_id ? String(dd.parent_id) : undefined) as any, + type: 'department', + }); + deptCount++; + } + await this.deptRepo.save(dept); + } + + 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); + + let userCount = 0; + const seenUserIds = new Set(); + for (const dd of dingDepts) { + const dingUsers = await this.fetchUsers(token, dd.dept_id); + 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); + } + } + + this.logger.log(`DingTalk sync done: ${deptCount} new depts, ${userCount} new users`); + return { deptCount, userCount }; + } +} diff --git a/apps/server/src/integration/wecom.service.ts b/apps/server/src/integration/wecom.service.ts new file mode 100644 index 0000000..2ff7299 --- /dev/null +++ b/apps/server/src/integration/wecom.service.ts @@ -0,0 +1,165 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Department } from '../entities/department.entity'; +import { User } from '../entities/user.entity'; + +interface WeComTokenResponse { + errcode: number; + errmsg: string; + access_token: string; + expires_in: number; +} + +interface WeComDeptListResponse { + errcode: number; + errmsg: string; + department: Array<{ id: number; name: string; parentid: number }>; +} + +interface WeComUserListResponse { + errcode: number; + errmsg: string; + userlist: Array<{ + userid: string; + name: string; + mobile: string; + department: number[]; + }>; +} + +@Injectable() +export class WeComService { + private readonly logger = new Logger(WeComService.name); + private accessToken: string | null = null; + private tokenExpiresAt = 0; + + constructor( + @InjectRepository(Department) + private readonly deptRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + private get configured(): boolean { + return !!(process.env.WECOM_CORP_ID && process.env.WECOM_CORP_SECRET); + } + + private async getAccessToken(): Promise { + if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { + return this.accessToken; + } + const corpId = process.env.WECOM_CORP_ID!; + const corpSecret = process.env.WECOM_CORP_SECRET!; + const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${corpSecret}`; + const res = await fetch(url); + const body: WeComTokenResponse = await res.json(); + if (body.errcode !== 0) { + throw new Error(`WeCom gettoken failed: ${body.errmsg} (${body.errcode})`); + } + this.accessToken = body.access_token; + this.tokenExpiresAt = Date.now() + body.expires_in * 1000; + return this.accessToken; + } + + private async fetchDepartments( + token: string, + parentId = 1, + ): Promise> { + const all: WeComDeptListResponse['department'] = []; + const url = `https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=${token}&id=${parentId}`; + const res = await fetch(url); + const body: WeComDeptListResponse = await res.json(); + if (body.errcode !== 0) { + if (body.errcode === 60003) return all; + throw new Error(`WeCom department list failed: ${body.errmsg} (${body.errcode})`); + } + for (const dept of body.department) { + all.push(dept); + if (dept.id !== parentId) { + const children = await this.fetchDepartments(token, dept.id); + all.push(...children); + } + } + return all; + } + + private async fetchUsers( + token: string, + deptId: number, + ): Promise> { + const url = `https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=${token}&department_id=${deptId}&fetch_child=1`; + const res = await fetch(url); + const body: WeComUserListResponse = await res.json(); + if (body.errcode !== 0) { + throw new Error(`WeCom user list failed: ${body.errmsg} (${body.errcode})`); + } + return body.userlist; + } + + async syncAll(): Promise<{ deptCount: number; userCount: number }> { + if (!this.configured) { + this.logger.warn('WeCom not configured (WECOM_CORP_ID / WECOM_CORP_SECRET missing), skipping sync'); + return { deptCount: 0, userCount: 0 }; + } + const token = await this.getAccessToken(); + const wxDepts = await this.fetchDepartments(token); + + let deptCount = 0; + for (const wd of wxDepts) { + const sourceId = String(wd.id); + let dept = await this.deptRepo.findOne({ where: { source: 'wecom', sourceId } }); + if (dept) { + dept.name = wd.name; + dept.parentSourceId = (wd.parentid ? String(wd.parentid) : undefined) as any; + } else { + dept = this.deptRepo.create({ + name: wd.name, + source: 'wecom', + sourceId, + parentSourceId: (wd.parentid ? String(wd.parentid) : undefined) as any, + type: 'department', + }); + deptCount++; + } + await this.deptRepo.save(dept); + } + + const syncedDepts = await this.deptRepo.find({ where: { source: 'wecom' } }); + 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 === '0' || dept.parentSourceId === '1') { + dept.parentId = undefined as any; + } + } + await this.deptRepo.save(syncedDepts); + + let userCount = 0; + const seenUserIds = new Set(); + for (const wd of wxDepts) { + const wxUsers = await this.fetchUsers(token, wd.id); + for (const wu of wxUsers) { + if (seenUserIds.has(wu.userid)) continue; + seenUserIds.add(wu.userid); + let user = await this.userRepo.findOne({ where: { username: wu.userid } }); + if (user) { + user.name = wu.name; + } else { + user = this.userRepo.create({ + username: wu.userid, + name: wu.name, + passwordHash: '', + isActive: true, + }); + userCount++; + } + await this.userRepo.save(user); + } + } + + this.logger.log(`WeCom sync done: ${deptCount} new depts, ${userCount} new users`); + return { deptCount, userCount }; + } +}