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 73fcc6d..862298c 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,27 +305,26 @@ 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()) { - 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으로 전환됐어요!")); + + if (feedItem.isReadyToMatch()) { + convertFeedToSpot(feedItem); } // 모든 후속 처리 완료 후 수락 알림 전송 (self-action 제외) @@ -371,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 f74707d..2f0749e 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; @@ -160,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; @@ -176,32 +184,43 @@ 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) { + /** + * 자동 Spot 전환 조건: 서포터 1명 + 파트너 수락 수 >= maxParticipants. + * maxParticipants 미설정 시 자동 전환 없음 — 작성자가 수동 진행 요청해야 함. + */ + public boolean isReadyToMatch() { + if (this.maxParticipants == null) { return false; } - return this.fundedAmount >= this.fundingGoal; + 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 && !this.earlyStartRequested; + } + + public void requestEarlyStart() { + this.earlyStartRequested = true; } }