-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 티켓 시스템 구현 #122
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
feat: 티켓 시스템 구현 #122
Changes from all commits
aba7548
d0d0c17
8db6705
1213fca
d809465
3623ce6
f602804
09eeb74
8934c8a
97fb536
2091668
9424ab2
e4d5373
4cc0fa0
c130a61
10bf8ce
679cef3
9248edb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package hanium.modic.backend.domain.ai.domain; | ||
|
|
||
| import static hanium.modic.backend.common.error.ErrorCode.*; | ||
| import static hanium.modic.backend.domain.ai.enums.AiRequestTicketConstants.*; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| import hanium.modic.backend.common.entity.BaseEntity; | ||
| import hanium.modic.backend.common.error.exception.AppException; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.GenerationType; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Table(name = "ai_request_tickets") | ||
| @Entity | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class AiRequestTicketEntity extends BaseEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(name = "user_id", nullable = false, unique = true) | ||
| private Long userId; | ||
|
|
||
| @Column(name = "ticket_count", nullable = false) | ||
| private Integer ticketCount; | ||
|
|
||
| @Column(name = "last_issued_at", nullable = false) | ||
| private LocalDateTime lastIssuedAt; | ||
|
|
||
| @Builder | ||
| private AiRequestTicketEntity(Long userId) { | ||
| this.userId = userId; | ||
| this.ticketCount = FREE_TICKET_COUNT_PER_DAY; | ||
| this.lastIssuedAt = LocalDateTime.now(); | ||
| } | ||
|
|
||
| // 잔여 티켓 차감 | ||
| public void decreaseTicket() { | ||
| if (this.ticketCount <= MINIMUM_TICKET_COUNT) { | ||
| throw new AppException(AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION); | ||
| } | ||
| this.ticketCount--; | ||
| } | ||
|
|
||
| // 티켓을 초기화 | ||
| public void resetTickets() { | ||
| this.ticketCount = FREE_TICKET_COUNT_PER_DAY; | ||
| this.lastIssuedAt = LocalDateTime.now(); | ||
| } | ||
|
|
||
| public boolean hasTickets() { | ||
| return this.ticketCount > MINIMUM_TICKET_COUNT; | ||
| } | ||
|
|
||
| public boolean isTicketExpired() { | ||
| return LocalDateTime.now().isAfter(this.lastIssuedAt.plusDays(1)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package hanium.modic.backend.domain.ai.enums; | ||
|
|
||
| public final class AiRequestTicketConstants { | ||
|
|
||
| private AiRequestTicketConstants() { | ||
| // Prevent instantiation | ||
| } | ||
|
|
||
| public static final int FREE_TICKET_COUNT_PER_DAY = 3; | ||
|
|
||
| public static final int MINIMUM_TICKET_COUNT = 0; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package hanium.modic.backend.domain.ai.repository; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import hanium.modic.backend.domain.ai.domain.AiRequestTicketEntity; | ||
|
|
||
| public interface AiRequestTicketRepository extends JpaRepository<AiRequestTicketEntity, Long> { | ||
|
|
||
| Optional<AiRequestTicketEntity> findByUserId(Long userId); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,7 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package hanium.modic.backend.domain.ai.service; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import static hanium.modic.backend.common.error.ErrorCode.*; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.stream.Collectors; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -20,8 +22,11 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.domain.ai.repository.AiRequestRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.domain.ai.repository.CreatedAiImageRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.domain.image.domain.ImagePrefix; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.domain.post.entity.PostEntity; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.domain.post.entity.PostImageEntity; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.domain.post.repository.PostEntityRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.domain.post.repository.PostImageEntityRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.domain.user.service.UserCoinService; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.web.ai.dto.response.MyGeneratedAiImageResponse; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.web.ai.dto.response.RequestAiImageGenerationResponse; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.AccessLevel; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -35,16 +40,25 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public class AiImageGenerationService { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final AiImageService aiImageService; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final AiRequestTicketService aiRequestTicketService; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final MessageQueueService messageQueueService; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final PostImageEntityRepository postImageEntityRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final AiRequestRepository aiRequestRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final CreatedAiImageRepository createdAiImageRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final CreatedAiImageService createdAiImageService; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final AiImagePermissionRepository aiImagePermissionRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final UserCoinService userCoinService; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final PostEntityRepository postEntityRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Transactional | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public RequestAiImageGenerationResponse processImageGeneration(ImagePrefix imageUsagePurpose, String fileName, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String imagePath, Long postId, Long userId) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public RequestAiImageGenerationResponse processImageGeneration( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ImagePrefix imageUsagePurpose, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String fileName, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String imagePath, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Long postId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Long userId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Boolean useTicket | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 사용자 권한 검증 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| validateAiRequestPermission(userId, postId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -57,6 +71,18 @@ public RequestAiImageGenerationResponse processImageGeneration(ImagePrefix image | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| AiRequestEntity aiRequestEntity = aiImageService.saveImage(imageUsagePurpose, fileName, imagePath, userId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| postId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 포스트 조회 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| PostEntity post = postEntityRepository.findById(postId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .orElseThrow(() -> new AppException(POST_NOT_FOUND_EXCEPTION)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 결제 처리 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (useTicket) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| aiRequestTicketService.useTicket(userId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Todo : 상업용, 비상업용에 대해 로직 처리 필요 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chain상업용/비상업용 로직 구현이 시급합니다. TODO 주석으로 남겨진 상업용/비상업용 구분 로직이 현재 서비스에서 중요한 기능인지 확인이 필요합니다. 비상업용 가격 로직의 필요성을 확인하고, 필요하다면 구현을 도와드릴 수 있습니다. 다음 스크립트로 관련 코드를 찾아보겠습니다: 🏁 Script executed: #!/bin/bash
# Description: 상업용/비상업용 가격 관련 코드 찾기
# Expected: PostEntity의 가격 필드 사용처와 관련 로직 확인
echo "=== PostEntity 가격 필드 사용처 검색 ==="
rg -A 3 -B 3 "(commercialPrice|nonCommercialPrice)"
echo -e "\n=== 상업용/비상업용 관련 주석이나 TODO 검색 ==="
rg -i "(commercial|non.commercial|상업|비상업)" --type javaLength of output: 28665 AiImageGenerationService에 상업용/비상업용 요금 분기 로직 구현 필요 현재
위 항목을 반영해 비상업용 가격 로직을 완성해주세요. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userCoinService.consumeCoin(userId, post.getCommercialPrice()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+74
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 결제 로직의 안전성을 개선해야 합니다. 현재 결제 로직에서 몇 가지 개선이 필요합니다:
다음과 같이 개선하는 것을 권장합니다: // 이미지 저장 및 ID 반환
AiRequestEntity aiRequestEntity = aiImageService.saveImage(imageUsagePurpose, fileName, imagePath, userId,
postId);
// 포스트 조회
PostEntity post = postEntityRepository.findById(postId)
.orElseThrow(() -> new AppException(POST_NOT_FOUND_EXCEPTION));
- // 결제 처리
- if (useTicket) {
- aiRequestTicketService.useTicket(userId);
- } else {
- // Todo : 상업용, 비상업용에 대해 로직 처리 필요
- userCoinService.consumeCoin(userId, post.getCommercialPrice());
- }
+ // 결제 처리 (트랜잭션 내에서 안전하게 처리)
+ try {
+ if (useTicket) {
+ aiRequestTicketService.useTicket(userId);
+ } else {
+ // 상업용 가격으로 우선 처리 (향후 비상업용 로직 추가 필요)
+ Long price = post.getCommercialPrice();
+ userCoinService.consumeCoin(userId, price);
+ }
+ } catch (Exception e) {
+ // 결제 실패 시 저장된 AI 요청 삭제 또는 상태 변경 필요
+ log.error("결제 처리 실패: userId={}, postId={}, useTicket={}", userId, postId, useTicket, e);
+ throw e;
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // MQ에 이미지 생성 요청 전송 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| messageQueueService.sendImageGenerationRequest( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| aiRequestEntity.getRequestId(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| package hanium.modic.backend.domain.ai.service; | ||
|
|
||
| import static hanium.modic.backend.common.error.ErrorCode.*; | ||
|
|
||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import hanium.modic.backend.common.error.exception.AppException; | ||
| import hanium.modic.backend.common.error.exception.LockException; | ||
| import hanium.modic.backend.common.redis.distributedLock.LockManager; | ||
| import hanium.modic.backend.domain.ai.domain.AiRequestTicketEntity; | ||
| import hanium.modic.backend.domain.ai.repository.AiRequestTicketRepository; | ||
| import hanium.modic.backend.web.ai.dto.response.GetTicketInformationResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class AiRequestTicketService { | ||
|
|
||
| private final AiRequestTicketRepository aiRequestTicketRepository; | ||
| private final LockManager lockManager; | ||
|
|
||
| // 티켓 관련 정보 조회 | ||
| @Transactional(readOnly = true) | ||
| public GetTicketInformationResponse getTicketInformation(Long userId) { | ||
| AiRequestTicketEntity ticket = getTicketEntity(userId); | ||
| return GetTicketInformationResponse.of(ticket.getTicketCount(), ticket.getLastIssuedAt().plusDays(1)); | ||
| } | ||
|
|
||
| // 티켓 엔티티 조회, 만료되면 갱신 | ||
| @Transactional | ||
| public AiRequestTicketEntity getTicketEntity(Long userId) { | ||
| return aiRequestTicketRepository.findByUserId(userId) | ||
| .map(this::refreshTicketIfExpired) | ||
| .orElseGet(() -> createInitialTicket(userId)); | ||
| } | ||
|
|
||
| // 티켓 한 개 소모, 잔여 티켓이 없으면 에러 | ||
| @Transactional | ||
| public void useTicket(Long userId) { | ||
| try { | ||
| lockManager.aiRequestTicketLock(userId, () -> { | ||
| // 티켓 조회, 티켓 만료 체크, 만료되면 티켓 초기화, 재진입 가능 락이라 refreshTicketsIfExpired 메서드에서 락 호출 가능. | ||
| AiRequestTicketEntity userTicket = getTicketEntity(userId); | ||
|
|
||
|
|
||
| // 티켓 차감, 잔여 티켓이 없으면 예외 발생 | ||
| userTicket.decreaseTicket(); | ||
| aiRequestTicketRepository.save(userTicket); | ||
| }); | ||
| } catch (LockException e) { | ||
| throw new AppException(AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION); | ||
| } | ||
| } | ||
|
|
||
| // 티켓이 만료되면 초기화 | ||
| private AiRequestTicketEntity refreshTicketIfExpired(AiRequestTicketEntity userTicket) { | ||
| try { | ||
| lockManager.aiRequestTicketLock(userTicket.getUserId(), () -> { | ||
| if (userTicket.isTicketExpired()) { | ||
| userTicket.resetTickets(); | ||
| } | ||
| aiRequestTicketRepository.save(userTicket); | ||
| }); | ||
| } catch (LockException e) { | ||
| throw new AppException(AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION); | ||
| } | ||
|
|
||
| return userTicket; | ||
| } | ||
|
|
||
| // 사용자가 티켓을 생성 및 저장 | ||
| @Transactional | ||
| public AiRequestTicketEntity createInitialTicket(Long userId) { | ||
| AiRequestTicketEntity newUserTicket = AiRequestTicketEntity.builder() | ||
| .userId(userId) | ||
| .build(); | ||
|
|
||
| return aiRequestTicketRepository.save(newUserTicket); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic for checking ticket expiration is correct. However,
LocalDateTime.now()can be problematic in unit tests as it's hard to control. For better testability, consider injecting ajava.time.Clockinstance and usingLocalDateTime.now(clock)instead. This allows you to provide a fixed clock in your tests to simulate the passage of time.This is not a critical change, but a good practice for future maintainability and testing.