diff --git a/CLAUDE.md b/CLAUDE.md index 127eaca..5c98c02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,6 +225,7 @@ log.warn("[Action] 경고 설명"); | 0.0.34 | `V0_0_34__add_todos_and_categories.sql` | `todos`, `todo_categories` 테이블 생성 (FK CASCADE, JSONB 컬럼) | | 0.0.36 | `V0_0_36__add_fuel.sql` | `user_fuel`, `fuel_transactions` 테이블 생성 (CHECK 제약, FK CASCADE) | | 0.0.39 | `V0_0_39__add_timer_sessions.sql` | `timer_sessions` 테이블 생성 (FK CASCADE, CHECK 제약 3종, 부분 unique 인덱스 on idempotency_key) | +| 0.0.42 | `V0_0_42__add_exploration.sql` | `exploration_nodes`, `user_exploration_progress` 테이블 + 행성/지역 시드 38노드 (프론트 시드 미러, self-FK, FK CASCADE, UNIQUE) | --- diff --git a/SS-Common/build.gradle b/SS-Common/build.gradle index cc21489..f162676 100644 --- a/SS-Common/build.gradle +++ b/SS-Common/build.gradle @@ -16,7 +16,8 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-actuator' api 'org.springframework.boot:spring-boot-starter-security' - // Flyway + // Flyway (Spring Boot 4: autoconfiguration이 spring-boot-flyway 모듈로 분리됨 — 없으면 마이그레이션 미실행) + api 'org.springframework.boot:spring-boot-flyway' api 'org.flywaydb:flyway-core' api 'org.flywaydb:flyway-database-postgresql' diff --git a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java index e2f631c..8e78939 100644 --- a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java +++ b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java @@ -41,6 +41,13 @@ public enum ErrorCode { SESSION_TOO_LONG(HttpStatus.BAD_REQUEST, "공부 시간은 24시간(1440분)을 초과할 수 없습니다."), FUTURE_SESSION(HttpStatus.BAD_REQUEST, "미래 시각의 세션은 저장할 수 없습니다."), + // Exploration + PLANET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 행성을 찾을 수 없습니다."), + REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 지역을 찾을 수 없습니다."), + ALREADY_UNLOCKED(HttpStatus.BAD_REQUEST, "이미 해금된 노드입니다."), + PLANET_LOCKED(HttpStatus.BAD_REQUEST, "상위 행성이 아직 해금되지 않았습니다."), + PREREQUISITE_NOT_CLEARED(HttpStatus.BAD_REQUEST, "이전 행성을 먼저 클리어해야 합니다."), + // Common INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "입력값이 유효하지 않습니다."), INVALID_REQUEST_BODY(HttpStatus.BAD_REQUEST, "요청 본문의 형식이 잘못되었습니다."), diff --git a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java index cc437c8..156965a 100644 --- a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java +++ b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java @@ -1,14 +1,23 @@ package com.elipair.spacestudyship.common.exception; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) public record ErrorResponse( String code, - String message + String message, + Integer requiredFuel, + Integer currentFuel ) { public static ErrorResponse of(ErrorCode errorCode) { - return new ErrorResponse(errorCode.name(), errorCode.getMessage()); + return new ErrorResponse(errorCode.name(), errorCode.getMessage(), null, null); } public static ErrorResponse of(ErrorCode errorCode, String message) { - return new ErrorResponse(errorCode.name(), message); + return new ErrorResponse(errorCode.name(), message, null, null); + } + + public static ErrorResponse ofInsufficientFuel(String message, int requiredFuel, int currentFuel) { + return new ErrorResponse(ErrorCode.INSUFFICIENT_FUEL.name(), message, requiredFuel, currentFuel); } } diff --git a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java index 1c9d6f3..a729db7 100644 --- a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java +++ b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java @@ -27,6 +27,16 @@ public ResponseEntity handleCustomException(CustomException ex) { .body(ErrorResponse.of(errorCode)); } + @ExceptionHandler(InsufficientFuelException.class) + public ResponseEntity handleInsufficientFuel(InsufficientFuelException ex) { + log.info("[Exception] 연료 부족 | required={}, current={}", ex.getRequiredFuel(), ex.getCurrentFuel()); + return ResponseEntity + .status(ErrorCode.INSUFFICIENT_FUEL.getHttpStatus()) + .body(ErrorResponse.ofInsufficientFuel( + ErrorCode.INSUFFICIENT_FUEL.getMessage(), + ex.getRequiredFuel(), ex.getCurrentFuel())); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { String detail = ex.getBindingResult().getFieldErrors().stream() diff --git a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/InsufficientFuelException.java b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/InsufficientFuelException.java new file mode 100644 index 0000000..80ec928 --- /dev/null +++ b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/InsufficientFuelException.java @@ -0,0 +1,16 @@ +package com.elipair.spacestudyship.common.exception; + +import lombok.Getter; + +@Getter +public class InsufficientFuelException extends RuntimeException { + + private final int requiredFuel; + private final int currentFuel; + + public InsufficientFuelException(int requiredFuel, int currentFuel) { + super(ErrorCode.INSUFFICIENT_FUEL.getMessage()); + this.requiredFuel = requiredFuel; + this.currentFuel = currentFuel; + } +} diff --git a/SS-Common/src/test/java/com/elipair/spacestudyship/common/exception/ErrorResponseTest.java b/SS-Common/src/test/java/com/elipair/spacestudyship/common/exception/ErrorResponseTest.java new file mode 100644 index 0000000..1ce94ef --- /dev/null +++ b/SS-Common/src/test/java/com/elipair/spacestudyship/common/exception/ErrorResponseTest.java @@ -0,0 +1,53 @@ +package com.elipair.spacestudyship.common.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ErrorResponseTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("of(ErrorCode): requiredFuel/currentFuel은 null") + void of_basic_nullFuelFields() { + ErrorResponse r = ErrorResponse.of(ErrorCode.PLANET_NOT_FOUND); + assertThat(r.code()).isEqualTo("PLANET_NOT_FOUND"); + assertThat(r.requiredFuel()).isNull(); + assertThat(r.currentFuel()).isNull(); + } + + @Test + @DisplayName("ofInsufficientFuel: 연료 수치 포함") + void ofInsufficientFuel_includesAmounts() { + ErrorResponse r = ErrorResponse.ofInsufficientFuel("연료가 부족합니다.", 10, 4); + assertThat(r.code()).isEqualTo("INSUFFICIENT_FUEL"); + assertThat(r.requiredFuel()).isEqualTo(10); + assertThat(r.currentFuel()).isEqualTo(4); + } + + @Test + @DisplayName("InsufficientFuelException: 게터로 수치 노출") + void exception_getters() { + InsufficientFuelException ex = new InsufficientFuelException(10, 4); + assertThat(ex.getRequiredFuel()).isEqualTo(10); + assertThat(ex.getCurrentFuel()).isEqualTo(4); + } + + @Test + @DisplayName("@JsonInclude(NON_NULL): null 연료 필드는 JSON에서 제외, 연료 필드 있으면 JSON에 포함") + void jsonInclude_nonNull_wireContract() throws Exception { + String basicJson = objectMapper.writeValueAsString(ErrorResponse.of(ErrorCode.MEMBER_NOT_FOUND)); + assertThat(basicJson).doesNotContain("requiredFuel"); + assertThat(basicJson).doesNotContain("currentFuel"); + + String fuelJson = objectMapper.writeValueAsString( + ErrorResponse.ofInsufficientFuel("연료가 부족합니다.", 10, 4)); + assertThat(fuelJson).contains("requiredFuel"); + assertThat(fuelJson).contains("currentFuel"); + assertThat(fuelJson).contains("10"); + assertThat(fuelJson).contains("4"); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeType.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeType.java new file mode 100644 index 0000000..8ce0396 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeType.java @@ -0,0 +1,15 @@ +package com.elipair.spacestudyship.study.exploration.constant; + +public enum NodeType { + PLANET, + REGION; + + /** DB 컬럼/JSON 직렬화용 소문자 표현 ("planet" / "region"). */ + public String value() { + return name().toLowerCase(); + } + + public static NodeType from(String value) { + return NodeType.valueOf(value.toUpperCase()); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeTypeConverter.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeTypeConverter.java new file mode 100644 index 0000000..57d7ab9 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeTypeConverter.java @@ -0,0 +1,18 @@ +package com.elipair.spacestudyship.study.exploration.constant; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class NodeTypeConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(NodeType attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public NodeType convertToEntityAttribute(String dbData) { + return dbData == null ? null : NodeType.from(dbData); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetResponse.java new file mode 100644 index 0000000..e41e156 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetResponse.java @@ -0,0 +1,36 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "행성 응답") +public record PlanetResponse( + String id, String name, String nodeType, int depth, String icon, + @Schema(nullable = true) String parentId, + @Schema(nullable = true) String prerequisiteId, + int requiredFuel, boolean isUnlocked, boolean isCleared, int sortOrder, + String description, double mapX, double mapY, + @Schema(nullable = true, example = "2026-04-01T00:00:00Z") String unlockedAt, + ProgressDto progress +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static PlanetResponse of(ExplorationNode n, boolean isUnlocked, boolean isCleared, + int clearedChildren, int totalChildren, double progressRatio, + LocalDateTime unlockedAt) { + return new PlanetResponse( + n.getId(), n.getName(), n.getNodeType().value(), n.getDepth(), n.getIcon(), + n.getParentId(), n.getPrerequisiteNodeId(), n.getRequiredFuel(), + isUnlocked, isCleared, n.getSortOrder(), n.getDescription(), n.getMapX(), n.getMapY(), + formatUtc(unlockedAt), + new ProgressDto(clearedChildren, totalChildren, progressRatio)); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetUnlockResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetUnlockResponse.java new file mode 100644 index 0000000..847411c --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetUnlockResponse.java @@ -0,0 +1,17 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "행성 해금 응답") +public record PlanetUnlockResponse( + UnlockedNodeDto planet, int fuelConsumed, int currentFuel +) { + public static PlanetUnlockResponse of(ExplorationNode planet, UserExploration progress, + int fuelConsumed, int currentFuel) { + return new PlanetUnlockResponse( + UnlockedNodeDto.of(planet, progress, false), + fuelConsumed, currentFuel); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/ProgressDto.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/ProgressDto.java new file mode 100644 index 0000000..fafe586 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/ProgressDto.java @@ -0,0 +1,10 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "행성 진행도") +public record ProgressDto( + @Schema(example = "3") int clearedChildren, + @Schema(example = "5") int totalChildren, + @Schema(example = "0.6") double progressRatio +) {} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionResponse.java new file mode 100644 index 0000000..fafcf58 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionResponse.java @@ -0,0 +1,32 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "지역 응답") +public record RegionResponse( + String id, String name, String nodeType, int depth, String icon, + @Schema(nullable = true) String parentId, + int requiredFuel, boolean isUnlocked, boolean isCleared, int sortOrder, + String description, double mapX, double mapY, + @Schema(nullable = true, example = "2026-04-05T15:30:00Z") String unlockedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static RegionResponse of(ExplorationNode n, boolean isUnlocked, boolean isCleared, + LocalDateTime unlockedAt) { + return new RegionResponse( + n.getId(), n.getName(), n.getNodeType().value(), n.getDepth(), n.getIcon(), + n.getParentId(), n.getRequiredFuel(), isUnlocked, isCleared, + n.getSortOrder(), n.getDescription(), n.getMapX(), n.getMapY(), + formatUtc(unlockedAt)); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionUnlockResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionUnlockResponse.java new file mode 100644 index 0000000..de8a512 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionUnlockResponse.java @@ -0,0 +1,17 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "지역 해금 응답") +public record RegionUnlockResponse( + UnlockedNodeDto region, int fuelConsumed, int currentFuel, boolean planetCleared +) { + public static RegionUnlockResponse of(ExplorationNode region, UserExploration progress, + int fuelConsumed, int currentFuel, boolean planetCleared) { + return new RegionUnlockResponse( + UnlockedNodeDto.of(region, progress, true), + fuelConsumed, currentFuel, planetCleared); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/UnlockedNodeDto.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/UnlockedNodeDto.java new file mode 100644 index 0000000..78577f1 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/UnlockedNodeDto.java @@ -0,0 +1,27 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "해금된 노드 요약") +public record UnlockedNodeDto( + String id, String name, boolean isUnlocked, boolean isCleared, + @Schema(example = "2026-04-16T11:00:00Z") String unlockedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static UnlockedNodeDto of(ExplorationNode node, UserExploration progress, boolean cleared) { + return new UnlockedNodeDto( + node.getId(), node.getName(), true, cleared, + formatUtc(progress.getUnlockedAt())); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNode.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNode.java new file mode 100644 index 0000000..31f51c2 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNode.java @@ -0,0 +1,61 @@ +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.constant.NodeTypeConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "exploration_nodes") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ExplorationNode { + + @Id + @Column(length = 50) + private String id; + + @Column(nullable = false, length = 50) + private String name; + + @Convert(converter = NodeTypeConverter.class) + @Column(name = "node_type", nullable = false, length = 10) + private NodeType nodeType; + + @Column(nullable = false) + private int depth; + + @Column(nullable = false, length = 30) + private String icon; + + @Column(name = "parent_id", length = 50) + private String parentId; + + @Column(name = "prerequisite_node_id", length = 50) + private String prerequisiteNodeId; + + @Column(name = "required_fuel", nullable = false) + private int requiredFuel; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @Column(nullable = false, length = 200) + private String description; + + @Column(name = "map_x", nullable = false) + private double mapX; + + @Column(name = "map_y", nullable = false) + private double mapY; +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/UserExploration.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/UserExploration.java new file mode 100644 index 0000000..7dc55c4 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/UserExploration.java @@ -0,0 +1,56 @@ +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_exploration_progress", + uniqueConstraints = @UniqueConstraint(name = "uq_user_expl", columnNames = {"user_id", "node_id"})) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserExploration extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "node_id", nullable = false, length = 50) + private String nodeId; + + @Column(name = "is_unlocked", nullable = false) + private boolean isUnlocked; + + @Column(name = "is_cleared", nullable = false) + private boolean isCleared; + + @Column(name = "unlocked_at", nullable = false) + private LocalDateTime unlockedAt; + + public static UserExploration unlock(Long userId, String nodeId, boolean cleared) { + return UserExploration.builder() + .userId(userId) + .nodeId(nodeId) + .isUnlocked(true) + .isCleared(cleared) + .unlockedAt(LocalDateTime.now()) + .build(); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepository.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepository.java new file mode 100644 index 0000000..167dcd3 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepository.java @@ -0,0 +1,14 @@ +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ExplorationNodeRepository extends JpaRepository { + + List findByNodeTypeOrderBySortOrderAsc(NodeType nodeType); + + List findByParentIdOrderBySortOrderAsc(String parentId); +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepository.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepository.java new file mode 100644 index 0000000..c07be0f --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepository.java @@ -0,0 +1,13 @@ +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface UserExplorationRepository extends JpaRepository { + + List findByUserId(Long userId); + + boolean existsByUserIdAndNodeId(Long userId, String nodeId); +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java new file mode 100644 index 0000000..78e9543 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java @@ -0,0 +1,172 @@ +package com.elipair.spacestudyship.study.exploration.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import com.elipair.spacestudyship.study.exploration.repository.ExplorationNodeRepository; +import com.elipair.spacestudyship.study.exploration.repository.UserExplorationRepository; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ExplorationService { + + private final ExplorationNodeRepository nodeRepository; + private final UserExplorationRepository userExplorationRepository; + private final FuelService fuelService; + + public List getPlanets(Long userId) { + List planets = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET); + List regions = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.REGION); + Map progress = progressMap(userId); + Set unlocked = progress.keySet(); + + Map totalByParent = regions.stream() + .collect(Collectors.groupingBy(ExplorationNode::getParentId, Collectors.counting())); + Map clearedByParent = regions.stream() + .filter(r -> r.getRequiredFuel() == 0 || unlocked.contains(r.getId())) + .collect(Collectors.groupingBy(ExplorationNode::getParentId, Collectors.counting())); + + return planets.stream().map(p -> { + int total = totalByParent.getOrDefault(p.getId(), 0L).intValue(); + int cleared = clearedByParent.getOrDefault(p.getId(), 0L).intValue(); + boolean isUnlocked = p.getRequiredFuel() == 0 || unlocked.contains(p.getId()); + boolean isCleared = total > 0 && cleared == total; + double ratio = total == 0 ? 0.0 : (double) cleared / total; + LocalDateTime unlockedAt = progress.containsKey(p.getId()) + ? progress.get(p.getId()).getUnlockedAt() : null; + return PlanetResponse.of(p, isUnlocked, isCleared, cleared, total, ratio, unlockedAt); + }).toList(); + } + + public List getRegions(Long userId, String planetId) { + nodeRepository.findById(planetId) + .filter(n -> n.getNodeType() == NodeType.PLANET) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc(planetId); + Map progress = progressMap(userId); + + return regions.stream().map(r -> { + UserExploration pr = progress.get(r.getId()); + boolean isUnlocked = r.getRequiredFuel() == 0 || pr != null; + LocalDateTime unlockedAt = pr == null ? null : pr.getUnlockedAt(); + return RegionResponse.of(r, isUnlocked, isUnlocked, unlockedAt); + }).toList(); + } + + private Map progressMap(Long userId) { + return userExplorationRepository.findByUserId(userId).stream() + .collect(Collectors.toMap(UserExploration::getNodeId, Function.identity())); + } + + @Transactional + public RegionUnlockResponse unlockRegion(Long userId, String regionId) { + ExplorationNode region = nodeRepository.findById(regionId) + .filter(n -> n.getNodeType() == NodeType.REGION) + .orElseThrow(() -> new CustomException(ErrorCode.REGION_NOT_FOUND)); + + ExplorationNode parent = nodeRepository.findById(region.getParentId()) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + boolean parentUnlocked = parent.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, parent.getId()); + if (!parentUnlocked) { + throw new CustomException(ErrorCode.PLANET_LOCKED); + } + + if (region.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, regionId)) { + throw new CustomException(ErrorCode.ALREADY_UNLOCKED); + } + + requireFuel(userId, region.getRequiredFuel()); + + FuelTransactionResponse fuelTx = fuelService.consume( + userId, region.getRequiredFuel(), FuelReason.EXPLORATION_UNLOCK, + regionId, UUID.randomUUID().toString()); + + UserExploration saved = userExplorationRepository.save( + UserExploration.unlock(userId, regionId, true)); + + boolean planetCleared = isPlanetCleared(userId, parent.getId()); + + log.info("[Exploration] 지역 해금 | userId={}, regionId={}, fuel={}, planetCleared={}", + userId, regionId, region.getRequiredFuel(), planetCleared); + + return RegionUnlockResponse.of(region, saved, + fuelTx.amount(), fuelTx.balanceAfter(), planetCleared); + } + + @Transactional + public PlanetUnlockResponse unlockPlanet(Long userId, String planetId) { + ExplorationNode planet = nodeRepository.findById(planetId) + .filter(n -> n.getNodeType() == NodeType.PLANET) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + if (planet.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, planetId)) { + throw new CustomException(ErrorCode.ALREADY_UNLOCKED); + } + + if (planet.getPrerequisiteNodeId() != null + && !isPlanetCleared(userId, planet.getPrerequisiteNodeId())) { + throw new CustomException(ErrorCode.PREREQUISITE_NOT_CLEARED); + } + + requireFuel(userId, planet.getRequiredFuel()); + + FuelTransactionResponse fuelTx = fuelService.consume( + userId, planet.getRequiredFuel(), FuelReason.EXPLORATION_UNLOCK, + planetId, UUID.randomUUID().toString()); + + UserExploration saved = userExplorationRepository.save( + UserExploration.unlock(userId, planetId, false)); + + log.info("[Exploration] 행성 해금 | userId={}, planetId={}, fuel={}", + userId, planetId, planet.getRequiredFuel()); + + return PlanetUnlockResponse.of(planet, saved, fuelTx.amount(), fuelTx.balanceAfter()); + } + + private void requireFuel(Long userId, int requiredFuel) { + int currentFuel = fuelService.getFuel(userId).currentFuel(); + if (currentFuel < requiredFuel) { + throw new InsufficientFuelException(requiredFuel, currentFuel); + } + } + + private boolean isPlanetCleared(Long userId, String planetId) { + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc(planetId); + if (regions.isEmpty()) { + return false; + } + Set unlocked = userExplorationRepository.findByUserId(userId).stream() + .map(UserExploration::getNodeId).collect(Collectors.toSet()); + // requiredFuel==0 지역은 암묵 해금(진행도 레코드 없음) — getRegions와 동일하게 클리어로 간주 + return regions.stream() + .allMatch(r -> r.getRequiredFuel() == 0 || unlocked.contains(r.getId())); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java index d7623b2..14d3bfb 100644 --- a/SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java @@ -22,7 +22,8 @@ @EnableJpaRepositories(basePackages = { "com.elipair.spacestudyship.study.todo.repository", "com.elipair.spacestudyship.study.fuel.repository", - "com.elipair.spacestudyship.study.timer.repository" + "com.elipair.spacestudyship.study.timer.repository", + "com.elipair.spacestudyship.study.exploration.repository" }) public class StudyTestApplication { } diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNodeTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNodeTest.java new file mode 100644 index 0000000..23ef9e6 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNodeTest.java @@ -0,0 +1,25 @@ +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExplorationNodeTest { + + @Test + @DisplayName("planet 빌더: 필드 매핑") + void buildsPlanet() { + ExplorationNode node = ExplorationNode.builder() + .id("earth").name("지구").nodeType(NodeType.PLANET).depth(2) + .icon("earth").parentId(null).prerequisiteNodeId(null) + .requiredFuel(0).sortOrder(0).description("시작점") + .mapX(0.5).mapY(0.08).build(); + + assertThat(node.getId()).isEqualTo("earth"); + assertThat(node.getNodeType()).isEqualTo(NodeType.PLANET); + assertThat(node.getRequiredFuel()).isZero(); + assertThat(node.getParentId()).isNull(); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/UserExplorationTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/UserExplorationTest.java new file mode 100644 index 0000000..5b234a9 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/UserExplorationTest.java @@ -0,0 +1,23 @@ +package com.elipair.spacestudyship.study.exploration.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserExplorationTest { + + @Test + @DisplayName("unlock 팩토리: isUnlocked=true, unlockedAt 세팅, cleared 반영") + void unlockFactory() { + UserExploration region = UserExploration.unlock(1L, "japan", true); + assertThat(region.getUserId()).isEqualTo(1L); + assertThat(region.getNodeId()).isEqualTo("japan"); + assertThat(region.isUnlocked()).isTrue(); + assertThat(region.isCleared()).isTrue(); + assertThat(region.getUnlockedAt()).isNotNull(); + + UserExploration planet = UserExploration.unlock(1L, "mars", false); + assertThat(planet.isCleared()).isFalse(); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepositoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepositoryTest.java new file mode 100644 index 0000000..2168a97 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepositoryTest.java @@ -0,0 +1,56 @@ +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class ExplorationNodeRepositoryTest { + + @Autowired + ExplorationNodeRepository nodeRepository; + + private ExplorationNode planet(String id, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.PLANET) + .depth(2).icon(id).requiredFuel(0).sortOrder(sort) + .description("").mapX(0).mapY(0).build(); + } + + private ExplorationNode region(String id, String parent, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.REGION) + .depth(3).icon(id).parentId(parent).requiredFuel(1).sortOrder(sort) + .description("").mapX(0).mapY(0).build(); + } + + @Test + @DisplayName("findByNodeTypeOrderBySortOrderAsc: 타입 필터 + 정렬") + void findByNodeType_sorted() { + nodeRepository.saveAll(List.of(planet("b", 1), planet("a", 0))); + nodeRepository.saveAll(List.of(region("r1", "a", 0))); + + List planets = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET); + + assertThat(planets).extracting(ExplorationNode::getId).containsExactly("a", "b"); + } + + @Test + @DisplayName("findByParentIdOrderBySortOrderAsc: 부모별 정렬 조회") + void findByParent_sorted() { + nodeRepository.save(planet("a", 0)); + nodeRepository.saveAll(List.of(region("r2", "a", 1), region("r1", "a", 0))); + + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc("a"); + + assertThat(regions).extracting(ExplorationNode::getId).containsExactly("r1", "r2"); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepositoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepositoryTest.java new file mode 100644 index 0000000..c58ace7 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepositoryTest.java @@ -0,0 +1,41 @@ +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class UserExplorationRepositoryTest { + + @Autowired + UserExplorationRepository repository; + + @Test + @DisplayName("findByUserId / existsByUserIdAndNodeId") + void findAndExists() { + repository.saveAndFlush(UserExploration.unlock(1L, "japan", true)); + + assertThat(repository.findByUserId(1L)).hasSize(1); + assertThat(repository.findByUserId(999L)).isEmpty(); + assertThat(repository.existsByUserIdAndNodeId(1L, "japan")).isTrue(); + assertThat(repository.existsByUserIdAndNodeId(1L, "mars")).isFalse(); + } + + @Test + @DisplayName("UNIQUE(user_id, node_id) 위반 시 예외") + void uniqueConstraint() { + repository.saveAndFlush(UserExploration.unlock(1L, "mars", false)); + + assertThatThrownBy(() -> + repository.saveAndFlush(UserExploration.unlock(1L, "mars", false))) + .isInstanceOf(Exception.class); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java new file mode 100644 index 0000000..9083f44 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java @@ -0,0 +1,296 @@ +package com.elipair.spacestudyship.study.exploration.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import com.elipair.spacestudyship.study.exploration.repository.ExplorationNodeRepository; +import com.elipair.spacestudyship.study.exploration.repository.UserExplorationRepository; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ExplorationServiceTest { + + @Mock ExplorationNodeRepository nodeRepository; + @Mock UserExplorationRepository userExplorationRepository; + @Mock FuelService fuelService; + @InjectMocks ExplorationService service; + + private ExplorationNode planet(String id, int requiredFuel, String prereq, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.PLANET).depth(2) + .icon(id).parentId(null).prerequisiteNodeId(prereq) + .requiredFuel(requiredFuel).sortOrder(sort).description("").mapX(0).mapY(0).build(); + } + + private ExplorationNode region(String id, String parent, int requiredFuel, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.REGION).depth(3) + .icon(id).parentId(parent).prerequisiteNodeId(null) + .requiredFuel(requiredFuel).sortOrder(sort).description("").mapX(0).mapY(0).build(); + } + + @Test + @DisplayName("getPlanets: earth는 requiredFuel=0이라 암묵 해금, 진행도 파생") + void getPlanets_derivesUnlockAndProgress() { + given(nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET)) + .willReturn(List.of(planet("earth", 0, null, 0), planet("mercury", 3, "earth", 1))); + given(nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.REGION)) + .willReturn(List.of(region("korea", "earth", 0, 0), + region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); + + List result = service.getPlanets(1L); + + PlanetResponse earth = result.get(0); + assertThat(earth.id()).isEqualTo("earth"); + assertThat(earth.isUnlocked()).isTrue(); + assertThat(earth.isCleared()).isFalse(); + assertThat(earth.progress().clearedChildren()).isEqualTo(1); + assertThat(earth.progress().totalChildren()).isEqualTo(2); + assertThat(earth.progress().progressRatio()).isEqualTo(0.5); + + PlanetResponse mercury = result.get(1); + assertThat(mercury.isUnlocked()).isFalse(); + assertThat(mercury.prerequisiteId()).isEqualTo("earth"); + } + + @Test + @DisplayName("getRegions: 행성 없으면 PLANET_NOT_FOUND") + void getRegions_planetNotFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getRegions(1L, "nope")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.PLANET_NOT_FOUND); + } + + @Test + @DisplayName("getRegions: 해금된 지역 isUnlocked/isCleared=true, korea(연료0) 암묵 해금") + void getRegions_mapsUnlock() { + given(nodeRepository.findById("earth")).willReturn(Optional.of(planet("earth", 0, null, 0))); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), + region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)).willReturn(List.of()); + + List result = service.getRegions(1L, "earth"); + + assertThat(result).extracting(RegionResponse::id).containsExactly("korea", "japan"); + assertThat(result.get(0).isUnlocked()).isTrue(); // korea requiredFuel=0 → 암묵 해금 + assertThat(result.get(0).isCleared()).isTrue(); + assertThat(result.get(1).isUnlocked()).isFalse(); // japan 미해금 + } + + private FuelResponse fuel(int currentFuel) { + return new FuelResponse(currentFuel, 0, 0, 0, null); + } + + private FuelTransactionResponse tx(int amount, int balanceAfter) { + return new FuelTransactionResponse( + "tx", "consume", amount, "EXPLORATION_UNLOCK", "ref", balanceAfter, null); + } + + @Test + @DisplayName("unlockRegion: 정상 해금 — 잔량충분 + 차감 + 저장 + 마지막 지역이면 planetCleared=true") + void unlockRegion_success_lastRegionClearsPlanet() { + given(nodeRepository.findById("japan")) + .willReturn(Optional.of(region("japan", "earth", 1, 1))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "japan")).willReturn(false); + given(fuelService.getFuel(1L)).willReturn(fuel(250)); + given(fuelService.consume(eq(1L), eq(1), eq(FuelReason.EXPLORATION_UNLOCK), eq("japan"), anyString())) + .willReturn(tx(1, 249)); + given(userExplorationRepository.save(any(UserExploration.class))) + .willAnswer(inv -> inv.getArgument(0)); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true), + UserExploration.unlock(1L, "japan", true))); + + var result = service.unlockRegion(1L, "japan"); + + assertThat(result.region().id()).isEqualTo("japan"); + assertThat(result.region().isCleared()).isTrue(); + assertThat(result.fuelConsumed()).isEqualTo(1); + assertThat(result.currentFuel()).isEqualTo(249); + assertThat(result.planetCleared()).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserExploration.class); + verify(userExplorationRepository).save(captor.capture()); + assertThat(captor.getValue().getNodeId()).isEqualTo("japan"); + assertThat(captor.getValue().isCleared()).isTrue(); + } + + @Test + @DisplayName("unlockRegion: 잔량 부족 → InsufficientFuelException + consume 미호출") + void unlockRegion_insufficientFuel() { + given(nodeRepository.findById("usa")) + .willReturn(Optional.of(region("usa", "earth", 3, 8))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "usa")).willReturn(false); + given(fuelService.getFuel(1L)).willReturn(fuel(1)); + + assertThatThrownBy(() -> service.unlockRegion(1L, "usa")) + .isInstanceOf(InsufficientFuelException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 부모 행성 미해금 → PLANET_LOCKED") + void unlockRegion_parentLocked() { + given(nodeRepository.findById("mars_olympus")) + .willReturn(Optional.of(region("mars_olympus", "mars", 3, 0))); + given(nodeRepository.findById("mars")) + .willReturn(Optional.of(planet("mars", 10, "venus", 3))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mars")).willReturn(false); + + assertThatThrownBy(() -> service.unlockRegion(1L, "mars_olympus")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.PLANET_LOCKED); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 이미 해금 → ALREADY_UNLOCKED") + void unlockRegion_alreadyUnlocked() { + given(nodeRepository.findById("japan")) + .willReturn(Optional.of(region("japan", "earth", 1, 1))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "japan")).willReturn(true); + + assertThatThrownBy(() -> service.unlockRegion(1L, "japan")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.ALREADY_UNLOCKED); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 없는 지역 → REGION_NOT_FOUND") + void unlockRegion_notFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.unlockRegion(1L, "nope")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.REGION_NOT_FOUND); + } + + @Test + @DisplayName("unlockPlanet: 선행 행성 클리어 시 정상 해금") + void unlockPlanet_success() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + // korea는 requiredFuel=0(암묵 해금) — 진행도 레코드가 없어도 earth는 클리어로 간주돼야 함 + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of()); + given(fuelService.getFuel(1L)).willReturn(fuel(100)); + given(fuelService.consume(eq(1L), eq(3), eq(FuelReason.EXPLORATION_UNLOCK), eq("mercury"), anyString())) + .willReturn(tx(3, 97)); + given(userExplorationRepository.save(any(UserExploration.class))) + .willAnswer(inv -> inv.getArgument(0)); + + var result = service.unlockPlanet(1L, "mercury"); + + assertThat(result.planet().id()).isEqualTo("mercury"); + assertThat(result.planet().isCleared()).isFalse(); + assertThat(result.fuelConsumed()).isEqualTo(3); + assertThat(result.currentFuel()).isEqualTo(97); + } + + @Test + @DisplayName("unlockPlanet: 선행 미클리어 → PREREQUISITE_NOT_CLEARED + consume 미호출") + void unlockPlanet_prerequisiteNotCleared() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); // 1/2만 + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.PREREQUISITE_NOT_CLEARED); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 잔량 부족 → InsufficientFuelException + consume 미호출") + void unlockPlanet_insufficientFuel() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); + given(fuelService.getFuel(1L)).willReturn(fuel(1)); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(InsufficientFuelException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 이미 해금 → ALREADY_UNLOCKED") + void unlockPlanet_alreadyUnlocked() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(true); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.ALREADY_UNLOCKED); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 없는 행성 → PLANET_NOT_FOUND") + void unlockPlanet_notFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "nope")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.PLANET_NOT_FOUND); + } +} diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration/ExplorationController.java b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration/ExplorationController.java new file mode 100644 index 0000000..916aeaa --- /dev/null +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration/ExplorationController.java @@ -0,0 +1,72 @@ +package com.elipair.spacestudyship.controller.exploration; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.exploration.service.ExplorationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Exploration", description = "우주 탐험(행성/지역 해금) API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/explorations") +public class ExplorationController { + + private final ExplorationService explorationService; + + @Operation(summary = "행성 목록 조회", + description = "전체 행성 목록과 유저의 해금/클리어 상태, 진행도를 반환합니다. 정렬: sortOrder 오름차순.") + @GetMapping("/planets") + public ResponseEntity> getPlanets(@AuthMember LoginMember loginMember) { + return ResponseEntity.ok(explorationService.getPlanets(loginMember.memberId())); + } + + @Operation(summary = "행성 하위 지역 목록 조회", + description = "특정 행성의 하위 지역과 유저 해금 상태를 반환합니다. 행성이 없으면 404 PLANET_NOT_FOUND.") + @GetMapping("/planets/{planetId}/regions") + public ResponseEntity> getRegions( + @AuthMember LoginMember loginMember, + @PathVariable String planetId) { + return ResponseEntity.ok(explorationService.getRegions(loginMember.memberId(), planetId)); + } + + @Operation(summary = "지역 해금", + description = """ + 연료를 소비하여 지역을 해금합니다(해금=클리어). 잔량 확인+차감+해금을 원자적으로 처리합니다. + 상위 행성의 모든 지역이 해금되면 planetCleared=true. + + 에러: 400 INSUFFICIENT_FUEL(requiredFuel/currentFuel 동봉) / ALREADY_UNLOCKED / PLANET_LOCKED, 404 REGION_NOT_FOUND + """) + @PostMapping("/regions/{regionId}/unlock") + public ResponseEntity unlockRegion( + @AuthMember LoginMember loginMember, + @PathVariable String regionId) { + return ResponseEntity.ok(explorationService.unlockRegion(loginMember.memberId(), regionId)); + } + + @Operation(summary = "행성 해금", + description = """ + 연료를 소비하여 행성을 해금합니다. 선행 행성을 클리어해야 해금할 수 있습니다. + + 에러: 400 INSUFFICIENT_FUEL(requiredFuel/currentFuel 동봉) / ALREADY_UNLOCKED / PREREQUISITE_NOT_CLEARED, 404 PLANET_NOT_FOUND + """) + @PostMapping("/planets/{planetId}/unlock") + public ResponseEntity unlockPlanet( + @AuthMember LoginMember loginMember, + @PathVariable String planetId) { + return ResponseEntity.ok(explorationService.unlockPlanet(loginMember.memberId(), planetId)); + } +} diff --git a/SS-Web/src/main/resources/application.yml b/SS-Web/src/main/resources/application.yml index 3546e20..06b0bf1 100644 --- a/SS-Web/src/main/resources/application.yml +++ b/SS-Web/src/main/resources/application.yml @@ -12,6 +12,9 @@ spring: flyway: enabled: true baseline-on-migrate: true + # 기존 비어있지 않은 DB(Hibernate가 생성)에서 baseline 기본값(1)이 0.0.x 마이그레이션을 + # 건너뛰는 것을 방지 — 0으로 두어 0.0.31~ 모든 마이그레이션이 적용되게 한다. + baseline-version: 0 locations: classpath:db/migration validate-on-migrate: false diff --git a/SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql b/SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql new file mode 100644 index 0000000..6d4ba03 --- /dev/null +++ b/SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql @@ -0,0 +1,82 @@ +-- exploration_nodes: 행성/지역 마스터 (시드, 읽기 전용) +CREATE TABLE IF NOT EXISTS exploration_nodes ( + id VARCHAR(50) PRIMARY KEY, + name VARCHAR(50) NOT NULL, + node_type VARCHAR(10) NOT NULL, + depth INTEGER NOT NULL, + icon VARCHAR(30) NOT NULL, + parent_id VARCHAR(50), + prerequisite_node_id VARCHAR(50), + required_fuel INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + description VARCHAR(200) NOT NULL DEFAULT '', + map_x DOUBLE PRECISION NOT NULL DEFAULT 0, + map_y DOUBLE PRECISION NOT NULL DEFAULT 0, + CONSTRAINT fk_expl_node_parent FOREIGN KEY (parent_id) REFERENCES exploration_nodes(id), + CONSTRAINT fk_expl_node_prerequisite FOREIGN KEY (prerequisite_node_id) REFERENCES exploration_nodes(id), + CONSTRAINT chk_expl_node_type CHECK (node_type IN ('planet','region')), + CONSTRAINT chk_expl_required_fuel_non_negative CHECK (required_fuel >= 0) +); + +-- user_exploration_progress: 유저별 해금 상태 (행 존재 = 해금) +CREATE TABLE IF NOT EXISTS user_exploration_progress ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + node_id VARCHAR(50) NOT NULL, + is_unlocked BOOLEAN NOT NULL DEFAULT TRUE, + is_cleared BOOLEAN NOT NULL DEFAULT FALSE, + unlocked_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_user_expl_member FOREIGN KEY (user_id) REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT fk_user_expl_node FOREIGN KEY (node_id) REFERENCES exploration_nodes(id), + CONSTRAINT uq_user_expl UNIQUE (user_id, node_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_expl_user ON user_exploration_progress (user_id); + +-- 시드: 행성 8 (행성 먼저) +INSERT INTO exploration_nodes (id, name, node_type, depth, icon, parent_id, prerequisite_node_id, required_fuel, sort_order, description, map_x, map_y) VALUES + ('earth', '지구', 'planet', 2, 'earth', NULL, NULL, 0, 0, '우리의 출발지, 고향 행성', 0.5, 0.08), + ('mercury', '수성', 'planet', 2, 'mercury', NULL, 'earth', 3, 1, '태양에 가장 가까운 작은 행성', 0.15, 0.20), + ('venus', '금성', 'planet', 2, 'venus', NULL, 'mercury', 5, 2, '두꺼운 대기로 뒤덮인 뜨거운 행성', 0.75, 0.32), + ('mars', '화성', 'planet', 2, 'mars', NULL, 'venus', 10, 3, '붉은 행성, 탐험의 꿈', 0.25, 0.44), + ('jupiter', '목성', 'planet', 2, 'jupiter', NULL, 'mars', 20, 4, '태양계 최대의 가스 행성', 0.7, 0.56), + ('saturn', '토성', 'planet', 2, 'saturn', NULL, 'jupiter', 30, 5, '아름다운 고리를 가진 행성', 0.2, 0.68), + ('uranus', '천왕성', 'planet', 2, 'uranus', NULL, 'saturn', 45, 6, '옆으로 누워 자전하는 얼음 행성', 0.8, 0.80), + ('neptune', '해왕성', 'planet', 2, 'neptune', NULL, 'uranus', 60, 7, '태양계 끝자락의 푸른 행성', 0.35, 0.92) +ON CONFLICT (id) DO NOTHING; + +-- 시드: 지역 30 (지구 12 + 그 외 18). region은 prerequisite NULL, map 0/0. +INSERT INTO exploration_nodes (id, name, node_type, depth, icon, parent_id, prerequisite_node_id, required_fuel, sort_order, description, map_x, map_y) VALUES + ('korea', '대한민국', 'region', 3, 'KR', 'earth', NULL, 0, 0, '한반도 남쪽, K-컬쳐의 중심', 0, 0), + ('japan', '일본', 'region', 3, 'JP', 'earth', NULL, 1, 1, '벚꽃과 기술의 나라', 0, 0), + ('thailand', '태국', 'region', 3, 'TH', 'earth', NULL, 1, 2, '미소의 나라, 동남아의 허브', 0, 0), + ('china', '중국', 'region', 3, 'CN', 'earth', NULL, 2, 3, '세계 최대 인구 대국', 0, 0), + ('india', '인도', 'region', 3, 'IN', 'earth', NULL, 2, 4, 'IT 강국, 다양한 문화의 보고', 0, 0), + ('uk', '영국', 'region', 3, 'GB', 'earth', NULL, 2, 5, '해가 지지 않는 나라', 0, 0), + ('france', '프랑스', 'region', 3, 'FR', 'earth', NULL, 2, 6, '예술과 낭만의 나라', 0, 0), + ('canada', '캐나다', 'region', 3, 'CA', 'earth', NULL, 2, 7, '단풍과 자연의 나라', 0, 0), + ('usa', '미국', 'region', 3, 'US', 'earth', NULL, 3, 8, '자유의 나라, 기회의 땅', 0, 0), + ('brazil', '브라질', 'region', 3, 'BR', 'earth', NULL, 3, 9, '삼바와 축구의 나라', 0, 0), + ('australia', '호주', 'region', 3, 'AU', 'earth', NULL, 3, 10, '코알라와 캥거루의 대륙', 0, 0), + ('egypt', '이집트', 'region', 3, 'EG', 'earth', NULL, 2, 11, '피라미드와 나일강의 나라', 0, 0), + ('mercury_caloris', '칼로리스 분지', 'region', 3, 'mercury', 'mercury', NULL, 1, 0, '수성 최대의 충돌 분지', 0, 0), + ('mercury_plains', '북극 평원', 'region', 3, 'mercury', 'mercury', NULL, 2, 1, '얼음이 숨겨진 영구 그림자 지대', 0, 0), + ('venus_ishtar', '이슈타르 대지', 'region', 3, 'venus', 'venus', NULL, 2, 0, '금성 북반구의 거대한 고원 지대', 0, 0), + ('venus_aphrodite', '아프로디테 대지','region', 3, 'venus', 'venus', NULL, 3, 1, '금성 적도를 따라 펼쳐진 최대 대지', 0, 0), + ('venus_maxwell', '맥스웰 산', 'region', 3, 'venus', 'venus', NULL, 3, 2, '금성에서 가장 높은 산맥', 0, 0), + ('mars_olympus', '올림푸스 산', 'region', 3, 'mars', 'mars', NULL, 3, 0, '태양계에서 가장 높은 화산', 0, 0), + ('mars_valles', '마리너 계곡', 'region', 3, 'mars', 'mars', NULL, 4, 1, '태양계 최대의 협곡', 0, 0), + ('mars_polar', '극관 지대', 'region', 3, 'mars', 'mars', NULL, 5, 2, '드라이아이스와 물 얼음의 극지방', 0, 0), + ('jupiter_red_spot', '대적점', 'region', 3, 'jupiter', 'jupiter', NULL, 5, 0, '수백 년간 지속되는 거대 폭풍', 0, 0), + ('jupiter_europa', '유로파', 'region', 3, 'jupiter', 'jupiter', NULL, 7, 1, '얼음 아래 바다가 있는 위성', 0, 0), + ('jupiter_io', '이오', 'region', 3, 'jupiter', 'jupiter', NULL, 8, 2, '화산 활동이 가장 활발한 위성', 0, 0), + ('saturn_rings', '토성 고리', 'region', 3, 'saturn', 'saturn', NULL, 8, 0, '얼음과 먼지로 이루어진 아름다운 고리', 0, 0), + ('saturn_titan', '타이탄', 'region', 3, 'saturn', 'saturn', NULL, 10, 1, '대기를 가진 유일한 위성, 메탄의 호수', 0, 0), + ('saturn_enceladus', '엔셀라두스', 'region', 3, 'saturn', 'saturn', NULL, 12, 2, '간헐천이 분출하는 얼음 위성', 0, 0), + ('uranus_miranda', '미란다', 'region', 3, 'uranus', 'uranus', NULL, 12, 0, '기괴한 지형의 작은 위성', 0, 0), + ('uranus_atmosphere', '천왕성 대기', 'region', 3, 'uranus', 'uranus', NULL, 15, 1, '메탄이 만드는 청록빛 대기', 0, 0), + ('neptune_dark_spot', '대흑점', 'region', 3, 'neptune', 'neptune', NULL, 15, 0, '초속 2000km 폭풍의 소용돌이', 0, 0), + ('neptune_triton', '트리톤', 'region', 3, 'neptune', 'neptune', NULL, 20, 1, '역행 궤도를 도는 거대 위성', 0, 0) +ON CONFLICT (id) DO NOTHING; diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration/ExplorationControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration/ExplorationControllerTest.java new file mode 100644 index 0000000..11a0cf7 --- /dev/null +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration/ExplorationControllerTest.java @@ -0,0 +1,170 @@ +package com.elipair.spacestudyship.controller.exploration; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.UnlockedNodeDto; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.service.ExplorationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class ExplorationControllerTest { + + @Mock ExplorationService explorationService; + @InjectMocks ExplorationController controller; + + MockMvc mockMvc; + + private ExplorationNode planetNode() { + return ExplorationNode.builder().id("earth").name("지구").nodeType(NodeType.PLANET) + .depth(2).icon("earth").requiredFuel(0).sortOrder(0) + .description("시작점").mapX(0.5).mapY(0.08).build(); + } + + private ExplorationNode regionNode() { + return ExplorationNode.builder().id("korea").name("대한민국").nodeType(NodeType.REGION) + .depth(3).icon("KR").parentId("earth").requiredFuel(0).sortOrder(0) + .description("한반도").mapX(0).mapY(0).build(); + } + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver loginMemberStub = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return new LoginMember(1L); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(loginMemberStub) + .build(); + } + + @Test + @DisplayName("GET /api/explorations/planets — 200, nodeType 소문자") + void getPlanets_200() throws Exception { + given(explorationService.getPlanets(1L)).willReturn(List.of( + PlanetResponse.of(planetNode(), true, false, 1, 2, 0.5, null))); + + mockMvc.perform(get("/api/explorations/planets")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("earth")) + .andExpect(jsonPath("$[0].nodeType").value("planet")) + .andExpect(jsonPath("$[0].isUnlocked").value(true)) + .andExpect(jsonPath("$[0].progress.totalChildren").value(2)); + } + + @Test + @DisplayName("GET /api/explorations/planets/{id}/regions — 200") + void getRegions_200() throws Exception { + given(explorationService.getRegions(1L, "earth")).willReturn(List.of( + RegionResponse.of(regionNode(), true, true, null))); + + mockMvc.perform(get("/api/explorations/planets/earth/regions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("korea")) + .andExpect(jsonPath("$[0].nodeType").value("region")); + } + + @Test + @DisplayName("GET regions — 행성 없음 404 PLANET_NOT_FOUND") + void getRegions_404() throws Exception { + given(explorationService.getRegions(1L, "nope")) + .willThrow(new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + mockMvc.perform(get("/api/explorations/planets/nope/regions")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PLANET_NOT_FOUND")) + .andExpect(jsonPath("$.requiredFuel").doesNotExist()); + } + + @Test + @DisplayName("POST /api/explorations/regions/{id}/unlock — 200") + void unlockRegion_200() throws Exception { + given(explorationService.unlockRegion(1L, "japan")).willReturn( + new RegionUnlockResponse( + new UnlockedNodeDto("japan", "일본", true, true, "2026-04-16T11:00:00Z"), + 1, 249, false)); + + mockMvc.perform(post("/api/explorations/regions/japan/unlock")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.region.id").value("japan")) + .andExpect(jsonPath("$.fuelConsumed").value(1)) + .andExpect(jsonPath("$.currentFuel").value(249)) + .andExpect(jsonPath("$.planetCleared").value(false)); + } + + @Test + @DisplayName("POST region unlock — 연료 부족 400 + requiredFuel/currentFuel 본문") + void unlockRegion_insufficientFuel_400() throws Exception { + willThrow(new InsufficientFuelException(3, 1)) + .given(explorationService).unlockRegion(1L, "usa"); + + mockMvc.perform(post("/api/explorations/regions/usa/unlock")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INSUFFICIENT_FUEL")) + .andExpect(jsonPath("$.requiredFuel").value(3)) + .andExpect(jsonPath("$.currentFuel").value(1)); + } + + @Test + @DisplayName("POST /api/explorations/planets/{id}/unlock — 200") + void unlockPlanet_200() throws Exception { + given(explorationService.unlockPlanet(1L, "mercury")).willReturn( + new PlanetUnlockResponse( + new UnlockedNodeDto("mercury", "수성", true, false, "2026-04-16T11:30:00Z"), + 3, 97)); + + mockMvc.perform(post("/api/explorations/planets/mercury/unlock")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.planet.id").value("mercury")) + .andExpect(jsonPath("$.fuelConsumed").value(3)) + .andExpect(jsonPath("$.currentFuel").value(97)); + } + + @Test + @DisplayName("POST planet unlock — 선행 미클리어 400 PREREQUISITE_NOT_CLEARED") + void unlockPlanet_prerequisite_400() throws Exception { + willThrow(new CustomException(ErrorCode.PREREQUISITE_NOT_CLEARED)) + .given(explorationService).unlockPlanet(1L, "mercury"); + + mockMvc.perform(post("/api/explorations/planets/mercury/unlock")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PREREQUISITE_NOT_CLEARED")); + } +} diff --git a/docs/api-specs/05_exploration.md b/docs/api-specs/05_exploration.md index fc8bdfd..8b1a2cb 100644 --- a/docs/api-specs/05_exploration.md +++ b/docs/api-specs/05_exploration.md @@ -13,19 +13,23 @@ ``` 태양계 (고정) - ├── 지구 (planet) ─ 해금됨 - │ ├── 대한민국 (region) ─ 해금됨 - │ ├── 일본 (region) ─ 잠김 (100연료) - │ └── 미국 (region) ─ 잠김 (100연료) - ├── 화성 (planet) ─ 잠김 (200연료) - │ ├── 올림푸스 (region) - │ └── 마리너 (region) - └── ... + ├── 지구 (planet, fuel=0) ─ 기본 해금 + │ ├── 대한민국 (region, fuel=0) ─ 기본 해금 + │ ├── 일본 (region, fuel=1) + │ └── ... (총 12개 지역) + ├── 수성 (planet, fuel=3) ─ 지구 클리어 후 해금 가능 + ├── 금성 (planet, fuel=5) ─ 수성 클리어 후 해금 가능 + ├── 화성 (planet, fuel=10) ─ 금성 클리어 후 해금 가능 + ├── 목성 (planet, fuel=20) ─ 화성 클리어 후 해금 가능 + ├── 토성 (planet, fuel=30) ─ 목성 클리어 후 해금 가능 + ├── 천왕성 (planet, fuel=45) ─ 토성 클리어 후 해금 가능 + └── 해왕성 (planet, fuel=60) ─ 천왕성 클리어 후 해금 가능 ``` ### 해금 규칙 -- **행성 해금**: 연료를 소비하여 행성에 진입 가능 상태로 변경. 지구는 기본 해금. +- **행성 해금**: 연료를 소비하여 행성에 진입 가능 상태로 변경. 지구는 기본 해금 (`requiredFuel=0`). +- **행성 진행 게이트**: 행성은 선행 행성(`prerequisiteId`)을 클리어해야 해금. 지구는 선행 없음 (체인: 지구→수성→금성→화성→목성→토성→천왕성→해왕성). - **지역 해금**: 행성이 해금된 상태에서 연료를 소비하여 지역 해금 (= 클리어). - **행성 클리어**: 행성의 모든 하위 지역이 해금되면 자동으로 행성 클리어 처리. - 연료 차감은 해금 API 내부에서 원자적으로 처리됩니다 (별도 fuel consume 호출 불필요). @@ -33,6 +37,9 @@ ### 시드 데이터 행성/지역 마스터 데이터는 서버에서 시드로 관리합니다. ID는 고정 문자열입니다. +- **행성 ID**: `earth`, `mercury`, `venus`, `mars`, `jupiter`, `saturn`, `uranus`, `neptune` (총 8개) +- **지역 ID**: 이름 기반 문자열 (예: `korea`, `japan`, `mars_olympus`) +- **icon 값**: 지구 지역은 국가 코드 (예: `KR`, `JP`), 그 외 행성/행성 지역은 행성 이름 (예: `mars`, `jupiter`) --- @@ -59,13 +66,14 @@ "depth": 2, "icon": "earth", "parentId": null, + "prerequisiteId": null, "requiredFuel": 0, "isUnlocked": true, "isCleared": false, "sortOrder": 0, - "description": "모든 여정의 시작점", + "description": "우리의 출발지, 고향 행성", "mapX": 0.5, - "mapY": 0.3, + "mapY": 0.08, "unlockedAt": "2026-04-01T00:00:00Z" } ``` @@ -76,8 +84,9 @@ | `name` | String | X | 노드 이름 | | `nodeType` | String | X | `"planet"` 또는 `"region"` | | `depth` | Integer | X | 계층 깊이 (planet=2, region=3) | -| `icon` | String | X | 아이콘 식별자 (행성: 이름, 지역: 국가코드) | +| `icon` | String | X | 아이콘 식별자 (지구 지역: 국가코드, 그 외: 행성이름) | | `parentId` | String | O | 상위 노드 ID (행성은 null) | +| `prerequisiteId` | String | O | 선행 행성 ID (행성만, 이 행성을 해금하려면 선행 행성을 클리어해야 함). region은 null | | `requiredFuel` | Integer | X | 해금에 필요한 연료량 (0이면 기본 해금) | | `isUnlocked` | Boolean | X | 해금 여부 | | `isCleared` | Boolean | X | 클리어 여부 (지역: 해금=클리어, 행성: 모든 지역 해금 시 클리어) | @@ -91,7 +100,7 @@ | 값 | 설명 | 해금 조건 | 클리어 조건 | |----|------|----------|-----------| -| `planet` | 행성 | 연료 소비 | 모든 하위 region 해금 시 자동 클리어 | +| `planet` | 행성 | 연료 소비 + 선행 행성 클리어 | 모든 하위 region 해금 시 자동 클리어 | | `region` | 지역 | 연료 소비 (상위 행성 해금 필수) | 해금 = 클리어 | --- @@ -119,38 +128,40 @@ "depth": 2, "icon": "earth", "parentId": null, + "prerequisiteId": null, "requiredFuel": 0, "isUnlocked": true, "isCleared": false, "sortOrder": 0, - "description": "모든 여정의 시작점", + "description": "우리의 출발지, 고향 행성", "mapX": 0.5, - "mapY": 0.3, + "mapY": 0.08, "unlockedAt": "2026-04-01T00:00:00Z", "progress": { "clearedChildren": 3, - "totalChildren": 5, - "progressRatio": 0.6 + "totalChildren": 12, + "progressRatio": 0.25 } }, { - "id": "mars", - "name": "화성", + "id": "mercury", + "name": "수성", "nodeType": "planet", "depth": 2, - "icon": "mars", + "icon": "mercury", "parentId": null, - "requiredFuel": 200, + "prerequisiteId": "earth", + "requiredFuel": 3, "isUnlocked": false, "isCleared": false, "sortOrder": 1, - "description": "붉은 행성", - "mapX": 0.8, - "mapY": 0.5, + "description": "태양에 가장 가까운 작은 행성", + "mapX": 0.15, + "mapY": 0.20, "unlockedAt": null, "progress": { "clearedChildren": 0, - "totalChildren": 3, + "totalChildren": 2, "progressRatio": 0.0 } } @@ -194,35 +205,35 @@ GET /api/explorations/planets/earth/regions ```json [ { - "id": "region-kr", + "id": "korea", "name": "대한민국", "nodeType": "region", "depth": 3, "icon": "KR", "parentId": "earth", - "requiredFuel": 100, + "requiredFuel": 0, "isUnlocked": true, "isCleared": true, "sortOrder": 0, - "description": "한반도의 남쪽", - "mapX": 0.7, - "mapY": 0.4, + "description": "한반도 남쪽, K-컬쳐의 중심", + "mapX": 0.0, + "mapY": 0.0, "unlockedAt": "2026-04-05T15:30:00Z" }, { - "id": "region-jp", + "id": "japan", "name": "일본", "nodeType": "region", "depth": 3, "icon": "JP", "parentId": "earth", - "requiredFuel": 100, + "requiredFuel": 1, "isUnlocked": false, "isCleared": false, "sortOrder": 1, - "description": "해가 뜨는 나라", - "mapX": 0.8, - "mapY": 0.3, + "description": "벚꽃과 기술의 나라", + "mapX": 0.0, + "mapY": 0.0, "unlockedAt": null } ] @@ -257,7 +268,7 @@ GET /api/explorations/planets/earth/regions ### Request Body: 없음 ``` -POST /api/explorations/regions/region-jp/unlock +POST /api/explorations/regions/japan/unlock ``` ### Response @@ -267,14 +278,14 @@ POST /api/explorations/regions/region-jp/unlock ```json { "region": { - "id": "region-jp", + "id": "japan", "name": "일본", "isUnlocked": true, "isCleared": true, "unlockedAt": "2026-04-16T11:00:00Z" }, - "fuelConsumed": 100, - "currentFuel": 250, + "fuelConsumed": 1, + "currentFuel": 25, "planetCleared": false } ``` @@ -295,6 +306,12 @@ POST /api/explorations/regions/region-jp/unlock | 400 | `PLANET_LOCKED` | 상위 행성이 아직 해금되지 않음 | | 404 | `REGION_NOT_FOUND` | regionId에 해당하는 지역 없음 | +**INSUFFICIENT_FUEL 응답 본문 예시:** + +```json +{ "code": "INSUFFICIENT_FUEL", "message": "연료가 부족합니다.", "requiredFuel": 10, "currentFuel": 4 } +``` + ### 서버 처리 로직 ``` @@ -331,7 +348,7 @@ COMMIT; ### Request Body: 없음 ``` -POST /api/explorations/planets/mars/unlock +POST /api/explorations/planets/mercury/unlock ``` ### Response @@ -341,13 +358,13 @@ POST /api/explorations/planets/mars/unlock ```json { "planet": { - "id": "mars", - "name": "화성", + "id": "mercury", + "name": "수성", "isUnlocked": true, "isCleared": false, "unlockedAt": "2026-04-16T11:30:00Z" }, - "fuelConsumed": 200, + "fuelConsumed": 3, "currentFuel": 50 } ``` @@ -364,14 +381,22 @@ POST /api/explorations/planets/mars/unlock |--------|------|------| | 400 | `INSUFFICIENT_FUEL` | 연료 잔량 부족 | | 400 | `ALREADY_UNLOCKED` | 이미 해금된 행성 | +| 400 | `PREREQUISITE_NOT_CLEARED` | 선행 행성이 아직 클리어되지 않음 | | 404 | `PLANET_NOT_FOUND` | planetId에 해당하는 행성 없음 | +**INSUFFICIENT_FUEL 응답 본문 예시:** + +```json +{ "code": "INSUFFICIENT_FUEL", "message": "연료가 부족합니다.", "requiredFuel": 10, "currentFuel": 4 } +``` + ### 서버 처리 로직 ``` BEGIN TRANSACTION; 1. planetId로 행성 마스터 데이터 조회 2. 이미 해금된 행성인지 확인 + 2-1. prerequisiteId가 있으면 선행 행성이 클리어(모든 하위 지역 해금)되었는지 확인 → 아니면 PREREQUISITE_NOT_CLEARED 3. 유저 연료 잔량 >= requiredFuel 확인 4. 연료 차감 5. 연료 거래 내역 생성 (type: consume, reason: EXPLORATION_UNLOCK, referenceId: planetId) @@ -387,24 +412,42 @@ COMMIT; | 컬럼 | 타입 | 설명 | |------|------|------| -| `id` | VARCHAR(50) (PK) | 노드 ID (earth, mars, region-kr 등) | +| `id` | VARCHAR(50) (PK) | 노드 ID (이름 기반 고정 문자열: `earth`, `korea`, `mars_olympus` 등) | | `name` | VARCHAR(50) | 노드 이름 | | `node_type` | VARCHAR(10) | planet / region | | `depth` | INTEGER | 계층 깊이 | -| `icon` | VARCHAR(20) | 아이콘 식별자 | +| `icon` | VARCHAR(30) | 아이콘 식별자 (지구 지역: 국가코드 예: `KR`, 그 외: 행성이름 예: `mars`) | | `parent_id` | VARCHAR(50) (FK → self) | 상위 노드 ID | +| `prerequisite_node_id` | VARCHAR(50) (FK → self) | 선행 행성 ID (행성만, 지역은 NULL) | | `required_fuel` | INTEGER | 해금 필요 연료 | | `sort_order` | INTEGER | 표시 순서 | | `description` | VARCHAR(200) | 설명 | | `map_x` | DOUBLE | 맵 가로 위치 | | `map_y` | DOUBLE | 맵 세로 위치 | +**행성 시드 (8개):** + +| id | name | required_fuel | prerequisite_node_id | sort_order | +|----|------|:---:|---|:---:| +| `earth` | 지구 | 0 | NULL | 0 | +| `mercury` | 수성 | 3 | `earth` | 1 | +| `venus` | 금성 | 5 | `mercury` | 2 | +| `mars` | 화성 | 10 | `venus` | 3 | +| `jupiter` | 목성 | 20 | `mars` | 4 | +| `saturn` | 토성 | 30 | `jupiter` | 5 | +| `uranus` | 천왕성 | 45 | `saturn` | 6 | +| `neptune` | 해왕성 | 60 | `uranus` | 7 | + +**지역 시드 (30개, required_fuel 범위: 0~20):** + +지역 ID는 이름 기반 문자열 (예: `korea`, `japan`, `mars_olympus`). 지구 12개, 그 외 행성 각 2~3개. + ### user_exploration_progress (유저별 진행 상태) | 컬럼 | 타입 | 설명 | |------|------|------| | `id` | BIGINT (PK) | | -| `user_id` | BIGINT (FK → users) | 유저 ID | +| `user_id` | BIGINT (FK → members) | 유저 ID | | `node_id` | VARCHAR(50) (FK → exploration_nodes) | 노드 ID | | `is_unlocked` | BOOLEAN | 해금 여부 | | `is_cleared` | BOOLEAN | 클리어 여부 | diff --git a/docs/api-specs/exploration-frontend-requirements.md b/docs/api-specs/exploration-frontend-requirements.md new file mode 100644 index 0000000..cf64c97 --- /dev/null +++ b/docs/api-specs/exploration-frontend-requirements.md @@ -0,0 +1,241 @@ +# 행성 탐험 — 프론트 통합 요구사항 (Frontend → Backend API 요청) + +> **작성:** 2026-05-29 +> **대상 기능:** Exploration (행성/지역 탐험) +> **성격:** Flutter(프론트)가 백엔드에 요구하는 API 계약 명세. 백엔드 `docs/api-specs/05_exploration.md`와 대조·정합을 맞추기 위한 문서. +> **관련 코드:** `lib/features/exploration/` + +--- + +## 1. 목적 & 범위 + +### 목적 + +Flutter 앱의 탐험 기능을 **게스트 로컬 모드 → 회원 서버 연동**으로 전환하기 위해, 프론트가 소비할 API 계약을 프론트 관점에서 정의한다. 현재 프론트는 `ExplorationLocalRepositoryImpl`(SharedPreferences + 시드 데이터)만 구현돼 있고, 회원용 `ExplorationRemoteRepositoryImpl`은 미구현 상태다. 이 문서는 그 Remote 구현의 입력 계약이 된다. + +### 범위 (In scope) + +- 회원(소셜 로그인, JWT 인증) 사용자가 사용하는 서버 API 계약 +- 프론트가 렌더링·상태표시에 필요한 데이터 필드 명세 +- 해금 동작의 요청/응답/에러 계약 + +### 비범위 (Out of scope) — 명시적 제외 + +- **게스트 데이터 마이그레이션 없음.** 게스트는 100% 로컬(SharedPreferences) 전용이다. 로그인/회원전환 시 게스트의 로컬 진행도를 서버로 올리는 동기화·병합 로직은 **요구하지 않는다.** 게스트 데이터는 삭제 시 그대로 소멸한다. +- 따라서 "guest progress → server sync" 같은 별도 엔드포인트는 불필요하다. + +--- + +## 2. 인증 & 게스트/회원 경계 + +| 구분 | 데이터 소스 | 인증 | 비고 | +|------|------------|------|------| +| 게스트 | 로컬 (SharedPreferences) | 없음 | 순수 로컬, 서버 호출 없음, 마이그레이션 없음 | +| 회원 | 서버 API (`/api/explorations/**`) | JWT 필요 | 이 문서가 정의하는 API | + +- 이 API의 모든 엔드포인트는 **JWT 인증 필수**다. +- 게스트와 회원의 진행 상태는 완전히 분리된다. 서로 연동되지 않는다. + +--- + +## 3. 프론트가 소비할 데이터 모델 + +프론트는 응답 노드를 `ExplorationNodeEntity`로 매핑한다. 아래는 프론트가 **렌더링·상태판정에 실제로 사용하는** 필드와 요구 사항이다. + +### 3.1 탐험 노드 필드 + +| 필드 | 타입 | Nullable | 프론트 용도 | 현재 프론트 entity 상태 | +|------|------|----------|------------|------------------------| +| `id` | String | X | 노드 식별, 해금 API 호출 키 | 있음 | +| `name` | String | X | 노드 이름 표시 | 있음 | +| `nodeType` | String (`planet`/`region`) | X | planet/region 분기 렌더링 | 있음 (enum: galaxy/starSystem/planet/region) | +| `depth` | Integer | X | 계층 깊이 (planet=2, region=3) | 있음 | +| `icon` | String | X | 아이콘 렌더링 (6번 섹션 참조) | 있음 | +| `parentId` | String | O | 지역의 상위 행성 (planet은 null) | 있음 | +| `prerequisiteId` | String | O | **선행 행성 게이트 표시** (planet만, region은 null) | **없음 — 추가 필요** | +| `requiredFuel` | Integer | X | 해금 비용 표시 (0이면 기본 해금) | 있음 | +| `isUnlocked` | Boolean | X | 잠김/해금 UI 상태 | 있음 | +| `isCleared` | Boolean | X | 클리어 배지·진행 표시 | 있음 | +| `sortOrder` | Integer | X | 표시 순서 정렬 | 있음 | +| `description` | String | X | 노드 설명 표시 | 있음 | +| `mapX` | Double | X | 맵상 가로 위치 (0.0~1.0) | 있음 | +| `mapY` | Double | X | 맵상 세로 위치 (0.0~1.0) | 있음 | +| `unlockedAt` | String(ISO8601) | O | 해금 시각 (null=미해금) | 있음 (DateTime?) | + +> **요구:** 백엔드 노드 응답은 위 필드를 모두 포함해야 한다. 특히 `prerequisiteId`는 프론트 entity에 아직 없으므로 추가 작업 대상이며, 응답 스키마에 반드시 포함돼야 한다. + +### 3.2 진행도(progress) 객체 — 행성 목록 응답에 포함 + +프론트 `ExplorationProgressEntity`와 매핑된다. + +| 필드 | 타입 | 프론트 용도 | +|------|------|------------| +| `clearedChildren` | Integer | 진행 바 "n / m" 표시 | +| `totalChildren` | Integer | 진행 바 분모 | +| `progressRatio` | Double (0.0~1.0) | 진행 바 비율 (프론트도 계산 가능, 서버 제공 시 그대로 사용) | + +> **요구:** 행성 목록 응답의 각 행성에 `progress` 객체가 포함돼야 한다. (프론트는 행성별 하위 지역 클리어 수를 별도 호출 없이 목록에서 바로 표시하고 싶음.) + +--- + +## 4. 필요 엔드포인트 (프론트 관점 계약) + +Base Path: `/api/explorations` + +| # | Method | Path | 프론트 호출 시점 | +|---|--------|------|-----------------| +| 1 | GET | `/planets` | 탐험 화면 진입 시 (행성 맵 렌더링) | +| 2 | GET | `/planets/{planetId}/regions` | 행성 상세 진입 시 (지역 목록 렌더링) | +| 3 | POST | `/regions/{regionId}/unlock` | 지역 해금 버튼 탭 | +| 4 | POST | `/planets/{planetId}/unlock` | 행성 해금 버튼 탭 | + +### 4.1 GET `/planets` — 행성 목록 + +- **호출 시점:** 탐험 메인 화면 진입, 해금 직후 갱신 +- **응답:** 전체 행성 배열. 각 행성은 3.1 필드 + 3.2 `progress` 포함. `sortOrder` 오름차순. +- **프론트 반영:** 행성 노드를 `mapX/mapY`로 맵에 배치, 잠김/해금/클리어 상태로 스타일 분기, 진행 바 표시. + +### 4.2 GET `/planets/{planetId}/regions` — 지역 목록 + +- **호출 시점:** 특정 행성 상세 진입 +- **응답:** 해당 행성 하위 지역 배열 (3.1 필드, region은 `prerequisiteId=null`). `sortOrder` 오름차순. +- **프론트 반영:** 지역 카드/노드 목록, 해금 비용·상태 표시. + +### 4.3 POST `/regions/{regionId}/unlock` — 지역 해금 + +- **요청:** Body 없음. Path에 `regionId`. +- **기대 응답(200):** + ```json + { + "region": { "id": "...", "name": "...", "isUnlocked": true, "isCleared": true, "unlockedAt": "..." }, + "fuelConsumed": 4, + "currentFuel": 250, + "planetCleared": false + } + ``` +- **프론트 반영:** + - `currentFuel`로 연료 게이지 즉시 갱신 (별도 fuel 조회 불필요) + - `region.isUnlocked/isCleared`로 해당 지역 상태 갱신 + - `planetCleared=true`면 상위 행성 클리어 연출 트리거 +- **요구:** 연료 차감은 서버에서 원자적으로 처리. 프론트는 별도 fuel consume API를 호출하지 않는다. + +### 4.4 POST `/planets/{planetId}/unlock` — 행성 해금 + +- **요청:** Body 없음. Path에 `planetId`. +- **기대 응답(200):** + ```json + { + "planet": { "id": "...", "name": "...", "isUnlocked": true, "isCleared": false, "unlockedAt": "..." }, + "fuelConsumed": 12, + "currentFuel": 50 + } + ``` +- **프론트 반영:** `currentFuel` 게이지 갱신, 행성 잠김 해제 연출. + +--- + +## 5. 에러 / 엣지케이스 계약 요청 + +프론트는 아래 상황별로 **사용자에게 다른 메시지/처리**를 보여줘야 하므로, 서버는 식별 가능한 `code`를 반환해야 한다. (공통 에러 포맷은 `00_common.md` 기준) + +| 엔드포인트 | Status | code | 프론트 처리 | +|-----------|--------|------|------------| +| 지역 해금 | 400 | `INSUFFICIENT_FUEL` | "연료가 부족해요" + 필요/보유 연료 안내 | +| 지역 해금 | 400 | `ALREADY_UNLOCKED` | 이미 해금됨 — 무음 처리 또는 상태 재동기화 | +| 지역 해금 | 400 | `PLANET_LOCKED` | "먼저 행성을 해금해야 해요" | +| 지역 해금 | 404 | `REGION_NOT_FOUND` | 데이터 오류 안내 + 목록 새로고침 | +| 행성 해금 | 400 | `INSUFFICIENT_FUEL` | "연료가 부족해요" | +| 행성 해금 | 400 | `ALREADY_UNLOCKED` | 이미 해금됨 — 무음/재동기화 | +| 행성 해금 | 400 | `PREREQUISITE_NOT_CLEARED` | "선행 행성을 먼저 클리어해야 해요" | +| 행성 해금 | 404 | `PLANET_NOT_FOUND` | 데이터 오류 안내 + 목록 새로고침 | +| 지역 목록 | 404 | `PLANET_NOT_FOUND` | 데이터 오류 안내 | + +> **요구:** +> - `INSUFFICIENT_FUEL` 응답에는 가능하면 `requiredFuel`, `currentFuel`을 함께 담아 프론트가 정확한 안내 문구를 만들 수 있게 해줄 것. +> - 에러 응답 본문 스키마(`code`, `message` 키)를 `00_common.md`와 일치시킬 것. + +--- + +## 6. 필드 정합성 요청 (icon / 좌표 / prerequisiteId) + +### 6.1 `icon` 값 규칙 — 프론트 렌더링 의존 + +프론트는 `icon` 값으로 두 가지 렌더링 분기를 이미 구현해 두었다: + +- **행성 / 비(非)지구 지역:** 행성 이름 식별자 사용 — `earth`, `mercury`, `venus`, `mars`, `jupiter`, `saturn`, `uranus`, `neptune` +- **지구 하위 지역:** ISO 3166-1 alpha-2 **국가 코드** 사용 — `KR`, `JP`, `TH`, `CN`, `IN`, `GB`, `FR`, `CA`, `US`, `BR`, `AU`, `EG` (국기 아이콘 렌더링) + +> **요구:** 서버 시드의 `icon` 값은 위 어휘(vocabulary)를 벗어나지 않아야 한다. 프론트가 모르는 `icon` 값이 오면 렌더링 폴백 처리만 가능하다. 새 노드 추가 시 icon 값 규칙을 프론트와 합의할 것. + +### 6.2 좌표 체계 `mapX` / `mapY` + +- 둘 다 `0.0 ~ 1.0` 정규화 비율. 프론트가 화면 크기에 곱해 배치한다. +- **요구:** 모든 행성 노드는 화면 안에 들어오는 좌표를 가져야 한다(겹침 최소화). 지역 노드 좌표는 현재 프론트에서 필수 사용은 아니지만, 응답에는 포함할 것(기본값 허용). + +### 6.3 `prerequisiteId` 추가 + +- 프론트 entity에 아직 없음. 백엔드가 선행 행성 게이트를 구현한다면 프론트도 entity·UI에 추가해야 한다. +- **요구:** 행성 노드 응답에 `prerequisiteId`(없으면 null) 포함. 이 값으로 프론트는 "선행 행성 클리어 필요" 잠금 사유를 표시한다. + +--- + +## 7. 동기화 Tier + +- 탐험 해금은 **Tier 2 (Server-Validated)**: 연료 잔량 확인·차감·해금이 서버에서 원자적으로 처리된다. 해금 동작은 **온라인 필수.** +- 행성/지역 목록 조회는 응답을 로컬에 **읽기 캐시**로 저장. 오프라인 시 캐시를 표시하되 "오프라인" 상태를 노출한다. +- **요구:** 해금 API는 오프라인에서 호출 불가하므로, 네트워크 실패 시 프론트가 명확히 구분할 수 있는 에러(타임아웃/네트워크)를 반환할 것. 부분 성공(연료만 차감되고 해금 실패 등)이 없도록 트랜잭션 보장. + +--- + +## 8. 현행 vs 요구 갭 체크리스트 + +프론트 현행 코드/구버전 spec과 신버전 백엔드 spec(`05_exploration.md`) 사이의 차이. **백엔드(본인)가 확정·정합을 맞춰야 할 항목.** + +### 8.1 데이터 모델 갭 + +- [ ] 프론트 `ExplorationNodeEntity`에 `prerequisiteId` 필드 추가 (현재 없음) +- [ ] 프론트 `exploration_node_entity.dart` icon 주석이 이모지(`🌍`) 기준 — 실제 구현은 식별자/국가코드. 주석 정리 필요 +- [ ] 행성 목록 응답의 `progress` 객체 ↔ 프론트 `ExplorationProgressEntity` 매핑 확인 + +### 8.2 시드 로스터 갭 (프론트 시드 vs 백엔드 spec) + +> ⚠️ 아래 표의 "백엔드 spec 예시" 칼럼은 작성 시점(구버전) 스냅샷이다. **본 PR(#27)에서 서버 시드를 프론트 게스트 시드와 1:1로 일치시켜 이 갭은 모두 해소되었다** (8행성/30지역, 이름 기반 ID, 선행 게이트, 연료 수치 동일). 표는 갭 이력 참고용으로 남긴다. + +> 프론트 `exploration_seed_data.dart`(로컬/게스트용)와 백엔드 spec 예시가 크게 다르다. 회원용 서버 시드를 어느 쪽 기준으로 확정할지 결정 필요. + +| 항목 | 프론트 시드 (게스트 로컬) | 백엔드 spec 예시 | +|------|--------------------------|------------------| +| 행성 구성 | 지구·수성·금성·화성·목성·토성·천왕성·해왕성 (8개, **달 없음**) | 지구·달·화성 (예시 3개) | +| 진행 게이트 | **선행조건 없음** (연료만 있으면 해금) | 선행 체인 (지구→달→화성, `prerequisiteId`) | +| 행성 연료 | earth 0 / mercury 3 / venus 5 / mars 10 / jupiter 20 / saturn 30 / uranus 45 / neptune 60 | earth 0 / moon 8 / mars 12 | +| 지구 지역 | 12개 (korea, japan, thailand, china, india, uk, france, canada, usa, brazil, australia, egypt) | 2개 예시 (대한민국, 일본) | +| 지역 ID 규칙 | `korea`, `japan`, `usa` (이름 기반) | `region-kr`, `region-jp` (prefix 기반) | +| 지구 지역 연료 | 0~3 | 4~6 | + +> **결정 필요 (백엔드 본인 확인):** +> - [ ] 회원용 서버 시드의 **행성 로스터**를 확정 (8행성 전체인지, 달 포함 여부) +> - [ ] **진행 게이트 모델** 확정 — 선행 행성 클리어 게이트를 쓸지(`prerequisiteId`), 연료만으로 해금할지. 프론트 게스트는 현재 게이트 없음 +> - [ ] **지역 ID 네이밍** 통일 (`korea` vs `region-kr`) — 게스트/회원 코드 재사용 위해 한쪽으로 정렬 권장 +> - [ ] **연료 밸런스** 확정 후 게스트 시드와 회원 서버 시드 동기화 + +### 8.3 spec 문서 정합 + +- [ ] 프론트 레포 `docs/api-specs/05_exploration.md`(구버전: 일본/미국 100연료, 화성 200연료, `prerequisiteId`/`progress` 없음)를 백엔드 신버전(4/6/8/12, prerequisite, progress)으로 갱신 + +--- + +## 9. 백엔드 확인 요청 요약 + +본인(백엔드)에게 확정 요청하는 핵심 항목: + +1. **노드 응답 스키마에 `prerequisiteId` 포함** (없으면 null) +2. **행성 목록 응답에 `progress` 객체 포함** +3. **`icon` 값 어휘 고정** (행성 이름 식별자 + 지구 지역 ISO 국가코드) — 6.1 어휘표 합의 +4. **에러 `code` 명세 확정** (5번 표) + `INSUFFICIENT_FUEL`에 `requiredFuel/currentFuel` 동봉 +5. **해금 응답에 `currentFuel` 포함** (프론트가 fuel 별도 조회 안 하도록) +6. **회원용 서버 시드 확정** — 행성 로스터/진행 게이트/지역 ID 네이밍/연료 밸런스 (8.2) +7. **해금 트랜잭션 원자성 보장** + 오프라인/네트워크 실패 구분 가능한 에러 + +--- + +> 게스트는 오직 로컬, 회원은 오직 서버. 이 경계만 지키면 마이그레이션 고민 없이 두 모드를 독립적으로 유지할 수 있다. diff --git a/docs/superpowers/plans/2026-05-29-exploration-domain.md b/docs/superpowers/plans/2026-05-29-exploration-domain.md new file mode 100644 index 0000000..3deba75 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-exploration-domain.md @@ -0,0 +1,1951 @@ +# 탐험(Exploration) 도메인 재구현 Implementation Plan (frontend 계약 정합) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 우주 탐험(행성→지역 트리)을 연료로 해금하는 도메인을, 프론트 게스트 시드와 1:1 일치하는 시드 + 진행 게이트 + INSUFFICIENT_FUEL 응답 보강으로 구현한다. + +**Architecture:** SS-Study 모듈에 `exploration/` 패키지, Controller만 SS-Web. 마스터 노드(`ExplorationNode`)는 시드 전용 read-only, 유저 진행(`UserExploration`)은 행 존재=해금. 행성 클리어/진행도는 조회 시 파생. 해금은 `@Transactional`로 `FuelService.consume`와 동일 트랜잭션, UNIQUE(user_id,node_id)로 멱등성. INSUFFICIENT_FUEL은 `requiredFuel`/`currentFuel`을 본문에 동봉. + +**Tech Stack:** Java 21, Spring Boot 4, Spring Data JPA, Lombok, JUnit5+Mockito+AssertJ, Testcontainers(Postgres), Flyway, springdoc. + +**Spec:** `docs/superpowers/specs/2026-05-29-exploration-domain-design.md` +**프론트 시드 원본:** Flutter 레포 `lib/features/exploration/data/seed/exploration_seed_data.dart` + +**공통 규칙:** +- 테스트: `./gradlew :SS-Study:test`, `./gradlew :SS-Web:test`, `./gradlew :SS-Common:test`. 단일: `--tests "FQCN"`. +- 테스트 환경 = Testcontainers + `ddl-auto=create-drop` (엔티티가 스키마 생성, Flyway 비활성). `members` FK는 엔티티에 매핑하지 않음(마이그레이션에만 존재). +- 커밋 형식: `탐험 도메인 구현 : {type} : {설명} #27`. 이슈번호 #27. **이모지 금지. Co-Authored-By 금지.** + +--- + +## File Structure + +**SS-Common** +- Modify: `.../common/exception/ErrorCode.java` — 탐험 에러 5종 추가 +- Modify: `.../common/exception/ErrorResponse.java` — nullable `requiredFuel`/`currentFuel` + `@JsonInclude(NON_NULL)` +- Create: `.../common/exception/InsufficientFuelException.java` +- Modify: `.../common/exception/GlobalExceptionHandler.java` — `InsufficientFuelException` 핸들러 + +**SS-Study** (`.../study/exploration/`) +- `constant/NodeType.java`, `constant/NodeTypeConverter.java` +- `entity/ExplorationNode.java`, `entity/UserExploration.java` +- `repository/ExplorationNodeRepository.java`, `repository/UserExplorationRepository.java` +- `dto/` 6 records +- `service/ExplorationService.java` +- Modify: `SS-Study/src/test/.../study/StudyTestApplication.java` — repo 패키지 등록 + +**SS-Web** +- `controller/exploration/ExplorationController.java` + +**리소스/문서** +- `SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql` — 스키마 + 시드 38노드 +- Modify: `docs/api-specs/05_exploration.md` + +--- + +## Task 0: Working tree 폐기 (clean 재시작) + +**목적:** 이전 구현(프론트 계약과 어긋난)을 전부 제거하고 main 기준 clean 상태로 되돌린다. (복구 필요 시 reflog `87b0fc8`) + +**Files:** (없음 — 정리 작업) + +- [ ] **Step 1: 추적 파일 수정분 되돌리기** + +```bash +cd /Users/luca/workspace/Java_Spring/space_study_ship +git checkout -- . +``` + +- [ ] **Step 2: 미추적 구현 파일/마이그레이션 삭제 (docs/superpowers는 보존)** + +```bash +rm -rf SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration +rm -rf SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration +rm -rf SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration +rm -rf SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration +rm -f SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql +``` + +- [ ] **Step 3: clean 상태 확인 (docs/superpowers/* 외에 변경 없어야 함)** + +Run: `git status -s` +Expected: `docs/superpowers/specs/...` 및 `docs/superpowers/plans/...` (untracked)만 표시. exploration 관련 코드/마이그레이션 흔적 없음. + +- [ ] **Step 4: clean 상태에서 전체 테스트 green 확인 (회귀 베이스라인)** + +Run: `./gradlew :SS-Common:test :SS-Study:test :SS-Web:test` +Expected: BUILD SUCCESSFUL + +커밋 없음(정리 단계). + +--- + +## Task 1: 에러 인프라 (ErrorCode 5종 + ErrorResponse 보강 + InsufficientFuelException + 핸들러) + +**Files:** +- Modify: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java` +- Modify: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java` +- Create: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/InsufficientFuelException.java` +- Modify: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java` +- Test: `SS-Common/src/test/java/com/elipair/spacestudyship/common/exception/ErrorResponseTest.java` + +- [ ] **Step 1: 실패 테스트 작성 (ErrorResponse 팩토리 + 예외 게터)** + +```java +package com.elipair.spacestudyship.common.exception; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ErrorResponseTest { + + @Test + @DisplayName("of(ErrorCode): requiredFuel/currentFuel은 null") + void of_basic_nullFuelFields() { + ErrorResponse r = ErrorResponse.of(ErrorCode.PLANET_NOT_FOUND); + assertThat(r.code()).isEqualTo("PLANET_NOT_FOUND"); + assertThat(r.requiredFuel()).isNull(); + assertThat(r.currentFuel()).isNull(); + } + + @Test + @DisplayName("ofInsufficientFuel: 연료 수치 포함") + void ofInsufficientFuel_includesAmounts() { + ErrorResponse r = ErrorResponse.ofInsufficientFuel("연료가 부족합니다.", 10, 4); + assertThat(r.code()).isEqualTo("INSUFFICIENT_FUEL"); + assertThat(r.requiredFuel()).isEqualTo(10); + assertThat(r.currentFuel()).isEqualTo(4); + } + + @Test + @DisplayName("InsufficientFuelException: 게터로 수치 노출") + void exception_getters() { + InsufficientFuelException ex = new InsufficientFuelException(10, 4); + assertThat(ex.getRequiredFuel()).isEqualTo(10); + assertThat(ex.getCurrentFuel()).isEqualTo(4); + } +} +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `./gradlew :SS-Common:test --tests "com.elipair.spacestudyship.common.exception.ErrorResponseTest"` +Expected: FAIL — `ofInsufficientFuel` / `InsufficientFuelException` 없음(컴파일 에러) + +- [ ] **Step 3: ErrorCode에 5종 추가** + +`ErrorCode.java`에서 `// Timer` 블록 뒤(또는 `// Common` 앞)에 추가: + +```java + // Exploration + PLANET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 행성을 찾을 수 없습니다."), + REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 지역을 찾을 수 없습니다."), + ALREADY_UNLOCKED(HttpStatus.BAD_REQUEST, "이미 해금된 노드입니다."), + PLANET_LOCKED(HttpStatus.BAD_REQUEST, "상위 행성이 아직 해금되지 않았습니다."), + PREREQUISITE_NOT_CLEARED(HttpStatus.BAD_REQUEST, "이전 행성을 먼저 클리어해야 합니다."), +``` + +- [ ] **Step 4: ErrorResponse 보강** + +`ErrorResponse.java` 전체를 아래로 교체: + +```java +package com.elipair.spacestudyship.common.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ErrorResponse( + String code, + String message, + Integer requiredFuel, + Integer currentFuel +) { + public static ErrorResponse of(ErrorCode errorCode) { + return new ErrorResponse(errorCode.name(), errorCode.getMessage(), null, null); + } + + public static ErrorResponse of(ErrorCode errorCode, String message) { + return new ErrorResponse(errorCode.name(), message, null, null); + } + + public static ErrorResponse ofInsufficientFuel(String message, int requiredFuel, int currentFuel) { + return new ErrorResponse(ErrorCode.INSUFFICIENT_FUEL.name(), message, requiredFuel, currentFuel); + } +} +``` + +- [ ] **Step 5: InsufficientFuelException 생성** + +```java +package com.elipair.spacestudyship.common.exception; + +import lombok.Getter; + +@Getter +public class InsufficientFuelException extends RuntimeException { + + private final int requiredFuel; + private final int currentFuel; + + public InsufficientFuelException(int requiredFuel, int currentFuel) { + super(ErrorCode.INSUFFICIENT_FUEL.getMessage()); + this.requiredFuel = requiredFuel; + this.currentFuel = currentFuel; + } +} +``` + +- [ ] **Step 6: GlobalExceptionHandler에 핸들러 추가** + +`GlobalExceptionHandler.java`의 `handleCustomException` 메서드 바로 뒤에 추가: + +```java + @ExceptionHandler(InsufficientFuelException.class) + public ResponseEntity handleInsufficientFuel(InsufficientFuelException ex) { + log.info("[Exception] 연료 부족 | required={}, current={}", ex.getRequiredFuel(), ex.getCurrentFuel()); + return ResponseEntity + .status(ErrorCode.INSUFFICIENT_FUEL.getHttpStatus()) + .body(ErrorResponse.ofInsufficientFuel( + ErrorCode.INSUFFICIENT_FUEL.getMessage(), + ex.getRequiredFuel(), ex.getCurrentFuel())); + } +``` + +- [ ] **Step 7: 테스트 통과 확인 + 회귀 확인** + +Run: `./gradlew :SS-Common:test` +Expected: PASS (신규 ErrorResponseTest 포함, 기존 회귀 없음) + +- [ ] **Step 8: Commit** + +```bash +git add SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ SS-Common/src/test/java/com/elipair/spacestudyship/common/exception/ErrorResponseTest.java +git commit -m "탐험 도메인 구현 : feat : 탐험 ErrorCode 5종 + INSUFFICIENT_FUEL 응답 보강 #27" +``` + +--- + +## Task 2: NodeType enum + Converter + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeType.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeTypeConverter.java` + +- [ ] **Step 1: NodeType enum** + +```java +package com.elipair.spacestudyship.study.exploration.constant; + +public enum NodeType { + PLANET, + REGION; + + /** DB 컬럼/JSON 직렬화용 소문자 표현 ("planet" / "region"). */ + public String value() { + return name().toLowerCase(); + } + + public static NodeType from(String value) { + return NodeType.valueOf(value.toUpperCase()); + } +} +``` + +- [ ] **Step 2: Converter** + +```java +package com.elipair.spacestudyship.study.exploration.constant; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class NodeTypeConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(NodeType attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public NodeType convertToEntityAttribute(String dbData) { + return dbData == null ? null : NodeType.from(dbData); + } +} +``` + +- [ ] **Step 3: 컴파일 확인** + +Run: `./gradlew :SS-Study:compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/ +git commit -m "탐험 도메인 구현 : feat : NodeType enum + Converter 추가 #27" +``` + +--- + +## Task 3: ExplorationNode 엔티티 (마스터) + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNode.java` +- Test: `SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNodeTest.java` + +- [ ] **Step 1: 실패 테스트** + +```java +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExplorationNodeTest { + + @Test + @DisplayName("planet 빌더: 필드 매핑") + void buildsPlanet() { + ExplorationNode node = ExplorationNode.builder() + .id("earth").name("지구").nodeType(NodeType.PLANET).depth(2) + .icon("earth").parentId(null).prerequisiteNodeId(null) + .requiredFuel(0).sortOrder(0).description("시작점") + .mapX(0.5).mapY(0.08).build(); + + assertThat(node.getId()).isEqualTo("earth"); + assertThat(node.getNodeType()).isEqualTo(NodeType.PLANET); + assertThat(node.getRequiredFuel()).isZero(); + assertThat(node.getParentId()).isNull(); + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.entity.ExplorationNodeTest"` +Expected: FAIL — 클래스 없음 + +- [ ] **Step 3: 엔티티 구현 (BaseTimeEntity 미상속)** + +```java +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.constant.NodeTypeConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "exploration_nodes") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ExplorationNode { + + @Id + @Column(length = 50) + private String id; + + @Column(nullable = false, length = 50) + private String name; + + @Convert(converter = NodeTypeConverter.class) + @Column(name = "node_type", nullable = false, length = 10) + private NodeType nodeType; + + @Column(nullable = false) + private int depth; + + @Column(nullable = false, length = 30) + private String icon; + + @Column(name = "parent_id", length = 50) + private String parentId; + + @Column(name = "prerequisite_node_id", length = 50) + private String prerequisiteNodeId; + + @Column(name = "required_fuel", nullable = false) + private int requiredFuel; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @Column(nullable = false, length = 200) + private String description; + + @Column(name = "map_x", nullable = false) + private double mapX; + + @Column(name = "map_y", nullable = false) + private double mapY; +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.entity.ExplorationNodeTest"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNode.java SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNodeTest.java +git commit -m "탐험 도메인 구현 : feat : ExplorationNode 마스터 엔티티 추가 #27" +``` + +--- + +## Task 4: UserExploration 엔티티 + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/UserExploration.java` +- Test: `SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/UserExplorationTest.java` + +- [ ] **Step 1: 실패 테스트** + +```java +package com.elipair.spacestudyship.study.exploration.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserExplorationTest { + + @Test + @DisplayName("unlock 팩토리: isUnlocked=true, unlockedAt 세팅, cleared 반영") + void unlockFactory() { + UserExploration region = UserExploration.unlock(1L, "japan", true); + assertThat(region.getUserId()).isEqualTo(1L); + assertThat(region.getNodeId()).isEqualTo("japan"); + assertThat(region.isUnlocked()).isTrue(); + assertThat(region.isCleared()).isTrue(); + assertThat(region.getUnlockedAt()).isNotNull(); + + UserExploration planet = UserExploration.unlock(1L, "mars", false); + assertThat(planet.isCleared()).isFalse(); + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.entity.UserExplorationTest"` +Expected: FAIL — 클래스 없음 + +- [ ] **Step 3: 엔티티 구현** + +```java +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_exploration_progress", + uniqueConstraints = @UniqueConstraint(name = "uq_user_expl", columnNames = {"user_id", "node_id"})) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserExploration extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "node_id", nullable = false, length = 50) + private String nodeId; + + @Column(name = "is_unlocked", nullable = false) + private boolean isUnlocked; + + @Column(name = "is_cleared", nullable = false) + private boolean isCleared; + + @Column(name = "unlocked_at", nullable = false) + private LocalDateTime unlockedAt; + + public static UserExploration unlock(Long userId, String nodeId, boolean cleared) { + return UserExploration.builder() + .userId(userId) + .nodeId(nodeId) + .isUnlocked(true) + .isCleared(cleared) + .unlockedAt(LocalDateTime.now()) + .build(); + } +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.entity.UserExplorationTest"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/UserExploration.java SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/UserExplorationTest.java +git commit -m "탐험 도메인 구현 : feat : UserExploration 진행 엔티티 추가 #27" +``` + +--- + +## Task 5: Repository 2종 + 테스트 + +**Files:** +- Create: `.../exploration/repository/ExplorationNodeRepository.java` +- Create: `.../exploration/repository/UserExplorationRepository.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java` +- Test: `.../exploration/repository/ExplorationNodeRepositoryTest.java`, `UserExplorationRepositoryTest.java` + +- [ ] **Step 1: Repository 인터페이스** + +`ExplorationNodeRepository.java`: + +```java +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ExplorationNodeRepository extends JpaRepository { + + List findByNodeTypeOrderBySortOrderAsc(NodeType nodeType); + + List findByParentIdOrderBySortOrderAsc(String parentId); +} +``` + +`UserExplorationRepository.java`: + +```java +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface UserExplorationRepository extends JpaRepository { + + List findByUserId(Long userId); + + boolean existsByUserIdAndNodeId(Long userId, String nodeId); +} +``` + +- [ ] **Step 2: StudyTestApplication 패키지 등록** + +`@EnableJpaRepositories` basePackages 배열에 추가: + +```java +@EnableJpaRepositories(basePackages = { + "com.elipair.spacestudyship.study.todo.repository", + "com.elipair.spacestudyship.study.fuel.repository", + "com.elipair.spacestudyship.study.timer.repository", + "com.elipair.spacestudyship.study.exploration.repository" +}) +``` + +- [ ] **Step 3: 실패 테스트 — ExplorationNodeRepositoryTest** + +```java +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class ExplorationNodeRepositoryTest { + + @Autowired + ExplorationNodeRepository nodeRepository; + + private ExplorationNode planet(String id, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.PLANET) + .depth(2).icon(id).requiredFuel(0).sortOrder(sort) + .description("").mapX(0).mapY(0).build(); + } + + private ExplorationNode region(String id, String parent, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.REGION) + .depth(3).icon(id).parentId(parent).requiredFuel(1).sortOrder(sort) + .description("").mapX(0).mapY(0).build(); + } + + @Test + @DisplayName("findByNodeTypeOrderBySortOrderAsc: 타입 필터 + 정렬") + void findByNodeType_sorted() { + nodeRepository.saveAll(List.of(planet("b", 1), planet("a", 0))); + nodeRepository.saveAll(List.of(region("r1", "a", 0))); + + List planets = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET); + + assertThat(planets).extracting(ExplorationNode::getId).containsExactly("a", "b"); + } + + @Test + @DisplayName("findByParentIdOrderBySortOrderAsc: 부모별 정렬 조회") + void findByParent_sorted() { + nodeRepository.save(planet("a", 0)); + nodeRepository.saveAll(List.of(region("r2", "a", 1), region("r1", "a", 0))); + + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc("a"); + + assertThat(regions).extracting(ExplorationNode::getId).containsExactly("r1", "r2"); + } +} +``` + +- [ ] **Step 4: 실패 테스트 — UserExplorationRepositoryTest** + +```java +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class UserExplorationRepositoryTest { + + @Autowired + UserExplorationRepository repository; + + @Test + @DisplayName("findByUserId / existsByUserIdAndNodeId") + void findAndExists() { + repository.saveAndFlush(UserExploration.unlock(1L, "japan", true)); + + assertThat(repository.findByUserId(1L)).hasSize(1); + assertThat(repository.findByUserId(999L)).isEmpty(); + assertThat(repository.existsByUserIdAndNodeId(1L, "japan")).isTrue(); + assertThat(repository.existsByUserIdAndNodeId(1L, "mars")).isFalse(); + } + + @Test + @DisplayName("UNIQUE(user_id, node_id) 위반 시 예외") + void uniqueConstraint() { + repository.saveAndFlush(UserExploration.unlock(1L, "mars", false)); + + assertThatThrownBy(() -> + repository.saveAndFlush(UserExploration.unlock(1L, "mars", false))) + .isInstanceOf(Exception.class); + } +} +``` + +- [ ] **Step 5: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.repository.*"` +Expected: FAIL — Repository 미존재(컴파일 에러) + +- [ ] **Step 6: 통과 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.repository.*"` +Expected: PASS (2 클래스) + +- [ ] **Step 7: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/ SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/ SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java +git commit -m "탐험 도메인 구현 : feat : Exploration Repository 2종 + 테스트 #27" +``` + +--- + +## Task 6: DTO 6종 + +**Files:** (모두 `SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/`) + +- [ ] **Step 1: ProgressDto** + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "행성 진행도") +public record ProgressDto( + @Schema(example = "3") int clearedChildren, + @Schema(example = "5") int totalChildren, + @Schema(example = "0.6") double progressRatio +) {} +``` + +- [ ] **Step 2: PlanetResponse** + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "행성 응답") +public record PlanetResponse( + String id, String name, String nodeType, int depth, String icon, + @Schema(nullable = true) String parentId, + @Schema(nullable = true) String prerequisiteId, + int requiredFuel, boolean isUnlocked, boolean isCleared, int sortOrder, + String description, double mapX, double mapY, + @Schema(nullable = true, example = "2026-04-01T00:00:00Z") String unlockedAt, + ProgressDto progress +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static PlanetResponse of(ExplorationNode n, boolean isUnlocked, boolean isCleared, + int clearedChildren, int totalChildren, double progressRatio, + LocalDateTime unlockedAt) { + return new PlanetResponse( + n.getId(), n.getName(), n.getNodeType().value(), n.getDepth(), n.getIcon(), + n.getParentId(), n.getPrerequisiteNodeId(), n.getRequiredFuel(), + isUnlocked, isCleared, n.getSortOrder(), n.getDescription(), n.getMapX(), n.getMapY(), + formatUtc(unlockedAt), + new ProgressDto(clearedChildren, totalChildren, progressRatio)); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +- [ ] **Step 3: RegionResponse** + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "지역 응답") +public record RegionResponse( + String id, String name, String nodeType, int depth, String icon, + @Schema(nullable = true) String parentId, + int requiredFuel, boolean isUnlocked, boolean isCleared, int sortOrder, + String description, double mapX, double mapY, + @Schema(nullable = true, example = "2026-04-05T15:30:00Z") String unlockedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static RegionResponse of(ExplorationNode n, boolean isUnlocked, boolean isCleared, + LocalDateTime unlockedAt) { + return new RegionResponse( + n.getId(), n.getName(), n.getNodeType().value(), n.getDepth(), n.getIcon(), + n.getParentId(), n.getRequiredFuel(), isUnlocked, isCleared, + n.getSortOrder(), n.getDescription(), n.getMapX(), n.getMapY(), + formatUtc(unlockedAt)); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +- [ ] **Step 4: UnlockedNodeDto** + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "해금된 노드 요약") +public record UnlockedNodeDto( + String id, String name, boolean isUnlocked, boolean isCleared, + @Schema(example = "2026-04-16T11:00:00Z") String unlockedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static UnlockedNodeDto of(ExplorationNode node, UserExploration progress, boolean cleared) { + return new UnlockedNodeDto( + node.getId(), node.getName(), true, cleared, + formatUtc(progress.getUnlockedAt())); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +- [ ] **Step 5: RegionUnlockResponse + PlanetUnlockResponse** + +`RegionUnlockResponse.java`: + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "지역 해금 응답") +public record RegionUnlockResponse( + UnlockedNodeDto region, int fuelConsumed, int currentFuel, boolean planetCleared +) { + public static RegionUnlockResponse of(ExplorationNode region, UserExploration progress, + int fuelConsumed, int currentFuel, boolean planetCleared) { + return new RegionUnlockResponse( + UnlockedNodeDto.of(region, progress, true), + fuelConsumed, currentFuel, planetCleared); + } +} +``` + +`PlanetUnlockResponse.java`: + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "행성 해금 응답") +public record PlanetUnlockResponse( + UnlockedNodeDto planet, int fuelConsumed, int currentFuel +) { + public static PlanetUnlockResponse of(ExplorationNode planet, UserExploration progress, + int fuelConsumed, int currentFuel) { + return new PlanetUnlockResponse( + UnlockedNodeDto.of(planet, progress, false), + fuelConsumed, currentFuel); + } +} +``` + +- [ ] **Step 6: 컴파일 확인** + +Run: `./gradlew :SS-Study:compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 7: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/ +git commit -m "탐험 도메인 구현 : feat : Exploration DTO 6종 추가 #27" +``` + +--- + +## Task 7: ExplorationService — 골격 + 목록 조회 2개 + +**Files:** +- Create: `.../exploration/service/ExplorationService.java` +- Test: `.../exploration/service/ExplorationServiceTest.java` + +> Mockito 단위 테스트(`@ExtendWith(MockitoExtension.class)`, repo + FuelService mock). + +- [ ] **Step 1: 실패 테스트** + +```java +package com.elipair.spacestudyship.study.exploration.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import com.elipair.spacestudyship.study.exploration.repository.ExplorationNodeRepository; +import com.elipair.spacestudyship.study.exploration.repository.UserExplorationRepository; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ExplorationServiceTest { + + @Mock ExplorationNodeRepository nodeRepository; + @Mock UserExplorationRepository userExplorationRepository; + @Mock FuelService fuelService; + @InjectMocks ExplorationService service; + + private ExplorationNode planet(String id, int requiredFuel, String prereq, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.PLANET).depth(2) + .icon(id).parentId(null).prerequisiteNodeId(prereq) + .requiredFuel(requiredFuel).sortOrder(sort).description("").mapX(0).mapY(0).build(); + } + + private ExplorationNode region(String id, String parent, int requiredFuel, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.REGION).depth(3) + .icon(id).parentId(parent).prerequisiteNodeId(null) + .requiredFuel(requiredFuel).sortOrder(sort).description("").mapX(0).mapY(0).build(); + } + + @Test + @DisplayName("getPlanets: earth는 requiredFuel=0이라 암묵 해금, 진행도 파생") + void getPlanets_derivesUnlockAndProgress() { + given(nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET)) + .willReturn(List.of(planet("earth", 0, null, 0), planet("mercury", 3, "earth", 1))); + given(nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.REGION)) + .willReturn(List.of(region("korea", "earth", 0, 0), + region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); + + List result = service.getPlanets(1L); + + PlanetResponse earth = result.get(0); + assertThat(earth.id()).isEqualTo("earth"); + assertThat(earth.isUnlocked()).isTrue(); + assertThat(earth.isCleared()).isFalse(); + assertThat(earth.progress().clearedChildren()).isEqualTo(1); + assertThat(earth.progress().totalChildren()).isEqualTo(2); + assertThat(earth.progress().progressRatio()).isEqualTo(0.5); + + PlanetResponse mercury = result.get(1); + assertThat(mercury.isUnlocked()).isFalse(); + assertThat(mercury.prerequisiteId()).isEqualTo("earth"); + } + + @Test + @DisplayName("getRegions: 행성 없으면 PLANET_NOT_FOUND") + void getRegions_planetNotFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getRegions(1L, "nope")) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("getRegions: 해금된 지역 isUnlocked/isCleared=true, korea(연료0) 암묵 해금") + void getRegions_mapsUnlock() { + given(nodeRepository.findById("earth")).willReturn(Optional.of(planet("earth", 0, null, 0))); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), + region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)).willReturn(List.of()); + + List result = service.getRegions(1L, "earth"); + + assertThat(result).extracting(RegionResponse::id).containsExactly("korea", "japan"); + assertThat(result.get(0).isUnlocked()).isTrue(); // korea requiredFuel=0 → 암묵 해금 + assertThat(result.get(0).isCleared()).isTrue(); + assertThat(result.get(1).isUnlocked()).isFalse(); // japan 미해금 + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.service.ExplorationServiceTest"` +Expected: FAIL — `ExplorationService` 없음 + +- [ ] **Step 3: 서비스 구현 (조회 2개 + private 헬퍼)** + +```java +package com.elipair.spacestudyship.study.exploration.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import com.elipair.spacestudyship.study.exploration.repository.ExplorationNodeRepository; +import com.elipair.spacestudyship.study.exploration.repository.UserExplorationRepository; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ExplorationService { + + private final ExplorationNodeRepository nodeRepository; + private final UserExplorationRepository userExplorationRepository; + private final FuelService fuelService; + + public List getPlanets(Long userId) { + List planets = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET); + List regions = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.REGION); + Map progress = progressMap(userId); + Set unlocked = progress.keySet(); + + Map totalByParent = regions.stream() + .collect(Collectors.groupingBy(ExplorationNode::getParentId, Collectors.counting())); + Map clearedByParent = regions.stream() + .filter(r -> unlocked.contains(r.getId())) + .collect(Collectors.groupingBy(ExplorationNode::getParentId, Collectors.counting())); + + return planets.stream().map(p -> { + int total = totalByParent.getOrDefault(p.getId(), 0L).intValue(); + int cleared = clearedByParent.getOrDefault(p.getId(), 0L).intValue(); + boolean isUnlocked = p.getRequiredFuel() == 0 || unlocked.contains(p.getId()); + boolean isCleared = total > 0 && cleared == total; + double ratio = total == 0 ? 0.0 : (double) cleared / total; + LocalDateTime unlockedAt = progress.containsKey(p.getId()) + ? progress.get(p.getId()).getUnlockedAt() : null; + return PlanetResponse.of(p, isUnlocked, isCleared, cleared, total, ratio, unlockedAt); + }).toList(); + } + + public List getRegions(Long userId, String planetId) { + nodeRepository.findById(planetId) + .filter(n -> n.getNodeType() == NodeType.PLANET) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc(planetId); + Map progress = progressMap(userId); + + return regions.stream().map(r -> { + UserExploration pr = progress.get(r.getId()); + boolean isUnlocked = r.getRequiredFuel() == 0 || pr != null; + LocalDateTime unlockedAt = pr == null ? null : pr.getUnlockedAt(); + return RegionResponse.of(r, isUnlocked, isUnlocked, unlockedAt); + }).toList(); + } + + private Map progressMap(Long userId) { + return userExplorationRepository.findByUserId(userId).stream() + .collect(Collectors.toMap(UserExploration::getNodeId, Function.identity())); + } + + private boolean isPlanetCleared(Long userId, String planetId) { + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc(planetId); + if (regions.isEmpty()) { + return false; + } + Set unlocked = userExplorationRepository.findByUserId(userId).stream() + .map(UserExploration::getNodeId).collect(Collectors.toSet()); + return regions.stream().allMatch(r -> unlocked.contains(r.getId())); + } +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.service.ExplorationServiceTest"` +Expected: PASS (3 테스트) + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java +git commit -m "탐험 도메인 구현 : feat : ExplorationService 목록 조회 2종 #27" +``` + +--- + +## Task 8: ExplorationService — 지역 해금 (+ 잔량 pre-check) + +**Files:** +- Modify: `.../exploration/service/ExplorationService.java` +- Modify: `.../exploration/service/ExplorationServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +import 추가: + +```java +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import org.mockito.ArgumentCaptor; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +``` + +테스트 추가: + +```java + private FuelResponse fuel(int currentFuel) { + return new FuelResponse(currentFuel, 0, 0, 0, null); + } + + private FuelTransactionResponse tx(int amount, int balanceAfter) { + return new FuelTransactionResponse( + "tx", "consume", amount, "EXPLORATION_UNLOCK", "ref", balanceAfter, null); + } + + @Test + @DisplayName("unlockRegion: 정상 해금 — 잔량충분 + 차감 + 저장 + 마지막 지역이면 planetCleared=true") + void unlockRegion_success_lastRegionClearsPlanet() { + given(nodeRepository.findById("japan")) + .willReturn(Optional.of(region("japan", "earth", 1, 1))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "japan")).willReturn(false); + given(fuelService.getFuel(1L)).willReturn(fuel(250)); + given(fuelService.consume(eq(1L), eq(1), eq(FuelReason.EXPLORATION_UNLOCK), eq("japan"), anyString())) + .willReturn(tx(1, 249)); + given(userExplorationRepository.save(any(UserExploration.class))) + .willAnswer(inv -> inv.getArgument(0)); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true), + UserExploration.unlock(1L, "japan", true))); + + var result = service.unlockRegion(1L, "japan"); + + assertThat(result.region().id()).isEqualTo("japan"); + assertThat(result.region().isCleared()).isTrue(); + assertThat(result.fuelConsumed()).isEqualTo(1); + assertThat(result.currentFuel()).isEqualTo(249); + assertThat(result.planetCleared()).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserExploration.class); + verify(userExplorationRepository).save(captor.capture()); + assertThat(captor.getValue().getNodeId()).isEqualTo("japan"); + assertThat(captor.getValue().isCleared()).isTrue(); + } + + @Test + @DisplayName("unlockRegion: 잔량 부족 → InsufficientFuelException + consume 미호출") + void unlockRegion_insufficientFuel() { + given(nodeRepository.findById("usa")) + .willReturn(Optional.of(region("usa", "earth", 3, 8))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "usa")).willReturn(false); + given(fuelService.getFuel(1L)).willReturn(fuel(1)); + + assertThatThrownBy(() -> service.unlockRegion(1L, "usa")) + .isInstanceOf(InsufficientFuelException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 부모 행성 미해금 → PLANET_LOCKED") + void unlockRegion_parentLocked() { + given(nodeRepository.findById("mars_olympus")) + .willReturn(Optional.of(region("mars_olympus", "mars", 3, 0))); + given(nodeRepository.findById("mars")) + .willReturn(Optional.of(planet("mars", 10, "venus", 3))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mars")).willReturn(false); + + assertThatThrownBy(() -> service.unlockRegion(1L, "mars_olympus")) + .isInstanceOf(CustomException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 이미 해금 → ALREADY_UNLOCKED") + void unlockRegion_alreadyUnlocked() { + given(nodeRepository.findById("japan")) + .willReturn(Optional.of(region("japan", "earth", 1, 1))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "japan")).willReturn(true); + + assertThatThrownBy(() -> service.unlockRegion(1L, "japan")) + .isInstanceOf(CustomException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 없는 지역 → REGION_NOT_FOUND") + void unlockRegion_notFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.unlockRegion(1L, "nope")) + .isInstanceOf(CustomException.class); + } +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.service.ExplorationServiceTest"` +Expected: FAIL — `unlockRegion` 없음 + +- [ ] **Step 3: 서비스에 import + unlockRegion 추가** + +import 추가: + +```java +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; + +import java.util.UUID; +``` + +메서드 추가: + +```java + @Transactional + public RegionUnlockResponse unlockRegion(Long userId, String regionId) { + ExplorationNode region = nodeRepository.findById(regionId) + .filter(n -> n.getNodeType() == NodeType.REGION) + .orElseThrow(() -> new CustomException(ErrorCode.REGION_NOT_FOUND)); + + ExplorationNode parent = nodeRepository.findById(region.getParentId()) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + boolean parentUnlocked = parent.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, parent.getId()); + if (!parentUnlocked) { + throw new CustomException(ErrorCode.PLANET_LOCKED); + } + + if (region.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, regionId)) { + throw new CustomException(ErrorCode.ALREADY_UNLOCKED); + } + + requireFuel(userId, region.getRequiredFuel()); + + FuelTransactionResponse fuelTx = fuelService.consume( + userId, region.getRequiredFuel(), FuelReason.EXPLORATION_UNLOCK, + regionId, UUID.randomUUID().toString()); + + UserExploration saved = userExplorationRepository.save( + UserExploration.unlock(userId, regionId, true)); + + boolean planetCleared = isPlanetCleared(userId, parent.getId()); + + log.info("[Exploration] 지역 해금 | userId={}, regionId={}, fuel={}, planetCleared={}", + userId, regionId, region.getRequiredFuel(), planetCleared); + + return RegionUnlockResponse.of(region, saved, + fuelTx.amount(), fuelTx.balanceAfter(), planetCleared); + } + + private void requireFuel(Long userId, int requiredFuel) { + int currentFuel = fuelService.getFuel(userId).currentFuel(); + if (currentFuel < requiredFuel) { + throw new InsufficientFuelException(requiredFuel, currentFuel); + } + } +``` + +- [ ] **Step 4: 통과 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.service.ExplorationServiceTest"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java +git commit -m "탐험 도메인 구현 : feat : 지역 해금 로직 + 잔량 pre-check + 자동 클리어 #27" +``` + +--- + +## Task 9: ExplorationService — 행성 해금 (선행 게이트) + +**Files:** +- Modify: `.../exploration/service/ExplorationService.java` +- Modify: `.../exploration/service/ExplorationServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +```java + @Test + @DisplayName("unlockPlanet: 선행 행성 클리어 시 정상 해금") + void unlockPlanet_success() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); + given(fuelService.getFuel(1L)).willReturn(fuel(100)); + given(fuelService.consume(eq(1L), eq(3), eq(FuelReason.EXPLORATION_UNLOCK), eq("mercury"), anyString())) + .willReturn(tx(3, 97)); + given(userExplorationRepository.save(any(UserExploration.class))) + .willAnswer(inv -> inv.getArgument(0)); + + var result = service.unlockPlanet(1L, "mercury"); + + assertThat(result.planet().id()).isEqualTo("mercury"); + assertThat(result.planet().isCleared()).isFalse(); + assertThat(result.fuelConsumed()).isEqualTo(3); + assertThat(result.currentFuel()).isEqualTo(97); + } + + @Test + @DisplayName("unlockPlanet: 선행 미클리어 → PREREQUISITE_NOT_CLEARED + consume 미호출") + void unlockPlanet_prerequisiteNotCleared() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); // 1/2만 + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(CustomException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 잔량 부족 → InsufficientFuelException + consume 미호출") + void unlockPlanet_insufficientFuel() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); + given(fuelService.getFuel(1L)).willReturn(fuel(1)); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(InsufficientFuelException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 이미 해금 → ALREADY_UNLOCKED") + void unlockPlanet_alreadyUnlocked() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(true); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(CustomException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 없는 행성 → PLANET_NOT_FOUND") + void unlockPlanet_notFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "nope")) + .isInstanceOf(CustomException.class); + } +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.service.ExplorationServiceTest"` +Expected: FAIL — `unlockPlanet` 없음 + +- [ ] **Step 3: import + unlockPlanet 추가** + +import 추가: + +```java +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +``` + +메서드 추가: + +```java + @Transactional + public PlanetUnlockResponse unlockPlanet(Long userId, String planetId) { + ExplorationNode planet = nodeRepository.findById(planetId) + .filter(n -> n.getNodeType() == NodeType.PLANET) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + if (planet.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, planetId)) { + throw new CustomException(ErrorCode.ALREADY_UNLOCKED); + } + + if (planet.getPrerequisiteNodeId() != null + && !isPlanetCleared(userId, planet.getPrerequisiteNodeId())) { + throw new CustomException(ErrorCode.PREREQUISITE_NOT_CLEARED); + } + + requireFuel(userId, planet.getRequiredFuel()); + + FuelTransactionResponse fuelTx = fuelService.consume( + userId, planet.getRequiredFuel(), FuelReason.EXPLORATION_UNLOCK, + planetId, UUID.randomUUID().toString()); + + UserExploration saved = userExplorationRepository.save( + UserExploration.unlock(userId, planetId, false)); + + log.info("[Exploration] 행성 해금 | userId={}, planetId={}, fuel={}", + userId, planetId, planet.getRequiredFuel()); + + return PlanetUnlockResponse.of(planet, saved, fuelTx.amount(), fuelTx.balanceAfter()); + } +``` + +- [ ] **Step 4: 통과 확인 (서비스 전체)** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.*"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java +git commit -m "탐험 도메인 구현 : feat : 행성 해금 로직 + 선행 클리어 게이트 #27" +``` + +--- + +## Task 10: ExplorationController + MockMvc 테스트 + +**Files:** +- Create: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration/ExplorationController.java` +- Test: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration/ExplorationControllerTest.java` + +- [ ] **Step 1: 실패 테스트** + +```java +package com.elipair.spacestudyship.controller.exploration; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.UnlockedNodeDto; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.service.ExplorationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class ExplorationControllerTest { + + @Mock ExplorationService explorationService; + @InjectMocks ExplorationController controller; + + MockMvc mockMvc; + + private ExplorationNode planetNode() { + return ExplorationNode.builder().id("earth").name("지구").nodeType(NodeType.PLANET) + .depth(2).icon("earth").requiredFuel(0).sortOrder(0) + .description("시작점").mapX(0.5).mapY(0.08).build(); + } + + private ExplorationNode regionNode() { + return ExplorationNode.builder().id("korea").name("대한민국").nodeType(NodeType.REGION) + .depth(3).icon("KR").parentId("earth").requiredFuel(0).sortOrder(0) + .description("한반도").mapX(0).mapY(0).build(); + } + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver loginMemberStub = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return new LoginMember(1L); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(loginMemberStub) + .build(); + } + + @Test + @DisplayName("GET /api/explorations/planets — 200, nodeType 소문자") + void getPlanets_200() throws Exception { + given(explorationService.getPlanets(1L)).willReturn(List.of( + PlanetResponse.of(planetNode(), true, false, 1, 2, 0.5, null))); + + mockMvc.perform(get("/api/explorations/planets")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("earth")) + .andExpect(jsonPath("$[0].nodeType").value("planet")) + .andExpect(jsonPath("$[0].isUnlocked").value(true)) + .andExpect(jsonPath("$[0].progress.totalChildren").value(2)); + } + + @Test + @DisplayName("GET /api/explorations/planets/{id}/regions — 200") + void getRegions_200() throws Exception { + given(explorationService.getRegions(1L, "earth")).willReturn(List.of( + RegionResponse.of(regionNode(), true, true, null))); + + mockMvc.perform(get("/api/explorations/planets/earth/regions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("korea")) + .andExpect(jsonPath("$[0].nodeType").value("region")); + } + + @Test + @DisplayName("GET regions — 행성 없음 404 PLANET_NOT_FOUND") + void getRegions_404() throws Exception { + given(explorationService.getRegions(1L, "nope")) + .willThrow(new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + mockMvc.perform(get("/api/explorations/planets/nope/regions")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PLANET_NOT_FOUND")) + .andExpect(jsonPath("$.requiredFuel").doesNotExist()); + } + + @Test + @DisplayName("POST /api/explorations/regions/{id}/unlock — 200") + void unlockRegion_200() throws Exception { + given(explorationService.unlockRegion(1L, "japan")).willReturn( + new RegionUnlockResponse( + new UnlockedNodeDto("japan", "일본", true, true, "2026-04-16T11:00:00Z"), + 1, 249, false)); + + mockMvc.perform(post("/api/explorations/regions/japan/unlock")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.region.id").value("japan")) + .andExpect(jsonPath("$.fuelConsumed").value(1)) + .andExpect(jsonPath("$.currentFuel").value(249)) + .andExpect(jsonPath("$.planetCleared").value(false)); + } + + @Test + @DisplayName("POST region unlock — 연료 부족 400 + requiredFuel/currentFuel 본문") + void unlockRegion_insufficientFuel_400() throws Exception { + willThrow(new InsufficientFuelException(3, 1)) + .given(explorationService).unlockRegion(1L, "usa"); + + mockMvc.perform(post("/api/explorations/regions/usa/unlock")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INSUFFICIENT_FUEL")) + .andExpect(jsonPath("$.requiredFuel").value(3)) + .andExpect(jsonPath("$.currentFuel").value(1)); + } + + @Test + @DisplayName("POST /api/explorations/planets/{id}/unlock — 200") + void unlockPlanet_200() throws Exception { + given(explorationService.unlockPlanet(1L, "mercury")).willReturn( + new PlanetUnlockResponse( + new UnlockedNodeDto("mercury", "수성", true, false, "2026-04-16T11:30:00Z"), + 3, 97)); + + mockMvc.perform(post("/api/explorations/planets/mercury/unlock")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.planet.id").value("mercury")) + .andExpect(jsonPath("$.fuelConsumed").value(3)) + .andExpect(jsonPath("$.currentFuel").value(97)); + } + + @Test + @DisplayName("POST planet unlock — 선행 미클리어 400 PREREQUISITE_NOT_CLEARED") + void unlockPlanet_prerequisite_400() throws Exception { + willThrow(new CustomException(ErrorCode.PREREQUISITE_NOT_CLEARED)) + .given(explorationService).unlockPlanet(1L, "mercury"); + + mockMvc.perform(post("/api/explorations/planets/mercury/unlock")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PREREQUISITE_NOT_CLEARED")); + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.exploration.ExplorationControllerTest"` +Expected: FAIL — `ExplorationController` 없음 + +- [ ] **Step 3: 컨트롤러 구현** + +```java +package com.elipair.spacestudyship.controller.exploration; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.exploration.service.ExplorationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Exploration", description = "우주 탐험(행성/지역 해금) API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/explorations") +public class ExplorationController { + + private final ExplorationService explorationService; + + @Operation(summary = "행성 목록 조회", + description = "전체 행성 목록과 유저의 해금/클리어 상태, 진행도를 반환합니다. 정렬: sortOrder 오름차순.") + @GetMapping("/planets") + public ResponseEntity> getPlanets(@AuthMember LoginMember loginMember) { + return ResponseEntity.ok(explorationService.getPlanets(loginMember.memberId())); + } + + @Operation(summary = "행성 하위 지역 목록 조회", + description = "특정 행성의 하위 지역과 유저 해금 상태를 반환합니다. 행성이 없으면 404 PLANET_NOT_FOUND.") + @GetMapping("/planets/{planetId}/regions") + public ResponseEntity> getRegions( + @AuthMember LoginMember loginMember, + @PathVariable String planetId) { + return ResponseEntity.ok(explorationService.getRegions(loginMember.memberId(), planetId)); + } + + @Operation(summary = "지역 해금", + description = """ + 연료를 소비하여 지역을 해금합니다(해금=클리어). 잔량 확인+차감+해금을 원자적으로 처리합니다. + 상위 행성의 모든 지역이 해금되면 planetCleared=true. + + 에러: 400 INSUFFICIENT_FUEL(requiredFuel/currentFuel 동봉) / ALREADY_UNLOCKED / PLANET_LOCKED, 404 REGION_NOT_FOUND + """) + @PostMapping("/regions/{regionId}/unlock") + public ResponseEntity unlockRegion( + @AuthMember LoginMember loginMember, + @PathVariable String regionId) { + return ResponseEntity.ok(explorationService.unlockRegion(loginMember.memberId(), regionId)); + } + + @Operation(summary = "행성 해금", + description = """ + 연료를 소비하여 행성을 해금합니다. 선행 행성을 클리어해야 해금할 수 있습니다. + + 에러: 400 INSUFFICIENT_FUEL(requiredFuel/currentFuel 동봉) / ALREADY_UNLOCKED / PREREQUISITE_NOT_CLEARED, 404 PLANET_NOT_FOUND + """) + @PostMapping("/planets/{planetId}/unlock") + public ResponseEntity unlockPlanet( + @AuthMember LoginMember loginMember, + @PathVariable String planetId) { + return ResponseEntity.ok(explorationService.unlockPlanet(loginMember.memberId(), planetId)); + } +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.exploration.ExplorationControllerTest"` +Expected: PASS (7 테스트) + +- [ ] **Step 5: Commit** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration/ SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration/ +git commit -m "탐험 도메인 구현 : feat : ExplorationController 4 엔드포인트 + 테스트 #27" +``` + +--- + +## Task 11: Flyway 마이그레이션 (스키마 + 시드 38노드) + +**Files:** +- Create: `SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql` +- Modify: `CLAUDE.md` (마이그레이션 이력표) + +> **시작 전:** `version.yml`의 `version` 확인. `V0_0_42__*.sql`가 이미 있으면 현재 version.yml 값으로 파일명 변경. 현재 가정: `0.0.42`. + +- [ ] **Step 1: 마이그레이션 작성** + +```sql +-- exploration_nodes: 행성/지역 마스터 (시드, 읽기 전용) +CREATE TABLE IF NOT EXISTS exploration_nodes ( + id VARCHAR(50) PRIMARY KEY, + name VARCHAR(50) NOT NULL, + node_type VARCHAR(10) NOT NULL, + depth INTEGER NOT NULL, + icon VARCHAR(30) NOT NULL, + parent_id VARCHAR(50), + prerequisite_node_id VARCHAR(50), + required_fuel INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + description VARCHAR(200) NOT NULL DEFAULT '', + map_x DOUBLE PRECISION NOT NULL DEFAULT 0, + map_y DOUBLE PRECISION NOT NULL DEFAULT 0, + CONSTRAINT fk_expl_node_parent FOREIGN KEY (parent_id) REFERENCES exploration_nodes(id), + CONSTRAINT fk_expl_node_prerequisite FOREIGN KEY (prerequisite_node_id) REFERENCES exploration_nodes(id), + CONSTRAINT chk_expl_node_type CHECK (node_type IN ('planet','region')), + CONSTRAINT chk_expl_required_fuel_non_negative CHECK (required_fuel >= 0) +); + +-- user_exploration_progress: 유저별 해금 상태 (행 존재 = 해금) +CREATE TABLE IF NOT EXISTS user_exploration_progress ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + node_id VARCHAR(50) NOT NULL, + is_unlocked BOOLEAN NOT NULL DEFAULT TRUE, + is_cleared BOOLEAN NOT NULL DEFAULT FALSE, + unlocked_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_user_expl_member FOREIGN KEY (user_id) REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT fk_user_expl_node FOREIGN KEY (node_id) REFERENCES exploration_nodes(id), + CONSTRAINT uq_user_expl UNIQUE (user_id, node_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_expl_user ON user_exploration_progress (user_id); + +-- 시드: 행성 8 (행성 먼저) +INSERT INTO exploration_nodes (id, name, node_type, depth, icon, parent_id, prerequisite_node_id, required_fuel, sort_order, description, map_x, map_y) VALUES + ('earth', '지구', 'planet', 2, 'earth', NULL, NULL, 0, 0, '우리의 출발지, 고향 행성', 0.5, 0.08), + ('mercury', '수성', 'planet', 2, 'mercury', NULL, 'earth', 3, 1, '태양에 가장 가까운 작은 행성', 0.15, 0.20), + ('venus', '금성', 'planet', 2, 'venus', NULL, 'mercury', 5, 2, '두꺼운 대기로 뒤덮인 뜨거운 행성', 0.75, 0.32), + ('mars', '화성', 'planet', 2, 'mars', NULL, 'venus', 10, 3, '붉은 행성, 탐험의 꿈', 0.25, 0.44), + ('jupiter', '목성', 'planet', 2, 'jupiter', NULL, 'mars', 20, 4, '태양계 최대의 가스 행성', 0.7, 0.56), + ('saturn', '토성', 'planet', 2, 'saturn', NULL, 'jupiter', 30, 5, '아름다운 고리를 가진 행성', 0.2, 0.68), + ('uranus', '천왕성', 'planet', 2, 'uranus', NULL, 'saturn', 45, 6, '옆으로 누워 자전하는 얼음 행성', 0.8, 0.80), + ('neptune', '해왕성', 'planet', 2, 'neptune', NULL, 'uranus', 60, 7, '태양계 끝자락의 푸른 행성', 0.35, 0.92) +ON CONFLICT (id) DO NOTHING; + +-- 시드: 지역 30 (지구 12 + 그 외 18). region은 prerequisite NULL, map 0/0. +INSERT INTO exploration_nodes (id, name, node_type, depth, icon, parent_id, prerequisite_node_id, required_fuel, sort_order, description, map_x, map_y) VALUES + ('korea', '대한민국', 'region', 3, 'KR', 'earth', NULL, 0, 0, '한반도 남쪽, K-컬쳐의 중심', 0, 0), + ('japan', '일본', 'region', 3, 'JP', 'earth', NULL, 1, 1, '벚꽃과 기술의 나라', 0, 0), + ('thailand', '태국', 'region', 3, 'TH', 'earth', NULL, 1, 2, '미소의 나라, 동남아의 허브', 0, 0), + ('china', '중국', 'region', 3, 'CN', 'earth', NULL, 2, 3, '세계 최대 인구 대국', 0, 0), + ('india', '인도', 'region', 3, 'IN', 'earth', NULL, 2, 4, 'IT 강국, 다양한 문화의 보고', 0, 0), + ('uk', '영국', 'region', 3, 'GB', 'earth', NULL, 2, 5, '해가 지지 않는 나라', 0, 0), + ('france', '프랑스', 'region', 3, 'FR', 'earth', NULL, 2, 6, '예술과 낭만의 나라', 0, 0), + ('canada', '캐나다', 'region', 3, 'CA', 'earth', NULL, 2, 7, '단풍과 자연의 나라', 0, 0), + ('usa', '미국', 'region', 3, 'US', 'earth', NULL, 3, 8, '자유의 나라, 기회의 땅', 0, 0), + ('brazil', '브라질', 'region', 3, 'BR', 'earth', NULL, 3, 9, '삼바와 축구의 나라', 0, 0), + ('australia', '호주', 'region', 3, 'AU', 'earth', NULL, 3, 10, '코알라와 캥거루의 대륙', 0, 0), + ('egypt', '이집트', 'region', 3, 'EG', 'earth', NULL, 2, 11, '피라미드와 나일강의 나라', 0, 0), + ('mercury_caloris', '칼로리스 분지', 'region', 3, 'mercury', 'mercury', NULL, 1, 0, '수성 최대의 충돌 분지', 0, 0), + ('mercury_plains', '북극 평원', 'region', 3, 'mercury', 'mercury', NULL, 2, 1, '얼음이 숨겨진 영구 그림자 지대', 0, 0), + ('venus_ishtar', '이슈타르 대지', 'region', 3, 'venus', 'venus', NULL, 2, 0, '금성 북반구의 거대한 고원 지대', 0, 0), + ('venus_aphrodite', '아프로디테 대지','region', 3, 'venus', 'venus', NULL, 3, 1, '금성 적도를 따라 펼쳐진 최대 대지', 0, 0), + ('venus_maxwell', '맥스웰 산', 'region', 3, 'venus', 'venus', NULL, 3, 2, '금성에서 가장 높은 산맥', 0, 0), + ('mars_olympus', '올림푸스 산', 'region', 3, 'mars', 'mars', NULL, 3, 0, '태양계에서 가장 높은 화산', 0, 0), + ('mars_valles', '마리너 계곡', 'region', 3, 'mars', 'mars', NULL, 4, 1, '태양계 최대의 협곡', 0, 0), + ('mars_polar', '극관 지대', 'region', 3, 'mars', 'mars', NULL, 5, 2, '드라이아이스와 물 얼음의 극지방', 0, 0), + ('jupiter_red_spot', '대적점', 'region', 3, 'jupiter', 'jupiter', NULL, 5, 0, '수백 년간 지속되는 거대 폭풍', 0, 0), + ('jupiter_europa', '유로파', 'region', 3, 'jupiter', 'jupiter', NULL, 7, 1, '얼음 아래 바다가 있는 위성', 0, 0), + ('jupiter_io', '이오', 'region', 3, 'jupiter', 'jupiter', NULL, 8, 2, '화산 활동이 가장 활발한 위성', 0, 0), + ('saturn_rings', '토성 고리', 'region', 3, 'saturn', 'saturn', NULL, 8, 0, '얼음과 먼지로 이루어진 아름다운 고리', 0, 0), + ('saturn_titan', '타이탄', 'region', 3, 'saturn', 'saturn', NULL, 10, 1, '대기를 가진 유일한 위성, 메탄의 호수', 0, 0), + ('saturn_enceladus', '엔셀라두스', 'region', 3, 'saturn', 'saturn', NULL, 12, 2, '간헐천이 분출하는 얼음 위성', 0, 0), + ('uranus_miranda', '미란다', 'region', 3, 'uranus', 'uranus', NULL, 12, 0, '기괴한 지형의 작은 위성', 0, 0), + ('uranus_atmosphere', '천왕성 대기', 'region', 3, 'uranus', 'uranus', NULL, 15, 1, '메탄이 만드는 청록빛 대기', 0, 0), + ('neptune_dark_spot', '대흑점', 'region', 3, 'neptune', 'neptune', NULL, 15, 0, '초속 2000km 폭풍의 소용돌이', 0, 0), + ('neptune_triton', '트리톤', 'region', 3, 'neptune', 'neptune', NULL, 20, 1, '역행 궤도를 도는 거대 위성', 0, 0) +ON CONFLICT (id) DO NOTHING; +``` + +- [ ] **Step 2: 빌드/회귀 확인** + +Run: `./gradlew :SS-Study:test :SS-Web:test` +Expected: BUILD SUCCESSFUL (Flyway는 테스트에서 비활성이지만 회귀 확인) + +- [ ] **Step 3: CLAUDE.md 이력표 갱신** + +"현재 마이그레이션 이력" 표에 행 추가: + +``` +| 0.0.42 | `V0_0_42__add_exploration.sql` | `exploration_nodes`, `user_exploration_progress` 테이블 + 행성/지역 시드 38노드 (프론트 시드 미러, self-FK, FK CASCADE, UNIQUE) | +``` + +- [ ] **Step 4: Commit** + +```bash +git add SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql CLAUDE.md +git commit -m "탐험 도메인 구현 : chore : exploration 테이블 + 시드 38노드 마이그레이션 #27" +``` + +--- + +## Task 12: API 스펙 문서 갱신 + +**Files:** +- Modify: `docs/api-specs/05_exploration.md` + +- [ ] **Step 1: 노드 객체에 prerequisiteId** + +"탐험 노드 객체 구조" 필드 표 `parentId` 행 아래에 추가: + +``` +| `prerequisiteId` | String | O | 선행 행성 ID (행성만, 이 행성을 해금하려면 선행 행성을 클리어해야 함). region은 null | +``` + +행성 목록 예시 JSON들에 `"prerequisiteId"` 추가 (earth=null, mercury="earth" 등 실제 체인 반영). + +- [ ] **Step 2: 행성 해금에 선행 게이트** + +"4. 행성 해금" Error 표에 추가: + +``` +| 400 | `PREREQUISITE_NOT_CLEARED` | 선행 행성이 아직 클리어되지 않음 | +``` + +서버 처리 로직 "2. 이미 해금된 행성인지 확인" 다음에 추가: + +``` + 2-1. prerequisiteId가 있으면 선행 행성이 클리어(모든 하위 지역 해금)되었는지 확인 → 아니면 PREREQUISITE_NOT_CLEARED +``` + +"해금 규칙" 개요에 추가: + +``` +- **행성 진행 게이트**: 행성은 선행 행성(prerequisiteId)을 클리어해야 해금. 지구는 선행 없음 (체인: 지구→수성→금성→화성→목성→토성→천왕성→해왕성). +``` + +- [ ] **Step 3: DB 테이블 + 시드/연료/ID 규칙 정정** + +- `exploration_nodes` 컬럼 표에 `prerequisite_node_id` (VARCHAR(50), FK→self) 추가. +- 개요 트리/예시 연료 수치를 본 시드값(행성 0/3/5/10/20/30/45/60, 지역 0~20)으로 정정. +- region ID는 이름 기반(`korea`,`mars_olympus`), icon은 지구지역=국가코드/그 외=행성이름임을 명시. +- 행성 로스터를 8행성(달 없음, 천왕성 포함)으로 정정. + +- [ ] **Step 4: INSUFFICIENT_FUEL 응답 보강 명시** + +지역/행성 해금 에러 섹션에 INSUFFICIENT_FUEL 응답 본문이 `requiredFuel`/`currentFuel`을 포함함을 예시와 함께 명시: + +```json +{ "code": "INSUFFICIENT_FUEL", "message": "연료가 부족합니다.", "requiredFuel": 10, "currentFuel": 4 } +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/api-specs/05_exploration.md +git commit -m "탐험 도메인 구현 : docs : 05_exploration 스펙 frontend 계약 정합 갱신 #27" +``` + +--- + +## 최종 검증 + +- [ ] **전체 테스트** + +Run: `./gradlew :SS-Common:test :SS-Study:test :SS-Web:test` +Expected: BUILD SUCCESSFUL — 신규 통과, 회귀 없음 + +- [ ] **시드 정합 spot-check** + +`V0_0_42__add_exploration.sql`의 행성 8 + 지역 30 = 38행, 프론트 시드(`exploration_seed_data.dart`)의 id/icon/required_fuel/sort_order와 일치하는지 대조. + +--- + +## Self-Review (작성자 기록) + +- **Spec coverage:** Task0 폐기 / Task1 에러인프라(ErrorCode·ErrorResponse·예외·핸들러) / Task2 NodeType / Task3-4 엔티티 / Task5 repo / Task6 DTO / Task7 조회 / Task8 지역해금+pre-check / Task9 행성해금+게이트 / Task10 컨트롤러 / Task11 마이그레이션 38노드 / Task12 문서. spec 전 항목 매핑. +- **Type 일관성:** `fuelService.getFuel(userId).currentFuel()`(FuelResponse), `consume(...)`→`FuelTransactionResponse.amount()/balanceAfter()`, `InsufficientFuelException(requiredFuel,currentFuel)`, `ErrorResponse.ofInsufficientFuel(msg,req,cur)`, `UserExploration.unlock(userId,nodeId,cleared)`, DTO `of(...)` 시그니처가 service 호출과 일치. +- **시드:** 8행성+30지역=38, 프론트 시드 1:1 (id/icon/fuel/sortOrder/description/mapXY). +- **Placeholder:** 없음. diff --git a/docs/superpowers/specs/2026-05-29-exploration-domain-design.md b/docs/superpowers/specs/2026-05-29-exploration-domain-design.md new file mode 100644 index 0000000..e3e4e54 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-exploration-domain-design.md @@ -0,0 +1,350 @@ +# 탐험(Exploration) 도메인 설계 (frontend 계약 정합 버전) + +> 작성일: 2026-05-29 (개정) +> 대상 API 스펙: `docs/api-specs/05_exploration.md` +> 프론트 계약: `docs/api-specs/exploration-frontend-requirements.md` +> 프론트 시드 원본: Flutter 레포 `lib/features/exploration/data/seed/exploration_seed_data.dart` +> 동기화 Tier: 2 (Server-Validated) + +--- + +## 0. 개정 배경 + +초기 구현은 대화 중 임의로 정한 로스터(달 포함, 천왕성 누락)·아이콘(`mars-mountain` 등 프론트 미인식 값)·연료·ID(`region-kr`)를 사용해 **프론트 계약과 어긋났다.** 본 개정판은 **서버 시드를 프론트 게스트 시드와 1:1로 일치**시키고, 프론트가 요구한 `INSUFFICIENT_FUEL` 응답 보강을 추가한다. 구조 코드(entity/repository/service/controller/DTO)는 계약을 이미 충족하므로 형태를 유지하되, 작업은 working tree를 폐기하고 깨끗한 상태에서 재구현한다. + +--- + +## 1. 개요 + +행성(planet) → 지역(region) 2단계 트리를 연료로 해금한다. 이전 행성을 **클리어**(모든 하위 지역 해금)해야 다음 행성을 해금할 수 있는 **진행 게이트**(`prerequisiteId`)를 둔다. 게스트(로컬)와 회원(서버)은 완전히 분리되며 마이그레이션은 없다. + +### 엔드포인트 (4개) + +| # | Method | Path | 설명 | +|---|--------|------|------| +| 1 | GET | `/api/explorations/planets` | 행성 목록 + 유저 해금/클리어/진행도 | +| 2 | GET | `/api/explorations/planets/{planetId}/regions` | 행성 하위 지역 목록 + 유저 해금 상태 | +| 3 | POST | `/api/explorations/regions/{regionId}/unlock` | 지역 해금 (연료 차감) | +| 4 | POST | `/api/explorations/planets/{planetId}/unlock` | 행성 해금 (연료 차감 + 선행 게이트) | + +--- + +## 2. 핵심 설계 결정 + +| 결정 | 선택 | 근거 | +|------|------|------| +| 기본 해금 표현 | 암묵적 (`requiredFuel == 0`) | per-user 시드/리스너/백필 불필요. earth·korea가 해당 | +| 행성 isCleared / progress | 조회 시 파생(derive) | 저장 안 함. 하위 region 마스터 수와 유저 해금 수를 메모리 집계 | +| region isCleared | = isUnlocked | 해금 = 클리어 | +| 진행 게이트 | 명시적 `prerequisiteNodeId` 컬럼, 행성만 | sortOrder 체인. 선행 행성 클리어 필수 | +| 해금 멱등성 | UNIQUE(user_id, node_id) + 단일 트랜잭션 | 동시 중복 시 제약 위반→롤백(연료 포함). transactionId는 매 호출 신규 UUID | +| 시드 출처 | **프론트 게스트 시드 1:1 미러** | 게스트/회원 코드·아이콘·진행감 일치 | +| INSUFFICIENT_FUEL 응답 | `requiredFuel`/`currentFuel` 동봉 | 프론트가 정확한 안내 문구 생성 | + +### 환율 컨텍스트 +`UserFuel.MINUTES_PER_FUEL = 30` (30분 공부 = 1 연료). 연료 수치는 프론트 시드 값을 그대로 사용한다(서버가 임의 재산정하지 않음). + +--- + +## 3. 모듈 / 패키지 배치 + +`fuel`/`timer`/`todo`와 동일하게 **SS-Study**, Controller만 **SS-Web**. + +``` +SS-Study/.../study/exploration/ +├── constant/ NodeType (PLANET, REGION), NodeTypeConverter +├── dto/ PlanetResponse, RegionResponse, ProgressDto, +│ RegionUnlockResponse, PlanetUnlockResponse, UnlockedNodeDto +├── entity/ ExplorationNode (마스터, read-only), UserExploration (유저 진행) +├── repository/ ExplorationNodeRepository, UserExplorationRepository +└── service/ ExplorationService + +SS-Web/.../controller/exploration/ExplorationController + +SS-Common/.../common/exception/ +├── ErrorCode.java (탐험 에러 5종 추가) +├── ErrorResponse.java (nullable requiredFuel/currentFuel 추가) +├── InsufficientFuelException.java(신규) +└── GlobalExceptionHandler.java (InsufficientFuelException 분기 추가) +``` + +--- + +## 4. Entity + +### ExplorationNode (마스터, 시드 전용·읽기 전용) +- `@Entity @Table(name="exploration_nodes")`, `@Getter @Builder @AllArgsConstructor @NoArgsConstructor(PROTECTED)`. **BaseTimeEntity 미상속.** + +| 필드 | 타입 | 컬럼 | +|------|------|------| +| `id` | String `@Id` | id (고정 문자열, 예 `mars_olympus`) | +| `name` | String | name | +| `nodeType` | NodeType (`@Convert` 소문자) | node_type | +| `depth` | int | depth (planet=2, region=3) | +| `icon` | String | icon | +| `parentId` | String (nullable) | parent_id | +| `prerequisiteNodeId` | String (nullable) | prerequisite_node_id | +| `requiredFuel` | int | required_fuel | +| `sortOrder` | int | sort_order | +| `description` | String | description | +| `mapX` | double | map_x | +| `mapY` | double | map_y | + +### UserExploration (유저 진행) +- `@Entity @Table(name="user_exploration_progress", uniqueConstraints=@UniqueConstraint(name="uq_user_expl", columnNames={"user_id","node_id"}))`, BaseTimeEntity 상속. +- 행 존재 = 해금. 정적 팩토리 `unlock(userId, nodeId, cleared)` (isUnlocked=true, unlockedAt=now). + +| 필드 | 타입 | 컬럼 | +|------|------|------| +| `id` | Long `@GeneratedValue(IDENTITY)` | id | +| `userId` | Long | user_id | +| `nodeId` | String | node_id | +| `isUnlocked` | boolean | is_unlocked (항상 true) | +| `isCleared` | boolean | is_cleared (region=true, planet=false) | +| `unlockedAt` | LocalDateTime | unlocked_at | + +--- + +## 5. NodeType + Converter + +```java +public enum NodeType { PLANET, REGION; + public String value() { return name().toLowerCase(); } + public static NodeType from(String v) { return valueOf(v.toUpperCase()); } +} +``` +`@Converter` `NodeTypeConverter implements AttributeConverter` — DB에는 소문자('planet'/'region') 저장(시드·CHECK·JSON과 일치). + +--- + +## 6. 서비스 로직 + +`ExplorationService` (`@Transactional(readOnly=true)` 기본, 해금 메서드만 `@Transactional`). 의존성: `ExplorationNodeRepository`, `UserExplorationRepository`, `FuelService`. + +### 6.1 GET 행성 목록 +``` +planets = nodeRepo.findByNodeType(PLANET) (sortOrder asc) +regions = nodeRepo.findByNodeType(REGION) +progress = userExplRepo.findByUserId(userId) → Map +각 planet: + isUnlocked = requiredFuel==0 || progress.containsKey(id) + total = 자식 region 수 ; cleared = 자식 region 중 progress에 있는 수 + isCleared = total>0 && cleared==total + progressRatio = total==0 ? 0.0 : cleared/total + unlockedAt = progress 행의 값(없으면 null) + prerequisiteId = prerequisiteNodeId +→ PlanetResponse 리스트 +``` + +### 6.2 GET 지역 목록 +``` +planet 존재·PLANET 확인 (아니면 PLANET_NOT_FOUND) +regions = nodeRepo.findByParentId(planetId) (sortOrder asc) +각 region: isUnlocked = requiredFuel==0 || progress 존재; isCleared = isUnlocked +→ RegionResponse 리스트 +``` + +### 6.3 POST 지역 해금 — `@Transactional` +``` +1. region 조회·REGION 확인 (아니면 REGION_NOT_FOUND) +2. 부모 행성 해금? (requiredFuel==0 || progress 존재) → 아니면 PLANET_LOCKED +3. 이미 해금? (requiredFuel==0 || progress 존재) → ALREADY_UNLOCKED +4. 잔량 pre-check: currentFuel < requiredFuel → InsufficientFuelException(requiredFuel, currentFuel) +5. fuelService.consume(userId, requiredFuel, EXPLORATION_UNLOCK, regionId, UUID) (원자적 최종 검증) +6. UserExploration.unlock(userId, regionId, true) save +7. planetCleared = isPlanetCleared(userId, 부모행성id) (save 후 재집계) +→ RegionUnlockResponse(region, fuelConsumed=tx.amount, currentFuel=tx.balanceAfter, planetCleared) +``` + +### 6.4 POST 행성 해금 — `@Transactional` +``` +1. planet 조회·PLANET 확인 (아니면 PLANET_NOT_FOUND) +2. 이미 해금? (requiredFuel==0 || progress 존재) → ALREADY_UNLOCKED +3. prerequisiteNodeId != null 이면 선행 행성 isPlanetCleared 확인 → 아니면 PREREQUISITE_NOT_CLEARED +4. 잔량 pre-check → 부족 시 InsufficientFuelException(requiredFuel, currentFuel) +5. fuelService.consume(...) → 6. save(cleared=false) +→ PlanetUnlockResponse(planet, fuelConsumed, currentFuel) +``` + +> **원자성:** unlock 메서드(@Transactional)가 consume(@Transactional)을 호출 → 동일 트랜잭션 합류. 연료 차감·거래내역·해금행 insert가 한 단위. 잔량 pre-check는 풍부한 에러 본문을 위한 것이고, 경합 시 최종 보증은 consume 내부의 락+검증이 담당. + +> **잔량 조회:** pre-check는 `fuelService.getFuel(userId).currentFuel()` 사용. + +`isPlanetCleared(userId, planetId)`: 하위 region이 비면 false, 아니면 모든 region이 해금됐는지 `allMatch`. + +--- + +## 7. DTO (record, dto/) + +```java +record PlanetResponse(String id, String name, String nodeType, int depth, String icon, + String parentId, String prerequisiteId, int requiredFuel, + boolean isUnlocked, boolean isCleared, int sortOrder, + String description, double mapX, double mapY, + String unlockedAt, ProgressDto progress) {} +record RegionResponse(String id, String name, String nodeType, int depth, String icon, + String parentId, int requiredFuel, boolean isUnlocked, boolean isCleared, + int sortOrder, String description, double mapX, double mapY, String unlockedAt) {} +record ProgressDto(int clearedChildren, int totalChildren, double progressRatio) {} +record UnlockedNodeDto(String id, String name, boolean isUnlocked, boolean isCleared, String unlockedAt) {} +record RegionUnlockResponse(UnlockedNodeDto region, int fuelConsumed, int currentFuel, boolean planetCleared) {} +record PlanetUnlockResponse(UnlockedNodeDto planet, int fuelConsumed, int currentFuel) {} +``` +`nodeType` 소문자(`value()`), `unlockedAt` ISO-8601 UTC. 정적 `of(...)` 팩토리 사용. + +--- + +## 8. 에러 처리 + +### 8.1 ErrorCode 추가 (5종) +```java +PLANET_NOT_FOUND(NOT_FOUND, "해당 행성을 찾을 수 없습니다."), +REGION_NOT_FOUND(NOT_FOUND, "해당 지역을 찾을 수 없습니다."), +ALREADY_UNLOCKED(BAD_REQUEST, "이미 해금된 노드입니다."), +PLANET_LOCKED(BAD_REQUEST, "상위 행성이 아직 해금되지 않았습니다."), +PREREQUISITE_NOT_CLEARED(BAD_REQUEST, "이전 행성을 먼저 클리어해야 합니다."), +``` +`INSUFFICIENT_FUEL`은 기존 것 재사용. + +### 8.2 INSUFFICIENT_FUEL 응답 보강 +- `ErrorResponse` record에 nullable `Integer requiredFuel`, `Integer currentFuel` 추가 + 클래스에 `@JsonInclude(JsonInclude.Include.NON_NULL)` → 기존 응답(두 필드 null)은 직렬화에서 생략되어 **기존 계약 불변**. + - 기존 `of(ErrorCode)` / `of(ErrorCode, message)`는 두 필드 null로 생성. 신규 `of(ErrorCode, requiredFuel, currentFuel)` 추가. +- `InsufficientFuelException extends RuntimeException` (필드 requiredFuel, currentFuel) 신규. +- `GlobalExceptionHandler`에 `@ExceptionHandler(InsufficientFuelException.class)` 추가 → 400 + `{code:"INSUFFICIENT_FUEL", message, requiredFuel, currentFuel}`. +- 응답 예시: +```json +{ "code": "INSUFFICIENT_FUEL", "message": "연료가 부족합니다.", "requiredFuel": 10, "currentFuel": 4 } +``` + +--- + +## 9. Flyway 마이그레이션 + +`SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql` +(version.yml 현재 `0.0.42`. 구현 시점에 CI가 올렸으면 그 값으로. 한 버전당 1파일.) + +### 스키마 +```sql +CREATE TABLE IF NOT EXISTS exploration_nodes ( + id VARCHAR(50) PRIMARY KEY, + name VARCHAR(50) NOT NULL, + node_type VARCHAR(10) NOT NULL, + depth INTEGER NOT NULL, + icon VARCHAR(30) NOT NULL, + parent_id VARCHAR(50), + prerequisite_node_id VARCHAR(50), + required_fuel INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + description VARCHAR(200) NOT NULL DEFAULT '', + map_x DOUBLE PRECISION NOT NULL DEFAULT 0, + map_y DOUBLE PRECISION NOT NULL DEFAULT 0, + CONSTRAINT fk_expl_node_parent FOREIGN KEY (parent_id) REFERENCES exploration_nodes(id), + CONSTRAINT fk_expl_node_prerequisite FOREIGN KEY (prerequisite_node_id) REFERENCES exploration_nodes(id), + CONSTRAINT chk_expl_node_type CHECK (node_type IN ('planet','region')), + CONSTRAINT chk_expl_required_fuel_non_negative CHECK (required_fuel >= 0) +); + +CREATE TABLE IF NOT EXISTS user_exploration_progress ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + node_id VARCHAR(50) NOT NULL, + is_unlocked BOOLEAN NOT NULL DEFAULT TRUE, + is_cleared BOOLEAN NOT NULL DEFAULT FALSE, + unlocked_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_user_expl_member FOREIGN KEY (user_id) REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT fk_user_expl_node FOREIGN KEY (node_id) REFERENCES exploration_nodes(id), + CONSTRAINT uq_user_expl UNIQUE (user_id, node_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_expl_user ON user_exploration_progress (user_id); +``` + +### 시드 — 행성 (8, 행성 먼저 INSERT, `ON CONFLICT (id) DO NOTHING`) + +| id | name | icon | required_fuel | prerequisite | sort | map_x | map_y | description | +|---|---|---|---|---|---|---|---|---| +|earth|지구|earth|0|NULL|0|0.5|0.08|우리의 출발지, 고향 행성| +|mercury|수성|mercury|3|earth|1|0.15|0.20|태양에 가장 가까운 작은 행성| +|venus|금성|venus|5|mercury|2|0.75|0.32|두꺼운 대기로 뒤덮인 뜨거운 행성| +|mars|화성|mars|10|venus|3|0.25|0.44|붉은 행성, 탐험의 꿈| +|jupiter|목성|jupiter|20|mars|4|0.7|0.56|태양계 최대의 가스 행성| +|saturn|토성|saturn|30|jupiter|5|0.2|0.68|아름다운 고리를 가진 행성| +|uranus|천왕성|uranus|45|saturn|6|0.8|0.80|옆으로 누워 자전하는 얼음 행성| +|neptune|해왕성|neptune|60|uranus|7|0.35|0.92|태양계 끝자락의 푸른 행성| + +### 시드 — 지역 (30, depth=3, prerequisite=NULL, map_x=0, map_y=0) + +**earth (12)** — icon=국가코드: +| id | name | icon | fuel | sort | description | +|---|---|---|---|---|---| +|korea|대한민국|KR|0|0|한반도 남쪽, K-컬쳐의 중심| +|japan|일본|JP|1|1|벚꽃과 기술의 나라| +|thailand|태국|TH|1|2|미소의 나라, 동남아의 허브| +|china|중국|CN|2|3|세계 최대 인구 대국| +|india|인도|IN|2|4|IT 강국, 다양한 문화의 보고| +|uk|영국|GB|2|5|해가 지지 않는 나라| +|france|프랑스|FR|2|6|예술과 낭만의 나라| +|canada|캐나다|CA|2|7|단풍과 자연의 나라| +|usa|미국|US|3|8|자유의 나라, 기회의 땅| +|brazil|브라질|BR|3|9|삼바와 축구의 나라| +|australia|호주|AU|3|10|코알라와 캥거루의 대륙| +|egypt|이집트|EG|2|11|피라미드와 나일강의 나라| + +**나머지 행성 지역 (18)** — icon=행성이름: +| id | parent | name | icon | fuel | sort | description | +|---|---|---|---|---|---|---| +|mercury_caloris|mercury|칼로리스 분지|mercury|1|0|수성 최대의 충돌 분지| +|mercury_plains|mercury|북극 평원|mercury|2|1|얼음이 숨겨진 영구 그림자 지대| +|venus_ishtar|venus|이슈타르 대지|venus|2|0|금성 북반구의 거대한 고원 지대| +|venus_aphrodite|venus|아프로디테 대지|venus|3|1|금성 적도를 따라 펼쳐진 최대 대지| +|venus_maxwell|venus|맥스웰 산|venus|3|2|금성에서 가장 높은 산맥| +|mars_olympus|mars|올림푸스 산|mars|3|0|태양계에서 가장 높은 화산| +|mars_valles|mars|마리너 계곡|mars|4|1|태양계 최대의 협곡| +|mars_polar|mars|극관 지대|mars|5|2|드라이아이스와 물 얼음의 극지방| +|jupiter_red_spot|jupiter|대적점|jupiter|5|0|수백 년간 지속되는 거대 폭풍| +|jupiter_europa|jupiter|유로파|jupiter|7|1|얼음 아래 바다가 있는 위성| +|jupiter_io|jupiter|이오|jupiter|8|2|화산 활동이 가장 활발한 위성| +|saturn_rings|saturn|토성 고리|saturn|8|0|얼음과 먼지로 이루어진 아름다운 고리| +|saturn_titan|saturn|타이탄|saturn|10|1|대기를 가진 유일한 위성, 메탄의 호수| +|saturn_enceladus|saturn|엔셀라두스|saturn|12|2|간헐천이 분출하는 얼음 위성| +|uranus_miranda|uranus|미란다|uranus|12|0|기괴한 지형의 작은 위성| +|uranus_atmosphere|uranus|천왕성 대기|uranus|15|1|메탄이 만드는 청록빛 대기| +|neptune_dark_spot|neptune|대흑점|neptune|15|0|초속 2000km 폭풍의 소용돌이| +|neptune_triton|neptune|트리톤|neptune|20|1|역행 궤도를 도는 거대 위성| + +총 **행성 8 + 지역 30 = 38 노드.** + +> 게이트 영향: mercury 해금하려면 earth의 12개 지역을 모두 해금(클리어)해야 함. 이는 의도된 진행 게이트다. + +--- + +## 10. API 스펙 문서 갱신 +`docs/api-specs/05_exploration.md`: +- 노드 객체에 `prerequisiteId` 필드, 행성 해금에 선행조건 + `PREREQUISITE_NOT_CLEARED`. +- DB 테이블 참고에 `prerequisite_node_id`. +- 예시 노드/연료 수치를 본 시드(8행성/30지역, 프론트 값)로 정정. region ID/icon 규칙(이름기반 ID, 국가코드/행성이름 icon) 명시. +- `INSUFFICIENT_FUEL` 응답에 `requiredFuel`/`currentFuel` 포함 명시. + +--- + +## 11. 테스트 전략 (TDD, 80%+) +프론트 Spring Boot 4 test-slice(StudyTestApplication, Testcontainers, create-drop) 사용. +- Entity 단위, Repository(타입/부모/유저 조회 + UNIQUE 위반), Service(Mockito: 목록 파생, 지역해금 정상/PLANET_LOCKED/ALREADY_UNLOCKED/REGION_NOT_FOUND/마지막지역 planetCleared, 행성해금 정상/PREREQUISITE_NOT_CLEARED/ALREADY_UNLOCKED/PLANET_NOT_FOUND, 잔량부족 시 InsufficientFuelException + consume 미호출), Controller(MockMvc 4엔드포인트 + 에러매핑 + INSUFFICIENT_FUEL 본문에 requiredFuel/currentFuel). +- ErrorResponse 직렬화: 두 필드 null이면 생략(@JsonInclude) 검증. + +--- + +## 12. 작업 범위 / 순서 +0. **working tree 변경 전부 폐기** (`git checkout -- .` + 미추적 exploration 파일/마이그레이션 삭제, 단 spec/plan 문서는 유지) → main 기준 clean. +1. ErrorCode 5종 + ErrorResponse 보강 + InsufficientFuelException + GlobalExceptionHandler 분기 (SS-Common) +2. NodeType + Converter (SS-Study) +3. ExplorationNode / UserExploration 엔티티 +4. Repository 2종 + StudyTestApplication 등록 +5. DTO 6종 +6. ExplorationService (조회 2 + 해금 2, 잔량 pre-check, FuelService 연동) +7. ExplorationController (SS-Web) +8. Flyway `V0_0_42` (스키마 + 시드 38노드) +9. `docs/api-specs/05_exploration.md` 갱신 +10. 단위/통합/컨트롤러 테스트