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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.example.cp_main_be.domain.member.user.domain.User;
import jakarta.persistence.*;
import jakarta.validation.constraints.Size;
import lombok.*;

@Entity
Expand All @@ -17,6 +18,7 @@ public class Avatar {
@Column(name = "avatar_id")
private Long id;

@Size(max = 14, message = "닉네임은 14자 이하여야 합니다.")
@Column(nullable = false)
private String nickname;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.example.cp_main_be.domain.member.user.domain.User;
import com.fasterxml.jackson.annotation.JsonBackReference;
import jakarta.persistence.*;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import lombok.*;
Expand Down Expand Up @@ -78,6 +79,27 @@ public Garden(User user, Integer slotNumber, GardenBackground gardenBackground,
this.avatar = avatar; // [추가]
}

@Transient // DB에 저장하지 않는, 계산된 필드임을 명시
public boolean isWaterableByOwner() {
if (this.lastWateredByOwnerAt == null) {
return true; // 한 번도 물을 준 적이 없다면 항상 가능
}
LocalDateTime nextWaterableTime = this.lastWateredByOwnerAt.plusHours(4);
// 다음 물주기 가능 시간이 현재 시간보다 이전이거나 같으면 true
return !nextWaterableTime.isAfter(LocalDateTime.now(ZoneId.of("Asia/Seoul")));
}

@Transient // DB에 저장하지 않는, 계산된 필드임을 명시
public long getWaterableByOwnerInSeconds() {
if (isWaterableByOwner()) {
return 0L; // 이미 물주기가 가능하면 남은 시간은 0
}
LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
LocalDateTime nextWaterableTime = this.lastWateredByOwnerAt.plusHours(4);
long remainingSeconds = Duration.between(now, nextWaterableTime).getSeconds();
return Math.max(0L, remainingSeconds);
}

public void unlock() {
this.isLocked = false;
this.createdAt = LocalDateTime.now();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.example.cp_main_be.domain.garden.garden.service;

import com.example.cp_main_be.domain.avatar.avatar.domain.repository.AvatarRepository;
import com.example.cp_main_be.domain.garden.garden.domain.Garden;
import com.example.cp_main_be.domain.garden.garden.domain.GardenBackground;
import com.example.cp_main_be.domain.garden.garden.domain.repository.GardenBackgroundRepository;
Expand All @@ -11,13 +10,11 @@
import com.example.cp_main_be.domain.garden.wateringlog.domain.repository.FriendWateringLogRepository;
import com.example.cp_main_be.domain.member.user.domain.User;
import com.example.cp_main_be.domain.member.user.domain.repository.UserRepository;
import com.example.cp_main_be.domain.member.user.service.UserService;
import com.example.cp_main_be.domain.mission.wishTree.WishTree;
import com.example.cp_main_be.domain.mission.wishTree.WishTreeRepository;
import com.example.cp_main_be.domain.mission.wishTree.WishTreeService;
import com.example.cp_main_be.global.common.CustomApiException;
import com.example.cp_main_be.global.common.ErrorCode;
import com.example.cp_main_be.global.event.WishTreeEvolvedEvent;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
Expand All @@ -36,19 +33,15 @@
@Slf4j
public class GardenService {

private static final int MAX_GARDEN_COUNT = 3;
private static final Long WATERING_POINTS = 2L;
private static final Long SUNLIGHT_POINTS = 3L;
private static final Long MAX_FRIEND_WATERING_PER_DAY = 3L;
private final WishTreeService wishTreeService;
private final GardenRepository gardenRepository;
private final UserService userService;
private final ApplicationEventPublisher eventPublisher;
private final GardenBackgroundRepository gardenBackgroundRepository;
private final AvatarRepository avatarRepository;
private final UserRepository userRepository;
private final FriendWateringLogRepository friendWateringLogRepository;
private final WishTreeRepository wishTreeRepository;

public GardenResponse findGardenById(Long gardenId) {
Garden garden =
Expand All @@ -61,8 +54,6 @@ public GardenResponse findGardenById(Long gardenId) {

@Transactional
public void waterGarden(Long actorId, Long gardenId) {
// N+1 문제를 방지하기 위해 Garden과 User를 함께 조회하는 것을 권장합니다.
// 예: gardenRepository.findByIdWithUser(gardenId)
Garden garden =
gardenRepository
.findById(gardenId)
Expand All @@ -80,12 +71,8 @@ public void waterGarden(Long actorId, Long gardenId) {
/** 자신의 정원에 물을 주는 로직을 처리합니다. */
@Transactional
public void waterOwnGarden(Long ownerId, Garden garden) {
// [수정] 시간대 문제를 방지하기 위해, 서울 시간 기준으로 현재 시간을 명시적으로 사용합니다.
LocalDateTime nowInSeoul = LocalDateTime.now(ZoneId.of("Asia/Seoul"));

// 8시간 쿨타임 체크
if (garden.getLastWateredByOwnerAt() != null
&& garden.getLastWateredByOwnerAt().plusHours(8).isAfter(nowInSeoul)) {
// [수정] Garden 객체에게 직접 물을 줄 수 있는지 물어봅니다.
if (!garden.isWaterableByOwner()) {
throw new CustomApiException(ErrorCode.WATERING_COOL_DOWN);
}

Expand All @@ -95,48 +82,40 @@ public void waterOwnGarden(Long ownerId, Garden garden) {
}

/** 친구의 정원에 물을 주는 로직을 처리합니다. */
private void waterFriendGarden(Long actorId, Garden garden) {
@Transactional
public void waterFriendGarden(Long actorId, Garden garden) {
User actor =
userRepository
.findById(actorId)
.orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND));

// 낮 12시 이후 -> 그날의 12시 날짜 반환, 이전 -> 전날의 12시 날짜 반환
LocalDateTime startOfWateringDay = getStartOfCurrentWateringDay();

// 1. 하루에 3회 제한 체크
checkFriendWateringLimit(actor, startOfWateringDay);
LocalDateTime startOfToday = getStartOfToday();

// 2. 같은 정원에 하루 한 번 제한 체크
checkAlreadyWateredToday(actor, garden, startOfWateringDay);
checkFriendWateringLimit(actor, startOfToday);
checkAlreadyWateredToday(actor, garden, startOfToday);

// 남한테 주는 경우에는 준 사람이 물 경험치를 받고 정원의 waterCount가 증가한다.
wishTreeService.addPointsToWishTree(actor.getId(), WATERING_POINTS);
garden.increaseWaterCount();

// [수정] 시간대(Timezone) 문제를 방지하기 위해, 물 준 시간을 명시적으로 기록합니다.
LocalDateTime nowInSeoul = LocalDateTime.now(ZoneId.of("Asia/Seoul"));

// 물주기 활동 기록
FriendWateringLog log =
FriendWateringLog.builder()
.waterGiver(actor)
.wateredGarden(garden)
.wateredAt(nowInSeoul) // @CreatedDate 대신 직접 시간을 설정합니다.
.wateredAt(nowInSeoul)
.build();
friendWateringLogRepository.save(log);
}
Comment on lines +85 to 109
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

동시성: 친구 물주기 중복 처리 레이스 조건

두 요청이 동시에 들어오면 limit/exists 체크를 모두 통과한 뒤 로그가 2건 저장될 수 있고 포인트가 중복 적립될 수 있습니다. 애플리케이션 레벨 체크만으로는 원자성을 보장하기 어렵습니다.

대안:

  • DB 고유 제약 도입(권장): (giver, garden, watering_day) 유니크. watering_day는 Asia/Seoul 기준의 날짜 컬럼(또는 함수 인덱스)로 설계. 충돌 시 DataIntegrityViolationException을 ALREADY_WATERED_GARDEN으로 변환.
  • 또는 비관적 락: Garden 또는 (giver,garden) 키에 대해 PESSIMISTIC_WRITE로 단일화 처리.
    선택지 확정 시 필요한 DDL/코드 수정 제안 드리겠습니다.
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
around lines 85-109, concurrent requests can bypass the in-memory checks and
create duplicate FriendWateringLog rows and duplicate point awards; add a
DB-level uniqueness constraint on (water_giver_id, watered_garden_id,
watering_day) where watering_day is computed in Asia/Seoul (either a persisted
DATE column set from now(ZoneId.of("Asia/Seoul")) or a DB function-based index),
and update service code to handle DataIntegrityViolationException (or the
specific SQL constraint exception) by translating it to ALREADY_WATERED_GARDEN;
alternatively, if you prefer locks, obtain a PESSIMISTIC_WRITE lock on the
relevant Garden or a composite owner/garden lock within the same @Transactional
boundary before performing checks and inserts to serialize concurrent writers.


// 물주기 남은 횟수 확인
private void checkFriendWateringLimit(User actor, LocalDateTime startOfWateringDay) {
int todayWateringCount =
long todayWateringCount =
friendWateringLogRepository.countByWaterGiverAndWateredAtAfter(actor, startOfWateringDay);
if (todayWateringCount >= MAX_FRIEND_WATERING_PER_DAY) {
throw new CustomApiException(ErrorCode.FRIEND_WATERING_LIMIT_EXCEEDED);
}
}

// 당일에 해당 정원에 이미 물을 주었는지 확인
private void checkAlreadyWateredToday(
User actor, Garden garden, LocalDateTime startOfWateringDay) {
boolean alreadyWatered =
Expand All @@ -149,18 +128,15 @@ private void checkAlreadyWateredToday(

@Transactional
public void sunlightGarden(Long actorId, Long gardenId) {
// 텃밭 가져오기
Garden garden =
gardenRepository
.findById(gardenId)
.orElseThrow(() -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND));

// 햇빛은 본인만 줄 수 있도록 검증
if (!garden.getUser().getId().equals(actorId)) {
throw new CustomApiException(ErrorCode.ACCESS_DENIED, "자신의 정원에만 햇빛을 줄 수 있습니다.");
}

// 하루에 한 번만 햇빛을 줄 수 있도록 체크 (초기화 시간: 오전 6시)
LocalDateTime startOfSunlightDay = getStartOfCurrentSunlightDay();
if (garden.getLastSunlightReceivedAt() != null
&& garden.getLastSunlightReceivedAt().isAfter(startOfSunlightDay)) {
Expand All @@ -170,38 +146,29 @@ public void sunlightGarden(Long actorId, Long gardenId) {

garden.increaseSunlightCount();
wishTreeService.addPointsToWishTree(actorId, SUNLIGHT_POINTS);

// [수정] 시간대 문제를 해결하기 위해, 서울 시간 기준으로 현재 시간을 명시적으로 기록합니다.
garden.recordSunlightTime();
}

@Transactional
public void unlockNextGarden(Long userId) {
// 1. 유저와 소원나무 정보 가져오기
User user =
userRepository
.findById(userId)
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));
WishTree wishTree =
wishTreeRepository
.findByUserId(user.getId())
.orElseThrow(() -> new CustomApiException(ErrorCode.NOT_FOUND));

// 2. 해금 가능한 상태인지 확인
if (!wishTree.isUnlockable()) {
throw new CustomApiException(ErrorCode.GARDEN_SLOT_LOCKED, "정원을 해금할 수 있는 포인트가 부족합니다.");
if (user.getUnlockableGardenCount() <= 0) {
throw new CustomApiException(ErrorCode.GARDEN_SLOT_LOCKED, "해금할 수 있는 정원이 없습니다.");
}

// 3. 소원나무 Stage를 다음 단계로 성장시키기
wishTree.evolveStage();

// 4. 다음으로 잠겨있는 정원을 찾아 해금하기
// 먼저 정원 조회 (실패 시 여기서 예외)
Garden gardenToUnlock =
gardenRepository
.findFirstByUserAndIsLockedIsTrueOrderBySlotNumberAsc(user)
.orElseThrow(
() -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND, "해금할 정원을 찾을 수 없습니다."));
.orElseThrow(() -> new CustomApiException(ErrorCode.GARDEN_NOT_FOUND, "해금할 정원이 없습니다."));

gardenToUnlock.unlock(); // Garden 엔티티의 isLocked를 false로 변경
// 정원이 존재할 때만 실행
gardenToUnlock.unlock();
user.decrementUnlockableGardenCount(); // 성공 확정 후 카운트 감소
}

@Transactional
Expand All @@ -225,34 +192,13 @@ public List<GardenBackgroundCandidateResponse> getAllBackgrounds() {
.collect(Collectors.toList());
}

/**
* 현재 시간 기준으로 물주기 횟수가 초기화되는 시간(정오)을 계산합니다. - 현재 시간이 정오 이전이면, 어제 정오를 반환합니다. - 현재 시간이 정오 이후이면, 오늘
* 정오를 반환합니다.
*
* @return 현재 물주기 주기의 시작 시간
*/
private LocalDateTime getStartOfCurrentWateringDay() {
// 서버 위치와 관계없이 항상 한국 시간 기준으로 동작하도록 시간대를 명시합니다.
LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
LocalDateTime todayNoon = now.toLocalDate().atTime(12, 0);

if (now.isBefore(todayNoon)) {
return todayNoon.minusDays(1);
} else {
return todayNoon;
}
/** 친구에게 물 주는 횟수가 초기화되는 시간(자정)을 반환합니다. */
private LocalDateTime getStartOfToday() {
return LocalDate.now(ZoneId.of("Asia/Seoul")).atStartOfDay();
}

/**
* 현재 시간 기준으로 햇빛 주기 횟수가 초기화되는 시간(오전 6시)을 계산합니다. - 현재 시간이 오전 6시 이전이면, 어제 오전 6시를 반환합니다. - 현재 시간이 오전
* 6시 이후이면, 오늘 오전 6시를 반환합니다.
*
* @return 현재 햇빛 주기의 시작 시간
*/
private LocalDateTime getStartOfCurrentSunlightDay() {
// 서버 위치와 관계없이 항상 한국 시간 기준으로 동작하도록 시간대를 명시합니다.
LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
// 해당 날짜의 6시 반환
LocalDateTime todaySixAM = now.toLocalDate().atTime(6, 0);

if (now.isBefore(todaySixAM)) {
Expand All @@ -262,11 +208,9 @@ private LocalDateTime getStartOfCurrentSunlightDay() {
}
}

/** 오래된 친구 물주기 로그를 주기적으로 삭제하는 스케줄링 작업입니다. cron = "0 0 4 * * *" : 매일 새벽 4시에 실행됩니다. */
@Scheduled(cron = "0 0 4 * * *")
@Transactional // 쓰기 작업이므로 클래스 레벨의 readOnly 설정을 오버라이드합니다.
@Transactional
public void cleanupOldWateringLogs() {
// 7일 이상된 기록을 삭제하도록 설정. 이 값은 application.yml에서 관리하는 것이 더 좋습니다.
final int RETENTION_DAYS = 7;
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(RETENTION_DAYS);
log.info("Starting cleanup of friend watering logs older than {} days...", RETENTION_DAYS);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.cp_main_be.domain.home;

import java.time.LocalDateTime;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
Expand Down Expand Up @@ -38,6 +39,10 @@ public static class GardenSummaryInfo {
private boolean isUnlockable;
private boolean isOwnerWateringAble; // 본인 정원에 물주기 가능한지 여부 -> 해금안되면 null
private boolean isOwnerSunlightAble; // 본인 정원에 햇빛 주기 가능한지 여부 -> 해금안되면 null
// [추가] 다음 물주기 가능 시간 (ISO 8601 형식 문자열)
private LocalDateTime nextWaterableAt;
// [추가] 다음 물주기까지 남은 시간 (초 단위)
private Long waterableInSeconds;
}

@Getter
Expand Down
Loading