Skip to content

Conversation

@dahyun24
Copy link
Contributor

@dahyun24 dahyun24 commented Feb 2, 2026

🔗 연관된 이슈

🚀 변경 유형

  • ✨ 기능 추가 (feature)
  • 🐛 버그 수정 (fix)
  • 📝 문서 변경 (docs)
  • ♻️ 리팩토링 (refactor)
  • 🧪 테스트 추가 / 수정 (test)
  • ⚙️ 설정 변경 (chore)

📝 작업 내용

  • clove studio를 이용한 치즈네컷 AI 기능 추가
  1. 수동 치즈네컷 확정
  2. 앨범 만료 후 치즈네컷 자동 생성

-> 이 두 경우에 대하여 비동기적으로 AI 를 이용하여 이미지 요약 기능 구현

  1. 프론트에서 AI 생성 완료 여부를 알기 위한 Polling API 구현

📸 스크린샷

수동 확정

스크린샷 2026-02-03 오전 1 53 35

polling API

스크린샷 2026-02-03 오전 1 22 40

로그 확인

스크린샷 2026-02-03 오전 1 26 55

💬 리뷰 요구사항

📜 리뷰 규칙

Reviewer는 아래 P5 Rule을 참고하여 리뷰를 진행합니다.
P5 Rule을 통해 Reviewer는 Reviewee에게 리뷰의 의도를 보다 정확히 전달할 수 있습니다.

  • P1: 꼭 반영해주세요 (Comment)
  • P2: 적극적으로 고려해주세요 (Comment)
  • P3: 웬만하면 반영해 주세요 (Comment)
  • P4: 반영해도 좋고 넘어가도 좋습니다 (Approve)
  • P5: 그냥 사소한 의견입니다 (Approve)

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • AI 기반 사진 앨범 요약 생성 기능 추가
    • 앨범 AI 요약 조회 엔드포인트 추가
    • 비동기 처리를 통한 AI 기능 구현
    • 사진 썸네일 자동 리사이징 기능 추가
  • Chores

    • 이미지 처리 라이브러리 의존성 추가

@dahyun24 dahyun24 self-assigned this Feb 2, 2026
@dahyun24 dahyun24 added the ✨feature New feature or request label Feb 2, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 2, 2026

Walkthrough

이 PR은 Clova AI API를 통한 AI 기반 치즈네컷 이미지 분석 기능을 추가합니다. 비동기 이벤트 기반 아키텍처로 구현되어 치즈네컷 생성 시 자동으로 AI 처리를 트리거하며, 이미지 리사이징 및 요약 생성 기능을 포함합니다.

Changes

Cohort / File(s) Summary
Configuration & Dependencies
build.gradle, src/main/java/com/cheeeese/CheeeeseApplication.java, src/main/java/com/cheeeese/global/config/AsyncConfig.java
Thumbnailator 이미지 리사이징 라이브러리 추가, @EnableAsync 애노테이션으로 비동기 처리 활성화, ThreadPoolTaskExecutor를 이용한 cheeseAsyncExecutor 빈 설정
Event-Driven Architecture
src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutFinalizedEvent.java, src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutAiEventListener.java
Cheese4cut 생성 완료 시 발행할 불변 이벤트 레코드 생성, @Async@TransactionalEventListener로 비동기 이벤트 처리 수행
AI Service & Infrastructure
src/main/java/com/cheeeese/cheese4cut/infrastructure/ClovaClient.java, src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutAiService.java
Clova HCX-005(이미지 분석) 및 HCX-DASH-002(요약 생성) API 호출 클라이언트 구현, 이미지 인코딩 및 AI 결과 집계 로직 포함 서비스 계층
Domain Model & Persistence
src/main/java/com/cheeeese/cheese4cut/domain/Cheese4cutAiSummary.java, src/main/java/com/cheeeese/cheese4cut/infrastructure/persistence/Cheese4cutAiSummaryRepository.java
AI 생성 요약을 저장할 JPA 엔티티(cheese4cut_ai_summary 테이블) 추가, Cheese4cut과 1:1 관계 설정
Response DTOs
src/main/java/com/cheeeese/cheese4cut/dto/response/AiResult.java, src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutAiResponse.java
AI 결과 데이터 캐리어 레코드 및 PROCESSING/COMPLETED 상태를 포함한 응답 레코드 추가
Application Service Updates
src/main/java/com/cheeeese/album/application/AlbumExpirationService.java, src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutService.java
Cheese4cut 생성 시 Cheese4cutFinalizedEvent 발행으로 AI 처리 트리거, finalizeCheese4cutWithAi 메서드 추가
Presentation Layer
src/main/java/com/cheeeese/cheese4cut/presentation/Cheese4cutController.java, src/main/java/com/cheeeese/cheese4cut/presentation/swagger/Cheese4cutSwagger.java
AI 기반 치즈네컷 완성(POST /fixed/ai) 및 AI 요약 조회(GET /ai-summary) 엔드포인트 추가
Error & Success Codes
src/main/java/com/cheeeese/global/common/code/ErrorCode.java, src/main/java/com/cheeeese/global/common/code/SuccessCode.java, src/main/java/com/cheeeese/photo/exception/code/PhotoErrorCode.java
CLOVA_API_ERROR, CLOVA_RESPONSE_EMPTY 에러 코드 및 CHEESE4CUT_AI_GET_SUCCESS 성공 코드, IMAGE_PROCESSING_FAILED 에러 코드 추가
Utilities
src/main/java/com/cheeeese/global/util/ImageUtil.java
URL로부터 이미지를 1024x1024로 리사이징하고 Base64 인코딩하는 유틸리티 메서드 추가

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Controller as Cheese4cutController
    participant Service as Cheese4cutService
    participant EventPublisher as EventPublisher
    participant EventListener as Cheese4cutAiEventListener
    participant AiService as Cheese4cutAiService
    participant ClovaClient as ClovaClient
    participant Repository as Repositories

    Client->>Controller: POST /fixed/ai (cheese4cut 최종화 with AI)
    Controller->>Service: finalizeCheese4cutWithAi()
    Service->>Repository: save(cheese4cut)
    Repository-->>Service: cheese4cut
    Service->>EventPublisher: publishEvent(Cheese4cutFinalizedEvent)
    EventPublisher-->>Service: 완료 (즉시 응답)
    Service-->>Controller: 성공
    Controller-->>Client: 200 OK (AI 처리 중 상태)
    
    rect rgba(100, 150, 255, 0.5)
    Note over EventListener,Repository: 비동기 AI 처리 (별도 스레드)
    EventPublisher->>EventListener: Cheese4cutFinalizedEvent (AFTER_COMMIT)
    EventListener->>AiService: generateAiSummary()
    AiService->>Repository: photo 목록 조회
    loop 각 사진마다
        AiService->>ClovaClient: callHcx005(base64Image)
        ClovaClient-->>AiService: 이미지 분석 결과
    end
    AiService->>ClovaClient: callHcxDash002(combinedAnalysis)
    ClovaClient-->>AiService: AiResult(title, content)
    AiService->>Repository: save(Cheese4cutAiSummary)
    Repository-->>AiService: 저장 완료
    end

    Client->>Controller: GET /ai-summary?code=xxx
    Controller->>AiService: getAiSummary(code)
    AiService->>Repository: findByCheese4cutId()
    alt AI 요약이 완성된 경우
        Repository-->>AiService: Cheese4cutAiSummary
        AiService-->>Controller: Cheese4cutAiResponse(COMPLETED, title, content)
    else AI 처리 중
        Repository-->>AiService: 없음
        AiService-->>Controller: Cheese4cutAiResponse(PROCESSING, null, null)
    end
    Controller-->>Client: 200 OK with status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

