Files
jaengseung-made/docs/superpowers/specs/2026-07-02-phase2-saju-tarot-design.md

129 lines
11 KiB
Markdown

# Phase 2 별도 서비스 — 사주 재활성 + 타로 신규 설계
- 날짜: 2026-07-02
- 선행: Phase 0(정리)·Phase 1(외주 코어) main 머지 완료
- 배경: 운영 비전 2축(별도 서비스)의 첫 구현. 사주는 숨김 해제·무료화, 타로는 web-ui(개인 사이트) 구현 구조를 이 repo에 포팅.
## 결정 사항 (CEO 확정, 2026-07-02)
| 결정 | 내용 |
|------|------|
| 노출 정책 | **공개** — 누구나 체험, **결과 저장·재확인만 로그인** |
| 사주 AI 무료화 | 회원 무료 + **일일 횟수 제한**(서버측 강제). 비회원은 계산 기반 기본 해석까지 |
| 타로 스펙 | **3카드 스프레드, web-ui 타로 구조 그대로 포팅**(카드 데이터·셔플·선택 UX·해석 스키마) |
| 마이페이지 | **5번째 탭 'AI 기록'**. 사주 경로 `/work/saju` 유지, 타로는 `/tarot` 신규 |
| 일일 제한 기본값 | 사주 AI 해석 **1회/일**, 타로 AI 인사이트 **3회/일** (상수 분리, 조정 가능) |
| 타로 AI | **Gemini** (기존 GEMINI_API_KEY 재사용, 사주와 동일 폴백 체인 패턴). tarot-lab(Claude)의 프롬프트·스키마를 포팅 |
## 포팅 원본 (web-ui 조사 결과 — 2026-07-02 감사)
- 카드: `web-ui/src/pages/tarot/data/cards.js` — 78장(메이저 22 하드코딩 + 마이너 56 프로그램 생성), 필드 `{id, slug, name, nameEn, arcana, element, suit?, rank?, keywords[], reversedKeywords[], meaningUpright, meaningReversed, symbols[], image}`
- 이미지: `web-ui/public/images/tarot/cards/*.png` 78장 + `card_back.png` 실재 — 복사해 사용. `<img onError>` CSS 텍스트 폴백 패턴 유지
- 셔플: Fisher-Yates + 카드별 독립 50% 역방향. 리딩 시 덱에서 20장만 부채꼴로 펼쳐 사용자가 3장 직접 선택(과거/현재/미래 position 순)
- AI 계약: `cards_reference`(카드 의미 텍스트 블록)와 `context_meta`(메이저 비율·원소 분포·정역 흐름)를 **프론트가 로컬 카드 데이터로 조립**해 전송 — 서버는 카드 DB 없음
- interpretation_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|null, confidence: high|medium|low}` — three_card는 interactions ≥1 필수
- 파이프라인 견고성: strict JSON 프롬프트 + 파싱 폴백(코드블록 스트립) + 검증 실패 시 사유 주입 reroll 1회
- 카테고리: `연애 / 일·커리어 / 관계 / 재물 / 건강 / 일반`
- interpret ↔ save 분리: 저장 실패해도 해석은 유지(`save_failed` 상태)
- 고아 컴포넌트(CardGrid, SpreadSlots)와 원카드·히스토리 페이지는 포팅 제외(YAGNI — 히스토리는 마이페이지 AI 기록 탭이 대체)
## 워크스트림 4개
### WS1. 사주 재활성 (공개 + 무료화)
- **가드 제거**: `app/work/saju/layout.tsx``isServiceVisible('saju')` + `notFound()` 제거. `lib/service-visibility.ts``HideableService`에서 `'saju'` 제거. `app/api/admin/services/route.ts` DEFAULT_SERVICES에서 saju 행 제거. `service_settings`에서 saju 행 DELETE(마이그레이션)
- **무료화(SajuAISection)**: Phase 0에서 넣은 "개편 준비 중" 안내와 `hasPaid` 게이트를 **로그인 게이트**로 교체:
- 비로그인: 기본 해석(사주팔자·오행·대운 등 계산 기반)은 그대로 표시 + "AI 상세 해석(12항목)은 로그인하면 무료" CTA(`/login?next=` 현재 경로)
- 로그인: AI 상세 해석 무료 생성. 이미 저장된 해석 있으면 즉시 표시(무제한)
- `hasPaid` 데이터 소스(orders 'saju_detail')는 제거하고 `user` 세션 유무로 대체. `saju_records` 저장 시 `is_paid: true` 유지(필드 의미: "AI 해석 보유" — 하위 호환)
- **일일 제한(서버측 강제)**: `app/api/saju/analyze/route.ts`에 ① 세션 인증 확인(401) ② `ai_usage_log`에서 user_id+service='saju'+오늘(KST) 카운트 ≥ `SAJU_DAILY_LIMIT`(=1)이면 429 `{ error: '오늘 사용량을 모두 썼습니다. 내일 다시 시도해주세요.' }` ③ 성공 시 usage 기록. 제한 상수는 `lib/ai-usage.ts`에 정의
- 로또 번호 섹션(SajuFortuneSection)·`hasLottoSubscription`은 현행 유지(추후 별도 정리)
### WS2. 타로 신규 (`/tarot`) — web-ui 구조 포팅
**데이터·유틸 (lib/tarot/)**
- `lib/tarot/cards.ts`: 78장 데이터 TS 포팅(타입 `TarotCard` 정의, 메이저 하드코딩+마이너 생성 로직 그대로). `SPREADS`(three_card만), `CATEGORIES`, `findCard(slug)`
- `lib/tarot/shuffle.ts`: `fisherYates`, `buildShuffle(deck, 20)` — 순수 함수(테스트 가능)
- `lib/tarot/reference.ts`: `buildReferenceBlock(picks)`, `buildContextMeta(picks)` — 순수 함수(테스트 가능)
- 단위 테스트: 78장 무결성(slug 중복 없음·필드 채움), 셔플(길이·중복 없음·원본 불변), reference 블록 형식
**에셋**
- `web-ui/public/images/tarot/cards/*.png` 78장 + `card_back.png``public/images/tarot/` 복사. `<img onError>` 텍스트 폴백 구현(카드명+영문명)
**UI (`app/tarot/page.tsx` + 컴포넌트)**
- 3-step 클라이언트 플로우(web-ui Reading 구조): ①질문(선택)·카테고리 선택 ②20장 부채꼴 뒷면에서 3장 클릭 선택(과거/현재/미래) ③결과
- 결과 2탭: **카드 해석**(로컬 데이터 — 키워드·의미·상징, 비회원 포함 모두) / **AI 인사이트**(로그인+일일 제한 — summary·카드별 해석+evidence·interactions·advice·warning·confidence)
- 비로그인이 AI 탭 클릭 시: "로그인하면 AI 해석 무료(일 3회)" CTA
- 셔플은 `'use client'` + `useEffect` 초기화(hydration mismatch 방지)
- 저장: AI 해석 성공 시 자동 저장 시도(로그인 상태). 저장 실패해도 해석 표시 유지
- 디자인: `--jsm-*` 토큰 라이트 재작성. gradient/blur/보라/이모지 금지. 카드 이미지는 그대로(에셋은 가드레일 대상 아님)
**API**
- `POST /api/tarot/interpret`: body는 web-ui 계약 그대로(`{spread_type:'three_card', category, question, cards[{position,card_id,reversed}], cards_reference, context_meta}`). 서버: ①세션 인증(401) ②일일 제한 `TAROT_DAILY_LIMIT`(=3) 체크(429) ③Gemini 호출(사주와 동일 모델 폴백 체인) — tarot-lab SYSTEM_PROMPT 포팅(RWS 전통·데이터 우선·strict JSON·confidence 기준) ④JSON 파싱 폴백+스키마 검증(위 interpretation_json 스키마, three_card interactions ≥1) ⑤실패 시 사유 주입 reroll 1회 ⑥성공 시 usage 기록. 응답 `{ interpretation_json, model }`
- `POST /api/tarot/readings`: 로그인 필수. `{spread_type, category, question, cards, interpretation_json}``tarot_readings` insert(user_id, summary는 interpretation_json.summary 추출). 응답 `{ id, created_at }`
- `GET /api/tarot/readings`: 로그인 필수, 본인 것만 최신순(마이페이지 소비)
**DB (마이그레이션 1개, 클라우드+NAS 양쪽)**
```sql
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';
```
(insert는 service role로 수행 — policy 불요. tarot_readings insert도 서버 admin client 경유)
**사용량 유틸 (`lib/ai-usage.ts`)**
- `SAJU_DAILY_LIMIT = 1`, `TAROT_DAILY_LIMIT = 3`
- `kstDayStart(now?)`: KST 자정 기준 오늘 시작 시각(UTC ISO) 계산 — 순수 함수, 단위 테스트(KST 경계)
- `getTodayUsage(admin, userId, service)`: `ai_usage_log`에서 오늘 카운트 조회
- `recordUsage(admin, userId, service)`: 사용 1건 기록
- 호출 순서(각 API): 인증 → `getTodayUsage ≥ limit`이면 429 → AI 호출 → **성공 시에만** `recordUsage` (실패한 생성은 카운트하지 않음 — 동시 요청 레이스는 개인 서비스 규모에서 허용)
### WS3. 마이페이지 'AI 기록' 탭
- `app/mypage/page.tsx`: Tab 타입에 `'ai'` 추가, 라벨 `AI 기록` (5탭)
- 콘텐츠: 사주 기록(`saju_records` 본인 — 세션 클라이언트 RLS 조회)과 타로 기록(`GET /api/tarot/readings`) 통합 최신순 리스트
- 사주 카드: 생년월일시·성별 요약 + 해석 보유 뱃지 + "결과 다시 보기" 링크(`/work/saju/result?` 저장된 birth 파라미터로 재구성)
- 타로 카드: 날짜·카테고리·질문·뽑은 카드 3장 이름 + summary + 접이식 상세(advice·warning)
- 빈 상태: 사주/타로 바로가기 CTA
### WS4. 진입점 + 문서
- TopNav LINKS: `{ href: '/work/saju', label: '사주' }`, `{ href: '/tarot', label: '타로' }` 추가 (5링크, 모바일 드로어 자동)
- CLAUDE.md: 핵심 IA에 사주(공개 전환)·`/tarot` 반영, 숨김 서비스 표에서 saju 제거, 사주 시스템 섹션의 "현재 숨김" 문구 갱신, 파일 구조에 tarot 추가
- 검증: `npm test`(신규 tarot·ai-usage 테스트 포함) + `npm run build` + 가드레일 grep(신규 공개 파일) + 수동 확인 안내(사주 무료 플로우·타로 3카드 플로우·AI 기록 탭)
## 범위 밖
- 타로 원카드("오늘의 카드")·전용 히스토리 페이지 — AI 기록 탭이 대체, 필요 시 추후
- 사주 로또 섹션 정리, music 고도화(Phase 3)
- 사주 결과 재확인용 딥링크 고도화(birth 파라미터 재구성으로 충분)
- 카드 이미지 최적화(next/image 전환) — `<img>` + 폴백으로 시작
## 리스크·주의
- `saju_records` 테이블은 이 repo 마이그레이션에 없음(클라우드 직접 생성) — AI 기록 탭의 사주 조회는 기존 result 페이지와 동일한 세션 클라이언트 쿼리 패턴 재사용. 스키마 확인은 구현 시 result/SajuAISection 코드 기준
- Gemini strict JSON: 사주 analyze의 기존 파싱 패턴 + tarot-lab의 폴백·reroll 포팅으로 이중 방어. 검증 실패 2회 시 사용자에게 재시도 안내(usage 카운트는 성공 시에만)
- 카드 이미지 78장(수 MB)이 repo에 들어감 — Vercel/NAS 정적 서빙 문제없음, git 용량만 인지
- GEMINI_API_KEY 미설정 환경(로컬)에서는 사주와 동일하게 예시 폴백 또는 503 — 타로는 503 + 안내 문구(예시 해석 미제공, 데이터 오염 방지)