# 旧项目参考：FF-ADP-to-AD 技术分析报告

> 源码位置：`docs/Documentation - ADP to AD/ADP to AD/FF-ADP-to-AD Source Code/`
> 分析日期：2026-04-24
> 用途：为新 ADP 同步项目提供字段映射、API 端点、认证流程等参考依据

---

## 1. 项目定位

C# 控制台程序，将 **ADP HR 系统的员工数据单向同步到 Windows Active Directory**，运行在公司内网 Windows 机器上。

目标系统：`faradayfuture.com` 域的 Active Directory（OU: FF-Users）

---

## 2. 整体架构

```
AD Synch/
├── ADP/
│   ├── Connection/           # OAuth2 连接（ClientCredential / AuthorizationCode）
│   ├── Configuration/        # JSON 配置文件读取
│   ├── Models/               # ADP 数据结构（AdpWorker 等）
│   ├── Wrappers/             # API 分页调用封装（ApiHelper）
│   ├── ADPException/         # 自定义异常
│   └── ADPAccessToken.cs     # Token 管理（含过期检测）
├── ActiveDirectory/
│   └── AdManager.cs          # 所有 AD 查询与更新操作
├── Comparison/
│   └── DataComparer.cs       # 新旧数据差异比对引擎
├── Files/                    # XLSX / XML 报告导出
├── SynchronyDb.cs            # 中间 SQL Server 数据库
└── Program.cs                # 主流程编排
```

---

## 3. ADP 认证流程

### 认证方式
**OAuth2 Client Credentials Grant + mTLS 双向证书**

### 配置参数（JSON 文件）
```json
{
  "clientID": "...",
  "clientSecret": "...",
  "sslCertPath": "path/to/certificate.pfx",
  "sslKeyPath": "path/to/key.pem",
  "sslKeyPass": "",
  "tokenServerURL": "https://api.adp.com/auth/oauth/v2/token",
  "apiRequestURL": "https://api.adp.com",
  "grantType": "client_credentials"
}
```

### Token 获取流程
```
POST https://api.adp.com/auth/oauth/v2/token
Body: client_id + client_secret + grant_type=client_credentials
TLS:  附带 .pfx 证书（mTLS）
← 返回: { access_token, token_type: "Bearer", expires_in: 3600 }
```

### Token 管理
- `ExpiresOn = now + expires_in`，自动计算
- `IsValid()` 方法在每次请求前校验，过期自动重新获取
- API 请求头：`Authorization: Bearer {access_token}`

### 工厂模式
```csharp
ADPApiConnectionFactory.createConnection(cfg)
// ClientCredentialConfiguration → ClientCredentialConnection
// AuthorizationCodeConfiguration → AuthorizationCodeConnection
```

---

## 4. ADP API 端点

| 端点 | 说明 |
|------|------|
| `GET /hr/v2/workers?count=true` | 获取员工总数（`meta.totalNumber`） |
| `GET /hr/v2/workers?$skip={n}&$top=50` | 分页获取员工列表（每页 50 条） |
| `GET /hr/v2/workers/{associateOID}` | 获取单个员工详情 |
| `GET /codelists/hr/v3/worker-management/business-units/WFN/1` | 业务部门代码表 |
| `GET /codelists/hr/v3/worker-management/departments/WFN/1` | 部门代码表 |
| `GET /codelists/hr/v3/worker-management/locations/WFN/1` | 工作地点代码表 |
| `GET /codelists/hr/v3/worker-management/job-titles/WFN/1` | 职位代码表 |
| `GET /codelists/hr/v3/worker-management/cost-numbers/WFN/1` | 成本中心代码表 |

---

## 5. ADP 响应数据结构

### WorkerCollection
```json
{
  "meta": { "totalNumber": 500 },
  "workers": [
    {
      "associateOID": "G3XXX...",
      "workerID": { "idValue": "FF12345" },
      "person": {
        "legalName": {
          "givenName": "John",
          "middleName": "A",
          "familyName1": "Doe",
          "nickName": "Johnny"
        },
        "birthDate": "1990-01-15",
        "genderCode": { "shortName": "M" }
      },
      "workerDates": {
        "originalHireDate": "2020-03-01",
        "terminationDate": null
      },
      "workerStatus": {
        "statusCode": { "codeValue": "Active" }
      },
      "businessCommunication": {
        "emails": [
          { "itemID": "Business", "emailUri": "john.doe@faradayfuture.com" }
        ]
      },
      "workAssignments": [
        {
          "assignmentStatus": { "statusCode": { "shortName": "Active" } },
          "hireDate": "2020-03-01",
          "workerTypeCode": { "shortName": "Employee" },
          "jobCode": { "codeValue": "ENG001" },
          "jobTitle": "Software Engineer",
          "positionID": "POS-001",
          "homeOrganizationalUnits": [
            {
              "typeCode": { "codeValue": "Business Unit" },
              "nameCode": { "shortName": "Technology", "codeValue": "TECH" }
            },
            {
              "typeCode": { "codeValue": "Department" },
              "nameCode": { "shortName": "Platform Engineering", "codeValue": "PE" }
            },
            {
              "typeCode": { "codeValue": "Cost Number" },
              "nameCode": { "shortName": "CC-001", "codeValue": "CC001" }
            }
          ],
          "homeWorkLocation": {
            "nameCode": { "shortName": "Gardena" }
          },
          "reportsTo": [
            {
              "associateOID": "G3YYY...",
              "workerID": { "idValue": "FF00100" },
              "reportsToWorkerName": { "formattedName": "Jane Smith" },
              "positionID": "POS-MGR-001"
            }
          ]
        }
      ]
    }
  ]
}
```

