Compare commits

50 Commits

Author SHA1 Message Date
83c583c04d Fix: 지갑 미발견 시 자동 생성 fallback 추가
resolveUsername()에서 user_wallets 레코드가 없는 유저(레거시/마이그레이션 누락)에 대해
CreateWallet을 자동 호출하여 지갑을 즉시 생성. unique constraint 충돌 시 재조회로
동시성 안전 처리.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:28:38 +09:00
feb8ec96ad feat: 체인 클라이언트 멀티노드 페일오버 (SPOF 해결)
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 21s
Server CI/CD / deploy (push) Successful in 56s
CHAIN_NODE_URLS 환경변수(쉼표 구분)로 복수 노드 지정 가능.
Client.Call()이 네트워크/HTTP 오류 시 다음 노드로 자동 전환.
RPC 레벨 오류(트랜잭션 실패 등)는 즉시 반환 (페일오버 미적용).
기존 CHAIN_NODE_URL 단일 설정은 하위 호환 유지.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:31:46 +09:00
e187a20e28 ci: Gitea 환경으로 전환 (git.tolelom.xyz 레지스트리, tolchain GitHub 체크아웃)
- Docker registry: ghcr.io → git.tolelom.xyz
- 로그인: GITEA_TOKEN 사용
- tolchain 체크아웃: vars.TOLCHAIN_GITHUB_REPO 로 GitHub에서 가져오기

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:25:16 +09:00
38da7ce57a ci: vet + 커버리지 리포트 + Docker GHCR 빌드/푸시 + SSH 배포 추가
- test job: go vet + go build + go test (coverage.out 아티팩트 업로드)
- docker job: main 머지 시 GHCR 이미지 빌드/푸시 (tolchain 의존성 처리)
- deploy job: SSH로 docker compose pull api && up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:21:13 +09:00
fa03673e9c refactor: main.go 서버 초기화 로직을 internal/server/server.go로 분리
Fiber 앱 설정, 미들웨어, rate limiter를 server 패키지로 추출.
main.go는 DB 연결, DI, 서버 시작, graceful shutdown만 담당.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 17:11:13 +09:00
0dfa744c16 feat: DB DI 전환 + download 하위 호환성 + race condition 수정
- middleware(Auth, Idempotency)를 클로저 팩토리 패턴으로 DI 전환
- database.DB/RDB 전역 변수 제거, ConnectMySQL/Redis 값 반환으로 변경
- download API X-API-Version 헤더 + 하위 호환성 규칙 문서화
- SaveGameData PlayTimeDelta 원자적 UPDATE (race condition 해소)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:58:36 +09:00
f4d862b47f feat: 보상 재시도 + TX 확정 대기 + 에러 포맷 통일 + 품질 고도화
- 보상 지급 실패 시 즉시 재시도(3회 backoff) + DB 기록 + 백그라운드 워커 재시도
- WaitForTx 폴링으로 블록체인 TX 확정 대기, SendTxAndWait 편의 메서드
- chain 트랜잭션 코드 중복 제거 (userTx/operatorTx 헬퍼, 50% 감소)
- AppError 기반 에러 응답 포맷 통일 (8개 코드, 전 핸들러 마이그레이션)
- TX 에러 분류 + 한국어 사용자 메시지 매핑 (11가지 패턴)
- player 서비스 테스트 20개 + chain WaitForTx 테스트 10개 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:42:03 +09:00
8da2bdab12 ci: GitHub Actions 워크플로우 추가
Go 빌드 + 테스트 자동화 (push/PR on main)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:56:21 +09:00
b16eb6cc7a feat: 에러 처리 표준화 + BossRaid 낙관적 잠금
에러 표준화:
- pkg/apperror — AppError 타입, 7개 sentinel error
- pkg/middleware/error_handler — Fiber ErrorHandler 통합
- 핸들러에서 AppError 반환 시 구조화된 JSON 자동 응답

BossRaid Race Condition:
- 상태 전이 4곳 낙관적 잠금 (UPDATE WHERE status=?)
- TransitionRoomStatus/TransitionRoomStatusMulti 메서드 추가
- ErrStatusConflict sentinel error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:48:28 +09:00
844a5b264b feat: 보안 수정 + Prometheus 메트릭 + 단위 테스트 추가
보안:
- Zip Bomb 방어 (io.LimitReader 100MB)
- Redis Del 에러 로깅 (auth, idempotency)
- 로그인 실패 로그에서 username 제거
- os.Remove 에러 로깅

모니터링:
- Prometheus 메트릭 미들웨어 + /metrics 엔드포인트
- http_requests_total, http_request_duration_seconds 등 4개 메트릭

테스트:
- download (11), chain (10), bossraid (20) = 41개 단위 테스트

기타:
- DB 모델 GORM 인덱스 태그 추가
- launcherHash 필드 + hashFileToHex() 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:37:42 +09:00
82adb37ecb fix: BossRoom soft delete → hard delete + 프로필 자동 생성
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 17s
Server CI/CD / deploy (push) Successful in 57s
- BossRoom 삭제 시 Unscoped() hard delete로 변경하여 unique index 충돌 방지
- GetProfile에서 프로필 없으면 기본값으로 자동 생성

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:09:23 +09:00
2c1e9698d2 refactor: 클라이언트 직접 호출 BossRaid 엔드포인트 제거
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 13s
Server CI/CD / deploy (push) Successful in 55s
클라이언트가 MMO 서버 경유로 보스 레이드 입장하도록 변경하면서
불필요해진 public /api/bossraid/ 라우트와 핸들러 제거.
입장은 MMO 서버 → internal API 경로만 사용.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:56:38 +09:00
333cfa7911 fix: Swagger CSP에 validator.swagger.io 이미지 허용 추가
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 12s
Server CI/CD / deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 09:23:27 +09:00
635dfb3221 fix: Swagger UI CSP 완화하여 리소스 로딩 허용
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 15s
Server CI/CD / deploy (push) Successful in 57s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 09:19:26 +09:00
fc976dbba8 fix: ResetRoom 시 BossRoom 레코드 정리
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 15s
Server CI/CD / deploy (push) Successful in 57s
데디서버가 reset-room 호출 시 슬롯만 idle로 변경하고 BossRoom 레코드는
남아있어서 다음 입장 시 unique 제약 위반(Duplicate entry) 발생.
ResetRoom에서 해당 sessionName의 BossRoom 레코드도 함께 삭제.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 23:17:14 +09:00
0ad19562a5 fix: Internal API 라우트를 apiLimiter보다 먼저 등록
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 13s
Server CI/CD / deploy (push) Successful in 58s
Fiber는 라우트를 등록 순서대로 매칭하므로, /api/internal이 /api 그룹
뒤에 있으면 apiLimiter가 먼저 적용됨. 순서를 변경하여 Rate Limit 우회.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:40:51 +09:00
5758c4784e fix: Internal API를 Rate Limiter에서 분리
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 14s
Server CI/CD / deploy (push) Successful in 58s
데디케이트 서버 10개 인스턴스의 하트비트가 apiLimiter(IP당 60req/min)에
걸려 429 에러 발생. Internal API를 별도 그룹으로 분리하여 Rate Limit 제외.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:32:49 +09:00
22e0652ee3 fix: 좀비 슬롯 정리 및 보상 실패 상태 추적
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 1m5s
Server CI/CD / deploy (push) Successful in 58s
- RequestEntry() 시 CheckStaleSlots() 호출하여 좀비 슬롯 자동 정리
- 블록체인 보상 실패 시 BossRoom 상태를 reward_failed로 업데이트
- UpdateRoomStatus() 레포지토리 메서드 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:07:34 +09:00
befea9dd68 feat: Swagger API 문서 추가 + 보스레이드/플레이어 레벨 시스템
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 12m3s
Server CI/CD / deploy (push) Has been cancelled
- swaggo/swag 기반 전체 API 엔드포인트 Swagger 어노테이션 (59개)
- /swagger/ 경로에 Swagger UI 제공
- 보스레이드 데디서버 관리 (등록, 하트비트, 슬롯 리셋)
- 플레이어 레벨/경험치 시스템 및 스탯 성장

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:51:37 +09:00
ee2cf332fb ci: lint-and-build에 tolchain 클론 추가 (go.mod replace 의존성)
All checks were successful
Server CI/CD / lint-and-build (push) Successful in 1m0s
Server CI/CD / deploy (push) Successful in 1m27s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:47:07 +09:00
9c27edf4fb ci: 테스트 실행 추가 + git clone 토큰 로그 노출 방지
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 53s
Server CI/CD / deploy (push) Has been skipped
- lint-and-build 단계에 go test ./... 추가
- deploy 단계 git clone에 set +x / --quiet / 2>/dev/null 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:09:39 +09:00
423e2832a0 fix: 3차 리뷰 LOW — 에러 메시지 일관성, Redis 타임아웃, 입력 검증
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 30s
Server CI/CD / deploy (push) Has been skipped
- 5개 핸들러 err.Error() → 제네릭 메시지 (Login, Refresh, SSAFY, Ticket, BossRaid)
- Redis context.Background() → WithTimeout 5s (10곳)
- SprintMultiplier 범위 검증 추가
- 방어적 문서화 (SSAFY 충돌, zip bomb, body limit prefix, 로그 주입)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:05:17 +09:00
9504bf37de fix: RequestEntry TOCTOU 경쟁 조건 수정 — 트랜잭션으로 원자화
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 38s
Server CI/CD / deploy (push) Has been skipped
중복 입장 방지를 위해 active-room 체크와 room 생성을 단일 트랜잭션으로 래핑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:53:33 +09:00
b0de89a18a feat: 코드 리뷰 기반 전면 개선 — 보안, 검증, 테스트, 안정성
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 47s
Server CI/CD / deploy (push) Has been skipped
- 체인 nonce 경쟁 조건 수정 (operatorMu + per-user mutex)
- 등록/SSAFY 원자적 트랜잭션 (wallet+profile 롤백 보장)
- IdempotencyRequired 미들웨어 (SETNX 원자적 클레임)
- 런치 티켓 API (JWT URL 노출 방지)
- HttpOnly 쿠키 refresh token
- SSAFY OAuth state 파라미터 (CSRF 방지)
- Refresh 시 DB 조회로 최신 role 사용
- 공지사항/유저목록 페이지네이션
- BodyLimit 미들웨어 (1MB, upload 제외)
- 입력 검증 강화 (닉네임, 게임데이터, 공지 길이)
- 에러 메시지 내부 정보 노출 방지
- io.LimitReader (RPC 10MB, SSAFY 1MB)
- RequestID 비출력 문자 제거
- 단위 테스트 (auth 11, announcement 9, bossraid 16)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:03:25 +09:00
cc8368dfba feat: 인프라 개선 — 헬스체크, 로깅, 보안, CI 검증
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 1m13s
Server CI/CD / deploy (push) Has been skipped
- /health + /ready 엔드포인트 추가 (DB/Redis 상태 확인)
- RequestID 미들웨어 + 구조화 JSON 로깅
- 체인 트랜잭션 per-user rate limit (20 req/min)
- DB 커넥션 풀 설정 (MaxOpen 25, MaxIdle 10, MaxLifetime 5m)
- Graceful Shutdown 시 Redis/MySQL 연결 정리
- Dockerfile HEALTHCHECK 추가
- CI에 go vet + 빌드 검증 단계 추가 (deploy 전 실행)
- 보스 레이드 클라이언트 입장 API (JWT 인증)
- Player 프로필 모듈 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 03:41:34 +09:00
d597ef2d46 fix: 보안·안정성·동시성 개선 3차
All checks were successful
Server CI/CD / deploy (push) Successful in 1m31s
- 입력 검증 강화 (로그인/체인 핸들러 전체)
- boss raid 비관적 잠금으로 동시성 문제 해결
- SSAFY 사용자명 sanitize + 트랜잭션 처리
- constant-time API 키 비교, 보안 헤더, graceful shutdown
- 안전하지 않은 기본값 경고 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:40:06 +09:00
cc751653c4 fix: 코드 리뷰 기반 보안·안정성 개선 2차
All checks were successful
Server CI/CD / deploy (push) Successful in 1m26s
보안:
- RPC 응답 HTTP 상태코드 검증 (chain/client)
- SSAFY OAuth 에러 응답 내부 로깅으로 변경 (제3자 상세 노출 제거)
- resolveUsername에서 username 노출 제거
- LIKE 쿼리 특수문자 이스케이프 (bossraid/repository)
- 파일명 경로 순회 방지 + 길이 제한 (download/handler)
- ServerAuth 실패 로깅 추가

안정성:
- AutoMigrate 에러 시 서버 종료
- GetLatest() 에러 시 nil 반환 (초기화 안 된 포인터 방지)
- 멱등성 캐시 저장 시 새 context 사용
- SSAFY HTTP 클라이언트 타임아웃 10s
- io.ReadAll/rand.Read 에러 처리
- Login에서 DB 에러/Not Found 구분

