diff --git a/docker-compose.yml b/docker-compose.yml index 79ab3203..415dd972 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - ./src:/app/src - ./saves:/app/saves - ./test:/app/test + - ./scripts:/app/scripts - ${MODEL_DIR:-./models}:/models # 使用默认值处理未定义的环境变量 ports: - "5050:5050" diff --git a/server/routers/auth_router.py b/server/routers/auth_router.py index 7b840a87..96e5400a 100644 --- a/server/routers/auth_router.py +++ b/server/routers/auth_router.py @@ -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() # 记录操作 diff --git a/server/routers/dashboard_router.py b/server/routers/dashboard_router.py index 74e27a35..eddf43ac 100644 --- a/server/routers/dashboard_router.py +++ b/server/routers/dashboard_router.py @@ -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 diff --git a/src/storage/db/models.py b/src/storage/db/models.py index 29f84e3a..e6823680 100644 --- a/src/storage/db/models.py +++ b/src/storage/db/models.py @@ -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