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:
claude-owner
2026-05-28 23:56:36 +09:00
parent ef127ae2e8
commit ecf8b9112b
7 changed files with 248 additions and 2 deletions

98
backend/app/api/views.py Normal file
View 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,
}

View 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.';

View File

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