# Sinsin Health 풀스택 개발 설계서

## 1. 아키텍처 요약

Sinsin Health는 운동 인증을 이벤트로 받아 EXP, 코인, 퀘스트, 길드 기여도를 갱신하는 게임화 헬스케어 플랫폼이다. MVP는 웹뷰 친화적인 React 프론트엔드, NestJS API, PostgreSQL, Prisma, S3 또는 Cloudflare R2 파일 저장소로 구성한다.

권장 흐름은 `운동 인증 제출 -> 어뷰징 검증 -> EXP 계산 -> 레벨업/보상 처리 -> 퀘스트 진행률 갱신 -> 길드 EXP 반영 -> 클라이언트 이벤트 반환`이다.

## 2. 추천 기술 스택

- Frontend: Next.js 또는 React Native WebView 호환 React 구조.
- Backend: NestJS, Prisma, PostgreSQL.
- Auth: JWT Access Token, Refresh Token Rotation.
- Storage: Cloudflare R2 또는 AWS S3.
- Batch: NestJS Schedule, 또는 Cloudflare Workers Cron.
- Queue: BullMQ와 Redis. AI 사진 검증, OCR, 랭킹 재계산 같은 비동기 작업에 사용한다.
- Admin: Retool, Appsmith, 또는 별도 Next.js Admin.

## 3. Prisma 스키마 초안

```prisma
model User {
  id             String        @id @default(cuid())
  email          String        @unique
  passwordHash   String
  nickname       String
  level          Int           @default(1)
  exp            Int           @default(0)
  coins          Int           @default(0)
  streakDays     Int           @default(0)
  totalDistance  Decimal       @default(0)
  totalCalories  Int           @default(0)
  dailyExp       Int           @default(0)
  dailyExpDate   DateTime?
  guildId        String?
  guild          Guild?        @relation(fields: [guildId], references: [id])
  exerciseLogs   ExerciseLog[]
  userQuests     UserQuest[]
  badges         UserBadge[]
  createdAt      DateTime      @default(now())
}

model Guild {
  id          String   @id @default(cuid())
  name        String   @unique
  ownerId     String
  level       Int      @default(1)
  exp         Int      @default(0)
  members     User[]
  quests      GuildQuest[]
  createdAt   DateTime @default(now())
}

model ExerciseLog {
  id             String       @id @default(cuid())
  userId         String
  user           User         @relation(fields: [userId], references: [id])
  type           ExerciseType
  distanceKm     Decimal
  durationMin    Int
  elevationM     Int          @default(0)
  calories       Int
  earnedExp      Int
  earnedCoins    Int
  guildExp       Int          @default(0)
  photoUrl       String?
  verification   VerifyStatus @default(PENDING)
  createdAt      DateTime     @default(now())
}

model Quest {
  id          String      @id @default(cuid())
  type        QuestType
  title       String
  metric      QuestMetric
  targetValue Decimal
  rewardExp   Int
  rewardCoins Int
  active      Boolean     @default(true)
  userQuests  UserQuest[]
}

model UserQuest {
  id          String      @id @default(cuid())
  userId      String
  questId     String
  user        User        @relation(fields: [userId], references: [id])
  quest       Quest       @relation(fields: [questId], references: [id])
  progress    Decimal     @default(0)
  status      QuestStatus @default(IN_PROGRESS)
  assignedAt  DateTime    @default(now())
  completedAt DateTime?
}

model GuildQuest {
  id          String      @id @default(cuid())
  guildId     String
  guild       Guild       @relation(fields: [guildId], references: [id])
  title       String
  targetKm    Decimal
  progressKm  Decimal     @default(0)
  rewardExp   Int
  startsAt    DateTime
  endsAt      DateTime
}

model Badge {
  id          String      @id @default(cuid())
  code        String      @unique
  name        String
  description String
  userBadges  UserBadge[]
}

model UserBadge {
  userId    String
  badgeId   String
  user      User   @relation(fields: [userId], references: [id])
  badge     Badge  @relation(fields: [badgeId], references: [id])
  earnedAt  DateTime @default(now())

  @@id([userId, badgeId])
}

enum ExerciseType {
  WALK
  RUN
  HIKE
}

enum VerifyStatus {
  PENDING
  APPROVED
  REJECTED
}

enum QuestType {
  DAILY
  MAIN
}

enum QuestMetric {
  DISTANCE
  DURATION
  ELEVATION
  GUILD_EXP
}

enum QuestStatus {
  IN_PROGRESS
  COMPLETED
  CLAIMED
}
```

## 4. 핵심 API 명세

