From d3b29ff8a5dbe9e11e6289eb457aa714a9678215 Mon Sep 17 00:00:00 2001 From: seoJing Date: Tue, 2 Jun 2026 01:05:50 +0900 Subject: [PATCH 1/2] feat: expose spot roles and settlement status --- .../backend/chat/dto/ChatMemberResponse.java | 9 +++ .../backend/chat/service/ChatService.java | 69 ++++++++++++++++++- .../feed/dto/FeedApplicationResponse.java | 8 +++ .../backend/feed/dto/FeedItemResponse.java | 34 +++++++++ .../backend/feed/service/FeedItemService.java | 7 +- .../spot/controller/SpotController.java | 11 +++ .../backend/spot/dto/SpotDetailResponse.java | 7 +- .../spot/dto/SpotParticipantResponse.java | 16 +++++ .../backend/spot/service/SpotService.java | 13 +++- .../feed/service/FeedItemServiceTest.java | 25 ++++++- .../java/backend/feed/entity/FeedItem.java | 6 ++ .../main/java/backend/spot/entity/Spot.java | 3 +- .../backend/spot/entity/SpotMemberRole.java | 14 ++++ .../backend/spot/entity/SpotParticipant.java | 5 ++ ...6-02_spot_participant_application_role.sql | 37 ++++++++++ 15 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 capstone-domain/src/main/java/backend/spot/entity/SpotMemberRole.java create mode 100644 docs/migrations/2026-06-02_spot_participant_application_role.sql diff --git a/capstone-api/src/main/java/backend/chat/dto/ChatMemberResponse.java b/capstone-api/src/main/java/backend/chat/dto/ChatMemberResponse.java index 4f1a5f7..fcaf1dc 100644 --- a/capstone-api/src/main/java/backend/chat/dto/ChatMemberResponse.java +++ b/capstone-api/src/main/java/backend/chat/dto/ChatMemberResponse.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import backend.chat.entity.ChatRoomMember; +import backend.spot.entity.SpotMemberRole; import backend.user.entity.UserEntity; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; @@ -27,14 +28,22 @@ public class ChatMemberResponse { @Schema(description = "프로필 이미지 URL") private String avatarUrl; + @Schema(description = "스팟/피드 그룹 내 표시용 역할 (OWNER / SUPPORTER / PARTNER)", nullable = true, example = "SUPPORTER") + private SpotMemberRole role; + @Schema(description = "입장 일시") private LocalDateTime joinedAt; public static ChatMemberResponse from(ChatRoomMember member, UserEntity user) { + return from(member, user, null); + } + + public static ChatMemberResponse from(ChatRoomMember member, UserEntity user, SpotMemberRole role) { return ChatMemberResponse.builder() .userId(member.getUserId()) .nickname(user == null ? null : user.getNickname()) .avatarUrl(user == null ? null : user.getAvatarUrl()) + .role(role) .joinedAt(member.getJoinedAt()) .build(); } diff --git a/capstone-api/src/main/java/backend/chat/service/ChatService.java b/capstone-api/src/main/java/backend/chat/service/ChatService.java index 862637d..f226ea6 100644 --- a/capstone-api/src/main/java/backend/chat/service/ChatService.java +++ b/capstone-api/src/main/java/backend/chat/service/ChatService.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.Collection; import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -37,9 +38,17 @@ import backend.chat.repository.ChatMessageRepository; import backend.chat.repository.ChatRoomMemberRepository; import backend.chat.repository.ChatRoomRepository; +import backend.feed.entity.FeedApplicationStatus; +import backend.feed.entity.FeedItem; +import backend.feed.repository.FeedApplicationRepository; +import backend.feed.repository.FeedItemRepository; import backend.global.error.exception.BusinessException; import backend.global.error.exception.ErrorCode; +import backend.spot.entity.ParticipantRole; import backend.spot.entity.Spot; +import backend.spot.entity.SpotMemberRole; +import backend.spot.entity.SpotParticipant; +import backend.spot.repository.SpotParticipantRepository; import backend.spot.repository.SpotRepository; import backend.user.entity.UserEntity; import backend.user.repository.UserRepository; @@ -56,6 +65,9 @@ public class ChatService { private final ChatBlockRepository chatBlockRepository; private final SseEmitterService sseEmitterService; private final SpotRepository spotRepository; + private final SpotParticipantRepository spotParticipantRepository; + private final FeedItemRepository feedItemRepository; + private final FeedApplicationRepository feedApplicationRepository; private final UserRepository userRepository; // ───────────────────────────────────────────── @@ -227,16 +239,71 @@ public List getRoomsBySpot(String spotId, String currentUserId @Transactional(readOnly = true) public List getMembers(Long roomId, String currentUserId) { + ChatRoom room = findRoomOrThrow(roomId); assertMembership(roomId, currentUserId); List members = chatRoomMemberRepository.findByChatRoomId(roomId); List userIds = members.stream().map(ChatRoomMember::getUserId).toList(); Map usersById = userRepository.findAllById(userIds).stream() .collect(Collectors.toMap(UserEntity::getId, Function.identity())); + Map rolesByUserId = resolveRoomMemberRoles(room); return members.stream() - .map(m -> ChatMemberResponse.from(m, usersById.get(m.getUserId()))) + .map(m -> ChatMemberResponse.from(m, usersById.get(m.getUserId()), rolesByUserId.get(m.getUserId()))) .toList(); } + private Map resolveRoomMemberRoles(ChatRoom room) { + if (room.getType() != ChatRoomType.GROUP) { + return Map.of(); + } + Long spotId = parseLongOrNull(room.getSpotId()); + if (spotId != null) { + Map roles = new HashMap<>(); + spotParticipantRepository.findBySpotId(spotId) + .forEach(participant -> { + SpotMemberRole role = resolveSpotMemberRole(participant); + if (role != null) { + roles.put(participant.getUserId(), role); + } + }); + return roles; + } + Long feedId = parseLongOrNull(room.getPostId()); + if (feedId == null) { + return Map.of(); + } + Map roles = new HashMap<>(); + feedItemRepository.findById(feedId) + .map(FeedItem::getAuthorId) + .ifPresent(authorId -> roles.put(authorId, SpotMemberRole.OWNER)); + feedApplicationRepository.findAllByFeedItemIdAndStatus(feedId, FeedApplicationStatus.ACCEPTED) + .stream() + .filter(application -> application.getAppliedRole() != null) + .forEach(application -> roles.put( + application.getUserId(), SpotMemberRole.valueOf(application.getAppliedRole().name()))); + return roles; + } + + private SpotMemberRole resolveSpotMemberRole(SpotParticipant participant) { + if (participant.getRole() == ParticipantRole.AUTHOR) { + return SpotMemberRole.OWNER; + } + if (participant.getApplicationRole() == null) { + return null; + } + return SpotMemberRole.valueOf(participant.getApplicationRole().name()); + } + + private Long parseLongOrNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Long.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } + // ───────────────────────────────────────────── // 멤버십 (Membership) — 외부 도메인에서도 호출 가능 // ───────────────────────────────────────────── diff --git a/capstone-api/src/main/java/backend/feed/dto/FeedApplicationResponse.java b/capstone-api/src/main/java/backend/feed/dto/FeedApplicationResponse.java index 6041fae..e63ae21 100644 --- a/capstone-api/src/main/java/backend/feed/dto/FeedApplicationResponse.java +++ b/capstone-api/src/main/java/backend/feed/dto/FeedApplicationResponse.java @@ -27,9 +27,15 @@ public class FeedApplicationResponse { private FeedApplicationStatus status; private FeedApplicationRole appliedRole; private Integer deposit; + private boolean spotConverted; + private Long spotId; private LocalDateTime createdAt; public static FeedApplicationResponse from(FeedApplication application) { + return from(application, null); + } + + public static FeedApplicationResponse from(FeedApplication application, Long spotId) { return FeedApplicationResponse.builder() .id(application.getId()) .feedId(application.getFeedItemId()) @@ -39,6 +45,8 @@ public static FeedApplicationResponse from(FeedApplication application) { .status(application.getStatus()) .appliedRole(application.getAppliedRole()) .deposit(application.getDeposit()) + .spotConverted(spotId != null) + .spotId(spotId) .createdAt(application.getCreatedAt()) .build(); } diff --git a/capstone-api/src/main/java/backend/feed/dto/FeedItemResponse.java b/capstone-api/src/main/java/backend/feed/dto/FeedItemResponse.java index 231db14..698d93b 100644 --- a/capstone-api/src/main/java/backend/feed/dto/FeedItemResponse.java +++ b/capstone-api/src/main/java/backend/feed/dto/FeedItemResponse.java @@ -69,6 +69,18 @@ public class FeedItemResponse { @Schema(description = "펀딩 진행률(OFFER 전용)", example = "80") private Integer progressPercent; + @Schema(description = "펀딩 목표 금액(OFFER/REQUEST)", example = "25000") + private Integer fundingGoal; + + @Schema(description = "현재 확정/적립 금액(OFFER/REQUEST)", example = "20000") + private Integer fundedAmount; + + @Schema(description = "스팟 전환까지 남은 금액", example = "5000") + private Integer remainingAmount; + + @Schema(description = "현재 1인 가격 기준 스팟 전환까지 추가로 필요한 인원 수", example = "1") + private Integer remainingParticipantCount; + @Schema(description = "신청자 수(REQUEST 전용)", example = "3") private Long applicantCount; @@ -155,6 +167,10 @@ public static FeedItemResponse from(FeedItem feedItem, Long applicantCount, Bool .deadline(feedItem.getDeadline()) .partnerCount(feedItem.getType() == FeedType.OFFER ? feedItem.getConfirmedPartnerCount() : null) .progressPercent(feedItem.getType() == FeedType.OFFER ? calculateProgressPercent(feedItem) : null) + .fundingGoal(feedItem.getFundingGoal()) + .fundedAmount(feedItem.getFundedAmount()) + .remainingAmount(calculateRemainingAmount(feedItem)) + .remainingParticipantCount(calculateRemainingParticipantCount(feedItem)) .applicantCount(feedItem.getType() == FeedType.REQUEST ? applicantCount : null) .isBookmarked(isBookmarked) .myApplicationStatus(myApplication != null ? myApplication.getStatus() : null) @@ -211,6 +227,24 @@ private static Integer calculateProgressPercent(FeedItem feedItem) { return (int) ((long) fundedAmount * 100L / fundingGoal); } + private static Integer calculateRemainingAmount(FeedItem feedItem) { + Integer fundingGoal = feedItem.getFundingGoal(); + if (fundingGoal == null || fundingGoal <= 0) { + return null; + } + Integer fundedAmount = feedItem.getFundedAmount() == null ? 0 : feedItem.getFundedAmount(); + return Math.max(fundingGoal - fundedAmount, 0); + } + + private static Integer calculateRemainingParticipantCount(FeedItem feedItem) { + Integer remainingAmount = calculateRemainingAmount(feedItem); + Integer price = feedItem.getPrice(); + if (remainingAmount == null || price == null || price <= 0) { + return null; + } + return (remainingAmount + price - 1) / price; + } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); static List parseJsonList(String json) { diff --git a/capstone-api/src/main/java/backend/feed/service/FeedItemService.java b/capstone-api/src/main/java/backend/feed/service/FeedItemService.java index 73fcc6d..7a6788d 100644 --- a/capstone-api/src/main/java/backend/feed/service/FeedItemService.java +++ b/capstone-api/src/main/java/backend/feed/service/FeedItemService.java @@ -317,9 +317,11 @@ public FeedApplicationResponse acceptApplication(Long feedId, String application chatService.ensureGroupRoomForPost(String.valueOf(feedId), feedItem.getTitle(), Set.of(application.getUserId())); feedItem.accumulateFunding(feedItem.getPrice()); + Long convertedSpotId = null; if (feedItem.isFundingGoalMet()) { Spot spot = spotRepository.save(Spot.fromFeedItem(feedItem)); - feedItem.softDelete(); // 피드는 소프트 딜리트 (스팟으로 전환됨) + convertedSpotId = spot.getId(); + feedItem.convertToSpot(convertedSpotId); // 피드는 소프트 딜리트 (스팟으로 전환됨) Set participantIds = registerSpotParticipants(spot, feedItem); chatService.linkGroupRoomToSpot(String.valueOf(feedId), String.valueOf(spot.getId()), spot.getTitle(), participantIds); // Spot 전환은 시스템 자동 처리 — 작성자 포함 모든 참여자에게 알림 @@ -333,7 +335,7 @@ public FeedApplicationResponse acceptApplication(Long feedId, String application "'" + feedItem.getTitle() + "' 신청이 수락됐어요"); } - return FeedApplicationResponse.from(application); + return FeedApplicationResponse.from(application, convertedSpotId); } @Transactional @@ -392,6 +394,7 @@ private Set registerSpotParticipants(Spot spot, FeedItem feedItem) { .spotId(spot.getId()) .userId(uid) .role(ParticipantRole.PARTICIPANT) + .applicationRole(app.getAppliedRole()) .state(ParticipantState.ACTIVE) .build()); } diff --git a/capstone-api/src/main/java/backend/spot/controller/SpotController.java b/capstone-api/src/main/java/backend/spot/controller/SpotController.java index a636872..82c6080 100644 --- a/capstone-api/src/main/java/backend/spot/controller/SpotController.java +++ b/capstone-api/src/main/java/backend/spot/controller/SpotController.java @@ -296,6 +296,17 @@ public ResponseEntity> requestSettlement( )); } + @Operation(summary = "스팟 정산 조회", description = "최근 정산 요청 상태를 조회합니다. 정산 요청이 없으면 data=null 입니다.") + @GetMapping("/{spotId}/settlement") + public ResponseEntity> getSettlement( + @PathVariable Long spotId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(ApiResponse.success( + spotService.getSettlement(spotId, requireAuth(userDetails)) + )); + } + @Operation(summary = "스팟 정산 승인", description = "승인 대기 중인 정산을 참여자가 승인 처리합니다.") @PostMapping("/{spotId}/settlement/approve") public ResponseEntity> approveSettlement( diff --git a/capstone-api/src/main/java/backend/spot/dto/SpotDetailResponse.java b/capstone-api/src/main/java/backend/spot/dto/SpotDetailResponse.java index d334193..15c3c16 100644 --- a/capstone-api/src/main/java/backend/spot/dto/SpotDetailResponse.java +++ b/capstone-api/src/main/java/backend/spot/dto/SpotDetailResponse.java @@ -65,8 +65,12 @@ public class SpotDetailResponse { @Schema(description = "스팟 활동 타임라인 (오래된 순)") private List timeline; + @Schema(description = "최근 정산 요청 상태", nullable = true) + private SpotSettlementResponse settlement; + public static SpotDetailResponse of( - Spot spot, int participantCount, boolean isOwner, List timeline + Spot spot, int participantCount, boolean isOwner, List timeline, + SpotSettlementResponse settlement ) { return SpotDetailResponse.builder() .id(spot.getId()) @@ -84,6 +88,7 @@ public static SpotDetailResponse of( .updatedAt(spot.getUpdatedAt()) .isOwner(isOwner) .timeline(timeline) + .settlement(settlement) .build(); } } diff --git a/capstone-api/src/main/java/backend/spot/dto/SpotParticipantResponse.java b/capstone-api/src/main/java/backend/spot/dto/SpotParticipantResponse.java index e342eba..86f7aee 100644 --- a/capstone-api/src/main/java/backend/spot/dto/SpotParticipantResponse.java +++ b/capstone-api/src/main/java/backend/spot/dto/SpotParticipantResponse.java @@ -4,6 +4,7 @@ import backend.spot.entity.ParticipantRole; import backend.spot.entity.ParticipantState; +import backend.spot.entity.SpotMemberRole; import backend.spot.entity.SpotParticipant; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; @@ -31,6 +32,9 @@ public class SpotParticipantResponse { @Schema(description = "역할 (AUTHOR: 작성자, PARTICIPANT: 참여자)", example = "PARTICIPANT") private ParticipantRole role; + @Schema(description = "표시용 구성원 역할 (OWNER / SUPPORTER / PARTNER)", nullable = true, example = "PARTNER") + private SpotMemberRole memberRole; + @Schema(description = "참여 상태 (ACTIVE / LEFT / EXPELLED)", example = "ACTIVE") private ParticipantState state; @@ -43,6 +47,7 @@ public static SpotParticipantResponse from(SpotParticipant participant) { .userId(participant.getUserId()) .nickname(null) .role(participant.getRole()) + .memberRole(resolveMemberRole(participant)) .state(participant.getState()) .joinedAt(participant.getJoinedAt()) .build(); @@ -54,8 +59,19 @@ public static SpotParticipantResponse of(SpotParticipant participant, String nic .userId(participant.getUserId()) .nickname(nickname) .role(participant.getRole()) + .memberRole(resolveMemberRole(participant)) .state(participant.getState()) .joinedAt(participant.getJoinedAt()) .build(); } + + private static SpotMemberRole resolveMemberRole(SpotParticipant participant) { + if (participant.getRole() == ParticipantRole.AUTHOR) { + return SpotMemberRole.OWNER; + } + if (participant.getApplicationRole() == null) { + return null; + } + return SpotMemberRole.valueOf(participant.getApplicationRole().name()); + } } diff --git a/capstone-api/src/main/java/backend/spot/service/SpotService.java b/capstone-api/src/main/java/backend/spot/service/SpotService.java index 5a7d255..8e2c44a 100644 --- a/capstone-api/src/main/java/backend/spot/service/SpotService.java +++ b/capstone-api/src/main/java/backend/spot/service/SpotService.java @@ -250,8 +250,11 @@ private static > E parseEnum(Class enumType, String value) public SpotDetailResponse getSpot(Long spotId, String currentUserId) { Spot spot = findSpotOrThrow(spotId); long participantCount = spotParticipantRepository.countBySpotIdAndState(spotId, ParticipantState.ACTIVE); + SpotSettlementResponse settlement = spotSettlementRepository.findFirstBySpotIdOrderByCreatedAtDescIdDesc(spotId) + .map(this::buildSettlementResponse) + .orElse(null); return SpotDetailResponse.of( - spot, Math.toIntExact(participantCount), isOwner(spotId, currentUserId), loadTimeline(spotId)); + spot, Math.toIntExact(participantCount), isOwner(spotId, currentUserId), loadTimeline(spotId), settlement); } private List loadTimeline(Long spotId) { @@ -799,6 +802,14 @@ public SpotSettlementResponse approveSettlement(Long spotId, String currentUserI return buildSettlementResponse(settlement); } + @Transactional(readOnly = true) + public SpotSettlementResponse getSettlement(Long spotId, String currentUserId) { + validateParticipant(spotId, currentUserId, ErrorCode.NOT_SPOT_PARTICIPANT); + return spotSettlementRepository.findFirstBySpotIdOrderByCreatedAtDescIdDesc(spotId) + .map(this::buildSettlementResponse) + .orElse(null); + } + private SpotSettlementResponse buildSettlementResponse(SpotSettlement settlement) { List lineItems = spotSettlementLineItemRepository .findBySettlementId(settlement.getId()).stream() diff --git a/capstone-api/src/test/java/backend/feed/service/FeedItemServiceTest.java b/capstone-api/src/test/java/backend/feed/service/FeedItemServiceTest.java index c0d30c4..ac854a4 100644 --- a/capstone-api/src/test/java/backend/feed/service/FeedItemServiceTest.java +++ b/capstone-api/src/test/java/backend/feed/service/FeedItemServiceTest.java @@ -23,6 +23,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import backend.chat.service.ChatService; import backend.feed.dto.FeedApplicationResponse; @@ -38,6 +39,7 @@ import backend.global.enums.FeedType; import backend.notification.service.NotificationService; import backend.spot.entity.Spot; +import backend.spot.entity.SpotParticipant; import backend.spot.repository.SpotParticipantRepository; import backend.spot.repository.SpotRepository; @@ -71,6 +73,9 @@ class FeedItemServiceTest { @Captor private ArgumentCaptor feedApplicationCaptor; + @Captor + private ArgumentCaptor> spotParticipantsCaptor; + // ───────────────────────────────────────────── // getFeedItem // ───────────────────────────────────────────── @@ -203,9 +208,13 @@ void acceptApplication_Success_SpotConversion() { .willReturn(Optional.of(application)); given(feedApplicationRepository.findAllByFeedItemIdAndStatus(1L, FeedApplicationStatus.ACCEPTED)) .willReturn(List.of(application)); - given(spotRepository.save(any(Spot.class))).willAnswer(inv -> inv.getArgument(0)); + given(spotRepository.save(any(Spot.class))).willAnswer(inv -> { + Spot spot = inv.getArgument(0); + ReflectionTestUtils.setField(spot, "id", 77L); + return spot; + }); - feedItemService.acceptApplication(1L, "app-001", "author-id"); + FeedApplicationResponse response = feedItemService.acceptApplication(1L, "app-001", "author-id"); // Spot 저장 확인 + 피드 필드가 Spot에 정확히 복사되었는지 검증 verify(spotRepository, times(1)).save(spotCaptor.capture()); @@ -216,6 +225,10 @@ void acceptApplication_Success_SpotConversion() { // 피드 소프트 딜리트 확인 assertTrue(feedItem.isDeleted()); + assertEquals(FeedItemStatus.MATCHED, feedItem.getStatus()); + assertEquals(77L, feedItem.getSpotId()); + assertTrue(response.isSpotConverted()); + assertEquals(77L, response.getSpotId()); // 알림 발송 확인 (PR #99에서 send → sendAfterCommit 으로 전환됨) verify(notificationService, times(1)).sendAfterCommit(eq("author-id"), anyString()); @@ -224,6 +237,13 @@ void acceptApplication_Success_SpotConversion() { // spot.getId()가 테스트 환경에서 null이므로 두 번째 인자는 anyString()으로 검증 // 4번째 인자(allMemberIds: Collection)까지 시그니처에 맞춰 검증 verify(chatService, times(1)).linkGroupRoomToSpot(eq("1"), anyString(), any(), any()); + + verify(spotParticipantRepository, times(1)).saveAll(spotParticipantsCaptor.capture()); + SpotParticipant participant = spotParticipantsCaptor.getValue().stream() + .filter(saved -> "user-001".equals(saved.getUserId())) + .findFirst() + .orElseThrow(); + assertEquals(FeedApplicationRole.SUPPORTER, participant.getApplicationRole()); } @Test @@ -293,6 +313,7 @@ private FeedApplication appliedApplication(String id, Long feedItemId) { .userId("user-001") .userNickname("테스터") .proposal("신청합니다.") + .appliedRole(FeedApplicationRole.SUPPORTER) .build(); } } diff --git a/capstone-domain/src/main/java/backend/feed/entity/FeedItem.java b/capstone-domain/src/main/java/backend/feed/entity/FeedItem.java index f74707d..0c0c575 100644 --- a/capstone-domain/src/main/java/backend/feed/entity/FeedItem.java +++ b/capstone-domain/src/main/java/backend/feed/entity/FeedItem.java @@ -176,6 +176,12 @@ public void softDelete() { this.deleted = true; } + public void convertToSpot(Long spotId) { + this.spotId = spotId; + this.status = FeedItemStatus.MATCHED; + this.deleted = true; + } + /** * 피드 당 수락 가능한 서포터 최대 인원. * OFFER/REQUEST 모두 서포터는 1명으로 고정하는 것이 현재 서비스 정책이다. diff --git a/capstone-domain/src/main/java/backend/spot/entity/Spot.java b/capstone-domain/src/main/java/backend/spot/entity/Spot.java index 1095f14..4ee30bf 100644 --- a/capstone-domain/src/main/java/backend/spot/entity/Spot.java +++ b/capstone-domain/src/main/java/backend/spot/entity/Spot.java @@ -125,8 +125,7 @@ public void complete() { /** * FeedItem 데이터를 기반으로 Spot을 생성하는 정적 팩토리 메서드. * 펀딩 목표 달성(신청 수락) 시 피드를 스팟으로 전환할 때 호출. - * FeedItem.spotId는 AI 피드 전용이므로 일반 전환 시 역참조를 저장하지 않는다. - * 전환 완료 후 원본 FeedItem은 소프트 딜리트 처리된다. + * 전환 완료 후 원본 FeedItem은 전환된 spotId를 보존하고 소프트 딜리트 처리된다. */ public static Spot fromFeedItem(FeedItem feedItem) { return Spot.builder() diff --git a/capstone-domain/src/main/java/backend/spot/entity/SpotMemberRole.java b/capstone-domain/src/main/java/backend/spot/entity/SpotMemberRole.java new file mode 100644 index 0000000..3b4db52 --- /dev/null +++ b/capstone-domain/src/main/java/backend/spot/entity/SpotMemberRole.java @@ -0,0 +1,14 @@ +package backend.spot.entity; + +/** + * 프론트에서 표시하는 스팟 구성원 역할. + * + * - OWNER : 피드/스팟 작성자 + * - SUPPORTER : 알려주는 사람 + * - PARTNER : 일반 참여자 + */ +public enum SpotMemberRole { + OWNER, + SUPPORTER, + PARTNER +} diff --git a/capstone-domain/src/main/java/backend/spot/entity/SpotParticipant.java b/capstone-domain/src/main/java/backend/spot/entity/SpotParticipant.java index fd5fb7a..0733026 100644 --- a/capstone-domain/src/main/java/backend/spot/entity/SpotParticipant.java +++ b/capstone-domain/src/main/java/backend/spot/entity/SpotParticipant.java @@ -5,6 +5,7 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import backend.feed.entity.FeedApplicationRole; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; @@ -50,6 +51,10 @@ public class SpotParticipant { @Column(nullable = false) private ParticipantRole role; + @Enumerated(EnumType.STRING) + @Column(name = "application_role") + private FeedApplicationRole applicationRole; + @Enumerated(EnumType.STRING) @Builder.Default @Column(nullable = false) diff --git a/docs/migrations/2026-06-02_spot_participant_application_role.sql b/docs/migrations/2026-06-02_spot_participant_application_role.sql new file mode 100644 index 0000000..d9d0a73 --- /dev/null +++ b/docs/migrations/2026-06-02_spot_participant_application_role.sql @@ -0,0 +1,37 @@ +-- ============================================================================ +-- Migration: spot_participants 테이블에 application_role 컬럼 추가 +-- Date : 2026-06-02 +-- Author : Hermes +-- Issue : #122 — expose spot member roles and settlement lookup +-- Target : PostgreSQL +-- ============================================================================ +-- +-- 변경 사항: +-- 1) spot_participants.application_role VARCHAR(20) NULL +-- - FeedApplication.applied_role(SUPPORTER|PARTNER)를 Spot 전환 후에도 보존한다. +-- - 작성자(AUTHOR)는 application_role=NULL 이며 API 응답에서 OWNER로 표시한다. +-- +-- 의미: +-- 기존 role(AUTHOR|PARTICIPANT)은 DB 호환성을 위해 유지하고, 프론트 표시용 +-- OWNER/SUPPORTER/PARTNER는 role + application_role 조합으로 계산한다. +-- +-- 실행 절차: +-- 1. psql -d backend_db -f docs/migrations/2026-06-02_spot_participant_application_role.sql +-- 2. 서버 재기동 +-- +-- 롤백: +-- ALTER TABLE spot_participants DROP COLUMN IF EXISTS application_role; +-- ============================================================================ + +BEGIN; + +ALTER TABLE spot_participants + ADD COLUMN IF NOT EXISTS application_role VARCHAR(20); + +COMMIT; + +-- 검증: +-- SELECT column_name, data_type, is_nullable +-- FROM information_schema.columns +-- WHERE table_name = 'spot_participants' +-- AND column_name = 'application_role'; From f6da9c9fcb0ff9cdf58b974f8061f0f8820a914f Mon Sep 17 00:00:00 2001 From: seoJing Date: Tue, 2 Jun 2026 03:35:25 +0900 Subject: [PATCH 2/2] fix: address spot role review comments --- .../backend/chat/service/ChatService.java | 31 +++---------------- .../backend/feed/dto/FeedItemResponse.java | 5 ++- .../spot/dto/SpotParticipantResponse.java | 14 ++------- .../backend/spot/service/SpotService.java | 8 ++--- .../backend/spot/entity/SpotMemberRole.java | 21 ++++++++++++- 5 files changed, 34 insertions(+), 45 deletions(-) diff --git a/capstone-api/src/main/java/backend/chat/service/ChatService.java b/capstone-api/src/main/java/backend/chat/service/ChatService.java index f226ea6..9946442 100644 --- a/capstone-api/src/main/java/backend/chat/service/ChatService.java +++ b/capstone-api/src/main/java/backend/chat/service/ChatService.java @@ -44,10 +44,8 @@ import backend.feed.repository.FeedItemRepository; import backend.global.error.exception.BusinessException; import backend.global.error.exception.ErrorCode; -import backend.spot.entity.ParticipantRole; import backend.spot.entity.Spot; import backend.spot.entity.SpotMemberRole; -import backend.spot.entity.SpotParticipant; import backend.spot.repository.SpotParticipantRepository; import backend.spot.repository.SpotRepository; import backend.user.entity.UserEntity; @@ -255,19 +253,19 @@ private Map resolveRoomMemberRoles(ChatRoom room) { if (room.getType() != ChatRoomType.GROUP) { return Map.of(); } - Long spotId = parseLongOrNull(room.getSpotId()); + Long spotId = parseSpotId(room.getSpotId()); if (spotId != null) { Map roles = new HashMap<>(); spotParticipantRepository.findBySpotId(spotId) .forEach(participant -> { - SpotMemberRole role = resolveSpotMemberRole(participant); + SpotMemberRole role = SpotMemberRole.fromParticipant(participant); if (role != null) { roles.put(participant.getUserId(), role); } }); return roles; } - Long feedId = parseLongOrNull(room.getPostId()); + Long feedId = parseSpotId(room.getPostId()); if (feedId == null) { return Map.of(); } @@ -279,31 +277,10 @@ private Map resolveRoomMemberRoles(ChatRoom room) { .stream() .filter(application -> application.getAppliedRole() != null) .forEach(application -> roles.put( - application.getUserId(), SpotMemberRole.valueOf(application.getAppliedRole().name()))); + application.getUserId(), SpotMemberRole.fromApplicationRole(application.getAppliedRole()))); return roles; } - private SpotMemberRole resolveSpotMemberRole(SpotParticipant participant) { - if (participant.getRole() == ParticipantRole.AUTHOR) { - return SpotMemberRole.OWNER; - } - if (participant.getApplicationRole() == null) { - return null; - } - return SpotMemberRole.valueOf(participant.getApplicationRole().name()); - } - - private Long parseLongOrNull(String value) { - if (value == null || value.isBlank()) { - return null; - } - try { - return Long.valueOf(value); - } catch (NumberFormatException e) { - return null; - } - } - // ───────────────────────────────────────────── // 멤버십 (Membership) — 외부 도메인에서도 호출 가능 // ───────────────────────────────────────────── diff --git a/capstone-api/src/main/java/backend/feed/dto/FeedItemResponse.java b/capstone-api/src/main/java/backend/feed/dto/FeedItemResponse.java index 698d93b..1a55fb7 100644 --- a/capstone-api/src/main/java/backend/feed/dto/FeedItemResponse.java +++ b/capstone-api/src/main/java/backend/feed/dto/FeedItemResponse.java @@ -242,7 +242,10 @@ private static Integer calculateRemainingParticipantCount(FeedItem feedItem) { if (remainingAmount == null || price == null || price <= 0) { return null; } - return (remainingAmount + price - 1) / price; + long remaining = remainingAmount; + long unitPrice = price; + long participantCount = (remaining + unitPrice - 1) / unitPrice; + return participantCount > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) participantCount; } private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); diff --git a/capstone-api/src/main/java/backend/spot/dto/SpotParticipantResponse.java b/capstone-api/src/main/java/backend/spot/dto/SpotParticipantResponse.java index 86f7aee..026d777 100644 --- a/capstone-api/src/main/java/backend/spot/dto/SpotParticipantResponse.java +++ b/capstone-api/src/main/java/backend/spot/dto/SpotParticipantResponse.java @@ -47,7 +47,7 @@ public static SpotParticipantResponse from(SpotParticipant participant) { .userId(participant.getUserId()) .nickname(null) .role(participant.getRole()) - .memberRole(resolveMemberRole(participant)) + .memberRole(SpotMemberRole.fromParticipant(participant)) .state(participant.getState()) .joinedAt(participant.getJoinedAt()) .build(); @@ -59,19 +59,9 @@ public static SpotParticipantResponse of(SpotParticipant participant, String nic .userId(participant.getUserId()) .nickname(nickname) .role(participant.getRole()) - .memberRole(resolveMemberRole(participant)) + .memberRole(SpotMemberRole.fromParticipant(participant)) .state(participant.getState()) .joinedAt(participant.getJoinedAt()) .build(); } - - private static SpotMemberRole resolveMemberRole(SpotParticipant participant) { - if (participant.getRole() == ParticipantRole.AUTHOR) { - return SpotMemberRole.OWNER; - } - if (participant.getApplicationRole() == null) { - return null; - } - return SpotMemberRole.valueOf(participant.getApplicationRole().name()); - } } diff --git a/capstone-api/src/main/java/backend/spot/service/SpotService.java b/capstone-api/src/main/java/backend/spot/service/SpotService.java index 8e2c44a..6d30999 100644 --- a/capstone-api/src/main/java/backend/spot/service/SpotService.java +++ b/capstone-api/src/main/java/backend/spot/service/SpotService.java @@ -250,11 +250,10 @@ private static > E parseEnum(Class enumType, String value) public SpotDetailResponse getSpot(Long spotId, String currentUserId) { Spot spot = findSpotOrThrow(spotId); long participantCount = spotParticipantRepository.countBySpotIdAndState(spotId, ParticipantState.ACTIVE); - SpotSettlementResponse settlement = spotSettlementRepository.findFirstBySpotIdOrderByCreatedAtDescIdDesc(spotId) - .map(this::buildSettlementResponse) - .orElse(null); + boolean participant = isOwner(spotId, currentUserId); + SpotSettlementResponse settlement = participant ? getSettlement(spotId, currentUserId) : null; return SpotDetailResponse.of( - spot, Math.toIntExact(participantCount), isOwner(spotId, currentUserId), loadTimeline(spotId), settlement); + spot, Math.toIntExact(participantCount), participant, loadTimeline(spotId), settlement); } private List loadTimeline(Long spotId) { @@ -804,6 +803,7 @@ public SpotSettlementResponse approveSettlement(Long spotId, String currentUserI @Transactional(readOnly = true) public SpotSettlementResponse getSettlement(Long spotId, String currentUserId) { + validateSpotExists(spotId); validateParticipant(spotId, currentUserId, ErrorCode.NOT_SPOT_PARTICIPANT); return spotSettlementRepository.findFirstBySpotIdOrderByCreatedAtDescIdDesc(spotId) .map(this::buildSettlementResponse) diff --git a/capstone-domain/src/main/java/backend/spot/entity/SpotMemberRole.java b/capstone-domain/src/main/java/backend/spot/entity/SpotMemberRole.java index 3b4db52..21e69e6 100644 --- a/capstone-domain/src/main/java/backend/spot/entity/SpotMemberRole.java +++ b/capstone-domain/src/main/java/backend/spot/entity/SpotMemberRole.java @@ -1,5 +1,7 @@ package backend.spot.entity; +import backend.feed.entity.FeedApplicationRole; + /** * 프론트에서 표시하는 스팟 구성원 역할. * @@ -10,5 +12,22 @@ public enum SpotMemberRole { OWNER, SUPPORTER, - PARTNER + PARTNER; + + public static SpotMemberRole fromApplicationRole(FeedApplicationRole applicationRole) { + if (applicationRole == null) { + return null; + } + return switch (applicationRole) { + case SUPPORTER -> SUPPORTER; + case PARTNER -> PARTNER; + }; + } + + public static SpotMemberRole fromParticipant(SpotParticipant participant) { + if (participant.getRole() == ParticipantRole.AUTHOR) { + return OWNER; + } + return fromApplicationRole(participant.getApplicationRole()); + } }