from __future__ import annotations from .shared import * from .helpers_roles import _normalize_role_input, _pack_roles def _translate_user_integrity_error(exc: IntegrityError) -> str: message = str(getattr(exc, "orig", exc)).lower() if "phone_e164" in message or "ix_users_phone_e164" in message: return "手机号已绑定其他账号" if "users.username" in message or "username" in message: return "用户名已存在" if "users.email" in message or "email" in message: return "邮箱已存在" return "用户数据冲突,请检查用户名或邮箱是否重复" async def _collect_user_enrichment( db: AsyncSession, tenant_id: int, users: list[User], ) -> tuple[dict[int, float], dict[int, list[str]], dict[int, list[int]]]: if not users: return {}, {}, {} user_ids = [int(u.id) for u in users] workloads_result = await db.execute( select(UserWorkload).where( and_( UserWorkload.tenant_id == tenant_id, UserWorkload.user_id.in_(user_ids), ) ) ) workload_map: dict[int, float] = { int(w.user_id): float(w.workload_ratio or 0.0) for w in workloads_result.scalars().all() } skill_rows = await db.execute( select(UserSkillBinding.user_id, SkillTag.name, UserSkillBinding.proficiency) .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) .where( and_( UserSkillBinding.tenant_id == tenant_id, UserSkillBinding.user_id.in_(user_ids), ) ) .order_by(UserSkillBinding.user_id, UserSkillBinding.proficiency.desc()) ) top_skills: dict[int, list[str]] = {} for uid, skill_name, _prof in skill_rows.all(): key = int(uid) top_skills.setdefault(key, []) if len(top_skills[key]) < 3: top_skills[key].append(skill_name) project_rows = await db.execute( select(UserProject.user_id, UserProject.project_id).where( and_( UserProject.tenant_id == tenant_id, UserProject.user_id.in_(user_ids), ) ) ) project_map: dict[int, list[int]] = {} for uid, pid in project_rows.all(): project_map.setdefault(int(uid), []).append(int(pid)) return workload_map, top_skills, project_map def _serialize_user( user: User, workload_map: dict[int, float] | None = None, top_skills: dict[int, list[str]] | None = None, project_map: dict[int, list[int]] | None = None, ) -> dict: payload = user.to_dict() uid = int(user.id) payload["workload_ratio"] = (workload_map or {}).get(uid, float(user.workload or 0)) payload["top_skills"] = (top_skills or {}).get(uid, []) payload["project_ids"] = (project_map or {}).get(uid, []) return payload async def _replace_user_projects( db: AsyncSession, tenant_id: int, user_id: int, project_ids: list[int], ) -> None: existing_rows = await db.execute( select(UserProject.project_id).where( and_( UserProject.tenant_id == tenant_id, UserProject.user_id == user_id, ) ) ) existing_project_ids = {int(pid) for pid in existing_rows.scalars().all() if pid is not None} await db.execute( delete(UserProject).where( and_( UserProject.tenant_id == tenant_id, UserProject.user_id == user_id, ) ) ) unique_project_ids = sorted(set(project_ids)) for pid in unique_project_ids: db.add(UserProject(tenant_id=tenant_id, user_id=user_id, project_id=pid)) # bulk delete(UserProject) 不会触发 ORM 对象事件,这里手动注册缓存失效意图。 cache_payload = db.sync_session.info.get("_pmflow_cache_invalidation") if not isinstance(cache_payload, dict): cache_payload = { "tenant_ids": set(), "invalidate_system": False, "invalidate_project_list": False, "invalidate_signal_pending": False, "user_ids": set(), } db.sync_session.info["_pmflow_cache_invalidation"] = cache_payload cache_payload["tenant_ids"].add(int(tenant_id)) cache_payload["invalidate_system"] = True cache_payload["invalidate_project_list"] = True cache_payload["user_ids"].add(int(user_id)) affected_project_ids = sorted(existing_project_ids.union({int(pid) for pid in unique_project_ids})) for project_id in affected_project_ids: await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id) async def _create_user_with_defaults( db: AsyncSession, tenant_id: int, user_data: UserCreate, actor_id: int, *, commit: bool, ) -> User: username = user_data.username.strip() if not username: raise ValueError("用户名不能为空") existing_username = await db.execute( select(User).where( and_( User.tenant_id == tenant_id, User.username == username, ) ) ) if existing_username.scalar_one_or_none(): raise ValueError("用户名已存在") if user_data.email: existing_email = await db.execute( select(User).where( and_( User.tenant_id == tenant_id, User.email == user_data.email.strip(), ) ) ) if existing_email.scalar_one_or_none(): raise ValueError("邮箱已存在") phone_payload: dict[str, str | None] = { "phone_country_code": None, "phone_number": None, "phone_e164": None, } if user_data.phone_number: from ...services.phone_auth_service import normalize_phone phone_payload = normalize_phone(user_data.phone_country_code, user_data.phone_number) user = User( tenant_id=tenant_id, username=username, password_hash=AuthService.hash_password(user_data.password), name=(user_data.name or username).strip(), email=(user_data.email or f"{username}@placeholder.local").strip(), phone_country_code=phone_payload["phone_country_code"], phone_number=phone_payload["phone_number"], phone_e164=phone_payload["phone_e164"], role=_pack_roles(user_data.role), assigned_domains=user_data.assigned_domains or [], workload=0, status=UserStatus.ACTIVE.value, is_active=True, ) db.add(user) await db.flush() db.add( UserWorkload( tenant_id=tenant_id, user_id=int(user.id), current_assigned_hours=0.0, max_available_hours_per_week=40.0, workload_ratio=0.0, ) ) await skill_service.init_user_skill_bindings( db, tenant_id=tenant_id, user_id=int(user.id), include_system_only=True, commit=False, ) if user_data.project_ids: await _replace_user_projects( db, tenant_id=tenant_id, user_id=int(user.id), project_ids=user_data.project_ids, ) await log_operation( db, tenant_id=tenant_id, user_id=actor_id, project_id=None, entity_type="user", entity_id=str(user.id), action="create", before_data=None, after_data=user.to_dict(), ) if commit: await db.commit() await db.refresh(user) return user def _normalize_import_row(raw: dict[str, Any]) -> dict[str, Any]: lower_map = {str(k).strip().lower(): v for k, v in raw.items()} return { "username": str(lower_map.get("username", "")).strip(), "password": str(lower_map.get("password", "")).strip(), "name": str(lower_map.get("name", "")).strip(), "email": str(lower_map.get("email", "")).strip() or None, "role": str(lower_map.get("role", "BUILDER")).strip().upper(), "project_ids": _parse_int_list(str(lower_map.get("project_ids", ""))), } async def _iter_import_rows(file: UploadFile) -> list[dict[str, Any]]: filename = (file.filename or "").lower() content = await file.read() if filename.endswith(".csv"): text = content.decode("utf-8-sig") reader = csv.DictReader(StringIO(text)) return [dict(row) for row in reader] if filename.endswith(".xlsx") or filename.endswith(".xls"): try: from openpyxl import load_workbook # type: ignore except Exception as exc: raise HTTPException( status_code=400, detail=f"Excel import requires openpyxl: {exc}", ) wb = load_workbook(filename=BytesIO(content), read_only=True) sheet = wb.active rows = list(sheet.iter_rows(values_only=True)) if not rows: return [] headers = [str(v).strip() if v is not None else "" for v in rows[0]] data_rows: list[dict[str, Any]] = [] for row in rows[1:]: item = { headers[idx]: row[idx] for idx in range(min(len(headers), len(row))) if headers[idx] } data_rows.append(item) return data_rows raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported")