From af9de64747ea3280fdfc483b29550ffcdf6578b9 Mon Sep 17 00:00:00 2001 From: donghyunkim Date: Fri, 29 May 2026 19:08:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(feed):=20Spot=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=E2=80=94=20=EC=84=9C=ED=8F=AC=ED=84=B0=20?= =?UTF-8?q?1=EB=AA=85=20+=20=ED=8C=8C=ED=8A=B8=EB=84=88=201=EB=AA=85=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=20=EC=88=98=EB=9D=BD=20=EC=8B=9C=20MATCHED?= =?UTF-8?q?=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FeedItem에 confirmedSupporterCount 필드 추가 - role별 카운트 분리: recordSupporterAccepted / recordPartnerAccepted - isReadyToMatch()로 전환 조건 교체 (isFundingGoalMet 제거) - acceptApplication()에서 appliedRole 분기 처리 Closes #120 Co-Authored-By: Claude Sonnet 4.6 --- .../backend/feed/service/FeedItemService.java | 18 ++++++---- .../java/backend/feed/entity/FeedItem.java | 36 +++++++++---------- 2 files changed, 29 insertions(+), 25 deletions(-) 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..c03de57 100644 --- a/capstone-api/src/main/java/backend/feed/service/FeedItemService.java +++ b/capstone-api/src/main/java/backend/feed/service/FeedItemService.java @@ -42,6 +42,7 @@ import backend.feed.dto.ResolvedPlace; import backend.feed.entity.Bookmark; import backend.feed.entity.FeedApplication; +import backend.feed.entity.FeedApplicationRole; import backend.feed.entity.FeedApplicationStatus; import backend.feed.entity.FeedItem; import backend.feed.repository.BookmarkRepository; @@ -304,20 +305,25 @@ public FeedApplicationResponse acceptApplication(Long feedId, String application throw new IllegalStateException("게시글 작성자만 신청을 수락할 수 있습니다."); } - if (!feedItem.canAcceptMore()) { - throw new IllegalStateException("이미 서포터 모집이 완료된 피드입니다."); - } - FeedApplication application = feedApplicationRepository .findByIdAndFeedItemId(applicationId, feedId) .orElseThrow(() -> new IllegalArgumentException("신청 내역을 찾을 수 없습니다.")); + FeedApplicationRole role = application.getAppliedRole(); + if (role == FeedApplicationRole.SUPPORTER) { + if (!feedItem.canAcceptMoreSupporters()) { + throw new IllegalStateException("이미 서포터가 수락된 피드입니다."); + } + feedItem.recordSupporterAccepted(); + } else { + feedItem.recordPartnerAccepted(); + } + application.accept(); // 수락 즉시 채팅방 참여 — Spot 전환 전에도 작성자와 소통 가능하도록 chatService.ensureGroupRoomForPost(String.valueOf(feedId), feedItem.getTitle(), Set.of(application.getUserId())); - feedItem.accumulateFunding(feedItem.getPrice()); - if (feedItem.isFundingGoalMet()) { + if (feedItem.isReadyToMatch()) { Spot spot = spotRepository.save(Spot.fromFeedItem(feedItem)); feedItem.softDelete(); // 피드는 소프트 딜리트 (스팟으로 전환됨) Set participantIds = registerSpotParticipants(spot, feedItem); 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..e5d76dd 100644 --- a/capstone-domain/src/main/java/backend/feed/entity/FeedItem.java +++ b/capstone-domain/src/main/java/backend/feed/entity/FeedItem.java @@ -82,6 +82,10 @@ public class FeedItem { @Column(nullable = false) private Integer confirmedPartnerCount = 0; + @Builder.Default + @Column(nullable = false, columnDefinition = "integer NOT NULL DEFAULT 0") + private Integer confirmedSupporterCount = 0; + @Builder.Default @Column(nullable = false) private Integer views = 0; @@ -176,32 +180,26 @@ public void softDelete() { this.deleted = true; } - /** - * 피드 당 수락 가능한 서포터 최대 인원. - * OFFER/REQUEST 모두 서포터는 1명으로 고정하는 것이 현재 서비스 정책이다. - * {@code maxParticipants} 필드는 프론트 UI 표시용(모집 정원 안내)이며, - * 수락 상한 판단에는 사용하지 않는다. - */ private static final int MAX_SUPPORTERS_PER_FEED = 1; - /** - * 추가 서포터를 수락할 수 있는지 확인한다. - * 수락 상한은 {@link #MAX_SUPPORTERS_PER_FEED}(현재 1명)으로 고정된다. - */ - public boolean canAcceptMore() { - int confirmed = this.confirmedPartnerCount != null ? this.confirmedPartnerCount : 0; + /** 서포터 추가 수락 가능 여부. 서포터는 1명으로 고정. */ + public boolean canAcceptMoreSupporters() { + int confirmed = this.confirmedSupporterCount != null ? this.confirmedSupporterCount : 0; return confirmed < MAX_SUPPORTERS_PER_FEED; } - public void accumulateFunding(int amount) { - this.fundedAmount = (this.fundedAmount != null ? this.fundedAmount : 0) + amount; + public void recordSupporterAccepted() { + this.confirmedSupporterCount = (this.confirmedSupporterCount != null ? this.confirmedSupporterCount : 0) + 1; + } + + public void recordPartnerAccepted() { this.confirmedPartnerCount = (this.confirmedPartnerCount != null ? this.confirmedPartnerCount : 0) + 1; } - public boolean isFundingGoalMet() { - if (this.fundingGoal == null || this.fundingGoal <= 0) { - return false; - } - return this.fundedAmount >= this.fundingGoal; + /** Spot 전환 조건: 서포터 1명 이상 + 파트너 1명 이상 수락 완료. */ + public boolean isReadyToMatch() { + int supporters = this.confirmedSupporterCount != null ? this.confirmedSupporterCount : 0; + int partners = this.confirmedPartnerCount != null ? this.confirmedPartnerCount : 0; + return supporters >= 1 && partners >= 1; } } From 01677e6968bb608e84960aa02117898cb6270df4 Mon Sep 17 00:00:00 2001 From: donghyunkim Date: Fri, 29 May 2026 19:18:10 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(feed):=20Spot=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=E2=80=94=20maxParticipants=20=EB=8B=AC?= =?UTF-8?q?=EC=84=B1=20=EC=9E=90=EB=8F=99=20=EC=A0=84=ED=99=98=20+=20?= =?UTF-8?q?=EC=A1=B0=EA=B8=B0=20=EC=8B=9C=EC=9E=91=20=EB=8F=99=EC=9D=98=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isReadyToMatch(): maxParticipants null이면 자동 전환 없음, 설정 시 서포터1+파트너>=maxParticipants - earlyStartRequested 필드: 작성자 조기 시작 요청 상태 - earlyStartConsented 필드: 참여자 개별 동의 상태 - POST /feeds/{feedId}/early-start: 작성자 조기 시작 요청 - POST /feeds/{feedId}/early-start/consent: 참여자 동의, 전원 동의 시 Spot 전환 - convertFeedToSpot() 추출로 자동/수동 전환 로직 공통화 Co-Authored-By: Claude Sonnet 4.6 --- .../feed/controller/FeedController.java | 20 +++++++ .../backend/feed/service/FeedItemService.java | 56 ++++++++++++++++--- .../backend/feed/entity/FeedApplication.java | 7 +++ .../java/backend/feed/entity/FeedItem.java | 25 ++++++++- 4 files changed, 99 insertions(+), 9 deletions(-) diff --git a/capstone-api/src/main/java/backend/feed/controller/FeedController.java b/capstone-api/src/main/java/backend/feed/controller/FeedController.java index c2b353c..50dcddd 100644 --- a/capstone-api/src/main/java/backend/feed/controller/FeedController.java +++ b/capstone-api/src/main/java/backend/feed/controller/FeedController.java @@ -154,6 +154,26 @@ public ApiResponse rejectApplication( return ApiResponse.success(response); } + @Operation(summary = "조기 시작 요청 (작성자 전용) — 인원 미달 시 참여자 동의 수집 시작") + @PostMapping("/{feedId}/early-start") + @ResponseStatus(HttpStatus.OK) + public ApiResponse requestEarlyStart( + @PathVariable Long feedId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + feedItemService.requestEarlyStart(feedId, requireAuth(userDetails)); + return ApiResponse.success(null); + } + + @Operation(summary = "조기 시작 동의 (수락된 참여자 전용) — 모두 동의 시 Spot 전환") + @PostMapping("/{feedId}/early-start/consent") + @ResponseStatus(HttpStatus.OK) + public ApiResponse consentEarlyStart( + @PathVariable Long feedId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + feedItemService.consentEarlyStart(feedId, requireAuth(userDetails)); + return ApiResponse.success(null); + } + private String requireAuth(CustomUserDetails userDetails) { if (userDetails == null || userDetails.getUserId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."); 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 c03de57..862298c 100644 --- a/capstone-api/src/main/java/backend/feed/service/FeedItemService.java +++ b/capstone-api/src/main/java/backend/feed/service/FeedItemService.java @@ -324,13 +324,7 @@ public FeedApplicationResponse acceptApplication(Long feedId, String application chatService.ensureGroupRoomForPost(String.valueOf(feedId), feedItem.getTitle(), Set.of(application.getUserId())); if (feedItem.isReadyToMatch()) { - Spot spot = spotRepository.save(Spot.fromFeedItem(feedItem)); - feedItem.softDelete(); // 피드는 소프트 딜리트 (스팟으로 전환됨) - Set participantIds = registerSpotParticipants(spot, feedItem); - chatService.linkGroupRoomToSpot(String.valueOf(feedId), String.valueOf(spot.getId()), spot.getTitle(), participantIds); - // Spot 전환은 시스템 자동 처리 — 작성자 포함 모든 참여자에게 알림 - participantIds.forEach(uid -> notificationService.sendAfterCommit(uid, - "피드 '" + feedItem.getTitle() + "'이 Spot으로 전환됐어요!")); + convertFeedToSpot(feedItem); } // 모든 후속 처리 완료 후 수락 알림 전송 (self-action 제외) @@ -377,6 +371,54 @@ public List getApplications(Long feedId, String request .collect(Collectors.toList()); } + @Transactional + public void requestEarlyStart(Long feedId, String requesterId) { + FeedItem feedItem = feedItemRepository.findByIdAndDeletedFalseForUpdate(feedId) + .orElseThrow(() -> new IllegalArgumentException("피드를 찾을 수 없습니다. id=" + feedId)); + if (!feedItem.getAuthorId().equals(requesterId)) { + throw new IllegalStateException("게시글 작성자만 조기 시작을 요청할 수 있습니다."); + } + if (!feedItem.canRequestEarlyStart()) { + throw new IllegalStateException("조기 시작 요청 불가: 서포터 1명 + 파트너 1명 이상 수락 후 요청하거나, 이미 요청 중입니다."); + } + feedItem.requestEarlyStart(); + List accepted = feedApplicationRepository + .findAllByFeedItemIdAndStatus(feedId, FeedApplicationStatus.ACCEPTED); + accepted.forEach(app -> notificationService.sendAfterCommit(app.getUserId(), + "'" + feedItem.getTitle() + "' 조기 시작 요청이 왔어요. 동의하면 Spot이 시작됩니다.")); + } + + @Transactional + public void consentEarlyStart(Long feedId, String currentUserId) { + FeedItem feedItem = feedItemRepository.findByIdAndDeletedFalseForUpdate(feedId) + .orElseThrow(() -> new IllegalArgumentException("피드를 찾을 수 없습니다. id=" + feedId)); + if (!feedItem.isEarlyStartRequested()) { + throw new IllegalStateException("조기 시작 요청이 없는 피드입니다."); + } + List allAccepted = feedApplicationRepository + .findAllByFeedItemIdAndStatus(feedId, FeedApplicationStatus.ACCEPTED); + FeedApplication myApplication = allAccepted.stream() + .filter(app -> currentUserId.equals(app.getUserId())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("수락된 신청 내역이 없습니다.")); + myApplication.consentEarlyStart(); + boolean allConsented = allAccepted.stream() + .allMatch(app -> Boolean.TRUE.equals(app.getEarlyStartConsented())); + if (allConsented) { + convertFeedToSpot(feedItem); + } + } + + private void convertFeedToSpot(FeedItem feedItem) { + Spot spot = spotRepository.save(Spot.fromFeedItem(feedItem)); + feedItem.softDelete(); + Set participantIds = registerSpotParticipants(spot, feedItem); + chatService.linkGroupRoomToSpot( + String.valueOf(feedItem.getId()), String.valueOf(spot.getId()), spot.getTitle(), participantIds); + participantIds.forEach(uid -> notificationService.sendAfterCommit(uid, + "피드 '" + feedItem.getTitle() + "'이 Spot으로 전환됐어요!")); + } + private Set registerSpotParticipants(Spot spot, FeedItem feedItem) { List participants = new ArrayList<>(); participants.add(SpotParticipant.builder() diff --git a/capstone-domain/src/main/java/backend/feed/entity/FeedApplication.java b/capstone-domain/src/main/java/backend/feed/entity/FeedApplication.java index 68790a5..2808f68 100644 --- a/capstone-domain/src/main/java/backend/feed/entity/FeedApplication.java +++ b/capstone-domain/src/main/java/backend/feed/entity/FeedApplication.java @@ -59,6 +59,9 @@ public class FeedApplication { @Column(nullable = false) private FeedApplicationStatus status = FeedApplicationStatus.APPLIED; + @Column + private Boolean earlyStartConsented; + @CreatedDate @Column(nullable = false, updatable = false) private LocalDateTime createdAt; @@ -83,4 +86,8 @@ public void cancel() { } this.status = FeedApplicationStatus.CANCELLED; } + + public void consentEarlyStart() { + this.earlyStartConsented = true; + } } 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 e5d76dd..2f0749e 100644 --- a/capstone-domain/src/main/java/backend/feed/entity/FeedItem.java +++ b/capstone-domain/src/main/java/backend/feed/entity/FeedItem.java @@ -164,6 +164,10 @@ public class FeedItem { @Column(columnDefinition = "TEXT") private String photoUrlsJson; + @Builder.Default + @Column(nullable = false, columnDefinition = "boolean NOT NULL DEFAULT false") + private boolean earlyStartRequested = false; + @Builder.Default @Column(name = "is_deleted", nullable = false) private boolean deleted = false; @@ -196,10 +200,27 @@ public void recordPartnerAccepted() { this.confirmedPartnerCount = (this.confirmedPartnerCount != null ? this.confirmedPartnerCount : 0) + 1; } - /** Spot 전환 조건: 서포터 1명 이상 + 파트너 1명 이상 수락 완료. */ + /** + * 자동 Spot 전환 조건: 서포터 1명 + 파트너 수락 수 >= maxParticipants. + * maxParticipants 미설정 시 자동 전환 없음 — 작성자가 수동 진행 요청해야 함. + */ public boolean isReadyToMatch() { + if (this.maxParticipants == null) { + return false; + } + int supporters = this.confirmedSupporterCount != null ? this.confirmedSupporterCount : 0; + int partners = this.confirmedPartnerCount != null ? this.confirmedPartnerCount : 0; + return supporters >= 1 && partners >= this.maxParticipants; + } + + /** 조기 시작 요청 가능 조건: 서포터 1명 + 파트너 1명 이상 (필수 조건). */ + public boolean canRequestEarlyStart() { int supporters = this.confirmedSupporterCount != null ? this.confirmedSupporterCount : 0; int partners = this.confirmedPartnerCount != null ? this.confirmedPartnerCount : 0; - return supporters >= 1 && partners >= 1; + return supporters >= 1 && partners >= 1 && !this.earlyStartRequested; + } + + public void requestEarlyStart() { + this.earlyStartRequested = true; } }