refactor(server): remove Department/UserDepartment entities, CampusScope, and departmentId from all entities
- Delete department.entity.ts, user-department.entity.ts - Remove Department/UserDepartment from entities/index.ts - Remove departmentId column from 18 entities (AttendanceRecord, ArchiveAttachment, Bill, ClassSchedule, Classroom, ClassroomRental, Deposit, DepositInstallment, ExamScore, LearningRecord, Occupancy, PersonalExpense, ResultArchive, Room, RoomExpense, Student, StudentEnrollment, StudentProfile, StudentReport) - Remove departments/ module entirely - Delete campus-scope.ts, campus-scope.middleware.ts (request-utils.ts kept — it's just IP extraction) - Simplify common.module.ts to empty module - Remove CampusScopeMiddleware from app.module.ts - Remove all CampusScope injections and filter calls across all services - Remove departmentId from all DTOs and controllers - Simplify dingtalk/wecom sync to only sync users (no dept table) - Update seed module to remove department seeding - Clean frontend compilation
This commit is contained in:
@@ -3,13 +3,11 @@
|
||||
*
|
||||
* 提供:
|
||||
* - OAuth2 access_token(新版 API + 缓存)
|
||||
* - BFS 遍历所有部门 + 用户(带限流)
|
||||
* - 用户同步(自动建 Student + StudentDingMapping)
|
||||
*/
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Department } from '../entities/department.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
|
||||
|
||||
@@ -20,18 +18,6 @@ interface DingTalkTokenResponse {
|
||||
expireIn: number;
|
||||
}
|
||||
|
||||
interface SubDeptIdListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result: { dept_id_list: number[] };
|
||||
}
|
||||
|
||||
interface DepartmentDetailResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result: { dept_id: number; name: string; parent_id: number };
|
||||
}
|
||||
|
||||
interface DingTalkUserListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
@@ -61,28 +47,6 @@ export interface DingTalkAttendanceResult {
|
||||
checkType: string;
|
||||
}
|
||||
|
||||
/** 钉钉部门树节点,供前端选择器使用 */
|
||||
export interface DingOrgTreeNode {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNode[];
|
||||
}
|
||||
|
||||
/** 钉钉部门树节点(含用户),供同步用户选择器使用 */
|
||||
export interface DingOrgTreeNodeWithUsers {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNodeWithUsers[];
|
||||
users: Array<{
|
||||
userid: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
deptIds: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── 考勤排班 API 类型 ──
|
||||
|
||||
/** 班次卡段打卡时间 */
|
||||
@@ -182,8 +146,6 @@ export class DingTalkService {
|
||||
private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Department)
|
||||
private readonly deptRepo: Repository<Department>,
|
||||
@InjectRepository(Student)
|
||||
private readonly studentRepo: Repository<Student>,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
@@ -222,65 +184,6 @@ export class DingTalkService {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Department BFS — 对齐 gongxue-dorm-sys getAllSubDepartmentIds
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private async getAllDeptIds(token: string, rootDeptId = 1): Promise<number[]> {
|
||||
const ids: number[] = [];
|
||||
const queue: number[] = [rootDeptId];
|
||||
|
||||
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 ids;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -334,191 +237,39 @@ export class DingTalkService {
|
||||
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, rootDeptId);
|
||||
this.logger.log(`共发现 ${deptIds.length} 个部门`);
|
||||
|
||||
// ── Step 2: Sync departments ──
|
||||
let deptCount = 0;
|
||||
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 = detail.name;
|
||||
if (detail.parent_id) dept.parentSourceId = String(detail.parent_id);
|
||||
} else {
|
||||
dept = this.deptRepo.create({
|
||||
name: detail.name,
|
||||
source: 'dingtalk',
|
||||
sourceId,
|
||||
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) {
|
||||
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);
|
||||
|
||||
|
||||
// ── Step 3: Sync users per department ──
|
||||
// ── Sync users from root department ──
|
||||
let userCount = 0;
|
||||
const seenUserIds = new Set<string>();
|
||||
|
||||
for (let i = 0; i < deptIds.length; i++) {
|
||||
const deptId = deptIds[i];
|
||||
const dingUsers = await this.getDeptUsers(token, deptId);
|
||||
const dingUsers = await this.getDeptUsers(token, rootDeptId);
|
||||
for (const du of dingUsers) {
|
||||
if (seenUserIds.has(du.userid)) continue;
|
||||
seenUserIds.add(du.userid);
|
||||
|
||||
for (const du of dingUsers) {
|
||||
if (seenUserIds.has(du.userid)) continue;
|
||||
seenUserIds.add(du.userid);
|
||||
|
||||
await this.syncOneUser(du);
|
||||
userCount++;
|
||||
}
|
||||
await this.syncOneUser(du);
|
||||
userCount++;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`钉钉同步完成: ${deptCount} 个新部门, ${userCount} 个用户, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
|
||||
`钉钉同步完成: ${userCount} 个用户, API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
|
||||
);
|
||||
return { deptCount, userCount };
|
||||
return { deptCount: 0, userCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。
|
||||
* 返回从指定 rootDeptId 开始的树;默认根部门 1。
|
||||
* 获取钉钉组织部门树(只含部门,不含用户)。
|
||||
* ponytail: Department entity removed; returns empty array.
|
||||
*/
|
||||
async fetchOrgTree(rootDeptId = 1): Promise<DingOrgTreeNode[]> {
|
||||
if (!this.configured) {
|
||||
throw new ServiceUnavailableException('钉钉未配置');
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
|
||||
// 拉每个部门详情
|
||||
const nodes: DingOrgTreeNode[] = [];
|
||||
for (let i = 0; i < deptIds.length; i++) {
|
||||
if (i > 0) await this.delay(i);
|
||||
const detail = await this.getDeptDetail(token, deptIds[i]);
|
||||
if (detail) {
|
||||
nodes.push({
|
||||
id: detail.dept_id,
|
||||
name: detail.name,
|
||||
parentId: detail.parent_id,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 组装成树
|
||||
const map = new Map<number, DingOrgTreeNode>();
|
||||
nodes.forEach((n) => map.set(n.id, n));
|
||||
const roots: DingOrgTreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const parent = map.get(node.parentId);
|
||||
if (parent && node.id !== rootDeptId) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
async fetchOrgTree(_rootDeptId = 1): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钉钉组织部门树(含用户),供前端同步用户选择器使用。
|
||||
* 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。
|
||||
* 获取钉钉组织部门树(含用户)。
|
||||
* ponytail: Department entity removed; returns empty array.
|
||||
*/
|
||||
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<DingOrgTreeNodeWithUsers[]> {
|
||||
if (!this.configured) {
|
||||
throw new ServiceUnavailableException('钉钉未配置');
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
|
||||
// 拉每个部门详情
|
||||
const nodes: DingOrgTreeNodeWithUsers[] = [];
|
||||
// Before dedup: collect deptIds per user
|
||||
const userDeptMap = new Map<string, number[]>();
|
||||
|
||||
for (let i = 0; i < deptIds.length; i++) {
|
||||
if (i > 0) await this.delay(i);
|
||||
const detail = await this.getDeptDetail(token, deptIds[i]);
|
||||
if (!detail) continue;
|
||||
|
||||
// 拉该部门下的用户
|
||||
const dingUsers = await this.getDeptUsers(token, deptIds[i]);
|
||||
this.logger.log(`[dingtalk] dept ${deptIds[i]} (${detail.name}): ${dingUsers.length} users`);
|
||||
|
||||
nodes.push({
|
||||
id: detail.dept_id,
|
||||
name: detail.name,
|
||||
parentId: detail.parent_id,
|
||||
children: [],
|
||||
users: dingUsers.map((u) => ({
|
||||
userid: u.userid,
|
||||
name: u.name,
|
||||
mobile: u.mobile,
|
||||
deptIds: [],
|
||||
})),
|
||||
});
|
||||
|
||||
// Record which departments each user belongs to
|
||||
for (const u of dingUsers) {
|
||||
if (!userDeptMap.has(u.userid)) {
|
||||
userDeptMap.set(u.userid, []);
|
||||
}
|
||||
userDeptMap.get(u.userid)!.push(detail.dept_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 全局去重:同一个 dingUserId 可能在多个部门出现
|
||||
const seenUserIds = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
node.users = node.users
|
||||
.filter((u) => {
|
||||
if (seenUserIds.has(u.userid)) return false;
|
||||
seenUserIds.add(u.userid);
|
||||
return true;
|
||||
})
|
||||
.map((u) => ({
|
||||
...u,
|
||||
deptIds: userDeptMap.get(u.userid) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
// 组装成树(父节点可能已被过滤,缺失的父节点 → 节点提升为根)
|
||||
const map = new Map<number, DingOrgTreeNodeWithUsers>();
|
||||
nodes.forEach((n) => map.set(n.id, n));
|
||||
const roots: DingOrgTreeNodeWithUsers[] = [];
|
||||
for (const node of nodes) {
|
||||
const parent = map.get(node.parentId);
|
||||
if (parent && node.id !== rootDeptId) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
async fetchOrgTreeWithUsers(_rootDeptId = 1): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Department, User, Student, StudentDingMapping, Class } from '../entities';
|
||||
import { User, Student, StudentDingMapping, Class } from '../entities';
|
||||
import { DingTalkService } from './dingtalk.service';
|
||||
import { WeComService } from './wecom.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Department, User, Student, StudentDingMapping, Class])],
|
||||
imports: [TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class])],
|
||||
providers: [DingTalkService, WeComService],
|
||||
exports: [DingTalkService, WeComService],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 {
|
||||
@@ -35,8 +34,6 @@ export class WeComService {
|
||||
private tokenExpiresAt = 0;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Department)
|
||||
private readonly deptRepo: Repository<Department>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
) {}
|
||||
@@ -97,45 +94,14 @@ export class WeComService {
|
||||
return body.userlist;
|
||||
}
|
||||
|
||||
async syncAll(): Promise<{ deptCount: number; userCount: number }> {
|
||||
async syncAll(): Promise<{ 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 };
|
||||
return { 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),
|
||||
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) {
|
||||
@@ -159,7 +125,7 @@ export class WeComService {
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`WeCom sync done: ${deptCount} new depts, ${userCount} new users`);
|
||||
return { deptCount, userCount };
|
||||
this.logger.log(`WeCom sync done: ${userCount} new users`);
|
||||
return { userCount };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user