Skip to content

feat: 티켓 시스템 구현#122

Merged
yooooonshine merged 18 commits intodevelopfrom
feature/82-implement-ticket
Jul 28, 2025
Merged

feat: 티켓 시스템 구현#122
yooooonshine merged 18 commits intodevelopfrom
feature/82-implement-ticket

Conversation

@yooooonshine
Copy link
Contributor

@yooooonshine yooooonshine commented Jul 23, 2025

Summary

티켓 시스템 기능을 구현했습니다. AI 이미지 생성과 연동된 티켓 검증 시스템을 통해 사용자의 AI 이미지 생성 횟수를 제한했습니다.
일 무료 3회 가능하게 했으며, 하루가 지나면 무료 생성 횟수를 리셋하도록 했습니다.

Problem

  • AI 이미지 생성에 대한 무료 티켓 기능 부재

Solution

🎫 티켓 시스템 도입

  • 자동 발급/갱신: 사용자별 티켓 생성 및 만료 시 자동 갱신
  • 소모 기반 제어: AI 이미지 생성 시 티켓 차감으로 사용량 제한
  • 유효성 검증: 요청 시점의 티켓 상태 실시간 검증

Key Changes

🎫 티켓 시스템 (핵심 구현)

// 티켓 엔티티 및 상수 정의
- AiRequestTicketEntity: 사용자별 티켓 정보 관리
- AiRequestTicketConstants: 티켓 제한  갱신 주기 상수
- AiRequestTicketService: 티켓 발급/검증/갱신 로직

Test Results

단위 테스트 (100% 통과)

- AiRequestTicketServiceTest: 티켓 발급/검증/갱신 로직 검증
- AiImageGenerationServiceTest: AI 이미지 생성 플로우 검증  

통합 테스트 (100% 통과)

- AiRequestTicketControllerIntegrationTest: 티켓 API 전체 플로우

📊 핵심 기능 검증 완료

  • 🎫 티켓 시스템: 발급 → 소모 → 갱신 전체 사이클 검증

🚀 성능 테스트

  • 응답 시간: 평균 200ms 이하 (캐싱 적용)
  • 동시성: 100개 동시 요청 처리 안정성 확인

Summary by CodeRabbit

  • 신규 기능

    • AI 이미지 생성 시 티켓 또는 코인 결제 방식 선택 기능이 추가되었습니다.
    • 사용자의 AI 요청 티켓 잔여 수량 및 리셋 시간을 조회하는 API가 추가되었습니다.
    • 사용자의 코인 잔액을 조회하는 API가 추가되었습니다.
  • 변경 사항

    • AI 이미지 생성 요청 시 결제 방식(useTicket) 입력이 필수로 변경되었습니다.
    • AI 이미지 생성 관련 일부 에러코드가 변경 및 추가되었습니다.
  • 삭제

    • AI 이미지 생성 권한(퍼미션) 관련 API 및 서비스가 제거되었습니다.
  • 버그 수정

    • AI 요청 티켓 동시성 문제를 방지하기 위한 분산 락 적용.
  • 테스트

    • AI 요청 티켓 서비스 및 컨트롤러에 대한 단위/통합 테스트가 추가되었습니다.

- 잔여 티켓 조회 기능 구현
- 티켓 소모 기능 구현
- 티켓 일마다 자동충전 기능 구현
- 기존 AI 요청에 티켓 추가
- Ticker -> Ticket
- 발급일이 보이는 오류를 갱신일로 수정
- 통합 테스트
- 서비스 테스트
- 컨트롤러 테스트
- 통합 테스트
- 서비스 테스트
- 컨트롤러 테스트
- 기존 로직 변경에 따른 테스트 변경
- 기존 로직 변경에 따른 테스트 변경
@yooooonshine yooooonshine linked an issue Jul 23, 2025 that may be closed by this pull request
@coderabbitai
Copy link

coderabbitai bot commented Jul 23, 2025

Caution

Review failed

The head commit changed during the review from e4d5373 to 9248edb.

