# 外部 API 同步实战陷阱

> **最后更新**: 2026-05-16
> **下次复查触发条件**: 新增外部数据源集成（Outlook / ADP / 新 IdP）/ 现有同步出现 P0 事故 / 上游 API 行为变更（API v2）/ 季度复查
> **沉淀来源**: 2026-04 → 2026-05 累计 5 条 `.learnings/` 外部集成踩坑（钉钉 + Entra + SAP + 钉钉司龄）
> **适用范围**: 写 `backend/src/modules/organization/<source>/sync/` 类同步代码、调外部 API（钉钉宜搭 / Entra ID / SAP / Outlook / ADP）时

---

## 0. 为什么需要这份文档

[`02-backend-architecture.md` §外部数据同步](../../../docs/standards/02-backend-architecture.md) 已定义**架构决策层**（必须接入 `platform_automation` 同步中心）。但**实战陷阱层**（字段命名兼容 / upsert 副作用对称 / 双触发重算 / 上游 API 怪癖）散落在 `.learnings/`，没归宿。

本文档把项目对接 4 个上游系统（**钉钉 / 宜搭 / Entra ID / SAP**）踩过的 5 类典型坑集中沉淀。写新 source 集成 / 修现有 sync bug 前**先翻一遍**。

---

## 1. 上游 API 返回字段命名兼容（钉钉/宜搭 蛇形 vs 驼峰）

### 现象

调用钉钉宜搭 `getInstanceById` 取审批实例：

```ts
const inst = await yidaApi.getInstanceById(id);
console.log(inst.processInstanceId);   // ← undefined
```

审批链为空。

### 根因

**宜搭 REST API 字段命名不一致**，有时驼峰有时蛇形——同一字段在不同 endpoint / 同 endpoint 不同时间可能命名风格变化。`processInstanceId` 实际返回 `process_instance_id`（蛇形）。

### 修法

同时检查两种风格，回退到等价字段：

```ts
const instanceId =
  inst.processInstanceId ??
  inst.process_instance_id ??
  inst.formInstanceId;          // 宜搭里 form/process instance id 通常相同
```

### 一般规则

调任何外部 API（钉钉 / 宜搭 / SAP / Outlook / 第三方 SaaS）时：

- **不要假设字段命名风格**——文档写驼峰、实测可能蛇形
- **多重 fallback**：本字段 → 同义字段 → 默认值
- **字段访问全部 optional chaining + nullish coalescing**：`a?.b ?? a?.b_c ?? defaultValue`
- 抽 helper `pickField(obj, ['processInstanceId', 'process_instance_id'])` 在多处复用

### 上游字段稳定性测试

新接一个 source 时，**调 100 次 API 看返回**，统计字段出现频率 + 类型，写到模块文档 `<source>/data-model.md` 的"上游 API 字段实测"表：

| 字段路径 | 出现率 | 命名风格 | 类型 | 备注 |
|---|---|---|---|---|
| `processInstanceId` | 80% | 驼峰 | string | 文档说必出 |
| `process_instance_id` | 20% | 蛇形 | string | 同上字段不同 case |
| `formInstanceId` | 100% | 驼峰 | string | 跟 process 通常相同 |

参考 learning: [`.learnings/2026-04-04-dingtalk-sap-sync-retro.md`](../../../.learnings/2026-04-04-dingtalk-sap-sync-retro.md) §3

---

## 2. `upsert` 的 existing 分支必须**对称**写副作用（老数据漂移）

### 事故记录

正式版钉钉员工的"在职历史"列表为空，**新员工正常但老员工没有第 1 段**。

`employee-management.service.ts`：

```ts
// ❌ 不对称
if (existing) {
  await employeeRepo.update(...);
  if (wasTerminated) await addRejoinPeriod(...);   // 只覆盖再入职
  // 漏：existing 分支没有 ensureInitialPeriod
} else {
  await employeeRepo.create(...);
  if (parsed.joinDate) await ensureInitialPeriod(...);  // ✅ 新员工有
}
```

