Files
FastapiAdmin/backend/app/modules/monitor/online/service.py
T

205 lines
8.7 KiB
Python

import json
from datetime import date, datetime, timedelta
from redis.asyncio.client import Redis
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.enums import RedisInitKeyConfig
from app.core.logger import logger
from app.core.redis_crud import RedisCURD
from app.core.security import decode_access_token
from app.modules.system.log.model import LoginLogModel
from app.modules.system.user.model import UserModel
from .schema import DashboardStatsSchema, LoginTrendItem, OnlineQueryParam, RecentLoginItem
class OnlineService:
"""在线用户管理模块服务层"""
@staticmethod
async def get_online_list(redis: Redis, search: OnlineQueryParam | None = None) -> list[dict]:
keys = await RedisCURD(redis).scan_keys(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:*")
tokens = await RedisCURD(redis).mget(keys)
online_users = []
for key, token in zip(keys, tokens, strict=True):
if not token:
continue
try:
payload = decode_access_token(token=token)
session_id = payload.sub
# 从 Redis 读取完整会话信息
raw = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
if not raw:
continue
session_info = json.loads(raw)
# 内联搜索匹配逻辑
if search:
if search.name and search.name[1]:
kw = search.name[1].strip("%")
if kw.lower() not in session_info.get("name", "").lower():
continue
if search.ipaddr and search.ipaddr[1]:
kw = search.ipaddr[1].strip("%")
if kw not in session_info.get("ipaddr", ""):
continue
if search.login_location and search.login_location[1]:
kw = search.login_location[1].strip("%")
if kw.lower() not in session_info.get("login_location", "").lower():
continue
online_users.append(session_info)
except Exception:
# token 已过期或无效,清理 Redis 中的脏数据
key_str = key.decode() if isinstance(key, bytes) else key
session_id = key_str.split(":")[-1]
await RedisCURD(redis).delete(key_str)
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}")
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
continue
online_users.sort(key=lambda x: x.get("login_time", ""), reverse=True)
return online_users
@staticmethod
async def get_current_user_sessions(redis: Redis, user_id: int) -> list[dict]:
"""获取当前用户的在线会话列表"""
all_online = await OnlineService.get_online_list(redis)
return [s for s in all_online if s.get("user_id") == user_id]
@staticmethod
async def delete_online(redis: Redis, session_id: str) -> None:
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}")
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}")
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
logger.info(f"强制下线用户会话: {session_id}")
@staticmethod
async def clear_online(redis: Redis) -> None:
await RedisCURD(redis).clear(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:*")
await RedisCURD(redis).clear(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:*")
await RedisCURD(redis).clear(f"{RedisInitKeyConfig.USER_SESSION.key}:*")
logger.info("清除所有在线用户会话成功")
@staticmethod
async def get_dashboard_stats(db: AsyncSession, redis: Redis) -> DashboardStatsSchema:
"""获取仪表盘统计数据"""
today_start = datetime.combine(date.today(), datetime.min.time())
week_start = today_start - timedelta(days=7)
online_count = len(await OnlineService.get_online_list(redis))
users_sql = select(func.count()).select_from(UserModel).where(UserModel.is_deleted.is_(False))
user_count = (await db.execute(users_sql)).scalar() or 0
users_week_sql = (
select(func.count()).select_from(UserModel)
.where(UserModel.is_deleted.is_(False), UserModel.created_time >= week_start)
)
user_week_count = (await db.execute(users_week_sql)).scalar() or 0
today_login_sql = (
select(func.count()).select_from(LoginLogModel)
.where(LoginLogModel.created_time >= today_start)
)
today_login_count = (await db.execute(today_login_sql)).scalar() or 0
today_unique_sql = (
select(func.count(func.distinct(LoginLogModel.username)))
.select_from(LoginLogModel)
.where(LoginLogModel.created_time >= today_start)
)
today_unique_count = (await db.execute(today_unique_sql)).scalar() or 0
recent_stmt = (
select(LoginLogModel.username, LoginLogModel.status, LoginLogModel.created_time,
LoginLogModel.login_ip, LoginLogModel.login_location)
.where(LoginLogModel.is_deleted.is_(False))
.order_by(LoginLogModel.created_time.desc())
.limit(10)
)
recent_rows = (await db.execute(recent_stmt)).all()
recent_logins = [
RecentLoginItem(username=r.username, status=r.status, login_time=r.created_time,
login_ip=r.login_ip, login_location=r.login_location)
for r in recent_rows
]
# 近 7 天登录趋势:按天聚合登录次数 / 独立用户 / 新增用户(含今天,共 7 天)
login_trend = await OnlineService._build_login_trend(db, today_start=today_start, week_start=week_start)
result = DashboardStatsSchema(
online_users=online_count,
total_users=user_count,
today_login_count=today_login_count,
today_unique_users=today_unique_count,
week_user_created=user_week_count,
login_trend=login_trend,
recent_logins=recent_logins,
)
return result
@staticmethod
async def _build_login_trend(db: AsyncSession, *, today_start: datetime, week_start: datetime) -> list[LoginTrendItem]:
"""构建近 7 天登录趋势(含今天,共 7 天,按日期倒序填充缺口为 0)。
参数:
- db (AsyncSession): 数据库会话
- today_start (datetime): 今天零点
- week_start (datetime): 7 天前零点
返回:
- list[LoginTrendItem]: 按日期升序的 7 天趋势
"""
# 查询 7 天内每天登录次数与独立用户数
login_sql = (
select(
func.date(LoginLogModel.created_time).label("day"),
func.count().label("logins"),
func.count(func.distinct(LoginLogModel.username)).label("unique_users"),
)
.where(
LoginLogModel.is_deleted.is_(False),
LoginLogModel.status == 1, # 仅统计成功登录
LoginLogModel.created_time >= week_start,
)
.group_by(func.date(LoginLogModel.created_time))
)
login_rows = (await db.execute(login_sql)).all()
login_map = {str(r.day): (r.logins, r.unique_users) for r in login_rows}
# 查询 7 天内每天新增用户数
new_user_sql = (
select(
func.date(UserModel.created_time).label("day"),
func.count().label("new_users"),
)
.where(
UserModel.is_deleted.is_(False),
UserModel.created_time >= week_start,
)
.group_by(func.date(UserModel.created_time))
)
new_rows = (await db.execute(new_user_sql)).all()
new_map = {str(r.day): r.new_users for r in new_rows}
# 生成连续 7 天日期序列(升序),缺口补 0
trend: list[LoginTrendItem] = []
for offset in range(7):
d = week_start.date() + timedelta(days=offset)
day_str = d.isoformat()
logins, unique_users = login_map.get(day_str, (0, 0))
trend.append(
LoginTrendItem(
day=day_str,
logins=int(logins or 0),
unique_users=int(unique_users or 0),
new_users=int(new_map.get(day_str, 0) or 0),
)
)
return trend