"""

Walkthrough

AI 이미지 생성 권한(퍼미션) 기반 결제 및 관리 기능이 삭제되고, AI 요청 티켓 기반의 결제 및 관리 시스템이 새롭게 도입되었습니다. 이에 따라 도메인, 서비스, 컨트롤러, DTO, 테스트 등 전반적인 코드가 티켓 중심으로 리팩터링 및 확장되었습니다. 유저 코인 잔액 조회 API도 추가되었습니다.

Changes

파일/그룹 변경 요약
.../ErrorCode.java AI 이미지 퍼미션 관련 에러코드 삭제, 티켓 처리 실패/부족 에러코드 추가
.../LockManager.java 사용자별 AI 요청 티켓 락 메서드 및 키 프리픽스 상수 추가
.../domain/ai/domain/AiRequestTicketEntity.java
.../domain/ai/enums/AiRequestTicketConstants.java
AI 요청 티켓 JPA 엔티티 및 상수 클래스 신설
.../domain/ai/repository/AiRequestTicketRepository.java AI 요청 티켓 JPA 리포지토리 및 userId로 조회 메서드 추가
.../service/AiImageGenerationService.java 이미지 생성 결제 방식에 티켓/코인 분기 추가, 파라미터 및 의존성 확장
.../service/AiImagePermissionService.java
.../controller/AiImagePermissionController.java
AI 이미지 퍼미션 서비스 및 컨트롤러 전체 삭제
.../service/AiRequestTicketService.java AI 요청 티켓 관리 서비스 신규 도입(조회, 생성, 사용, 갱신 등)
.../service/UserCoinService.java 유저 코인 잔액 조회 메서드 추가
.../service/UserService.java AiRequestTicketService 의존성 추가
.../controller/AiImageController.java 이미지 생성 요청 시 useTicket 파라미터 전달로 변경
.../controller/AiRequestTicketController.java AI 요청 티켓 정보 조회 REST 컨트롤러 신설
.../dto/request/AiImageGenerationRequest.java useTicket(Boolean) 필드 추가 및 검증/스키마 적용
.../dto/response/GetTicketInformationResponse.java 티켓 정보 응답 DTO(티켓수, 다음 리셋 시각) 신설
.../controller/UserController.java 유저 코인 잔액 조회 GET 엔드포인트 추가
.../dto/response/GetCoinBalanceResponse.java 코인 잔액 응답 DTO 신설
.../service/AiImageGenerationServiceTest.java processImageGeneration 테스트에 useTicket 파라미터 반영, 의존성/목 추가
.../service/AiRequestTicketServiceTest.java AI 요청 티켓 서비스 통합 테스트 신규 작성(생성, 사용, 만료, 동시성 등)
.../controller/AiRequestTicketControllerIntegrationTest.java 티켓 컨트롤러 통합 테스트 신설(조회, 만료, 유저 분리 등)
.../controller/AiRequestTicketControllerTest.java 티켓 컨트롤러 단위 테스트 신설(정상/잔여0/다른유저 등)
.../controller/AiImageControllerTest.java 이미지 생성 요청 테스트에 useTicket 파라미터 추가

Sequence Diagram(s)

sequenceDiagram
actor User
participant AiImageController
participant AiImageGenerationService
participant AiRequestTicketService
participant UserCoinService
participant PostEntityRepository

User->>AiImageController: POST /api/ai/images (useTicket)
AiImageController->>AiImageGenerationService: processImageGeneration(..., useTicket)
AiImageGenerationService->>PostEntityRepository: findById(postId)
alt useTicket == true
    AiImageGenerationService->>AiRequestTicketService: useTicket(userId)
else
    AiImageGenerationService->>UserCoinService: consumeCoin(userId, price)
end
AiImageGenerationService-->>AiImageController: 이미지 생성 결과 반환
AiImageController-->>User: 응답 반환
Loading
sequenceDiagram
actor User
participant AiRequestTicketController
participant AiRequestTicketService

User->>AiRequestTicketController: GET /api/ai/tickets/me
AiRequestTicketController->>AiRequestTicketService: getTicketInformation(userId)
AiRequestTicketService-->>AiRequestTicketController: 티켓 정보(잔여수, 리셋시각)
AiRequestTicketController-->>User: 응답 반환
Loading

Estimated code review effort

4 (~90분)

Poem

🐇
티켓 들고 깡총깡총,
퍼미션은 안녕~ 이제 티켓이 주인공!
코인도 티켓도, 모두 내 손에,
동시성도 걱정 마, 락으로 꽉 잡았네.
테스트까지 꽉꽉,
오늘도 개발토끼는 신나게 달린다!
🥕✨
"""

✨ Finishing Touches
  • 📝 Generate Docstrings

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Summary of Changes

Hello @yooooonshine, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a ticket-based system for AI image generation, replacing the previous permission-based access. Users receive daily tickets for free generations and can use in-app coins for additional generations. The change includes infrastructure for managing tickets, ensuring data consistency through distributed locking, and providing new API endpoints for users to monitor their ticket and coin statuses. The aim is to complete core application functionalities by integrating a more flexible and controlled AI image generation economy.

Highlights

  • AI Image Generation Ticket System: Implemented a ticket-based system for AI image generation, providing users with a daily quota of free tickets to generate AI images, replacing the previous permission-based system.
  • Flexible Payment Integration: AI image generation requests can now be processed using either a free daily ticket or by consuming user coins, offering more flexibility. The AiImageGenerationService handles this payment logic.
  • Distributed Locking for Ticket Operations: Integrated Redisson-based distributed locking within the AiRequestTicketService and LockManager to ensure thread-safe and consistent ticket consumption and renewal.
  • New API Endpoints for Tickets and Coins: Introduced new API endpoints: /api/ai/tickets/me allows users to check their remaining AI image generation tickets and their next reset time, and /api/user/coins enables users to query their current coin balance.
  • Removal of Legacy Permission System: The old AiImagePermissionService and AiImagePermissionController, along with related error codes and entities, have been removed, streamlining the AI image generation authorization flow to the new ticket system.
  • Comprehensive Testing: Added unit and integration tests for the new AiRequestTicketService and AiRequestTicketController, including concurrency tests to validate the distributed locking mechanism.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in issue comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments or fill out our survey to provide feedback.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a comprehensive ticket system for AI image generation and finalizes many core application features. The implementation includes entities, repositories, services, and controllers for ticket management, along with distributed locking for concurrency control. The changes are well-supported by a suite of unit and integration tests, which is excellent.

My review focuses on improving code clarity, correctness, and adherence to best practices. I've pointed out a few areas for refinement, such as removing a redundant method call, correcting a method name to follow Java conventions, and addressing potentially misleading transactional annotations due to self-invocation. I've also noted an unused dependency that can be cleaned up.

Overall, this is a substantial and well-executed feature implementation. Addressing these minor points will further enhance the quality and maintainability of the codebase.

}

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.

