Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,26 @@ public ApiResponse<FeedApplicationResponse> rejectApplication(
return ApiResponse.success(response);
}

@Operation(summary = "조기 시작 요청 (작성자 전용) — 인원 미달 시 참여자 동의 수집 시작")
@PostMapping("/{feedId}/early-start")
@ResponseStatus(HttpStatus.OK)
public ApiResponse<Void> 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<Void> 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, "인증이 필요합니다.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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 제외)
Expand Down Expand Up @@ -371,6 +371,54 @@ public List<FeedApplicationResponse> 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<FeedApplication> 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<FeedApplication> 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<String> 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<String> registerSpotParticipants(Spot spot, FeedItem feedItem) {
List<SpotParticipant> participants = new ArrayList<>();
participants.add(SpotParticipant.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -83,4 +86,8 @@ public void cancel() {
}
this.status = FeedApplicationStatus.CANCELLED;
}

public void consentEarlyStart() {
this.earlyStartConsented = true;
}
}
53 changes: 36 additions & 17 deletions capstone-domain/src/main/java/backend/feed/entity/FeedItem.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

authorRole = SUPPORTER인 피드에서 confirmedSupporterCount = 0 < 1true 반환. 작성자가 이미 서포터 슬롯을 차지했으므로 외부 SUPPORTER 수락이 허용돼서는 안 됩니다.

public boolean canAcceptMoreSupporters() {
    int authorSupplied = (this.authorRole == FeedAuthorRole.SUPPORTER) ? 1 : 0;
    int confirmed = (this.confirmedSupporterCount != null ? this.confirmedSupporterCount : 0);
    return (confirmed + authorSupplied) < MAX_SUPPORTERS_PER_FEED;
}

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() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

authorRole이 반영되지 않아 authorRole = SUPPORTER인 피드에서 supporters가 항상 0 → 자동 전환이 절대 발생하지 않습니다.

public boolean isReadyToMatch() {
    if (this.maxParticipants == null) return false;
    int supporters = (confirmedSupporterCount != null ? confirmedSupporterCount : 0)
        + (this.authorRole == FeedAuthorRole.SUPPORTER ? 1 : 0);
    int partners = (confirmedPartnerCount != null ? confirmedPartnerCount : 0)
        + (this.authorRole == FeedAuthorRole.PARTNER ? 1 : 0);
    return supporters >= 1 && partners >= this.maxParticipants;
}

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;
}
}