Skip to content

Commit d208b8c

Browse files
authored
Merge pull request #169 from prgrms-aibe-devcourse/feat/168-seed-assignment-policy
feat: 씨앗 배정 정책 개선 - 해금 풀 기반 랜덤 배정 및 FULL_BLOOM 수확 시 씨앗 풀 확장
2 parents 8c593e8 + 034f7b3 commit d208b8c

8 files changed

Lines changed: 286 additions & 84 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.Rootin.domain.garden.entity;
2+
3+
import com.Rootin.global.BaseEntity;
4+
import jakarta.persistence.*;
5+
import lombok.AccessLevel;
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
@Entity
11+
@Table(name = "plant_collection",
12+
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "plant_id"}))
13+
@Getter
14+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
15+
public class PlantCollection extends BaseEntity {
16+
17+
@Id
18+
@GeneratedValue(strategy = GenerationType.IDENTITY)
19+
private Long id;
20+
21+
@Column(name = "user_id", nullable = false)
22+
private Long userId;
23+
24+
@Column(name = "plant_id", nullable = false)
25+
private Long plantId;
26+
27+
@Builder
28+
public PlantCollection(Long userId, Long plantId) {
29+
this.userId = userId;
30+
this.plantId = plantId;
31+
}
32+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.Rootin.domain.garden.repository;
2+
3+
import com.Rootin.domain.garden.entity.PlantCollection;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Query;
6+
import org.springframework.data.repository.query.Param;
7+
import org.springframework.stereotype.Repository;
8+
9+
import java.util.List;
10+
11+
@Repository
12+
public interface PlantCollectionRepository extends JpaRepository<PlantCollection, Long> {
13+
14+
boolean existsByUserId(Long userId);
15+
16+
boolean existsByUserIdAndPlantId(Long userId, Long plantId);
17+
18+
@Query("SELECT pc.plantId FROM PlantCollection pc WHERE pc.userId = :userId")
19+
List<Long> findPlantIdsByUserId(@Param("userId") Long userId);
20+
}

src/main/java/com/Rootin/domain/garden/service/HarvestService.java

Lines changed: 13 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,14 @@
1414
import org.springframework.stereotype.Service;
1515
import org.springframework.transaction.annotation.Transactional;
1616

17-
import java.util.List;
18-
import java.util.concurrent.ThreadLocalRandom;
19-
2017
@Service
2118
@RequiredArgsConstructor
2219
public class HarvestService {
2320

2421
private final PotRepository potRepository;
2522
private final PlantItemRepository plantItemRepository;
2623
private final PlantRepository plantRepository;
24+
private final SeedAssignmentService seedAssignmentService;
2725
private final LevelCalculator levelCalculator;
2826

2927
@Transactional
@@ -45,12 +43,22 @@ public HarvestResponse harvest(Long userId, Long potId) {
4543

4644
// 4. 수확 처리 (경험치 중복 연산을 피하고 화분에 저장된 최신 레벨 정보를 직접 활용하여 상태를 연동합니다)
4745
current.harvest(pot.getLevel(), stageIndex);
46+
// IDENTITY 전략의 즉시 INSERT로 인해 ux_plant_item_one_active_per_pot 제약 위반을 막기 위해 먼저 플러시합니다.
47+
plantItemRepository.saveAndFlush(current);
4848

4949
Plant harvestedPlant = plantRepository.findById(current.getPlantId())
5050
.orElseThrow(() -> CustomException.notFound("식물 마스터 데이터를 찾을 수 없습니다."));
5151

52-
// 5. 다음 키울 새로운 랜덤 식물(씨앗 단계)을 선택하여 화분에 배정합니다.
53-
Plant nextPlant = selectRandomPlant();
52+
// 5. 수확 단계에 따라 다음 씨앗 배정
53+
// FULL_BLOOM: 전체 풀 랜덤 + 새 종이면 해금 풀에 추가
54+
// 미만: 기존 해금 풀 내 랜덤 (풀 변화 없음)
55+
Plant nextPlant;
56+
if (stageIndex == GrowthStage.FULL_BLOOM.ordinal()) {
57+
nextPlant = seedAssignmentService.selectFromAllPlants();
58+
seedAssignmentService.addToCollectionIfNew(userId, nextPlant);
59+
} else {
60+
nextPlant = seedAssignmentService.selectFromCollection(userId);
61+
}
5462

5563
plantItemRepository.save(PlantItem.builder()
5664
.userId(userId)
@@ -67,32 +75,4 @@ public HarvestResponse harvest(Long userId, Long potId) {
6775
nextPlant.getGrade() == Grade.RARE ? "희귀" : "일반"
6876
);
6977
}
70-
71-
/**
72-
* 다음 화분에 배정할 임의의 식물 마스터(SEED 단계) 데이터를 선택합니다.
73-
*/
74-
private Plant selectRandomPlant() {
75-
Grade grade = decideNextPlantGrade();
76-
77-
List<Plant> candidates = plantRepository.findByGradeAndGrowthStage(grade, GrowthStage.SEED);
78-
if (candidates.isEmpty()) {
79-
// RARE 등급 식물이 데이터베이스에 존재하지 않으면 COMMON 등급 식물로 대체(Fallback)합니다.
80-
candidates = plantRepository.findByGradeAndGrowthStage(Grade.COMMON, GrowthStage.SEED);
81-
}
82-
if (candidates.isEmpty()) {
83-
throw CustomException.notFound("배정 가능한 식물 마스터 데이터가 없습니다.");
84-
}
85-
86-
return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
87-
}
88-
89-
/**
90-
* 다음 식물의 등급을 무작위로 선택합니다. (10% 확률로 RARE, 90% 확률로 COMMON)
91-
* 단위 테스트 시 난수 확률과 무관하게 RARE Fallback 분기를 100% 확정 검증할 수 있도록
92-
* Mockito Spy를 적용 가능하도록 protected 메소드로 추출했습니다.
93-
*/
94-
protected Grade decideNextPlantGrade() {
95-
boolean isRare = ThreadLocalRandom.current().nextDouble() < 0.1;
96-
return isRare ? Grade.RARE : Grade.COMMON;
97-
}
9878
}

src/main/java/com/Rootin/domain/garden/service/PotService.java

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import com.Rootin.domain.garden.dto.PotResponse;
55
import com.Rootin.domain.garden.dto.PotSummaryResponse;
66
import com.Rootin.domain.garden.dto.PotUpdateRequest;
7+
import com.Rootin.domain.garden.entity.PlantCollection;
78
import com.Rootin.domain.garden.entity.PlantItem;
89
import com.Rootin.domain.garden.entity.Pot;
10+
import com.Rootin.domain.garden.repository.PlantCollectionRepository;
911
import com.Rootin.domain.garden.repository.PlantItemRepository;
1012
import com.Rootin.domain.garden.repository.PotRepository;
1113
import com.Rootin.domain.garden.repository.WateringLogRepository;
@@ -52,6 +54,8 @@ public class PotService {
5254
private final PotRepository potRepository;
5355
private final PlantItemRepository plantItemRepository;
5456
private final PlantRepository plantRepository;
57+
private final PlantCollectionRepository plantCollectionRepository;
58+
private final SeedAssignmentService seedAssignmentService;
5559
private final TilRepository tilRepository;
5660
private final AiResultRepository aiResultRepository;
5761
private final WateringLogRepository wateringLogRepository;
@@ -72,32 +76,39 @@ public class PotService {
7276
*/
7377
@Transactional
7478
public PotResponse createPot(Long userId, PotCreateRequest request) {
75-
Plant defaultPlant = getDefaultPlant();
79+
Plant selectedPlant = resolveInitialSeed(userId);
7680

77-
// 1. 화분 데이터 생성 및 저장
7881
Pot pot = Pot.builder()
7982
.userId(userId)
8083
.title(request.getTitle())
8184
.description(request.getDescription())
8285
.build();
8386
Pot savedPot = potRepository.save(pot);
8487

85-
// 2. 화분에 심을 기본 씨앗 정보 매핑 저장
86-
PlantItem plantItem = PlantItem.builder()
88+
plantItemRepository.save(PlantItem.builder()
8789
.userId(userId)
8890
.potId(savedPot.getId())
89-
.plantId(defaultPlant.getId())
90-
.build();
91-
plantItemRepository.save(plantItem);
91+
.plantId(selectedPlant.getId())
92+
.build());
9293

9394
return PotResponse.from(savedPot);
9495
}
9596

96-
private Plant getDefaultPlant() {
97-
// 화분 생성 시 반드시 필요한 마스터 데이터입니다.
98-
// 이 데이터가 없으면 서비스가 정상 동작할 수 없으므로 notFound 예외로 빠르게 문제를 드러냅니다.
99-
return plantRepository.findFirstByNameAndGradeAndGrowthStage(DEFAULT_PLANT_NAME, Grade.COMMON, GrowthStage.SEED)
100-
.orElseThrow(() -> CustomException.notFound("기본 식물 마스터 데이터가 존재하지 않습니다."));
97+
private Plant resolveInitialSeed(Long userId) {
98+
boolean hasCollection = plantCollectionRepository.existsByUserId(userId);
99+
if (!hasCollection) {
100+
// 첫 화분: 기본 씨앗 고정 후 해금 풀에 등록
101+
Plant defaultPlant = plantRepository.findFirstByNameAndGradeAndGrowthStage(
102+
DEFAULT_PLANT_NAME, Grade.COMMON, GrowthStage.SEED)
103+
.orElseThrow(() -> CustomException.notFound("기본 식물 마스터 데이터가 존재하지 않습니다."));
104+
plantCollectionRepository.save(PlantCollection.builder()
105+
.userId(userId)
106+
.plantId(defaultPlant.getId())
107+
.build());
108+
return defaultPlant;
109+
}
110+
// 이후 화분: 해금 풀 내 랜덤 배정
111+
return seedAssignmentService.selectFromCollection(userId);
101112
}
102113

103114
/**
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.Rootin.domain.garden.service;
2+
3+
import com.Rootin.domain.garden.entity.PlantCollection;
4+
import com.Rootin.domain.garden.repository.PlantCollectionRepository;
5+
import com.Rootin.domain.plant.entity.Plant;
6+
import com.Rootin.domain.plant.entity.enums.Grade;
7+
import com.Rootin.domain.plant.entity.enums.GrowthStage;
8+
import com.Rootin.domain.plant.repository.PlantRepository;
9+
import com.Rootin.global.exception.CustomException;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
import java.util.List;
15+
import java.util.concurrent.ThreadLocalRandom;
16+
17+
/**
18+
* 씨앗 배정 정책을 담당하는 서비스입니다.
19+
*
20+
* CASE A: 화분 생성 시
21+
* - 첫 화분(해금 풀 비어있음) → 기본 씨앗 고정
22+
* - 이후 화분 → 해금 풀(plant_collection) 내 등급별 랜덤
23+
*
24+
* CASE B: FULL_BLOOM 미만 수확 시
25+
* - 해금 풀 내 등급별 랜덤 (풀 변화 없음)
26+
*
27+
* CASE C: FULL_BLOOM 수확 시
28+
* - 전체 풀 랜덤 → 새 종이면 해금 풀에 추가
29+
*
30+
* 확률: RARE 해금 있으면 COMMON 90% / RARE 10%, 없으면 COMMON 100%
31+
* 등급 내에서는 해금된 종끼리 균등 분배
32+
*/
33+
@Service
34+
@RequiredArgsConstructor
35+
@Transactional(readOnly = true)
36+
public class SeedAssignmentService {
37+
38+
private final PlantCollectionRepository plantCollectionRepository;
39+
private final PlantRepository plantRepository;
40+
41+
public Plant selectFromCollection(Long userId) {
42+
List<Long> plantIdsInCollection = plantCollectionRepository.findPlantIdsByUserId(userId);
43+
if (plantIdsInCollection.isEmpty()) {
44+
throw CustomException.notFound("해금된 씨앗이 없습니다.");
45+
}
46+
47+
Grade grade = decideGrade();
48+
List<Plant> candidates = plantRepository.findByGradeAndGrowthStageAndIdIn(grade, GrowthStage.SEED, plantIdsInCollection);
49+
50+
if (candidates.isEmpty() && grade == Grade.RARE) {
51+
candidates = plantRepository.findByGradeAndGrowthStageAndIdIn(Grade.COMMON, GrowthStage.SEED, plantIdsInCollection);
52+
}
53+
if (candidates.isEmpty()) {
54+
throw CustomException.notFound("배정 가능한 씨앗 마스터 데이터가 없습니다.");
55+
}
56+
57+
return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
58+
}
59+
60+
public Plant selectFromAllPlants() {
61+
Grade grade = decideGrade();
62+
List<Plant> candidates = plantRepository.findByGradeAndGrowthStage(grade, GrowthStage.SEED);
63+
if (candidates.isEmpty()) {
64+
candidates = plantRepository.findByGradeAndGrowthStage(Grade.COMMON, GrowthStage.SEED);
65+
}
66+
if (candidates.isEmpty()) {
67+
throw CustomException.notFound("배정 가능한 식물 마스터 데이터가 없습니다.");
68+
}
69+
return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
70+
}
71+
72+
@Transactional
73+
public void addToCollectionIfNew(Long userId, Plant plant) {
74+
if (!plantCollectionRepository.existsByUserIdAndPlantId(userId, plant.getId())) {
75+
plantCollectionRepository.save(PlantCollection.builder()
76+
.userId(userId)
77+
.plantId(plant.getId())
78+
.build());
79+
}
80+
}
81+
82+
protected Grade decideGrade() {
83+
return ThreadLocalRandom.current().nextDouble() < 0.1 ? Grade.RARE : Grade.COMMON;
84+
}
85+
}

src/main/java/com/Rootin/domain/plant/repository/PlantRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,6 @@ public interface PlantRepository extends JpaRepository<Plant, Long> {
2626
* @return 해당 이름들을 가진 모든 단계/등급의 식물 엔티티 목록
2727
*/
2828
List<Plant> findByNameIn(List<String> names);
29+
30+
List<Plant> findByGradeAndGrowthStageAndIdIn(Grade grade, GrowthStage growthStage, List<Long> ids);
2931
}

0 commit comments

Comments
 (0)