diff --git a/src/main/java/com/offway/core/itinerary/controller/CourseStorageApi.java b/src/main/java/com/offway/core/itinerary/controller/CourseStorageApi.java index c2cb17f..d43dde4 100644 --- a/src/main/java/com/offway/core/itinerary/controller/CourseStorageApi.java +++ b/src/main/java/com/offway/core/itinerary/controller/CourseStorageApi.java @@ -8,6 +8,7 @@ import com.offway.core.itinerary.domain.CourseScope; import com.offway.core.itinerary.controller.dto.CourseResponse; import com.offway.core.itinerary.controller.dto.CourseSaveRequest; +import com.offway.core.itinerary.controller.dto.CourseShareResponse; import com.offway.core.itinerary.controller.dto.CourseSummaryResponse; import com.offway.core.itinerary.controller.dto.CourseUpdateRequest; import io.swagger.v3.oas.annotations.Operation; @@ -37,6 +38,32 @@ public interface CourseStorageApi { ApiResponseBody save( @Parameter(description = "게스트 식별자", example = "guest-abc123") String guestId, CourseSaveRequest request); + @Operation( + summary = "공유 링크만 발급 (내 코스에 담지 않음)", + description = + """ + 추천 결과 화면에서 **담지 않고 바로 공유**할 때 쓴다. 요청 본문은 저장(`POST /courses`)과 같고, + 응답은 `shareToken` 하나다. 공유 URL 은 `/c/{shareToken}` 이고, 받은 사람은 + `GET /api/v1/public/courses/{shareToken}` 으로 인증 없이 볼 수 있다. + + **내 코스 목록·상세에 나오지 않는다.** 담은 것이 아니므로 주인 없이 보관하며, 그래서 + `X-Guest-Id` 도 받지 않는다. 담으려면 저장 API 를 따로 부른다 — 그쪽 응답에도 토큰이 실린다. + + **한 번 발급하면 되돌릴 수 없다.** 주인이 없어 삭제 API 로 지울 수 없으므로, 링크를 뿌리기 전에 + 누를 버튼이다. 담은 코스의 공유는 저장 API 로 가면 나중에 코스째 지울 수 있다. + + 같은 코스를 두 번 보내면 **링크가 두 개** 생긴다. 요청 본문만으로는 같은 코스인지 알 수 없어 + 멱등하게 만들 근거가 없다 — 담은 코스의 링크가 코스당 하나인 것과 다른 점이다. + + 구성 검증은 저장과 똑같다. 링크로 열리는 코스가 담은 코스보다 느슨할 이유가 없다. + """) + @ApiResponse(responseCode = "201", description = "발급 성공") + @ApiResponse( + responseCode = "400", + description = "코스 구성 오류(순서·좌표 등) · Day 날짜가 여행 시작일보다 앞서거나 기간을 넘음 · 출발지 위도·경도 중 하나만 보냄") + @ApiResponse(responseCode = "401", description = "인증 필요") + ApiResponseBody share(CourseSaveRequest request); + @Operation( summary = "내 코스 목록", description = """ diff --git a/src/main/java/com/offway/core/itinerary/controller/CourseStorageController.java b/src/main/java/com/offway/core/itinerary/controller/CourseStorageController.java index 8e3ae5e..e458983 100644 --- a/src/main/java/com/offway/core/itinerary/controller/CourseStorageController.java +++ b/src/main/java/com/offway/core/itinerary/controller/CourseStorageController.java @@ -12,6 +12,7 @@ import com.offway.core.itinerary.service.dto.MyCourses; import com.offway.core.itinerary.controller.dto.CourseResponse; import com.offway.core.itinerary.controller.dto.CourseSaveRequest; +import com.offway.core.itinerary.controller.dto.CourseShareResponse; import com.offway.core.itinerary.controller.dto.CourseSummaryResponse; import com.offway.core.itinerary.controller.dto.CourseUpdateRequest; import com.offway.core.itinerary.service.CourseStorageService; @@ -50,6 +51,14 @@ public ApiResponseBody save( return ApiResponseBody.created(CourseResponse.from(courseStorageService.save(request.toCourse(guestId)))); } + @Override + @PostMapping("/share") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponseBody share(@Valid @RequestBody CourseSaveRequest request) { + return ApiResponseBody.created( + CourseShareResponse.from(courseStorageService.shareWithoutSaving(request.toSharedCourse()))); + } + @Override @GetMapping public ApiResponseBody> myCourses( diff --git a/src/main/java/com/offway/core/itinerary/controller/dto/CourseSaveRequest.java b/src/main/java/com/offway/core/itinerary/controller/dto/CourseSaveRequest.java index c627286..f97fa98 100644 --- a/src/main/java/com/offway/core/itinerary/controller/dto/CourseSaveRequest.java +++ b/src/main/java/com/offway/core/itinerary/controller/dto/CourseSaveRequest.java @@ -21,6 +21,7 @@ import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.function.Function; /** * 코스 저장 요청 — API 계약. 생성({@code POST /courses/generate})으로 받은 코스를 그대로 담아 "내 코스"로 저장한다. @@ -61,30 +62,54 @@ public record CourseSaveRequest( Double originLng, @NotEmpty List<@Valid Day> days) { + /** 게스트 소유의 도메인 코스로 변환한다 — 예외 번역은 {@link #build} 가 소유한다. */ + public Course toCourse(String guestId) { + return build(origin -> Course.ownedBy( + guestId, regionId, density, transport, schedules(), travelDate, span(), origin)); + } + /** - * 게스트 소유의 도메인 코스로 변환한다. Bean Validation 이 못 잡는 도메인 불변식(일차·슬롯 순서 연속성, 게스트 ID 규칙 등)은 - * 도메인 팩토리가 던지고, 여기서 계약 예외(400)로 번역한다 — 입력 경계가 계약 검증을 소유하므로 이 매핑에서 400 을 확정한다. + * 주인 없는 코스로 변환한다(#261) — 담지 않고 공유 링크만 만들 때. + * + *

구성 검증은 저장과 똑같다. 링크로 열리는 코스가 담은 코스보다 느슨할 이유가 없고, + * 두 경로의 규칙이 갈리면 같은 payload 가 한쪽에서만 통과한다. */ - public Course toCourse(String guestId) { + public Course toSharedCourse() { + return build(origin -> + Course.sharedOnly(regionId, density, transport, schedules(), travelDate, span(), origin)); + } + + /** + * 출발지를 확정하고 도메인 팩토리를 부른다 — Bean Validation 이 못 잡는 도메인 불변식(일차·슬롯 순서 + * 연속성, 게스트 ID 규칙 등)은 도메인이 던지고, 여기서 계약 예외(400)로 번역한다. 입력 경계가 계약 검증을 + * 소유하므로 이 매핑에서 400 을 확정한다. + */ + private Course build(Function factory) { try { - List schedules = - days.stream().map(day -> day.toSchedule(travelDate)).toList(); - // 기간을 안 보낸 클라이언트는 담아 보낸 날 수로 본다 — 이 필드가 생기기 전과 같은 동작이라 - // 기존 연동이 깨지지 않는다. 그 경우 첫날이 빠진 코스는 종료일이 하루 이른 채로 남는다(#164). - int span = travelDays != null ? travelDays : schedules.size(); // 출발지는 위도·경도가 함께여야 좌표가 된다. 한쪽만 오면 조용히 버리지 않고 거절한다 — // 클라이언트는 출발지를 보냈다고 여기는데 저장 코스에서 열차 접근이 비고, 그 이유를 알 수 없다. // Day 날짜(#180)에서 시작일 없이 날짜만 온 요청을 거절한 것과 같은 판단이다. if ((originLat == null) != (originLng == null)) { throw new IllegalArgumentException("출발지는 위도·경도를 함께 보내야 합니다"); } - Coordinate origin = originLat == null ? null : new Coordinate(originLat, originLng); - return Course.ownedBy(guestId, regionId, density, transport, schedules, travelDate, span, origin); + return factory.apply(originLat == null ? null : new Coordinate(originLat, originLng)); } catch (IllegalArgumentException e) { throw ItineraryException.invalidCourse(); } } + private List schedules() { + return days.stream().map(day -> day.toSchedule(travelDate)).toList(); + } + + /** + * 여행 기간 — 기간을 안 보낸 클라이언트는 담아 보낸 날 수로 본다. 이 필드가 생기기 전과 같은 동작이라 + * 기존 연동이 깨지지 않는다. 그 경우 첫날이 빠진 코스는 종료일이 하루 이른 채로 남는다(#164). + */ + private int span() { + return travelDays != null ? travelDays : days.size(); + } + /** * @param day 며칠째(1부터) — 화면에 보이는 번호 * @param date 그 날의 실제 날짜. 생성 응답의 {@code date} 를 그대로 돌려주면 된다(없으면 null) diff --git a/src/main/java/com/offway/core/itinerary/controller/dto/CourseShareResponse.java b/src/main/java/com/offway/core/itinerary/controller/dto/CourseShareResponse.java new file mode 100644 index 0000000..c55979b --- /dev/null +++ b/src/main/java/com/offway/core/itinerary/controller/dto/CourseShareResponse.java @@ -0,0 +1,19 @@ +package com.offway.core.itinerary.controller.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 공유 링크 발급 응답(#261) — 담지 않고 링크만 만들었을 때. + * + *

토큰 하나만 준다. 화면은 방금 보고 있던 코스를 그대로 들고 있어 코스 내용을 되돌려줄 이유가 없고, + * 붙여 보내려면 혜택·날씨를 다시 조립해야 해서 외부 호출까지 딸려온다. + * + * @param shareToken 공유 토큰. 공유 URL 은 {@code /c/{shareToken}} + */ +public record CourseShareResponse( + @Schema(example = "a1B2c3D4e5F6g7H8i9J0kL") String shareToken) { + + public static CourseShareResponse from(String shareToken) { + return new CourseShareResponse(shareToken); + } +} diff --git a/src/main/java/com/offway/core/itinerary/domain/Course.java b/src/main/java/com/offway/core/itinerary/domain/Course.java index 3efdccd..86d5226 100644 --- a/src/main/java/com/offway/core/itinerary/domain/Course.java +++ b/src/main/java/com/offway/core/itinerary/domain/Course.java @@ -184,6 +184,31 @@ public static Course ownedBy( origin == null ? null : origin.lat(), origin == null ? null : origin.lng()); } + /** + * 소유자 없이 영속하는 코스(#261) — 담지 않고 공유 링크만 만들 때. + * + *

공유 링크로 열려면 코스가 어딘가 있어야 하는데, 사용자는 이걸 "내 코스에 담았다" 고 여기지 않는다. + * 그래서 주인을 두지 않는다 — "내 코스" 조회는 전부 {@code guest_id} 로 좁히므로(목록·상세·삭제) + * 주인이 없는 코스는 어느 질의에도 걸리지 않는다. 목록에서 빼려고 플래그를 더하고 질의마다 조건을 + * 붙이는 것보다, 애초에 소유 관계를 만들지 않는 편이 규칙이 하나로 끝난다. + * + *

그 대가로 이 코스는 아무도 지울 수 없다. 삭제도 소유자 범위로 도는 길뿐이기 때문이다. + * 정리는 발급 시각({@code course_share.created_at})을 근거로 나중에 일괄로 한다. + * + * @param origin 출발지. 공개 조회에서 열차 접근을 다시 계산하는 근거다(#187). 모르면 null + */ + public static Course sharedOnly( + Long regionId, + Density density, + TransportMode transport, + List days, + LocalDate travelDate, + int travelDays, + Coordinate origin) { + return new Course(null, regionId, density, transport, days, travelDate, travelDays, + origin == null ? null : origin.lat(), origin == null ? null : origin.lng()); + } + /** * 저장된 출발지 — 대중교통 열차 접근을 다시 계산할 근거. * diff --git a/src/main/java/com/offway/core/itinerary/service/CoursePersistenceService.java b/src/main/java/com/offway/core/itinerary/service/CoursePersistenceService.java index a87af4e..e398155 100644 --- a/src/main/java/com/offway/core/itinerary/service/CoursePersistenceService.java +++ b/src/main/java/com/offway/core/itinerary/service/CoursePersistenceService.java @@ -1,8 +1,8 @@ package com.offway.core.itinerary.service; import com.offway.core.itinerary.domain.Course; -import com.offway.core.itinerary.domain.DayStart; import com.offway.core.itinerary.domain.CourseShare; +import com.offway.core.itinerary.domain.DayStart; import com.offway.core.itinerary.domain.ItineraryException; import com.offway.core.itinerary.repository.CourseRepository; import com.offway.core.itinerary.repository.CourseShareRepository; @@ -79,6 +79,25 @@ public Course persist(Course course) { return saved; } + /** + * 주인 없는 코스와 그 공유 링크를 한 트랜잭션으로 저장한다(#261). + * + *

나눠 저장하면 아무도 닿을 수 없는 행이 남는다. 코스만 커밋되고 링크 발급이 실패하면, 그 + * 코스는 주인이 없어 목록·상세·삭제 어디에도 안 걸리고 공유 행이 없어 링크로도 못 연다. 나중에 붙일 + * 정리는 공유 행의 발급 시각으로 나이를 재므로(코스 테이블에 생성 시각이 없다) 그 정리조차 이 + * 행을 못 찾는다 — 영영 남는 죽은 데이터다. + * + *

"담지 않은 코스는 반드시 공유 행과 짝" 이라는 정리의 전제를 여기서 지킨다. + * + *

여기서는 발급 경합을 다루지 않는다({@link #shareOf} 와 다른 점이다) — 방금 만든 코스라 그 id 를 + * 아는 요청이 하나뿐이고, 유니크 제약에 걸릴 상대가 없다. + */ + @Transactional + public CourseShare persistWithShare(Course course) { + Course saved = courseRepository.save(course); + return courseShareRepository.save(CourseShare.issue(saved.getId(), LocalDateTime.now())); + } + /** * 공유 토큰으로 코스를 읽는다(#143) — 소유자 확인 없이. 링크를 받은 사람에게는 우리 계정이 없다. * diff --git a/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java b/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java index 6229630..d4003c2 100644 --- a/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java +++ b/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java @@ -3,6 +3,7 @@ import com.offway.core.common.response.Paging; import com.offway.core.itinerary.domain.Course; import com.offway.core.itinerary.domain.CourseScope; +import com.offway.core.itinerary.domain.CourseShare; import com.offway.core.itinerary.domain.DayStart; import com.offway.core.itinerary.domain.ItineraryException; import com.offway.core.itinerary.repository.CourseRepository; @@ -72,6 +73,26 @@ public GeneratedCourse save(Course course) { return withBenefits(saved, false).withShareToken(shareTokenOf(saved.getId())); } + /** + * 담지 않고 공유 링크만 만든다(#261) — 추천 결과 화면의 공유 버튼. + * + *

링크로 열려면 코스가 어딘가 있어야 하므로 코스 자체는 영속한다. 다만 주인 없이 저장해 + * "내 코스" 어디에도 나오지 않게 한다({@link Course#sharedOnly}). 사용자가 담은 것이 아니기 때문이다. + * + *

혜택·날씨를 붙이지 않는다 — 응답이 토큰 하나라 조립할 것이 없고, 그 조립은 외부 호출(기상청)을 + * 탄다. 링크를 여는 쪽({@code GET /public/courses/{token}})이 그때 붙인다. + * + * @return 공유 토큰 + */ + public String shareWithoutSaving(Course course) { + // 코스와 링크를 한 트랜잭션으로 저장한다. 나눠 저장하면 링크 발급이 실패했을 때 아무도 닿을 수 + // 없는 코스가 남고, 정리 배치가 나이를 재는 근거(공유 행의 발급 시각)도 없어 영영 남는다. + CourseShare share = coursePersistenceService.persistWithShare(course); + log.info("담지 않은 코스로 공유 링크를 만들었습니다 courseId={} regionId={}", + share.getCourseId(), course.getRegionId()); + return share.getShareToken(); + } + /** * 코스의 공유 토큰 — 동시에 발급하려는 경합을 흡수한다. * diff --git a/src/test/java/com/offway/core/itinerary/controller/CourseShareIntegrationTest.java b/src/test/java/com/offway/core/itinerary/controller/CourseShareIntegrationTest.java index 64099e8..9ea2b1d 100644 --- a/src/test/java/com/offway/core/itinerary/controller/CourseShareIntegrationTest.java +++ b/src/test/java/com/offway/core/itinerary/controller/CourseShareIntegrationTest.java @@ -2,6 +2,7 @@ import static org.hamcrest.Matchers.notNullValue; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -14,6 +15,7 @@ import com.jayway.jsonpath.JsonPath; import com.offway.core.itinerary.domain.Course; +import com.offway.core.itinerary.domain.CourseShare; import com.offway.core.itinerary.domain.DaySchedule; import com.offway.core.itinerary.domain.Density; import com.offway.core.itinerary.domain.Slot; @@ -44,6 +46,7 @@ class CourseShareIntegrationTest { private static final String COURSES_URL = "/api/v1/courses"; + private static final String SHARE_URL = "/api/v1/courses/share"; private static final String PUBLIC_URL = "/api/v1/public/courses/{shareToken}"; private static final String GUEST_HEADER = "X-Guest-Id"; @@ -251,6 +254,98 @@ class CourseShareIntegrationTest { .andExpect(jsonPath("$.data.shareToken").value(saved)); } + /** 담기 전 추천 결과 화면의 공유 버튼(#261) — 담지 않고 링크만 받는다. */ + @Test + void 담지_않고_공유하면_토큰만_받고_링크가_열린다() throws Exception { + String token = shareWithoutSaving(VALID_BODY); + + mockMvc.perform(get(PUBLIC_URL, token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.regionId").value(16)) + .andExpect(jsonPath("$.data.days[0].items[0].title").value("장소1")) + .andExpect(jsonPath("$.data.courseId").doesNotExist()); + } + + /** + * 담지 않은 코스는 반드시 공유 행과 짝이다(#261) — 나중에 붙일 정리가 이 전제 위에 선다. + * + *

코스만 저장되고 링크 발급이 실패하면 그 코스는 아무도 닿을 수 없다. 주인이 없어 목록·상세·삭제 + * 어디에도 안 걸리고, 공유 행이 없어 링크로도 못 연다. 정리는 공유 행의 발급 시각으로 나이를 재므로 + * 그 정리조차 못 찾는다 — 짝이 깨지면 영영 남는 죽은 데이터가 된다. + */ + @Test + void 담지_않은_코스는_공유_행과_함께_저장된다() throws Exception { + String token = shareWithoutSaving(VALID_BODY); + + CourseShare share = courseShareRepository + .findByShareToken(token) + .orElseThrow(() -> new AssertionError("발급한 토큰으로 공유 행을 못 찾는다")); + assertTrue(courseRepository.findById(share.getCourseId()).isPresent(), + "공유 행이 가리키는 코스가 없다 — 링크가 열리지 않는다"); + } + + /** + * 담지 않은 코스가 목록에 끼면 사용자는 담지 않은 것을 담았다고 오해한다. + * + *

주인 없이 보관하는 것이 그 장치다 — "내 코스" 조회가 전부 게스트 범위라 어느 질의에도 안 걸린다. + */ + @Test + void 담지_않고_공유한_코스는_내_코스에_나오지_않는다() throws Exception { + String guest = guest(); + shareWithoutSaving(VALID_BODY); + + mockMvc.perform(get(COURSES_URL).with(user("dev")).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data").isEmpty()); + } + + /** 링크로 열리는 코스가 담은 코스보다 느슨하면, 같은 payload 가 한쪽에서만 통과한다. */ + @Test + void 담지_않는_공유도_구성이_틀리면_400_이다() throws Exception { + String invalid = VALID_BODY.replace("\"order\":2", "\"order\":3"); + + mockMvc.perform(post(SHARE_URL) + .with(user("dev")) + .contentType(MediaType.APPLICATION_JSON) + .content(invalid)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.code").value("ITINERARY-002")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + /** + * 같은 코스를 두 번 보내면 링크도 두 개다 — 요청 본문만으로는 같은 코스인지 알 근거가 없다. + * + *

담은 코스의 링크가 코스당 하나인 것과 다른 점이라 문서에 적었다. 여기서 잠가 둔다. + */ + @Test + void 담지_않는_공유는_부를_때마다_새_링크다() throws Exception { + String first = shareWithoutSaving(VALID_BODY); + String second = shareWithoutSaving(VALID_BODY); + + assertNotEquals(first, second); + mockMvc.perform(get(PUBLIC_URL, first)).andExpect(status().isOk()); + mockMvc.perform(get(PUBLIC_URL, second)).andExpect(status().isOk()); + } + + private String shareWithoutSaving(String body) throws Exception { + String response = mockMvc.perform(post(SHARE_URL) + .with(user("dev")) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value(201)) + .andExpect(jsonPath("$.code").value("OK")) + .andReturn() + .getResponse() + .getContentAsString(); + return JsonPath.read(response, "$.data.shareToken"); + } + private String tokenFromDetail(String guest, long courseId) throws Exception { String body = mockMvc.perform(get(COURSES_URL + "/" + courseId) .with(user("dev")) diff --git a/src/test/java/com/offway/core/itinerary/domain/CourseTest.java b/src/test/java/com/offway/core/itinerary/domain/CourseTest.java index 3c54998..25f23ff 100644 --- a/src/test/java/com/offway/core/itinerary/domain/CourseTest.java +++ b/src/test/java/com/offway/core/itinerary/domain/CourseTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.offway.core.transport.domain.Coordinate; import com.offway.core.transport.domain.TransportMode; import java.time.LocalDate; import java.util.List; @@ -34,6 +35,32 @@ private static DaySchedule day(int dayNumber, int slots) { assertEquals(5, course.totalSlots()); // 3 + 2 } + /** + * 담지 않고 공유하는 코스는 주인이 없다(#261). + * + *

"내 코스" 조회가 전부 게스트 범위라, 주인이 없는 것이 곧 목록에 안 나오는 장치다. 여기에 게스트가 + * 붙는 순간 담지 않은 코스가 남의 목록에 뜬다. + */ + @Test + void 공유전용_코스는_주인이_없고_출발지는_받는다() { + Course course = Course.sharedOnly(42L, Density.PACKED, TransportMode.CAR, List.of(day(1, 2)), + LocalDate.of(2026, 9, 12), 1, new Coordinate(37.55, 126.97)); + + assertNull(course.getGuestId()); + assertEquals(42L, course.getRegionId()); + assertTrue(course.origin().isPresent()); + } + + /** 링크로 열리는 코스가 담은 코스보다 느슨하면, 같은 구성이 한쪽에서만 통과한다. */ + @Test + void 공유전용_코스도_구성_불변식을_그대로_지킨다() { + assertThrows(IllegalArgumentException.class, + () -> Course.sharedOnly(42L, Density.RELAXED, TransportMode.CAR, List.of(), null, 1, null)); + assertThrows(IllegalArgumentException.class, + () -> Course.sharedOnly(42L, Density.RELAXED, TransportMode.CAR, + List.of(day(1, 1), day(2, 1), day(3, 1), day(4, 1)), null, 3, null)); + } + @Test void 하루도_없으면_거부한다() { assertThrows(IllegalArgumentException.class,