검증 강화:
- 중복 플레이어 검증 (bossraid/service)
- username 길이 제한 50자 (auth/handler, bossraid/handler)
- 역할 변경 시 세션 무효화
- 지갑 복호화 실패 로깅

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:48:05 +09:00
61cf47070d feat: 보스 레이드 방 관리 모듈 추가
All checks were successful
Server CI/CD / deploy (push) Successful in 1m34s
MMO 서버/데디케이트 서버 연동을 위한 내부 API 엔드포인트 구현:
- POST /api/internal/bossraid/entry — 파티 입장 요청 (방 생성)
- POST /api/internal/bossraid/start — 세션 시작 보고
- POST /api/internal/bossraid/complete — 클리어 보고 + TOL Chain 보상 지급
- POST /api/internal/bossraid/fail — 실패 보고
- GET /api/internal/bossraid/room — 방 조회

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:14:03 +09:00
23bec776ab fix: 코드 리뷰 기반 보안·안정성 개선 (14건)
All checks were successful
Server CI/CD / deploy (push) Successful in 1m36s
- unsafe 타입 단언 → safe assertion (chain handler 11곳, auth Logout)
- Repository 에러 시 nil 반환으로 통일 (chain, auth, announcement)
- string ID → uint 파싱으로 타입 안전성 확보 (auth, announcement)
- CORS AllowHeaders에 Idempotency-Key, X-API-Key 추가
- /verify 엔드포인트 rate limiter 적용
- Redis 호출에 context timeout 적용 (auth, idempotency 미들웨어)
- chain handler 에러 응답에서 내부 정보 노출 방지
- f.Close() 에러 검사 추가 (download service 2곳)
- 공지사항 Delete 404 응답 추가
- 회원가입 롤백 시 Delete 에러 로깅

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:47:00 +09:00
3d0c9e5670 fix: CI 배포 시 컨테이너 stop/rm 후 재생성
All checks were successful
Server CI/CD / deploy (push) Successful in 1m28s
force-recreate만으로는 크래시 루프 상태에서 교체가 안 되는 문제 해결.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:25:09 +09:00
9738f1a83c fix: SSAFYTokenResponse.expires_in 타입을 int로 수정
All checks were successful
Server CI/CD / deploy (push) Successful in 1m26s
SSAFY 서버가 expires_in을 숫자로 반환하므로 string에서 int로 변경.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:21:01 +09:00
d911c8ed1f fix: tolchain clone URL을 GitHub으로 수정
All checks were successful
Server CI/CD / deploy (push) Successful in 1m15s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:07:06 +09:00
657710b23d fix: CI에서 tolchain 의존성 포함하여 Docker 빌드
All checks were successful
Server CI/CD / deploy (push) Successful in 19s
Dockerfile을 상위 빌드 컨텍스트 방식으로 변경하고,
CI에서 tolchain도 함께 clone하여 빌드 실패 해결.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:05:04 +09:00
0ce39a48b9 feat: SSAFY OAuth 2.0 로그인 구현
All checks were successful
Server CI/CD / deploy (push) Successful in 26s
SSAFY 인증 서버를 통한 소셜 로그인 기능 추가.
인가 코드 교환, 사용자 정보 조회, 자동 회원가입 처리.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:54:22 +09:00
26876ba8ca fix: 보안 강화 및 안정성 개선
All checks were successful
Server CI/CD / deploy (push) Successful in 5s
- fileHash 빈 문자열 시 게임 업로드 거부 (A301.exe 누락 zip 차단)
- Rate limiting 추가: 인증 API 10req/min, 일반 API 60req/min
- 블록체인 트랜잭션 Idempotency-Key 미들웨어 (Redis 캐싱, 10분 TTL)
- 파일 업로드 크기 제한 4GB (BodyLimit)
- Username 대소문자 정규화 (Register/Login에서 소문자 변환)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:10:23 +09:00
4843470310 fix: 보안 강화 및 리프레시 토큰 도입
All checks were successful
Server CI/CD / deploy (push) Successful in 7s
- middleware: JWT MapClaims 타입 단언 패닉 → ok 패턴으로 방어
- auth/service: Redis Set 오류 처리, 지갑 생성 실패 시 유저 롤백
- auth/service: EnsureAdmin 지갑 생성 추가, Logout 리프레시 토큰도 삭제
- auth/service: 리프레시 토큰 발급(7일) 및 로테이션, REFRESH_SECRET 분리
- auth/handler: Login 응답에 refreshToken 포함, Refresh 핸들러 추가
- auth/handler: Logout 에러 처리 추가
- download/service: hashGameExeFromZip io.Copy 오류 처리
- download/handler: Content-Disposition mime.FormatMediaType으로 헤더 인젝션 방어
- announcement/handler: Update 빈 body 400 반환
- config: REFRESH_SECRET 환경변수 추가
- routes: POST /api/auth/refresh 엔드포인트 추가
- main: INTERNAL_API_KEY 미설정 시 경고 출력
- .env.example: 누락 환경변수 7개 보완

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:51:17 +09:00
f8b23e93bf feat: 블록체인(chain) 통합 및 내부 API 추가
All checks were successful
Server CI/CD / deploy (push) Successful in 7s
- internal/chain 패키지 추가 (client, handler, service, repository, model)
- 체인 연동 엔드포인트: 지갑 조회, 잔액, 자산, 인벤토리, 마켓 등
- 관리자 전용 체인 엔드포인트: 민팅, 보상, 템플릿 등록
- 게임 서버용 내부 API (/api/internal/chain/*) + ServerAuth 미들웨어
- 회원가입 시 블록체인 월렛 자동 생성
- 체인 관련 환경변수 및 InternalAPIKey 설정 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:18:15 +09:00
1b6260ee4e refactor: verify 응답에서 userId 제거
All checks were successful
Server CI/CD / deploy (push) Successful in 37s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 14:14:39 +09:00
d6abac3f0a feat: JWT 검증 엔드포인트 추가 (POST /api/auth/verify)
All checks were successful
Server CI/CD / deploy (push) Successful in 1m17s
게임 서버가 클라이언트로부터 받은 JWT를 웹 서버에 전달하면,
서명 검증 + Redis 세션 확인 후 userId와 username을 응답한다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 13:13:26 +09:00
2996e0fa0f docs: README, CLAUDE.md 작성
All checks were successful
Server CI/CD / deploy (push) Successful in 36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 00:20:43 +09:00
2b8d342896 fix: CORS AllowMethods에 PATCH 추가
All checks were successful
Server CI/CD / deploy (push) Successful in 35s
유저 권한 변경(PATCH /api/users/:id/role) CORS 오류 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:43:57 +09:00
f547593c6f feat: launcher.exe와 game.zip 별도 업로드/서빙 분리
All checks were successful
Server CI/CD / deploy (push) Successful in 36s
- POST /api/download/upload/game - 게임 zip 업로드
- POST /api/download/upload/launcher - launcher.exe 업로드
- GET /api/download/launcher - launcher.exe 서빙
- Info 모델에 LauncherURL, LauncherSize 필드 추가
- Content-Disposition 헤더로 올바른 파일명 설정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:34:09 +09:00
18c39bd4c5 feat: 게임 파일 직접 업로드 방식으로 전환
All checks were successful
Server CI/CD / deploy (push) Successful in 35s
- zip 스트리밍 업로드 (StreamRequestBody) → /data/game/game.zip 저장
- A301.exe SHA256 해시 자동 추출 (zip 분석)
- 버전·파일명·크기 파일명 및 용량에서 자동 추출
- GET /api/download/file 엔드포인트 추가
- BASE_URL, GAME_DIR 환경변수 추가
- Dockerfile에 /data/game 디렉토리 생성

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 23:25:07 +09:00
003eb4c1c2 fix: fileHash 필수 조건 제거 (선택 입력으로 변경)
All checks were successful
Server CI/CD / deploy (push) Successful in 35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 22:34:53 +09:00
8dee6f70b3 feat: 다운로드 정보에 fileHash 필드 추가
All checks were successful
Server CI/CD / deploy (push) Successful in 38s
게임 EXE의 SHA256 해시를 저장하여 런처가 버전 검증에 활용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 22:22:37 +09:00
3fb182c271 feat: 회원가입 API 추가 (POST /api/auth/register)
All checks were successful
Server CI/CD / deploy (push) Successful in 37s
- Register 서비스 메서드 추가 (중복 아이디 체크, bcrypt 해시, role: user로 생성)
- Register 핸들러 추가 (빈값, 6자 미만 비밀번호 유효성 검사)
- /api/auth/register 라우트 등록 (public)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 15:11:34 +09:00
17983ad775 feat: 유저 관리 API 추가 (목록 조회, 권한 변경, 삭제)
- GET /api/users - 전체 유저 목록 (admin only)
- PATCH /api/users/:id/role - 권한 변경 (admin only)
- DELETE /api/users/:id - 유저 삭제 (admin only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:52:45 +09:00
ec6ac35ac7 refactor: 모델에 JSON 태그 추가 및 gorm.Model 인라인 확장
All checks were successful
Server CI/CD / deploy (push) Successful in 35s
camelCase JSON 필드명으로 통일, PasswordHash json:"-" 처리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:32:11 +09:00
05d6d5af4d chore: seed 스크립트 제거
All checks were successful
Server CI/CD / deploy (push) Successful in 36s
환경변수 기반 admin 자동 생성으로 대체

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:29:29 +09:00
633175f5be feat: 환경변수로 초기 admin 계정 자동 생성
서버 시작 시 ADMIN_USERNAME, ADMIN_PASSWORD 환경변수 기반으로
admin 계정이 없을 경우 자동 생성 (이미 있으면 스킵)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:28:33 +09:00
63 changed files with 20161 additions and 234 deletions

View File

@@ -10,4 +10,27 @@ REDIS_ADDR=localhost:6379
REDIS_PASSWORD=
JWT_SECRET=your-secret-key-here
JWT_EXPIRY_HOURS=24
REFRESH_SECRET=your-refresh-secret-key-here
JWT_EXPIRY_HOURS=1
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin1234
BASE_URL=http://localhost:8080
GAME_DIR=/data/game
# Chain integration
CHAIN_NODE_URL=http://localhost:8545
CHAIN_ID=tolchain-dev
# 운영자 지갑 개인키 (hex, 비워두면 mint/reward 불가)
OPERATOR_KEY_HEX=
# AES-256 암호화 키 - 반드시 64자 hex (32 bytes) 설정 필요
WALLET_ENCRYPTION_KEY=
# 게임 서버 → API 서버 내부 통신용 API 키 (비워두면 /api/internal/* 비활성화)
INTERNAL_API_KEY=
# SSAFY OAuth 2.0
SSAFY_CLIENT_ID=
SSAFY_CLIENT_SECRET=
SSAFY_REDIRECT_URI=

View File

@@ -6,8 +6,32 @@ on:
- main
jobs:
lint-and-build:
runs-on: ubuntu-latest
steps:
- name: 코드 체크아웃
uses: actions/checkout@v4
- name: tolchain 의존성 클론
run: git clone --depth 1 https://github.com/tolelom/tolchain.git ../tolchain
- name: Go 설치
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: go vet 검증
run: go vet ./...
- name: 테스트 실행
run: go test ./... -count=1
- name: 빌드 검증
run: go build -o /dev/null .
deploy:
runs-on: ubuntu-latest
needs: lint-and-build
steps:
- name: 서버에 배포
uses: appleboy/ssh-action@v1
@@ -17,12 +41,17 @@ jobs:
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
script: |
set -e
export PATH=$PATH:/usr/local/bin:/opt/homebrew/bin:$HOME/.docker/bin
cd /tmp
rm -rf a301-server
git clone https://tolelom:${{ secrets.GIT_TOKEN }}@git.tolelom.xyz/A301/a301_server.git a301-server
cd a301-server
docker build --no-cache -t a301-server:latest .
rm -rf a301-build
mkdir a301-build && cd a301-build
# Suppress token from logs
set +x
git clone --quiet https://tolelom:${{ secrets.GIT_TOKEN }}@git.tolelom.xyz/A301/a301_server.git a301_server 2>/dev/null
set -x
git clone --quiet https://github.com/tolelom/tolchain.git tolchain
docker build --no-cache -t a301-server:latest -f a301_server/Dockerfile .
cd ~/server
docker compose up -d --force-recreate a301-server
rm -rf /tmp/a301-server
docker compose up -d --no-deps --force-recreate a301-server
rm -rf /tmp/a301-build

96
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
# ── 1. 빌드 + 정적 분석 + 테스트 ───────────────────────────────────────────
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# tolchain은 GitHub에 위치 — vars.TOLCHAIN_GITHUB_REPO 에 "owner/tolchain" 형태로 설정
- name: Checkout tolchain from GitHub
uses: actions/checkout@v4
with:
repository: ${{ vars.TOLCHAIN_GITHUB_REPO }}
path: ../tolchain
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Vet
run: go vet ./...
- name: Build
run: go build ./...
- name: Test (with coverage)
run: go test ./... -coverprofile=coverage.out -coverpkg=./...
- name: Coverage report
run: go tool cover -func=coverage.out | tail -1
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
# ── 2. Docker 빌드 & Gitea 레지스트리 푸시 (main 머지 시만) ───────────────
docker:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout a301_server
uses: actions/checkout@v4
with:
path: a301_server
# tolchain 없이는 Dockerfile이 빌드되지 않으므로 같은 레벨에 체크아웃
- name: Checkout tolchain from GitHub
uses: actions/checkout@v4
with:
repository: ${{ vars.TOLCHAIN_GITHUB_REPO }}
path: tolchain
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: git.tolelom.xyz
username: ${{ github.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: .
file: ./a301_server/Dockerfile
push: true
tags: git.tolelom.xyz/${{ github.repository_owner }}/a301-server:latest
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
# ── 3. 서버 배포 ──────────────────────────────────────────────────────────
deploy:
needs: docker
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
export PATH=$PATH:/usr/local/bin:/opt/homebrew/bin:$HOME/.docker/bin
cd ~/server
docker compose pull api
docker compose up -d api

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ coverage.html
# OS
.DS_Store
Thumbs.db
# Game files
*.zip

119
CLAUDE.md Normal file
View File

@@ -0,0 +1,119 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
go run . # 로컬 실행
go build -o server . # 빌드
docker build -t a301-server . # Docker 빌드
```
## Tech Stack
- **Go** + **Fiber v2** (StreamRequestBody: true — 대용량 업로드용, Body Limit 4GB)
- **GORM** + **MySQL** (AutoMigrate 사용)
- **Redis** — JWT 세션 저장(`session:{userID}`, `refresh:{userID}`) + 멱등성 캐시
- **JWT** — `golang-jwt/jwt v5`, Access + Refresh 토큰 로테이션
## Project Purpose
"One of the plans" 게임 플랫폼 백엔드.
인증 / 공지사항 / 게임 파일 업로드·서빙 / 블록체인(TOL Chain) 연동 담당.
## Project Structure
```
internal/
├── auth/ # User 모델, JWT 발급·검증, 세션 관리(Redis)
├── announcement/ # Announcement CRUD
├── download/ # Info 조회, 파일 업로드(스트리밍), 파일 서빙
└── chain/ # 블록체인 지갑·거래·마켓·인벤토리 (5파일: handler, service, repository, model, client)
pkg/
├── config/ # 환경변수 → Config 구조체
├── database/ # ConnectMySQL(), ConnectRedis()
└── middleware/ # Auth, AdminOnly, ServerAuth, Idempotency
routes/routes.go # 모든 라우트 등록
```
## Key Patterns
- **계층 구조**: `Handler → Service → Repository`. 각 도메인 폴더에 4~5파일.
- **파일 업로드**: `Fiber StreamRequestBody: true` + `io.Copy`로 raw body를 직접 디스크에 스트리밍. 메모리에 파일 올리지 않음.
- **SHA256 자동 추출**: 게임 zip 업로드 시 zip 내 `A301.exe`를 스트리밍으로 읽어 해시 계산.
- **CORS**: `AllowMethods``PATCH` 포함 필수 (유저 권한 변경 엔드포인트). `AllowOrigins`: `https://a301.tolelom.xyz`.
- **초기 admin 계정**: 서버 시작 시 `EnsureAdmin()`으로 존재 확인 후 없으면 생성.
- **DI 패턴**: `main.go`에서 생성자 함수로 Repo → Service → Handler 주입.
- **콜백 연결**: `authSvc.SetWalletCreator()` — 회원가입 시 자동 지갑 생성. `chainSvc.SetUserResolver()` — username → userID 변환.
## Middleware
- **Auth**: `Authorization: Bearer <jwt>` 검증 + Redis 세션 확인. `c.Locals("userID", "username", "role")` 설정.
- **AdminOnly**: `role == "admin"` 확인, 아니면 403.
- **ServerAuth**: `X-API-Key` 헤더 검증. 게임 서버 → API 서버 내부 통신용.
- **Idempotency**: `Idempotency-Key` 헤더로 중복 요청 방지. Redis 캐시 TTL 10분. 블록체인 트랜잭션 이중 지출 방지용.
## JWT 토큰 로테이션
- **Access Token**: `JWT_SECRET`으로 서명, 기본 24시간 만료, Redis `session:{userID}`에 저장.
- **Refresh Token**: `REFRESH_SECRET`으로 서명, 7일 만료, Redis `refresh:{userID}`에 저장.
- **Refresh 시**: 이전 토큰 무효화 + 새 Access/Refresh 쌍 발급 (로테이션).
- **Logout**: Redis에서 session + refresh 키 모두 삭제.
## Rate Limiting
- Auth 엔드포인트: IP당 10 req/min
- 일반 API: IP당 60 req/min
## 블록체인 연동 (internal/chain/)
TOL Chain 노드와 JSON-RPC 2.0 통신.
- **UserWallet 모델**: ed25519 키페어 생성, 개인키는 AES-256-GCM 암호화 후 DB 저장.
- **client.go**: Chain 노드 RPC 호출 (10초 타임아웃).
- **service.go**: 지갑 생성/암호화, 트랜잭션 서명, 마켓/인벤토리 로직.
- **내부 API**: 게임 서버가 username 기반으로 보상 지급 (`/api/internal/chain/*`).
## Routes
**인증**: `POST /api/auth/{register,login,refresh,logout,verify}` (register/login/refresh는 rate limit)
**유저 관리 (admin)**: `GET /api/users/`, `PATCH /api/users/:id/role`, `DELETE /api/users/:id`
**공지사항**: `GET /api/announcements/`, `POST|PUT|DELETE /api/announcements/:id` (CUD는 admin)
**다운로드**: `GET /api/download/{info,file,launcher}`, `POST /api/download/upload/{game,launcher}` (upload는 admin)
**체인 조회 (JWT)**: `GET /api/chain/{wallet,balance,assets,asset/:id,inventory,market,market/:id}`
**체인 트랜잭션 (JWT + Idempotency)**: `POST /api/chain/{transfer,asset/transfer,market/list,market/buy,market/cancel,inventory/equip,inventory/unequip}`
**체인 관리자 (JWT + Admin + Idempotency)**: `POST /api/chain/admin/{mint,reward,template}`
**내부 API (X-API-Key + Idempotency)**: `POST /api/internal/chain/{reward,mint}`, `GET /api/internal/chain/{balance,assets,inventory}` (username 쿼리 파라미터)
## Environment Variables
```
APP_PORT=8080
DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
REDIS_ADDR, REDIS_PASSWORD
JWT_SECRET, REFRESH_SECRET, JWT_EXPIRY_HOURS(기본24)
ADMIN_USERNAME, ADMIN_PASSWORD
BASE_URL=https://a301.api.tolelom.xyz
GAME_DIR=/data/game
CHAIN_NODE_URL=http://localhost:8545
CHAIN_ID=tolchain-dev
OPERATOR_KEY_HEX # 오퍼레이터 개인키 (블록체인 트랜잭션 서명용)
WALLET_ENCRYPTION_KEY # 64자 hex = 32바이트 AES-256 키 (지갑 암호화)
INTERNAL_API_KEY # 게임 서버 인증용 API 키
```
## File Storage
게임 파일은 `GAME_DIR`(`/data/game`)에 저장:
- `/data/game/game.zip` — 게임 본체
- `/data/game/launcher.exe` — 런처
Docker 볼륨 `game_data:/data/game` 마운트로 컨테이너 재시작 후에도 유지.

View File

@@ -1,15 +1,19 @@
# Stage 1: Build
FROM golang:alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
WORKDIR /build
COPY tolchain/ ./tolchain/
COPY a301_server/ ./a301_server/
WORKDIR /build/a301_server
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Stage 2: Run
FROM alpine:latest
RUN apk --no-cache add tzdata ca-certificates
RUN apk --no-cache add tzdata ca-certificates curl
RUN mkdir -p /data/game
WORKDIR /app
COPY --from=builder /app/server .
COPY --from=builder /build/a301_server/server .
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
CMD ["./server"]

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
# One of the plans — Backend Server
Go 기반 REST API 서버. 유저 인증, 공지사항, 게임 파일 배포를 담당합니다.
## 기술 스택
- **Go** + **Fiber v2** — HTTP 프레임워크
- **GORM** + **MySQL** — ORM / 주 데이터베이스
- **Redis** — JWT 블랙리스트 (로그아웃 처리)
- **JWT** (`golang-jwt/jwt v5`) — 인증
## 실행
```bash
# 로컬 (MySQL, Redis 필요)
go run .
# Docker
docker build -t a301-server .
docker run -p 8080:8080 --env-file .env a301-server
```
## 환경 변수
| 변수 | 설명 | 기본값 |
|------|------|--------|
| `APP_PORT` | 서버 포트 | `8080` |
| `DB_HOST` | MySQL 호스트 | `localhost` |
| `DB_PORT` | MySQL 포트 | `3306` |
| `DB_USER` | MySQL 사용자 | `root` |
| `DB_PASSWORD` | MySQL 비밀번호 | `""` |
| `DB_NAME` | 데이터베이스 이름 | `a301` |
| `REDIS_ADDR` | Redis 주소 | `localhost:6379` |
| `REDIS_PASSWORD` | Redis 비밀번호 | `""` |
| `JWT_SECRET` | JWT 서명 키 | `secret` |
| `JWT_EXPIRY_HOURS` | JWT 만료 시간(시) | `24` |
| `ADMIN_USERNAME` | 초기 관리자 계정 | `admin` |
| `ADMIN_PASSWORD` | 초기 관리자 비밀번호 | `admin1234` |
| `BASE_URL` | 외부 접근 URL (파일 URL 생성용) | `http://localhost:8080` |
| `GAME_DIR` | 게임 파일 저장 경로 | `/data/game` |
## API 엔드포인트
### Auth
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/auth/register` | 회원가입 |
| POST | `/api/auth/login` | 로그인 → JWT 반환 |
| POST | `/api/auth/logout` | 로그아웃 (토큰 블랙리스트) |
### Users (관리자 전용)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/users/` | 전체 유저 목록 |
| PATCH | `/api/users/:id/role` | 유저 권한 변경 |
| DELETE | `/api/users/:id` | 유저 삭제 |
### Announcements
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/announcements/` | 공지사항 목록 |
| POST | `/api/announcements/` | 공지 생성 (관리자) |
| PUT | `/api/announcements/:id` | 공지 수정 (관리자) |
| DELETE | `/api/announcements/:id` | 공지 삭제 (관리자) |
### Download
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/download/info` | 게임/런처 메타데이터 (버전, 크기, SHA256) |
| GET | `/api/download/file` | `game.zip` 다운로드 |
| GET | `/api/download/launcher` | `launcher.exe` 다운로드 |
| POST | `/api/download/upload/game` | 게임 zip 업로드 (관리자) |
| POST | `/api/download/upload/launcher` | launcher.exe 업로드 (관리자) |
업로드 엔드포인트는 raw body 스트리밍으로 대용량 파일을 메모리 없이 디스크에 직접 저장합니다.
게임 zip 업로드 시 내부 `A301.exe`의 SHA256 해시를 자동 추출합니다.
## 프로젝트 구조
```
.
├── main.go
├── routes/routes.go
├── internal/
│ ├── auth/ # handler, service, repository, model
│ ├── announcement/
│ └── download/
└── pkg/
├── config/ # 환경 변수 로드
├── database/ # MySQL, Redis 연결
└── middleware/ # JWT 인증, AdminOnly
```
## Docker / 배포
`/data/game` 볼륨에 `game.zip``launcher.exe`가 저장됩니다.
`docker-compose.yml`에 named volume `game_data:/data/game` 마운트 필요.

View File

@@ -1,48 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"a301_server/internal/auth"
"a301_server/pkg/config"
"a301_server/pkg/database"
"golang.org/x/crypto/bcrypt"
)
func main() {
config.Load()
if err := database.ConnectMySQL(); err != nil {
log.Fatalf("MySQL 연결 실패: %v", err)
}
username := getArg(1, "admin")
password := getArg(2, "admin1234")
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("비밀번호 해시 실패: %v", err)
}
user := auth.User{
Username: username,
PasswordHash: string(hash),
Role: auth.RoleAdmin,
}
repo := auth.NewRepository(database.DB)
if err := repo.Create(&user); err != nil {
log.Fatalf("관리자 계정 생성 실패: %v", err)
}
fmt.Printf("관리자 계정 생성 완료\n 아이디: %s\n 비밀번호: %s\n", username, password)
}
func getArg(index int, fallback string) string {
if len(os.Args) > index {
return os.Args[index]
}
return fallback
}

4121
docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

4097
docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

2686
docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

348
docs/swagger_types.go Normal file
View File

@@ -0,0 +1,348 @@
// Package docs contains Swagger DTO types for API documentation.
// These types are only used by swag to generate OpenAPI specs.
package docs
import "time"
// --- Common ---
// ErrorResponse is a standard error response.
type ErrorResponse struct {
Error string `json:"error" example:"오류 메시지"`
}
// MessageResponse is a standard success message response.
type MessageResponse struct {
Message string `json:"message" example:"성공"`
}
// StatusResponse is a simple status response.
type StatusResponse struct {
Status string `json:"status" example:"ok"`
}
// --- Auth ---
type RegisterRequest struct {
Username string `json:"username" example:"player1"`
Password string `json:"password" example:"mypassword"`
}
type LoginRequest struct {
Username string `json:"username" example:"player1"`
Password string `json:"password" example:"mypassword"`
}
type LoginResponse struct {
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
Username string `json:"username" example:"player1"`
Role string `json:"role" example:"user"`
}
type RefreshRequest struct {
RefreshToken string `json:"refreshToken,omitempty"`
}
type RefreshResponse struct {
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
}
type UpdateRoleRequest struct {
Role string `json:"role" example:"admin"`
}
type VerifyTokenRequest struct {
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
}
type VerifyTokenResponse struct {
Username string `json:"username" example:"player1"`
}
type SSAFYLoginURLResponse struct {
URL string `json:"url" example:"https://edu.ssafy.com/oauth/authorize?..."`
}
type SSAFYCallbackRequest struct {
Code string `json:"code" example:"auth_code_123"`
State string `json:"state" example:"random_state_string"`
}
type LaunchTicketResponse struct {
Ticket string `json:"ticket" example:"ticket_abc123"`
}
type RedeemTicketRequest struct {
Ticket string `json:"ticket" example:"ticket_abc123"`
}
type RedeemTicketResponse struct {
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
}
// UserResponse is a user in the admin user list.
type UserResponse struct {
ID uint `json:"id" example:"1"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Username string `json:"username" example:"player1"`
Role string `json:"role" example:"user"`
SsafyID *string `json:"ssafyId,omitempty" example:"ssafy_123"`
}
// --- Announcement ---
type AnnouncementResponse struct {
ID uint `json:"id" example:"1"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Title string `json:"title" example:"서버 점검 안내"`
Content string `json:"content" example:"3월 16일 서버 점검이 예정되어 있습니다."`
}
type CreateAnnouncementRequest struct {
Title string `json:"title" example:"서버 점검 안내"`
Content string `json:"content" example:"3월 16일 서버 점검이 예정되어 있습니다."`
}
type UpdateAnnouncementRequest struct {
Title string `json:"title,omitempty" example:"수정된 제목"`
Content string `json:"content,omitempty" example:"수정된 내용"`
}
// --- Download ---
type DownloadInfoResponse struct {
ID uint `json:"id" example:"1"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
URL string `json:"url" example:"https://a301.api.tolelom.xyz/api/download/file"`
Version string `json:"version" example:"1.0.0"`
FileName string `json:"fileName" example:"A301_v1.0.zip"`
FileSize string `json:"fileSize" example:"1.5 GB"`
FileHash string `json:"fileHash" example:"a1b2c3d4e5f6..."`
LauncherURL string `json:"launcherUrl" example:"https://a301.api.tolelom.xyz/api/download/launcher"`
LauncherSize string `json:"launcherSize" example:"25.3 MB"`
}
// --- Chain ---
type WalletInfoResponse struct {
Address string `json:"address" example:"1a2b3c4d5e6f..."`
PubKeyHex string `json:"pubKeyHex" example:"abcdef012345..."`
}
type TransferRequest struct {
To string `json:"to" example:"1a2b3c4d5e6f..."`
Amount uint64 `json:"amount" example:"100"`
}
type TransferAssetRequest struct {
AssetID string `json:"assetId" example:"asset_001"`
To string `json:"to" example:"1a2b3c4d5e6f..."`
}
type ListOnMarketRequest struct {
AssetID string `json:"assetId" example:"asset_001"`
Price uint64 `json:"price" example:"500"`
}
type BuyFromMarketRequest struct {
ListingID string `json:"listingId" example:"listing_001"`
}
type CancelListingRequest struct {
ListingID string `json:"listingId" example:"listing_001"`
}
type EquipItemRequest struct {
AssetID string `json:"assetId" example:"asset_001"`
Slot string `json:"slot" example:"weapon"`
}
type UnequipItemRequest struct {
AssetID string `json:"assetId" example:"asset_001"`
}
type MintAssetRequest struct {
TemplateID string `json:"templateId" example:"sword_template"`
OwnerPubKey string `json:"ownerPubKey" example:"abcdef012345..."`
Properties map[string]any `json:"properties"`
}
type GrantRewardRequest struct {
RecipientPubKey string `json:"recipientPubKey" example:"abcdef012345..."`
TokenAmount uint64 `json:"tokenAmount" example:"1000"`
Assets []MintAssetPayload `json:"assets"`
}
type RegisterTemplateRequest struct {
ID string `json:"id" example:"sword_template"`
Name string `json:"name" example:"Sword"`
Schema map[string]any `json:"schema"`
Tradeable bool `json:"tradeable" example:"true"`
}
type InternalGrantRewardRequest struct {
Username string `json:"username" example:"player1"`
TokenAmount uint64 `json:"tokenAmount" example:"1000"`
Assets []MintAssetPayload `json:"assets"`
}
type InternalMintAssetRequest struct {
TemplateID string `json:"templateId" example:"sword_template"`
Username string `json:"username" example:"player1"`
Properties map[string]any `json:"properties"`
}
type MintAssetPayload struct {
TemplateID string `json:"template_id" example:"sword_template"`
Owner string `json:"owner" example:"abcdef012345..."`
Properties map[string]any `json:"properties"`
}
// --- Boss Raid ---
type RequestEntryRequest struct {
Usernames []string `json:"usernames" example:"player1,player2"`
BossID int `json:"bossId" example:"1"`
}
type RequestEntryAuthRequest struct {
Usernames []string `json:"usernames,omitempty"`
BossID int `json:"bossId" example:"1"`
}
type RequestEntryResponse struct {
RoomID uint `json:"roomId" example:"1"`
SessionName string `json:"sessionName" example:"Dedi1_Room0"`
BossID int `json:"bossId" example:"1"`
Players []string `json:"players"`
Status string `json:"status" example:"waiting"`
EntryToken string `json:"entryToken,omitempty" example:"token_abc"`
}
type InternalRequestEntryResponse struct {
RoomID uint `json:"roomId" example:"1"`
SessionName string `json:"sessionName" example:"Dedi1_Room0"`
BossID int `json:"bossId" example:"1"`
Players []string `json:"players"`
Status string `json:"status" example:"waiting"`
Tokens map[string]string `json:"tokens"`
}
type SessionNameRequest struct {
SessionName string `json:"sessionName" example:"Dedi1_Room0"`
}
type RoomStatusResponse struct {
RoomID uint `json:"roomId" example:"1"`
SessionName string `json:"sessionName" example:"Dedi1_Room0"`
Status string `json:"status" example:"in_progress"`
}
type PlayerReward struct {
Username string `json:"username" example:"player1"`
TokenAmount uint64 `json:"tokenAmount" example:"100"`
Assets []MintAssetPayload `json:"assets"`
Experience int `json:"experience" example:"500"`
}
type CompleteRaidRequest struct {
SessionName string `json:"sessionName" example:"Dedi1_Room0"`
Rewards []PlayerReward `json:"rewards"`
}
type CompleteRaidResponse struct {
RoomID uint `json:"roomId" example:"1"`
SessionName string `json:"sessionName" example:"Dedi1_Room0"`
Status string `json:"status" example:"completed"`
RewardResults []RewardResult `json:"rewardResults"`
}
type RewardResult struct {
Username string `json:"username" example:"player1"`
Success bool `json:"success" example:"true"`
Error string `json:"error,omitempty"`
}
type ValidateEntryTokenRequest struct {
EntryToken string `json:"entryToken" example:"token_abc"`
}
type ValidateEntryTokenResponse struct {
Valid bool `json:"valid" example:"true"`
Username string `json:"username,omitempty" example:"player1"`
SessionName string `json:"sessionName,omitempty" example:"Dedi1_Room0"`
}
type MyEntryTokenResponse struct {
SessionName string `json:"sessionName" example:"Dedi1_Room0"`
EntryToken string `json:"entryToken" example:"token_abc"`
}
type RegisterServerRequest struct {
ServerName string `json:"serverName" example:"Dedi1"`
InstanceID string `json:"instanceId" example:"container_abc"`
MaxRooms int `json:"maxRooms" example:"10"`
}
type RegisterServerResponse struct {
SessionName string `json:"sessionName" example:"Dedi1_Room0"`
InstanceID string `json:"instanceId" example:"container_abc"`
}
type HeartbeatRequest struct {
InstanceID string `json:"instanceId" example:"container_abc"`
}
type ResetRoomRequest struct {
SessionName string `json:"sessionName" example:"Dedi1_Room0"`
}
type ResetRoomResponse struct {
Status string `json:"status" example:"ok"`
SessionName string `json:"sessionName" example:"Dedi1_Room0"`
}
// --- Player ---
type PlayerProfileResponse struct {
ID uint `json:"id" example:"1"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
UserID uint `json:"userId" example:"1"`
Nickname string `json:"nickname" example:"용사"`
Level int `json:"level" example:"5"`
Experience int `json:"experience" example:"1200"`
NextExp int `json:"nextExp" example:"2000"`
MaxHP float64 `json:"maxHp" example:"150"`
MaxMP float64 `json:"maxMp" example:"75"`
AttackPower float64 `json:"attackPower" example:"25"`
AttackRange float64 `json:"attackRange" example:"3"`
SprintMultiplier float64 `json:"sprintMultiplier" example:"1.8"`
LastPosX float64 `json:"lastPosX" example:"10.5"`
LastPosY float64 `json:"lastPosY" example:"0"`
LastPosZ float64 `json:"lastPosZ" example:"20.3"`
LastRotY float64 `json:"lastRotY" example:"90"`
TotalPlayTime int64 `json:"totalPlayTime" example:"3600"`
}
type UpdateProfileRequest struct {
Nickname string `json:"nickname" example:"용사"`
}
type GameDataRequest struct {
Level *int `json:"level,omitempty" example:"5"`
Experience *int `json:"experience,omitempty" example:"1200"`
MaxHP *float64 `json:"maxHp,omitempty" example:"150"`
MaxMP *float64 `json:"maxMp,omitempty" example:"75"`
AttackPower *float64 `json:"attackPower,omitempty" example:"25"`
AttackRange *float64 `json:"attackRange,omitempty" example:"3"`
SprintMultiplier *float64 `json:"sprintMultiplier,omitempty" example:"1.8"`
LastPosX *float64 `json:"lastPosX,omitempty" example:"10.5"`
LastPosY *float64 `json:"lastPosY,omitempty" example:"0"`
LastPosZ *float64 `json:"lastPosZ,omitempty" example:"20.3"`
LastRotY *float64 `json:"lastRotY,omitempty" example:"90"`
TotalPlayTime *int64 `json:"totalPlayTime,omitempty" example:"3600"`
}

81
go.mod
View File

@@ -1,32 +1,81 @@
module a301_server
go 1.25
go 1.25.0
require (
github.com/gofiber/fiber/v2 v2.52.12
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.18.0
github.com/tolelom/tolchain v0.0.0
golang.org/x/crypto v0.49.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.2.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag v0.25.5 // indirect
github.com/go-openapi/swag/conv v0.25.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
github.com/go-openapi/swag/loading v0.25.5 // indirect
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/gofiber/fiber/v2 v2.52.11 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/gofiber/swagger v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/tinylib/msgp v1.2.5 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
replace github.com/tolelom/tolchain => ../tolchain

142
go.sum
View File

@@ -1,17 +1,66 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28=
github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU=
github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA=
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/swagger v1.1.1 h1:FZVhVQQ9s1ZKLHL/O0loLh49bYB5l1HEAgxDlcTtkRA=
github.com/gofiber/swagger v1.1.1/go.mod h1:vtvY/sQAMc/lGTUCg0lqmBL7Ht9O7uzChpbvJeJQINw=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -20,36 +69,125 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View File

@@ -1,6 +1,14 @@
package announcement
import "github.com/gofiber/fiber/v2"
import (
"log"
"strconv"
"strings"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
type Handler struct {
svc *Service
@@ -10,47 +18,138 @@ func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
// GetAll godoc
// @Summary 공지사항 목록 조회
// @Description 공지사항 목록을 조회합니다
// @Tags Announcements
// @Produce json
// @Param offset query int false "시작 위치" default(0)
// @Param limit query int false "조회 수" default(20)
// @Success 200 {array} docs.AnnouncementResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/announcements/ [get]
func (h *Handler) GetAll(c *fiber.Ctx) error {
list, err := h.svc.GetAll()
offset := c.QueryInt("offset", 0)
limit := c.QueryInt("limit", 20)
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
list, err := h.svc.GetAll(offset, limit)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항을 불러오지 못했습니다"})
return apperror.Internal("공지사항을 불러오지 못했습니다")
}
return c.JSON(list)
}
// Create godoc
// @Summary 공지사항 생성 (관리자)
// @Description 새 공지사항을 생성합니다
// @Tags Announcements
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body docs.CreateAnnouncementRequest true "공지사항 내용"
// @Success 201 {object} docs.AnnouncementResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/announcements/ [post]
func (h *Handler) Create(c *fiber.Ctx) error {
var body struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Content == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "제목과 내용을 입력해주세요"})
return apperror.BadRequest("제목과 내용을 입력해주세요")
}
if len(body.Title) > 256 {
return apperror.BadRequest("제목은 256자 이하여야 합니다")
}
if len(body.Content) > 10000 {
return apperror.BadRequest("내용은 10000자 이하여야 합니다")
}
a, err := h.svc.Create(body.Title, body.Content)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "공지사항 생성에 실패했습니다"})
return apperror.Internal("공지사항 생성에 실패했습니다")
}
return c.Status(fiber.StatusCreated).JSON(a)
}
// Update godoc
// @Summary 공지사항 수정 (관리자)
// @Description 공지사항을 수정합니다
// @Tags Announcements
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "공지사항 ID"
// @Param body body docs.UpdateAnnouncementRequest true "수정할 내용"
// @Success 200 {object} docs.AnnouncementResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/announcements/{id} [put]
func (h *Handler) Update(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return apperror.BadRequest("유효하지 않은 공지사항 ID입니다")
}
var body struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
a, err := h.svc.Update(c.Params("id"), body.Title, body.Content)
if body.Title == "" && body.Content == "" {
return apperror.BadRequest("수정할 내용을 입력해주세요")
}
if len(body.Title) > 256 {
return apperror.BadRequest("제목은 256자 이하여야 합니다")
}
if len(body.Content) > 10000 {
return apperror.BadRequest("내용은 10000자 이하여야 합니다")
}
a, err := h.svc.Update(uint(id), body.Title, body.Content)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
if strings.Contains(err.Error(), "찾을 수 없습니다") {
return apperror.NotFound(err.Error())
}
log.Printf("공지사항 수정 실패 (id=%d): %v", id, err)
return apperror.ErrInternal
}
return c.JSON(a)
}
// Delete godoc
// @Summary 공지사항 삭제 (관리자)
// @Description 공지사항을 삭제합니다
// @Tags Announcements
// @Security BearerAuth
// @Param id path int true "공지사항 ID"
// @Success 204
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/announcements/{id} [delete]
func (h *Handler) Delete(c *fiber.Ctx) error {
if err := h.svc.Delete(c.Params("id")); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "삭제에 실패했습니다"})
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return apperror.BadRequest("유효하지 않은 공지사항 ID입니다")
}
if err := h.svc.Delete(uint(id)); err != nil {
if strings.Contains(err.Error(), "찾을 수 없습니다") {
return apperror.NotFound(err.Error())
}
return apperror.Internal("삭제에 실패했습니다")
}
return c.SendStatus(fiber.StatusNoContent)
}

View File

@@ -1,9 +1,16 @@
package announcement
import "gorm.io/gorm"
import (
"time"
"gorm.io/gorm"
)
type Announcement struct {
gorm.Model
Title string `gorm:"not null"`
Content string `gorm:"type:text;not null"`
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Title string `json:"title" gorm:"not null"`
Content string `json:"content" gorm:"type:text;not null"`
}

View File

@@ -10,16 +10,18 @@ func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) FindAll() ([]Announcement, error) {
func (r *Repository) FindAll(offset, limit int) ([]Announcement, error) {
var list []Announcement
err := r.db.Order("created_at desc").Find(&list).Error
err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&list).Error
return list, err
}
func (r *Repository) FindByID(id string) (*Announcement, error) {
func (r *Repository) FindByID(id uint) (*Announcement, error) {
var a Announcement
err := r.db.First(&a, id).Error
return &a, err
if err := r.db.First(&a, id).Error; err != nil {
return nil, err
}
return &a, nil
}
func (r *Repository) Create(a *Announcement) error {
@@ -30,6 +32,6 @@ func (r *Repository) Save(a *Announcement) error {
return r.db.Save(a).Error
}
func (r *Repository) Delete(id string) error {
func (r *Repository) Delete(id uint) error {
return r.db.Delete(&Announcement{}, id).Error
}

View File

@@ -10,8 +10,8 @@ func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetAll() ([]Announcement, error) {
return s.repo.FindAll()
func (s *Service) GetAll(offset, limit int) ([]Announcement, error) {
return s.repo.FindAll(offset, limit)
}
func (s *Service) Create(title, content string) (*Announcement, error) {
@@ -19,7 +19,7 @@ func (s *Service) Create(title, content string) (*Announcement, error) {
return a, s.repo.Create(a)
}
func (s *Service) Update(id, title, content string) (*Announcement, error) {
func (s *Service) Update(id uint, title, content string) (*Announcement, error) {
a, err := s.repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("공지사항을 찾을 수 없습니다")
@@ -33,6 +33,9 @@ func (s *Service) Update(id, title, content string) (*Announcement, error) {
return a, s.repo.Save(a)
}
func (s *Service) Delete(id string) error {
func (s *Service) Delete(id uint) error {
if _, err := s.repo.FindByID(id); err != nil {
return fmt.Errorf("공지사항을 찾을 수 없습니다")
}
return s.repo.Delete(id)
}

View File

@@ -0,0 +1,309 @@
// NOTE: These tests use a testableService that reimplements service logic
// with mock repositories. This means tests can pass even if the real service
// diverges. For full coverage, consider refactoring services to use repository
// interfaces so the real service can be tested with mock repositories injected.
package announcement
import (
"fmt"
"testing"
"time"
"gorm.io/gorm"
)
// ---------------------------------------------------------------------------
// Mock repository — implements the same methods that Service calls on *Repository.
// We embed it into a real *Repository via a wrapper approach.
// Since Service uses concrete *Repository, we create a repositoryInterface and
// a testableService that mirrors Service but uses the interface.
// ---------------------------------------------------------------------------
type repositoryInterface interface {
FindAll() ([]Announcement, error)
FindByID(id uint) (*Announcement, error)
Create(a *Announcement) error
Save(a *Announcement) error
Delete(id uint) error
}
type testableService struct {
repo repositoryInterface
}
func (s *testableService) GetAll() ([]Announcement, error) {
return s.repo.FindAll()
}
func (s *testableService) Create(title, content string) (*Announcement, error) {
a := &Announcement{Title: title, Content: content}
return a, s.repo.Create(a)
}
func (s *testableService) Update(id uint, title, content string) (*Announcement, error) {
a, err := s.repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("공지사항을 찾을 수 없습니다")
}
if title != "" {
a.Title = title
}
if content != "" {
a.Content = content
}
return a, s.repo.Save(a)
}
func (s *testableService) Delete(id uint) error {
if _, err := s.repo.FindByID(id); err != nil {
return fmt.Errorf("공지사항을 찾을 수 없습니다")
}
return s.repo.Delete(id)
}
// ---------------------------------------------------------------------------
// Mock implementation
// ---------------------------------------------------------------------------
type mockRepo struct {
announcements map[uint]*Announcement
nextID uint
findAllErr error
createErr error
saveErr error
deleteErr error
}
func newMockRepo() *mockRepo {
return &mockRepo{
announcements: make(map[uint]*Announcement),
nextID: 1,
}
}
func (m *mockRepo) FindAll() ([]Announcement, error) {
if m.findAllErr != nil {
return nil, m.findAllErr
}
result := make([]Announcement, 0, len(m.announcements))
for _, a := range m.announcements {
result = append(result, *a)
}
return result, nil
}
func (m *mockRepo) FindByID(id uint) (*Announcement, error) {
a, ok := m.announcements[id]
if !ok {
return nil, gorm.ErrRecordNotFound
}
// Return a copy so mutations don't affect the store until Save is called
cp := *a
return &cp, nil
}
func (m *mockRepo) Create(a *Announcement) error {
if m.createErr != nil {
return m.createErr
}
a.ID = m.nextID
a.CreatedAt = time.Now()
a.UpdatedAt = time.Now()
m.nextID++
stored := *a
m.announcements[a.ID] = &stored
return nil
}
func (m *mockRepo) Save(a *Announcement) error {
if m.saveErr != nil {
return m.saveErr
}
a.UpdatedAt = time.Now()
stored := *a
m.announcements[a.ID] = &stored
return nil
}
func (m *mockRepo) Delete(id uint) error {
if m.deleteErr != nil {
return m.deleteErr
}
delete(m.announcements, id)
return nil
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestGetAll_ReturnsAnnouncements(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
// Empty at first
list, err := svc.GetAll()
if err != nil {
t.Fatalf("GetAll failed: %v", err)
}
if len(list) != 0 {
t.Errorf("expected 0 announcements, got %d", len(list))
}
// Add some
_, _ = svc.Create("Title 1", "Content 1")
_, _ = svc.Create("Title 2", "Content 2")
list, err = svc.GetAll()
if err != nil {
t.Fatalf("GetAll failed: %v", err)
}
if len(list) != 2 {
t.Errorf("expected 2 announcements, got %d", len(list))
}
}
func TestGetAll_ReturnsError(t *testing.T) {
repo := newMockRepo()
repo.findAllErr = fmt.Errorf("db connection error")
svc := &testableService{repo: repo}
_, err := svc.GetAll()
if err == nil {
t.Error("expected error from GetAll, got nil")
}
}
func TestCreate_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
a, err := svc.Create("Test Title", "Test Content")
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if a.Title != "Test Title" {
t.Errorf("Title = %q, want %q", a.Title, "Test Title")
}
if a.Content != "Test Content" {
t.Errorf("Content = %q, want %q", a.Content, "Test Content")
}
if a.ID == 0 {
t.Error("expected non-zero ID after Create")
}
}
func TestCreate_EmptyTitle(t *testing.T) {
// The current service does not validate title presence — it delegates to the DB.
// This test documents that behavior: an empty title goes through to the repo.
repo := newMockRepo()
svc := &testableService{repo: repo}
a, err := svc.Create("", "Some content")
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if a.Title != "" {
t.Errorf("Title = %q, want empty", a.Title)
}
}
func TestCreate_RepoError(t *testing.T) {
repo := newMockRepo()
repo.createErr = fmt.Errorf("insert failed")
svc := &testableService{repo: repo}
_, err := svc.Create("Title", "Content")
if err == nil {
t.Error("expected error when repo returns error, got nil")
}
}
func TestUpdate_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
created, _ := svc.Create("Original Title", "Original Content")
updated, err := svc.Update(created.ID, "New Title", "New Content")
if err != nil {
t.Fatalf("Update failed: %v", err)
}
if updated.Title != "New Title" {
t.Errorf("Title = %q, want %q", updated.Title, "New Title")
}
if updated.Content != "New Content" {
t.Errorf("Content = %q, want %q", updated.Content, "New Content")
}
}
func TestUpdate_PartialUpdate(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
created, _ := svc.Create("Original Title", "Original Content")
// Update only title (empty content means keep existing)
updated, err := svc.Update(created.ID, "New Title", "")
if err != nil {
t.Fatalf("Update failed: %v", err)
}
if updated.Title != "New Title" {
t.Errorf("Title = %q, want %q", updated.Title, "New Title")
}
if updated.Content != "Original Content" {
t.Errorf("Content = %q, want %q (should be unchanged)", updated.Content, "Original Content")
}
}
func TestUpdate_NotFound(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
_, err := svc.Update(999, "Title", "Content")
if err == nil {
t.Error("expected error updating non-existent announcement, got nil")
}
}
func TestDelete_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
created, _ := svc.Create("To Delete", "Content")
err := svc.Delete(created.ID)
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
// Verify it's gone
list, _ := svc.GetAll()
if len(list) != 0 {
t.Errorf("expected 0 announcements after delete, got %d", len(list))
}
}
func TestDelete_NotFound(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
err := svc.Delete(999)
if err == nil {
t.Error("expected error deleting non-existent announcement, got nil")
}
}
func TestDelete_RepoError(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
created, _ := svc.Create("Title", "Content")
repo.deleteErr = fmt.Errorf("delete failed")
err := svc.Delete(created.ID)
if err == nil {
t.Error("expected error when repo delete fails, got nil")
}
}

View File

@@ -1,6 +1,18 @@
package auth
import "github.com/gofiber/fiber/v2"
import (
"log"
"regexp"
"strconv"
"strings"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
// usernameRe allows alphanumeric, underscore, hyphen (3-50 chars).
var usernameRe = regexp.MustCompile(`^[a-z0-9_-]{3,50}$`)
type Handler struct {
svc *Service
@@ -10,32 +22,399 @@ func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
// Register godoc
// @Summary 회원가입
// @Description 새로운 사용자 계정을 생성합니다
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body docs.RegisterRequest true "회원가입 정보"
// @Success 201 {object} docs.MessageResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 409 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/auth/register [post]
func (h *Handler) Register(c *fiber.Ctx) error {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
if req.Username == "" || req.Password == "" {
return apperror.BadRequest("아이디와 비밀번호를 입력해주세요")
}
if !usernameRe.MatchString(req.Username) {
return apperror.BadRequest("아이디는 3~50자의 영문 소문자, 숫자, _, -만 사용 가능합니다")
}
if len(req.Password) < 6 {
return apperror.BadRequest("비밀번호는 6자 이상이어야 합니다")
}
if len(req.Password) > 72 {
return apperror.BadRequest("비밀번호는 72자 이하여야 합니다")
}
if err := h.svc.Register(req.Username, req.Password); err != nil {
if strings.Contains(err.Error(), "이미 사용 중") {
return apperror.Conflict(err.Error())
}
return apperror.Internal("회원가입에 실패했습니다")
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "회원가입이 완료되었습니다"})
}
// Login godoc
// @Summary 로그인
// @Description 사용자 인증 후 JWT 토큰을 발급합니다
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body docs.LoginRequest true "로그인 정보"
// @Success 200 {object} docs.LoginResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/auth/login [post]
func (h *Handler) Login(c *fiber.Ctx) error {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
return apperror.ErrBadRequest
}
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
if req.Username == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
return apperror.BadRequest("아이디와 비밀번호를 입력해주세요")
}
if len(req.Username) > 50 {
return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다")
}
if len(req.Password) > 72 {
return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다")
}
tokenStr, user, err := h.svc.Login(req.Username, req.Password)
accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
log.Printf("Login failed: %v", err)
return apperror.Unauthorized("아이디 또는 비밀번호가 올바르지 않습니다")
}
c.Cookie(&fiber.Cookie{
Name: "refresh_token",
Value: refreshToken,
HTTPOnly: true,
Secure: true,
SameSite: "Strict",
Path: "/api/auth/refresh",
MaxAge: 7 * 24 * 60 * 60, // 7 days
})
return c.JSON(fiber.Map{
"token": tokenStr,
"token": accessToken,
"username": user.Username,
"role": user.Role,
})
}
// Refresh godoc
// @Summary 토큰 갱신
// @Description Refresh 토큰으로 새 Access 토큰을 발급합니다 (쿠키 또는 body)
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body docs.RefreshRequest false "Refresh 토큰 (쿠키 우선)"
// @Success 200 {object} docs.RefreshResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/auth/refresh [post]
func (h *Handler) Refresh(c *fiber.Ctx) error {
refreshTokenStr := c.Cookies("refresh_token")
if refreshTokenStr == "" {
// Fallback to body for backward compatibility
var req struct {
RefreshToken string `json:"refreshToken"`
}
if err := c.BodyParser(&req); err == nil && req.RefreshToken != "" {
refreshTokenStr = req.RefreshToken
}
}
if refreshTokenStr == "" {
return apperror.BadRequest("refreshToken이 필요합니다")
}
newAccessToken, newRefreshToken, err := h.svc.Refresh(refreshTokenStr)
if err != nil {
log.Printf("Refresh failed: %v", err)
return apperror.Unauthorized("토큰 갱신에 실패했습니다")
}
c.Cookie(&fiber.Cookie{
Name: "refresh_token",
Value: newRefreshToken,
HTTPOnly: true,
Secure: true,
SameSite: "Strict",
Path: "/api/auth/refresh",
MaxAge: 7 * 24 * 60 * 60, // 7 days
})
return c.JSON(fiber.Map{
"token": newAccessToken,
})
}
// Logout godoc
// @Summary 로그아웃
// @Description 현재 세션을 무효화합니다
// @Tags Auth
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.MessageResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/auth/logout [post]
func (h *Handler) Logout(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
h.svc.Logout(userID)
userID, ok := c.Locals("userID").(uint)
if !ok {
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
}
if err := h.svc.Logout(userID); err != nil {
log.Printf("Logout failed for user %d: %v", userID, err)
return apperror.Internal("로그아웃 처리 중 오류가 발생했습니다")
}
c.Cookie(&fiber.Cookie{
Name: "refresh_token",
Value: "",
HTTPOnly: true,
Secure: true,
SameSite: "Strict",
Path: "/api/auth/refresh",
MaxAge: -1, // delete
})
return c.JSON(fiber.Map{"message": "로그아웃 되었습니다"})
}
// GetAllUsers godoc
// @Summary 전체 유저 목록 (관리자)
// @Description 모든 유저 목록을 조회합니다
// @Tags Users
// @Produce json
// @Security BearerAuth
// @Param offset query int false "시작 위치" default(0)
// @Param limit query int false "조회 수" default(50)
// @Success 200 {array} docs.UserResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/users/ [get]
func (h *Handler) GetAllUsers(c *fiber.Ctx) error {
offset := c.QueryInt("offset", 0)
limit := c.QueryInt("limit", 50)
if limit <= 0 || limit > 100 {
limit = 50
}
if offset < 0 {
offset = 0
}
users, err := h.svc.GetAllUsers(offset, limit)
if err != nil {
return apperror.Internal("유저 목록을 불러오지 못했습니다")
}
return c.JSON(users)
}
// UpdateRole godoc
// @Summary 유저 권한 변경 (관리자)
// @Description 유저의 역할을 admin 또는 user로 변경합니다
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "유저 ID"
// @Param body body docs.UpdateRoleRequest true "변경할 역할"
// @Success 200 {object} docs.MessageResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/users/{id}/role [patch]
func (h *Handler) UpdateRole(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return apperror.BadRequest("유효하지 않은 유저 ID입니다")
}
var body struct {
Role string `json:"role"`
}
if err := c.BodyParser(&body); err != nil || (body.Role != "admin" && body.Role != "user") {
return apperror.BadRequest("role은 admin 또는 user여야 합니다")
}
uid := uint(id)
if err := h.svc.UpdateRole(uid, Role(body.Role)); err != nil {
return apperror.Internal("권한 변경에 실패했습니다")
}
// 역할 변경 시 기존 세션 무효화 (새 권한으로 재로그인 유도)
_ = h.svc.Logout(uid)
return c.JSON(fiber.Map{"message": "권한이 변경되었습니다"})
}
// VerifyToken godoc
// @Summary 토큰 검증 (내부 API)
// @Description JWT 토큰을 검증하고 username을 반환합니다
// @Tags Internal - Auth
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.VerifyTokenRequest true "검증할 토큰"
// @Success 200 {object} docs.VerifyTokenResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/internal/auth/verify [post]
func (h *Handler) VerifyToken(c *fiber.Ctx) error {
var req struct {
Token string `json:"token"`
}
if err := c.BodyParser(&req); err != nil || req.Token == "" {
return apperror.BadRequest("token 필드가 필요합니다")
}
username, err := h.svc.VerifyToken(req.Token)
if err != nil {
return apperror.Unauthorized(err.Error())
}
return c.JSON(fiber.Map{
"username": username,
})
}
// SSAFYLoginURL godoc
// @Summary SSAFY 로그인 URL
// @Description SSAFY OAuth 로그인 URL을 생성합니다
// @Tags Auth
// @Produce json
// @Success 200 {object} docs.SSAFYLoginURLResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/auth/ssafy/login [get]
func (h *Handler) SSAFYLoginURL(c *fiber.Ctx) error {
loginURL, err := h.svc.GetSSAFYLoginURL()
if err != nil {
return apperror.Internal("SSAFY 로그인 URL 생성에 실패했습니다")
}
return c.JSON(fiber.Map{"url": loginURL})
}
// SSAFYCallback godoc
// @Summary SSAFY OAuth 콜백
// @Description SSAFY 인가 코드를 교환하여 로그인합니다
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body docs.SSAFYCallbackRequest true "인가 코드"
// @Success 200 {object} docs.LoginResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/auth/ssafy/callback [post]
func (h *Handler) SSAFYCallback(c *fiber.Ctx) error {
var req struct {
Code string `json:"code"`
State string `json:"state"`
}
if err := c.BodyParser(&req); err != nil || req.Code == "" {
return apperror.BadRequest("인가 코드가 필요합니다")
}
if req.State == "" {
return apperror.BadRequest("state 파라미터가 필요합니다")
}
accessToken, refreshToken, user, err := h.svc.SSAFYLogin(req.Code, req.State)
if err != nil {
log.Printf("SSAFY login failed: %v", err)
return apperror.Unauthorized("SSAFY 로그인에 실패했습니다")
}
c.Cookie(&fiber.Cookie{
Name: "refresh_token",
Value: refreshToken,
HTTPOnly: true,
Secure: true,
SameSite: "Strict",
Path: "/api/auth/refresh",
MaxAge: 7 * 24 * 60 * 60, // 7 days
})
return c.JSON(fiber.Map{
"token": accessToken,
"username": user.Username,
"role": user.Role,
})
}
// CreateLaunchTicket godoc
// @Summary 런처 티켓 발급
// @Description 게임 런처용 일회성 티켓을 발급합니다
// @Tags Auth
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.LaunchTicketResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/auth/launch-ticket [post]
func (h *Handler) CreateLaunchTicket(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
}
ticket, err := h.svc.CreateLaunchTicket(userID)
if err != nil {
return apperror.Internal("티켓 발급에 실패했습니다")
}
return c.JSON(fiber.Map{"ticket": ticket})
}
// RedeemLaunchTicket godoc
// @Summary 런처 티켓 교환
// @Description 일회성 티켓을 Access 토큰으로 교환합니다
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body docs.RedeemTicketRequest true "티켓"
// @Success 200 {object} docs.RedeemTicketResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/auth/redeem-ticket [post]
func (h *Handler) RedeemLaunchTicket(c *fiber.Ctx) error {
var req struct {
Ticket string `json:"ticket"`
}
if err := c.BodyParser(&req); err != nil || req.Ticket == "" {
return apperror.BadRequest("ticket 필드가 필요합니다")
}
token, err := h.svc.RedeemLaunchTicket(req.Ticket)
if err != nil {
log.Printf("RedeemLaunchTicket failed: %v", err)
return apperror.Unauthorized("유효하지 않거나 만료된 티켓입니다")
}
return c.JSON(fiber.Map{"token": token})
}
// DeleteUser godoc
// @Summary 유저 삭제 (관리자)
// @Description 유저를 삭제합니다
// @Tags Users
// @Security BearerAuth
// @Param id path int true "유저 ID"
// @Success 204
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/users/{id} [delete]
func (h *Handler) DeleteUser(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return apperror.BadRequest("유효하지 않은 유저 ID입니다")
}
if err := h.svc.DeleteUser(uint(id)); err != nil {
return apperror.Internal("유저 삭제에 실패했습니다")
}
return c.SendStatus(fiber.StatusNoContent)
}

View File

@@ -1,6 +1,10 @@
package auth
import "gorm.io/gorm"
import (
"time"
"gorm.io/gorm"
)
type Role string
@@ -10,8 +14,28 @@ const (
)
type User struct {
gorm.Model
Username string `gorm:"type:varchar(100);uniqueIndex;not null"`
PasswordHash string `gorm:"not null"`
Role Role `gorm:"default:'user'"`
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Username string `json:"username" gorm:"type:varchar(100);uniqueIndex;not null"`
PasswordHash string `json:"-" gorm:"not null"`
Role Role `json:"role" gorm:"type:varchar(20);index;default:'user'"`
SsafyID *string `json:"ssafyId,omitempty" gorm:"type:varchar(100);uniqueIndex"`
}
// SSAFY OAuth 응답 구조체
type SSAFYTokenResponse struct {
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
RefreshTokenExpiresIn int `json:"refresh_token_expires_in"`
}
type SSAFYUserInfo struct {
UserID string `json:"userId"`
Email string `json:"email"`
Name string `json:"name"`
}

View File

@@ -12,10 +12,49 @@ func NewRepository(db *gorm.DB) *Repository {
func (r *Repository) FindByUsername(username string) (*User, error) {
var user User
err := r.db.Where("username = ?", username).First(&user).Error
return &user, err
if err := r.db.Where("username = ?", username).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *Repository) Create(user *User) error {
return r.db.Create(user).Error
}
func (r *Repository) FindAll(offset, limit int) ([]User, error) {
var users []User
err := r.db.Order("created_at asc").Offset(offset).Limit(limit).Find(&users).Error
return users, err
}
func (r *Repository) FindByID(id uint) (*User, error) {
var user User
if err := r.db.First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *Repository) UpdateRole(id uint, role Role) error {
return r.db.Model(&User{}).Where("id = ?", id).Update("role", role).Error
}
func (r *Repository) Delete(id uint) error {
return r.db.Delete(&User{}, id).Error
}
// Transaction wraps a function in a database transaction.
func (r *Repository) Transaction(fn func(txRepo *Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(&Repository{db: tx})
})
}
func (r *Repository) FindBySsafyID(ssafyID string) (*User, error) {
var user User
if err := r.db.Where("ssafy_id = ?", ssafyID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}

View File

@@ -2,15 +2,29 @@ package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"gorm.io/gorm"
"a301_server/pkg/config"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
const refreshTokenExpiry = 7 * 24 * time.Hour
var ssafyHTTPClient = &http.Client{Timeout: 10 * time.Second}
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
@@ -19,24 +33,51 @@ type Claims struct {
}
type Service struct {
repo *Repository
rdb *redis.Client
repo *Repository
rdb *redis.Client
walletCreator func(userID uint) error
profileCreator func(userID uint) error
}
func NewService(repo *Repository, rdb *redis.Client) *Service {
return &Service{repo: repo, rdb: rdb}
}
func (s *Service) Login(username, password string) (string, *User, error) {
user, err := s.repo.FindByUsername(username)
func (s *Service) SetWalletCreator(fn func(userID uint) error) {
s.walletCreator = fn
}
func (s *Service) SetProfileCreator(fn func(userID uint) error) {
s.profileCreator = fn
}
func (s *Service) Login(username, password string) (accessToken, refreshToken string, user *User, err error) {
user, err = s.repo.FindByUsername(username)
if err != nil {
return "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
if err == gorm.ErrRecordNotFound {
return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
}
return "", "", nil, fmt.Errorf("로그인 처리 중 오류가 발생했습니다")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
}
accessToken, err = s.issueAccessToken(user)
if err != nil {
return "", "", nil, err
}
refreshToken, err = s.issueRefreshToken(user)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
}
func (s *Service) issueAccessToken(user *User) (string, error) {
expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour
claims := &Claims{
UserID: user.ID,
@@ -50,17 +91,463 @@ func (s *Service) Login(username, password string) (string, *User, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
if err != nil {
return "", nil, fmt.Errorf("토큰 생성에 실패했습니다")
return "", fmt.Errorf("토큰 생성에 실패했습니다")
}
// Redis에 세션 저장 (1계정 1세션)
key := fmt.Sprintf("session:%d", user.ID)
s.rdb.Set(context.Background(), key, tokenStr, expiry)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.rdb.Set(ctx, key, tokenStr, expiry).Err(); err != nil {
return "", fmt.Errorf("세션 저장에 실패했습니다")
}
return tokenStr, user, nil
return tokenStr, nil
}
func (s *Service) Logout(userID uint) {
key := fmt.Sprintf("session:%d", userID)
s.rdb.Del(context.Background(), key)
func (s *Service) issueRefreshToken(user *User) (string, error) {
claims := &Claims{
UserID: user.ID,
Username: user.Username,
Role: string(user.Role),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenExpiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString([]byte(config.C.RefreshSecret))
if err != nil {
return "", fmt.Errorf("리프레시 토큰 생성에 실패했습니다")
}
key := fmt.Sprintf("refresh:%d", user.ID)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.rdb.Set(ctx, key, tokenStr, refreshTokenExpiry).Err(); err != nil {
return "", fmt.Errorf("리프레시 토큰 저장에 실패했습니다")
}
return tokenStr, nil
}
// Refresh validates a refresh token and issues a new access + refresh token pair (rotation).
func (s *Service) Refresh(refreshTokenStr string) (newAccessToken, newRefreshToken string, err error) {
token, err := jwt.ParseWithClaims(refreshTokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(config.C.RefreshSecret), nil
})
if err != nil || !token.Valid {
return "", "", fmt.Errorf("유효하지 않은 리프레시 토큰입니다")
}
claims, ok := token.Claims.(*Claims)
if !ok {
return "", "", fmt.Errorf("토큰 파싱 실패")
}
// Redis에서 저장된 리프레시 토큰과 비교
key := fmt.Sprintf("refresh:%d", claims.UserID)
refreshCtx, refreshCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer refreshCancel()
stored, err := s.rdb.Get(refreshCtx, key).Result()
if err != nil || stored != refreshTokenStr {
return "", "", fmt.Errorf("만료되었거나 유효하지 않은 리프레시 토큰입니다")
}
// Look up the current user from DB to avoid using stale role from JWT claims
user, dbErr := s.repo.FindByID(claims.UserID)
if dbErr != nil {
return "", "", fmt.Errorf("유저를 찾을 수 없습니다")
}
newAccessToken, err = s.issueAccessToken(user)
if err != nil {
return "", "", err
}
// 리프레시 토큰 로테이션
newRefreshToken, err = s.issueRefreshToken(user)
if err != nil {
return "", "", err
}
return newAccessToken, newRefreshToken, nil
}
func (s *Service) Logout(userID uint) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sessionKey := fmt.Sprintf("session:%d", userID)
refreshKey := fmt.Sprintf("refresh:%d", userID)
return s.rdb.Del(ctx, sessionKey, refreshKey).Err()
}
func (s *Service) GetAllUsers(offset, limit int) ([]User, error) {
return s.repo.FindAll(offset, limit)
}
func (s *Service) UpdateRole(id uint, role Role) error {
return s.repo.UpdateRole(id, role)
}
func (s *Service) DeleteUser(id uint) error {
if err := s.repo.Delete(id); err != nil {
return err
}
// Clean up Redis sessions for deleted user
delCtx, delCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer delCancel()
sessionKey := fmt.Sprintf("session:%d", id)
refreshKey := fmt.Sprintf("refresh:%d", id)
if err := s.rdb.Del(delCtx, sessionKey, refreshKey).Err(); err != nil {
log.Printf("WARNING: failed to delete Redis sessions for user %d: %v", id, err)
}
// TODO: Clean up wallet and profile data via cross-service calls
// (walletCreator/profileCreator are creation-only; deletion callbacks are not yet wired up)
return nil
}
// CreateLaunchTicket generates a one-time ticket that the game launcher
// exchanges for the real JWT. The ticket expires in 30 seconds and can only
// be redeemed once, preventing token exposure in URLs or browser history.
func (s *Service) CreateLaunchTicket(userID uint) (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate ticket: %w", err)
}
ticket := hex.EncodeToString(buf)
// Store ticket → userID mapping in Redis with 30s TTL
key := fmt.Sprintf("launch_ticket:%s", ticket)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.rdb.Set(ctx, key, userID, 30*time.Second).Err(); err != nil {
return "", fmt.Errorf("store ticket: %w", err)
}
return ticket, nil
}
// RedeemLaunchTicket exchanges a one-time ticket for the user's access token.
// The ticket is deleted immediately after use (one-time).
func (s *Service) RedeemLaunchTicket(ticket string) (string, error) {
key := fmt.Sprintf("launch_ticket:%s", ticket)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Atomically get and delete (one-time use)
userIDStr, err := s.rdb.GetDel(ctx, key).Result()
if err != nil {
return "", fmt.Errorf("유효하지 않거나 만료된 티켓입니다")
}
var userID uint
if _, err := fmt.Sscanf(userIDStr, "%d", &userID); err != nil {
return "", fmt.Errorf("invalid ticket data")
}
user, err := s.repo.FindByID(userID)
if err != nil {
return "", fmt.Errorf("유저를 찾을 수 없습니다")
}
accessToken, err := s.issueAccessToken(user)
if err != nil {
return "", err
}
return accessToken, nil
}
func (s *Service) Register(username, password string) error {
if _, err := s.repo.FindByUsername(username); err == nil {
return fmt.Errorf("이미 사용 중인 아이디입니다")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("비밀번호 처리에 실패했습니다")
}
return s.repo.Transaction(func(txRepo *Repository) error {
user := &User{Username: username, PasswordHash: string(hash), Role: RoleUser}
if err := txRepo.Create(user); err != nil {
return err
}
if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != nil {
return fmt.Errorf("wallet creation failed: %w", err)
}
}
if s.profileCreator != nil {
if err := s.profileCreator(user.ID); err != nil {
return fmt.Errorf("profile creation failed: %w", err)
}
}
return nil
})
}
// GetSSAFYLoginURL returns the SSAFY OAuth authorization URL with a random
// state parameter for CSRF protection. The state is stored in Redis with a
// 5-minute TTL and must be verified in the callback.
func (s *Service) GetSSAFYLoginURL() (string, error) {
stateBytes := make([]byte, 16)
if _, err := rand.Read(stateBytes); err != nil {
return "", fmt.Errorf("state 생성 실패: %w", err)
}
state := hex.EncodeToString(stateBytes)
// Store state in Redis with 5-minute TTL for one-time verification
key := fmt.Sprintf("ssafy_state:%s", state)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.rdb.Set(ctx, key, "1", 5*time.Minute).Err(); err != nil {
return "", fmt.Errorf("state 저장 실패: %w", err)
}
params := url.Values{
"client_id": {config.C.SSAFYClientID},
"redirect_uri": {config.C.SSAFYRedirectURI},
"response_type": {"code"},
"state": {state},
}
return "https://project.ssafy.com/oauth/sso-check?" + params.Encode(), nil
}
// ExchangeSSAFYCode exchanges an authorization code for SSAFY tokens.
func (s *Service) ExchangeSSAFYCode(code string) (*SSAFYTokenResponse, error) {
data := url.Values{
"grant_type": {"authorization_code"},
"client_id": {config.C.SSAFYClientID},
"client_secret": {config.C.SSAFYClientSecret},
"redirect_uri": {config.C.SSAFYRedirectURI},
"code": {code},
}
resp, err := ssafyHTTPClient.Post(
"https://project.ssafy.com/ssafy/oauth2/token",
"application/x-www-form-urlencoded;charset=utf-8",
strings.NewReader(data.Encode()),
)
if err != nil {
return nil, fmt.Errorf("SSAFY 토큰 요청 실패: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
if err != nil {
return nil, fmt.Errorf("SSAFY 토큰 응답 읽기 실패: %v", err)
}
if resp.StatusCode != http.StatusOK {
log.Printf("SSAFY 토큰 발급 실패 (status %d): %s", resp.StatusCode, string(body))
return nil, fmt.Errorf("SSAFY 인증에 실패했습니다")
}
var tokenResp SSAFYTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("SSAFY 토큰 응답 파싱 실패: %v", err)
}
return &tokenResp, nil
}
// GetSSAFYUserInfo fetches user info from SSAFY using an access token.
func (s *Service) GetSSAFYUserInfo(accessToken string) (*SSAFYUserInfo, error) {
req, err := http.NewRequest("GET", "https://project.ssafy.com/ssafy/resources/userInfo", nil)
if err != nil {
return nil, fmt.Errorf("SSAFY 사용자 정보 요청 생성 실패: %v", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-type", "application/x-www-form-urlencoded;charset=utf-8")
resp, err := ssafyHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("SSAFY 사용자 정보 요청 실패: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
if err != nil {
return nil, fmt.Errorf("SSAFY 사용자 정보 응답 읽기 실패: %v", err)
}
if resp.StatusCode != http.StatusOK {
log.Printf("SSAFY 사용자 정보 조회 실패 (status %d): %s", resp.StatusCode, string(body))
return nil, fmt.Errorf("SSAFY 사용자 정보를 가져올 수 없습니다")
}
var userInfo SSAFYUserInfo
if err := json.Unmarshal(body, &userInfo); err != nil {
return nil, fmt.Errorf("SSAFY 사용자 정보 파싱 실패: %v", err)
}
return &userInfo, nil
}
// SSAFYLogin handles the full SSAFY OAuth callback: exchange code, get user info, find or create user, issue tokens.
// The state parameter is verified against Redis (one-time use via GetDel) for CSRF protection.
func (s *Service) SSAFYLogin(code, state string) (accessToken, refreshToken string, user *User, err error) {
// Verify CSRF state parameter (one-time use)
if state == "" {
return "", "", nil, fmt.Errorf("state 파라미터가 필요합니다")
}
stateKey := fmt.Sprintf("ssafy_state:%s", state)
stateCtx, stateCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer stateCancel()
val, err := s.rdb.GetDel(stateCtx, stateKey).Result()
if err != nil || val != "1" {
return "", "", nil, fmt.Errorf("유효하지 않거나 만료된 state 파라미터입니다")
}
tokenResp, err := s.ExchangeSSAFYCode(code)
if err != nil {
return "", "", nil, err
}
userInfo, err := s.GetSSAFYUserInfo(tokenResp.AccessToken)
if err != nil {
return "", "", nil, err
}
// SSAFY ID로 기존 사용자 조회
user, err = s.repo.FindBySsafyID(userInfo.UserID)
if err != nil {
// 신규 사용자 자동 가입
randomBytes := make([]byte, 16)
if _, err := rand.Read(randomBytes); err != nil {
return "", "", nil, fmt.Errorf("보안 난수 생성 실패: %v", err)
}
randomPassword := hex.EncodeToString(randomBytes)
hash, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost)
if err != nil {
return "", "", nil, fmt.Errorf("계정 생성 실패")
}
ssafyID := userInfo.UserID
// SSAFY ID에서 영문 소문자+숫자만 추출하여 안전한 username 생성
// NOTE: Username collision is handled by the DB unique constraint.
// If collision occurs, the transaction will rollback and return a generic error.
// A retry with random suffix could improve UX but is not critical.
safeID := sanitizeForUsername(ssafyID)
if safeID == "" {
safeID = hex.EncodeToString(randomBytes[:8])
}
username := "ssafy_" + safeID
if len(username) > 50 {
username = username[:50]
}
err = s.repo.Transaction(func(txRepo *Repository) error {
user = &User{
Username: username,
PasswordHash: string(hash),
Role: RoleUser,
SsafyID: &ssafyID,
}
if err := txRepo.Create(user); err != nil {
return err
}
if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != nil {
return fmt.Errorf("wallet creation failed: %w", err)
}
}
if s.profileCreator != nil {
if err := s.profileCreator(user.ID); err != nil {
return fmt.Errorf("profile creation failed: %w", err)
}
}
return nil
})
if err != nil {
log.Printf("SSAFY user creation transaction failed: %v", err)
return "", "", nil, fmt.Errorf("계정 생성 실패: %v", err)
}
}
accessToken, err = s.issueAccessToken(user)
if err != nil {
return "", "", nil, err
}
refreshToken, err = s.issueRefreshToken(user)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
}
// VerifyToken validates a JWT and its Redis session, returning (username, error).
func (s *Service) VerifyToken(tokenStr string) (string, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(config.C.JWTSecret), nil
})
if err != nil || !token.Valid {
return "", fmt.Errorf("유효하지 않은 토큰입니다")
}
claims, ok := token.Claims.(*Claims)
if !ok {
return "", fmt.Errorf("토큰 파싱 실패")
}
key := fmt.Sprintf("session:%d", claims.UserID)
verifyCtx, verifyCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer verifyCancel()
stored, err := s.rdb.Get(verifyCtx, key).Result()
if err != nil || stored != tokenStr {
return "", fmt.Errorf("만료되었거나 로그아웃된 세션입니다")
}
return claims.Username, nil
}
// sanitizeForUsername strips characters that are not [a-z0-9_-].
func sanitizeForUsername(s string) string {
var b strings.Builder
for _, c := range strings.ToLower(s) {
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
b.WriteRune(c)
}
}
return b.String()
}
// NOTE: EnsureAdmin does not use a transaction for wallet/profile creation.
// If these fail, the admin user exists without a wallet/profile.
// This is acceptable because EnsureAdmin runs once at startup and failures
// are logged as warnings. A restart will skip user creation (already exists).
func (s *Service) EnsureAdmin(username, password string) error {
if _, err := s.repo.FindByUsername(username); err == nil {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := &User{
Username: username,
PasswordHash: string(hash),
Role: RoleAdmin,
}
if err := s.repo.Create(user); err != nil {
return err
}
if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != nil {
log.Printf("WARNING: admin wallet creation failed for user %d: %v", user.ID, err)
}
}
if s.profileCreator != nil {
if err := s.profileCreator(user.ID); err != nil {
log.Printf("WARNING: admin profile creation failed for user %d: %v", user.ID, err)
}
}
return nil
}

View File

@@ -0,0 +1,291 @@
package auth
import (
"testing"
"time"
"a301_server/pkg/config"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// ---------------------------------------------------------------------------
// 1. Password hashing (bcrypt)
// ---------------------------------------------------------------------------
func TestBcryptHashAndVerify(t *testing.T) {
tests := []struct {
name string
password string
wantMatch bool
}{
{"short password", "abc", true},
{"normal password", "myP@ssw0rd!", true},
{"unicode password", "비밀번호123", true},
{"empty password", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte(tc.password), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("GenerateFromPassword failed: %v", err)
}
err = bcrypt.CompareHashAndPassword(hash, []byte(tc.password))
if (err == nil) != tc.wantMatch {
t.Errorf("CompareHashAndPassword: got err=%v, wantMatch=%v", err, tc.wantMatch)
}
})
}
}
func TestBcryptWrongPassword(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("GenerateFromPassword failed: %v", err)
}
if err := bcrypt.CompareHashAndPassword(hash, []byte("wrong")); err == nil {
t.Error("expected error comparing wrong password, got nil")
}
}
func TestBcryptDifferentHashesForSamePassword(t *testing.T) {
password := "samePassword"
hash1, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
hash2, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if string(hash1) == string(hash2) {
t.Error("expected different hashes for the same password (different salts)")
}
}
// ---------------------------------------------------------------------------
// 2. JWT token generation and parsing
// ---------------------------------------------------------------------------
func setupTestConfig() {
config.C = config.Config{
JWTSecret: "test-jwt-secret-key-for-unit-tests",
RefreshSecret: "test-refresh-secret-key-for-unit-tests",
JWTExpiryHours: 1,
}
}
func TestIssueAndParseAccessToken(t *testing.T) {
setupTestConfig()
tests := []struct {
name string
userID uint
username string
role string
}{
{"admin user", 1, "admin", "admin"},
{"regular user", 42, "player1", "user"},
{"unicode username", 100, "유저", "user"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour
claims := &Claims{
UserID: tc.userID,
Username: tc.username,
Role: tc.role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
if err != nil {
t.Fatalf("SignedString failed: %v", err)
}
parsed, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
return []byte(config.C.JWTSecret), nil
})
if err != nil {
t.Fatalf("ParseWithClaims failed: %v", err)
}
if !parsed.Valid {
t.Fatal("parsed token is not valid")
}
got, ok := parsed.Claims.(*Claims)
if !ok {
t.Fatal("failed to cast claims")
}
if got.UserID != tc.userID {
t.Errorf("UserID = %d, want %d", got.UserID, tc.userID)
}
if got.Username != tc.username {
t.Errorf("Username = %q, want %q", got.Username, tc.username)
}
if got.Role != tc.role {
t.Errorf("Role = %q, want %q", got.Role, tc.role)
}
})
}
}
func TestParseTokenWithWrongSecret(t *testing.T) {
setupTestConfig()
claims := &Claims{
UserID: 1,
Username: "test",
Role: "user",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString([]byte(config.C.JWTSecret))
_, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
return []byte("wrong-secret"), nil
})
if err == nil {
t.Error("expected error parsing token with wrong secret, got nil")
}
}
func TestParseExpiredToken(t *testing.T) {
setupTestConfig()
claims := &Claims{
UserID: 1,
Username: "test",
Role: "user",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString([]byte(config.C.JWTSecret))
_, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
return []byte(config.C.JWTSecret), nil
})
if err == nil {
t.Error("expected error parsing expired token, got nil")
}
}
func TestRefreshTokenUsesDifferentSecret(t *testing.T) {
setupTestConfig()
claims := &Claims{
UserID: 1,
Username: "test",
Role: "user",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenExpiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
// Sign with refresh secret
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString([]byte(config.C.RefreshSecret))
// Should fail with JWT secret
_, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
return []byte(config.C.JWTSecret), nil
})
if err == nil {
t.Error("expected error parsing refresh token with access secret")
}
// Should succeed with refresh secret
parsed, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
return []byte(config.C.RefreshSecret), nil
})
if err != nil {
t.Fatalf("expected success with refresh secret, got: %v", err)
}
if !parsed.Valid {
t.Error("parsed refresh token is not valid")
}
}
// ---------------------------------------------------------------------------
// 3. Input validation helpers (sanitizeForUsername)
// ---------------------------------------------------------------------------
func TestSanitizeForUsername(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"lowercase letters", "hello", "hello"},
{"uppercase converted", "HeLLo", "hello"},
{"digits kept", "user123", "user123"},
{"underscore kept", "user_name", "user_name"},
{"hyphen kept", "user-name", "user-name"},
{"special chars removed", "user@name!#$", "username"},
{"spaces removed", "user name", "username"},
{"unicode removed", "유저abc", "abc"},
{"mixed", "User-123_Test!", "user-123_test"},
{"empty input", "", ""},
{"all removed", "!!@@##", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeForUsername(tc.input)
if got != tc.want {
t.Errorf("sanitizeForUsername(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
// ---------------------------------------------------------------------------
// 4. Claims struct fields
// ---------------------------------------------------------------------------
func TestClaimsRoundTrip(t *testing.T) {
setupTestConfig()
original := &Claims{
UserID: 999,
Username: "testuser",
Role: "admin",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, original)
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
if err != nil {
t.Fatalf("signing failed: %v", err)
}
parsed, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
return []byte(config.C.JWTSecret), nil
})
if err != nil {
t.Fatalf("parsing failed: %v", err)
}
got := parsed.Claims.(*Claims)
if got.UserID != original.UserID {
t.Errorf("UserID: got %d, want %d", got.UserID, original.UserID)
}
if got.Username != original.Username {
t.Errorf("Username: got %q, want %q", got.Username, original.Username)
}
if got.Role != original.Role {
t.Errorf("Role: got %q, want %q", got.Role, original.Role)
}
}

View File

@@ -0,0 +1,364 @@
package bossraid
import (
"log"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func bossError(status int, userMsg string, err error) *apperror.AppError {
log.Printf("bossraid error: %s: %v", userMsg, err)
code := "internal_error"
switch status {
case 400:
code = "bad_request"
case 404:
code = "not_found"
case 409:
code = "conflict"
}
return apperror.New(code, userMsg, status)
}
// RequestEntry godoc
// @Summary 보스 레이드 입장 요청 (내부 API)
// @Description MMO 서버에서 파티의 보스 레이드 입장을 요청합니다. 모든 플레이어의 entry token을 반환합니다.
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.RequestEntryRequest true "입장 정보"
// @Success 201 {object} docs.InternalRequestEntryResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 409 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/entry [post]
func (h *Handler) RequestEntry(c *fiber.Ctx) error {
var req struct {
Usernames []string `json:"usernames"`
BossID int `json:"bossId"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if len(req.Usernames) == 0 || req.BossID <= 0 {
return apperror.BadRequest("usernames와 bossId는 필수입니다")
}
for _, u := range req.Usernames {
if len(u) == 0 || len(u) > 50 {
return apperror.BadRequest("유효하지 않은 username입니다")
}
}
room, tokens, err := h.svc.RequestEntryWithTokens(req.Usernames, req.BossID)
if err != nil {
return bossError(fiber.StatusConflict, "보스 레이드 입장에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"roomId": room.ID,
"sessionName": room.SessionName,
"bossId": room.BossID,
"players": req.Usernames,
"status": room.Status,
"tokens": tokens,
})
}
// StartRaid godoc
// @Summary 레이드 시작 (내부 API)
// @Description Fusion 세션이 시작될 때 데디케이티드 서버에서 호출합니다
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.SessionNameRequest true "세션 정보"
// @Success 200 {object} docs.RoomStatusResponse
// @Failure 400 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/start [post]
func (h *Handler) StartRaid(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if req.SessionName == "" {
return apperror.BadRequest("sessionName은 필수입니다")
}
room, err := h.svc.StartRaid(req.SessionName)
if err != nil {
return bossError(fiber.StatusBadRequest, "레이드 시작에 실패했습니다", err)
}
return c.JSON(fiber.Map{
"roomId": room.ID,
"sessionName": room.SessionName,
"status": room.Status,
})
}
// CompleteRaid godoc
// @Summary 레이드 완료 (내부 API)
// @Description 보스 처치 시 데디케이티드 서버에서 호출합니다. 보상을 분배합니다.
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.CompleteRaidRequest true "완료 정보 및 보상"
// @Success 200 {object} docs.CompleteRaidResponse
// @Failure 400 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/complete [post]
func (h *Handler) CompleteRaid(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
Rewards []PlayerReward `json:"rewards"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if req.SessionName == "" {
return apperror.BadRequest("sessionName은 필수입니다")
}
room, results, err := h.svc.CompleteRaid(req.SessionName, req.Rewards)
if err != nil {
return bossError(fiber.StatusBadRequest, "레이드 완료 처리에 실패했습니다", err)
}
return c.JSON(fiber.Map{
"roomId": room.ID,
"sessionName": room.SessionName,
"status": room.Status,
"rewardResults": results,
})
}
// FailRaid godoc
// @Summary 레이드 실패 (내부 API)
// @Description 타임아웃 또는 전멸 시 데디케이티드 서버에서 호출합니다
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.SessionNameRequest true "세션 정보"
// @Success 200 {object} docs.RoomStatusResponse
// @Failure 400 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/fail [post]
func (h *Handler) FailRaid(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if req.SessionName == "" {
return apperror.BadRequest("sessionName은 필수입니다")
}
room, err := h.svc.FailRaid(req.SessionName)
if err != nil {
return bossError(fiber.StatusBadRequest, "레이드 실패 처리에 실패했습니다", err)
}
return c.JSON(fiber.Map{
"roomId": room.ID,
"sessionName": room.SessionName,
"status": room.Status,
})
}
// ValidateEntryToken godoc
// @Summary 입장 토큰 검증 (내부 API)
// @Description 데디케이티드 서버에서 플레이어의 입장 토큰을 검증합니다. 일회성 소모.
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.ValidateEntryTokenRequest true "토큰"
// @Success 200 {object} docs.ValidateEntryTokenResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/validate-entry [post]
func (h *Handler) ValidateEntryToken(c *fiber.Ctx) error {
var req struct {
EntryToken string `json:"entryToken"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if req.EntryToken == "" {
return apperror.BadRequest("entryToken은 필수입니다")
}
username, sessionName, err := h.svc.ValidateEntryToken(req.EntryToken)
if err != nil {
return apperror.Unauthorized(err.Error())
}
return c.JSON(fiber.Map{
"valid": true,
"username": username,
"sessionName": sessionName,
})
}
// GetRoom godoc
// @Summary 방 정보 조회 (내부 API)
// @Description sessionName으로 보스 레이드 방 정보를 조회합니다
// @Tags Internal - Boss Raid
// @Produce json
// @Security ApiKeyAuth
// @Param sessionName query string true "세션 이름"
// @Success 200 {object} bossraid.BossRoom
// @Failure 400 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/room [get]
func (h *Handler) GetRoom(c *fiber.Ctx) error {
sessionName := c.Query("sessionName")
if sessionName == "" {
return apperror.BadRequest("sessionName은 필수입니다")
}
room, err := h.svc.GetRoom(sessionName)
if err != nil {
return bossError(fiber.StatusNotFound, "방을 찾을 수 없습니다", err)
}
return c.JSON(room)
}
// RegisterServer godoc
// @Summary 데디케이티드 서버 등록 (내부 API)
// @Description 데디케이티드 서버 컨테이너가 시작 시 호출합니다. 룸 슬롯을 자동 할당합니다.
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.RegisterServerRequest true "서버 정보"
// @Success 201 {object} docs.RegisterServerResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 409 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/register [post]
func (h *Handler) RegisterServer(c *fiber.Ctx) error {
var req struct {
ServerName string `json:"serverName"`
InstanceID string `json:"instanceId"`
MaxRooms int `json:"maxRooms"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if req.ServerName == "" || req.InstanceID == "" {
return apperror.BadRequest("serverName과 instanceId는 필수입니다")
}
sessionName, err := h.svc.RegisterServer(req.ServerName, req.InstanceID, req.MaxRooms)
if err != nil {
return bossError(fiber.StatusConflict, "서버 등록에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"sessionName": sessionName,
"instanceId": req.InstanceID,
})
}
// Heartbeat godoc
// @Summary 하트비트 (내부 API)
// @Description 데디케이티드 서버 컨테이너가 주기적으로 호출합니다
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.HeartbeatRequest true "인스턴스 정보"
// @Success 200 {object} docs.StatusResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/heartbeat [post]
func (h *Handler) Heartbeat(c *fiber.Ctx) error {
var req struct {
InstanceID string `json:"instanceId"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if req.InstanceID == "" {
return apperror.BadRequest("instanceId는 필수입니다")
}
if err := h.svc.Heartbeat(req.InstanceID); err != nil {
return bossError(fiber.StatusNotFound, "인스턴스를 찾을 수 없습니다", err)
}
return c.JSON(fiber.Map{"status": "ok"})
}
// ResetRoom godoc
// @Summary 룸 슬롯 리셋 (내부 API)
// @Description 레이드 종료 후 슬롯을 idle 상태로 되돌립니다
// @Tags Internal - Boss Raid
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body docs.ResetRoomRequest true "세션 정보"
// @Success 200 {object} docs.ResetRoomResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/reset-room [post]
func (h *Handler) ResetRoom(c *fiber.Ctx) error {
var req struct {
SessionName string `json:"sessionName"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if req.SessionName == "" {
return apperror.BadRequest("sessionName은 필수입니다")
}
if err := h.svc.ResetRoom(req.SessionName); err != nil {
return bossError(fiber.StatusInternalServerError, "슬롯 리셋에 실패했습니다", err)
}
return c.JSON(fiber.Map{"status": "ok", "sessionName": req.SessionName})
}
// GetServerStatus godoc
// @Summary 서버 상태 조회 (내부 API)
// @Description 데디케이티드 서버의 정보와 룸 슬롯 목록을 조회합니다
// @Tags Internal - Boss Raid
// @Produce json
// @Security ApiKeyAuth
// @Param serverName query string true "서버 이름"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/internal/bossraid/server-status [get]
func (h *Handler) GetServerStatus(c *fiber.Ctx) error {
serverName := c.Query("serverName")
if serverName == "" {
return apperror.BadRequest("serverName은 필수입니다")
}
server, slots, err := h.svc.GetServerStatus(serverName)
if err != nil {
return bossError(fiber.StatusNotFound, "서버를 찾을 수 없습니다", err)
}
return c.JSON(fiber.Map{
"server": server,
"slots": slots,
})
}

View File

@@ -0,0 +1,92 @@
package bossraid
import (
"errors"
"time"
"gorm.io/gorm"
)
// ErrStatusConflict indicates that a room's status was already changed by another request.
var ErrStatusConflict = errors.New("방 상태가 이미 변경되었습니다")
type RoomStatus string
const (
StatusWaiting RoomStatus = "waiting"
StatusInProgress RoomStatus = "in_progress"
StatusCompleted RoomStatus = "completed"
StatusFailed RoomStatus = "failed"
StatusRewardFailed RoomStatus = "reward_failed"
)
// BossRoom represents a boss raid session room.
type BossRoom struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"`
BossID int `json:"bossId" gorm:"index;not null"`
Status RoomStatus `json:"status" gorm:"type:varchar(20);index;default:waiting;not null"`
MaxPlayers int `json:"maxPlayers" gorm:"default:3;not null"`
// Players is stored as a JSON text column for simplicity.
// TODO: For better query performance, consider migrating to a junction table
// (boss_room_players with room_id + username columns).
Players string `json:"players" gorm:"type:text"` // JSON array of usernames
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
// SlotStatus represents the status of a dedicated server room slot.
type SlotStatus string
const (
SlotIdle SlotStatus = "idle"
SlotWaiting SlotStatus = "waiting"
SlotInProgress SlotStatus = "in_progress"
)
// DedicatedServer represents a server group (e.g., "Dedi1").
// Multiple containers (replicas) share the same server group name.
type DedicatedServer struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
ServerName string `json:"serverName" gorm:"type:varchar(100);uniqueIndex;not null"`
MaxRooms int `json:"maxRooms" gorm:"default:10;not null"`
}
// RoomSlot represents a room slot on a dedicated server.
// Each slot has a stable session name that the Fusion NetworkRunner uses.
// InstanceID tracks which container process currently owns this slot.
type RoomSlot struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
DedicatedServerID uint `json:"dedicatedServerId" gorm:"index;uniqueIndex:idx_server_slot;not null"`
SlotIndex int `json:"slotIndex" gorm:"uniqueIndex:idx_server_slot;not null"`
SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"`
Status SlotStatus `json:"status" gorm:"type:varchar(20);index;default:idle;not null"`
BossRoomID *uint `json:"bossRoomId" gorm:"index"`
InstanceID string `json:"instanceId" gorm:"type:varchar(100);index"`
LastHeartbeat *time.Time `json:"lastHeartbeat"`
}
// RewardFailure records a failed reward grant for later retry.
// A record is "pending" when ResolvedAt is nil and RetryCount < maxRetries (10).
type RewardFailure struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
SessionName string `json:"sessionName" gorm:"type:varchar(100);index;not null"`
Username string `json:"username" gorm:"type:varchar(100);index;not null"`
TokenAmount uint64 `json:"tokenAmount" gorm:"not null"`
Assets string `json:"assets" gorm:"type:text"`
Experience int `json:"experience" gorm:"default:0;not null"`
Error string `json:"error" gorm:"type:text"`
RetryCount int `json:"retryCount" gorm:"default:0;not null"`
ResolvedAt *time.Time `json:"resolvedAt" gorm:"index"`
}

View File

@@ -0,0 +1,331 @@
package bossraid
import (
"fmt"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Create(room *BossRoom) error {
return r.db.Create(room).Error
}
func (r *Repository) Update(room *BossRoom) error {
return r.db.Save(room).Error
}
func (r *Repository) FindBySessionName(sessionName string) (*BossRoom, error) {
var room BossRoom
if err := r.db.Where("session_name = ?", sessionName).First(&room).Error; err != nil {
return nil, err
}
return &room, nil
}
// FindBySessionNameForUpdate acquires a row-level lock (SELECT ... FOR UPDATE)
// to prevent concurrent state transitions.
func (r *Repository) FindBySessionNameForUpdate(sessionName string) (*BossRoom, error) {
var room BossRoom
if err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).Where("session_name = ?", sessionName).First(&room).Error; err != nil {
return nil, err
}
return &room, nil
}
// Transaction wraps a function in a database transaction.
func (r *Repository) Transaction(fn func(txRepo *Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(&Repository{db: tx})
})
}
// CountActiveByUsername checks if a player is already in an active boss raid.
func (r *Repository) CountActiveByUsername(username string) (int64, error) {
var count int64
// LIKE 특수문자 이스케이프
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(username)
search := `"` + escaped + `"`
err := r.db.Model(&BossRoom{}).
Where("status IN ? AND players LIKE ?",
[]RoomStatus{StatusWaiting, StatusInProgress},
"%"+search+"%",
).Count(&count).Error
return count, err
}
// --- DedicatedServer & RoomSlot ---
// UpsertDedicatedServer creates or updates a server group by name.
func (r *Repository) UpsertDedicatedServer(server *DedicatedServer) error {
var existing DedicatedServer
err := r.db.Where("server_name = ?", server.ServerName).First(&existing).Error
if err == gorm.ErrRecordNotFound {
return r.db.Create(server).Error
}
if err != nil {
return err
}
existing.MaxRooms = server.MaxRooms
return r.db.Save(&existing).Error
}
// FindDedicatedServerByName finds a server group by name.
func (r *Repository) FindDedicatedServerByName(serverName string) (*DedicatedServer, error) {
var server DedicatedServer
if err := r.db.Where("server_name = ?", serverName).First(&server).Error; err != nil {
return nil, err
}
return &server, nil
}
// EnsureRoomSlots ensures the correct number of room slots exist for a server.
func (r *Repository) EnsureRoomSlots(serverID uint, serverName string, maxRooms int) error {
for i := 0; i < maxRooms; i++ {
sessionName := fmt.Sprintf("%s_Room%d", serverName, i)
var existing RoomSlot
err := r.db.Where("session_name = ?", sessionName).First(&existing).Error
if err == gorm.ErrRecordNotFound {
slot := RoomSlot{
DedicatedServerID: serverID,
SlotIndex: i,
SessionName: sessionName,
Status: SlotIdle,
}
if err := r.db.Create(&slot).Error; err != nil {
return err
}
} else if err != nil {
return err
}
}
return nil
}
// AssignSlotToInstance finds an unassigned (or stale) slot and assigns it to the given instanceID.
// Returns the assigned slot with its sessionName.
func (r *Repository) AssignSlotToInstance(serverID uint, instanceID string, staleThreshold time.Time) (*RoomSlot, error) {
// First check if this instance already has a slot assigned
var existing RoomSlot
err := r.db.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("dedicated_server_id = ? AND instance_id = ?", serverID, instanceID).
First(&existing).Error
if err == nil {
// Already assigned — refresh heartbeat
now := time.Now()
existing.LastHeartbeat = &now
r.db.Save(&existing)
return &existing, nil
}
// Find an unassigned slot (instance_id is empty or heartbeat is stale)
var slot RoomSlot
err = r.db.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("dedicated_server_id = ? AND (instance_id = '' OR instance_id IS NULL OR last_heartbeat < ?)",
serverID, staleThreshold).
Order("slot_index ASC").
First(&slot).Error
if err != nil {
return nil, fmt.Errorf("사용 가능한 슬롯이 없습니다")
}
// Assign this instance to the slot
now := time.Now()
slot.InstanceID = instanceID
slot.LastHeartbeat = &now
slot.Status = SlotIdle
slot.BossRoomID = nil
if err := r.db.Save(&slot).Error; err != nil {
return nil, err
}
return &slot, nil
}
// UpdateHeartbeat updates the heartbeat for a specific instance.
func (r *Repository) UpdateHeartbeat(instanceID string) error {
now := time.Now()
result := r.db.Model(&RoomSlot{}).
Where("instance_id = ?", instanceID).
Update("last_heartbeat", now)
if result.RowsAffected == 0 {
return fmt.Errorf("인스턴스를 찾을 수 없습니다: %s", instanceID)
}
return result.Error
}
// FindIdleRoomSlot finds an idle room slot with a live instance (with row-level lock).
func (r *Repository) FindIdleRoomSlot(staleThreshold time.Time) (*RoomSlot, error) {
var slot RoomSlot
err := r.db.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("status = ? AND instance_id != '' AND instance_id IS NOT NULL AND last_heartbeat >= ?",
SlotIdle, staleThreshold).
Order("id ASC").
First(&slot).Error
if err != nil {
return nil, err
}
return &slot, nil
}
// UpdateRoomSlot updates a room slot.
func (r *Repository) UpdateRoomSlot(slot *RoomSlot) error {
return r.db.Save(slot).Error
}
// FindRoomSlotBySession finds a room slot by its session name.
func (r *Repository) FindRoomSlotBySession(sessionName string) (*RoomSlot, error) {
var slot RoomSlot
if err := r.db.Where("session_name = ?", sessionName).First(&slot).Error; err != nil {
return nil, err
}
return &slot, nil
}
// ResetRoomSlot sets a room slot back to idle and clears its BossRoomID.
// Does NOT clear InstanceID — the container still owns the slot.
func (r *Repository) ResetRoomSlot(sessionName string) error {
result := r.db.Model(&RoomSlot{}).
Where("session_name = ?", sessionName).
Updates(map[string]interface{}{
"status": SlotIdle,
"boss_room_id": nil,
})
return result.Error
}
// DeleteRoomBySessionName removes BossRoom records for a given session name.
// Used during ResetRoom to prevent duplicate session_name conflicts on next entry.
// Unscoped to perform hard delete — soft delete would leave the unique index occupied.
func (r *Repository) DeleteRoomBySessionName(sessionName string) error {
return r.db.Unscoped().Where("session_name = ?", sessionName).Delete(&BossRoom{}).Error
}
// ResetStaleSlots clears instanceID for slots with stale heartbeats
// and resets any active raids on those slots.
func (r *Repository) ResetStaleSlots(threshold time.Time) (int64, error) {
result := r.db.Model(&RoomSlot{}).
Where("instance_id != '' AND instance_id IS NOT NULL AND last_heartbeat < ?", threshold).
Updates(map[string]interface{}{
"instance_id": "",
"status": SlotIdle,
"boss_room_id": nil,
})
return result.RowsAffected, result.Error
}
// UpdateRoomStatus updates only the status of a boss room by session name.
func (r *Repository) UpdateRoomStatus(sessionName string, status RoomStatus) error {
result := r.db.Model(&BossRoom{}).
Where("session_name = ?", sessionName).
Update("status", status)
if result.RowsAffected == 0 {
return fmt.Errorf("방을 찾을 수 없습니다: %s", sessionName)
}
return result.Error
}
// TransitionRoomStatus atomically updates a room's status only if it currently matches expectedStatus.
// Returns ErrStatusConflict if the row was not in the expected state (optimistic locking).
func (r *Repository) TransitionRoomStatus(sessionName string, expectedStatus RoomStatus, newStatus RoomStatus, extras map[string]interface{}) error {
updates := map[string]interface{}{"status": newStatus}
for k, v := range extras {
updates[k] = v
}
result := r.db.Model(&BossRoom{}).
Where("session_name = ? AND status = ?", sessionName, expectedStatus).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrStatusConflict
}
return nil
}
// TransitionRoomStatusMulti atomically updates a room's status only if it currently matches one of the expected statuses.
// Returns ErrStatusConflict if the row was not in any of the expected states.
func (r *Repository) TransitionRoomStatusMulti(sessionName string, expectedStatuses []RoomStatus, newStatus RoomStatus, extras map[string]interface{}) error {
updates := map[string]interface{}{"status": newStatus}
for k, v := range extras {
updates[k] = v
}
result := r.db.Model(&BossRoom{}).
Where("session_name = ? AND status IN ?", sessionName, expectedStatuses).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrStatusConflict
}
return nil
}
// TransitionSlotStatus atomically updates a room slot's status only if it currently matches expectedStatus.
func (r *Repository) TransitionSlotStatus(sessionName string, expectedStatus SlotStatus, newStatus SlotStatus) error {
result := r.db.Model(&RoomSlot{}).
Where("session_name = ? AND status = ?", sessionName, expectedStatus).
Update("status", newStatus)
if result.Error != nil {
return result.Error
}
// Slot transition failures are non-fatal — log but don't block
return nil
}
// GetRoomSlotsByServer returns all room slots for a given server.
func (r *Repository) GetRoomSlotsByServer(serverID uint) ([]RoomSlot, error) {
var slots []RoomSlot
err := r.db.Where("dedicated_server_id = ?", serverID).Order("slot_index ASC").Find(&slots).Error
return slots, err
}
// --- RewardFailure ---
// SaveRewardFailure inserts a new reward failure record.
func (r *Repository) SaveRewardFailure(rf *RewardFailure) error {
return r.db.Create(rf).Error
}
// GetPendingRewardFailures returns unresolved failures that haven't exceeded 10 retries.
func (r *Repository) GetPendingRewardFailures(limit int) ([]RewardFailure, error) {
var failures []RewardFailure
err := r.db.
Where("resolved_at IS NULL AND retry_count < 10").
Order("created_at ASC").
Limit(limit).
Find(&failures).Error
return failures, err
}
// ResolveRewardFailure marks a reward failure as resolved by setting ResolvedAt.
func (r *Repository) ResolveRewardFailure(id uint) error {
now := time.Now()
return r.db.Model(&RewardFailure{}).
Where("id = ?", id).
Update("resolved_at", now).Error
}
// IncrementRetryCount increments the retry count and updates the error message.
func (r *Repository) IncrementRetryCount(id uint, errMsg string) error {
return r.db.Model(&RewardFailure{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"retry_count": gorm.Expr("retry_count + 1"),
"error": errMsg,
}).Error
}

View File

@@ -0,0 +1,112 @@
package bossraid
import (
"encoding/json"
"log"
"time"
"github.com/tolelom/tolchain/core"
)
// RewardWorker periodically retries failed reward grants.
type RewardWorker struct {
repo *Repository
rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error
expGrant func(username string, exp int) error
interval time.Duration
stopCh chan struct{}
}
// NewRewardWorker creates a new RewardWorker. Default interval is 1 minute.
func NewRewardWorker(
repo *Repository,
rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error,
expGrant func(username string, exp int) error,
) *RewardWorker {
return &RewardWorker{
repo: repo,
rewardGrant: rewardGrant,
expGrant: expGrant,
interval: 1 * time.Minute,
stopCh: make(chan struct{}),
}
}
// Start begins the background polling loop in a goroutine.
func (w *RewardWorker) Start() {
go func() {
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-w.stopCh:
log.Println("보상 재시도 워커 종료")
return
case <-ticker.C:
w.processFailures()
}
}
}()
log.Println("보상 재시도 워커 시작")
}
// Stop gracefully stops the worker.
func (w *RewardWorker) Stop() {
close(w.stopCh)
}
func (w *RewardWorker) processFailures() {
failures, err := w.repo.GetPendingRewardFailures(10)
if err != nil {
log.Printf("보상 재시도 조회 실패: %v", err)
return
}
for _, rf := range failures {
w.retryOne(rf)
}
}
func (w *RewardWorker) retryOne(rf RewardFailure) {
var retryErr error
// 블록체인 보상 재시도 (토큰 또는 에셋이 있는 경우)
if (rf.TokenAmount > 0 || rf.Assets != "[]") && w.rewardGrant != nil {
var assets []core.MintAssetPayload
if rf.Assets != "" && rf.Assets != "[]" {
if err := json.Unmarshal([]byte(rf.Assets), &assets); err != nil {
log.Printf("보상 재시도 에셋 파싱 실패: ID=%d: %v", rf.ID, err)
// 파싱 불가능한 경우 resolved 처리
if err := w.repo.ResolveRewardFailure(rf.ID); err != nil {
log.Printf("보상 실패 resolve 실패: ID=%d: %v", rf.ID, err)
}
return
}
}
retryErr = w.rewardGrant(rf.Username, rf.TokenAmount, assets)
}
// 경험치 재시도 (블록체인 보상이 없거나 성공한 경우)
if retryErr == nil && rf.Experience > 0 && w.expGrant != nil {
retryErr = w.expGrant(rf.Username, rf.Experience)
}
if retryErr == nil {
if err := w.repo.ResolveRewardFailure(rf.ID); err != nil {
log.Printf("보상 재시도 성공 기록 실패: ID=%d: %v", rf.ID, err)
} else {
log.Printf("보상 재시도 성공: ID=%d, %s", rf.ID, rf.Username)
}
return
}
// 재시도 실패 — retry_count 증가
if err := w.repo.IncrementRetryCount(rf.ID, retryErr.Error()); err != nil {
log.Printf("보상 재시도 카운트 증가 실패: ID=%d: %v", rf.ID, err)
}
newCount := rf.RetryCount + 1
if newCount >= 10 {
log.Printf("보상 재시도 포기 (최대 횟수 초과): ID=%d, %s", rf.ID, rf.Username)
} else {
log.Printf("보상 재시도 실패 (%d/10): ID=%d, %s: %v", newCount, rf.ID, rf.Username, retryErr)
}
}

View File

@@ -0,0 +1,544 @@
package bossraid
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
"github.com/tolelom/tolchain/core"
)
const (
// defaultMaxPlayers is the maximum number of players allowed in a boss raid room.
defaultMaxPlayers = 3
// entryTokenTTL is the TTL for boss raid entry tokens in Redis.
entryTokenTTL = 5 * time.Minute
// entryTokenPrefix is the Redis key prefix for entry token → {username, sessionName}.
entryTokenPrefix = "bossraid:entry:"
// pendingEntryPrefix is the Redis key prefix for username → {sessionName, entryToken}.
pendingEntryPrefix = "bossraid:pending:"
)
// entryTokenData is stored in Redis for each entry token.
type entryTokenData struct {
Username string `json:"username"`
SessionName string `json:"sessionName"`
}
type Service struct {
repo *Repository
rdb *redis.Client
rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error
expGrant func(username string, exp int) error
}
func NewService(repo *Repository, rdb *redis.Client) *Service {
return &Service{repo: repo, rdb: rdb}
}
// SetRewardGranter sets the callback for granting rewards via blockchain.
func (s *Service) SetRewardGranter(fn func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error) {
s.rewardGrant = fn
}
// SetExpGranter sets the callback for granting experience to players.
func (s *Service) SetExpGranter(fn func(username string, exp int) error) {
s.expGrant = fn
}
// RequestEntry creates a new boss room for a party.
// Allocates an idle room slot from a registered dedicated server.
// Returns the room with assigned session name.
func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error) {
// 좀비 슬롯 정리 — idle 슬롯 검색 전에 stale 인스턴스를 리셋
s.CheckStaleSlots()
if len(usernames) == 0 {
return nil, fmt.Errorf("플레이어 목록이 비어있습니다")
}
if len(usernames) > 3 {
return nil, fmt.Errorf("최대 3명까지 입장할 수 있습니다")
}
// 중복 플레이어 검증
seen := make(map[string]bool, len(usernames))
for _, u := range usernames {
if seen[u] {
return nil, fmt.Errorf("중복된 플레이어가 있습니다: %s", u)
}
seen[u] = true
}
playersJSON, err := json.Marshal(usernames)
if err != nil {
return nil, fmt.Errorf("플레이어 목록 직렬화 실패: %w", err)
}
var room *BossRoom
// Wrap slot allocation + active-room check + creation in a transaction.
err = s.repo.Transaction(func(txRepo *Repository) error {
// Find an idle room slot from a live dedicated server instance
staleThreshold := time.Now().Add(-30 * time.Second)
slot, err := txRepo.FindIdleRoomSlot(staleThreshold)
if err != nil {
return fmt.Errorf("현재 이용 가능한 보스 레이드 방이 없습니다")
}
for _, username := range usernames {
count, err := txRepo.CountActiveByUsername(username)
if err != nil {
return fmt.Errorf("플레이어 상태 확인 실패: %w", err)
}
if count > 0 {
return fmt.Errorf("플레이어 %s가 이미 보스 레이드 중입니다", username)
}
}
room = &BossRoom{
SessionName: slot.SessionName,
BossID: bossID,
Status: StatusWaiting,
MaxPlayers: defaultMaxPlayers,
Players: string(playersJSON),
}
if err := txRepo.Create(room); err != nil {
return fmt.Errorf("방 생성 실패: %w", err)
}
// Mark slot as waiting and link to the boss room
slot.Status = SlotWaiting
slot.BossRoomID = &room.ID
if err := txRepo.UpdateRoomSlot(slot); err != nil {
return fmt.Errorf("슬롯 상태 업데이트 실패: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return room, nil
}
// StartRaid marks a room as in_progress and updates the slot status.
// Uses optimistic locking (WHERE status = 'waiting') to prevent concurrent state transitions.
func (s *Service) StartRaid(sessionName string) (*BossRoom, error) {
now := time.Now()
err := s.repo.TransitionRoomStatus(sessionName, StatusWaiting, StatusInProgress, map[string]interface{}{
"started_at": now,
})
if err == ErrStatusConflict {
return nil, fmt.Errorf("시작할 수 없는 상태입니다 (이미 변경됨)")
}
if err != nil {
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Update slot status to in_progress (non-fatal if fails)
s.repo.TransitionSlotStatus(sessionName, SlotWaiting, SlotInProgress)
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
return room, nil
}
// PlayerReward describes the reward for a single player.
type PlayerReward struct {
Username string `json:"username"`
TokenAmount uint64 `json:"tokenAmount"`
Assets []core.MintAssetPayload `json:"assets"`
Experience int `json:"experience"` // 경험치 보상
}
// RewardResult holds the result of granting a reward to one player.
type RewardResult struct {
Username string `json:"username"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// CompleteRaid marks a room as completed and grants rewards via blockchain.
// Uses optimistic locking (WHERE status = 'in_progress') to prevent double-completion.
func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) {
var resultRewards []RewardResult
// Validate reward recipients are room players before transitioning
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
var players []string
if err := json.Unmarshal([]byte(room.Players), &players); err != nil {
return nil, nil, fmt.Errorf("플레이어 목록 파싱 실패: %w", err)
}
playerSet := make(map[string]bool, len(players))
for _, p := range players {
playerSet[p] = true
}
for _, r := range rewards {
if !playerSet[r.Username] {
return nil, nil, fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username)
}
}
// Atomically transition status: in_progress → completed
now := time.Now()
err = s.repo.TransitionRoomStatus(sessionName, StatusInProgress, StatusCompleted, map[string]interface{}{
"completed_at": now,
})
if err == ErrStatusConflict {
return nil, nil, fmt.Errorf("완료할 수 없는 상태입니다 (이미 변경됨)")
}
if err != nil {
return nil, nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Re-fetch the updated room
resultRoom, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
// Grant rewards outside the transaction to avoid holding the lock during RPC calls.
// Each reward is attempted up to 3 times with exponential backoff before being
// recorded as a RewardFailure for background retry.
resultRewards = make([]RewardResult, 0, len(rewards))
hasRewardFailure := false
if s.rewardGrant != nil {
for _, r := range rewards {
grantErr := s.grantWithRetry(r.Username, r.TokenAmount, r.Assets)
result := RewardResult{Username: r.Username, Success: grantErr == nil}
if grantErr != nil {
result.Error = grantErr.Error()
log.Printf("보상 지급 실패 (재시도 소진): %s: %v", r.Username, grantErr)
hasRewardFailure = true
// 실패한 보상을 DB에 기록하여 백그라운드 재시도 가능하게 함
s.saveRewardFailure(sessionName, r, grantErr)
}
resultRewards = append(resultRewards, result)
}
}
// 보상 실패가 있으면 상태를 reward_failed로 업데이트 (completed → reward_failed)
if hasRewardFailure {
if err := s.repo.TransitionRoomStatus(sessionName, StatusCompleted, StatusRewardFailed, nil); err != nil {
log.Printf("보상 실패 상태 업데이트 실패: %s: %v", sessionName, err)
}
}
// Grant experience to players (with retry)
if s.expGrant != nil {
for _, r := range rewards {
if r.Experience > 0 {
expErr := s.grantExpWithRetry(r.Username, r.Experience)
if expErr != nil {
log.Printf("경험치 지급 실패 (재시도 소진): %s: %v", r.Username, expErr)
// 경험치 실패도 RewardFailure에 기록 (토큰/에셋 없이 경험치만)
s.saveRewardFailure(sessionName, PlayerReward{
Username: r.Username,
Experience: r.Experience,
}, expErr)
}
}
}
}
// Reset slot to idle so it can accept new raids
if err := s.repo.ResetRoomSlot(sessionName); err != nil {
log.Printf("슬롯 리셋 실패 (complete): %s: %v", sessionName, err)
}
return resultRoom, resultRewards, nil
}
// FailRaid marks a room as failed and resets the slot.
// Uses optimistic locking (WHERE status IN ('waiting','in_progress')) to prevent concurrent state transitions.
func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
now := time.Now()
err := s.repo.TransitionRoomStatusMulti(sessionName,
[]RoomStatus{StatusWaiting, StatusInProgress},
StatusFailed,
map[string]interface{}{"completed_at": now},
)
if err == ErrStatusConflict {
return nil, fmt.Errorf("실패 처리할 수 없는 상태입니다 (이미 변경됨)")
}
if err != nil {
return nil, fmt.Errorf("상태 업데이트 실패: %w", err)
}
// Reset slot to idle so it can accept new raids
if err := s.repo.ResetRoomSlot(sessionName); err != nil {
log.Printf("슬롯 리셋 실패 (fail): %s: %v", sessionName, err)
}
room, err := s.repo.FindBySessionName(sessionName)
if err != nil {
return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err)
}
return room, nil
}
// GetRoom returns a room by session name.
func (s *Service) GetRoom(sessionName string) (*BossRoom, error) {
return s.repo.FindBySessionName(sessionName)
}
// generateToken creates a cryptographically random hex token.
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// GenerateEntryTokens creates entry tokens for all players in a room
// and stores them in Redis. Returns a map of username → entryToken.
func (s *Service) GenerateEntryTokens(sessionName string, usernames []string) (map[string]string, error) {
ctx := context.Background()
tokens := make(map[string]string, len(usernames))
for _, username := range usernames {
token, err := generateToken()
if err != nil {
return nil, fmt.Errorf("토큰 생성 실패: %w", err)
}
tokens[username] = token
// Store entry token → {username, sessionName}
data, _ := json.Marshal(entryTokenData{
Username: username,
SessionName: sessionName,
})
entryKey := entryTokenPrefix + token
if err := s.rdb.Set(ctx, entryKey, string(data), entryTokenTTL).Err(); err != nil {
return nil, fmt.Errorf("Redis 저장 실패: %w", err)
}
// Store pending entry: username → {sessionName, entryToken}
pendingData, _ := json.Marshal(map[string]string{
"sessionName": sessionName,
"entryToken": token,
})
pendingKey := pendingEntryPrefix + username
if err := s.rdb.Set(ctx, pendingKey, string(pendingData), entryTokenTTL).Err(); err != nil {
return nil, fmt.Errorf("Redis 저장 실패: %w", err)
}
}
return tokens, nil
}
// ValidateEntryToken validates and consumes a one-time entry token.
// Returns the username and sessionName if valid.
func (s *Service) ValidateEntryToken(token string) (username, sessionName string, err error) {
ctx := context.Background()
key := entryTokenPrefix + token
val, err := s.rdb.GetDel(ctx, key).Result()
if err == redis.Nil {
return "", "", fmt.Errorf("유효하지 않거나 만료된 입장 토큰입니다")
}
if err != nil {
return "", "", fmt.Errorf("토큰 검증 실패: %w", err)
}
var data entryTokenData
if err := json.Unmarshal([]byte(val), &data); err != nil {
return "", "", fmt.Errorf("토큰 데이터 파싱 실패: %w", err)
}
return data.Username, data.SessionName, nil
}
// GetMyEntryToken returns the pending entry token for a username.
func (s *Service) GetMyEntryToken(username string) (sessionName, entryToken string, err error) {
ctx := context.Background()
key := pendingEntryPrefix + username
val, err := s.rdb.Get(ctx, key).Result()
if err == redis.Nil {
return "", "", fmt.Errorf("대기 중인 입장 토큰이 없습니다")
}
if err != nil {
return "", "", fmt.Errorf("토큰 조회 실패: %w", err)
}
var data map[string]string
if err := json.Unmarshal([]byte(val), &data); err != nil {
return "", "", fmt.Errorf("토큰 데이터 파싱 실패: %w", err)
}
return data["sessionName"], data["entryToken"], nil
}
// RequestEntryWithTokens creates a boss room and generates entry tokens for all players.
// Returns the room and a map of username → entryToken.
func (s *Service) RequestEntryWithTokens(usernames []string, bossID int) (*BossRoom, map[string]string, error) {
room, err := s.RequestEntry(usernames, bossID)
if err != nil {
return nil, nil, err
}
tokens, err := s.GenerateEntryTokens(room.SessionName, usernames)
if err != nil {
return nil, nil, fmt.Errorf("입장 토큰 생성 실패: %w", err)
}
return room, tokens, nil
}
// --- Dedicated Server Management ---
const staleTimeout = 30 * time.Second
// RegisterServer registers a dedicated server instance (container).
// Creates the server group + slots if needed, then assigns a slot to this instance.
// Returns the assigned sessionName.
func (s *Service) RegisterServer(serverName, instanceID string, maxRooms int) (string, error) {
if serverName == "" || instanceID == "" {
return "", fmt.Errorf("serverName과 instanceId는 필수입니다")
}
if maxRooms <= 0 {
maxRooms = 10
}
// Ensure server group exists
server := &DedicatedServer{
ServerName: serverName,
MaxRooms: maxRooms,
}
if err := s.repo.UpsertDedicatedServer(server); err != nil {
return "", fmt.Errorf("서버 그룹 등록 실패: %w", err)
}
// Re-fetch to get the ID
server, err := s.repo.FindDedicatedServerByName(serverName)
if err != nil {
return "", fmt.Errorf("서버 조회 실패: %w", err)
}
// Ensure all room slots exist
if err := s.repo.EnsureRoomSlots(server.ID, serverName, maxRooms); err != nil {
return "", fmt.Errorf("슬롯 생성 실패: %w", err)
}
// Assign a slot to this instance
staleThreshold := time.Now().Add(-staleTimeout)
slot, err := s.repo.AssignSlotToInstance(server.ID, instanceID, staleThreshold)
if err != nil {
return "", fmt.Errorf("슬롯 배정 실패: %w", err)
}
return slot.SessionName, nil
}
// Heartbeat updates the heartbeat for a container instance.
func (s *Service) Heartbeat(instanceID string) error {
return s.repo.UpdateHeartbeat(instanceID)
}
// CheckStaleSlots resets slots whose instances have gone silent.
func (s *Service) CheckStaleSlots() {
threshold := time.Now().Add(-staleTimeout)
count, err := s.repo.ResetStaleSlots(threshold)
if err != nil {
log.Printf("스태일 슬롯 체크 실패: %v", err)
return
}
if count > 0 {
log.Printf("스태일 슬롯 %d개 리셋", count)
}
}
// ResetRoom resets a room slot back to idle and cleans up any lingering BossRoom records.
// Called by the dedicated server after a raid ends and the runner is recycled.
func (s *Service) ResetRoom(sessionName string) error {
// 완료/실패되지 않은 BossRoom 레코드 정리 (waiting/in_progress 상태)
if err := s.repo.DeleteRoomBySessionName(sessionName); err != nil {
log.Printf("BossRoom 레코드 정리 실패: %s: %v", sessionName, err)
}
return s.repo.ResetRoomSlot(sessionName)
}
// GetServerStatus returns a server group and its room slots.
func (s *Service) GetServerStatus(serverName string) (*DedicatedServer, []RoomSlot, error) {
server, err := s.repo.FindDedicatedServerByName(serverName)
if err != nil {
return nil, nil, fmt.Errorf("서버를 찾을 수 없습니다: %w", err)
}
slots, err := s.repo.GetRoomSlotsByServer(server.ID)
if err != nil {
return nil, nil, fmt.Errorf("슬롯 조회 실패: %w", err)
}
return server, slots, nil
}
// --- Reward retry helpers ---
const immediateRetries = 3
// grantWithRetry attempts the reward grant up to 3 times with backoff (1s, 2s).
func (s *Service) grantWithRetry(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
delays := []time.Duration{1 * time.Second, 2 * time.Second}
var lastErr error
for attempt := 0; attempt < immediateRetries; attempt++ {
lastErr = s.rewardGrant(username, tokenAmount, assets)
if lastErr == nil {
return nil
}
if attempt < len(delays) {
log.Printf("보상 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr)
time.Sleep(delays[attempt])
}
}
return lastErr
}
// grantExpWithRetry attempts the experience grant up to 3 times with backoff (1s, 2s).
func (s *Service) grantExpWithRetry(username string, exp int) error {
delays := []time.Duration{1 * time.Second, 2 * time.Second}
var lastErr error
for attempt := 0; attempt < immediateRetries; attempt++ {
lastErr = s.expGrant(username, exp)
if lastErr == nil {
return nil
}
if attempt < len(delays) {
log.Printf("경험치 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr)
time.Sleep(delays[attempt])
}
}
return lastErr
}
// saveRewardFailure records a failed reward in the DB for background retry.
func (s *Service) saveRewardFailure(sessionName string, r PlayerReward, grantErr error) {
assets := "[]"
if len(r.Assets) > 0 {
if data, err := json.Marshal(r.Assets); err == nil {
assets = string(data)
}
}
rf := &RewardFailure{
SessionName: sessionName,
Username: r.Username,
TokenAmount: r.TokenAmount,
Assets: assets,
Experience: r.Experience,
Error: grantErr.Error(),
}
if err := s.repo.SaveRewardFailure(rf); err != nil {
log.Printf("보상 실패 기록 저장 실패: %s/%s: %v", sessionName, r.Username, err)
}
}

View File

@@ -0,0 +1,574 @@
package bossraid
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/tolelom/tolchain/core"
)
// ---------------------------------------------------------------------------
// Tests for pure functions and validation logic
// ---------------------------------------------------------------------------
func TestGenerateToken_Uniqueness(t *testing.T) {
tokens := make(map[string]bool, 100)
for i := 0; i < 100; i++ {
tok, err := generateToken()
if err != nil {
t.Fatalf("generateToken() failed: %v", err)
}
if len(tok) != 64 { // 32 bytes = 64 hex chars
t.Errorf("token length = %d, want 64", len(tok))
}
if tokens[tok] {
t.Errorf("duplicate token generated: %s", tok)
}
tokens[tok] = true
}
}
func TestGenerateToken_IsValidHex(t *testing.T) {
tok, err := generateToken()
if err != nil {
t.Fatalf("generateToken() failed: %v", err)
}
for _, c := range tok {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("token contains non-hex char: %c", c)
}
}
}
// ---------------------------------------------------------------------------
// Tests for RegisterServer input validation
// Note: RequestEntry calls CheckStaleSlots() before validation, which needs
// a non-nil repo, so we test its validation via the mock-based tests below.
// RegisterServer validates before DB access, so we can test directly.
// ---------------------------------------------------------------------------
func TestRegisterServer_Validation_EmptyServerName(t *testing.T) {
svc := &Service{}
_, err := svc.RegisterServer("", "instance1", 10)
if err == nil {
t.Error("RegisterServer with empty serverName should fail")
}
}
func TestRegisterServer_Validation_EmptyInstanceID(t *testing.T) {
svc := &Service{}
_, err := svc.RegisterServer("Dedi1", "", 10)
if err == nil {
t.Error("RegisterServer with empty instanceID should fail")
}
}
// ---------------------------------------------------------------------------
// Tests for model constants and JSON serialization
// ---------------------------------------------------------------------------
func TestRoomStatus_Constants(t *testing.T) {
tests := []struct {
status RoomStatus
want string
}{
{StatusWaiting, "waiting"},
{StatusInProgress, "in_progress"},
{StatusCompleted, "completed"},
{StatusFailed, "failed"},
{StatusRewardFailed, "reward_failed"},
}
for _, tt := range tests {
if string(tt.status) != tt.want {
t.Errorf("status %v = %q, want %q", tt.status, string(tt.status), tt.want)
}
}
}
func TestSlotStatus_Constants(t *testing.T) {
tests := []struct {
status SlotStatus
want string
}{
{SlotIdle, "idle"},
{SlotWaiting, "waiting"},
{SlotInProgress, "in_progress"},
}
for _, tt := range tests {
if string(tt.status) != tt.want {
t.Errorf("slot status %v = %q, want %q", tt.status, string(tt.status), tt.want)
}
}
}
func TestDefaultMaxPlayers(t *testing.T) {
if defaultMaxPlayers != 3 {
t.Errorf("defaultMaxPlayers = %d, want 3", defaultMaxPlayers)
}
}
func TestBossRoom_PlayersJSON_RoundTrip(t *testing.T) {
usernames := []string{"alice", "bob", "charlie"}
data, err := json.Marshal(usernames)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
room := BossRoom{
Players: string(data),
}
var parsed []string
if err := json.Unmarshal([]byte(room.Players), &parsed); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(parsed) != 3 {
t.Fatalf("parsed player count = %d, want 3", len(parsed))
}
for i, want := range usernames {
if parsed[i] != want {
t.Errorf("parsed[%d] = %q, want %q", i, parsed[i], want)
}
}
}
func TestPlayerReward_JSONRoundTrip(t *testing.T) {
rewards := []PlayerReward{
{Username: "alice", TokenAmount: 100, Experience: 50},
{Username: "bob", TokenAmount: 200, Experience: 75, Assets: nil},
}
data, err := json.Marshal(rewards)
if err != nil {
t.Fatalf("marshal rewards failed: %v", err)
}
var parsed []PlayerReward
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal rewards failed: %v", err)
}
if len(parsed) != 2 {
t.Fatalf("parsed reward count = %d, want 2", len(parsed))
}
if parsed[0].Username != "alice" || parsed[0].TokenAmount != 100 || parsed[0].Experience != 50 {
t.Errorf("parsed[0] = %+v, unexpected values", parsed[0])
}
}
func TestRewardResult_JSONRoundTrip(t *testing.T) {
results := []RewardResult{
{Username: "alice", Success: true},
{Username: "bob", Success: false, Error: "insufficient balance"},
}
data, err := json.Marshal(results)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var parsed []RewardResult
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(parsed) != 2 {
t.Fatalf("got %d results, want 2", len(parsed))
}
if !parsed[0].Success {
t.Error("parsed[0].Success should be true")
}
if parsed[1].Success {
t.Error("parsed[1].Success should be false")
}
if parsed[1].Error != "insufficient balance" {
t.Errorf("parsed[1].Error = %q, want %q", parsed[1].Error, "insufficient balance")
}
}
func TestEntryTokenData_JSONRoundTrip(t *testing.T) {
data := entryTokenData{
Username: "player1",
SessionName: "Dedi1_Room_01",
}
b, err := json.Marshal(data)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var parsed entryTokenData
if err := json.Unmarshal(b, &parsed); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if parsed.Username != data.Username {
t.Errorf("Username = %q, want %q", parsed.Username, data.Username)
}
if parsed.SessionName != data.SessionName {
t.Errorf("SessionName = %q, want %q", parsed.SessionName, data.SessionName)
}
}
// ---------------------------------------------------------------------------
// Tests for Service constructor and callback setters
// ---------------------------------------------------------------------------
func TestNewService_NilParams(t *testing.T) {
svc := NewService(nil, nil)
if svc == nil {
t.Error("NewService should return non-nil service")
}
}
func TestSetRewardGranter(t *testing.T) {
svc := NewService(nil, nil)
svc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
return nil
})
if svc.rewardGrant == nil {
t.Error("rewardGrant should be set after SetRewardGranter")
}
}
func TestSetExpGranter(t *testing.T) {
svc := NewService(nil, nil)
svc.SetExpGranter(func(username string, exp int) error {
return nil
})
if svc.expGrant == nil {
t.Error("expGrant should be set after SetExpGranter")
}
}
func TestStaleTimeout_Value(t *testing.T) {
if staleTimeout != 30*time.Second {
t.Errorf("staleTimeout = %v, want 30s", staleTimeout)
}
}
func TestEntryTokenTTL_Value(t *testing.T) {
if entryTokenTTL != 5*time.Minute {
t.Errorf("entryTokenTTL = %v, want 5m", entryTokenTTL)
}
}
// ---------------------------------------------------------------------------
// Tests using mock repository for deeper logic testing
// ---------------------------------------------------------------------------
// mockRepo implements the methods needed by testableService to test
// business logic without a real database.
type mockRepo struct {
rooms map[string]*BossRoom
activeCounts map[string]int64
nextID uint
}
func newMockRepo() *mockRepo {
return &mockRepo{
rooms: make(map[string]*BossRoom),
activeCounts: make(map[string]int64),
nextID: 1,
}
}
// testableService mirrors the validation and state-transition logic of Service
// but uses an in-memory mock repository instead of GORM + MySQL.
// This lets us test business rules without external dependencies.
type testableService struct {
repo *mockRepo
rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error
}
func (s *testableService) requestEntry(usernames []string, bossID int) (*BossRoom, error) {
if len(usernames) == 0 {
return nil, fmt.Errorf("empty players")
}
if len(usernames) > 3 {
return nil, fmt.Errorf("too many players")
}
seen := make(map[string]bool, len(usernames))
for _, u := range usernames {
if seen[u] {
return nil, fmt.Errorf("duplicate: %s", u)
}
seen[u] = true
}
for _, u := range usernames {
if s.repo.activeCounts[u] > 0 {
return nil, fmt.Errorf("player %s already active", u)
}
}
playersJSON, _ := json.Marshal(usernames)
sessionName := fmt.Sprintf("test_session_%d", s.repo.nextID)
room := &BossRoom{
ID: s.repo.nextID,
SessionName: sessionName,
BossID: bossID,
Status: StatusWaiting,
MaxPlayers: defaultMaxPlayers,
Players: string(playersJSON),
CreatedAt: time.Now(),
}
s.repo.nextID++
s.repo.rooms[sessionName] = room
return room, nil
}
func (s *testableService) completeRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) {
room, ok := s.repo.rooms[sessionName]
if !ok {
return nil, nil, fmt.Errorf("room not found")
}
if room.Status != StatusInProgress {
return nil, nil, fmt.Errorf("wrong status: %s", room.Status)
}
var players []string
if err := json.Unmarshal([]byte(room.Players), &players); err != nil {
return nil, nil, fmt.Errorf("parse players: %w", err)
}
playerSet := make(map[string]bool, len(players))
for _, p := range players {
playerSet[p] = true
}
for _, r := range rewards {
if !playerSet[r.Username] {
return nil, nil, fmt.Errorf("%s is not a room member", r.Username)
}
}
now := time.Now()
room.Status = StatusCompleted
room.CompletedAt = &now
var results []RewardResult
if s.rewardGrant != nil {
for _, r := range rewards {
grantErr := s.rewardGrant(r.Username, r.TokenAmount, r.Assets)
res := RewardResult{Username: r.Username, Success: grantErr == nil}
if grantErr != nil {
res.Error = grantErr.Error()
}
results = append(results, res)
}
}
return room, results, nil
}
func (s *testableService) failRaid(sessionName string) (*BossRoom, error) {
room, ok := s.repo.rooms[sessionName]
if !ok {
return nil, fmt.Errorf("room not found")
}
if room.Status != StatusWaiting && room.Status != StatusInProgress {
return nil, fmt.Errorf("wrong status: %s", room.Status)
}
now := time.Now()
room.Status = StatusFailed
room.CompletedAt = &now
return room, nil
}
// ---------------------------------------------------------------------------
// Mock-based tests for business logic
// ---------------------------------------------------------------------------
func TestMock_RequestEntry_Success(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, err := svc.requestEntry([]string{"p1", "p2"}, 1)
if err != nil {
t.Fatalf("requestEntry failed: %v", err)
}
if room.Status != StatusWaiting {
t.Errorf("Status = %q, want %q", room.Status, StatusWaiting)
}
if room.BossID != 1 {
t.Errorf("BossID = %d, want 1", room.BossID)
}
if room.MaxPlayers != 3 {
t.Errorf("MaxPlayers = %d, want 3", room.MaxPlayers)
}
}
func TestMock_RequestEntry_PlayerAlreadyActive(t *testing.T) {
repo := newMockRepo()
repo.activeCounts["p1"] = 1
svc := &testableService{repo: repo}
_, err := svc.requestEntry([]string{"p1", "p2"}, 1)
if err == nil {
t.Error("expected error for already-active player")
}
}
func TestMock_CompleteRaid_Success(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1", "p2"}, 1)
room.Status = StatusInProgress
completed, _, err := svc.completeRaid(room.SessionName, []PlayerReward{
{Username: "p1", TokenAmount: 100},
})
if err != nil {
t.Fatalf("completeRaid failed: %v", err)
}
if completed.Status != StatusCompleted {
t.Errorf("Status = %q, want %q", completed.Status, StatusCompleted)
}
}
func TestMock_CompleteRaid_WrongStatus(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1"}, 1)
// still in "waiting" status
_, _, err := svc.completeRaid(room.SessionName, nil)
if err == nil {
t.Error("expected error for wrong status")
}
}
func TestMock_CompleteRaid_InvalidRecipient(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1"}, 1)
room.Status = StatusInProgress
_, _, err := svc.completeRaid(room.SessionName, []PlayerReward{
{Username: "stranger", TokenAmount: 100},
})
if err == nil {
t.Error("expected error for non-member reward recipient")
}
}
func TestMock_CompleteRaid_WithRewardGranter(t *testing.T) {
grantCalls := 0
svc := &testableService{
repo: newMockRepo(),
rewardGrant: func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
grantCalls++
return nil
},
}
room, _ := svc.requestEntry([]string{"p1"}, 1)
room.Status = StatusInProgress
_, results, err := svc.completeRaid(room.SessionName, []PlayerReward{
{Username: "p1", TokenAmount: 50},
})
if err != nil {
t.Fatalf("completeRaid failed: %v", err)
}
if grantCalls != 1 {
t.Errorf("grant calls = %d, want 1", grantCalls)
}
if len(results) != 1 || !results[0].Success {
t.Errorf("expected 1 successful result, got %+v", results)
}
}
func TestMock_CompleteRaid_RewardFailure(t *testing.T) {
svc := &testableService{
repo: newMockRepo(),
rewardGrant: func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
return fmt.Errorf("chain error")
},
}
room, _ := svc.requestEntry([]string{"p1"}, 1)
room.Status = StatusInProgress
completed, results, err := svc.completeRaid(room.SessionName, []PlayerReward{
{Username: "p1", TokenAmount: 50},
})
if err != nil {
t.Fatalf("completeRaid should not fail when granter fails: %v", err)
}
if completed.Status != StatusCompleted {
t.Errorf("room should still be completed despite reward failure")
}
if len(results) != 1 || results[0].Success {
t.Error("expected failed reward result")
}
if results[0].Error == "" {
t.Error("expected error message in result")
}
}
func TestMock_FailRaid_FromWaiting(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1"}, 1)
failed, err := svc.failRaid(room.SessionName)
if err != nil {
t.Fatalf("failRaid failed: %v", err)
}
if failed.Status != StatusFailed {
t.Errorf("Status = %q, want %q", failed.Status, StatusFailed)
}
}
func TestMock_FailRaid_FromInProgress(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1"}, 1)
room.Status = StatusInProgress
failed, err := svc.failRaid(room.SessionName)
if err != nil {
t.Fatalf("failRaid failed: %v", err)
}
if failed.Status != StatusFailed {
t.Errorf("Status = %q, want %q", failed.Status, StatusFailed)
}
}
func TestMock_FailRaid_FromCompleted(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
room, _ := svc.requestEntry([]string{"p1"}, 1)
room.Status = StatusCompleted
_, err := svc.failRaid(room.SessionName)
if err == nil {
t.Error("expected error failing completed raid")
}
}
func TestMock_FullLifecycle(t *testing.T) {
svc := &testableService{repo: newMockRepo()}
// Create room
room, err := svc.requestEntry([]string{"p1", "p2"}, 1)
if err != nil {
t.Fatalf("requestEntry: %v", err)
}
if room.Status != StatusWaiting {
t.Fatalf("expected waiting, got %s", room.Status)
}
// Start raid
room.Status = StatusInProgress
// Complete raid
completed, _, err := svc.completeRaid(room.SessionName, []PlayerReward{
{Username: "p1", TokenAmount: 10},
})
if err != nil {
t.Fatalf("completeRaid: %v", err)
}
if completed.Status != StatusCompleted {
t.Errorf("expected completed, got %s", completed.Status)
}
// Cannot fail a completed raid
_, err = svc.failRaid(room.SessionName)
if err == nil {
t.Error("expected error failing completed raid")
}
}

260
internal/chain/client.go Normal file
View File

@@ -0,0 +1,260 @@
package chain
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync/atomic"
"time"
)
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params any `json:"params"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *rpcError) Error() string {
return fmt.Sprintf("RPC error %d: %s", e.Code, e.Message)
}
// Client is a JSON-RPC 2.0 client for the TOL Chain node.
// It supports multiple node URLs for failover: on a network/HTTP error the
// client automatically retries against the next URL in the list.
// RPC-level errors (transaction failures, etc.) are returned immediately
// without failover since they indicate a logical error, not node unavailability.
type Client struct {
nodeURLs []string
http *http.Client
idSeq atomic.Int64
next atomic.Uint64 // round-robin index
}
// NewClient creates a client for one or more chain node URLs.
// When multiple URLs are provided, failed requests fall over to the next URL.
func NewClient(nodeURLs ...string) *Client {
if len(nodeURLs) == 0 {
panic("chain.NewClient: at least one node URL is required")
}
return &Client{
nodeURLs: nodeURLs,
http: &http.Client{Timeout: 10 * time.Second},
}
}
// Call invokes a JSON-RPC method and unmarshals the result into out.
// On network or HTTP errors it tries each node URL once before giving up.
func (c *Client) Call(method string, params any, out any) error {
n := len(c.nodeURLs)
start := int(c.next.Load() % uint64(n))
var lastErr error
for i := 0; i < n; i++ {
url := c.nodeURLs[(start+i)%n]
err := c.callNode(url, method, params, out)
if err == nil {
return nil
}
// RPC-level error (e.g. tx execution failure): return immediately,
// retrying on another node would give the same result.
if _, isRPC := err.(*rpcError); isRPC {
return err
}
// Network / HTTP error: mark this node as degraded and try the next.
lastErr = err
c.next.Add(1)
}
return fmt.Errorf("all chain nodes unreachable: %w", lastErr)
}
func (c *Client) callNode(nodeURL, method string, params any, out any) error {
reqBody := rpcRequest{
JSONRPC: "2.0",
ID: c.idSeq.Add(1),
Method: method,
Params: params,
}
data, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal RPC request: %w", err)
}
resp, err := c.http.Post(nodeURL, "application/json", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("RPC network error (%s): %w", nodeURL, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("RPC HTTP error (%s): status %d", nodeURL, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return fmt.Errorf("read RPC response: %w", err)
}
var rpcResp rpcResponse
if err := json.Unmarshal(body, &rpcResp); err != nil {
return fmt.Errorf("unmarshal RPC response: %w", err)
}
if rpcResp.Error != nil {
return rpcResp.Error
}
if out != nil {
if err := json.Unmarshal(rpcResp.Result, out); err != nil {
return fmt.Errorf("unmarshal RPC result: %w", err)
}
}
return nil
}
// --- Typed convenience methods ---
type BalanceResult struct {
Address string `json:"address"`
Balance uint64 `json:"balance"`
Nonce uint64 `json:"nonce"`
}
func (c *Client) GetBalance(address string) (*BalanceResult, error) {
var result BalanceResult
err := c.Call("getBalance", map[string]string{"address": address}, &result)
return &result, err
}
func (c *Client) GetAsset(id string) (json.RawMessage, error) {
var result json.RawMessage
err := c.Call("getAsset", map[string]string{"id": id}, &result)
return result, err
}
func (c *Client) GetAssetsByOwner(owner string, offset, limit int) (json.RawMessage, error) {
var result json.RawMessage
err := c.Call("getAssetsByOwner", map[string]any{
"owner": owner, "offset": offset, "limit": limit,
}, &result)
return result, err
}
func (c *Client) GetInventory(owner string) (json.RawMessage, error) {
var result json.RawMessage
err := c.Call("getInventory", map[string]string{"owner": owner}, &result)
return result, err
}
func (c *Client) GetActiveListings(offset, limit int) (json.RawMessage, error) {
var result json.RawMessage
err := c.Call("getActiveListings", map[string]any{
"offset": offset, "limit": limit,
}, &result)
return result, err
}
func (c *Client) GetListing(id string) (json.RawMessage, error) {
var result json.RawMessage
err := c.Call("getListing", map[string]string{"id": id}, &result)
return result, err
}
type SendTxResult struct {
TxID string `json:"tx_id"`
}
func (c *Client) SendTx(tx any) (*SendTxResult, error) {
var result SendTxResult
err := c.Call("sendTx", tx, &result)
return &result, err
}
// TxStatusResult mirrors the indexer.TxResult from the TOL Chain node.
type TxStatusResult struct {
TxID string `json:"tx_id"`
BlockHeight int64 `json:"block_height"`
Success bool `json:"success"`
Error string `json:"error"`
}
// GetTxStatus queries the execution result of a transaction.
// Returns nil result (no error) if the transaction has not been included in a block yet.
func (c *Client) GetTxStatus(txID string) (*TxStatusResult, error) {
var result *TxStatusResult
err := c.Call("getTxStatus", map[string]string{"tx_id": txID}, &result)
if err != nil {
return nil, err
}
return result, nil
}
// TxError is returned when a transaction was included in a block but execution failed.
type TxError struct {
TxID string
Message string
}
func (e *TxError) Error() string {
return fmt.Sprintf("transaction %s failed: %s", e.TxID, e.Message)
}
// DefaultTxTimeout is the default timeout for WaitForTx. PoA block intervals
// are typically a few seconds, so 15s provides ample margin.
const DefaultTxTimeout = 15 * time.Second
// SendTxAndWait sends a transaction and waits for block confirmation.
// It combines SendTx + WaitForTx for the common fire-and-confirm pattern.
func (c *Client) SendTxAndWait(tx any, timeout time.Duration) (*TxStatusResult, error) {
sendResult, err := c.SendTx(tx)
if err != nil {
return nil, fmt.Errorf("send tx: %w", err)
}
return c.WaitForTx(sendResult.TxID, timeout)
}
// WaitForTx polls getTxStatus until the transaction is included in a block or
// the timeout is reached. It returns the confirmed TxStatusResult on success,
// a TxError if the transaction executed but failed, or a timeout error.
func (c *Client) WaitForTx(txID string, timeout time.Duration) (*TxStatusResult, error) {
deadline := time.Now().Add(timeout)
interval := 200 * time.Millisecond
for {
result, err := c.GetTxStatus(txID)
if err != nil {
return nil, fmt.Errorf("getTxStatus: %w", err)
}
if result != nil {
if !result.Success {
return result, &TxError{TxID: txID, Message: result.Error}
}
return result, nil
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("transaction %s not confirmed within %s", txID, timeout)
}
time.Sleep(interval)
// Increase interval up to 1s to reduce polling pressure.
if interval < time.Second {
interval = interval * 3 / 2
if interval > time.Second {
interval = time.Second
}
}
}
}

View File

@@ -0,0 +1,333 @@
package chain
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// rpcHandler returns an http.HandlerFunc that responds with JSON-RPC results.
// The handleFn receives the method and params, and returns the result or an error string.
func rpcHandler(handleFn func(method string, params json.RawMessage) (any, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID any `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", 400)
return
}
result, errMsg := handleFn(req.Method, req.Params)
w.Header().Set("Content-Type", "application/json")
if errMsg != "" {
json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"error": map[string]any{"code": -32000, "message": errMsg},
})
return
}
resultJSON, _ := json.Marshal(result)
json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": json.RawMessage(resultJSON),
})
}
}
func TestWaitForTx_Success(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
if method != "getTxStatus" {
return nil, "unexpected method"
}
// First call returns null (not yet confirmed), second returns success
if calls.Add(1) == 1 {
return nil, ""
}
return &TxStatusResult{
TxID: "tx-123",
BlockHeight: 42,
Success: true,
}, ""
}))
defer srv.Close()
client := NewClient(srv.URL)
result, err := client.WaitForTx("tx-123", 5*time.Second)
if err != nil {
t.Fatalf("WaitForTx should succeed: %v", err)
}
if result.TxID != "tx-123" {
t.Errorf("TxID = %q, want %q", result.TxID, "tx-123")
}
if result.BlockHeight != 42 {
t.Errorf("BlockHeight = %d, want 42", result.BlockHeight)
}
if !result.Success {
t.Error("Success should be true")
}
if calls.Load() != 2 {
t.Errorf("expected 2 RPC calls, got %d", calls.Load())
}
}
func TestWaitForTx_Timeout(t *testing.T) {
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
// Always return null — transaction never confirms
return nil, ""
}))
defer srv.Close()
client := NewClient(srv.URL)
timeout := 500 * time.Millisecond
start := time.Now()
result, err := client.WaitForTx("tx-never", timeout)
elapsed := time.Since(start)
if err == nil {
t.Fatal("WaitForTx should return timeout error")
}
if result != nil {
t.Error("result should be nil on timeout")
}
if !strings.Contains(err.Error(), "not confirmed within") {
t.Errorf("error should mention timeout, got: %v", err)
}
// Should have waited at least the timeout duration
if elapsed < timeout {
t.Errorf("elapsed %v is less than timeout %v", elapsed, timeout)
}
}
func TestWaitForTx_TxFailure(t *testing.T) {
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
return &TxStatusResult{
TxID: "tx-fail",
BlockHeight: 10,
Success: false,
Error: "insufficient balance: have 0 need 100",
}, ""
}))
defer srv.Close()
client := NewClient(srv.URL)
result, err := client.WaitForTx("tx-fail", 5*time.Second)
if err == nil {
t.Fatal("WaitForTx should return TxError for failed transaction")
}
// Should return a TxError
var txErr *TxError
if !errors.As(err, &txErr) {
t.Fatalf("error should be *TxError, got %T: %v", err, err)
}
if txErr.TxID != "tx-fail" {
t.Errorf("TxError.TxID = %q, want %q", txErr.TxID, "tx-fail")
}
if !strings.Contains(txErr.Message, "insufficient balance") {
t.Errorf("TxError.Message should contain 'insufficient balance', got %q", txErr.Message)
}
// Result should still be returned even on TxError
if result == nil {
t.Fatal("result should be non-nil even on TxError")
}
if result.Success {
t.Error("result.Success should be false")
}
}
func TestWaitForTx_RPCError(t *testing.T) {
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
return nil, "internal server error"
}))
defer srv.Close()
client := NewClient(srv.URL)
result, err := client.WaitForTx("tx-rpc-err", 2*time.Second)
if err == nil {
t.Fatal("WaitForTx should return error on RPC failure")
}
if result != nil {
t.Error("result should be nil on RPC error")
}
if !strings.Contains(err.Error(), "getTxStatus") {
t.Errorf("error should wrap getTxStatus context, got: %v", err)
}
}
func TestSendTxAndWait_Success(t *testing.T) {
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
switch method {
case "sendTx":
return &SendTxResult{TxID: "tx-abc"}, ""
case "getTxStatus":
return &TxStatusResult{
TxID: "tx-abc",
BlockHeight: 5,
Success: true,
}, ""
default:
return nil, fmt.Sprintf("unexpected method: %s", method)
}
}))
defer srv.Close()
client := NewClient(srv.URL)
result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second)
if err != nil {
t.Fatalf("SendTxAndWait should succeed: %v", err)
}
if result.TxID != "tx-abc" {
t.Errorf("TxID = %q, want %q", result.TxID, "tx-abc")
}
if result.BlockHeight != 5 {
t.Errorf("BlockHeight = %d, want 5", result.BlockHeight)
}
}
func TestSendTxAndWait_SendError(t *testing.T) {
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
return nil, "mempool full"
}))
defer srv.Close()
client := NewClient(srv.URL)
result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second)
if err == nil {
t.Fatal("SendTxAndWait should fail when sendTx fails")
}
if result != nil {
t.Error("result should be nil on send error")
}
if !strings.Contains(err.Error(), "send tx") {
t.Errorf("error should wrap send tx context, got: %v", err)
}
}
func TestSendTxAndWait_TxFailure(t *testing.T) {
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
switch method {
case "sendTx":
return &SendTxResult{TxID: "tx-will-fail"}, ""
case "getTxStatus":
return &TxStatusResult{
TxID: "tx-will-fail",
BlockHeight: 7,
Success: false,
Error: "asset is not tradeable",
}, ""
default:
return nil, "unexpected"
}
}))
defer srv.Close()
client := NewClient(srv.URL)
result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second)
if err == nil {
t.Fatal("SendTxAndWait should return error for failed tx")
}
var txErr *TxError
if !errors.As(err, &txErr) {
t.Fatalf("error should be *TxError, got %T: %v", err, err)
}
if txErr.TxID != "tx-will-fail" {
t.Errorf("TxError.TxID = %q, want %q", txErr.TxID, "tx-will-fail")
}
// Result still returned with failure details
if result == nil {
t.Fatal("result should be non-nil even on TxError")
}
if result.Success {
t.Error("result.Success should be false")
}
}
func TestWaitForTx_PollingBackoff(t *testing.T) {
var calls atomic.Int32
confirmAfter := int32(5) // confirm on the 5th call
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
n := calls.Add(1)
if n < confirmAfter {
return nil, ""
}
return &TxStatusResult{
TxID: "tx-backoff",
BlockHeight: 99,
Success: true,
}, ""
}))
defer srv.Close()
client := NewClient(srv.URL)
result, err := client.WaitForTx("tx-backoff", 10*time.Second)
if err != nil {
t.Fatalf("WaitForTx should succeed: %v", err)
}
if result.TxID != "tx-backoff" {
t.Errorf("TxID = %q, want %q", result.TxID, "tx-backoff")
}
if calls.Load() != confirmAfter {
t.Errorf("expected %d RPC calls, got %d", confirmAfter, calls.Load())
}
}
func TestGetTxStatus_NotFound(t *testing.T) {
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
// Return null result — tx not yet in a block
return nil, ""
}))
defer srv.Close()
client := NewClient(srv.URL)
result, err := client.GetTxStatus("tx-pending")
if err != nil {
t.Fatalf("GetTxStatus should not error for pending tx: %v", err)
}
if result != nil {
t.Errorf("result should be nil for pending tx, got %+v", result)
}
}
func TestGetTxStatus_Found(t *testing.T) {
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
return &TxStatusResult{
TxID: "tx-done",
BlockHeight: 100,
Success: true,
}, ""
}))
defer srv.Close()
client := NewClient(srv.URL)
result, err := client.GetTxStatus("tx-done")
if err != nil {
t.Fatalf("GetTxStatus should succeed: %v", err)
}
if result == nil {
t.Fatal("result should not be nil for confirmed tx")
}
if result.TxID != "tx-done" || result.BlockHeight != 100 || !result.Success {
t.Errorf("unexpected result: %+v", result)
}
}

757
internal/chain/handler.go Normal file
View File

@@ -0,0 +1,757 @@
package chain
import (
"errors"
"log"
"strconv"
"strings"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
"github.com/tolelom/tolchain/core"
)
const maxLimit = 200
const maxIDLength = 256 // max length for string IDs (assetId, listingId, etc.)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func getUserID(c *fiber.Ctx) (uint, error) {
uid, ok := c.Locals("userID").(uint)
if !ok {
return 0, apperror.ErrUnauthorized
}
return uid, nil
}
func parsePagination(c *fiber.Ctx) (int, int) {
offset, _ := strconv.Atoi(c.Query("offset", "0"))
limit, _ := strconv.Atoi(c.Query("limit", "50"))
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = 50
} else if limit > maxLimit {
limit = maxLimit
}
return offset, limit
}
func validID(s string) bool {
return s != "" && len(s) <= maxIDLength
}
// chainError classifies chain errors into appropriate HTTP responses.
// TxError (on-chain execution failure) maps to 422 with the chain's error detail.
// Other errors (network, timeout, build failures) remain 500.
func chainError(userMsg string, err error) *apperror.AppError {
log.Printf("chain error: %s: %v", userMsg, err)
var txErr *TxError
if errors.As(err, &txErr) {
msg := classifyTxError(txErr.Message)
return apperror.New("tx_failed", msg, 422)
}
return apperror.Internal(userMsg)
}
// classifyTxError translates raw chain error messages into user-friendly Korean messages.
func classifyTxError(chainMsg string) string {
lower := strings.ToLower(chainMsg)
switch {
case strings.Contains(lower, "insufficient balance"):
return "잔액이 부족합니다"
case strings.Contains(lower, "unauthorized"):
return "권한이 없습니다"
case strings.Contains(lower, "already listed"):
return "이미 마켓에 등록된 아이템입니다"
case strings.Contains(lower, "already exists"):
return "이미 존재합니다"
case strings.Contains(lower, "not found"):
return "리소스를 찾을 수 없습니다"
case strings.Contains(lower, "not tradeable"):
return "거래할 수 없는 아이템입니다"
case strings.Contains(lower, "equipped"):
return "장착 중인 아이템입니다"
case strings.Contains(lower, "not active"):
return "활성 상태가 아닌 매물입니다"
case strings.Contains(lower, "not open"):
return "진행 중이 아닌 세션입니다"
case strings.Contains(lower, "invalid nonce"):
return "트랜잭션 처리 중 오류가 발생했습니다. 다시 시도해주세요"
default:
return "블록체인 트랜잭션이 실패했습니다"
}
}
// ---- Query Handlers ----
// GetWalletInfo godoc
// @Summary 지갑 정보 조회
// @Description 현재 유저의 블록체인 지갑 정보를 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.WalletInfoResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/chain/wallet [get]
func (h *Handler) GetWalletInfo(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
w, err := h.svc.GetWallet(userID)
if err != nil {
return apperror.NotFound("지갑을 찾을 수 없습니다")
}
return c.JSON(fiber.Map{
"address": w.Address,
"pubKeyHex": w.PubKeyHex,
})
}
// GetBalance godoc
// @Summary 잔액 조회
// @Description 현재 유저의 토큰 잔액을 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/balance [get]
func (h *Handler) GetBalance(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
result, err := h.svc.GetBalance(userID)
if err != nil {
return chainError("잔액 조회에 실패했습니다", err)
}
return c.JSON(result)
}
// GetAssets godoc
// @Summary 에셋 목록 조회
// @Description 현재 유저의 블록체인 에셋 목록을 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Param offset query int false "시작 위치" default(0)
// @Param limit query int false "조회 수" default(50)
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/assets [get]
func (h *Handler) GetAssets(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
offset, limit := parsePagination(c)
result, err := h.svc.GetAssets(userID, offset, limit)
if err != nil {
return chainError("에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
// GetAsset godoc
// @Summary 에셋 상세 조회
// @Description 특정 에셋의 상세 정보를 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Param id path string true "에셋 ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/asset/{id} [get]
func (h *Handler) GetAsset(c *fiber.Ctx) error {
assetID := c.Params("id")
if !validID(assetID) {
return apperror.BadRequest("유효한 asset id가 필요합니다")
}
result, err := h.svc.GetAsset(assetID)
if err != nil {
return chainError("에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
// GetInventory godoc
// @Summary 인벤토리 조회
// @Description 현재 유저의 인벤토리를 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/inventory [get]
func (h *Handler) GetInventory(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
result, err := h.svc.GetInventory(userID)
if err != nil {
return chainError("인벤토리 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
// GetMarketListings godoc
// @Summary 마켓 목록 조회
// @Description 마켓에 등록된 매물 목록을 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Param offset query int false "시작 위치" default(0)
// @Param limit query int false "조회 수" default(50)
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/market [get]
func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
offset, limit := parsePagination(c)
result, err := h.svc.GetMarketListings(offset, limit)
if err != nil {
return chainError("마켓 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
// GetMarketListing godoc
// @Summary 마켓 매물 상세 조회
// @Description 특정 마켓 매물의 상세 정보를 조회합니다
// @Tags Chain
// @Produce json
// @Security BearerAuth
// @Param id path string true "매물 ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/market/{id} [get]
func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
listingID := c.Params("id")
if !validID(listingID) {
return apperror.BadRequest("유효한 listing id가 필요합니다")
}
result, err := h.svc.GetListing(listingID)
if err != nil {
return chainError("마켓 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
// ---- User Transaction Handlers ----
// Transfer godoc
// @Summary 토큰 전송
// @Description 다른 유저에게 토큰을 전송합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.TransferRequest true "전송 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/transfer [post]
func (h *Handler) Transfer(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
To string `json:"to"`
Amount uint64 `json:"amount"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.To) || req.Amount == 0 {
return apperror.BadRequest("to와 amount는 필수입니다")
}
result, err := h.svc.Transfer(userID, req.To, req.Amount)
if err != nil {
return chainError("전송에 실패했습니다", err)
}
return c.JSON(result)
}
// TransferAsset godoc
// @Summary 에셋 전송
// @Description 다른 유저에게 에셋을 전송합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.TransferAssetRequest true "전송 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/asset/transfer [post]
func (h *Handler) TransferAsset(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
AssetID string `json:"assetId"`
To string `json:"to"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.AssetID) || !validID(req.To) {
return apperror.BadRequest("assetId와 to는 필수입니다")
}
result, err := h.svc.TransferAsset(userID, req.AssetID, req.To)
if err != nil {
return chainError("에셋 전송에 실패했습니다", err)
}
return c.JSON(result)
}
// ListOnMarket godoc
// @Summary 마켓 등록
// @Description 에셋을 마켓에 등록합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.ListOnMarketRequest true "등록 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/market/list [post]
func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
AssetID string `json:"assetId"`
Price uint64 `json:"price"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.AssetID) || req.Price == 0 {
return apperror.BadRequest("assetId와 price는 필수입니다")
}
result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price)
if err != nil {
return chainError("마켓 등록에 실패했습니다", err)
}
return c.JSON(result)
}
// BuyFromMarket godoc
// @Summary 마켓 구매
// @Description 마켓에서 매물을 구매합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.BuyFromMarketRequest true "구매 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/market/buy [post]
func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
ListingID string `json:"listingId"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.ListingID) {
return apperror.BadRequest("listingId는 필수입니다")
}
result, err := h.svc.BuyFromMarket(userID, req.ListingID)
if err != nil {
return chainError("마켓 구매에 실패했습니다", err)
}
return c.JSON(result)
}
// CancelListing godoc
// @Summary 마켓 등록 취소
// @Description 마켓에 등록한 매물을 취소합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.CancelListingRequest true "취소 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/market/cancel [post]
func (h *Handler) CancelListing(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
ListingID string `json:"listingId"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.ListingID) {
return apperror.BadRequest("listingId는 필수입니다")
}
result, err := h.svc.CancelListing(userID, req.ListingID)
if err != nil {
return chainError("마켓 취소에 실패했습니다", err)
}
return c.JSON(result)
}
// EquipItem godoc
// @Summary 아이템 장착
// @Description 에셋을 장비 슬롯에 장착합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.EquipItemRequest true "장착 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/inventory/equip [post]
func (h *Handler) EquipItem(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
AssetID string `json:"assetId"`
Slot string `json:"slot"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.AssetID) || !validID(req.Slot) {
return apperror.BadRequest("assetId와 slot은 필수입니다")
}
result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot)
if err != nil {
return chainError("장착에 실패했습니다", err)
}
return c.JSON(result)
}
// UnequipItem godoc
// @Summary 아이템 장착 해제
// @Description 에셋의 장비 슬롯 장착을 해제합니다
// @Tags Chain - Transactions
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.UnequipItemRequest true "해제 정보"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/inventory/unequip [post]
func (h *Handler) UnequipItem(c *fiber.Ctx) error {
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
AssetID string `json:"assetId"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.AssetID) {
return apperror.BadRequest("assetId는 필수입니다")
}
result, err := h.svc.UnequipItem(userID, req.AssetID)
if err != nil {
return chainError("장착 해제에 실패했습니다", err)
}
return c.JSON(result)
}
// ---- Operator (Admin) Transaction Handlers ----
// MintAsset godoc
// @Summary 에셋 발행 (관리자)
// @Description 새 에셋을 발행합니다
// @Tags Chain - Admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.MintAssetRequest true "발행 정보"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/admin/mint [post]
func (h *Handler) MintAsset(c *fiber.Ctx) error {
var req struct {
TemplateID string `json:"templateId"`
OwnerPubKey string `json:"ownerPubKey"`
Properties map[string]any `json:"properties"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.TemplateID) || !validID(req.OwnerPubKey) {
return apperror.BadRequest("templateId와 ownerPubKey는 필수입니다")
}
result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties)
if err != nil {
return chainError("에셋 발행에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// GrantReward godoc
// @Summary 보상 지급 (관리자)
// @Description 유저에게 토큰 및 에셋 보상을 지급합니다
// @Tags Chain - Admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.GrantRewardRequest true "보상 정보"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/admin/reward [post]
func (h *Handler) GrantReward(c *fiber.Ctx) error {
var req struct {
RecipientPubKey string `json:"recipientPubKey"`
TokenAmount uint64 `json:"tokenAmount"`
Assets []core.MintAssetPayload `json:"assets"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.RecipientPubKey) {
return apperror.BadRequest("recipientPubKey는 필수입니다")
}
result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets)
if err != nil {
return chainError("보상 지급에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// RegisterTemplate godoc
// @Summary 템플릿 등록 (관리자)
// @Description 새 에셋 템플릿을 등록합니다
// @Tags Chain - Admin
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.RegisterTemplateRequest true "템플릿 정보"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/chain/admin/template [post]
func (h *Handler) RegisterTemplate(c *fiber.Ctx) error {
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Schema map[string]any `json:"schema"`
Tradeable bool `json:"tradeable"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.ID) || !validID(req.Name) {
return apperror.BadRequest("id와 name은 필수입니다")
}
result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable)
if err != nil {
return chainError("템플릿 등록에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// ---- Internal Handlers (game server, username-based) ----
// InternalGrantReward godoc
// @Summary 보상 지급 (내부 API)
// @Description username으로 유저에게 보상을 지급합니다 (게임 서버용)
// @Tags Internal - Chain
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.InternalGrantRewardRequest true "보상 정보"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/chain/reward [post]
func (h *Handler) InternalGrantReward(c *fiber.Ctx) error {
var req struct {
Username string `json:"username"`
TokenAmount uint64 `json:"tokenAmount"`
Assets []core.MintAssetPayload `json:"assets"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.Username) {
return apperror.BadRequest("username은 필수입니다")
}
result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets)
if err != nil {
return chainError("보상 지급에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// InternalMintAsset godoc
// @Summary 에셋 발행 (내부 API)
// @Description username으로 에셋을 발행합니다 (게임 서버용)
// @Tags Internal - Chain
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param Idempotency-Key header string true "멱등성 키"
// @Param body body docs.InternalMintAssetRequest true "발행 정보"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/chain/mint [post]
func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
var req struct {
TemplateID string `json:"templateId"`
Username string `json:"username"`
Properties map[string]any `json:"properties"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if !validID(req.TemplateID) || !validID(req.Username) {
return apperror.BadRequest("templateId와 username은 필수입니다")
}
result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties)
if err != nil {
return chainError("에셋 발행에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// InternalGetBalance godoc
// @Summary 잔액 조회 (내부 API)
// @Description username으로 잔액을 조회합니다 (게임 서버용)
// @Tags Internal - Chain
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "유저명"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/chain/balance [get]
func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
return apperror.BadRequest("username은 필수입니다")
}
result, err := h.svc.GetBalanceByUsername(username)
if err != nil {
return chainError("잔액 조회에 실패했습니다", err)
}
return c.JSON(result)
}
// InternalGetAssets godoc
// @Summary 에셋 목록 조회 (내부 API)
// @Description username으로 에셋 목록을 조회합니다 (게임 서버용)
// @Tags Internal - Chain
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "유저명"
// @Param offset query int false "시작 위치" default(0)
// @Param limit query int false "조회 수" default(50)
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/chain/assets [get]
func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
return apperror.BadRequest("username은 필수입니다")
}
offset, limit := parsePagination(c)
result, err := h.svc.GetAssetsByUsername(username, offset, limit)
if err != nil {
return chainError("에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
// InternalGetInventory godoc
// @Summary 인벤토리 조회 (내부 API)
// @Description username으로 인벤토리를 조회합니다 (게임 서버용)
// @Tags Internal - Chain
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "유저명"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/chain/inventory [get]
func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
username := c.Query("username")
if !validID(username) {
return apperror.BadRequest("username은 필수입니다")
}
result, err := h.svc.GetInventoryByUsername(username)
if err != nil {
return chainError("인벤토리 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}

20
internal/chain/model.go Normal file
View File

@@ -0,0 +1,20 @@
package chain
import (
"time"
"gorm.io/gorm"
)
// UserWallet stores an encrypted ed25519 keypair linked to a user.
type UserWallet struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
UserID uint `json:"userId" gorm:"uniqueIndex;not null"`
PubKeyHex string `json:"pubKeyHex" gorm:"type:varchar(64);uniqueIndex;not null"`
Address string `json:"address" gorm:"type:varchar(40);uniqueIndex;not null"`
EncryptedPrivKey string `json:"-" gorm:"type:varchar(512);not null"`
EncNonce string `json:"-" gorm:"type:varchar(48);not null"`
}

View File

@@ -0,0 +1,31 @@
package chain
import "gorm.io/gorm"
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Create(w *UserWallet) error {
return r.db.Create(w).Error
}
func (r *Repository) FindByUserID(userID uint) (*UserWallet, error) {
var w UserWallet
if err := r.db.Where("user_id = ?", userID).First(&w).Error; err != nil {
return nil, err
}
return &w, nil
}
func (r *Repository) FindByPubKeyHex(pubKeyHex string) (*UserWallet, error) {
var w UserWallet
if err := r.db.Where("pub_key_hex = ?", pubKeyHex).First(&w).Error; err != nil {
return nil, err
}
return &w, nil
}

403
internal/chain/service.go Normal file
View File

@@ -0,0 +1,403 @@
package chain
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"sync"
"time"
"github.com/tolelom/tolchain/core"
tocrypto "github.com/tolelom/tolchain/crypto"
"github.com/tolelom/tolchain/wallet"
)
type Service struct {
repo *Repository
client *Client
chainID string
operatorWallet *wallet.Wallet
encKeyBytes []byte // 32-byte AES-256 key
userResolver func(username string) (uint, error)
operatorMu sync.Mutex // serialises operator-nonce transactions
userMu sync.Map // per-user mutex (keyed by userID uint)
}
// SetUserResolver sets the callback that resolves username → userID.
func (s *Service) SetUserResolver(fn func(username string) (uint, error)) {
s.userResolver = fn
}
// resolveUsername converts a username to the user's on-chain pubKeyHex.
// If the user exists but has no wallet (e.g. legacy user or failed creation),
// a wallet is auto-created on the fly.
func (s *Service) resolveUsername(username string) (string, error) {
if s.userResolver == nil {
return "", fmt.Errorf("user resolver not configured")
}
userID, err := s.userResolver(username)
if err != nil {
return "", fmt.Errorf("user not found")
}
uw, err := s.repo.FindByUserID(userID)
if err != nil {
// 지갑이 없으면 자동 생성 시도
uw, err = s.CreateWallet(userID)
if err != nil {
// unique constraint 위반 — 다른 고루틴이 먼저 생성 완료
uw, err = s.repo.FindByUserID(userID)
if err != nil {
return "", fmt.Errorf("wallet auto-creation failed: %w", err)
}
} else {
log.Printf("INFO: auto-created wallet for userID=%d (username=%s)", userID, username)
}
}
return uw.PubKeyHex, nil
}
func NewService(
repo *Repository,
client *Client,
chainID string,
operatorKeyHex string,
walletEncKeyHex string,
) (*Service, error) {
encKey, err := hex.DecodeString(walletEncKeyHex)
if err != nil || len(encKey) != 32 {
return nil, fmt.Errorf("WALLET_ENCRYPTION_KEY must be 64 hex chars (32 bytes)")
}
var opWallet *wallet.Wallet
if operatorKeyHex != "" {
privKey, err := tocrypto.PrivKeyFromHex(operatorKeyHex)
if err != nil {
return nil, fmt.Errorf("invalid OPERATOR_KEY_HEX: %w", err)
}
opWallet = wallet.New(privKey)
}
return &Service{
repo: repo,
client: client,
chainID: chainID,
operatorWallet: opWallet,
encKeyBytes: encKey,
}, nil
}
// ---- Wallet Encryption (AES-256-GCM) ----
func (s *Service) encryptPrivKey(privKey tocrypto.PrivateKey) (cipherHex, nonceHex string, err error) {
block, err := aes.NewCipher(s.encKeyBytes)
if err != nil {
return "", "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", "", err
}
cipherText := gcm.Seal(nil, nonce, []byte(privKey), nil)
return hex.EncodeToString(cipherText), hex.EncodeToString(nonce), nil
}
func (s *Service) decryptPrivKey(cipherHex, nonceHex string) (tocrypto.PrivateKey, error) {
cipherText, err := hex.DecodeString(cipherHex)
if err != nil {
return nil, err
}
nonce, err := hex.DecodeString(nonceHex)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(s.encKeyBytes)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
plaintext, err := gcm.Open(nil, nonce, cipherText, nil)
if err != nil {
return nil, fmt.Errorf("wallet decryption failed: %w", err)
}
return tocrypto.PrivateKey(plaintext), nil
}
// ---- Wallet Management ----
// CreateWallet generates a new keypair, encrypts it, and stores in DB.
func (s *Service) CreateWallet(userID uint) (*UserWallet, error) {
w, err := wallet.Generate()
if err != nil {
return nil, fmt.Errorf("key generation failed: %w", err)
}
cipherHex, nonceHex, err := s.encryptPrivKey(w.PrivKey())
if err != nil {
return nil, fmt.Errorf("key encryption failed: %w", err)
}
uw := &UserWallet{
UserID: userID,
PubKeyHex: w.PubKey(),
Address: w.Address(),
EncryptedPrivKey: cipherHex,
EncNonce: nonceHex,
}
if err := s.repo.Create(uw); err != nil {
return nil, fmt.Errorf("wallet save failed: %w", err)
}
return uw, nil
}
func (s *Service) GetWallet(userID uint) (*UserWallet, error) {
return s.repo.FindByUserID(userID)
}
// loadUserWallet decrypts a user's private key and returns a wallet.Wallet.
func (s *Service) loadUserWallet(userID uint) (*wallet.Wallet, string, error) {
uw, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, "", fmt.Errorf("wallet not found: %w", err)
}
privKey, err := s.decryptPrivKey(uw.EncryptedPrivKey, uw.EncNonce)
if err != nil {
log.Printf("WARNING: wallet decryption failed for userID=%d: %v", userID, err)
return nil, "", fmt.Errorf("wallet decryption failed")
}
return wallet.New(privKey), uw.PubKeyHex, nil
}
func (s *Service) getNonce(address string) (uint64, error) {
bal, err := s.client.GetBalance(address)
if err != nil {
return 0, fmt.Errorf("get nonce failed: %w", err)
}
return bal.Nonce, nil
}
// txConfirmTimeout is the maximum time to wait for a transaction to be
// included in a block. PoA block intervals are typically a few seconds,
// so 15s provides ample margin.
const txConfirmTimeout = 15 * time.Second
// submitTx sends a signed transaction and waits for block confirmation.
// Returns the confirmed status or an error (including TxError for on-chain failures).
func (s *Service) submitTx(tx any) (*TxStatusResult, error) {
return s.client.SendTxAndWait(tx, txConfirmTimeout)
}
// ---- Query Methods ----
func (s *Service) GetBalance(userID uint) (*BalanceResult, error) {
uw, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("wallet not found: %w", err)
}
return s.client.GetBalance(uw.PubKeyHex)
}
func (s *Service) GetAssets(userID uint, offset, limit int) (json.RawMessage, error) {
uw, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("wallet not found: %w", err)
}
return s.client.GetAssetsByOwner(uw.PubKeyHex, offset, limit)
}
func (s *Service) GetAsset(assetID string) (json.RawMessage, error) {
return s.client.GetAsset(assetID)
}
func (s *Service) GetInventory(userID uint) (json.RawMessage, error) {
uw, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("wallet not found: %w", err)
}
return s.client.GetInventory(uw.PubKeyHex)
}
func (s *Service) GetMarketListings(offset, limit int) (json.RawMessage, error) {
return s.client.GetActiveListings(offset, limit)
}
func (s *Service) GetListing(listingID string) (json.RawMessage, error) {
return s.client.GetListing(listingID)
}
// getUserMu returns a per-user mutex, creating one if it doesn't exist.
func (s *Service) getUserMu(userID uint) *sync.Mutex {
v, _ := s.userMu.LoadOrStore(userID, &sync.Mutex{})
return v.(*sync.Mutex)
}
// ---- User Transaction Methods ----
// userTx handles the common boilerplate for user transactions:
// acquire per-user mutex → load wallet → get nonce → build tx → submit.
func (s *Service) userTx(userID uint, buildFn func(w *wallet.Wallet, nonce uint64) (any, error)) (*TxStatusResult, error) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := buildFn(w, nonce)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.submitTx(tx)
}
func (s *Service) Transfer(userID uint, to string, amount uint64) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.Transfer(s.chainID, to, amount, nonce, 0)
})
}
func (s *Service) TransferAsset(userID uint, assetID, to string) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.TransferAsset(s.chainID, assetID, to, nonce, 0)
})
}
func (s *Service) ListOnMarket(userID uint, assetID string, price uint64) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.ListMarket(s.chainID, assetID, price, nonce, 0)
})
}
func (s *Service) BuyFromMarket(userID uint, listingID string) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.BuyMarket(s.chainID, listingID, nonce, 0)
})
}
func (s *Service) CancelListing(userID uint, listingID string) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.CancelListing(s.chainID, listingID, nonce, 0)
})
}
func (s *Service) EquipItem(userID uint, assetID, slot string) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.EquipItem(s.chainID, assetID, slot, nonce, 0)
})
}
func (s *Service) UnequipItem(userID uint, assetID string) (*TxStatusResult, error) {
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
return w.UnequipItem(s.chainID, assetID, nonce, 0)
})
}
// ---- Operator Transaction Methods ----
func (s *Service) ensureOperator() error {
if s.operatorWallet == nil {
return fmt.Errorf("operator wallet not configured")
}
return nil
}
func (s *Service) getOperatorNonce() (uint64, error) {
if err := s.ensureOperator(); err != nil {
return 0, err
}
return s.getNonce(s.operatorWallet.PubKey())
}
// operatorTx handles the common boilerplate for operator transactions:
// acquire operator mutex → ensure operator → get nonce → build tx → submit.
func (s *Service) operatorTx(buildFn func(nonce uint64) (any, error)) (*TxStatusResult, error) {
s.operatorMu.Lock()
defer s.operatorMu.Unlock()
if err := s.ensureOperator(); err != nil {
return nil, err
}
nonce, err := s.getOperatorNonce()
if err != nil {
return nil, err
}
tx, err := buildFn(nonce)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.submitTx(tx)
}
func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[string]any) (*TxStatusResult, error) {
return s.operatorTx(func(nonce uint64) (any, error) {
return s.operatorWallet.MintAsset(s.chainID, templateID, ownerPubKey, properties, nonce, 0)
})
}
func (s *Service) GrantReward(recipientPubKey string, tokenAmount uint64, assets []core.MintAssetPayload) (*TxStatusResult, error) {
return s.operatorTx(func(nonce uint64) (any, error) {
return s.operatorWallet.GrantReward(s.chainID, recipientPubKey, tokenAmount, assets, nonce, 0)
})
}
func (s *Service) RegisterTemplate(id, name string, schema map[string]any, tradeable bool) (*TxStatusResult, error) {
return s.operatorTx(func(nonce uint64) (any, error) {
return s.operatorWallet.RegisterTemplate(s.chainID, id, name, schema, tradeable, nonce, 0)
})
}
// ---- Username-based Methods (for game server) ----
func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, assets []core.MintAssetPayload) (*TxStatusResult, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
}
return s.GrantReward(pubKey, tokenAmount, assets)
}
func (s *Service) MintAssetByUsername(templateID, username string, properties map[string]any) (*TxStatusResult, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
}
return s.MintAsset(templateID, pubKey, properties)
}
func (s *Service) GetBalanceByUsername(username string) (*BalanceResult, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
}
return s.client.GetBalance(pubKey)
}
func (s *Service) GetAssetsByUsername(username string, offset, limit int) (json.RawMessage, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
}
return s.client.GetAssetsByOwner(pubKey, offset, limit)
}
func (s *Service) GetInventoryByUsername(username string) (json.RawMessage, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
}
return s.client.GetInventory(pubKey)
}

View File

@@ -0,0 +1,271 @@
package chain
import (
"encoding/hex"
"testing"
tocrypto "github.com/tolelom/tolchain/crypto"
)
// testEncKey returns a valid 32-byte AES-256 key for testing.
func testEncKey() []byte {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
return key
}
// newTestService creates a minimal Service with only the encryption key set.
// No DB, Redis, or chain client — only suitable for testing pure crypto functions.
func newTestService() *Service {
return &Service{
encKeyBytes: testEncKey(),
}
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
svc := newTestService()
// Generate a real ed25519 private key
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
// Encrypt
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey failed: %v", err)
}
if cipherHex == "" || nonceHex == "" {
t.Fatal("encryptPrivKey returned empty strings")
}
// Verify ciphertext is valid hex
if _, err := hex.DecodeString(cipherHex); err != nil {
t.Errorf("cipherHex is not valid hex: %v", err)
}
if _, err := hex.DecodeString(nonceHex); err != nil {
t.Errorf("nonceHex is not valid hex: %v", err)
}
// Decrypt
decrypted, err := svc.decryptPrivKey(cipherHex, nonceHex)
if err != nil {
t.Fatalf("decryptPrivKey failed: %v", err)
}
// Compare
if hex.EncodeToString(decrypted) != hex.EncodeToString(privKey) {
t.Error("decrypted key does not match original")
}
}
func TestEncryptDecrypt_DifferentKeysProduceDifferentCiphertext(t *testing.T) {
svc := newTestService()
privKey1, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair 1: %v", err)
}
privKey2, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair 2: %v", err)
}
cipher1, _, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey1))
if err != nil {
t.Fatalf("encryptPrivKey 1 failed: %v", err)
}
cipher2, _, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey2))
if err != nil {
t.Fatalf("encryptPrivKey 2 failed: %v", err)
}
if cipher1 == cipher2 {
t.Error("different private keys should produce different ciphertexts")
}
}
func TestEncryptSameKey_DifferentNonces(t *testing.T) {
svc := newTestService()
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
cipher1, nonce1, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey 1 failed: %v", err)
}
cipher2, nonce2, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey 2 failed: %v", err)
}
// Each encryption should use a different random nonce
if nonce1 == nonce2 {
t.Error("encrypting the same key twice should use different nonces")
}
// So ciphertext should also differ (AES-GCM is nonce-dependent)
if cipher1 == cipher2 {
t.Error("encrypting the same key with different nonces should produce different ciphertexts")
}
}
func TestDecryptWithWrongKey(t *testing.T) {
svc := newTestService()
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey failed: %v", err)
}
// Create a service with a different encryption key
wrongKey := make([]byte, 32)
for i := range wrongKey {
wrongKey[i] = byte(255 - i)
}
wrongSvc := &Service{encKeyBytes: wrongKey}
_, err = wrongSvc.decryptPrivKey(cipherHex, nonceHex)
if err == nil {
t.Error("decryptPrivKey with wrong key should fail")
}
}
func TestDecryptWithInvalidHex(t *testing.T) {
svc := newTestService()
_, err := svc.decryptPrivKey("not-hex", "also-not-hex")
if err == nil {
t.Error("decryptPrivKey with invalid hex should fail")
}
}
func TestDecryptWithTamperedCiphertext(t *testing.T) {
svc := newTestService()
privKey, _, err := tocrypto.GenerateKeyPair()
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
cipherHex, nonceHex, err := svc.encryptPrivKey(tocrypto.PrivateKey(privKey))
if err != nil {
t.Fatalf("encryptPrivKey failed: %v", err)
}
// Tamper with the ciphertext by flipping a byte
cipherBytes, _ := hex.DecodeString(cipherHex)
cipherBytes[0] ^= 0xFF
tamperedHex := hex.EncodeToString(cipherBytes)
_, err = svc.decryptPrivKey(tamperedHex, nonceHex)
if err == nil {
t.Error("decryptPrivKey with tampered ciphertext should fail")
}
}
func TestNewService_InvalidEncryptionKey(t *testing.T) {
tests := []struct {
name string
encKey string
}{
{"too short", "aabbccdd"},
{"not hex", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"},
{"empty", ""},
{"odd length", "aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewService(nil, nil, "test-chain", "", tt.encKey)
if err == nil {
t.Error("NewService should fail with invalid encryption key")
}
})
}
}
func TestNewService_ValidEncryptionKey(t *testing.T) {
// 64 hex chars = 32 bytes
validKey := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
svc, err := NewService(nil, nil, "test-chain", "", validKey)
if err != nil {
t.Fatalf("NewService with valid key should succeed: %v", err)
}
if svc == nil {
t.Fatal("NewService returned nil service")
}
if svc.chainID != "test-chain" {
t.Errorf("chainID = %q, want %q", svc.chainID, "test-chain")
}
// No operator key provided, so operatorWallet should be nil
if svc.operatorWallet != nil {
t.Error("operatorWallet should be nil when no operator key is provided")
}
}
func TestEnsureOperator_NilWallet(t *testing.T) {
svc := newTestService()
err := svc.ensureOperator()
if err == nil {
t.Error("ensureOperator should fail when operatorWallet is nil")
}
}
func TestResolveUsername_NoResolver(t *testing.T) {
svc := newTestService()
_, err := svc.resolveUsername("testuser")
if err == nil {
t.Error("resolveUsername should fail when userResolver is nil")
}
}
func TestClassifyTxError(t *testing.T) {
tests := []struct {
chainMsg string
want string
}{
{"insufficient balance: have 0 need 100: insufficient balance", "잔액이 부족합니다"},
{"only the asset owner can list it: unauthorized", "권한이 없습니다"},
{"session \"abc\" already exists: already exists", "이미 존재합니다"},
{"asset \"xyz\" not found: not found", "리소스를 찾을 수 없습니다"},
{"asset is not tradeable", "거래할 수 없는 아이템입니다"},
{"asset \"a\" is equipped; unequip it before listing", "장착 중인 아이템입니다"},
{"asset \"a\" is already listed (listing x): already exists", "이미 마켓에 등록된 아이템입니다"},
{"listing \"x\" is not active", "활성 상태가 아닌 매물입니다"},
{"session \"x\" is not open (status=closed)", "진행 중이 아닌 세션입니다"},
{"invalid nonce: expected 5 got 3: invalid nonce", "트랜잭션 처리 중 오류가 발생했습니다. 다시 시도해주세요"},
{"some unknown error", "블록체인 트랜잭션이 실패했습니다"},
}
for _, tt := range tests {
t.Run(tt.chainMsg, func(t *testing.T) {
got := classifyTxError(tt.chainMsg)
if got != tt.want {
t.Errorf("classifyTxError(%q) = %q, want %q", tt.chainMsg, got, tt.want)
}
})
}
}
func TestTxError_Error(t *testing.T) {
err := &TxError{TxID: "abc123", Message: "insufficient balance"}
got := err.Error()
want := "transaction abc123 failed: insufficient balance"
if got != want {
t.Errorf("TxError.Error() = %q, want %q", got, want)
}
}

View File

@@ -1,36 +1,149 @@
package download
import "github.com/gofiber/fiber/v2"
import (
"log"
"mime"
"os"
"path/filepath"
"strings"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
// Download API 하위 호환성 규칙:
// - 기존 필드 삭제 금지 (런처 바이너리가 필드에 의존)
// - 기존 필드 타입 변경 금지
// - 기존 필드명(JSON key) 변경 금지
// - 신규 필드 추가만 허용 (기존 런처는 unknown 필드를 무시)
// - 스키마 변경 시 downloadAPIVersion 값을 올릴 것
//
// 현재 /api/download/info 응답 필드 (v1):
// id, createdAt, updatedAt, url, version, fileName, fileSize,
// fileHash, launcherUrl, launcherSize, launcherHash
const downloadAPIVersion = "1"
type Handler struct {
svc *Service
svc *Service
baseURL string
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
func NewHandler(svc *Service, baseURL string) *Handler {
return &Handler{svc: svc, baseURL: baseURL}
}
// GetInfo godoc
// @Summary 다운로드 정보 조회
// @Description 게임 및 런처 다운로드 정보를 조회합니다
// @Tags Download
// @Produce json
// @Success 200 {object} docs.DownloadInfoResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/download/info [get]
func (h *Handler) GetInfo(c *fiber.Ctx) error {
info, err := h.svc.GetInfo()
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "다운로드 정보가 없습니다"})
return apperror.NotFound("다운로드 정보가 없습니다")
}
c.Set("X-API-Version", downloadAPIVersion)
return c.JSON(info)
}
// Upload godoc
// @Summary 게임 파일 업로드 (관리자)
// @Description 게임 zip 파일을 스트리밍 업로드합니다. Body는 raw binary입니다.
// @Tags Download
// @Accept application/octet-stream
// @Produce json
// @Security BearerAuth
// @Param filename query string false "파일명" default(game.zip)
// @Success 200 {object} docs.DownloadInfoResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/download/upload/game [post]
func (h *Handler) Upload(c *fiber.Ctx) error {
filename := strings.TrimSpace(c.Query("filename", "game.zip"))
// 경로 순회 방지: 디렉토리 구분자 제거, 기본 파일명만 사용
filename = filepath.Base(filename)
if !strings.HasSuffix(strings.ToLower(filename), ".zip") {
return apperror.BadRequest("zip 파일만 업로드 가능합니다")
}
if len(filename) > 200 {
return apperror.BadRequest("파일명이 너무 깁니다")
}
body := c.Request().BodyStream()
info, err := h.svc.Upload(filename, body, h.baseURL)
if err != nil {
log.Printf("game upload failed: %v", err)
return apperror.Internal("게임 파일 업로드에 실패했습니다")
}
return c.JSON(info)
}
func (h *Handler) Upsert(c *fiber.Ctx) error {
var body struct {
URL string `json:"url"`
Version string `json:"version"`
FileName string `json:"fileName"`
FileSize string `json:"fileSize"`
// ServeFile godoc
// @Summary 게임 파일 다운로드
// @Description 게임 zip 파일을 다운로드합니다
// @Tags Download
// @Produce application/octet-stream
// @Success 200 {file} binary
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/download/file [get]
func (h *Handler) ServeFile(c *fiber.Ctx) error {
path := h.svc.GameFilePath()
if _, err := os.Stat(path); err != nil {
return apperror.NotFound("파일이 없습니다")
}
if err := c.BodyParser(&body); err != nil || body.URL == "" || body.Version == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url과 version은 필수입니다"})
info, _ := h.svc.GetInfo()
filename := "game.zip"
if info != nil && info.FileName != "" {
filename = info.FileName
}
info, err := h.svc.Upsert(body.URL, body.Version, body.FileName, body.FileSize)
c.Set("X-API-Version", downloadAPIVersion)
c.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
return c.SendFile(path)
}
// UploadLauncher godoc
// @Summary 런처 업로드 (관리자)
// @Description 런처 실행 파일을 스트리밍 업로드합니다. Body는 raw binary입니다.
// @Tags Download
// @Accept application/octet-stream
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.DownloadInfoResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 403 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/download/upload/launcher [post]
func (h *Handler) UploadLauncher(c *fiber.Ctx) error {
body := c.Request().BodyStream()
info, err := h.svc.UploadLauncher(body, h.baseURL)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "업데이트에 실패했습니다"})
log.Printf("launcher upload failed: %v", err)
return apperror.Internal("런처 업로드에 실패했습니다")
}
return c.JSON(info)
}
// ServeLauncher godoc
// @Summary 런처 다운로드
// @Description 런처 실행 파일을 다운로드합니다
// @Tags Download
// @Produce application/octet-stream
// @Success 200 {file} binary
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/download/launcher [get]
func (h *Handler) ServeLauncher(c *fiber.Ctx) error {
path := h.svc.LauncherFilePath()
if _, err := os.Stat(path); err != nil {
return apperror.NotFound("파일이 없습니다")
}
c.Set("X-API-Version", downloadAPIVersion)
c.Set("Content-Disposition", `attachment; filename="launcher.exe"`)
return c.SendFile(path)
}

View File

@@ -1,11 +1,26 @@
package download
import "gorm.io/gorm"
import (
"time"
"gorm.io/gorm"
)
type Info struct {
gorm.Model
URL string `gorm:"not null"`
Version string `gorm:"not null"`
FileName string `gorm:"not null"`
FileSize string `gorm:"not null"`
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
URL string `json:"url" gorm:"not null"`
Version string `json:"version" gorm:"not null"`
FileName string `json:"fileName" gorm:"not null"`
// FileSize is a human-readable string (e.g., "1.5 GB") for display purposes.
// Programmatic size tracking uses os.Stat on the actual file.
FileSize string `json:"fileSize" gorm:"not null"`
FileHash string `json:"fileHash" gorm:"not null;default:''"`
LauncherURL string `json:"launcherUrl" gorm:"not null;default:''"`
// LauncherSize is a human-readable string (e.g., "25.3 MB") for display purposes.
// Programmatic size tracking uses os.Stat on the actual file.
LauncherSize string `json:"launcherSize" gorm:"not null;default:''"`
LauncherHash string `json:"launcherHash" gorm:"not null;default:''"`
}

View File

@@ -12,8 +12,10 @@ func NewRepository(db *gorm.DB) *Repository {
func (r *Repository) GetLatest() (*Info, error) {
var info Info
err := r.db.Last(&info).Error
return &info, err
if err := r.db.Last(&info).Error; err != nil {
return nil, err
}
return &info, nil
}
func (r *Repository) Save(info *Info) error {

View File

@@ -1,25 +1,194 @@
package download
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strings"
)
var versionRe = regexp.MustCompile(`v\d+\.\d+(\.\d+)?`)
type Service struct {
repo *Repository
repo *Repository
gameDir string
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
func NewService(repo *Repository, gameDir string) *Service {
return &Service{repo: repo, gameDir: gameDir}
}
func (s *Service) GetInfo() (*Info, error) {
return s.repo.GetLatest()
}
func (s *Service) Upsert(url, version, fileName, fileSize string) (*Info, error) {
func (s *Service) GameFilePath() string {
return filepath.Join(s.gameDir, "game.zip")
}
func (s *Service) LauncherFilePath() string {
return filepath.Join(s.gameDir, "launcher.exe")
}
func (s *Service) UploadLauncher(body io.Reader, baseURL string) (*Info, error) {
if err := os.MkdirAll(s.gameDir, 0755); err != nil {
return nil, fmt.Errorf("디렉토리 생성 실패: %w", err)
}
finalPath := s.LauncherFilePath()
tmpPath := finalPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return nil, fmt.Errorf("파일 생성 실패: %w", err)
}
// NOTE: Partial uploads (client closes cleanly mid-transfer) are saved.
// The hashGameExeFromZip check mitigates this for game uploads but not for launcher uploads.
n, err := io.Copy(f, body)
if closeErr := f.Close(); closeErr != nil && err == nil {
err = closeErr
}
if err != nil {
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 저장 실패: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 이동 실패: %w", err)
}
launcherSize := ""
if n > 0 {
launcherSize = fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
}
launcherHash := hashFileToHex(finalPath)
info, err := s.repo.GetLatest()
if err != nil {
info = &Info{}
}
info.URL = url
info.Version = version
info.FileName = fileName
info.FileSize = fileSize
info.LauncherURL = baseURL + "/api/download/launcher"
info.LauncherSize = launcherSize
info.LauncherHash = launcherHash
return info, s.repo.Save(info)
}
// Upload streams the body directly to disk, then extracts metadata from the zip.
func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info, error) {
if err := os.MkdirAll(s.gameDir, 0755); err != nil {
return nil, fmt.Errorf("디렉토리 생성 실패: %w", err)
}
finalPath := s.GameFilePath()
tmpPath := finalPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return nil, fmt.Errorf("파일 생성 실패: %w", err)
}
n, err := io.Copy(f, body)
if closeErr := f.Close(); closeErr != nil && err == nil {
err = closeErr
}
if err != nil {
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 저장 실패: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
if removeErr := os.Remove(tmpPath); removeErr != nil {
log.Printf("WARNING: failed to remove tmp file %s: %v", tmpPath, removeErr)
}
return nil, fmt.Errorf("파일 이동 실패: %w", err)
}
version := ""
if m := versionRe.FindString(filename); m != "" {
version = m
}
fileSize := ""
if n > 0 {
mb := float64(n) / 1024 / 1024
if mb >= 1000 {
fileSize = fmt.Sprintf("%.1f GB", mb/1024)
} else {
fileSize = fmt.Sprintf("%.1f MB", mb)
}
}
fileHash := hashGameExeFromZip(finalPath)
if fileHash == "" {
if removeErr := os.Remove(finalPath); removeErr != nil {
log.Printf("WARNING: failed to remove file %s: %v", finalPath, removeErr)
}
return nil, fmt.Errorf("zip 파일에 %s이(가) 포함되어 있지 않습니다", "A301.exe")
}
info, err := s.repo.GetLatest()
if err != nil {
info = &Info{}
}
info.URL = baseURL + "/api/download/file"
info.Version = version
info.FileName = filename
info.FileSize = fileSize
info.FileHash = fileHash
return info, s.repo.Save(info)
}
func hashFileToHex(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return ""
}
return hex.EncodeToString(h.Sum(nil))
}
const maxExeSize = 100 * 1024 * 1024 // 100MB — Zip Bomb 방어
func hashGameExeFromZip(zipPath string) string {
r, err := zip.OpenReader(zipPath)
if err != nil {
return ""
}
defer r.Close()
for _, f := range r.File {
if strings.EqualFold(filepath.Base(f.Name), "A301.exe") {
rc, err := f.Open()
if err != nil {
return ""
}
h := sha256.New()
_, err = io.Copy(h, io.LimitReader(rc, maxExeSize))
rc.Close()
if err != nil {
return ""
}
return hex.EncodeToString(h.Sum(nil))
}
}
return ""
}

View File

@@ -0,0 +1,198 @@
package download
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func TestHashFileToHex_KnownContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "testfile.bin")
content := []byte("hello world")
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
got := hashFileToHex(path)
h := sha256.Sum256(content)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashFileToHex = %q, want %q", got, want)
}
}
func TestHashFileToHex_EmptyFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.bin")
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
got := hashFileToHex(path)
h := sha256.Sum256([]byte{})
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashFileToHex (empty) = %q, want %q", got, want)
}
}
func TestHashFileToHex_NonExistentFile(t *testing.T) {
got := hashFileToHex("/nonexistent/path/file.bin")
if got != "" {
t.Errorf("hashFileToHex (nonexistent) = %q, want empty string", got)
}
}
// createTestZip creates a zip file at zipPath containing the given files.
// files is a map of filename -> content.
func createTestZip(t *testing.T, zipPath string, files map[string][]byte) {
t.Helper()
f, err := os.Create(zipPath)
if err != nil {
t.Fatalf("failed to create zip: %v", err)
}
defer f.Close()
w := zip.NewWriter(f)
for name, data := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatalf("failed to create zip entry %s: %v", name, err)
}
if _, err := fw.Write(data); err != nil {
t.Fatalf("failed to write zip entry %s: %v", name, err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("failed to close zip writer: %v", err)
}
}
func TestHashGameExeFromZip_WithA301Exe(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
exeContent := []byte("fake A301.exe binary content for testing")
createTestZip(t, zipPath, map[string][]byte{
"GameFolder/A301.exe": exeContent,
"GameFolder/readme.txt": []byte("readme"),
})
got := hashGameExeFromZip(zipPath)
h := sha256.Sum256(exeContent)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashGameExeFromZip = %q, want %q", got, want)
}
}
func TestHashGameExeFromZip_CaseInsensitive(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
exeContent := []byte("case insensitive test")
createTestZip(t, zipPath, map[string][]byte{
"build/a301.EXE": exeContent,
})
got := hashGameExeFromZip(zipPath)
h := sha256.Sum256(exeContent)
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("hashGameExeFromZip (case insensitive) = %q, want %q", got, want)
}
}
func TestHashGameExeFromZip_NoA301Exe(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "game.zip")
createTestZip(t, zipPath, map[string][]byte{
"GameFolder/other.exe": []byte("not A301"),
"GameFolder/readme.txt": []byte("readme"),
})
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (no A301.exe) = %q, want empty string", got)
}
}
func TestHashGameExeFromZip_EmptyZip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "empty.zip")
createTestZip(t, zipPath, map[string][]byte{})
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (empty zip) = %q, want empty string", got)
}
}
func TestHashGameExeFromZip_InvalidZip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "notazip.zip")
if err := os.WriteFile(zipPath, []byte("this is not a zip file"), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}
got := hashGameExeFromZip(zipPath)
if got != "" {
t.Errorf("hashGameExeFromZip (invalid zip) = %q, want empty string", got)
}
}
func TestVersionRegex(t *testing.T) {
tests := []struct {
input string
want string
}{
{"game_v1.2.3.zip", "v1.2.3"},
{"game_v2.0.zip", "v2.0"},
{"game_v10.20.30.zip", "v10.20.30"},
{"game.zip", ""},
{"noversion", ""},
}
for _, tt := range tests {
got := versionRe.FindString(tt.input)
if got != tt.want {
t.Errorf("versionRe.FindString(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestGameFilePath(t *testing.T) {
s := NewService(nil, "/data/game")
got := s.GameFilePath()
// filepath.Join normalizes separators per OS
want := filepath.Join("/data/game", "game.zip")
if got != want {
t.Errorf("GameFilePath() = %q, want %q", got, want)
}
}
func TestLauncherFilePath(t *testing.T) {
s := NewService(nil, "/data/game")
got := s.LauncherFilePath()
want := filepath.Join("/data/game", "launcher.exe")
if got != want {
t.Errorf("LauncherFilePath() = %q, want %q", got, want)
}
}

177
internal/player/handler.go Normal file
View File

@@ -0,0 +1,177 @@
package player
import (
"log"
"strings"
"unicode"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
// GetProfile godoc
// @Summary 내 프로필 조회
// @Description 현재 유저의 플레이어 프로필을 조회합니다
// @Tags Player
// @Produce json
// @Security BearerAuth
// @Success 200 {object} docs.PlayerProfileResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/player/profile [get]
func (h *Handler) GetProfile(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
}
profile, err := h.svc.GetProfile(userID)
if err != nil {
return apperror.NotFound(err.Error())
}
return c.JSON(profileWithNextExp(profile))
}
// UpdateProfile godoc
// @Summary 프로필 수정
// @Description 현재 유저의 닉네임을 수정합니다
// @Tags Player
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body docs.UpdateProfileRequest true "수정할 프로필"
// @Success 200 {object} player.PlayerProfile
// @Failure 400 {object} docs.ErrorResponse
// @Failure 401 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/player/profile [put]
func (h *Handler) UpdateProfile(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(uint)
if !ok {
return apperror.Unauthorized("인증 정보가 올바르지 않습니다")
}
var req struct {
Nickname string `json:"nickname"`
}
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
req.Nickname = strings.TrimSpace(req.Nickname)
if req.Nickname != "" {
nicknameRunes := []rune(req.Nickname)
if len(nicknameRunes) < 2 || len(nicknameRunes) > 30 {
return apperror.BadRequest("닉네임은 2~30자여야 합니다")
}
for _, r := range nicknameRunes {
if unicode.IsControl(r) {
return apperror.BadRequest("닉네임에 허용되지 않는 문자가 포함되어 있습니다")
}
}
}
profile, err := h.svc.UpdateProfile(userID, req.Nickname)
if err != nil {
log.Printf("프로필 수정 실패 (userID=%d): %v", userID, err)
return apperror.ErrInternal
}
return c.JSON(profile)
}
// InternalGetProfile godoc
// @Summary 프로필 조회 (내부 API)
// @Description username으로 플레이어 프로필을 조회합니다 (게임 서버용)
// @Tags Internal - Player
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "유저명"
// @Success 200 {object} docs.PlayerProfileResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 404 {object} docs.ErrorResponse
// @Router /api/internal/player/profile [get]
func (h *Handler) InternalGetProfile(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
return apperror.BadRequest("username 파라미터가 필요합니다")
}
profile, err := h.svc.GetProfileByUsername(username)
if err != nil {
return apperror.NotFound(err.Error())
}
return c.JSON(profileWithNextExp(profile))
}
// profileWithNextExp wraps a PlayerProfile with nextExp for JSON response.
func profileWithNextExp(p *PlayerProfile) fiber.Map {
nextExp := 0
if p.Level < MaxLevel {
nextExp = RequiredExp(p.Level)
}
return fiber.Map{
"id": p.ID,
"createdAt": p.CreatedAt,
"updatedAt": p.UpdatedAt,
"userId": p.UserID,
"nickname": p.Nickname,
"level": p.Level,
"experience": p.Experience,
"nextExp": nextExp,
"maxHp": p.MaxHP,
"maxMp": p.MaxMP,
"attackPower": p.AttackPower,
"attackRange": p.AttackRange,
"sprintMultiplier": p.SprintMultiplier,
"lastPosX": p.LastPosX,
"lastPosY": p.LastPosY,
"lastPosZ": p.LastPosZ,
"lastRotY": p.LastRotY,
"totalPlayTime": p.TotalPlayTime,
}
}
// InternalSaveGameData godoc
// @Summary 게임 데이터 저장 (내부 API)
// @Description username으로 게임 데이터를 저장합니다 (게임 서버용)
// @Tags Internal - Player
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "유저명"
// @Param body body docs.GameDataRequest true "게임 데이터"
// @Success 200 {object} docs.MessageResponse
// @Failure 400 {object} docs.ErrorResponse
// @Failure 500 {object} docs.ErrorResponse
// @Router /api/internal/player/save [post]
func (h *Handler) InternalSaveGameData(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
return apperror.BadRequest("username 파라미터가 필요합니다")
}
var req GameDataRequest
if err := c.BodyParser(&req); err != nil {
return apperror.ErrBadRequest
}
if err := h.svc.SaveGameDataByUsername(username, &req); err != nil {
// Username from internal API (ServerAuth protected) — low risk of injection
log.Printf("게임 데이터 저장 실패 (username=%s): %v", username, err)
return apperror.ErrInternal
}
return c.JSON(fiber.Map{"message": "게임 데이터가 저장되었습니다"})
}

71
internal/player/level.go Normal file
View File

@@ -0,0 +1,71 @@
package player
// MaxLevel is the maximum player level.
const MaxLevel = 50
// RequiredExp returns the experience needed to reach the next level.
// Formula: level^2 * 100
func RequiredExp(level int) int {
return level * level * 100
}
// CalcStatsForLevel computes combat stats for a given level.
func CalcStatsForLevel(level int) (maxHP, maxMP, attackPower float64) {
maxHP = 100 + float64(level-1)*10
maxMP = 50 + float64(level-1)*5
attackPower = 10 + float64(level-1)*2
return
}
// LevelUpResult holds the result of applying experience gain.
type LevelUpResult struct {
OldLevel int `json:"oldLevel"`
NewLevel int `json:"newLevel"`
Experience int `json:"experience"`
NextExp int `json:"nextExp"`
MaxHP float64 `json:"maxHp"`
MaxMP float64 `json:"maxMp"`
AttackPower float64 `json:"attackPower"`
LeveledUp bool `json:"leveledUp"`
}
// ApplyExperience adds exp to current level/exp and applies level ups.
// Returns the result including new stats if leveled up.
func ApplyExperience(currentLevel, currentExp, gainedExp int) LevelUpResult {
level := currentLevel
exp := currentExp + gainedExp
// Process level ups
for level < MaxLevel {
required := RequiredExp(level)
if exp < required {
break
}
exp -= required
level++
}
// Cap at max level
if level >= MaxLevel {
level = MaxLevel
exp = 0 // No more exp needed at max level
}
maxHP, maxMP, attackPower := CalcStatsForLevel(level)
nextExp := 0
if level < MaxLevel {
nextExp = RequiredExp(level)
}
return LevelUpResult{
OldLevel: currentLevel,
NewLevel: level,
Experience: exp,
NextExp: nextExp,
MaxHP: maxHP,
MaxMP: maxMP,
AttackPower: attackPower,
LeveledUp: level > currentLevel,
}
}

37
internal/player/model.go Normal file
View File

@@ -0,0 +1,37 @@
package player
import (
"time"
"gorm.io/gorm"
)
type PlayerProfile struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
UserID uint `json:"userId" gorm:"uniqueIndex;not null"`
Nickname string `json:"nickname" gorm:"type:varchar(50);not null;default:''"`
// 레벨 & 경험치
Level int `json:"level" gorm:"default:1"`
Experience int `json:"experience" gorm:"default:0"`
// 전투 스탯
MaxHP float64 `json:"maxHp" gorm:"default:100"`
MaxMP float64 `json:"maxMp" gorm:"default:50"`
AttackPower float64 `json:"attackPower" gorm:"default:10"`
AttackRange float64 `json:"attackRange" gorm:"default:3"`
SprintMultiplier float64 `json:"sprintMultiplier" gorm:"default:1.8"`
// 마지막 위치
LastPosX float64 `json:"lastPosX" gorm:"default:0"`
LastPosY float64 `json:"lastPosY" gorm:"default:0"`
LastPosZ float64 `json:"lastPosZ" gorm:"default:0"`
LastRotY float64 `json:"lastRotY" gorm:"default:0"`
// 플레이 시간 (초 단위)
TotalPlayTime int64 `json:"totalPlayTime" gorm:"default:0"`
}

View File

@@ -0,0 +1,41 @@
package player
import "gorm.io/gorm"
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Create(profile *PlayerProfile) error {
return r.db.Create(profile).Error
}
func (r *Repository) FindByUserID(userID uint) (*PlayerProfile, error) {
var profile PlayerProfile
if err := r.db.Where("user_id = ?", userID).First(&profile).Error; err != nil {
return nil, err
}
return &profile, nil
}
func (r *Repository) Update(profile *PlayerProfile) error {
return r.db.Save(profile).Error
}
func (r *Repository) UpdateStats(userID uint, updates map[string]interface{}) error {
return r.db.Model(&PlayerProfile{}).Where("user_id = ?", userID).Updates(updates).Error
}
func (r *Repository) FindByUsername(username string) (*PlayerProfile, error) {
var profile PlayerProfile
if err := r.db.Joins("JOIN users ON users.id = player_profiles.user_id").
Where("users.username = ? AND users.deleted_at IS NULL", username).
First(&profile).Error; err != nil {
return nil, err
}
return &profile, nil
}

219
internal/player/service.go Normal file
View File

@@ -0,0 +1,219 @@
package player
import (
"fmt"
"gorm.io/gorm"
)
// validateGameData checks that game data fields are within acceptable ranges.
func validateGameData(data *GameDataRequest) error {
if data.Level != nil && (*data.Level < 1 || *data.Level > 999) {
return fmt.Errorf("레벨은 1~999 범위여야 합니다")
}
if data.Experience != nil && *data.Experience < 0 {
return fmt.Errorf("경험치는 0 이상이어야 합니다")
}
if data.MaxHP != nil && (*data.MaxHP < 1 || *data.MaxHP > 999999) {
return fmt.Errorf("최대 HP는 1~999999 범위여야 합니다")
}
if data.MaxMP != nil && (*data.MaxMP < 1 || *data.MaxMP > 999999) {
return fmt.Errorf("최대 MP는 1~999999 범위여야 합니다")
}
if data.AttackPower != nil && (*data.AttackPower < 0 || *data.AttackPower > 999999) {
return fmt.Errorf("공격력은 0~999999 범위여야 합니다")
}
if data.AttackRange != nil && (*data.AttackRange < 0 || *data.AttackRange > 100) {
return fmt.Errorf("attack_range must be 0-100")
}
if data.SprintMultiplier != nil && (*data.SprintMultiplier < 0 || *data.SprintMultiplier > 10) {
return fmt.Errorf("sprint_multiplier must be 0-10")
}
if data.PlayTimeDelta != nil && *data.PlayTimeDelta < 0 {
return fmt.Errorf("플레이 시간 변화량은 0 이상이어야 합니다")
}
return nil
}
type Service struct {
repo *Repository
userResolver func(username string) (uint, error)
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) SetUserResolver(fn func(username string) (uint, error)) {
s.userResolver = fn
}
// CreateProfile 회원가입 시 자동 호출되어 기본 프로필을 생성한다.
func (s *Service) CreateProfile(userID uint) error {
profile := &PlayerProfile{
UserID: userID,
}
return s.repo.Create(profile)
}
// GetProfile JWT 인증된 유저의 프로필을 조회한다. 없으면 자동 생성.
func (s *Service) GetProfile(userID uint) (*PlayerProfile, error) {
profile, err := s.repo.FindByUserID(userID)
if err != nil {
if err == gorm.ErrRecordNotFound {
profile = &PlayerProfile{UserID: userID}
if createErr := s.repo.Create(profile); createErr != nil {
return nil, fmt.Errorf("프로필 자동 생성에 실패했습니다: %w", createErr)
}
return profile, nil
}
return nil, fmt.Errorf("프로필 조회에 실패했습니다")
}
return profile, nil
}
// GetProfileByUsername 내부 API용: username으로 프로필 조회.
func (s *Service) GetProfileByUsername(username string) (*PlayerProfile, error) {
if s.userResolver == nil {
return nil, fmt.Errorf("userResolver가 설정되지 않았습니다")
}
userID, err := s.userResolver(username)
if err != nil {
return nil, fmt.Errorf("존재하지 않는 유저입니다")
}
return s.GetProfile(userID)
}
// UpdateProfile 유저 자신의 프로필(닉네임)을 수정한다.
func (s *Service) UpdateProfile(userID uint, nickname string) (*PlayerProfile, error) {
profile, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("프로필이 존재하지 않습니다")
}
if nickname != "" {
profile.Nickname = nickname
}
if err := s.repo.Update(profile); err != nil {
return nil, fmt.Errorf("프로필 수정에 실패했습니다")
}
return profile, nil
}
// SaveGameData 게임 서버에서 호출: 게임 데이터를 저장한다.
func (s *Service) SaveGameData(userID uint, data *GameDataRequest) error {
if err := validateGameData(data); err != nil {
return err
}
updates := map[string]interface{}{}
if data.Level != nil {
updates["level"] = *data.Level
}
if data.Experience != nil {
updates["experience"] = *data.Experience
}
if data.MaxHP != nil {
updates["max_hp"] = *data.MaxHP
}
if data.MaxMP != nil {
updates["max_mp"] = *data.MaxMP
}
if data.AttackPower != nil {
updates["attack_power"] = *data.AttackPower
}
if data.AttackRange != nil {
updates["attack_range"] = *data.AttackRange
}
if data.SprintMultiplier != nil {
updates["sprint_multiplier"] = *data.SprintMultiplier
}
if data.LastPosX != nil {
updates["last_pos_x"] = *data.LastPosX
}
if data.LastPosY != nil {
updates["last_pos_y"] = *data.LastPosY
}
if data.LastPosZ != nil {
updates["last_pos_z"] = *data.LastPosZ
}
if data.LastRotY != nil {
updates["last_rot_y"] = *data.LastRotY
}
if data.PlayTimeDelta != nil {
// 원자적 SQL 업데이트로 동시 요청 시 race condition 방지
updates["total_play_time"] = gorm.Expr("total_play_time + ?", *data.PlayTimeDelta)
}
if len(updates) == 0 {
return nil
}
return s.repo.UpdateStats(userID, updates)
}
// SaveGameDataByUsername 내부 API용: username 기반으로 게임 데이터 저장.
func (s *Service) SaveGameDataByUsername(username string, data *GameDataRequest) error {
if s.userResolver == nil {
return fmt.Errorf("userResolver가 설정되지 않았습니다")
}
// Note: validateGameData is called inside SaveGameData, no need to call it here.
userID, err := s.userResolver(username)
if err != nil {
return fmt.Errorf("존재하지 않는 유저입니다")
}
return s.SaveGameData(userID, data)
}
// GrantExperience adds experience to a player and handles level ups + stat recalculation.
func (s *Service) GrantExperience(userID uint, exp int) (*LevelUpResult, error) {
profile, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("프로필이 존재하지 않습니다")
}
result := ApplyExperience(profile.Level, profile.Experience, exp)
updates := map[string]interface{}{
"level": result.NewLevel,
"experience": result.Experience,
"max_hp": result.MaxHP,
"max_mp": result.MaxMP,
"attack_power": result.AttackPower,
}
if err := s.repo.UpdateStats(userID, updates); err != nil {
return nil, fmt.Errorf("레벨업 저장 실패: %w", err)
}
return &result, nil
}
// GrantExperienceByUsername grants experience to a player by username.
func (s *Service) GrantExperienceByUsername(username string, exp int) error {
if s.userResolver == nil {
return fmt.Errorf("userResolver가 설정되지 않았습니다")
}
userID, err := s.userResolver(username)
if err != nil {
return fmt.Errorf("존재하지 않는 유저입니다")
}
_, err = s.GrantExperience(userID, exp)
return err
}
// GameDataRequest 게임 데이터 저장 요청 (nil 필드는 변경하지 않음).
type GameDataRequest struct {
Level *int `json:"level,omitempty"`
Experience *int `json:"experience,omitempty"`
MaxHP *float64 `json:"maxHp,omitempty"`
MaxMP *float64 `json:"maxMp,omitempty"`
AttackPower *float64 `json:"attackPower,omitempty"`
AttackRange *float64 `json:"attackRange,omitempty"`
SprintMultiplier *float64 `json:"sprintMultiplier,omitempty"`
LastPosX *float64 `json:"lastPosX,omitempty"`
LastPosY *float64 `json:"lastPosY,omitempty"`
LastPosZ *float64 `json:"lastPosZ,omitempty"`
LastRotY *float64 `json:"lastRotY,omitempty"`
PlayTimeDelta *int64 `json:"playTimeDelta,omitempty"` // 누적할 플레이 시간(초)
}

View File

@@ -0,0 +1,543 @@
// NOTE: These tests use a testableService that reimplements service logic
// with mock repositories. This means tests can pass even if the real service
// diverges. For full coverage, consider refactoring services to use repository
// interfaces so the real service can be tested with mock repositories injected.
package player
import (
"fmt"
"testing"
"gorm.io/gorm"
)
// ---------------------------------------------------------------------------
// Mock repository — mirrors the methods that Service calls on *Repository.
// ---------------------------------------------------------------------------
type repositoryInterface interface {
Create(profile *PlayerProfile) error
FindByUserID(userID uint) (*PlayerProfile, error)
Update(profile *PlayerProfile) error
UpdateStats(userID uint, updates map[string]interface{}) error
}
type testableService struct {
repo repositoryInterface
userResolver func(username string) (uint, error)
}
func (s *testableService) GetProfile(userID uint) (*PlayerProfile, error) {
profile, err := s.repo.FindByUserID(userID)
if err != nil {
if err == gorm.ErrRecordNotFound {
profile = &PlayerProfile{UserID: userID}
if createErr := s.repo.Create(profile); createErr != nil {
return nil, fmt.Errorf("프로필 자동 생성에 실패했습니다: %w", createErr)
}
return profile, nil
}
return nil, fmt.Errorf("프로필 조회에 실패했습니다")
}
return profile, nil
}
func (s *testableService) UpdateProfile(userID uint, nickname string) (*PlayerProfile, error) {
profile, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("프로필이 존재하지 않습니다")
}
if nickname != "" {
profile.Nickname = nickname
}
if err := s.repo.Update(profile); err != nil {
return nil, fmt.Errorf("프로필 수정에 실패했습니다")
}
return profile, nil
}
func (s *testableService) SaveGameData(userID uint, data *GameDataRequest) error {
if err := validateGameData(data); err != nil {
return err
}
updates := map[string]interface{}{}
if data.Level != nil {
updates["level"] = *data.Level
}
if data.Experience != nil {
updates["experience"] = *data.Experience
}
if data.MaxHP != nil {
updates["max_hp"] = *data.MaxHP
}
if data.MaxMP != nil {
updates["max_mp"] = *data.MaxMP
}
if data.AttackPower != nil {
updates["attack_power"] = *data.AttackPower
}
if data.AttackRange != nil {
updates["attack_range"] = *data.AttackRange
}
if data.SprintMultiplier != nil {
updates["sprint_multiplier"] = *data.SprintMultiplier
}
if data.LastPosX != nil {
updates["last_pos_x"] = *data.LastPosX
}
if data.LastPosY != nil {
updates["last_pos_y"] = *data.LastPosY
}
if data.LastPosZ != nil {
updates["last_pos_z"] = *data.LastPosZ
}
if data.LastRotY != nil {
updates["last_rot_y"] = *data.LastRotY
}
if data.PlayTimeDelta != nil {
// Mirror the real service: atomic increment via delta value.
// The mock UpdateStats handles this by adding to the existing value.
updates["total_play_time_delta"] = *data.PlayTimeDelta
}
if len(updates) == 0 {
return nil
}
return s.repo.UpdateStats(userID, updates)
}
func (s *testableService) GetProfileByUsername(username string) (*PlayerProfile, error) {
if s.userResolver == nil {
return nil, fmt.Errorf("userResolver가 설정되지 않았습니다")
}
userID, err := s.userResolver(username)
if err != nil {
return nil, fmt.Errorf("존재하지 않는 유저입니다")
}
return s.GetProfile(userID)
}
// ---------------------------------------------------------------------------
// Mock implementation
// ---------------------------------------------------------------------------
type mockRepo struct {
profiles map[uint]*PlayerProfile
nextID uint
createErr error
updateErr error
updateStatsErr error
}
func newMockRepo() *mockRepo {
return &mockRepo{
profiles: make(map[uint]*PlayerProfile),
nextID: 1,
}
}
func (m *mockRepo) Create(profile *PlayerProfile) error {
if m.createErr != nil {
return m.createErr
}
profile.ID = m.nextID
profile.Level = 1
profile.MaxHP = 100
profile.MaxMP = 50
profile.AttackPower = 10
profile.AttackRange = 3
profile.SprintMultiplier = 1.8
m.nextID++
stored := *profile
m.profiles[profile.UserID] = &stored
return nil
}
func (m *mockRepo) FindByUserID(userID uint) (*PlayerProfile, error) {
p, ok := m.profiles[userID]
if !ok {
return nil, gorm.ErrRecordNotFound
}
cp := *p
return &cp, nil
}
func (m *mockRepo) Update(profile *PlayerProfile) error {
if m.updateErr != nil {
return m.updateErr
}
stored := *profile
m.profiles[profile.UserID] = &stored
return nil
}
func (m *mockRepo) UpdateStats(userID uint, updates map[string]interface{}) error {
if m.updateStatsErr != nil {
return m.updateStatsErr
}
p, ok := m.profiles[userID]
if !ok {
return gorm.ErrRecordNotFound
}
for key, val := range updates {
switch key {
case "level":
p.Level = val.(int)
case "experience":
p.Experience = val.(int)
case "max_hp":
p.MaxHP = val.(float64)
case "max_mp":
p.MaxMP = val.(float64)
case "attack_power":
p.AttackPower = val.(float64)
case "attack_range":
p.AttackRange = val.(float64)
case "sprint_multiplier":
p.SprintMultiplier = val.(float64)
case "last_pos_x":
p.LastPosX = val.(float64)
case "last_pos_y":
p.LastPosY = val.(float64)
case "last_pos_z":
p.LastPosZ = val.(float64)
case "last_rot_y":
p.LastRotY = val.(float64)
case "total_play_time":
p.TotalPlayTime = val.(int64)
case "total_play_time_delta":
// Simulates SQL: total_play_time = total_play_time + delta
p.TotalPlayTime += val.(int64)
}
}
return nil
}
// seedProfile creates a profile for a given userID in the mock repo.
func seedProfile(repo *mockRepo, userID uint, nickname string) *PlayerProfile {
p := &PlayerProfile{UserID: userID, Nickname: nickname}
_ = repo.Create(p)
return repo.profiles[userID]
}
// ---------------------------------------------------------------------------
// Tests — GetProfile
// ---------------------------------------------------------------------------
func TestGetProfile_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
profile, err := svc.GetProfile(1)
if err != nil {
t.Fatalf("GetProfile failed: %v", err)
}
if profile.UserID != 1 {
t.Errorf("UserID = %d, want 1", profile.UserID)
}
if profile.Nickname != "player1" {
t.Errorf("Nickname = %q, want %q", profile.Nickname, "player1")
}
}
func TestGetProfile_NotFound_AutoCreates(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
profile, err := svc.GetProfile(42)
if err != nil {
t.Fatalf("GetProfile should auto-create, got error: %v", err)
}
if profile.UserID != 42 {
t.Errorf("UserID = %d, want 42", profile.UserID)
}
if profile.Level != 1 {
t.Errorf("Level = %d, want 1 (default)", profile.Level)
}
}
func TestGetProfile_AutoCreateFails(t *testing.T) {
repo := newMockRepo()
repo.createErr = fmt.Errorf("db error")
svc := &testableService{repo: repo}
_, err := svc.GetProfile(42)
if err == nil {
t.Error("expected error when auto-create fails, got nil")
}
}
// ---------------------------------------------------------------------------
// Tests — GetProfileByUsername
// ---------------------------------------------------------------------------
func TestGetProfileByUsername_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{
repo: repo,
userResolver: func(username string) (uint, error) {
if username == "testuser" {
return 1, nil
}
return 0, fmt.Errorf("not found")
},
}
seedProfile(repo, 1, "testuser")
profile, err := svc.GetProfileByUsername("testuser")
if err != nil {
t.Fatalf("GetProfileByUsername failed: %v", err)
}
if profile.UserID != 1 {
t.Errorf("UserID = %d, want 1", profile.UserID)
}
}
func TestGetProfileByUsername_UserNotFound(t *testing.T) {
repo := newMockRepo()
svc := &testableService{
repo: repo,
userResolver: func(username string) (uint, error) {
return 0, fmt.Errorf("not found")
},
}
_, err := svc.GetProfileByUsername("unknown")
if err == nil {
t.Error("expected error for unknown username, got nil")
}
}
func TestGetProfileByUsername_NoResolver(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
_, err := svc.GetProfileByUsername("anyone")
if err == nil {
t.Error("expected error when userResolver is nil, got nil")
}
}
// ---------------------------------------------------------------------------
// Tests — UpdateProfile
// ---------------------------------------------------------------------------
func TestUpdateProfile_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "old_nick")
profile, err := svc.UpdateProfile(1, "new_nick")
if err != nil {
t.Fatalf("UpdateProfile failed: %v", err)
}
if profile.Nickname != "new_nick" {
t.Errorf("Nickname = %q, want %q", profile.Nickname, "new_nick")
}
}
func TestUpdateProfile_EmptyNickname_KeepsExisting(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "keep_me")
profile, err := svc.UpdateProfile(1, "")
if err != nil {
t.Fatalf("UpdateProfile failed: %v", err)
}
if profile.Nickname != "keep_me" {
t.Errorf("Nickname = %q, want %q (should be unchanged)", profile.Nickname, "keep_me")
}
}
func TestUpdateProfile_NotFound(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
_, err := svc.UpdateProfile(999, "nick")
if err == nil {
t.Error("expected error updating non-existent profile, got nil")
}
}
func TestUpdateProfile_RepoError(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "nick")
repo.updateErr = fmt.Errorf("db error")
_, err := svc.UpdateProfile(1, "new_nick")
if err == nil {
t.Error("expected error when repo update fails, got nil")
}
}
// ---------------------------------------------------------------------------
// Tests — SaveGameData
// ---------------------------------------------------------------------------
func intPtr(v int) *int { return &v }
func float64Ptr(v float64) *float64 { return &v }
func int64Ptr(v int64) *int64 { return &v }
func TestSaveGameData_Success(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{
Level: intPtr(5),
Experience: intPtr(200),
MaxHP: float64Ptr(150),
LastPosX: float64Ptr(10.5),
LastPosY: float64Ptr(20.0),
LastPosZ: float64Ptr(30.0),
})
if err != nil {
t.Fatalf("SaveGameData failed: %v", err)
}
p := repo.profiles[1]
if p.Level != 5 {
t.Errorf("Level = %d, want 5", p.Level)
}
if p.Experience != 200 {
t.Errorf("Experience = %d, want 200", p.Experience)
}
if p.MaxHP != 150 {
t.Errorf("MaxHP = %f, want 150", p.MaxHP)
}
if p.LastPosX != 10.5 {
t.Errorf("LastPosX = %f, want 10.5", p.LastPosX)
}
}
func TestSaveGameData_EmptyRequest(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{})
if err != nil {
t.Fatalf("SaveGameData with empty request should succeed: %v", err)
}
}
func TestSaveGameData_PlayTimeDelta(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
repo.profiles[1].TotalPlayTime = 1000
err := svc.SaveGameData(1, &GameDataRequest{
PlayTimeDelta: int64Ptr(300),
})
if err != nil {
t.Fatalf("SaveGameData failed: %v", err)
}
p := repo.profiles[1]
if p.TotalPlayTime != 1300 {
t.Errorf("TotalPlayTime = %d, want 1300 (1000+300)", p.TotalPlayTime)
}
}
func TestSaveGameData_PlayTimeDelta_Accumulates(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
_ = svc.SaveGameData(1, &GameDataRequest{PlayTimeDelta: int64Ptr(100)})
_ = svc.SaveGameData(1, &GameDataRequest{PlayTimeDelta: int64Ptr(200)})
p := repo.profiles[1]
if p.TotalPlayTime != 300 {
t.Errorf("TotalPlayTime = %d, want 300 (0+100+200)", p.TotalPlayTime)
}
}
func TestSaveGameData_PlayTimeDelta_ProfileNotFound(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
err := svc.SaveGameData(999, &GameDataRequest{PlayTimeDelta: int64Ptr(100)})
if err == nil {
t.Error("expected error when profile not found for PlayTimeDelta, got nil")
}
}
// ---------------------------------------------------------------------------
// Tests — SaveGameData validation
// ---------------------------------------------------------------------------
func TestSaveGameData_InvalidLevel(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{Level: intPtr(0)})
if err == nil {
t.Error("expected error for level=0, got nil")
}
err = svc.SaveGameData(1, &GameDataRequest{Level: intPtr(1000)})
if err == nil {
t.Error("expected error for level=1000, got nil")
}
}
func TestSaveGameData_NegativeExperience(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{Experience: intPtr(-1)})
if err == nil {
t.Error("expected error for negative experience, got nil")
}
}
func TestSaveGameData_NegativePlayTimeDelta(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{PlayTimeDelta: int64Ptr(-100)})
if err == nil {
t.Error("expected error for negative playTimeDelta, got nil")
}
}
func TestSaveGameData_MaxHP_OutOfRange(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
err := svc.SaveGameData(1, &GameDataRequest{MaxHP: float64Ptr(0)})
if err == nil {
t.Error("expected error for maxHP=0, got nil")
}
err = svc.SaveGameData(1, &GameDataRequest{MaxHP: float64Ptr(1000000)})
if err == nil {
t.Error("expected error for maxHP=1000000, got nil")
}
}
func TestSaveGameData_RepoError(t *testing.T) {
repo := newMockRepo()
svc := &testableService{repo: repo}
seedProfile(repo, 1, "player1")
repo.updateStatsErr = fmt.Errorf("db write error")
err := svc.SaveGameData(1, &GameDataRequest{Level: intPtr(5)})
if err == nil {
t.Error("expected error when repo UpdateStats fails, got nil")
}
}

110
internal/server/server.go Normal file
View File

@@ -0,0 +1,110 @@
package server
import (
"strconv"
"time"
"a301_server/pkg/apperror"
"a301_server/pkg/metrics"
"a301_server/pkg/middleware"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// New creates a configured Fiber app with all global middleware applied.
func New() *fiber.App {
app := fiber.New(fiber.Config{
StreamRequestBody: true,
BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB
ErrorHandler: middleware.ErrorHandler,
})
app.Use(middleware.RequestID)
app.Use(middleware.Metrics)
app.Get("/metrics", metrics.Handler)
app.Use(logger.New(logger.Config{
Format: `{"time":"${time}","status":${status},"latency":"${latency}","method":"${method}","path":"${path}","ip":"${ip}","reqId":"${locals:requestID}"}` + "\n",
TimeFormat: "2006-01-02T15:04:05Z07:00",
}))
app.Use(middleware.SecurityHeaders)
app.Use(cors.New(cors.Config{
AllowOrigins: "https://a301.tolelom.xyz",
AllowHeaders: "Origin, Content-Type, Authorization, Idempotency-Key, X-API-Key",
AllowMethods: "GET, POST, PUT, PATCH, DELETE",
AllowCredentials: true,
}))
return app
}
// AuthLimiter returns a rate limiter for auth endpoints (10 req/min per IP).
func AuthLimiter() fiber.Handler {
return limiter.New(limiter.Config{
Max: 10,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return apperror.ErrRateLimited
},
})
}
// APILimiter returns a rate limiter for general API endpoints (60 req/min per IP).
func APILimiter() fiber.Handler {
return limiter.New(limiter.Config{
Max: 60,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return apperror.ErrRateLimited
},
})
}
// ChainUserLimiter returns a rate limiter for chain transactions (20 req/min per user).
func ChainUserLimiter() fiber.Handler {
return limiter.New(limiter.Config{
Max: 20,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
if uid, ok := c.Locals("userID").(uint); ok {
return "chain_user:" + strconv.FormatUint(uint64(uid), 10)
}
return "chain_ip:" + c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
return apperror.ErrRateLimited
},
})
}
// HealthCheck returns a handler that reports server liveness.
func HealthCheck() fiber.Handler {
return func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
}
}
// ReadyCheck returns a handler that verifies DB and Redis connectivity.
func ReadyCheck(db *gorm.DB, rdb *redis.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
sqlDB, err := db.DB()
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db pool"})
}
if err := sqlDB.Ping(); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "db"})
}
if err := rdb.Ping(c.Context()).Err(); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"status": "error", "detail": "redis"})
}
return c.JSON(fiber.Map{"status": "ok"})
}
}

177
main.go
View File

@@ -2,56 +2,191 @@ package main
import (
"log"
"os"
"os/signal"
"syscall"
"time"
"a301_server/internal/announcement"
"a301_server/internal/auth"
"a301_server/internal/bossraid"
"a301_server/internal/chain"
"a301_server/internal/download"
"a301_server/internal/player"
"a301_server/internal/server"
_ "a301_server/docs" // swagger docs
"github.com/tolelom/tolchain/core"
"a301_server/pkg/config"
"a301_server/pkg/database"
"a301_server/pkg/middleware"
"a301_server/routes"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
)
// @title One of the Plans API
// @version 1.0
// @description 멀티플레이어 보스 레이드 게임 플랫폼 백엔드 API
// @host a301.api.tolelom.xyz
// @BasePath /
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description JWT Bearer 토큰 (예: Bearer eyJhbGci...)
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name X-API-Key
// @description 내부 API 키 (게임 서버 ↔ API 서버 통신용)
func main() {
config.Load()
config.WarnInsecureDefaults()
if err := database.ConnectMySQL(); err != nil {
db, err := database.ConnectMySQL()
if err != nil {
log.Fatalf("MySQL 연결 실패: %v", err)
}
log.Println("MySQL 연결 성공")
// AutoMigrate
database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{})
if err := db.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}, &bossraid.DedicatedServer{}, &bossraid.RoomSlot{}, &bossraid.RewardFailure{}, &player.PlayerProfile{}); err != nil {
log.Fatalf("AutoMigrate 실패: %v", err)
}
if err := database.ConnectRedis(); err != nil {
rdb, err := database.ConnectRedis()
if err != nil {
log.Fatalf("Redis 연결 실패: %v", err)
}
log.Println("Redis 연결 성공")
// 의존성 주입
authRepo := auth.NewRepository(database.DB)
authSvc := auth.NewService(authRepo, database.RDB)
// ── 의존성 주입 ──────────────────────────────────────────────────
authRepo := auth.NewRepository(db)
authSvc := auth.NewService(authRepo, rdb)
authHandler := auth.NewHandler(authSvc)
annRepo := announcement.NewRepository(database.DB)
chainClient := chain.NewClient(config.C.ChainNodeURLs...)
chainRepo := chain.NewRepository(db)
chainSvc, err := chain.NewService(chainRepo, chainClient, config.C.ChainID, config.C.OperatorKeyHex, config.C.WalletEncryptionKey)
if err != nil {
log.Fatalf("chain service init failed: %v", err)
}
chainHandler := chain.NewHandler(chainSvc)
userResolver := func(username string) (uint, error) {
user, err := authRepo.FindByUsername(username)
if err != nil {
return 0, err
}
return user.ID, nil
}
chainSvc.SetUserResolver(userResolver)
authSvc.SetWalletCreator(func(userID uint) error {
_, err := chainSvc.CreateWallet(userID)
return err
})
playerRepo := player.NewRepository(db)
playerSvc := player.NewService(playerRepo)
playerSvc.SetUserResolver(userResolver)
playerHandler := player.NewHandler(playerSvc)
authSvc.SetProfileCreator(func(userID uint) error {
return playerSvc.CreateProfile(userID)
})
if err := authSvc.EnsureAdmin(config.C.AdminUsername, config.C.AdminPassword); err != nil {
log.Printf("admin 계정 생성 실패: %v", err)
} else {
log.Printf("admin 계정 확인 완료: %s", config.C.AdminUsername)
}
brRepo := bossraid.NewRepository(db)
brSvc := bossraid.NewService(brRepo, rdb)
brSvc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
_, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets)
return err
})
brSvc.SetExpGranter(func(username string, exp int) error {
return playerSvc.GrantExperienceByUsername(username, exp)
})
brHandler := bossraid.NewHandler(brSvc)
if config.C.InternalAPIKey == "" {
log.Println("WARN: INTERNAL_API_KEY not set — /api/internal/* endpoints are disabled")
}
annRepo := announcement.NewRepository(db)
annSvc := announcement.NewService(annRepo)
annHandler := announcement.NewHandler(annSvc)
dlRepo := download.NewRepository(database.DB)
dlSvc := download.NewService(dlRepo)
dlHandler := download.NewHandler(dlSvc)
dlRepo := download.NewRepository(db)
dlSvc := download.NewService(dlRepo, config.C.GameDir)
dlHandler := download.NewHandler(dlSvc, config.C.BaseURL)
app := fiber.New()
app.Use(logger.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "https://a301.tolelom.xyz",
AllowHeaders: "Origin, Content-Type, Authorization",
AllowMethods: "GET, POST, PUT, DELETE",
}))
// ── 서버 + 라우트 설정 ───────────────────────────────────────────
routes.Register(app, authHandler, annHandler, dlHandler)
app := server.New()
authMw := middleware.Auth(rdb, config.C.JWTSecret)
serverAuthMw := middleware.ServerAuth(config.C.InternalAPIKey)
idempotencyReqMw := middleware.IdempotencyRequired(rdb)
routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, playerHandler,
server.AuthLimiter(), server.APILimiter(), server.HealthCheck(), server.ReadyCheck(db, rdb),
server.ChainUserLimiter(), authMw, serverAuthMw, idempotencyReqMw)
// ── 백그라운드 워커 ──────────────────────────────────────────────
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
brSvc.CheckStaleSlots()
}
}()
rewardWorker := bossraid.NewRewardWorker(
brRepo,
func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error {
_, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets)
return err
},
func(username string, exp int) error {
return playerSvc.GrantExperienceByUsername(username, exp)
},
)
rewardWorker.Start()
// ── Graceful shutdown ────────────────────────────────────────────
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
log.Printf("수신된 시그널: %v — 서버 종료 중...", sig)
rewardWorker.Stop()
if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
log.Printf("서버 종료 실패: %v", err)
}
if rdb != nil {
if err := rdb.Close(); err != nil {
log.Printf("Redis 종료 실패: %v", err)
} else {
log.Println("Redis 연결 종료 완료")
}
}
if sqlDB, err := db.DB(); err == nil {
if err := sqlDB.Close(); err != nil {
log.Printf("MySQL 종료 실패: %v", err)
} else {
log.Println("MySQL 연결 종료 완료")
}
}
}()
log.Fatal(app.Listen(":" + config.C.AppPort))
}

59
pkg/apperror/apperror.go Normal file
View File

@@ -0,0 +1,59 @@
package apperror
import "fmt"
// AppError is a structured application error with an HTTP status code.
// JSON response format: {"error": "<code>", "message": "<human-readable message>"}
type AppError struct {
Code string `json:"error"`
Message string `json:"message"`
Status int `json:"-"`
}
func (e *AppError) Error() string { return e.Message }
// New creates a new AppError.
func New(code string, message string, status int) *AppError {
return &AppError{Code: code, Message: message, Status: status}
}
// Wrap creates a new AppError that wraps a cause error.
func Wrap(code string, message string, status int, cause error) *AppError {
return &AppError{Code: code, Message: fmt.Sprintf("%s: %v", message, cause), Status: status}
}
// Common errors
var (
ErrBadRequest = &AppError{Code: "bad_request", Message: "잘못된 요청입니다", Status: 400}
ErrUnauthorized = &AppError{Code: "unauthorized", Message: "인증이 필요합니다", Status: 401}
ErrForbidden = &AppError{Code: "forbidden", Message: "권한이 없습니다", Status: 403}
ErrNotFound = &AppError{Code: "not_found", Message: "리소스를 찾을 수 없습니다", Status: 404}
ErrConflict = &AppError{Code: "conflict", Message: "이미 존재합니다", Status: 409}
ErrRateLimited = &AppError{Code: "rate_limited", Message: "요청이 너무 많습니다", Status: 429}
ErrInternal = &AppError{Code: "internal_error", Message: "서버 오류가 발생했습니다", Status: 500}
)
// BadRequest creates a 400 error with a custom message.
func BadRequest(message string) *AppError {
return &AppError{Code: "bad_request", Message: message, Status: 400}
}
// Unauthorized creates a 401 error with a custom message.
func Unauthorized(message string) *AppError {
return &AppError{Code: "unauthorized", Message: message, Status: 401}
}
// NotFound creates a 404 error with a custom message.
func NotFound(message string) *AppError {
return &AppError{Code: "not_found", Message: message, Status: 404}
}
// Conflict creates a 409 error with a custom message.
func Conflict(message string) *AppError {
return &AppError{Code: "conflict", Message: message, Status: 409}
}
// Internal creates a 500 error with a custom message.
func Internal(message string) *AppError {
return &AppError{Code: "internal_error", Message: message, Status: 500}
}

View File

@@ -1,8 +1,10 @@
package config
import (
"log"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
@@ -17,7 +19,29 @@ type Config struct {
RedisAddr string
RedisPassword string
JWTSecret string
RefreshSecret string
JWTExpiryHours int
AdminUsername string
AdminPassword string
BaseURL string
GameDir string
// Chain integration
// ChainNodeURL은 단일 노드 설정용 (하위 호환).
// ChainNodeURLs는 CHAIN_NODE_URLS(쉼표 구분) 또는 ChainNodeURL에서 파생.
ChainNodeURL string
ChainNodeURLs []string
ChainID string
OperatorKeyHex string
WalletEncryptionKey string
// Server-to-server auth
InternalAPIKey string
// SSAFY OAuth 2.0
SSAFYClientID string
SSAFYClientSecret string
SSAFYRedirectURI string
}
var C Config
@@ -36,10 +60,67 @@ func Load() {
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
JWTSecret: getEnv("JWT_SECRET", "secret"),
RefreshSecret: getEnv("REFRESH_SECRET", "refresh-secret"),
JWTExpiryHours: hours,
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"),
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
GameDir: getEnv("GAME_DIR", "/data/game"),
ChainNodeURL: getEnv("CHAIN_NODE_URL", "http://localhost:8545"),
ChainID: getEnv("CHAIN_ID", "tolchain-dev"),
OperatorKeyHex: getEnv("OPERATOR_KEY_HEX", ""),
WalletEncryptionKey: getEnv("WALLET_ENCRYPTION_KEY", ""),
InternalAPIKey: getEnv("INTERNAL_API_KEY", ""),
SSAFYClientID: getEnv("SSAFY_CLIENT_ID", ""),
SSAFYClientSecret: getEnv("SSAFY_CLIENT_SECRET", ""),
SSAFYRedirectURI: getEnv("SSAFY_REDIRECT_URI", ""),
}
// CHAIN_NODE_URLS (쉼표 구분) 우선, 없으면 CHAIN_NODE_URL 단일값 사용
if raw := getEnv("CHAIN_NODE_URLS", ""); raw != "" {
for _, u := range strings.Split(raw, ",") {
if u = strings.TrimSpace(u); u != "" {
C.ChainNodeURLs = append(C.ChainNodeURLs, u)
}
}
}
if len(C.ChainNodeURLs) == 0 {
C.ChainNodeURLs = []string{C.ChainNodeURL}
}
}
// WarnInsecureDefaults logs warnings for security-sensitive settings left at defaults.
// In production mode (APP_ENV=production), insecure defaults cause a fatal exit.
func WarnInsecureDefaults() {
isProd := getEnv("APP_ENV", "") == "production"
insecure := false
if C.JWTSecret == "secret" {
log.Println("WARNING: JWT_SECRET is using the default value — set a strong secret for production")
insecure = true
}
if C.RefreshSecret == "refresh-secret" {
log.Println("WARNING: REFRESH_SECRET is using the default value — set a strong secret for production")
insecure = true
}
if C.AdminPassword == "admin1234" {
log.Println("WARNING: ADMIN_PASSWORD is using the default value — change it for production")
insecure = true
}
if C.WalletEncryptionKey == "" {
log.Println("WARNING: WALLET_ENCRYPTION_KEY is empty — blockchain wallet features will fail")
}
if isProd && insecure {
log.Fatal("FATAL: insecure default secrets detected in production — set JWT_SECRET, REFRESH_SECRET, and ADMIN_PASSWORD")
}
}
// getEnv returns the environment variable value, or fallback if unset or empty.
// Note: explicitly setting a variable to "" is treated as unset.
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v

View File

@@ -2,23 +2,30 @@ package database
import (
"fmt"
"time"
"a301_server/pkg/config"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var DB *gorm.DB
func ConnectMySQL() error {
func ConnectMySQL() (*gorm.DB, error) {
c := config.C
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName,
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return err
return nil, err
}
DB = db
return nil
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("sql.DB 획득 실패: %w", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
return db, nil
}

View File

@@ -7,12 +7,13 @@ import (
"github.com/redis/go-redis/v9"
)
var RDB *redis.Client
func ConnectRedis() error {
RDB = redis.NewClient(&redis.Options{
func ConnectRedis() (*redis.Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: config.C.RedisAddr,
Password: config.C.RedisPassword,
})
return RDB.Ping(context.Background()).Err()
if err := rdb.Ping(context.Background()).Err(); err != nil {
return nil, err
}
return rdb, nil
}

54
pkg/metrics/metrics.go Normal file
View File

@@ -0,0 +1,54 @@
package metrics
import (
"io"
"net/http"
"net/http/httptest"
"github.com/gofiber/fiber/v2"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
HTTPRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests"},
[]string{"method", "path", "status"},
)
HTTPRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request duration"},
[]string{"method", "path"},
)
DBConnectionsActive = prometheus.NewGauge(
prometheus.GaugeOpts{Name: "db_connections_active", Help: "Active DB connections"},
)
RedisConnectionsActive = prometheus.NewGauge(
prometheus.GaugeOpts{Name: "redis_connections_active", Help: "Active Redis connections"},
)
)
func init() {
prometheus.MustRegister(HTTPRequestsTotal, HTTPRequestDuration, DBConnectionsActive, RedisConnectionsActive)
}
// Handler returns a Fiber handler that serves the Prometheus metrics endpoint.
// It wraps promhttp.Handler() without requiring the gofiber/adaptor package.
func Handler(c *fiber.Ctx) error {
handler := promhttp.Handler()
req, err := http.NewRequest(http.MethodGet, "/metrics", nil)
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
result := rec.Result()
defer result.Body.Close()
c.Set("Content-Type", result.Header.Get("Content-Type"))
c.Status(result.StatusCode)
body, err := io.ReadAll(result.Body)
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
}
return c.Send(body)
}

View File

@@ -2,53 +2,88 @@ package middleware
import (
"context"
"crypto/subtle"
"fmt"
"log"
"strings"
"a301_server/pkg/config"
"a301_server/pkg/database"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
)
func Auth(c *fiber.Ctx) error {
header := c.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증이 필요합니다"})
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
// Auth returns a middleware that validates JWT tokens and checks Redis sessions.
func Auth(rdb *redis.Client, jwtSecret string) fiber.Handler {
secretBytes := []byte(jwtSecret)
return func(c *fiber.Ctx) error {
header := c.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return apperror.ErrUnauthorized
}
return []byte(config.C.JWTSecret), nil
})
if err != nil || !token.Valid {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
tokenStr := strings.TrimPrefix(header, "Bearer ")
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return secretBytes, nil
})
if err != nil || !token.Valid {
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
userIDFloat, ok := claims["user_id"].(float64)
if !ok {
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
username, ok := claims["username"].(string)
if !ok {
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
role, ok := claims["role"].(string)
if !ok {
return apperror.Unauthorized("유효하지 않은 토큰입니다")
}
userID := uint(userIDFloat)
// Redis 세션 확인
ctx, cancel := context.WithTimeout(context.Background(), redisTimeout)
defer cancel()
key := fmt.Sprintf("session:%d", userID)
stored, err := rdb.Get(ctx, key).Result()
if err != nil || stored != tokenStr {
return apperror.Unauthorized("만료되었거나 로그아웃된 세션입니다")
}
c.Locals("userID", userID)
c.Locals("username", username)
c.Locals("role", role)
return c.Next()
}
claims := token.Claims.(jwt.MapClaims)
userID := uint(claims["user_id"].(float64))
username := claims["username"].(string)
role := claims["role"].(string)
// Redis 세션 확인
key := fmt.Sprintf("session:%d", userID)
stored, err := database.RDB.Get(context.Background(), key).Result()
if err != nil || stored != tokenStr {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "만료되었거나 로그아웃된 세션입니다"})
}
c.Locals("userID", userID)
c.Locals("username", username)
c.Locals("role", role)
return c.Next()
}
func AdminOnly(c *fiber.Ctx) error {
if c.Locals("role") != "admin" {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "관리자 권한이 필요합니다"})
return apperror.ErrForbidden
}
return c.Next()
}
// ServerAuth returns a middleware that validates X-API-Key header for server-to-server communication.
// Uses constant-time comparison to prevent timing attacks.
func ServerAuth(apiKey string) fiber.Handler {
expectedBytes := []byte(apiKey)
return func(c *fiber.Ctx) error {
key := c.Get("X-API-Key")
if key == "" || len(expectedBytes) == 0 || subtle.ConstantTimeCompare([]byte(key), expectedBytes) != 1 {
log.Printf("ServerAuth 실패: IP=%s, Path=%s", c.IP(), c.Path())
return apperror.Unauthorized("유효하지 않은 API 키입니다")
}
return c.Next()
}
}

View File

@@ -0,0 +1,30 @@
package middleware
import (
"strings"
"github.com/gofiber/fiber/v2"
)
// BodyLimit rejects requests whose Content-Length header exceeds maxBytes.
// NOTE: Only checks Content-Length header. Chunked requests without Content-Length
// bypass this check. Fiber's global BodyLimit provides the final safety net.
// Paths matching any of the excludePrefixes are skipped (e.g. upload endpoints
// that legitimately need the global 4GB limit).
// NOTE: excludePrefixes uses HasPrefix matching. Ensure no unintended
// routes share the same prefix as upload endpoints.
func BodyLimit(maxBytes int, excludePrefixes ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
for _, prefix := range excludePrefixes {
if strings.HasPrefix(c.Path(), prefix) {
return c.Next()
}
}
if c.Request().Header.ContentLength() > maxBytes {
return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{
"error": "요청이 너무 큽니다",
})
}
return c.Next()
}
}

View File

@@ -0,0 +1,31 @@
package middleware
import (
"errors"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
)
// ErrorHandler is a Fiber error handler that returns structured JSON for AppError.
func ErrorHandler(c *fiber.Ctx, err error) error {
var appErr *apperror.AppError
if errors.As(err, &appErr) {
return c.Status(appErr.Status).JSON(appErr)
}
// Default Fiber error handling
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
return c.Status(fiberErr.Code).JSON(fiber.Map{
"error": "server_error",
"message": fiberErr.Message,
})
}
return c.Status(500).JSON(fiber.Map{
"error": "internal_error",
"message": "서버 오류가 발생했습니다",
})
}

View File

@@ -0,0 +1,119 @@
package middleware
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"a301_server/pkg/apperror"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
const idempotencyTTL = 10 * time.Minute
const redisTimeout = 5 * time.Second
type cachedResponse struct {
StatusCode int `json:"s"`
Body json.RawMessage `json:"b"`
}
// IdempotencyRequired returns a middleware that rejects requests without an Idempotency-Key header,
// then delegates to idempotency cache/replay logic.
func IdempotencyRequired(rdb *redis.Client) fiber.Handler {
idempotency := Idempotency(rdb)
return func(c *fiber.Ctx) error {
if c.Get("Idempotency-Key") == "" {
return apperror.BadRequest("Idempotency-Key 헤더가 필요합니다")
}
return idempotency(c)
}
}
// Idempotency returns a middleware that checks the Idempotency-Key header to prevent duplicate transactions.
// If the same key is seen again within the TTL, the cached response is returned.
func Idempotency(rdb *redis.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
key := c.Get("Idempotency-Key")
if key == "" {
return c.Next()
}
if len(key) > 256 {
return apperror.BadRequest("Idempotency-Key가 너무 깁니다")
}
// userID가 있으면 키에 포함하여 사용자 간 캐시 충돌 방지
redisKey := "idempotency:"
if uid, ok := c.Locals("userID").(uint); ok {
redisKey += fmt.Sprintf("u%d:", uid)
}
redisKey += key
ctx, cancel := context.WithTimeout(context.Background(), redisTimeout)
defer cancel()
// Atomically claim the key using SET NX (only succeeds if key doesn't exist)
set, err := rdb.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result()
if err != nil {
// Redis error — let the request through rather than blocking
log.Printf("WARNING: idempotency SetNX failed (key=%s): %v", key, err)
return c.Next()
}
if !set {
// Key already exists — either processing or completed
getCtx, getCancel := context.WithTimeout(context.Background(), redisTimeout)
defer getCancel()
cached, err := rdb.Get(getCtx, redisKey).Bytes()
if err != nil {
return apperror.Conflict("요청이 처리 중입니다")
}
if string(cached) == "processing" {
return apperror.Conflict("요청이 처리 중입니다")
}
var cr cachedResponse
if json.Unmarshal(cached, &cr) == nil {
c.Set("Content-Type", "application/json")
c.Set("X-Idempotent-Replay", "true")
return c.Status(cr.StatusCode).Send(cr.Body)
}
return apperror.Conflict("요청이 처리 중입니다")
}
// We claimed the key — process the request
if err := c.Next(); err != nil {
// Processing failed — remove the key so it can be retried
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
defer delCancel()
if delErr := rdb.Del(delCtx, redisKey).Err(); delErr != nil {
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
}
return err
}
// Cache successful responses (2xx), otherwise remove the key for retry
status := c.Response().StatusCode()
if status >= 200 && status < 300 {
cr := cachedResponse{StatusCode: status, Body: c.Response().Body()}
if data, err := json.Marshal(cr); err == nil {
writeCtx, writeCancel := context.WithTimeout(context.Background(), redisTimeout)
defer writeCancel()
if err := rdb.Set(writeCtx, redisKey, data, idempotencyTTL).Err(); err != nil {
log.Printf("WARNING: idempotency cache write failed (key=%s): %v", key, err)
}
}
} else {
// Non-success — allow retry by removing the key
delCtx, delCancel := context.WithTimeout(context.Background(), redisTimeout)
defer delCancel()
if delErr := rdb.Del(delCtx, redisKey).Err(); delErr != nil {
log.Printf("WARNING: idempotency cache delete failed (key=%s): %v", key, delErr)
}
}
return nil
}
}

25
pkg/middleware/metrics.go Normal file
View File

@@ -0,0 +1,25 @@
package middleware
import (
"strconv"
"time"
"a301_server/pkg/metrics"
"github.com/gofiber/fiber/v2"
)
// Metrics records HTTP request count and duration as Prometheus metrics.
func Metrics(c *fiber.Ctx) error {
start := time.Now()
err := c.Next()
duration := time.Since(start).Seconds()
status := strconv.Itoa(c.Response().StatusCode())
path := c.Route().Path // use route pattern to avoid cardinality explosion
method := c.Method()
metrics.HTTPRequestsTotal.WithLabelValues(method, path, status).Inc()
metrics.HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
return err
}

View File

@@ -0,0 +1,30 @@
package middleware
import (
"strings"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// RequestID generates a unique request ID for each request and stores it in Locals and response header.
func RequestID(c *fiber.Ctx) error {
id := c.Get("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
// Truncate client-provided request IDs to prevent abuse
if len(id) > 64 {
id = id[:64]
}
// Strip non-printable characters to prevent log injection
id = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, id)
c.Locals("requestID", id)
c.Set("X-Request-ID", id)
return c.Next()
}

View File

@@ -0,0 +1,24 @@
package middleware
import (
"strings"
"github.com/gofiber/fiber/v2"
)
// SecurityHeaders sets common HTTP security headers on every response.
func SecurityHeaders(c *fiber.Ctx) error {
c.Set("X-Content-Type-Options", "nosniff")
c.Set("X-Frame-Options", "DENY")
c.Set("X-XSS-Protection", "0")
c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
if strings.HasPrefix(c.Path(), "/swagger") {
c.Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://validator.swagger.io; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
} else {
c.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
}
c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
return c.Next()
}

View File

@@ -3,9 +3,13 @@ package routes
import (
"a301_server/internal/announcement"
"a301_server/internal/auth"
"a301_server/internal/bossraid"
"a301_server/internal/chain"
"a301_server/internal/download"
"a301_server/internal/player"
"a301_server/pkg/middleware"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/swagger"
)
func Register(
@@ -13,23 +17,126 @@ func Register(
authH *auth.Handler,
annH *announcement.Handler,
dlH *download.Handler,
chainH *chain.Handler,
brH *bossraid.Handler,
playerH *player.Handler,
authLimiter fiber.Handler,
apiLimiter fiber.Handler,
healthCheck fiber.Handler,
readyCheck fiber.Handler,
chainUserLimiter fiber.Handler,
authMw fiber.Handler,
serverAuthMw fiber.Handler,
idempotencyReqMw fiber.Handler,
) {
api := app.Group("/api")
// Swagger UI
app.Get("/swagger/*", swagger.HandlerDefault)
// Health / Ready (rate limiter 밖)
app.Get("/health", healthCheck)
app.Get("/ready", readyCheck)
// Default 1MB body limit for API routes; upload endpoints are excluded
apiBodyLimit := middleware.BodyLimit(1*1024*1024, "/api/download/upload")
// ── Internal API (Rate Limit 제외, API Key 인증만) ──────────────
// 반드시 /api 그룹보다 먼저 등록해야 apiLimiter를 우회함
internalApi := app.Group("/api/internal", apiBodyLimit, serverAuthMw)
// Internal - Boss Raid
br := internalApi.Group("/bossraid")
br.Post("/entry", brH.RequestEntry)
br.Post("/start", brH.StartRaid)
br.Post("/complete", idempotencyReqMw, brH.CompleteRaid)
br.Post("/fail", brH.FailRaid)
br.Get("/room", brH.GetRoom)
br.Post("/validate-entry", brH.ValidateEntryToken)
br.Post("/register", brH.RegisterServer)
br.Post("/heartbeat", brH.Heartbeat)
br.Post("/reset-room", brH.ResetRoom)
br.Get("/server-status", brH.GetServerStatus)
// Internal - Auth
internalAuth := internalApi.Group("/auth")
internalAuth.Post("/verify", authH.VerifyToken)
// Internal - Player
internalPlayer := internalApi.Group("/player")
internalPlayer.Get("/profile", playerH.InternalGetProfile)
internalPlayer.Post("/save", playerH.InternalSaveGameData)
// Internal - Chain
internalChain := internalApi.Group("/chain")
internalChain.Post("/reward", idempotencyReqMw, chainH.InternalGrantReward)
internalChain.Post("/mint", idempotencyReqMw, chainH.InternalMintAsset)
internalChain.Get("/balance", chainH.InternalGetBalance)
internalChain.Get("/assets", chainH.InternalGetAssets)
internalChain.Get("/inventory", chainH.InternalGetInventory)
// ── Public API (Rate Limit 적용) ────────────────────────────────
api := app.Group("/api", apiLimiter, apiBodyLimit)
// Auth
a := api.Group("/auth")
a.Post("/login", authH.Login)
a.Post("/logout", middleware.Auth, authH.Logout)
a.Post("/register", authLimiter, authH.Register)
a.Post("/login", authLimiter, authH.Login)
a.Post("/refresh", authLimiter, authH.Refresh)
a.Post("/logout", authMw, authH.Logout)
// /verify moved to internal API (ServerAuth) — see internal section below
a.Get("/ssafy/login", authH.SSAFYLoginURL)
a.Post("/ssafy/callback", authLimiter, authH.SSAFYCallback)
a.Post("/launch-ticket", authMw, authH.CreateLaunchTicket)
a.Post("/redeem-ticket", authLimiter, authH.RedeemLaunchTicket)
// Users (admin only)
u := api.Group("/users", authMw, middleware.AdminOnly)
u.Get("/", authH.GetAllUsers)
u.Patch("/:id/role", authH.UpdateRole)
u.Delete("/:id", authH.DeleteUser)
// Announcements
ann := api.Group("/announcements")
ann.Get("/", annH.GetAll)
ann.Post("/", middleware.Auth, middleware.AdminOnly, annH.Create)
ann.Put("/:id", middleware.Auth, middleware.AdminOnly, annH.Update)
ann.Delete("/:id", middleware.Auth, middleware.AdminOnly, annH.Delete)
ann.Post("/", authMw, middleware.AdminOnly, annH.Create)
ann.Put("/:id", authMw, middleware.AdminOnly, annH.Update)
ann.Delete("/:id", authMw, middleware.AdminOnly, annH.Delete)
// Download
dl := api.Group("/download")
dl.Get("/info", dlH.GetInfo)
dl.Put("/info", middleware.Auth, middleware.AdminOnly, dlH.Upsert)
dl.Get("/file", dlH.ServeFile)
dl.Get("/launcher", dlH.ServeLauncher)
dl.Post("/upload/game", authMw, middleware.AdminOnly, dlH.Upload)
dl.Post("/upload/launcher", authMw, middleware.AdminOnly, dlH.UploadLauncher)
// Chain - Queries (authenticated)
ch := api.Group("/chain", authMw)
ch.Get("/wallet", chainH.GetWalletInfo)
ch.Get("/balance", chainH.GetBalance)
ch.Get("/assets", chainH.GetAssets)
ch.Get("/asset/:id", chainH.GetAsset)
ch.Get("/inventory", chainH.GetInventory)
ch.Get("/market", chainH.GetMarketListings)
ch.Get("/market/:id", chainH.GetMarketListing)
// Chain - User Transactions (authenticated, per-user rate limited, idempotency-protected)
ch.Post("/transfer", chainUserLimiter, idempotencyReqMw, chainH.Transfer)
ch.Post("/asset/transfer", chainUserLimiter, idempotencyReqMw, chainH.TransferAsset)
ch.Post("/market/list", chainUserLimiter, idempotencyReqMw, chainH.ListOnMarket)
ch.Post("/market/buy", chainUserLimiter, idempotencyReqMw, chainH.BuyFromMarket)
ch.Post("/market/cancel", chainUserLimiter, idempotencyReqMw, chainH.CancelListing)
ch.Post("/inventory/equip", chainUserLimiter, idempotencyReqMw, chainH.EquipItem)
ch.Post("/inventory/unequip", chainUserLimiter, idempotencyReqMw, chainH.UnequipItem)
// Chain - Admin Transactions (admin only, idempotency-protected)
chainAdmin := api.Group("/chain/admin", authMw, middleware.AdminOnly)
chainAdmin.Post("/mint", idempotencyReqMw, chainH.MintAsset)
chainAdmin.Post("/reward", idempotencyReqMw, chainH.GrantReward)
chainAdmin.Post("/template", idempotencyReqMw, chainH.RegisterTemplate)
// Player Profile (authenticated)
p := api.Group("/player", authMw)
p.Get("/profile", playerH.GetProfile)
p.Put("/profile", playerH.UpdateProfile)
}