diff --git a/build.gradle b/build.gradle index dc9cb11d..09de3c1a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,60 +1,75 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.7' - id 'io.spring.dependency-management' version '1.1.5' - id 'com.google.cloud.tools.jib' version '3.4.3' + id 'java' + id 'org.springframework.boot' version '3.2.7' + id 'io.spring.dependency-management' version '1.1.5' + id 'com.google.cloud.tools.jib' version '3.4.3' } group = 'com.bbteam' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-security' // security - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' //Swagger - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' // security + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' //Swagger + + implementation 'net.nurigo:sdk:4.2.7' // 문자메시지 대행 서비스 + + implementation 'org.springframework.boot:spring-boot-starter-cache' // cache + + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly('io.jsonwebtoken:jjwt-jackson:0.11.5') // jackson으로 jwt 파싱 + + // querydsl 관련 + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } jib { - from { - image = 'openjdk:17-alpine' - platforms { - platform { - architecture = 'amd64' - os = 'linux' - } - } - } - to { - image = 'binjumeoniz1/binjumeoniz:latest' - } - container { - jvmFlags = ['-Dspring.profiles.active=dev'] - } + from { + image = 'openjdk:17-alpine' + platforms { + platform { + architecture = 'amd64' + os = 'linux' + } + } + } + to { + image = 'binjumeoniz1/binjumeoniz:latest' + } + container { + jvmFlags = ['-Dspring.profiles.active=dev'] + } } diff --git a/src/main/java/com/bbteam/budgetbuddies/BudgetbuddiesApplication.java b/src/main/java/com/bbteam/budgetbuddies/BudgetbuddiesApplication.java index 42ea116f..8563c078 100644 --- a/src/main/java/com/bbteam/budgetbuddies/BudgetbuddiesApplication.java +++ b/src/main/java/com/bbteam/budgetbuddies/BudgetbuddiesApplication.java @@ -2,10 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableJpaAuditing +@EnableAsync +@EnableCaching public class BudgetbuddiesApplication { public static void main(String[] args) { diff --git a/src/main/java/com/bbteam/budgetbuddies/apiPayload/ApiResponse.java b/src/main/java/com/bbteam/budgetbuddies/apiPayload/ApiResponse.java index 86a85c2d..d3baa699 100644 --- a/src/main/java/com/bbteam/budgetbuddies/apiPayload/ApiResponse.java +++ b/src/main/java/com/bbteam/budgetbuddies/apiPayload/ApiResponse.java @@ -1,7 +1,6 @@ package com.bbteam.budgetbuddies.apiPayload; -import com.bbteam.budgetbuddies.apiPayload.code.BaseCode; import com.bbteam.budgetbuddies.apiPayload.code.status.SuccessStatus; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -25,12 +24,6 @@ public static ApiResponse onSuccess(T result){ } - public static ApiResponse of(BaseCode code, T result){ - return new ApiResponse<>(true, code.getReasonHttpStatus().getCode(), - code.getReasonHttpStatus().getMessage(), result); - } - - public static ApiResponse onFailure(String code, String message, T data){ return new ApiResponse<>(false, code,message, data); } diff --git a/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/BaseCode.java b/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/BaseCode.java deleted file mode 100644 index f21b4b56..00000000 --- a/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/BaseCode.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.bbteam.budgetbuddies.apiPayload.code; - -public interface BaseCode { - - ReasonDto getReason(); - - ReasonDto getReasonHttpStatus(); -} diff --git a/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/ReasonDto.java b/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/ReasonDto.java deleted file mode 100644 index aa642b35..00000000 --- a/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/ReasonDto.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.bbteam.budgetbuddies.apiPayload.code; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Builder -@AllArgsConstructor -@Getter -public class ReasonDto { - String message; - String code; - Boolean isSuccess; - HttpStatus httpStatus; -} diff --git a/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/status/CommonErrorStatus.java b/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/status/CommonErrorStatus.java deleted file mode 100644 index e88c7f5b..00000000 --- a/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/status/CommonErrorStatus.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.bbteam.budgetbuddies.apiPayload.code.status; - -import com.bbteam.budgetbuddies.apiPayload.code.BaseErrorCode; -import com.bbteam.budgetbuddies.apiPayload.code.ErrorReasonDto; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum CommonErrorStatus implements BaseErrorCode { - - _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버에러"), - _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), - _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다"), - _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."),; - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - - @Override - public ErrorReasonDto getReason() { - return ErrorReasonDto.builder() - .message(message) - .code(code) - .isSuccess(false) - .build(); - } - - @Override - public ErrorReasonDto getReasonHttpStatus() { - return ErrorReasonDto.builder() - .message(message) - .code(code) - .isSuccess(false) - .httpStatus(httpStatus) - .build(); - } -} diff --git a/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/status/ErrorStatus.java index b8dc8c81..b50a6ca4 100644 --- a/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/bbteam/budgetbuddies/apiPayload/code/status/ErrorStatus.java @@ -11,11 +11,25 @@ public enum ErrorStatus implements BaseErrorCode { - _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버에러"), - _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청"), - USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "USER4001", "사용자가 없습니다."), - COMMENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "COMMENT4001", "해당 댓글이 없습니다.") , - PAGE_LOWER_ZERO(HttpStatus.BAD_REQUEST, "PAGE4001", "요청된 페이지가 0보다 작습니다."); + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON5000", "서버 에러. 관리자에게 문의하세요."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON4000", "잘못된 요청"), + _USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "USER4001", "사용자가 없습니다."), + _COMMENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "COMMENT4001", "해당 댓글이 없습니다.") , + _NOTICE_NOT_FOUND(HttpStatus.BAD_REQUEST, "NOTICE4001", "해당 공지가 없습니다."), + _PAGE_LOWER_ZERO(HttpStatus.BAD_REQUEST, "PAGE4001", "요청된 페이지가 0보다 작습니다."), + + // TOKEN 관련 예외 + _TOKEN_NOT_VALID(HttpStatus.BAD_REQUEST, "TOKEN4001", "토큰이 유효하지 않습니다."), + _TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "TOKEN4002", "토큰이 존재하지 않습니다."), + _TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, "TOKEN4003", "토큰이 만료되었습니다."), + _TOKEN_PAYLOAD_OR_SIGNATURE_NOT_VALID(HttpStatus.BAD_REQUEST, "TOKEN4004", "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다."), + _TOKEN_HEADER_NOT_VALID(HttpStatus.BAD_REQUEST, "TOKEN4005", "토큰 헤더가 유효하지 않습니다."), + + _OTP_NOT_VALID(HttpStatus.BAD_REQUEST, "OTP4001", "인증번호가 유효하지 않습니다."), + _PHONE_NUMBER_NOT_VALID(HttpStatus.BAD_REQUEST, "AUTH4001", "전화번호 형식이 유효하지 않습니다. (예: 01012341234)"), + _FAQ_NOT_FOUND(HttpStatus.NOT_FOUND, "FAQ4004", "해당 FAQ가 존재하지 않습니다."), + _SEARCH_KEYWORD_NOT_FOUND(HttpStatus.NOT_FOUND, "SEARCH_KEYWORD4004", "해당 SearchKeyword가 존재하지 않습니다."), + _FAQ_KEYWORD_NOT_FOUND(HttpStatus.NOT_FOUND, "FAQ_KEYWORD4004", "해당 FaqKeyword가 존재하지 않습니다."); private HttpStatus httpStatus; @@ -26,19 +40,19 @@ public enum ErrorStatus implements BaseErrorCode { @Override public ErrorReasonDto getReason() { return ErrorReasonDto.builder() - .message(message) - .code(code) - .isSuccess(false) - .build(); + .message(message) + .code(code) + .isSuccess(false) + .build(); } @Override public ErrorReasonDto getReasonHttpStatus() { return ErrorReasonDto.builder() - .message(message) - .code(code) - .isSuccess(false) - .httpStatus(httpStatus) - .build(); + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/category/entity/Category.java b/src/main/java/com/bbteam/budgetbuddies/domain/category/entity/Category.java index 547c0f24..16e647e2 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/category/entity/Category.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/category/entity/Category.java @@ -4,6 +4,8 @@ import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; @Entity @Getter @@ -24,10 +26,11 @@ public class Category { private Boolean isDefault; @Column(nullable = false) - @ColumnDefault("false") + @Builder.Default private Boolean deleted = false; @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) @JoinColumn(name = "user_id") private User user; } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/category/service/CategoryService.java b/src/main/java/com/bbteam/budgetbuddies/domain/category/service/CategoryService.java index 92083193..0a75b0c9 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/category/service/CategoryService.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/category/service/CategoryService.java @@ -14,8 +14,10 @@ public interface CategoryService { List getUserCategories(Long userId); - Category handleCategoryChange(Expense expense, ExpenseUpdateRequestDto request, User user); - void deleteCategory(Long id, Long userId); + + Category getCategory(Long categoryId); + + List getUserCategoryList(Long userId); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/category/service/CategoryServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/category/service/CategoryServiceImpl.java index af6ae372..7a010856 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/category/service/CategoryServiceImpl.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/category/service/CategoryServiceImpl.java @@ -5,7 +5,6 @@ import java.util.Optional; import java.util.stream.Collectors; -import com.bbteam.budgetbuddies.domain.expense.repository.ExpenseRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,9 +15,8 @@ import com.bbteam.budgetbuddies.domain.category.repository.CategoryRepository; import com.bbteam.budgetbuddies.domain.consumptiongoal.entity.ConsumptionGoal; import com.bbteam.budgetbuddies.domain.consumptiongoal.repository.ConsumptionGoalRepository; -import com.bbteam.budgetbuddies.domain.consumptiongoal.service.ConsumptionGoalService; -import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseUpdateRequestDto; import com.bbteam.budgetbuddies.domain.expense.entity.Expense; +import com.bbteam.budgetbuddies.domain.expense.repository.ExpenseRepository; import com.bbteam.budgetbuddies.domain.user.entity.User; import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; @@ -30,7 +28,6 @@ public class CategoryServiceImpl implements CategoryService { private final CategoryRepository categoryRepository; - private final ConsumptionGoalService consumptionGoalService; private final UserRepository userRepository; private final CategoryConverter categoryConverter; private final ConsumptionGoalRepository consumptionGoalRepository; @@ -40,11 +37,11 @@ public class CategoryServiceImpl implements CategoryService { @Transactional public CategoryResponseDto createCategory(Long userId, CategoryRequestDto categoryRequestDto) { User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("Invalid user ID")); + .orElseThrow(() -> new IllegalArgumentException("Invalid user ID")); // 동일한 이름의 삭제된 카테고리가 존재하는지 확인 Optional existingCategory = categoryRepository.findByNameAndUserIdAndDeletedTrue( - categoryRequestDto.getName(), userId); + categoryRequestDto.getName(), userId); if (existingCategory.isPresent()) { // 삭제된 카테고리가 존재하면 복구 (deleted = false) @@ -54,7 +51,7 @@ public CategoryResponseDto createCategory(Long userId, CategoryRequestDto catego // 해당 카테고리의 삭제된 ConsumptionGoal도 복구 Optional existingConsumptionGoal = consumptionGoalRepository.findByUserAndCategoryAndDeletedTrue( - user, categoryToRestore); + user, categoryToRestore); if (existingConsumptionGoal.isPresent()) { ConsumptionGoal consumptionGoalToRestore = existingConsumptionGoal.get(); @@ -65,13 +62,13 @@ public CategoryResponseDto createCategory(Long userId, CategoryRequestDto catego } else { // ConsumptionGoal이 존재하지 않으면 새로 생성 ConsumptionGoal newConsumptionGoal = ConsumptionGoal.builder() - .user(user) - .category(categoryToRestore) - .goalMonth(LocalDate.now().withDayOfMonth(1)) // 현재 달로 목표 설정 - .consumeAmount(0L) - .goalAmount(0L) - .deleted(false) // 생성할 때 삭제 상태가 아니도록 - .build(); + .user(user) + .category(categoryToRestore) + .goalMonth(LocalDate.now().withDayOfMonth(1)) // 현재 달로 목표 설정 + .consumeAmount(0L) + .goalAmount(0L) + .deleted(false) // 생성할 때 삭제 상태가 아니도록 + .build(); consumptionGoalRepository.save(newConsumptionGoal); } @@ -83,13 +80,13 @@ public CategoryResponseDto createCategory(Long userId, CategoryRequestDto catego // 새로운 카테고리에 대한 ConsumptionGoal도 생성 ConsumptionGoal newConsumptionGoal = ConsumptionGoal.builder() - .user(user) - .category(newCategory) - .goalMonth(LocalDate.now().withDayOfMonth(1)) // 현재 달로 목표 설정 - .consumeAmount(0L) - .goalAmount(0L) - .deleted(false) // 생성할 때 삭제 상태가 아니도록 - .build(); + .user(user) + .category(newCategory) + .goalMonth(LocalDate.now().withDayOfMonth(1)) // 현재 달로 목표 설정 + .consumeAmount(0L) + .goalAmount(0L) + .deleted(false) // 생성할 때 삭제 상태가 아니도록 + .build(); consumptionGoalRepository.save(newConsumptionGoal); return categoryConverter.toCategoryResponseDto(newCategory); @@ -100,21 +97,15 @@ public CategoryResponseDto createCategory(Long userId, CategoryRequestDto catego public List getUserCategories(Long userId) { userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found with id: " + userId)); + .orElseThrow(() -> new IllegalArgumentException("User not found with id: " + userId)); List categories = categoryRepository.findUserCategoryByUserId(userId); return categories.stream().map(categoryConverter::toCategoryResponseDto).collect(Collectors.toList()); } @Override - @Transactional(readOnly = true) - public Category handleCategoryChange(Expense expense, ExpenseUpdateRequestDto request, User user) { - Category categoryToReplace = categoryRepository.findById(request.getCategoryId()) - .orElseThrow(() -> new IllegalArgumentException("Not found category")); - - consumptionGoalService.recalculateConsumptionAmount(expense, request, user); - - return categoryToReplace; + public Category getCategory(Long id) { + return categoryRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("Not found category")); } @Override @@ -131,15 +122,15 @@ public void deleteCategory(Long categoryId, Long userId) { // 현재 월에 해당하는 삭제되지 않은 Expense 조회 (deleted = false) List currentMonthExpenses = expenseRepository.findByCategoryIdAndUserIdAndExpenseDateBetweenAndDeletedFalse( - categoryId, userId, startOfMonth.atStartOfDay(), endOfMonth.atTime(23, 59, 59)); + categoryId, userId, startOfMonth.atStartOfDay(), endOfMonth.atTime(23, 59, 59)); long totalAmount = currentMonthExpenses.stream() - .mapToLong(Expense::getAmount) - .sum(); + .mapToLong(Expense::getAmount) + .sum(); // category_id = 10(기타 카테고리)의 소비 목표 업데이트 (custom 카테고리 삭제로 인한 소비 내역은 삭제되지 않고 기타 카테고리로..) ConsumptionGoal goal = consumptionGoalRepository.findByCategoryIdAndUserId(10L, userId) - .orElseThrow(() -> new IllegalArgumentException("No consumption goal found for category_id 10.")); + .orElseThrow(() -> new IllegalArgumentException("No consumption goal found for category_id 10.")); goal.setConsumeAmount(goal.getConsumeAmount() + totalAmount); consumptionGoalRepository.save(goal); @@ -161,4 +152,8 @@ public void deleteCategory(Long categoryId, Long userId) { }); } + @Override + public List getUserCategoryList(Long userId) { + return categoryRepository.findUserCategoryByUserId(userId); + } } \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/comment/controller/CommentController.java b/src/main/java/com/bbteam/budgetbuddies/domain/comment/controller/CommentController.java index 3d96a560..ece131ec 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/comment/controller/CommentController.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/comment/controller/CommentController.java @@ -5,7 +5,8 @@ import com.bbteam.budgetbuddies.domain.comment.dto.CommentResponseDto; import com.bbteam.budgetbuddies.domain.comment.service.CommentService; import com.bbteam.budgetbuddies.domain.comment.validation.ExistComment; -import com.bbteam.budgetbuddies.domain.user.validation.ExistUser; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.domain.Page; @@ -36,9 +37,9 @@ public CommentController(CommentService saveDiscountInfoComment( - @RequestParam("userId") @ExistUser Long userId, + @AuthUser UserDto.AuthUserDto userDto, @RequestBody CommentRequestDto.DiscountInfoCommentRequestDto discountInfoCommentRequestDto){ - CommentResponseDto.DiscountInfoCommentResponseDto dto = discountCommentService.saveComment(userId, discountInfoCommentRequestDto); + CommentResponseDto.DiscountInfoCommentResponseDto dto = discountCommentService.saveComment(userDto.getId(), discountInfoCommentRequestDto); return ApiResponse.onSuccess(dto); } @@ -54,9 +55,9 @@ public ApiResponse> find @PostMapping("/supports/comments") public ApiResponse saveSupportInfoComment( - @RequestParam("userId") @ExistUser Long userId, + @AuthUser UserDto.AuthUserDto userDto, @RequestBody CommentRequestDto.SupportInfoCommentRequestDto supportInfoCommentRequestDto){ - CommentResponseDto.SupportInfoCommentResponseDto dto = supportCommentService.saveComment(userId, supportInfoCommentRequestDto); + CommentResponseDto.SupportInfoCommentResponseDto dto = supportCommentService.saveComment(userDto.getId(), supportInfoCommentRequestDto); return ApiResponse.onSuccess(dto); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/comment/controller/CommentControllerApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/comment/controller/CommentControllerApi.java index b8279e41..69336b43 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/comment/controller/CommentControllerApi.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/comment/controller/CommentControllerApi.java @@ -5,7 +5,8 @@ import com.bbteam.budgetbuddies.domain.comment.dto.CommentRequestDto; import com.bbteam.budgetbuddies.domain.comment.dto.CommentResponseDto; import com.bbteam.budgetbuddies.domain.comment.validation.ExistComment; -import com.bbteam.budgetbuddies.domain.user.validation.ExistUser; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; @@ -24,12 +25,11 @@ public interface CommentControllerApi { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), }) @Parameters({ - @Parameter(name = "userId", description = "현재 댓글을 다는 유저 id입니다. parameter"), @Parameter(name = "discountInfoId", description = "댓글을 다는 할인 정보 게시글 id입니다. requestBody"), @Parameter(name = "content", description = "댓글 내용입니다. requestBody"), }) ApiResponse saveDiscountInfoComment( - @ExistUser Long userId, + @AuthUser UserDto.AuthUserDto userDto, CommentRequestDto.DiscountInfoCommentRequestDto discountInfoCommentRequestDto); @@ -51,12 +51,11 @@ ApiResponse> findAllByDi @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), }) @Parameters({ - @Parameter(name = "userId", description = "현재 댓글을 다는 유저 id입니다. parameter"), @Parameter(name = "supportInfoId", description = "댓글을 다는 지원 정보 게시글 id입니다. requestBody"), @Parameter(name = "content", description = "댓글 내용입니다. requestBody"), }) ApiResponse saveSupportInfoComment( - @ExistUser Long userId, + @AuthUser UserDto.AuthUserDto userDto, CommentRequestDto.SupportInfoCommentRequestDto supportInfoCommentRequestDto); @Operation(summary = "[User] 특정 지원 정보 게시글의 댓글 조회하기", description = "특정 지원 정보 게시글의 댓글을 가져오는 API입니다.") diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/comment/entity/Comment.java b/src/main/java/com/bbteam/budgetbuddies/domain/comment/entity/Comment.java index 81827240..f54ac0e3 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/comment/entity/Comment.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/comment/entity/Comment.java @@ -15,6 +15,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; @Entity @Getter @@ -27,14 +29,17 @@ public class Comment extends BaseEntity { private String content; @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) @JoinColumn(name = "user_id") private User user; @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) @JoinColumn(name = "discount_info_id") private DiscountInfo discountInfo; @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) @JoinColumn(name = "support_info_id") private SupportInfo supportInfo; diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/comment/validation/CommentExistValidation.java b/src/main/java/com/bbteam/budgetbuddies/domain/comment/validation/CommentExistValidation.java index fb63bf58..e78c0c2a 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/comment/validation/CommentExistValidation.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/comment/validation/CommentExistValidation.java @@ -8,7 +8,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import java.lang.annotation.Annotation; import java.util.Optional; @RequiredArgsConstructor @@ -22,7 +21,7 @@ public boolean isValid(Long aLong, ConstraintValidatorContext constraintValidato if(comment.isEmpty()) { constraintValidatorContext.disableDefaultConstraintViolation(); - constraintValidatorContext.buildConstraintViolationWithTemplate(ErrorStatus.COMMENT_NOT_FOUND.toString()).addConstraintViolation(); + constraintValidatorContext.buildConstraintViolationWithTemplate(ErrorStatus._COMMENT_NOT_FOUND.toString()).addConstraintViolation(); return false; } return true; diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/connectedinfo/entity/ConnectedInfo.java b/src/main/java/com/bbteam/budgetbuddies/domain/connectedinfo/entity/ConnectedInfo.java new file mode 100644 index 00000000..397b1251 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/connectedinfo/entity/ConnectedInfo.java @@ -0,0 +1,45 @@ +package com.bbteam.budgetbuddies.domain.connectedinfo.entity; + +import com.bbteam.budgetbuddies.common.BaseEntity; +import com.bbteam.budgetbuddies.domain.discountinfo.entity.DiscountInfo; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; +import com.bbteam.budgetbuddies.domain.supportinfo.entity.SupportInfo; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +@SQLRestriction("deleted = false") +public class ConnectedInfo extends BaseEntity { + /** + * 연결된 지원정보 또는 할인정보를 나타내는 엔티티 (교차 엔티티) + * supportInfo, discountInfo 둘 중 하나 이상 반드시 연결되어 있어야 함. + */ + + @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) + @JoinColumn(name = "hashtag_id") + private Hashtag hashtag; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "support_info_id") + @NotFound(action = NotFoundAction.IGNORE) + private SupportInfo supportInfo; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "discount_info_id") + @NotFound(action = NotFoundAction.IGNORE) + private DiscountInfo discountInfo; + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/connectedinfo/repository/ConnectedInfoRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/connectedinfo/repository/ConnectedInfoRepository.java new file mode 100644 index 00000000..61e81d66 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/connectedinfo/repository/ConnectedInfoRepository.java @@ -0,0 +1,19 @@ +package com.bbteam.budgetbuddies.domain.connectedinfo.repository; + +import com.bbteam.budgetbuddies.domain.connectedinfo.entity.ConnectedInfo; +import com.bbteam.budgetbuddies.domain.discountinfo.entity.DiscountInfo; +import com.bbteam.budgetbuddies.domain.supportinfo.entity.SupportInfo; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ConnectedInfoRepository extends JpaRepository { + + List findAllByDiscountInfo(DiscountInfo discountInfo); + + void deleteAllByDiscountInfo(DiscountInfo discountInfo); + + List findAllBySupportInfo(SupportInfo supportInfo); + + void deleteAllBySupportInfo(SupportInfo supportInfo); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/controller/ConsumptionGoalApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/controller/ConsumptionGoalApi.java index 69094dbc..304fd7c5 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/controller/ConsumptionGoalApi.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/controller/ConsumptionGoalApi.java @@ -2,18 +2,20 @@ import java.time.LocalDate; import java.util.List; +import java.util.concurrent.ExecutionException; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; import com.bbteam.budgetbuddies.apiPayload.ApiResponse; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.AllConsumptionCategoryResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionAnalysisResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalListRequestDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalResponseListDto; +import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.MonthReportResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.PeerInfoResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.TopCategoryConsumptionDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.TopGoalCategoryResponseDto; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -25,62 +27,75 @@ public interface ConsumptionGoalApi { @Operation(summary = "[User] 또래들이 가장 큰 계획을 세운 카테고리 조회 Top4", description = "특정 사용자의 소비 목표 카테고리별 소비 목표 금액을 조회하는 API 입니다.") @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공")}) - @Parameters({@Parameter(name = "userId", description = "로그인 한 유저 아이디"), + @Parameters({ @Parameter(name = "peerAgeStart", description = "또래나이 시작 범위"), @Parameter(name = "peerAgeEnd", description = "또래나이 끝 범위"), @Parameter(name = "peerGender", description = "또래 성별")}) ApiResponse> getTopConsumptionGoalCategories( - Long userId, int peerAgeStart, int peerAgeEnd, String peerGender); + UserDto.AuthUserDto user, int peerAgeStart, int peerAgeEnd, String peerGender); @Operation(summary = "[User] 또래들이 가장 많이 계획한 카테고리와 평균 금액 및 내 목표금액 차이 조회", description = "특정 사용자의 또래 소비 카테고리별 평균 목표 금액을 조회하는 API 입니다.") @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공")}) - @Parameters({@Parameter(name = "userId", description = "로그인 한 유저 아이디"), + @Parameters({ @Parameter(name = "peerAgeStart", description = "또래나이 시작 범위"), @Parameter(name = "peerAgeEnd", description = "또래나이 끝 범위"), @Parameter(name = "peerGender", description = "또래 성별")}) - ApiResponse> getAllConsumptionGoalCategories(Long userId, int peerAgeStart, + ApiResponse> getAllConsumptionGoalCategories(UserDto.AuthUserDto user, + int peerAgeStart, int peerAgeEnd, String peerGender); @Operation(summary = "[User] 또래나이와 성별 조회", description = "또래나이와 성별을 조회하는 API 입니다.") @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공")}) - @Parameters({@Parameter(name = "userId", description = "로그인 한 유저 아이디"), + @Parameters({ @Parameter(name = "peerAgeStart", description = "또래나이 시작 범위"), @Parameter(name = "peerAgeEnd", description = "또래나이 끝 범위"), @Parameter(name = "peerGender", description = "또래 성별")}) - ApiResponse getPeerInfo(Long userId, int peerAgeStart, int peerAgeEnd, String peerGender); + ApiResponse getPeerInfo(UserDto.AuthUserDto user, int peerAgeStart, int peerAgeEnd, + String peerGender); @Operation(summary = "[User] 소비 목표 조회", description = "date={yyyy-MM-dd} 형식의 query string을 통해서 사용자의 목표 달을 조회하는 API 입니다.") @Parameters({@Parameter(name = "date", description = "yyyy-MM-dd 형식으로 목표 달의 소비를 조회")}) - ApiResponse findUserConsumptionGoal(LocalDate date, Long userId); + ApiResponse findUserConsumptionGoal(LocalDate date, UserDto.AuthUserDto user); @Operation(summary = "[User] 이번 달 소비 목표 수정", description = "다른 달의 소비 목표를 업데이트하는 것은 불가능하고 오직 이번 달의 소비 목표만 업데이트 하는 API 입니다.") - ResponseEntity updateOrElseGenerateConsumptionGoal(Long userId, + ResponseEntity updateOrElseGenerateConsumptionGoal(UserDto.AuthUserDto user, ConsumptionGoalListRequestDto consumptionGoalListRequestDto); @Operation(summary = "[User] 또래들이 가장 많이한 소비 카테고리 조회 Top3", description = "특정 사용자의 또래 소비 카테고리별 소비 건 수을 조회하는 API 입니다.") @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공")}) - @Parameters({@Parameter(name = "userId", description = "로그인 한 유저 아이디"), + @Parameters({ @Parameter(name = "peerAgeStart", description = "또래나이 시작 범위"), @Parameter(name = "peerAgeEnd", description = "또래나이 끝 범위"), @Parameter(name = "peerGender", description = "또래 성별")}) - ApiResponse> getTopConsumptionCategories(Long userId, int peerAgeStart, + ApiResponse> getTopConsumptionCategories(UserDto.AuthUserDto user, int peerAgeStart, int peerAgeEnd, String peerGender); @Operation(summary = "[User] 또래들이 가장 많이한 소비 카테고리와 평균 금액 및 내 소비금액 차이 조회", description = "특정 사용자의 또래 소비 카테고리별 평균 소비 금액을 조회하는 API 입니다.") @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공")}) - @Parameters({@Parameter(name = "userId", description = "로그인 한 유저 아이디"), + @Parameters({ @Parameter(name = "peerAgeStart", description = "또래나이 시작 범위"), @Parameter(name = "peerAgeEnd", description = "또래나이 끝 범위"), @Parameter(name = "peerGender", description = "또래 성별")}) - ApiResponse> getAllConsumptionCategories(Long userId, int peerAgeStart, + ApiResponse> getAllConsumptionCategories(UserDto.AuthUserDto user, + int peerAgeStart, int peerAgeEnd, String peerGender); @Operation(summary = "[User] 또래들이 가장 큰 목표로 세운 카테고리와 그 카테고리에서 이번주 사용한 금액 조회", description = "특정 사용자의 또래 소비 카테고리별 이번주 소비 금액을 조회하는 API 입니다.") @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공")}) - ApiResponse getTopCategoryAndConsumptionAmount(@PathVariable Long userId); + ApiResponse getTopCategoryAndConsumptionAmount(UserDto.AuthUserDto user); + + @Operation(summary = "[User] 이번 달 레포트 표정, 멘트 조회 ", description = "특정 사용자의 이번 달 레포트 표정, 멘트를 조회하는 API 입니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공")}) + ApiResponse getMonthReport(UserDto.AuthUserDto user); + + @Operation(summary = "[User] 소비 분석 멘트 생성", description = "특정 사용자의 소비 분석 멘트를 생성 하는 API 입니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공")}) + ApiResponse getConsumptionMention(UserDto.AuthUserDto user) throws ExecutionException, InterruptedException; } \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/controller/ConsumptionGoalController.java b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/controller/ConsumptionGoalController.java index d14dce3e..f8ee55bf 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/controller/ConsumptionGoalController.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/controller/ConsumptionGoalController.java @@ -2,6 +2,8 @@ import java.time.LocalDate; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; @@ -18,10 +20,13 @@ import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionAnalysisResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalListRequestDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalResponseListDto; +import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.MonthReportResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.PeerInfoResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.TopCategoryConsumptionDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.TopGoalCategoryResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.service.ConsumptionGoalService; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; import lombok.RequiredArgsConstructor; @@ -35,82 +40,104 @@ public class ConsumptionGoalController implements ConsumptionGoalApi { @Override @GetMapping("/categories/top-goals/top-4") public ApiResponse> getTopConsumptionGoalCategories( - @RequestParam(name = "userId") Long userId, + @AuthUser UserDto.AuthUserDto user, @RequestParam(name = "peerAgeStart", defaultValue = "0") int peerAgeStart, @RequestParam(name = "peerAgeEnd", defaultValue = "0") int peerAgeEnd, @RequestParam(name = "peerGender", defaultValue = "none") String peerGender) { List response = consumptionGoalService.getTopConsumptionGoalCategories( - userId, peerAgeStart, peerAgeEnd, peerGender); + user.getId(), peerAgeStart, peerAgeEnd, peerGender); return ApiResponse.onSuccess(response); } + @Override @GetMapping("/categories/top-goals") public ApiResponse> getAllConsumptionGoalCategories( - @RequestParam(name = "userId") Long userId, + @AuthUser UserDto.AuthUserDto user, @RequestParam(name = "peerAgeStart", defaultValue = "0") int peerAgeStart, @RequestParam(name = "peerAgeEnd", defaultValue = "0") int peerAgeEnd, @RequestParam(name = "peerGender", defaultValue = "none") String peerGender) { List response = consumptionGoalService.getAllConsumptionGoalCategories( - userId, + user.getId(), peerAgeStart, peerAgeEnd, peerGender); return ApiResponse.onSuccess(response); } @Override @GetMapping("/peer-info") - public ApiResponse getPeerInfo(@RequestParam(name = "userId") Long userId, + public ApiResponse getPeerInfo( + @AuthUser UserDto.AuthUserDto user, @RequestParam(name = "peerAgeStart", defaultValue = "0") int peerAgeStart, @RequestParam(name = "peerAgeEnd", defaultValue = "0") int peerAgeEnd, @RequestParam(name = "peerGender", defaultValue = "none") String peerGender) { - PeerInfoResponseDto response = consumptionGoalService.getPeerInfo(userId, peerAgeStart, peerAgeEnd, peerGender); + PeerInfoResponseDto response = consumptionGoalService.getPeerInfo(user.getId(), peerAgeStart, peerAgeEnd, + peerGender); return ApiResponse.onSuccess(response); } - @GetMapping("/{userId}") + @GetMapping() public ApiResponse findUserConsumptionGoal( - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, @PathVariable Long userId) { - - ConsumptionGoalResponseListDto response = consumptionGoalService.findUserConsumptionGoalList(userId, date); + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, @AuthUser UserDto.AuthUserDto user) { - return ApiResponse.onSuccess(response); + return ApiResponse.onSuccess(consumptionGoalService.findUserConsumptionGoalList(user.getId(), date)); } @Override - @PostMapping("/{userId}") - public ResponseEntity updateOrElseGenerateConsumptionGoal(@PathVariable Long userId, + @PostMapping() + public ResponseEntity updateOrElseGenerateConsumptionGoal( + @AuthUser UserDto.AuthUserDto user, @RequestBody ConsumptionGoalListRequestDto consumptionGoalListRequestDto) { return ResponseEntity.ok() - .body(consumptionGoalService.updateConsumptionGoals(userId, consumptionGoalListRequestDto)); + .body(consumptionGoalService.updateConsumptionGoals(user.getId(), consumptionGoalListRequestDto)); } + @Override @GetMapping("/categories/top-consumptions/top-3") public ApiResponse> getTopConsumptionCategories( - @RequestParam(name = "userId") Long userId, + @AuthUser UserDto.AuthUserDto user, @RequestParam(name = "peerAgeStart", defaultValue = "0") int peerAgeStart, @RequestParam(name = "peerAgeEnd", defaultValue = "0") int peerAgeEnd, @RequestParam(name = "peerGender", defaultValue = "none") String peerGender) { - List response = consumptionGoalService.getTopConsumptionCategories(userId, + List response = consumptionGoalService.getTopConsumptionCategories(user.getId(), peerAgeStart, peerAgeEnd, peerGender); return ApiResponse.onSuccess(response); } + @Override @GetMapping("/categories/top-consumptions") public ApiResponse> getAllConsumptionCategories( - @RequestParam(name = "userId") Long userId, + @AuthUser UserDto.AuthUserDto user, @RequestParam(name = "peerAgeStart", defaultValue = "0") int peerAgeStart, @RequestParam(name = "peerAgeEnd", defaultValue = "0") int peerAgeEnd, @RequestParam(name = "peerGender", defaultValue = "none") String peerGender) { - List response = consumptionGoalService.getAllConsumptionCategories(userId, + List response = consumptionGoalService.getAllConsumptionCategories( + user.getId(), peerAgeStart, peerAgeEnd, peerGender); return ApiResponse.onSuccess(response); } + @Override @GetMapping("/category/top-goals") public ApiResponse getTopCategoryAndConsumptionAmount( - @RequestParam(name = "userId") Long userId) { - ConsumptionAnalysisResponseDto response = consumptionGoalService.getTopCategoryAndConsumptionAmount(userId); + @AuthUser UserDto.AuthUserDto user) { + ConsumptionAnalysisResponseDto response = consumptionGoalService.getTopCategoryAndConsumptionAmount( + user.getId()); return ApiResponse.onSuccess(response); } + @Override + @GetMapping("/month-report") + public ApiResponse getMonthReport( + @AuthUser UserDto.AuthUserDto user) { + MonthReportResponseDto response = consumptionGoalService.getMonthReport(user.getId()); + return ApiResponse.onSuccess(response); + } + + @Override + @GetMapping("/consumption-ment") + public ApiResponse getConsumptionMention( + @AuthUser UserDto.AuthUserDto user) throws ExecutionException, InterruptedException { + CompletableFuture response = consumptionGoalService.getConsumptionMention(user.getId()); + return ApiResponse.onSuccess(response.get()); + } } \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/converter/ConsumptionGoalConverter.java b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/converter/ConsumptionGoalConverter.java index 0ffd0cea..44c0dd08 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/converter/ConsumptionGoalConverter.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/converter/ConsumptionGoalConverter.java @@ -1,14 +1,15 @@ package com.bbteam.budgetbuddies.domain.consumptiongoal.converter; import java.time.LocalDate; +import java.util.Comparator; import java.util.List; import org.springframework.stereotype.Component; -import com.bbteam.budgetbuddies.domain.category.entity.Category; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionAnalysisResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalResponseListDto; +import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.MonthReportResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.PeerInfoResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.UserConsumptionGoalResponse; import com.bbteam.budgetbuddies.domain.consumptiongoal.entity.ConsumptionGoal; @@ -16,15 +17,6 @@ @Component public class ConsumptionGoalConverter { - public ConsumptionGoalResponseDto toConsumptionGoalResponseDto(Category category) { - return ConsumptionGoalResponseDto.builder() - .categoryName(category.getName()) - .categoryId(category.getId()) - .goalAmount(0L) - .consumeAmount(0L) - .build(); - } - public ConsumptionGoalResponseDto toConsumptionGoalResponseDto(ConsumptionGoal consumptionGoal) { return ConsumptionGoalResponseDto.builder() .categoryName(consumptionGoal.getCategory().getName()) @@ -35,16 +27,23 @@ public ConsumptionGoalResponseDto toConsumptionGoalResponseDto(ConsumptionGoal c } public ConsumptionGoalResponseListDto toConsumptionGoalResponseListDto( - List consumptionGoalList, LocalDate goalMonth) { - Long totalGoalAmount = sumTotalGoalAmount(consumptionGoalList); - Long totalConsumptionAmount = sumTotalConsumptionAmount(consumptionGoalList); + List consumptionGoalList, LocalDate goalMonth) { + + List consumptionGoalResponseList = consumptionGoalList + .stream() + .map(this::toConsumptionGoalResponseDto) + .sorted(Comparator.comparingLong(ConsumptionGoalResponseDto::getRemainingBalance).reversed()) + .toList(); + + Long totalGoalAmount = sumTotalGoalAmount(consumptionGoalResponseList); + Long totalConsumptionAmount = sumTotalConsumptionAmount(consumptionGoalResponseList); return ConsumptionGoalResponseListDto.builder() .goalMonth(goalMonth) .totalGoalAmount(totalGoalAmount) .totalConsumptionAmount(totalConsumptionAmount) .totalRemainingBalance(totalGoalAmount - totalConsumptionAmount) - .consumptionGoalList(consumptionGoalList) + .consumptionGoalList(consumptionGoalResponseList) .build(); } @@ -82,4 +81,12 @@ public PeerInfoResponseDto toPeerInfo(int peerAgeStart, int peerAgeEnd, Gender p .peerGender(peerGender.name()) .build(); } + + public MonthReportResponseDto toMonthReportResponseDto(String facialExpression, String comment) { + + return MonthReportResponseDto.builder() + .facialExpression(facialExpression) + .comment(comment) + .build(); + } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/dto/ConsumeAmountAndGoalAmountDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/dto/ConsumeAmountAndGoalAmountDto.java new file mode 100644 index 00000000..bdc631d1 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/dto/ConsumeAmountAndGoalAmountDto.java @@ -0,0 +1,15 @@ +package com.bbteam.budgetbuddies.domain.consumptiongoal.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ConsumeAmountAndGoalAmountDto { + + private Long categoryId; + private Long consumeAmount; + private Long goalAmount; +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/dto/MonthReportResponseDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/dto/MonthReportResponseDto.java new file mode 100644 index 00000000..3a26ebf6 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/dto/MonthReportResponseDto.java @@ -0,0 +1,16 @@ +package com.bbteam.budgetbuddies.domain.consumptiongoal.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MonthReportResponseDto { + + private String facialExpression; + private String comment; +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/entity/ConsumptionGoal.java b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/entity/ConsumptionGoal.java index a0dea332..229f2df3 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/entity/ConsumptionGoal.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/entity/ConsumptionGoal.java @@ -15,6 +15,8 @@ import lombok.*; import lombok.experimental.SuperBuilder; import lombok.extern.slf4j.Slf4j; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; @Entity @Getter @@ -37,14 +39,16 @@ public class ConsumptionGoal extends BaseEntity { private LocalDate goalMonth; @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) @JoinColumn(name = "user_id") private User user; @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) @JoinColumn(name = "category_id") private Category category; - public void updateConsumeAmount(Long amount) { + public void addConsumeAmount(Long amount) { this.consumeAmount += amount; } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/repository/ConsumptionGoalRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/repository/ConsumptionGoalRepository.java index 61ab976e..72284b9b 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/repository/ConsumptionGoalRepository.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/repository/ConsumptionGoalRepository.java @@ -13,7 +13,7 @@ import com.bbteam.budgetbuddies.domain.category.entity.Category; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.AvgConsumptionGoalDto; -import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.CategoryConsumptionCountDto; +import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumeAmountAndGoalAmountDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.MyConsumptionGoalDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.entity.ConsumptionGoal; import com.bbteam.budgetbuddies.domain.user.entity.User; @@ -25,8 +25,7 @@ public interface ConsumptionGoalRepository extends JpaRepository findConsumptionGoalByUserIdAndGoalMonth(Long userId, LocalDate goalMonth); - Optional findConsumptionGoalByUserAndCategoryAndGoalMonth(User user, Category category, - LocalDate goalMonth); + Optional findByUserAndCategoryAndGoalMonth(User user, Category category, LocalDate goalMonth); @Query("SELECT AVG(cg.consumeAmount) FROM ConsumptionGoal cg " + "JOIN cg.category c " + @@ -65,9 +64,11 @@ List findAvgConsumptionAmountByCategory( "WHERE cg.category.isDefault = true " + "AND cg.deleted = false " + "AND cg.user.id = :userId " + + "AND cg.goalMonth >= :currentMonth " + "GROUP BY cg.category.id " + "ORDER BY cg.category.id") - List findAllConsumptionAmountByUserId(@Param("userId") Long userId); + List findAllConsumptionAmountByUserId(@Param("userId") Long userId, + @Param("currentMonth") LocalDate currentMonth); @Query("SELECT new com.bbteam.budgetbuddies.domain.consumptiongoal.dto.AvgConsumptionGoalDto(" + "cg.category.id, AVG(cg.goalAmount))" + @@ -91,26 +92,11 @@ List findAvgGoalAmountByCategory( "WHERE cg.category.isDefault = true " + "AND cg.deleted = false " + "AND cg.user.id = :userId " + + "AND cg.goalMonth >= :currentMonth " + "GROUP BY cg.category.id " + "ORDER BY cg.category.id") - List findAllGoalAmountByUserId(@Param("userId") Long userId); - - @Query("SELECT new com.bbteam.budgetbuddies.domain.consumptiongoal.dto.CategoryConsumptionCountDto(" + - "e.category.id, COUNT(e)) " + - "FROM Expense e " + - "WHERE e.category.isDefault = true " + - "AND e.deleted = false " + - "AND e.user.age BETWEEN :peerAgeStart AND :peerAgeEnd " + - "AND e.user.gender = :peerGender " + - "AND e.expenseDate >= :currentMonth " + - "AND e.amount > 0 " + - "GROUP BY e.category.id " + - "ORDER BY COUNT(e) DESC") - List findTopCategoriesByConsumptionCount( - @Param("peerAgeStart") int peerAgeStart, - @Param("peerAgeEnd") int peerAgeEnd, - @Param("peerGender") Gender peerGender, - @Param("currentMonth") LocalDateTime currentMonth); + List findAllGoalAmountByUserId(@Param("userId") Long userId, + @Param("currentMonth") LocalDate currentMonth); @Modifying @Query("UPDATE ConsumptionGoal cg SET cg.deleted = TRUE WHERE cg.category.id = :categoryId AND cg.user.id = :userId") @@ -121,5 +107,80 @@ Optional findByCategoryIdAndUserId(@Param("categoryId") Long ca @Param("userId") Long userId); @Query("SELECT cg FROM ConsumptionGoal cg WHERE cg.user = :user AND cg.category = :category AND cg.deleted = true") - Optional findByUserAndCategoryAndDeletedTrue(@Param("user") User user, @Param("category") Category category); + Optional findByUserAndCategoryAndDeletedTrue(@Param("user") User user, + @Param("category") Category category); + + @Query("SELECT cg FROM ConsumptionGoal cg " + + "WHERE cg.user.id = :userId AND cg.category.id = :categoryId AND cg.goalMonth <= :localDate " + + "ORDER BY cg.goalMonth DESC LIMIT 1") + Optional findLatelyGoal(@Param("userId") Long userId, @Param("categoryId") Long categoryId, + @Param("localDate") LocalDate localDate); + + @Query("SELECT cg.goalAmount " + + "FROM ConsumptionGoal cg " + + "WHERE cg.category.isDefault = true " + + "AND cg.deleted = false " + + "AND cg.user.age BETWEEN :peerAgeStart AND :peerAgeEnd " + + "AND cg.user.gender = :peerGender " + + "AND cg.goalMonth >= :currentMonth " + + "AND cg.category.id = :categoryId ") + List findGoalAmountsByCategories( + @Param("peerAgeStart") int peerAgeStart, + @Param("peerAgeEnd") int peerAgeEnd, + @Param("peerGender") Gender peerGender, + @Param("currentMonth") LocalDate currentMonth, + @Param("categoryId") Long categoryId); + + @Query("SELECT cg.consumeAmount " + + "FROM ConsumptionGoal cg " + + "WHERE cg.category.isDefault = true " + + "AND cg.deleted = false " + + "AND cg.user.age BETWEEN :peerAgeStart AND :peerAgeEnd " + + "AND cg.user.gender = :peerGender " + + "AND cg.goalMonth >= :currentMonth " + + "AND cg.category.id = :categoryId ") + List findConsumeAmountsByCategories( + @Param("peerAgeStart") int peerAgeStart, + @Param("peerAgeEnd") int peerAgeEnd, + @Param("peerGender") Gender peerGender, + @Param("currentMonth") LocalDate currentMonth, + @Param("categoryId") Long categoryId); + + @Query("SELECT new com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumeAmountAndGoalAmountDto(" + + "cg.category.id, cg.consumeAmount, cg.goalAmount) " + + "FROM ConsumptionGoal cg " + + "WHERE cg.category.isDefault = true " + + "AND cg.user = :user " + + "AND cg.goalMonth >= :currentMonth") + List findConsumeAmountAndGoalAmount( + @Param("user") User user, + @Param("currentMonth") LocalDate currentMonth); + + @Query("SELECT cg " + + "FROM ConsumptionGoal cg " + + "WHERE cg.category.isDefault = true " + + "AND cg.deleted = false " + + "AND cg.user.age BETWEEN :peerAgeStart AND :peerAgeEnd " + + "AND cg.user.gender = :peerGender " + + "AND cg.goalMonth >= :currentMonth " + + "ORDER BY cg.consumeAmount DESC LIMIT 1") + Optional findMaxConsumeAmountByCategory( + @Param("peerAgeStart") int peerAgeStart, + @Param("peerAgeEnd") int peerAgeEnd, + @Param("peerGender") Gender peerGender, + @Param("currentMonth") LocalDate currentMonth); + + @Query("SELECT cg " + + "FROM ConsumptionGoal cg " + + "WHERE cg.category.isDefault = true " + + "AND cg.deleted = false " + + "AND cg.user.age BETWEEN :peerAgeStart AND :peerAgeEnd " + + "AND cg.user.gender = :peerGender " + + "AND cg.goalMonth >= :currentMonth " + + "ORDER BY cg.goalAmount DESC LIMIT 1") + Optional findMaxGoalAmountByCategory( + @Param("peerAgeStart") int peerAgeStart, + @Param("peerAgeEnd") int peerAgeEnd, + @Param("peerGender") Gender peerGender, + @Param("currentMonth") LocalDate currentMonth); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalService.java b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalService.java index 1c35f2ce..18e6948e 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalService.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalService.java @@ -2,49 +2,58 @@ import java.time.LocalDate; import java.util.List; +import java.util.concurrent.CompletableFuture; import org.springframework.stereotype.Service; +import com.bbteam.budgetbuddies.domain.category.entity.Category; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.AllConsumptionCategoryResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionAnalysisResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalListRequestDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalResponseListDto; +import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.MonthReportResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.PeerInfoResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.TopCategoryConsumptionDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.TopGoalCategoryResponseDto; -import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseUpdateRequestDto; -import com.bbteam.budgetbuddies.domain.expense.entity.Expense; +import com.bbteam.budgetbuddies.domain.consumptiongoal.entity.ConsumptionGoal; import com.bbteam.budgetbuddies.domain.user.entity.User; @Service public interface ConsumptionGoalService { List getTopConsumptionGoalCategories(Long userId, int peerAgeStart, int peerAgeEnd, - String peerGender); + String peerGender); List getAllConsumptionGoalCategories(Long userId, int peerAgeS, int peerAgeE, - String peerG); + String peerG); ConsumptionGoalResponseListDto findUserConsumptionGoalList(Long userId, LocalDate date); PeerInfoResponseDto getPeerInfo(Long userId, int peerAgeStart, int peerAgeEnd, String peerGender); ConsumptionGoalResponseListDto updateConsumptionGoals(Long userId, - ConsumptionGoalListRequestDto consumptionGoalListRequestDto); + ConsumptionGoalListRequestDto consumptionGoalListRequestDto); ConsumptionAnalysisResponseDto getTopCategoryAndConsumptionAmount(Long userId); - void recalculateConsumptionAmount(Expense expense, ExpenseUpdateRequestDto request, User user); + void recalculateConsumptionAmount(ConsumptionGoal beforeConsumptionGoal, Long beforeAmount, + ConsumptionGoal afterConsumptionGoal, Long afterAmount); void updateConsumeAmount(Long userId, Long categoryId, Long amount); void decreaseConsumeAmount(Long userId, Long categoryId, Long amount, LocalDate expenseDate); List getTopConsumptionCategories(Long userId, int peerAgeStart, int peerAgeEnd, - String peerGender); + String peerGender); List getAllConsumptionCategories(Long userId, int peerAgeS, int peerAgeE, - String peerG); + String peerG); void updateOrCreateDeletedConsumptionGoal(Long userId, Long categoryId, LocalDate goalMonth, Long amount); + + MonthReportResponseDto getMonthReport(Long userId); + + CompletableFuture getConsumptionMention(Long userId); + + ConsumptionGoal getUserConsumptionGoal(User user, Category category, LocalDate goalDate); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalServiceImpl.java index 37dc7d7b..24e8e4c5 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalServiceImpl.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalServiceImpl.java @@ -2,44 +2,54 @@ import java.math.BigDecimal; import java.math.RoundingMode; +import java.text.NumberFormat; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Function; import java.util.stream.Collectors; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.bbteam.budgetbuddies.domain.category.entity.Category; import com.bbteam.budgetbuddies.domain.category.repository.CategoryRepository; +import com.bbteam.budgetbuddies.domain.category.service.CategoryService; import com.bbteam.budgetbuddies.domain.consumptiongoal.converter.ConsumptionGoalConverter; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.AllConsumptionCategoryResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.AvgConsumptionGoalDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.CategoryConsumptionCountDto; +import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumeAmountAndGoalAmountDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionAnalysisResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalListRequestDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalRequestDto; -import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.ConsumptionGoalResponseListDto; +import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.MonthReportResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.MyConsumptionGoalDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.PeerInfoResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.TopCategoryConsumptionDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.TopGoalCategoryResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.entity.ConsumptionGoal; import com.bbteam.budgetbuddies.domain.consumptiongoal.repository.ConsumptionGoalRepository; -import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseUpdateRequestDto; -import com.bbteam.budgetbuddies.domain.expense.entity.Expense; +import com.bbteam.budgetbuddies.domain.expense.repository.ExpenseRepository; +import com.bbteam.budgetbuddies.domain.openai.service.OpenAiService; import com.bbteam.budgetbuddies.domain.user.entity.User; import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import com.bbteam.budgetbuddies.domain.user.service.UserService; import com.bbteam.budgetbuddies.enums.Gender; import lombok.RequiredArgsConstructor; @@ -51,8 +61,12 @@ public class ConsumptionGoalServiceImpl implements ConsumptionGoalService { private final ConsumptionGoalRepository consumptionGoalRepository; + private final ExpenseRepository expenseRepository; private final CategoryRepository categoryRepository; + private final CategoryService categoryService; private final UserRepository userRepository; + private final UserService userService; + private final OpenAiService openAiService; private final ConsumptionGoalConverter consumptionGoalConverter; private final LocalDate currentMonth = LocalDate.now().withDayOfMonth(1); @@ -78,7 +92,7 @@ public List getTopConsumptionGoalCategories(Long use .categoryName(getCategoryNameById(avgGoal.getCategoryId())) .goalAmount(avgGoal.getAverageAmount()) .build()) - .collect(Collectors.toList()); + .toList(); } @Override @@ -88,42 +102,40 @@ public List getAllConsumptionGoalCategories(L checkPeerInfo(userId, peerAgeS, peerAgeE, peerG); - List categoryAvgList = getAvgGoalAmount(); + List categoryAvgList = getMedianGoalAmount(); List myConsumptionAmountList = getMyGoalAmount(userId); List defaultCategories = categoryRepository.findAllByIsDefaultTrue(); - return defaultCategories.stream() - .map(category -> { - MyConsumptionGoalDto myConsumptionAmountDto = myConsumptionAmountList.stream() - .filter(dto -> dto.getCategoryId().equals(category.getId())) - .findFirst() - .orElse(new MyConsumptionGoalDto(category.getId(), 0L)); - - AvgConsumptionGoalDto avgDto = categoryAvgList.stream() - .filter(dto -> dto.getCategoryId().equals(category.getId())) - .findFirst() - .orElse(new AvgConsumptionGoalDto(category.getId(), 0L)); - - Long avgConsumeAmount = avgDto.getAverageAmount(); - Long myConsumeAmount = myConsumptionAmountDto.getMyAmount(); - Long roundedAvgConsumeAmount = roundToNearest10(avgConsumeAmount); - - long consumeAmountDifference; - - if (roundedAvgConsumeAmount == 0L) { - consumeAmountDifference = -myConsumeAmount; - } else { - consumeAmountDifference = myConsumeAmount - roundedAvgConsumeAmount; - } - - return AllConsumptionCategoryResponseDto.builder() - .categoryName(category.getName()) - .avgAmount(roundedAvgConsumeAmount) - .amountDifference(consumeAmountDifference) - .build(); - }) - .collect(Collectors.toList()); + return defaultCategories.stream().map(category -> { + MyConsumptionGoalDto myConsumptionAmountDto = myConsumptionAmountList.stream() + .filter(dto -> dto.getCategoryId().equals(category.getId())) + .findFirst() + .orElse(new MyConsumptionGoalDto(category.getId(), 0L)); + + AvgConsumptionGoalDto avgDto = categoryAvgList.stream() + .filter(dto -> dto.getCategoryId().equals(category.getId())) + .findFirst() + .orElse(new AvgConsumptionGoalDto(category.getId(), 0L)); + + Long avgConsumeAmount = avgDto.getAverageAmount(); + Long myConsumeAmount = myConsumptionAmountDto.getMyAmount(); + Long roundedAvgConsumeAmount = roundToNearest10(avgConsumeAmount); + + long consumeAmountDifference; + + if (roundedAvgConsumeAmount == 0L) { + consumeAmountDifference = -myConsumeAmount; + } else { + consumeAmountDifference = myConsumeAmount - roundedAvgConsumeAmount; + } + + return AllConsumptionCategoryResponseDto.builder() + .categoryName(category.getName()) + .avgAmount(roundedAvgConsumeAmount) + .amountDifference(consumeAmountDifference) + .build(); + }).toList(); } @Override @@ -142,18 +154,15 @@ public ConsumptionAnalysisResponseDto getTopCategoryAndConsumptionAmount(Long us checkPeerInfo(userId, 0, 0, "none"); List avgConsumptionGoalList = consumptionGoalRepository.findAvgGoalAmountByCategory( - peerAgeStart, - peerAgeEnd, peerGender, currentMonth); + peerAgeStart, peerAgeEnd, peerGender, currentMonth); Long topConsumptionGoalCategoryId = avgConsumptionGoalList.get(0).getCategoryId(); LocalDateTime startOfWeekDateTime = startOfWeek.atStartOfDay(); LocalDateTime endOfWeekDateTime = endOfWeek.atTime(LocalTime.MAX); - Long currentWeekConsumptionAmount = consumptionGoalRepository - .findAvgConsumptionByCategoryIdAndCurrentWeek(topConsumptionGoalCategoryId, startOfWeekDateTime, - endOfWeekDateTime, - peerAgeStart, peerAgeEnd, peerGender) + Long currentWeekConsumptionAmount = consumptionGoalRepository.findAvgConsumptionByCategoryIdAndCurrentWeek( + topConsumptionGoalCategoryId, startOfWeekDateTime, endOfWeekDateTime, peerAgeStart, peerAgeEnd, peerGender) .orElse(0L); currentWeekConsumptionAmount = roundToNearest10(currentWeekConsumptionAmount); @@ -171,8 +180,8 @@ public List getTopConsumptionCategories(Long userId, checkPeerInfo(userId, peerAgeS, peerAgeE, peerG); - List categoryConsumptionCountDto = consumptionGoalRepository - .findTopCategoriesByConsumptionCount(peerAgeStart, peerAgeEnd, peerGender, currentMonth.atStartOfDay()); + List categoryConsumptionCountDto = expenseRepository.findTopCategoriesByConsumptionCount( + peerAgeStart, peerAgeEnd, peerGender, currentMonth.atStartOfDay()); return categoryConsumptionCountDto.stream() .limit(3) @@ -180,7 +189,7 @@ public List getTopConsumptionCategories(Long userId, .categoryName(getCategoryNameById(category.getCategoryId())) .consumptionCount(category.getConsumptionCount()) .build()) - .collect(Collectors.toList()); + .toList(); } @Override @@ -190,42 +199,40 @@ public List getAllConsumptionCategories(Long checkPeerInfo(userId, peerAgeS, peerAgeE, peerG); - List categoryAvgList = getAvgConsumptionAmount(); + List categoryAvgList = getMedianConsumeAmount(); List myConsumptionAmountList = getMyConsumptionAmount(userId); List defaultCategories = categoryRepository.findAllByIsDefaultTrue(); - return defaultCategories.stream() - .map(category -> { - MyConsumptionGoalDto myConsumptionAmountDto = myConsumptionAmountList.stream() - .filter(dto -> dto.getCategoryId().equals(category.getId())) - .findFirst() - .orElse(new MyConsumptionGoalDto(category.getId(), 0L)); - - AvgConsumptionGoalDto avgDto = categoryAvgList.stream() - .filter(dto -> dto.getCategoryId().equals(category.getId())) - .findFirst() - .orElse(new AvgConsumptionGoalDto(category.getId(), 0L)); - - Long avgConsumeAmount = avgDto.getAverageAmount(); - Long myConsumeAmount = myConsumptionAmountDto.getMyAmount(); - Long roundedAvgConsumeAmount = roundToNearest10(avgConsumeAmount); - - long consumeAmountDifference; - if (roundedAvgConsumeAmount == 0L) { - consumeAmountDifference = -myConsumeAmount; - } else { - consumeAmountDifference = myConsumeAmount - roundedAvgConsumeAmount; - } - - return AllConsumptionCategoryResponseDto.builder() - .categoryName(category.getName()) - .avgAmount(roundedAvgConsumeAmount) - .amountDifference(consumeAmountDifference) - .build(); - }) - .collect(Collectors.toList()); + return defaultCategories.stream().map(category -> { + MyConsumptionGoalDto myConsumptionAmountDto = myConsumptionAmountList.stream() + .filter(dto -> dto.getCategoryId().equals(category.getId())) + .findFirst() + .orElse(new MyConsumptionGoalDto(category.getId(), 0L)); + + AvgConsumptionGoalDto avgDto = categoryAvgList.stream() + .filter(dto -> dto.getCategoryId().equals(category.getId())) + .findFirst() + .orElse(new AvgConsumptionGoalDto(category.getId(), 0L)); + + Long avgConsumeAmount = avgDto.getAverageAmount(); + Long myConsumeAmount = myConsumptionAmountDto.getMyAmount(); + Long roundedAvgConsumeAmount = roundToNearest10(avgConsumeAmount); + + long consumeAmountDifference; + if (roundedAvgConsumeAmount == 0L) { + consumeAmountDifference = -myConsumeAmount; + } else { + consumeAmountDifference = myConsumeAmount - roundedAvgConsumeAmount; + } + + return AllConsumptionCategoryResponseDto.builder() + .categoryName(category.getName()) + .avgAmount(roundedAvgConsumeAmount) + .amountDifference(consumeAmountDifference) + .build(); + }).toList(); } private User findUserById(Long userId) { @@ -268,30 +275,30 @@ private void setAgeGroupByUser(int userAge) { peerAgeStart = 29; peerAgeEnd = 99; } else { - peerAgeStart = 0; + peerAgeStart = 1; peerAgeEnd = 19; } } - - private List getAvgConsumptionAmount() { - - List defaultCategories = categoryRepository.findAllByIsDefaultTrue(); - List categoryAvgList = new ArrayList<>(); - - List avgConsumptionGoalDto = consumptionGoalRepository - .findAvgConsumptionAmountByCategory(peerAgeStart, peerAgeEnd, peerGender, currentMonth); - - Map categoryAvgMap = avgConsumptionGoalDto.stream() - .collect(Collectors.toMap(AvgConsumptionGoalDto::getCategoryId, Function.identity())); - - for (Category category : defaultCategories) { - AvgConsumptionGoalDto avgDto = categoryAvgMap.getOrDefault(category.getId(), - new AvgConsumptionGoalDto(category.getId(), 0.0)); - - categoryAvgList.add(avgDto); - } - return categoryAvgList; - } + // 평균소비목표금액을 가져오는 메서드 + // private List getAvgConsumptionAmount() { + // + // List defaultCategories = categoryRepository.findAllByIsDefaultTrue(); + // List categoryAvgList = new ArrayList<>(); + // + // List avgConsumptionGoalDto = consumptionGoalRepository + // .findAvgConsumptionAmountByCategory(peerAgeStart, peerAgeEnd, peerGender, currentMonth); + // + // Map categoryAvgMap = avgConsumptionGoalDto.stream() + // .collect(Collectors.toMap(AvgConsumptionGoalDto::getCategoryId, Function.identity())); + // + // for (Category category : defaultCategories) { + // AvgConsumptionGoalDto avgDto = categoryAvgMap.getOrDefault(category.getId(), + // new AvgConsumptionGoalDto(category.getId(), 0.0)); + // + // categoryAvgList.add(avgDto); + // } + // return categoryAvgList; + // } private List getMyConsumptionAmount(Long userId) { @@ -299,7 +306,7 @@ private List getMyConsumptionAmount(Long userId) { List myConsumptionAmountList = new ArrayList<>(); List myConsumptionGoalDto = consumptionGoalRepository.findAllConsumptionAmountByUserId( - userId); + userId, currentMonth); Map myConsumptionMap = myConsumptionGoalDto.stream() .collect(Collectors.toMap(MyConsumptionGoalDto::getCategoryId, Function.identity())); @@ -338,8 +345,8 @@ private List getMyGoalAmount(Long userId) { List defaultCategories = categoryRepository.findAllByIsDefaultTrue(); List myConsumptionAmountList = new ArrayList<>(); - List myConsumptionGoalDto = consumptionGoalRepository.findAllGoalAmountByUserId( - userId); + List myConsumptionGoalDto = consumptionGoalRepository.findAllGoalAmountByUserId(userId, + currentMonth); Map myConsumptionMap = myConsumptionGoalDto.stream() .collect(Collectors.toMap(MyConsumptionGoalDto::getCategoryId, Function.identity())); @@ -369,143 +376,139 @@ private Long roundToNearest10(Long amount) { return roundedAmount.longValue(); } - @Override - @Transactional - public ConsumptionGoalResponseListDto updateConsumptionGoals(Long userId, - ConsumptionGoalListRequestDto consumptionGoalListRequestDto) { - LocalDate thisMonth = LocalDate.now().withDayOfMonth(1); - User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("Not found user")); + private List getMedianGoalAmount() { - List updatedConsumptionGoal = consumptionGoalListRequestDto.getConsumptionGoalList() - .stream() - .map(c -> updateConsumptionGoalWithRequestDto(user, c, thisMonth)) - .toList(); + /** + * 기본 카테고리만 가져와서 리스트에 저장 + * 기본 카테고리 id 별로 소비 목표 데이터를 가져와 리스트로 저장 + * 데이터가 존재하는 경우 리스트의 중앙값 계산 + * 리스트가 비어 있으면 기본 값 0으로 설정 + * 카테고리 별 중앙값 리스트 반환 + */ - List response = consumptionGoalRepository.saveAll(updatedConsumptionGoal) - .stream() - .map(consumptionGoalConverter::toConsumptionGoalResponseDto) - .toList(); + List defaultCategories = categoryRepository.findAllByIsDefaultTrue(); - return consumptionGoalConverter.toConsumptionGoalResponseListDto(response, thisMonth); - } + List categoryMedianList = new ArrayList<>(); - private ConsumptionGoal updateConsumptionGoalWithRequestDto(User user, - ConsumptionGoalRequestDto consumptionGoalRequestDto, LocalDate goalMonth) { + for (Category category : defaultCategories) { - Category category = categoryRepository.findById(consumptionGoalRequestDto.getCategoryId()) - .orElseThrow(() -> new IllegalArgumentException("Not found Category")); + List goalAmounts = consumptionGoalRepository.findGoalAmountsByCategories(peerAgeStart, peerAgeEnd, + peerGender, currentMonth, category.getId()); - ConsumptionGoal consumptionGoal = findOrElseGenerateConsumptionGoal(user, category, goalMonth); - consumptionGoal.updateGoalAmount(consumptionGoalRequestDto.getGoalAmount()); + if (goalAmounts != null && !goalAmounts.isEmpty()) { - return consumptionGoal; + double median = calculateMedian(goalAmounts); + categoryMedianList.add(new AvgConsumptionGoalDto(category.getId(), median)); + } else { + // 데이터가 없는 경우 기본 값으로 + categoryMedianList.add(new AvgConsumptionGoalDto(category.getId(), 0.0)); + } + } + return categoryMedianList; } - private ConsumptionGoal findOrElseGenerateConsumptionGoal(User user, Category category, LocalDate goalMonth) { - return consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, category, goalMonth) - .orElseGet(() -> generateNewConsumptionGoal(user, category, goalMonth)); - } + private List getMedianConsumeAmount() { - private ConsumptionGoal generateNewConsumptionGoal(User user, Category category, LocalDate goalMonth) { - return ConsumptionGoal.builder() - .goalMonth(goalMonth) - .user(user) - .category(category) - .consumeAmount(0L) - .goalAmount(0L) - .build(); - } + /* + * 기본 카테고리만 가져와서 리스트에 저장 + * 기본 카테고리 id 별로 소비 금액 데이터를 가져와 리스트로 저장 + * 데이터가 존재하는 경우 리스트의 중앙값 계산 + * 리스트가 비어 있으면 기본 값 0으로 설정 + * 카테고리 별 중앙값 리스트 반환 + */ - @Override - @Transactional(readOnly = true) - public ConsumptionGoalResponseListDto findUserConsumptionGoalList(Long userId, LocalDate date) { - LocalDate goalMonth = date.withDayOfMonth(1); - Map goalMap = initializeGoalMap(userId); + List defaultCategories = categoryRepository.findAllByIsDefaultTrue(); - updateGoalMapWithPreviousMonth(userId, goalMonth, goalMap); - updateGoalMapWithCurrentMonth(userId, goalMonth, goalMap); + List categoryMedianList = new ArrayList<>(); - List consumptionGoalList = new ArrayList<>(goalMap.values()); + for (Category category : defaultCategories) { - return consumptionGoalConverter.toConsumptionGoalResponseListDto( - orderByRemainingBalanceDescending(consumptionGoalList), goalMonth); - } + List goalAmounts = consumptionGoalRepository.findConsumeAmountsByCategories(peerAgeStart, + peerAgeEnd, peerGender, currentMonth, category.getId()); - private Map initializeGoalMap(Long userId) { - return categoryRepository.findUserCategoryByUserId(userId) - .stream() - .collect(Collectors.toMap(Category::getId, consumptionGoalConverter::toConsumptionGoalResponseDto)); + if (goalAmounts != null && !goalAmounts.isEmpty()) { + double median = calculateMedian(goalAmounts); + categoryMedianList.add(new AvgConsumptionGoalDto(category.getId(), median)); + } else { + // 데이터가 없는 경우 기본 값으로 + categoryMedianList.add(new AvgConsumptionGoalDto(category.getId(), 0.0)); + } + } + return categoryMedianList; } - private void updateGoalMapWithPreviousMonth(Long userId, LocalDate goalMonth, - Map goalMap) { - updateGoalMap(userId, goalMonth.minusMonths(1), goalMap); - } + private double calculateMedian(List values) { - private void updateGoalMapWithCurrentMonth(Long userId, LocalDate goalMonth, - Map goalMap) { - updateGoalMap(userId, goalMonth, goalMap); - } + /* + * values 리스트에서 0 보다 큰(소비 금액이 존재하는) 값만 필터링 + * size 에 필터링한 값의 개수를 저장 + * 홀수일 경우 size / 2 (가운데) 인덱스에 해당하는 값 반환 + * 짝수일 경우 와 size/ 2 -1 인덱스 데이터와 size / 2의 인덱스 데이터의 평균을 처리 + */ - private void updateGoalMap(Long userId, LocalDate month, Map goalMap) { - consumptionGoalRepository.findConsumptionGoalByUserIdAndGoalMonth(userId, month) - .stream() - .map(consumptionGoalConverter::toConsumptionGoalResponseDto) - .forEach(goal -> goalMap.put(goal.getCategoryId(), goal)); - } + List filteredValues = values.stream().filter(value -> value > 0).collect(Collectors.toList()); - private List orderByRemainingBalanceDescending( - List consumptionGoalList) { - return consumptionGoalList.stream() - .sorted(Comparator.comparingLong(ConsumptionGoalResponseDto::getRemainingBalance).reversed()) - .toList(); + int size = filteredValues.size(); + + if (size == 0) { + return 0.0; + } + Collections.sort(filteredValues); + + if (size % 2 == 0) { + return (filteredValues.get(size / 2 - 1) + filteredValues.get(size / 2)) / 2.0; + } else { + return filteredValues.get(size / 2); + } } @Override @Transactional - public void recalculateConsumptionAmount(Expense expense, ExpenseUpdateRequestDto request, User user) { - restorePreviousGoalConsumptionAmount(expense, user); - calculatePresentGoalConsumptionAmount(request, user); - } + public ConsumptionGoalResponseListDto updateConsumptionGoals(Long userId, + ConsumptionGoalListRequestDto consumptionGoalListRequestDto) { + LocalDate thisMonth = LocalDate.now().withDayOfMonth(1); + User user = userService.getUser(userId); - private void restorePreviousGoalConsumptionAmount(Expense expense, User user) { - ConsumptionGoal previousConsumptionGoal = consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth( - user, expense.getCategory(), expense.getExpenseDate().toLocalDate().withDayOfMonth(1)) - .orElseThrow(() -> new IllegalArgumentException("Not found consumptionGoal")); + List updatedConsumptionGoal = consumptionGoalListRequestDto.getConsumptionGoalList() + .stream() + .map(c -> updateConsumptionGoalWithRequestDto(user, c, thisMonth)) + .toList(); - previousConsumptionGoal.restoreConsumeAmount(expense.getAmount()); - consumptionGoalRepository.save(previousConsumptionGoal); + return consumptionGoalConverter.toConsumptionGoalResponseListDto(updatedConsumptionGoal, thisMonth); } - private void calculatePresentGoalConsumptionAmount(ExpenseUpdateRequestDto request, User user) { - Category categoryToReplace = categoryRepository.findById(request.getCategoryId()) - .orElseThrow(() -> new IllegalArgumentException("Not found category")); + private ConsumptionGoal updateConsumptionGoalWithRequestDto(User user, + ConsumptionGoalRequestDto consumptionGoalRequestDto, LocalDate goalMonth) { + + Category category = categoryService.getCategory(consumptionGoalRequestDto.getCategoryId()); - ConsumptionGoal consumptionGoal = consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth( - user, categoryToReplace, request.getExpenseDate().toLocalDate().withDayOfMonth(1)) - .orElseGet(() -> this.generateGoalByPreviousOrElseNew(user, categoryToReplace, - request.getExpenseDate().toLocalDate().withDayOfMonth(1))); + ConsumptionGoal consumptionGoal = this.getUserConsumptionGoal(user, category, goalMonth); + consumptionGoal.updateGoalAmount(consumptionGoalRequestDto.getGoalAmount()); - consumptionGoal.updateConsumeAmount(request.getAmount()); - consumptionGoalRepository.save(consumptionGoal); + return consumptionGoal; } - private ConsumptionGoal generateGoalByPreviousOrElseNew(User user, Category category, LocalDate goalMonth) { - LocalDate previousMonth = goalMonth.minusMonths(1); + @Override + @Transactional + public ConsumptionGoalResponseListDto findUserConsumptionGoalList(Long userId, LocalDate date) { + LocalDate goalMonth = date.withDayOfMonth(1); + User user = userService.getUser(userId); + List categoryList = categoryService.getUserCategoryList(userId); - return consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, category, previousMonth) - .map(this::generateGoalByPrevious) - .orElseGet(() -> generateNewConsumptionGoal(user, category, goalMonth)); + List thisMonthUserConsumptionGoal = categoryList + .stream() + .map(category -> this.getUserConsumptionGoal(user, category, goalMonth)) + .toList(); + + return consumptionGoalConverter.toConsumptionGoalResponseListDto(thisMonthUserConsumptionGoal, goalMonth); } - private ConsumptionGoal generateGoalByPrevious(ConsumptionGoal consumptionGoal) { - return ConsumptionGoal.builder() - .goalMonth(consumptionGoal.getGoalMonth().plusMonths(1)) - .user(consumptionGoal.getUser()) - .category(consumptionGoal.getCategory()) - .consumeAmount(0L) - .goalAmount(consumptionGoal.getGoalAmount()) - .build(); + @Override + @Transactional + public void recalculateConsumptionAmount(ConsumptionGoal beforeConsumptionGoal, Long beforeAmount, + ConsumptionGoal afterConsumptionGoal, Long afterAmount) { + beforeConsumptionGoal.restoreConsumeAmount(beforeAmount); + afterConsumptionGoal.addConsumeAmount(afterAmount); } @Override @@ -516,25 +519,23 @@ public void updateConsumeAmount(Long userId, Long categoryId, Long amount) { .orElseThrow(() -> new IllegalArgumentException("Not found Category")); LocalDate thisMonth = LocalDate.now().withDayOfMonth(1); - ConsumptionGoal consumptionGoal = consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth( + ConsumptionGoal consumptionGoal = consumptionGoalRepository.findByUserAndCategoryAndGoalMonth( user, category, thisMonth).orElseGet(() -> generateNewConsumptionGoal(user, category, thisMonth)); - consumptionGoal.updateConsumeAmount(amount); + consumptionGoal.addConsumeAmount(amount); consumptionGoalRepository.save(consumptionGoal); } @Override public void decreaseConsumeAmount(Long userId, Long categoryId, Long amount, LocalDate expenseDate) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("Not found user")); + User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("Not found user")); Category category = categoryRepository.findById(categoryId) .orElseThrow(() -> new IllegalArgumentException("Not found Category")); LocalDate goalMonth = expenseDate.withDayOfMonth(1); - ConsumptionGoal consumptionGoal = consumptionGoalRepository - .findConsumptionGoalByUserAndCategoryAndGoalMonth(user, category, goalMonth) - .orElseThrow(() -> new IllegalArgumentException("Not found ConsumptionGoal")); + ConsumptionGoal consumptionGoal = consumptionGoalRepository.findByUserAndCategoryAndGoalMonth( + user, category, goalMonth).orElseThrow(() -> new IllegalArgumentException("Not found ConsumptionGoal")); consumptionGoal.decreaseConsumeAmount(amount); consumptionGoalRepository.save(consumptionGoal); @@ -544,18 +545,17 @@ public void decreaseConsumeAmount(Long userId, Long categoryId, Long amount, Loc @Override @Transactional public void updateOrCreateDeletedConsumptionGoal(Long userId, Long categoryId, LocalDate goalMonth, Long amount) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("Invalid user ID")); + User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("Invalid user ID")); Category category = categoryRepository.findById(categoryId) .orElseThrow(() -> new IllegalArgumentException("Invalid category ID")); // 해당 월의 ConsumptionGoal이 존재하는지 확인 - Optional existingGoal = consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth( + Optional existingGoal = consumptionGoalRepository.findByUserAndCategoryAndGoalMonth( user, category, goalMonth); if (existingGoal.isPresent()) { // 존재하는 경우, consumeAmount 업데이트 ConsumptionGoal consumptionGoal = existingGoal.get(); - consumptionGoal.updateConsumeAmount(amount); + consumptionGoal.addConsumeAmount(amount); consumptionGoalRepository.save(consumptionGoal); } else { // 존재하지 않는 경우, 새로운 ConsumptionGoal을 생성 (이 때 목표 금액은 0) ConsumptionGoal newGoal = ConsumptionGoal.builder() @@ -566,8 +566,265 @@ public void updateOrCreateDeletedConsumptionGoal(Long userId, Long categoryId, L .goalAmount(0L) .build(); - newGoal.updateConsumeAmount(amount); // 신규 생성된 목표에 소비 금액 추가 + newGoal.addConsumeAmount(amount); // 신규 생성된 목표에 소비 금액 추가 consumptionGoalRepository.save(newGoal); } } + + @Override + @Transactional(readOnly = true) + public MonthReportResponseDto getMonthReport(Long userId) { // 이번 달 비교 분석 레포트 (표정 변화, 멘트) + + User user = findUserById(userId); + + // 카테고리별 소비금액과 목표금액 리스트로 반환 + List dtoList = consumptionGoalRepository.findConsumeAmountAndGoalAmount(user, + currentMonth); + + Map consumeRatioByCategories = consumptionRate( + dtoList); // 카테고리별 소비 비율 리스트 (소비 금액 / 목표 금액 * 100) + + Long remainDaysRatio = dateRatio(); // 현재 날짜 기준 이번 달 날짜 비율 (예시 9월 20일 -> 20/30 * 100 = 33%) + + // 두 비율의 차를 통해 카테고리별 표정 변화 로직 + Map facialExpressionByCategoryId = updateFacialExpressionByCategoryId(consumeRatioByCategories, + remainDaysRatio); + + // 표정 변화 로직을 통해 메인 표정 반환 + String facialExpression = getMainFacialExpression(facialExpressionByCategoryId); + + // 카테고리별 표정 변화 로직을 통해 메인 멘트 반환 + String mainComment = getMainComment(dtoList); + + return consumptionGoalConverter.toMonthReportResponseDto(facialExpression, mainComment); + } + + private Map consumptionRate(List list) { + + // 가테고리별 소비 금액을 목표 금액으로 나눈 비율 + + Map consumeRatio = new HashMap<>(); + for (ConsumeAmountAndGoalAmountDto dto : list) { + + Double cAmount = Double.valueOf(dto.getConsumeAmount()); + Double gAmount = Double.valueOf(dto.getGoalAmount()); + + Long ratio = (long)((cAmount / gAmount) * 100); // 소비 금액 / 목표 금액 * 100 + consumeRatio.put(dto.getCategoryId(), ratio); + } + return consumeRatio; + } + + private Long dateRatio() { + + Double lastDayOfMonth = (double)LocalDate.now().lengthOfMonth(); // 이번 달 마지막 일 + Double nowDayOfMonth = (double)LocalDate.now().getDayOfMonth(); // 현재 일 + + return (long)((nowDayOfMonth / lastDayOfMonth) * 100); // 현재 일 / 마지막 일 * 100 + } + + private Map updateFacialExpressionByCategoryId(Map consumeRatioByCategories, + Long remainDaysRatio) { + + Map facialList = new HashMap<>(); + + // 카테고리 별 소비 비율의 차로 표정 로직 업데이트 + /** + • 성공: 소비 비율이 남은 시간 비율보다 5% 이상 적음 (사용 속도가 매우 느림) + • 잘 지키고 있음: 소비 비율이 남은 시간 비율보다 0~5% 정도 차이 (적절한 소비) + • 기본: 소비 비율이 남은 시간 비율보다 0~10% 정도 높음 (소비가 조금 빠름) + • 아슬아슬함: 소비 비율이 남은 시간 비율보다 10~20% 높음 (조금 더 신경 써야 함) + • 위기: 소비 비율이 남은 시간 비율보다 20~30% 높음 (위험한 수준) + • 실패: 소비 비율이 남은 시간 비율보다 30% 이상 높음 (예산 초과 가능성 큼) + */ + for (Long key : consumeRatioByCategories.keySet()) { + long ratioDifference = consumeRatioByCategories.get(key) - remainDaysRatio; + + if (ratioDifference <= -5) { + facialList.put(key, "성공"); + } else if (ratioDifference <= 0) { + facialList.put(key, "잘 지키고 있음"); + } else if (ratioDifference <= 10) { + facialList.put(key, "기본"); + } else if (ratioDifference <= 20) { + facialList.put(key, "아슬아슬함"); + } else if (ratioDifference <= 30) { + facialList.put(key, "위기"); + } else { + facialList.put(key, "실패"); + } + } + return facialList; + } + + private String getMainFacialExpression(Map facialList) { + + if (facialList.containsValue("실패")) { + return "실패"; + } else if (facialList.containsValue("위기")) { + return "위기"; + } else if (facialList.containsValue("아슬아슬함")) { + return "아슬아슬함"; + } else if (facialList.containsValue("기본")) { + return "기본"; + } else if (facialList.containsValue("잘 지키고 있음")) { + return "잘 지키고 있음"; + } else { + return "성공"; + } + } + + private String getMainComment(List list) { + + // 현재 일수 + long minDifference = Long.MAX_VALUE; + long minCategoryId = -1L; + + int remainDays = LocalDate.now().lengthOfMonth() - LocalDate.now().getDayOfMonth(); + + for (ConsumeAmountAndGoalAmountDto dto : list) { + Long cAmount = dto.getConsumeAmount(); + Long gAmount = dto.getGoalAmount(); + + long differenceAmount = gAmount - cAmount; + + // 차이가 가장 적은 값을 찾기 + if (differenceAmount < minDifference) { + minDifference = differenceAmount; + minCategoryId = dto.getCategoryId(); + } + } + Optional minCategory = categoryRepository.findById(minCategoryId); + + if (minCategory.isEmpty()) { + throw new IllegalArgumentException("해당 카테고리를 찾을 수 없습니다."); + } + + String minCategoryName = minCategory.get().getName(); + + long todayAvailableConsumptionAmount = minDifference / remainDays; + long weekAvailableConsumptionAmount = todayAvailableConsumptionAmount * 7; + + NumberFormat nf = NumberFormat.getInstance(Locale.KOREA); // 한국 단위로 locale + + if (weekAvailableConsumptionAmount < 0) { + return "이번 달에는 " + minCategoryName + "에 " + Math.abs(minDifference) / 10000 + "만원 이상 초과했어요!"; + } else if (weekAvailableConsumptionAmount <= 10000) { + return "이번 달에는 " + minCategoryName + "에 " + nf.format(weekAvailableConsumptionAmount / 1000 * 1000) + + "원 이상 쓰시면 안 돼요!"; + } else { + return "이번 주에는 " + minCategoryName + "에 " + Math.abs(Math.abs(weekAvailableConsumptionAmount) / 10000) + + "만원 이상 쓰시면 안 돼요!"; + } + } + + @Override + @Async + @Transactional(readOnly = true) + @Cacheable(value = "consumptionMent", key = "#userId") + public CompletableFuture getConsumptionMention(Long userId) { + + /** + * 가장 큰 소비를 한 카테고리의 소비 목표 데이터 정보와 가장 큰 목표로 세운 카테고리의 소비 목표 데이터를 각각 가져온다. + * 위 데이터들을 가지고 프롬프트 진행 + * Chat GPT + */ + + // 유저 아이디로 또래 정보 확인 + checkPeerInfo(userId, 0, 0, "none"); + + // 가장 큰 소비를 한 카테고리의 소비 목표 데이터 가져오기 + Optional maxConsumeAmount = consumptionGoalRepository.findMaxConsumeAmountByCategory( + peerAgeStart, + peerAgeEnd, + peerGender, currentMonth); + + // 가장 큰 목표로 세운 카테고리의 소비 목표 데이터 가져오기 + Optional maxGoalAmount = consumptionGoalRepository.findMaxGoalAmountByCategory( + peerAgeStart, + peerAgeEnd, + peerGender, currentMonth); + + if (maxConsumeAmount.isEmpty()) { + throw new IllegalArgumentException("해당 소비목표 데이터를 찾을 수 없습니다."); + } + + // 유저 이름과 소비 목표 데이터로 카테고리 이름, 소비 금액을 가져 온다. + String username = findUserById(userId).getName(); + String categoryName = maxConsumeAmount.get().getCategory().getName(); + long consumeAmount = maxConsumeAmount.get().getConsumeAmount(); + + // 또래의 상위 소비 금액에 대한 정보로 프롬프트 작성 + String firstPrompt = "00은 " + username + ", 가장 큰 소비 카테고리 이름은 " + categoryName + + "," + "해당 카테고리 소비금액은" + consumeAmount + "이야"; + + if (maxGoalAmount.isEmpty()) { + throw new IllegalArgumentException("해당 소비목표 데이터를 찾을 수 없습니다."); + } + + // 가장 큰 목표 소비 금액에 대한 정보로 프롬프트 작성 + categoryName = maxGoalAmount.get().getCategory().getName(); + long goalAmount = maxGoalAmount.get().getGoalAmount(); + + // 또래의 상위 목표 소비 금액에 대한 정보로 프롬프트 작성 + String secondPrompt = "가장 큰 목표 소비 카테고리 이름은 " + categoryName + + ", 해당 카테고리 목표금액은" + goalAmount + "이야"; + + // 프롬프트를 통해 소비 목표에 대한 멘트를 작성 + String basePrompt = "소비 분석 관련 멘트를 2개 만들거야 이때," + username + + "님 또래는 ~ 이라는 문장으로 시작하고 35자 이내 한 문장씩 만들어줘" + + firstPrompt + "와" + secondPrompt + "를 사용하고 두 문장의 구분은 줄바꿈으로 해주고, " + + "카테고리 관련 내용(ex. 패션-밥보다 옷을 더 많이 사요, 유흥-술자리에 N만원 써요)같은 멘트나" + + "카테고리 목표 금액(ex. 패션에 N만원 소비를 계획해요)같은 트렌드 한 멘트, 인터넷상 바이럴 문구" + + "참고하여 만들어줘"; + + String response = openAiService.chat(basePrompt); + + // GPT 프롬프트 실패 시 기본 멘트 생성 반환 + if (response == null) { + NumberFormat nf = NumberFormat.getInstance(Locale.KOREA); + response = "총 " + nf.format(goalAmount - consumeAmount) + "원 더 쓸 수 있어요."; + return CompletableFuture.completedFuture(response); + } + + return CompletableFuture.completedFuture(response); + } + + @Override + @Transactional + public ConsumptionGoal getUserConsumptionGoal(User user, Category category, LocalDate goalDate) { + LocalDate goalMonth = goalDate.withDayOfMonth(1); + + return consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, category, goalMonth) + .orElseGet(() -> this.generateGoalFromPreviousOrNew(user, category, goalMonth)); + } + + private ConsumptionGoal generateGoalFromPreviousOrNew(User user, Category category, LocalDate goalMonth) { + LocalDate previousMonth = goalMonth.minusMonths(1); + + return consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, category, previousMonth) + .map(this::generateGoalByPrevious) + .orElseGet(() -> generateNewConsumptionGoal(user, category, goalMonth)); + } + + private ConsumptionGoal generateGoalByPrevious(ConsumptionGoal consumptionGoal) { + return consumptionGoalRepository.save(ConsumptionGoal.builder() + .goalMonth(consumptionGoal.getGoalMonth().plusMonths(1)) + .user(consumptionGoal.getUser()) + .category(consumptionGoal.getCategory()) + .consumeAmount(0L) + .goalAmount(consumptionGoal.getGoalAmount()) + .build()); + } + + private ConsumptionGoal generateNewConsumptionGoal(User user, Category category, LocalDate goalMonth) { + return consumptionGoalRepository.save(ConsumptionGoal.builder() + .goalMonth(goalMonth) + .user(user) + .category(category) + .consumeAmount(0L) + .goalAmount(0L) + .build()); + } + } \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/controller/DiscountInfoApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/controller/DiscountInfoApi.java index bf3445b6..f2e0bf76 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/controller/DiscountInfoApi.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/controller/DiscountInfoApi.java @@ -1,12 +1,17 @@ package com.bbteam.budgetbuddies.domain.discountinfo.controller; import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.apiPayload.code.ErrorReasonDto; import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountRequest; import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountResponseDto; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; import com.bbteam.budgetbuddies.domain.user.validation.ExistUser; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.PathVariable; @@ -17,10 +22,14 @@ public interface DiscountInfoApi { @Operation(summary = "[User] 특정 년월 할인정보 리스트 가져오기 API", description = "특정 년도와 월에 해당하는 할인정보 목록을 조회하는 API이며, 페이징을 포함합니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PAGE4001", description = "요청된 페이지가 0보다 작습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) @Parameters({ @Parameter(name = "year", description = "데이터를 가져올 연도입니다."), @@ -37,11 +46,13 @@ ApiResponse> getDiscountsByYearAndMonth( @Operation(summary = "[ADMIN] 할인정보 등록하기 API", description = "할인정보를 등록하는 API이며, 추후에는 관리자만 접근 가능하도록 할 예정입니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) - }) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) @Parameters({ // @Parameter(name = "discountRequestDto", description = "등록할 할인 정보의 전체 내용입니다."), }) @@ -51,27 +62,31 @@ ApiResponse registerDiscountInfo( @Operation(summary = "[User] 특정 할인정보에 좋아요 클릭 API", description = "특정 할인정보에 좋아요 버튼을 클릭하는 API이며, 일단은 사용자 ID를 입력하여 사용합니다. (추후 토큰으로 검증)") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) - }) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) @Parameters({ - @Parameter(name = "userId", description = "좋아요를 누른 사용자의 id입니다."), @Parameter(name = "discountInfoId", description = "좋아요를 누를 할인정보의 id입니다."), }) ApiResponse likeDiscountInfo( - @RequestParam @ExistUser Long userId, + @AuthUser UserDto.AuthUserDto user, @PathVariable Long discountInfoId ); @Operation(summary = "[ADMIN] 특정 할인정보 수정하기 API", description = "특정 할인정보를 수정하는 API입니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) - }) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PAGE4001", description = "요청된 페이지가 0보다 작습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) @Parameters({ }) ApiResponse updateDiscountInfo( @@ -80,10 +95,13 @@ ApiResponse updateDiscountInfo( @Operation(summary = "[ADMIN] 특정 할인정보 삭제하기 API", description = "ID를 통해 특정 할인정보를 삭제하는 API입니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) @Parameters({ @Parameter(name = "discountInfoId", description = "삭제할 할인 정보의 id입니다."), @@ -94,11 +112,14 @@ ApiResponse deleteDiscountInfo( @Operation(summary = "[ADMIN] 특정 할인정보 가져오기 API", description = "ID를 통해 특정 할인정보를 가져오는 API입니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) - }) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PAGE4001", description = "요청된 페이지가 0보다 작습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) @Parameters({ @Parameter(name = "discountInfoId", description = "조회할 할인 정보의 id입니다."), }) @@ -106,4 +127,25 @@ ApiResponse getDiscountInfo( @PathVariable Long discountInfoId ); + @Operation(summary = "[ADMIN] 특정 사용자가 좋아요를 누른 할인정보 가져오기 API", description = "특정 사용자가 좋아요를 누른 할인정보들을 가져오는 API입니다. 페이징을 포함합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PAGE4001", description = "요청된 페이지가 0보다 작습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) + @Parameters({ + @Parameter(name = "userId", description = "특정 사용자의 id입니다."), + @Parameter(name = "page", description = "페이지 번호, 0번이 1 페이지 입니다. (기본값은 0입니다.)"), + @Parameter(name = "size", description = "한 페이지에 불러올 데이터 개수입니다. (기본값은 10개입니다.)") + }) + ApiResponse> getLikedDiscountInfo( + @AuthUser UserDto.AuthUserDto user, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "10") Integer size + ); + } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/controller/DiscountInfoController.java b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/controller/DiscountInfoController.java index a70a3bf4..9a29fe89 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/controller/DiscountInfoController.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/controller/DiscountInfoController.java @@ -4,7 +4,8 @@ import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountRequest; import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountResponseDto; import com.bbteam.budgetbuddies.domain.discountinfo.service.DiscountInfoService; -import com.bbteam.budgetbuddies.domain.user.validation.ExistUser; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.*; @@ -42,10 +43,10 @@ public ApiResponse registerDiscountInfo( @Override @PostMapping("/likes/{discountInfoId}") public ApiResponse likeDiscountInfo( - @RequestParam @ExistUser Long userId, + @AuthUser UserDto.AuthUserDto user, @PathVariable Long discountInfoId ) { - DiscountResponseDto discountResponseDto = discountInfoService.toggleLike(userId, discountInfoId); + DiscountResponseDto discountResponseDto = discountInfoService.toggleLike(user.getId(), discountInfoId); return ApiResponse.onSuccess(discountResponseDto); } @@ -81,4 +82,16 @@ public ApiResponse getDiscountInfo( return ApiResponse.onSuccess(discountResponseDto); } + @Override + @GetMapping("/liked-all") + public ApiResponse> getLikedDiscountInfo( + @AuthUser UserDto.AuthUserDto user, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "10") Integer size + ) { + Page likedDiscountInfoPage = discountInfoService.getLikedDiscountInfo(user.getId(), page, size); + + return ApiResponse.onSuccess(likedDiscountInfoPage); + } + } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/converter/DiscountInfoConverter.java b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/converter/DiscountInfoConverter.java index c00cb6fa..fba82b05 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/converter/DiscountInfoConverter.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/converter/DiscountInfoConverter.java @@ -1,30 +1,51 @@ package com.bbteam.budgetbuddies.domain.discountinfo.converter; +import com.bbteam.budgetbuddies.domain.connectedinfo.entity.ConnectedInfo; +import com.bbteam.budgetbuddies.domain.connectedinfo.repository.ConnectedInfoRepository; import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountRequest; import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountResponseDto; import com.bbteam.budgetbuddies.domain.discountinfo.entity.DiscountInfo; +import com.bbteam.budgetbuddies.domain.discountinfolike.entity.DiscountInfoLike; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.stream.Collectors; + @Service +@RequiredArgsConstructor public class DiscountInfoConverter { + private final ConnectedInfoRepository connectedInfoRepository; + + /** * @param entity * @return responseDto */ - public DiscountResponseDto toDto(DiscountInfo entity) { + public DiscountResponseDto toDto(DiscountInfo discountInfo, List connectedInfos) { + // 특정 할인정보의 해시태그 가져오기 + List hashtags = connectedInfos.stream() + .filter(connectedInfo -> connectedInfo.getDiscountInfo() != null && connectedInfo.getDiscountInfo().equals(discountInfo)) // 특정 DiscountInfo와 연결된 ConnectedInfo만 필터링 + .map(ConnectedInfo::getHashtag) + .map(Hashtag::getName) + .collect(Collectors.toList()); return DiscountResponseDto.builder() - .id(entity.getId()) - .title(entity.getTitle()) - .startDate(entity.getStartDate()) - .endDate(entity.getEndDate()) - .anonymousNumber(entity.getAnonymousNumber()) - .discountRate(entity.getDiscountRate()) - .likeCount(entity.getLikeCount()) - .siteUrl(entity.getSiteUrl()) - .thumbnailUrl(entity.getThumbnailUrl()) + .id(discountInfo.getId()) + .title(discountInfo.getTitle()) + .startDate(discountInfo.getStartDate()) + .endDate(discountInfo.getEndDate()) + .anonymousNumber(discountInfo.getAnonymousNumber()) + .discountRate(discountInfo.getDiscountRate()) + .likeCount(discountInfo.getLikeCount()) + .siteUrl(discountInfo.getSiteUrl()) + .thumbnailUrl(discountInfo.getThumbnailUrl()) + .hashtags(hashtags) .build(); + } /** @@ -43,8 +64,48 @@ public DiscountInfo toEntity(DiscountRequest.RegisterDiscountDto requestDto) { .likeCount(0) .siteUrl(requestDto.getSiteUrl()) .thumbnailUrl(requestDto.getThumbnailUrl()) - .isInCalendar(requestDto.getIsInCalendar()) + .isInCalendar(requestDto.getIsInCalendar()) .build(); } + + /** + * @param Page + * @return Page + */ + public Page toEntityPage(Page likes) { + + return likes.map(like -> { + // 1. DiscountInfoLike 객체에서 DiscountInfo를 추출합니다. + DiscountInfo discountInfo = like.getDiscountInfo(); + + // 2. 해당 DiscountInfo와 연결된 모든 ConnectedInfo를 connectedInfoRepository를 통해 조회합니다. + List connectedInfos = connectedInfoRepository.findAllByDiscountInfo(discountInfo); + + // 3. ConnectedInfo 리스트에서 DiscountInfo와 연결된 해시태그를 필터링하고, 해시태그의 이름을 추출합니다. + List hashtags = connectedInfos.stream() + // 3-1. DiscountInfo와 연결된 ConnectedInfo만 필터링합니다. + .filter(connectedInfo -> connectedInfo.getDiscountInfo() != null + && connectedInfo.getDiscountInfo().equals(discountInfo)) + // 3-2. ConnectedInfo에서 Hashtag 엔티티를 추출하고, 그 해시태그의 이름을 얻습니다. + .map(ConnectedInfo::getHashtag) + .map(Hashtag::getName) + .toList(); + + // 4. 추출한 데이터를 기반으로 DiscountResponseDto를 생성합니다. + return DiscountResponseDto.builder() + .id(discountInfo.getId()) + .title(discountInfo.getTitle()) + .startDate(discountInfo.getStartDate()) + .endDate(discountInfo.getEndDate()) + .anonymousNumber(discountInfo.getAnonymousNumber()) + .discountRate(discountInfo.getDiscountRate()) + .likeCount(discountInfo.getLikeCount()) + .siteUrl(discountInfo.getSiteUrl()) + .thumbnailUrl(discountInfo.getThumbnailUrl()) + .hashtags(hashtags) + .build(); + }); + } + } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/dto/DiscountRequest.java b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/dto/DiscountRequest.java index 9fc2e34b..89496ba7 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/dto/DiscountRequest.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/dto/DiscountRequest.java @@ -3,6 +3,7 @@ import lombok.*; import java.time.LocalDate; +import java.util.List; public class DiscountRequest { @@ -26,6 +27,8 @@ public static class RegisterDiscountDto { private Boolean isInCalendar; + private List hashtagIds; + } @Getter @@ -49,6 +52,9 @@ public static class UpdateDiscountDto { private String thumbnailUrl; private Boolean isInCalendar; + + private List hashtagIds; + } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/dto/DiscountResponseDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/dto/DiscountResponseDto.java index 3948f419..57fa40d7 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/dto/DiscountResponseDto.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/dto/DiscountResponseDto.java @@ -1,8 +1,10 @@ package com.bbteam.budgetbuddies.domain.discountinfo.dto; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; import lombok.*; import java.time.LocalDate; +import java.util.List; @Getter @Builder @@ -28,4 +30,6 @@ public class DiscountResponseDto { private String thumbnailUrl; + private List hashtags; + } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/entity/DiscountInfo.java b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/entity/DiscountInfo.java index 91153236..ccbed0f5 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/entity/DiscountInfo.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/entity/DiscountInfo.java @@ -64,4 +64,10 @@ public void update(DiscountRequest.UpdateDiscountDto discountRequestDto) { this.isInCalendar = discountRequestDto.getIsInCalendar(); } + public static DiscountInfo withId(Long id) { + DiscountInfo discountInfo = new DiscountInfo(); + discountInfo.setId(id); + return discountInfo; + } + } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoService.java b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoService.java index 3b2aee74..8e51ad99 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoService.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoService.java @@ -22,4 +22,10 @@ Page getDiscountsByYearAndMonth( DiscountResponseDto getDiscountInfoById(Long discountInfoId); + Page getLikedDiscountInfo( + Long userId, + Integer page, + Integer size + ); + } \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoServiceImpl.java index 54051dca..2773d773 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoServiceImpl.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoServiceImpl.java @@ -1,5 +1,7 @@ package com.bbteam.budgetbuddies.domain.discountinfo.service; +import com.bbteam.budgetbuddies.domain.connectedinfo.entity.ConnectedInfo; +import com.bbteam.budgetbuddies.domain.connectedinfo.repository.ConnectedInfoRepository; import com.bbteam.budgetbuddies.domain.discountinfo.converter.DiscountInfoConverter; import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountRequest; import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountResponseDto; @@ -7,6 +9,8 @@ import com.bbteam.budgetbuddies.domain.discountinfo.repository.DiscountInfoRepository; import com.bbteam.budgetbuddies.domain.discountinfolike.entity.DiscountInfoLike; import com.bbteam.budgetbuddies.domain.discountinfolike.repository.DiscountInfoLikeRepository; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; +import com.bbteam.budgetbuddies.domain.hashtag.repository.HashtagRepository; import com.bbteam.budgetbuddies.domain.user.entity.User; import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -17,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.List; import java.util.Optional; @Service @@ -31,15 +36,19 @@ public class DiscountInfoServiceImpl implements DiscountInfoService { private final UserRepository userRepository; + private final HashtagRepository hashtagRepository; + + private final ConnectedInfoRepository connectedInfoRepository; @Transactional(readOnly = true) @Override public Page getDiscountsByYearAndMonth(Integer year, Integer month, Integer page, Integer size) { /** - * 1. Pageable 객체 생성 (사용자로부터 입력받은 page 번호와 size) - * 2. 사용자가 요청한 년월을 기준으로 해당 년월의 1일로 LocalDate 객체 생성 - * 3. 해당 년월에 겹치는 할인정보 데이터 가져오기 - * 4. Entity 리스트를 -> Dto로 모두 변환하여 리턴 + * 1. Pageable 객체 생성: 사용자가 요청한 페이지 번호와 사이즈를 기반으로 Pageable 객체를 생성합니다. + * 2. 사용자가 요청한 년월에 해당하는 LocalDate 객체 생성: 해당 월의 첫날과 마지막 날을 기준으로 LocalDate 객체를 생성합니다. + * 3. 해당 년월에 겹치는 할인정보 데이터 가져오기: Repository를 사용하여 해당 월에 포함되는 할인정보를 페이지네이션하여 가져옵니다. + * 4. DiscountInfo 엔티티를 통해 ConnectedInfo 리스트 가져오기: 각 DiscountInfo에 대해 연결된 ConnectedInfo 리스트를 가져옵니다. + * 5. DiscountInfo와 ConnectedInfo 리스트를 Dto로 변환하여 리턴: 모든 데이터를 DTO로 변환하여 반환합니다. */ Pageable pageable = PageRequest.of(page, size); @@ -48,38 +57,55 @@ public Page getDiscountsByYearAndMonth(Integer year, Intege Page discountInfoPage = discountInfoRepository.findByDateRange(startDate, endDate, pageable); - return discountInfoPage.map(discountInfoConverter::toDto); + return discountInfoPage.map( + discountInfo -> { + List connectedInfos = connectedInfoRepository.findAllByDiscountInfo(discountInfo); + return discountInfoConverter.toDto(discountInfo, connectedInfos); + } + ); } @Transactional @Override public DiscountResponseDto registerDiscountInfo(DiscountRequest.RegisterDiscountDto discountRequestDto) { /** - * 1. RequestDto -> Entity로 변환 - * 2. Entity 저장 - * 3. Entity -> ResponseDto로 변환 후 리턴 + * 1. RequestDto를 Entity로 변환: 입력받은 DTO를 DiscountInfo 엔티티로 변환합니다. + * 2. Entity 저장: 변환된 엔티티를 Repository를 통해 데이터베이스에 저장합니다. + * 3. Hashtag 엔티티 조회 및 연결: 입력받은 해시태그 ID 리스트를 사용하여 Hashtag 엔티티를 조회하고, 각 해시태그와 DiscountInfo를 연결하는 ConnectedInfo 엔티티를 생성하여 저장합니다. + * 4. ConnectedInfo 리스트 조회: 저장된 DiscountInfo와 관련된 ConnectedInfo 리스트를 조회합니다. + * 5. Entity를 ResponseDto로 변환하여 반환: 저장된 DiscountInfo와 관련된 ConnectedInfo 리스트를 포함한 DTO를 반환합니다. */ DiscountInfo entity = discountInfoConverter.toEntity(discountRequestDto); discountInfoRepository.save(entity); - return discountInfoConverter.toDto(entity); + List hashtags = hashtagRepository.findByIdIn(discountRequestDto.getHashtagIds()); + hashtags.forEach(hashtag -> { + ConnectedInfo connectedInfo = ConnectedInfo.builder() + .discountInfo(entity) + .hashtag(hashtag) + .build(); + connectedInfoRepository.save(connectedInfo); + }); + + List connectedInfos = connectedInfoRepository.findAllByDiscountInfo(entity); + return discountInfoConverter.toDto(entity, connectedInfos); } @Transactional @Override public DiscountResponseDto toggleLike(Long userId, Long discountInfoId) { /** - * 1. 사용자 조회 -> 없으면 에러 - * 2. 할인정보 조회 -> 없으면 에러 - * 3. 사용자가 특정 할인정보에 좋아요를 눌렀는지 확인 (DiscountInfoLike 테이블에서 userId로부터 데이터 가져오기) - * 4. 누르지 않은 상태라면, - * 4-1. DiscountInfo의 likeCount 1 증가 - * 4-2. DiscountInfoLike의 - * 5. 이미 누른 상태라면, - * 5-1. DiscountInfo의 likeCount 1 감소 + * 1. 사용자 조회: 주어진 userId로 User 엔티티를 조회하며, 존재하지 않을 경우 에러를 발생시킵니다. + * 2. 할인정보 조회: 주어진 discountInfoId로 DiscountInfo 엔티티를 조회하며, 존재하지 않을 경우 에러를 발생시킵니다. + * 3. 사용자가 특정 할인정보에 좋아요를 눌렀는지 확인: DiscountInfoLike 테이블을 통해 사용자가 해당 할인정보에 좋아요를 눌렀는지 확인합니다. + * 4. 좋아요 상태에 따라 처리: + * - 이미 좋아요를 누른 경우: 좋아요 상태를 변경하고, DiscountInfo의 likeCount를 감소시킵니다. + * - 좋아요를 누르지 않은 경우: 좋아요 상태를 변경하고, DiscountInfo의 likeCount를 증가시킵니다. + * 5. 새 좋아요 객체 생성: 좋아요가 처음 생성된 경우 새로운 DiscountInfoLike 객체를 생성하고 저장합니다. + * 6. 저장된 DiscountInfo와 관련된 ConnectedInfo 리스트 조회: 업데이트된 DiscountInfo와 연결된 ConnectedInfo 리스트를 조회합니다. + * 7. Entity를 ResponseDto로 변환하여 반환: 최종적으로 업데이트된 DiscountInfo와 관련된 ConnectedInfo 리스트를 포함한 DTO를 반환합니다. */ - User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("User not found")); @@ -100,7 +126,7 @@ public DiscountResponseDto toggleLike(Long userId, Long discountInfoId) { discountInfo.addLikeCount(); } } else { - // 처음 객체를 생성한다면 + // 아직 좋아요를 누르지 않은 상태라면 DiscountInfoLike newLike = DiscountInfoLike.builder() .user(user) .discountInfo(discountInfo) @@ -112,20 +138,21 @@ public DiscountResponseDto toggleLike(Long userId, Long discountInfoId) { DiscountInfo savedEntity = discountInfoRepository.save(discountInfo); - return discountInfoConverter.toDto(savedEntity); + List connectedInfos = connectedInfoRepository.findAllByDiscountInfo(discountInfo); + + return discountInfoConverter.toDto(savedEntity, connectedInfos); } @Transactional @Override public DiscountResponseDto updateDiscountInfo(DiscountRequest.UpdateDiscountDto discountRequestDto) { /** - * 1. 할인정보 조회 -> 없으면 에러 - * 2. 변경사항 업데이트 - * 3. 변경사항 저장 - * 4. Entity -> ResponseDto로 변환 후 리턴 + * 1. 할인정보 조회: 주어진 discountRequestDto의 ID로 DiscountInfo 엔티티를 조회하며, 존재하지 않을 경우 에러를 발생시킵니다. + * 2. 변경사항 업데이트: 조회된 DiscountInfo 엔티티에 대해 DTO에서 전달된 변경사항을 업데이트합니다. + * 3. 변경사항 저장: 업데이트된 DiscountInfo 엔티티를 데이터베이스에 저장합니다. + * 4. 저장된 DiscountInfo와 관련된 ConnectedInfo 리스트 조회: 업데이트된 DiscountInfo와 연결된 ConnectedInfo 리스트를 조회합니다. + * 5. Entity를 ResponseDto로 변환하여 반환: 업데이트된 DiscountInfo와 관련된 ConnectedInfo 리스트를 포함한 DTO를 반환합니다. */ - - DiscountInfo discountInfo = discountInfoRepository.findById(discountRequestDto.getId()) .orElseThrow(() -> new IllegalArgumentException("DiscountInfo not found")); @@ -133,39 +160,65 @@ public DiscountResponseDto updateDiscountInfo(DiscountRequest.UpdateDiscountDto discountInfoRepository.save(discountInfo); // 변경사항 저장 - return discountInfoConverter.toDto(discountInfo); + List connectedInfos = connectedInfoRepository.findAllByDiscountInfo(discountInfo); + + return discountInfoConverter.toDto(discountInfo, connectedInfos); } @Transactional @Override public String deleteDiscountInfo(Long discountInfoId) { /** - * 1. 할인정보 조회 -> 없으면 에러 - * 2. Entity 삭제 - * 3. 성공여부 반환 + * 1. 할인정보 조회: 주어진 discountInfoId로 DiscountInfo 엔티티를 조회하며, 존재하지 않을 경우 에러를 발생시킵니다. + * 2. 연관된 ConnectedInfo 삭제: 연결된 모든 ConnectedInfo를 삭제하여 연관된 데이터를 제거합니다. + * 3. DiscountInfo 삭제: DiscountInfo 엔티티를 데이터베이스에서 삭제합니다. + * 4. 성공 여부 반환: 삭제 작업이 완료된 후 "Success"를 반환합니다. */ - DiscountInfo discountInfo = discountInfoRepository.findById(discountInfoId) .orElseThrow(() -> new IllegalArgumentException("DiscountInfo not found")); + // 연결된 ConnectedInfo 삭제 (일단 삭제되지 않도록 주석 처리) +// connectedInfoRepository.deleteAllByDiscountInfo(discountInfo); + + // DiscountInfo 삭제 discountInfoRepository.deleteById(discountInfoId); return "Success"; - } @Transactional @Override public DiscountResponseDto getDiscountInfoById(Long discountInfoId) { /** - * 1. 할인정보 조회 -> 없으면 에러 - * 2. Entity 조회 - * 3. Entity -> ResponseDto로 변환 후 리턴 + * 1. 할인정보 조회: 주어진 discountInfoId로 DiscountInfo 엔티티를 조회하며, 존재하지 않을 경우 에러를 발생시킵니다. + * 2. Entity 조회: 조회된 DiscountInfo와 관련된 ConnectedInfo 리스트를 가져옵니다. + * 3. Entity를 ResponseDto로 변환하여 반환: DiscountInfo와 관련된 ConnectedInfo 리스트를 포함한 DTO를 반환합니다. */ - DiscountInfo discountInfo = discountInfoRepository.findById(discountInfoId) .orElseThrow(() -> new IllegalArgumentException("DiscountInfo not found")); - return discountInfoConverter.toDto(discountInfo); + List connectedInfos = connectedInfoRepository.findAllByDiscountInfo(discountInfo); + + return discountInfoConverter.toDto(discountInfo, connectedInfos); + } + + @Transactional + @Override + public Page getLikedDiscountInfo(Long userId, Integer page, Integer size) { + /** + * 1. 페이징 설정: 주어진 page와 size를 사용하여 Pageable 객체를 생성합니다. + * 2. 사용자 조회: userId로 User 엔티티를 조회하며, 존재하지 않을 경우 IllegalArgumentException을 발생시킵니다. + * 3. 좋아요 정보 조회: 조회된 User에 대한 DiscountInfoLike 리스트를 업데이트 시간 기준으로 내림차순 정렬하여 가져옵니다. + * 4. ResponseDto로 변환: DiscountInfoLike 리스트를 DiscountResponseDto 페이지로 변환하여 반환합니다. + */ + + Pageable pageable = PageRequest.of(page, size); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + Page likes = discountInfoLikeRepository.findAllByUserOrderByUpdatedAtDesc(user, pageable); + + return discountInfoConverter.toEntityPage(likes); } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfolike/repository/DiscountInfoLikeRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfolike/repository/DiscountInfoLikeRepository.java index 3a55285d..bf4606d1 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/discountinfolike/repository/DiscountInfoLikeRepository.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/discountinfolike/repository/DiscountInfoLikeRepository.java @@ -3,6 +3,8 @@ import com.bbteam.budgetbuddies.domain.discountinfo.entity.DiscountInfo; import com.bbteam.budgetbuddies.domain.discountinfolike.entity.DiscountInfoLike; import com.bbteam.budgetbuddies.domain.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -11,4 +13,7 @@ public interface DiscountInfoLikeRepository extends JpaRepository findByUserAndDiscountInfo(User user, DiscountInfo discountInfo); + // 사용자가 좋아요를 누른 시점을 기준으로 최신순 정렬 + Page findAllByUserOrderByUpdatedAtDesc(User user, Pageable pageable); + } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/expense/controller/ExpenseApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/expense/controller/ExpenseApi.java index 50a8cc9a..45856956 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/expense/controller/ExpenseApi.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/expense/controller/ExpenseApi.java @@ -2,20 +2,16 @@ import java.time.LocalDate; -import org.springframework.data.repository.query.Param; -import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; 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.RequestParam; +import com.bbteam.budgetbuddies.domain.expense.dto.DetailExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseRequestDto; -import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseUpdateRequestDto; import com.bbteam.budgetbuddies.domain.expense.dto.MonthlyExpenseResponseDto; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -31,9 +27,9 @@ public interface ExpenseApi { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), @ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), @ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class)))}) - ResponseEntity createExpense( - @Parameter(description = "user_id") @PathVariable Long userId, - @Parameter(description = "category_id, amount, description, expenseDate") @RequestBody ExpenseRequestDto expenseRequestDto + ResponseEntity createExpense( + @Parameter(description = "user_id") @PathVariable Long userId, + @Parameter(description = "category_id, amount, description, expenseDate") @RequestBody ExpenseRequestDto expenseRequestDto ); @Operation(summary = "[User] 월별 소비 조회", description = "무한 스크롤을 통한 조회로 예상하여 Slice를 통해서 조회") @@ -43,8 +39,8 @@ ResponseEntity createExpense( @ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), @ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class)))}) ResponseEntity findExpensesForMonth( - @PathVariable @Param("userId") Long userId, - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date); + UserDto.AuthUserDto user, + LocalDate date); @Operation(summary = "[User] 단일 소비 조회하기", description = "queryParameter를 통해 소비 Id를 전달 받아서 응답값을 조회") @ApiResponses({ @@ -52,8 +48,7 @@ ResponseEntity findExpensesForMonth( @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), @ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), @ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class)))}) - @GetMapping("/{userId}/{expenseId}") - ResponseEntity findExpense(@Param("userId") Long userId, @Param("expenseId") Long expenseId); + ResponseEntity findExpense(UserDto.AuthUserDto user, Long expenseId); @Operation(summary = "[User] 단일 소비 업데이트하기", description = "소비 아이디와 카테고리 아이디, amount(소비 금액)을 body에 담아서 소비를 업데이트") @ApiResponses({ @@ -61,10 +56,7 @@ ResponseEntity findExpensesForMonth( @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), @ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), @ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class)))}) - @GetMapping("/{userId}/{expenseId}") - @PostMapping("/{userId}") - ResponseEntity updateExpense(@PathVariable @Param("userId") Long userId, - @RequestBody ExpenseUpdateRequestDto request); + ResponseEntity updateExpense(UserDto.AuthUserDto user, ExpenseUpdateRequestDto request); @Operation(summary = "[User] 소비 내역 삭제", description = "사용자가 소비 내역을 삭제합니다.") @ApiResponses({ diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/expense/controller/ExpenseController.java b/src/main/java/com/bbteam/budgetbuddies/domain/expense/controller/ExpenseController.java index c6499330..75329f71 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/expense/controller/ExpenseController.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/expense/controller/ExpenseController.java @@ -15,10 +15,12 @@ import org.springframework.web.bind.annotation.RestController; import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseRequestDto; -import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseResponseDto; +import com.bbteam.budgetbuddies.domain.expense.dto.DetailExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseUpdateRequestDto; import com.bbteam.budgetbuddies.domain.expense.dto.MonthlyExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.service.ExpenseService; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; import io.swagger.v3.oas.annotations.Parameter; import lombok.RequiredArgsConstructor; @@ -31,41 +33,37 @@ public class ExpenseController implements ExpenseApi { @Override @PostMapping("/add/{userId}") - public ResponseEntity createExpense( - @Parameter(description = "user_id") @PathVariable Long userId, - @Parameter(description = "category_id, amount, description, expenseDate") @RequestBody ExpenseRequestDto expenseRequestDto) { - ExpenseResponseDto response = expenseService.createExpense(userId, expenseRequestDto); + public ResponseEntity createExpense( + @Parameter(description = "user_id") @PathVariable Long userId, + @Parameter(description = "category_id, amount, description, expenseDate") @RequestBody ExpenseRequestDto expenseRequestDto) { + DetailExpenseResponseDto response = expenseService.createExpense(userId, expenseRequestDto); return ResponseEntity.ok(response); } @Override - @GetMapping("/{userId}") - public ResponseEntity findExpensesForMonth( - @PathVariable @Param("userId") Long userId, + @GetMapping() + public ResponseEntity findExpensesForMonth(@AuthUser UserDto.AuthUserDto user, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) { - return ResponseEntity.ok(expenseService.getMonthlyExpense(userId, date)); + return ResponseEntity.ok(expenseService.getMonthlyExpense(user.getId(), date)); } @Override - @GetMapping("/{userId}/{expenseId}") - public ResponseEntity findExpense(@PathVariable @Param("userId") Long userId, + @GetMapping("/{expenseId}") + public ResponseEntity findExpense(@AuthUser UserDto.AuthUserDto user, @PathVariable @Param("expenseId") Long expenseId) { - return ResponseEntity.ok(expenseService.findExpenseResponseFromUserIdAndExpenseId(userId, expenseId)); + return ResponseEntity.ok(expenseService.findDetailExpenseResponse(user.getId(), expenseId)); } @Override - @PostMapping("/{userId}") - public ResponseEntity updateExpense(@PathVariable @Param("userId") Long userId, + @PostMapping() + public ResponseEntity updateExpense(@AuthUser UserDto.AuthUserDto user, @RequestBody ExpenseUpdateRequestDto request) { - ExpenseResponseDto response = expenseService.updateExpense(userId, request); - return ResponseEntity.ok(response); + return ResponseEntity.ok(expenseService.updateExpense(user.getId(), request)); } @DeleteMapping("/delete/{expenseId}") - public ResponseEntity deleteExpense( - @Parameter(description = "expense_id") - @PathVariable Long expenseId) { + public ResponseEntity deleteExpense(@Parameter(description = "expense_id") @PathVariable Long expenseId) { expenseService.deleteExpense(expenseId); return ResponseEntity.ok("Successfully deleted expense!"); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/expense/converter/ExpenseConverter.java b/src/main/java/com/bbteam/budgetbuddies/domain/expense/converter/ExpenseConverter.java index 665849d1..3c639164 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/expense/converter/ExpenseConverter.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/expense/converter/ExpenseConverter.java @@ -13,7 +13,7 @@ import com.bbteam.budgetbuddies.domain.expense.dto.CompactExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.DailyExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseRequestDto; -import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseResponseDto; +import com.bbteam.budgetbuddies.domain.expense.dto.DetailExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.MonthlyExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.entity.Expense; import com.bbteam.budgetbuddies.domain.user.entity.User; @@ -31,10 +31,9 @@ public Expense toExpenseEntity(ExpenseRequestDto expenseRequestDto, User user, C .build(); } - public ExpenseResponseDto toExpenseResponseDto(Expense expense) { - return ExpenseResponseDto.builder() + public DetailExpenseResponseDto toDetailExpenseResponseDto(Expense expense) { + return DetailExpenseResponseDto.builder() .expenseId(expense.getId()) - .userId(expense.getUser().getId()) .categoryId(expense.getCategory().getId()) .categoryName(expense.getCategory().getName()) .amount(expense.getAmount()) diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/expense/dto/ExpenseResponseDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/expense/dto/DetailExpenseResponseDto.java similarity index 91% rename from src/main/java/com/bbteam/budgetbuddies/domain/expense/dto/ExpenseResponseDto.java rename to src/main/java/com/bbteam/budgetbuddies/domain/expense/dto/DetailExpenseResponseDto.java index f0996be0..ef0fdef5 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/expense/dto/ExpenseResponseDto.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/expense/dto/DetailExpenseResponseDto.java @@ -13,9 +13,8 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class ExpenseResponseDto { +public class DetailExpenseResponseDto { private Long expenseId; - private Long userId; private Long categoryId; private String categoryName; private Long amount; diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/expense/dto/ExpenseUpdateRequestDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/expense/dto/ExpenseUpdateRequestDto.java index 14312ca2..464cd098 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/expense/dto/ExpenseUpdateRequestDto.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/expense/dto/ExpenseUpdateRequestDto.java @@ -16,9 +16,11 @@ @Builder public class ExpenseUpdateRequestDto { private Long expenseId; + private Long categoryId; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime expenseDate; + private Long amount; } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/expense/entity/Expense.java b/src/main/java/com/bbteam/budgetbuddies/domain/expense/entity/Expense.java index 45e44253..e03c72f9 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/expense/entity/Expense.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/expense/entity/Expense.java @@ -15,6 +15,8 @@ import jakarta.validation.constraints.Min; import lombok.*; import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; @Entity @Getter @@ -33,10 +35,12 @@ public class Expense extends BaseEntity { private LocalDateTime expenseDate; @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) @JoinColumn(name = "user_id") private User user; @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) @JoinColumn(name = "category_id") private Category category; diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/expense/repository/ExpenseRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/expense/repository/ExpenseRepository.java index 86dfd81a..62279b8b 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/expense/repository/ExpenseRepository.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/expense/repository/ExpenseRepository.java @@ -8,19 +8,39 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.CategoryConsumptionCountDto; import com.bbteam.budgetbuddies.domain.expense.entity.Expense; import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.enums.Gender; public interface ExpenseRepository extends JpaRepository { - @Query("SELECT e FROM Expense e WHERE e.user = :user AND e.expenseDate BETWEEN :startDate AND :endDate ORDER BY e.expenseDate DESC") - List findAllByUserIdForPeriod(@Param("user") User user, @Param("startDate") LocalDateTime startDate, + @Query("SELECT e FROM Expense e WHERE e.user.id = :userId AND e.expenseDate >= :startDate AND e.expenseDate < :endDate ORDER BY e.expenseDate DESC") + List findAllByUserIdForPeriod(@Param("userId") Long userId, @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); - List findByCategoryIdAndUserIdAndExpenseDateBetweenAndDeletedFalse(Long categoryId, Long userId, LocalDateTime startDate, LocalDateTime endDate); + List findByCategoryIdAndUserIdAndExpenseDateBetweenAndDeletedFalse(Long categoryId, Long userId, + LocalDateTime startDate, LocalDateTime endDate); List findByCategoryIdAndUserIdAndDeletedFalse(Long categoryId, Long userId); @Modifying @Query("UPDATE Expense e SET e.deleted = TRUE WHERE e.id = :expenseId") void softDeleteById(@Param("expenseId") Long expenseId); + + @Query("SELECT new com.bbteam.budgetbuddies.domain.consumptiongoal.dto.CategoryConsumptionCountDto(" + + "e.category.id, COUNT(e)) " + + "FROM Expense e " + + "WHERE e.category.isDefault = true " + + "AND e.deleted = false " + + "AND e.user.age BETWEEN :peerAgeStart AND :peerAgeEnd " + + "AND e.user.gender = :peerGender " + + "AND e.expenseDate >= :currentMonth " + + "AND e.amount > 0 " + + "GROUP BY e.category.id " + + "ORDER BY COUNT(e) DESC") + List findTopCategoriesByConsumptionCount( + @Param("peerAgeStart") int peerAgeStart, + @Param("peerAgeEnd") int peerAgeEnd, + @Param("peerGender") Gender peerGender, + @Param("currentMonth") LocalDateTime currentMonth); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseService.java b/src/main/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseService.java index 67df0f7e..b14c8332 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseService.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseService.java @@ -3,18 +3,18 @@ import java.time.LocalDate; import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseRequestDto; -import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseResponseDto; +import com.bbteam.budgetbuddies.domain.expense.dto.DetailExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseUpdateRequestDto; import com.bbteam.budgetbuddies.domain.expense.dto.MonthlyExpenseResponseDto; public interface ExpenseService { - ExpenseResponseDto createExpense(Long userId, ExpenseRequestDto expenseRequestDto); + DetailExpenseResponseDto createExpense(Long userId, ExpenseRequestDto expenseRequestDto); MonthlyExpenseResponseDto getMonthlyExpense(Long userId, LocalDate localDate); - ExpenseResponseDto findExpenseResponseFromUserIdAndExpenseId(Long userId, Long expenseId); + DetailExpenseResponseDto findDetailExpenseResponse(Long userId, Long expenseId); - ExpenseResponseDto updateExpense(Long userId, ExpenseUpdateRequestDto request); + DetailExpenseResponseDto updateExpense(Long userId, ExpenseUpdateRequestDto request); void deleteExpense(Long expenseId); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseServiceImpl.java index a51df924..5d6bc445 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseServiceImpl.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseServiceImpl.java @@ -1,7 +1,6 @@ package com.bbteam.budgetbuddies.domain.expense.service; import java.time.LocalDate; -import java.time.LocalTime; import java.util.List; import org.springframework.stereotype.Service; @@ -10,16 +9,18 @@ import com.bbteam.budgetbuddies.domain.category.entity.Category; import com.bbteam.budgetbuddies.domain.category.repository.CategoryRepository; import com.bbteam.budgetbuddies.domain.category.service.CategoryService; +import com.bbteam.budgetbuddies.domain.consumptiongoal.entity.ConsumptionGoal; import com.bbteam.budgetbuddies.domain.consumptiongoal.service.ConsumptionGoalService; import com.bbteam.budgetbuddies.domain.expense.converter.ExpenseConverter; +import com.bbteam.budgetbuddies.domain.expense.dto.DetailExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseRequestDto; -import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseUpdateRequestDto; import com.bbteam.budgetbuddies.domain.expense.dto.MonthlyExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.entity.Expense; import com.bbteam.budgetbuddies.domain.expense.repository.ExpenseRepository; import com.bbteam.budgetbuddies.domain.user.entity.User; import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import com.bbteam.budgetbuddies.domain.user.service.UserService; import lombok.RequiredArgsConstructor; @@ -29,13 +30,14 @@ public class ExpenseServiceImpl implements ExpenseService { private final ExpenseRepository expenseRepository; private final UserRepository userRepository; + private final UserService userService; private final CategoryRepository categoryRepository; private final CategoryService categoryService; private final ExpenseConverter expenseConverter; private final ConsumptionGoalService consumptionGoalService; @Override - public ExpenseResponseDto createExpense(Long userId, ExpenseRequestDto expenseRequestDto) { + public DetailExpenseResponseDto createExpense(Long userId, ExpenseRequestDto expenseRequestDto) { User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("Invalid user ID")); Category category = categoryRepository.findById(expenseRequestDto.getCategoryId()) @@ -73,14 +75,15 @@ else if (!category.getIsDefault() && category.getUser().getId().equals(userId)) if (expenseDateMonth.equals(currentMonth)) { // 현재 월의 소비 내역일 경우 ConsumptionGoal을 업데이트 - consumptionGoalService.updateConsumeAmount(userId, expenseRequestDto.getCategoryId(), expenseRequestDto.getAmount()); + consumptionGoalService.updateConsumeAmount(userId, expenseRequestDto.getCategoryId(), + expenseRequestDto.getAmount()); } -// else { -// // 과거 월의 소비 내역일 경우 해당 월의 ConsumptionGoal을 업데이트 또는 삭제 상태로 생성 -// consumptionGoalService.updateOrCreateDeletedConsumptionGoal(userId, expenseRequestDto.getCategoryId(), expenseDateMonth, expenseRequestDto.getAmount()); -// } + // else { + // // 과거 월의 소비 내역일 경우 해당 월의 ConsumptionGoal을 업데이트 또는 삭제 상태로 생성 + // consumptionGoalService.updateOrCreateDeletedConsumptionGoal(userId, expenseRequestDto.getCategoryId(), expenseDateMonth, expenseRequestDto.getAmount()); + // } - return expenseConverter.toExpenseResponseDto(expense); + return expenseConverter.toDetailExpenseResponseDto(expense); /* Case 1 결과) 해당 유저의 user_id + immutable 필드 중 하나의 조합으로 Expense 테이블에 저장 Case 2 결과) 내가 직접 생성한 카테고리 중 하나로 카테고리를 설정하여 Expense 테이블에 저장 @@ -91,7 +94,7 @@ else if (!category.getIsDefault() && category.getUser().getId().equals(userId)) @Transactional public void deleteExpense(Long expenseId) { Expense expense = expenseRepository.findById(expenseId) - .orElseThrow(() -> new IllegalArgumentException("Not found Expense")); + .orElseThrow(() -> new IllegalArgumentException("Not found Expense")); Long userId = expense.getUser().getId(); Long categoryId = expense.getCategory().getId(); @@ -113,45 +116,42 @@ public void deleteExpense(Long expenseId) { @Transactional(readOnly = true) public MonthlyExpenseResponseDto getMonthlyExpense(Long userId, LocalDate localDate) { LocalDate startOfMonth = localDate.withDayOfMonth(1); - LocalDate endOfMonth = localDate.withDayOfMonth(startOfMonth.lengthOfMonth()); + LocalDate nextMonth = startOfMonth.plusMonths(1L); - User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("Invalid user ID")); - - List expenseSlice = expenseRepository.findAllByUserIdForPeriod(user, - startOfMonth.atStartOfDay(), endOfMonth.atTime(LocalTime.MAX)); + List expenseSlice = expenseRepository.findAllByUserIdForPeriod(userId, + startOfMonth.atStartOfDay(), nextMonth.atStartOfDay()); return expenseConverter.toMonthlyExpenseResponseDto(expenseSlice, startOfMonth); } @Override - public ExpenseResponseDto findExpenseResponseFromUserIdAndExpenseId(Long userId, Long expenseId) { - Expense expense = expenseRepository.findById(expenseId) - .orElseThrow(() -> new IllegalArgumentException("Not found expense")); - - checkUserAuthority(userId, expense); - - return expenseConverter.toExpenseResponseDto(expense); + public DetailExpenseResponseDto findDetailExpenseResponse(Long userId, Long expenseId) { + return expenseConverter.toDetailExpenseResponseDto(getExpense(expenseId)); } - private void checkUserAuthority(Long userId, Expense expense) { - if (!expense.getUser().getId().equals(userId)) - throw new IllegalArgumentException("Unauthorized user"); + private Expense getExpense(Long expenseId) { + return expenseRepository.findById(expenseId) + .orElseThrow(() -> new IllegalArgumentException("Not found expense")); } @Override @Transactional - public ExpenseResponseDto updateExpense(Long userId, ExpenseUpdateRequestDto request) { - Expense expense = expenseRepository.findById(request.getExpenseId()) - .orElseThrow(() -> new IllegalArgumentException("Not found expense")); + public DetailExpenseResponseDto updateExpense(Long userId, ExpenseUpdateRequestDto request) { + User user = userService.getUser(userId); + Expense expense = getExpense(request.getExpenseId()); + Category categoryToReplace = categoryService.getCategory(request.getCategoryId()); - User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("Not found user")); - checkUserAuthority(userId, expense); + expense.updateExpenseFromRequest(request, categoryToReplace); - Category categoryToReplace = categoryService.handleCategoryChange(expense, request, user); + ConsumptionGoal beforeConsumptionGoal = consumptionGoalService.getUserConsumptionGoal( + user, expense.getCategory(), expense.getExpenseDate().toLocalDate().withDayOfMonth(1)); + ConsumptionGoal afterConsumptionGoal = consumptionGoalService.getUserConsumptionGoal( + user, categoryToReplace, request.getExpenseDate().toLocalDate().withDayOfMonth(1)); - expense.updateExpenseFromRequest(request, categoryToReplace); + consumptionGoalService.recalculateConsumptionAmount(beforeConsumptionGoal, expense.getAmount(), + afterConsumptionGoal, request.getAmount()); - return expenseConverter.toExpenseResponseDto(expenseRepository.save(expense)); + return expenseConverter.toDetailExpenseResponseDto(expenseRepository.save(expense)); } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/controller/FaqApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/controller/FaqApi.java new file mode 100644 index 00000000..1f2d5fb7 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/controller/FaqApi.java @@ -0,0 +1,118 @@ +package com.bbteam.budgetbuddies.domain.faq.controller; + + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.faq.dto.FaqRequestDto; +import com.bbteam.budgetbuddies.domain.faq.validation.ExistFaq; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +public interface FaqApi { + @Operation(summary = "[User] FAQ 게시 API", description = "FAQ를 게시하는 API입니다.", + requestBody = @RequestBody( + content = @Content( + schema = @Schema( + allOf = { FaqRequestDto.FaqPostRequest.class}, + requiredProperties = {"title", "body"} + ), + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = { + @ExampleObject(name = "someExample1", value = """ + { + "title" : "FAQ 제목 써주시면 됩니다", + "body" : "FAQ 내용 써주시면 됩니다." + } + """) + } + ) + ) + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + ApiResponse postFaq(@AuthUser UserDto.AuthUserDto userDto, + @Valid @org.springframework.web.bind.annotation.RequestBody FaqRequestDto.FaqPostRequest dto); + @Operation(summary = "[Admin] FAQ 조회 API", description = "FAQ를 조회하는 API입니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "faqId", description = "조회할 공지 id입니다. pathVariable", in = ParameterIn.PATH), + } + ) + ApiResponse findFaq(@ExistFaq Long FaqId); + @Operation(summary = "[Admin] FAQ 다건 조회 API", description = "FAQ를 페이징으로 조회하는 API입니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "page", description = "조회할 page입니다. 0부터 시작합니다. pathVariable", in = ParameterIn.QUERY), + @Parameter(name = "size", description = "조회할 페이지의 크기입니다. pathVariable", in = ParameterIn.QUERY) + + } + ) + ApiResponse findByPaging(Pageable pageable, String SearchCondition); + + @Operation(summary = "[User] FAQ 수정 API", description = "FAQ를 수정하는 API입니다.", + requestBody = @RequestBody( + content = @Content( + schema = @Schema( + allOf = { FaqRequestDto.FaqPostRequest.class}, + requiredProperties = {"title", "body"} + ), + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = { + @ExampleObject(name = "someExample1", value = """ + { + "title" : "FAQ 제목 써주시면 됩니다", + "body" : "FAQ 내용 써주시면 됩니다." + } + """) + } + ) + ) + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "faqId", description = "수정할 FAQ id입니다. pathVariable", in = ParameterIn.PATH), + } + ) + ApiResponse modifyFaq(@PathVariable @ExistFaq Long faqId, + @Valid @org.springframework.web.bind.annotation.RequestBody FaqRequestDto.FaqModifyRequest dto); + + @Operation(summary = "[Admin] FAQ 삭제 API", description = "FAQ를 삭제하는 API입니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "faqId", description = "삭제할 faqId입니다.. pathVariable", in = ParameterIn.PATH), + + } + ) + ApiResponse deleteFaq(@ExistFaq Long faqId); + + ApiResponse addKeyword(@ExistFaq Long faqId, Long searchKeywordId); + + ApiResponse deleteKeyword(@ExistFaq Long faqId, Long searchKeywordId); + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/controller/FaqController.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/controller/FaqController.java new file mode 100644 index 00000000..5c0b1a1b --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/controller/FaqController.java @@ -0,0 +1,72 @@ +package com.bbteam.budgetbuddies.domain.faq.controller; + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.faq.dto.FaqRequestDto; +import com.bbteam.budgetbuddies.domain.faq.dto.FaqResponseDto; +import com.bbteam.budgetbuddies.domain.faq.service.FaqService; +import com.bbteam.budgetbuddies.domain.faq.validation.ExistFaq; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.lang.Nullable; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Validated +@RestController +@RequestMapping("/faqs") +@RequiredArgsConstructor +public class FaqController implements FaqApi{ + + private final FaqService faqService; + + @Override + @PostMapping("") + public ApiResponse postFaq(@AuthUser UserDto.AuthUserDto userDto, + @Valid @RequestBody FaqRequestDto.FaqPostRequest dto) { + return ApiResponse.onSuccess(faqService.postFaq(dto, userDto.getId())); + } + + @Override + @GetMapping("/{faqId}") + public ApiResponse findFaq(@PathVariable @ExistFaq Long faqId) { + return ApiResponse.onSuccess(faqService.findOneFaq(faqId)); + } + + @Override + @GetMapping("/all") + public ApiResponse> findByPaging(@ParameterObject Pageable pageable, + @RequestParam @Nullable String searchCondition) { + return ApiResponse.onSuccess(faqService.searchFaq(pageable, searchCondition)); + } + + @Override + @PutMapping("/{faqId}") + public ApiResponse modifyFaq(@PathVariable @ExistFaq Long faqId, + @Valid @RequestBody FaqRequestDto.FaqModifyRequest dto) { + return ApiResponse.onSuccess(faqService.modifyFaq(dto, faqId)); + } + + @Override + @DeleteMapping("/{faqId}") + public ApiResponse deleteFaq(@PathVariable @ExistFaq Long faqId) { + faqService.deleteFaq(faqId); + return ApiResponse.onSuccess("Delete Success"); + } + + @Override + @PostMapping("/{faqId}/keyword") + public ApiResponse addKeyword(@PathVariable @ExistFaq Long faqId, @RequestParam Long searchKeywordId) { + return ApiResponse.onSuccess(faqService.addKeyword(faqId, searchKeywordId)); + } + + @Override + @DeleteMapping("/{faqId}/keyword") + public ApiResponse deleteKeyword(@PathVariable @ExistFaq Long faqId, @RequestParam Long searchKeywordId) { + return ApiResponse.onSuccess(faqService.removeKeyword(faqId, searchKeywordId)); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/converter/FaqConverter.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/converter/FaqConverter.java new file mode 100644 index 00000000..d011fe60 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/converter/FaqConverter.java @@ -0,0 +1,46 @@ +package com.bbteam.budgetbuddies.domain.faq.converter; + +import com.bbteam.budgetbuddies.domain.faq.dto.FaqRequestDto; +import com.bbteam.budgetbuddies.domain.faq.dto.FaqResponseDto; +import com.bbteam.budgetbuddies.domain.faq.entity.Faq; +import com.bbteam.budgetbuddies.domain.user.entity.User; + +public class FaqConverter { + + public static Faq postToEntity(FaqRequestDto.FaqPostRequest dto, User user) { + return Faq.builder() + .title(dto.getTitle()) + .body(dto.getBody()) + .user(user) + .build(); + } + + public static FaqResponseDto.FaqPostResponse entityToPost(Faq faq) { + return FaqResponseDto.FaqPostResponse.builder() + .title(faq.getTitle()) + .body(faq.getBody()) + .faqId(faq.getId()) + .username(faq.getUser().getName()) + .build(); + } + + public static FaqResponseDto.FaqModifyResponse entityToModify(Faq faq) { + return FaqResponseDto.FaqModifyResponse.builder() + .title(faq.getTitle()) + .body(faq.getBody()) + .faqId(faq.getId()) + .username(faq.getUser().getName()) + .build(); + } + + public static FaqResponseDto.FaqFindResponse entityToFind(Faq faq) { + return FaqResponseDto.FaqFindResponse.builder() + .title(faq.getTitle()) + .body(faq.getBody()) + .username(faq.getUser().getName()) + .faqId(faq.getId()) + .userId(faq.getUser().getId()) + .build(); + } + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/dto/FaqRequestDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/dto/FaqRequestDto.java new file mode 100644 index 00000000..a485e46f --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/dto/FaqRequestDto.java @@ -0,0 +1,29 @@ +package com.bbteam.budgetbuddies.domain.faq.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +public class FaqRequestDto { + + @Getter + @Builder + @AllArgsConstructor + public static class FaqPostRequest { + @NotBlank + private String title; + @NotBlank + private String body; + } + + @Getter + @Builder + @AllArgsConstructor + public static class FaqModifyRequest { + @NotBlank + private String title; + @NotBlank + private String body; + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/dto/FaqResponseDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/dto/FaqResponseDto.java new file mode 100644 index 00000000..3de30f64 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/dto/FaqResponseDto.java @@ -0,0 +1,38 @@ +package com.bbteam.budgetbuddies.domain.faq.dto; + +import lombok.Builder; +import lombok.Getter; + +public class FaqResponseDto { + + // User + @Getter + @Builder + public static class FaqPostResponse { + private Long faqId; + private String username; + private String title; + private String body; + } + + //User + @Getter + @Builder + public static class FaqModifyResponse { + private Long faqId; + private String username; + private String title; + private String body; + } + + //Admin + @Getter + @Builder + public static class FaqFindResponse { + private Long faqId; + private Long userId; + private String username; + private String title; + private String body; + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/entity/Faq.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/entity/Faq.java new file mode 100644 index 00000000..604f0fb1 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/entity/Faq.java @@ -0,0 +1,37 @@ +package com.bbteam.budgetbuddies.domain.faq.entity; + +import com.bbteam.budgetbuddies.common.BaseEntity; +import com.bbteam.budgetbuddies.domain.faq.dto.FaqRequestDto; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; + + +@Entity +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class Faq extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + @NotFound(action = NotFoundAction.IGNORE) + private User user; + + private String title; + + private String body; + + public void update(FaqRequestDto.FaqModifyRequest dto) { + title = dto.getTitle(); + body = dto.getBody(); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqRepository.java new file mode 100644 index 00000000..d9c6cf4f --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqRepository.java @@ -0,0 +1,9 @@ +package com.bbteam.budgetbuddies.domain.faq.repository; + +import com.bbteam.budgetbuddies.domain.faq.entity.Faq; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FaqRepository extends JpaRepository, FaqSearchRepository { + + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqSearchRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqSearchRepository.java new file mode 100644 index 00000000..b54ab5be --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqSearchRepository.java @@ -0,0 +1,10 @@ +package com.bbteam.budgetbuddies.domain.faq.repository; + +import com.bbteam.budgetbuddies.domain.faq.entity.Faq; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface FaqSearchRepository { + + Page searchFaq(Pageable pageable, String searchCondition); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqSearchRepositoryImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqSearchRepositoryImpl.java new file mode 100644 index 00000000..1e3116ab --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqSearchRepositoryImpl.java @@ -0,0 +1,61 @@ +package com.bbteam.budgetbuddies.domain.faq.repository; + +import com.bbteam.budgetbuddies.domain.faq.entity.Faq; +import com.bbteam.budgetbuddies.domain.searchkeyword.domain.QSearchKeyword; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static com.bbteam.budgetbuddies.domain.faq.entity.QFaq.*; +import static com.bbteam.budgetbuddies.domain.faqkeyword.domain.QFaqKeyword.*; +import static com.bbteam.budgetbuddies.domain.searchkeyword.domain.QSearchKeyword.*; + +public class FaqSearchRepositoryImpl implements FaqSearchRepository{ + + private final JPAQueryFactory queryFactory; + + public FaqSearchRepositoryImpl(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + @Override + public Page searchFaq(Pageable pageable, String searchCondition) { + List result = queryFactory.select(faq) + .from(faq) + .where(faq.id.in( + JPAExpressions + .select(faqKeyword.faq.id) + .from(faqKeyword) + .join(searchKeyword).on(keywordMatch(searchCondition)) + )) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(faq.count()) + .from(faq) + .where(faq.id.in( + JPAExpressions + .select(faqKeyword.faq.id) + .from(faqKeyword) + .join(searchKeyword).on(keywordMatch(searchCondition)) + )) + .fetchOne(); + + return new PageImpl<>(result, pageable, total); + + } + + private BooleanExpression keywordMatch(String searchCondition) { + return searchCondition != null ? searchKeyword.keyword.contains(searchCondition) : null; + } + +} + diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/service/FaqService.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/service/FaqService.java new file mode 100644 index 00000000..f334dac5 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/service/FaqService.java @@ -0,0 +1,26 @@ +package com.bbteam.budgetbuddies.domain.faq.service; + +import com.bbteam.budgetbuddies.domain.faq.dto.FaqRequestDto; +import com.bbteam.budgetbuddies.domain.faq.dto.FaqResponseDto; +import com.bbteam.budgetbuddies.domain.faqkeyword.dto.FaqKeywordResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface FaqService { + + FaqResponseDto.FaqFindResponse findOneFaq(Long faqId); + + Page findAllWithPaging(Pageable pageable); + + Page searchFaq(Pageable pageable, String searchCondition); + + FaqResponseDto.FaqPostResponse postFaq(FaqRequestDto.FaqPostRequest dto, Long userId); + + FaqResponseDto.FaqModifyResponse modifyFaq(FaqRequestDto.FaqModifyRequest dto, Long faqId); + + String deleteFaq(Long faqId); + + FaqKeywordResponseDto addKeyword(Long faqId, Long searchKeywordId); + + String removeKeyword(Long faqId, Long searchKeywordId); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/service/FaqServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/service/FaqServiceImpl.java new file mode 100644 index 00000000..6b732095 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/service/FaqServiceImpl.java @@ -0,0 +1,104 @@ +package com.bbteam.budgetbuddies.domain.faq.service; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.apiPayload.exception.GeneralException; +import com.bbteam.budgetbuddies.domain.faq.converter.FaqConverter; +import com.bbteam.budgetbuddies.domain.faq.dto.FaqRequestDto; +import com.bbteam.budgetbuddies.domain.faq.dto.FaqResponseDto; +import com.bbteam.budgetbuddies.domain.faq.entity.Faq; +import com.bbteam.budgetbuddies.domain.faq.repository.FaqRepository; +import com.bbteam.budgetbuddies.domain.faqkeyword.domain.FaqKeyword; +import com.bbteam.budgetbuddies.domain.faqkeyword.dto.FaqKeywordResponseDto; +import com.bbteam.budgetbuddies.domain.faqkeyword.repository.FaqKeywordRepository; +import com.bbteam.budgetbuddies.domain.searchkeyword.domain.SearchKeyword; +import com.bbteam.budgetbuddies.domain.searchkeyword.repository.SearchKeywordRepository; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FaqServiceImpl implements FaqService{ + + private final FaqRepository faqRepository; + private final UserRepository userRepository; + private final FaqKeywordRepository faqKeywordRepository; + private final SearchKeywordRepository searchKeywordRepository; + + @Override + public FaqResponseDto.FaqFindResponse findOneFaq(Long faqId) { + Faq faq = findFaq(faqId); + return FaqConverter.entityToFind(faq); + } + + @Override + public Page findAllWithPaging(Pageable pageable) { + return faqRepository.findAll(pageable).map(FaqConverter::entityToFind); + } + + @Override + @Transactional + public FaqResponseDto.FaqPostResponse postFaq(FaqRequestDto.FaqPostRequest dto, Long userId) { + User user = userRepository.findById(userId).orElseThrow(() -> new GeneralException(ErrorStatus._USER_NOT_FOUND)); + Faq faq = FaqConverter.postToEntity(dto, user); + faqRepository.save(faq); + return FaqConverter.entityToPost(faq); + } + + @Override + @Transactional + public FaqResponseDto.FaqModifyResponse modifyFaq(FaqRequestDto.FaqModifyRequest dto, Long faqId) { + Faq faq = findFaq(faqId); + faq.update(dto); + return FaqConverter.entityToModify(faq); + } + + @Override + @Transactional + public String deleteFaq(Long faqId) { + Faq faq = findFaq(faqId); + faqRepository.delete(faq); + return "ok!"; + } + + private Faq findFaq(Long faqId) { + return faqRepository.findById(faqId).orElseThrow(() -> new GeneralException(ErrorStatus._FAQ_NOT_FOUND)); + } + + @Override + public Page searchFaq(Pageable pageable, String searchCondition) { + return faqRepository.searchFaq(pageable, searchCondition).map(FaqConverter::entityToFind); + } + + @Override + @Transactional + public FaqKeywordResponseDto addKeyword(Long faqId, Long searchKeywordId) { + Faq faq = findFaq(faqId); + SearchKeyword searchKeyword = searchKeywordRepository.findById(searchKeywordId).orElseThrow(() -> new GeneralException(ErrorStatus._SEARCH_KEYWORD_NOT_FOUND)); + + FaqKeyword faqKeyword = FaqKeyword.builder() + .searchKeyword(searchKeyword) + .faq(faq) + .build(); + + faqKeywordRepository.save(faqKeyword); + return FaqKeywordResponseDto.toDto(faqKeyword); + } + + @Override + @Transactional + public String removeKeyword(Long faqId, Long searchKeywordId) { + Faq faq = findFaq(faqId); + SearchKeyword searchKeyword = searchKeywordRepository.findById(searchKeywordId).orElseThrow(() -> new GeneralException(ErrorStatus._SEARCH_KEYWORD_NOT_FOUND)); + + FaqKeyword faqKeyword = faqKeywordRepository.findByFaqAndSearchKeyword(faq, searchKeyword).orElseThrow(() -> new GeneralException(ErrorStatus._FAQ_KEYWORD_NOT_FOUND)); + faqKeywordRepository.delete(faqKeyword); + + return "ok"; + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/validation/ExistFaq.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/validation/ExistFaq.java new file mode 100644 index 00000000..feba4d2d --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/validation/ExistFaq.java @@ -0,0 +1,18 @@ +package com.bbteam.budgetbuddies.domain.faq.validation; + +import com.bbteam.budgetbuddies.domain.notice.validation.NoticeExistValidation; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = FaqExistValidation.class) +@Target( { ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExistFaq { + + String message() default "해당 FAQ가 존재하지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faq/validation/FaqExistValidation.java b/src/main/java/com/bbteam/budgetbuddies/domain/faq/validation/FaqExistValidation.java new file mode 100644 index 00000000..3b9490e9 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faq/validation/FaqExistValidation.java @@ -0,0 +1,35 @@ +package com.bbteam.budgetbuddies.domain.faq.validation; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.domain.faq.entity.Faq; +import com.bbteam.budgetbuddies.domain.faq.repository.FaqRepository; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +@RequiredArgsConstructor +public class FaqExistValidation implements ConstraintValidator { + + private final FaqRepository faqRepository; + @Override + public boolean isValid(Long faqId, ConstraintValidatorContext constraintValidatorContext) { + + Optional faq = faqRepository.findById(faqId); + + if(faq.isEmpty()) { + constraintValidatorContext.disableDefaultConstraintViolation(); + constraintValidatorContext.buildConstraintViolationWithTemplate(ErrorStatus._FAQ_NOT_FOUND.toString()).addConstraintViolation(); + return false; + } + + return true; + + } + + @Override + public void initialize(ExistFaq constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faqkeyword/domain/FaqKeyword.java b/src/main/java/com/bbteam/budgetbuddies/domain/faqkeyword/domain/FaqKeyword.java new file mode 100644 index 00000000..6f5e9f14 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faqkeyword/domain/FaqKeyword.java @@ -0,0 +1,32 @@ +package com.bbteam.budgetbuddies.domain.faqkeyword.domain; + +import com.bbteam.budgetbuddies.common.BaseEntity; +import com.bbteam.budgetbuddies.domain.faq.entity.Faq; +import com.bbteam.budgetbuddies.domain.searchkeyword.domain.SearchKeyword; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class FaqKeyword extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "faq_id") + @NotFound(action = NotFoundAction.IGNORE) + private Faq faq; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "search_keyword_id") + @NotFound(action = NotFoundAction.IGNORE) + private SearchKeyword searchKeyword; + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faqkeyword/dto/FaqKeywordResponseDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/faqkeyword/dto/FaqKeywordResponseDto.java new file mode 100644 index 00000000..da6ef042 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faqkeyword/dto/FaqKeywordResponseDto.java @@ -0,0 +1,21 @@ +package com.bbteam.budgetbuddies.domain.faqkeyword.dto; + +import com.bbteam.budgetbuddies.domain.faqkeyword.domain.FaqKeyword; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class FaqKeywordResponseDto { + + private Long faqId; + private Long searchKeywordId; + + private String faqTitle; + private String keyword; + + public static FaqKeywordResponseDto toDto(FaqKeyword faqKeyword) { + return new FaqKeywordResponseDto(faqKeyword.getFaq().getId(), faqKeyword.getSearchKeyword().getId(), + faqKeyword.getFaq().getTitle(), faqKeyword.getSearchKeyword().getKeyword()); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/faqkeyword/repository/FaqKeywordRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/faqkeyword/repository/FaqKeywordRepository.java new file mode 100644 index 00000000..c0606ba0 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/faqkeyword/repository/FaqKeywordRepository.java @@ -0,0 +1,12 @@ +package com.bbteam.budgetbuddies.domain.faqkeyword.repository; + +import com.bbteam.budgetbuddies.domain.faq.entity.Faq; +import com.bbteam.budgetbuddies.domain.faqkeyword.domain.FaqKeyword; +import com.bbteam.budgetbuddies.domain.searchkeyword.domain.SearchKeyword; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface FaqKeywordRepository extends JpaRepository { + Optional findByFaqAndSearchKeyword(Faq faq, SearchKeyword searchKeyword); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/controller/FavoriteHashtagApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/controller/FavoriteHashtagApi.java new file mode 100644 index 00000000..40011d83 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/controller/FavoriteHashtagApi.java @@ -0,0 +1,27 @@ +package com.bbteam.budgetbuddies.domain.favoritehashtag.controller; + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.favoritehashtag.dto.FavoriteHashtagResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +public interface FavoriteHashtagApi { + + @Operation(summary = "[User] 해당되는 해시태그를 설정한 유저 조회 API", description = "특정 할인정보 또는 지원정보에 등록된 해시태그를 설정한 유저를 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "discountInfoId", description = "조회할 할인정보 ID", required = false), + @Parameter(name = "supportInfoId", description = "조회할 지원정보 ID", required = false), + }) + ApiResponse> getUsersByHashtags( + @RequestParam(required = false) Long discountInfoId, + @RequestParam(required = false) Long supportInfoId + ); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/controller/FavoriteHashtagController.java b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/controller/FavoriteHashtagController.java new file mode 100644 index 00000000..7e567845 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/controller/FavoriteHashtagController.java @@ -0,0 +1,27 @@ +package com.bbteam.budgetbuddies.domain.favoritehashtag.controller; + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.favoritehashtag.dto.FavoriteHashtagResponseDto; +import com.bbteam.budgetbuddies.domain.favoritehashtag.service.FavoriteHashtagService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/favoriteHashtags") +public class FavoriteHashtagController implements FavoriteHashtagApi { + + private final FavoriteHashtagService favoriteHashtagService; + + @Override + @GetMapping("/applicable-users") + public ApiResponse> getUsersByHashtags( + @RequestParam(required = false) Long discountInfoId, + @RequestParam(required = false) Long supportInfoId + ) { + List users = favoriteHashtagService.findUsersByHashtag(discountInfoId, supportInfoId); + return ApiResponse.onSuccess(users); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/dto/FavoriteHashtagResponseDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/dto/FavoriteHashtagResponseDto.java new file mode 100644 index 00000000..4eec9293 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/dto/FavoriteHashtagResponseDto.java @@ -0,0 +1,16 @@ +package com.bbteam.budgetbuddies.domain.favoritehashtag.dto; + +import com.bbteam.budgetbuddies.domain.user.entity.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor + +public class FavoriteHashtagResponseDto { + private Long userId; + + public FavoriteHashtagResponseDto(User user) { + this.userId = user.getId(); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/entity/FavoriteHashtag.java b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/entity/FavoriteHashtag.java new file mode 100644 index 00000000..0fbbbd81 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/entity/FavoriteHashtag.java @@ -0,0 +1,38 @@ +package com.bbteam.budgetbuddies.domain.favoritehashtag.entity; + +import com.bbteam.budgetbuddies.common.BaseEntity; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class FavoriteHashtag extends BaseEntity { + /** + * 특정 사용자가 관심있는 해시태그를 나타내는 엔티티 (교차 엔티티) + */ + + @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) + @JoinColumn(name = "hashtag_id") + private Hashtag hashtag; + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/repository/FavoriteHashtagRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/repository/FavoriteHashtagRepository.java new file mode 100644 index 00000000..1d7dd47f --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/repository/FavoriteHashtagRepository.java @@ -0,0 +1,13 @@ +package com.bbteam.budgetbuddies.domain.favoritehashtag.repository; + +import com.bbteam.budgetbuddies.domain.favoritehashtag.entity.FavoriteHashtag; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface FavoriteHashtagRepository extends JpaRepository { + List findByUser(User user); + + List findByHashtagIdIn(List hashtagIds); +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/service/FavoriteHashtagService.java b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/service/FavoriteHashtagService.java new file mode 100644 index 00000000..1945a5e7 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/service/FavoriteHashtagService.java @@ -0,0 +1,12 @@ +package com.bbteam.budgetbuddies.domain.favoritehashtag.service; + +import com.bbteam.budgetbuddies.domain.favoritehashtag.dto.FavoriteHashtagResponseDto; + +import java.util.List; + +public interface FavoriteHashtagService { + List getUsersForHashtag(Long discountInfoId, Long supportInfoId); + + List findUsersByHashtag(Long discountInfoId, Long supportInfoId); + +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/service/FavoriteHashtagServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/service/FavoriteHashtagServiceImpl.java new file mode 100644 index 00000000..8f336b80 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/favoritehashtag/service/FavoriteHashtagServiceImpl.java @@ -0,0 +1,76 @@ +package com.bbteam.budgetbuddies.domain.favoritehashtag.service; + +import com.bbteam.budgetbuddies.domain.connectedinfo.entity.ConnectedInfo; +import com.bbteam.budgetbuddies.domain.connectedinfo.repository.ConnectedInfoRepository; +import com.bbteam.budgetbuddies.domain.discountinfo.entity.DiscountInfo; +import com.bbteam.budgetbuddies.domain.favoritehashtag.dto.FavoriteHashtagResponseDto; +import com.bbteam.budgetbuddies.domain.favoritehashtag.entity.FavoriteHashtag; +import com.bbteam.budgetbuddies.domain.favoritehashtag.repository.FavoriteHashtagRepository; +import com.bbteam.budgetbuddies.domain.supportinfo.entity.SupportInfo; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FavoriteHashtagServiceImpl implements FavoriteHashtagService { + + private final ConnectedInfoRepository connectedInfoRepository; + private final FavoriteHashtagRepository favoriteHashtagRepository; + private final UserRepository userRepository; + + @Override + public List getUsersForHashtag(Long discountInfoId, Long supportInfoId) { + List connectedInfos; + + if (discountInfoId != null) { + DiscountInfo discountInfo = DiscountInfo.withId(discountInfoId); + connectedInfos = connectedInfoRepository.findAllByDiscountInfo(discountInfo); + } else if (supportInfoId != null) { + SupportInfo supportInfo = SupportInfo.withId(supportInfoId); + connectedInfos = connectedInfoRepository.findAllBySupportInfo(supportInfo); + } else { + throw new IllegalArgumentException("discountInfoId 또는 supportInfoId 중 하나는 필수입니다."); + } + + List hashtagIds = connectedInfos.stream() + .map(connectedInfo -> connectedInfo.getHashtag().getId()) + .collect(Collectors.toList()); + System.out.println("Connected Hashtags IDs: " + hashtagIds); + + + List favoriteHashtags = favoriteHashtagRepository.findByHashtagIdIn(hashtagIds); + System.out.println("Favorite Hashtags: " + favoriteHashtags); + + return favoriteHashtags.stream() + .map(favoriteHashtag -> favoriteHashtag.getUser().getId()) + .distinct() + .collect(Collectors.toList()); + } + + @Override + public List findUsersByHashtag(Long discountInfoId, Long supportInfoId) { + List userIds = getUsersForHashtag(discountInfoId, supportInfoId); + + return userIds.stream() + .map(userId -> { + Optional optionalUser = userRepository.findById(userId); + optionalUser.ifPresent(user -> System.out.println("User found: " + user)); // 여기에 추가 + return optionalUser.map(FavoriteHashtagResponseDto::new) + .orElseGet(() -> { + System.out.println("User not found with id: " + userId); + return null; + }); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/controller/HashtagApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/controller/HashtagApi.java new file mode 100644 index 00000000..adcb2e92 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/controller/HashtagApi.java @@ -0,0 +1,75 @@ +package com.bbteam.budgetbuddies.domain.hashtag.controller; + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagRequest; +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +public interface HashtagApi { + + @Operation(summary = "[User] 모든 해시태그 리스트 가져오기 API", description = "해시태그 목록을 조회하는 API입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ApiResponse> getAllHashtag(); + + @Operation(summary = "[ADMIN] 해시태그 등록하기 API", description = "해시태그를 등록하는 API이며, 관리자만 접근 가능합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ApiResponse registerHashtag( + @RequestBody HashtagRequest.RegisterHashtagDto registerHashtagDto + ); + + @Operation(summary = "[ADMIN] 특정 해시태그 수정하기 API", description = "특정 해시태그를 수정하는 API입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ApiResponse updateHashtag( + @RequestBody HashtagRequest.UpdateHashtagDto updateHashtagDto + ); + + @Operation(summary = "[ADMIN] 특정 해시태그 삭제하기 API", description = "ID를 통해 특정 해시태그를 삭제하는 API입니다. 해시태그 삭제 시 연관된 지원정보 및 할인정보에 표시되지 않습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @Parameters({ + @Parameter(name = "hashtagId", description = "삭제할 해시태그의 id입니다."), + }) + ApiResponse deleteHashtag( + @PathVariable Long hashtagId + ); + + @Operation(summary = "[ADMIN] 특정 해시태그 가져오기 API", description = "ID를 통해 특정 해시태그를 가져오는 API입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), +// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @Parameters({ + @Parameter(name = "hashtagId", description = "조회할 해시태그의 id입니다."), + }) + ApiResponse getHashtag( + @PathVariable Long hashtagId + ); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/controller/HashtagController.java b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/controller/HashtagController.java new file mode 100644 index 00000000..48a3808f --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/controller/HashtagController.java @@ -0,0 +1,69 @@ +package com.bbteam.budgetbuddies.domain.hashtag.controller; + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagRequest; +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagResponse; +import com.bbteam.budgetbuddies.domain.hashtag.service.HashtagService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/hashtags") +public class HashtagController implements HashtagApi { + + private final HashtagService hashtagService; + + @Override + @GetMapping("/find-all") + public ApiResponse> getAllHashtag() { + + List hashtagResponses = hashtagService.getAllHashtag(); + + return ApiResponse.onSuccess(hashtagResponses); + } + + @Override + @PostMapping("") + public ApiResponse registerHashtag( + @RequestBody HashtagRequest.RegisterHashtagDto registerHashtagDto + ) { + + HashtagResponse hashtagResponse = hashtagService.registerHashtag(registerHashtagDto); + + return ApiResponse.onSuccess(hashtagResponse); + } + + @Override + @PutMapping("") + public ApiResponse updateHashtag( + @RequestBody HashtagRequest.UpdateHashtagDto updateHashtagDto + ) { + + HashtagResponse hashtagResponse = hashtagService.updateHashtag(updateHashtagDto); + + return ApiResponse.onSuccess(hashtagResponse); + } + + @Override + @DeleteMapping("/{hashtagId}") + public ApiResponse deleteHashtag( + @PathVariable Long hashtagId + ) { + String message = hashtagService.deleteHashtag(hashtagId); + + return ApiResponse.onSuccess(message); + } + + @Override + @GetMapping("/{hashtagId}") + public ApiResponse getHashtag( + @PathVariable Long hashtagId + ) { + HashtagResponse hashtagResponse = hashtagService.getHashtagById(hashtagId); + + return ApiResponse.onSuccess(hashtagResponse); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/converter/HashtagConverter.java b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/converter/HashtagConverter.java new file mode 100644 index 00000000..881c9685 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/converter/HashtagConverter.java @@ -0,0 +1,37 @@ +package com.bbteam.budgetbuddies.domain.hashtag.converter; + +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagRequest; +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagResponse; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; +import org.springframework.stereotype.Service; + +@Service +public class HashtagConverter { + + /** + * @param entity + * @return responseDto + */ + + public HashtagResponse toDto(Hashtag hashtag) { + + return HashtagResponse.builder() + .id(hashtag.getId()) + .name(hashtag.getName()) + .build(); + + } + + /** + * @param requestDto + * @return entity + */ + public Hashtag toEntity(HashtagRequest.RegisterHashtagDto registerHashtagDto) { + + return Hashtag.builder() + .name(registerHashtagDto.getName()) + .build(); + + } + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/dto/HashtagRequest.java b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/dto/HashtagRequest.java new file mode 100644 index 00000000..d76dfc51 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/dto/HashtagRequest.java @@ -0,0 +1,34 @@ +package com.bbteam.budgetbuddies.domain.hashtag.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +public class HashtagRequest { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RegisterHashtagDto { + + private String name; + + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class UpdateHashtagDto { + + private Long id; + + private String name; + + } + + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/dto/HashtagResponse.java b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/dto/HashtagResponse.java new file mode 100644 index 00000000..59ef52ac --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/dto/HashtagResponse.java @@ -0,0 +1,18 @@ +package com.bbteam.budgetbuddies.domain.hashtag.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HashtagResponse { + + private Long id; + + private String name; + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/entity/Hashtag.java b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/entity/Hashtag.java new file mode 100644 index 00000000..228acc68 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/entity/Hashtag.java @@ -0,0 +1,23 @@ +package com.bbteam.budgetbuddies.domain.hashtag.entity; + +import com.bbteam.budgetbuddies.common.BaseEntity; +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagRequest; +import jakarta.persistence.Entity; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Entity +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class Hashtag extends BaseEntity { + + private String name; // 해시태그 이름 + + public void update(HashtagRequest.UpdateHashtagDto updateHashtagDto) { + this.name = updateHashtagDto.getName(); + } + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/repository/HashtagRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/repository/HashtagRepository.java new file mode 100644 index 00000000..0faa0a22 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/repository/HashtagRepository.java @@ -0,0 +1,13 @@ +package com.bbteam.budgetbuddies.domain.hashtag.repository; + +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface HashtagRepository extends JpaRepository { + + // 해시태그 아이디를 기반으로 해시태그 엔티티 가져오기 + List findByIdIn(List hashtagIds); + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/service/HashtagService.java b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/service/HashtagService.java new file mode 100644 index 00000000..17467698 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/service/HashtagService.java @@ -0,0 +1,21 @@ +package com.bbteam.budgetbuddies.domain.hashtag.service; + + +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagRequest; +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagResponse; + +import java.util.List; + +public interface HashtagService { + + List getAllHashtag(); + + HashtagResponse registerHashtag(HashtagRequest.RegisterHashtagDto registerHashtagDto); + + HashtagResponse updateHashtag(HashtagRequest.UpdateHashtagDto updateHashtagDto); + + String deleteHashtag(Long hashtagId); + + HashtagResponse getHashtagById(Long hashtagId); + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/service/HashtagServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/service/HashtagServiceImpl.java new file mode 100644 index 00000000..c7b0367e --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/hashtag/service/HashtagServiceImpl.java @@ -0,0 +1,94 @@ +package com.bbteam.budgetbuddies.domain.hashtag.service; + +import com.bbteam.budgetbuddies.domain.hashtag.converter.HashtagConverter; +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagRequest; +import com.bbteam.budgetbuddies.domain.hashtag.dto.HashtagResponse; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; +import com.bbteam.budgetbuddies.domain.hashtag.repository.HashtagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class HashtagServiceImpl implements HashtagService { + + private final HashtagRepository hashtagRepository; + + private final HashtagConverter hashtagConverter; + + @Transactional + @Override + public List getAllHashtag() { + /** + * 모든 해시태그를 찾아 리스트로 리턴합니다. + */ + + List hashtags = hashtagRepository.findAll(); + + return hashtags.stream() + .map(hashtagConverter::toDto) + .toList(); + } + + @Transactional + @Override + public HashtagResponse registerHashtag(HashtagRequest.RegisterHashtagDto registerHashtagDto) { + /** + * 해시태그를 등록합니다. + */ + + Hashtag hashtag = hashtagConverter.toEntity(registerHashtagDto); + + hashtagRepository.save(hashtag); + + return hashtagConverter.toDto(hashtag); + } + + @Transactional + @Override + public HashtagResponse updateHashtag(HashtagRequest.UpdateHashtagDto updateHashtagDto) { + /** + * 해시태그 이름을 수정합니다. + */ + + Hashtag hashtag = hashtagRepository.findById(updateHashtagDto.getId()) + .orElseThrow(() -> new IllegalArgumentException("Hashtag not found")); + + hashtag.update(updateHashtagDto); + + hashtagRepository.save(hashtag); + + return hashtagConverter.toDto(hashtag); + } + + @Transactional + @Override + public String deleteHashtag(Long hashtagId) { + /** + * 해시태그를 삭제합니다. + */ + + Hashtag hashtag = hashtagRepository.findById(hashtagId) + .orElseThrow(() -> new IllegalArgumentException("Hashtag not found")); + + hashtagRepository.deleteById(hashtagId); + + return "Success"; + } + + @Transactional + @Override + public HashtagResponse getHashtagById(Long hashtagId) { + /** + * 특정 해시태그를 하나 조회합니다. + */ + + Hashtag hashtag = hashtagRepository.findById(hashtagId) + .orElseThrow(() -> new IllegalArgumentException("Hashtag not found")); + + return hashtagConverter.toDto(hashtag); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/mainpage/controller/MainPageApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/mainpage/controller/MainPageApi.java index 757722f8..9caf9f26 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/mainpage/controller/MainPageApi.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/mainpage/controller/MainPageApi.java @@ -2,13 +2,10 @@ import com.bbteam.budgetbuddies.apiPayload.ApiResponse; import com.bbteam.budgetbuddies.domain.mainpage.dto.MainPageResponseDto; -import com.bbteam.budgetbuddies.domain.user.validation.ExistUser; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestParam; public interface MainPageApi { @@ -16,8 +13,5 @@ public interface MainPageApi { @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), }) - @Parameters({ - @Parameter(name = "userId", description = "현재 데이터를 요청하는 사용자입니다. parameter"), - }) - ApiResponse getMainPage(@RequestParam("userId") @ExistUser Long userId); + ApiResponse getMainPage(@AuthUser UserDto.AuthUserDto userDto); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/mainpage/controller/MainPageController.java b/src/main/java/com/bbteam/budgetbuddies/domain/mainpage/controller/MainPageController.java index 534bb66a..1be20827 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/mainpage/controller/MainPageController.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/mainpage/controller/MainPageController.java @@ -3,13 +3,11 @@ import com.bbteam.budgetbuddies.apiPayload.ApiResponse; import com.bbteam.budgetbuddies.domain.mainpage.dto.MainPageResponseDto; import com.bbteam.budgetbuddies.domain.mainpage.service.MainPageService; -import com.bbteam.budgetbuddies.domain.user.validation.ExistUser; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -21,8 +19,8 @@ public class MainPageController implements MainPageApi{ @GetMapping("/main") public ApiResponse getMainPage - (@RequestParam("userId") @ExistUser Long userId) { - MainPageResponseDto mainPage = mainPageService.getMainPage(userId); + (@AuthUser UserDto.AuthUserDto userDto) { + MainPageResponseDto mainPage = mainPageService.getMainPage(userDto.getId()); return ApiResponse.onSuccess(mainPage); } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/controller/NoticeApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/controller/NoticeApi.java new file mode 100644 index 00000000..6c0ca7af --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/controller/NoticeApi.java @@ -0,0 +1,109 @@ +package com.bbteam.budgetbuddies.domain.notice.controller; + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeRequestDto; +import com.bbteam.budgetbuddies.domain.notice.validation.ExistNotice; +import com.bbteam.budgetbuddies.domain.user.validation.ExistUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; + +public interface NoticeApi { + + @Operation(summary = "[Admin] 공지 저장 API", description = "공지를 저장하는 API입니다.", + requestBody = @RequestBody( + content = @Content( + schema = @Schema( + allOf = { NoticeRequestDto.class}, + requiredProperties = {"title", "body"} + ), + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = { + @ExampleObject(name = "someExample1", value = """ + { + "title" : "공지 제목 써주시면 됩니다", + "body" : "공지 내용 써주시면 됩니다." + } + """) + } + ) + ) + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "userId", description = "현재 데이터를 게시하는 어드민입니다. parameter"), + } + ) + ApiResponse saveNotice(@ExistUser Long userId, NoticeRequestDto dto); + + @Operation(summary = "[Admin] 공지 전체 확인 API", description = "공지 전체를 확인하는 API입니다. 페이징 포함합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "size", description = "페이징 size입니다. parameter"), + @Parameter(name = "page", description = "페이징 page입니다. parameter") + } + ) + ApiResponse findAllWithPaging(Pageable pageable); + + @Operation(summary = "[Admin] 공지 확인 API", description = "공지를 확인하는 API입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "noticeId", description = "확인할 공지 id입니다. pathVariable", in = ParameterIn.PATH), + } + ) + ApiResponse findOne(@ExistNotice Long noticeId); + + @Operation(summary = "[Admin] 공지 수정 API", description = "공지를 수정하는 API입니다.", + requestBody = @RequestBody( + content = @Content( + schema = @Schema( + allOf = { NoticeRequestDto.class}, + requiredProperties = {"title", "body"} + ), + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = { + @ExampleObject(name = "someExample1", value = """ + { + "title" : "공지 제목 써주시면 됩니다", + "body" : "공지 내용 써주시면 됩니다." + } + """) + } + ) + ) + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "noticeId", description = "수정할 공지 id입니다. pathVariable", in = ParameterIn.PATH), + } + ) + ApiResponse modifyNotice(@ExistNotice Long noticeId, NoticeRequestDto noticeRequestDto); + + @Operation(summary = "[Admin] 공지 삭제 API", description = "공지를 삭제하는 API입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "noticeId", description = "삭제할 공지 id입니다. pathVariable", in = ParameterIn.PATH), + } + ) + ApiResponse deleteNotice(@ExistNotice Long noticeId); + + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/controller/NoticeController.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/controller/NoticeController.java new file mode 100644 index 00000000..d756b9fa --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/controller/NoticeController.java @@ -0,0 +1,59 @@ +package com.bbteam.budgetbuddies.domain.notice.controller; + + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeRequestDto; +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeResponseDto; +import com.bbteam.budgetbuddies.domain.notice.service.NoticeService; +import com.bbteam.budgetbuddies.domain.notice.validation.ExistNotice; +import com.bbteam.budgetbuddies.domain.user.validation.ExistUser; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@Validated +@RequestMapping("/notices") +public class NoticeController implements NoticeApi{ + + private final NoticeService noticeService; + + @Override + @PostMapping + public ApiResponse saveNotice(@ExistUser @RequestParam Long userId, @RequestBody NoticeRequestDto dto) { + return ApiResponse.onSuccess(noticeService.save(dto, userId)); + } + + @Override + @GetMapping("/all") + public ApiResponse> findAllWithPaging( + @ParameterObject @PageableDefault(page = 0, size = 20, + sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + return ApiResponse.onSuccess(noticeService.findAll(pageable)); + } + + @Override + @GetMapping("/{noticeId}") + public ApiResponse findOne(@ExistNotice @PathVariable Long noticeId) { + return ApiResponse.onSuccess(noticeService.findOne(noticeId)); + } + + @Override + @PutMapping("/{noticeId}") + public ApiResponse modifyNotice(@ExistNotice @PathVariable Long noticeId, @RequestBody NoticeRequestDto noticeRequestDto) { + return ApiResponse.onSuccess(noticeService.update(noticeId, noticeRequestDto)); + } + + @Override + @DeleteMapping("/{noticeId}") + public ApiResponse deleteNotice(@ExistNotice @PathVariable Long noticeId) { + noticeService.delete(noticeId); + return ApiResponse.onSuccess("ok"); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/converter/NoticeConverter.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/converter/NoticeConverter.java new file mode 100644 index 00000000..229f2507 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/converter/NoticeConverter.java @@ -0,0 +1,27 @@ +package com.bbteam.budgetbuddies.domain.notice.converter; + +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeRequestDto; +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeResponseDto; +import com.bbteam.budgetbuddies.domain.notice.entity.Notice; +import com.bbteam.budgetbuddies.domain.user.entity.User; + +public class NoticeConverter { + + public static Notice toEntity(NoticeRequestDto dto, User user) { + return Notice.builder() + .title(dto.getTitle()) + .body(dto.getBody()) + .user(user) + .build(); + } + + public static NoticeResponseDto toDto(Notice notice) { + return NoticeResponseDto.builder() + .noticeId(notice.getId()) + .title(notice.getTitle()) + .body(notice.getBody()) + .userName(notice.getUser().getName()) + .createdAt(notice.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/dto/NoticeRequestDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/dto/NoticeRequestDto.java new file mode 100644 index 00000000..777350e4 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/dto/NoticeRequestDto.java @@ -0,0 +1,13 @@ +package com.bbteam.budgetbuddies.domain.notice.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Builder +@Getter +public class NoticeRequestDto { + private String title; + private String body; +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/dto/NoticeResponseDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/dto/NoticeResponseDto.java new file mode 100644 index 00000000..5eb72ad9 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/dto/NoticeResponseDto.java @@ -0,0 +1,18 @@ +package com.bbteam.budgetbuddies.domain.notice.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Builder +@Getter +public class NoticeResponseDto { + private Long noticeId; + private String userName; + private String title; + private String body; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm", timezone = "Asia/Seoul") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/entity/Notice.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/entity/Notice.java new file mode 100644 index 00000000..cced216c --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/entity/Notice.java @@ -0,0 +1,33 @@ +package com.bbteam.budgetbuddies.domain.notice.entity; + +import com.bbteam.budgetbuddies.common.BaseEntity; +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeRequestDto; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class Notice extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) + @JoinColumn(name = "user_id") + private User user; + + private String title; + + private String body; + + public void update(NoticeRequestDto dto) { + title = dto.getTitle(); + body = dto.getBody(); + } + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/repository/NoticeRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/repository/NoticeRepository.java new file mode 100644 index 00000000..b888df14 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/repository/NoticeRepository.java @@ -0,0 +1,10 @@ +package com.bbteam.budgetbuddies.domain.notice.repository; + +import com.bbteam.budgetbuddies.domain.notice.entity.Notice; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NoticeRepository extends JpaRepository { + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/service/NoticeService.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/service/NoticeService.java new file mode 100644 index 00000000..c6949cd5 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/service/NoticeService.java @@ -0,0 +1,20 @@ +package com.bbteam.budgetbuddies.domain.notice.service; + +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeRequestDto; +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface NoticeService { + NoticeResponseDto save(NoticeRequestDto dto, Long userId); + + NoticeResponseDto findOne(Long noticeId); + + Page findAll(Pageable pageable); + + NoticeResponseDto update(Long noticeId, NoticeRequestDto dto); + + void delete(Long noticeId); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/service/NoticeServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/service/NoticeServiceImpl.java new file mode 100644 index 00000000..f7a15878 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/service/NoticeServiceImpl.java @@ -0,0 +1,68 @@ +package com.bbteam.budgetbuddies.domain.notice.service; + +import com.bbteam.budgetbuddies.domain.notice.converter.NoticeConverter; +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeRequestDto; +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeResponseDto; +import com.bbteam.budgetbuddies.domain.notice.entity.Notice; +import com.bbteam.budgetbuddies.domain.notice.repository.NoticeRepository; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.NoSuchElementException; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class NoticeServiceImpl implements NoticeService{ + + private final NoticeRepository noticeRepository; + private final UserRepository userRepository; + @Override + @Transactional + public NoticeResponseDto save(NoticeRequestDto dto, Long userId) { + User user = userRepository.findById(userId).orElseThrow(() -> new NoSuchElementException("member x")); + Notice notice = NoticeConverter.toEntity(dto, user); + noticeRepository.save(notice); + return NoticeConverter.toDto(notice); + } + + @Override + public NoticeResponseDto findOne(Long noticeId) { + Notice notice = findNotice(noticeId); + return NoticeConverter.toDto(notice); + + } + + @Override + public Page findAll(Pageable pageable) { + return noticeRepository.findAll(pageable) + .map(NoticeConverter::toDto); + } + + @Override + @Transactional + public NoticeResponseDto update(Long noticeId, NoticeRequestDto dto) { + Notice notice = findNotice(noticeId); + notice.update(dto); + return NoticeConverter.toDto(notice); + } + + @Override + @Transactional + public void delete(Long noticeId) { + Notice notice = findNotice(noticeId); + noticeRepository.delete(notice); + } + private Notice findNotice(Long noticeId) { + Notice notice = noticeRepository.findById(noticeId).orElseThrow(() -> new NoSuchElementException("notice x")); + return notice; + } + + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/validation/ExistNotice.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/validation/ExistNotice.java new file mode 100644 index 00000000..2c95245f --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/validation/ExistNotice.java @@ -0,0 +1,17 @@ +package com.bbteam.budgetbuddies.domain.notice.validation; + +import com.bbteam.budgetbuddies.domain.user.validation.UserExistValidation; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = NoticeExistValidation.class) +@Target( { ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExistNotice { + String message() default "해당 Notice가 존재하지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notice/validation/NoticeExistValidation.java b/src/main/java/com/bbteam/budgetbuddies/domain/notice/validation/NoticeExistValidation.java new file mode 100644 index 00000000..018807ad --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notice/validation/NoticeExistValidation.java @@ -0,0 +1,33 @@ +package com.bbteam.budgetbuddies.domain.notice.validation; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.domain.notice.entity.Notice; +import com.bbteam.budgetbuddies.domain.notice.repository.NoticeRepository; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +@RequiredArgsConstructor +public class NoticeExistValidation implements ConstraintValidator { + + private final NoticeRepository noticeRepository; + @Override + public boolean isValid(Long aLong, ConstraintValidatorContext constraintValidatorContext) { + Optional notice = noticeRepository.findById(aLong); + + if(notice.isEmpty()) { + constraintValidatorContext.disableDefaultConstraintViolation(); + constraintValidatorContext.buildConstraintViolationWithTemplate(ErrorStatus._NOTICE_NOT_FOUND.toString()).addConstraintViolation(); + return false; + } + + return true; + } + + @Override + public void initialize(ExistNotice constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notification/entity/Notification.java b/src/main/java/com/bbteam/budgetbuddies/domain/notification/entity/Notification.java new file mode 100644 index 00000000..f491d7be --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notification/entity/Notification.java @@ -0,0 +1,32 @@ +package com.bbteam.budgetbuddies.domain.notification.entity; + +import com.bbteam.budgetbuddies.common.BaseEntity; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.enums.NotiType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Entity +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class Notification extends BaseEntity { + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "user_id") + private User user; + + private String message; // 알림 메시지 내용 + + private Boolean readOrNot; // 읽기 여부 + + private NotiType type; // 알림 타입 (소비경고/정보/시스템/커뮤니티) + + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/notification/repository/NotificationRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..9bf5ec39 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,9 @@ +package com.bbteam.budgetbuddies.domain.notification.repository; + +import com.bbteam.budgetbuddies.domain.notification.entity.Notification; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository { + + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/openai/controller/OpenAiController.java b/src/main/java/com/bbteam/budgetbuddies/domain/openai/controller/OpenAiController.java new file mode 100644 index 00000000..40ed89cd --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/openai/controller/OpenAiController.java @@ -0,0 +1,33 @@ +package com.bbteam.budgetbuddies.domain.openai.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +import com.bbteam.budgetbuddies.domain.openai.dto.ChatGPTRequest; +import com.bbteam.budgetbuddies.domain.openai.dto.ChatGPTResponse; + +@RestController +@RequestMapping("/bot") +public class OpenAiController { + @Value("${spring.openai.model}") + private String model; + + @Value("${spring.openai.api.url}") + private String apiURL; + + @Autowired + private RestTemplate template; + + @GetMapping("/chat") + public String chat(@RequestParam(name = "prompt") String prompt) { + ChatGPTRequest request = new ChatGPTRequest(model, prompt); + ChatGPTResponse chatGPTResponse = template.postForObject(apiURL, request, ChatGPTResponse.class); + return chatGPTResponse.getChoices().get(0).getMessage().getContent(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/openai/dto/ChatGPTRequest.java b/src/main/java/com/bbteam/budgetbuddies/domain/openai/dto/ChatGPTRequest.java new file mode 100644 index 00000000..d1ee13be --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/openai/dto/ChatGPTRequest.java @@ -0,0 +1,19 @@ +package com.bbteam.budgetbuddies.domain.openai.dto; + + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class ChatGPTRequest { + private String model; + private List messages; + + public ChatGPTRequest(String model, String prompt) { + this.model = model; + this.messages = new ArrayList<>(); + this.messages.add(new Message("user", prompt)); + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/openai/dto/ChatGPTResponse.java b/src/main/java/com/bbteam/budgetbuddies/domain/openai/dto/ChatGPTResponse.java new file mode 100644 index 00000000..1124c67e --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/openai/dto/ChatGPTResponse.java @@ -0,0 +1,23 @@ +package com.bbteam.budgetbuddies.domain.openai.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatGPTResponse { + private List choices; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Choice { + private int index; + private Message message; + + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/openai/dto/Message.java b/src/main/java/com/bbteam/budgetbuddies/domain/openai/dto/Message.java new file mode 100644 index 00000000..778b23b7 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/openai/dto/Message.java @@ -0,0 +1,15 @@ +package com.bbteam.budgetbuddies.domain.openai.dto; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Message { + private String role; + private String content; + +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/openai/service/OpenAiService.java b/src/main/java/com/bbteam/budgetbuddies/domain/openai/service/OpenAiService.java new file mode 100644 index 00000000..869784c9 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/openai/service/OpenAiService.java @@ -0,0 +1,28 @@ +package com.bbteam.budgetbuddies.domain.openai.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.bbteam.budgetbuddies.domain.openai.dto.ChatGPTRequest; +import com.bbteam.budgetbuddies.domain.openai.dto.ChatGPTResponse; + +@Service +public class OpenAiService { + + @Value("${spring.openai.model}") + private String model; + + @Value("${spring.openai.api.url}") + private String apiURL; + + @Autowired + private RestTemplate template; + + public String chat(String prompt) { + ChatGPTRequest request = new ChatGPTRequest(model, prompt); + ChatGPTResponse chatGPTResponse = template.postForObject(apiURL, request, ChatGPTResponse.class); + return chatGPTResponse.getChoices().get(0).getMessage().getContent(); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/report/controller/ReportApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/report/controller/ReportApi.java new file mode 100644 index 00000000..158b20fe --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/report/controller/ReportApi.java @@ -0,0 +1,39 @@ +package com.bbteam.budgetbuddies.domain.report.controller; + +import org.springframework.data.repository.query.Param; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.report.dto.request.ReportRequestDto; +import com.bbteam.budgetbuddies.domain.report.dto.response.ReportResponseDto; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; + +public interface ReportApi { + @Operation(summary = "[User] 댓글에대한 신고여부 조회", description = "존재하지 않을 경우에만 신고 뷰로 이동") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON404", description = "NOTFOUND, 실패") + }) + @Parameters({ + @Parameter(name = "commentId", description = "신고할 댓글 아이디"), + @Parameter(name = "userId", description = "로그인 한 유저 아이디") + }) + ApiResponse isExistReport(Long commentId, Long userId); + + @Operation(summary = "[User] 댓글에대한 신고") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + @Parameters({ + @Parameter(name = "commentId", description = "신고할 댓글 아이디"), + @Parameter(name = "userId", description = "로그인 한 유저 아이디") + }) + ApiResponse reportComment(@Valid @RequestBody ReportRequestDto request, + @PathVariable @Param("commentId") Long commentId, @PathVariable @Param("userId") Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/report/controller/ReportController.java b/src/main/java/com/bbteam/budgetbuddies/domain/report/controller/ReportController.java new file mode 100644 index 00000000..a53e6632 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/report/controller/ReportController.java @@ -0,0 +1,43 @@ +package com.bbteam.budgetbuddies.domain.report.controller; + +import org.springframework.data.repository.query.Param; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +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.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.report.dto.request.ReportRequestDto; +import com.bbteam.budgetbuddies.domain.report.dto.response.ReportResponseDto; +import com.bbteam.budgetbuddies.domain.report.service.ReportService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/{commentId}/report") +@RequiredArgsConstructor +public class ReportController implements ReportApi { + private final ReportService reportService; + + @GetMapping("/{userId}") + public ApiResponse isExistReport(@PathVariable @Param("commentId") Long commentId, + @PathVariable @Param("userId") Long userId) { + if (reportService.isExistReport(commentId, userId)) { + return ApiResponse.onSuccess("신고 이력이 존재합니다."); + } + return ApiResponse.onFailure(HttpStatus.NOT_FOUND.toString(), HttpStatus.NOT_FOUND.getReasonPhrase(), + "신고 이력이 존재하지 않습니다."); + } + + @PostMapping("/{userId}") + public ApiResponse reportComment(@Valid @RequestBody ReportRequestDto request, + @PathVariable @Param("commentId") Long commentId, @PathVariable @Param("userId") Long userId) { + + ReportResponseDto response = reportService.reportComment(request, commentId, userId); + return ApiResponse.onSuccess(response); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/report/convertor/ReportConvertor.java b/src/main/java/com/bbteam/budgetbuddies/domain/report/convertor/ReportConvertor.java new file mode 100644 index 00000000..1fa2fa58 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/report/convertor/ReportConvertor.java @@ -0,0 +1,24 @@ +package com.bbteam.budgetbuddies.domain.report.convertor; + +import org.springframework.stereotype.Component; + +import com.bbteam.budgetbuddies.domain.comment.entity.Comment; +import com.bbteam.budgetbuddies.domain.report.dto.request.ReportRequestDto; +import com.bbteam.budgetbuddies.domain.report.dto.response.ReportResponseDto; +import com.bbteam.budgetbuddies.domain.report.entity.Report; +import com.bbteam.budgetbuddies.domain.user.entity.User; + +@Component +public class ReportConvertor { + public Report toEntity(ReportRequestDto request, User user, Comment comment) { + return Report.builder().user(user).comment(comment).reason(request.getReason()).build(); + } + + public ReportResponseDto toReportResponse(Report report) { + return ReportResponseDto.builder() + .reportId(report.getId()) + .userId(report.getUser().getId()) + .commentId(report.getComment().getId()) + .build(); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/report/dto/request/ReportRequestDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/report/dto/request/ReportRequestDto.java new file mode 100644 index 00000000..66f5474b --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/report/dto/request/ReportRequestDto.java @@ -0,0 +1,12 @@ +package com.bbteam.budgetbuddies.domain.report.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +public class ReportRequestDto { + @NotBlank + private String reason; +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/report/dto/response/ReportResponseDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/report/dto/response/ReportResponseDto.java new file mode 100644 index 00000000..c5893e37 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/report/dto/response/ReportResponseDto.java @@ -0,0 +1,17 @@ +package com.bbteam.budgetbuddies.domain.report.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +@Builder +@Getter +public class ReportResponseDto { + private Long reportId; + private Long userId; + private Long commentId; +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/report/entity/Report.java b/src/main/java/com/bbteam/budgetbuddies/domain/report/entity/Report.java new file mode 100644 index 00000000..5b05f6a7 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/report/entity/Report.java @@ -0,0 +1,39 @@ +package com.bbteam.budgetbuddies.domain.report.entity; + +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; + +import com.bbteam.budgetbuddies.common.BaseEntity; +import com.bbteam.budgetbuddies.domain.comment.entity.Comment; +import com.bbteam.budgetbuddies.domain.user.entity.User; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Report extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @NotFound(action = NotFoundAction.IGNORE) + @JoinColumn(name = "comment_id") + private Comment comment; + + @NonNull + private String reason; +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/report/repository/ReportRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/report/repository/ReportRepository.java new file mode 100644 index 00000000..30196ae1 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/report/repository/ReportRepository.java @@ -0,0 +1,11 @@ +package com.bbteam.budgetbuddies.domain.report.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.bbteam.budgetbuddies.domain.comment.entity.Comment; +import com.bbteam.budgetbuddies.domain.report.entity.Report; +import com.bbteam.budgetbuddies.domain.user.entity.User; + +public interface ReportRepository extends JpaRepository { + boolean existsByUser_IdAndComment_Id(Long userId, Long CommentId); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/report/service/ReportService.java b/src/main/java/com/bbteam/budgetbuddies/domain/report/service/ReportService.java new file mode 100644 index 00000000..6335c055 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/report/service/ReportService.java @@ -0,0 +1,23 @@ +package com.bbteam.budgetbuddies.domain.report.service; + +import com.bbteam.budgetbuddies.domain.report.dto.request.ReportRequestDto; +import com.bbteam.budgetbuddies.domain.report.dto.response.ReportResponseDto; + +public interface ReportService { + /** + * 댓글에 대한 신고이력 여부 조회 + * @param commentId + * @param userId + * @return 존재하면 true 존재하지 않으면 false + */ + boolean isExistReport(Long commentId, Long userId); + + /** + * 댓글에 대한 신고 생성 + * @param request + * @param commentId + * @param userId + * @return reportID를 포함한 신고정보 + */ + ReportResponseDto reportComment(ReportRequestDto request, Long commentId, Long userId); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/report/service/ReportServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/report/service/ReportServiceImpl.java new file mode 100644 index 00000000..62839644 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/report/service/ReportServiceImpl.java @@ -0,0 +1,45 @@ +package com.bbteam.budgetbuddies.domain.report.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.bbteam.budgetbuddies.domain.comment.entity.Comment; +import com.bbteam.budgetbuddies.domain.comment.repository.CommentRepository; +import com.bbteam.budgetbuddies.domain.report.convertor.ReportConvertor; +import com.bbteam.budgetbuddies.domain.report.dto.request.ReportRequestDto; +import com.bbteam.budgetbuddies.domain.report.dto.response.ReportResponseDto; +import com.bbteam.budgetbuddies.domain.report.entity.Report; +import com.bbteam.budgetbuddies.domain.report.repository.ReportRepository; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReportServiceImpl implements ReportService { + private final ReportRepository reportRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + + private final ReportConvertor reportConvertor; + + @Override + @Transactional(readOnly = true) + public boolean isExistReport(Long commentId, Long userId) { + return reportRepository.existsByUser_IdAndComment_Id(userId, commentId); + } + + @Override + @Transactional + public ReportResponseDto reportComment(ReportRequestDto request, Long commentId, Long userId) { + User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("not found user")); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("not found comment")); + + Report report = reportConvertor.toEntity(request, user, comment); + Report savedReport = reportRepository.save(report); + + return reportConvertor.toReportResponse(savedReport); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/controller/SearchKeywordController.java b/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/controller/SearchKeywordController.java new file mode 100644 index 00000000..45786bbe --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/controller/SearchKeywordController.java @@ -0,0 +1,48 @@ +package com.bbteam.budgetbuddies.domain.searchkeyword.controller; + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.searchkeyword.domain.SearchKeyword; +import com.bbteam.budgetbuddies.domain.searchkeyword.service.SearchKeywordService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/search-keyword") +public class SearchKeywordController { + + private final SearchKeywordService searchKeywordService; + + @PostMapping("") + public ApiResponse saveKeyword(String keyword) { + + return ApiResponse.onSuccess(searchKeywordService.saveKeyword(keyword)); + } + + @GetMapping("") + public ApiResponse findOne(Long searchKeywordId) { + return ApiResponse.onSuccess(searchKeywordService.findOne(searchKeywordId)); + } + + @GetMapping("/all") + public ApiResponse> findAll(Pageable pageable) { + return ApiResponse.onSuccess(searchKeywordService.findAll(pageable)); + } + + @PutMapping("") + public ApiResponse modifyOne(Long searchKeywordId, String newKeyword) { + return ApiResponse.onSuccess((searchKeywordService.modifyOne(searchKeywordId, newKeyword))); + } + + @DeleteMapping("") + public ApiResponse deleteOne(Long searchKeywordId) { + searchKeywordService.deleteOne(searchKeywordId); + return ApiResponse.onSuccess("OK"); + } + + + + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/domain/SearchKeyword.java b/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/domain/SearchKeyword.java new file mode 100644 index 00000000..970d72f4 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/domain/SearchKeyword.java @@ -0,0 +1,23 @@ +package com.bbteam.budgetbuddies.domain.searchkeyword.domain; + +import com.bbteam.budgetbuddies.common.BaseEntity; +import jakarta.persistence.Entity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class SearchKeyword extends BaseEntity { + + private String keyword; + + public void changeKeyword(String newKeyword) { + this.keyword = newKeyword; + } + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/repository/SearchKeywordRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/repository/SearchKeywordRepository.java new file mode 100644 index 00000000..1fcea8b7 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/repository/SearchKeywordRepository.java @@ -0,0 +1,7 @@ +package com.bbteam.budgetbuddies.domain.searchkeyword.repository; + +import com.bbteam.budgetbuddies.domain.searchkeyword.domain.SearchKeyword; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SearchKeywordRepository extends JpaRepository { +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/service/SearchKeywordService.java b/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/service/SearchKeywordService.java new file mode 100644 index 00000000..b960855d --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/domain/searchkeyword/service/SearchKeywordService.java @@ -0,0 +1,44 @@ +package com.bbteam.budgetbuddies.domain.searchkeyword.service; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.apiPayload.exception.GeneralException; +import com.bbteam.budgetbuddies.domain.searchkeyword.domain.SearchKeyword; +import com.bbteam.budgetbuddies.domain.searchkeyword.repository.SearchKeywordRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class SearchKeywordService { + + private final SearchKeywordRepository searchKeywordRepository; + + public SearchKeyword saveKeyword(String keyword) { + SearchKeyword searchKeyword = SearchKeyword.builder().keyword(keyword).build(); + searchKeywordRepository.save(searchKeyword); + return searchKeyword; + } + + public SearchKeyword findOne(Long searchKeywordId) { + return searchKeywordRepository.findById(searchKeywordId).orElseThrow(() -> new GeneralException(ErrorStatus._SEARCH_KEYWORD_NOT_FOUND)); + } + + public Page findAll(Pageable pageable) { + return searchKeywordRepository.findAll(pageable); + } + + public SearchKeyword modifyOne(Long searchKeywordId, String keyword) { + SearchKeyword searchKeyword = searchKeywordRepository.findById(searchKeywordId).orElseThrow(() -> new GeneralException(ErrorStatus._SEARCH_KEYWORD_NOT_FOUND)); + searchKeyword.changeKeyword(keyword); + return searchKeyword; + } + + public void deleteOne(Long searchKeywordId) { + SearchKeyword searchKeyword = searchKeywordRepository.findById(searchKeywordId).orElseThrow(() -> new GeneralException(ErrorStatus._SEARCH_KEYWORD_NOT_FOUND)); + searchKeywordRepository.delete(searchKeyword); + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/controller/SupportInfoApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/controller/SupportInfoApi.java index 2e87c495..b92e9257 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/controller/SupportInfoApi.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/controller/SupportInfoApi.java @@ -1,12 +1,18 @@ package com.bbteam.budgetbuddies.domain.supportinfo.controller; import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.apiPayload.code.ErrorReasonDto; +import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountResponseDto; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportRequest; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportResponseDto; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; import com.bbteam.budgetbuddies.domain.user.validation.ExistUser; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.PathVariable; @@ -17,10 +23,15 @@ public interface SupportInfoApi { @Operation(summary = "[User] 특정 년월 지원정보 리스트 가져오기 API", description = "특정 년도와 월에 해당하는 지원정보 목록을 조회하는 API이며, 페이징을 포함합니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PAGE4001", description = "요청된 페이지가 0보다 작습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) + }) @Parameters({ @Parameter(name = "year", description = "데이터를 가져올 연도입니다."), @@ -37,10 +48,13 @@ ApiResponse> getSupportsByYearAndMonth( @Operation(summary = "[ADMIN] 지원정보 등록하기 API", description = "지원정보를 등록하는 API이며, 추후에는 관리자만 접근 가능하도록 할 예정입니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) ApiResponse registerSupportInfo( @RequestBody SupportRequest.RegisterSupportDto requestDto @@ -48,26 +62,33 @@ ApiResponse registerSupportInfo( @Operation(summary = "[User] 특정 지원정보에 좋아요 클릭 API", description = "특정 지원정보에 좋아요 버튼을 클릭하는 API이며, 일단은 사용자 ID를 입력하여 사용합니다. (추후 토큰으로 검증)") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) @Parameters({ @Parameter(name = "userId", description = "좋아요를 누른 사용자의 id입니다."), @Parameter(name = "supportInfoId", description = "좋아요를 누를 지원정보의 id입니다."), }) ApiResponse likeSupportInfo( - @RequestParam @ExistUser Long userId, + @AuthUser UserDto.AuthUserDto user, @PathVariable Long supportInfoId ); @Operation(summary = "[ADMIN] 특정 지원정보 수정하기 API", description = "ID를 통해 특정 지원정보를 수정하는 API입니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PAGE4001", description = "요청된 페이지가 0보다 작습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) @Parameters({ }) @@ -77,10 +98,13 @@ ApiResponse updateSupportInfo( @Operation(summary = "[ADMIN] 특정 지원정보 삭제하기 API", description = "ID를 통해 특정 지원정보를 삭제하는 API입니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) @Parameters({ @Parameter(name = "supportInfoId", description = "삭제할 지원 정보의 id입니다."), @@ -91,10 +115,14 @@ ApiResponse deleteSupportInfo( @Operation(summary = "[ADMIN] 특정 지원정보 가져오기 API", description = "ID를 통해 특정 지원정보를 가져오는 API입니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "access 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "access 토큰 모양이 이상함", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PAGE4001", description = "요청된 페이지가 0보다 작습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) }) @Parameters({ @Parameter(name = "supportInfoId", description = "조회할 지원 정보의 id입니다."), @@ -102,4 +130,26 @@ ApiResponse deleteSupportInfo( ApiResponse getSupportInfo( @PathVariable Long supportInfoId ); + + @Operation(summary = "[ADMIN] 특정 사용자가 좋아요를 누른 지원정보 가져오기 API", description = "특정 사용자가 좋아요를 누른 지원정보들을 가져오는 API입니다. 페이징을 포함합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PAGE4001", description = "요청된 페이지가 0보다 작습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON5000", description = "서버 에러. 관리자에게 문의하세요.", content = @Content(schema = @Schema(implementation = ErrorReasonDto.class))) + }) + @Parameters({ + @Parameter(name = "userId", description = "특정 사용자의 id입니다."), + @Parameter(name = "page", description = "페이지 번호, 0번이 1 페이지 입니다. (기본값은 0입니다.)"), + @Parameter(name = "size", description = "한 페이지에 불러올 데이터 개수입니다. (기본값은 10개입니다.)") + }) + ApiResponse> getLikedSupportInfo( + @AuthUser UserDto.AuthUserDto user, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "10") Integer size + ); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/controller/SupportInfoController.java b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/controller/SupportInfoController.java index bbac6fb9..ca04c776 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/controller/SupportInfoController.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/controller/SupportInfoController.java @@ -1,10 +1,13 @@ package com.bbteam.budgetbuddies.domain.supportinfo.controller; import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountResponseDto; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportRequest; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportResponseDto; import com.bbteam.budgetbuddies.domain.supportinfo.service.SupportInfoService; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; import com.bbteam.budgetbuddies.domain.user.validation.ExistUser; +import com.bbteam.budgetbuddies.global.security.utils.AuthUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.*; @@ -42,10 +45,10 @@ public ApiResponse registerSupportInfo( @Override @PostMapping("/likes/{supportInfoId}") public ApiResponse likeSupportInfo( - @RequestParam @ExistUser Long userId, + @AuthUser UserDto.AuthUserDto user, @PathVariable Long supportInfoId ) { - SupportResponseDto supportResponseDto = supportInfoService.toggleLike(userId, supportInfoId); + SupportResponseDto supportResponseDto = supportInfoService.toggleLike(user.getId(), supportInfoId); return ApiResponse.onSuccess(supportResponseDto); } @@ -80,4 +83,15 @@ public ApiResponse getSupportInfo( return ApiResponse.onSuccess(supportResponseDto); } + @Override + @GetMapping("/liked-all") + public ApiResponse> getLikedSupportInfo( + @AuthUser UserDto.AuthUserDto user, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "10") Integer size + ) { + Page likedSupportInfoPage = supportInfoService.getLikedSupportInfo(user.getId(), page, size); + + return ApiResponse.onSuccess(likedSupportInfoPage); + } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/converter/SupportInfoConverter.java b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/converter/SupportInfoConverter.java index e064492d..dfd0d38e 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/converter/SupportInfoConverter.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/converter/SupportInfoConverter.java @@ -1,27 +1,50 @@ package com.bbteam.budgetbuddies.domain.supportinfo.converter; +import com.bbteam.budgetbuddies.domain.connectedinfo.entity.ConnectedInfo; +import com.bbteam.budgetbuddies.domain.connectedinfo.repository.ConnectedInfoRepository; +import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportResponseDto; +import com.bbteam.budgetbuddies.domain.supportinfo.entity.SupportInfo; +import com.bbteam.budgetbuddies.domain.supportinfolike.entity.SupportInfoLike; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportRequest; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportResponseDto; import com.bbteam.budgetbuddies.domain.supportinfo.entity.SupportInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + @Service +@RequiredArgsConstructor public class SupportInfoConverter { + + private final ConnectedInfoRepository connectedInfoRepository; + /** * @param entity * @return responseDto */ - public SupportResponseDto toDto(SupportInfo entity) { + public SupportResponseDto toDto(SupportInfo supportInfo, List connectedInfos) { + // 특정 지원정보의 해시태그 가져오기 + List hashtags = connectedInfos.stream() + .filter(connectedInfo -> connectedInfo.getSupportInfo() != null && connectedInfo.getSupportInfo().equals(supportInfo)) // 특정 SupportInfo와 연결된 ConnectedInfo만 필터링 + .map(ConnectedInfo::getHashtag) + .map(Hashtag::getName) + .collect(Collectors.toList()); return SupportResponseDto.builder() - .id(entity.getId()) - .title(entity.getTitle()) - .startDate(entity.getStartDate()) - .endDate(entity.getEndDate()) - .anonymousNumber(entity.getAnonymousNumber()) - .likeCount(entity.getLikeCount()) - .siteUrl(entity.getSiteUrl()) - .thumbnailUrl(entity.getThumbnailUrl()) + .id(supportInfo.getId()) + .title(supportInfo.getTitle()) + .startDate(supportInfo.getStartDate()) + .endDate(supportInfo.getEndDate()) + .anonymousNumber(supportInfo.getAnonymousNumber()) + .likeCount(supportInfo.getLikeCount()) + .siteUrl(supportInfo.getSiteUrl()) + .thumbnailUrl(supportInfo.getThumbnailUrl()) + .hashtags(hashtags) .build(); } @@ -40,7 +63,51 @@ public SupportInfo toEntity(SupportRequest.RegisterSupportDto requestDto) { .likeCount(0) .siteUrl(requestDto.getSiteUrl()) .thumbnailUrl(requestDto.getThumbnailUrl()) - .isInCalendar(requestDto.getIsInCalendar()) + .isInCalendar(requestDto.getIsInCalendar()) .build(); } + + /** + * @param Page + * @return Page + */ + public Page toEntityPage(Page likes) { + return likes.map(like -> { + // 1. SupportInfoLike 객체에서 SupportInfo를 추출하고, null인지 확인 + SupportInfo supportInfo = like.getSupportInfo(); + if (supportInfo == null) { + // supportInfo가 null인 경우 null을 반환 + return null; + } + + // 2. 해당 SupportInfo와 연결된 모든 ConnectedInfo를 connectedInfoRepository를 통해 조회합니다. + List connectedInfos = connectedInfoRepository.findAllBySupportInfo(supportInfo); + if (connectedInfos == null) { + connectedInfos = Collections.emptyList(); // connectedInfos가 null일 경우 빈 리스트로 처리 + } + + // 3. ConnectedInfo 리스트에서 SupportInfo와 연결된 해시태그를 필터링하고, 해시태그의 이름을 추출합니다. + List hashtags = connectedInfos.stream() + // 3-1. SupportInfo와 연결된 ConnectedInfo만 필터링하고, Hashtag가 null이 아닌지 확인 + .filter(connectedInfo -> connectedInfo.getSupportInfo() != null + && connectedInfo.getSupportInfo().equals(supportInfo) + && connectedInfo.getHashtag() != null) + .map(ConnectedInfo::getHashtag) + .map(Hashtag::getName) + .toList(); + + // 4. 추출한 데이터를 기반으로 SupportResponseDto를 생성합니다. + return SupportResponseDto.builder() + .id(supportInfo.getId()) + .title(supportInfo.getTitle()) + .startDate(supportInfo.getStartDate()) + .endDate(supportInfo.getEndDate()) + .anonymousNumber(supportInfo.getAnonymousNumber()) + .likeCount(supportInfo.getLikeCount()) + .siteUrl(supportInfo.getSiteUrl()) + .thumbnailUrl(supportInfo.getThumbnailUrl()) + .hashtags(hashtags) + .build(); + }); + } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/dto/SupportRequest.java b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/dto/SupportRequest.java index 48cca65f..c40fdf18 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/dto/SupportRequest.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/dto/SupportRequest.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDate; +import java.util.List; public class SupportRequest { @@ -28,6 +29,7 @@ public static class RegisterSupportDto { private Boolean isInCalendar; + private List hashtagIds; } @@ -51,6 +53,7 @@ public static class UpdateSupportDto { private Boolean isInCalendar; + private List hashtagIds; } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/dto/SupportResponseDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/dto/SupportResponseDto.java index 5392e54c..327773a1 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/dto/SupportResponseDto.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/dto/SupportResponseDto.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDate; +import java.util.List; @Getter @Builder @@ -29,4 +30,6 @@ public class SupportResponseDto { private String thumbnailUrl; + private List hashtags; + } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/entity/SupportInfo.java b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/entity/SupportInfo.java index 57152c04..bcc21a4d 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/entity/SupportInfo.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/entity/SupportInfo.java @@ -60,5 +60,9 @@ public void update(SupportRequest.UpdateSupportDto supportRequestDto) { this.thumbnailUrl = supportRequestDto.getThumbnailUrl(); this.isInCalendar = supportRequestDto.getIsInCalendar(); } - + public static SupportInfo withId(Long id) { + SupportInfo supportInfo = new SupportInfo(); + supportInfo.setId(id); + return supportInfo; + } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoService.java b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoService.java index c9ef7783..50bfa5b0 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoService.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoService.java @@ -1,5 +1,6 @@ package com.bbteam.budgetbuddies.domain.supportinfo.service; +import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountResponseDto; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportRequest; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportResponseDto; import org.springframework.data.domain.Page; @@ -22,4 +23,10 @@ Page getSupportsByYearAndMonth( SupportResponseDto getSupportInfoById(Long supportInfoId); + Page getLikedSupportInfo( + Long userId, + Integer page, + Integer size + ); + } \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoServiceImpl.java index f5ca3f7e..cf1d72db 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoServiceImpl.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoServiceImpl.java @@ -1,5 +1,11 @@ package com.bbteam.budgetbuddies.domain.supportinfo.service; +import com.bbteam.budgetbuddies.domain.connectedinfo.entity.ConnectedInfo; +import com.bbteam.budgetbuddies.domain.connectedinfo.repository.ConnectedInfoRepository; +import com.bbteam.budgetbuddies.domain.discountinfo.dto.DiscountResponseDto; +import com.bbteam.budgetbuddies.domain.discountinfolike.entity.DiscountInfoLike; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; +import com.bbteam.budgetbuddies.domain.hashtag.repository.HashtagRepository; import com.bbteam.budgetbuddies.domain.supportinfo.converter.SupportInfoConverter; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportRequest; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportResponseDto; @@ -17,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.List; import java.util.Optional; @Service @@ -31,6 +38,9 @@ public class SupportInfoServiceImpl implements SupportInfoService { private final UserRepository userRepository; + private final HashtagRepository hashtagRepository; + + private final ConnectedInfoRepository connectedInfoRepository; @Transactional(readOnly = true) @Override @@ -48,22 +58,37 @@ public Page getSupportsByYearAndMonth(Integer year, Integer Page supportInfoPage = supportInfoRepository.findByDateRange(startDate, endDate, pageable); - return supportInfoPage.map(supportInfoConverter::toDto); + return supportInfoPage.map( + supportInfo -> { + List connectedInfos = connectedInfoRepository.findAllBySupportInfo(supportInfo); + return supportInfoConverter.toDto(supportInfo, connectedInfos); + } + ); } @Transactional @Override - public SupportResponseDto registerSupportInfo(SupportRequest.RegisterSupportDto supportRequest) { + public SupportResponseDto registerSupportInfo(SupportRequest.RegisterSupportDto supportRequestDto) { /** * 1. RequestDto -> Entity로 변환 * 2. Entity 저장 * 3. Entity -> ResponseDto로 변환 후 리턴 */ - SupportInfo entity = supportInfoConverter.toEntity(supportRequest); + SupportInfo entity = supportInfoConverter.toEntity(supportRequestDto); supportInfoRepository.save(entity); - return supportInfoConverter.toDto(entity); + List hashtags = hashtagRepository.findByIdIn(supportRequestDto.getHashtagIds()); + hashtags.forEach(hashtag -> { + ConnectedInfo connectedInfo = ConnectedInfo.builder() + .supportInfo(entity) + .hashtag(hashtag) + .build(); + connectedInfoRepository.save(connectedInfo); + }); + + List connectedInfos = connectedInfoRepository.findAllBySupportInfo(entity); + return supportInfoConverter.toDto(entity, connectedInfos); } @Transactional @@ -112,7 +137,9 @@ public SupportResponseDto toggleLike(Long userId, Long supportInfoId) { SupportInfo savedEntity = supportInfoRepository.save(supportInfo); - return supportInfoConverter.toDto(savedEntity); + List connectedInfos = connectedInfoRepository.findAllBySupportInfo(supportInfo); + + return supportInfoConverter.toDto(savedEntity, connectedInfos); } @Transactional @@ -132,7 +159,9 @@ public SupportResponseDto updateSupportInfo(SupportRequest.UpdateSupportDto supp supportInfoRepository.save(supportInfo); // 변경사항 저장 - return supportInfoConverter.toDto(supportInfo); + List connectedInfos = connectedInfoRepository.findAllBySupportInfo(supportInfo); + + return supportInfoConverter.toDto(supportInfo, connectedInfos); } @Transactional @@ -147,6 +176,9 @@ public String deleteSupportInfo(Long supportInfoId) { SupportInfo supportInfo = supportInfoRepository.findById(supportInfoId) .orElseThrow(() -> new IllegalArgumentException("SupportInfo not found")); + // 연결된 ConnectedInfo 삭제 (일단 삭제되지 않도록 주석 처리) +// connectedInfoRepository.deleteAllBySupportInfo(supportInfo); + supportInfoRepository.deleteById(supportInfoId); return "Success"; @@ -165,6 +197,28 @@ public SupportResponseDto getSupportInfoById(Long supportInfoId) { SupportInfo supportInfo = supportInfoRepository.findById(supportInfoId) .orElseThrow(() -> new IllegalArgumentException("SupportInfo not found")); - return supportInfoConverter.toDto(supportInfo); + List connectedInfos = connectedInfoRepository.findAllBySupportInfo(supportInfo); + + return supportInfoConverter.toDto(supportInfo, connectedInfos); + } + + @Transactional + @Override + public Page getLikedSupportInfo(Long userId, Integer page, Integer size) { + /** + * 1. 페이징 설정: 주어진 page와 size를 사용하여 Pageable 객체를 생성합니다. + * 2. 사용자 조회: userId로 User 엔티티를 조회하며, 존재하지 않을 경우 IllegalArgumentException을 발생시킵니다. + * 3. 좋아요 정보 조회: 조회된 User에 대한 SupportInfoLike 리스트를 업데이트 시간 기준으로 내림차순 정렬하여 가져옵니다. + * 4. ResponseDto로 변환: SupportInfoLike 리스트를 SupportResponseDto 페이지로 변환하여 반환합니다. + */ + + Pageable pageable = PageRequest.of(page, size); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + Page likes = supportInfoLikeRepository.findAllByUserOrderByUpdatedAtDesc(user, pageable); + + return supportInfoConverter.toEntityPage(likes); } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfolike/repository/SupportInfoLikeRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfolike/repository/SupportInfoLikeRepository.java index b2618344..9a97fe5e 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/supportinfolike/repository/SupportInfoLikeRepository.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/supportinfolike/repository/SupportInfoLikeRepository.java @@ -5,6 +5,8 @@ import com.bbteam.budgetbuddies.domain.supportinfo.entity.SupportInfo; import com.bbteam.budgetbuddies.domain.supportinfolike.entity.SupportInfoLike; import com.bbteam.budgetbuddies.domain.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -13,4 +15,7 @@ public interface SupportInfoLikeRepository extends JpaRepository findByUserAndSupportInfo(User user, SupportInfo supportInfo); + // 사용자가 좋아요를 누른 시점을 기준으로 최신순 정렬 + Page findAllByUserOrderByUpdatedAtDesc(User user, Pageable pageable); + } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/user/controller/UserApi.java b/src/main/java/com/bbteam/budgetbuddies/domain/user/controller/UserApi.java index 901f97fb..0922fb8d 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/user/controller/UserApi.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/user/controller/UserApi.java @@ -15,7 +15,7 @@ import java.util.List; public interface UserApi { - @Operation(summary = "[Admin] 기본 카테고리 생성 API ", description = "기본 카테고리가 없는 사용자에게 기본 카테고리를 생성합니다.") + @Operation(summary = "[Admin] 기본 카테고리 생성 API ", description = "(회원 가입시 필수적으로 자동 생성되어야 합니다!)기본 카테고리가 없는 사용자에게 기본 카테고리를 생성합니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), }) @@ -30,13 +30,16 @@ public interface UserApi { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), }) @Parameters({ - @Parameter(name = "phoneNumber", description = "휴대폰 번호. requestBody"), - @Parameter(name = "name", description = "사용자 이름. requestBody"), - @Parameter(name = "age", description = "사용자 나이. requestBody"), - @Parameter(name = "gender", description = "사용자 성별 / MALE, FEMALE, NONE requestBody"), - @Parameter(name = "email", description = "메일 주소. requestBody"), - @Parameter(name = "photoUrl", description = "사진 Url. 아마 사용 x requestBody"), - @Parameter(name = "consumptionPattern", description = "소비 패턴. 아마 사용 x requestBody") + @Parameter(name = "phoneNumber", description = "휴대폰 번호"), + @Parameter(name = "name", description = "사용자 이름"), + @Parameter(name = "age", description = "사용자 나이"), + @Parameter(name = "mobileCarrier", description = "통신사"), + @Parameter(name = "region", description = "지역"), + @Parameter(name = "gender", description = "사용자 성별 / MALE, FEMALE, NONE"), + @Parameter(name = "email", description = "메일 주소"), + @Parameter(name = "hashtagIds", description = "사용자가 선택한 해시태그 ID 리스트(제공되는 리스트 기반으로 Id 매칭 필요(통신사, 지역 포함))") +// @Parameter(name = "photoUrl", description = "사진 Url. 아마 사용 x requestBody"), +// @Parameter(name = "consumptionPattern", description = "소비 패턴. 아마 사용 x requestBody") }) ApiResponse registerUser(@RequestBody UserDto.RegisterUserDto dto); @Operation(summary = "[Admin] User 찾기 ", description = "ID를 기반으로 해당 사용자를 찾습니다.") diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/user/controller/UserController.java b/src/main/java/com/bbteam/budgetbuddies/domain/user/controller/UserController.java index 6d49ba8b..93fad058 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/user/controller/UserController.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/user/controller/UserController.java @@ -28,6 +28,12 @@ public ResponseEntity> createConsumptionGoals( @PostMapping("/register") public ApiResponse registerUser(@RequestBody UserDto.RegisterUserDto dto) { + // 유저 정보 저장 + UserDto.ResponseUserDto savedUser = userService.saveUser(dto); + + // 유저가 선택한 해시태그를 저장 + userService.saveFavoriteHashtags(savedUser.getId(), dto.getHashtagIds()); + return ApiResponse.onSuccess(userService.saveUser(dto)); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/user/converter/UserConverter.java b/src/main/java/com/bbteam/budgetbuddies/domain/user/converter/UserConverter.java index 7de36315..78971dc5 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/user/converter/UserConverter.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/user/converter/UserConverter.java @@ -12,8 +12,10 @@ public static UserDto.ResponseUserDto toDto(User user) { .phoneNumber(user.getPhoneNumber()) .lastLoginAt(user.getLastLoginAt()) .gender(user.getGender()) - .consumptionPattern(user.getConsumptionPattern()) - .photoUrl(user.getPhotoUrl()) + .region(user.getRegion()) + .mobileCarrier(user.getMobileCarrier()) +// .consumptionPattern(user.getConsumptionPattern()) +// .photoUrl(user.getPhotoUrl()) .email(user.getEmail()) .age(user.getAge()) .build(); @@ -25,9 +27,11 @@ public static User toUser(UserDto.RegisterUserDto dto) { .email(dto.getEmail()) .age(dto.getAge()) .name(dto.getName()) - .consumptionPattern(dto.getConsumptionPattern()) + .region(dto.getRegion()) + .mobileCarrier(dto.getMobileCarrier()) .gender(dto.getGender()) - .photoUrl(dto.getPhotoUrl()) +// .consumptionPattern(dto.getConsumptionPattern()) +// .photoUrl(dto.getPhotoUrl()) .build(); } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/user/dto/UserDto.java b/src/main/java/com/bbteam/budgetbuddies/domain/user/dto/UserDto.java index 449178b2..fe41208e 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/user/dto/UserDto.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/user/dto/UserDto.java @@ -8,6 +8,7 @@ import org.hibernate.validator.constraints.Length; import java.time.LocalDateTime; +import java.util.List; public class UserDto { @@ -19,10 +20,13 @@ public static class RegisterUserDto { private String name; @Min(value = 1, message = "나이는 0또는 음수가 될 수 없습니다.") private Integer age; + private String mobileCarrier; + private String region; private Gender gender; private String email; - private String photoUrl; - private String consumptionPattern; + private List hashtagIds; // 사용자가 선택한 해시태그 ID 목록 +// private String photoUrl; +// private String consumptionPattern; } @Getter @@ -33,9 +37,11 @@ public static class ResponseUserDto { private String name; private String email; private Integer age; + private String mobileCarrier; + private String region; private Gender gender; - private String photoUrl; - private String consumptionPattern; +// private String photoUrl; +// private String consumptionPattern; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm", timezone = "Asia/Seoul") private LocalDateTime lastLoginAt; } @@ -46,4 +52,10 @@ public static class ModifyUserDto { private String email; private String name; } + + @Getter + @Builder + public static class AuthUserDto { + private Long id; + } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/user/entity/User.java b/src/main/java/com/bbteam/budgetbuddies/domain/user/entity/User.java index dc4d6d6d..4fc48bda 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/user/entity/User.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/user/entity/User.java @@ -3,15 +3,17 @@ import com.bbteam.budgetbuddies.common.BaseEntity; import com.bbteam.budgetbuddies.domain.user.dto.UserDto; import com.bbteam.budgetbuddies.enums.Gender; +import com.bbteam.budgetbuddies.enums.Role; import jakarta.persistence.*; import jakarta.validation.constraints.Min; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import lombok.experimental.SuperBuilder; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; @Entity @@ -21,32 +23,40 @@ @SuperBuilder public class User extends BaseEntity { - @Column(nullable = false, unique = true) - private String phoneNumber; + @Builder.Default + private Role role = Role.USER; // 기본값 User 권한 - @Column(nullable = false, length = 20) - private String name; + @Column(nullable = false, unique = true) + private String phoneNumber; - @Min(value = 1, message = "나이는 0또는 음수가 될 수 없습니다.") - private Integer age; + @Column(nullable = false, length = 20) + private String name; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "varchar(20)") - private Gender gender; + @Min(value = 1, message = "나이는 0또는 음수가 될 수 없습니다.") + private Integer age; - @Column(nullable = false, length = 50, unique = true) - private String email; + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar(20)") + private Gender gender; - @Column(length = 1000) - private String photoUrl; + @Column(nullable = false, length = 50, unique = true) + private String email; - private String consumptionPattern; + @Column(nullable = true) + private String mobileCarrier; // 통신사 - private LocalDateTime lastLoginAt; + @Column(nullable = true) + private String region; // 거주지 - public void changeUserDate(String email, String name) { - this.name = name; - this.email = email; - } + private LocalDateTime lastLoginAt; + + public void changeUserDate(String email, String name) { + this.name = name; + this.email = email; + } + + public List getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(role.name())); + } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/user/repository/UserRepository.java b/src/main/java/com/bbteam/budgetbuddies/domain/user/repository/UserRepository.java index f932a14e..78396a35 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/user/repository/UserRepository.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/user/repository/UserRepository.java @@ -10,4 +10,5 @@ public interface UserRepository extends JpaRepository { Optional findById(Long userId); + Optional findFirstByPhoneNumber(String phoneNumber); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/user/service/UserService.java b/src/main/java/com/bbteam/budgetbuddies/domain/user/service/UserService.java index 0cb29dba..a4457ba9 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/user/service/UserService.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/user/service/UserService.java @@ -1,12 +1,15 @@ package com.bbteam.budgetbuddies.domain.user.service; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.UserConsumptionGoalResponse; import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.domain.user.entity.User; import java.util.List; public interface UserService { List createConsumptionGoalWithDefaultGoals(Long userId); + void saveFavoriteHashtags(Long userId, List hashtagIds); + UserDto.ResponseUserDto saveUser(UserDto.RegisterUserDto dto); UserDto.ResponseUserDto findUser(Long userId); @@ -14,4 +17,6 @@ public interface UserService { UserDto.ResponseUserDto modifyUser(Long userId, UserDto.ModifyUserDto dto); List findAll(); + + User getUser(Long userId); } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/user/service/UserServiceImpl.java b/src/main/java/com/bbteam/budgetbuddies/domain/user/service/UserServiceImpl.java index 2ce9dbb1..7fad61d6 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/user/service/UserServiceImpl.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/user/service/UserServiceImpl.java @@ -6,6 +6,10 @@ import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.UserConsumptionGoalResponse; import com.bbteam.budgetbuddies.domain.consumptiongoal.entity.ConsumptionGoal; import com.bbteam.budgetbuddies.domain.consumptiongoal.repository.ConsumptionGoalRepository; +import com.bbteam.budgetbuddies.domain.favoritehashtag.entity.FavoriteHashtag; +import com.bbteam.budgetbuddies.domain.favoritehashtag.repository.FavoriteHashtagRepository; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; +import com.bbteam.budgetbuddies.domain.hashtag.repository.HashtagRepository; import com.bbteam.budgetbuddies.domain.user.converter.UserConverter; import com.bbteam.budgetbuddies.domain.user.dto.UserDto; import com.bbteam.budgetbuddies.domain.user.entity.User; @@ -28,6 +32,8 @@ public class UserServiceImpl implements UserService { private final CategoryRepository categoryRepository; private final ConsumptionGoalRepository consumptionGoalRepository; private final ConsumptionGoalConverter consumptionGoalConverter; + private final HashtagRepository hashtagRepository; + private final FavoriteHashtagRepository favoriteHashtagRepository; @Override @Transactional @@ -53,6 +59,26 @@ public List createConsumptionGoalWithDefaultGoals(L .collect(Collectors.toList()); } + @Override + @Transactional + public void saveFavoriteHashtags(Long userId, List hashtagIds) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found")); + + List favoriteHashtags = hashtagIds.stream() + .map(hashtagId -> { + Hashtag hashtag = hashtagRepository.findById(hashtagId) + .orElseThrow(() -> new NoSuchElementException("Hashtag not found")); + return FavoriteHashtag.builder() + .user(user) + .hashtag(hashtag) + .build(); + }) + .collect(Collectors.toList()); + + favoriteHashtagRepository.saveAll(favoriteHashtags); + } + @Override @Transactional public UserDto.ResponseUserDto saveUser(UserDto.RegisterUserDto dto) { @@ -82,4 +108,9 @@ public List findAll() { .map(UserConverter::toDto) .toList(); } + + @Override + public User getUser(Long userId) { + return userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("Not found user")); + } } diff --git a/src/main/java/com/bbteam/budgetbuddies/domain/user/validation/UserExistValidation.java b/src/main/java/com/bbteam/budgetbuddies/domain/user/validation/UserExistValidation.java index 3c636ba7..44c52940 100644 --- a/src/main/java/com/bbteam/budgetbuddies/domain/user/validation/UserExistValidation.java +++ b/src/main/java/com/bbteam/budgetbuddies/domain/user/validation/UserExistValidation.java @@ -1,7 +1,6 @@ package com.bbteam.budgetbuddies.domain.user.validation; import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; -import com.bbteam.budgetbuddies.domain.comment.entity.Comment; import com.bbteam.budgetbuddies.domain.user.entity.User; import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; import jakarta.validation.ConstraintValidator; @@ -21,7 +20,7 @@ public boolean isValid(Long aLong, ConstraintValidatorContext constraintValidato if(foundUser.isEmpty()) { constraintValidatorContext.disableDefaultConstraintViolation(); - constraintValidatorContext.buildConstraintViolationWithTemplate(ErrorStatus.USER_NOT_FOUND.toString()).addConstraintViolation(); + constraintValidatorContext.buildConstraintViolationWithTemplate(ErrorStatus._USER_NOT_FOUND.toString()).addConstraintViolation(); return false; } return true; diff --git a/src/main/java/com/bbteam/budgetbuddies/enums/NotiType.java b/src/main/java/com/bbteam/budgetbuddies/enums/NotiType.java new file mode 100644 index 00000000..c04ca9f0 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/enums/NotiType.java @@ -0,0 +1,10 @@ +package com.bbteam.budgetbuddies.enums; + +public enum NotiType { + + CONSUMPTION, // 소비경고알림 + INFORMATION, // 정보알림 + SYSTEM, // 시스템알림 + COMMUNITY // 커뮤니티 알림 + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/enums/Role.java b/src/main/java/com/bbteam/budgetbuddies/enums/Role.java new file mode 100644 index 00000000..e394418c --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/enums/Role.java @@ -0,0 +1,6 @@ +package com.bbteam.budgetbuddies.enums; + +public enum Role { + ADMIN, + USER +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/config/CachingConfig.java b/src/main/java/com/bbteam/budgetbuddies/global/config/CachingConfig.java new file mode 100644 index 00000000..54ad8a20 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/config/CachingConfig.java @@ -0,0 +1,22 @@ +package com.bbteam.budgetbuddies.global.config; + +import java.util.List; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@EnableCaching +@Configuration +public class CachingConfig { + + @Bean + public CacheManager cacheManager() { + ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager(); + cacheManager.setAllowNullValues(false); + cacheManager.setCacheNames(List.of("consumptionMent")); + return cacheManager; + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/config/OpenAiConfig.java b/src/main/java/com/bbteam/budgetbuddies/global/config/OpenAiConfig.java new file mode 100644 index 00000000..be785382 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/config/OpenAiConfig.java @@ -0,0 +1,22 @@ +package com.bbteam.budgetbuddies.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class OpenAiConfig { + @Value("${spring.openai.api-key}") + private String openAiKey; + + @Bean + public RestTemplate template() { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add((request, body, execution) -> { + request.getHeaders().add("Authorization", "Bearer " + openAiKey); + return execution.execute(request, body); + }); + return restTemplate; + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/config/SecurityConfig.java b/src/main/java/com/bbteam/budgetbuddies/global/config/SecurityConfig.java index d8b7e218..a17564f3 100644 --- a/src/main/java/com/bbteam/budgetbuddies/global/config/SecurityConfig.java +++ b/src/main/java/com/bbteam/budgetbuddies/global/config/SecurityConfig.java @@ -1,63 +1,65 @@ package com.bbteam.budgetbuddies.global.config; +import com.bbteam.budgetbuddies.global.security.utils.PhoneNumberAuthenticationProvider; +import com.bbteam.budgetbuddies.global.security.jwt.*; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import java.util.Objects; - -import static org.springframework.security.config.Customizer.withDefaults; +import java.util.List; @Configuration @EnableWebSecurity @Slf4j +@EnableMethodSecurity public class SecurityConfig { - private final Environment env; - public SecurityConfig(Environment env) { - this.env = env; - } + private final JwtRequestFilter jwtRequestFilter; + + private final JwtExceptionFilter jwtExceptionFilter; - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) // csrf 설정 비활성화 - .authorizeHttpRequests(authorizeRequests -> - authorizeRequests - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").hasRole("ADMIN") - .anyRequest().permitAll() - ) - .formLogin(withDefaults()) - .httpBasic(withDefaults()); + private final PhoneNumberAuthenticationProvider phoneNumberAuthenticationProvider; - return http.build(); - } + private final List swaggers = List.of( // Swagger 관련 URL 목록 + "/swagger-ui/**", + "/v3/api-docs/**" + ); - @Bean - public InMemoryUserDetailsManager userDetailsService() { - String username = env.getProperty("spring.security.user.name"); - String password = env.getProperty("spring.security.user.password"); + private final List auth = List.of( // 인증 관련 URL 목록 + "/auth/get-otp", + "/auth/login" + ); - log.info("username : {}", username); - log.info("password : {}", password); - UserDetails user = User.withDefaultPasswordEncoder() - .username(Objects.requireNonNull(username)) - .password(Objects.requireNonNull(password)) - .roles("ADMIN") - .build(); + public SecurityConfig(JwtRequestFilter jwtRequestFilter, JwtExceptionFilter jwtExceptionFilter, PhoneNumberAuthenticationProvider phoneNumberAuthenticationProvider) { + this.jwtRequestFilter = jwtRequestFilter; + this.jwtExceptionFilter = jwtExceptionFilter; + this.phoneNumberAuthenticationProvider = phoneNumberAuthenticationProvider; + } - return new InMemoryUserDetailsManager(user); - } + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) // csrf 설정 비활성화 + .authorizeHttpRequests(authorizeRequests -> + authorizeRequests + .requestMatchers(swaggers.toArray(new String[0])).permitAll() // 스웨거 주소 허용 + .requestMatchers(auth.toArray(new String[0])).permitAll() // 로그인 주소 허용 + .anyRequest().authenticated() // 그 외 모든 요청에 대해 인증 요구 + ) + .httpBasic(AbstractHttpConfigurer::disable) + .authenticationProvider(phoneNumberAuthenticationProvider) + .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtRequestFilter.class); // jwt 에러처리를 위한 필터등록 + return http.build(); + } } \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/config/WebConfig.java b/src/main/java/com/bbteam/budgetbuddies/global/config/WebConfig.java new file mode 100644 index 00000000..712fb35a --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/config/WebConfig.java @@ -0,0 +1,21 @@ +package com.bbteam.budgetbuddies.global.config; + +import com.bbteam.budgetbuddies.global.security.utils.AuthArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final AuthArgumentResolver authArgumentResolver; + + @Override + public void addArgumentResolvers(List argumentResolvers) { + argumentResolvers.add(authArgumentResolver); + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/auth/controller/AuthenticationApi.java b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/controller/AuthenticationApi.java new file mode 100644 index 00000000..96ea50c9 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/controller/AuthenticationApi.java @@ -0,0 +1,44 @@ +package com.bbteam.budgetbuddies.global.security.auth.controller; + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.global.security.auth.dto.AuthenticationRequest; +import com.bbteam.budgetbuddies.global.security.auth.dto.AuthenticationResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.web.bind.annotation.RequestBody; + +public interface AuthenticationApi { + + @Operation(summary = "OTP 요청 API", description = "전화번호를 입력받아 해당 번호로 OTP를 발송하고, 발송된 OTP 정보를 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH4001", description = "전화번호 형식이 유효하지 않습니다. (예: 01012341234)", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ApiResponse getOtpNumber( + @RequestBody AuthenticationRequest.ToReceiveNumber request + ); + + @Operation(summary = "로그인 API", description = "전화번호와 OTP를 받아 로그인 처리 후, 인증 토큰을 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "OTP4001", description = "인증번호가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH4001", description = "전화번호 형식이 유효하지 않습니다. (예: 01012341234)", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ApiResponse login( + @RequestBody AuthenticationRequest.ToLogin request + ); + + @Operation(summary = "액세스 토큰 재발급 API", description = "현재 인증된 사용자로부터 새로운 액세스 토큰을 발급받습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH4001", description = "전화번호 형식이 유효하지 않습니다. (예: 01012341234)", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4001", description = "토큰이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "토큰이 존재하지 않습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4003", description = "토큰이 만료되었습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4004", description = "토큰의 페이로드 혹은 시그니처가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4005", description = "토큰 헤더가 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ApiResponse reIssueAccessToken(); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/auth/controller/AuthenticationController.java b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/controller/AuthenticationController.java new file mode 100644 index 00000000..2d7a31fc --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/controller/AuthenticationController.java @@ -0,0 +1,84 @@ +package com.bbteam.budgetbuddies.global.security.auth.controller; + +import com.bbteam.budgetbuddies.apiPayload.ApiResponse; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.global.security.auth.dto.AuthenticationRequest; +import com.bbteam.budgetbuddies.global.security.auth.dto.AuthenticationResponse; +import com.bbteam.budgetbuddies.global.security.auth.service.AuthenticationService; +import com.bbteam.budgetbuddies.global.security.otp.OtpNumber; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") // 인증 관련 요청을 처리하는 컨트롤러 +public class AuthenticationController implements AuthenticationApi { + + private final AuthenticationService authenticationService; // 인증 관련 서비스 + + /** + * OTP를 요청하는 엔드포인트. + * 전화번호를 입력받아 해당 번호로 OTP를 발송하고, 발송된 OTP 정보를 반환합니다. + * + * @param request OTP 요청에 필요한 전화번호 정보 + * @return 성공 시 전화번호와 생성된 OTP를 포함한 ApiResponse + */ + @PostMapping("/get-otp") + @Override + public ApiResponse getOtpNumber( + @Valid @RequestBody AuthenticationRequest.ToReceiveNumber request + ) { + String phoneNumber = request.getPhoneNumber(); // 요청에서 전화번호 추출 + OtpNumber generatedOtp = authenticationService.generateOtp(phoneNumber); // OTP 생성 + AuthenticationResponse.SendOtpNumber response = AuthenticationResponse.SendOtpNumber.builder() + .phoneNumber(phoneNumber) // 전화번호 설정 + .otpNumber(generatedOtp) // 생성된 OTP 정보 설정 + .build(); + return ApiResponse.onSuccess(response); // 성공 응답 반환 + } + + /** + * 로그인 요청을 처리하는 엔드포인트. + * 전화번호와 OTP를 받아 로그인 처리 후, 인증 토큰을 반환합니다. + * + * @param request 로그인 요청에 필요한 전화번호 및 OTP 정보 + * @return 성공 시 인증 토큰 정보를 포함한 ApiResponse + */ + @PostMapping("/login") + @Override + public ApiResponse login( + @Valid @RequestBody AuthenticationRequest.ToLogin request + ) { + AuthenticationResponse.SendTokens response = authenticationService.login( + request.getPhoneNumber(), // 전화번호 추출 + request.getOtpNumber() // OTP 추출 + ); + return ApiResponse.onSuccess(response); // 성공 응답 반환 + } + + /** + * 액세스 토큰 재발급 요청을 처리하는 엔드포인트. + * 현재 인증된 사용자로부터 새로운 액세스 토큰을 발급받습니다. + * + * @return 성공 시 새로 발급된 액세스 토큰 정보를 포함한 ApiResponse + */ + @GetMapping("/reissue-access-token") + @Override + public ApiResponse reIssueAccessToken() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 현재 인증 정보 가져오기 + User user = null; + + // 인증된 사용자 정보가 있는지 확인 + if (authentication != null && authentication.isAuthenticated()) { + user = (User) authentication.getPrincipal(); // 인증된 사용자 추출 + } + + // 새로운 액세스 토큰 발급 + AuthenticationResponse.SendAccessToken response = authenticationService.reIssueAccessToken(user); + return ApiResponse.onSuccess(response); // 성공 응답 반환 + } +} + diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/auth/dto/AuthenticationRequest.java b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/dto/AuthenticationRequest.java new file mode 100644 index 00000000..11fb3021 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/dto/AuthenticationRequest.java @@ -0,0 +1,34 @@ +package com.bbteam.budgetbuddies.global.security.auth.dto; + + +import com.bbteam.budgetbuddies.global.security.auth.validation.ValidPhoneNumber; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class AuthenticationRequest { + + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Getter + public static class ToReceiveNumber { + @ValidPhoneNumber // 전화번호 유효성 검증 + private String phoneNumber; + } + + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Getter + public static class ToLogin { + // 전화번호, 인증번호 + @ValidPhoneNumber // 전화번호 유효성 검증 + private String phoneNumber; + + private String otpNumber; + } + + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/auth/dto/AuthenticationResponse.java b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/dto/AuthenticationResponse.java new file mode 100644 index 00000000..b7346f3d --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/dto/AuthenticationResponse.java @@ -0,0 +1,62 @@ +package com.bbteam.budgetbuddies.global.security.auth.dto; + +import com.bbteam.budgetbuddies.global.security.otp.OtpNumber; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class AuthenticationResponse { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "OTP 요청에 대한 응답 DTO") + public static class SendOtpNumber { // AuthenticationRequest.ToReceiveNumber와 대응 + + @Schema(description = "전화번호", example = "01012341234") + private String phoneNumber; // 전화번호 + + @Schema(description = "생성된 OTP 정보") + private OtpNumber otpNumber; // 인증번호 객체 + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "로그인 요청에 대한 응답 DTO") + public static class SendTokens { // AuthenticationRequest.ToLogin과 대응 + + @Schema(description = "유저 ID") + private Long userId; // 유저 아이디 + + @Schema(description = "전화번호", example = "01012341234") + private String phoneNumber; // 전화번호 + + @Schema(description = "액세스 토큰") + private String accessToken; // 액세스 토큰 + + @Schema(description = "리프레시 토큰") + private String refreshToken; // 리프레시 토큰 + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "액세스 토큰 재발급 요청에 대한 응답 DTO") + public static class SendAccessToken { + + @Schema(description = "유저 ID") + private Long userId; // 유저 아이디 + + @Schema(description = "전화번호", example = "01012341234") + private String phoneNumber; // 전화번호 + + @Schema(description = "액세스 토큰") + private String accessToken; // 액세스 토큰 + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/auth/service/AuthenticationService.java b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/service/AuthenticationService.java new file mode 100644 index 00000000..3976af3b --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/service/AuthenticationService.java @@ -0,0 +1,84 @@ +package com.bbteam.budgetbuddies.global.security.auth.service; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.apiPayload.exception.GeneralException; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import com.bbteam.budgetbuddies.global.security.auth.dto.AuthenticationResponse; +import com.bbteam.budgetbuddies.global.security.jwt.JwtUtil; +import com.bbteam.budgetbuddies.global.security.otp.OtpNumber; +import com.bbteam.budgetbuddies.global.security.otp.OtpService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + + private final JwtUtil jwtUtil; // JWT 관련 유틸리티 클래스 + private final OtpService otpService; // OTP 관련 서비스 클래스 + private final UserRepository userRepository; // 사용자 정보 저장소 + + /** + * OTP를 생성하여 반환하는 메서드. + * @param phoneNumber 전화번호 + * @return 생성된 OTP 정보 + */ + public OtpNumber generateOtp(String phoneNumber) { + // 전화번호를 사용하여 OTP를 생성하고 반환합니다. + return otpService.generateOtp(phoneNumber); + } + + /** + * 로그인 처리 메서드. + * 전화번호와 OTP를 검증한 후, 인증이 성공하면 JWT 토큰을 발급합니다. + * + * @param phoneNumber 전화번호 + * @param otpNumber 인증번호 + * @return 발급된 JWT 액세스 토큰과 리프레시 토큰 정보 + */ + public AuthenticationResponse.SendTokens login(String phoneNumber, String otpNumber) { + // 입력된 OTP가 유효한지 검증 + if (!otpService.validateOtp(phoneNumber, otpNumber)) { + throw new GeneralException(ErrorStatus._OTP_NOT_VALID); // 유효하지 않은 OTP일 경우 예외 발생 + } + + // 전화번호로 사용자를 로드, 존재하지 않으면 새 사용자 생성 + final User user = userRepository.findFirstByPhoneNumber(phoneNumber) + .orElseGet(() -> userRepository.save(User.builder() // 사용자 정보가 없으면 새로 생성 + .phoneNumber(phoneNumber) + .build() + )); + + // JWT 액세스 토큰 발급 + final String accessToken = jwtUtil.generateAccessToken(user); + // JWT 리프레시 토큰 발급 + final String refreshToken = jwtUtil.generateRefreshToken(user); + + // 발급된 토큰 정보를 포함한 응답 객체를 반환 + return AuthenticationResponse.SendTokens.builder() + .userId(user.getId()) // 사용자 ID + .phoneNumber(user.getPhoneNumber()) // 전화번호 + .accessToken(accessToken) // 액세스 토큰 + .refreshToken(refreshToken) // 리프레시 토큰 + .build(); + } + + /** + * 새로운 액세스 토큰을 재발급하는 메서드. + * + * @param user 사용자 정보 + * @return 발급된 새로운 액세스 토큰 정보 + */ + public AuthenticationResponse.SendAccessToken reIssueAccessToken(User user) { + // 새로운 액세스 토큰 발급 + String newAccessToken = jwtUtil.generateAccessToken(user); + + // 발급된 새로운 토큰 정보를 포함한 응답 객체를 반환 + return AuthenticationResponse.SendAccessToken.builder() + .userId(user.getId()) // 사용자 ID + .phoneNumber(user.getPhoneNumber()) // 전화번호 + .accessToken(newAccessToken) // 새로운 액세스 토큰 + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/auth/validation/PhoneNumberValidation.java b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/validation/PhoneNumberValidation.java new file mode 100644 index 00000000..01bbf656 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/validation/PhoneNumberValidation.java @@ -0,0 +1,34 @@ +package com.bbteam.budgetbuddies.global.security.auth.validation; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; + +import java.util.regex.Pattern; + +@RequiredArgsConstructor +public class PhoneNumberValidation implements ConstraintValidator { + + // 전화번호 형식은 '01012341234'와 같아야 합니다. + private static final String PHONE_NUMBER_PATTERN = "^010\\d{8}$"; + + @Override + public boolean isValid(String phoneNumber, ConstraintValidatorContext constraintValidatorContext) { + // Check if phone number matches the specified format + if (phoneNumber == null || !Pattern.matches(PHONE_NUMBER_PATTERN, phoneNumber)) { + // 커스텀 에러 코드와 메시지 사용 + constraintValidatorContext.disableDefaultConstraintViolation(); + constraintValidatorContext.buildConstraintViolationWithTemplate( + ErrorStatus._PHONE_NUMBER_NOT_VALID.getCode() + ": " + ErrorStatus._PHONE_NUMBER_NOT_VALID.getMessage() + ).addConstraintViolation(); + return false; + } + return true; + } + + @Override + public void initialize(ValidPhoneNumber constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/auth/validation/ValidPhoneNumber.java b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/validation/ValidPhoneNumber.java new file mode 100644 index 00000000..1852dd76 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/auth/validation/ValidPhoneNumber.java @@ -0,0 +1,17 @@ +package com.bbteam.budgetbuddies.global.security.auth.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = PhoneNumberValidation.class) +@Target( { ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPhoneNumber { + + String message() default "전화번호 형식이 유효하지 않습니다. (예: 01012341234)"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/jwt/JwtExceptionFilter.java b/src/main/java/com/bbteam/budgetbuddies/global/security/jwt/JwtExceptionFilter.java new file mode 100644 index 00000000..efde7d61 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/jwt/JwtExceptionFilter.java @@ -0,0 +1,78 @@ +package com.bbteam.budgetbuddies.global.security.jwt; + +import com.bbteam.budgetbuddies.apiPayload.code.ErrorReasonDto; +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtExceptionFilter extends OncePerRequestFilter { + + // JWT 검증 중 발생한 예외를 처리하는 필터 메소드 + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { + try { + chain.doFilter(request, response); + } catch (JwtException ex) { + String message = ex.getMessage(); + + // 토큰이 없는 경우 + if(ErrorStatus._TOKEN_NOT_FOUND.getMessage().equals(message)) { + log.info("error message: {}", message); + setResponse(response, ErrorStatus._TOKEN_NOT_FOUND); + } + // 토큰 만료된 경우 + else if(message.startsWith("JWT expired at")) { + log.info("error message: {}", message); + setResponse(response, ErrorStatus._TOKEN_EXPIRED); + } + // 잘못된 형식의 토큰인 경우 (페이로드 혹은 시그니처 불일치) + else if(message.startsWith("JWT signature does not match")) { + log.info("error message: {}", message); + setResponse(response, ErrorStatus._TOKEN_PAYLOAD_OR_SIGNATURE_NOT_VALID); + } + // 잘못된 형식의 토큰인 경우 (잘못된 헤더 정보) + else if(message.startsWith("Malformed JWT JSON:")) { + log.info("error message: {}", message); + setResponse(response, ErrorStatus._TOKEN_HEADER_NOT_VALID); + } + // 그 외: 유효하지 않는 토큰 + else { + log.info("error message: {}", message); + setResponse(response, ErrorStatus._TOKEN_NOT_VALID); + } + + } + } + + // 에러 응답을 설정하는 메소드 + private void setResponse(HttpServletResponse response, ErrorStatus errorStatus) throws RuntimeException, IOException { + // ErrorReasonDto를 빌더로 생성하여 에러 정보 설정 + ErrorReasonDto errorReason = ErrorReasonDto.builder() + .message(errorStatus.getMessage()) // 에러 메시지 + .code(errorStatus.getCode()) // 에러 코드 + .isSuccess(false) // 성공 여부는 false + .httpStatus(errorStatus.getHttpStatus()) // HTTP 상태 코드 + .build(); + + // 응답 설정 + response.setStatus(errorReason.getHttpStatus().value()); // HTTP 상태 코드 설정 + response.setContentType("application/json; charset=UTF-8"); // JSON 형식 및 UTF-8 설정 + response.setCharacterEncoding("UTF-8"); // 응답 인코딩 설정 + + // JSON 형식으로 에러 정보를 클라이언트에 전송 + response.getWriter().write(new ObjectMapper().writeValueAsString(errorReason)); + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/jwt/JwtRequestFilter.java b/src/main/java/com/bbteam/budgetbuddies/global/security/jwt/JwtRequestFilter.java new file mode 100644 index 00000000..882f14b6 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/jwt/JwtRequestFilter.java @@ -0,0 +1,148 @@ +package com.bbteam.budgetbuddies.global.security.jwt; + +import com.bbteam.budgetbuddies.apiPayload.code.ErrorReasonDto; +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.apiPayload.exception.GeneralException; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.global.security.utils.CustomAuthenticationToken; +import com.bbteam.budgetbuddies.global.security.utils.MyUserDetailsService; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtRequestFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + private final MyUserDetailsService myUserDetailsService; + + private final String BEARER = "Bearer "; + + // Swagger 및 인증 관련 요청 주소 목록 + private final List swaggers = List.of( + "/swagger-ui", + "/v3/api-docs" + ); + private final List auth = List.of( + "/auth/get-otp", + "/auth/login" + ); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String requestURI = request.getRequestURI(); // 요청한 URI 확인 (리프레시 토큰 요청 여부 확인) + + // Swagger 또는 인증 관련 URI일 경우 필터 통과 + if (swaggers.stream().anyMatch(requestURI::startsWith) || auth.stream().anyMatch(requestURI::startsWith)) { + chain.doFilter(request, response); // 필터 통과 + return; // 메소드 종료 + } + + final String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); // Authorization 헤더 추출 + Long userId = null; + String phoneNumber = null; + String token = null; + + // Bearer 토큰이 포함된 Authorization 헤더에서 토큰 추출 + if (authorizationHeader != null && authorizationHeader.startsWith(BEARER)) { + token = authorizationHeader.substring(7); // "Bearer " 이후의 부분을 추출 + userId = Long.parseLong(jwtUtil.extractUserId(token)); // JWT에서 사용자 ID 추출 + phoneNumber = jwtUtil.extractPhoneNumber(token); // JWT에서 전화번호 추출 + log.info("request header's token: {}", token); + log.info("request header's userId: {}", userId); + log.info("request header's phoneNumber: {}", phoneNumber); + } + + try { + // 토큰이 없는 경우 예외 발생 + if (token == null) { + throw new GeneralException(ErrorStatus._TOKEN_NOT_FOUND); + } + + // 인증 로직 + if (userId != null && phoneNumber != null && request.getAttribute("authenticatedUser") == null) { + if (isRefreshTokenApiRequest(requestURI)) { + authenticateToken(request, token, phoneNumber, true); // 리프레시 토큰 검증 + } else { + authenticateToken(request, token, phoneNumber, false); // 액세스 토큰 검증 + } + } + + // 필터 체인을 계속해서 실행 + chain.doFilter(request, response); + + } catch (ExpiredJwtException e) { + // 토큰 만료 시 GeneralException 발생 + throw new GeneralException(ErrorStatus._TOKEN_EXPIRED); + } catch (GeneralException ex) { + // GeneralException 처리 및 클라이언트에 에러 응답 전송 + ErrorReasonDto errorReason = ex.getErrorReasonHttpStatus(); + response.setStatus(errorReason.getHttpStatus().value()); // HTTP 상태 코드 설정 + response.setContentType("application/json; charset=UTF-8"); // JSON 형식 및 UTF-8 설정 + response.setCharacterEncoding("UTF-8"); // 응답 인코딩 설정 + response.getWriter().write(new ObjectMapper().writeValueAsString(errorReason)); // JSON 형식으로 에러 응답 전송 + } + } + + // 리프레시 토큰 API 요청 여부 확인 + private boolean isRefreshTokenApiRequest(String requestURI) { + return "/auth/reissue-access-token".equals(requestURI); + } + + /** + * 공통 인증 로직: 토큰 검증 및 인증 생성 + * + * @param request HttpServletRequest 객체 + * @param token JWT 토큰 + * @param phoneNumber JWT에서 추출한 전화번호 + * @param isRefreshToken 리프레시 토큰 여부 + */ + private void authenticateToken(HttpServletRequest request, String token, String phoneNumber, boolean isRefreshToken) { + // 사용자 정보 로드 + User user = this.myUserDetailsService.loadUserByPhoneNumber(phoneNumber); + + // 토큰 검증: 리프레시 토큰이면 리프레시 토큰 검증, 액세스 토큰이면 액세스 토큰 검증 + boolean isValidToken = isRefreshToken + ? jwtUtil.validateRefreshToken(token, user.getId(), user.getPhoneNumber()) // 리프레시 토큰 검증 + : jwtUtil.validateAccessToken(token, user.getId(), user.getPhoneNumber()); // 액세스 토큰 검증 + + if (isValidToken) { + // 인증 객체 생성 + CustomAuthenticationToken authentication = new CustomAuthenticationToken(user, null, user.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + // SecurityContext에 인증 정보 저장 + SecurityContextHolder.getContext().setAuthentication(authentication); + + /** + * <사용 예시> + * Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + * if (authentication != null && authentication.isAuthenticated()) { + * User user = (User) authentication.getPrincipal(); + * // User 객체를 통해 필요한 정보를 사용 + * } + */ + } else { + // 토큰이 유효하지 않을 경우 예외 발생 + throw new GeneralException(ErrorStatus._TOKEN_NOT_VALID); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/jwt/JwtUtil.java b/src/main/java/com/bbteam/budgetbuddies/global/security/jwt/JwtUtil.java new file mode 100644 index 00000000..4c3fe9c8 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/jwt/JwtUtil.java @@ -0,0 +1,163 @@ +package com.bbteam.budgetbuddies.global.security.jwt; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.apiPayload.exception.GeneralException; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.global.security.refreshtoken.RefreshTokenRepository; +import com.bbteam.budgetbuddies.global.security.refreshtoken.RefreshTokenService; +import com.bbteam.budgetbuddies.global.security.utils.MyUserDetailsService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class JwtUtil { + + private final MyUserDetailsService myUserDetailsService; + + private final RefreshTokenRepository refreshTokenRepository; + + private final RefreshTokenService refreshTokenService; + + // JWT 토큰을 서명하는 데 사용되는 비밀 키 + @Value("${jwt.token.key}") + private String SECRET_KEY; + + @Value("${jwt.token.access-token.expiration-time}") + private Long accessTokenExpirationTime; // 엑세스 토큰 만료 기한 + + @Value("${jwt.token.refresh-token.expiration-time}") + private Long refreshTokenExpirationTime; // 리프레시 토큰 만료 기한 + + // 토큰에서 사용자 이름(주체, subject)을 추출하는 메서드 + public String extractUserId(String token) { + return extractClaim(token, Claims::getSubject); + } + + // 토큰에서 만료 시간을 추출하는 메서드 + public Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public String extractPhoneNumber(String token) { + // JWT 전화번호 클레임 추출 + return (String) extractAllClaims(token).get("phoneNumber"); + } + + public User extractUser(String token) { + + Long userId = Long.parseLong(extractAllClaims(token).getSubject()); + + User user = myUserDetailsService.loadUserByUserId(userId); + + return user; + } + + // 토큰에서 특정 클레임을 추출하는 메서드 + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); // 모든 클레임을 추출 + return claimsResolver.apply(claims); // 추출된 클레임에서 원하는 정보를 반환 + } + + // 토큰에서 모든 클레임을 추출하는 메서드 + private Claims extractAllClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(SECRET_KEY.getBytes()) // 서명 키 설정 + .build() + .parseClaimsJws(token) // 토큰 파싱 + .getBody(); // 클레임 반환 + } + + // 토큰이 만료되었는지 확인하는 메서드 + private void isTokenExpired(String token) throws GeneralException { + if (extractExpiration(token).before(new Date())) { + throw new GeneralException(ErrorStatus._TOKEN_EXPIRED); + } + } + + // 사용자 전화번호를 기반으로 엑세스 토큰을 생성하는 메서드 + public String generateAccessToken(User user) { + Map claims = new HashMap<>(); // 클레임으로 빈 맵을 사용 + claims.put("phoneNumber", user.getPhoneNumber()); + + String token = createToken(claims, user.getId(), accessTokenExpirationTime);// 토큰 생성: 1시간 유효 + + log.info("access token: {}", token); + + return token; + } + + // 사용자 전화번호를 기반으로 리프레시 토큰을 생성하는 메서드 + public String generateRefreshToken(User user) { + Map claims = new HashMap<>(); // 클레임으로 빈 맵을 사용 + claims.put("phoneNumber", user.getPhoneNumber()); + + String token = createToken(claims, user.getId(), refreshTokenExpirationTime);// 토큰 생성: 1년 유효 + + log.info("refresh token: {}", token); + + refreshTokenService.saveOrUpdateRefreshToken(user, token); + + return token; + } + + // 클레임, 주체, 만료 시간을 기반으로 JWT 토큰을 생성하는 메서드 + private String createToken(Map claims, Long userId, Long expirationTime) { + + return Jwts.builder() + .setClaims(claims) // 클레임 설정 + .setSubject(String.valueOf(userId)) // 주체 설정 (유저 ID) + .setIssuedAt(new Date(System.currentTimeMillis())) // 현재 시간으로 발행 시간 설정 + .setExpiration(new Date(System.currentTimeMillis() + expirationTime)) // 만료 시간 설정 + .signWith(SignatureAlgorithm.HS256, SECRET_KEY.getBytes()) // 서명 알고리즘과 서명 키 설정 + .compact(); // 최종 토큰을 생성하여 반환 + } + + // 엑세스 토큰이 유효한지 검증하는 메서드 (사용자 이름과 만료 여부 확인) + public Boolean validateAccessToken(String token, Long userId, String phoneNumber) throws GeneralException { + final String extractedUserId = extractUserId(token); // 토큰에서 사용자 ID 추출 + final String extractedPhoneNumber = extractPhoneNumber(token); // 토큰에서 사용자 전화번호 추출 + + log.info("extractedUserId: {}", extractedUserId); + log.info("extractedPhoneNumber: {}", extractedPhoneNumber); + + isTokenExpired(token); // 만료 확인, 만료된 경우 예외 발생 + + return ( + extractedUserId.equals(String.valueOf(userId)) // 사용자 ID 일치 여부 검증 + && extractedPhoneNumber.equals(phoneNumber) // 사용자 전화번호 일치 여부 검증 + ); + } + + // 리프레시 토큰이 유효한지 검증하는 메서드 (사용자 이름과 만료 여부 확인 및 DB 값과 비교) + public Boolean validateRefreshToken(String token, Long userId, String phoneNumber) { + final String extractedUserId = extractUserId(token); // 토큰에서 사용자 ID 추출 + final String extractedPhoneNumber = extractPhoneNumber(token); // 토큰에서 사용자 전화번호 추출 + + User user = myUserDetailsService.loadUserByUserId(userId); + + isTokenExpired(token); // 만료 확인, 만료된 경우 예외 발생 + + // 리프레시 토큰 검증 + return ( + refreshTokenService.validateRefreshToken(user, token) && + extractedUserId.equals(String.valueOf(userId)) && + extractedPhoneNumber.equals(phoneNumber) + ); + } +} + + diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/otp/OtpNumber.java b/src/main/java/com/bbteam/budgetbuddies/global/security/otp/OtpNumber.java new file mode 100644 index 00000000..9f13963e --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/otp/OtpNumber.java @@ -0,0 +1,20 @@ +package com.bbteam.budgetbuddies.global.security.otp; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OtpNumber { + + private String otp; // 인증번호 + + private LocalDateTime expirationTime; // 만료시각 + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/otp/OtpService.java b/src/main/java/com/bbteam/budgetbuddies/global/security/otp/OtpService.java new file mode 100644 index 00000000..40c2eac3 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/otp/OtpService.java @@ -0,0 +1,118 @@ +package com.bbteam.budgetbuddies.global.security.otp; + + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import net.nurigo.sdk.NurigoApp; +import net.nurigo.sdk.message.model.Message; +import net.nurigo.sdk.message.request.SingleMessageSendingRequest; +import net.nurigo.sdk.message.response.SingleMessageSentResponse; +import net.nurigo.sdk.message.service.DefaultMessageService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@Slf4j +public class OtpService { + + // OTP를 저장할 캐시. ConcurrentHashMap을 사용하여 다중 스레드 환경에서 안전하게 OTP를 저장 + private final Map otpCache = new ConcurrentHashMap<>(); + + // Cool-SMS 메시지 서비스를 위한 변수 + private DefaultMessageService messageService; + + // OTP 인증번호 유효 시간 (초 단위): 3분 + private static final long OTP_VALID_DURATION = 3 * 60; + + @Value("${cool-sms.api-key}") + private String apiKey; + + @Value("${cool-sms.api-secret}") + private String apiSecret; + + @Value("${cool-sms.sender-phone-number}") + private String senderPhoneNumber; + + // 초기화 메소드: Cool-SMS 메시지 서비스 인스턴스를 초기화 + @PostConstruct + public void init() { + this.messageService = NurigoApp.INSTANCE.initialize(apiKey, apiSecret, "https://api.coolsms.co.kr"); + } + + /** + * OTP를 생성하고 캐시에 저장하는 메소드 + * + * @param phoneNumber 사용자의 전화번호 + * @return 생성된 OTP 정보가 담긴 OtpNumber 객체 + */ + public OtpNumber generateOtp(String phoneNumber) { + // 6자리 랜덤 OTP 번호 생성 + String randomNumber = String.valueOf((int)(Math.random() * 900000) + 100000); + + // OTP 만료 시간 설정 (현재 시간 기준으로 3분 뒤) + LocalDateTime expirationTime = LocalDateTime.now().plusSeconds(OTP_VALID_DURATION); + + // OTP 정보를 담은 객체 생성 + OtpNumber otp = OtpNumber.builder() + .otp(randomNumber) + .expirationTime(expirationTime) + .build(); + + // 생성된 OTP를 전화번호를 키로 하여 캐시에 저장 + otpCache.put(phoneNumber, otp); + + // 실제 메시지 전송 + sendMessage(phoneNumber, otp); + + return otp; + } + + /** + * OTP 문자 메시지를 전송하는 메소드 + * + * @param phoneNumber 수신자의 전화번호 + * @param otp 생성된 OTP 객체 + */ + public void sendMessage(String phoneNumber, OtpNumber otp) { + Message message = new Message(); + + // 발신 번호 및 수신 번호 설정 (01012345678 형태로 입력해야 함) + message.setFrom(senderPhoneNumber); // 발신 번호 설정 + message.setTo(phoneNumber); // 수신 번호 설정 + + // 메시지 내용 설정 (한글 45자 이하일 경우 자동으로 SMS로 전송) + message.setText("[빈주머니즈]\n인증번호: " + otp.getOtp() + "\n보안을 위해 번호를 타인과 공유하지 마세요."); + + // 메시지 전송 요청 및 응답 로그 출력 + SingleMessageSentResponse response = this.messageService.sendOne(new SingleMessageSendingRequest(message)); + log.info("전송된 메시지: {}", response); + } + + /** + * OTP가 유효한지 검증하는 메소드 + * + * @param phoneNumber 사용자의 전화번호 + * @param otp 사용자가 입력한 OTP + * @return OTP가 유효하고 만료되지 않았으면 true, 그렇지 않으면 false + */ + public boolean validateOtp(String phoneNumber, String otp) { + // 캐시에서 전화번호에 해당하는 OTP 가져오기 + OtpNumber otpEntry = otpCache.get(phoneNumber); + + if (otpEntry == null) { + return false; // 캐시에 OTP가 없으면 false 반환 + } + + // OTP가 일치하고 유효 기간 내에 있는지 확인 + if (otpEntry.getOtp().equals(otp) && otpEntry.getExpirationTime().isAfter(LocalDateTime.now())) { + otpCache.remove(phoneNumber); // OTP가 유효하면 사용 후 제거 + return true; + } + + return false; // OTP가 일치하지 않거나 만료된 경우 false 반환 + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/refreshtoken/RefreshToken.java b/src/main/java/com/bbteam/budgetbuddies/global/security/refreshtoken/RefreshToken.java new file mode 100644 index 00000000..47f31a98 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/refreshtoken/RefreshToken.java @@ -0,0 +1,26 @@ +package com.bbteam.budgetbuddies.global.security.refreshtoken; + +import com.bbteam.budgetbuddies.common.BaseEntity; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.*; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Entity +public class RefreshToken extends BaseEntity { + + @OneToOne + @JoinColumn(name = "user_id", referencedColumnName = "id") + private User user; + + @Setter + @Column(nullable = false, unique = true) + private String token; + +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/refreshtoken/RefreshTokenRepository.java b/src/main/java/com/bbteam/budgetbuddies/global/security/refreshtoken/RefreshTokenRepository.java new file mode 100644 index 00000000..cda7f70b --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/refreshtoken/RefreshTokenRepository.java @@ -0,0 +1,15 @@ +package com.bbteam.budgetbuddies.global.security.refreshtoken; + +import com.bbteam.budgetbuddies.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + Optional findByUser(User user); + + void deleteByUser(User user); +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/refreshtoken/RefreshTokenService.java b/src/main/java/com/bbteam/budgetbuddies/global/security/refreshtoken/RefreshTokenService.java new file mode 100644 index 00000000..fffc5f69 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/refreshtoken/RefreshTokenService.java @@ -0,0 +1,75 @@ +package com.bbteam.budgetbuddies.global.security.refreshtoken; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.apiPayload.exception.GeneralException; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + /** + * 리프레시 토큰을 저장하거나 갱신하는 메서드. + * 해당 사용자의 리프레시 토큰이 이미 존재하면 새 토큰으로 갱신하고, + * 존재하지 않으면 새 리프레시 토큰을 생성하여 저장합니다. + * + * @param user 사용자 정보 + * @param newToken 새로운 리프레시 토큰 + */ + @Transactional + public void saveOrUpdateRefreshToken(User user, String newToken) { + // 해당 사용자의 리프레시 토큰이 이미 존재하는지 확인 + Optional existingToken = refreshTokenRepository.findByUser(user); + + // 기존 토큰이 있으면 갱신, 없으면 새로 저장 + if (existingToken.isPresent()) { + // 기존 리프레시 토큰을 새로운 토큰으로 갱신 + existingToken.get().setToken(newToken); + refreshTokenRepository.save(existingToken.get()); + } else { + // 새 리프레시 토큰 생성 및 저장 + RefreshToken refreshToken = RefreshToken.builder() + .user(user) + .token(newToken) + .build(); + refreshTokenRepository.save(refreshToken); + } + } + + /** + * 리프레시 토큰이 해당 사용자와 일치하는지 검증하는 메서드. + * 사용자와 연관된 리프레시 토큰이 없으면 예외를 발생시키고, + * 있으면 토큰이 일치하는지 여부를 반환합니다. + * + * @param user 해당 사용자 + * @param token 리프레시 토큰 문자열 + * @return 토큰이 사용자와 일치하는지 여부 + */ + public boolean validateRefreshToken(User user, String token) { + // 사용자와 연결된 리프레시 토큰이 없으면 예외 발생 + RefreshToken refreshToken = refreshTokenRepository.findByUser(user) + .orElseThrow(() -> new GeneralException(ErrorStatus._TOKEN_NOT_FOUND)); + + // 리프레시 토큰 문자열이 일치하는지 검증 + return refreshToken.getToken().equals(token); + } + + /** + * 리프레시 토큰을 삭제하는 메서드. + * 사용자 정보를 기반으로 리프레시 토큰을 삭제합니다. + * + * @param user 사용자 정보 + */ + @Transactional + public void deleteRefreshToken(User user) { + // 사용자와 연관된 리프레시 토큰 삭제 + refreshTokenRepository.deleteByUser(user); + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/utils/AuthArgumentResolver.java b/src/main/java/com/bbteam/budgetbuddies/global/security/utils/AuthArgumentResolver.java new file mode 100644 index 00000000..7560b0f2 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/utils/AuthArgumentResolver.java @@ -0,0 +1,47 @@ +package com.bbteam.budgetbuddies.global.security.utils; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.apiPayload.exception.GeneralException; +import com.bbteam.budgetbuddies.domain.user.dto.UserDto; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + // @Auth 존재 여부 확인 + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthUser.class); + } + + // @Auth 존재 시, 사용자 정보 확인하여 반환 + @Override + public Object resolveArgument(@NonNull MethodParameter parameter, ModelAndViewContainer mavContainer, @NonNull NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new GeneralException(ErrorStatus._USER_NOT_FOUND); + } + + Object principal = authentication.getPrincipal(); + if (!(principal instanceof User user)) { + throw new GeneralException(ErrorStatus._USER_NOT_FOUND); + } + + UserDto.AuthUserDto authUserDto = UserDto.AuthUserDto.builder() + .id(user.getId()) + .build(); + + return authUserDto; + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/utils/AuthUser.java b/src/main/java/com/bbteam/budgetbuddies/global/security/utils/AuthUser.java new file mode 100644 index 00000000..9fa4a0e1 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/utils/AuthUser.java @@ -0,0 +1,12 @@ +package com.bbteam.budgetbuddies.global.security.utils; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthUser { +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/utils/CustomAuthenticationToken.java b/src/main/java/com/bbteam/budgetbuddies/global/security/utils/CustomAuthenticationToken.java new file mode 100644 index 00000000..357d272f --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/utils/CustomAuthenticationToken.java @@ -0,0 +1,47 @@ +package com.bbteam.budgetbuddies.global.security.utils; + +import com.bbteam.budgetbuddies.domain.user.entity.User; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class CustomAuthenticationToken extends AbstractAuthenticationToken { + + private final User user; // 사용자 정보가 담긴 커스텀 User 객체 + private Object credentials; // 사용자 인증 자격 정보 (비밀번호 또는 토큰 등) + + /** + * CustomAuthenticationToken 생성자. + * + * @param user 커스텀 User 객체 + * @param credentials 인증 자격 정보 (예: 비밀번호 또는 토큰) + * @param authorities GrantedAuthority 컬렉션 (사용자의 권한 정보) + */ + public CustomAuthenticationToken(User user, Object credentials, Collection authorities) { + super(authorities); // 부모 클래스에 권한 정보 전달 + this.user = user; // 사용자 정보 설정 + this.credentials = credentials; // 인증 자격 정보 설정 + super.setAuthenticated(true); // 인증 완료 상태로 설정 (인증된 상태로 처리) + } + + /** + * 사용자 인증 자격 정보를 반환하는 메서드. + * + * @return 인증 자격 정보 (비밀번호 또는 토큰 등) + */ + @Override + public Object getCredentials() { + return credentials; + } + + /** + * 인증된 사용자 정보를 반환하는 메서드. + * + * @return 커스텀 User 객체 (사용자 정보) + */ + @Override + public User getPrincipal() { + return user; // 사용자 정보를 담고 있는 커스텀 User 객체 반환 + } +} diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/utils/MyUserDetailsService.java b/src/main/java/com/bbteam/budgetbuddies/global/security/utils/MyUserDetailsService.java new file mode 100644 index 00000000..a8e0a47b --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/utils/MyUserDetailsService.java @@ -0,0 +1,45 @@ +package com.bbteam.budgetbuddies.global.security.utils; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.apiPayload.exception.GeneralException; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@Qualifier("MyUserDetailsService") +@RequiredArgsConstructor +public class MyUserDetailsService { + + private final UserRepository userRepository; // 사용자 정보를 조회하기 위한 레포지토리 + + /** + * 전화번호를 통해 사용자 정보를 조회하는 메서드. + * + * @param phoneNumber 사용자 전화번호 + * @return 조회된 사용자 정보 (User 객체) + */ + public User loadUserByPhoneNumber(String phoneNumber) { + // 전화번호로 사용자 정보를 조회하고 없을 시 예외 발생 + User user = userRepository.findFirstByPhoneNumber(phoneNumber) + .orElseThrow(() -> new GeneralException(ErrorStatus._USER_NOT_FOUND)); + return user; // 조회된 사용자 반환 + } + + /** + * 사용자 ID를 통해 사용자 정보를 조회하는 메서드. + * + * @param userId 사용자 ID + * @return 조회된 사용자 정보 (User 객체) + */ + public User loadUserByUserId(Long userId) { + // 사용자 ID로 사용자 정보를 조회하고 없을 시 예외 발생 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus._USER_NOT_FOUND)); + return user; // 조회된 사용자 반환 + } +} \ No newline at end of file diff --git a/src/main/java/com/bbteam/budgetbuddies/global/security/utils/PhoneNumberAuthenticationProvider.java b/src/main/java/com/bbteam/budgetbuddies/global/security/utils/PhoneNumberAuthenticationProvider.java new file mode 100644 index 00000000..c453c3b2 --- /dev/null +++ b/src/main/java/com/bbteam/budgetbuddies/global/security/utils/PhoneNumberAuthenticationProvider.java @@ -0,0 +1,56 @@ +package com.bbteam.budgetbuddies.global.security.utils; + +import com.bbteam.budgetbuddies.apiPayload.code.status.ErrorStatus; +import com.bbteam.budgetbuddies.apiPayload.exception.GeneralException; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.global.security.otp.OtpService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PhoneNumberAuthenticationProvider implements AuthenticationProvider { + + private final MyUserDetailsService myUserDetailsService; // 사용자 정보를 로드하기 위한 서비스 + + private final OtpService otpService; // OTP를 검증하기 위한 서비스 + + /** + * 전화번호와 OTP를 이용해 인증을 처리하는 메서드. + * + * @param authentication 인증 객체 (전화번호와 OTP 정보 포함) + * @return 인증 성공 시 UsernamePasswordAuthenticationToken 반환 + * @throws AuthenticationException 인증 실패 시 발생하는 예외 + */ + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String phoneNumber = authentication.getName(); // 전화번호 (사용자 이름에 해당) + String otp = authentication.getCredentials().toString(); // OTP (비밀번호에 해당) + + // OTP 검증 (OTP가 유효하지 않으면 예외 발생) + if (!otpService.validateOtp(phoneNumber, otp)) { + throw new GeneralException(ErrorStatus._OTP_NOT_VALID); // OTP가 유효하지 않음 + } + + // OTP 검증이 완료되면 사용자 정보 로드 + User user = myUserDetailsService.loadUserByPhoneNumber(phoneNumber); + + // 비밀번호 검증 없이 인증된 사용자 정보로 토큰 생성 + return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + } + + /** + * 해당 인증 제공자가 지원하는 인증 타입을 명시하는 메서드. + * + * @param authentication 지원 여부를 확인할 인증 타입 클래스 + * @return UsernamePasswordAuthenticationToken 타입을 지원하는지 여부 + */ + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); // UsernamePasswordAuthenticationToken 지원 + } +} diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/consumptiongoal/repository/ConsumptionGoalRepositoryTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/consumptiongoal/repository/ConsumptionGoalRepositoryTest.java index 5682da22..49634967 100644 --- a/src/test/java/com/bbteam/budgetbuddies/domain/consumptiongoal/repository/ConsumptionGoalRepositoryTest.java +++ b/src/test/java/com/bbteam/budgetbuddies/domain/consumptiongoal/repository/ConsumptionGoalRepositoryTest.java @@ -1,10 +1,12 @@ package com.bbteam.budgetbuddies.domain.consumptiongoal.repository; import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -60,6 +62,7 @@ void setUp() { .name("Peer User 1") .gender(Gender.MALE) .phoneNumber("010-1111-1111") + .mobileCarrier("SK Telecom") .build()); peerUser2 = userRepository.save( @@ -69,6 +72,7 @@ void setUp() { .name("Peer User 2") .gender(Gender.MALE) .phoneNumber("010-2222-2222") + .mobileCarrier("KT") .build()); currentMonth = LocalDate.now(); @@ -201,7 +205,7 @@ void findAvgConsumptionAmountByCategory_Success() { void findAllConsumptionAmountByUserId_Success() { // when List result = consumptionGoalRepository.findAllConsumptionAmountByUserId( - peerUser1.getId()); + peerUser1.getId(), currentMonth); // then assertThat(result).isNotEmpty(); @@ -273,7 +277,8 @@ void findAvgGoalAmountByCategory_Success() { @DisplayName("또래 나이와 성별 정보를 통해 카테고리와 평균 목표 금액 조회 성공") void findAllGoalAmountByUserId_Success() { // when - List result = consumptionGoalRepository.findAllGoalAmountByUserId(peerUser1.getId()); + List result = consumptionGoalRepository.findAllGoalAmountByUserId(peerUser1.getId(), + currentMonth); // then assertThat(result).isNotEmpty(); @@ -301,7 +306,7 @@ void findTopCategoriesByConsumptionCount_Success() { Gender peerGender = Gender.MALE; LocalDate currentMonth = LocalDate.now(); - List result = consumptionGoalRepository.findTopCategoriesByConsumptionCount( + List result = expenseRepository.findTopCategoriesByConsumptionCount( peerAgeStart, peerAgeEnd, peerGender, currentMonth.atStartOfDay()); // then @@ -320,4 +325,102 @@ void findTopCategoriesByConsumptionCount_Success() { assertThat(firstResult.getConsumptionCount()).isEqualTo(2); assertThat(secondResult.getConsumptionCount()).isEqualTo(1); } + + @Test + @DisplayName("특정 카테고리에 대해 특정 달 이전 가장 최근 소비목표 조회") + void findLatelyGoal_Success() { + // given + LocalDate targetMonth = LocalDate.of(2024, 7, 1); + User user = userRepository.save( + User.builder().email("email").age(24).name("name").phoneNumber("010-1234-5678").build()); + + Category category = categoryRepository.save( + Category.builder().name("유저 카테고리").user(user).isDefault(false).build()); + + consumptionGoalRepository.save(ConsumptionGoal.builder() + .goalAmount(1L) + .consumeAmount(1L) + .user(user) + .goalMonth(targetMonth) + .category(category) + .build()); + + LocalDate searchDate = LocalDate.of(2024, 9, 1); + + // when + ConsumptionGoal result = consumptionGoalRepository.findLatelyGoal(user.getId(), category.getId(), searchDate) + .get(); + + // then + assertEquals(result.getGoalMonth(), targetMonth); + } + + @Test + @DisplayName("소비목표가 여러 개 있을 경우, 특정 카테고리에 대해 특정 달 이전 가장 최근 소비목표 조회") + void findLatelyGoal_Success2() { + // given + LocalDate targetMonth = LocalDate.of(2024, 7, 1); + User user = userRepository.save( + User.builder().email("email").age(24).name("name").phoneNumber("010-1234-5678").build()); + + Category category = categoryRepository.save( + Category.builder().name("유저 카테고리").user(user).isDefault(false).build()); + + consumptionGoalRepository.save(ConsumptionGoal.builder() + .goalAmount(1L) + .consumeAmount(1L) + .user(user) + .goalMonth(targetMonth) + .category(category) + .build()); + + consumptionGoalRepository.save(ConsumptionGoal.builder() + .goalAmount(1L) + .consumeAmount(1L) + .user(user) + .goalMonth(targetMonth.minusMonths(1)) + .category(category) + .build()); + + // when + ConsumptionGoal result = consumptionGoalRepository.findLatelyGoal(user.getId(), category.getId(), targetMonth) + .get(); + + // then + assertEquals(result.getGoalMonth(), targetMonth); + } + + @Test + @DisplayName("또래 나이, 성별, 카테고리로 최대 소비 금액 조회 성공") + void findMaxConsumeAmountByCategory_Success() { + // when + int peerAgeStart = 23; + int peerAgeEnd = 25; + Gender peerGender = Gender.MALE; + + Optional result = consumptionGoalRepository.findMaxConsumeAmountByCategory( + peerAgeStart, peerAgeEnd, peerGender, currentMonth); + + // then + assertThat(result).isPresent(); + assertThat(result.get().getConsumeAmount()).isEqualTo(150L); + assertThat(result.get().getCategory().getId()).isEqualTo(defaultCategory2.getId()); + } + + @Test + @DisplayName("또래 나이, 성별, 카테고리로 최대 목표 금액 조회 성공") + void findMaxGoalAmountByCategory_Success() { + // when + int peerAgeStart = 23; + int peerAgeEnd = 25; + Gender peerGender = Gender.MALE; + + Optional result = consumptionGoalRepository.findMaxGoalAmountByCategory( + peerAgeStart, peerAgeEnd, peerGender, currentMonth); + + // then + assertThat(result).isPresent(); + assertThat(result.get().getGoalAmount()).isEqualTo(200L); + assertThat(result.get().getCategory().getId()).isEqualTo(defaultCategory2.getId()); + } } \ No newline at end of file diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalServiceTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalServiceTest.java index 8ec47b51..a010109f 100644 --- a/src/test/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalServiceTest.java +++ b/src/test/java/com/bbteam/budgetbuddies/domain/consumptiongoal/service/ConsumptionGoalServiceTest.java @@ -12,13 +12,12 @@ import java.util.List; import java.util.NoSuchElementException; import java.util.Optional; -import java.util.Random; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; +import org.mockito.AdditionalAnswers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; @@ -27,6 +26,7 @@ import com.bbteam.budgetbuddies.domain.category.entity.Category; import com.bbteam.budgetbuddies.domain.category.repository.CategoryRepository; +import com.bbteam.budgetbuddies.domain.category.service.CategoryService; import com.bbteam.budgetbuddies.domain.consumptiongoal.converter.ConsumptionGoalConverter; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.AllConsumptionCategoryResponseDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.AvgConsumptionGoalDto; @@ -41,19 +41,18 @@ import com.bbteam.budgetbuddies.domain.consumptiongoal.dto.TopCategoryConsumptionDto; import com.bbteam.budgetbuddies.domain.consumptiongoal.entity.ConsumptionGoal; import com.bbteam.budgetbuddies.domain.consumptiongoal.repository.ConsumptionGoalRepository; -import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseUpdateRequestDto; -import com.bbteam.budgetbuddies.domain.expense.entity.Expense; +import com.bbteam.budgetbuddies.domain.expense.repository.ExpenseRepository; import com.bbteam.budgetbuddies.domain.user.entity.User; import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import com.bbteam.budgetbuddies.domain.user.service.UserService; import com.bbteam.budgetbuddies.enums.Gender; @DisplayName("ConsumptionGoalImpl 서비스 테스트의 ") @ExtendWith(MockitoExtension.class) class ConsumptionGoalServiceTest { - private final LocalDate GOAL_MONTH = LocalDate.of(2024, 07, 01); private final LocalDate currentMonth = LocalDate.now().withDayOfMonth(1); private User user; - private LocalDate goalMonthRandomDay; + private LocalDate requestMonth; @InjectMocks private ConsumptionGoalServiceImpl consumptionGoalService; @Mock @@ -61,15 +60,19 @@ class ConsumptionGoalServiceTest { @Mock private CategoryRepository categoryRepository; @Mock + private CategoryService categoryService; + @Mock private UserRepository userRepository; + @Mock + private UserService userService; + @Mock + private ExpenseRepository expenseRepository; @Spy private ConsumptionGoalConverter consumptionGoalConverter; @BeforeEach void setUp() { - Random random = new Random(); - int randomDay = random.nextInt(30) + 1; - goalMonthRandomDay = LocalDate.of(GOAL_MONTH.getYear(), GOAL_MONTH.getMonth(), randomDay); + requestMonth = LocalDate.of(2024, 7, 15); user = Mockito.spy(User.builder() .email("email") @@ -81,26 +84,23 @@ void setUp() { } @Test - @DisplayName("findUserConsumptionGoal : 생성된 ConsumptionGoal이 없고 카테고리만 있는 경우 목표 금액, 소비 금액 0으로 초기화") - void findUserConsumptionGoal_onlyCategory() { + void 유저소비목표조회시_이번달_이전달_소비목표가_없는_경우_소비목표를_새로_생성() { // given Category defaultCategory = Mockito.spy(Category.builder().name("디폴트 카테고리").user(null).isDefault(true).build()); given(defaultCategory.getId()).willReturn(-1L); - Category userCategory = Mockito.spy(Category.builder().name("유저 카테고리").user(user).isDefault(false).build()); given(userCategory.getId()).willReturn(-2L); - List categoryList = List.of(defaultCategory, userCategory); + given(categoryService.getUserCategoryList(user.getId())).willReturn(List.of(defaultCategory, userCategory)); + given(consumptionGoalRepository.save(any(ConsumptionGoal.class))).will(AdditionalAnswers.returnsFirstArg()); List expected = List.of( new ConsumptionGoalResponseDto(defaultCategory.getName(), defaultCategory.getId(), 0L, 0L), new ConsumptionGoalResponseDto(userCategory.getName(), userCategory.getId(), 0L, 0L)); // when - when(categoryRepository.findUserCategoryByUserId(user.getId())).thenReturn(categoryList); - ConsumptionGoalResponseListDto result = consumptionGoalService.findUserConsumptionGoalList(user.getId(), - goalMonthRandomDay); + requestMonth); // then assertThat(result.getConsumptionGoalList()).usingRecursiveComparison().isEqualTo(expected); @@ -108,117 +108,105 @@ void findUserConsumptionGoal_onlyCategory() { } @Test - @DisplayName("findUserConsumptionGoal : 한달전 ConsumptionGoal만 있을 경우 한달전으로 초기화") - void findUserConsumptionGoal_previousMonth() { + void 소비목표조회시_지난달_소비목표만_존재할시_지난달_기준으로_소비목표를_생성() { // given Category defaultCategory = Mockito.spy(Category.builder().name("디폴트 카테고리").user(null).isDefault(true).build()); given(defaultCategory.getId()).willReturn(-1L); - Category userCategory = Mockito.spy(Category.builder().name("유저 카테고리").user(user).isDefault(false).build()); given(userCategory.getId()).willReturn(-2L); - given(categoryRepository.findUserCategoryByUserId(user.getId())).willReturn( - List.of(defaultCategory, userCategory)); + given(categoryService.getUserCategoryList(user.getId())).willReturn(List.of(defaultCategory, userCategory)); + given(userService.getUser(user.getId())).willReturn(user); ConsumptionGoal previousMonthDefaultCategoryGoal = ConsumptionGoal.builder() .goalAmount(1_000_000L) .consumeAmount(200_000L) .user(user) .category(defaultCategory) - .goalMonth(goalMonthRandomDay.minusMonths(1)) + .goalMonth(requestMonth.withDayOfMonth(1).minusMonths(1)) .build(); - Long previousMonthDefaultGoalRemainingBalance = - previousMonthDefaultCategoryGoal.getGoalAmount() - previousMonthDefaultCategoryGoal.getConsumeAmount(); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, defaultCategory, + requestMonth.withDayOfMonth(1))).willReturn(Optional.empty()); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, defaultCategory, + requestMonth.withDayOfMonth(1).minusMonths(1))).willReturn(Optional.of(previousMonthDefaultCategoryGoal)); ConsumptionGoal previousMonthUserCategoryGoal = ConsumptionGoal.builder() .goalAmount(1_000_000L) .consumeAmount(20_000L) .user(user) .category(userCategory) - .goalMonth(goalMonthRandomDay.minusMonths(1)) + .goalMonth(requestMonth.withDayOfMonth(1).minusMonths(1)) .build(); - Long previousMonthUseGoalRemainingBalance = - previousMonthUserCategoryGoal.getGoalAmount() - previousMonthUserCategoryGoal.getConsumeAmount(); - - List previousGoalList = List.of(previousMonthDefaultCategoryGoal, - previousMonthUserCategoryGoal); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, userCategory, + requestMonth.withDayOfMonth(1))).willReturn(Optional.empty()); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, userCategory, + requestMonth.withDayOfMonth(1).minusMonths(1))).willReturn(Optional.of(previousMonthUserCategoryGoal)); - List expected = List.of( - consumptionGoalConverter.toConsumptionGoalResponseDto(previousMonthUserCategoryGoal), - consumptionGoalConverter.toConsumptionGoalResponseDto(previousMonthDefaultCategoryGoal)); + given(consumptionGoalRepository.save(any(ConsumptionGoal.class))).will(AdditionalAnswers.returnsFirstArg()); // when - when(consumptionGoalRepository.findConsumptionGoalByUserIdAndGoalMonth(user.getId(), - GOAL_MONTH.minusMonths(1))).thenReturn(previousGoalList); - ConsumptionGoalResponseListDto result = consumptionGoalService.findUserConsumptionGoalList(user.getId(), - goalMonthRandomDay); + requestMonth); // then - assertThat(result.getConsumptionGoalList()).usingRecursiveComparison().isEqualTo(expected); - assertEquals(result.getTotalRemainingBalance(), - previousMonthDefaultGoalRemainingBalance + previousMonthUseGoalRemainingBalance); + assertThat(result.getTotalRemainingBalance()).isEqualTo(2_000_000L); + assertThat(result.getTotalConsumptionAmount()).isEqualTo(0L); } @Test - @DisplayName("findUserConsumptionGoal : 한달 전과 목표 달 ConsumptionGoal이 있을 경우 목표 달로 초기화") - void findUserConsumptionGoal_previousMonthAndGoalMonth() { + void 소비목표_조회시_이번달_소비목표가_존재하는_경우_이번달_소비목표를_반환() { // given + Category defaultCategory = Mockito.spy(Category.builder().name("디폴트 카테고리").user(null).isDefault(true).build()); + given(defaultCategory.getId()).willReturn(-1L); Category userCategory = Mockito.spy(Category.builder().name("유저 카테고리").user(user).isDefault(false).build()); given(userCategory.getId()).willReturn(-2L); - ConsumptionGoal previousMonthUserCategoryGoal = ConsumptionGoal.builder() + given(categoryService.getUserCategoryList(user.getId())).willReturn(List.of(defaultCategory, userCategory)); + given(userService.getUser(user.getId())).willReturn(user); + + ConsumptionGoal thisMonthDefaultCategoryGoal = ConsumptionGoal.builder() .goalAmount(1_000_000L) - .consumeAmount(20_000L) + .consumeAmount(200_000L) .user(user) - .category(userCategory) - .goalMonth(goalMonthRandomDay.minusMonths(1)) + .category(defaultCategory) + .goalMonth(requestMonth.withDayOfMonth(1)) .build(); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, defaultCategory, + requestMonth.withDayOfMonth(1))).willReturn(Optional.of(thisMonthDefaultCategoryGoal)); - ConsumptionGoal goalMonthUserCategoryGoal = ConsumptionGoal.builder() - .goalAmount(2_000_000L) - .consumeAmount(30_000L) + ConsumptionGoal thisMonthUserCategoryGoal = ConsumptionGoal.builder() + .goalAmount(1_000_000L) + .consumeAmount(20_000L) .user(user) .category(userCategory) - .goalMonth(goalMonthRandomDay) + .goalMonth(requestMonth.withDayOfMonth(1)) .build(); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, userCategory, + requestMonth.withDayOfMonth(1))).willReturn(Optional.of(thisMonthUserCategoryGoal)); // when - when(consumptionGoalRepository.findConsumptionGoalByUserIdAndGoalMonth(user.getId(), - GOAL_MONTH.minusMonths(1))).thenReturn(List.of(previousMonthUserCategoryGoal)); - - when(consumptionGoalRepository.findConsumptionGoalByUserIdAndGoalMonth(user.getId(), GOAL_MONTH)).thenReturn( - List.of(goalMonthUserCategoryGoal)); - ConsumptionGoalResponseListDto result = consumptionGoalService.findUserConsumptionGoalList(user.getId(), - goalMonthRandomDay); + requestMonth); // then - assertThat(result.getConsumptionGoalList()).usingRecursiveComparison() - .isEqualTo(List.of(consumptionGoalConverter.toConsumptionGoalResponseDto(goalMonthUserCategoryGoal))); + assertThat(result.getTotalRemainingBalance()).isEqualTo(1_780_000L); + assertThat(result.getTotalConsumptionAmount()).isEqualTo(220_000L); } @Test - @DisplayName("updateConsumptionGoal : 이번달 목표가 있는 경우(defaultCategory)와 목표가 없는 경우(userCategory)") - void updateConsumptionGoal_Success() { + void 소비목표_업데이트_성공() { // given - Long defaultGoalAmount = 100L; - Long userGoalAmount = 200L; + Long updateAmount = 100L; LocalDate thisMonth = LocalDate.now().withDayOfMonth(1); - given(userRepository.findById(user.getId())).willReturn(Optional.ofNullable(user)); + given(userService.getUser(user.getId())).willReturn(user); ConsumptionGoalListRequestDto request = new ConsumptionGoalListRequestDto( - List.of(new ConsumptionGoalRequestDto(-1L, defaultGoalAmount), - new ConsumptionGoalRequestDto(-2L, userGoalAmount))); + List.of(new ConsumptionGoalRequestDto(-1L, updateAmount))); Category defaultCategory = Mockito.spy(Category.builder().name("디폴트 카테고리").user(null).isDefault(true).build()); given(defaultCategory.getId()).willReturn(-1L); - given(categoryRepository.findById(defaultCategory.getId())).willReturn(Optional.of(defaultCategory)); - - Category userCategory = Mockito.spy(Category.builder().name("유저 카테고리").user(user).isDefault(false).build()); - given(userCategory.getId()).willReturn(-2L); - given(categoryRepository.findById(userCategory.getId())).willReturn(Optional.of(userCategory)); + given(categoryService.getCategory(defaultCategory.getId())).willReturn(defaultCategory); ConsumptionGoal defaultCategoryGoal = ConsumptionGoal.builder() .goalAmount(1_000_000L) @@ -227,27 +215,14 @@ void updateConsumptionGoal_Success() { .category(defaultCategory) .goalMonth(thisMonth) .build(); - given(consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, defaultCategory, - thisMonth)).willReturn(Optional.ofNullable(defaultCategoryGoal)); - - given(consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, userCategory, - thisMonth)).willReturn(Optional.ofNullable(null)); - - when(consumptionGoalRepository.saveAll(any())).thenAnswer(invocation -> { - List goalsToSave = invocation.getArgument(0); - return goalsToSave; - }); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, defaultCategory, thisMonth)).willReturn( + Optional.ofNullable(defaultCategoryGoal)); List expected = List.of(ConsumptionGoalResponseDto.builder() - .goalAmount(defaultGoalAmount) + .goalAmount(updateAmount) .consumeAmount(defaultCategoryGoal.getConsumeAmount()) .categoryName(defaultCategory.getName()) .categoryId(defaultCategory.getId()) - .build(), ConsumptionGoalResponseDto.builder() - .goalAmount(userGoalAmount) - .consumeAmount(0L) - .categoryName(userCategory.getName()) - .categoryId(userCategory.getId()) .build()); // when @@ -299,11 +274,7 @@ void getPeerInfo_UserNotFound() { @DisplayName("getTopCategoryAndConsumptionAmount : 가장 큰 계획 카테고리와 이번 주 소비 금액 조회 성공") void getTopCategoryAndConsumptionAmount_Success() { // given - Category defaultCategory = Mockito.spy(Category.builder() - .name("디폴트 카테고리") - .user(null) - .isDefault(true) - .build()); + Category defaultCategory = Mockito.spy(Category.builder().name("디폴트 카테고리").user(null).isDefault(true).build()); given(defaultCategory.getId()).willReturn(1L); LocalDate goalMonthRandomDay = LocalDate.now(); @@ -330,25 +301,21 @@ void getTopCategoryAndConsumptionAmount_Success() { .build(); List avgConsumptionGoalList = List.of( - new AvgConsumptionGoalDto(defaultCategory.getId(), 5000L) - ); + new AvgConsumptionGoalDto(defaultCategory.getId(), 5000L)); int peerAgeStart = 23; int peerAgeEnd = 25; Gender peerGender = Gender.MALE; given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); - given(consumptionGoalRepository.findAvgGoalAmountByCategory( - peerAgeStart, peerAgeEnd, peerGender, currentMonth)) - .willReturn(avgConsumptionGoalList); + given(consumptionGoalRepository.findAvgGoalAmountByCategory(peerAgeStart, peerAgeEnd, peerGender, + currentMonth)).willReturn(avgConsumptionGoalList); - given(consumptionGoalRepository.findAvgConsumptionByCategoryIdAndCurrentWeek( - defaultCategory.getId(), startOfWeekDateTime, endOfWeekDateTime, - peerAgeStart, peerAgeEnd, peerGender)) - .willReturn(Optional.of(currentWeekConsumptionGoal.getConsumeAmount())); + given(consumptionGoalRepository.findAvgConsumptionByCategoryIdAndCurrentWeek(defaultCategory.getId(), + startOfWeekDateTime, endOfWeekDateTime, peerAgeStart, peerAgeEnd, peerGender)).willReturn( + Optional.of(currentWeekConsumptionGoal.getConsumeAmount())); - given(categoryRepository.findById(defaultCategory.getId())) - .willReturn(Optional.of(defaultCategory)); + given(categoryRepository.findById(defaultCategory.getId())).willReturn(Optional.of(defaultCategory)); // when ConsumptionAnalysisResponseDto result = consumptionGoalService.getTopCategoryAndConsumptionAmount(user.getId()); @@ -383,17 +350,17 @@ void getTopConsumptionCategories_Success() { String peerGender = "MALE"; given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); - given(consumptionGoalRepository.findTopCategoriesByConsumptionCount(peerAgeStart, peerAgeEnd, - Gender.valueOf(peerGender), currentMonth.atStartOfDay())) - .willReturn(List.of(topConsumption1, topConsumption2, topConsumption3)); + given( + expenseRepository.findTopCategoriesByConsumptionCount(peerAgeStart, peerAgeEnd, Gender.valueOf(peerGender), + currentMonth.atStartOfDay())).willReturn(List.of(topConsumption1, topConsumption2, topConsumption3)); given(categoryRepository.findById(defaultCategory1.getId())).willReturn(Optional.of(defaultCategory1)); given(categoryRepository.findById(defaultCategory2.getId())).willReturn(Optional.of(defaultCategory2)); given(categoryRepository.findById(defaultCategory3.getId())).willReturn(Optional.of(defaultCategory3)); // when - List result = consumptionGoalService.getTopConsumptionCategories( - user.getId(), peerAgeStart, peerAgeEnd, peerGender); + List result = consumptionGoalService.getTopConsumptionCategories(user.getId(), + peerAgeStart, peerAgeEnd, peerGender); // then assertThat(result).hasSize(3); @@ -423,23 +390,19 @@ void getAllConsumptionGoalCategories_Success() { defaultCategory2.setName("디폴트 카테고리2"); defaultCategory2.setIsDefault(true); - List categoryAvgList = List.of( - new AvgConsumptionGoalDto(1L, 3000L), - new AvgConsumptionGoalDto(2L, 4000L) - ); + List categoryAvgList = List.of(new AvgConsumptionGoalDto(1L, 3000L), + new AvgConsumptionGoalDto(2L, 4000L)); - List myConsumptionAmountList = List.of( - new MyConsumptionGoalDto(1L, 5000L), - new MyConsumptionGoalDto(2L, 2000L) - ); + List myConsumptionAmountList = List.of(new MyConsumptionGoalDto(1L, 5000L), + new MyConsumptionGoalDto(2L, 2000L)); List defaultCategories = List.of(defaultCategory1, defaultCategory2); given(categoryRepository.findAllByIsDefaultTrue()).willReturn(defaultCategories); - given(consumptionGoalRepository.findAvgGoalAmountByCategory( - anyInt(), anyInt(), any(), any())).willReturn(categoryAvgList); - given(consumptionGoalRepository.findAllGoalAmountByUserId(user.getId())) - .willReturn(myConsumptionAmountList); + given(consumptionGoalRepository.findAvgGoalAmountByCategory(anyInt(), anyInt(), any(), any())).willReturn( + categoryAvgList); + given(consumptionGoalRepository.findAllGoalAmountByUserId(user.getId(), currentMonth)).willReturn( + myConsumptionAmountList); given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); // when @@ -475,21 +438,18 @@ void getAllConsumptionCategories_Success() { defaultCategory2.setName("디폴트 카테고리2"); defaultCategory2.setIsDefault(true); - List categoryAvgList = List.of( - new AvgConsumptionGoalDto(1L, 3000L), - new AvgConsumptionGoalDto(2L, 4000L) - ); + List categoryAvgList = List.of(new AvgConsumptionGoalDto(1L, 3000L), + new AvgConsumptionGoalDto(2L, 4000L)); - List myConsumptionAmountList = List.of( - new MyConsumptionGoalDto(1L, 5000L), - new MyConsumptionGoalDto(2L, 2000L) - ); + List myConsumptionAmountList = List.of(new MyConsumptionGoalDto(1L, 5000L), + new MyConsumptionGoalDto(2L, 2000L)); List defaultCategories = List.of(defaultCategory1, defaultCategory2); given(categoryRepository.findAllByIsDefaultTrue()).willReturn(defaultCategories); - given(consumptionGoalRepository.findAvgConsumptionAmountByCategory( - anyInt(), anyInt(), any(), any())).willReturn(categoryAvgList); + given( + consumptionGoalRepository.findAvgConsumptionAmountByCategory(anyInt(), anyInt(), any(), any())).willReturn( + categoryAvgList); given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); // when @@ -512,166 +472,84 @@ void getAllConsumptionCategories_Success() { } @Test - @DisplayName("지난 달, 이번 달 소비 목표가 없는 카테고리에 대한 소비 업데이트를 진행하는 경우 새로운 소비 목표를 생성해 소비 금액을 갱신") - void recalculateConsumptionAmount_notExistPreviousMonthAndThisMonthGoal() { + void 이번달_사용자_소비목표_조회하기() { // given - Category existGoalCategory = Category.builder().name("유저 카테고리").user(user).isDefault(false).build(); - Category notExistGoalCategory = Mockito.spy(Category.builder().name("디폴트 카테고리").isDefault(true).build()); - given(notExistGoalCategory.getId()).willReturn(-1L); - - Expense expense = Mockito.spy( - Expense.builder().category(existGoalCategory).expenseDate(GOAL_MONTH.atStartOfDay()).amount(1000L).build()); - when(expense.getId()).thenReturn(-1L); - - ExpenseUpdateRequestDto request = ExpenseUpdateRequestDto.builder() - .amount(1000L) - .expenseId(expense.getId()) - .expenseDate(LocalDate.of(2024, 8, 7).atStartOfDay()) - .categoryId(notExistGoalCategory.getId()) - .build(); + Category category = Mockito.spy(Category.builder().name("TEST CATEGORY").user(user).isDefault(false).build()); + LocalDate goalMonth = LocalDate.of(2024, 7, 1); - ConsumptionGoal oldGoal = ConsumptionGoal.builder().consumeAmount(1000L).category(existGoalCategory).build(); - ConsumptionGoal expected = ConsumptionGoal.builder() - .goalMonth(LocalDate.of(2024, 8, 1)) - .goalAmount(0L) - .consumeAmount(1000L) - .category(notExistGoalCategory) + ConsumptionGoal userConsumptionGoal = ConsumptionGoal.builder() + .goalAmount(1_000_000L) + .consumeAmount(200_000L) .user(user) + .category(category) + .goalMonth(goalMonth) .build(); - // when - when(consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, expense.getCategory(), - expense.getExpenseDate().toLocalDate().withDayOfMonth(1))).thenReturn(Optional.ofNullable(oldGoal)); - - when(categoryRepository.findById(request.getCategoryId())).thenReturn(Optional.of(notExistGoalCategory)); - when(consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, notExistGoalCategory, - request.getExpenseDate().toLocalDate().withDayOfMonth(1))).thenReturn(Optional.empty()); - - when(consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, notExistGoalCategory, - request.getExpenseDate().minusMonths(1).toLocalDate().withDayOfMonth(1))).thenReturn(Optional.empty()); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, category, goalMonth)).willReturn( + Optional.of(userConsumptionGoal)); - consumptionGoalService.recalculateConsumptionAmount(expense, request, user); - - ArgumentCaptor consumptionGoalCaptor = ArgumentCaptor.forClass(ConsumptionGoal.class); - verify(consumptionGoalRepository, times(2)).save(consumptionGoalCaptor.capture()); - - List savedConsumptionGoals = consumptionGoalCaptor.getAllValues(); + // when + ConsumptionGoal result = consumptionGoalService.getUserConsumptionGoal(user, category, goalMonth); // then - assertEquals(oldGoal.getConsumeAmount(), 0L); - assertThat(savedConsumptionGoals.get(1)).usingRecursiveComparison().isEqualTo(expected); + assertEquals(result, userConsumptionGoal); } @Test - @DisplayName("이번달 소비 목표가 없는 카테고리에 대한 소비 업데이트를 진행하는 경우 지난 달 소비 목표의 목표 금액을 복사한 이번 달 소비 목표를 생성해 소비 금액을 갱신") - void recalculateConsumptionAmount_notExistThisMonthGoal() { + void 이번달_소비목표가_없는경우_저번달_기준으로_소비목표_생성() { // given - Category existGoalCategory = Category.builder().name("유저 카테고리").user(user).isDefault(false).build(); - Category notExistThisMonthGoalCategory = Mockito.spy( - Category.builder().name("디폴트 카테고리").isDefault(true).build()); - given(notExistThisMonthGoalCategory.getId()).willReturn(-1L); - - Expense expense = Mockito.spy( - Expense.builder().category(existGoalCategory).expenseDate(GOAL_MONTH.atStartOfDay()).amount(1000L).build()); - when(expense.getId()).thenReturn(-1L); - - ExpenseUpdateRequestDto request = ExpenseUpdateRequestDto.builder() - .amount(1000L) - .expenseId(expense.getId()) - .expenseDate(LocalDate.of(2024, 8, 7).atStartOfDay()) - .categoryId(notExistThisMonthGoalCategory.getId()) - .build(); + Category category = Mockito.spy(Category.builder().name("TEST CATEGORY").user(user).isDefault(false).build()); + LocalDate goalMonth = LocalDate.of(2024, 7, 1); - ConsumptionGoal oldGoal = ConsumptionGoal.builder().consumeAmount(1000L).category(existGoalCategory).build(); - - ConsumptionGoal previousMonthGoal = ConsumptionGoal.builder() - .goalMonth(LocalDate.of(2024, 7, 1)) - .goalAmount(3000L) - .consumeAmount(3000L) - .category(notExistThisMonthGoalCategory) + ConsumptionGoal beforeUserConsumptionGoal = ConsumptionGoal.builder() + .goalAmount(1_000_000L) + .consumeAmount(200_000L) .user(user) + .category(category) + .goalMonth(LocalDate.of(2024, 6, 1)) .build(); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, category, goalMonth)).willReturn( + Optional.empty()); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, category, + goalMonth.minusMonths(1))).willReturn(Optional.of(beforeUserConsumptionGoal)); + given(consumptionGoalRepository.save(any(ConsumptionGoal.class))).will(AdditionalAnswers.returnsFirstArg()); ConsumptionGoal expected = ConsumptionGoal.builder() - .goalMonth(LocalDate.of(2024, 8, 1)) - .goalAmount(3000L) - .consumeAmount(1000L) - .category(notExistThisMonthGoalCategory) + .goalAmount(1_000_000L) + .consumeAmount(0L) .user(user) + .category(category) + .goalMonth(LocalDate.of(2024, 7, 1)) .build(); - // when - when(consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, expense.getCategory(), - expense.getExpenseDate().toLocalDate().withDayOfMonth(1))).thenReturn(Optional.ofNullable(oldGoal)); - - when(categoryRepository.findById(request.getCategoryId())).thenReturn( - Optional.of(notExistThisMonthGoalCategory)); - when(consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, - notExistThisMonthGoalCategory, request.getExpenseDate().toLocalDate().withDayOfMonth(1))).thenReturn( - Optional.empty()); - - when(consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, - notExistThisMonthGoalCategory, - request.getExpenseDate().minusMonths(1).toLocalDate().withDayOfMonth(1))).thenReturn( - Optional.ofNullable(previousMonthGoal)); - - consumptionGoalService.recalculateConsumptionAmount(expense, request, user); - - ArgumentCaptor consumptionGoalCaptor = ArgumentCaptor.forClass(ConsumptionGoal.class); - verify(consumptionGoalRepository, times(2)).save(consumptionGoalCaptor.capture()); - - List savedConsumptionGoals = consumptionGoalCaptor.getAllValues(); + ConsumptionGoal result = consumptionGoalService.getUserConsumptionGoal(user, category, goalMonth); // then - assertEquals(oldGoal.getConsumeAmount(), 0L); - assertThat(savedConsumptionGoals.get(1)).usingRecursiveComparison().isEqualTo(expected); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); } @Test - @DisplayName("이번달 소비 목표가 있는 경우 이번 달 소비 목표의 소비 금액을 갱신") - void recalculateConsumptionAmount_existThisMonthGoal() { + void 이번달_저번달_소비목표가_없는경우_소비목표를_새로_생성() { // given - Category existGoalCategory = Mockito.spy( - Category.builder().name("유저 카테고리").user(user).isDefault(false).build()); - given(existGoalCategory.getId()).willReturn(-1L); - - Expense expense = Mockito.spy( - Expense.builder().category(existGoalCategory).expenseDate(GOAL_MONTH.atStartOfDay()).amount(1000L).build()); - when(expense.getId()).thenReturn(-1L); - - ExpenseUpdateRequestDto request = ExpenseUpdateRequestDto.builder() - .amount(2000L) - .expenseId(expense.getId()) - .expenseDate(LocalDate.of(2024, 8, 7).atStartOfDay()) - .categoryId(existGoalCategory.getId()) - .build(); + Category category = Mockito.spy(Category.builder().name("TEST CATEGORY").user(user).isDefault(false).build()); + LocalDate goalMonth = LocalDate.of(2024, 7, 1); - ConsumptionGoal oldGoal = ConsumptionGoal.builder() - .goalMonth(LocalDate.of(2024, 8, 1)) - .goalAmount(3000L) - .consumeAmount(1000L) - .category(existGoalCategory) - .user(user) - .build(); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, category, goalMonth)).willReturn( + Optional.empty()); + given(consumptionGoalRepository.findByUserAndCategoryAndGoalMonth(user, category, + goalMonth.minusMonths(1))).willReturn(Optional.empty()); + given(consumptionGoalRepository.save(any(ConsumptionGoal.class))).will(AdditionalAnswers.returnsFirstArg()); ConsumptionGoal expected = ConsumptionGoal.builder() - .goalMonth(LocalDate.of(2024, 8, 1)) - .goalAmount(3000L) - .consumeAmount(2000L) - .category(existGoalCategory) + .goalAmount(0L) + .consumeAmount(0L) .user(user) + .category(category) + .goalMonth(LocalDate.of(2024, 7, 1)) .build(); - // when - when(consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, expense.getCategory(), - expense.getExpenseDate().toLocalDate().withDayOfMonth(1))).thenReturn(Optional.ofNullable(oldGoal)); - - when(categoryRepository.findById(request.getCategoryId())).thenReturn(Optional.of(existGoalCategory)); - when(consumptionGoalRepository.findConsumptionGoalByUserAndCategoryAndGoalMonth(user, existGoalCategory, - request.getExpenseDate().toLocalDate().withDayOfMonth(1))).thenReturn(Optional.ofNullable(oldGoal)); - - consumptionGoalService.recalculateConsumptionAmount(expense, request, user); + ConsumptionGoal result = consumptionGoalService.getUserConsumptionGoal(user, category, goalMonth); // then - assertThat(oldGoal).usingRecursiveComparison().isEqualTo(expected); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); } } \ No newline at end of file diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoServiceTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoServiceTest.java index eea83d2b..119a69da 100644 --- a/src/test/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoServiceTest.java +++ b/src/test/java/com/bbteam/budgetbuddies/domain/discountinfo/service/DiscountInfoServiceTest.java @@ -7,6 +7,9 @@ import com.bbteam.budgetbuddies.domain.discountinfo.repository.DiscountInfoRepository; import com.bbteam.budgetbuddies.domain.discountinfolike.entity.DiscountInfoLike; import com.bbteam.budgetbuddies.domain.discountinfolike.repository.DiscountInfoLikeRepository; +import com.bbteam.budgetbuddies.domain.connectedinfo.entity.ConnectedInfo; +import com.bbteam.budgetbuddies.domain.connectedinfo.repository.ConnectedInfoRepository; +import com.bbteam.budgetbuddies.domain.hashtag.repository.HashtagRepository; import com.bbteam.budgetbuddies.domain.user.entity.User; import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; import com.bbteam.budgetbuddies.enums.Gender; @@ -46,6 +49,12 @@ class DiscountInfoServiceTest { @Mock private UserRepository userRepository; + @Mock + private ConnectedInfoRepository connectedInfoRepository; + + @Mock + private HashtagRepository hashtagRepository; + @InjectMocks private DiscountInfoServiceImpl discountInfoService; @@ -79,24 +88,26 @@ void getDiscountsByYearAndMonthTest() { DiscountResponseDto dto1 = new DiscountResponseDto(); DiscountResponseDto dto2 = new DiscountResponseDto(); + when(connectedInfoRepository.findAllByDiscountInfo(discount1)).thenReturn(List.of()); + when(connectedInfoRepository.findAllByDiscountInfo(discount2)).thenReturn(List.of()); + when(discountInfoRepository.findByDateRange(startDate, endDate, pageable)).thenReturn(discountPage); - when(discountInfoConverter.toDto(discount1)).thenReturn(dto1); - when(discountInfoConverter.toDto(discount2)).thenReturn(dto2); + when(discountInfoConverter.toDto(discount1, List.of())).thenReturn(dto1); + when(discountInfoConverter.toDto(discount2, List.of())).thenReturn(dto2); // when Page result = discountInfoService.getDiscountsByYearAndMonth(2024, 7, 0, 10); // then // 1. 데이터가 2개인지 검증 - // 2. 각 데이터 내용이 일치하는지 검증 assertThat(result.getContent()).hasSize(2); + // 2. 각 데이터 내용이 일치하는지 검증 assertThat(result.getContent().get(0)).isEqualTo(dto1); assertThat(result.getContent().get(1)).isEqualTo(dto2); - // 3. 각 메소드가 1번씩만 호출되었는지 검증 verify(discountInfoRepository, times(1)).findByDateRange(startDate, endDate, pageable); - verify(discountInfoConverter, times(1)).toDto(discount1); - verify(discountInfoConverter, times(1)).toDto(discount2); + verify(discountInfoConverter, times(1)).toDto(discount1, List.of()); + verify(discountInfoConverter, times(1)).toDto(discount2, List.of()); } @Test @@ -110,6 +121,7 @@ void registerDiscountInfoTest() { .discountRate(30) .siteUrl("http://example.com") .thumbnailUrl("http://example.com2") + .hashtagIds(List.of()) .build(); DiscountInfo entity = DiscountInfo.builder() @@ -131,11 +143,12 @@ void registerDiscountInfoTest() { .thumbnailUrl("http://example.com2") .likeCount(0) .anonymousNumber(0) + .hashtags(List.of()) .build(); when(discountInfoConverter.toEntity(requestDto)).thenReturn(entity); when(discountInfoRepository.save(entity)).thenReturn(entity); - when(discountInfoConverter.toDto(entity)).thenReturn(responseDto); + when(discountInfoConverter.toDto(entity, List.of())).thenReturn(responseDto); // when DiscountResponseDto result = discountInfoService.registerDiscountInfo(requestDto); @@ -143,11 +156,10 @@ void registerDiscountInfoTest() { // then // 1. 데이터 내용이 일치하는지 검증 assertThat(result).isEqualTo(responseDto); - // 2. 각 메소드가 1번씩만 호출되었는지 검증 verify(discountInfoConverter, times(1)).toEntity(requestDto); verify(discountInfoRepository, times(1)).save(entity); - verify(discountInfoConverter, times(1)).toDto(entity); + verify(discountInfoConverter, times(1)).toDto(entity, List.of()); } @Test @@ -161,8 +173,6 @@ void toggleLikeTest() { .age(30) .gender(Gender.MALE) .email("john.doe@example.com") - .photoUrl("http://example.com/photo.jpg") - .consumptionPattern("Regular") .lastLoginAt(LocalDateTime.now()) .build(); @@ -195,11 +205,12 @@ void toggleLikeTest() { .siteUrl("http://example.com") .build(); + when(connectedInfoRepository.findAllByDiscountInfo(discountInfo)).thenReturn(List.of()); when(discountInfoRepository.save(any(DiscountInfo.class))).thenReturn(discountInfo); when(userRepository.findById(anyLong())).thenReturn(Optional.of(user)); when(discountInfoRepository.findById(anyLong())).thenReturn(Optional.of(discountInfo)); when(discountInfoLikeRepository.findByUserAndDiscountInfo(user, discountInfo)).thenReturn(Optional.of(discountInfoLike)); - when(discountInfoConverter.toDto(any(DiscountInfo.class))).thenReturn(responseDto); + when(discountInfoConverter.toDto(any(DiscountInfo.class), anyList())).thenReturn(responseDto); // when DiscountResponseDto result = discountInfoService.toggleLike(1L, 1L); @@ -207,16 +218,11 @@ void toggleLikeTest() { // then // 1. 결과 객체 비교 검증 assertThat(result).isEqualTo(responseDto); - // 2. 좋아요 개수 0 -> 1로 증가했는지 검증 assertThat(discountInfo.getLikeCount()).isEqualTo(1); - // 3. 각 메소드가 1번씩만 호출되었는지 검증 verify(discountInfoRepository, times(1)).findById(1L); verify(discountInfoRepository, times(1)).save(discountInfo); - verify(discountInfoConverter, times(1)).toDto(discountInfo); + verify(discountInfoConverter, times(1)).toDto(discountInfo, List.of()); } - - - -} \ No newline at end of file +} diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/expense/repository/ExpenseRepositoryTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/expense/repository/ExpenseRepositoryTest.java index c1b34ada..97806cd2 100644 --- a/src/test/java/com/bbteam/budgetbuddies/domain/expense/repository/ExpenseRepositoryTest.java +++ b/src/test/java/com/bbteam/budgetbuddies/domain/expense/repository/ExpenseRepositoryTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.*; import java.time.LocalDate; -import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -30,59 +29,63 @@ class ExpenseRepositoryTest { private CategoryRepository categoryRepository; @Test - @DisplayName("findAllByUserIdFOrPeriod 성공") - void findAllByUserIdForPeriod_Success() { + void 월별_소비조회_월초_월말_조회_성공() { // given - User user = userRepository.save( - User.builder().email("email").age(24).name("name").phoneNumber("010-1234-5678").build()); + User user = userRepository.save(User.builder() + .email("email") + .age(24) + .name("test user") + .phoneNumber("010-1234-5678") + .mobileCarrier("TEST") + .build()); Category userCategory = categoryRepository.save( Category.builder().name("유저 카테고리").user(user).isDefault(false).build()); - LocalDate startDate = LocalDate.of(2024, 7, 1); - LocalDate endDate = startDate.withDayOfMonth(startDate.lengthOfMonth()); + LocalDate startMonth = LocalDate.of(2024, 7, 1); + LocalDate nextMonth = startMonth.plusMonths(1L); - List expected = setExpense(user, userCategory, startDate); + List expected = getExpectedExpense(user, userCategory); + setUnExpectedExpense(user, userCategory, nextMonth); // when - List result = expenseRepository.findAllByUserIdForPeriod(user, - startDate.atStartOfDay(), endDate.atStartOfDay()); + List result = expenseRepository.findAllByUserIdForPeriod(user.getId(), startMonth.atStartOfDay(), + nextMonth.atStartOfDay()); assertThat(result).usingRecursiveComparison().isEqualTo(expected); } - private List setExpense(User user, Category userCategory, LocalDate startDate) { - setUnexpectedExpense(user, userCategory, startDate); - - return setExpectedExpenseOrderByDateDesc(user, userCategory, startDate); - } - - private List setExpectedExpenseOrderByDateDesc(User user, Category userCategory, LocalDate startDate) { - List expenses = new ArrayList<>(); - - for (int i = startDate.lengthOfMonth(); i > startDate.lengthOfMonth() - 5; i--) { - Expense expense = Expense.builder() - .user(user) - .category(userCategory) - .amount(100000L * i) - .expenseDate(startDate.withDayOfMonth(i).atStartOfDay()) - .build(); - - expenses.add(expenseRepository.save(expense)); - } - return expenses; + private List getExpectedExpense(User user, Category userCategory) { + Expense monthOfStartExpense = Expense.builder() + .user(user) + .category(userCategory) + .amount(100000L) + .expenseDate(LocalDate.of(2024, 7, 1).atStartOfDay()) + .build(); + + Expense monthOfLastExpense = Expense.builder() + .user(user) + .category(userCategory) + .amount(100000L) + .expenseDate(LocalDate.of(2024, 7, 31).atTime(23, 59)) + .build(); + + return List.of(expenseRepository.save(monthOfLastExpense), expenseRepository.save(monthOfStartExpense)); } - private void setUnexpectedExpense(User user, Category userCategory, LocalDate startDate) { - for (int i = 1; i <= 5; i++) { - Expense expense = Expense.builder() - .user(user) - .category(userCategory) - .amount(100000L * i) - .expenseDate(startDate.withMonth(8).atStartOfDay()) - .build(); - - expenseRepository.save(expense); - } + private void setUnExpectedExpense(User user, Category userCategory, LocalDate nextMonth) { + expenseRepository.save(Expense.builder() + .user(user) + .category(userCategory) + .amount(100000L) + .expenseDate(LocalDate.of(2024, 8, 1).atStartOfDay()) + .build()); + + expenseRepository.save(Expense.builder() + .user(user) + .category(userCategory) + .amount(100000L) + .expenseDate(LocalDate.of(2024, 6, 30).atTime(23, 59)) + .build()); } } \ No newline at end of file diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseServiceImplTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseServiceImplTest.java index 2c7a8484..3de8bbed 100644 --- a/src/test/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseServiceImplTest.java +++ b/src/test/java/com/bbteam/budgetbuddies/domain/expense/service/ExpenseServiceImplTest.java @@ -24,7 +24,7 @@ import com.bbteam.budgetbuddies.domain.expense.converter.ExpenseConverter; import com.bbteam.budgetbuddies.domain.expense.dto.CompactExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.DailyExpenseResponseDto; -import com.bbteam.budgetbuddies.domain.expense.dto.ExpenseResponseDto; +import com.bbteam.budgetbuddies.domain.expense.dto.DetailExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.dto.MonthlyExpenseResponseDto; import com.bbteam.budgetbuddies.domain.expense.entity.Expense; import com.bbteam.budgetbuddies.domain.expense.repository.ExpenseRepository; @@ -54,11 +54,8 @@ void setUp() { } @Test - @DisplayName("월별 소비 조회 소비를 DailyExpenseResponseDto로 반환") - void getMonthlyExpense_Success() { + void 월별_소비목록_조회_소비일을_기준으로_소비를_반환_성공() { // given - given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); - Category userCategory = Mockito.spy(Category.builder().build()); given(userCategory.getId()).willReturn(-1L); @@ -66,32 +63,10 @@ void getMonthlyExpense_Success() { List expenses = generateExpenseList(requestMonth, user, userCategory); - given(expenseRepository.findAllByUserIdForPeriod(any(User.class), any(LocalDateTime.class), + given(expenseRepository.findAllByUserIdForPeriod(anyLong(), any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(expenses); - MonthlyExpenseResponseDto expected = MonthlyExpenseResponseDto.builder() - .expenseMonth(LocalDate.of(2024, 07, 01)) - .totalConsumptionAmount(300_000L) - .dailyExpenses(List.of(DailyExpenseResponseDto.builder() - .daysOfMonth(2) - .daysOfTheWeek("화요일") - .expenses(List.of(CompactExpenseResponseDto.builder() - .amount(200_000L) - .description("User 소비") - .expenseId(-2L) - .categoryId(userCategory.getId()) - .build())) - .build(), DailyExpenseResponseDto.builder() - .daysOfMonth(1) - .daysOfTheWeek("월요일") - .expenses(List.of(CompactExpenseResponseDto.builder() - .amount(100_000L) - .description("User 소비") - .expenseId(-1L) - .categoryId(userCategory.getId()) - .build())) - .build())) - .build(); + MonthlyExpenseResponseDto expected = getExpectedMonthlyExpense(userCategory); // when MonthlyExpenseResponseDto result = expenseService.getMonthlyExpense(user.getId(), requestMonth); @@ -100,6 +75,35 @@ void getMonthlyExpense_Success() { assertThat(result).usingRecursiveComparison().isEqualTo(expected); } + private MonthlyExpenseResponseDto getExpectedMonthlyExpense(Category userCategory) { + return MonthlyExpenseResponseDto.builder() + .expenseMonth(LocalDate.of(2024, 7, 1)) + .totalConsumptionAmount(300_000L) + .dailyExpenses( + List.of( + DailyExpenseResponseDto.builder() + .daysOfMonth(2) + .daysOfTheWeek("화요일") + .expenses(List.of(CompactExpenseResponseDto.builder() + .amount(200_000L) + .description("User 소비") + .expenseId(-2L) + .categoryId(userCategory.getId()) + .build())) + .build(), + DailyExpenseResponseDto.builder() + .daysOfMonth(1) + .daysOfTheWeek("월요일") + .expenses(List.of(CompactExpenseResponseDto.builder() + .amount(100_000L) + .description("User 소비") + .expenseId(-1L) + .categoryId(userCategory.getId()) + .build())) + .build())) + .build(); + } + private List generateExpenseList(LocalDate month, User user, Category userCategory) { Expense e1 = Mockito.spy(Expense.builder() .amount(100_000L) @@ -123,8 +127,7 @@ private List generateExpenseList(LocalDate month, User user, Category u } @Test - @DisplayName("findExpenseFromUserIdAndExpenseId : 성공") - void findExpenseResponseFromUserIdAndExpenseId_Success() { + void 소비상세조회_성공() { // given final Long expenseId = -1L; @@ -135,8 +138,7 @@ void findExpenseResponseFromUserIdAndExpenseId_Success() { given(expense.getId()).willReturn(expenseId); given(expenseRepository.findById(expense.getId())).willReturn(Optional.of(expense)); - ExpenseResponseDto expected = ExpenseResponseDto.builder() - .userId(user.getId()) + DetailExpenseResponseDto expected = DetailExpenseResponseDto.builder() .expenseId(-1L) .description("유저 소비") .categoryName("유저 카테고리") @@ -144,15 +146,14 @@ void findExpenseResponseFromUserIdAndExpenseId_Success() { .build(); // when - ExpenseResponseDto result = expenseService.findExpenseResponseFromUserIdAndExpenseId(user.getId(), expenseId); + DetailExpenseResponseDto result = expenseService.findDetailExpenseResponse(user.getId(), expenseId); // then assertThat(result).usingRecursiveComparison().isEqualTo(expected); } @Test - @DisplayName("findExpenseFromUserIdAndExpenseId : 소비 유저와 다른 유저로 인한 예외 반환") - void findExpenseResponseFromUserIdAndExpenseId_Fail() { + void 조회_권한이_없는_유저의_소비조회로_인한_예외_반환() { // given final Long expenseId = -1L; @@ -164,6 +165,6 @@ void findExpenseResponseFromUserIdAndExpenseId_Fail() { // then assertThrows(IllegalArgumentException.class, - () -> expenseService.findExpenseResponseFromUserIdAndExpenseId(-2L, expenseId)); + () -> expenseService.findDetailExpenseResponse(-2L, expenseId)); } } \ No newline at end of file diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqRepositoryTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqRepositoryTest.java new file mode 100644 index 00000000..6263fd38 --- /dev/null +++ b/src/test/java/com/bbteam/budgetbuddies/domain/faq/repository/FaqRepositoryTest.java @@ -0,0 +1,101 @@ +package com.bbteam.budgetbuddies.domain.faq.repository; + +import com.bbteam.budgetbuddies.domain.faq.entity.Faq; +import com.bbteam.budgetbuddies.domain.faqkeyword.domain.FaqKeyword; +import com.bbteam.budgetbuddies.domain.faqkeyword.repository.FaqKeywordRepository; +import com.bbteam.budgetbuddies.domain.searchkeyword.domain.SearchKeyword; +import com.bbteam.budgetbuddies.domain.searchkeyword.repository.SearchKeywordRepository; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +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.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class FaqRepositoryTest { + @Autowired + FaqRepository faqRepository; + @Autowired + FaqKeywordRepository faqKeywordRepository; + @Autowired + SearchKeywordRepository searchKeywordRepository; + @Autowired + UserRepository userRepository; + + + @Test + void searchTest() { + + User user1 = User.builder() + .name("tester1") + .email("1234") + .age(5) + .phoneNumber("123456") + .mobileCarrier("skt") + .build(); + userRepository.save(user1); + + Faq faq1 = Faq.builder() + .user(user1) + .title("test1") + .body("test1") + .build(); + faqRepository.save(faq1); + + Faq faq2 = Faq.builder() + .user(user1) + .title("test2") + .body("test2") + .build(); + faqRepository.save(faq2); + + SearchKeyword keyword1 = SearchKeyword.builder() + .keyword("러닝") + .build(); + searchKeywordRepository.save(keyword1); + + FaqKeyword faqKeyword = FaqKeyword.builder() + .searchKeyword(keyword1) + .faq(faq1) + .build(); + faqKeywordRepository.save(faqKeyword); + + SearchKeyword keyword2 = SearchKeyword.builder() + .keyword("헬스") + .build(); + searchKeywordRepository.save(keyword2); + + FaqKeyword faqKeyword2 = FaqKeyword.builder() + .searchKeyword(keyword2) + .faq(faq1) + .build(); + faqKeywordRepository.save(faqKeyword2); + + FaqKeyword faqKeyword3 = FaqKeyword.builder() + .searchKeyword(keyword1) + .faq(faq2) + .build(); + faqKeywordRepository.save(faqKeyword3); + + PageRequest pageRequest = PageRequest.of(0, 5); + + Page result1 = faqRepository.searchFaq(pageRequest, "러닝"); + + Page result2 = faqRepository.searchFaq(pageRequest, "헬스"); + + assertThat(result1.getNumberOfElements()).isEqualTo(2); + assertThat(result2.getNumberOfElements()).isEqualTo(1); + + } + + +} \ No newline at end of file diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/faq/service/FaqServiceTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/faq/service/FaqServiceTest.java new file mode 100644 index 00000000..67d5e7b1 --- /dev/null +++ b/src/test/java/com/bbteam/budgetbuddies/domain/faq/service/FaqServiceTest.java @@ -0,0 +1,192 @@ +package com.bbteam.budgetbuddies.domain.faq.service; + +import com.bbteam.budgetbuddies.domain.faq.dto.FaqRequestDto; +import com.bbteam.budgetbuddies.domain.faq.dto.FaqResponseDto; +import com.bbteam.budgetbuddies.domain.faq.entity.Faq; +import com.bbteam.budgetbuddies.domain.faq.repository.FaqRepository; +import com.bbteam.budgetbuddies.domain.faqkeyword.dto.FaqKeywordResponseDto; +import com.bbteam.budgetbuddies.domain.faqkeyword.repository.FaqKeywordRepository; +import com.bbteam.budgetbuddies.domain.searchkeyword.domain.SearchKeyword; +import com.bbteam.budgetbuddies.domain.searchkeyword.repository.SearchKeywordRepository; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +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.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.annotation.Rollback; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@Transactional +class FaqServiceTest { + + @Autowired + FaqService faqService; + @Autowired + FaqRepository faqRepository; + @Autowired + UserRepository userRepository; + @Autowired + FaqKeywordRepository faqKeywordRepository; + @Autowired + SearchKeywordRepository searchKeywordRepository; + + static Long userId; + static Long faqId; + + @BeforeEach + void init() { + User user = User.builder() + .email("changjun157@naver.com") + .phoneNumber("010-1234-1234") + .name("tester1") + .mobileCarrier("kt") + .build(); + + userRepository.save(user); + userId = user.getId(); + + Faq faq = Faq.builder() + .title("testtitle1") + .body("testbody") + .user(user) + .build(); + faqRepository.save(faq); + faqId = faq.getId(); + } + + @Test + void findOneFaq() { + FaqResponseDto.FaqFindResponse response = faqService.findOneFaq(faqId); + + assertThat(response.getBody()).isEqualTo("testbody"); + assertThat(response.getTitle()).isEqualTo("testtitle1"); + } + + @Test + void findAllWithPaging() { + User user = userRepository.findById(userId).get(); + + Faq faq1 = Faq.builder() + .title("test1") + .body("test1") + .user(user) + .build(); + faqRepository.save(faq1); + + Faq faq2 = Faq.builder() + .title("test2") + .body("test2") + .user(user) + .build(); + faqRepository.save(faq2); + + Faq faq3 = Faq.builder() + .title("test3") + .body("test3") + .user(user) + .build(); + faqRepository.save(faq3); + + Faq faq4 = Faq.builder() + .title("test4") + .body("test4") + .user(user) + .build(); + faqRepository.save(faq4); + + Faq faq5 = Faq.builder() + .title("test5") + .body("test5") + .user(user) + .build(); + faqRepository.save(faq5); + + PageRequest pageRequest = PageRequest.of(0, 2); + Page page1 = faqService.findAllWithPaging(pageRequest); + assertThat(page1.getNumberOfElements()).isEqualTo(2); + assertThat(page1.getTotalPages()).isEqualTo(3); + } + + @Test + void postFaq() { + FaqRequestDto.FaqPostRequest dto = FaqRequestDto.FaqPostRequest.builder() + .body("안녕하세요") + .title("테스트입니다.") + .build(); + + FaqResponseDto.FaqPostResponse response = faqService.postFaq(dto, userId); + + Faq faq = faqRepository.findById(response.getFaqId()).get(); + + assertThat(faq.getBody()).isEqualTo("안녕하세요"); + assertThat(faq.getTitle()).isEqualTo("테스트입니다."); + } + @Test + void modifyFaq() { + User user = userRepository.findById(userId).get(); + Faq faq = faqRepository.findById(faqId).get(); + + FaqRequestDto.FaqModifyRequest dto = FaqRequestDto.FaqModifyRequest.builder() + .title("modititle") + .body("modibody") + .build(); + + faqService.modifyFaq(dto, faqId); + + assertThat(faq.getTitle()).isEqualTo("modititle"); + assertThat(faq.getBody()).isEqualTo("modibody"); + + + } + + + @Test + void deleteFaq() { + faqService.deleteFaq(faqId); + Optional faq = faqRepository.findById(faqId); + + assertThat(faq.isEmpty()).isTrue(); + } + + @Test + void addKeywordAndRemoveKeyword() { + User user = userRepository.findById(userId).get(); + + Faq faq1 = Faq.builder() + .title("test1") + .body("test1") + .user(user) + .build(); + faqRepository.save(faq1); + + SearchKeyword searchKeyword = SearchKeyword.builder() + .keyword("testKeyword") + .build(); + searchKeywordRepository.save(searchKeyword); + faqService.addKeyword(faq1.getId(), searchKeyword.getId()); + PageRequest pageRequest = PageRequest.of(0, 1); + + Page result1 = faqService.searchFaq(pageRequest, "test"); + Assertions.assertThat(result1.getNumberOfElements()).isEqualTo(1); + + Page result2 = faqService.searchFaq(pageRequest, "no"); + Assertions.assertThat(result2.getNumberOfElements()).isEqualTo(0); + + faqService.removeKeyword(faq1.getId(), searchKeyword.getId()); + Page result3 = faqService.searchFaq(pageRequest, "test"); + Assertions.assertThat(result3.getNumberOfElements()).isEqualTo(0); + + + } +} \ No newline at end of file diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/favoritehashtag/service/FavoriteHashtagServiceTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/favoritehashtag/service/FavoriteHashtagServiceTest.java new file mode 100644 index 00000000..49195057 --- /dev/null +++ b/src/test/java/com/bbteam/budgetbuddies/domain/favoritehashtag/service/FavoriteHashtagServiceTest.java @@ -0,0 +1,100 @@ +package com.bbteam.budgetbuddies.domain.favoritehashtag.service; + +import com.bbteam.budgetbuddies.domain.connectedinfo.entity.ConnectedInfo; +import com.bbteam.budgetbuddies.domain.connectedinfo.repository.ConnectedInfoRepository; +import com.bbteam.budgetbuddies.domain.discountinfo.entity.DiscountInfo; +import com.bbteam.budgetbuddies.domain.discountinfo.repository.DiscountInfoRepository; +import com.bbteam.budgetbuddies.domain.favoritehashtag.dto.FavoriteHashtagResponseDto; +import com.bbteam.budgetbuddies.domain.favoritehashtag.entity.FavoriteHashtag; +import com.bbteam.budgetbuddies.domain.favoritehashtag.repository.FavoriteHashtagRepository; +import com.bbteam.budgetbuddies.domain.hashtag.entity.Hashtag; +import com.bbteam.budgetbuddies.domain.hashtag.repository.HashtagRepository; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +public class FavoriteHashtagServiceTest { + + @Autowired + private FavoriteHashtagRepository favoriteHashtagRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private HashtagRepository hashtagRepository; + + @Autowired + private FavoriteHashtagService favoriteHashtagService; + + @Autowired + private ConnectedInfoRepository connectedInfoRepository; + + @Autowired + private DiscountInfoRepository discountInfoRepository; + + private User user; + private Hashtag hashtag; + private DiscountInfo discountInfo; + + @BeforeEach + void setUp() { + // 데이터 초기화 + connectedInfoRepository.deleteAll(); + favoriteHashtagRepository.deleteAll(); + discountInfoRepository.deleteAll(); + hashtagRepository.deleteAll(); + userRepository.deleteAll(); + + // Given: 사용자와 해시태그, 할인정보를 생성 + user = userRepository.save(User.builder() + .phoneNumber("01012345678") + .name("Test User") + .age(25) + .email("test1@example.com") + .build()); + + hashtag = hashtagRepository.save(new Hashtag("식비")); + discountInfo = discountInfoRepository.save(DiscountInfo.withId(1L)); + + favoriteHashtagRepository.save(FavoriteHashtag.builder().user(user).hashtag(hashtag).build()); + + connectedInfoRepository.save(ConnectedInfo.builder() + .discountInfo(discountInfo) + .hashtag(hashtag) + .build()); + } + + @Test + void testSaveFavoriteHashtags() { + // When: 사용자가 관심 있는 해시태그를 선택하여 저장함 + FavoriteHashtag favorite1 = FavoriteHashtag.builder().user(user).hashtag(hashtag).build(); + favoriteHashtagRepository.save(favorite1); + + // Then: FavoriteHashtag에 잘 저장되었는지 검증 + List favorites = favoriteHashtagRepository.findByUser(user); + assertThat(favorites).hasSize(1); + assertThat(favorites).extracting(fav -> fav.getHashtag().getName()) + .containsExactly("식비"); + } + + @Test + void testFindUsersByHashtagForDiscountInfo() { + // When: 할인정보에 연결된 해시태그를 기반으로 사용자를 조회 + List userResponses = favoriteHashtagService.findUsersByHashtag(discountInfo.getId(), hashtag.getId()); + + // Then: 해당 해시태그를 가진 유저가 응답되는지 확인 + assertThat(userResponses).hasSize(1); + assertThat(userResponses.get(0).getUserId()).isEqualTo(user.getId()); + } +} diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/notice/service/NoticeServiceTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/notice/service/NoticeServiceTest.java new file mode 100644 index 00000000..0456d9e0 --- /dev/null +++ b/src/test/java/com/bbteam/budgetbuddies/domain/notice/service/NoticeServiceTest.java @@ -0,0 +1,164 @@ +package com.bbteam.budgetbuddies.domain.notice.service; + +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeRequestDto; +import com.bbteam.budgetbuddies.domain.notice.dto.NoticeResponseDto; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import org.assertj.core.api.Assertions; +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.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.NoSuchElementException; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@Transactional +class NoticeServiceTest { + + @Autowired + NoticeService noticeService; + @Autowired + UserRepository userRepository; + + @Test + void save() { + User user1 = User.builder() + .name("tester1") + .email("1234") + .age(5) + .phoneNumber("123456") + .build(); + userRepository.save(user1); + + NoticeRequestDto dto = NoticeRequestDto.builder() + .body("바바이") + .title("헬로우") + .build(); + NoticeResponseDto result = noticeService.save(dto, user1.getId()); + + Assertions.assertThat(result.getUserName()).isEqualTo(user1.getName()); + Assertions.assertThat(result.getTitle()).isEqualTo("헬로우"); + Assertions.assertThat(result.getBody()).isEqualTo("바바이"); + } + + @Test + void findOne() { + User user1 = User.builder() + .name("tester1") + .email("1234") + .age(5) + .phoneNumber("123456") + .build(); + userRepository.save(user1); + + NoticeRequestDto dto = NoticeRequestDto.builder() + .body("바바이") + .title("헬로우") + .build(); + NoticeResponseDto result = noticeService.save(dto, user1.getId()); + + NoticeResponseDto one = noticeService.findOne(result.getNoticeId()); + + Assertions.assertThat(result.getNoticeId()).isEqualTo(one.getNoticeId()); + Assertions.assertThat(result.getUserName()).isEqualTo(one.getUserName()); + } + + @Test + void findAll() { + User user1 = User.builder() + .name("tester1") + .email("1234") + .age(5) + .phoneNumber("123456") + .build(); + userRepository.save(user1); + + NoticeRequestDto dto = NoticeRequestDto.builder() + .body("바바이") + .title("헬로우") + .build(); + NoticeResponseDto result = noticeService.save(dto, user1.getId()); + NoticeRequestDto dto2 = NoticeRequestDto.builder() + .body("바바이") + .title("헬로우") + .build(); + NoticeResponseDto result2 = noticeService.save(dto2, user1.getId()); + NoticeRequestDto dto3 = NoticeRequestDto.builder() + .body("바바이") + .title("헬로우") + .build(); + NoticeResponseDto result3 = noticeService.save(dto3, user1.getId()); + NoticeRequestDto dto4 = NoticeRequestDto.builder() + .body("바바이") + .title("헬로우") + .build(); + NoticeResponseDto result4 = noticeService.save(dto4, user1.getId()); + + PageRequest request1 = PageRequest.of(0, 2); + Page list1 = noticeService.findAll(request1); + Assertions.assertThat(list1.getNumberOfElements()).isEqualTo(2); + + PageRequest request2 = PageRequest.of(1, 2); + Page list2 = noticeService.findAll(request2); + Assertions.assertThat(list2.getNumberOfElements()).isEqualTo(2); + + PageRequest request3 = PageRequest.of(2, 2); + Page list3 = noticeService.findAll(request3); + Assertions.assertThat(list3.getNumberOfElements()).isEqualTo(0); + } + + @Test + void update() { + User user1 = User.builder() + .name("tester1") + .email("1234") + .age(5) + .phoneNumber("123456") + .build(); + userRepository.save(user1); + + NoticeRequestDto dto = NoticeRequestDto.builder() + .body("바바이") + .title("헬로우") + .build(); + NoticeResponseDto result = noticeService.save(dto, user1.getId()); + + NoticeRequestDto updateDto = NoticeRequestDto.builder() + .body("좋아요") + .title("아니에요!") + .build(); + NoticeResponseDto update = noticeService.update(result.getNoticeId(), updateDto); + + Assertions.assertThat(update.getNoticeId()).isEqualTo(result.getNoticeId()); + Assertions.assertThat(update.getBody()).isEqualTo("좋아요"); + Assertions.assertThat(update.getTitle()).isEqualTo("아니에요!"); + } + + @Test + void delete() { + User user1 = User.builder() + .name("tester1") + .email("1234") + .age(5) + .phoneNumber("123456") + .build(); + userRepository.save(user1); + + NoticeRequestDto dto = NoticeRequestDto.builder() + .body("바바이") + .title("헬로우") + .build(); + NoticeResponseDto result = noticeService.save(dto, user1.getId()); + + noticeService.delete(result.getNoticeId()); + + assertThrows(NoSuchElementException.class, () -> noticeService.findOne(result.getNoticeId())); + } + + +} \ No newline at end of file diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/report/repository/ReportRepositoryTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/report/repository/ReportRepositoryTest.java new file mode 100644 index 00000000..6e7345c7 --- /dev/null +++ b/src/test/java/com/bbteam/budgetbuddies/domain/report/repository/ReportRepositoryTest.java @@ -0,0 +1,48 @@ +package com.bbteam.budgetbuddies.domain.report.repository; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import com.bbteam.budgetbuddies.domain.comment.entity.Comment; +import com.bbteam.budgetbuddies.domain.comment.repository.CommentRepository; +import com.bbteam.budgetbuddies.domain.report.entity.Report; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; +import com.bbteam.budgetbuddies.enums.Gender; + +@DisplayName("Report 레포지토리 테스트의") +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ReportRepositoryTest { + @Autowired + ReportRepository reportRepository; + + @Autowired + UserRepository userRepository; + @Autowired + CommentRepository commentRepository; + + @Test + void userId와_commentId를_통해서_Report_존재_여부_조회() { + User user = userRepository.save(User.builder() + .email("testUser@example.com") + .mobileCarrier("TEST") + .age(24) + .name("Test User") + .gender(Gender.MALE) + .phoneNumber("010-1234-5678") + .build()); + + Comment comment = commentRepository.save( + Comment.builder().user(user).content("test2").anonymousNumber(1212).build()); + + reportRepository.save(Report.builder().user(user).comment(comment).reason("test1").build()); + + assertTrue(reportRepository.existsByUser_IdAndComment_Id(user.getId(), comment.getId())); + } +} \ No newline at end of file diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/report/service/ReportServiceImplTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/report/service/ReportServiceImplTest.java new file mode 100644 index 00000000..f03d0a6c --- /dev/null +++ b/src/test/java/com/bbteam/budgetbuddies/domain/report/service/ReportServiceImplTest.java @@ -0,0 +1,75 @@ +package com.bbteam.budgetbuddies.domain.report.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +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.Mockito; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.bbteam.budgetbuddies.domain.comment.entity.Comment; +import com.bbteam.budgetbuddies.domain.comment.repository.CommentRepository; +import com.bbteam.budgetbuddies.domain.report.convertor.ReportConvertor; +import com.bbteam.budgetbuddies.domain.report.dto.request.ReportRequestDto; +import com.bbteam.budgetbuddies.domain.report.dto.response.ReportResponseDto; +import com.bbteam.budgetbuddies.domain.report.entity.Report; +import com.bbteam.budgetbuddies.domain.report.repository.ReportRepository; +import com.bbteam.budgetbuddies.domain.user.entity.User; +import com.bbteam.budgetbuddies.domain.user.repository.UserRepository; + +@DisplayName("Report 서비스 테스트의 ") +@ExtendWith(MockitoExtension.class) +class ReportServiceImplTest { + @InjectMocks + ReportServiceImpl reportService; + + @Mock + ReportRepository reportRepository; + @Mock + UserRepository userRepository; + @Mock + CommentRepository commentRepository; + @Spy + ReportConvertor reportConvertor; + + User user; + Comment comment; + + @BeforeEach + void setUp() { + user = Mockito.spy(User.builder().build()); + given(user.getId()).willReturn(-1L); + + comment = Mockito.spy(Comment.builder().build()); + given(comment.getId()).willReturn(-1L); + } + + @Test + void 댓글에_대한_신고_생성_성공() { + // given + ReportRequestDto request = Mockito.spy(new ReportRequestDto()); + given(request.getReason()).willReturn("TEST"); + + // when + when(userRepository.findById(user.getId())).thenReturn(Optional.ofNullable(user)); + when(commentRepository.findById(comment.getId())).thenReturn(Optional.ofNullable(comment)); + when(reportRepository.save(any(Report.class))) + .thenAnswer(invocation -> { + Report spyReport = spy((Report)invocation.getArgument(0)); + when(spyReport.getId()).thenReturn(-1L); + return spyReport; + }); + ReportResponseDto result = reportService.reportComment(request, user.getId(), comment.getId()); + + // then + assertThat(result.getReportId()).isEqualTo(-1L); + } +} \ No newline at end of file diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoServiceTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoServiceTest.java index 258c674b..4d8fd68e 100644 --- a/src/test/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoServiceTest.java +++ b/src/test/java/com/bbteam/budgetbuddies/domain/supportinfo/service/SupportInfoServiceTest.java @@ -1,5 +1,7 @@ package com.bbteam.budgetbuddies.domain.supportinfo.service; +import com.bbteam.budgetbuddies.domain.connectedinfo.repository.ConnectedInfoRepository; +import com.bbteam.budgetbuddies.domain.hashtag.repository.HashtagRepository; import com.bbteam.budgetbuddies.domain.supportinfo.converter.SupportInfoConverter; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportRequest; import com.bbteam.budgetbuddies.domain.supportinfo.dto.SupportResponseDto; @@ -48,6 +50,12 @@ class SupportInfoServiceTest { @Mock private UserRepository userRepository; + @Mock + private ConnectedInfoRepository connectedInfoRepository; + + @Mock + private HashtagRepository hashtagRepository; + @InjectMocks private SupportInfoServiceImpl supportInfoService; @@ -79,9 +87,12 @@ void getSupportsByYearAndMonthTest() { SupportResponseDto dto1 = new SupportResponseDto(); SupportResponseDto dto2 = new SupportResponseDto(); + when(connectedInfoRepository.findAllBySupportInfo(support1)).thenReturn(List.of()); + when(connectedInfoRepository.findAllBySupportInfo(support2)).thenReturn(List.of()); + when(supportInfoRepository.findByDateRange(startDate, endDate, pageable)).thenReturn(supportPage); - when(supportInfoConverter.toDto(support1)).thenReturn(dto1); - when(supportInfoConverter.toDto(support2)).thenReturn(dto2); + when(supportInfoConverter.toDto(support1, List.of())).thenReturn(dto1); + when(supportInfoConverter.toDto(support2, List.of())).thenReturn(dto2); // when Page result = supportInfoService.getSupportsByYearAndMonth(2024, 7, 0, 10); @@ -95,8 +106,8 @@ void getSupportsByYearAndMonthTest() { // 3. 각 메소드가 1번씩만 호출되었는지 검증 verify(supportInfoRepository, times(1)).findByDateRange(startDate, endDate, pageable); - verify(supportInfoConverter, times(1)).toDto(support1); - verify(supportInfoConverter, times(1)).toDto(support2); + verify(supportInfoConverter, times(1)).toDto(support1, List.of()); + verify(supportInfoConverter, times(1)).toDto(support2, List.of()); } @Test @@ -129,7 +140,7 @@ void registerSupportInfoTest() { when(supportInfoConverter.toEntity(requestDto)).thenReturn(entity); when(supportInfoRepository.save(entity)).thenReturn(entity); - when(supportInfoConverter.toDto(entity)).thenReturn(responseDto); + when(supportInfoConverter.toDto(entity, List.of())).thenReturn(responseDto); // when SupportResponseDto result = supportInfoService.registerSupportInfo(requestDto); @@ -141,7 +152,7 @@ void registerSupportInfoTest() { // 2. 각 메소드가 1번씩만 호출되었는지 검증 verify(supportInfoConverter, times(1)).toEntity(requestDto); verify(supportInfoRepository, times(1)).save(entity); - verify(supportInfoConverter, times(1)).toDto(entity); + verify(supportInfoConverter, times(1)).toDto(entity, List.of()); } @Test @@ -155,8 +166,8 @@ void toggleLikeTest() { .age(30) .gender(Gender.MALE) .email("john.doe@example.com") - .photoUrl("http://example.com/photo.jpg") - .consumptionPattern("Regular") +// .photoUrl("http://example.com/photo.jpg") +// .consumptionPattern("Regular") .lastLoginAt(LocalDateTime.now()) .build(); @@ -187,11 +198,12 @@ void toggleLikeTest() { .siteUrl("http://example.com") .build(); + when(connectedInfoRepository.findAllBySupportInfo(supportInfo)).thenReturn(List.of()); when(supportInfoRepository.save(any(SupportInfo.class))).thenReturn(supportInfo); when(userRepository.findById(anyLong())).thenReturn(Optional.of(user)); when(supportInfoRepository.findById(anyLong())).thenReturn(Optional.of(supportInfo)); when(supportInfoLikeRepository.findByUserAndSupportInfo(user, supportInfo)).thenReturn(Optional.of(supportInfoLike)); - when(supportInfoConverter.toDto(any(SupportInfo.class))).thenReturn(responseDto); + when(supportInfoConverter.toDto(any(SupportInfo.class), anyList())).thenReturn(responseDto); // when SupportResponseDto result = supportInfoService.toggleLike(1L, 1L); @@ -206,7 +218,7 @@ void toggleLikeTest() { // 3. 각 메소드가 1번씩만 호출되었는지 검증 verify(supportInfoRepository, times(1)).findById(1L); verify(supportInfoRepository, times(1)).save(supportInfo); - verify(supportInfoConverter, times(1)).toDto(supportInfo); + verify(supportInfoConverter, times(1)).toDto(supportInfo, List.of()); } } \ No newline at end of file diff --git a/src/test/java/com/bbteam/budgetbuddies/domain/user/repository/UserRepositoryTest.java b/src/test/java/com/bbteam/budgetbuddies/domain/user/repository/UserRepositoryTest.java index f89b41ba..7fa143f9 100644 --- a/src/test/java/com/bbteam/budgetbuddies/domain/user/repository/UserRepositoryTest.java +++ b/src/test/java/com/bbteam/budgetbuddies/domain/user/repository/UserRepositoryTest.java @@ -30,8 +30,8 @@ void saveUser() { .age(25) .gender(Gender.MALE) .email("hong@naver.com") - .photoUrl("abc") - .consumptionPattern("TypeA") +// .photoUrl("abc") +// .consumptionPattern("TypeA") .lastLoginAt(LocalDateTime.now()) .createdAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now())