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:
534
docs/prd-dingtalk-batch1.md
Normal file
534
docs/prd-dingtalk-batch1.md
Normal 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 子目录)。
|
||||
|
||||
做完后,用一句话总结你创建/修改了哪些文件。
|
||||
295
docs/prd-dingtalk-batch2.md
Normal file
295
docs/prd-dingtalk-batch2.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# PRD 批次2 — 钉钉「指定节点 BFS 同步」+ 组织树接口(后端)
|
||||
|
||||
> 你是 NestJS 后端工程师,项目 `gongxue-base`。本批次是**改造已有文件**,不是全新建。
|
||||
> 严格按下面给的方法体照抄替换。不要改动未提到的方法或文件。
|
||||
> 完成后跑 tsc 自检。
|
||||
|
||||
## 背景
|
||||
|
||||
现状:`apps/server/src/integration/dingtalk.service.ts` 里的 `DingTalkService.syncAll()` 会从钉钉**根部门(dept_id=1)**开始 BFS 遍历所有部门+用户,写入本地。现在写死从 1 开始。
|
||||
|
||||
本批次目标:
|
||||
1. 让 `syncAll` 支持传一个**起始部门 ID**,从该节点开始 BFS(默认仍是 1,保持向后兼容)。
|
||||
2. 新增一个方法 `fetchOrgTree()`:只拉钉钉部门(不拉用户),返回**树形结构**,给前端"组织树选择器"用来勾选同步起点。
|
||||
3. 新增一个 controller 接口把组织树暴露给前端。
|
||||
4. 让现有的 `SyncService.syncDingTalk` / `triggerSync` 能把起始节点透传下去。
|
||||
|
||||
## 关键事实(现有代码,供你理解,不要改这些无关部分)
|
||||
|
||||
`DingTalkService` 已有这些**私有方法**(保持不动,你会复用它们):
|
||||
- `private async getAccessToken(): Promise<string>` — 拿 token(从 env 读密钥)
|
||||
- `private async getAllDeptIds(token: string): Promise<number[]>` — **当前写死 `queue=[1]`**,你要改它
|
||||
- `private async getDeptDetail(token, deptId): Promise<{dept_id, name, parent_id} | null>`
|
||||
- `private async getDeptUsers(token, deptId): Promise<...>`
|
||||
- `private async rateLimit()` / `private delay(i)` / `private sleep(ms)`
|
||||
- `private get configured(): boolean`
|
||||
|
||||
`syncAll()` 现在签名是 `async syncAll(): Promise<{ deptCount: number; userCount: number }>`,内部第一步调用 `const deptIds = await this.getAllDeptIds(token);`。
|
||||
|
||||
---
|
||||
|
||||
## 任务
|
||||
|
||||
### 修改 1:`apps/server/src/integration/dingtalk.service.ts`
|
||||
|
||||
#### (1a) 改造 `getAllDeptIds` —— 支持传起始节点
|
||||
|
||||
找到现有方法(大致长这样):
|
||||
|
||||
```typescript
|
||||
private async getAllDeptIds(token: string): Promise<number[]> {
|
||||
const ids: number[] = [];
|
||||
const queue: number[] = [1];
|
||||
// ... while 循环 BFS ...
|
||||
}
|
||||
```
|
||||
|
||||
把签名和第一行改成接受可选起始节点,**其余循环体不动**:
|
||||
|
||||
```typescript
|
||||
private async getAllDeptIds(token: string, rootDeptId = 1): Promise<number[]> {
|
||||
const ids: number[] = [];
|
||||
const queue: number[] = [rootDeptId];
|
||||
// ... 下面的 while 循环体保持原样,一个字都不要改 ...
|
||||
}
|
||||
```
|
||||
|
||||
#### (1b) 改造 `syncAll` —— 接受可选起始节点并透传
|
||||
|
||||
找到 `async syncAll()`,把签名改成:
|
||||
|
||||
```typescript
|
||||
async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> {
|
||||
```
|
||||
|
||||
然后在方法体里找到这一行:
|
||||
|
||||
```typescript
|
||||
const deptIds = await this.getAllDeptIds(token);
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```typescript
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
```
|
||||
|
||||
**方法体其它部分(同步部门、同步用户、日志)全部保持原样。**
|
||||
|
||||
#### (1c) 新增方法 `fetchOrgTree` —— 返回部门树给前端
|
||||
|
||||
在类里新增一个 **public** 方法(放在 `syncAll` 之后即可)。它 BFS 拉所有部门详情,然后组装成树。照抄:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。
|
||||
* 返回从指定 rootDeptId 开始的树;默认根部门 1。
|
||||
*/
|
||||
async fetchOrgTree(rootDeptId = 1): Promise<DingOrgTreeNode[]> {
|
||||
if (!this.configured) {
|
||||
throw new Error('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET)');
|
||||
}
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
#### (1d) 新增类型定义 `DingOrgTreeNode`
|
||||
|
||||
在文件**顶部的类型定义区**(现有那些 `interface DingTalkTokenResponse {...}` 附近)新增并导出:
|
||||
|
||||
```typescript
|
||||
/** 钉钉部门树节点,供前端选择器使用 */
|
||||
export interface DingOrgTreeNode {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNode[];
|
||||
}
|
||||
```
|
||||
|
||||
**验收 (修改1)**:
|
||||
- `getAllDeptIds(token, rootDeptId=1)` 第一行是 `const queue: number[] = [rootDeptId];`
|
||||
- `syncAll(rootDeptId=1)` 内部调用 `getAllDeptIds(token, rootDeptId)`
|
||||
- 新增 public 方法 `fetchOrgTree(rootDeptId=1)`,返回 `DingOrgTreeNode[]`
|
||||
- 导出 `DingOrgTreeNode` interface
|
||||
- 其余原有方法体不变
|
||||
|
||||
---
|
||||
|
||||
### 修改 2:`apps/server/src/sync/sync.service.ts`
|
||||
|
||||
现有 `SyncService` 里有:
|
||||
- `async syncDingTalk(): Promise<SyncLog>` — 内部调用 `this.performDingTalkSync(lastSyncAt)`
|
||||
- `private async performDingTalkSync(lastSyncAt): Promise<number>` — 内部第一行 `const result = await this.dingTalkService.syncAll();`
|
||||
- `async triggerSync(platform?): Promise<SyncLog[]>`
|
||||
|
||||
我们要让触发同步时能带一个可选起始部门 ID `rootDeptId`。
|
||||
|
||||
#### (2a) `performDingTalkSync` 接受 rootDeptId
|
||||
|
||||
找到:
|
||||
|
||||
```typescript
|
||||
private async performDingTalkSync(lastSyncAt: Date | null): Promise<number> {
|
||||
// Stage 1: Sync departments and users
|
||||
const result = await this.dingTalkService.syncAll();
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```typescript
|
||||
private async performDingTalkSync(lastSyncAt: Date | null, rootDeptId = 1): Promise<number> {
|
||||
// Stage 1: Sync departments and users
|
||||
const result = await this.dingTalkService.syncAll(rootDeptId);
|
||||
```
|
||||
|
||||
**该方法其余部分(考勤导入那段)保持原样。**
|
||||
|
||||
#### (2b) `syncDingTalk` 接受并透传 rootDeptId
|
||||
|
||||
找到 `async syncDingTalk(): Promise<SyncLog> {`,把签名改成:
|
||||
|
||||
```typescript
|
||||
async syncDingTalk(rootDeptId = 1): Promise<SyncLog> {
|
||||
```
|
||||
|
||||
在方法体里找到调用 `performDingTalkSync` 的那行(大概是 `const recordsCount = await this.performDingTalkSync(lastSyncAt);`),改成:
|
||||
|
||||
```typescript
|
||||
const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId);
|
||||
```
|
||||
|
||||
**该方法其余部分(createSyncLog、finishSyncLog、catch 等)保持原样。**
|
||||
|
||||
#### (2c) `triggerSync` 接受 rootDeptId 并透传给钉钉
|
||||
|
||||
找到:
|
||||
|
||||
```typescript
|
||||
async triggerSync(platform?: SyncPlatform): Promise<SyncLog[]> {
|
||||
if (platform === 'dingtalk') return [await this.syncDingTalk()];
|
||||
if (platform === 'wecom') return [await this.syncWeCom()];
|
||||
return [await this.syncDingTalk(), await this.syncWeCom()];
|
||||
}
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```typescript
|
||||
async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise<SyncLog[]> {
|
||||
if (platform === 'dingtalk') return [await this.syncDingTalk(rootDeptId)];
|
||||
if (platform === 'wecom') return [await this.syncWeCom()];
|
||||
return [await this.syncDingTalk(rootDeptId), await this.syncWeCom()];
|
||||
}
|
||||
```
|
||||
|
||||
#### (2d) 新增一个获取组织树的方法
|
||||
|
||||
在 `SyncService` 类里新增一个 public 方法(放在 `triggerSync` 之后):
|
||||
|
||||
```typescript
|
||||
/** 获取钉钉组织部门树,供前端选择同步起点 */
|
||||
async getDingTalkOrgTree(rootDeptId = 1) {
|
||||
return this.dingTalkService.fetchOrgTree(rootDeptId);
|
||||
}
|
||||
```
|
||||
|
||||
注意:`SyncService` 构造函数里已经注入了 `private readonly dingTalkService: DingTalkService`,直接用即可,不用改构造函数。
|
||||
|
||||
**验收 (修改2)**:`triggerSync(platform?, rootDeptId=1)`、`syncDingTalk(rootDeptId=1)`、`performDingTalkSync(lastSyncAt, rootDeptId=1)` 三处签名都改了并透传;新增 `getDingTalkOrgTree(rootDeptId=1)`。
|
||||
|
||||
---
|
||||
|
||||
### 修改 3:`apps/server/src/sync/sync.controller.ts`
|
||||
|
||||
现有 controller(`@Controller('sync')`,全局前缀 api,所以是 `/api/sync/...`)有:
|
||||
- `@Post('trigger')` → `triggerSync(@Query('platform') platform?)`
|
||||
- `@Get('status')`
|
||||
- `@Get('logs')`
|
||||
|
||||
#### (3a) 给 trigger 接口加 rootDeptId 查询参数
|
||||
|
||||
找到:
|
||||
|
||||
```typescript
|
||||
@Post('trigger')
|
||||
@RequirePermission('sync:trigger')
|
||||
async triggerSync(@Query('platform') platform?: SyncPlatform) {
|
||||
const logs = await this.syncService.triggerSync(platform);
|
||||
return { synced: logs.length, logs };
|
||||
}
|
||||
```
|
||||
|
||||
改成(新增 `rootDeptId` 查询参数,字符串转数字):
|
||||
|
||||
```typescript
|
||||
@Post('trigger')
|
||||
@RequirePermission('sync:trigger')
|
||||
async triggerSync(
|
||||
@Query('platform') platform?: SyncPlatform,
|
||||
@Query('rootDeptId') rootDeptId?: string,
|
||||
) {
|
||||
const rootId = rootDeptId ? Number(rootDeptId) : 1;
|
||||
const logs = await this.syncService.triggerSync(platform, rootId);
|
||||
return { synced: logs.length, logs };
|
||||
}
|
||||
```
|
||||
|
||||
#### (3b) 新增获取组织树接口
|
||||
|
||||
在 controller 里新增(放在 `getStatus` 之后):
|
||||
|
||||
```typescript
|
||||
/** 获取钉钉组织部门树,供前端选择同步起点 */
|
||||
@Get('dingtalk/org-tree')
|
||||
@RequirePermission('sync:read')
|
||||
async getDingTalkOrgTree(@Query('rootDeptId') rootDeptId?: string) {
|
||||
const rootId = rootDeptId ? Number(rootDeptId) : 1;
|
||||
const tree = await this.syncService.getDingTalkOrgTree(rootId);
|
||||
return { success: true, data: tree };
|
||||
}
|
||||
```
|
||||
|
||||
**路由顺序检查**:现有 GET 路由是 `status`、`logs`,新增的是 `dingtalk/org-tree`,三者路径不同,无冲突。无需调整顺序。
|
||||
|
||||
**验收 (修改3)**:`trigger` 接口多了 `rootDeptId` 查询参数;新增 `GET /api/sync/dingtalk/org-tree` 接口,权限 `sync:read`。
|
||||
|
||||
---
|
||||
|
||||
## 全局验收(做完自检)
|
||||
|
||||
1. `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json` —— 你改的文件不能有 error。(忽略 `attendance/dto/dingtalk-import.dto.ts` 等你没碰的历史文件。)
|
||||
2. 只改了 3 个文件:`integration/dingtalk.service.ts`、`sync/sync.service.ts`、`sync/sync.controller.ts`。没动别的。
|
||||
3. 所有改动都保持"默认 rootDeptId=1"的向后兼容——不传参时行为和以前完全一样。
|
||||
|
||||
做完后用一句话总结改了哪些文件、加了哪些方法。
|
||||
Reference in New Issue
Block a user