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.refresh import router as refresh_router
from app.api.symbols import router as symbols_router from app.api.symbols import router as symbols_router
from app.api.themes import router as themes_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.config import settings
from app.db.connection import get_engine, ping as db_ping from app.db.connection import get_engine, ping as db_ping
from app.fetch import dart as dart_mod 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(themes_router)
app.include_router(orderbook_router) app.include_router(orderbook_router)
app.include_router(fundamentals_router) app.include_router(fundamentals_router)
app.include_router(views_router)
def _resolved_device() -> str: def _resolved_device() -> str:

View File

@@ -21,6 +21,7 @@ import { SymbolSidebar } from "../../components/SymbolSidebar";
import { TradingValuePanel } from "../../components/TradingValuePanel"; import { TradingValuePanel } from "../../components/TradingValuePanel";
import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api"; import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api";
import { recent } from "../../lib/recent"; import { recent } from "../../lib/recent";
import { shouldRecordView } from "../../lib/views";
export default function CodePage({ params }: { params: { code: string } }) { export default function CodePage({ params }: { params: { code: string } }) {
const { code } = params; const { code } = params;
@@ -28,6 +29,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null); const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [period, setPeriod] = useState<Period>("3M"); const [period, setPeriod] = useState<Period>("3M");
const [viewsToday, setViewsToday] = useState<number | null>(null);
const spec = periodSpec(period); const spec = periodSpec(period);
const isIntraday = spec.interval === "10m"; const isIntraday = spec.interval === "10m";
@@ -37,6 +39,29 @@ export default function CodePage({ params }: { params: { code: string } }) {
recent.push(code); recent.push(code);
}, [code]); }, [code]);
// 조회 카운터.
// - 같은 (code, KST today) 는 localStorage 마크로 dedupe → POST 1회.
// - dedupe 실패 (스토리지 잠금 등) 해도 GET 으로 today_views 는 조회.
// - POST 응답이 today_views 를 주므로 별도 GET 안 해도 됨 (한 라운드트립으로 끝).
// - 이미 봤던 종목이면 GET 만.
useEffect(() => {
let alive = true;
const apply = (n: number) => {
if (alive) setViewsToday(n);
};
if (shouldRecordView(code)) {
api.recordView(code).then((r) => apply(r.today_views)).catch(() => {
// POST 실패 시 GET 한 번 시도 (혹시 다른 사용자 카운트라도 보여줌).
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
});
} else {
api.views(code).then((r) => apply(r.today_views)).catch(() => {});
}
return () => {
alive = false;
};
}, [code]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setErr(null); setErr(null);
@@ -140,6 +165,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
prev={prev} prev={prev}
asOf={asOf} asOf={asOf}
series={sparkSeries} series={sparkSeries}
viewsToday={viewsToday}
/> />
<div className="grid gap-6 lg:grid-cols-[1fr_280px]"> <div className="grid gap-6 lg:grid-cols-[1fr_280px]">
<div> <div>

View File

@@ -15,9 +15,20 @@ type Props = {
asOf?: string | null; asOf?: string | null;
// 헤더 우측 sparkline 용 종가 시계열 (오래된 → 최신 순). 비어 있으면 미표시. // 헤더 우측 sparkline 용 종가 시계열 (오래된 → 최신 순). 비어 있으면 미표시.
series?: number[]; series?: number[];
// social-proof — "오늘 N명이 봤어요" 배지. null/0 이면 숨김.
viewsToday?: number | null;
}; };
export function PriceHero({ name, code, market, current, prev, asOf, series }: Props) { export function PriceHero({
name,
code,
market,
current,
prev,
asOf,
series,
viewsToday,
}: Props) {
const change = current != null && prev != null ? current - prev : null; const change = current != null && prev != null ? current - prev : null;
const pct = change != null && prev ? (change / prev) * 100 : null; const pct = change != null && prev ? (change / prev) * 100 : null;
const tone = const tone =
@@ -49,7 +60,17 @@ export function PriceHero({ name, code, market, current, prev, asOf, series }: P
</span> </span>
)} )}
</div> </div>
{asOf && <div className="mt-1 text-xs text-zinc-500"> {asOf}</div>} <div className="mt-1 flex items-center gap-2 text-xs text-zinc-500">
{asOf && <span> {asOf}</span>}
{viewsToday != null && viewsToday > 0 && (
<span className="rounded-full border border-zinc-700/70 bg-zinc-900/60 px-2 py-0.5 text-[11px] text-zinc-300">
<span className="tabular-nums text-zinc-100">
{viewsToday.toLocaleString()}
</span>
</span>
)}
</div>
</div> </div>
{series && series.length >= 2 && ( {series && series.length >= 2 && (

View File

@@ -352,4 +352,28 @@ export const api = {
getJson<OrderbookResponse>(`/api/orderbook/${encodeURIComponent(code)}`), getJson<OrderbookResponse>(`/api/orderbook/${encodeURIComponent(code)}`),
fundamentals: (code: string) => fundamentals: (code: string) =>
getJson<FundamentalsResponse>(`/api/fundamentals/${encodeURIComponent(code)}`), getJson<FundamentalsResponse>(`/api/fundamentals/${encodeURIComponent(code)}`),
recordView: (code: string) =>
getJson<RecordViewResponse>(`/api/views/${encodeURIComponent(code)}`, {
method: "POST",
}),
views: (code: string) =>
getJson<ViewsResponse>(`/api/views/${encodeURIComponent(code)}`),
};
export type RecordViewResponse = {
ok: boolean;
code?: string;
today_views: number;
};
export type ViewsTrendPoint = {
date: string;
views: number;
};
export type ViewsResponse = {
code: string;
today_views: number;
last_7d_views: number;
trend: ViewsTrendPoint[];
}; };

56
web/lib/views.ts Normal file
View File

@@ -0,0 +1,56 @@
// 종목 조회 카운터 클라이언트 dedupe.
//
// 서버는 받은 POST 횟수만큼 +1. 같은 사용자가 페이지를 새로고침할 때마다 카운트되면
// 인플레됨 → 클라이언트가 localStorage 에 (code, KST today) 마크를 남겨 하루 1회만 POST.
// 새 탭/시크릿 모드는 새 사용자로 간주 (브라우저 한정 dedupe — 비로그인 앱이라 한계).
const STORAGE_KEY = "views:logged";
type LoggedMap = Record<string, string>; // code → "YYYY-MM-DD"
function kstToday(): string {
// KST = UTC+9. naive: Date 가 시스템 TZ 영향을 받으므로 UTC 기준 +9 로 직접 계산.
const now = new Date();
const utcMs = now.getTime() + now.getTimezoneOffset() * 60_000;
const kst = new Date(utcMs + 9 * 60 * 60_000);
const y = kst.getFullYear();
const m = String(kst.getMonth() + 1).padStart(2, "0");
const d = String(kst.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function readLogged(): LoggedMap {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as LoggedMap) : {};
} catch {
return {};
}
}
function writeLogged(m: LoggedMap): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(m));
} catch {
/* quota 초과 등 — 무시. dedupe 실패 시 최악으로 한 사용자가 N번 카운트될 뿐. */
}
}
/** 오늘 처음 보는 code 면 true, 이미 봤으면 false. true 인 경우 mark 까지 수행. */
export function shouldRecordView(code: string): boolean {
const today = kstToday();
const logged = readLogged();
if (logged[code] === today) return false;
logged[code] = today;
// 한도: 너무 커지면 오래된 항목 제거. 단순히 100개로 잘라냄.
const entries = Object.entries(logged);
if (entries.length > 100) {
const trimmed: LoggedMap = Object.fromEntries(entries.slice(-100));
writeLogged(trimmed);
} else {
writeLogged(logged);
}
return true;
}