Files
gongxue-base/docs/prd-dingtalk-batch1.md
wangziqi 42d3f0e27f 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
2026-07-09 09:11:56 +08:00

535 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 子目录)。
做完后,用一句话总结你创建/修改了哪些文件。