* feat(docs): mirror every docs/ page in all 65 locales Extends the documentation mirrors from the 22-page core set (#13940) to every Markdown page under docs/: 152 sources x 65 locales = 9,880 mirrors (6,208 new), language bars rewritten for the full locale list, state adopted so the blocking drift gate now covers all 152 pages. run-translation.mjs: an oversized block made only of table rows or list items (PROVIDER_REFERENCE.md 244-row table, FREE_TIERS.md 71-item list) is cut at item boundaries and rejoined without a blank line — the single 16-40 KB request outlived the backend socket for verbose scripts. 48 older mirrors whose tables had lost rows were retranslated with --force. * docs(i18n): refresh mirrors for the sources the base changed since the branch cut Section-level retranslation of the 29 docs (and README.md) whose source or mirrors moved on release/v3.8.51 during the run, then state adoption; the drift gate is green again on the merged tree.
52 KiB
Gamification & Leaderboard System (한국어)
🌐 Languages: 🇺🇸 English · 🇪🇹 am · 🇸🇦 ar · 🇦🇿 az · 🇧🇬 bg · 🇧🇩 bn · 🇨🇿 cs · 🇩🇰 da · 🇩🇪 de · 🇬🇷 el · 🇪🇸 es · 🇪🇪 et · 🇮🇷 fa · 🇫🇮 fi · 🇫🇷 fr · 🇮🇪 ga · 🇮🇳 gu · 🇳🇬 ha · 🇮🇱 he · 🇮🇳 hi · 🇭🇷 hr · 🇭🇺 hu · 🇦🇲 hy · 🇮🇩 id · 🇳🇬 ig · 🇮🇹 it · 🇯🇵 ja · 🇬🇪 ka · 🇰🇭 km · 🇮🇳 kn · 🇱🇹 lt · 🇱🇻 lv · 🇮🇳 ml · 🇮🇳 mr · 🇲🇾 ms · 🇲🇹 mt · 🇲🇲 my · 🇳🇵 ne · 🇳🇱 nl · 🇳🇴 no · 🇮🇳 or · 🇮🇳 pa · 🇵🇭 phi · 🇵🇱 pl · 🇵🇹 pt · 🇧🇷 pt-BR · 🇷🇴 ro · 🇷🇺 ru · 🇱🇰 si · 🇸🇰 sk · 🇸🇮 sl · 🇷🇸 sr · 🇸🇪 sv · 🇰🇪 sw · 🇮🇳 ta · 🇮🇳 te · 🇹🇭 th · 🇹🇷 tr · 🇺🇦 uk-UA · 🇵🇰 ur · 🇺🇿 uz · 🇻🇳 vi · 🇳🇬 yo · 🇨🇳 zh-CN · 🇹🇼 zh-TW
신뢰할 수 있는 원본:
src/lib/gamification/,src/lib/db/gamification.ts,src/app/api/gamification/마지막 업데이트: 2026-06-28 — v3.8.40
OmniRoute에는 사용자가 플랫폼에서 요청을 보내고, 제공자를 전환하고, 콤보를 생성하고, 토큰을 공유하고, 커뮤니티에 기여하는 활동에 대해 보상하는 로컬 우선 게임화 계층이 포함되어 있습니다. 모든 상태는 SQLite에 저장되며, 커뮤니티 서버와의 연합은 선택적으로 활성화할 수 있는 푸시 기반 방식입니다.
이 시스템은 핵심 경로에서 지연 시간이 발생하지 않도록 설계되었습니다. 게임화 이벤트는 요청 파이프라인에서 응답을 기다리지 않는 방식으로 전달되며, LLM 응답을 절대 차단하지 않습니다.
개요
목적
눈에 보이는 진행 상황(XP, 레벨, 배지), 사회적 증거(리더보드), 경제적 인센티브(토큰 공유, 초대 보상)를 제공하여 사용자 참여도와 유지율을 높입니다.
범위
| 기능 | 설명 |
|---|---|
| XP 및 레벨 | 활동별로 XP를 획득하고 다항 곡선에 따라 레벨 상승 |
| 배지 | 4개의 희귀도 등급으로 구성된 5개 카테고리의 20개 이상 업적 |
| 연속 활동 | 현재/최장 연속 일수를 포함한 일일 활성 사용 추적 |
| 리더보드 | 전체, 주간, 월간, 토큰 공유 및 기여도 범위 |
| 토큰 공유 | 복식부기 원장을 통해 사용자 간 크레딧 전송 |
| 초대 및 코드 사용 | SHA-256 해시 스토리지를 사용하는 추천 코드 |
| 커뮤니티 서버 | 외부 OmniRoute 인스턴스와 연합 |
| 부정행위 방지 | 서버 측 점수 산정, 속도 제한, z-점수 이상 탐지 |
설계 원칙
- 로컬 우선 — 모든 상태를 SQLite에 저장하며 외부 서비스가 필요하지 않습니다.
- 비차단 — 이벤트는 응답을 기다리지 않는 방식으로 전달되며, 게임화 로직으로 인해 LLM 응답 경로가 지연되지 않습니다.
- 서버 권한 기반 — XP는 서버 측에서만 계산되며, 클라이언트는 점수를 부풀릴 수 없습니다.
- 개인정보 보호 — 리더보드 참여는 선택 사항이며, 사용자는 자신의 프로필을 숨길 수 있습니다.
- 연합 지원 — 커뮤니티 서버는 서명된 API를 통해 점수를 푸시할 수 있으며, 동기화는 누적이 아닌 덮어쓰기 방식입니다.
아키텍처
상위 수준 흐름
클라이언트 요청
→ /v1/chat/completions
→ handleChatCore() [open-sse/handlers/chatCore.ts]
→ ... (기존 파이프라인) ...
→ 업스트림 응답을 클라이언트에 전송
→ setImmediate (응답 대기 없이 실행):
→ emitGamificationEvent() [src/lib/gamification/events.ts]
→ awardXp() [src/lib/gamification/xp.ts]
→ updateStreak() [src/lib/gamification/streaks.ts]
→ evaluateBadges() [src/lib/gamification/badges.ts]
→ updateLeaderboard() [src/lib/gamification/leaderboard.ts]
→ checkAnomalies() [src/lib/gamification/antiCheat.ts]
이벤트 이미터는 단일 통합 지점입니다. chatCore.ts는 응답을 전송한 후 emitGamificationEvent()를 호출하며, 이벤트 모듈은 이벤트를 XP, 연속 활동, 배지, 리더보드 및 부정행위 방지 하위 시스템으로 분배합니다.
모듈 의존성 그래프
src/lib/gamification/
events.ts ← 진입점(chatCore.ts에서 호출)
├── xp.ts ← XP 계산 및 레벨 결정
├── streaks.ts ← 일일 활성 연속 기록 추적
├── badges.ts ← 배지 기준 평가
├── leaderboard.ts ← 순위 계산 및 SSE 브로드캐스트
├── antiCheat.ts ← 속도 제한 및 이상 탐지
├── sharing.ts ← 토큰 전송 원장
├── invites.ts ← 초대/코드 사용 관리
├── servers.ts ← 커뮤니티 서버 연합
└── notifications.ts ← SSE 알림 스트림
src/lib/db/
gamification.ts ← 모든 CRUD 작업(테이블 8개)
src/app/api/gamification/
leaderboard/ ← GET 순위, POST 수동 새로고침
leaderboard/stream ← SSE 실시간 업데이트
transfer/ ← GET 내역, POST 토큰 전송
invite/ ← GET/POST 코드, DELETE 취소
invite/redeem/ ← POST 코드 사용
servers/ ← GET/POST/DELETE 커뮤니티 서버
federation/score/ ← POST 서버에 점수 푸시
federation/leaderboard/ ← GET 서버에서 리더보드 가져오기
notifications/ ← SSE 배지/레벨 상승 알림
anomalies/ ← GET 이상 보고서(관리자)
rotate/ ← POST 초대 토큰 시크릿 교체
데이터 계층
데이터베이스 테이블
모든 테이블은 마이그레이션 060_create_gamification.sql로 생성된 기본 OmniRoute SQLite 데이터베이스에 있습니다. WAL 저널링은 src/lib/db/core.ts의 싱글턴 getDbInstance()로부터 상속됩니다.
┌─────────────────────────┐ ┌──────────────────────────┐
│ leaderboard │ │ user_levels │
├─────────────────────────┤ ├──────────────────────────┤
│ id TEXT PK │ │ api_key_id TEXT PK │
│ api_key_id TEXT │ │ xp INTEGER │
│ scope TEXT │ │ level INTEGER │
│ score INTEGER │ │ title TEXT │
│ period TEXT │ │ updated_at TEXT │
│ updated_at TEXT │ └──────────────────────────┘
└─────────────────────────┘
│
│ 1:N
▼
┌─────────────────────────┐ ┌──────────────────────────┐
│ user_badges │ │ badge_definitions │
├─────────────────────────┤ ├──────────────────────────┤
│ id TEXT PK │ │ id TEXT PK │
│ api_key_id TEXT │ │ name TEXT │
│ badge_id TEXT FK │ │ category TEXT │
│ earned_at TEXT │ │ rarity TEXT │
│ notified INTEGER │ │ criteria_type TEXT │
└─────────────────────────┘ │ criteria TEXT(JSON) │
│ description TEXT │
│ icon TEXT │
│ hidden INTEGER │
└──────────────────────────┘
┌─────────────────────────┐ ┌──────────────────────────┐
│ xp_audit_log │ │ token_ledger │
├─────────────────────────┤ ├──────────────────────────┤
│ id TEXT PK │ │ id TEXT PK │
│ api_key_id TEXT │ │ from_key_id TEXT │
│ action TEXT │ │ to_key_id TEXT │
│ xp_awarded INTEGER │ │ amount INTEGER │
│ metadata TEXT(JSON)│ │ idempotency_key TEXT UQ │
│ created_at TEXT │ │ created_at TEXT │
└─────────────────────────┘ └──────────────────────────┘
┌─────────────────────────┐ ┌──────────────────────────┐
│ invite_tokens │ │ community_servers │
├─────────────────────────┤ ├──────────────────────────┤
│ id TEXT PK │ │ id TEXT PK │
│ api_key_id TEXT │ │ name TEXT │
│ code TEXT UQ │ │ url TEXT │
│ token_hash TEXT │ │ token_hash TEXT │
│ uses INTEGER │ │ status TEXT │
│ max_uses INTEGER │ │ last_sync TEXT │
│ created_at TEXT │ │ created_at TEXT │
│ expires_at TEXT │ └──────────────────────────┘
└─────────────────────────┘
도메인 모듈: src/lib/db/gamification.ts
표준 OmniRoute 패턴을 따릅니다. core.ts에서 getDbInstance()를 가져오고 타입이 지정된 CRUD 함수를 내보냅니다. 라우트 핸들러에는 원시 SQL을 사용하지 않습니다.
주요 함수:
| 함수 | 설명 |
|---|---|
upsertLeaderboardEntry() |
(api_key_id, scope, period)의 점수 삽입 또는 업데이트 |
getLeaderboard() |
지정된 범위/기간에 대한 페이지네이션된 순위 조회 |
getUserLevel() |
사용자 레벨 레코드 조회 또는 생성 |
updateUserLevel() |
XP, 레벨 및 칭호를 원자적으로 설정 |
getBadgeDefinitions() |
모든 배지 정의 조회(선택적으로 필터링 가능) |
getUserBadges() |
사용자가 획득한 배지 조회 |
awardBadge() |
배지 획득 내역 삽입(badge_id 기준 멱등성 보장) |
logXpAction() |
xp_audit_log에 항목 추가 |
getXpAuditLog() |
사용자의 페이지네이션된 감사 이력 조회 |
insertLedgerEntry() |
복식부기 이체(트랜잭션 내에서 수행) |
getBalance() |
사용자의 수신액에서 송신액을 뺀 합계 계산 |
getTransferHistory() |
페이지네이션된 이체 로그 조회 |
createInviteToken() |
초대 코드와 해시된 토큰 삽입 |
redeemInviteToken() |
코드로 조회하고 검증한 후 사용 횟수 증가 |
upsertCommunityServer() |
페더레이션 서버 등록 또는 업데이트 |
getCommunityServers() |
사용자의 서버 목록 조회 |
deleteCommunityServer() |
서버 등록 제거 |
XP / 레벨 시스템
파일: src/lib/gamification/xp.ts
레벨 곡선
레벨 n에 도달하는 데 필요한 XP는 다음 다항식 곡선을 따릅니다:
xp_for_level(n) = floor(100 * n^1.5)
| 레벨 | 다음 레벨까지 필요한 XP | 누적 XP | 칭호 |
|---|---|---|---|
| 1 | 100 | 100 | 초보자 |
| 5 | 1,118 | 2,415 | 초보자 |
| 10 | 3,162 | 10,523 | 탐험가 |
| 25 | 12,500 | 86,024 | 탐험가 |
| 50 | 35,355 | 345,529 | 전문가 |
| 75 | 64,952 | 948,683 | 마스터 |
| 100 | 100,000 | 2,050,000 | 전설 |
칭호
| 레벨 범위 | 칭호 |
|---|---|
| 1 – 9 | 초보자 |
| 10 – 24 | 탐험가 |
| 25 – 49 | 전문가 |
| 50 – 74 | 마스터 |
| 75 – 100 | 전설 |
XP 보상
| 작업 | XP | 설명 |
|---|---|---|
request |
1 | OmniRoute를 통해 라우팅된 API 요청당 |
provider_switch |
5 | 다른 제공자로 전환 |
model_switch |
3 | 다른 모델로 전환 |
combo_create |
10 | 새 콤보 생성 |
combo_use |
2 | 요청에 콤보 사용 |
token_share |
1 | 다른 사용자와 공유한 토큰 1,000개당 |
invite_redeem |
50 | 초대 코드 사용 |
daily_login |
5 | 일일 활성 사용(하루에 한 번) |
streak_bonus |
2 | 연속 기록 일수당(연속 기록 길이를 곱함) |
badge_unlock |
10 | 배지 잠금 해제 |
지급 흐름
export async function awardXp(
apiKeyId: string,
action: XpAction,
metadata?: Record<string, unknown>
): Promise<{ xp: number; level: number; title: string; levelUp: boolean }>;
- XP 수량을 가져오기 위해
XP_REWARDS[action]을 조회합니다. checkRateLimit()을 통과시킵니다(부정행위 방지: 키당 분당 최대 1000 XP).- 트랜잭션을 시작합니다:
- 현재
user_levels행을 읽습니다. - XP를 추가하고
levelFromXp(totalXp)를 통해 레벨을 다시 계산합니다. - 레벨이 변경되면
levelUp = true로 설정합니다. user_levels행을 업데이트합니다.xp_audit_log에 삽입합니다.
- 현재
- 결과를 반환합니다. 호출자가 알림을 처리합니다.
헬퍼: levelFromXp(totalXp)
누적 XP가 totalXp를 초과할 때까지 레벨 1..100을 순회하며 xp_for_level(n)을
합산합니다. 임계값을 충족한 가장 높은 레벨을 반환합니다.
이는 O(100)이며, 레벨이 100으로 제한되므로 허용 가능한 수준입니다.
배지 시스템
파일: src/lib/gamification/badges.ts
카테고리
| 카테고리 | 설명 | 배지 예시 |
|---|---|---|
usage |
사용량 기반 마일스톤 | 첫 요청, 요청 1천 회, 10만 회 |
sharing |
토큰 공유 및 추천 | 첫 공유, 관대한 사용자(10회 공유) |
contribution |
커뮤니티 참여 | 콤보 제작자, 제공자 탐험가 |
streak |
시간에 따른 꾸준함 | 주간 전사, 월간 헌신자 |
rare |
획득하기 어렵거나 숨겨진 업적 | 얼리 어답터, 버그 제보자 |
희귀도
| 희귀도 | 색상 | 확률 참고 |
|---|---|---|
common |
회색 | 대부분의 사용자 |
uncommon |
초록색 | 활성 사용자 |
rare |
파란색 | 헌신적인 사용자 |
legendary |
금색 | 상위 1% |
조건 유형
| 유형 | 필드 | 설명 |
|---|---|---|
action_count |
count |
작업을 N회 수행(예: 요청 1000회) |
streak |
days |
N일 연속 기록 유지 |
unique_count |
field, n |
N개의 고유 값 사용(예: 서로 다른 모델 10개) |
rank |
scope, n |
리더보드 범위에서 N위 달성 |
first |
— | 작업을 가장 먼저 수행 |
hidden |
(다양함) | 획득하기 전까지 표시되지 않는 조건 |
배지 정의는 badge_definitions에 JSON criteria로 저장됩니다:
{
"type": "action_count",
"action": "request",
"count": 1000
}
평가 흐름
emitGamificationEvent(event)
→ evaluateBadges(apiKeyId, event)
→ getBadgeDefinitions() # 모든 정의
→ getUserBadges(apiKeyId) # 이미 획득함(건너뜀)
→ 획득하지 않은 각 배지에 대해:
→ matchesCriteria(badge, event, userState)
→ 일치하는 경우: awardBadge(apiKeyId, badgeId)
→ 알림 페이로드 반환
평가는 이벤트 기반으로 이루어집니다. 즉, 모든 게임화 이벤트 후에 실행되지만
criteria.type이 이벤트 작업과 일치하는 배지만 확인합니다. 따라서 평가가
빠르게 유지됩니다(대부분의 이벤트에서 < 5ms).
matchesCriteria(badge, event, userState)
| 조건 유형 | 확인 |
|---|---|
action_count |
getActionCount(apiKeyId, action) >= count |
streak |
getCurrentStreak(apiKeyId) >= days |
unique_count |
getUniqueCount(apiKeyId, field) >= n |
rank |
getRank(apiKeyId, scope) <= n |
first |
이 작업 유형에 대한 이전 xp_audit_log 항목 없음 |
hidden |
적절한 하위 검사에 위임 |
기본 제공 배지(20개 이상)
전체 배지 목록
| 배지 | 카테고리 | 희귀도 | 기준 |
|---|---|---|---|
| 첫걸음 | 사용량 | 일반 | 요청 1회 |
| 준비 완료 | 사용량 | 일반 | 요청 100회 |
| 파워 유저 | 사용량 | 고급 | 요청 1,000회 |
| 센추리온 | 사용량 | 희귀 | 요청 10,000회 |
| 옴니파워 | 사용량 | 전설 | 요청 100,000회 |
| 제공자 탐험가 | 기여 | 일반 | 서로 다른 제공자 5개 사용 |
| 제공자 마스터 | 기여 | 고급 | 서로 다른 제공자 20개 사용 |
| 콤보 설계자 | 기여 | 고급 | 콤보 5개 생성 |
| 콤보 그랜드마스터 | 기여 | 희귀 | 콤보 25개 생성 |
| 첫 공유 | 공유 | 일반 | 토큰 전송 1회 |
| 관대한 사람 | 공유 | 고급 | 토큰 전송 10회 |
| 자선가 | 공유 | 희귀 | 총 10,000개 토큰 전송 |
| 추천인 | 공유 | 일반 | 추천 1회 성공 |
| 네트워크 구축자 | 공유 | 고급 | 추천 10회 성공 |
| 주간 전사 | 연속 활동 | 고급 | 7일 연속 활동 |
| 한 달의 헌신 | 연속 활동 | 희귀 | 30일 연속 활동 |
| 불굴의 사용자 | 연속 활동 | 전설 | 365일 연속 활동 |
| 얼리 어답터 | 희귀 | 전설 | 베타 기간에 가입 |
| 압축 개척자 | 희귀 | 고급 | 압축 100회 사용 |
| 스킬 수집가 | 희귀 | 희귀 | 서로 다른 스킬 10개 사용 |
| 모델 탐험가 | 기여 | 고급 | 서로 다른 모델 15개 사용 |
연속 기록 추적기
파일: src/lib/gamification/streaks.ts
데이터 모델
연속 기록은 네임스페이스가 적용된 키를 사용하여 key_value 테이블(공용 유틸리티 테이블)에 저장됩니다.
| 키 | 값 | 설명 |
|---|---|---|
gamification:streak:{keyId} |
{current},{longest},{lastDate} |
활성 연속 기록 데이터 |
로직
export async function updateStreak(
apiKeyId: string
): Promise<{ current: number; longest: number; milestone: boolean }>;
key_value에서 연속 기록을 읽습니다.{current},{longest},{lastDate}(ISO 날짜 문자열)를 파싱합니다.lastDate === today이면 변경하지 않습니다(오늘 이미 집계됨).lastDate === yesterday이면current를 증가시키고, 필요한 경우longest를 업데이트합니다.lastDate < yesterday이면current = 1로 재설정합니다(연속 기록 중단).- 업데이트된 기록을 저장합니다.
- 마일스톤인 7일, 14일, 30일, 60일, 90일, 180일, 365일을 확인합니다. 마일스톤을 돌파한 경우
milestone = true로 설정합니다(호출자가 XP를 지급하고 배지를 확인함).
예외 상황
- 시간대: 연속 기록은 UTC 날짜(
new Date().toISOString().slice(0, 10))를 사용합니다. 이는 의도된 동작입니다. 단일 표준 시간대를 사용하면 시간대를 옮겨 다니며 시스템을 악용하는 것을 방지할 수 있습니다. - 신규 사용자: 연속 기록이 존재하지 않으며, 첫 번째 요청 시
current=1, longest=1, lastDate=today로 생성됩니다. - 하루에 여러 번 요청: UTC 기준 하루의 첫 번째 요청만 연속 기록을 증가시킵니다.
리더보드
파일: src/lib/gamification/leaderboard.ts
범위
| 범위 | 기간 | 설명 |
|---|---|---|
global |
all |
전체 기간 누적 XP |
weekly |
week |
현재 UTC 주간(월요일~일요일)에 획득한 XP |
monthly |
month |
현재 UTC 월에 획득한 XP |
tokens_shared |
all |
다른 사용자에게 전송한 총 토큰 수 |
contributions |
all |
생성한 콤보 수 + 사용한 제공자 수 + 사용한 스킬 수 |
순위 계산
순위는 저장되지 않고 읽을 때 계산됩니다. 이를 통해 오래된 순위 데이터가 남는 것을 방지하고 주기적인 순위 재계산 작업이 필요하지 않게 됩니다.
export async function getLeaderboard(
scope: LeaderboardScope,
period: string,
limit: number,
offset: number
): Promise<{ entries: LeaderboardEntry[]; total: number }>;
쿼리 패턴:
SELECT api_key_id, score,
RANK() OVER (ORDER BY score DESC) as rank
FROM leaderboard
WHERE scope = ? AND period = ?
ORDER BY score DESC
LIMIT ? OFFSET ?
기간 전환
주간 및 월간 리더보드는 자동으로 전환됩니다.
- 보관: 기간 경계에 도달하면 현재 항목을 기간 레이블과 함께
leaderboard_archive에 복사합니다. - 재설정: 만료된 기간의 항목을 삭제합니다.
- 트리거:
updateLeaderboard()를 호출할 때마다 확인하며, 새 기간의 첫 번째 요청이 전환을 트리거합니다.
이를 통해 주간 보드는 매주 월요일 00:00 UTC에 재설정되고, 월간 보드는 매월 1일에 재설정됩니다.
SSE 실시간 업데이트
엔드포인트: GET /api/gamification/stream
클라이언트 → GET /api/gamification/stream
→ SSE 연결 설정
→ 서버가 즉시 상위 10개 리더보드 스냅샷 전송
→ 5초마다: 변경된 경우 업데이트된 상위 10개 항목 푸시
→ 15초마다: 하트비트 주석 전송(": heartbeat\n\n")
→ 클라이언트 연결 해제 → 정리(리스너 제거)
이벤트 형식:
event: leaderboard
data: {"scope":"global","entries":[...]}
event: leaderboard
data: {"scope":"weekly","entries":[...]}
: heartbeat
SSE 관리자는 범위별로 연결된 클라이언트를 추적하며, 마지막 푸시 이후 리더보드 데이터가 실제로 변경된 경우에만 업데이트를 전송합니다.
토큰 공유
파일: src/lib/gamification/sharing.ts
복식부기 원장
모든 전송은 token_ledger에 두 개의 행을 생성합니다:
| 행 | from_key_id |
to_key_id |
amount |
|---|---|---|---|
| 차변 | 발신자 | 수신자 | +금액 |
| 대변 | 수신자 | 발신자 | -금액 |
잠깐 — 실제 규칙은 다음과 같습니다:
| 행 | from_key_id |
to_key_id |
amount |
의미 |
|---|---|---|---|---|
| 전송 | 발신자 | 수신자 | +금액 | 발신자로부터의 유출 |
| 수신 | 수신자 | 발신자 | +금액 | 수신자에게로의 유입 |
잔액은 다음과 같이 계산됩니다:
SELECT
COALESCE(SUM(CASE WHEN to_key_id = ? THEN amount ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN from_key_id = ? THEN amount ELSE 0 END), 0)
AS balance
FROM token_ledger
WHERE from_key_id = ? OR to_key_id = ?
전송 흐름
export async function transferTokens(
fromKeyId: string,
toKeyId: string,
amount: number,
idempotencyKey: string
): Promise<{ success: boolean; balance: number }>;
- 검증:
amount > 0,fromKeyId !== toKeyId. - 멱등성:
idempotency_key가 원장에 이미 존재하는지 확인합니다. 존재하면 캐시된 결과를 반환합니다. - 트랜잭션 (단일 SQLite 트랜잭션):
a. 발신자의 잔액을 계산합니다.
b.
balance < amount이면 중단합니다(잔액 부족). c. 전송 행을 삽입합니다(from=sender,를 반환합니다.
속도 제한
- API 키당 분당 최대 10회 전송.
- 단일 전송당 최대 10,000개 토큰.
- API 키당 하루에 최대 100,000개 토큰 전송.
토큰 초대 및 교환
파일: src/lib/gamification/invites.ts
코드 형식
- 코드: 사람이 읽을 수 있으며 사용자에게 표시되는 8자리 영숫자
(예:
A3K9-X7M2). - 토큰: SHA-256 해시로 저장되는 32바이트 무작위 토큰. 프로그래밍 방식의 교환(예: URL 링크)에 사용됩니다.
저장소
| 열 | 값 |
|---|---|
code |
A3K9X7M2 (고유, 인덱스됨) |
token_hash |
SHA-256(raw_token) |
원시 토큰은 생성 시 사용자에게 정확히 한 번만 반환됩니다. OmniRoute는 이후 이를 저장하거나 다시 표시하지 않으며, 해시만 유지됩니다.
자기 추천 방지
사용자가 코드를 교환할 때 시스템은 다음을 확인합니다:
- 코드가 다른
api_key_id에 속하는지 확인합니다. - 교환하는 사용자가 동일한 추천인의 코드를 이전에 교환한 적이 없는지
확인합니다(
invite_tokens와 교환 로그를 조인).
두 확인 중 하나라도 실패하면 명확한 오류 메시지와 함께 교환이 거부됩니다.
만료 및 제한
- 기본
max_uses: 10(생성 시 설정 가능). - 기본
expires_at: 생성일로부터 30일. - 만료되었거나 사용 횟수가 소진된 코드는 HTTP 410 Gone을 반환합니다.
커뮤니티 서버 페더레이션
파일: src/lib/gamification/servers.ts
연결
커뮤니티 서버는 원격 서버에서 발급한 초대 토큰을 통해 등록됩니다. 로컬 인스턴스는 다음을 수행합니다.
- 초대 토큰을 받습니다(예: 대시보드에 붙여넣기).
- 원격 서버의
POST /api/gamification/federation/leaderboard를 호출하여 토큰을 검증하고 현재 리더보드를 가져옵니다. status: connected로 서버 레코드를 저장합니다.
동기화 모델
페더레이션은 추가 방식이 아닌 덮어쓰기 동기화를 사용합니다.
로컬 인스턴스 커뮤니티 서버
│ │
├── 점수 푸시 ────────────────►│ POST /federation/score
│ { api_key_id, score } │ (서버가 토큰 해시 검증)
│ │
├── 리더보드 가져오기 ────────►│ GET /federation/leaderboard
│◄── 상위 N개 항목 ───────────┤ (로컬 캐시 덮어쓰기)
│ │
└── 상태 확인 ────────────────►│ GET /federation/health
(60초마다, 타임아웃 5초) │
인증
페더레이션 요청에는 다음이 포함됩니다.
Authorization: Bearer <raw_token>
X-Federation-Version: 1
원격 서버는 토큰을 해시한 후 일치하는 community_servers 행을 조회합니다. 이를 통해 저장된 해시를 전송하지 않을 수 있습니다.
상태 모니터링
각 서버 레코드는 다음 정보를 추적합니다.
| 필드 | 설명 |
|---|---|
status |
connected, degraded, unreachable |
last_sync |
마지막으로 성공한 동기화의 ISO 타임스탬프 |
failures |
연속 상태 확인 실패 횟수 |
5회 연속 실패하면 상태가 unreachable로 변경되고, 수동 상태 확인이 성공할 때까지 동기화가 일시 중지됩니다.
부정행위 방지
파일: src/lib/gamification/antiCheat.ts
서버 측 점수 계산
모든 XP 계산은 src/lib/gamification/xp.ts에서 수행됩니다. 클라이언트는 점수를 제출하지 않습니다. 대신 작업을 제출하면 서버가 XP를 계산합니다. leaderboard.score 열은 서버 측 코드에서만 쓸 수 있습니다.
요청 속도 제한
| 제한 | 값 | 범위 |
|---|---|---|
| 분당 최대 XP | 1,000 | API 키별 |
| 분당 최대 전송 횟수 | 10 | API 키별 |
| 최대 전송량 | 10,000 | 전송 건별 |
| 일일 최대 전송량 | 100,000 | API 키별 |
요청 속도 제한에는 메모리 내 슬라이딩 윈도우(open-sse/services/의 RateLimitManager와 동일한 패턴)를 사용합니다. 프로세스가 재시작되면 SQLite 기반 카운터를 대체 수단으로 사용합니다.
Z-점수 이상 탐지
시스템은 각 API 키에 대해 시간당 획득한 XP의 최근 7일 이동 창을 유지합니다. XP가 부여될 때마다 다음을 수행합니다.
- 사용자의 현재 시간당 XP 획득률을 계산합니다.
- 모집단 평균과 표준편차를 계산합니다.
z = (user_rate - mean) / stddev를 계산합니다.z > 3.0(표준편차 3개)이면 이상으로 표시합니다.
이상 징후는 action = 'anomaly_detected'와 함께 xp_audit_log에 기록되며 관리자 대시보드에 표시됩니다.
감사 추적
모든 XP 부여, 전송, 배지 획득 및 이상 탐지는 다음 정보와 함께 xp_audit_log에 기록됩니다.
| 필드 | 설명 |
|---|---|
api_key_id |
수행 주체 |
action |
발생한 작업(xp_award, transfer, anomaly, …) |
xp_awarded |
수량(XP 이외 이벤트의 경우 0) |
metadata |
컨텍스트가 포함된 JSON(작업 유형, 대상, …) |
created_at |
발생 시각(ISO 8601) |
관리자는 GET /api/gamification/anomalies를 통해 전체 감사 추적을 조회할 수 있습니다.
API 라우트
모든 라우트는 표준 OmniRoute 패턴을 따릅니다.
라우트 → CORS 사전 요청 → 본문 검증(Zod) → 인증(extractApiKey)
→ 핸들러
엔드포인트
| 메서드 | 경로 | 설명 | 인증 |
|---|---|---|---|
| GET | /api/gamification/leaderboard |
리더보드 조회(범위, 기간, 페이지네이션) | 선택 사항 |
| POST | /api/gamification/leaderboard |
리더보드 캐시 강제 새로 고침 | 필수 |
| GET | /api/gamification/stream |
SSE 실시간 리더보드 업데이트 | 선택 사항 |
| GET | /api/gamification/transfer |
전송 내역 조회(페이지네이션) | 필수 |
| POST | /api/gamification/transfer |
다른 사용자에게 토큰 전송 | 필수 |
| GET | /api/gamification/invite |
내 초대 코드 목록 조회 | 필수 |
| POST | /api/gamification/invite |
새 초대 코드 생성 | 필수 |
| DELETE | /api/gamification/invite |
초대 코드 폐기 | 필수 |
| POST | /api/gamification/invite/redeem |
초대 코드 사용 | 필수 |
| GET | /api/gamification/servers |
커뮤니티 서버 목록 조회 | 필수 |
| POST | /api/gamification/servers |
커뮤니티 서버에 연결 | 필수 |
| DELETE | /api/gamification/servers |
커뮤니티 서버와의 연결 해제 | 필수 |
| POST | /api/gamification/federation/score |
원격 서버로 점수 전송 | 페더레이션 |
| GET | /api/gamification/federation/leaderboard |
원격 서버에서 리더보드 가져오기 | 페더레이션 |
| GET | /api/gamification/notifications |
SSE 배지/레벨 상승 알림 | 필수 |
| GET | /api/gamification/anomalies |
이상 징후 보고서 조회(관리자) | 관리자 |
| POST | /api/gamification/rotate |
초대 토큰 시크릿 교체 | 필수 |
요청/응답 예시
POST /api/gamification/transfer
// 요청
{
"to": "recipient-api-key-id",
"amount": 500,
"idempotencyKey": "uuid-v4"
}
// 응답 200
{
"success": true,
"transfer": {
"id": "txn-uuid",
"from": "sender-api-key-id",
"to": "recipient-api-key-id",
"amount": 500,
"createdAt": "2026-05-19T12:00:00.000Z"
},
"balance": 2500
}
// 응답 400(잔액 부족)
{
"error": "Insufficient balance",
"balance": 200,
"requested": 500
}
GET /api/gamification/leaderboard?scope=weekly&limit=10
{
"scope": "weekly",
"period": "2026-W20",
"entries": [
{
"rank": 1,
"apiKeyId": "key-uuid",
"displayName": "User***1234",
"score": 15230,
"level": 42,
"title": "Expert"
}
],
"total": 847,
"updatedAt": "2026-05-19T12:00:00.000Z"
}
MCP 도구 (8개)
기존 도구와 함께 open-sse/mcp-server/에 등록됩니다. gamification 권한 범위로
제한됩니다.
| 도구 | 설명 | 입력 스키마 | |
|---|---|---|---|
gamification_leaderboard |
범위/기간별 리더보드 조회 | { scope, period?, limit? } |
|
gamification_rank |
호출자의 순위와 인접 순위 조회 | { scope } |
|
gamification_profile |
XP, 레벨, 칭호, 연속 기록 요약 조회 | {} |
|
gamification_badges |
획득한 배지 또는 모든 정의 목록 조회 | { earned?: boolean } |
|
gamification_transfer |
다른 사용자에게 토큰 전송 | { to, amount } |
|
gamification_invite |
초대 코드 생성 또는 목록 조회 | `{ action: "create" | "list" }` |
gamification_servers |
커뮤니티 서버 목록 조회 또는 연결 | { action, token? } |
|
gamification_anomalies |
이상 징후 보고서 조회(관리자 범위) | { limit?, since? } |
대시보드 페이지
/dashboard/leaderboard
- 포디엄 표시(상위 3명의 아바타와 XP).
- 범위 선택기: 전체 / 주간 / 월간 / 공유한 토큰 / 기여.
- 순위, 이름, 점수, 레벨, 칭호가 포함된 페이지네이션 테이블(페이지당 25개).
- SSE 실시간 업데이트 — 순위 변경 시 애니메이션으로 표시.
- 현재 사용자는 테이블에서 강조 표시되며 "내 순위" 고정 행이 제공됨.
/dashboard/profile
- 현재 레벨과 다음 레벨 기준값을 보여주는 XP 진행률 표시줄.
- 칭호 배지를 눈에 잘 띄게 표시.
- 배지 갤러리 — 획득한 배지는 획득 날짜와 함께 표시하고, 획득하지 않은 배지는 회색으로 표시 (숨겨진 배지는 획득할 때까지 "???"로 표시).
- 불꽃 아이콘이 있는 연속 기록 카운터, 연속 기록 달력(최근 30일).
- XP 기록 차트(최근 30일간의 일일 XP).
/dashboard/tokens
- 토큰 잔액(페이지 상단에 눈에 잘 띄게 표시).
- 전송 양식: 수신자, 금액, 확인 대화 상자.
- 필터가 있는 전송 내역 테이블(보냄/받음/전체).
- 초대 섹션: 활성 코드, 새 코드 생성, 공유 링크.
- 커뮤니티 서버: 상태 정보가 포함된 목록, 연결/연결 해제.
/dashboard/gamification/admin
- 심각도, 사용자, 타임스탬프, z-점수가 포함된 이상 징후 목록.
- 필터가 있는 감사 로그 뷰어(작업 유형, 사용자, 날짜 범위).
- 시스템 통계: 지급된 총 XP, 활성 사용자, 배지 획득률.
- 페더레이션 서버 상태 개요.
파이프라인 통합
통합 지점
게이미피케이션은 open-sse/handlers/chatCore.ts의 단일 지점에서 요청 파이프라인에
연결됩니다.
// 응답이 클라이언트로 전송된 후:
setImmediate(() => {
emitGamificationEvent({
type: "request.completed",
apiKeyId,
metadata: {
provider: selectedProvider,
model: selectedModel,
comboId: resolvedCombo?.id,
compressionUsed: compressionStats?.applied,
skillUsed: skillExecution?.name,
},
}).catch(() => {
// 실행 후 처리하지 않음: 로그는 남기되 클라이언트로는 절대 전파하지 않음
});
});
이벤트 유형
| 이벤트 유형 | 발생 시점 |
|---|---|
request.completed |
성공적인 LLM 응답이 전송됨 |
provider.switch |
공급자가 변경됨(콤보 폴백 포함) |
combo.created |
새 콤보 구성이 저장됨 |
combo.used |
콤보 대상에 성공적으로 도달함 |
badge.earned |
배지 평가에서 일치 항목을 찾음 |
streak.milestone |
연속 기록 기준값을 넘어섬 |
transfer.sent |
토큰 전송이 완료됨 |
referral.redeemed |
초대 코드가 성공적으로 사용됨 |
compression.used |
프롬프트 압축이 적용됨 |
skill.executed |
스킬 실행이 완료됨 |
model.first_use |
지난 7일 동안 사용되지 않은 모델이 사용됨 |
비차단 보장
setImmediate + .catch(() => {}) 패턴은 다음을 보장합니다.
- 게이미피케이션이 실행되기 전에 응답이 완전히 전송됩니다.
- 게이미피케이션 오류는 절대 클라이언트에 노출되지 않습니다.
- 이벤트 처리는 인라인이 아니라 다음 마이크로태스크에서 실행됩니다.
보안
위협 모델
| 위협 | 완화 조치 |
|---|---|
| 점수 부풀리기 | XP는 서버 측에서만 계산하며, 클라이언트는 점수가 아닌 행동을 제출 |
| 재전송 공격 | 전송에 멱등성 키 적용, 감사 로그 중복 제거 |
| 전송 사기 | 복식부기 원장, 원자적 트랜잭션, 요청 속도 제한 |
| 자기 추천 | 교환 시 api_key_id 교차 확인 |
| 리더보드 조작 | Z-점수 이상 탐지, 관리자용 이상 현상 대시보드 |
| 페더레이션 토큰 탈취 | SHA-256 해시로 저장, 원본 토큰은 한 번만 표시 |
| 초대 코드 무차별 대입 | 교환 엔드포인트에 요청 속도 제한 적용, 8자 엔트로피 |
| 표시 이름의 XSS | 표시 이름 정제, 리더보드 항목 이스케이프 |
| 해시에 대한 타이밍 공격 | 토큰 해시 비교에 crypto.timingSafeEqual 사용 |
인증 요구 사항
- 공개(인증 불필요):
GET /leaderboard,GET /stream(읽기 전용 리더보드). - API 키 필요: 모든 쓰기 작업, 프로필, 전송, 초대.
- 관리자 전용: 이상 현상 대시보드, 감사 로그 뷰어.
- 페더레이션:
Authorization헤더의 원본 토큰을 사용하는 별도의 인증 경로이며, 저장된 SHA-256 해시와 대조하여 검증.
테스트
테스트 파일
모든 테스트는 Node.js 기본 테스트 러너(node --import tsx/esm --test)를 사용합니다.
| 테스트 파일 | 검사 대상 | 테스트 |
|---|---|---|
tests/unit/gamification/xp.test.ts |
XP 계산, 레벨 곡선, 칭호 | 8 |
tests/unit/gamification/badges.test.ts |
배지 기준 일치 여부, 수여 | 10 |
tests/unit/gamification/streaks.test.ts |
연속 기록 로직, 마일스톤, 경계 사례 | 7 |
tests/unit/gamification/leaderboard.test.ts |
순위 계산, 페이지네이션, 순환 | 8 |
tests/unit/gamification/sharing.test.ts |
전송, 잔액, 멱등성 | 9 |
tests/unit/gamification/invites.test.ts |
생성, 교환, 만료, 자기 추천 | 7 |
tests/unit/gamification/antiCheat.test.ts |
요청 속도 제한, Z-점수, 감사 로깅 | 6 |
tests/unit/gamification/events.test.ts |
이벤트 발생, 팬아웃, 오류 처리 | 5 |
테스트 실행
# 모든 게이미피케이션 테스트
node --import tsx/esm --test tests/unit/gamification/*.test.ts
# 단일 테스트 파일
node --import tsx/esm --test tests/unit/gamification/xp.test.ts
커버리지 요구 사항
CONTRIBUTING.md에 따라 모든 신규 모듈은 다음 조건을 충족해야 합니다.
- 브랜치 커버리지 >= 80%.
- 모든 공개 함수를 최소 한 번 테스트.
- 오류 경로 테스트(잔액 부족, 만료된 코드, 요청 속도 제한).
파일 구조
src/
lib/
db/
migrations/
060_create_gamification.sql # 테이블 8개 및 인덱스 전체
gamification.ts # 도메인 CRUD 모듈
gamification/
xp.ts # XP 계산, 레벨 곡선, 칭호
badges.ts # 배지 정의, 기준, 평가
streaks.ts # 일일 연속 기록 추적
leaderboard.ts # 순위 계산, SSE, 로테이션
antiCheat.ts # 속도 제한, z-score, 감사
sharing.ts # 토큰 전송 원장
invites.ts # 초대/사용 코드
servers.ts # 커뮤니티 서버 페더레이션
events.ts # 이벤트 이미터(통합 지점)
notifications.ts # SSE 알림 스트림
app/
api/
gamification/
leaderboard/route.ts # GET/POST 리더보드
leaderboard/stream/route.ts # SSE 실시간 업데이트
transfer/route.ts # GET/POST 전송
invite/route.ts # GET/POST/DELETE 초대 코드
invite/redeem/route.ts # POST 코드 사용
servers/route.ts # GET/POST/DELETE 서버
federation/score/route.ts # POST 점수 푸시
federation/leaderboard/route.ts # GET 리더보드 가져오기
notifications/route.ts # SSE 알림
anomalies/route.ts # GET 이상 징후 보고서
rotate/route.ts # POST 시크릿 로테이션
(dashboard)/
dashboard/
leaderboard/page.tsx # 순위 페이지
profile/page.tsx # XP/배지/연속 기록 페이지
tokens/page.tsx # 잔액/전송/초대 페이지
gamification/admin/page.tsx # 관리자용 이상 징후 모니터링
shared/
constants/
gamification.ts # XP_REWARDS, TITLES, BADGE_DEFS, LIMITS
tests/
unit/
gamification/
xp.test.ts
badges.test.ts
streaks.test.ts
leaderboard.test.ts
sharing.test.ts
invites.test.ts
antiCheat.test.ts
events.test.ts
docs/
frameworks/
GAMIFICATION.md # 이 문서
마이그레이션 전략
1단계: 백엔드 핵심 기능(PR 1)
- 마이그레이션
060_create_gamification.sql(테이블 8개). src/lib/db/gamification.ts(도메인 모듈).src/lib/gamification/xp.ts,streaks.ts,events.ts.chatCore.ts의 통합 지점.- XP, 연속 기록, 이벤트 단위 테스트.
2단계: 배지 및 리더보드(PR 2)
src/lib/gamification/badges.ts,leaderboard.ts.- 상수에 배지 정의 추가.
- 리더보드 API 라우트 및 SSE 스트림.
- 배지 및 리더보드 단위 테스트.
3단계: 공유 및 초대(PR 3)
src/lib/gamification/sharing.ts,invites.ts,antiCheat.ts.- 전송 및 초대 API 라우트.
- 공유, 초대, 부정행위 방지 단위 테스트.
4단계: 페더레이션 및 대시보드(PR 4)
src/lib/gamification/servers.ts,notifications.ts.- 페더레이션 API 라우트.
- 대시보드 페이지(리더보드, 프로필, 토큰, 관리자).
- MCP 도구 등록.
향후 고려 사항
- 시즌 이벤트: 기간 한정 배지 세트 및 리더보드 시즌.
- 팀 리더보드: 조직 또는 콤보별로 사용자를 그룹화.
- XP 배율: 프로모션 기간에 XP를 추가로 제공.
- 업적 공유: 공유 가능한 배지 카드(OpenGraph 이미지) 생성.
- 모바일 푸시: 배지/레벨 이벤트에 대한 웹훅 기반 알림.
- 리더보드 API: 서드파티 통합을 위한 공개 API.