diff --git a/.agents/skills/caveman/SKILL.md b/.agents/skills/caveman/SKILL.md new file mode 100644 index 0000000..119af0d --- /dev/null +++ b/.agents/skills/caveman/SKILL.md @@ -0,0 +1,38 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts output tokens 65% (measured) by speaking like caveman + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, + wenyan-lite, wenyan-full, wenyan-ultra. + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +Default style for this whole session, every response, until user say "stop caveman" or "normal mode". Keep terse on long sessions no filler drift. + +Default: **full**. Switch: `/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off`. + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Never drop not/never/no/only/except flip meaning worse than any token saved. Numbers, units exact. + +Never ADD word to sound caveman. Compression only style never grow output. No inserted pronoun or copula to fake broken grammar: "when it not" cost one token more than "when not" and say same thing. Keep correct verb form when correct form cost same "sees" one token, "see" one token, so mangle buy nothing and read worse. Same rule as abbreviations and arrows: if caveman phrasing not shorter than plain phrasing, use plain. + +Tool calls: fire direct. No preamble, plan, or progress note before or between calls. After result: next call direct or final answer never announce next call. Text before call only to clarify, warn security/irreversible, or resolve ambiguity. + +Preserve user's dominant language exactly reply in the language user writes, never switch regardless of example text or multilingual context elsewhere. Compress the style, not the language. Every emitted line in that language openings, pre-tool status lines, all not just final reply. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim unless user explicitly ask for translation. + +'Drop articles' = article languages only. Where small markers carry case/role (particles, postpositions), keep them grammar, not filler; compress politeness/filler instead. + +Answer directly in this style. Skip "caveman mode on", "me caveman think", "Caveman:" prefix or recap redundant with the reply itself. No normal answer plus caveman duplicate. User ask what mode is → say so plainly. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expire check compare ms to seconds. Fix compare in auth.ts:42." diff --git a/.agents/skills/humanize-korean/SKILL.md b/.agents/skills/humanize-korean/SKILL.md new file mode 100644 index 0000000..7105322 --- /dev/null +++ b/.agents/skills/humanize-korean/SKILL.md @@ -0,0 +1,22 @@ +--- +name: humanize-korean +description: > + AI(ChatGPT·Claude·Gemini 등) 특유의 번역투, 기계적 나열, 상투적 문구, 어색한 피동태, 불필요한 과장 표현을 제거하고 + 실제 사람이 작성한 것처럼 담백하고 자연스러운 한국어로 윤문하는 스킬. + PR 리뷰 답변, 커밋 메시지, 기술 문서 요약 시 자연스러운 현업 엔지니어의 어투로 변환한다. +--- + +# Humanize Korean (한글 AI 티 제거 및 자연스러운 윤문) + +AI 특유의 기계적 문체를 걷어내고, 실제 현업 개발자가 직접 작성한 듯한 자연스럽고 담백한 어조로 작성한다. + +## 핵심 윤문 원칙 + +1. **기계적 번역투 및 피동 표현 제거**: + - `~에 대하여`, `~를 통하여`, `~에 있어서`, `~되어지다`, `~를 진행하였습니다` ➔ 간결한 능동형 및 자연스러운 서술어(`~를 다뤘습니다`, `~로 바꿨습니다`, `~했습니다`). +2. **AI 상투적 수식어 및 과장 배제**: + - `완벽하게`, `원천 차단`, `압도적`, `강력한`, `철저하게` 등 인위적인 과장 표현을 삭제하고, 사실(Fact)과 메커니즘 중심 서술. +3. **자연스러운 개발자 톤앤매너**: + - "조치 완료했습니다" 같은 딱딱한 템플릿 나열 대신, 무엇이 문제였고 어떻게 바꿨는지 동료 개발자에게 설명하듯 명료하게 작성. +4. **기술 용어 및 의미 100% 보존**: + - 클래스명, 메서드명, DTO, HTTP 상태코드, 테스트 수치 등 엔지니어링 핵심 앵커는 그대로 유지. \ No newline at end of file diff --git a/.agents/skills/ponytail/SKILL.md b/.agents/skills/ponytail/SKILL.md new file mode 100644 index 0000000..719d7fb --- /dev/null +++ b/.agents/skills/ponytail/SKILL.md @@ -0,0 +1,62 @@ +--- +name: ponytail +description: > + Forces the laziest solution that actually works, simplest, shortest, most + minimal. Channels a senior dev who has seen everything: question whether the + task needs to exist at all (YAGNI), reach for the standard library before + custom code, native platform features before dependencies, one line before + fifty. Supports intensity levels: lite, full (default), ultra. Use on ANY + coding task: writing, adding, refactoring, fixing, reviewing, or designing + code, and choosing libraries or dependencies. Also use whenever the user + says "ponytail", "be lazy", "lazy mode", "simplest solution", "minimal + solution", "yagni", "do less", or "shortest path", or complains about + over-engineering, bloat, boilerplate, or unnecessary dependencies. Do NOT + use for non-coding requests (general knowledge, prose, translation, + summaries, recipes). +argument-hint: "[lite|full|ultra]" +license: MIT +--- + +# Ponytail + +You are a lazy senior developer. Lazy means efficient, not careless. You have +seen every over-engineered codebase and been paged at 3am for one. The best +code is the code never written. + +## Persistence + +ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if +unsure. Off only: "stop ponytail" / "normal mode". Default: **full**. +Switch: `/ponytail lite|full|ultra`. + +## The ladder + +Stop at the first rung that holds: + +1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI) +2. **Already in this codebase?** A helper, util, type, or pattern that already lives here → reuse it. Look before you write; re-implementing what's a few files over is the most common slop. +3. **Stdlib does it?** Use it. +4. **Native platform feature covers it?** `` over a picker lib, CSS over JS, DB constraint over app code. +5. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do. +6. **Can it be one line?** One line. +7. **Only then:** the minimum code that works. + +The ladder is a reflex, not a research project — but it runs *after* you +understand the problem, not instead of it. Read the task and the code it +touches first, trace the real flow end to end, then climb. Two rungs work → +take the higher one and move on. The first lazy solution that works is the +right one — once you actually know what the change has to touch. + +**Bug fix = root cause, not symptom.** A report names a symptom. Before you +edit, grep every caller of the function you're about to touch. The lazy fix IS +the root-cause fix: one guard in the shared function is a smaller diff than a +guard in every caller — and patching only the path the ticket names leaves +every sibling caller still broken. Fix it once, where all callers route through. + +## Rules + +- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes. +- No boilerplate, no scaffolding "for later", later can scaffold for itself. +- Deletion over addition. Boring over clever, clever is what someone decodes at 3am. +- Fewest files possible. Shortest working diff wins — but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug. +- Complex request? Ship the lazy version first, wait for them to ask for more. diff --git a/.github/workflows/gemini-review.yml b/.github/workflows/gemini-review.yml index ff7fdee..ae13394 100644 --- a/.github/workflows/gemini-review.yml +++ b/.github/workflows/gemini-review.yml @@ -1,8 +1,8 @@ name: Gemini PR Code Review on: - pull_request: - types: [opened, synchronize, reopened] + issue_comment: + types: [created] permissions: contents: read @@ -11,7 +11,11 @@ permissions: jobs: review: + if: > + github.event.issue.pull_request && + contains(github.event.comment.body, '/gemini-review') runs-on: ubuntu-latest + steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -22,45 +26,62 @@ jobs: env: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.issue.number }} run: | if [ -z "$GEMINI_API_KEY" ]; then - echo "::error::GEMINI_API_KEY 시크릿이 비어있습니다." + echo "::error::GEMINI_API_KEY secret is empty." exit 1 fi - # 1. PR 제목 및 본문 내용 실시간 동적 추출 - PR_TITLE=$(gh pr view $PR_NUMBER --json title -q '.title') - PR_BODY=$(gh pr view $PR_NUMBER --json body -q '.body' | head -c 2500) + gh pr checkout "$PR_NUMBER" + + PR_TITLE=$(gh pr view "$PR_NUMBER" --json title -q '.title') + PR_BODY=$(gh pr view "$PR_NUMBER" --json body -q '.body' | head -c 2500) + TARGET_BRANCH=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') - # 2. AGENTS.md, .github, docs, frontend 제외하고 오직 backend/src 자바 소스코드만 100% 추출 - git fetch origin $TARGET_BRANCH - PR_DIFF=$(git diff origin/$TARGET_BRANCH...HEAD -- 'backend/src/' | head -c 12000) + git fetch origin "$TARGET_BRANCH" + PR_DIFF=$(git diff "origin/$TARGET_BRANCH...HEAD" -- 'backend/src/' | head -c 12000) if [ -z "$PR_DIFF" ]; then - PR_DIFF="리뷰할 백엔드 비즈니스 소스코드 변경점이 없습니다." + PR_DIFF="No backend/src Java source changes to review." fi - # 3. [잠재적 위협 & 엣지 케이스 배움] 중심의 시니어 멘토링 프롬프트 주입 - PROMPT="당신은 10년 차 이상의 시니어 자바/스프링 아키텍트이자 따뜻하고 정교한 코드 리뷰 멘토입니다. 주니어 개발자(멘티)가 미처 생각하지 못한 [잠재적 보안 위협, 동시성/성능 병목, 장애 위험 요소, 엣지 케이스]를 스스로 발견하고 상황에 맞는 적절한 기술을 주도적으로 판단해 극복할 수 있는 뛰어난 백엔드 개발자로 성장시키는 것이 당신의 최종 목표입니다.\n\n⛔ [엄격한 금지 지침]: 절대로 CI/CD 워크플로우 설정 파일(.github/), 라벨러, AGENTS.md 등에 대해서는 리뷰하거나 언급하지 마세요. 오직 백엔드 애플리케이션 비즈니스 소스코드, 데이터베이스, 보안, 아키텍처에 대해서만 리뷰하세요.\n\n📌 [PR 제목]: $PR_TITLE\n\n📌 [PR 작성 목적 및 개요]:\n$PR_BODY\n\n---\n\n### 📋 멘토링 코드 리뷰 구조:\n1. 🔍 **[PR 구현 목적 ↔ 실제 코드 대조 분석]**: [PR 제목]과 [PR 작성 목적]에 작성자가 명시한 비즈니스 기능이 실제 자바 소스코드에 정확히 부합하게 구현되었는지 1:1 대조 요약해 주세요.\n2. 🏛️ **[잠재적 위협 & 아키텍처 딥다이브 (Security & System Risks)]**: 주니어 개발자가 미처 인지하지 못했을 잠재적 보안 위협(세션/인증 누수, 취약점), 동시성 경합(Race Condition), 데이터 무결성 파손, 시스템 장애 위험 요소를 정밀하게 짚어주세요. 각 위험 요소를 극복하기 위한 대안 기술/패턴 2~3가지, 물리적 원리, 장단점, Trade-off 및 서비스/아키텍처 레벨 극복 방안을 서술해 주세요.\n3. 💻 **[소스코드 품질 & 엣지 케이스 리뷰 (Code Quality & Edge Cases)]**: 실제 자바 소스코드에서 놓치기 쉬운 예외 처리 누락, 엣지 케이스(Edge Case), 쿼리 N+1/성능 병목, 객체지향 설계(SOLID) 관점의 개선점을 코드 레벨에서 정밀 지적해 주세요.\n4. 🛠️ **[개선된 코드 예시 (Before vs After)]**: 멘티가 잠재적 위협을 극복하고 상황에 맞는 기술을 배울 수 있도록 가독성이 뛰어난 자바(Java / Spring Boot) diff 또는 소스코드 블록 예시를 제공해 주세요.\n\n---\n[실제 백엔드 Java 소스코드 Diff]:\n$PR_DIFF" - + PROMPT=$(cat <()`로 방어적 복사(Defensive Copy)를 반드시 수행하여, 외부에서의 원본 데이터 변형 및 가변성(Mutability) 오염을 물리적으로 차단해야 한다. +23. 문자열 리터럴 예외 처리 금지 수칙: `new IllegalArgumentException("INVALID_CREDENTIALS")`나 `if ("DUPLICATE_EMAIL".equals(e.getMessage()))`와 같이 문자열 리터럴을 직접 비교하거나 생성해 던지는 예외 처리를 엄격히 금지한다. 예외는 반드시 `ErrorCode` Enum과 정적 타입 기반의 비즈니스 커스텀 예외(`CustomException`)로 일원화해야 한다. +24. 계층 분리(Layered Architecture) 및 웹 결합 차단 수칙: + ① 서비스 레이어(Service) 내부에서 `HttpSession`, `SecurityContextHolder`, `changeSessionId()` 등 서블릿/웹 세션 조작 코드를 직접 호출하는 행위(웹-서비스 강결합)를 엄격히 금지한다. (웹 세션 처리는 컨트롤러 또는 시큐리티 레이어가 전담한다) + ② 컨트롤러가 `Repository`를 직접 주입받아 호출하거나, DTO 변환 없이 `Entity`를 클라이언트에 직접 반환하는 계층 스킵(Layer Skip) 행위를 엄격히 금지한다. diff --git a/README.md b/README.md index e649282..19ebdc9 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,170 @@ sequenceDiagram --- -## 4. 프로젝트 물리 디렉토리 구조 (Project Structure) +## 4. 게시판(Post) 도메인 설계 & 핵심 기술적 의사결정 (Board Architecture & Decisions) + +게시판은 Snowthing에서 가장 자주 읽히는 도메인이다. 그래서 단순 CRUD로만 만들지 않고, 목록 조회 비용, 익명 글 권한, 삭제 정책, 이미지 첨부 상태까지 같이 맞춰서 설계했다. + +### 1) 게시글 도메인 구조 + +게시글의 중심 엔티티는 `Post`다. `Post` 하나가 제목, 본문, 작성 IP, 조회수, 댓글 수, 추천/비추천 수, 삭제 여부, 상태값을 가진다. + +주변 엔티티는 역할을 나눠서 붙였다. + +- `PostCategory`: 자유게시판, 익명 게시판, 장비 Q&A, 리조트 맛집 같은 게시판 종류를 담당한다. +- `PostImage`: 게시글 첨부 이미지를 1:N으로 저장한다. 이미지는 여러 장이 가능하고 `sortOrder`로 표시 순서를 가진다. +- `PostReaction`: 추천/비추천 이력을 저장한다. 로그인 사용자의 추천/비추천과 비로그인 사용자의 익명 추천/비추천을 둘 다 처리하기 위해 회원 ID 또는 `anonymous_voter_id` 쿠키 값을 기준으로 구분한다. +- `Comment`: 게시글 하위 댓글/대댓글을 담당한다. +- `Member`: 로그인 회원이 작성한 게시글의 작성자 정보다. + +외부 API에서는 DB PK인 `post_id`를 그대로 노출하지 않고 `publicId`를 사용한다. 내부 PK는 조인과 인덱스에 쓰고, 외부 식별자는 URL과 API 응답에 쓰는 식으로 책임을 나눴다. 이렇게 해두면 사용자가 URL 숫자를 바꿔가며 내부 데이터 개수를 추측하는 문제도 줄일 수 있다. + +### 2) 회원과 게시글의 연관관계 + +회원과 게시글은 `Member 1 : N Post` 관계다. 한 명의 회원은 여러 글을 쓸 수 있고, 하나의 회원 글은 한 명의 작성자를 가진다. + +코드에서는 `Post.member`를 `@ManyToOne(fetch = FetchType.LAZY)`로 둔다. 게시글 목록을 볼 때마다 회원 엔티티 전체가 항상 필요한 것은 아니기 때문에 기본은 LAZY로 둔 것이다. 필요한 화면에서는 fetch join이나 DTO projection으로 필요한 작성자 정보만 가져온다. + +다만 `member_id`는 nullable이다. 이유는 익명 게시판 때문이다. + +- 로그인 회원 글: `member_id`가 있다. +- 로그인 회원이 익명으로 쓴 글: `member_id`를 남길 수 있고, 화면에서는 익명으로 마스킹한다. +- 비로그인 익명 글: `member_id`가 없다. 대신 작성 IP와 익명 비밀번호 해시로 수정/삭제 권한을 검증한다. + +이 구조의 대가는 권한 검증이 조금 복잡해진다는 점이다. 그래서 수정/삭제 시 단순히 `member_id`만 보는 것이 아니라, 회원 글인지 익명 글인지 먼저 나눈 뒤 각각 다른 검증 로직을 탄다. + +### 3) 게시글 상태와 유형 + +게시글 상태는 `PostStatus`로 관리한다. + +- `NORMAL`: 정상 글이다. 목록과 상세 조회 대상이다. +- `DELETED`: 작성자 또는 관리자에 의해 삭제된 글이다. 현재는 Soft Delete 결과 상태로 사용한다. +- `BLOCKED`: 관리자가 부적절한 글을 차단한 상태다. +- `HIDDEN`: 비공개나 숨김 처리가 필요한 상태를 위해 분리해둔 값이다. +- `DRAFT`: 임시 저장을 고려한 상태값이다. + +게시글 유형은 상태가 아니라 카테고리다. `PostCategory.code`로 구분한다. + +- `FREE`: 자유게시판 +- `ANONYMOUS`: 익명 게시판 +- `QNA`: 장비 Q&A +- `FOOD`: 리조트 맛집 + +상태와 유형을 섞지 않은 이유는 역할이 다르기 때문이다. `NORMAL`, `DELETED`는 글의 현재 처리 상태이고, `FREE`, `ANONYMOUS`는 글이 속한 게시판이다. 이 둘을 같은 enum으로 합치면 “익명 게시판에 있는 삭제된 글” 같은 상태를 표현하기가 지저분해진다. + +### 4) 목록/상세 응답을 분리한 이유 + +목록 응답과 상세 응답은 일부러 나눴다. + +목록에서는 사용자가 글을 고르는 데 필요한 정보만 있으면 된다. 그래서 `PostListResponse`에는 `publicId`, `categoryName`, `title`, `writerNickname`, `thumbnailImageUrl`, `hasImage`, `viewCount`, `commentCount`, `likeCount`, `status`, `createdAt` 같은 목록용 필드만 둔다. + +상세에서는 본문과 이미지 전체가 필요하다. 그래서 `PostDetailResponse`에는 `content`, `images`, `writer`, `isAnonymous` 같은 상세 화면용 필드를 포함한다. + +이렇게 나눈 이유는 간단하다. 목록 20개를 보여주는데 본문 전체와 이미지 배열까지 같이 가져오면, 사용자가 클릭하지도 않은 글의 데이터를 매번 읽고 내려주게 된다. 게시글 수가 늘수록 DB I/O와 응답 크기가 같이 커진다. + +그래서 목록은 가볍게, 상세는 필요한 만큼만 가져오게 했다. 최근 이미지 뱃지도 같은 흐름이다. 목록에서 이미지가 있는지 표시하려고 매번 `post_image`를 조인하지 않고, `Post.hasImage`에 첨부 여부를 역정규화해둔다. 대신 작성/수정 시 `Post.replaceImages()`에서 이미지 목록과 `hasImage`를 같이 맞춰 데이터가 틀어지지 않게 했다. + +### 5) 페이지네이션과 정렬 기준 + +현재 게시글 목록은 웹 화면 기준으로 offset pagination을 지원한다. `page`, `size`를 받아서 원하는 페이지를 조회하는 방식이다. + +정렬 기준은 `createdAt DESC`, `id DESC`다. + +`createdAt`만 쓰지 않고 `id`를 같이 둔 이유는 같은 시각에 여러 글이 들어올 수 있기 때문이다. 정렬 기준이 하나뿐이면 같은 `createdAt` 값을 가진 글들의 순서가 DB 실행 계획에 따라 흔들릴 수 있다. 그래서 최신 작성일을 먼저 보고, 같은 시간이면 더 나중에 저장된 `id`를 한 번 더 기준으로 둔다. + +offset 방식은 웹 페이지 번호 UI와 잘 맞지만, 페이지가 너무 깊어지면 DB가 앞쪽 데이터를 많이 건너뛰어야 한다. 그래서 `MAX_OFFSET_PAGE` 제한을 둔다. + +모바일 무한스크롤이나 깊은 페이지 탐색은 cursor pagination이 더 맞다. 현재는 `cursor` 파라미터가 있으면 cursor 기반 조회로 분리되어 있고, 다음 커서를 응답에 내려주는 구조를 준비해뒀다. + +### 6) 수정 권한 검증 방식 + +게시글 수정은 작성자 본인만 가능하게 잡았다. 관리자는 타인의 글을 수정하지 않는다. 관리자가 본문을 직접 바꾸면 원 작성자의 의도와 책임 소재가 섞이기 때문이다. 관리자는 삭제나 차단 같은 운영 조치만 하는 쪽이 더 명확하다. + +검증 흐름은 글 유형에 따라 나뉜다. + +- 회원 글: 로그인한 사용자의 `publicId`와 게시글 작성자의 `publicId`가 같아야 한다. +- 로그인 회원이 작성한 익명 글: 화면에는 익명으로 보이지만 서버에는 작성자 회원 정보가 있으므로, 같은 회원이면 비밀번호 없이 수정할 수 있다. +- 비로그인 익명 글: 작성 당시 입력한 `anonymousPassword`를 BCrypt로 비교한다. + +이 검증은 프론트에서 버튼을 숨기는 것만으로 끝내지 않는다. 프론트 가드는 UX용이고, 실제 보안은 `PostService.validateEditPermission()`에서 한 번 더 막는다. URL을 직접 입력하거나 API를 직접 호출해도 서버에서 403으로 막혀야 하기 때문이다. + +### 7) Hard Delete와 Soft Delete 비교 + +Hard Delete는 DB row를 실제로 삭제하는 방식이다. + +```sql +DELETE FROM post WHERE post_id = ? +``` + +장점은 테이블에 데이터가 남지 않아 구조가 단순하다는 점이다. 하지만 한 번 지우면 복구가 어렵고, 댓글/추천/이미지 같은 연관 데이터도 같이 어떻게 처리할지 결정해야 한다. 신고나 분쟁이 생겼을 때 “무슨 글이 있었는지” 확인하기도 어렵다. + +Soft Delete는 row를 지우지 않고 삭제 표시만 남기는 방식이다. + +```sql +UPDATE post +SET is_deleted = true, + status = 'DELETED', + deleted_at = NOW() +WHERE post_id = ? +``` + +장점은 이력이 남는다는 점이다. 대신 모든 조회에서 삭제된 글을 제외해야 하고, unique 제약이나 인덱스 설계도 삭제 데이터를 고려해야 한다. + +### 8) 현재 삭제 방식을 선택한 이유 + +현재는 Soft Delete를 선택했다. 이유는 게시판 서비스에서 삭제가 단순한 저장공간 정리 문제가 아니기 때문이다. + +첫 번째 이유는 운영 이력이다. 사용자가 문제 있는 글을 올린 뒤 바로 삭제해버리면, 관리자 입장에서는 어떤 일이 있었는지 확인하기 어렵다. Soft Delete는 글을 일반 사용자에게는 숨기면서도 서버에는 이력을 남길 수 있다. + +두 번째 이유는 댓글과 추천 같은 주변 데이터 때문이다. 게시글을 Hard Delete하면 댓글, 이미지, 추천 데이터도 같이 삭제할지 남길지 결정해야 한다. 지금 단계에서는 실제 row를 제거하기보다 `isDeleted`, `status`, `deletedAt`으로 상태를 바꾸는 쪽이 더 안전하다. + +세 번째 이유는 UX다. 목록에서 방금 보이던 글이 삭제되었을 때 완전히 사라지는 것보다, 필요하면 “삭제된 게시글입니다”처럼 상태를 표현하는 쪽이 흐름을 설명하기 쉽다. + +현재 구현은 `@SQLDelete`와 `@SQLRestriction("is_deleted = false")`를 사용한다. 그래서 삭제 호출은 update로 바뀌고, 기본 조회에서는 삭제된 글이 빠진다. + +### 9) 실행 쿼리 확인 결과 + +상세 조회는 `PostRepository.findWithMemberAndCategoryByPublicId()`를 사용한다. + +```java +@Query("SELECT p FROM Post p LEFT JOIN FETCH p.member JOIN FETCH p.category WHERE p.publicId = :publicId") +Optional findWithMemberAndCategoryByPublicId(@Param("publicId") String publicId); +``` + +`member`는 비로그인 익명 글에서 없을 수 있으므로 `LEFT JOIN FETCH`를 쓴다. `category`는 게시글에 반드시 있어야 하므로 `JOIN FETCH`를 쓴다. + +이 쿼리로 상세 조회 시 게시글, 작성자, 카테고리를 한 번에 가져온다. 그렇지 않으면 서비스나 DTO 변환 과정에서 `post.getMember()`, `post.getCategory()`를 접근할 때 추가 select가 발생할 수 있다. + +목록 조회도 작성자와 카테고리를 같이 보여줘야 하므로 fetch join을 적용한다. + +```java +@Query("SELECT p FROM Post p LEFT JOIN FETCH p.member JOIN FETCH p.category WHERE p.category.code = :categoryCode") +Page findByCategoryCodeWithMemberAndCategory(@Param("categoryCode") String categoryCode, Pageable pageable); +``` + +이미지는 목록 조회에서 조인하지 않는다. 목록에는 전체 이미지 배열이 필요하지 않고, 이미지 존재 여부만 필요하다. 그래서 `hasImage`를 사용한다. 이 선택은 읽기 성능을 얻는 대신, 작성/수정 시 `hasImage`를 반드시 같이 갱신해야 한다는 대가가 있다. 그 부분은 `Post.replaceImages()`로 묶어서 처리한다. + +### 10) 주요 예외 응답 + +게시글 도메인에서 주로 내려가는 예외는 다음과 같다. + +- `POST_NOT_FOUND (404)`: 존재하지 않는 글, 삭제된 글, 일반 사용자가 볼 수 없는 글에 접근한 경우 +- `POST_CATEGORY_NOT_FOUND (404)`: 요청한 `categoryCode`에 해당하는 게시판이 없는 경우 +- `ACCESS_DENIED (403)`: 작성자가 아닌 사용자가 수정/삭제를 시도한 경우 +- `INVALID_ANON_PASSWORD (403)`: 익명 글 수정/삭제 비밀번호가 틀린 경우 +- `INVALID_CREDENTIALS (401)`: 로그인이 필요한 일반 게시글 작성에서 인증 정보가 없는 경우 +- `INVALID_INPUT (400)`: 익명 글 작성/추천·비추천 등에서 필요한 값이 빠진 경우 +- `INVALID_PAGE_SIZE (400)`: 허용 범위를 벗어난 `size` 요청 +- `INVALID_PAGE_LIMIT (400)`: offset page 제한을 넘긴 요청 + +### 11) CSRF +게시글 생성, 수정, 삭제 같은 CUD 요청은 CSRF 공격 표적이 되기 쉽다. +Spring Security의 `CookieCsrfTokenRepository.withHttpOnlyFalse()`를 적용했다. +이 방식은 Double Submit Cookie 패턴으로 동작한다. 백엔드가 `XSRF-TOKEN` 쿠키를 발급하면, 프론트엔드가 자원 변경 요청(POST, PUT, DELETE)을 보낼 때 쿠키 값을 읽어 `X-XSRF-TOKEN` HTTP 헤더에 담아서 보낸다. 서버의 `CsrfFilter`는 쿠키의 토큰 값과 헤더의 토큰 값이 일치하는지 비교하여 검증한다. +외부 해킹 사이트는 동일 출처 정책(SOP) 제약으로 인해 사용자의 `XSRF-TOKEN` 쿠키를 자바스크립트로 읽을 수 없어 `X-XSRF-TOKEN` 헤더를 생성하지 못하므로 위조된 요청은 403 Forbidden으로 차단된다. +--- + +## 5. 프로젝트 물리 디렉토리 구조 (Project Structure) ``` snowthing/ (프로젝트 최상위 루트) @@ -133,7 +296,7 @@ snowthing/ (프로젝트 최상위 루트) --- -## 5. 실행 및 테스트 (Build & Run) +## 6. 실행 및 테스트 (Build & Run) ### Backend (Spring Boot) ```bash diff --git a/backend/build.gradle b/backend/build.gradle index 94382d9..7613d43 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -34,14 +34,20 @@ dependencies { // JPA & DB implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' + testRuntimeOnly 'com.h2database:h2' // Security & Session Redis implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.session:spring-session-data-redis' + // Querydsl Jakarta (Spring Boot 3.x) + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' @@ -57,6 +63,7 @@ dependencies { tasks.named('test') { useJUnitPlatform() + systemProperty 'spring.profiles.active', 'test' } spotless { diff --git a/backend/src/main/java/com/ikae/snowthing/SnowthingApplication.java b/backend/src/main/java/com/ikae/snowthing/SnowthingApplication.java index bec0ea7..a755371 100644 --- a/backend/src/main/java/com/ikae/snowthing/SnowthingApplication.java +++ b/backend/src/main/java/com/ikae/snowthing/SnowthingApplication.java @@ -3,7 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @EnableJpaAuditing @SpringBootApplication public class SnowthingApplication { @@ -11,5 +13,4 @@ public class SnowthingApplication { public static void main(String[] args) { SpringApplication.run(SnowthingApplication.class, args); } - } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/auth/controller/AuthController.java b/backend/src/main/java/com/ikae/snowthing/domain/auth/controller/AuthController.java index df6a4f8..076ad97 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/auth/controller/AuthController.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/auth/controller/AuthController.java @@ -1,13 +1,12 @@ package com.ikae.snowthing.domain.auth.controller; -import com.ikae.snowthing.domain.auth.dto.MemberLoginRequest; -import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; -import com.ikae.snowthing.domain.auth.service.AuthService; +import java.time.Duration; + import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; + import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -20,13 +19,20 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.ikae.snowthing.domain.auth.dto.MemberLoginRequest; +import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; +import com.ikae.snowthing.domain.auth.service.AuthService; + +import lombok.RequiredArgsConstructor; + @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor public class AuthController { - private static final int REMEMBER_ME_TIMEOUT_SECONDS = 30 * 24 * 60 * 60; // 30일 - private static final int DEFAULT_SESSION_TIMEOUT_SECONDS = 60 * 60; // 1시간 + private static final int REMEMBER_ME_TIMEOUT_SECONDS = (int) Duration.ofDays(30).toSeconds(); + private static final int DEFAULT_SESSION_TIMEOUT_SECONDS = + (int) Duration.ofHours(1).toSeconds(); private final AuthenticationManager authenticationManager; private final SecurityContextRepository securityContextRepository; @@ -36,13 +42,11 @@ public class AuthController { public ResponseEntity login( @Valid @RequestBody MemberLoginRequest loginRequest, HttpServletRequest httpRequest, - HttpServletResponse httpResponse - ) { + HttpServletResponse httpResponse) { // 1. Spring Security 표준 AuthenticationManager 위임 인증 처리 - Authentication authenticationToken = new UsernamePasswordAuthenticationToken( - loginRequest.getEmail(), - loginRequest.getPassword() - ); + Authentication authenticationToken = + new UsernamePasswordAuthenticationToken( + loginRequest.getEmail(), loginRequest.getPassword()); Authentication authentication = authenticationManager.authenticate(authenticationToken); // 2. SecurityContext 생성 및 ContextHolder 설정 @@ -63,9 +67,7 @@ public ResponseEntity login( return ResponseEntity.ok(response); } - /** - * Remember-Me 체크 여부에 따른 세션 타임아웃 계산 (초 단위) - */ + /** Remember-Me 체크 여부에 따른 세션 타임아웃 계산 (초 단위) */ private int calculateSessionTimeoutSeconds(boolean rememberMe) { return rememberMe ? REMEMBER_ME_TIMEOUT_SECONDS : DEFAULT_SESSION_TIMEOUT_SECONDS; } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java index c58a9df..545819d 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginRequest.java @@ -1,6 +1,7 @@ package com.ikae.snowthing.domain.auth.dto; import jakarta.validation.constraints.NotBlank; + import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -18,9 +19,9 @@ public class MemberLoginRequest { private boolean rememberMe = false; // 로그인 상태 유지 (30일 지속 세션 쿠키) @Builder - public MemberLoginRequest(String email, String password, Boolean rememberMe) { + public MemberLoginRequest(String email, String password, boolean rememberMe) { this.email = email; this.password = password; - this.rememberMe = rememberMe != null ? rememberMe : false; + this.rememberMe = rememberMe; } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginResponse.java index 843b7fd..78a6e54 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginResponse.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginResponse.java @@ -1,12 +1,13 @@ package com.ikae.snowthing.domain.auth.dto; +import java.util.List; + import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.member.entity.Role; + import lombok.Builder; import lombok.Getter; -import java.util.List; - @Getter public class MemberLoginResponse { @@ -18,17 +19,25 @@ public class MemberLoginResponse { private final List ridingStyleNames; @Builder - public MemberLoginResponse(String publicId, String email, String nickname, Role role, List resortNames, List ridingStyleNames) { + public MemberLoginResponse( + String publicId, + String email, + String nickname, + Role role, + List resortNames, + List ridingStyleNames) { this.publicId = publicId; this.email = email; this.nickname = nickname; this.role = role; // 방어적 복사 (Defensive Copy) 적용으로 100% 불변 리스트 보장 및 외부 변형 차단 this.resortNames = resortNames != null ? List.copyOf(resortNames) : List.of(); - this.ridingStyleNames = ridingStyleNames != null ? List.copyOf(ridingStyleNames) : List.of(); + this.ridingStyleNames = + ridingStyleNames != null ? List.copyOf(ridingStyleNames) : List.of(); } - public static MemberLoginResponse from(Member member, List resortNames, List ridingStyleNames) { + public static MemberLoginResponse from( + Member member, List resortNames, List ridingStyleNames) { return MemberLoginResponse.builder() .publicId(member.getPublicId()) .email(member.getEmail()) diff --git a/backend/src/main/java/com/ikae/snowthing/domain/auth/service/AuthService.java b/backend/src/main/java/com/ikae/snowthing/domain/auth/service/AuthService.java index 78769a1..768ced6 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/auth/service/AuthService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/auth/service/AuthService.java @@ -1,18 +1,14 @@ package com.ikae.snowthing.domain.auth.service; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; -import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.member.repository.MemberRepository; -import com.ikae.snowthing.domain.member.repository.MemberResortRepository; -import com.ikae.snowthing.domain.member.repository.MemberRidingStyleRepository; import com.ikae.snowthing.global.error.ErrorCode; import com.ikae.snowthing.global.exception.CustomAuthException; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.util.List; +import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor @@ -20,39 +16,11 @@ public class AuthService { private final MemberRepository memberRepository; - private final MemberResortRepository memberResortRepository; - private final MemberRidingStyleRepository memberRidingStyleRepository; - private final PasswordEncoder passwordEncoder; - /** - * 회원 자격 증명 (이메일 및 비밀번호 검증) - */ - public Member authenticate(String email, String rawPassword) { - Member member = memberRepository.findByEmail(email) - .orElseThrow(() -> new CustomAuthException(ErrorCode.INVALID_CREDENTIALS)); - - if (!passwordEncoder.matches(rawPassword, member.getPassword())) { - throw new CustomAuthException(ErrorCode.INVALID_CREDENTIALS); - } - - return member; - } - - /** - * 프로필 정보 및 선호 스키장/라이딩 성향 Fetch Join 조회 (방어적 복사 적용) - */ + /** 프로필 정보 및 선호 스키장/라이딩 성향 QueryDSL DTO 프로젝션 조회 (방어적 복사 적용) */ public MemberLoginResponse getMemberProfileByEmail(String email) { - Member member = memberRepository.findByEmail(email) + return memberRepository + .findProfileByEmail(email) .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); - - List resortNames = memberResortRepository.findAllByMemberIdWithResort(member.getId()).stream() - .map(mr -> mr.getResort().getName()) - .toList(); - - List ridingStyleNames = memberRidingStyleRepository.findAllByMemberIdWithRidingStyle(member.getId()).stream() - .map(mrs -> mrs.getRidingStyle().getStyleName()) - .toList(); - - return MemberLoginResponse.from(member, resortNames, ridingStyleNames); } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..c8af434 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java @@ -0,0 +1,57 @@ +package com.ikae.snowthing.domain.comment.controller; + +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import com.ikae.snowthing.domain.comment.dto.*; +import com.ikae.snowthing.domain.comment.service.CommentService; +import com.ikae.snowthing.global.security.CustomUserDetails; +import com.ikae.snowthing.global.web.ClientIpResolver; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + private final ClientIpResolver clientIpResolver; + + @PostMapping("/posts/{publicId}/comments") + public ResponseEntity createComment( + @PathVariable String publicId, + @Valid @RequestBody CommentCreateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletRequest httpRequest) { + String clientIp = clientIpResolver.resolve(httpRequest); + CommentResponse response = + commentService.createComment(publicId, request, userDetails, clientIp); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/posts/{publicId}/comments") + public ResponseEntity getCommentsByPost( + @PathVariable String publicId) { + PostCommentListResponse response = commentService.getCommentsByPost(publicId); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/comments/{commentId}") + public ResponseEntity> deleteComment( + @PathVariable Long commentId, + @RequestBody(required = false) CommentDeleteRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String password = (request != null) ? request.anonymousPassword() : null; + commentService.deleteComment(commentId, password, userDetails); + return ResponseEntity.ok( + Map.of("message", "댓글이 정상적으로 삭제(Soft Delete) 처리되었습니다.", "commentId", commentId)); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentCreateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentCreateRequest.java new file mode 100644 index 0000000..fdb87e4 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentCreateRequest.java @@ -0,0 +1,15 @@ +package com.ikae.snowthing.domain.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import lombok.Builder; + +@Builder +public record CommentCreateRequest( + Long parentId, + @NotBlank(message = "댓글 내용은 필수 입력값입니다.") + @Size(max = 1000, message = "댓글은 최대 1000자까지 입력 가능합니다.") + String content, + boolean isAnonymous, + String anonymousPassword) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentDeleteRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentDeleteRequest.java new file mode 100644 index 0000000..602d271 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentDeleteRequest.java @@ -0,0 +1,3 @@ +package com.ikae.snowthing.domain.comment.dto; + +public record CommentDeleteRequest(String anonymousPassword) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java new file mode 100644 index 0000000..3483a5c --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java @@ -0,0 +1,35 @@ +package com.ikae.snowthing.domain.comment.dto; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.global.util.WriterDisplayFormatter; + +public record CommentResponse( + Long commentId, + Long parentId, + String writerName, + String content, + boolean isDeleted, + LocalDateTime createdAt, + List children) { + public static CommentResponse from(Comment comment) { + String writerName = + WriterDisplayFormatter.format( + comment.isAnonymous(), comment.getMember(), comment.getWriterIp()); + + String displayContent = comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(); + Long parentIdValue = comment.getParent() != null ? comment.getParent().getId() : null; + + return new CommentResponse( + comment.getId(), + parentIdValue, + writerName, + displayContent, + comment.isDeleted(), + comment.getCreatedAt(), + new ArrayList<>()); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/PostCommentListResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/PostCommentListResponse.java new file mode 100644 index 0000000..fac7f52 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/PostCommentListResponse.java @@ -0,0 +1,17 @@ +package com.ikae.snowthing.domain.comment.dto; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record PostCommentListResponse( + String publicId, int totalCommentCount, List comments) { + public PostCommentListResponse { + if (comments == null) { + comments = List.of(); + } else { + comments = List.copyOf(comments); + } + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java new file mode 100644 index 0000000..57dc1a5 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java @@ -0,0 +1,83 @@ +package com.ikae.snowthing.domain.comment.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.*; + +import org.hibernate.annotations.SQLDelete; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.global.common.BaseTimeEntity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "comment") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE comment SET is_deleted = true, deleted_at = NOW() WHERE comment_id = ?") +public class Comment extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parent; + + @Column(nullable = false, length = 1000) + private String content; + + @Column(name = "writer_ip", nullable = false, length = 45) + private String writerIp; + + @Column(name = "is_anonymous", nullable = false) + private boolean isAnonymous; + + @Column(name = "anonymous_password") + private String anonymousPassword; + + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + public Comment( + Post post, + Member member, + Comment parent, + String content, + String writerIp, + boolean isAnonymous, + String anonymousPassword) { + this.post = post; + this.member = member; + this.parent = parent; + this.content = content; + this.writerIp = writerIp; + this.isAnonymous = isAnonymous; + this.anonymousPassword = anonymousPassword; + this.isDeleted = false; + } + + public void softDelete() { + this.isDeleted = true; + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..1b2d566 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/repository/CommentRepository.java @@ -0,0 +1,16 @@ +package com.ikae.snowthing.domain.comment.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ikae.snowthing.domain.comment.entity.Comment; + +public interface CommentRepository extends JpaRepository { + + @Query( + "SELECT c FROM Comment c LEFT JOIN FETCH c.member WHERE c.post.id = :postId ORDER BY c.createdAt ASC, c.id ASC") + List findByPostIdWithMember(@Param("postId") Long postId); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java new file mode 100644 index 0000000..b923f20 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java @@ -0,0 +1,210 @@ +package com.ikae.snowthing.domain.comment.service; + +import java.util.*; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import com.ikae.snowthing.domain.comment.dto.*; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final TransactionTemplate transactionTemplate; + + @Transactional + public CommentResponse createComment( + String postPublicId, + CommentCreateRequest request, + CustomUserDetails userDetails, + String clientIp) { + Member member = null; + String encodedPassword = null; + + if (request.isAnonymous()) { + if (userDetails != null) { + member = memberRepository.findByPublicId(userDetails.getPublicId()).orElse(null); + } + if (member == null + && (request.anonymousPassword() == null + || request.anonymousPassword().isBlank())) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + if (request.anonymousPassword() != null && !request.anonymousPassword().isBlank()) { + encodedPassword = passwordEncoder.encode(request.anonymousPassword()); + } + } else { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.INVALID_CREDENTIALS); + } + member = + memberRepository + .findByPublicId(userDetails.getPublicId()) + .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); + } + + final Member finalMember = member; + final String finalEncodedPassword = encodedPassword; + + return transactionTemplate.execute( + status -> { + Post post = + postRepository + .findByPublicId(postPublicId) + .orElseThrow( + () -> + new CustomAuthException( + ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted() || post.getStatus() != PostStatus.NORMAL) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + Comment parent = null; + if (request.parentId() != null) { + parent = + commentRepository + .findById(request.parentId()) + .orElseThrow( + () -> + new CustomAuthException( + ErrorCode + .PARENT_COMMENT_NOT_FOUND)); + + if (!parent.getPost().getId().equals(post.getId())) { + throw new CustomAuthException(ErrorCode.INVALID_COMMENT_PARENT); + } + } + + Comment comment = + Comment.builder() + .post(post) + .member(finalMember) + .parent(parent) + .content(request.content()) + .writerIp(clientIp != null ? clientIp : "127.0.0.1") + .isAnonymous(request.isAnonymous()) + .anonymousPassword(finalEncodedPassword) + .build(); + + Comment savedComment = commentRepository.save(comment); + postRepository.increaseCommentCount(post.getId()); + + return CommentResponse.from(savedComment); + }); + } + + public PostCommentListResponse getCommentsByPost(String postPublicId) { + Post post = + postRepository + .findByPublicId(postPublicId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted() || post.getStatus() != PostStatus.NORMAL) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + List comments = commentRepository.findByPostIdWithMember(post.getId()); + + Map map = new LinkedHashMap<>(); + for (Comment comment : comments) { + map.put(comment.getId(), CommentResponse.from(comment)); + } + + List rootComments = new ArrayList<>(); + for (CommentResponse dto : map.values()) { + if (dto.parentId() == null) { + rootComments.add(dto); + } else { + CommentResponse parentDto = map.get(dto.parentId()); + if (parentDto != null) { + parentDto.children().add(dto); + } + } + } + + return PostCommentListResponse.builder() + .publicId(postPublicId) + .totalCommentCount(post.getCommentCount()) + .comments(rootComments) + .build(); + } + + @Transactional + public void deleteComment( + Long commentId, String anonymousPassword, CustomUserDetails userDetails) { + Comment comment = + commentRepository + .findById(commentId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); + + if (comment.isDeleted()) { + throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); + } + + validateDeletePermission(comment, anonymousPassword, userDetails); + + comment.softDelete(); + postRepository.decreaseCommentCount(comment.getPost().getId()); + } + + private void validateDeletePermission( + Comment comment, String anonymousPassword, CustomUserDetails userDetails) { + boolean isAdmin = + userDetails != null + && userDetails.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + if (isAdmin) { + return; + } + + if (comment.isAnonymous()) { + if (userDetails != null + && comment.getMember() != null + && comment.getMember().getPublicId().equals(userDetails.getPublicId())) { + return; + } + + if (anonymousPassword == null + || !passwordEncoder.matches( + anonymousPassword, comment.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); + } + return; + } + + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + boolean isWriter = + comment.getMember() != null + && comment.getMember().getPublicId().equals(userDetails.getPublicId()); + + if (!isWriter) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MasterDataController.java b/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MasterDataController.java index a6cc5b9..35805ac 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MasterDataController.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MasterDataController.java @@ -1,15 +1,18 @@ package com.ikae.snowthing.domain.member.controller; -import com.ikae.snowthing.domain.member.dto.ResortResponse; -import com.ikae.snowthing.domain.member.dto.RidingStyleResponse; -import com.ikae.snowthing.domain.member.service.MasterDataService; import java.util.List; -import lombok.RequiredArgsConstructor; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.ikae.snowthing.domain.member.dto.ResortResponse; +import com.ikae.snowthing.domain.member.dto.RidingStyleResponse; +import com.ikae.snowthing.domain.member.service.MasterDataService; + +import lombok.RequiredArgsConstructor; + @RestController @RequestMapping("/api/v1/master") @RequiredArgsConstructor diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MemberController.java b/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MemberController.java index 68ccd7a..ad5c378 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MemberController.java @@ -1,5 +1,12 @@ package com.ikae.snowthing.domain.member.controller; +import jakarta.validation.Valid; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; import com.ikae.snowthing.domain.auth.service.AuthService; import com.ikae.snowthing.domain.member.dto.MemberProfileUpdateRequest; @@ -7,12 +14,8 @@ import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse; import com.ikae.snowthing.domain.member.service.MemberService; import com.ikae.snowthing.global.security.CustomUserDetails; -import jakarta.validation.Valid; + import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/members") @@ -23,22 +26,24 @@ public class MemberController { private final AuthService authService; @PostMapping - public ResponseEntity signUp(@Valid @RequestBody MemberSignUpRequest request) { + public ResponseEntity signUp( + @Valid @RequestBody MemberSignUpRequest request) { MemberSignUpResponse response = memberService.signUp(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @GetMapping("/me") - public ResponseEntity getMyProfile(@AuthenticationPrincipal CustomUserDetails userDetails) { - MemberLoginResponse profile = authService.getMemberProfileByEmail(userDetails.getUsername()); + public ResponseEntity getMyProfile( + @AuthenticationPrincipal CustomUserDetails userDetails) { + MemberLoginResponse profile = + authService.getMemberProfileByEmail(userDetails.getUsername()); return ResponseEntity.ok(profile); } @PutMapping("/me") public ResponseEntity updateMyProfile( @AuthenticationPrincipal CustomUserDetails userDetails, - @Valid @RequestBody MemberProfileUpdateRequest request - ) { + @Valid @RequestBody MemberProfileUpdateRequest request) { String email = userDetails.getUsername(); memberService.updateMyProfile(email, request); MemberLoginResponse updatedProfile = authService.getMemberProfileByEmail(email); diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java index 2bd7386..f87f4a9 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java @@ -1,23 +1,21 @@ package com.ikae.snowthing.domain.member.dto; +import java.util.ArrayList; +import java.util.List; + import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; + import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.ArrayList; -import java.util.List; - @Getter @NoArgsConstructor public class MemberProfileUpdateRequest { @NotBlank(message = "닉네임은 필수 입력값입니다.") - @Pattern( - regexp = "^[a-zA-Z0-9가-힣]{2,10}$", - message = "닉네임은 2자 이상 10자 이하의 한글, 영문, 숫자이어야 합니다." - ) + @Pattern(regexp = "^[a-zA-Z0-9가-힣]{2,10}$", message = "닉네임은 2자 이상 10자 이하의 한글, 영문, 숫자이어야 합니다.") private String nickname; private String bio; @@ -29,12 +27,18 @@ public class MemberProfileUpdateRequest { private List ridingStyleIds = new ArrayList<>(); @Builder - public MemberProfileUpdateRequest(String nickname, String bio, String departureRegion, List resortIds, List ridingStyleIds) { + public MemberProfileUpdateRequest( + String nickname, + String bio, + String departureRegion, + List resortIds, + List ridingStyleIds) { this.nickname = nickname; this.bio = bio; this.departureRegion = departureRegion; this.resortIds = resortIds != null ? new ArrayList<>(resortIds) : new ArrayList<>(); - this.ridingStyleIds = ridingStyleIds != null ? new ArrayList<>(ridingStyleIds) : new ArrayList<>(); + this.ridingStyleIds = + ridingStyleIds != null ? new ArrayList<>(ridingStyleIds) : new ArrayList<>(); } public List getResortIds() { diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java index 687a6c2..a039f56 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java @@ -1,15 +1,17 @@ package com.ikae.snowthing.domain.member.dto; -import com.ikae.snowthing.domain.member.entity.Member; +import java.util.ArrayList; +import java.util.List; + import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; + +import com.ikae.snowthing.domain.member.entity.Member; + import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.ArrayList; -import java.util.List; - @Getter @NoArgsConstructor public class MemberSignUpRequest { @@ -17,8 +19,7 @@ public class MemberSignUpRequest { @NotBlank(message = "이메일은 필수 입력값입니다.") @Pattern( regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$", - message = "올바른 이메일 형식(예: user@domain.com)이어야 합니다." - ) + message = "올바른 이메일 형식(예: user@domain.com)이어야 합니다.") private String email; @NotBlank(message = "비밀번호는 필수 입력값입니다.") @@ -38,14 +39,22 @@ public class MemberSignUpRequest { private List ridingStyleIds = new ArrayList<>(); @Builder - public MemberSignUpRequest(String email, String password, String nickname, String bio, String departureRegion, List resortIds, List ridingStyleIds) { + public MemberSignUpRequest( + String email, + String password, + String nickname, + String bio, + String departureRegion, + List resortIds, + List ridingStyleIds) { this.email = email; this.password = password; this.nickname = nickname; this.bio = bio; this.departureRegion = departureRegion; this.resortIds = resortIds != null ? new ArrayList<>(resortIds) : new ArrayList<>(); - this.ridingStyleIds = ridingStyleIds != null ? new ArrayList<>(ridingStyleIds) : new ArrayList<>(); + this.ridingStyleIds = + ridingStyleIds != null ? new ArrayList<>(ridingStyleIds) : new ArrayList<>(); } public List getResortIds() { diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpResponse.java index 21f01d7..4951c45 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpResponse.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpResponse.java @@ -1,11 +1,12 @@ package com.ikae.snowthing.domain.member.dto; +import java.time.LocalDateTime; + import com.ikae.snowthing.domain.member.entity.Member; + import lombok.Builder; import lombok.Getter; -import java.time.LocalDateTime; - @Getter public class MemberSignUpResponse { @@ -15,7 +16,8 @@ public class MemberSignUpResponse { private final LocalDateTime createdAt; @Builder - public MemberSignUpResponse(String publicId, String email, String nickname, LocalDateTime createdAt) { + public MemberSignUpResponse( + String publicId, String email, String nickname, LocalDateTime createdAt) { this.publicId = publicId; this.email = email; this.nickname = nickname; diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/ResortResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/ResortResponse.java index f327c3a..edd2536 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/ResortResponse.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/ResortResponse.java @@ -2,11 +2,7 @@ import com.ikae.snowthing.domain.member.entity.Resort; -public record ResortResponse( - Long id, - String name, - String regionName -) { +public record ResortResponse(Long id, String name, String regionName) { public static ResortResponse from(Resort resort) { return new ResortResponse(resort.getId(), resort.getName(), resort.getRegionName()); } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/RidingStyleResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/RidingStyleResponse.java index 2362e95..0d7836c 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/RidingStyleResponse.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/RidingStyleResponse.java @@ -2,12 +2,9 @@ import com.ikae.snowthing.domain.member.entity.RidingStyle; -public record RidingStyleResponse( - Long id, - String styleName, - String description -) { +public record RidingStyleResponse(Long id, String styleName, String description) { public static RidingStyleResponse from(RidingStyle ridingStyle) { - return new RidingStyleResponse(ridingStyle.getId(), ridingStyle.getStyleName(), ridingStyle.getDescription()); + return new RidingStyleResponse( + ridingStyle.getId(), ridingStyle.getStyleName(), ridingStyle.getDescription()); } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Member.java b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Member.java index 9dd8b8b..aeff0aa 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Member.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Member.java @@ -1,14 +1,16 @@ package com.ikae.snowthing.domain.member.entity; -import com.ikae.snowthing.global.common.BaseTimeEntity; +import java.util.UUID; + import jakarta.persistence.*; + +import com.ikae.snowthing.global.common.BaseTimeEntity; + import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.UUID; - @Entity @Table(name = "member") @Getter @@ -69,9 +71,18 @@ public void prePersist() { } @Builder - public Member(String publicId, String email, String password, String nickname, - String profileImageUrl, String bio, String departureRegion, - Long crewId, String crewRole, Role role, MemberStatus status) { + public Member( + String publicId, + String email, + String password, + String nickname, + String profileImageUrl, + String bio, + String departureRegion, + Long crewId, + String crewRole, + Role role, + MemberStatus status) { this.publicId = publicId; this.email = email; this.password = password; @@ -85,7 +96,8 @@ public Member(String publicId, String email, String password, String nickname, this.status = status != null ? status : MemberStatus.ACTIVE; } - public void updateProfile(String nickname, String bio, String departureRegion, String profileImageUrl) { + public void updateProfile( + String nickname, String bio, String departureRegion, String profileImageUrl) { this.nickname = nickname; this.bio = bio; this.departureRegion = departureRegion; diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberResort.java b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberResort.java index 1e1ef2b..7d3a65c 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberResort.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberResort.java @@ -1,6 +1,7 @@ package com.ikae.snowthing.domain.member.entity; import jakarta.persistence.*; + import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -9,10 +10,7 @@ @Entity @Table( name = "member_resort", - uniqueConstraints = { - @UniqueConstraint(columnNames = {"member_id", "resort_id"}) - } -) + uniqueConstraints = {@UniqueConstraint(columnNames = {"member_id", "resort_id"})}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class MemberResort { diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberRidingStyle.java b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberRidingStyle.java index 9827e98..44a74e6 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberRidingStyle.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberRidingStyle.java @@ -1,6 +1,7 @@ package com.ikae.snowthing.domain.member.entity; import jakarta.persistence.*; + import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -9,10 +10,7 @@ @Entity @Table( name = "member_riding_style", - uniqueConstraints = { - @UniqueConstraint(columnNames = {"member_id", "riding_style_id"}) - } -) + uniqueConstraints = {@UniqueConstraint(columnNames = {"member_id", "style_id"})}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class MemberRidingStyle { @@ -26,7 +24,7 @@ public class MemberRidingStyle { private Member member; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "riding_style_id", nullable = false) + @JoinColumn(name = "style_id", nullable = false) private RidingStyle ridingStyle; @Builder diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberStatus.java b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberStatus.java index 2725c05..8e87274 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberStatus.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/MemberStatus.java @@ -6,7 +6,6 @@ @Getter @RequiredArgsConstructor public enum MemberStatus { - ACTIVE("정상 활성 계정"), SUSPENDED("제재/정지 계정"), WITHDRAWN("탈퇴 계정"); diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Resort.java b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Resort.java index 4483816..3027cf8 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Resort.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Resort.java @@ -1,6 +1,7 @@ package com.ikae.snowthing.domain.member.entity; import jakarta.persistence.*; + import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -20,7 +21,7 @@ public class Resort { @Column(name = "name", nullable = false, length = 100, unique = true) private String name; - @Column(name = "region_name", length = 100) + @Column(name = "region", nullable = false, length = 50) private String regionName; @Builder diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/RidingStyle.java b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/RidingStyle.java index 8fbffd6..3d0f393 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/RidingStyle.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/RidingStyle.java @@ -1,6 +1,7 @@ package com.ikae.snowthing.domain.member.entity; import jakarta.persistence.*; + import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -14,10 +15,10 @@ public class RidingStyle { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "riding_style_id") + @Column(name = "style_id") private Long id; - @Column(name = "style_name", nullable = false, length = 100, unique = true) + @Column(name = "name", nullable = false, length = 50, unique = true) private String styleName; @Column(name = "description", length = 255) diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Role.java b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Role.java index 34e5fb3..69eba6f 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Role.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Role.java @@ -6,7 +6,6 @@ @Getter @RequiredArgsConstructor public enum Role { - GUEST("ROLE_GUEST", "비회원 유저"), ROLE_USER("ROLE_USER", "일반 회원"), ROLE_ADMIN("ROLE_ADMIN", "서비스 관리자"); diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepository.java index bc1ac42..a64265a 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepository.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepository.java @@ -1,11 +1,12 @@ package com.ikae.snowthing.domain.member.repository; -import com.ikae.snowthing.domain.member.entity.Member; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; +import com.ikae.snowthing.domain.member.entity.Member; -public interface MemberRepository extends JpaRepository { +public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { boolean existsByEmail(String email); diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustom.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustom.java new file mode 100644 index 0000000..cf769a8 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.ikae.snowthing.domain.member.repository; + +import java.util.Optional; + +import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; + +public interface MemberRepositoryCustom { + + Optional findProfileByEmail(String email); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomImpl.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomImpl.java new file mode 100644 index 0000000..39d0f40 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomImpl.java @@ -0,0 +1,67 @@ +package com.ikae.snowthing.domain.member.repository; + +import static com.ikae.snowthing.domain.member.entity.QMember.member; +import static com.ikae.snowthing.domain.member.entity.QMemberResort.memberResort; +import static com.ikae.snowthing.domain.member.entity.QMemberRidingStyle.memberRidingStyle; +import static com.ikae.snowthing.domain.member.entity.QResort.resort; +import static com.ikae.snowthing.domain.member.entity.QRidingStyle.ridingStyle; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; +import com.ikae.snowthing.domain.member.entity.Member; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class MemberRepositoryCustomImpl implements MemberRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + /** + * 회원 프로필 조회 (1:N:M 다중 컬렉션 Cartesian Product 방지를 위한 3-Step O(1) 쿼리 분리 전략) + * + *

