Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' // 버전을 삭제!
implementation 'com.google.firebase:firebase-admin:9.2.0'
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.75' // amazonaws 사용
implementation 'com.fasterxml.jackson.core:jackson-databind'
// JJWT 추가
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@
import com.example.cp_main_be.domain.mission.quiz.domain.QuizOptions;
import com.example.cp_main_be.domain.reports.domain.Reports;
import com.example.cp_main_be.domain.reports.enums.ReportStatus;
import com.example.cp_main_be.global.common.ApiResponse;
import com.example.cp_main_be.global.dto.ListResponse;
import com.example.cp_main_be.global.util.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
Expand All @@ -34,6 +35,7 @@
@RestController
@RequestMapping("/api/v1/admin")
@RequiredArgsConstructor
@Tag(name = "어드민 API", description = "어드민 권한의 기능을 제공합니다.")
public class AdminController {

private final AdminService adminService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.example.cp_main_be.domain.member.user.service.UserService;
import com.example.cp_main_be.domain.mission.daily_keywords.domain.DailyKeywords;
import com.example.cp_main_be.domain.mission.daily_keywords.domain.repository.DailyKeywordsRepository;
import com.example.cp_main_be.domain.mission.daily_mission_master.MissionType;
import com.example.cp_main_be.domain.mission.daily_mission_master.domain.DailyMissionMaster;
import com.example.cp_main_be.domain.mission.daily_mission_master.domain.repository.DailyMissionMasterRepository;
import com.example.cp_main_be.domain.mission.quiz.domain.QuizOptions;
Expand Down Expand Up @@ -80,6 +81,10 @@ public QuizOptions createQuizOption(AdminRequestDTO.CreateQuizRequestDTO request
.findById(requestDTO.getMissionMasterId())
.orElseThrow(() -> new IllegalStateException("해당 ID를 가진 미션이 존재하지 않습니다."));

if (dailyMissionMaster.getMissionType() != MissionType.QUIZ) {
throw new IllegalArgumentException("퀴즈 타입의 미션에만 선지를 추가할 수 있습니다.");
}
Comment on lines +84 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

퀴즈 타입 가드 추가로 단위 테스트 실패 발생 — 계약 변경 영향 처리 필요

새 가드가 비-QUIZ 미션에 대해 IllegalArgumentException을 던지면서 파이프라인 테스트(“퀴즈 옵션 생성 성공”)가 실패했습니다. 비즈니스 규칙 상 “QUIZ 타입에만 선지 추가”가 올바르다면, 테스트/픽스처를 QUIZ 타입으로 정정해야 하고, 아니라면 서비스 로직을 재고해야 합니다.

권장 대응 옵션:

  • 테스트 수정: 테스트 픽스처가 생성하는 DailyMissionMaster.missionTypeQUIZ로 세팅.
  • 예외 표준화: 도메인 예외(예: InvalidMissionTypeException)로 교체해 에러 코드/메시지 일관성 확보.
  • 로깅 보강: 타입 불일치 시 미션 ID/타입을 warn 로그로 남겨 운영 추적성 향상.

검증 스크립트(문제 테스트와 픽스처 파악):

#!/bin/bash
# 실패 테스트와 MissionType 설정 코드 위치 찾기
fd -t f -e java | xargs rg -nP 'AdminService(Test)?|createQuizOption|MissionType\.(?!QUIZ)\w+'

추가 확인 사항:

  • QuizOptions가 미션과의 연관을 필수로 요구한다면, 아래처럼 연관 주입을 누락하지 않았는지 점검하세요(필드명이 다를 수 있으니 엔티티 확인 필요).
 return QuizOptions.builder() // QuizOptions 퀴즈의 선지
     .optionText(requestDTO.getOptionText())
     .optionOrder(requestDTO.getOptionOrder())
     .isCorrect(requestDTO.isCorrect())
+    .dailyMissionMaster(dailyMissionMaster) // 연관 필드명 예시
     .build();
  • 생성 후 영속화 경로(레포지토리 save 또는 연관 편의 메서드 cascade)가 명확한지 확인하세요. 현재 메서드는 build만 하고 save는 하지 않습니다. 호출부에서 save하지 않는다면 데이터가 유실됩니다.
🧰 Tools
🪛 GitHub Check: Gradle Tests

[failure] 85-85: com.example.cp_main_be.domain.admin.service.AdminServiceTest ► 퀴즈 옵션 생성 성공
Failed test found in:
build/test-results/test/TEST-com.example.cp_main_be.domain.admin.service.AdminServiceTest.xml
Error:
java.lang.IllegalArgumentException: 퀴즈 타입의 미션에만 선지를 추가할 수 있습니다.

🪛 GitHub Actions: Spring Boot CI/CD with AWS

[error] 85-85: 퀴즈 타입의 미션에만 선지를 추가할 수 있습니다.

🤖 Prompt for AI Agents
In src/main/java/com/example/cp_main_be/domain/admin/service/AdminService.java
around lines 84-86, the new guard throws IllegalArgumentException for non-QUIZ
missions causing the "create quiz option success" unit test to fail; to fix,
either update the failing test fixture to set DailyMissionMaster.missionType to
MissionType.QUIZ so it matches the business rule, or replace the thrown
IllegalArgumentException with a domain-specific exception (e.g.,
InvalidMissionTypeException) to standardize error handling and update
callers/tests accordingly; additionally, add a warn log that includes the
mission ID and actual mission type when the guard triggers, and ensure the
created QuizOption entity is persisted (call repository.save or use the owning
entity’s convenience method/cascade) or document that persistence is handled by
the caller.


return QuizOptions.builder() // QuizOptions 퀴즈의 선지
.optionText(requestDTO.getOptionText())
.optionOrder(requestDTO.getOptionOrder())
Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public class Avatar {
@Column(name = "avatar_id")
private Long id;

private String name;

@Column(name = "image_url")
private String imageUrl;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
package com.example.cp_main_be.domain.content.avatar.dto.response;

import com.example.cp_main_be.domain.content.avatar.domain.Avatar;
import java.util.ArrayList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class AvatarResponse {

List<Avatar> avatars = new ArrayList<>();
List<AvatarSimpleResponse> avatars;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.example.cp_main_be.domain.content.avatar.dto.response;

import com.example.cp_main_be.domain.content.avatar.domain.Avatar;
import lombok.Getter;

@Getter
public class AvatarSimpleResponse {
private Long id;
private String name;
private String imageUrl;

// Avatar 엔티티를 AvatarDto로 변환하는 생성자
public AvatarSimpleResponse(Avatar avatar) {
this.id = avatar.getId();
this.name = avatar.getName();
this.imageUrl = avatar.getImageUrl();
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.example.cp_main_be.domain.content.avatar.presentation;

import com.example.cp_main_be.domain.content.avatar.domain.Avatar;
import com.example.cp_main_be.domain.content.avatar.dto.response.AvatarResponse;
import com.example.cp_main_be.domain.content.avatar.dto.response.AvatarSimpleResponse;
import com.example.cp_main_be.domain.content.avatar.service.AvatarService;
import com.example.cp_main_be.domain.member.user.domain.User;
import com.example.cp_main_be.global.util.ApiResponse;
import com.example.cp_main_be.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
Expand All @@ -19,11 +22,19 @@ public class AvatarController {

private final AvatarService avatarService;

@Operation(summary = "아바타 선택 목록 조회", description = "선택 가능한 아바타 목록을 반환합니다")
@Operation(summary = "선택 가능 아바타들 조회", description = "선택할 아바타 목록을 조회")
@GetMapping("/register/avatars")
public ResponseEntity<AvatarResponse> selectableAvatars() {
AvatarResponse avatarResponse = new AvatarResponse(avatarService.getAllAvatar());
return ResponseEntity.ok(avatarResponse);
public ResponseEntity<ApiResponse<AvatarResponse>> getSelectableAvatars() {
// 1. 서비스로부터 Avatar 엔티티 리스트를 받습니다.
List<Avatar> avatarEntities = avatarService.getAllAvatar();

// 2. 엔티티 리스트를 DTO 리스트로 변환합니다.
List<AvatarSimpleResponse> avatarDtos =
avatarEntities.stream().map(AvatarSimpleResponse::new).toList();

// 3. DTO 리스트를 최종 응답 객체에 담아 반환합니다.
AvatarResponse avatarResponse = new AvatarResponse(avatarDtos);
return ResponseEntity.ok(ApiResponse.success(avatarResponse));
}

@Operation(summary = "꽃가루 주기", description = "남의 아바타에게 꽃가루를 줍니다")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.example.cp_main_be.domain.delivery.domain;

import com.example.cp_main_be.domain.member.user.domain.User;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Delivery {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user; // 배송을 요청한 사용자

@Column(nullable = false)
private String recipientName;

@Column(nullable = false)
private String recipientPhone;

@Column(nullable = false)
private String postalCode;

@Column(nullable = false)
private String address;

private String addressDetail;
private String message;

// 배송 상태를 관리하기 위한 Enum (추후 확장용)
// @Enumerated(EnumType.STRING)
// private DeliveryStatus status;

private LocalDateTime createdAt;

@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
// this.status = DeliveryStatus.REQUESTED;
}

@Builder
public Delivery(
User user,
String recipientName,
String recipientPhone,
String postalCode,
String address,
String addressDetail,
String message) {
this.user = user;
this.recipientName = recipientName;
this.recipientPhone = recipientPhone;
this.postalCode = postalCode;
this.address = address;
this.addressDetail = addressDetail;
this.message = message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.cp_main_be.domain.delivery.domain.repository;

import com.example.cp_main_be.domain.delivery.domain.Delivery;
import org.springframework.data.jpa.repository.JpaRepository;

public interface DeliveryRepository extends JpaRepository<Delivery, Long> {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.example.cp_main_be.domain.delivery.dto.request;

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class DeliveryRequest {
Comment on lines +7 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Jackson 역직렬화 실패 가능성 — 세터/생성자/빌더 누락

현재 @Getter + @NoArgsConstructor 만으로는 기본 설정의 Jackson에서 필드 바인딩이 실패할 가능성이 큽니다(특히 private 필드, 세터 없음). 요청 바인딩 시 400(Bad Request)로 떨어질 수 있습니다. 세터를 추가하거나(간단) 빌더/@AllArgsConstructor + @JsonCreator를 도입해 주세요.

간단 수정안(@Setter 추가):

 import jakarta.validation.constraints.NotBlank;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
+import lombok.Setter;

 @Getter
+@Setter
 @NoArgsConstructor
 public class DeliveryRequest {

대안(불변 DTO 선호 시): @Builder + @AllArgsConstructor(onConstructor=@__(@JsonCreator)) 를 사용하고 세터는 제거하세요.

🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/delivery/dto/request/DeliveryRequest.java
around lines 7-9, Jackson deserialization may fail because the class only has
@Getter and @NoArgsConstructor (private fields without setters), so add either
standard setters (e.g., annotate with @Setter or add explicit setX methods) to
allow Jackson to bind request fields, or convert to an immutable DTO by
replacing setters with @Builder and
@AllArgsConstructor(onConstructor=@__(@JsonCreator)) so Jackson can construct
the object via the annotated constructor; pick one approach and update
imports/annotations accordingly.


@NotBlank(message = "수령인 이름은 필수입니다.")
private String recipientName;

@NotBlank(message = "수령인 연락처는 필수입니다.")
private String recipientPhone;

@NotBlank(message = "우편번호는 필수입니다.")
private String postalCode;

@NotBlank(message = "주소는 필수입니다.")
private String address;

private String addressDetail;
private String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.example.cp_main_be.domain.delivery.dto.response;

public class DeliveryResponse {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.example.cp_main_be.domain.delivery.presentation;

import com.example.cp_main_be.domain.delivery.dto.request.DeliveryRequest;
import com.example.cp_main_be.domain.delivery.service.DeliveryService;
import com.example.cp_main_be.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/deliveries")
@RequiredArgsConstructor
@Tag(name = "씨앗 배송 api")
public class DeliveryController {

private final DeliveryService deliveryService;

@Operation(summary = "씨앗 배송 정보를 입력받습니다")
@PostMapping("/seeds")
public ResponseEntity<ApiResponse<Void>> requestDelivery(
@AuthenticationPrincipal UserDetails userDetails, // 현재 로그인한 사용자 정보
@Valid @RequestBody DeliveryRequest deliveryRequest) {

// userDetails에서 사용자 ID를 추출하여 서비스에 전달
// Long userId = ((CustomUserDetails) userDetails).getId(); // UserDetails 구현체에 맞게 수정
Long userId = 1L; // 임시 ID, 실제로는 위와 같이 인증 정보에서 가져와야 합니다.

deliveryService.createDeliveryRequest(userId, deliveryRequest);
Comment on lines +26 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

하드코딩된 userId=1L 제거 — 인증 사용자 ID를 @AuthenticationPrincipal로 직접 주입

고정값 1L는 보안·권한 측면에서 치명적이며, 실제 사용자 데이터가 오염됩니다. 프로젝트 내 다른 컨트롤러(예: ReportController)와 동일하게 Long userId를 직접 주입 받도록 변경하세요.

-  public ResponseEntity<ApiResponse<Void>> requestDelivery(
-      @AuthenticationPrincipal UserDetails userDetails, // 현재 로그인한 사용자 정보
-      @Valid @RequestBody DeliveryRequest deliveryRequest) {
-
-    // userDetails에서 사용자 ID를 추출하여 서비스에 전달
-    // Long userId = ((CustomUserDetails) userDetails).getId(); // UserDetails 구현체에 맞게 수정
-    Long userId = 1L; // 임시 ID, 실제로는 위와 같이 인증 정보에서 가져와야 합니다.
-
-    deliveryService.createDeliveryRequest(userId, deliveryRequest);
+  public ResponseEntity<ApiResponse<Void>> requestDelivery(
+      @AuthenticationPrincipal Long userId,
+      @Valid @RequestBody DeliveryRequest deliveryRequest) {
+
+    deliveryService.createDeliveryRequest(userId, deliveryRequest);

검증용 스크립트:

결과에 따라 보안 설정(Principal 타입)을 일관화하세요.


🏁 Script executed:

#!/bin/bash
# 프로젝트에서 @AuthenticationPrincipal Long 사용 패턴 확인
rg -nP -C2 --type=java '@AuthenticationPrincipal\s+Long\b'
# 혹시 CustomUserDetails 패턴을 쓰는지 확인
rg -nP -C3 --type=java 'class\s+CustomUserDetails\b|implements\s+UserDetails'

Length of output: 3229


DeliveryController에서 하드코딩된 userId 제거 및 @AuthenticationPrincipal Long userId 적용 필요

DeliveryControlleruserId = 1L 하드코딩은 보안·권한 관리에 심각한 문제를 야기합니다. 프로젝트 내 다른 컨트롤러들(ReportController, GardenController)은 이미 @AuthenticationPrincipal Long userId를 사용하여 인증된 사용자 ID를 일관되게 주입하고 있으므로, 동일한 방식으로 수정해야 합니다.

  • 대상 파일:
    src/main/java/com/example/cp_main_be/domain/delivery/presentation/DeliveryController.java:26-33

  • 수정 예시(diff):

    -  public ResponseEntity<ApiResponse<Void>> requestDelivery(
    -      @AuthenticationPrincipal UserDetails userDetails, // 현재 로그인한 사용자 정보
    -      @Valid @RequestBody DeliveryRequest deliveryRequest) {
    -
    -    // userDetails에서 사용자 ID를 추출하여 서비스에 전달
    -    // Long userId = ((CustomUserDetails) userDetails).getId(); // UserDetails 구현체에 맞게 수정
    -    Long userId = 1L; // 임시 ID, 실제로는 위와 같이 인증 정보에서 가져와야 합니다.
    -
    -    deliveryService.createDeliveryRequest(userId, deliveryRequest);
    +  public ResponseEntity<ApiResponse<Void>> requestDelivery(
    +      @AuthenticationPrincipal Long userId,
    +      @Valid @RequestBody DeliveryRequest deliveryRequest) {
    +
    +    deliveryService.createDeliveryRequest(userId, deliveryRequest);
  • 검증 결과:
    ReportController, GardenController 등에서 이미 @AuthenticationPrincipal Long userId 패턴 사용 확인됨 (rg 결과)

위 수정으로 보안·권한 로직을 일관화하고, 실제 로그인된 사용자 ID가 서비스 로직에 전달되도록 보장하세요.

📝 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
@AuthenticationPrincipal UserDetails userDetails, // 현재 로그인한 사용자 정보
@Valid @RequestBody DeliveryRequest deliveryRequest) {
// userDetails에서 사용자 ID를 추출하여 서비스에 전달
// Long userId = ((CustomUserDetails) userDetails).getId(); // UserDetails 구현체에 맞게 수정
Long userId = 1L; // 임시 ID, 실제로는 위와 같이 인증 정보에서 가져와야 합니다.
deliveryService.createDeliveryRequest(userId, deliveryRequest);
public ResponseEntity<ApiResponse<Void>> requestDelivery(
@AuthenticationPrincipal Long userId,
@Valid @RequestBody DeliveryRequest deliveryRequest) {
deliveryService.createDeliveryRequest(userId, deliveryRequest);
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/delivery/presentation/DeliveryController.java
around lines 26-33, remove the hardcoded Long userId = 1L and change the
controller method to accept the authenticated user id by replacing the current
@AuthenticationPrincipal UserDetails userDetails parameter with
@AuthenticationPrincipal Long userId (or add @AuthenticationPrincipal Long
userId alongside if other details are needed), then pass that userId into
deliveryService.createDeliveryRequest(userId, deliveryRequest); and remove the
temporary comment and unused casts so the real authenticated user id is used
consistently like ReportController/GardenController.


return ResponseEntity.ok(ApiResponse.success(null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.example.cp_main_be.domain.delivery.service;

import com.example.cp_main_be.domain.delivery.domain.Delivery;
import com.example.cp_main_be.domain.delivery.domain.repository.DeliveryRepository;
import com.example.cp_main_be.domain.delivery.dto.request.DeliveryRequest;
import com.example.cp_main_be.domain.member.notification.domain.NotificationType;
import com.example.cp_main_be.domain.member.notification.service.NotificationService;
import com.example.cp_main_be.domain.member.user.domain.User;
import com.example.cp_main_be.domain.member.user.domain.repository.UserRepository;
import com.example.cp_main_be.global.exception.UserNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class DeliveryService {

private final DeliveryRepository deliveryRepository;
private final UserRepository userRepository;

private final NotificationService notificationService; // 👈 NotificationService 주입

public void createDeliveryRequest(Long userId, DeliveryRequest request) {
User user =
userRepository
.findById(userId)
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));

Delivery delivery =
Delivery.builder()
.user(user)
.recipientName(request.getRecipientName())
.recipientPhone(request.getRecipientPhone())
.postalCode(request.getPostalCode())
.address(request.getAddress())
.addressDetail(request.getAddressDetail())
.message(request.getMessage())
.build();

Delivery savedDelivery = deliveryRepository.save(delivery); // 👈 저장 후 객체 받기

// 👈 알림 전송 로직 추가
// 알림을 받는 사람(owner)과 보내는 사람(writer)이 자기 자신인 시스템 알림
notificationService.send(
user,
user,
NotificationType.SEED_DELIVERY,
"/deliveries/" + savedDelivery.getId() // 배송 상세 조회 페이지 URL
);
Comment on lines +46 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

알림 전송의 트랜잭션 타이밍 — 커밋 전 발송 위험(AFTER_COMMIT 권장)

현재 send 호출이 같은 트랜잭션 내에서 실행되어 롤백 시 “없는 리소스에 대한 알림”이 발송될 수 있습니다. 이벤트 발행 + @TransactionalEventListener(phase = AFTER_COMMIT) 패턴으로 커밋 이후에 알림을 보내세요.

적용 스케치(핵심 변경만, 실제 이벤트 클래스/리스너는 프로젝트 표준에 맞춰 조정):

-    notificationService.send(
-        user,
-        user,
-        NotificationType.SEED_DELIVERY,
-        "/deliveries/" + savedDelivery.getId()
-    );
+    publisher.publishEvent(
+        new SeedDeliveryStartedEvent(
+            savedDelivery.getId(),
+            user.getId(),
+            "/deliveries/" + savedDelivery.getId())
+    );

클래스 상단(필드 주입 추가 — Lombok @requiredargsconstructor로 생성자 자동 생성):

private final org.springframework.context.ApplicationEventPublisher publisher;

검증용 스크립트(레포지토리 루트에서 실행):

예상: AFTER_COMMIT 리스너가 없으면 위 제안을 적용하는 것이 안전합니다.


🏁 Script executed:

#!/bin/bash
# NotificationService 사용 방식/시그니처 확인
rg -nP -C3 --type=java 'class\s+NotificationService\b'
rg -nP -C3 --type=java '\bNotificationService\b[^\n]*\bsend\s*\('

# AFTER_COMMIT 리스너 존재 여부 확인
rg -nP -C2 --type=java '@TransactionalEventListener|TransactionPhase\.AFTER_COMMIT'

# 이미 존재하는 SeedDelivery 관련 이벤트/리스너가 있는지 확인
rg -nP -C3 --type=java 'SeedDeliveryStartedEvent|deliver(y|ies).*(Event|Listener)'

Length of output: 1861


🏁 Script executed:

#!/bin/bash
set -e
# Inspect send(...) methods in NotificationService
rg -n -nP --type=java 'send\s*\(' -C3 src/main/java/com/example/cp_main_be/domain/member/notification/service/NotificationService.java || true

# Dump the existing SeedDeliveryStartedEvent signature
echo "==== SeedDeliveryStartedEvent class ===="
sed -n '1,200p' src/main/java/com/example/cp_main_be/global/event/SeedDeliveryStartedEvent.java || true

Length of output: 1073


🏁 Script executed:

#!/bin/bash
set -e
# Locate send method signature and usages in NotificationService
rg --type=java '\bsend\s*\(' -C3 src/main/java/com/example/cp_main_be/domain/member/notification/service/NotificationService.java || true

# Print implementation context around the send method declaration
sed -n '1,200p' src/main/java/com/example/cp_main_be/domain/member/notification/service/NotificationService.java | sed -n '30,120p'

Length of output: 5323


커밋 이후 알림 발송 패턴 적용 권장 (AFTER_COMMIT)

현재 DeliveryService에서 배송 생성 직후 즉시 notificationService.send(…)를 호출하고 있어, 서비스 트랜잭션이 롤백될 경우 “존재하지 않는 배송”에 대한 알림이 발송될 위험이 있습니다. Spring 이벤트 + @TransactionalEventListener(phase = AFTER_COMMIT) 패턴으로 트랜잭션 커밋 이후에 알림을 보내도록 리팩터링을 반드시 적용하세요.

검증 결과:

  • NotificationService.send(…)@Transactional이 적용된 클래스 내부 메서드로, 호출 시점에 동일 트랜잭션 컨텍스트를 사용합니다.
  • 코드베이스에 @TransactionalEventListener(phase = AFTER_COMMIT) 또는 TransactionPhase.AFTER_COMMIT 사용 사례가 없습니다.
  • 이미 SeedDeliveryStartedEvent 클래스만 존재할 뿐, 해당 이벤트를 처리하는 리스너는 없습니다.

수정 포인트:

  • DeliveryService.java(46–51)
    • 직접 호출하던 notificationService.send(…) 코드를 이벤트 발행으로 대체
    • ApplicationEventPublisher 주입
  • 이벤트 클래스
    • 기존 SeedDeliveryStartedEvent를 배송 ID, URL, 사용자 ID를 담도록 확장하거나, 새 이벤트 클래스 정의
  • 이벤트 리스너
    • @Component + @TransactionalEventListener(phase = AFTER_COMMIT)로 구현
    • 리스너 내부에서 notificationService.send(…) 호출

예시 스케치:

--- src/main/java/com/example/cp_main_be/domain/delivery/service/DeliveryService.java
@@ -46,6 +46,7 @@ public class DeliveryService {
     Delivery savedDelivery = deliveryRepository.save(delivery);
+    publisher.publishEvent(new SeedDeliveryStartedEvent(
+        savedDelivery.getId(),
+        user.getId(),
+        "/deliveries/" + savedDelivery.getId()));
 }
// DeliveryService 클래스 상단
private final ApplicationEventPublisher publisher;
// 새 이벤트 리스너 예시
@Component
public class SeedDeliveryEventListener {
  private final NotificationService notificationService;

  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void onSeedDeliveryStarted(SeedDeliveryStartedEvent event) {
    User user = userRepository.findById(event.getUserId()).orElseThrow();
    notificationService.send(
        user, user, NotificationType.SEED_DELIVERY, event.getUrl());
  }
}

이렇게 변경하면, 트랜잭션 커밋이 보장된 이후에만 알림이 전송되어 “없는 리소스” 에 대한 알림 발송 문제를 방지할 수 있습니다.

🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/delivery/service/DeliveryService.java
around lines 46–51, replace the direct call to notificationService.send(...)
with publishing an application event (inject ApplicationEventPublisher into
DeliveryService) carrying deliveryId, url and userId; create or extend
SeedDeliveryStartedEvent to contain these fields; implement a @Component event
listener class with @TransactionalEventListener(phase =
TransactionPhase.AFTER_COMMIT) that resolves the user (via repository) and calls
notificationService.send(...) using data from the event; ensure the publisher
publishes the event after saving the delivery so the notification is sent only
after transaction commit.

}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.cp_main_be.domain.garden.garden.domain;

import com.example.cp_main_be.domain.member.user.domain.User;
import com.fasterxml.jackson.annotation.JsonBackReference;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import lombok.AccessLevel;
Expand All @@ -24,6 +25,7 @@ public class Garden {

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@JsonBackReference
private User user;

@Column(nullable = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

import com.example.cp_main_be.domain.garden.garden.dto.GardenResponse;
import com.example.cp_main_be.domain.garden.garden.service.GardenService;
import com.example.cp_main_be.global.util.ApiResponse;
import com.example.cp_main_be.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -26,4 +28,20 @@ public ResponseEntity<ApiResponse<GardenResponse>> getGarden(@PathVariable Long
GardenResponse gardenResponse = gardenService.findGardenById(gardenId);
return ResponseEntity.ok(ApiResponse.success(gardenResponse));
}

@Operation(summary = "정원에 물 주기", description = "자신 또는 다른 사람의 정원에 물을 줍니다.")
@PostMapping("/{gardenId}/water")
public ResponseEntity<ApiResponse<Void>> waterGarden(
@AuthenticationPrincipal Long userId, @PathVariable Long gardenId) {
gardenService.waterGarden(userId, gardenId);
return ResponseEntity.ok(ApiResponse.success(null));
}
Comment on lines +35 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

@AuthenticationPrincipal 타입 불일치로 인한 런타임 오류 가능성

다른 컨트롤러는 @AuthenticationPrincipal User user를 사용 중입니다. 본 컨트롤러만 Long userId로 선언되어 있으면 시큐리티 설정에 따라 ClassCastException 또는 null 주입이 발생할 수 있습니다.

두 가지 중 하나로 정리하세요.

  • 옵션 A: User를 주입받고 서비스에 user.getId() 전달
+import com.example.cp_main_be.domain.member.user.domain.User;
 ...
-  public ResponseEntity<ApiResponse<Void>> waterGarden(
-      @AuthenticationPrincipal Long userId, @PathVariable Long gardenId) {
-    gardenService.waterGarden(userId, gardenId);
+  public ResponseEntity<ApiResponse<Void>> waterGarden(
+      @AuthenticationPrincipal User user, @PathVariable Long gardenId) {
+    gardenService.waterGarden(user.getId(), gardenId);
     return ResponseEntity.ok(ApiResponse.success(null));
   }
 ...
-  public ResponseEntity<ApiResponse<Void>> sunlightGarden(
-      @AuthenticationPrincipal Long userId, @PathVariable Long gardenId) {
-    gardenService.sunlightGarden(userId, gardenId);
+  public ResponseEntity<ApiResponse<Void>> sunlightGarden(
+      @AuthenticationPrincipal User user, @PathVariable Long gardenId) {
+    gardenService.sunlightGarden(user.getId(), gardenId);
     return ResponseEntity.ok(ApiResponse.success(null));
   }
  • 옵션 B: SpEL로 id만 추출
-      @AuthenticationPrincipal Long userId, @PathVariable Long gardenId) {
+      @AuthenticationPrincipal(expression = "id") Long userId, @PathVariable Long gardenId) {

레포 내 다른 컨트롤러들과 일관된 방식(옵션 A)을 권장합니다.

Also applies to: 43-46

🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/garden/presentation/GardenController.java
around lines 35-38 (and similarly 43-46), the controller is using
@AuthenticationPrincipal Long userId which can cause ClassCastException or null
depending on Spring Security principal; change the method signatures to accept
the authenticated User object (e.g., @AuthenticationPrincipal User user), then
pass user.getId() into gardenService.waterGarden(...) (and the other service
call), and adjust imports/signatures accordingly so this controller matches the
other controllers’ approach.


@Operation(summary = "정원에 햇빛 주기", description = "자신의 정원에 햇빛을 줍니다.")
@PostMapping("/{gardenId}/sunlight")
public ResponseEntity<ApiResponse<Void>> sunlightGarden(
@AuthenticationPrincipal Long userId, @PathVariable Long gardenId) {
gardenService.sunlightGarden(userId, gardenId);
return ResponseEntity.ok(ApiResponse.success(null));
}
}
Loading