feat(views): anonymous daily view counter with social-proof badge
- New table symbol_views_daily(code, view_date, views) via migration 004.
- POST /api/views/{code} UPSERTs and returns today_views; GET returns
today + last_7d + trend (last 7 days).
- Client dedupes via localStorage (views:logged map keyed by KST date) so a
given browser counts once per day per code; refresh/extra tabs don't inflate.
- PriceHero shows "오늘 N명이 봤어요" pill when viewsToday > 0.
Read-only social proof — Toss 의 종목 상세에서 익명 카운트로 진입 신호를 주는 패턴.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
98
backend/app/api/views.py
Normal file
98
backend/app/api/views.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""종목 페이지 익명 조회 카운터.
|
||||
|
||||
토스 같은 social-proof — "오늘 N명이 봤어요" 배지를 위한 가벼운 PG 카운터.
|
||||
|
||||
- POST /api/views/{code} : views(code, today) UPSERT +1, 새 today_views 반환
|
||||
- GET /api/views/{code} : today_views + 최근 7일 시계열 반환
|
||||
|
||||
dedupe 는 클라이언트(localStorage) 에서 — 하루 1회만 POST. 서버는 단순 +1.
|
||||
부하 들어와도 같은 코드는 하루 1 increment 라 row 하나에 hot write 가 누적되지만
|
||||
유즘량 수십~수백 수준이라 lock contention 무시 가능. 진짜 늘면 그때 sharded 카운터로.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.connection import get_engine
|
||||
|
||||
router = APIRouter(prefix="/api/views", tags=["views"])
|
||||
|
||||
|
||||
def _kst_today() -> date:
|
||||
# PG NOW() AT TIME ZONE 'Asia/Seoul' 과 같은 의미로 KST 기준 오늘 날짜.
|
||||
# 컨테이너 TZ 가 KST 면 date.today() 가 같지만, UTC 면 안 맞음 → 명시.
|
||||
from datetime import datetime, timezone
|
||||
utc = datetime.now(timezone.utc)
|
||||
# KST = UTC+9
|
||||
return (utc + timedelta(hours=9)).date()
|
||||
|
||||
|
||||
@router.post("/{code}")
|
||||
def record_view(code: str) -> dict:
|
||||
"""code 의 오늘 카운터 +1. UPSERT 후 today_views 반환.
|
||||
|
||||
symbols 테이블 매칭 검사 안 함 — 카운터 자체는 코드 존재 여부와 독립적으로 동작해야 함
|
||||
(오타/리스트 빠진 종목도 추적). 다만 빈 code 만 거부.
|
||||
"""
|
||||
code = code.strip()
|
||||
if not code:
|
||||
return {"ok": False, "today_views": 0}
|
||||
today = _kst_today()
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO symbol_views_daily (code, view_date, views)
|
||||
VALUES (:c, :d, 1)
|
||||
ON CONFLICT (code, view_date)
|
||||
DO UPDATE SET views = symbol_views_daily.views + 1
|
||||
RETURNING views
|
||||
"""
|
||||
),
|
||||
{"c": code, "d": today},
|
||||
).first()
|
||||
today_views = int(row[0]) if row else 0
|
||||
return {"ok": True, "code": code, "today_views": today_views}
|
||||
|
||||
|
||||
@router.get("/{code}")
|
||||
def get_views(code: str) -> dict:
|
||||
"""code 의 오늘 + 최근 7일 일별 시계열.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"code": "005930",
|
||||
"today_views": 42,
|
||||
"last_7d_views": 320,
|
||||
"trend": [{"date": "YYYY-MM-DD", "views": N}, ...] # 오래된 → 최신
|
||||
}
|
||||
"""
|
||||
code = code.strip()
|
||||
today = _kst_today()
|
||||
start = today - timedelta(days=6)
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT view_date, views
|
||||
FROM symbol_views_daily
|
||||
WHERE code = :c AND view_date BETWEEN :s AND :e
|
||||
ORDER BY view_date ASC
|
||||
"""
|
||||
),
|
||||
{"c": code, "s": start, "e": today},
|
||||
).all()
|
||||
trend = [{"date": r[0].isoformat(), "views": int(r[1])} for r in rows]
|
||||
today_views = next((t["views"] for t in trend if t["date"] == today.isoformat()), 0)
|
||||
last_7d = sum(t["views"] for t in trend)
|
||||
return {
|
||||
"code": code,
|
||||
"today_views": today_views,
|
||||
"last_7d_views": last_7d,
|
||||
"trend": trend,
|
||||
}
|
||||
19
backend/app/db/migrations/004_symbol_views.sql
Normal file
19
backend/app/db/migrations/004_symbol_views.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Phase 5: 종목 페이지 익명 조회 카운터.
|
||||
-- (code, view_date) PK 로 일 단위 집계. Redis/KV 없이 PG 만으로 충분 — 쓰기 빈도가
|
||||
-- 낮고(페이지 진입 시 클라이언트 측에서 하루 1회로 dedupe), UPSERT 1회 비용은 무시 가능.
|
||||
-- symbols(code) FK 는 일부러 안 걺 — 삭제된/오타 코드 방문에도 카운터가 죽지 않게.
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
CREATE TABLE IF NOT EXISTS symbol_views_daily (
|
||||
code TEXT NOT NULL,
|
||||
view_date DATE NOT NULL,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (code, view_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS symbol_views_daily_date_idx
|
||||
ON symbol_views_daily (view_date DESC);
|
||||
|
||||
COMMENT ON TABLE symbol_views_daily IS
|
||||
'Phase 5: 종목 페이지 익명 조회 카운터. 일 단위 UPSERT(+1). 클라이언트가 localStorage 로 dedupe.';
|
||||
@@ -18,6 +18,7 @@ from app.api.predict import router as predict_router
|
||||
from app.api.refresh import router as refresh_router
|
||||
from app.api.symbols import router as symbols_router
|
||||
from app.api.themes import router as themes_router
|
||||
from app.api.views import router as views_router
|
||||
from app.config import settings
|
||||
from app.db.connection import get_engine, ping as db_ping
|
||||
from app.fetch import dart as dart_mod
|
||||
@@ -110,6 +111,7 @@ app.include_router(markets_router)
|
||||
app.include_router(themes_router)
|
||||
app.include_router(orderbook_router)
|
||||
app.include_router(fundamentals_router)
|
||||
app.include_router(views_router)
|
||||
|
||||
|
||||
def _resolved_device() -> str:
|
||||
|
||||
Reference in New Issue
Block a user