# 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 子目录）。

做完后，用一句话总结你创建/修改了哪些文件。