---

## 6. 字段映射

### 6.1 ADP JSON → 本地 AdpWorker 对象

| ADP JSON 路径 | 本地字段 | 说明 |
|--------------|---------|------|
| `associateOID` | `AssociateOId` | ADP 内部唯一 ID |
| `workerID.idValue` | `WorkerId` / `AssociateId` | 员工工号 |
| `person.legalName.givenName` | `GivenName` | 名 |
| `person.legalName.middleName` | `MiddleName` | 中间名 |
| `person.legalName.familyName1` | `FamilyName` | 姓 |
| `person.legalName.nickName` | `NickName` | 昵称（英文名） |
| `person.birthDate` | `BirthDate` | 出生日期 |
| `person.genderCode.shortName` | `Gender` | 性别 |
| `workerDates.originalHireDate` | `OriginalHireDate` | 原始入职日期 |
| `workerDates.terminationDate` | `TerminationDate` | 离职日期 |
| `workerStatus.statusCode.codeValue` | `Status` | `"Active"` / `"Terminated"` |
| `businessCommunication.emails[itemID="Business"].emailUri` | `WorkEmail` | 工作邮箱 |
| `workAssignments[0].hireDate` | `HireDate` | 本次入职日期 |
| `workAssignments[0].workerTypeCode.shortName` | `WorkerType` | `"Employee"` / `"Contractor"` |
| `workAssignments[0].jobCode.codeValue` | `JobCode` | 职位代码 |
| `workAssignments[0].jobTitle` | `JobTitle` | 职位名称 |
| `workAssignments[0].positionID` | `PositionId` | 岗位 ID |
| `workAssignments[0].homeOrganizationalUnits[Business Unit].nameCode.shortName` | `BusinessUnit` | 业务部门名 |
| `workAssignments[0].homeOrganizationalUnits[Business Unit].nameCode.codeValue` | `BusinessUnitCode` | 业务部门代码 |
| `workAssignments[0].homeOrganizationalUnits[Department].nameCode.shortName` | `Department` | 部门名 |
| `workAssignments[0].homeOrganizationalUnits[Department].nameCode.codeValue` | `DepartmentCode` | 部门代码 |
| `workAssignments[0].homeOrganizationalUnits[Cost Number].nameCode.shortName` | `CostCenter` | 成本中心名 |
| `workAssignments[0].homeOrganizationalUnits[Cost Number].nameCode.codeValue` | `CostCenterCode` | 成本中心代码 |
| `workAssignments[0].homeWorkLocation.nameCode.shortName` | `Location` | 工作地点 |
| `workAssignments[0].reportsTo[0].associateOID` | `ManagerOID` | 直属经理 ADP OID |
| `workAssignments[0].reportsTo[0].workerID.idValue` | `ManagerId` | 直属经理工号 |
| `workAssignments[0].reportsTo[0].reportsToWorkerName.formattedName` | `ManagerName` | 直属经理姓名 |
| `workAssignments[0].reportsTo[0].positionID` | `ManagerPositionId` | 直属经理岗位 ID |

### 6.2 会被同步更新的字段（实际写入目标系统的字段）

| 本地字段 | 目标属性（AD） | 备注 |
|---------|--------------|------|
| `GivenName` | `givenName` | |
| `MiddleName` | `middleName` | |
| `FamilyName` | `sn` | |
| `NickName` / 显示名 | `displayName` | |
| `JobTitle` | `title` + `description` | 两个 AD 字段都写入 |
| `Location` | `physicalDeliveryOfficeName` | |
| `Department` | `department` | |
| `BusinessUnit` | `division` | |
| `ManagerId` | `manager`（存为 DN） | 需先查经理的 distinguishedName |

### 6.3 官方字段映射表（来自 AD Sync Mapping.xlsx）