| Method | Endpoint | 설명 |
| --- | --- | --- |
| POST | `/auth/register` | 자체 회원가입 |
| POST | `/auth/login` | 로그인 및 토큰 발급 |
| GET | `/me` | 프로필, 레벨, 누적 스탯 조회 |
| POST | `/exercise-logs` | 운동 인증 생성, EXP 계산 |
| GET | `/quests/today` | 일일 퀘스트 3개 조회 |
| POST | `/quests/:id/claim` | 완료 퀘스트 보상 수령 |
| POST | `/guilds` | 길드 생성 |
| POST | `/guilds/:id/join` | 길드 가입 |
| POST | `/guilds/:id/leave` | 길드 탈퇴 |
| GET | `/guilds/:id/leaderboard` | 길드 기여도 랭킹 |

운동 인증 요청 예시다.

```json
{
  "type": "HIKE",
  "distanceKm": 4.2,
  "durationMin": 85,
  "elevationM": 360,
  "photoUrl": "https://cdn.example.com/proofs/abc.jpg"
}
```

운동 인증 응답 예시다.

```json
{
  "logId": "clx_123",
  "earnedExp": 282,
  "earnedCoins": 50,
  "guildExp": 33,
  "levelUp": true,
  "newLevel": 13,
  "completedQuestIds": ["daily_walk_3km"],
  "dailyExp": {
    "earned": 622,
    "cap": 1000
  }
}
```

## 5. 핵심 백엔드 서비스 코드

```ts
const EXP_RULES = {
  WALK: { expPerKm: 10, maxSpeed: 8, caloriesPerKm: 48 },
  RUN: { expPerKm: 25, maxSpeed: 22, caloriesPerKm: 72 },
  HIKE: { expPerKm: 50, maxSpeed: 7, caloriesPerKm: 92, elevationExpPer100m: 20 }
} as const;

function requiredExp(level: number) {
  return Math.floor(120 * Math.pow(level, 1.82) + level * 35);
}

function streakBonus(days: number) {
  if (days >= 30) return 0.2;
  if (days >= 7) return 0.1;
  if (days >= 3) return 0.05;
  return 0;
}

async function createExerciseLog(userId: string, dto: CreateExerciseLogDto) {
  return prisma.$transaction(async (tx) => {
    const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
    const rule = EXP_RULES[dto.type];
    const speed = dto.distanceKm / (dto.durationMin / 60);

    if (speed > rule.maxSpeed) {
      throw new BadRequestException("비정상 속도로 판단되어 인증이 보류되었습니다.");
    }

    const todayDailyExp = sameDay(user.dailyExpDate, new Date()) ? user.dailyExp : 0;
    const baseExp = Math.floor(dto.distanceKm * rule.expPerKm);
    const elevationExp = dto.type === "HIKE"
      ? Math.floor(dto.elevationM / 100) * (rule.elevationExpPer100m ?? 0)
      : 0;
    const bonusExp = Math.floor((baseExp + elevationExp) * streakBonus(user.streakDays));
    const available = Math.max(0, 1000 - todayDailyExp);
    const earnedExp = Math.min(baseExp + elevationExp + bonusExp, available);
    const earnedCoins = Math.max(10, Math.floor(earnedExp * 0.18));
    const guildExp = Math.floor(earnedExp * 0.12);
    const calories = Math.floor(dto.distanceKm * rule.caloriesPerKm);

    let nextLevel = user.level;
    let expAfter = user.exp + earnedExp;
    while (nextLevel < 100 && expAfter >= requiredExp(nextLevel)) {
      nextLevel += 1;
    }

    const log = await tx.exerciseLog.create({
      data: {
        userId,
        type: dto.type,
        distanceKm: dto.distanceKm,
        durationMin: dto.durationMin,
        elevationM: dto.elevationM ?? 0,
        calories,
        earnedExp,
        earnedCoins,
        guildExp,
        photoUrl: dto.photoUrl,
        verification: "APPROVED"
      }
    });

    await tx.user.update({
      where: { id: userId },
      data: {
        exp: expAfter,
        level: nextLevel,
        coins: { increment: earnedCoins + (nextLevel > user.level ? 120 : 0) },
        totalDistance: { increment: dto.distanceKm },
        totalCalories: { increment: calories },
        dailyExp: todayDailyExp + earnedExp,
        dailyExpDate: new Date()
      }
    });

    if (user.guildId) {
      await tx.guild.update({
        where: { id: user.guildId },
        data: { exp: { increment: guildExp } }
      });
    }

    await updateQuestProgress(tx, userId, dto, guildExp);

    return {
      logId: log.id,
      earnedExp,
      earnedCoins,
      guildExp,
      levelUp: nextLevel > user.level,
      newLevel: nextLevel
    };
  });
}
```

## 6. 프론트엔드 화면 구성

- 홈 대시보드: 레벨, EXP 진행률, 일일 한도, 누적 거리, 칼로리, 스트릭, 코인.
- 운동 인증: 운동 종류, 거리, 시간, 고도, 사진 업로드, 인증 제출.
- 퀘스트: 일일 랜덤 퀘스트, 메인 퀘스트, 완료와 보상 수령 상태.
- 길드: 길드 정보, 길드 EXP, 주간 협동 퀘스트, 기여도 랭킹.
- 프로필: 배지, 아바타, 프로필 테두리, 누적 업적.
- 포인트 숍: 꾸미기 아이템, 한정 보상, 실제 기프티콘.

