feat: 添加软删除功能,更新用户模型和相关路由以支持用户注销和状态检查

This commit is contained in:
Wenjie Zhang 2025-10-11 20:19:09 +08:00
parent 43ec5a4a71
commit c8ea1e7179
4 changed files with 64 additions and 21 deletions

View File

@ -14,6 +14,7 @@ services:
- ./src:/app/src - ./src:/app/src
- ./saves:/app/saves - ./saves:/app/saves
- ./test:/app/test - ./test:/app/test
- ./scripts:/app/scripts
- ${MODEL_DIR:-./models}:/models # 使用默认值处理未定义的环境变量 - ${MODEL_DIR:-./models}:/models # 使用默认值处理未定义的环境变量
ports: ports:
- "5050:5050" - "5050:5050"

View File

@ -108,6 +108,14 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# 检查用户是否已被删除
if user.is_deleted:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="该账户已注销",
headers={"WWW-Authenticate": "Bearer"},
)
# 检查用户是否处于登录锁定状态 # 检查用户是否处于登录锁定状态
if user.is_login_locked(): if user.is_login_locked():
remaining_time = user.get_remaining_lock_time() remaining_time = user.get_remaining_lock_time()
@ -391,14 +399,14 @@ async def create_user(
async def read_users( async def read_users(
skip: int = 0, limit: int = 100, current_user: User = Depends(get_admin_user), db: Session = Depends(get_db) skip: int = 0, limit: int = 100, current_user: User = Depends(get_admin_user), db: Session = Depends(get_db)
): ):
users = db.query(User).offset(skip).limit(limit).all() users = db.query(User).filter(User.is_deleted == 0).offset(skip).limit(limit).all()
return [user.to_dict() for user in users] return [user.to_dict() for user in users]
# 路由:获取特定用户信息(管理员权限) # 路由:获取特定用户信息(管理员权限)
@auth.get("/users/{user_id}", response_model=UserResponse) @auth.get("/users/{user_id}", response_model=UserResponse)
async def read_user(user_id: int, current_user: User = Depends(get_admin_user), db: Session = Depends(get_db)): async def read_user(user_id: int, current_user: User = Depends(get_admin_user), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id, User.is_deleted == 0).first()
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@ -416,7 +424,7 @@ async def update_user(
current_user: User = Depends(get_admin_user), current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id, User.is_deleted == 0).first()
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@ -472,7 +480,7 @@ async def update_user(
async def delete_user( async def delete_user(
user_id: int, request: Request, current_user: User = Depends(get_admin_user), db: Session = Depends(get_db) user_id: int, request: Request, current_user: User = Depends(get_admin_user), db: Session = Depends(get_db)
): ):
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id, User.is_deleted == 0).first()
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@ -489,7 +497,7 @@ async def delete_user(
) )
# 检查是否是最后一个超级管理员 # 检查是否是最后一个超级管理员
superadmin_count = db.query(User).filter(User.role == "superadmin").count() superadmin_count = db.query(User).filter(User.role == "superadmin", User.is_deleted == 0).count()
if superadmin_count <= 1: if superadmin_count <= 1:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@ -503,13 +511,28 @@ async def delete_user(
detail="不能删除自己的账户", detail="不能删除自己的账户",
) )
# 检查是否已经被删除
if user.is_deleted:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该用户已经被删除",
)
deletion_detail = f"删除用户: {user.username}, ID: {user.id}, 角色: {user.role}" deletion_detail = f"删除用户: {user.username}, ID: {user.id}, 角色: {user.role}"
# 清理关联的操作日志,避免外键约束报错 # 软删除:标记删除状态并脱敏
db.query(OperationLog).filter(OperationLog.user_id == user.id).delete(synchronize_session=False) import hashlib
# 生成4位哈希基于user_id保证唯一性
hash_suffix = hashlib.md5(user.user_id.encode()).hexdigest()[:4]
user.is_deleted = 1
user.deleted_at = datetime.now()
user.username = f"已注销用户-{hash_suffix}"
user.phone_number = None # 清空手机号,释放该手机号供其他用户使用
user.password_hash = "DELETED" # 禁止登录
user.avatar = None # 清空头像
# 删除用户
db.delete(user)
db.commit() db.commit()
# 记录操作 # 记录操作

View File

