From 0665cdaca864aae2c34fdf3ac681b227b0c7735e Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 29 Apr 2026 16:57:27 +0900 Subject: [PATCH 1/9] =?UTF-8?q?docs(agent-guides):=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=C2=B7=ED=85=8C=EC=8A=A4=ED=8A=B8=C2=B7=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=EB=A5=BC=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agent-guides/설정보안.md | 6 ++++ docs/agent-guides/코드스타일.md | 59 +++++++++++++++++++++++++++++++ docs/agent-guides/테스트스타일.md | 9 +++++ 3 files changed, 74 insertions(+) create mode 100644 docs/agent-guides/설정보안.md create mode 100644 docs/agent-guides/코드스타일.md create mode 100644 docs/agent-guides/테스트스타일.md diff --git a/docs/agent-guides/설정보안.md b/docs/agent-guides/설정보안.md new file mode 100644 index 00000000..c54a2999 --- /dev/null +++ b/docs/agent-guides/설정보안.md @@ -0,0 +1,6 @@ +# 설정 보안 + +## 설정/보안 유의사항 +- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다. +- 비밀값(API Key, Secret, Token, DB 비밀번호)을 코드/문서/로그에 평문으로 남기지 않는다. +- 환경변수/시크릿 파일은 커밋 대상에서 제외한다. diff --git a/docs/agent-guides/코드스타일.md b/docs/agent-guides/코드스타일.md new file mode 100644 index 00000000..953e5eb2 --- /dev/null +++ b/docs/agent-guides/코드스타일.md @@ -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) 주석 +- 의미 단위별로 주석을 작성한다. +- 주석은 한 문장으로 간결하게 작성한다. +- 주석은 코드의 의도와 구조를 설명한다. +- 주석은 코드 변경 시 업데이트를 잊지 않는다. diff --git a/docs/agent-guides/테스트스타일.md b/docs/agent-guides/테스트스타일.md new file mode 100644 index 00000000..db000809 --- /dev/null +++ b/docs/agent-guides/테스트스타일.md @@ -0,0 +1,9 @@ +# 테스트 스타일 + +## 테스트 스타일 규칙 +- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`) +- 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``) +- 검증: `assertEquals`, `assertThrows` 패턴 준수. +- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다. +- 테스트는 DisplayName으로 한국어 설명을 추가한다. +- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다. From 1b20bc81b7cc569c108cc519a8eeef2290141e6f Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 29 Apr 2026 16:57:36 +0900 Subject: [PATCH 2/9] =?UTF-8?q?docs(agent-guides):=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=A0=88=EC=B0=A8=EC=99=80=20=EB=AC=B8=EC=84=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=EB=B3=B4=EC=88=98=20=EA=B7=9C=EC=B9=99=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260429_연속작업계획문서재사용규칙.md | 15 +++++++++++++++ docs/agent-guides/문서유지보수.md | 12 ++++++++++++ docs/agent-guides/작업절차.md | 9 +++++++++ 3 files changed, 36 insertions(+) create mode 100644 docs/20260429_연속작업계획문서재사용규칙.md create mode 100644 docs/agent-guides/문서유지보수.md create mode 100644 docs/agent-guides/작업절차.md diff --git a/docs/20260429_연속작업계획문서재사용규칙.md b/docs/20260429_연속작업계획문서재사용규칙.md new file mode 100644 index 00000000..d7a3f3ad --- /dev/null +++ b/docs/20260429_연속작업계획문서재사용규칙.md @@ -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`을 확인했다. diff --git a/docs/agent-guides/문서유지보수.md b/docs/agent-guides/문서유지보수.md new file mode 100644 index 00000000..d2addaa2 --- /dev/null +++ b/docs/agent-guides/문서유지보수.md @@ -0,0 +1,12 @@ +# 문서 유지보수 + +## 문서 유지보수 규칙 +- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다. +- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다. +- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다. +- Cursor/Copilot 규칙 파일이 생기면 해당 내용을 이 문서에 반영한다. +- 연속된 하나의 작업에 대해 계획 문서가 여러 개 생기지 않도록 기존 계획 문서 재사용 여부를 먼저 확인한다. +- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다. +- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다. +- 에이전트 안내 문구는 한국어 중심으로 유지한다. +- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다. diff --git a/docs/agent-guides/작업절차.md b/docs/agent-guides/작업절차.md new file mode 100644 index 00000000..8484f703 --- /dev/null +++ b/docs/agent-guides/작업절차.md @@ -0,0 +1,9 @@ +# 작업 절차 + +## 작업 절차 체크리스트 +- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다. +- 변경 전: 같은 작업의 연속 후속 수정인지 먼저 확인하고, 연속 작업이면 새 계획 문서를 만들지 말고 기존 계획 문서를 갱신한다. +- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다. +- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다. +- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다. +- 커밋 전/후 확인 시 Sisyphus attribution footer가 없는지 함께 검증한다. From c7352c4bd325fd89fc3c3e1c0dbb2c8e64d20bdd Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 29 Apr 2026 16:57:44 +0900 Subject: [PATCH 3/9] =?UTF-8?q?docs(agent):=20=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8A=B8=20=EC=9A=B4=EC=98=81=20=EA=B0=80=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=AC=EA=B5=AC=EC=84=B1=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 227 +++++++++++++----------- docs/20260429_에이전트가이드통합정리.md | 35 ++++ 2 files changed, 156 insertions(+), 106 deletions(-) create mode 100644 docs/20260429_에이전트가이드통합정리.md diff --git a/AGENTS.md b/AGENTS.md index 01e1a3c8..d91426da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`를 먼저 로드한다. - 기본 형식은 `(scope): `를 사용한다. @@ -114,43 +156,16 @@ - 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다. - 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다. - 커밋 본문에는 `Ultraworked with [Sisyphus]...` 및 `Co-authored-by: Sisyphus ` 자동 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]`로 갱신하고, 범위가 바뀌면 문서를 먼저 갱신한다. +- 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 누적 기록한다. ## 에이전트 동작 원칙 - 추측하지 말고, 근거 파일을 읽고 결정한다. diff --git a/docs/20260429_에이전트가이드통합정리.md b/docs/20260429_에이전트가이드통합정리.md new file mode 100644 index 00000000..7bcefda4 --- /dev/null +++ b/docs/20260429_에이전트가이드통합정리.md @@ -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`을 확인했다. From 0c0da6cbc998ff0dc1c98eda29cac0932c9365bb Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 29 Apr 2026 18:43:34 +0900 Subject: [PATCH 4/9] =?UTF-8?q?docs(chat-quota):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=BF=BC=ED=84=B0=20=EC=B6=A9=EC=A0=84=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=ED=99=95=EC=9E=A5=20=EC=9E=91=EC=97=85=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260429_채팅방쿼터충전방식확장.md | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/20260429_채팅방쿼터충전방식확장.md diff --git a/docs/20260429_채팅방쿼터충전방식확장.md b/docs/20260429_채팅방쿼터충전방식확장.md new file mode 100644 index 00000000..706e3a6f --- /dev/null +++ b/docs/20260429_채팅방쿼터충전방식확장.md @@ -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`의 광고 케이스를 성공 경로 검증으로 변경했다. From d736ec4368aab4a49dc7dcdf0f9cdc61842c6aa6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 29 Apr 2026 18:44:36 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat(chat-quota):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=BF=BC=ED=84=B0=20=EC=B6=A9=EC=A0=84=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EA=B3=BC=20=EC=98=B5=EC=85=98=EC=9D=84=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quota/room/ChatRoomQuotaController.kt | 38 ++- .../quota/room/ChatRoomQuotaPurchaseOption.kt | 14 + .../chat/quota/room/ChatRoomQuotaService.kt | 39 ++- .../quota/room/ChatRoomQuotaControllerTest.kt | 240 ++++++++++++++++++ .../quota/room/ChatRoomQuotaServiceTest.kt | 105 ++++++++ 5 files changed, 422 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaPurchaseOption.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt index e886de05..2d01efcc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt @@ -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( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaPurchaseOption.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaPurchaseOption.kt new file mode 100644 index 00000000..91984ef5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaPurchaseOption.kt @@ -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) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt index b6ba430d..a1a5006d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt @@ -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, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaControllerTest.kt new file mode 100644 index 00000000..fe2fec83 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaControllerTest.kt @@ -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 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaServiceTest.kt new file mode 100644 index 00000000..d3cd60a9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaServiceTest.kt @@ -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) + } +} From dc11f44a325fa8457feda8543be5d47dc366de7e Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 1 May 2026 14:33:24 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix(member):=20=EA=B0=95=EC=A0=9C=20KR=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EB=8C=80=EC=83=81=EC=97=90=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20pg-jp-test(44144)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contentpreference/MemberContentPreferenceCountryResolver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt index a4a60385..af639e19 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt @@ -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 { From b98cc4b018e23bebdf6169ef88db3ea6b0b2ee95 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 1 May 2026 14:38:24 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix(can):=20=ED=8A=B9=EC=A0=95=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90(2,=204,=2044144)=20=EC=A0=91=EC=86=8D=20=EC=8B=9C=20g?= =?UTF-8?q?etCans=20=ED=86=B5=ED=99=94=EB=A5=BC=20JPY=EB=A1=9C=20=EA=B0=95?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CanService.getCans 시그니처를 isNotSelectedCurrency(Boolean) → forcedCurrency(String?)로 변경해 의도 명확화 - 통화 결정 로직을 forcedCurrency 우선 적용 후, 국가 코드(KR=KRW, 그 외=USD)로 fallback - CanController에서 회원 ID가 2, 4, 44144인 경우 forcedCurrency="JPY"로 설정하여 서비스 호출 --- .../kr/co/vividnext/sodalive/can/CanController.kt | 8 ++++++-- .../kr/co/vividnext/sodalive/can/CanService.kt | 12 ++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt index 662dbe8f..739f7807 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt @@ -17,8 +17,12 @@ class CanController(private val service: CanService) { fun getCans( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ): ApiResponse> { - 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") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index 1e99c89b..39680d37 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -14,14 +14,10 @@ class CanService( private val repository: CanRepository, private val countryContext: CountryContext ) { - fun getCans(isNotSelectedCurrency: Boolean): List { - val currency = if (isNotSelectedCurrency) { - null - } else { - when (countryContext.countryCode) { - "KR" -> "KRW" - else -> "USD" - } + fun getCans(forcedCurrency: String? = null): List { + val currency = forcedCurrency ?: when (countryContext.countryCode) { + "KR" -> "KRW" + else -> "USD" } return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency) } From 343dee1f6ca8f2d7b8c91ebe8da74b7b64114648 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 1 May 2026 14:54:55 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat(payverse):=20JPY=20=EA=B2=B0=EC=A0=9C?= =?UTF-8?q?=20=EC=A7=80=EC=9B=90=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=ED=8F=AC=EB=A7=B7=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChargeService에 JPY 전용 자격 증명 주입(payverse.jpy-*) - payverseCharge/payverseWebhook/payverseVerify에 KRW/JPY/USD 3분기 적용 - JPY 금액 정수화(FLOOR) 처리 및 공통 함수 computePayverseAmount 추가 - 검증/체크리스트 문서 추가(docs/20260501_payverse-jpy-지원.md) --- docs/20260501_payverse-jpy-지원.md | 23 +++++ .../sodalive/can/charge/ChargeService.kt | 93 +++++++++++-------- src/main/resources/application.yml | 3 + 3 files changed, 79 insertions(+), 40 deletions(-) create mode 100644 docs/20260501_payverse-jpy-지원.md diff --git a/docs/20260501_payverse-jpy-지원.md b/docs/20260501_payverse-jpy-지원.md new file mode 100644 index 00000000..447a0550 --- /dev/null +++ b/docs/20260501_payverse-jpy-지원.md @@ -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`가 항상 정수로 전송되는지 로깅/샘플 요청으로 확인(스테이징) + +## 검증 로그 +- [ ] 빌드/테스트 결과: +- [ ] 수기 점검 결과: diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt index 2c631b16..944c9d53 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -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()) + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 85d5f0f4..d9c3eaa8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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} From dfb97fba80c19c667ea65e254f7881c647207c6d Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 1 May 2026 15:21:05 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix(member):=20getMemberInfo=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20role=EC=9D=84=20CREATOR=20=EC=99=B8=EC=97=90?= =?UTF-8?q?=EB=8A=94=20USER=EB=A1=9C=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/kr/co/vividnext/sodalive/member/MemberService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 872cde05..9f65dae9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -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,