✨ feature

Suggested reviewers

  • zyovn

Poem

🐰 치즈네컷에 AI의 마법이 내려앉고,
비동기 이벤트가 춤을 추며,
이미지는 리사이징되고 요약은 피어난다.
Clova와 함께 꿈꾸는 자동화의 세계,
한 번의 클릭으로 완성되는 지능형 분석! ✨📸

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 주요 변경사항인 AI 기능 개발을 명확하게 요약하며, Clova를 사용한 Cheese4Cut AI 기능 추가라는 핵심 목표를 잘 반영합니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch CEEZ-24-AI-API-개발

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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: 8

🤖 Fix all issues with AI agents
In `@build.gradle`:
- Around line 74-75: Update the Thumbnailator dependency to the newer 0.4.21
release by replacing the current artifact version used in the build.gradle
dependency declaration for 'net.coobird:thumbnailator' so the project pulls the
0.4.21 artifact with its bug fixes and memory optimizations.

In `@src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutAiService.java`:
- Around line 62-66: The AI response lengths are not validated before building
Cheese4cutAiSummary, risking DB errors; in Cheese4cutAiService before calling
Cheese4cutAiSummary.builder() validate and normalize result.title() and
result.content(): trim whitespace, enforce title length <= 10 (truncate and log
or return error) and enforce content length between 180 and 220 characters (if
shorter, pad/mark as invalid or reject; if longer, truncate to 220 and log);
update the code around result.title() and result.content() to perform these
checks and only build/save the Cheese4cutAiSummary when the values meet the
constraints (or handle/record validation failures) so DB insert won't fail.
- Around line 72-74: The catch block in Cheese4cutAiService (where you log "AI
Summary 생성 중 치명적 오류 발생") swallows exceptions leaving the AI job permanently in
PROCESSING; update the exception handling to persist a failure state so
getAiSummary can return FAILED instead of forever PROCESSING: add a FAILED value
to Cheese4cutAiResponse (or the status enum/entity used), and in the catch of
the AI summary generation method in Cheese4cutAiService set and save the
entity/status to FAILED (or record retry metadata) — alternatively implement a
retry mechanism there — ensuring getAiSummary reads the persisted status and
returns FAILED when appropriate.

In `@src/main/java/com/cheeeese/cheese4cut/infrastructure/ClovaClient.java`:
- Around line 167-170: 현재 ClovaClient.java의 catch(Exception e) 블록이
BusinessException을 포함한 모든 예외를 잡아 ErrorCode.CLOVA_API_ERROR로 덮어써 원본 오류 정보를 잃습니다;
수정 방법은 BusinessException은 그대로 재던지거나 그대로 처리하도록 별도 catch로 분리하고(즉
catch(BusinessException be) { throw be; }) 나머지 일반 예외는 기존 log.error(...)에 원본 예외를
포함시키고(new BusinessException(ErrorCode.CLOVA_API_ERROR, e) 또는 생성자에 원인 전달) 원본 예외를
cause로 유지해 던지도록 변경하세요; 참조 심볼: ClovaClient 클래스의 catch 블록, BusinessException,
ErrorCode.CLOVA_API_ERROR, log.error.
- Around line 30-31: ClovaClient currently instantiates RestTemplate and
ObjectMapper inline, preventing configured timeouts/connection pooling; change
to inject them as Spring beans instead: create a configuration class that
defines a RestTemplate bean (eg. clovaRestTemplate built with
RestTemplateBuilder and setConnectTimeout/setReadTimeout to desired values) and
an ObjectMapper bean (or use Jackson2ObjectMapperBuilder), then modify the
ClovaClient class to accept RestTemplate and ObjectMapper via constructor
injection (replace the private final new RestTemplate()/new ObjectMapper()
fields with the injected instances) so external API calls use the configured
timeouts and pooling.
- Around line 126-129: In the ClovaClient class catch block that currently
returns new AiResult(title, response) on Exception, avoid saving the raw
unparsed response into AiResult (to prevent leaking malformed/unsafe data);
instead log the exception and the original response using the existing logger,
and return a safe fallback AiResult (e.g., new AiResult(title, "") or a
sanitized placeholder) or set a dedicated error field if AiResult supports it.
Update the catch in ClovaClient.java around the JSON parsing to call
logger.error(...) with the exception and raw response, and return the
safe/sanitized AiResult rather than the full raw response.

In `@src/main/java/com/cheeeese/global/common/code/ErrorCode.java`:
- Around line 17-19: The ErrorCode enum contains a duplicate semicolon after the
enum constant list (CLOVA_API_ERROR and CLOVA_RESPONSE_EMPTY); remove the extra
semicolon so there is only a single terminating semicolon (or none if there is
no following member/constructor) after the enum constants in the ErrorCode enum
to eliminate the redundant punctuation.

In `@src/main/java/com/cheeeese/global/util/ImageUtil.java`:
- Around line 16-32: The resizeAndEncodeToBase64FromUrl method currently opens a
stream via URI.create(...).toURL().openStream() without timeouts and doesn't
validate the imageUrl; update it to first validate imageUrl is non-null and
parsable (handle IllegalArgumentException/MalformedURLException), then open an
HttpURLConnection (or URLConnection) to the URI, set explicit connect and read
timeouts (e.g., reasonable ms values), get the InputStream from that connection
in a try-with-resources block, pass that stream to Thumbnails as before, and
wrap any IOException/IllegalArgumentException/MalformedURLException in
PhotoException(PhotoErrorCode.IMAGE_PROCESSING_FAILED) so all failure modes are
consistently handled; ensure the connection and stream are properly closed.
🧹 Nitpick comments (12)
src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutFinalizedEvent.java (1)

9-13: 이벤트 데이터 구조가 적절합니다.

AI 처리에 필요한 Cheese4cut, Album, List<Photo> 정보를 포함한 불변 이벤트 record입니다.

선택적 개선사항: List<Photo>는 외부에서 수정 가능하므로, 방어적 복사(List.copyOf(photos))를 고려해볼 수 있습니다.

♻️ 방어적 복사 적용 예시
 public record Cheese4cutFinalizedEvent(
         Cheese4cut cheese4cut,
         Album album,
         List<Photo> photos
-) {}
+) {
+    public Cheese4cutFinalizedEvent {
+        photos = photos != null ? List.copyOf(photos) : List.of();
+    }
+}
src/main/java/com/cheeeese/global/config/AsyncConfig.java (1)

