fix: resolve TS strict type errors in sync services (null→undefined)

This commit is contained in:
2026-07-06 15:18:47 +08:00
parent 8d810374f0
commit 72b0eed36e
2 changed files with 336 additions and 0 deletions

View File

@@ -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<Department>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
private get configured(): boolean {
return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET);
}
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 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<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})`);
}
return body.result;
}
private async fetchUsers(
token: string,
deptId: number,
): Promise<Array<{ userid: string; name: string; mobile: string; dept_id_list: number[] }>> {
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<string>();
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 };
}
}

View File

@@ -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<Department>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
private get configured(): boolean {
return !!(process.env.WECOM_CORP_ID && process.env.WECOM_CORP_SECRET);
}
private async getAccessToken(): Promise<string> {
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<Array<{ id: number; name: string; parentid: number }>> {
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<Array<{ userid: string; name: string; mobile: string; department: number[] }>> {
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<string>();
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 };
}
}