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:
2026-07-09 17:51:32 +08:00
parent b0f7883f33
commit 6029d8e2fd
68 changed files with 220 additions and 1516 deletions

View File

@@ -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 [];
}
// ═══════════════════════════════════════════

View File

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

View File

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