Files
stock_chart_site/backend/app/api/views.py
claude-owner ecf8b9112b 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>
2026-05-28 23:56:36 +09:00

99 lines
3.3 KiB
Python

"""종목 페이지 익명 조회 카운터.
토스 같은 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,
}