import csv import io from fastapi import APIRouter, Depends, HTTPException, UploadFile from sqlalchemy import delete, or_, select from sqlalchemy.orm import Session from app.db import get_db from app.deps import require_admin from app.models import Unit, User from app.models.chat import Attachment, Feedback, Message, MessageReference from app.models.user import user_roles from app.schemas.user import BulkImportResult, UserCreate, UserResponse, UserUpdate from app.services.audit_service import write_audit from app.services.user_service import apply_roles, ensure_roles router = APIRouter() def serialize_user(user: User) -> UserResponse: return UserResponse( id=user.id, phone=user.phone, name=user.name, department=user.department, unit_id=user.unit_id, unit_name=user.unit.name if user.unit else None, status=user.status, role_codes=[role.code for role in user.roles], is_superuser=user.is_superuser, created_at=user.created_at, ) def _validate_unit_id(db: Session, unit_id: int | None) -> None: if unit_id is None: return if not db.get(Unit, unit_id): raise HTTPException(status_code=400, detail="所选单位不存在") @router.get("", response_model=list[UserResponse]) def list_users( q: str = "", status: str = "", db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> list[UserResponse]: stmt = select(User).order_by(User.id.desc()) q = (q or "").strip() if q: like = f"%{q}%" stmt = stmt.where(or_( User.name.ilike(like), User.phone.ilike(like), User.department.ilike(like), )) status = (status or "").strip() if status: stmt = stmt.where(User.status == status) users = db.scalars(stmt).all() return [serialize_user(user) for user in users] @router.post("", response_model=UserResponse) def create_user(payload: UserCreate, db: Session = Depends(get_db), admin: User = Depends(require_admin)) -> UserResponse: existing = db.scalar(select(User).where(User.phone == payload.phone)) if existing: raise HTTPException(status_code=409, detail="手机号已存在") _validate_unit_id(db, payload.unit_id) user = User( phone=payload.phone, name=payload.name, department=payload.department, unit_id=payload.unit_id, is_superuser=payload.is_superuser, ) db.add(user) db.flush() apply_roles(db, user, payload.role_codes) write_audit(db, "user.create", actor_id=admin.id, target_type="user", target_id=str(user.id)) db.commit() db.refresh(user) return serialize_user(user) @router.patch("/{user_id}", response_model=UserResponse) def update_user( user_id: int, payload: UserUpdate, db: Session = Depends(get_db), admin: User = Depends(require_admin), ) -> UserResponse: user = db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="用户不存在") updates = payload.model_dump(exclude_unset=True) if "unit_id" in updates: _validate_unit_id(db, updates["unit_id"]) user.unit_id = updates["unit_id"] for field in ("name", "department", "status", "is_superuser"): if field in updates and updates[field] is not None: setattr(user, field, updates[field]) if payload.role_codes is not None: apply_roles(db, user, payload.role_codes) write_audit(db, "user.update", actor_id=admin.id, target_type="user", target_id=str(user.id)) db.commit() db.refresh(user) return serialize_user(user) @router.delete("/{user_id}") def delete_user( user_id: int, db: Session = Depends(get_db), admin: User = Depends(require_admin), ) -> dict: user = db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="用户不存在") if user.id == admin.id: raise HTTPException(status_code=400, detail="不能删除当前登录账户") # 清理关联:user_roles / 该用户的消息/反馈/附件/引用 db.execute(delete(user_roles).where(user_roles.c.user_id == user_id)) msg_ids = list(db.scalars(select(Message.id).where(Message.user_id == user_id))) if msg_ids: db.execute(delete(MessageReference).where(MessageReference.message_id.in_(msg_ids))) db.execute(delete(Feedback).where(Feedback.message_id.in_(msg_ids))) db.execute(delete(Message).where(Message.id.in_(msg_ids))) db.execute(delete(Attachment).where(Attachment.user_id == user_id)) write_audit(db, "user.delete", actor_id=admin.id, target_type="user", target_id=str(user_id), detail={"phone": user.phone, "name": user.name}) db.delete(user) db.commit() return {"deleted": True, "id": user_id} @router.post("/bulk-import", response_model=BulkImportResult) def bulk_import( file: UploadFile, db: Session = Depends(get_db), admin: User = Depends(require_admin), ) -> BulkImportResult: ensure_roles(db) name = (file.filename or "").lower() raw = file.file.read() if not raw: raise HTTPException(status_code=400, detail="文件为空") try: rows = _parse_user_rows(raw, name) except RuntimeError as exc: raise HTTPException(status_code=400, detail=str(exc)) created = 0 skipped = 0 errors: list[str] = [] existing_phones = {p for p, in db.execute(select(User.phone))} for idx, row in enumerate(rows, start=2): # 行号 from 2 (header was 1) phone = (row.get("phone") or "").strip() u_name = (row.get("name") or "").strip() dept = (row.get("department") or "").strip() or None roles_raw = (row.get("role_codes") or "").strip() if not phone or not u_name: errors.append(f"第 {idx} 行:手机号或姓名为空") continue if phone in existing_phones: skipped += 1 continue role_codes = [c.strip() for c in roles_raw.replace(",", ",").split(",") if c.strip()] user = User(phone=phone, name=u_name, department=dept) db.add(user) db.flush() apply_roles(db, user, role_codes) existing_phones.add(phone) created += 1 write_audit(db, "user.bulk_import", actor_id=admin.id, target_type="user", detail={"created": created, "skipped": skipped, "errors": len(errors)}) db.commit() return BulkImportResult(created=created, skipped=skipped, errors=errors) def _parse_user_rows(raw: bytes, filename: str) -> list[dict]: """支持 CSV / XLSX。列:phone, name, department(可选), role_codes(可选,多个用逗号分隔)。""" header_map = { "手机号": "phone", "phone": "phone", "电话": "phone", "姓名": "name", "name": "name", "用户名": "name", "用户名称": "name", "部门": "department", "department": "department", "角色": "role_codes", "role_codes": "role_codes", "角色代码": "role_codes", } def normalize(rows: list[list[str]]) -> list[dict]: if not rows: return [] head = [str(c or "").strip() for c in rows[0]] keys = [header_map.get(c, c.lower()) for c in head] out: list[dict] = [] for r in rows[1:]: if not any((str(x) or "").strip() for x in r): continue d: dict = {} for k, v in zip(keys, r): d[k] = "" if v is None else str(v) out.append(d) return out if filename.endswith(".csv") or filename.endswith(".txt"): text = raw.decode("utf-8-sig", errors="replace") reader = csv.reader(io.StringIO(text)) return normalize(list(reader)) if filename.endswith(".xlsx") or filename.endswith(".xls"): try: from openpyxl import load_workbook except ImportError: raise RuntimeError("缺少 openpyxl,无法解析 XLSX。请使用 CSV 或运行 pip install openpyxl") wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True) ws = wb.active rows = [[cell for cell in row] for row in ws.iter_rows(values_only=True)] return normalize(rows) raise RuntimeError("仅支持 CSV 或 XLSX")