`ensureInitialPeriod` 是**后来加的副作用**。加之前创建的所有员工（正式版几乎所有人）永远走 `existing` 分支——**新逻辑从未对老数据生效**。

### 根因

upsert 的两个分支**不对称**：副作用只挂在 `else`（新建）侧。开发者写新 feature 时往往只想"新员工要补这个"，忘了"老员工每次同步也应该自检"。

### 修法

幂等的副作用应该在**两个分支**都调用：

```ts
// ✅ 对称
if (existing) {
  await employeeRepo.update(...);
  if (wasTerminated && parsed.joinDate) {
    await addRejoinPeriod(...);
  } else if (parsed.joinDate) {
    await ensureInitialPeriod(...);    // ← 新增：老员工兜底
  }
} else {
  await employeeRepo.create(...);
  if (parsed.joinDate) await ensureInitialPeriod(...);
}
```

### 一般规则（写新 upsert / 同步逻辑时）

每次给 upsert 加新副作用，**强制检查 4 件事**：

1. 新副作用是否**幂等**（重复调不出问题）？——大部分 `ensureXxx` / `syncXxx` 应该幂等
2. 老数据需不需要**回填**？如果是 → existing 分支也调
3. **新建分支 + existing 分支的副作用** diff 是否能用一句话解释？
4. 副作用调 list 是不是按"对称 + 一致 + 幂等"原则排序？

**反模式**：写新 feature 只在 `else` 加副作用，靠"下次新员工同步会触发"——**老员工永远走 `existing` 分支，永远不会被触发**。

参考 learning: [`.learnings/2026-04-30-upsert-existing-branch-side-effect-gap.md`](../../../.learnings/2026-04-30-upsert-existing-branch-side-effect-gap.md)

---

## 3. 同步表的派生字段必须前后端**双触发重算**

### 事故记录

用户在「在职历史」对话框里**新增停薪留职区间后，司龄数字不变**。对照：新增 / 编辑「在职段」时司龄正常变化。

### 根因（三层）

1. **后端 service 漏调重算**
   - `employee-management.service.ts` 的 `addSuspensionPeriod` / `updateSuspensionPeriod` / `deleteSuspensionPeriod` 三个方法**没调用** `calculateTenureDays(userId)` 把缓存写回 `dingtalk_employees.tenure_days`
   - 同文件的 `addEmploymentPeriod` 等都正确调了——属于"加并行表时漏接的半成品"

2. **`calculateTenureDays` 公式根本没读 `SuspensionPeriod` 表**
   - 即使后端 1 修了，重算结果也不变——停薪从未参与扣减
   - 设计漏洞：`SuspensionPeriod` 表存在但在司龄逻辑里是"摆设"

3. **前端 handler 没刷新司龄**
   - `employees/page.tsx` 的 `handleAddSuspension` 没在成功后调 `refreshTenure()`
   - employment 段的 3 个 handler 都调了——同样不对称

### 规则：派生字段三件事

任何会改变累计派生字段（司龄 / 年假 / 工时 / 累计奖金）的**关联表 CRUD**，必须做到：

1. **后端 service 层**：写入操作返回前 `await recalculate<Field>(userId)`，把缓存写回主表
2. **后端 recalculate 公式**：把新关联表读进去**实际参与计算**——否则触发了也只是空跑
3. **前端 handler**：CRUD 成功后调 `refresh<Field>()`，否则用户看到 dialog 内陈旧数据

**写新关联表时 checklist**：

- [ ] 该表是否影响主表派生字段？
- [ ] 后端 CRUD 三方法（add/update/delete）都调了 recalculate？
- [ ] recalculate 公式真把这张表读进去？
- [ ] 前端 CRUD handler 都调了 refresh？