14-24: 비동기 Executor 설정에 거부 정책 및 종료 설정 추가를 권장합니다.

현재 설정에서 큐(50)가 가득 차고 최대 스레드(10)가 모두 사용 중일 때 새 작업은 기본적으로 AbortPolicy로 거부됩니다. AI 처리가 ~28초 소요되는 점을 감안하면, 거부 정책과 graceful shutdown 설정이 필요합니다.

♻️ 권장 설정 추가
     `@Bean`(name = "cheeseAsyncExecutor")
     public Executor cheeseAsyncExecutor() {
         ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
         executor.setCorePoolSize(4);
         executor.setMaxPoolSize(10);
         executor.setQueueCapacity(50);
-
         executor.setThreadNamePrefix("AI-ASYNC-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.setWaitForTasksToCompleteOnShutdown(true);
+        executor.setAwaitTerminationSeconds(60);
         executor.initialize();
         return executor;
     }

CallerRunsPolicy는 큐가 가득 찼을 때 호출자 스레드에서 작업을 실행하여 작업 손실을 방지합니다.

src/main/java/com/cheeeese/CheeeeseApplication.java (1)

10-10: @EnableAsync 중복 선언

AsyncConfig.java에 이미 @EnableAsync가 선언되어 있습니다. 둘 중 하나만 유지하는 것을 권장합니다. 일반적으로 설정 클래스(AsyncConfig)에 두는 것이 관례입니다.

♻️ 중복 제거
-@EnableAsync
 `@SpringBootApplication`
 `@EnableJpaAuditing`
 `@EnableRedisRepositories`(basePackages = "com.cheeeese.auth.infrastructure.persistence")
 `@EnableScheduling`
 public class CheeeeseApplication {
src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutAiResponse.java (1)

3-14: 상태 값에 enum 사용 및 Swagger 문서화 추가를 권장합니다.

  1. status가 String으로 선언되어 있어 타입 안전성이 부족합니다. enum으로 변경하면 유효하지 않은 상태 값을 컴파일 타임에 방지할 수 있습니다.

  2. 동일 패키지의 다른 Response DTO(Cheese4cutFinalResponse, Cheese4cutPreviewResponse)는 @Schema 어노테이션이 있으나, 이 클래스에는 누락되어 API 문서 일관성이 떨어집니다.

♻️ enum 적용 및 Schema 추가 예시
 package com.cheeeese.cheese4cut.dto.response;

+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "치즈네컷 AI 요약 응답 DTO")
 public record Cheese4cutAiResponse(
-        String status,
+        `@Schema`(description = "AI 처리 상태", example = "COMPLETED")
+        AiStatus status,
+        `@Schema`(description = "AI 생성 제목", example = "졸업식의 기쁨")
         String title,
+        `@Schema`(description = "AI 생성 내용")
         String content
 ) {
+    public enum AiStatus {
+        PROCESSING, COMPLETED
+    }
+
     public static Cheese4cutAiResponse processing() {
-        return new Cheese4cutAiResponse("PROCESSING", null, null);
+        return new Cheese4cutAiResponse(AiStatus.PROCESSING, null, null);
     }

     public static Cheese4cutAiResponse completed(String title, String content) {
-        return new Cheese4cutAiResponse("COMPLETED", title, content);
+        return new Cheese4cutAiResponse(AiStatus.COMPLETED, title, content);
     }
 }
src/main/java/com/cheeeese/album/application/AlbumExpirationService.java (1)

43-43: 사용되지 않는 의존성을 제거해주세요.

cheese4cutAiService 필드가 주입되었지만 이 클래스 내에서 사용되지 않습니다. 이벤트 기반 아키텍처로 전환되어 Cheese4cutAiEventListener가 AI 처리를 담당하므로, 이 필드는 불필요합니다.

♻️ 수정 제안
 import com.cheeeese.cheese4cut.application.Cheese4cutAiService;

위 import와 아래 필드를 제거하세요:

-    private final Cheese4cutAiService cheese4cutAiService;
src/main/java/com/cheeeese/cheese4cut/presentation/swagger/Cheese4cutSwagger.java (2)

125-142: API 설명이 AI 기능에 대한 내용을 포함하지 않습니다.

finalizeCheese4cutWithAi의 설명이 기존 finalizeCheese4cut과 동일하게 복사되어 있습니다. AI 처리가 비동기로 트리거된다는 핵심 차이점이 문서화되어 있지 않아 API 사용자가 혼동할 수 있습니다.

📝 설명 개선 제안
     `@Operation`(
             summary = "치즈네컷 수동 확정 API with AI",
             description = """
                     ### PathVariable
                     ---
                     `code`: 앨범 코드
                     
                     ### RequestBody
                     ---
                     `photoIds`: 사용자가 최종 선택한 4장의 사진 ID \n
                     
                     ### 로직 상세
                     ---
                     1. 사용자 권한 확인 (MAKER만 가능).
                     2. 앨범 만료 및 이미 확정 여부 확인.
                     3. 요청된 4장의 사진 ID가 모두 **COMPLETED 상태**이고 해당 앨범에 속하는지 검증.
                     4. `Cheese4cut` 레코드를 생성하고 저장.
+                    5. AI 요약 생성이 비동기로 시작됩니다. `/ai-summary` 엔드포인트로 결과를 폴링하세요.
                     """
     )

182-182: code 파라미터에 @NotBlank 검증이 누락되었습니다.

다른 엔드포인트(getCheese4cut, finalizeCheese4cut, finalizeCheese4cutWithAi)에서는 @PathVariable @notblank String code를 사용하지만, getAiSummary에서는 @NotBlank가 빠져 있어 일관성이 없습니다.

✏️ 수정 제안
-    CommonResponse<Cheese4cutAiResponse> getAiSummary(`@PathVariable` String code);
+    CommonResponse<Cheese4cutAiResponse> getAiSummary(`@PathVariable` `@NotBlank` String code);
src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutService.java (2)

186-223: finalizeCheese4cut과 중복 코드가 많습니다.

finalizeCheese4cutWithAi의 검증 로직 및 저장 로직이 finalizeCheese4cut (Lines 127-151)과 거의 동일합니다. 이벤트 발행 여부만 다르므로 공통 로직을 추출하면 유지보수성이 향상됩니다.

♻️ 리팩토링 제안
+    private Cheese4cut doFinalizeCheese4cut(User user, String code, Cheese4cutFixedRequest request) {
+        Album album = albumValidator.validateAlbumCode(code);
+
+        if (album.isExpired()) {
+            throw new AlbumException(AlbumErrorCode.ALBUM_EXPIRED);
+        }
+
+        cheese4cutValidator.validateUserIsMaker(album, user);
+
+        if (cheese4cutRepository.findByAlbumId(album.getId()).isPresent()) {
+            throw new Cheese4cutException(Cheese4cutErrorCode.CHEESE4CUT_ALREADY_FINALIZED);
+        }
+
+        cheese4cutValidator.validateFinalizePhotos(album, request.photoIds());
+
+        List<Photo> orderedPhotos =
+                photoRepository.findAllByIdInOrderByLikesDescCreatedDesc(request.photoIds());
+
+        if (orderedPhotos.size() != request.photoIds().size()) {
+            throw new Cheese4cutException(Cheese4cutErrorCode.INSUFFICIENT_COUNT_FOR_CHEESE4CUT);
+        }
+
+        Cheese4cut cheese4cut = cheese4cutRepository.save(Cheese4cutMapper.toEntity(album, orderedPhotos));
+        cheese4cutLogger.logCheese4CutFinalized(user.getId(), request.photoIds(), album.getCode());
+        
+        return cheese4cut;
+    }
+
     `@Transactional`
     public void finalizeCheese4cut(User user, String code, Cheese4cutFixedRequest request) {
-        // ... 기존 로직 ...
+        doFinalizeCheese4cut(user, code, request);
     }
 
     `@Transactional`
     public void finalizeCheese4cutWithAi(User user, String code, Cheese4cutFixedRequest request) {
-        // ... 중복 로직 ...
+        Cheese4cut cheese4cut = doFinalizeCheese4cut(user, code, request);
+        
+        Album album = cheese4cut.getAlbum();
+        List<Photo> orderedPhotos = photoRepository.findAllByIdInOrderByLikesDescCreatedDesc(request.photoIds());
+        
+        eventPublisher.publishEvent(
+                new Cheese4cutFinalizedEvent(cheese4cut, album, orderedPhotos)
+        );
     }

217-219: 현재 코드에서는 lazy loading 문제가 발생하지 않지만, 향후 변경 시 주의 필요

Cheese4cutFinalizedEventorderedPhotos에는 Photo 엔티티의 lazy-loaded 관계(user, album)가 존재합니다. 하지만 현재 Cheese4cutAiService.generateAiSummary에서는 photo.getThumbnailUrl()만 접근하므로 lazy loading이 발생하지 않습니다. 또한 해당 메서드는 @Transactional(propagation = Propagation.REQUIRES_NEW)로 새로운 트랜잭션을 생성하므로, 설령 lazy 관계를 접근하더라도 트랜잭션 컨텍스트가 유지됩니다.

다만 향후 Photo의 다른 lazy-loaded 필드(user, album 등)에 접근하는 로직이 추가될 경우 주의가 필요합니다. 필요시 event listener에서 Photo 엔티티를 미리 초기화(Hibernate.initialize)하거나, 쿼리 단계에서 eager loading을 적용하는 것을 고려하세요.

src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutAiService.java (1)

44-52: 외부 API 호출이 순차적으로 4회 발생합니다.

각 사진에 대해 clovaClient.callHcx005를 순차적으로 호출하여 총 ~28초(PR 설명의 로그 기준) 소요됩니다. 병렬 처리로 응답 시간을 개선할 수 있습니다.

⚡ 병렬 처리 제안
// CompletableFuture를 사용한 병렬 처리 예시
List<CompletableFuture<String>> futures = photos.stream()
        .map(photo -> CompletableFuture.supplyAsync(() -> {
            String absoluteUrl = cdnUrlResolver.resolveThumbnail(photo.getThumbnailUrl());
            String base64 = imageUtil.resizeAndEncodeToBase64FromUrl(absoluteUrl);
            return String.format("[사진 분석]\n%s\n", clovaClient.callHcx005(base64));
        }, asyncExecutor))
        .toList();

String combinedAnalysis = futures.stream()
        .map(CompletableFuture::join)
        .collect(Collectors.joining("\n"));
src/main/java/com/cheeeese/cheese4cut/presentation/Cheese4cutController.java (1)

64-64: code 파라미터에 @NotBlank 검증이 누락되었습니다.

Swagger 인터페이스와 마찬가지로 다른 엔드포인트와의 일관성을 위해 @NotBlank 어노테이션을 추가해주세요.

✏️ 수정 제안
-    public CommonResponse<Cheese4cutAiResponse> getAiSummary(`@PathVariable` String code) {
+    public CommonResponse<Cheese4cutAiResponse> getAiSummary(`@PathVariable` `@NotBlank` String code) {
src/main/java/com/cheeeese/cheese4cut/infrastructure/ClovaClient.java (1)

148-149: Raw type Map 사용으로 타입 안전성이 떨어집니다.

✏️ 타입 파라미터 추가
-            ResponseEntity<Map> resp =
-                    restTemplate.postForEntity(url, new HttpEntity<>(body, headers), Map.class);
+            `@SuppressWarnings`("unchecked")
+            ResponseEntity<Map<String, Object>> resp =
+                    restTemplate.postForEntity(url, new HttpEntity<>(body, headers), 
+                            (Class<Map<String, Object>>)(Class<?>)Map.class);

또는 Clova API 응답에 맞는 DTO 클래스를 정의하는 것이 더 좋습니다.

Comment on lines +74 to +75
// 리사이징
implementation 'net.coobird:thumbnailator:0.4.20'
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check latest Thumbnailator version from Maven Central
curl -s "https://search.maven.org/solrsearch/select?q=g:net.coobird+AND+a:thumbnailator&rows=5&wt=json" | jq '.response.docs[] | {version: .v, latestVersion: .latestVersion}' | head -20

Repository: Say-Cheeeese/BE

Length of output: 109


🏁 Script executed:

#!/bin/bash
# Search for Thumbnailator usage in the codebase
rg -i "thumbnailator|thumbnail|Thumbnailator" --type java --type gradle -B 2 -A 2

Repository: Say-Cheeeese/BE

Length of output: 46021


🏁 Script executed:

#!/bin/bash
# Check if ImageUtil file exists and uses Thumbnailator
find . -name "*ImageUtil*" -o -name "*imageutil*" | head -10

Repository: Say-Cheeeese/BE

Length of output: 177


🏁 Script executed:

#!/bin/bash
# Check the ImageUtil file to see how Thumbnailator is being used
cat -n src/main/java/com/cheeeese/global/util/ImageUtil.java | head -60

Repository: Say-Cheeeese/BE

Length of output: 1464


🏁 Script executed:

#!/bin/bash
# Check for any security advisories on Thumbnailator 0.4.20
curl -s "https://www.cvedetails.com/json/vendor.php?vendor=thumbnailator" 2>/dev/null || echo "No CVE data available"

Repository: Say-Cheeeese/BE

Length of output: 4609


🌐 Web query:

Thumbnailator 0.4.20 security vulnerabilities CVE

💡 Result:

No CVEs are currently associated with Thumbnailator 0.4.20 (Maven coordinate net.coobird:thumbnailator:0.4.20) in common public vulnerability feeds, and the artifact has no compile/runtime dependencies (only test-scope dependencies like JUnit/Hamcrest/Mockito), so there are no transitive runtime CVEs to inherit via Maven either. [1], [2]

If you want a simple “upgrade anyway” remediation step, 0.4.21 is the next available release after 0.4.20. [1]

Sources:
[1] Maven Repository – Thumbnailator artifact overview / versions (shows 0.4.20 and newer 0.4.21) (mvnrepository.com)
[2] Maven Repository – Thumbnailator 0.4.20 dependencies (shows 0 compile deps; only test deps) (mvnrepository.com)

Citations:


🌐 Web query:

net.coobird thumbnailator 0.4.20 latest version update

💡 Result:

net.coobird:thumbnailator 0.4.20 is not the latest—the latest released version on Maven Central is 0.4.21 (released October 1, 2025). [1][2][3]

Update (Maven/Gradle):

<dependency>
  <groupId>net.coobird</groupId>
  <artifactId>thumbnailator</artifactId>
  <version>0.4.21</version>
</dependency>
implementation("net.coobird:thumbnailator:0.4.21")

[2][3]

What changed from 0.4.20 → 0.4.21: bug fix for incorrect output dimensions and reduced memory usage in certain cases (Issue 225). [1]

Sources: [1] https://github.com/coobird/thumbnailator/wiki/changes, [2] https://repo.maven.apache.org/maven2/net/coobird/thumbnailator/, [3] https://mvnrepository.com/artifact/net.coobird/thumbnailator


Thumbnailator 버전 0.4.21로 업그레이드를 권장합니다.

Thumbnailator 라이브러리는 잘 알려진 Java 이미지 처리 라이브러리이며 ImageUtil에서 이미지 리사이징에 적절히 활용되고 있습니다. 다만 현재 사용 중인 0.4.20에서 최신 버전인 0.4.21(2025년 10월 1일 출시)로 업그레이드할 수 있습니다. 0.4.21은 출력 이미지 크기 관련 버그 수정과 메모리 사용량 최적화가 포함되어 있습니다.

implementation 'net.coobird:thumbnailator:0.4.21'
🤖 Prompt for AI Agents
In `@build.gradle` around lines 74 - 75, Update the Thumbnailator dependency to
the newer 0.4.21 release by replacing the current artifact version used in the
build.gradle dependency declaration for 'net.coobird:thumbnailator' so the
project pulls the 0.4.21 artifact with its bug fixes and memory optimizations.

Comment on lines +62 to +66
Cheese4cutAiSummary summary = Cheese4cutAiSummary.builder()
.cheese4cut(cheese4cut)
.aiTitle(result.title()) // 10자 이내 검증된 결과
.aiContent(result.content()) // 180~220자 사이의 기록
.build();
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

제목/내용 길이 검증이 누락되었습니다.

주석에는 "10자 이내 검증된 결과", "180~220자 사이의 기록"이라고 되어 있지만, 실제 검증 로직이 없습니다. AI 응답이 예상 길이를 초과할 경우 DB 저장 시 오류가 발생할 수 있습니다.

🛡️ 길이 검증 추가 제안
+            String aiTitle = result.title();
+            String aiContent = result.content();
+            
+            // DB 컬럼 제한에 맞게 truncate
+            if (aiTitle != null && aiTitle.length() > 10) {
+                aiTitle = aiTitle.substring(0, 10);
+            }
+            if (aiContent != null && aiContent.length() > 255) {
+                aiContent = aiContent.substring(0, 255);
+            }
+
             Cheese4cutAiSummary summary = Cheese4cutAiSummary.builder()
                     .cheese4cut(cheese4cut)
-                    .aiTitle(result.title())     // 10자 이내 검증된 결과
-                    .aiContent(result.content()) // 180~220자 사이의 기록
+                    .aiTitle(aiTitle)
+                    .aiContent(aiContent)
                     .build();
🤖 Prompt for AI Agents
In `@src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutAiService.java`
around lines 62 - 66, The AI response lengths are not validated before building
Cheese4cutAiSummary, risking DB errors; in Cheese4cutAiService before calling
Cheese4cutAiSummary.builder() validate and normalize result.title() and
result.content(): trim whitespace, enforce title length <= 10 (truncate and log
or return error) and enforce content length between 180 and 220 characters (if
shorter, pad/mark as invalid or reject; if longer, truncate to 220 and log);
update the code around result.title() and result.content() to perform these
checks and only build/save the Cheese4cutAiSummary when the values meet the
constraints (or handle/record validation failures) so DB insert won't fail.

Comment on lines +72 to +74
} catch (Exception e) {
log.error("AI Summary 생성 중 치명적 오류 발생: ", e);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

AI 실패 시 상태가 영구적으로 PROCESSING으로 남습니다.

예외 발생 시 로그만 남기고 삼키기 때문에, 프론트엔드가 getAiSummary를 폴링하면 영원히 PROCESSING 상태를 받게 됩니다. 사용자 경험과 운영 모니터링 관점에서 문제가 됩니다.

실패 상태(예: FAILED)를 DB에 저장하거나, 재시도 로직을 추가하는 것을 고려해주세요.

🛠️ 개선 제안 - 실패 상태 저장
         } catch (Exception e) {
             log.error("AI Summary 생성 중 치명적 오류 발생: ", e);
+            // 실패 상태 저장하여 프론트엔드가 인지할 수 있도록 함
+            Cheese4cutAiSummary failedSummary = Cheese4cutAiSummary.builder()
+                    .cheese4cut(cheese4cut)
+                    .status(AiSummaryStatus.FAILED)
+                    .build();
+            aiSummaryRepository.saveAndFlush(failedSummary);
         }

또는 Cheese4cutAiResponseFAILED 상태를 추가하고 getAiSummary에서 이를 반환하도록 수정할 수 있습니다.

🤖 Prompt for AI Agents
In `@src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutAiService.java`
around lines 72 - 74, The catch block in Cheese4cutAiService (where you log "AI
Summary 생성 중 치명적 오류 발생") swallows exceptions leaving the AI job permanently in
PROCESSING; update the exception handling to persist a failure state so
getAiSummary can return FAILED instead of forever PROCESSING: add a FAILED value
to Cheese4cutAiResponse (or the status enum/entity used), and in the catch of
the AI summary generation method in Cheese4cutAiService set and save the
entity/status to FAILED (or record retry metadata) — alternatively implement a
retry mechanism there — ensuring getAiSummary reads the persisted status and
returns FAILED when appropriate.

Comment on lines +30 to +31
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

RestTemplateObjectMapper를 Spring Bean으로 주입받는 것이 좋습니다.

인라인으로 생성하면 connection timeout, read timeout, connection pooling 등의 설정이 적용되지 않습니다. 외부 API 호출 시 무한 대기가 발생할 수 있습니다.

🔧 수정 제안
 `@Slf4j`
 `@Component`
 `@RequiredArgsConstructor`
 public class ClovaClient {
 
     `@Value`("${clova.api.key}") private String apiKey;
     `@Value`("${clova.api.hcx-url}") private String hcxUrl;
     `@Value`("${clova.api.dash-url}") private String dashUrl;
 
-    private final RestTemplate restTemplate = new RestTemplate();
-    private final ObjectMapper objectMapper = new ObjectMapper();
+    private final RestTemplate restTemplate;
+    private final ObjectMapper objectMapper;

별도의 Configuration 클래스에서 timeout이 설정된 RestTemplate Bean을 생성하세요:

`@Configuration`
public class RestTemplateConfig {
    `@Bean`
    public RestTemplate clovaRestTemplate() {
        return new RestTemplateBuilder()
                .setConnectTimeout(Duration.ofSeconds(10))
                .setReadTimeout(Duration.ofSeconds(60))
                .build();
    }
}
📝 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
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
🤖 Prompt for AI Agents
In `@src/main/java/com/cheeeese/cheese4cut/infrastructure/ClovaClient.java` around
lines 30 - 31, ClovaClient currently instantiates RestTemplate and ObjectMapper
inline, preventing configured timeouts/connection pooling; change to inject them
as Spring beans instead: create a configuration class that defines a
RestTemplate bean (eg. clovaRestTemplate built with RestTemplateBuilder and
setConnectTimeout/setReadTimeout to desired values) and an ObjectMapper bean (or
use Jackson2ObjectMapperBuilder), then modify the ClovaClient class to accept
RestTemplate and ObjectMapper via constructor injection (replace the private
final new RestTemplate()/new ObjectMapper() fields with the injected instances)
so external API calls use the configured timeouts and pooling.

Comment on lines +126 to +129
} catch (Exception e) {
// 예외 시 기본값 처리
return new AiResult(title, response);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

JSON 파싱 실패 시 원본 응답을 그대로 반환하면 데이터 품질 문제가 발생할 수 있습니다.

AI가 예상과 다른 형식으로 응답하면 title에 원본 제목, content에 파싱되지 않은 전체 응답이 저장됩니다. 이는 사용자에게 노출될 수 있습니다.

💡 개선 제안
         } catch (Exception e) {
-            // 예외 시 기본값 처리
-            return new AiResult(title, response);
+            log.warn("AI 응답 JSON 파싱 실패. title={}, response={}", title, response, e);
+            // 파싱 실패 시 기본 메시지 사용 또는 예외 전파
+            return new AiResult(title, "AI 요약을 생성하지 못했습니다. 잠시 후 다시 시도해주세요.");
         }
📝 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
} catch (Exception e) {
// 예외 시 기본값 처리
return new AiResult(title, response);
}
} catch (Exception e) {
log.warn("AI 응답 JSON 파싱 실패. title={}, response={}", title, response, e);
// 파싱 실패 시 기본 메시지 사용 또는 예외 전파
return new AiResult(title, "AI 요약을 생성하지 못했습니다. 잠시 후 다시 시도해주세요.");
}
🤖 Prompt for AI Agents
In `@src/main/java/com/cheeeese/cheese4cut/infrastructure/ClovaClient.java` around
lines 126 - 129, In the ClovaClient class catch block that currently returns new
AiResult(title, response) on Exception, avoid saving the raw unparsed response
into AiResult (to prevent leaking malformed/unsafe data); instead log the
exception and the original response using the existing logger, and return a safe
fallback AiResult (e.g., new AiResult(title, "") or a sanitized placeholder) or
set a dedicated error field if AiResult supports it. Update the catch in
ClovaClient.java around the JSON parsing to call logger.error(...) with the
exception and raw response, and return the safe/sanitized AiResult rather than
the full raw response.

Comment on lines +167 to +170
} catch (Exception e) {
log.error("Clova API 호출 중 예상치 못한 오류 발생: {}", e.getMessage());
throw new BusinessException(ErrorCode.CLOVA_API_ERROR);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

BusinessException을 catch한 후 다시 감싸면서 원본 에러 정보가 손실됩니다.

Lines 154, 160에서 던진 BusinessException이 이 catch 블록에서 잡혀 새로운 CLOVA_API_ERROR로 덮어씌워집니다.

🐛 수정 제안
-        } catch (Exception e) {
+        } catch (BusinessException e) {
+            throw e; // 이미 처리된 예외는 그대로 전파
+        } catch (Exception e) {
             log.error("Clova API 호출 중 예상치 못한 오류 발생: {}", e.getMessage());
             throw new BusinessException(ErrorCode.CLOVA_API_ERROR);
         }
