Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Api: ✨ Spending History Sharing API #233

Merged
merged 30 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1d95bd2
feat: add spending list read method with day to spending repository
psychology50 Jan 28, 2025
71e8ed6
feat: add spending list read method with day to spending service
psychology50 Jan 28, 2025
e9cd809
feat: impl daily spending aggregate service
psychology50 Jan 29, 2025
4811628
test: aggregate slice test
psychology50 Jan 29, 2025
6038581
fix: change return type to pair
psychology50 Jan 29, 2025
0256c4c
rename: fix test name
psychology50 Jan 30, 2025
5a36bb1
feat: spending-chat-share-event
psychology50 Jan 30, 2025
92d5586
feat: add spending-chat-share-exchange-properties
psychology50 Jan 30, 2025
51ca942
chore: add spending chat share property to infra yml
psychology50 Jan 30, 2025
1598feb
feat: impl spending chat share event handler
psychology50 Jan 30, 2025
addca4c
fix: spending-on-dates type error in the spending-chat-share-event
psychology50 Jan 30, 2025
f554619
feat: impl spending-chat-share-helper
psychology50 Jan 30, 2025
383b641
feat: add share-to-chat-room to spending usecase
psychology50 Jan 30, 2025
58bb94e
feat: add invalid share type error code to spending-error-code
psychology50 Jan 30, 2025
dc684a3
feat: impl spending share enum type
psychology50 Jan 30, 2025
d3eeb88
feat: add spending-chat-share-query dto
psychology50 Jan 30, 2025
a292294
feat: add missing-share-param error code to spending error code
psychology50 Jan 30, 2025
954469a
feat: add share-spending api to controller
psychology50 Jan 30, 2025
775ebd6
docs: write swagger docs about spending share api
psychology50 Jan 30, 2025
19acba4
chore: apply binder & queue & event handler for share to chat room
psychology50 Jan 30, 2025
2d2d099
fix: invalid validation in the spending chat share event's name field
psychology50 Jan 30, 2025
ce6e078
feat: add share constant to the message category type
psychology50 Jan 30, 2025
ccb3b48
feat: add default create message method to the send-message-command
psychology50 Jan 30, 2025
fab4a74
fix: add sender id field to spending-chat-share-event
psychology50 Jan 30, 2025
2a75ff2
fix: add user id to event parameter when publish event
psychology50 Jan 30, 2025
bfe178f
feat: impl sending-share-event listener
psychology50 Jan 30, 2025
9f44df1
fix: add headers to send message command
psychology50 Jan 30, 2025
95b2622
refactor: convert spending-share-event-listener to kotlin
psychology50 Jan 31, 2025
eaf5662
fix: add date field to spending-chat-share-event
psychology50 Jan 31, 2025
676ef5a
fix: add date to chat message's header
psychology50 Jan 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading