Merge pull request 'test' (#420) from test into main

Reviewed-on: #420
This commit is contained in:
2026-05-01 06:40:58 +00:00
21 changed files with 833 additions and 172 deletions

227
AGENTS.md
View File

@@ -5,6 +5,113 @@
- 목표는 "추측 최소화 + 기존 패턴 준수 + 검증 우선"이다.
- 이 문서의 규칙은 코드/테스트/문서 변경 모두에 적용한다.
## 지시 우선순위
- 충돌 시 항상 더 높은 우선순위의 지시를 따른다.
- 우선순위는 다음 순서를 따른다.
1. 사용자 직접 지시
2. `AGENTS.md`
3. 프로젝트별 제약 조건
4. oh-my-openagent 플러그인의 agents / workflows / hooks
5. superpowers skills
6. 기본 모델 동작
## CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)
These principles override plugin behavior, skill behavior, workflow behavior, and default model behavior unless the user's direct instruction explicitly says otherwise.
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
## 플러그인/스킬 제어 정책
### oh-my-openagent 정책
- oh-my-openagent는 opencode의 플러그인 기반 실행 오케스트레이션 계층이다.
- oh-my-openagent는 의사결정 권한이 아니라 실행 보조 권한만 가진다.
- 작은 작업에는 multi-agent 실행이나 과도한 workflow를 사용하지 않는다.
- 병렬 실행은 명확한 이득이 있을 때만 사용한다.
- 모든 oh-my-openagent 동작은 CORE EXECUTION PRINCIPLES를 따라야 한다.
### superpowers 정책
- superpowers는 선택적 스킬 계층이다.
- superpowers skill은 필요한 경우에만 사용한다.
- superpowers가 과도한 리팩토링, 불필요한 범위 확장, 가정 기반 실행을 유도하면 따르지 않는다.
- superpowers를 사용할 때도 최소 변경, 단순성, 검증 가능성을 우선한다.
- 모든 superpowers 동작은 CORE EXECUTION PRINCIPLES를 따라야 한다.
## 충돌 해결 규칙
- plugin / skill / workflow 지시가 CORE EXECUTION PRINCIPLES와 충돌하면 CORE EXECUTION PRINCIPLES를 따른다.
- 사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다.
- 불확실하거나 모호한 경우 추측하지 말고 확인하거나, 가능한 최소 범위의 안전한 조치를 취한다.
## 실행 모드
- 기본 모드: 보수적 실행
- 최소 변경
- 단순한 구현
- 검증 가능한 결과
- 확장 모드:
- 사용자가 명시적으로 요청한 경우에만 사용한다.
- 대규모 리팩토링, 브레인스토밍, 다중 에이전트 실행, 병렬 workflow를 허용한다.
## 커뮤니케이션 규칙
- **"질문에 대한 답변과 설명은 한국어로 한다."**
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
@@ -31,82 +138,17 @@
./gradlew ktlintFormat
```
## 코드 스타일 규칙
## 프로젝트 핵심 규칙
- Kotlin/Spring 스타일, 테스트 스타일, 보안 유의사항, 작업 절차, 문서 유지보수 상세 규칙은 아래 문서를 따른다.
- `docs/agent-guides/코드스타일.md`
- `docs/agent-guides/테스트스타일.md`
- `docs/agent-guides/설정보안.md`
- `docs/agent-guides/작업절차.md`
- `docs/agent-guides/문서유지보수.md`
- 공개 API 스키마는 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 기존 코드베이스 관례를 우선하며, 불확실한 규칙은 추측하지 말고 근거 파일을 먼저 확인한다.
### 1) 포맷/기본 규칙
- `.editorconfig` 기준을 준수한다.
- 인덴트: 공백 4칸.
- 줄바꿈: LF.
- 최대 라인 길이: 130.
- 파일 끝 개행 유지, trailing whitespace 제거.
### 2) import 규칙
- 와일드카드 import(`*`)를 사용하지 않는다.
- 사용하지 않는 import를 남기지 않는다.
- import alias(`as`)는 현재 코드베이스에서 사용 사례가 없으므로 지양한다.
- 기존 파일의 import 정렬/그룹 스타일을 그대로 맞춘다.
### 3) 네이밍 규칙
- 클래스/인터페이스/enum: PascalCase.
- 함수/변수/파라미터: camelCase.
- 상수: UPPER_SNAKE_CASE (`companion object` 내부 `const val`).
- Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다.
- 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다.
### 4) 타입/널 처리
- Kotlin 타입 시스템을 활용하고 nullable(`?`)를 명시한다.
- 불필요한 `Any`/약한 타입을 피하고 구체 타입을 우선한다.
- 기존 코드에서 `!!` 사용이 많지만, 신규 코드는 가능한 안전 호출/가드절/명시적 예외로 대체를 우선 고려한다.
### 5) API/응답 규칙
- API 응답은 `ApiResponse.ok(...)`, `ApiResponse.error(...)` 패턴을 따른다.
- 컨트롤러는 도메인 예외를 직접 포맷하지 말고 `SodaException`을 던진다.
- 인증 사용자 필요 시 `@AuthenticationPrincipal(... ) member: Member?` 패턴 + null 가드절을 사용한다.
### 6) 예외 처리 규칙
- 비즈니스 예외는 `SodaException(messageKey = "...")` 우선 사용.
- 사용자 노출 문구는 하드코딩보다 `messageKey` 기반 i18n을 우선한다.
- 공통 예외 변환은 `SodaExceptionHandler`에서 수행하므로, 개별 컨트롤러에서 중복 처리하지 않는다.
- 예외를 삼키는 빈 `catch` 블록을 금지한다.
### 7) 트랜잭션 규칙
- 서비스 계층에서 `@Transactional`을 사용한다.
- 조회 위주 메서드는 `@Transactional(readOnly = true)`를 우선한다.
- 쓰기 로직은 메서드 단위 `@Transactional`로 경계를 명확히 한다.
### 8) 비동기/동시성 규칙
- 비동기 처리는 Kotlin Coroutines 패턴을 따른다.
- `CoroutineScope(Dispatchers.IO)` + `launch` + 예외 처리 패턴을 일관되게 유지한다.
- 생명주기 종료 시 scope 정리(`@PreDestroy`) 패턴을 참고한다.
### 9) 의존성 주입
- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다.
- 필드 주입보다 명시적 생성자 주입을 우선한다.
### 10) 주석
- 의미 단위별로 주석을 작성한다.
- 주석은 한 문장으로 간결하게 작성한다.
- 주석은 코드의 의도와 구조를 설명한다.
- 주석은 코드 변경 시 업데이트를 잊지 않는다.
## 테스트 스타일 규칙
- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`)
- 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``)
- 검증: `assertEquals`, `assertThrows` 패턴 준수.
- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다.
- 테스트는 DisplayName으로 한국어 설명을 추가한다.
- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다.
## 설정/보안 유의사항
- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다.
- 비밀값(API Key, Secret, Token, DB 비밀번호)을 코드/문서/로그에 평문으로 남기지 않는다.
- 환경변수/시크릿 파일은 커밋 대상에서 제외한다.
## Cursor/Copilot 규칙 반영
`/.cursorrules`, `/.cursor/rules/`, `/.github/copilot-instructions.md` 파일은 현재 없다.
별도 규칙 파일이 추가되면 본 문서보다 해당 규칙을 우선 반영한다.
## 커밋 메시지 규칙 (표준 Conventional Commits)
## 커밋 메시지 규칙
- 커밋 상세 가이드/절차는 `.opencode/skills/commit-policy/SKILL.md`를 단일 기준으로 사용한다.
- 커밋 작업 시작 시 `skill` 도구로 `commit-policy`를 먼저 로드한다.
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
@@ -114,43 +156,16 @@
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
- 커밋 본문에는 `Ultraworked with [Sisyphus]...``Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` 자동 footer를 포함하지 않는다.
### 커밋 메시지 검증 절차
- `git commit` 실행 직전에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
- `git commit` 실행 직후에도 `work/scripts/check-commit-message-rules.sh`를 다시 실행해 최종 메시지를 재검증한다.
- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 규칙에 맞게 수정한 뒤 다시 검증한다.
- 커밋 실행 시 검증한 메시지를 그대로 사용하고, 도구 기본 footer가 자동 추가되지 않도록 최종 커밋 본문을 확인한다.
## 작업 절차 체크리스트
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다.
- 커밋 전/후 확인 시 Sisyphus attribution footer가 없는지 함께 검증한다.
- `git commit` 실행 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 검증한다.
## 작업 계획 문서 규칙 (docs)
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
- 연속된 하나의 작업에 대한 후속 수정/보완이라면 새 계획 문서를 만들지 말고 기존 계획 문서에 작업 항목과 검증 기록을 이어서 추가한다.
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- 파일명 예시: `20260101_구글계정으로로그인.md`
- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다.
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다.
- 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다.
- 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰지 않고 누적한다(예: `1차 구현`, `2차 수정`).
- 검증 기록은 단계별로 `무엇을/왜/어떻게`를 유지해 작성하고, 이전 단계와 구분이 되도록 명시한다.
- 단계별 `어떻게`에는 실제 실행한 검증 명령과 결과(성공/실패/불가 사유)를 함께 기록한다.
- 기존 기록 정정이 필요하면 원문을 지우지 말고 `정정` 항목을 추가해 사유와 변경 내용을 남긴다.
## 문서 유지보수 규칙
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
- Cursor/Copilot 규칙 파일이 생기면 해당 내용을 이 문서에 반영한다.
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다.
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 범위가 바뀌면 문서를 먼저 갱신한다.
- 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 누적 기록한다.
## 에이전트 동작 원칙
- 추측하지 말고, 근거 파일을 읽고 결정한다.