🤖 Prompt for AI Agents
In `@src/main/java/com/cheeeese/cheese4cut/infrastructure/ClovaClient.java` around
lines 167 - 170, 현재 ClovaClient.java의 catch(Exception e) 블록이 BusinessException을
포함한 모든 예외를 잡아 ErrorCode.CLOVA_API_ERROR로 덮어써 원본 오류 정보를 잃습니다; 수정 방법은
BusinessException은 그대로 재던지거나 그대로 처리하도록 별도 catch로 분리하고(즉 catch(BusinessException
be) { throw be; }) 나머지 일반 예외는 기존 log.error(...)에 원본 예외를 포함시키고(new
BusinessException(ErrorCode.CLOVA_API_ERROR, e) 또는 생성자에 원인 전달) 원본 예외를 cause로 유지해
던지도록 변경하세요; 참조 심볼: ClovaClient 클래스의 catch 블록, BusinessException,
ErrorCode.CLOVA_API_ERROR, log.error.

Comment on lines +17 to 19
CLOVA_API_ERROR(HttpStatus.BAD_GATEWAY, "CLOVA API 호출 중 오류가 발생했습니다."),
CLOVA_RESPONSE_EMPTY(HttpStatus.BAD_GATEWAY, "CLOVA API로부터 빈 응답을 받았습니다.");
;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