参考 learning: [`.learnings/2026-05-06-dingtalk-tenure-suspension-double-loop.md`](../../../.learnings/2026-05-06-dingtalk-tenure-suspension-double-loop.md)（已 superseded，但**派生字段三件事模式仍适用**）

---

## 4. 业务规则的事实源不是上游系统行为

### 事故记录

司龄计算口径在 **48 小时内被改了两次**：

- 2026-05-07 PR #241：AI 自查"为什么停薪不扣司龄"后，把代码改成"停薪扣司龄"，写入文档、加了 5 个 L1 测试**锁公式**
- 2026-05-08：用户与 HR 二次确认，**停薪计入司龄**才是正确口径。整 PR **回退**

两次方向都"看起来合理"，但**正确答案只有 HR 知道**。

### 根因

把"钉钉自带的司龄字段扣停薪"当成业务规则的事实源——这是**钉钉 app 实现**，不是公司 HR 规则。**两者可以不同**。

AI 在没有可信业务来源时，倾向用"上游系统怎么做"或"代码注释怎么写"作为锚点；**这些都不是 HR 规则的事实源**。

### 规则：业务规则改动开工前必须三选一确认

业务规则类改动（司龄 / 年假 / 加班 / 考勤 / 薪酬 / 定价 / 税率 / 合规阈值）**开工前必须满足之一**：

1. `docs/modules/{module}/` 文档明写规则 + 来源（HR / 制度文件 / 确认日期 / 法规条款）
2. 用户在本次会话**明确确认**
3. 没有以上两条 → **停下问用户**，不要从代码现状 / 上游系统行为 / 注释推断

### 锁公式的集成测试只有规则被确认后才有意义

否则测试会把**错误口径**锁死，下次改方向时同样要回退。**先 HR 对口径 → 再写代码 → 最后加 L1 锁公式**——不要反过来。

### 二阶教训：fetch develop 再回答

AI 第一轮回答基于本地分支旧代码，没看到 PR #241 已合并 → 结论与现状脱节。**所有处理之前先检查是否拉的最新代码**——CLAUDE.md 已加该规则。

详见 [`backend-main/SKILL.md`](../SKILL.md) 守则段「业务规则改动开工前必须"三选一"确认事实源」。

参考 learning: [`.learnings/2026-05-08-dingtalk-tenure-confirm-source-with-hr.md`](../../../.learnings/2026-05-08-dingtalk-tenure-confirm-source-with-hr.md)

---

## 5. Entra ROPC：应用层 MFA bypass 的合规判断

### 背景

`feature/entra-ropc-fallback` 给登录加了第三条认证路径（cloud-only Entra 用户）。原 `case 'ENTRA'` 分支假设"Entra 与本地 AD 同步"，对 cloud-only 用户永远 401。新逻辑：

```
LOCAL → bcrypt
LDAP  → LDAP → (fail) Entra ROPC 兜底
ENTRA → Entra ROPC → (fail) LDAP 兜底 → (fail) 本地密码兜底
```

**ROPC（OAuth2 Resource Owner Password Credentials）行为上等同 LDAP bind**：后端 HTTPS POST 用户名密码到 Microsoft token endpoint，拿到 token 即视为验证通过。前端体验不变（**没有跳转、没有 MFA 弹窗**）。

### 关键决策：应用层 MFA bypass（`AADSTS50076` 当成功）

#### Free 版的硬约束

AIxCrypto 租户是 **Microsoft Entra ID Free**，**没有 Conditional Access 能力**——没法做"应用级排除 MFA"。Free 版下能控制 MFA 的粒度只剩两个：

- 租户级：Security Defaults（开/关）
- 用户级：Per-user MFA（每个用户 Enforced/Disabled）

**如果想"仅这个 app 不要 MFA、其它 Microsoft 服务保留 MFA"——Free 版做不到**。

#### 应用层 bypass 的具体做法

