feat: 로또 번호 히스토리 저장 + 마이페이지 구독정보/히스토리 표시
- POST /api/lotto/history: 생성 번호 저장 API - GET /api/lotto/history: 히스토리 조회 API - 번호 생성 시 자동 히스토리 저장 (NAS/클라이언트 출처 구분) - 합계 표시 복원 - 마이페이지: 활성 구독 카드 (D-day, 만료일 표시) - 마이페이지: 로또 기록 탭 추가 (번호볼 + 출처 + 플랜 표시) - Supabase 마이그레이션: lotto_history 테이블 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
69
app/api/lotto/history/route.ts
Normal file
69
app/api/lotto/history/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
/**
|
||||
* POST /api/lotto/history
|
||||
* 생성된 로또 번호 조합을 히스토리에 저장
|
||||
* Body: { numbers: number[], source: 'nas' | 'client', plan_id: string }
|
||||
*
|
||||
* GET /api/lotto/history
|
||||
* 내 로또 번호 히스토리 조회
|
||||
* Query: limit (기본 50)
|
||||
*/
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { numbers?: number[]; source?: string; plan_id?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'INVALID_JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { numbers, source = 'client', plan_id } = body;
|
||||
if (!Array.isArray(numbers) || numbers.length !== 6 || !plan_id) {
|
||||
return NextResponse.json({ error: 'INVALID_BODY' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('lotto_history').insert({
|
||||
user_id: user.id,
|
||||
numbers,
|
||||
source,
|
||||
plan_id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('lotto_history insert error:', error);
|
||||
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get('limit') ?? '50'), 200);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('lotto_history')
|
||||
.select('id, numbers, source, plan_id, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, history: data ?? [] });
|
||||
}
|
||||
@@ -15,7 +15,7 @@ function buildSajuResultUrl(rec: SajuRecord) {
|
||||
return url;
|
||||
}
|
||||
|
||||
type Tab = 'profile' | 'saju' | 'payments' | 'orders';
|
||||
type Tab = 'profile' | 'saju' | 'lotto' | 'payments' | 'orders';
|
||||
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
|
||||
|
||||
interface SajuRecord {
|
||||
@@ -48,6 +48,26 @@ interface Order {
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LottoHistoryItem {
|
||||
id: number;
|
||||
numbers: number[];
|
||||
source: string;
|
||||
plan_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ActiveSubscription {
|
||||
product_id: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<string, { label: string; emoji: string; color: string }> = {
|
||||
lotto_gold: { label: '골드', emoji: '🥇', color: 'amber' },
|
||||
lotto_platinum: { label: '플래티넘', emoji: '💎', color: 'sky' },
|
||||
lotto_diamond: { label: '다이아', emoji: '👑', color: 'violet' },
|
||||
};
|
||||
|
||||
export default function MyPage() {
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
@@ -57,6 +77,8 @@ export default function MyPage() {
|
||||
const [sajuRecords, setSajuRecords] = useState<SajuRecord[]>([]);
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [lottoHistory, setLottoHistory] = useState<LottoHistoryItem[]>([]);
|
||||
const [activeSubscriptions, setActiveSubscriptions] = useState<ActiveSubscription[]>([]);
|
||||
|
||||
// 텔레그램 연동 상태
|
||||
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
|
||||
@@ -109,6 +131,35 @@ export default function MyPage() {
|
||||
.maybeSingle();
|
||||
setTelegramChatId(profile?.telegram_chat_id ?? null);
|
||||
|
||||
// 활성 구독 조회 (paid 상태의 lotto 플랜)
|
||||
const LOTTO_PLANS = ['lotto_gold', 'lotto_platinum', 'lotto_diamond'];
|
||||
const { data: subs } = await supabase
|
||||
.from('orders')
|
||||
.select('product_id, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'paid')
|
||||
.in('product_id', LOTTO_PLANS)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (subs && subs.length > 0) {
|
||||
const activeSubs: ActiveSubscription[] = subs.map((s) => {
|
||||
const createdAt = new Date(s.created_at);
|
||||
const expiresAt = new Date(createdAt);
|
||||
expiresAt.setDate(expiresAt.getDate() + 31);
|
||||
return { product_id: s.product_id, created_at: s.created_at, expires_at: expiresAt.toISOString() };
|
||||
});
|
||||
setActiveSubscriptions(activeSubs);
|
||||
}
|
||||
|
||||
// 로또 히스토리 조회
|
||||
const { data: history } = await supabase
|
||||
.from('lotto_history')
|
||||
.select('id, numbers, source, plan_id, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
setLottoHistory(history ?? []);
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
init();
|
||||
@@ -178,6 +229,7 @@ export default function MyPage() {
|
||||
const tabs: { key: Tab; label: string; count?: number }[] = [
|
||||
{ key: 'profile', label: '내 정보' },
|
||||
{ key: 'saju', label: '사주 기록', count: sajuRecords.length },
|
||||
{ key: 'lotto', label: '🎰 로또 기록', count: lottoHistory.length },
|
||||
{ key: 'payments', label: '결제 내역', count: payments.length },
|
||||
{ key: 'orders', label: '의뢰 내역', count: orders.length },
|
||||
];
|
||||
@@ -269,6 +321,58 @@ export default function MyPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구독 중인 서비스 */}
|
||||
{activeSubscriptions.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
|
||||
<div className="w-1 h-5 bg-gradient-to-b from-amber-400 to-orange-500 rounded-full" />
|
||||
구독 중인 서비스
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{activeSubscriptions.map((sub) => {
|
||||
const info = PLAN_LABELS[sub.product_id];
|
||||
const expiresDate = new Date(sub.expires_at);
|
||||
const daysLeft = Math.max(0, Math.ceil((expiresDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||
const isExpired = daysLeft === 0;
|
||||
return (
|
||||
<div key={sub.product_id + sub.created_at}
|
||||
className={`flex items-center justify-between p-4 rounded-xl border ${isExpired ? 'border-slate-200 bg-slate-50' : 'border-amber-200 bg-amber-50/50'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{info?.emoji ?? '🎟'}</span>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-[#04102b]">
|
||||
로또 번호 추천 {info?.label ?? sub.product_id}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{new Date(sub.created_at).toLocaleDateString('ko-KR')} 구독 시작
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{isExpired ? (
|
||||
<span className="text-xs font-bold text-slate-400 bg-slate-100 px-2 py-1 rounded-lg">만료됨</span>
|
||||
) : (
|
||||
<>
|
||||
<div className={`text-sm font-bold ${daysLeft <= 5 ? 'text-red-500' : 'text-amber-600'}`}>
|
||||
D-{daysLeft}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">{expiresDate.toLocaleDateString('ko-KR')} 만료</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<a href="/services/lotto/recommend"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold text-amber-600 hover:text-amber-700 transition">
|
||||
번호 추천받기 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 텔레그램 연동 카드 */}
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
|
||||
@@ -416,6 +520,56 @@ export default function MyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로또 번호 기록 */}
|
||||
{tab === 'lotto' && (
|
||||
<div>
|
||||
{lottoHistory.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="🎰"
|
||||
title="생성된 번호 기록이 없습니다"
|
||||
desc="로또 번호 추천 페이지에서 번호를 생성하면 여기에 기록됩니다"
|
||||
linkHref="/services/lotto/recommend"
|
||||
linkLabel="번호 추천받기"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-slate-400 mb-1">총 {lottoHistory.length}개 조합 생성</div>
|
||||
{lottoHistory.map((item) => {
|
||||
const info = PLAN_LABELS[item.plan_id];
|
||||
return (
|
||||
<div key={item.id} className="bg-white rounded-2xl border border-[#dbe8ff] px-5 py-4 flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{item.numbers.map((n) => {
|
||||
const color =
|
||||
n <= 10 ? 'bg-yellow-400 text-yellow-900' :
|
||||
n <= 20 ? 'bg-blue-500 text-white' :
|
||||
n <= 30 ? 'bg-red-500 text-white' :
|
||||
n <= 40 ? 'bg-slate-500 text-white' :
|
||||
'bg-green-500 text-white';
|
||||
return (
|
||||
<span key={n} className={`w-8 h-8 rounded-full ${color} flex items-center justify-center text-xs font-black shadow-sm`}>
|
||||
{n}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${item.source === 'nas' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : 'bg-slate-100 text-slate-500'}`}>
|
||||
{item.source === 'nas' ? 'NAS 추천' : '로컬 생성'}
|
||||
</span>
|
||||
<span className="text-xs text-amber-600 font-semibold">{info?.emoji} {info?.label}</span>
|
||||
<span className="text-xs text-slate-400">{new Date(item.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사주 기록 */}
|
||||
{tab === 'saju' && (
|
||||
<div>
|
||||
|
||||
@@ -274,6 +274,16 @@ export default function LottoRecommendPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── 히스토리 저장 (fire-and-forget) ──
|
||||
const saveHistory = (numbers: number[], source: 'nas' | 'client') => {
|
||||
if (!plan) return;
|
||||
fetch('/api/lotto/history', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ numbers, source, plan_id: plan }),
|
||||
}).catch(() => {/* 저장 실패는 조용히 무시 */});
|
||||
};
|
||||
|
||||
// ── 프리미엄 번호 생성 ──
|
||||
const handleGenerate = async () => {
|
||||
if (proState === 'loading' || combos.length >= MAX_COMBOS) return;
|
||||
@@ -292,6 +302,7 @@ export default function LottoRecommendPage() {
|
||||
const newCombos: Combo[] = Array.from({ length: count }, () => {
|
||||
idRef.current += 1;
|
||||
const { numbers, metrics } = clientMonteCarlo();
|
||||
saveHistory(numbers, 'client');
|
||||
return { id: idRef.current, numbers, metrics, createdAt: new Date() };
|
||||
});
|
||||
setCombos((prev) => [...prev, ...newCombos].slice(-MAX_COMBOS));
|
||||
@@ -305,16 +316,20 @@ export default function LottoRecommendPage() {
|
||||
const data: BatchResponse = await res.json();
|
||||
const newCombos: Combo[] = (data.items ?? []).map((item) => {
|
||||
idRef.current += 1;
|
||||
return { id: idRef.current, numbers: [...item.numbers].sort((a,b)=>a-b), metrics: item.metrics, createdAt: new Date() };
|
||||
const numbers = [...item.numbers].sort((a,b)=>a-b);
|
||||
saveHistory(numbers, 'nas');
|
||||
return { id: idRef.current, numbers, metrics: item.metrics, createdAt: new Date() };
|
||||
});
|
||||
setCombos((prev) => [...prev, ...newCombos].slice(-MAX_COMBOS));
|
||||
} else {
|
||||
const data: RecommendResponse = await res.json();
|
||||
if (!data.numbers?.length) throw new Error('EMPTY_RESULT');
|
||||
idRef.current += 1;
|
||||
const numbers = [...data.numbers].sort((a,b)=>a-b);
|
||||
saveHistory(numbers, 'nas');
|
||||
setCombos((prev) => [...prev, {
|
||||
id: idRef.current,
|
||||
numbers: [...data.numbers].sort((a,b)=>a-b),
|
||||
numbers,
|
||||
metrics: data.metrics,
|
||||
overlap: data.recent_overlap?.repeated_numbers,
|
||||
createdAt: new Date(),
|
||||
@@ -583,7 +598,7 @@ export default function LottoRecommendPage() {
|
||||
{/* 메트릭 */}
|
||||
{latestCombo?.metrics && !isProLoading && (
|
||||
<div style={{ display:'flex',gap:'1rem',justifyContent:'center',marginBottom:'1.25rem',flexWrap:'wrap' }}>
|
||||
{[{l:'홀수',v:`${latestCombo.metrics.odd}개`},{l:'짝수',v:`${latestCombo.metrics.even}개`},{l:'범위',v:latestCombo.metrics.range}].map(s=>(
|
||||
{[{l:'합계',v:latestCombo.metrics.sum},{l:'홀수',v:`${latestCombo.metrics.odd}개`},{l:'짝수',v:`${latestCombo.metrics.even}개`},{l:'범위',v:latestCombo.metrics.range}].map(s=>(
|
||||
<div key={s.l} style={{ background:'rgba(251,191,36,.07)',border:'1px solid rgba(251,191,36,.12)',borderRadius:'.5rem',padding:'.3rem .75rem',textAlign:'center' }}>
|
||||
<div style={{ color:'#fbbf24',fontSize:'.85rem',fontWeight:800 }}>{s.v}</div>
|
||||
<div style={{ color:'rgba(253,230,138,.4)',fontSize:'.62rem' }}>{s.l}</div>
|
||||
@@ -637,7 +652,7 @@ export default function LottoRecommendPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex',alignItems:'center',gap:'.75rem' }}>
|
||||
{c.metrics&&<span style={{ color:'rgba(251,191,36,.4)',fontSize:'.68rem' }}>홀 {c.metrics.odd} · 짝 {c.metrics.even}</span>}
|
||||
{c.metrics&&<span style={{ color:'rgba(251,191,36,.4)',fontSize:'.68rem' }}>합 {c.metrics.sum} · 홀 {c.metrics.odd}</span>}
|
||||
<div style={{ color:'rgba(255,255,255,.18)',fontSize:'.68rem' }}>{c.createdAt.toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit',second:'2-digit'})}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
21
supabase/migrations/003_lotto_history.sql
Normal file
21
supabase/migrations/003_lotto_history.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- ─── 로또 번호 히스토리 테이블 ──────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS lotto_history (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
numbers integer[] NOT NULL,
|
||||
source text NOT NULL DEFAULT 'client', -- 'nas' | 'client'
|
||||
plan_id text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE lotto_history ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "lotto_history_select_own" ON lotto_history
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "lotto_history_insert_own" ON lotto_history
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS lotto_history_user_created ON lotto_history (user_id, created_at DESC);
|
||||
Reference in New Issue
Block a user