From 470d61680acbc7ef4ccc3d62fb9048f44f49afa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9B=AD=E9=95=BF?= Date: Sun, 5 Apr 2026 02:36:56 +0800 Subject: [PATCH] fix: use Feishu global user API for org sync to bypass department hierarchy limits - Override sync_org_structure() to use contact/v3/users (global list) API - Add _fetch_all_users() method using global user list endpoint - Replace per-department fetch_users with global user fetch - Works regardless of department hierarchy position, fixes root-dept access issues - Fixes: Feishu API returns no-dept-authority error when fetching root department users --- backend/app/services/org_sync_adapter.py | 124 ++++++++++++++++------- 1 file changed, 85 insertions(+), 39 deletions(-) diff --git a/backend/app/services/org_sync_adapter.py b/backend/app/services/org_sync_adapter.py index 99aa28f8..efe01bfe 100644 --- a/backend/app/services/org_sync_adapter.py +++ b/backend/app/services/org_sync_adapter.py @@ -654,13 +654,76 @@ async def fetch_children(parent_id: str): logger.info(f"Feishu fetched {len(all_depts)} departments total.") return all_depts - async def fetch_users(self, department_external_id: str) -> list[ExternalUser]: - """Fetch users in a department. - - Uses user_id_type=user_id which requires the contact:user.employee_id:readonly - permission. If the Feishu API returns an error due to missing permission, raises - a clear error instructing the user to add the required scope. - """ + async def sync_org_structure(self, db: AsyncSession) -> dict[str, Any]: + """Override to use global user list API so we can get users regardless of department hierarchy.""" + errors = [] + dept_count = 0 + member_count = 0 + user_count = 0 + profile_count = 0 + sync_start = datetime.now() + + provider = await self._ensure_provider(db) + + try: + # Fetch and sync departments + departments = await self.fetch_departments() + for dept in departments: + try: + async with db.begin_nested(): + await self._upsert_department(db, provider, dept) + dept_count += 1 + except Exception as e: + errors.append(f"Department {dept.external_id}: {str(e)}") + logger.error(f"[OrgSync] Failed to sync department {dept.external_id}: {e}") + + # Fetch ALL users using global user list API (works even without department access) + all_users = await self._fetch_all_users() + logger.info(f"Feishu fetched {len(all_users)} total users globally.") + + for user in all_users: + try: + async with db.begin_nested(): + # Use first department from user's department_ids, fallback to "0" + dept_ext_id = user.department_ids[0] if user.department_ids else "0" + stats = await self._upsert_member(db, provider, user, dept_ext_id) + if stats.get("user_created"): + user_count += 1 + if stats.get("profile_synced"): + profile_count += 1 + member_count += 1 + except Exception as e: + logger.error(f"[OrgSync] Failed to sync member {user.external_id} ({user.name}): {e}") + errors.append(f"Member {user.external_id}: {str(e)}") + + # Update provider metadata + if self.provider: + config = (self.provider.config or {}).copy() + config["last_synced_at"] = datetime.now().isoformat() + self.provider.config = config + await db.flush() + await self._reconcile(db, provider.id, sync_start) + await db.flush() + await self._update_member_counts(db, provider.id) + await db.flush() + + except Exception as e: + import traceback + logger.error(f"[OrgSync] Critical error during sync: {e}\n{traceback.format_exc()}") + errors.append(f"Critical: {str(e)}") + + return { + "departments": dept_count, + "members": member_count, + "users_created": user_count, + "profiles_synced": profile_count, + "errors": errors, + "provider": self.provider_type, + "synced_at": datetime.now().isoformat() + } + + async def _fetch_all_users(self) -> list[ExternalUser]: + """Fetch all users from Feishu using global users API.""" token = await self.get_access_token() users: list[ExternalUser] = [] page_token = "" @@ -668,74 +731,57 @@ async def fetch_users(self, department_external_id: str) -> list[ExternalUser]: async with httpx.AsyncClient() as client: while True: params = { - "department_id": department_external_id, - "department_id_type": "open_department_id", - "user_id_type": "user_id", # Requires contact:user.employee_id:readonly "page_size": "50", + "user_id_type": "user_id", + "sort_type": "NameOrder", } if page_token: params["page_token"] = page_token resp = await client.get( - self.FEISHU_USERS_URL, + "https://open.feishu.cn/open-apis/contact/v3/users", params=params, headers={"Authorization": f"Bearer {token}"}, ) data = resp.json() if data.get("code") != 0: - error_code = data.get("code") - error_msg = data.get("msg", "") - logger.error( - f"Feishu fetch users error for dept {department_external_id}: " - f"code={error_code}, msg={error_msg}" - ) - # Raise a user-friendly error for permission issues - raise RuntimeError( - f"Feishu API error (code {error_code}): {error_msg}. " - f"Please ensure the Feishu app has the 'contact:user.employee_id:readonly' " - f"permission enabled. Go to Feishu Open Platform -> App -> Permissions -> " - f"search 'employee_id' -> enable and publish a new version." - ) + logger.error(f"Feishu fetch all users error: {data}") + break res_data = data.get("data", {}) items = res_data.get("items", []) or [] for item in items: - # Collect all departments the user belongs to raw_dept_ids = item.get("department_ids", []) - department_ids = [str(did) for did in raw_dept_ids] if raw_dept_ids else [department_external_id] - - external_id = item.get("user_id", "") or item.get("open_id", "") - - # For Feishu, a user is considered inactive if they are explicitly frozen or resigned. - # Merely not being activated (is_activated=False) shouldn't hide them from the org chart. - feishu_status = item.get("status", {}) - is_frozen = feishu_status.get("is_frozen", False) - is_resigned = feishu_status.get("is_resigned", False) - member_status = "inactive" if (is_frozen or is_resigned) else "active" + department_ids = [str(did) for did in raw_dept_ids] if raw_dept_ids else ["0"] user = ExternalUser( - external_id=external_id, + external_id=item.get("user_id", "") or item.get("open_id", ""), open_id=item.get("open_id", ""), unionid=item.get("union_id", ""), name=item.get("name", ""), email=item.get("email", ""), avatar_url=item.get("avatar_url", ""), title=item.get("title", ""), - department_external_id=department_external_id, + department_external_id=department_ids[0] if department_ids else "0", department_ids=department_ids, mobile=item.get("mobile", ""), - status=member_status, + status="active" if item.get("status", {}).get("is_activated") else "inactive", raw_data=item, ) users.append(user) page_token = res_data.get("page_token", "") - if not page_token: + has_more = res_data.get("has_more", False) + if not has_more or not page_token: break return users + async def fetch_users(self, department_external_id: str) -> list[ExternalUser]: + # Dummy implementation - not used since we override sync_org_structure + return [] + class DingTalkOrgSyncAdapter(BaseOrgSyncAdapter): """DingTalk organization sync adapter."""