后端把 `AADSTS50076`（"User needs MFA"）当**成功**处理：

```ts
// 错误码 50076 = "User needs to perform MFA"
// 在 ROPC 流程里这表示用户密码对 + 但需要补 MFA 才能拿 token
// 我们应用层不需要 token，只需"密码对"这个事实 → 当成功
if (err.code === 'AADSTS50076') {
  // 当作密码验证通过
  return { authenticated: true };
}
```

### 合规权衡（决策记录）

**此设计为 AIxCrypto Entra Free 限制下的应用层折衷**。如果租户升级到 Entra ID P1+，可以走 **Conditional Access 应用排除**，**不再需要应用层 bypass**——届时移除此分支。

**文档要求**：任何引入此类"应用层绕过上游安全"的设计，必须在 PRD 写明：

- 上游限制（Free 版 / 版本 / 政策）
- 替代方案对比（升级 / 用其他 IdP / 应用层 bypass）
- 升级后下线计划

参考 learning: [`.learnings/2026-05-04-entra-ropc-it-setup.md`](../../../.learnings/2026-05-04-entra-ropc-it-setup.md)

---

## 6. SAP 内网服务的隧道结构（SSH 反向 + TCP 代理）

### 现象

SAP 服务在公司内网（`las-popci-01.faradayfuture.com:51000`），**公网服务器无法直接访问**。SSH `-R` 反向隧道直连 SAP 时 **connection reset**。

### 根因

SSH `-R` 反向隧道用 SOCKS 模式时跟 SAP HTTP 协议**握手不兼容**——SAP 端口是 **51000（HTTP）不是 443**，且 SAP 服务对 SSH tunneling 的某些行为有特殊响应。

### 解决：内网起 Python TCP 代理 + SSH `-R` 暴露代理端口

```
[公网服务器] →(SSH 反向隧道 -R 19443)→ [内网跳板机]
                                          └─(本地 Python TCP 代理 :19443)→ SAP HTTP :51000
```

1. **内网跳板机** 跑 Python TCP 代理（转发 :19443 → SAP :51000）
2. **SSH 反向隧道** 把跳板机 :19443 暴露到公网服务器 localhost
3. **公网服务器** 通过 `http://localhost:19443/<sap-path>` 访问

### 一般规则：内网 HTTP API 集成

对接公司内网 HTTP API（非 443、非标准 protocol）时：

| 方案 | 适用 |
|---|---|
| 直接 SSH `-R` | ⚠️ 仅 443 / SSH 友好协议（被 SAP 这类拒） |
| **内网 TCP 代理 + SSH `-R`** | ✅ 任何 TCP 协议 |
| VPN 连接 | ✅ 但是公网服务器装 VPN 客户端有运维负担 |
| API gateway 中间层 | ✅ 长期方案，把内网 API 暴露成公网 HTTPS |

**autossh 自愈 + systemd unit** 必须配，否则跳板机重启后隧道不自动恢复——参考 `docs/ops/06-sap-tunnel.md`。

### 隧道挂了的应急通路

如果 SAP 隧道挂掉、且开发机无法直连内网跳板机：**通过用户 Mac 反向 SSH 隧道**重新建立——`Linux 开发机 → :2222 Mac → :22 跳板机` 三跳。详见 [`mcp-remote-execution.md`](../../test-frontend/references/mcp-remote-execution.md) §6 / memory `reference_mac_bridge_to_intranet.md`。

参考 learning: [`.learnings/2026-04-04-dingtalk-sap-sync-retro.md`](../../../.learnings/2026-04-04-dingtalk-sap-sync-retro.md) §4

---

## 7. 写新 source 集成前置 checklist

接入新外部数据源（钉钉 / Entra / SAP / ADP / Outlook / 第三方 SaaS）前：

### 架构层