| ADP 字段 | AD 属性 | 是否同步更新 | 备注 |
|---------|---------|------------|------|
| `AssociateId` | `employeeID` | 仅首次匹配时写入 | 后续以此为主键 |
| `GivenName` | `givenName` | YES | |
| `MiddleName` | `middleName` | YES | |
| `FamilyName` | `sn` | YES | |
| `NickName` | `displayName` | YES | |
| `JobTitle` | `title` | YES | |
| `JobTitle` | `description` | YES | 两个 AD 字段都写入 |
| `Location` | `physicalDeliveryOfficeName` | YES | |
| `Department` | `department` | YES | |
| `BusinessUnit` | `division` | YES | |
| `ManagerId` | `manager` | YES | 存为 DN 格式 |
| `Email` | `mail` | **NO** | AD 是权威源，仅用于匹配 |
| `Status` | `userAccountControl` | **NO** | 状态不匹配时跳过整个员工 |

### 6.4 不同步的字段
- **Email**：AD 是权威源，ADP 的 Email 仅用于初始匹配，不覆盖 AD 值
- **Status**：不同步，状态不一致时整个员工跳过更新

---

## 7. 同步逻辑

### 7.1 整体流程
```
1. 从 ADP API 分页获取所有员工（每页 50 条）
2. 从目标系统（AD）查询现有员工数据
3. 三级匹配：Email → 法定姓名 → 昵称+姓
4. 首次匹配成功后写入 EmployeeId，后续以 EmployeeId 为主键
5. DataComparer 差异比对（逐字段）
6. 批量更新差异字段
7. 发送邮件报告（未匹配、状态异常、经理缺失等）
```

### 7.2 三级匹配容错
```
优先级 1：WorkEmail 精确匹配
    ↓ 未命中
优先级 2：GivenName + FamilyName 精确匹配
    ↓ 未命中
优先级 3：NickName + FamilyName 精确匹配
    ↓ 未命中
→ 记录为未匹配，发邮件告警，跳过该员工
```

### 7.3 更新保护规则
- 员工 `Status` 在两端不一致时：**整个员工跳过所有字段更新**，记录告警
- 经理在目标系统中不存在时：记录警告，但不阻断其他字段更新
- 离职员工（`Status = "Terminated"`）：从未匹配告警列表中过滤，静默跳过

### 7.4 新增员工
旧项目**不负责创建**新账号，只处理已存在账号的数据更新。

---

## 8. 错误处理

### 重试机制
- ADP API 每个分页请求最多重试 3 次
- 重试间隔：立即重试（无 sleep）

### 异常类型
| 异常类 | 触发场景 |
|--------|---------|
| `ADPConnectionException` | 网络连接失败、配置缺失、证书加载失败 |
| `SqlException` | 数据库连接或查询失败 |
| `ActiveDirectoryObjectNotFoundException` | AD 中用户或组不存在 |
| `PrincipalServerDownException` | 域控制器不可用 |

### 日志（log4net）
- 日志文件：`Logs/debug.log`
- 级别：Debug / Info / Warn / Error
- 变更日志文件：`ADPtoAD_Differences_{timestamp}.txt`
  ```
  UPDATE(John Doe - john@ff.com - FF12345) - Field: title - Value: Senior Engineer
  ERROR(Jane Smith - jane@ff.com - FF00200) - Field: manager
  [错误详情堆栈]
  ```

---

## 9. 配置项

| 配置项 | 说明 |
|--------|------|
| `SynchronyDb` | 中间数据库连接字符串（SQL Server） |
| `Domain` | AD 域控制器地址（`FF-AD1.faradayfuture.com`） |
| `ADPath` | AD 搜索 OU（`FF User;FF-Users,`） |
| `DebugMode` | `true` 时只生成报告，不写入 AD |
| `SMTP` | 邮件服务器配置 |
| `ADPKeysPath` | ADP JSON 配置文件目录 |
| `ADPKeysType` | 配置文件名（`Production` / `UAT`） |
| `MailRecipient` | 报告收件人列表 |

---

## 10. 对新项目的参考价值

| 参考点 | 可复用内容 |
|--------|----------|
| mTLS + OAuth2 认证 | Token 获取、过期处理、工厂模式，直接翻译为 Node.js |
| `/hr/v2/workers` 响应结构 | 已验证的 JSON 解析路径，省去摸索 API 时间 |
| 字段映射关系 | 19+ 字段，已在生产环境验证，直接用于 `corp_hr` schema 设计 |
| 三级匹配容错 | 处理 ADP 与本系统员工对应关系的健壮方案 |
| 更新保护规则 | 状态不一致时的跳过逻辑，防止脏数据 |
| 代码表端点 | 部门/职位/地点等枚举数据的标准获取方式 |
