Skip to content

Commit 9a509aa

Browse files
committed
v2.5.0f
1 parent 5a516ab commit 9a509aa

12 files changed

Lines changed: 464 additions & 494 deletions

File tree

backend/app/api/v1/todos.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414

1515
router = APIRouter(prefix="/todos", tags=["todos"])
1616

17-
_LOCK_TTL = 5
18-
1917

2018
# ----依赖注入----
2119
def get_todo_service(

backend/app/core/container.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
def get_user_service():
2+
pass

backend/app/repositories/monitor_repo.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,130 @@ async def list_users_by_ids(self, user_ids: list[int]) -> list[User]:
156156
.options(selectinload(User.profile))
157157
)
158158
return list(result.scalars().all())
159+
160+
async def count_visits_between(
161+
self, start: datetime, end: datetime
162+
) -> int:
163+
result = await self.session.execute(
164+
select(func.count(VisitorTrack.id)).where(
165+
VisitorTrack.visit_time >= start,
166+
VisitorTrack.visit_time < end,
167+
)
168+
)
169+
return int(result.scalar_one() or 0)
170+
171+
async def count_unique_visitors_between(
172+
self, start: datetime, end: datetime
173+
) -> int:
174+
result = await self.session.execute(
175+
select(func.count(func.distinct(VisitorTrack.visitor_id))).where(
176+
VisitorTrack.visit_time >= start,
177+
VisitorTrack.visit_time < end,
178+
)
179+
)
180+
return int(result.scalar_one() or 0)
181+
182+
async def count_unique_ips_between(
183+
self, start: datetime, end: datetime
184+
) -> int:
185+
result = await self.session.execute(
186+
select(func.count(func.distinct(VisitorTrack.ip_address))).where(
187+
VisitorTrack.visit_time >= start,
188+
VisitorTrack.visit_time < end,
189+
)
190+
)
191+
return int(result.scalar_one() or 0)
192+
193+
async def get_top_pages_between(
194+
self, start: datetime, end: datetime, *, limit: int = 5
195+
) -> list[dict[str, int | str]]:
196+
result = await self.session.execute(
197+
select(
198+
VisitorTrack.page_path,
199+
func.count(VisitorTrack.id).label("count"),
200+
)
201+
.where(
202+
VisitorTrack.visit_time >= start,
203+
VisitorTrack.visit_time < end,
204+
)
205+
.group_by(VisitorTrack.page_path)
206+
.order_by(func.count(VisitorTrack.id).desc())
207+
.limit(limit)
208+
)
209+
return [
210+
{"page_path": row[0], "count": row[1]} for row in result.fetchall()
211+
]
212+
213+
async def get_browser_stats_between(
214+
self, start: datetime, end: datetime
215+
) -> list[dict[str, int | str | None]]:
216+
result = await self.session.execute(
217+
select(
218+
VisitorTrack.browser_name,
219+
func.count(func.distinct(VisitorTrack.visitor_id)).label(
220+
"count"
221+
),
222+
)
223+
.where(
224+
VisitorTrack.visit_time >= start,
225+
VisitorTrack.visit_time < end,
226+
VisitorTrack.browser_name.isnot(None),
227+
)
228+
.group_by(VisitorTrack.browser_name)
229+
.order_by(
230+
func.count(func.distinct(VisitorTrack.visitor_id)).desc()
231+
)
232+
)
233+
return [
234+
{"browser_name": row[0], "count": row[1]}
235+
for row in result.fetchall()
236+
]
237+
238+
async def get_os_stats_between(
239+
self, start: datetime, end: datetime
240+
) -> list[dict[str, int | str | None]]:
241+
result = await self.session.execute(
242+
select(
243+
VisitorTrack.os_name,
244+
func.count(func.distinct(VisitorTrack.visitor_id)).label(
245+
"count"
246+
),
247+
)
248+
.where(
249+
VisitorTrack.visit_time >= start,
250+
VisitorTrack.visit_time < end,
251+
VisitorTrack.os_name.isnot(None),
252+
)
253+
.group_by(VisitorTrack.os_name)
254+
.order_by(
255+
func.count(func.distinct(VisitorTrack.visitor_id)).desc()
256+
)
257+
)
258+
return [
259+
{"os_name": row[0], "count": row[1]} for row in result.fetchall()
260+
]
261+
262+
async def get_device_stats_between(
263+
self, start: datetime, end: datetime
264+
) -> list[dict[str, int | str | None]]:
265+
result = await self.session.execute(
266+
select(
267+
VisitorTrack.device_type,
268+
func.count(func.distinct(VisitorTrack.visitor_id)).label(
269+
"count"
270+
),
271+
)
272+
.where(
273+
VisitorTrack.visit_time >= start,
274+
VisitorTrack.visit_time < end,
275+
VisitorTrack.device_type.isnot(None),
276+
)
277+
.group_by(VisitorTrack.device_type)
278+
.order_by(
279+
func.count(func.distinct(VisitorTrack.visitor_id)).desc()
280+
)
281+
)
282+
return [
283+
{"device_type": row[0], "count": row[1]}
284+
for row in result.fetchall()
285+
]

