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,
|
||||
}
|
||||
Reference in New Issue
Block a user