- [ ] 接入 `platform_automation` 同步中心（详见 [`02-backend-architecture.md`](../../../docs/standards/02-backend-architecture.md) §外部数据同步）
- [ ] 同步源数据表放在 `platform_automation` schema
- [ ] 不自建 `XxxSyncRun` / `XxxSyncLog` 表

### 上游 API 兼容层

- [ ] 字段访问全部 optional chaining + 多 fallback（蛇形 / 驼峰 / 同义字段）
- [ ] 抽 `pickField` helper 在多处复用
- [ ] 调 100 次实测，统计字段出现频率写到 `<source>/data-model.md` "上游 API 字段实测"段
- [ ] **不依赖文档**——文档可能跟实测不一致

### upsert 副作用对称

- [ ] 每次给 upsert 加新副作用，检查 4 件事（§2）
- [ ] 新建分支 + existing 分支的副作用 diff 用一句话能解释

### 派生字段三件事

- [ ] 影响主表派生字段的关联表 CRUD，后端 service / recalculate 公式 / 前端 handler 三方对齐（§3）

### 业务规则源头

- [ ] 业务规则有事实源（HR / 制度文件 / 法规 / 用户明示确认）—— 不能从上游系统行为推断（§4）
- [ ] 在 `<source>/01-prd.md` 写明规则 + 来源 + 确认日期
- [ ] 锁公式的 L1 测试**只在规则确认后才加**

### 凭据与安全

- [ ] 凭据走 `docs/ops/07-env-and-secrets.md` 规范，不进 git
- [ ] 应用层 bypass 上游安全（如 §5 MFA bypass）必须 PRD 写明权衡 + 升级后下线计划
- [ ] OAuth client secret / API key 5 处部署环境同步（`docs/ops/07-env-and-secrets.md` §2.1）

### 隧道与网络

- [ ] 内网 API 走 TCP 代理 + SSH `-R`（§6），不要直接 `-R`
- [ ] autossh + systemd 自愈
- [ ] 隧道挂了的应急通路文档化（Mac 反向 SSH / VPN 备用 / API gateway）

---

## 8. 相关 learning

- [`.learnings/2026-04-04-dingtalk-sap-sync-retro.md`](../../../.learnings/2026-04-04-dingtalk-sap-sync-retro.md)（钉钉 SAP 集成复盘，含 §1 Prisma schema / §3 宜搭字段 / §4 SAP 隧道）
- [`.learnings/2026-04-30-upsert-existing-branch-side-effect-gap.md`](../../../.learnings/2026-04-30-upsert-existing-branch-side-effect-gap.md)（upsert 副作用对称）
- [`.learnings/2026-05-06-dingtalk-tenure-suspension-double-loop.md`](../../../.learnings/2026-05-06-dingtalk-tenure-suspension-double-loop.md)（派生字段三件事，superseded by 司龄回退但模式适用）
- [`.learnings/2026-05-08-dingtalk-tenure-confirm-source-with-hr.md`](../../../.learnings/2026-05-08-dingtalk-tenure-confirm-source-with-hr.md)（业务规则源头三选一）
- [`.learnings/2026-05-04-entra-ropc-it-setup.md`](../../../.learnings/2026-05-04-entra-ropc-it-setup.md)（Entra ROPC + 应用层 MFA bypass）
- [`.learnings/2026-05-14-sap-tunnel-recovery-via-mac-bridge.md`](../../../.learnings/2026-05-14-sap-tunnel-recovery-via-mac-bridge.md)（SAP 隧道挂掉的应急通路）

## 配套阅读

- [`02-backend-architecture.md`](../../../docs/standards/02-backend-architecture.md) §外部数据同步（架构决策层，跟本文档实战层正交）
- [`docs/ops/06-sap-tunnel.md`](../../../docs/ops/06-sap-tunnel.md)（SAP 隧道运维）
- [`docs/ops/07-env-and-secrets.md`](../../../docs/ops/07-env-and-secrets.md) §2.1（凭据 5 处部署同步）
