Skip to content

Commit b18baae

Browse files
authored
Merge pull request #134 from prgrms-aibe-devcourse/feat/expense-LLM
Feat/expense llm
2 parents ce912c4 + baf1caf commit b18baae

7 files changed

Lines changed: 279 additions & 4 deletions

File tree

src/main/java/store/lastdance/controller/expense/ExpenseController.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,4 +243,32 @@ public ResponseEntity<ApiResponse<AnalyzeExpenseResponseDTO>> analyzeExpenses(
243243
AnalyzeExpenseResponseDTO response = expenseService.analyzeExpenses(userId, requestDTO);
244244
return ResponseEntity.ok(ApiResponse.success(response));
245245
}
246+
247+
@PostMapping("/analyze/save")
248+
@Operation(summary = "LLM 지출 분석 결과 저장", description = "LLM 지출 분석 결과를 저장합니다.")
249+
public ResponseEntity<ApiResponse<String>> saveAnalysisResult(
250+
@Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User oAuth2User,
251+
@Valid @RequestBody SaveAnalysisResultRequestDTO saveRequestDTO
252+
) {
253+
UUID userId = oAuth2User.getUserId();
254+
expenseService.saveExpenseAnalysisHistory(userId, saveRequestDTO.requestDTO(), saveRequestDTO.analysisResponseDTO());
255+
return ResponseEntity.ok(ApiResponse.success("분석 결과가 성공적으로 저장되었습니다."));
256+
}
257+
258+
@GetMapping("/analyze/history")
259+
@Operation(summary = "LLM 지출 분석 내역 조회", description = "사용자의 전체 지출 분석 내역을 최신순으로 조회 (페이징 포함)")
260+
public ResponseEntity<ApiResponse<PageWithSummaryResponse<ExpenseAnalysisHistoryDTO>>> getAnalysisHistoryList(
261+
@Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User oAuth2User,
262+
@RequestParam(defaultValue = "0") int page,
263+
@RequestParam(defaultValue = "10") int size,
264+
@RequestParam(defaultValue = "createdAt") String sortBy,
265+
@RequestParam(defaultValue = "DESC") String sortDirection
266+
){
267+
UUID userId = oAuth2User.getUserId();
268+
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
269+
Pageable pageable = PageRequest.of(page, size, sort);
270+
PageWithSummaryResponse<ExpenseAnalysisHistoryDTO> response = expenseService.getExpenseAnalysisHistory(userId,pageable);
271+
272+
return ResponseEntity.ok(ApiResponse.success(response));
273+
}
246274
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package store.lastdance.domain.expense;
2+
3+
import jakarta.persistence.*;
4+
import lombok.AccessLevel;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
9+
import store.lastdance.domain.common.BaseTimeEntity;
10+
import store.lastdance.domain.user.User;
11+
12+
import java.math.BigDecimal;
13+
import java.time.LocalDate;
14+
@Entity
15+
@Getter
16+
@Table(name = "expense_analysis_history")
17+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
18+
@EntityListeners(AuditingEntityListener.class)
19+
public class ExpenseAnalysisHistory extends BaseTimeEntity {
20+
21+
@Id
22+
@GeneratedValue(strategy = GenerationType.IDENTITY)
23+
private Long id;
24+
25+
@ManyToOne(fetch = FetchType.LAZY)
26+
@JoinColumn(name = "user_id", nullable = false)
27+
private User user;
28+
29+
@Column(nullable = false)
30+
private LocalDate startDate;
31+
32+
@Column(nullable = false)
33+
private LocalDate endDate;
34+
35+
// 예산 사용률 정보
36+
@Column(nullable = false)
37+
private Double budgetUsagePercentage;
38+
39+
@Column(nullable = false)
40+
private BigDecimal budgetUsageCurrentSpending;
41+
42+
@Column(nullable = false)
43+
private BigDecimal budgetUsageTotalBudget;
44+
45+
// 일평균 지출 정보
46+
@Column(nullable = false)
47+
private BigDecimal dailySpendingAverageSoFar;
48+
49+
@Column(nullable = false)
50+
private BigDecimal dailySpendingEstimatedEom;
51+
52+
// 분석 결과 요약
53+
@Column(nullable = false)
54+
private String mainFinding;
55+
56+
// 개선 제안 (Suggestion)
57+
@Column(nullable = false)
58+
private String suggestionTitle;
59+
60+
@Lob // 긴 텍스트를 위해
61+
@Column(nullable = false)
62+
private String suggestionDescription;
63+
64+
@Column(nullable = false)
65+
private String suggestionEffect;
66+
67+
@Column(nullable = false)
68+
private String suggestionDifficulty;
69+
70+
71+
@Builder
72+
public ExpenseAnalysisHistory(User user, LocalDate startDate, LocalDate endDate,
73+
Double budgetUsagePercentage, BigDecimal budgetUsageCurrentSpending, BigDecimal budgetUsageTotalBudget,
74+
BigDecimal dailySpendingAverageSoFar, BigDecimal dailySpendingEstimatedEom,
75+
String mainFinding, String suggestionTitle, String suggestionDescription,
76+
String suggestionEffect, String suggestionDifficulty) {
77+
this.user = user;
78+
this.startDate = startDate;
79+
this.endDate = endDate;
80+
this.budgetUsagePercentage = budgetUsagePercentage;
81+
this.budgetUsageCurrentSpending = budgetUsageCurrentSpending;
82+
this.budgetUsageTotalBudget = budgetUsageTotalBudget;
83+
this.dailySpendingAverageSoFar = dailySpendingAverageSoFar;
84+
this.dailySpendingEstimatedEom = dailySpendingEstimatedEom;
85+
this.mainFinding = mainFinding;
86+
this.suggestionTitle = suggestionTitle;
87+
this.suggestionDescription = suggestionDescription;
88+
this.suggestionEffect = suggestionEffect;
89+
this.suggestionDifficulty = suggestionDifficulty;
90+
}
91+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package store.lastdance.dto.expense;
2+
3+
import com.fasterxml.jackson.annotation.JsonFormat;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import store.lastdance.domain.expense.ExpenseAnalysisHistory;
6+
7+
import java.math.BigDecimal;
8+
import java.time.LocalDate;
9+
import java.time.LocalDateTime;
10+
11+
@Schema(description = "지출 분석 내역 응답 DTO")
12+
public record ExpenseAnalysisHistoryDTO(
13+
@Schema(description = "내역 ID")
14+
Long id,
15+
16+
@Schema(description = "분석 시작일")
17+
LocalDate startDate,
18+
19+
@Schema(description = "분석 종료일")
20+
LocalDate endDate,
21+
22+
@Schema(description = "예산 사용률 (%)")
23+
Double budgetUsagePercentage,
24+
25+
@Schema(description = "현재 사용액")
26+
BigDecimal budgetUsageCurrentSpending,
27+
28+
@Schema(description = "총 예산")
29+
BigDecimal budgetUsageTotalBudget,
30+
31+
@Schema(description = "일평균 지출액")
32+
BigDecimal dailySpendingAverageSoFar,
33+
34+
@Schema(description = "월말 예상 지출액")
35+
BigDecimal dailySpendingEstimatedEom,
36+
37+
@Schema(description = "핵심 소비 패턴 요약")
38+
String mainFinding,
39+
40+
@Schema(description = "개선 제안 제목")
41+
String suggestionTitle,
42+
43+
@Schema(description = "개선 제안 내용")
44+
String suggestionDescription,
45+
46+
@Schema(description = "개선 제안 예상 효과")
47+
String suggestionEffect,
48+
49+
@Schema(description = "개선 제안 난이도")
50+
String suggestionDifficulty,
51+
52+
@Schema(description = "분석 생성일")
53+
LocalDateTime createdAt
54+
) {
55+
public static ExpenseAnalysisHistoryDTO from(ExpenseAnalysisHistory history) {
56+
return new ExpenseAnalysisHistoryDTO(
57+
history.getId(),
58+
history.getStartDate(),
59+
history.getEndDate(),
60+
history.getBudgetUsagePercentage(),
61+
history.getBudgetUsageCurrentSpending(),
62+
history.getBudgetUsageTotalBudget(),
63+
history.getDailySpendingAverageSoFar(),
64+
history.getDailySpendingEstimatedEom(),
65+
history.getMainFinding(),
66+
history.getSuggestionTitle(),
67+
history.getSuggestionDescription(),
68+
history.getSuggestionEffect(),
69+
history.getSuggestionDifficulty(),
70+
history.getCreatedAt()
71+
);
72+
}
73+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package store.lastdance.dto.expense;
2+
3+
import jakarta.validation.Valid;
4+
import jakarta.validation.constraints.NotNull;
5+
6+
public record SaveAnalysisResultRequestDTO(
7+
@NotNull @Valid AnalyzeExpenseRequestDTO requestDTO,
8+
@NotNull @Valid AnalyzeExpenseResponseDTO analysisResponseDTO
9+
) {
10+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package store.lastdance.repository.expense;
2+
3+
import org.springframework.data.domain.Page;
4+
import org.springframework.data.domain.Pageable;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import store.lastdance.domain.expense.ExpenseAnalysisHistory;
7+
import store.lastdance.domain.user.User;
8+
9+
import java.util.List;
10+
import java.util.UUID;
11+
12+
public interface ExpenseAnalysisHistoryRepository extends JpaRepository<ExpenseAnalysisHistory, Long> {
13+
14+
// 특정 사용자의 모든 분석 내역을 최신순으로 조회
15+
List<ExpenseAnalysisHistory> findByUserOrderByCreatedAtDesc(User user);
16+
17+
Page<ExpenseAnalysisHistory> findByUser(User user, Pageable pageable);
18+
}

src/main/java/store/lastdance/service/expense/ExpenseService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,8 @@ PageWithSummaryResponse<CombinedExpenseResponseDTO> getCombinedExpenses(
5555

5656
// LLM 지출 분석 응답
5757
AnalyzeExpenseResponseDTO analyzeExpenses(UUID userId, AnalyzeExpenseRequestDTO requestDTO);
58+
// LLM 지출 분석 내역 조회
59+
PageWithSummaryResponse<ExpenseAnalysisHistoryDTO> getExpenseAnalysisHistory(UUID userId, Pageable pageable);
60+
// LLM 지출 분석 내역 저장
61+
void saveExpenseAnalysisHistory(UUID userId, AnalyzeExpenseRequestDTO requestDTO,AnalyzeExpenseResponseDTO analysisResponseDTO);
5862
}

src/main/java/store/lastdance/service/expense/ExpenseServiceImpl.java

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,15 @@
1111
import org.springframework.transaction.annotation.Transactional;
1212
import org.springframework.web.multipart.MultipartFile;
1313
import store.lastdance.domain.common.ImageFile;
14-
import store.lastdance.domain.expense.Expense;
15-
import store.lastdance.domain.expense.ExpenseCategory;
16-
import store.lastdance.domain.expense.ExpenseSplit;
17-
import store.lastdance.domain.expense.ExpenseType;
14+
import store.lastdance.domain.expense.*;
1815
import store.lastdance.domain.group.Group;
1916
import store.lastdance.domain.group.GroupMember;
2017
import store.lastdance.domain.user.User;
2118
import store.lastdance.dto.expense.*;
2219
import store.lastdance.dto.response.PageWithSummaryResponse;
2320
import store.lastdance.exception.CustomException;
2421
import store.lastdance.exception.ErrorCode;
22+
import store.lastdance.repository.expense.ExpenseAnalysisHistoryRepository;
2523
import store.lastdance.repository.expense.ExpenseRepository;
2624
import store.lastdance.repository.expense.ExpenseSplitRepository;
2725
import store.lastdance.repository.group.GroupMemberRepository;
@@ -47,6 +45,7 @@ public class ExpenseServiceImpl implements ExpenseService {
4745
private final GroupRepository groupRepository;
4846
private final GroupMemberRepository groupMemberRepository;
4947
private final UserRepository userRepository;
48+
private final ExpenseAnalysisHistoryRepository expenseAnalysisHistoryRepository;
5049
private final ImageService imageService;
5150
private final ObjectMapper objectMapper;
5251
private final ExpenseAnalyzer expenseAnalyzer;
@@ -1104,6 +1103,13 @@ private ExpenseSummary calculateGroupExpensesSummary(List<ExpenseResponseDTO> ex
11041103
);
11051104
}
11061105

1106+
/**
1107+
* LLM 지출 분석
1108+
* @param userId 사용자의 ID
1109+
* @param requestDTO 시작일, 종료일
1110+
* @return 4가지 유형 - 예산 사용률, 일 평균 지출(월말 예상), 분석 결과, 카테고리별 상세 분석
1111+
*/
1112+
@Override
11071113
public AnalyzeExpenseResponseDTO analyzeExpenses(UUID userId, AnalyzeExpenseRequestDTO requestDTO) {
11081114
List<Expense> expenses = expenseRepository.findPersonalAndShareExpensesByDateRange(userId, requestDTO.startDate(), requestDTO.endDate());
11091115
BigDecimal totalBudget = getUserBudget(userId);
@@ -1122,10 +1128,55 @@ public AnalyzeExpenseResponseDTO analyzeExpenses(UUID userId, AnalyzeExpenseRequ
11221128
AnalyzeExpenseResponseDTO.Suggestion suggestion = getLlmAnalysisResult(expenses);
11231129

11241130
String mainFinding = createMainFinding(categoryDetails);
1131+
11251132
AnalyzeExpenseResponseDTO.AnalysisResult analysisResult = new AnalyzeExpenseResponseDTO.AnalysisResult(mainFinding, suggestion);
11261133

11271134
return new AnalyzeExpenseResponseDTO(budgetUsage,dailySpending,analysisResult,categoryDetails);
11281135
}
1136+
1137+
/**
1138+
* LLM 지출 분석 기록 조회
1139+
*/
1140+
@Override
1141+
public PageWithSummaryResponse<ExpenseAnalysisHistoryDTO> getExpenseAnalysisHistory(UUID userId, Pageable pageable) {
1142+
1143+
User user = userRepository.findById(userId).orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
1144+
1145+
Page<ExpenseAnalysisHistory> historyPage = expenseAnalysisHistoryRepository.findByUser(user,pageable);
1146+
1147+
Page<ExpenseAnalysisHistoryDTO> historyDTOPage = historyPage.map(ExpenseAnalysisHistoryDTO::from);
1148+
1149+
return PageWithSummaryResponse.of(historyDTOPage, ExpenseSummary.empty());
1150+
1151+
}
1152+
1153+
/**
1154+
* LLM 지출 분석 내역 저장
1155+
*/
1156+
@Override
1157+
@Transactional
1158+
public void saveExpenseAnalysisHistory(UUID userId, AnalyzeExpenseRequestDTO requestDTO, AnalyzeExpenseResponseDTO analysisResponseDTO) {
1159+
User user = userRepository.findById(userId).orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
1160+
1161+
ExpenseAnalysisHistory history = ExpenseAnalysisHistory.builder()
1162+
.user(user)
1163+
.startDate(requestDTO.startDate())
1164+
.endDate(requestDTO.endDate())
1165+
.budgetUsagePercentage(analysisResponseDTO.budgetUsage().percentage())
1166+
.budgetUsageCurrentSpending(analysisResponseDTO.budgetUsage().currentSpending())
1167+
.budgetUsageTotalBudget(analysisResponseDTO.budgetUsage().totalBudget())
1168+
.dailySpendingAverageSoFar(analysisResponseDTO.dailySpending().averageSoFar())
1169+
.dailySpendingEstimatedEom(analysisResponseDTO.dailySpending().estimatedEom())
1170+
.mainFinding(analysisResponseDTO.analysisResult().mainFinding())
1171+
.suggestionTitle(analysisResponseDTO.analysisResult().suggestion().title())
1172+
.suggestionDescription(analysisResponseDTO.analysisResult().suggestion().description())
1173+
.suggestionEffect(analysisResponseDTO.analysisResult().suggestion().effect())
1174+
.suggestionDifficulty(analysisResponseDTO.analysisResult().suggestion().difficulty())
1175+
.build();
1176+
1177+
expenseAnalysisHistoryRepository.save(history);
1178+
}
1179+
11291180
/**
11301181
* 사용자의 예산 정보를 조회합니다.
11311182
* @param userId 사용자의 UUID

0 commit comments

Comments
 (0)