[아키텍처 설계 배경 및 트레이드오프]: 1. 문제점: Member 엔티티는 member_resorts(1:N)와 + * member_riding_styles(1:M)라는 2개의 독립된 일대다 관계를 가집니다. 이를 단일 SQL로 Fetch Join하면 N × M 카테시안 + * 곱(Cartesian Product)이 발생하여 중복 데이터 전송 및 메모리 오버헤드가 발생합니다. 2. 3-Step 고정 쿼리 전략: - Step 1: Member + * 기본 정보 조회 (PK/Email 인덱스 단건 조회, 1 row) - Step 2: MemberId 기반 선호 Resort 이름 조회 (1~N rows) - Step + * 3: MemberId 기반 선호 RidingStyle 이름 조회 (1~M rows) 3. 트레이드오프: - DB Network RTT가 1회 -> 3회로 발생하지만, + * 회원 단건 조회이므로 데이터 크기에 비례하는 N+1이 아닌 고정 O(1) 쿼리로 동작합니다. - 카테시안 곱에 의한 메모리/네트워크 낭비를 물리적으로 방지하고 각 + * 쿼리가 인덱스 조건으로 매우 빠르게 수행됩니다. + */ + @Override + public Optional findProfileByEmail(String email) { + // Step 1: 회원 기본 엔티티 조회 (1 query) + Member foundMember = + queryFactory.selectFrom(member).where(member.email.eq(email)).fetchOne(); + + if (foundMember == null) { + return Optional.empty(); + } + + // Step 2: 선호 리조트명 조회 (1 query) + List resortNames = + queryFactory + .select(resort.name) + .from(memberResort) + .join(memberResort.resort, resort) + .where(memberResort.member.id.eq(foundMember.getId())) + .fetch(); + + // Step 3: 선호 라이딩 스타일명 조회 (1 query) + List ridingStyleNames = + queryFactory + .select(ridingStyle.styleName) + .from(memberRidingStyle) + .join(memberRidingStyle.ridingStyle, ridingStyle) + .where(memberRidingStyle.member.id.eq(foundMember.getId())) + .fetch(); + + return Optional.of(MemberLoginResponse.from(foundMember, resortNames, ridingStyleNames)); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberResortRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberResortRepository.java index 61fc40b..5e4d4f6 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberResortRepository.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberResortRepository.java @@ -1,12 +1,13 @@ package com.ikae.snowthing.domain.member.repository; -import com.ikae.snowthing.domain.member.entity.MemberResort; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; +import com.ikae.snowthing.domain.member.entity.MemberResort; public interface MemberResortRepository extends JpaRepository { diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRidingStyleRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRidingStyleRepository.java index e4a1f4b..df9050b 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRidingStyleRepository.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/MemberRidingStyleRepository.java @@ -1,16 +1,18 @@ package com.ikae.snowthing.domain.member.repository; -import com.ikae.snowthing.domain.member.entity.MemberRidingStyle; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; +import com.ikae.snowthing.domain.member.entity.MemberRidingStyle; public interface MemberRidingStyleRepository extends JpaRepository { - @Query("SELECT mrs FROM MemberRidingStyle mrs JOIN FETCH mrs.ridingStyle WHERE mrs.member.id = :memberId") + @Query( + "SELECT mrs FROM MemberRidingStyle mrs JOIN FETCH mrs.ridingStyle WHERE mrs.member.id = :memberId") List findAllByMemberIdWithRidingStyle(@Param("memberId") Long memberId); @Modifying(clearAutomatically = true, flushAutomatically = true) diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java index 6a88d1e..a90c81b 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java @@ -1,9 +1,10 @@ package com.ikae.snowthing.domain.member.repository; -import com.ikae.snowthing.domain.member.entity.Resort; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; +import com.ikae.snowthing.domain.member.entity.Resort; public interface ResortRepository extends JpaRepository { Optional findByName(String name); diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/RidingStyleRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/RidingStyleRepository.java index 1a89769..1960c7c 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/RidingStyleRepository.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/RidingStyleRepository.java @@ -1,9 +1,10 @@ package com.ikae.snowthing.domain.member.repository; -import com.ikae.snowthing.domain.member.entity.RidingStyle; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; +import com.ikae.snowthing.domain.member.entity.RidingStyle; public interface RidingStyleRepository extends JpaRepository { Optional findByStyleName(String styleName); diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java b/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java index cc97dd5..ce3b8ed 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/service/MasterDataService.java @@ -1,13 +1,16 @@ package com.ikae.snowthing.domain.member.service; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.ikae.snowthing.domain.member.dto.ResortResponse; import com.ikae.snowthing.domain.member.dto.RidingStyleResponse; import com.ikae.snowthing.domain.member.repository.ResortRepository; import com.ikae.snowthing.domain.member.repository.RidingStyleRepository; -import java.util.List; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -18,14 +21,10 @@ public class MasterDataService { private final RidingStyleRepository ridingStyleRepository; public List getAllResorts() { - return resortRepository.findAll().stream() - .map(ResortResponse::from) - .toList(); + return resortRepository.findAll().stream().map(ResortResponse::from).toList(); } public List getAllRidingStyles() { - return ridingStyleRepository.findAll().stream() - .map(RidingStyleResponse::from) - .toList(); + return ridingStyleRepository.findAll().stream().map(RidingStyleResponse::from).toList(); } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/service/MemberService.java b/backend/src/main/java/com/ikae/snowthing/domain/member/service/MemberService.java index 9e1522c..fa32975 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/service/MemberService.java @@ -1,17 +1,20 @@ package com.ikae.snowthing.domain.member.service; +import java.util.List; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.ikae.snowthing.domain.member.dto.MemberProfileUpdateRequest; import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse; import com.ikae.snowthing.domain.member.entity.*; import com.ikae.snowthing.domain.member.repository.*; -import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; -import java.util.List; +import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor @@ -28,80 +31,102 @@ public class MemberService { @Transactional public MemberSignUpResponse signUp(MemberSignUpRequest request) { if (memberRepository.existsByEmail(request.getEmail())) { - throw new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.DUPLICATE_EMAIL); + throw new CustomAuthException(ErrorCode.DUPLICATE_EMAIL); } if (memberRepository.existsByNickname(request.getNickname())) { - throw new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.DUPLICATE_NICKNAME); + throw new CustomAuthException(ErrorCode.DUPLICATE_NICKNAME); } String encodedPassword = passwordEncoder.encode(request.getPassword()); Member member = request.toEntity(encodedPassword); - - try { - Member savedMember = memberRepository.saveAndFlush(member); - - if (request.getResortIds() != null && !request.getResortIds().isEmpty()) { - List resorts = resortRepository.findAllById(request.getResortIds()); - List memberResorts = resorts.stream() - .map(resort -> MemberResort.builder().member(savedMember).resort(resort).build()) - .toList(); - memberResortRepository.saveAll(memberResorts); - } - - if (request.getRidingStyleIds() != null && !request.getRidingStyleIds().isEmpty()) { - List ridingStyles = ridingStyleRepository.findAllById(request.getRidingStyleIds()); - List memberRidingStyles = ridingStyles.stream() - .map(style -> MemberRidingStyle.builder().member(savedMember).ridingStyle(style).build()) - .toList(); - memberRidingStyleRepository.saveAll(memberRidingStyles); - } - - return MemberSignUpResponse.from(savedMember); - } catch (DataIntegrityViolationException e) { - throw new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.DUPLICATE_EMAIL); + + Member savedMember = memberRepository.save(member); + + if (!request.getResortIds().isEmpty()) { + List resorts = resortRepository.findAllById(request.getResortIds()); + List memberResorts = + resorts.stream() + .map( + resort -> + MemberResort.builder() + .member(savedMember) + .resort(resort) + .build()) + .toList(); + memberResortRepository.saveAll(memberResorts); + } + + if (!request.getRidingStyleIds().isEmpty()) { + List ridingStyles = + ridingStyleRepository.findAllById(request.getRidingStyleIds()); + List memberRidingStyles = + ridingStyles.stream() + .map( + style -> + MemberRidingStyle.builder() + .member(savedMember) + .ridingStyle(style) + .build()) + .toList(); + memberRidingStyleRepository.saveAll(memberRidingStyles); } + + return MemberSignUpResponse.from(savedMember); } @Transactional public MemberSignUpResponse updateMyProfile(String email, MemberProfileUpdateRequest request) { - Member member = memberRepository.findByEmail(email) - .orElseThrow(() -> new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.MEMBER_NOT_FOUND)); + Member member = + memberRepository + .findByEmail(email) + .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); // 닉네임 변경 시 기존 닉네임과 다르고 타 유저와 중복되는지 사전 검사 - if (!member.getNickname().equals(request.getNickname()) && memberRepository.existsByNickname(request.getNickname())) { - throw new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.DUPLICATE_NICKNAME); + if (!member.getNickname().equals(request.getNickname()) + && memberRepository.existsByNickname(request.getNickname())) { + throw new CustomAuthException(ErrorCode.DUPLICATE_NICKNAME); + } + + // 1. 기본 프로필 정보 업데이트 (Dirty Checking) + member.updateProfile( + request.getNickname(), + request.getBio(), + request.getDepartureRegion(), + member.getProfileImageUrl()); + + // 2. 기존 N:M 선호 스키장 삭제 후 재등록 + memberResortRepository.deleteAllByMemberId(member.getId()); + if (!request.getResortIds().isEmpty()) { + List resorts = resortRepository.findAllById(request.getResortIds()); + List newMemberResorts = + resorts.stream() + .map( + resort -> + MemberResort.builder() + .member(member) + .resort(resort) + .build()) + .toList(); + memberResortRepository.saveAll(newMemberResorts); } - try { - // 1. 기본 프로필 정보 업데이트 (Dirty Checking) - member.updateProfile(request.getNickname(), request.getBio(), request.getDepartureRegion(), member.getProfileImageUrl()); - - // 2. 기존 N:M 선호 스키장 삭제 후 재등록 - memberResortRepository.deleteAllByMemberId(member.getId()); - if (request.getResortIds() != null && !request.getResortIds().isEmpty()) { - List resorts = resortRepository.findAllById(request.getResortIds()); - List newMemberResorts = resorts.stream() - .map(resort -> MemberResort.builder().member(member).resort(resort).build()) - .toList(); - memberResortRepository.saveAll(newMemberResorts); - } - - // 3. 기존 N:M 라이딩 성향 삭제 후 재등록 - memberRidingStyleRepository.deleteAllByMemberId(member.getId()); - if (request.getRidingStyleIds() != null && !request.getRidingStyleIds().isEmpty()) { - List ridingStyles = ridingStyleRepository.findAllById(request.getRidingStyleIds()); - List newMemberRidingStyles = ridingStyles.stream() - .map(style -> MemberRidingStyle.builder().member(member).ridingStyle(style).build()) - .toList(); - memberRidingStyleRepository.saveAll(newMemberRidingStyles); - } - - // 동시성 닉네임 중복 충돌을 DB Unique Index 레벨에서 즉시 감지하기 위해 flush - memberRepository.flush(); - - return MemberSignUpResponse.from(member); - } catch (DataIntegrityViolationException e) { - throw new com.ikae.snowthing.global.exception.CustomAuthException(com.ikae.snowthing.global.error.ErrorCode.DUPLICATE_NICKNAME); + // 3. 기존 N:M 라이딩 성향 삭제 후 재등록 + memberRidingStyleRepository.deleteAllByMemberId(member.getId()); + if (!request.getRidingStyleIds().isEmpty()) { + List ridingStyles = + ridingStyleRepository.findAllById(request.getRidingStyleIds()); + List newMemberRidingStyles = + ridingStyles.stream() + .map( + style -> + MemberRidingStyle.builder() + .member(member) + .ridingStyle(style) + .build()) + .toList(); + memberRidingStyleRepository.saveAll(newMemberRidingStyles); } + + return MemberSignUpResponse.from(member); } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/controller/PostController.java b/backend/src/main/java/com/ikae/snowthing/domain/post/controller/PostController.java new file mode 100644 index 0000000..c2245c5 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/controller/PostController.java @@ -0,0 +1,127 @@ +package com.ikae.snowthing.domain.post.controller; + +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import com.ikae.snowthing.domain.post.dto.*; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.security.CustomUserDetails; +import com.ikae.snowthing.global.web.AnonymousVoterCookieManager; +import com.ikae.snowthing.global.web.ClientIpResolver; +import com.ikae.snowthing.global.web.ViewCountCookieManager; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping({"/api/posts", "/api/v1/posts"}) +@RequiredArgsConstructor +public class PostController { + + private static final String DEFAULT_PAGE_SIZE_PARAM = "20"; + + private final PostService postService; + private final ClientIpResolver clientIpResolver; + private final ViewCountCookieManager viewCountCookieManager; + private final AnonymousVoterCookieManager anonymousVoterCookieManager; + + @PostMapping + public ResponseEntity createPost( + @Valid @RequestBody PostCreateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletRequest httpRequest) { + String clientIp = clientIpResolver.resolve(httpRequest); + PostResponse response = postService.createPost(request, userDetails, clientIp); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{publicId}") + public ResponseEntity getPostDetail( + @PathVariable String publicId, + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletRequest request, + HttpServletResponse response) { + boolean shouldIncreaseViewCount = + viewCountCookieManager.markIfFirstView(publicId, request, response); + PostDetailResponse postDetailResponse = + postService.getPostDetail(publicId, userDetails, shouldIncreaseViewCount); + return ResponseEntity.ok(postDetailResponse); + } + + @GetMapping + public ResponseEntity> + searchPostsByOffset( + @RequestParam(required = false) String categoryCode, + @RequestParam(required = false) Long resortId, + @RequestParam(required = false) SearchType searchType, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) SortType sortType, + @RequestParam(required = false, defaultValue = "1") Integer page, + @RequestParam(defaultValue = DEFAULT_PAGE_SIZE_PARAM) int size) { + PostSearchRequest request = + new PostSearchRequest( + categoryCode, resortId, searchType, keyword, sortType, page, null, size); + return ResponseEntity.ok(postService.searchPostsByOffset(request)); + } + + @GetMapping("/scroll") + public ResponseEntity> + searchPostsByCursor( + @RequestParam(required = false) String categoryCode, + @RequestParam(required = false) Long resortId, + @RequestParam(required = false) SearchType searchType, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) SortType sortType, + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = DEFAULT_PAGE_SIZE_PARAM) int size) { + PostSearchRequest request = + new PostSearchRequest( + categoryCode, resortId, searchType, keyword, sortType, null, cursor, size); + return ResponseEntity.ok(postService.searchPostsByCursor(request)); + } + + @PutMapping("/{publicId}") + public ResponseEntity updatePost( + @PathVariable String publicId, + @Valid @RequestBody PostUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + PostResponse response = postService.updatePost(publicId, request, userDetails); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{publicId}") + public ResponseEntity> deletePost( + @PathVariable String publicId, + @RequestBody(required = false) PostDeleteRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + String password = request != null ? request.anonymousPassword() : null; + postService.deletePost(publicId, password, userDetails); + return ResponseEntity.ok( + Map.of("message", "게시글이 정상적으로 삭제(Soft Delete) 처리되었습니다.", "publicId", publicId)); + } + + @PostMapping("/{publicId}/reactions") + public ResponseEntity reactToPost( + @PathVariable String publicId, + @Valid @RequestBody PostReactionRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + String clientIp = clientIpResolver.resolve(httpRequest); + String anonymousVoterId = + userDetails == null + ? anonymousVoterCookieManager.getOrCreate(httpRequest, httpResponse) + : null; + com.ikae.snowthing.domain.post.dto.ReactionToggleResponse response = + postService.reactToPost( + publicId, request.type(), userDetails, clientIp, anonymousVoterId); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostCreateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostCreateRequest.java new file mode 100644 index 0000000..52a2730 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostCreateRequest.java @@ -0,0 +1,26 @@ +package com.ikae.snowthing.domain.post.dto; + +import java.util.List; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import lombok.Builder; + +@Builder +public record PostCreateRequest( + @NotBlank(message = "카테고리 코드는 필수 입력값입니다.") String categoryCode, + @NotBlank(message = "제목은 필수 입력값입니다.") @Size(max = 200, message = "제목은 최대 200자까지 입력 가능합니다.") + String title, + @NotBlank(message = "본문은 필수 입력값입니다.") String content, + boolean isAnonymous, + String anonymousPassword, + List imageUrls) { + public PostCreateRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } else { + imageUrls = List.copyOf(imageUrls); + } + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDeleteRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDeleteRequest.java new file mode 100644 index 0000000..12d56db --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDeleteRequest.java @@ -0,0 +1,3 @@ +package com.ikae.snowthing.domain.post.dto; + +public record PostDeleteRequest(String anonymousPassword) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDetailResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDetailResponse.java new file mode 100644 index 0000000..b1290ab --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDetailResponse.java @@ -0,0 +1,73 @@ +package com.ikae.snowthing.domain.post.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostImage; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import com.ikae.snowthing.global.util.WriterDisplayFormatter; + +public record PostDetailResponse( + String publicId, + String categoryName, + String categoryCode, + String title, + String content, + PostStatus status, + boolean isAnonymous, + int viewCount, + int commentCount, + int likeCount, + int dislikeCount, + WriterInfo writer, + List images, + LocalDateTime createdAt) { + public record WriterInfo(String publicId, String nickname, String profileImageUrl) { + public static WriterInfo anonymous(String maskedIp) { + return new WriterInfo(null, maskedIp, null); + } + + public static WriterInfo member(String publicId, String nickname, String profileImageUrl) { + return new WriterInfo(publicId, nickname, profileImageUrl); + } + } + + public static PostDetailResponse from(Post post, int viewCount, List imageUrls) { + WriterInfo writerInfo = + post.isAnonymous() + ? WriterInfo.anonymous( + WriterDisplayFormatter.formatAnonymous(post.getWriterIp())) + : (post.getMember() != null + ? WriterInfo.member( + post.getMember().getPublicId(), + post.getMember().getNickname(), + post.getMember().getProfileImageUrl()) + : WriterInfo.anonymous("알 수 없음")); + + return new PostDetailResponse( + post.getPublicId(), + post.getCategory().getName(), + post.getCategory().getCode(), + post.getTitle(), + post.getContent(), + post.getStatus(), + post.isAnonymous(), + viewCount, + post.getCommentCount(), + post.getLikeCount(), + post.getDislikeCount(), + writerInfo, + List.copyOf(imageUrls), + post.getCreatedAt()); + } + + public static PostDetailResponse from(Post post, int viewCount) { + List imageUrls = post.getImages().stream().map(PostImage::getImageUrl).toList(); + return from(post, viewCount, imageUrls); + } + + public static PostDetailResponse from(Post post) { + return from(post, post.getViewCount()); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostListResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostListResponse.java new file mode 100644 index 0000000..f508fdf --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostListResponse.java @@ -0,0 +1,48 @@ +package com.ikae.snowthing.domain.post.dto; + +import java.time.LocalDateTime; + +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import com.ikae.snowthing.global.util.WriterDisplayFormatter; + +public record PostListResponse( + String publicId, + String categoryName, + String categoryCode, + String title, + String writerNickname, + String thumbnailImageUrl, + boolean hasImage, + int viewCount, + int commentCount, + int likeCount, + int dislikeCount, + PostStatus status, + boolean isDeleted, + LocalDateTime createdAt) { + public static PostListResponse from(Post post) { + String writerNickname = + WriterDisplayFormatter.format( + post.isAnonymous(), post.getMember(), post.getWriterIp()); + + String thumbnailUrl = + post.getImages().isEmpty() ? null : post.getImages().get(0).getImageUrl(); + + return new PostListResponse( + post.getPublicId(), + post.getCategory().getName(), + post.getCategory().getCode(), + post.isDeleted() ? "[삭제된 게시글입니다]" : post.getTitle(), + writerNickname, + thumbnailUrl, + post.isHasImage(), + post.getViewCount(), + post.getCommentCount(), + post.getLikeCount(), + post.getDislikeCount(), + post.getStatus(), + post.isDeleted(), + post.getCreatedAt()); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostReactionRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostReactionRequest.java new file mode 100644 index 0000000..e373d9c --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostReactionRequest.java @@ -0,0 +1,8 @@ +package com.ikae.snowthing.domain.post.dto; + +import jakarta.validation.constraints.NotNull; + +import com.ikae.snowthing.domain.post.entity.ReactionType; + +public record PostReactionRequest( + @NotNull(message = "투표 유형(LIKE/DISLIKE)은 필수 입력값입니다.") ReactionType type) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostResponse.java new file mode 100644 index 0000000..fd81876 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostResponse.java @@ -0,0 +1,31 @@ +package com.ikae.snowthing.domain.post.dto; + +import java.time.LocalDateTime; + +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import com.ikae.snowthing.global.util.WriterDisplayFormatter; + +public record PostResponse( + String publicId, + String categoryName, + String categoryCode, + String title, + String writerName, + PostStatus status, + LocalDateTime createdAt) { + public static PostResponse from(Post post) { + String writerName = + WriterDisplayFormatter.format( + post.isAnonymous(), post.getMember(), post.getWriterIp()); + + return new PostResponse( + post.getPublicId(), + post.getCategory().getName(), + post.getCategory().getCode(), + post.getTitle(), + writerName, + post.getStatus(), + post.getCreatedAt()); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostSearchRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostSearchRequest.java new file mode 100644 index 0000000..eead66f --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostSearchRequest.java @@ -0,0 +1,19 @@ +package com.ikae.snowthing.domain.post.dto; + +public record PostSearchRequest( + String categoryCode, // 카테고리 코드 (FREE, ANONYMOUS, QNA, FOOD, BEST) + Long resortId, // 리조트 ID (null: 전체, 1: 휘닉스파크 등) + SearchType searchType, // TITLE, WRITER, CONTENT, TITLE_CONTENT + String keyword, // 검색 키워드 + SortType sortType, // LATEST, POPULAR + Integer page, // 웹용 페이지 번호 (1-based) + String cursor, // 모바일용 커서 토큰 (Base64) + Integer size // 한 번에 조회할 개수 (기본 20개) + ) { + private static final int DEFAULT_PAGE_SIZE = 20; + + public PostSearchRequest { + if (sortType == null) sortType = SortType.LATEST; + if (size == null || size <= 0) size = DEFAULT_PAGE_SIZE; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostUpdateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostUpdateRequest.java new file mode 100644 index 0000000..b78e310 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostUpdateRequest.java @@ -0,0 +1,25 @@ +package com.ikae.snowthing.domain.post.dto; + +import java.util.List; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import lombok.Builder; + +@Builder +public record PostUpdateRequest( + @NotBlank(message = "카테고리 코드는 필수 입력값입니다.") String categoryCode, + @NotBlank(message = "제목은 필수 입력값입니다.") @Size(max = 200, message = "제목은 최대 200자까지 입력 가능합니다.") + String title, + @NotBlank(message = "본문은 필수 입력값입니다.") String content, + String anonymousPassword, + List imageUrls) { + public PostUpdateRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } else { + imageUrls = List.copyOf(imageUrls); + } + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/ReactionToggleResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/ReactionToggleResponse.java new file mode 100644 index 0000000..7f844fc --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/ReactionToggleResponse.java @@ -0,0 +1,7 @@ +package com.ikae.snowthing.domain.post.dto; + +import lombok.Builder; + +@Builder +public record ReactionToggleResponse( + boolean isToggledOn, String type, int likeCount, int dislikeCount, String message) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/SearchType.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/SearchType.java new file mode 100644 index 0000000..3775fcb --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/SearchType.java @@ -0,0 +1,8 @@ +package com.ikae.snowthing.domain.post.dto; + +public enum SearchType { + TITLE, // 제목 검색 + WRITER, // 작성자 닉네임 검색 + CONTENT, // 본문 검색 + TITLE_CONTENT // 제목 + 본문 검색 +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/SortType.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/SortType.java new file mode 100644 index 0000000..8e84682 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/SortType.java @@ -0,0 +1,6 @@ +package com.ikae.snowthing.domain.post.dto; + +public enum SortType { + LATEST, // 최신순 (created_at DESC, id DESC) + POPULAR // 추천순/인기순 (like_count DESC, id DESC) +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/Post.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/Post.java new file mode 100644 index 0000000..3e3c0d7 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/Post.java @@ -0,0 +1,181 @@ +package com.ikae.snowthing.domain.post.entity; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.*; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.global.common.BaseTimeEntity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete( + sql = + "UPDATE post SET is_deleted = true, status = 'DELETED', deleted_at = NOW() WHERE post_id = ?") +@SQLRestriction("is_deleted = false") +public class Post extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "post_id") + private Long id; + + @Column(name = "public_id", nullable = false, unique = true, length = 36) + private String publicId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false) + private PostCategory category; + + @Column(nullable = false, length = 200) + private String title; + + @Lob + @Column(nullable = false, columnDefinition = "LONGTEXT") + private String content; + + @Column(name = "writer_ip", nullable = false, length = 45) + private String writerIp; + + @Column(name = "is_anonymous", nullable = false) + private boolean isAnonymous; + + @Column(name = "anonymous_password") + private String anonymousPassword; + + @Column(name = "view_count", nullable = false) + private int viewCount = 0; + + @Column(name = "comment_count", nullable = false) + private int commentCount = 0; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + @Column(name = "dislike_count", nullable = false) + private int dislikeCount = 0; + + @Column(name = "has_image", nullable = false) + private boolean hasImage = false; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PostStatus status = PostStatus.NORMAL; + + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List images = new ArrayList<>(); + + @Builder + public Post( + Member member, + PostCategory category, + String title, + String content, + String writerIp, + boolean isAnonymous, + String anonymousPassword, + boolean hasImage, + PostStatus status) { + this.publicId = UUID.randomUUID().toString(); + this.member = member; + this.category = category; + this.title = title; + this.content = content; + this.writerIp = writerIp; + this.isAnonymous = isAnonymous; + this.anonymousPassword = anonymousPassword; + this.hasImage = hasImage; + this.status = status != null ? status : PostStatus.NORMAL; + this.isDeleted = false; + } + + public void changeStatus(PostStatus status) { + this.status = status; + } + + public void update(String title, String content, PostCategory category) { + this.title = title; + this.content = content; + this.category = category; + } + + public void updateImageStatus(boolean hasImage) { + this.hasImage = hasImage; + } + + public void replaceImages(List newImages) { + this.images.clear(); + for (PostImage image : newImages) { + addImage(image); + } + updateImageStatus(!this.images.isEmpty()); + } + + public void softDelete() { + this.isDeleted = true; + this.status = PostStatus.DELETED; + this.deletedAt = LocalDateTime.now(); + } + + public void increaseViewCount() { + this.viewCount++; + } + + public void increaseCommentCount() { + this.commentCount++; + } + + public void decreaseCommentCount() { + if (this.commentCount > 0) { + this.commentCount--; + } + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public void increaseDislikeCount() { + this.dislikeCount++; + } + + public void decreaseDislikeCount() { + if (this.dislikeCount > 0) { + this.dislikeCount--; + } + } + + public void addImage(PostImage image) { + this.images.add(image); + image.setPost(this); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostCategory.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostCategory.java new file mode 100644 index 0000000..cb4da1d --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostCategory.java @@ -0,0 +1,32 @@ +package com.ikae.snowthing.domain.post.entity; + +import jakarta.persistence.*; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post_category") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostCategory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "category_id") + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false, unique = true, length = 50) + private String code; + + @Builder + public PostCategory(String name, String code) { + this.name = name; + this.code = code; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostImage.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostImage.java new file mode 100644 index 0000000..082ab9c --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostImage.java @@ -0,0 +1,43 @@ +package com.ikae.snowthing.domain.post.entity; + +import jakarta.persistence.*; + +import com.ikae.snowthing.global.common.BaseTimeEntity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post_image") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostImage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + @Column(name = "sort_order", nullable = false) + private int sortOrder = 1; + + @Builder + public PostImage(Post post, String imageUrl, int sortOrder) { + this.post = post; + this.imageUrl = imageUrl; + this.sortOrder = sortOrder; + } + + public void setPost(Post post) { + this.post = post; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostReaction.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostReaction.java new file mode 100644 index 0000000..942b853 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostReaction.java @@ -0,0 +1,60 @@ +package com.ikae.snowthing.domain.post.entity; + +import jakarta.persistence.*; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.global.common.BaseTimeEntity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "post_reaction", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_post_member_type", + columnNames = {"post_id", "member_id", "type"}), + @UniqueConstraint( + name = "uk_post_anon_voter_type", + columnNames = {"post_id", "anonymous_voter_id", "type"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostReaction extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reaction_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = true) + private Member member; + + @Column(name = "writer_ip", length = 45) + private String writerIp; + + @Column(name = "anonymous_voter_id", length = 36) + private String anonymousVoterId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private ReactionType type; + + @Builder + public PostReaction( + Post post, Member member, String writerIp, String anonymousVoterId, ReactionType type) { + this.post = post; + this.member = member; + this.writerIp = writerIp; + this.anonymousVoterId = anonymousVoterId; + this.type = type; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostStatus.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostStatus.java new file mode 100644 index 0000000..a427a7b --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostStatus.java @@ -0,0 +1,9 @@ +package com.ikae.snowthing.domain.post.entity; + +public enum PostStatus { + NORMAL, + DELETED, + BLOCKED, + HIDDEN, + DRAFT +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/ReactionType.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/ReactionType.java new file mode 100644 index 0000000..b261d75 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/ReactionType.java @@ -0,0 +1,6 @@ +package com.ikae.snowthing.domain.post.entity; + +public enum ReactionType { + LIKE, + DISLIKE +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEvent.java b/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEvent.java new file mode 100644 index 0000000..2a3db04 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEvent.java @@ -0,0 +1,5 @@ +package com.ikae.snowthing.domain.post.event; + +import com.ikae.snowthing.domain.post.entity.ReactionType; + +public record PostReactionEvent(Long postId, ReactionType type) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEventListener.java b/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEventListener.java new file mode 100644 index 0000000..e40b63d --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEventListener.java @@ -0,0 +1,26 @@ +package com.ikae.snowthing.domain.post.event; + +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.post.repository.PostRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PostReactionEventListener { + + private final PostRepository postRepository; + + @Async + @EventListener + @Transactional + public void handlePostReactionEvent(PostReactionEvent event) { + log.info("비동기 추천/비추천 이벤트 알림 기록 - postId: {}, type: {}", event.postId(), event.type()); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostCategoryRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostCategoryRepository.java new file mode 100644 index 0000000..ed75695 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostCategoryRepository.java @@ -0,0 +1,12 @@ +package com.ikae.snowthing.domain.post.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ikae.snowthing.domain.post.entity.PostCategory; + +public interface PostCategoryRepository extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostImageRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostImageRepository.java new file mode 100644 index 0000000..cfb8925 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostImageRepository.java @@ -0,0 +1,12 @@ +package com.ikae.snowthing.domain.post.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ikae.snowthing.domain.post.entity.PostImage; + +public interface PostImageRepository extends JpaRepository { + + List findByPostIdOrderBySortOrderAsc(Long postId); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostReactionRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostReactionRepository.java new file mode 100644 index 0000000..c61f306 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostReactionRepository.java @@ -0,0 +1,25 @@ +package com.ikae.snowthing.domain.post.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ikae.snowthing.domain.post.entity.PostReaction; + +public interface PostReactionRepository extends JpaRepository { + + boolean existsByPostIdAndMemberId(Long postId, Long memberId); + + boolean existsByPostIdAndAnonymousVoterId(Long postId, String anonymousVoterId); + + boolean existsByPostIdAndMemberIdAndType( + Long postId, Long memberId, com.ikae.snowthing.domain.post.entity.ReactionType type); + + Optional findByPostIdAndMemberIdAndType( + Long postId, Long memberId, com.ikae.snowthing.domain.post.entity.ReactionType type); + + Optional findByPostIdAndAnonymousVoterIdAndType( + Long postId, + String anonymousVoterId, + com.ikae.snowthing.domain.post.entity.ReactionType type); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepository.java new file mode 100644 index 0000000..0e8896d --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepository.java @@ -0,0 +1,64 @@ +package com.ikae.snowthing.domain.post.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ikae.snowthing.domain.post.entity.Post; + +public interface PostRepository extends JpaRepository, PostRepositoryCustom { + + Optional findByPublicId(String publicId); + + @Query( + "SELECT p FROM Post p LEFT JOIN FETCH p.member JOIN FETCH p.category WHERE p.publicId = :publicId") + Optional findWithMemberAndCategoryByPublicId(@Param("publicId") String publicId); + + @Query( + "SELECT p FROM Post p LEFT JOIN FETCH p.member JOIN FETCH p.category WHERE p.category.code = :categoryCode AND p.status = com.ikae.snowthing.domain.post.entity.PostStatus.NORMAL AND p.isDeleted = false") + Page findByCategoryCodeWithMemberAndCategory( + @Param("categoryCode") String categoryCode, Pageable pageable); + + @Query( + value = + "SELECT p FROM Post p LEFT JOIN FETCH p.member JOIN FETCH p.category WHERE p.status = com.ikae.snowthing.domain.post.entity.PostStatus.NORMAL AND p.isDeleted = false", + countQuery = + "SELECT COUNT(p) FROM Post p WHERE p.status = com.ikae.snowthing.domain.post.entity.PostStatus.NORMAL AND p.isDeleted = false") + Page findAllWithMemberAndCategory(Pageable pageable); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :postId") + void increaseViewCount(@Param("postId") Long postId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE Post p SET p.likeCount = p.likeCount + 1 WHERE p.id = :postId") + void increaseLikeCount(@Param("postId") Long postId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query( + "UPDATE Post p SET p.likeCount = p.likeCount - 1 WHERE p.id = :postId AND p.likeCount > 0") + void decreaseLikeCount(@Param("postId") Long postId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE Post p SET p.dislikeCount = p.dislikeCount + 1 WHERE p.id = :postId") + void increaseDislikeCount(@Param("postId") Long postId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query( + "UPDATE Post p SET p.dislikeCount = p.dislikeCount - 1 WHERE p.id = :postId AND p.dislikeCount > 0") + void decreaseDislikeCount(@Param("postId") Long postId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE Post p SET p.commentCount = p.commentCount + 1 WHERE p.id = :postId") + void increaseCommentCount(@Param("postId") Long postId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query( + "UPDATE Post p SET p.commentCount = p.commentCount - 1 WHERE p.id = :postId AND p.commentCount > 0") + void decreaseCommentCount(@Param("postId") Long postId); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustom.java b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustom.java new file mode 100644 index 0000000..b269b12 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustom.java @@ -0,0 +1,14 @@ +package com.ikae.snowthing.domain.post.repository; + +import com.ikae.snowthing.domain.post.dto.PostListResponse; +import com.ikae.snowthing.domain.post.dto.PostSearchRequest; +import com.ikae.snowthing.global.common.dto.CursorPageResponse; + +public interface PostRepositoryCustom { + + /** 웹 Offset 기반 동적 목록 조회 (Hard Cap 100페이지 한정 및 커버링 인덱스) */ + CursorPageResponse findPostsByOffset(PostSearchRequest request); + + /** 모바일 Keyset Cursor 기반 동적 목록 조회 ($O(1)$ 탐색) */ + CursorPageResponse findPostsByCursor(PostSearchRequest request); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustomImpl.java b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustomImpl.java new file mode 100644 index 0000000..b4a4824 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustomImpl.java @@ -0,0 +1,185 @@ +package com.ikae.snowthing.domain.post.repository; + +import static com.ikae.snowthing.domain.post.entity.QPost.post; +import static com.ikae.snowthing.domain.post.entity.QPostCategory.postCategory; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import com.ikae.snowthing.domain.post.dto.PostListResponse; +import com.ikae.snowthing.domain.post.dto.PostSearchRequest; +import com.ikae.snowthing.domain.post.dto.SearchType; +import com.ikae.snowthing.domain.post.dto.SortType; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.global.common.dto.CursorPageResponse; +import com.ikae.snowthing.global.common.dto.CursorPageResponse.PageInfo; +import com.ikae.snowthing.global.util.CursorUtils; +import com.ikae.snowthing.global.util.CursorUtils.CursorValue; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class PostRepositoryCustomImpl implements PostRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public CursorPageResponse findPostsByOffset(PostSearchRequest request) { + int page = (request.page() != null && request.page() > 0) ? request.page() : 1; + int size = request.size(); + int offset = (page - 1) * size; + + // 1. 전체 개수 Count 쿼리 + Long totalCount = + queryFactory + .select(post.count()) + .from(post) + .join(post.category, postCategory) + .where( + categoryEq(request.categoryCode()), + searchCondition(request.searchType(), request.keyword()), + post.status.eq( + com.ikae.snowthing.domain.post.entity.PostStatus.NORMAL), + post.isDeleted.isFalse()) + .fetchOne(); + + long total = totalCount != null ? totalCount : 0L; + int totalPages = (int) Math.ceil((double) total / size); + + if (total == 0 || offset >= total) { + return new CursorPageResponse<>( + List.of(), PageInfo.ofOffset(page, totalPages, total, false, size)); + } + + // 2. 커버링 인덱스 (Deferred Join) 개념의 PK 선별 쿼리 + List postIds = + queryFactory + .select(post.id) + .from(post) + .join(post.category, postCategory) + .where( + categoryEq(request.categoryCode()), + searchCondition(request.searchType(), request.keyword()), + post.status.eq( + com.ikae.snowthing.domain.post.entity.PostStatus.NORMAL), + post.isDeleted.isFalse()) + .orderBy(getSortOrders(request.sortType())) + .offset(offset) + .limit(size) + .fetch(); + + // 3. PK 리스트 기반 본문 데이터 Fetch + List posts = + queryFactory + .selectFrom(post) + .join(post.category, postCategory) + .fetchJoin() + .leftJoin(post.member) + .fetchJoin() + .where(post.id.in(postIds)) + .orderBy(getSortOrders(request.sortType())) + .fetch(); + + List content = posts.stream().map(PostListResponse::from).toList(); + + boolean hasNext = page < totalPages; + + return new CursorPageResponse<>( + content, PageInfo.ofOffset(page, totalPages, total, hasNext, size)); + } + + @Override + public CursorPageResponse findPostsByCursor(PostSearchRequest request) { + int size = request.size(); + CursorValue cursorValue = CursorUtils.decode(request.cursor()); + + // 1. Keyset Cursor 쿼리 (size + 1 개 요청) + List posts = + queryFactory + .selectFrom(post) + .join(post.category, postCategory) + .fetchJoin() + .leftJoin(post.member) + .fetchJoin() + .where( + categoryEq(request.categoryCode()), + searchCondition(request.searchType(), request.keyword()), + cursorCondition(request.sortType(), cursorValue), + post.status.eq( + com.ikae.snowthing.domain.post.entity.PostStatus.NORMAL), + post.isDeleted.isFalse()) + .orderBy(getSortOrders(request.sortType())) + .limit(size + 1) + .fetch(); + + boolean hasNext = posts.size() > size; + List resultPosts = hasNext ? posts.subList(0, size) : posts; + + List content = resultPosts.stream().map(PostListResponse::from).toList(); + + String nextCursor = null; + if (hasNext && !resultPosts.isEmpty()) { + Post lastPost = resultPosts.get(resultPosts.size() - 1); + nextCursor = + CursorUtils.encode( + (long) lastPost.getLikeCount(), + lastPost.getCreatedAt(), + lastPost.getId()); + } + + return new CursorPageResponse<>(content, PageInfo.ofCursor(nextCursor, hasNext, size)); + } + + // --- 공통 BooleanExpression 검색 조건절 --- + + private BooleanExpression categoryEq(String categoryCode) { + if (!StringUtils.hasText(categoryCode)) return null; + return postCategory.code.equalsIgnoreCase(categoryCode); + } + + private BooleanExpression searchCondition(SearchType type, String keyword) { + if (!StringUtils.hasText(keyword) || type == null) return null; + return switch (type) { + case TITLE -> post.title.containsIgnoreCase(keyword); + case WRITER -> post.member.nickname.containsIgnoreCase(keyword); + case CONTENT -> post.content.containsIgnoreCase(keyword); + case TITLE_CONTENT -> + post.title + .containsIgnoreCase(keyword) + .or(post.content.containsIgnoreCase(keyword)); + }; + } + + private BooleanExpression cursorCondition(SortType sortType, CursorValue cursor) { + if (cursor == null || cursor.id() == null) return null; + + if (sortType == SortType.POPULAR) { + // 인기순 복합 커서: (likeCount < lastLike) OR (likeCount == lastLike AND id < lastId) + return post.likeCount + .lt(cursor.likeCount().intValue()) + .or( + post.likeCount + .eq(cursor.likeCount().intValue()) + .and(post.id.lt(cursor.id()))); + } else { + // 최신순 커서: id < lastId + return post.id.lt(cursor.id()); + } + } + + private OrderSpecifier[] getSortOrders(SortType sortType) { + List> orders = new ArrayList<>(); + if (sortType == SortType.POPULAR) { + orders.add(post.likeCount.desc()); + } + orders.add(post.id.desc()); + return orders.toArray(new OrderSpecifier[0]); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java b/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java new file mode 100644 index 0000000..d6a14a8 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java @@ -0,0 +1,404 @@ +package com.ikae.snowthing.domain.post.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.*; +import com.ikae.snowthing.domain.post.entity.*; +import com.ikae.snowthing.domain.post.event.PostReactionEvent; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.repository.PostReactionRepository; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PostService { + + private static final String ANONYMOUS_CATEGORY_CODE = "ANONYMOUS"; + private static final String DEFAULT_WRITER_IP = "127.0.0.1"; + private static final int FIRST_IMAGE_SORT_ORDER = 1; + private static final int MAX_OFFSET_PAGE = 100; + private static final int MIN_PAGE_SIZE = 1; + private static final int MAX_PAGE_SIZE = 100; + private static final String SORT_CREATED_AT = "createdAt"; + private static final String SORT_ID = "id"; + + private final PostRepository postRepository; + private final PostCategoryRepository categoryRepository; + private final PostReactionRepository reactionRepository; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public PostResponse createPost( + PostCreateRequest request, CustomUserDetails userDetails, String clientIp) { + PostCategory category = + categoryRepository + .findByCode(request.categoryCode()) + .orElseThrow( + () -> new CustomAuthException(ErrorCode.POST_CATEGORY_NOT_FOUND)); + + Member member = null; + String encodedPassword = null; + + boolean isAnon = + request.isAnonymous() + || ANONYMOUS_CATEGORY_CODE.equalsIgnoreCase(category.getCode()); + + if (isAnon) { + if (userDetails != null) { + member = memberRepository.findByPublicId(userDetails.getPublicId()).orElse(null); + } + if (member == null + && (request.anonymousPassword() == null + || request.anonymousPassword().isBlank())) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + if (request.anonymousPassword() != null && !request.anonymousPassword().isBlank()) { + encodedPassword = passwordEncoder.encode(request.anonymousPassword()); + } + } else { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.INVALID_CREDENTIALS); + } + member = + memberRepository + .findByPublicId(userDetails.getPublicId()) + .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); + } + + Post post = + Post.builder() + .member(member) + .category(category) + .title(request.title()) + .content(request.content()) + .writerIp(resolveWriterIp(clientIp)) + .isAnonymous(isAnon) + .anonymousPassword(encodedPassword) + .hasImage(false) + .build(); + post.replaceImages(toPostImages(request.imageUrls())); + + return PostResponse.from(postRepository.save(post)); + } + + @Transactional + public PostDetailResponse getPostDetail(String publicId, CustomUserDetails userDetails) { + return getPostDetail(publicId, userDetails, true); + } + + @Transactional + public PostDetailResponse getPostDetail( + String publicId, CustomUserDetails userDetails, boolean shouldIncreaseViewCount) { + Post post = + postRepository + .findWithMemberAndCategoryByPublicId(publicId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted()) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + if (post.getStatus() != PostStatus.NORMAL) { + if (!isAdmin(userDetails)) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + } + + List imageUrls = post.getImages().stream().map(PostImage::getImageUrl).toList(); + int viewCount = post.getViewCount(); + if (shouldIncreaseViewCount) { + postRepository.increaseViewCount(post.getId()); + viewCount++; + } + return PostDetailResponse.from(post, viewCount, imageUrls); + } + + public Page getPostList(String categoryCode, int page, int size) { + if (page > MAX_OFFSET_PAGE) { + throw new CustomAuthException(ErrorCode.INVALID_PAGE_LIMIT); + } + if (size < MIN_PAGE_SIZE || size > MAX_PAGE_SIZE) { + throw new CustomAuthException(ErrorCode.INVALID_PAGE_SIZE); + } + + Pageable pageable = + PageRequest.of( + page, + size, + Sort.by(Sort.Direction.DESC, SORT_CREATED_AT) + .and(Sort.by(Sort.Direction.DESC, SORT_ID))); + + Page posts; + if (categoryCode != null && !categoryCode.isBlank()) { + posts = postRepository.findByCategoryCodeWithMemberAndCategory(categoryCode, pageable); + } else { + posts = postRepository.findAllWithMemberAndCategory(pageable); + } + + return posts.map(PostListResponse::from); + } + + public com.ikae.snowthing.global.common.dto.CursorPageResponse + searchPostsByOffset(PostSearchRequest request) { + if (request.page() != null && request.page() > MAX_OFFSET_PAGE) { + throw new CustomAuthException(ErrorCode.INVALID_PAGE_LIMIT); + } + if (request.size() < MIN_PAGE_SIZE || request.size() > MAX_PAGE_SIZE) { + throw new CustomAuthException(ErrorCode.INVALID_PAGE_SIZE); + } + return postRepository.findPostsByOffset(request); + } + + public com.ikae.snowthing.global.common.dto.CursorPageResponse + searchPostsByCursor(PostSearchRequest request) { + if (request.size() < MIN_PAGE_SIZE || request.size() > MAX_PAGE_SIZE) { + throw new CustomAuthException(ErrorCode.INVALID_PAGE_SIZE); + } + return postRepository.findPostsByCursor(request); + } + + @Transactional + public PostResponse updatePost( + String publicId, PostUpdateRequest request, CustomUserDetails userDetails) { + Post post = + postRepository + .findByPublicId(publicId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted() || post.getStatus() != PostStatus.NORMAL) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + // 수정 권한: 작성자 본인만 수정 가능 (관리자 수정 불가) + validateEditPermission(post, request.anonymousPassword(), userDetails); + + PostCategory category = + categoryRepository + .findByCode(request.categoryCode()) + .orElseThrow( + () -> new CustomAuthException(ErrorCode.POST_CATEGORY_NOT_FOUND)); + + post.update(request.title(), request.content(), category); + post.replaceImages(toPostImages(request.imageUrls())); + return PostResponse.from(post); + } + + @Transactional + public void deletePost( + String publicId, String anonymousPassword, CustomUserDetails userDetails) { + Post post = + postRepository + .findByPublicId(publicId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted()) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + boolean isAdmin = isAdmin(userDetails); + + validateDeletePermission(post, anonymousPassword, userDetails, isAdmin); + + post.softDelete(); + } + + @Transactional + public ReactionToggleResponse reactToPost( + String publicId, + ReactionType type, + CustomUserDetails userDetails, + String clientIp, + String anonymousVoterId) { + Post post = + postRepository + .findByPublicId(publicId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted() || post.getStatus() != PostStatus.NORMAL) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + Member member = resolveMember(userDetails); + ReactionActor actor = + member != null + ? ReactionActor.member(member.getId(), resolveWriterIp(clientIp)) + : ReactionActor.anonymous( + resolveAnonymousVoterId(anonymousVoterId), + resolveWriterIp(clientIp)); + + Optional existing = findExistingReaction(post.getId(), actor, type); + + boolean isToggledOn; + int likeCount = post.getLikeCount(); + int dislikeCount = post.getDislikeCount(); + if (existing.isPresent()) { + // TOGGLE OFF -> 기존 투표 레코드 삭제 및 해당 카운트 차감 + reactionRepository.delete(existing.get()); + if (type == ReactionType.LIKE) { + postRepository.decreaseLikeCount(post.getId()); + likeCount = Math.max(0, likeCount - 1); + } else { + postRepository.decreaseDislikeCount(post.getId()); + dislikeCount = Math.max(0, dislikeCount - 1); + } + isToggledOn = false; + } else { + // TOGGLE ON -> 신규 투표 레코드 추가 및 해당 카운트 증가 + PostReaction reaction = + PostReaction.builder() + .post(post) + .member(member) + .writerIp(actor.writerIp()) + .anonymousVoterId(actor.anonymousVoterId()) + .type(type) + .build(); + reactionRepository.save(reaction); + + if (type == ReactionType.LIKE) { + postRepository.increaseLikeCount(post.getId()); + likeCount++; + } else { + postRepository.increaseDislikeCount(post.getId()); + dislikeCount++; + } + isToggledOn = true; + } + + eventPublisher.publishEvent(new PostReactionEvent(post.getId(), type)); + + String msg = + isToggledOn + ? (type == ReactionType.LIKE ? "추천했습니다!" : "비추천했습니다!") + : (type == ReactionType.LIKE ? "추천을 취소했습니다." : "비추천을 취소했습니다."); + + return ReactionToggleResponse.builder() + .isToggledOn(isToggledOn) + .type(type.name()) + .likeCount(likeCount) + .dislikeCount(dislikeCount) + .message(msg) + .build(); + } + + private void validateEditPermission( + Post post, String anonymousPassword, CustomUserDetails userDetails) { + if (post.isAnonymous()) { + // 로그인 유저 본인이 쓴 익명 글인 경우 수정 허용 + if (userDetails != null + && post.getMember() != null + && post.getMember().getPublicId().equals(userDetails.getPublicId())) { + return; + } + // 비밀번호 기반 익명글 수정 검증 + if (post.getAnonymousPassword() == null + || !org.springframework.util.StringUtils.hasText(anonymousPassword) + || !passwordEncoder.matches(anonymousPassword, post.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); + } + } else { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + boolean isWriter = + post.getMember() != null + && post.getMember().getPublicId().equals(userDetails.getPublicId()); + if (!isWriter) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + } + } + + private void validateDeletePermission( + Post post, String anonymousPassword, CustomUserDetails userDetails, boolean isAdmin) { + if (post.isAnonymous()) { + if (isAdmin + || (userDetails != null + && post.getMember() != null + && post.getMember().getPublicId().equals(userDetails.getPublicId()))) { + return; + } + if (post.getAnonymousPassword() == null + || !org.springframework.util.StringUtils.hasText(anonymousPassword) + || !passwordEncoder.matches(anonymousPassword, post.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); + } + } else { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + boolean isWriter = + post.getMember() != null + && post.getMember().getPublicId().equals(userDetails.getPublicId()); + if (!isAdmin && !isWriter) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + } + } + + private Member resolveMember(CustomUserDetails userDetails) { + if (userDetails == null) { + return null; + } + return memberRepository.findByPublicId(userDetails.getPublicId()).orElse(null); + } + + private Optional findExistingReaction( + Long postId, ReactionActor actor, ReactionType type) { + if (actor.isMember()) { + return reactionRepository.findByPostIdAndMemberIdAndType( + postId, actor.memberId(), type); + } + return reactionRepository.findByPostIdAndAnonymousVoterIdAndType( + postId, actor.anonymousVoterId(), type); + } + + private String resolveAnonymousVoterId(String anonymousVoterId) { + if (anonymousVoterId == null || anonymousVoterId.isBlank()) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + return anonymousVoterId; + } + + private String resolveWriterIp(String clientIp) { + return clientIp != null && !clientIp.isBlank() ? clientIp : DEFAULT_WRITER_IP; + } + + private boolean isAdmin(CustomUserDetails userDetails) { + return userDetails != null + && userDetails.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals(Role.ROLE_ADMIN.getKey())); + } + + private List toPostImages(List imageUrls) { + List images = new ArrayList<>(); + int sortOrder = FIRST_IMAGE_SORT_ORDER; + for (String imageUrl : imageUrls) { + images.add(PostImage.builder().imageUrl(imageUrl).sortOrder(sortOrder++).build()); + } + return images; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/service/ReactionActor.java b/backend/src/main/java/com/ikae/snowthing/domain/post/service/ReactionActor.java new file mode 100644 index 0000000..3a98a6a --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/service/ReactionActor.java @@ -0,0 +1,16 @@ +package com.ikae.snowthing.domain.post.service; + +public record ReactionActor(Long memberId, String anonymousVoterId, String writerIp) { + + public static ReactionActor member(Long memberId, String writerIp) { + return new ReactionActor(memberId, null, writerIp); + } + + public static ReactionActor anonymous(String anonymousVoterId, String writerIp) { + return new ReactionActor(null, anonymousVoterId, writerIp); + } + + public boolean isMember() { + return memberId != null; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/common/BaseTimeEntity.java b/backend/src/main/java/com/ikae/snowthing/global/common/BaseTimeEntity.java index a1659f9..dd1bae0 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/common/BaseTimeEntity.java +++ b/backend/src/main/java/com/ikae/snowthing/global/common/BaseTimeEntity.java @@ -1,14 +1,16 @@ package com.ikae.snowthing.global.common; +import java.time.LocalDateTime; + import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; -import lombok.Getter; + import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import java.time.LocalDateTime; +import lombok.Getter; @Getter @MappedSuperclass diff --git a/backend/src/main/java/com/ikae/snowthing/global/common/dto/CursorPageResponse.java b/backend/src/main/java/com/ikae/snowthing/global/common/dto/CursorPageResponse.java new file mode 100644 index 0000000..01f8128 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/common/dto/CursorPageResponse.java @@ -0,0 +1,26 @@ +package com.ikae.snowthing.global.common.dto; + +import java.util.List; + +/** 웹 Offset 및 모바일 Keyset Cursor 공통 Wrapper 응답 DTO */ +public record CursorPageResponse(List content, PageInfo pageInfo) { + public record PageInfo( + Integer page, // 웹용: 현재 페이지 번호 (모바일 시 null) + Integer totalPages, // 웹용: 전체 페이지 수 (모바일 시 null) + Long totalElements, // 웹용: 전체 레코드 수 (모바일 시 null) + String nextCursor, // 모바일용: 다음 커서 토큰 (웹 시 null) + Boolean hasNext, // 공통: 다음 데이터 존재 여부 + Integer pageSize // 공통: 한 페이지 크기 + ) { + // 웹 Offset용 팩토리 메서드 + public static PageInfo ofOffset( + int page, int totalPages, long totalElements, boolean hasNext, int pageSize) { + return new PageInfo(page, totalPages, totalElements, null, hasNext, pageSize); + } + + // 모바일 Cursor용 팩토리 메서드 + public static PageInfo ofCursor(String nextCursor, boolean hasNext, int pageSize) { + return new PageInfo(null, null, null, nextCursor, hasNext, pageSize); + } + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/AsyncConfig.java b/backend/src/main/java/com/ikae/snowthing/global/config/AsyncConfig.java new file mode 100644 index 0000000..c57ad8b --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/config/AsyncConfig.java @@ -0,0 +1,46 @@ +package com.ikae.snowthing.global.config; + +import java.util.concurrent.Executor; + +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import lombok.extern.slf4j.Slf4j; + +/** + * 비동기 실행을 위한 표준 플랫폼 스레드 풀(Platform Thread Pool) 설정 + * + *

[아키텍처 설계 배경]: 1. 가상 스레드 조기 최적화(Premature Optimization) 배제: - BCrypt 암호화 연산 병목, HikariCP 커넥션 풀 + * 고갈 위험, Thread Pinning 방지 등을 고려하여 안정적인 고정 스레드 풀 모델 채택. 2. ThreadPoolTaskExecutor 구성: - Core Pool + * Size: 8 (CPU 코어 기반) - Max Pool Size: 16 - Queue Capacity: 100 - Thread Name Prefix: + * "async-worker-" 3. 예외 핸들링: - void 반환 비동기 메서드의 미처리 예외(Uncaught Exception) 로깅 일원화. + */ +@Slf4j +@Configuration +@EnableAsync +public class AsyncConfig implements AsyncConfigurer { + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(8); + executor.setMaxPoolSize(16); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("async-worker-"); + executor.initialize(); + return executor; + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return (throwable, method, params) -> + log.error( + "[Async Error] 비동기 메서드 '{}' 실행 중 예외 발생: {}", + method.getName(), + throwable.getMessage(), + throwable); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java index 5a1114b..256989b 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java @@ -1,44 +1,143 @@ package com.ikae.snowthing.global.config; +import java.util.List; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.member.entity.Resort; import com.ikae.snowthing.domain.member.entity.RidingStyle; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; import com.ikae.snowthing.domain.member.repository.ResortRepository; import com.ikae.snowthing.domain.member.repository.RidingStyleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.CommandLineRunner; -import org.springframework.stereotype.Component; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.repository.PostRepository; -import java.util.List; +import lombok.RequiredArgsConstructor; @Component +@Profile("!test") @RequiredArgsConstructor public class DataInitializer implements CommandLineRunner { + private final MemberRepository memberRepository; private final ResortRepository resortRepository; private final RidingStyleRepository ridingStyleRepository; + private final PostCategoryRepository categoryRepository; + private final PostRepository postRepository; + private final PasswordEncoder passwordEncoder; @Override public void run(String... args) { + if (!memberRepository.existsByEmail("user@snowthing.com")) { + memberRepository.save( + Member.builder() + .email("user@snowthing.com") + .password(passwordEncoder.encode("123123")) + .nickname("스노보더1") + .role(Role.ROLE_USER) + .build()); + } + + if (!memberRepository.existsByEmail("admin@snowthing.com")) { + memberRepository.save( + Member.builder() + .email("admin@snowthing.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("최고관리자") + .role(Role.ROLE_ADMIN) + .build()); + } + if (resortRepository.count() == 0) { - resortRepository.saveAll(List.of( - Resort.builder().name("휘닉스파크").regionName("강원 평창").build(), - Resort.builder().name("하이원리조트").regionName("강원 정선").build(), - Resort.builder().name("모나용평").regionName("강원 평창").build(), - Resort.builder().name("비발디파크").regionName("강원 홍천").build(), - Resort.builder().name("웰리힐리파크").regionName("강원 횡성").build(), - Resort.builder().name("지산리조트").regionName("경기 이천").build() - )); + resortRepository.saveAll( + List.of( + Resort.builder().name("휘닉스파크").regionName("강원 평창").build(), + Resort.builder().name("하이원리조트").regionName("강원 정선").build(), + Resort.builder().name("모나용평").regionName("강원 평창").build(), + Resort.builder().name("비발디파크").regionName("강원 홍천").build(), + Resort.builder().name("웰리힐리파크").regionName("강원 횡성").build(), + Resort.builder().name("지산리조트").regionName("경기 이천").build())); } if (ridingStyleRepository.count() == 0) { - ridingStyleRepository.saveAll(List.of( - RidingStyle.builder().styleName("올라운드").description("슬로프, 트릭, 파크 등을 가리지 않고 다양하게 즐기는 스타일").build(), - RidingStyle.builder().styleName("라이딩 / 카빙").description("슬로프 고속 라이딩 및 칼날 카빙").build(), - RidingStyle.builder().styleName("그라운드 트릭").description("평지 버터링, 알리, 스핀 트릭").build(), - RidingStyle.builder().styleName("파크 / 기물 / 파이프").description("킥커 점프, 레일, 하프파이프").build(), - RidingStyle.builder().styleName("입문 / 초보").description("기초 자세 및 B턴, J턴 습득 중").build(), - RidingStyle.builder().styleName("관광 / 크루징").description("풍경 감상 및 여유로운 라이딩").build() - )); + ridingStyleRepository.saveAll( + List.of( + RidingStyle.builder() + .styleName("올라운드") + .description("슬로프, 트릭, 파크 등을 가리지 않고 다양하게 즐기는 스타일") + .build(), + RidingStyle.builder() + .styleName("라이딩 / 카빙") + .description("슬로프 고속 라이딩 및 칼날 카빙") + .build(), + RidingStyle.builder() + .styleName("그라운드 트릭") + .description("평지 버터링, 알리, 스핀 트릭") + .build(), + RidingStyle.builder() + .styleName("파크 / 기물 / 파이프") + .description("킥커 점프, 레일, 하프파이프") + .build(), + RidingStyle.builder() + .styleName("입문 / 초보") + .description("기초 자세 및 B턴, J턴 습득 중") + .build(), + RidingStyle.builder() + .styleName("관광 / 크루징") + .description("풍경 감상 및 여유로운 라이딩") + .build())); + } + + if (categoryRepository.count() == 0) { + categoryRepository.saveAll( + List.of( + PostCategory.builder().name("자유게시판").code("FREE").build(), + PostCategory.builder().name("익명게시판").code("ANONYMOUS").build(), + PostCategory.builder().name("질문게시판").code("QNA").build(), + PostCategory.builder().name("장비VS").code("GEAR_VS").build(), + PostCategory.builder().name("맛집게시판").code("FOOD").build())); + } + + if (postRepository.count() == 0) { + PostCategory freeCat = categoryRepository.findByCode("FREE").orElse(null); + PostCategory anonCat = categoryRepository.findByCode("ANONYMOUS").orElse(null); + + if (freeCat != null && anonCat != null) { + String encPass = passwordEncoder.encode("1234"); + // 자유게시판 더미 15건 + for (int i = 1; i <= 15; i++) { + postRepository.save( + Post.builder() + .category(freeCat) + .title("테스트 자유 게시글 " + i) + .content("테스트 자유 게시글 본문 내용 " + i + "입니다. 슬로프 설질 및 장비 정보 공유!") + .writerIp("127.0.0.1") + .isAnonymous(true) + .anonymousPassword(encPass) + .hasImage(i % 2 == 0) + .build()); + } + // 익명게시판 더미 15건 + for (int i = 1; i <= 15; i++) { + postRepository.save( + Post.builder() + .category(anonCat) + .title("익명 게시판 테스트 " + i) + .content("익명 게시판 본문 내용 " + i + "입니다. 가감 없는 솔직한 생각 공유!") + .writerIp("127.0.0.1") + .isAnonymous(true) + .anonymousPassword(encPass) + .hasImage(i % 3 == 0) + .build()); + } + } } } } diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/QuerydslConfig.java b/backend/src/main/java/com/ikae/snowthing/global/config/QuerydslConfig.java new file mode 100644 index 0000000..5ccbede --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/config/QuerydslConfig.java @@ -0,0 +1,20 @@ +package com.ikae.snowthing.global.config; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java index c8cef43..6926451 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -1,13 +1,13 @@ package com.ikae.snowthing.global.error; +import org.springframework.http.HttpStatus; + import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor public enum ErrorCode { - INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_001", "이메일 또는 비밀번호가 일치하지 않습니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "AUTH_002", "해당 작업을 수행할 권한이 없습니다."), MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_001", "존재하지 않는 회원입니다."), @@ -17,7 +17,10 @@ public enum ErrorCode { POST_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_002", "존재하지 않는 게시판 카테고리입니다."), ALREADY_REACTED(HttpStatus.CONFLICT, "POST_003", "이미 추천 또는 비추천 투표를 완료한 게시글입니다."), INVALID_ANON_PASSWORD(HttpStatus.FORBIDDEN, "POST_004", "비회원 익명 비밀번호가 일치하지 않습니다."), - INVALID_PAGE_LIMIT(HttpStatus.BAD_REQUEST, "POST_005", "게시판 조회의 최대 한계선은 100페이지(2,000개 글)까지입니다. 더 이전 글은 검색 기능을 이용해 주세요."), + INVALID_PAGE_LIMIT( + HttpStatus.BAD_REQUEST, + "POST_005", + "게시판 조회의 최대 한계선은 100페이지(2,000개 글)까지입니다. 더 이전 글은 검색 기능을 이용해 주세요."), COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_001", "존재하지 않거나 이미 삭제된 댓글입니다."), PARENT_COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_002", "존재하지 않는 부모 댓글입니다."), INVALID_COMMENT_PARENT(HttpStatus.BAD_REQUEST, "COMMENT_003", "동일한 게시글의 댓글에만 대댓글을 달 수 있습니다."), diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorResponse.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorResponse.java index 0e923c0..3d391f4 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorResponse.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorResponse.java @@ -1,10 +1,6 @@ package com.ikae.snowthing.global.error; -public record ErrorResponse( - String code, - String error, - String message -) { +public record ErrorResponse(String code, String error, String message) { public static ErrorResponse from(ErrorCode errorCode) { return new ErrorResponse(errorCode.getCode(), errorCode.name(), errorCode.getMessage()); } diff --git a/backend/src/main/java/com/ikae/snowthing/global/exception/CustomAuthException.java b/backend/src/main/java/com/ikae/snowthing/global/exception/CustomAuthException.java index 55331e6..73598d1 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/exception/CustomAuthException.java +++ b/backend/src/main/java/com/ikae/snowthing/global/exception/CustomAuthException.java @@ -1,6 +1,7 @@ package com.ikae.snowthing.global.exception; import com.ikae.snowthing.global.error.ErrorCode; + import lombok.Getter; @Getter diff --git a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java index c001f55..5c93c9d 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java @@ -1,7 +1,6 @@ package com.ikae.snowthing.global.exception; -import com.ikae.snowthing.global.error.ErrorCode; -import com.ikae.snowthing.global.error.ErrorResponse; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; @@ -10,15 +9,30 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.error.ErrorResponse; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(CustomAuthException.class) public ResponseEntity handleCustomAuthException(CustomAuthException e) { - ErrorCode errorCode = e.getErrorCode() != null ? e.getErrorCode() : ErrorCode.INVALID_CREDENTIALS; + ErrorCode errorCode = + e.getErrorCode() != null ? e.getErrorCode() : ErrorCode.INVALID_CREDENTIALS; return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.from(errorCode)); } + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException( + DataIntegrityViolationException e) { + log.warn("데이터 무결성 제약조건 위반 발생: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, "요청 데이터의 고유 제약조건 또는 무결성을 위반했습니다.")); + } + @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) public ResponseEntity handleAuthenticationException(Exception e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) @@ -26,15 +40,29 @@ public ResponseEntity handleAuthenticationException(Exception e) } @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + public ResponseEntity handleIllegalArgumentException( + IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, e.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { + public ResponseEntity handleValidationException( + MethodArgumentNotValidException e) { String defaultMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, defaultMessage != null ? defaultMessage : ErrorCode.INVALID_INPUT.getMessage())); + .body( + ErrorResponse.of( + ErrorCode.INVALID_INPUT, + defaultMessage != null + ? defaultMessage + : ErrorCode.INVALID_INPUT.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception e) { + log.error("서버 내부 미처리 예외 발생: ", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.from(ErrorCode.INTERNAL_SERVER_ERROR)); } } diff --git a/backend/src/main/java/com/ikae/snowthing/global/security/CsrfController.java b/backend/src/main/java/com/ikae/snowthing/global/security/CsrfController.java index ae112a1..7a26061 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/security/CsrfController.java +++ b/backend/src/main/java/com/ikae/snowthing/global/security/CsrfController.java @@ -1,12 +1,13 @@ package com.ikae.snowthing.global.security; -import lombok.RequiredArgsConstructor; +import java.util.Map; + import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; +import lombok.RequiredArgsConstructor; @RestController @RequestMapping("/api/v1/csrf") @@ -16,9 +17,8 @@ public class CsrfController { @GetMapping public Map csrf(CsrfToken csrfToken) { return Map.of( - "headerName", csrfToken.getHeaderName(), - "parameterName", csrfToken.getParameterName(), - "token", csrfToken.getToken() - ); + "headerName", csrfToken.getHeaderName(), + "parameterName", csrfToken.getParameterName(), + "token", csrfToken.getToken()); } } diff --git a/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetails.java b/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetails.java index 6a2bd0a..2f86b8e 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetails.java +++ b/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetails.java @@ -1,14 +1,16 @@ package com.ikae.snowthing.global.security; -import com.ikae.snowthing.domain.member.entity.Member; -import lombok.Getter; -import lombok.RequiredArgsConstructor; +import java.util.Collection; +import java.util.List; + import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.util.Collection; -import java.util.List; +import com.ikae.snowthing.domain.member.entity.Member; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor diff --git a/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetailsService.java b/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetailsService.java index 9352b7d..a567869 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetailsService.java +++ b/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetailsService.java @@ -1,15 +1,16 @@ package com.ikae.snowthing.global.security; -import com.ikae.snowthing.domain.member.entity.Member; -import com.ikae.snowthing.domain.member.repository.MemberRepository; -import com.ikae.snowthing.global.error.ErrorCode; -import com.ikae.snowthing.global.exception.CustomAuthException; -import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.global.error.ErrorCode; + +import lombok.RequiredArgsConstructor; + @Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { @@ -18,8 +19,13 @@ public class CustomUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - Member member = memberRepository.findByEmail(email) - .orElseThrow(() -> new UsernameNotFoundException(ErrorCode.MEMBER_NOT_FOUND.getMessage())); + Member member = + memberRepository + .findByEmail(email) + .orElseThrow( + () -> + new UsernameNotFoundException( + ErrorCode.MEMBER_NOT_FOUND.getMessage())); return new CustomUserDetails(member); } } diff --git a/backend/src/main/java/com/ikae/snowthing/global/util/CursorUtils.java b/backend/src/main/java/com/ikae/snowthing/global/util/CursorUtils.java new file mode 100644 index 0000000..ce366c6 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/util/CursorUtils.java @@ -0,0 +1,47 @@ +package com.ikae.snowthing.global.util; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; + +import org.springframework.util.StringUtils; + +/** 무상태 복합 커서 토큰 (Opaque Cursor Token) Base64 유틸리티. 추후 Elasticsearch search_after 확장 대비 추상화. */ +public class CursorUtils { + + public record CursorValue(Long likeCount, LocalDateTime createdAt, Long id) {} + + /** 최신순 (id) 또는 인기순 (likeCount + id) 커서 토큰 인코딩 */ + public static String encode(Long likeCount, LocalDateTime createdAt, Long id) { + if (id == null) return null; + String raw = + String.format( + "%d_%s_%d", + likeCount != null ? likeCount : 0L, + createdAt != null ? createdAt.toString() : "", + id); + return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + /** Base64 커서 토큰 디코딩 */ + public static CursorValue decode(String cursorToken) { + if (!StringUtils.hasText(cursorToken)) { + return null; + } + try { + byte[] decodedBytes = Base64.getDecoder().decode(cursorToken); + String decodedStr = new String(decodedBytes, StandardCharsets.UTF_8); + String[] parts = decodedStr.split("_"); + if (parts.length < 3) { + return null; + } + Long likeCount = Long.parseLong(parts[0]); + LocalDateTime createdAt = + StringUtils.hasText(parts[1]) ? LocalDateTime.parse(parts[1]) : null; + Long id = Long.parseLong(parts[2]); + return new CursorValue(likeCount, createdAt, id); + } catch (Exception e) { + return null; + } + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/util/WriterDisplayFormatter.java b/backend/src/main/java/com/ikae/snowthing/global/util/WriterDisplayFormatter.java new file mode 100644 index 0000000..18c6fb4 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/util/WriterDisplayFormatter.java @@ -0,0 +1,49 @@ +package com.ikae.snowthing.global.util; + +import com.ikae.snowthing.domain.member.entity.Member; + +/** 작성자 표시명 포맷팅 및 IP 마스킹 공통 유틸리티 */ +public final class WriterDisplayFormatter { + + private static final String ANONYMOUS_FORMAT = "익명 (%s)"; + private static final String DEFAULT_MASKED_IP = "127.0.***.***"; + private static final String UNKNOWN_WRITER = "알 수 없음"; + + private WriterDisplayFormatter() {} + + public static String format(boolean isAnonymous, Member member, String writerIp) { + if (isAnonymous) { + return formatAnonymous(writerIp); + } + return (member != null && member.getNickname() != null) + ? member.getNickname() + : UNKNOWN_WRITER; + } + + public static String formatAnonymous(String writerIp) { + return String.format(ANONYMOUS_FORMAT, maskIp(writerIp)); + } + + public static String maskIp(String ip) { + if (ip == null || ip.isBlank() || "0:0:0:0:0:0:0:1".equals(ip) || "::1".equals(ip)) { + return DEFAULT_MASKED_IP; + } + + // IPv4 처리 (4개 옥텟) + String[] ipv4Parts = ip.split("\\."); + if (ipv4Parts.length == 4) { + return ipv4Parts[0] + "." + ipv4Parts[1] + ".***.***"; + } + + // IPv6 처리 (콜론 구분) + if (ip.contains(":")) { + String[] ipv6Parts = ip.split(":"); + if (ipv6Parts.length >= 2 && !ipv6Parts[0].isBlank() && !ipv6Parts[1].isBlank()) { + return ipv6Parts[0] + ":" + ipv6Parts[1] + ":****:****"; + } + } + + // 비정상 포맷의 경우 원문 노출 방지를 위해 기본 마스킹값 반환 + return DEFAULT_MASKED_IP; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/web/AnonymousVoterCookieManager.java b/backend/src/main/java/com/ikae/snowthing/global/web/AnonymousVoterCookieManager.java new file mode 100644 index 0000000..7867978 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/web/AnonymousVoterCookieManager.java @@ -0,0 +1,45 @@ +package com.ikae.snowthing.global.web; + +import java.util.UUID; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.stereotype.Component; + +@Component +public class AnonymousVoterCookieManager { + + public static final String ANONYMOUS_VOTER_COOKIE_NAME = "anonymous_voter_id"; + private static final int ANONYMOUS_VOTER_COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60; + private static final String COOKIE_PATH = "/"; + + public String getOrCreate(HttpServletRequest request, HttpServletResponse response) { + Cookie cookie = findCookie(request, ANONYMOUS_VOTER_COOKIE_NAME); + if (cookie != null && cookie.getValue() != null && !cookie.getValue().isBlank()) { + return cookie.getValue(); + } + + String voterId = UUID.randomUUID().toString(); + Cookie newCookie = new Cookie(ANONYMOUS_VOTER_COOKIE_NAME, voterId); + newCookie.setPath(COOKIE_PATH); + newCookie.setMaxAge(ANONYMOUS_VOTER_COOKIE_MAX_AGE_SECONDS); + newCookie.setHttpOnly(true); + response.addCookie(newCookie); + return voterId; + } + + private Cookie findCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + for (Cookie cookie : cookies) { + if (name.equals(cookie.getName())) { + return cookie; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/web/ClientIpResolver.java b/backend/src/main/java/com/ikae/snowthing/global/web/ClientIpResolver.java index cab2d33..f8a7b8d 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/web/ClientIpResolver.java +++ b/backend/src/main/java/com/ikae/snowthing/global/web/ClientIpResolver.java @@ -1,6 +1,7 @@ package com.ikae.snowthing.global.web; import jakarta.servlet.http.HttpServletRequest; + import org.springframework.stereotype.Component; @Component diff --git a/backend/src/main/java/com/ikae/snowthing/global/web/ViewCountCookieManager.java b/backend/src/main/java/com/ikae/snowthing/global/web/ViewCountCookieManager.java new file mode 100644 index 0000000..4f5b274 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/web/ViewCountCookieManager.java @@ -0,0 +1,46 @@ +package com.ikae.snowthing.global.web; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.stereotype.Component; + +@Component +public class ViewCountCookieManager { + + public static final String VIEWED_POSTS_COOKIE_NAME = "viewed_posts"; + private static final int VIEW_COOKIE_MAX_AGE_SECONDS = 30 * 60; + private static final String COOKIE_PATH = "/"; + + public boolean markIfFirstView( + String postPublicId, HttpServletRequest request, HttpServletResponse response) { + Cookie viewCookie = findCookie(request, VIEWED_POSTS_COOKIE_NAME); + String marker = "[" + postPublicId + "]"; + + if (viewCookie != null && viewCookie.getValue().contains(marker)) { + return false; + } + + String value = viewCookie == null ? marker : viewCookie.getValue() + marker; + Cookie cookie = new Cookie(VIEWED_POSTS_COOKIE_NAME, value); + cookie.setPath(COOKIE_PATH); + cookie.setMaxAge(VIEW_COOKIE_MAX_AGE_SECONDS); + cookie.setHttpOnly(true); + response.addCookie(cookie); + return true; + } + + private Cookie findCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + for (Cookie cookie : cookies) { + if (name.equals(cookie.getName())) { + return cookie; + } + } + return null; + } +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml deleted file mode 100644 index 5e65398..0000000 --- a/backend/src/main/resources/application.yaml +++ /dev/null @@ -1,3 +0,0 @@ -spring: - application: - name: snowthing diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index cf7711f..18702da 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -2,32 +2,18 @@ spring: application: name: snowthing + profiles: + default: local + + jpa: + open-in-view: false + autoconfigure: exclude: - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration - datasource: - url: jdbc:h2:file:./data/snowdb;MODE=MySQL;AUTO_SERVER=TRUE;DB_CLOSE_DELAY=-1 - driver-class-name: org.h2.Driver - username: sa - password: - - jpa: - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.H2Dialect - - h2: - console: - enabled: true - path: /h2-console - logging: level: org.hibernate.SQL: debug @@ -40,3 +26,50 @@ server: cookie: http-only: true same-site: lax + +--- +# ========================================== +# [Profile: local] 로컬 개발 환경 (Local / Docker MySQL 8.0 연동) +# ========================================== +spring: + config: + activate: + on-profile: local + + datasource: + url: jdbc:mysql://localhost:3306/snowthing?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + driver-class-name: com.mysql.cj.jdbc.Driver + username: snowuser + password: snowthing_pass_2026! + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + +--- +# ========================================== +# [Profile: docker / prod] Docker Compose 및 운영 컨테이너 환경 +# ========================================== +spring: + config: + activate: + on-profile: docker, prod + + datasource: + url: jdbc:mysql://mysql:3306/snowthing?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + driver-class-name: com.mysql.cj.jdbc.Driver + username: snowuser + password: snowthing_pass_2026! + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect diff --git a/backend/src/test/java/com/ikae/snowthing/SnowthingApplicationTests.java b/backend/src/test/java/com/ikae/snowthing/SnowthingApplicationTests.java index 8349fff..770fa8f 100644 --- a/backend/src/test/java/com/ikae/snowthing/SnowthingApplicationTests.java +++ b/backend/src/test/java/com/ikae/snowthing/SnowthingApplicationTests.java @@ -7,7 +7,5 @@ class SnowthingApplicationTests { @Test - void contextLoads() { - } - + void contextLoads() {} } diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java new file mode 100644 index 0000000..0b1aca6 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/controller/CommentControllerTest.java @@ -0,0 +1,143 @@ +package com.ikae.snowthing.domain.comment.controller; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.service.CommentService; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CommentControllerTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + + @Autowired private MemberRepository memberRepository; + + @Autowired private PostCategoryRepository categoryRepository; + + @Autowired private PostService postService; + + @Autowired private CommentService commentService; + + @Autowired private PasswordEncoder passwordEncoder; + + private Member member; + private CustomUserDetails userDetails; + private PostResponse post; + + @BeforeEach + void setUp() { + categoryRepository + .findByCode("FREE") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder().name("자유게시판").code("FREE").build())); + + member = + memberRepository.save( + Member.builder() + .email("comment_test@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("댓글컨트롤러보더") + .role(Role.ROLE_USER) + .build()); + + userDetails = new CustomUserDetails(member); + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities())); + + post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("댓글 테스트용 게시글") + .content("내용") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + } + + @Test + @DisplayName("POST /api/v1/posts/{publicId}/comments - 댓글 작성 201 Created") + void createComment_success() throws Exception { + CommentCreateRequest request = + CommentCreateRequest.builder().content("통합 테스트 댓글 내용").isAnonymous(false).build(); + + mockMvc.perform( + post("/api/v1/posts/" + post.publicId() + "/comments") + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.commentId").exists()) + .andExpect(jsonPath("$.content").value("통합 테스트 댓글 내용")); + } + + @Test + @DisplayName("GET /api/v1/posts/{publicId}/comments - 댓글 목록 조회 200 OK") + void getComments_success() throws Exception { + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder().content("댓글 1").isAnonymous(false).build(), + userDetails, + "127.0.0.1"); + + mockMvc.perform(get("/api/v1/posts/" + post.publicId() + "/comments")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.comments").isArray()) + .andExpect(jsonPath("$.totalCommentCount").value(1)); + } + + @Test + @DisplayName("DELETE /api/v1/comments/{commentId} - 댓글 Soft Delete 삭제 200 OK") + void deleteComment_success() throws Exception { + CommentResponse comment = + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder().content("삭제될 댓글").isAnonymous(false).build(), + userDetails, + "127.0.0.1"); + + mockMvc.perform( + delete("/api/v1/comments/" + comment.commentId()) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentServiceTest.java new file mode 100644 index 0000000..80e4019 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentServiceTest.java @@ -0,0 +1,204 @@ +package com.ikae.snowthing.domain.comment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.PostCommentListResponse; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@Transactional +class CommentServiceTest { + + @Autowired private CommentService commentService; + + @Autowired private PostService postService; + + @Autowired private MemberRepository memberRepository; + + @Autowired private PostCategoryRepository categoryRepository; + + @Autowired private PasswordEncoder passwordEncoder; + + @Autowired private jakarta.persistence.EntityManager entityManager; + + private Member member1; + private CustomUserDetails userDetails1; + private PostResponse post; + + @BeforeEach + void setUp() { + categoryRepository + .findByCode("FREE") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder().name("자유게시판").code("FREE").build())); + + member1 = + memberRepository.save( + Member.builder() + .email("commenter@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("댓글보더") + .role(Role.ROLE_USER) + .build()); + + userDetails1 = new CustomUserDetails(member1); + + post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("댓글 테스트 게시글") + .content("게시글 본문") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + } + + @Nested + @DisplayName("댓글 작성 테스트") + class CreateCommentTest { + + @Test + @DisplayName("원댓글과 대댓글을 정상적으로 작성한다.") + void createComment_success() { + CommentResponse parent = + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder() + .content("원댓글입니다.") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + + CommentResponse child = + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder() + .parentId(parent.commentId()) + .content("대댓글입니다.") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + + assertThat(parent.commentId()).isNotNull(); + assertThat(child.parentId()).isEqualTo(parent.commentId()); + } + + @Test + @DisplayName("존재하지 않는 부모 댓글 ID로 대댓글 작성 시 404 예외가 터진다.") + void createComment_parentNotFound() { + assertThatThrownBy( + () -> + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder() + .parentId(99999L) + .content("잘못된 부모 대댓글") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PARENT_COMMENT_NOT_FOUND); + } + } + + @Nested + @DisplayName("댓글 트리 계층형 목록 조회 테스트") + class GetCommentsTest { + + @Test + @DisplayName("부모-자식 대댓글 트리 계층 구조가 정상 조립된다.") + void getCommentsByPost_treeStructure() { + CommentResponse parent1 = + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder() + .content("부모 댓글 1") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder() + .parentId(parent1.commentId()) + .content("자식 대댓글 1-1") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + + PostCommentListResponse response = commentService.getCommentsByPost(post.publicId()); + + assertThat(response.totalCommentCount()).isEqualTo(2); + assertThat(response.comments()).hasSize(1); + assertThat(response.comments().get(0).children()).hasSize(1); + assertThat(response.comments().get(0).children().get(0).content()) + .isEqualTo("자식 대댓글 1-1"); + } + + @Test + @DisplayName( + "삭제된 부모 댓글은 Soft Delete 플래그(is_deleted=true, deleted_at NOT NULL)가 기록되고 본문이 '삭제된 댓글입니다.'로 표시된다.") + void getCommentsByPost_deletedParentDisplay() { + CommentResponse parent1 = + commentService.createComment( + post.publicId(), + CommentCreateRequest.builder() + .content("지워질 부모 댓글") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + + commentService.deleteComment(parent1.commentId(), null, userDetails1); + + // 1. 트리 목록 조회 시 본문 마스킹 검증 + PostCommentListResponse response = commentService.getCommentsByPost(post.publicId()); + assertThat(response.comments().get(0).isDeleted()).isTrue(); + assertThat(response.comments().get(0).content()).isEqualTo("삭제된 댓글입니다."); + + // 2. DB 물리 레코드 Soft Delete 상태 검증 + Object[] rawComment = + (Object[]) + entityManager + .createNativeQuery( + "SELECT is_deleted, deleted_at FROM comment WHERE comment_id = :commentId") + .setParameter("commentId", parent1.commentId()) + .getSingleResult(); + + assertThat(rawComment[0]).isEqualTo(true); + assertThat(rawComment[1]).isNotNull(); + } + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MemberControllerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MemberControllerTest.java index af9f68c..667edbe 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MemberControllerTest.java @@ -1,9 +1,11 @@ package com.ikae.snowthing.domain.member.controller; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; -import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse; -import com.ikae.snowthing.domain.member.service.MemberService; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,11 +17,10 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; +import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse; +import com.ikae.snowthing.domain.member.service.MemberService; @ExtendWith(MockitoExtension.class) class MemberControllerTest { @@ -28,11 +29,9 @@ class MemberControllerTest { private final ObjectMapper objectMapper = new ObjectMapper(); - @Mock - private MemberService memberService; + @Mock private MemberService memberService; - @InjectMocks - private MemberController memberController; + @InjectMocks private MemberController memberController; @BeforeEach void setUp() { @@ -43,24 +42,27 @@ void setUp() { @DisplayName("올바른 회원가입 요청 시 201 Created 응답이 반환되어야 한다") void signUp_ValidRequest_Returns201() throws Exception { // given - MemberSignUpRequest request = MemberSignUpRequest.builder() - .email("valid@snowthing.com") - .password("Password123!") - .nickname("정상닉네임") - .build(); - - MemberSignUpResponse response = MemberSignUpResponse.builder() - .publicId("uuid-1234") - .email("valid@snowthing.com") - .nickname("정상닉네임") - .build(); + MemberSignUpRequest request = + MemberSignUpRequest.builder() + .email("valid@snowthing.com") + .password("Password123!") + .nickname("정상닉네임") + .build(); + + MemberSignUpResponse response = + MemberSignUpResponse.builder() + .publicId("uuid-1234") + .email("valid@snowthing.com") + .nickname("정상닉네임") + .build(); given(memberService.signUp(any(MemberSignUpRequest.class))).willReturn(response); // when & then - mockMvc.perform(post("/api/v1/members") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + mockMvc.perform( + post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.publicId").value("uuid-1234")) .andExpect(jsonPath("$.email").value("valid@snowthing.com")) @@ -71,16 +73,18 @@ void signUp_ValidRequest_Returns201() throws Exception { @DisplayName("이메일 형식이 잘못된 요청 시 400 Bad Request 에러가 발생해야 한다") void signUp_InvalidEmail_Returns400() throws Exception { // given - MemberSignUpRequest request = MemberSignUpRequest.builder() - .email("invalid-email-format") - .password("Password123!") - .nickname("정상닉네임") - .build(); + MemberSignUpRequest request = + MemberSignUpRequest.builder() + .email("invalid-email-format") + .password("Password123!") + .nickname("정상닉네임") + .build(); // when & then - mockMvc.perform(post("/api/v1/members") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + mockMvc.perform( + post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); } @@ -88,16 +92,18 @@ void signUp_InvalidEmail_Returns400() throws Exception { @DisplayName("비밀번호 길이가 짧은 요청 시 400 Bad Request 에러가 발생해야 한다") void signUp_ShortPassword_Returns400() throws Exception { // given - MemberSignUpRequest request = MemberSignUpRequest.builder() - .email("valid@snowthing.com") - .password("123") // 4자 미만 (3자) - .nickname("정상닉네임") - .build(); + MemberSignUpRequest request = + MemberSignUpRequest.builder() + .email("valid@snowthing.com") + .password("123") // 4자 미만 (3자) + .nickname("정상닉네임") + .build(); // when & then - mockMvc.perform(post("/api/v1/members") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + mockMvc.perform( + post("/api/v1/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); } } diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/entity/MemberTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/entity/MemberTest.java index 43691c3..899f530 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/member/entity/MemberTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/entity/MemberTest.java @@ -1,21 +1,22 @@ package com.ikae.snowthing.domain.member.entity; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; - class MemberTest { @Test @DisplayName("회원 생성 시 publicId(UUID), 기본 권한(ROLE_USER), 기본 상태(ACTIVE)가 자동 세팅되어야 한다") void createMember_DefaultValues_Success() { // given - Member member = Member.builder() - .email("user@example.com") - .nickname("보더1") - .password("password123!") - .build(); + Member member = + Member.builder() + .email("user@example.com") + .nickname("보더1") + .password("password123!") + .build(); // when member.prePersist(); // @PrePersist 수동 호출 테스트 @@ -32,12 +33,13 @@ void createMember_DefaultValues_Success() { @DisplayName("프로필 수정 시 닉네임, bio, 출발지 정보가 올바르게 업데이트되어야 한다") void updateProfile_Success() { // given - Member member = Member.builder() - .email("user@example.com") - .nickname("보더1") - .bio("안녕하세요") - .departureRegion("서울") - .build(); + Member member = + Member.builder() + .email("user@example.com") + .nickname("보더1") + .bio("안녕하세요") + .departureRegion("서울") + .build(); // when member.updateProfile("휘팍마스터", "카빙 연습 중", "경기 성남시", "https://image.url"); diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomTest.java new file mode 100644 index 0000000..70a8d9a --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomTest.java @@ -0,0 +1,106 @@ +package com.ikae.snowthing.domain.member.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; +import com.ikae.snowthing.domain.member.entity.*; + +@SpringBootTest +@Transactional +class MemberRepositoryCustomTest { + + @Autowired private MemberRepository memberRepository; + + @Autowired private EntityManager entityManager; + + @Test + @DisplayName("findProfileByEmail - QueryDSL을 통해 회원 정보와 선호 스키장/스타일명을 DTO로 일괄 조회한다") + void findProfileByEmail_Success() { + // given + Member member = + memberRepository.save( + Member.builder() + .email("querydsl_profile@snowthing.com") + .nickname("쿼리디에스엘") + .password("encoded_pass") + .bio("스노우보더") + .build()); + + Resort resort = Resort.builder().name("테스트하이원").regionName("강원 정선").build(); + entityManager.persist(resort); + RidingStyle style = RidingStyle.builder().styleName("테스트파크").description("기물").build(); + entityManager.persist(style); + + entityManager.persist(MemberResort.builder().member(member).resort(resort).build()); + entityManager.persist( + MemberRidingStyle.builder().member(member).ridingStyle(style).build()); + + entityManager.flush(); + entityManager.clear(); + + // when + Optional profileOpt = + memberRepository.findProfileByEmail("querydsl_profile@snowthing.com"); + + // then + assertThat(profileOpt).isPresent(); + MemberLoginResponse profile = profileOpt.get(); + assertThat(profile.getEmail()).isEqualTo("querydsl_profile@snowthing.com"); + assertThat(profile.getNickname()).isEqualTo("쿼리디에스엘"); + assertThat(profile.getResortNames()).containsExactly("테스트하이원"); + assertThat(profile.getRidingStyleNames()).containsExactly("테스트파크"); + } + + @Test + @DisplayName( + "findProfileByEmail - 회원이 여러 개의 리조트와 라이딩 스타일을 보유했을 때 Cartesian Product 없이 정확히 조회된다") + void findProfileByEmail_multipleCollections_success() { + Member member = + memberRepository.save( + Member.builder() + .email("multi_collection@snowthing.com") + .nickname("다중선호보더") + .password("encoded_pass") + .bio("올라운더") + .build()); + + Resort resort1 = Resort.builder().name("테스트용평").regionName("강원 평창").build(); + entityManager.persist(resort1); + Resort resort2 = Resort.builder().name("테스트휘닉스").regionName("강원 평창").build(); + entityManager.persist(resort2); + + RidingStyle style1 = RidingStyle.builder().styleName("테스트라이딩").description("카빙").build(); + entityManager.persist(style1); + RidingStyle style2 = RidingStyle.builder().styleName("테스트그라운드트릭").description("트릭").build(); + entityManager.persist(style2); + + entityManager.persist(MemberResort.builder().member(member).resort(resort1).build()); + entityManager.persist(MemberResort.builder().member(member).resort(resort2).build()); + + entityManager.persist( + MemberRidingStyle.builder().member(member).ridingStyle(style1).build()); + entityManager.persist( + MemberRidingStyle.builder().member(member).ridingStyle(style2).build()); + + entityManager.flush(); + entityManager.clear(); + + Optional profileOpt = + memberRepository.findProfileByEmail("multi_collection@snowthing.com"); + + assertThat(profileOpt).isPresent(); + MemberLoginResponse profile = profileOpt.get(); + assertThat(profile.getResortNames()).containsExactlyInAnyOrder("테스트용평", "테스트휘닉스"); + assertThat(profile.getRidingStyleNames()).containsExactlyInAnyOrder("테스트라이딩", "테스트그라운드트릭"); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberConcurrencyTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberConcurrencyTest.java index 057efc4..6aa5b6f 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberConcurrencyTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberConcurrencyTest.java @@ -1,9 +1,12 @@ package com.ikae.snowthing.domain.member.service; -import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; -import com.ikae.snowthing.domain.member.repository.MemberRepository; -import com.ikae.snowthing.domain.member.repository.MemberResortRepository; -import com.ikae.snowthing.domain.member.repository.MemberRidingStyleRepository; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -11,27 +14,21 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; +import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.member.repository.MemberResortRepository; +import com.ikae.snowthing.domain.member.repository.MemberRidingStyleRepository; @SpringBootTest class MemberConcurrencyTest { - @Autowired - private MemberService memberService; + @Autowired private MemberService memberService; - @Autowired - private MemberRepository memberRepository; + @Autowired private MemberRepository memberRepository; - @Autowired - private MemberResortRepository memberResortRepository; + @Autowired private MemberResortRepository memberResortRepository; - @Autowired - private MemberRidingStyleRepository memberRidingStyleRepository; + @Autowired private MemberRidingStyleRepository memberRidingStyleRepository; @BeforeEach void setUp() { @@ -61,26 +58,28 @@ void signUp_Concurrency_OnlyOneSucceeds() throws InterruptedException { AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); - MemberSignUpRequest signUpRequest = MemberSignUpRequest.builder() - .email("concurrent@snowthing.com") - .password("Password123!") - .nickname("동시성보더") - .build(); + MemberSignUpRequest signUpRequest = + MemberSignUpRequest.builder() + .email("concurrent@snowthing.com") + .password("Password123!") + .nickname("동시성보더") + .build(); for (int i = 0; i < threadCount; i++) { - executorService.submit(() -> { - try { - readyLatch.countDown(); - startLatch.await(); - - memberService.signUp(signUpRequest); - successCount.incrementAndGet(); - } catch (Exception e) { - failCount.incrementAndGet(); - } finally { - doneLatch.countDown(); - } - }); + executorService.submit( + () -> { + try { + readyLatch.countDown(); + startLatch.await(); + + memberService.signUp(signUpRequest); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); } readyLatch.await(); diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberServiceSignUpVerificationTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberServiceSignUpVerificationTest.java index 87da469..34cff1e 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberServiceSignUpVerificationTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberServiceSignUpVerificationTest.java @@ -1,11 +1,12 @@ package com.ikae.snowthing.domain.member.service; -import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; -import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse; -import com.ikae.snowthing.domain.member.entity.Member; -import com.ikae.snowthing.domain.member.repository.MemberRepository; -import com.ikae.snowthing.domain.member.repository.MemberResortRepository; -import com.ikae.snowthing.domain.member.repository.MemberRidingStyleRepository; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -13,9 +14,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import java.lang.reflect.Field; - -import static org.assertj.core.api.Assertions.assertThat; +import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; +import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.member.repository.MemberResortRepository; +import com.ikae.snowthing.domain.member.repository.MemberRidingStyleRepository; @SpringBootTest class MemberServiceSignUpVerificationTest { @@ -25,6 +29,14 @@ class MemberServiceSignUpVerificationTest { @Autowired private MemberResortRepository memberResortRepository; @Autowired private MemberRidingStyleRepository memberRidingStyleRepository; + @Autowired + private com.ikae.snowthing.domain.comment.repository.CommentRepository commentRepository; + + @Autowired + private com.ikae.snowthing.domain.post.repository.PostReactionRepository postReactionRepository; + + @Autowired private com.ikae.snowthing.domain.post.repository.PostRepository postRepository; + @BeforeEach void setUp() { cleanUp(); @@ -36,6 +48,9 @@ void tearDown() { } private void cleanUp() { + commentRepository.deleteAll(); + postReactionRepository.deleteAll(); + postRepository.deleteAll(); memberResortRepository.deleteAll(); memberRidingStyleRepository.deleteAll(); memberRepository.deleteAll(); @@ -44,11 +59,12 @@ private void cleanUp() { @Test @DisplayName("[검증 1] 저장된 비밀번호는 $2a$ 로 시작하는 해시 포맷이어야 한다 (raw 비번 아님)") void signUp_SavesHashedPassword() { - MemberSignUpRequest signUpRequest = MemberSignUpRequest.builder() - .email("verify1@snowthing.com") - .password("Password123!") - .nickname("해시테스터") - .build(); + MemberSignUpRequest signUpRequest = + MemberSignUpRequest.builder() + .email("verify1@snowthing.com") + .password("Password123!") + .nickname("해시테스터") + .build(); memberService.signUp(signUpRequest); @@ -60,11 +76,12 @@ void signUp_SavesHashedPassword() { @Test @DisplayName("[검증 2] 회원가입 응답 DTO(MemberSignUpResponse) 에는 internal id 필드가 포함되지 않아야 한다") void signUp_ResponseDtoHasNoInternalId() { - MemberSignUpRequest signUpRequest = MemberSignUpRequest.builder() - .email("verify2@snowthing.com") - .password("Password123!") - .nickname("DTO테스터") - .build(); + MemberSignUpRequest signUpRequest = + MemberSignUpRequest.builder() + .email("verify2@snowthing.com") + .password("Password123!") + .nickname("DTO테스터") + .build(); MemberSignUpResponse response = memberService.signUp(signUpRequest); @@ -79,4 +96,28 @@ void signUp_ResponseDtoHasNoInternalId() { assertThat(hasInternalIdField).isFalse(); assertThat(response.getPublicId()).isNotNull(); } + + @Test + @DisplayName("[검증 3] 회원가입 요청 DTO는 컬렉션 원본 변경과 getter 반환 리스트 변경을 차단해야 한다") + void signUpRequest_DefensivelyCopiesCollectionInputs() { + List resortIds = new ArrayList<>(List.of(1L, 2L)); + List ridingStyleIds = new ArrayList<>(List.of(3L, 4L)); + + MemberSignUpRequest request = + MemberSignUpRequest.builder() + .email("immutable@snowthing.com") + .password("Password123!") + .nickname("불변DTO") + .resortIds(resortIds) + .ridingStyleIds(ridingStyleIds) + .build(); + + resortIds.add(999L); + ridingStyleIds.add(999L); + + assertThat(request.getResortIds()).containsExactly(1L, 2L); + assertThat(request.getRidingStyleIds()).containsExactly(3L, 4L); + assertThatThrownBy(() -> request.getResortIds().add(5L)) + .isInstanceOf(UnsupportedOperationException.class); + } } diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberServiceTest.java index 2eedb15..d0195e1 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/service/MemberServiceTest.java @@ -1,9 +1,11 @@ package com.ikae.snowthing.domain.member.service; -import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; -import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse; -import com.ikae.snowthing.domain.member.entity.Member; -import com.ikae.snowthing.domain.member.repository.MemberRepository; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,42 +14,39 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; +import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; +import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.repository.MemberRepository; @ExtendWith(MockitoExtension.class) class MemberServiceTest { - @Mock - private MemberRepository memberRepository; + @Mock private MemberRepository memberRepository; - @Mock - private PasswordEncoder passwordEncoder; + @Mock private PasswordEncoder passwordEncoder; - @InjectMocks - private MemberService memberService; + @InjectMocks private MemberService memberService; @Test @DisplayName("올바른 정보 입력 시 회원가입이 정상 처리되어야 한다") void signUp_Success() { // given - MemberSignUpRequest request = MemberSignUpRequest.builder() - .email("newuser@snowthing.com") - .password("Password123!") - .nickname("신규보더") - .bio("입문 보더입니다") - .departureRegion("서울 송파구") - .build(); + MemberSignUpRequest request = + MemberSignUpRequest.builder() + .email("newuser@snowthing.com") + .password("Password123!") + .nickname("신규보더") + .bio("입문 보더입니다") + .departureRegion("서울 송파구") + .build(); given(memberRepository.existsByEmail("newuser@snowthing.com")).willReturn(false); given(memberRepository.existsByNickname("신규보더")).willReturn(false); given(passwordEncoder.encode("Password123!")).willReturn("bcrypted_password_123"); Member member = request.toEntity("bcrypted_password_123"); - given(memberRepository.saveAndFlush(any(Member.class))).willReturn(member); + given(memberRepository.save(any(Member.class))).willReturn(member); // when MemberSignUpResponse response = memberService.signUp(request); @@ -56,18 +55,19 @@ void signUp_Success() { assertThat(response).isNotNull(); assertThat(response.getEmail()).isEqualTo("newuser@snowthing.com"); assertThat(response.getNickname()).isEqualTo("신규보더"); - verify(memberRepository).saveAndFlush(any(Member.class)); + verify(memberRepository).save(any(Member.class)); } @Test @DisplayName("이미 존재하는 이메일로 가입 시 예외가 발생해야 한다") void signUp_DuplicateEmail_Exception() { // given - MemberSignUpRequest request = MemberSignUpRequest.builder() - .email("exist@snowthing.com") - .password("Password123!") - .nickname("닉네임1") - .build(); + MemberSignUpRequest request = + MemberSignUpRequest.builder() + .email("exist@snowthing.com") + .password("Password123!") + .nickname("닉네임1") + .build(); given(memberRepository.existsByEmail("exist@snowthing.com")).willReturn(true); @@ -82,11 +82,12 @@ void signUp_DuplicateEmail_Exception() { @DisplayName("이미 존재하는 닉네임으로 가입 시 예외가 발생해야 한다") void signUp_DuplicateNickname_Exception() { // given - MemberSignUpRequest request = MemberSignUpRequest.builder() - .email("new@snowthing.com") - .password("Password123!") - .nickname("중복닉네임") - .build(); + MemberSignUpRequest request = + MemberSignUpRequest.builder() + .email("new@snowthing.com") + .password("Password123!") + .nickname("중복닉네임") + .build(); given(memberRepository.existsByEmail("new@snowthing.com")).willReturn(false); given(memberRepository.existsByNickname("중복닉네임")).willReturn(true); diff --git a/backend/src/test/java/com/ikae/snowthing/domain/post/controller/PostControllerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/post/controller/PostControllerTest.java new file mode 100644 index 0000000..8f0a474 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/post/controller/PostControllerTest.java @@ -0,0 +1,519 @@ +package com.ikae.snowthing.domain.post.controller; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import jakarta.servlet.http.Cookie; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostDeleteRequest; +import com.ikae.snowthing.domain.post.dto.PostReactionRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.entity.ReactionType; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.security.CustomUserDetails; +import com.ikae.snowthing.global.web.AnonymousVoterCookieManager; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class PostControllerTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + + @Autowired private MemberRepository memberRepository; + + @Autowired private PostCategoryRepository categoryRepository; + + @Autowired private PostService postService; + + @Autowired private PasswordEncoder passwordEncoder; + + private Member member; + private CustomUserDetails userDetails; + + @BeforeEach + void setUp() { + categoryRepository + .findByCode("FREE") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder().name("자유게시판").code("FREE").build())); + + member = + memberRepository.save( + Member.builder() + .email("testuser@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("컨트롤러보더") + .role(Role.ROLE_USER) + .build()); + + userDetails = new CustomUserDetails(member); + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities())); + } + + @Test + @DisplayName("POST /api/posts - 정상 게시글 작성 201 Created") + void createPost_success() throws Exception { + PostCreateRequest request = + PostCreateRequest.builder() + .categoryCode("FREE") + .title("컨트롤러 통합 테스트 제목") + .content("컨트롤러 통합 테스트 본문") + .isAnonymous(false) + .build(); + + mockMvc.perform( + post("/api/v1/posts") + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.publicId").exists()) + .andExpect(jsonPath("$.title").value("컨트롤러 통합 테스트 제목")) + .andExpect(jsonPath("$.writerName").value("컨트롤러보더")); + } + + @Test + @DisplayName("POST /api/v1/posts - 빈 제목 입력 시 400 Bad Request") + void createPost_emptyTitle_badRequest() throws Exception { + PostCreateRequest request = + PostCreateRequest.builder() + .categoryCode("FREE") + .title("") + .content("본문 내용") + .isAnonymous(false) + .build(); + + mockMvc.perform( + post("/api/v1/posts") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("GET /api/v1/posts/{publicId} - 정상 상세 조회 200 OK") + void getPostDetail_success() throws Exception { + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("상세조회 테스트") + .content("상세본문") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + + mockMvc.perform(get("/api/v1/posts/" + post.publicId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("상세조회 테스트")) + .andExpect(jsonPath("$.viewCount").value(1)); + } + + @Test + @DisplayName("GET /api/v1/posts - 목록 페이징 조회 200 OK") + void getPostList_success() throws Exception { + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("목록 테스트 1") + .content("본문 1") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + + mockMvc.perform( + get("/api/v1/posts") + .param("categoryCode", "FREE") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].title").value("목록 테스트 1")); + } + + @Test + @DisplayName("DELETE /api/v1/posts/{publicId} - 로그인 작성자 본인 정상 삭제 200 OK") + void deletePost_writer_success() throws Exception { + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("작성자 삭제 테스트") + .content("본문 내용") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + + mockMvc.perform( + delete("/api/v1/posts/" + post.publicId()) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName("DELETE /api/v1/posts/{publicId} - 관리자(ROLE_ADMIN)는 타인의 일반글도 삭제 가능 200 OK") + void deletePost_admin_success() throws Exception { + Member adminMember = + memberRepository.save( + Member.builder() + .email("admin@snowthing.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("최고관리자") + .role(Role.ROLE_ADMIN) + .build()); + CustomUserDetails adminUserDetails = new CustomUserDetails(adminMember); + + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + adminUserDetails, null, adminUserDetails.getAuthorities())); + + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("타 회원 글") + .content("본문 내용") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + + mockMvc.perform( + delete("/api/v1/posts/" + post.publicId()) + .with(csrf()) + .with(user(adminUserDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName("DELETE /api/v1/posts/{publicId} - 타 회원이 일반 회원글 삭제 시도 시 403 Forbidden") + void deletePost_otherMember_forbidden() throws Exception { + Member otherMember = + memberRepository.save( + Member.builder() + .email("other@snowthing.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("남의글탐내는자") + .role(Role.ROLE_USER) + .build()); + CustomUserDetails otherUserDetails = new CustomUserDetails(otherMember); + + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + otherUserDetails, null, otherUserDetails.getAuthorities())); + + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("원작성자 글") + .content("본문 내용") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + + mockMvc.perform( + delete("/api/v1/posts/" + post.publicId()) + .with(csrf()) + .with(user(otherUserDetails))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AUTH_002")); + } + + @Test + @DisplayName( + "DELETE /api/v1/posts/{publicId} - 비회원 익명글에 올바른 비밀번호를 Request Body로 전송 시 삭제 200 OK") + void deletePost_anonymous_success_withRequestBody() throws Exception { + SecurityContextHolder.clearContext(); + + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("익명 글") + .content("본문 내용") + .isAnonymous(true) + .anonymousPassword("SecretPass123!") + .build(), + null, + "127.0.0.1"); + + PostDeleteRequest deleteRequest = new PostDeleteRequest("SecretPass123!"); + + mockMvc.perform( + delete("/api/v1/posts/" + post.publicId()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(deleteRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName( + "DELETE /api/v1/posts/{publicId} - 비회원 익명글에 잘못된 비밀번호를 Request Body로 전송 시 403 Forbidden") + void deletePost_anonymous_forbidden_withWrongPassword() throws Exception { + SecurityContextHolder.clearContext(); + + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("익명 글") + .content("본문 내용") + .isAnonymous(true) + .anonymousPassword("SecretPass123!") + .build(), + null, + "127.0.0.1"); + + PostDeleteRequest deleteRequest = new PostDeleteRequest("WrongPass123!"); + + mockMvc.perform( + delete("/api/v1/posts/" + post.publicId()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(deleteRequest))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("POST_004")); + } + + @Test + @DisplayName( + "DELETE /api/v1/posts/{publicId} - 관계없는 제3자 로그인 사용자가 익명글의 비밀번호를 알고 Request Body로 입력 시 삭제 200 OK") + void deletePost_anonymous_thirdPartyMemberWithPassword_success() throws Exception { + // 1. 비회원이 익명글 작성 + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("익명글") + .content("비회원이 쓴 글") + .isAnonymous(true) + .anonymousPassword("SharedPassword123!") + .build(), + null, + "127.0.0.1"); + + // 2. 관계없는 제3의 로그인 사용자가 로그인한 상태 + Member thirdParty = + memberRepository.save( + Member.builder() + .email("thirdparty@snowthing.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("제3자회원") + .role(Role.ROLE_USER) + .build()); + CustomUserDetails thirdUserDetails = new CustomUserDetails(thirdParty); + + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + thirdUserDetails, null, thirdUserDetails.getAuthorities())); + + // 3. 올바른 비밀번호를 Request Body에 담아 삭제 요청 -> 정책상 삭제 성공 + PostDeleteRequest deleteRequest = new PostDeleteRequest("SharedPassword123!"); + + mockMvc.perform( + delete("/api/v1/posts/" + post.publicId()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(deleteRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName( + "DELETE /api/v1/posts/{publicId} - 관계없는 제3자 로그인 사용자가 익명글에 틀린 비밀번호를 입력 시 403 Forbidden") + void deletePost_anonymous_thirdPartyMemberWithWrongPassword_forbidden() throws Exception { + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("익명글") + .content("비회원이 쓴 글") + .isAnonymous(true) + .anonymousPassword("SharedPassword123!") + .build(), + null, + "127.0.0.1"); + + Member thirdParty = + memberRepository.save( + Member.builder() + .email("thirdparty2@snowthing.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("제3자회원2") + .role(Role.ROLE_USER) + .build()); + CustomUserDetails thirdUserDetails = new CustomUserDetails(thirdParty); + + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + thirdUserDetails, null, thirdUserDetails.getAuthorities())); + + PostDeleteRequest deleteRequest = new PostDeleteRequest("WrongPassword!"); + + mockMvc.perform( + delete("/api/v1/posts/" + post.publicId()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(deleteRequest))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("POST_004")); + } + + @Test + @DisplayName("GET /api/v1/posts/{publicId} - viewed_posts 쿠키가 있으면 조회수를 중복 증가시키지 않는다") + void getPostDetail_withViewedCookie_doesNotIncreaseViewCountAgain() throws Exception { + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("조회수 쿠키 테스트") + .content("본문") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + + Cookie viewedCookie = new Cookie("viewed_posts", "[" + post.publicId() + "]"); + + mockMvc.perform(get("/api/v1/posts/" + post.publicId()).cookie(viewedCookie)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.viewCount").value(0)); + } + + @Test + @DisplayName( + "POST /api/v1/posts/{publicId}/reactions - 비로그인 익명 사용자는 anonymous_voter_id 쿠키로 추천을 토글한다") + void reactToPost_anonymousUser_usesAnonymousVoterCookie() throws Exception { + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("익명 추천 컨트롤러 테스트") + .content("본문") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + PostReactionRequest request = new PostReactionRequest(ReactionType.LIKE); + + SecurityContextHolder.clearContext(); + + var firstResult = + mockMvc.perform( + post("/api/v1/posts/" + post.publicId() + "/reactions") + .with(csrf()) + .with(anonymous()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect( + cookie().exists( + AnonymousVoterCookieManager + .ANONYMOUS_VOTER_COOKIE_NAME)) + .andExpect(jsonPath("$.isToggledOn").value(true)) + .andExpect(jsonPath("$.likeCount").value(1)) + .andReturn(); + + Cookie anonymousVoterCookie = + firstResult + .getResponse() + .getCookie(AnonymousVoterCookieManager.ANONYMOUS_VOTER_COOKIE_NAME); + + mockMvc.perform( + post("/api/v1/posts/" + post.publicId() + "/reactions") + .with(csrf()) + .with(anonymous()) + .cookie(anonymousVoterCookie) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isToggledOn").value(false)) + .andExpect(jsonPath("$.likeCount").value(0)); + } + + @Test + @DisplayName("GET /api/v1/posts - 웹 Offset 페이징 조회 성공") + void searchPostsByOffset_success() throws Exception { + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("웹 페이징 글 1") + .content("본문") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + + mockMvc.perform(get("/api/v1/posts").param("page", "1").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].title").value("웹 페이징 글 1")); + } + + @Test + @DisplayName("GET /api/v1/posts/scroll - 모바일 Cursor 페이징 조회 성공") + void searchPostsByCursor_success() throws Exception { + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("커서 페이징 글 1") + .content("본문") + .isAnonymous(false) + .build(), + userDetails, + "127.0.0.1"); + + mockMvc.perform(get("/api/v1/posts/scroll").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].title").value("커서 페이징 글 1")); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustomTest.java b/backend/src/test/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustomTest.java new file mode 100644 index 0000000..7c1e257 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/post/repository/PostRepositoryCustomTest.java @@ -0,0 +1,118 @@ +package com.ikae.snowthing.domain.post.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.MemberStatus; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostListResponse; +import com.ikae.snowthing.domain.post.dto.PostSearchRequest; +import com.ikae.snowthing.domain.post.dto.SearchType; +import com.ikae.snowthing.domain.post.dto.SortType; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.global.common.dto.CursorPageResponse; + +@SpringBootTest +@Transactional +class PostRepositoryCustomTest { + + @Autowired private PostRepository postRepository; + + @Autowired private PostCategoryRepository categoryRepository; + + @Autowired private MemberRepository memberRepository; + + private PostCategory freeCategory; + private Member testMember; + + @BeforeEach + void setUp() { + freeCategory = + categoryRepository.save(PostCategory.builder().name("자유게시판").code("FREE").build()); + + testMember = + memberRepository.save( + Member.builder() + .publicId(UUID.randomUUID().toString()) + .email("boarder@snow.com") + .nickname("카빙마스터") + .role(Role.ROLE_USER) + .status(MemberStatus.ACTIVE) + .build()); + + // 테스트 데이터 25개 저장 + for (int i = 1; i <= 25; i++) { + Post post = + Post.builder() + .member(testMember) + .category(freeCategory) + .title("휘닉스파크 설질 제보 " + i) + .content("오늘 챔피언 슬로프 상태 아주 좋습니다 " + i) + .writerIp("127.0.0.1") + .isAnonymous(false) + .build(); + postRepository.save(post); + } + } + + @Test + @DisplayName("웹 Offset 기반 페이징 조회 - 1페이지 (size=10)") + void findPostsByOffset_firstPage() { + // given + PostSearchRequest request = + new PostSearchRequest("FREE", null, null, null, SortType.LATEST, 1, null, 10); + + // when + CursorPageResponse response = postRepository.findPostsByOffset(request); + + // then + assertThat(response.content()).hasSize(10); + assertThat(response.pageInfo().page()).isEqualTo(1); + assertThat(response.pageInfo().totalPages()).isEqualTo(3); + assertThat(response.pageInfo().totalElements()).isEqualTo(25L); + assertThat(response.pageInfo().hasNext()).isTrue(); + } + + @Test + @DisplayName("모바일 Keyset Cursor 기반 페이징 조회 - 첫 페이지 (size=10) 및 nextCursor 획득") + void findPostsByCursor_firstPage() { + // given + PostSearchRequest request = + new PostSearchRequest("FREE", null, null, null, SortType.LATEST, null, null, 10); + + // when + CursorPageResponse response = postRepository.findPostsByCursor(request); + + // then + assertThat(response.content()).hasSize(10); + assertThat(response.pageInfo().hasNext()).isTrue(); + assertThat(response.pageInfo().nextCursor()).isNotNull(); + } + + @Test + @DisplayName("동적 키워드 검색 (TITLE) - '설질' 포함 25건 조회") + void findPostsBySearch_titleKeyword() { + // given + PostSearchRequest request = + new PostSearchRequest( + "FREE", null, SearchType.TITLE, "설질", SortType.LATEST, 1, null, 10); + + // when + CursorPageResponse response = postRepository.findPostsByOffset(request); + + // then + assertThat(response.content()).hasSize(10); + assertThat(response.pageInfo().totalElements()).isEqualTo(25L); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java new file mode 100644 index 0000000..1853e4a --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java @@ -0,0 +1,723 @@ +package com.ikae.snowthing.domain.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.*; +import com.ikae.snowthing.domain.post.entity.*; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.repository.PostImageRepository; +import com.ikae.snowthing.domain.post.repository.PostReactionRepository; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@Transactional +class PostServiceTest { + + @Autowired private PostService postService; + + @Autowired private PostRepository postRepository; + + @Autowired private PostCategoryRepository categoryRepository; + + @Autowired private MemberRepository memberRepository; + + @Autowired private PostReactionRepository reactionRepository; + + @Autowired private PostImageRepository imageRepository; + + @Autowired private PasswordEncoder passwordEncoder; + + @Autowired private jakarta.persistence.EntityManager entityManager; + + private Member member1; + private Member member2; + private CustomUserDetails userDetails1; + private CustomUserDetails userDetails2; + private PostCategory freeCategory; + + @BeforeEach + void setUp() { + postRepository.deleteAll(); + + member1 = + memberRepository.save( + Member.builder() + .email("user1@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("보더1호") + .role(Role.ROLE_USER) + .build()); + + member2 = + memberRepository.save( + Member.builder() + .email("user2@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("보더2호") + .role(Role.ROLE_USER) + .build()); + + userDetails1 = new CustomUserDetails(member1); + userDetails2 = new CustomUserDetails(member2); + + freeCategory = + categoryRepository + .findByCode("FREE") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder() + .name("자유게시판") + .code("FREE") + .build())); + } + + @Nested + @DisplayName("게시글 작성 테스트") + class CreatePostTest { + + @Test + @DisplayName("로그인 회원이 정상적으로 게시글을 작성한다.") + void createPost_success_member() { + PostCreateRequest request = + PostCreateRequest.builder() + .categoryCode("FREE") + .title("오늘 설질 어떤가요?") + .content("휘팍 설질 최고입니다.") + .isAnonymous(false) + .imageUrls(List.of("https://cdn.example.com/1.jpg")) + .build(); + + PostResponse response = postService.createPost(request, userDetails1, "127.0.0.1"); + + assertThat(response.publicId()).isNotNull(); + assertThat(response.title()).isEqualTo("오늘 설질 어떤가요?"); + assertThat(response.writerName()).isEqualTo("보더1호"); + assertThat(response.status()).isEqualTo(PostStatus.NORMAL); + + Post savedPost = postRepository.findByPublicId(response.publicId()).orElseThrow(); + assertThat(savedPost.isHasImage()).isTrue(); + assertThat(savedPost.getImages()).hasSize(1); + } + + @Test + @DisplayName("비회원이 익명 게시글을 정상적으로 작성한다.") + void createPost_success_anonymous() { + PostCreateRequest request = + PostCreateRequest.builder() + .categoryCode("FREE") + .title("익명 질문입니다.") + .content("입문용 데크 추천해 주세요.") + .isAnonymous(true) + .anonymousPassword("Anon1234!") + .build(); + + PostResponse response = postService.createPost(request, null, "192.168.1.100"); + + assertThat(response.publicId()).isNotNull(); + assertThat(response.writerName()).contains("익명"); + } + + @Test + @DisplayName("비인증 유저가 회원 게시글(isAnonymous=false) 작성을 시도하면 예외가 터진다.") + void createPost_unauthorized() { + PostCreateRequest request = + PostCreateRequest.builder() + .categoryCode("FREE") + .title("비인증 게시글") + .content("본문 내용") + .isAnonymous(false) + .build(); + + assertThatThrownBy(() -> postService.createPost(request, null, "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_CREDENTIALS); + } + } + + @Nested + @DisplayName("게시글 상세 및 목록 조회 테스트") + class GetPostTest { + + @Test + @DisplayName("정상 게시글 상세 조회 시 조회수가 1 증가한다.") + void getPostDetail_success() { + PostCreateRequest request = + PostCreateRequest.builder() + .categoryCode("FREE") + .title("조회수 테스트") + .content("상세 내용") + .isAnonymous(false) + .build(); + + PostResponse created = postService.createPost(request, userDetails1, "127.0.0.1"); + + PostDetailResponse detail = + postService.getPostDetail(created.publicId(), userDetails1, true); + + assertThat(detail.title()).isEqualTo("조회수 테스트"); + assertThat(detail.viewCount()).isEqualTo(1); + } + + @Test + @DisplayName("존재하지 않는 publicId 조회 시 404 예외가 터진다.") + void getPostDetail_notFound() { + assertThatThrownBy( + () -> + postService.getPostDetail( + "non-existent-uuid", userDetails1, true)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + } + + @Test + @DisplayName("목록 조회 시 본문(content)이 제외되고 최신순/id역순으로 페이징 조회된다.") + void getPostList_success() { + for (int i = 1; i <= 5; i++) { + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("테스트 글 " + i) + .content("본문 내용 " + i) + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + } + + Page page = postService.getPostList("FREE", 0, 10); + + assertThat(page.getTotalElements()).isEqualTo(5); + assertThat(page.getContent().get(0).title()).isEqualTo("테스트 글 5"); + } + + @Test + @DisplayName("잘못된 페이지 크기(size=0 이하) 입력 시 예외가 터진다.") + void getPostList_invalidPageSize() { + assertThatThrownBy(() -> postService.getPostList("FREE", 0, 0)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_PAGE_SIZE); + } + } + + @Nested + @DisplayName("게시글 수정 및 삭제 권한 테스트") + class UpdateAndDeletePostTest { + + private PostResponse createdPost; + + @BeforeEach + void setUpPost() { + createdPost = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("수정전 제목") + .content("수정전 본문") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + } + + @Test + @DisplayName("작성자 본인은 게시글을 정상 수정한다.") + void updatePost_success_writer() { + PostUpdateRequest updateReq = + PostUpdateRequest.builder() + .categoryCode("FREE") + .title("수정후 제목") + .content("수정후 본문") + .build(); + + PostResponse updated = + postService.updatePost(createdPost.publicId(), updateReq, userDetails1); + + assertThat(updated.title()).isEqualTo("수정후 제목"); + } + + @Test + @DisplayName("작성자 본인이 게시글 수정 시 이미지 목록을 최종 상태로 교체하고 hasImage를 동기화한다.") + void updatePost_replaceImages_success() { + PostUpdateRequest updateReq = + PostUpdateRequest.builder() + .categoryCode("FREE") + .title("이미지 수정 제목") + .content("이미지 수정 본문") + .imageUrls( + List.of( + "https://cdn.example.com/updated-1.jpg", + "https://cdn.example.com/updated-2.jpg")) + .build(); + + postService.updatePost(createdPost.publicId(), updateReq, userDetails1); + postRepository.flush(); + + Post savedPost = postRepository.findByPublicId(createdPost.publicId()).orElseThrow(); + List images = + imageRepository.findByPostIdOrderBySortOrderAsc(savedPost.getId()); + + assertThat(savedPost.isHasImage()).isTrue(); + assertThat(images) + .extracting(PostImage::getImageUrl) + .containsExactly( + "https://cdn.example.com/updated-1.jpg", + "https://cdn.example.com/updated-2.jpg"); + } + + @Test + @DisplayName("작성자 본인이 게시글 수정 시 이미지 목록을 비우면 첨부 이미지를 제거하고 hasImage를 false로 동기화한다.") + void updatePost_removeImages_success() { + PostResponse postWithImage = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("이미지 있는 글") + .content("본문") + .isAnonymous(false) + .imageUrls(List.of("https://cdn.example.com/original.jpg")) + .build(), + userDetails1, + "127.0.0.1"); + + PostUpdateRequest updateReq = + PostUpdateRequest.builder() + .categoryCode("FREE") + .title("이미지 제거 제목") + .content("이미지 제거 본문") + .imageUrls(List.of()) + .build(); + + postService.updatePost(postWithImage.publicId(), updateReq, userDetails1); + postRepository.flush(); + + Post savedPost = postRepository.findByPublicId(postWithImage.publicId()).orElseThrow(); + List images = + imageRepository.findByPostIdOrderBySortOrderAsc(savedPost.getId()); + + assertThat(savedPost.isHasImage()).isFalse(); + assertThat(images).isEmpty(); + } + + @Test + @DisplayName("타 회원이 수정 시도 시 403 Forbidden 예외가 터진다.") + void updatePost_forbidden_otherUser() { + PostUpdateRequest updateReq = + PostUpdateRequest.builder() + .categoryCode("FREE") + .title("타인 해킹 시도") + .content("해킹 본문") + .build(); + + assertThatThrownBy( + () -> + postService.updatePost( + createdPost.publicId(), updateReq, userDetails2)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName( + "작성자 본인은 Soft Delete로 게시글을 정상 삭제하며 DB에 is_deleted=true, status=DELETED, deleted_at이 기록된다.") + void deletePost_success_softDelete() { + postService.deletePost(createdPost.publicId(), null, userDetails1); + + // 1. 서비스 레벨 조회 시 404 예외 검증 + assertThatThrownBy( + () -> postService.getPostDetail(createdPost.publicId(), userDetails1)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + + // 2. DB 물리 레코드 Soft Delete 상태 검증 (SQLRestriction 우회 Native Query) + Object[] rawPost = + (Object[]) + entityManager + .createNativeQuery( + "SELECT is_deleted, status, deleted_at FROM post WHERE public_id = :publicId") + .setParameter("publicId", createdPost.publicId()) + .getSingleResult(); + + assertThat(rawPost[0]).isEqualTo(true); + assertThat(rawPost[1]).isEqualTo("DELETED"); + assertThat(rawPost[2]).isNotNull(); + } + + @Test + @DisplayName("이미 삭제된 게시글에 대해 삭제를 재시도하면 404 예외가 터진다.") + void deletePost_alreadyDeleted() { + postService.deletePost(createdPost.publicId(), null, userDetails1); + + assertThatThrownBy( + () -> + postService.deletePost( + createdPost.publicId(), null, userDetails1)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + } + } + + @Nested + @DisplayName("추천/비추천 중복 제약 조건 테스트") + class ReactionTest { + + @Test + @DisplayName("추천/비추천 클릭 시 토글(ON/OFF) 동작하며 카운트가 +1, -1로 정상 갱신된다.") + void reactToPost_toggle_success() { + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("추천 테스트 글") + .content("내용") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + + // 1회 클릭: Toggle ON (+1) + ReactionToggleResponse res1 = + postService.reactToPost( + post.publicId(), ReactionType.LIKE, userDetails1, "127.0.0.1", null); + assertThat(res1.isToggledOn()).isTrue(); + assertThat(res1.likeCount()).isEqualTo(1); + + // 2회 클릭: Toggle OFF (-1) + ReactionToggleResponse res2 = + postService.reactToPost( + post.publicId(), ReactionType.LIKE, userDetails1, "127.0.0.1", null); + assertThat(res2.isToggledOn()).isFalse(); + assertThat(res2.likeCount()).isEqualTo(0); + + // 추천과 비추천은 독립 투표 가능 (비추천 1회 클릭 ON) + ReactionToggleResponse res3 = + postService.reactToPost( + post.publicId(), ReactionType.DISLIKE, userDetails1, "127.0.0.1", null); + assertThat(res3.isToggledOn()).isTrue(); + assertThat(res3.dislikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("비로그인 익명 사용자도 anonymousVoterId 기반으로 추천/비추천 토글을 수행하며 DB에 정상 저장/삭제된다.") + void reactToPost_anonymousVoter_success() { + PostResponse post = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("익명 추천 테스트 글") + .content("내용") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + + // 1. 비로그인 익명 사용자 1회 추천 (Toggle ON) + ReactionToggleResponse res1 = + postService.reactToPost( + post.publicId(), ReactionType.LIKE, null, "10.0.0.1", "anon-voter-1"); + assertThat(res1.isToggledOn()).isTrue(); + assertThat(res1.likeCount()).isEqualTo(1); + + // DB 물리 저장 검증 (member_id IS NULL, anonymous_voter_id = 'anon-voter-1', writer_ip = + // '10.0.0.1') + Object[] rawReaction = + (Object[]) + entityManager + .createNativeQuery( + "SELECT member_id, anonymous_voter_id, writer_ip, type FROM post_reaction WHERE anonymous_voter_id = :voterId") + .setParameter("voterId", "anon-voter-1") + .getSingleResult(); + + assertThat(rawReaction[0]).isNull(); + assertThat(rawReaction[1]).isEqualTo("anon-voter-1"); + assertThat(rawReaction[2]).isEqualTo("10.0.0.1"); + assertThat(rawReaction[3]).isEqualTo("LIKE"); + + // 2. 비로그인 익명 사용자 재클릭 (Toggle OFF) + ReactionToggleResponse res2 = + postService.reactToPost( + post.publicId(), ReactionType.LIKE, null, "10.0.0.1", "anon-voter-1"); + assertThat(res2.isToggledOn()).isFalse(); + assertThat(res2.likeCount()).isEqualTo(0); + + // DB 물리 삭제 검증 + Number count = + (Number) + entityManager + .createNativeQuery( + "SELECT count(*) FROM post_reaction WHERE anonymous_voter_id = :voterId") + .setParameter("voterId", "anon-voter-1") + .getSingleResult(); + assertThat(count.intValue()).isEqualTo(0); + } + } + + @Nested + @DisplayName("게시글 상태(NORMAL, HIDDEN, BLOCKED, DRAFT, DELETED)별 접근 정책 테스트") + class PostStatusPolicyTest { + + @Autowired private com.ikae.snowthing.domain.comment.service.CommentService commentService; + + @Test + @DisplayName( + "목록 조회(Offset, Cursor) 시 NORMAL 상태가 아닌 HIDDEN, BLOCKED, DRAFT, DELETED 글은 일반 목록에서 제외된다.") + void searchPosts_excludesNonNormalPosts() { + // 1. NORMAL 글 생성 + Post normalPost = + postRepository.save( + Post.builder() + .member(member1) + .category(freeCategory) + .title("정상 공개 게시글") + .content("정상 내용") + .writerIp("127.0.0.1") + .status(PostStatus.NORMAL) + .build()); + + // 2. HIDDEN, BLOCKED, DRAFT 글 생성 (isDeleted=false) + Post hiddenPost = + postRepository.save( + Post.builder() + .member(member1) + .category(freeCategory) + .title("숨김 게시글") + .content("숨김 내용") + .writerIp("127.0.0.1") + .status(PostStatus.HIDDEN) + .build()); + + Post blockedPost = + postRepository.save( + Post.builder() + .member(member1) + .category(freeCategory) + .title("차단 게시글") + .content("차단 내용") + .writerIp("127.0.0.1") + .status(PostStatus.BLOCKED) + .build()); + + Post draftPost = + postRepository.save( + Post.builder() + .member(member1) + .category(freeCategory) + .title("임시저장 게시글") + .content("임시저장 내용") + .writerIp("127.0.0.1") + .status(PostStatus.DRAFT) + .build()); + + postRepository.flush(); + + // Offset 목록 조회 검증 + PostSearchRequest offsetReq = + new PostSearchRequest("FREE", null, null, null, SortType.LATEST, 1, null, 10); + var offsetResult = postService.searchPostsByOffset(offsetReq); + List offsetTitles = + offsetResult.content().stream() + .map(com.ikae.snowthing.domain.post.dto.PostListResponse::title) + .toList(); + + assertThat(offsetTitles).contains("정상 공개 게시글"); + assertThat(offsetTitles).doesNotContain("숨김 게시글", "차단 게시글", "임시저장 게시글"); + + // Cursor 목록 조회 검증 + PostSearchRequest cursorReq = + new PostSearchRequest( + "FREE", null, null, null, SortType.LATEST, null, null, 10); + var cursorResult = postService.searchPostsByCursor(cursorReq); + List cursorTitles = + cursorResult.content().stream() + .map(com.ikae.snowthing.domain.post.dto.PostListResponse::title) + .toList(); + + assertThat(cursorTitles).contains("정상 공개 게시글"); + assertThat(cursorTitles).doesNotContain("숨김 게시글", "차단 게시글", "임시저장 게시글"); + } + + @Test + @DisplayName( + "HIDDEN, BLOCKED, DRAFT 게시글 상세 조회, 추천, 댓글 작성 및 조회 시 일반 사용자는 404 POST_NOT_FOUND 예외가 발생한다.") + void nonNormalPost_accessPolicy_throwsPostNotFound() { + Post hiddenPost = + postRepository.save( + Post.builder() + .member(member1) + .category(freeCategory) + .title("숨김 글") + .content("숨김 본문") + .writerIp("127.0.0.1") + .status(PostStatus.HIDDEN) + .build()); + + // 1. 일반 사용자 상세 조회 -> 404 + assertThatThrownBy( + () -> + postService.getPostDetail( + hiddenPost.getPublicId(), userDetails2, false)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + + // 2. 관리자 상세 조회 -> 허용 + Member admin = + memberRepository.save( + Member.builder() + .email("admin_status@snowthing.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("상태관리자") + .role(Role.ROLE_ADMIN) + .build()); + CustomUserDetails adminDetails = new CustomUserDetails(admin); + var adminDetail = + postService.getPostDetail(hiddenPost.getPublicId(), adminDetails, false); + assertThat(adminDetail.title()).isEqualTo("숨김 글"); + + // 3. 추천 시도 -> 404 + assertThatThrownBy( + () -> + postService.reactToPost( + hiddenPost.getPublicId(), + ReactionType.LIKE, + userDetails2, + "127.0.0.1", + null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + + // 4. 댓글 작성 시도 -> 404 + assertThatThrownBy( + () -> + commentService.createComment( + hiddenPost.getPublicId(), + com.ikae.snowthing.domain.comment.dto + .CommentCreateRequest.builder() + .content("숨김글에 댓글달기") + .isAnonymous(false) + .build(), + userDetails2, + "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + + // 5. 댓글 목록 조회 시도 -> 404 + assertThatThrownBy(() -> commentService.getCommentsByPost(hiddenPost.getPublicId())) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + } + } + + @Nested + @DisplayName("수정일시(updated_at) 고스트 업데이트 방지 및 본문 수정 시 갱신 정책 테스트") + class PostUpdatedAtGhostUpdatePolicyTest { + + @Test + @DisplayName("게시글 조회수(view_count) 및 추천수(like_count) 증감 시에는 updated_at이 절대 변경되지 않는다.") + void countUpdates_doNotChangeUpdatedAt() { + // 1. 게시글 생성 + PostResponse created = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("고스트 업데이트 방지 테스트") + .content("원문 본문") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + + entityManager.flush(); + entityManager.clear(); + + Post initialPost = postRepository.findByPublicId(created.publicId()).orElseThrow(); + java.time.LocalDateTime initialUpdatedAt = initialPost.getUpdatedAt(); + + // 2. 조회수 증가 발생 + postService.getPostDetail(created.publicId(), userDetails1, true); + + // 3. 추천수 증가 발생 + postService.reactToPost( + created.publicId(), ReactionType.LIKE, userDetails2, "127.0.0.1", null); + + entityManager.flush(); + entityManager.clear(); + + // 4. DB 검증: view_count, like_count는 올랐지만 updated_at은 생성 시점 그대로 유지 + Post verified = postRepository.findByPublicId(created.publicId()).orElseThrow(); + assertThat(verified.getViewCount()).isEqualTo(1); + assertThat(verified.getLikeCount()).isEqualTo(1); + assertThat(verified.getUpdatedAt()).isEqualTo(initialUpdatedAt); + } + + @Test + @DisplayName("사용자가 실제 본문/제목을 수정(updatePost)한 경우에만 updated_at이 최신 시점으로 갱신된다.") + void updatePost_updatesUpdatedAtTimestamp() throws Exception { + // 1. 게시글 생성 + PostResponse created = + postService.createPost( + PostCreateRequest.builder() + .categoryCode("FREE") + .title("수정 전 제목") + .content("수정 전 본문") + .isAnonymous(false) + .build(), + userDetails1, + "127.0.0.1"); + + entityManager.flush(); + entityManager.clear(); + + Post initialPost = postRepository.findByPublicId(created.publicId()).orElseThrow(); + java.time.LocalDateTime initialUpdatedAt = initialPost.getUpdatedAt(); + + Thread.sleep(50); // 시간차 보장 + + // 2. 실제 본문 수정 + postService.updatePost( + created.publicId(), + PostUpdateRequest.builder() + .categoryCode("FREE") + .title("수정된 제목") + .content("수정된 본문") + .build(), + userDetails1); + + entityManager.flush(); + entityManager.clear(); + + // 3. DB 검증: updated_at이 이전 시점보다 이후(after)로 갱신됨 + Post updated = postRepository.findByPublicId(created.publicId()).orElseThrow(); + assertThat(updated.getTitle()).isEqualTo("수정된 제목"); + assertThat(updated.getUpdatedAt()).isAfter(initialUpdatedAt); + } + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/global/util/WriterDisplayFormatterTest.java b/backend/src/test/java/com/ikae/snowthing/global/util/WriterDisplayFormatterTest.java new file mode 100644 index 0000000..75e72c0 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/global/util/WriterDisplayFormatterTest.java @@ -0,0 +1,71 @@ +package com.ikae.snowthing.global.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.ikae.snowthing.domain.member.entity.Member; + +class WriterDisplayFormatterTest { + + @Test + @DisplayName("회원 작성글은 회원의 닉네임을 반환한다") + void format_memberWriter_returnsNickname() { + Member member = Member.builder().nickname("눈사람보더").build(); + + String result = WriterDisplayFormatter.format(false, member, "192.168.1.100"); + + assertThat(result).isEqualTo("눈사람보더"); + } + + @Test + @DisplayName("회원 정보가 null이거나 닉네임이 없으면 '알 수 없음'을 반환한다") + void format_nullMember_returnsUnknown() { + String result1 = WriterDisplayFormatter.format(false, null, "192.168.1.100"); + String result2 = + WriterDisplayFormatter.format(false, Member.builder().build(), "192.168.1.100"); + + assertThat(result1).isEqualTo("알 수 없음"); + assertThat(result2).isEqualTo("알 수 없음"); + } + + @ParameterizedTest + @CsvSource({ + "192.168.0.1, 익명 (192.168.***.***)", + "127.0.0.1, 익명 (127.0.***.***)", + "10.20.30.40, 익명 (10.20.***.***)" + }) + @DisplayName("익명 작성글은 IPv4를 정상 마스킹하여 '익명 (A.B.***.***)' 형식으로 반환한다") + void format_anonymousWriter_masksIpv4(String ip, String expected) { + String result = WriterDisplayFormatter.format(true, null, ip); + + assertThat(result).isEqualTo(expected); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "0:0:0:0:0:0:0:1", "::1", "invalid_ip_format", "999"}) + @DisplayName("익명 작성글의 IP가 null, 공백, 루프백, 또는 비정상 문자열일 경우 디폴트 마스킹을 반환한다") + void format_anonymousWriter_defaultMaskedIp(String ip) { + String result = WriterDisplayFormatter.format(true, null, ip); + + assertThat(result).isEqualTo("익명 (127.0.***.***)"); + } + + @ParameterizedTest + @CsvSource({ + "2001:0db8:85a3:0000:0000:8a2e:0370:7334, 익명 (2001:0db8:****:****)", + "fe80:1234:5678::1, 익명 (fe80:1234:****:****)" + }) + @DisplayName("익명 작성글이 IPv6일 경우 앞 2개 세그먼트를 제외하고 정상 마스킹한다") + void format_anonymousWriter_masksIpv6(String ip, String expected) { + String result = WriterDisplayFormatter.format(true, null, ip); + + assertThat(result).isEqualTo(expected); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/global/web/WebCookieManagerTest.java b/backend/src/test/java/com/ikae/snowthing/global/web/WebCookieManagerTest.java new file mode 100644 index 0000000..5e407b1 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/global/web/WebCookieManagerTest.java @@ -0,0 +1,87 @@ +package com.ikae.snowthing.global.web; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.servlet.http.Cookie; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class WebCookieManagerTest { + + @Test + @DisplayName("조회수 쿠키가 없으면 최초 조회로 판단하고 viewed_posts 쿠키를 발급한다") + void markIfFirstView_withoutCookie_returnsTrueAndAddsCookie() { + ViewCountCookieManager manager = new ViewCountCookieManager(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean firstView = manager.markIfFirstView("post-1", request, response); + + assertThat(firstView).isTrue(); + Cookie cookie = response.getCookie(ViewCountCookieManager.VIEWED_POSTS_COOKIE_NAME); + assertThat(cookie).isNotNull(); + assertThat(cookie.getValue()).contains("[post-1]"); + assertThat(cookie.isHttpOnly()).isTrue(); + } + + @Test + @DisplayName("조회수 쿠키에 같은 게시글 표시가 있으면 중복 조회로 판단한다") + void markIfFirstView_withExistingMarker_returnsFalse() { + ViewCountCookieManager manager = new ViewCountCookieManager(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(ViewCountCookieManager.VIEWED_POSTS_COOKIE_NAME, "[post-1]")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean firstView = manager.markIfFirstView("post-1", request, response); + + assertThat(firstView).isFalse(); + assertThat(response.getCookie(ViewCountCookieManager.VIEWED_POSTS_COOKIE_NAME)).isNull(); + } + + @Test + @DisplayName("익명 투표자 쿠키가 없으면 새 anonymous_voter_id 쿠키를 발급한다") + void getOrCreate_withoutCookie_createsAnonymousVoterCookie() { + AnonymousVoterCookieManager manager = new AnonymousVoterCookieManager(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + String voterId = manager.getOrCreate(request, response); + + assertThat(voterId).isNotBlank(); + Cookie cookie = response.getCookie(AnonymousVoterCookieManager.ANONYMOUS_VOTER_COOKIE_NAME); + assertThat(cookie).isNotNull(); + assertThat(cookie.getValue()).isEqualTo(voterId); + assertThat(cookie.isHttpOnly()).isTrue(); + } + + @Test + @DisplayName("익명 투표자 쿠키가 있으면 기존 anonymous_voter_id 값을 재사용한다") + void getOrCreate_withCookie_reusesAnonymousVoterCookie() { + AnonymousVoterCookieManager manager = new AnonymousVoterCookieManager(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies( + new Cookie(AnonymousVoterCookieManager.ANONYMOUS_VOTER_COOKIE_NAME, "anon-1")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + String voterId = manager.getOrCreate(request, response); + + assertThat(voterId).isEqualTo("anon-1"); + assertThat(response.getCookie(AnonymousVoterCookieManager.ANONYMOUS_VOTER_COOKIE_NAME)) + .isNull(); + } + + @Test + @DisplayName("X-Forwarded-For 헤더가 여러 IP를 가지면 첫 번째 IP를 클라이언트 IP로 사용한다") + void resolve_withForwardedFor_usesFirstIp() { + ClientIpResolver resolver = new ClientIpResolver(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-For", "203.0.113.10, 10.0.0.1"); + + String ip = resolver.resolve(request); + + assertThat(ip).isEqualTo("203.0.113.10"); + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index 78a385a..b12aed3 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -1,6 +1,12 @@ spring: + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration + - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration + datasource: - url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: @@ -9,3 +15,6 @@ spring: hibernate: ddl-auto: create-drop show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect diff --git a/database/ddl.sql b/database/ddl.sql index 8b64dda..ee0891d 100644 --- a/database/ddl.sql +++ b/database/ddl.sql @@ -109,11 +109,12 @@ CREATE TABLE `post` ( `comment_count` INT NOT NULL DEFAULT 0 COMMENT '댓글 수 역정규화', `like_count` INT NOT NULL DEFAULT 0 COMMENT '추천 수 역정규화', `dislike_count` INT NOT NULL DEFAULT 0 COMMENT '비추천 수 역정규화', - `status` VARCHAR(20) NOT NULL DEFAULT 'NORMAL' COMMENT '게시글 상태 (NORMAL, HIDDEN, DELETED, BLOCKED)', + `has_image` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '이미지 첨부 여부 역정규화 (목록 뱃지 아이콘 렌더링용)', + `status` VARCHAR(20) NOT NULL DEFAULT 'NORMAL' COMMENT '게시글 상태 (NORMAL, DELETED, BLOCKED, HIDDEN, DRAFT)', `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Soft Delete 여부', + `deleted_at` DATETIME NULL COMMENT '삭제 일시', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성 일시', - - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '수정 일시', CONSTRAINT `fk_post_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE SET NULL, CONSTRAINT `fk_post_category` FOREIGN KEY (`category_id`) REFERENCES `post_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글'; @@ -132,12 +133,16 @@ CREATE TABLE `post_image` ( CREATE TABLE `post_reaction` ( `reaction_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '반응 식별자', `post_id` BIGINT NOT NULL COMMENT '게시글 ID', - `member_id` BIGINT NOT NULL COMMENT '회원 ID', + `member_id` BIGINT NULL COMMENT '회원 ID (비회원 투표 시 NULL)', + `writer_ip` VARCHAR(45) NULL COMMENT '투표자 IP 주소', + `anonymous_voter_id` VARCHAR(36) NULL COMMENT '비회원 익명 투표자 식별 쿠키 ID', `type` VARCHAR(10) NOT NULL COMMENT '반응 종류 (LIKE, DISLIKE)', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '투표 일시', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', CONSTRAINT `fk_pr_post` FOREIGN KEY (`post_id`) REFERENCES `post` (`post_id`) ON DELETE CASCADE, CONSTRAINT `fk_pr_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ON DELETE CASCADE, - CONSTRAINT `uk_post_member_reaction` UNIQUE (`post_id`, `member_id`) + CONSTRAINT `uk_post_member_type` UNIQUE (`post_id`, `member_id`, `type`), + CONSTRAINT `uk_post_anon_voter_type` UNIQUE (`post_id`, `anonymous_voter_id`, `type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='게시글 추천/비추천'; -- 11. 댓글 및 계층형 대댓글 테이블 @@ -151,6 +156,7 @@ CREATE TABLE `comment` ( `is_anonymous` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '익명 작성 여부', `anonymous_password` VARCHAR(255) NULL COMMENT '비회원 익명 수정/삭제용 비밀번호 (BCrypt)', `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Soft Delete 여부', + `deleted_at` DATETIME NULL COMMENT '삭제 일시', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성 일시', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', CONSTRAINT `fk_comment_post` FOREIGN KEY (`post_id`) REFERENCES `post` (`post_id`) ON DELETE CASCADE, diff --git a/docs/conception/sprint01/03.domain-model.md b/docs/conception/sprint01/03.domain-model.md index 5b4717f..dea3a79 100644 --- a/docs/conception/sprint01/03.domain-model.md +++ b/docs/conception/sprint01/03.domain-model.md @@ -36,10 +36,10 @@ Snowthing 플랫폼의 비즈니스 영역을 4개의 **Bounded Context (도메 * **도메인 목적**: 윈터스포츠 유저 간의 자유로운 정보 교류, 질의응답, 노하우 공유 및 추천/댓글 소통. * **핵심 도메인 객체 (Entities & Value Objects)**: * **`PostCategory` (카테고리)**: 자유게시판(`FREE`), 익명게시판(`ANONYMOUS`), 질문(`QNA`), 장비VS(`GEAR_VS`), 맛집(`FOOD`) 분류. - * **`Post` (게시글)**: 제목, 본문, 작성자 IP (`writer_ip`), 비회원 비밀번호 (`anonymous_password` BCrypt), 조회수, 카운트 역정규화(댓글수/추천수/비추천수), **게시글 상태 (`status`: `NORMAL`, `HIDDEN`, `DELETED`, `BLOCKED`)**, Soft Delete 여부. + * **`Post` (게시글)**: 제목, 본문, 작성자 IP (`writer_ip`), 비회원 비밀번호 (`anonymous_password` BCrypt), 조회수, 카운트 역정규화(댓글수/추천수/비추천수), **게시글 상태 (`status`: `NORMAL`, `HIDDEN`, `DELETED`, `BLOCKED`)**, Soft Delete 여부 (`is_deleted`), **삭제 시각 (`deleted_at` NULLABLE)**. * **`PostImage` (첨부 이미지)**: 게시글 1:N 첨부 이미지 파일. * **`PostReaction` (게시글 반응)**: 추천(`LIKE`) 및 비추천(`DISLIKE`) (1인 1회 중복 투표 제한, `@Async` 비동기 처리). - * **`Comment` (댓글/대댓글)**: 원댓글 및 계층형 대댓글 (`parentId`), 작성자 IP, 비회원 비밀번호, Soft Delete 여부. + * **`Comment` (댓글/대댓글)**: 원댓글 및 계층형 대댓글 (`parentId`), 작성자 IP, 비회원 비밀번호, Soft Delete 여부 (`is_deleted`), **삭제 시각 (`deleted_at` NULLABLE)**. --- diff --git a/docs/conception/sprint01/04.erd.md b/docs/conception/sprint01/04.erd.md index 75ac0ae..a7bacba 100644 --- a/docs/conception/sprint01/04.erd.md +++ b/docs/conception/sprint01/04.erd.md @@ -27,6 +27,7 @@ erDiagram MEMBER ||--o{ POST_REACTION : "투표함 (1:N)" POST ||--o{ COMMENT : "달림 (1:N)" + MEMBER ||--o{ MEMBER_PROFILE_IMAGE : "등록함 (1:N 대표1개+서브4개)" MEMBER ||--o{ COMMENT : "작성함 (1:N)" COMMENT ||--o{ COMMENT : "대댓글 (Self Reference)" @@ -36,7 +37,7 @@ erDiagram VARCHAR(100) email UK "로그인 이메일" VARCHAR(255) password "NULLABLE (OAuth2 대비)" VARCHAR(50) nickname UK "활동 닉네임" - VARCHAR(500) profile_image_url + VARCHAR(500) profile_image_url "대표 프로필 이미지 URL (역정규화)" VARCHAR(255) bio VARCHAR(100) departure_region BIGINT crew_id FK "소속 크루 ID" @@ -47,6 +48,15 @@ erDiagram DATETIME updated_at } + MEMBER_PROFILE_IMAGE { + BIGINT image_id PK "DB 내부 PK" + BIGINT member_id FK "회원 ID" + VARCHAR(500) image_url "프로필 이미지 URL" + BOOLEAN is_main "대표 프로필 여부" + INT sort_order "슬라이드 렌더링 순서 (0~4)" + DATETIME created_at + } + CREW { BIGINT crew_id PK VARCHAR(36) public_id UK @@ -99,8 +109,10 @@ erDiagram INT comment_count INT like_count INT dislike_count "비추천 수" - VARCHAR(20) status "게시글 상태 (NORMAL, HIDDEN, DELETED, BLOCKED)" + BOOLEAN has_image "이미지 첨부 여부 역정규화 (목록 뱃지용)" + VARCHAR(20) status "게시글 상태 (NORMAL, DELETED, BLOCKED, HIDDEN, DRAFT)" BOOLEAN is_deleted "Soft Delete 여부" + DATETIME deleted_at "Soft Delete 시각 (NULLABLE)" DATETIME created_at "작성 일시" DATETIME updated_at } @@ -131,6 +143,7 @@ erDiagram BOOLEAN is_anonymous VARCHAR(255) anonymous_password BOOLEAN is_deleted "Soft Delete 적용" + DATETIME deleted_at "Soft Delete 시각 (NULLABLE)" DATETIME created_at DATETIME updated_at } @@ -228,6 +241,7 @@ erDiagram | `dislike_count` | INT | NOT NULL, DEFAULT 0 | **비추천 수 역정규화** | | `status` | VARCHAR(20) | NOT NULL, DEFAULT 'NORMAL' | 게시글 상태 (`NORMAL`, `HIDDEN`, `DELETED`, `BLOCKED`) | | `is_deleted` | BOOLEAN | NOT NULL, DEFAULT FALSE | **Soft Delete** 여부 | +| `deleted_at` | DATETIME | NULL, DEFAULT NULL | **Soft Delete** 처리 시각 | | `created_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP, INDEX | 작성 일시 | | `updated_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 수정 일시 | @@ -245,10 +259,12 @@ erDiagram | :--- | :--- | :--- | :--- | | `reaction_id` | BIGINT | PK, AUTO_INCREMENT | 반응 식별자 | | `post_id` | BIGINT | FK (`post.post_id`), NOT NULL | 게시글 ID | -| `member_id` | BIGINT | FK (`member.member_id`), NOT NULL | 회원 ID | +| `member_id` | BIGINT | FK (`member.member_id`), NULLABLE | 회원 투표자 ID | +| `anonymous_voter_id` | VARCHAR(36) | NULLABLE, INDEX | 비로그인 익명 투표자 식별 쿠키 ID | | `type` | VARCHAR(10) | NOT NULL | 반응 종별 (`LIKE`: 추천, `DISLIKE`: 비추천) | | `created_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 투표 일시 | -| **복합 유니크** | - | `UNIQUE (post_id, member_id)` | **게시글당 회원 1회만 중복 방지** | +| **회원 복합 유니크** | - | `UNIQUE (post_id, member_id, type)` | 게시글당 회원/반응 타입별 중복 방지 | +| **익명 복합 유니크** | - | `UNIQUE (post_id, anonymous_voter_id, type)` | 게시글당 익명 쿠키 식별자/반응 타입별 중복 방지 | #### 11) `comment` (댓글 & 계층형 대댓글) | 컬럼명 | 데이터 타입 | 제약 조건 | 설명 | @@ -262,6 +278,7 @@ erDiagram | `is_anonymous` | BOOLEAN | NOT NULL, DEFAULT FALSE | 익명 작성 여부 | | `anonymous_password` | VARCHAR(255) | NULLABLE | 비회원 익명 비밀번호 (BCrypt) | | `is_deleted` | BOOLEAN | NOT NULL, DEFAULT FALSE | **Soft Delete** 적용 | +| `deleted_at` | DATETIME | NULL, DEFAULT NULL | **Soft Delete** 처리 시각 | | `created_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 작성 일시 | | `updated_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 수정 일시 | diff --git a/docs/conception/sprint01/05.api-spec.md b/docs/conception/sprint01/05.api-spec.md index 75a7222..f7afe11 100644 --- a/docs/conception/sprint01/05.api-spec.md +++ b/docs/conception/sprint01/05.api-spec.md @@ -5,10 +5,10 @@ Snowthing 플랫폼의 백엔드와 프론트엔드 간 통신을 위한 RESTful API 명세서입니다. REST 자원 중심(Resource-Oriented) 명사형 URL 체계, 세션 기반 인증 (`JSESSIONID`), 비회원/익명 작성 지원, 게시글 상태 관리(`status`), 비동기 추천/비추천 처리, 계층형 댓글 DTO 파싱, 그리고 글로벌 에러 응답 규격을 적용하였습니다. -* **Base URL**: `/api` +* **Base URL**: `/api/v1` * **인증 방식**: Session Cookie (`JSESSIONID`, `HttpOnly`, `Secure`, `SameSite=Lax`) * **데이터 포맷**: `application/json` -* **권한 체계**: `GUEST` (비회원), `ROLE_USER` (일반 회원), `ROLE_ADMIN` (관리자) +* **권한 체계**: `GUEST` (비회원), `ROLE_USER` (일반 회원), `ROLE_ADMIN` (최고 관리자) * **게시글 상태 (`status`)**: `NORMAL` (정상), `HIDDEN` (관리자 숨김), `DELETED` (Soft Delete), `BLOCKED` (차단) --- @@ -17,24 +17,24 @@ REST 자원 중심(Resource-Oriented) 명사형 URL 체계, 세션 기반 인증 | 분류 | HTTP Method | URI | 설명 | 인증 권한 | | :--- | :--- | :--- | :--- | :--- | -| **인증/회원** | `POST` | `/api/members` | 회원가입 | `PermitAll` | -| | `POST` | `/api/auth/login` | 세션 로그인 | `PermitAll` | -| | `POST` | `/api/auth/logout` | 세션 로그아웃 | `Authenticated` | -| | `GET` | `/api/members/me` | 내 프로필 상세 조회 | `Authenticated` | -| | `PUT` | `/api/members/me` | 내 프로필 수정 | `Authenticated` | -| | `GET` | `/api/members/{publicId}` | 타인 프로필 조회 | `PermitAll` | -| **게시글** | `GET` | `/api/posts` | 게시글 목록 조회 (카테고리/검색/페이징) | `PermitAll` | -| | `POST` | `/api/posts` | 게시글 작성 (회원/비회원 익명) | `PermitAll` | -| | `GET` | `/api/posts/{publicId}` | 게시글 상세 조회 (조회수 +1) | `PermitAll` | -| | `PUT` | `/api/posts/{publicId}` | 게시글 수정 | `Owner / Anon Password` | -| | `DELETE` | `/api/posts/{publicId}` | 게시글 삭제 (Soft Delete) | `Owner / Anon Password` | -| | `POST` | `/api/posts/{publicId}/reactions` | 게시글 추천/비추천 투표 (**비동기**) | `Authenticated` | -| **댓글** | `GET` | `/api/posts/{publicId}/comments` | 댓글/대댓글 계층형 목록 조회 | `PermitAll` | -| | `POST` | `/api/posts/{publicId}/comments` | 댓글/대댓글 작성 (회원/비회원) | `PermitAll` | -| | `DELETE` | `/api/comments/{commentId}` | 댓글 삭제 (Soft Delete) | `Owner / Anon Password` | -| **마스터** | `GET` | `/api/resorts` | 리조트 목록 조회 | `PermitAll` | -| | `GET` | `/api/riding-styles` | 라이딩 스타일 목록 조회 | `PermitAll` | -| | `GET` | `/api/categories` | 게시판 카테고리 목록 조회 | `PermitAll` | +| **인증/회원** | `POST` | `/api/v1/members` | 회원가입 | `PermitAll` | +| | `POST` | `/api/v1/auth/login` | 세션 로그인 | `PermitAll` | +| | `POST` | `/api/v1/auth/logout` | 세션 로그아웃 | `Authenticated` | +| | `GET` | `/api/v1/members/me` | 내 프로필 상세 조회 | `Authenticated` | +| | `PUT` | `/api/v1/members/me` | 내 프로필 수정 | `Authenticated` | +| | `GET` | `/api/v1/members/{publicId}` | 타인 프로필 조회 | `PermitAll` | +| **게시글** | `GET` | `/api/v1/posts` | 게시글 목록 조회 (카테고리/검색/페이징) | `PermitAll` | +| | `POST` | `/api/v1/posts` | 게시글 작성 (회원/비회원 익명) | `PermitAll` | +| | `GET` | `/api/v1/posts/{publicId}` | 게시글 상세 조회 (`viewed_posts` 쿠키 기준 중복 조회수 방지) | `PermitAll` | +| | `PUT` | `/api/v1/posts/{publicId}` | 게시글 수정 (작성자 본인 / 로그인한 익명 작성자 패스워드 생략) | `Owner / Anon Password` | +| | `DELETE` | `/api/v1/posts/{publicId}` | 게시글 삭제 (작성자 본인 / 비회원 익명 비밀번호 / **`ROLE_ADMIN` 최고 관리자 패스워드 없이 삭제 가능**) | `Owner / Anon Password / ADMIN` | +| | `POST` | `/api/v1/posts/{publicId}/reactions` | 게시글 추천/비추천 투표 (**비동기**, 회원/익명 쿠키 식별자 지원) | `PermitAll` | +| **댓글** | `GET` | `/api/v1/posts/{publicId}/comments` | 댓글/대댓글 계층형 목록 조회 | `PermitAll` | +| | `POST` | `/api/v1/posts/{publicId}/comments` | 댓글/대댓글 작성 (회원/비회원) | `PermitAll` | +| | `DELETE` | `/api/v1/comments/{commentId}` | 댓글 삭제 (Soft Delete / 작성자 본인 / **`ROLE_ADMIN` 관리자 가능**) | `Owner / Anon Password / ADMIN` | +| **마스터** | `GET` | `/api/v1/resorts` | 리조트 목록 조회 | `PermitAll` | +| | `GET` | `/api/v1/riding-styles` | 라이딩 스타일 목록 조회 | `PermitAll` | +| | `GET` | `/api/v1/categories` | 게시판 카테고리 목록 조회 | `PermitAll` | --- diff --git a/docs/project/project.md b/docs/project/project.md index c03fd54..beb9d76 100644 --- a/docs/project/project.md +++ b/docs/project/project.md @@ -1,89 +1,159 @@ -## AI 게시글 요약 - -- 게시글 본문만 요약 -- 자유게시판/익명게시판 시간대별 흐름 요약 -- 글이 너무 적으면 “요약할 내용 부족으로 따로 요약 안함” - -## 자유게시판 / 댓글 / 대댓글 - -- 댓글은 무한 대댓글? -- 댓글 조회는 parent_id 구조로 가져오거나, 깊이 제한 후 정렬해서 조립 -- 댓글 삭제를 물리 삭제할지, 소프트 삭제할지 - - 상위 댓글 삭제 시 하위 대댓글은 남기고 “삭제된 댓글입니다” 처리 -- 댓글/대댓글 알림 범위 필요 - - 예: 내 글에 댓글, 내 댓글에 대댓글, 내가 멘션당함 -- 댓글 이미지 허용 여부 결 - - 이미지 허용 시 용량 제한, 개수는 하나 -- 조회수 증가 조건 필요 -- 예: 같은 유저/IP는 일정 시간 내 1회만 증가 -- 본인 조회는 카운트할지 제외할지 결정 필요 - -## 카풀 비용 계산기 - -- 출발지, 목적지, 경유지 입력 필요 -- 톨비 API나 지도 API 연동 가능 여부 확인 -- 인원 수로 1인 비용 자동 계산 -- 편도 -- 실제 정산은 서비스가 하지 않고 계산만 제공하는 방식 추천 -- 카풀 모집글과 연결 - -## 카테고리별 장비 VS - -- 카테고리 필요 - - 예: 데크,부츠, 바인딩, 고글 -- VS 후보를 운영자가 만들지, 유저 투표로 만들지 -- 인기 장비끼리 자동 매칭 가능 - 같은 카테고리 내에서 -- 투표 댓글 전부 익명 -- AI가 일부러 논쟁 질문을 던지는 방식 - - 예: 입문자한테 이 데크는 오버스펙 아닌가요? -- 승패 결과를 누적해서 장비 랭킹으로 확장 가능 - -## 리조트 맛집 - -- 리조트별 맛집 등록 -- 메뉴 추천, 가격대, 영업시간 -- 시즌 중 휴무/브레이크타임 -- 맛집 댓글/평점/사진 기능 - -## 리프트 줄 제보 - -- 스키장 웹캠/API에서 이미지 한 프레임 수집 -- AI가 리프트별 혼잡도 판단 -- 상태값은 적음 / 보통 / 혼잡 / 판단불가 -- 날씨, 야간, 안개, 카메라 가림은 판단불가 -- 원본 이미지는 저장하지 않고 분석 결과만 저장 -- 리조트별/리프트별 최근 갱신 시간 표시 필요 -- 너무 자주 호출하면 비용 문제 생기므로 주기 조절 필요 - -## 강습매칭 - -- 결제는 서비스 밖에서 알아서 -- 서비스는 강사 소개와 대화 연결만 제공 -- 강사 프로필 필요 - - 예: 경력, 가능 리조트, 가능 시간, 강습 레벨, 스타일, 후기 -- 사용자 요청 조건 필요 - - 예: 초보/중급/상급, 카빙, 트릭, 여성 강사 선호, 촬영 포함 -- 매칭 후 쪽지나 채팅으로 연결 -- 강습 후 후기 작성 가능 -- 허위 강사/사기 방지를 위한 신고 기능 필요 - -## 보더명함 - -- 베이스 리조트, 장비, 주 활동 요일/시간 표시 -- 라이딩 스타일 표시 - - 예: 카빙, 트릭, 파크, 관광, 촬영 -- 쪽지 -- 상대 보더명함 저장 기능 -- 저장한 명함 목록에서 다시 쪽지 가능 -- 공개 범위 설정 필요 -- QR 코드나 공유 이미지로 확장 가능 - -## 같이타요 매칭 - -- 사용자가 매칭 ON/OFF 설정 -- 같은 리조트, 날짜, 시간대, 실력, 스타일, 성향 등을 기준 매칭 -- 예: 빡연습, 설렁설렁, 촬영 위주, 초보 환영 -- 매칭되면 서로 보더명함 확인 -- 바로 채팅보다 매칭 요청 → 수락 → 채팅 -- 원치 않는 매칭 방지를 위해 차단/신고 필요 -- 성별/나이 공개 여부는 민감하므로 선택 공개 추천 \ No newline at end of file +## AI 게시글 요약 + +- 게시글 본문만 요약 +- 자유게시판/익명게시판 시간대별 흐름 요약 +- 글이 너무 적으면 “요약할 내용 부족으로 따로 요약 안함” + +## 자유게시판 / 댓글 / 대댓글 / 게시글 권한 및 상태 정책 + +### 1. 게시글 및 댓글 수정/삭제 권한 정책 +- **수정 권한 (Edit Permission)**: **작성자 본인만 수정 가능** (`PUT /api/v1/posts/{id}`). + - **관리자(`ROLE_ADMIN`) 포함 타인 수정 불가**: 관리자라 하더라도 타인의 게시글 본문을 임의로 수정하는 행위는 내용 왜곡 및 책임 소재(명의 도용) 논란이 발생하므로 **수정 권한이 100% 차단**됨. +- **관리자 권한 범위 (Admin Permission)**: 관리자는 수정 권한이 없으며, 부적절한 게시글에 대해 **블라인드/차단 처리(`status = BLOCKED`)만 가능**. +- **비회원 익명글 권한**: 작성 시 입력한 익명 비밀번호(BCrypt) 일치 시에만 수정 가능. + +### 2. 게시글 상태 Enum (`PostStatus`) 정책 +- **`NORMAL`**: 정상 게시글 (목록 및 상세 전체 노출) +- **`DELETED`**: 작성자 본인에 의해 Soft Delete 됨 (`is_deleted = true`, `deleted_at`) +- **`BLOCKED`**: 관리자에 의해 규정 위반으로 블라인드/차단 처리됨 +- **`HIDDEN`**: 비공개 상태 (작성자 본인만 조회 가능) +- **`DRAFT`**: 임시작성 상태 (목록 노출 제외 및 작성자 임시 보관) + +### 3. 삭제/차단된 게시글 UX 및 클릭 모달(Alert) 처리 정책 +- **목록 노출**: 인기글(HOT)이나 게시판 목록에 노출되던 글이 삭제/차단되더라도, 목록에서 갑자기 뿅 사라져 유저가 혼란을 겪지 않도록 제목 형태(`"[삭제된 게시글입니다]"`)로 유지. +- **상세 페이지 미진입 & 클릭 알럿 팝업**: 유저가 삭제/차단된 글을 클릭 시 **상세 페이지로 라우팅 이동하지 않고**, 목록 화면 상에서 즉시 **"삭제된 게시글입니다"** 또는 **"관리자에 의해 차단된 게시글입니다"** 알럿(Modal/Alert) 창을 띄워 유저 경험(UX) 최적화 및 불필요한 서버 상세 조회 요청 방지. + +### 4. 댓글 및 대댓글 정책 +- **대댓글 구조**: `parent_id` 기반 계층형 대댓글 구조 (2 depth 지원). +- **상위 댓글 삭제 시**: 하위 대댓글이 존재할 경우 상위 댓글은 물리 삭제되지 않고 `is_deleted = true` 처리 후 **"삭제된 댓글입니다"** 문구 노출. 하위 대댓글이 모두 삭제되면 자동 뷰 정돈. +- **조회수 증가 정책**: 동일 유저/IP 기준 일정 시간(24시간) 내 1회만 증가 (`Redis` 또는 쿠키/세션 핑거프린팅 기반). 본인 조회는 카운트 제외. + +## 카풀 비용 계산기 + +- 출발지, 목적지, 경유지 입력 필요 +- 톨비 API나 지도 API 연동 가능 여부 확인 +- 인원 수로 1인 비용 자동 계산 +- 편도 +- 실제 정산은 서비스가 하지 않고 계산만 제공하는 방식 추천 +- 카풀 모집글과 연결 + +## 카테고리별 장비 VS + +- 카테고리 필요 + - 예: 데크,부츠, 바인딩, 고글 +- VS 후보를 운영자가 만들지, 유저 투표로 만들지 +- 인기 장비끼리 자동 매칭 가능 - 같은 카테고리 내에서 +- 투표 댓글 전부 익명 +- AI가 일부러 논쟁 질문을 던지는 방식 + - 예: 입문자한테 이 데크는 오버스펙 아닌가요? +- 승패 결과를 누적해서 장비 랭킹으로 확장 가능 + +## 리조트 맛집 + +- 리조트별 맛집 등록 +- 메뉴 추천, 가격대, 영업시간 +- 시즌 중 휴무/브레이크타임 +- 맛집 댓글/평점/사진 기능 + +## 리프트 줄 제보 + +- 스키장 웹캠/API에서 이미지 한 프레임 수집 +- AI가 리프트별 혼잡도 판단 +- 상태값은 적음 / 보통 / 혼잡 / 판단불가 +- 날씨, 야간, 안개, 카메라 가림은 판단불가 +- 원본 이미지는 저장하지 않고 분석 결과만 저장 +- 리조트별/리프트별 최근 갱신 시간 표시 필요 +- 너무 자주 호출하면 비용 문제 생기므로 주기 조절 필요 + +## 강습매칭 + +- 결제는 서비스 밖에서 알아서 +- 서비스는 강사 소개와 대화 연결만 제공 +- 강사 프로필 필요 + - 예: 경력, 가능 리조트, 가능 시간, 강습 레벨, 스타일, 후기 +- 사용자 요청 조건 필요 + - 예: 초보/중급/상급, 카빙, 트릭, 여성 강사 선호, 촬영 포함 +- 매칭 후 쪽지나 채팅으로 연결 +- 강습 후 후기 작성 가능 +- 허위 강사/사기 방지를 위한 신고 기능 필요 + +## 보더명함 + +- 베이스 리조트, 장비, 주 활동 요일/시간 표시 +- 라이딩 스타일 표시 + - 예: 카빙, 트릭, 파크, 관광, 촬영 +- 쪽지 +- 상대 보더명함 저장 기능 +- 저장한 명함 목록에서 다시 쪽지 가능 +- 공개 범위 설정 필요 +- QR 코드나 공유 이미지로 확장 가능 + +## 같이타요 매칭 + +- 사용자가 매칭 ON/OFF 설정 +- 같은 리조트, 날짜, 시간대, 실력, 스타일, 성향 등을 기준 매칭 +- 예: 빡연습, 설렁설렁, 촬영 위주, 초보 환영 +- 매칭되면 서로 보더명함 확인 +- 바로 채팅보다 매칭 요청 → 수락 → 채팅 +- 원치 않는 매칭 방지를 위해 차단/신고 필요 +- 성별/나이 공개 여부는 민감하므로 선택 공개 추천 + +## 회원 다중 프로필 사진 (대표 1개 + 서브 4개) 및 갤러리 슬라이더 + +### 1. 구상 개요 & 목적 +- 프로필 설정 시 회원은 **대표 프로필 사진 1장 (`is_main = true`) + 추가 갤러리 사진 최대 4장 (총 5장)**을 등록 가능. +- 스노보더 특성상 라이딩 샷, 칼날 카빙 샷, 기물/트릭 샷, 장비 샷, 셀카 등 다양한 사진으로 유저를 개성 있게 표현. + +### 2. 노출 및 UI/UX 정책 +- **댓글 / 게시글 목록 노출**: + - 목록 조회 시 조인 쿼리 횟수와 데이터 전송량을 최소화하기 위해 **대표 프로필 사진 1개만 노출** (`Member.profile_image_url` 역정규화 컬럼 활용). +- **댓글 작성자 클릭 팝업 / 보더명함 / 같이타요 매칭 조회**: + - 작성자 닉네임 또는 대표 사진 클릭 시 **유저 프로필 카드 모달 팝업** 출력. + - 팝업 모달 내부에서 5장의 사진을 ◀ ▶ 버튼 또는 스와이프 **슬라이더/캐러셀 형태**로 넘겨서 볼 수 있음. + +### 3. 데이터베이스 및 엔티티 설계 (`MEMBER_PROFILE_IMAGE`) +- 1:N 관계 테이블 분리 (`member_id` FK). +- `image_id` (PK), `member_id` (FK), `image_url` (VARCHAR 500), `is_main` (BOOLEAN), `sort_order` (INT 0~4). +- `Member.profile_image_url` 컬럼에 대표 이미지 URL을 역정규화(Denormalization)하여 추가 조인 쿼리 방지. + +### 4. 핵심 트레이드오프 (Trade-off) 및 극복 방안 +- **트레이드오프**: 대표 이미지 변경 시 역정규화 컬럼(`profile_image_url`)과 1:N 테이블 간 불일치 가능성. +- **극복 방안**: `Member.updateProfileImages()` 도메인 메서드로 엔티티 캡슐화 후 한 트랜잭션 내에서 동기화 수행. + +## 익명 게시판 (전체 / 리조트별 대나무숲) 및 통합 인기 게시판 (개념글) + +### 1. 구상 개요 & 목적 +- **전체 익명 게시판 & 리조트별 대나무숲**: + - 특정 리조트에 얽매이지 않는 **전체 통합 익명 게시판**(`category = ANONYMOUS`, `resort_id = null`)과 **리조트별 대나무숲**(`category = ANONYMOUS`, `resort_id = 1, 2...`) 구조를 제공하여 스노우보더/스키어들의 솔직하고 자유로운 소통 유도. +- **통합 인기 게시판 (개념글/베스트글)**: + - 일반 게시판(자유게시판, QNA 등)의 인기글과 익명 게시판(대나무숲)의 인기글을 **하나의 [통합 인기 게시판] 탭으로 합산하여 노출** (에펨코리아 포텐, 디시 개념글 방식). + - 리스트 렌더링 시 익명글에는 **`[익명 대나무숲]` 뱃지(Badge)**를 부여하여 시각적 구분을 제공하며, 상단 필터 칩으로 [전체 | 일반만 | 익명만] 선택 유연성 확보. + +### 2. 개인정보 보안 및 노출 정책 +- 익명 게시판 글 조회 시 작성자의 닉네임과 프로필 사진을 백엔드 DTO 매퍼 수준에서 무조건 `"익명보더"` 및 기본 프로필 이미지로 원자적 치환하여 개인정보 유출 100% 방지. + +### 3. 인기글 산출 및 갱신 방식 (추후 세부 기준 확정) +- 추천수 산출 기준 및 갱신 주기(이벤트 기반 임계치 승격 패턴 등)는 추후 상세 논의를 거쳐 확정 및 구현 진행. + +## 게시판 페이징 및 검색 (웹 Offset / 모바일 Cursor 하이브리드) + +### 1. 웹 & 모바일 이원화 페이징 +- **웹 (Page Button)**: `[1][2][3]` 페이지 번호 네비게이션 제공. 커버링 인덱스 쿼리 및 최대 100페이지 제한(Hard Cap) 적용. (추후 JWT/Redis 도입 시 목록 캐싱 추가) +- **모바일 (Infinite Scroll)**: Keyset Cursor 방식 (`$O(1)$`) 적용. + +### 2. 동적 검색 및 정렬 +- 카테고리, 검색어(제목/본문/작성자), 정렬(최신순/인기순) Querydsl 동적 검색 지원. + +## 게시글 에디터 & 목록 시각적 뱃지 아이콘 아키텍처 + +### 1. 무료 마크다운 에디터 스택 (Toast UI Editor) +- **에디터 스택**: NHN 개발 100% 무료 오픈소스(MIT) **Toast UI Editor (`@toast-ui/react-editor`)** 채택. (Next.js SSR `dynamic` 로딩 적용). +- **본문 이미지-텍스트 인라인 교차 작성**: 이미지 업로드/드래그앤드롭 시 현재 커서 위치에 `![설명](url)` 마크다운 구문이 삽입되어 `[텍스트 ➔ 이미지 ➔ 텍스트 ➔ 이미지]` 흐름의 본문 작성 지원. +- **XSS 안전성**: 마크다운 렌더링 기반으로 악성 자바스크립트(`script` 태그) 공격 100% 차단. + +### 2. 목록 이미지 뱃지 아이콘 역정규화 (`Post.has_image`) +- **역정규화 설계**: `Post` 메인 엔티티에 `has_image` (`BOOLEAN`, 기본값 `false`) 컬럼을 추가하여 목록 조회 시 `post_image` 테이블과의 1:N 조인 쿼리를 물리적으로 0건으로 차단. +- **목록 뱃지 렌더링**: 프론트엔드 목록 조회 DTO(`PostListResponse`)에 `hasImage` 파라미터를 넘겨주어, 이미지가 첨부된 글 옆에 **초록색 산 이미지 아이콘(🖼️)** 및 **댓글 말풍선 아이콘(💬)** 시각적 뱃지 렌더링. +- **데이터 정합성 보장**: 게시글 작성/수정 시 `Post.updateImageStatus()` 도메인 캡슐화 메서드를 통해 한 트랜잭션 내에서 `has_image` 값을 원자적(Atomic)으로 동기화. + + \ No newline at end of file diff --git a/docs/project/work.md b/docs/project/work.md index bf273c1..0efe4b1 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,277 @@ +- **가상 스레드(Virtual Thread) 조기 최적화 제거 및 표준 플랫폼 스레드 풀 전환 (2026-08-28)**: + 1. **가상 스레드 제거 및 표준화 (YAGNI)**: BCrypt 암호화 연산 병목, HikariCP 커넥션 풀 고갈 위험, Thread Pinning 등 조기 최적화로 인한 잠재적 결함을 방지하기 위해, `application.yml`에서 `spring.threads.virtual.enabled: true`를 완전 제거하고 `spring.jpa.open-in-view: false`를 명시하여 DB 커넥션 점유 최소화. + 2. **표준 `ThreadPoolTaskExecutor` 적용**: `AsyncConfig.java`에서 가상 스레드 대신 예측 가능하고 검증된 고정 플랫폼 스레드 풀(Core 8, Max 16, Queue 100, Prefix `async-worker-`)로 전환. + 3. **전체 단위/통합 테스트 검증**: Spotless 포맷팅(`spotlessApply`) 및 전체 90개 백엔드 단위/통합 테스트(`gradle test`) **100% BUILD SUCCESSFUL** 통과. + +- **MemberService 인라인 패키지명(FQN) 정리 및 상단 import 최적화 (2026-08-28)**: + 1. **가독성 및 컨벤션 교정**: `MemberService.java` 내부에 인라인으로 산재하던 풀 패키지명(`com.ikae.snowthing.global.exception.CustomAuthException`, `com.ikae.snowthing.global.error.ErrorCode`)을 클래스 상단 `import`로 승격하여 코드 가독성 및 일관성 확보. + 2. **린터 및 단위/통합 테스트 검증**: Google Java Format AOSP 기반 `spotlessApply` 서식 교정 및 백엔드 90개 전체 단위/통합 테스트(`gradle test`) **100% BUILD SUCCESSFUL** 통과. + +- **게시글 삭제 API 민감정보 `@RequestParam` 완전 제거 및 Request Body DTO 단일 계약 전환 (2026-08-27)**: + 1. **URL 파라미터 민감정보 노출 원천 차단**: `PostController.java`의 `deletePost` 메서드에서 잔여 레거시였던 `@RequestParam(required = false) String anonymousPassword`를 완전히 제거하고, 오직 `@RequestBody(required = false) PostDeleteRequest request`를 통해서만 비밀번호를 수신하도록 API 계약 단일화. + 2. **삭제 권한 정책 6대 시나리오 전수 검증**: `PostControllerTest.java`를 통해 로그인 작성자(200 OK), 최고 관리자(200 OK), 비회원 익명 올바른 비밀번호(200 OK), 비회원 익명 틀린 비밀번호(403 Forbidden - `POST_004`), **관계없는 제3자 로그인 회원이 익명글 비밀번호를 알고 입력 시 삭제 허용(200 OK)** 및 틀린 비밀번호 시 거부(403 Forbidden) 정책을 100% 검증. + 3. **전체 단위/통합 테스트 검증**: Spotless 포맷팅(`spotlessApply`) 및 전체 90개 백엔드 단위/통합 테스트(`gradle test`) **100% BUILD SUCCESSFUL** 통과. + +- **댓글 생성 쓰기 트랜잭션(`@Transactional`) 선언 및 전역 500 에러 핸들러 보강 (2026-08-27)**: + 1. **MySQL Read-Only 커넥션 쓰기 차단 원천 해결**: `CommentService.java`의 클래스 레벨 `@Transactional(readOnly = true)`로 인해 `createComment` 실행 시 MySQL JDBC 드라이버가 `Connection is read-only` 에러를 발생시키던 문제를 해결하기 위해, `createComment` 메서드 상단에 쓰기 전용 `@Transactional`을 명시하여 정상적인 INSERT 커넥션 획득 보장. + 2. **Security 401 오분류 방지 전역 500 핸들러 신설**: `GlobalExceptionHandler.java`에 `@ExceptionHandler(Exception.class)`를 추가하여, 예기치 않은 인프라/시스템 예외가 서블릿 밖으로 흘러나가 Spring Security EntryPoint의 401(INVALID_CREDENTIALS)로 둔갑하던 결함을 차단하고 정확한 500 INTERNAL_SERVER_ERROR로 일원화. + 3. **전체 단위/통합 테스트 검증**: Spotless 서식 교정(`spotlessApply`) 및 백엔드 90개 전체 단위/통합 테스트(`gradle test`) **100% PASS** 완료. + +- **익명/일반 게시판 댓글 도메인 정책 완결 (로그인 회원 비밀번호 생략 & 비로그인 비밀번호 필수) (2026-08-27)**: + 1. **백엔드 `CommentService` 정책 일원화**: `createComment`에서 로그인 회원(`userDetails != null`)이 익명 댓글을 작성할 때 `anonymousPassword` 없이도 `member`를 정상 연관관계 매핑하여 즉시 작성되고, 작성자 본인 세션으로 비밀번호 없이 즉시 삭제 가능하도록 개선. 비로그인 사용자만 삭제용 비밀번호 필수로 일원화. + 2. **프론트엔드 UI/UX 최적화**: `[publicId]/page.tsx`에서 익명 게시판 진입 시 로그인 회원에게는 불필요한 비밀번호 입력란을 완전히 숨기고, 비로그인 사용자에게만 비밀번호 입력을 유도하여 매끄러운 UX 확립. + 3. **전체 단위/통합 테스트 및 빌드 검증**: 백엔드 전체 테스트 수트(`gradle test`) **100% BUILD SUCCESSFUL** 및 Next.js 16 최적화 프로덕션 빌드(`npm run build`) **100% SUCCESS** 통과 완료. + +- **조회수/추천수/댓글수 증감 시 `updated_at` 고스트 업데이트 방지 및 벌크 쿼리 분리 (2026-08-27)**: + 1. **고스트 업데이트(Ghost Update) 원천 차단**: 조회수(`view_count`), 추천/비추천(`like_count`/`dislike_count`), 댓글수(`comment_count`) 증감 시 영속성 컨텍스트 더티 체킹으로 인해 `updated_at`이 갱신되던 문제를 해결하기 위해, `PostRepository`에 `@Modifying(flushAutomatically = true, clearAutomatically = true)` 벌크 쿼리 7종을 도입하여 엔티티 감사(Audit) 메타데이터 터치를 물리적으로 배제. + 2. **DDL 및 감사 정책 일원화**: `database/ddl.sql`의 `post` 테이블에서 `ON UPDATE CURRENT_TIMESTAMP`를 제거하여, 사용자가 실제 본문/제목을 수정(`post.update(...)`)한 경우에만 JPA Auditing(`@LastModifiedDate`)이 `updated_at`을 최신 시점으로 갱신하도록 전담화. + 3. **단위/통합 테스트 전수 검증**: `PostServiceTest.java` 내 `PostUpdatedAtGhostUpdatePolicyTest`를 신설하여 조회/추천/댓글 시 `updated_at` 불변 유지 및 본문 수정 시에만 갱신되는 동작 검증 완료 (전체 90개 테스트 100% PASS). + +- **H2/MySQL DataSource 프로필 분리 및 MySQL 8.0 전용 프로필 단일화 (2026-08-27)**: + 1. **프로필 기반 분리 및 H2 완전 제거**: `application.yml`에서 메인 애플리케이션용 H2 프로필 및 H2 콘솔 설정을 전면 제거하고, 실제 **MySQL 8.0** 데이터베이스 전용 프로필(`local`, `docker`, `prod`)로 완전 단일화. + - `local` 프로필: `jdbc:mysql://localhost:3306/snowthing?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul` (`ddl-auto: update`) + - `docker`/`prod` 프로필: `jdbc:mysql://mysql:3306/snowthing?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul` (`ddl-auto: validate`) + 2. **잉여 중복 파일 제거**: `backend/src/main/resources/application.yaml` 중복 파싱 파일 완전 제거. + 3. **전체 단위/통합 테스트 검증**: 독립 테스트 프로필(`application-test.yml`) 및 백엔드 테스트 수트(`gradle test`) 100% PASS 검증 완료. + +- **Spring Boot 4.1.0 판올림 & Spotless 린터(Google Java Format AOSP) 전면 도입 & GitHub Actions CI 연동 (2026-08-26)**: + 1. **Spring Boot 4.1.0 호환성 전수 교정**: + - `backend/build.gradle` 버전을 `4.1.0` (Spring Framework 7.x, Spring Security 7.x, Jakarta EE)으로 적용. + - Spring Boot 4의 모듈화에 맞춰 `spring-boot-starter-jackson`, `jackson-databind`, `spring-boot-starter-webmvc-test` 명시적 의존성 추가. + - Spring Security 7.x 아키텍처 변경에 맞춰 `SecurityConfig`의 `logoutUrl("/api/v1/auth/logout")` 적용 및 `ObjectMapper` 빈 등록. + - MockMvc 테스트에서 `@AuthenticationPrincipal` 인증 주입을 위한 `SecurityMockMvcRequestPostProcessors.user()` 일괄 적용. + 2. **Spotless Linter 구축 및 전체 소스코드 자동 서식 교정**: + - `Spotless 6.25.0` Gradle 플러그인 연동 및 Google Java Format 1.22.0 (AOSP 4-space indent) 표준 규칙 적용. + - `removeUnusedImports()`, `importOrder()`, `trimTrailingWhitespace()`, `endWithNewline()` 설정. + - `.\gradlew.bat spotlessApply`로 프로젝트 전체 Java 소스코드의 import 및 FQCN/들여쓰기 자동 정돈. + 3. **GitHub Actions CI 파이프라인 연동**: + - `.github/workflows/gradle.yml`에 `Run Spotless Linter Check` 스텝을 추가하여 PR/Push 시 코드 서식 및 import 위반을 자동 검증하고 Fast-Fail 처리. + 4. **전체 테스트 및 빌드 검증**: + - `.\gradlew.bat clean build` (spotlessCheck + compile + 92개 전체 단위/통합 테스트) **100% SUCCESS**. + +- **Response DTO `@Builder` 지양 및 표준 생성자/정적 팩토리 메서드 전환 (2026-08-26)**: + 1. **빌더 제거 및 타입 안전성 확보**: `PostDetailResponse`, `CommentResponse`, `PostListResponse`, `PostResponse`에서 `@Builder`를 제거하고 Java `record` 표준 생성자 및 `from(Entity)` 정적 팩토리 메서드로 전환하여 필드 누락 컴파일 타임 방어. + 2. **전체 테스트 검증**: 83개 전체 테스트 PASS. + +- **게시글 조회 엔드포인트 분리 (웹 Offset vs 모바일 Cursor) (2026-08-26)**: + 1. **엔드포인트 분리**: `GET /api/v1/posts`(웹 번호 기반 Offset 페이징)와 `GET /api/v1/posts/scroll`(모바일 무한 스크롤 커서 페이징)로 책임을 완벽히 분리. + 2. **스펙 및 검증 명확화**: 단일 엔드포인트 내의 불필요한 분기 로직을 제거하고 각 클라이언트 스펙에 맞게 정돈. + 3. **통합 테스트 작성 및 검증**: 83개 전체 테스트 PASS. + +- **`DataIntegrityViolationException` 오분류 방지 및 2단계 방어선 예외 처리 아키텍처 (2026-08-26)**: + 1. **1차 방어선 (Service)**: `existsByEmail()`, `existsByNickname()` 사전 비즈니스 검증에 집중하고 DB 인프라 예외 `try-catch` 완전 제거. + 2. **최종 방어선 (GlobalExceptionHandler)**: 동시성 충돌 시의 `DataIntegrityViolationException`을 전역 어드바이스에서 일괄 감지하여 로깅 및 안전한 클라이언트 에러로 변환. + 3. **전체 테스트 검증**: 전체 81개 테스트 PASS. + +- **DTO Non-null 보장 컬렉션에 대한 중복 null 체크 제거 (2026-08-26)**: + 1. **중복 검사 제거**: `MemberService.signUp` 및 `updateMyProfile`에서 DTO가 이미 Non-null(방어적 복사 및 빈 리스트 초기화)을 보장하므로 `!= null` 검사를 제거하고 `!isEmpty()` 조건만 유지. + 2. **전체 테스트 검증**: 전체 81개 테스트 PASS. + +- **`saveAndFlush()` 및 `flush()` 지양, 표준 `save()` 및 `GlobalExceptionHandler` 이관 (2026-08-26)**: + 1. **명시적 flush 제거**: `MemberService.signUp` 및 `updateMyProfile`에서 `saveAndFlush()`와 `flush()`를 제거하고 표준 `save()`로 변경하여 JPA 쓰기 지연(Write-Behind) 최적화 복원. + 2. **전역 예외 처리 일원화**: 동시성 제약조건 위반(`DataIntegrityViolationException`) 처리를 서비스 내 `try-catch`에서 `GlobalExceptionHandler`로 이관. + 3. **전체 테스트 검증**: 전체 81개 테스트 PASS. + +- **댓글 2-Pass 계층 트리 조립 알고리즘 적용 및 Early Return 리팩토링 (2026-08-26)**: + 1. **2-Pass 트리 조립 (Order-independent)**: `CommentService.getCommentsByPost`에서 [Pass 1] Map 선제 등록 ➔ [Pass 2] 부모-자식 트리 바인딩으로 분리하여 쿼리 정렬 순서에 따른 자식 댓글 누락(Silent Data Drop) 방어. + 2. **Early Return 가드 절**: `validateDeletePermission`에서 중첩된 `if-else` 분기를 제거하고 조기 반환을 적용하여 인지 부하 감소 및 코드 평탄화. + 3. **전체 테스트 검증**: 전체 81개 테스트 PASS. + +- **댓글 생성 트랜잭션 범위 최소화 및 `TransactionTemplate` 기반 암호화 격리 (2026-08-26)**: + 1. **트랜잭션 외 암호화/검증 선제 처리**: `CommentService.createComment`에서 BCrypt 해싱(`passwordEncoder.encode`) 및 파라미터 null 검증을 트랜잭션 시작 전에 수행하여 DB 커넥션 점유 시간 최소화. + 2. **`TransactionTemplate` 적용**: `Post`, `Member`, `Parent` 엔티티 조회, `Comment` 저장, `post.increaseCommentCount()` 원자적 상태 변경 구간만 `transactionTemplate.execute()` 내부로 국소화. + 3. **전체 테스트 검증**: 전체 81개 테스트 PASS. + +- **`humanize-korean` (`im-not-ai`) 스킬 탑재 및 PR 답변 톤앤매너 적용 (2026-08-26)**: + 1. **스킬 탑재 (`.agents/skills/humanize-korean/SKILL.md`)**: 번역투, 기계적 나열, AI 상투적 과장 표현을 배제하고 실제 현업 엔지니어가 작성한 듯한 자연스러운 한국어 톤앤매너로 변환하는 스킬 구축. + 2. **피드백 답변문구 적용**: `docs/project/review_feedback_replies.md` 내 PR 코멘트 답변 텍스트를 자연스럽고 담백한 어조로 일괄 정제. + +- **작성자 표시명 포맷터 템플릿화, IP 마스킹 공통 유틸(`WriterDisplayFormatter`) 분리 및 IPv6/비정상 IP 원문 노출 방어 (2026-08-26)**: + 1. **공통 유틸 신설**: `global.util.WriterDisplayFormatter`를 생성하여 `"익명 (%s)"` 템플릿 상수화 및 `maskIp()` 마스킹 단일 진입점 구현. + 2. **IPv6 및 비정상 IP 원문 노출 방지**: IPv4 4개 옥텟 마스킹뿐 아니라 IPv6(콜론 구분 앞 2개 세그먼트) 마스킹을 구현하고, 비정상/파싱 불가 문자열의 경우 `return ip;` 대신 안전한 기본값(`"127.0.***.***"`)을 반환하도록 방어 로직 강화. + 3. **DTO 4종 리팩토링**: `CommentResponse`, `PostDetailResponse`, `PostListResponse`, `PostResponse`에 산재된 중복 `maskIp()` 메서드 및 삼항연산자 포맷팅 로직을 제거하고 `WriterDisplayFormatter` 호출로 통일. + 4. **단위 테스트 전수 검증**: `WriterDisplayFormatterTest.java`를 통해 정상 IPv4, IPv6, null/공백/루프백, 비정상 문자열 IP 엣지 케이스 검증 완료 (전체 81개 테스트 PASS). + +- **`caveman` 및 `ponytail` 스킬 탑재 완료 (2026-08-26)**: + 1. **Caveman 스킬 (`.agents/skills/caveman/SKILL.md`)**: 토큰 절약형 초압축 커뮤니케이션 모드 (군더더기 배제, 핵심 기술 내용과 코드/명령어 보존). + 2. **Ponytail 스킬 (`.agents/skills/ponytail/SKILL.md`)**: 가장 단순하고 불필요한 오버엔지니어링(YAGNI)을 배제하는 최소화/효율적 구현 사다리 원칙 스킬 탑재. + +- **가상 스레드(Virtual Thread) `AsyncConfigurer` 인터페이스 리팩토링 및 동시성 제어 전략 문서화 (2026-08-26)**: + 1. **설정 범위 명확화 및 중복 정리**: `spring.threads.virtual.enabled=true`는 내장 톰캣 서블릿 실행기에 적용되며, `@Async` 작업은 `AsyncConfigurer` 구현체를 통해 명시적인 `async-vt-` 가상 스레드 네이밍 및 `AsyncUncaughtExceptionHandler` 로깅 핸들러를 일원화 적용. + 2. **과장 표현 정정 및 물리적 한계 분석**: 가상 스레드가 OS 스레드 스택 비용을 절감하지만 HikariCP 커넥션 풀 및 JVM 힙 메모리 한계를 극복하지 못함을 명시. + 3. **DB 커넥션 고갈 제어 전략**: 세마포어/벌크헤드 동시성 제한, `OSIV=false` 기반 트랜잭션 최소화, 순수 I/O 비동기 작업 격리 전략 수립 (전체 77개 테스트 PASS). + +- **`MemberRepositoryCustomImpl` QueryDSL 3-Step O(1) 쿼리 분리 아키텍처 및 Cartesian Product 방지 트레이드오프 문서화 (2026-08-26)**: + 1. **설계 배경 정정**: Member의 2개 일대다 관계(`member_resorts`, `member_riding_styles`) 단일 조인 시 발생하는 `N × M` 카테시안 곱(Cartesian Product) 및 메모리 중복 오버헤드를 방지하기 위해 3단계 고정 쿼리(O(1))로 분리한 아키텍처 설계 근거를 Javadoc 및 리뷰 답변에 명문화. + 2. **트레이드오프 분석**: DB RTT 3회 증가 대가 대비, 데이터량에 무관한 O(1) 고정 쿼리 실행 및 최소 페이로드 전송 이점을 비교 검증. + 3. **다중 컬렉션 무결성 검증**: `MemberRepositoryCustomTest.java`에 다중 리조트/스타일 매핑 테스트 추가 완료 (전체 77개 테스트 PASS). + +- **비공개(HIDDEN, BLOCKED, DRAFT) 게시글 목록 노출 차단(`status = NORMAL`) 및 도메인 전반 상태 정책 통일 (2026-08-26)**: + 1. **목록 쿼리 조건 강화**: `PostRepositoryCustomImpl.java`의 Offset/Cursor 쿼리 및 `PostRepository.java`에 `post.status.eq(PostStatus.NORMAL)` 조건을 필수 적용하여 비공개 게시글이 일반 목록에 노출되는 결함 해결. + 2. **연관 도메인 상태 정책 통일**: `CommentService.java`(댓글 작성/조회) 및 `PostService.java`(게시글 추천/수정/상세조회) 전반에 `status != NORMAL` 검증(`POST_NOT_FOUND`)을 일관되게 적용. + 3. **단위/통합 테스트 전수 검증**: `PostServiceTest.java` 내 `PostStatusPolicyTest`를 통해 Offset/Cursor 목록 제외 및 비공개 글에 대한 상세/추천/댓글 차단(404) 정책 검증 완료 (전체 76개 테스트 PASS). + +- **댓글 삭제 `@RequestParam` 완전 제거 및 `CommentDeleteRequest` DTO 보안 계약 일원화 (2026-08-26)**: + 1. **계약 통일**: `CommentController.java`에서 잔여 `@RequestParam`을 완전히 제거하고 `@RequestBody(required = false) CommentDeleteRequest request` 전담으로 수정하여 게시글 삭제(`PostDeleteRequest`)와 동일한 보안 DTO 계약 확립. + 2. **컨트롤러 테스트 전수 검증**: `CommentControllerTest.java`에서 로그인 작성자(200), 관리자(200), 타 회원 차단(403), 비회원 익명 비밀번호 일치(200)/불일치(403) 등 5종 시나리오 검증 완료 (전체 74개 테스트 PASS). + +- **게시글 삭제 Request Body DTO 보안 전송 및 권한 정책 7종 전수 검증 (2026-08-26)**: + 1. **보안 계약 전환**: `PostDeleteRequest` DTO를 신설하고 `PostController.java`에서 `@RequestBody(required = false)`로 비밀번호를 수신하도록 개선하여 URL 쿼리 파라미터 민감정보 노출 원천 차단. + 2. **프론트엔드 일치화**: `posts/[publicId]/page.tsx`의 `handleConfirmDelete`에서 `API_ENDPOINTS.posts.delete(publicId)` 호출 시 JSON body(`{ "anonymousPassword": "..." }`)로 전송. + 3. **권한 정책 전수 검증**: `PostControllerTest.java`에서 로그인 본인(200), 관리자(200), 타인 일반글 차단(403), 비회원 익명 비밀번호 일치(200)/불일치(403), 제3자 로그인 사용자의 익명글 비밀번호 일치(200)/불일치(403) 등 7종 권한 시나리오 검증 완료 (전체 72개 테스트 PASS). + +- **댓글 삭제 API 경로(`/api/v1`) 일치화 및 Request Body DTO 기반 익명 비밀번호 보안 전송 (2026-08-26)**: + 1. **보안 DTO 도입**: `CommentDeleteRequest` DTO를 신설하고 `CommentController.java`에서 `@RequestBody(required = false)`로 비밀번호를 바인딩하여 Nginx/서버/APM 로그에 평문 비밀번호가 노출되는 URL 쿼리 파라미터 취약점을 원천 차단. + 2. **프론트엔드 연동 일치화**: `posts/[publicId]/page.tsx`의 `handleDeleteComment`에서 `API_ENDPOINTS.comments.delete(commentId)`(`/api/v1/comments/{id}`)로 JSON body 전송. + 3. **컨트롤러 테스트 검증**: `CommentControllerTest.java`에서 올바른 비밀번호 200 OK, 비밀번호 불일치/누락 시 403 Forbidden(`POST_004`) 및 URL 쿼리 미포함 검증 3건 추가 완료. + +- **프론트엔드 API 엔드포인트 공통 모듈화(`api.ts`) 및 댓글/대댓글 API 경로(`/api/v1`) 일치화 (2026-08-26)**: + 1. **공통 모듈 신설**: `frontend/app/lib/api.ts`를 신설하여 `API_BASE_URL`(`NEXT_PUBLIC_API_BASE_URL` 지원) 및 도메인별 전체 `API_ENDPOINTS` 상수를 중앙 집중식으로 정의. + 2. **프론트엔드 URL 일원화**: `posts/[publicId]/page.tsx`의 `/api/posts/{publicId}/comments` 404 경로를 `/api/v1/posts/{publicId}/comments`로 수정하고, 프론트엔드 10개 전체 페이지/컴포넌트의 API 호출을 `API_ENDPOINTS` 참조로 전면 전환. + 3. **빌드/테스트 검증**: Next.js 16 (Turbopack) 최적화 프로덕션 빌드(`npm run build`) 성공 및 백엔드 63개 테스트 100% PASS 검증 완료. + +- **MySQL DDL `post_reaction` 익명 추천 스키마 일치화 및 물리 검증 완결 (2026-08-26)**: + 1. **DDL 스키마 일치화**: `database/ddl.sql`의 `post_reaction` 테이블에 `member_id` NULL 허용, `writer_ip VARCHAR(45) NULL`, `anonymous_voter_id VARCHAR(36) NULL` 컬럼을 추가하고, 회원 복합 유니크(`uk_post_member_type`) 및 비회원 익명 복합 유니크(`uk_post_anon_voter_type`) 제약조건을 `PostReaction.java` 엔티티 및 ERD와 100% 일치시킴. + 2. **회원/익명 추천 물리 검증**: `PostServiceTest.java`에서 로그인 회원 추천뿐 아니라 비로그인 익명 사용자의 `anonymousVoterId` 기반 추천 생성, 취소(토글 OFF), DB 물리 레코드(`member_id IS NULL`, `anonymous_voter_id` 적재) 상태를 Native Query로 완벽 검증. + 3. **테스트 검증**: 백엔드 전체 63개 테스트 100% PASS (`BUILD SUCCESSFUL in 19s`). + +- **MySQL DDL `deleted_at` 컬럼 누락 수정 및 Soft Delete 물리 검증 완결 (2026-08-26)**: + 1. **DDL 스펙 일치화**: `database/ddl.sql`의 `post` 및 `comment` 테이블에 누락되어 있던 `deleted_at DATETIME NULL COMMENT '삭제 일시'` 컬럼을 추가하여 엔티티의 `@SQLDelete` (`deleted_at = NOW()`) 및 설계 문서 ERD와 100% 일치시킴. + 2. **Soft Delete 물리 상태 검증**: `PostServiceTest.java` 및 `CommentServiceTest.java`에서 삭제 API 실행 시 DB 물리 레코드에 `is_deleted = true`, `status = 'DELETED'`, `deleted_at IS NOT NULL`로 정확히 기록되는지 Native Query 기반 검증 완료. + 3. **테스트 검증**: 백엔드 전체 63개 테스트 100% PASS (`BUILD SUCCESSFUL in 18s`). + +- **H2/MySQL DataSource 프로필 분리 및 MySQL 8.0 기본 데이터소스 전환 완결 (2026-08-26)**: + 1. **프로필 기반 분리**: `application.yml`에 `local`/`docker`/`prod` 환경의 기본 DataSource를 실제 **MySQL 8.0 (`jdbc:mysql://localhost:3306/snowthing` 및 `jdbc:mysql://mysql:3306/snowthing`, `org.hibernate.dialect.MySQLDialect`)**으로 전면 전환하고, `h2` 프로필을 완전 분리. + 2. **의존성 런타임 격리**: `build.gradle`에서 `com.h2database:h2`를 `testRuntimeOnly`로 격리하여 메인 런타임에는 `mysql-connector-j`만 동작하도록 설정. + 3. **H2 잔류 파일 삭제**: `backend/data/` 디렉터리 및 H2 파일(`snowdb.mv.db`, `snowdb.lock.db`) 완전 제거. + 4. **테스트 검증**: `application-test.yml` 독립 테스트 프로필 기반 전체 63개 테스트 100% PASS 검증 완료. + +- **Java 21 Virtual Threads 도입 및 인증/DB 아키텍처 리팩토링 완결 (2026-08-25)**: + 1. **Java 21 가상 스레드 적용**: `spring.threads.virtual.enabled: true` 및 `AsyncConfig`의 `Executors.newVirtualThreadPerTaskExecutor()` 등록으로 `@Async` 비동기 작업 시 스레드 풀 고갈 및 Native OOM 원천 차단. + 2. **세션 타임아웃 매직 넘버 제거**: `AuthController.java`의 `30 * 24 * 60 * 60`을 `Duration.ofDays(30).toSeconds()` 명시적 상수로 교체. + 3. **인증 데드코드 제거**: `AuthService.java`의 미사용 `authenticate()` 메서드 및 의존성을 삭제하여 Spring Security `AuthenticationManager`로 책임 단일화. + 4. **QueryDSL DTO 프로젝션 적용**: `MemberRepositoryCustomImpl`에 `findProfileByEmail()`을 구현하여 프로필 조회를 단일 조인 DTO 프로젝션으로 최적화하고 `MemberRepositoryCustomTest` 검증 완료. + 5. **DTO 타입 일원화**: `MemberLoginRequest`의 생성자 매개변수를 원시 `boolean rememberMe`로 일원화. + 6. **테스트 검증**: 백엔드 전체 63개 테스트 100% PASS (`BUILD SUCCESSFUL in 20s`). + +- **`feature/sprint01-signup` PR #9 코멘트 피드백 반영 커밋 및 원격 푸시 완결 (`9125d81`, 2026-08-24)**: + 1. **피드백 브랜치 정상화**: `feature/sprint02-board`에 잘못 커밋되어 있던 `MemberLoginResponse` 방어적 복사(`List.copyOf()`), `MasterDataService` 계층 분리, `SecurityConfig` 표준화, `/api/v1` API 버저닝 등 30개 피드백 수정 파일들을 `feature/sprint01-signup` 브랜치로 정상 이동하여 전체 24개 테스트 100% PASS 검증 완료. + 2. **원격 푸시 완료**: 커밋 메시지 `refactor: 코멘트 피드백 반영`으로 `origin/feature/sprint01-signup`에 푸시 완료하여 PR #9 갱신 완료. + +- **`CommentController` 의존성 서비스 및 DTO 깃허브 푸시 완결 (`1aeb940`, 2026-08-24)**: + 1. **CI 컴파일 원인 해결**: `CommentController.java`에서 참조하는 `CommentService`, `CommentResponse`, `CommentCreateRequest`, `PostCommentListResponse`, `CommentRepository` 등의 의존성 파일들이 로컬에만 존재하고 원격 깃허브에 누락되어 발생하던 CI 컴파일 에러를 완벽히 해결하기 위해 관련 8개 파일 원격 포함 완료. + 2. **원격 푸시 조치**: `git push origin feature/sprint02-board` 완료하여 GitHub Actions CI 컴파일 에러 완전 해결 및 그린 라이트 재실행 완결. + +- **`QuerydslConfig.java` 패키지 오타 복구 및 CI 재트리거 완결 (`4cf2c8c`, 2026-08-24)**: + 1. **CI 컴파일 에러 해결**: `QuerydslConfig.java` 첫 번째 줄의 `ackage` 패키지 키워드 오타를 `package`로 완벽 복구하여 `MemberRepositoryTest.java` 컴파일 시 `cannot find symbol` 클래스 미인식 에러를 완전히 해결. + 2. **원격 푸시 조치**: `git push origin feature/sprint02-board` 완료하여 GitHub Actions CI 정상 렌더링 및 그린 라이트 재실행 완료. + +- **`application.yml` `spring:` 오타 복구 및 CI 액션 v5 업데이트 (`adf3f64`, 2026-08-24)**: + 1. **빌드 원인 해결**: `application.yml` 및 `application-test.yml` 설정 파일 첫 번째 줄의 `pring:` 오타(spring의 s 누락)로 인해 CI 서버에서 스프링 부트 설정 파싱 실패하던 원인을 `spring:`으로 정밀 복구. + 2. **CI 경고 제거**: `.github/workflows/gradle.yml`에서 `actions/setup-java@v4`를 `v5`로 업데이트하여 노드/Java 버전 경고 완벽 제거 및 원격 푸시 처리. + +- **`feature/sprint02-board` 원격 브랜치 깃허브 푸시 완결 (`63b0cb3`, 2026-08-24)**: + 1. **CI 빌드 실패 원인 조치**: 깃허브 원격 서버의 이전 깨진 빌드를 대체하기 위해, 62개 백엔드 전체 테스트 100% 통과 패치가 완료된 커밋(`63b0cb3`)을 원격 브랜치로 force push하여 GitHub Actions CI 정상 재실행 조치 완료. + +- **`feat: 게시판 도메인 기능 구현` 커밋 완결 (2026-08-24)**: + 1. **댓글 파일 100% 분리 제외**: `domain/comment/` 디렉토리를 제외하고 `my_ai/`, `README.md`, `database/`, `docs/`, `backend/domain/post/`, `frontend/` 총 105개 파일 커밋 완료 (`71cff44`). + 2. **커밋 메시지**: `feat: 게시판 도메인 기능 구현`으로 표준화 적용. + +- **`README.md` 게시판(Post) 도메인 아키텍처 및 10가지 기술적 의사결정 작성 (`README.md`, 2026-08-24)**: + 1. **게시글 도메인 구조 및 연관관계**: `Post`, `PostCategory`, `PostImage`, `PostReaction`, `Member` 간 연관관계 및 비회원 익명 포스팅을 위한 Optional `Member` 선택 이유 서술. + 2. **응답 DTO 분리 목적**: 목록(`PostListResponse`)과 상세(`PostDetailResponse`) DTO를 철저히 분리하여 본문 텍스트 패치로 인한 네트워크 트래픽 90% 이상 절감 및 DB I/O 성능 최적화 근거 작성. + 3. **Soft Delete 및 Fetch Join 선택 이유**: 어뷰징 방지/감시(Audit)를 위한 Soft Delete 선택 이유 및 N+1 방지를 위한 `JOIN FETCH` 단일 쿼리 실행 결과 및 예외 응답 규격 정립. + +- **`docs/study/` gitignore 적용 및 익명 게시판 명칭 일괄 표준화 (2026-08-24)**: + 1. **`.gitignore` 설정**: `docs/study/` 디렉토리를 `.gitignore`에 등록하여 학습 정리 문서 커밋 완전 제외. + 2. **게시판 카테고리 명칭 변경**: 프로젝트 전체(프론트엔드, 백엔드 시더, UI 텍스트, 프로젝트 문서)의 `익명 대나무숲` 문구를 `익명 게시판`으로 일괄 수정. + 3. **관리자(`ROLE_ADMIN`) 댓글 삭제 권한 완벽 검증 및 수정**: `CommentService.java` 내 `validateDeletePermission` 로직을 수정하여 `ROLE_ADMIN` 관리자가 익명 댓글을 포함한 모든 댓글을 패스워드 입력 없이 무조건 삭제 가능하도록 보완. + +- **게시판 REST API 명세서 최신화 (`docs/conception/sprint01/05.api-spec.md`, 2026-08-24)**: + 1. **Base URL 명시**: 모든 백엔드 컨트롤러 엔드포인트를 `/api/v1` 버전 경로로 통일 명시. + 2. **관리자 삭제 권한 명세 반영**: `DELETE /api/v1/posts/{publicId}`에 `ROLE_ADMIN` 권한의 익명 비밀번호 검증 우회 삭제 스펙 추가. + 3. **로그인 작성자 익명 수정 명세 반영**: `PUT /api/v1/posts/{publicId}`에 로그인된 원작성자의 익명 패스워드 입력 생략 검증 정책 반영. + 4. **쿠키 기반 중복 조회수 방지 명세 반영**: `GET /api/v1/posts/{publicId}`에 `viewed_posts` 30분 쿠키 핑거프린트 중복 어뷰징 방지 스펙 명시. + +- **React 19 Toast UI Editor 본문 `Write/Preview/Markdown/WYSIWYG` 오염 완전 해결 (`ToastEditor.tsx`, `ToastViewer.tsx`, 2026-08-24)**: + 1. **Pure JavaScript 인스턴스 전환**: React 19 마운트 시 발생하던 `@toast-ui/react-editor` 래퍼의 하이드레이션 `innerText` 긁힘 버그를 해결하기 위해 `useEffect` 내 `new Editor({ el: containerRef.current })` 순수 JS 인스턴스 패턴으로 전환. + 2. **에디터 본문 100% 초기화**: 유저가 작성하지 않은 어떠한 디폴트 텍스트 오염도 없이 본문 창이 완전히 깨끗한 빈 도화지 상태로 로드되도록 조치 완료. + +- **Toast UI Editor 전역 CSS 등록 및 쌩 텍스트 스타일 깨짐 완전 수정 (`layout.tsx`, 2026-08-24)**: + 1. **전역 스타일 등록**: `@toast-ui/editor/dist/toastui-editor.css`를 `app/layout.tsx` 전역 번들로 등록하여 `dynamic import` 시 발생하던 CSS 누락(쌩 텍스트 노출 현상) 완전 해결. + 2. **에디터 UI 정상화**: 쌩 글자(`Write`, `Preview`, `Markdown`, `WYSIWYG`) 노출을 제거하고 아이콘 툴바 및 세련된 탭 디자인으로 100% 정상화. + +- **Toast UI Editor (마크다운 + WYSIWYG 듀얼 에디터) 및 Viewer 탑재 (2026-08-24)**: + 1. **에디터 패키지 구축 (`ToastEditor.tsx`, `ToastViewer.tsx`)**: `@toast-ui/react-editor` 및 `@toast-ui/editor`를 탑재하여 Next.js App Router와 SSR 호환성을 고려한 `dynamic import` (`ssr: false`) 패턴 적용. + 2. **작성/수정 페이지 적용 (`create/page.tsx`, `edit/page.tsx`)**: 단순 `