"""用户管理 API。""" from __future__ import annotations import csv from datetime import datetime from io import BytesIO, StringIO from typing import Any, Optional from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from pydantic import BaseModel from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..database import get_db from ..dependencies import ( get_current_active_user, get_current_user, get_tenant_id, require_roles, require_self_or_manager, ) from ..models import ( SkillTag, Task, User, UserProject, UserRole, UserSkillBinding, UserStatus, UserWorkload, ) from ..schemas import ( UserCreate, UserResetPasswordRequest, UserResponse, UserStatusPatchRequest, UserUpdate, ) from ..services.auth import AuthService from ..services.ai_pmo_kernel_service import AIPMOKernelService from ..services.operation_log_service import log_operation from ..services import skill_service from ..utils.timezone import now_cn_naive router = APIRouter(prefix="/users", tags=["users"]) COMPLETED_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"} MANAGER_ROLE_SET = {"BOSS", "ADMIN"} VALID_ROLE_VALUES = {r.value for r in UserRole} class BulkDeleteUsersRequest(BaseModel): user_ids: list[int] class BulkAssignUsersRequest(BaseModel): user_ids: list[int] role: Optional[str] = None project_ids: Optional[list[int]] = None def _enum_value(value: Any) -> Any: return value.value if hasattr(value, "value") else value def _parse_role_values(raw: Any) -> list[str]: if raw is None: return [] if isinstance(raw, (list, tuple, set)): values = [str(_enum_value(item)).strip().upper() for item in raw] else: values = [token.strip().upper() for token in str(_enum_value(raw)).split(",")] deduped: list[str] = [] for value in values: if value and value not in deduped: deduped.append(value) return deduped def _normalize_role_input(raw: Any, *, allow_empty: bool = False) -> list[str]: values = _parse_role_values(raw) if not values and not allow_empty: raise ValueError("role required") invalid = [value for value in values if value not in VALID_ROLE_VALUES] if invalid: raise ValueError(f"Invalid role: {','.join(invalid)}") return values def _pack_roles(raw: Any) -> str: return ",".join(_normalize_role_input(raw)) def _has_any_role(raw: Any, *roles: str) -> bool: values = set(_parse_role_values(raw)) expected = {str(role).upper() for role in roles} return bool(values.intersection(expected)) def _build_role_filter(column: Any, roles: list[str]) -> Any: filters = [] for role in roles: filters.extend( [ column == role, column.like(f"{role},%"), column.like(f"%,{role},%"), column.like(f"%,{role}"), ] ) return or_(*filters) def _parse_int_list(raw: Optional[str]) -> list[int]: if not raw: return [] ids: list[int] = [] for part in raw.split(","): token = part.strip() if not token: continue try: ids.append(int(token)) except ValueError: continue return ids def _translate_user_integrity_error(exc: IntegrityError) -> str: message = str(getattr(exc, "orig", exc)).lower() 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("邮箱已存在") 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(), 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 @router.get("/me", response_model=UserResponse) async def get_current_user_info(current_user: User = Depends(get_current_active_user)): """当前用户信息。""" return UserResponse(**_serialize_user(current_user)) @router.get("/") async def list_users( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), search: Optional[str] = Query(None, description="用户名/姓名/邮箱关键字"), role: Optional[str] = Query(None), status_value: Optional[str] = Query(None, alias="status"), is_active: Optional[bool] = Query(None), project_id: Optional[int] = Query(None), workload_state: Optional[str] = Query(None, description="idle|normal|overloaded"), skill_ids: Optional[str] = Query(None, description="逗号分隔技能ID"), tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """用户分页列表(支持多维过滤)。""" _ = current_user skill_id_list = _parse_int_list(skill_ids) query = select(User).where(User.tenant_id == tenant_id) if role: try: role_values = _normalize_role_input(role, allow_empty=True) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) if role_values: query = query.where(_build_role_filter(User.role, role_values)) if status_value: query = query.where(User.status == status_value) if is_active is not None: query = query.where(User.is_active == is_active) if search: q = f"%{search.strip()}%" query = query.where( or_( User.username.ilike(q), User.name.ilike(q), User.email.ilike(q), ) ) if project_id is not None: query = query.join( UserProject, and_( UserProject.user_id == User.id, UserProject.tenant_id == tenant_id, ), ).where(UserProject.project_id == project_id) if skill_id_list: query = query.join( UserSkillBinding, and_( UserSkillBinding.user_id == User.id, UserSkillBinding.tenant_id == tenant_id, ), ).where(UserSkillBinding.skill_tag_id.in_(skill_id_list)) if workload_state: query = query.outerjoin( UserWorkload, and_( UserWorkload.user_id == User.id, UserWorkload.tenant_id == tenant_id, ), ) if workload_state == "idle": query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) < 70.0) elif workload_state == "normal": query = query.where( and_( func.coalesce(UserWorkload.workload_ratio, 0.0) >= 70.0, func.coalesce(UserWorkload.workload_ratio, 0.0) <= 100.0, ) ) elif workload_state == "overloaded": query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) > 100.0) total_result = await db.execute( select(func.count()).select_from(query.distinct().subquery()) ) total = int(total_result.scalar() or 0) result = await db.execute( query.distinct() .order_by(User.updated_at.desc(), User.id.desc()) .offset((page - 1) * page_size) .limit(page_size) ) users = list(result.scalars().all()) workload_map, top_skills, project_map = await _collect_user_enrichment(db, tenant_id, users) return { "items": [ _serialize_user( u, workload_map=workload_map, top_skills=top_skills, project_map=project_map, ) for u in users ], "page": page, "page_size": page_size, "total": total, } @router.get("/available") async def get_available_staff( role: Optional[str] = Query(None, description="按角色过滤"), tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """获取可分配任务成员(启用 + active)。""" _ = current_user query = select(User).where( and_( User.tenant_id == tenant_id, User.status == UserStatus.ACTIVE.value, User.is_active == True, ) ) if role: try: role_values = _normalize_role_input(role, allow_empty=True) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) if role_values: query = query.where(_build_role_filter(User.role, role_values)) result = await db.execute(query.order_by(User.workload.asc(), User.id.asc())) users = result.scalars().all() return [_serialize_user(u) for u in users] @router.get("/{user_id}") async def get_user( user_id: int, tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """获取单用户详情。""" _ = current_user result = await db.execute( select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) ) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) return _serialize_user(user, workloads, skills, projects) @router.post("/") async def create_user( user_data: UserCreate, tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(require_roles("BOSS", "ADMIN")), db: AsyncSession = Depends(get_db), ): """创建用户(自动初始化 user_workloads)。""" try: user = await _create_user_with_defaults( db, tenant_id=tenant_id, user_data=user_data, actor_id=int(current_user.id), commit=True, ) except ValueError as exc: await db.rollback() raise HTTPException(status_code=400, detail=str(exc)) except IntegrityError as exc: await db.rollback() raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc)) except Exception: await db.rollback() raise workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) return _serialize_user(user, workloads, skills, projects) @router.put("/{user_id}") @router.patch("/{user_id}") async def update_user( user_id: int, user_data: UserUpdate, tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """更新用户信息。""" result = await db.execute( select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) ) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") current_role = _enum_value(current_user.role) is_self = int(current_user.id) == user_id is_manager = _has_any_role(current_role, *MANAGER_ROLE_SET) if not is_self and not is_manager: raise HTTPException(status_code=403, detail="No permission") if is_self and not is_manager: # 普通成员仅可修改自身基础资料。 if user_data.role is not None or user_data.status is not None or user_data.is_active is not None: raise HTTPException(status_code=403, detail="Members can only edit own profile fields") if user_data.project_ids is not None: raise HTTPException(status_code=403, detail="Members cannot change project assignments") before_data = user.to_dict() if user_data.name is not None: user.name = user_data.name.strip() if user_data.email is not None: user.email = user_data.email.strip() if user_data.assigned_domains is not None: user.assigned_domains = user_data.assigned_domains if user_data.workload is not None: user.workload = max(0, min(200, int(user_data.workload))) if user_data.status is not None: user.status = _enum_value(user_data.status) user.is_active = user.status == UserStatus.ACTIVE.value if user_data.is_active is not None: user.is_active = bool(user_data.is_active) if not user.is_active: user.status = UserStatus.OFFLINE.value elif user.status == UserStatus.OFFLINE.value: user.status = UserStatus.ACTIVE.value if user_data.role is not None and is_manager: try: user.role = _pack_roles(user_data.role) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) if user_data.project_ids is not None: await _replace_user_projects( db, tenant_id=tenant_id, user_id=user_id, project_ids=user_data.project_ids, ) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=None, entity_type="user", entity_id=str(user.id), action="update", before_data=before_data, after_data=user.to_dict(), ) try: await db.commit() await db.refresh(user) except IntegrityError as exc: await db.rollback() raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc)) workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) return _serialize_user(user, workloads, skills, projects) @router.post("/{user_id}/reset-password") async def reset_password( user_id: int, body: UserResetPasswordRequest, tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """重置用户密码。""" result = await db.execute( select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) ) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") current_role = _enum_value(current_user.role) is_manager = _has_any_role(current_role, *MANAGER_ROLE_SET) if not is_manager and int(current_user.id) != user_id: raise HTTPException(status_code=403, detail="No permission") try: user.password_hash = AuthService.hash_password(body.new_password) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc user.updated_at = now_cn_naive() await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=None, entity_type="user", entity_id=str(user.id), action="reset_password", before_data=None, after_data={"updated_at": user.updated_at.isoformat()}, ) await db.commit() return {"message": "Password reset successfully"} @router.patch("/{user_id}/status") async def patch_user_status( user_id: int, body: UserStatusPatchRequest, tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(require_roles("BOSS", "ADMIN")), db: AsyncSession = Depends(get_db), ): """禁用/启用用户,禁用时清空任务指派并归零负载。""" result = await db.execute( select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) ) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") before_data = user.to_dict() user.is_active = body.is_active user.status = UserStatus.ACTIVE.value if body.is_active else UserStatus.OFFLINE.value if not body.is_active: user.workload = 0 await db.execute( update(Task) .where( and_( Task.tenant_id == tenant_id, Task.assignee_id == user_id, ) ) .values(assignee_id=None, updated_at=now_cn_naive()) ) workload_result = await db.execute( select(UserWorkload).where( and_( UserWorkload.tenant_id == tenant_id, UserWorkload.user_id == user_id, ) ) ) workload = workload_result.scalar_one_or_none() if not workload: workload = UserWorkload(tenant_id=tenant_id, user_id=user_id) db.add(workload) workload.current_assigned_hours = 0.0 workload.workload_ratio = 0.0 await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=None, entity_type="user", entity_id=str(user.id), action="status_patch", before_data=before_data, after_data=user.to_dict(), ) await db.commit() await db.refresh(user) return {"message": "Status updated", "user": _serialize_user(user)} @router.delete("/{user_id}") async def delete_user( user_id: int, tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(require_roles("BOSS", "ADMIN")), db: AsyncSession = Depends(get_db), ): """删除用户(有未完成任务时禁止)。""" if int(current_user.id) == user_id: raise HTTPException(status_code=400, detail="Cannot delete yourself") result = await db.execute( select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) ) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") incomplete_count_result = await db.execute( select(func.count(Task.id)).where( and_( Task.tenant_id == tenant_id, Task.assignee_id == user_id, func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES), ) ) ) incomplete_count = int(incomplete_count_result.scalar() or 0) if incomplete_count > 0: raise HTTPException( status_code=400, detail=f"User has {incomplete_count} incomplete tasks", ) before_data = user.to_dict() await AIPMOKernelService.invalidate_world_model_cache_for_user( db=db, tenant_id=tenant_id, user_id=user_id, ) await db.execute( delete(UserSkillBinding).where( and_( UserSkillBinding.tenant_id == tenant_id, UserSkillBinding.user_id == user_id, ) ) ) await db.execute( delete(UserWorkload).where( and_( UserWorkload.tenant_id == tenant_id, UserWorkload.user_id == user_id, ) ) ) await db.execute( delete(UserProject).where( and_( UserProject.tenant_id == tenant_id, UserProject.user_id == user_id, ) ) ) await db.delete(user) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=None, entity_type="user", entity_id=str(user_id), action="delete", before_data=before_data, after_data=None, ) await db.commit() return {"message": "User deleted successfully"} @router.post("/bulk-delete") async def bulk_delete_users( body: BulkDeleteUsersRequest, tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(require_roles("BOSS", "ADMIN")), db: AsyncSession = Depends(get_db), ): """批量删除用户(带未完成任务保护)。""" if not body.user_ids: raise HTTPException(status_code=400, detail="user_ids required") deleted: list[int] = [] skipped: list[dict[str, Any]] = [] for user_id in sorted(set(body.user_ids)): if int(current_user.id) == user_id: skipped.append({"user_id": user_id, "reason": "Cannot delete yourself"}) continue user = ( await db.execute( select(User).where( and_( User.tenant_id == tenant_id, User.id == user_id, ) ) ) ).scalar_one_or_none() if not user: skipped.append({"user_id": user_id, "reason": "User not found"}) continue incomplete_count_result = await db.execute( select(func.count(Task.id)).where( and_( Task.tenant_id == tenant_id, Task.assignee_id == user_id, func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES), ) ) ) incomplete_count = int(incomplete_count_result.scalar() or 0) if incomplete_count > 0: skipped.append( { "user_id": user_id, "reason": f"User has {incomplete_count} incomplete tasks", } ) continue await AIPMOKernelService.invalidate_world_model_cache_for_user( db=db, tenant_id=tenant_id, user_id=user_id, ) await db.execute( delete(UserSkillBinding).where( and_( UserSkillBinding.tenant_id == tenant_id, UserSkillBinding.user_id == user_id, ) ) ) await db.execute( delete(UserWorkload).where( and_( UserWorkload.tenant_id == tenant_id, UserWorkload.user_id == user_id, ) ) ) await db.execute( delete(UserProject).where( and_( UserProject.tenant_id == tenant_id, UserProject.user_id == user_id, ) ) ) await db.delete(user) deleted.append(user_id) await db.commit() return {"deleted_count": len(deleted), "deleted_user_ids": deleted, "skipped": skipped} @router.post("/bulk-assign") async def bulk_assign_users( body: BulkAssignUsersRequest, tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(require_roles("BOSS", "ADMIN")), db: AsyncSession = Depends(get_db), ): """批量分配项目/角色。""" user_ids = sorted(set(body.user_ids or [])) if not user_ids: raise HTTPException(status_code=400, detail="user_ids required") if body.role is None and body.project_ids is None: raise HTTPException(status_code=400, detail="role or project_ids required") updated = 0 skipped: list[dict[str, Any]] = [] for user_id in user_ids: user = ( await db.execute( select(User).where( and_( User.tenant_id == tenant_id, User.id == user_id, ) ) ) ).scalar_one_or_none() if not user: skipped.append({"user_id": user_id, "reason": "User not found"}) continue before_data = user.to_dict() if body.role is not None: if not _has_any_role(_enum_value(current_user.role), "BOSS"): skipped.append({"user_id": user_id, "reason": "Only BOSS can bulk assign roles"}) continue try: user.role = _pack_roles(body.role) except ValueError as exc: skipped.append({"user_id": user_id, "reason": str(exc)}) continue if body.project_ids is not None: await _replace_user_projects( db, tenant_id=tenant_id, user_id=user_id, project_ids=body.project_ids, ) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=None, entity_type="user", entity_id=str(user_id), action="bulk_assign", before_data=before_data, after_data=user.to_dict(), remark=f"role={body.role}, projects={body.project_ids}", ) updated += 1 await db.commit() return {"updated_count": updated, "skipped": skipped} @router.put("/{user_id}/workload") async def update_user_workload( user_id: int, workload: int = Query(..., ge=0, le=200, description="Workload value (0-200)"), tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(require_self_or_manager), db: AsyncSession = Depends(get_db), ): """手动更新用户 workload 百分比。""" _ = current_user result = await db.execute( select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) ) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") user.workload = max(0, min(200, int(workload))) user.updated_at = now_cn_naive() await db.commit() await db.refresh(user) return _serialize_user(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", "FE")).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") @router.post("/import") async def import_users( file: UploadFile = File(...), tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(require_roles("BOSS", "ADMIN")), db: AsyncSession = Depends(get_db), ): """批量导入用户(最多 50 行)。""" rows = await _iter_import_rows(file) if not rows: raise HTTPException(status_code=400, detail="Import file is empty") if len(rows) > 50: raise HTTPException(status_code=400, detail="Import rows exceed max limit: 50") results: list[dict[str, Any]] = [] success_count = 0 for idx, raw in enumerate(rows, start=1): row = _normalize_import_row(raw) try: if row["role"] not in {r.value for r in UserRole}: raise ValueError(f"Invalid role: {row['role']}") if not row["username"] or not row["password"]: raise ValueError("username/password required") data = UserCreate( username=row["username"], password=row["password"], name=row["name"] or row["username"], email=row["email"], role=UserRole(row["role"]), assigned_domains=[], project_ids=row["project_ids"], ) user = await _create_user_with_defaults( db, tenant_id=tenant_id, user_data=data, actor_id=int(current_user.id), commit=True, ) success_count += 1 results.append( { "row": idx, "status": "success", "user_id": str(user.id), "message": "created", } ) except Exception as exc: await db.rollback() results.append( { "row": idx, "status": "failed", "message": str(exc), } ) return { "total": len(rows), "success": success_count, "failed": len(rows) - success_count, "results": results, }