중복 세미콜론을 제거해주세요.

Line 18의 enum 상수 뒤에 이미 세미콜론이 있고, Line 19에 또 세미콜론이 있습니다. Java에서는 동작하지만 불필요한 중복입니다.

🔧 수정 제안
-    CLOVA_RESPONSE_EMPTY(HttpStatus.BAD_GATEWAY, "CLOVA API로부터 빈 응답을 받았습니다.");
-    ;
+    CLOVA_RESPONSE_EMPTY(HttpStatus.BAD_GATEWAY, "CLOVA API로부터 빈 응답을 받았습니다.");
📝 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
CLOVA_API_ERROR(HttpStatus.BAD_GATEWAY, "CLOVA API 호출 중 오류가 발생했습니다."),
CLOVA_RESPONSE_EMPTY(HttpStatus.BAD_GATEWAY, "CLOVA API로부터 빈 응답을 받았습니다.");
;
CLOVA_API_ERROR(HttpStatus.BAD_GATEWAY, "CLOVA API 호출 중 오류가 발생했습니다."),
CLOVA_RESPONSE_EMPTY(HttpStatus.BAD_GATEWAY, "CLOVA API로부터 빈 응답을 받았습니다.");
🤖 Prompt for AI Agents
In `@src/main/java/com/cheeeese/global/common/code/ErrorCode.java` around lines 17
- 19, The ErrorCode enum contains a duplicate semicolon after the enum constant
list (CLOVA_API_ERROR and CLOVA_RESPONSE_EMPTY); remove the extra semicolon so
there is only a single terminating semicolon (or none if there is no following
member/constructor) after the enum constants in the ErrorCode enum to eliminate
the redundant punctuation.

