Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Copy this file to .env and replace the placeholders with local-only values.
# Never commit the generated .env file.
SNOWTHING_DB_USERNAME=snowuser
SNOWTHING_DB_PASSWORD=replace-with-a-local-password
SNOWTHING_DB_ROOT_PASSWORD=replace-with-a-different-root-password

# Optional credentials for CommentCreateTest's fixed snowthing_test MySQL schema.
SNOWTHING_TEST_DB_USERNAME=snowuser
SNOWTHING_TEST_DB_PASSWORD=replace-with-a-local-test-password
47 changes: 21 additions & 26 deletions .github/workflows/gemini-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,67 +11,62 @@ permissions:

jobs:
review:
if: >
github.event.issue.pull_request &&
contains(github.event.comment.body, '/gemini-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
with:
fetch-depth: 0

- name: Run Gemini Review via REST API
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
run: |
if [ -z "$GEMINI_API_KEY" ]; then
echo "::error::GEMINI_API_KEY secret is empty."
exit 1
fi

gh pr checkout "$PR_NUMBER"
PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json title,body)
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
PR_BODY=$(echo "$PR_JSON" | jq -r '.body' | head -c 2500)

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

git fetch origin "$TARGET_BRANCH"
PR_DIFF=$(git diff "origin/$TARGET_BRANCH...HEAD" -- 'backend/src/' | head -c 12000)
# PR Diff 추출 (backend/src Java 코드 위주)
PR_DIFF=$(gh pr diff "$PR_NUMBER" --repo "$REPO" | head -c 12000)

if [ -z "$PR_DIFF" ]; then
PR_DIFF="No backend/src Java source changes to review."
PR_DIFF="리뷰할 코드 변경점이 없습니다."
fi

PROMPT=$(cat <<EOF
You are a senior Java and Spring Boot architect reviewing a pull request for a junior backend developer.
당신은 10년 차 이상의 시니어 자바/스프링 아키텍트이자 정교한 코드 리뷰 멘토입니다.
주니어 개발자가 작성한 PR 코드 변경사항(Diff)을 분석하여 잠재적 위험을 예방하고 배움을 얻을 수 있도록 한국어로 정밀하게 리뷰해 주세요.

Do not review CI/CD workflow files, labels, AGENTS.md, docs, or frontend files.
Review only backend application source code, database behavior, security, and architecture.
⛔ [엄격한 금지 지침]: CI/CD 워크플로우 설정 파일(.github/), 라벨러, AGENTS.md, docs 등은 리뷰하지 마세요. 오직 백엔드 애플리케이션 비즈니스 소스코드, 데이터베이스, 보안, 아키텍처에 대해서만 리뷰하세요.

[PR title]
[PR 제목]
$PR_TITLE

[PR body]
[PR 작성 목적 및 개요]
$PR_BODY

[Review structure]
1. Compare the PR goal with the actual code changes.
2. Identify security, concurrency, data integrity, performance, and failure-mode risks.
3. Point out code-quality issues and edge cases from the Java/Spring source diff.
4. Provide concrete Java/Spring Boot improvement examples where useful.
[리뷰 구조]
1. 🔍 **[PR 구현 목적 ↔ 실제 코드 대조 분석]**: 비즈니스 기능이 실제 소스코드에 부합하게 구현되었는지 1:1 대조 요약
2. 🏛️ **[잠재적 위협 & 아키텍처 딥다이브]**: 보안 위협(세션/인증), 동시성(Race Condition), 데이터 무결성, 성능 병목, Trade-off 분석
3. 💻 **[소스코드 품질 & 엣지 케이스]**: 예외 처리 누락, 엣지 케이스, N+1 쿼리, 객체지향 설계 개선점
4. 🛠️ **[개선된 코드 예시 (Before vs After)]**: 가독성이 뛰어난 자바/스프링 코드 블록 예시

[Backend Java source diff]
[실제 코드 Diff]
$PR_DIFF
EOF
)

PAYLOAD=$(jq -n --arg prompt "$PROMPT" '{contents: [{parts: [{text: $prompt}]}]}')

