From a849438677446187791ba20d20180bc3b25323bb Mon Sep 17 00:00:00 2001 From: kms152000 Date: Mon, 22 Dec 2025 16:24:32 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20UUID=20=EC=82=AC=EC=9A=A9=EC=9D=84?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/controller/PlanController.java | 69 ++++++++++--------- .../dto/response/PlanShareLinkResponse.java | 21 ++++++ .../domain/plans/entity/Plan.java | 13 ++++ .../plans/repository/PlanRepository.java | 13 +++- 4 files changed, 80 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanShareLinkResponse.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java index 9e1b74ba..ae3356c4 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java @@ -9,6 +9,7 @@ import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanDetailResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanListResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanResponse; +import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanShareLinkResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleListResponse; import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleDeleteResponse; @@ -212,75 +213,75 @@ public RsData updateParticipantRole( } /** - * 계획 공유 초대 + * 계획 공유 인원 추방 * * @param planId 계획 ID + * @param participantId 참가자 ID * @return 성공 메시지 (200 OK) */ - @PostMapping("/invite/{planId}") - @Operation(summary = "계획 공유 초대", description = "다른 사용자에게 계획 공유를 초대합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 초대할 수 있습니다. 초대된 사용자는 수락 또는 거절할 수 있습니다.") - public RsData invitePlan(@PathVariable Long planId) { + @DeleteMapping("/kick/{planId}/{participantId}") + @Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자만 다른 참가자를 추방할 수 있으며, 소유자 자신은 추방할 수 없습니다.") + public RsData kickParticipant( + @PathVariable Long planId, + @PathVariable Long participantId) { User user = rq.getUser(); // TODO: 구현 필요 - return RsData.success("계획 공유 초대 성공", null); + return RsData.success("참가자 추방 성공", null); } /** - * 계획 공유 수락 + * 계획 공유 나가기 * * @param planId 계획 ID * @return 성공 메시지 (200 OK) */ - @PostMapping("/accept/{planId}") - @Operation(summary = "계획 공유 수락", description = "받은 계획 공유 초대를 수락합니다. 수락 시 해당 계획에 참가자로 추가되며, 초대 시 설정된 역할(Editor 또는 Viewer)로 참여하게 됩니다.") - public RsData acceptPlanInvite(@PathVariable Long planId) { + @DeleteMapping("/quit/{planId}") + @Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기 시 해당 계획의 참가자 목록에서 제거됩니다.") + public RsData quitPlan(@PathVariable Long planId) { User user = rq.getUser(); // TODO: 구현 필요 - return RsData.success("계획 공유 수락 성공", null); + return RsData.success("계획 나가기 성공", null); } /** - * 계획 공유 거절 + * 공유 링크 생성 (UUID) * * @param planId 계획 ID - * @return 성공 메시지 (200 OK) + * @return 공유 링크 정보 (200 OK) */ - @PostMapping("/deny/{planId}") - @Operation(summary = "계획 공유 거절", description = "받은 계획 공유 초대를 거절합니다. 거절 시 해당 계획에 참가자로 추가되지 않으며, 초대 상태가 거절로 변경됩니다.") - public RsData denyPlanInvite(@PathVariable Long planId) { + @PostMapping("/{planId}/share/link") + @Operation(summary = "공유 링크 생성", description = "플랜 공유를 위한 UUID 링크를 생성합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 링크를 생성할 수 있습니다.") + public RsData generateShareLink(@PathVariable Long planId) { User user = rq.getUser(); - // TODO: 구현 필요 - return RsData.success("계획 공유 거절 성공", null); + PlanShareLinkResponse response = planService.generateShareLink(planId, user); + return RsData.success("공유 링크 생성 성공", response); } /** - * 계획 공유 인원 추방 + * 공유 링크로 플랜 참가 * - * @param planId 계획 ID - * @param participantId 참가자 ID - * @return 성공 메시지 (200 OK) + * @param shareToken 공유 토큰 (UUID) + * @return 플랜 상세 정보 (200 OK) */ - @DeleteMapping("/kick/{planId}/{participantId}") - @Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자만 다른 참가자를 추방할 수 있으며, 소유자 자신은 추방할 수 없습니다.") - public RsData kickParticipant( - @PathVariable Long planId, - @PathVariable Long participantId) { + @PostMapping("/share/{shareToken}") + @Operation(summary = "공유 링크로 플랜 참가", description = "공유 링크 토큰을 통해 플랜에 참가합니다. 유효한 공유 토큰이 필요하며, 이미 참가자인 경우 상태만 업데이트됩니다.") + public RsData joinPlanByShareToken(@PathVariable String shareToken) { User user = rq.getUser(); - // TODO: 구현 필요 - return RsData.success("참가자 추방 성공", null); + PlanDetailResponse response = planService.joinPlanByShareToken(shareToken, user); + return RsData.success("플랜 참가 성공", response); } /** - * 계획 공유 나가기 + * 공유 링크 삭제 * * @param planId 계획 ID * @return 성공 메시지 (200 OK) */ - @DeleteMapping("/quit/{planId}") - @Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기 시 해당 계획의 참가자 목록에서 제거됩니다.") - public RsData quitPlan(@PathVariable Long planId) { + @DeleteMapping("/{planId}/share/link") + @Operation(summary = "공유 링크 삭제", description = "생성된 공유 링크를 삭제합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 링크를 삭제할 수 있습니다.") + public RsData deleteShareLink(@PathVariable Long planId) { User user = rq.getUser(); - // TODO: 구현 필요 - return RsData.success("계획 나가기 성공", null); + planService.deleteShareLink(planId, user); + return RsData.success("공유 링크 삭제 성공", null); } } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanShareLinkResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanShareLinkResponse.java new file mode 100644 index 00000000..afac0d87 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanShareLinkResponse.java @@ -0,0 +1,21 @@ +package com.back.web7_9_codecrete_be.domain.plans.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "플랜 공유 링크 응답 DTO") +public class PlanShareLinkResponse { + + @Schema(description = "플랜 ID", example = "1") + private Long planId; + + @Schema(description = "공유 토큰 (UUID)", example = "550e8400-e29b-41d4-a716-446655440000") + private String shareToken; + + @Schema(description = "공유 링크", example = "/plans/share/550e8400-e29b-41d4-a716-446655440000") + private String shareLink; +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java index 38b8995c..d83f63fd 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java @@ -7,6 +7,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; @@ -14,6 +15,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.UUID; @Entity @@ -49,11 +51,14 @@ public class Plan { @Column(name = "modified_date", nullable = false) private LocalDateTime modifiedDate; + @Column(name = "share_token", unique = true, length = 36) + private String shareToken; @OneToMany(mappedBy = "plan", cascade = CascadeType.ALL, orphanRemoval = true) private List participants = new ArrayList<>(); @OneToMany(mappedBy = "plan", cascade = CascadeType.ALL, orphanRemoval = true) + @BatchSize(size = 20) private List schedules = new ArrayList<>(); @Builder @@ -83,4 +88,12 @@ public void addSchedule(Schedule schedule) { this.schedules.add(schedule); schedule.setPlan(this); } + + public void generateShareToken() { + this.shareToken = UUID.randomUUID().toString(); + } + + public void clearShareToken() { + this.shareToken = null; + } } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java index 2f95d728..ad7443e3 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java @@ -26,9 +26,10 @@ public interface PlanRepository extends JpaRepository { /** * - 특정 사용자가 참가자로 포함된 모든 Plan 조회 * @param userId 참가자로 포함된 사용자 ID - * @return 해당 사용자가 참가자인 모든 Plan 목록 (concert, participants, schedules 포함) + * @return 해당 사용자가 참가자인 모든 Plan 목록 (concert, participants 포함) + * schedules는 @BatchSize로 배치 로드됨 (MultipleBagFetchException 방지) */ - @EntityGraph(attributePaths = {"concert", "participants", "schedules"}) + @EntityGraph(attributePaths = {"concert", "participants"}) List findDistinctByParticipants_User_Id(Long userId); /** @@ -38,4 +39,12 @@ public interface PlanRepository extends JpaRepository { */ @EntityGraph(attributePaths = {"schedules"}) List findByUser_Id(Long userId); + + /** + * - shareToken으로 Plan 조회 + * @param shareToken 공유 토큰 + * @return 해당 토큰을 가진 Plan 엔티티 + */ + @EntityGraph(attributePaths = {"concert", "participants", "schedules"}) + Optional findByShareToken(String shareToken); } \ No newline at end of file From 9f5e6349cc87a8b1cd46c6d43c0c83a6d9f5f9a9 Mon Sep 17 00:00:00 2001 From: kms152000 Date: Mon, 22 Dec 2025 16:25:07 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20PlanErrorCode=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/error/code/PlanErrorCode.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java index 17dc5427..c3464399 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java @@ -15,9 +15,13 @@ public enum PlanErrorCode implements ErrorCode { PLAN_UNAUTHORIZED(HttpStatus.FORBIDDEN, "P-103", "해당 작업을 수행할 권한이 없습니다."), SCHEDULE_INVALID_TRANSPORT_FIELDS(HttpStatus.BAD_REQUEST, "P-104", "교통 수단 타입일 경우 출발지/도착지 좌표, 거리, 교통 수단 종류는 필수입니다."), SCHEDULE_INVALID_LOCATION_FOR_TRANSPORT(HttpStatus.BAD_REQUEST, "P-105", "교통 수단 타입일 경우 locationLat/Lon은 사용할 수 없습니다. endPlaceLat/Lon을 사용해주세요."), - SCHEDULE_INVALID_LOCATION_COORDINATES(HttpStatus.BAD_REQUEST, "P-107", "위도와 경도는 함께 제공되어야 합니다."), CONCERT_NOT_FOUND(HttpStatus.NOT_FOUND, "P-106", "공연을 찾을 수 없습니다."), - SCHEDULE_MAIN_EVENT_NOT_DELETABLE(HttpStatus.FORBIDDEN, "P-109", "메인 이벤트(콘서트) 일정은 삭제할 수 없습니다."); + SCHEDULE_INVALID_LOCATION_COORDINATES(HttpStatus.BAD_REQUEST, "P-107", "위도와 경도는 함께 제공되어야 합니다."), + SCHEDULE_MAIN_EVENT_NOT_DELETABLE(HttpStatus.FORBIDDEN, "P-108", "메인 이벤트(콘서트) 일정은 삭제할 수 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "P-109", "사용자를 찾을 수 없습니다."), + USER_ALREADY_PARTICIPANT(HttpStatus.BAD_REQUEST, "P-110", "이미 참가자로 등록된 사용자입니다."), + INVALID_SHARE_TOKEN(HttpStatus.NOT_FOUND, "P-111", "유효하지 않은 공유 링크입니다."), + SHARE_TOKEN_NOT_GENERATED(HttpStatus.BAD_REQUEST, "P-112", "공유 링크가 생성되지 않았습니다."); private final HttpStatus status; private final String code; From 2662ee964b39ada2619400ffb57d5f045343ee95 Mon Sep 17 00:00:00 2001 From: kms152000 Date: Mon, 22 Dec 2025 16:28:01 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20UUID=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/PlanParticipantRepository.java | 17 +++ .../domain/plans/service/PlanService.java | 115 +++++++++++++++++- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanParticipantRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanParticipantRepository.java index ef640b94..a7d09edb 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanParticipantRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanParticipantRepository.java @@ -4,7 +4,24 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface PlanParticipantRepository extends JpaRepository { + /** + * 특정 사용자와 플랜의 조합으로 참가자 존재 여부 확인 + * @param userId 사용자 ID + * @param planId 플랜 ID + * @return 존재 여부 + */ + boolean existsByUser_IdAndPlan_PlanId(Long userId, Long planId); + + /** + * 특정 사용자와 플랜의 조합으로 참가자 조회 + * @param userId 사용자 ID + * @param planId 플랜 ID + * @return PlanParticipant + */ + Optional findByUser_IdAndPlan_PlanId(Long userId, Long planId); } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java index c12c60c0..bacd58f1 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java @@ -224,7 +224,7 @@ public PlanResponse updatePlan(Long planId, User user, PlanUpdateRequest request */ @Transactional public PlanDeleteResponse deletePlan(Long planId, User user) { - Plan plan = findPlanWithEditPermissionCheck(planId, user); + Plan plan = findPlanWithOwnerCheck(planId, user); Long deletedPlanId = plan.getPlanId(); // Plan 삭제 시 cascade 설정으로 인해 participants와 schedules도 함께 삭제. @@ -460,6 +460,7 @@ public ScheduleDeleteResponse deleteSchedule(Long planId, Long scheduleId, User .build(); } + /** * Plan을 조회하고 참가자 권한을 체크하는 메서드 * @@ -491,6 +492,28 @@ private Plan findPlanWithParticipantCheck(Long planId, User user) { return plan; } + /** + * Plan을 조회하고 소유자 권한을 체크 + * 소유자만 삭제 가능 + * + * @param planId 계획 ID + * @param user 현재 로그인한 사용자 + * @return Plan 엔티티 + * @throws BusinessException 계획을 찾을 수 없거나 소유자가 아닌 경우 + */ + private Plan findPlanWithOwnerCheck(Long planId, User user) { + Long userId = user.getId(); + Plan plan = planRepository.findById(planId) + .orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND)); + + // 소유자만 삭제 가능 + if (!plan.getUserId().equals(userId)) { + throw new BusinessException(PlanErrorCode.PLAN_UNAUTHORIZED); + } + + return plan; + } + /** * Plan을 조회하고 수정/삭제 권한을 체크 * OWNER 또는 EDITOR만 수정/삭제 가능 @@ -667,7 +690,95 @@ public void updateParticipantRole(Long planId, Long participantId, User user, participant.updateRole(request.getRole()); } - // 계획 공유 초대 + /** + * 공유 링크 생성 (UUID) + * + * @param planId 계획 ID + * @param user 현재 로그인한 사용자 (권한 체크용) + * @return 공유 링크 응답 DTO + * @throws BusinessException 계획을 찾을 수 없거나 권한이 없는 경우 + */ + @Transactional + public PlanShareLinkResponse generateShareLink(Long planId, User user) { + // 권한 체크 (수정 권한 확인: OWNER 또는 EDITOR) + Plan plan = findPlanWithEditPermissionCheck(planId, user); + + // shareToken이 이미 있으면 재사용, 없으면 생성 + if (plan.getShareToken() == null) { + plan.generateShareToken(); + planRepository.save(plan); + } + + return PlanShareLinkResponse.builder() + .planId(plan.getPlanId()) + .shareToken(plan.getShareToken()) + .shareLink("/plans/share/" + plan.getShareToken()) + .build(); + } + + /** + * 공유 링크로 플랜 참가 + * + * @param shareToken 공유 토큰 (UUID) + * @param user 현재 로그인한 사용자 + * @return 플랜 상세 정보 + * @throws BusinessException 공유 링크가 유효하지 않은 경우, 이미 참가자인 경우 + */ + @Transactional + public PlanDetailResponse joinPlanByShareToken(String shareToken, User user) { + // shareToken으로 Plan 찾기 + Plan plan = planRepository.findByShareToken(shareToken) + .orElseThrow(() -> new BusinessException(PlanErrorCode.INVALID_SHARE_TOKEN)); + + // 자기 자신의 플랜은 참가할 수 없음 + if (plan.getUserId().equals(user.getId())) { + throw new BusinessException(PlanErrorCode.USER_ALREADY_PARTICIPANT); + } + + // DB 레벨에서 이미 참가자인지 확인 (유니크 제약조건 검증) + boolean isAlreadyParticipant = planParticipantRepository.existsByUser_IdAndPlan_PlanId( + user.getId(), plan.getPlanId()); + + if (isAlreadyParticipant) { + // 이미 참가자인 경우 상태를 ACCEPTED로 변경 + PlanParticipant participant = planParticipantRepository + .findByUser_IdAndPlan_PlanId(user.getId(), plan.getPlanId()) + .orElseThrow(() -> new BusinessException(PlanErrorCode.PLAN_NOT_FOUND)); + + participant.updateInviteStatus(PlanParticipant.InviteStatus.ACCEPTED); + planParticipantRepository.save(participant); + } else { + // 새로운 참가자 추가 (기본 역할은 VIEWER, 상태는 ACCEPTED) + PlanParticipant participant = PlanParticipant.builder() + .user(user) + .plan(plan) + .inviteStatus(PlanParticipant.InviteStatus.ACCEPTED) + .role(PlanParticipant.ParticipantRole.VIEWER) + .build(); + + plan.addParticipant(participant); + planRepository.save(plan); + } + + return getPlanDetail(plan.getPlanId(), user); + } + + /** + * 공유 링크 삭제 (shareToken 제거) + * + * @param planId 계획 ID + * @param user 현재 로그인한 사용자 (권한 체크용) + * @throws BusinessException 계획을 찾을 수 없거나 권한이 없는 경우 + */ + @Transactional + public void deleteShareLink(Long planId, User user) { + // 권한 체크 (수정 권한 확인: OWNER 또는 EDITOR) + Plan plan = findPlanWithEditPermissionCheck(planId, user); + + plan.clearShareToken(); + planRepository.save(plan); + } + // 계획 공유 수락 // 계획 공유 거절 // 계획 공유 인원 추방 From 2ad3bc361e11e5e9d536989f629d9de2ea3fc89a Mon Sep 17 00:00:00 2001 From: kms152000 Date: Mon, 22 Dec 2025 16:45:41 +0900 Subject: [PATCH 4/7] =?UTF-8?q?bug:=20MultipleBagFetchException=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/plans/repository/PlanRepository.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java index ad7443e3..5ee3e831 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java @@ -15,12 +15,10 @@ public interface PlanRepository extends JpaRepository { /** * - Plan 상세 조회 및 권한 체크 시 * @param id Plan ID - * @return Plan 엔티티 (concert, participants, schedules 포함) - * @EntityGraph: - * - Plan + concert + participants + schedules를 LEFT OUTER JOIN으로 한 번에 조회 - * - 총 1번의 쿼리만 실행되어 N + 1 문제 방지 & 성능 향상 + * @return Plan 엔티티 (concert, participants 포함) + * schedules는 @BatchSize로 배치 로드됨 (MultipleBagFetchException 방지) */ - @EntityGraph(attributePaths = {"concert", "participants", "schedules"}) + @EntityGraph(attributePaths = {"concert", "participants"}) Optional findById(Long id); /** @@ -43,8 +41,9 @@ public interface PlanRepository extends JpaRepository { /** * - shareToken으로 Plan 조회 * @param shareToken 공유 토큰 - * @return 해당 토큰을 가진 Plan 엔티티 + * @return 해당 토큰을 가진 Plan 엔티티 (concert, participants 포함) + * schedules는 @BatchSize로 배치 로드됨 (MultipleBagFetchException 방지) */ - @EntityGraph(attributePaths = {"concert", "participants", "schedules"}) + @EntityGraph(attributePaths = {"concert", "participants"}) Optional findByShareToken(String shareToken); } \ No newline at end of file From 00e965be69c1435cbe607d0f45d905fa543a8903 Mon Sep 17 00:00:00 2001 From: kms152000 Date: Mon, 22 Dec 2025 17:25:20 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20UUID=2036=20->=2013=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=ED=86=A0=ED=81=B0=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/plans/controller/PlanController.java | 6 +++--- .../domain/plans/dto/response/PlanShareLinkResponse.java | 4 ++-- .../back/web7_9_codecrete_be/domain/plans/entity/Plan.java | 4 ++-- .../domain/plans/service/PlanService.java | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java index ae3356c4..f9e363c6 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java @@ -244,13 +244,13 @@ public RsData quitPlan(@PathVariable Long planId) { } /** - * 공유 링크 생성 (UUID) + * 공유 링크 생성 (UUID 기반 13자) * * @param planId 계획 ID * @return 공유 링크 정보 (200 OK) */ @PostMapping("/{planId}/share/link") - @Operation(summary = "공유 링크 생성", description = "플랜 공유를 위한 UUID 링크를 생성합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 링크를 생성할 수 있습니다.") + @Operation(summary = "공유 링크 생성", description = "플랜 공유를 위한 UUID 기반 13자 토큰 링크를 생성합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 링크를 생성할 수 있습니다.") public RsData generateShareLink(@PathVariable Long planId) { User user = rq.getUser(); PlanShareLinkResponse response = planService.generateShareLink(planId, user); @@ -260,7 +260,7 @@ public RsData generateShareLink(@PathVariable Long planId /** * 공유 링크로 플랜 참가 * - * @param shareToken 공유 토큰 (UUID) + * @param shareToken 공유 토큰 (UUID 기반 13자) * @return 플랜 상세 정보 (200 OK) */ @PostMapping("/share/{shareToken}") diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanShareLinkResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanShareLinkResponse.java index afac0d87..f01803b4 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanShareLinkResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/dto/response/PlanShareLinkResponse.java @@ -12,10 +12,10 @@ public class PlanShareLinkResponse { @Schema(description = "플랜 ID", example = "1") private Long planId; - @Schema(description = "공유 토큰 (UUID)", example = "550e8400-e29b-41d4-a716-446655440000") + @Schema(description = "공유 토큰 (UUID 기반 13자)", example = "550e8400-e29b") private String shareToken; - @Schema(description = "공유 링크", example = "/plans/share/550e8400-e29b-41d4-a716-446655440000") + @Schema(description = "공유 링크", example = "/plans/share/550e8400-e29b") private String shareLink; } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java index d83f63fd..a081003e 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java @@ -51,7 +51,7 @@ public class Plan { @Column(name = "modified_date", nullable = false) private LocalDateTime modifiedDate; - @Column(name = "share_token", unique = true, length = 36) + @Column(name = "share_token", unique = true, length = 13) private String shareToken; @OneToMany(mappedBy = "plan", cascade = CascadeType.ALL, orphanRemoval = true) @@ -90,7 +90,7 @@ public void addSchedule(Schedule schedule) { } public void generateShareToken() { - this.shareToken = UUID.randomUUID().toString(); + this.shareToken = UUID.randomUUID().toString().substring(0, 13); } public void clearShareToken() { diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java index bacd58f1..a7b1494f 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java @@ -691,7 +691,7 @@ public void updateParticipantRole(Long planId, Long participantId, User user, } /** - * 공유 링크 생성 (UUID) + * 공유 링크 생성 (UUID 기반 13자) * * @param planId 계획 ID * @param user 현재 로그인한 사용자 (권한 체크용) @@ -719,7 +719,7 @@ public PlanShareLinkResponse generateShareLink(Long planId, User user) { /** * 공유 링크로 플랜 참가 * - * @param shareToken 공유 토큰 (UUID) + * @param shareToken 공유 토큰 (UUID 기반 13자) * @param user 현재 로그인한 사용자 * @return 플랜 상세 정보 * @throws BusinessException 공유 링크가 유효하지 않은 경우, 이미 참가자인 경우 From a32e42bd40ad022284a6bf2ead173e1129e08ad0 Mon Sep 17 00:00:00 2001 From: kms152000 Date: Tue, 23 Dec 2025 00:11:51 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EB=A7=81=ED=81=AC=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=8B=9C=20=ED=95=B4=EB=8B=B9=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=EB=A1=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/controller/PlanController.java | 26 ++++++++++--- .../domain/plans/service/PlanService.java | 38 +++++++++++++++++-- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java index f9e363c6..cc81d0e1 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java @@ -258,17 +258,31 @@ public RsData generateShareLink(@PathVariable Long planId } /** - * 공유 링크로 플랜 참가 + * 공유 링크로 플랜 조회 (참가자 생성 없이 조회만) * * @param shareToken 공유 토큰 (UUID 기반 13자) * @return 플랜 상세 정보 (200 OK) */ - @PostMapping("/share/{shareToken}") - @Operation(summary = "공유 링크로 플랜 참가", description = "공유 링크 토큰을 통해 플랜에 참가합니다. 유효한 공유 토큰이 필요하며, 이미 참가자인 경우 상태만 업데이트됩니다.") - public RsData joinPlanByShareToken(@PathVariable String shareToken) { + @GetMapping("/share/{shareToken}") + @Operation(summary = "공유 링크로 플랜 조회", description = "공유 링크 토큰을 통해 플랜을 조회합니다. 참가자 생성 없이 플랜 정보만 조회합니다.") + public RsData getPlanByShareToken(@PathVariable String shareToken) { User user = rq.getUser(); - PlanDetailResponse response = planService.joinPlanByShareToken(shareToken, user); - return RsData.success("플랜 참가 성공", response); + PlanDetailResponse response = planService.getPlanByShareToken(shareToken, user); + return RsData.success("플랜 조회 성공", response); + } + + /** + * 공유 링크로 플랜 참가 수락 + * + * @param shareToken 공유 토큰 (UUID 기반 13자) + * @return 플랜 상세 정보 (200 OK) + */ + @PostMapping("/share/{shareToken}/accept") + @Operation(summary = "공유 링크로 플랜 참가 수락", description = "공유 링크 토큰을 통해 플랜 참가를 수락합니다. 참가자가 생성되며 상태가 ACCEPTED로 설정됩니다.") + public RsData acceptPlanInvitation(@PathVariable String shareToken) { + User user = rq.getUser(); + PlanDetailResponse response = planService.acceptPlanInvitation(shareToken, user); + return RsData.success("플랜 참가 수락 성공", response); } /** diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java index a7b1494f..7a30b14d 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java @@ -125,7 +125,16 @@ public List getPlanList(User user) { */ public PlanDetailResponse getPlanDetail(Long planId, User user) { Plan plan = findPlanWithParticipantCheck(planId, user); + return buildPlanDetailResponse(plan); + } + /** + * Plan 엔티티를 PlanDetailResponse로 변환 (공통 메서드) + * + * @param plan Plan 엔티티 + * @return 계획 상세 정보 + */ + private PlanDetailResponse buildPlanDetailResponse(Plan plan) { List participants = plan.getParticipants().stream() .map(participant -> PlanDetailResponse.ParticipantInfo.builder() .id(participant.getParticipantId()) @@ -138,7 +147,7 @@ public PlanDetailResponse getPlanDetail(Long planId, User user) { // 타임라인 형태로 일정 정렬 (startAt 기준) - 메인 이벤트와 일반 일정 모두 포함 // Concert 정보까지 포함하여 조회 (메인 이벤트의 Concert 정보 포함) List sortedSchedules = scheduleRepository - .findByPlan_PlanIdOrderByStartAtAsc(planId); + .findByPlan_PlanIdOrderByStartAtAsc(plan.getPlanId()); List schedules = sortedSchedules.stream() .map(item -> { @@ -717,7 +726,29 @@ public PlanShareLinkResponse generateShareLink(Long planId, User user) { } /** - * 공유 링크로 플랜 참가 + * 공유 링크로 플랜 조회 (참가자 생성 없이 조회만) + * + * @param shareToken 공유 토큰 (UUID 기반 13자) + * @param user 현재 로그인한 사용자 + * @return 플랜 상세 정보 + * @throws BusinessException 공유 링크가 유효하지 않은 경우 + */ + public PlanDetailResponse getPlanByShareToken(String shareToken, User user) { + // shareToken으로 Plan 찾기 + Plan plan = planRepository.findByShareToken(shareToken) + .orElseThrow(() -> new BusinessException(PlanErrorCode.INVALID_SHARE_TOKEN)); + + // 자기 자신의 플랜은 조회 불가 + if (plan.getUserId().equals(user.getId())) { + throw new BusinessException(PlanErrorCode.USER_ALREADY_PARTICIPANT); + } + + // 참가자 체크 없이 조회 가능 (공유 링크이므로) + return buildPlanDetailResponse(plan); + } + + /** + * 공유 링크로 플랜 참가 수락 (참가자 생성) * * @param shareToken 공유 토큰 (UUID 기반 13자) * @param user 현재 로그인한 사용자 @@ -725,7 +756,7 @@ public PlanShareLinkResponse generateShareLink(Long planId, User user) { * @throws BusinessException 공유 링크가 유효하지 않은 경우, 이미 참가자인 경우 */ @Transactional - public PlanDetailResponse joinPlanByShareToken(String shareToken, User user) { + public PlanDetailResponse acceptPlanInvitation(String shareToken, User user) { // shareToken으로 Plan 찾기 Plan plan = planRepository.findByShareToken(shareToken) .orElseThrow(() -> new BusinessException(PlanErrorCode.INVALID_SHARE_TOKEN)); @@ -779,7 +810,6 @@ public void deleteShareLink(Long planId, User user) { planRepository.save(plan); } - // 계획 공유 수락 // 계획 공유 거절 // 계획 공유 인원 추방 // 계획 공유 나가기 From 9ca3f6dfca5aa709c242324edfd891632d55ab20 Mon Sep 17 00:00:00 2001 From: kms152000 Date: Tue, 23 Dec 2025 02:37:59 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EB=A7=81=ED=81=AC=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EC=8B=9C=EA=B0=84=20=ED=95=98=EB=A3=A8=EB=A1=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/plans/entity/Plan.java | 17 +++++++++++++++++ .../domain/plans/service/PlanService.java | 14 ++++++++++++-- .../global/error/code/PlanErrorCode.java | 3 ++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java index a081003e..f4c58d28 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java @@ -54,6 +54,9 @@ public class Plan { @Column(name = "share_token", unique = true, length = 13) private String shareToken; + @Column(name = "share_token_expires_at") + private LocalDateTime shareTokenExpiresAt; + @OneToMany(mappedBy = "plan", cascade = CascadeType.ALL, orphanRemoval = true) private List participants = new ArrayList<>(); @@ -91,9 +94,23 @@ public void addSchedule(Schedule schedule) { public void generateShareToken() { this.shareToken = UUID.randomUUID().toString().substring(0, 13); + // 만료 시간: 현재 시간으로부터 1일 후 + this.shareTokenExpiresAt = LocalDateTime.now().plusDays(1); } public void clearShareToken() { this.shareToken = null; + this.shareTokenExpiresAt = null; + } + + /** + * 공유 토큰이 만료되었는지 확인 + * @return 만료되었으면 true, 아니면 false + */ + public boolean isShareTokenExpired() { + if (shareTokenExpiresAt == null) { + return true; // 만료 시간이 없으면 만료된 것으로 간주 + } + return LocalDateTime.now().isAfter(shareTokenExpiresAt); } } \ No newline at end of file diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java index 7a30b14d..63654f92 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/plans/service/PlanService.java @@ -712,8 +712,8 @@ public PlanShareLinkResponse generateShareLink(Long planId, User user) { // 권한 체크 (수정 권한 확인: OWNER 또는 EDITOR) Plan plan = findPlanWithEditPermissionCheck(planId, user); - // shareToken이 이미 있으면 재사용, 없으면 생성 - if (plan.getShareToken() == null) { + // shareToken이 없거나 만료되었으면 새로 생성, 유효하면 재사용 + if (plan.getShareToken() == null || plan.isShareTokenExpired()) { plan.generateShareToken(); planRepository.save(plan); } @@ -738,6 +738,11 @@ public PlanDetailResponse getPlanByShareToken(String shareToken, User user) { Plan plan = planRepository.findByShareToken(shareToken) .orElseThrow(() -> new BusinessException(PlanErrorCode.INVALID_SHARE_TOKEN)); + // 만료 시간 검증 + if (plan.isShareTokenExpired()) { + throw new BusinessException(PlanErrorCode.SHARE_TOKEN_EXPIRED); + } + // 자기 자신의 플랜은 조회 불가 if (plan.getUserId().equals(user.getId())) { throw new BusinessException(PlanErrorCode.USER_ALREADY_PARTICIPANT); @@ -761,6 +766,11 @@ public PlanDetailResponse acceptPlanInvitation(String shareToken, User user) { Plan plan = planRepository.findByShareToken(shareToken) .orElseThrow(() -> new BusinessException(PlanErrorCode.INVALID_SHARE_TOKEN)); + // 만료 시간 검증 + if (plan.isShareTokenExpired()) { + throw new BusinessException(PlanErrorCode.SHARE_TOKEN_EXPIRED); + } + // 자기 자신의 플랜은 참가할 수 없음 if (plan.getUserId().equals(user.getId())) { throw new BusinessException(PlanErrorCode.USER_ALREADY_PARTICIPANT); diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java index c3464399..70515c4b 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/PlanErrorCode.java @@ -21,7 +21,8 @@ public enum PlanErrorCode implements ErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "P-109", "사용자를 찾을 수 없습니다."), USER_ALREADY_PARTICIPANT(HttpStatus.BAD_REQUEST, "P-110", "이미 참가자로 등록된 사용자입니다."), INVALID_SHARE_TOKEN(HttpStatus.NOT_FOUND, "P-111", "유효하지 않은 공유 링크입니다."), - SHARE_TOKEN_NOT_GENERATED(HttpStatus.BAD_REQUEST, "P-112", "공유 링크가 생성되지 않았습니다."); + SHARE_TOKEN_NOT_GENERATED(HttpStatus.BAD_REQUEST, "P-112", "공유 링크가 생성되지 않았습니다."), + SHARE_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, "P-113", "공유 링크가 만료되었습니다."); private final HttpStatus status; private final String code;