"""合作方通讯录的每日同步任务。 一个任务同时干两件事(拉名册 + 打标),理由见 ``app.domains.partner_directory.service`` 的模块文档。 失败处理遵循本仓的定时任务约定:**handler 必须 raise**,由框架的 ``maybe_notify_failure`` 去 page IT。这里尤其重要 —— 这个任务失败最可能 的原因是对方把应用权限收了回去,而那种失败在飞书那边表现为"调用成功 但字段是空的",不是异常。所以任务自己要判:一次**一个人都没拉到**的 同步是失败,不是"今天他们没人"。 """ from __future__ import annotations import logging from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.scheduled_jobs.registry import register_scheduled_job logger = logging.getLogger(__name__) @register_scheduled_job( key="partner_directory.sync", display_name="合作方通讯录 · 每日同步与打标", description=( "只读拉取合作方飞书租户的员工名册,并用它给我们这边认不出人的" "账号打上归属标签(Google 孤儿账号 / 各厂商 external 坐席 → " "external_account_registry 的 external_guest;GitHub 上没关联到" "人的 handle → 已确认是外部人员)。只对无主行生效,不覆盖管理员" "的显式判断;弱匹配进待确认队列不自动写。" ), # 一次几百人的名册 + 三张表的候选扫描,分钟级、占着连接 —— 归重。 weight="heavy", ) async def run_partner_directory_sync( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.scheduled_jobs.handlers import run_with_lock_retry return await run_with_lock_retry( db, lambda session: _run_once(session, config), label="partner_directory.sync", ) async def _run_once( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.partner_directory import service trace_id = str(config.get("trace_id") or "partner_directory.sync") apply_tags = bool(config.get("apply_tags", True)) integrations = await service.list_partner_integrations(db) if not integrations: # 没配连接器不是失败,但也不能报成 "ok, 0 done" 混在成功里 —— # 那正是「配置从来没生效过」这类问题能藏一整个季度的地方。 return { "status": "skipped", "reason": "no enabled partner_directory integration configured", "integrations": 0, } results: list[dict[str, Any]] = [] failures: list[str] = [] for integration in integrations: try: results.append(await service.sync_one( db, integration=integration, trace_id=trace_id, apply_tags=apply_tags, )) except Exception as exc: # noqa: BLE001 — 汇总后统一 raise logger.exception( "partner_directory.sync: %s failed", integration.slug, ) failures.append( f"{integration.slug or integration.id}: " f"{type(exc).__name__}: {exc}", ) # 拉到 0 个人 = 失败。飞书在权限被收回时返回的是 code 0 + 空字段, # 所以没有异常可以依赖;唯一可靠的判据是"这一轮什么都没读到"。 empty = [ r["integration"] for r in results if r.get("roster", {}).get("pulled", 0) == 0 ] for slug in empty: failures.append( f"{slug}: 名册拉取返回 0 人 —— 多半是对方收回了通讯录权限" f"(飞书此时返回 code 0 而不是报错),或权限范围被改窄", ) incomplete = [ r["integration"] for r in results if not r.get("roster", {}).get("complete", True) ] summary = { "status": "ok" if not failures else "failed", "integrations": len(integrations), "results": results, "incomplete": incomplete, "failures": failures, } if failures: raise RuntimeError( "partner_directory.sync failed: " + "; ".join(failures), ) return summary