feat(phase2): 사주 공개 전환 + analyze 로그인·일일제한(서버 강제)

- app/work/saju/layout.tsx: isServiceVisible 가드 제거, 사주 서비스 공개 전환
- lib/service-visibility.ts: HideableService에서 saju 제거
- app/api/admin/services/route.ts: DEFAULT_SERVICES에서 saju 행 제거
- app/api/saju/analyze/route.ts: saju_detail 결제 게이트(403) 제거,
  로그인(401) + 서버측 일일 1회 제한(429, ai_usage_log 기반)으로 교체.
  recordUsage는 실제 Gemini 해석 성공 반환 직전에만 호출(MOCK 폴백 제외)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:28:24 +09:00
parent a9f5d8cee6
commit 5fd7ab8872
4 changed files with 14 additions and 21 deletions

View File

@@ -51,7 +51,6 @@ export async function PATCH(request: Request) {
} }
const DEFAULT_SERVICES = [ const DEFAULT_SERVICES = [
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 (레거시)', is_active: false, order_index: 101 },
{ id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 }, { id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 },
{ id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 }, { id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 },
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 }, { id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },

View File

@@ -64,29 +64,23 @@ const MODELS = [
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
// ── 결제 사용자 인증 (Gemini API 무단 호출 방지) ────────── // ── 로그인 인증 + 서버측 일일 사용량 제한 (Gemini API 무단 호출 방지) ──────────
const { createClient } = await import('@/lib/supabase/server'); const { createClient } = await import('@/lib/supabase/server');
const supabase = await createClient(); const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
if (user) { if (!user) {
// 로그인된 경우: saju_detail 결제 여부 확인
const { data: paidOrder } = await supabase
.from('orders')
.select('id')
.eq('user_id', user.id)
.eq('product_id', 'saju_detail')
.eq('status', 'paid')
.maybeSingle();
if (!paidOrder) {
return NextResponse.json({ error: '사주 리포트를 구매한 사용자만 이용할 수 있습니다' }, { status: 403 });
}
} else {
// 비로그인 사용자는 AI 호출 불가 // 비로그인 사용자는 AI 호출 불가
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 }); return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
} }
const { createAdminClient } = await import('@/lib/supabase/admin');
const { getTodayUsage, recordUsage, SAJU_DAILY_LIMIT } = await import('@/lib/ai-usage');
const admin = createAdminClient();
if ((await getTodayUsage(admin, user.id, 'saju')) >= SAJU_DAILY_LIMIT) {
return NextResponse.json({ error: `오늘 AI 사주 해석을 모두 사용했습니다. (${SAJU_DAILY_LIMIT}회/일)` }, { status: 429 });
}
// ── 입력 길이 검증 (DoS / 프롬프트 인젝션 기초 방어) ────── // ── 입력 길이 검증 (DoS / 프롬프트 인젝션 기초 방어) ──────
const raw = await request.json(); const raw = await request.json();
if (JSON.stringify(raw).length > 50_000) { if (JSON.stringify(raw).length > 50_000) {
@@ -182,6 +176,9 @@ export async function POST(request: Request) {
return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis }); return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
} }
// 실제 Gemini 해석 성공 시에만 일일 사용량 카운트 (MOCK 폴백 경로는 카운트하지 않음)
await recordUsage(admin, user.id, 'saju');
return NextResponse.json({ interpretation, analysis }); return NextResponse.json({ interpretation, analysis });
} catch (error: any) { } catch (error: any) {

View File

@@ -1,6 +1,4 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { isServiceVisible } from '@/lib/service-visibility';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'AI 사주 분석', title: 'AI 사주 분석',
@@ -24,7 +22,6 @@ export const metadata: Metadata = {
}, },
}; };
export default async function SajuLayout({ children }: { children: React.ReactNode }) { export default function SajuLayout({ children }: { children: React.ReactNode }) {
if (!(await isServiceVisible('saju'))) notFound();
return <>{children}</>; return <>{children}</>;
} }

View File

@@ -3,7 +3,7 @@ import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth'; import { verifyAdminTokenNode } from '@/lib/admin-auth';
/** 숨김 가능 서비스 id (service_settings.id와 일치) */ /** 숨김 가능 서비스 id (service_settings.id와 일치) */
export type HideableService = 'saju' | 'music' | 'gyeol' | 'lotto'; export type HideableService = 'music' | 'gyeol' | 'lotto';
/** /**
* 서비스 노출 여부. admin_token 세션이면 항상 true. * 서비스 노출 여부. admin_token 세션이면 항상 true.