feat(stats): 일별 방문자 집계 추가
page_visits 테이블((visit_date, device_id) 유니크) — 하루 1기기 1회 집계. POST /api/visit(접속 기록), GET /api/admin/visits(일별 순수 방문자, Bearer). 프론트는 App 마운트 시 1회 recordVisit() 호출. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>develop
parent
51352c7c5c
commit
140e15fb9f
|
|
@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
|
||||
from .config import settings
|
||||
from .database import init_db
|
||||
from .routers import admin, leaderboard, matches, predictions
|
||||
from .routers import admin, leaderboard, matches, predictions, visits
|
||||
from .scoring import load_scoring_data
|
||||
from .seed import seed_if_empty
|
||||
|
||||
|
|
@ -44,6 +44,7 @@ app.include_router(matches.router)
|
|||
app.include_router(predictions.router)
|
||||
app.include_router(leaderboard.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(visits.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
|
|
@ -178,3 +179,22 @@ class UserPoints(Base):
|
|||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class PageVisit(Base):
|
||||
"""페이지 방문 — 하루(KST)에 같은 기기(device_id)는 1회만 기록(순수 방문자 수).
|
||||
|
||||
일별 집계 = visit_date 로 GROUP BY COUNT. 같은 날 재방문은 유니크 제약으로 무시.
|
||||
"""
|
||||
|
||||
__tablename__ = "page_visits"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("visit_date", "device_id", name="uq_visit_date_device"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
visit_date: Mapped[date] = mapped_column(Date, index=True) # KST 기준 날짜
|
||||
device_id: Mapped[str] = mapped_column(String) # 비로그인 식별 (localStorage uuid)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..domain import now_utc
|
||||
from ..models import AIPrediction, Match
|
||||
from ..schemas import AdminAIPredictionIn, AdminSetResultIn, GenericOk
|
||||
from ..models import AIPrediction, Match, PageVisit
|
||||
from ..schemas import AdminAIPredictionIn, AdminSetResultIn, DailyVisitOut, GenericOk
|
||||
from ..services.grading import apply_result
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
|
@ -70,3 +70,18 @@ async def set_result(
|
|||
detail="result set & graded",
|
||||
extra={"gradedPicks": graded, "emailsSentBy": "worker"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/visits", response_model=list[DailyVisitOut], dependencies=[Depends(require_admin)]
|
||||
)
|
||||
async def daily_visits(db: AsyncSession = Depends(get_db)) -> list[DailyVisitOut]:
|
||||
"""일별 순수 방문자 수 (최신순)."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(PageVisit.visit_date, func.count())
|
||||
.group_by(PageVisit.visit_date)
|
||||
.order_by(PageVisit.visit_date.desc())
|
||||
)
|
||||
).all()
|
||||
return [DailyVisitOut(date=d.isoformat(), uniqueVisitors=c) for d, c in rows]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
"""방문자 집계 — 페이지 접속 기록(일별 순수 방문자)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..domain import KST, now_utc
|
||||
from ..models import PageVisit
|
||||
from ..schemas import VisitIn, VisitOut
|
||||
|
||||
router = APIRouter(prefix="/api/visit", tags=["visits"])
|
||||
|
||||
|
||||
@router.post("", response_model=VisitOut)
|
||||
async def record_visit(body: VisitIn, db: AsyncSession = Depends(get_db)) -> VisitOut:
|
||||
"""페이지 접속 기록. 같은 기기가 같은 날(KST) 재접속하면 집계하지 않는다."""
|
||||
today = now_utc().astimezone(KST).date()
|
||||
db.add(PageVisit(visit_date=today, device_id=body.deviceId))
|
||||
try:
|
||||
await db.commit()
|
||||
return VisitOut(ok=True, counted=True) # 오늘 첫 방문 → 집계
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
return VisitOut(ok=True, counted=False) # 재방문 → 무시
|
||||
|
|
@ -125,3 +125,18 @@ class GenericOk(BaseModel):
|
|||
ok: bool
|
||||
detail: str = ""
|
||||
extra: dict | None = None
|
||||
|
||||
|
||||
# ── 방문자 집계 ─────────────────────────────────────────────
|
||||
class VisitIn(BaseModel):
|
||||
deviceId: str = Field(min_length=8, max_length=64)
|
||||
|
||||
|
||||
class VisitOut(BaseModel):
|
||||
ok: bool
|
||||
counted: bool # 오늘 첫 방문이면 True(집계됨), 재방문이면 False
|
||||
|
||||
|
||||
class DailyVisitOut(BaseModel):
|
||||
date: str # YYYY-MM-DD (KST)
|
||||
uniqueVisitors: int
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import { useEffect } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import MatchDetail from "./pages/MatchDetail";
|
||||
import Leaderboard from "./pages/Leaderboard";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import { recordVisit } from "./lib/api";
|
||||
|
||||
export default function App() {
|
||||
// 접속 시 1회 방문 기록 (하루 1기기 1회 집계는 백엔드가 처리)
|
||||
useEffect(() => {
|
||||
recordVisit();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
|
|
|
|||
|
|
@ -66,6 +66,14 @@ export function getLeaderboard(): Promise<Leaderboard> {
|
|||
return http<Leaderboard>(`/leaderboard`);
|
||||
}
|
||||
|
||||
// 방문 기록 (하루 1기기 1회 집계). 실패는 무시(fire-and-forget).
|
||||
export function recordVisit(): void {
|
||||
void http(`/visit`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ deviceId: getDeviceId() }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ── 비로그인 식별: deviceId (localStorage 영속) ──
|
||||
const DEVICE_KEY = "tp_device_id";
|
||||
export function getDeviceId(): string {
|
||||
|
|
|
|||
Loading…
Reference in New Issue