## 7. 게임 밸런스

- 걷기: 1km당 10 EXP.
- 달리기: 1km당 25 EXP.
- 등산: 1km당 50 EXP.
- 등산 고도 보너스: 100m당 20 EXP.
- 일일 최대 획득 EXP: 1,000 EXP.
- 스트릭 보너스: 3일 5%, 7일 10%, 30일 20%.
- Lv.1부터 Lv.100 요구 EXP: `floor(120 * level^1.82 + level * 35)`.

어뷰징 방지 기준은 운동별 최대 속도, 하루 최대 거리, 중복 사진 해시, GPS/EXIF 시간 검증, OCR 기반 운동 앱 캡처 판독을 조합한다.

## 8. 운영 및 보안

- 이미지 업로드는 확장자, MIME, 파일 크기, 악성 메타데이터 제거를 적용한다.
- 위치 정보는 상세 좌표 대신 검증 후 요약값 중심으로 저장한다.
- 신고된 인증, 유저 제재, 퀘스트 편집, 숍 상품 관리를 위한 어드민을 별도 권한으로 둔다.
- 매일 자정 일일 퀘스트 배정, 주간 길드 퀘스트 정산, 랭킹 캐시 갱신 배치를 운영한다.
- API 에러, 비정상 EXP 획득, 인증 실패율, 퀘스트 완료율을 모니터링한다.

## 9. MVP 로드맵

MVP에는 회원가입과 로그인, 프로필 레벨/EXP, 운동 인증, EXP 지급과 레벨업, 일일 퀘스트, 기본 랭킹, 기본 길드 생성/가입을 포함한다.

v1에는 길드 협동 퀘스트, 스트릭, 배지, 포인트 지급을 포함한다.

v2에는 포인트 숍, AI 사진 인증, GPS 검증, 기프티콘 연동, 시즌제 랭킹, 프리미엄 멤버십을 포함한다.

## 10. 프로토타입 고도화 반영 사항

- PWA 매니페스트와 서비스 워커를 추가해 모바일 홈 화면 설치와 정적 자산 캐시를 지원한다.
- 운동 인증 히스토리, 사진 첨부 기반 신뢰도 보너스, 퀘스트 보상 수령, 포인트 숍 구매 상태를 구현했다.
- 데모 상태는 브라우저 LocalStorage에 저장되어 새로고침 후에도 유지된다.
- 모바일웹 하단 빠른 실행 내비게이션을 인증, 퀘스트, 기록, 설계서 4개 탭으로 확장했다.

## 11. D1 저장소 연결

- Cloudflare D1 데이터베이스 `sinsin-health-db`를 생성했다.
- Pages Functions의 `DB` 바인딩으로 프로필, 퀘스트, 운동 로그를 저장한다.
- `/api/exercise-logs`는 운동 인증을 `exercise_logs`에 저장하고 유저 누적 EXP, 코인, 거리, 칼로리, 퀘스트 진행률을 갱신한다.
- `/api/quests/:id/claim`은 완료된 퀘스트 보상을 D1에 반영한다.

## 12. 게임 경제와 길드 저장소

- `guilds`, `guild_members`, `shop_items`, `user_purchases` 테이블을 추가했다.
- `/api/guild`는 길드 레벨, 길드 EXP, 멤버 기여도 랭킹을 반환한다.
- `/api/shop`은 상점 아이템과 유저 구매 상태를 반환한다.
- `/api/shop/:id/purchase`는 보유 코인을 차감하고 구매 내역을 저장한다.
- 운동 인증 시 길드 EXP와 내 길드 기여도가 함께 증가한다.

## 13. 관리자 심사

- `admin.html`에서 심사 대기 인증, 승인/반려/전체 통계를 확인한다.
- `/api/admin/reviews`는 `PENDING_REVIEW` 인증 큐와 통계를 반환한다.
- `/api/admin/reviews/:id/approve`, `/api/admin/reviews/:id/reject`로 인증 상태를 변경한다.
- 관리자 화면은 `ADMIN_TOKEN` Pages Secret 기반 토큰 로그인으로 보호한다.
- 실제 운영 전에는 Cloudflare Access, OAuth, 관리자 역할, 세션 만료 정책을 추가해야 한다.

## 14. 인증 사진 저장소

- `/api/proof-uploads`는 이미지 파일을 검증하고 R2 `PROOFS` 바인딩에 저장한다.
- `/api/proofs/:key`는 저장된 인증 사진을 반환한다.
- `exercise_logs`에 `proof_key`, `proof_url` 컬럼을 추가해 운동 로그와 인증 사진을 연결한다.
- R2가 비활성화된 계정에서는 `/api/health`의 `proofStorage`가 `false`로 내려가고, 프론트는 저장 없이 인증 신뢰도 보너스만 반영한다.
