From 67891b5f200fd6b202715aeb3068031ec540daea Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:13:11 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=8B=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B3=A0=20=EA=B3=B5=EC=9C=A0=20=EB=A7=81=ED=81=AC=EB=A7=8C=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=ED=95=98=EB=8A=94=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/courses/share — 요청은 저장과 같은 payload, 응답은 shareToken 하나 - 추천 결과 화면에서 공유를 누르면 "먼저 담아주세요" 안내만 띄우고 있었다. 담기는 여행 날짜 선택까지 거쳐야 해서, 그냥 보여주려는 사람에게는 단계가 과했다 - 링크로 열려면 코스가 어딘가 있어야 하므로 코스 자체는 영속한다. 다만 **주인 없이** 저장해 "내 코스" 어디에도 안 나오게 했다 — 목록·상세·삭제가 전부 guest_id 로 좁히므로 주인이 없으면 어느 질의에도 안 걸린다. 플래그 컬럼을 더하고 질의마다 조건을 붙이는 안도 있었지만, 그러면 기존 질의 넷을 모두 고쳐야 하고 하나라도 빠뜨리면 담지 않은 코스가 남의 목록에 뜬다 - 그 대가로 이 코스는 아무도 지울 수 없다(삭제도 소유자 범위로만 돈다). 정리는 발급 시각 (course_share.created_at)을 근거로 나중에 일괄로 한다 — 지금 배치까지 만들지는 않는다 - X-Guest-Id 를 받지 않는다. 소유 관계를 만들지 않으므로 쓸 데가 없고, 받아두면 "담긴다" 는 오해만 남는다 - 응답에 코스 내용을 싣지 않는다. 화면이 방금 보던 코스를 그대로 들고 있고, 붙여 보내려면 혜택·날씨를 다시 조립해야 해서 기상청 호출이 딸려온다 - 구성 검증은 저장과 같은 길을 탄다. 두 경로가 갈리면 같은 payload 가 한쪽에서만 통과한다 — CourseSaveRequest 의 출발지 검사·예외 번역을 한 곳으로 모아 두 팩토리가 함께 쓴다 --- .../controller/CourseStorageApi.java | 27 +++++++++++ .../controller/CourseStorageController.java | 9 ++++ .../controller/dto/CourseSaveRequest.java | 45 ++++++++++++++----- .../controller/dto/CourseShareResponse.java | 19 ++++++++ .../offway/core/itinerary/domain/Course.java | 25 +++++++++++ .../service/CourseStorageService.java | 18 ++++++++ 6 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/offway/core/itinerary/controller/dto/CourseShareResponse.java 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 540ab972..aae1bc3a 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 8e3ae5ec..e4589834 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 c627286f..f97fa987 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 00000000..c55979b2 --- /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 3efdccd0..86d52264 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/CourseStorageService.java b/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java index 873d504c..498e49c0 100644 --- a/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java +++ b/src/main/java/com/offway/core/itinerary/service/CourseStorageService.java @@ -72,6 +72,24 @@ 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) { + Course stored = coursePersistenceService.persist(course); + log.info("담지 않은 코스로 공유 링크를 만들었습니다 courseId={} regionId={}", + stored.getId(), stored.getRegionId()); + return shareTokenOf(stored.getId()); + } + /** * 코스의 공유 토큰 — 동시에 발급하려는 경합을 흡수한다. * From ba8dbb7cc6a2557ccfc13553182c9a8c6d29d130 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:13:21 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=EB=8B=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EA=B3=B5=EC=9C=A0=EA=B0=80=20=EB=82=B4=20=EC=BD=94?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EC=83=88=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EC=A7=80=20=EC=9E=A0=EA=B7=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 발급한 링크가 실제로 열리고 공개 응답에 내부 courseId 가 없는지 확인한다 - **담지 않은 코스가 내 코스 목록에 안 나오는지**를 단언한다. 이 PR 의 전제가 "주인이 없으면 게스트 범위 질의에 안 걸린다" 라, 그 전제가 깨지면 여기서 걸려야 한다 - 구성이 틀린 요청이 저장과 같은 400(ITINERARY-002)을 받는지 본다 — 두 경로의 검증이 갈리는 회귀를 잡는다 - 같은 payload 를 두 번 보내면 링크가 두 개라는 것도 잠근다. 문서에 적은 동작이라 우연히 바뀌면 안 된다 - 도메인 단위 테스트로 sharedOnly 가 주인 없이 만들어지고 구성 불변식은 그대로 지키는지 본다 --- .../CourseShareIntegrationTest.java | 76 +++++++++++++++++++ .../core/itinerary/domain/CourseTest.java | 27 +++++++ 2 files changed, 103 insertions(+) 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 8684fea8..a8f8e712 100644 --- a/src/test/java/com/offway/core/itinerary/controller/CourseShareIntegrationTest.java +++ b/src/test/java/com/offway/core/itinerary/controller/CourseShareIntegrationTest.java @@ -1,6 +1,7 @@ package com.offway.core.itinerary.controller; import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -29,6 +30,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"; @@ -122,6 +124,80 @@ class CourseShareIntegrationTest { .andExpect(header().doesNotExist("Access-Control-Allow-Credentials")); } + /** 담기 전 추천 결과 화면의 공유 버튼(#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()); + } + + /** + * 담지 않은 코스가 목록에 끼면 사용자는 담지 않은 것을 담았다고 오해한다. + * + *

주인 없이 보관하는 것이 그 장치다 — "내 코스" 조회가 전부 게스트 범위라 어느 질의에도 안 걸린다. + */ + @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 saveAndGetToken(String guest) throws Exception { String body = mockMvc.perform(post(COURSES_URL) .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 3c549986..25f23ffe 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, From 641d3cc20658a5e43eb6863f423df7c0433ae33c Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:22:01 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=8B=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=80=20=EC=BD=94=EC=8A=A4=EC=99=80=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=ED=96=89=EC=9D=84=20=ED=95=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 나눠 저장하면 코스만 커밋되고 링크 발급이 실패했을 때 아무도 닿을 수 없는 행이 남는다. 주인이 없어 목록·상세·삭제 어디에도 안 걸리고, 공유 행이 없어 링크로도 못 연다 - 정리 배치가 공유 행의 발급 시각으로 나이를 재므로(코스 테이블에 생성 시각이 없다) 그 배치조차 이 행을 못 찾는다 — 영영 남는 죽은 데이터가 된다 - "담지 않은 코스는 반드시 공유 행과 짝" 이라는 정리의 전제를 여기서 지킨다 - 이 경로는 발급 경합을 다루지 않는다. 방금 만든 코스라 그 id 를 아는 요청이 하나뿐이라 유니크 제약에 걸릴 상대가 없다 --- .../service/CoursePersistenceService.java | 19 +++++++++++++++++++ .../service/CourseStorageService.java | 9 ++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) 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 1022314d..d0ec9331 100644 --- a/src/main/java/com/offway/core/itinerary/service/CoursePersistenceService.java +++ b/src/main/java/com/offway/core/itinerary/service/CoursePersistenceService.java @@ -76,6 +76,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 498e49c0..5bed37a3 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; @@ -84,10 +85,12 @@ public GeneratedCourse save(Course course) { * @return 공유 토큰 */ public String shareWithoutSaving(Course course) { - Course stored = coursePersistenceService.persist(course); + // 코스와 링크를 한 트랜잭션으로 저장한다. 나눠 저장하면 링크 발급이 실패했을 때 아무도 닿을 수 + // 없는 코스가 남고, 정리 배치가 나이를 재는 근거(공유 행의 발급 시각)도 없어 영영 남는다. + CourseShare share = coursePersistenceService.persistWithShare(course); log.info("담지 않은 코스로 공유 링크를 만들었습니다 courseId={} regionId={}", - stored.getId(), stored.getRegionId()); - return shareTokenOf(stored.getId()); + share.getCourseId(), course.getRegionId()); + return share.getShareToken(); } /** From 105e6077d0c862e6f4de7b23e167a3bc763ad887 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:22:01 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EB=8B=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=80=20=EC=BD=94=EC=8A=A4=EA=B0=80=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=ED=96=89=EA=B3=BC=20=EC=A7=9D=EC=9D=B8=EC=A7=80=20=EC=9E=A0?= =?UTF-8?q?=EA=B7=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정리 배치가 이 전제 위에 서는데 그것을 확인하는 단언이 없었다 --- .../CourseShareIntegrationTest.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 a8f8e712..84bc1a77 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.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; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -11,6 +12,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.jayway.jsonpath.JsonPath; +import com.offway.core.itinerary.domain.CourseShare; +import com.offway.core.itinerary.repository.CourseRepository; +import com.offway.core.itinerary.repository.CourseShareRepository; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -47,6 +51,12 @@ class CourseShareIntegrationTest { @Autowired private MockMvc mockMvc; + @Autowired + private CourseRepository courseRepository; + + @Autowired + private CourseShareRepository courseShareRepository; + @Test void 저장하면_공유토큰이_함께_온다() throws Exception { mockMvc.perform(post(COURSES_URL) @@ -138,6 +148,24 @@ class CourseShareIntegrationTest { .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(), + "공유 행이 가리키는 코스가 없다 — 링크가 열리지 않는다"); + } + /** * 담지 않은 코스가 목록에 끼면 사용자는 담지 않은 것을 담았다고 오해한다. *