# 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 { 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": , "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, @InjectRepository(IntegrationConfigDetail) private readonly detailRepo: Repository, ) {} /** 获取或创建主配置(全局单例) */ private async ensureConfig(): Promise { 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 { 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 { const all = await this.getThirdConfig(); return all.find((c) => c.type === type.toUpperCase()) || null; } /** 保存/更新配置 */ async saveConfig(request: SaveConfigRequest): Promise { 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; // 更新时若前端未传 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 { try { const token = await this.getTokenForTest(type, config as Record); return !!token; } catch (e) { this.logger.error(`连接测试失败: ${(e as Error).message}`); return false; } } /** 读同步状态:某类型是否已同步过 */ async getSyncStatus(type: string): Promise { 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 { 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 | 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 { 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, ): Promise { 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 { 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 子目录)。 做完后,用一句话总结你创建/修改了哪些文件。