Skip to content

Commit

Permalink
feat: ✨ Spending History Sharing API (#233)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
psychology50 authored Jan 31, 2025
1 parent bf5dbd1 commit dc8c0c2
Show file tree
Hide file tree
Showing 22 changed files with 516 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -109,4 +107,25 @@ public interface SpendingApi {
)
}))
ResponseEntity<?> deleteSpendings(@RequestBody SpendingIdsDto spendingIds, @AuthenticationPrincipal SecurityUserDetails user);

@Operation(summary = "지출 내역 공유", method = "GET", description = """
사용자의 지출 내역을 공유하고 공유된 지출 내역을 반환합니다. <br/>
<채팅방 공유 시> 전송할 채팅방 아이디를 누락한 경우 예외를 발생시키지만, 가입하지 않은 채팅방 아이디를 전송한 경우는 유효한 방에만 전송하고 별도의 예외를 발생시키지 않습니다.
""")
@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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +19,8 @@
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;

@Slf4j
@RestController
@RequiredArgsConstructor
Expand Down Expand Up @@ -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이 될 수 없고, <br/>
* categoryId가 -1이 아니면 사용자가 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM임을 확인한다.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Long> chatRoomIds
) {
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> 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<SpendingChatShareEvent.SpendingOnDate>();
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));
});
}
}
Original file line number Diff line number Diff line change
@@ -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<Pair<CategoryInfo, Long>> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,6 +14,7 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.List;

@Slf4j
Expand All @@ -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);
Expand Down Expand Up @@ -62,4 +66,7 @@ public void deleteSpendings(List<Long> spendingIds) {
spendingDeleteService.deleteSpendings(spendingIds);
}

public void shareToChatRoom(Long userId, List<Long> chatRoomIds, LocalDate date) {
spendingChatShareHelper.execute(userId, chatRoomIds, date);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, SpendingShareType> {
@Override
public SpendingShareType convert(String type) {
try {
return SpendingShareType.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
throw new SpendingErrorException(SpendingErrorCode.INVALID_SHARE_TYPE);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, "존재하지 않는 지출 내역입니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ public interface SpendingCustomRepository {
Optional<TotalSpendingAmount> findTotalSpendingAmountByUserId(Long userId, int year, int month);

List<Spending> findByYearAndMonth(Long userId, int year, int month);

List<Spending> findByYearAndMonthAndDay(Long userId, int year, int month, int day);
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,18 @@ public List<Spending> findByYearAndMonth(Long userId, int year, int month) {
.orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
.fetch();
}

@Override
public List<Spending> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ public List<Spending> readSpendings(Long userId, int year, int month) {
return spendingRepository.findByYearAndMonth(userId, year, month);
}

@Transactional(readOnly = true)
public List<Spending> 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);
Expand Down
Loading

0 comments on commit dc8c0c2

Please sign in to comment.