diff --git a/.github/scripts/code_review.py b/.github/scripts/code_review.py new file mode 100644 index 00000000..79ac173d --- /dev/null +++ b/.github/scripts/code_review.py @@ -0,0 +1,148 @@ +import os +import requests +import json +import re +from github import Github +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed + +def get_changed_files(pr): + changed_files = [] + for file in pr.get_files(): + if file.filename.endswith('.java'): + changed_files.append({ + 'filename': file.filename, + 'patch': file.patch, + 'status': file.status, + }) + return changed_files + +def get_file_content(repo, file_path, ref): + return repo.get_contents(file_path, ref=ref).decoded_content.decode('utf-8') + +def search_file(repo, file, changed_files, ref): + if file.type == 'file' and file.name.endswith('.java'): + content = get_file_content(repo, file.path, ref) + related = set() + for changed_file in changed_files: + changed_name = os.path.splitext(os.path.basename(changed_file['filename']))[0] + if re.search(r'\b' + re.escape(changed_name) + r'\b', content): + related.add(changed_file['filename']) + return file.path, related + return None, set() + +def find_related_files(repo, changed_files, ref): + related_files = defaultdict(set) + all_files = repo.get_contents('', ref=ref) + dirs_to_process = [file for file in all_files if file.type == 'dir'] + + with ThreadPoolExecutor(max_workers=10) as executor: + future_to_file = {executor.submit(search_file, repo, file, changed_files, ref): file for file in all_files if file.type == 'file'} + + while dirs_to_process: + dir_files = repo.get_contents(dirs_to_process.pop().path, ref=ref) + dirs_to_process.extend([file for file in dir_files if file.type == 'dir']) + future_to_file.update({executor.submit(search_file, repo, file, changed_files, ref): file for file in dir_files if file.type == 'file'}) + + for future in as_completed(future_to_file): + file_path, related = future.result() + if related: + for changed_file in related: + related_files[changed_file].add(file_path) + + return related_files + + +def call_claude_api(changes, related_files): + url = "https://api.anthropic.com/v1/messages" + headers = { + "Content-Type": "application/json", + "x-api-key": os.environ['CLAUDE_API_KEY'], + "anthropic-version": "2023-06-01" + } + + system_content = ( + "경험 많은 시니어 개발자로서, 다음 변경사항들에 대해 전체적이고 간결한 코드 리뷰를 수행해주세요.\n\n" + "리뷰 지침:\n" + "1. 모든 변경사항을 종합적으로 검토하고, 가장 중요한 문제점이나 개선사항에만 집중하세요.\n" + "2. 파일별로 개별 리뷰를 하지 말고, 전체 변경사항에 대한 통합된 리뷰를 제공하세요.\n" + "3. 각 주요 이슈에 대해 간단한 설명과 구체적인 개선 제안을 제시하세요.\n" + "4. 개선 제안에는 실제 코드 예시를 포함하세요. 단, 코드 예시는 제공한 코드와 연관된 코드여야 합니다. \n" + "5. 사소한 스타일 문제나 개인적 선호도는 무시하세요.\n" + "6. 심각한 버그, 성능 문제, 또는 보안 취약점이 있는 경우에만 언급하세요.\n" + "7. 전체 리뷰는 간결하게 유지하세요.\n" + "8. 변경된 부분만 집중하여 리뷰하고, 이미 개선된 코드를 다시 지적하지 마세요.\n" + "9. 기존에 이미 개선된 사항(예: 중복 코드 제거를 위한 함수 생성)을 인식하고 이를 긍정적으로 언급하세요.\n" + "10. 변경된 파일과 관련된 다른 파일들에 미칠 수 있는 영향을 분석하세요.\n\n" + "리뷰 형식:\n" + "- 개선된 사항: [이미 개선된 부분에 대한 긍정적 언급]\n" + "- 주요 이슈 (있는 경우에만):\n" + " 1. [문제 설명]\n" + " - 제안: [개선 방안 설명]\n" + " ```java\n" + " // 수정된 코드 예시\n" + " ```\n" + " 2. ...\n" + "- 관련 파일에 대한 영향 분석:\n" + " [변경된 파일과 관련된 다른 파일들에 미칠 수 있는 잠재적 영향 설명]\n" + "- 전반적인 의견: [1-2문장으로 요약]\n\n" + "변경된 파일들:\n" + ) + + for file_info in changes: + system_content += f"- {file_info['filename']} ({file_info['status']})\n" + + system_content += "\n변경 내용:\n" + for file_info in changes: + system_content += f"파일: {file_info['filename']}\n전체 내용:\n{file_info['full_content']}\n\n변경된 부분:\n{file_info['patch']}\n\n" + + system_content += "\n관련된 파일들:\n" + for changed_file, related in related_files.items(): + system_content += f"- {changed_file}에 영향을 받을 수 있는 파일들:\n" + for related_file in related: + system_content += f" - {related_file}\n" + + payload = { + "model": "claude-3-5-sonnet-20240620", + "max_tokens": 2000, + "system": system_content, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "제공된 모든 변경사항에 대해 통합된, 간결하고 핵심적인 코드 리뷰를 제공해주세요. 가장 중요한 이슈에만 집중하고, 각 개선 제안에는 구체적인 코드 예시를 포함해주세요. 변경된 부분만 집중하여 리뷰하고, 이미 개선된 코드를 다시 지적하지 마세요. 또한, 변경된 파일과 관련된 다른 파일들에 미칠 수 있는 잠재적 영향을 분석해주세요." + } + ] + } + ] + } + + response = requests.post(url, headers=headers, json=payload) + if response.status_code == 200: + return response.json()['content'][0]['text'] + else: + return f"Error: API returned status code {response.status_code}" + +def main(): + g = Github(os.environ['GITHUB_TOKEN']) + repo = g.get_repo(os.environ['GITHUB_REPOSITORY']) + pr_number = int(os.environ['PR_NUMBER']) + pr = repo.get_pull(pr_number) + + changed_files = get_changed_files(pr) + changes = [] + + for file_info in changed_files: + full_content = get_file_content(repo, file_info['filename'], pr.head.sha) + file_info['full_content'] = full_content + changes.append(file_info) + + related_files = find_related_files(repo, changed_files, pr.head.sha) + review = call_claude_api(changes, related_files) + + pr.create_issue_comment(f"Claude의 전체 변경사항 및 관련 파일에 대한 리뷰:\n\n{review}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..53ba9c8d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +name: log4u-build +on: +# push: +# branches: +# - develop # dev 브랜치 push + pull_request: + branches: + - main # main pr + - develop # develop pr + types: [ opened, synchronize, reopened ] +jobs: + build: + name: Build and analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: 'zulu' # Alternative distribution options are available + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + - name: Cache SonarCloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + DB_URL: ${{ secrets.DB_URL }} # Database URL + DB_USERNAME: ${{ secrets.DB_USERNAME }} # Database username + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} # Database password + run: | + chmod +x ./gradlew + ./gradlew build jacocoTestReport sonar --info -Dsonar.branch.name=${{ github.ref_name }} \ No newline at end of file diff --git a/.github/workflows/code-review-claude.yml b/.github/workflows/code-review-claude.yml new file mode 100644 index 00000000..819e2cb0 --- /dev/null +++ b/.github/workflows/code-review-claude.yml @@ -0,0 +1,34 @@ +name: Code Review from Claude + +on: + pull_request: + types: [opened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests PyGithub + + - name: Run Code Review + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: python .github/scripts/code_review.py \ No newline at end of file diff --git a/.github/workflows/code-review-gpt.yml b/.github/workflows/code-review-gpt.yml new file mode 100644 index 00000000..5e2d6540 --- /dev/null +++ b/.github/workflows/code-review-gpt.yml @@ -0,0 +1,20 @@ +name: Code Review From ChatGPT + +permissions: + contents: read + pull-requests: write + +on: + pull_request: + types: [opened, synchronize] + +jobs: + review: + runs-on: ubuntu-latest + steps: + - uses: anc95/ChatGPT-CodeReview@main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + LANGUAGE: Korean + MODEL: gpt-3.5-turbo diff --git a/.github/workflows/daily-archive.yml b/.github/workflows/daily-archive.yml new file mode 100644 index 00000000..aba01d3a --- /dev/null +++ b/.github/workflows/daily-archive.yml @@ -0,0 +1,28 @@ +name: Archive dev branch daily + +on: + schedule: + - cron: "59 14 * * *" # 한국 시간(KST) 23:59 (UTC+14:59) + workflow_dispatch: # 수동 실행 가능 + +jobs: + archive: + name: Archive dev branch + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # 모든 브랜치 가져오기 + + - name: Set archive branch name + id: date + run: echo "BRANCH_NAME=archive-$(date +'%Y-%m-%d')" >> $GITHUB_ENV + + - name: Create new archive branch + run: | + git checkout develop + git pull origin develop + git checkout -b ${{ env.BRANCH_NAME }} + git push origin ${{ env.BRANCH_NAME }} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index c412ca78..e7a3f595 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,9 @@ plugins { java id("org.springframework.boot") version "3.4.4" id("io.spring.dependency-management") version "1.1.7" + id("org.sonarqube") version "6.0.1.5171" + jacoco + checkstyle } group = "com.example" @@ -25,13 +28,16 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-oauth2-client") - implementation("org.springframework.boot:spring-boot-starter-security") +// implementation("org.springframework.boot:spring-boot-starter-oauth2-client") +// implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("mysql:mysql-connector-java:8.0.33") compileOnly("org.projectlombok:lombok") + testCompileOnly("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") runtimeOnly("com.h2database:h2") annotationProcessor("org.projectlombok:lombok") testImplementation("org.springframework.boot:spring-boot-starter-test") @@ -50,3 +56,27 @@ dependencies { tasks.withType { useJUnitPlatform() } + +tasks.jacocoTestReport { + reports { + xml.required = true + csv.required = false + } +} + +checkstyle { + configFile = file("${rootDir}/naver-checkstyle-rules.xml") + configProperties["suppressionFile"] = "${rootDir}/naver-checkstyle-suppressions.xml" + toolVersion = "9.2" +} + +sonar { + properties { + property("sonar.projectKey", "sapiens2000-dev_simple-sns") + property("sonar.organization", "sapiens2000-dev") + property("sonar.host.url", "https://sonarcloud.io") + property("sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/test/jacocoTestReport.xml") + property("sonar.java.checkstyle.reportPaths", "build/reports/checkstyle/main.xml") + property("sonar.branch.name", System.getenv("BRANCH_NAME") ?: "main") + } +} \ No newline at end of file diff --git a/naver-checkstyle-rules.xml b/naver-checkstyle-rules.xml new file mode 100644 index 00000000..dafbb4d1 --- /dev/null +++ b/naver-checkstyle-rules.xml @@ -0,0 +1,433 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/naver-checkstyle-suppressions.xml b/naver-checkstyle-suppressions.xml new file mode 100644 index 00000000..3f11e0cd --- /dev/null +++ b/naver-checkstyle-suppressions.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/src/main/java/com/example/log4u/Log4UApplication.java b/src/main/java/com/example/log4u/Log4UApplication.java index 57fa72f5..d54d143b 100644 --- a/src/main/java/com/example/log4u/Log4UApplication.java +++ b/src/main/java/com/example/log4u/Log4UApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class Log4UApplication { diff --git a/src/main/java/com/example/log4u/common/advice/GlobalExceptionHandler.java b/src/main/java/com/example/log4u/common/advice/GlobalExceptionHandler.java new file mode 100644 index 00000000..7f9e2153 --- /dev/null +++ b/src/main/java/com/example/log4u/common/advice/GlobalExceptionHandler.java @@ -0,0 +1,114 @@ +package com.example.log4u.common.advice; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.NonNull; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import com.example.log4u.common.exception.ApiErrorResponse; +import com.example.log4u.common.exception.CommonErrorCode; +import com.example.log4u.common.exception.base.ErrorCode; +import com.example.log4u.common.exception.base.ServiceException; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + @Override + public ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + @NonNull HttpHeaders headers, + @NonNull HttpStatusCode status, + @NonNull WebRequest request) { + HttpServletRequest servletRequest = ((ServletWebRequest)request).getRequest(); + + String requestUrl = servletRequest.getRequestURI(); + String httpMethod = servletRequest.getMethod(); + List errors = e.getBindingResult() + .getFieldErrors() + .stream() + .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage()) + .collect(Collectors.toList()); + + log.warn("Validation failed for request to {} {}. Errors: {}", + httpMethod, requestUrl, errors); + CommonErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER; + return handleExceptionInternal(e, errorCode); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { + String location = getExceptionLocation(e); + + log.warn("Illegal argument encountered at {}: {}", location, e.getMessage()); + + CommonErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER; + return handleExceptionInternal(errorCode); + } + + @ExceptionHandler(ServiceException.class) + public ResponseEntity handleGiveMeTiConException(ServiceException e) { + String location = getExceptionLocation(e); + log.warn("Error invoke in our app at {}: {} ErrorCode: {}", location, e.getMessage(), e.getErrorCode()); + ErrorCode errorCode = e.getErrorCode(); + return handleExceptionInternal(errorCode); + } + + @ExceptionHandler({Exception.class}) + public ResponseEntity handleAllException(Exception e) { + String location = getExceptionLocation(e); + log.warn("Unhandled exception occurred at {}: {}", location, e.getMessage()); + + CommonErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; + return handleExceptionInternal(errorCode); + } + + private ResponseEntity handleExceptionInternal(ErrorCode errorCode) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(makeErrorResponse(errorCode)); + } + + private ApiErrorResponse makeErrorResponse(ErrorCode errorCode) { + return ApiErrorResponse.builder() + .errorMessage(errorCode.getErrorMessage()) + .errorCode(errorCode.getHttpStatus().value()) + .build(); + } + + private ResponseEntity handleExceptionInternal(BindException e, ErrorCode errorCode) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(makeErrorResponse(e, errorCode)); + } + + private ApiErrorResponse makeErrorResponse(BindException e, ErrorCode errorCode) { + List validationErrorList = e.getBindingResult() + .getFieldErrors() + .stream() + .map(ApiErrorResponse.ValidationError::of) + .collect(Collectors.toList()); + + return ApiErrorResponse.builder() + .errorMessage(errorCode.getErrorMessage()) + .errorCode(errorCode.getHttpStatus().value()) + .errors(validationErrorList) + .build(); + } + + private String getExceptionLocation(Exception e) { + StackTraceElement element = e.getStackTrace()[0]; + return element.getClassName() + "." + element.getMethodName() + ":" + element.getLineNumber(); + } +} diff --git a/src/main/java/com/example/log4u/common/entity/BaseEntity.java b/src/main/java/com/example/log4u/common/entity/BaseEntity.java new file mode 100644 index 00000000..6f5adb62 --- /dev/null +++ b/src/main/java/com/example/log4u/common/entity/BaseEntity.java @@ -0,0 +1,28 @@ +package com.example.log4u.common.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +abstract public class BaseEntity { + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + private String deleteYn = "N"; +} diff --git a/src/main/java/com/example/log4u/common/exception/ApiErrorResponse.java b/src/main/java/com/example/log4u/common/exception/ApiErrorResponse.java new file mode 100644 index 00000000..63afc991 --- /dev/null +++ b/src/main/java/com/example/log4u/common/exception/ApiErrorResponse.java @@ -0,0 +1,29 @@ +package com.example.log4u.common.exception; + +import java.util.List; + +import org.springframework.validation.FieldError; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@Builder +@RequiredArgsConstructor +public class ApiErrorResponse { + private final String errorMessage; + private final int errorCode; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List errors; + + public record ValidationError(String field, String message) { + + public static ValidationError of(final FieldError fieldError) { + return new ValidationError(fieldError.getField(), fieldError.getDefaultMessage()); + } + } +} diff --git a/src/main/java/com/example/log4u/common/exception/CommonErrorCode.java b/src/main/java/com/example/log4u/common/exception/CommonErrorCode.java new file mode 100644 index 00000000..bbfa3b01 --- /dev/null +++ b/src/main/java/com/example/log4u/common/exception/CommonErrorCode.java @@ -0,0 +1,33 @@ +package com.example.log4u.common.exception; + +import org.springframework.http.HttpStatus; + +import com.example.log4u.common.exception.base.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CommonErrorCode implements ErrorCode { + + INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 요청입니다"), + UNAUTHENTICATED(HttpStatus.UNAUTHORIZED,"로그인이 필요한 기능입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다"), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "요청 정보를 찾을 수 없습니다"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다. 관리자에게 문의하세요.") + ; + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public String getErrorMessage() { + return this.message; + } +} diff --git a/src/main/java/com/example/log4u/common/exception/base/ErrorCode.java b/src/main/java/com/example/log4u/common/exception/base/ErrorCode.java new file mode 100644 index 00000000..d55237eb --- /dev/null +++ b/src/main/java/com/example/log4u/common/exception/base/ErrorCode.java @@ -0,0 +1,9 @@ +package com.example.log4u.common.exception.base; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + String name(); + HttpStatus getHttpStatus(); + String getErrorMessage(); +} diff --git a/src/main/java/com/example/log4u/common/exception/base/ServiceException.java b/src/main/java/com/example/log4u/common/exception/base/ServiceException.java new file mode 100644 index 00000000..060f128d --- /dev/null +++ b/src/main/java/com/example/log4u/common/exception/base/ServiceException.java @@ -0,0 +1,15 @@ +package com.example.log4u.common.exception.base; + + +import lombok.Getter; + +@Getter +public class ServiceException extends RuntimeException { + + private final ErrorCode errorCode; + + public ServiceException(ErrorCode errorCode) { + super(errorCode.getErrorMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/example/log4u/common/external/ClientConfig.java b/src/main/java/com/example/log4u/common/external/ClientConfig.java new file mode 100644 index 00000000..e78d537b --- /dev/null +++ b/src/main/java/com/example/log4u/common/external/ClientConfig.java @@ -0,0 +1,18 @@ +package com.example.log4u.common.external; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import com.example.log4u.common.external.hanlder.ApiResponseErrorHandler; + +@Configuration +public class ClientConfig { + + @Bean + public RestTemplate restTemplate() { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setErrorHandler(new ApiResponseErrorHandler()); + return restTemplate; + } +} diff --git a/src/main/java/com/example/log4u/common/external/exception/ExternalApiRequestException.java b/src/main/java/com/example/log4u/common/external/exception/ExternalApiRequestException.java new file mode 100644 index 00000000..54b0717d --- /dev/null +++ b/src/main/java/com/example/log4u/common/external/exception/ExternalApiRequestException.java @@ -0,0 +1,13 @@ +package com.example.log4u.common.external.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ExternalApiRequestException extends RuntimeException{ + + private final String statusCode; + private final String message; + +} diff --git a/src/main/java/com/example/log4u/common/external/hanlder/ApiResponseErrorHandler.java b/src/main/java/com/example/log4u/common/external/hanlder/ApiResponseErrorHandler.java new file mode 100644 index 00000000..4bd2acdd --- /dev/null +++ b/src/main/java/com/example/log4u/common/external/hanlder/ApiResponseErrorHandler.java @@ -0,0 +1,34 @@ +package com.example.log4u.common.external.hanlder; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.stream.Collectors; + +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.ResponseErrorHandler; + +import com.example.log4u.common.external.exception.ExternalApiRequestException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ApiResponseErrorHandler implements ResponseErrorHandler { + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + return !response.getStatusCode().is2xxSuccessful(); + } + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + String body; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.getBody()))) { + body = reader.lines().collect(Collectors.joining("\n")); + } + + log.error("API 호출 중 에러 발생: HTTP 상태 코드: {}, 응답 본문: {}", response.getStatusCode().value(), body); + + throw new ExternalApiRequestException(response.getStatusCode().toString(), + "API 호출 중 에러 발생: " + response.getStatusCode().value() + " 응답 본문: " + body); + } +} diff --git a/src/main/java/com/example/log4u/domain/comment/exception/CommentErrorCode.java b/src/main/java/com/example/log4u/domain/comment/exception/CommentErrorCode.java new file mode 100644 index 00000000..9b6bf8ed --- /dev/null +++ b/src/main/java/com/example/log4u/domain/comment/exception/CommentErrorCode.java @@ -0,0 +1,28 @@ +package com.example.log4u.domain.comment.exception; + +import org.springframework.http.HttpStatus; + +import com.example.log4u.common.exception.base.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CommentErrorCode implements ErrorCode { + + NOT_FOUND_COMMENT(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getErrorMessage() { + return message; + } +} diff --git a/src/main/java/com/example/log4u/domain/comment/exception/CommentException.java b/src/main/java/com/example/log4u/domain/comment/exception/CommentException.java new file mode 100644 index 00000000..17b0ee87 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/comment/exception/CommentException.java @@ -0,0 +1,10 @@ +package com.example.log4u.domain.comment.exception; + +import com.example.log4u.common.exception.base.ErrorCode; +import com.example.log4u.common.exception.base.ServiceException; + +public class CommentException extends ServiceException { + public CommentException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/example/log4u/domain/comment/exception/NotFoundCommentException.java b/src/main/java/com/example/log4u/domain/comment/exception/NotFoundCommentException.java new file mode 100644 index 00000000..af319025 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/comment/exception/NotFoundCommentException.java @@ -0,0 +1,7 @@ +package com.example.log4u.domain.comment.exception; + +public class NotFoundCommentException extends CommentException { + public NotFoundCommentException() { + super(CommentErrorCode.NOT_FOUND_COMMENT); + } +} diff --git a/src/main/java/com/example/log4u/domain/comment/testController/TestController.java b/src/main/java/com/example/log4u/domain/comment/testController/TestController.java new file mode 100644 index 00000000..f8cecdf9 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/comment/testController/TestController.java @@ -0,0 +1,33 @@ +package com.example.log4u.domain.comment.testController; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.log4u.domain.comment.testDto.TestRequest; +import com.example.log4u.domain.comment.exception.NotFoundCommentException; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/test") +public class TestController { + + @PostMapping("/valid") + public ResponseEntity testValidation(@RequestBody @Valid TestRequest request) { + return ResponseEntity.ok().build(); + } + + @GetMapping("/illegal") + public String testIllegalArgument() { + throw new IllegalArgumentException("잘못된 인자입니다!"); + } + + @GetMapping("/log4u") + public String testLog4uException() { + throw new NotFoundCommentException(); // 또는 임의의 ServiceException + } +} diff --git a/src/main/java/com/example/log4u/domain/comment/testDto/TestRequest.java b/src/main/java/com/example/log4u/domain/comment/testDto/TestRequest.java new file mode 100644 index 00000000..90c051ef --- /dev/null +++ b/src/main/java/com/example/log4u/domain/comment/testDto/TestRequest.java @@ -0,0 +1,19 @@ +package com.example.log4u.domain.comment.testDto; + +// dto/TestRequest.java +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TestRequest { + + @NotBlank + private String name; + + @Min(value = 18, message = "나이는 18세 이상이어야 합니다.") + private int age; + +} diff --git a/src/main/java/com/example/log4u/domain/diary/entity/Diary.java b/src/main/java/com/example/log4u/domain/diary/entity/Diary.java new file mode 100644 index 00000000..9f558d7f --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/entity/Diary.java @@ -0,0 +1,58 @@ +package com.example.log4u.domain.diary.entity; + +import com.example.log4u.common.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Diary extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long diaryId; + + //JPA 연관관계 사용 X + // 외래키 방식을 사용 O + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private String title; + + private String thumbnailUrl; + + @Column(nullable = false) + private String content; + + @Column(nullable = false) + private Double latitude; + + @Column(nullable = false) + private Double longitude; + + @Column(nullable = false) + private Long likeCount; + + public Long incrementLikeCount() { + this.likeCount++; + return this.likeCount; + } + + public Long decreaseLikeCount() { + this.likeCount--; + return this.likeCount; + } +} diff --git a/src/main/java/com/example/log4u/domain/diary/exception/DiaryErrorCode.java b/src/main/java/com/example/log4u/domain/diary/exception/DiaryErrorCode.java new file mode 100644 index 00000000..def651be --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/exception/DiaryErrorCode.java @@ -0,0 +1,29 @@ +package com.example.log4u.domain.diary.exception; + +import org.springframework.http.HttpStatus; + +import com.example.log4u.common.exception.base.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DiaryErrorCode implements ErrorCode { + + NOT_FOUND_DIARY(HttpStatus.NOT_FOUND, "다이어리를 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getErrorMessage() { + return message; + } +} + diff --git a/src/main/java/com/example/log4u/domain/diary/exception/DiaryException.java b/src/main/java/com/example/log4u/domain/diary/exception/DiaryException.java new file mode 100644 index 00000000..b8f325f8 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/exception/DiaryException.java @@ -0,0 +1,11 @@ +package com.example.log4u.domain.diary.exception; + +import com.example.log4u.common.exception.base.ErrorCode; +import com.example.log4u.common.exception.base.ServiceException; + +public class DiaryException extends ServiceException { + public DiaryException(ErrorCode errorCode) { + super(errorCode); + } +} + diff --git a/src/main/java/com/example/log4u/domain/diary/exception/NotFoundDiaryException.java b/src/main/java/com/example/log4u/domain/diary/exception/NotFoundDiaryException.java new file mode 100644 index 00000000..452f1141 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/exception/NotFoundDiaryException.java @@ -0,0 +1,7 @@ +package com.example.log4u.domain.diary.exception; + +public class NotFoundDiaryException extends DiaryException { + public NotFoundDiaryException() { + super(DiaryErrorCode.NOT_FOUND_DIARY); + } +} diff --git a/src/main/java/com/example/log4u/domain/diary/repository/DiaryRepository.java b/src/main/java/com/example/log4u/domain/diary/repository/DiaryRepository.java new file mode 100644 index 00000000..fbd9d14b --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/repository/DiaryRepository.java @@ -0,0 +1,9 @@ +package com.example.log4u.domain.diary.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.log4u.domain.diary.entity.Diary; + +public interface DiaryRepository extends JpaRepository +{ +} diff --git a/src/main/java/com/example/log4u/domain/diary/service/DiaryService.java b/src/main/java/com/example/log4u/domain/diary/service/DiaryService.java new file mode 100644 index 00000000..48b95d34 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/diary/service/DiaryService.java @@ -0,0 +1,37 @@ +package com.example.log4u.domain.diary.service; + +import org.springframework.stereotype.Service; + +import com.example.log4u.domain.diary.entity.Diary; +import com.example.log4u.domain.diary.exception.NotFoundDiaryException; +import com.example.log4u.domain.diary.repository.DiaryRepository; + +import lombok.RequiredArgsConstructor; + + +@Service +@RequiredArgsConstructor +public class DiaryService { + + private final DiaryRepository diaryRepository; + + public Diary getDiary(Long diaryId) { + return diaryRepository.findById(diaryId) + .orElseThrow(NotFoundDiaryException::new); + } + + public Long incrementLikeCount(Long diaryId) { + Diary diary = getDiary(diaryId); + return diary.incrementLikeCount(); + } + + public Long decreaseLikeCount(Long diaryId) { + Diary diary = getDiary(diaryId); + return diary.decreaseLikeCount(); + } + + public Long getLikeCount(Long diaryId) { + Diary diary = getDiary(diaryId); + return diary.getLikeCount(); + } +} diff --git a/src/main/java/com/example/log4u/domain/like/controller/LikeController.java b/src/main/java/com/example/log4u/domain/like/controller/LikeController.java new file mode 100644 index 00000000..e0922c73 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/like/controller/LikeController.java @@ -0,0 +1,43 @@ +package com.example.log4u.domain.like.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.log4u.domain.like.dto.request.LikeAddRequestDto; +import com.example.log4u.domain.like.dto.response.LikeAddResponseDto; +import com.example.log4u.domain.like.dto.response.LikeCancelResponseDto; +import com.example.log4u.domain.like.service.LikeService; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "좋아요 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/likes") +public class LikeController { + + private final LikeService likeService; + + @PostMapping + public ResponseEntity addLike(@Valid @RequestBody LikeAddRequestDto requestDto) { + Long userId = 1L; // 실제 구현에서는 토큰에서 추출 + + LikeAddResponseDto response = likeService.addLike(userId, requestDto); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{diaryId}") + public ResponseEntity cancelLike(@PathVariable Long diaryId){ + Long userId = 1L;// 실제 구현에서는 토큰에서 추출 + + LikeCancelResponseDto response = likeService.cancelLike(userId, diaryId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/example/log4u/domain/like/dto/request/LikeAddRequestDto.java b/src/main/java/com/example/log4u/domain/like/dto/request/LikeAddRequestDto.java new file mode 100644 index 00000000..e38ef6f6 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/like/dto/request/LikeAddRequestDto.java @@ -0,0 +1,16 @@ +package com.example.log4u.domain.like.dto.request; + +import com.example.log4u.domain.like.entity.Like; + +public record LikeAddRequestDto( + Long diaryId + +) { + + public Like toEntity(Long userId) { + return Like.builder() + .userId(userId) + .diaryId(diaryId) + .build(); + } +} diff --git a/src/main/java/com/example/log4u/domain/like/dto/response/LikeAddResponseDto.java b/src/main/java/com/example/log4u/domain/like/dto/response/LikeAddResponseDto.java new file mode 100644 index 00000000..6d040813 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/like/dto/response/LikeAddResponseDto.java @@ -0,0 +1,10 @@ +package com.example.log4u.domain.like.dto.response; + +public record LikeAddResponseDto( + boolean liked, + Long likeCount) { + + public static LikeAddResponseDto of(boolean liked, Long likeCount) { + return new LikeAddResponseDto(liked, likeCount); + } +} diff --git a/src/main/java/com/example/log4u/domain/like/dto/response/LikeCancelResponseDto.java b/src/main/java/com/example/log4u/domain/like/dto/response/LikeCancelResponseDto.java new file mode 100644 index 00000000..5c778678 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/like/dto/response/LikeCancelResponseDto.java @@ -0,0 +1,10 @@ +package com.example.log4u.domain.like.dto.response; + +public record LikeCancelResponseDto( + boolean liked, + Long likeCount) { + + public static LikeCancelResponseDto of(boolean liked, Long likeCount) { + return new LikeCancelResponseDto(liked, likeCount); + } +} diff --git a/src/main/java/com/example/log4u/domain/like/entity/Like.java b/src/main/java/com/example/log4u/domain/like/entity/Like.java new file mode 100644 index 00000000..6b449d4e --- /dev/null +++ b/src/main/java/com/example/log4u/domain/like/entity/Like.java @@ -0,0 +1,34 @@ +package com.example.log4u.domain.like.entity; + +import com.example.log4u.common.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "likes") +public class Like extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long likeId; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private Long diaryId; +} diff --git a/src/main/java/com/example/log4u/domain/like/exception/DuplicateLikeException.java b/src/main/java/com/example/log4u/domain/like/exception/DuplicateLikeException.java new file mode 100644 index 00000000..46096185 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/like/exception/DuplicateLikeException.java @@ -0,0 +1,7 @@ +package com.example.log4u.domain.like.exception; + +public class DuplicateLikeException extends LikeException { + public DuplicateLikeException() { + super(LikeErrorCode.DUPLICATE_LIKE); + } +} diff --git a/src/main/java/com/example/log4u/domain/like/exception/LikeErrorCode.java b/src/main/java/com/example/log4u/domain/like/exception/LikeErrorCode.java new file mode 100644 index 00000000..bd11f6d6 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/like/exception/LikeErrorCode.java @@ -0,0 +1,31 @@ +package com.example.log4u.domain.like.exception; + +import org.springframework.http.HttpStatus; + +import com.example.log4u.common.exception.base.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum LikeErrorCode implements ErrorCode { + + NOT_FOUND_LIKE(HttpStatus.NOT_FOUND, "좋아요 정보를 찾을 수 없습니다."), + DUPLICATE_LIKE(HttpStatus.BAD_REQUEST, "이미 좋아요를 눌렀습니다."); + + + private final HttpStatus httpStatus; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getErrorMessage() { + return message; + } +} + diff --git a/src/main/java/com/example/log4u/domain/like/exception/LikeException.java b/src/main/java/com/example/log4u/domain/like/exception/LikeException.java new file mode 100644 index 00000000..ffca40df --- /dev/null +++ b/src/main/java/com/example/log4u/domain/like/exception/LikeException.java @@ -0,0 +1,10 @@ +package com.example.log4u.domain.like.exception; + +import com.example.log4u.common.exception.base.ErrorCode; +import com.example.log4u.common.exception.base.ServiceException; + +public class LikeException extends ServiceException { + public LikeException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/example/log4u/domain/like/repository/LikeRepository.java b/src/main/java/com/example/log4u/domain/like/repository/LikeRepository.java new file mode 100644 index 00000000..01997377 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/like/repository/LikeRepository.java @@ -0,0 +1,14 @@ +package com.example.log4u.domain.like.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.log4u.domain.like.entity.Like; + +public interface LikeRepository extends JpaRepository { + boolean existsByUserIdAndDiaryId(Long userId, Long diaryId); + + Optional findByUserIdAndDiaryId(Long userId, Long diaryId); +} diff --git a/src/main/java/com/example/log4u/domain/like/service/LikeService.java b/src/main/java/com/example/log4u/domain/like/service/LikeService.java new file mode 100644 index 00000000..4f368cb7 --- /dev/null +++ b/src/main/java/com/example/log4u/domain/like/service/LikeService.java @@ -0,0 +1,55 @@ +package com.example.log4u.domain.like.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.log4u.domain.diary.service.DiaryService; +import com.example.log4u.domain.like.dto.request.LikeAddRequestDto; +import com.example.log4u.domain.like.dto.response.LikeAddResponseDto; +import com.example.log4u.domain.like.dto.response.LikeCancelResponseDto; +import com.example.log4u.domain.like.entity.Like; +import com.example.log4u.domain.like.exception.DuplicateLikeException; +import com.example.log4u.domain.like.repository.LikeRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + private final DiaryService diaryService; + + @Transactional + public LikeAddResponseDto addLike(Long userId, LikeAddRequestDto requestDto) { + validateDuplicateLike(userId, requestDto.diaryId()); + + Like like = requestDto.toEntity(userId); + likeRepository.save(like); + + Long likeCount = diaryService.incrementLikeCount(requestDto.diaryId()); + return LikeAddResponseDto.of(true, likeCount); + } + + @Transactional + public LikeCancelResponseDto cancelLike(Long userId, Long diaryId) { + return likeRepository.findByUserIdAndDiaryId(userId, diaryId) + .map(like -> { + likeRepository.delete(like); + Long likeCount = diaryService.decreaseLikeCount(diaryId); + return LikeCancelResponseDto.of(false, likeCount); + }) + .orElseGet(() -> { + Long currentCount = diaryService.getLikeCount(diaryId); + return LikeCancelResponseDto.of(false, currentCount); + }); + } + + private void validateDuplicateLike(Long userId, Long diaryId) { + if (likeRepository.existsByUserIdAndDiaryId(userId, diaryId)) { + throw new DuplicateLikeException(); + } + } +} diff --git a/src/main/java/com/example/log4u/domain/user/entity/SocialType.java b/src/main/java/com/example/log4u/domain/user/entity/SocialType.java new file mode 100644 index 00000000..84f2002f --- /dev/null +++ b/src/main/java/com/example/log4u/domain/user/entity/SocialType.java @@ -0,0 +1,7 @@ +package com.example.log4u.domain.user.entity; + +public enum SocialType { + KAKAO, + GOOGLE, + NAVER, +} diff --git a/src/main/java/com/example/log4u/domain/user/entity/User.java b/src/main/java/com/example/log4u/domain/user/entity/User.java new file mode 100644 index 00000000..b20e0d9d --- /dev/null +++ b/src/main/java/com/example/log4u/domain/user/entity/User.java @@ -0,0 +1,46 @@ +package com.example.log4u.domain.user.entity; + +import com.example.log4u.common.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userId; + + @Column(nullable = false) + private String nickname; + + @Column(nullable = false) + private Long providerId; + + @Column(nullable = false) + private String email; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SocialType socialType; + + private String statusMessage; + + @Column(nullable = false) + private boolean isPremium; +} diff --git a/src/test/java/com/example/log4u/domain/like/service/LikeServiceTest.java b/src/test/java/com/example/log4u/domain/like/service/LikeServiceTest.java new file mode 100644 index 00000000..36c14b2c --- /dev/null +++ b/src/test/java/com/example/log4u/domain/like/service/LikeServiceTest.java @@ -0,0 +1,149 @@ +package com.example.log4u.domain.like.service; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.example.log4u.domain.diary.entity.Diary; +import com.example.log4u.domain.diary.exception.NotFoundDiaryException; +import com.example.log4u.domain.diary.service.DiaryService; +import com.example.log4u.domain.like.dto.request.LikeAddRequestDto; +import com.example.log4u.domain.like.dto.response.LikeAddResponseDto; +import com.example.log4u.domain.like.dto.response.LikeCancelResponseDto; +import com.example.log4u.domain.like.entity.Like; +import com.example.log4u.domain.like.exception.DuplicateLikeException; +import com.example.log4u.domain.like.repository.LikeRepository; +import com.example.log4u.domain.user.entity.User; +import com.example.log4u.fixture.DiaryFixture; +import com.example.log4u.fixture.LikeFixture; +import com.example.log4u.fixture.UserFixture; + +@DisplayName("좋아요 API 단위 테스트") +@ExtendWith(MockitoExtension.class) +public class LikeServiceTest { + + @InjectMocks + private LikeService likeService; + + @Mock + private LikeRepository likeRepository; + + @Mock + private DiaryService diaryService; + + @Test + @DisplayName("성공 테스트: 좋아요 추가 ") + void likeSuccess() { + // given + Long userId = 1L; + Long diaryId = 123L; + LikeAddRequestDto requestDto = new LikeAddRequestDto(diaryId); + + Like like = LikeFixture.createLikeFixture(123243L, userId, diaryId); + Long updatedLikeCount = 11L; + + given(likeRepository.existsByUserIdAndDiaryId(userId, diaryId)).willReturn(false); + given(likeRepository.save(any(Like.class))).willReturn(like); + given(diaryService.incrementLikeCount(diaryId)).willReturn(updatedLikeCount); + + // when + LikeAddResponseDto response = likeService.addLike(userId, requestDto); + + // then + verify(likeRepository).save(any(Like.class)); + assertThat(response.liked()).isTrue(); + assertThat(response.likeCount()).isEqualTo(updatedLikeCount); + } + + @Test + @DisplayName("예외 테스트: 좋아요 추가 - 존재하지 않는 다이어리에 좋아요 요청") + void likeFail_whenDiaryNotFound() { + // given + Long userId = 1L; + Long diaryId = 100L; + LikeAddRequestDto requestDto = new LikeAddRequestDto(diaryId); + + given(likeRepository.existsByUserIdAndDiaryId(userId, diaryId)).willReturn(false); + given(diaryService.incrementLikeCount(diaryId)).willThrow(new NotFoundDiaryException()); + + // when & then + assertThrows(NotFoundDiaryException.class, () -> { + likeService.addLike(userId, requestDto); + }); + + verify(likeRepository).save(any(Like.class)); + } + + @Test + @DisplayName("예외 테스트: 좋아요 추가 - 이미 누른 좋아요 또 요청") + void likeFail_whenAlreadyLiked() { + // given + Long userId = 1L; + Long diaryId = 100L; + LikeAddRequestDto requestDto = new LikeAddRequestDto(diaryId); + + given(likeRepository.existsByUserIdAndDiaryId(userId, diaryId)).willReturn(true); + + // when & then + assertThrows(DuplicateLikeException.class, () -> { + likeService.addLike(userId, requestDto); + }); + + verify(likeRepository, never()).save(any(Like.class)); + } + + @DisplayName("성공 테스트: 좋아요 취소") + @Test + void cancelLike_Success() { + Long userId = 1L; + Long diaryId = 123L; + + Like like = LikeFixture.createLikeFixture(123243L, userId, diaryId); + Long updatedLikeCount = 11L; + + given(likeRepository.findByUserIdAndDiaryId(userId, diaryId)).willReturn(Optional.of(like)); + doNothing().when(likeRepository).delete(like); + given(diaryService.decreaseLikeCount(diaryId)).willReturn(updatedLikeCount); + + // when + LikeCancelResponseDto response = likeService.cancelLike(userId, diaryId); + + // then + verify(likeRepository).delete(like); + verify(diaryService).decreaseLikeCount(diaryId); + + assertThat(response.liked()).isFalse(); + assertThat(response.likeCount()).isEqualTo(updatedLikeCount); + } + + @DisplayName("성공 테스트: 좋아요 취소 - 존재하지 않는 좋아요에 대해 동일한 결과 반환)") + @Test + void cancelLike_NoLikeExists() { + // given + Long userId = 1L; + Long diaryId = 100L; + Long currentCount = 5L; + + given(likeRepository.findByUserIdAndDiaryId(userId, diaryId)).willReturn(Optional.empty()); + given(diaryService.getLikeCount(diaryId)).willReturn(currentCount); + + // when + LikeCancelResponseDto response = likeService.cancelLike(userId, diaryId); + + // then + verify(likeRepository, never()).delete(any()); + verify(diaryService).getLikeCount(diaryId); + + assertThat(response.liked()).isFalse(); + assertThat(response.likeCount()).isEqualTo(currentCount); + } +} diff --git a/src/test/java/com/example/log4u/fixture/DiaryFixture.java b/src/test/java/com/example/log4u/fixture/DiaryFixture.java new file mode 100644 index 00000000..c53dbbe7 --- /dev/null +++ b/src/test/java/com/example/log4u/fixture/DiaryFixture.java @@ -0,0 +1,19 @@ +package com.example.log4u.fixture; + +import com.example.log4u.domain.diary.entity.Diary; + +public class DiaryFixture { + + public static Diary createDiaryFixture() { + return Diary.builder() + .diaryId(1L) + .userId(1L) + .title("테스트 다이어리") + .thumbnailUrl("thumbnail.jpg") + .content("다이어리 내용입니다.") + .latitude(37.1234) + .longitude(127.5678) + .likeCount(11L) + .build(); + } +} diff --git a/src/test/java/com/example/log4u/fixture/LikeFixture.java b/src/test/java/com/example/log4u/fixture/LikeFixture.java new file mode 100644 index 00000000..b57481e8 --- /dev/null +++ b/src/test/java/com/example/log4u/fixture/LikeFixture.java @@ -0,0 +1,14 @@ +package com.example.log4u.fixture; + +import com.example.log4u.domain.like.entity.Like; + +public class LikeFixture { + + public static Like createLikeFixture(Long likeId, Long userId, Long diaryId) { + return Like.builder() + .likeId(likeId) + .userId(userId) + .diaryId(diaryId) + .build(); + } +} diff --git a/src/test/java/com/example/log4u/fixture/UserFixture.java b/src/test/java/com/example/log4u/fixture/UserFixture.java new file mode 100644 index 00000000..db87a26f --- /dev/null +++ b/src/test/java/com/example/log4u/fixture/UserFixture.java @@ -0,0 +1,44 @@ +package com.example.log4u.fixture; + +import com.example.log4u.domain.user.entity.SocialType; +import com.example.log4u.domain.user.entity.User; + +public class UserFixture { + + public static User createUserFixture() { + return User.builder() + .userId(1L) + .nickname("testUser") + .providerId(123L) + .email("test@example.com") + .socialType(SocialType.KAKAO) + .statusMessage("상태 메시지") + .isPremium(false) + .build(); + } + + public static User createUserFixture(Long userId) { + return User.builder() + .userId(userId) + .nickname("testUser" + userId) + .providerId(100L + userId) + .email("test" + userId + "@example.com") + .socialType(SocialType.KAKAO) + .statusMessage("상태 메시지 " + userId) + .isPremium(false) + .build(); + } + + public static User createPremiumUserFixture(Long userId) { + return User.builder() + .userId(userId) + .nickname("premiumUser" + userId) + .providerId(1000L + userId) + .email("premium" + userId + "@example.com") + .socialType(SocialType.KAKAO) + .statusMessage("프리미엄 사용자") + .isPremium(true) + .build(); + } +} +