forked from wangziqi/gongxue-base
feat: DingTalk attendance import + integration config + expense types + UI polish
Server: - Add DingTalk attendance import service with SSE progress streaming - Add IntegrationConfig entity & module for multi-tenant DingTalk setup - Add ExpenseType entity & ExpenseTypesModule - Add SeedModule for DB initialization - Add UserDingMapping entity for DingTalk user linkage - Attendance service: import flow with dedup & student auto-mapping - Rooms service: time-range overlap queries - Sync controller/service: DingTalk integration wiring - Permission guard: refactor to pure re-export - Campus scope middleware: tenant-aware filtering Admin UI: - Attendance page: import UI with progress & result summary - All pages: tableStyle/tablePagination standardization - Login page: responsive styling - Sensitive data: useViewSensitive hook for masked viewing - Vite config: path aliases, build optimization - Test infra: vitest config, test utilities Docs: PRD DingTalk batch 1 & 2 design docs
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
* - BFS 遍历所有部门 + 用户(带限流)
|
||||
* - 用户同步(自动建 User + Student + UserDingMapping)
|
||||
*/
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
@@ -53,6 +53,7 @@ interface DingTalkUserListResponse {
|
||||
/** 钉钉打卡结果 — 对齐 dws attendance check result */
|
||||
export interface DingTalkAttendanceResult {
|
||||
userId: string;
|
||||
userName: string;
|
||||
workDate: string;
|
||||
timeResult: string;
|
||||
locationResult: string;
|
||||
@@ -62,6 +63,14 @@ export interface DingTalkAttendanceResult {
|
||||
checkType: string;
|
||||
}
|
||||
|
||||
/** 钉钉部门树节点,供前端选择器使用 */
|
||||
export interface DingOrgTreeNode {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNode[];
|
||||
}
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class DingTalkService {
|
||||
@@ -121,9 +130,9 @@ export class DingTalkService {
|
||||
// Department BFS — 对齐 gongxue-dorm-sys getAllSubDepartmentIds
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private async getAllDeptIds(token: string): Promise<number[]> {
|
||||
private async getAllDeptIds(token: string, rootDeptId = 1): Promise<number[]> {
|
||||
const ids: number[] = [];
|
||||
const queue: number[] = [1];
|
||||
const queue: number[] = [rootDeptId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const deptId = queue.shift()!;
|
||||
@@ -219,7 +228,7 @@ export class DingTalkService {
|
||||
// Sync all — 主入口
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
async syncAll(): Promise<{ deptCount: number; userCount: number }> {
|
||||
async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> {
|
||||
if (!this.configured) {
|
||||
this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步');
|
||||
return { deptCount: 0, userCount: 0 };
|
||||
@@ -230,7 +239,7 @@ export class DingTalkService {
|
||||
|
||||
// ── Step 1: BFS traverse all departments ──
|
||||
this.logger.log('开始 BFS 遍历钉钉部门...');
|
||||
const deptIds = await this.getAllDeptIds(token);
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
this.logger.log(`共发现 ${deptIds.length} 个部门`);
|
||||
|
||||
// ── Step 2: Sync departments ──
|
||||
@@ -295,6 +304,47 @@ export class DingTalkService {
|
||||
return { deptCount, userCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。
|
||||
* 返回从指定 rootDeptId 开始的树;默认根部门 1。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Sync one user (with mapping)
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -400,6 +450,8 @@ export class DingTalkService {
|
||||
checkDateTo: dateTo,
|
||||
};
|
||||
if (params.userIds?.length) body.userIds = params.userIds;
|
||||
if (params.offset !== undefined) body.offset = params.offset;
|
||||
if (params.limit !== undefined) body.limit = params.limit;
|
||||
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,
|
||||
@@ -423,6 +475,7 @@ export class DingTalkService {
|
||||
|
||||
return (data.recordresult ?? []).map((r) => ({
|
||||
userId: r.userId,
|
||||
userName: '',
|
||||
workDate: new Date(r.workDate).toISOString().slice(0, 10),
|
||||
timeResult: r.timeResult ?? r.sourceType ?? '',
|
||||
locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '',
|
||||
|
||||
Reference in New Issue
Block a user