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:
2026-07-09 09:11:56 +08:00
parent f1959f0d2a
commit 42d3f0e27f
71 changed files with 5331 additions and 609 deletions

View File

@@ -0,0 +1,226 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
IntegrationConfig,
IntegrationConfigDetail,
} from '../entities/integration-config.entity';
import {
ThirdConfigBaseDTO,
DingTalkThirdConfig,
WeComThirdConfig,
SaveConfigRequest,
} from './dto/config.dto';
@Injectable()
export class IntegrationConfigService {
private readonly logger = new Logger(IntegrationConfigService.name);
constructor(
@InjectRepository(IntegrationConfig)
private readonly configRepo: Repository<IntegrationConfig>,
@InjectRepository(IntegrationConfigDetail)
private readonly detailRepo: Repository<IntegrationConfigDetail>,
) {}
/** 获取或创建主配置(全局单例) */
private async ensureConfig(): Promise<IntegrationConfig> {
let config = await this.configRepo.findOne({ where: { type: 'THIRD' } });
if (!config) {
config = this.configRepo.create({ type: 'THIRD', isSync: false });
await this.configRepo.save(config);
}
return config;
}
/** DINGTALK -> DINGTALK_SYNC, WECOM -> WECOM_SYNC */
private getDetailType(type: string): string {
switch (type.toUpperCase()) {
case 'WECOM':
return 'WECOM_SYNC';
case 'DINGTALK':
return 'DINGTALK_SYNC';
default:
throw new BadRequestException(`不支持的第三方类型: ${type}`);
}
}
/** 获取所有配置(脱敏,不返回 appSecret */
async getThirdConfig(): Promise<ThirdConfigBaseDTO[]> {
const config = await this.ensureConfig();
const details = await this.detailRepo.find({ where: { configId: config.id } });
return details.map((detail) => ({
type: detail.type.includes('WECOM')
? 'WECOM'
: detail.type.includes('DINGTALK')
? 'DINGTALK'
: detail.type,
verify: detail.enable,
config: this.parseAndMaskConfig(detail.content),
}));
}
/** 按类型获取单个配置(脱敏) */
async getConfigByType(type: string): Promise<ThirdConfigBaseDTO | null> {
const all = await this.getThirdConfig();
return all.find((c) => c.type === type.toUpperCase()) || null;
}
/** 保存/更新配置 */
async saveConfig(request: SaveConfigRequest): Promise<void> {
const config = await this.ensureConfig();
const detailType = this.getDetailType(request.type);
let existingDetail = await this.detailRepo.findOne({
where: { configId: config.id, type: detailType },
});
const finalConfig = { ...request.config } as Record<string, unknown>;
// 更新时若前端未传 appSecret则保留旧值
if (existingDetail && existingDetail.content) {
if (!finalConfig.appSecret) {
try {
const oldParsed = JSON.parse(existingDetail.content);
const oldCfg = oldParsed.config || oldParsed;
if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret;
} catch {
// ignore
}
}
} else if (!finalConfig.appSecret) {
throw new BadRequestException('首次配置必须提供 AppSecret');
}
// 连通性验证
const token = await this.getTokenForTest(request.type, finalConfig);
const verified = !!token;
const content = JSON.stringify({
type: request.type,
verify: verified,
config: finalConfig,
});
if (existingDetail) {
existingDetail.content = content;
existingDetail.enable = verified;
await this.detailRepo.save(existingDetail);
} else {
existingDetail = this.detailRepo.create({
configId: config.id,
name: '第三方设置',
type: detailType,
content,
enable: verified,
});
await this.detailRepo.save(existingDetail);
}
this.logger.log(`第三方配置已保存: ${request.type}, 验证: ${verified}`);
}
/** 测试连接 */
async testConnection(
type: string,
config: DingTalkThirdConfig | WeComThirdConfig,
): Promise<boolean> {
try {
const token = await this.getTokenForTest(type, config as unknown as Record<string, unknown>);
return !!token;
} catch (e) {
this.logger.error(`连接测试失败: ${(e as Error).message}`);
return false;
}
}
/** 读同步状态:某类型是否已同步过 */
async getSyncStatus(type: string): Promise<boolean> {
const config = await this.configRepo.findOne({ where: { type: 'THIRD' } });
if (!config || !config.isSync) return false;
return config.syncResource === type.toUpperCase();
}
/** 写同步状态 */
async setSyncStatus(syncing: boolean, type?: string): Promise<void> {
const config = await this.ensureConfig();
config.isSync = syncing;
if (type) config.syncResource = type.toUpperCase();
await this.configRepo.save(config);
}
/**
* 供同步逻辑使用:读原始(未脱敏)配置。
* 返回 { agentId, appSecret, corpId, appId? } 或 null。
*/
async getRawConfig(type: string): Promise<Record<string, any> | null> {
const config = await this.ensureConfig();
const detailType = this.getDetailType(type);
const detail = await this.detailRepo.findOne({
where: { configId: config.id, type: detailType },
});
if (!detail || !detail.content) return null;
try {
const parsed = JSON.parse(detail.content);
return parsed.config || parsed;
} catch {
return null;
}
}
/**
* 供同步逻辑使用:拿一个可用 access_token未脱敏配置直接用
* 目前仅实现钉钉。
*/
async getAccessToken(type: string): Promise<string> {
const config = await this.getRawConfig(type);
if (!config) throw new NotFoundException(`未配置 ${type} 平台信息`);
const token = await this.getTokenForTest(type, config);
if (!token) throw new BadRequestException(`获取 ${type} access_token 失败`);
return token;
}
// ── 私有工具 ──
/** 用给定配置获取 token钉钉真实调用企微暂返回 null */
private async getTokenForTest(
type: string,
config: Record<string, unknown>,
): Promise<string | null> {
try {
if (type.toUpperCase() === 'DINGTALK') {
const appKey = String(config.agentId || '');
const appSecret = String(config.appSecret || '');
if (!appKey || !appSecret) return null;
return await this.fetchDingTalkToken(appKey, appSecret);
}
// 企微暂不实现,返回 null
return null;
} catch {
return null;
}
}
/** 调钉钉新版接口拿 access_token */
private async fetchDingTalkToken(appKey: string, appSecret: string): Promise<string | null> {
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appKey, appSecret }),
});
const body = (await res.json()) as { accessToken?: string; expireIn?: number };
return body.accessToken || null;
}
/** 解析并脱敏:删掉 appSecret 后返回 config 对象 */
private parseAndMaskConfig(content: string | null): unknown {
if (!content) return {};
try {
const parsed = JSON.parse(content);
const cfg = parsed.config || parsed;
if (cfg.appSecret) delete cfg.appSecret;
return cfg;
} catch {
return {};
}
}
}