From b931438e51f40fbeeb406de43e4fb9e649fd2624 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 16 Mar 2026 03:32:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B5=AC=EB=8F=85=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20(=ED=95=B4=EC=A7=80,=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=EA=B0=B1=EC=8B=A0=20=ED=86=A0=EA=B8=80,=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20Cron)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subscriptions 테이블 마이그레이션 (기존 paid orders에서 자동 생성) - GET/PATCH /api/subscription: 구독 조회, 해지, 자동갱신 토글 - 마이페이지 구독 관리 탭: D-day, 해지 버튼, 자동갱신 토글 - 해지 시 만료일까지 서비스 계속 이용 가능 - Vercel Cron: 매일 01:00 KST 만료 구독 자동 처리 + 텔레그램 알림 Co-Authored-By: Claude Sonnet 4.6 --- app/api/cron/subscription-expiry/route.ts | 78 +++++++ app/api/subscription/[id]/route.ts | 87 ++++++++ app/api/subscription/route.ts | 27 +++ app/mypage/page.tsx | 256 ++++++++++++++++------ supabase/migrations/004_subscriptions.sql | 44 ++++ vercel.json | 8 + 6 files changed, 428 insertions(+), 72 deletions(-) create mode 100644 app/api/cron/subscription-expiry/route.ts create mode 100644 app/api/subscription/[id]/route.ts create mode 100644 app/api/subscription/route.ts create mode 100644 supabase/migrations/004_subscriptions.sql create mode 100644 vercel.json diff --git a/app/api/cron/subscription-expiry/route.ts b/app/api/cron/subscription-expiry/route.ts new file mode 100644 index 0000000..9d54347 --- /dev/null +++ b/app/api/cron/subscription-expiry/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { sendMessage } from '@/lib/telegram'; + +/** + * GET /api/cron/subscription-expiry + * Vercel Cron: 매일 01:00 KST (UTC 16:00) 실행 + * - 만료된 구독 → status='expired' + * - 3일 후 만료 예정 구독 → 텔레그램 알림 발송 + */ +export async function GET(req: NextRequest) { + // Vercel Cron 인증 + const authHeader = req.headers.get('authorization'); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 }); + } + + const supabase = createAdminClient(); + const now = new Date().toISOString(); + const in3days = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(); + + // 1. 만료된 구독 처리 + const { data: expired, error: expireError } = await supabase + .from('subscriptions') + .update({ status: 'expired' }) + .eq('status', 'active') + .lt('expires_at', now) + .select('id, user_id, product_id'); + + if (expireError) { + console.error('subscription expiry error:', expireError); + } + + // 2. 3일 후 만료 예정 → 텔레그램 알림 + const { data: expiringSoon } = await supabase + .from('subscriptions') + .select('id, user_id, product_id, expires_at, profiles!inner(telegram_chat_id)') + .eq('status', 'active') + .eq('auto_renew', false) + .lt('expires_at', in3days) + .gt('expires_at', now); + + const PLAN_NAMES: Record = { + lotto_gold: '🥇 골드', + lotto_platinum: '💎 플래티넘', + lotto_diamond: '👑 다이아', + }; + + let notified = 0; + if (expiringSoon) { + for (const sub of expiringSoon) { + const profile = sub.profiles as unknown as { telegram_chat_id: string | null }; + const chatId = profile?.telegram_chat_id; + if (!chatId) continue; + + const expiresAt = new Date(sub.expires_at).toLocaleDateString('ko-KR'); + const planName = PLAN_NAMES[sub.product_id] ?? sub.product_id; + + await sendMessage( + chatId, + `⏰ *구독 만료 안내*\n\n` + + `로또 번호 추천 *${planName}* 플랜이\n` + + `*${expiresAt}*에 만료됩니다.\n\n` + + `지속적인 번호 추천을 받으시려면\n` + + `마이페이지에서 구독을 갱신해 주세요.\n\n` + + `👉 https://jaengseung-made.com/mypage` + ); + notified++; + } + } + + return NextResponse.json({ + ok: true, + expired_count: expired?.length ?? 0, + notified_count: notified, + processed_at: now, + }); +} diff --git a/app/api/subscription/[id]/route.ts b/app/api/subscription/[id]/route.ts new file mode 100644 index 0000000..7962417 --- /dev/null +++ b/app/api/subscription/[id]/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +/** + * PATCH /api/subscription/[id] + * action: 'cancel' | 'toggle_autorenew' + * + * cancel — 구독 즉시 해지 (status='cancelled', auto_renew=false) + * toggle_autorenew — 자동갱신 on/off 전환 + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 }); + } + + const { id } = await params; + let body: { action?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'INVALID_JSON' }, { status: 400 }); + } + + const { action } = body; + + // 본인 구독인지 확인 + const { data: sub, error: fetchError } = await supabase + .from('subscriptions') + .select('id, status, auto_renew, expires_at') + .eq('id', id) + .eq('user_id', user.id) + .maybeSingle(); + + if (fetchError || !sub) { + return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 }); + } + + if (action === 'cancel') { + if (sub.status === 'cancelled') { + return NextResponse.json({ error: 'ALREADY_CANCELLED' }, { status: 400 }); + } + + const { error } = await supabase + .from('subscriptions') + .update({ + status: 'cancelled', + auto_renew: false, + cancelled_at: new Date().toISOString(), + }) + .eq('id', id); + + if (error) { + return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 }); + } + + return NextResponse.json({ + ok: true, + message: '구독이 해지되었습니다. 만료일까지는 서비스를 계속 이용할 수 있습니다.', + expires_at: sub.expires_at, + }); + } + + if (action === 'toggle_autorenew') { + if (sub.status === 'cancelled' || sub.status === 'expired') { + return NextResponse.json({ error: 'SUBSCRIPTION_NOT_ACTIVE' }, { status: 400 }); + } + + const newValue = !sub.auto_renew; + const { error } = await supabase + .from('subscriptions') + .update({ auto_renew: newValue }) + .eq('id', id); + + if (error) { + return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 }); + } + + return NextResponse.json({ ok: true, auto_renew: newValue }); + } + + return NextResponse.json({ error: 'INVALID_ACTION' }, { status: 400 }); +} diff --git a/app/api/subscription/route.ts b/app/api/subscription/route.ts new file mode 100644 index 0000000..3fbce5c --- /dev/null +++ b/app/api/subscription/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +/** + * GET /api/subscription + * 내 활성/만료 구독 목록 조회 + */ +export async function GET() { + const supabase = await createClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 }); + } + + const { data, error } = await supabase + .from('subscriptions') + .select('id, product_id, status, auto_renew, started_at, expires_at, cancelled_at') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + .limit(20); + + if (error) { + return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 }); + } + + return NextResponse.json({ ok: true, subscriptions: data ?? [] }); +} diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx index a39d307..451b6c3 100644 --- a/app/mypage/page.tsx +++ b/app/mypage/page.tsx @@ -15,7 +15,7 @@ function buildSajuResultUrl(rec: SajuRecord) { return url; } -type Tab = 'profile' | 'saju' | 'lotto' | 'payments' | 'orders'; +type Tab = 'profile' | 'subscription' | 'lotto' | 'saju' | 'payments' | 'orders'; type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting'; interface SajuRecord { @@ -57,9 +57,13 @@ interface LottoHistoryItem { } interface ActiveSubscription { + id: string; product_id: string; - created_at: string; + status: string; + auto_renew: boolean; + started_at: string; expires_at: string; + cancelled_at: string | null; } const PLAN_LABELS: Record = { @@ -131,24 +135,11 @@ 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); + // 구독 목록 조회 (subscriptions 테이블) + const subRes = await fetch('/api/subscription'); + if (subRes.ok) { + const subData = await subRes.json(); + setActiveSubscriptions(subData.subscriptions ?? []); } // 로또 히스토리 조회 @@ -171,6 +162,38 @@ export default function MyPage() { router.refresh(); }; + // ── 구독 해지 ── + const handleCancelSubscription = async (subId: string) => { + if (!confirm('구독을 해지하시겠습니까?\n만료일까지는 서비스를 계속 이용할 수 있습니다.')) return; + const res = await fetch(`/api/subscription/${subId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'cancel' }), + }); + if (res.ok) { + setActiveSubscriptions((prev) => + prev.map((s) => s.id === subId ? { ...s, status: 'cancelled', auto_renew: false, cancelled_at: new Date().toISOString() } : s) + ); + } else { + alert('해지 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + } + }; + + // ── 자동갱신 토글 ── + const handleToggleAutoRenew = async (subId: string) => { + const res = await fetch(`/api/subscription/${subId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'toggle_autorenew' }), + }); + if (res.ok) { + const data = await res.json(); + setActiveSubscriptions((prev) => + prev.map((s) => s.id === subId ? { ...s, auto_renew: data.auto_renew } : s) + ); + } + }; + // ── 텔레그램 연결 ── const handleTelegramConnect = async () => { setTelegramLinkState('generating'); @@ -226,12 +249,15 @@ export default function MyPage() { if (!user) return null; + const activeSubs = activeSubscriptions.filter((s) => s.status === 'active' || s.status === 'cancelled'); + 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 }, + { key: 'subscription', label: '구독 관리', count: activeSubs.length || undefined }, + { key: 'lotto', label: '로또 기록', count: lottoHistory.length || undefined }, + { key: 'saju', label: '사주 기록', count: sajuRecords.length || undefined }, + { key: 'payments', label: '결제 내역', count: payments.length || undefined }, + { key: 'orders', label: '의뢰 내역', count: orders.length || undefined }, ]; return ( @@ -321,55 +347,25 @@ export default function MyPage() { - {/* 구독 중인 서비스 */} - {activeSubscriptions.length > 0 && ( -
-

-
- 구독 중인 서비스 -

-
- {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 ( -
-
- {info?.emoji ?? '🎟'} -
-
- 로또 번호 추천 {info?.label ?? sub.product_id} -
-
- {new Date(sub.created_at).toLocaleDateString('ko-KR')} 구독 시작 -
-
-
-
- {isExpired ? ( - 만료됨 - ) : ( - <> -
- D-{daysLeft} -
-
{expiresDate.toLocaleDateString('ko-KR')} 만료
- - )} -
-
- ); - })} -
-
- - 번호 추천받기 → - + {/* 구독 중인 서비스 - 요약 (탭으로 유도) */} + {activeSubs.length > 0 && ( +
+
+ {PLAN_LABELS[activeSubs[0].product_id]?.emoji ?? '🎟'} +
+
+ 로또 {PLAN_LABELS[activeSubs[0].product_id]?.label} 구독 중 +
+
+ {Math.max(0, Math.ceil((new Date(activeSubs[0].expires_at).getTime() - Date.now()) / 86400000))}일 후 만료 + {activeSubs[0].status === 'cancelled' && ' · 해지 예정'} +
+
+
)} @@ -520,6 +516,122 @@ export default function MyPage() {
)} + {/* 구독 관리 */} + {tab === 'subscription' && ( +
+ {activeSubscriptions.length === 0 ? ( + + ) : ( + 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()) / 86400000)); + const isExpired = sub.status === 'expired'; + const isCancelled = sub.status === 'cancelled'; + const isActive = sub.status === 'active'; + + return ( +
+ {/* 헤더 */} +
+
+ {info?.emoji ?? '🎟'} +
+
+ 로또 번호 추천 {info?.label ?? sub.product_id} +
+
+ {new Date(sub.started_at).toLocaleDateString('ko-KR')} 시작 +
+
+
+ + {isActive ? '이용 중' : isCancelled ? '해지 예정' : '만료됨'} + +
+ + {/* 만료 정보 */} +
+
+
만료일
+
+ {expiresDate.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })} +
+
+
+
남은 기간
+
+ {isExpired ? '만료됨' : `D-${daysLeft}`} +
+
+
+ + {/* 자동갱신 토글 */} + {!isExpired && ( +
+
+
자동 갱신
+
+ {sub.auto_renew ? '만료 시 자동으로 갱신됩니다' : '만료 시 자동 갱신되지 않습니다'} +
+
+ +
+ )} + + {/* 해지 취소 버튼 */} + {isCancelled && ( +
+ 해지 신청됨 · {expiresDate.toLocaleDateString('ko-KR')}까지 서비스를 이용할 수 있습니다. + {sub.cancelled_at && ` (해지일: ${new Date(sub.cancelled_at).toLocaleDateString('ko-KR')})`} +
+ )} + + {/* 액션 버튼 */} +
+ + 번호 추천받기 + + {isActive && ( + + )} +
+
+ ); + }) + )} + + {/* 구독 플랜 이동 */} + +
+ )} + {/* 로또 번호 기록 */} {tab === 'lotto' && (
diff --git a/supabase/migrations/004_subscriptions.sql b/supabase/migrations/004_subscriptions.sql new file mode 100644 index 0000000..a6f2e5d --- /dev/null +++ b/supabase/migrations/004_subscriptions.sql @@ -0,0 +1,44 @@ +-- ─── 구독 관리 테이블 ────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS public.subscriptions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + product_id text NOT NULL REFERENCES public.products(id), + order_id uuid REFERENCES public.orders(id), + status text NOT NULL DEFAULT 'active', -- 'active' | 'cancelled' | 'expired' + auto_renew boolean NOT NULL DEFAULT false, -- Toss 빌링키 연동 전까지 false + started_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + cancelled_at timestamptz, + billing_key text, -- Toss 자동결제 빌링키 (향후) + created_at timestamptz NOT NULL DEFAULT now() +); + +-- RLS +ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "subscriptions_select_own" ON public.subscriptions + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "subscriptions_update_own" ON public.subscriptions + FOR UPDATE USING (auth.uid() = user_id); + +-- 인덱스 +CREATE INDEX IF NOT EXISTS subscriptions_user_status ON public.subscriptions (user_id, status); +CREATE INDEX IF NOT EXISTS subscriptions_expires_at ON public.subscriptions (expires_at) WHERE status = 'active'; + +-- ─── 기존 paid lotto orders → subscriptions 마이그레이션 ─────────────────────── +INSERT INTO public.subscriptions (user_id, product_id, order_id, status, started_at, expires_at) +SELECT + o.user_id, + o.product_id, + o.id, + CASE + WHEN (o.created_at + INTERVAL '31 days') < now() THEN 'expired' + ELSE 'active' + END, + o.created_at, + o.created_at + INTERVAL '31 days' +FROM public.orders o +WHERE o.status = 'paid' + AND o.product_id IN ('lotto_gold', 'lotto_platinum', 'lotto_diamond') +ON CONFLICT DO NOTHING; diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..eec2d3c --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/cron/subscription-expiry", + "schedule": "0 16 * * *" + } + ] +}