Compare commits
299 Commits
test
...
cbbfe014cc
| Author | SHA1 | Date | |
|---|---|---|---|
| cbbfe014cc | |||
| 83028f7817 | |||
| 70d1795557 | |||
| 8c6c681424 | |||
| 50bc9f4ff3 | |||
| f00ea03fad | |||
| f22e7b9ad1 | |||
| c7ec95f4bb | |||
| 229e7a8ccc | |||
| 3c616474ff | |||
| 56eb6b3ce3 | |||
| 545836d43c | |||
| 219f83dec0 | |||
| a76a841238 | |||
| c26680de84 | |||
| 8fffad9d3a | |||
| f4f0f203a2 | |||
| b7196f5a0c | |||
| 5d33a18890 | |||
| 96186a1a50 | |||
| bc8bc479d1 | |||
| 47595b1291 | |||
| 01a88964df | |||
| 3a2b77379f | |||
| dc4e5f75cd | |||
| d0178d551c | |||
| 827333108d | |||
| 587b90bd27 | |||
| 4dc20c5e90 | |||
| ac25782f2b | |||
| 20437d56e7 | |||
| f0b412828a | |||
| 367faac5c3 | |||
| 84deaaa970 | |||
| a2b39466c2 | |||
| 03586c4005 | |||
| 6ea69e1510 | |||
| 553c6dc539 | |||
| 6cc22f5b6d | |||
| 9103d67cc1 | |||
| 25083fb0e4 | |||
| d2dc045255 | |||
| b8621dfbb0 | |||
| 93633940dd | |||
| b6f5325351 | |||
| 7c32c08f1f | |||
| 1d268da08d | |||
| 797666ae0d | |||
| dcf470997e | |||
| 0974d1dbf8 | |||
| 12a35db6cd | |||
| 9abbb05ad8 | |||
| 1ecaf69b0b | |||
| e334d1e5d9 | |||
| b735e861d0 | |||
| 4eb433d372 | |||
| 2416ae61f3 | |||
| 01fb336985 | |||
| b6af88a732 | |||
| 58a2a17d6d | |||
| 79f5a0f520 | |||
| 7f6c0f7f04 | |||
| f658df4dca | |||
| 9d43b8e23a | |||
| 4270aef79b | |||
| 1c0dc82d44 | |||
| c1e325aadf | |||
| cec87da69d | |||
| f68f24cb2c | |||
| ed094347fc | |||
| b8afdffbe1 | |||
| f6ba79f31c | |||
| 5f3b1663d2 | |||
| 66e786b4bb | |||
| f671114574 | |||
| ce37060d94 | |||
| 7d19a4d184 | |||
| 22f28a2f8a | |||
| ceef9ca979 | |||
| efe8f4f939 | |||
| ba692a1195 | |||
| d732bad042 | |||
| 4c935c3bee | |||
| c160dd791f | |||
| 23cd1b4601 | |||
| 031fc8ba1b | |||
| c6853289ad | |||
| 2497bb69bc | |||
| a58a67e0a2 | |||
| 4315fe12a5 | |||
| 42f10a8899 | |||
| 1e4b47f989 | |||
| ff255dbfae | |||
| dbe9b72feb | |||
| 95a714b391 | |||
| 28f58c7f56 | |||
| 8bd46d8f21 | |||
| e1bb8e54ed | |||
| 1de705b063 | |||
| f6926ad356 | |||
| 2cdbbb1b37 | |||
| 4dce8c8f03 | |||
| 97a5bace6f | |||
| d4d51ec48f | |||
| fb91398462 | |||
| 105dadd798 | |||
| 2abf2837d3 | |||
| 422aa67af6 | |||
| 7fffab6985 | |||
| 5a4be3d2c1 | |||
| f39a7681db | |||
| c60a7580ba | |||
| 97edb56edc | |||
| 6ebca8d22b | |||
| 95371ad934 | |||
| 2c176825fd | |||
| fae7de48d3 | |||
| b8230646a2 | |||
| 43279541dd | |||
| b4791977c1 | |||
| ef917ecc25 | |||
| a93faad951 | |||
| fd001d24d3 | |||
| 7aa5884797 | |||
| 5b237a1547 | |||
| 2e37990d87 | |||
| dd07d724a8 | |||
| 03ce8618e7 | |||
| db1a7a7fd6 | |||
| 36a82d7f53 | |||
| 3a34401113 | |||
| 9927268330 | |||
| c45c97e29d | |||
| c64a315226 | |||
| a4cafca6ab | |||
| 46284a0660 | |||
| 05df86e15a | |||
| 8b433027e2 | |||
| 5bd4ff7610 | |||
| d693c397ea | |||
| 1d8d1ec9a5 | |||
| 5e491f11ee | |||
| 7cedea06ac | |||
| 2e5f750e50 | |||
| 20289cad10 | |||
| e0d64c31c7 | |||
| 8c1b95dc97 | |||
| fb5641343e | |||
| 87765941eb | |||
| 1809862c16 | |||
| 300f784f7d | |||
| 67a045eae6 | |||
| 2a79903a28 | |||
| d3222ce083 | |||
| 406a421742 | |||
| 10bf728faf | |||
| 607617747c | |||
| f0a69eb1a2 | |||
| 6b307a6e17 | |||
| 08d08a934a | |||
| c500c12668 | |||
| 62060adeba | |||
| b2fc75edb8 | |||
| a999dd2085 | |||
| 49f95ab100 | |||
| 1a84d5b30c | |||
| 3b65050632 | |||
| d0df31674c | |||
| 1fe88402e2 | |||
| 67097696e6 | |||
| 8e7e77067a | |||
| 9899390b61 | |||
| 80c476a908 | |||
| 59da1d6e49 | |||
| 5aef7dac33 | |||
| faf7aa06b6 | |||
| 38ef6e5583 | |||
| c0b15b5d94 | |||
| 2cfc067ea1 | |||
| a91db4f956 | |||
| 8a09780a02 | |||
| 45e8ec6505 | |||
| 4554b85914 | |||
| 8aa79c4a9c | |||
| c8d3210b57 | |||
| 2282a49563 | |||
| b82fdfb2c8 | |||
| 2d17eac199 | |||
| e482bc3aad | |||
| ec022b74d1 | |||
| dc42c09ce3 | |||
| 046a34d2a4 | |||
| 9ff6ec1888 | |||
| d2950106ec | |||
| 962f800d2e | |||
| 962107e507 | |||
| 039bd11963 | |||
| 5c250ea4ae | |||
| e3405bcec6 | |||
| 0fd1c2235f | |||
| b20c29b022 | |||
| 12d5dcd298 | |||
| 2c305dc6c6 | |||
| 62f76f7433 | |||
| 858ce524f9 | |||
| 3795fb4a40 | |||
| 0c01aeec50 | |||
| 892206744d | |||
| 9e2c1474db | |||
| 16328f73d9 | |||
| e0d4f53cf4 | |||
| e09a59c5b4 | |||
| 049e654535 | |||
| c927dc4ecd | |||
| fe4ecd0ad8 | |||
| 78d476fe80 | |||
| a11c8465d5 | |||
| 366304a9b7 | |||
| 4356663688 | |||
| 26b55e6fcf | |||
| 0d743f7204 | |||
| 6cbe113b3e | |||
| 6409b69d6c | |||
| c5164c76fc | |||
| baade8e138 | |||
| b848d6b4e0 | |||
| d8139d2ab0 | |||
| e96d8f7469 | |||
| 2acffd8afc | |||
| 3c8e72073c | |||
| 724d7a9d9b | |||
| 2da3b0db78 | |||
| 685ad7afaf | |||
| 264cf75964 | |||
| c773dbc7b5 | |||
| 37cbc64f52 | |||
| cb1dde17bb | |||
| c29988acf4 | |||
| eadbf56dae | |||
| 4b3b455135 | |||
| e6ac177396 | |||
| 3d0e29003f | |||
| 78b9b00f77 | |||
| 0ee7faa551 | |||
| e5fdced681 | |||
| afb99fef64 | |||
| 7dfaa36024 | |||
| 0496f665aa | |||
| 0d19e1be74 | |||
| 4aff0111aa | |||
| 63b3ba2bb2 | |||
| 7444b41f60 | |||
| 8e90dbc8b6 | |||
| 9f70722521 | |||
| 52fae596fa | |||
| ccb67957bc | |||
| fb82538d0d | |||
| 72ee39612e | |||
| 51fd5408dc | |||
| 3fae40fbef | |||
| 0745890af0 | |||
| 4abe1730a7 | |||
| 626f0e6989 | |||
| 9f42d9d173 | |||
| f90a93c4bc | |||
| 8000ad6c6a | |||
| 1f1f1bea1a | |||
| d95460c7cd | |||
| a3d93d4b08 | |||
| 07a92af982 | |||
| f4618877d4 | |||
| 2b914fd222 | |||
| 109e42a5a3 | |||
| fa515ad39c | |||
| f09673a795 | |||
| f71536c614 | |||
| 7bdddc7ae8 | |||
| aa8926a624 | |||
| be71e59be2 | |||
| 4d7753378f | |||
| 60257c4ef4 | |||
| 1e0b79bf62 | |||
| 6883434d0d | |||
| eda2193e64 | |||
| 99bf829c88 | |||
| 5feafe1b48 | |||
| c9292b7d04 | |||
| ae7e1a91c1 | |||
| 3e1887e0d1 | |||
| 474646db47 | |||
| 56f7b6c449 | |||
| 76b2b5f7e3 | |||
| e918d809eb | |||
| 7af059e543 | |||
| 897726e1ec | |||
| 8b98a2dd07 | |||
| cca75420f0 | |||
| 86c627ed1d | |||
| d55514e3a7 |
@@ -7,5 +7,5 @@ indent_size = 4
|
|||||||
indent_style = space
|
indent_style = space
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 130
|
max_line_length = 120
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -323,7 +323,4 @@ gradle-app.setting
|
|||||||
### Gradle Patch ###
|
### Gradle Patch ###
|
||||||
**/build/
|
**/build/
|
||||||
|
|
||||||
.kiro/
|
|
||||||
.junie
|
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
description: commit-policy 스킬을 로드해 커밋 메시지 생성과 전후 검증을 수행한다
|
|
||||||
agent: build
|
|
||||||
subtask: true
|
|
||||||
---
|
|
||||||
|
|
||||||
작업 목표:
|
|
||||||
현재 변경사항을 안전하게 커밋한다.
|
|
||||||
|
|
||||||
필수 시작 단계:
|
|
||||||
1. `skill` 도구로 `commit-policy` 스킬을 먼저 로드한다.
|
|
||||||
- `skill({ name: "commit-policy" })`
|
|
||||||
|
|
||||||
실행 단계:
|
|
||||||
1. 로드한 `commit-policy` 스킬의 Hard Requirements와 Execution Flow를 그대로 수행한다.
|
|
||||||
2. `AGENTS.md`의 최소 정책(형식/한글 description/검증 스크립트)을 항상 만족한다.
|
|
||||||
3. `$ARGUMENTS`가 있으면 scope 또는 description 의도에 반영하되, 스킬 규칙과 형식을 깨지 않는다.
|
|
||||||
4. 마지막에 실행 명령과 pre-check/post-check PASS/FAIL 핵심 결과를 간단히 보고한다.
|
|
||||||
|
|
||||||
추가 사용자 의도:
|
|
||||||
$ARGUMENTS
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
name: commit-policy
|
|
||||||
description: Apply this skill for any git commit task in this repository. It enforces commit message format and validation flow defined in AGENTS.md and work/scripts/check-commit-message-rules.sh, including pre-commit and post-commit verification.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Commit Policy Skill
|
|
||||||
|
|
||||||
Use this workflow whenever the task includes creating a commit.
|
|
||||||
|
|
||||||
## Required References
|
|
||||||
|
|
||||||
- `@AGENTS.md`
|
|
||||||
- `@work/scripts/check-commit-message-rules.sh`
|
|
||||||
|
|
||||||
## Hard Requirements
|
|
||||||
|
|
||||||
1. Use commit subject format: `<type>(scope): <description>`.
|
|
||||||
2. `type` must be lowercase (for example `feat`, `fix`, `chore`, `docs`, `refactor`, `test`).
|
|
||||||
3. `description` must include Korean text and stay concise in imperative present tone.
|
|
||||||
4. Optional footer must use `Refs: #123` or `Refs: #123, #456` format.
|
|
||||||
5. Never commit secret files (`.env`, key/token/secret credential files).
|
|
||||||
6. Never bypass hooks with `--no-verify`.
|
|
||||||
|
|
||||||
## Execution Flow
|
|
||||||
|
|
||||||
1. Inspect context with:
|
|
||||||
- `git status`
|
|
||||||
- `git diff --cached`
|
|
||||||
- `git diff`
|
|
||||||
- `git log -5 --oneline`
|
|
||||||
2. Stage commit target files only. Exclude suspicious secret-bearing files.
|
|
||||||
3. Draft commit message from the change intent (focus on why, not only what).
|
|
||||||
4. Run pre-commit validation with the full draft message:
|
|
||||||
- `./work/scripts/check-commit-message-rules.sh --message "<full message>"`
|
|
||||||
5. If validation fails, revise message and re-run until PASS.
|
|
||||||
6. Commit using the validated message.
|
|
||||||
7. Run post-commit validation:
|
|
||||||
- `./work/scripts/check-commit-message-rules.sh`
|
|
||||||
8. Report executed commands and PASS/FAIL summary.
|
|
||||||
|
|
||||||
## Output Checklist
|
|
||||||
|
|
||||||
- Final commit subject.
|
|
||||||
- Whether pre-check passed.
|
|
||||||
- Whether post-check passed.
|
|
||||||
- Any excluded files and reason.
|
|
||||||
156
AGENTS.md
156
AGENTS.md
@@ -1,156 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## 문서 목적
|
|
||||||
- 이 문서는 `/Users/klaus/Develop/sodalive/Server/sodalive` 저장소에서 작업하는 에이전트용 실행 가이드다.
|
|
||||||
- 목표는 "추측 최소화 + 기존 패턴 준수 + 검증 우선"이다.
|
|
||||||
- 이 문서의 규칙은 코드/테스트/문서 변경 모두에 적용한다.
|
|
||||||
|
|
||||||
## 커뮤니케이션 규칙
|
|
||||||
- **"질문에 대한 답변과 설명은 한국어로 한다."**
|
|
||||||
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
|
|
||||||
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.
|
|
||||||
|
|
||||||
## 프로젝트 개요
|
|
||||||
- 빌드 도구: Gradle Wrapper (`./gradlew`)
|
|
||||||
- 언어/런타임: Kotlin + Java 17
|
|
||||||
- 프레임워크: Spring Boot 2.7.14
|
|
||||||
- 주요 플러그인: `org.jlleitschuh.gradle.ktlint`
|
|
||||||
- 단일 루트 프로젝트: `settings.gradle.kts`의 `rootProject.name = "sodalive"`
|
|
||||||
|
|
||||||
## 실행 명령어 (Build/Lint/Test)
|
|
||||||
아래 명령은 저장소 루트(`/Users/klaus/Develop/sodalive/Server/sodalive`)에서 실행한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew tasks --all
|
|
||||||
./gradlew bootRun
|
|
||||||
./gradlew build
|
|
||||||
./gradlew clean build
|
|
||||||
./gradlew test
|
|
||||||
./gradlew check
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
./gradlew ktlintFormat
|
|
||||||
```
|
|
||||||
|
|
||||||
## 코드 스타일 규칙
|
|
||||||
|
|
||||||
### 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>`를 사용한다.
|
|
||||||
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다.
|
|
||||||
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
|
|
||||||
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
|
|
||||||
|
|
||||||
### 커밋 메시지 검증 절차
|
|
||||||
- `git commit` 실행 직전에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
|
|
||||||
- `git commit` 실행 직후에도 `work/scripts/check-commit-message-rules.sh`를 다시 실행해 최종 메시지를 재검증한다.
|
|
||||||
- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 규칙에 맞게 수정한 뒤 다시 검증한다.
|
|
||||||
|
|
||||||
## 작업 절차 체크리스트
|
|
||||||
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
|
|
||||||
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
|
|
||||||
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
|
|
||||||
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `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`로 명령 유효성을 확인한다.
|
|
||||||
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
|
|
||||||
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
|
|
||||||
- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다.
|
|
||||||
|
|
||||||
## 에이전트 동작 원칙
|
|
||||||
- 추측하지 말고, 근거 파일을 읽고 결정한다.
|
|
||||||
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
|
|
||||||
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
|
|
||||||
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
|
|
||||||
@@ -18,7 +18,7 @@ version = "0.0.1-SNAPSHOT"
|
|||||||
val querydslVersion = "5.0.0"
|
val querydslVersion = "5.0.0"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
@@ -41,8 +41,6 @@ dependencies {
|
|||||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
|
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
|
||||||
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
|
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
|
||||||
|
|
||||||
implementation("com.nimbusds:nimbus-jose-jwt:9.37.3")
|
|
||||||
|
|
||||||
// querydsl (추가 설정)
|
// querydsl (추가 설정)
|
||||||
implementation("com.querydsl:querydsl-jpa:$querydslVersion")
|
implementation("com.querydsl:querydsl-jpa:$querydslVersion")
|
||||||
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")
|
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")
|
||||||
@@ -67,14 +65,9 @@ dependencies {
|
|||||||
// android publisher
|
// android publisher
|
||||||
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0")
|
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0")
|
||||||
|
|
||||||
implementation("com.google.api-client:google-api-client:1.32.1")
|
|
||||||
|
|
||||||
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
||||||
|
|
||||||
// file mimetype check
|
|
||||||
implementation("org.apache.tika:tika-core:3.2.0")
|
|
||||||
|
|
||||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
runtimeOnly("com.h2database:h2")
|
runtimeOnly("com.h2database:h2")
|
||||||
runtimeOnly("com.mysql:mysql-connector-j")
|
runtimeOnly("com.mysql:mysql-connector-j")
|
||||||
@@ -91,7 +84,7 @@ allOpen {
|
|||||||
tasks.withType<KotlinCompile> {
|
tasks.withType<KotlinCompile> {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
freeCompilerArgs += "-Xjsr305=strict"
|
freeCompilerArgs += "-Xjsr305=strict"
|
||||||
jvmTarget = "17"
|
jvmTarget = "11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# 20260220 LSP 설정 추가
|
|
||||||
|
|
||||||
## 구현 계획
|
|
||||||
- [x] oh-my-opencode 설정 파일에서 현재 LSP 매핑을 확인한다.
|
|
||||||
- [x] `.md` 확장자에 `remark-language-server` 매핑을 추가하고, `.sh`는 기존 `bash` 서버 설정이 정상 동작하는지 확인한다.
|
|
||||||
- [x] 수정 후 `lsp_diagnostics`로 Bash/Markdown 파일 진단이 가능한지 검증한다.
|
|
||||||
- [x] 저장소 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
|
||||||
|
|
||||||
- 무엇을: `/Users/klaus/.config/opencode/oh-my-opencode.json`에 `remark-language-server --stdio` 기반 `.md` 매핑을 추가했다.
|
|
||||||
- 왜: Bash는 설치 후 즉시 진단 가능했지만 Markdown은 LSP 매핑이 없어 `lsp_diagnostics`가 실패했기 때문이다.
|
|
||||||
- 어떻게 검증했는지: `work/scripts/check-commit-message-rules.sh`와 `docs/20260220_lsp설정추가.md`에 대해 `lsp_diagnostics`를 실행해 모두 `No diagnostics found`를 확인했다. 추가로 `./gradlew tasks --all`, `./gradlew build`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
ALTER TABLE member
|
|
||||||
ADD fancimm_url VARCHAR(255) DEFAULT NULL COMMENT '팬심M url' AFTER instagram_url,
|
|
||||||
ADD x_url VARCHAR(255) DEFAULT NULL COMMENT 'X url' AFTER fancimm_url
|
|
||||||
;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 20260220 삭제 닉네임 접두사 표시 정리
|
|
||||||
|
|
||||||
## 구현 계획
|
|
||||||
- [x] 콘텐츠 댓글, 팬톡 응원, 커뮤니티 댓글의 닉네임 표시 흐름(조회/매핑/응답 DTO)을 각각 식별한다.
|
|
||||||
- [x] 닉네임이 `deleted_`로 시작하는지 판별하고 표시 시 접두사만 제거하는 공통 처리 지점을 설계한다.
|
|
||||||
- [x] 콘텐츠 댓글 표시 로직에 `deleted_` 접두사 제거 규칙을 적용한다.
|
|
||||||
- [x] 팬톡 응원 표시 로직에 `deleted_` 접두사 제거 규칙을 적용한다.
|
|
||||||
- [x] 커뮤니티 댓글 표시 로직에 `deleted_` 접두사 제거 규칙을 적용한다.
|
|
||||||
- [x] `deleted_` 미포함 닉네임, `deleted_` 포함 닉네임, 접두사만 존재하는 경계 케이스를 기준으로 테스트 케이스를 추가/보강한다.
|
|
||||||
|
|
||||||
## 검증 계획
|
|
||||||
- [x] 닉네임 표시에 영향이 있는 테스트를 우선 실행하고 실패 시 원인을 보정한다.
|
|
||||||
- [x] `./gradlew test`를 실행해 회귀 여부를 확인한다.
|
|
||||||
- [x] 필요 시 `./gradlew ktlintCheck`로 스타일 규칙 위반 여부를 확인한다.
|
|
||||||
- [x] `./gradlew build`를 실행해 전체 빌드 성공을 확인한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
|
||||||
|
|
||||||
- 무엇을: `String.removeDeletedNicknamePrefix()` 공통 확장 함수를 추가하고, 콘텐츠 댓글(`AudioContentCommentRepository`), 팬톡 응원(`ExplorerQueryRepository#getCheersList`), 커뮤니티 댓글(`CreatorCommunityCommentRepository`) 응답 닉네임에 동일 규칙을 적용했다.
|
|
||||||
- 왜: 탈퇴/비활성 사용자 닉네임 저장 정책(`deleted_` 접두사 유지)과 화면 표시 정책(접두사 제거)을 분리해, 사용자에게는 일관된 표시값을 제공하기 위해서다.
|
|
||||||
- 어떻게 검증했는지: `./gradlew test --tests "kr.co.vividnext.sodalive.extensions.StringExtensionsTest"`, `./gradlew test`, `./gradlew ktlintCheck`, `./gradlew build`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. 또한 경계 케이스(`deleted_testUser`, `testUser`, `deleted_`) 단위 테스트를 추가해 기대 출력이 각각 `testUser`, `testUser`, `""`인지 검증했다.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 20260220 커밋 규칙 스킬 분리
|
|
||||||
|
|
||||||
## 구현 계획
|
|
||||||
- [x] 커밋 메시지 정책의 최소 필수 항목을 `AGENTS.md`에 유지한다.
|
|
||||||
- [x] 커밋 상세 절차와 실행 가이드를 `.opencode/skills/commit-policy/SKILL.md`로 분리한다.
|
|
||||||
- [x] `/commit` 커맨드가 커밋 작업 시작 시 `commit-policy` 스킬을 우선 로드하도록 갱신한다.
|
|
||||||
- [x] 커밋 검증 강제 수단(`work/scripts/check-commit-message-rules.sh`)이 유지되는지 확인한다.
|
|
||||||
- [x] 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
|
||||||
|
|
||||||
- 무엇을: `AGENTS.md`의 커밋 섹션을 최소 정책(형식, 한글 description, 검증 절차, 스킬 로드 지침) 중심으로 정리하고, 상세 절차를 `.opencode/skills/commit-policy/SKILL.md`로 분리했다. `/commit` 커맨드(`.opencode/commands/commit.md`)는 실행 시 `commit-policy` 스킬을 먼저 로드하도록 변경했다.
|
|
||||||
- 왜: 커밋 상세 규칙을 상시 컨텍스트에서 분리해 토큰 사용량을 줄이면서도, 커밋 시점에는 스킬 로드로 동일한 절차를 강제하기 위해서다.
|
|
||||||
- 어떻게 검증했는지: `AGENTS.md`, `.opencode/commands/commit.md`, `.opencode/skills/commit-policy/SKILL.md`, `docs/20260220_커밋규칙스킬분리.md`에 대해 `lsp_diagnostics`를 실행해 모두 `No diagnostics found`를 확인했다. 추가로 `./gradlew tasks --all`과 `./gradlew build`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 20260220 커밋 메시지 검증 규칙 추가
|
|
||||||
|
|
||||||
## 구현 계획
|
|
||||||
- [x] AGENTS.md의 커밋 메시지 규칙 섹션에 커밋 전/후 검증 절차를 추가한다.
|
|
||||||
- [x] AGENTS.md의 작업 절차 체크리스트에 커밋 전/후 스크립트 실행 규칙을 추가한다.
|
|
||||||
- [x] 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
|
|
||||||
- [x] AGENTS.md 커밋 메시지 규칙과 불일치하는 `work/scripts/check-commit-message-rules.sh` 검증 로직을 정합성 있게 수정한다.
|
|
||||||
- [x] 수정한 스크립트에 대해 문법 및 실행 검증을 수행한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- [x] 검증 결과를 작업 완료 후 기록한다.
|
|
||||||
|
|
||||||
- 무엇을: `AGENTS.md`에 커밋 전/후 검증 절차를 추가했고, `work/scripts/check-commit-message-rules.sh`를 AGENTS.md 기준(Conventional Commit 형식, 소문자 type, 한글 description, `Refs:` footer 형식)으로 정합성 있게 수정했다.
|
|
||||||
- 왜: 문서 규칙과 실제 검증 로직이 어긋나면 커밋 메시지 정책이 일관되게 강제되지 않기 때문이다.
|
|
||||||
- 어떻게 검증했는지: `bash -n ./work/scripts/check-commit-message-rules.sh`, 유효/무효 메시지 실행 검증(`--message`), `Refs` footer 유효/무효 케이스 검증을 수행했다. 추가로 `./gradlew tasks --all`과 `./gradlew build`를 실행해 저장소 명령 유효성과 전체 빌드 성공(`BUILD SUCCESSFUL`)을 확인했다.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 20260220 커스텀 커맨드 /commit 추가
|
|
||||||
|
|
||||||
## 구현 계획
|
|
||||||
- [x] `.opencode/commands/` 디렉터리에 `/commit` 커맨드 파일을 추가한다.
|
|
||||||
- [x] `/commit` 커맨드가 AGENTS.md 커밋 메시지 규칙(`type(scope): description`, 소문자 type, 한글 description)을 따르도록 지시한다.
|
|
||||||
- [x] `/commit` 커맨드가 커밋 직전 `./work/scripts/check-commit-message-rules.sh --message` 검증을 수행하도록 지시한다.
|
|
||||||
- [x] `/commit` 커맨드가 커밋 직후 `./work/scripts/check-commit-message-rules.sh` 재검증을 수행하도록 지시한다.
|
|
||||||
- [x] 문서 변경 검증을 위해 `./gradlew tasks --all`을 실행한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
|
||||||
|
|
||||||
- 무엇을: `.opencode/commands/commit.md`에 `/commit` 커스텀 커맨드를 추가해 변경사항 분석, AGENTS.md 규칙 기반 커밋 메시지 생성, 커밋 전/후 검증 스크립트 실행 절차를 일관되게 지시하도록 구성했다.
|
|
||||||
- 왜: 저장소의 커밋 메시지 컨벤션(Conventional Commit + 한글 description + Refs footer 규칙)과 검증 절차를 반복 작업마다 동일하게 강제하기 위해서다.
|
|
||||||
- 어떻게 검증했는지: `.opencode/commands/commit.md`, `docs/20260220_커스텀커맨드커밋추가.md`에 대해 `lsp_diagnostics`를 실행해 모두 `No diagnostics found`를 확인했고, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 팬심M/X URL 추가 작업 계획
|
|
||||||
|
|
||||||
- [x] `Member` 엔티티 SNS 필드에 팬심M URL, X URL 속성 추가
|
|
||||||
- [x] 인스타그램 URL 수정 흐름 분석 후 동일한 수정 요청 DTO 반영
|
|
||||||
- [x] 서비스의 프로필 수정 로직에 팬심M URL, X URL 수정 처리 추가
|
|
||||||
- [x] 관련 응답 DTO에 신규 URL 필드 반영 및 매핑 연결
|
|
||||||
- [x] 후속 요청 반영: `fansimMUrl` 필드명을 `fancimmUrl`로 일괄 변경
|
|
||||||
- [x] `ddl-auto: validate` 대응을 위한 DB 컬럼 추가 SQL 파일 생성
|
|
||||||
- [x] 진단/테스트/빌드 검증 실행 후 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- 무엇을: 팬심M/X URL 필드 추가, 인스타그램 URL 수정 흐름과 동일한 수정/응답 매핑 반영, `fansimMUrl` -> `fancimmUrl` 명칭 변경을 검증했다.
|
|
||||||
- 왜: 프로필 수정 API에서 두 URL이 저장되고, 주요 응답 DTO에서 값이 일관되게 내려가야 하기 때문이다.
|
|
||||||
- 어떻게: `./gradlew ktlintCheck test build`를 팬심M/X URL 추가 시점과 `fancimmUrl` 명칭 변경 시점에 각각 실행해 정적 검사, 테스트, 빌드 성공(Exit code 0)을 확인했다. 또한 `docs/20260220_member_fancimm_x_url_ddl.sql`에 운영 DB 반영용 DDL을 추가했다. Kotlin LSP 미구성으로 `lsp_diagnostics`는 수행할 수 없었다.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
CREATE TABLE channel_donation_message
|
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
|
|
||||||
member_id BIGINT NOT NULL COMMENT '후원한 유저',
|
|
||||||
creator_id BIGINT NOT NULL COMMENT '후원 받은 채널 크리에이터',
|
|
||||||
can INT NOT NULL COMMENT '후원한 캔',
|
|
||||||
is_secret TINYINT(1) NOT NULL DEFAULT 0 COMMENT '비밀후원 여부(false=0, true=1)',
|
|
||||||
additional_message TEXT NULL COMMENT '추가 메시지',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY idx_channel_donation_message_creator_created_at (creator_id, created_at),
|
|
||||||
KEY idx_channel_donation_message_member (member_id),
|
|
||||||
CONSTRAINT fk_channel_donation_message_member
|
|
||||||
FOREIGN KEY (member_id) REFERENCES member (id),
|
|
||||||
CONSTRAINT fk_channel_donation_message_creator
|
|
||||||
FOREIGN KEY (creator_id) REFERENCES member (id)
|
|
||||||
) COMMENT ='채널 후원 메시지';
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 차단 유저 댓글 및 크리에이터 노출 차단 구현
|
|
||||||
|
|
||||||
- [x] 차단(`BlockMember`) 데이터 접근 패턴 및 기존 필터 지점 확인
|
|
||||||
- [x] 콘텐츠 댓글 목록에서 차단한 유저 댓글 비노출 적용
|
|
||||||
- [x] 채널 응원 목록에서 차단한 유저 댓글 비노출 적용
|
|
||||||
- [x] 커뮤니티 댓글 목록에서 차단한 유저 댓글 비노출 적용
|
|
||||||
- [x] 차단한 크리에이터의 콘텐츠/라이브 비노출 동작 보강
|
|
||||||
- [x] 변경 파일 진단 및 테스트/빌드 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 무엇을: 리뷰에서 지적된 단방향 차단 누락을 기준으로 콘텐츠/라이브/콘텐츠 댓글/커뮤니티 댓글/채널 응원(cheers) 노출 경로를 재점검해, 한쪽이라도 차단 관계면 조회·검색·상세 접근에서 숨겨지도록 양방향 차단 로직으로 보강했다. `/explorer/profile/{id}/cheers`의 우회 접근도 양방향 차단으로 막았다.
|
|
||||||
- 왜: 사용자 차단 정책을 일관되게 적용해 차단한 유저와 차단한 크리에이터의 활동이 조회 결과에 보이지 않도록 하기 위함이다.
|
|
||||||
- 어떻게 검증했는가:
|
|
||||||
- `lsp_diagnostics`를 수정 Kotlin 파일들에 대해 실행했으나, 현재 환경에 `.kt` LSP 서버가 설정되어 있지 않아 진단 불가를 확인했다.
|
|
||||||
- `./gradlew test` 실행 성공.
|
|
||||||
- `./gradlew build -x test` 실행 성공(ktlint/check 포함).
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# 채널 후원 기능 추가 작업 계획
|
|
||||||
|
|
||||||
## 메시지 저장 전략 선택
|
|
||||||
- 선택: 기본 메시지는 DB에 저장하지 않고, 후원 이력에는 `can`, `isSecret`, `additionalMessage`를 저장한 뒤 리스트 조회 시 메시지를 생성한다.
|
|
||||||
- 이유: 일반/비밀 구분과 캔 수 노출 요구를 구조화 필드로 충족할 수 있고, 문구 변경/다국어 확장 시 DB 마이그레이션 없이 대응 가능하다.
|
|
||||||
- 메시지 생성 규칙:
|
|
||||||
- 일반 후원: `OO캔을 후원하셨습니다.`
|
|
||||||
- 비밀 후원: `OO캔을 비밀후원하셨습니다.`
|
|
||||||
- 추가 메시지 입력 시: 기본 메시지 + `\n` + `"사용자 추가 메시지"`
|
|
||||||
|
|
||||||
- [x] 채널 후원 도메인 모델/저장소 설계 (`ChannelDonationMessage` 성격의 별도 엔티티, creator/sponsor/can/isSecret/additionalMessage/createdAt)
|
|
||||||
- [x] `CanUsage`에 채널 후원 전용 값 1종 추가 및 영향 범위 정의 (`CanPaymentService`, 사용내역 타이틀 매핑)
|
|
||||||
- [x] 채널 후원 API 요청/응답 스펙 확정 (필드: `creatorId`, `can`, `isSecret`, `message`, `container`)
|
|
||||||
- [x] 채널 후원 API 서비스 플로우 설계 (인증/크리에이터 검증 -> 캔 차감 -> 후원 메시지 DB 저장)
|
|
||||||
- [x] 채널 후원 리스트 API 스펙 확정 (최근 1개월, `createdAt` 내림차순, 페이징)
|
|
||||||
- [x] 채널 후원 리스트 조회 권한 규칙 반영
|
|
||||||
- 크리에이터: 모든 후원 내역 조회
|
|
||||||
- 유저: 일반 후원 + 본인이 한 비밀 후원 내역 조회
|
|
||||||
- [x] 리스트 응답 메시지 조합 규칙 반영 (일반/비밀 기본 메시지 + 추가 메시지 쌍따옴표 처리)
|
|
||||||
- [x] `explorer/profile/{id}` 응답 확장 설계 (최근 1개월 채널 후원 내역 최대 5건 포함)
|
|
||||||
- [x] QueryDSL 조회 조건 확정 (`createdAt >= now().minusMonths(1)`, `orderBy(createdAt.desc(), id.desc())`, `limit 5`)
|
|
||||||
- [x] 테스트 계획 수립 (서비스 단위 테스트 + 리포지토리 날짜 필터/정렬 테스트 + 컨트롤러 통합 테스트)
|
|
||||||
- [x] 정산 로직 제외 범위 명시 (정산 비율 변경 작업은 미포함, 채널 후원 기능만 구현)
|
|
||||||
- [x] 구현 후 검증 계획 확정 (`./gradlew test`, `./gradlew build`, 필요 시 `./gradlew ktlintCheck`)
|
|
||||||
- [x] 운영 반영용 DDL 파일 추가 (`docs/20260223_channel_donation_message_ddl.sql`)
|
|
||||||
- [x] 채널 후원 회귀 테스트 구현
|
|
||||||
- 서비스: `ChannelDonationServiceTest`
|
|
||||||
- 리포지토리: `ChannelDonationMessageRepositoryTest`
|
|
||||||
- 컨트롤러: `ChannelDonationControllerTest`
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- 무엇을:
|
|
||||||
- 1차 계획 수립: 채널 후원 기능의 API/도메인/조회 범위를 정의하고, 메시지 저장 전략을 선택해 계획 문서로 고정했다.
|
|
||||||
- 2차 수정: 채널 후원 리스트 API의 조회 권한 규칙(크리에이터 전체 조회, 유저는 일반 후원+본인 비밀 후원 조회)을 계획 항목에 추가했다.
|
|
||||||
- 3차 구현: 채널 후원 API/리스트 API/Explorer 프로필 확장, `CanUsage.CHANNEL_DONATION`, 메시지 엔티티 저장, 권한별 노출 필터를 구현했다.
|
|
||||||
- 왜:
|
|
||||||
- 기존 코드 패턴(Explorer/CanUsage/후원 조회)을 따르는 구현 범위를 먼저 고정해 불필요한 확장과 API 불일치를 방지하기 위해.
|
|
||||||
- 리스트 조회 시 요청자 역할에 따라 비밀 후원 노출 범위가 달라지므로, 구현 전 권한 규칙을 계획 단계에서 명확히 고정하기 위해.
|
|
||||||
- 채널 후원은 기존 라이브/콘텐츠 후원과 정산 분리를 위해 별도 `CanUsage`와 별도 메시지 저장소가 필요하고, 프로필 화면에 최근 내역 노출 요구가 있어 Explorer 응답 확장이 필요하기 때문에.
|
|
||||||
- 어떻게:
|
|
||||||
- 내부 탐색: `ExplorerController`, `ExplorerService`, `ExplorerQueryRepository`, `CanUsage`, `CanPaymentService`, `LiveRoomService`, `LiveRoomRepository`를 확인했다.
|
|
||||||
- 병렬 조사: `explore` 2건(`bg_07537536`, `bg_5be8611b`)과 `librarian` 1건(`bg_bfe81033`) 결과를 수집해 근거를 보강했다.
|
|
||||||
- 추가 확인: `AudioContentCommentRepository`, `CreatorCommunityCommentRepository`, `LiveRoomRepository`의 비밀/본인 공개 조건 패턴(`isSecret.isFalse.or(writerId.eq(memberId))`)을 확인해 문서 규칙에 반영했다.
|
|
||||||
- 구현 파일: `explorer/profile/channelDonation/*`, `CanUsage.kt`, `CanPaymentService.kt`, `CanService.kt`, `ExplorerService.kt`, `GetCreatorProfileResponse.kt`를 수정/추가했다.
|
|
||||||
- 검증 명령:
|
|
||||||
- `./gradlew test` -> 성공
|
|
||||||
- `./gradlew build` -> 최초 1회 `GetCreatorProfileResponse.kt` import 정렬 실패(ktlint), 정렬 수정 후 재실행 성공
|
|
||||||
- `./gradlew ktlintCheck` -> 성공
|
|
||||||
|
|
||||||
### 4차 보완(리뷰 지적사항 반영)
|
|
||||||
- 무엇을:
|
|
||||||
- 누락됐던 운영 반영용 DDL 파일 `docs/20260223_channel_donation_message_ddl.sql`을 추가했다.
|
|
||||||
- 채널 후원 회귀 테스트 3종(서비스/리포지토리/컨트롤러)을 신규 추가했다.
|
|
||||||
- 왜:
|
|
||||||
- `ddl-auto: validate` 환경에서 신규 엔티티 스키마 누락 시 부팅 실패 위험이 있어 적용 스크립트를 분리 관리해야 했기 때문이다.
|
|
||||||
- 권한별 비밀후원 노출, 1개월 필터, 정렬/페이징 규칙을 자동 검증해 회귀를 방지하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 추가 파일:
|
|
||||||
- `docs/20260223_channel_donation_message_ddl.sql`
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt`
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt`
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt`
|
|
||||||
- 검증 명령:
|
|
||||||
- `lsp_diagnostics` -> Kotlin LSP 미설정으로 실행 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "*ChannelDonation*"` -> 성공
|
|
||||||
- `./gradlew test` -> 성공
|
|
||||||
- `./gradlew build` -> 성공
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 크리에이터 상세정보 조회 API 추가 작업 계획
|
|
||||||
|
|
||||||
- [x] `ExplorerController`에 크리에이터 상세정보 조회 엔드포인트 추가
|
|
||||||
- [x] `ExplorerService`에 상세정보 조회 비즈니스 로직 추가
|
|
||||||
- [x] `ExplorerQueryRepository`에 데뷔일/활동요약 조회 쿼리 추가
|
|
||||||
- [x] 응답 DTO 추가 및 `Member` SNS URL 매핑 연결
|
|
||||||
- [x] 3차 수정: 미래 라이브만 있는 크리에이터의 음수 `D+` 노출 방지
|
|
||||||
- [x] 정적 진단/테스트/빌드 검증 및 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- 무엇을:
|
|
||||||
- 1차 구현: 크리에이터 상세정보 조회 API(`/explorer/profile/{id}/detail`)와 응답 DTO를 추가하고, 데뷔일(라이브 `beginDateTime`/콘텐츠 `releaseDate` 최솟값), `D+N`, 활동요약, SNS URL 반환을 구현했다.
|
|
||||||
- 2차 수정: 상세 조회에 차단 관계 검사를 추가하고, 활동요약의 `contentCount`를 오픈된 콘텐츠(`releaseDate <= now`) 기준으로 집계하도록 기존 쿼리를 보정했다.
|
|
||||||
- 3차 수정: 라이브 데뷔 후보 조회에서 미래 `beginDateTime`을 제외하고, `D+` 계산 결과가 음수인 경우 `""`을 반환하도록 상세 조회 로직을 보정했다.
|
|
||||||
- 왜:
|
|
||||||
- 1차 구현: 탐색 화면에서 크리에이터 기본 정보·활동 통계·데뷔 정보·SNS를 한 번에 조회할 수 있어야 했다.
|
|
||||||
- 2차 수정: 차단 관계에서도 상세정보가 노출되는 우회가 있었고, 예약 공개 콘텐츠가 포함되어 요구사항의 “오픈한 콘텐츠 수”와 불일치할 수 있었다.
|
|
||||||
- 3차 수정: 오픈된 콘텐츠 없이 미래 예약 라이브만 있을 때 `D+-N`이 내려가 요구사항의 “오늘 기준 데뷔일로부터 며칠째(D+N)” 표현과 불일치했다.
|
|
||||||
- 어떻게:
|
|
||||||
- 1차 구현/2차 수정 모두 Kotlin LSP 부재로 `lsp_diagnostics`는 불가를 확인했다.
|
|
||||||
- 1차 구현 시점과 2차 수정 시점에 각각 `./gradlew ktlintCheck test build`를 실행해 정적검사/테스트/빌드 성공(Exit code 0)을 확인했다.
|
|
||||||
- 3차 수정 시점에도 Kotlin LSP 부재로 `lsp_diagnostics`는 불가를 확인했고, `./gradlew ktlintCheck test build`를 실행해 정적검사/테스트/빌드 성공(Exit code 0)을 확인했다.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 회원 차단 동일 본인인증 확장 구현
|
|
||||||
|
|
||||||
- [x] `memberBlock` 기존 단일 유저 차단 동작 확인
|
|
||||||
- [x] 차단 대상 유저가 본인인증(`Auth`)된 유저인지 확인
|
|
||||||
- [x] 본인인증 유저일 경우 동일 `di`를 가진 유저 id 목록 조회
|
|
||||||
- [x] 요청 유저(`memberId`)가 목록에 포함된 경우 제외
|
|
||||||
- [x] 대상 유저 + 동일 본인인증 유저 전체에 대해 차단 활성화 처리
|
|
||||||
- [x] 변경 파일 LSP 진단 및 관련 테스트 실행
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 무엇을: `MemberService.memberBlock`을 확장해 차단 대상 1명 + 동일 `Auth.di`를 가진 모든 계정을 일괄 차단하도록 수정했다.
|
|
||||||
- 왜: 본인인증 기반 다중 계정 우회 차단을 방지하고, 요청된 정책(동일 본인인증 정보 보유 계정 전체 차단)을 반영하기 위함이다.
|
|
||||||
- 어떻게 검증했는가:
|
|
||||||
- `lsp_diagnostics` 실행 시 `.kt` LSP 서버 미구성으로 진단 불가를 확인했다.
|
|
||||||
- `./gradlew test` 실행 성공.
|
|
||||||
- `./gradlew build -x test` 실행 성공(ktlint/check 포함).
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
## 구현 항목
|
|
||||||
|
|
||||||
- [x] SNS 응답/요청 DTO 전수 점검 후 `blogUrl` 제거
|
|
||||||
- [x] SNS 응답/요청 DTO에 `kakaoOpenChatUrl` 추가
|
|
||||||
- [x] 기존 `websiteUrl` 입력/반환 값을 `kakaoOpenChatUrl`로 동일 매핑
|
|
||||||
- [x] 회원 정보 수정 API(`ProfileUpdateRequest`, `MemberService.profileUpdate`) 반영
|
|
||||||
- [x] SNS 정보를 반환하는 API 응답(`ProfileResponse`, `MyPageResponse`, `CreatorResponse`, `GetCreatorDetailResponse`, `GetLiveRoomUserProfileResponse`, `GetRoomDetailManager`) 반영
|
|
||||||
- [x] LSP 진단/테스트/빌드 검증 및 결과 기록
|
|
||||||
- [x] 2차 수정: non-null Response 호환성을 위해 `GetCreatorDetailResponse`의 `websiteUrl`, `blogUrl` 복구
|
|
||||||
- [x] 2차 수정: non-null Response 호환성을 위해 `GetLiveRoomUserProfileResponse`의 `websiteUrl`, `blogUrl` 복구
|
|
||||||
- [x] 2차 수정 검증: 테스트/빌드 재실행 및 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 1차 구현
|
|
||||||
- 무엇을: SNS 필드를 `instagramUrl`, `fancimmUrl`, `xUrl`, `youtubeUrl`, `kakaoOpenChatUrl` 구조로 통일하고 `blogUrl`을 API 요청/응답 계층에서 제거했다. `kakaoOpenChatUrl`은 기존 `member.websiteUrl` 컬럼 값을 그대로 사용하도록 매핑했다.
|
|
||||||
- 왜: DB/Entity 변경 없이 기존 `websiteUrl` 저장 데이터를 카카오 오픈채팅 링크로 재해석해 노출하고, 더 이상 사용하지 않는 `blogUrl`을 API 스펙에서 제거하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 코드 반영: `ProfileUpdateRequest`, `ProfileResponse`, `MyPageResponse`, `CreatorResponse`, `GetCreatorDetailResponse`, `GetLiveRoomUserProfileResponse`, `GetRoomDetailResponse`, `MemberService`, `ExplorerService`, `LiveRoomService`
|
|
||||||
- 정적 진단: `lsp_diagnostics` 실행 시 `.kt` LSP 미구성으로 불가(환경 제약 확인)
|
|
||||||
- 동작 검증: `./gradlew test && ./gradlew build` 실행
|
|
||||||
- 결과: `BUILD SUCCESSFUL` (test 성공 후 build 성공)
|
|
||||||
|
|
||||||
- 2차 수정
|
|
||||||
- 무엇을: non-null Response에서 제거되었던 `websiteUrl`, `blogUrl` 필드를 `GetCreatorDetailResponse`, `GetLiveRoomUserProfileResponse`에 복구했다. 동시에 각 서비스 매핑에서 해당 필드를 다시 응답에 포함했다.
|
|
||||||
- 왜: 필수 응답 키 제거로 인한 하위 호환성 이슈를 해소하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 코드 반영: `GetCreatorDetailResponse`, `ExplorerService`, `GetLiveRoomUserProfileResponse`, `LiveRoomService`
|
|
||||||
- 동작 검증: `./gradlew test && ./gradlew build` 실행
|
|
||||||
- 결과: `BUILD SUCCESSFUL` (test 성공 후 build 성공)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
- [x] 홈 인기 크리에이터 조회 경로 확인 및 차단 필터 적용 지점 확정
|
|
||||||
- [x] 인기 크리에이터 목록에서 내가 차단한 크리에이터 제외 로직 적용
|
|
||||||
- [x] 변경 파일 진단 및 테스트/빌드 검증 수행
|
|
||||||
- [x] 검증 결과 기록
|
|
||||||
|
|
||||||
## 1차 구현 검증 기록
|
|
||||||
- 무엇: 홈 인기 크리에이터 조회 시 차단 관계 조건을 양방향으로 반영해 내가 차단한 크리에이터가 노출되지 않도록 수정했다.
|
|
||||||
- 왜: 일반 유저가 차단한 크리에이터가 인기 크리에이터 목록에 계속 노출되는 문제를 해결하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`: Kotlin LSP 서버가 환경에 구성되지 않아 해당 도구 기반 진단은 수행 불가.
|
|
||||||
- `./gradlew ktlintCheck`: 성공.
|
|
||||||
- `./gradlew test`: 성공.
|
|
||||||
- `./gradlew build -x test`: 성공.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 20260225_채널후원메시지_캔_천단위콤마추가
|
|
||||||
|
|
||||||
## 구현 항목
|
|
||||||
- [x] `ChannelDonationService.kt`의 `buildMessage` 함수 수정 (캔 수량 천단위 콤마 추가)
|
|
||||||
- [x] 관련 테스트 코드를 통한 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
### 1차 구현
|
|
||||||
- **무엇을**: `buildMessage` 함수 내에서 `can` 변수를 `String.format("%,d", can)`으로 포맷팅하도록 수정
|
|
||||||
- **왜**: 후원 메시지 표시 시 캔 수량에 천단위 콤마를 추가하여 가독성을 높이기 위함
|
|
||||||
- **어떻게**:
|
|
||||||
- `ChannelDonationService.kt` 수정
|
|
||||||
- `./gradlew test` 실행 후 결과 확인
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- **무엇을**: `ChannelDonationServiceTest`에 `can = 1000`일 때 메시지가 `1,000캔` 형식으로 생성되는지 검증하는 테스트(`shouldFormatCanWithCommaInDonationMessage`)를 추가하고 문서 체크박스를 완료 처리
|
|
||||||
- **왜**: 기존 테스트는 천단위 콤마 포맷을 직접 검증하지 않아 문서의 "관련 테스트 코드를 통한 검증" 항목을 충족하기 어려웠기 때문
|
|
||||||
- **어떻게**:
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt`에 메시지 포맷 검증 테스트 추가
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
- [x] 기존 `memberBlock` 동일인 판별 로직(`di` 단일 조건)과 연관 Repository 조회 경로 확인
|
|
||||||
- [x] `AuthRepository`에 `name + birth + di + gender` AND 조건 조회 메서드 추가
|
|
||||||
- [x] `MemberService.memberBlock`에서 다중 조건 조회 메서드 사용으로 변경
|
|
||||||
- [x] 변경 파일 정적 진단 및 테스트 실행
|
|
||||||
- [x] 구현 결과/검증 기록 문서 반영
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `memberBlock`의 동일인 확장 조회를 `di` 단일 조건에서 `name + birth + di + gender` AND 조건으로 변경했다.
|
|
||||||
- 왜: 동일인 판단 정밀도를 높여, `di`만 일치하는 케이스로 과차단되는 가능성을 줄이기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt`에 `getMemberIdsByNameAndBirthAndDiAndGender(...)` QueryDSL 조회를 추가했다.
|
|
||||||
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`의 `memberBlock`에서 `blockedMember.auth`의 `name/birth/di/gender`를 사용해 신규 조회 메서드를 호출하도록 바꿨다.
|
|
||||||
- 검증: `lsp_diagnostics`는 `.kt` LSP 서버 미구성으로 실행 불가(도구 에러 확인). 대신 `./gradlew test` 성공, `./gradlew build -x test` 성공으로 테스트/빌드 및 `ktlint` 체크 통과를 확인했다.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
SET @schema_name := DATABASE();
|
|
||||||
|
|
||||||
SET @use_can_index_exists := (
|
|
||||||
SELECT COUNT(1)
|
|
||||||
FROM information_schema.statistics
|
|
||||||
WHERE table_schema = @schema_name
|
|
||||||
AND table_name = 'use_can'
|
|
||||||
AND index_name = 'idx_use_can_channel_donation_filter'
|
|
||||||
);
|
|
||||||
SET @use_can_index_sql := IF(
|
|
||||||
@use_can_index_exists = 0,
|
|
||||||
'ALTER TABLE use_can ADD INDEX idx_use_can_channel_donation_filter (can_usage, is_refund, created_at, id)',
|
|
||||||
'SELECT "idx_use_can_channel_donation_filter already exists"'
|
|
||||||
);
|
|
||||||
PREPARE use_can_index_stmt FROM @use_can_index_sql;
|
|
||||||
EXECUTE use_can_index_stmt;
|
|
||||||
DEALLOCATE PREPARE use_can_index_stmt;
|
|
||||||
|
|
||||||
SET @use_can_calculate_join_index_exists := (
|
|
||||||
SELECT COUNT(1)
|
|
||||||
FROM information_schema.statistics
|
|
||||||
WHERE table_schema = @schema_name
|
|
||||||
AND table_name = 'use_can_calculate'
|
|
||||||
AND index_name = 'idx_use_can_calculate_settlement_join'
|
|
||||||
);
|
|
||||||
SET @use_can_calculate_join_index_sql := IF(
|
|
||||||
@use_can_calculate_join_index_exists = 0,
|
|
||||||
'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_settlement_join (use_can_id, status, recipient_creator_id)',
|
|
||||||
'SELECT "idx_use_can_calculate_settlement_join already exists"'
|
|
||||||
);
|
|
||||||
PREPARE use_can_calculate_join_index_stmt FROM @use_can_calculate_join_index_sql;
|
|
||||||
EXECUTE use_can_calculate_join_index_stmt;
|
|
||||||
DEALLOCATE PREPARE use_can_calculate_join_index_stmt;
|
|
||||||
|
|
||||||
SET @use_can_calculate_creator_index_exists := (
|
|
||||||
SELECT COUNT(1)
|
|
||||||
FROM information_schema.statistics
|
|
||||||
WHERE table_schema = @schema_name
|
|
||||||
AND table_name = 'use_can_calculate'
|
|
||||||
AND index_name = 'idx_use_can_calculate_creator_settlement'
|
|
||||||
);
|
|
||||||
SET @use_can_calculate_creator_index_sql := IF(
|
|
||||||
@use_can_calculate_creator_index_exists = 0,
|
|
||||||
'ALTER TABLE use_can_calculate ADD INDEX idx_use_can_calculate_creator_settlement (recipient_creator_id, status, use_can_id)',
|
|
||||||
'SELECT "idx_use_can_calculate_creator_settlement already exists"'
|
|
||||||
);
|
|
||||||
PREPARE use_can_calculate_creator_index_stmt FROM @use_can_calculate_creator_index_sql;
|
|
||||||
EXECUTE use_can_calculate_creator_index_stmt;
|
|
||||||
DEALLOCATE PREPARE use_can_calculate_creator_index_stmt;
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
# 관리자/크리에이터 관리자 채널 후원 정산 페이지 API 작업 계획
|
|
||||||
|
|
||||||
- [x] 기존 정산 API 패턴(`admin.calculate`, `creator.admin.calculate`)과 채널 후원 데이터 소스(`ChannelDonationMessage`, `CanUsage.CHANNEL_DONATION`)를 확인한다.
|
|
||||||
- [x] 기존 패키지에 직접 누적하지 않도록 신규 하위 패키지를 설계한다.
|
|
||||||
- 관리자: `kr.co.vividnext.sodalive.admin.calculate.channelDonation`
|
|
||||||
- 크리에이터 관리자: `kr.co.vividnext.sodalive.creator.admin.calculate.channelDonation`
|
|
||||||
- [x] 관리자 채널 후원 정산 조회 API를 추가하고, 날짜 범위(`startDateStr`, `endDateStr`)로 전체 데이터를 조회한 뒤 응답을 크리에이터별로 그룹화해 반환하도록 설계한다.
|
|
||||||
- [x] 크리에이터 관리자 채널 후원 정산 조회 API를 추가하고, 날짜 범위(`startDateStr`, `endDateStr`)만 입력받아 인증 사용자 본인 데이터만 조회한다.
|
|
||||||
- [x] 서비스 계층에서 날짜 문자열을 `convertLocalDateTime()`으로 변환하고 종료일은 `23:59:59`로 보정해 조회 구간을 통일한다.
|
|
||||||
- [x] 저장소(QueryRepository) 계층에 날짜 범위 조건(`createdAt >= startDate`, `createdAt <= endDate`)과 크리에이터 기준 그룹화(`groupBy(member.id)` 등)를 반영한 집계 조회를 추가한다.
|
|
||||||
- [x] API URL을 기존 정산 URL 규칙에 맞춰 확정하고 문서화한다.
|
|
||||||
- 관리자: `GET /admin/calculate/channel-donation-by-creator`
|
|
||||||
- 크리에이터 관리자: `GET /creator-admin/calculate/channel-donation`
|
|
||||||
- [x] 정산 계산 공식을 공통 로직으로 구현하고, 사람이 이해하기 쉬운 한글 주석을 추가한다.
|
|
||||||
- 원화 = 캔 * 100
|
|
||||||
- 수수료 = 원화 * 6.6%
|
|
||||||
- 정산금액 = (원화 - 수수료) * 85%
|
|
||||||
- 원천세 = 정산금액 * 3.3%
|
|
||||||
- 입금액 = 정산금액 - 원천세
|
|
||||||
- [x] 계산 정밀도 정책을 정의한다(`BigDecimal`, `RoundingMode.HALF_UP`, 반올림 시점 고정).
|
|
||||||
- [x] 성능/효율 개선 항목을 반영한다(집계 쿼리 중심 처리, 불필요한 애플리케이션 후처리 최소화, count 조회 최적화 검토).
|
|
||||||
- [x] 응답 DTO 스펙을 아래 필드로 고정하고 권한 정책(관리자=전체, 크리에이터 관리자=본인)을 함께 검증한다.
|
|
||||||
- 날짜(`yyyy-MM-dd`)
|
|
||||||
- 크리에이터
|
|
||||||
- 건수(`count`)
|
|
||||||
- 총 받은 캔 수(`totalCan`)
|
|
||||||
- 원화
|
|
||||||
- 수수료
|
|
||||||
- 정산금액
|
|
||||||
- 원천세
|
|
||||||
- 입금액
|
|
||||||
- [x] 테스트를 추가한다(관리자 날짜 필터 + 크리에이터별 그룹화 응답, 크리에이터 관리자 날짜 필터/본인 범위, 계산식 정확성, 경계값).
|
|
||||||
- [x] 검증을 수행한다(`./gradlew test`, `./gradlew build`, 필요 시 `./gradlew ktlintCheck`).
|
|
||||||
|
|
||||||
## API URL 선정 근거
|
|
||||||
|
|
||||||
- 기본 경로는 권한 범위별 정산 컨트롤러 관례를 따른다.
|
|
||||||
- 관리자: `@RequestMapping("/admin/calculate")`
|
|
||||||
- 크리에이터 관리자: `@RequestMapping("/creator-admin/calculate")`
|
|
||||||
- 하위 경로는 기존 정산 API와 동일하게 소문자 하이픈(`kebab-case`) 명사 조합을 사용한다.
|
|
||||||
- 예: `content-donation-list`, `cumulative-sales-by-content`, `community-by-creator`
|
|
||||||
- `channel-donation` 토큰은 기존 채널 후원 API 경로(`@RequestMapping("/explorer/profile/channel-donation")`)와 용어를 맞춰 도메인 표현을 통일한다.
|
|
||||||
- 관리자 정산은 조회 결과가 크리에이터별 그룹화 응답이므로 기존 `*-by-creator` 패턴을 적용해 `channel-donation-by-creator`로 정한다.
|
|
||||||
- 크리에이터 관리자 정산은 인증 사용자 본인 범위로 고정되므로 `-by-creator` 접미사를 제외하고 `channel-donation`으로 정한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 계획 수립
|
|
||||||
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 페이지 API 구현을 위한 작업 계획 문서를 작성했다.
|
|
||||||
- 왜: 구현 전에 패키지 구조, 날짜 범위 조회, 정산 계산식, 성능 검증 기준을 명확히 하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `docs`의 기존 작업 계획 문서 형식(체크박스 + 검증 기록)을 기준으로 템플릿을 맞췄다.
|
|
||||||
- `admin.calculate`, `creator.admin.calculate`, `explorer.profile.channelDonation` 경로를 탐색해 반영했다.
|
|
||||||
- 사용자 요청에 따라 실제 코드 구현/테스트는 수행하지 않고 계획 문서만 작성했다.
|
|
||||||
|
|
||||||
### 2차 계획 수정
|
|
||||||
- 무엇을: 조회 조건을 `관리자=날짜+크리에이터 구분`, `크리에이터 관리자=날짜만`으로 명확히 분리했고, 응답 필드를 `날짜(yyyy-MM-dd), 크리에이터, 원화, 수수료, 정산금액, 원천세, 입금액`으로 고정했다.
|
|
||||||
- 왜: 추가 요구사항(조회 조건 분리, Response 필드 고정)을 계획 단계에서 누락 없이 반영하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `admin.calculate`/`creator.admin.calculate`의 기존 날짜 파라미터 및 인증 기반 필터링 패턴을 재탐색해 계획 항목을 수정했다.
|
|
||||||
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
|
|
||||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
- 문서만 작성해야 하는 요청 범위를 유지하기 위해 코드 구현/테스트 변경은 수행하지 않았다.
|
|
||||||
|
|
||||||
### 3차 계획 수정
|
|
||||||
- 무엇을: 관리자 조회 요구사항을 `크리에이터 식별값으로 필터`가 아닌 `조회 결과를 크리에이터별로 그룹화하여 반환`으로 정정했다.
|
|
||||||
- 왜: 사용자 의도가 “조회 조건 추가”가 아니라 “응답 결과 구성 방식(크리에이터별 그룹화)”이었기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `AdminCalculateController`의 `*-by-creator` 엔드포인트가 날짜/페이지 파라미터만 받고(`creatorId/memberId` 미입력), 서비스/리포지토리에서 `GetCalculateByCreatorResponse`와 `groupBy(member.id)` 기반으로 결과를 구성하는 패턴을 확인했다.
|
|
||||||
- 위 근거를 바탕으로 체크리스트를 `관리자=날짜 필터 + 크리에이터별 그룹화 응답` 기준으로 수정했다.
|
|
||||||
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
|
|
||||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
### 4차 계획 수정
|
|
||||||
- 무엇을: 작업 계획 문서에 API URL을 어떤 기준으로 정했는지(경로 규칙, 용어 선택, 최종 URL) 근거를 추가했다.
|
|
||||||
- 왜: 구현 전에 URL 명명 기준을 명확히 남겨, 이후 개발 시 경로 해석 차이와 재작업을 방지하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `AdminCalculateController`, `CreatorAdminCalculateController`, `ChannelDonationController`의 `@RequestMapping`/`@GetMapping` 패턴을 비교해 기준 경로와 하위 경로 규칙을 도출했다.
|
|
||||||
- 관리자 URL은 `*-by-creator` 관례를 적용해 `/admin/calculate/channel-donation-by-creator`, 크리에이터 관리자 URL은 본인 범위 고정 특성에 맞춰 `/creator-admin/calculate/channel-donation`으로 문서화했다.
|
|
||||||
- `lsp_diagnostics`(대상: 본 문서) 결과 `No diagnostics found`를 확인했다.
|
|
||||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
|
|
||||||
### 5차 구현
|
|
||||||
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 API를 신규 하위 패키지로 구현하고, 날짜 범위 조회/크리에이터별 그룹화/정산 공식 공통 계산 로직을 적용했다.
|
|
||||||
- 왜: 기존 정산 코드에 얽히지 않고 유지보수 가능한 구조로 요구사항(관리자=크리에이터별 그룹 응답, 크리에이터 관리자=본인 범위 조회)을 정확히 반영하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 신규 패키지 생성: `admin.calculate.channelDonation`, `creator.admin.calculate.channelDonation`, 공통 계산기 `calculate.channelDonation`.
|
|
||||||
- API 구현: `GET /admin/calculate/channel-donation-by-creator`, `GET /creator-admin/calculate/channel-donation`.
|
|
||||||
- QueryDSL 집계: `UseCan` + `UseCanCalculate`를 사용해 `CanUsage.CHANNEL_DONATION`, 날짜 범위, 환불 제외 조건을 적용하고 관리자 응답은 날짜+크리에이터 기준 그룹화, 크리에이터 관리자 응답은 날짜 기준 그룹화로 구현.
|
|
||||||
- 정산 계산식 공통화: `ChannelDonationSettlementCalculator`에서 `BigDecimal("0.066")`, `BigDecimal("0.85")`, `BigDecimal("0.033")`, `RoundingMode.HALF_UP` 정책으로 계산하고 공식 설명 한글 주석을 추가.
|
|
||||||
- 테스트 추가: 계산식/반올림 단위 테스트 및 관리자·크리에이터 관리자 컨트롤러/서비스 경로 테스트를 추가.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculatorTest"` → 성공
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew test` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
- 참고: Kotlin LSP 서버 미설정 환경이라 `.kt` 파일에 대한 `lsp_diagnostics`는 실행 시 서버 미설정 오류를 반환했다.
|
|
||||||
|
|
||||||
### 6차 수정
|
|
||||||
- 무엇을: 정산 계산식을 단계별 반올림 후 다음 단계 계산하는 방식으로 수정하고, 크리에이터 관리자 조회 쿼리/카운트에서 불필요한 `member` 조인을 제거했다.
|
|
||||||
- 왜: 정산 항목 간 관계(`입금액 = 정산금액 - 원천세`)를 정수 기준으로 일관되게 맞추고, 조회 성능 최적화를 위해 불필요 조인을 줄이기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `ChannelDonationSettlementCalculator`를 단계별 반올림 파이프라인으로 변경했다.
|
|
||||||
- `수수료 = round(원화 * 6.6%)`
|
|
||||||
- `정산금액 = round((원화 - 수수료) * 85%)`
|
|
||||||
- `원천세 = round(정산금액 * 3.3%)`
|
|
||||||
- `입금액 = 정산금액 - 원천세`
|
|
||||||
- 크리에이터 관리자 경로는 인증 사용자 닉네임을 서비스 인자로 전달해 응답 `creator`를 구성하고, QueryRepository의 `member` 조인/닉네임 select를 제거했다.
|
|
||||||
- 관리자 totalCount는 `member` 조인 없이 `recipientCreatorId` 기반 distinct 키로 계산하도록 변경했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew test` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
|
|
||||||
### 7차 수정
|
|
||||||
- 무엇을: 요청한 2번/3번 최적화를 반영해 QueryDSL `@QueryProjection` 기반 매핑으로 전환하고, 날짜 그룹 조회 경로 인덱스 전략 DDL을 추가했다. 또한 테스트 가독성을 위해 `@DisplayName`을 추가했다.
|
|
||||||
- 왜: `Projections.constructor` 대비 타입 안전성과 유지보수성을 높이고, 채널 후원 정산 조회의 날짜 범위/조인 필터 성능 개선 근거를 DDL로 명확히 남기기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- Query DTO 전환:
|
|
||||||
- `GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`에 `@QueryProjection`을 적용했다.
|
|
||||||
- 각 QueryRepository의 `Projections.constructor`를 `QGet*QueryData(...)` 호출로 교체했다.
|
|
||||||
- 인덱스 전략 반영:
|
|
||||||
- `docs/20260226_channel_donation_settlement_index_ddl.sql` 파일을 추가해 `use_can`, `use_can_calculate` 인덱스 DDL을 정의했다.
|
|
||||||
- 테스트 가독성 개선:
|
|
||||||
- 채널 후원 정산 관련 신규 테스트에 `@DisplayName`(한글)을 추가해 테스트 의도를 명확히 했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew test` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
- 참고: `./gradlew test`와 `./gradlew build`를 병렬 실행하면 테스트 결과 XML 파일 쓰기 충돌이 재발할 수 있어, 순차 실행 기준으로 최종 검증했다.
|
|
||||||
|
|
||||||
### 8차 수정
|
|
||||||
- 무엇을: 채널 후원 정산 Item 응답(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)에 `count` 필드를 추가하고, QueryData/Repository/Test를 함께 갱신했다.
|
|
||||||
- 왜: 사용자 요청에 따라 정산 응답에서 그룹별 건수를 직접 확인할 수 있도록 하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- Item DTO에 `@JsonProperty("count") val count: Int`를 추가했다.
|
|
||||||
- QueryDTO(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)에 `count: Long`을 추가하고 `toResponseItem()`에서 `count.toInt()`로 매핑했다.
|
|
||||||
- Repository projection에 `useCan.id.count()`를 추가해 count 값을 조회하도록 반영했다.
|
|
||||||
- 컨트롤러/서비스 테스트 fixture 및 assertion에 `count` 검증을 추가했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew test` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
|
|
||||||
### 9차 수정
|
|
||||||
- 무엇을: 채널 후원 정산 `count`가 분할 정산 레코드 수로 과집계되던 문제를 수정하고, 동일 후원(`UseCan` 1건) + 분할 정산(`UseCanCalculate` 2건) 회귀 테스트를 관리자/크리에이터 관리자 경로에 추가했다.
|
|
||||||
- 왜: 결제 게이트웨이별 분할 정산이 발생하면 기존 `useCan.id.count()`가 실제 후원 건수보다 크게 집계되어 정산 화면 `count`가 잘못 표시되기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `AdminChannelDonationCalculateQueryRepository`, `CreatorAdminChannelDonationCalculateQueryRepository`의 집계 `count`를 `useCan.id.countDistinct()`로 변경했다.
|
|
||||||
- QueryRepository 통합 테스트(`AdminChannelDonationCalculateQueryRepositoryTest`, `CreatorAdminChannelDonationCalculateQueryRepositoryTest`)를 추가해 분할 정산 시 `count=1`, `totalCan` 합산(50) 동작을 검증했다.
|
|
||||||
- H2 환경에서 MySQL 함수(`DATE_FORMAT`, `CONVERT_TZ`)를 테스트 가능하게 하기 위해 `H2MySqlFunctionDialect`, `H2MysqlDateFunctions` 테스트 지원 코드를 추가하고 각 테스트에서 alias를 등록했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
- 참고: Kotlin LSP 미설정 환경이라 `.kt` 대상 `lsp_diagnostics`는 실행 시 서버 미설정 오류가 발생했다.
|
|
||||||
|
|
||||||
### 10차 수정
|
|
||||||
- 무엇을: 관리자 채널 후원 정산의 `totalCount` 쿼리에 `member` `innerJoin`을 추가해 목록 조회와 동일한 조인 조건으로 집계하도록 정렬했다.
|
|
||||||
- 왜: 기존에는 `totalCount`는 `member` 조인 없이 계산하고 목록은 `member` `innerJoin`을 사용해, 데이터 정합성 이슈(고아 `recipientCreatorId`)가 있을 때 `totalCount`와 `items`가 불일치할 수 있었다.
|
|
||||||
- 어떻게:
|
|
||||||
- `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotalCount(...)`에 `member` 조인(`member.id = useCanCalculate.recipientCreatorId`)을 추가했다.
|
|
||||||
- distinct 그룹 키를 `recipientCreatorId` 문자열 대신 `member.id` 문자열 기준으로 변경해 목록 쿼리의 그룹 축(날짜+멤버)과 맞췄다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
- 참고: `./gradlew test`와 `./gradlew build`를 병렬 실행했을 때 test result XML 쓰기 충돌이 1회 발생해, 이후 순차 실행으로 재검증했다.
|
|
||||||
|
|
||||||
### 10차 수정
|
|
||||||
- 무엇을: 정산 페이지 Item 응답(`GetAdminChannelDonationSettlementItem`, `GetCreatorChannelDonationSettlementItem`)에 `totalCan` 필드를 추가했다.
|
|
||||||
- 왜: 사용자 요청대로 화면에서 건수 다음에 총 받은 캔 수를 함께 노출하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- Item DTO에 `@JsonProperty("totalCan") val totalCan: Int`를 `count` 다음 위치로 추가했다.
|
|
||||||
- QueryData(`GetAdminChannelDonationSettlementQueryData`, `GetCreatorChannelDonationSettlementQueryData`)의 `toResponseItem()`에서 `totalCan ?: 0`을 응답 Item의 `totalCan`으로 매핑했다.
|
|
||||||
- 컨트롤러/서비스 테스트 fixture와 assertion에 `totalCan` 검증을 추가했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew test` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
|
|
||||||
### 11차 수정
|
|
||||||
- 무엇을: 채널 후원 정산 인덱스 DDL(`docs/20260226_channel_donation_settlement_index_ddl.sql`)을 재실행 가능한 멱등 스크립트로 수정했다.
|
|
||||||
- 왜: 동일 DB에 DDL을 재적용할 때 기존 `ADD INDEX`가 `Duplicate key name`으로 실패할 수 있어, 운영 재적용/롤백 후 재적용 시 안정성을 확보해야 했기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `information_schema.statistics`에서 `table_schema = DATABASE()` 기준으로 인덱스 존재 여부를 조회하도록 변경했다.
|
|
||||||
- 인덱스가 없을 때만 `ALTER TABLE ... ADD INDEX`를 실행하고, 이미 존재하면 안내 `SELECT`를 실행하는 동적 SQL(`PREPARE`/`EXECUTE`) 패턴을 적용했다.
|
|
||||||
- 대상 인덱스 3개(`idx_use_can_channel_donation_filter`, `idx_use_can_calculate_settlement_join`, `idx_use_can_calculate_creator_settlement`) 모두 동일 규칙으로 반영했다.
|
|
||||||
- 검증 실행:
|
|
||||||
- `lsp_diagnostics`(대상: `docs/20260226_channel_donation_settlement_index_ddl.sql`) → `.sql` LSP 서버 미설정으로 진단 불가(환경 제약)
|
|
||||||
- `lsp_diagnostics`(대상: 본 문서) → `No diagnostics found`
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 라이브 추천 차단 JOIN 및 캐시 무효화
|
|
||||||
|
|
||||||
- [x] `LiveRecommendService.getRecommendLive`의 차단 필터 처리 구조 점검
|
|
||||||
- [x] `LiveRecommendRepository.getRecommendLive`를 DB 조회 시 차단 관계를 JOIN/조건으로 제외하도록 변경
|
|
||||||
- [x] 차단(`memberBlock`) 및 차단 해제(`memberUnBlock`) 시 추천 라이브 캐시가 즉시 반영되도록 무효화 처리
|
|
||||||
- [x] 변경 코드 정적 진단 및 테스트/빌드 검증
|
|
||||||
- [x] 검증 기록 작성
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `getRecommendLive`의 차단 제외 로직을 서비스 단 필터링에서 QueryDSL `leftJoin(blockMember)` + `blockMember.id.isNull` 조건으로 이동했고, 차단/차단해제 시 `CacheManager`로 `getRecommendLive:{memberId}` 키를 직접 evict 하도록 적용했다.
|
|
||||||
- 왜: 기존 방식은 추천 결과 조회 후 creator마다 `isBlocked`를 반복 호출해 후처리하고, 캐시 만료 전까지 차단/해제 결과가 반영되지 않는 문제가 있어 DB 레벨 필터링과 이벤트성 캐시 무효화가 필요했다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` (대상: `LiveRecommendRepository.kt`, `LiveRecommendService.kt`, `MemberService.kt`) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
|
|
||||||
- `./gradlew test` 실행 결과: **성공 (BUILD SUCCESSFUL)**
|
|
||||||
- `./gradlew build` 실행 결과: **성공 (BUILD SUCCESSFUL, ktlint/check 포함)**
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 라이브 추천 차단 JOIN/캐시 무효화 검증 테스트
|
|
||||||
|
|
||||||
- [x] `LiveRecommendRepository.getRecommendLive`가 차단 관계(`member -> creator`, `creator -> member`)를 DB 조회 단계에서 제외하는지 테스트 추가
|
|
||||||
- [x] `LiveRecommendService.getRecommendLive`가 서비스 단 후처리 없이 저장소 결과를 그대로 위임하는지 테스트 추가
|
|
||||||
- [x] `MemberService.memberBlock`/`memberUnBlock` 호출 시 추천 라이브 캐시 키(`getRecommendLive:{memberId}`)가 즉시 무효화되는지 테스트 추가
|
|
||||||
- [x] 테스트 및 빌드 검증 수행
|
|
||||||
- [x] 검증 기록 작성
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 검증 테스트 구현
|
|
||||||
- 무엇을: 문서 요구사항(추천 라이브 차단 JOIN, 서비스 위임 구조, 차단/해제 시 캐시 무효화)을 검증하는 테스트 3종을 추가했다.
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt`
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt`
|
|
||||||
- `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt`
|
|
||||||
- 왜: `docs/20260226_라이브추천차단조인및캐시무효화.md`에 기재된 구현이 실제 코드에서 회귀 없이 유지되는지 자동 검증이 필요하다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` (대상: 위 3개 Kotlin 테스트 파일) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"` 실행 결과: **성공 (BUILD SUCCESSFUL)**
|
|
||||||
- `./gradlew build` 1차 실행 결과: **실패 (`MemberServiceCacheEvictionTest.kt` 라인 길이/인자 줄바꿈 ktlint 위반)**
|
|
||||||
- `MemberServiceCacheEvictionTest.kt` 포맷 수정 후 `./gradlew build` 재실행 결과: **성공 (BUILD SUCCESSFUL, test/check/ktlint 통과)**
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 오리지널 시리즈 차단 필터 적용
|
|
||||||
|
|
||||||
## 구현 체크리스트
|
|
||||||
- [x] `HomeService.fetchData` 경로에서 오리지널 시리즈 조회 시 `memberId` 전달
|
|
||||||
- [x] `ContentSeriesService.getOriginalAudioDramaList` 시그니처에 `memberId` 반영
|
|
||||||
- [x] `ContentSeriesRepository.getOriginalAudioDramaList` 인터페이스/구현에 `memberId` 반영
|
|
||||||
- [x] 오리지널 시리즈 QueryDSL 조회에 양방향 차단(`내가 차단`/`나를 차단`) 서브쿼리 필터 적용
|
|
||||||
- [x] 오리지널 탭 API 경로(`AudioContentMainTabSeries*`)에도 `memberId` 전달
|
|
||||||
- [x] 빌드/테스트/진단 실행 후 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
- 1차 구현
|
|
||||||
- 무엇을: 홈/시리즈탭의 오리지널 시리즈 조회 경로에 `memberId`를 전달하고, `ContentSeriesRepository.getOriginalAudioDramaList` 및 `getOriginalAudioDramaTotalCount`에 양방향 차단 서브쿼리(`blockedSubquery.exists().not()`)를 추가해 차단된 크리에이터 시리즈가 제외되도록 반영했다.
|
|
||||||
- 왜: 기존에는 오리지널 시리즈 조회 쿼리에 차단 조건이 없어, 내가 차단했거나 나를 차단한 크리에이터의 시리즈가 노출될 수 있었다.
|
|
||||||
- 어떻게: `./gradlew test` 실행 성공, `./gradlew build` 실행 성공으로 컴파일/테스트/정적검사(ktlint 포함 check 단계) 통과를 확인했다. Kotlin LSP는 환경에 서버가 없어(`.kt` 미지원) 진단 도구로는 확인할 수 없어 Gradle 빌드 기반으로 검증했다.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
- [x] Admin 채널 후원 정산 조회 흐름(Controller/Service/Repository/DTO) 확인
|
|
||||||
- [x] Creator 정산 조회 흐름(Controller/Service/Repository/DTO) 확인
|
|
||||||
- [x] 날짜 기준 비페이징 합계 조회 방식 결정 및 반영
|
|
||||||
- [x] `GetAdminChannelDonationSettlementResponse`에 합계 필드 추가
|
|
||||||
- [x] `GetCreatorChannelDonationSettlementResponse`에 합계 필드 추가
|
|
||||||
- [x] 관련 테스트/빌드/진단 실행 및 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자/크리에이터 관리자 채널 후원 정산 응답에 날짜 범위 전체(비페이징) 합계(`total`)를 추가하고, QueryRepository에 합계 전용 집계 쿼리를 추가했다.
|
|
||||||
- 왜: 기존 응답이 페이지 내 `items`와 `totalCount`만 제공해 날짜 범위 전체 정산 합계를 확인할 수 없었기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- 응답 DTO 확장
|
|
||||||
- `GetAdminChannelDonationSettlementResponse`에 `total` 필드 추가
|
|
||||||
- `GetCreatorChannelDonationSettlementResponse`에 `total` 필드 추가
|
|
||||||
- 합계 DTO/QueryData 추가: `GetAdminChannelDonationSettlementTotal`, `GetCreatorChannelDonationSettlementTotal`, 각 `*TotalQueryData`
|
|
||||||
- 서비스/리포지토리 반영
|
|
||||||
- 관리자: `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotal(...)` 추가 후 서비스에서 `total` 매핑
|
|
||||||
- 크리에이터 관리자: `CreatorAdminChannelDonationCalculateQueryRepository.getChannelDonationSettlementTotal(...)` 추가 후 서비스에서 `total` 매핑
|
|
||||||
- 테스트 반영
|
|
||||||
- 컨트롤러/서비스/리포지토리 테스트에서 `total` 필드와 합계 집계 검증 추가
|
|
||||||
- 검증 명령 및 결과
|
|
||||||
- `lsp_diagnostics`(Kotlin 대상): `.kt` LSP 서버 미설정으로 진단 불가(환경 제약)
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew build` → 1차 실패(ktlint max line length), 코드 포맷 수정 후 재실행 성공
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 2026-02-26 콘텐츠/시리즈 상세 차단 오류메시지 수정
|
|
||||||
|
|
||||||
## 구현 체크리스트
|
|
||||||
- [x] 콘텐츠 상세(`getDetail`) 차단 예외 메시지 키를 전용 차단 키로 변경
|
|
||||||
- [x] 시리즈 상세(`getSeriesDetail`) 차단 예외 메시지 키를 전용 차단 키로 변경
|
|
||||||
- [x] `SodaMessageSource`에 콘텐츠/시리즈 차단 전용 메시지 키 추가
|
|
||||||
- [x] 정적 진단 및 테스트로 변경 영향 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇: `AudioContentService.getDetail`의 차단 예외 키를 `content.error.blocked_access`로 변경하고, `ContentSeriesService.getSeriesDetail`의 차단 예외 키를 `series.error.blocked_access`로 변경했다. `SodaMessageSource`에 두 키를 추가해 한국어 기준으로 각각 "콘텐츠 접근이 차단되었습니다.", "시리즈 접근이 차단되었습니다."를 반환하도록 반영했다.
|
|
||||||
- 왜: 기존에는 차단 상황에서도 `invalid_content_retry`/`invalid_series_retry`를 사용해 오류 의미가 모호했고, 요청 사항대로 차단 상황을 명확한 문구로 안내해야 했기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` (`AudioContentService.kt`, `ContentSeriesService.kt`, `SodaMessageSource.kt`) 실행: 실패 (현재 실행 환경에 Kotlin LSP 미구성으로 `.kt` 진단 불가)
|
|
||||||
- `./gradlew test` 실행: 성공
|
|
||||||
- `./gradlew ktlintCheck` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
- [x] 홈 `fetchData` 콘텐츠 랭킹 조회 경로 및 차단 적용 패턴 확인
|
|
||||||
- [x] `RankingRepository.getAudioContentRanking`에 양방향 차단(내가 차단/나를 차단) 조건 적용
|
|
||||||
- [x] 변경 파일 진단 및 테스트/빌드 검증 수행
|
|
||||||
- [x] 검증 결과 기록
|
|
||||||
|
|
||||||
## 1차 구현 검증 기록
|
|
||||||
- 무엇: 홈 `fetchData`의 `contentRanking`에서 내가 차단한 크리에이터와 나를 차단한 크리에이터의 콘텐츠를
|
|
||||||
모두 제외하도록 서비스 레벨 필터를 추가했다.
|
|
||||||
- 왜: 기존 랭킹 조회 쿼리에는 한 방향 차단만 반영되어 양방향 차단 관계를 완전히 차단하지 못할 수 있기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`: Kotlin(`.kt`)용 LSP 서버가 현재 환경에 없어 도구 기반 진단은 수행 불가.
|
|
||||||
- `./gradlew ktlintCheck`: 성공.
|
|
||||||
- `./gradlew test`: 성공.
|
|
||||||
- `./gradlew build -x test`: 성공.
|
|
||||||
|
|
||||||
## 2차 수정 검증 기록
|
|
||||||
- 무엇: 서비스(`HomeService`)에서 처리하던 `contentRanking` 차단 필터를 제거하고, `RankingRepository.getAudioContentRanking`
|
|
||||||
쿼리의 `blockMemberCondition`을 양방향 차단 조건으로 수정했다.
|
|
||||||
- 왜: 홈 서비스가 아닌 랭킹 데이터 조회 계층에서 차단 정책을 일관되게 보장하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`: Kotlin(`.kt`)용 LSP 서버가 현재 환경에 없어 도구 기반 진단은 수행 불가.
|
|
||||||
- `./gradlew ktlintCheck`: 성공.
|
|
||||||
- `./gradlew test`: 성공.
|
|
||||||
- `./gradlew build -x test`: 성공.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
- [x] Explorer 후원랭킹 집계 경로에서 후원 타입 필터 조건을 확인한다.
|
|
||||||
- [x] 크리에이터 프로필 후원랭킹 집계에 `CanUsage.CHANNEL_DONATION`을 반영하도록 쿼리를 수정한다.
|
|
||||||
- [x] 변경 범위와 연관된 테스트/검증(컴파일/테스트)을 실행한다.
|
|
||||||
- [x] 구현 완료 후 체크박스를 갱신하고 검증 기록(무엇을/왜/어떻게)을 남긴다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
- 1차 구현
|
|
||||||
- 무엇을: `CreatorDonationRankingQueryRepository`의 후원랭킹 조회/총원 집계 조건에 `CanUsage.CHANNEL_DONATION`을 추가했다.
|
|
||||||
- 왜: `ExplorerService.getCreatorProfile`의 후원랭킹이 기존 `DONATION`, `SPIN_ROULETTE`, `LIVE`만 포함해 채널 후원이 누락되고 있었기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`로 Kotlin 파일 진단을 시도했지만, 현재 환경에 `.kt` LSP 서버가 없어 도구 기반 진단은 불가했다.
|
|
||||||
- `./gradlew test` 실행 결과: 성공
|
|
||||||
- `./gradlew build -x test` 실행 결과: 성공
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 최근 종료 라이브(getLatestFinishedLive) 최적화
|
|
||||||
|
|
||||||
- [x] `getLatestFinishedLive` 조회를 DB 단계에서 차단 관계(`left join`)로 필터링하도록 변경
|
|
||||||
- [x] 조회 결과를 `GetLatestFinishedLiveResponse`로 QueryProjection 하여 서비스 단 추가 `map` 제거
|
|
||||||
- [x] 회원 차단(`memberBlock`) / 차단해제(`memberUnBlock`) 시 최근 종료 라이브 캐시 무효화 적용
|
|
||||||
- [x] 정적 진단 및 테스트/빌드 검증 수행
|
|
||||||
- [x] 검증 기록 작성
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `getLatestFinishedLive`를 서비스 후처리(`filter`/`map`)에서 제거하고, `LiveRoomRepository`에서 `leftJoin(blockMember)` + `blockMember.id.isNull` 조건으로 차단 관계를 DB 단계에서 제외하도록 변경했다. 또한 `GetLatestFinishedLiveResponse`에 `@QueryProjection` 생성자를 추가해 쿼리 결과를 응답 DTO로 바로 생성했다. 마지막으로 `memberBlock`/`memberUnBlock`에서 `getLatestFinishedLive:{memberId}` 캐시를 즉시 evict 하도록 반영했다.
|
|
||||||
- 왜: 기존 로직은 조회 후 애플리케이션 레벨에서 차단 여부를 반복 조회하고 별도 `map`을 수행해 비용이 컸고, 차단/차단해제 직후 최근 종료 라이브 캐시가 TTL 만료 전까지 stale 상태가 될 수 있어 DB 레벨 필터링 및 이벤트성 캐시 무효화가 필요했다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` (대상: `GetLatestFinishedLiveResponse.kt`, `LiveRoomRepository.kt`, `LiveRoomService.kt`, `MemberService.kt`) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
|
|
||||||
- `./gradlew test && ./gradlew build` 실행 결과: **성공 (BUILD SUCCESSFUL)**
|
|
||||||
- `./gradlew tasks --all` 실행 결과: **성공 (BUILD SUCCESSFUL)**
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
- [x] `getCreatorProfile`의 채널 후원 리스트 조회 경로 식별 (`ExplorerService` -> `ChannelDonationService` -> `ChannelDonationMessageRepository`)
|
|
||||||
- [x] 프로필 채널 후원 조회 시 조회 월의 1일~말일 범위만 조회되도록 기간 조건 반영
|
|
||||||
- [x] 기존 일반 채널 후원 목록 API 동작 영향 없는지 확인
|
|
||||||
- [x] 수정 파일 기준 정적 진단/테스트/빌드 검증 수행
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 크리에이터 프로필의 채널 후원 리스트 조회 기간을 월 단위로 제한
|
|
||||||
- 왜: 기존 기간 계산(`now - 1 month`)은 월 경계 기준 요구사항(해당 월 1일~말일)과 다름
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`(수정 파일 2개) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: 프로필 집계 응답뿐 아니라 전체 채널 후원 리스트 API도 월 단위(1일~말일) 조회로 통일
|
|
||||||
- 왜: 요구사항이 프로필 전용이 아닌 전체 채널 후원 리스트 대상까지 확장됨
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`(수정 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
|
|
||||||
|
|
||||||
### 3차 수정
|
|
||||||
- 무엇을: `endDateTime` nullable 분기와 중복 메서드를 제거하고 기존 조회 메서드 시그니처에 `endDateTime`을 포함해 단일 로직으로 정리
|
|
||||||
- 왜: `endDateTime`이 항상 존재하는 현재 요구사항에서 null 분기 로직은 불필요하며 유지보수 복잡도만 증가시킴
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`(수정 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
|
|
||||||
|
|
||||||
### 4차 수정
|
|
||||||
- 무엇을: `endDateTime` 도입 이후 테스트 의미를 월 경계 의도에 맞게 보강 (`Service`는 월 시작/종료 전달 검증, `Repository`는 월 범위 기반 필터 검증)
|
|
||||||
- 왜: 기존 테스트 일부는 단순 파라미터 통과 확인 수준이어서 월 경계 요구사항을 직접 담지 못함
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`(수정 테스트 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
|
|
||||||
|
|
||||||
### 5차 수정
|
|
||||||
- 무엇을: 채널 후원 테스트 2개 파일의 가독성 개선을 위해 `@DisplayName`(한글)과 BDD(`given/when/then`) 단락 설명을 추가
|
|
||||||
- 왜: 테스트 코드 길이가 길어지며 의도 파악이 어려워져, 시나리오/준비/실행/검증 흐름을 빠르게 읽을 수 있도록 개선 필요
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics`(수정 테스트 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*" && ./gradlew build` 실행: 성공
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 관리자 채널후원 정산 리뷰 지적사항 반영 작업 계획
|
|
||||||
|
|
||||||
- [x] 하위 호환성 유지 이슈는 요구사항 재확인 결과, 기존 이름을 신규 목적 경로로 사용하기로 확정되어 작업 범위에서 제외한다.
|
|
||||||
- [x] 엑셀 다운로드 API 테스트에서 `Content-Disposition` 헤더를 실질적으로 검증하도록 보강한다.
|
|
||||||
- [x] 관련 테스트와 빌드를 실행해 회귀 여부를 확인한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 반영
|
|
||||||
- 무엇을: 엑셀 다운로드 컨트롤러 테스트에서 `Content-Disposition` 헤더를 `getFirst(HttpHeaders.CONTENT_DISPOSITION)`로 조회하고, `attachment; filename*=` 포함 여부를 검증하도록 수정했다.
|
|
||||||
- 왜: 기존 `response.headers.contentDisposition` null 체크만으로는 헤더 누락/형식 회귀를 충분히 잡지 못해 테스트 신뢰도를 높이기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 코드 변경: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt`
|
|
||||||
- 범위 조정: 하위 호환성 유지 이슈는 요구사항 재확인 결과 작업 제외로 확정
|
|
||||||
- 실행 결과:
|
|
||||||
- `lsp_diagnostics (AdminChannelDonationCalculateControllerTest.kt)` → Kotlin LSP 미설정으로 진단 불가
|
|
||||||
- `./gradlew test --tests "*AdminChannelDonationCalculateControllerTest"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 관리자 채널후원 크리에이터별 정산 조회 및 엑셀 API 작업 계획
|
|
||||||
|
|
||||||
- [x] 기존 관리자 채널후원 정산 API의 날짜별 조회 경로를 식별하고 URL 변경 범위를 확정한다.
|
|
||||||
- [x] 관리자 채널후원 정산 날짜별 조회 API URL을 목적에 맞게 변경한다.
|
|
||||||
- [x] 관리자 크리에이터별 채널후원 정산 조회 API(`GET /admin/calculate/channel-donation-by-creator`)를 구현한다.
|
|
||||||
- [x] 관리자 크리에이터별 채널후원 정산 엑셀 다운로드 API(`GET /admin/calculate/channel-donation-by-creator/excel`)를 구현한다.
|
|
||||||
- [x] 크리에이터별 집계/카운트/합계 Query를 추가하고, 정산 계산 비율은 기존 채널후원 정산과 동일하게 적용한다.
|
|
||||||
- [x] 관련 테스트를 수정/추가하고 `./gradlew test`, `./gradlew build`로 검증한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 채널후원 정산 API를 날짜별/크리에이터별로 분리하고, 크리에이터별 정산 엑셀 다운로드 API를 추가했다.
|
|
||||||
- 왜: 기존 `/admin/calculate/channel-donation-by-creator`가 날짜별 조회 성격이어서 URL 의미를 분리하고, 요청한 크리에이터별 목록/엑셀 기능을 제공하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 컨트롤러에서 기존 날짜별 조회 경로를 `GET /admin/calculate/channel-donation-by-date`로 변경했다.
|
|
||||||
- 신규 크리에이터별 조회 `GET /admin/calculate/channel-donation-by-creator`와 엑셀 다운로드 `GET /admin/calculate/channel-donation-by-creator/excel`를 추가했다.
|
|
||||||
- QueryRepository에 날짜별/크리에이터별 집계 메서드를 분리하고, 크리에이터별 총건수(distinct creator) 및 엑셀용 전체 조회를 추가했다.
|
|
||||||
- 서비스에서 크리에이터별 조회 응답 DTO와 엑셀(XSSFWorkbook) 생성 로직을 구현했다.
|
|
||||||
- 정산 비율/공식은 기존 `ChannelDonationSettlementCalculator`를 그대로 사용해 동일 정책을 유지했다.
|
|
||||||
- 테스트를 수정/추가해 날짜별 라우팅, 크리에이터별 조회, 엑셀 다운로드, Query 집계를 검증했다.
|
|
||||||
- 실행 결과:
|
|
||||||
- `lsp_diagnostics` (수정된 `.kt` 파일들) → Kotlin LSP 미설정으로 진단 불가
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: 크리에이터별 정산 엑셀 다운로드 파일의 시트명과 헤더를 한글로 변경했다.
|
|
||||||
- 왜: 관리자 화면에서 다운로드한 엑셀의 컬럼 의미를 즉시 식별할 수 있도록 가독성을 높이기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 시트명 `channel-donation-by-creator`를 `크리에이터별 채널후원 정산`으로 변경했다.
|
|
||||||
- 헤더를 `크리에이터`, `건수`, `총 받은 캔 수`, `원화`, `수수료`, `정산금액`, `원천세`, `입금액`으로 변경했다.
|
|
||||||
- 실행 결과:
|
|
||||||
- `./gradlew test --tests "*channelDonation*"` → 성공
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# 20260303_기부목록조회월범위한국시간수정
|
|
||||||
|
|
||||||
## 구현 항목
|
|
||||||
- [x] `ChannelDonationService.kt`의 `getChannelDonationList` 내 조회 범위 수정
|
|
||||||
- UTC 현재 시각을 기준으로 한국 시간(KST) 월 경계를 계산
|
|
||||||
- KST 월 경계(해당월 1일 00:00:00 ~ 다음달 1일 00:00:00)를 UTC 조회 구간으로 변환
|
|
||||||
- [x] 채널 후원 조회 UTC 전달값 검증 테스트 보강
|
|
||||||
- `ChannelDonationServiceTest`에서 전달된 UTC 범위를 KST로 역변환했을 때 월 경계가 유지되는지 검증
|
|
||||||
|
|
||||||
## 검증 결과
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 기부 목록 조회 시 사용되는 시간 범위를 한국 시간 기준으로 변경
|
|
||||||
- 왜: 현재 UTC 기준으로 1일~말일이 설정되어 있어 한국 사용자의 기대와 다름
|
|
||||||
- 어떻게: `ZoneId.of("Asia/Seoul")`을 사용하여 현재 한국 시간을 구하고, 해당 월의 시작일 자정을 계산하도록 수정함.
|
|
||||||
```kotlin
|
|
||||||
val kstZoneId = ZoneId.of("Asia/Seoul")
|
|
||||||
val nowKst = ZonedDateTime.now(kstZoneId)
|
|
||||||
val startDateTime = nowKst
|
|
||||||
.with(TemporalAdjusters.firstDayOfMonth())
|
|
||||||
.toLocalDate()
|
|
||||||
.atStartOfDay()
|
|
||||||
val endDateTime = startDateTime.plusMonths(1)
|
|
||||||
```
|
|
||||||
- 결과: 기존 단위 테스트(`ChannelDonationServiceTest`) 4건 모두 통과 확인.
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest` 실행 결과 성공.
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: `getChannelDonationList`에서 월 조회 시작/종료 시각을 KST 기준으로 계산한 뒤 UTC `LocalDateTime`으로 변환해 repository에 전달하도록 수정
|
|
||||||
- 왜: KST 타임존만 적용하고 조회 파라미터를 UTC로 변환하지 않으면 조회 날짜가 기존과 동일하게 남아 월 경계가 의도대로 이동하지 않음
|
|
||||||
- 어떻게:
|
|
||||||
- `ChannelDonationService.kt`
|
|
||||||
- `ZonedDateTime.now(ZoneId.of("UTC"))`로 현재 시각을 얻고 `withZoneSameInstant(ZoneId.of("Asia/Seoul"))`로 KST 변환
|
|
||||||
- KST 월 시작/종료(`startDateTimeKst`, `endDateTimeKst`)를 각각 UTC로 변환해 `startDateTime`, `endDateTime` 생성
|
|
||||||
- `ChannelDonationServiceTest.kt`
|
|
||||||
- 캡처한 UTC 조회 파라미터를 KST로 역변환해 `1일 00:00:00` 및 `+1개월` 월 경계를 검증하도록 수정
|
|
||||||
- 정적 진단: `lsp_diagnostics` 실행 시 `.kt` 확장자 LSP 미구성으로 진단 불가(환경 제약)
|
|
||||||
- 검증 명령:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
|
|
||||||
- `./gradlew build` 실행: 성공
|
|
||||||
- `./gradlew tasks --all` 실행: 성공
|
|
||||||
- 결과: KST 월 경계가 UTC 조회 구간으로 반영되어 예시와 같은 형태(예: 2026-03-01 00:00:00 KST → 2026-02-28 15:00:00 UTC 시작)로 조회 조건이 구성됨
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
- [x] 관리자 차단 신규 API/DTO/서비스 파일 생성
|
|
||||||
- [x] 차단 처리 시 탈퇴 이유 저장 및 회원 비활성화 처리
|
|
||||||
- [x] 차단 처리 시 Redis 로그인 토큰 전체 삭제
|
|
||||||
- [x] 본인인증 회원 BlockAuth 기록 처리
|
|
||||||
- [x] 동일 본인인증 정보 계정 일괄 탈퇴 처리
|
|
||||||
- [x] 활성 계정 조회 조건을 `name + birth + di + uniqueCi`로 강화
|
|
||||||
- [x] 관리자 차단 서비스 테스트 추가
|
|
||||||
- [x] 정적 진단 및 테스트/빌드 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `kr.co.vividnext.sodalive.admin.member` 패키지에 신규 관리자 차단 API(`AdminMemberBlockController`), 요청 DTO(`AdminMemberBlockRequest`), 서비스(`AdminMemberBlockService`)를 추가했다. 서비스에서 탈퇴 이유 저장/회원 비활성화, Redis 로그인 토큰 전체 삭제, 본인인증 정보 `BlockAuth` 기록을 순서대로 처리하고, 서비스 단위 테스트(`AdminMemberBlockServiceTest`)를 추가했다.
|
|
||||||
- 왜: 관리자 페이지에서 사용자 차단 시 계정 비활성화 이력, 세션 무효화, 본인인증 기반 재가입 차단 정보를 한 번의 동작으로 일관되게 처리하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
|
||||||
- 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberBlockServiceTest` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: 관리자 차단 시 차단 대상 회원의 본인인증 정보(`di`)와 동일한 활성 계정을 모두 조회해 일괄 탈퇴 처리하도록 `AdminMemberBlockService`를 수정했다. 각 대상 계정마다 탈퇴 사유(`SignOut`) 저장, 회원 비활성화, Redis 로그인 토큰 전체 삭제를 수행하고, 기존 `BlockAuth` 저장 로직은 유지했다. 테스트도 동일 본인인증 다계정 탈퇴 시나리오를 포함하도록 확장했다.
|
|
||||||
- 왜: 본인인증 정보를 공유하는 다중 계정을 관리자 차단 시 함께 정리해야 우회 가입 계정이 활성 상태로 남지 않기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
|
||||||
- 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberBlockServiceTest` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
|
|
||||||
### 3차 수정
|
|
||||||
- 무엇을: 활성 계정 조회 조건을 `di` 단일 조건에서 `name + birth + di + uniqueCi` AND 조건으로 강화했다. 이를 위해 `AuthRepository`의 활성 계정 조회 메서드를 `getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(...)`로 변경하고, 호출부인 `AdminMemberBlockService`, `AuthService.authenticate`를 모두 신규 메서드로 교체했다. `AdminMemberBlockServiceTest`도 신규 시그니처 기준으로 스텁/검증을 수정했다.
|
|
||||||
- 왜: `di`만으로 동일인을 판단하면 과매칭 리스크가 있어, 본인인증 핵심 식별 속성을 함께 사용해 활성 계정 판별 정확도를 높이기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
|
||||||
- 테스트: `./gradlew test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# 관리자 정산 엑셀 다운로드 추가 작업 계획
|
|
||||||
|
|
||||||
- [x] 기존 정산 API 구조와 엑셀 다운로드 응답 패턴(`ResponseEntity<InputStreamResource>`)을 기준으로 구현 범위를 확정한다.
|
|
||||||
- [x] 라이브 정산 엑셀 다운로드 API(`GET /admin/calculate/live/excel`)를 추가한다.
|
|
||||||
- [x] 콘텐츠 정산 엑셀 다운로드 API(`GET /admin/calculate/content-list/excel`)를 추가한다.
|
|
||||||
- [x] 콘텐츠 후원 정산 엑셀 다운로드 API(`GET /admin/calculate/content-donation-list/excel`)를 추가한다.
|
|
||||||
- [x] 커뮤니티 정산 엑셀 다운로드 API(`GET /admin/calculate/community-post/excel`)를 추가한다.
|
|
||||||
- [x] 크리에이터별 라이브 정산 엑셀 다운로드 API(`GET /admin/calculate/live-by-creator/excel`)를 추가한다.
|
|
||||||
- [x] 크리에이터별 콘텐츠 정산 엑셀 다운로드 API(`GET /admin/calculate/content-by-creator/excel`)를 추가한다.
|
|
||||||
- [x] 크리에이터별 커뮤니티 정산 엑셀 다운로드 API(`GET /admin/calculate/community-by-creator/excel`)를 추가한다.
|
|
||||||
- [x] 채널후원 정산 엑셀 다운로드 API(`GET /admin/calculate/channel-donation-by-date/excel`)를 추가한다.
|
|
||||||
- [x] 각 엑셀 API가 시작/끝 날짜를 받아 전체 데이터를 내려주도록 서비스/리포지토리를 확장한다.
|
|
||||||
- [x] `lsp_diagnostics`, 테스트, 빌드로 변경사항을 검증하고 결과를 문서 하단에 기록한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 정산 API 8종(라이브/콘텐츠/콘텐츠후원/커뮤니티/크리에이터별 3종/채널후원 날짜별)에 `/excel` 다운로드 엔드포인트를 추가하고, 전체 데이터 엑셀 생성 서비스를 구현했다.
|
|
||||||
- 왜: 페이지네이션 기반 조회 API와 별도로 시작일/종료일 기준의 전체 정산 데이터를 한 번에 내려받을 수 있어야 한다는 요구사항을 충족하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `AdminCalculateController`에 7개 엔드포인트(`.../excel`)를 추가하고 공통 다운로드 헤더(`Content-Disposition`, xlsx content type)를 적용했다.
|
|
||||||
- `AdminCalculateService`에 7개 엑셀 생성 메서드를 추가해 기간 변환 후 전체 데이터 조회 및 `XSSFWorkbook` 기반 시트/헤더/행 작성을 구현했다.
|
|
||||||
- 페이지네이션 대상(커뮤니티 정산, 크리에이터별 정산 3종)은 `totalCount`를 조회해 `offset=0`, `limit=totalCount`로 전체 행을 조회하도록 처리했다.
|
|
||||||
- `AdminChannelDonationCalculateController`에 `GET /admin/calculate/channel-donation-by-date/excel`를 추가하고 기존 크리에이터별 엑셀 응답 로직과 동일한 규칙을 적용했다.
|
|
||||||
- `AdminChannelDonationCalculateService`에 날짜별 엑셀 다운로드 메서드를 추가해 전체 데이터 기준 시트를 생성했다.
|
|
||||||
- 테스트를 보강했다.
|
|
||||||
- `AdminChannelDonationCalculateControllerTest`: 날짜별 엑셀 다운로드 테스트 추가
|
|
||||||
- `AdminChannelDonationCalculateServiceTest`: 날짜별 엑셀 바이트 생성 테스트 추가
|
|
||||||
- 실행 결과:
|
|
||||||
- `lsp_diagnostics` (수정된 `.kt` 파일) → Kotlin LSP 미설정으로 진단 불가
|
|
||||||
- `./gradlew test --tests "*AdminChannelDonationCalculate*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 관리자 정산 콘텐츠 크리에이터별 조회 SQL 오류 수정 작업 계획
|
|
||||||
|
|
||||||
- [x] `/admin/calculate/content-by-creator` 호출 경로(Controller/Service/Repository)와 SQL 생성 지점을 확인한다.
|
|
||||||
- [x] `ONLY_FULL_GROUP_BY` 위반 원인(`content_settlement_ratio` 비집계 컬럼)을 제거하는 최소 수정안을 적용한다.
|
|
||||||
- [x] 수정된 쿼리가 기존 응답 스키마/정산 계산 로직과 호환되는지 코드 레벨로 검증한다.
|
|
||||||
- [x] `lsp_diagnostics`, 관련 테스트, 빌드를 실행해 정상 동작을 검증한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 수정
|
|
||||||
- 무엇을: `AdminCalculateQueryRepository#getCalculateContentByCreator`의 `groupBy`를 `member.id`에서 `member.id, creatorSettlementRatio.contentSettlementRatio`로 수정해 SELECT의 비집계 컬럼(`contentSettlementRatio`)이 GROUP BY에 포함되도록 변경했다.
|
|
||||||
- 왜: `/admin/calculate/content-by-creator` 조회 시 `creator_settlement_ratio.content_settlement_ratio`가 SELECT 절에 존재하지만 GROUP BY에 없어 MySQL `ONLY_FULL_GROUP_BY` 모드에서 SQLSyntaxErrorException이 발생했기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- 경로/원인 확인: `AdminCalculateController#getCalculateContentByCreator` -> `AdminCalculateService#getCalculateContentByCreator` -> `AdminCalculateQueryRepository#getCalculateContentByCreator` 호출 체인을 확인했다.
|
|
||||||
- 코드 수정: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`의 콘텐츠 크리에이터별 조회 쿼리 `groupBy`를 보완했다.
|
|
||||||
- 검증 실행 결과:
|
|
||||||
- `lsp_diagnostics` (`AdminCalculateQueryRepository.kt`) -> Kotlin LSP 미설정으로 진단 불가
|
|
||||||
- `./gradlew test` -> 성공
|
|
||||||
- `./gradlew build -x test` -> 성공
|
|
||||||
- `./gradlew tasks --all` -> 성공
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
- [x] 페이징 미적용 관리자 정산 API 식별
|
|
||||||
- [x] Controller에 Pageable 파라미터 추가 및 Service 호출에 offset/limit 전달
|
|
||||||
- [x] Service/Repository 쿼리에 offset/limit 반영
|
|
||||||
- [x] 정적 진단 및 테스트/빌드 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 정산 API 중 페이징이 없던 `/admin/calculate/live`, `/admin/calculate/content-list`, `/admin/calculate/content-donation-list`에 `Pageable` 기반 페이징을 추가하고, 응답을 `totalCount + items` 구조로 변경했다. 또한 동일 쿼리를 사용하는 엑셀 다운로드 로직이 기존과 동일하게 전체 데이터를 내려주도록 totalCount 기반 전체 조회 방식으로 맞췄다.
|
|
||||||
- 왜: 조회 건수가 많아질 수 있는 정산 목록 API에서 페이지 단위 조회를 지원해 응답 크기와 조회 성능을 안정적으로 관리하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 정적 진단: `lsp_diagnostics`로 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
|
||||||
- 테스트: `./gradlew test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# 관리자 충전 상태 상세 응답 필드 수정
|
|
||||||
|
|
||||||
- [x] `GetChargeStatusDetailResponse`에서 `memberId` 제거
|
|
||||||
- [x] `GetChargeStatusDetailResponse`에 `chargeId` 추가
|
|
||||||
- [x] 연관 매핑 코드 반영 및 빌드 검증
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 충전 상세 응답 DTO의 식별자를 `memberId`에서 `chargeId`로 변경하고, Query DTO/서비스 매핑/QueryDSL select 값을 동일하게 정합성 맞춰 수정했다.
|
|
||||||
- 왜: 충전 상세 응답에서 회원 식별자 대신 충전 건 식별자를 내려주도록 요구사항이 변경되었기 때문이다.
|
|
||||||
- 어떻게: `lsp_diagnostics`는 `.kt` 확장자 LSP 미설정으로 도구 검증이 불가해 사유를 확인했고, `./gradlew build`를 실행해 컴파일/테스트/체크를 통합 검증했으며 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# 관리자 충전 상세 캔 개수 추가
|
|
||||||
|
|
||||||
- [x] `GetChargeStatusDetailResponse`에 `chargeCan`, `rewardCan` 필드 추가
|
|
||||||
- [x] `AdminChargeStatusQueryRepository.getChargeStatusDetail` QueryProjection 인자에 캔 개수 매핑 추가
|
|
||||||
- [x] 관련 검증 수행 (`lsp_diagnostics`, `./gradlew test`, `./gradlew build`)
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 충전 상세 응답 DTO에 `chargeCan`, `rewardCan` 필드를 추가하고, 상세 조회 QueryProjection(`QGetChargeStatusDetailResponse`) 인자에 `charge.chargeCan`, `charge.rewardCan` 매핑을 추가했다.
|
|
||||||
- 왜: 충전 상세 응답에 유료 캔/보너스 캔 수량 정보를 함께 내려주기 위한 요구사항을 반영하기 위해서다.
|
|
||||||
- 어떻게: `lsp_diagnostics`로 수정 파일 진단을 시도했으나 `.kt` LSP 미설정으로 도구 검증이 불가함을 확인했고, 대신 `./gradlew test`와 `./gradlew build -x test`를 실행해 테스트/빌드 모두 `BUILD SUCCESSFUL`을 확인했다.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 관리자 충전 상세 QueryProjection 리팩토링
|
|
||||||
|
|
||||||
- [x] `AdminChargeStatusService.getChargeStatusDetail` 후처리 매핑 제거
|
|
||||||
- [x] `AdminChargeStatusQueryRepository.getChargeStatusDetail` 반환 타입을 응답 DTO QueryProjection으로 변경
|
|
||||||
- [x] 관련 DTO/QueryDSL 생성 타입 정합성 확인
|
|
||||||
- [x] 검증 수행 (`lsp_diagnostics`, `./gradlew test`, `./gradlew build`)
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `GetChargeStatusDetailResponse`에 `@QueryProjection`을 적용하고, `AdminChargeStatusQueryRepository`가 해당 DTO를 직접 select 하도록 변경했으며, 서비스의 후처리 `map`을 제거했다. 또한 불필요해진 `GetChargeStatusDetailQueryDto.kt` 파일을 삭제했다.
|
|
||||||
- 왜: 상세 응답 가공을 서비스에서 한 번 더 수행하지 않고 DB 조회 시점(QueryProjection)에서 완성된 응답 형태를 가져오도록 구조를 단순화하기 위해서다.
|
|
||||||
- 어떻게: `lsp_diagnostics`로 수정 파일 진단을 시도했으나 `.kt` LSP 미설정으로 도구 검증이 불가함을 확인했고, 대신 `./gradlew test`와 `./gradlew build -x test`를 실행해 테스트/빌드 성공(`BUILD SUCCESSFUL`)을 확인했다.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 관리자 정산 엑셀 스트리밍 전환 작업 계획
|
|
||||||
|
|
||||||
- [x] 기존 정산 엑셀 다운로드 API의 요청/응답 계약(엔드포인트, 쿼리 파라미터, 헤더)을 유지한다.
|
|
||||||
- [x] `AdminCalculateController`의 엑셀 응답 타입을 `StreamingResponseBody` 기반으로 전환한다.
|
|
||||||
- [x] `AdminCalculateService`의 엑셀 생성 방식을 `XSSFWorkbook + ByteArrayOutputStream`에서 `SXSSFWorkbook + 스트리밍 write`로 전환한다.
|
|
||||||
- [x] `AdminChannelDonationCalculateController`의 날짜별/크리에이터별 엑셀 응답을 `StreamingResponseBody` 기반으로 전환한다.
|
|
||||||
- [x] `AdminChannelDonationCalculateService`의 날짜별/크리에이터별 엑셀 생성을 `SXSSFWorkbook` 스트리밍 방식으로 전환한다.
|
|
||||||
- [x] 관련 테스트를 스트리밍 응답 기준으로 수정한다.
|
|
||||||
- [x] `lsp_diagnostics`, 테스트, 빌드를 실행하고 결과를 검증 기록에 남긴다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 정산 엑셀 다운로드 API 전체(라이브/콘텐츠/콘텐츠후원/커뮤니티/크리에이터별 3종/채널후원 날짜별/채널후원 크리에이터별)의 서버 내부 생성/전송 방식을 스트리밍으로 전환했다.
|
|
||||||
- 왜: 기존 `XSSFWorkbook + ByteArrayOutputStream + InputStreamResource` 방식은 전체 워크북과 바이트 배열을 메모리에 유지해 대용량 다운로드 시 피크 메모리 사용량이 커지기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- 컨트롤러 응답 타입을 `ResponseEntity<StreamingResponseBody>`로 변경하고, 기존 파일명 인코딩/`Content-Disposition`/xlsx MIME 타입은 유지했다.
|
|
||||||
- 서비스 반환 타입을 `StreamingResponseBody`로 변경하고 `SXSSFWorkbook(100)`로 row window 기반 생성 후 `outputStream`에 직접 `write`하도록 변경했다.
|
|
||||||
- 스트리밍 완료 시 `workbook.dispose()`와 `workbook.close()`를 호출해 임시 파일/리소스 해제를 보장했다.
|
|
||||||
- 채널후원 컨트롤러/서비스(날짜별, 크리에이터별)에도 동일 패턴을 적용했다.
|
|
||||||
- 테스트를 스트리밍 응답 기준으로 수정했다.
|
|
||||||
- 컨트롤러 테스트: `InputStreamResource` 검증 -> `StreamingResponseBody` 검증
|
|
||||||
- 서비스 테스트: `readAllBytes()` -> `StreamingResponseBody.writeTo(ByteArrayOutputStream)` 검증
|
|
||||||
- 실행 결과:
|
|
||||||
- `lsp_diagnostics` (수정된 `.kt` 파일) → Kotlin LSP 미설정으로 진단 불가
|
|
||||||
- `./gradlew test --tests "*AdminChannelDonationCalculate*"` → 성공
|
|
||||||
- `./gradlew build` → 성공
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
- [x] 기존 charge/payment/member 및 admin API 패턴 확인
|
|
||||||
- [x] `kr.co.vividnext.sodalive.admin.charge` 패키지에 캔 환불 API 생성
|
|
||||||
- [x] 환불 조건 검증 구현 (미사용, 7일 이내)
|
|
||||||
- [x] ChargeEntity/PaymentEntity/MemberEntity 환불 반영 로직 구현
|
|
||||||
- [x] 캔 환불 API 테스트 코드 작성
|
|
||||||
- [x] 검증 실행 및 결과 기록
|
|
||||||
|
|
||||||
## 환불 조건 상세
|
|
||||||
- 환불 가능 충전내역 조건: `charge.status == CHARGE` 그리고 `payment.status == COMPLETE`
|
|
||||||
- 이미 사용한 캔 판정 조건: `charge.title`에서 숫자를 추출해 현재 `chargeCan/rewardCan`과 비교
|
|
||||||
- 예시1) `100 캔 + 50 캔` -> `chargeCan = 100`, `rewardCan = 50`
|
|
||||||
- 예시2) `5,000 캔 + 500 캔` -> `chargeCan = 5000`, `rewardCan = 500`
|
|
||||||
- 예시3) `500캔` -> `chargeCan = 500`
|
|
||||||
- 예시4) `4,000 캔` -> `chargeCan = 4000`
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 관리자 캔 환불 API(`POST /admin/charge/refund`)와 환불 서비스/요청 DTO, i18n 메시지, 단위 테스트를 추가했다.
|
|
||||||
- 왜: 사용하지 않은 캔만 7일 이내 환불 가능하도록 하고, 환불 시 Charge/Payment/Member 상태를 요구사항대로 갱신하기 위해.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
|
|
||||||
- `./gradlew build` 실행 → 성공 (ktlint/check/test/build 포함)
|
|
||||||
- LSP 진단 시도(`lsp_diagnostics`) → Kotlin LSP 미설정으로 불가, 대신 Gradle 컴파일/ktlint/test/build로 검증
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: `AdminChargeRefundServiceTest`에 한글 `@DisplayName`을 추가하고, 각 테스트 문단에 given/when/then 역할 주석을 보강했다.
|
|
||||||
- 왜: 테스트 의도를 한눈에 파악하고, 문단별 책임을 명확히 하기 위해.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
|
|
||||||
- `./gradlew ktlintTestSourceSetCheck` 실행 → 성공
|
|
||||||
|
|
||||||
### 3차 수정
|
|
||||||
- 무엇을: 이미 사용한 캔 판정을 `charge.title` 숫자 파싱 비교 방식으로 변경하고, 단일 숫자/콤마 포함 제목 테스트 케이스를 추가했다.
|
|
||||||
- 왜: 환불 조건을 충전 제목 기반 비교 규칙(단일/복수 숫자, 콤마 포함)으로 명확하게 적용하기 위해.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundServiceTest"` 실행 → 성공
|
|
||||||
- `./gradlew build` 실행 → 성공
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
- [x] `getCalculateContentDonationList` 호출 경로(Controller → Service → QueryData) 확인
|
|
||||||
- [x] 유료/무료 콘텐츠 후원 정산 비율이 모두 70%로 적용되는지 검증
|
|
||||||
- [x] `GetCalculateContentDonationQueryData` 계산 로직의 불필요 분기/중복 제거 및 가독성 개선
|
|
||||||
- [x] 관련 테스트/빌드/정적 진단 실행 및 결과 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `GetCalculateContentDonationQueryData`에서 유료/무료 공통 정산 비율 70% 적용 상태를 확인하고, 정산 계산 상수(`KRW_PER_CAN`, `PAYMENT_FEE_RATE`, `SETTLEMENT_RATE`, `TAX_RATE`)를 `companion object`로 추출해 계산 로직을 정리했다.
|
|
||||||
- 왜: 유료/무료 분기 제거 후 동일 70% 정책을 명확히 유지하고, `BigDecimal` 상수 재사용으로 계산 의도와 유지보수성을 높이기 위해서다.
|
|
||||||
- 어떻게: 호출 경로(`AdminCalculateController` → `AdminCalculateService` → `AdminCalculateQueryRepository` → `GetCalculateContentDonationQueryData`)를 확인했고, 정적 진단은 `.kt` LSP 미구성으로 대체 검증했다. 실행 명령과 결과는 아래와 같다.
|
|
||||||
- `lsp_diagnostics` (`GetCalculateContentDonationQueryData.kt`): Kotlin LSP 미지원으로 실행 불가(환경 제약 확인)
|
|
||||||
- `./gradlew test`: 성공 (`BUILD SUCCESSFUL`)
|
|
||||||
- `./gradlew build`: 성공 (`BUILD SUCCESSFUL`, `ktlintMainSourceSetCheck` 포함)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
- [x] deep_link 파라미터 추가 여부를 푸시 발송 코드 기준으로 확인한다.
|
|
||||||
- [x] deep_link 값이 `voiceon://community/345` 형태인지 생성 규칙을 확인한다.
|
|
||||||
- [x] 검증 결과를 문서 하단에 기록한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 확인
|
|
||||||
- 무엇을: 푸시 발송 시 FCM payload에 `deep_link` 파라미터가 실제로 추가되는지와 커뮤니티 알림 형식이 `voiceon://community/{id}`인지 확인했다.
|
|
||||||
- 왜: 서버 구현이 문서 설명과 일치하는지, 그리고 앱이 기대하는 딥링크 문자열을 실제로 내려주는지 검증하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` 확인: `createDeepLink(deepLinkValue, deepLinkId)` 결과가 null이 아니면 `multicastMessage.putData("deep_link", deepLink)`로 payload에 추가됨.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` 확인: 생성 규칙은 `server.env == voiceon`일 때 `voiceon://{deepLinkValue.value}/{deepLinkId}`, 그 외 환경은 `voiceon-test://{deepLinkValue.value}/{deepLinkId}`임.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` 확인: 커뮤니티 새 글 알림은 `deepLinkValue = FcmDeepLinkValue.COMMUNITY`, `deepLinkId = member.id!!`를 전달하므로 운영 환경 기준 최종 값은 `voiceon://community/{creatorId}` 형식임.
|
|
||||||
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt` 확인: 커뮤니티 목록 조회 API가 `creatorId`를 받으므로 커뮤니티 딥링크의 식별자도 크리에이터 ID 기준과 일치함.
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
- 코드 수정은 하지 않음(확인 작업만 수행).
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
- [x] FCM 푸시 생성 경로에서 딥링크 파라미터 추가 위치 확정
|
|
||||||
- [x] `server.env` 기반 URI scheme(`voiceon://`, `voiceon-test://`) 분기 로직 구현
|
|
||||||
- [x] `deep_link_value` 매핑 규칙(`live`, `channel`, `content`, `series`, `audition`, `community`) 반영
|
|
||||||
- [x] FCM payload에 최종 딥링크 문자열(`{URISCHEME}://{deep_link_value}/{ID}`) 주입
|
|
||||||
- [x] 관련 테스트/검증 수행 후 결과 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: FCM 이벤트에 딥링크 메타(`deepLinkValue`, `deepLinkId`)를 추가하고, `FcmService`에서 `deep_link` payload(`{URISCHEME}://{deep_link_value}/{ID}`)를 생성하도록 구현했다.
|
|
||||||
- 왜: 푸시 수신 시 앱이 직접 딥링크로 진입하도록 서버에서 일관된 규칙으로 URL을 포함하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(초기 실패: import 정렬 ktlint 위반)
|
|
||||||
- `./gradlew ktlintFormat` 실행(성공)
|
|
||||||
- `./gradlew test && ./gradlew build` 재실행(성공)
|
|
||||||
- LSP 진단은 Kotlin LSP 미구성 환경으로 실행 불가(Gradle 컴파일/테스트/ktlint로 대체 검증)
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: 오디션 푸시의 `deepLinkId`를 `-1` 대체값이 아닌 실제 `audition.id` nullable 값으로 조정했다.
|
|
||||||
- 왜: ID가 null일 때 비정상 딥링크(`/audition/-1`)가 생성되는 가능성을 제거하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test && ./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 3차 수정
|
|
||||||
- 무엇을: `server.env` 값 해석 기준을 `voiceon`(프로덕션), `voiceon_test` 및 그 외(개발/기타)로 조정했다.
|
|
||||||
- 왜: 실제 운영 환경 변수 규칙과 딥링크 URI scheme 선택 조건을 일치시키기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test && ./gradlew build` 실행(성공)
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
- [x] 요구사항 확정: 푸시 발송 내용을 알림 리스트에 적재하고, 미수신 상황에서도 조회 가능하도록 범위를 고정한다.
|
|
||||||
- [x] 도메인 모델 설계: 알림 본문/발송자 스냅샷/카테고리/딥링크/언어코드/수신자 청크(JSON 배열) 저장 구조를 JPA 엔티티로 정의한다.
|
|
||||||
- [x] 푸시 적재 로직 구현: 수신자가 없으면 저장하지 않고, 언어별 데이터로 분리 저장하며 수신자 ID를 청크 단위(JSON 배열)로 기록한다.
|
|
||||||
- [x] 조회 기간 제한 구현: 알림 조회는 최근 1개월 데이터만 조회하도록 서비스/리포지토리에 공통 조건을 적용한다.
|
|
||||||
- [x] API 구현: 인증 사용자 기준 알림 목록 조회 API(전체/카테고리별)와 알림 존재 카테고리 조회 API를 구현한다.
|
|
||||||
- [x] 카테고리 다국어 응답 구현: 카테고리 조회 API 응답을 현재 기기 언어(ko/en/ja) 라벨로 반환한다.
|
|
||||||
- [x] 페이징 구현: Pageable 파라미터를 사용해 offset/limit 기반 조회를 적용한다.
|
|
||||||
- [x] 시간 포맷 구현: 발송시간을 UTC 기반 String으로 응답 DTO에 포함한다.
|
|
||||||
- [x] TDD 구현: 스프링 컨테이너 없이 실행 가능한 단위 테스트를 먼저 작성하고, 구현 후 테스트를 통과시킨다.
|
|
||||||
- [x] SQL 문서화: 신규 테이블 생성 SQL 및 추가 인덱스 SQL(MySQL, TIMESTAMP NOT NULL)을 문서 하단에 기록한다.
|
|
||||||
|
|
||||||
## API 상세 작업 계획
|
|
||||||
|
|
||||||
### 1) GET `/push/notification/list`
|
|
||||||
- 목적: 인증 사용자의 알림 리스트를 현재 기기 언어 기준으로 조회한다.
|
|
||||||
- 요청 파라미터:
|
|
||||||
- `page`, `size`, `sort` (Pageable)
|
|
||||||
- `category` (선택, 없으면 전체 조회)
|
|
||||||
- 처리 규칙:
|
|
||||||
- 인증 사용자(`Member?`) null이면 `SodaException(messageKey = "common.error.bad_credentials")`
|
|
||||||
- 현재 요청 언어(`LangContext.lang.code`)와 일치하는 알림만 조회
|
|
||||||
- 조회 범위는 `now(UTC) - 1개월` 이후 데이터만 허용
|
|
||||||
- `category` 미지정 시 전체 카테고리 조회
|
|
||||||
- `category`는 코드(`live`) 또는 다국어 라벨(`라이브`/`Live`/`ライブ`) 입력을 허용한다
|
|
||||||
- `category`가 `전체`/`All`/`すべて`이면 전체 카테고리 조회로 처리한다
|
|
||||||
- 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 알림만 조회
|
|
||||||
- 응답 항목:
|
|
||||||
- 발송자 스냅샷(닉네임, 프로필 이미지)
|
|
||||||
- 발송 메시지
|
|
||||||
- 카테고리
|
|
||||||
- 딥링크
|
|
||||||
- 발송시간(UTC String)
|
|
||||||
- 구현 작업:
|
|
||||||
- [x] Controller: 인증/파라미터/ApiResponse 처리
|
|
||||||
- [x] Service: 1개월/언어/카테고리/페이지 조건 조합
|
|
||||||
- [x] Repository: 수신자 청크 JSON membership + pageable 조회 + totalCount
|
|
||||||
- [x] DTO: `GetPushNotificationListResponse`, `PushNotificationListItem` 정의
|
|
||||||
|
|
||||||
### 2) GET `/push/notification/categories`
|
|
||||||
- 목적: 인증 사용자 기준으로 알림 데이터가 실제 존재하는 카테고리만 조회한다.
|
|
||||||
- 요청 파라미터: 없음
|
|
||||||
- 처리 규칙:
|
|
||||||
- 인증 필수
|
|
||||||
- 현재 요청 언어 기준 데이터만 대상
|
|
||||||
- 최근 1개월 데이터만 대상
|
|
||||||
- 수신자 청크(JSON 배열)에 인증 사용자 ID가 포함된 데이터만 대상
|
|
||||||
- 응답 항목:
|
|
||||||
- 카테고리 목록(현재 기기 언어 라벨)
|
|
||||||
- 구현 작업:
|
|
||||||
- [x] Controller: 인증/ApiResponse 처리
|
|
||||||
- [x] Service: 중복 제거된 카테고리 목록 반환
|
|
||||||
- [x] Repository: 사용자/언어/기간 기반 카테고리 distinct 조회
|
|
||||||
- [x] DTO: `GetPushNotificationCategoryResponse` 정의
|
|
||||||
|
|
||||||
## 비API 작업 계획
|
|
||||||
- [x] FCM 이벤트 모델 확장: 알림 리스트 적재에 필요한 카테고리/발송자 스냅샷 정보를 이벤트에 포함한다.
|
|
||||||
- [x] FCM 전송 리스너 연동: 언어별 푸시 전송 시점에 알림 리스트 저장 서비스를 호출한다.
|
|
||||||
- [x] 발송자 스냅샷 처리: 이벤트 스냅샷 우선 사용, 없으면 발송자 ID 기반 조회로 보완한다.
|
|
||||||
- [x] 딥링크 저장 처리: 현재 푸시 딥링크 규칙과 동일한 값으로 저장한다.
|
|
||||||
- [x] 수신자 청크 저장 처리: 수신자 ID를 고정 크기 청크로 분할해 JSON 배열로 저장한다.
|
|
||||||
- [x] 수신자 미존재 처리: 최종 수신자 ID가 비어 있으면 알림 자체를 저장하지 않는다.
|
|
||||||
|
|
||||||
## 테스트(TDD) 계획
|
|
||||||
- [x] 단위 테스트: 알림 저장 서비스가 수신자 없음/언어별 분리/청크 분할/스냅샷 저장을 정확히 처리하는지 검증한다.
|
|
||||||
- [x] 단위 테스트: 조회 서비스가 1개월 제한/언어 필터/카테고리 옵션/pageable 전달을 정확히 적용하는지 검증한다.
|
|
||||||
- [x] 단위 테스트: 카테고리 조회 서비스가 사용자/언어/기간 기준 distinct 결과를 반환하는지 검증한다.
|
|
||||||
- [x] 단위 테스트: 컨트롤러가 인증 실패 시 에러 응답을 반환하고, 정상 시 서비스 호출 파라미터를 올바르게 전달하는지 검증한다.
|
|
||||||
|
|
||||||
## SQL 초안 (구현 확정)
|
|
||||||
|
|
||||||
### 1) 신규 테이블 생성 SQL (MySQL)
|
|
||||||
```sql
|
|
||||||
CREATE TABLE push_notification_list
|
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
|
|
||||||
sender_nickname_snapshot VARCHAR(255) NOT NULL COMMENT '발송자 닉네임 스냅샷',
|
|
||||||
sender_profile_image_snapshot VARCHAR(500) NULL COMMENT '발송자 프로필 이미지 스냅샷',
|
|
||||||
message TEXT NOT NULL COMMENT '발송 메시지',
|
|
||||||
category VARCHAR(20) NOT NULL COMMENT '발송 카테고리',
|
|
||||||
deep_link VARCHAR(500) NULL COMMENT '딥링크',
|
|
||||||
language_code VARCHAR(8) NOT NULL COMMENT '언어 코드',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)'
|
|
||||||
) COMMENT ='푸시 알림 리스트';
|
|
||||||
|
|
||||||
CREATE TABLE push_notification_recipient_chunk
|
|
||||||
(
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK',
|
|
||||||
notification_id BIGINT NOT NULL COMMENT '알림 ID',
|
|
||||||
recipient_member_ids JSON NOT NULL COMMENT '수신자 회원 ID 청크(JSON 배열)',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각(UTC)',
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각(UTC)',
|
|
||||||
CONSTRAINT fk_push_notification_recipient_chunk_notification
|
|
||||||
FOREIGN KEY (notification_id) REFERENCES push_notification_list (id)
|
|
||||||
) COMMENT ='푸시 알림 수신자 청크';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2) 추가 인덱스 SQL (MySQL)
|
|
||||||
```sql
|
|
||||||
ALTER TABLE push_notification_list
|
|
||||||
ADD INDEX idx_push_notification_list_language_created (language_code, created_at, id),
|
|
||||||
ADD INDEX idx_push_notification_list_category_language_created (category, language_code, created_at, id);
|
|
||||||
|
|
||||||
ALTER TABLE push_notification_recipient_chunk
|
|
||||||
ADD INDEX idx_push_notification_recipient_chunk_notification (notification_id);
|
|
||||||
|
|
||||||
-- MySQL 8.0.17+ 환경에서 JSON 배열 membership 최적화가 필요할 때 사용
|
|
||||||
ALTER TABLE push_notification_recipient_chunk
|
|
||||||
ADD INDEX idx_push_notification_recipient_chunk_member_ids_mvi ((CAST(recipient_member_ids AS UNSIGNED ARRAY)));
|
|
||||||
```
|
|
||||||
|
|
||||||
#### MVI 조건부 적용 가이드 (짧게)
|
|
||||||
- MySQL 8.0.17+ 환경이면 인덱스를 먼저 추가해 둔다.
|
|
||||||
- 실제 사용 여부는 옵티마이저가 쿼리 조건과 비용을 보고 결정하므로 `EXPLAIN`으로 확인한다.
|
|
||||||
- 현재 조회 조건처럼 `JSON_CONTAINS(JSON컬럼, JSON_ARRAY(값), '$')` 형태일 때 사용 후보가 된다.
|
|
||||||
- 인덱스가 선택되지 않아도 기능 오동작은 없지만, 쓰기/저장공간 비용은 항상 발생한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 푸시 발송 시 언어별 메시지를 알림 리스트로 적재하는 `PushNotificationService`와 관련 JPA 엔티티/리포지토리/조회 API 2종(`/push/notification/list`, `/push/notification/categories`)을 추가하고, 기존 `FcmEvent` 발행 지점에 카테고리/발송자 스냅샷 소스를 연결했다.
|
|
||||||
- 왜: 푸시를 놓친 사용자도 최근 1개월 내 알림을 현재 기기 언어 기준으로 확인하고, 카테고리별 필터/카테고리 존재 여부를 조회할 수 있어야 하기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew test` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(초기 실패: ktlint import 정렬 위반)
|
|
||||||
- `./gradlew ktlintFormat` 실행(성공)
|
|
||||||
- `./gradlew test` 재실행(성공)
|
|
||||||
- `./gradlew build` 재실행(성공)
|
|
||||||
- Kotlin LSP 미구성으로 `lsp_diagnostics`는 실행 불가, Gradle test/build/ktlint로 대체 검증
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: `PushNotificationRecipientChunk`의 `chunkOrder` 필드를 제거하고, 저장 로직/문서 SQL(컬럼 및 인덱스)을 함께 정리했다.
|
|
||||||
- 왜: 저장 시점에만 값이 할당되고 조회/정렬/필터에서 실제 사용되지 않아 불필요한 데이터였기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 3차 수정
|
|
||||||
- 무엇을: 알림 리스트 조회를 `PushNotificationListRow -> service map` 구조에서 `PushNotificationListItem` 직접 프로젝션 구조로 변경하고, 조회/카운트 쿼리에서 `innerJoin + distinct/countDistinct`를 제거해 `EXISTS` 기반 JSON membership 필터로 최적화했다.
|
|
||||||
- 왜: 중간 변환 객체가 불필요하고, 조인 기반 중복 제거 비용(distinct/countDistinct)이 커질 수 있어 페이지 조회 성능을 개선하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 4차 수정
|
|
||||||
- 무엇을: `sentAt` 포맷을 DB `DATE_FORMAT` 문자열 생성 방식에서 `PushNotificationListItem` QueryProjection 생성자 기반 UTC Instant 문자열(`...Z`) 생성 방식으로 변경했다.
|
|
||||||
- 왜: `GetLatestFinishedLiveResponse.dateUtc`와 동일하게 애플리케이션 레이어에서 명시적 UTC 변환을 적용해 포맷/의미 일관성을 맞추기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 5차 수정
|
|
||||||
- 무엇을: `getAvailableCategories`가 카테고리 코드를 그대로 반환하던 동작을, 현재 기기 언어(`ko/en/ja`)에 맞는 카테고리 라벨을 반환하도록 변경했다.
|
|
||||||
- 왜: 카테고리 조회 응답을 조회 기기 언어에 따라 한글/영어/일본어로 내려주어야 하기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 6차 수정
|
|
||||||
- 무엇을: `getAvailableCategories` 응답 리스트 맨 앞에 `전체` 항목을 고정 추가하고, `ko/en/ja` 다국어 라벨로 반환하도록 변경했다.
|
|
||||||
- 왜: 카테고리 필터 UI에서 전체 조회 옵션이 항상 첫 번째로 필요하기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 7차 수정
|
|
||||||
- 무엇을: `getNotificationList`의 `category` 입력이 한글/영어/일본어 라벨(`라이브`/`Live`/`ライブ` 등)도 파싱되도록 확장하고, `전체`/`All`/`すべて` 입력은 전체 조회로 처리하도록 수정했다.
|
|
||||||
- 왜: 카테고리 조회 API가 다국어 라벨을 반환하므로, 목록 조회 API도 동일 라벨 입력을 처리할 수 있어야 하기 때문이다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew test --tests "*PushNotificationServiceTest"` 실행(성공)
|
|
||||||
- `./gradlew test --tests "*PushNotification*"` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 8차 수정
|
|
||||||
- 무엇을: 추가 인덱스 SQL 하단에 MVI 인덱스의 조건부 사용 가이드를 짧게 추가했다.
|
|
||||||
- 왜: 인덱스는 선반영 가능하지만 실제 사용은 쿼리/옵티마이저 조건에 따라 달라진다는 점을 문서에 명시하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `./gradlew tasks --all` 실행(성공)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 푸시 알림 조회 쿼리 오류 수정
|
|
||||||
|
|
||||||
- [x] `PushNotificationController` 연계 조회 API에서 발생한 DB 조회 오류 재현 경로와 실제 실패 쿼리 식별
|
|
||||||
- [x] `QuerySyntaxException` 원인인 JPQL/HQL 함수 사용 구문을 코드베이스 패턴에 맞게 수정
|
|
||||||
- [x] 수정 코드 정적 진단 및 테스트/빌드 검증 수행
|
|
||||||
- [x] 검증 결과를 문서 하단에 기록
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 수정
|
|
||||||
- 무엇을: `PushNotificationListRepository.recipientContainsMember`의 QueryDSL 템플릿을 `JSON_CONTAINS({0}, JSON_ARRAY({1}), '$')`에서 `function('JSON_CONTAINS', {0}, function('JSON_ARRAY', {1}), '$') = 1`로 수정했다.
|
|
||||||
- 왜: Hibernate JPQL/HQL 파서는 MySQL 함수명(`JSON_CONTAINS`, `JSON_ARRAY`) 직접 호출 구문을 인식하지 못해 `QuerySyntaxException`이 발생하므로, JPQL 표준 함수 호출 래퍼(`function`)로 감싸 파싱 가능하도록 변경이 필요했다.
|
|
||||||
- 어떻게:
|
|
||||||
- 검색: `grep`/AST/Explore/Librarian로 `PushNotificationController -> PushNotificationService -> PushNotificationListRepository` 호출 흐름과 문제 쿼리를 확인했다.
|
|
||||||
- 정적 진단: `lsp_diagnostics`로 Kotlin 파일 진단을 시도했으나 현재 환경에 `.kt` LSP 서버 미설정으로 실행 불가를 확인했다.
|
|
||||||
- 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.fcm.notification.PushNotificationServiceTest" --tests "kr.co.vividnext.sodalive.fcm.notification.PushNotificationControllerTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
- 빌드: `./gradlew build -x test` 실행 결과 `BUILD SUCCESSFUL`.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
- [x] getFollowingAllChannelList 오류 재현 경로와 원인 쿼리 위치를 확인한다.
|
|
||||||
- [x] only_full_group_by 호환 방식으로 조회 쿼리를 수정한다.
|
|
||||||
- [x] 관련 응답/페이징 동작이 유지되는지 확인한다.
|
|
||||||
- [x] 변경 파일 진단과 테스트/빌드를 수행한다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `getCreatorFollowingAllList` 쿼리의 `groupBy` 컬럼을 `member.id`, `member.nickname`, `member.profileImage`, `creatorFollowing.isNotify`로 확장하고, 회귀 방지를 위해 `LiveRecommendRepositoryTest.shouldReturnFollowingCreatorListWithNotifyFlag` 테스트를 추가했다.
|
|
||||||
- 왜: `only_full_group_by` 모드에서 SELECT에 포함된 비집계 컬럼(`creatorFollowing.isNotify`)이 GROUP BY에 없어 발생하는 SQL 오류를 제거하고, 팔로잉 목록 응답(`isNotify` 포함) 동작을 재검증하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- 명령: `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest.shouldReturnFollowingCreatorListWithNotifyFlag"` / 결과: 성공
|
|
||||||
- 명령: `./gradlew build` / 결과: 성공
|
|
||||||
- 명령: `lsp_diagnostics` / 결과: `.kt` 확장 LSP 미구성으로 실행 불가(대신 Gradle 컴파일/테스트 성공으로 검증)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
- [x] 요구사항/기존 패턴 확정: 크리에이터 커뮤니티 댓글 등록 시점에 푸시 발송 + 알림 리스트 저장 경로를 기존 FCM 이벤트 파이프라인으로 연결한다.
|
|
||||||
- QA: `CreatorCommunityService#createCommunityPostComment`, `FcmEvent`, `FcmSendListener`, `PushNotificationService` 흐름을 코드로 확인한다.
|
|
||||||
- [x] 딥링크 규칙 확정: 댓글 알림의 딥링크를 `voiceon://community/{creatorId}?postId={postId}`(테스트 환경은 `voiceon-test://community/{creatorId}?postId={postId}`)로 생성되도록 이벤트 메타를 설정한다.
|
|
||||||
- QA: `FcmService.buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)` 규칙과 `creatorId/postId` 매핑을 확인한다.
|
|
||||||
- [x] 댓글 등록 시 알림 이벤트 구현: 댓글 작성자가 크리에이터 본인이 아닌 경우에만 크리에이터 대상 `INDIVIDUAL` 이벤트를 발행한다.
|
|
||||||
- QA: 이벤트에 `category=COMMUNITY`, `deepLinkValue=COMMUNITY`, `deepLinkId=creatorId`, `deepLinkCommentPostId=postId`, `recipients=[creatorId]`가 포함되는지 확인한다.
|
|
||||||
- [x] 알림 문구 메시지 키 추가: 크리에이터 커뮤니티 댓글 알림용 다국어 키를 `SodaMessageSource`에 추가한다.
|
|
||||||
- QA: KO/EN/JA 값이 모두 존재하고 `messageKey`로 조회 가능해야 한다.
|
|
||||||
- [x] 검증 실행: 수정 파일 LSP 진단, 관련 테스트, 전체 빌드 실행 후 결과를 기록한다.
|
|
||||||
- QA: `./gradlew test`, `./gradlew build` 성공.
|
|
||||||
|
|
||||||
## 완료 기준 (Acceptance Criteria)
|
|
||||||
- [x] 댓글 등록 API 호출 후(작성자 != 크리에이터) `FcmEvent`가 발행되어 크리에이터에게 푸시 전송 대상이 생성된다.
|
|
||||||
- [x] 동일 이벤트로 저장되는 알림 리스트의 `deepLink` 값이 푸시 payload `deep_link`와 동일 규칙으로 생성된다.
|
|
||||||
- [x] 댓글 알림 딥링크는 커뮤니티 전체보기 진입 경로(`community/{creatorId}`)를 유지하면서 대상 게시글 식별자(`postId`)를 포함한다.
|
|
||||||
- [x] 기존 커뮤니티 새 글 알림 및 다른 도메인 푸시 딥링크 동작에 회귀 영향이 없다.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `CreatorCommunityService#createCommunityPostComment`에 댓글 등록 직후 크리에이터 대상 `FcmEventType.INDIVIDUAL` 이벤트 발행 로직을 추가했다. 이벤트에는 `category=COMMUNITY`, `messageKey=creator.community.fcm.new_comment`, `deepLinkValue=COMMUNITY`, `deepLinkId=creatorId`, `recipients=[creatorId]`를 설정했고, 크리에이터 본인 댓글은 알림을 발행하지 않도록 제외했다. 또한 `SodaMessageSource`에 `creator.community.fcm.new_comment` 다국어 메시지를 추가했다.
|
|
||||||
- 왜: 댓글 알림 수신자가 푸시 터치/알림 리스트 터치 시 동일 딥링크(`community/{creatorId}`)로 이동하도록, 기존 FCM 이벤트-알림 저장 공통 경로를 그대로 재사용하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` 실행 시도: Kotlin LSP 미구성으로 실행 불가(환경 한계)
|
|
||||||
- `./gradlew test --tests "*CreatorCommunityServiceTest"` 실행(성공)
|
|
||||||
- `./gradlew test` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
|
|
||||||
### 2차 수정
|
|
||||||
- 무엇을: 커뮤니티 댓글 알림 딥링크에 `postId`를 함께 전달하도록 `FcmEvent`에 `deepLinkCommentPostId`를 추가하고, `FcmService.buildDeepLink`에서 커뮤니티 딥링크일 때 `?postId={postId}`를 붙이도록 수정했다. 이에 맞춰 `CreatorCommunityService`에서 댓글 등록 이벤트 발행 시 `deepLinkCommentPostId = postId`를 설정했고, `PushNotificationService`도 동일 딥링크 문자열을 알림 리스트에 저장하도록 반영했다. 테스트는 `CreatorCommunityServiceTest`, `PushNotificationServiceTest`를 보강했다.
|
|
||||||
- 왜: 기존 `community/{creatorId}`만으로는 어떤 게시글의 댓글 리스트를 열어야 하는지 식별할 수 없어, 커뮤니티 전체보기 진입은 유지하면서 대상 게시글 식별자를 함께 전달하기 위해서다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` 실행 시도: Kotlin LSP 미구성으로 실행 불가(환경 한계)
|
|
||||||
- `./gradlew test --tests "*CreatorCommunityServiceTest" --tests "*PushNotificationServiceTest"` 실행(성공)
|
|
||||||
- `./gradlew test` 실행(성공)
|
|
||||||
- `./gradlew build` 실행(성공)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
- [x] 리뷰 결과 요약 및 수정 범위 확정
|
|
||||||
- [x] FcmEvent 저장 조건 제거 및 서비스 계층으로 정책 이동
|
|
||||||
- [x] PushNotificationService에서 SYSTEM 저장 제외 보장
|
|
||||||
- [x] category null 회귀 방지 테스트 추가
|
|
||||||
- [x] 검증 실행 (LSP, 테스트, 빌드)
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `SYSTEM` 카테고리 저장 제외 정책을 Listener에서 Service로 이동하고, `category = null` 회귀를 막는 테스트를 추가했다.
|
|
||||||
- 왜: 현재 Listener 조건은 `category != null`을 요구해 타입 기반 카테고리 보정(`resolveCategory`)을 우회할 수 있어, 비SYSTEM 이벤트의 저장 누락 위험이 있었다.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` 실행: Kotlin LSP 미설정으로 불가(환경상 `.kt` 진단 서버 없음).
|
|
||||||
- `./gradlew test --tests kr.co.vividnext.sodalive.fcm.notification.PushNotificationServiceTest` 실행: 성공.
|
|
||||||
- `./gradlew build` 실행: 성공.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
## 작업 개요
|
|
||||||
|
|
||||||
- [x] `PushNotificationService`의 1주 조회 시작 시각 계산 기준을 저장 시각(`BaseEntity.createdAt`)과 동일한 시스템 기본 타임존으로 통일한다.
|
|
||||||
- [x] `getNotificationList` 및 `getAvailableCategories`가 동일한 1주일 범위를 유지하는지 확인한다.
|
|
||||||
- [x] 관련 import/함수명을 정리해 코드 가독성과 의도를 명확히 한다.
|
|
||||||
- [x] 변경 파일 진단과 Gradle 검증(`test`, `build`)을 수행하고 결과를 기록한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
|
|
||||||
- 무엇을: `PushNotificationService`의 조회 기간 계산을 UTC 기준에서 시스템 기본 타임존 기준으로 변경.
|
|
||||||
- 왜: `createdAt` 저장 시각이 시스템 기본 타임존(`LocalDateTime.now()`)이므로 조회 기준만 UTC를 사용하면 서버 타임존이 UTC가 아닐 때 실제 조회 기간이 7일과 어긋날 수 있음.
|
|
||||||
- 어떻게:
|
|
||||||
- `lsp_diagnostics` 실행: `.kt` 확장자용 LSP 서버 미설정으로 도구 진단 불가(환경 제약 확인).
|
|
||||||
- `./gradlew test` 실행: 성공(BUILD SUCCESSFUL).
|
|
||||||
- `./gradlew build` 실행: 성공(BUILD SUCCESSFUL).
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# 20260316_라이브환불기능추가
|
|
||||||
|
|
||||||
## 구현 항목
|
|
||||||
- [x] `GetCalculateLiveQueryData`에 `roomId` 필드 추가 및 `toGetCalculateLiveResponse` 수정 (email 제거 예정)
|
|
||||||
- [x] `GetCalculateLiveResponse`에 `roomId` 필드 추가 (email 제거 예정)
|
|
||||||
- [x] 환불 요청 시 `roomId`, `canUsageStr` 필수 조건 확인 로직 추가
|
|
||||||
- [x] `LiveRoomService` 내 환불 처리 로직 구현 (1차 수정: `cancelLive`와 동일하게 예약자 대상)
|
|
||||||
- [x] 환불 요청 API 엔드포인트 구현 (또는 수정)
|
|
||||||
- [x] `GetCalculateLiveQueryData` 및 `GetCalculateLiveResponse`에서 `email` 필드 제거
|
|
||||||
- [x] `AdminCalculateQueryRepository` 및 `CreatorAdminCalculateQueryRepository`에서 `email` 조회 제거
|
|
||||||
- [x] 환불 대상을 '예약자'가 아닌 '해당 라이브 및 사용 조건에 맞는 모든 미환불 UseCan'으로 변경
|
|
||||||
- [x] `LiveRoomService`의 `refundLiveByAdmin` 로직을 `AdminCalculateService`로 이동 및 수정
|
|
||||||
- [x] 이미 환불 처리된 건은 환불하지 않도록 재검증
|
|
||||||
- [x] 사용 전/후/환불 후 캔 수 일치 여부 검증 테스트 추가
|
|
||||||
- [x] 테스트 코드에 DisplayName을 사용하여 한글 설명 추가
|
|
||||||
- [x] 환불 실패 케이스에 대한 테스트 추가
|
|
||||||
|
|
||||||
## 검증 결과
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 라이브 환불 기능 추가
|
|
||||||
- 왜: 관리자 정산 페이지 등에서 라이브별 환불 처리를 지원하기 위함
|
|
||||||
- 어떻게:
|
|
||||||
- [x] `GetCalculateLiveQueryData`, `GetCalculateLiveResponse` 수정 확인
|
|
||||||
- [x] 환불 요청 API 호출 및 `LiveRoomService.refundLiveByAdmin` 로직 실행 여부 확인
|
|
||||||
- [x] 테스트 코드(`AdminCalculateServiceTest`) 작성 및 실행 결과 확인 (성공)
|
|
||||||
|
|
||||||
### 2차 수정 (잘못된 처리 반영)
|
|
||||||
- 무엇을: 라이브 환불 로직 수정 및 필드 정리
|
|
||||||
- 왜: 환불은 예약자 기준이 아니며, 관리자 기능이므로 관리자 서비스에서 처리해야 함. 또한 개인정보 보호 등을 위해 불필요한 `email` 필드 제거.
|
|
||||||
- 어떻게:
|
|
||||||
- [x] `GetCalculateLiveQueryData`, `GetCalculateLiveResponse`에서 `email` 제거 확인
|
|
||||||
- [x] '모든 미환불 UseCan' 대상 환불 로직 검증 (테스트 코드 수정 및 실행)
|
|
||||||
- [x] `LiveRoomService`에서 해당 로직 제거 및 `AdminCalculateService`에서 직접 처리 확인
|
|
||||||
|
|
||||||
### 3차 수정 (캔 수 검증 테스트 추가)
|
|
||||||
- 무엇을: 환불 시 사용자의 캔 수 변화 검증 테스트 추가
|
|
||||||
- 왜: 환불 후 사용자의 캔 수가 사용 전과 동일한지 확인하여 정합성을 보장하기 위함
|
|
||||||
- 어떻게:
|
|
||||||
- [x] `AdminCalculateServiceTest`에 `shouldMaintainCanBalanceAfterRefund` 테스트 추가
|
|
||||||
- [x] 사용 전, 사용 후(시뮬레이션), 환불 후 캔 수를 비교하여 사용 전과 환불 후가 동일함을 검증
|
|
||||||
- [x] `./gradlew test` 실행 결과 성공 확인
|
|
||||||
|
|
||||||
### 4차 수정 (테스트 코드 가독성 개선)
|
|
||||||
- 무엇을: 테스트 코드에 `@DisplayName`을 사용하여 한글 설명 추가
|
|
||||||
- 왜: 테스트의 의도를 보다 명확하게 전달하기 위함
|
|
||||||
- 어떻게:
|
|
||||||
- [x] `AdminCalculateServiceTest.kt`의 모든 테스트 메서드에 `@DisplayName` 적용
|
|
||||||
- [x] `./gradlew test` 실행 시 한글 설명이 정상적으로 출력됨을 확인
|
|
||||||
|
|
||||||
### 5차 수정 (환불 실패 케이스 테스트 추가)
|
|
||||||
- 무엇을: 환불이 실패하는 예외 상황에 대한 테스트 케이스 추가
|
|
||||||
- 왜: 환불 요청 중 발생 가능한 예외 상황(잘못된 방 ID, 잘못된 구분 값, 파라미터 누락 등)을 사전에 검증하기 위함
|
|
||||||
- 어떻게:
|
|
||||||
- [x] `AdminCalculateServiceTest.kt`에 3개의 실패 테스트 추가
|
|
||||||
- `shouldFailWhenRoomNotFound`: 존재하지 않는 방 ID 요청 시 `live.room.not_found` 예외 검증
|
|
||||||
- `shouldFailWhenInvalidCanUsage`: 지원하지 않는 사용 구분 문자열 요청 시 예외 검증
|
|
||||||
- `shouldFailWhenRequiredParameterMissing`: 필수 파라미터 누락 시 `common.error.invalid_request` 예외 검증
|
|
||||||
- [x] `./gradlew test` 실행 결과 5개의 테스트 모두 성공 확인
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# 20260316_작업문서한글명변경.md
|
|
||||||
|
|
||||||
## 구현 항목
|
|
||||||
- [x] 이번 세션에서 생성된 영문 작업 문서 이름 변경
|
|
||||||
- [x] `docs/20260316_CanServiceGetCanUseStatusRefactoring.md` -> `docs/20260316_캔사용내역조회리팩토링.md`
|
|
||||||
- [x] `docs/20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md` -> `docs/20260316_캔사용내역타임존및널처리개선.md`
|
|
||||||
|
|
||||||
## 검증 결과
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 이번 세션에서 생성된 작업 문서 2개의 이름을 한글로 변경
|
|
||||||
- 왜: 작업 계획 문서의 파일명 형식([날짜]_구현할내용한글.md)을 준수하기 위해
|
|
||||||
- 어떻게: bash 명령어로 `mv` 실행
|
|
||||||
- `mv docs/20260316_CanServiceGetCanUseStatusRefactoring.md docs/20260316_캔사용내역조회리팩토링.md`
|
|
||||||
- `mv docs/20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md docs/20260316_캔사용내역타임존및널처리개선.md`
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 캐릭터 등록 JP 성별 일본어 변환
|
|
||||||
|
|
||||||
- [x] `AdminChatCharacterController.registerCharacter`의 외부 API 호출 경로 확인
|
|
||||||
- QA: `callExternalApi`에서 `region`/`gender` 바디 구성 위치 확인
|
|
||||||
- [x] `region == JP`일 때 `gender` 값을 일본어로 변환하는 로직 추가
|
|
||||||
- QA: `여성 -> 女性`, `남성 -> 男性`, `기타 -> その他` 매핑 확인
|
|
||||||
- [x] 등록 API 외부 호출 시에만 변환이 적용되도록 구현
|
|
||||||
- QA: DB 저장용 `request.gender`는 기존 값 유지 여부 확인
|
|
||||||
- [x] 정적 진단 및 테스트 수행
|
|
||||||
- QA: Kotlin LSP 미구성으로 `lsp_diagnostics` 불가 확인, `./gradlew test --tests "kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest"` 및 `./gradlew build -x test` 성공
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: `registerCharacter` 외부 API 호출 시 `region == JP` 조건에서만 `gender`를 일본어(`女性`/`男性`/`その他`)로 변환하도록 구현하고, 매핑 단위 테스트를 추가했다.
|
|
||||||
- 왜: JP 리전 요청에서 외부 API가 일본어 성별 값을 요구하므로 등록 API 요청 바디의 `gender` 값만 조건부 변환이 필요했다.
|
|
||||||
- 어떻게:
|
|
||||||
- 코드 확인: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt`에서 `callExternalApi` 바디 구성 지점 확인 후 `mapGenderForExternalApi` 헬퍼 추가
|
|
||||||
- 매핑 검증: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt`에서 JP 매핑(여성/남성/기타) 및 KR 유지 케이스 검증
|
|
||||||
- 정적 진단: `lsp_diagnostics` 실행 시 Kotlin LSP 미구성으로 불가(환경 제약)
|
|
||||||
- 실행 검증 1: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest"` → 성공
|
|
||||||
- 수동 확인: `build/test-results/test/TEST-kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest.xml`에서 `tests="4" failures="0" errors="0"` 확인
|
|
||||||
- 실행 검증 2: `./gradlew build -x test` → 성공
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# 20260316_캔사용내역조회DISTINCT오류수정.md
|
|
||||||
|
|
||||||
## 구현 목표
|
|
||||||
- `CanRepository.getCanUseStatus` 호출 시 발생하는 `java.sql.SQLException` (DISTINCT와 ORDER BY 충돌)을 해결한다.
|
|
||||||
|
|
||||||
## 작업 내용
|
|
||||||
- [x] `UseCanQueryDto.kt`에 `id: Long` 필드 추가
|
|
||||||
- [x] `CanRepository.kt`의 `getCanUseStatus` 쿼리 `select` 절에 `useCan.id` 추가
|
|
||||||
- [x] `CanServiceTest.kt`의 `UseCanQueryDto` 생성자 호출 로직에 `id` 추가
|
|
||||||
- [x] `./gradlew ktlintFormat` 실행 및 스타일 확인
|
|
||||||
- [x] `./gradlew test` 실행하여 검증
|
|
||||||
|
|
||||||
## 검증 결과
|
|
||||||
- 무엇을: 캔 사용 내역 조회 API
|
|
||||||
- 왜: `DISTINCT` 사용 시 `ORDER BY` 컬럼(`id`)이 `SELECT` 목록에 없어 발생하는 런타임 오류 해결
|
|
||||||
- 어떻게: `id`를 DTO에 포함시켜 `SELECT` 목록에 노출되도록 수정
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 20260316_CanServiceGetCanUseStatusRefactoring.md
|
|
||||||
|
|
||||||
## 작업 목표
|
|
||||||
- `CanService.getCanUseStatus` 함수의 비효율적인 필터링 및 데이터 로딩 로직 개선.
|
|
||||||
- Kotlin 레벨에서 수행하던 필터링을 DB 레벨(Querydsl)로 이동.
|
|
||||||
- Entity 전체를 조회하는 대신 필요한 필드만 조회(Query Projection)하도록 리팩토링.
|
|
||||||
|
|
||||||
## 작업 내용
|
|
||||||
- [x] `CanService.getCanUseStatus` 현재 기능 검증용 테스트 코드 작성.
|
|
||||||
- [x] `UseCanQueryDto` 생성 (QueryProjection용 DTO).
|
|
||||||
- [x] `CanRepository`에 Querydsl 기반의 고도화된 `getCanUseStatus` 추가 (또는 기존 메서드 수정).
|
|
||||||
- [x] `member.id` 필터링 (기존 유지).
|
|
||||||
- [x] `(can + rewardCan) > 0` 필터링.
|
|
||||||
- [x] `container`(`aos`, `ios`, `else`)별 `paymentGateway` 필터링 (Join 사용).
|
|
||||||
- [x] 필요한 연관 엔티티(`Member`, `Room`, `AudioContent` 등)의 필드만 선택적으로 조회.
|
|
||||||
- [x] `CanService.getCanUseStatus` 리팩토링.
|
|
||||||
- [x] 리포지토리에서 바로 DTO 또는 필요한 데이터만 받아오도록 수정.
|
|
||||||
- [x] Kotlin `filter` 제거.
|
|
||||||
- [x] Kotlin `map` 로직 단순화 또는 QueryProjection으로 흡수 가능한지 판단하여 처리.
|
|
||||||
- [x] 작성한 테스트 코드로 기능 검증.
|
|
||||||
- [x] 테스트 코드에 `@DisplayName` 추가 및 예외/엣지 케이스 테스트 보강.
|
|
||||||
- [x] 성능 및 쿼리 최적화 확인.
|
|
||||||
|
|
||||||
## 검증 결과
|
|
||||||
- **기능 검증**:
|
|
||||||
- `CanServiceTest.kt`를 작성하여 리팩토링 전후의 필터링 및 맵핑 로직이 동일하게 유지됨을 확인.
|
|
||||||
- `@DisplayName`을 추가하여 테스트 의도를 명확히 기술.
|
|
||||||
- 유효하지 않은 타임존 입력 시 `DateTimeException`이 발생하는 예외 케이스 추가.
|
|
||||||
- 데이터가 없을 때 빈 리스트 반환 및 각 `CanUsage`별 nullable 필드(닉네임, 제목 등)가 누락되었을 때의 기본 타이틀 처리 로직 검증.
|
|
||||||
- **성능 개선**:
|
|
||||||
- Kotlin 레벨의 필터링을 DB 레벨(Querydsl)로 이동하여 불필요한 데이터 조회를 줄이고 페이지네이션 정확도 향상.
|
|
||||||
- Entity 전체 조회 대신 필요한 12개 필드만 조회하는 `UseCanQueryDto` 사용 (Projection).
|
|
||||||
- `CHANNEL_DONATION` 시 별도의 Member 조회를 위해 발생하던 N+1 또는 추가 쿼리를 Join을 통해 1번의 쿼리로 최적화.
|
|
||||||
- **코드 품질**:
|
|
||||||
- `CanService`에서 더 이상 사용하지 않는 `memberRepository` 의존성 제거.
|
|
||||||
- 복잡한 맵핑 로직을 QueryProjection DTO 기반으로 깔끔하게 정리.
|
|
||||||
|
|
||||||
### 단계별 검증 내용
|
|
||||||
1. **1차 구현 및 단위 테스트**: `CanServiceTest`를 통해 `aos`, `ios` 컨테이너별 필터링 조건이 올바르게 DB 쿼리에 반영되고 결과가 맵핑되는지 검증 (성공).
|
|
||||||
2. **쿼리 최적화 확인**: `UseCanCalculate` 및 관련 엔티티들을 `leftJoin` 및 `innerJoin`을 통해 한 번의 쿼리로 가져오도록 구현됨을 코드 레벨에서 확인.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# 20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md
|
|
||||||
|
|
||||||
## 작업 개요
|
|
||||||
- `CanService.getCanUseStatus` 함수에서 유효하지 않은 타임존 입력 시 처리 방식 변경 (예외 발생 -> UTC 기본값 사용).
|
|
||||||
- 캔 사용 내역 타이틀에서 `null` 문자열이 노출되는 문제 해결 및 크리에이터 닉네임 활용 로직 강화.
|
|
||||||
|
|
||||||
## 구현 항목
|
|
||||||
- [x] `CanService.getCanUseStatus` 타임존 처리 로직 수정
|
|
||||||
- `ZoneId.of(timezone)` 호출 시 예외 발생 시 `UTC`를 기본값으로 사용하도록 변경.
|
|
||||||
- [x] `CanService.getCanUseStatus` 타이틀 생성 로직 수정
|
|
||||||
- `CanUsage.LIVE` 등에서 `roomTitle`이 null인 경우 `roomMemberNickname`을 출력하도록 변경.
|
|
||||||
- 기타 `null` 문자열이 노출될 수 있는 지점 확인 및 수정.
|
|
||||||
- [x] `CanServiceTest.kt` 수정
|
|
||||||
- 타임존 예외 테스트를 UTC 기본값 동작 검증 테스트로 변경.
|
|
||||||
- 타이틀 `null` 처리 로직 변경에 따른 검증 코드 업데이트.
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
### 1차 구현
|
|
||||||
- **무엇을**: 타임존 안전 처리 및 타이틀 null 방지 로직 구현
|
|
||||||
- **왜**: 사용자 경험 개선 및 데이터 무결성 표시
|
|
||||||
- **어떻게**:
|
|
||||||
- `CanService.kt`: `ZoneId.of(timezone)`에 try-catch 적용, `CanUsage.LIVE` 등에서 제목 null 시 닉네임 사용하도록 수정.
|
|
||||||
- `CanServiceTest.kt`: 타임존 UTC 폴백 테스트 및 타이틀 null 방지 테스트 케이스 업데이트.
|
|
||||||
- `./gradlew test` 실행 결과: 5개 테스트 모두 통과.
|
|
||||||
- `./gradlew ktlintCheck` 실행 결과: 성공.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
- [x] 크리에이터 커뮤니티 게시물 고정/고정해제 API 경로 및 요청 스펙을 정의하고 반영한다.
|
|
||||||
- [x] 게시물 엔티티에 고정 상태와 고정 시각(또는 순서) 정보를 저장할 수 있도록 반영한다.
|
|
||||||
- [x] 동일 크리에이터 기준 고정 게시물 최대 3개 제한 검증을 추가하고, 초과 시 예외를 발생시킨다.
|
|
||||||
- [x] 커뮤니티 게시물 목록 정렬을 고정 우선, 최근 고정 우선, 기존 최신순 우선순위로 반영한다.
|
|
||||||
- [x] 고정/해제 및 3개 초과 예외, 정렬 우선순위를 검증하는 테스트를 추가/수정한다.
|
|
||||||
- [x] 검증 결과(무엇/왜/어떻게)를 문서 하단에 기록한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1차 구현 검증 기록
|
|
||||||
|
|
||||||
- 무엇을: 크리에이터 커뮤니티 게시물 고정/해제 API, 최대 3개 제한 예외, 고정 우선 정렬 반영 여부를 검증했다.
|
|
||||||
- 왜: 요청된 기능 요구사항(고정 가능 개수 제한, 최근 고정 우선 노출, 고정 해제)을 코드/테스트 기준으로 충족하는지 확인하기 위해서다.
|
|
||||||
- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"`를 실행했고, 총 5개 테스트(신규 3개 포함)가 모두 성공했다.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# 라이브 방 후원 랭킹 기간 반영
|
|
||||||
|
|
||||||
- [x] `LiveRoomService.getRoomInfo`의 Top3 후원 랭킹 조회 로직 현황 확인
|
|
||||||
- [x] `CreatorDonationRankingService.getMemberDonationRanking`의 기간 처리 패턴 확인 및 적용 방식 결정
|
|
||||||
- [x] 크리에이터의 `DonationRankingPeriod` 선택값(`WEEKLY`/`CUMULATIVE`)을 반영해 Top3 `List<Long>` 조회 로직 수정
|
|
||||||
- [x] 정적 진단 및 테스트/빌드 검증 수행
|
|
||||||
- [x] 검증 결과 문서화
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 초기 계획 수립
|
|
||||||
- 왜: 작업 전 구현 범위와 검증 기준을 명확히 하기 위해
|
|
||||||
- 어떻게: 계획 문서 생성 완료
|
|
||||||
|
|
||||||
### 2차 구현
|
|
||||||
- 무엇을: 후원 랭킹 기간 처리 패턴 전수 탐색 및 `getRoomInfo` 구현 변경
|
|
||||||
- 왜: 기존 누적 고정 조회를 크리에이터 선택 기간(`DonationRankingPeriod`) 기준 조회로 변경하기 위해
|
|
||||||
- 어떻게: `grep`/`ast-grep`/백그라운드 `explore`/`librarian` 탐색 결과를 근거로 `LiveRoomService`에서 `CreatorDonationRankingService.getMemberDonationRanking(..., period = donationRankingPeriod)` 호출 후 `.map { it.userId }`로 `List<Long>` 유지
|
|
||||||
|
|
||||||
### 3차 검증
|
|
||||||
- 무엇을: 코드 스타일/컴파일/테스트/빌드 검증
|
|
||||||
- 왜: 변경이 기존 규칙과 빌드 체인에서 안전하게 동작하는지 확인하기 위해
|
|
||||||
- 어떻게: `lsp_diagnostics`는 Kotlin LSP 미구성으로 수행 불가 확인, `./gradlew test && ./gradlew build` 1차 실행 시 import 정렬 실패(`ktlintMainSourceSetCheck`), import 순서 수정 후 동일 명령 재실행하여 `BUILD SUCCESSFUL` 확인
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# 라이브 룸 채팅 얼림 상태 저장/조회 추가
|
|
||||||
|
|
||||||
## 체크리스트
|
|
||||||
- [x] 데이터 모델(LiveRoomInfo)에 `isChatFrozen` 필드(Boolean, 기본 false) 추가
|
|
||||||
- [x] 요청 DTO `SetChatFreezeRequest(roomId, isChatFrozen)` 추가
|
|
||||||
- [x] 서비스 `setChatFreeze` 구현(권한: 크리에이터만)
|
|
||||||
- [x] 컨트롤러 `PUT /live/room/info/set/chat-freeze` 엔드포인트 추가
|
|
||||||
- [x] `GetRoomInfoResponse`에 `isChatFrozen`(Boolean, 기본 false) 추가 및 조회 응답 포함
|
|
||||||
- [x] 단위 테스트는 불필요 판단으로 제거(수동 테스트 가이드로 대체)
|
|
||||||
- [x] `./gradlew build`로 컴파일 확인
|
|
||||||
- [x] `./gradlew ktlintCheck` 실행 및 포맷 확인
|
|
||||||
|
|
||||||
## 검증 기록
|
|
||||||
### 1차 구현
|
|
||||||
- 무엇을: 채팅 얼림 상태 저장/조회 기능 구현
|
|
||||||
- 왜: 라이브 룸 채팅 제어 기능 제공을 위해
|
|
||||||
- 어떻게:
|
|
||||||
- 빌드/테스트 명령 실행: `./gradlew clean build` 성공, `./gradlew ktlintCheck` 예정
|
|
||||||
- API 수동 점검 예정: `PUT /live/room/info/set/chat-freeze` 요청 본문 `{ "roomId": 1, "isChatFrozen": true }` → 200 OK, 이후 `GET /live/room/info/{id}` 응답에 `isChatFrozen: true` 포함 확인
|
|
||||||
|
|
||||||
### 수동 테스트 방법
|
|
||||||
- 사전조건: 방 생성 및 시작되어 Redis에 `LiveRoomInfo`가 존재해야 함
|
|
||||||
- 1) 채팅 얼림 설정
|
|
||||||
- 요청: `PUT /live/room/info/set/chat-freeze`
|
|
||||||
- 헤더: `Authorization: Bearer <creator_token>`
|
|
||||||
- 바디: `{ "roomId": <roomId>, "isChatFrozen": true }`
|
|
||||||
- 기대: 200 OK, 본문은 `ApiResponse.ok` 규격
|
|
||||||
- 2) 룸 정보 조회에서 반영 확인
|
|
||||||
- 요청: `GET /live/room/info/{roomId}`
|
|
||||||
- 기대: 응답 JSON 내 `isChatFrozen: true`
|
|
||||||
- 3) 해제 시나리오 재검증
|
|
||||||
- `isChatFrozen`을 false로 요청 후 조회 시 `false` 확인
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Gradle ?? JVM(daemon/worker) ?
|
|
||||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
|
||||||
|
|
||||||
# Kotlin ??? ?? ? (?? ???? ??)
|
|
||||||
kotlin.daemon.jvmargs=-Xmx2048m
|
|
||||||
|
|
||||||
# CI ???(?? ?? ??? ??? ?? ? ??)
|
|
||||||
org.gradle.workers.max=2
|
|
||||||
org.gradle.parallel=false
|
|
||||||
@@ -5,10 +5,8 @@ import kr.co.vividnext.sodalive.admin.audition.role.AdminAuditionRoleRepository
|
|||||||
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
|
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -46,7 +44,7 @@ class AdminAuditionService(
|
|||||||
fun updateAudition(image: MultipartFile?, requestString: String) {
|
fun updateAudition(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java)
|
val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java)
|
||||||
val audition = repository.findByIdOrNull(id = request.id)
|
val audition = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
if (request.title != null) {
|
if (request.title != null) {
|
||||||
audition.title = request.title
|
audition.title = request.title
|
||||||
@@ -65,7 +63,7 @@ class AdminAuditionService(
|
|||||||
(audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) &&
|
(audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) &&
|
||||||
request.status == AuditionStatus.NOT_STARTED
|
request.status == AuditionStatus.NOT_STARTED
|
||||||
) {
|
) {
|
||||||
throw SodaException(messageKey = "admin.audition.status_cannot_revert")
|
throw SodaException("모집전 상태로 변경할 수 없습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
audition.status = request.status
|
audition.status = request.status
|
||||||
@@ -93,14 +91,10 @@ class AdminAuditionService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.IN_PROGRESS_AUDITION,
|
type = FcmEventType.IN_PROGRESS_AUDITION,
|
||||||
category = PushNotificationCategory.AUDITION,
|
title = "새로운 오디션 등록!",
|
||||||
titleKey = "admin.audition.fcm.title.new",
|
message = "'${audition.title}'이 등록되었습니다. 지금 바로 오리지널 오디오 드라마 오디션에 지원해보세요!",
|
||||||
messageKey = "admin.audition.fcm.message.new",
|
|
||||||
args = listOf(audition.title),
|
|
||||||
isAuth = audition.isAdult,
|
isAuth = audition.isAdult,
|
||||||
auditionId = audition.id ?: -1,
|
auditionId = audition.id ?: -1
|
||||||
deepLinkValue = FcmDeepLinkValue.AUDITION,
|
|
||||||
deepLinkId = audition.id
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ data class CreateAuditionRequest(
|
|||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
if (title.isBlank()) {
|
if (title.isBlank()) {
|
||||||
throw SodaException(messageKey = "admin.audition.title_required")
|
throw SodaException("오디션 제목을 입력하세요")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (information.isBlank() || information.length < 10) {
|
if (information.isBlank() || information.length < 10) {
|
||||||
throw SodaException(messageKey = "admin.audition.information_min_length")
|
throw SodaException("오디션 정보는 최소 10글자 입니다")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class AdminAuditionApplicantService(private val repository: AdminAuditionApplica
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun deleteAuditionApplicant(id: Long) {
|
fun deleteAuditionApplicant(id: Long) {
|
||||||
val applicant = repository.findByIdOrNull(id)
|
val applicant = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
applicant.isActive = false
|
applicant.isActive = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class AdminAuditionRoleService(
|
|||||||
auditionScriptUrl = request.auditionScriptUrl
|
auditionScriptUrl = request.auditionScriptUrl
|
||||||
)
|
)
|
||||||
val audition = auditionRepository.findByIdOrNull(id = request.auditionId)
|
val audition = auditionRepository.findByIdOrNull(id = request.auditionId)
|
||||||
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
auditionRole.audition = audition
|
auditionRole.audition = audition
|
||||||
repository.save(auditionRole)
|
repository.save(auditionRole)
|
||||||
|
|
||||||
@@ -48,19 +48,15 @@ class AdminAuditionRoleService(
|
|||||||
fun updateAuditionRole(image: MultipartFile?, requestString: String) {
|
fun updateAuditionRole(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java)
|
val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java)
|
||||||
val auditionRole = repository.findByIdOrNull(id = request.id)
|
val auditionRole = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
if (!request.name.isNullOrBlank()) {
|
if (!request.name.isNullOrBlank()) {
|
||||||
if (request.name.length < 2) {
|
if (request.name.length < 2) throw SodaException("배역 이름은 최소 2글자 입니다")
|
||||||
throw SodaException(messageKey = "admin.audition.role.name_min_length")
|
|
||||||
}
|
|
||||||
auditionRole.name = request.name
|
auditionRole.name = request.name
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request.information.isNullOrBlank()) {
|
if (!request.information.isNullOrBlank()) {
|
||||||
if (request.information.length < 10) {
|
if (request.information.length < 10) throw SodaException("오디션 배역 정보는 최소 10글자 입니다")
|
||||||
throw SodaException(messageKey = "admin.audition.role.information_min_length")
|
|
||||||
}
|
|
||||||
auditionRole.information = request.information
|
auditionRole.information = request.information
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ data class CreateAuditionRoleRequest(
|
|||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
if (auditionId < 0) {
|
if (auditionId < 0) {
|
||||||
throw SodaException(messageKey = "admin.audition.role.audition_required")
|
throw SodaException("캐릭터가 등록될 오디션을 선택하세요")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name.isBlank() || name.length < 2) {
|
if (name.isBlank() || name.length < 2) {
|
||||||
throw SodaException(messageKey = "admin.audition.role.name_required")
|
throw SodaException("캐릭터명을 입력하세요")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) {
|
if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) {
|
||||||
throw SodaException(messageKey = "admin.audition.role.script_url_required")
|
throw SodaException("오디션 대본 URL을 입력하세요")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (information.isBlank() || information.length < 10) {
|
if (information.isBlank() || information.length < 10) {
|
||||||
throw SodaException(messageKey = "admin.audition.role.information_required")
|
throw SodaException("오디션 캐릭터 정보는 최소 10글자 입니다")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ data class UpdateAuditionRoleRequest(
|
|||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
if (id < 0) {
|
if (id < 0) {
|
||||||
throw SodaException(messageKey = "common.error.invalid_request")
|
throw SodaException("잘못된 요청입니다.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,72 +2,27 @@ package kr.co.vividnext.sodalive.admin.calculate
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.http.HttpHeaders
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.http.ResponseEntity
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
|
||||||
import java.net.URLEncoder
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@RequestMapping("/admin/calculate")
|
@RequestMapping("/admin/calculate")
|
||||||
class AdminCalculateController(private val service: AdminCalculateService) {
|
class AdminCalculateController(private val service: AdminCalculateService) {
|
||||||
@PostMapping("/live/refund")
|
|
||||||
fun refundLive(@RequestBody request: AdminLiveRefundRequest) = ApiResponse.ok(service.refundLive(request))
|
|
||||||
|
|
||||||
@GetMapping("/live")
|
@GetMapping("/live")
|
||||||
fun getCalculateLive(
|
fun getCalculateLive(
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String,
|
|
||||||
pageable: Pageable
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
service.getCalculateLive(
|
|
||||||
startDateStr,
|
|
||||||
endDateStr,
|
|
||||||
pageable.offset,
|
|
||||||
pageable.pageSize.toLong()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/live/excel")
|
|
||||||
fun downloadCalculateLiveExcel(
|
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@RequestParam endDateStr: String
|
@RequestParam endDateStr: String
|
||||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
) = ApiResponse.ok(service.getCalculateLive(startDateStr, endDateStr))
|
||||||
fileName = "live.xlsx",
|
|
||||||
response = service.downloadCalculateLiveExcel(startDateStr, endDateStr)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/content-list")
|
@GetMapping("/content-list")
|
||||||
fun getCalculateContentList(
|
fun getCalculateContentList(
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String,
|
|
||||||
pageable: Pageable
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
service.getCalculateContentList(
|
|
||||||
startDateStr,
|
|
||||||
endDateStr,
|
|
||||||
pageable.offset,
|
|
||||||
pageable.pageSize.toLong()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/content-list/excel")
|
|
||||||
fun downloadCalculateContentListExcel(
|
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@RequestParam endDateStr: String
|
@RequestParam endDateStr: String
|
||||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
) = ApiResponse.ok(service.getCalculateContentList(startDateStr, endDateStr))
|
||||||
fileName = "content-list.xlsx",
|
|
||||||
response = service.downloadCalculateContentListExcel(startDateStr, endDateStr)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/cumulative-sales-by-content")
|
@GetMapping("/cumulative-sales-by-content")
|
||||||
fun getCumulativeSalesByContent(pageable: Pageable) = ApiResponse.ok(
|
fun getCumulativeSalesByContent(pageable: Pageable) = ApiResponse.ok(
|
||||||
@@ -76,26 +31,9 @@ class AdminCalculateController(private val service: AdminCalculateService) {
|
|||||||
|
|
||||||
@GetMapping("/content-donation-list")
|
@GetMapping("/content-donation-list")
|
||||||
fun getCalculateContentDonationList(
|
fun getCalculateContentDonationList(
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String,
|
|
||||||
pageable: Pageable
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
service.getCalculateContentDonationList(
|
|
||||||
startDateStr,
|
|
||||||
endDateStr,
|
|
||||||
pageable.offset,
|
|
||||||
pageable.pageSize.toLong()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/content-donation-list/excel")
|
|
||||||
fun downloadCalculateContentDonationListExcel(
|
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@RequestParam endDateStr: String
|
@RequestParam endDateStr: String
|
||||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
) = ApiResponse.ok(service.getCalculateContentDonationList(startDateStr, endDateStr))
|
||||||
fileName = "content-donation-list.xlsx",
|
|
||||||
response = service.downloadCalculateContentDonationListExcel(startDateStr, endDateStr)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/community-post")
|
@GetMapping("/community-post")
|
||||||
fun getCalculateCommunityPost(
|
fun getCalculateCommunityPost(
|
||||||
@@ -111,15 +49,6 @@ class AdminCalculateController(private val service: AdminCalculateService) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@GetMapping("/community-post/excel")
|
|
||||||
fun downloadCalculateCommunityPostExcel(
|
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String
|
|
||||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
|
||||||
fileName = "community-post.xlsx",
|
|
||||||
response = service.downloadCalculateCommunityPostExcel(startDateStr, endDateStr)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/live-by-creator")
|
@GetMapping("/live-by-creator")
|
||||||
fun getCalculateLiveByCreator(
|
fun getCalculateLiveByCreator(
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@@ -134,15 +63,6 @@ class AdminCalculateController(private val service: AdminCalculateService) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@GetMapping("/live-by-creator/excel")
|
|
||||||
fun downloadCalculateLiveByCreatorExcel(
|
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String
|
|
||||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
|
||||||
fileName = "live-by-creator.xlsx",
|
|
||||||
response = service.downloadCalculateLiveByCreatorExcel(startDateStr, endDateStr)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/content-by-creator")
|
@GetMapping("/content-by-creator")
|
||||||
fun getCalculateContentByCreator(
|
fun getCalculateContentByCreator(
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@@ -157,15 +77,6 @@ class AdminCalculateController(private val service: AdminCalculateService) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@GetMapping("/content-by-creator/excel")
|
|
||||||
fun downloadCalculateContentByCreatorExcel(
|
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String
|
|
||||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
|
||||||
fileName = "content-by-creator.xlsx",
|
|
||||||
response = service.downloadCalculateContentByCreatorExcel(startDateStr, endDateStr)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/community-by-creator")
|
@GetMapping("/community-by-creator")
|
||||||
fun getCalculateCommunityByCreator(
|
fun getCalculateCommunityByCreator(
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@@ -179,28 +90,4 @@ class AdminCalculateController(private val service: AdminCalculateService) {
|
|||||||
pageable.pageSize.toLong()
|
pageable.pageSize.toLong()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@GetMapping("/community-by-creator/excel")
|
|
||||||
fun downloadCalculateCommunityByCreatorExcel(
|
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String
|
|
||||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
|
||||||
fileName = "community-by-creator.xlsx",
|
|
||||||
response = service.downloadCalculateCommunityByCreatorExcel(startDateStr, endDateStr)
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun createExcelResponse(
|
|
||||||
fileName: String,
|
|
||||||
response: StreamingResponseBody
|
|
||||||
): ResponseEntity<StreamingResponseBody> {
|
|
||||||
val encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()).replace("+", "%20")
|
|
||||||
val headers = HttpHeaders().apply {
|
|
||||||
add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName")
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
|
||||||
.headers(headers)
|
|
||||||
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
|
||||||
.body(response)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
import com.querydsl.core.types.dsl.CaseBuilder
|
|
||||||
import com.querydsl.core.types.dsl.DateTimePath
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.core.types.dsl.StringTemplate
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
@@ -18,42 +17,16 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||||
fun getCalculateLiveTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
fun getCalculateLive(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateLiveQueryData> {
|
||||||
return queryFactory
|
|
||||||
.select(liveRoom.id)
|
|
||||||
.from(useCan)
|
|
||||||
.innerJoin(useCan.room, liveRoom)
|
|
||||||
.innerJoin(liveRoom.member, member)
|
|
||||||
.leftJoin(creatorSettlementRatio)
|
|
||||||
.on(
|
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
useCan.isRefund.isFalse
|
|
||||||
.and(useCan.createdAt.goe(startDate))
|
|
||||||
.and(useCan.createdAt.loe(endDate))
|
|
||||||
)
|
|
||||||
.groupBy(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
|
|
||||||
.fetch()
|
|
||||||
.size
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCalculateLive(
|
|
||||||
startDate: LocalDateTime,
|
|
||||||
endDate: LocalDateTime,
|
|
||||||
offset: Long,
|
|
||||||
limit: Long
|
|
||||||
): List<GetCalculateLiveQueryData> {
|
|
||||||
val formattedDate = getFormattedDate(liveRoom.beginDateTime)
|
val formattedDate = getFormattedDate(liveRoom.beginDateTime)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCalculateLiveQueryData(
|
QGetCalculateLiveQueryData(
|
||||||
|
member.email,
|
||||||
member.nickname,
|
member.nickname,
|
||||||
formattedDate,
|
formattedDate,
|
||||||
liveRoom.title,
|
liveRoom.title,
|
||||||
liveRoom.id,
|
|
||||||
liveRoom.price,
|
liveRoom.price,
|
||||||
useCan.canUsage,
|
useCan.canUsage,
|
||||||
useCan.id.count(),
|
useCan.id.count(),
|
||||||
@@ -65,10 +38,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -76,55 +46,11 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
)
|
)
|
||||||
.groupBy(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
|
.groupBy(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
|
||||||
.orderBy(member.nickname.desc(), liveRoom.id.desc(), useCan.canUsage.desc(), formattedDate.desc())
|
.orderBy(member.nickname.desc(), liveRoom.id.desc(), useCan.canUsage.desc(), formattedDate.desc())
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCalculateContentListTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> {
|
||||||
val orderFormattedDate = getFormattedDate(order.createdAt)
|
val orderFormattedDate = getFormattedDate(order.createdAt)
|
||||||
val pointGroup = CaseBuilder()
|
|
||||||
.`when`(order.point.loe(0)).then(0)
|
|
||||||
.otherwise(1)
|
|
||||||
|
|
||||||
return queryFactory
|
|
||||||
.select(audioContent.id)
|
|
||||||
.from(order)
|
|
||||||
.innerJoin(order.audioContent, audioContent)
|
|
||||||
.innerJoin(audioContent.member, member)
|
|
||||||
.leftJoin(creatorSettlementRatio)
|
|
||||||
.on(
|
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
order.createdAt.goe(startDate)
|
|
||||||
.and(order.createdAt.loe(endDate))
|
|
||||||
.and(order.isActive.isTrue)
|
|
||||||
)
|
|
||||||
.groupBy(
|
|
||||||
audioContent.id,
|
|
||||||
order.type,
|
|
||||||
orderFormattedDate,
|
|
||||||
order.can,
|
|
||||||
pointGroup,
|
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
|
||||||
)
|
|
||||||
.fetch()
|
|
||||||
.size
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCalculateContentList(
|
|
||||||
startDate: LocalDateTime,
|
|
||||||
endDate: LocalDateTime,
|
|
||||||
offset: Long,
|
|
||||||
limit: Long
|
|
||||||
): List<GetCalculateContentQueryData> {
|
|
||||||
val orderFormattedDate = getFormattedDate(order.createdAt)
|
|
||||||
val pointGroup = CaseBuilder()
|
|
||||||
.`when`(order.point.loe(0)).then(0)
|
|
||||||
.otherwise(1)
|
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCalculateContentQueryData(
|
QGetCalculateContentQueryData(
|
||||||
@@ -136,7 +62,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
order.can,
|
order.can,
|
||||||
order.id.count(),
|
order.id.count(),
|
||||||
order.can.sum(),
|
order.can.sum(),
|
||||||
order.point.sum(),
|
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -144,10 +69,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -158,12 +80,9 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
order.type,
|
order.type,
|
||||||
orderFormattedDate,
|
orderFormattedDate,
|
||||||
order.can,
|
order.can,
|
||||||
pointGroup,
|
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
)
|
)
|
||||||
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,10 +113,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getCumulativeSalesByContent(offset: Long, limit: Long): List<GetCumulativeSalesByContentQueryData> {
|
fun getCumulativeSalesByContent(offset: Long, limit: Long): List<GetCumulativeSalesByContentQueryData> {
|
||||||
val pointGroup = CaseBuilder()
|
|
||||||
.`when`(order.point.loe(0)).then(0)
|
|
||||||
.otherwise(1)
|
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCumulativeSalesByContentQueryData(
|
QGetCumulativeSalesByContentQueryData(
|
||||||
@@ -208,7 +123,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
order.can,
|
order.can,
|
||||||
order.id.count(),
|
order.id.count(),
|
||||||
order.can.sum(),
|
order.can.sum(),
|
||||||
order.point.sum(),
|
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -216,52 +130,20 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(order.isActive.isTrue)
|
.where(order.isActive.isTrue)
|
||||||
.groupBy(
|
.groupBy(member.id, audioContent.id, order.type, order.can)
|
||||||
member.id,
|
|
||||||
audioContent.id,
|
|
||||||
order.type,
|
|
||||||
order.can,
|
|
||||||
pointGroup,
|
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
|
||||||
)
|
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.orderBy(member.id.desc(), audioContent.id.desc())
|
.orderBy(member.id.desc(), audioContent.id.desc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCalculateContentDonationListTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
|
||||||
val donationFormattedDate = getFormattedDate(useCan.createdAt)
|
|
||||||
|
|
||||||
return queryFactory
|
|
||||||
.select(audioContent.id)
|
|
||||||
.from(useCan)
|
|
||||||
.innerJoin(useCan.audioContent, audioContent)
|
|
||||||
.innerJoin(audioContent.member, member)
|
|
||||||
.where(
|
|
||||||
useCan.isRefund.isFalse
|
|
||||||
.and(useCan.canUsage.eq(CanUsage.DONATION))
|
|
||||||
.and(useCan.createdAt.goe(startDate))
|
|
||||||
.and(useCan.createdAt.loe(endDate))
|
|
||||||
)
|
|
||||||
.groupBy(donationFormattedDate, audioContent.id)
|
|
||||||
.fetch()
|
|
||||||
.size
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCalculateContentDonationList(
|
fun getCalculateContentDonationList(
|
||||||
startDate: LocalDateTime,
|
startDate: LocalDateTime,
|
||||||
endDate: LocalDateTime,
|
endDate: LocalDateTime
|
||||||
offset: Long,
|
|
||||||
limit: Long
|
|
||||||
): List<GetCalculateContentDonationQueryData> {
|
): List<GetCalculateContentDonationQueryData> {
|
||||||
val donationFormattedDate = getFormattedDate(useCan.createdAt)
|
val donationFormattedDate = getFormattedDate(useCan.createdAt)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCalculateContentDonationQueryData(
|
QGetCalculateContentDonationQueryData(
|
||||||
@@ -285,8 +167,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
)
|
)
|
||||||
.groupBy(donationFormattedDate, audioContent.id)
|
.groupBy(donationFormattedDate, audioContent.id)
|
||||||
.orderBy(member.id.asc(), donationFormattedDate.desc(), audioContent.id.desc())
|
.orderBy(member.id.asc(), donationFormattedDate.desc(), audioContent.id.desc())
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,10 +211,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -355,10 +232,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -388,10 +262,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -411,10 +282,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -444,16 +312,13 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
.and(order.isActive.isTrue)
|
.and(order.isActive.isTrue)
|
||||||
)
|
)
|
||||||
.groupBy(member.id, creatorSettlementRatio.contentSettlementRatio)
|
.groupBy(member.id)
|
||||||
.orderBy(member.id.desc())
|
.orderBy(member.id.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -467,10 +332,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -501,10 +363,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
|
|||||||
@@ -1,134 +1,39 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.can.CanRepository
|
|
||||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
|
||||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
|
||||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
|
||||||
import kr.co.vividnext.sodalive.can.payment.Payment
|
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
|
||||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse
|
import kr.co.vividnext.sodalive.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse
|
||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
|
|
||||||
import org.apache.poi.ss.usermodel.Sheet
|
|
||||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook
|
|
||||||
import org.springframework.cache.annotation.Cacheable
|
import org.springframework.cache.annotation.Cacheable
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AdminCalculateService(
|
class AdminCalculateService(private val repository: AdminCalculateQueryRepository) {
|
||||||
private val repository: AdminCalculateQueryRepository,
|
|
||||||
private val canRepository: CanRepository,
|
|
||||||
private val useCanCalculateRepository: UseCanCalculateRepository,
|
|
||||||
private val chargeRepository: ChargeRepository,
|
|
||||||
private val liveRoomRepository: LiveRoomRepository,
|
|
||||||
private val messageSource: SodaMessageSource,
|
|
||||||
private val langContext: LangContext
|
|
||||||
) {
|
|
||||||
private fun formatMessage(key: String, vararg args: Any): String {
|
|
||||||
val template = messageSource.getMessage(key, langContext.lang).orEmpty()
|
|
||||||
return if (args.isNotEmpty()) {
|
|
||||||
String.format(template, *args)
|
|
||||||
} else {
|
|
||||||
template
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun refundLive(request: AdminLiveRefundRequest) {
|
|
||||||
if (request.roomId == null || request.canUsageStr.isNullOrBlank()) {
|
|
||||||
throw SodaException(messageKey = "common.error.invalid_request")
|
|
||||||
}
|
|
||||||
|
|
||||||
val room = liveRoomRepository.findByIdOrNull(request.roomId)
|
|
||||||
?: throw SodaException(messageKey = "live.room.not_found")
|
|
||||||
|
|
||||||
val canUsage = when (request.canUsageStr) {
|
|
||||||
"유료" -> CanUsage.LIVE
|
|
||||||
"룰렛" -> CanUsage.SPIN_ROULETTE
|
|
||||||
"하트" -> CanUsage.HEART
|
|
||||||
"후원" -> CanUsage.DONATION
|
|
||||||
else -> throw SodaException(message = "Invalid canUsageStr: ${request.canUsageStr}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val useCanList = canRepository.findAllByRoomIdAndCanUsageAndIsRefundFalse(
|
|
||||||
roomId = room.id!!,
|
|
||||||
canUsage = canUsage
|
|
||||||
)
|
|
||||||
|
|
||||||
for (useCan in useCanList) {
|
|
||||||
useCan.isRefund = true
|
|
||||||
val member = useCan.member!!
|
|
||||||
|
|
||||||
val useCanCalculate = useCanCalculateRepository.findByUseCanIdAndStatus(useCanId = useCan.id!!)
|
|
||||||
useCanCalculate.forEach {
|
|
||||||
it.status = UseCanCalculateStatus.REFUND
|
|
||||||
|
|
||||||
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
|
|
||||||
charge.title = formatMessage("live.room.can_title", it.can)
|
|
||||||
charge.useCan = useCan
|
|
||||||
|
|
||||||
when (it.paymentGateway) {
|
|
||||||
PaymentGateway.GOOGLE_IAP -> member.googleRewardCan += charge.rewardCan
|
|
||||||
PaymentGateway.APPLE_IAP -> member.appleRewardCan += charge.rewardCan
|
|
||||||
else -> member.pgRewardCan += charge.rewardCan
|
|
||||||
}
|
|
||||||
charge.member = member
|
|
||||||
|
|
||||||
val payment = Payment(
|
|
||||||
status = PaymentStatus.COMPLETE,
|
|
||||||
paymentGateway = it.paymentGateway
|
|
||||||
)
|
|
||||||
payment.method = formatMessage("live.room.refund_method")
|
|
||||||
charge.payment = payment
|
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getCalculateLive(
|
@Cacheable(
|
||||||
startDateStr: String,
|
cacheNames = ["cache_ttl_3_hours"],
|
||||||
endDateStr: String,
|
key = "'calculateLive:' + " + "#startDateStr + ':' + #endDateStr"
|
||||||
offset: Long,
|
)
|
||||||
limit: Long
|
fun getCalculateLive(startDateStr: String, endDateStr: String): List<GetCalculateLiveResponse> {
|
||||||
): GetCalculateLiveListResponse {
|
|
||||||
val startDate = startDateStr.convertLocalDateTime()
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
val totalCount = repository.getCalculateLiveTotalCount(startDate, endDate)
|
|
||||||
val items = repository
|
return repository
|
||||||
.getCalculateLive(startDate, endDate, offset, limit)
|
.getCalculateLive(startDate, endDate)
|
||||||
.map { it.toGetCalculateLiveResponse() }
|
.map { it.toGetCalculateLiveResponse() }
|
||||||
|
|
||||||
return GetCalculateLiveListResponse(totalCount, items)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getCalculateContentList(
|
@Cacheable(
|
||||||
startDateStr: String,
|
cacheNames = ["cache_ttl_3_hours"],
|
||||||
endDateStr: String,
|
key = "'calculateContent:' + " + "#startDateStr + ':' + #endDateStr"
|
||||||
offset: Long,
|
)
|
||||||
limit: Long
|
fun getCalculateContentList(startDateStr: String, endDateStr: String): List<GetCalculateContentResponse> {
|
||||||
): GetCalculateContentListResponse {
|
|
||||||
val startDate = startDateStr.convertLocalDateTime()
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
val totalCount = repository.getCalculateContentListTotalCount(startDate, endDate)
|
|
||||||
val items = repository
|
|
||||||
.getCalculateContentList(startDate, endDate, offset, limit)
|
|
||||||
.map { it.toGetCalculateContentResponse() }
|
|
||||||
|
|
||||||
return GetCalculateContentListResponse(totalCount, items)
|
return repository
|
||||||
|
.getCalculateContentList(startDate, endDate)
|
||||||
|
.map { it.toGetCalculateContentResponse() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -146,23 +51,27 @@ class AdminCalculateService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(
|
||||||
|
cacheNames = ["cache_ttl_3_hours"],
|
||||||
|
key = "'calculateContentDonationList2:' + " + "#startDateStr + ':' + #endDateStr"
|
||||||
|
)
|
||||||
fun getCalculateContentDonationList(
|
fun getCalculateContentDonationList(
|
||||||
startDateStr: String,
|
startDateStr: String,
|
||||||
endDateStr: String,
|
endDateStr: String
|
||||||
offset: Long,
|
): List<GetCalculateContentDonationResponse> {
|
||||||
limit: Long
|
|
||||||
): GetCalculateContentDonationListResponse {
|
|
||||||
val startDate = startDateStr.convertLocalDateTime()
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
val totalCount = repository.getCalculateContentDonationListTotalCount(startDate, endDate)
|
|
||||||
val items = repository
|
|
||||||
.getCalculateContentDonationList(startDate, endDate, offset, limit)
|
|
||||||
.map { it.toGetCalculateContentDonationResponse() }
|
|
||||||
|
|
||||||
return GetCalculateContentDonationListResponse(totalCount, items)
|
return repository
|
||||||
|
.getCalculateContentDonationList(startDate, endDate)
|
||||||
|
.map { it.toGetCalculateContentDonationResponse() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(
|
||||||
|
cacheNames = ["cache_ttl_3_hours"],
|
||||||
|
key = "'calculateCommunityPost:' + " + "#startDateStr + ':' + #endDateStr + ':' + #offset"
|
||||||
|
)
|
||||||
fun getCalculateCommunityPost(
|
fun getCalculateCommunityPost(
|
||||||
startDateStr: String,
|
startDateStr: String,
|
||||||
endDateStr: String,
|
endDateStr: String,
|
||||||
@@ -180,7 +89,6 @@ class AdminCalculateService(
|
|||||||
return GetCreatorCalculateCommunityPostResponse(totalCount, items)
|
return GetCreatorCalculateCommunityPostResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getCalculateLiveByCreator(
|
fun getCalculateLiveByCreator(
|
||||||
startDateStr: String,
|
startDateStr: String,
|
||||||
endDateStr: String,
|
endDateStr: String,
|
||||||
@@ -198,7 +106,6 @@ class AdminCalculateService(
|
|||||||
GetCalculateByCreatorResponse(totalCount, items)
|
GetCalculateByCreatorResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getCalculateContentByCreator(
|
fun getCalculateContentByCreator(
|
||||||
startDateStr: String,
|
startDateStr: String,
|
||||||
endDateStr: String,
|
endDateStr: String,
|
||||||
@@ -216,7 +123,6 @@ class AdminCalculateService(
|
|||||||
GetCalculateByCreatorResponse(totalCount, items)
|
GetCalculateByCreatorResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getCalculateCommunityByCreator(
|
fun getCalculateCommunityByCreator(
|
||||||
startDateStr: String,
|
startDateStr: String,
|
||||||
endDateStr: String,
|
endDateStr: String,
|
||||||
@@ -233,299 +139,4 @@ class AdminCalculateService(
|
|||||||
|
|
||||||
GetCalculateByCreatorResponse(totalCount, items)
|
GetCalculateByCreatorResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun downloadCalculateLiveExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
|
||||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
|
||||||
val totalCount = repository.getCalculateLiveTotalCount(startDate, endDate)
|
|
||||||
val items = if (totalCount == 0) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
repository
|
|
||||||
.getCalculateLive(startDate, endDate, 0L, totalCount.toLong())
|
|
||||||
.map { it.toGetCalculateLiveResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
return createExcelStream(
|
|
||||||
sheetName = "라이브 정산",
|
|
||||||
headers = listOf(
|
|
||||||
"닉네임",
|
|
||||||
"날짜",
|
|
||||||
"라이브 제목",
|
|
||||||
"입장료(캔)",
|
|
||||||
"사용구분",
|
|
||||||
"참여인원",
|
|
||||||
"총 캔",
|
|
||||||
"원화",
|
|
||||||
"결제수수료",
|
|
||||||
"정산금액",
|
|
||||||
"원천세",
|
|
||||||
"입금액"
|
|
||||||
)
|
|
||||||
) { sheet ->
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
val row = sheet.createRow(index + 1)
|
|
||||||
row.createCell(0).setCellValue(item.nickname)
|
|
||||||
row.createCell(1).setCellValue(item.date)
|
|
||||||
row.createCell(2).setCellValue(item.title)
|
|
||||||
row.createCell(3).setCellValue(item.entranceFee.toDouble())
|
|
||||||
row.createCell(4).setCellValue(item.canUsageStr)
|
|
||||||
row.createCell(5).setCellValue(item.numberOfPeople.toDouble())
|
|
||||||
row.createCell(6).setCellValue(item.totalAmount.toDouble())
|
|
||||||
row.createCell(7).setCellValue(item.totalKrw.toDouble())
|
|
||||||
row.createCell(8).setCellValue(item.paymentFee.toDouble())
|
|
||||||
row.createCell(9).setCellValue(item.settlementAmount.toDouble())
|
|
||||||
row.createCell(10).setCellValue(item.tax.toDouble())
|
|
||||||
row.createCell(11).setCellValue(item.depositAmount.toDouble())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun downloadCalculateContentListExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
|
||||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
|
||||||
val totalCount = repository.getCalculateContentListTotalCount(startDate, endDate)
|
|
||||||
val items = if (totalCount == 0) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
repository
|
|
||||||
.getCalculateContentList(startDate, endDate, 0L, totalCount.toLong())
|
|
||||||
.map { it.toGetCalculateContentResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
return createExcelStream(
|
|
||||||
sheetName = "콘텐츠 정산",
|
|
||||||
headers = listOf(
|
|
||||||
"크리에이터",
|
|
||||||
"콘텐츠 제목",
|
|
||||||
"등록일",
|
|
||||||
"판매일",
|
|
||||||
"구분",
|
|
||||||
"가격(캔)",
|
|
||||||
"인원",
|
|
||||||
"총 캔",
|
|
||||||
"원화",
|
|
||||||
"결제수수료",
|
|
||||||
"정산금액",
|
|
||||||
"원천세",
|
|
||||||
"입금액"
|
|
||||||
)
|
|
||||||
) { sheet ->
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
val row = sheet.createRow(index + 1)
|
|
||||||
row.createCell(0).setCellValue(item.nickname)
|
|
||||||
row.createCell(1).setCellValue(item.title)
|
|
||||||
row.createCell(2).setCellValue(item.registrationDate)
|
|
||||||
row.createCell(3).setCellValue(item.saleDate)
|
|
||||||
row.createCell(4).setCellValue(item.orderType)
|
|
||||||
row.createCell(5).setCellValue(item.orderPrice.toDouble())
|
|
||||||
row.createCell(6).setCellValue(item.numberOfPeople.toDouble())
|
|
||||||
row.createCell(7).setCellValue(item.totalCan.toDouble())
|
|
||||||
row.createCell(8).setCellValue(item.totalKrw.toDouble())
|
|
||||||
row.createCell(9).setCellValue(item.paymentFee.toDouble())
|
|
||||||
row.createCell(10).setCellValue(item.settlementAmount.toDouble())
|
|
||||||
row.createCell(11).setCellValue(item.tax.toDouble())
|
|
||||||
row.createCell(12).setCellValue(item.depositAmount.toDouble())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun downloadCalculateContentDonationListExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
|
||||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
|
||||||
val totalCount = repository.getCalculateContentDonationListTotalCount(startDate, endDate)
|
|
||||||
val items = if (totalCount == 0) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
repository
|
|
||||||
.getCalculateContentDonationList(startDate, endDate, 0L, totalCount.toLong())
|
|
||||||
.map { it.toGetCalculateContentDonationResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
return createExcelStream(
|
|
||||||
sheetName = "콘텐츠 후원 정산",
|
|
||||||
headers = listOf(
|
|
||||||
"크리에이터",
|
|
||||||
"콘텐츠 제목",
|
|
||||||
"유무료",
|
|
||||||
"등록일",
|
|
||||||
"후원일",
|
|
||||||
"후원건수",
|
|
||||||
"총 캔",
|
|
||||||
"원화",
|
|
||||||
"결제수수료",
|
|
||||||
"정산금액",
|
|
||||||
"원천세",
|
|
||||||
"입금액"
|
|
||||||
)
|
|
||||||
) { sheet ->
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
val row = sheet.createRow(index + 1)
|
|
||||||
row.createCell(0).setCellValue(item.nickname)
|
|
||||||
row.createCell(1).setCellValue(item.title)
|
|
||||||
row.createCell(2).setCellValue(item.paidOrFree)
|
|
||||||
row.createCell(3).setCellValue(item.registrationDate)
|
|
||||||
row.createCell(4).setCellValue(item.donationDate)
|
|
||||||
row.createCell(5).setCellValue(item.numberOfDonation.toDouble())
|
|
||||||
row.createCell(6).setCellValue(item.totalCan.toDouble())
|
|
||||||
row.createCell(7).setCellValue(item.totalKrw.toDouble())
|
|
||||||
row.createCell(8).setCellValue(item.paymentFee.toDouble())
|
|
||||||
row.createCell(9).setCellValue(item.settlementAmount.toDouble())
|
|
||||||
row.createCell(10).setCellValue(item.tax.toDouble())
|
|
||||||
row.createCell(11).setCellValue(item.depositAmount.toDouble())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun downloadCalculateCommunityPostExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
|
||||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
|
||||||
val totalCount = repository.getCalculateCommunityPostTotalCount(startDate, endDate)
|
|
||||||
val items = if (totalCount == 0) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
repository
|
|
||||||
.getCalculateCommunityPostList(startDate, endDate, 0L, totalCount.toLong())
|
|
||||||
.map { it.toGetCalculateCommunityPostResponse() }
|
|
||||||
}
|
|
||||||
|
|
||||||
return createExcelStream(
|
|
||||||
sheetName = "커뮤니티 정산",
|
|
||||||
headers = listOf(
|
|
||||||
"크리에이터",
|
|
||||||
"게시글",
|
|
||||||
"날짜",
|
|
||||||
"가격(캔)",
|
|
||||||
"구매건수",
|
|
||||||
"총 캔",
|
|
||||||
"원화",
|
|
||||||
"결제수수료",
|
|
||||||
"정산금액",
|
|
||||||
"원천세",
|
|
||||||
"입금액"
|
|
||||||
)
|
|
||||||
) { sheet ->
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
val row = sheet.createRow(index + 1)
|
|
||||||
row.createCell(0).setCellValue(item.nickname)
|
|
||||||
row.createCell(1).setCellValue(item.title)
|
|
||||||
row.createCell(2).setCellValue(item.date)
|
|
||||||
row.createCell(3).setCellValue(item.can.toDouble())
|
|
||||||
row.createCell(4).setCellValue(item.numberOfPurchase.toDouble())
|
|
||||||
row.createCell(5).setCellValue(item.totalCan.toDouble())
|
|
||||||
row.createCell(6).setCellValue(item.totalKrw.toDouble())
|
|
||||||
row.createCell(7).setCellValue(item.paymentFee.toDouble())
|
|
||||||
row.createCell(8).setCellValue(item.settlementAmount.toDouble())
|
|
||||||
row.createCell(9).setCellValue(item.tax.toDouble())
|
|
||||||
row.createCell(10).setCellValue(item.depositAmount.toDouble())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun downloadCalculateLiveByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
|
||||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
|
||||||
val totalCount = repository.getCalculateLiveByCreatorTotalCount(startDate, endDate)
|
|
||||||
val items = if (totalCount == 0) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
repository
|
|
||||||
.getCalculateLiveByCreator(startDate, endDate, 0L, totalCount.toLong())
|
|
||||||
.map { it.toGetCalculateByCreator() }
|
|
||||||
}
|
|
||||||
|
|
||||||
return createCalculateByCreatorExcel("크리에이터별 라이브 정산", items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun downloadCalculateContentByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
|
||||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
|
||||||
val totalCount = repository.getCalculateContentByCreatorTotalCount(startDate, endDate)
|
|
||||||
val items = if (totalCount == 0) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
repository
|
|
||||||
.getCalculateContentByCreator(startDate, endDate, 0L, totalCount.toLong())
|
|
||||||
.map { it.toGetCalculateByCreator() }
|
|
||||||
}
|
|
||||||
|
|
||||||
return createCalculateByCreatorExcel("크리에이터별 콘텐츠 정산", items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun downloadCalculateCommunityByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
|
||||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
|
||||||
val totalCount = repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate)
|
|
||||||
val items = if (totalCount == 0) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
repository
|
|
||||||
.getCalculateCommunityByCreator(startDate, endDate, 0L, totalCount.toLong())
|
|
||||||
.map { it.toGetCalculateByCreator() }
|
|
||||||
}
|
|
||||||
|
|
||||||
return createCalculateByCreatorExcel("크리에이터별 커뮤니티 정산", items)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createCalculateByCreatorExcel(
|
|
||||||
sheetName: String,
|
|
||||||
items: List<GetCalculateByCreatorItem>
|
|
||||||
): StreamingResponseBody {
|
|
||||||
return createExcelStream(
|
|
||||||
sheetName = sheetName,
|
|
||||||
headers = listOf(
|
|
||||||
"이메일",
|
|
||||||
"닉네임",
|
|
||||||
"총 캔",
|
|
||||||
"원화",
|
|
||||||
"결제수수료",
|
|
||||||
"정산금액",
|
|
||||||
"원천세",
|
|
||||||
"입금액"
|
|
||||||
)
|
|
||||||
) { sheet ->
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
val row = sheet.createRow(index + 1)
|
|
||||||
row.createCell(0).setCellValue(item.email)
|
|
||||||
row.createCell(1).setCellValue(item.nickname)
|
|
||||||
row.createCell(2).setCellValue(item.totalCan.toDouble())
|
|
||||||
row.createCell(3).setCellValue(item.totalKrw.toDouble())
|
|
||||||
row.createCell(4).setCellValue(item.paymentFee.toDouble())
|
|
||||||
row.createCell(5).setCellValue(item.settlementAmount.toDouble())
|
|
||||||
row.createCell(6).setCellValue(item.tax.toDouble())
|
|
||||||
row.createCell(7).setCellValue(item.depositAmount.toDouble())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createExcelStream(
|
|
||||||
sheetName: String,
|
|
||||||
headers: List<String>,
|
|
||||||
writeRows: (Sheet) -> Unit
|
|
||||||
): StreamingResponseBody {
|
|
||||||
return StreamingResponseBody { outputStream ->
|
|
||||||
val workbook = SXSSFWorkbook(100)
|
|
||||||
try {
|
|
||||||
val sheet = workbook.createSheet(sheetName)
|
|
||||||
val headerRow = sheet.createRow(0)
|
|
||||||
headers.forEachIndexed { index, value ->
|
|
||||||
headerRow.createCell(index).setCellValue(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeRows(sheet)
|
|
||||||
workbook.write(outputStream)
|
|
||||||
outputStream.flush()
|
|
||||||
} finally {
|
|
||||||
workbook.dispose()
|
|
||||||
workbook.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> {
|
|
||||||
val startDate = startDateStr.convertLocalDateTime()
|
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
|
||||||
|
|
||||||
return startDate to endDate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
|
|
||||||
data class AdminLiveRefundRequest(
|
|
||||||
@JsonProperty("roomId") val roomId: Long?,
|
|
||||||
@JsonProperty("canUsageStr") val canUsageStr: String?
|
|
||||||
)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
|
|
||||||
data class GetCalculateContentDonationListResponse(
|
|
||||||
@JsonProperty("totalCount") val totalCount: Int,
|
|
||||||
@JsonProperty("items") val items: List<GetCalculateContentDonationResponse>
|
|
||||||
)
|
|
||||||
@@ -20,32 +20,33 @@ data class GetCalculateContentDonationQueryData @QueryProjection constructor(
|
|||||||
// 합계
|
// 합계
|
||||||
val totalCan: Int
|
val totalCan: Int
|
||||||
) {
|
) {
|
||||||
companion object {
|
|
||||||
private val KRW_PER_CAN = BigDecimal("100")
|
|
||||||
private val PAYMENT_FEE_RATE = BigDecimal("0.066")
|
|
||||||
private val SETTLEMENT_RATE = BigDecimal("0.7")
|
|
||||||
private val TAX_RATE = BigDecimal("0.033")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toGetCalculateContentDonationResponse(): GetCalculateContentDonationResponse {
|
fun toGetCalculateContentDonationResponse(): GetCalculateContentDonationResponse {
|
||||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||||
val totalKrw = BigDecimal(totalCan).multiply(KRW_PER_CAN)
|
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||||
|
|
||||||
// 결제수수료 : 6.6%
|
// 결제수수료 : 6.6%
|
||||||
val paymentFee = totalKrw.multiply(PAYMENT_FEE_RATE)
|
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||||
|
|
||||||
// 정산금액
|
// 정산금액
|
||||||
// 유료콘텐츠 (원화 - 결제수수료) 의 70%
|
// 유료콘텐츠 (원화 - 결제수수료) 의 90%
|
||||||
// 무료콘텐츠 (원화 - 결제수수료) 의 70%
|
// 무료콘텐츠 (원화 - 결제수수료) 의 70%
|
||||||
val settlementAmount = totalKrw.subtract(paymentFee).multiply(SETTLEMENT_RATE)
|
val settlementAmount = if (price > 0) {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.9))
|
||||||
|
} else {
|
||||||
|
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
// 원천세 = 정산금액의 3.3%
|
// 원천세 = 정산금액의 3.3%
|
||||||
val tax = settlementAmount.multiply(TAX_RATE)
|
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||||
|
|
||||||
// 입금액
|
// 입금액
|
||||||
val depositAmount = settlementAmount.subtract(tax)
|
val depositAmount = settlementAmount.subtract(tax)
|
||||||
|
|
||||||
val paidOrFree = if (price > 0) "유료" else "무료"
|
val paidOrFree = if (price > 0) {
|
||||||
|
"유료"
|
||||||
|
} else {
|
||||||
|
"무료"
|
||||||
|
}
|
||||||
|
|
||||||
return GetCalculateContentDonationResponse(
|
return GetCalculateContentDonationResponse(
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
|
|
||||||
data class GetCalculateContentListResponse(
|
|
||||||
@JsonProperty("totalCount") val totalCount: Int,
|
|
||||||
@JsonProperty("items") val items: List<GetCalculateContentResponse>
|
|
||||||
)
|
|
||||||
@@ -22,15 +22,11 @@ data class GetCalculateContentQueryData @QueryProjection constructor(
|
|||||||
val numberOfPeople: Long,
|
val numberOfPeople: Long,
|
||||||
// 합계
|
// 합계
|
||||||
val totalCan: Int,
|
val totalCan: Int,
|
||||||
// 포인트
|
|
||||||
val totalPoint: Int,
|
|
||||||
// 정산비율
|
// 정산비율
|
||||||
val settlementRatio: Int?
|
val settlementRatio: Int?
|
||||||
) {
|
) {
|
||||||
fun toGetCalculateContentResponse(): GetCalculateContentResponse {
|
fun toGetCalculateContentResponse(): GetCalculateContentResponse {
|
||||||
val orderTypeStr = if (totalPoint > 0) {
|
val orderTypeStr = if (orderType == OrderType.RENTAL) {
|
||||||
"포인트"
|
|
||||||
} else if (orderType == OrderType.RENTAL) {
|
|
||||||
"대여"
|
"대여"
|
||||||
} else {
|
} else {
|
||||||
"소장"
|
"소장"
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
|
|
||||||
data class GetCalculateLiveListResponse(
|
|
||||||
@JsonProperty("totalCount") val totalCount: Int,
|
|
||||||
@JsonProperty("items") val items: List<GetCalculateLiveResponse>
|
|
||||||
)
|
|
||||||
@@ -6,11 +6,10 @@ import java.math.BigDecimal
|
|||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
|
|
||||||
data class GetCalculateLiveQueryData @QueryProjection constructor(
|
data class GetCalculateLiveQueryData @QueryProjection constructor(
|
||||||
|
val email: String,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val date: String,
|
val date: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
// 라이브 방 id
|
|
||||||
val roomId: Long,
|
|
||||||
// 유료방 입장 금액
|
// 유료방 입장 금액
|
||||||
val entranceFee: Int,
|
val entranceFee: Int,
|
||||||
// 코인 사용 구분
|
// 코인 사용 구분
|
||||||
@@ -67,10 +66,10 @@ data class GetCalculateLiveQueryData @QueryProjection constructor(
|
|||||||
val depositAmount = settlementAmount.subtract(tax)
|
val depositAmount = settlementAmount.subtract(tax)
|
||||||
|
|
||||||
return GetCalculateLiveResponse(
|
return GetCalculateLiveResponse(
|
||||||
|
email = email,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
date = date,
|
date = date,
|
||||||
title = title,
|
title = title,
|
||||||
roomId = roomId,
|
|
||||||
entranceFee = entranceFee,
|
entranceFee = entranceFee,
|
||||||
canUsageStr = canUsageStr,
|
canUsageStr = canUsageStr,
|
||||||
numberOfPeople = numberOfPeople,
|
numberOfPeople = numberOfPeople,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package kr.co.vividnext.sodalive.admin.calculate
|
|||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
data class GetCalculateLiveResponse(
|
data class GetCalculateLiveResponse(
|
||||||
|
@JsonProperty("email") val email: String,
|
||||||
@JsonProperty("nickname") val nickname: String,
|
@JsonProperty("nickname") val nickname: String,
|
||||||
@JsonProperty("date") val date: String,
|
@JsonProperty("date") val date: String,
|
||||||
@JsonProperty("title") val title: String,
|
@JsonProperty("title") val title: String,
|
||||||
@JsonProperty("roomId") val roomId: Long,
|
|
||||||
@JsonProperty("entranceFee") val entranceFee: Int,
|
@JsonProperty("entranceFee") val entranceFee: Int,
|
||||||
@JsonProperty("canUsageStr") val canUsageStr: String,
|
@JsonProperty("canUsageStr") val canUsageStr: String,
|
||||||
@JsonProperty("numberOfPeople") val numberOfPeople: Int,
|
@JsonProperty("numberOfPeople") val numberOfPeople: Int,
|
||||||
|
|||||||
@@ -21,15 +21,11 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor(
|
|||||||
val numberOfPeople: Long,
|
val numberOfPeople: Long,
|
||||||
// 합계
|
// 합계
|
||||||
val totalCan: Int,
|
val totalCan: Int,
|
||||||
// 포인트
|
|
||||||
val totalPoint: Int,
|
|
||||||
// 정산비율
|
// 정산비율
|
||||||
val settlementRatio: Int?
|
val settlementRatio: Int?
|
||||||
) {
|
) {
|
||||||
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
|
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
|
||||||
val orderTypeStr = if (totalPoint > 0) {
|
val orderTypeStr = if (orderType == OrderType.RENTAL) {
|
||||||
"포인트"
|
|
||||||
} else if (orderType == OrderType.RENTAL) {
|
|
||||||
"대여"
|
"대여"
|
||||||
} else {
|
} else {
|
||||||
"소장"
|
"소장"
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import org.springframework.data.domain.Pageable
|
|
||||||
import org.springframework.http.HttpHeaders
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.http.ResponseEntity
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
|
||||||
import java.net.URLEncoder
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@RequestMapping("/admin/calculate")
|
|
||||||
class AdminChannelDonationCalculateController(
|
|
||||||
private val service: AdminChannelDonationCalculateService
|
|
||||||
) {
|
|
||||||
@GetMapping("/channel-donation-by-date")
|
|
||||||
fun getChannelDonationByDate(
|
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String,
|
|
||||||
pageable: Pageable
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
service.getChannelDonationByDate(
|
|
||||||
startDateStr = startDateStr,
|
|
||||||
endDateStr = endDateStr,
|
|
||||||
offset = pageable.offset,
|
|
||||||
limit = pageable.pageSize.toLong()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/channel-donation-by-date/excel")
|
|
||||||
fun downloadChannelDonationByDateExcel(
|
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String
|
|
||||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
|
||||||
fileName = "channel-donation-by-date.xlsx",
|
|
||||||
response = service.downloadChannelDonationByDateExcel(startDateStr, endDateStr)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/channel-donation-by-creator")
|
|
||||||
fun getChannelDonationByCreator(
|
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String,
|
|
||||||
pageable: Pageable
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
service.getChannelDonationByCreator(
|
|
||||||
startDateStr = startDateStr,
|
|
||||||
endDateStr = endDateStr,
|
|
||||||
offset = pageable.offset,
|
|
||||||
limit = pageable.pageSize.toLong()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@GetMapping("/channel-donation-by-creator/excel")
|
|
||||||
fun downloadChannelDonationByCreatorExcel(
|
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String
|
|
||||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
|
||||||
fileName = "channel-donation-by-creator.xlsx",
|
|
||||||
response = service.downloadChannelDonationByCreatorExcel(startDateStr, endDateStr)
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun createExcelResponse(
|
|
||||||
fileName: String,
|
|
||||||
response: StreamingResponseBody
|
|
||||||
): ResponseEntity<StreamingResponseBody> {
|
|
||||||
val encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()).replace("+", "%20")
|
|
||||||
val headers = HttpHeaders().apply {
|
|
||||||
add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName")
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
|
||||||
.headers(headers)
|
|
||||||
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
|
||||||
.body(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
import com.querydsl.core.types.dsl.DateTimePath
|
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
|
||||||
import com.querydsl.core.types.dsl.StringTemplate
|
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
|
||||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
|
||||||
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
|
||||||
import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate
|
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
class AdminChannelDonationCalculateQueryRepository(
|
|
||||||
private val queryFactory: JPAQueryFactory
|
|
||||||
) {
|
|
||||||
fun getChannelDonationByDateTotal(
|
|
||||||
startDate: LocalDateTime,
|
|
||||||
endDate: LocalDateTime
|
|
||||||
): GetAdminChannelDonationSettlementTotalQueryData {
|
|
||||||
return getChannelDonationSettlementTotal(startDate, endDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChannelDonationByCreatorTotal(
|
|
||||||
startDate: LocalDateTime,
|
|
||||||
endDate: LocalDateTime
|
|
||||||
): GetAdminChannelDonationSettlementTotalQueryData {
|
|
||||||
return getChannelDonationSettlementTotal(startDate, endDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getChannelDonationSettlementTotal(
|
|
||||||
startDate: LocalDateTime,
|
|
||||||
endDate: LocalDateTime
|
|
||||||
): GetAdminChannelDonationSettlementTotalQueryData {
|
|
||||||
return queryFactory
|
|
||||||
.select(
|
|
||||||
QGetAdminChannelDonationSettlementTotalQueryData(
|
|
||||||
useCan.id.countDistinct(),
|
|
||||||
useCanCalculate.can.sum()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.from(useCanCalculate)
|
|
||||||
.innerJoin(useCanCalculate.useCan, useCan)
|
|
||||||
.innerJoin(member)
|
|
||||||
.on(member.id.eq(useCanCalculate.recipientCreatorId))
|
|
||||||
.where(baseWhereCondition(startDate, endDate))
|
|
||||||
.fetchOne()
|
|
||||||
?: GetAdminChannelDonationSettlementTotalQueryData(
|
|
||||||
count = 0L,
|
|
||||||
totalCan = 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChannelDonationByDateTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
|
||||||
val formattedDate = getFormattedDate(useCan.createdAt)
|
|
||||||
val distinctGroupKey = Expressions.stringTemplate(
|
|
||||||
"CONCAT({0}, '-', {1})",
|
|
||||||
formattedDate,
|
|
||||||
member.id.stringValue()
|
|
||||||
)
|
|
||||||
|
|
||||||
return queryFactory
|
|
||||||
.select(distinctGroupKey.countDistinct())
|
|
||||||
.from(useCanCalculate)
|
|
||||||
.innerJoin(useCanCalculate.useCan, useCan)
|
|
||||||
.innerJoin(member)
|
|
||||||
.on(member.id.eq(useCanCalculate.recipientCreatorId))
|
|
||||||
.where(baseWhereCondition(startDate, endDate))
|
|
||||||
.fetchOne()
|
|
||||||
?.toInt()
|
|
||||||
?: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChannelDonationByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
|
||||||
return queryFactory
|
|
||||||
.select(member.id.countDistinct())
|
|
||||||
.from(useCanCalculate)
|
|
||||||
.innerJoin(useCanCalculate.useCan, useCan)
|
|
||||||
.innerJoin(member)
|
|
||||||
.on(member.id.eq(useCanCalculate.recipientCreatorId))
|
|
||||||
.where(baseWhereCondition(startDate, endDate))
|
|
||||||
.fetchOne()
|
|
||||||
?.toInt()
|
|
||||||
?: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChannelDonationByDate(
|
|
||||||
startDate: LocalDateTime,
|
|
||||||
endDate: LocalDateTime,
|
|
||||||
offset: Long,
|
|
||||||
limit: Long
|
|
||||||
): List<GetAdminChannelDonationSettlementQueryData> {
|
|
||||||
val formattedDate = getFormattedDate(useCan.createdAt)
|
|
||||||
|
|
||||||
return queryFactory
|
|
||||||
.select(
|
|
||||||
QGetAdminChannelDonationSettlementQueryData(
|
|
||||||
formattedDate,
|
|
||||||
member.nickname,
|
|
||||||
useCan.id.countDistinct(),
|
|
||||||
useCanCalculate.can.sum()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.from(useCanCalculate)
|
|
||||||
.innerJoin(useCanCalculate.useCan, useCan)
|
|
||||||
.innerJoin(member)
|
|
||||||
.on(member.id.eq(useCanCalculate.recipientCreatorId))
|
|
||||||
.where(baseWhereCondition(startDate, endDate))
|
|
||||||
.groupBy(formattedDate, member.id)
|
|
||||||
.orderBy(formattedDate.desc(), member.id.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
.fetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChannelDonationByCreator(
|
|
||||||
startDate: LocalDateTime,
|
|
||||||
endDate: LocalDateTime,
|
|
||||||
offset: Long,
|
|
||||||
limit: Long
|
|
||||||
): List<GetAdminChannelDonationSettlementByCreatorQueryData> {
|
|
||||||
return queryFactory
|
|
||||||
.select(
|
|
||||||
QGetAdminChannelDonationSettlementByCreatorQueryData(
|
|
||||||
member.nickname,
|
|
||||||
useCan.id.countDistinct(),
|
|
||||||
useCanCalculate.can.sum()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.from(useCanCalculate)
|
|
||||||
.innerJoin(useCanCalculate.useCan, useCan)
|
|
||||||
.innerJoin(member)
|
|
||||||
.on(member.id.eq(useCanCalculate.recipientCreatorId))
|
|
||||||
.where(baseWhereCondition(startDate, endDate))
|
|
||||||
.groupBy(member.id)
|
|
||||||
.orderBy(member.id.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
.fetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChannelDonationByCreatorForExcel(
|
|
||||||
startDate: LocalDateTime,
|
|
||||||
endDate: LocalDateTime
|
|
||||||
): List<GetAdminChannelDonationSettlementByCreatorQueryData> {
|
|
||||||
return queryFactory
|
|
||||||
.select(
|
|
||||||
QGetAdminChannelDonationSettlementByCreatorQueryData(
|
|
||||||
member.nickname,
|
|
||||||
useCan.id.countDistinct(),
|
|
||||||
useCanCalculate.can.sum()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.from(useCanCalculate)
|
|
||||||
.innerJoin(useCanCalculate.useCan, useCan)
|
|
||||||
.innerJoin(member)
|
|
||||||
.on(member.id.eq(useCanCalculate.recipientCreatorId))
|
|
||||||
.where(baseWhereCondition(startDate, endDate))
|
|
||||||
.groupBy(member.id)
|
|
||||||
.orderBy(member.id.desc())
|
|
||||||
.fetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun baseWhereCondition(
|
|
||||||
startDate: LocalDateTime,
|
|
||||||
endDate: LocalDateTime
|
|
||||||
) = useCan.canUsage.eq(CanUsage.CHANNEL_DONATION)
|
|
||||||
.and(useCan.isRefund.isFalse)
|
|
||||||
.and(useCanCalculate.status.eq(UseCanCalculateStatus.RECEIVED))
|
|
||||||
.and(useCan.createdAt.goe(startDate))
|
|
||||||
.and(useCan.createdAt.loe(endDate))
|
|
||||||
|
|
||||||
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
|
||||||
return Expressions.stringTemplate(
|
|
||||||
"DATE_FORMAT({0}, {1})",
|
|
||||||
Expressions.dateTimeTemplate(
|
|
||||||
LocalDateTime::class.java,
|
|
||||||
"CONVERT_TZ({0},{1},{2})",
|
|
||||||
dateTimePath,
|
|
||||||
"UTC",
|
|
||||||
"Asia/Seoul"
|
|
||||||
),
|
|
||||||
"%Y-%m-%d"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
|
||||||
import org.apache.poi.ss.usermodel.Sheet
|
|
||||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class AdminChannelDonationCalculateService(
|
|
||||||
private val repository: AdminChannelDonationCalculateQueryRepository
|
|
||||||
) {
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getChannelDonationByDate(
|
|
||||||
startDateStr: String,
|
|
||||||
endDateStr: String,
|
|
||||||
offset: Long,
|
|
||||||
limit: Long
|
|
||||||
): GetAdminChannelDonationSettlementResponse {
|
|
||||||
val startDate = startDateStr.convertLocalDateTime()
|
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
|
||||||
|
|
||||||
val total = repository.getChannelDonationByDateTotal(startDate, endDate).toResponseTotal()
|
|
||||||
val totalCount = repository.getChannelDonationByDateTotalCount(startDate, endDate)
|
|
||||||
val items = repository
|
|
||||||
.getChannelDonationByDate(startDate, endDate, offset, limit)
|
|
||||||
.map { it.toResponseItem() }
|
|
||||||
|
|
||||||
return GetAdminChannelDonationSettlementResponse(totalCount, total, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getChannelDonationByCreator(
|
|
||||||
startDateStr: String,
|
|
||||||
endDateStr: String,
|
|
||||||
offset: Long,
|
|
||||||
limit: Long
|
|
||||||
): GetAdminChannelDonationSettlementByCreatorResponse {
|
|
||||||
val startDate = startDateStr.convertLocalDateTime()
|
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
|
||||||
|
|
||||||
val total = repository.getChannelDonationByCreatorTotal(startDate, endDate).toResponseTotal()
|
|
||||||
val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate)
|
|
||||||
val items = repository
|
|
||||||
.getChannelDonationByCreator(startDate, endDate, offset, limit)
|
|
||||||
.map { it.toResponseItem() }
|
|
||||||
|
|
||||||
return GetAdminChannelDonationSettlementByCreatorResponse(totalCount, total, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun downloadChannelDonationByDateExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
|
||||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
|
||||||
val totalCount = repository.getChannelDonationByDateTotalCount(startDate, endDate)
|
|
||||||
val items = if (totalCount == 0) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
repository
|
|
||||||
.getChannelDonationByDate(startDate, endDate, 0L, totalCount.toLong())
|
|
||||||
.map { it.toResponseItem() }
|
|
||||||
}
|
|
||||||
|
|
||||||
return createExcelStream(
|
|
||||||
sheetName = "채널후원 정산",
|
|
||||||
headers = listOf(
|
|
||||||
"날짜",
|
|
||||||
"크리에이터",
|
|
||||||
"건수",
|
|
||||||
"총 받은 캔 수",
|
|
||||||
"원화",
|
|
||||||
"수수료",
|
|
||||||
"정산금액",
|
|
||||||
"원천세",
|
|
||||||
"입금액"
|
|
||||||
)
|
|
||||||
) { sheet ->
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
val row = sheet.createRow(index + 1)
|
|
||||||
row.createCell(0).setCellValue(item.date)
|
|
||||||
row.createCell(1).setCellValue(item.creator)
|
|
||||||
row.createCell(2).setCellValue(item.count.toDouble())
|
|
||||||
row.createCell(3).setCellValue(item.totalCan.toDouble())
|
|
||||||
row.createCell(4).setCellValue(item.krw.toDouble())
|
|
||||||
row.createCell(5).setCellValue(item.fee.toDouble())
|
|
||||||
row.createCell(6).setCellValue(item.settlementAmount.toDouble())
|
|
||||||
row.createCell(7).setCellValue(item.withholdingTax.toDouble())
|
|
||||||
row.createCell(8).setCellValue(item.depositAmount.toDouble())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun downloadChannelDonationByCreatorExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
|
||||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
|
||||||
val items = repository
|
|
||||||
.getChannelDonationByCreatorForExcel(startDate, endDate)
|
|
||||||
.map { it.toResponseItem() }
|
|
||||||
|
|
||||||
return createExcelStream(
|
|
||||||
sheetName = "크리에이터별 채널후원 정산",
|
|
||||||
headers = listOf(
|
|
||||||
"크리에이터",
|
|
||||||
"건수",
|
|
||||||
"총 받은 캔 수",
|
|
||||||
"원화",
|
|
||||||
"수수료",
|
|
||||||
"정산금액",
|
|
||||||
"원천세",
|
|
||||||
"입금액"
|
|
||||||
)
|
|
||||||
) { sheet ->
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
val row = sheet.createRow(index + 1)
|
|
||||||
row.createCell(0).setCellValue(item.creator)
|
|
||||||
row.createCell(1).setCellValue(item.count.toDouble())
|
|
||||||
row.createCell(2).setCellValue(item.totalCan.toDouble())
|
|
||||||
row.createCell(3).setCellValue(item.krw.toDouble())
|
|
||||||
row.createCell(4).setCellValue(item.fee.toDouble())
|
|
||||||
row.createCell(5).setCellValue(item.settlementAmount.toDouble())
|
|
||||||
row.createCell(6).setCellValue(item.withholdingTax.toDouble())
|
|
||||||
row.createCell(7).setCellValue(item.depositAmount.toDouble())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createExcelStream(
|
|
||||||
sheetName: String,
|
|
||||||
headers: List<String>,
|
|
||||||
writeRows: (Sheet) -> Unit
|
|
||||||
): StreamingResponseBody {
|
|
||||||
return StreamingResponseBody { outputStream ->
|
|
||||||
val workbook = SXSSFWorkbook(100)
|
|
||||||
try {
|
|
||||||
val sheet = workbook.createSheet(sheetName)
|
|
||||||
val headerRow = sheet.createRow(0)
|
|
||||||
headers.forEachIndexed { index, value ->
|
|
||||||
headerRow.createCell(index).setCellValue(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeRows(sheet)
|
|
||||||
workbook.write(outputStream)
|
|
||||||
outputStream.flush()
|
|
||||||
} finally {
|
|
||||||
workbook.dispose()
|
|
||||||
workbook.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> {
|
|
||||||
val startDate = startDateStr.convertLocalDateTime()
|
|
||||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
|
||||||
|
|
||||||
return startDate to endDate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
|
|
||||||
data class GetAdminChannelDonationSettlementByCreatorItem(
|
|
||||||
@JsonProperty("creator") val creator: String,
|
|
||||||
@JsonProperty("count") val count: Int,
|
|
||||||
@JsonProperty("totalCan") val totalCan: Int,
|
|
||||||
@JsonProperty("krw") val krw: Int,
|
|
||||||
@JsonProperty("fee") val fee: Int,
|
|
||||||
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
|
||||||
@JsonProperty("withholdingTax") val withholdingTax: Int,
|
|
||||||
@JsonProperty("depositAmount") val depositAmount: Int
|
|
||||||
)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
|
||||||
import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator
|
|
||||||
|
|
||||||
data class GetAdminChannelDonationSettlementByCreatorQueryData @QueryProjection constructor(
|
|
||||||
val creator: String,
|
|
||||||
val count: Long,
|
|
||||||
val totalCan: Int?
|
|
||||||
) {
|
|
||||||
fun toResponseItem(): GetAdminChannelDonationSettlementByCreatorItem {
|
|
||||||
val settlement = ChannelDonationSettlementCalculator.calculate(totalCan ?: 0)
|
|
||||||
|
|
||||||
return GetAdminChannelDonationSettlementByCreatorItem(
|
|
||||||
creator = creator,
|
|
||||||
count = count.toInt(),
|
|
||||||
totalCan = totalCan ?: 0,
|
|
||||||
krw = settlement.krw,
|
|
||||||
fee = settlement.fee,
|
|
||||||
settlementAmount = settlement.settlementAmount,
|
|
||||||
withholdingTax = settlement.withholdingTax,
|
|
||||||
depositAmount = settlement.depositAmount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
data class GetAdminChannelDonationSettlementByCreatorResponse(
|
|
||||||
val totalCount: Int,
|
|
||||||
val total: GetAdminChannelDonationSettlementTotal,
|
|
||||||
val items: List<GetAdminChannelDonationSettlementByCreatorItem>
|
|
||||||
)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
|
|
||||||
data class GetAdminChannelDonationSettlementItem(
|
|
||||||
@JsonProperty("date") val date: String,
|
|
||||||
@JsonProperty("creator") val creator: String,
|
|
||||||
@JsonProperty("count") val count: Int,
|
|
||||||
@JsonProperty("totalCan") val totalCan: Int,
|
|
||||||
@JsonProperty("krw") val krw: Int,
|
|
||||||
@JsonProperty("fee") val fee: Int,
|
|
||||||
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
|
||||||
@JsonProperty("withholdingTax") val withholdingTax: Int,
|
|
||||||
@JsonProperty("depositAmount") val depositAmount: Int
|
|
||||||
)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
|
||||||
import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator
|
|
||||||
|
|
||||||
data class GetAdminChannelDonationSettlementQueryData @QueryProjection constructor(
|
|
||||||
val date: String,
|
|
||||||
val creator: String,
|
|
||||||
val count: Long,
|
|
||||||
val totalCan: Int?
|
|
||||||
) {
|
|
||||||
fun toResponseItem(): GetAdminChannelDonationSettlementItem {
|
|
||||||
val settlement = ChannelDonationSettlementCalculator.calculate(totalCan ?: 0)
|
|
||||||
|
|
||||||
return GetAdminChannelDonationSettlementItem(
|
|
||||||
date = date,
|
|
||||||
creator = creator,
|
|
||||||
count = count.toInt(),
|
|
||||||
totalCan = totalCan ?: 0,
|
|
||||||
krw = settlement.krw,
|
|
||||||
fee = settlement.fee,
|
|
||||||
settlementAmount = settlement.settlementAmount,
|
|
||||||
withholdingTax = settlement.withholdingTax,
|
|
||||||
depositAmount = settlement.depositAmount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
data class GetAdminChannelDonationSettlementResponse(
|
|
||||||
val totalCount: Int,
|
|
||||||
val total: GetAdminChannelDonationSettlementTotal,
|
|
||||||
val items: List<GetAdminChannelDonationSettlementItem>
|
|
||||||
)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
|
|
||||||
data class GetAdminChannelDonationSettlementTotal(
|
|
||||||
@JsonProperty("count") val count: Int,
|
|
||||||
@JsonProperty("totalCan") val totalCan: Int,
|
|
||||||
@JsonProperty("krw") val krw: Int,
|
|
||||||
@JsonProperty("fee") val fee: Int,
|
|
||||||
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
|
||||||
@JsonProperty("withholdingTax") val withholdingTax: Int,
|
|
||||||
@JsonProperty("depositAmount") val depositAmount: Int
|
|
||||||
)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate.channelDonation
|
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
|
||||||
import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator
|
|
||||||
|
|
||||||
data class GetAdminChannelDonationSettlementTotalQueryData @QueryProjection constructor(
|
|
||||||
val count: Long?,
|
|
||||||
val totalCan: Int?
|
|
||||||
) {
|
|
||||||
fun toResponseTotal(): GetAdminChannelDonationSettlementTotal {
|
|
||||||
val totalCan = totalCan ?: 0
|
|
||||||
val settlement = ChannelDonationSettlementCalculator.calculate(totalCan)
|
|
||||||
|
|
||||||
return GetAdminChannelDonationSettlementTotal(
|
|
||||||
count = (count ?: 0L).toInt(),
|
|
||||||
totalCan = totalCan,
|
|
||||||
krw = settlement.krw,
|
|
||||||
fee = settlement.fee,
|
|
||||||
settlementAmount = settlement.settlementAmount,
|
|
||||||
withholdingTax = settlement.withholdingTax,
|
|
||||||
depositAmount = settlement.depositAmount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import java.time.LocalDateTime
|
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.FetchType
|
import javax.persistence.FetchType
|
||||||
import javax.persistence.JoinColumn
|
import javax.persistence.JoinColumn
|
||||||
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
|
|||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
data class CreatorSettlementRatio(
|
data class CreatorSettlementRatio(
|
||||||
var subsidy: Int,
|
val subsidy: Int,
|
||||||
var liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
var contentSettlementRatio: Int,
|
val contentSettlementRatio: Int,
|
||||||
var communitySettlementRatio: Int
|
val communitySettlementRatio: Int
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "member_id", nullable = false)
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
var member: Member? = null
|
var member: Member? = null
|
||||||
|
|
||||||
var deletedAt: LocalDateTime? = null
|
|
||||||
|
|
||||||
fun softDelete() {
|
|
||||||
this.deletedAt = LocalDateTime.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restore() {
|
|
||||||
this.deletedAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
|
|
||||||
this.subsidy = subsidy
|
|
||||||
this.liveSettlementRatio = live
|
|
||||||
this.contentSettlementRatio = content
|
|
||||||
this.communitySettlementRatio = community
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
@@ -28,14 +27,4 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
|
|||||||
limit = pageable.pageSize.toLong()
|
limit = pageable.pageSize.toLong()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@PostMapping("/update")
|
|
||||||
fun updateCreatorSettlementRatio(
|
|
||||||
@RequestBody request: CreateCreatorSettlementRatioRequest
|
|
||||||
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
|
|
||||||
|
|
||||||
@PostMapping("/delete/{memberId}")
|
|
||||||
fun deleteCreatorSettlementRatio(
|
|
||||||
@PathVariable memberId: Long
|
|
||||||
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
|
|
||||||
interface CreatorSettlementRatioRepository :
|
interface CreatorSettlementRatioRepository :
|
||||||
JpaRepository<CreatorSettlementRatio, Long>,
|
JpaRepository<CreatorSettlementRatio, Long>,
|
||||||
CreatorSettlementRatioQueryRepository {
|
CreatorSettlementRatioQueryRepository
|
||||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreatorSettlementRatioQueryRepository {
|
interface CreatorSettlementRatioQueryRepository {
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||||
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCreatorSettlementRatioItem(
|
QGetCreatorSettlementRatioItem(
|
||||||
member.id,
|
|
||||||
member.nickname,
|
member.nickname,
|
||||||
creatorSettlementRatio.subsidy,
|
creatorSettlementRatio.subsidy,
|
||||||
creatorSettlementRatio.liveSettlementRatio,
|
creatorSettlementRatio.liveSettlementRatio,
|
||||||
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
)
|
)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.innerJoin(creatorSettlementRatio.member, member)
|
.innerJoin(creatorSettlementRatio.member, member)
|
||||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
.orderBy(creatorSettlementRatio.id.asc())
|
.orderBy(creatorSettlementRatio.id.asc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(creatorSettlementRatio.id)
|
.select(creatorSettlementRatio.id)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,59 +14,19 @@ class CreatorSettlementRatioService(
|
|||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
|
val creatorSettlementRatio = request.toEntity()
|
||||||
|
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
?: throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
|
||||||
if (creator.role != MemberRole.CREATOR) {
|
if (creator.role != MemberRole.CREATOR) {
|
||||||
throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
val existing = repository.findByMemberId(request.memberId)
|
|
||||||
if (existing != null) {
|
|
||||||
// revive if soft-deleted, then update values
|
|
||||||
existing.restore()
|
|
||||||
existing.updateValues(
|
|
||||||
request.subsidy,
|
|
||||||
request.liveSettlementRatio,
|
|
||||||
request.contentSettlementRatio,
|
|
||||||
request.communitySettlementRatio
|
|
||||||
)
|
|
||||||
repository.save(existing)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val creatorSettlementRatio = request.toEntity()
|
|
||||||
creatorSettlementRatio.member = creator
|
creatorSettlementRatio.member = creator
|
||||||
repository.save(creatorSettlementRatio)
|
repository.save(creatorSettlementRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
|
||||||
?: throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
|
||||||
if (creator.role != MemberRole.CREATOR) {
|
|
||||||
throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
|
||||||
}
|
|
||||||
val existing = repository.findByMemberId(request.memberId)
|
|
||||||
?: throw SodaException(messageKey = "admin.settlement_ratio.not_found")
|
|
||||||
existing.restore()
|
|
||||||
existing.updateValues(
|
|
||||||
request.subsidy,
|
|
||||||
request.liveSettlementRatio,
|
|
||||||
request.contentSettlementRatio,
|
|
||||||
request.communitySettlementRatio
|
|
||||||
)
|
|
||||||
repository.save(existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun deleteCreatorSettlementRatio(memberId: Long) {
|
|
||||||
val existing = repository.findByMemberId(memberId)
|
|
||||||
?: throw SodaException(messageKey = "admin.settlement_ratio.not_found")
|
|
||||||
existing.softDelete()
|
|
||||||
repository.save(existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ data class GetCreatorSettlementRatioResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||||
val memberId: Long,
|
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val subsidy: Int,
|
val subsidy: Int,
|
||||||
val liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
data class AdminCanChargeRequest(
|
data class AdminCanChargeRequest(
|
||||||
val memberIds: List<Long>,
|
val memberId: Long,
|
||||||
val method: String,
|
val method: String,
|
||||||
val can: Int
|
val can: Int
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.can.CanResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
@@ -15,11 +13,6 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
@RequestMapping("/admin/can")
|
@RequestMapping("/admin/can")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
class AdminCanController(private val service: AdminCanService) {
|
class AdminCanController(private val service: AdminCanService) {
|
||||||
@GetMapping
|
|
||||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
|
||||||
return ApiResponse.ok(service.getCans())
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user