Compare commits

...

9 Commits

49 changed files with 1063 additions and 250 deletions

View File

@@ -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

View File

@@ -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: `<type>(scope): <description>`.
2. `type` must be lowercase (for example `feat`, `fix`, `chore`, `docs`, `refactor`, `test`).
3. `description` must include Korean text and stay concise in imperative present tone.
4. Optional footer must use `Refs: #123` or `Refs: #123, #456` format.
5. Never commit secret files (`.env`, key/token/secret credential files).
6. Never bypass hooks with `--no-verify`.
## Execution Flow
1. Inspect context with:
- `git status`
- `git diff --cached`
- `git diff`
- `git log -5 --oneline`
2. Stage commit target files only. Exclude suspicious secret-bearing files.
3. Draft commit message from the change intent (focus on why, not only what).
4. Run pre-commit validation with the full draft message:
- `./work/scripts/check-commit-message-rules.sh --message "<full message>"`
5. If validation fails, revise message and re-run until PASS.
6. Commit using the validated message.
7. Run post-commit validation:
- `./work/scripts/check-commit-message-rules.sh`
8. Report executed commands and PASS/FAIL summary.
## Output Checklist
- Final commit subject.
- Whether pre-check passed.
- Whether post-check passed.
- Any excluded files and reason.

161
AGENTS.md
View File

@@ -1,37 +1,144 @@
> 이 문서는 본 저장소에서 **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)
- 커밋 상세 가이드/절차는 `.opencode/skills/commit-policy/SKILL.md`를 단일 기준으로 사용한다.
- 커밋 작업 시작 시 `skill` 도구로 `commit-policy`를 먼저 로드한다.
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test` 등)를 사용한다.
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
### 커밋 메시지 검증 절차
- `git commit` 실행 직전에 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
- `git commit` 실행 직후에도 `work/scripts/check-commit-message-rules.sh`를 다시 실행해 최종 메시지를 재검증한다.
- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 규칙에 맞게 수정한 뒤 다시 검증한다.
## 작업 절차 체크리스트
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다.
- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다.
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다.
## 작업 계획 문서 규칙 (docs)
- 모든 작업 시작 전에 `docs` 폴더 아래에 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현을 진행한다.
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- 파일명 예시: `20260101_구글계정으로로그인.md`
- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다.
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다.
- 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다.
- 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다.
## 문서 유지보수 규칙
- `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다.
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
- Cursor/Copilot 규칙 파일이 생기면 해당 내용을 이 문서에 반영한다.
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
- 커밋 규칙 예시는 팀 컨벤션 변경 시 즉시 업데이트한다.
## 에이전트 동작 원칙
- 추측하지 말고, 근거 파일을 읽고 결정한다.
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.

View File

@@ -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`을 확인했다.

View File

@@ -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
;

View File

@@ -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`, `""`인지 검증했다.

View File

@@ -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`을 확인했다.

View File

@@ -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`)을 확인했다.

View File

@@ -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`을 확인했다.

View File

@@ -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`는 수행할 수 없었다.

View File

@@ -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 포함).

View File

@@ -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 포함).

View File

@@ -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
}

View File

@@ -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,

View File

@@ -469,9 +469,15 @@ class AudioContentQueryRepositoryImpl(
limit: Long,
isFree: Boolean
): List<GetAudioContentMainItem> {
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<GetAudioContentMainItem> {
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<GetAudioContentMainItem> {
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<AudioContentMainItem> {
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
}

View File

@@ -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)
}
}

View File