RESPONSE=$(curl -s -X POST "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.6-flash:generateContent?key=${GEMINI_API_KEY}" \
RESPONSE=$(curl -s -X POST "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")

Expand All @@ -82,6 +77,6 @@ EOF
exit 1
fi

gh issue comment "$PR_NUMBER" --body "### Gemini AI PR Code Review
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "### 🤖 Gemini AI PR Code Review

$REVIEW_TEXT"
19 changes: 19 additions & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ jobs:
runs-on: ubuntu-latest
needs: spotless

services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: snowthing_test
MYSQL_USER: snowuser
MYSQL_PASSWORD: snowthing_pass_2026!
MYSQL_ROOT_PASSWORD: snowthing_root_2026!
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5

env:
SNOWTHING_DB_USERNAME: snowuser
SNOWTHING_DB_PASSWORD: snowthing_pass_2026!
SNOWTHING_TEST_DB_URL: jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul
SNOWTHING_TEST_DB_USERNAME: snowuser
SNOWTHING_TEST_DB_PASSWORD: snowthing_pass_2026!

defaults:
run:
working-directory: ./backend
Expand Down
1 change: 0 additions & 1 deletion backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ dependencies {
// JPA & DB
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'
testRuntimeOnly 'com.h2database:h2'

// Security & Session Redis
implementation 'org.springframework.boot:spring-boot-starter-security'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,21 @@ public ResponseEntity<CommentResponse> createComment(

@GetMapping("/posts/{publicId}/comments")
public ResponseEntity<PostCommentListResponse> getCommentsByPost(
@PathVariable String publicId) {
PostCommentListResponse response = commentService.getCommentsByPost(publicId);
@PathVariable String publicId,
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int size) {
PostCommentListResponse response = commentService.getCommentsByPost(publicId, cursor, size);
return ResponseEntity.ok(response);
}

@GetMapping("/comments/{commentId}/replies")
public ResponseEntity<CommentReplyListResponse> getCommentReplies(
@PathVariable Long commentId,
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(commentService.getCommentReplies(commentId, cursor, size));
}

@DeleteMapping("/comments/{commentId}")
public ResponseEntity<Map<String, Object>> deleteComment(
@PathVariable Long commentId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ public record CommentCreateRequest(
@Size(max = 1000, message = "댓글은 최대 1000자까지 입력 가능합니다.")
String content,
boolean isAnonymous,
String anonymousPassword) {}
@Size(min = 4, max = 20, message = "익명 비밀번호는 4자 이상 20자 이하로 입력해야 합니다.")
String anonymousPassword) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.ikae.snowthing.domain.comment.dto;

import java.util.List;

public record CommentReplyListResponse(
Long rootCommentId,
long totalReplyCount,
List<CommentResponse> replies,
Long nextCursor,
boolean hasNext) {
public CommentReplyListResponse {
replies = replies == null ? List.of() : List.copyOf(replies);
}
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,107 @@
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.domain.member.entity.Member;
import com.ikae.snowthing.global.util.WriterDisplayFormatter;

public record CommentResponse(
Long commentId,
Long postId,
Long parentId,
String writerName,
WriterResponse writer,
boolean isAnonymous,
String writerIp,
String content,
boolean isDeleted,
LocalDateTime createdAt,
List<CommentResponse> children) {
public static CommentResponse from(Comment comment) {
String writerName =
WriterDisplayFormatter.format(
comment.isAnonymous(), comment.getMember(), comment.getWriterIp());
long replyCount,
List<CommentResponse> previewReplies,
boolean hasMoreReplies,
LocalDateTime createdAt) {

private static final String ANONYMOUS_NAME = "ㅇㅇ";

public CommentResponse {
previewReplies = previewReplies == null ? List.of() : List.copyOf(previewReplies);
}

String displayContent = comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent();
Long parentIdValue = comment.getParent() != null ? comment.getParent().getId() : null;
public record WriterResponse(String publicId, String nickname, String profileImageUrl) {}

public static CommentResponse from(Comment comment) {
Member member = comment.getMember();
WriterResponse writer =
!comment.isAnonymous() && member != null
? new WriterResponse(
member.getPublicId(),
member.getNickname(),
member.getProfileImageUrl())
: null;
return new CommentResponse(
comment.getId(),
parentIdValue,
writerName,
displayContent,
comment.getPost().getId(),
comment.getParent() == null ? null : comment.getParent().getId(),
writer,
comment.isAnonymous(),
WriterDisplayFormatter.maskIp(comment.getWriterIp()),
comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(),
comment.isDeleted(),
comment.getCreatedAt(),
new ArrayList<>());
0,
List.of(),
false,
comment.getCreatedAt());
}

public CommentResponse withPreviewReplies(List<CommentResponse> replies) {
return new CommentResponse(
commentId,
postId,
parentId,
writer,
isAnonymous,
writerIp,
content,
isDeleted,
replyCount,
replies,
hasMoreReplies,
createdAt);
}

public CommentResponse withReplyInfo(
long replyCount, boolean hasMoreReplies, List<CommentResponse> replies) {
return new CommentResponse(
commentId,
postId,
parentId,
writer,
isAnonymous,
writerIp,
content,
isDeleted,
replyCount,
replies,
hasMoreReplies,
createdAt);
}

public List<CommentResponse> children() {
return previewReplies;
}

public String writerName() {
if (!isAnonymous) {
return (writer != null && writer.nickname() != null)
? writer.nickname()
: ANONYMOUS_NAME;
}
if (writerIp == null || writerIp.isBlank()) {
return ANONYMOUS_NAME;
}

String[] ip = writerIp.split("\\.");
String shortIp = (ip.length >= 2) ? ip[0] + "." + ip[1] : writerIp;
return ANONYMOUS_NAME + "(" + shortIp + ")";
}
Comment thread
devikae marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@

import java.util.List;

import lombok.Builder;

@Builder
public record PostCommentListResponse(
String publicId, int totalCommentCount, List<CommentResponse> comments) {
String publicId,
int totalCommentCount,
List<CommentResponse> comments,
Long nextCursor,
boolean hasNext) {
public PostCommentListResponse {
if (comments == null) {
comments = List.of();
} else {
comments = List.copyOf(comments);
}
comments = comments == null ? List.of() : List.copyOf(comments);
Comment thread
devikae marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@
import lombok.NoArgsConstructor;

@Entity
@Table(name = "comment")
@Table(
name = "comment",
indexes = {
@Index(
name = "idx_comment_post_parent_id",
columnList = "post_id,parent_id,comment_id"),
@Index(
name = "idx_comment_parent_deleted_id",
columnList = "parent_id,is_deleted,comment_id")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(sql = "UPDATE comment SET is_deleted = true, deleted_at = NOW() WHERE comment_id = ?")
Expand Down Expand Up @@ -76,6 +85,25 @@ public Comment(
this.isDeleted = false;
}

public static Comment create(
Post post,
Member member,
Comment parent,
String content,
String writerIp,
boolean isAnonymous,
String anonymousPassword) {
return new Comment(post, member, parent, content, writerIp, isAnonymous, anonymousPassword);
}

public Comment rootParent() {
Comment thread
devikae marked this conversation as resolved.
Comment current = this;
while (current.getParent() != null) {
current = current.getParent();
}
return current;
}

public void softDelete() {
this.isDeleted = true;
this.deletedAt = LocalDateTime.now();
Expand Down
Loading
Loading