"""Server-side DingTalk auth-code exchange and local account upsert.""" import json import time from datetime import UTC, datetime import httpx from app.core.config import get_settings from app.core.exceptions import AppException from app.core.security import create_access_token from app.models.entities import OperationAudit, UserAccount from app.repositories.users import UserRepository from app.schemas.auth import DingTalkConfigData, TokenData from app.services.auth import AuthService TOKEN_URL = "https://oapi.dingtalk.com/gettoken" USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo" USERDETAIL_URL = "https://oapi.dingtalk.com/topapi/v2/user/get" DEPARTMENT_DETAIL_URL = "https://oapi.dingtalk.com/topapi/v2/department/get" class DingTalkServiceError(AppException): def __init__(self, message: str = "钉钉服务暂时不可用,请稍后重试") -> None: super().__init__(message, code=503, status_code=503) class DingTalkAuthService: _token_cache: tuple[str, float] | None = None def __init__(self, session) -> None: # noqa: ANN001 self.session = session self.repo = UserRepository(session) @staticmethod def config() -> DingTalkConfigData: settings = get_settings() if not settings.dingtalk_app_key or not settings.dingtalk_agent_id or not settings.dingtalk_corp_id: raise DingTalkServiceError("钉钉免登未配置,请联系管理员") return DingTalkConfigData( corp_id=settings.dingtalk_corp_id, app_key=settings.dingtalk_app_key, agent_id=settings.dingtalk_agent_id, ) async def login(self, auth_code: str) -> TokenData: settings = get_settings() if not settings.dingtalk_app_secret or not settings.dingtalk_app_secret.get_secret_value().strip(): raise DingTalkServiceError("钉钉免登未配置,请联系管理员") access_token = await self._app_access_token() user_id = await self._exchange_user_id(access_token, auth_code) detail = await self._user_detail(access_token, user_id) user = await self._upsert_user(user_id, detail) if user.status != "启用": raise AppException("用户账号已停用", code=401, status_code=401) permissions = await self.repo.permissions_for_role(user.role) permission_keys = sorted({item.func_key for item in permissions}) self.session.add( OperationAudit( actor_user_id=user.id, actor_emp_no=user.emp_no, action="auth.dingtalk_login", ) ) return TokenData( access_token=create_access_token(user.id, role=user.role), expires_in=settings.jwt_expire_minutes * 60, user=AuthService.to_user_data(user, permission_keys), ) async def _app_access_token(self) -> str: settings = get_settings() if self._token_cache and self._token_cache[1] > time.time() + 60: return self._token_cache[0] try: async with httpx.AsyncClient(timeout=8) as client: response = await client.get( TOKEN_URL, params={ "appkey": settings.dingtalk_app_key, "appsecret": settings.dingtalk_app_secret.get_secret_value(), }, ) response.raise_for_status() data = response.json() except httpx.HTTPError as exc: raise DingTalkServiceError() from exc if data.get("errcode") or not data.get("access_token"): raise DingTalkServiceError() self._token_cache = (data["access_token"], time.time() + int(data.get("expires_in", 7200))) return data["access_token"] async def _exchange_user_id(self, access_token: str, auth_code: str) -> str: try: async with httpx.AsyncClient(timeout=8) as client: response = await client.post( USERINFO_URL, params={"access_token": access_token}, json={"code": auth_code}, ) response.raise_for_status() data = response.json() except httpx.HTTPError as exc: raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401) from exc result = data.get("result") or data.get("user") or data.get("data") or {} user_id = ( result.get("userid") or result.get("userId") or result.get("unionid") if isinstance(result, dict) else None ) if data.get("errcode") or not user_id: raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401) return str(user_id) async def _user_detail(self, access_token: str, user_id: str) -> dict: try: async with httpx.AsyncClient(timeout=8) as client: response = await client.post( USERDETAIL_URL, params={"access_token": access_token}, json={"userid": user_id, "language": "zh_CN"}, ) response.raise_for_status() data = response.json() except httpx.HTTPError: return {"userid": user_id} detail = data.get("result") or data.get("user") or {"userid": user_id} if not isinstance(detail, dict): detail = {"userid": user_id} detail.setdefault("userid", user_id) dept_ids = self._department_ids(detail) if dept_ids and not self._department_name(detail): dept_name = await self._department_name_from_id(access_token, dept_ids[0]) if dept_name: detail["dept_name"] = dept_name return detail async def _department_name_from_id(self, access_token: str, department_id: int) -> str | None: try: async with httpx.AsyncClient(timeout=8) as client: response = await client.post( DEPARTMENT_DETAIL_URL, params={"access_token": access_token}, json={"dept_id": department_id, "language": "zh_CN"}, ) response.raise_for_status() data = response.json() except (httpx.HTTPError, ValueError): return None result = data.get("result") or data.get("department") or {} if not isinstance(result, dict): return None name = result.get("name") or result.get("dept_name") or result.get("deptName") return str(name).strip()[:50] if name else None @staticmethod def _department_name(detail: dict) -> str | None: value = detail.get("dept_name") or detail.get("deptName") or detail.get("department_name") return str(value).strip() if value else None @staticmethod def _department_ids(detail: dict) -> list[int]: raw = (detail.get("dept_id_list") or detail.get("deptIdList") or detail.get("department_ids") or detail.get("dept_id") or detail.get("main_dept_id") or detail.get("mainDeptId") or []) if not isinstance(raw, list): raw = [raw] result: list[int] = [] for value in raw: try: result.append(int(value)) except (TypeError, ValueError): continue return result @staticmethod def _display_name(detail: dict, fallback: str) -> str: value = detail.get("name") or detail.get("nick") or detail.get("nickname") or detail.get("real_name") or detail.get("display_name") or fallback return str(value).strip()[:50] or fallback async def _upsert_user(self, user_id: str, detail: dict) -> UserAccount: user = await self.repo.get_by_dingtalk_id(user_id) role = self.map_role( str(detail.get("dept_name") or detail.get("deptName") or ""), str(detail.get("title") or detail.get("position") or ""), self._is_dingtalk_manager(detail), ) if user is None: user = UserAccount( emp_no=f"dt_{user_id}"[:20], name=self._display_name(detail, user_id), role=role, status="启用", dingtalk_id=user_id, ) self.session.add(user) user.name = self._display_name(detail, user.name or user_id) user.dept = (self._department_name(detail) or user.dept or "")[:50] or None user.position = str(detail.get("title") or detail.get("position") or detail.get("job_title") or user.position or "")[:50] or None user.role = role user.dingtalk_union_id = str(detail.get("unionid") or user.dingtalk_union_id or "")[:64] or None user.dingtalk_avatar = str(detail.get("avatar") or user.dingtalk_avatar or "")[:512] or None user.dingtalk_job_number = str(detail.get("job_number") or user.dingtalk_job_number or "")[:64] or None user.dingtalk_title = str(detail.get("title") or user.dingtalk_title or "")[:128] or None dept_id = detail.get("main_dept_id") or detail.get("dept_id") or user.dingtalk_dept_id if isinstance(dept_id, list): dept_id = dept_id[0] if dept_id else None try: user.dingtalk_dept_id = int(dept_id) if dept_id is not None else None except (TypeError, ValueError): user.dingtalk_dept_id = None user.dingtalk_is_dept_manager = self._is_dingtalk_manager(detail) role_codes = detail.get("role_list") or detail.get("roleList") or user.dingtalk_role_codes if isinstance(role_codes, (list, dict)): role_codes = json.dumps(role_codes, ensure_ascii=False) user.dingtalk_role_codes = str(role_codes or "")[:512] or None user.last_login_at = datetime.now(UTC) user.dingtalk_id = user_id user.updated_at = datetime.now(UTC) await self.session.flush() return user @staticmethod def map_role(dept: str, title: str, is_admin: bool = False) -> str: """Map DingTalk users to admin (including review) or regular user.""" return "admin" if is_admin else "user" @staticmethod def _is_dingtalk_manager(detail: dict) -> bool: """Read administrator/department-leader flags from DingTalk API variants.""" direct = ( detail.get("admin"), detail.get("is_admin"), detail.get("isAdmin"), detail.get("is_leader"), detail.get("isLeader"), detail.get("is_boss"), detail.get("isBoss"), detail.get("is_dept_manager"), detail.get("isDeptManager"), ) if any(value is True or str(value).lower() in {"1", "true"} for value in direct if value is not None): return True leaders = detail.get("leader_in_dept") or detail.get("leaderInDept") or [] if isinstance(leaders, dict): leaders = [leaders] if isinstance(leaders, list): return any( item is True or str(item).lower() in {"1", "true"} or isinstance(item, dict) and any( value is True or str(value).lower() in {"1", "true"} for value in item.values() ) for item in leaders ) return False