Fix API compatibility and add user/role/permission and asset import/export

This commit is contained in:
2026-01-25 23:36:23 +08:00
commit 501d11e14e
371 changed files with 68853 additions and 0 deletions

271
backend/app/api/v1/users.py Normal file
View File

@@ -0,0 +1,271 @@
"""
User management API routes.
"""
from typing import Optional, List, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func, or_, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.core.response import success_response, paginated_response
from app.core.security import get_password_hash
from app.models.user import User, Role, UserRole
from app.schemas.user import UserCreate, UserUpdate, ResetPasswordRequest
from app.services.auth_service import auth_service
router = APIRouter()
def _status_to_is_active(status_value: Optional[str]) -> Optional[bool]:
if not status_value:
return None
if status_value == "active":
return True
if status_value in {"disabled", "locked"}:
return False
return None
def _role_to_dict(role: Role) -> Dict:
return {
"id": role.id,
"role_name": role.role_name,
"role_code": role.role_code,
"description": role.description,
"status": role.status,
"sort_order": role.sort_order,
"created_at": role.created_at,
}
def _user_to_dict(user: User, roles: List[Role]) -> Dict:
return {
"id": user.id,
"username": user.username,
"real_name": user.full_name or user.username,
"email": user.email,
"phone": user.phone,
"avatar_url": user.avatar_url,
"status": "active" if user.is_active else "disabled",
"is_admin": user.is_superuser,
"last_login_at": user.last_login_at,
"created_at": user.created_at,
"roles": [_role_to_dict(role) for role in roles],
}
async def _ensure_roles_exist(db: AsyncSession, role_ids: List[int]) -> None:
if not role_ids:
return
result = await db.execute(
select(Role.id).where(Role.id.in_(role_ids)).where(Role.deleted_at.is_(None))
)
existing_ids = {row[0] for row in result.all()}
missing = set(role_ids) - existing_ids
if missing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role IDs: {sorted(missing)}",
)
async def _set_user_roles(
db: AsyncSession,
user_id: int,
role_ids: List[int],
operator_id: Optional[int] = None,
) -> None:
await db.execute(delete(UserRole).where(UserRole.user_id == user_id))
if role_ids:
for role_id in role_ids:
db.add(UserRole(user_id=user_id, role_id=role_id, created_by=operator_id))
@router.get("/")
async def list_users(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Page size"),
keyword: Optional[str] = Query(None, description="Search keyword"),
status_value: Optional[str] = Query(None, alias="status", description="Status"),
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
is_active = _status_to_is_active(status_value)
query = select(User)
count_query = select(func.count(User.id))
conditions = []
if keyword:
like_value = f"%{keyword}%"
conditions.append(
or_(
User.username.ilike(like_value),
User.full_name.ilike(like_value),
User.phone.ilike(like_value),
User.email.ilike(like_value),
)
)
if is_active is not None:
conditions.append(User.is_active == is_active)
if conditions:
query = query.where(*conditions)
count_query = count_query.where(*conditions)
query = query.order_by(User.id.desc())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size))
users = list(result.scalars().all())
role_map: Dict[int, List[Role]] = {user.id: [] for user in users}
if users:
user_ids = [user.id for user in users]
role_result = await db.execute(
select(UserRole.user_id, Role)
.join(Role, Role.id == UserRole.role_id)
.where(UserRole.user_id.in_(user_ids))
.where(Role.deleted_at.is_(None))
)
for user_id, role in role_result.all():
role_map.setdefault(user_id, []).append(role)
items = [_user_to_dict(user, role_map.get(user.id, [])) for user in users]
return paginated_response(items, total, page, page_size)
@router.post("/", status_code=status.HTTP_201_CREATED)
async def create_user(
payload: UserCreate,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
existing = await db.execute(select(User).where(User.username == payload.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already exists")
email_value = payload.email
if not email_value:
email_value = f"{payload.username}@local.invalid"
email_check = await db.execute(select(User).where(User.email == email_value))
if email_check.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already exists")
await _ensure_roles_exist(db, payload.role_ids)
user = User(
username=payload.username,
email=email_value,
hashed_password=get_password_hash(payload.password),
full_name=payload.real_name,
phone=payload.phone,
is_active=True,
is_superuser=False,
)
db.add(user)
await db.flush()
await _set_user_roles(db, user.id, payload.role_ids, current_user.id)
await db.commit()
await db.refresh(user)
roles_result = await db.execute(
select(Role)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == user.id)
.where(Role.deleted_at.is_(None))
)
roles = list(roles_result.scalars().all())
return success_response(data=_user_to_dict(user, roles))
@router.put("/{user_id}")
async def update_user(
user_id: int,
payload: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
update_data = payload.model_dump(exclude_unset=True)
if "real_name" in update_data:
user.full_name = update_data.pop("real_name")
if "status" in update_data:
status_value = update_data.pop("status")
is_active = _status_to_is_active(status_value)
if is_active is not None:
user.is_active = is_active
if "email" in update_data:
email_value = update_data.pop("email")
if email_value:
email_check = await db.execute(
select(User).where(User.email == email_value).where(User.id != user_id)
)
if email_check.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already exists")
user.email = email_value
if "phone" in update_data:
user.phone = update_data.pop("phone")
role_ids = update_data.pop("role_ids", None)
if role_ids is not None:
await _ensure_roles_exist(db, role_ids)
await _set_user_roles(db, user.id, role_ids, current_user.id)
db.add(user)
await db.commit()
await db.refresh(user)
roles_result = await db.execute(
select(Role)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == user.id)
.where(Role.deleted_at.is_(None))
)
roles = list(roles_result.scalars().all())
return success_response(data=_user_to_dict(user, roles))
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await db.execute(delete(UserRole).where(UserRole.user_id == user_id))
await db.delete(user)
await db.commit()
return success_response(message="Deleted")
@router.post("/{user_id}/reset-password")
async def reset_password(
user_id: int,
payload: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
success = await auth_service.reset_password(db=db, user_id=user_id, new_password=payload.new_password)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return success_response(message="Password reset")