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:
16
apps/server/src/integration/config/config.module.ts
Normal file
16
apps/server/src/integration/config/config.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import {
|
||||
IntegrationConfig,
|
||||
IntegrationConfigDetail,
|
||||
} from '../entities/integration-config.entity';
|
||||
import { IntegrationConfigService } from './integration-config.service';
|
||||
import { IntegrationConfigController } from './integration-config.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([IntegrationConfig, IntegrationConfigDetail])],
|
||||
controllers: [IntegrationConfigController],
|
||||
providers: [IntegrationConfigService],
|
||||
exports: [IntegrationConfigService],
|
||||
})
|
||||
export class IntegrationConfigModule {}
|
||||
29
apps/server/src/integration/config/dto/config.dto.ts
Normal file
29
apps/server/src/integration/config/dto/config.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** 钉钉配置 */
|
||||
export interface DingTalkThirdConfig {
|
||||
agentId: string; // AppKey
|
||||
appSecret: string; // AppSecret
|
||||
corpId: string; // CorpId
|
||||
startEnable: boolean; // 是否启用同步
|
||||
appId?: string; // 内部应用ID,用于消息推送(可选)
|
||||
}
|
||||
|
||||
/** 企微配置 */
|
||||
export interface WeComThirdConfig {
|
||||
agentId: string;
|
||||
appSecret: string;
|
||||
corpId: string;
|
||||
startEnable: boolean;
|
||||
}
|
||||
|
||||
/** 对外返回的配置(脱敏后,不含 appSecret) */
|
||||
export interface ThirdConfigBaseDTO<T = unknown> {
|
||||
type: string;
|
||||
verify?: boolean;
|
||||
config: T;
|
||||
}
|
||||
|
||||
/** 保存配置的请求体 */
|
||||
export interface SaveConfigRequest {
|
||||
type: 'WECOM' | 'DINGTALK';
|
||||
config: DingTalkThirdConfig | WeComThirdConfig;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../auth/decorators/permission.decorator';
|
||||
import { IntegrationConfigService } from './integration-config.service';
|
||||
import type { SaveConfigRequest } from './dto/config.dto';
|
||||
|
||||
@Controller('integration/config')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class IntegrationConfigController {
|
||||
constructor(private readonly service: IntegrationConfigService) {}
|
||||
|
||||
/** 获取全部配置(脱敏) */
|
||||
@Get()
|
||||
@RequirePermission('integration:read')
|
||||
async getConfigs() {
|
||||
const data = await this.service.getThirdConfig();
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/** 按类型获取单个配置 */
|
||||
@Get(':type')
|
||||
@RequirePermission('integration:read')
|
||||
async getConfig(@Param('type') type: string) {
|
||||
const data = await this.service.getConfigByType(type.toUpperCase());
|
||||
if (!data) {
|
||||
return { success: false, message: `未找到 ${type} 的配置` };
|
||||
}
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/** 保存配置 */
|
||||
@Post()
|
||||
@RequirePermission('integration:read')
|
||||
async saveConfig(@Body() body: SaveConfigRequest) {
|
||||
await this.service.saveConfig(body);
|
||||
return { success: true, message: '配置已保存' };
|
||||
}
|
||||
|
||||
/** 测试连接 */
|
||||
@Post('test')
|
||||
@RequirePermission('integration:read')
|
||||
async testConnection(@Body() body: SaveConfigRequest) {
|
||||
const success = await this.service.testConnection(body.type, body.config);
|
||||
return { success, message: success ? '连接成功' : '连接失败,请检查配置信息' };
|
||||
}
|
||||
}
|
||||
226
apps/server/src/integration/config/integration-config.service.ts
Normal file
226
apps/server/src/integration/config/integration-config.service.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user