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
hbyang 2026-06-11 17:31:23 +09:00
parent 51352c7c5c
commit 140e15fb9f
7 changed files with 97 additions and 5 deletions

View File

@ -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")

View File

@ -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()
)

View File

@ -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]

View File

@ -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) # 재방문 → 무시

View File

@ -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

View File

@ -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 />} />

View File

@ -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 {