Comment on lines +93 to +100
// Reflection을 사용하여 lastIssuedAt을 어제로 설정
try {
java.lang.reflect.Field field = expiredTicket.getClass().getDeclaredField("lastIssuedAt");
field.setAccessible(true);
field.set(expiredTicket, LocalDateTime.now().minusDays(2));
} catch (Exception e) {
throw new RuntimeException(e);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Using reflection in tests is a valid approach to modify private state, but it can be brittle if the field name changes. An alternative approach for better encapsulation and testability is to add a package-private or protected constructor to AiRequestTicketEntity that allows setting lastIssuedAt.

For example:

// In AiRequestTicketEntity.java
@Builder
private AiRequestTicketEntity(Long userId, LocalDateTime lastIssuedAt) {
    this.userId = userId;
    this.ticketCount = FREE_TICKET_COUNT_PER_DAY;
    this.lastIssuedAt = lastIssuedAt != null ? lastIssuedAt : LocalDateTime.now();
}

This is just a suggestion for future consideration to improve test design.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (8)
src/main/java/hanium/modic/backend/domain/ai/enums/AiRequestTicketConstants.java (1)

3-8: 상수 클래스 구조 개선을 제안합니다.

상수 클래스의 기본 구조는 좋지만, 다음과 같은 개선사항을 고려해보세요:

-public class AiRequestTicketConstants {
+public final class AiRequestTicketConstants {
+
+	private AiRequestTicketConstants() {
+		// 인스턴스화 방지
+	}

	public static final int FREE_TICKET_COUNT_PER_DAY = 3;

	public static final int MINIMUM_TICKET_COUNT = 0;
}
  • final 클래스로 만들어 상속을 방지
  • private 생성자 추가로 인스턴스화 방지
src/main/java/hanium/modic/backend/web/ai/dto/response/GetTicketInformationResponse.java (1)

7-21: 잘 설계된 응답 DTO이지만 nullable 타입을 검토해보세요.

Record 패턴과 Swagger 문서화가 잘 적용되었습니다. 다만 ticketCountInteger 타입으로 선언되어 null 값을 허용하는데, 비즈니스 로직상 티켓 수가 null일 가능성이 없다면 primitive int 타입 사용을 고려해보세요.

public record GetTicketInformationResponse(
	@Schema(description = "남은 티켓 수")
-	Integer ticketCount,
+	int ticketCount,

	@Schema(description = "다음 리셋까지 시간")
	LocalDateTime nextReset
) {
src/main/java/hanium/modic/backend/web/ai/controller/AiRequestTicketController.java (1)

30-36: API 문서 응답 코드를 보완해야 합니다.

현재 500 에러만 문서화되어 있는데, 인증 관련 에러(401, 403)도 추가하는 것이 좋겠습니다.

다음과 같이 응답 코드를 보완하는 것을 권장합니다:

	@Operation(
		summary = "사용자 티켓 정보 조회",
		description = "현재 사용자의 AI 요청 티켓 정보를 조회합니다. 잔여 티켓 수와 다음 갱신까지의 시간을 포함합니다.",
		responses = {
+			@ApiResponse(responseCode = "200", description = "티켓 정보 조회 성공"),
+			@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"),
			@ApiResponse(responseCode = "500", description = "티켓 처리에 실패했습니다.[U-006]")
		}
	)
src/main/java/hanium/modic/backend/domain/ai/domain/AiRequestTicketEntity.java (2)

29-30: userId에 인덱스 추가 필요

userId 필드는 unique = true 제약 조건이 있지만, 조회 성능 향상을 위해 명시적으로 인덱스를 추가하는 것이 좋습니다.

+@Table(name = "ai_request_tickets", indexes = {
+    @Index(name = "idx_user_id", columnList = "user_id")
+})
 @Entity

62-64: 만료 확인 로직 개선 제안

현재 구현은 정확하지만, 테스트 가능성을 높이기 위해 시간을 주입받을 수 있도록 개선하면 좋겠습니다.

-public boolean isTicketExpired() {
-    return LocalDateTime.now().isAfter(this.lastIssuedAt.plusDays(1));
+public boolean isTicketExpired() {
+    return isTicketExpiredAt(LocalDateTime.now());
+}
+
+public boolean isTicketExpiredAt(LocalDateTime currentTime) {
+    return currentTime.isAfter(this.lastIssuedAt.plusDays(1));
}
src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java (1)

88-131: 리플렉션 사용에 대한 개선 제안

리플렉션을 사용한 테스트는 유지보수가 어렵습니다. 테스트 전용 메서드나 TestFixture를 사용하는 것을 고려해보세요.

// 테스트 헬퍼 클래스 예시
public class AiRequestTicketTestHelper {
    public static AiRequestTicketEntity createExpiredTicket(Long userId) {
        AiRequestTicketEntity ticket = AiRequestTicketEntity.builder()
            .userId(userId)
            .build();
        // 패키지 레벨 메서드나 테스트 전용 setter 사용
        ticket.setLastIssuedAtForTesting(LocalDateTime.now().minusDays(2));
        return ticket;
    }
}
src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java (1)

62-77: 불필요한 저장 작업 최적화

티켓이 만료되지 않은 경우에도 save를 호출하고 있습니다. 변경사항이 있을 때만 저장하도록 개선하면 좋겠습니다.

 public AiRequestTicketEntity refreshTicketIfExpired(AiRequestTicketEntity userTicket) {
     try {
         lockManager.AiRequestTicketLock(userTicket.getUserId(), () -> {
             if (userTicket.isTicketExpired()) {
                 userTicket.resetTickets();
+                aiRequestTicketRepository.save(userTicket);
             }
-            aiRequestTicketRepository.save(userTicket);
         });
     } catch (LockException e) {
         throw new AppException(AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION);
     }
     
     return userTicket;
 }
src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java (1)

273-286: ExecutorService 종료 처리 개선 제안

현재 구현도 좋지만, 로깅을 추가하면 디버깅에 도움이 됩니다.

 private void shutdownExecutor(ExecutorService executor) {
     executor.shutdown();
     try {
         if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
             executor.shutdownNow();
             if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
-                System.err.println("Executor did not terminate");
+                throw new IllegalStateException("Executor did not terminate within timeout");
             }
         }
     } catch (InterruptedException e) {
         executor.shutdownNow();
         Thread.currentThread().interrupt();
+        throw new IllegalStateException("Thread interrupted while waiting for executor termination", e);
     }
 }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 475f55d and 09eeb74.

📒 Files selected for processing (22)
  • src/main/java/hanium/modic/backend/common/error/ErrorCode.java (1 hunks)
  • src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (2 hunks)
  • src/main/java/hanium/modic/backend/domain/ai/domain/AiRequestTicketEntity.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/ai/enums/AiRequestTicketConstants.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/ai/repository/AiRequestTicketRepository.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/ai/service/AiImageGenerationService.java (4 hunks)
  • src/main/java/hanium/modic/backend/domain/ai/service/AiImagePermissionService.java (0 hunks)
  • src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java (1 hunks)
  • src/main/java/hanium/modic/backend/domain/user/service/UserCoinService.java (2 hunks)
  • src/main/java/hanium/modic/backend/domain/user/service/UserService.java (2 hunks)
  • src/main/java/hanium/modic/backend/web/ai/controller/AiImageController.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/ai/controller/AiImagePermissionController.java (0 hunks)
  • src/main/java/hanium/modic/backend/web/ai/controller/AiRequestTicketController.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/ai/dto/request/AiImageGenerationRequest.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/ai/dto/response/GetTicketInformationResponse.java (1 hunks)
  • src/main/java/hanium/modic/backend/web/user/controller/UserController.java (2 hunks)
  • src/main/java/hanium/modic/backend/web/user/dto/response/GetCoinBalanceResponse.java (1 hunks)
  • src/test/java/hanium/modic/backend/domain/ai/service/AiImageGenerationServiceTest.java (4 hunks)
  • src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java (1 hunks)
  • src/test/java/hanium/modic/backend/web/ai/controller/AiImageControllerTest.java (1 hunks)
  • src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java (1 hunks)
  • src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerTest.java (1 hunks)
🧬 Code Graph Analysis (6)
src/main/java/hanium/modic/backend/domain/ai/domain/AiRequestTicketEntity.java (1)
src/main/java/hanium/modic/backend/domain/ai/enums/AiRequestTicketConstants.java (1)
  • AiRequestTicketConstants (3-8)
src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java (2)
src/main/java/hanium/modic/backend/domain/ai/enums/AiRequestTicketConstants.java (1)
  • AiRequestTicketConstants (3-8)
src/test/java/hanium/modic/backend/base/login/ContextHolderUtil.java (1)
  • ContextHolderUtil (10-25)
src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java (1)
src/test/java/hanium/modic/backend/domain/user/factory/UserFactory.java (1)
  • UserFactory (9-29)
src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerTest.java (1)
src/test/java/hanium/modic/backend/base/BaseControllerTest.java (1)
  • BaseControllerTest (13-49)
src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java (1)
src/main/java/hanium/modic/backend/common/error/exception/LockException.java (1)
  • LockException (3-8)
src/test/java/hanium/modic/backend/domain/ai/service/AiImageGenerationServiceTest.java (2)
src/test/java/hanium/modic/backend/domain/post/entityfactory/PostFactory.java (1)
  • PostFactory (10-36)
src/test/java/hanium/modic/backend/domain/user/factory/UserFactory.java (1)
  • UserFactory (9-29)
💤 Files with no reviewable changes (2)
  • src/main/java/hanium/modic/backend/web/ai/controller/AiImagePermissionController.java
  • src/main/java/hanium/modic/backend/domain/ai/service/AiImagePermissionService.java
🧰 Additional context used
🧬 Code Graph Analysis (6)
src/main/java/hanium/modic/backend/domain/ai/domain/AiRequestTicketEntity.java (1)
src/main/java/hanium/modic/backend/domain/ai/enums/AiRequestTicketConstants.java (1)
  • AiRequestTicketConstants (3-8)
src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java (2)
src/main/java/hanium/modic/backend/domain/ai/enums/AiRequestTicketConstants.java (1)
  • AiRequestTicketConstants (3-8)
src/test/java/hanium/modic/backend/base/login/ContextHolderUtil.java (1)
  • ContextHolderUtil (10-25)
src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java (1)
src/test/java/hanium/modic/backend/domain/user/factory/UserFactory.java (1)
  • UserFactory (9-29)
src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerTest.java (1)
src/test/java/hanium/modic/backend/base/BaseControllerTest.java (1)
  • BaseControllerTest (13-49)
src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java (1)
src/main/java/hanium/modic/backend/common/error/exception/LockException.java (1)
  • LockException (3-8)
src/test/java/hanium/modic/backend/domain/ai/service/AiImageGenerationServiceTest.java (2)
src/test/java/hanium/modic/backend/domain/post/entityfactory/PostFactory.java (1)
  • PostFactory (10-36)
src/test/java/hanium/modic/backend/domain/user/factory/UserFactory.java (1)
  • UserFactory (9-29)
🔇 Additional comments (24)
src/main/java/hanium/modic/backend/web/user/dto/response/GetCoinBalanceResponse.java (1)

3-6: 깔끔한 DTO 구현입니다.

Java record를 사용한 간단하고 명확한 응답 DTO입니다. 단일 필드를 위한 구조로 적절하며, Long 타입도 코인 잔액을 표현하기에 적합합니다.

src/main/java/hanium/modic/backend/domain/user/service/UserCoinService.java (1)

26-31: 잘 구현된 코인 잔액 조회 메서드입니다.

기존 클래스의 패턴을 잘 따르고 있으며, 적절한 예외 처리와 응답 DTO 사용이 좋습니다. 메서드명도 명확하고 구현이 간결합니다.

src/main/java/hanium/modic/backend/web/ai/dto/request/AiImageGenerationRequest.java (1)

25-27: 결제 방식 선택 필드가 잘 구현되었습니다.

useTicket 필드 추가가 적절합니다:

  • @NotNull 검증으로 필수 값 보장
  • 명확한 Swagger 문서화
  • Boolean 타입으로 티켓/코인 선택을 직관적으로 표현
  • 티켓 시스템 도입 목적에 부합하는 구현
src/main/java/hanium/modic/backend/domain/ai/repository/AiRequestTicketRepository.java (1)

9-12: 깔끔한 Repository 인터페이스 구현입니다.

Spring Data JPA 컨벤션을 잘 따르고 있으며, Optional 반환 타입으로 null 안전성을 보장하고 있습니다. 사용자별 티켓 조회 메서드도 적절히 정의되었습니다.

src/test/java/hanium/modic/backend/web/ai/controller/AiImageControllerTest.java (1)

86-111: 테스트 데이터 업데이트가 일관성 있게 적용되었습니다.

새로 추가된 useTicket 파라미터가 모든 테스트 케이스에 일관되게 적용되어 있으며, 기존 검증 로직은 그대로 유지되고 있습니다.

src/main/java/hanium/modic/backend/web/user/controller/UserController.java (1)

124-133: 코인 잔액 조회 API가 잘 구현되었습니다.

기존 패턴을 잘 따르고 있으며, 인증 처리(@CurrentUser), Swagger 문서화, 응답 구조가 모두 적절합니다. 티켓 시스템과 함께 사용자에게 결제 수단에 대한 가시성을 제공하는 좋은 기능입니다.

src/main/java/hanium/modic/backend/web/ai/controller/AiImageController.java (1)

87-94: 티켓 사용 옵션이 깔끔하게 전달되고 있습니다.

새로운 useTicket 파라미터가 적절히 서비스 레이어로 전달되고 있으며, 컨트롤러의 책임 범위를 벗어나지 않고 깔끔하게 구현되었습니다.

src/test/java/hanium/modic/backend/domain/ai/service/AiImageGenerationServiceTest.java (4)

36-42: 새로운 의존성 import가 적절히 추가되었습니다.

티켓 시스템 도입에 따른 필요한 의존성들이 올바르게 추가되었습니다.


66-69: 새로운 Mock 객체들이 적절히 선언되었습니다.

PostEntityRepository와 AiRequestTicketService에 대한 Mock 객체가 추가되어 새로운 기능을 테스트할 수 있도록 구성되었습니다.


88-96: 테스트 데이터 설정이 개선되었습니다.

Factory 패턴을 사용하여 테스트 데이터를 생성하고, 새로운 의존성에 대한 적절한 stubbing이 추가되었습니다.


115-117: 기존 실패 테스트가 올바르게 업데이트되었습니다.

새로운 매개변수가 추가되어도 기존의 권한 검증 로직이 여전히 제대로 작동하는지 확인하는 테스트입니다.

src/main/java/hanium/modic/backend/common/error/ErrorCode.java (1)

77-78: 새로운 티켓 시스템 에러 코드가 적절히 추가되었습니다.

에러 코드 네이밍과 HTTP 상태 코드가 적절하며, 기존 패턴을 잘 따르고 있습니다.

src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java (2)

33-33: 새로운 락 프리픽스가 적절히 추가되었습니다.

티켓 전용 락을 위한 프리픽스가 기존 패턴을 따라 명확하게 정의되었습니다.


124-147: 티켓 락 메서드가 올바르게 구현되었습니다.

기존 userLock 메서드와 동일한 패턴을 따르며, 적절한 타임아웃과 리스 시간을 사용하고 있습니다. 예외 처리와 락 해제 로직도 올바르게 구현되어 있습니다.

src/main/java/hanium/modic/backend/domain/ai/service/AiImageGenerationService.java (2)

25-29: 새로운 의존성들이 적절히 추가되었습니다.

티켓 시스템과 코인 결제를 위한 필요한 의존성들이 올바르게 주입되었습니다.

Also applies to: 43-43, 50-51


54-61: 메서드 시그니처가 명확하게 확장되었습니다.

useTicket 매개변수 추가로 결제 방식을 선택할 수 있도록 개선되었습니다.

src/main/java/hanium/modic/backend/web/ai/controller/AiRequestTicketController.java (3)

1-44: 새로운 티켓 컨트롤러가 잘 구현되었습니다.

REST API 설계 원칙을 잘 따르고 있으며, Swagger 문서화, 인증 처리, 응답 형식이 모두 적절합니다.


19-24: API 문서화가 상세하고 명확합니다.

Swagger 태그와 컨트롤러 매핑이 적절하며, 검증 어노테이션도 올바르게 적용되었습니다.


37-43: 엔드포인트 구현이 간결하고 효율적입니다.

인증된 사용자 정보를 올바르게 활용하고, 서비스 레이어와의 결합도가 적절합니다.

src/main/java/hanium/modic/backend/domain/ai/domain/AiRequestTicketEntity.java (1)

38-43: 빌더 구현이 적절합니다

빌더 패턴을 통해 티켓 엔티티를 생성하며, 초기 티켓 수와 발급 시간을 적절히 설정하고 있습니다.

src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java (1)

133-165: 사용자 격리 테스트가 잘 구현되었습니다

다른 사용자의 티켓이 현재 사용자에게 영향을 주지 않는지 확인하는 중요한 테스트입니다. 구현이 명확하고 검증이 충분합니다.

src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java (1)

38-60: 재진입 가능한 RLock 사용으로 중첩 락 안전함
Redisson의 RLock은 동일 키에 대해 재진입이 가능하여, 이미 획득한 락을 같은 스레드에서 다시 요청해도 deadlock이 발생하지 않습니다. 따라서 useTicket 메서드 내에서 refreshTicketIfExpired가 락을 재호출하더라도 안전하며, 기존 구현을 유지하셔도 됩니다.

  • 확인 위치
    • LockManager.AiRequestTicketLock(...)
      (src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java)
    • RLock 사용부: redissonClient.getLock(key).tryLock(...)

Likely an incorrect or invalid review comment.

src/test/java/hanium/modic/backend/domain/ai/service/AiRequestTicketServiceTest.java (1)

190-271: 동시성 테스트가 잘 구현되었습니다

동일 사용자와 다른 사용자 간의 동시 티켓 사용을 테스트하는 중요한 시나리오를 다루고 있습니다. ExecutorService의 적절한 종료 처리도 포함되어 있습니다.

src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerTest.java (1)

36-96: 컨트롤러 테스트가 잘 구성되었습니다

다양한 시나리오(성공, 티켓 없음, 다른 사용자)를 커버하고 있으며, 서비스 계층을 적절히 모킹하여 컨트롤러 로직만 테스트하고 있습니다.

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_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "U-006", "티켓 처리에 실패했습니다."),
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

에러 코드 중복 가능성을 확인해야 합니다.

AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION의 에러 코드가 "U-006"으로 설정되어 있는데, 이미 USER_COIN_TRANSFER_FAIL_EXCEPTION에서 동일한 코드를 사용하고 있습니다.

에러 코드 중복을 확인하기 위해 다음 스크립트를 실행해보겠습니다:

다음과 같이 수정해야 합니다:

-	AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "U-006", "티켓 처리에 실패했습니다."),
+	AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "AI-007", "티켓 처리에 실패했습니다."),

