From ecf8b9112bb28fd815b7c2cc259718cdef72877d Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 23:56:36 +0900 Subject: [PATCH] feat(views): anonymous daily view counter with social-proof badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/api/views.py | 98 +++++++++++++++++++ .../app/db/migrations/004_symbol_views.sql | 19 ++++ backend/app/main.py | 2 + web/app/[code]/page.tsx | 26 +++++ web/components/PriceHero.tsx | 25 ++++- web/lib/api.ts | 24 +++++ web/lib/views.ts | 56 +++++++++++ 7 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/views.py create mode 100644 backend/app/db/migrations/004_symbol_views.sql create mode 100644 web/lib/views.ts diff --git a/backend/app/api/views.py b/backend/app/api/views.py new file mode 100644 index 0000000..7bf0539 --- /dev/null +++ b/backend/app/api/views.py @@ -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, + } diff --git a/backend/app/db/migrations/004_symbol_views.sql b/backend/app/db/migrations/004_symbol_views.sql new file mode 100644 index 0000000..827fe69 --- /dev/null +++ b/backend/app/db/migrations/004_symbol_views.sql @@ -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.'; diff --git a/backend/app/main.py b/backend/app/main.py index 6ff06a4..c690e96 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 265cb6f..e48e8fd 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -21,6 +21,7 @@ import { SymbolSidebar } from "../../components/SymbolSidebar"; import { TradingValuePanel } from "../../components/TradingValuePanel"; import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api"; import { recent } from "../../lib/recent"; +import { shouldRecordView } from "../../lib/views"; export default function CodePage({ params }: { params: { code: string } }) { const { code } = params; @@ -28,6 +29,7 @@ export default function CodePage({ params }: { params: { code: string } }) { const [prediction, setPrediction] = useState(null); const [err, setErr] = useState(null); const [period, setPeriod] = useState("3M"); + const [viewsToday, setViewsToday] = useState(null); const spec = periodSpec(period); const isIntraday = spec.interval === "10m"; @@ -37,6 +39,29 @@ export default function CodePage({ params }: { params: { code: string } }) { recent.push(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(() => { let alive = true; setErr(null); @@ -140,6 +165,7 @@ export default function CodePage({ params }: { params: { code: string } }) { prev={prev} asOf={asOf} series={sparkSeries} + viewsToday={viewsToday} />
diff --git a/web/components/PriceHero.tsx b/web/components/PriceHero.tsx index 6f291d7..57de371 100644 --- a/web/components/PriceHero.tsx +++ b/web/components/PriceHero.tsx @@ -15,9 +15,20 @@ type Props = { asOf?: string | null; // 헤더 우측 sparkline 용 종가 시계열 (오래된 → 최신 순). 비어 있으면 미표시. 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 pct = change != null && prev ? (change / prev) * 100 : null; const tone = @@ -49,7 +60,17 @@ export function PriceHero({ name, code, market, current, prev, asOf, series }: P )}
- {asOf &&
기준 {asOf}
} +
+ {asOf && 기준 {asOf}} + {viewsToday != null && viewsToday > 0 && ( + + 오늘 + {viewsToday.toLocaleString()} + + 명이 봤어요 + + )} +
{series && series.length >= 2 && ( diff --git a/web/lib/api.ts b/web/lib/api.ts index 1759d04..d752b3c 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -352,4 +352,28 @@ export const api = { getJson(`/api/orderbook/${encodeURIComponent(code)}`), fundamentals: (code: string) => getJson(`/api/fundamentals/${encodeURIComponent(code)}`), + recordView: (code: string) => + getJson(`/api/views/${encodeURIComponent(code)}`, { + method: "POST", + }), + views: (code: string) => + getJson(`/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[]; }; diff --git a/web/lib/views.ts b/web/lib/views.ts new file mode 100644 index 0000000..29b15bb --- /dev/null +++ b/web/lib/views.ts @@ -0,0 +1,56 @@ +// 종목 조회 카운터 클라이언트 dedupe. +// +// 서버는 받은 POST 횟수만큼 +1. 같은 사용자가 페이지를 새로고침할 때마다 카운트되면 +// 인플레됨 → 클라이언트가 localStorage 에 (code, KST today) 마크를 남겨 하루 1회만 POST. +// 새 탭/시크릿 모드는 새 사용자로 간주 (브라우저 한정 dedupe — 비로그인 앱이라 한계). + +const STORAGE_KEY = "views:logged"; + +type LoggedMap = Record; // 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; +}