Comment on lines +16 to +32
public String resizeAndEncodeToBase64FromUrl(String imageUrl) {
try (InputStream is = URI.create(imageUrl).toURL().openStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {

Thumbnails.of(is)
.size(1024, 1024)
.outputFormat("jpg")
.outputQuality(0.8)
.toOutputStream(outputStream);

byte[] imageBytes = outputStream.toByteArray();
String b64 = Base64.getEncoder().encodeToString(imageBytes);

return b64;
} catch (IOException e) {
throw new PhotoException(PhotoErrorCode.IMAGE_PROCESSING_FAILED);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

네트워크 요청에 타임아웃 설정이 필요합니다.

URI.create(imageUrl).toURL().openStream()은 연결 타임아웃이 없어 응답하지 않는 서버에 대해 무한 대기할 수 있습니다. 또한 imageUrl이 null이거나 잘못된 형식일 경우 IllegalArgumentException이 발생하여 PhotoException으로 래핑되지 않습니다.

🛡️ 타임아웃 및 입력 검증 추가 제안
+import java.net.HttpURLConnection;
+import java.net.URL;

 `@Component`
 public class ImageUtil {
+    private static final int CONNECTION_TIMEOUT_MS = 10_000;
+    private static final int READ_TIMEOUT_MS = 30_000;
+
     // URL로부터 이미지를 가져와 리사이징 및 Base64 인코딩
     public String resizeAndEncodeToBase64FromUrl(String imageUrl) {
-        try (InputStream is = URI.create(imageUrl).toURL().openStream();
+        if (imageUrl == null || imageUrl.isBlank()) {
+            throw new PhotoException(PhotoErrorCode.IMAGE_PROCESSING_FAILED);
+        }
+        try {
+            URL url = URI.create(imageUrl).toURL();
+            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+            conn.setConnectTimeout(CONNECTION_TIMEOUT_MS);
+            conn.setReadTimeout(READ_TIMEOUT_MS);
+            try (InputStream is = conn.getInputStream();
              ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
 
             Thumbnails.of(is)
                     .size(1024, 1024)
                     .outputFormat("jpg")
                     .outputQuality(0.8)
                     .toOutputStream(outputStream);
 
             byte[] imageBytes = outputStream.toByteArray();
-            String b64 = Base64.getEncoder().encodeToString(imageBytes);
-
-            return b64;
-        } catch (IOException e) {
+            return Base64.getEncoder().encodeToString(imageBytes);
+            }
+        } catch (IOException | IllegalArgumentException e) {
             throw new PhotoException(PhotoErrorCode.IMAGE_PROCESSING_FAILED);
         }
     }
 }
🤖 Prompt for AI Agents
In `@src/main/java/com/cheeeese/global/util/ImageUtil.java` around lines 16 - 32,
The resizeAndEncodeToBase64FromUrl method currently opens a stream via
URI.create(...).toURL().openStream() without timeouts and doesn't validate the
imageUrl; update it to first validate imageUrl is non-null and parsable (handle
IllegalArgumentException/MalformedURLException), then open an HttpURLConnection
(or URLConnection) to the URI, set explicit connect and read timeouts (e.g.,
reasonable ms values), get the InputStream from that connection in a
try-with-resources block, pass that stream to Thumbnails as before, and wrap any
IOException/IllegalArgumentException/MalformedURLException in
PhotoException(PhotoErrorCode.IMAGE_PROCESSING_FAILED) so all failure modes are
consistently handled; ensure the connection and stream are properly closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants