Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
aba7548
feat: 티켓 기능 구현
yooooonshine Jul 23, 2025
d0d0c17
feat: 잔여 코인 조회 API 구현
yooooonshine Jul 23, 2025
8db6705
fix: 오타 수정
yooooonshine Jul 23, 2025
1213fca
fix: 티켓 갱신일 오류 수정
yooooonshine Jul 23, 2025
d809465
feat: 티켓 테스트 구현
yooooonshine Jul 23, 2025
3623ce6
feat: 티켓 테스트 구현
yooooonshine Jul 23, 2025
f602804
refactor: processImageGeneration_Success() 테스트 변경
yooooonshine Jul 23, 2025
09eeb74
refactor: requestAiImageGenerationValidationFail() 테스트 변경
yooooonshine Jul 23, 2025
8934c8a
refactor: 에러코드 수정
yooooonshine Jul 23, 2025
97fb536
Update src/main/java/hanium/modic/backend/common/redis/distributedLoc…
yooooonshine Jul 23, 2025
2091668
refactor: decreaseTicket에 예외처리 추가
yooooonshine Jul 23, 2025
9424ab2
Merge branch 'feature/82-implement-ticket' of https://github.com/Modi…
yooooonshine Jul 23, 2025
e4d5373
refactor: AiRequestTicketConstants클래스 상속불가 및 생성자 금지로직 추가
yooooonshine Jul 23, 2025
4cc0fa0
fix: aiRequestTicketLock 오타 수정
yooooonshine Jul 23, 2025
c130a61
refactor: refreshTicketIfExpired 중복 호출 제거
yooooonshine Jul 23, 2025
10bf8ce
refactor: refreshTicketIfExpired메서드 접근자 private로 변경
yooooonshine Jul 23, 2025
679cef3
refactor: 불필요한 AiRequestTicketService 주입 제거
yooooonshine Jul 23, 2025
9248edb
refactor: 사용자 ID를 user로부터 받아오도록 변경
yooooonshine Jul 23, 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 @@ -73,11 +73,9 @@ public enum ErrorCode {
AI_REQUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "A-001", "해당 AI 요청을 찾을 수 없습니다."),
CREATED_AI_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "A-002", "생성된 AI 이미지를 찾을 수 없습니다."),
AI_IMAGE_DATA_INCONSISTENCY(HttpStatus.INTERNAL_SERVER_ERROR, "A-003", "AI 이미지 데이터 일관성 오류가 발생했습니다."),

// AI Image Permission
AI_IMAGE_PERMISSION_NOT_FOUND(HttpStatus.FORBIDDEN, "AI-003", "AI 이미지 생성 권한이 없습니다."),
AI_IMAGE_PERMISSION_ALREADY_EXISTS(HttpStatus.CONFLICT, "AI-004", "이미 AI 이미지 생성 권한이 존재합니다."),
AI_IMAGE_PERMISSION_ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "AI-005", "해당 AI 이미지 생성 권한을 찾을 수 없습니다."),
AI_IMAGE_PERMISSION_NOT_FOUND(HttpStatus.FORBIDDEN, "A-004", "AI 이미지 생성 권한이 없습니다."),
AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "A-005", "티켓 처리에 실패했습니다."),
AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION(HttpStatus.BAD_REQUEST, "AI-006", "티켓이 부족합니다."),
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class LockManager {
private static final long postLikeLeaseTime = 1L; // 좋아요 락 유지 시간(1초)
private static final String REDISSON_USER_LOCK_PREFIX = "USER_LOCK:";
private static final String REDISSON_POST_LIKE_LOCK_PREFIX = "POST_LIKE:";
private static final String REDISSON_USER_TICKET_LOCK_PREFIX = "USER_TICKET_LOCK:";

// 유저 단일 락
public void userLock(
Expand Down Expand Up @@ -118,4 +119,30 @@ public void postLikeLock(
}
}
}

// AI 요청 티켓 락
public void aiRequestTicketLock(
final Long userId,
Runnable block
) throws LockException {
String key = REDISSON_USER_TICKET_LOCK_PREFIX + userId;
RLock rLock = redissonClient.getLock(key);

try {
boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
if (!available) {
throw new LockException(new InterruptedException("유저 티켓 락 획득 실패"));
}

aopForTransaction.proceed(block); // lock 범위 안에서 트랜잭션 적용 후 로직 처리
} catch (InterruptedException e) {
throw new LockException(e); // 락 획득 실패시 Exception 발생
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("유저 티켓 분산 락이 이미 해제되었습니다. :key({})", key);
}
}
}
}
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));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 a java.time.Clock instance and using LocalDateTime.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.

}
}
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;
Expand All @@ -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;
Expand All @@ -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);