@@ -128,7 +128,8 @@ class CategoryService(
@Transactional
fun getCategoryList(creatorId: Long, memberId: Long): List<GetCategoryListResponse> {
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")
// 기본 카테고리 목록 조회 (원본 언어 기준)

View File

@@ -98,6 +98,7 @@ class AudioContentCommentController(
return ApiResponse.ok(
service.getCommentReplyList(
commentId = commentId,
memberId = member.id!!,
timezone = timezone,
pageable = pageable
)

View File

@@ -1,13 +1,16 @@
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
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
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
@@ -27,10 +30,11 @@ interface AudioContentCommentQueryRepository {
): List<GetAudioContentCommentListItem>
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
@@ -59,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(
@@ -103,8 +109,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, memberId)
)
}
}
@@ -112,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(
@@ -130,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
@@ -145,6 +157,7 @@ class AudioContentCommentQueryRepositoryImpl(
override fun getAudioContentCommentReplyList(
cloudFrontHost: String,
commentId: Long,
memberId: Long,
timezone: String,
offset: Long,
limit: Int
@@ -182,11 +195,16 @@ 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())
.orderBy(audioContentComment.createdAt.desc())
.fetch()
.map {
it.copy(nickname = it.nickname.removeDeletedNicknamePrefix())
}
}
override fun findPushTokenByContentIdAndCommentParentIdMyMemberId(
@@ -238,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)
)
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -27,9 +27,15 @@ class AudioContentMainTabRepository(
isAdult: Boolean,
contentType: ContentType
): List<ContentCreatorResponse> {
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<ContentCreatorResponse> {
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))

View File

@@ -62,9 +62,15 @@ class ContentMainTabTagCurationRepository(
tag: String,
contentType: ContentType
): List<GetAudioContentMainItem> {
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)

View File

@@ -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<GetSeriesGenreListResponse> {
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<GetSeriesListResponse.SeriesListItem> {
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())
}

View File

@@ -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")
}

View File

@@ -7,6 +7,8 @@ data class CreatorResponse(
val tags: List<String>,
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,

View File

@@ -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))
}
}

View File

@@ -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
@@ -450,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())
@@ -487,7 +510,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 {
@@ -497,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"))
@@ -505,7 +531,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 {

View File

@@ -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,
@@ -372,6 +377,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,
@@ -514,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)
@@ -553,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()
@@ -605,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)
}
}

View File

@@ -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()

View File

@@ -181,7 +181,7 @@ class CreatorCommunityService(
limit: Long,
isAdult: Boolean
): List<GetCommunityPostListResponse> {
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)
}
}

View File

@@ -1,8 +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
@@ -20,7 +23,7 @@ interface CreatorCommunityCommentQueryRepository {
limit: Long
): List<GetCommunityPostCommentListItem>
fun commentReplyCountByCommentId(commentId: Long): Int
fun commentReplyCountByCommentId(commentId: Long, memberId: Long): Int
fun totalCountCommentByPostId(
postId: Long,
@@ -30,6 +33,7 @@ interface CreatorCommunityCommentQueryRepository {
fun getCommunityCommentReplyList(
commentId: Long,
memberId: Long,
timezone: String,
offset: Long,
limit: Long
@@ -65,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(
@@ -93,18 +99,22 @@ class CreatorCommunityCommentQueryRepositoryImpl(
.orderBy(creatorCommunityComment.createdAt.desc())
.fetch()
.map {
it.replyCount = commentReplyCountByCommentId(it.id)
it
it.copy(
nickname = it.nickname.removeDeletedNicknamePrefix(),
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
@@ -118,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(
@@ -135,6 +147,7 @@ class CreatorCommunityCommentQueryRepositoryImpl(
override fun getCommunityCommentReplyList(
commentId: Long,
memberId: Long,
timezone: String,
offset: Long,
limit: Long
@@ -169,10 +182,31 @@ 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)
.orderBy(creatorCommunityComment.createdAt.desc())
.fetch()
.map {
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)
)
}

View File

@@ -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
}
}

View File

@@ -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<GetRecommendChannelResponse> {
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)
}
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
@@ -1098,6 +1120,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,
@@ -1425,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
}

View File

@@ -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) {

View File

@@ -81,6 +81,8 @@ data class Member(
// SNS
var instagramUrl = ""
var fancimmUrl = ""
var xUrl = ""
var youtubeUrl = ""
var websiteUrl = ""
var blogUrl = ""

View File

@@ -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,
@@ -301,6 +303,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,
@@ -520,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
}
}
}
@@ -562,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
@@ -714,6 +729,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
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -28,9 +28,15 @@ class RecommendChannelQueryRepository(
contentType: ContentType
): List<RecommendChannelResponse> {
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<RecommendChannelContentItem> {
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
}

View File

@@ -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<SearchResponseItem> {
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<SearchResponseItem> {
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<SearchResponseItem> {
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)

View File

@@ -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)
}
}

View File

@@ -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 <commit-hash>"
echo " $0 --message \"<commit-message>\""
echo " $0 --message-file <file-path>"
}
# 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: <type>(scope): <description>"
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