🏁 Script executed:

#!/bin/bash
# Description: ErrorCode enum에서 중복된 코드 값 찾기
# Expected: 중복된 에러 코드가 있다면 출력됨

echo "=== ErrorCode enum에서 중복된 코드 값 검색 ==="
rg -A 1 '"U-006"' src/main/java/hanium/modic/backend/common/error/ErrorCode.java

echo -e "\n=== 모든 에러 코드 값 추출 및 중복 확인 ==="
rg -o '"[A-Z]+-[0-9]+"' src/main/java/hanium/modic/backend/common/error/ErrorCode.java | sort | uniq -d

Length of output: 767


에러 코드 “U-006” 중복 확인 및 수정 필요
AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(src/main/java/hanium/modic/backend/common/error/ErrorCode.java:77)가 USER_COIN_TRANSFER_FAIL_EXCEPTION에서도 동일하게 “U-006”을 사용하고 있습니다. 전체 검사 결과 “U-006” 외에도 “A-001”, “A-002” 코드가 중복되어 있으니, 신규 항목은 고유한 AI 에러 코드(e.g. “AI-007”)로 변경해주세요.

제안된 수정:

-    AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "U-006", "티켓 처리에 실패했습니다."),
+    AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "AI-007", "티켓 처리에 실패했습니다."),
📝 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
AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "U-006", "티켓 처리에 실패했습니다."),
AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "AI-007", "티켓 처리에 실패했습니다."),
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/common/error/ErrorCode.java at line 77,
the error code "U-006" used for AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION is
duplicated in USER_COIN_TRANSFER_FAIL_EXCEPTION and other codes like "A-001" and
"A-002" are also duplicated. To fix this, assign a unique AI-specific error code
such as "AI-007" to this new error constant and ensure all new AI-related error
codes are unique and do not overlap with existing ones.

}

