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:
98
backend/app/api/views.py
Normal file
98
backend/app/api/views.py
Normal 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,
|
||||||
|
}
|
||||||
19
backend/app/db/migrations/004_symbol_views.sql
Normal file
19
backend/app/db/migrations/004_symbol_views.sql
Normal 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.';
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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
56
web/lib/views.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user