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

534
docs/prd-dingtalk-batch1.md Normal file
View File

@@ -0,0 +1,534 @@
# PRD 批次1 — 钉钉集成配置持久化(后端)
> 你是一名 NestJS 后端工程师。本项目是 `gongxue-base`,一个 NestJS + TypeORM 的宿舍管理系统。
> 你**只做本文件描述的事**,不要改动本文件未提到的任何文件。严格按字段名/列名/路径照抄,不要自己发明命名。
> 每写完一个文件,回到本文档核对"验收清单"。
## 背景(只读,不要改这些文件)
现状:钉钉配置目前从环境变量读取(`DINGTALK_APP_KEY` / `DINGTALK_APP_SECRET`),没有数据库表、没有配置界面。
本批次目标:**新增一张配置表 + 一个配置服务 + 一个 config controller**,让钉钉/企微的 corpId、AppKey、AppSecret 可以存进数据库并通过接口读写。本批次**不做**同步逻辑那是批次2
已知的项目约定(必须遵守):
1. 全局路由前缀是 `api`(在 `main.ts``app.setGlobalPrefix('api')`)。所以 controller 里写 `@Controller('integration/config')`,实际路径是 `/api/integration/config`
2. 权限用装饰器 `@RequirePermission('code')`,从 `../auth/decorators/permission.decorator.ts` 引入。已存在的权限码:`integration:read`(读配置)、`integration:trigger`(触发同步)。本批次读接口用 `integration:read`,写接口也用 `integration:read`(本项目没有单独的 write 码,先复用)。
3. 类级别要加 `@UseGuards(JwtAuthGuard)`,从 `../auth/guards/jwt-auth.guard.ts` 引入。
4. 实体列命名风格:属性名用 camelCase数据库列名用 snake_case通过 `@Column({ name: 'snake_case' })` 映射。参考现有实体 `apps/server/src/entities/user-ding-mapping.entity.ts` 的写法。
5. 时间戳用 `@CreateDateColumn({ name: 'created_at' })``@UpdateDateColumn({ name: 'updated_at' })`
6. 本项目**没有 orgId/多组织概念**。源项目 dorm-sys 有 orgId你要**去掉所有 orgId 相关字段和参数**。全局只有一份配置。
---
## 任务清单4 个新文件 + 1 处修改)
### 文件 1新建`apps/server/src/integration/entities/integration-config.entity.ts`
新建两个实体。**去掉源项目的 orgId 和 Organization 外键**。完整内容如下(照抄):
```typescript
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
/**
* 第三方集成主配置表。全局单例(本项目无多组织)。
* type 目前固定为 'THIRD'。
*/
@Entity('integration_config')
export class IntegrationConfig {
@PrimaryGeneratedColumn()
id: number;
/** 配置类型,目前固定 'THIRD' */
@Column({ length: 50 })
type: string;
/** 最近一次同步的来源: 'WECOM' | 'DINGTALK' | null */
@Column({ name: 'sync_resource', length: 50, nullable: true })
syncResource: string;
/** 是否已同步过 */
@Column({ name: 'is_sync', default: false })
isSync: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
/**
* 第三方集成明细配置表。一个主配置对应多条明细(钉钉/企微各一条)。
* content 存 JSON 字符串,含加密/明文的 corpId、appSecret 等。
*/
@Entity('integration_config_detail')
@Index(['configId'])
export class IntegrationConfigDetail {
@PrimaryGeneratedColumn()
id: number;
/** 关联主配置表 IntegrationConfig.id */
@Column({ name: 'config_id', type: 'integer' })
configId: number;
@ManyToOne(() => IntegrationConfig)
@JoinColumn({ name: 'config_id' })
config: IntegrationConfig;
@Column({ length: 100, nullable: true })
name: string;
/** 明细类型: 'DINGTALK_SYNC' | 'WECOM_SYNC' */
@Column({ length: 50 })
type: string;
/** 配置 JSON 字符串: { type, verify, config: {...} } */
@Column({ type: 'text', nullable: true })
content: string;
/** 该明细是否验证通过(能拿到 token */
@Column({ default: false })
enable: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
```
**验收**:文件存在,导出两个 class `IntegrationConfig``IntegrationConfigDetail`,无 orgId 字段,无 import Organization。
---
### 文件 2新建`apps/server/src/integration/config/dto/config.dto.ts`
配置的类型定义。完整内容(照抄):
```typescript
/** 钉钉配置 */
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;
}
```
**验收**:文件存在,导出 4 个 interface。
---
### 文件 3新建`apps/server/src/integration/config/integration-config.service.ts`
配置服务。职责:读配置(脱敏)、保存配置(保留旧 appSecret、测试连接、读写同步状态、给同步逻辑提供原始 appKey/appSecret。
**重要:如何测试连接 / 拿 token**
本项目已有一个 `DingTalkService`(在 `apps/server/src/integration/dingtalk.service.ts`),但它现在从 env 读取密钥、**没有**接受传参的 `getAccessToken(appKey, appSecret)` 方法。为避免改动它那是批次2的事**本批次的测试连接直接自己用 fetch 调钉钉接口**,逻辑如下:
```
POST https://api.dingtalk.com/v1.0/oauth2/accessToken
body: { "appKey": <agentId>, "appSecret": <appSecret> }
成功响应含 { accessToken, expireIn }
```
企微暂时只做占位(返回 false 即可,本项目重点是钉钉)。
完整内容(照抄,仔细核对每个方法):
```typescript
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 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 {};
}
}
}
```
**验收**
- 导出 `IntegrationConfigService`
- 没有任何 orgId 参数。
- `getThirdConfig` 返回脱敏配置(无 appSecret
- `saveConfig` 更新时若未传 appSecret 会保留旧值。
---
### 文件 4新建`apps/server/src/integration/config/integration-config.controller.ts`
REST 接口。完整内容(照抄):
```typescript
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 { 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 ? '连接成功' : '连接失败,请检查配置信息' };
}
}
```
**注意路由顺序陷阱**`@Get(':type')` 会匹配任意路径段。但 `@Post('test')` 是 POST`@Get()` 无参,所以本文件没有 GET 冲突。**不要**新增会和 `:type` 冲突的 GET 路由。
**验收**4 个路由,全部 `integration:read` 权限,路径前缀 `integration/config`
---
### 文件 5新建`apps/server/src/integration/config/config.module.ts`
模块,注册实体和 provider。完整内容照抄
```typescript
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 {}
```
**验收**:导出 `IntegrationConfigModule`exports 里有 `IntegrationConfigService`批次2 要用)。
---
### 修改 1`apps/server/src/app.module.ts`
有 3 处要改,**只加不删**
**(a) 注册两个新实体到 `allEntities` 数组。** 找到 `allEntities = [ ... ]` 数组(里面有 `Student, Room, ...UserDingMapping`)。在数组**末尾 `UserDingMapping,` 之后**加两行:
```typescript
UserDingMapping,
IntegrationConfig,
IntegrationConfigDetail,
```
**(b) 在文件顶部 import 这两个实体。** 这两个实体**不在** `./entities` 桶文件里(它们在 integration 目录下),所以单独 import。在其它 import 语句附近(比如 `import { AttendanceModule } ...` 那一片)加一行:
```typescript
import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity';
```
**(c) 注册模块。** 找到 `@Module({ imports: [ ... ] })` 里的模块列表(有 `AttendanceModule, SchedulesModule, ...`),在合适位置加:
```typescript
IntegrationConfigModule,
```
并在顶部 import
```typescript
import { IntegrationConfigModule } from './integration/config/config.module';
```
**验收**`app.module.ts` 里能看到新增的实体2 个,在 allEntities 数组里)+ 实体 import + 模块 import + `IntegrationConfigModule` 出现在 imports 数组里。
---
## 全局验收(做完全部后自检)
1. 运行 `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json` —— 你新建/修改的文件**不能有任何报错**。(如果看到 `attendance/dto/dingtalk-import.dto.ts` 之类你没碰过的文件报错,忽略,那不是你的。)
2. 不要改动本 PRD 未提到的任何文件。
3. 不要引入 orgId。
4. 所有新文件都在 `apps/server/src/integration/` 目录下entities 子目录、config 子目录)。
做完后,用一句话总结你创建/修改了哪些文件。