# Zoom 运维手册

> 适用范围:Zoom Meetings 付费坐席的接入、日常同步、申请流程与坐席紧缺时的自动挤出
> 最近更新:2026-04-23(首版 — migration 051 上线 Zoom 集成 + `zoom.seat_sync` 定时任务 + 坐席不够时 LRU 自动挤出)

---

## 零、Zoom 集成在 Helix 里的全貌

```
 ┌───────────────────────────────────┐      ┌───────────────────────────────┐
 │ integrations                      │      │ asset_software_catalog        │
 │   slug = 'zoom-primary'           │◄─┐   │   name = 'Zoom'               │
 │   kind = software_provisioning    │  │   │   offboarding_policy          │
 │   provider = 'zoom'               │  │   │     = 'sso_cascade'           │
 │   secrets = {account_id, client_id│  │   │   sso_provider = 'google'     │
 │              client_secret}       │  │   └──────────┬────────────────────┘
 └───────────────────────────────────┘  │              │
                                        │              │
                           ┌────────────┴──────────┐   │
                           │ software_connectors   │   │
                           │   binding_config:     │◄──┘
                           │     plan_type = licensed
                           │     action_on_offboard = disassociate
                           └────────────┬──────────┘
                                        │
                           ┌────────────▼──────────┐
                           │ software_license_plans│
                           │   name = 'Zoom Licensed'
                           │   over_quota_policy   │
                           │     = 'evict_lru'     │   ◄── 坐席不够时挤出策略
                           │   max_seats           │   ◄── 由 zoom.seat_sync 回填
                           └──────────┬────────────┘
                                      │
                           ┌──────────▼────────────┐
                           │ software_seat_allocations
                           │ (每个用户一行)         │
                           └───────────────────────┘

        ▲                     ▲
        │                     │
 每天 03:00 UTC        坐席不够时 grant-time LRU eviction
 scheduled_jobs.zoom.seat_sync          (provisioning worker 自动触发)
```

**关键不变量**:
- `over_quota_policy='evict_lru'` 的计划不被请求路由器的配额检查拒绝(会跳到审批);真正的容量决策发生在 worker 分发 grant 任务时。
- Zoom 坐席由 `zoom.seat_sync` 负责镜像,离职本身不主动回收坐席(`sso_cascade` — Google SSO 一停用户自然进不去),付费坐席会在下次有新申请时被 LRU 自动挤出。
- `protected_roles`(默认 `global_admin` + `asset_admin`)对应的用户 + 配置中心 `zoom.seat_eviction.whitelist_emails` 里的邮箱**永远不会**被自动挤掉。

---

## 一、首次接入(本地 + 生产一样的步骤)

### 1. 获取 Zoom Server-to-Server OAuth 凭据

