- 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>
99 lines
3.3 KiB
Python
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,
|
|
}
|