View File

@@ -0,0 +1,35 @@
# 20260429 에이전트 가이드 통합 정리
## 구현 계획
- [x] 기존 `AGENTS.md`의 우선순위, 에이전트 행동, 스킬/워크플로우 관련 항목을 분석한다.
- [x] 공식 `andrej-karpathy-skills` `CLAUDE.md` 원문을 확인하고 삽입 위치와 래퍼 문구를 확정한다.
- [x] `AGENTS.md` 상단 근처에 `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)` 섹션을 추가한다.
- [x] 사용자 지시 > `AGENTS.md` > 프로젝트별 제약 조건 > oh-my-openagent > superpowers > 기본 모델 동작 우선순위를 명시한다.
- [x] oh-my-openagent, superpowers, 충돌 해결 규칙, 실행 모드를 한국어 정책으로 추가한다.
- [x] `AGENTS.md`에는 핵심 규칙만 남기고 세부 규칙은 `docs/agent-guides/` 아래 별도 문서로 분리해 참조하도록 정리한다.
- [x] 기존 `docs/20260429_에이전트가이드상세규칙.md`의 섹션 구성을 분석하고 분리 단위를 확정한다.
- [x] `docs/agent-guides/` 아래에 주제별 문서를 생성한다.
- [x] `AGENTS.md`가 분리된 문서를 직접 참조하도록 수정한다.
- [x] 기존 `docs/20260429_에이전트가이드상세규칙.md`를 제거한다.
- [x] `docs/agent-guides/` 하위 파일명에서 `에이전트가이드` 접두를 제거한 새 이름 규칙을 확정한다.
- [x] `docs/agent-guides/` 하위 파일명을 `코드스타일.md`, `테스트스타일.md`, `설정보안.md`, `작업절차.md`, `문서유지보수.md`로 정리한다.
- [x] `AGENTS.md`와 연결된 작업 기록 문서의 참조 경로를 새 파일명 기준으로 갱신한다.
- [x] 문서 변경 검증을 수행하고 결과를 기록한다.
## 검증 기록
- [x] 작업 완료 후 검증 결과를 기록한다.
- 1차 구현
- 무엇을: `AGENTS.md`에 명시적 지시 우선순위, `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)` 원문, oh-my-openagent/superpowers 제어 정책, 충돌 해결 규칙, 실행 모드를 추가했다. 동시에 Kotlin/Spring 스타일·테스트·보안·문서 유지보수의 상세 규칙은 `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/설정보안.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`로 분리하고 `AGENTS.md`는 핵심 규칙과 참조만 남기도록 정리했다.
- 왜: 기존 `AGENTS.md`는 프로젝트 규칙은 충분했지만 명시적 우선순위 체계와 플러그인/스킬 통제 계층이 없었고, 세부 규칙이 길어져 상단에서 핵심 실행 원칙을 빠르게 파악하기 어려웠기 때문이다.
- 어떻게: `AGENTS.md`, `docs/20260429_에이전트가이드통합정리.md`, `docs/agent-guides/` 아래 분리 문서를 다시 읽어 필수 문구, 영어 원문 유지, 한국어 정책 분리, 참조 경로를 확인했다. `lsp_diagnostics`로 관련 문서 모두 `No diagnostics found`를 확인하고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2차 정리
- 무엇을: 동일 작업을 별도로 기록하던 `docs/20260429_에이전트가이드분리.md`의 구현 계획과 검증 범위를 이 문서로 통합하고, 중복 작업 문서는 제거했다.
- 왜: 두 문서가 모두 같은 날짜의 동일 작업 흐름을 기록하고 있었고, 하나는 `AGENTS.md` 재구성, 다른 하나는 세부 규칙 분리라는 하위 단계만 다를 뿐 논리적으로 하나의 작업 계획 문서였기 때문이다.
- 어떻게: 두 문서의 구현 계획과 검증 기록을 대조해 누락된 분리 작업 항목만 이 문서에 합쳤고, 이후 `grep`으로 두 문서명 참조를 확인한 뒤 중복 문서를 제거했다. 마지막으로 `lsp_diagnostics``./gradlew tasks --all`로 문서 정합성과 저장소 문서 검증 명령 성공을 다시 확인했다.
- 3차 정리
- 무엇을: `docs/agent-guides/` 하위 파일명에서 `에이전트가이드` 접두를 제거해 `코드스타일.md`, `테스트스타일.md`, `설정보안.md`, `작업절차.md`, `문서유지보수.md`로 정리했다. 함께 `AGENTS.md`와 이 문서의 참조 경로도 새 파일명 기준으로 수정했고, 별도 작업 문서였던 `docs/20260429_agent_guides파일명정리.md`는 이 문서로 통합했다.
- 왜: 디렉터리 경로 자체가 이미 `agent-guides`라서 파일명에 동일한 의미를 반복할 필요가 없고, AGENTS 가이드 정비의 후속 단계 역시 별도 문서보다 하나의 연속 작업 기록 안에 남기는 편이 더 자연스럽기 때문이다.
- 어떻게: `grep`으로 기존 `docs/agent-guides/에이전트가이드*.md` 참조가 남지 않았음을 확인했고, `glob`으로 새 파일명 5개만 존재함을 확인했다. `lsp_diagnostics``AGENTS.md`, 이 문서, `docs/agent-guides/` 전체에 대해 오류가 없음을 확인했고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,15 @@
# 20260429 연속 작업 계획 문서 재사용 규칙
## 구현 계획
- [x] 작업 계획 문서 생성과 관련된 기존 규칙 위치를 확인한다.
- [x] 연속된 하나의 작업일 때는 새 계획 문서를 만들지 않고 기존 문서에 이어서 기록하는 규칙을 추가한다.
- [x] 관련 문서 간 표현과 의도를 일치시킨다.
- [x] 문서 진단과 검증 결과를 기록한다.
## 검증 기록
- [x] 작업 완료 후 검증 결과를 기록한다.
- 1차 구현
- 무엇을: `AGENTS.md``작업 계획 문서 규칙 (docs)`에 연속된 하나의 작업이라면 새 계획 문서를 만들지 않고 기존 계획 문서에 작업 항목과 검증 기록을 이어서 추가한다는 규칙을 넣었다. 함께 `docs/agent-guides/작업절차.md``docs/agent-guides/문서유지보수.md`에도 같은 취지의 보완 문구를 추가해 실행 흐름과 문서 정책이 일치하도록 정리했다.
- 왜: 같은 작업의 후속 수정마다 새 계획 문서가 계속 생성되면 문서 수가 불필요하게 늘어나고, 작업 이력도 여러 파일로 흩어져 관리와 탐색이 어려워지기 때문이다.
- 어떻게: `read`로 세 문서의 해당 섹션을 다시 확인해 문구 위치와 의미 일치를 검토했고, `lsp_diagnostics``AGENTS.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/20260429_연속작업계획문서재사용규칙.md`에 대해 모두 `No diagnostics found`를 확인했다. 추가로 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,54 @@
# 채팅방 쿼터 충전 방식 확장
## 구현 계획
- [x] `purchaseRoomQuota` 요청 DTO만 보고도 `캔 충전`인지 `광고 충전`인지 구분할 수 있도록 충전 방식 필드를 확장하고, 광고 충전 요청을 구분할 수 있는 최소 스키마를 정의한다.
- [x] 캔 충전 옵션을 기존 단일 고정값에서 `10캔 → 15개`, `20캔 → 40개`의 2개 시나리오로 분기하도록 컨트롤러/서비스 전달 구조를 정리한다.
- [x] `ChatRoomQuotaService.purchase`의 하드코딩된 `needCan=10`, `addPaid=12` 로직을 요청값 기반 분기로 치환하고, 광고 충전 시 캔 차감 없이 `5` quota를 지급하는 경로를 분리한다.
- [x] 광고 충전은 1회당 `5` quota 지급으로 반영하고, 중복 호출 방지 여부와 추가 검증 조건만 별도 확인한다.
- [x] 관련 테스트 또는 최소 검증 경로를 정리하고 `./gradlew test` 기준으로 회귀 여부를 확인한다.
## 요구사항 반영 체크
- [x] Quota 충전 방식은 `캔 충전`, `광고 보고 충전`의 2가지로 확장한다.
- [x] 서버는 요청 DTO를 통해 `캔 충전`인지 `광고 충전`인지 식별할 수 있어야 한다.
- [x] 캔 충전 옵션은 `10캔 → 15개`, `20캔 → 40개`의 2가지다.
- [x] 광고 충전은 1회당 `5` quota를 지급한다.
## 영향 범위 메모
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt`
- 요청 DTO와 `purchaseRoomQuota` 분기 진입점 변경 대상.
- `src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt`
- 캔 사용량/유료 quota 지급량 하드코딩 제거 및 충전 방식별 처리 분기 대상.
- 테스트 코드
- 현재 `src/test/kotlin`에서 `ChatRoomQuota` 관련 테스트가 바로 확인되지 않아, 필요 시 서비스 중심 테스트를 새로 보강한다.
## 확인 필요 사항
- 광고 충전은 앱에서 선처리한다고 했으므로 서버는 DTO 기반 분기와 1회당 `5` quota 지급만 담당하고, 광고 시청 자체의 검증 책임은 범위 밖으로 본다.
- 기존 클라이언트 호환을 위해 요청 본문에 `container`만 오면 서버는 기본값으로 `CAN + CAN_10` 충전으로 해석한다.
- 광고 충전 요청에 `canOption`이 함께 오더라도 서버는 예외를 내지 않고 값을 무시한 뒤 광고 충전으로 처리한다.
## 검증 기록
### 1차 계획 수립
- 무엇을: `purchaseRoomQuota` API에 광고 충전 구분과 2종 캔 충전 옵션을 추가하기 위한 구현 계획과 예상 영향 범위를 문서화했다.
- 왜: 현재 구현은 `container`만 받고 서비스에서 `10캔 → 12개 quota`를 고정 처리하고 있어, 요구사항을 반영하려면 DTO/서비스 분기와 미정 항목을 먼저 분리해 두는 것이 안전하기 때문이다.
- 어떻게: `ChatRoomQuotaController.kt`, `ChatRoomQuotaService.kt`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, 기존 계획 문서 예시를 읽어 형식과 영향 범위를 맞췄고, 문서 변경 후 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
### 2차 요구사항 점검 반영
- 무엇을: 문서가 충전 방식 2종, 요청 DTO를 통한 충전 방식 식별, 캔 충전 2가지 옵션, 광고 충전 5개 지급을 모두 명시적으로 포함하도록 보강했다.
- 왜: 기존 문서도 대부분의 요구사항을 담고 있었지만, 요구사항 전체를 한눈에 대조할 수 있는 체크 형태가 없어 일부 표현이 암묵적으로 읽힐 여지가 있었기 때문이다.
- 어떻게: 계획 문서 본문에 `요구사항 반영 체크` 섹션을 추가하고 핵심 조건을 체크박스로 정리했으며, 문서 변경 후 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 다시 확인했다.
### 3차 구현 및 검증
- 무엇을: `purchaseRoomQuota` 요청 DTO에 충전 방식과 캔 옵션 enum을 추가하고, 컨트롤러에서 `캔 충전``광고 충전`을 분기하도록 변경했다. 함께 `ChatRoomQuotaService``purchaseWithCan`, `purchaseWithAd` 경로로 분리해 `10캔 → 15개`, `20캔 → 40개`, `광고 → 5개` 지급 규칙을 반영했다. 또한 `ChatRoomQuotaControllerTest`, `ChatRoomQuotaServiceTest`를 추가해 컨트롤러 분기와 서비스 적립 로직을 검증했다.
- 왜: 기존 구현은 `container`만 받고 `10캔 → 12개`를 단일 하드코딩으로 처리해 새로운 충전 방식과 옵션을 구분할 수 없었기 때문이다.
- 어떻게: `ChatRoomQuotaController.kt`, `ChatRoomQuotaService.kt`에 최소 분기 로직을 추가하고 `ChatRoomQuotaPurchaseOption.kt`에 enum을 분리했다. 검증은 Kotlin LSP가 이 환경에서 지원되지 않아 진단 대신 `./gradlew test --tests "kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaServiceTest" --tests "kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaControllerTest"`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
### 4차 하위 호환 보완
- 무엇을: 기존 클라이언트가 `container`만 보내도 동작하도록 `PurchaseRoomQuotaRequest`의 기본 충전 방식을 `CAN`, 기본 캔 옵션을 `CAN_10`으로 해석하도록 보완했고, 이를 검증하는 테스트를 추가했다.
- 왜: 새 DTO 필드를 필수로 두면 구버전 클라이언트의 기존 요청 본문이 역직렬화 단계에서 바로 깨질 수 있기 때문이다.
- 어떻게: `ChatRoomQuotaController.kt`에서 `chargeType` 기본값을 `CAN`으로 두고 `canOption` 누락 시 `CAN_10`을 사용하도록 변경했다. 검증은 `ChatRoomQuotaControllerTest``container`만 전달하는 직접 호출 케이스를 추가해 수행했다.
### 5차 광고 옵션 허용 완화
- 무엇을: 광고 충전 요청에 `canOption`이 함께 들어와도 예외를 던지지 않고 무시하도록 컨트롤러 분기를 완화했고, 해당 요청이 정상적으로 광고 충전 경로로 전달되는 테스트로 기대값을 바꿨다.
- 왜: 광고 충전은 지급량이 고정이라 `canOption`이 의미 없는 부가 필드에 가깝고, 이를 이유로 요청을 실패시키는 것보다 무시하는 편이 클라이언트 호환성과 운영 안정성에 유리하기 때문이다.
- 어떻게: `ChatRoomQuotaController.kt``AD` 분기에서 `canOption` 검증 예외를 제거했고, `ChatRoomQuotaControllerTest`의 광고 케이스를 성공 경로 검증으로 변경했다.

View File

@@ -0,0 +1,23 @@
# Payverse JPY 지원 작업 계획
- [x] 요구사항 정리
- JPY 전용 자격 증명 사용
- `payverseCharge`, `payverseWebhook`, `payverseVerify` 모두 일관 분기 추가
- 금액 포맷: JPY는 강제 정수화(소수점 버림)
- 결제수단 표기는 현행 규칙 유지
- [x] 구현 항목
- [x] 환경변수 주입: `payverse.jpy-mid`, `payverse.jpy-client-key`, `payverse.jpy-secret-key`
- [x] `ChargeService.payverseCharge`에 JPY 분기 및 금액 포맷 적용
- [x] `ChargeService.payverseWebhook`에 JPY 분기 및 금액 검증 적용
- [x] `ChargeService.payverseVerify`에 JPY 분기 및 금액 검증 적용
- [x] 공통 금액 포맷 함수 `computePayverseAmount` 추가 (JPY=버림, 그외=4자리 반올림)
- [ ] 검증 항목
- [ ] 단위/통합 테스트 빌드 및 실행 (`./gradlew test`)
- [ ] KRW/JPY/USD 각각에 대해 payload 서명 및 검증 로직 수기 점검
- [ ] JPY에서 `requestAmount`가 항상 정수로 전송되는지 로깅/샘플 요청으로 확인(스테이징)
## 검증 로그
- [ ] 빌드/테스트 결과:
- [ ] 수기 점검 결과:

View File

@@ -0,0 +1,12 @@
# 문서 유지보수
## 문서 유지보수 규칙
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
- Cursor/Copilot 규칙 파일이 생기면 해당 내용을 이 문서에 반영한다.
- 연속된 하나의 작업에 대해 계획 문서가 여러 개 생기지 않도록 기존 계획 문서 재사용 여부를 먼저 확인한다.
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다.

View File

@@ -0,0 +1,6 @@
# 설정 보안
## 설정/보안 유의사항
- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다.
- 비밀값(API Key, Secret, Token, DB 비밀번호)을 코드/문서/로그에 평문으로 남기지 않는다.
- 환경변수/시크릿 파일은 커밋 대상에서 제외한다.

View File

@@ -0,0 +1,9 @@
# 작업 절차
## 작업 절차 체크리스트
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 전: 같은 작업의 연속 후속 수정인지 먼저 확인하고, 연속 작업이면 새 계획 문서를 만들지 말고 기존 계획 문서를 갱신한다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다.
- 커밋 전/후 확인 시 Sisyphus attribution footer가 없는지 함께 검증한다.

View File

@@ -0,0 +1,59 @@
# 코드 스타일
## 코드 스타일 규칙
### 1) 포맷/기본 규칙
- `.editorconfig` 기준을 준수한다.
- 인덴트: 공백 4칸.
- 줄바꿈: LF.
- 최대 라인 길이: 130.
- 파일 끝 개행 유지, trailing whitespace 제거.
### 2) import 규칙
- 와일드카드 import(`*`)를 사용하지 않는다.
- 사용하지 않는 import를 남기지 않는다.
- import alias(`as`)는 현재 코드베이스에서 사용 사례가 없으므로 지양한다.
- 기존 파일의 import 정렬/그룹 스타일을 그대로 맞춘다.
### 3) 네이밍 규칙
- 클래스/인터페이스/enum: PascalCase.
- 함수/변수/파라미터: camelCase.
- 상수: UPPER_SNAKE_CASE (`companion object` 내부 `const val`).
- Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다.
- 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다.
### 4) 타입/널 처리
- Kotlin 타입 시스템을 활용하고 nullable(`?`)를 명시한다.
- 불필요한 `Any`/약한 타입을 피하고 구체 타입을 우선한다.
- 기존 코드에서 `!!` 사용이 많지만, 신규 코드는 가능한 안전 호출/가드절/명시적 예외로 대체를 우선 고려한다.
### 5) API/응답 규칙
- API 응답은 `ApiResponse.ok(...)`, `ApiResponse.error(...)` 패턴을 따른다.
- 컨트롤러는 도메인 예외를 직접 포맷하지 말고 `SodaException`을 던진다.
- 인증 사용자 필요 시 `@AuthenticationPrincipal(... ) member: Member?` 패턴 + null 가드절을 사용한다.
### 6) 예외 처리 규칙
- 비즈니스 예외는 `SodaException(messageKey = "...")` 우선 사용.
- 사용자 노출 문구는 하드코딩보다 `messageKey` 기반 i18n을 우선한다.
- 공통 예외 변환은 `SodaExceptionHandler`에서 수행하므로, 개별 컨트롤러에서 중복 처리하지 않는다.
- 예외를 삼키는 빈 `catch` 블록을 금지한다.
### 7) 트랜잭션 규칙
- 서비스 계층에서 `@Transactional`을 사용한다.
- 조회 위주 메서드는 `@Transactional(readOnly = true)`를 우선한다.
- 쓰기 로직은 메서드 단위 `@Transactional`로 경계를 명확히 한다.
### 8) 비동기/동시성 규칙
- 비동기 처리는 Kotlin Coroutines 패턴을 따른다.
- `CoroutineScope(Dispatchers.IO)` + `launch` + 예외 처리 패턴을 일관되게 유지한다.
- 생명주기 종료 시 scope 정리(`@PreDestroy`) 패턴을 참고한다.
### 9) 의존성 주입
- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다.
- 필드 주입보다 명시적 생성자 주입을 우선한다.
### 10) 주석
- 의미 단위별로 주석을 작성한다.
- 주석은 한 문장으로 간결하게 작성한다.
- 주석은 코드의 의도와 구조를 설명한다.
- 주석은 코드 변경 시 업데이트를 잊지 않는다.

View File

@@ -0,0 +1,9 @@
# 테스트 스타일
## 테스트 스타일 규칙
- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`)
- 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``)
- 검증: `assertEquals`, `assertThrows` 패턴 준수.
- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다.
- 테스트는 DisplayName으로 한국어 설명을 추가한다.
- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다.

