From 140e15fb9fb2517cf56d4fbaa5b2a2da109bc7f7 Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 11 Jun 2026 17:31:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(stats):=20=EC=9D=BC=EB=B3=84=20=EB=B0=A9?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=EC=A7=91=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/main.py | 3 ++- backend/app/models.py | 22 +++++++++++++++++++++- backend/app/routers/admin.py | 21 ++++++++++++++++++--- backend/app/routers/visits.py | 26 ++++++++++++++++++++++++++ backend/app/schemas.py | 15 +++++++++++++++ frontend/src/App.tsx | 7 +++++++ frontend/src/lib/api.ts | 8 ++++++++ 7 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 backend/app/routers/visits.py diff --git a/backend/app/main.py b/backend/app/main.py index 7bcbbc7..ef50f77 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index dc4df40..1948adc 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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() + ) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2ec22c3..4393ea2 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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] diff --git a/backend/app/routers/visits.py b/backend/app/routers/visits.py new file mode 100644 index 0000000..247fc78 --- /dev/null +++ b/backend/app/routers/visits.py @@ -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) # 재방문 → 무시 diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ffa59aa..1b6a8bd 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b62c28c..b5a85bb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( } /> diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f4eac22..5081fbf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -66,6 +66,14 @@ export function getLeaderboard(): Promise { return http(`/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 {