diff --git a/docs/superpowers/plans/2026-07-02-phase2-saju-tarot.md b/docs/superpowers/plans/2026-07-02-phase2-saju-tarot.md new file mode 100644 index 0000000..1cdf7d4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-phase2-saju-tarot.md @@ -0,0 +1,858 @@ +# 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) ` + +## 확인된 기존 구조 + +- 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:28` `isServiceVisible('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`: +```typescript +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`로 옮긴다. 상단에 타입 정의 추가: +```typescript +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(input: T[]): T[]` + - `buildShuffle(deck: TarotCard[], size: number): (TarotCard & { reversed: boolean })[]` + - `buildReferenceBlock(picks: Pick[]): string` + - `buildContextMeta(picks: Pick[]): { major_minor_ratio: string; element_distribution: Record; orientation_flow: string }` + - Task 4(interpret API)·Task 6(UI)이 소비 + +- [ ] **Step 1: 셔플 테스트** + +`lib/__tests__/tarot-shuffle.test.ts`: +```typescript +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`: +```typescript +import type { TarotCard } from './cards'; + +export type Pick = { card: TarotCard; position: string; reversed: boolean }; + +export function fisherYates(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`: +```typescript +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 선택): +```typescript +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 = { 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 = 3` + - `kstDayStartISO(now: Date): string` (KST 자정의 UTC ISO) + - `getTodayUsage(admin, userId, service): Promise` + - `recordUsage(admin, userId, service): Promise` + - Task 4·7이 소비. `admin`은 `createAdminClient()` 반환 타입 + +- [ ] **Step 1: kstDayStartISO 테스트** + +`lib/__tests__/ai-usage.test.ts`: +```typescript +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`: +```typescript +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 { + 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 { + 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`: +```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 build` PASS. `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` 포팅: +```typescript +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; + 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[]) { + 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 | 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: +```typescript +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; + 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 build` PASS(라우트 등장). `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 }` / 401 + - `GET /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`: +```typescript +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; + 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 build` PASS. `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: 이미지 복사** + +```bash +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`: +```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 + 클라이언트 마운트): +```tsx +import TarotReadingClient from './TarotReadingClient'; +export default function TarotPage() { + return ( +
+ {/* Hero: h1 "타로 리딩" + 부제 "3장의 카드로 과거·현재·미래의 흐름을 읽습니다." */} + +
+ ); +} +``` + +`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장 앞면(이미지 + `` 텍스트 폴백: 카드명/영문명) + **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 build` PASS(라우트 `/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`: +```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 핸들러 최상단(입력 파싱 전)에: +```typescript +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 build` PASS. `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에서 "개편 준비 중" 문구로 바뀐 부분)을 로그인 유도로 교체: +```tsx +if (!hasPaid) { + return ( +
+ {/* AI PREMIUM 뱃지 + "AI 상세 해석 (12개 항목)" 제목 + 미리보기 SECTION_META 그리드 유지 */} + + 로그인하고 AI 상세 해석 무료로 받기 + +

로그인 회원은 하루 1회 무료 · 저장된 해석은 언제든 다시 보기

+
+ ); +} +``` +(현재 경로는 `usePathname`/`useSearchParams`로. 컴포넌트가 이미 클라이언트면 그대로, 아니면 next 파라미터는 서버에서 prop으로 전달) +- 429 처리: 해석 요청 fetch가 429면 상태 메시지로 "오늘 무료 횟수를 모두 사용했습니다" 표시(기존 error 상태 재사용) + +- [ ] **Step 3: 검증·커밋** — `npm test && npm run build` PASS. 가드레일 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([]); const [sajuRecords, setSajuRecords] = useState([]);` +- 타입: +```typescript +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; created_at: string; is_paid: boolean }; +``` +- 로드 함수(초기 useEffect에 배선): +```typescript +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 병합 내림차순으로 렌더. +- 타로 카드: 날짜·카테고리·질문·`cards` 3장 카드명(findCard(card_id)?.name)·`summary` + 접이식(advice/warning) +- 사주 카드: 날짜·생년월일 요약(saju_data에서)·"결과 다시 보기" 링크. birth 파라미터로 `/work/saju/result?...` 재구성 (result 페이지가 받는 쿼리 파라미터 형식 확인 후) +- 빈 상태: 사주·타로 바로가기 CTA(`/work/saju`, `/tarot`) + +- [ ] **Step 3: 검증·커밋** — `npm test && npm run build` PASS. `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 배열에 추가: +```typescript +{ 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: 최종 검증** +```bash +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 |