// AI 요청 티켓 락
public void AiRequestTicketLock(
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

메서드 네이밍 컨벤션을 개선해야 합니다.

메서드명 AiRequestTicketLock이 PascalCase로 되어있는데, Java 메서드 네이밍 컨벤션에 따라 camelCase를 사용해야 합니다.

다음과 같이 수정하는 것을 권장합니다:

-	public void AiRequestTicketLock(
+	public void aiRequestTicketLock(

해당 메서드를 사용하는 모든 곳에서도 동일하게 수정해야 합니다.

메서드 사용처를 확인하기 위해 다음 스크립트를 실행해보겠습니다:


🏁 Script executed:

#!/bin/bash
# Description: AiRequestTicketLock 메서드 사용처 찾기
# Expected: 메서드를 호출하는 모든 위치 출력

echo "=== AiRequestTicketLock 메서드 사용처 검색 ==="
rg -A 2 "AiRequestTicketLock\("

Length of output: 1182


메서드명 camelCase로 수정 필요

Java 네이밍 컨벤션에 맞게 AiRequestTicketLock 메서드를 aiRequestTicketLock으로 변경하고, 호출부 2곳에서도 동일하게 수정하세요.

수정 대상:

  • src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java
  • src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java (2곳)

예시 diff:

--- a/src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java
+++ b/src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java
@@ -121,7 +121,7 @@ public class LockManager {
-   public void AiRequestTicketLock(final Long userId, Runnable block) {
+   public void aiRequestTicketLock(final Long userId, Runnable block) {
        // ...
    }
--- a/src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java
+++ b/src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java
@@ -45,7 +45,7 @@ public class AiRequestTicketService {
-       lockManager.AiRequestTicketLock(userId, () -> {
+       lockManager.aiRequestTicketLock(userId, () -> {
            // ...
        });
@@ -60,7 +60,7 @@ public class AiRequestTicketService {
-       lockManager.AiRequestTicketLock(userTicket.getUserId(), () -> {
+       lockManager.aiRequestTicketLock(userTicket.getUserId(), () -> {
            // ...
        });
🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/common/redis/distributedLock/LockManager.java
at line 124, rename the method AiRequestTicketLock to aiRequestTicketLock to
follow Java camelCase naming conventions. Also, update all calls to this method
in
src/main/java/hanium/modic/backend/domain/ai/service/AiRequestTicketService.java
at the two locations where it is invoked to use the new method name
aiRequestTicketLock.

Comment on lines +74 to +84
// 포스트 조회
PostEntity post = postEntityRepository.findById(postId)
.orElseThrow(() -> new AppException(POST_NOT_FOUND_EXCEPTION));

// 결제 처리
if (useTicket) {
aiRequestTicketService.useTicket(userId);
} else {
// Todo : 상업용, 비상업용에 대해 로직 처리 필요
userCoinService.consumeCoin(userId, post.getCommercialPrice());
}
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.

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.

Comment on lines 99 to 101
RequestAiImageGenerationResponse result = aiImageGenerationService.processImageGeneration(
imageUsagePurpose, TEST_FILE_NAME, TEST_IMAGE_PATH, TEST_POST_ID, TEST_USER_ID);
imageUsagePurpose, TEST_FILE_NAME, TEST_IMAGE_PATH, TEST_POST_ID, TEST_USER_ID, true);

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

티켓 사용 시나리오에 대한 테스트 커버리지를 확장해야 합니다.

현재 테스트는 useTicket=true만 테스트하고 있습니다. 코인 결제(useTicket=false) 시나리오와 PostEntity가 존재하지 않는 경우에 대한 테스트도 추가해야 합니다.

다음과 같은 테스트 케이스들을 추가하는 것을 권장합니다:

@Test
@DisplayName("processImageGeneration - 성공: 코인 결제 사용")
void processImageGeneration_Success_WithCoin() {
    // given
    ImagePrefix imageUsagePurpose = ImagePrefix.AI_REQUEST;
    AiRequestEntity mockAiRequest = createTestAiRequestEntity();
    List<PostImageEntity> mockPostImages = List.of(createTestPostImageEntity());
    UserEntity user = UserFactory.createMockUser(TEST_USER_ID);
    PostEntity mockPostEntity = PostFactory.createMockPost(user);

    when(aiImagePermissionRepository.existsByUserIdAndPostId(TEST_USER_ID, TEST_POST_ID)).thenReturn(true);
    when(postImageEntityRepository.findAllByPostId(TEST_POST_ID)).thenReturn(mockPostImages);
    when(aiImageService.saveImage(imageUsagePurpose, TEST_FILE_NAME, TEST_IMAGE_PATH, TEST_USER_ID, TEST_POST_ID))
            .thenReturn(mockAiRequest);
    when(postEntityRepository.findById(TEST_POST_ID)).thenReturn(Optional.of(mockPostEntity));
    doNothing().when(userCoinService).consumeCoin(TEST_USER_ID, mockPostEntity.getCommercialPrice());

    // when
    RequestAiImageGenerationResponse result = aiImageGenerationService.processImageGeneration(
            imageUsagePurpose, TEST_FILE_NAME, TEST_IMAGE_PATH, TEST_POST_ID, TEST_USER_ID, false);

    // then
    assertEquals(TEST_IMAGE_ID, result.imageId());
    verify(userCoinService).consumeCoin(TEST_USER_ID, mockPostEntity.getCommercialPrice());
    verify(aiRequestTicketService, never()).useTicket(anyLong());
}

@Test
@DisplayName("processImageGeneration - 실패: 포스트를 찾을 수 없음")
void processImageGeneration_Fail_PostNotFound() {
    // given
    when(aiImagePermissionRepository.existsByUserIdAndPostId(TEST_USER_ID, TEST_POST_ID)).thenReturn(true);
    when(postImageEntityRepository.findAllByPostId(TEST_POST_ID)).thenReturn(List.of(createTestPostImageEntity()));
    when(aiImageService.saveImage(any(), any(), any(), any(), any())).thenReturn(createTestAiRequestEntity());
    when(postEntityRepository.findById(TEST_POST_ID)).thenReturn(Optional.empty());

    // when & then
    AppException exception = assertThrows(AppException.class, () -> 
        aiImageGenerationService.processImageGeneration(
            ImagePrefix.AI_REQUEST, TEST_FILE_NAME, TEST_IMAGE_PATH, TEST_POST_ID, TEST_USER_ID, true));
    
    assertEquals(ErrorCode.POST_NOT_FOUND_EXCEPTION, exception.getErrorCode());
}
🤖 Prompt for AI Agents
In
src/test/java/hanium/modic/backend/domain/ai/service/AiImageGenerationServiceTest.java
around lines 99 to 101, the current test only covers the scenario where
useTicket is true. You need to add additional test cases to cover the scenario
when useTicket is false (coin payment) and when the PostEntity is not found.
Implement one test method to verify successful image generation with coin
payment, mocking necessary dependencies and verifying coin consumption without
ticket usage. Implement another test method to simulate the failure case when
the post is not found, asserting that the appropriate exception with the correct
error code is thrown.

Comment on lines +35 to +62
@Test
@WithCustomUser(email = "test@test.com")
@DisplayName("통합 테스트 - 티켓 정보 조회 성공")
void getUserTicketInformation_IntegrationTest_Success() throws Exception {
// when & then
ResultActions resultActions = mockMvc.perform(get("/api/ai/tickets/me"));

// 응답 형식 검증
resultActions
.andExpect(jsonPath("$.data.ticketCount").exists())
.andExpect(jsonPath("$.data.nextReset").exists());

// 응답 값 검증
GetTicketInformationResponse ticketInfo = testUtil.getResponseData(
resultActions,
GetTicketInformationResponse.class
);

assertThat(ticketInfo.ticketCount()).isEqualTo(3);
assertThat(ticketInfo.nextReset()).isAfter(LocalDateTime.now());
assertThat(ticketInfo.nextReset()).isBefore(LocalDateTime.now().plusDays(1).plusMinutes(1));

// 데이터베이스에 저장되었는지 확인
AiRequestTicketEntity savedTicket = aiRequestTicketRepository.findByUserId(1L).orElse(null);
assertThat(savedTicket).isNotNull();
assertThat(savedTicket.getTicketCount()).isEqualTo(3);
assertThat(savedTicket.getUserId()).isEqualTo(1L);
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

하드코딩된 사용자 ID 개선 필요

테스트가 사용자 ID가 1L이라고 가정하고 있습니다. 더 안정적인 테스트를 위해 실제 사용자 ID를 가져오는 것이 좋습니다.

 @Test
 @WithCustomUser(email = "test@test.com")
 @DisplayName("통합 테스트 - 티켓 정보 조회 성공")
 void getUserTicketInformation_IntegrationTest_Success() throws Exception {
+    // 현재 인증된 사용자 조회
+    UserEntity currentUser = userRepository.findByEmail("test@test.com")
+        .orElseThrow(() -> new AssertionError("테스트 사용자를 찾을 수 없습니다"));
+    Long userId = currentUser.getId();
+    
     // when & then
     ResultActions resultActions = mockMvc.perform(get("/api/ai/tickets/me"));
     
     // ... 중략 ...
     
     // 데이터베이스에 저장되었는지 확인
-    AiRequestTicketEntity savedTicket = aiRequestTicketRepository.findByUserId(1L).orElse(null);
+    AiRequestTicketEntity savedTicket = aiRequestTicketRepository.findByUserId(userId).orElse(null);
     assertThat(savedTicket).isNotNull();
     assertThat(savedTicket.getTicketCount()).isEqualTo(3);
-    assertThat(savedTicket.getUserId()).isEqualTo(1L);
+    assertThat(savedTicket.getUserId()).isEqualTo(userId);
 }
📝 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
@Test
@WithCustomUser(email = "test@test.com")
@DisplayName("통합 테스트 - 티켓 정보 조회 성공")
void getUserTicketInformation_IntegrationTest_Success() throws Exception {
// when & then
ResultActions resultActions = mockMvc.perform(get("/api/ai/tickets/me"));
// 응답 형식 검증
resultActions
.andExpect(jsonPath("$.data.ticketCount").exists())
.andExpect(jsonPath("$.data.nextReset").exists());
// 응답 값 검증
GetTicketInformationResponse ticketInfo = testUtil.getResponseData(
resultActions,
GetTicketInformationResponse.class
);
assertThat(ticketInfo.ticketCount()).isEqualTo(3);
assertThat(ticketInfo.nextReset()).isAfter(LocalDateTime.now());
assertThat(ticketInfo.nextReset()).isBefore(LocalDateTime.now().plusDays(1).plusMinutes(1));
// 데이터베이스에 저장되었는지 확인
AiRequestTicketEntity savedTicket = aiRequestTicketRepository.findByUserId(1L).orElse(null);
assertThat(savedTicket).isNotNull();
assertThat(savedTicket.getTicketCount()).isEqualTo(3);
assertThat(savedTicket.getUserId()).isEqualTo(1L);
}
@Test
@WithCustomUser(email = "test@test.com")
@DisplayName("통합 테스트 - 티켓 정보 조회 성공")
void getUserTicketInformation_IntegrationTest_Success() throws Exception {
+ // 현재 인증된 사용자 조회
+ UserEntity currentUser = userRepository.findByEmail("test@test.com")
+ .orElseThrow(() -> new AssertionError("테스트 사용자를 찾을 수 없습니다"));
+ Long userId = currentUser.getId();
// when & then
ResultActions resultActions = mockMvc.perform(get("/api/ai/tickets/me"));
// 응답 형식 검증
resultActions
.andExpect(jsonPath("$.data.ticketCount").exists())
.andExpect(jsonPath("$.data.nextReset").exists());
// 응답 값 검증
GetTicketInformationResponse ticketInfo = testUtil.getResponseData(
resultActions,
GetTicketInformationResponse.class
);
assertThat(ticketInfo.ticketCount()).isEqualTo(3);
assertThat(ticketInfo.nextReset()).isAfter(LocalDateTime.now());
assertThat(ticketInfo.nextReset()).isBefore(LocalDateTime.now().plusDays(1).plusMinutes(1));
// 데이터베이스에 저장되었는지 확인
- AiRequestTicketEntity savedTicket = aiRequestTicketRepository.findByUserId(1L).orElse(null);
+ AiRequestTicketEntity savedTicket = aiRequestTicketRepository.findByUserId(userId).orElse(null);
assertThat(savedTicket).isNotNull();
assertThat(savedTicket.getTicketCount()).isEqualTo(3);
- assertThat(savedTicket.getUserId()).isEqualTo(1L);
+ assertThat(savedTicket.getUserId()).isEqualTo(userId);
}
🤖 Prompt for AI Agents
In
src/test/java/hanium/modic/backend/web/ai/controller/AiRequestTicketControllerIntegrationTest.java
between lines 35 and 62, the test hardcodes the user ID as 1L when verifying the
saved ticket entity. To improve test reliability, retrieve the actual user ID
dynamically from the authenticated test user context or the test setup instead
of using a fixed value. Replace the hardcoded 1L with this dynamic user ID when
querying the repository and asserting values.

@yooooonshine yooooonshine changed the title feat: 티켓 시스템 구현 및 전체 애플리케이션 기능 완성 feat: 티켓 시스템 구현 Jul 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

티켓 발급 API 구현

1 participant