@ -9,7 +9,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import distinct, func from sqlalchemy import String, cast, distinct, func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from server.routers.auth_router import get_admin_user from server.routers.auth_router import get_admin_user
@ -233,32 +233,45 @@ async def get_user_activity_stats(
now = datetime.utcnow() now = datetime.utcnow()
# 基础用户统计 # Conversations may store either the numeric user primary key or the login user_id string.
total_users = db.query(func.count(User.id)).scalar() or 0 # Join condition accounts for both representations.
user_join_condition = or_(
Conversation.user_id == User.user_id,
Conversation.user_id == cast(User.id, String),
)
# 不同时间段的活跃用户数(基于对话活动) # 基础用户统计(排除已删除用户)
total_users = db.query(func.count(User.id)).filter(User.is_deleted == 0).scalar() or 0
# 不同时间段的活跃用户数(基于对话活动,排除已删除用户)
active_users_24h = ( active_users_24h = (
db.query(func.count(distinct(Conversation.user_id))) db.query(func.count(distinct(User.id)))
.filter(Conversation.updated_at >= now - timedelta(days=1)) .select_from(Conversation)
.join(User, user_join_condition)
.filter(Conversation.updated_at >= now - timedelta(days=1), User.is_deleted == 0)
.scalar() .scalar()
or 0 or 0
) )
active_users_30d = ( active_users_30d = (
db.query(func.count(distinct(Conversation.user_id))) db.query(func.count(distinct(User.id)))
.filter(Conversation.updated_at >= now - timedelta(days=30)) .select_from(Conversation)
.join(User, user_join_condition)
.filter(Conversation.updated_at >= now - timedelta(days=30), User.is_deleted == 0)
.scalar() .scalar()
or 0 or 0
) )
# 最近7天每日活跃用户 # 最近7天每日活跃用户(排除已删除用户)
daily_active_users = [] daily_active_users = []
for i in range(7): for i in range(7):
day_start = now - timedelta(days=i + 1) day_start = now - timedelta(days=i + 1)
day_end = now - timedelta(days=i) day_end = now - timedelta(days=i)
active_count = ( active_count = (
db.query(func.count(distinct(Conversation.user_id))) db.query(func.count(distinct(User.id)))
.filter(Conversation.updated_at >= day_start, Conversation.updated_at < day_end) .select_from(Conversation)
.join(User, user_join_condition)
.filter(Conversation.updated_at >= day_start, Conversation.updated_at < day_end, User.is_deleted == 0)
.scalar() .scalar()
or 0 or 0
) )
@ -603,7 +616,7 @@ async def get_dashboard_stats(
db.query(func.count(Conversation.id)).filter(Conversation.status == "active").scalar() or 0 db.query(func.count(Conversation.id)).filter(Conversation.status == "active").scalar() or 0
) )
total_messages = db.query(func.count(Message.id)).scalar() or 0 total_messages = db.query(func.count(Message.id)).scalar() or 0
total_users = db.query(func.count(User.id)).scalar() or 0 total_users = db.query(func.count(User.id)).filter(User.is_deleted == 0).scalar() or 0
# Feedback statistics # Feedback statistics
total_feedbacks = db.query(func.count(MessageFeedback.id)).scalar() or 0 total_feedbacks = db.query(func.count(MessageFeedback.id)).scalar() or 0

View File

@ -175,6 +175,10 @@ class User(Base):
last_failed_login = Column(DateTime, nullable=True) # 最后一次登录失败时间 last_failed_login = Column(DateTime, nullable=True) # 最后一次登录失败时间
login_locked_until = Column(DateTime, nullable=True) # 锁定到什么时候 login_locked_until = Column(DateTime, nullable=True) # 锁定到什么时候
# 软删除相关字段
is_deleted = Column(Integer, nullable=False, default=0, index=True) # 是否已删除0=否1=是
deleted_at = Column(DateTime, nullable=True) # 删除时间
# 关联操作日志 # 关联操作日志
operation_logs = relationship("OperationLog", back_populates="user", cascade="all, delete-orphan") operation_logs = relationship("OperationLog", back_populates="user", cascade="all, delete-orphan")
@ -191,6 +195,8 @@ class User(Base):
"login_failed_count": self.login_failed_count, "login_failed_count": self.login_failed_count,
"last_failed_login": self.last_failed_login.isoformat() if self.last_failed_login else None, "last_failed_login": self.last_failed_login.isoformat() if self.last_failed_login else None,
"login_locked_until": self.login_locked_until.isoformat() if self.login_locked_until else None, "login_locked_until": self.login_locked_until.isoformat() if self.login_locked_until else None,
"is_deleted": self.is_deleted,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
if include_password: if include_password:
result["password_hash"] = self.password_hash result["password_hash"] = self.password_hash