Skip to content

Commit ed47bc7

Browse files
authored
Merge pull request #233 from prgrms-aibe-devcourse/refactor/v2-analysis
refactor: V2 Controller, CQRS 및 Converter 추가
2 parents b353618 + abf4a17 commit ed47bc7

9 files changed

Lines changed: 646 additions & 1 deletion

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package store.lastdance.controller.analysis;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Parameter;
5+
import io.swagger.v3.oas.annotations.tags.Tag;
6+
import jakarta.validation.Valid;
7+
import jakarta.validation.constraints.Min;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.data.domain.Pageable;
10+
import org.springframework.data.domain.Sort;
11+
import org.springframework.data.web.PageableDefault;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
14+
import org.springframework.validation.annotation.Validated;
15+
import org.springframework.web.bind.annotation.*;
16+
import store.lastdance.aspect.RateLimit;
17+
import store.lastdance.domain.analysis.FeedbackType;
18+
import store.lastdance.dto.analysis.AnalyzeExpenseRequestDTO;
19+
import store.lastdance.dto.analysis.AnalyzeExpenseResponseDTO;
20+
import store.lastdance.dto.analysis.ExpenseAnalysisHistoryDTO;
21+
import store.lastdance.dto.response.ApiResponse;
22+
import store.lastdance.dto.response.PageWithSummaryResponse;
23+
import store.lastdance.security.oauth.CustomOAuth2User;
24+
import store.lastdance.service.analysis.AnalysisV2CommandService;
25+
import store.lastdance.service.analysis.AnalysisV2QueryService;
26+
27+
import java.util.UUID;
28+
29+
@RestController
30+
@RequestMapping("/api/v2/analysis")
31+
@RequiredArgsConstructor
32+
@Tag(name = "Analysis V2", description = "AI 지출 분석 V2 API")
33+
@Validated
34+
public class AnalysisV2Controller {
35+
36+
private final AnalysisV2CommandService analysisV2CommandService;
37+
private final AnalysisV2QueryService analysisV2QueryService;
38+
39+
@PostMapping("/expenses")
40+
@RateLimit // 30초에 1번만 요청 가능
41+
@Operation(summary = "LLM 지출 분석 요청", description = "지정된 기간의 지출 내역을 LLM을 통해 분석")
42+
public ResponseEntity<ApiResponse<AnalyzeExpenseResponseDTO>> analyzeExpenses(
43+
@Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User oAuth2User,
44+
@Valid @RequestBody AnalyzeExpenseRequestDTO requestDTO
45+
) {
46+
UUID userId = oAuth2User.getUserId();
47+
AnalyzeExpenseResponseDTO response = analysisV2QueryService.analyzeExpenses(userId, requestDTO);
48+
return ResponseEntity.ok(ApiResponse.success(response));
49+
}
50+
51+
@PostMapping("/expenses/{historyId}/feedback")
52+
@RateLimit // 30초에 1번만 요청 가능
53+
@Operation(summary = "LLM 지출 분석 피드백 토글", description = "LLM 지출분석 결과에 대해 피드백(좋아요/싫어요)을 남기거나 취소합니다.")
54+
public ResponseEntity<?> feedbackAnalyzeExpense(
55+
@Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User oAuth2User,
56+
@PathVariable @Min(1) Long historyId,
57+
@RequestParam FeedbackType type
58+
) {
59+
UUID userId = oAuth2User.getUserId();
60+
FeedbackType result = analysisV2CommandService.toggleFeedback(historyId, userId, type);
61+
62+
if (result == null) {
63+
return ResponseEntity.noContent().build();
64+
}
65+
return ResponseEntity.ok(ApiResponse.success(result));
66+
}
67+
68+
69+
@GetMapping("/expenses")
70+
@Operation(summary = "LLM 지출 분석 내역 조회", description = "사용자의 전체 지출 분석 내역을 최신순으로 조회 (페이징 포함)")
71+
public ResponseEntity<ApiResponse<PageWithSummaryResponse<ExpenseAnalysisHistoryDTO>>> getAnalysisHistoryList(
72+
@Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User oAuth2User,
73+
@PageableDefault(
74+
sort = "createdAt",
75+
direction = Sort.Direction.DESC
76+
) Pageable pageable
77+
) {
78+
UUID userId = oAuth2User.getUserId();
79+
PageWithSummaryResponse<ExpenseAnalysisHistoryDTO> response = analysisV2QueryService.getExpenseAnalysisHistory(userId, pageable);
80+
81+
return ResponseEntity.ok(ApiResponse.success(response));
82+
}
83+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package store.lastdance.converter.analysis;
2+
3+
import org.springframework.stereotype.Component;
4+
import store.lastdance.domain.analysis.ExpenseAnalysisHistory;
5+
import store.lastdance.dto.analysis.AnalyzeExpenseResponseDTO;
6+
import store.lastdance.dto.analysis.ExpenseAnalysisHistoryDTO;
7+
8+
import java.math.BigDecimal;
9+
import java.math.RoundingMode;
10+
import java.util.List;
11+
12+
@Component
13+
public class AnalysisV2Converter {
14+
15+
public ExpenseAnalysisHistoryDTO toExpenseAnalysisHistoryDTO(ExpenseAnalysisHistory history) {
16+
return ExpenseAnalysisHistoryDTO.from(history);
17+
}
18+
19+
public AnalyzeExpenseResponseDTO toAnalyzeExpenseResponseDTO(
20+
Long historyId,
21+
AnalyzeExpenseResponseDTO.BudgetUsage budgetUsage,
22+
AnalyzeExpenseResponseDTO.DailySpending dailySpending,
23+
AnalyzeExpenseResponseDTO.AnalysisResult analysisResult,
24+
List<AnalyzeExpenseResponseDTO.CategoryDetail> categoryDetails
25+
) {
26+
return new AnalyzeExpenseResponseDTO(historyId, budgetUsage, dailySpending, analysisResult, categoryDetails == null ? List.of() : List.copyOf(categoryDetails));
27+
}
28+
29+
public AnalyzeExpenseResponseDTO.BudgetUsage toBudgetUsage(double percentage, BigDecimal currentSpending, BigDecimal totalBudget) {
30+
BigDecimal roundedPercentage = BigDecimal.valueOf(percentage).setScale(2, RoundingMode.HALF_UP);
31+
return new AnalyzeExpenseResponseDTO.BudgetUsage(roundedPercentage.doubleValue(), currentSpending, totalBudget);
32+
}
33+
34+
public AnalyzeExpenseResponseDTO.DailySpending toDailySpending(BigDecimal averageSoFar, BigDecimal estimatedEom) {
35+
return new AnalyzeExpenseResponseDTO.DailySpending(averageSoFar, estimatedEom);
36+
}
37+
38+
public AnalyzeExpenseResponseDTO.AnalysisResult toAnalysisResult(String mainFinding, AnalyzeExpenseResponseDTO.Suggestion suggestion) {
39+
return new AnalyzeExpenseResponseDTO.AnalysisResult(mainFinding, suggestion);
40+
}
41+
42+
public AnalyzeExpenseResponseDTO.CategoryDetail toCategoryDetail(String category, double percentage, BigDecimal totalAmount, int transactionCount) {
43+
BigDecimal roundedPercentage = BigDecimal.valueOf(percentage).setScale(2, RoundingMode.HALF_UP);
44+
return new AnalyzeExpenseResponseDTO.CategoryDetail(category, roundedPercentage.doubleValue(), totalAmount, transactionCount);
45+
}
46+
}

src/main/java/store/lastdance/domain/analysis/ExpenseAnalysisHistory.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package store.lastdance.domain.analysis;
22

33
import jakarta.persistence.*;
4+
import jakarta.persistence.Version;
45
import lombok.AccessLevel;
56
import lombok.Builder;
67
import lombok.Getter;
@@ -25,6 +26,9 @@ public class ExpenseAnalysisHistory extends BaseTimeEntity {
2526
@GeneratedValue(strategy = GenerationType.IDENTITY)
2627
private Long id;
2728

29+
@Version
30+
private Long version;
31+
2832
@ManyToOne(fetch = FetchType.LAZY)
2933
@JoinColumn(name = "user_id", nullable = false)
3034
private User user;

src/main/java/store/lastdance/exception/ErrorCode.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@ public enum ErrorCode {
114114
NOTIFICATION_SETTING_FOUND_FAILED("알림 설정 조회에 실패했습니다", HttpStatus.INTERNAL_SERVER_ERROR),
115115
NOTIFICATION_SETTING_UPDATE_FAILED("알림 설정 수정에 실패했습니다", HttpStatus.INTERNAL_SERVER_ERROR),
116116
NOTIFICATION_SETTING_NOT_FOUND("알림 설정을 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
117-
NOTIFICATION_SETTING_ALREADY_EXISTS("사용자의 알림 설정이 이미 존재합니다", HttpStatus.CONFLICT);
117+
NOTIFICATION_SETTING_ALREADY_EXISTS("사용자의 알림 설정이 이미 존재합니다", HttpStatus.CONFLICT),
118+
119+
// 동시성 제어 관련
120+
OPTIMISTIC_LOCK_FAILURE("다른 사용자에 의해 데이터가 변경되었습니다. 다시 시도해주세요.", HttpStatus.CONFLICT);
118121

119122
private final String message;
120123
private final HttpStatus httpStatus;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package store.lastdance.service.analysis;
2+
3+
import store.lastdance.domain.analysis.FeedbackType;
4+
5+
import java.util.UUID;
6+
7+
public interface AnalysisV2CommandService {
8+
FeedbackType toggleFeedback(Long historyId, UUID userId, FeedbackType type);
9+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package store.lastdance.service.analysis;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.dao.OptimisticLockingFailureException;
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Transactional;
7+
import store.lastdance.domain.analysis.ExpenseAnalysisHistory;
8+
import store.lastdance.domain.analysis.FeedbackType;
9+
import store.lastdance.exception.CustomException;
10+
import store.lastdance.exception.ErrorCode;
11+
import store.lastdance.repository.analysis.ExpenseAnalysisHistoryRepository;
12+
import store.lastdance.service.analysis.validator.AnalysisHistoryValidator;
13+
14+
import java.util.UUID;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
@Transactional
19+
public class AnalysisV2CommandServiceImpl implements AnalysisV2CommandService {
20+
21+
private final AnalysisHistoryValidator analysisHistoryValidator;
22+
private final ExpenseAnalysisHistoryRepository expenseAnalysisHistoryRepository;
23+
24+
@Override
25+
public FeedbackType toggleFeedback(Long historyId, UUID userId, FeedbackType type) {
26+
// 검증 로직 위임
27+
ExpenseAnalysisHistory history = analysisHistoryValidator.validate(historyId, userId);
28+
29+
try {
30+
// 비즈니스 로직
31+
boolean isUp = (type == FeedbackType.UP);
32+
boolean isDown = (type == FeedbackType.DOWN);
33+
34+
boolean cancel = (isUp && Boolean.TRUE.equals(history.getUp())) || (isDown && Boolean.TRUE.equals(history.getDown()));
35+
if (cancel) {
36+
history.feedback(null, null);
37+
} else {
38+
history.feedback(isUp, isDown);
39+
}
40+
41+
expenseAnalysisHistoryRepository.saveAndFlush(history);
42+
return cancel ? null : type;
43+
44+
} catch (OptimisticLockingFailureException e) {
45+
throw new CustomException(ErrorCode.OPTIMISTIC_LOCK_FAILURE);
46+
}
47+
}
48+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package store.lastdance.service.analysis;
2+
3+
import org.springframework.data.domain.Pageable;
4+
import store.lastdance.dto.analysis.AnalyzeExpenseRequestDTO;
5+
import store.lastdance.dto.analysis.AnalyzeExpenseResponseDTO;
6+
import store.lastdance.dto.analysis.ExpenseAnalysisHistoryDTO;
7+
import store.lastdance.dto.response.PageWithSummaryResponse;
8+
9+
import java.util.UUID;
10+
11+
public interface AnalysisV2QueryService {
12+
AnalyzeExpenseResponseDTO analyzeExpenses(UUID userId, AnalyzeExpenseRequestDTO requestDTO);
13+
PageWithSummaryResponse<ExpenseAnalysisHistoryDTO> getExpenseAnalysisHistory(UUID userId, Pageable pageable);
14+
}

0 commit comments

Comments
 (0)