"""Owner 端用户管理 API。 - GET /admin/users - 列出全部用户 - POST /admin/users - 创建 member(固定角色,不允许创建 owner) - PATCH /admin/users/{id} - 重置密码 / 启停 / 改昵称 / 改邮箱(role 永远不可改) - DELETE /admin/users/{id} - 软删除(置 is_active=False;保留外键完整性) 设计原则: - 软删除而非硬删除:用户有 bookmarks / subscriptions / api_tokens / article_reads 等外键, 硬删会污染历史数据(收藏的"作者"凭空消失)。禁用后无法登录,效果等同删除,可恢复。 - 不允许 owner 把自己禁用/删除,避免自锁。 - API 层禁止提权:不能创建 owner,不能修改 role。 - 列表/详情绝不返回 password_hash。 """ from __future__ import annotations import logging from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import require_owner from app.core.security import hash_password from app.database import get_session from app.models.user import User, UserRole from app.schemas.user import UserCreate, UserDeleteResult, UserOut, UserUpdate router = APIRouter( prefix="/admin/users", tags=["admin"], dependencies=[Depends(require_owner)], ) logger = logging.getLogger("news.admin.users") def _to_out(u: User) -> UserOut: return UserOut.model_validate(u) @router.get("", response_model=list[UserOut]) async def list_users(session: AsyncSession = Depends(get_session)): """列出全部用户(含已禁用)。按 id 升序。""" result = await session.execute(select(User).order_by(User.id)) return [_to_out(u) for u in result.scalars()] @router.post( "", response_model=UserOut, status_code=status.HTTP_201_CREATED, ) async def create_user( body: UserCreate, session: AsyncSession = Depends(get_session), actor: User = Depends(require_owner), ): """创建 member。role 固定为 member,前端不需要传(后端兜底)。""" # 防御性兜底:即便 schemas 接受 owner,这里也强制锁住 member u = User( username=body.username, email=body.email, password_hash=hash_password(body.password), role=UserRole.MEMBER, is_active=True, display_name=body.display_name, ) session.add(u) try: await session.commit() except IntegrityError as e: await session.rollback() # username 或 email 唯一冲突 msg = str(e.orig).lower() if "username" in msg: raise HTTPException( status.HTTP_409_CONFLICT, f"用户名 '{body.username}' 已存在", ) from e if "email" in msg: raise HTTPException( status.HTTP_409_CONFLICT, f"邮箱 '{body.email}' 已被使用", ) from e raise HTTPException(status.HTTP_409_CONFLICT, "用户已存在") from e await session.refresh(u) logger.info( "user created: id=%s username=%s by owner_id=%s", u.id, u.username, actor.id, ) return _to_out(u) @router.patch("/{user_id}", response_model=UserOut) async def update_user( user_id: int, body: UserUpdate, session: AsyncSession = Depends(get_session), actor: User = Depends(require_owner), ): """修改用户:重置密码 / 启停 / 改昵称 / 改邮箱。 - role 永远不能改(防越权) - owner 不能禁用自己(防自锁) """ result = await session.execute(select(User).where(User.id == user_id)) u = result.scalar_one_or_none() if not u: raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") data = body.model_dump(exclude_unset=True) # 自锁保护 if data.get("is_active") is False and u.id == actor.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, "不能禁用自己", ) # 永远不接收 role 字段(就算有人塞进来也忽略) data.pop("role", None) if "password" in data and data["password"]: u.password_hash = hash_password(data.pop("password")) elif "password" in data: # 空串 = 不改 data.pop("password") for k, v in data.items(): setattr(u, k, v) try: await session.commit() except IntegrityError as e: await session.rollback() msg = str(e.orig).lower() if "email" in msg: raise HTTPException( status.HTTP_409_CONFLICT, f"邮箱 '{data.get('email')}' 已被使用", ) from e raise HTTPException(status.HTTP_409_CONFLICT, "更新冲突") from e await session.refresh(u) logger.info( "user updated: id=%s username=%s fields=%s by owner_id=%s", u.id, u.username, sorted(data.keys()), actor.id, ) return _to_out(u) @router.delete("/{user_id}", response_model=UserDeleteResult) async def delete_user( user_id: int, session: AsyncSession = Depends(get_session), actor: User = Depends(require_owner), ): """软删除用户(置 is_active=False)。 - owner 不能删除自己 - 已经是 inactive 的:返回 200 + already_inactive=True - 不删除 owner(防止误删唯一管理员导致失联) """ result = await session.execute(select(User).where(User.id == user_id)) u = result.scalar_one_or_none() if not u: raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") if u.id == actor.id: raise HTTPException(status.HTTP_400_BAD_REQUEST, "不能删除自己") if u.role == UserRole.OWNER: raise HTTPException( status.HTTP_400_BAD_REQUEST, "不能删除 owner(防失联;请直接在服务器上调整)", ) if not u.is_active: return UserDeleteResult( id=u.id, username=u.username, is_active=False, detail="用户已经是禁用状态", ) u.is_active = False await session.commit() logger.info( "user soft-deleted (deactivated): id=%s username=%s by owner_id=%s", u.id, u.username, actor.id, ) return UserDeleteResult( id=u.id, username=u.username, is_active=False, detail=f"已禁用用户 '{u.username}'(可重新启用)", )