docs(phase2): 사주 재활성 + 타로 신규 구현 플랜 (10 Task)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
This commit is contained in:
858
docs/superpowers/plans/2026-07-02-phase2-saju-tarot.md
Normal file
858
docs/superpowers/plans/2026-07-02-phase2-saju-tarot.md
Normal file
@@ -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) <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: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<T>(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<string, number>; 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<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`:
|
||||||
|
```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<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 = 3`
|
||||||
|
- `kstDayStartISO(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`:
|
||||||
|
```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<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`:
|
||||||
|
```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<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:
|
||||||
|
```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<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 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<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 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 (
|
||||||
|
<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 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 (
|
||||||
|
<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 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<TarotReadingRow[]>([]); const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);`
|
||||||
|
- 타입:
|
||||||
|
```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<string, unknown>; 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 |
|
||||||
Reference in New Issue
Block a user