Compare commits
7 Commits
test
...
e918d809eb
| Author | SHA1 | Date | |
|---|---|---|---|
| e918d809eb | |||
| 7af059e543 | |||
| 897726e1ec | |||
| 8b98a2dd07 | |||
| cca75420f0 | |||
| 86c627ed1d | |||
| d55514e3a7 |
@@ -7,5 +7,5 @@ indent_size = 4
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 130
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,8 +1,6 @@
|
||||
HELP.md
|
||||
.gradle
|
||||
.envrc
|
||||
.omx/
|
||||
.worktrees/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
@@ -325,7 +323,4 @@ gradle-app.setting
|
||||
### Gradle Patch ###
|
||||
**/build/
|
||||
|
||||
.kiro/
|
||||
.junie
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
||||
|
||||
@@ -1,22 +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. 가능하면 메시지 파일을 검증한 뒤 같은 파일을 `git commit -F`에 전달해 검증을 통과한 메시지를 그대로 사용하고, `Ultraworked with [Sisyphus]...` 및 `Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` 라인이 본문에 추가되지 않도록 확인한다.
|
||||
5. 마지막에 실행 명령과 pre-check/post-check PASS/FAIL 핵심 결과를 간단히 보고한다.
|
||||
|
||||
추가 사용자 의도:
|
||||
$ARGUMENTS
|
||||
115
.opencode/package-lock.json
generated
115
.opencode/package-lock.json
generated
@@ -1,115 +0,0 @@
|
||||
{
|
||||
"name": ".opencode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.0.tgz",
|
||||
"integrity": "sha512-VFIff6LHp/RVaJdrK3EQ1ijx0K1tV5i1DY5YJ+pRqwC6trunPHbvqSN0GHSTZX39RdnSc+XuzCTZQCy1W2qNOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.4.0",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.0.tgz",
|
||||
"integrity": "sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +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`.
|
||||
7. Never include `Ultraworked with [Sisyphus]...` or `Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` in the commit body.
|
||||
|
||||
## 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. Prefer validating a message file with `./work/scripts/check-commit-message-rules.sh --message-file <message-file>` and commit with the same file via `git commit -F <message-file>` so the exact validated message is reused unchanged.
|
||||
7. Run post-commit validation:
|
||||
- `./work/scripts/check-commit-message-rules.sh`
|
||||
8. If post-commit validation fails because an automatic footer was appended, stop and report the failure instead of treating the commit as valid.
|
||||
9. 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.
|
||||
- Whether forbidden Sisyphus footer lines were absent in the final commit body.
|
||||
174
AGENTS.md
174
AGENTS.md
@@ -1,174 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
## 문서 목적
|
||||
- 이 문서는 `/Users/klaus/Develop/sodalive/Server/sodalive` 저장소에서 작업하는 에이전트용 실행 가이드다.
|
||||
- 목표는 "추측 최소화 + 기존 패턴 준수 + 검증 우선"이다.
|
||||
- 이 문서의 규칙은 코드/테스트/문서 변경 모두에 적용한다.
|
||||
|
||||
## 지시 우선순위
|
||||
- 충돌 시 항상 더 높은 우선순위의 지시를 따른다.
|
||||
- 우선순위는 다음 순서를 따른다.
|
||||
1. 사용자 직접 지시
|
||||
2. `AGENTS.md`
|
||||
3. 프로젝트별 제약 조건
|
||||
4. oh-my-openagent 플러그인의 agents / workflows / hooks
|
||||
5. superpowers skills
|
||||
6. 기본 모델 동작
|
||||
|
||||
## CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)
|
||||
These principles override plugin behavior, skill behavior, workflow behavior, and default model behavior unless the user's direct instruction explicitly says otherwise.
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
## 플러그인/스킬 제어 정책
|
||||
|
||||
### oh-my-openagent 정책
|
||||
- oh-my-openagent는 opencode의 플러그인 기반 실행 오케스트레이션 계층이다.
|
||||
- oh-my-openagent는 의사결정 권한이 아니라 실행 보조 권한만 가진다.
|
||||
- 작은 작업에는 multi-agent 실행이나 과도한 workflow를 사용하지 않는다.
|
||||
- 병렬 실행은 명확한 이득이 있을 때만 사용한다.
|
||||
- 모든 oh-my-openagent 동작은 CORE EXECUTION PRINCIPLES를 따라야 한다.
|
||||
|
||||
### superpowers 정책
|
||||
- superpowers는 선택적 스킬 계층이다.
|
||||
- superpowers skill은 필요한 경우에만 사용한다.
|
||||
- superpowers가 과도한 리팩토링, 불필요한 범위 확장, 가정 기반 실행을 유도하면 따르지 않는다.
|
||||
- superpowers를 사용할 때도 최소 변경, 단순성, 검증 가능성을 우선한다.
|
||||
- 모든 superpowers 동작은 CORE EXECUTION PRINCIPLES를 따라야 한다.
|
||||
|
||||
## 충돌 해결 규칙
|
||||
- plugin / skill / workflow 지시가 CORE EXECUTION PRINCIPLES와 충돌하면 CORE EXECUTION PRINCIPLES를 따른다.
|
||||
- 사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다.
|
||||
- 불확실하거나 모호한 경우 추측하지 말고 확인하거나, 가능한 최소 범위의 안전한 조치를 취한다.
|
||||
|
||||
## 실행 모드
|
||||
- 기본 모드: 보수적 실행
|
||||
- 최소 변경
|
||||
- 단순한 구현
|
||||
- 검증 가능한 결과
|
||||
- 확장 모드:
|
||||
- 사용자가 명시적으로 요청한 경우에만 사용한다.
|
||||
- 대규모 리팩토링, 브레인스토밍, 다중 에이전트 실행, 병렬 workflow를 허용한다.
|
||||
|
||||
## 커뮤니케이션 규칙
|
||||
- **"질문에 대한 답변과 설명은 한국어로 한다."**
|
||||
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
|
||||
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.
|
||||
|
||||
## 프로젝트 개요
|
||||
- 빌드 도구: 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
|
||||
```
|
||||
|
||||
## 프로젝트 핵심 규칙
|
||||
- Kotlin/Spring 스타일, 테스트 스타일, 보안 유의사항, 작업 절차, 문서 유지보수 상세 규칙은 아래 문서를 따른다.
|
||||
- `docs/agent-guides/코드스타일.md`
|
||||
- `docs/agent-guides/테스트스타일.md`
|
||||
- `docs/agent-guides/설정보안.md`
|
||||
- `docs/agent-guides/작업절차.md`
|
||||
- `docs/agent-guides/문서유지보수.md`
|
||||
- 공개 API 스키마는 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
|
||||
- 기존 코드베이스 관례를 우선하며, 불확실한 규칙은 추측하지 말고 근거 파일을 먼저 확인한다.
|
||||
|
||||
## 커밋 메시지 규칙
|
||||
- 커밋 상세 가이드/절차는 `.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` 형식을 사용한다.
|
||||
- 커밋 본문에는 `Ultraworked with [Sisyphus]...` 및 `Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` 자동 footer를 포함하지 않는다.
|
||||
- `git commit` 실행 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 검증한다.
|
||||
|
||||
## 작업 계획 문서 규칙 (docs)
|
||||
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
|
||||
- 연속된 하나의 작업에 대한 후속 수정/보완이라면 새 계획 문서를 만들지 말고 기존 계획 문서에 작업 항목과 검증 기록을 이어서 추가한다.
|
||||
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
|
||||
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
||||
- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다.
|
||||
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 범위가 바뀌면 문서를 먼저 갱신한다.
|
||||
- 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 누적 기록한다.
|
||||
|
||||
## 에이전트 동작 원칙
|
||||
- 추측하지 말고, 근거 파일을 읽고 결정한다.
|
||||
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
|
||||
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
|
||||
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
|
||||
@@ -18,7 +18,7 @@ version = "0.0.1-SNAPSHOT"
|
||||
val querydslVersion = "5.0.0"
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
repositories {
|
||||
@@ -26,14 +26,11 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.redisson:redisson-spring-data-27:3.19.2")
|
||||
implementation("org.springframework.boot:spring-boot-starter-aop")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.springframework.retry:spring-retry")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
|
||||
// jwt
|
||||
@@ -41,15 +38,12 @@ dependencies {
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
|
||||
|
||||
implementation("com.nimbusds:nimbus-jose-jwt:9.37.3")
|
||||
|
||||
// querydsl (추가 설정)
|
||||
implementation("com.querydsl:querydsl-jpa:$querydslVersion")
|
||||
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")
|
||||
kapt("org.springframework.boot:spring-boot-configuration-processor")
|
||||
|
||||
// aws
|
||||
implementation("com.amazonaws:aws-java-sdk-sqs:1.12.380")
|
||||
implementation("com.amazonaws:aws-java-sdk-ses:1.12.380")
|
||||
implementation("com.amazonaws:aws-java-sdk-s3:1.12.380")
|
||||
implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.380")
|
||||
@@ -64,17 +58,6 @@ dependencies {
|
||||
// firebase admin sdk
|
||||
implementation("com.google.firebase:firebase-admin:9.2.0")
|
||||
|
||||
// android publisher
|
||||
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.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")
|
||||
runtimeOnly("com.h2database:h2")
|
||||
runtimeOnly("com.mysql:mysql-connector-j")
|
||||
@@ -91,7 +74,7 @@ allOpen {
|
||||
tasks.withType<KotlinCompile> {
|
||||
kotlinOptions {
|
||||
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,39 +0,0 @@
|
||||
# 20260324 라이브 생성 시 19금 방 전환 로직 추가
|
||||
|
||||
## 목적
|
||||
- 라이브 생성(createLiveRoom) 시 태그 기준으로 `room.isAdult` 전환 조건을 확장한다.
|
||||
- 기존 문자열 매칭("음담패설") 조건은 유지하고, `tag.isAdult = true`인 경우에도 19금 방으로 전환한다.
|
||||
|
||||
## 범위
|
||||
- `LiveRoomService.createLiveRoom`의 태그 처리 구간.
|
||||
- 테스트/빌드 회귀 확인.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] 기존 문자열 조건 유지: `tag.tag.contains("음담패설")` → `room.isAdult = true`
|
||||
- [x] 추가 조건 구현: `tag.isAdult == true` → `room.isAdult = true`
|
||||
- [x] 리팩토링: `isAdultTag(LiveTag)` 보조 함수 추출 및 태그 루프 내 부수효과 제거
|
||||
- [x] 리팩토링: 태그 기반 19금 여부를 누적 계산 후 최종 한 번만 `room.isAdult` 반영
|
||||
- [x] 코드 스타일/네이밍/예외 규칙 준수(AGENTS.md)
|
||||
- [x] `./gradlew test` 실행으로 회귀 확인
|
||||
|
||||
## 변경 파일
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt`
|
||||
|
||||
## 검증 계획
|
||||
1차 구현
|
||||
- 무엇을: 라이브 생성 시 태그에 `isAdult=true`가 포함되면 `room.isAdult`가 true로 설정되는지 확인
|
||||
- 왜: 19금 태그를 구조적으로 식별해 19금 방 전환을 정확히 반영하기 위함
|
||||
- 어떻게:
|
||||
- 명령: `./gradlew test`
|
||||
- 기대: 빌드 및 모든 테스트 통과(회귀 없음)
|
||||
|
||||
2차(수동) 확인
|
||||
- 무엇을: 태그가 `음담패설` 또는 `isAdult=true`일 때 19금 전환되는지 로직 리뷰(보조 함수 경유)
|
||||
- 왜: 런타임 리스크 없이 조건 충족 여부를 빠르게 확인
|
||||
- 어떻게: 코드 라인 수동 점검
|
||||
- 위치: `LiveRoomService.isAdultTag` 및 `createLiveRoom`의 태그 forEach 블록
|
||||
- 기대: 두 조건 중 하나라도 만족 시 `room.isAdult = true`
|
||||
|
||||
## 정정/추가 메모
|
||||
- 현 단계에서 공개 API 스키마 변경 없음.
|
||||
- 도메인 예외/응답 포맷 변경 없음.
|
||||
@@ -1,40 +0,0 @@
|
||||
# 20260324 차단 유저 구매 콘텐츠 상세 조회 예외 처리
|
||||
|
||||
## 목적
|
||||
- 차단 관계가 있어도 조회자가 해당 콘텐츠를 구매한 경우에는 상세 조회를 허용한다.
|
||||
- 차단 예외 경로에서는 댓글 및 시리즈 내 이전/다음 콘텐츠 정보를 노출하지 않는다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] `AudioContentService.getDetail`에서 구매 여부(`isExistOrderedAndOrderType`)를 차단 판정보다 먼저 계산
|
||||
- [x] 차단 + 미구매인 경우 기존 `content.error.blocked_access` 예외 유지
|
||||
- [x] 차단 + 구매인 경우 상세 조회 허용
|
||||
- [x] 차단 + 구매인 경우 댓글 목록/댓글 수 조회 쿼리 미실행 및 응답을 `[]`, `0`으로 반환
|
||||
- [x] 차단 + 구매인 경우 `previousContent`, `nextContent` 조회 쿼리 미실행 및 응답을 `null`로 반환
|
||||
- [x] 정적 진단/테스트/빌드 검증 수행
|
||||
|
||||
## 완료 기준 (Pass/Fail)
|
||||
- [x] AC1: 차단 + 미구매 요청 시 `SodaException(messageKey = "content.error.blocked_access")`가 발생해야 한다.
|
||||
- QA: `getDetail` 분기 코드 확인 및 관련 테스트/빌드 통과
|
||||
- [x] AC2: 차단 + 구매 요청 시 상세 조회가 실패하지 않아야 한다.
|
||||
- QA: `getDetail` 분기 코드 확인 및 관련 테스트/빌드 통과
|
||||
- [x] AC3: 차단 + 구매 요청 시 댓글/이전/다음 콘텐츠 조회 로직이 실행되지 않아야 한다.
|
||||
- QA: 조건문 가드로 `commentRepository.findByContentId`, `totalCountCommentByContentId`, `findPreviousContent`, `findNextContent` 호출 차단 확인
|
||||
|
||||
## 검증 기록
|
||||
- 1차 구현: 진행 전
|
||||
- 무엇을: 요구사항 분석 및 기존 패턴 탐색
|
||||
- 왜: 차단/구매 예외 규칙을 기존 서비스 로직과 일관되게 반영하기 위해
|
||||
- 어떻게: `grep`, `ast-grep`, explore/librarian 백그라운드 탐색 수행
|
||||
|
||||
- 2차 구현: 기능 반영 및 시나리오 검증
|
||||
- 무엇을: `AudioContentService.getDetail`에서 차단+구매 예외를 허용하고, 해당 경로에서 댓글/이전·다음 조회를 생략하도록 분기 로직을 수정했다. 또한 `AudioContentServiceTest`를 추가해 차단+미구매/차단+구매 시나리오를 실제 메서드 호출로 검증했다.
|
||||
- 왜: 요청사항(구매자 접근 허용 + 댓글/이전·다음 비조회)을 코드 레벨뿐 아니라 실행 가능한 테스트로 재현해 회귀를 방지하기 위해.
|
||||
- 어떻게:
|
||||
- 명령: `lsp_diagnostics` (`AudioContentService.kt`, `AudioContentServiceTest.kt`)
|
||||
- 결과: 실패 (현재 실행 환경에 Kotlin LSP 미구성으로 `.kt` 진단 불가)
|
||||
- 명령: `./gradlew test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"`
|
||||
- 결과: 성공 (신규 2개 시나리오 테스트 통과)
|
||||
- 명령: `./gradlew test`
|
||||
- 결과: 성공
|
||||
- 명령: `./gradlew build`
|
||||
- 결과: 성공
|
||||
@@ -1,781 +0,0 @@
|
||||
# 20260325 콘텐츠 조회 설정 서버 저장 전환
|
||||
|
||||
## 목적
|
||||
- 클라이언트 요청 파라미터(`isAdultContentVisible`, `contentType`) 중심 조회 방식을 서버 저장값 중심 조회 방식으로 전환한다.
|
||||
- 국가별(한국/해외) 성인 노출 정책을 분리해 적용한다.
|
||||
- 구버전 클라이언트 호환을 위해 **기존 `isAdultContentVisible` 파라미터를 받는 API 전체**에서 전달 파라미터를 저장한다.
|
||||
- 신규 회원은 회원가입 시 기본값을 선저장하고, 기존 회원은 호환 대상 API 호출 시 저장(row 생성/갱신) 후 저장값 기반으로 조회한다.
|
||||
- 설정 변경 시각을 관리해 추적 가능성을 확보한다.
|
||||
|
||||
## 핵심 요구사항 정리
|
||||
- `isAdultContentVisible` 기본값은 `false`로 변경한다. (현재 다수 컨트롤러에서 `true` 기본)
|
||||
- `contentType`은 콘텐츠 조회 성향값으로 사용한다. (`ALL`, `FEMALE`, `MALE`)
|
||||
- `남성향(MALE)`은 **여성 크리에이터(auth.gender=0)** 콘텐츠만 조회한다.
|
||||
- `여성향(FEMALE)`은 **남성 크리에이터(auth.gender=1)** 콘텐츠만 조회한다.
|
||||
- 호환 API 저장과 별도로 **직접 설정 API**(가칭 `PATCH /member/content-preference`)를 생성한다.
|
||||
- 국가 판별 우선순위:
|
||||
1) 회원 ID 강제 매핑 우선 적용
|
||||
- `member.id in [16, 17]` → `countryCode = "KR"`
|
||||
- `member.id in [2, 29721, 32050, 40850]` → `countryCode = "JP"`
|
||||
2) 그 외 회원은 `CloudFront-Viewer-Country` 기반으로 결정
|
||||
3) 헤더 누락/오작동 시 `countryCode = "KR"` fallback 적용
|
||||
- 한국(`countryCode == "KR"`) 정책:
|
||||
- 저장 시: `member.auth != null`일 때만 전달값 반영
|
||||
- 조회 시: `isAdult = isAdultContentVisible && (member.auth != null)`로 계산하고, `contentType` 필터를 함께 적용
|
||||
- 해외(한국 외) 정책:
|
||||
- 저장 시: 전달받은 값 그대로 저장
|
||||
- 조회 시: `isAdult = isAdultContentVisible`로 계산하고, `contentType` 필터를 함께 적용
|
||||
- `AuthController.authVerify` 본인인증 성공 시 `isAdultContentVisible = true`로 즉시 저장한다.
|
||||
- 주의: 조회 판단은 **서버 저장값 기준**으로 수행하며, 구버전 호환 구간에서는 기존 파라미터 수신 후 저장값을 갱신해 동일 정책을 적용한다.
|
||||
- 기존 회원(설정 row 미존재)은 호환 대상 API 호출 시 저장 조건에 따라 row를 생성/갱신하고, 생성 즉시 저장값 기준 조회를 적용한다.
|
||||
- `/member/info` 응답에 아래 필드를 추가한다.
|
||||
- `countryCode`
|
||||
- `isAdultContentVisible`
|
||||
- `contentType`
|
||||
|
||||
## 네이밍 정책 결정 (이번 작업에서 확정)
|
||||
- [x] **외부 API 파라미터명은 유지**: `isAdultContentVisible`, `contentType`
|
||||
- 이유: 기존 클라이언트 호환성과 현재 코드베이스 전역 사용량이 매우 큼.
|
||||
- 적용: `isAdultContentVisible` 파라미터 수신 API 전체에서 기존 키 그대로 수신/저장.
|
||||
- [x] **내부 도메인 캡슐화 객체를 추가**: (예시) `ViewerContentPreference`
|
||||
- 필드명은 기존과 동일(`isAdultContentVisible`, `contentType`)로 유지해 해석 혼선을 최소화.
|
||||
- 이유: 필드명 변경으로 발생하는 전역 대규모 리네임 리스크를 피하면서도, 도메인 객체로 의미를 명확화.
|
||||
- [x] 장기적으로 파라미터명 변경이 필요하면 alias 전략으로 단계적 전환(이번 범위에서는 미적용).
|
||||
- [x] 최종 결정: **이번 변경 범위에서는 리네임을 하지 않는다.**
|
||||
|
||||
## 생성 시점 결정 (회원가입 시 선저장 vs 필요시 생성)
|
||||
- [x] **신규 회원가입 시 선저장(Eager) 채택**
|
||||
- 이유:
|
||||
- 서버 저장값 기반 조회로 전환 시 null/미생성 분기 제거로 일관성 향상
|
||||
- 조회 경로에서 동적 생성(Lazy) 경쟁 조건/추가 트랜잭션 복잡도 감소
|
||||
- `/member/info` 즉시 응답 가능
|
||||
- [x] 기존 회원 데이터는 마이그레이션 또는 최초 조회 시 안전한 보정 로직(백필)으로 누락 방지
|
||||
|
||||
## 변경 대상 상세 맵
|
||||
|
||||
### 1) 저장 모델/도메인 계층
|
||||
- [x] 사용자 조회설정 저장 엔티티 신설 (예: `MemberContentPreference`)
|
||||
- 후보 경로: `src/main/kotlin/kr/co/vividnext/sodalive/member/...`
|
||||
- 필드(안):
|
||||
- `member` (1:1, unique)
|
||||
- `isAdultContentVisible: Boolean = false`
|
||||
- `contentType: ContentType = ContentType.ALL`
|
||||
- `adultContentVisibilityChangedAt: LocalDateTime?`
|
||||
- `contentTypeChangedAt: LocalDateTime?`
|
||||
- `createdAt`, `updatedAt` (BaseEntity)
|
||||
- [x] Repository/QueryRepository/Service 추가
|
||||
- 저장/조회/업데이트 정책 캡슐화
|
||||
- 국가별 저장 정책/조회 정책 계산 함수 제공
|
||||
|
||||
### 2) 회원가입/소셜가입 기본값 선저장
|
||||
- [x] 일반 가입
|
||||
- `MemberService.signUpV2` (`MemberService.kt:126`)
|
||||
- `MemberService.signUp` (`MemberService.kt:175`)
|
||||
- [x] 소셜 가입
|
||||
- `MemberService.findOrRegister(...)` 오버로드 4개
|
||||
- Google/Kakao/Apple/Line 각 신규 회원 생성 지점
|
||||
- [x] 기본값 저장
|
||||
- `isAdultContentVisible = false`
|
||||
- `contentType = ContentType.ALL`
|
||||
- `changedAt` 초기값 = 생성 시각
|
||||
|
||||
### 3) 기존 `isAdultContentVisible` 파라미터 수신 API 전체 호환 저장
|
||||
- [x] 호환 대상 API(4-1, 4-2 목록)에서 기존 파라미터 수신 후 저장 처리
|
||||
- [x] 대표 진입점 구현/검증
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt`
|
||||
- [x] `contentType`를 받지 않는 API 처리 규칙
|
||||
- 대상: `LiveRoomController.kt`, `ExplorerController.kt` 등
|
||||
- `isAdultContentVisible`만 저장하고 `contentType`은 기존 저장값 유지(미존재 시 `ContentType.ALL`)
|
||||
- [x] 기존 회원 누락 row 보정 규칙
|
||||
- 호환 대상 API 호출 시 row 미존재이면 기본값 row 생성 후 저장 정책 적용
|
||||
- [x] 저장 정책 구현
|
||||
- 한국: `member.auth != null`일 때만 전달값 반영
|
||||
- 해외: 전달값 그대로 반영
|
||||
- [x] 파라미터 미전달 시 저장값을 조회해 사용
|
||||
|
||||
### 3-1) 직접 설정 API 신설 (호환 저장과 분리)
|
||||
- [x] 현행 점검: 직접 설정 API 부재 확인
|
||||
- 점검 결과: `MemberController`, `AuthController`, 조회 컨트롤러에 `isAdultContentVisible`+`contentType`를 직접 저장하는 전용 엔드포인트가 없다.
|
||||
- 현재는 조회 API 파라미터 전달 방식(legacy 호환)만 존재한다.
|
||||
- [x] 직접 설정 API 추가
|
||||
- 가칭: `PATCH /member/content-preference`
|
||||
- Request: `isAdultContentVisible`, `contentType` (둘 중 하나 이상 필수)
|
||||
- Response: 저장 후 최신 `isAdultContentVisible`, `contentType`
|
||||
- `countryCode`는 직접 설정 API가 아닌 `/member/info` 응답에서 제공한다.
|
||||
- `changedAt`은 변경 추적용 내부 필드이며 직접 설정 API 응답에는 포함하지 않는다.
|
||||
- 메서드 선택 근거(`PATCH`):
|
||||
- 기존 `member` 갱신 API는 `PUT/POST` 위주이지만, 본 API는 "두 필드 중 일부만 변경" 계약을 URL/메서드 수준에서 명확히 드러내기 위해 `PATCH`를 사용한다.
|
||||
- `isAdultContentVisible`/`contentType` 중 일부만 변경하는 **부분 업데이트**가 기본 시나리오다.
|
||||
- 전송되지 않은 필드는 기존 저장값을 유지해야 하므로 전체 교체(`PUT`)보다 부분 갱신 의미가 명확하다.
|
||||
- 요청은 "전달된 필드만 대입"으로 설계해 동일 payload 재요청 시 동일 상태를 보장한다.
|
||||
- [x] 직접 설정 API 저장 규칙
|
||||
- 회원 설정 row가 없으면 기본값(`false`, `ALL`)으로 생성 후 요청값 반영
|
||||
- 국가 결정은 강제 매핑(KR/JP) → 접속 국가 헤더 → `KR` fallback 순서를 따른다.
|
||||
- `isAdultContentVisible`/`contentType` 변경 시 `changedAt` 갱신 규칙(동일값 재저장 미갱신)을 동일 적용한다.
|
||||
|
||||
### 3-2) 본인인증 성공 연동 저장
|
||||
- [x] `AuthController.authVerify` 성공 시 `isAdultContentVisible = true` 저장
|
||||
- 대상: `src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt`
|
||||
- 구현: `service.authenticate(...)` 성공 직후 선호 저장 서비스 호출
|
||||
- [x] 저장 시나리오
|
||||
- 설정 row 미존재 시 기본 row 생성 후 `isAdultContentVisible = true` 반영
|
||||
- `contentType`은 기존 저장값 유지(미존재 시 `ALL`)
|
||||
- `adultContentVisibilityChangedAt` 갱신, 동일값이면 미갱신
|
||||
- [x] 실패/차단 시나리오
|
||||
- `isBlockAuth(...)`로 차단되어 예외가 발생한 경우 저장하지 않는다.
|
||||
- 본인인증 실패 예외 흐름에서는 저장하지 않는다.
|
||||
|
||||
### 4) 콘텐츠/라이브/채팅 조회 경로를 저장값 기반으로 전환
|
||||
|
||||
#### 4-1. 홈/라이브 진입점
|
||||
- [x] `/api/home` 계열
|
||||
- `HomeController.kt`, `HomeService.kt`
|
||||
- [x] `/api/live`
|
||||
- `LiveApiController.kt`, `LiveApiService.kt`
|
||||
- 연계 추천 경로: `LiveRecommendService.kt`, `LiveRecommendRepository.kt`
|
||||
- [x] `/live/room`
|
||||
- `LiveRoomController.kt`, `LiveRoomService.kt`
|
||||
|
||||
#### 4-2. 파라미터 수신 컨트롤러 전수 목록(저장값 기반 전환 대상)
|
||||
- [x] 참고: `/api/home`, `/api/live`, `/live/room`은 4-1에서 별도 관리하며, 아래는 그 외 컨트롤러 전수 목록
|
||||
- [x] `isAdultContentVisible` + `contentType`를 **둘 다 받는 컨트롤러**
|
||||
- [x] `AudioContentController.kt`
|
||||
- [x] `AudioContentMainController.kt`
|
||||
- [x] `AudioContentCurationController.kt`
|
||||
- [x] `AudioContentThemeController.kt`
|
||||
- [x] `SearchController.kt`
|
||||
- [x] `ContentSeriesController.kt`
|
||||
- [x] `SeriesMainController.kt`
|
||||
- [x] `AudioContentMainTabHomeController.kt`
|
||||
- [x] `AudioContentMainTabContentController.kt`
|
||||
- [x] `AudioContentMainTabFreeController.kt`
|
||||
- [x] `AudioContentMainTabAsmrController.kt`
|
||||
- [x] `AudioContentMainTabAlarmController.kt`
|
||||
- [x] `AudioContentMainTabLiveReplayController.kt`
|
||||
- [x] `AudioContentMainTabSeriesController.kt`
|
||||
- [x] `isAdultContentVisible`만 받는 컨트롤러(동일 저장값 정책 연계 필요)
|
||||
- `ExplorerController.kt` (`/explorer/profile/{id}`)
|
||||
- `LiveRoomController.kt` (`/live/room`)
|
||||
- [x] 컨트롤러 레벨에서 `member.auth != null && (isAdultContentVisible ?: true)`를 직접 계산하는 구간도 함께 전환
|
||||
- `AudioContentController.kt`, `AudioContentMainController.kt`, `AudioContentThemeController.kt`
|
||||
- `SeriesMainController.kt`, `AudioContentMainTabContentController.kt`, `AudioContentMainTabFreeController.kt`
|
||||
- `AudioContentMainTabHomeController.kt`, `AudioContentMainTabAsmrController.kt`, `AudioContentMainTabSeriesController.kt`, `AudioContentMainTabLiveReplayController.kt`
|
||||
|
||||
#### 4-3. 서비스/쿼리 계층 (실제 필터 적용)
|
||||
- [x] `member.auth != null && isAdultContentVisible` 계산식을 사용하는 서비스 전수 수정
|
||||
- `HomeService.kt`, `LiveApiService.kt`, `LiveRoomService.kt`, `LiveRecommendService.kt`
|
||||
- `AudioContentService.kt`, `AudioContentMainService.kt`
|
||||
- `AudioContentMainTabHomeService.kt`, `AudioContentMainTabContentService.kt`, `AudioContentMainTabFreeService.kt`
|
||||
- `AudioContentMainTabAsmrService.kt`, `AudioContentMainTabAlarmService.kt`, `AudioContentMainTabLiveReplayService.kt`, `AudioContentMainTabSeriesService.kt`
|
||||
- `AudioContentCurationService.kt`, `AudioContentThemeService.kt`
|
||||
- `ContentSeriesService.kt`, `SearchService.kt`, `ExplorerService.kt`
|
||||
- [x] `AudioContentRepository.kt` 및 아래 쿼리 레이어의 `contentType`/성인 필터 검증
|
||||
- `RankingRepository.kt`
|
||||
- `SearchRepository.kt`
|
||||
- `ContentSeriesRepository.kt`
|
||||
- `ContentSeriesContentRepository.kt`
|
||||
- `AudioContentThemeQueryRepository.kt`
|
||||
- `AudioContentCurationQueryRepository.kt`
|
||||
- `AudioContentMainTabRepository.kt`
|
||||
- `RecommendSeriesRepository.kt`
|
||||
- `ContentMainTabTagCurationRepository.kt`
|
||||
- `RecommendChannelQueryRepository.kt`
|
||||
- [x] `member.auth == null` 직접 분기 기반 성인 제어 로직 점검(정책 일관화)
|
||||
- `AudioContentService.kt` (`isMosaic` 계산)
|
||||
- `LiveRoomService.kt` (성인 라이브 입장/조회 가드)
|
||||
- `LiveRecommendRepository.kt` (추천 라이브/채널에서 성인 라이브 제외 조건)
|
||||
- `ExplorerQueryRepository.kt` (인증 미완료 시 성인 라이브 제외)
|
||||
- `CreatorCommunityController.kt` / `CreatorCommunityService.kt` (커뮤니티 성인글 조회에서 인증 여부 분기)
|
||||
- `LiveTagRepository.kt` (성인 태그 조회 가드)
|
||||
|
||||
#### 4-4. 채팅 캐릭터 조회
|
||||
- [x] `ChatCharacterController.kt`
|
||||
- 현재 `member.auth == null` 강제 체크(`common.error.adult_verification_required`)가 있어 국가별 정책 반영 지점 설계 필요
|
||||
- 저장값 + 국가 정책으로 19금 캐릭터 노출 제한 로직을 통합
|
||||
- [x] `ChatCharacterService.kt` / Repository 레벨에서 19금 캐릭터 필터가 필요한지 점검 후 반영
|
||||
- [x] 연관 채널(캐릭터 이미지/댓글)도 동일 정책 적용 여부 검토
|
||||
- `CharacterImageController.kt`
|
||||
- `CharacterCommentController.kt`
|
||||
|
||||
### 5) `/member/info` 응답 확장
|
||||
- [x] DTO 확장
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/member/info/GetMemberInfoResponse.kt`
|
||||
- 추가: `countryCode`, `isAdultContentVisible`, `contentType`
|
||||
- [x] 서비스 확장
|
||||
- `MemberService.getMemberInfo(...)`에서 저장값 조회 후 응답 주입
|
||||
- `countryCode`는 `member.countryCode`가 아닌 **요청 시점 국가 결정값**으로 반환
|
||||
- 국가 결정 우선순위:
|
||||
1) `member.id` 강제 매핑 (`KR`: 16, 17 / `JP`: 2, 29721, 32050, 40850)
|
||||
2) `CountryContext.countryCode` (`CloudFront-Viewer-Country`)
|
||||
3) 헤더 누락/오작동 시 `KR`
|
||||
- 인프라 전제: CloudFront에서 `CloudFront-Viewer-Country` 헤더를 오리진으로 전달하도록 설정되어 있어야 한다.
|
||||
- 캐시 주의: 국가별 응답이 달라지는 구간은 캐시 키에 국가 헤더를 포함하는지 함께 점검한다.
|
||||
|
||||
### 6) 기본값 true → false 전환
|
||||
- [x] 기존 `?: true` 기본값 사용 지점 제거 또는 서버 저장값 fallback으로 대체
|
||||
- 전수 대상(18개):
|
||||
- `HomeController.kt`, `LiveApiController.kt`, `LiveRoomController.kt`, `ExplorerController.kt`
|
||||
- `AudioContentController.kt`, `AudioContentMainController.kt`, `AudioContentCurationController.kt`, `AudioContentThemeController.kt`
|
||||
- `SearchController.kt`, `ContentSeriesController.kt`, `SeriesMainController.kt`
|
||||
- `AudioContentMainTabHomeController.kt`, `AudioContentMainTabContentController.kt`, `AudioContentMainTabFreeController.kt`
|
||||
- `AudioContentMainTabAsmrController.kt`, `AudioContentMainTabAlarmController.kt`, `AudioContentMainTabLiveReplayController.kt`, `AudioContentMainTabSeriesController.kt`
|
||||
- [x] fallback 규칙 표준화:
|
||||
1) 저장값 존재 시 저장값 사용
|
||||
2) 저장값 미존재 시 신규 기본값(`false`, `ContentType.ALL`) 사용 및 보정 저장
|
||||
|
||||
### 7) 변경 시각 관리
|
||||
- [x] `isAdultContentVisible` 변경 시 `adultContentVisibilityChangedAt` 갱신
|
||||
- [x] `contentType` 변경 시 `contentTypeChangedAt` 갱신
|
||||
- [x] 전체 변경 추적은 `updatedAt`으로도 확인 가능하게 유지
|
||||
- [x] row 최초 생성 시 `adultContentVisibilityChangedAt`, `contentTypeChangedAt` 초기값을 생성 시각으로 기록
|
||||
- [x] 동일값 재저장 요청 시 `changedAt`은 갱신하지 않도록 정책 정의(노이즈 업데이트 방지)
|
||||
|
||||
## 데이터 마이그레이션/릴리스 계획
|
||||
- [x] DDL 문서 작성 (`docs/*_ddl.sql` 패턴 준수)
|
||||
- 신규 테이블 생성 또는 기존 `member` 컬럼 추가 중 1안 확정
|
||||
- DDL 생성 시 컬럼 타입 규칙
|
||||
- `created_at`, `updated_at`처럼 날짜/시간 저장 필드는 `timestamp`로 생성
|
||||
- boolean 저장 필드는 `tinyint(1)`로 생성
|
||||
- [x] 기존 회원 백필 전략 수립
|
||||
- 기본값: `false` + `ALL`
|
||||
- 적용 대상: 기존에 `isAdultContentVisible`, `contentType`를 받던 API 호출 시점
|
||||
- 범위: **기존 회원 누락 row 보정 전용 규칙** (정상 운영 저장 정책은 3) 전체 API 호환 저장 정책을 따름)
|
||||
- 처리 순서:
|
||||
1) 회원 설정 테이블에 해당 member row 존재 여부 확인
|
||||
2) row가 없으면 기본값(`isAdultContentVisible=false`, `contentType=ALL`)으로 생성
|
||||
3) `member.auth != null`이면 요청으로 받은 값으로 갱신
|
||||
4) `member.auth == null`이면 기본값을 그대로 유지(요청값으로 갱신하지 않음)
|
||||
- 필요 시 배치/스크립트 실행
|
||||
- [x] 단계적 배포
|
||||
1) 저장 모델 배포 + 백필
|
||||
2) 직접 설정 API 배포 + `authVerify` 성공 연동 배포
|
||||
3) 호환 파라미터 수신 저장 전환(기존 `isAdultContentVisible` 파라미터 수신 API 전체)
|
||||
4) 조회 경로 저장값 전환 + `/member/info` 확장 배포
|
||||
5) 호환 파라미터 종료 조건 문서화(구버전 비율/공지/제거 시점)
|
||||
|
||||
## 1차 배포 구현 우선순위 (실행 순서 재정렬)
|
||||
- [x] 0단계: 정책 고정
|
||||
- [x] 국가 판별 우선순위 확정: `member.id` 강제 매핑(KR: 16,17 / JP: 2,29721,32050,40850) → 접속 국가 헤더 → `KR` fallback
|
||||
- [x] 기존 회원 row 미존재 보정 규칙 확정: `member.auth` 여부 기반 기본값 저장/보정
|
||||
- [x] `changedAt` 갱신 규칙 확정: 최초 생성 시 초기화, 동일값 재저장 시 미갱신
|
||||
- [x] 직접 설정 API 계약 확정: endpoint, request/response, validation(둘 중 하나 이상 입력)
|
||||
- [x] 1단계: 저장 모델/DDL 선반영
|
||||
- [x] `MemberContentPreference`(가칭) 엔티티/리포지토리/서비스 추가
|
||||
- [x] DDL 작성(`timestamp`, `tinyint(1)` 규칙 준수)
|
||||
- [x] 2단계: 가입 경로 선저장
|
||||
- [x] `signUpV2`, `signUp`, `findOrRegister`(Google/Kakao/Apple/Line)에서 기본값(`false`, `ALL`) 저장
|
||||
- [x] 3단계: 직접 설정 API 우선 구현
|
||||
- [x] `PATCH /member/content-preference` 추가(호환 API 저장 로직과 분리)
|
||||
- [x] 설정 row 생성/갱신 + 응답 DTO + validation/예외 처리
|
||||
- [x] 4단계: 본인인증 성공 연동
|
||||
- [x] `AuthController.authVerify` 성공 시 `isAdultContentVisible = true` 저장
|
||||
- [x] 차단/실패 예외 흐름에서 저장되지 않음을 보장
|
||||
- [x] 5단계: 호환 저장 진입점 우선 전환(트래픽 핵심)
|
||||
- [x] `/api/home`, `/api/live`, `/live/room`, `/explorer/profile/{id}`에서 파라미터 수신 후 저장
|
||||
- [x] row 미존재 시 생성 + 정책 반영(국가/인증 분기)
|
||||
- [x] 6단계: 파라미터 수신 컨트롤러 전수 전환(4-2)
|
||||
- [x] 콘텐츠/검색/시리즈/메인탭 컨트롤러 전체 저장값 연동
|
||||
- [x] `contentType` 미수신 API는 `isAdultContentVisible`만 저장하고 `contentType`은 기존값 유지
|
||||
- [x] 7단계: 조회 경로 저장값 기준 전환(4-3, 4-4)
|
||||
- [x] 서비스/쿼리 계층 `?: true` 및 직접 계산식 제거 후 저장값 기반 계산으로 통일
|
||||
- [x] 채팅 캐릭터/이미지/댓글 경로를 국가+저장값 정책으로 통합
|
||||
- [x] 8단계: `/member/info` 확장
|
||||
- [x] 응답 필드 `countryCode`, `isAdultContentVisible`, `contentType` 추가
|
||||
- [x] `countryCode`는 회원 ID 강제 매핑 우선 적용 후 접속 국가/`KR` fallback 적용
|
||||
- [x] 9단계: 기본값 true → false 전수 치환
|
||||
- [x] 컨트롤러 18개 `isAdultContentVisible ?: true` 제거
|
||||
- [x] 저장값 우선 + 미존재 시 `false/ALL` 보정 저장으로 표준화
|
||||
- [x] 10단계: 테스트/검증
|
||||
- [x] 테스트 작성 원칙: `@SpringBootTest`를 사용하지 않고 단위 테스트(JUnit5 + Mockito) 중심으로 작성
|
||||
- [x] 단위: 국가 분기/강제 매핑, auth 분기, changedAt, row 보정, 가입 선저장, 직접 설정 API, authVerify 연동, `/member/info` 반환
|
||||
- [x] 통합: 직접 설정 API 저장 반영, authVerify 성공 자동 true 저장, 호환 API 저장 반영, 헤더 누락(`KR`) fallback
|
||||
- [x] 회귀: `./gradlew test`, `./gradlew build`, `./gradlew ktlintCheck`
|
||||
|
||||
## 테스트/검증 계획
|
||||
- [x] 테스트 작성 원칙
|
||||
- `@SpringBootTest`를 사용하지 않는다.
|
||||
- 서비스/정책 로직은 JUnit5 + Mockito 기반 단위 테스트로 작성한다.
|
||||
- [x] 단위 테스트
|
||||
- 국가 결정 우선순위 테스트
|
||||
- `member.id=16,17`은 헤더와 무관하게 `KR`
|
||||
- `member.id=2,29721,32050,40850`은 헤더와 무관하게 `JP`
|
||||
- 그 외 회원은 `CloudFront-Viewer-Country` 사용, 누락 시 `KR` fallback
|
||||
- 한국/해외 저장 정책 분기 테스트
|
||||
- 한국 + `member.auth == null`에서 호환 API 호출 시 요청값으로 갱신되지 않고 기본값 유지되는지 테스트
|
||||
- 해외 + `member.auth == null`에서 호환 API 호출 시 요청값이 저장되는지 테스트
|
||||
- 한국/해외 조회 정책 분기 테스트
|
||||
- 직접 설정 API 테스트
|
||||
- `isAdultContentVisible`/`contentType`를 각각 단독/동시 변경할 때 저장 반영 및 응답(`isAdultContentVisible`, `contentType`)이 기대값인지 테스트
|
||||
- 둘 다 누락된 요청을 validation 에러로 처리하는지 테스트
|
||||
- `isAdultContentVisible` 값 변경 시 `adultContentVisibilityChangedAt`만 갱신되는지 테스트
|
||||
- `contentType` 값 변경 시 `contentTypeChangedAt`만 갱신되는지 테스트
|
||||
- 동일값 재저장 시 `changedAt`이 갱신되지 않는지 테스트
|
||||
- `contentType`(ALL/FEMALE/MALE) 성별 필터 기대값 테스트
|
||||
- `AuthController.authVerify` 성공 시 `isAdultContentVisible=true`로 저장되는지 테스트
|
||||
- `AuthController.authVerify` 실패/차단 시 저장이 발생하지 않는지 테스트
|
||||
- `contentType` 미수신 API(`LiveRoom`, `Explorer profile`)에서 `isAdultContentVisible`만 저장되는지 테스트
|
||||
- 기존 회원 row 미존재 시 API 호출로 row 생성/갱신되는지 테스트
|
||||
- 신규 회원가입 직후 기본값(`false`/`ALL`) 선저장 검증 테스트
|
||||
- `/member/info` 필드 노출 테스트(`countryCode`는 회원 ID 강제 매핑 우선 + 비대상 회원은 접속 국가 기준 반환 검증 포함)
|
||||
- [x] 통합 테스트
|
||||
- 직접 설정 API(`PATCH /member/content-preference`) 호출 시 저장 후 즉시 조회 경로에 반영되는지 확인
|
||||
- `authVerify` 성공 호출 시 `isAdultContentVisible=true` 자동 저장 반영 확인
|
||||
- 호환 대상 API(`/api/home`, `/api/live`, `/live/room`, `explorer/profile`, 콘텐츠/검색/시리즈 계열) 파라미터 전달 → 저장 반영 확인
|
||||
- 기존 회원(설정 row 없음) 첫 호출 시 저장 생성 + 같은 요청에서 저장값 기반 조회 적용 확인
|
||||
- 한국/해외 각각에서 동일 API 호출 시 저장 결과와 조회 결과가 정책대로 달라지는지 확인
|
||||
- `/member/info` 호출 시 강제 매핑 회원은 헤더 변경과 무관하게 고정 국가를 반환하는지 확인
|
||||
- `/member/info` 호출 시 강제 매핑 대상이 아닌 회원은 헤더 변경(`KR`/`US` 등)에 따라 국가 응답이 변경되는지 확인
|
||||
- `CloudFront-Viewer-Country` 헤더 누락 시 `/member/info.countryCode`가 fallback(`KR`)으로 반환되는지 확인
|
||||
- 콘텐츠/라이브/채팅 캐릭터 조회 결과 정책 반영 확인
|
||||
- [x] 회귀 검증 명령
|
||||
- `./gradlew test`
|
||||
- `./gradlew build`
|
||||
- `./gradlew ktlintCheck`
|
||||
|
||||
## 리스크 및 대응
|
||||
- [x] 리스크: 파라미터 제거 시 구버전 앱 동작 불일치
|
||||
- 대응: 초기에는 구/신 정책을 공존 운영하고, 기존 회원 중 저장값이 없으면 `member.auth` 여부에 따라 기본값을 저장/보정해 조회 기준을 단일화한다.
|
||||
- 판정: 대응 가능(공존 기간의 잔여 리스크는 운영으로 관리).
|
||||
- [x] 리스크: 기존 회원 저장값 미존재
|
||||
- 대응: `isAdultContentVisible`를 받는 API에서 설정 row 존재 여부를 확인하고, 없으면 즉시 생성/저장한다.
|
||||
- 판정: 대응 가능(런타임 백필로 해소).
|
||||
- [x] 리스크: 한국 인증 전 사용자 성인값 처리 혼선
|
||||
- 대응: 한국은 `member.auth == null`이면 저장값을 기본값으로 저장/유지하고, `member.auth != null && isAdultContentVisible == true`일 때만 성인 처리한다.
|
||||
- 판정: 대응 가능(정책 명시로 혼선 축소).
|
||||
- [x] 리스크: `CloudFront-Viewer-Country` 헤더 미전달/오작동으로 현재 접속 국가 판별 실패
|
||||
- 대응: 국가 판별 실패 시 한국(`KR`)으로 판단한다.
|
||||
- 판정: 대응 가능(보수적 안전 기준 적용), 단 해외 사용자의 과차단 가능성은 모니터링한다.
|
||||
- [x] 리스크: 호환 파라미터(legacy fallback) 장기 존치로 정책 복잡도 증가
|
||||
- 대응: 앱 배포 상태(버전 점유율) 기반으로 제거 일자를 결정하고 단계적으로 삭제한다.
|
||||
- 판정: 대응 가능(종료 기준·일정 관리 필요).
|
||||
- [x] 리스크: 직접 설정 API가 없으면 호환 API 호출 여부에 따라 저장 타이밍이 불안정해짐
|
||||
- 대응: 1차 배포에 직접 설정 API를 포함하고, 호환 저장은 구버전 공존 목적의 보조 경로로 제한한다.
|
||||
- 판정: 대응 가능(명시적 설정 진입점 도입으로 안정화).
|
||||
- [x] 리스크: 회원 ID 강제 국가 매핑 하드코딩이 운영 중 누락/충돌을 유발할 수 있음
|
||||
- 대응: 강제 매핑 목록을 정책 상수로 단일화하고 테스트 케이스(각 ID별 기대 국가)를 고정한다.
|
||||
- 판정: 대응 가능(목록 변경 절차와 테스트 동반 시 관리 가능).
|
||||
|
||||
## 구현 완료 후 기록 섹션 (구현 단계에서 작성)
|
||||
### 사전 점검 (2026-03-25)
|
||||
- 무엇을:
|
||||
- 상단 목적(서버 저장값 전환/국가별 정책 분리/호환 저장/선저장/변경시각) 기준으로 변경 대상 체크리스트의 누락 여부를 점검했다.
|
||||
- 왜:
|
||||
- 구현 전 문서 범위 누락을 제거해 실제 작업 시 정책 누락/회귀를 방지하기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `grep(include=*Controller.kt, pattern=isAdultContentVisible)`
|
||||
- `ast-grep(lang=kotlin, pattern=member.auth != null && $X)`
|
||||
- `grep(pattern=CloudFront-Viewer-Country|CountryContext\.countryCode)`
|
||||
- `Read(ExplorerController.kt, ExplorerService.kt, MemberService.kt, GetMemberInfoResponse.kt)`
|
||||
- `Explore/Librarian 병렬 점검(bg_db6e2179, bg_525f613e, bg_908b86f6, bg_7bad3593, bg_3736f748)`
|
||||
- 결과:
|
||||
- `ExplorerService.kt`가 서비스 전수 수정 목록(4-3)에 빠져 있어 추가했다.
|
||||
- `/member/info.countryCode`에 대해 CloudFront 헤더 전달 전제, fallback(`KR`), 캐시 키 점검 항목을 추가했다.
|
||||
- `changedAt` 정책(초기값/동일값 재저장)과 단위 테스트 항목을 보강했다.
|
||||
- legacy fallback 장기 존치 리스크 및 종료 조건 문서화 항목을 추가했다.
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을:
|
||||
- `MemberContentPreference` 저장 모델/리포지토리/정책 서비스를 추가하고, 강제 국가 매핑(KR/JP) + 헤더 + `KR` fallback 규칙을 서비스 단일 경로로 구현했다.
|
||||
- 회원가입/소셜가입(`signUpV2`, `signUp`, `findOrRegister` 4종) 직후 기본값(`false`, `ALL`) 선저장을 연동했다.
|
||||
- `PATCH /member/content-preference`를 추가하고, 요청값(둘 중 하나 이상) 갱신 및 최신 설정 응답을 구현했다.
|
||||
- `AuthController.authVerify` 성공 직후 `isAdultContentVisible=true` 저장 연동을 추가했다.
|
||||
- 핵심 트래픽 진입점(`/api/home`, `/api/live`, `/live/room`, `/explorer/profile/{id}`)을 저장값 기반으로 전환하고, `/member/info`에 `countryCode`, `isAdultContentVisible`, `contentType`를 확장했다.
|
||||
- 서비스 계층의 `member.auth != null && isAdultContentVisible` 계산식을 정책 유틸(`isAdultVisibleByPolicy`) 기반으로 전환해 한국/해외 분기를 통합했다.
|
||||
- DDL 문서 `docs/20260326_member_content_preference_ddl.sql`을 추가했다.
|
||||
- 왜:
|
||||
- 구버전 클라이언트 호환을 유지하면서도, 조회 정책 판단의 단일 기준을 서버 저장값으로 전환해 국가/인증 분기 불일치를 줄이기 위해서다.
|
||||
- 본인인증 성공 이후 성인 노출 상태를 자동 동기화하고, 사용자 설정 변경 진입점을 명시적으로 제공하기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test`
|
||||
- `./gradlew build`
|
||||
- `./gradlew ktlintCheck`
|
||||
- 결과:
|
||||
- 단위 테스트 추가: `MemberContentPreferenceServiceTest`, `AuthControllerTest` 작성 및 기존 테스트(`MemberServiceCacheEvictionTest`, `LiveRecommendServiceTest`) 의존성 갱신 완료.
|
||||
- 회귀 검증 결과: `test`, `build`, `ktlintCheck` 모두 성공.
|
||||
- 참고: `.kt` 대상 LSP 서버가 환경에 없어 LSP 진단은 실행 불가였고, 대신 Gradle 컴파일/테스트/린트 통과로 검증했다.
|
||||
- 남은 항목:
|
||||
- 4-2 전수 컨트롤러(콘텐츠/검색/시리즈/메인탭)와 4-4 채팅 캐릭터 경로는 후속 단계에서 동일 정책으로 확장 적용이 필요하다.
|
||||
|
||||
### 2차 문서 보강 (2026-03-26)
|
||||
- 무엇을:
|
||||
- 회원 ID 강제 국가 매핑 정책(KR: 16,17 / JP: 2,29721,32050,40850)과 `authVerify` 성공 시 `isAdultContentVisible=true` 저장 요구사항을 문서 전반에 반영했다.
|
||||
- 호환 저장과 별개의 직접 설정 API(가칭 `PATCH /member/content-preference`) 필요성을 명시하고, 1차 배포 우선순위와 테스트 계획을 재정렬했다.
|
||||
- 왜:
|
||||
- 현재 코드는 조회 파라미터 기반(legacy) 흐름만 존재해 사용자 설정을 명시적으로 저장/관리하는 진입점이 없고,
|
||||
본인인증 성공 이후 성인 노출 상태를 자동 동기화해야 정책 일관성을 유지할 수 있기 때문이다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `grep(include=*Controller.kt, pattern=isAdultContentVisible|contentType)`
|
||||
- `grep(path=src/main/kotlin, pattern=CloudFront-Viewer-Country|CountryContext\.countryCode)`
|
||||
- `ast-grep(lang=kotlin, pattern=member.auth != null && $X)`
|
||||
- `Read(MemberController.kt, AuthController.kt, CountryInterceptor.kt, CountryContext.kt, MemberService.kt)`
|
||||
- `Explore/Librarian 병렬 점검(bg_9725b309, bg_7d18bd4d, bg_5be1625e, bg_234021df)`
|
||||
- 결과:
|
||||
- 직접 설정 API 부재(`MemberController`에 전용 엔드포인트 없음) 확인 결과를 문서에 반영했다.
|
||||
- 국가 결정 우선순위(회원 ID 강제 매핑 > 접속 국가 헤더 > KR fallback)를 핵심 요구사항, `/member/info`, 테스트 항목에 일관 반영했다.
|
||||
- `AuthController.authVerify` 성공 시 `isAdultContentVisible=true` 저장 항목을 구현 범위/우선순위/테스트에 추가했다.
|
||||
|
||||
### 3차 구현 (2026-03-26)
|
||||
- 무엇을:
|
||||
- 4-2 전수 대상 컨트롤러(`AudioContent*`, `SearchController`, `ContentSeriesController`, `SeriesMainController`, 메인탭 7종)에서 `MemberContentPreferenceService.resolveForQuery(...)`를 사용하도록 변경했다.
|
||||
- 컨트롤러 단의 `isAdultContentVisible ?: true`, `member.auth != null && (isAdultContentVisible ?: true)` 계산식을 제거하고, 저장값 기반 `preference.isAdultContentVisible / preference.contentType / preference.isAdult`를 사용하도록 통일했다.
|
||||
- 4-4 범위로 `ChatCharacterController`, `CharacterImageController`, `CharacterCommentController`의 `member.auth` 강제 분기를 `MemberContentPreferenceService.getStoredPreference(member).isAdult` 기반 정책 가드로 전환했다.
|
||||
- 왜:
|
||||
- legacy 파라미터 기본값(`true`) 의존을 제거해 국가/인증 정책이 컨트롤러별로 분산되는 문제를 없애고, 저장값 기준 단일 정책으로 수렴하기 위해서다.
|
||||
- 채팅 캐릭터 연관 경로까지 동일 정책을 적용해 도메인별 예외 분기를 줄이고 운영 일관성을 확보하기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `grep(pattern=isAdultContentVisible\s*\?:\s*true|member\??\.auth\s*!=\s*null\s*&&\s*\(isAdultContentVisible\s*\?:\s*true\), path=src/main/kotlin, output_mode=content)`
|
||||
- `grep(pattern=isAdultContentVisible\s*\?:\s*true, path=src/main/kotlin, output_mode=count)`
|
||||
- `./gradlew test`
|
||||
- `./gradlew ktlintCheck`
|
||||
- `./gradlew build`
|
||||
- 결과:
|
||||
- `src/main/kotlin` 기준 `isAdultContentVisible ?: true` 패턴 0건 확인.
|
||||
- 회귀 검증(`test`, `ktlintCheck`, `build`) 모두 성공.
|
||||
- 참고: Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
|
||||
|
||||
### 정정 (2026-03-26)
|
||||
- 무엇을:
|
||||
- `1차 구현` 섹션의 "남은 항목"에 기재된 4-2/4-4 미완 상태를 최신 구현 상태(완료)와 맞춰 정정한다.
|
||||
- 왜:
|
||||
- 3차 구현에서 해당 범위가 실제로 완료되어, 과거 시점의 미완 표기가 현재 상태와 달라졌기 때문이다.
|
||||
- 어떻게:
|
||||
- 4-2 체크리스트 전 항목, 4-4 체크리스트 전 항목, 1차 배포 우선순위 6/7/9단계를 완료 상태(`[x]`)로 동기화했다.
|
||||
|
||||
### 4차 구현 (2026-03-26)
|
||||
- 무엇을:
|
||||
- 4-3 잔여 항목 중 성인 제어의 `member.auth` 직접 분기를 정책 기반으로 재정렬했다.
|
||||
- `AudioContentService` 상세 조회의 연관 콘텐츠/모자이크 판단을 저장 선호 정책(`isAdult`) 기준으로 통일했다.
|
||||
- `ExplorerQueryRepository#getLiveRoomList`는 성인 라이브 필터를 호출부 정책값(`isAdult`)만 사용하도록 변경했다.
|
||||
- `CreatorCommunityController/Service`, `LiveTagService/Repository`는 저장 선호 기반 성인 필터를 사용하도록 정리했다.
|
||||
- 태그 큐레이션/시리즈 조회의 누락 필터를 보완했다.
|
||||
- `ContentMainTabTagCurationRepository`에 비성인 조회 시 `audioContent.isAdult.isFalse`를 추가했다.
|
||||
- `ContentSeriesRepository#getGenreList`에 비성인 조회 시 `audioContent.isAdult.isFalse`를 추가했다.
|
||||
- 단위 테스트를 보강했다.
|
||||
- `MemberContentPreferenceServiceTest`, `MemberControllerTest`, `MemberServiceContentPreferenceTest`, `CreatorCommunityServiceTest`, `LiveTagServiceTest`를 추가/확장했다.
|
||||
- 사용자 요청에 따라 정책 분기 의도를 설명하는 주석을 변경 코드의 핵심 분기 지점에 보강했다.
|
||||
- 왜:
|
||||
- 동일 기능 내에서 `member.auth` 직접 분기와 저장 선호 분기가 혼재하면 국가/인증 정책 일관성이 깨질 수 있어, 조회/필터 기준을 저장 선호 정책으로 단일화할 필요가 있었다.
|
||||
- 누락된 성인 필터는 비성인 조회에서 의도치 않은 노출을 만들 수 있어 쿼리 레이어 보완이 필요했다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceContentPreferenceTest"`
|
||||
- `./gradlew test`
|
||||
- `./gradlew ktlintCheck`
|
||||
- `./gradlew build`
|
||||
- 결과:
|
||||
- 초기 `test`에서 `MemberServiceContentPreferenceTest` 2건 실패를 확인했고, Mockito matcher null 이슈를 테스트 코드에서 수정했다.
|
||||
- 수정 후 대상 테스트/전체 테스트/ktlint/build를 재실행해 모두 성공했다.
|
||||
- Kotlin LSP 미구성으로 LSP 진단은 불가했으며, Gradle 검증으로 대체했다.
|
||||
|
||||
### 4차 후속 보완 (Oracle 점검 반영, 2026-03-26)
|
||||
- 무엇을:
|
||||
- `AudioContentService#getDetail`에 비성인 정책 사용자의 성인 콘텐츠 직접 상세 진입 차단(`common.error.adult_verification_required`)을 추가했다.
|
||||
- `CreatorCommunity` 댓글/답글 경로(`createCommunityPostComment`, `getCommunityPostCommentList`, `getCommentReplyList`)에 저장 선호 기반 `isAdult` 검증을 추가해 성인 게시물 우회 접근을 차단했다.
|
||||
- 관련 단위 테스트(`CreatorCommunityServiceTest`)에 비성인 정책에서의 댓글 작성/댓글 목록/답글 목록 차단 케이스를 추가했다.
|
||||
- 왜:
|
||||
- 목록/상세/구매 경로는 정책이 적용되어도 댓글 경로와 직접 상세 진입이 열려 있으면 정책 우회가 가능해, 성인 노출 정책 일관성이 깨질 수 있기 때문이다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"`
|
||||
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
|
||||
- 결과:
|
||||
- 커뮤니티 서비스 단위 테스트 통과.
|
||||
- 전체 검증 체인(test/ktlint/build) 모두 성공.
|
||||
|
||||
### 5차 구현 (미체크 항목 마감, 2026-03-26)
|
||||
- 무엇을:
|
||||
- 계획 문서의 미체크 항목 5개를 전수 점검하고, 구현/검증/문서화를 완료했다.
|
||||
- 4-3 쿼리 레이어 검증 항목은 explore 병렬 감사 결과와 직접 검색 결과를 근거로 완료 처리했다.
|
||||
- 통합 테스트 항목은 `MemberContentPreferenceIntegrationTest`를 추가해 아래 시나리오를 실제 영속성 연동으로 검증했다.
|
||||
- 직접 설정(updatePreference) 저장 후 즉시 조회 반영
|
||||
- `authVerify` 연동 메서드(`markAdultVisibleAfterAuthVerify`) 저장 반영
|
||||
- legacy 호출 경로(`resolveForQuery`)의 row 생성 + 즉시 반영
|
||||
- 헤더 누락 시 `KR` fallback 및 KR+미인증 기본값 유지
|
||||
- KR+인증 회원의 요청값 반영 및 `isAdult` 계산
|
||||
- 강제 국가 매핑 ID(`2`, `16`) 우선 적용
|
||||
- 기존 회원 백필 전략/단계적 배포 항목은 현재 구현 상태(런타임 row 보정 + 단계별 배포 절차 문서화) 기준으로 완료 처리했다.
|
||||
- 왜:
|
||||
- 체크리스트 미완 상태를 해소하지 않으면 정책 전환 완료 기준이 불명확해지고, 운영 시 회귀 검증 근거가 약해지기 때문이다.
|
||||
- 특히 통합 시나리오 부재는 “저장 후 즉시 반영” 보장을 약화시키므로 실제 repository 연동 테스트가 필요했다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `grep(pattern="^- \[ \]", include="20260325_콘텐츠조회설정서버저장전환.md")`
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
|
||||
- 결과:
|
||||
- 신규 통합 테스트 통과.
|
||||
- 전체 검증 체인(test/ktlint/build) 모두 성공.
|
||||
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
|
||||
|
||||
### 5차 후속 보완 (Oracle 리뷰 반영, 2026-03-26)
|
||||
- 무엇을:
|
||||
- `AudioContentService#getDetail`의 성인 상세 직접 진입 차단 로직에 대한 회귀 테스트를 `AudioContentServiceTest`에 추가했다.
|
||||
- 비성인 정책(`isAdultContentVisible=false`)에서 성인 콘텐츠 조회 시 `common.error.adult_verification_required` 예외를 검증했다.
|
||||
- 왜:
|
||||
- 최종 리뷰에서 기능은 구현되어 있었지만 전용 테스트 증빙이 부족해, 정책 우회 회귀를 방지하기 위한 테스트 고정을 추가할 필요가 있었다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"`
|
||||
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
|
||||
- 결과:
|
||||
- 신규 회귀 테스트 포함 대상 테스트 통과.
|
||||
- 전체 검증 체인(test/ktlint/build) 모두 성공.
|
||||
|
||||
### 6차 구현 (이슈 1/2/3 안정화, 2026-03-26)
|
||||
- 무엇을:
|
||||
- 이슈 1 대응: `MemberContentPreferenceService.resolveForQuery`, `getStoredPreference`를 `REQUIRES_NEW` 트랜잭션으로 분리해 `LiveRoomService`/`ExplorerService`의 `readOnly` 조회 흐름에서도 설정 생성·갱신이 반영되도록 수정했다.
|
||||
- 이슈 2 대응: 선호 변경 경로(`updatePreference`, `markAdultVisibleAfterAuthVerify`, legacy `resolveForQuery` 변경 발생 시)에 `getRecommendLive` 캐시 무효화를 연결하고, 커밋 이후에 evict 되도록 `afterCommit` 동기화를 적용했다.
|
||||
- 이슈 3 대응: `initializeDefaultPreference`에서 `member` row를 `PESSIMISTIC_WRITE`로 잠근 뒤 재조회/생성하도록 변경해 동시 최초 요청 경쟁에서도 단일 row만 생성되도록 보강했다.
|
||||
- 테스트 보강: `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`에 캐시 무효화/충돌 재조회/초기 생성 반영 케이스를 추가했다.
|
||||
- 사용자 요청 반영: 별도 계획 문서를 만들지 않고 기존 문서(`20260325_콘텐츠조회설정서버저장전환.md`)에 구현/검증 기록을 누적했다.
|
||||
- 왜:
|
||||
- readOnly 트랜잭션 참여로 저장이 누락될 수 있는 경로를 제거하고,
|
||||
선호 변경 이후 추천 캐시 stale을 즉시 해소하며,
|
||||
최초 row 생성 경쟁 시 unique 충돌이 사용자 오류로 노출되는 문제를 방지하기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew test ktlintCheck build`
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldEvictRecommendLiveCacheWhenPreferenceChangesByLegacyResolveForQuery" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldReturnReloadedPreferenceWhenRowIsCreatedByAnotherTransactionAfterLock" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest.shouldCreateRowAndReflectImmediatelyOnFirstLegacyResolveCall"`
|
||||
- 결과:
|
||||
- 타깃 테스트(서비스/통합) 통과.
|
||||
- 전체 검증 체인(`test`, `ktlintCheck`, `build`) 통과.
|
||||
- 수동 QA 성격의 핵심 시나리오 3건(legacy 변경 캐시 무효화, 생성 충돌 재조회, 최초 legacy 호출 즉시 반영) 재실행 통과.
|
||||
|
||||
### 7차 버그 수정 (요청 국가 정합화 + 강제 매핑 유지, 2026-03-26)
|
||||
- 무엇을:
|
||||
- 검색 경로 불일치 보정을 위해 `SearchController`/`SearchService`를 수정해, `resolveForQuery(...)`에서 계산된 `preference.isAdult`를 검색 쿼리에 그대로 전달하도록 변경했다.
|
||||
- `MemberContentPreferencePolicy`의 국가 결정을 `member.countryCode` 의존에서 제거하고, **강제 매핑 회원 ID(KR/JP) 우선 + 그 외 `CloudFront-Viewer-Country` 헤더 + `KR` fallback** 순서로 통일했다.
|
||||
- `MemberContentPreferenceService.resolveCountryCode`도 동일하게 **강제 매핑 우선 + 접속 국가 헤더 + KR fallback**으로 유지/정렬했다.
|
||||
- 사용자 지시(2번)대로 라이브 추천 캐시 키에 접속 국가를 반영하는 변경은 적용하지 않았고, 관련 시도 변경분은 모두 원복했다.
|
||||
- 회귀 고정을 위해 `MemberContentPreferencePolicyTest`, `SearchServiceTest`를 추가하고, `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`를 정책 기준에 맞게 보강했다.
|
||||
- 버그 수정 문서 전략은 별도 신규 문서 분리 대신, 기존 계획 문서(본 문서)에 구현/검증 기록을 누적하는 방식으로 확정했다.
|
||||
- 왜:
|
||||
- 검색 정책 계산에서 요청 국가와 멤버 저장 국가가 혼재되면 국가별 성인 노출 정책이 엇갈릴 수 있어, 정책 기준을 요청 흐름으로 일관화할 필요가 있었다.
|
||||
- 다만 운영 중인 강제 매핑 회원은 기존 정책 계약이므로 그대로 보존해야 했고, 캐시 키 국가 분리는 현재 우선순위에서 제외하라는 사용자 지시를 준수해야 했다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" --tests "kr.co.vividnext.sodalive.search.SearchServiceTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest"`
|
||||
- `./gradlew test`
|
||||
- `./gradlew ktlintCheck`
|
||||
- `./gradlew build`
|
||||
- 수동 QA 성격 검증: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest.shouldPrioritizeForcedCountryMapping" --tests "kr.co.vividnext.sodalive.search.SearchServiceTest.shouldUseProvidedIsAdultForContentSearch"`
|
||||
- 결과:
|
||||
- 정책/검색/통합/캐시 관련 타깃 테스트 통과.
|
||||
- 전체 `test`, `ktlintCheck`, `build` 통과.
|
||||
- 수동 QA 시나리오(강제 매핑 우선, 검색 isAdult 전달 고정) 통과.
|
||||
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
|
||||
|
||||
### 정정 (2026-03-26, 7차 중간 수정)
|
||||
- 무엇을:
|
||||
- `ktlintCheck` 1회 실패(테스트 파일 들여쓰기) 후 즉시 수정하고 재검증 결과를 반영한다.
|
||||
- 왜:
|
||||
- 7차 구현 중 테스트 파일 패치 과정에서 들여쓰기 불일치가 발생했기 때문이다.
|
||||
- 어떻게:
|
||||
- 실패 명령: `./gradlew ktlintCheck` (`MemberContentPreferenceServiceTest.kt` 들여쓰기 오류)
|
||||
- 조치: 해당 파일 들여쓰기 정정
|
||||
- 재실행: `./gradlew ktlintCheck` 성공
|
||||
|
||||
### 8차 리팩터링 (강제 매핑 국가 결정 로직 단일화, 2026-03-26)
|
||||
- 무엇을:
|
||||
- `MemberContentPreferenceService.resolveCountryCode(...)`와 `MemberContentPreferencePolicy.resolveCountryCodeByPolicy(...)`에 중복되어 있던 강제 매핑 국가 결정 로직을 공통 함수로 통합했다.
|
||||
- 신규 파일 `MemberContentPreferenceCountryResolver.kt`를 추가하고, 두 경로가 동일한 `resolveCountryCodeWithForcedMapping(...)`를 사용하도록 변경했다.
|
||||
- 왜:
|
||||
- 동일 정책 로직이 두 파일에 복제되어 있으면 한쪽만 수정될 때 운영 정책 불일치가 발생할 수 있어, 단일 소스로 유지보수 리스크를 줄이기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew ktlintCheck`
|
||||
- `./gradlew test`
|
||||
- `./gradlew build`
|
||||
- 결과:
|
||||
- 정책 관련 타깃 테스트 통과.
|
||||
- `ktlintCheck`, `test`, `build` 통과.
|
||||
- 참고: 병렬 실행 중 1회 테스트 리포트 파일 쓰기 충돌이 있었고(`:test`), 이후 `./gradlew test` 단독 재실행으로 정상 통과를 확인했다.
|
||||
|
||||
### 9차 정리 (MemberService 미사용 주입 제거, 2026-03-27)
|
||||
- 무엇을:
|
||||
- `MemberService` 생성자에서 실제로 사용되지 않던 `authRepository: AuthRepository` 주입을 제거했다.
|
||||
- 관련 import(`kr.co.vividnext.sodalive.member.auth.AuthRepository`)를 함께 제거했다.
|
||||
- 생성자 시그니처 변경에 맞춰 테스트 수동 생성부(`MemberServiceContentPreferenceTest`, `MemberServiceCacheEvictionTest`)의 인자 목록을 정렬했다.
|
||||
- 왜:
|
||||
- 미사용 주입을 유지하면 클래스 결합도와 유지보수 비용이 불필요하게 증가하고, 생성자 계약이 실제 책임보다 과도하게 커지기 때문이다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceContentPreferenceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"`
|
||||
- `./gradlew ktlintCheck`
|
||||
- `./gradlew test`
|
||||
- `./gradlew build`
|
||||
- 결과:
|
||||
- MemberService 관련 타깃 테스트 통과.
|
||||
- `ktlintCheck`, `test`, `build` 전체 통과.
|
||||
|
||||
### 10차 작업 계획 (communityPostLike 호출부 정합화, 2026-03-27)
|
||||
- [x] `CreatorCommunityService.communityPostLike` 호출부를 전수 탐색한다.
|
||||
- [x] 누락된 호출부에 `isAdult` 인자를 전달하도록 수정한다.
|
||||
- [x] 관련 테스트 및 전체 검증(`ktlintCheck`, `test`, `build`)을 수행한다.
|
||||
|
||||
### 10차 정합화 (communityPostLike 호출부 인자 반영, 2026-03-27)
|
||||
- 무엇을:
|
||||
- `CreatorCommunityService.communityPostLike(request, member, isAdult)` 호출부를 전수 확인해 누락 지점을 정리했다.
|
||||
- 운영 코드(`CreatorCommunityController`)는 이미 `isAdult` 전달이 되어 있어 유지했다.
|
||||
- 테스트 코드(`CreatorCommunityServiceTest`)의 구 시그니처 호출을 신 시그니처로 수정하고, 테스트 설명/목 객체를 현재 구조에 맞게 정리했다.
|
||||
- 왜:
|
||||
- 서비스 시그니처 변경 이후 호출부가 일부 구 버전 형태를 유지하면 컴파일 실패 또는 정책 불일치가 발생할 수 있기 때문이다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityControllerTest"`
|
||||
- `./gradlew ktlintCheck`
|
||||
- `./gradlew test`
|
||||
- `./gradlew build`
|
||||
- 결과:
|
||||
- CreatorCommunity 타깃 테스트 통과.
|
||||
- `ktlintCheck`, `test`, `build` 전체 통과.
|
||||
|
||||
### 코드리뷰 결과 (문서 목적 적합성/잠재 버그/일반 리뷰, 2026-03-27)
|
||||
- 무엇을:
|
||||
- 문서 요구사항(서버 저장값 전환, 국가 정책, legacy 호환 저장, 가입 선저장, `/member/info` 확장, `authVerify` 연동, 직접 설정 API)의 구현 여부를 코드 기준으로 대조 점검했다.
|
||||
- `git diff --cached`, `git diff` 기준 변경 파일 전체를 검토하고, 변경된 핵심 경로(`MemberContentPreferenceService`, `MemberController`, `MemberService`, `AuthController`, `Home/Live/Explorer/LiveRoom/Search`, 채팅/커뮤니티/태그 경로)를 우선 리뷰했다.
|
||||
- 실제 회귀 검증(`test`, `ktlintCheck`, `build`)을 다시 실행해 문서화했다.
|
||||
- 왜:
|
||||
- 체크리스트의 완료 표시(`[x]`)와 실제 구현 상태의 불일치, 그리고 변경분 내 정책 회귀 가능성을 배포 전에 제거하기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `git status --short`
|
||||
- `git diff --cached --name-only`
|
||||
- `git diff --name-only`
|
||||
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
|
||||
- 결과:
|
||||
- 문서 핵심 목적 항목은 코드상 대부분 구현되어 있으며, API/서비스/테스트 경로가 문서 체크리스트와 전반적으로 일치함을 확인했다.
|
||||
- 회귀 검증(`test`, `ktlintCheck`, `build`)은 모두 성공했다.
|
||||
|
||||
- 잠재 버그 1 (중요도: 중)
|
||||
- 위치:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt`
|
||||
- `@Cacheable(key = "'getRecommendLive:' + (#member?.id ?: 'guest')")`
|
||||
- 시나리오:
|
||||
- 동일 회원이 캐시 TTL(3시간) 내에 국가(`CloudFront-Viewer-Country`)가 달라진 요청을 보낼 때,
|
||||
국가별 정책으로 계산되는 `isAdult` 결과가 달라도 캐시 키가 동일해 이전 국가 결과를 재사용할 수 있다.
|
||||
- 예: US 요청에서 성인 추천이 캐시된 뒤 KR 요청에서도 동일 캐시를 반환.
|
||||
- 영향:
|
||||
- 국가별 성인 노출 정책 정합성이 깨질 수 있음(특히 요청 국가가 자주 바뀌는 환경/네트워크).
|
||||
- 제안:
|
||||
- 캐시 키에 정책 결정값(예: `countryCode` 또는 최종 `isAdult`)을 포함하거나,
|
||||
- 선호/국가 관련 변경 시 국가 차원을 포함한 캐시 무효화 전략을 추가.
|
||||
|
||||
- 잠재 버그 2 (중요도: 중)
|
||||
- 위치:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
||||
- `initializeDefaultPreference(...)`의 조회 순서(`findByMemberId` → `findByIdForUpdate` → `findByMemberId`)
|
||||
- 시나리오:
|
||||
- MySQL 기본 격리수준(REPEATABLE READ)에서 동일 회원에 대한 최초 동시 요청이 들어오면,
|
||||
첫 비잠금 조회 스냅샷이 유지되어 잠금 이후 재조회에서도 신규 row를 보지 못하고 중복 insert를 시도할 여지가 있다.
|
||||
- 영향:
|
||||
- 드물지만 최초 접근 경쟁 상황에서 unique key 충돌(`member_id`)로 간헐적 실패 가능.
|
||||
- 제안:
|
||||
- 잠금 획득을 선행한 뒤 선호 row를 조회하도록 순서를 변경하거나,
|
||||
- 선호 row 조회 자체를 `FOR UPDATE`로 수행하거나,
|
||||
- unique 충돌 예외를 잡아 재조회 후 반환하는 idempotent fallback을 추가.
|
||||
|
||||
- 일반 코드리뷰 코멘트
|
||||
- 정책/저장 로직을 `MemberContentPreferenceService`로 집중시킨 방향은 유지보수 관점에서 일관성이 좋다.
|
||||
- 다만 정책 계산이 "요청 국가"에 의존하는 경로는 캐시 키·무효화 정책과 항상 같이 검토되어야 하며,
|
||||
해당 항목은 운영 이슈 재발 방지를 위해 테스트(국가 전환 + 캐시 적중)까지 고정하는 것을 권장한다.
|
||||
|
||||
### 코드리뷰 재검증 보강 (2026-03-27)
|
||||
- 무엇을:
|
||||
- 앞서 기록한 잠재 버그 2건을 실제 구현 파일 기준으로 재검토하고, 재현 전제와 우선순위를 보강했다.
|
||||
- 왜:
|
||||
- 현재 브랜치에 추가 수정(리팩터링/테스트 보강)이 포함되어 있어, 기존 리뷰 결론의 유효성을 재확인할 필요가 있었기 때문이다.
|
||||
- 어떻게:
|
||||
- 확인 파일:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceRepository.kt`
|
||||
- 결과:
|
||||
- 잠재 버그 1(추천 캐시 키 국가 차원 누락)은 여전히 유효하다.
|
||||
- 근거: `LiveRecommendService.getRecommendLive`의 캐시 키가 `memberId`만 사용(`'getRecommendLive:' + memberId`)하고,
|
||||
조회 결과는 `getStoredPreference(member).isAdult`(요청 국가 영향)로 달라질 수 있다.
|
||||
- 전제: 동일 회원의 요청 국가가 TTL(3시간) 내 변경되는 환경.
|
||||
- 잠재 버그 2(초기 생성 경쟁 시 중복 insert 위험)도 여전히 유효하다.
|
||||
- 근거: `initializeDefaultPreference`가 `findByMemberId`(비잠금 조회) 이후 `findByIdForUpdate(member)`를 잡고,
|
||||
다시 `findByMemberId`(비잠금 조회)를 수행한다. MySQL REPEATABLE READ에서는 최초 스냅샷 영향으로
|
||||
잠금 이후 재조회가 최신 row를 못 보고 중복 insert를 시도할 수 있다.
|
||||
- 전제: 동일 회원 최초 접근이 동시 다발적으로 발생하는 경쟁 구간.
|
||||
|
||||
- 우선순위 제안:
|
||||
- P1: 잠재 버그 2 완화(간헐적 DB unique 충돌/500 위험) — 사용자 오류로 직접 노출될 수 있어 우선 대응 권장.
|
||||
- P2: 잠재 버그 1 보강(국가 전환 환경에서 정책 불일치 가능) — 운영 트래픽 특성(국가 전환 빈도)에 따라 단계 적용.
|
||||
|
||||
### 11차 작업 계획 (코드리뷰 잠재 버그 2건 보강, 2026-03-27)
|
||||
- [x] 추천 라이브 캐시 키를 `memberId + isAdult` 기준으로 분리하고 무효화 키와 테스트를 동기화한다.
|
||||
- [x] 선호 초기 row 생성 경로를 잠금 재조회 + unique 충돌 재조회 방식으로 보강한다.
|
||||
- [x] 관련 타깃 테스트 및 전체 검증(`ktlintCheck`, `test`, `build`)을 수행한다.
|
||||
|
||||
### 11차 보강 구현 (잠재 버그 1/2 대응, 2026-03-27)
|
||||
- 무엇을:
|
||||
- 잠재 버그 1 대응:
|
||||
- `LiveRecommendService`의 추천 조회 캐싱을 별도 빈 `LiveRecommendCacheService`로 분리하고,
|
||||
캐시 키를 `getRecommendLive:{memberId}:{isAdult}` 형식으로 변경했다.
|
||||
- 선호/차단 기반 무효화 경로(`MemberContentPreferenceService`, `MemberService`)를 `:false`, `:true` 키 양쪽 삭제로 확장했고,
|
||||
롤링 배포 중 잔존 캐시 정리를 위해 기존 `getRecommendLive:{memberId}` 키 삭제도 함께 유지했다.
|
||||
- 관련 테스트(`MemberContentPreferenceServiceTest`, `MemberServiceCacheEvictionTest`)를 신규 키 형식 기준으로 갱신했다.
|
||||
- 잠재 버그 2 대응:
|
||||
- `MemberContentPreferenceRepository`에 `findByMemberIdForUpdate`를 추가해 잠금 재조회 경로를 명시했다.
|
||||
- `MemberContentPreferenceService.initializeDefaultPreference`를
|
||||
`findByMemberId -> member lock -> findByMemberIdForUpdate -> saveAndFlush`로 보강하고,
|
||||
unique 충돌(`DataIntegrityViolationException`) 발생 시 재조회 후 반환하도록 fallback을 추가했다.
|
||||
- 경쟁 시나리오 회귀용 테스트(`shouldReturnStoredRowWhenDuplicateInsertOccurs`)를 추가했다.
|
||||
- 왜:
|
||||
- 동일 회원의 요청 정책 결과(`isAdult`)가 달라질 수 있는데 캐시 키가 memberId만 사용하면 stale 응답이 재사용될 수 있고,
|
||||
REPEATABLE READ 환경에서 최초 동시 생성 경쟁 시 unique 충돌이 간헐적으로 사용자 오류로 노출될 수 있기 때문이다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew ktlintCheck test build`
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldReturnStoredRowWhenDuplicateInsertOccurs" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest.shouldEvictRecommendLiveCacheForRequesterAndTargetOnBlock" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest.shouldDelegateToRepositoryWithAdultFlagWhenMemberIsAuthenticated"`
|
||||
- 결과:
|
||||
- 타깃 테스트 통과.
|
||||
- 전체 검증(`ktlintCheck`, `test`, `build`) 통과.
|
||||
- 핵심 수동 QA 성격 시나리오(중복 insert fallback, 차단 시 양쪽 캐시 무효화, 성인 플래그 전달 조회) 통과.
|
||||
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
|
||||
|
||||
### 정정 (2026-03-27, 11차 중간 수정)
|
||||
- 무엇을:
|
||||
- 11차 1차 테스트에서 `MemberContentPreferenceServiceTest` 검증문이 `save`를 확인하고 있어 실패한 항목을 `saveAndFlush` 검증으로 정정했다.
|
||||
- 왜:
|
||||
- 동시성 보강 과정에서 서비스 저장 호출이 `save`에서 `saveAndFlush`로 변경되었기 때문이다.
|
||||
- 어떻게:
|
||||
- 실패 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- 조치:
|
||||
- `MemberContentPreferenceServiceTest.shouldCreateDefaultPreferenceWhenRowIsMissing` 검증 대상을 `saveAndFlush`로 교체
|
||||
- 재실행:
|
||||
- 동일 타깃 테스트 명령 재실행 통과
|
||||
|
||||
### 12차 잠재 버그 재점검 (보강 후 재검토, 2026-03-27)
|
||||
- 무엇을:
|
||||
- 11차 보강 코드 재검토 중 `initializeDefaultPreference`의 unique 충돌 fallback 재조회가
|
||||
비잠금 조회(`findByMemberId`)로 남아 있던 지점을 추가 보강했다.
|
||||
- fallback 재조회를 `findByMemberIdForUpdate`로 변경해, REPEATABLE READ 스냅샷 영향으로 row를 못 보는 가능성을 낮췄다.
|
||||
- 회귀 테스트(`MemberContentPreferenceServiceTest.shouldReturnStoredRowWhenDuplicateInsertOccurs`)의 목 시퀀스를
|
||||
변경된 fallback 호출 순서에 맞게 업데이트했다.
|
||||
- 왜:
|
||||
- 충돌 예외 이후 같은 트랜잭션에서 비잠금 재조회를 수행하면 스냅샷 일관성 때문에 최신 row를 못 보고
|
||||
예외 재전파로 끝날 수 있어, 충돌 복구 경로의 신뢰성을 높일 필요가 있었기 때문이다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew ktlintCheck test build`
|
||||
- 결과:
|
||||
- preference 서비스/통합 타깃 테스트 통과.
|
||||
- 전체 검증(`ktlintCheck`, `test`, `build`) 통과.
|
||||
- Kotlin LSP 미구성으로 LSP 진단은 불가하여 Gradle 검증으로 대체했다.
|
||||
@@ -1,37 +0,0 @@
|
||||
# 20260325 회원 차단 요청 id만 적용
|
||||
|
||||
- [x] memberBlock 호출 흐름 및 동일 auth 일괄 차단 지점 확인
|
||||
- [x] memberBlock 로직을 request.id 단일 차단으로 수정
|
||||
- [x] 관련 테스트 보강 및 회귀 검증
|
||||
- [x] LSP 진단, 테스트, 빌드 검증 수행
|
||||
|
||||
## 2차 수정 체크리스트
|
||||
|
||||
- [x] `MemberService.memberBlock` 의미 단위 주석 추가
|
||||
- [x] `MemberServiceCacheEvictionTest` 신규 테스트 의미 단위 주석 추가
|
||||
- [x] 테스트 및 빌드 재검증
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: `MemberService.memberBlock`에서 동일 `auth` 기반 다중 계정 확장 차단을 제거하고, `request.blockMemberId` 1건만 차단/재활성화하도록 수정했다.
|
||||
- 왜: 회원 차단 API가 요청한 대상 ID만 차단해야 하며, 동일 auth 계정 전체가 함께 차단되는 과차단 동작을 제거해야 하기 때문이다.
|
||||
- 어떻게:
|
||||
- 탐색: explore 2개 + librarian 1개 백그라운드 분석, `grep`/`ast-grep`/`glob`로 호출 흐름과 확장 지점 확인.
|
||||
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`의 `memberBlock`에서 `authRepository.getMemberIdsByNameAndBirthAndDiAndGender(...)` 및 다중 루프 제거.
|
||||
- 테스트 변경: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt`에 `shouldBlockOnlyRequestedMemberEvenWhenTargetHasAuth` 추가.
|
||||
- 진단: `lsp_diagnostics` 실행 시 `.kt` LSP 서버 미구성으로 진단 불가 확인.
|
||||
- 검증 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
|
||||
### 2차 수정
|
||||
- 무엇을: 1차에서 작성한 `memberBlock` 변경 코드와 회귀 테스트 코드에 의미 단위 주석을 추가했다.
|
||||
- 왜: 요청하신 대로 작성된 코드의 의도를 블록 단위로 바로 파악할 수 있도록 하기 위해서다.
|
||||
- 어떻게:
|
||||
- 코드 변경: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`의 `memberBlock`에 검증/단일대상차단/캐시무효화 의도 주석 추가.
|
||||
- 코드 변경: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt`의 `shouldBlockOnlyRequestedMemberEvenWhenTargetHasAuth`에 준비/실행/검증 주석 추가.
|
||||
- 진단: `lsp_diagnostics` 실행 시 `.kt` LSP 서버 미구성으로 진단 불가 확인.
|
||||
- 검증 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
@@ -1,30 +0,0 @@
|
||||
SET @schema_name := DATABASE();
|
||||
|
||||
SET @table_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'member_content_preference'
|
||||
);
|
||||
|
||||
SET @create_table_sql := IF(
|
||||
@table_exists = 0,
|
||||
'CREATE TABLE member_content_preference (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
|
||||
member_id BIGINT NOT NULL COMMENT ''회원 ID (member.id 참조)'',
|
||||
is_adult_content_visible TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''성인 콘텐츠 노출 여부 (0: 비노출, 1: 노출)'',
|
||||
content_type VARCHAR(20) NOT NULL DEFAULT ''ALL'' COMMENT ''콘텐츠 타입 필터 값'',
|
||||
adult_content_visibility_changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''성인 콘텐츠 노출 설정 변경 시각'',
|
||||
content_type_changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''콘텐츠 타입 설정 변경 시각'',
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_member_content_preference_member_id (member_id),
|
||||
CONSTRAINT fk_member_content_preference_member_id FOREIGN KEY (member_id) REFERENCES member (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''회원 콘텐츠 조회 설정''',
|
||||
'SELECT ''member_content_preference already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE create_table_stmt FROM @create_table_sql;
|
||||
EXECUTE create_table_stmt;
|
||||
DEALLOCATE PREPARE create_table_stmt;
|
||||
@@ -1,102 +0,0 @@
|
||||
# 20260327 멤버 콘텐츠 선호 기본값 조정
|
||||
|
||||
## 목적
|
||||
- `MemberContentPreference` 신규 생성 기본값을 다음 정책으로 고정한다.
|
||||
- 기존 회원 + `member.auth != null` 인 경우: `isAdultContentVisible = true`, `contentType = ContentType.ALL`
|
||||
- 그 외: `isAdultContentVisible = false`, `contentType = ContentType.ALL`
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] 기본값 시드 로직을 `member.auth` 기준 정책으로 단순화한다.
|
||||
- QA: row 미존재 + 인증/미인증 케이스에서 저장값이 각각 `true/ALL`, `false/ALL`인지 테스트로 확인
|
||||
- [x] 레거시 조회 파라미터(`isAdultContentVisible`, `contentType`)가 신규 row 기본값에 영향을 주지 않도록 정리한다.
|
||||
- QA: `resolveForQuery` 호출 시 파라미터 전달 여부와 무관하게 정책 기본값으로 생성되는지 확인
|
||||
- [x] 관련 단위/통합 테스트 기대값을 정책에 맞게 수정한다.
|
||||
- QA: `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest` 통과
|
||||
- [x] 회귀 검증을 실행한다.
|
||||
- QA: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`, `./gradlew build` 성공
|
||||
|
||||
## 구현 완료 후 기록
|
||||
### 1차 구현
|
||||
- 무엇을:
|
||||
- `MemberContentPreferenceService.initializeDefaultPreference`의 기본 seed를 `member.auth != null` 기준으로 변경해 인증 회원은 `true/ALL`, 그 외는 `false/ALL`로 생성되도록 수정했다.
|
||||
- `resolveForQuery`의 신규 row 생성 seed 계산에서 legacy 파라미터를 제거하고 `member.auth` 기반 고정 정책(`true/ALL` 또는 `false/ALL`)만 사용하도록 정리했다.
|
||||
- `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`의 관련 시나리오를 정책에 맞게 수정했다.
|
||||
- 왜:
|
||||
- 요청사항이 “기존 회원가입 + `member.auth != null`이면 `true/ALL`, 그 외는 `false/ALL`”로 명확하여, 신규 row 기본값이 요청 파라미터에 영향을 받지 않도록 일관된 기준으로 통일해야 했기 때문이다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest.shouldSeedPreferenceToTrueAndAllWhenRowMissingAndAuthenticatedRegardlessOfLegacyParams"`
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew build`
|
||||
- 결과:
|
||||
- 정책 핵심 시나리오 단일 테스트 통과.
|
||||
- 대상 단위/통합 테스트 통과.
|
||||
- 전체 build(테스트/ktlint 포함) 통과.
|
||||
- `.kt` 확장자용 LSP 서버가 현재 환경에 없어 `lsp_diagnostics`는 실행 불가였고, 대신 Gradle 검증으로 정합성을 확인했다.
|
||||
|
||||
## 연계 작업(동일 기능)
|
||||
### 2차 구현 - `resolveForQuery` 조회 파라미터 제거
|
||||
- 무엇을:
|
||||
- `MemberContentPreferenceService.resolveForQuery` 시그니처에서 미사용 파라미터 2개
|
||||
(`isAdultContentVisible`, `contentType`)를 제거하고 `member` 단일 파라미터로 정리했다.
|
||||
- 시그니처 변경에 맞춰 서비스/컨트롤러/테스트의 `resolveForQuery` 호출부 인자 전달 코드를 일괄 정리했다.
|
||||
- 왜:
|
||||
- 실제로 사용되지 않는 파라미터를 제거해 함수 계약을 단순화하고, 호출부 가독성과 유지보수성을 높이기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew compileKotlin compileTestKotlin`
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew build`
|
||||
- `lsp_diagnostics(filePath="src/main/kotlin", extension=".kt")`
|
||||
- 결과:
|
||||
- 시그니처 변경 직후 컴파일 에러로 표시된 호출부를 모두 정리한 뒤 `compileKotlin/compileTestKotlin` 성공.
|
||||
- 관련 단위/통합 테스트 통과.
|
||||
- 전체 build(ktlint/test 포함) 성공.
|
||||
- 현재 환경에는 Kotlin LSP 서버가 없어 `lsp_diagnostics`는 실행 불가였고,
|
||||
Gradle 컴파일/테스트/빌드로 정합성을 확인했다.
|
||||
|
||||
### 3차 구현 - 수정 파일 미사용 파라미터 정리
|
||||
- 무엇을:
|
||||
- `resolveForQuery(member = member)`로 단순화된 이후 미사용 상태가 된
|
||||
`resolvePreference` 헬퍼 파라미터를 12개 파일에서 제거했다.
|
||||
- 헬퍼 호출부를 정리했고, null 회원 분기에서 실제로 파라미터를 사용하는 서비스/컨트롤러
|
||||
(`HomeService`, `LiveApiService`, `AudioContentController`, `AudioContentMainTabHomeController`)는
|
||||
기존 전달 로직을 유지했다.
|
||||
- 왜:
|
||||
- 사용되지 않는 파라미터는 경고와 혼선을 유발해 유지보수 비용을 높이므로,
|
||||
실제 사용하는 함수 계약만 남겨 코드 의도를 명확히 하기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew compileKotlin`
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew build`
|
||||
- `lsp_diagnostics(filePath="src/main/kotlin", extension=".kt")`
|
||||
- 결과:
|
||||
- `compileKotlin` 성공.
|
||||
- 관련 단위/통합 테스트 성공.
|
||||
- 전체 build(ktlint/test 포함) 성공.
|
||||
- 현재 환경에 Kotlin LSP 서버가 없어 `lsp_diagnostics`는 실행 불가였고,
|
||||
Gradle 컴파일/테스트/빌드 결과로 정합성을 확인했다.
|
||||
|
||||
### 4차 수정 - 잔여 미사용 파라미터 추가 정리
|
||||
- 무엇을:
|
||||
- 3차 정리 이후에도 남아 있던 수정 파일 내 함수 미사용 파라미터를 추가 제거했다.
|
||||
- `resolvePreference(member: Member)`만 사용하는 컨트롤러들의
|
||||
`@RequestParam("isAdultContentVisible")`, `@RequestParam("contentType")`를 제거하고 import를 정리했다.
|
||||
- `ExplorerService.getCreatorProfile`의 미사용 파라미터 `isAdultContentVisible`을 제거하고
|
||||
`ExplorerController` 호출부를 함께 수정했다.
|
||||
- 왜:
|
||||
- 실제 로직에서 사용되지 않는 파라미터를 제거해 함수 계약을 단순화하고,
|
||||
유지보수 시 혼선을 줄이기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew compileKotlin compileTestKotlin`
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew build`
|
||||
- `lsp_diagnostics(filePath="src/main/kotlin", extension=".kt")`
|
||||
- 결과:
|
||||
- `compileKotlin`, `compileTestKotlin` 성공.
|
||||
- 관련 단위/통합 테스트 성공.
|
||||
- 전체 build(ktlint/test 포함) 성공.
|
||||
- 현재 환경에 Kotlin LSP 서버가 없어 `lsp_diagnostics`는 실행 불가였고,
|
||||
Gradle 검증으로 정합성을 확인했다.
|
||||
@@ -1,46 +0,0 @@
|
||||
# 20260327 멤버 콘텐츠 선호 신규 생성 정책 수정
|
||||
|
||||
## 목적
|
||||
- `resolveForQuery` 레거시 파라미터를 기존 row 갱신 용도로 사용하지 않고, **row 미존재 최초 생성 시에만** 제한적으로 사용한다.
|
||||
- 최종 목표인 "MemberContentPreference 저장값만 조회에 사용" 방향으로 정책을 단순화한다.
|
||||
|
||||
## 최종 정책
|
||||
- [x] `MemberContentPreference` 없음 + `member.auth != null`
|
||||
- 요청 파라미터(`isAdultContentVisible`, `contentType`)가 있으면 전달값으로 생성한다.
|
||||
- 요청 파라미터가 없으면 `isAdultContentVisible = true`, `contentType = ContentType.ALL`로 생성한다.
|
||||
- [x] `MemberContentPreference` 없음 + `member.auth == null`
|
||||
- `isAdultContentVisible = false`, `contentType = ContentType.ALL`로 생성한다.
|
||||
- [x] `MemberContentPreference` 있음
|
||||
- `resolveForQuery`로 들어온 요청 파라미터는 무시하고 저장값만 사용한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] `MemberContentPreferenceService` 생성 경로(`initializeDefaultPreference`)가 초기값을 정책 기반으로 받을 수 있도록 수정
|
||||
- QA: `resolveForQuery` 호출 시 row 유/무에 따른 생성값이 테스트에서 일치하는지 확인
|
||||
- [x] `resolveForQuery`에서 기존 row에 대한 레거시 파라미터 반영/캐시 무효화 제거
|
||||
- QA: 기존 row + 파라미터 입력 시 저장값 불변 및 캐시 미무효화 테스트 통과
|
||||
- [x] 관련 단위/통합 테스트 갱신
|
||||
- QA: `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest` 통과
|
||||
- [x] 회귀 검증 실행
|
||||
- QA: `./gradlew test`, `./gradlew ktlintCheck`, `./gradlew build` 성공
|
||||
|
||||
## 구현 완료 후 기록
|
||||
### 1차 구현
|
||||
- 무엇을:
|
||||
- `MemberContentPreferenceService`에 `PreferenceSeed`를 도입해 row 미존재 시 초기 생성값을 호출 목적에 맞게 주입하도록 변경했다.
|
||||
- `resolveForQuery`는 더 이상 기존 row를 요청 파라미터로 갱신하지 않고, 저장값 조회 전용으로 동작하도록 수정했다.
|
||||
- row 미존재 시 seed 정책을 다음과 같이 반영했다.
|
||||
- `member.auth != null` + legacy 파라미터 존재: 전달값 기반 생성
|
||||
- `member.auth != null` + legacy 파라미터 미존재: `true/ALL` 생성
|
||||
- `member.auth == null`: 파라미터와 무관하게 `false/ALL` 생성
|
||||
- `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`를 정책에 맞게 갱신/추가했다.
|
||||
- 왜:
|
||||
- 기존 row를 조회 API 파라미터로 계속 갱신하면 "저장값 단일 기준" 목표와 충돌하므로, 레거시 파라미터 역할을 row 최초 생성 시점으로 한정하기 위해서다.
|
||||
- 기존 회원 중 row 미존재 사용자의 초기 생성 경로를 명시적으로 제어해 운영 일관성을 확보하기 위해서다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"`
|
||||
- `./gradlew test && ./gradlew ktlintCheck && ./gradlew build`
|
||||
- 결과:
|
||||
- 정책 관련 단위/통합 테스트 통과.
|
||||
- 전체 회귀 검증(`test`, `ktlintCheck`, `build`) 통과.
|
||||
- `.kt` 대상 LSP 서버가 현재 환경에 없어 Kotlin LSP 진단은 수행 불가였고, 대신 Gradle 검증으로 대체했다.
|
||||
@@ -1,50 +0,0 @@
|
||||
# 라이브 진행중 목록 19금 노출 정책 수정
|
||||
|
||||
## 완료 기준 (Pass/Fail)
|
||||
- [x] `LiveRoomStatus.NOW` 조회 시 사용자 성인 설정과 무관하게 19금 라이브 방이 포함된다.
|
||||
- [x] 예약 조회(`getLiveRoomListReservationWithDate`, `getLiveRoomListReservationWithoutDate`)의 성인 설정 필터 동작은 기존과 동일하다.
|
||||
- [x] 기존 코드 패턴을 유지하며 최소 범위로 변경된다.
|
||||
- [x] 변경 파일 LSP 진단 에러가 0건이다. *(Kotlin LSP 미지원 환경으로 `lsp_diagnostics` 실행 불가, 테스트/빌드 성공으로 대체 검증)*
|
||||
- [x] 관련 테스트/빌드 검증 명령이 성공한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] NOW/예약 목록 분기 및 성인 필터 전달 경로를 확인한다.
|
||||
- [x] NOW 목록 조회 경로만 정책에 맞게 수정한다. *(QA: NOW 경로 호출 인자 검증)*
|
||||
- [x] 예약 목록 조회 경로가 기존 로직을 유지하는지 검증한다. *(QA: 예약 경로 호출 인자/쿼리 유지 확인)*
|
||||
- [x] 익명 사용자(member=null) NOW 조회에서 성인 필터 우회 범위가 과도하지 않도록 조건을 보강한다. *(2차 가정, 3차에서 정책 정정됨)*
|
||||
- [x] 정책 정정 반영: NOW 목록은 익명 사용자도 노출 대상이며, 후속 상세/입장 단계에서 인증/성인 검증을 수행하도록 분기와 테스트를 재정렬한다.
|
||||
- [x] `FORCED_JP_MEMBER_IDS`의 `37543L` 강제 매핑 회귀 테스트를 추가한다. *(QA: 정책/통합 테스트에 ID 37543L 검증 추가)*
|
||||
- [x] 관련 테스트와 빌드 검증을 수행하고 결과를 문서에 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
### 1차 구현
|
||||
- 무엇을: `LiveRoomService.getRoomList`의 NOW 분기에서 `isAdult = true`를 전달하도록 수정하고, 예약 분기는 기존 `isAdult` 전달을 유지했다. 또한 NOW/예약 전달 정책을 검증하는 `LiveRoomServiceAdultVisibilityPolicyTest`를 추가했다.
|
||||
- 왜: 진행 중 라이브 목록은 사용자 성인 설정과 무관하게 19금 방을 노출하고, 예약 목록은 기존 정책대로 사용자 설정을 반영해야 하기 때문이다.
|
||||
- 어떻게:
|
||||
- 전달값 확인: `grep`으로 NOW/예약 분기의 `isAdult` 전달값 확인 (`isAdult = true` / `isAdult = isAdult`).
|
||||
- LSP 진단 시도: `lsp_diagnostics` for `LiveRoomService.kt`, `LiveRoomServiceAdultVisibilityPolicyTest.kt` → **불가(환경에 Kotlin LSP 서버 미구성)**
|
||||
- 정책 단위 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest"` → **성공(BUILD SUCCESSFUL)**
|
||||
- 관련 선호도 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` → **성공(BUILD SUCCESSFUL)**
|
||||
- 전체 빌드: `./gradlew build` → **성공(BUILD SUCCESSFUL)**
|
||||
|
||||
### 2차 수정 (리뷰 피드백 반영)
|
||||
- 무엇을: `LiveRoomService.getRoomList`의 NOW 분기에서 `isAdult` 전달값을 `member != null || isAdult`로 조정해 로그인 사용자에게만 우회가 적용되도록 보강했다. 또한 `LiveRoomServiceAdultVisibilityPolicyTest`에 비로그인 NOW 조회 회귀 케이스를 추가하고, `MemberContentPreferencePolicyTest`/`MemberContentPreferenceIntegrationTest`에 `37543L -> JP` 강제 매핑 검증을 추가했다.
|
||||
- 왜: 기존 `isAdult = true` 고정은 익명 사용자까지 성인 진행중 라이브를 노출할 수 있어 정책 범위가 과도해질 수 있으며, 강제 JP ID 추가(`37543L`)는 테스트로 고정해 회귀를 방지해야 하기 때문이다.
|
||||
- 어떻게:
|
||||
- LSP 진단 시도: `lsp_diagnostics` for 변경된 Kotlin 파일 4개 → **불가(환경에 Kotlin LSP 서버 미구성)**
|
||||
- 정책/회귀 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` → **성공(BUILD SUCCESSFUL)**
|
||||
- 전체 빌드(ktlint 포함): `./gradlew build` → **성공(BUILD SUCCESSFUL)**
|
||||
|
||||
### 정정
|
||||
- 정정 대상: `2차 수정 (리뷰 피드백 반영)`의 정책 가정(익명 NOW 노출 제한)
|
||||
- 사유: 요구사항 재확인 결과, NOW 목록에서 익명 사용자 노출은 의도된 기능이며 상세/입장 단계에서 인증 및 성인 검증을 수행하는 정책으로 확정되었다.
|
||||
- 변경 내용: NOW 분기의 익명 제한 보강(`member != null || isAdult`)을 제거하고, 익명 포함 우회(`isAdult = true`)로 복원했다. 관련 회귀 테스트도 익명 우회 기대값으로 정렬했다.
|
||||
|
||||
### 3차 수정 (정책 정정 반영)
|
||||
- 무엇을: `LiveRoomService.getRoomList` NOW 분기의 `isAdult` 전달값을 `isAdult = true`로 복원했다. `LiveRoomServiceAdultVisibilityPolicyTest`의 익명 NOW 케이스를 `isAdult = true` 기대로 수정하고, 테스트명/DisplayName을 정책 의미에 맞게 변경했다.
|
||||
- 왜: NOW 목록은 익명 사용자에게도 노출하되, 실제 터치 후 상세/입장 단계에서 인증 및 성인 검증(`live.room.adult_verification_required`)을 수행하는 것이 의도된 정책이기 때문이다.
|
||||
- 어떻게:
|
||||
- 탐색 근거 수집: Explore/Librarian + `grep` + `sg`로 NOW 노출 경로, 후속 인증 가드, 테스트 기대값을 재확인했다. (`rg`는 실행 환경에 미설치로 대체 탐색 수행)
|
||||
- LSP 진단 시도: `lsp_diagnostics` for 변경된 Kotlin 파일들 → **불가(환경에 Kotlin LSP 서버 미구성)**
|
||||
- 정책/회귀 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` → **성공(BUILD SUCCESSFUL)**
|
||||
- 전체 빌드(ktlint 포함): `./gradlew build` → **성공(BUILD SUCCESSFUL)**
|
||||
@@ -1,24 +0,0 @@
|
||||
# 채널 후원 내역 탈퇴 닉네임 접두사 제거
|
||||
|
||||
## 완료 기준 (Pass/Fail)
|
||||
- [x] 채널 후원 내역 리스트 조회 응답에서 탈퇴 회원 닉네임의 `deleted_` 접두사가 제거된다.
|
||||
- [x] 비탈퇴 회원 닉네임은 기존과 동일하게 노출된다.
|
||||
- [x] 기존 코드베이스의 유사 처리 패턴과 동일한 방식으로 구현된다.
|
||||
- [x] 변경 파일 LSP 진단 에러가 0건이다. *(Kotlin LSP 미지원 환경으로 `lsp_diagnostics` 실행 불가, `./gradlew build` 성공으로 대체 검증)*
|
||||
- [x] 관련 테스트/빌드 검증 명령이 성공한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] `deleted_` 닉네임 처리 유사 구현 위치를 전수 탐색한다.
|
||||
- [x] 채널 후원 내역 조회 응답 생성 경로를 확인한다.
|
||||
- [x] 조회 시점에 닉네임 접두사 제거 로직을 반영한다.
|
||||
- [x] 변경사항 검증 후 체크리스트를 완료 처리한다.
|
||||
|
||||
## 검증 기록
|
||||
### 1차 구현
|
||||
- 무엇을: 채널 후원 내역 조회 응답의 탈퇴 회원 닉네임에서 `deleted_` 접두사를 제거하고, 동일 동작을 검증하는 테스트를 추가했다.
|
||||
- 왜: 탈퇴 회원 닉네임이 API 응답에 내부 저장 포맷(`deleted_`) 그대로 노출되는 문제를 해결하기 위해서다.
|
||||
- 어떻게:
|
||||
- `lsp_diagnostics` 실행 시도: `ChannelDonationService.kt` 대상 실행 → **불가(환경에 Kotlin LSP 서버 미구성)**
|
||||
- 기능 집중 테스트 실행: `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest.shouldRemoveDeletedPrefixFromNicknameInDonationList"` → **성공(BUILD SUCCESSFUL)**
|
||||
- 관련 테스트 실행: `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationControllerTest"` → **성공(BUILD SUCCESSFUL)**
|
||||
- 전체 빌드 실행: `./gradlew build` → **성공(BUILD SUCCESSFUL)**
|
||||
@@ -1,44 +0,0 @@
|
||||
# 20260328 콘텐츠 조회 파라미터 제거 및 비로그인 기본값 고정
|
||||
|
||||
## 목적
|
||||
- 모든 API에서 `isAdultContentVisible`, `contentType` 요청 파라미터를 제거한다.
|
||||
- 비로그인 사용자는 항상 `isAdultContentVisible = false`, `contentType = ContentType.ALL`로 처리한다.
|
||||
- 로그인 사용자는 기존과 동일하게 `MemberContentPreference` 기반 로직을 유지한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] `isAdultContentVisible`, `contentType`를 받는 잔여 API 시그니처를 모두 제거한다.
|
||||
- QA: `grep("@RequestParam(\"isAdultContentVisible\"|@RequestParam(\"contentType\")")` 결과가 0인지 확인
|
||||
- [x] 연관 서비스 메서드 시그니처/호출부를 정리한다.
|
||||
- QA: `compileKotlin` 성공으로 시그니처 불일치가 없는지 확인
|
||||
- [x] 비로그인 기본값을 `false/ALL`로 고정한다.
|
||||
- QA: 익명 분기 `ViewerContentPreference(false/ALL)` 코드 확인 + 관련 테스트 통과
|
||||
- [x] 로그인 분기는 기존 `memberContentPreferenceService.resolveForQuery(member = member)` 흐름을 유지한다.
|
||||
- QA: 관련 컨트롤러/서비스에서 로그인 분기 호출 유지 확인
|
||||
- [x] 회귀 검증을 수행한다.
|
||||
- QA: `./gradlew test`, `./gradlew build` 성공
|
||||
|
||||
## 구현 완료 후 기록
|
||||
### 1차 구현
|
||||
- 무엇을:
|
||||
- 잔여 API 파라미터를 전부 제거했다.
|
||||
- `HomeController`, `LiveApiController`, `LiveRoomController`, `AudioContentController`, `AudioContentMainTabHomeController`
|
||||
- 연관 서비스 시그니처와 호출부를 정리했다.
|
||||
- `HomeService`, `LiveApiService`, `LiveRoomService`
|
||||
- 비로그인 분기 기본값을 `ViewerContentPreference(isAdultContentVisible = false, contentType = ContentType.ALL, isAdult = false)`로 고정했다.
|
||||
- 왜:
|
||||
- 요청사항이 “모든 API에서 해당 파라미터 제거 + 비로그인 기본값 고정 + 로그인 기존 동작 유지”로 명확했기 때문이다.
|
||||
- 어떻게:
|
||||
- 명령:
|
||||
- `./gradlew compileKotlin compileTestKotlin`
|
||||
- `grep("@RequestParam(\"isAdultContentVisible\"|@RequestParam(\"contentType\")", include="*Controller.kt")`
|
||||
- `ast-grep: ViewerContentPreference(isAdultContentVisible = false, contentType = ContentType.ALL)`
|
||||
- `./gradlew test`
|
||||
- `./gradlew build`
|
||||
- `lsp_diagnostics`(수정된 `.kt` 파일 대상)
|
||||
- 결과:
|
||||
- 컴파일 성공(`compileKotlin`, `compileTestKotlin`).
|
||||
- 컨트롤러의 `@RequestParam("isAdultContentVisible")`, `@RequestParam("contentType")` 검색 결과 0건.
|
||||
- 비로그인 기본값 고정 분기 5개 위치 확인(`HomeService`, `LiveApiService`, `LiveRoomService`, `AudioContentController`, `AudioContentMainTabHomeController`).
|
||||
- `./gradlew test` 성공.
|
||||
- `./gradlew build` 성공.
|
||||
- 현재 환경은 Kotlin LSP 서버 미구성으로 `lsp_diagnostics(.kt)` 실행 불가였고, Gradle 컴파일/테스트/빌드로 정합성 검증 완료.
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE live_room
|
||||
ADD COLUMN is_capture_recording_available TINYINT(1) NOT NULL DEFAULT 0 COMMENT '캡쳐/녹화 가능 여부';
|
||||
@@ -1,20 +0,0 @@
|
||||
# 라이브 캡쳐/녹화 설정 추가
|
||||
|
||||
## 구현 항목
|
||||
- [x] 라이브 생성/수정/조회 관련 기존 필드 및 흐름 분석
|
||||
- [x] 라이브 정보에 캡쳐/녹화 단일 가능 여부 플래그 추가
|
||||
- [x] 라이브 생성 시에만 캡쳐/녹화 가능 여부를 설정하도록 반영
|
||||
- [x] DB 컬럼 추가 DDL 작성
|
||||
- [x] 관련 테스트 코드 보강
|
||||
- [x] 정적 진단/테스트/빌드 검증 수행
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: 라이브 생성 요청(`CreateLiveRoomRequest`)과 라이브 엔티티(`LiveRoom`)에 `isCaptureRecordingAvailable` 단일 플래그를 추가하고, 라이브 정보 응답(`GetRoomInfoResponse`)에 동일 플래그를 노출하도록 반영했다.
|
||||
- 왜: 캡쳐/녹화를 분리하지 않고 하나의 설정값으로 관리하면서, 해당 값이 생성 시점에만 결정되도록 하기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest"` 실행 결과: 성공
|
||||
- `./gradlew build` 실행 결과: 성공
|
||||
- 수동 QA(서비스 단위): `shouldPersistCaptureAndRecordingAvailabilityOnCreate`, `shouldIncludeCaptureAndRecordingAvailabilityInRoomInfo` 테스트로 생성 저장값/정보 응답값 확인
|
||||
- `lsp_diagnostics` 실행 결과: `.kt` LSP 서버 미구성으로 실행 불가(대신 Gradle 컴파일·ktlint·test·build 통과로 검증)
|
||||
@@ -1,50 +0,0 @@
|
||||
# 애플 로그인 aud 검증 실패 원인 분석
|
||||
|
||||
## 구현/분석 항목
|
||||
- [x] `/member/login/apple` 요청 흐름과 `AppleIdentityTokenVerifier` 검증 로직을 확인한다.
|
||||
QA: 관련 코드 경로와 실제 비교값(`audience` vs 설정값)을 파일 근거로 정리한다.
|
||||
- [x] Apple Identity Token의 `aud` 규칙(웹 Service ID / 네이티브 Bundle ID)을 확인해 실패 원인을 확정한다.
|
||||
QA: 공식 문서/신뢰 가능한 레퍼런스 근거를 함께 기록한다.
|
||||
- [x] 필요 시 서버 검증 로직을 수정해 웹/앱 로그인 환경과 일치시키고, 불필요하면 수정하지 않는다.
|
||||
QA: 수정 전/후 조건을 비교해 실패 지점 해소 여부를 설명한다.
|
||||
- [x] 변경 사항에 대해 정적/실행 검증을 수행한다.
|
||||
QA: 실행 명령과 성공/실패 결과를 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
- 1차 분석: 진행 전
|
||||
- 무엇을: 애플 로그인 aud 검증 실패 재현 경로 분석을 시작했다.
|
||||
- 왜: 62번째 줄 audience 검증 실패 원인을 코드/설정/외부 규격 기준으로 확정하기 위해서다.
|
||||
- 어떻게: 코드 검색, 외부 문서 조사, 필요 시 테스트/빌드 검증을 수행할 계획이다.
|
||||
|
||||
- 2차 분석: 실패 원인 확정
|
||||
- 무엇을: `/member/login/apple` 호출 경로와 Apple 토큰 audience 비교 대상을 확인했다.
|
||||
- 왜: 실제 실패 지점이 검증 로직 문제인지, 설정 누락인지를 분리하기 위해서다.
|
||||
- 어떻게: `MemberController.loginApple` → `AppleAuthService.authenticate` → `AppleIdentityTokenVerifier.validateClaims` 흐름을 확인했고,
|
||||
`claims.audience.contains(bundleId)`(기존 62줄) 비교가 `apple.bundle-id` 단일값에만 의존함을 확인했다.
|
||||
|
||||
- 3차 분석: 외부 규격 대조
|
||||
- 무엇을: Apple 공식 문서 기준으로 `id_token.aud` 의미를 확인했다.
|
||||
- 왜: 웹 로그인에서 `aud` 기대값이 Bundle ID인지 Service ID인지 확정해야 수정 기준이 생긴다.
|
||||
- 어떻게: Apple 문서에서 `aud == client_id`, 웹 Sign in with Apple JS는 `client_id`로 Service ID를 사용함을 확인했다.
|
||||
따라서 웹 토큰의 `aud`가 Service ID일 때 기존 bundleId 단일 비교는 실패가 정상임을 확정했다.
|
||||
|
||||
- 4차 구현: 검증 로직 보완
|
||||
- 무엇을: Apple 로그인 audience 검증 대상을 `bundleId` + `serviceId`로 확장했다.
|
||||
- 왜: 웹(Service ID)과 앱(Bundle ID) 토큰 모두 동일 백엔드 검증 로직에서 처리하기 위해서다.
|
||||
- 어떻게:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt`
|
||||
- `@Value("\${apple.service-id:}")` 추가
|
||||
- `resolveExpectedAudiences()`로 유효 audience 집합 생성
|
||||
- `isSupportedAudience()`로 `claims.audience` 교집합 검증
|
||||
- `src/main/resources/application.yml`
|
||||
- `apple.serviceId: ${APPLE_SERVICE_ID:}` 추가
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifierTest.kt`
|
||||
- bundleId/serviceId 허용 및 미일치 거부 케이스 추가
|
||||
|
||||
- 5차 검증: 정적/실행 확인
|
||||
- 무엇을: 변경 코드의 테스트/린트/빌드를 수행했다.
|
||||
- 왜: audience 로직 변경이 실제로 컴파일/테스트/스타일 검증을 통과하는지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- `lsp_diagnostics` (Kotlin 파일): 로컬 환경에 `.kt` LSP 서버 미설정으로 도구 진단 불가(환경 제약 확인)
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.member.social.apple.AppleIdentityTokenVerifierTest"` → 성공
|
||||
- `./gradlew ktlintCheck build -x test` → 성공
|
||||
@@ -1,22 +0,0 @@
|
||||
- [x] chat 패키지의 AI 캐릭터 상세/채팅 본인인증 적용 지점을 확인한다.
|
||||
- [x] 기존 캐릭터 상세의 국가별 본인인증 분기 방식을 확인한다.
|
||||
- [x] chat 패키지의 AI 캐릭터 및 AI 캐릭터 채팅 로직에 동일한 국가별 인증 방식을 반영한다.
|
||||
- [x] 변경 사항에 대한 진단 및 관련 검증을 수행한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: `ChatRoomController`, `ChatQuotaController`, `ChatRoomQuotaController`의 본인인증 체크를 `member.auth` 직접 검사에서 `MemberContentPreferenceService.getStoredPreference(member).isAdult` 기반 국가별 판정으로 변경했다.
|
||||
- 왜: AI 캐릭터 상세와 동일하게 한국은 본인인증이 필요하고, 그 외 국가는 저장된 성인 노출 설정 기준으로 접근하도록 맞추기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew compileKotlin` → 성공
|
||||
- `./gradlew test` → 성공
|
||||
- 변경 컨트롤러 3개에서 `member.auth == null` 직접 검사가 제거되고 `resolveIsAdultAccessible(...)`로 치환된 것을 확인함
|
||||
|
||||
### 2차 수정
|
||||
- 무엇을: `OriginalWorkController`의 목록/상세 본인인증 체크도 동일한 국가별 판정으로 변경했다.
|
||||
- 왜: `chat/original` 하위에 `member.auth` 직접 검사 잔여 지점이 남아 있어, 최초 요청 범위인 `chat` 패키지 전체 기준으로 정책이 완전히 일치하지 않았기 때문이다.
|
||||
- 어떻게:
|
||||
- `./gradlew compileKotlin` → 성공
|
||||
- `./gradlew test` → 성공
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/chat` 전체에서 `member.auth == null|member?.auth != null` 검색 → 결과 없음
|
||||
@@ -1,41 +0,0 @@
|
||||
SET @schema_name := DATABASE();
|
||||
|
||||
SET @lang_column_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'content_banner'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @add_lang_column_sql := IF(
|
||||
@lang_column_exists = 0,
|
||||
'ALTER TABLE content_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''배너 노출 언어'' AFTER type',
|
||||
'SELECT ''content_banner.lang already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
|
||||
EXECUTE add_lang_column_stmt;
|
||||
DEALLOCATE PREPARE add_lang_column_stmt;
|
||||
|
||||
UPDATE content_banner
|
||||
SET lang = 'KO'
|
||||
WHERE lang IS NULL;
|
||||
|
||||
SET @lang_column_nullable := (
|
||||
SELECT IS_NULLABLE
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'content_banner'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @alter_lang_column_sql := IF(
|
||||
@lang_column_nullable = 'YES',
|
||||
'ALTER TABLE content_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''배너 노출 언어 (KO 기본, EN/JA 추가 가능)''',
|
||||
'SELECT ''content_banner.lang already normalized'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
|
||||
EXECUTE alter_lang_column_stmt;
|
||||
DEALLOCATE PREPARE alter_lang_column_stmt;
|
||||
@@ -1,41 +0,0 @@
|
||||
SET @schema_name := DATABASE();
|
||||
|
||||
SET @lang_column_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'chat_character_banner'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @add_lang_column_sql := IF(
|
||||
@lang_column_exists = 0,
|
||||
'ALTER TABLE chat_character_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''배너 노출 언어'' AFTER sort_order',
|
||||
'SELECT ''chat_character_banner.lang already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
|
||||
EXECUTE add_lang_column_stmt;
|
||||
DEALLOCATE PREPARE add_lang_column_stmt;
|
||||
|
||||
UPDATE chat_character_banner
|
||||
SET lang = 'KO'
|
||||
WHERE lang IS NULL;
|
||||
|
||||
SET @lang_column_nullable := (
|
||||
SELECT IS_NULLABLE
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'chat_character_banner'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @alter_lang_column_sql := IF(
|
||||
@lang_column_nullable = 'YES',
|
||||
'ALTER TABLE chat_character_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''배너 노출 언어 (KO 기본, EN/JA 추가 가능)''',
|
||||
'SELECT ''chat_character_banner.lang already normalized'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
|
||||
EXECUTE alter_lang_column_stmt;
|
||||
DEALLOCATE PREPARE alter_lang_column_stmt;
|
||||
@@ -1,41 +0,0 @@
|
||||
SET @schema_name := DATABASE();
|
||||
|
||||
SET @lang_column_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'recommend_live_creator_banner'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @add_lang_column_sql := IF(
|
||||
@lang_column_exists = 0,
|
||||
'ALTER TABLE recommend_live_creator_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''배너 노출 언어'' AFTER is_adult',
|
||||
'SELECT ''recommend_live_creator_banner.lang already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
|
||||
EXECUTE add_lang_column_stmt;
|
||||
DEALLOCATE PREPARE add_lang_column_stmt;
|
||||
|
||||
UPDATE recommend_live_creator_banner
|
||||
SET lang = 'KO'
|
||||
WHERE lang IS NULL;
|
||||
|
||||
SET @lang_column_nullable := (
|
||||
SELECT IS_NULLABLE
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'recommend_live_creator_banner'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @alter_lang_column_sql := IF(
|
||||
@lang_column_nullable = 'YES',
|
||||
'ALTER TABLE recommend_live_creator_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''배너 노출 언어 (KO 기본, EN/JA 추가 가능)''',
|
||||
'SELECT ''recommend_live_creator_banner.lang already normalized'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
|
||||
EXECUTE alter_lang_column_stmt;
|
||||
DEALLOCATE PREPARE alter_lang_column_stmt;
|
||||
@@ -1,10 +0,0 @@
|
||||
- [x] 배너 목록 조회 응답 생성 경로와 언어 정보 위치를 확인한다.
|
||||
- [x] 배너 목록 응답의 연결 캐릭터 이름에 배너 등록 언어를 `(언어)` 형식으로 추가한다.
|
||||
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: 관리자 배너 목록 조회 응답에서 연결 캐릭터 이름 뒤에 배너 등록 언어를 `(언어)` 형식으로 붙이도록 수정했다.
|
||||
- 왜: 같은 이름과 같은 이미지의 배너라도 등록 언어가 다르면 관리자 페이지에서 즉시 구분할 수 있어야 하기 때문이다.
|
||||
- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest"` 실행으로 컨트롤러 테스트를 검증했고, 새 테스트에서 목록 조회 응답 이름이 `character-12 (일본어)`로 반환되는 것을 확인했다. 결과는 `BUILD SUCCESSFUL`이다.
|
||||
@@ -1,11 +0,0 @@
|
||||
- [x] 추천 크리에이터 배너 등록·조회 경로와 언어 처리 기준을 확인한다.
|
||||
- [x] 추천 크리에이터 등록 API에 `lang` 파라미터를 추가하고 `Lang` 기준으로 저장하도록 수정한다.
|
||||
- [x] 관리자 추천 크리에이터 목록은 전체 언어를 유지하고, `LiveApiService.fetchData`의 추천 크리에이터 조회는 사용자 언어에 맞는 배너만 반환하도록 수정한다.
|
||||
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: 추천 크리에이터 배너 엔티티와 관리자 등록 API에 `lang`을 추가하고, 라이브 메인 `fetchData` 및 `/live/recommend` 조회가 현재 요청 언어와 일치하는 배너만 조회하도록 수정했다. 운영 반영용으로 `recommend_live_creator_banner.lang` 컬럼 DDL 문서도 추가했다.
|
||||
- 왜: 관리자에서는 언어별 추천 크리에이터 배너를 등록할 수 있어야 하고, 사용자 라이브 화면에서는 자신의 언어와 맞는 추천 크리에이터만 노출되어야 하기 때문이다. 관리자 목록 API는 기존처럼 전체 언어 배너를 그대로 조회해야 한다.
|
||||
- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 검증으로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.admin.live.AdminLiveServiceTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest"`로 소문자 `lang` 저장, 서비스 언어 전달, 언어별 추천 배너 조회를 검증했다. 이어서 `./gradlew ktlintCheck`와 `./gradlew build`를 실행했고 모두 `BUILD SUCCESSFUL`이다.
|
||||
@@ -1,10 +0,0 @@
|
||||
- [x] 시리즈 배너 등록·조회 경로와 언어 처리 기준을 확인한다.
|
||||
- [x] 배너 등록 시 언어를 저장하고 관리자 목록에서 시리즈 제목에 `(언어)` 표기를 추가한다.
|
||||
- [x] 사용자 시리즈 메인 조회에서 요청 언어와 일치하는 배너만 반환하도록 수정하고 검증 결과를 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: 시리즈 배너 등록 요청에 `lang`을 추가하고, 관리자 목록에서는 `seriesTitle (언어)` 형태로 응답하며, 사용자 시리즈 메인에서는 `LangContext`와 일치하는 언어 배너만 조회하도록 수정했다.
|
||||
- 왜: 관리자 화면에서는 같은 시리즈명의 다국어 배너를 구분할 수 있어야 하고, 사용자 화면에서는 요청 언어와 맞는 배너만 노출되어야 하기 때문이다.
|
||||
- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 컴파일로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceTest" --tests "kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest" --tests "kr.co.vividnext.sodalive.content.series.main.SeriesMainControllerTest"`를 실행해 등록 언어 저장, 관리자 목록 언어 표기, 사용자 언어별 배너 조회를 검증했다. 결과는 `BUILD SUCCESSFUL`이다.
|
||||
@@ -1,11 +0,0 @@
|
||||
- [x] 오디오 콘텐츠 배너 등록·조회 경로와 언어 처리 기준을 확인한다.
|
||||
- [x] 배너 등록 API에 `lang` 파라미터를 추가하고 지원 언어를 `Lang` 기준으로 저장하도록 수정한다.
|
||||
- [x] 관리자 배너 목록은 전체 언어 배너를 유지하고, HomeService `fetchData`는 사용자 언어와 일치하는 배너만 조회하도록 수정한다.
|
||||
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: 오디오 콘텐츠 배너 엔티티와 등록 요청에 `lang`을 추가하고, 홈 `fetchData`에서 현재 사용자 언어를 넘겨 해당 언어 배너만 조회하도록 수정했다. 운영 반영용으로 `content_banner.lang` 컬럼 DDL도 추가했다.
|
||||
- 왜: 관리자 등록 시 언어별 배너를 구분해 저장해야 하고, 홈에서는 사용자 언어와 맞는 배너만 노출되어야 하기 때문이다. 관리자 목록 API는 기존처럼 언어 전체 배너를 그대로 조회해야 한다.
|
||||
- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 컴파일/테스트로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerServiceTest" --tests "kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest" --tests "kr.co.vividnext.sodalive.api.home.HomeServiceTest"`로 등록 언어 저장, 언어별 배너 조회, 홈 언어 전달을 검증했다. 이어서 `./gradlew ktlintCheck`를 실행해 스타일 검증까지 확인했고 두 명령 모두 `BUILD SUCCESSFUL`이다.
|
||||
@@ -1,25 +0,0 @@
|
||||
- [x] ChatCharacterBanner 엔티티에 한국어 기본 배너와 일본어/영어 배너를 구분할 언어 필드를 유지한다.
|
||||
- [x] 관리자 배너 등록 API가 기본언어 한국어를 기본값으로 사용하고, 일본어/영어 배너도 등록할 수 있도록 요청값과 서비스 로직을 수정한다.
|
||||
- [x] 캐릭터 메인 배너 조회가 요청 언어 배너를 우선 조회하고, 없으면 한국어 배너를 fallback 하도록 수정한다.
|
||||
- [x] 관련 테스트 또는 검증을 수행하고 결과를 기록한다.
|
||||
- [x] 관리자 배너 등록 요청의 `lang`이 ISO 639 언어코드(`ko`, `en`, `ja`)로 들어와도 `Lang` enum으로 역직렬화되도록 수정한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: 채팅 캐릭터 배너를 기본 배너와 일본어 배너 행으로 분리해 저장하도록 `lang` 필드를 추가하고, 관리자 등록 API와 메인 배너 조회 로직을 일본어 기준으로 분기했다. 운영 반영용 MySQL DDL 문서 `docs/20260402_chat_character_banner_lang_ddl.sql`도 함께 추가했다.
|
||||
- 왜: 현재 배너 구조는 이미지 1개 기준 행 모델이라 동일 목적지에 여러 언어 이미지를 한 레코드에 묶는 것보다, 언어별 행 분리가 기존 정렬/활성화/수정 흐름을 가장 적게 건드리는 방식이기 때문이다.
|
||||
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest`로 서비스 분기와 언어 검증을 확인했고, `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest`로 등록 API와 메인 조회 API 흐름을 실행 검증했다. 이어서 `./gradlew ktlintCheck`, `./gradlew build`를 실행했다.
|
||||
- 결과: 지정한 테스트는 모두 성공했고, `ktlintCheck`와 `build`도 모두 성공했다. Kotlin LSP 서버가 없어 `lsp_diagnostics`는 수행할 수 없었다.
|
||||
|
||||
### 2차 수정
|
||||
- 무엇을: 배너 기본언어를 명시적 `KO`로 변경하고, 등록 가능 언어를 `KO`, `EN`, `JA`로 확장했다. 또한 메인 배너 조회는 요청 언어 배너가 없을 때 `KO` 배너로 fallback 하도록 수정했고, MySQL DDL도 `NULL -> KO` 데이터 정규화와 `NOT NULL DEFAULT 'KO'`로 보강했다.
|
||||
- 왜: 기본 배너를 `null`로 해석하는 방식보다 `KO`를 명시 저장하는 방식이 등록 규칙과 조회 fallback 규칙을 더 일관되게 표현하고, 영어 배너 추가 요구사항도 자연스럽게 수용할 수 있기 때문이다.
|
||||
- 어떻게: 서비스 로직과 테스트를 `KO/EN/JA` 기준으로 재작성하고, 관리자 등록 API 기본값과 메인 조회 경로를 대상으로 단위 테스트를 추가·수정했다. 이후 `ktlintCheck`, 대상 테스트, 전체 빌드를 다시 실행했다.
|
||||
- 결과: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest`, `./gradlew ktlintCheck`, `./gradlew build`를 모두 실행했고 전부 성공했다. 관리자 등록 테스트에서는 `lang`이 없을 때 `registerBanner(2L, "", null)` 호출이 발생하고 성공 응답이 반환되는 것을 확인했다. Kotlin LSP 서버는 없어 `lsp_diagnostics`는 이번에도 수행하지 못했다.
|
||||
|
||||
### 3차 수정
|
||||
- 무엇을: `Lang` enum에 Jackson `@JsonCreator` 기반 역직렬화 진입점을 추가해 관리자 배너 등록 요청의 `lang`이 `ko`, `en`, `ja` 같은 ISO 639 코드로 들어와도 `Lang.KO`, `Lang.EN`, `Lang.JA`로 파싱되도록 수정했다. 기존 enum 이름(`KO`, `EN`, `JA`) 입력도 계속 허용했다.
|
||||
- 왜: 관리자 요청에서 `Lang` enum을 직접 받고 있으므로, 외부에서 ISO 639 코드 값을 보내더라도 별도 DTO 변환 없이 안전하게 처리되게 해야 하기 때문이다.
|
||||
- 어떻게: `Lang.fromCode(...)`를 Jackson 역직렬화 팩토리로 연결하고, 관리자 배너 컨트롤러 테스트 요청값을 `"ja"`로 바꿨다. 또한 `ObjectMapper().readValue(...)`로 `"en"` 입력이 실제 `Lang.EN`으로 역직렬화되는 테스트를 추가했다.
|
||||
- 결과: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest`, `./gradlew ktlintCheck`, `./gradlew build`를 실행했고 모두 성공했다. 관리자 배너 등록 테스트는 실제 요청 문자열 `{"characterId":1,"lang":"ja"}` 를 사용해 성공 응답과 `registerBanner(..., Lang.JA)` 호출을 확인했다. Kotlin LSP 서버는 없어 `lsp_diagnostics`는 수행하지 못했다.
|
||||
@@ -1,10 +0,0 @@
|
||||
- [x] `CanCouponService.useCanCoupon`의 기존 본인인증 요구 조건과 국가/성인노출 관련 패턴을 확인한다.
|
||||
- [x] 한국이 아닌 국가에서 `MemberContentPreference.isAdultContentVisibl`가 `true`이면 본인인증 없이 쿠폰 사용이 가능하도록 수정한다.
|
||||
- [x] 변경 파일 진단과 관련 검증을 수행하고 결과를 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: `CanCouponService.useCanCoupon`이 `MemberContentPreferenceService.getStoredPreference(member).isAdult`를 기준으로 쿠폰 사용 가능 여부를 판단하도록 수정하고, 해당 분기 회귀 테스트를 추가했다.
|
||||
- 왜: 한국 사용자는 기존처럼 본인인증이 필요하고, 한국이 아닌 사용자는 성인 노출 설정이 `true`이면 본인인증 없이 쿠폰을 사용할 수 있어야 하기 때문이다.
|
||||
- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.can.coupon.CanCouponServiceTest"` 실행 성공, `./gradlew ktlintCheck` 실행 성공.
|
||||
@@ -1,15 +0,0 @@
|
||||
- [x] sendMessage의 외부 채팅 API 호출 경로와 요청 payload 구성을 확인한다.
|
||||
- [x] 외부 `/api/chat` 요청 body에 `member.nickname`을 `userName` 파라미터로 전달하도록 수정한다.
|
||||
- [x] 변경 파일 기준으로 검증을 수행하고 결과를 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: `ChatRoomService.sendMessage`가 외부 `/api/chat` 호출 시 `member.nickname`을 `username` 파라미터로 함께 전달하도록 수정했다.
|
||||
- 왜: 외부 채팅 API가 사용자 닉네임을 함께 받아야 하는 요구사항을 기존 메시지 전송 흐름 안에서 최소 범위로 반영해야 했기 때문이다.
|
||||
- 어떻게: 내부 탐색으로 `/api/chat` payload 생성 위치가 `ChatRoomService.callExternalApiForChatSend`임을 확인한 뒤 `./gradlew compileKotlin`과 `./gradlew test`를 실행했고 둘 다 `BUILD SUCCESSFUL`이었다. 추가로 `./gradlew test --tests '*ChatRoom*'`를 시도했지만 해당 패턴의 테스트 클래스는 없어 필터 검증은 불가했다.
|
||||
|
||||
### 2차 수정
|
||||
- 무엇을: 외부 `/api/chat` 요청 body의 키 이름을 `username`에서 `userName`으로 변경했다.
|
||||
- 왜: 외부 API 계약에서 사용자명 필드명이 camelCase인 `userName`으로 요구되기 때문이다.
|
||||
- 어떻게: `ChatRoomService.callExternalApiForChatSend`의 request body 키가 `"userName"`으로 생성되는 것을 코드에서 재확인했다. 이 환경에는 Kotlin LSP가 구성되어 있지 않아 별도 diagnostics는 수행할 수 없었고, 대신 `./gradlew compileKotlin test`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
@@ -1,10 +0,0 @@
|
||||
# `.omx/` Git 제외 처리
|
||||
|
||||
- [x] `.gitignore`에 `.omx/`를 추가한다.
|
||||
- [x] `git status`와 `git check-ignore`로 제외가 정상 동작하는지 확인한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 무엇을: `.gitignore`에 `.omx/`를 추가하고 `.omx/` 하위 파일들이 무시되는지 확인했다.
|
||||
- 왜: `.omx/`는 런타임 상태, 로그, 메트릭 파일이라 버전 관리 대상이 아니기 때문이다.
|
||||
- 어떻게: `git check-ignore -v .omx/tmux-hook.json .omx/state/hud-state.json .omx/logs/turns-2026-04-06.jsonl`로 무시 규칙을 확인했고, `git status --short`로 `.omx/`가 더 이상 추적 대상이 아니고 문서만 남는지 확인했다.
|
||||
@@ -1,19 +0,0 @@
|
||||
SET @schema_name := DATABASE();
|
||||
|
||||
SET @settlement_ratio_column_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'content'
|
||||
AND column_name = 'settlement_ratio'
|
||||
);
|
||||
|
||||
SET @add_settlement_ratio_column_sql := IF(
|
||||
@settlement_ratio_column_exists = 0,
|
||||
'ALTER TABLE content ADD COLUMN settlement_ratio INT NULL COMMENT ''콘텐츠별 정산 요율(%)'' AFTER price',
|
||||
'SELECT ''content.settlement_ratio already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_settlement_ratio_column_stmt FROM @add_settlement_ratio_column_sql;
|
||||
EXECUTE add_settlement_ratio_column_stmt;
|
||||
DEALLOCATE PREPARE add_settlement_ratio_column_stmt;
|
||||
@@ -1,22 +0,0 @@
|
||||
# 20260407 커밋 footer 자동 추가 차단
|
||||
|
||||
## 구현 계획
|
||||
- [x] oh-my-openagent 기본 footer 동작과 저장소 로컬 커밋 워크플로우의 영향 범위를 문서화한다.
|
||||
- [x] `AGENTS.md`에 커밋 본문에서 Sisyphus footer와 자동 `Co-authored-by` 라인을 허용하지 않는 규칙을 추가한다.
|
||||
- [x] `.opencode/skills/commit-policy/SKILL.md`에 검증된 메시지를 그대로 `git commit`에 전달하고 자동 footer를 금지하는 절차를 반영한다.
|
||||
- [x] `.opencode/commands/commit.md`에 `/commit` 커맨드가 자동 footer 없는 최종 메시지를 사용하도록 지시를 보강한다.
|
||||
- [x] `work/scripts/check-commit-message-rules.sh`에 Sisyphus footer 및 자동 `Co-authored-by` 라인 차단 검증을 추가한다.
|
||||
- [x] 변경 문서와 스크립트에 대해 진단 및 실행 검증을 수행한다.
|
||||
|
||||
## 검증 기록
|
||||
- [x] 작업 완료 후 검증 결과를 기록한다.
|
||||
|
||||
- 1차 구현
|
||||
- 무엇을: `AGENTS.md`, `.opencode/skills/commit-policy/SKILL.md`, `.opencode/commands/commit.md`, `work/scripts/check-commit-message-rules.sh`를 수정해 커밋 본문에서 `Ultraworked with [Sisyphus]...`와 `Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` 자동 footer를 금지하고, `/commit` 경로가 검증된 메시지를 그대로 `git commit`에 전달하도록 명시했다.
|
||||
- 왜: oh-my-openagent 기본 설정과 알려진 버그로 자동 footer가 붙을 수 있으므로, 저장소 로컬 규칙과 검증 스크립트에서 이를 명시적으로 차단해야 커밋 결과를 일관되게 통제할 수 있기 때문이다.
|
||||
- 어떻게: `lsp_diagnostics`로 `AGENTS.md`, `.opencode/skills/commit-policy/SKILL.md`, `.opencode/commands/commit.md`, `work/scripts/check-commit-message-rules.sh`, `docs/20260407_커밋footer자동추가차단.md`에 대해 모두 `No diagnostics found`를 확인했다. `bash -n work/scripts/check-commit-message-rules.sh`로 문법을 검증했고, `./work/scripts/check-commit-message-rules.sh --message`로 정상 메시지/`Refs` footer 허용 케이스는 PASS, Sisyphus footer와 자동 `Co-authored-by` 케이스는 FAIL을 확인했다. 추가로 `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
- 2차 수정
|
||||
- 무엇을: Oracle 검토 의견을 반영해 `.opencode/skills/commit-policy/SKILL.md`와 `.opencode/commands/commit.md`에서 `--message-file` 검증 후 같은 파일을 `git commit -F`에 전달하는 경로를 권장하도록 보강했고, `work/scripts/check-commit-message-rules.sh`의 `Co-authored-by` 차단 조건을 공백 변형까지 탐지하도록 확장했다.
|
||||
- 왜: exact string 하나만 금지하면 footer 형식이 조금만 달라져도 놓칠 수 있으므로, 외부 기본 동작이나 버그로 인한 변형까지 더 안정적으로 차단해야 하기 때문이다.
|
||||
- 어떻게: `lsp_diagnostics`로 `.opencode/skills/commit-policy/SKILL.md`, `.opencode/commands/commit.md`, `work/scripts/check-commit-message-rules.sh`에 대해 모두 `No diagnostics found`를 확인했다. `bash -n work/scripts/check-commit-message-rules.sh`를 다시 실행해 문법을 검증했고, `./work/scripts/check-commit-message-rules.sh --message`로 기본 메시지와 `Refs` footer는 PASS, Sisyphus footer/기본 `Co-authored-by`/공백 변형 `Co-authored-by` 케이스는 모두 FAIL을 확인했다.
|
||||
@@ -1,85 +0,0 @@
|
||||
- [x] 변수명 확정: 엔티티 내부 추가 변수는 `AudioContent.settlementRatio: Int?`로 사용한다.
|
||||
- 이유: `AudioContent`는 이미 콘텐츠 도메인 엔티티이므로 `contentSettlementRatio`는 중복 표현에 가깝다.
|
||||
- 근거: 이 저장소의 엔티티 필드는 `AudioContent.price`, `LiveRoom.price`, `CreatorCommunity.price`처럼 엔티티 스코프 안에서는 도메인 접두어를 반복하지 않는다.
|
||||
- 예외 기준: `CreatorSettlementRatio.contentSettlementRatio`처럼 하나의 엔티티 안에서 `live/content/community` 여러 정산 대상을 함께 구분해야 할 때만 `content` 접두어가 필요하다.
|
||||
- DTO/API 정책: 해당 값은 관리자에서만 사용하므로 관리자 요청/응답 DTO와 API 필드명도 예외 없이 `settlementRatio`로 통일한다.
|
||||
- nullable 정책: 기존 데이터와 크리에이터 정산 요율 미등록 케이스를 안전하게 수용하기 위해 초기 도입 시 `Int?`로 두고, 계산 시 `콘텐츠별 요율 -> 크리에이터 기본 요율 -> 70% 기본값` 순서로 fallback 하도록 설계한다.
|
||||
|
||||
- [x] `AudioContent` 엔티티에 콘텐츠별 정산 요율 필드를 추가한다.
|
||||
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt`
|
||||
- 작업 내용: `price` 인접 위치에 `settlementRatio: Int?` 필드를 추가하고, 기존 생성자 호출부가 모두 컴파일되도록 생성 경로를 함께 정리한다.
|
||||
|
||||
- [x] 관리자 콘텐츠 목록 조회 응답에 콘텐츠별 정산 요율을 노출한다.
|
||||
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt`
|
||||
- 작업 내용:
|
||||
- `QGetAdminContentListItem(...)` QueryProjection에 `audioContent.settlementRatio`를 추가한다.
|
||||
- `GetAdminContentListItem`에 `settlementRatio: Int?`를 추가하고, 관리자 목록 응답 필드명도 동일하게 `settlementRatio`로 맞춘다.
|
||||
- `AdminContentController.getAudioContentList` 응답에 정산 요율이 함께 내려가도록 조회 체인을 맞춘다.
|
||||
|
||||
- [x] 관리자 콘텐츠 수정 API에서 콘텐츠별 정산 요율을 수정할 수 있게 한다.
|
||||
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt`
|
||||
- 작업 내용:
|
||||
- `UpdateAdminContentRequest`에 `settlementRatio: Int?`를 추가하고, 관리자 수정 요청 필드명도 동일하게 `settlementRatio`로 맞춘다.
|
||||
- `AdminContentService.updateAudioContent`에서 요청값이 들어오면 `audioContent.settlementRatio`를 갱신한다.
|
||||
- 숫자 범위 정책은 `0~100`으로 검증한다.
|
||||
- 개별 콘텐츠 정산 요율 삭제는 `isSettlementRatioDeleted: true` 플래그로만 처리하고, `settlementRatio`와 동시 전달 시 invalid request 로 처리한다.
|
||||
|
||||
- [x] 실제 콘텐츠 정산 계산이 크리에이터 기본 요율이 아니라 콘텐츠별 요율을 우선 사용하도록 변경한다.
|
||||
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateContentQueryData.kt`
|
||||
- 작업 내용:
|
||||
- 콘텐츠 판매/누적 판매 집계 쿼리에서 `creatorSettlementRatio.contentSettlementRatio` 직접 사용 부분을 점검한다.
|
||||
- 정산 대상 비율은 `audioContent.settlementRatio`를 우선 사용하고, 값이 없을 때만 `creatorSettlementRatio.contentSettlementRatio`를 fallback 하도록 쿼리 또는 계산 DTO를 조정한다.
|
||||
- 현재 `GetCalculateContentQueryData`의 70% 기본값 정책은 마지막 fallback 으로 유지한다.
|
||||
|
||||
- [x] 크리에이터 관리자 정산 조회도 동일 기준을 사용하도록 맞춘다.
|
||||
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt`
|
||||
- 작업 내용: 관리자 정산 조회와 동일하게 콘텐츠별 정산 요율 우선 정책을 반영해 관리자/크리에이터 화면 간 계산 기준이 달라지지 않게 한다.
|
||||
|
||||
- [x] 기존 데이터 처리 정책을 정리한다.
|
||||
- 대상 범위: 운영 DB 스키마/기존 콘텐츠 데이터
|
||||
- 작업 내용:
|
||||
- 신규 컬럼 추가 시 nullable 로 도입하고, DDL은 `docs/20260407_audio_content_settlement_ratio_ddl.sql` 기준으로 관리한다.
|
||||
- 콘텐츠 등록 시 크리에이터 기본 정산 요율을 복사하지 않고, `NULL` 상태에서도 계산이 가능하도록 fallback 순서를 유지한다.
|
||||
- 운영 정책상 기존 콘텐츠에도 즉시 고정값이 필요하면 별도 SQL 또는 배치 백필 계획을 추가로 작성한다.
|
||||
|
||||
- [x] 영향 DTO/Q 클래스/컴파일 산출물을 재생성하고 검증한다.
|
||||
- 작업 내용:
|
||||
- QueryDSL projection 변경 후 Q 클래스 재생성이 필요한지 확인하고 빌드로 반영한다.
|
||||
- 엔티티/관리자 DTO/API/QueryProjection 필드명이 모두 `settlementRatio`로 일치하는지 확인해 매핑 누락 가능성을 제거한다.
|
||||
|
||||
- [x] 검증을 단계별로 수행한다.
|
||||
- 작업 내용:
|
||||
- `AdminContentController.getAudioContentList`에서 정산 요율 조회 포함 여부를 확인한다.
|
||||
- `AdminContentController.modifyAudioContent`에서 정산 요율 수정 반영 여부를 확인한다.
|
||||
- `AdminContentController.modifyAudioContent`에서 `isSettlementRatioDeleted = true` 요청 시 개별 콘텐츠 정산 요율이 삭제되는지 확인한다.
|
||||
- 콘텐츠 등록 시 `settlementRatio`가 nullable 상태로 유지되어도 계산 fallback 이 정상 동작하는지 확인한다.
|
||||
- 콘텐츠 정산 조회(`AdminCalculateQueryRepository`, `CreatorAdminCalculateQueryRepository`)가 콘텐츠별 요율을 우선 적용하는지 확인한다.
|
||||
- 실행 검증은 최소 `./gradlew build`, 필요 시 `./gradlew test`까지 수행한다.
|
||||
|
||||
## 1차 구현 검증 기록
|
||||
|
||||
- 무엇을: `AudioContent.settlementRatio` nullable 컬럼 추가, 관리자 목록/수정 API 반영, 콘텐츠/누적 정산 쿼리의 콘텐츠별 요율 우선 fallback 반영, 생성 시 기본 요율 복사 제거.
|
||||
- 왜: 기존 콘텐츠와 미설정 콘텐츠를 `NULL`로 유지하면서도 관리자에서 개별 요율을 조회/수정하고 정산 시 올바른 fallback 순서를 적용하기 위해.
|
||||
- 어떻게:
|
||||
- `./gradlew build` → 성공
|
||||
- `./gradlew test` → `build` 과정에 포함되어 성공
|
||||
- 관리자/정산 API의 실서버 수동 호출 검증 → 이 로컬 작업 세션에서는 애플리케이션 실행 및 인증 가능한 테스트 데이터가 없어 미실행
|
||||
|
||||
## 2차 수정 검증 기록
|
||||
|
||||
- 무엇을: `@SpringBootTest` 없이 콘텐츠 정산 계산 DTO의 명시 비율 적용과 `null -> 70% fallback`을 검증하는 순수 단위 테스트를 추가했다.
|
||||
- 왜: 정산 쿼리 변경만으로는 계산 결과가 코드상에서 충분히 고정되지 않아, 문서에 적힌 계산 규칙을 재현 가능한 테스트로 보장하기 위해.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` → 성공
|
||||
- 검증 대상: `GetCalculateContentQueryData`, `GetCumulativeSalesByContentQueryData`
|
||||
- 검증 시나리오: `settlementRatio = 80` 적용, `settlementRatio = null` 시 70% fallback 적용
|
||||
|
||||
## 3차 수정 검증 기록
|
||||
|
||||
- 무엇을: 관리자 콘텐츠 수정 API의 개별 콘텐츠 정산 요율 삭제 방식을 명시적 null 대신 `isSettlementRatioDeleted` 플래그로 전환한다.
|
||||
- 왜: 부분 업데이트 요청에서 필드 생략과 null 삭제 의미가 섞이지 않도록 API 계약을 명확히 하기 위해.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.AdminContentServiceTest` → 성공
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
- 검증 시나리오: 유효한 요율 설정, 삭제 플래그 삭제, 값/삭제 플래그 동시 전달 충돌, `null` 키/삭제 플래그 동시 전달 충돌, 범위 초과 거부, 정산 fallback 회귀 확인
|
||||
@@ -1,658 +0,0 @@
|
||||
# 에이전트 권한 및 정산 기능 추가 작업 계획
|
||||
|
||||
## 요구사항 상세 분석
|
||||
|
||||
### 1. 권한/역할 분석
|
||||
- `MemberRole.AGENT`는 이미 `Member.kt`에 정의되어 있으므로 역할 enum 자체의 신규 추가는 필요하지 않다.
|
||||
- 다만 현재 코드베이스에는 에이전트 전용 소속 관리/정산 조회 모듈이 없으므로, 실제 기능은 신규 구현이 필요하다.
|
||||
- 크리에이터를 에이전트에 소속하거나 해제하는 작업은 `ADMIN`만 수행할 수 있어야 한다.
|
||||
- 에이전트 전용 조회 API는 `AGENT` 권한 계정만 자신의 소속 크리에이터 데이터에 한해 접근할 수 있어야 한다.
|
||||
- 관리자용 권한 경계는 기존 `@PreAuthorize("hasRole('ADMIN')")`, 에이전트용 권한 경계는 `@PreAuthorize("hasRole('AGENT')")` 패턴을 따른다.
|
||||
|
||||
### 2. 관계 모델 분석
|
||||
- 요구사항은 `에이전트 1 : N 크리에이터`, `크리에이터 1 : 0..1 에이전트` 관계다.
|
||||
- 이 관계는 `Member` 자체에 필드를 직접 늘리는 방식보다, `kr.co.vividnext.sodalive.partner.agent` 패키지 안에 전용 연관 엔티티를 두는 편이 패키지 경계와 제약 관리에 더 적합하다.
|
||||
- 신규 연관 엔티티는 `creator_id` 유니크 제약으로 "한 크리에이터는 하나의 에이전트에만 소속"을 강제해야 한다.
|
||||
- 서비스 계층에서는 다음을 모두 검증해야 한다.
|
||||
- agent 회원이 실제 `MemberRole.AGENT`인지
|
||||
- creator 회원이 실제 `MemberRole.CREATOR`인지
|
||||
- creator가 이미 다른 agent에 소속되어 있는지
|
||||
- 자기 자신을 agent/creator로 잘못 연결하는 요청이 아닌지
|
||||
|
||||
### 3. 정산 규칙 분석
|
||||
- 에이전트 정산 비율은 관리자 설정값 1개만 있으면 된다.
|
||||
- 기준 금액은 "크리에이터 세전 정산금액(settlementAmount)"이며, 라이브/콘텐츠/커뮤니티처럼 항목별 비율이 아니라 최종 정산금액에 대해 에이전트 비율을 적용한다.
|
||||
- 에이전트 정산금은 크리에이터 정산금에서 차감하지 않고 별도로 계산한다.
|
||||
- 에이전트 정산금 계산식은 다음으로 고정한다.
|
||||
- `agentSettlementAmount = round(creatorSettlementAmount * agentSettlementRatio / 100)`
|
||||
- 크리에이터 입금액 계산식은 기존 로직을 그대로 유지한다.
|
||||
- 라이브/콘텐츠/커뮤니티는 기존 `CreatorSettlementRatio` 또는 콘텐츠별 정산 비율을 이용해 `settlementAmount`를 계산한 뒤, 그 결과에 에이전트 비율을 곱해야 한다.
|
||||
- 채널후원/콘텐츠후원도 기존 정산 계산 결과의 `settlementAmount`를 기준으로 에이전트 금액을 별도 계산해야 한다.
|
||||
|
||||
### 4. 조회 요구사항 분석
|
||||
- 에이전트는 소속 크리에이터 목록을 조회할 수 있어야 한다.
|
||||
- 에이전트는 소속 크리에이터 기준으로 아래 5개 현황을 조회할 수 있어야 한다.
|
||||
- 라이브
|
||||
- 콘텐츠 판매
|
||||
- 커뮤니티
|
||||
- 채널후원
|
||||
- 콘텐츠후원
|
||||
- 각 조회는 `/admin/calculate/content-by-creator` 계열과 유사하게 크리에이터별 집계 응답을 제공해야 한다.
|
||||
- 각 응답은 최소한 다음 정보를 포함해야 한다.
|
||||
- 크리에이터 식별 정보
|
||||
- 건수
|
||||
- 총 캔 수
|
||||
- 원화
|
||||
- 수수료
|
||||
- 정산금액
|
||||
- 합계(total)
|
||||
- 에이전트 정산금액(agentSettlementAmount)
|
||||
- 기존 `GetCalculateByCreatorItem`은 건수가 없고 total 객체도 없으므로, 에이전트 전용 응답 DTO는 신규 정의가 필요하다.
|
||||
|
||||
## 구현 방향
|
||||
|
||||
### 1차 구현 범위 (2026-04-09)
|
||||
- 이번 구현 슬라이스는 관리자 전용 기능만 포함한다.
|
||||
- 포함 범위
|
||||
- 에이전트-크리에이터 소속 지정 API
|
||||
- 에이전트-크리에이터 소속 해제 API
|
||||
- 에이전트 정산 비율 생성 API
|
||||
- 에이전트 정산 비율 수정 API
|
||||
- 에이전트 정산 비율 목록 API
|
||||
- 위 기능에 필요한 엔티티/리포지토리/서비스/테스트/DDL 문서
|
||||
- 제외 범위
|
||||
- 에이전트 본인 소속 크리에이터 목록 조회 API
|
||||
- 라이브/콘텐츠/커뮤니티/채널후원/콘텐츠후원 에이전트 정산 조회 API
|
||||
|
||||
### 권장 설계
|
||||
- 공유 도메인 모델/리포지토리는 `kr.co.vividnext.sodalive.partner.agent` 하위 패키지에 둔다.
|
||||
- `ADMIN` 전용 controller/service 진입점은 `kr.co.vividnext.sodalive.admin.partner.agent` 하위 패키지에 둔다.
|
||||
- `AGENT` 전용 정산 조회 진입점은 `kr.co.vividnext.sodalive.partner.agent.calculate` 하위 패키지에 둔다.
|
||||
- 기존 `admin.calculate`, `creator.admin.calculate`, `admin.member`의 구현 패턴은 재사용하되, DTO/쿼리/서비스는 에이전트 요구사항에 맞는 별도 모듈로 분리한다.
|
||||
- 에이전트-크리에이터 소속 관계와 에이전트 정산 비율은 전용 엔티티로 분리해 기능 응집도를 유지한다.
|
||||
|
||||
### 3차 구현 범위 (이력형 소속/비율 + 확정 정산 스냅샷)
|
||||
- 이번 구현 슬라이스는 기존 current-state 기반 에이전트 정산 구조를 historical model로 전환한다.
|
||||
- 포함 범위
|
||||
- `agent_creator_relation`을 `assignedAt/unassignedAt` 기반 이력형 소속 모델로 전환
|
||||
- `agent_settlement_ratio`를 `effectiveFrom/effectiveTo` 기반 이력형 비율 모델로 전환
|
||||
- 관리자 소속 지정/해제 API를 시간 경계 기반으로 수정
|
||||
- 관리자 비율 생성/수정/조회 API를 이력형 비율 기준으로 수정
|
||||
- AGENT 정산 조회 쿼리를 거래 시점(event time)의 소속/비율을 기준으로 계산하도록 수정
|
||||
- 확정 정산 스냅샷 저장 모델 및 관리자 확정 API 추가
|
||||
- finalized 기간 조회는 스냅샷 우선, 미확정 기간 조회는 live 계산 유지
|
||||
- 위 구조에 필요한 테스트, DDL 문서, 계획 문서 갱신
|
||||
- 제외 범위
|
||||
- 기존 과거 데이터의 완전 복원 보장
|
||||
- 이벤트소싱 도입
|
||||
- 프론트엔드 화면 개편
|
||||
|
||||
### current-state 설계의 한계
|
||||
- 현재 `AgentCreatorRelation`은 `agent`, `creator`만 저장하고 remove 시 hard-delete 하므로 과거 소속 이력을 보존하지 못한다.
|
||||
- 현재 `AgentSettlementRatio`는 `deletedAt` 기반 현재 활성 행 조회에 의존하므로 특정 거래 시점의 비율을 안정적으로 재현하지 못한다.
|
||||
- 현재 `AgentCalculateQueryRepository`는 거래 발생 시각(`useCan.createdAt`, `order.createdAt`)으로 기간을 자르면서도, 소속/비율은 현재 row를 기준으로 조인한다.
|
||||
- 따라서 크리에이터가 에이전트에서 해제되거나 비율이 변경되면, 과거 정산 조회 결과도 바뀔 수 있다.
|
||||
- 사용자가 요구한 "당시 적용 비율까지 고정된 완전한 과거 정산"은 current-state 조인만으로 충족할 수 없다.
|
||||
|
||||
### 변경 후 목표 모델
|
||||
- 소속 관계는 append-only 이력 모델로 관리한다.
|
||||
- `assignedAt`: 소속 시작 시각
|
||||
- `unassignedAt`: 소속 종료 시각(nullable)
|
||||
- 현재 소속은 `unassignedAt is null`로 정의한다.
|
||||
- 정산 비율도 append-only 이력 모델로 관리한다.
|
||||
- `effectiveFrom`: 비율 시작 시각
|
||||
- `effectiveTo`: 비율 종료 시각(nullable)
|
||||
- 현재 비율은 `effectiveTo is null`로 정의한다.
|
||||
- 정산 확정 시점에는 별도 스냅샷 테이블에 결과를 저장한다.
|
||||
- 소속/비율 foreign key만 저장하지 않고, 적용된 비율과 계산 결과를 숫자 값으로 함께 저장한다.
|
||||
- 이후 소속/비율 변경이 발생해도 확정 정산은 변경되지 않는다.
|
||||
|
||||
### 확정 정산 스냅샷 설계 초안
|
||||
- 신규 도메인 패키지 후보: `kr.co.vividnext.sodalive.partner.agent.settlement.snapshot`
|
||||
- 신규 관리자 진입점 패키지 후보: `kr.co.vividnext.sodalive.admin.partner.agent.settlement`
|
||||
- 신규 스냅샷 엔티티 초안 필드
|
||||
- `periodStart`, `periodEnd`
|
||||
- `settlementType` (`LIVE`, `CONTENT`, `COMMUNITY`, `CHANNEL_DONATION`, `CONTENT_DONATION`)
|
||||
- `agentId`, `agentNickname`
|
||||
- `creatorId`, `creatorNickname`
|
||||
- `assignmentId`
|
||||
- `agentSettlementRatioId`, `appliedAgentSettlementRatio`
|
||||
- `count`, `totalCan`, `krw`, `fee`, `settlementAmount`, `agentSettlementAmount`
|
||||
- 필요 시 `tax`, `depositAmount`
|
||||
- `finalizedAt`, `finalizedByMemberId`
|
||||
- 스냅샷은 append-only로 저장하고 동일 기간/타입/agent/creator 기준 재확정은 idempotent하게 막거나 재사용한다.
|
||||
|
||||
### 조회 전략 변경
|
||||
- finalized 기간 조회
|
||||
- 스냅샷 데이터가 있으면 스냅샷을 우선 조회한다.
|
||||
- 스냅샷 데이터는 이후 소속 해제/비율 변경과 무관하게 그대로 반환한다.
|
||||
- 미확정 기간 조회
|
||||
- `AgentCalculateQueryRepository`에서 거래 시점 기준으로 소속/비율 이력 row를 찾아 계산한다.
|
||||
- 시간 경계는 `start <= txTime < end` 형태의 반열린 구간으로 처리한다.
|
||||
|
||||
### API 변경 방향
|
||||
- 관리자 소속 지정 API
|
||||
- 기존 path 유지
|
||||
- request에 `assignedAt` 추가
|
||||
- 관리자 소속 해제 API
|
||||
- 기존 path 유지
|
||||
- request에 `unassignedAt` 추가
|
||||
- delete 대신 종료 시각 기록
|
||||
- 관리자 비율 생성/수정 API
|
||||
- 기존 path 유지
|
||||
- request에 `effectiveFrom` 추가
|
||||
- update는 기존 행 수정이 아니라 이전 활성 행 종료 + 신규 행 추가로 동작
|
||||
- 관리자 확정 정산 API
|
||||
- 신규 path 후보: `POST /admin/partner/agent/settlement/finalize`
|
||||
- 입력: 기간, 정산 타입, 대상 에이전트(`agentId` 기준 단일 에이전트 확정)
|
||||
- 에이전트 조회 API
|
||||
- 기존 path 유지
|
||||
- finalized 기간은 스냅샷 우선, 그 외 기간은 live 계산
|
||||
|
||||
### 패키지/파일 초안
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelation.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelationRepository.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AssignAgentCreatorRequest.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/RemoveAgentCreatorRequest.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatio.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/response/*`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshot.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotRepository.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/**`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/**`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/**`
|
||||
- `docs/20260408_에이전트권한및정산기능추가.md`
|
||||
|
||||
### API 방향
|
||||
- 관리자 전용
|
||||
- 크리에이터 소속 지정 API
|
||||
- 크리에이터 소속 해제 API
|
||||
- 에이전트 정산 비율 생성/수정/조회 API
|
||||
- 에이전트 전용
|
||||
- 소속 크리에이터 목록 조회 API
|
||||
- 라이브 크리에이터별 현황 조회 API
|
||||
- 콘텐츠 크리에이터별 현황 조회 API
|
||||
- 커뮤니티 크리에이터별 현황 조회 API
|
||||
- 채널후원 크리에이터별 현황 조회 API
|
||||
- 콘텐츠후원 크리에이터별 현황 조회 API
|
||||
|
||||
### 역할별 신규 엔드포인트 정리
|
||||
|
||||
#### ADMIN 전용 엔드포인트
|
||||
| Method | Path | 권한 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/admin/partner/agent/assignment` | `ADMIN` | 에이전트와 크리에이터 소속을 지정한다. |
|
||||
| `POST` | `/admin/partner/agent/assignment/remove` | `ADMIN` | 지정된 크리에이터의 에이전트 소속을 해제한다. |
|
||||
| `POST` | `/admin/partner/agent/ratio` | `ADMIN` | 에이전트 정산 비율을 생성한다. |
|
||||
| `POST` | `/admin/partner/agent/ratio/update` | `ADMIN` | 기존 에이전트 정산 비율을 수정한다. |
|
||||
| `GET` | `/admin/partner/agent/ratio` | `ADMIN` | 에이전트 정산 비율 목록을 페이지 단위로 조회한다. |
|
||||
|
||||
#### AGENT 전용 엔드포인트
|
||||
| Method | Path | 권한 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/agent/calculate/creator/list` | `AGENT` | 로그인한 에이전트에게 소속된 크리에이터 목록을 조회한다. |
|
||||
| `GET` | `/agent/calculate/live-by-creator` | `AGENT` | 소속 크리에이터의 라이브 정산 현황을 크리에이터별로 조회한다. |
|
||||
| `GET` | `/agent/calculate/content-by-creator` | `AGENT` | 소속 크리에이터의 콘텐츠 판매 정산 현황을 크리에이터별로 조회한다. |
|
||||
| `GET` | `/agent/calculate/community-by-creator` | `AGENT` | 소속 크리에이터의 커뮤니티 정산 현황을 크리에이터별로 조회한다. |
|
||||
| `GET` | `/agent/calculate/channel-donation-by-creator` | `AGENT` | 소속 크리에이터의 채널후원 정산 현황을 크리에이터별로 조회한다. |
|
||||
| `GET` | `/agent/calculate/content-donation-by-creator` | `AGENT` | 소속 크리에이터의 콘텐츠후원 정산 현황을 크리에이터별로 조회한다. |
|
||||
|
||||
#### 공통 인증 메모
|
||||
- 관리자 엔드포인트는 모두 `@PreAuthorize("hasRole('ADMIN')")` 기준으로 제한한다.
|
||||
- 에이전트 엔드포인트는 모두 `@PreAuthorize("hasRole('AGENT')")` 기준으로 제한한다.
|
||||
- 에이전트 조회 API는 `@AuthenticationPrincipal(... ) member: Member?`를 통해 로그인 사용자를 확인하고, `agent_creator_relation` 기준으로 본인 소속 크리에이터 데이터만 조회한다.
|
||||
|
||||
### 패키지/엔드포인트 배치에 대한 최종 판단
|
||||
|
||||
#### 1. ADMIN 전용 partner-agent API는 어디에 두는 것이 더 자연스러운가?
|
||||
- **최종 판단: `ADMIN` 전용 controller/service 진입점은 `kr.co.vividnext.sodalive.admin.partner.agent.*` 아래로 옮기는 것이 더 자연스럽다.**
|
||||
- 근거는 이 저장소의 기존 관례가 관리자 전용 기능을 `kr.co.vividnext.sodalive.admin.*` 아래에 배치하는 흐름이 더 강하기 때문이다.
|
||||
- 예: `admin/calculate/AdminCalculateController.kt`
|
||||
- 예: `admin/marketing/AdminAdMediaPartnerController.kt`
|
||||
- 예: `admin/member/AdminMemberController.kt`
|
||||
- `creator`처럼 역할이 강하게 분리된 영역도 `creator/admin/*` 패턴을 쓰므로, 현재 `partner.agent.assignment.AdminAgentCreatorController`, `partner.agent.ratio.AdminAgentSettlementRatioController`처럼 **도메인 패키지 안에 관리자 전용 진입점이 섞여 있는 구조는 이 저장소 기준으로는 예외에 가깝다.**
|
||||
- 따라서 권장 구조는 아래와 같다.
|
||||
- **공유 도메인 객체/리포지토리**: `kr.co.vividnext.sodalive.partner.agent.*`
|
||||
- **ADMIN 전용 controller/service**: `kr.co.vividnext.sodalive.admin.partner.agent.*`
|
||||
- 즉, `AgentCreatorRelation`, `AgentSettlementRatio`, repository까지 `admin.*`로 옮기는 것이 아니라, **관리자 진입점만 `admin.*`로 재배치**하는 것이 균형이 가장 좋다.
|
||||
|
||||
#### 2. 소속 크리에이터 목록 조회 API가 `calculate` 아래에 있는 것은 맞는 선택인가?
|
||||
- **최종 판단: 현재 요구사항 범위에서는 유지해도 된다.**
|
||||
- 이유는 이 API가 “에이전트 소속 관리용 일반 목록 API”라기보다, **에이전트 정산 화면에서 크리에이터별 현황 조회로 진입하기 위한 보조 목록 API**에 가깝기 때문이다.
|
||||
- 현재 같은 컨트롤러에는 아래처럼 모두 정산/집계성 조회가 함께 모여 있다.
|
||||
- `/agent/calculate/live-by-creator`
|
||||
- `/agent/calculate/content-by-creator`
|
||||
- `/agent/calculate/community-by-creator`
|
||||
- `/agent/calculate/channel-donation-by-creator`
|
||||
- `/agent/calculate/content-donation-by-creator`
|
||||
- 따라서 **현재 맥락에서는 `/agent/calculate/creator/list`를 정산 조회의 진입용 목록으로 보는 해석이 가능하고, 응집도도 유지된다.**
|
||||
- 다만 이 API가 앞으로 정산 외 목적(메시지, 운영, 소속 관리, 일반 대시보드)에도 재사용되기 시작하면, 그 시점에는 아래처럼 분리 재검토하는 것이 맞다.
|
||||
- 패키지 후보: `kr.co.vividnext.sodalive.partner.agent.assignment` 또는 `kr.co.vividnext.sodalive.partner.agent.creator`
|
||||
- 엔드포인트 후보: `/agent/creator/list`, `/agent/assignment/creator/list`
|
||||
|
||||
#### 3. 이번 기능에 대한 권장 정리 기준
|
||||
- **ADMIN 전용 API**: `kr.co.vividnext.sodalive.admin.partner.agent.*`
|
||||
- **AGENT 정산 조회 API**: `kr.co.vividnext.sodalive.partner.agent.calculate.*`
|
||||
- **공유 도메인 모델/리포지토리**: `kr.co.vividnext.sodalive.partner.agent.*`
|
||||
- **`/agent/calculate/creator/list`**: 현재는 유지, 단 정산 외 재사용이 커지면 별도 read/assignment 축으로 분리
|
||||
|
||||
## 작업 체크리스트
|
||||
|
||||
- [x] 기존 `MemberRole.AGENT` 사용 범위를 점검하고, 관리자 화면/API에서 에이전트 계정을 식별할 수 있는 조회 경로를 확정한다.
|
||||
- [x] `kr.co.vividnext.sodalive.partner.agent` 패키지 아래에 에이전트-크리에이터 소속 전용 엔티티/리포지토리를 추가한다.
|
||||
- [x] 소속 엔티티에 `creator_id` 유니크 제약과 agent/creator role 검증 로직을 추가해 "한 크리에이터는 하나의 에이전트에만 소속" 규칙을 보장한다.
|
||||
- [x] 관리자 전용 크리에이터 소속 지정 API를 추가한다.
|
||||
- [x] 관리자 전용 크리에이터 소속 해제 API를 추가한다.
|
||||
- [x] 에이전트 정산 비율 전용 엔티티/리포지토리를 추가하고, 에이전트당 단일 비율만 유지되도록 한다.
|
||||
- [x] 관리자 전용 에이전트 정산 비율 생성/수정/조회 API를 추가한다.
|
||||
- [x] 1차 구현 범위의 assignment/ratio 컨트롤러/서비스 테스트를 추가해 role 검증, 중복 소속 방지, 누락 엔티티, pageable 위임을 검증한다.
|
||||
- [x] 신규 assignment/ratio 테이블 생성을 위한 DDL 문서를 `docs/20260409_partner_agent_assignment_ratio_ddl.sql`에 추가한다.
|
||||
- [x] 에이전트 본인의 소속 크리에이터 목록 조회 API를 추가한다.
|
||||
- [x] `/agent/calculate/creator/list`가 현재 시각 기준 `assignedAt <= now < unassignedAt` 활성 구간의 크리에이터만 노출하도록 보강한다.
|
||||
- [x] 라이브 현황용 agent 전용 Query/DTO/응답을 추가하고, `settlementAmount` 기준 `agentSettlementAmount`를 계산한다.
|
||||
- [x] 콘텐츠 판매 현황용 agent 전용 Query/DTO/응답을 추가하고, 기존 콘텐츠 정산 비율(`audioContent.settlementRatio` 또는 `CreatorSettlementRatio.contentSettlementRatio`)을 재사용한다.
|
||||
- [x] 커뮤니티 현황용 agent 전용 Query/DTO/응답을 추가하고, `CreatorSettlementRatio.communitySettlementRatio` 기반 정산 후 agent 금액을 계산한다.
|
||||
- [x] 채널후원 현황용 agent 전용 Query/DTO/응답을 추가하고, 기존 `ChannelDonationSettlementCalculator` 결과의 `settlementAmount` 기준으로 agent 금액을 계산한다.
|
||||
- [x] 콘텐츠후원 현황용 agent 전용 Query/DTO/응답을 추가하고, 기존 콘텐츠후원 계산 결과의 `settlementAmount` 기준으로 agent 금액을 계산한다.
|
||||
- [x] 5개 현황 응답 모두에 `totalCount + total + items` 구조를 맞추고, item/total 양쪽에 `agentSettlementAmount`를 포함한다.
|
||||
- [x] AGENT 계정이 자신에게 소속된 크리에이터 데이터만 조회하도록 `@AuthenticationPrincipal` + relation 기반 필터링을 구현한다.
|
||||
- [x] ADMIN/AGENT 권한 오류, 잘못된 role 요청, 중복 소속 요청, 비소속 데이터 조회 차단에 대한 예외 처리를 추가한다.
|
||||
- [x] 컨트롤러/서비스/쿼리 리포지토리 테스트를 추가해 소속 제약, 권한 제약, 정산 계산식, total 합계를 검증한다.
|
||||
- [x] `./gradlew test`, `./gradlew build`로 최종 검증한다.
|
||||
- [x] `agent_creator_relation`에 `assignedAt`, `unassignedAt`를 추가하고 current-state 단일 row 모델을 append-only 이력 모델로 전환한다.
|
||||
- [x] 관리자 소속 지정 API가 `assignedAt`을 받아 활성 기간 중복을 검증하도록 수정한다.
|
||||
- [x] 관리자 소속 해제 API가 hard-delete 대신 `unassignedAt` 종료 처리로 변경되도록 수정한다.
|
||||
- [x] `agent_settlement_ratio`에 `effectiveFrom`, `effectiveTo`를 추가하고 단일 현재 row 갱신 모델을 append-only 이력 모델로 전환한다.
|
||||
- [x] 관리자 비율 생성/수정 API가 `effectiveFrom`을 받아 기존 활성 row 종료 + 신규 row 추가로 동작하도록 수정한다.
|
||||
- [x] 관리자 비율 생성/수정 API가 `effectiveFrom` backdate, 동일 시각 입력, 기존 ratio history와 겹치는 시점을 거절하도록 검증을 보강한다.
|
||||
- [x] 관리자 비율 생성/수정 API가 `settlementRatio`를 0..100 범위로 검증하도록 보강한다.
|
||||
- [x] `agent_settlement_ratio` DDL에 MySQL 생성 컬럼 + UNIQUE 인덱스로 active row 단일성을 보장하고, `effective_from < effective_to` 기간 무결성 제약을 추가한다.
|
||||
- [x] `agent_creator_relation` DDL에 MySQL 생성 컬럼 + UNIQUE 인덱스로 active row 단일성을 보장하고, `assigned_at < unassigned_at` 기간 무결성 제약을 추가한다.
|
||||
- [x] 관리자 소속/비율 쓰기 경로가 `MemberRepository.findByIdForUpdate(...)` 기반 비관적 락과 unique violation 대응 패턴으로 직렬화되도록 보강한다.
|
||||
- [x] AGENT 정산 조회가 거래 시점 기준의 소속 이력과 비율 이력을 조인하도록 `AgentCalculateQueryRepository`를 수정한다.
|
||||
- [x] 기간 중 소속 변경 또는 비율 변경이 있는 경우 결과가 올바르게 분리/집계되는 테스트를 추가한다.
|
||||
- [x] AGENT 정산 조회의 paged query가 사전 조회된 `creatorIds`가 빈 페이지일 때 전체 결과로 fallback하지 않고 빈 rows/items를 반환하도록 보강한다.
|
||||
- [x] AGENT 정산 조회에서 `agent_settlement_ratio` 이력이 없으면 agent 정산금을 0% 대신 10% 기본값으로 계산하도록 수정한다.
|
||||
- [x] 확정 정산 스냅샷 엔티티/리포지토리/관리자 API를 추가한다.
|
||||
- [x] 확정 정산 스냅샷이 소속/비율 foreign key뿐 아니라 적용 비율과 계산 결과 숫자값을 함께 저장하도록 구현한다.
|
||||
- 정정(2026-04-09): 현재 구현은 `appliedAgentSettlementRatio`와 계산 결과 숫자값은 저장하지만, 설계 초안에 명시한 `assignmentId`, `agentSettlementRatioId`는 아직 스냅샷/DDL에 포함하지 않았다. 따라서 이 항목은 엄밀히는 부분 충족 상태로 본다.
|
||||
- [x] `AgentSettlementSnapshot` 및 `agent_settlement_snapshot` DDL에 `assignmentId`, `agentSettlementRatioId` 컬럼을 추가한다.
|
||||
- [x] `AgentCalculateQueryRepository`와 snapshot 생성용 query DTO에 거래 시점 기준 `assignmentId`, `agentSettlementRatioId` projection을 추가한다.
|
||||
- [x] `AdminAgentSettlementSnapshotService`와 관련 테스트를 갱신해 finalize 시점에 foreign key + 적용 비율 + 계산 숫자값이 함께 저장되도록 보완한다.
|
||||
- 참고: 위 보완은 creator-period summary가 단일 소속/단일 비율 이력 row로 귀결되는 경우의 추적성은 복구하지만, 기간 중 복수 history row가 섞인 summary의 완전 provenance까지 보장하지는 않는다. 그 수준의 감사 추적이 필요하면 별도 snapshot source detail 테이블이 추가로 필요하다.
|
||||
- [x] `agent_settlement_snapshot_source_detail`(가칭) DDL을 추가해 summary를 구성한 원천 source row별 provenance를 별도 저장한다.
|
||||
- [x] finalize가 `raw source row`를 기준으로 source detail과 creator-period summary를 같은 트랜잭션 안에서 함께 저장하도록 보강한다.
|
||||
- [x] source detail이 1건인 summary만 `assignmentId`, `agentSettlementRatioId`, `appliedAgentSettlementRatio`를 채우고, mixed-period summary는 `null`로 유지하는 규칙을 테스트로 고정한다.
|
||||
- [x] finalized 기간 조회는 스냅샷 우선, 미확정 기간 조회는 live 계산을 사용하도록 분기한다.
|
||||
- [x] 신규 이력/스냅샷 구조에 맞는 DDL 문서를 추가 또는 기존 DDL 문서를 확장한다.
|
||||
- [x] 기존 계획 문서 하단 검증 기록에 이력형 전환과 스냅샷 도입 구현/검증 결과를 누적한다.
|
||||
|
||||
## 세부 구현 메모
|
||||
|
||||
### 1. 소속 관계 구현 기준
|
||||
- 기존 `Member` 엔티티에 agent 필드를 직접 추가하지 않고, 전용 relation 테이블로 구현한다.
|
||||
- 이유는 다음과 같다.
|
||||
- 에이전트 전용 기능을 `partner.agent` 패키지에 응집시킬 수 있다.
|
||||
- creator role 제약과 unique 제약을 명확히 걸 수 있다.
|
||||
- 향후 소속 이력/상태 필드가 필요해져도 확장이 쉽다.
|
||||
|
||||
### 2. 정산 비율 구현 기준
|
||||
- 기존 `CreatorSettlementRatio`가 도메인별 다중 비율을 관리하므로, 에이전트는 별도 `AgentSettlementRatio`로 분리하는 편이 자연스럽다.
|
||||
- 필드는 단일 `settlementRatio`만 두고, 대상 회원은 `MemberRole.AGENT`로 제한한다.
|
||||
|
||||
### 3. 조회 응답 구현 기준
|
||||
- 라이브/콘텐츠/커뮤니티의 기존 관리자 creator별 응답은 count/total 객체가 부족하므로 재사용보다 agent 전용 응답 신설이 적합하다.
|
||||
- 채널후원 응답은 이미 `total + items` 구조가 있어 이를 가장 가까운 기준으로 삼는다.
|
||||
- 에이전트 응답은 아래 공통 필드를 기준으로 통일한다.
|
||||
- `creatorId`
|
||||
- `creatorNickname`
|
||||
- `count`
|
||||
- `totalCan`
|
||||
- `krw`
|
||||
- `fee`
|
||||
- `settlementAmount`
|
||||
- `agentSettlementAmount`
|
||||
- 필요 시 `tax`, `depositAmount`
|
||||
|
||||
### 4. 재사용 기준 파일
|
||||
- 권한/인증 패턴: `AdminMemberController`, `CreatorAdminCalculateController`, `SecurityConfig`
|
||||
- creator별 집계 패턴: `AdminCalculateQueryRepository`
|
||||
- 채널후원 total 응답 패턴: `AdminChannelDonationCalculateService`, `GetAdminChannelDonationSettlementTotal`
|
||||
- 정산 비율 검증 패턴: `CreatorSettlementRatioService`
|
||||
|
||||
### 5. 후속 보완 구현 순서 (2026-04-09 추가)
|
||||
- 아래 보완 작업은 **ratio 입력 무결성 차단 → 관리자 쓰기 직렬화/DDL 보강 → snapshot traceability 연결 → 전체 검증** 순서로 진행한다.
|
||||
|
||||
#### 5-1. ratio 입력 무결성 차단부터 먼저 수정한다.
|
||||
- [x] `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt`에 아래 RED 테스트를 추가한다.
|
||||
- [x] active row보다 과거 `effectiveFrom`으로 create/update 요청 시 예외가 발생한다.
|
||||
- [x] active row와 같은 `effectiveFrom`으로 create/update 요청 시 예외가 발생한다.
|
||||
- [x] active row가 없더라도 기존 closed history와 겹치는 `effectiveFrom`이면 예외가 발생한다.
|
||||
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt`에서 `effectiveFrom` backdate / same-time / history overlap을 거절하도록 검증을 추가한다.
|
||||
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt`에 history overlap 판별용 조회 메서드를 추가한다.
|
||||
- [x] ratio 서비스 테스트를 재실행해 backdate 차단이 먼저 보장되는지 확인한다.
|
||||
|
||||
#### 5-2. 관리자 소속/비율 쓰기 경로를 직렬화한다.
|
||||
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt`에서 agent 대상 `MemberRepository.findByIdForUpdate(...)`를 사용하도록 수정한다.
|
||||
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt`에서 creator 대상 `MemberRepository.findByIdForUpdate(...)`를 사용하도록 수정한다.
|
||||
- [x] 두 서비스 모두 `saveAndFlush` + `DataIntegrityViolationException` 대응 패턴으로 unique violation을 사용자 예외로 변환하도록 보강한다.
|
||||
- [x] `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt`에 락/unique violation 대응 케이스를 추가한다.
|
||||
|
||||
#### 5-3. active row 단일성과 기간 무결성을 DDL/엔티티에 맞춘다.
|
||||
- [x] `docs/20260409_partner_agent_assignment_ratio_ddl.sql` 기준으로 `agent_creator_relation`, `agent_settlement_ratio`, `agent_settlement_snapshot` 최종 스키마가 구현 코드와 일치하는지 다시 점검한다.
|
||||
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelation.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatio.kt`의 매핑이 DDL의 최종 컬럼 구조와 충돌하지 않는지 확인한다.
|
||||
- [x] generated column(`active_creator_key`, `active_ratio_key`)은 JPA 쓰기 대상에서 제외하고, 서비스/리포지토리 로직이 해당 컬럼 없이도 동작하는지 확인한다.
|
||||
|
||||
#### 5-4. snapshot traceability를 query → service → entity 순서로 연결한다.
|
||||
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshot.kt`에 `assignmentId`, `agentSettlementRatioId` 필드를 추가한다.
|
||||
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt`와 snapshot 생성용 query DTO에 거래 시점 기준 `assignmentId`, `agentSettlementRatioId` projection을 추가한다.
|
||||
- [x] `docs/20260409_partner_agent_assignment_ratio_ddl.sql`에 `agent_settlement_snapshot_source_detail`(가칭) 테이블을 추가하고, `snapshot_id`, `assignment_id`, `agent_settlement_ratio_id`, source subtotal 컬럼, 조회 인덱스/FK를 정의한다.
|
||||
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/**`에 source detail 엔티티/리포지토리 파일을 추가한다.
|
||||
- [x] `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt`를 `raw source row` 기준의 source detail과 creator-period summary를 함께 저장하도록 바꾼다.
|
||||
- [x] creator-period summary가 단일 source row로 귀결될 때만 `assignmentId`, `agentSettlementRatioId`, `appliedAgentSettlementRatio`를 summary에 채우고, mixed-period summary는 `null`로 저장하도록 매핑 규칙을 고정한다.
|
||||
- [x] `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt`에 아래 검증을 추가한다.
|
||||
- [x] 단일 source summary는 summary row와 detail row가 같은 `assignmentId`, `agentSettlementRatioId`, `appliedAgentSettlementRatio`를 가진다.
|
||||
- [x] mixed-period summary는 summary의 `assignmentId`, `agentSettlementRatioId`, `appliedAgentSettlementRatio`가 `null`이고, detail row 여러 건으로 provenance를 복원할 수 있다.
|
||||
- [x] detail 합계가 summary 숫자값과 일치한다.
|
||||
|
||||
#### 5-5. 최종 회귀 검증을 수행한다.
|
||||
- [x] `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.*"`
|
||||
- [x] `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.*"`
|
||||
- [x] `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.settlement.*"`
|
||||
- [x] `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"`
|
||||
- [x] `./gradlew test`
|
||||
- [x] `./gradlew build`
|
||||
|
||||
## 검증 계획
|
||||
- 단위/서비스 테스트
|
||||
- agent와 creator role 검증
|
||||
- creator 중복 소속 방지
|
||||
- 비소속 creator 조회 차단
|
||||
- agentSettlementAmount 반올림 계산 검증
|
||||
- category별 total 합계 검증
|
||||
- 통합 성격 검증
|
||||
- ADMIN assignment API 정상/실패 케이스
|
||||
- AGENT 목록/정산 조회 API 정상/권한 실패 케이스
|
||||
- 빌드 검증
|
||||
- `./gradlew test`
|
||||
- `./gradlew build`
|
||||
|
||||
---
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 계획 수립 1차
|
||||
- 무엇을: 에이전트 권한, 소속 관계, 관리자 소속 관리 API, 에이전트 정산 비율, 에이전트 전용 크리에이터별 정산 조회 기능의 구현 범위를 분석하고 작업 계획 문서를 작성했다.
|
||||
- 왜: 기존 코드베이스에 이미 존재하는 `AGENT` 역할, 관리자 정산 모듈, 크리에이터 정산 비율 모듈을 기준으로 중복 구현 없이 가장 자연스러운 확장 경로를 먼저 확정하기 위해서다.
|
||||
- 어떻게:
|
||||
- 권한/역할/정산 패턴을 코드 기준으로 확인했다.
|
||||
- `docs` 폴더의 기존 작업 계획 문서 형식을 확인해 동일한 형식으로 문서를 작성했다.
|
||||
- 실행/확인 결과:
|
||||
- `explore` 백그라운드 탐색 2건(권한 패턴, 정산 패턴) → 완료
|
||||
- `read`로 확인한 기준 파일: `Member.kt`, `AdminMemberController.kt`, `AdminMemberService.kt`, `SecurityConfig.kt`, `AdminCalculateController.kt`, `AdminCalculateService.kt`, `AdminCalculateQueryRepository.kt`, `CreatorAdminCalculateController.kt`, `CreatorAdminCalculateService.kt`, `AdminChannelDonationCalculateController.kt`, `AdminChannelDonationCalculateService.kt`, `ChannelDonationSettlementCalculator.kt`
|
||||
- `./gradlew test` / `./gradlew build` → 문서 작성 단계이므로 미실행
|
||||
|
||||
### 1차 구현 1차
|
||||
- 무엇을: 공유 도메인인 `partner.agent.assignment`, `partner.agent.ratio`와 관리자 진입점인 `admin.partner.agent.assignment`, `admin.partner.agent.ratio` 패키지에 관리자 전용 assignment/remove, ratio create/update/list 기능과 테스트, 신규 테이블 DDL 문서를 추가했다.
|
||||
- 왜: 전체 에이전트 기능 중 첫 vertical slice로서 관리자 관점의 소속 관리와 정산 비율 관리부터 독립적으로 배포 가능한 최소 단위를 먼저 완성하기 위해서다.
|
||||
- 어떻게:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/**`에 relation 엔티티/리포지토리/요청 DTO를 추가했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/**`에 관리자 전용 서비스/컨트롤러를 추가했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/**`에 ratio 엔티티/리포지토리(querydsl)/요청·응답 DTO를 추가했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/**`에 관리자 전용 서비스/컨트롤러를 추가했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/**`에 assignment/ratio 서비스·컨트롤러 테스트를 추가하고 TDD 순서로 RED→GREEN을 확인했다.
|
||||
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql`에 `agent_creator_relation`, `agent_settlement_ratio` 생성 스크립트를 추가했다.
|
||||
- 실행/확인 결과:
|
||||
- `lsp_diagnostics` on `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent`, `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent` → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.assignment.*"` → 1차 실행 실패(신규 클래스 unresolved reference), 구현 후 재실행 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.ratio.*"` → 1차 실행 실패(신규 클래스 unresolved reference), 구현 후 재실행 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.*"` → 성공
|
||||
- `./gradlew test` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
|
||||
### 2차 구현 1차
|
||||
- 무엇을: `partner.agent.calculate` 패키지 아래에 AGENT 전용 소속 크리에이터 목록 조회 API와 라이브/콘텐츠/커뮤니티/채널후원/콘텐츠후원 creator-level summary API, QueryRepository, 응답 DTO, 테스트를 추가했다.
|
||||
- 왜: 에이전트 기능의 두 번째 vertical slice로서 실제 에이전트 계정이 본인에게 배정된 크리에이터 범위 안에서만 정산 현황을 볼 수 있도록 기능을 완성하기 위해서다.
|
||||
- 어떻게:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/**`에 `AgentCalculateController`, `AgentCalculateService`, `AgentCalculateQueryRepository`, assigned creator 응답 DTO, 일반 정산 summary DTO, 채널후원 summary DTO를 추가했다.
|
||||
- AGENT 인증 패턴은 `@PreAuthorize("hasRole('AGENT')")`와 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 가드절로 맞췄다.
|
||||
- QueryRepository는 `agent_creator_relation` 조인으로 소속 크리에이터만 필터링하고, 콘텐츠 summary는 creator별 페이지를 유지하면서 콘텐츠별 정산 비율 버킷을 병합하도록 구현했다.
|
||||
- `agentSettlementAmount`는 모든 응답에서 creator의 세전 `settlementAmount` 기준으로 `round(settlementAmount * agentRatio / 100)`를 적용했고, creator `settlementAmount`/`depositAmount` 자체는 차감하지 않았다.
|
||||
- 스키마 변경은 없어서 추가 DDL 문서는 만들지 않았다.
|
||||
- 실행/확인 결과:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"` → 1차 실행 실패(신규 클래스 unresolved reference), 구현 후 재실행 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.*"` → 성공
|
||||
- `lsp_diagnostics` on `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate`, `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate` → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
|
||||
- `./gradlew test` → 성공
|
||||
- `./gradlew build` → 최초 1회 `ktlintMainSourceSetCheck` 실패(신규 `AgentCalculateService.kt` 줄바꿈 규칙 위반), 포맷 수정 후 재실행 성공
|
||||
|
||||
### 3차 수정
|
||||
- 무엇을: `partner.agent.assignment.*`, `partner.agent.ratio.*` 예외 키를 `SodaMessageSource`에 등록하고, 해당 메시지 조회를 보장하는 단위 테스트를 추가했다.
|
||||
- 왜: 기능 자체는 동작하더라도 메시지 소스에 키가 없으면 런타임에서 사용자에게 의도한 다국어 문구 대신 키 문자열 또는 빈 메시지가 노출될 수 있기 때문이다.
|
||||
- 어떻게:
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSourceTest.kt`를 추가해 `partner.agent.assignment.creator_already_assigned`, `partner.agent.ratio.invalid_agent`, `partner.agent.ratio.not_found` 메시지 조회를 RED→GREEN으로 검증했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt`에 `partnerAgentMessages` 맵을 추가하고 `getMessage` 그룹 목록에 포함시켰다.
|
||||
- 실행/확인 결과:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.i18n.SodaMessageSourceTest"` → 1차 실행 실패(메시지 미등록), 수정 후 재실행 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.*"` → 성공
|
||||
- `./gradlew test` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
- `jshell --class-path "build/classes/kotlin/main:build/resources/main:...kotlin-stdlib..."` 수동 확인 →
|
||||
- `partner.agent.assignment.creator_already_assigned` 한국어 메시지 출력: `이미 다른 에이전트에 소속된 크리에이터입니다.`
|
||||
- `GetAgentCreatorSettlementSummaryQueryData(21, creator-a, 2, 100, 70).toResponseItem(10)` 출력: `agentSettlementAmount=654`
|
||||
- `GetAgentChannelDonationSettlementByCreatorQueryData(21, creator-a, 1, 50).toResponseItem(10)` 출력: `agentSettlementAmount=397`
|
||||
|
||||
### 4차 수정
|
||||
- 무엇을: 관리자 전용 partner-agent controller/service 진입점을 `kr.co.vividnext.sodalive.admin.partner.agent.*` 아래로 재배치했다.
|
||||
- 왜: 이 저장소의 기존 관례상 관리자 전용 진입점은 `admin.*` 계층에 두는 편이 더 일관적이고, 공유 도메인 객체와 관리자 API 진입점을 분리하는 것이 책임 경계를 더 명확하게 만들기 때문이다.
|
||||
- 어떻게:
|
||||
- `AdminAgentCreatorController`, `AdminAgentCreatorService`를 `admin.partner.agent.assignment`로 이동하고, relation/request DTO/repository는 `partner.agent.assignment`에 유지했다.
|
||||
- `AdminAgentSettlementRatioController`, `AdminAgentSettlementRatioService`를 `admin.partner.agent.ratio`로 이동하고, ratio 엔티티/리포지토리/DTO는 `partner.agent.ratio`에 유지했다.
|
||||
- assignment/ratio 테스트 패키지도 `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/**`로 이동했다.
|
||||
- 계획 문서의 권장 설계/패키지 초안을 실제 구조에 맞게 갱신했다.
|
||||
- 실행/확인 결과:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.*"` → 1차 실행 실패(새 패키지 unresolved reference), 이동 후 재실행 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.*"` → 1차 실행 실패(새 패키지 unresolved reference), 이동 후 재실행 성공
|
||||
- `grep "kr.co.vividnext.sodalive.partner.agent.(assignment|ratio).AdminAgent" src/**/*.kt` 성격 확인 → 코드 기준 잔존 참조 없음
|
||||
- `./gradlew test` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
- `jshell --class-path "build/classes/kotlin/main:build/resources/main:...kotlin-stdlib..."` 수동 확인 →
|
||||
- `kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorController` 로딩 성공
|
||||
- `kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioController` 로딩 성공
|
||||
- 기존 `kr.co.vividnext.sodalive.partner.agent.assignment.AdminAgentCreatorController` / `...ratio.AdminAgentSettlementRatioController`는 `ClassNotFoundException`으로 미존재 확인
|
||||
|
||||
### 5차 수정
|
||||
- 무엇을: `agent_creator_relation`을 `assignedAt/unassignedAt` 기반 append-only 이력 모델로 전환하고, 관리자 소속 지정/해제 API를 시간 경계 기반 계약으로 수정했다.
|
||||
- 왜: 기존 current-state + hard-delete 구조로는 과거 소속 이력을 보존할 수 없고, 소속 해제 후 재배정 같은 운영 시나리오를 안전하게 표현할 수 없기 때문이다.
|
||||
- 어떻게:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelation.kt`에 `assignedAt`, `unassignedAt`를 추가하고 `creator` 연관을 `ManyToOne`으로 변경해 동일 creator의 이력 row 누적을 허용했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AssignAgentCreatorRequest.kt`, `RemoveAgentCreatorRequest.kt`에 명시적 시간 필드를 추가하고, `AdminAgentCreatorService.kt`에서 overlap 검증 및 hard-delete 대신 종료 시각 기록으로 동작을 바꿨다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt`에는 현재 동작 유지용으로 `unassignedAt is null` 조건만 추가해 active assignment 조회가 계속 현재 row만 보도록 맞췄다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorServiceTest.kt`, `AdminAgentCreatorControllerTest.kt`를 TDD로 갱신해 RED에서 새 계약 부재를 확인한 뒤 GREEN으로 구현했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt`는 새 not-null `assignedAt` 계약에 맞게 relation fixture를 갱신했다.
|
||||
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql`에 `agent_creator_relation` 생성 스키마를 이력형 컬럼/인덱스로 바꾸고, 기존 테이블에 대한 `assigned_at`, `unassigned_at`, unique index 제거, backfill migration 블록을 추가했다.
|
||||
- 실행/확인 결과:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.*"` → 1차 실행 실패(새 `assignedAt/unassignedAt`, repository 메서드, 엔티티 필드 부재), 구현 후 재실행 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.assignment.*" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.i18n.SodaMessageSourceTest"` → 성공
|
||||
- `lsp_diagnostics` on modified Kotlin files/directories → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
|
||||
- `./gradlew test`와 `./gradlew build`를 병렬 실행 → 실패 (`build/test-results/test/*.xml` 동시 쓰기 충돌)
|
||||
- `./gradlew test` 순차 재실행 → 성공
|
||||
- `./gradlew build` 순차 재실행 → 성공
|
||||
|
||||
### 6차 수정
|
||||
- 무엇을: `agent_settlement_ratio`를 `effectiveFrom/effectiveTo` 기반 append-only 이력 모델로 전환하고, 관리자 비율 생성/수정/목록 API 계약을 유효 기간 노출 방식으로 갱신했다.
|
||||
- 왜: 기존 `deletedAt` + 단일 current-row 갱신 방식으로는 과거 비율 이력을 보존할 수 없어서, 이후 거래 시점 기준 정산 조회나 운영 감사 시나리오에 필요한 근거 데이터를 남길 수 없기 때문이다.
|
||||
- 어떻게:
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt`, `AdminAgentSettlementRatioControllerTest.kt`를 먼저 수정해 `effectiveFrom` 입력, `effectiveFrom/effectiveTo` 응답, 기존 활성 row 종료 + 신규 row 추가 동작을 RED로 만들었다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatio.kt`를 `effectiveFrom/effectiveTo` 필드와 `close(...)` 메서드를 가진 이력 엔티티로 바꾸고, `member` 연관을 `ManyToOne`으로 변경해 동일 agent의 다중 이력 row를 허용했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/CreateAgentSettlementRatioRequest.kt`, `GetAgentSettlementRatioResponse.kt`, `AgentSettlementRatioRepository.kt`를 갱신해 요청/응답 계약과 active lookup 메서드 `findFirstByMemberIdAndEffectiveToIsNull(...)`를 도입했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt`에서 create/update 모두 기존 활성 row를 `effectiveTo`로 닫은 뒤 새 row를 저장하도록 수정했고, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt`는 현재 활성 비율 lookup만 새 repository 메서드로 맞췄다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt` fixture도 새 repository 메서드와 `effectiveFrom` 필수 생성자에 맞게 최소 호환 수정했다.
|
||||
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql`에는 `agent_settlement_ratio` 생성 스키마를 `effective_from/effective_to` + history index 구조로 변경하고, 기존 테이블에 대한 컬럼 추가/backfill/unique index 제거/`deleted_at` 제거 migration 블록을 확장했다.
|
||||
- 실행/확인 결과:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → 1차 실행 실패(새 `effectiveFrom/effectiveTo` 계약, repository 메서드, 엔티티 필드 부재), 구현 후 재실행 성공
|
||||
- `lsp_diagnostics` on modified Kotlin files/directories → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
|
||||
### 7차 수정
|
||||
- 무엇을: `AgentCalculateQueryRepository`, `AgentCalculateService`, agent calculate 응답용 query DTO와 테스트를 이벤트 시점 기준 소속/agent ratio 계산 방식으로 수정해, 기간 중 재배정과 agent 비율 변경이 있어도 다섯 개 정산 카테고리의 creator-level 결과가 당시 기준으로 집계되도록 바꿨다.
|
||||
- 왜: 기존 구현은 거래 발생 시각으로 기간만 자르고, 소속은 현재 active relation, agent 비율은 현재 active ratio 한 개를 전체 기간에 적용하고 있어서 중간 재배정/비율 변경이 생기면 과거 조회 결과가 잘못 왜곡됐기 때문이다.
|
||||
- 어떻게:
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt`에 기간 중 소속 변경, 기간 중 agent ratio 변경을 재현하는 통합 성격 테스트를 먼저 추가해 RED를 확인했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt`에서 라이브/콘텐츠/커뮤니티/채널후원/콘텐츠후원 모두에 대해 거래 시각 기준 `assignedAt <= eventTime < unassignedAt`, `effectiveFrom <= eventTime < effectiveTo` 조건으로 이력 row를 조인하도록 수정했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt`, `GetAgentChannelDonationSettlementByCreatorResponse.kt`, `AgentCalculateService.kt`를 바꿔 row별 agent ratio를 응답 아이템 변환 시 적용하고, 채널후원 포함 전 카테고리에서 creator 기준 merge 후 total을 계산하도록 정리했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt`도 row별 agent ratio 기대값으로 갱신해 서비스 레벨 병합 규칙을 검증했다.
|
||||
- 실행/확인 결과:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest"` → 1차 실행 실패(기간 중 소속 변경/agent ratio 변경 2건 assertion failure), 구현 후 재실행 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"` → 성공
|
||||
- `lsp_diagnostics` on modified Kotlin files → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
|
||||
- `./gradlew test` → 성공
|
||||
- `./gradlew build` → 1차 `ktlintTestSourceSetCheck` 실패(신규 테스트 장문 라인), 2차 `ktlintMainSourceSetCheck` 실패(import 순서/장문 line), 포맷 수정 후 재실행 성공
|
||||
|
||||
### 8차 수정
|
||||
- 무엇을: `partner.agent.settlement.snapshot` 패키지에 immutable creator-level snapshot 저장 모델과 repository/request-response mapper를 추가하고, `admin.partner.agent.settlement`에 확정 API를 만들었으며, `AgentCalculateService`가 finalized 기간이면 스냅샷을 우선 읽도록 다섯 카테고리 전체를 연결했다.
|
||||
- 왜: 이력형 소속/비율 계산만으로는 확정 시점의 creator-level 결과를 별도 보존하거나 재사용할 수 없어서, 이후 읽기에서 동일 기간을 다시 계산하지 않고도 확정된 숫자값을 안정적으로 반환할 수 있어야 했기 때문이다.
|
||||
- 어떻게:
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt`, `AdminAgentSettlementSnapshotControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt`에 snapshot 생성/idempotency/finalized snapshot-first read RED 테스트를 먼저 추가했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/**`에 `AgentSettlementSnapshot`, `AgentSettlementSnapshotRepository`, `FinalizeAgentSettlementSnapshotRequest/Response`, snapshot-to-response mapper를 추가했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt`에서 기존 `AgentCalculateQueryRepository` live 계산 결과를 creator-level 응답으로 병합한 뒤 숫자값을 그대로 스냅샷 row에 저장하고, 동일 기간/타입/agent 조합은 `exists...` 검사로 idempotent하게 막도록 구현했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt`에 `POST /admin/partner/agent/settlement/finalize`를 추가하고, 인증 관리자 `member.id`를 `finalizedByMemberId`로 전달하도록 맞췄다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt`는 라이브 계산 전에 스냅샷 repository를 먼저 조회하고, 스냅샷이 있으면 generic 4종과 channel donation 1종 모두 동일 응답 DTO로 변환해 반환하도록 분기했다.
|
||||
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql`에 `agent_settlement_snapshot` 테이블과 lookup/unique 인덱스 DDL을 추가했다.
|
||||
- 실행/확인 결과:
|
||||
- `lsp_diagnostics` on `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement`, `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate`, `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement` → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.settlement.*" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest"` → 1차 실행 실패(신규 snapshot 도메인/서비스/분기 미구현), 구현 및 테스트 fixture 수정 후 재실행 성공
|
||||
- `./gradlew test` → 성공
|
||||
- `./gradlew build` → 1차 `ktlintTestSourceSetCheck` 실패(import 순서/unused import), 2차 `ktlintMainSourceSetCheck` 실패(import 순서/장문 line), 포맷 수정 후 재실행 성공
|
||||
|
||||
### 9차 수정
|
||||
- 무엇을: 최종 정리 과정에서 `AgentCalculateService`의 request-wide current ratio 의존성을 제거하고, 관련 테스트/빌드/수동 확인까지 다시 수행했다.
|
||||
- 왜: 시점 기준 정산 조회와 finalized snapshot-first read로 전환된 뒤에는 `ratioRepository`가 더 이상 `AgentCalculateService`에서 직접 사용되지 않으므로, 잔존 의존성을 남기지 않는 것이 실제 설계와 코드 구조를 일치시키기 때문이다.
|
||||
- 어떻게:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt`에서 사용되지 않는 `ratioRepository` 의존성을 제거했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt`, `AgentCalculateQueryRepositoryTest.kt`의 생성자 호출부를 새 시그니처에 맞게 정리했다.
|
||||
- 실행/확인 결과:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.settlement.*"` → 1차 실행 실패(`AgentCalculateQueryRepositoryTest`의 구 생성자 시그니처 참조), 수정 후 재실행 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.settlement.*"` → 성공
|
||||
- `./gradlew test && ./gradlew build` → 성공
|
||||
- `jshell --class-path "build/classes/kotlin/main:build/resources/main:...kotlin-stdlib..."` 수동 확인 →
|
||||
- `FinalizeAgentSettlementSnapshotRequest(agentId=7, settlementType=LIVE, startDateStr=2026-02-20, endDateStr=2026-02-21).toDateRange()` 출력 확인
|
||||
- `AgentSettlementSnapshot` 1건을 `toSettlementByCreatorItems()`로 변환했을 때 `agentSettlementAmount=654` 포함 응답 아이템 출력 확인
|
||||
- `kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotController` 클래스 로딩 성공
|
||||
|
||||
### 10차 정정
|
||||
- 무엇을: 체크리스트 279번의 충족 범위를 문서/코드 기준으로 다시 대조하고, 누락된 후속 구현 항목을 기존 계획 문서에 추가했다.
|
||||
- 왜: 현재 스냅샷 구현은 `appliedAgentSettlementRatio`와 계산 숫자값은 저장하지만, 설계 초안과 체크리스트 문맥이 요구하는 `assignmentId`, `agentSettlementRatioId`는 저장하지 않아 문서 기준 완전 충족으로 보기 어려웠기 때문이다.
|
||||
- 어떻게:
|
||||
- 체크리스트 279 바로 아래에 정정 메모와 후속 체크박스 3개를 추가해 누락 범위를 `snapshot 컬럼`, `query projection`, `finalize 매핑/테스트`로 분리했다.
|
||||
- 문서 설계 초안(`assignmentId`, `agentSettlementRatioId`)과 현재 구현(`AgentSettlementSnapshot`, `AdminAgentSettlementSnapshotService`, `agent_settlement_snapshot` DDL)을 대조해 차이를 명시했다.
|
||||
- 실행/확인 결과:
|
||||
- `docs/20260408_에이전트권한및정산기능추가.md:106-123, 278-282` 확인 → 스냅샷 설계 초안과 체크리스트가 `assignmentId`, `agentSettlementRatioId`, `appliedAgentSettlementRatio`, 계산 숫자값 저장을 함께 기대함을 재확인
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshot.kt` 확인 → 현재는 `appliedAgentSettlementRatio`와 계산 숫자값만 저장하고 `assignmentId`, `agentSettlementRatioId` 필드는 없음
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt` 확인 → finalize 매핑이 ratio 값과 숫자값만 채우고 FK 값은 생성하지 않음
|
||||
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql` 확인 → `agent_settlement_snapshot` 테이블에도 `assignment_id`, `agent_settlement_ratio_id` 컬럼이 없음
|
||||
|
||||
### 11차 정정
|
||||
- 무엇을: ratio backdate 금지, active row 단일성 보장, 관리자 쓰기 직렬화, snapshot traceability 범위 메모를 기존 체크리스트에 후속 작업으로 추가했다.
|
||||
- 왜: 현재 `AdminAgentSettlementRatioService`는 `effectiveFrom`의 시간순 검증 없이 활성 row를 닫고 새 row를 저장해 backdate 시 interval 무결성이 깨질 수 있고, `agent_settlement_ratio`/`agent_creator_relation` DDL은 active row 중복을 막는 DB 제약이 없어 동시 요청 시 조회 결과 왜곡 위험이 있기 때문이다. 또한 snapshot의 `assignmentId`/`agentSettlementRatioId` 보완은 문서 279의 의도는 충족하지만 mixed-period creator summary의 완전 provenance까지는 아님을 분명히 할 필요가 있었다.
|
||||
- 어떻게:
|
||||
- 체크리스트 ratio 구간에 `effectiveFrom` backdate/same-time/overlap 거절 검증, `agent_settlement_ratio`/`agent_creator_relation`의 MySQL 생성 컬럼 + UNIQUE 인덱스 + 기간 무결성 제약, `MemberRepository.findByIdForUpdate(...)` 기반 비관적 락 적용 항목을 추가했다.
|
||||
- snapshot 279 보완 항목 아래에 `assignmentId`/`agentSettlementRatioId` 추가가 creator-period summary 기준 추적성은 복구하지만, mixed-period summary의 완전 provenance가 필요하면 별도 source detail 테이블이 필요하다는 참고 메모를 추가했다.
|
||||
- 실행/확인 결과:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt` 확인 → `effectiveFrom`의 시간순/겹침 검증 없이 active row close + insert 수행
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt` 확인 → overlap 검증은 있지만 비관적 락/DB active uniqueness 없이 read-then-write 수행
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt`, `member/contentpreference/MemberContentPreferenceService.kt`, `member/contentpreference/MemberContentPreferenceRepository.kt` 확인 → `findByIdForUpdate`, `@Lock(PESSIMISTIC_WRITE)`, `saveAndFlush + DataIntegrityViolationException` 재조회 패턴이 저장소 기존 관례로 존재함
|
||||
- `docs/20260409_partner_agent_assignment_ratio_ddl.sql` 확인 → 현재 `agent_settlement_ratio`, `agent_creator_relation` 모두 active row 단일성 보장용 unique 제약과 기간 무결성 제약이 없음
|
||||
- MySQL 제약 조사 결과 확인 → partial unique index 대신 생성 컬럼 + UNIQUE 인덱스가 가장 실용적이며, nullable unique를 직접 사용하는 방식은 active row 중복을 막지 못함
|
||||
|
||||
### 12차 정정
|
||||
- 무엇을: 남은 후속 수정 범위를 실제 구현 순서대로 더 잘게 쪼갠 작업 계획으로 재배치했다.
|
||||
- 왜: 현재 체크리스트는 해야 할 항목은 보이지만, 어떤 순서로 진행해야 리스크가 가장 적은지와 어떤 파일부터 수정해야 하는지가 한 번에 드러나지 않았기 때문이다.
|
||||
- 어떻게:
|
||||
- `세부 구현 메모` 아래에 `### 5. 후속 보완 구현 순서 (2026-04-09 추가)` 섹션을 새로 만들고, 작업을 `ratio 입력 무결성 차단 → 관리자 쓰기 직렬화 → DDL/엔티티 정합성 점검 → snapshot traceability 연결 → 최종 회귀 검증` 순서로 나눴다.
|
||||
- 각 단계마다 실제 수정 대상 파일 경로와 테스트/검증 명령을 체크박스로 세분화해, 바로 실행 가능한 작업 순서 문서가 되도록 정리했다.
|
||||
- 실행/확인 결과:
|
||||
- `docs/20260408_에이전트권한및정산기능추가.md:326-361` 확인 → 후속 보완 구현 순서 섹션과 5개 단계 체크리스트가 문서에 반영됨
|
||||
- `grep` 확인 → `### 5. 후속 보완 구현 순서`, `MemberRepository.findByIdForUpdate(...)`, `assignmentId`, `agentSettlementRatioId`, `./gradlew test`, `./gradlew build`가 새 순서형 계획에 포함됨
|
||||
- 코드/빌드 실행 여부 → 문서 수정 단계이므로 미실행
|
||||
|
||||
### 13차 정정
|
||||
- 무엇을: snapshot traceability 문제를 `summary FK 보완 + full audit provenance detail`까지 포함해 닫는 방향으로 체크리스트를 확장했다.
|
||||
- 왜: 현재 creator-period summary snapshot은 `creatorId` 기준 병합 후 한 줄로 저장되므로, `assignmentId`/`agentSettlementRatioId`만 summary에 추가해도 mixed-period 구간의 원천 provenance는 완전히 복원되지 않기 때문이다. 완전 감사 추적이 필요하면 summary와 별도로 source detail row를 함께 저장해야 한다.
|
||||
- 어떻게:
|
||||
- snapshot 체크리스트 아래에 `agent_settlement_snapshot_source_detail`(가칭) DDL 추가, finalize의 `raw source row -> source detail 저장 -> summary 저장` 순서 보강, mixed-period summary null 규칙 테스트 고정 항목을 추가했다.
|
||||
- `세부 구현 메모 > 5-4`를 확장해 DDL → detail entity/repository → finalize 저장 순서 → summary null 규칙 → detail-summary 합계 검증까지 실제 구현 순서대로 재배치했다.
|
||||
- 실행/확인 결과:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt` 확인 → 현재는 `rows.toMergedResponseItems()`로 creator-period summary만 저장하고 source detail 저장 단계는 없음
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt`, `GetAgentChannelDonationSettlementByCreatorResponse.kt` 확인 → `creatorId` 기준 병합 구조가 mixed-period provenance 소실의 직접 원인임
|
||||
- `docs/20260408_에이전트권한및정산기능추가.md:106-123` 확인 → 문서 설계 초안은 summary FK/숫자 스냅샷까지는 기대하지만, full audit provenance는 별도 설계가 필요함
|
||||
- Oracle/탐색 결과 확인 → `assignmentId + agentSettlementRatioId` summary 보완은 부분 해결이고, `source detail`까지 포함해야 mixed-period provenance 공백이 닫힘
|
||||
|
||||
### 14차 구현 및 정리
|
||||
- 무엇을: 문서에서 미체크로 남아 있던 ratio/assignment 무결성, 쓰기 직렬화, snapshot traceability/source detail provenance, 최종 검증 항목을 실제 구현 결과에 맞게 모두 완료 처리하고, 검증 기록을 최신 상태로 갱신했다.
|
||||
- 왜: 체크리스트와 실제 코드 상태가 어긋나 있으면 이후 작업자가 범위를 잘못 이해하거나, 이미 끝난 작업을 다시 추적해야 하는 비용이 생기기 때문이다. 또한 이번 변경은 정산 무결성과 확정 스냅샷 provenance를 함께 건드렸기 때문에, 최신 검증 결과를 문서에 남겨두는 것이 필수였다.
|
||||
- 어떻게:
|
||||
- `AdminAgentSettlementRatioService`, `AdminAgentCreatorService`, `AgentSettlementSnapshot`, `AdminAgentSettlementSnapshotService`, `AgentCalculateQueryRepository`, `docs/20260409_partner_agent_assignment_ratio_ddl.sql`을 기준으로 미체크 항목을 다시 대조하고, 실제 구현된 항목은 모두 `- [x]`로 갱신했다.
|
||||
- ratio 쪽에는 `effectiveFrom` backdate/same-time/history overlap 차단과 concurrent unique violation 변환 테스트를 추가했고, assignment 쪽에는 `findByIdForUpdate(...)` + `saveAndFlush` + `DataIntegrityViolationException` 대응 패턴과 테스트를 맞췄다.
|
||||
- snapshot 쪽에는 `assignmentId`, `agentSettlementRatioId`, `agent_settlement_snapshot_source_detail` provenance 저장, mixed-period summary null 규칙, detail 합계 검증까지 반영했다.
|
||||
- 실행/확인 결과:
|
||||
- `grep "^- \[ \]|^ - \[ \]" docs/20260408_에이전트권한및정산기능추가.md` → 미체크 항목 0건 확인
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest"` → 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotServiceTest"` → 성공
|
||||
- `./gradlew test` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
- `Oracle` completion review → 현재 문서 기준 미체크 항목 판단을 검토한 뒤, 테스트/문서 정리를 마치면 완료 처리 가능하다는 방향 재확인
|
||||
|
||||
### 15차 수정
|
||||
- 무엇을: `agent_settlement_ratio` 이력이 없는 AGENT 정산 조회에서 agent 정산금을 0%가 아니라 10% 기본값으로 계산하도록 수정하고, 해당 동작을 서비스 테스트와 수동 계산으로 검증했다.
|
||||
- 왜: 현재 구현은 agent 비율 row가 없으면 agent 정산금이 0원으로 계산되는데, 이번 정책 결정은 미설정 상태를 10% 기본 비율로 간주하는 것이기 때문이다.
|
||||
- 어떻게:
|
||||
- `docs/20260408_에이전트권한및정산기능추가.md` 체크리스트에 기본 fallback 10% 변경 항목을 추가한 뒤 완료 처리했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt`에 `agentSettlementRatio = null`일 때 일반 정산/채널후원 응답이 10%를 적용하는 RED 테스트 2건을 추가했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentChannelDonationSettlementByCreatorResponse.kt`에서 null fallback을 `DEFAULT_AGENT_SETTLEMENT_RATIO = 10`으로 변경했다.
|
||||
- 실행/확인 결과:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest.shouldApplyDefaultAgentSettlementRatioWhenAgentRatioHistoryDoesNotExist" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest.shouldApplyDefaultAgentSettlementRatioToChannelDonationWhenAgentRatioHistoryDoesNotExist"` → 1차 실행 실패, 수정 후 재실행 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
- `lsp_diagnostics` on Kotlin changed files → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
|
||||
- `jshell --class-path "build/classes/kotlin/main:build/resources/main:..."` 수동 확인 → `genericAgentSettlementAmount=131`, `channelAgentSettlementAmount=397`
|
||||
|
||||
### 16차 수정
|
||||
- 무엇을: 관리자 에이전트 정산 비율 생성/수정 API에 `settlementRatio` 0..100 범위 검증을 추가하고, 문서 체크리스트 미완료 항목을 실제 구현 상태로 정리했다.
|
||||
- 왜: 계획 문서에는 범위 검증 항목이 남아 있었지만 실제 서비스에는 guard가 없어 음수나 100 초과 비율이 저장될 수 있었기 때문이다.
|
||||
- 어떻게:
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt`에 `settlementRatio = -1` 생성, `settlementRatio = 101` 수정 요청이 `common.error.invalid_request`를 던지고 `memberRepository`/`repository`가 호출되지 않는다는 RED 테스트 2건을 추가했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt`에 `validateSettlementRatio(settlementRatio: Int)`를 추가하고 `createAgentSettlementRatio`, `updateAgentSettlementRatio` 진입부에서 0..100 범위를 먼저 검증하도록 수정했다.
|
||||
- `docs/20260408_에이전트권한및정산기능추가.md`의 체크리스트 277 항목을 완료 처리했다.
|
||||
- 실행/확인 결과:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest.shouldThrowWhenCreatingRatioWithSettlementRatioBelowZero" --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest.shouldThrowWhenUpdatingRatioWithSettlementRatioAboveHundred"` → 1차 실행 실패, 서비스 수정 후 재실행 성공
|
||||
- `./gradlew build` → 성공
|
||||
- `jshell --class-path "/Users/klaus/Develop/sodalive/Server/sodalive/build/classes/kotlin/main:/Users/klaus/Develop/sodalive/Server/sodalive/build/resources/main:..."` 수동 확인 → `messageKey=common.error.invalid_request`, `memberRepositoryCalls=0`, `repositoryCalls=0`
|
||||
- `lsp_diagnostics` on Kotlin changed files → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
|
||||
|
||||
### 17차 수정
|
||||
- 무엇을: `creator/list` 현재 소속 판정을 현재 시각 활성 구간 기준으로 바로잡고, AGENT 정산 조회의 빈 페이지가 전체 결과로 새는 pagination 버그를 함께 수정했다.
|
||||
- 왜: 기존 구현은 미래 `assignedAt` 소속을 너무 일찍 노출하고 미래 `unassignedAt` 소속을 너무 일찍 숨겼으며, paged query의 사전 조회 `creatorIds`가 빈 리스트일 때 2차 rows query가 전체 결과를 다시 읽을 수 있었기 때문이다.
|
||||
- 어떻게:
|
||||
- `docs/20260408_에이전트권한및정산기능추가.md` 체크리스트에 두 버그 수정 항목을 추가한 뒤 완료 처리했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt`에 현재 시각 활성 구간 creator list 회귀 테스트와, 5개 카테고리 paged query가 빈 페이지에서 빈 rows를 반환하는 회귀 테스트를 RED로 추가했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt`에서 `getAssignedCreators()`가 `currentTime`을 한 번만 잡아 count/items 조회에 공유하도록 수정했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt`에서 creator list 쿼리를 `assignedAt <= now < unassignedAt` 반열린 구간으로 바꾸고, 5개 paged calculate 메서드가 사전 조회 `creatorIds`가 빈 리스트면 즉시 `emptyList()`를 반환하도록 보강했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt`는 변경된 repository 시그니처에 맞춰 현재 시각 인자를 검증하도록 갱신했다.
|
||||
- 실행/확인 결과:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldGetAssignedCreatorsOnlyWithinCurrentAssignmentWindow" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldReturnEmptyRowsWhenPagedCreatorSelectionIsEmptyAcrossAllCategories"` → 1차 실행 실패, 수정 후 재실행 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest"` → 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
- `lsp_diagnostics` on changed Kotlin files → 불가 (현재 환경에 `.kt`용 LSP 서버 미구성)
|
||||
@@ -1,658 +0,0 @@
|
||||
SET @schema_name := DATABASE();
|
||||
|
||||
SET @agent_creator_relation_table_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_creator_relation'
|
||||
);
|
||||
|
||||
SET @create_agent_creator_relation_table_sql := IF(
|
||||
@agent_creator_relation_table_exists = 0,
|
||||
'CREATE TABLE agent_creator_relation (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
|
||||
agent_id BIGINT NOT NULL COMMENT ''에이전트 회원 ID (member.id 참조)'',
|
||||
creator_id BIGINT NOT NULL COMMENT ''크리에이터 회원 ID (member.id 참조)'',
|
||||
assigned_at TIMESTAMP NOT NULL COMMENT ''소속 시작 시각'',
|
||||
unassigned_at TIMESTAMP NULL DEFAULT NULL COMMENT ''소속 종료 시각(NULL이면 현재 활성 소속)'',
|
||||
active_creator_key TINYINT GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN unassigned_at IS NULL THEN 1
|
||||
ELSE NULL
|
||||
END
|
||||
) STORED,
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_agent_creator_relation_creator_active (creator_id, active_creator_key),
|
||||
KEY idx_agent_creator_relation_agent_id (agent_id),
|
||||
KEY idx_agent_creator_relation_creator_id (creator_id),
|
||||
KEY idx_agent_creator_relation_agent_unassigned_at (agent_id, unassigned_at),
|
||||
KEY idx_agent_creator_relation_creator_assigned_at (creator_id, assigned_at),
|
||||
KEY idx_agent_creator_relation_creator_unassigned_at (creator_id, unassigned_at),
|
||||
CONSTRAINT fk_agent_creator_relation_agent_id FOREIGN KEY (agent_id) REFERENCES member (id),
|
||||
CONSTRAINT fk_agent_creator_relation_creator_id FOREIGN KEY (creator_id) REFERENCES member (id),
|
||||
CONSTRAINT chk_agent_creator_relation_period CHECK (unassigned_at IS NULL OR assigned_at < unassigned_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''에이전트-크리에이터 소속 관계''',
|
||||
'SELECT ''agent_creator_relation already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE create_agent_creator_relation_table_stmt FROM @create_agent_creator_relation_table_sql;
|
||||
EXECUTE create_agent_creator_relation_table_stmt;
|
||||
DEALLOCATE PREPARE create_agent_creator_relation_table_stmt;
|
||||
|
||||
SET @agent_creator_relation_has_assigned_at := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_creator_relation'
|
||||
AND column_name = 'assigned_at'
|
||||
);
|
||||
|
||||
SET @alter_agent_creator_relation_add_assigned_at_sql := IF(
|
||||
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_has_assigned_at = 0,
|
||||
'ALTER TABLE agent_creator_relation ADD COLUMN assigned_at TIMESTAMP NOT NULL COMMENT ''소속 시작 시각'' AFTER creator_id',
|
||||
'SELECT ''agent_creator_relation.assigned_at already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_agent_creator_relation_add_assigned_at_stmt FROM @alter_agent_creator_relation_add_assigned_at_sql;
|
||||
EXECUTE alter_agent_creator_relation_add_assigned_at_stmt;
|
||||
DEALLOCATE PREPARE alter_agent_creator_relation_add_assigned_at_stmt;
|
||||
|
||||
SET @agent_creator_relation_has_unassigned_at := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_creator_relation'
|
||||
AND column_name = 'unassigned_at'
|
||||
);
|
||||
|
||||
SET @alter_agent_creator_relation_add_unassigned_at_sql := IF(
|
||||
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_has_unassigned_at = 0,
|
||||
'ALTER TABLE agent_creator_relation ADD COLUMN unassigned_at TIMESTAMP NULL DEFAULT NULL COMMENT ''소속 종료 시각(NULL이면 현재 활성 소속)'' AFTER assigned_at',
|
||||
'SELECT ''agent_creator_relation.unassigned_at already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_agent_creator_relation_add_unassigned_at_stmt FROM @alter_agent_creator_relation_add_unassigned_at_sql;
|
||||
EXECUTE alter_agent_creator_relation_add_unassigned_at_stmt;
|
||||
DEALLOCATE PREPARE alter_agent_creator_relation_add_unassigned_at_stmt;
|
||||
|
||||
SET @agent_creator_relation_has_active_creator_key := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_creator_relation'
|
||||
AND column_name = 'active_creator_key'
|
||||
);
|
||||
|
||||
SET @alter_agent_creator_relation_add_active_creator_key_sql := IF(
|
||||
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_has_active_creator_key = 0,
|
||||
'ALTER TABLE agent_creator_relation ADD COLUMN active_creator_key TINYINT GENERATED ALWAYS AS (CASE WHEN unassigned_at IS NULL THEN 1 ELSE NULL END) STORED AFTER unassigned_at',
|
||||
'SELECT ''agent_creator_relation.active_creator_key already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_agent_creator_relation_add_active_creator_key_stmt FROM @alter_agent_creator_relation_add_active_creator_key_sql;
|
||||
EXECUTE alter_agent_creator_relation_add_active_creator_key_stmt;
|
||||
DEALLOCATE PREPARE alter_agent_creator_relation_add_active_creator_key_stmt;
|
||||
|
||||
SET @agent_creator_relation_creator_unique_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_creator_relation'
|
||||
AND index_name = 'uk_agent_creator_relation_creator_id'
|
||||
);
|
||||
|
||||
SET @drop_agent_creator_relation_creator_unique_sql := IF(
|
||||
@agent_creator_relation_creator_unique_exists > 0,
|
||||
'ALTER TABLE agent_creator_relation DROP INDEX uk_agent_creator_relation_creator_id',
|
||||
'SELECT ''uk_agent_creator_relation_creator_id already dropped'' AS message'
|
||||
);
|
||||
|
||||
PREPARE drop_agent_creator_relation_creator_unique_stmt FROM @drop_agent_creator_relation_creator_unique_sql;
|
||||
EXECUTE drop_agent_creator_relation_creator_unique_stmt;
|
||||
DEALLOCATE PREPARE drop_agent_creator_relation_creator_unique_stmt;
|
||||
|
||||
SET @agent_creator_relation_creator_active_unique_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_creator_relation'
|
||||
AND index_name = 'uk_agent_creator_relation_creator_active'
|
||||
);
|
||||
|
||||
SET @add_agent_creator_relation_creator_active_unique_sql := IF(
|
||||
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_creator_active_unique_exists = 0,
|
||||
'ALTER TABLE agent_creator_relation ADD UNIQUE INDEX uk_agent_creator_relation_creator_active (creator_id, active_creator_key)',
|
||||
'SELECT ''uk_agent_creator_relation_creator_active already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_creator_relation_creator_active_unique_stmt FROM @add_agent_creator_relation_creator_active_unique_sql;
|
||||
EXECUTE add_agent_creator_relation_creator_active_unique_stmt;
|
||||
DEALLOCATE PREPARE add_agent_creator_relation_creator_active_unique_stmt;
|
||||
|
||||
SET @agent_creator_relation_period_check_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_creator_relation'
|
||||
AND constraint_name = 'chk_agent_creator_relation_period'
|
||||
AND constraint_type = 'CHECK'
|
||||
);
|
||||
|
||||
SET @add_agent_creator_relation_period_check_sql := IF(
|
||||
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_period_check_exists = 0,
|
||||
'ALTER TABLE agent_creator_relation ADD CONSTRAINT chk_agent_creator_relation_period CHECK (unassigned_at IS NULL OR assigned_at < unassigned_at)',
|
||||
'SELECT ''chk_agent_creator_relation_period already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_creator_relation_period_check_stmt FROM @add_agent_creator_relation_period_check_sql;
|
||||
EXECUTE add_agent_creator_relation_period_check_stmt;
|
||||
DEALLOCATE PREPARE add_agent_creator_relation_period_check_stmt;
|
||||
|
||||
SET @agent_creator_relation_agent_unassigned_index_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_creator_relation'
|
||||
AND index_name = 'idx_agent_creator_relation_agent_unassigned_at'
|
||||
);
|
||||
|
||||
SET @add_agent_creator_relation_agent_unassigned_index_sql := IF(
|
||||
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_agent_unassigned_index_exists = 0,
|
||||
'ALTER TABLE agent_creator_relation ADD INDEX idx_agent_creator_relation_agent_unassigned_at (agent_id, unassigned_at)',
|
||||
'SELECT ''idx_agent_creator_relation_agent_unassigned_at already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_creator_relation_agent_unassigned_index_stmt FROM @add_agent_creator_relation_agent_unassigned_index_sql;
|
||||
EXECUTE add_agent_creator_relation_agent_unassigned_index_stmt;
|
||||
DEALLOCATE PREPARE add_agent_creator_relation_agent_unassigned_index_stmt;
|
||||
|
||||
SET @agent_creator_relation_creator_assigned_index_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_creator_relation'
|
||||
AND index_name = 'idx_agent_creator_relation_creator_assigned_at'
|
||||
);
|
||||
|
||||
SET @add_agent_creator_relation_creator_assigned_index_sql := IF(
|
||||
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_creator_assigned_index_exists = 0,
|
||||
'ALTER TABLE agent_creator_relation ADD INDEX idx_agent_creator_relation_creator_assigned_at (creator_id, assigned_at)',
|
||||
'SELECT ''idx_agent_creator_relation_creator_assigned_at already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_creator_relation_creator_assigned_index_stmt FROM @add_agent_creator_relation_creator_assigned_index_sql;
|
||||
EXECUTE add_agent_creator_relation_creator_assigned_index_stmt;
|
||||
DEALLOCATE PREPARE add_agent_creator_relation_creator_assigned_index_stmt;
|
||||
|
||||
SET @agent_creator_relation_creator_unassigned_index_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_creator_relation'
|
||||
AND index_name = 'idx_agent_creator_relation_creator_unassigned_at'
|
||||
);
|
||||
|
||||
SET @add_agent_creator_relation_creator_unassigned_index_sql := IF(
|
||||
@agent_creator_relation_table_exists = 1 AND @agent_creator_relation_creator_unassigned_index_exists = 0,
|
||||
'ALTER TABLE agent_creator_relation ADD INDEX idx_agent_creator_relation_creator_unassigned_at (creator_id, unassigned_at)',
|
||||
'SELECT ''idx_agent_creator_relation_creator_unassigned_at already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_creator_relation_creator_unassigned_index_stmt FROM @add_agent_creator_relation_creator_unassigned_index_sql;
|
||||
EXECUTE add_agent_creator_relation_creator_unassigned_index_stmt;
|
||||
DEALLOCATE PREPARE add_agent_creator_relation_creator_unassigned_index_stmt;
|
||||
|
||||
SET @agent_settlement_ratio_table_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_ratio'
|
||||
);
|
||||
|
||||
SET @create_agent_settlement_ratio_table_sql := IF(
|
||||
@agent_settlement_ratio_table_exists = 0,
|
||||
'CREATE TABLE agent_settlement_ratio (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
|
||||
member_id BIGINT NOT NULL COMMENT ''에이전트 회원 ID (member.id 참조)'',
|
||||
settlement_ratio INT NOT NULL COMMENT ''에이전트 정산 비율(%)'',
|
||||
effective_from TIMESTAMP NOT NULL COMMENT ''비율 시작 시각'',
|
||||
effective_to TIMESTAMP NULL DEFAULT NULL COMMENT ''비율 종료 시각(NULL이면 현재 활성 비율)'',
|
||||
active_ratio_key TINYINT GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN effective_to IS NULL THEN 1
|
||||
ELSE NULL
|
||||
END
|
||||
) STORED,
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_agent_settlement_ratio_member_active (member_id, active_ratio_key),
|
||||
KEY idx_agent_settlement_ratio_member_effective_to (member_id, effective_to),
|
||||
KEY idx_agent_settlement_ratio_member_effective_from (member_id, effective_from),
|
||||
CONSTRAINT fk_agent_settlement_ratio_member_id FOREIGN KEY (member_id) REFERENCES member (id),
|
||||
CONSTRAINT chk_agent_settlement_ratio_period CHECK (effective_to IS NULL OR effective_from < effective_to)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''에이전트 정산 비율''',
|
||||
'SELECT ''agent_settlement_ratio already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE create_agent_settlement_ratio_table_stmt FROM @create_agent_settlement_ratio_table_sql;
|
||||
EXECUTE create_agent_settlement_ratio_table_stmt;
|
||||
DEALLOCATE PREPARE create_agent_settlement_ratio_table_stmt;
|
||||
|
||||
SET @agent_settlement_ratio_has_effective_from := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_ratio'
|
||||
AND column_name = 'effective_from'
|
||||
);
|
||||
|
||||
SET @alter_agent_settlement_ratio_add_effective_from_sql := IF(
|
||||
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_has_effective_from = 0,
|
||||
'ALTER TABLE agent_settlement_ratio ADD COLUMN effective_from TIMESTAMP NOT NULL COMMENT ''비율 시작 시각'' AFTER settlement_ratio',
|
||||
'SELECT ''agent_settlement_ratio.effective_from already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_agent_settlement_ratio_add_effective_from_stmt FROM @alter_agent_settlement_ratio_add_effective_from_sql;
|
||||
EXECUTE alter_agent_settlement_ratio_add_effective_from_stmt;
|
||||
DEALLOCATE PREPARE alter_agent_settlement_ratio_add_effective_from_stmt;
|
||||
|
||||
SET @agent_settlement_ratio_has_effective_to := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_ratio'
|
||||
AND column_name = 'effective_to'
|
||||
);
|
||||
|
||||
SET @alter_agent_settlement_ratio_add_effective_to_sql := IF(
|
||||
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_has_effective_to = 0,
|
||||
'ALTER TABLE agent_settlement_ratio ADD COLUMN effective_to TIMESTAMP NULL DEFAULT NULL COMMENT ''비율 종료 시각(NULL이면 현재 활성 비율)'' AFTER effective_from',
|
||||
'SELECT ''agent_settlement_ratio.effective_to already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_agent_settlement_ratio_add_effective_to_stmt FROM @alter_agent_settlement_ratio_add_effective_to_sql;
|
||||
EXECUTE alter_agent_settlement_ratio_add_effective_to_stmt;
|
||||
DEALLOCATE PREPARE alter_agent_settlement_ratio_add_effective_to_stmt;
|
||||
|
||||
SET @agent_settlement_ratio_has_active_ratio_key := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_ratio'
|
||||
AND column_name = 'active_ratio_key'
|
||||
);
|
||||
|
||||
SET @alter_agent_settlement_ratio_add_active_ratio_key_sql := IF(
|
||||
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_has_active_ratio_key = 0,
|
||||
'ALTER TABLE agent_settlement_ratio ADD COLUMN active_ratio_key TINYINT GENERATED ALWAYS AS (CASE WHEN effective_to IS NULL THEN 1 ELSE NULL END) STORED AFTER effective_to',
|
||||
'SELECT ''agent_settlement_ratio.active_ratio_key already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_agent_settlement_ratio_add_active_ratio_key_stmt FROM @alter_agent_settlement_ratio_add_active_ratio_key_sql;
|
||||
EXECUTE alter_agent_settlement_ratio_add_active_ratio_key_stmt;
|
||||
DEALLOCATE PREPARE alter_agent_settlement_ratio_add_active_ratio_key_stmt;
|
||||
|
||||
SET @agent_settlement_ratio_member_unique_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_ratio'
|
||||
AND index_name = 'uk_agent_settlement_ratio_member_id'
|
||||
);
|
||||
|
||||
SET @drop_agent_settlement_ratio_member_unique_sql := IF(
|
||||
@agent_settlement_ratio_member_unique_exists > 0,
|
||||
'ALTER TABLE agent_settlement_ratio DROP INDEX uk_agent_settlement_ratio_member_id',
|
||||
'SELECT ''uk_agent_settlement_ratio_member_id already dropped'' AS message'
|
||||
);
|
||||
|
||||
PREPARE drop_agent_settlement_ratio_member_unique_stmt FROM @drop_agent_settlement_ratio_member_unique_sql;
|
||||
EXECUTE drop_agent_settlement_ratio_member_unique_stmt;
|
||||
DEALLOCATE PREPARE drop_agent_settlement_ratio_member_unique_stmt;
|
||||
|
||||
SET @agent_settlement_ratio_member_active_unique_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_ratio'
|
||||
AND index_name = 'uk_agent_settlement_ratio_member_active'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_ratio_member_active_unique_sql := IF(
|
||||
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_member_active_unique_exists = 0,
|
||||
'ALTER TABLE agent_settlement_ratio ADD UNIQUE INDEX uk_agent_settlement_ratio_member_active (member_id, active_ratio_key)',
|
||||
'SELECT ''uk_agent_settlement_ratio_member_active already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_ratio_member_active_unique_stmt FROM @add_agent_settlement_ratio_member_active_unique_sql;
|
||||
EXECUTE add_agent_settlement_ratio_member_active_unique_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_ratio_member_active_unique_stmt;
|
||||
|
||||
SET @agent_settlement_ratio_has_deleted_at := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_ratio'
|
||||
AND column_name = 'deleted_at'
|
||||
);
|
||||
|
||||
SET @drop_agent_settlement_ratio_deleted_at_sql := IF(
|
||||
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_has_deleted_at > 0,
|
||||
'ALTER TABLE agent_settlement_ratio DROP COLUMN deleted_at',
|
||||
'SELECT ''agent_settlement_ratio.deleted_at already dropped'' AS message'
|
||||
);
|
||||
|
||||
PREPARE drop_agent_settlement_ratio_deleted_at_stmt FROM @drop_agent_settlement_ratio_deleted_at_sql;
|
||||
EXECUTE drop_agent_settlement_ratio_deleted_at_stmt;
|
||||
DEALLOCATE PREPARE drop_agent_settlement_ratio_deleted_at_stmt;
|
||||
|
||||
SET @agent_settlement_ratio_period_check_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_ratio'
|
||||
AND constraint_name = 'chk_agent_settlement_ratio_period'
|
||||
AND constraint_type = 'CHECK'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_ratio_period_check_sql := IF(
|
||||
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_period_check_exists = 0,
|
||||
'ALTER TABLE agent_settlement_ratio ADD CONSTRAINT chk_agent_settlement_ratio_period CHECK (effective_to IS NULL OR effective_from < effective_to)',
|
||||
'SELECT ''chk_agent_settlement_ratio_period already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_ratio_period_check_stmt FROM @add_agent_settlement_ratio_period_check_sql;
|
||||
EXECUTE add_agent_settlement_ratio_period_check_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_ratio_period_check_stmt;
|
||||
|
||||
SET @agent_settlement_ratio_member_effective_to_index_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_ratio'
|
||||
AND index_name = 'idx_agent_settlement_ratio_member_effective_to'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_ratio_member_effective_to_index_sql := IF(
|
||||
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_member_effective_to_index_exists = 0,
|
||||
'ALTER TABLE agent_settlement_ratio ADD INDEX idx_agent_settlement_ratio_member_effective_to (member_id, effective_to)',
|
||||
'SELECT ''idx_agent_settlement_ratio_member_effective_to already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_ratio_member_effective_to_index_stmt FROM @add_agent_settlement_ratio_member_effective_to_index_sql;
|
||||
EXECUTE add_agent_settlement_ratio_member_effective_to_index_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_ratio_member_effective_to_index_stmt;
|
||||
|
||||
SET @agent_settlement_ratio_member_effective_from_index_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_ratio'
|
||||
AND index_name = 'idx_agent_settlement_ratio_member_effective_from'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_ratio_member_effective_from_index_sql := IF(
|
||||
@agent_settlement_ratio_table_exists = 1 AND @agent_settlement_ratio_member_effective_from_index_exists = 0,
|
||||
'ALTER TABLE agent_settlement_ratio ADD INDEX idx_agent_settlement_ratio_member_effective_from (member_id, effective_from)',
|
||||
'SELECT ''idx_agent_settlement_ratio_member_effective_from already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_ratio_member_effective_from_index_stmt FROM @add_agent_settlement_ratio_member_effective_from_index_sql;
|
||||
EXECUTE add_agent_settlement_ratio_member_effective_from_index_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_ratio_member_effective_from_index_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_table_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot'
|
||||
);
|
||||
|
||||
SET @create_agent_settlement_snapshot_table_sql := IF(
|
||||
@agent_settlement_snapshot_table_exists = 0,
|
||||
'CREATE TABLE agent_settlement_snapshot (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
|
||||
period_start TIMESTAMP NOT NULL COMMENT ''정산 기간 시작 시각'',
|
||||
period_end TIMESTAMP NOT NULL COMMENT ''정산 기간 종료 시각'',
|
||||
settlement_type VARCHAR(50) NOT NULL COMMENT ''정산 유형(LIVE, CONTENT, COMMUNITY, CHANNEL_DONATION, CONTENT_DONATION)'',
|
||||
agent_id BIGINT NOT NULL COMMENT ''에이전트 회원 ID 스냅샷'',
|
||||
agent_nickname VARCHAR(255) NOT NULL COMMENT ''에이전트 닉네임 스냅샷'',
|
||||
creator_id BIGINT NOT NULL COMMENT ''크리에이터 회원 ID 스냅샷'',
|
||||
creator_nickname VARCHAR(255) NOT NULL COMMENT ''크리에이터 닉네임 스냅샷'',
|
||||
assignment_id BIGINT NULL COMMENT ''적용된 소속 이력 row ID 스냅샷'',
|
||||
agent_settlement_ratio_id BIGINT NULL COMMENT ''적용된 에이전트 정산 비율 이력 row ID 스냅샷'',
|
||||
applied_agent_settlement_ratio INT NULL COMMENT ''적용된 에이전트 정산 비율 스냅샷(creator-level summary에 단일 값으로 귀결될 때만 저장)'',
|
||||
count INT NOT NULL COMMENT ''건수 스냅샷'',
|
||||
total_can INT NOT NULL COMMENT ''총 캔 수 스냅샷'',
|
||||
krw INT NOT NULL COMMENT ''원화 금액 스냅샷'',
|
||||
fee INT NOT NULL COMMENT ''수수료 스냅샷'',
|
||||
settlement_amount INT NOT NULL COMMENT ''크리에이터 세전 정산금 스냅샷'',
|
||||
tax INT NOT NULL COMMENT ''원천세/세금 스냅샷'',
|
||||
deposit_amount INT NOT NULL COMMENT ''입금액 스냅샷'',
|
||||
agent_settlement_amount INT NOT NULL COMMENT ''에이전트 정산금 스냅샷'',
|
||||
finalized_at TIMESTAMP NOT NULL COMMENT ''확정 시각'',
|
||||
finalized_by_member_id BIGINT NOT NULL COMMENT ''확정한 관리자 회원 ID 스냅샷'',
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_agent_settlement_snapshot_period_type_agent_creator (period_start, period_end, settlement_type, agent_id, creator_id),
|
||||
KEY idx_agent_settlement_snapshot_lookup (agent_id, settlement_type, period_start, period_end),
|
||||
KEY idx_agent_settlement_snapshot_creator_lookup (creator_id, settlement_type, period_start, period_end),
|
||||
KEY idx_agent_settlement_snapshot_assignment_id (assignment_id),
|
||||
KEY idx_agent_settlement_snapshot_ratio_id (agent_settlement_ratio_id),
|
||||
CONSTRAINT fk_agent_settlement_snapshot_assignment_id FOREIGN KEY (assignment_id) REFERENCES agent_creator_relation (id),
|
||||
CONSTRAINT fk_agent_settlement_snapshot_ratio_id FOREIGN KEY (agent_settlement_ratio_id) REFERENCES agent_settlement_ratio (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''에이전트 확정 정산 creator-level 스냅샷''',
|
||||
'SELECT ''agent_settlement_snapshot already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE create_agent_settlement_snapshot_table_stmt FROM @create_agent_settlement_snapshot_table_sql;
|
||||
EXECUTE create_agent_settlement_snapshot_table_stmt;
|
||||
DEALLOCATE PREPARE create_agent_settlement_snapshot_table_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_has_assignment_id := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot'
|
||||
AND column_name = 'assignment_id'
|
||||
);
|
||||
|
||||
SET @alter_agent_settlement_snapshot_add_assignment_id_sql := IF(
|
||||
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_has_assignment_id = 0,
|
||||
'ALTER TABLE agent_settlement_snapshot ADD COLUMN assignment_id BIGINT NULL COMMENT ''적용된 소속 이력 row ID 스냅샷'' AFTER creator_nickname',
|
||||
'SELECT ''agent_settlement_snapshot.assignment_id already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_agent_settlement_snapshot_add_assignment_id_stmt FROM @alter_agent_settlement_snapshot_add_assignment_id_sql;
|
||||
EXECUTE alter_agent_settlement_snapshot_add_assignment_id_stmt;
|
||||
DEALLOCATE PREPARE alter_agent_settlement_snapshot_add_assignment_id_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_has_ratio_id := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot'
|
||||
AND column_name = 'agent_settlement_ratio_id'
|
||||
);
|
||||
|
||||
SET @alter_agent_settlement_snapshot_add_ratio_id_sql := IF(
|
||||
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_has_ratio_id = 0,
|
||||
'ALTER TABLE agent_settlement_snapshot ADD COLUMN agent_settlement_ratio_id BIGINT NULL COMMENT ''적용된 에이전트 정산 비율 이력 row ID 스냅샷'' AFTER assignment_id',
|
||||
'SELECT ''agent_settlement_snapshot.agent_settlement_ratio_id already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_agent_settlement_snapshot_add_ratio_id_stmt FROM @alter_agent_settlement_snapshot_add_ratio_id_sql;
|
||||
EXECUTE alter_agent_settlement_snapshot_add_ratio_id_stmt;
|
||||
DEALLOCATE PREPARE alter_agent_settlement_snapshot_add_ratio_id_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_unique_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot'
|
||||
AND index_name = 'uk_agent_settlement_snapshot_period_type_agent_creator'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_snapshot_unique_sql := IF(
|
||||
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_unique_exists = 0,
|
||||
'ALTER TABLE agent_settlement_snapshot ADD UNIQUE INDEX uk_agent_settlement_snapshot_period_type_agent_creator (period_start, period_end, settlement_type, agent_id, creator_id)',
|
||||
'SELECT ''uk_agent_settlement_snapshot_period_type_agent_creator already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_snapshot_unique_stmt FROM @add_agent_settlement_snapshot_unique_sql;
|
||||
EXECUTE add_agent_settlement_snapshot_unique_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_snapshot_unique_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_lookup_index_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot'
|
||||
AND index_name = 'idx_agent_settlement_snapshot_lookup'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_snapshot_lookup_index_sql := IF(
|
||||
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_lookup_index_exists = 0,
|
||||
'ALTER TABLE agent_settlement_snapshot ADD INDEX idx_agent_settlement_snapshot_lookup (agent_id, settlement_type, period_start, period_end)',
|
||||
'SELECT ''idx_agent_settlement_snapshot_lookup already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_snapshot_lookup_index_stmt FROM @add_agent_settlement_snapshot_lookup_index_sql;
|
||||
EXECUTE add_agent_settlement_snapshot_lookup_index_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_snapshot_lookup_index_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_creator_lookup_index_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot'
|
||||
AND index_name = 'idx_agent_settlement_snapshot_creator_lookup'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_snapshot_creator_lookup_index_sql := IF(
|
||||
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_creator_lookup_index_exists = 0,
|
||||
'ALTER TABLE agent_settlement_snapshot ADD INDEX idx_agent_settlement_snapshot_creator_lookup (creator_id, settlement_type, period_start, period_end)',
|
||||
'SELECT ''idx_agent_settlement_snapshot_creator_lookup already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_snapshot_creator_lookup_index_stmt FROM @add_agent_settlement_snapshot_creator_lookup_index_sql;
|
||||
EXECUTE add_agent_settlement_snapshot_creator_lookup_index_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_snapshot_creator_lookup_index_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_assignment_index_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot'
|
||||
AND index_name = 'idx_agent_settlement_snapshot_assignment_id'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_snapshot_assignment_index_sql := IF(
|
||||
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_assignment_index_exists = 0,
|
||||
'ALTER TABLE agent_settlement_snapshot ADD INDEX idx_agent_settlement_snapshot_assignment_id (assignment_id)',
|
||||
'SELECT ''idx_agent_settlement_snapshot_assignment_id already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_snapshot_assignment_index_stmt FROM @add_agent_settlement_snapshot_assignment_index_sql;
|
||||
EXECUTE add_agent_settlement_snapshot_assignment_index_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_snapshot_assignment_index_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_ratio_index_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot'
|
||||
AND index_name = 'idx_agent_settlement_snapshot_ratio_id'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_snapshot_ratio_index_sql := IF(
|
||||
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_ratio_index_exists = 0,
|
||||
'ALTER TABLE agent_settlement_snapshot ADD INDEX idx_agent_settlement_snapshot_ratio_id (agent_settlement_ratio_id)',
|
||||
'SELECT ''idx_agent_settlement_snapshot_ratio_id already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_snapshot_ratio_index_stmt FROM @add_agent_settlement_snapshot_ratio_index_sql;
|
||||
EXECUTE add_agent_settlement_snapshot_ratio_index_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_snapshot_ratio_index_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_assignment_fk_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot'
|
||||
AND constraint_name = 'fk_agent_settlement_snapshot_assignment_id'
|
||||
AND constraint_type = 'FOREIGN KEY'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_snapshot_assignment_fk_sql := IF(
|
||||
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_assignment_fk_exists = 0,
|
||||
'ALTER TABLE agent_settlement_snapshot ADD CONSTRAINT fk_agent_settlement_snapshot_assignment_id FOREIGN KEY (assignment_id) REFERENCES agent_creator_relation (id)',
|
||||
'SELECT ''fk_agent_settlement_snapshot_assignment_id already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_snapshot_assignment_fk_stmt FROM @add_agent_settlement_snapshot_assignment_fk_sql;
|
||||
EXECUTE add_agent_settlement_snapshot_assignment_fk_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_snapshot_assignment_fk_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_ratio_fk_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot'
|
||||
AND constraint_name = 'fk_agent_settlement_snapshot_ratio_id'
|
||||
AND constraint_type = 'FOREIGN KEY'
|
||||
);
|
||||
|
||||
SET @add_agent_settlement_snapshot_ratio_fk_sql := IF(
|
||||
@agent_settlement_snapshot_table_exists = 1 AND @agent_settlement_snapshot_ratio_fk_exists = 0,
|
||||
'ALTER TABLE agent_settlement_snapshot ADD CONSTRAINT fk_agent_settlement_snapshot_ratio_id FOREIGN KEY (agent_settlement_ratio_id) REFERENCES agent_settlement_ratio (id)',
|
||||
'SELECT ''fk_agent_settlement_snapshot_ratio_id already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_agent_settlement_snapshot_ratio_fk_stmt FROM @add_agent_settlement_snapshot_ratio_fk_sql;
|
||||
EXECUTE add_agent_settlement_snapshot_ratio_fk_stmt;
|
||||
DEALLOCATE PREPARE add_agent_settlement_snapshot_ratio_fk_stmt;
|
||||
|
||||
SET @agent_settlement_snapshot_source_detail_table_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'agent_settlement_snapshot_source_detail'
|
||||
);
|
||||
|
||||
SET @create_agent_settlement_snapshot_source_detail_table_sql := IF(
|
||||
@agent_settlement_snapshot_source_detail_table_exists = 0,
|
||||
'CREATE TABLE agent_settlement_snapshot_source_detail (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''PK'',
|
||||
snapshot_id BIGINT NOT NULL COMMENT ''summary snapshot FK'',
|
||||
assignment_id BIGINT NULL COMMENT ''적용된 소속 이력 row ID'',
|
||||
agent_settlement_ratio_id BIGINT NULL COMMENT ''적용된 에이전트 정산 비율 이력 row ID'',
|
||||
applied_agent_settlement_ratio INT NULL COMMENT ''적용된 에이전트 정산 비율'',
|
||||
count INT NOT NULL COMMENT ''source subtotal 건수'',
|
||||
total_can INT NOT NULL COMMENT ''source subtotal 총 캔 수'',
|
||||
krw INT NOT NULL COMMENT ''source subtotal 원화 금액'',
|
||||
fee INT NOT NULL COMMENT ''source subtotal 수수료'',
|
||||
settlement_amount INT NOT NULL COMMENT ''source subtotal 크리에이터 세전 정산금'',
|
||||
tax INT NOT NULL COMMENT ''source subtotal 세금'',
|
||||
deposit_amount INT NOT NULL COMMENT ''source subtotal 입금액'',
|
||||
agent_settlement_amount INT NOT NULL COMMENT ''source subtotal 에이전트 정산금'',
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''생성 시각'',
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''수정 시각'',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_agent_settlement_snapshot_source_detail_snapshot_id (snapshot_id),
|
||||
KEY idx_agent_settlement_snapshot_source_detail_assignment_id (assignment_id),
|
||||
KEY idx_agent_settlement_snapshot_source_detail_ratio_id (agent_settlement_ratio_id),
|
||||
CONSTRAINT fk_agent_settlement_snapshot_source_detail_snapshot_id FOREIGN KEY (snapshot_id) REFERENCES agent_settlement_snapshot (id),
|
||||
CONSTRAINT fk_agent_settlement_snapshot_source_detail_assignment_id FOREIGN KEY (assignment_id) REFERENCES agent_creator_relation (id),
|
||||
CONSTRAINT fk_agent_settlement_snapshot_source_detail_ratio_id FOREIGN KEY (agent_settlement_ratio_id) REFERENCES agent_settlement_ratio (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''에이전트 확정 정산 source provenance detail''',
|
||||
'SELECT ''agent_settlement_snapshot_source_detail already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE create_agent_settlement_snapshot_source_detail_table_stmt FROM @create_agent_settlement_snapshot_source_detail_table_sql;
|
||||
EXECUTE create_agent_settlement_snapshot_source_detail_table_stmt;
|
||||
DEALLOCATE PREPARE create_agent_settlement_snapshot_source_detail_table_stmt;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,313 +0,0 @@
|
||||
# 관리자 에이전트 정산 상세 조회 설계
|
||||
|
||||
## 문서 목적
|
||||
- 관리자 페이지에서 에이전트 목록을 보고, 특정 에이전트 상세 화면에서 소속 크리에이터와 에이전트별 정산 현황을 조회할 수 있도록 백엔드 read API 설계를 고정한다.
|
||||
- 기존 `partner/agent/calculate` 계산 로직은 최대한 재사용하고, `ADMIN` 전용 조회 진입점만 별도로 추가한다.
|
||||
|
||||
## 요구사항 정리
|
||||
- 관리자 화면에는 에이전트 리스트가 필요하다.
|
||||
- 에이전트 닉네임
|
||||
- 에이전트에 속한 크리에이터 수
|
||||
- 관리자 화면에는 크리에이터 검색이 필요하다.
|
||||
- 특정 에이전트에 크리에이터를 소속시키기 위한 검색
|
||||
- 관리자 화면에는 특정 에이전트에 현재 소속된 크리에이터 목록이 필요하다.
|
||||
- 에이전트 소속 해제를 위한 목록
|
||||
- 관리자 화면에는 특정 에이전트 기준 정산 상세가 필요하다.
|
||||
- 라이브 정산 현황
|
||||
- 콘텐츠 판매 정산 현황
|
||||
- 커뮤니티 정산 현황
|
||||
- 채널 후원 정산 현황
|
||||
- 콘텐츠 후원 정산 현황
|
||||
|
||||
## 범위와 해석
|
||||
- 이번 설계는 **B안: 에이전트 목록 → 에이전트 상세** 흐름을 기준으로 한다.
|
||||
- 에이전트 목록 화면에서는 요약 정보만 제공한다.
|
||||
- 실제 정산 데이터는 에이전트 상세 화면에서 조회한다.
|
||||
- 기존 `assignment`, `ratio`, `settlement/finalize` 쓰기 기능은 유지하고, 이번 범위에서는 관리자용 read API만 추가한다.
|
||||
|
||||
## 현재 코드 기준 확인 사항
|
||||
- 관리자 전용 에이전트 관련 컨트롤러는 이미 존재한다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt`
|
||||
- 에이전트 본인 전용 정산 조회 컨트롤러도 이미 존재한다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt`
|
||||
- 따라서 현재 부족한 것은 정산 계산 로직이 아니라, `ADMIN`이 특정 `agentId`를 지정해 같은 데이터를 읽는 관리자 전용 read API 레이어다.
|
||||
|
||||
## 권장 아키텍처
|
||||
|
||||
### 1. 관리자 전용 read 진입점 추가
|
||||
- 신규 패키지: `kr.co.vividnext.sodalive.admin.partner.agent.read`
|
||||
- 신규 구성
|
||||
- `AdminAgentReadController`
|
||||
- `AdminAgentReadService`
|
||||
- `AdminAgentReadQueryRepository`
|
||||
- 역할
|
||||
- 에이전트 목록 조회
|
||||
- 크리에이터 검색 조회
|
||||
- 특정 에이전트 소속 크리에이터 목록 조회
|
||||
- 특정 에이전트 기준 5종 정산 조회
|
||||
|
||||
### 2. 기존 도메인 계산 로직 재사용
|
||||
- 기존 계산/집계 로직은 계속 `kr.co.vividnext.sodalive.partner.agent.calculate` 아래에 둔다.
|
||||
- 관리자용 read 서비스는 기존 `AgentCalculateService`, `AgentCalculateQueryRepository`, snapshot 조회 경로를 재사용한다.
|
||||
- 현재 `AgentCalculateService`의 정산 조회 메서드는 이미 `agentId`를 직접 받으므로, 관리자 read 서비스는 이 메서드를 그대로 호출한다.
|
||||
- 최종안은 **정산 계산 로직은 `partner.agent.calculate`에 유지하고, ADMIN은 별도 read 서비스에서 기존 agentId 기반 조회 메서드를 재사용하는 구조**다.
|
||||
|
||||
### 3. 쓰기와 읽기 축 분리
|
||||
- 쓰기 축
|
||||
- `admin.partner.agent.assignment`
|
||||
- `admin.partner.agent.ratio`
|
||||
- `admin.partner.agent.settlement`
|
||||
- 읽기 축
|
||||
- `admin.partner.agent.read`
|
||||
- `partner.agent.calculate`
|
||||
- 이렇게 나누면 “관리자 권한으로 읽는다”와 “정산 계산 규칙을 제공한다”의 책임이 섞이지 않는다.
|
||||
|
||||
## 화면 흐름 기준 API 설계
|
||||
|
||||
### 1. 에이전트 목록 API
|
||||
- 목적: 관리자 화면의 첫 진입 리스트
|
||||
- 권장 경로: `GET /admin/partner/agent/list`
|
||||
- 권한: `ADMIN`
|
||||
- 요청 파라미터
|
||||
- `pageable`
|
||||
- 응답 필드
|
||||
- `agentId`
|
||||
- `agentNickname`
|
||||
- `assignedCreatorCount`
|
||||
- `liveAgentSettlementAmount`
|
||||
- `contentAgentSettlementAmount`
|
||||
- `communityAgentSettlementAmount`
|
||||
- `contentDonationAgentSettlementAmount`
|
||||
- `channelDonationAgentSettlementAmount`
|
||||
- `totalCount`
|
||||
- 조회 기준
|
||||
- `Member.role == AGENT`
|
||||
- 현재 활성 소속 크리에이터 수만 집계
|
||||
- 활성 소속 판정은 현재 코드의 assignment window 규칙과 동일하게 현재 시각 기준 `assignedAt <= now < unassignedAt(or null)`를 따른다.
|
||||
- 정산 합계는 별도 날짜 입력 없이 **현재 월 기준**으로 계산한다.
|
||||
- 기준 시간대: `Asia/Seoul`
|
||||
- 시작 시각: 현재 월 1일 `00:00:00`
|
||||
- 종료 시각: 다음 달 1일 `00:00:00` 직전까지 포함되는 배타 상한 방식
|
||||
- 실제 조회 구간은 위 KST 월 경계를 UTC `LocalDateTime`으로 변환해 DB의 UTC 저장 시각과 비교한다.
|
||||
- 각 합계 값의 의미는 해당 기간의 상세 조회 응답 `total.agentSettlementAmount`와 동일하다.
|
||||
- 해당 월에 정산 내역이 없더라도 에이전트 목록에서는 제외하지 않고, 5종 합계를 모두 `0`으로 내려준다.
|
||||
|
||||
### 2. 크리에이터 검색 API
|
||||
- 목적: 특정 에이전트에 크리에이터를 소속시키기 전 검색
|
||||
- 권장 경로: `GET /admin/partner/agent/creator/search`
|
||||
- 권한: `ADMIN`
|
||||
- 요청 파라미터
|
||||
- `search_word`
|
||||
- `pageable`
|
||||
- 응답 필드
|
||||
- `creatorId`
|
||||
- `creatorNickname`
|
||||
- `currentAgentId` nullable
|
||||
- `currentAgentNickname` nullable
|
||||
- 동작 원칙
|
||||
- 기존 `AdminMemberController.searchCreator()` 검색 관례를 따른다.
|
||||
- 검색 결과에 현재 활성 소속 agent 정보를 붙여서, 이미 다른 agent 소속인지 운영자가 바로 판단할 수 있게 한다.
|
||||
|
||||
### 3. 특정 에이전트 소속 크리에이터 목록 API
|
||||
- 목적: 상세 화면의 소속 크리에이터 탭
|
||||
- 권장 경로: `GET /admin/partner/agent/{agentId}/creator/list`
|
||||
- 권한: `ADMIN`
|
||||
- 응답 필드
|
||||
- `creatorId`
|
||||
- `creatorNickname`
|
||||
- `assignedAt`
|
||||
- 참고
|
||||
- 현재 `GetAgentAssignedCreatorResponse`는 `creatorId`, `creatorNickname`만 제공한다.
|
||||
- 관리자 상세 화면에서는 운영 판단을 위해 `assignedAt`이 같이 내려가는 편이 자연스럽다.
|
||||
|
||||
### 4. 특정 에이전트 정산 상세 API 5종
|
||||
- 목적: 에이전트 상세 화면의 정산 탭
|
||||
- 권한: `ADMIN`
|
||||
- 권장 경로
|
||||
- `GET /admin/partner/agent/{agentId}/calculate/live-by-creator`
|
||||
- `GET /admin/partner/agent/{agentId}/calculate/content-by-creator`
|
||||
- `GET /admin/partner/agent/{agentId}/calculate/community-by-creator`
|
||||
- `GET /admin/partner/agent/{agentId}/calculate/channel-donation-by-creator`
|
||||
- `GET /admin/partner/agent/{agentId}/calculate/content-donation-by-creator`
|
||||
- 공통 요청 파라미터
|
||||
- `startDateStr`
|
||||
- `endDateStr`
|
||||
- `pageable`
|
||||
- 응답 원칙
|
||||
- 기존 AGENT 전용 응답 계약을 가능한 그대로 유지한다.
|
||||
- `totalCount`, `total`, `items` 구조 유지
|
||||
- 각 item의 집계 필드 유지
|
||||
- `count`
|
||||
- `totalCan`
|
||||
- `krw`
|
||||
- `fee`
|
||||
- `settlementAmount`
|
||||
- `agentSettlementAmount`
|
||||
- 차이점
|
||||
- 기존 AGENT API는 로그인 principal에서 `agentId`를 얻는다.
|
||||
- 관리자 API는 path variable `agentId`를 받는다.
|
||||
|
||||
## 데이터 모델/응답 설계
|
||||
|
||||
### 1. 에이전트 목록 응답 DTO
|
||||
- 신규 DTO 필요
|
||||
- DTO 명
|
||||
- `GetAdminAgentListResponse`
|
||||
- `GetAdminAgentListItem`
|
||||
- 필수 필드
|
||||
- response: `totalCount: Int`, `items: List<GetAdminAgentListItem>`
|
||||
- item:
|
||||
- `agentId: Long`
|
||||
- `agentNickname: String`
|
||||
- `assignedCreatorCount: Int`
|
||||
- `liveAgentSettlementAmount: Int`
|
||||
- `contentAgentSettlementAmount: Int`
|
||||
- `communityAgentSettlementAmount: Int`
|
||||
- `contentDonationAgentSettlementAmount: Int`
|
||||
- `channelDonationAgentSettlementAmount: Int`
|
||||
|
||||
### 2. 크리에이터 검색 응답 DTO
|
||||
- 신규 DTO 필요
|
||||
- 기존 `admin/member` 검색 응답을 그대로 쓰기보다, 현재 활성 agent 정보를 함께 주는 전용 DTO가 필요하다.
|
||||
- DTO 명
|
||||
- `SearchAdminAgentAssignableCreatorResponse`
|
||||
- `SearchAdminAgentAssignableCreatorItem`
|
||||
- 필수 필드
|
||||
- response: `totalCount: Int`, `items: List<SearchAdminAgentAssignableCreatorItem>`
|
||||
- item: `creatorId: Long`, `creatorNickname: String`, `currentAgentId: Long?`, `currentAgentNickname: String?`
|
||||
|
||||
### 3. 관리자용 소속 크리에이터 목록 DTO
|
||||
- 기존 `GetAgentAssignedCreatorResponse`를 그대로 재사용하기보다는 관리자용 항목에 `assignedAt`을 포함한 전용 DTO가 적합하다.
|
||||
- DTO 명
|
||||
- `GetAdminAgentAssignedCreatorResponse`
|
||||
- `GetAdminAgentAssignedCreatorItem`
|
||||
- 필수 필드
|
||||
- response: `totalCount: Int`, `items: List<GetAdminAgentAssignedCreatorItem>`
|
||||
- item: `creatorId: Long`, `creatorNickname: String`, `assignedAt: LocalDateTime`
|
||||
|
||||
### 4. 관리자용 정산 상세 DTO
|
||||
- 가능하면 기존 아래 DTO를 그대로 재사용한다.
|
||||
- `GetAgentSettlementByCreatorResponse`
|
||||
- `GetAgentChannelDonationSettlementByCreatorResponse`
|
||||
- 이유
|
||||
- 화면 주체만 ADMIN으로 바뀌고 데이터 shape는 동일하기 때문이다.
|
||||
- DTO까지 갈라지면 정산 계약이 중복될 가능성이 높다.
|
||||
|
||||
## 서비스 설계
|
||||
|
||||
### 1. `AdminAgentReadService`
|
||||
- 책임
|
||||
- 에이전트 목록 조회
|
||||
- 크리에이터 검색 조회
|
||||
- 에이전트 소속 크리에이터 목록 조회
|
||||
- 에이전트 정산 상세 조회 진입
|
||||
- 내부 동작
|
||||
- 목록/검색/소속 목록은 전용 read query를 사용한다.
|
||||
- 에이전트 목록 조회 시 현재 월 기준 시작/종료 시각을 서비스에서 계산해 전용 read query에 전달한다.
|
||||
- 정산 상세는 기존 `AgentCalculateService`의 agentId 기반 public 조회 메서드를 사용한다.
|
||||
|
||||
### 2. 공통 정산 read 메서드 분리
|
||||
- 현재 `AgentCalculateService`의 핵심 정산 조회 메서드는 이미 `agentId` 파라미터 중심 public 메서드다.
|
||||
- 따라서 아래 방향으로 정리한다.
|
||||
- controller는 AGENT/ADMIN 별도로 유지
|
||||
- ADMIN read 서비스는 기존 `AgentCalculateService` public 메서드를 직접 호출한다.
|
||||
- 기대 효과
|
||||
- 정산 계산 규칙 중복 제거
|
||||
- snapshot 우선 조회 / live 계산 fallback 규칙 재사용
|
||||
|
||||
## Repository / Query 방향
|
||||
|
||||
### 1. 에이전트 목록용 조회 추가
|
||||
- 필요 기능
|
||||
- AGENT role member 목록
|
||||
- 각 agent의 현재 활성 creator count
|
||||
- 각 agent의 현재 월 기준 5종 정산 합계
|
||||
- 구현 방식
|
||||
- `AdminAgentReadQueryRepository`에서 전용 projection query를 제공한다.
|
||||
- 기본 에이전트 목록과 활성 creator count는 기존 Querydsl projection query로 조회한다.
|
||||
- 현재 월 5종 정산 합계는 각 목록 item마다 기존 `AgentCalculateQueryRepository` total 조회 메서드를 재사용해 채운다.
|
||||
- 정산 row가 없는 agent도 목록에 남겨야 하므로 기존 total 조회 응답의 `0` 기본값을 그대로 사용한다.
|
||||
|
||||
### 2. 크리에이터 검색용 조회 추가
|
||||
- 필요 기능
|
||||
- creator nickname 기준 검색
|
||||
- 현재 활성 소속 agent nullable join
|
||||
- 구현 방식
|
||||
- `AdminAgentReadQueryRepository`에서 전용 검색 query를 제공한다.
|
||||
|
||||
### 3. 소속 크리에이터 목록 조회 추가
|
||||
- 기존 `AgentCalculateQueryRepository.getAssignedCreators()`를 재사용할 수 있다.
|
||||
- 다만 `assignedAt`을 응답에 내려야 하면 projection을 확장하거나 관리자 전용 projection을 추가해야 한다.
|
||||
|
||||
## 인증/예외 처리 원칙
|
||||
- 새 관리자 read 엔드포인트는 모두 `@PreAuthorize("hasRole('ADMIN')")`를 사용한다.
|
||||
- `agentId`가 존재하지 않거나 role이 `AGENT`가 아니면 `SodaException(messageKey = ...)`로 실패한다.
|
||||
- `creator/search`는 기존 `admin/member/search` 관례처럼 최소 검색어 길이 검증을 적용한다.
|
||||
- 정산 계산식, snapshot 우선 전략, assignment/ratio history 적용 규칙은 기존 `partner.agent.calculate` 동작을 그대로 사용한다.
|
||||
|
||||
## 테스트 설계
|
||||
|
||||
### 1. 컨트롤러 테스트
|
||||
- `ADMIN` 접근 성공
|
||||
- 익명 사용자 접근 실패
|
||||
- `AGENT` 또는 일반 사용자 접근 실패
|
||||
|
||||
### 2. 서비스/리포지토리 테스트
|
||||
- 에이전트 목록에서 닉네임과 현재 활성 creator count가 맞는지 검증
|
||||
- 크리에이터 검색 결과에 현재 agent 소속 정보가 올바르게 붙는지 검증
|
||||
- 특정 agent 소속 크리에이터 목록이 현재 활성 구간 기준으로만 내려오는지 검증
|
||||
|
||||
### 3. 정산 parity 테스트
|
||||
- 동일 기간, 동일 `agentId`에 대해
|
||||
- AGENT 전용 조회 응답
|
||||
- ADMIN 전용 조회 응답
|
||||
- 두 결과의 `totalCount`, `total`, `items`가 동일한지 검증
|
||||
- 대상 5종
|
||||
- live
|
||||
- content
|
||||
- community
|
||||
- channel donation
|
||||
- content donation
|
||||
|
||||
### 4. 설계 대비 구현 계획 누락 체크리스트
|
||||
- [x] 관리자 컨트롤러 테스트에 `ADMIN` 접근 성공, 익명 접근 실패, `AGENT` 또는 일반 사용자 접근 실패 시나리오가 모두 포함되어 있는지 확인한다.
|
||||
- [x] 동일 기간, 동일 `agentId` 기준으로 AGENT 전용 응답과 ADMIN 전용 응답의 `totalCount`, `total`, `items` parity를 5종 모두 검증하는 테스트가 구현 계획에 포함되어 있는지 확인한다.
|
||||
- [x] 구현 계획의 검증 기록 단계에 `무엇을/왜/어떻게`, 실제 실행 명령과 결과, 후속 수정 시 누적 기록 및 `정정` 추가 원칙이 명시되어 있는지 확인한다.
|
||||
- [x] `/admin/partner/agent/list`가 별도 날짜 입력 없이 현재 월 기준 5종 정산 합계를 계산하도록 구현 계획에 반영되어 있는지 확인한다.
|
||||
- [x] 에이전트 목록 응답 DTO와 리스트 조회 테스트에 `live/content/community/contentDonation/channelDonation` 5종 `agentSettlementAmount` summary 필드가 모두 반영되어 있는지 확인한다.
|
||||
- [x] 해당 월에 정산 내역이 없는 에이전트도 목록에서 제외하지 않고 5종 합계를 `0`으로 표기하는 쿼리/응답/테스트 계획이 포함되어 있는지 확인한다.
|
||||
- [x] 리스트 summary 값이 같은 월 기준 상세 조회 응답의 `total.agentSettlementAmount`와 일치하는지 검증하는 회귀 테스트가 구현 계획에 포함되어 있는지 확인한다.
|
||||
|
||||
## 구현 시 유의사항
|
||||
- 관리자용 정산 API를 새로 만든다고 해서 계산 query를 복제하지 않는다.
|
||||
- 현재 assignment 활성 판정은 시간창 규칙을 반드시 동일하게 사용한다.
|
||||
- 정산 응답 계약은 기존 agent 응답과 불필요하게 분기하지 않는다.
|
||||
- 목록 화면은 요약 정보만 제공하고, 상세 정산은 상세 화면에서만 조회한다.
|
||||
- 목록의 5종 합계는 상세 API의 기간 필터와 별개로, 현재 월 기준 요약값이라는 의미를 문서와 코드에서 동일하게 유지한다.
|
||||
- 해당 월에 정산 내역이 없더라도 에이전트 행은 유지하고 금액은 `0`으로 표기한다.
|
||||
|
||||
## 최종 설계 결론
|
||||
- 이번 기능은 “새 정산 엔진 추가”가 아니라 “기존 agent 정산 계산을 ADMIN에서 조회할 수 있게 read API를 보강”하는 작업으로 본다.
|
||||
- 관리자 페이지 흐름은 아래로 고정한다.
|
||||
1. `/admin/partner/agent/list`로 에이전트 목록과 현재 월 기준 5종 정산 합계 조회
|
||||
2. 상세 화면에서 `/admin/partner/agent/{agentId}/creator/list`로 현재 소속 크리에이터 조회
|
||||
3. 필요 시 `/admin/partner/agent/creator/search`로 크리에이터 검색 후 기존 assignment API로 소속 지정
|
||||
4. 상세 화면에서 `/admin/partner/agent/{agentId}/calculate/*` 5종으로 정산 현황 조회
|
||||
- 목록의 5종 합계는 상세 페이지 이동 포인트용 현재 월 summary이며, 상세 화면의 날짜 범위 조회와 역할을 구분한다.
|
||||
- 현재 월에 정산 내역이 없는 에이전트도 목록에 포함되며, 합계는 0원으로 표시한다.
|
||||
- 이 설계를 기준으로 다음 단계에서는 구현 계획 문서를 작성한다.
|
||||
|
||||
## 검증 기록
|
||||
- 1차 설계
|
||||
- 무엇을: 관리자용 에이전트 목록/상세 read API 구조와 기존 agent 계산 로직 재사용 범위를 문서로 고정했다.
|
||||
- 왜: 현재 코드에는 관리자용 assignment/ratio/finalize는 있지만, 관리자용 agent 정산 상세 조회 API는 없어 read 레이어 설계가 먼저 필요했기 때문이다.
|
||||
- 어떻게:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt` 확인
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt` 확인
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt` 확인
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt` 확인
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt` 확인
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberController.kt` 확인
|
||||
- 결과: 관리자용 read API 부재와 기존 계산 로직 재사용 가능성을 문서에 반영했다.
|
||||
@@ -1,21 +0,0 @@
|
||||
- [x] `admin/partner/agent/**` 현재 구현과 테스트 범위 확인
|
||||
- [x] `partner/agent/**` 현재 구현과 테스트 범위 확인
|
||||
- [x] 관련 DDL/기획 문서에서 요구사항과 변경 배경 추적
|
||||
- [x] git history와 GitHub 메타데이터에서 이전 결정/경고 사항 확인
|
||||
- [x] 제외 대상 2건을 제외하고 남는 실질 이슈만 판정
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 컨텍스트 리뷰
|
||||
- 무엇을: 에이전트 권한 및 정산 기능의 최근 구현/수정 이력, 현재 코드, QA 문서, 관련 테스트를 교차 검토해 남아 있는 실질 결함이 있는지 확인했다.
|
||||
- 왜: 최근 수정으로 일부 validation 이슈는 닫혔지만, event-time 이력 모델과 finalized snapshot 정책이 실제 조회 동작까지 일관되게 반영되는지 재확인이 필요했기 때문이다.
|
||||
- 어떻게:
|
||||
- `GIT_MASTER=1 git status`, `GIT_MASTER=1 git log -30 --oneline`, `GIT_MASTER=1 git log --oneline <merge-base>..HEAD`로 브랜치 상태와 최근 변경 맥락을 확인했다.
|
||||
- `docs/20260408_에이전트권한및정산기능추가.md`, `docs/20260409_partner_agent_assignment_ratio_ddl.sql`, `docs/20260410_에이전트정산기능QA.md`를 읽어 요구사항/후속 수정/제외 대상 2건을 확인했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/**`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/**`, 관련 테스트 파일을 읽어 assignment, ratio, calculate, snapshot 로직을 대조했다.
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.*" --tests "kr.co.vividnext.sodalive.partner.agent.calculate.*"`를 실행해 관련 테스트를 검증했다.
|
||||
- 실행/확인 결과:
|
||||
- 관련 코드/문서 검토 결과, `AgentCalculateQueryRepository.getAssignedCreatorTotalCount/getAssignedCreators`가 `assignedAt` 현재 시점 조건 없이 `unassignedAt is null`만 사용함을 확인했다.
|
||||
- `docs/20260410_에이전트정산기능QA.md:35`에 "미래 assignedAt만 가진 예약 소속은 현재 목록에 노출되지 않아야 한다" 요구사항이 명시되어 있음을 확인했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentAssignedCreatorFutureWindowQaTest.kt`의 첫 테스트가 실제로 실패해 위 요구사항 누락이 재현됨을 확인했다.
|
||||
- `gh` 명령은 현재 환경에 설치되어 있지 않아 GitHub PR/이슈 메타데이터 조회는 불가했다 (`zsh:1: command not found: gh`).
|
||||
@@ -1,200 +0,0 @@
|
||||
# 에이전트 정산 기능 QA 계획
|
||||
|
||||
## QA 목표
|
||||
- 최근 구현된 agent authorization 및 settlement 기능에서 남아 있는 실제 결함을 백엔드 관점으로 점검한다.
|
||||
- 범위는 `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/**`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/**`로 한정한다.
|
||||
- 이미 수용된 두 가지 저우선순위 이슈(빈 finalized 기간 미생성, 중복 finalize 동시성 rough edge)는 제외한다.
|
||||
|
||||
## 작업 체크리스트
|
||||
- [x] 관련 모듈/테스트/기존 작업 문서를 확인해 QA 범위를 정리한다.
|
||||
- [x] 백엔드 QA 시나리오를 20개 이상 도출하고 P0/P1/P2로 분류한다.
|
||||
- [x] P0/P1 시나리오를 중심으로 기존 테스트와 실행형 검증 명령을 수행한다.
|
||||
- [x] 실패 시 재현 경로와 실제 증거를 확보해 결함 여부를 확정한다.
|
||||
- [x] 최종 PASS/FAIL 판정을 정리하고 검증 기록을 남긴다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] assignment/finalize 가드 미체크 항목 회귀 테스트를 추가한다.
|
||||
- [x] calculate empty-result/snapshot pagination/finalized immutability 회귀 테스트를 추가한다.
|
||||
- [x] ratio 목록 응답을 member 단위 current/history 구조로 확장한다.
|
||||
- [x] generic 4종 calculate total 경로를 전용 total 계산 경로로 분리한다.
|
||||
- [x] generic 4종 calculate total 경로를 DB total projection 전용 쿼리로 재리팩터링한다.
|
||||
- [x] channel donation total 경로를 DB total projection 전용 쿼리로 재리팩터링한다.
|
||||
- [x] finalize snapshot 생성 경로의 중복 groupBy/row 변환을 줄인다.
|
||||
- [x] 관련 테스트, 진단, 수동 검증 결과를 문서에 반영한다.
|
||||
|
||||
## 검증 대상 축
|
||||
- assignment create/remove 시간 경계 동작
|
||||
- ratio create/update 검증과 history 동작
|
||||
- calculate 5개 카테고리 조회
|
||||
- snapshot finalize 및 finalized 조회
|
||||
- provenance/source detail 무결성
|
||||
- 신규 validation과 기존 동작의 상호작용
|
||||
|
||||
## 시나리오 목록
|
||||
|
||||
### P0
|
||||
- [x] assignment 생성: 유효한 agent/creator/assignedAt이면 신규 이력 row가 생성된다.
|
||||
- [x] assignment 생성: agentId와 creatorId가 같으면 거부된다.
|
||||
- [x] assignment 생성: agent가 AGENT 역할이 아니면 거부된다.
|
||||
- [x] assignment 생성: creator가 CREATOR 역할이 아니면 거부된다.
|
||||
- [x] assignment 생성: 활성 소속과 시간이 겹치면 거부된다.
|
||||
- [x] assignment 생성: 이전 소속의 `unassignedAt`과 동일한 `assignedAt`은 허용된다.
|
||||
- [x] assignment 해제: 활성 소속이면 `unassignedAt`이 기록된다.
|
||||
- [x] assignment 해제: `unassignedAt <= assignedAt`이면 거부된다.
|
||||
- [x] assigned creator 목록 조회: 다른 agent 소속 creator는 제외된다.
|
||||
- [x] assigned creator 목록 조회: 미래 `assignedAt`만 가진 예약 소속은 현재 목록에 노출되지 않아야 한다.
|
||||
- [x] assigned creator 목록 조회: 미래 `unassignedAt`이 있는 현재 소속은 종료 전까지 유지되어야 한다.
|
||||
- [x] ratio 생성: 유효한 `effectiveFrom`이면 활성 row 종료 후 신규 이력 row가 추가된다.
|
||||
- [x] ratio 생성/수정: `settlementRatio`가 0..100 범위를 벗어나면 거부된다.
|
||||
- [x] ratio 생성/수정: 과거 closed history 구간과 겹치는 `effectiveFrom`이면 거부된다.
|
||||
- [x] calculate 조회: 5개 카테고리 모두 거래 시점 assignment를 기준으로 agent가 갈린다.
|
||||
- [x] calculate 조회: 5개 카테고리 모두 거래 시점 ratio를 기준으로 agentSettlementAmount가 계산된다.
|
||||
- [x] calculate 조회: content는 콘텐츠 개별 ratio와 creator 기본 ratio fallback을 모두 반영한다.
|
||||
- [x] calculate 조회: 일반 정산/채널후원 모두 agent ratio 이력이 없으면 10% fallback이 적용된다.
|
||||
- [x] snapshot finalize: LIVE finalize는 immutable snapshot과 source detail을 함께 저장한다.
|
||||
- [x] snapshot finalize: 기간 내 source row가 여러 개면 summary FK는 비우고 provenance detail만 남긴다.
|
||||
- [x] finalized read: 5개 카테고리 모두 동일 기간 finalized snapshot을 live 계산보다 우선 사용한다.
|
||||
- [x] snapshot finalize: 동일 기간/타입/agent 재요청은 중복 저장 없이 alreadyFinalized=true를 반환한다.
|
||||
|
||||
### P1
|
||||
- [x] admin finalize: 대상 member가 없으면 실패한다.
|
||||
- [x] admin finalize: 대상 member가 AGENT 역할이 아니면 실패한다.
|
||||
- [x] agent controller: 익명 사용자는 creator/list 조회에 실패한다.
|
||||
- [x] admin finalize controller: 익명 사용자는 finalize 호출에 실패한다.
|
||||
- [x] channel donation 조회: 분할 정산 레코드가 있어도 후원 건수는 distinct useCan 기준이다.
|
||||
- [x] calculate 조회: 기간 내 결과가 없으면 total=0, items=[]로 일관되게 반환된다.
|
||||
- [x] assigned creator 목록 조회: 정렬/페이지네이션이 creatorId desc 기준으로 유지된다.
|
||||
|
||||
### P2
|
||||
- [x] snapshot read: snapshot pagination이 `creatorId desc` 기준으로 안정적이다.
|
||||
- [x] ratio 목록 조회: current/history가 페이지 응답에서 누락 없이 노출된다.
|
||||
- [x] provenance detail 합계는 snapshot summary 합계와 일치한다.
|
||||
- [x] finalized 이후 ratio/assignment 변경이 생겨도 동일 finalized 기간 응답은 변하지 않는다.
|
||||
|
||||
## 미체크 항목 재확인 및 후속 방안
|
||||
- `assignment 생성: agentId와 creatorId가 같으면 거부된다.`
|
||||
- 현재 상태: `AdminAgentCreatorService.assignCreator()`가 동일 ID를 `partner.agent.assignment.invalid_relation`으로 즉시 거부한다 (`src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt:22-25`).
|
||||
- 후속 방안: `AdminAgentCreatorServiceTest`에 `agentId == creatorId` 요청을 추가해 동일 예외 키를 직접 검증한다.
|
||||
- `admin finalize: 대상 member가 없으면 실패한다.`
|
||||
- 현재 상태: `AdminAgentSettlementSnapshotService.getAgent()`가 member 미존재 시 `partner.agent.ratio.agent_not_found`를 던진다 (`src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt:122-128`).
|
||||
- 후속 방안: `AdminAgentSettlementSnapshotServiceTest`에 `memberRepository.findById()`가 비어 있는 케이스를 추가해 finalize 진입 전 실패를 검증한다.
|
||||
- `admin finalize: 대상 member가 AGENT 역할이 아니면 실패한다.`
|
||||
- 현재 상태: 같은 `getAgent()`에서 role이 `AGENT`가 아니면 `partner.agent.ratio.invalid_agent`를 던진다 (`src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt:122-128`).
|
||||
- 후속 방안: `AdminAgentSettlementSnapshotServiceTest`에 `USER` 또는 `CREATOR` role member를 주입해 예외 키를 직접 검증한다.
|
||||
- `calculate 조회: 기간 내 결과가 없으면 total=0, items=[]로 일관되게 반환된다.`
|
||||
- 현재 상태: 조회 repository는 total count를 `?: 0`으로 반환하고, 페이지 대상 creator가 없으면 빈 목록을 반환한다. 서비스도 이 값을 그대로 응답으로 조립한다 (`src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt:272-326`, `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt:200-211`).
|
||||
- 후속 방안: in-range 데이터가 전혀 없는 fixture로 `AgentCalculateService` 또는 query integration test를 추가해 `total.count/total.totalCan/...`가 모두 0이고 `items=[]`인 응답 계약을 직접 고정한다.
|
||||
- `snapshot read: snapshot pagination이 creatorId desc 기준으로 안정적이다.`
|
||||
- 현재 상태: snapshot 조회는 `findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(...)`를 사용해 정렬 기준 자체는 명시되어 있다 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotRepository.kt:14-19`). 다만 다건 snapshot에서 offset/limit slicing 순서를 직접 검증하는 테스트는 없다.
|
||||
- 후속 방안: `AgentCalculateServiceTest`에 creatorId가 다른 snapshot 3건 이상을 주입하고, `offset/limit`별 응답 순서와 크기를 검증하는 회귀 테스트를 추가한다.
|
||||
- `ratio 목록 조회: current/history가 페이지 응답에서 누락 없이 노출된다.`
|
||||
- 현재 상태: 현재 응답 DTO는 `items: List<GetAgentSettlementRatioItem>` 단일 flat 구조이고 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/GetAgentSettlementRatioResponse.kt:6-17`), query도 ratio row를 flat list로만 조회한다 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt:23-49`). 기존 서비스 테스트도 flat 목록 1건만 검증한다 (`src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt:344-367`).
|
||||
- 구현 방안: `current/history`를 분리한 응답 DTO를 새로 정의하고, query/service 계층에서 member별 ratio row를 current 1건 + history n건으로 재구성하도록 응답 모델을 확장한다. 페이지 기준은 member 단위로 재정의하고, current/history 누락 회귀 테스트를 함께 추가한다.
|
||||
- `finalized 이후 ratio/assignment 변경이 생겨도 동일 finalized 기간 응답은 변하지 않는다.`
|
||||
- 현재 상태: finalize는 snapshot/source detail에 당시 값을 저장하고 (`src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt:44-202`), 읽기 경로는 finalized snapshot을 live 계산보다 우선 사용한다 (`src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt:458-598`). 다만 finalize 이후 assignment/ratio를 실제로 바꾼 뒤 같은 기간 재조회가 불변인지 직접 검증하는 테스트는 없다.
|
||||
- 후속 방안: finalize 이후 assignment/ratio fixture를 변경한 뒤 동일 기간 read 응답이 snapshot 값 그대로 유지되는지를 검증하는 통합 회귀 테스트를 추가한다.
|
||||
|
||||
## 추가 구조/성능 메모
|
||||
- `우선 반영 1순위: AgentCalculateService.buildSettlementByCreatorResponse()`의 `totalRowsLoader` 기반 total 계산을 DB total query로 분리한다.
|
||||
- 현재 경로: generic 4종(LIVE/CONTENT/COMMUNITY/CONTENT_DONATION) total은 `totalRowsLoader(startDate, endDate).toMergedResponseItems().toResponseTotal()`로 계산된다 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt:200-203`).
|
||||
- 현재 비용: 기간 전체 `GetAgentCreatorSettlementSummaryQueryData`를 메모리에 적재한 뒤, 각 row마다 `toResponseItem()`에서 `BigDecimal` 기반 금액 계산을 수행하고 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt:17-40`), 다시 `groupBy { creatorId }`로 creator별 병합 후 (`.../GetAgentCreatorSettlementSummaryQueryData.kt:60-77`), 마지막에 `sumOf`로 grand total을 한 번 더 순회한다 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentSettlementByCreatorResponse.kt:33-43`).
|
||||
- 왜 1순위인가: pagedRowsLoader는 페이지 범위만 읽지만 totalRowsLoader는 기간 전체 row를 읽는다. 따라서 메모리 사용량과 GC 비용, total 계산 지연을 동시에 줄이려면 total 경로를 먼저 제거하는 편이 효과가 가장 크다.
|
||||
- row granularity 주의점: total을 단순 `sum(totalCan)`으로 바꾸면 안 된다. 현재 row는 creator 1건이 아니라 assignment/agent ratio/settlement ratio 기준으로 분할되어 있고 (`src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt:392-596`), 같은 creator도 콘텐츠 개별 ratio 차이로 여러 row가 생긴다 (`src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt:145-176`). 또한 거래 시점 assignment/agent ratio 이력 때문에 row가 갈라진다 (`.../AgentCalculateQueryRepositoryTest.kt:328-381`, `421-470`).
|
||||
- 핵심 관찰: total 계산에서 creator별 병합 자체는 필수가 아니다. 현재 로직은 row → creator 병합 → grand total 순서지만, 최종 total 값은 row별 계산 결과를 그대로 모두 더한 값과 동일하다. 즉 total 전용 경로는 creator 응답 shape를 만들 필요 없이 “현재 row granularity의 정산 결과 총합”만 DB에서 반환하면 된다.
|
||||
- 권장 구현 방안: `AgentCalculateQueryRepository`에 generic 4종용 `getCalculate*Total()` 전용 query를 추가하고, `GetAgentSettlementByCreatorTotal`에 대응하는 total projection을 `fetchOne()`으로 바로 반환한다. 이때 현재 반올림/비율 적용 semantics를 유지하려면, 기존 grouped row granularity를 유지한 뒤 그 row 단위 정산 금액을 다시 합산하는 형태가 필요하다. Spring Boot 2.7 + Querydsl JPA 제약을 고려하면, 1차 권장은 파생 테이블(native SQL 또는 Querydsl SQL/JPASQLQuery fallback) 기반 total query이고, 로컬 선례는 total을 목록 쿼리와 분리한 `AdminChannelDonationCalculateQueryRepository.getChannelDonationSettlementTotal()` / `CreatorAdminChannelDonationCalculateQueryRepository.getChannelDonationSettlementTotal()`이다 (`src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt:33-54`, `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt:18-38`).
|
||||
- 차선책: DB query 분리가 바로 어렵다면, 최소한 total 계산에서 `.toMergedResponseItems()`를 제거하고 row를 단일 `fold`로 누적해 creator별 `groupBy`/중간 리스트 생성을 없앨 수 있다. 다만 이 방법은 기간 전체 row 적재 자체는 남기므로 임시 완화책으로만 본다.
|
||||
- 구현 결과(1차): `List<GetAgentCreatorSettlementSummaryQueryData>.toResponseTotal()` 전용 경로를 추가해 generic 4종 total 계산에서 creator별 `groupBy`와 중간 `GetAgentSettlementByCreatorItem` 리스트 병합을 제거했다.
|
||||
- 구현 결과(2차): `AgentCalculateQueryRepository`에 generic 4종용 `getCalculate*ByCreatorTotal()` native SQL derived-table query를 추가해 total을 DB에서 바로 계산하도록 바꿨다. 서비스는 더 이상 full row list를 total 계산용으로 읽지 않고, paged item 조회에만 기존 Querydsl row query를 사용한다.
|
||||
- `AdminAgentSettlementSnapshotService.finalizeSnapshots()`는 DB가 assignment/ratio 단위로 이미 집계한 row를 creator별 snapshot 1건으로 다시 합산하는데, 이 합산 자체는 필요하지만 `rows.groupBy { creatorId }` 뒤에 `toMergedResponseItems()`가 다시 같은 key로 groupBy를 수행해 불필요한 재-groupBy가 한 번 더 발생한다. 반면 source detail은 query row 1건을 detail 1건으로 보존하므로 추가 집계는 없고, 동일 row의 `toResponseItem()` 변환만 summary/detail에서 각각 반복된다 (`src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt:131-179`, `182-253`).
|
||||
- 구현 방안: finalize 내부에서는 creator별 재-groupBy 없는 전용 merge 로직을 두고, row 변환 결과를 summary/detail 양쪽에서 재사용하도록 draft 구조를 조정한다.
|
||||
- 구현 결과: `SnapshotAggregateDraft` 단일 패스 누적 구조로 summary/source detail 수치를 함께 합산하도록 바꿔 creator별 재-groupBy와 중복 `toResponseItem()` 변환을 제거했다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 QA
|
||||
- 무엇을:
|
||||
- assignment/ratio/calculate/snapshot/controller 축의 기존 테스트를 집중 실행했다.
|
||||
- `assigned creator list`의 시간창(window) 처리 누락 여부를 임시 DataJpa 재현 테스트로 검증했다.
|
||||
- 왜:
|
||||
- 기존 테스트가 보장하는 범위와, 목록 조회처럼 테스트 공백이 있는 시간 경계 시나리오를 분리해서 확인해야 실제 latent bug를 잡을 수 있기 때문이다.
|
||||
- 어떻게:
|
||||
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotControllerTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateControllerTest` → BUILD SUCCESSFUL
|
||||
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorControllerTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → BUILD SUCCESSFUL
|
||||
- 실패 재현 1: 임시 QA 테스트에서 미래 `assignedAt` 예약 소속 목록 노출 검증 → `TEST-kr.co.vividnext.sodalive.partner.agent.calculate.AgentAssignedCreatorFutureWindowQaTest.xml` 기준 `expected <0> but was <1>`
|
||||
- 실패 재현 2: 임시 QA 테스트에서 미래 `unassignedAt` 이전 현재 소속 유지 검증 → `TEST-kr.co.vividnext.sodalive.partner.agent.calculate.AgentAssignedCreatorFutureWindowQaTest.xml` 기준 `expected <1> but was <0>`
|
||||
|
||||
### 2차 QA 문서 정리
|
||||
- 무엇을:
|
||||
- 이후 수정/테스트로 증거가 확보된 QA 항목만 문서 상태를 최신화했다.
|
||||
- 왜:
|
||||
- 현재 QA 문서는 일부 항목이 이미 수정/검증되었는데도 실패 메모나 미체크 상태로 남아 있어, 실제 상태와 문서가 어긋나 있었기 때문이다.
|
||||
- 어떻게:
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt:91-110` 확인 → 현재 시각 활성 구간 creator list 회귀 테스트가 미래 `assignedAt` 미노출 / 미래 `unassignedAt` 이전 유지 두 조건을 함께 검증함을 확인했다.
|
||||
- `docs/20260408_에이전트권한및정산기능추가.md:644-658` 확인 → 위 creator list window 버그가 17차 수정에서 실제 수정되고 테스트/빌드 검증까지 완료되었음을 확인했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt:191-201` 확인 → provenance detail 각 수치 합계가 snapshot summary와 일치하는 검증이 이미 존재함을 확인했다.
|
||||
- 위 세 근거에 따라 creator list 2개 항목의 실패 메모를 제거하고, `provenance detail 합계` 항목을 완료 처리했다.
|
||||
|
||||
### 3차 미체크 항목/구조 점검
|
||||
- 무엇을:
|
||||
- QA 문서에 남아 있던 미체크 항목을 구현/테스트 기준으로 다시 분류했다.
|
||||
- `AgentCalculateService`의 creator 합산 경로와 `AdminAgentSettlementSnapshotService` finalize 경로의 중복 집계 여부를 코드 기준으로 재점검했다.
|
||||
- 왜:
|
||||
- 현재 문서에는 “구현은 이미 있으나 테스트가 빠진 항목”, “응답 모델 자체가 요구를 충족하지 못하는 항목”, “기능 결함은 아니지만 구조적으로 비효율적인 항목”이 섞여 있어 후속 작업 우선순위가 드러나지 않았기 때문이다.
|
||||
- 어떻게:
|
||||
- 명령 실행 없음: 이번 단계는 문서/코드 정합성 점검 목적이라 읽기 전용 파일 검토만 수행했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt:22-25` 확인 → 동일 agentId/creatorId 거부 로직은 구현돼 있으나 직접 테스트가 없음을 확인했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt:69-89` 및 `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateControllerTest.kt:37-62` 확인 → assigned creator 정렬/페이지네이션은 이미 검증돼 있어 완료 처리했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/GetAgentSettlementRatioResponse.kt:6-17` 및 `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt:23-49` 확인 → ratio 목록은 flat 응답만 제공하므로 `current/history` 요구를 충족하려면 응답 모델과 조회 로직 확장이 필요함을 확인했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt:141-168,172-211` 확인 → finalized snapshot 경로가 전체 snapshot row를 메모리에서 page/total 처리함을 확인했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt:131-179,182-253` 확인 → finalize summary 생성 시 creator별 재-groupBy가 한 번 더 수행되고, source detail은 집계 대신 row 변환만 반복함을 확인했다.
|
||||
|
||||
### 4차 totalRowsLoader 개선 방향 구체화
|
||||
- 무엇을:
|
||||
- `buildSettlementByCreatorResponse()`의 `totalRowsLoader` 경로만 따로 떼어 메모리/성능 병목과 개선 우선순위를 구체화했다.
|
||||
- 왜:
|
||||
- 기존 성능 메모는 “live 계산 경로 전반을 DB 쿼리로 옮기자” 수준이라 범위가 넓었고, 실제로 어떤 부분을 먼저 고쳐야 효과가 큰지 드러나지 않았기 때문이다.
|
||||
- 어떻게:
|
||||
- 명령 실행 없음: 이번 단계도 읽기 전용 코드/문서 검토만 수행했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt:200-203` 확인 → total이 `totalRowsLoader(...).toMergedResponseItems().toResponseTotal()`로 계산됨을 다시 확인했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt:17-40,60-77` 및 `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentSettlementByCreatorResponse.kt:33-43` 확인 → row별 금액 계산, creator별 groupBy 병합, grand total 재합산이 모두 애플리케이션 메모리에서 수행됨을 확인했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt:392-596` 확인 → generic 4종 쿼리가 assignment/agent ratio/settlement ratio 기준의 grouped row를 반환함을 확인했다.
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt:145-176,328-381,421-470` 확인 → 같은 creator도 settlement ratio/assignment/agent ratio 이력 때문에 여러 row로 분할될 수 있어 total 계산이 단순 `sum(totalCan)`으로 대체되면 안 됨을 확인했다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt:33-54` 및 `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/channelDonation/CreatorAdminChannelDonationCalculateQueryRepository.kt:18-38` 확인 → 이 저장소 안에 total 전용 `fetchOne()` aggregate query 선례가 이미 있음을 확인했다.
|
||||
|
||||
### 5차 미체크 항목 구현 및 성능 완화
|
||||
- 무엇을:
|
||||
- assignment/finalize 가드, calculate empty-result, snapshot pagination, finalized immutability 회귀 테스트를 추가했다.
|
||||
- ratio 목록 응답을 member 단위 `current/history` 구조로 확장하고, generic total/finalize 내부 계산 경로를 가볍게 정리했다.
|
||||
- 왜:
|
||||
- 문서에 남아 있던 미체크 항목을 실제 테스트와 응답 계약으로 고정해야 후속 회귀를 막을 수 있고, total/finalize 경로의 불필요한 groupBy/중간 객체 생성을 줄여야 현재 범위 안에서 안전하게 성능을 완화할 수 있기 때문이다.
|
||||
- 어떻게:
|
||||
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest` → BUILD SUCCESSFUL
|
||||
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → BUILD SUCCESSFUL
|
||||
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.assignment.AdminAgentCreatorServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.settlement.AdminAgentSettlementSnapshotServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → BUILD SUCCESSFUL
|
||||
- 참고: `lsp_diagnostics`는 현재 환경에 Kotlin LSP가 없어 사용할 수 없었고, 대신 위 Gradle 실행에서 `compileKotlin`/`compileTestKotlin`까지 함께 통과한 것으로 컴파일 진단을 대체했다.
|
||||
|
||||
### 6차 generic total DB projection 리팩터링
|
||||
- 무엇을:
|
||||
- generic 4종(LIVE/CONTENT/COMMUNITY/CONTENT_DONATION) total 계산을 full row load 기반 Kotlin 합산에서 DB total projection 전용 쿼리로 교체했다.
|
||||
- 서비스 테스트와 DataJpa parity 테스트를 추가해 새 DB total이 기존 Kotlin total과 동일한지 고정했다.
|
||||
- Oracle 리뷰에서 지적된 `CONTENT` total grouping drift(`explicit 70` vs `null -> fallback 70`)를 보정하고 전용 회귀 테스트를 추가했다.
|
||||
- 왜:
|
||||
- 1차 완화 이후에도 total 계산은 기간 전체 grouped row를 메모리로 읽어야 했고, 이 비용은 기간이 커질수록 그대로 남았다. 요청한 방향대로 total projection을 DB로 내리면서도 row-level rounding semantics를 유지하려면 parity 테스트와 함께 옮겨야 했다.
|
||||
- 어떻게:
|
||||
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest` → BUILD SUCCESSFUL
|
||||
- 성공: `./gradlew build` → BUILD SUCCESSFUL
|
||||
- 성공(수동 확인): `./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldMatchDbTotalProjectionForContentRowsSplitByEffectiveSettlementRatio --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldMatchDbTotalProjectionAcrossAllGenericCategoriesWhenAgentRatioHistorySplitsRows` → BUILD SUCCESSFUL
|
||||
- 성공(Oracle 후속 보정): `./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldMatchDbTotalProjectionWhenExplicitAndFallbackSeventyMustStaySeparated --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest` → BUILD SUCCESSFUL
|
||||
- 성공(Oracle 후속 보정): `./gradlew build` → BUILD SUCCESSFUL
|
||||
- 참고: Kotlin LSP 부재로 `lsp_diagnostics`는 여전히 실행 불가했고, 이번 단계도 `compileKotlin`/`compileTestKotlin` 포함 Gradle 결과를 타입/컴파일 진단 근거로 사용했다.
|
||||
|
||||
### 7차 channel donation total DB projection 리팩터링
|
||||
- 무엇을:
|
||||
- `AgentCalculateService.getChannelDonationByCreator()`의 total 계산을 full row load 기반 Kotlin 합산에서 DB total projection 전용 쿼리로 교체했다.
|
||||
- split `useCanCalculate`와 agent 비율 이력이 섞인 채널후원에서도 새 DB total이 기존 Kotlin total과 같은지 서비스/Repository 테스트로 고정했다.
|
||||
- 왜:
|
||||
- generic 4종 total만 DB projection으로 내려가고 채널후원 total은 여전히 전체 row를 읽고 있었기 때문에, 동일한 최적화 방향을 채널후원에도 적용해 total 계산용 메모리 적재를 제거할 필요가 있었다.
|
||||
- 어떻게:
|
||||
- 성공: `./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest` → BUILD SUCCESSFUL
|
||||
- 성공: `./gradlew build && ./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest.shouldMatchDbTotalProjectionForChannelDonationWithSplitCalculatesAndRatioHistory` → BUILD SUCCESSFUL
|
||||
- 참고: Kotlin LSP 부재로 `lsp_diagnostics`는 실행 불가했고, 이번 단계도 `compileKotlin`/`compileTestKotlin` 포함 Gradle 결과를 타입/컴파일 진단 근거로 사용했다.
|
||||
@@ -1,24 +0,0 @@
|
||||
# 에이전트 검색 기능 추가
|
||||
|
||||
## 작업 체크리스트
|
||||
- [x] 관리자 에이전트 검색 관련 기존 controller/service/query/test 패턴을 확인한다.
|
||||
- [x] 에이전트 검색 API 위치를 확정하고 요청/응답 형태를 정의한다.
|
||||
- [x] 에이전트 닉네임 검색 동작을 검증하는 테스트를 먼저 추가한다.
|
||||
- [x] 에이전트 닉네임으로 검색해 `memberId`를 식별할 수 있는 조회 기능을 구현한다.
|
||||
- [x] 관련 진단/테스트/수동 QA를 수행하고 결과를 기록한다.
|
||||
|
||||
## 검증 기준
|
||||
- 관리자 권한 진입점에서 검색 API가 노출되어야 한다.
|
||||
- 검색어로 에이전트 닉네임을 조회했을 때 UI가 사용할 식별자(`memberId`)가 응답에 포함되어야 한다.
|
||||
- 기존 관리자 에이전트 read 패키지의 응답/검색 패턴을 따른다.
|
||||
|
||||
## 검증 기록
|
||||
- 1차 구현
|
||||
- 무엇을: `admin.partner.agent.read` 패키지에 에이전트 닉네임 검색 API를 추가하고 관련 controller/service/query/test를 확장했다.
|
||||
- 왜: 관리자 정산 비율 입력 시 memberId 직접 입력 대신 에이전트 검색 기반 선택을 지원하기 위해
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest"` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest.shouldAllowAdminRoleForAgentNicknameSearch" --info` → 성공
|
||||
- 수동 QA 확인 응답: `{"success":true,"message":null,"data":[{"id":11,"nickname":"agent-a"}],"errorProperty":null}`
|
||||
- 참고: Kotlin LSP가 환경에 구성되어 있지 않아 LSP 진단 대신 Gradle compile/test/build 결과로 검증했다.
|
||||
@@ -1,19 +0,0 @@
|
||||
# 에이전트 정산 비율 수정 오류 대응 계획
|
||||
|
||||
- [x] 에이전트 정산 비율 수정 API/서비스/엔티티/리포지토리 흐름을 확인한다.
|
||||
- [x] `Duplicate entry '2-1' for key 'agent_settlement_ratio.uk_agent_settlement_ratio_member_active'` 발생 조건과 현재 활성 비율 갱신 방식의 충돌 지점을 확인한다.
|
||||
- [x] 실패를 재현하는 테스트를 먼저 추가하고, 테스트가 의도한 이유로 실패하는지 확인한다.
|
||||
- [x] 기존 활성 비율을 안전하게 종료한 뒤 새 비율을 저장하도록 최소 범위로 수정한다.
|
||||
- [x] 관련 테스트와 필요한 검증 명령을 실행하고 결과를 문서 하단에 누적 기록한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 1차 구현
|
||||
- 무엇을: 에이전트 정산 비율 수정 시 기존 활성 row 종료를 먼저 flush하도록 바꾸고, active unique 제약 충돌 시 같은 세션 재조회 없이 비즈니스 예외로 변환하도록 수정했다.
|
||||
- 왜: 기존 활성 row가 DB에 닫히기 전에 새 row insert가 먼저 flush되면 `uk_agent_settlement_ratio_member_active` 충돌이 발생하고, 그 뒤 같은 세션 재조회가 이어지면 Hibernate `null id` assertion이 연쇄로 발생할 수 있기 때문이다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest` → 실패, 수정 전 flush 순서 검증이 깨지고 예외 후 재조회 검증도 실패해 재현 확인.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest` → 성공.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → 성공.
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.*"` → 성공.
|
||||
- `./gradlew ktlintCheck` → 성공.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user