Skip to content

Commit 5596fb6

Browse files
Merge pull request #171 from prgrms-web-devcourse-final-project/feat/#106
[Plan] 링크 기반 계획 공유 & UK 검증
2 parents 384d670 + 9ca3f6d commit 5596fb6

7 files changed

Lines changed: 294 additions & 47 deletions

File tree

src/main/java/com/back/web7_9_codecrete_be/domain/plans/controller/PlanController.java

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanDetailResponse;
1010
import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanListResponse;
1111
import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanResponse;
12+
import com.back.web7_9_codecrete_be.domain.plans.dto.response.PlanShareLinkResponse;
1213
import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleResponse;
1314
import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleListResponse;
1415
import com.back.web7_9_codecrete_be.domain.plans.dto.response.ScheduleDeleteResponse;
@@ -212,75 +213,89 @@ public RsData<Void> updateParticipantRole(
212213
}
213214

214215
/**
215-
* 계획 공유 초대
216+
* 계획 공유 인원 추방
216217
*
217218
* @param planId 계획 ID
219+
* @param participantId 참가자 ID
218220
* @return 성공 메시지 (200 OK)
219221
*/
220-
@PostMapping("/invite/{planId}")
221-
@Operation(summary = "계획 공유 초대", description = "다른 사용자에게 계획 공유를 초대합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 초대할 수 있습니다. 초대된 사용자는 수락 또는 거절할 수 있습니다.")
222-
public RsData<Void> invitePlan(@PathVariable Long planId) {
222+
@DeleteMapping("/kick/{planId}/{participantId}")
223+
@Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자만 다른 참가자를 추방할 수 있으며, 소유자 자신은 추방할 수 없습니다.")
224+
public RsData<Void> kickParticipant(
225+
@PathVariable Long planId,
226+
@PathVariable Long participantId) {
223227
User user = rq.getUser();
224228
// TODO: 구현 필요
225-
return RsData.success("계획 공유 초대 성공", null);
229+
return RsData.success("참가자 추방 성공", null);
226230
}
227231

228232
/**
229-
* 계획 공유 수락
233+
* 계획 공유 나가기
230234
*
231235
* @param planId 계획 ID
232236
* @return 성공 메시지 (200 OK)
233237
*/
234-
@PostMapping("/accept/{planId}")
235-
@Operation(summary = "계획 공유 수락", description = "받은 계획 공유 초대를 수락합니다. 수락 시 해당 계획에 참가자로 추가되며, 초대설정된 역할(Editor 또는 Viewer)로 참여하게 됩니다.")
236-
public RsData<Void> acceptPlanInvite(@PathVariable Long planId) {
238+
@DeleteMapping("/quit/{planId}")
239+
@Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기해당 계획의 참가자 목록에서 제거됩니다.")
240+
public RsData<Void> quitPlan(@PathVariable Long planId) {
237241
User user = rq.getUser();
238242
// TODO: 구현 필요
239-
return RsData.success("계획 공유 수락 성공", null);
243+
return RsData.success("계획 나가기 성공", null);
240244
}
241245

242246
/**
243-
* 계획 공유 거절
247+
* 공유 링크 생성 (UUID 기반 13자)
244248
*
245249
* @param planId 계획 ID
246-
* @return 성공 메시지 (200 OK)
250+
* @return 공유 링크 정보 (200 OK)
247251
*/
248-
@PostMapping("/deny/{planId}")
249-
@Operation(summary = "계획 공유 거절", description = "받은 계획 공유 초대를 거절합니다. 거절 시 해당 계획에 참가자로 추가되지 않으며, 초대 상태가 거절로 변경됩니다.")
250-
public RsData<Void> denyPlanInvite(@PathVariable Long planId) {
252+
@PostMapping("/{planId}/share/link")
253+
@Operation(summary = "공유 링크 생성", description = "플랜 공유를 위한 UUID 기반 13자 토큰 링크를 생성합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 링크를 생성할 수 있습니다.")
254+
public RsData<PlanShareLinkResponse> generateShareLink(@PathVariable Long planId) {
251255
User user = rq.getUser();
252-
// TODO: 구현 필요
253-
return RsData.success("계획 공유 거절 성공", null);
256+
PlanShareLinkResponse response = planService.generateShareLink(planId, user);
257+
return RsData.success("공유 링크 생성 성공", response);
254258
}
255259

256260
/**
257-
* 계획 공유 인원 추방
261+
* 공유 링크로 플랜 조회 (참가자 생성 없이 조회만)
258262
*
259-
* @param planId 계획 ID
260-
* @param participantId 참가자 ID
261-
* @return 성공 메시지 (200 OK)
263+
* @param shareToken 공유 토큰 (UUID 기반 13자)
264+
* @return 플랜 상세 정보 (200 OK)
262265
*/
263-
@DeleteMapping("/kick/{planId}/{participantId}")
264-
@Operation(summary = "계획 공유 인원 추방", description = "계획에 참여 중인 사용자를 추방합니다. 계획의 소유자만 다른 참가자를 추방할 수 있으며, 소유자 자신은 추방할 수 없습니다.")
265-
public RsData<Void> kickParticipant(
266-
@PathVariable Long planId,
267-
@PathVariable Long participantId) {
266+
@GetMapping("/share/{shareToken}")
267+
@Operation(summary = "공유 링크로 플랜 조회", description = "공유 링크 토큰을 통해 플랜을 조회합니다. 참가자 생성 없이 플랜 정보만 조회합니다.")
268+
public RsData<PlanDetailResponse> getPlanByShareToken(@PathVariable String shareToken) {
268269
User user = rq.getUser();
269-
// TODO: 구현 필요
270-
return RsData.success("참가자 추방 성공", null);
270+
PlanDetailResponse response = planService.getPlanByShareToken(shareToken, user);
271+
return RsData.success("플랜 조회 성공", response);
271272
}
272273

273274
/**
274-
* 계획 공유 나가기
275+
* 공유 링크로 플랜 참가 수락
276+
*
277+
* @param shareToken 공유 토큰 (UUID 기반 13자)
278+
* @return 플랜 상세 정보 (200 OK)
279+
*/
280+
@PostMapping("/share/{shareToken}/accept")
281+
@Operation(summary = "공유 링크로 플랜 참가 수락", description = "공유 링크 토큰을 통해 플랜 참가를 수락합니다. 참가자가 생성되며 상태가 ACCEPTED로 설정됩니다.")
282+
public RsData<PlanDetailResponse> acceptPlanInvitation(@PathVariable String shareToken) {
283+
User user = rq.getUser();
284+
PlanDetailResponse response = planService.acceptPlanInvitation(shareToken, user);
285+
return RsData.success("플랜 참가 수락 성공", response);
286+
}
287+
288+
/**
289+
* 공유 링크 삭제
275290
*
276291
* @param planId 계획 ID
277292
* @return 성공 메시지 (200 OK)
278293
*/
279-
@DeleteMapping("/quit/{planId}")
280-
@Operation(summary = "계획 공유 나가기", description = "공유된 계획에서 나갑니다. 계획의 소유자가 아닌 참가자만 사용할 수 있으며, 나가기 시 해당 계획의 참가자 목록에서 제거됩니다.")
281-
public RsData<Void> quitPlan(@PathVariable Long planId) {
294+
@DeleteMapping("/{planId}/share/link")
295+
@Operation(summary = "공유 링크 삭제", description = "생성된 공유 링크를 삭제합니다. 계획의 소유자 또는 편집 권한이 있는 사용자만 링크를 삭제할 수 있습니다.")
296+
public RsData<Void> deleteShareLink(@PathVariable Long planId) {
282297
User user = rq.getUser();
283-
// TODO: 구현 필요
284-
return RsData.success("계획 나가기 성공", null);
298+
planService.deleteShareLink(planId, user);
299+
return RsData.success("공유 링크 삭제 성공", null);
285300
}
286301
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.web7_9_codecrete_be.domain.plans.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
@Getter
8+
@Builder
9+
@Schema(description = "플랜 공유 링크 응답 DTO")
10+
public class PlanShareLinkResponse {
11+
12+
@Schema(description = "플랜 ID", example = "1")
13+
private Long planId;
14+
15+
@Schema(description = "공유 토큰 (UUID 기반 13자)", example = "550e8400-e29b")
16+
private String shareToken;
17+
18+
@Schema(description = "공유 링크", example = "/plans/share/550e8400-e29b")
19+
private String shareLink;
20+
}
21+

src/main/java/com/back/web7_9_codecrete_be/domain/plans/entity/Plan.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
import lombok.Builder;
88
import lombok.Getter;
99
import lombok.NoArgsConstructor;
10+
import org.hibernate.annotations.BatchSize;
1011
import org.hibernate.annotations.CreationTimestamp;
1112
import org.hibernate.annotations.UpdateTimestamp;
1213

1314
import java.time.LocalDate;
1415
import java.time.LocalDateTime;
1516
import java.util.ArrayList;
1617
import java.util.List;
18+
import java.util.UUID;
1719

1820

1921
@Entity
@@ -49,11 +51,17 @@ public class Plan {
4951
@Column(name = "modified_date", nullable = false)
5052
private LocalDateTime modifiedDate;
5153

54+
@Column(name = "share_token", unique = true, length = 13)
55+
private String shareToken;
56+
57+
@Column(name = "share_token_expires_at")
58+
private LocalDateTime shareTokenExpiresAt;
5259

5360
@OneToMany(mappedBy = "plan", cascade = CascadeType.ALL, orphanRemoval = true)
5461
private List<PlanParticipant> participants = new ArrayList<>();
5562

5663
@OneToMany(mappedBy = "plan", cascade = CascadeType.ALL, orphanRemoval = true)
64+
@BatchSize(size = 20)
5765
private List<Schedule> schedules = new ArrayList<>();
5866

5967
@Builder
@@ -83,4 +91,26 @@ public void addSchedule(Schedule schedule) {
8391
this.schedules.add(schedule);
8492
schedule.setPlan(this);
8593
}
94+
95+
public void generateShareToken() {
96+
this.shareToken = UUID.randomUUID().toString().substring(0, 13);
97+
// 만료 시간: 현재 시간으로부터 1일 후
98+
this.shareTokenExpiresAt = LocalDateTime.now().plusDays(1);
99+
}
100+
101+
public void clearShareToken() {
102+
this.shareToken = null;
103+
this.shareTokenExpiresAt = null;
104+
}
105+
106+
/**
107+
* 공유 토큰이 만료되었는지 확인
108+
* @return 만료되었으면 true, 아니면 false
109+
*/
110+
public boolean isShareTokenExpired() {
111+
if (shareTokenExpiresAt == null) {
112+
return true; // 만료 시간이 없으면 만료된 것으로 간주
113+
}
114+
return LocalDateTime.now().isAfter(shareTokenExpiresAt);
115+
}
86116
}

src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanParticipantRepository.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,24 @@
44
import org.springframework.data.jpa.repository.JpaRepository;
55
import org.springframework.stereotype.Repository;
66

7+
import java.util.Optional;
8+
79
@Repository
810
public interface PlanParticipantRepository extends JpaRepository<PlanParticipant, Long> {
911

12+
/**
13+
* 특정 사용자와 플랜의 조합으로 참가자 존재 여부 확인
14+
* @param userId 사용자 ID
15+
* @param planId 플랜 ID
16+
* @return 존재 여부
17+
*/
18+
boolean existsByUser_IdAndPlan_PlanId(Long userId, Long planId);
19+
20+
/**
21+
* 특정 사용자와 플랜의 조합으로 참가자 조회
22+
* @param userId 사용자 ID
23+
* @param planId 플랜 ID
24+
* @return PlanParticipant
25+
*/
26+
Optional<PlanParticipant> findByUser_IdAndPlan_PlanId(Long userId, Long planId);
1027
}

src/main/java/com/back/web7_9_codecrete_be/domain/plans/repository/PlanRepository.java

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,19 @@ public interface PlanRepository extends JpaRepository<Plan, Long> {
1515
/**
1616
* - Plan 상세 조회 및 권한 체크 시
1717
* @param id Plan ID
18-
* @return Plan 엔티티 (concert, participants, schedules 포함)
19-
* @EntityGraph:
20-
* - Plan + concert + participants + schedules를 LEFT OUTER JOIN으로 한 번에 조회
21-
* - 총 1번의 쿼리만 실행되어 N + 1 문제 방지 & 성능 향상
18+
* @return Plan 엔티티 (concert, participants 포함)
19+
* schedules는 @BatchSize로 배치 로드됨 (MultipleBagFetchException 방지)
2220
*/
23-
@EntityGraph(attributePaths = {"concert", "participants", "schedules"})
21+
@EntityGraph(attributePaths = {"concert", "participants"})
2422
Optional<Plan> findById(Long id);
2523

2624
/**
2725
* - 특정 사용자가 참가자로 포함된 모든 Plan 조회
2826
* @param userId 참가자로 포함된 사용자 ID
29-
* @return 해당 사용자가 참가자인 모든 Plan 목록 (concert, participants, schedules 포함)
27+
* @return 해당 사용자가 참가자인 모든 Plan 목록 (concert, participants 포함)
28+
* schedules는 @BatchSize로 배치 로드됨 (MultipleBagFetchException 방지)
3029
*/
31-
@EntityGraph(attributePaths = {"concert", "participants", "schedules"})
30+
@EntityGraph(attributePaths = {"concert", "participants"})
3231
List<Plan> findDistinctByParticipants_User_Id(Long userId);
3332

3433
/**
@@ -38,4 +37,13 @@ public interface PlanRepository extends JpaRepository<Plan, Long> {
3837
*/
3938
@EntityGraph(attributePaths = {"schedules"})
4039
List<Plan> findByUser_Id(Long userId);
40+
41+
/**
42+
* - shareToken으로 Plan 조회
43+
* @param shareToken 공유 토큰
44+
* @return 해당 토큰을 가진 Plan 엔티티 (concert, participants 포함)
45+
* schedules는 @BatchSize로 배치 로드됨 (MultipleBagFetchException 방지)
46+
*/
47+
@EntityGraph(attributePaths = {"concert", "participants"})
48+
Optional<Plan> findByShareToken(String shareToken);
4149
}

0 commit comments

Comments
 (0)