From 62bf336e10f1905d66ff8e23424703cc0df4df40 Mon Sep 17 00:00:00 2001 From: "nap.liu" Date: Wed, 8 Apr 2026 20:08:08 +0800 Subject: [PATCH] feat: cross-channel user auto-merge via mobile and email matching When a user messages via DingTalk bot, fetch their corp profile (mobile, email) from the DingTalk API and pass it to channel_user_service. This enables matching against existing users who registered via web SSO or other channels, preventing duplicate user records for the same person. Also enrich existing matched users with mobile/email/name from the channel API so future cross-channel lookups succeed. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/dingtalk.py | 32 +++++++++++++- backend/app/services/channel_user_service.py | 44 +++++++++++++++++++- backend/app/services/dingtalk_service.py | 32 ++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/backend/app/api/dingtalk.py b/backend/app/api/dingtalk.py index 9a431ecf2..7905e209e 100644 --- a/backend/app/api/dingtalk.py +++ b/backend/app/api/dingtalk.py @@ -177,13 +177,43 @@ async def process_dingtalk_message( # P2P / single chat conv_id = f"dingtalk_p2p_{sender_staff_id}" + # Fetch user detail from DingTalk corp API for cross-channel matching + extra_info: dict = {"unionid": sender_staff_id} + try: + cfg_r = await db.execute( + _select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == "dingtalk", + ) + ) + dt_config = cfg_r.scalar_one_or_none() + if dt_config and dt_config.app_id and dt_config.app_secret: + from app.services.dingtalk_service import get_dingtalk_user_detail + user_detail = await get_dingtalk_user_detail( + dt_config.app_id, dt_config.app_secret, sender_staff_id + ) + if user_detail: + dt_mobile = user_detail.get("mobile", "") + dt_email = user_detail.get("email", "") or user_detail.get("org_email", "") + dt_unionid = user_detail.get("unionid", "") + dt_name = user_detail.get("name", "") + extra_info = { + "unionid": dt_unionid or sender_staff_id, + "name": dt_name, + "mobile": dt_mobile or None, + "email": dt_email or None, + "avatar_url": user_detail.get("avatar", ""), + } + except Exception as e: + logger.warning(f"[DingTalk] Failed to fetch user detail for {sender_staff_id}: {e}") + # Resolve channel user via unified service (uses OrgMember + SSO patterns) platform_user = await channel_user_service.resolve_channel_user( db=db, agent=agent_obj, channel_type="dingtalk", external_user_id=sender_staff_id, - extra_info={"unionid": sender_staff_id}, + extra_info=extra_info, ) platform_user_id = platform_user.id diff --git a/backend/app/services/channel_user_service.py b/backend/app/services/channel_user_service.py index 56fd90d41..b71fdaf9b 100644 --- a/backend/app/services/channel_user_service.py +++ b/backend/app/services/channel_user_service.py @@ -70,6 +70,7 @@ async def resolve_channel_user( logger.debug( f"[{channel_type}] Found user via linked OrgMember: {user.id}" ) + await self._enrich_user_from_extra_info(db, user, extra_info) return user # Step 4: Try to find User by email/mobile from extra_info @@ -90,8 +91,9 @@ async def resolve_channel_user( f"[{channel_type}] Matched user by mobile: {user.id}" ) - # If found User by email/mobile, link OrgMember if exists (only for org-sync channels) + # If found User by email/mobile, enrich and link OrgMember if user: + await self._enrich_user_from_extra_info(db, user, extra_info) if channel_type in ("feishu", "dingtalk", "wecom"): if org_member and not org_member.user_id: # Existing shell OrgMember not yet linked → link it @@ -273,6 +275,46 @@ async def _find_existing_org_member_for_user( result = await db.execute(query.limit(1)) return result.scalar_one_or_none() + async def _enrich_user_from_extra_info( + self, + db: AsyncSession, + user: User, + extra_info: dict[str, Any], + ) -> None: + """Enrich existing user with mobile/email/name from channel extra_info. + + Only fills in fields that are currently empty on the user, to avoid + overwriting data the user may have set themselves. + """ + from app.models.user import Identity + + updated = False + name = extra_info.get("name") + mobile = extra_info.get("mobile") + email = extra_info.get("email") + avatar = extra_info.get("avatar_url") + + if name and not user.display_name: + user.display_name = name + updated = True + if avatar and not user.avatar_url: + user.avatar_url = avatar + updated = True + + # Enrich Identity-level fields (phone, email) if available + if user.identity_id and (mobile or email): + identity = await db.get(Identity, user.identity_id) + if identity: + if mobile and not identity.phone: + identity.phone = mobile + updated = True + if email and not identity.email: + identity.email = email + updated = True + + if updated: + await db.flush() + async def _create_channel_user( self, db: AsyncSession, diff --git a/backend/app/services/dingtalk_service.py b/backend/app/services/dingtalk_service.py index 010fc56a4..5eae0cda8 100644 --- a/backend/app/services/dingtalk_service.py +++ b/backend/app/services/dingtalk_service.py @@ -31,6 +31,38 @@ async def get_dingtalk_access_token(app_id: str, app_secret: str) -> dict: return {"errcode": -1, "errmsg": str(e)} +async def get_dingtalk_user_detail(app_id: str, app_secret: str, userid: str) -> dict | None: + """Fetch user detail from DingTalk corp API by userid (staff_id). + + Returns dict with mobile, email, org_email, unionid, name, etc. + Returns None on failure. + + API: https://open.dingtalk.com/document/orgapp/query-user-details + """ + token_result = await get_dingtalk_access_token(app_id, app_secret) + access_token = token_result.get("access_token") + if not access_token: + return None + + url = "https://oapi.dingtalk.com/topapi/v2/user/get" + async with httpx.AsyncClient(timeout=10) as client: + try: + resp = await client.post( + url, + params={"access_token": access_token}, + json={"userid": userid}, + ) + data = resp.json() + if data.get("errcode") == 0: + return data.get("result", {}) + else: + logger.warning(f"[DingTalk] user/get failed for {userid}: {data.get('errmsg')}") + return None + except Exception as e: + logger.warning(f"[DingTalk] user/get error for {userid}: {e}") + return None + + async def send_dingtalk_v1_robot_oto_message( app_id: str, app_secret: str,