From dc8c0c2ce34750e9183a0289a9af3ad478ad5deb Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 31 Jan 2025 16:12:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20Spending=20History=20Sharin?= =?UTF-8?q?g=20API=20(#233)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add spending list read method with day to spending repository * feat: add spending list read method with day to spending service * feat: impl daily spending aggregate service * test: aggregate slice test * fix: change return type to pair * rename: fix test name * feat: spending-chat-share-event * feat: add spending-chat-share-exchange-properties * chore: add spending chat share property to infra yml * feat: impl spending chat share event handler * fix: spending-on-dates type error in the spending-chat-share-event * feat: impl spending-chat-share-helper * feat: add share-to-chat-room to spending usecase * feat: add invalid share type error code to spending-error-code * feat: impl spending share enum type * feat: add spending-chat-share-query dto * feat: add missing-share-param error code to spending error code * feat: add share-spending api to controller * docs: write swagger docs about spending share api * chore: apply binder & queue & event handler for share to chat room * fix: invalid validation in the spending chat share event's name field * feat: add share constant to the message category type * feat: add default create message method to the send-message-command * fix: add sender id field to spending-chat-share-event * fix: add user id to event parameter when publish event * feat: impl sending-share-event listener * fix: add headers to send message command * refactor: convert spending-share-event-listener to kotlin * fix: add date field to spending-chat-share-event * fix: add date to chat message's header --- .../api/apis/ledger/api/SpendingApi.java | 27 ++++++- .../ledger/controller/SpendingController.java | 26 +++++++ .../api/apis/ledger/dto/SpendingShareReq.java | 20 +++++ .../helper/SpendingChatShareHelper.java | 49 ++++++++++++ .../DailySpendingAggregateService.java | 39 ++++++++++ .../apis/ledger/usecase/SpendingUseCase.java | 7 ++ .../converter/SpendingShareTypeConverter.java | 17 ++++ .../api/common/query/SpendingShareType.java | 11 +++ .../DailySpendingAggregateServiceTest.java | 78 +++++++++++++++++++ .../spending/exception/SpendingErrorCode.java | 2 + .../repository/SpendingCustomRepository.java | 2 + .../SpendingCustomRepositoryImpl.java | 14 ++++ .../spending/service/SpendingRdbService.java | 5 ++ .../message/type/MessageCategoryType.java | 3 +- .../common/event/SpendingChatShareEvent.java | 53 +++++++++++++ .../event/SpendingChatShareEventHandler.java | 36 +++++++++ .../SpendingChatShareExchangeProperties.java | 21 +++++ .../infra/config/MessageBrokerConfig.java | 23 +++++- .../src/main/resources/application-infra.yml | 3 + .../socket/command/SendMessageCommand.java | 21 ++++- .../relay/SpendingShareEventListener.kt | 65 ++++++++++++++++ .../socket/service/ChatMessageSendService.kt | 3 +- 22 files changed, 516 insertions(+), 9 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingShareReq.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/helper/SpendingChatShareHelper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingShareTypeConverter.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingShareType.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateServiceTest.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEvent.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEventHandler.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/SpendingChatShareExchangeProperties.java create mode 100644 pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/SpendingShareEventListener.kt diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java index c0ba48b1e..04a277bd5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -4,16 +4,14 @@ 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.media.SchemaProperty; +import io.swagger.v3.oas.annotations.media.*; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.ledger.dto.SpendingIdsDto; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.apis.ledger.dto.SpendingShareReq; import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation; import kr.co.pennyway.api.common.annotation.ApiResponseExplanations; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; @@ -109,4 +107,25 @@ public interface SpendingApi { ) })) ResponseEntity deleteSpendings(@RequestBody SpendingIdsDto spendingIds, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "지출 내역 공유", method = "GET", description = """ + 사용자의 지출 내역을 공유하고 공유된 지출 내역을 반환합니다.
+ <채팅방 공유 시> 전송할 채팅방 아이디를 누락한 경우 예외를 발생시키지만, 가입하지 않은 채팅방 아이디를 전송한 경우는 유효한 방에만 전송하고 별도의 예외를 발생시키지 않습니다. + """) + @Parameters({ + @Parameter(name = "type", description = "공유할 목적지(타입)", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(name = "채팅방 공유", value = "CHAT_ROOM") + }), + @Parameter(name = "year", description = "년도", example = "2025", required = true, in = ParameterIn.QUERY), + @Parameter(name = "month", description = "월", example = "1", required = true, in = ParameterIn.QUERY), + @Parameter(name = "day", description = "일", example = "28", required = true, in = ParameterIn.QUERY), + @Parameter(name = "chatRoomIds", description = "공유할 채팅방 ID 목록 배열 (채팅방 공유 시, null 혹은 빈 배열 허용하지 않음.)", in = ParameterIn.QUERY, array = @ArraySchema(schema = @Schema(type = "long"))), + @Parameter(name = "query", hidden = true) + }) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(name = "전송 타입 오류", description = "유효하지 않은 목적지로 지출 내용을 공유할 수 없습니다.", value = SpendingErrorCode.class, constant = "INVALID_SHARE_TYPE"), + @ApiExceptionExplanation(name = "채팅방 공유 파라미터 누락", description = "지출 내역 공유 시 필수 파라미터가 누락되었습니다.", value = SpendingErrorCode.class, constant = "MISSING_SHARE_PARAM") + }) + @ApiResponse(responseCode = "200") + ResponseEntity shareSpending(@Validated SpendingShareReq.ShareQueryParam query, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java index 720619b9f..31e634b45 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -3,7 +3,9 @@ import kr.co.pennyway.api.apis.ledger.api.SpendingApi; import kr.co.pennyway.api.apis.ledger.dto.SpendingIdsDto; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; +import kr.co.pennyway.api.apis.ledger.dto.SpendingShareReq; import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase; +import kr.co.pennyway.api.common.query.SpendingShareType; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; @@ -17,6 +19,8 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + @Slf4j @RestController @RequiredArgsConstructor @@ -79,6 +83,28 @@ public ResponseEntity deleteSpendings(@RequestBody SpendingIdsDto spendingIds return ResponseEntity.ok(SuccessResponse.noContent()); } + @Override + @GetMapping("/share") + @PreAuthorize("isAuthenticated()") + public ResponseEntity shareSpending( + @Validated SpendingShareReq.ShareQueryParam query, + @AuthenticationPrincipal SecurityUserDetails user + ) { + var date = LocalDate.of(query.year(), query.month(), query.day()); + + if (query.type().equals(SpendingShareType.CHAT_ROOM)) { + if (query.chatRoomIds() == null || query.chatRoomIds().isEmpty()) { + throw new SpendingErrorException(SpendingErrorCode.MISSING_SHARE_PARAM); + } + + spendingUseCase.shareToChatRoom(user.getUserId(), query.chatRoomIds(), date); + } else { + throw new SpendingErrorException(SpendingErrorCode.INVALID_SHARE_TYPE); + } + + return ResponseEntity.ok(SuccessResponse.noContent()); + } + /** * categoryId가 -1이면 서비스에서 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM이나 OTHER이 될 수 없고,
* categoryId가 -1이 아니면 사용자가 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM임을 확인한다. diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingShareReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingShareReq.java new file mode 100644 index 000000000..f1d704b30 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingShareReq.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.apis.ledger.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.api.common.query.SpendingShareType; + +import java.util.List; + +public class SpendingShareReq { + @Schema(description = "지출 공유 요청") + public record ShareQueryParam( + @Schema(description = "공유 타입 (대/소문자 허용)", example = "chat_room") + SpendingShareType type, + int year, + int month, + int day, + @Schema(description = "공유할 채팅방 ID 배열. 공유 타입이 chat_room인 경우 필수", example = "1") + List chatRoomIds + ) { + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/helper/SpendingChatShareHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/helper/SpendingChatShareHelper.java new file mode 100644 index 000000000..bcd892d3a --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/helper/SpendingChatShareHelper.java @@ -0,0 +1,49 @@ +package kr.co.pennyway.api.apis.ledger.helper; + +import kr.co.pennyway.api.apis.ledger.service.DailySpendingAggregateService; +import kr.co.pennyway.common.annotation.Helper; +import kr.co.pennyway.domain.context.account.service.UserService; +import kr.co.pennyway.domain.context.chat.service.ChatMemberService; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.infra.common.event.SpendingChatShareEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Helper +@RequiredArgsConstructor +public class SpendingChatShareHelper { + private final DailySpendingAggregateService dailySpendingAggregateService; + + private final UserService userService; + private final ChatMemberService chatMemberService; + + private final ApplicationEventPublisher eventPublisher; + + public void execute(Long userId, List chatRoomIds, LocalDate date) { + var user = userService.readUser(userId) + .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + var aggregatedSpendings = dailySpendingAggregateService.execute(userId, date.getYear(), date.getMonthValue(), date.getDayOfMonth()); + var joinedChatRoomIds = chatMemberService.readChatRoomIdsByUserId(userId); + + var spendingOnDate = new ArrayList(); + for (var pair : aggregatedSpendings) { + var categoryInfo = pair.getFirst(); + var amount = pair.getSecond(); + + spendingOnDate.add(SpendingChatShareEvent.SpendingOnDate.of(categoryInfo.id(), categoryInfo.name(), categoryInfo.icon().name(), amount)); + } + + chatRoomIds.stream() + .filter(joinedChatRoomIds::contains) + .forEach(chatRoomId -> { + eventPublisher.publishEvent(new SpendingChatShareEvent(chatRoomId, user.getName(), user.getId(), date, spendingOnDate)); + }); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateService.java new file mode 100644 index 000000000..d53edd0f0 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateService.java @@ -0,0 +1,39 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; +import kr.co.pennyway.domain.domains.spending.service.SpendingRdbService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.summingLong; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DailySpendingAggregateService { + private final SpendingRdbService spendingRdbService; + + @Transactional(readOnly = true) + public List> execute(Long userId, int year, int month, int day) { + var spendings = spendingRdbService.readSpendings(userId, year, month, day); + + return spendings.stream() + .collect( + groupingBy( + Spending::getCategory, + summingLong(Spending::getAmount) + ) + ) + .entrySet().stream() + .map(entry -> Pair.of(entry.getKey(), entry.getValue())) + .sorted((o1, o2) -> (int) (o2.getSecond() - o1.getSecond())) + .toList(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index 9a3a1d59e..fbe5e1f53 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.apis.ledger.helper.SpendingChatShareHelper; import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; import kr.co.pennyway.api.apis.ledger.service.SpendingDeleteService; import kr.co.pennyway.api.apis.ledger.service.SpendingSaveService; @@ -13,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.List; @Slf4j @@ -24,6 +26,8 @@ public class SpendingUseCase { private final SpendingUpdateService spendingUpdateService; private final SpendingDeleteService spendingDeleteService; + private final SpendingChatShareHelper spendingChatShareHelper; + @Transactional public SpendingSearchRes.Individual createSpending(Long userId, SpendingReq request) { Spending spending = spendingSaveService.createSpending(userId, request); @@ -62,4 +66,7 @@ public void deleteSpendings(List spendingIds) { spendingDeleteService.deleteSpendings(spendingIds); } + public void shareToChatRoom(Long userId, List chatRoomIds, LocalDate date) { + spendingChatShareHelper.execute(userId, chatRoomIds, date); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingShareTypeConverter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingShareTypeConverter.java new file mode 100644 index 000000000..bb0c8503b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingShareTypeConverter.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.common.converter; + +import kr.co.pennyway.api.common.query.SpendingShareType; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; +import org.springframework.core.convert.converter.Converter; + +public class SpendingShareTypeConverter implements Converter { + @Override + public SpendingShareType convert(String type) { + try { + return SpendingShareType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_SHARE_TYPE); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingShareType.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingShareType.java new file mode 100644 index 000000000..5b57a1f75 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingShareType.java @@ -0,0 +1,11 @@ +package kr.co.pennyway.api.common.query; + +public enum SpendingShareType { + CHAT_ROOM("chat_room"); + + private final String type; + + SpendingShareType(String type) { + this.type = type; + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateServiceTest.java new file mode 100644 index 000000000..3016f8856 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/DailySpendingAggregateServiceTest.java @@ -0,0 +1,78 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.repository.SpendingCustomCategoryRepository; +import kr.co.pennyway.domain.domains.spending.repository.SpendingRepository; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Slf4j +@ExternalApiIntegrationTest +public class DailySpendingAggregateServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserRepository userRepository; + + @Autowired + private SpendingRepository spendingRepository; + + @Autowired + private SpendingCustomCategoryRepository spendingCustomCategoryRepository; + + @Autowired + private DailySpendingAggregateService dailySpendingAggregateService; + + private static Spending createSpending(String accountName, LocalDateTime spendAt, SpendingCategory category, Integer amount, SpendingCustomCategory spendingCustomCategory, User user) { + return Spending.builder() + .accountName(accountName) + .spendAt(spendAt) + .category(category) + .amount(amount) + .spendingCustomCategory(spendingCustomCategory) + .user(user) + .build(); + } + + @Test + public void shouldReturnDailySpendingDescOrder() { + // given + var user = userRepository.save(UserFixture.GENERAL_USER.toUser()); + var spendingCustomCategory1 = spendingCustomCategoryRepository.save(SpendingCustomCategory.of("커스텀1", SpendingCategory.EDUCATION, user)); + var spendingCustomCategory2 = spendingCustomCategoryRepository.save(SpendingCustomCategory.of("커스텀2", SpendingCategory.FOOD, user)); + + var today = LocalDateTime.now(); + + var defaultFoodSpending1 = spendingRepository.save(createSpending("시스템 카테고리 지출1", today, SpendingCategory.FOOD, 10000, null, user)); + var defaultFoodSpending2 = spendingRepository.save(createSpending("시스템 카테고리 지출2", today, SpendingCategory.FOOD, 20000, null, user)); + var defaultEducationSpending1 = spendingRepository.save(createSpending("시스템 카테고리 지출3", today, SpendingCategory.EDUCATION, 30000, null, user)); + var defaultEducationSpending2 = spendingRepository.save(createSpending("시스템 카테고리 지출4", today, SpendingCategory.EDUCATION, 40000, null, user)); + var systemEducationSpending1 = spendingRepository.save(createSpending("커스텀 카테고리 지출1", today, SpendingCategory.CUSTOM, 50000, spendingCustomCategory1, user)); + var systemEducationSpending2 = spendingRepository.save(createSpending("커스텀 카테고리 지출2", today, SpendingCategory.CUSTOM, 60000, spendingCustomCategory1, user)); + var systemFoodSpending1 = spendingRepository.save(createSpending("커스텀 카테고리 지출3", today, SpendingCategory.CUSTOM, 70000, spendingCustomCategory2, user)); + var systemFoodSpending2 = spendingRepository.save(createSpending("커스텀 카테고리 지출4", today, SpendingCategory.CUSTOM, 80000, spendingCustomCategory2, user)); + + // when + var result = dailySpendingAggregateService.execute(user.getId(), today.getYear(), today.getMonthValue(), today.getDayOfMonth()); + + // then + assertEquals(result.get(0).getFirst(), systemFoodSpending1.getCategory()); + assertEquals(result.get(0).getSecond(), systemFoodSpending1.getAmount() + systemFoodSpending2.getAmount()); + assertEquals(result.get(1).getFirst(), systemEducationSpending1.getCategory()); + assertEquals(result.get(1).getSecond(), systemEducationSpending1.getAmount() + systemEducationSpending2.getAmount()); + assertEquals(result.get(2).getFirst(), defaultEducationSpending1.getCategory()); + assertEquals(result.get(2).getSecond(), defaultEducationSpending1.getAmount() + defaultEducationSpending2.getAmount()); + assertEquals(result.get(3).getFirst(), defaultFoodSpending1.getCategory()); + assertEquals(result.get(3).getSecond(), defaultFoodSpending1.getAmount() + defaultFoodSpending2.getAmount()); + } +} diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java index eb38dc5f4..6cfb30c2c 100644 --- a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java @@ -15,6 +15,8 @@ public enum SpendingErrorCode implements BaseErrorCode { INVALID_ICON_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "icon의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), INVALID_TYPE_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), INVALID_CATEGORY_TYPE(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "존재하지 않는 카테고리 타입입니다."), + INVALID_SHARE_TYPE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "부적절한 공유 타입입니다."), + MISSING_SHARE_PARAM(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "지출 내역 공유 시 필수 파라미터가 누락되었습니다."), /* 404 Not Found */ NOT_FOUND_SPENDING(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 지출 내역입니다."), diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java index 3c94a3481..c0c5d7619 100644 --- a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java @@ -10,4 +10,6 @@ public interface SpendingCustomRepository { Optional findTotalSpendingAmountByUserId(Long userId, int year, int month); List findByYearAndMonth(Long userId, int year, int month); + + List findByYearAndMonthAndDay(Long userId, int year, int month, int day); } diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java index 7c780895c..89b05dfbc 100644 --- a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -61,4 +61,18 @@ public List findByYearAndMonth(Long userId, int year, int month) { .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) .fetch(); } + + @Override + public List findByYearAndMonthAndDay(Long userId, int year, int month, int day) { + Sort sort = Sort.by(Sort.Order.desc("spendAt")); + + return queryFactory.selectFrom(spending) + .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() + .where(spending.spendAt.year().eq(year) + .and(spending.spendAt.month().eq(month)) + .and(spending.spendAt.dayOfMonth().eq(day)) + .and(spending.user.id.eq(userId)) + ) + .fetch(); + } } diff --git a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingRdbService.java b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingRdbService.java index a8c2c08a7..5f6eb2df5 100644 --- a/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingRdbService.java +++ b/pennyway-domain/domain-rdb/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingRdbService.java @@ -50,6 +50,11 @@ public List readSpendings(Long userId, int year, int month) { return spendingRepository.findByYearAndMonth(userId, year, month); } + @Transactional(readOnly = true) + public List readSpendings(Long userId, int year, int month, int day) { + return spendingRepository.findByYearAndMonthAndDay(userId, year, month, day); + } + @Transactional(readOnly = true) public int readSpendingTotalCountByCategoryId(Long userId, Long categoryId) { return spendingRepository.countByUser_IdAndSpendingCustomCategory_Id(userId, categoryId); diff --git a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageCategoryType.java b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageCategoryType.java index 869f62f23..971e8cd8a 100644 --- a/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageCategoryType.java +++ b/pennyway-domain/domain-redis/src/main/java/kr/co/pennyway/domain/domains/message/type/MessageCategoryType.java @@ -9,7 +9,8 @@ @RequiredArgsConstructor public enum MessageCategoryType { NORMAL("0", "NORMAL"), - SYSTEM("1", "SYSTEM"); + SYSTEM("1", "SYSTEM"), + SHARE("2", "SHARE"); private static final Map stringToEnum = Stream.of(values()).collect(java.util.stream.Collectors.toMap(Object::toString, e -> e)); private final String code; diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEvent.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEvent.java new file mode 100644 index 000000000..c3f2951a8 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEvent.java @@ -0,0 +1,53 @@ +package kr.co.pennyway.infra.common.event; + +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; + +public record SpendingChatShareEvent( + Long chatRoomId, + String name, + Long senderId, + LocalDate date, + List spendingOnDates +) { + public SpendingChatShareEvent { + Objects.requireNonNull(chatRoomId, "chatRoomId는 null일 수 없습니다."); + Objects.requireNonNull(name, "name은 null일 수 없습니다."); + Objects.requireNonNull(senderId, "senderId는 null일 수 없습니다."); + Objects.requireNonNull(date, "date는 null일 수 없습니다."); + Objects.requireNonNull(spendingOnDates, "spendingOnDates는 null일 수 없습니다."); + } + + public record SpendingOnDate( + boolean isCustom, + Long categoryId, + String name, + String icon, + Long amount + ) { + public SpendingOnDate { + Objects.requireNonNull(categoryId, "categoryId는 null일 수 없습니다."); + Objects.requireNonNull(icon, "icon은 null일 수 없습니다."); + Objects.requireNonNull(amount, "amount는 null일 수 없습니다."); + + if (isCustom && categoryId < 0 || !isCustom && categoryId != -1) { + throw new IllegalArgumentException("isCustom이 " + isCustom + "일 때 categoryId는 " + (isCustom ? "0 이상" : "-1") + "이어야 합니다."); + } + + if (isCustom && icon.equals("CUSTOM")) { + throw new IllegalArgumentException("사용자 정의 카테고리는 OTHER가 될 수 없습니다."); + } + + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열일 수 없습니다."); + } + } + + public static SpendingOnDate of(Long categoryId, String name, String icon, Long amount) { + return new SpendingOnDate(!categoryId.equals(-1L), categoryId, name, icon, amount); + } + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEventHandler.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEventHandler.java new file mode 100644 index 000000000..4619e999c --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/SpendingChatShareEventHandler.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.infra.common.event; + +import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter; +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; +import kr.co.pennyway.infra.common.properties.SpendingChatShareExchangeProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.scheduling.annotation.Async; + +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +public class SpendingChatShareEventHandler { + private final MessageBrokerAdapter messageBrokerAdapter; + private final ChatExchangeProperties chatExchangeProperties; + private final SpendingChatShareExchangeProperties spendingChatShareExchangeProperties; + + @Async + @EventListener + public void handle(SpendingChatShareEvent event) { + log.debug("handle: {}", event); + + var headers = new MessageHeaders(Map.of("Content-Type", "application/json")); + var message = MessageBuilder.createMessage(event, headers); + + messageBrokerAdapter.send( + chatExchangeProperties.getExchange(), + spendingChatShareExchangeProperties.getRoutingKey(), + message + ); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/SpendingChatShareExchangeProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/SpendingChatShareExchangeProperties.java new file mode 100644 index 000000000..242c42ec0 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/SpendingChatShareExchangeProperties.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.infra.common.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "pennyway.rabbitmq.spending-chat-share") +public class SpendingChatShareExchangeProperties { + private final String queue; + private final String routingKey; + + @Override + public String toString() { + return "SpendingChatShareExchangeProperties{" + + "queue='" + queue + '\'' + + ", routingKey='" + routingKey + '\'' + + '}'; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java index b56061b92..69961bb20 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MessageBrokerConfig.java @@ -7,10 +7,12 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import kr.co.pennyway.infra.client.broker.MessageBrokerAdapter; import kr.co.pennyway.infra.common.event.ChatRoomJoinEventHandler; +import kr.co.pennyway.infra.common.event.SpendingChatShareEventHandler; import kr.co.pennyway.infra.common.importer.PennywayInfraConfig; import kr.co.pennyway.infra.common.properties.ChatExchangeProperties; import kr.co.pennyway.infra.common.properties.ChatJoinEventExchangeProperties; import kr.co.pennyway.infra.common.properties.RabbitMqProperties; +import kr.co.pennyway.infra.common.properties.SpendingChatShareExchangeProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.core.Binding; @@ -35,11 +37,12 @@ @Slf4j @EnableRabbit @RequiredArgsConstructor -@EnableConfigurationProperties({ChatExchangeProperties.class, ChatJoinEventExchangeProperties.class, RabbitMqProperties.class}) +@EnableConfigurationProperties({ChatExchangeProperties.class, ChatJoinEventExchangeProperties.class, RabbitMqProperties.class, SpendingChatShareExchangeProperties.class}) public class MessageBrokerConfig implements PennywayInfraConfig { private final RabbitMqProperties rabbitMqProperties; private final ChatExchangeProperties chatExchangeProperties; private final ChatJoinEventExchangeProperties chatJoinEventExchangeProperties; + private final SpendingChatShareExchangeProperties spendingChatShareExchangeProperties; @Bean public TopicExchange chatExchange() { @@ -56,6 +59,11 @@ public Queue chatJoinEventQueue(ChatJoinEventExchangeProperties chatJoinEventExc return new Queue(chatJoinEventExchangeProperties.getQueue(), true); } + @Bean + public Queue spendingChatShareQueue(SpendingChatShareExchangeProperties spendingChatShareExchangeProperties) { + return new Queue(spendingChatShareExchangeProperties.getQueue(), true); + } + @Bean public Binding chatBinding(Queue chatQueue, TopicExchange chatExchange) { return BindingBuilder @@ -72,6 +80,14 @@ public Binding chatJoinEventBinding(Queue chatJoinEventQueue, TopicExchange chat .with(chatJoinEventExchangeProperties.getRoutingKey()); } + @Bean + public Binding spendingShareEventBinding(Queue spendingChatShareQueue, TopicExchange chatExchange) { + return BindingBuilder + .bind(spendingChatShareQueue) + .to(chatExchange) + .with(spendingChatShareExchangeProperties.getRoutingKey()); + } + @Bean public Module dateTimeModule() { return new JavaTimeModule(); @@ -151,4 +167,9 @@ public MessageBrokerAdapter messageBrokerAdapter(RabbitMessagingTemplate rabbitM public ChatRoomJoinEventHandler chatRoomJoinEventHandler(MessageBrokerAdapter messageBrokerAdapter, ChatExchangeProperties chatExchangeProperties, ChatJoinEventExchangeProperties chatJoinEventExchangeProperties) { return new ChatRoomJoinEventHandler(messageBrokerAdapter, chatExchangeProperties, chatJoinEventExchangeProperties); } + + @Bean + public SpendingChatShareEventHandler spendingChatShareEventHandler(MessageBrokerAdapter messageBrokerAdapter, ChatExchangeProperties chatExchangeProperties, SpendingChatShareExchangeProperties spendingChatShareExchangeProperties) { + return new SpendingChatShareEventHandler(messageBrokerAdapter, chatExchangeProperties, spendingChatShareExchangeProperties); + } } diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index 818f50a44..4b33bc6f4 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -63,6 +63,9 @@ pennyway: chat-join-event: queue: ${RABBITMQ_CHAT_JOIN_QUEUE:chat.join.queue} routing-key: ${RABBITMQ_CHAT_JOIN_ROUTING:chat.join.*} + spending-chat-share: + queue: ${RABBITMQ_SPENDING_CHAT_QUEUE:spending.chat.queue} + routing-key: ${RABBITMQ_SPENDING_CHAT_EXCHANGE:chat.share.spending.*} oauth2: client: diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/command/SendMessageCommand.java b/pennyway-socket/src/main/java/kr/co/pennyway/socket/command/SendMessageCommand.java index f27a912e5..22c01928b 100644 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/command/SendMessageCommand.java +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/command/SendMessageCommand.java @@ -1,5 +1,6 @@ package kr.co.pennyway.socket.command; +import jakarta.annotation.Nullable; import kr.co.pennyway.domain.domains.message.type.MessageCategoryType; import kr.co.pennyway.domain.domains.message.type.MessageContentType; import kr.co.pennyway.socket.common.constants.SystemMessageConstants; @@ -16,7 +17,8 @@ public record SendMessageCommand( MessageCategoryType categoryType, long senderId, String senderName, - Map messageIdHeader + Map messageIdHeader, + @Nullable Map headers ) { public SendMessageCommand { if (chatRoomId <= 0) { @@ -51,6 +53,7 @@ public static SendMessageCommand createSystemMessage(long chatRoomId, String con MessageCategoryType.SYSTEM, SystemMessageConstants.SYSTEM_SENDER_ID, null, + null, null ); } @@ -74,7 +77,21 @@ public static SendMessageCommand createUserMessage(long chatRoomId, String conte MessageCategoryType.NORMAL, senderId, senderName, - messageIdHeader + messageIdHeader, + null + ); + } + + public static SendMessageCommand createMessage(long chatRoomId, String content, MessageContentType contentType, MessageCategoryType categoryType, long senderId, String senderName, Map messageIdHeader, @Nullable Map headers) { + return new SendMessageCommand( + chatRoomId, + content, + contentType, + categoryType, + senderId, + senderName, + messageIdHeader, + headers ); } } \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/SpendingShareEventListener.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/SpendingShareEventListener.kt new file mode 100644 index 000000000..cde1a95b8 --- /dev/null +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/relay/SpendingShareEventListener.kt @@ -0,0 +1,65 @@ +package kr.co.pennyway.socket.relay; + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.pennyway.domain.domains.message.type.MessageCategoryType +import kr.co.pennyway.domain.domains.message.type.MessageContentType +import kr.co.pennyway.infra.common.event.SpendingChatShareEvent +import kr.co.pennyway.infra.common.properties.ChatExchangeProperties +import kr.co.pennyway.socket.command.SendMessageCommand +import kr.co.pennyway.socket.common.util.logger +import kr.co.pennyway.socket.service.ChatMessageSendService +import lombok.extern.slf4j.Slf4j +import org.springframework.amqp.rabbit.annotation.Exchange +import org.springframework.amqp.rabbit.annotation.Queue +import org.springframework.amqp.rabbit.annotation.QueueBinding +import org.springframework.amqp.rabbit.annotation.RabbitListener +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.stereotype.Component + +@Slf4j +@Component +@EnableConfigurationProperties(ChatExchangeProperties::class) +class SpendingShareEventListener( + private val chatMessageSendService: ChatMessageSendService, + private val objectMapper: ObjectMapper +) { + private companion object { + private val log = logger() + } + + @RabbitListener( + containerFactory = "simpleRabbitListenerContainerFactory", + bindings = [QueueBinding( + value = Queue("\${pennyway.rabbitmq.spending-chat-share.queue}"), + exchange = Exchange(value = "\${pennyway.rabbitmq.chat.exchange}", type = "topic"), + key = ["\${pennyway.rabbitmq.spending-chat-share.routing-key}"] + )] + ) + fun handle(event: SpendingChatShareEvent) { + log.debug("handle: {}", event) + + convertToJson(event.spendingOnDates()) + .getOrNull() + ?.let { payload -> + chatMessageSendService.execute( + SendMessageCommand.createMessage( + event.chatRoomId(), + payload, + MessageContentType.TEXT, + MessageCategoryType.SHARE, + event.senderId(), + event.name(), + null, + mapOf("Content-Type" to "application/json", "date" to event.date()) + ) + ) + } + } + + private fun convertToJson(spendingOnDates: List): Result = + runCatching { + objectMapper.writeValueAsString(spendingOnDates) + }.onFailure { + log.error("Failed to serialize spendingOnDates", it) + } +} \ No newline at end of file diff --git a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt index 4f8fb2c61..7ba6a8382 100644 --- a/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt +++ b/pennyway-socket/src/main/java/kr/co/pennyway/socket/service/ChatMessageSendService.kt @@ -33,7 +33,8 @@ class ChatMessageSendService( messageBrokerAdapter.convertAndSend( exchange, "chat.room.${command.chatRoomId}", - ChatMessageDto.Response.from(message) + ChatMessageDto.Response.from(message), + command.headers ) }