Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,6 +38,32 @@ public interface CourseStorageApi {
ApiResponseBody<CourseResponse> 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<CourseShareResponse> share(CourseSaveRequest request);

@Operation(
summary = "내 코스 목록",
description = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +51,14 @@ public ApiResponseBody<CourseResponse> save(
return ApiResponseBody.created(CourseResponse.from(courseStorageService.save(request.toCourse(guestId))));
}

@Override
@PostMapping("/share")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponseBody<CourseShareResponse> share(@Valid @RequestBody CourseSaveRequest request) {
return ApiResponseBody.created(
CourseShareResponse.from(courseStorageService.shareWithoutSaving(request.toSharedCourse())));
}

@Override
@GetMapping
public ApiResponseBody<List<CourseSummaryResponse>> myCourses(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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})으로 받은 코스를 그대로 담아 "내 코스"로 저장한다.
Expand Down Expand Up @@ -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 을 확정한다.
* <b>주인 없는</b> 코스로 변환한다(#261) — 담지 않고 공유 링크만 만들 때.
*
* <p>구성 검증은 저장과 <b>똑같다</b>. 링크로 열리는 코스가 담은 코스보다 느슨할 이유가 없고,
* 두 경로의 규칙이 갈리면 같은 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<Coordinate, Course> factory) {
try {
List<DaySchedule> 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<DaySchedule> 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.offway.core.itinerary.controller.dto;

import io.swagger.v3.oas.annotations.media.Schema;

/**
* 공유 링크 발급 응답(#261) — 담지 않고 링크만 만들었을 때.
*
* <p><b>토큰 하나만 준다.</b> 화면은 방금 보고 있던 코스를 그대로 들고 있어 코스 내용을 되돌려줄 이유가 없고,
* 붙여 보내려면 혜택·날씨를 다시 조립해야 해서 외부 호출까지 딸려온다.
*
* @param shareToken 공유 토큰. 공유 URL 은 {@code /c/{shareToken}}
*/
public record CourseShareResponse(
@Schema(example = "a1B2c3D4e5F6g7H8i9J0kL") String shareToken) {

public static CourseShareResponse from(String shareToken) {
return new CourseShareResponse(shareToken);
}
}
25 changes: 25 additions & 0 deletions src/main/java/com/offway/core/itinerary/domain/Course.java
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,31 @@ public static Course ownedBy(
origin == null ? null : origin.lat(), origin == null ? null : origin.lng());
}

/**
* <b>소유자 없이</b> 영속하는 코스(#261) — 담지 않고 공유 링크만 만들 때.
*
* <p>공유 링크로 열려면 코스가 어딘가 있어야 하는데, 사용자는 이걸 "내 코스에 담았다" 고 여기지 않는다.
* 그래서 <b>주인을 두지 않는다</b> — "내 코스" 조회는 전부 {@code guest_id} 로 좁히므로(목록·상세·삭제)
* 주인이 없는 코스는 어느 질의에도 걸리지 않는다. 목록에서 빼려고 플래그를 더하고 질의마다 조건을
* 붙이는 것보다, 애초에 소유 관계를 만들지 않는 편이 규칙이 하나로 끝난다.
*
* <p>그 대가로 <b>이 코스는 아무도 지울 수 없다</b>. 삭제도 소유자 범위로 도는 길뿐이기 때문이다.
* 정리는 발급 시각({@code course_share.created_at})을 근거로 나중에 일괄로 한다.
*
* @param origin 출발지. 공개 조회에서 열차 접근을 다시 계산하는 근거다(#187). 모르면 null
*/
public static Course sharedOnly(
Long regionId,
Density density,
TransportMode transport,
List<DaySchedule> 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());
}

/**
* 저장된 출발지 — 대중교통 열차 접근을 다시 계산할 근거.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -79,6 +79,25 @@ public Course persist(Course course) {
return saved;
}

/**
* 주인 없는 코스와 그 공유 링크를 <b>한 트랜잭션으로</b> 저장한다(#261).
*
* <p><b>나눠 저장하면 아무도 닿을 수 없는 행이 남는다.</b> 코스만 커밋되고 링크 발급이 실패하면, 그
* 코스는 주인이 없어 목록·상세·삭제 어디에도 안 걸리고 공유 행이 없어 링크로도 못 연다. 나중에 붙일
* 정리는 <b>공유 행의 발급 시각으로 나이를 재므로</b>(코스 테이블에 생성 시각이 없다) 그 정리조차 이
* 행을 못 찾는다 — 영영 남는 죽은 데이터다.
*
* <p>"담지 않은 코스는 반드시 공유 행과 짝" 이라는 정리의 전제를 여기서 지킨다.
*
* <p>여기서는 발급 경합을 다루지 않는다({@link #shareOf} 와 다른 점이다) — 방금 만든 코스라 그 id 를
* 아는 요청이 하나뿐이고, 유니크 제약에 걸릴 상대가 없다.
*/
@Transactional
public CourseShare persistWithShare(Course course) {
Course saved = courseRepository.save(course);
return courseShareRepository.save(CourseShare.issue(saved.getId(), LocalDateTime.now()));
}

/**
* 공유 토큰으로 코스를 읽는다(#143) — <b>소유자 확인 없이</b>. 링크를 받은 사람에게는 우리 계정이 없다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,6 +73,26 @@ public GeneratedCourse save(Course course) {
return withBenefits(saved, false).withShareToken(shareTokenOf(saved.getId()));
}

/**
* 담지 않고 <b>공유 링크만</b> 만든다(#261) — 추천 결과 화면의 공유 버튼.
*
* <p>링크로 열려면 코스가 어딘가 있어야 하므로 코스 자체는 영속한다. 다만 <b>주인 없이</b> 저장해
* "내 코스" 어디에도 나오지 않게 한다({@link Course#sharedOnly}). 사용자가 담은 것이 아니기 때문이다.
*
* <p>혜택·날씨를 붙이지 않는다 — 응답이 토큰 하나라 조립할 것이 없고, 그 조립은 외부 호출(기상청)을
* 탄다. 링크를 여는 쪽({@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();
}

/**
* 코스의 공유 토큰 — 동시에 발급하려는 경합을 흡수한다.
*
Expand Down
Loading