diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index edb5faa..931e6ca 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -111,4 +111,4 @@ jobs: docker compose -f docker-compose.blue.yml down || true docker compose -f docker-compose.blue.yml up -d - docker image prune -a -f \ No newline at end of file + docker image prune -a -f diff --git a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java index b0c1bf1..6e3d3d6 100644 --- a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java +++ b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java @@ -346,6 +346,7 @@ public AuthResponse refreshToken(RefreshTokenRequest request) { @Transactional public void logout(Long userId) { refreshTokenRepository.deleteByUserId(userId); + userRepository.deleteById(userId); log.info("로그아웃 - User ID: {}", userId); } diff --git a/src/main/java/ita/tinybite/domain/chat/entity/ChatRoom.java b/src/main/java/ita/tinybite/domain/chat/entity/ChatRoom.java new file mode 100644 index 0000000..129369a --- /dev/null +++ b/src/main/java/ita/tinybite/domain/chat/entity/ChatRoom.java @@ -0,0 +1,68 @@ +package ita.tinybite.domain.chat.entity; + +import ita.tinybite.domain.chat.enums.ChatRoomType; +import ita.tinybite.domain.party.entity.Party; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "chat_room") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class ChatRoom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "party_id", nullable = false) + private Party party; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ChatRoomType type; + + @Column(nullable = false, length = 100) + private String name; + + @Column(nullable = false) + @Builder.Default + private Boolean isActive = true; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List members = new ArrayList<>(); + + // ========== 비즈니스 메서드 ========== + + /** + * 멤버 추가 + */ + public void addMember(ita.tinybite.domain.user.entity.User user) { + ChatRoomMember member = ChatRoomMember.builder() + .chatRoom(this) + .user(user) + .isActive(true) + .build(); + members.add(member); + } + + /** + * 채팅방 비활성화 + */ + public void deactivate() { + this.isActive = false; + } +} diff --git a/src/main/java/ita/tinybite/domain/chat/entity/ChatRoomMember.java b/src/main/java/ita/tinybite/domain/chat/entity/ChatRoomMember.java new file mode 100644 index 0000000..19863ad --- /dev/null +++ b/src/main/java/ita/tinybite/domain/chat/entity/ChatRoomMember.java @@ -0,0 +1,52 @@ +package ita.tinybite.domain.chat.entity; + +import ita.tinybite.domain.party.entity.Party; +import ita.tinybite.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "chat_room_member") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class ChatRoomMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "party_id") + private Party party; + + @Column(nullable = false) + @Builder.Default + private Boolean isActive = true; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime joinedAt; + + private LocalDateTime leftAt; + + /** + * 퇴장 + */ + public void leave() { + this.isActive = false; + this.leftAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/chat/enums/ChatRoomType.java b/src/main/java/ita/tinybite/domain/chat/enums/ChatRoomType.java new file mode 100644 index 0000000..d5d9544 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/chat/enums/ChatRoomType.java @@ -0,0 +1,14 @@ +package ita.tinybite.domain.chat.enums; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public enum ChatRoomType { + ONE_TO_ONE("1:1 채팅"), + GROUP("단체 채팅"); + + private final String description; +} diff --git a/src/main/java/ita/tinybite/domain/chat/enums/ParticipantRole.java b/src/main/java/ita/tinybite/domain/chat/enums/ParticipantRole.java new file mode 100644 index 0000000..fb21cba --- /dev/null +++ b/src/main/java/ita/tinybite/domain/chat/enums/ParticipantRole.java @@ -0,0 +1,14 @@ +package ita.tinybite.domain.chat.enums; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public enum ParticipantRole { + LEADER("리더", "파티를 생성하고 관리하는 리더"), + MEMBER("멤버", "일반 참가자"); + private final String displayName; + private final String description; +} diff --git a/src/main/java/ita/tinybite/domain/chat/repository/ChatRoomRepository.java b/src/main/java/ita/tinybite/domain/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..ce0f82c --- /dev/null +++ b/src/main/java/ita/tinybite/domain/chat/repository/ChatRoomRepository.java @@ -0,0 +1,16 @@ +package ita.tinybite.domain.chat.repository; + +import ita.tinybite.domain.chat.entity.ChatRoom; +import ita.tinybite.domain.chat.enums.ChatRoomType; +import ita.tinybite.domain.party.entity.Party; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ChatRoomRepository extends JpaRepository { + + Optional findByPartyAndType(Party party, ChatRoomType type); + +} diff --git a/src/main/java/ita/tinybite/domain/party/controller/PartyController.java b/src/main/java/ita/tinybite/domain/party/controller/PartyController.java index 365a04e..512289f 100644 --- a/src/main/java/ita/tinybite/domain/party/controller/PartyController.java +++ b/src/main/java/ita/tinybite/domain/party/controller/PartyController.java @@ -8,9 +8,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import ita.tinybite.domain.auth.entity.JwtTokenProvider; +import ita.tinybite.domain.chat.entity.ChatRoom; import ita.tinybite.domain.party.dto.request.PartyCreateRequest; +import ita.tinybite.domain.party.dto.response.ChatRoomResponse; import ita.tinybite.domain.party.dto.response.PartyDetailResponse; import ita.tinybite.domain.party.dto.response.PartyListResponse; +import ita.tinybite.domain.party.entity.PartyParticipant; import ita.tinybite.domain.party.enums.PartyCategory; import ita.tinybite.domain.party.service.PartyService; import jakarta.validation.Valid; @@ -25,6 +28,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Tag(name = "파티 API", description = "파티 생성, 조회, 참여 관련 API") @RestController @RequestMapping("/api/parties") @@ -61,7 +66,7 @@ public class PartyController { ) }) @PostMapping("/{partyId}/join") - public ResponseEntity joinParty( + public ResponseEntity joinParty( @PathVariable Long partyId, @RequestHeader("Authorization") String token) { @@ -71,9 +76,206 @@ public ResponseEntity joinParty( return ResponseEntity.ok().build(); } + @Operation(summary = "참여 승인", description = "파티장이 참여를 승인하면 단체 채팅방에 자동 입장됩니다") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "승인 성공", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "403", + description = "파티장 권한 없음", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = "파티 또는 참여자를 찾을 수 없음", + content = @Content + ) + }) + @PostMapping("{partyId}/participants/{participantId}/approve") + public ResponseEntity approveParticipant( + @PathVariable Long partyId, + @PathVariable Long participantId, + @RequestHeader("Authorization") String token) { + + Long hostId = jwtTokenProvider.getUserId(token); + partyService.approveParticipant(partyId, participantId, hostId); + + return ResponseEntity.ok().build(); + } + + @Operation(summary = "참여 거절", description = "파티장이 참여를 거절합니다") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "거절 성공", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "403", + description = "파티장 권한 없음", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = "파티 또는 참여자를 찾을 수 없음", + content = @Content + ) + }) + @PostMapping("/participants/{participantId}/reject") + public ResponseEntity rejectParticipant( + @PathVariable Long partyId, + @PathVariable Long participantId, + @RequestHeader("Authorization") String token) { + + Long hostId = jwtTokenProvider.getUserId(token); + partyService.rejectParticipant(partyId, participantId, hostId); + + return ResponseEntity.ok().build(); + } + + @Operation(summary = "단체 채팅방 조회", description = "승인된 참여자가 단체 채팅방을 조회합니다") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = ChatRoomResponse.class)) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "403", + description = "승인된 참여자가 아님", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = "파티 또는 채팅방을 찾을 수 없음", + content = @Content + ) + }) + @GetMapping("{partyId}/chat/group") + public ResponseEntity getGroupChatRoom( + @PathVariable Long partyId, + @RequestHeader("Authorization") String token) { + + Long userId = jwtTokenProvider.getUserId(token); + ChatRoomResponse chatRoom = partyService.getGroupChatRoom(partyId, userId); + + return ResponseEntity.ok(chatRoom); + } + + @Operation(summary = "결산 가능 여부", description = "목표 인원 달성 시 true 반환") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = Boolean.class)) + ), + @ApiResponse( + responseCode = "404", + description = "파티를 찾을 수 없음", + content = @Content + ) + }) + @GetMapping("{partyId}/can-settle") + public ResponseEntity canSettle(@PathVariable Long partyId) { + boolean canSettle = partyService.canSettle(partyId); + return ResponseEntity.ok(canSettle); + } + + @Operation(summary = "파티 결산", description = "목표 인원 달성 후 파티를 마감합니다") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "결산 성공", + content = @Content + ), + @ApiResponse( + responseCode = "400", + description = "결산 조건 미충족", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "403", + description = "파티장 권한 없음", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = "파티를 찾을 수 없음", + content = @Content + ) + }) + @PostMapping("{partyId}/settle") + public ResponseEntity settleParty( + @PathVariable Long partyId, + @RequestHeader("Authorization") String token) { + + Long hostId = jwtTokenProvider.getUserId(token); + partyService.settleParty(partyId, hostId); + + return ResponseEntity.ok().build(); + } + + @Operation(summary = "승인 대기 목록", description = "파티장이 승인 대기 중인 참여자 목록을 조회합니다") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = PartyParticipant.class)) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "403", + description = "파티장 권한 없음", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = "파티를 찾을 수 없음", + content = @Content + ) + }) + @GetMapping("{partyId}/participants/pending") + public ResponseEntity> getPendingParticipants( + @PathVariable Long partyId, + @RequestHeader("Authorization") String token) { + + Long hostId = jwtTokenProvider.getUserId(token); + List participants = partyService.getPendingParticipants(partyId, hostId); + + return ResponseEntity.ok(participants); + } + + + /** * 파티 목록 조회 (홈 화면) - * 비회원도 접근 가능 */ @GetMapping public ResponseEntity getPartyList( diff --git a/src/main/java/ita/tinybite/domain/party/dto/response/ChatRoomResponse.java b/src/main/java/ita/tinybite/domain/party/dto/response/ChatRoomResponse.java new file mode 100644 index 0000000..560b730 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/party/dto/response/ChatRoomResponse.java @@ -0,0 +1,13 @@ +package ita.tinybite.domain.party.dto.response; + +import ita.tinybite.domain.chat.enums.ChatRoomType; +import lombok.AllArgsConstructor; +import lombok.Builder; + +@AllArgsConstructor +@Builder +public class ChatRoomResponse { + private Long id; + private ChatRoomType type; + private PartyInfo party; +} diff --git a/src/main/java/ita/tinybite/domain/party/dto/response/PartyInfo.java b/src/main/java/ita/tinybite/domain/party/dto/response/PartyInfo.java new file mode 100644 index 0000000..ea6e635 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/party/dto/response/PartyInfo.java @@ -0,0 +1,12 @@ +package ita.tinybite.domain.party.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; + +@AllArgsConstructor +@Builder +public class PartyInfo { + private Long id; + private String title; + private HostInfo host; +} diff --git a/src/main/java/ita/tinybite/domain/party/dto/response/ProductInfo.java b/src/main/java/ita/tinybite/domain/party/dto/response/ProductInfo.java new file mode 100644 index 0000000..6eb1d5e --- /dev/null +++ b/src/main/java/ita/tinybite/domain/party/dto/response/ProductInfo.java @@ -0,0 +1,15 @@ +package ita.tinybite.domain.party.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProductInfo { + String name; + String imageUrl; +} diff --git a/src/main/java/ita/tinybite/domain/party/entity/Party.java b/src/main/java/ita/tinybite/domain/party/entity/Party.java index 9294230..47bea29 100644 --- a/src/main/java/ita/tinybite/domain/party/entity/Party.java +++ b/src/main/java/ita/tinybite/domain/party/entity/Party.java @@ -2,6 +2,7 @@ import ita.tinybite.domain.party.enums.PartyCategory; +import ita.tinybite.domain.party.enums.PartyStatus; import ita.tinybite.domain.user.entity.User; import jakarta.persistence.*; import lombok.*; @@ -43,6 +44,9 @@ public class Party { @Column(nullable = false) private Integer maxParticipants; // 최대 인원 + @Column(nullable = false) + private Integer currentParticipants; + @Column(length = 500) private String link; // 링크 (예: 배달앱 링크) @@ -54,6 +58,10 @@ public class Party { @Column(nullable = false, length = 20) private PartyCategory category; // 카테고리 + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PartyStatus status; + @Column(nullable = false) private Double latitude; // 위도 (거리 계산용) @@ -71,6 +79,8 @@ public class Party { @Column(nullable = false) private LocalDateTime updatedAt; + private LocalDateTime closedAt; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "host_id", nullable = false) private User host; // 파티 개설자 @@ -79,12 +89,6 @@ public class Party { @Builder.Default private List participants = new ArrayList<>(); // 파티 참여 유저 - public int getApprovedParticipantCount() { - return (int) participants.stream() - .filter(PartyParticipant::getIsApproved) - .count() + 1; // 호스트 포함 - } - public String getTimeAgo() { LocalDateTime now = LocalDateTime.now(); @@ -158,4 +162,22 @@ public void updateLimitedFields(String description, List images) { this.thumbnailImage = images.get(0); } } + + public void close() { + validateCanClose(); + this.status = PartyStatus.CLOSED; + this.closedAt = LocalDateTime.now(); + } + + /** + * 파티 종료 가능 여부 검증 + */ + private void validateCanClose() { + if (this.status == PartyStatus.CLOSED) { + throw new IllegalStateException("이미 종료된 파티입니다."); + } + if (this.status == PartyStatus.CANCELLED) { + throw new IllegalStateException("취소된 파티는 종료할 수 없습니다."); + } + } } diff --git a/src/main/java/ita/tinybite/domain/party/entity/PartyParticipant.java b/src/main/java/ita/tinybite/domain/party/entity/PartyParticipant.java index 450e3ce..ab9b1b5 100644 --- a/src/main/java/ita/tinybite/domain/party/entity/PartyParticipant.java +++ b/src/main/java/ita/tinybite/domain/party/entity/PartyParticipant.java @@ -1,4 +1,6 @@ package ita.tinybite.domain.party.entity; +import ita.tinybite.domain.chat.entity.ChatRoom; +import ita.tinybite.domain.party.enums.ParticipantStatus; import ita.tinybite.domain.user.entity.User; import jakarta.persistence.*; import lombok.*; @@ -26,29 +28,44 @@ public class PartyParticipant { @JoinColumn(name = "user_id", nullable = false) private User user; - @Column(nullable = false) + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) @Builder.Default - private Boolean isApproved = false; // 승인 여부 + private ParticipantStatus status = ParticipantStatus.PENDING; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "one_to_one_chat_room_id") + private ChatRoom oneToOneChatRoom; @CreationTimestamp @Column(nullable = false, updatable = false) - private LocalDateTime joinedAt; // 참여 신청 시간 + private LocalDateTime requestedAt; + + private LocalDateTime approvedAt; - private LocalDateTime approvedAt; // 승인 시간 + private LocalDateTime rejectedAt; /** * 참여 승인 */ public void approve() { - this.isApproved = true; + this.status = ParticipantStatus.APPROVED; this.approvedAt = LocalDateTime.now(); } /** - * 승인 취소 + * 참여 거절 */ public void reject() { - this.isApproved = false; - this.approvedAt = null; + this.status = ParticipantStatus.REJECTED; + this.rejectedAt = LocalDateTime.now(); + } + + /** + * 승인 여부 + */ + public boolean isApproved() { + return this.status == ParticipantStatus.APPROVED; } } diff --git a/src/main/java/ita/tinybite/domain/party/enums/ParticipantStatus.java b/src/main/java/ita/tinybite/domain/party/enums/ParticipantStatus.java new file mode 100644 index 0000000..e47f061 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/party/enums/ParticipantStatus.java @@ -0,0 +1,15 @@ +package ita.tinybite.domain.party.enums; + +import lombok.AccessLevel; +import lombok.Getter;; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public enum ParticipantStatus { + PENDING("승인 대기"), + APPROVED("승인됨"), + REJECTED("거절됨"); + + private final String description; +} diff --git a/src/main/java/ita/tinybite/domain/party/enums/PartyStatus.java b/src/main/java/ita/tinybite/domain/party/enums/PartyStatus.java new file mode 100644 index 0000000..b5fb216 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/party/enums/PartyStatus.java @@ -0,0 +1,22 @@ +package ita.tinybite.domain.party.enums; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public enum PartyStatus { + + RECRUITING("모집 중", "참가자를 모집하고 있는 상태"), + + + COMPLETED("모집 완료", "정원이 찼거나 모집이 완료된 상태"), + + CLOSED("종료됨", "파티가 정상적으로 종료된 상태"), + + CANCELLED("취소됨", "파티가 취소된 상태"); + + private final String displayName; + private final String description; +} diff --git a/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java b/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java index b251fa4..fbb9974 100644 --- a/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java +++ b/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java @@ -1,15 +1,27 @@ package ita.tinybite.domain.party.repository; +import io.lettuce.core.dynamic.annotation.Param; +import ita.tinybite.domain.chat.entity.ChatRoom; +import ita.tinybite.domain.chat.enums.ChatRoomType; import ita.tinybite.domain.party.entity.Party; import ita.tinybite.domain.party.entity.PartyParticipant; +import ita.tinybite.domain.party.enums.ParticipantStatus; import ita.tinybite.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + @Repository public interface PartyParticipantRepository extends JpaRepository { boolean existsByPartyAndUser(Party party, User user); - boolean existsByPartyAndUserAndIsApprovedTrue(Party party, User user); + boolean existsByPartyAndUserUserIdAndStatus(Party party, Long userId, ParticipantStatus status); + + List findByPartyAndStatus(Party party, ParticipantStatus status); + + boolean existsByPartyAndUserAndStatus(Party party, User user, ParticipantStatus status); } \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/party/repository/PartyRepository.java b/src/main/java/ita/tinybite/domain/party/repository/PartyRepository.java index c8442b4..12a88f0 100644 --- a/src/main/java/ita/tinybite/domain/party/repository/PartyRepository.java +++ b/src/main/java/ita/tinybite/domain/party/repository/PartyRepository.java @@ -1,15 +1,22 @@ package ita.tinybite.domain.party.repository; +import io.lettuce.core.dynamic.annotation.Param; import ita.tinybite.domain.party.entity.Neighborhood; import ita.tinybite.domain.party.entity.Party; import ita.tinybite.domain.party.enums.PartyCategory; import java.util.List; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository public interface PartyRepository extends JpaRepository { + @Query("SELECT p FROM Party p JOIN FETCH p.host WHERE p.id = :id") + Optional findByIdWithHost(@Param("id") Long id); + List findByPickupLocation_Place(String place); List findByPickupLocation_PlaceAndCategory(String place, PartyCategory category); diff --git a/src/main/java/ita/tinybite/domain/party/service/PartyService.java b/src/main/java/ita/tinybite/domain/party/service/PartyService.java index 11a7937..f4b35f0 100644 --- a/src/main/java/ita/tinybite/domain/party/service/PartyService.java +++ b/src/main/java/ita/tinybite/domain/party/service/PartyService.java @@ -1,12 +1,17 @@ package ita.tinybite.domain.party.service; +import ita.tinybite.domain.chat.entity.ChatRoom; +import ita.tinybite.domain.chat.enums.ChatRoomType; +import ita.tinybite.domain.chat.repository.ChatRoomRepository; import ita.tinybite.domain.party.dto.request.PartyCreateRequest; import ita.tinybite.domain.party.dto.request.PartyUpdateRequest; import ita.tinybite.domain.party.dto.response.*; import ita.tinybite.domain.party.entity.Party; import ita.tinybite.domain.party.entity.PartyParticipant; import ita.tinybite.domain.party.entity.PickupLocation; +import ita.tinybite.domain.party.enums.ParticipantStatus; import ita.tinybite.domain.party.enums.PartyCategory; +import ita.tinybite.domain.party.enums.PartyStatus; import ita.tinybite.domain.party.repository.PartyParticipantRepository; import ita.tinybite.domain.party.repository.PartyRepository; import ita.tinybite.domain.user.entity.User; @@ -17,6 +22,7 @@ import ita.tinybite.global.location.LocationService; import ita.tinybite.global.util.DistanceCalculator; +import ita.tinybite.global.util.UrlParser; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,7 +36,7 @@ public class PartyService { private final UserRepository userRepository; private final LocationService locationService; private final PartyParticipantRepository partyParticipantRepository; - + private final ChatRoomRepository chatRoomRepository; /** * 파티 생성 */ @@ -45,6 +51,8 @@ public Long createParty(Long userId, PartyCreateRequest request) { // 첫 번째 이미지를 썸네일로 사용, 없으면 기본 이미지 String thumbnailImage = getDefaultImageIfEmpty(request.getImages(), request.getCategory()); + ProductInfo productInfo = urlParser.getProductInfo(request.getProductLink()); + Party party = Party.builder() .title(request.getTitle()) .category(request.getCategory()) @@ -61,6 +69,8 @@ public Long createParty(Long userId, PartyCreateRequest request) { .thumbnailImage(thumbnailImage) .link(request.getProductLink()) .description(request.getDescription()) + .currentParticipants(1) + .status(PartyStatus.RECRUITING) .isClosed(false) .host(user) .build(); @@ -90,7 +100,7 @@ public PartyListResponse getPartyList(Long userId, PartyCategory category, } } else { // 비회원이거나 동네 미설정 시 - String location = locationService.getLocation(userLat,userLon); + String location = locationService.getLocation(userLat, userLon); if (category == PartyCategory.ALL) { parties = partyRepository.findByPickupLocation_Place(location); } else { @@ -145,7 +155,7 @@ public PartyDetailResponse getPartyDetail(Long partyId, Long userId, Double user boolean isParticipating = false; if (user != null) { isParticipating = partyParticipantRepository - .existsByPartyAndUserAndIsApprovedTrue(party, user); + .existsByPartyAndUserAndStatus(party, user, ParticipantStatus.APPROVED); } // 거리 계산 (사용자 위치 필요) @@ -166,36 +176,30 @@ public PartyDetailResponse getPartyDetail(Long partyId, Long userId, Double user * 파티 참여 */ @Transactional - public void joinParty(Long partyId, Long userId) { + public Long joinParty(Long partyId, Long userId) { Party party = partyRepository.findById(partyId) .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다")); User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다")); - // 마감 체크 - if (party.getIsClosed()) { - throw new IllegalStateException("마감된 파티입니다"); - } + // 유효성 검증 + validateJoinRequest(party, user); - // 인원 체크 - if (party.getApprovedParticipantCount() >= party.getMaxParticipants()) { - throw new IllegalStateException("인원이 가득 찼습니다"); - } + // 1:1 채팅방 생성 (파티장 + 신청자) + ChatRoom oneToOneChatRoom = createOneToOneChatRoom(party, user); - // 중복 참여 체크 - if (partyParticipantRepository.existsByPartyAndUser(party, user)) { - throw new IllegalStateException("이미 참여 신청한 파티입니다"); - } - - // 참여 신청 (승인 대기) + // 참여 신청 생성 PartyParticipant participant = PartyParticipant.builder() .party(party) .user(user) - .isApproved(false) // 초기에는 승인 대기 + .status(ParticipantStatus.PENDING) + .oneToOneChatRoom(oneToOneChatRoom) .build(); - partyParticipantRepository.save(participant); + PartyParticipant saved = partyParticipantRepository.save(participant); + + return saved.getId(); } private void validateProductLink(PartyCategory category, String productLink) { @@ -222,7 +226,7 @@ private String getDefaultImageIfEmpty(List images, PartyCategory categor private PartyCardResponse convertToCardResponse(Party party, double distanceKm, Long userId, java.time.LocalDateTime createdAt) { int pricePerPerson = party.getPrice() / party.getMaxParticipants(); - String participantStatus = party.getApprovedParticipantCount() + "/" + String participantStatus = party.getCurrentParticipants() + "/" + party.getMaxParticipants() + "명"; return PartyCardResponse.builder() @@ -243,7 +247,7 @@ private PartyCardResponse convertToCardResponse(Party party, double distanceKm, private PartyDetailResponse convertToDetailResponse(Party party, double distance, boolean isParticipating) { - int currentCount = party.getApprovedParticipantCount(); + int currentCount = party.getCurrentParticipants(); int pricePerPerson = party.getPrice() / party.getMaxParticipants(); // 이미지 파싱 @@ -291,7 +295,7 @@ public void updateParty(Long partyId, Long userId, PartyUpdateRequest request) { } // 승인된 파티원 확인 - boolean hasApprovedParticipants = party.getApprovedParticipantCount() > 1; + boolean hasApprovedParticipants = party.getCurrentParticipants() > 1; if (hasApprovedParticipants) { // 승인된 파티원이 있는 경우: 설명과 이미지만 수정 가능 @@ -305,7 +309,7 @@ public void updateParty(Long partyId, Long userId, PartyUpdateRequest request) { request.getTitle(), request.getTotalPrice(), request.getMaxParticipants(), - new PickupLocation(request.getPickupLocation(),request.getLatitude(),request.getLongitude()), + new PickupLocation(request.getPickupLocation(), request.getLatitude(), request.getLongitude()), request.getLatitude(), request.getLongitude(), request.getProductLink(), @@ -326,7 +330,7 @@ public void deleteParty(Long partyId, Long userId) { } // 승인된 파티원 확인 - boolean hasApprovedParticipants = party.getApprovedParticipantCount() > 1; + boolean hasApprovedParticipants = party.getCurrentParticipants() > 1; if (hasApprovedParticipants) { throw new IllegalStateException("승인된 파티원이 있어 삭제할 수 없습니다"); @@ -336,5 +340,222 @@ public void deleteParty(Long partyId, Long userId) { partyRepository.delete(party); } + + /** + * 참여 승인 → 단체 채팅방 자동 입장 + */ + @Transactional + public void approveParticipant(Long partyId, Long participantId, Long hostId) { + Party party = partyRepository.findById(partyId) + .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다")); + + // 파티장 권한 확인 + if (!party.getHost().getUserId().equals(hostId)) { + throw new IllegalStateException("파티장만 승인할 수 있습니다"); + } + + PartyParticipant participant = partyParticipantRepository.findById(participantId) + .orElseThrow(() -> new IllegalArgumentException("참여 신청을 찾을 수 없습니다")); + + // 승인 처리 + participant.approve(); + + // 단체 채팅방 조회 또는 생성 + ChatRoom groupChatRoom = getOrCreateGroupChatRoom(party); + + // 단체 채팅방에 참여자 추가 + groupChatRoom.addMember(participant.getUser()); + + // 목표 인원 달성 확인 + checkAndCloseIfFull(party); + } + + /** + * 참여 거절 + */ + @Transactional + public void rejectParticipant(Long partyId, Long participantId, Long hostId) { + Party party = partyRepository.findById(partyId) + .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다")); + + if (!party.getHost().getUserId().equals(hostId)) { + throw new IllegalStateException("파티장만 거절할 수 있습니다"); + } + + PartyParticipant participant = partyParticipantRepository.findById(participantId) + .orElseThrow(() -> new IllegalArgumentException("참여 신청을 찾을 수 없습니다")); + + // 거절 처리 + participant.reject(); + + // 1:1 채팅방 비활성화 + if (participant.getOneToOneChatRoom() != null) { + participant.getOneToOneChatRoom().deactivate(); + } + + } + + /** + * 승인 대기 목록 조회 + */ + public List getPendingParticipants(Long partyId, Long hostId) { + Party party = partyRepository.findById(partyId) + .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다")); + + if (!party.getHost().getUserId().equals(hostId)) { + throw new IllegalStateException("파티장만 조회할 수 있습니다"); + } + + return partyParticipantRepository.findByPartyAndStatus(party, ParticipantStatus.PENDING); + } + + /** + * 단체 채팅방 조회 + */ + public ChatRoomResponse getGroupChatRoom(Long partyId, Long userId) { + Party party = partyRepository.findByIdWithHost(partyId) + .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다")); + + // 접근 권한 확인 + validateGroupChatRoomAccess(party, userId); + + ChatRoom chatRoom= chatRoomRepository.findByPartyAndType(party, ChatRoomType.GROUP) + .orElseThrow(() -> new IllegalStateException("단체 채팅방이 아직 생성되지 않았습니다")); + + return ChatRoomResponse.builder() + .id(chatRoom.getId()) + .type(chatRoom.getType()) + .party(PartyInfo.builder() + .id(partyId) + .title(party.getTitle()) + .host(HostInfo.builder() + .userId(party.getHost().getUserId()) + .nickname(party.getHost().getNickname()) + .profileImage(party.getHost().getProfileImage()) + .build() + ).build() + ).build(); + } + + /** + * 1:1 채팅방 조회 + */ + public ChatRoom getOneToOneChatRoom(Long participantId, Long userId) { + PartyParticipant participant = partyParticipantRepository.findById(participantId) + .orElseThrow(() -> new IllegalArgumentException("참여 신청을 찾을 수 없습니다")); + + // 파티장 또는 신청자만 접근 가능 + boolean isHost = participant.getParty().getHost().getUserId().equals(userId); + boolean isApplicant = participant.getUser().getUserId().equals(userId); + + if (!isHost && !isApplicant) { + throw new IllegalStateException("1:1 채팅방에 접근할 수 없습니다"); + } + + return participant.getOneToOneChatRoom(); + } + + /** + * 파티 결산 가능 여부 확인 + */ + public boolean canSettle(Long partyId) { + Party party = partyRepository.findById(partyId) + .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다")); + + // 목표 인원 달성 여부 + return party.getCurrentParticipants() >= party.getMaxParticipants(); + } + + /** + * 파티 결산 (마감) + */ + @Transactional + public void settleParty(Long partyId, Long hostId) { + Party party = partyRepository.findById(partyId) + .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다")); + + if (!party.getHost().getUserId().equals(hostId)) { + throw new IllegalStateException("파티장만 결산할 수 있습니다"); + } + + if (!canSettle(partyId)) { + throw new IllegalStateException("목표 인원이 달성되지 않았습니다"); + } + + // 파티 마감 + party.close(); + } + + // ========== Private Methods ========== + + private void validateJoinRequest(Party party, User user) { + if (party.getIsClosed()) { + throw new IllegalStateException("마감된 파티입니다"); + } + + if (party.getCurrentParticipants() >= party.getMaxParticipants()) { + throw new IllegalStateException("인원이 가득 찼습니다"); + } + + if (partyParticipantRepository.existsByPartyAndUser(party, user)) { + throw new IllegalStateException("이미 참여 신청한 파티입니다"); + } + + if (party.getHost().getUserId().equals(user.getUserId())) { + throw new IllegalStateException("파티장은 참여 신청할 수 없습니다"); + } + } + + private ChatRoom createOneToOneChatRoom(Party party, User applicant) { + ChatRoom chatRoom = ChatRoom.builder() + .party(party) + .type(ChatRoomType.ONE_TO_ONE) + .name(party.getTitle()) + .isActive(true) + .build(); + + ChatRoom saved = chatRoomRepository.save(chatRoom); + + // 파티장과 신청자 추가 + saved.addMember(party.getHost()); + saved.addMember(applicant); + + return saved; + } + + private ChatRoom getOrCreateGroupChatRoom(Party party) { + return chatRoomRepository.findByPartyAndType(party, ChatRoomType.GROUP) + .orElseGet(() -> { + ChatRoom chatRoom = ChatRoom.builder() + .party(party) + .type(ChatRoomType.GROUP) + .name(party.getTitle()) + .isActive(true) + .build(); + + ChatRoom saved = chatRoomRepository.save(chatRoom); + + // 파티장 자동 추가 + saved.addMember(party.getHost()); + + return saved; + }); + } + + private void validateGroupChatRoomAccess(Party party, Long userId) { + boolean isHost = party.getHost().getUserId().equals(userId); + boolean isApproved = partyParticipantRepository + .existsByPartyAndUserUserIdAndStatus(party, userId, ParticipantStatus.APPROVED); + + if (!isHost && !isApproved) { + throw new IllegalStateException("단체 채팅방에 접근할 수 없습니다"); + } + } + + private void checkAndCloseIfFull(Party party) { + if (party.getCurrentParticipants() >= party.getMaxParticipants()) { + party.close(); + } + } } diff --git a/src/main/java/ita/tinybite/domain/user/controller/UserController.java b/src/main/java/ita/tinybite/domain/user/controller/UserController.java index 8ff3c76..d1d72a6 100644 --- a/src/main/java/ita/tinybite/domain/user/controller/UserController.java +++ b/src/main/java/ita/tinybite/domain/user/controller/UserController.java @@ -41,4 +41,10 @@ public APIResponse deleteUser() { userService.deleteUser(); return success(); } + + @GetMapping("/nickname/check") + public APIResponse validateNickname(@RequestParam String nickname) { + userService.validateNickname(nickname); + return success(); + } } diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 24b5e44..56f9e02 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -1,7 +1,7 @@ spring: config: activate: - on-profile: "dev" + on-profile: 'dev' datasource: driver-class-name: com.mysql.cj.jdbc.Driver @@ -25,4 +25,4 @@ kakao: redirect-uri: ${KAKAO_REDIRECT_URI} fcm: - file_path: ${DEV_FCM_PATH} \ No newline at end of file + file_path: ${DEV_FCM_PATH} diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 187064c..b6cb9fc 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -2,7 +2,7 @@ spring: config: import: optional:file:.env[.properties] activate: - on-profile: "local" + on-profile: 'local' datasource: driver-class-name: com.mysql.cj.jdbc.Driver @@ -32,4 +32,4 @@ fcm: file_path: firebase/tinybite_fcm.json logging: level: - org.hibernate.SQL: debug \ No newline at end of file + org.hibernate.SQL: debug diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index bce0348..9ba462d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -6,11 +6,9 @@ spring: profiles: group: - local: "local" - dev: "dev" - test: "test" - - + local: 'local' + dev: 'dev' + test: 'test' jwt: secret: ${JWT_SECRET}