1. 以管理员登录 [Zoom Marketplace](https://marketplace.zoom.us/) → **Develop** → **Build App** → 选 **Server-to-Server OAuth**。
2. 给 App 命名(e.g. `Helix IT Platform`)→ 记下 **Account ID / Client ID / Client Secret**。
3. **Scopes** — 至少勾(Zoom 新 UI 把 scope 拆得很细,**一个都不能少**,否则会走到一半 400):
   - `user:read:list_users:admin` — 列全量用户。zoom.seat_sync + LRU 挤出都依赖。
   - `user:read:user:admin` — 查单个用户详情。grant 时 409 补登、挤出时补取 last_login_time。
   - `user:write:user:admin` — 创建新 Zoom 用户(员工从未用过 Zoom 的情况)。
   - **`user:update:user:admin`** — 把现有 Basic 用户**升档**成 Licensed。这是最容易漏的一个,不给的话老员工升档全 400。
   - `user:delete:user:admin` — 离职 / LRU 挤出时 disassociate 用户。
   - `account:read:account_plan:admin` 或 `account:read:admin`(读订阅计划,用来自动回填 `max_seats`;不给不影响核心流程,只是 `max_seats` 需要在 `/admin/license-plans` 手动填)

   加完 scope 一定要点 **Deactivate → Activate** 重新激活 App,否则后端的 access token 还是旧 scope 集(Helix token cache 只按 `expires_in` 到期才重取,默认 1h)。
4. 激活(Activate)该 App。

### 2. 把凭据写到 Helix

**方法 A — 跑一次脚本(推荐,幂等、可重复)**:

```bash
# 本地
cd server && uv run python scripts/seed_zoom_integration.py --probe --enable

# 生产 (AWS EC2 或 K8s pod)
export HELIX_ZOOM_ACCOUNT_ID=XXXXXXXXXX
export HELIX_ZOOM_CLIENT_ID=YYYYYYYYYY
export HELIX_ZOOM_CLIENT_SECRET=ZZZZZZZZZZ
uv run python scripts/seed_zoom_integration.py --probe --enable
# --probe 先调一次 Zoom test_connection 验证凭据
# --enable 把 integration 行 enabled 置为 true(第一次必须带)
```

执行后输出 `✓ Zoom test_connection OK · N active users on account` 就算成功。

**方法 B — 走 UI**:
进入 `/admin/integrations` → 找到 `Zoom`(migration 051 已经 seed 好)→ 点**编辑** → 填 Account ID / Client ID / Client Secret → **测试连接**绿色后启用。

### 3. 打开定时同步

进入 `/admin/scheduled-jobs` → 搜 `zoom.seat_sync` → 开关拨到**启用**。
- 触发器:`cron 0 3 * * * (UTC)` — 凌晨 3 点,国内 11 点,避免高峰时段影响 Zoom API quota。
- 手动立即跑:列表行右侧「立即执行」。

### 4. 确认初始化

同步跑完后查看 `/admin/scheduled-jobs/runs?name=zoom.seat_sync` 最近一次运行的 summary,字段含义:

| 字段 | 意义 |
|------|------|
| `zoom_users_listed` | Zoom 上活跃用户总数(全档位) |
| `licensed_users_on_zoom` | 其中 type=2(Licensed)的用户数 |
| `seats_imported` | 本次新建的 `software_seat_allocations` 行数(之前没镜像的老用户) |
| `seats_revoked` | 本次标记 revoked 的老行(用户在 Zoom 已不再持有 Licensed) |
| `max_seats_updated` | 是否把 Zoom 订阅里的付费主机数回写到了 `SoftwareLicensePlan.max_seats` |
| `max_seats_after` | 回写后的 max_seats 值 |
| `errors` | 本次跑出的逐用户错误(不会让整个任务失败) |

**健康信号**:第二次运行起 `seats_imported=0 seats_revoked=0` 应该是常态。

---

## 二、员工申请 Zoom 付费坐席的完整链路

### 申请方(员工)

1. 打开 `/catalog/software` → 点**新建**。
2. 选「软件 = Zoom」、「计划 = Zoom Licensed」、写用途 → 提交。
3. 飞书里会收到审批流程(`software_seat` 模板),走正常审批链。

### 系统侧(审批通过之后)

```
飞书「同意」按钮
   │
   ▼
POST /api/v1/approval/feishu/callback
   │
   ▼
ApprovalService._activate_software_seat
  · SoftwareSeatAllocation.status: pending → active
  · 调 enqueue_grant_for_seat → 创建 ProvisioningTask(action=grant)
   │
   ▼
定时调度 (scheduled_jobs.provisioning.dispatch,每 1 分钟)
   │
   ▼
ProvisioningService.execute_task
  └── _invoke_connector
        ├── _maybe_evict_for_zoom  ◄── 新增:只对 over_quota_policy='evict_lru' 的计划跑
        │     └── zoom_seat_coordinator.ensure_seat_capacity
        │           · 查 plan.max_seats
        │           · 如果 active seats > max_seats,列 Zoom 用户,
        │             找出 last_login_time 最早且不在白名单的人
        │           · 调 driver.revoke_seat(disassociate)
        │           · 把对应 Helix 行翻成 revoked
        │           · 发邮件通知被挤用户
        └── driver.grant_seat(ctx)
              · Zoom 侧创建用户(或升级现有 Basic → Licensed)
              · 成功后 _post_success_side_effects 不动 seat(已经是 active)
```

> 全流程异步。飞书回调只做轻活(翻状态 + 建 task);真正调 Zoom API 在 worker 里跑,不会阻塞 webhook。

### 被挤用户

收到邮件(模板 `email_template.zoom.seat_evicted`,可在 `/admin/configs` 编辑),内容包含:
- 为什么被挤(自己是最久没登录的付费用户)
- 影响面(Zoom Basic 仍可用 — 40 分钟以内会议,录制/历史不丢)
- 如何重新申请(指向 `/catalog/software`)

---

## 三、坐席不够时谁会被挤

**排序逻辑**(`zoom_seat_coordinator._pick_eviction_victim`):

1. **从不登录的用户优先被挤**(`last_login_time` 为空)。
2. 在有登录记录的用户里,**最早的那次登录**优先被挤。
3. 不计入候选的人(永远安全):
   - 配置中心 `zoom.seat_eviction.whitelist_emails` 里列出的邮箱(默认空列表)
   - `zoom.seat_eviction.protected_roles` 列出的角色对应的活跃用户(默认 `[global_admin, asset_admin]`)
   - **本次被审批通过的新申请人**(显然不能把他自己挤掉)

**调整白名单**:

进入 `/admin/configs` 或直接 SQL:

```sql
-- 加一个永远保护的邮箱
UPDATE platform_settings
SET value = JSON_ARRAY_APPEND(COALESCE(value, JSON_ARRAY()), '$', 'ceo@pingcap.com')
WHERE `key` = 'zoom.seat_eviction.whitelist_emails';

-- 调整保护角色(改默认两个角色以外的集合)
UPDATE platform_settings
SET value = JSON_ARRAY('global_admin', 'asset_admin', 'hr', 'finance')
WHERE `key` = 'zoom.seat_eviction.protected_roles';
```

---

## 四、离职员工坐席的回收

> 注(2026-07-24):启用第九节的**账号生命周期目标**后,离职会由数据同步侧**主动停用** Zoom 账号(`PUT /users/{id}/status deactivate`),这会顺带释放付费坐席——比下面的 `sso_cascade` 被动路径更实。本节描述的是坐席流自身的行为,仍然有效(两条线互补)。

- 坐席流本身**不**在离职瞬间主动调 Zoom 取消坐席(`offboarding_policy='sso_cascade'`)。
- 用户 Google SSO 被停用后立即无法登录 Zoom,但 Zoom 账号下仍占一个付费坐席。
- 下次有新人申请 Zoom 坐席:
  - `zoom.seat_sync` 每天凌晨会把 Helix 侧的 `SoftwareSeatAllocation` 标记为 revoked(检测到该用户已不是 Licensed)—— 这需要在 Zoom 上手动 disassociate 离职用户之后才生效,或者等下面的 LRU eviction 自动触发。
  - 或者 worker 跑 grant 时发现坐席不够,LRU 找上这位离职员工(他的 `last_login_time` 一定是最早的),自动 disassociate 他并把坐席让给新人。

> 想更激进的回收:把 `asset_software_catalog.offboarding_policy` 从 `sso_cascade` 改为 `auto_revoke`,T+0 force-transfer 会用连接器 `revoke_seat` 在离职当天就把 Zoom 坐席释放。这会走飞书通知 + 审计日志,比 SSO cascade 更显式。本地 SQL:
>
> ```sql
> UPDATE asset_software_catalog SET offboarding_policy = 'auto_revoke' WHERE name = 'Zoom';
> ```

---

## 五、日常巡检 / 故障排查

| 症状 | 先查 | 常见原因 |
|------|------|----------|
| `/admin/integrations` Zoom 行显示 disabled | `integrations.enabled` 字段 | 跑过 migration 但没执行 `seed_zoom_integration.py --enable`,凭据没进库 |
| 某次 `zoom.seat_sync` run 全量报 401 | `/admin/integrations` → Zoom → 点「测试连接」| Client Secret 轮换过 / S2S App 被 Deactivate |
| summary 里 `max_seats_updated=false` 且 `max_seats_after=null` | Zoom App 的 scopes | 缺 `account:read:admin` — 不影响坐席镜像,只是 max_seats 要手动填 |
| seat 自动挤出没触发,worker 报 Zoom 400 quota | `SoftwareLicensePlan.max_seats` | max_seats 为 NULL 表示「无限」—— 手动填一个(或加 Zoom scopes 让 sync 回填) |
| 被挤的人没收到邮件 | `notification_outbox` WHERE related_resource_id=<alloc_id> | SMTP 配置 / 模板格式变量不全 |
| 审批通过后 grant 始终 failed,错误是 `no enabled Zoom integration` | `integrations.enabled` + `provider='zoom'` | integration 被禁用了但计划仍是 `evict_lru` — 要么启用,要么把计划改回 `reject` |

---

## 六、快速 SQL 检查

```sql
-- 当前 Zoom 坐席占用 / 配额
SELECT p.name, p.max_seats, p.over_quota_policy,
       COUNT(CASE WHEN sa.status='active' THEN 1 END) AS active_seats,
       COUNT(CASE WHEN sa.status='pending' THEN 1 END) AS pending_seats
FROM software_license_plans p
LEFT JOIN software_seat_allocations sa ON sa.plan_id = p.id
JOIN asset_software_catalog c ON c.id = p.software_id
WHERE c.name = 'Zoom'
GROUP BY p.id;

-- 最近 10 次 Zoom 坐席挤出(审计日志)
SELECT r.created_at, r.action, r.detail->>'$.plan_id' AS plan_id,
       r.detail->>'$.user_id' AS new_user_id
FROM audit_logs r
WHERE r.action = 'provisioning.auto_api_success'
  AND r.detail->>'$.action' = 'grant'
  AND JSON_CONTAINS_PATH(r.detail, 'one', '$.zoom_eviction')
ORDER BY r.created_at DESC LIMIT 10;

-- 保护名单现状(code-default + config-center)
SELECT value AS whitelist_emails FROM platform_settings
  WHERE `key` = 'zoom.seat_eviction.whitelist_emails';
SELECT value AS protected_roles FROM platform_settings
  WHERE `key` = 'zoom.seat_eviction.protected_roles';
```

---

## 七、凭据轮换

Client Secret 泄露 / 定期轮换流程:

1. 在 Zoom Marketplace 重新生成 Client Secret。
2. 本地或 prod `export HELIX_ZOOM_CLIENT_SECRET=<新值>`(其余两项不变)。
3. 重跑 `uv run python scripts/seed_zoom_integration.py --probe`(不加 `--enable` 保持原启用状态)。
4. 看到 `✓ Zoom test_connection OK` 就完事了 — 底层用的是 AES-GCM 加密,脚本会整包替换密文;旧 Secret 立即失效。

---

## 九、账号生命周期同步(数据同步目标,2026-07-24 新增)

除了上面的「付费坐席」链路,Zoom 现在还有一条**账号生命周期**链路,挂在数据同步中心(`/admin/identity-sync`)里,作为一个正经的同步目标 `zoom`(slug `zoom-account-lifecycle`)。两条线**互补、不冲突**:

| | 坐席流(本文档前八节) | 账号生命周期(数据同步目标) |
|---|---|---|
| 管什么 | 付费 **Licensed** 坐席(按申请授予 + LRU 挤出) | 全员**基线账号**(`no_meetings` / type 4)的建/关 |
| 入口 | `/catalog/software` 审批流 | 入职事件即时 + 每日 sweep |
| 建号 | 审批通过 → 建/升 Licensed | in-scope 员工缺号 → 建 **no_meetings**;**已有用户绝不改档位** |
| 关号 | `sso_cascade` / LRU 被动 | 离职/出范围 → **主动停用**(`PUT /users/{id}/status deactivate`) |
| 凭据 | `zoom-primary` S2S OAuth | **同一个** `zoom-primary`(无需另配) |

> ### ⚠️ 档位那件事:Basic 不是免费无限档(2026-07-30 修正)
>
> Zoom 的档位数字与直觉相反,踩过一次一个月的坑:
>
> | type | Helix 里的名字 | 含义 | 本租户现状(2026-07-29 快照) |
> |---|---|---|---|
> | 1 | `basic` | Basic —— **按租户限量**,本租户只有 **20 个名额** | **20 个,已坐满** |
> | 2 | `licensed` | 付费 Licensed 坐席 | 190 个 |
> | 4 | `no_meetings` | 有账号、无 Meetings 许可(能登录/协作/参加别人的会) | **397 个活跃 —— 绝大多数员工在这** |
> | 99 | `none` | 占位,不可用 | 0 |
>
> 建号时如果 Basic 名额满了,Zoom 返回 `400 / code 3412`:*"reached the permitted maximum number of 20 basic users"*。**2026-07-24 首次建号全量跑 17 人被拒 16**(只有 `julia@` 抢到最后一个名额),目标随后被调成 `write_mode=update_only` 止损,411 个 in-scope 员工全部 skipped —— 一个月后有人反馈新入职的 `bin.ye@pingcap.com` 没有 Zoom 账号才被发现。
>
> 所以:**给全员发基线账号请用 `no_meetings`,不要用 `basic`**。`basic` 只留给坐席流「Licensed 降级释放付费许可」那条路(managed-domain 租户唯一的释放方式)。
>
> 撞上 3412 时现在的表现:该员工记 `blocked`(不是 `failed` —— 重试不会自愈),`last_error` 带 `[ZOOM_BASIC_CAP_EXCEEDED]` + 中文补救提示 + **实时 Basic 计数**,并且**同一次 run 里后续的人直接 blocked、不再发注定被拒的请求**;`alert_recipient_ids` 会收到一张「N 人被下游拒绝待处理」的卡片(去重:同一批人只提醒一次)。

**为什么分两条**:付费坐席是「按需权益」,基线账号是「身份生命周期」,不同层。账号生命周期目标 `upsert` 对已有用户**永不动 type**,所以坐席流授的 Licensed 在同步时不会被降级。

**离职语义**:目标 `on_offboard=deactivate`(停用,保录制/会议、释放坐席、managed-domain 也可停),`supports_delete=False`(彻底删除仍走坐席流 `revoke_seat action=delete`)。

**与 Authing / SSO 的关系**:此目标取代了已下线的 Authing → Zoom 账号供给链路;**Zoom 登录仍走 Google SSO**,此目标绝不修改 Zoom 的 SAML/SSO 配置。

**上线开关**:migration 428 已 seed 该目标(`enabled=0`)。建号是**对外可见**动作 —— Zoom 会给每个被建号的人发一封邀请邮件,所以存量补建有限速:单次 sweep 最多建 `runner_options.zoom_max_creates_per_run` 个(默认 **25**),超出的人记 `blocked` + `zoom_create_budget_exhausted` 并在下一次 sweep 继续,~400 人的存量按天匀速排空(这类「自愈型」blocked **不会**告警)。

1. 确认 `zoom-primary` 集成已启用(见本文档一、二节)。
2. `/admin/identity-sync` → 找到「Zoom Meetings 账号生命周期」目标 → 需要的话先收窄 `filter_config`(默认是全体在职 `^P\d+$` 员工)。
3. 填 `alert_recipient_ids`(告警收件人 —— 失败告警**和** blocked 积压卡片都发这个列表,空列表 = 谁都不发,没有管理员兜底)。
4. 确认 `runner_options.zoom_plan_type = no_meetings`(见上面的档位警告框;`basic` 会撞 20 名额上限)。
5. 拨到**启用** → 观察首次 run 的 `created` / `blocked` / `deactivated` 计数。

**`write_mode=update_only` 的目标要开建号闸,用「锁人灰度」验证**(不要指望「同步此人」能绕过):

> ⚠️ **「同步此人」不能用来做 update_only 下的验证**。`force_in_scope` 只绕过 **filter**,**从不绕过 write_mode**([service.py](../../server/app/domains/identity_sync/service.py) 的 write-mode gate,#1736 起单人路径与 sweep 对齐)—— `update_only` + 未链接 = 一律 `skipped`,不管你是 cron、事件还是手动点的。所以灰度要靠 `filter_config.person_ids` 锁人,和 `feishu-contact` 当年全员开闸前一样的做法。

1. **一次改三项**:`zoom_plan_type` → `no_meetings`、`filter_config.person_ids` → `["<待验证的人的 person_id>"]`(名单外的人直接不在 scope)、`write_mode` → `create_update`。此时建号闸虽然开了,但 scope 里只有 1 个人。
2. **跑一次手动全量** → 期望 `persons_in_scope=1, created=1`。去 Zoom 后台或 `GET /users/{email}` 核对该账号 `type=4`、`status=active`。
3. **清空 `filter_config.person_ids`** → 恢复全员 scope。之后每日 cron 按 `zoom_max_creates_per_run`(默认 25)排空存量,新入职当天由 hire 事件即时建号。

**顺序反了会怎样**:先把 `write_mode` 放回 `create_update` 而没改档位 —— 立刻按 `basic` 对存量发起建号,第一个人就 3412、同 run 跳闸、全批 blocked,白跑一轮(不会有真实损害,但会给收件人发一张 blocked 卡片)。

**健康信号**:稳态起 `created=0`(存量已建完),只有新入职当天出现 `created=1`;离职当天出现 `deactivated=1`。排空存量期间 `created≈25 + blocked=剩余人数` 是**正常**的,`blocked` 数字应该每天变小 —— 若不再变小,查 `blocked` 行的 `last_error`(大概率不是配额,而是真的容量/数据问题)。

### 九·一、回收离职员工占用的坐席(一次性清历史 + 每日兜底)

离职的人如果没被关号,会一直占着付费 **Licensed** 坐席。Zoom 已接入既有的**离职回收对账机**(`leaver_revocation_reconcile`),清理有两条路:

**A. 每日自动兜底(目标启用后)**:`zoom` 目标 `enabled=1` 后,每日 cron `identity_sync.leaver_revocation_reconcile`(03:25 UTC)会:刷新 Zoom 快照 → 找出快照里**匹配到 Helix `employment_status='left'`、且不在保留名单**的账号 → **Deactivate**(释放 License)。护栏:仅 `left`(非 leaver_pending)、保留名单豁免、快照晚于离职日、逐行提交、批量上限。

**B. 一次性清历史欠账(可在目标仍 `enabled=0` 时跑,不触发建号 sweep)**:

`revoke_leavers_on_target` 不看 enabled,可对禁用目标直接跑,分**预览**和**执行**两步(prod 走 SSM → `docker exec compose-backend-1`):

```python
# 1) 只读刷新 Zoom 快照(对 Zoom 纯读)
#    POST /api/v1/identity-sync/targets/{id}/vendor-snapshot/refresh  或:
await svc.refresh_vendor_snapshot(target_id)

# 2) 预览:谁会被停用(dry_run,不调 Zoom 写)——这就是「匹配清单」
res = await svc.revoke_leavers_on_target(target, dry_run=True)
#   res["candidates"] / res["would_revoke"] = [{email, external_key, employee_no, push_code}, ...]

# 3) 人工核对确认后执行:Deactivate 释放坐席
res = await svc.revoke_leavers_on_target(target, dry_run=False)
#   res["revoked"] / res["failed"] / res["errors"]
```

**只关 Helix 明确已离职的人**;查无匹配的 Zoom 号(服务号/外部访客/保留号)**不动**——需要单独人工判断。停用是可恢复的(Zoom 侧可重新 activate)。

源码:runner `server/app/domains/identity_sync/runners/zoom.py`;驱动新增原语 `ZoomSoftwareDriver.create_user` / `set_user_status`(`server/app/integrations/drivers/zoom_software.py`);快照采集器 `IdentitySyncService._collect_zoom_records`;seed migration `server/alembic/versions/428_seed_zoom_identity_sync_target.py`。

---

## 十、离职账号两段式停用 + 永久删除(2026-08-04,对标 Google)

> 目的:Zoom 号「只增不减」。九·一 的对账机只**停用**(账号仍占位,逼近账户总用户上限),且主路径对约 397 个基线 `no_meetings` 在职者是静默 no-op(external_id 为空)。本机制补上**终态删除**,并从根上绕开 external_id 盲区(**按 `work_email` 寻址**)。

**两段式(与 Google 完全一致)**:

1. **末日次日停用**:离职流程走到 `_push_force_logout` 时(caller 闸 `local_today > leaver_date`),经 `account_lifecycle_coordinator.suspend_zoom_account` 停用账号,落 `session_enforcement.zoom_account = {status: suspended}`。保留号(`external_account_registry` 服务/共享/外部)豁免。
2. **leaver_date + `delete_lead_days`(默认 2,员工本地时区)永久删除**:每日 cron **`hr.offboarding_zoom_account_delete`(03:10 UTC)** 扫已 COMPLETED 流程,对**已确认停用**且非保留号调 `delete_user_unconditional`(`DELETE ?action=delete`)。幂等键 `session_enforcement.zoom_account_deletion`(deleted/already_absent/retained 为终态)。

**⚠️ 不可逆**:Zoom 删除**永久、无 undelete**(不像 Google 有 ~20 天恢复窗口)——停用→删除的 2 天间隔即唯一纠错窗口。要放宽只需调 cron 的 `config.delete_lead_days`(更长=更大纠错窗)。

**数据交接(2026-08-13 起,推翻了最初的「无脑删全部、无内容转交」)**:删除调用带 `transfer_email` + `transfer_meeting/webinar/recording`,交接人解析链为「流程默认交接人 → 直属 lead → lead 的上级链(≤5 层)→ 平台设置 `offboarding.default_handover_fallback_person_id`」。**解析不出交接人就不删**(账本 `skipped_no_successor`,单次告警 IT)。

**Zoom 拒绝收件人时怎么办(2026-09-06)**:Zoom **先校验 `transfer_email` 再决定删不删**,收件人不合格则整个调用被拒(HTTP 400 + `code: 200`),账号原封不动。两种分流:

| 账本 `zoom_account_deletion.status` | 含义 | Helix 的动作 | 运维要做的 |
|---|---|---|---|
| `deleted` + `data_transfer: skipped_no_webinar_license` | 收件人存在但没有 Webinars 许可 | 不带转移重删一次(运营决定;审计记 `offboarding.zoom_account_deleted_without_transfer`) | 无(**注意该员工的 webinar / 云录制已随号消失**) |
| `blocked_transfer_recipient` | 收件人 Zoom 不接收 —— 基本都是交接人在 Zoom 里**没有账号** | **拒绝删除**,账号保持停用;计入 `blocked_transfer_recipient` 计数器(不进 `errors`,cron 仍是绿的);按 `del_blocked_zoom_recipient` **只告警一次** | 在离职流程上改选一位**有 Zoom 账号**的交接人(或给现交接人开号);下一跳自动完成,**不需要补跑** |

判断某人有没有 Zoom 账号:查 `identity_sync_vendor_records` 中 Zoom 目标的 `email` 快照(每日刷新)。2026-09-06 抽查:416 名在职者中 6 人无 Zoom 账号。

**闸门**:删除只对确认已停用的账号执行(永不领先于停用);返聘/活跃/未到期/人已删/保留号跳过;逐流程行锁 + 逐条 commit;`errors` 非空 → run 落 `failed`(失败通知触发)。

**误判离职还原**:取消离职流程时,`zoom_account=suspended` 计入 `already_advanced` 确认闸(需 `confirm_irreversible`),router 提交后调 `unsuspend_zoom_after_cancel` 重新激活——重新入职的人不会被锁死在 Zoom 外。

**存量注意**:已离职的历史号**不会被本机制追溯清理**(它们从未走过新停用闸,ledger 无 `zoom_account=suspended`)。历史清理仍走九·一 的一次性对账(停用)或人工删除。

**巡检**:
```sql
-- 近期两段式处置进度
SELECT JSON_EXTRACT(session_enforcement,'$.zoom_account.status') AS suspend_state,
       JSON_EXTRACT(session_enforcement,'$.zoom_account_deletion.status') AS delete_state,
       COUNT(*)
FROM offboarding_processes
WHERE session_enforcement IS NOT NULL
GROUP BY 1,2;
```
cron 首次上线在生产是 `enabled` 的(seed 迁移 451),但只对**新离职者**(走过末日停用)生效,故无追溯性批量删除风险;首日观察 `scheduled_job_runs` 里该 job 的 `last_result_summary`(scanned/deleted/skipped/errors)。

源码:coordinator `server/app/core/coordinators/account_lifecycle_coordinator.py`(`suspend_zoom_account` / `unsuspend_zoom_account` / `delete_zoom_account`);sweep `OffboardingService.sweep_zoom_account_deletions` / `_delete_zoom_account_if_due`;handler `server/app/scheduled_jobs/handlers.py::run_offboarding_zoom_account_delete`;seed 迁移 `server/alembic/versions/451_seed_offboarding_zoom_delete_job.py`。

---

## 八、依赖文档

- `docs/architecture/PLATFORM.md` — 集成框架 + 调度框架全貌
- `docs/architecture/DECISIONS.md` — 坐席额度 / 挤出策略 / 离职策略的决策记录
- `server/app/integrations/drivers/zoom_software.py` — Zoom 驱动源码
- `server/app/core/coordinators/zoom_seat_coordinator.py` — 挤出 + 受保护名单的实现
- `server/app/scheduled_jobs/zoom_handlers.py` — 定时同步的 handler
- `server/alembic/versions/051_seed_zoom_integration.py` — 一把 seed 的 migration