backend/app/services/book_service.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
from __future__ import annotations
22

3-
from datetime import UTC, datetime
4-
53
from app.models.models import UserBook
64
from app.repositories.book_repo import BookRepository
75

backend/app/services/monitor_service.py

Lines changed: 134 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,30 @@ def __init__(self, message: str, code: int) -> None:
2020

2121

2222
class MonitorService:
23-
def __init__(self, repo: MonitorRepository, redis: AsyncRedis) -> None:
23+
def __init__(
24+
self,
25+
repo: MonitorRepository | None,
26+
redis: AsyncRedis | None,
27+
) -> None:
2428
self.repo = repo
2529
self.redis = redis
2630

31+
def _require_repo(self) -> MonitorRepository:
32+
if self.repo is None:
33+
raise MonitorDomainError(
34+
"Monitor repository is not configured",
35+
500,
36+
)
37+
return self.repo
38+
39+
def _require_redis(self) -> AsyncRedis:
40+
if self.redis is None:
41+
raise MonitorDomainError(
42+
"Redis client is not configured",
43+
500,
44+
)
45+
return self.redis
46+
2747
@staticmethod
2848
def _get_server_status_payload() -> dict[str, float | int | None]:
2949
cpu_percent = psutil.cpu_percent(interval=1)
@@ -58,17 +78,17 @@ async def get_overview(self, days: int) -> dict:
5878
end_time = datetime.now(UTC)
5979
start_time = end_time - timedelta(days=days)
6080

