From aaf6a1779f76deef24252b95f4e8f3b6ea8ae776 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 20 Feb 2026 11:14:26 +0900 Subject: [PATCH 01/22] =?UTF-8?q?AGENTS=20=EC=9E=91=EC=97=85=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=A5=BC=20=EC=B5=9C=EC=8B=A0=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=ED=8E=B8=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 167 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 140 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a8b9224..d5f399ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,37 +1,150 @@ -> 이 문서는 본 저장소에서 **AI Coding Agent가 반드시 따라야 할 개발 헌법(운영 규칙)**이다. -> 모든 신규 코드는 본 문서를 최우선 기준으로 작성한다. +# AGENTS.md ---- +## 문서 목적 +- 이 문서는 `/Users/klaus/Develop/sodalive/Server/sodalive` 저장소에서 작업하는 에이전트용 실행 가이드다. +- 목표는 "추측 최소화 + 기존 패턴 준수 + 검증 우선"이다. +- 이 문서의 규칙은 코드/테스트/문서 변경 모두에 적용한다. -## 0. 전제 -질문에 대한 답변과 설명은 한국어로 한다. +## 커뮤니케이션 규칙 +- **"질문에 대한 답변과 설명은 한국어로 한다."** +- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다. +- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다. ---- +## 프로젝트 개요 +- 빌드 도구: Gradle Wrapper (`./gradlew`) +- 언어/런타임: Kotlin + Java 17 +- 프레임워크: Spring Boot 2.7.14 +- 주요 플러그인: `org.jlleitschuh.gradle.ktlint` +- 단일 루트 프로젝트: `settings.gradle.kts`의 `rootProject.name = "sodalive"` -## 15. Commit Standards +## 실행 명령어 (Build/Lint/Test) +아래 명령은 저장소 루트(`/Users/klaus/Develop/sodalive/Server/sodalive`)에서 실행한다. -1. 커밋 메시지는 **반드시 한국어로 작성한다.** -2. 제목(subject)은 **현재형**으로 작성한다. (예: “기능 추가”, “기능 추가함” 금지) -3. 제목은 **50자 이내**로 작성한다. -4. 제목과 본문 사이에는 **반드시 한 줄 공백**을 둔다. -5. 본문은 **한 줄당 72자 이내**로 작성한다. -6. 하나의 문단에서는 72자를 초과할 때만 줄바꿈한다. -7. **공개 API 변경 사항만 설명**하며, 패키지 프라이빗 구현 상세는 포함하지 않는다. -8. **테스트 코드에 대한 언급은 커밋 메시지에 포함하지 않는다.** -9. 제목에 `fix:`, `feat:`, `docs:` 등의 **접두어를 사용하지 않는다.** -10. 제목은 **첫 글자를 대문자로 시작**한다. 단, 함수명 등 소문자가 합당한 경우만 예외를 허용한다. -11. 도구 광고, 브랜딩, 홍보성 문구를 **절대 포함하지 않는다.** -12. 커밋 전에는 **반드시 파일을 개별 stage 한다.** -13. 커밋 전 **`work/scripts/check-commit-message-rules.sh` 검증을 통과해야 한다.** +```bash +./gradlew tasks --all +./gradlew bootRun +./gradlew build +./gradlew clean build +./gradlew test +./gradlew check +./gradlew ktlintCheck +./gradlew ktlintFormat +``` ---- +## 코드 스타일 규칙 -## 16. AI 사용 규칙 (AI Interaction Rules) +### 1) 포맷/기본 규칙 +- `.editorconfig` 기준을 준수한다. +- 인덴트: 공백 4칸. +- 줄바꿈: LF. +- 최대 라인 길이: 130. +- 파일 끝 개행 유지, trailing whitespace 제거. -- 매우 작은 단위의 변경만 수행한다. -- 대규모 리팩터링은 반드시 사전 승인을 요청한다. +### 2) import 규칙 +- 와일드카드 import(`*`)를 사용하지 않는다. +- 사용하지 않는 import를 남기지 않는다. +- import alias(`as`)는 현재 코드베이스에서 사용 사례가 없으므로 지양한다. +- 기존 파일의 import 정렬/그룹 스타일을 그대로 맞춘다. ---- +### 3) 네이밍 규칙 +- 클래스/인터페이스/enum: PascalCase. +- 함수/변수/파라미터: camelCase. +- 상수: UPPER_SNAKE_CASE (`companion object` 내부 `const val`). +- Request/Response DTO는 `...Request`, `...Response` 접미사를 유지한다. +- 서비스/컨트롤러/리포지토리 명명은 역할 접미사(`Service`, `Controller`, `Repository`)를 유지한다. -✅ 본 문서는 **AI와 사람이 동일한 개발 규칙을 공유하기 위한 최상위 기준 문서**이며, -✅ 모든 신규 코드는 본 문서를 기준으로 검토된다. +### 4) 타입/널 처리 +- Kotlin 타입 시스템을 활용하고 nullable(`?`)를 명시한다. +- 불필요한 `Any`/약한 타입을 피하고 구체 타입을 우선한다. +- 기존 코드에서 `!!` 사용이 많지만, 신규 코드는 가능한 안전 호출/가드절/명시적 예외로 대체를 우선 고려한다. + +### 5) API/응답 규칙 +- API 응답은 `ApiResponse.ok(...)`, `ApiResponse.error(...)` 패턴을 따른다. +- 컨트롤러는 도메인 예외를 직접 포맷하지 말고 `SodaException`을 던진다. +- 인증 사용자 필요 시 `@AuthenticationPrincipal(... ) member: Member?` 패턴 + null 가드절을 사용한다. + +### 6) 예외 처리 규칙 +- 비즈니스 예외는 `SodaException(messageKey = "...")` 우선 사용. +- 사용자 노출 문구는 하드코딩보다 `messageKey` 기반 i18n을 우선한다. +- 공통 예외 변환은 `SodaExceptionHandler`에서 수행하므로, 개별 컨트롤러에서 중복 처리하지 않는다. +- 예외를 삼키는 빈 `catch` 블록을 금지한다. + +### 7) 트랜잭션 규칙 +- 서비스 계층에서 `@Transactional`을 사용한다. +- 조회 위주 메서드는 `@Transactional(readOnly = true)`를 우선한다. +- 쓰기 로직은 메서드 단위 `@Transactional`로 경계를 명확히 한다. + +### 8) 비동기/동시성 규칙 +- 비동기 처리는 Kotlin Coroutines 패턴을 따른다. +- `CoroutineScope(Dispatchers.IO)` + `launch` + 예외 처리 패턴을 일관되게 유지한다. +- 생명주기 종료 시 scope 정리(`@PreDestroy`) 패턴을 참고한다. + +### 9) 의존성 주입 +- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다. +- 필드 주입보다 명시적 생성자 주입을 우선한다. + +## 테스트 스타일 규칙 +- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`) +- 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``) +- 검증: `assertEquals`, `assertThrows` 패턴 준수. +- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다. + +## 설정/보안 유의사항 +- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다. +- 비밀값(API Key, Secret, Token, DB 비밀번호)을 코드/문서/로그에 평문으로 남기지 않는다. +- 환경변수/시크릿 파일은 커밋 대상에서 제외한다. + +## Cursor/Copilot 규칙 반영 +`/.cursorrules`, `/.cursor/rules/`, `/.github/copilot-instructions.md` 파일은 현재 없다. +별도 규칙 파일이 추가되면 본 문서보다 해당 규칙을 우선 반영한다. + +## 커밋 메시지 규칙 (표준 Conventional Commits) +기본 형식: +```text +(scope): +``` + +핵심 규칙: +- `type`은 소문자 사용 (`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등). +- `scope`는 선택 사항이지만 가능하면 모듈 단위로 명시. +- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다. +- 브레이킹 변경은 `!` 또는 `BREAKING CHANGE:` footer로 명시. +- 이슈 참조는 footer 사용(예: `Refs: #123`). + +예시: +```text +feat(chat): 채팅 쿼터 구매 엔드포인트를 추가한다 +fix(member): 마이페이지 API의 null 인증 주체 처리를 보완한다 +refactor(content): 랭킹 조회 로직을 전용 리포지토리로 분리한다 +``` + +## 작업 절차 체크리스트 +- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다. +- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다. +- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다. + +## 작업 계획 문서 규칙 (docs) +- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다. +- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다. +- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다. +- 파일명 예시: `20260101_구글계정으로로그인.md` +- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다. +- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다. +- 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다. +- 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다. + +## 문서 유지보수 규칙 +- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다. +- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다. +- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다. +- Cursor/Copilot 규칙 파일이 생기면 해당 내용을 이 문서에 반영한다. +- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다. +- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다. +- 에이전트 안내 문구는 한국어 중심으로 유지한다. +- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다. + +## 에이전트 동작 원칙 +- 추측하지 말고, 근거 파일을 읽고 결정한다. +- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다. +- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다. +- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다. From fe5eefde3119e5333523072f45afba63ccc90285 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 20 Feb 2026 11:47:18 +0900 Subject: [PATCH 02/22] =?UTF-8?q?fix(commit):=20AGENTS=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EA=B3=BC=20=EC=BB=A4=EB=B0=8B=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EA=B2=80=EC=82=AC=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=A0=95=ED=95=A9=ED=99=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 + docs/20260220_커밋메시지검증규칙추가.md | 15 +++ work/scripts/check-commit-message-rules.sh | 148 ++++++++++++++------- 3 files changed, 120 insertions(+), 49 deletions(-) create mode 100644 docs/20260220_커밋메시지검증규칙추가.md diff --git a/AGENTS.md b/AGENTS.md index d5f399ac..6a4ec545 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,10 +118,16 @@ fix(member): 마이페이지 API의 null 인증 주체 처리를 보완한다 refactor(content): 랭킹 조회 로직을 전용 리포지토리로 분리한다 ``` +### 커밋 메시지 검증 절차 +- `git commit` 실행 직전에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다. +- `git commit` 실행 직후에도 `work/scripts/check-commit-message-rules.sh`를 다시 실행해 최종 메시지를 재검증한다. +- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 규칙에 맞게 수정한 뒤 다시 검증한다. + ## 작업 절차 체크리스트 - 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다. - 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다. - 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다. +- 커밋 전/후: `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다. ## 작업 계획 문서 규칙 (docs) - 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다. diff --git a/docs/20260220_커밋메시지검증규칙추가.md b/docs/20260220_커밋메시지검증규칙추가.md new file mode 100644 index 00000000..a22606d2 --- /dev/null +++ b/docs/20260220_커밋메시지검증규칙추가.md @@ -0,0 +1,15 @@ +# 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`)을 확인했다. diff --git a/work/scripts/check-commit-message-rules.sh b/work/scripts/check-commit-message-rules.sh index db3a9a14..f95abbe3 100755 --- a/work/scripts/check-commit-message-rules.sh +++ b/work/scripts/check-commit-message-rules.sh @@ -1,71 +1,121 @@ -#!/bin/bash +#!/usr/bin/env bash -# Check if a commit message follows project rules -# Rules: 50/72 formatting, no advertisements/branding -# Usage: ./check-commit-message-rules.sh [commit-hash] -# If no commit-hash is provided, checks the latest commit +print_usage() { + echo "Usage:" + echo " $0" + echo " $0 " + echo " $0 --message \"\"" + echo " $0 --message-file " +} -# Determine which commit to check -if [ $# -eq 0 ]; then - commit_ref="HEAD" - echo "Checking latest commit..." -else - commit_ref="$1" - echo "Checking commit: $commit_ref" -fi +load_commit_message() { + if [ $# -eq 0 ]; then + local commit_ref="HEAD" + echo "Checking latest commit..." >&2 + git log -1 --pretty=format:"%s%n%b" "$commit_ref" + return + fi -# Get the commit message -commit_message=$(git log -1 --pretty=format:"%s%n%b" "$commit_ref") + case "$1" in + -h|--help) + print_usage + exit 0 + ;; + --message) + shift + if [ $# -eq 0 ]; then + echo "[FAIL] --message option requires a commit message" + print_usage + exit 1 + fi + echo "Checking provided commit message..." >&2 + printf '%s' "$*" + ;; + --message-file) + shift + if [ $# -ne 1 ]; then + echo "[FAIL] --message-file option requires a file path" + print_usage + exit 1 + fi + if [ ! -f "$1" ]; then + echo "[FAIL] Commit message file not found: $1" + exit 1 + fi + echo "Checking commit message file: $1" >&2 + cat "$1" + ;; + *) + if [ $# -ne 1 ]; then + echo "[FAIL] Invalid arguments" + print_usage + exit 1 + fi -# Split into subject and body -subject=$(echo "$commit_message" | head -n1) -body=$(echo "$commit_message" | tail -n +2 | sed '/^$/d') + local commit_ref="$1" + if ! git rev-parse --verify "$commit_ref^{commit}" >/dev/null 2>&1; then + echo "[FAIL] Invalid commit reference: $commit_ref" + exit 1 + fi + + echo "Checking commit: $commit_ref" >&2 + git log -1 --pretty=format:"%s%n%b" "$commit_ref" + ;; + esac +} + +commit_message=$(load_commit_message "$@") +subject=$(printf '%s\n' "$commit_message" | head -n1) +body=$(printf '%s\n' "$commit_message" | tail -n +2) echo "Checking commit message format..." echo "Subject: $subject" -# Check subject line length -subject_length=${#subject} -if [ $subject_length -gt 50 ]; then - echo "[FAIL] Subject line too long: $subject_length characters (max 50)" - exit_code=1 -else - echo "[PASS] Subject line length OK: $subject_length characters" - exit_code=0 +exit_code=0 + +if [ -z "$subject" ]; then + echo "[FAIL] Subject must not be empty" + exit 1 fi -# Check body line lengths if body exists -if [ -n "$body" ]; then - echo "Checking body line lengths..." - while IFS= read -r line; do - line_length=${#line} - if [ $line_length -gt 72 ]; then - echo "[FAIL] Body line too long: $line_length characters (max 72)" - echo "Line: $line" - exit_code=1 - fi - done <<< "$body" +subject_pattern='^([a-z]+)(\([a-z0-9._/-]+\))?(!)?: (.+)$' +if [[ "$subject" =~ $subject_pattern ]]; then + type="${BASH_REMATCH[1]}" + description="${BASH_REMATCH[4]}" - if [ $exit_code -eq 0 ]; then - echo "[PASS] All body lines within 72 characters" + echo "[PASS] Subject follows Conventional Commit format" + echo "[PASS] Type is lowercase: $type" + + if printf '%s\n' "$description" | grep -Eq '[가-힣]'; then + echo "[PASS] Description contains Korean text" + else + echo "[FAIL] Description must contain Korean text" + exit_code=1 fi else - echo "[INFO] No body content to check" + echo "[FAIL] Subject must match: (scope): " + echo " scope is optional, example: feat: 기능을 추가한다" + exit_code=1 fi -# Check for advertisements, branding, or promotional content -echo "Checking for advertisements and branding..." -if echo "$commit_message" | grep -qi "generated with\|claude code\|anthropic\|co-authored-by.*claude\|🤖"; then - echo "[FAIL] Commit message contains advertisements, branding, or promotional content" - exit_code=1 -else - echo "[PASS] No advertisements or branding detected" +if [ -n "$body" ] && printf '%s\n' "$body" | grep -Eq '^Refs:'; then + while IFS= read -r refs_line; do + if ! printf '%s\n' "$refs_line" | grep -Eq '^Refs: #[0-9]+(, #[0-9]+)*$'; then + echo "[FAIL] Refs footer format is invalid: $refs_line" + echo " expected format: Refs: #123 or Refs: #123, #456" + exit_code=1 + fi + done < <(printf '%s\n' "$body" | grep -E '^Refs:') + + if [ $exit_code -eq 0 ]; then + echo "[PASS] Refs footer format is valid" + fi fi if [ $exit_code -eq 0 ]; then - echo "[PASS] Commit message follows all rules" + echo "[PASS] Commit message follows AGENTS.md rules" else - echo "[FAIL] Commit message violates project rules" + echo "[FAIL] Commit message violates AGENTS.md rules" fi exit $exit_code From a178fb655824c44f4f2e3736cf549cb562f29694 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 20 Feb 2026 11:47:24 +0900 Subject: [PATCH 03/22] =?UTF-8?q?docs(lsp):=20Markdown=20LSP=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=98=EC=98=81=20=EA=B8=B0=EB=A1=9D=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260220_lsp설정추가.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/20260220_lsp설정추가.md diff --git a/docs/20260220_lsp설정추가.md b/docs/20260220_lsp설정추가.md new file mode 100644 index 00000000..af0db311 --- /dev/null +++ b/docs/20260220_lsp설정추가.md @@ -0,0 +1,14 @@ +# 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`을 확인했다. From 6cf9a353f4fb5a9b5903b9b1564c4319a1c61480 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 20 Feb 2026 16:25:58 +0900 Subject: [PATCH 04/22] =?UTF-8?q?docs(opencode):=20/commit=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=9E=91=EC=97=85=20=EA=B8=B0=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260220_커스텀커맨드커밋추가.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/20260220_커스텀커맨드커밋추가.md diff --git a/docs/20260220_커스텀커맨드커밋추가.md b/docs/20260220_커스텀커맨드커밋추가.md new file mode 100644 index 00000000..a496a45e --- /dev/null +++ b/docs/20260220_커스텀커맨드커밋추가.md @@ -0,0 +1,15 @@ +# 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`을 확인했다. From 211eb3507cd502a73e1a4cf3c377d63523718538 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 20 Feb 2026 16:26:11 +0900 Subject: [PATCH 05/22] =?UTF-8?q?refactor(commit):=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EC=9D=84=20commit-policy=20=EC=8A=A4?= =?UTF-8?q?=ED=82=AC=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/commands/commit.md | 21 +++++++++++ .opencode/skills/commit-policy/SKILL.md | 46 +++++++++++++++++++++++++ AGENTS.md | 24 ++++--------- docs/20260220_커밋규칙스킬분리.md | 15 ++++++++ 4 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 .opencode/commands/commit.md create mode 100644 .opencode/skills/commit-policy/SKILL.md create mode 100644 docs/20260220_커밋규칙스킬분리.md diff --git a/.opencode/commands/commit.md b/.opencode/commands/commit.md new file mode 100644 index 00000000..0528089b --- /dev/null +++ b/.opencode/commands/commit.md @@ -0,0 +1,21 @@ +--- +description: commit-policy 스킬을 로드해 커밋 메시지 생성과 전후 검증을 수행한다 +agent: build +subtask: true +--- + +작업 목표: +현재 변경사항을 안전하게 커밋한다. + +필수 시작 단계: +1. `skill` 도구로 `commit-policy` 스킬을 먼저 로드한다. + - `skill({ name: "commit-policy" })` + +실행 단계: +1. 로드한 `commit-policy` 스킬의 Hard Requirements와 Execution Flow를 그대로 수행한다. +2. `AGENTS.md`의 최소 정책(형식/한글 description/검증 스크립트)을 항상 만족한다. +3. `$ARGUMENTS`가 있으면 scope 또는 description 의도에 반영하되, 스킬 규칙과 형식을 깨지 않는다. +4. 마지막에 실행 명령과 pre-check/post-check PASS/FAIL 핵심 결과를 간단히 보고한다. + +추가 사용자 의도: +$ARGUMENTS diff --git a/.opencode/skills/commit-policy/SKILL.md b/.opencode/skills/commit-policy/SKILL.md new file mode 100644 index 00000000..b13180e7 --- /dev/null +++ b/.opencode/skills/commit-policy/SKILL.md @@ -0,0 +1,46 @@ +--- +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: `(scope): `. +2. `type` must be lowercase (for example `feat`, `fix`, `chore`, `docs`, `refactor`, `test`). +3. `description` must include Korean text and stay concise in imperative present tone. +4. Optional footer must use `Refs: #123` or `Refs: #123, #456` format. +5. Never commit secret files (`.env`, key/token/secret credential files). +6. Never bypass hooks with `--no-verify`. + +## Execution Flow + +1. Inspect context with: + - `git status` + - `git diff --cached` + - `git diff` + - `git log -5 --oneline` +2. Stage commit target files only. Exclude suspicious secret-bearing files. +3. Draft commit message from the change intent (focus on why, not only what). +4. Run pre-commit validation with the full draft message: + - `./work/scripts/check-commit-message-rules.sh --message ""` +5. If validation fails, revise message and re-run until PASS. +6. Commit using the validated message. +7. Run post-commit validation: + - `./work/scripts/check-commit-message-rules.sh` +8. Report executed commands and PASS/FAIL summary. + +## Output Checklist + +- Final commit subject. +- Whether pre-check passed. +- Whether post-check passed. +- Any excluded files and reason. diff --git a/AGENTS.md b/AGENTS.md index 6a4ec545..77aa2626 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,24 +99,12 @@ 별도 규칙 파일이 추가되면 본 문서보다 해당 규칙을 우선 반영한다. ## 커밋 메시지 규칙 (표준 Conventional Commits) -기본 형식: -```text -(scope): -``` - -핵심 규칙: -- `type`은 소문자 사용 (`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등). -- `scope`는 선택 사항이지만 가능하면 모듈 단위로 명시. +- 커밋 상세 가이드/절차는 `.opencode/skills/commit-policy/SKILL.md`를 단일 기준으로 사용한다. +- 커밋 작업 시작 시 `skill` 도구로 `commit-policy`를 먼저 로드한다. +- 기본 형식은 `(scope): `를 사용한다. +- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다. - 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다. -- 브레이킹 변경은 `!` 또는 `BREAKING CHANGE:` footer로 명시. -- 이슈 참조는 footer 사용(예: `Refs: #123`). - -예시: -```text -feat(chat): 채팅 쿼터 구매 엔드포인트를 추가한다 -fix(member): 마이페이지 API의 null 인증 주체 처리를 보완한다 -refactor(content): 랭킹 조회 로직을 전용 리포지토리로 분리한다 -``` +- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다. ### 커밋 메시지 검증 절차 - `git commit` 실행 직전에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다. @@ -127,7 +115,7 @@ refactor(content): 랭킹 조회 로직을 전용 리포지토리로 분리한 - 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다. - 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다. - 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다. -- 커밋 전/후: `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다. +- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다. ## 작업 계획 문서 규칙 (docs) - 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다. diff --git a/docs/20260220_커밋규칙스킬분리.md b/docs/20260220_커밋규칙스킬분리.md new file mode 100644 index 00000000..29eb6cf9 --- /dev/null +++ b/docs/20260220_커밋규칙스킬분리.md @@ -0,0 +1,15 @@ +# 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`을 확인했다. From c3a2ca66f827e699851a4505e2c2d3a17624f156 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 20 Feb 2026 18:47:01 +0900 Subject: [PATCH 06/22] =?UTF-8?q?fix(comment-nickname):=20deleted=5F=20?= =?UTF-8?q?=EB=A1=9C=20=EC=8B=9C=EC=9E=91=ED=95=98=EB=8A=94=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=A0=91=EB=91=90=EC=82=AC=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260220_삭제닉네임접두사표시정리.md | 22 +++++++++++++ .../comment/AudioContentCommentRepository.kt | 10 ++++-- .../explorer/ExplorerQueryRepository.kt | 5 +-- .../CreatorCommunityCommentRepository.kt | 10 ++++-- .../sodalive/extensions/StringExtensions.kt | 10 ++++++ .../extensions/StringExtensionsTest.kt | 33 +++++++++++++++++++ 6 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 docs/20260220_삭제닉네임접두사표시정리.md create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensionsTest.kt diff --git a/docs/20260220_삭제닉네임접두사표시정리.md b/docs/20260220_삭제닉네임접두사표시정리.md new file mode 100644 index 00000000..e56b4959 --- /dev/null +++ b/docs/20260220_삭제닉네임접두사표시정리.md @@ -0,0 +1,22 @@ +# 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`, `""`인지 검증했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt index f4e679bf..59f23dd6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt @@ -4,6 +4,7 @@ import com.querydsl.core.types.dsl.Expressions import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment +import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix import kr.co.vividnext.sodalive.fcm.PushTokenInfo import kr.co.vividnext.sodalive.fcm.QPushToken.pushToken import kr.co.vividnext.sodalive.fcm.QPushTokenInfo @@ -103,8 +104,10 @@ class AudioContentCommentQueryRepositoryImpl( .orderBy(audioContentComment.createdAt.desc()) .fetch() .map { - it.replyCount = commentReplyCountByAudioContentCommentId(it.id) - it + it.copy( + nickname = it.nickname.removeDeletedNicknamePrefix(), + replyCount = commentReplyCountByAudioContentCommentId(it.id) + ) } } @@ -187,6 +190,9 @@ class AudioContentCommentQueryRepositoryImpl( .limit(limit.toLong()) .orderBy(audioContentComment.createdAt.desc()) .fetch() + .map { + it.copy(nickname = it.nickname.removeDeletedNicknamePrefix()) + } } override fun findPushTokenByContentIdAndCommentParentIdMyMemberId( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index c0c1fae6..f6dace80 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers import kr.co.vividnext.sodalive.explorer.profile.QChannelNotice.channelNotice import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers import kr.co.vividnext.sodalive.explorer.profile.TimeDifferenceResult +import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource @@ -487,7 +488,7 @@ class ExplorerQueryRepository( GetCheersResponseItem( cheersId = it.id!!, memberId = it.member!!.id!!, - nickname = it.member!!.nickname, + nickname = it.member!!.nickname.removeDeletedNicknamePrefix(), profileUrl = if (it.member!!.profileImage != null) { "$cloudFrontHost/${it.member!!.profileImage}" } else { @@ -505,7 +506,7 @@ class ExplorerQueryRepository( GetCheersResponseItem( cheersId = cheers.id!!, memberId = cheers.member!!.id!!, - nickname = cheers.member!!.nickname, + nickname = cheers.member!!.nickname.removeDeletedNicknamePrefix(), profileUrl = if (cheers.member!!.profileImage != null) { "$cloudFrontHost/${cheers.member!!.profileImage}" } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt index d6390ff3..f7e12fe1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment import com.querydsl.core.types.dsl.Expressions import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment +import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import java.time.LocalDateTime @@ -93,8 +94,10 @@ class CreatorCommunityCommentQueryRepositoryImpl( .orderBy(creatorCommunityComment.createdAt.desc()) .fetch() .map { - it.replyCount = commentReplyCountByCommentId(it.id) - it + it.copy( + nickname = it.nickname.removeDeletedNicknamePrefix(), + replyCount = commentReplyCountByCommentId(it.id) + ) } } @@ -174,5 +177,8 @@ class CreatorCommunityCommentQueryRepositoryImpl( .limit(limit) .orderBy(creatorCommunityComment.createdAt.desc()) .fetch() + .map { + it.copy(nickname = it.nickname.removeDeletedNicknamePrefix()) + } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensions.kt b/src/main/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensions.kt index b609b750..0875e7d4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensions.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensions.kt @@ -5,6 +5,8 @@ import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter +private const val DELETED_NICKNAME_PREFIX = "deleted_" + fun String.convertLocalDateTime(format: String): LocalDateTime { val dateTimeFormatter = DateTimeFormatter.ofPattern(format) return LocalDateTime.parse(this, dateTimeFormatter) @@ -24,3 +26,11 @@ fun String.convertLocalDateTime( .withZoneSameInstant(ZoneId.of("UTC")) .toLocalDateTime() } + +fun String.removeDeletedNicknamePrefix(): String { + return if (startsWith(DELETED_NICKNAME_PREFIX)) { + removePrefix(DELETED_NICKNAME_PREFIX) + } else { + this + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensionsTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensionsTest.kt new file mode 100644 index 00000000..9474f1cb --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/extensions/StringExtensionsTest.kt @@ -0,0 +1,33 @@ +package kr.co.vividnext.sodalive.extensions + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class StringExtensionsTest { + @Test + fun shouldRemoveDeletedPrefixWhenNicknameStartsWithDeletedPrefix() { + val nickname = "deleted_testUser" + + val sanitizedNickname = nickname.removeDeletedNicknamePrefix() + + assertEquals("testUser", sanitizedNickname) + } + + @Test + fun shouldKeepNicknameWhenDeletedPrefixDoesNotExist() { + val nickname = "testUser" + + val sanitizedNickname = nickname.removeDeletedNicknamePrefix() + + assertEquals("testUser", sanitizedNickname) + } + + @Test + fun shouldReturnEmptyStringWhenNicknameContainsOnlyDeletedPrefix() { + val nickname = "deleted_" + + val sanitizedNickname = nickname.removeDeletedNicknamePrefix() + + assertEquals("", sanitizedNickname) + } +} From ecef49393b091473904bbe7d27514f51ee15f515 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 20 Feb 2026 19:31:13 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat(member):=20=ED=8C=AC=EC=8B=ACM=20?= =?UTF-8?q?=EB=B0=8F=20X=20URL=20=ED=95=84=EB=93=9C=EB=A5=BC=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260220_member_fancimm_x_url_ddl.sql | 4 ++++ docs/20260220_팬심M와XURL추가.md | 14 ++++++++++++++ .../vividnext/sodalive/explorer/CreatorResponse.kt | 2 ++ .../vividnext/sodalive/explorer/ExplorerService.kt | 2 ++ .../live/room/GetLiveRoomUserProfileResponse.kt | 2 ++ .../sodalive/live/room/LiveRoomService.kt | 2 ++ .../live/room/detail/GetRoomDetailResponse.kt | 4 ++++ .../kr/co/vividnext/sodalive/member/Member.kt | 2 ++ .../co/vividnext/sodalive/member/MemberService.kt | 10 ++++++++++ .../vividnext/sodalive/member/ProfileResponse.kt | 4 ++++ .../sodalive/member/ProfileUpdateRequest.kt | 2 ++ .../sodalive/member/myPage/MyPageResponse.kt | 2 ++ 12 files changed, 50 insertions(+) create mode 100644 docs/20260220_member_fancimm_x_url_ddl.sql create mode 100644 docs/20260220_팬심M와XURL추가.md diff --git a/docs/20260220_member_fancimm_x_url_ddl.sql b/docs/20260220_member_fancimm_x_url_ddl.sql new file mode 100644 index 00000000..66762ed2 --- /dev/null +++ b/docs/20260220_member_fancimm_x_url_ddl.sql @@ -0,0 +1,4 @@ +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 +; diff --git a/docs/20260220_팬심M와XURL추가.md b/docs/20260220_팬심M와XURL추가.md new file mode 100644 index 00000000..8138f1ea --- /dev/null +++ b/docs/20260220_팬심M와XURL추가.md @@ -0,0 +1,14 @@ +# 팬심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`는 수행할 수 없었다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt index 1252cca1..3d1c2d52 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt @@ -7,6 +7,8 @@ data class CreatorResponse( val tags: List, val introduce: String = "", val instagramUrl: String? = null, + val fancimmUrl: String? = null, + val xUrl: String? = null, val youtubeUrl: String? = null, val websiteUrl: String? = null, val blogUrl: String? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index d0366c4d..c24b10e8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -372,6 +372,8 @@ class ExplorerService( tags = creatorAccount.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList(), introduce = creatorAccount.introduce, instagramUrl = creatorAccount.instagramUrl, + fancimmUrl = creatorAccount.fancimmUrl, + xUrl = creatorAccount.xUrl, youtubeUrl = creatorAccount.youtubeUrl, websiteUrl = creatorAccount.websiteUrl, blogUrl = creatorAccount.blogUrl, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt index 42226abc..f93d51e9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt @@ -6,6 +6,8 @@ data class GetLiveRoomUserProfileResponse( val profileUrl: String, val gender: String, val instagramUrl: String, + val fancimmUrl: String, + val xUrl: String, val youtubeUrl: String, val websiteUrl: String, val blogUrl: String, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 60653ff1..600e8b9d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -1098,6 +1098,8 @@ class LiveRoomService( else -> messageSource.getMessage("member.gender.unknown", langContext.lang) }.orEmpty(), instagramUrl = user.instagramUrl, + fancimmUrl = user.fancimmUrl, + xUrl = user.xUrl, youtubeUrl = user.youtubeUrl, websiteUrl = user.websiteUrl, blogUrl = user.blogUrl, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt index a97b0460..b22590fb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt @@ -31,6 +31,8 @@ data class GetRoomDetailManager( val introduce: String, val youtubeUrl: String?, val instagramUrl: String?, + val fancimmUrl: String?, + val xUrl: String?, val websiteUrl: String?, val blogUrl: String?, val profileImageUrl: String, @@ -42,6 +44,8 @@ data class GetRoomDetailManager( introduce = member.introduce, youtubeUrl = member.youtubeUrl, instagramUrl = member.instagramUrl, + fancimmUrl = member.fancimmUrl, + xUrl = member.xUrl, websiteUrl = member.websiteUrl, blogUrl = member.blogUrl, profileImageUrl = if (member.profileImage != null) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt index 75ff126f..c1f631b2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt @@ -81,6 +81,8 @@ data class Member( // SNS var instagramUrl = "" + var fancimmUrl = "" + var xUrl = "" var youtubeUrl = "" var websiteUrl = "" var blogUrl = "" diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index d6868bd0..7ea24956 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -301,6 +301,8 @@ class MemberService( point = totalPoint, youtubeUrl = member.youtubeUrl, instagramUrl = member.instagramUrl, + fancimmUrl = member.fancimmUrl, + xUrl = member.xUrl, websiteUrl = member.websiteUrl, blogUrl = member.blogUrl, liveReservationCount = liveReservationCount, @@ -714,6 +716,14 @@ class MemberService( member.instagramUrl = profileUpdateRequest.instagramUrl } + if (profileUpdateRequest.fancimmUrl != null) { + member.fancimmUrl = profileUpdateRequest.fancimmUrl + } + + if (profileUpdateRequest.xUrl != null) { + member.xUrl = profileUpdateRequest.xUrl + } + if (profileUpdateRequest.websiteUrl != null) { member.websiteUrl = profileUpdateRequest.websiteUrl } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt index 12475347..4172767f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt @@ -10,6 +10,8 @@ data class ProfileResponse( val rewardCan: Int, val youtubeUrl: String?, val instagramUrl: String?, + val fancimmUrl: String?, + val xUrl: String?, val blogUrl: String?, val websiteUrl: String?, val introduce: String, @@ -29,6 +31,8 @@ data class ProfileResponse( rewardCan = member.getRewardCan(container), youtubeUrl = member.youtubeUrl, instagramUrl = member.instagramUrl, + fancimmUrl = member.fancimmUrl, + xUrl = member.xUrl, websiteUrl = member.websiteUrl, blogUrl = member.blogUrl, introduce = member.introduce, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt index 4f5d97de..1f85148a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt @@ -11,6 +11,8 @@ data class ProfileUpdateRequest( val introduce: String? = null, val youtubeUrl: String? = null, val instagramUrl: String? = null, + val fancimmUrl: String? = null, + val xUrl: String? = null, val websiteUrl: String? = null, val blogUrl: String? = null, val isVisibleDonationRank: Boolean? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt index 5bf0481c..5c89d6e3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt @@ -10,6 +10,8 @@ data class MyPageResponse( val point: Int, val youtubeUrl: String?, val instagramUrl: String?, + val fancimmUrl: String? = null, + val xUrl: String? = null, val websiteUrl: String? = null, val blogUrl: String? = null, val liveReservationCount: Int, From 07fb6202a83ead25ce347a8523bd155a439b7457 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 23 Feb 2026 11:00:00 +0900 Subject: [PATCH 08/22] =?UTF-8?q?fix(member):=20=EB=8F=99=EC=9D=BC=20?= =?UTF-8?q?=EB=B3=B8=EC=9D=B8=EC=9D=B8=EC=A6=9D=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=EC=9D=84=20=ED=95=A8=EA=BB=98=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260223_회원차단동일본인인증확장.md | 17 ++++++++ .../sodalive/member/MemberService.kt | 42 ++++++++++++------- 2 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 docs/20260223_회원차단동일본인인증확장.md diff --git a/docs/20260223_회원차단동일본인인증확장.md b/docs/20260223_회원차단동일본인인증확장.md new file mode 100644 index 00000000..ca2f4189 --- /dev/null +++ b/docs/20260223_회원차단동일본인인증확장.md @@ -0,0 +1,17 @@ +# 회원 차단 동일 본인인증 확장 구현 + +- [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 포함). diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 7ea24956..81e5d686 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -17,6 +17,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser +import kr.co.vividnext.sodalive.member.auth.AuthRepository import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.GetBlockedMemberListResponse @@ -80,6 +81,7 @@ class MemberService( private val stipulationAgreeRepository: StipulationAgreeRepository, private val creatorFollowingRepository: CreatorFollowingRepository, private val blockMemberRepository: BlockMemberRepository, + private val authRepository: AuthRepository, private val signOutRepository: SignOutRepository, private val nicknameChangeLogRepository: NicknameChangeLogRepository, private val memberTagRepository: MemberTagRepository, @@ -522,25 +524,35 @@ class MemberService( @Transactional fun memberBlock(request: MemberBlockRequest, memberId: Long) { - var blockMember = blockMemberRepository.getBlockAccount( - blockedMemberId = request.blockMemberId, - memberId = memberId - ) + val member = repository.findByIdOrNull(id = memberId) + ?: throw SodaException(messageKey = "common.error.invalid_request") + val blockedMember = repository.findByIdOrNull(id = request.blockMemberId) + ?: throw SodaException(messageKey = "common.error.invalid_request") - if (blockMember == null) { - val blockedMember = repository.findByIdOrNull(id = request.blockMemberId) - ?: throw SodaException(messageKey = "common.error.invalid_request") + val blockTargetMemberIds = mutableSetOf(request.blockMemberId) + blockedMember.auth?.di?.let { di -> + val verifiedMemberIds = authRepository.getMemberIdsByDi(di = di) + blockTargetMemberIds.addAll(verifiedMemberIds) + } + blockTargetMemberIds.remove(memberId) - val member = repository.findByIdOrNull(id = memberId) - ?: throw SodaException(messageKey = "common.error.invalid_request") + blockTargetMemberIds.forEach { targetMemberId -> + val targetMember = repository.findByIdOrNull(id = targetMemberId) ?: return@forEach - blockMember = BlockMember() - blockMember.member = member - blockMember.blockedMember = blockedMember + var blockMember = blockMemberRepository.getBlockAccount( + blockedMemberId = targetMemberId, + memberId = memberId + ) - blockMemberRepository.save(blockMember) - } else { - blockMember.isActive = true + if (blockMember == null) { + blockMember = BlockMember() + blockMember.member = member + blockMember.blockedMember = targetMember + + blockMemberRepository.save(blockMember) + } else { + blockMember.isActive = true + } } } From cc74628107ae1aed6f263e8d55fe23e04c3621ca Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 23 Feb 2026 14:08:23 +0900 Subject: [PATCH 09/22] =?UTF-8?q?fix(block-member):=20=EC=96=91=EB=B0=A9?= =?UTF-8?q?=ED=96=A5=20=EC=B0=A8=EB=8B=A8=20=EA=B4=80=EA=B3=84=EC=9D=98=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=C2=B7=EC=9D=91=EC=9B=90=C2=B7=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EB=85=B8=EC=B6=9C=EC=9D=84=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...260223_차단유저댓글및크리에이터노출차단.md | 17 ++++ .../sodalive/api/live/LiveApiService.kt | 4 +- .../content/AudioContentController.kt | 2 + .../content/AudioContentRepository.kt | 60 +++++++++---- .../sodalive/content/AudioContentService.kt | 24 ++++-- .../content/category/CategoryService.kt | 3 +- .../comment/AudioContentCommentController.kt | 1 + .../comment/AudioContentCommentRepository.kt | 34 +++++++- .../comment/AudioContentCommentService.kt | 25 +++++- .../content/main/AudioContentMainService.kt | 19 +++-- .../main/banner/AudioContentBannerService.kt | 9 +- .../curation/AudioContentCurationService.kt | 9 +- .../main/tab/AudioContentMainTabRepository.kt | 24 ++++-- .../ContentMainTabTagCurationRepository.kt | 12 ++- .../content/series/ContentSeriesRepository.kt | 84 +++++++++++++++---- .../content/series/ContentSeriesService.kt | 3 +- .../sodalive/explorer/ExplorerController.kt | 2 +- .../explorer/ExplorerQueryRepository.kt | 47 ++++++++--- .../sodalive/explorer/ExplorerService.kt | 48 +++++++---- .../CreatorCommunityController.kt | 1 + .../CreatorCommunityService.kt | 38 +++++++-- .../CreatorCommunityCommentRepository.kt | 34 +++++++- .../live/recommend/LiveRecommendService.kt | 19 +++-- .../sodalive/live/room/LiveRoomRepository.kt | 36 ++++++-- .../sodalive/live/room/LiveRoomService.kt | 25 +++++- .../sodalive/member/MemberService.kt | 7 +- .../RecommendChannelQueryRepository.kt | 24 ++++-- .../sodalive/search/SearchRepository.kt | 72 ++++++++++++---- 28 files changed, 528 insertions(+), 155 deletions(-) create mode 100644 docs/20260223_차단유저댓글및크리에이터노출차단.md diff --git a/docs/20260223_차단유저댓글및크리에이터노출차단.md b/docs/20260223_차단유저댓글및크리에이터노출차단.md new file mode 100644 index 00000000..73ddb3fc --- /dev/null +++ b/docs/20260223_차단유저댓글및크리에이터노출차단.md @@ -0,0 +1,17 @@ +# 차단 유저 댓글 및 크리에이터 노출 차단 구현 + +- [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 포함). diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt index 1bc1c8ad..81a756d9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/live/LiveApiService.kt @@ -53,6 +53,7 @@ class LiveApiService( val latestFinishedLiveList = liveService.getLatestFinishedLive(member) val replayLive = contentService.getLatestContentByTheme( + memberId = memberId, theme = listOf("다시듣기"), contentType = contentType, isFree = false, @@ -60,7 +61,8 @@ class LiveApiService( ) .filter { content -> if (memberId != null) { - !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = content.creatorId) + !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = content.creatorId) && + !blockMemberRepository.isBlocked(blockedMemberId = content.creatorId, memberId = memberId) } else { true } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt index 46fd18da..ab0464b5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -252,6 +252,7 @@ class AudioContentController(private val service: AudioContentService) { ApiResponse.ok( service.getLatestContentByTheme( + memberId = member.id!!, theme = if (theme == null) listOf() else listOf(theme), contentType = contentType ?: ContentType.ALL, offset = pageable.offset, @@ -272,6 +273,7 @@ class AudioContentController(private val service: AudioContentService) { ) = run { ApiResponse.ok( service.getLatestContentByTheme( + memberId = member?.id, theme = listOf("다시듣기"), contentType = contentType ?: ContentType.ALL, isFree = false, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt index aa1ae861..cf2ea86c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -469,9 +469,15 @@ class AudioContentQueryRepositoryImpl( limit: Long, isFree: Boolean ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) val orderBy = when (sortType) { SortType.NEWEST -> listOf(audioContent.releaseDate.desc(), audioContent.id.desc()) @@ -562,9 +568,15 @@ class AudioContentQueryRepositoryImpl( offset: Long, limit: Long ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) val orderBy = listOf(audioContent.releaseDate.desc(), audioContent.id.desc()) @@ -630,9 +642,15 @@ class AudioContentQueryRepositoryImpl( isAdult: Boolean, contentType: ContentType ): Int { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = audioContent.isActive.isTrue .and(audioContent.duration.isNotNull) @@ -960,9 +978,15 @@ class AudioContentQueryRepositoryImpl( offset: Long, limit: Long ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = audioContentCuration.isActive.isTrue .and(audioContentCurationItem.isActive.isTrue) @@ -1337,9 +1361,15 @@ class AudioContentQueryRepositoryImpl( locale: String? ): List { val blockMemberCondition = if (memberId != null) { - blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) } else { null } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 89922a4a..e810c95f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -524,6 +524,10 @@ class AudioContentService( val creator = explorerQueryRepository.getMember(creatorId) ?: throw SodaException(messageKey = "content.error.user_not_found") + if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) { + throw SodaException(messageKey = "content.error.invalid_content_retry") + } + val creatorFollowing = explorerQueryRepository.getCreatorFollowing( creatorId = creatorId, memberId = member.id!! @@ -534,12 +538,6 @@ class AudioContentService( contentId = audioContent.id!! ) - // 차단된 사용자 체크 - val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) - if (isBlocked && !isExistsAudioContent) { - throw SodaException(formatMessage("content.error.access_restricted_by_creator", creator.nickname)) - } - val orderSequence = if (isExistsAudioContent) { limitedEditionOrderRepository.getOrderSequence( contentId = audioContent.id!!, @@ -886,6 +884,10 @@ class AudioContentService( ): GetAudioContentListItem? { val isAdult = member.auth != null && isAdultContentVisible + if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) { + return null + } + val audioContent = repository.findLatestContentByCreatorId(creatorId, isAdult) ?: return null val commentCount = commentRepository @@ -957,6 +959,10 @@ class AudioContentService( val isAdult = member.auth != null && isAdultContentVisible val isCreator = member.id == creatorId + if (!isCreator && isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) { + return GetAudioContentListResponse(totalCount = 0, items = listOf()) + } + val totalCount = repository.findTotalCountByCreatorId( creatorId = creatorId, isCreator = isCreator, @@ -1313,8 +1319,8 @@ class AudioContentService( } } - private fun formatMessage(key: String, vararg args: Any): String { - val template = messageSource.getMessage(key, langContext.lang) ?: return "" - return String.format(template, *args) + private fun isBlockedBetweenMembers(memberId: Long, creatorId: Long): Boolean { + return blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) || + blockMemberRepository.isBlocked(blockedMemberId = creatorId, memberId = memberId) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt index f42a1293..c2908021 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt @@ -128,7 +128,8 @@ class CategoryService( @Transactional fun getCategoryList(creatorId: Long, memberId: Long): List { - val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) + val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) || + blockMemberRepository.isBlocked(blockedMemberId = creatorId, memberId = memberId) if (isBlocked) throw SodaException(messageKey = "category.error.invalid_access") // 기본 카테고리 목록 조회 (원본 언어 기준) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt index 9a6e56cd..1f91f63f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt @@ -98,6 +98,7 @@ class AudioContentCommentController( return ApiResponse.ok( service.getCommentReplyList( commentId = commentId, + memberId = member.id!!, timezone = timezone, pageable = pageable ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt index 59f23dd6..90cd4530 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.content.comment import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.JPAExpressions import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment @@ -9,6 +10,7 @@ import kr.co.vividnext.sodalive.fcm.PushTokenInfo import kr.co.vividnext.sodalive.fcm.QPushToken.pushToken import kr.co.vividnext.sodalive.fcm.QPushTokenInfo import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -28,10 +30,11 @@ interface AudioContentCommentQueryRepository { ): List fun totalCountCommentByContentId(contentId: Long, memberId: Long, isContentCreator: Boolean): Int - fun commentReplyCountByAudioContentCommentId(commentId: Long): Int + fun commentReplyCountByAudioContentCommentId(commentId: Long, memberId: Long): Int fun getAudioContentCommentReplyList( cloudFrontHost: String, commentId: Long, + memberId: Long, timezone: String, offset: Long, limit: Int @@ -60,6 +63,8 @@ class AudioContentCommentQueryRepositoryImpl( var where = audioContentComment.audioContent.id.eq(contentId) .and(audioContentComment.isActive.isTrue) .and(audioContentComment.parent.isNull) + .and(audioContentComment.member.id.notIn(blockedMemberIdSubQuery(memberId))) + .and(audioContentComment.member.id.notIn(blockingMemberIdSubQuery(memberId))) if (!isContentCreator) { where = where.and( @@ -106,7 +111,7 @@ class AudioContentCommentQueryRepositoryImpl( .map { it.copy( nickname = it.nickname.removeDeletedNicknamePrefix(), - replyCount = commentReplyCountByAudioContentCommentId(it.id) + replyCount = commentReplyCountByAudioContentCommentId(it.id, memberId) ) } } @@ -115,6 +120,8 @@ class AudioContentCommentQueryRepositoryImpl( var where = audioContentComment.audioContent.id.eq(contentId) .and(audioContentComment.isActive.isTrue) .and(audioContentComment.parent.isNull) + .and(audioContentComment.member.id.notIn(blockedMemberIdSubQuery(memberId))) + .and(audioContentComment.member.id.notIn(blockingMemberIdSubQuery(memberId))) if (!isContentCreator) { where = where.and( @@ -133,13 +140,15 @@ class AudioContentCommentQueryRepositoryImpl( .size } - override fun commentReplyCountByAudioContentCommentId(commentId: Long): Int { + override fun commentReplyCountByAudioContentCommentId(commentId: Long, memberId: Long): Int { return queryFactory.select(audioContentComment.id) .from(audioContentComment) .where( audioContentComment.parent.isNotNull .and(audioContentComment.parent.id.eq(commentId)) .and(audioContentComment.isActive.isTrue) + .and(audioContentComment.member.id.notIn(blockedMemberIdSubQuery(memberId))) + .and(audioContentComment.member.id.notIn(blockingMemberIdSubQuery(memberId))) ) .fetch() .size @@ -148,6 +157,7 @@ class AudioContentCommentQueryRepositoryImpl( override fun getAudioContentCommentReplyList( cloudFrontHost: String, commentId: Long, + memberId: Long, timezone: String, offset: Long, limit: Int @@ -185,6 +195,8 @@ class AudioContentCommentQueryRepositoryImpl( audioContentComment.parent.isNotNull .and(audioContentComment.parent.id.eq(commentId)) .and(audioContentComment.isActive.isTrue) + .and(audioContentComment.member.id.notIn(blockedMemberIdSubQuery(memberId))) + .and(audioContentComment.member.id.notIn(blockingMemberIdSubQuery(memberId))) ) .offset(offset) .limit(limit.toLong()) @@ -244,4 +256,20 @@ class AudioContentCommentQueryRepositoryImpl( return response } + + private fun blockedMemberIdSubQuery(memberId: Long) = JPAExpressions + .select(blockMember.blockedMember.id) + .from(blockMember) + .where( + blockMember.member.id.eq(memberId) + .and(blockMember.isActive.isTrue) + ) + + private fun blockingMemberIdSubQuery(memberId: Long) = JPAExpressions + .select(blockMember.member.id) + .from(blockMember) + .where( + blockMember.blockedMember.id.eq(memberId) + .and(blockMember.isActive.isTrue) + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt index 401484ed..5413e497 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -45,7 +45,7 @@ class AudioContentCommentService( ?: throw SodaException(messageKey = "content.error.invalid_content_retry") val creator = audioContent.member!! - val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = creator.id!!) + val isBlocked = isBlockedBetweenMembers(memberId = member.id!!, creatorId = creator.id!!) if (isBlocked) { throw SodaException(formatMessage("content.comment.error.blocked_by_creator", creator.nickname)) } @@ -136,6 +136,13 @@ class AudioContentCommentService( timezone: String, pageable: Pageable ): GetAudioContentCommentListResponse { + val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId) + ?: return GetAudioContentCommentListResponse(totalCount = 0, items = listOf()) + + if (isBlockedBetweenMembers(memberId = memberId, creatorId = audioContent.member!!.id!!)) { + return GetAudioContentCommentListResponse(totalCount = 0, items = listOf()) + } + val isContentCreator = audioContentRepository.isContentCreator(audioContentId, memberId) val commentList = repository.findByContentId( cloudFrontHost = cloudFrontHost, @@ -157,17 +164,26 @@ class AudioContentCommentService( fun getCommentReplyList( commentId: Long, + memberId: Long, timezone: String, pageable: Pageable ): GetAudioContentCommentListResponse { + val parentComment = repository.findByIdOrNull(id = commentId) + ?: return GetAudioContentCommentListResponse(totalCount = 0, items = listOf()) + + if (isBlockedBetweenMembers(memberId = memberId, creatorId = parentComment.audioContent!!.member!!.id!!)) { + return GetAudioContentCommentListResponse(totalCount = 0, items = listOf()) + } + val commentList = repository.getAudioContentCommentReplyList( cloudFrontHost = cloudFrontHost, commentId = commentId, + memberId = memberId, timezone = timezone, offset = pageable.offset, limit = pageable.pageSize ) - val totalCount = repository.commentReplyCountByAudioContentCommentId(commentId) + val totalCount = repository.commentReplyCountByAudioContentCommentId(commentId, memberId) return GetAudioContentCommentListResponse(totalCount, commentList) } @@ -176,4 +192,9 @@ class AudioContentCommentService( val template = messageSource.getMessage(key, langContext.lang) ?: return "" return String.format(template, *args) } + + private fun isBlockedBetweenMembers(memberId: Long, creatorId: Long): Boolean { + return blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) || + blockMemberRepository.isBlocked(blockedMemberId = creatorId, memberId = memberId) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt index 9317544b..6a78dfc6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt @@ -118,7 +118,7 @@ class AudioContentMainService( offset = pageable.offset, limit = pageable.pageSize.toLong() ) - .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } + .filter { !isBlockedBetweenMembers(memberId = member.id!!, creatorId = it.creatorId) } val contentIds = contentList.map { it.contentId } val translatedContentList = if (contentIds.isNotEmpty()) { @@ -198,20 +198,17 @@ class AudioContentMainService( cloudfrontHost = imageHost, isAdult = isAdult ) - .asSequence() - .filter { !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = it.creatorId) } - .toList() + .filter { !isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId) } } @Transactional(readOnly = true) fun getAudioContentMainBannerList(memberId: Long, isAdult: Boolean) = repository.getAudioContentMainBannerList(isAdult = isAdult) - .asSequence() .filter { if (it.type == AudioContentBannerType.CREATOR && it.creator != null) { - !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = it.creator!!.id!!) + !isBlockedBetweenMembers(memberId = memberId, creatorId = it.creator!!.id!!) } else if (it.type == AudioContentBannerType.SERIES && it.series != null) { - !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = it.series!!.member!!.id!!) + !isBlockedBetweenMembers(memberId = memberId, creatorId = it.series!!.member!!.id!!) } else { true } @@ -255,7 +252,6 @@ class AudioContentMainService( link = it.link ) } - .toList() @Transactional(readOnly = true) @Cacheable( @@ -281,9 +277,14 @@ class AudioContentMainService( contentType = contentType ) .filter { content -> - !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = content.creatorId) + !isBlockedBetweenMembers(memberId = memberId, creatorId = content.creatorId) } ) } .filter { it.contents.isNotEmpty() } + + private fun isBlockedBetweenMembers(memberId: Long, creatorId: Long): Boolean { + return blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) || + blockMemberRepository.isBlocked(blockedMemberId = creatorId, memberId = memberId) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerService.kt index 6226b23e..4aaaffa7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerService.kt @@ -17,9 +17,9 @@ class AudioContentBannerService( return repository.getAudioContentMainBannerList(tabId, isAdult) .filter { if (it.type == AudioContentBannerType.CREATOR && it.creator != null && memberId != null) { - !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = it.creator!!.id!!) + !isBlockedBetweenMembers(memberId = memberId, creatorId = it.creator!!.id!!) } else if (it.type == AudioContentBannerType.SERIES && it.series != null && memberId != null) { - !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = it.series!!.member!!.id!!) + !isBlockedBetweenMembers(memberId = memberId, creatorId = it.series!!.member!!.id!!) } else { true } @@ -64,4 +64,9 @@ class AudioContentBannerService( ) } } + + private fun isBlockedBetweenMembers(memberId: Long, creatorId: Long): Boolean { + return blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) || + blockMemberRepository.isBlocked(blockedMemberId = creatorId, memberId = memberId) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt index b21d8628..661cddd2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt @@ -43,7 +43,7 @@ class AudioContentCurationService( offset = pageable.offset, limit = pageable.pageSize.toLong() ) - .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } + .filter { !isBlockedBetweenMembers(memberId = member.id!!, creatorId = it.creatorId) } return GetCurationContentResponse( totalCount = totalCount, @@ -68,7 +68,7 @@ class AudioContentCurationService( contentType = contentType ).filter { item -> if (memberId != null) { - !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = item.creatorId) + !isBlockedBetweenMembers(memberId = memberId, creatorId = item.creatorId) } else { true } @@ -77,4 +77,9 @@ class AudioContentCurationService( } .filter { it.items.isNotEmpty() } } + + private fun isBlockedBetweenMembers(memberId: Long, creatorId: Long): Boolean { + return blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) || + blockMemberRepository.isBlocked(blockedMemberId = creatorId, memberId = memberId) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/AudioContentMainTabRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/AudioContentMainTabRepository.kt index 73d85898..a860c0ab 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/AudioContentMainTabRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/AudioContentMainTabRepository.kt @@ -27,9 +27,15 @@ class AudioContentMainTabRepository( isAdult: Boolean, contentType: ContentType ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = member.isActive.isTrue .and(member.role.eq(MemberRole.CREATOR)) @@ -88,9 +94,15 @@ class AudioContentMainTabRepository( isAdult: Boolean, contentType: ContentType ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = member.isActive.isTrue .and(member.role.eq(MemberRole.CREATOR)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationRepository.kt index 243a9ffd..271fd4b5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationRepository.kt @@ -62,9 +62,15 @@ class ContentMainTabTagCurationRepository( tag: String, contentType: ContentType ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = audioContent.isActive.isTrue .and(audioContent.duration.isNotNull) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt index f9995040..f351a8f6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt @@ -162,9 +162,15 @@ class ContentSeriesQueryRepositoryImpl( .select(blockMember.id) .from(blockMember) .where( - blockMember.member.id.eq(series.member.id), - blockMember.blockedMember.id.eq(memberId), blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(series.member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(series.member.id)) + ) + ) ) where = where.and(blockedSubquery.exists().not()) } @@ -230,9 +236,15 @@ class ContentSeriesQueryRepositoryImpl( .select(blockMember.id) .from(blockMember) .where( - blockMember.member.id.eq(series.member.id), - blockMember.blockedMember.id.eq(memberId), blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(series.member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(series.member.id)) + ) + ) ) where = where.and(blockedSubquery.exists().not()) } @@ -361,9 +373,15 @@ class ContentSeriesQueryRepositoryImpl( .select(blockMember.id) .from(blockMember) .where( - blockMember.member.id.eq(series.member.id), - blockMember.blockedMember.id.eq(memberId), blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(series.member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(series.member.id)) + ) + ) ) where = where.and(blockedSubquery.exists().not()) } @@ -419,9 +437,15 @@ class ContentSeriesQueryRepositoryImpl( .select(blockMember.id) .from(blockMember) .where( - blockMember.member.id.eq(series.member.id), - blockMember.blockedMember.id.eq(memberId), blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(series.member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(series.member.id)) + ) + ) ) where = where.and(blockedSubquery.exists().not()) } @@ -594,9 +618,15 @@ class ContentSeriesQueryRepositoryImpl( .select(blockMember.id) .from(blockMember) .where( - blockMember.member.id.eq(series.member.id), - blockMember.blockedMember.id.eq(memberId), blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(series.member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(series.member.id)) + ) + ) ) where = where.and(blockedSubquery.exists().not()) } @@ -829,9 +859,15 @@ class ContentSeriesQueryRepositoryImpl( memberId: Long, contentType: ContentType ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = seriesGenre.isActive.isTrue .and(series.isActive.isTrue) @@ -884,9 +920,15 @@ class ContentSeriesQueryRepositoryImpl( contentType: ContentType, locale: String ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = series.isActive.isTrue .and(member.isActive.isTrue) @@ -1033,9 +1075,15 @@ class ContentSeriesQueryRepositoryImpl( .select(blockMember.id) .from(blockMember) .where( - blockMember.member.id.eq(series.member.id), - blockMember.blockedMember.id.eq(memberId), blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(series.member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(series.member.id)) + ) + ) ) where = where.and(blockedSubquery.exists().not()) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index 6cf0342c..dc2b2140 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -226,7 +226,8 @@ class ContentSeriesService( contentType = contentType ) ?: throw SodaException(messageKey = "series.error.invalid_series_retry") - val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = series.member!!.id!!) + val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = series.member!!.id!!) || + blockMemberRepository.isBlocked(blockedMemberId = series.member!!.id!!, memberId = member.id!!) if (isBlocked) { throw SodaException(messageKey = "series.error.invalid_series_retry") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt index 46fdeae2..b634b0ab 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt @@ -133,6 +133,6 @@ class ExplorerController( pageable: Pageable ) = run { if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - ApiResponse.ok(service.getCreatorProfileCheers(creatorId, timezone, pageable)) + ApiResponse.ok(service.getCreatorProfileCheers(creatorId, timezone, member, pageable)) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index f6dace80..02d0fe45 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -451,30 +451,52 @@ class ExplorerQueryRepository( .fetchFirst() ?: "" } - fun getCheersList(creatorId: Long, timezone: String, offset: Long, limit: Long): GetCheersResponse { + fun getCheersList(creatorId: Long, memberId: Long, timezone: String, offset: Long, limit: Long): GetCheersResponse { val cheersDatePattern = messageSource .getMessage("explorer.date.cheers.format", langContext.lang) ?: messageSource.getMessage("explorer.date.cheers.format", Lang.KO).orEmpty() val cheersDateFormatter = DateTimeFormatter.ofPattern(cheersDatePattern) .withLocale(langContext.lang.locale) + val blockedByMemberIdSet = queryFactory + .select(blockMember.blockedMember.id) + .from(blockMember) + .where( + blockMember.member.id.eq(memberId) + .and(blockMember.isActive.isTrue) + ) + .fetch() + .toSet() + + val blockingMemberIdSet = queryFactory + .select(blockMember.member.id) + .from(blockMember) + .where( + blockMember.blockedMember.id.eq(memberId) + .and(blockMember.isActive.isTrue) + ) + .fetch() + .toSet() + + val blockedMemberIdSet = blockedByMemberIdSet + blockingMemberIdSet + + var where = creatorCheers.creator.id.eq(creatorId) + .and(creatorCheers.isActive.isTrue) + .and(creatorCheers.parent.isNull) + + if (blockedMemberIdSet.isNotEmpty()) { + where = where.and(creatorCheers.member.id.notIn(blockedMemberIdSet)) + } + val totalCount = queryFactory .selectFrom(creatorCheers) - .where( - creatorCheers.creator.id.eq(creatorId) - .and(creatorCheers.isActive.isTrue) - .and(creatorCheers.parent.isNull) - ) + .where(where) .fetch() .count() val cheers = queryFactory .selectFrom(creatorCheers) - .where( - creatorCheers.creator.id.eq(creatorId) - .and(creatorCheers.isActive.isTrue) - .and(creatorCheers.parent.isNull) - ) + .where(where) .offset(offset) .limit(limit) .orderBy(creatorCheers.id.desc()) @@ -498,6 +520,9 @@ class ExplorerQueryRepository( languageCode = it.languageCode, date = date.format(cheersDateFormatter), replyList = it.children.asSequence() + .filterNot { cheers -> + cheers.member?.id != null && blockedMemberIdSet.contains(cheers.member!!.id!!) + } .map { cheers -> val replyDate = cheers.createdAt!! .atZone(ZoneId.of("UTC")) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index c24b10e8..ed2c9f5f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -65,7 +65,7 @@ class ExplorerService( fun getCreatorRank(memberId: Long): GetExplorerSectionResponse { val creatorRankings = queryRepository .getCreatorRankings() - .filter { !memberService.isBlocked(blockedMemberId = memberId, memberId = it.id!!) } + .filter { !isBlockedBetweenMembers(memberId = memberId, otherMemberId = it.id!!) } .map { it.toExplorerSectionCreator(cloudFrontHost) } val currentDateTime = LocalDateTime.now() @@ -101,7 +101,7 @@ class ExplorerService( // 인기 크리에이터 val creatorRankings = queryRepository .getCreatorRankings() - .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) } + .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } .map { it.toExplorerSectionCreator(cloudFrontHost) } val currentDateTime = LocalDateTime.now() @@ -134,7 +134,7 @@ class ExplorerService( // 새로 시작 (newCreators) val newCreators = queryRepository .getNewCreators() - .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) } + .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } .map { it.toExplorerSectionCreator(cloudFrontHost) } val newCreatorsSection = GetExplorerSectionResponse( @@ -153,7 +153,7 @@ class ExplorerService( color = "39abde", creators = queryRepository .findCreatorByGender(1) - .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) } + .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } .map { it.toExplorerSectionCreator(cloudFrontHost) } ) @@ -164,7 +164,7 @@ class ExplorerService( color = "ffa517", creators = queryRepository .findCreatorByGender(0) - .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) } + .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } .map { it.toExplorerSectionCreator(cloudFrontHost) } ) @@ -187,7 +187,7 @@ class ExplorerService( return queryRepository.getSearchChannel(channel, member.id!!) .asSequence() - .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) } + .filter { !isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = it.id!!) } .map { GetRoomDetailUser(it, cloudFrontHost) } .toList() } @@ -203,8 +203,9 @@ class ExplorerService( ?: throw SodaException(messageKey = "member.validation.user_not_found") // 차단된 사용자 체크 - val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) - if (isBlocked) { + val isBlockedByCreator = memberService.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) + val isBlock = memberService.isBlocked(blockedMemberId = creatorId, memberId = member.id!!) + if (isBlockedByCreator || isBlock) { val messageTemplate = messageSource .getMessage("explorer.creator.blocked_access", langContext.lang) .orEmpty() @@ -240,7 +241,7 @@ class ExplorerService( } // 라이브 - val liveRoomList = if (isCreator) { + val liveRoomList = if (isCreator && !isBlock) { queryRepository.getLiveRoomList( creatorId, userMember = member, @@ -251,7 +252,7 @@ class ExplorerService( } // 오디오 콘텐츠 - val contentList = if (isCreator) { + val contentList = if (isCreator && !isBlock) { audioContentService.getAudioContentList( creatorId = creatorId, sortType = SortType.NEWEST, @@ -284,7 +285,7 @@ class ExplorerService( } // 크리에이터의 최신 오디오 콘텐츠 1개 - val latestContent = if (isCreator) { + val latestContent = if (isCreator && !isBlock) { audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible) } else { null @@ -326,11 +327,15 @@ class ExplorerService( } // 응원 - val cheers = queryRepository.getCheersList(creatorId, timezone = timezone, offset = 0, limit = 4) + val cheers = queryRepository.getCheersList( + creatorId = creatorId, + memberId = member.id!!, + timezone = timezone, + offset = 0, + limit = 4 + ) // 차단한 크리에이터 인지 체크 - val isBlock = memberService.isBlocked(blockedMemberId = creatorId, memberId = member.id!!) - val activitySummary = if (isCreator) { // 활동요약 (라이브 횟수, 라이브 시간, 라이브 참여자, 콘텐츠 수) val liveCount = queryRepository.getLiveCount(creatorId) ?: 0 @@ -347,7 +352,7 @@ class ExplorerService( GetCreatorActivitySummary(0, 0, 0, 0) } - val seriesList = if (isCreator) { + val seriesList = if (isCreator && !isBlock) { seriesService .getSeriesList( creatorId = creatorId, @@ -516,7 +521,7 @@ class ExplorerService( val creator = queryRepository.getMember(request.creatorId) ?: throw SodaException(messageKey = "member.validation.user_not_found") - val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = request.creatorId) + val isBlocked = isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = request.creatorId) if (isBlocked) { val messageTemplate = messageSource .getMessage("explorer.creator.blocked_cheers", langContext.lang) @@ -555,10 +560,16 @@ class ExplorerService( fun getCreatorProfileCheers( creatorId: Long, timezone: String, + member: Member, pageable: Pageable ): GetCheersResponse { + if (isBlockedBetweenMembers(memberId = member.id!!, otherMemberId = creatorId)) { + throw SodaException(messageKey = "common.error.invalid_request") + } + return queryRepository.getCheersList( creatorId = creatorId, + memberId = member.id!!, timezone = timezone, offset = pageable.offset, limit = pageable.pageSize.toLong() @@ -607,4 +618,9 @@ class ExplorerService( ) ) } + + private fun isBlockedBetweenMembers(memberId: Long, otherMemberId: Long): Boolean { + return memberService.isBlocked(blockedMemberId = memberId, memberId = otherMemberId) || + memberService.isBlocked(blockedMemberId = otherMemberId, memberId = memberId) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt index b85e3cf3..dd60eaff 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt @@ -179,6 +179,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { ApiResponse.ok( service.getCommentReplyList( commentId = commentId, + memberId = member.id!!, timezone = timezone, offset = pageable.offset, limit = pageable.pageSize.toLong() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index cc3be756..8e827e69 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -181,7 +181,7 @@ class CreatorCommunityService( limit: Long, isAdult: Boolean ): List { - if (blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId)) { + if (isBlockedBetweenMembers(memberId = memberId, creatorId = creatorId)) { return listOf() } @@ -275,7 +275,7 @@ class CreatorCommunityService( val post = repository.getCommunityPost(postId, isAdult = isAdult) ?: throw SodaException(messageKey = "creator.community.invalid_request_retry") - val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = post.creatorId) + val isBlocked = isBlockedBetweenMembers(memberId = memberId, creatorId = post.creatorId) if (isBlocked) { val messageTemplate = messageSource .getMessage("creator.community.blocked_access", langContext.lang) @@ -375,6 +375,11 @@ class CreatorCommunityService( ) { val post = repository.findByIdOrNull(id = postId) ?: throw SodaException(messageKey = "creator.community.invalid_post_retry") + + if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = post.member!!.id!!)) { + throw SodaException(messageKey = "creator.community.invalid_access_retry") + } + val isExistOrdered = useCanRepository.isExistCommunityPostOrdered(postId = postId, memberId = member.id!!) if (isSecret && !isExistOrdered) { @@ -425,6 +430,11 @@ class CreatorCommunityService( offset: Long, limit: Long ): GetCommunityPostCommentListResponse { + val post = repository.findByIdOrNull(id = postId) + if (post != null && isBlockedBetweenMembers(memberId = memberId, creatorId = post.member!!.id!!)) { + return GetCommunityPostCommentListResponse(totalCount = 0, items = listOf()) + } + val commentList = commentRepository.findByPostId( id = postId, memberId = memberId, @@ -444,18 +454,28 @@ class CreatorCommunityService( fun getCommentReplyList( commentId: Long, + memberId: Long, timezone: String, offset: Long, limit: Long ): GetCommunityPostCommentListResponse { + val parentComment = commentRepository.findByIdOrNull(id = commentId) + if ( + parentComment != null && + isBlockedBetweenMembers(memberId = memberId, creatorId = parentComment.creatorCommunity!!.member!!.id!!) + ) { + return GetCommunityPostCommentListResponse(totalCount = 0, items = listOf()) + } + val commentList = commentRepository.getCommunityCommentReplyList( commentId = commentId, + memberId = memberId, timezone = timezone, offset = offset, limit = limit ) - val totalCount = commentRepository.commentReplyCountByCommentId(commentId) + val totalCount = commentRepository.commentReplyCountByCommentId(commentId, memberId) return GetCommunityPostCommentListResponse(totalCount = totalCount, items = commentList) } @@ -469,10 +489,7 @@ class CreatorCommunityService( return postList .filter { - !blockMemberRepository.isBlocked( - blockedMemberId = memberId, - memberId = it.creatorId - ) + !isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId) } .map { val isLike = @@ -541,7 +558,7 @@ class CreatorCommunityService( val post = repository.findByIdAndActive(postId, isAdult) ?: throw SodaException(messageKey = "creator.community.invalid_request_retry") - val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = post.member!!.id!!) + val isBlocked = isBlockedBetweenMembers(memberId = memberId, creatorId = post.member!!.id!!) if (isBlocked) { val messageTemplate = messageSource .getMessage("creator.community.blocked_access", langContext.lang) @@ -616,4 +633,9 @@ class CreatorCommunityService( firstComment = firstComment ) } + + private fun isBlockedBetweenMembers(memberId: Long, creatorId: Long): Boolean { + return blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) || + blockMemberRepository.isBlocked(blockedMemberId = creatorId, memberId = memberId) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt index f7e12fe1..4d56d68e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt @@ -1,9 +1,11 @@ package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.JPAExpressions import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix +import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import java.time.LocalDateTime @@ -21,7 +23,7 @@ interface CreatorCommunityCommentQueryRepository { limit: Long ): List - fun commentReplyCountByCommentId(commentId: Long): Int + fun commentReplyCountByCommentId(commentId: Long, memberId: Long): Int fun totalCountCommentByPostId( postId: Long, @@ -31,6 +33,7 @@ interface CreatorCommunityCommentQueryRepository { fun getCommunityCommentReplyList( commentId: Long, + memberId: Long, timezone: String, offset: Long, limit: Long @@ -66,6 +69,8 @@ class CreatorCommunityCommentQueryRepositoryImpl( var where = creatorCommunityComment.isActive.isTrue .and(creatorCommunityComment.creatorCommunity.id.eq(id)) .and(creatorCommunityComment.parent.isNull) + .and(creatorCommunityComment.member.id.notIn(blockedMemberIdSubQuery(memberId))) + .and(creatorCommunityComment.member.id.notIn(blockingMemberIdSubQuery(memberId))) if (!isContentCreator) { where = where.and( @@ -96,18 +101,20 @@ class CreatorCommunityCommentQueryRepositoryImpl( .map { it.copy( nickname = it.nickname.removeDeletedNicknamePrefix(), - replyCount = commentReplyCountByCommentId(it.id) + replyCount = commentReplyCountByCommentId(it.id, memberId) ) } } - override fun commentReplyCountByCommentId(commentId: Long): Int { + override fun commentReplyCountByCommentId(commentId: Long, memberId: Long): Int { return queryFactory.select(creatorCommunityComment.id) .from(creatorCommunityComment) .where( creatorCommunityComment.isActive.isTrue .and(creatorCommunityComment.parent.isNotNull) .and(creatorCommunityComment.parent.id.eq(commentId)) + .and(creatorCommunityComment.member.id.notIn(blockedMemberIdSubQuery(memberId))) + .and(creatorCommunityComment.member.id.notIn(blockingMemberIdSubQuery(memberId))) ) .fetch() .size @@ -121,6 +128,8 @@ class CreatorCommunityCommentQueryRepositoryImpl( var where = creatorCommunityComment.creatorCommunity.id.eq(postId) .and(creatorCommunityComment.isActive.isTrue) .and(creatorCommunityComment.parent.isNull) + .and(creatorCommunityComment.member.id.notIn(blockedMemberIdSubQuery(memberId))) + .and(creatorCommunityComment.member.id.notIn(blockingMemberIdSubQuery(memberId))) if (!isContentCreator) { where = where.and( @@ -138,6 +147,7 @@ class CreatorCommunityCommentQueryRepositoryImpl( override fun getCommunityCommentReplyList( commentId: Long, + memberId: Long, timezone: String, offset: Long, limit: Long @@ -172,6 +182,8 @@ class CreatorCommunityCommentQueryRepositoryImpl( creatorCommunityComment.isActive.isTrue .and(creatorCommunityComment.parent.isNotNull) .and(creatorCommunityComment.parent.id.eq(commentId)) + .and(creatorCommunityComment.member.id.notIn(blockedMemberIdSubQuery(memberId))) + .and(creatorCommunityComment.member.id.notIn(blockingMemberIdSubQuery(memberId))) ) .offset(offset) .limit(limit) @@ -181,4 +193,20 @@ class CreatorCommunityCommentQueryRepositoryImpl( it.copy(nickname = it.nickname.removeDeletedNicknamePrefix()) } } + + private fun blockedMemberIdSubQuery(memberId: Long) = JPAExpressions + .select(blockMember.blockedMember.id) + .from(blockMember) + .where( + blockMember.member.id.eq(memberId) + .and(blockMember.isActive.isTrue) + ) + + private fun blockingMemberIdSubQuery(memberId: Long) = JPAExpressions + .select(blockMember.member.id) + .from(blockMember) + .where( + blockMember.blockedMember.id.eq(memberId) + .and(blockMember.isActive.isTrue) + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt index 066dbda4..a68d896e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt @@ -22,7 +22,7 @@ class LiveRecommendService( return repository.getRecommendLive( isBlocked = { if (member != null) { - blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) + isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) } else { false } @@ -35,7 +35,7 @@ class LiveRecommendService( val onAirChannelList = repository.getOnAirRecommendChannelList( isBlocked = { if (member != null) { - blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) + isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) } else { false } @@ -55,7 +55,7 @@ class LiveRecommendService( limit = (20 - onAirChannelList.size).toLong(), isBlocked = { if (member != null) { - blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) + isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) } else { false } @@ -68,7 +68,7 @@ class LiveRecommendService( fun getFollowingChannelList(member: Member): List { val onAirFollowingChannelList = repository.getOnAirFollowingChannelList( memberId = member.id!!, - isBlocked = { blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) }, + isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) }, isCreator = member.role == MemberRole.CREATOR, isAdult = member.auth != null ) @@ -83,7 +83,7 @@ class LiveRecommendService( memberId = member.id!!, withOutCreatorList = onAirCreatorIdList, limit = (20 - onAirCreatorIdList.size).toLong(), - isBlocked = { blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) } + isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) } ) return onAirFollowingChannelList + notOnAirFollowingChannelList @@ -92,14 +92,14 @@ class LiveRecommendService( fun getFollowingAllChannelList(member: Member, pageable: Pageable): GetCreatorFollowingAllListResponse { val totalCount = repository.getCreatorFollowingAllListTotalCount( memberId = member.id!!, - isBlocked = { blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) } + isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) } ) val items = repository.getCreatorFollowingAllList( memberId = member.id!!, offset = pageable.offset, limit = pageable.pageSize.toLong(), - isBlocked = { blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it) } + isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) } ) return GetCreatorFollowingAllListResponse( @@ -107,4 +107,9 @@ class LiveRecommendService( items = items ) } + + private fun isBlockedBetweenMembers(memberId: Long, creatorId: Long): Boolean { + return blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) || + blockMemberRepository.isBlocked(blockedMemberId = creatorId, memberId = memberId) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt index 849a1adc..552a2360 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt @@ -121,9 +121,15 @@ class LiveRoomQueryRepositoryImpl( .leftJoin(quarterLiveRankings).on(liveRoom.id.eq(quarterLiveRankings.roomId)) if (hasMemberId) { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.blockedMember.id.eq(memberId)) - .and(blockMember.isActive.isTrue) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) select = select.leftJoin(blockMember).on(blockMemberCondition) where = where.and(blockMember.id.isNull) @@ -190,9 +196,15 @@ class LiveRoomQueryRepositoryImpl( .innerJoin(liveRoom.member, member) if (memberId != null) { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.blockedMember.id.eq(memberId)) - .and(blockMember.isActive.isTrue) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) select = select.leftJoin(blockMember).on(blockMemberCondition) where = where.and(blockMember.id.isNull) @@ -269,9 +281,15 @@ class LiveRoomQueryRepositoryImpl( .limit(10) if (memberId != null) { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.blockedMember.id.eq(memberId)) - .and(blockMember.isActive.isTrue) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) select = select.leftJoin(blockMember).on(blockMemberCondition) where = where.and(blockMember.id.isNull) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 600e8b9d..2b86cdd2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -500,6 +500,13 @@ class LiveRoomService( val room = repository.getLiveRoom(id = roomId) ?: throw SodaException(messageKey = "live.room.already_ended") + val creatorId = room.member!!.id!! + val isBlockedByCreator = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) + val isBlockedByMember = blockMemberRepository.isBlocked(blockedMemberId = creatorId, memberId = member.id!!) + if (isBlockedByCreator || isBlockedByMember) { + throw SodaException(messageKey = "live.room.already_ended") + } + if (room.isAdult && member.auth == null) { throw SodaException(messageKey = "live.room.adult_verification_required") } @@ -747,6 +754,14 @@ class LiveRoomService( ) } + val isBlockedByMember = blockMemberRepository.isBlocked( + blockedMemberId = room.member!!.id!!, + memberId = member.id!! + ) + if (isBlockedByMember) { + throw SodaException(messageKey = "live.room.not_found") + } + val kickOutCount = kickOutService.getKickOutCount(roomId = room.id!!, userId = member.id!!) if (kickOutCount >= 2) { throw SodaException( @@ -910,6 +925,13 @@ class LiveRoomService( val room = repository.findByIdOrNull(roomId) ?: throw SodaException(messageKey = "live.room.info_not_found") + val creatorId = room.member!!.id!! + val isBlockedByCreator = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) + val isBlockedByMember = blockMemberRepository.isBlocked(blockedMemberId = creatorId, memberId = member.id!!) + if (isBlockedByCreator || isBlockedByMember) { + throw SodaException(messageKey = "live.room.info_not_found") + } + val currentTimeStamp = Date().time val expireTimestamp = (currentTimeStamp + (60 * 60 * 24 * 1000)) / 1000 @@ -1427,7 +1449,8 @@ class LiveRoomService( return repository.getLatestFinishedLive() .filter { if (member?.id != null) { - !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.memberId) + !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.memberId) && + !blockMemberRepository.isBlocked(blockedMemberId = it.memberId, memberId = member.id!!) } else { true } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 81e5d686..8c60dc8b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -576,12 +576,13 @@ class MemberService( } return repository.findByNicknameAndOtherCondition(nickname, member) - .asSequence() - .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) } + .filter { + !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) && + !blockMemberRepository.isBlocked(blockedMemberId = it.id!!, memberId = member.id!!) + } .map { GetRoomDetailUser(it, cloudFrontHost) } - .toList() } @Transactional diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt index fc99f58a..45f70335 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt @@ -28,9 +28,15 @@ class RecommendChannelQueryRepository( contentType: ContentType ): List { val blockMemberCondition = if (memberId != null) { - blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) } else { null } @@ -89,9 +95,15 @@ class RecommendChannelQueryRepository( locale: String? = null ): List { val blockMemberCondition = if (memberId != null) { - blockMember.member.id.eq(audioContent.member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(audioContent.member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(audioContent.member.id)) + ) + ) } else { null } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt index 5920a062..dee7a1c7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt @@ -29,9 +29,15 @@ class SearchRepository( keyword: String, memberId: Long ): Int { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) return queryFactory .select(member.id) @@ -61,9 +67,15 @@ class SearchRepository( offset: Long, limit: Long ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) return queryFactory .select( @@ -102,9 +114,15 @@ class SearchRepository( isAdult: Boolean, contentType: ContentType ): Int { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = audioContent.member.isActive.isTrue .and(audioContent.member.role.eq(MemberRole.CREATOR)) @@ -161,9 +179,15 @@ class SearchRepository( offset: Long, limit: Long ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = audioContent.member.isActive.isTrue .and(audioContent.member.role.eq(MemberRole.CREATOR)) @@ -227,9 +251,15 @@ class SearchRepository( isAdult: Boolean, contentType: ContentType ): Int { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = series.isActive.isTrue .and(audioContent.isActive.isTrue) @@ -291,9 +321,15 @@ class SearchRepository( offset: Long, limit: Long ): List { - val blockMemberCondition = blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) var where = series.isActive.isTrue .and(audioContent.isActive.isTrue) From 10e1c1eed066627ca1a51bdb302dfcb2aa695a6a Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 23 Feb 2026 16:25:57 +0900 Subject: [PATCH 10/22] =?UTF-8?q?feat(explorer):=20=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=9D=B4=ED=84=B0=20=EC=83=81=EC=84=B8=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260223_크리에이터상세정보조회api추가.md | 18 ++++++ .../sodalive/explorer/ExplorerController.kt | 9 +++ .../explorer/ExplorerQueryRepository.kt | 26 +++++++++ .../sodalive/explorer/ExplorerService.kt | 58 +++++++++++++++++++ .../explorer/GetCreatorDetailResponse.kt | 15 +++++ 5 files changed, 126 insertions(+) create mode 100644 docs/20260223_크리에이터상세정보조회api추가.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt diff --git a/docs/20260223_크리에이터상세정보조회api추가.md b/docs/20260223_크리에이터상세정보조회api추가.md new file mode 100644 index 00000000..77be446e --- /dev/null +++ b/docs/20260223_크리에이터상세정보조회api추가.md @@ -0,0 +1,18 @@ +# 크리에이터 상세정보 조회 API 추가 작업 계획 + +- [x] `ExplorerController`에 크리에이터 상세정보 조회 엔드포인트 추가 +- [x] `ExplorerService`에 상세정보 조회 비즈니스 로직 추가 +- [x] `ExplorerQueryRepository`에 데뷔일/활동요약 조회 쿼리 추가 +- [x] 응답 DTO 추가 및 `Member` SNS URL 매핑 연결 +- [x] 정적 진단/테스트/빌드 검증 및 결과 기록 + +## 검증 기록 +- 무엇을: + - 1차 구현: 크리에이터 상세정보 조회 API(`/explorer/profile/{id}/detail`)와 응답 DTO를 추가하고, 데뷔일(라이브 `beginDateTime`/콘텐츠 `releaseDate` 최솟값), `D+N`, 활동요약, SNS URL 반환을 구현했다. + - 2차 수정: 상세 조회에 차단 관계 검사를 추가하고, 활동요약의 `contentCount`를 오픈된 콘텐츠(`releaseDate <= now`) 기준으로 집계하도록 기존 쿼리를 보정했다. +- 왜: + - 1차 구현: 탐색 화면에서 크리에이터 기본 정보·활동 통계·데뷔 정보·SNS를 한 번에 조회할 수 있어야 했다. + - 2차 수정: 차단 관계에서도 상세정보가 노출되는 우회가 있었고, 예약 공개 콘텐츠가 포함되어 요구사항의 “오픈한 콘텐츠 수”와 불일치할 수 있었다. +- 어떻게: + - 1차 구현/2차 수정 모두 Kotlin LSP 부재로 `lsp_diagnostics`는 불가를 확인했다. + - 1차 구현 시점과 2차 수정 시점에 각각 `./gradlew ktlintCheck test build`를 실행해 정적검사/테스트/빌드 성공(Exit code 0)을 확인했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt index b634b0ab..46cd9eb8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt @@ -73,6 +73,15 @@ class ExplorerController( ) } + @GetMapping("/profile/{id}/detail") + fun getCreatorDetail( + @PathVariable("id") creatorId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok(service.getCreatorDetail(creatorId = creatorId, memberId = member.id!!)) + } + @GetMapping("/profile/{id}/donation-rank") fun getCreatorProfileDonationRanking( @PathVariable("id") creatorId: Long, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index 02d0fe45..e776dce8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -565,6 +565,30 @@ class ExplorerQueryRepository( .fetchFirst() } + fun getFirstLiveBeginDateTime(creatorId: Long): LocalDateTime? { + return queryFactory + .select(liveRoom.beginDateTime.min()) + .from(liveRoom) + .where( + liveRoom.member.id.eq(creatorId) + .and(liveRoom.channelName.isNotNull) + ) + .fetchFirst() + } + + fun getFirstContentReleaseDate(creatorId: Long): LocalDateTime? { + return queryFactory + .select(audioContent.releaseDate.min()) + .from(audioContent) + .where( + audioContent.member.id.eq(creatorId) + .and(audioContent.isActive.isTrue) + .and(audioContent.releaseDate.isNotNull) + .and(audioContent.releaseDate.loe(LocalDateTime.now())) + ) + .fetchFirst() + } + fun getLiveTime(creatorId: Long): Long { val diffs = queryFactory .select( @@ -708,6 +732,8 @@ class ExplorerQueryRepository( .where( audioContent.isActive.isTrue .and(audioContent.member.id.eq(creatorId)) + .and(audioContent.releaseDate.isNotNull) + .and(audioContent.releaseDate.loe(LocalDateTime.now())) ) .fetchFirst() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index ed2c9f5f..bd99454d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -37,6 +37,7 @@ import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import java.time.temporal.TemporalAdjusters import kotlin.random.Random @@ -192,6 +193,63 @@ class ExplorerService( .toList() } + fun getCreatorDetail(creatorId: Long, memberId: Long): GetCreatorDetailResponse { + val creatorAccount = queryRepository.getMember(creatorId) + ?: throw SodaException(messageKey = "member.validation.user_not_found") + + if (isBlockedBetweenMembers(memberId = memberId, otherMemberId = creatorId)) { + val messageTemplate = messageSource + .getMessage("explorer.creator.blocked_access", langContext.lang) + .orEmpty() + throw SodaException(message = String.format(messageTemplate, creatorAccount.nickname)) + } + + if (creatorAccount.role != MemberRole.CREATOR) { + throw SodaException(messageKey = "member.validation.creator_not_found") + } + + val liveCount = queryRepository.getLiveCount(creatorId) ?: 0 + val liveTime = queryRepository.getLiveTime(creatorId) + val liveContributorCount = queryRepository.getLiveContributorCount(creatorId) ?: 0 + val contentCount = queryRepository.getContentCount(creatorId) ?: 0 + val activitySummary = GetCreatorActivitySummary( + liveCount = liveCount, + liveTime = liveTime, + liveContributorCount = liveContributorCount, + contentCount = contentCount + ) + + val debutDateTime = listOfNotNull( + queryRepository.getFirstLiveBeginDateTime(creatorId), + queryRepository.getFirstContentReleaseDate(creatorId) + ).minOrNull() + + val debutDate = debutDateTime?.toLocalDate() + val dDay = if (debutDate != null) { + "D+${ChronoUnit.DAYS.between(debutDate, LocalDate.now())}" + } else { + "" + } + + return GetCreatorDetailResponse( + nickname = creatorAccount.nickname, + profileImageUrl = if (creatorAccount.profileImage != null) { + "$cloudFrontHost/${creatorAccount.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + debutDate = debutDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "", + dDay = dDay, + activitySummary = activitySummary, + instagramUrl = creatorAccount.instagramUrl, + fancimmUrl = creatorAccount.fancimmUrl, + xUrl = creatorAccount.xUrl, + youtubeUrl = creatorAccount.youtubeUrl, + websiteUrl = creatorAccount.websiteUrl, + blogUrl = creatorAccount.blogUrl + ) + } + fun getCreatorProfile( creatorId: Long, timezone: String, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt new file mode 100644 index 00000000..cee89522 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.explorer + +data class GetCreatorDetailResponse( + val nickname: String, + val profileImageUrl: String, + val debutDate: String, + val dDay: String, + val activitySummary: GetCreatorActivitySummary, + val instagramUrl: String, + val fancimmUrl: String, + val xUrl: String, + val youtubeUrl: String, + val websiteUrl: String, + val blogUrl: String +) From 2cf797869ba870af8e7dadc94201240e289a26ea Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 23 Feb 2026 16:26:14 +0900 Subject: [PATCH 11/22] =?UTF-8?q?docs(agents):=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EB=88=84=EC=A0=81=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 77aa2626..8fac2538 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,10 @@ - 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다. - 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다. - 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다. +- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰지 않고 누적한다(예: `1차 구현`, `2차 수정`). +- 검증 기록은 단계별로 `무엇을/왜/어떻게`를 유지해 작성하고, 이전 단계와 구분이 되도록 명시한다. +- 단계별 `어떻게`에는 실제 실행한 검증 명령과 결과(성공/실패/불가 사유)를 함께 기록한다. +- 기존 기록 정정이 필요하면 원문을 지우지 말고 `정정` 항목을 추가해 사유와 변경 내용을 남긴다. ## 문서 유지보수 규칙 - `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다. From fa5e65b4322aa3886990451ec7b56b383bb86f6b Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 23 Feb 2026 16:51:53 +0900 Subject: [PATCH 12/22] =?UTF-8?q?fix(explorer):=20=EB=AF=B8=EB=9E=98=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=ED=8F=AC=ED=95=A8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=8C=EC=88=98=20D+=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=EC=9D=84=20=EB=B0=A9=EC=A7=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260223_크리에이터상세정보조회api추가.md | 4 ++++ .../vividnext/sodalive/explorer/ExplorerQueryRepository.kt | 2 ++ .../kr/co/vividnext/sodalive/explorer/ExplorerService.kt | 5 +++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/20260223_크리에이터상세정보조회api추가.md b/docs/20260223_크리에이터상세정보조회api추가.md index 77be446e..30dccfc8 100644 --- a/docs/20260223_크리에이터상세정보조회api추가.md +++ b/docs/20260223_크리에이터상세정보조회api추가.md @@ -4,15 +4,19 @@ - [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)을 확인했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index e776dce8..bd0a9a1f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -572,6 +572,8 @@ class ExplorerQueryRepository( .where( liveRoom.member.id.eq(creatorId) .and(liveRoom.channelName.isNotNull) + .and(liveRoom.beginDateTime.isNotNull) + .and(liveRoom.beginDateTime.loe(LocalDateTime.now())) ) .fetchFirst() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index bd99454d..49205a9f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -225,8 +225,9 @@ class ExplorerService( ).minOrNull() val debutDate = debutDateTime?.toLocalDate() - val dDay = if (debutDate != null) { - "D+${ChronoUnit.DAYS.between(debutDate, LocalDate.now())}" + val elapsedDebutDays = debutDate?.let { ChronoUnit.DAYS.between(it, LocalDate.now()) } + val dDay = if (elapsedDebutDays != null && elapsedDebutDays >= 0) { + "D+$elapsedDebutDays" } else { "" } From 1650ed402c4aff06571bac269a6448d3d84ffa32 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 23 Feb 2026 22:46:50 +0900 Subject: [PATCH 13/22] =?UTF-8?q?feat(channel-donation):=20=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=ED=9B=84=EC=9B=90=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260223_channel_donation_message_ddl.sql | 18 ++ docs/20260223_채널후원기능추가.md | 67 +++++++ .../co/vividnext/sodalive/can/CanService.kt | 32 +++- .../sodalive/can/payment/CanPaymentService.kt | 4 + .../co/vividnext/sodalive/can/use/CanUsage.kt | 1 + .../sodalive/explorer/ExplorerService.kt | 13 ++ .../explorer/GetCreatorProfileResponse.kt | 2 + .../ChannelDonationController.kt | 45 +++++ .../channelDonation/ChannelDonationMessage.kt | 25 +++ .../ChannelDonationMessageRepository.kt | 99 ++++++++++ .../channelDonation/ChannelDonationService.kt | 133 +++++++++++++ .../GetChannelDonationListResponse.kt | 17 ++ .../PostChannelDonationRequest.kt | 9 + .../sodalive/i18n/SodaMessageSource.kt | 10 + .../ChannelDonationControllerTest.kt | 106 +++++++++++ .../ChannelDonationMessageRepositoryTest.kt | 136 ++++++++++++++ .../ChannelDonationServiceTest.kt | 175 ++++++++++++++++++ 17 files changed, 890 insertions(+), 2 deletions(-) create mode 100644 docs/20260223_channel_donation_message_ddl.sql create mode 100644 docs/20260223_채널후원기능추가.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/GetChannelDonationListResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/PostChannelDonationRequest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt diff --git a/docs/20260223_channel_donation_message_ddl.sql b/docs/20260223_channel_donation_message_ddl.sql new file mode 100644 index 00000000..58a5d04d --- /dev/null +++ b/docs/20260223_channel_donation_message_ddl.sql @@ -0,0 +1,18 @@ +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 ='채널 후원 메시지'; diff --git a/docs/20260223_채널후원기능추가.md b/docs/20260223_채널후원기능추가.md new file mode 100644 index 00000000..1cc9c80e --- /dev/null +++ b/docs/20260223_채널후원기능추가.md @@ -0,0 +1,67 @@ +# 채널 후원 기능 추가 작업 계획 + +## 메시지 저장 전략 선택 +- 선택: 기본 메시지는 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` -> 성공 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index bd433c6c..b0b64759 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import java.time.ZoneId @@ -13,7 +14,8 @@ import java.time.format.DateTimeFormatter @Service class CanService( private val repository: CanRepository, - private val countryContext: CountryContext + private val countryContext: CountryContext, + private val memberRepository: MemberRepository ) { fun getCans(isNotSelectedCurrency: Boolean): List { val currency = if (isNotSelectedCurrency) { @@ -40,7 +42,7 @@ class CanService( timezone: String, container: String ): List { - return repository.getCanUseStatus(member, pageable) + val useCanList = repository.getCanUseStatus(member, pageable) .filter { (it.can + it.rewardCan) > 0 } .filter { when (container) { @@ -66,6 +68,21 @@ class CanService( } } } + + val channelDonationCreatorIds = useCanList + .asSequence() + .filter { it.canUsage == CanUsage.CHANNEL_DONATION } + .mapNotNull { it.useCanCalculates.firstOrNull()?.recipientCreatorId } + .distinct() + .toList() + + val channelDonationCreatorNicknameMap = if (channelDonationCreatorIds.isEmpty()) { + emptyMap() + } else { + memberRepository.findAllById(channelDonationCreatorIds).associate { it.id!! to it.nickname } + } + + return useCanList .map { val title: String = when (it.canUsage) { CanUsage.HEART, CanUsage.DONATION, CanUsage.SPIN_ROULETTE -> { @@ -78,6 +95,17 @@ class CanService( } } + CanUsage.CHANNEL_DONATION -> { + val creatorId = it.useCanCalculates.firstOrNull()?.recipientCreatorId + val creatorNickname = creatorId?.let { id -> channelDonationCreatorNicknameMap[id] } + + if (creatorNickname.isNullOrBlank()) { + "[채널 후원]" + } else { + "[채널 후원] $creatorNickname" + } + } + CanUsage.LIVE -> { "[라이브] ${it.room!!.title}" } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index 8a93aaa7..d5de9e6d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -48,6 +48,7 @@ class CanPaymentService( characterId: Long? = null, isSecret: Boolean = false, liveRoom: LiveRoom? = null, + creator: Member? = null, order: Order? = null, audioContent: AudioContent? = null, communityPost: CreatorCommunity? = null, @@ -93,6 +94,9 @@ class CanPaymentService( recipientId = liveRoom.member!!.id!! useCan.room = liveRoom useCan.member = member + } else if (canUsage == CanUsage.CHANNEL_DONATION && creator != null) { + recipientId = creator.id!! + useCan.member = member } else if (canUsage == CanUsage.ORDER_CONTENT && order != null) { recipientId = order.creator!!.id!! useCan.order = order diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt index 44bcbd55..32259413 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -4,6 +4,7 @@ enum class CanUsage { LIVE, HEART, DONATION, + CHANNEL_DONATION, // 채널 후원 CHANGE_NICKNAME, ORDER_CONTENT, SPIN_ROULETTE, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 49205a9f..55549600 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -17,6 +17,7 @@ import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType @@ -51,6 +52,7 @@ class ExplorerService( private val queryRepository: ExplorerQueryRepository, private val cheersRepository: CreatorCheersRepository, private val noticeRepository: ChannelNoticeRepository, + private val channelDonationService: ChannelDonationService, private val communityService: CreatorCommunityService, private val seriesService: ContentSeriesService, @@ -394,6 +396,16 @@ class ExplorerService( limit = 4 ) + val channelDonationList = if (isCreator && !isBlock) { + channelDonationService.getChannelDonationListForProfile( + creatorId = creatorId, + member = member, + limit = 5 + ) + } else { + listOf() + } + // 차단한 크리에이터 인지 체크 val activitySummary = if (isCreator) { // 활동요약 (라이브 횟수, 라이브 시간, 라이브 참여자, 콘텐츠 수) @@ -455,6 +467,7 @@ class ExplorerService( ownedContentCount = ownedContentCount, notice = notice, communityPostList = communityPostList, + channelDonationList = channelDonationList, cheers = cheers, activitySummary = activitySummary, seriesList = seriesList, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt index 083d3e22..50d7665c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.explorer import kr.co.vividnext.sodalive.content.GetAudioContentListItem import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.GetChannelDonationListItem import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse data class GetCreatorProfileResponse( @@ -15,6 +16,7 @@ data class GetCreatorProfileResponse( val ownedContentCount: Long, val notice: String, val communityPostList: List, + val channelDonationList: List, val cheers: GetCheersResponse, val activitySummary: GetCreatorActivitySummary, val seriesList: List, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationController.kt new file mode 100644 index 00000000..4f1b69b1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationController.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/explorer/profile/channel-donation") +class ChannelDonationController( + private val channelDonationService: ChannelDonationService +) { + @PostMapping + fun donate( + @RequestBody request: PostChannelDonationRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok(channelDonationService.donate(request, member)) + } + + @GetMapping + fun getChannelDonationList( + @RequestParam creatorId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok( + channelDonationService.getChannelDonationList( + creatorId = creatorId, + member = member, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt new file mode 100644 index 00000000..3b2e1002 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class ChannelDonationMessage( + val can: Int, + val isSecret: Boolean = false, + @Column(columnDefinition = "TEXT") + var additionalMessage: String? = null +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = false) + var creator: Member? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt new file mode 100644 index 00000000..dcc5f886 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt @@ -0,0 +1,99 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +interface ChannelDonationMessageRepository : + JpaRepository, ChannelDonationMessageQueryRepository + +interface ChannelDonationMessageQueryRepository { + fun getChannelDonationMessageList( + creatorId: Long, + memberId: Long, + isCreator: Boolean, + offset: Long, + limit: Long, + startDateTime: LocalDateTime + ): List + + fun getChannelDonationMessageTotalCount( + creatorId: Long, + memberId: Long, + isCreator: Boolean, + startDateTime: LocalDateTime + ): Int +} + +class ChannelDonationMessageQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : ChannelDonationMessageQueryRepository { + override fun getChannelDonationMessageList( + creatorId: Long, + memberId: Long, + isCreator: Boolean, + offset: Long, + limit: Long, + startDateTime: LocalDateTime + ): List { + val where = whereCondition( + creatorId = creatorId, + memberId = memberId, + isCreator = isCreator, + startDateTime = startDateTime + ) + + return queryFactory + .selectFrom(channelDonationMessage) + .where(where) + .offset(offset) + .limit(limit) + .orderBy( + channelDonationMessage.createdAt.desc(), + channelDonationMessage.id.desc() + ) + .fetch() + } + + override fun getChannelDonationMessageTotalCount( + creatorId: Long, + memberId: Long, + isCreator: Boolean, + startDateTime: LocalDateTime + ): Int { + val where = whereCondition( + creatorId = creatorId, + memberId = memberId, + isCreator = isCreator, + startDateTime = startDateTime + ) + + return queryFactory + .select(channelDonationMessage.id) + .from(channelDonationMessage) + .where(where) + .fetch() + .size + } + + private fun whereCondition( + creatorId: Long, + memberId: Long, + isCreator: Boolean, + startDateTime: LocalDateTime + ) = channelDonationMessage.creator.id.eq(creatorId) + .and(channelDonationMessage.createdAt.goe(startDateTime)) + .let { + if (isCreator) { + it + } else { + it.and( + channelDonationMessage.isSecret.isFalse + .or(channelDonationMessage.member.id.eq(memberId)) + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt new file mode 100644 index 00000000..d6cba758 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt @@ -0,0 +1,133 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Service +class ChannelDonationService( + private val canPaymentService: CanPaymentService, + private val memberRepository: MemberRepository, + private val channelDonationMessageRepository: ChannelDonationMessageRepository, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional + fun donate(request: PostChannelDonationRequest, member: Member) { + if (request.can < 1) { + throw SodaException(messageKey = "content.donation.error.minimum_can") + } + + val creator = memberRepository.findCreatorByIdOrNull(request.creatorId) + ?: throw SodaException(messageKey = "member.validation.creator_not_found") + + canPaymentService.spendCan( + memberId = member.id!!, + needCan = request.can, + canUsage = CanUsage.CHANNEL_DONATION, + creator = creator, + isSecret = request.isSecret, + container = request.container + ) + + val channelDonationMessage = ChannelDonationMessage( + can = request.can, + isSecret = request.isSecret, + additionalMessage = request.message.takeIf { it.isNotBlank() } + ) + channelDonationMessage.member = member + channelDonationMessage.creator = creator + channelDonationMessageRepository.save(channelDonationMessage) + } + + fun getChannelDonationList( + creatorId: Long, + member: Member, + offset: Long, + limit: Long + ): GetChannelDonationListResponse { + memberRepository.findCreatorByIdOrNull(creatorId) + ?: throw SodaException(messageKey = "member.validation.creator_not_found") + + val startDateTime = LocalDateTime.now().minusMonths(1) + val isCreator = member.role == MemberRole.CREATOR && creatorId == member.id + + val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount( + creatorId = creatorId, + memberId = member.id!!, + isCreator = isCreator, + startDateTime = startDateTime + ) + + val items = channelDonationMessageRepository.getChannelDonationMessageList( + creatorId = creatorId, + memberId = member.id!!, + isCreator = isCreator, + offset = offset, + limit = limit, + startDateTime = startDateTime + ).map { + GetChannelDonationListItem( + id = it.id!!, + memberId = it.member!!.id!!, + nickname = it.member!!.nickname, + profileUrl = if (it.member!!.profileImage != null) { + "$cloudFrontHost/${it.member!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + can = it.can, + isSecret = it.isSecret, + message = buildMessage(it.can, it.isSecret, it.additionalMessage), + createdAt = it.createdAt!!.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ) + } + + return GetChannelDonationListResponse(totalCount = totalCount, items = items) + } + + fun getChannelDonationListForProfile( + creatorId: Long, + member: Member, + limit: Long = 5 + ): List { + return getChannelDonationList( + creatorId = creatorId, + member = member, + offset = 0, + limit = limit + ).items + } + + private fun buildMessage(can: Int, isSecret: Boolean, additionalMessage: String?): String { + val key = if (isSecret) { + "explorer.channel_donation.message.default.secret" + } else { + "explorer.channel_donation.message.default.public" + } + val defaultMessage = getMessage(key, can) + + return if (additionalMessage.isNullOrBlank()) { + defaultMessage + } else { + "$defaultMessage\n\"$additionalMessage\"" + } + } + + private fun getMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang).orEmpty() + return if (args.isEmpty()) template else String.format(template, *args) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/GetChannelDonationListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/GetChannelDonationListResponse.kt new file mode 100644 index 00000000..070b0bc0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/GetChannelDonationListResponse.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +data class GetChannelDonationListResponse( + val totalCount: Int, + val items: List +) + +data class GetChannelDonationListItem( + val id: Long, + val memberId: Long, + val nickname: String, + val profileUrl: String, + val can: Int, + val isSecret: Boolean, + val message: String, + val createdAt: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/PostChannelDonationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/PostChannelDonationRequest.kt new file mode 100644 index 00000000..a15ccf18 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/PostChannelDonationRequest.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +data class PostChannelDonationRequest( + val creatorId: Long, + val can: Int, + val isSecret: Boolean = false, + val message: String = "", + val container: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 8e78ce2d..1fa07b51 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1770,6 +1770,16 @@ class SodaMessageSource { Lang.KO to "새 글이 등록되었습니다.", Lang.EN to "A new post has been added.", Lang.JA to "新しい投稿が登録されました。" + ), + "explorer.channel_donation.message.default.public" to mapOf( + Lang.KO to "%s캔을 후원하셨습니다.", + Lang.EN to "You sponsored %s cans.", + Lang.JA to "%sCANを支援しました。" + ), + "explorer.channel_donation.message.default.secret" to mapOf( + Lang.KO to "%s캔을 비밀후원하셨습니다.", + Lang.EN to "You secretly sponsored %s cans.", + Lang.JA to "%sCANをシークレット支援しました。" ) ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt new file mode 100644 index 00000000..79ecd128 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt @@ -0,0 +1,106 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.common.SodaExceptionHandler +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageRequest +import org.springframework.data.web.PageableHandlerMethodArgumentResolver +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders + +class ChannelDonationControllerTest { + private lateinit var channelDonationService: ChannelDonationService + private lateinit var controller: ChannelDonationController + private lateinit var mockMvc: MockMvc + + @BeforeEach + fun setup() { + channelDonationService = Mockito.mock(ChannelDonationService::class.java) + controller = ChannelDonationController(channelDonationService) + + mockMvc = MockMvcBuilders + .standaloneSetup(controller) + .setControllerAdvice(SodaExceptionHandler(LangContext(), SodaMessageSource())) + .setCustomArgumentResolvers( + AuthenticationPrincipalArgumentResolver(), + PageableHandlerMethodArgumentResolver() + ) + .build() + } + + @Test + fun shouldReturnErrorResponseWhenRequesterIsAnonymous() { + mockMvc.perform( + get("/explorer/profile/channel-donation") + .param("creatorId", "1") + .param("page", "0") + .param("size", "5") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("로그인 정보를 확인해주세요.")) + } + + @Test + fun shouldForwardPageableAndMemberToServiceWhenControllerMethodIsCalled() { + val member = createMember(id = 7L, role = MemberRole.USER, nickname = "viewer") + val item = GetChannelDonationListItem( + id = 1001L, + memberId = member.id!!, + nickname = member.nickname, + profileUrl = "https://cdn.test/profile/default-profile.png", + can = 3, + isSecret = false, + message = "3캔을 후원하셨습니다.", + createdAt = "2026-02-23T09:30:00" + ) + val response = GetChannelDonationListResponse(totalCount = 1, items = listOf(item)) + + Mockito.`when`( + channelDonationService.getChannelDonationList( + creatorId = 1L, + member = member, + offset = 10L, + limit = 5L + ) + ).thenReturn(response) + + val apiResponse = controller.getChannelDonationList( + creatorId = 1L, + member = member, + pageable = PageRequest.of(2, 5) + ) + + assertEquals(true, apiResponse.success) + assertEquals(1, apiResponse.data!!.totalCount) + assertEquals(1001L, apiResponse.data!!.items[0].id) + + Mockito.verify(channelDonationService).getChannelDonationList( + creatorId = 1L, + member = member, + offset = 10L, + limit = 5L + ) + } + + private fun createMember(id: Long, role: MemberRole, nickname: String): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + member.id = id + return member + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt new file mode 100644 index 00000000..d1738bc0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt @@ -0,0 +1,136 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest +@Import(QueryDslConfig::class) +class ChannelDonationMessageRepositoryTest @Autowired constructor( + private val channelDonationMessageRepository: ChannelDonationMessageRepository, + private val memberRepository: MemberRepository, + private val entityManager: EntityManager +) { + @Test + fun shouldFilterByDateAndSortByCreatedAtAndIdDescForViewer() { + val creator = saveMember(nickname = "creator", role = MemberRole.CREATOR) + val viewer = saveMember(nickname = "viewer", role = MemberRole.USER) + val otherUser = saveMember(nickname = "other", role = MemberRole.USER) + + val now = LocalDateTime.now() + val tieTime = now.minusDays(2) + + val oldPublic = saveMessage(member = viewer, creator = creator, can = 1, isSecret = false) + val publicTieFirst = saveMessage(member = otherUser, creator = creator, can = 2, isSecret = false) + val publicTieSecond = saveMessage(member = viewer, creator = creator, can = 3, isSecret = false) + val secretMine = saveMessage(member = viewer, creator = creator, can = 4, isSecret = true) + val secretOther = saveMessage(member = otherUser, creator = creator, can = 5, isSecret = true) + + updateCreatedAt(oldPublic.id!!, now.minusMonths(2)) + updateCreatedAt(publicTieFirst.id!!, tieTime) + updateCreatedAt(publicTieSecond.id!!, tieTime) + updateCreatedAt(secretMine.id!!, now.minusDays(1)) + updateCreatedAt(secretOther.id!!, now.minusHours(12)) + entityManager.flush() + entityManager.clear() + + val list = channelDonationMessageRepository.getChannelDonationMessageList( + creatorId = creator.id!!, + memberId = viewer.id!!, + isCreator = false, + offset = 0, + limit = 10, + startDateTime = now.minusMonths(1) + ) + + val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount( + creatorId = creator.id!!, + memberId = viewer.id!!, + isCreator = false, + startDateTime = now.minusMonths(1) + ) + + assertEquals(3, list.size) + assertEquals(secretMine.id, list[0].id) + assertEquals(publicTieSecond.id, list[1].id) + assertEquals(publicTieFirst.id, list[2].id) + assertEquals(3, totalCount) + } + + @Test + fun shouldIncludeAllRecentSecretMessagesForCreator() { + val creator = saveMember(nickname = "creator2", role = MemberRole.CREATOR) + val viewer = saveMember(nickname = "viewer2", role = MemberRole.USER) + val otherUser = saveMember(nickname = "other2", role = MemberRole.USER) + + val now = LocalDateTime.now() + val oldPublic = saveMessage(member = viewer, creator = creator, can = 1, isSecret = false) + val recentPublic = saveMessage(member = viewer, creator = creator, can = 2, isSecret = false) + val recentSecretMine = saveMessage(member = viewer, creator = creator, can = 3, isSecret = true) + val recentSecretOther = saveMessage(member = otherUser, creator = creator, can = 4, isSecret = true) + + updateCreatedAt(oldPublic.id!!, now.minusMonths(2)) + updateCreatedAt(recentPublic.id!!, now.minusDays(3)) + updateCreatedAt(recentSecretMine.id!!, now.minusDays(2)) + updateCreatedAt(recentSecretOther.id!!, now.minusDays(1)) + entityManager.flush() + entityManager.clear() + + val list = channelDonationMessageRepository.getChannelDonationMessageList( + creatorId = creator.id!!, + memberId = creator.id!!, + isCreator = true, + offset = 0, + limit = 10, + startDateTime = now.minusMonths(1) + ) + + val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount( + creatorId = creator.id!!, + memberId = creator.id!!, + isCreator = true, + startDateTime = now.minusMonths(1) + ) + + assertEquals(3, list.size) + assertEquals(recentSecretOther.id, list[0].id) + assertEquals(recentSecretMine.id, list[1].id) + assertEquals(recentPublic.id, list[2].id) + assertEquals(3, totalCount) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveMessage(member: Member, creator: Member, can: Int, isSecret: Boolean): ChannelDonationMessage { + val message = ChannelDonationMessage(can = can, isSecret = isSecret) + message.member = member + message.creator = creator + return channelDonationMessageRepository.saveAndFlush(message) + } + + private fun updateCreatedAt(id: Long, createdAt: LocalDateTime) { + entityManager.createQuery( + "update ChannelDonationMessage m set m.createdAt = :createdAt where m.id = :id" + ) + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt new file mode 100644 index 00000000..e3d86771 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt @@ -0,0 +1,175 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.LocalDateTime + +class ChannelDonationServiceTest { + private lateinit var canPaymentService: CanPaymentService + private lateinit var memberRepository: MemberRepository + private lateinit var channelDonationMessageRepository: ChannelDonationMessageRepository + private lateinit var service: ChannelDonationService + + @BeforeEach + fun setup() { + canPaymentService = Mockito.mock(CanPaymentService::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) + channelDonationMessageRepository = Mockito.mock(ChannelDonationMessageRepository::class.java) + service = ChannelDonationService( + canPaymentService = canPaymentService, + memberRepository = memberRepository, + channelDonationMessageRepository = channelDonationMessageRepository, + messageSource = SodaMessageSource(), + langContext = LangContext(), + cloudFrontHost = "https://cdn.test" + ) + } + + @Test + fun shouldThrowWhenDonateCanIsLessThanOne() { + val member = createMember(id = 10L, role = MemberRole.USER, nickname = "viewer") + val request = PostChannelDonationRequest( + creatorId = 1L, + can = 0, + isSecret = false, + message = "", + container = "aos" + ) + + val exception = assertThrows(SodaException::class.java) { + service.donate(request, member) + } + + assertEquals("content.donation.error.minimum_can", exception.messageKey) + } + + @Test + fun shouldPassUserVisibilityFlagToRepositoryWhenRequesterIsNotCreator() { + val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator") + val viewer = createMember(id = 2L, role = MemberRole.USER, nickname = "viewer") + val message = ChannelDonationMessage(can = 3, isSecret = true, additionalMessage = "응원합니다") + message.id = 1001L + message.member = viewer + message.creator = creator + message.createdAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0) + + Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator) + Mockito.`when`( + channelDonationMessageRepository.getChannelDonationMessageTotalCount( + Mockito.eq(creator.id!!), + Mockito.eq(viewer.id!!), + Mockito.eq(false), + anyLocalDateTime() + ) + ).thenReturn(1) + Mockito.`when`( + channelDonationMessageRepository.getChannelDonationMessageList( + Mockito.eq(creator.id!!), + Mockito.eq(viewer.id!!), + Mockito.eq(false), + Mockito.eq(0L), + Mockito.eq(5L), + anyLocalDateTime() + ) + ).thenReturn(listOf(message)) + + val result = service.getChannelDonationList( + creatorId = creator.id!!, + member = viewer, + offset = 0, + limit = 5 + ) + + assertEquals(1, result.totalCount) + assertEquals(1, result.items.size) + assertEquals("3캔을 비밀후원하셨습니다.\n\"응원합니다\"", result.items[0].message) + + Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageTotalCount( + Mockito.eq(creator.id!!), + Mockito.eq(viewer.id!!), + Mockito.eq(false), + anyLocalDateTime() + ) + Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageList( + Mockito.eq(creator.id!!), + Mockito.eq(viewer.id!!), + Mockito.eq(false), + Mockito.eq(0L), + Mockito.eq(5L), + anyLocalDateTime() + ) + } + + @Test + fun shouldPassCreatorVisibilityFlagToRepositoryWhenRequesterIsCreatorSelf() { + val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator") + + Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator) + Mockito.`when`( + channelDonationMessageRepository.getChannelDonationMessageTotalCount( + Mockito.eq(creator.id!!), + Mockito.eq(creator.id!!), + Mockito.eq(true), + anyLocalDateTime() + ) + ).thenReturn(0) + Mockito.`when`( + channelDonationMessageRepository.getChannelDonationMessageList( + Mockito.eq(creator.id!!), + Mockito.eq(creator.id!!), + Mockito.eq(true), + Mockito.eq(0L), + Mockito.eq(5L), + anyLocalDateTime() + ) + ).thenReturn(emptyList()) + + service.getChannelDonationList( + creatorId = creator.id!!, + member = creator, + offset = 0, + limit = 5 + ) + + Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageTotalCount( + Mockito.eq(creator.id!!), + Mockito.eq(creator.id!!), + Mockito.eq(true), + anyLocalDateTime() + ) + Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageList( + Mockito.eq(creator.id!!), + Mockito.eq(creator.id!!), + Mockito.eq(true), + Mockito.eq(0L), + Mockito.eq(5L), + anyLocalDateTime() + ) + } + + private fun createMember(id: Long, role: MemberRole, nickname: String): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + member.id = id + return member + } + + private fun anyLocalDateTime(): LocalDateTime { + Mockito.any(LocalDateTime::class.java) + return LocalDateTime.MIN + } +} From 772883993bec36efc92d9bd0abcb3412b112445c Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 24 Feb 2026 17:22:29 +0900 Subject: [PATCH 14/22] =?UTF-8?q?feat(profile):=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EC=98=A4=ED=94=88=EC=B1=84=ED=8C=85=20URL=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=A1=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EA=B3=BC=20=EC=88=98=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260224_SNS카카오오픈채팅전환.md | 19 +++++++++++++++++++ .../sodalive/explorer/CreatorResponse.kt | 3 +-- .../sodalive/explorer/ExplorerService.kt | 6 ++---- .../explorer/GetCreatorDetailResponse.kt | 3 +-- .../room/GetLiveRoomUserProfileResponse.kt | 3 +-- .../sodalive/live/room/LiveRoomService.kt | 3 +-- .../live/room/detail/GetRoomDetailResponse.kt | 6 ++---- .../sodalive/member/MemberService.kt | 11 +++-------- .../sodalive/member/ProfileResponse.kt | 6 ++---- .../sodalive/member/ProfileUpdateRequest.kt | 3 +-- .../sodalive/member/myPage/MyPageResponse.kt | 3 +-- 11 files changed, 34 insertions(+), 32 deletions(-) create mode 100644 docs/20260224_SNS카카오오픈채팅전환.md diff --git a/docs/20260224_SNS카카오오픈채팅전환.md b/docs/20260224_SNS카카오오픈채팅전환.md new file mode 100644 index 00000000..7c741b58 --- /dev/null +++ b/docs/20260224_SNS카카오오픈채팅전환.md @@ -0,0 +1,19 @@ +## 구현 항목 + +- [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 진단/테스트/빌드 검증 및 결과 기록 + +## 검증 기록 + +- 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 성공) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt index 3d1c2d52..3316b7ea 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt @@ -10,8 +10,7 @@ data class CreatorResponse( val fancimmUrl: String? = null, val xUrl: String? = null, val youtubeUrl: String? = null, - val websiteUrl: String? = null, - val blogUrl: String? = null, + val kakaoOpenChatUrl: String? = null, val isFollow: Boolean, val isNotify: Boolean, val isNotification: Boolean, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 55549600..a8eac5e5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -248,8 +248,7 @@ class ExplorerService( fancimmUrl = creatorAccount.fancimmUrl, xUrl = creatorAccount.xUrl, youtubeUrl = creatorAccount.youtubeUrl, - websiteUrl = creatorAccount.websiteUrl, - blogUrl = creatorAccount.blogUrl + kakaoOpenChatUrl = creatorAccount.websiteUrl ) } @@ -451,8 +450,7 @@ class ExplorerService( fancimmUrl = creatorAccount.fancimmUrl, xUrl = creatorAccount.xUrl, youtubeUrl = creatorAccount.youtubeUrl, - websiteUrl = creatorAccount.websiteUrl, - blogUrl = creatorAccount.blogUrl, + kakaoOpenChatUrl = creatorAccount.websiteUrl, isFollow = creatorFollowing?.isFollow ?: false, isNotify = creatorFollowing?.isNotify ?: false, isNotification = creatorFollowing?.isFollow ?: false, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt index cee89522..e190ce87 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt @@ -10,6 +10,5 @@ data class GetCreatorDetailResponse( val fancimmUrl: String, val xUrl: String, val youtubeUrl: String, - val websiteUrl: String, - val blogUrl: String + val kakaoOpenChatUrl: String ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt index f93d51e9..cfbb15a9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt @@ -9,8 +9,7 @@ data class GetLiveRoomUserProfileResponse( val fancimmUrl: String, val xUrl: String, val youtubeUrl: String, - val websiteUrl: String, - val blogUrl: String, + val kakaoOpenChatUrl: String, val introduce: String, val tags: String, val isSpeaker: Boolean?, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 2b86cdd2..bd8502c5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -1123,8 +1123,7 @@ class LiveRoomService( fancimmUrl = user.fancimmUrl, xUrl = user.xUrl, youtubeUrl = user.youtubeUrl, - websiteUrl = user.websiteUrl, - blogUrl = user.blogUrl, + kakaoOpenChatUrl = user.websiteUrl, introduce = user.introduce, tags = user.tags .asSequence() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt index b22590fb..a0def2f7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/detail/GetRoomDetailResponse.kt @@ -33,8 +33,7 @@ data class GetRoomDetailManager( val instagramUrl: String?, val fancimmUrl: String?, val xUrl: String?, - val websiteUrl: String?, - val blogUrl: String?, + val kakaoOpenChatUrl: String?, val profileImageUrl: String, val isCreator: Boolean ) { @@ -46,8 +45,7 @@ data class GetRoomDetailManager( instagramUrl = member.instagramUrl, fancimmUrl = member.fancimmUrl, xUrl = member.xUrl, - websiteUrl = member.websiteUrl, - blogUrl = member.blogUrl, + kakaoOpenChatUrl = member.websiteUrl, profileImageUrl = if (member.profileImage != null) { "$cloudFrontHost/${member.profileImage}" } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 8c60dc8b..80a3ef77 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -305,8 +305,7 @@ class MemberService( instagramUrl = member.instagramUrl, fancimmUrl = member.fancimmUrl, xUrl = member.xUrl, - websiteUrl = member.websiteUrl, - blogUrl = member.blogUrl, + kakaoOpenChatUrl = member.websiteUrl, liveReservationCount = liveReservationCount, isAuth = member.auth != null, orderList = orderList @@ -737,12 +736,8 @@ class MemberService( member.xUrl = profileUpdateRequest.xUrl } - if (profileUpdateRequest.websiteUrl != null) { - member.websiteUrl = profileUpdateRequest.websiteUrl - } - - if (profileUpdateRequest.blogUrl != null) { - member.blogUrl = profileUpdateRequest.blogUrl + if (profileUpdateRequest.kakaoOpenChatUrl != null) { + member.websiteUrl = profileUpdateRequest.kakaoOpenChatUrl } if (profileUpdateRequest.isVisibleDonationRank != null) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt index 4172767f..c401a6ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt @@ -12,8 +12,7 @@ data class ProfileResponse( val instagramUrl: String?, val fancimmUrl: String?, val xUrl: String?, - val blogUrl: String?, - val websiteUrl: String?, + val kakaoOpenChatUrl: String?, val introduce: String, val tags: List ) { @@ -33,8 +32,7 @@ data class ProfileResponse( instagramUrl = member.instagramUrl, fancimmUrl = member.fancimmUrl, xUrl = member.xUrl, - websiteUrl = member.websiteUrl, - blogUrl = member.blogUrl, + kakaoOpenChatUrl = member.websiteUrl, introduce = member.introduce, tags = member.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList() ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt index 1f85148a..1e7b02b0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt @@ -13,8 +13,7 @@ data class ProfileUpdateRequest( val instagramUrl: String? = null, val fancimmUrl: String? = null, val xUrl: String? = null, - val websiteUrl: String? = null, - val blogUrl: String? = null, + val kakaoOpenChatUrl: String? = null, val isVisibleDonationRank: Boolean? = null, val donationRankingPeriod: DonationRankingPeriod? = null, val container: String diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt index 5c89d6e3..71930497 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/myPage/MyPageResponse.kt @@ -12,8 +12,7 @@ data class MyPageResponse( val instagramUrl: String?, val fancimmUrl: String? = null, val xUrl: String? = null, - val websiteUrl: String? = null, - val blogUrl: String? = null, + val kakaoOpenChatUrl: String? = null, val liveReservationCount: Int, val isAuth: Boolean, val orderList: GetAudioContentOrderListResponse From 02cb4aa29ce4d4b5ed8d0aec32970ffb62ed5706 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 24 Feb 2026 19:30:09 +0900 Subject: [PATCH 15/22] =?UTF-8?q?fix(profile):=20non-null=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=ED=98=B8=ED=99=98=EC=9D=84=20=EC=9C=84=ED=95=B4=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=EB=90=9C=20SNS=20=ED=95=84=EB=93=9C=EB=A5=BC?= =?UTF-8?q?=20=EB=B3=B5=EA=B5=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260224_SNS카카오오픈채팅전환.md | 11 +++++++++++ .../co/vividnext/sodalive/explorer/ExplorerService.kt | 2 ++ .../sodalive/explorer/GetCreatorDetailResponse.kt | 2 ++ .../live/room/GetLiveRoomUserProfileResponse.kt | 2 ++ .../vividnext/sodalive/live/room/LiveRoomService.kt | 2 ++ 5 files changed, 19 insertions(+) diff --git a/docs/20260224_SNS카카오오픈채팅전환.md b/docs/20260224_SNS카카오오픈채팅전환.md index 7c741b58..35b9497f 100644 --- a/docs/20260224_SNS카카오오픈채팅전환.md +++ b/docs/20260224_SNS카카오오픈채팅전환.md @@ -6,6 +6,9 @@ - [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차 수정 검증: 테스트/빌드 재실행 및 결과 기록 ## 검증 기록 @@ -17,3 +20,11 @@ - 정적 진단: `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 성공) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index a8eac5e5..9ee7cdcf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -248,6 +248,8 @@ class ExplorerService( fancimmUrl = creatorAccount.fancimmUrl, xUrl = creatorAccount.xUrl, youtubeUrl = creatorAccount.youtubeUrl, + websiteUrl = creatorAccount.websiteUrl, + blogUrl = creatorAccount.blogUrl, kakaoOpenChatUrl = creatorAccount.websiteUrl ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt index e190ce87..835714d6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt @@ -10,5 +10,7 @@ data class GetCreatorDetailResponse( val fancimmUrl: String, val xUrl: String, val youtubeUrl: String, + val websiteUrl: String, + val blogUrl: String, val kakaoOpenChatUrl: String ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt index cfbb15a9..b2d2cabb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt @@ -9,6 +9,8 @@ data class GetLiveRoomUserProfileResponse( val fancimmUrl: String, val xUrl: String, val youtubeUrl: String, + val websiteUrl: String, + val blogUrl: String, val kakaoOpenChatUrl: String, val introduce: String, val tags: String, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index bd8502c5..04a230f7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -1123,6 +1123,8 @@ class LiveRoomService( fancimmUrl = user.fancimmUrl, xUrl = user.xUrl, youtubeUrl = user.youtubeUrl, + websiteUrl = user.websiteUrl, + blogUrl = user.blogUrl, kakaoOpenChatUrl = user.websiteUrl, introduce = user.introduce, tags = user.tags From 16cc26f3f9f8dc77bc73220ff4207dc7df955e8f Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 25 Feb 2026 11:56:34 +0900 Subject: [PATCH 16/22] =?UTF-8?q?fix(explorer):=20JSON=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=ED=82=A4=EB=A5=BC=20=EB=AA=85=EC=8B=9C?= =?UTF-8?q?=ED=95=B4=20=EC=9D=91=EB=8B=B5=20=ED=95=84=EB=93=9C=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=EC=9D=84=20=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../explorer/GetCreatorDetailResponse.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt index 835714d6..4e143590 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt @@ -1,16 +1,18 @@ package kr.co.vividnext.sodalive.explorer +import com.fasterxml.jackson.annotation.JsonProperty + data class GetCreatorDetailResponse( - val nickname: String, - val profileImageUrl: String, - val debutDate: String, - val dDay: String, - val activitySummary: GetCreatorActivitySummary, - val instagramUrl: String, - val fancimmUrl: String, - val xUrl: String, - val youtubeUrl: String, - val websiteUrl: String, - val blogUrl: String, - val kakaoOpenChatUrl: String + @JsonProperty("nickname") val nickname: String, + @JsonProperty("profileImageUrl") val profileImageUrl: String, + @JsonProperty("debutDate") val debutDate: String, + @JsonProperty("dDay") val dDay: String, + @JsonProperty("activitySummary") val activitySummary: GetCreatorActivitySummary, + @JsonProperty("instagramUrl") val instagramUrl: String, + @JsonProperty("fancimmUrl") val fancimmUrl: String, + @JsonProperty("xUrl") val xUrl: String, + @JsonProperty("youtubeUrl") val youtubeUrl: String, + @JsonProperty("websiteUrl") val websiteUrl: String, + @JsonProperty("blogUrl") val blogUrl: String, + @JsonProperty("kakaoOpenChatUrl") val kakaoOpenChatUrl: String ) From d398d4780a8101a38e2890936501516147ca639a Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 25 Feb 2026 14:21:50 +0900 Subject: [PATCH 17/22] =?UTF-8?q?fix(profile):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20websiteUrl,=20blogUrl?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt | 2 -- .../co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 9ee7cdcf..d6ab8ef8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -247,8 +247,6 @@ class ExplorerService( instagramUrl = creatorAccount.instagramUrl, fancimmUrl = creatorAccount.fancimmUrl, xUrl = creatorAccount.xUrl, - youtubeUrl = creatorAccount.youtubeUrl, - websiteUrl = creatorAccount.websiteUrl, blogUrl = creatorAccount.blogUrl, kakaoOpenChatUrl = creatorAccount.websiteUrl ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt index 4e143590..f8dd05e1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt @@ -11,8 +11,6 @@ data class GetCreatorDetailResponse( @JsonProperty("instagramUrl") val instagramUrl: String, @JsonProperty("fancimmUrl") val fancimmUrl: String, @JsonProperty("xUrl") val xUrl: String, - @JsonProperty("youtubeUrl") val youtubeUrl: String, - @JsonProperty("websiteUrl") val websiteUrl: String, @JsonProperty("blogUrl") val blogUrl: String, @JsonProperty("kakaoOpenChatUrl") val kakaoOpenChatUrl: String ) From 4e12eaddfe20c5d7e70c3ea6cb57167a3417b056 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 25 Feb 2026 20:40:54 +0900 Subject: [PATCH 18/22] =?UTF-8?q?fix(channel-donation):=20=ED=9B=84?= =?UTF-8?q?=EC=9B=90=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=BA=94=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=EC=9D=84=20=EC=B2=9C=EB=8B=A8=EC=9C=84=20=EC=BD=A4?= =?UTF-8?q?=EB=A7=88=EB=A1=9C=20=ED=91=9C=EC=8B=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...260225_채널후원메시지_캔_천단위콤마추가.md | 21 ++++++++++ .../channelDonation/ChannelDonationService.kt | 3 +- .../ChannelDonationControllerTest.kt | 4 +- .../ChannelDonationServiceTest.kt | 40 +++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 docs/20260225_채널후원메시지_캔_천단위콤마추가.md diff --git a/docs/20260225_채널후원메시지_캔_천단위콤마추가.md b/docs/20260225_채널후원메시지_캔_천단위콤마추가.md new file mode 100644 index 00000000..ab188846 --- /dev/null +++ b/docs/20260225_채널후원메시지_캔_천단위콤마추가.md @@ -0,0 +1,21 @@ +# 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` 실행: 성공 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt index d6cba758..d1006170 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt @@ -117,7 +117,8 @@ class ChannelDonationService( } else { "explorer.channel_donation.message.default.public" } - val defaultMessage = getMessage(key, can) + val formattedCan = String.format("%,d", can) + val defaultMessage = getMessage(key, formattedCan) return if (additionalMessage.isNullOrBlank()) { defaultMessage diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt index 79ecd128..3babff1a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt @@ -59,9 +59,9 @@ class ChannelDonationControllerTest { memberId = member.id!!, nickname = member.nickname, profileUrl = "https://cdn.test/profile/default-profile.png", - can = 3, + can = 1000, isSecret = false, - message = "3캔을 후원하셨습니다.", + message = "1,000캔을 후원하셨습니다.", createdAt = "2026-02-23T09:30:00" ) val response = GetChannelDonationListResponse(totalCount = 1, items = listOf(item)) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt index e3d86771..d9705cb0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt @@ -110,6 +110,46 @@ class ChannelDonationServiceTest { ) } + @Test + fun shouldFormatCanWithCommaInDonationMessage() { + val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator") + val viewer = createMember(id = 2L, role = MemberRole.USER, nickname = "viewer") + val message = ChannelDonationMessage(can = 1000, isSecret = true, additionalMessage = "응원합니다") + message.id = 1001L + message.member = viewer + message.creator = creator + message.createdAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0) + + Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator) + Mockito.`when`( + channelDonationMessageRepository.getChannelDonationMessageTotalCount( + Mockito.eq(creator.id!!), + Mockito.eq(viewer.id!!), + Mockito.eq(false), + anyLocalDateTime() + ) + ).thenReturn(1) + Mockito.`when`( + channelDonationMessageRepository.getChannelDonationMessageList( + Mockito.eq(creator.id!!), + Mockito.eq(viewer.id!!), + Mockito.eq(false), + Mockito.eq(0L), + Mockito.eq(5L), + anyLocalDateTime() + ) + ).thenReturn(listOf(message)) + + val result = service.getChannelDonationList( + creatorId = creator.id!!, + member = viewer, + offset = 0, + limit = 5 + ) + + assertEquals("1,000캔을 비밀후원하셨습니다.\n\"응원합니다\"", result.items[0].message) + } + @Test fun shouldPassCreatorVisibilityFlagToRepositoryWhenRequesterIsCreatorSelf() { val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator") From a983ed15625f27af705141ee3f167f06860db71f Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 25 Feb 2026 21:03:14 +0900 Subject: [PATCH 19/22] =?UTF-8?q?fix(profile):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20blogUrl=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20=EC=9E=98=EB=AA=BB=20=EC=A0=9C=EA=B1=B0=EB=90=9C?= =?UTF-8?q?=20youtubeUrl=20=EB=8B=A4=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt index f8dd05e1..b7d5eab8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt @@ -11,6 +11,6 @@ data class GetCreatorDetailResponse( @JsonProperty("instagramUrl") val instagramUrl: String, @JsonProperty("fancimmUrl") val fancimmUrl: String, @JsonProperty("xUrl") val xUrl: String, - @JsonProperty("blogUrl") val blogUrl: String, + @JsonProperty("youtubeUrl") val youtubeUrl: String, @JsonProperty("kakaoOpenChatUrl") val kakaoOpenChatUrl: String ) From 5f63574daad61311dc429c22c9d07a1b23682d60 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 25 Feb 2026 21:10:40 +0900 Subject: [PATCH 20/22] =?UTF-8?q?fix(profile):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20blogUrl=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20=EC=9E=98=EB=AA=BB=20=EC=A0=9C=EA=B1=B0=EB=90=9C?= =?UTF-8?q?=20youtubeUrl=20=EB=8B=A4=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index d6ab8ef8..a8eac5e5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -247,7 +247,7 @@ class ExplorerService( instagramUrl = creatorAccount.instagramUrl, fancimmUrl = creatorAccount.fancimmUrl, xUrl = creatorAccount.xUrl, - blogUrl = creatorAccount.blogUrl, + youtubeUrl = creatorAccount.youtubeUrl, kakaoOpenChatUrl = creatorAccount.websiteUrl ) } From 39c215c0422bc203170d2237c63cc6a67d7e6e69 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 25 Feb 2026 22:03:57 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix(member-block):=20=EB=8F=99=EC=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=ED=8C=90=EB=B3=84=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20?= =?UTF-8?q?name=20birth=20di=20gender=20=EC=A1=B0=ED=95=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=95=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260225_회원차단동일인판별조건강화.md | 15 +++++++++++++++ .../co/vividnext/sodalive/member/MemberService.kt | 9 +++++++-- .../sodalive/member/auth/AuthRepository.kt | 15 +++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 docs/20260225_회원차단동일인판별조건강화.md diff --git a/docs/20260225_회원차단동일인판별조건강화.md b/docs/20260225_회원차단동일인판별조건강화.md new file mode 100644 index 00000000..43128247 --- /dev/null +++ b/docs/20260225_회원차단동일인판별조건강화.md @@ -0,0 +1,15 @@ +- [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` 체크 통과를 확인했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 80a3ef77..feaf92d5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -529,8 +529,13 @@ class MemberService( ?: throw SodaException(messageKey = "common.error.invalid_request") val blockTargetMemberIds = mutableSetOf(request.blockMemberId) - blockedMember.auth?.di?.let { di -> - val verifiedMemberIds = authRepository.getMemberIdsByDi(di = di) + blockedMember.auth?.let { auth -> + val verifiedMemberIds = authRepository.getMemberIdsByNameAndBirthAndDiAndGender( + name = auth.name, + birth = auth.birth, + di = auth.di, + gender = auth.gender + ) blockTargetMemberIds.addAll(verifiedMemberIds) } blockTargetMemberIds.remove(memberId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt index 0fe96e5e..7f6b9f94 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt @@ -13,6 +13,7 @@ interface AuthRepository : JpaRepository, AuthQueryRepository interface AuthQueryRepository { fun getOldestCreatedAtByDi(di: String): LocalDateTime fun getMemberIdsByDi(di: String): List + fun getMemberIdsByNameAndBirthAndDiAndGender(name: String, birth: String, di: String, gender: Int): List fun getAuthIdByMemberId(memberId: Long): Long? fun getActiveMemberIdsByDi(di: String): List } @@ -36,6 +37,20 @@ class AuthQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AuthQ .fetch() } + override fun getMemberIdsByNameAndBirthAndDiAndGender(name: String, birth: String, di: String, gender: Int): List { + return queryFactory + .select(member.id) + .from(member) + .leftJoin(member.auth, auth) + .where( + auth.name.eq(name) + .and(auth.birth.eq(birth)) + .and(auth.di.eq(di)) + .and(auth.gender.eq(gender)) + ) + .fetch() + } + override fun getAuthIdByMemberId(memberId: Long): Long? { return queryFactory .select(auth.id) From 1f611ef46ecc96aff2315b8384078d268687b448 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 25 Feb 2026 22:23:37 +0900 Subject: [PATCH 22/22] =?UTF-8?q?fix(rank):=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=96=91=EB=B0=A9?= =?UTF-8?q?=ED=96=A5=EC=9C=BC=EB=A1=9C=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260225_인기크리에이터차단필터링.md | 13 +++++++++++++ .../co/vividnext/sodalive/rank/RankingRepository.kt | 12 +++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 docs/20260225_인기크리에이터차단필터링.md diff --git a/docs/20260225_인기크리에이터차단필터링.md b/docs/20260225_인기크리에이터차단필터링.md new file mode 100644 index 00000000..7044a11c --- /dev/null +++ b/docs/20260225_인기크리에이터차단필터링.md @@ -0,0 +1,13 @@ +- [x] 홈 인기 크리에이터 조회 경로 확인 및 차단 필터 적용 지점 확정 +- [x] 인기 크리에이터 목록에서 내가 차단한 크리에이터 제외 로직 적용 +- [x] 변경 파일 진단 및 테스트/빌드 검증 수행 +- [x] 검증 결과 기록 + +## 1차 구현 검증 기록 +- 무엇: 홈 인기 크리에이터 조회 시 차단 관계 조건을 양방향으로 반영해 내가 차단한 크리에이터가 노출되지 않도록 수정했다. +- 왜: 일반 유저가 차단한 크리에이터가 인기 크리에이터 목록에 계속 노출되는 문제를 해결하기 위해서다. +- 어떻게: + - `lsp_diagnostics`: Kotlin LSP 서버가 환경에 구성되지 않아 해당 도구 기반 진단은 수행 불가. + - `./gradlew ktlintCheck`: 성공. + - `./gradlew test`: 성공. + - `./gradlew build -x test`: 성공. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt index b813d9ae..a95c5e05 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt @@ -36,9 +36,15 @@ class RankingRepository( ) { fun getCreatorRankings(memberId: Long? = null): List { val blockMemberCondition = if (memberId != null) { - blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) } else { null }