Expand All @@ -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 : 상업용, 비상업용에 대해 로직 처리 필요
Copy link

Choose a reason for hiding this comment

The 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 java

Length of output: 28665


AiImageGenerationService에 상업용/비상업용 요금 분기 로직 구현 필요

현재 AiImageGenerationService.java(82행)에는 TODO 주석만 남겨져 있고, 실제로는 항상 post.getCommercialPrice()만 소비하고 있습니다. 비상업용 가격을 처리하려면 다음을 반영해주세요:

  • 대상 파일 및 위치

    • src/main/java/hanium/modic/backend/domain/ai/service/AiImageGenerationService.java
      • 라인 82: // Todo : 상업용, 비상업용에 대해 로직 처리 필요
      • 바로 아래: userCoinService.consumeCoin(userId, post.getCommercialPrice());
  • 구현 예시 (diff)

    - // Todo : 상업용, 비상업용에 대해 로직 처리 필요
    - userCoinService.consumeCoin(userId, post.getCommercialPrice());
    + // 상업/비상업 구분에 따른 소비 코인 결정
    + long price = (usageType == UsageType.COMMERCIAL)
    +     ? post.getCommercialPrice()
    +     : post.getNonCommercialPrice();
    + userCoinService.consumeCoin(userId, price);
  • 추가 작업

    1. Web/Controller 레이어의 요청 DTO(Create/UpdatePostRequest)에 usageType 필드 추가
    2. AiImageGenerationService.generate… 메서드 시그니처에 usageType 파라미터 반영
    3. 관련 테스트(PostControllerIntegrationTest 등)에도 usageType 기반 로직 검증 추가

위 항목을 반영해 비상업용 가격 로직을 완성해주세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Todo : 상업용, 비상업용에 대해 로직 처리 필요
// 상업/비상업 구분에 따른 소비 코인 결정
long price = (usageType == UsageType.COMMERCIAL)
? post.getCommercialPrice()
: post.getNonCommercialPrice();
userCoinService.consumeCoin(userId, price);
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/ai/service/AiImageGenerationService.java
at line 82, replace the TODO comment and the current unconditional call to
userCoinService.consumeCoin(userId, post.getCommercialPrice()) with logic that
checks the usageType parameter and consumes either the commercial or
non-commercial price accordingly. Also, add a usageType field to the request DTO
in the Web/Controller layer, update the generate method signature in
AiImageGenerationService to accept usageType, and extend related tests to verify
the new usageType-based pricing logic.

userCoinService.consumeCoin(userId, post.getCommercialPrice());
}
Comment on lines +74 to +84
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

결제 로직의 안전성을 개선해야 합니다.

현재 결제 로직에서 몇 가지 개선이 필요합니다:

  1. 결제 실패 시 롤백 처리가 필요합니다
  2. 티켓 사용과 코인 사용 모두에서 예외 발생 시 저장된 이미지 요청을 정리해야 합니다
  3. 상업용/비상업용 가격 로직이 구현되지 않았습니다

다음과 같이 개선하는 것을 권장합니다:

	// 이미지 저장 및 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 포스트 조회
PostEntity post = postEntityRepository.findById(postId)
.orElseThrow(() -> new AppException(POST_NOT_FOUND_EXCEPTION));
// 결제 처리
if (useTicket) {
aiRequestTicketService.useTicket(userId);
} else {
// Todo : 상업용, 비상업용에 대해 로직 처리 필요
userCoinService.consumeCoin(userId, post.getCommercialPrice());
}
// 이미지 저장 및 ID 반환
AiRequestEntity aiRequestEntity = aiImageService.saveImage(imageUsagePurpose, fileName, imagePath, userId,
postId);
// 포스트 조회
PostEntity post = postEntityRepository.findById(postId)
.orElseThrow(() -> new AppException(POST_NOT_FOUND_EXCEPTION));
// 결제 처리 (트랜잭션 내에서 안전하게 처리)
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;
}
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/domain/ai/service/AiImageGenerationService.java
around lines 74 to 84, improve the payment logic by adding transaction
management to ensure rollback on failure, implement exception handling to clean
up any saved image requests if ticket or coin usage fails, and complete the
logic to differentiate between commercial and non-commercial pricing. Wrap the
payment steps in a try-catch block or use a transactional annotation to handle
rollback, ensure cleanup code runs on exceptions, and add the missing
commercial/non-commercial price handling before consuming coins.


// MQ에 이미지 생성 요청 전송
messageQueueService.sendImageGenerationRequest(
aiRequestEntity.getRequestId(),
Expand Down

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);
}
}
Loading