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
- ./saves:/app/saves
- ./test:/app/test
- ./scripts:/app/scripts
- ${MODEL_DIR:-./models}:/models # 使用默认值处理未定义的环境变量
ports:
- "5050:5050"

View File

@ -108,6 +108,14 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
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():
remaining_time = user.get_remaining_lock_time()
@ -391,14 +399,14 @@ async def create_user(
async def read_users(
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]
# 路由:获取特定用户信息(管理员权限)
@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)):
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:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -416,7 +424,7 @@ async def update_user(
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:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -472,7 +480,7 @@ async def update_user(
async def delete_user(
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:
raise HTTPException(
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:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -503,13 +511,28 @@ async def delete_user(
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}"
# 清理关联的操作日志,避免外键约束报错
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()
# 记录操作

View File

@ -9,7 +9,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import distinct, func
from sqlalchemy import String, cast, distinct, func, or_
from sqlalchemy.orm import Session
from server.routers.auth_router import get_admin_user
@ -233,32 +233,45 @@ async def get_user_activity_stats(
now = datetime.utcnow()
# 基础用户统计
total_users = db.query(func.count(User.id)).scalar() or 0
# Conversations may store either the numeric user primary key or the login user_id string.
# 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 = (
db.query(func.count(distinct(Conversation.user_id)))
.filter(Conversation.updated_at >= now - timedelta(days=1))
db.query(func.count(distinct(User.id)))
.select_from(Conversation)
.join(User, user_join_condition)
.filter(Conversation.updated_at >= now - timedelta(days=1), User.is_deleted == 0)
.scalar()
or 0
)
active_users_30d = (
db.query(func.count(distinct(Conversation.user_id)))
.filter(Conversation.updated_at >= now - timedelta(days=30))
db.query(func.count(distinct(User.id)))
.select_from(Conversation)
.join(User, user_join_condition)
.filter(Conversation.updated_at >= now - timedelta(days=30), User.is_deleted == 0)
.scalar()
or 0
)
# 最近7天每日活跃用户
# 最近7天每日活跃用户(排除已删除用户)
daily_active_users = []
for i in range(7):
day_start = now - timedelta(days=i + 1)
day_end = now - timedelta(days=i)
active_count = (
db.query(func.count(distinct(Conversation.user_id)))
.filter(Conversation.updated_at >= day_start, Conversation.updated_at < day_end)
db.query(func.count(distinct(User.id)))
.select_from(Conversation)
.join(User, user_join_condition)
.filter(Conversation.updated_at >= day_start, Conversation.updated_at < day_end, User.is_deleted == 0)
.scalar()
or 0
)
@ -603,7 +616,7 @@ async def get_dashboard_stats(
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_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
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) # 最后一次登录失败时间
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")
@ -191,6 +195,8 @@ class User(Base):
"login_failed_count": self.login_failed_count,
"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,
"is_deleted": self.is_deleted,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
}
if include_password:
result["password_hash"] = self.password_hash