61-
total_visits = await self.repo.count_visits_since(start_time)
62-
unique_visitors = await self.repo.count_unique_visitors_since(
63-
start_time
64-
)
65-
unique_visitor_ids = await self.repo.count_unique_visitor_ids_since(
81+
repo = self._require_repo()
82+
83+
total_visits = await repo.count_visits_since(start_time)
84+
unique_visitors = await repo.count_unique_visitors_since(start_time)
85+
unique_visitor_ids = await repo.count_unique_visitor_ids_since(
6686
start_time
6787
)
68-
top_pages = await self.repo.get_top_pages_since(start_time, limit=10)
69-
browser_stats = await self.repo.get_browser_stats_since(start_time)
70-
os_stats = await self.repo.get_os_stats_since(start_time)
71-
daily_trend = await self.repo.get_daily_trend_since(start_time)
88+
top_pages = await repo.get_top_pages_since(start_time, limit=10)
89+
browser_stats = await repo.get_browser_stats_since(start_time)
90+
os_stats = await repo.get_os_stats_since(start_time)
91+
daily_trend = await repo.get_daily_trend_since(start_time)
7292

7393
return {
7494
"total_visits": total_visits,
@@ -85,9 +105,11 @@ async def get_visitors(self, days: int, page: int, page_size: int) -> dict:
85105
end_time = datetime.now(UTC)
86106
start_time = end_time - timedelta(days=days)
87107

88-
total = await self.repo.count_visits_since(start_time)
108+
repo = self._require_repo()
109+
110+
total = await repo.count_visits_since(start_time)
89111
offset = (page - 1) * page_size
90-
visitors = await self.repo.list_visitors_since(
112+
visitors = await repo.list_visitors_since(
91113
start_time,
92114
offset=offset,
93115
limit=page_size,
@@ -128,7 +150,9 @@ async def get_user_logins(
128150
end_time = datetime.now(UTC)
129151
start_time = end_time - timedelta(days=days)
130152

131-
users = await self.repo.list_users_with_login_records()
153+
repo = self._require_repo()
154+
155+
users = await repo.list_users_with_login_records()
132156

133157
login_logs = []
134158
for user in users:
@@ -177,20 +201,21 @@ async def get_server_status(self) -> dict[str, float | int | None]:
177201
return self._get_server_status_payload()
178202

179203
async def get_online_users(self, include_user_details: bool) -> dict:
180-
online_count_raw = await self.redis.get("stats:online_count")
204+
redis = self._require_redis()
205+
206+
online_count_raw = await redis.get("stats:online_count")
181207
online_count = int(online_count_raw) if online_count_raw else 0
182208

183-
online_user_ids_raw = await self.redis.zrange("online_users_z", 0, -1)
209+
online_user_ids_raw = await redis.zrange("online_users_z", 0, -1)
184210
online_user_ids = [
185211
int(uid.decode() if isinstance(uid, bytes) else str(uid))
186212
for uid in online_user_ids_raw
187213
]
188214

189215
user_details: list[dict] = []
190216
if include_user_details and online_user_ids:
191-
users: list[User] = await self.repo.list_users_by_ids(
192-
online_user_ids
193-
)
217+
repo = self._require_repo()
218+
users: list[User] = await repo.list_users_by_ids(online_user_ids)
194219
user_details = [
195220
{
196221
"id": user.id,
@@ -215,3 +240,94 @@ async def stream_server_status(self) -> AsyncIterator[str]:
215240
payload = self._get_server_status_payload()
216241
yield self._to_sse_event(payload)
217242
await asyncio.sleep(5)
243+
244+
async def get_daily_summary(self, date: datetime) -> dict:
245+
"""获取指定日期的访问统计摘要。
246+
247+
Args:
248+
date: 日期(取其当天 00:00 ~ 次日 00:00)
249+
250+
Returns:
251+
包含总访问量、独立访客、热门页面等统计信息的字典
252+
"""
253+
start = date.replace(hour=0, minute=0, second=0, microsecond=0)
254+
end = start + timedelta(days=1)
255+
256+
repo = self._require_repo()
257+
258+
total_visits = await repo.count_visits_between(start, end)
259+
unique_visitors = await repo.count_unique_visitors_between(start, end)
260+
unique_ips = await repo.count_unique_ips_between(start, end)
261+
top_pages = await repo.get_top_pages_between(start, end, limit=5)
262+
browser_stats = await repo.get_browser_stats_between(start, end)
263+
os_stats = await repo.get_os_stats_between(start, end)
264+
device_stats = await repo.get_device_stats_between(start, end)
265+
266+
return {
267+
"date": start.strftime("%Y-%m-%d"),
268+
"total_visits": total_visits,
269+
"unique_visitors": unique_visitors,
270+
"unique_ips": unique_ips,
271+
"top_pages": top_pages,
272+
"browser_stats": browser_stats,
273+
"os_stats": os_stats,
274+
"device_stats": device_stats,
275+
}
276+
277+
async def cleanup_stale_heartbeats(
278+
self, *, cutoff_seconds: int = 600
279+
) -> dict:
280+
"""清理过期心跳并同步用户在线状态到数据库。
281+
282+
Args:
283+
cutoff_seconds: 超时秒数,默认 600 秒 (10 分钟)
284+
285+
Returns:
286+
包含清理统计信息的字典
287+
"""
288+
from sqlalchemy import update
289+
290+
from app.api.des.db import AsyncSessionFactory
291+
from app.models.models import User
292+
293+
redis = self._require_redis()
294+
295+
now = int(datetime.now(UTC).timestamp())
296+
cutoff_time = now - cutoff_seconds
297+
298+
removed_count = await redis.zremrangebyscore(
299+
"online_users_z", 0, cutoff_time
300+
)
301+
online_count = await redis.zcard("online_users_z")
302+
303+
await redis.set("stats:online_count", str(online_count), ex=120)
304+
305+
online_user_ids = await redis.zrange("online_users_z", 0, -1)
306+
online_user_ids = [
307+
int(uid.decode() if isinstance(uid, bytes) else str(uid))
308+
for uid in online_user_ids
309+
]
310+
311+
if online_user_ids:
312+
async with AsyncSessionFactory() as session:
313+
await session.execute(
314+
update(User)
315+
.where(User.id.in_(online_user_ids))
316+
.values(active=True)
317+
.execution_options(synchronize_session=False)
318+
)
319+
await session.execute(
320+
update(User)
321+
.where(
322+
User.id.not_in(online_user_ids),
323+
User.active,
324+
)
325+
.values(active=False)
326+
.execution_options(synchronize_session=False)
327+
)
328+
await session.commit()
329+
330+
return {
331+
"removed_count": removed_count,
332+
"online_count": online_count,
333+
}

0 commit comments

Comments
 (0)