View File

@@ -17,8 +17,12 @@ class CanController(private val service: CanService) {
fun getCans(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<List<CanResponse>> {
val isNotSelectedCurrency = member != null && member.id == 2L
return ApiResponse.ok(service.getCans(isNotSelectedCurrency = isNotSelectedCurrency))
val forcedCurrency = if (member != null && (member.id == 2L || member.id == 4L || member.id == 44144L)) {
"JPY"
} else {
null
}
return ApiResponse.ok(service.getCans(forcedCurrency = forcedCurrency))
}
@GetMapping("/status")

View File

@@ -14,14 +14,10 @@ class CanService(
private val repository: CanRepository,
private val countryContext: CountryContext
) {
fun getCans(isNotSelectedCurrency: Boolean): List<CanResponse> {
val currency = if (isNotSelectedCurrency) {
null
} else {
when (countryContext.countryCode) {
"KR" -> "KRW"
else -> "USD"
}
fun getCans(forcedCurrency: String? = null): List<CanResponse> {
val currency = forcedCurrency ?: when (countryContext.countryCode) {
"KR" -> "KRW"
else -> "USD"
}
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
}

View File

@@ -85,6 +85,13 @@ class ChargeService(
@Value("\${payverse.usd-secret-key}")
private val payverseUsdSecretKey: String,
@Value("\${payverse.jpy-mid}")
private val payverseJpyMid: String,
@Value("\${payverse.jpy-client-key}")
private val payverseJpyClientKey: String,
@Value("\${payverse.jpy-secret-key}")
private val payverseJpySecretKey: String,
@Value("\${payverse.host}")
private val payverseHost: String,
@@ -106,18 +113,18 @@ class ChargeService(
return when (charge.payment?.status) {
PaymentStatus.REQUEST -> {
// 성공 조건 검증
val mid = if (request.requestCurrency == "KRW") {
payverseMid
} else {
payverseUsdMid
val mid = when (request.requestCurrency) {
"KRW" -> payverseMid
"JPY" -> payverseJpyMid
else -> payverseUsdMid
}
val expectedSign = DigestUtils.sha512Hex(
String.format(
"||%s||%s||%s||%s||%s||",
if (request.requestCurrency == "KRW") {
payverseSecretKey
} else {
payverseUsdSecretKey
when (request.requestCurrency) {
"KRW" -> payverseSecretKey
"JPY" -> payverseJpySecretKey
else -> payverseUsdSecretKey
},
mid,
request.orderId,
@@ -126,9 +133,8 @@ class ChargeService(
)
)
val isAmountMatch = request.requestAmount.compareTo(
charge.payment!!.price
) == 0
val expectedAmount = computePayverseAmount(charge.payment!!.price, request.requestCurrency)
val isAmountMatch = request.requestAmount.compareTo(expectedAmount) == 0
val isSuccess = request.resultStatus == "SUCCESS" &&
request.mid == mid &&
@@ -241,21 +247,20 @@ class ChargeService(
?: throw SodaException(messageKey = "can.charge.invalid_request_restart")
val requestCurrency = can.currency
val isKrw = requestCurrency == "KRW"
val mid = if (isKrw) {
payverseMid
} else {
payverseUsdMid
val mid = when (requestCurrency) {
"KRW" -> payverseMid
"JPY" -> payverseJpyMid
else -> payverseUsdMid
}
val clientKey = if (isKrw) {
payverseClientKey
} else {
payverseUsdClientKey
val clientKey = when (requestCurrency) {
"KRW" -> payverseClientKey
"JPY" -> payverseJpyClientKey
else -> payverseUsdClientKey
}
val secretKey = if (isKrw) {
payverseSecretKey
} else {
payverseUsdSecretKey
val secretKey = when (requestCurrency) {
"KRW" -> payverseSecretKey
"JPY" -> payverseJpySecretKey
else -> payverseUsdSecretKey
}
val charge = Charge(can.can, can.rewardCan)
@@ -270,12 +275,7 @@ class ChargeService(
val savedCharge = chargeRepository.save(charge)
val chargeId = savedCharge.id!!
val amount = BigDecimal(
savedCharge.payment!!.price
.setScale(4, RoundingMode.HALF_UP)
.stripTrailingZeros()
.toPlainString()
)
val amount = computePayverseAmount(savedCharge.payment!!.price, requestCurrency)
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
val sign = DigestUtils.sha512Hex(
String.format(
@@ -312,16 +312,16 @@ class ChargeService(
val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
val isKrw = charge.can?.currency == "KRW"
val mid = if (isKrw) {
payverseMid
} else {
payverseUsdMid
val currency = charge.can?.currency
val mid = when (currency) {
"KRW" -> payverseMid
"JPY" -> payverseJpyMid
else -> payverseUsdMid
}
val clientKey = if (isKrw) {
payverseClientKey
} else {
payverseUsdClientKey
val clientKey = when (currency) {
"KRW" -> payverseClientKey
"JPY" -> payverseJpyClientKey
else -> payverseUsdClientKey
}
// 결제수단 확인
@@ -351,11 +351,12 @@ class ChargeService(
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
val customerId = "${serverEnv}_user_${member.id!!}"
val expectedAmount = computePayverseAmount(charge.can!!.price, charge.can!!.currency)
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
verifyResponse.transactionStatus == "SUCCESS" &&
verifyResponse.orderId.toLongOrNull() == charge.id &&
verifyResponse.customerId == customerId &&
verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0
verifyResponse.requestAmount.compareTo(expectedAmount) == 0
if (isSuccess) {
// verify 함수의 232~248 라인과 동일 처리
@@ -737,4 +738,16 @@ class ChargeService(
}
return messageSource.getMessage("can.charge.payment_method.card", langContext.lang)
}
// Payverse 금액 포맷: 통화별 규칙 적용
private fun computePayverseAmount(price: BigDecimal, currency: String): BigDecimal {
val scaled = if (currency == "JPY") {
// JPY: 강제 정수화, 소수점 버림
price.setScale(0, RoundingMode.FLOOR)
} else {
// 그 외: 4자리까지 반올림 후 불필요 0 제거
price.setScale(4, RoundingMode.HALF_UP).stripTrailingZeros()
}
return BigDecimal(scaled.stripTrailingZeros().toPlainString())
}
}

View File

@@ -27,7 +27,9 @@ class ChatRoomQuotaController(
) {
data class PurchaseRoomQuotaRequest(
val container: String
val container: String,
val chargeType: ChatRoomQuotaChargeType = ChatRoomQuotaChargeType.CAN,
val canOption: ChatRoomQuotaCanOption? = null
)
data class PurchaseRoomQuotaResponse(
@@ -45,8 +47,9 @@ class ChatRoomQuotaController(
/**
* 채팅방 유료 쿼터 구매 API
* - 참여 여부 검증(내가 USER로 참여 중인 활성 방)
* - 30캔 결제 (UseCan에 chatRoomId:characterId 기록)
* - 방 유료 쿼터 40 충전
* - 요청 DTO로 캔 충전 / 광고 충전을 구분
* - 캔 충전은 옵션별 캔 차감 후 방 유료 쿼터 지급
* - 광고 충전은 캔 차감 없이 방 유료 쿼터 5 지급
*/
@PostMapping("/{chatRoomId}/quota/purchase")
fun purchaseRoomQuota(
@@ -74,13 +77,28 @@ class ChatRoomQuotaController(
val characterId = character.id
?: throw SodaException(messageKey = "chat.room.quota.character_required")
val status = chatRoomQuotaService.purchase(
memberId = member.id!!,
chatRoomId = chatRoomId,
characterId = characterId,
addPaid = 12,
container = req.container
)
val chargeType = req.chargeType
val status = when (chargeType) {
ChatRoomQuotaChargeType.CAN -> {
val canOption = req.canOption ?: ChatRoomQuotaCanOption.CAN_10
chatRoomQuotaService.purchaseWithCan(
memberId = member.id!!,
chatRoomId = chatRoomId,
characterId = characterId,
canOption = canOption,
container = req.container
)
}
ChatRoomQuotaChargeType.AD -> {
chatRoomQuotaService.purchaseWithAd(
memberId = member.id!!,
chatRoomId = chatRoomId,
characterId = characterId
)
}
}
ApiResponse.ok(
PurchaseRoomQuotaResponse(

View File

@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.chat.quota.room
enum class ChatRoomQuotaChargeType {
CAN,
AD
}
enum class ChatRoomQuotaCanOption(
val needCan: Int,
val quota: Int
) {
CAN_10(10, 15),
CAN_20(20, 40)
}

View File

@@ -13,6 +13,10 @@ class ChatRoomQuotaService(
private val repo: ChatRoomQuotaRepository,
private val canPaymentService: CanPaymentService
) {
companion object {
private const val AD_REWARD_QUOTA = 5
}
data class RoomQuotaStatus(
val totalRemaining: Int,
val nextRechargeAtEpochMillis: Long?,
@@ -122,23 +126,50 @@ class ChatRoomQuotaService(
}
@Transactional
fun purchase(
fun purchaseWithCan(
memberId: Long,
chatRoomId: Long,
characterId: Long,
addPaid: Int = 12,
canOption: ChatRoomQuotaCanOption,
container: String
): RoomQuotaStatus {
// 요구사항: 10캔 결제 및 UseCan에 방/캐릭터 기록
canPaymentService.spendCan(
memberId = memberId,
needCan = 10,
needCan = canOption.needCan,
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
chatRoomId = chatRoomId,
characterId = characterId,
container = container
)
return addPaidQuota(
memberId = memberId,
chatRoomId = chatRoomId,
characterId = characterId,
addPaid = canOption.quota
)
}
@Transactional
fun purchaseWithAd(
memberId: Long,
chatRoomId: Long,
characterId: Long
): RoomQuotaStatus {
return addPaidQuota(
memberId = memberId,
chatRoomId = chatRoomId,
characterId = characterId,
addPaid = AD_REWARD_QUOTA
)
}
private fun addPaidQuota(
memberId: Long,
chatRoomId: Long,
characterId: Long,
addPaid: Int
): RoomQuotaStatus {
val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save(
ChatRoomQuota(
memberId = memberId,

View File

@@ -252,7 +252,7 @@ class MemberService(
gender = gender,
signupDate = signUpDate,
chargeCount = chargeCount,
role = member.role,
role = if (member.role == MemberRole.CREATOR) MemberRole.CREATOR else MemberRole.USER,
messageNotice = member.notification?.message,
followingChannelLiveNotice = member.notification?.live,
followingChannelUploadContentNotice = member.notification?.uploadContent,

View File

@@ -2,7 +2,7 @@ package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L)
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L, 44144L)
private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 37543L, 40850L)
fun resolveCountryCodeWithForcedMapping(member: Member, requestCountryCode: String?): String {

View File

@@ -23,6 +23,9 @@ payverse:
usdMid: ${PAYVERSE_USD_MID}
usdClientKey: ${PAYVERSE_USD_CLIENT_KEY}
usdSecretKey: ${PAYVERSE_USD_SECRET_KEY}
jpyMid: ${PAYVERSE_JPY_MID}
jpyClientKey: ${PAYVERSE_JPY_CLIENT_KEY}
jpySecretKey: ${PAYVERSE_JPY_SECRET_KEY}
bootpay:
applicationId: ${BOOTPAY_APPLICATION_ID}

View File

@@ -0,0 +1,240 @@
package kr.co.vividnext.sodalive.chat.quota.room
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.quota.ChatQuotaService
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class ChatRoomQuotaControllerTest {
private lateinit var chatRoomRepository: ChatRoomRepository
private lateinit var participantRepository: ChatParticipantRepository
private lateinit var chatRoomQuotaService: ChatRoomQuotaService
private lateinit var chatQuotaService: ChatQuotaService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var controller: ChatRoomQuotaController
@BeforeEach
fun setup() {
chatRoomRepository = Mockito.mock(ChatRoomRepository::class.java)
participantRepository = Mockito.mock(ChatParticipantRepository::class.java)
chatRoomQuotaService = Mockito.mock(ChatRoomQuotaService::class.java)
chatQuotaService = Mockito.mock(ChatQuotaService::class.java)
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
controller = ChatRoomQuotaController(
chatRoomRepository = chatRoomRepository,
participantRepository = participantRepository,
chatRoomQuotaService = chatRoomQuotaService,
chatQuotaService = chatQuotaService,
memberContentPreferenceService = memberContentPreferenceService
)
}
@Test
@DisplayName("캔 충전 요청은 선택한 캔 옵션으로 서비스에 전달된다")
fun shouldDelegateCanPurchaseWithSelectedCanOption() {
val member = createMember(id = 7L, nickname = "user")
val room = createRoom(id = 101L)
val character = createCharacter(id = 202L)
stubAccessibleRoom(member, room, character)
Mockito.`when`(
chatRoomQuotaService.purchaseWithCan(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!,
canOption = ChatRoomQuotaCanOption.CAN_20,
container = "aos"
)
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(50, null, 10, 40))
val response = controller.purchaseRoomQuota(
member = member,
chatRoomId = room.id!!,
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
container = "aos",
chargeType = ChatRoomQuotaChargeType.CAN,
canOption = ChatRoomQuotaCanOption.CAN_20
)
)
assertEquals(true, response.success)
assertEquals(50, response.data!!.totalRemaining)
assertEquals(40, response.data!!.remainingPaid)
Mockito.verify(chatRoomQuotaService).purchaseWithCan(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!,
canOption = ChatRoomQuotaCanOption.CAN_20,
container = "aos"
)
}
@Test
@DisplayName("container만 있는 기존 요청은 기본적으로 10캔 충전으로 처리한다")
fun shouldFallbackToCan10WhenOnlyContainerIsProvided() {
val member = createMember(id = 17L, nickname = "legacy")
val room = createRoom(id = 301L)
val character = createCharacter(id = 402L)
stubAccessibleRoom(member, room, character)
Mockito.`when`(
chatRoomQuotaService.purchaseWithCan(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!,
canOption = ChatRoomQuotaCanOption.CAN_10,
container = "aos"
)
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(25, null, 10, 15))
val response = controller.purchaseRoomQuota(
member = member,
chatRoomId = room.id!!,
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
container = "aos"
)
)
assertEquals(true, response.success)
assertEquals(25, response.data!!.totalRemaining)
assertEquals(15, response.data!!.remainingPaid)
Mockito.verify(chatRoomQuotaService).purchaseWithCan(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!,
canOption = ChatRoomQuotaCanOption.CAN_10,
container = "aos"
)
}
@Test
@DisplayName("광고 충전 요청은 캔 차감 없이 광고 서비스 경로로 전달된다")
fun shouldDelegateAdPurchaseWithoutCanOption() {
val member = createMember(id = 8L, nickname = "user")
val room = createRoom(id = 111L)
val character = createCharacter(id = 222L)
stubAccessibleRoom(member, room, character)
Mockito.`when`(
chatRoomQuotaService.purchaseWithAd(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!
)
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(15, null, 10, 5))
val response = controller.purchaseRoomQuota(
member = member,
chatRoomId = room.id!!,
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
container = "aos",
chargeType = ChatRoomQuotaChargeType.AD,
canOption = null
)
)
assertEquals(true, response.success)
assertEquals(15, response.data!!.totalRemaining)
assertEquals(5, response.data!!.remainingPaid)
Mockito.verify(chatRoomQuotaService).purchaseWithAd(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!
)
}
@Test
@DisplayName("광고 충전 요청에 캔 옵션이 포함되어도 무시하고 광고 충전을 처리한다")
fun shouldIgnoreCanOptionWhenAdPurchaseContainsIt() {
val member = createMember(id = 9L, nickname = "user")
val room = createRoom(id = 121L)
val character = createCharacter(id = 232L)
stubAccessibleRoom(member, room, character)
Mockito.`when`(
chatRoomQuotaService.purchaseWithAd(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!
)
).thenReturn(ChatRoomQuotaService.RoomQuotaStatus(15, null, 10, 5))
val response = controller.purchaseRoomQuota(
member = member,
chatRoomId = room.id!!,
req = ChatRoomQuotaController.PurchaseRoomQuotaRequest(
container = "aos",
chargeType = ChatRoomQuotaChargeType.AD,
canOption = ChatRoomQuotaCanOption.CAN_10
)
)
assertEquals(true, response.success)
assertEquals(15, response.data!!.totalRemaining)
assertEquals(5, response.data!!.remainingPaid)
Mockito.verify(chatRoomQuotaService).purchaseWithAd(
memberId = member.id!!,
chatRoomId = room.id!!,
characterId = character.id!!
)
}
private fun stubAccessibleRoom(member: Member, room: ChatRoom, character: ChatCharacter) {
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = ContentType.ALL,
isAdult = true
)
)
Mockito.`when`(chatRoomRepository.findByIdAndIsActiveTrue(room.id!!)).thenReturn(room)
Mockito.`when`(participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)).thenReturn(
ChatParticipant(chatRoom = room, participantType = ParticipantType.USER, member = member)
)
Mockito.`when`(
participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
).thenReturn(
ChatParticipant(chatRoom = room, participantType = ParticipantType.CHARACTER, character = character)
)
}
private fun createMember(id: Long, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = MemberRole.USER
)
member.id = id
return member
}
private fun createRoom(id: Long): ChatRoom {
val room = ChatRoom(sessionId = "session-$id", title = "room-$id")
room.id = id
return room
}
private fun createCharacter(id: Long): ChatCharacter {
val character = ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "desc",
systemPrompt = "prompt",
characterType = CharacterType.Character
)
character.id = id
return character
}
}

View File

@@ -0,0 +1,105 @@
package kr.co.vividnext.sodalive.chat.quota.room
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.CanUsage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class ChatRoomQuotaServiceTest {
private lateinit var repo: ChatRoomQuotaRepository
private lateinit var canPaymentService: CanPaymentService
private lateinit var service: ChatRoomQuotaService
@BeforeEach
fun setup() {
repo = Mockito.mock(ChatRoomQuotaRepository::class.java)
canPaymentService = Mockito.mock(CanPaymentService::class.java)
service = ChatRoomQuotaService(repo, canPaymentService)
}
@Test
@DisplayName("10캔 충전은 15개 유료 quota를 지급한다")
fun shouldAddFifteenPaidQuotaWhenPurchasingCan10Option() {
val quota = ChatRoomQuota(memberId = 1L, chatRoomId = 2L, characterId = 3L, remainingFree = 10, remainingPaid = 0)
Mockito.`when`(repo.findForUpdate(1L, 2L)).thenReturn(quota)
val result = service.purchaseWithCan(
memberId = 1L,
chatRoomId = 2L,
characterId = 3L,
canOption = ChatRoomQuotaCanOption.CAN_10,
container = "aos"
)
Mockito.verify(canPaymentService).spendCan(
1L,
10,
CanUsage.CHAT_QUOTA_PURCHASE,
2L,
3L,
false,
null,
null,
null,
null,
null,
null,
"aos"
)
assertEquals(15, result.remainingPaid)
assertEquals(25, result.totalRemaining)
}
@Test
@DisplayName("20캔 충전은 40개 유료 quota를 지급한다")
fun shouldAddFortyPaidQuotaWhenPurchasingCan20Option() {
val quota = ChatRoomQuota(memberId = 11L, chatRoomId = 12L, characterId = 13L, remainingFree = 10, remainingPaid = 2)
Mockito.`when`(repo.findForUpdate(11L, 12L)).thenReturn(quota)
val result = service.purchaseWithCan(
memberId = 11L,
chatRoomId = 12L,
characterId = 13L,
canOption = ChatRoomQuotaCanOption.CAN_20,
container = "ios"
)
Mockito.verify(canPaymentService).spendCan(
11L,
20,
CanUsage.CHAT_QUOTA_PURCHASE,
12L,
13L,
false,
null,
null,
null,
null,
null,
null,
"ios"
)
assertEquals(42, result.remainingPaid)
assertEquals(52, result.totalRemaining)
}
@Test
@DisplayName("광고 충전은 캔 차감 없이 5개 유료 quota를 지급한다")
fun shouldAddFivePaidQuotaWithoutSpendingCanWhenPurchasingWithAd() {
val quota = ChatRoomQuota(memberId = 21L, chatRoomId = 22L, characterId = 23L, remainingFree = 10, remainingPaid = 1)
Mockito.`when`(repo.findForUpdate(21L, 22L)).thenReturn(quota)
val result = service.purchaseWithAd(
memberId = 21L,
chatRoomId = 22L,
characterId = 23L
)
Mockito.verifyNoInteractions(canPaymentService)
assertEquals(6, result.remainingPaid)
assertEquals(16, result.totalRemaining)
}
}