Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
40 KiB
Phase 2 사주 재활성 + 타로 신규 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 사주를 공개·무료화(로그인+일일제한)하고, web-ui 타로 구조를 이 repo에 포팅해 회원 결과 저장·마이페이지 재확인까지 붙인다.
Architecture: 타로는 순수 로직(카드·셔플·reference)을 lib/tarot/에 두고 테스트, AI는 Gemini 재사용(strict JSON + reroll), 저장은 user_id+RLS 테이블. 사주는 가드/결제 게이트를 로그인 게이트로 교체하고 서버측 일일 제한을 강제. 마이페이지 5번째 탭이 두 서비스 기록을 통합.
Tech Stack: Next.js 16 (App Router, TS), Tailwind v4(--jsm-*), Supabase, @google/generative-ai (기존), vitest
Spec: docs/superpowers/specs/2026-07-02-phase2-saju-tarot-design.md
포팅 원본: C:\Users\jaeoh\Desktop\workspace\web-ui\src\pages\tarot\ (조사 보고 스펙에 포함)
Global Constraints
- 디자인 가드레일: gradient / blur / 보라(violet/purple) / 이모지 금지, 공개 신규 페이지는
--jsm-*토큰. (카드 PNG 이미지 에셋은 가드레일 대상 아님) - 카피 가드레일: "대기업 N년차" 류 자격 어필 금지
- 셔플/역방향은 클라이언트 전용(
'use client'+ effect 초기화) — SSR hydration mismatch 방지 - AI 실패한 생성은 일일 카운트에 넣지 않음 — 성공 시에만 recordUsage
- 일일 제한 상수:
SAJU_DAILY_LIMIT = 1,TAROT_DAILY_LIMIT = 3(lib/ai-usage.ts) - next.config.ts redirects() 수정 금지 (
/saju→/work/saju유지) - 기존 supabase/migrations/ 파일 삭제·수정 금지, 신규 1개만
- GEMINI_API_KEY 미설정 시 타로 interpret는 503(예시 해석 미제공)
- 커밋은 스코프 파일만 스테이징 —
git add -A·git commit -a금지, 커밋 전git status확인 - 각 Task 종료 시
npm test전체 통과 +npm run build성공 후 커밋 - 커밋 트레일러:
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
확인된 기존 구조
- supabase:
createClient()(세션·RLS,lib/supabase/server.ts),createAdminClient()(service role,lib/supabase/admin.ts) - saju analyze:
@google/generative-ai, MODELS 폴백 배열(gemini-2.5-pro→2.5-flash→2.0-flash),GEMINI_API_KEY미설정 시 MOCK 반환,dotenv.env.local 로드,maxDuration=60 - saju guard:
app/work/saju/layout.tsx:28isServiceVisible('saju')+notFound() - mypage:
type Tab = 'profile'|'requests'|'products'|'orders'(25행), TABS 배열(308~311행) - TopNav LINKS(9~13행): outsourcing/products/showcase 3개
Task 1: 타로 카드 데이터 포팅 (lib/tarot/cards.ts)
Files:
- Create:
lib/tarot/cards.ts - Test:
lib/__tests__/tarot-cards.test.ts
Interfaces:
-
Produces:
type TarotCard,TAROT_DECK: TarotCard[](78장),SPREADS,CATEGORIES: string[],findCard(slug: string): TarotCard | undefined— 이후 Task 2·4·6이 소비 -
Step 1: 실패 테스트 작성
lib/__tests__/tarot-cards.test.ts:
import { describe, it, expect } from 'vitest';
import { TAROT_DECK, findCard, CATEGORIES } from '../tarot/cards';
describe('TAROT_DECK', () => {
it('78장이다', () => { expect(TAROT_DECK).toHaveLength(78); });
it('slug가 고유하다', () => {
const slugs = TAROT_DECK.map((c) => c.slug);
expect(new Set(slugs).size).toBe(78);
});
it('메이저 22 + 마이너 56', () => {
expect(TAROT_DECK.filter((c) => c.arcana === 'major')).toHaveLength(22);
expect(TAROT_DECK.filter((c) => c.arcana === 'minor')).toHaveLength(56);
});
it('모든 카드에 필수 필드가 채워져 있다', () => {
for (const c of TAROT_DECK) {
expect(c.name.length).toBeGreaterThan(0);
expect(c.nameEn.length).toBeGreaterThan(0);
expect(c.keywords.length).toBeGreaterThan(0);
expect(c.reversedKeywords.length).toBeGreaterThan(0);
expect(c.meaningUpright.length).toBeGreaterThan(0);
expect(c.meaningReversed.length).toBeGreaterThan(0);
expect(c.image).toMatch(/^\/images\/tarot\/cards\/[a-z0-9-]+\.png$/);
}
});
it('findCard가 slug로 카드를 찾는다', () => {
expect(findCard('the-fool')?.nameEn).toBe('The Fool');
expect(findCard('nonexistent')).toBeUndefined();
});
it('CATEGORIES는 6개', () => { expect(CATEGORIES).toHaveLength(6); });
});
-
Step 2: 실패 확인 —
npx vitest run lib/__tests__/tarot-cards.test.ts→ FAIL(모듈 없음) -
Step 3: 구현 — web-ui cards.js를 TS로 포팅
C:\Users\jaeoh\Desktop\workspace\web-ui\src\pages\tarot\data\cards.js(672줄)의 MAJOR_ARCANA, MAJOR_DETAILS, SUIT_DETAILS, RANK_DETAILS, CARD_LENSES, buildMinor()/buildMinorDetails() 로직, SPREADS, CATEGORIES, findCard를 데이터·알고리즘 그대로 lib/tarot/cards.ts로 옮긴다. 상단에 타입 정의 추가:
export type TarotCard = {
id: number;
slug: string;
name: string;
nameEn: string;
arcana: 'major' | 'minor';
element: 'air' | 'water' | 'fire' | 'earth';
suit?: 'wands' | 'cups' | 'swords' | 'pentacles';
rank?: number;
keywords: string[];
reversedKeywords: string[];
meaningUpright: string;
meaningReversed: string;
symbols: { label: string; meaning: string }[];
image: string;
};
export type Spread = { id: 'three_card'; name: string; positions: string[] };
-
image필드는/images/tarot/cards/${slug}.png형식 유지(web-ui와 동일 경로) -
SPREADS는 three_card만 포함(원카드 제외 — 범위 밖):[{ id:'three_card', name:'3카드(과거·현재·미래)', positions:['과거','현재','미래'] }] -
CATEGORIES = ['연애','일·커리어','관계','재물','건강','일반'] -
JS의 무타입 객체에 위 타입을 부여하되 데이터 값은 변경 금지. lint(
no-explicit-any) 통과하도록 타입 명시 -
Step 4: 통과 확인 —
npx vitest run lib/__tests__/tarot-cards.test.ts→ 6 PASS -
Step 5: 커밋 —
git add lib/tarot/cards.ts lib/__tests__/tarot-cards.test.ts && git commit -m "feat(phase2): 타로 78장 카드 데이터 TS 포팅 + 무결성 테스트"
Task 2: 셔플 + reference 유틸 (lib/tarot/)
Files:
- Create:
lib/tarot/shuffle.ts,lib/tarot/reference.ts - Test:
lib/__tests__/tarot-shuffle.test.ts,lib/__tests__/tarot-reference.test.ts
Interfaces:
-
Consumes:
TarotCard(Task 1) -
Produces:
type Pick = { card: TarotCard; position: string; reversed: boolean }fisherYates<T>(input: T[]): T[]buildShuffle(deck: TarotCard[], size: number): (TarotCard & { reversed: boolean })[]buildReferenceBlock(picks: Pick[]): stringbuildContextMeta(picks: Pick[]): { major_minor_ratio: string; element_distribution: Record<string, number>; orientation_flow: string }- Task 4(interpret API)·Task 6(UI)이 소비
-
Step 1: 셔플 테스트
lib/__tests__/tarot-shuffle.test.ts:
import { describe, it, expect } from 'vitest';
import { fisherYates, buildShuffle } from '../tarot/shuffle';
import { TAROT_DECK } from '../tarot/cards';
describe('fisherYates', () => {
it('원본을 변형하지 않고 같은 원소 집합을 반환한다', () => {
const input = [1, 2, 3, 4, 5];
const out = fisherYates(input);
expect(input).toEqual([1, 2, 3, 4, 5]);
expect([...out].sort()).toEqual([1, 2, 3, 4, 5]);
});
});
describe('buildShuffle', () => {
it('요청한 수만큼, 중복 없이, reversed 필드를 갖고 반환한다', () => {
const out = buildShuffle(TAROT_DECK, 20);
expect(out).toHaveLength(20);
expect(new Set(out.map((c) => c.slug)).size).toBe(20);
for (const c of out) expect(typeof c.reversed).toBe('boolean');
});
});
- Step 2: 셔플 구현
lib/tarot/shuffle.ts:
import type { TarotCard } from './cards';
export type Pick = { card: TarotCard; position: string; reversed: boolean };
export function fisherYates<T>(input: T[]): T[] {
const a = [...input];
for (let i = a.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
export function buildShuffle(deck: TarotCard[], size: number): (TarotCard & { reversed: boolean })[] {
return fisherYates(deck)
.slice(0, size)
.map((c) => ({ ...c, reversed: Math.random() < 0.5 }));
}
- Step 3: reference 테스트
lib/__tests__/tarot-reference.test.ts:
import { describe, it, expect } from 'vitest';
import { buildReferenceBlock, buildContextMeta } from '../tarot/reference';
import { findCard } from '../tarot/cards';
const picks = [
{ card: findCard('the-fool')!, position: '과거', reversed: false },
{ card: findCard('the-magician')!, position: '현재', reversed: true },
{ card: findCard('the-high-priestess')!, position: '미래', reversed: false },
];
describe('buildReferenceBlock', () => {
it('각 카드의 위치·정역·키워드·의미를 텍스트 블록으로 만든다', () => {
const block = buildReferenceBlock(picks);
expect(block).toContain('과거');
expect(block).toContain('The Fool');
expect(block).toContain('정방향');
expect(block).toContain('역방향');
expect(block.length).toBeGreaterThan(50);
});
});
describe('buildContextMeta', () => {
it('메이저 비율·원소 분포·정역 흐름을 계산한다', () => {
const meta = buildContextMeta(picks);
expect(meta.major_minor_ratio).toBe('3:0');
expect(meta.orientation_flow).toBe('upright→reversed→upright');
expect(typeof meta.element_distribution).toBe('object');
});
});
- Step 4: reference 구현
lib/tarot/reference.ts — web-ui useTarotReading.js:6-41의 buildReferenceBlock/buildContextMeta 로직 포팅(정역방향에 따라 keywords/reversedKeywords·meaningUpright/meaningReversed 선택):
import type { Pick } from './shuffle';
export function buildReferenceBlock(picks: Pick[]): string {
return picks
.map((p, i) => {
const c = p.card;
const dir = p.reversed ? '역방향' : '정방향';
const kws = (p.reversed ? c.reversedKeywords : c.keywords).join(', ');
const meaning = p.reversed ? c.meaningReversed : c.meaningUpright;
const arcana = c.arcana === 'major' ? `Major (${c.id})` : `Minor (${c.suit})`;
return [
`## ${i + 1}. 위치: ${p.position} | 카드: ${c.nameEn} (${dir})`,
`- 아르카나: ${arcana}`,
`- 원소: ${c.element}`,
`- ${dir} 키워드: ${kws}`,
`- ${dir} 의미: ${meaning}`,
].join('\n');
})
.join('\n\n');
}
export function buildContextMeta(picks: Pick[]) {
const major = picks.filter((p) => p.card.arcana === 'major').length;
const minor = picks.length - major;
const element_distribution: Record<string, number> = { air: 0, water: 0, fire: 0, earth: 0 };
for (const p of picks) element_distribution[p.card.element] += 1;
const orientation_flow = picks.map((p) => (p.reversed ? 'reversed' : 'upright')).join('→');
return { major_minor_ratio: `${major}:${minor}`, element_distribution, orientation_flow };
}
- Step 5: 통과 확인 —
npx vitest run lib/__tests__/tarot-shuffle.test.ts lib/__tests__/tarot-reference.test.ts→ PASS - Step 6: 커밋 —
git add lib/tarot/shuffle.ts lib/tarot/reference.ts lib/__tests__/tarot-shuffle.test.ts lib/__tests__/tarot-reference.test.ts && git commit -m "feat(phase2): 타로 셔플·reference 순수 유틸 + 테스트"
Task 3: 일일 사용량 유틸 + DB 마이그레이션
Files:
- Create:
lib/ai-usage.ts,lib/__tests__/ai-usage.test.ts - Create:
supabase/migrations/2026-07-02-phase2-saju-tarot.sql
Interfaces:
-
Produces:
SAJU_DAILY_LIMIT = 1,TAROT_DAILY_LIMIT = 3kstDayStartISO(now: Date): string(KST 자정의 UTC ISO)getTodayUsage(admin, userId, service): Promise<number>recordUsage(admin, userId, service): Promise<void>- Task 4·7이 소비.
admin은createAdminClient()반환 타입
-
Step 1: kstDayStartISO 테스트
lib/__tests__/ai-usage.test.ts:
import { describe, it, expect } from 'vitest';
import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT } from '../ai-usage';
describe('kstDayStartISO', () => {
it('KST 자정을 UTC로 환산한다 (KST 15:00 UTC = 당일 00:00 KST)', () => {
// 2026-07-02T05:00:00Z = 2026-07-02 14:00 KST → 그날 KST 자정 = 2026-07-01T15:00:00Z
expect(kstDayStartISO(new Date('2026-07-02T05:00:00Z'))).toBe('2026-07-01T15:00:00.000Z');
});
it('KST 자정 직후도 같은 날로 계산한다', () => {
// 2026-07-01T15:30:00Z = 2026-07-02 00:30 KST → KST 자정 = 2026-07-01T15:00:00Z
expect(kstDayStartISO(new Date('2026-07-01T15:30:00Z'))).toBe('2026-07-01T15:00:00.000Z');
});
it('제한 상수', () => {
expect(SAJU_DAILY_LIMIT).toBe(1);
expect(TAROT_DAILY_LIMIT).toBe(3);
});
});
-
Step 2: 실패 확인 —
npx vitest run lib/__tests__/ai-usage.test.ts→ FAIL -
Step 3: 구현
lib/ai-usage.ts:
import type { SupabaseClient } from '@supabase/supabase-js';
export const SAJU_DAILY_LIMIT = 1;
export const TAROT_DAILY_LIMIT = 3;
export type AiService = 'saju' | 'tarot';
/** KST(UTC+9) 자정을 UTC ISO로. 오늘 사용량 집계 하한. */
export function kstDayStartISO(now: Date): string {
const kstMs = now.getTime() + 9 * 60 * 60 * 1000;
const kst = new Date(kstMs);
const kstMidnightUtcMs = Date.UTC(kst.getUTCFullYear(), kst.getUTCMonth(), kst.getUTCDate()) - 9 * 60 * 60 * 1000;
return new Date(kstMidnightUtcMs).toISOString();
}
export async function getTodayUsage(admin: SupabaseClient, userId: string, service: AiService): Promise<number> {
const since = kstDayStartISO(new Date());
const { count } = await admin
.from('ai_usage_log')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('service', service)
.gte('created_at', since);
return count ?? 0;
}
export async function recordUsage(admin: SupabaseClient, userId: string, service: AiService): Promise<void> {
await admin.from('ai_usage_log').insert({ user_id: userId, service });
}
-
Step 4: 통과 확인 —
npx vitest run lib/__tests__/ai-usage.test.ts→ PASS -
Step 5: 마이그레이션 파일 — 스펙 §WS2 DB SQL 그대로
supabase/migrations/2026-07-02-phase2-saju-tarot.sql:
-- Phase 2 (2026-07-02): 타로 저장·AI 사용량 로그 + 사주 숨김 해제
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
CREATE TABLE IF NOT EXISTS tarot_readings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
spread_type text NOT NULL DEFAULT 'three_card',
category text,
question text,
cards jsonb NOT NULL,
interpretation jsonb NOT NULL,
summary text,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE tarot_readings ENABLE ROW LEVEL SECURITY;
CREATE POLICY tarot_select_own ON tarot_readings FOR SELECT USING (auth.uid() = user_id);
CREATE TABLE IF NOT EXISTS ai_usage_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
service text NOT NULL CHECK (service IN ('saju','tarot')),
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE ai_usage_log ENABLE ROW LEVEL SECURITY;
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_day ON ai_usage_log (user_id, service, created_at);
DELETE FROM service_settings WHERE id = 'saju';
- Step 6: 검증·커밋 —
npm test && npm run buildPASS.git add lib/ai-usage.ts lib/__tests__/ai-usage.test.ts supabase/migrations/2026-07-02-phase2-saju-tarot.sql && git commit -m "feat(phase2): 일일 사용량 유틸(KST) + tarot_readings·ai_usage_log 마이그레이션"
Task 4: 타로 프롬프트 + interpret API
Files:
- Create:
lib/tarot/prompt.ts,app/api/tarot/interpret/route.ts
Interfaces:
-
Consumes:
TarotCard/findCard(T1),Pick(T2),getTodayUsage/recordUsage/TAROT_DAILY_LIMIT(T3) -
Produces:
type TarotInterpretation(interpretation_json 스키마 타입)POST /api/tarot/interpret→ 200{ interpretation_json: TarotInterpretation, model: string }/ 401 / 429{ error }/ 503{ error }- Task 5·6이 소비
-
Step 1: 프롬프트·스키마 모듈
lib/tarot/prompt.ts — web-ui tarot-lab prompt.py/schema.py 포팅:
export type TarotInterpretation = {
summary: string;
cards: {
position: string; card: string; reversed: boolean; interpretation: string;
evidence: { card_meaning_used: string; position_logic: string; category_lens: string };
advice: string;
}[];
interactions: { type: 'synergy' | 'conflict' | 'transition'; between: string[]; explanation: string }[];
advice: string;
warning: string | null;
confidence: 'high' | 'medium' | 'low';
};
export const TAROT_SYSTEM_PROMPT = `당신은 라이더-웨이트(RWS) 덱 전통 상징에 기반해 타로를 해석하는 전문가입니다.
해석 원칙:
1. 제공된 참고 블록의 키워드/의미만 근거로 삼습니다. 외부 해석·임의 상징을 도입하지 않습니다.
2. 각 카드의 위치 의미와 카드 의미를 결합하고, evidence에 근거를 남깁니다.
3. 3장 스프레드는 카드 간 상호작용(원소·슈트·메이저 비율의 시너지, 슈트 충돌, 정역 전환)을 분석합니다.
4. 운명을 단정하지 말고 성찰을 돕는 톤으로 씁니다.
5. 카테고리에 따라 강조점을 달리합니다.
6. 사용자의 질문을 evidence와 advice에서 인용합니다.
반드시 코드블록 없이 순수 JSON만 출력합니다. 아래 스키마를 정확히 따릅니다:
{"summary","cards":[{"position","card","reversed","interpretation","evidence":{"card_meaning_used","position_logic","category_lens"},"advice"}],"interactions":[{"type":"synergy|conflict|transition","between":[],"explanation"}],"advice","warning","confidence":"high|medium|low"}
confidence: 카드들이 일관된 서사면 high, 충돌이 크면 low.`;
export function buildTarotUserMessage(input: {
spread_type: string; category: string | null; question: string | null;
cards_reference: string; context_meta: unknown;
}): string {
return [
input.question ? `질문: ${input.question}` : '질문: (없음)',
input.category ? `카테고리: ${input.category}` : '카테고리: 일반',
`스프레드: ${input.spread_type} (3장)`,
'--- 카드 참고 블록 ---',
input.cards_reference,
'--- 맥락 메타 ---',
JSON.stringify(input.context_meta),
'위 근거만으로 스키마에 맞는 JSON을 생성하세요.',
].join('\n');
}
/** 코드블록 스트립 + {...} 추출 후 파싱. 실패 시 null */
export function parseTarotJson(raw: string): TarotInterpretation | null {
let text = raw.trim().replace(/^\`\`\`(json)?/i, '').replace(/\`\`\`$/,'').trim();
const first = text.indexOf('{'); const last = text.lastIndexOf('}');
if (first >= 0 && last > first) text = text.slice(first, last + 1);
try { return JSON.parse(text) as TarotInterpretation; } catch { return null; }
}
/** 스키마 검증. 통과 못하면 사유 문자열, 통과면 null */
export function validateTarot(obj: unknown, spreadType: string): string | null {
if (!obj || typeof obj !== 'object') return 'not an object';
const o = obj as Record<string, unknown>;
if (typeof o.summary !== 'string' || !o.summary) return 'summary 누락';
if (!Array.isArray(o.cards) || o.cards.length === 0) return 'cards 누락';
for (const c of o.cards as Record<string, unknown>[]) {
if (typeof c.position !== 'string' || typeof c.card !== 'string') return 'card position/card 누락';
if (typeof c.interpretation !== 'string' || !c.interpretation) return 'card interpretation 누락';
const ev = c.evidence as Record<string, unknown> | undefined;
if (!ev || !ev.card_meaning_used || !ev.position_logic || !ev.category_lens) return 'evidence 3필드 필요';
if (typeof c.advice !== 'string') return 'card advice 누락';
}
if (!Array.isArray(o.interactions)) return 'interactions 누락';
if (spreadType === 'three_card' && (o.interactions as unknown[]).length < 1) return 'three_card interactions ≥1 필요';
if (typeof o.advice !== 'string' || !o.advice) return 'advice 누락';
if (!['high', 'medium', 'low'].includes(o.confidence as string)) return 'confidence enum 오류';
return null;
}
- Step 2: interpret API
app/api/tarot/interpret/route.ts — 사주 analyze의 Gemini 폴백 패턴 재사용 + 인증·제한·reroll:
import { NextResponse } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { getTodayUsage, recordUsage, TAROT_DAILY_LIMIT } from '@/lib/ai-usage';
import { TAROT_SYSTEM_PROMPT, buildTarotUserMessage, parseTarotJson, validateTarot } from '@/lib/tarot/prompt';
import { config as loadDotenv } from 'dotenv';
import { resolve } from 'path';
export const runtime = 'nodejs';
export const maxDuration = 60;
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
const MODELS = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'];
export async function POST(request: Request) {
// 1) 인증
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
// 2) 일일 제한
const admin = createAdminClient();
const used = await getTodayUsage(admin, user.id, 'tarot');
if (used >= TAROT_DAILY_LIMIT) {
return NextResponse.json({ error: `오늘 타로 AI 해석을 모두 사용했습니다. (${TAROT_DAILY_LIMIT}회/일)` }, { status: 429 });
}
// 3) 입력
let body: Record<string, unknown>;
try { body = await request.json(); } catch { return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 }); }
const spread_type = String(body.spread_type ?? 'three_card');
const cards_reference = typeof body.cards_reference === 'string' ? body.cards_reference : '';
if (!cards_reference) return NextResponse.json({ error: 'cards_reference 필요' }, { status: 400 });
// 4) API 키
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
const genAI = new GoogleGenerativeAI(apiKey);
const userMsg = buildTarotUserMessage({
spread_type,
category: (body.category as string) ?? null,
question: (body.question as string) ?? null,
cards_reference,
context_meta: body.context_meta ?? {},
});
// 5) 호출 + 최대 2회(검증 실패 시 사유 주입 reroll 1회)
let feedback = '';
for (let attempt = 0; attempt < 2; attempt += 1) {
for (const modelId of MODELS) {
try {
const model = genAI.getGenerativeModel({ model: modelId, systemInstruction: TAROT_SYSTEM_PROMPT });
const prompt = feedback ? `${userMsg}\n\n[이전 시도 오류: ${feedback}] 스키마를 정확히 지켜 다시 출력하세요.` : userMsg;
const res = await model.generateContent(prompt);
const parsed = parseTarotJson(res.response.text());
const invalid = parsed ? validateTarot(parsed, spread_type) : 'JSON 파싱 실패';
if (parsed && !invalid) {
await recordUsage(admin, user.id, 'tarot');
return NextResponse.json({ interpretation_json: parsed, model: modelId });
}
feedback = invalid ?? 'JSON 파싱 실패';
} catch (e) {
feedback = e instanceof Error ? e.message : 'model error';
continue; // 다음 모델
}
}
}
return NextResponse.json({ error: '해석 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' }, { status: 502 });
}
(사주 analyze의 실제 MODELS 배열·model 옵션 형태를 Read해서 파라미터명이 다르면 맞출 것 — 특히 systemInstruction/getGenerativeModel 시그니처)
- Step 3: 검증·커밋 —
npm test && npm run buildPASS(라우트 등장).git add lib/tarot/prompt.ts app/api/tarot/interpret/route.ts && git commit -m "feat(phase2): 타로 interpret API — Gemini strict JSON + 인증·일일제한·reroll"
Task 5: 타로 저장·조회 API
Files:
- Create:
app/api/tarot/readings/route.ts
Interfaces:
-
Consumes:
createClient,createAdminClient,TarotInterpretation(T4) -
Produces:
POST /api/tarot/readings(로그인) body{ spread_type, category, question, cards, interpretation_json }→ 200{ id, created_at }/ 401GET /api/tarot/readings(로그인) →{ readings: [{ id, spread_type, category, question, cards, interpretation, summary, created_at }] }/ 401- Task 6·9가 소비
-
Step 1: 구현
app/api/tarot/readings/route.ts:
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
let body: Record<string, unknown>;
try { body = await request.json(); } catch { return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 }); }
const interp = body.interpretation_json as { summary?: string } | undefined;
if (!interp) return NextResponse.json({ error: 'interpretation_json 필요' }, { status: 400 });
const admin = createAdminClient();
const { data, error } = await admin.from('tarot_readings').insert({
user_id: user.id,
spread_type: (body.spread_type as string) ?? 'three_card',
category: (body.category as string) ?? null,
question: (body.question as string) ?? null,
cards: body.cards ?? [],
interpretation: interp,
summary: interp.summary ?? null,
}).select('id, created_at').single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data);
}
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
// 세션 클라이언트로 본인 것만(RLS tarot_select_own)
const { data, error } = await supabase
.from('tarot_readings')
.select('id, spread_type, category, question, cards, interpretation, summary, created_at')
.order('created_at', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ readings: data ?? [] });
}
- Step 2: 검증·커밋 —
npm test && npm run buildPASS.git add app/api/tarot/readings/route.ts && git commit -m "feat(phase2): 타로 저장·조회 API (user_id + RLS 본인 조회)"
Task 6: 카드 이미지 복사 + 타로 UI
Files:
- Create:
public/images/tarot/cards/*.png(78) +public/images/tarot/card_back.png - Create:
app/tarot/page.tsx,app/tarot/TarotReadingClient.tsx,app/tarot/layout.tsx
Interfaces:
-
Consumes:
TAROT_DECK/findCard/SPREADS/CATEGORIES(T1),buildShuffle/Pick/buildReferenceBlock/buildContextMeta(T2), interpret·readings API(T4·T5) -
Produces: 공개 라우트
/tarot -
Step 1: 이미지 복사
mkdir -p public/images/tarot/cards
cp /c/Users/jaeoh/Desktop/workspace/web-ui/public/images/tarot/cards/*.png public/images/tarot/cards/
cp /c/Users/jaeoh/Desktop/workspace/web-ui/public/images/tarot/card_back.png public/images/tarot/
ls public/images/tarot/cards | wc -l # 기대: 78
- Step 2: layout(메타데이터)
app/tarot/layout.tsx:
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '타로 리딩 | 쟁승메이드',
description: '3카드(과거·현재·미래) 타로 스프레드. AI가 카드 상징을 근거로 해석합니다.',
openGraph: { title: '타로 리딩 | 쟁승메이드', url: 'https://jaengseung-made.com/tarot' },
};
export default function TarotLayout({ children }: { children: React.ReactNode }) { return <>{children}</>; }
- Step 3: 페이지 셸 + 클라이언트 컴포넌트
app/tarot/page.tsx(서버, Hero + 클라이언트 마운트):
import TarotReadingClient from './TarotReadingClient';
export default function TarotPage() {
return (
<div>
{/* Hero: h1 "타로 리딩" + 부제 "3장의 카드로 과거·현재·미래의 흐름을 읽습니다." */}
<TarotReadingClient />
</div>
);
}
app/tarot/TarotReadingClient.tsx('use client') — web-ui Reading.jsx 구조 포팅:
-
셔플 초기화:
const [deck, setDeck] = useState<(TarotCard&{reversed:boolean})[]>([]); useEffect(() => setDeck(buildShuffle(TAROT_DECK, 20)), [])(hydration mismatch 방지 — 최초 빈 배열 렌더 후 클라에서 셔플) -
3-step 상태머신:
step: 'setup'|'pick'|'result'- setup: 질문 textarea(선택) + 카테고리 버튼(CATEGORIES) → "카드 뽑기" → step 'pick'
- pick: deck 20장 뒷면(
card_back.png) 부채꼴, 클릭 시 position 순서(SPREADS[0].positions: 과거/현재/미래)대로picks에 push, 이미 뽑은 slug 제외. 3장 차면 step 'result' - result: 뽑은 3장 앞면(이미지 +
<img onError>텍스트 폴백: 카드명/영문명) + 2탭- "카드 해석"(항상): 각 카드 키워드·의미(정역 반영)·상징
- "AI 인사이트": 버튼으로 interpret 호출. 로그인 안 됐으면(401) "로그인하면 AI 해석 무료(일 3회)" +
/login?next=/tarot링크. 429면 제한 안내. 성공 시 summary·카드별 해석+evidence·interactions·advice·warning·confidence 뱃지 렌더 + 자동POST /readings저장 시도(실패해도 해석 유지)
-
interpret 호출 payload:
{ spread_type:'three_card', category, question, cards: picks.map(p=>({position:p.position, card_id:p.card.slug, reversed:p.reversed})), cards_reference: buildReferenceBlock(picks), context_meta: buildContextMeta(picks) } -
디자인:
--jsm-*토큰, 카드 앞/뒷면·역방향 회전(transform: rotate(180deg)), gradient/blur/보라/이모지 금지 -
주석 블록은 전부 실제 구현. 스타일은
app/products/page.tsx·app/showcase/page.tsx라이트 관용구 참고 -
Step 4: 검증 —
npm test && npm run buildPASS(라우트/tarot등장).grep -nE "gradient|violet|purple|blur" app/tarot/*.tsx→ 0건 -
Step 5: 커밋 —
git add public/images/tarot app/tarot && git commit -m "feat(phase2): 타로 UI(3카드 리딩) + 카드 이미지 78종"
Task 7: 사주 공개 전환 + 서버측 일일 제한
Files:
- Modify:
app/work/saju/layout.tsx(가드 제거) - Modify:
lib/service-visibility.ts(HideableService에서 saju 제거) - Modify:
app/api/admin/services/route.ts(DEFAULT_SERVICES saju 행 제거) - Modify:
app/api/saju/analyze/route.ts(인증 + 일일 제한)
Interfaces:
-
Consumes:
getTodayUsage/recordUsage/SAJU_DAILY_LIMIT(T3) -
Produces:
/work/saju공개, analyze는 로그인+일 1회 -
Step 1: 가드 제거
app/work/saju/layout.tsx:
import type { Metadata } from 'next';
export const metadata: Metadata = { /* 기존 metadata 유지 */ };
export default function SajuLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
(import notFound·isServiceVisible 제거, metadata 객체는 기존 값 그대로 유지)
- Step 2: HideableService에서 saju 제거
lib/service-visibility.ts: export type HideableService = 'music' | 'gyeol' | 'lotto';
- Step 3: DEFAULT_SERVICES saju 행 제거
app/api/admin/services/route.ts에서 { id: 'saju', ... } 한 줄 삭제 (music/gyeol/lotto 유지)
- Step 4: analyze에 인증 + 일일 제한 추가
app/api/saju/analyze/route.ts POST 핸들러 최상단(입력 파싱 전)에:
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { getTodayUsage, recordUsage, SAJU_DAILY_LIMIT } from '@/lib/ai-usage';
// ...
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
const admin = createAdminClient();
if ((await getTodayUsage(admin, user.id, 'saju')) >= SAJU_DAILY_LIMIT) {
return NextResponse.json({ error: `오늘 AI 사주 해석을 모두 사용했습니다. (${SAJU_DAILY_LIMIT}회/일)` }, { status: 429 });
}
그리고 실제 Gemini 해석이 성공 반환되는 지점 직전에 await recordUsage(admin, user.id, 'saju'); 추가. (MOCK 폴백 경로에는 recordUsage 넣지 않음 — 실 해석 성공만 카운트. 기존 핸들러 구조를 Read해서 성공 반환 지점 정확히 파악)
- Step 5: 검증·커밋 —
npm test && npm run buildPASS.git add app/work/saju/layout.tsx lib/service-visibility.ts app/api/admin/services/route.ts app/api/saju/analyze/route.ts && git commit -m "feat(phase2): 사주 공개 전환 + analyze 로그인·일일제한(서버 강제)"
Task 8: 사주 AI 섹션 무료화(로그인 게이트)
Files:
- Modify:
app/work/saju/result/SajuAISection.tsx - Modify:
app/work/saju/result/page.tsx(hasPaid → 로그인 여부)
Interfaces:
-
Consumes: analyze/save API(기존), 401·429 응답(T7)
-
Produces: 없음
-
Step 1: page.tsx의 hasPaid를 로그인 여부로
app/work/saju/result/page.tsx: 기존 hasPaid(orders 'saju_detail' 조회)를 제거하고 const hasPaid = !!user;(세션 유저 존재)로 대체. 저장된 해석 조회(savedInterpretation) 로직은 유지. hasPaid prop 이름은 유지(SajuAISection이 소비) — 의미만 "로그인됨"으로
- Step 2: SajuAISection의 미로그인 UI 교체
app/work/saju/result/SajuAISection.tsx의 if (!hasPaid) 블록(Phase 0에서 "개편 준비 중" 문구로 바뀐 부분)을 로그인 유도로 교체:
if (!hasPaid) {
return (
<div className="...(기존 컨테이너 스타일 유지)">
{/* AI PREMIUM 뱃지 + "AI 상세 해석 (12개 항목)" 제목 + 미리보기 SECTION_META 그리드 유지 */}
<a href={`/login?next=${encodeURIComponent(pathname + search)}`} className="...(기존 버튼 스타일)">
로그인하고 AI 상세 해석 무료로 받기
</a>
<p className="...">로그인 회원은 하루 1회 무료 · 저장된 해석은 언제든 다시 보기</p>
</div>
);
}
(현재 경로는 usePathname/useSearchParams로. 컴포넌트가 이미 클라이언트면 그대로, 아니면 next 파라미터는 서버에서 prop으로 전달)
-
429 처리: 해석 요청 fetch가 429면 상태 메시지로 "오늘 무료 횟수를 모두 사용했습니다" 표시(기존 error 상태 재사용)
-
Step 3: 검증·커밋 —
npm test && npm run buildPASS. 가드레일 grep(변경분).git add app/work/saju/result/SajuAISection.tsx app/work/saju/result/page.tsx && git commit -m "feat(phase2): 사주 AI 해석 무료화 — 결제 게이트 → 로그인 게이트"
Task 9: 마이페이지 'AI 기록' 탭
Files:
- Modify:
app/mypage/page.tsx
Interfaces:
-
Consumes:
GET /api/tarot/readings(T5),saju_records(세션 조회) -
Produces: 없음
-
Step 1: Tab 타입·TABS·로드
app/mypage/page.tsx:
type Tab = 'profile' | 'requests' | 'products' | 'orders' | 'ai';(25행)- TABS 배열에
{ key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length) || undefined }추가 - state:
const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]); const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]); - 타입:
type TarotReadingRow = { id: string; category: string | null; question: string | null; cards: { position: string; card_id?: string; reversed?: boolean }[]; interpretation: { summary?: string; advice?: string; warning?: string | null }; summary: string | null; created_at: string };
type SajuRecordRow = { id: string; saju_data: Record<string, unknown>; created_at: string; is_paid: boolean };
- 로드 함수(초기 useEffect에 배선):
const loadAiRecords = useCallback(async () => {
try {
const tr = await fetch('/api/tarot/readings');
if (tr.ok) setTarotReadings((await tr.json()).readings ?? []);
} catch { /* 무시 */ }
try {
// 사주: 세션 클라이언트로 본인 saju_records (result 페이지와 동일 패턴)
const supabase = createClient(); // lib/supabase/client
const { data: { user } } = await supabase.auth.getUser();
if (user) {
const { data } = await supabase.from('saju_records')
.select('id, saju_data, created_at, is_paid')
.eq('user_id', user.id).order('created_at', { ascending: false });
setSajuRecords(data ?? []);
}
} catch { /* 무시 */ }
}, []);
(saju_records 실제 컬럼은 app/work/saju/result/page.tsx의 쿼리를 Read해서 정확히 맞출 것 — saju_data/interpretation/is_paid/user_id 존재 확인)
- Step 2: AI 기록 탭 렌더
{tab === 'ai' && (...)} 블록: 사주·타로 카드를 created_at 병합 내림차순으로 렌더.
-
타로 카드: 날짜·카테고리·질문·
cards3장 카드명(findCard(card_id)?.name)·summary+ 접이식(advice/warning) -
사주 카드: 날짜·생년월일 요약(saju_data에서)·"결과 다시 보기" 링크. birth 파라미터로
/work/saju/result?...재구성 (result 페이지가 받는 쿼리 파라미터 형식 확인 후) -
빈 상태: 사주·타로 바로가기 CTA(
/work/saju,/tarot) -
Step 3: 검증·커밋 —
npm test && npm run buildPASS.git add app/mypage/page.tsx && git commit -m "feat(phase2): 마이페이지 AI 기록 탭 — 사주·타로 결과 통합"
Task 10: TopNav 진입점 + CLAUDE.md + 최종 검증
Files:
- Modify:
app/components/TopNav.tsx(LINKS) - Modify:
CLAUDE.md
Interfaces:
-
Consumes: Task 1~9 완료
-
Produces: 문서·네비 정합
-
Step 1: TopNav 링크
app/components/TopNav.tsx LINKS 배열에 추가:
{ href: '/work/saju', label: '사주' },
{ href: '/tarot', label: '타로' },
(외주 개발/소프트웨어/제작 사례/사주/타로 — 5링크. 모바일 드로어는 같은 배열이라 자동)
-
Step 2: CLAUDE.md 갱신
-
핵심 IA 표:
/work/saju(공개 AI 사주),/tarot(3카드 타로) 추가 -
숨김 서비스 표에서
/work/saju*행 제거(공개 전환) -
사주 시스템 섹션 상단 "> 서비스는 현재 숨김" 문구 → "공개 서비스(로그인 시 AI 무료 1회/일)"로 갱신
-
파일 구조에
tarot/,api/tarot/,lib/tarot/,lib/ai-usage.ts추가 -
Step 3: 최종 검증
npm test # tarot-cards/shuffle/reference/ai-usage 포함 전체 PASS
npm run build # /tarot, /work/saju(공개), /api/tarot/* 라우트 존재
grep -rnE "gradient|violet|purple|blur" app/tarot/ app/work/saju/result/SajuAISection.tsx # 신규/변경분 0건
-
Step 4: 커밋 —
git add app/components/TopNav.tsx CLAUDE.md && git commit -m "feat(phase2): TopNav 사주·타로 진입점 + CLAUDE.md 정합화" -
Step 5: CEO 안내(보고)
-
2026-07-02-phase2-saju-tarot.sql을 클라우드 Supabase + NAS self-host 양쪽 적용(tarot_readings·ai_usage_log 생성, service_settings saju 삭제) -
saju_records테이블이 클라우드에 존재하는지 확인(AI 기록 탭 사주 조회 의존) -
수동 E2E: 비로그인 타로 카드 해석 → 로그인 AI 인사이트(일 3회 제한) → 마이페이지 AI 기록 / 사주 무료 해석(일 1회)
-
GEMINI_API_KEY 운영 환경 설정 확인
검증 요약
| 검증 | 명령 | 기대 |
|---|---|---|
| 단위 테스트 | npm test |
tarot(cards/shuffle/reference)·ai-usage + 기존 전체 PASS |
| 빌드 | npm run build |
/tarot·/work/saju·/api/tarot/* 라우트, 실패 없음 |
| 가드레일 | grep(신규 공개 파일) | gradient/violet/purple/blur 0건 |
| 이미지 | ls public/images/tarot/cards | wc -l |
78 |