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
@@ -0,0 +1,17 @@
package com.elipair.spacestudyship.study.fuel.dto;

/**
* 공부 세션에서 파생된 연료 충전 결과.
*
* 환율: 30분 = 1 연료. 30분 미만 잔여분은 {@code newPendingMinutes}에 누적되어
* 다음 세션과 합산된다.
*
* @param amount 이번 호출로 충전된 연료 통 수 (0 이상)
* @param newPendingMinutes 충전 후 남은 잔여 분 (0~29)
* @param currentFuel 충전 후의 현재 연료 잔량
*/
public record FuelChargeFromStudyResult(
int amount,
int newPendingMinutes,
int currentFuel
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public class UserFuel extends BaseTimeEntity {
@Column(name = "pending_minutes", nullable = false)
private Integer pendingMinutes;

/**
* 공부 환율: 30분 = 1 연료.
*/
public static final int MINUTES_PER_FUEL = 30;

public static UserFuel initialize(Long userId) {
return UserFuel.builder()
.userId(userId)
Expand All @@ -61,6 +66,38 @@ public void charge(int amount) {
this.totalCharged += amount;
}

/**
* 공부 세션으로부터 연료를 충전한다.
*
* 잔여분({@link #pendingMinutes})과 이번 세션의 공부 시간을 합산하여
* {@value #MINUTES_PER_FUEL}분 단위로 끊어 충전하고, 나머지는 다음 세션을 위해
* 잔여분으로 이월한다. 정수 연료가 발생할 때만 {@code currentFuel} /
* {@code totalCharged}가 증가한다.
*
* @param studyMinutes 이번 세션의 공부 시간(분), 0 이하 입력은 {@link ErrorCode#INVALID_INPUT_VALUE}
* @return 충전된 연료 통 수와 갱신된 잔여 분
*/
public ChargeFromStudyResult chargeFromStudy(int studyMinutes) {
if (studyMinutes <= 0) {
throw new CustomException(ErrorCode.INVALID_INPUT_VALUE);
}
int totalMinutes = this.pendingMinutes + studyMinutes;
int amount = totalMinutes / MINUTES_PER_FUEL;
int newPending = totalMinutes % MINUTES_PER_FUEL;

this.pendingMinutes = newPending;
if (amount > 0) {
this.currentFuel += amount;
this.totalCharged += amount;
}
return new ChargeFromStudyResult(amount, newPending);
}

/**
* {@link #chargeFromStudy(int)} 결과 — Entity 내부 계산 결과를 서비스에 전달하기 위한 값 객체.
*/
public record ChargeFromStudyResult(int amount, int newPendingMinutes) {}

public void consume(int amount) {
if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE);
if (this.currentFuel < amount) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.elipair.spacestudyship.common.exception.ErrorCode;
import com.elipair.spacestudyship.study.fuel.constant.FuelReason;
import com.elipair.spacestudyship.study.fuel.constant.TransactionType;
import com.elipair.spacestudyship.study.fuel.dto.FuelChargeFromStudyResult;
import com.elipair.spacestudyship.study.fuel.dto.FuelResponse;
import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionListResponse;
import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse;
Expand Down Expand Up @@ -132,6 +133,91 @@ public FuelTransactionResponse consume(
return FuelTransactionResponse.from(tx);
}

/**
* 타이머 세션 완료로 인한 연료 충전.
*
* 환율: 30분 = 1 연료 (잔여분은 {@link UserFuel#pendingMinutes}로 이월).
*
* <h3>Idempotency 계약</h3>
* Idempotency 키는 {@code sessionId} (= {@link FuelTransaction#getId()}).
* <ul>
* <li><b>amount &gt; 0 (30분 이상 합산)</b>: 동일 sessionId 재호출 시 기존
* {@code fuel_transactions} row를 조회해 idempotent skip한다.
* 잔량/pending 변경 없음.</li>
* <li><b>amount = 0 (30분 미만 합산)</b>: {@code fuel_transactions} INSERT가 발생하지
* 않으므로 fuel-side에서 idempotency를 보장하지 못한다.
* <b>호출자는 sessionId의 유일성을 직접 보장해야 한다</b> (예: timer-side의
* {@code Idempotency-Key} 헤더 기반 dedup + 매 호출 신규 UUID 생성).
* 동일 sessionId 재호출 시 {@code pendingMinutes}가 중복 누적된다.</li>
* </ul>
*
* 현재 단일 호출자({@code TimerSessionService.create})가 매 호출마다 신규 UUID를 생성하므로
* 위 amount=0 시나리오는 발생하지 않는다. 향후 직접 호출자를 추가할 때는 이 계약을 확인하라.
*/
@Transactional
public FuelChargeFromStudyResult chargeFromStudy(
Long userId, int studyMinutes, String sessionId) {

if (studyMinutes <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE);

Optional<FuelTransaction> existing = transactionRepository.findById(sessionId);
if (existing.isPresent()) {
return idempotentReturnFromStudy(existing.get(), userId, sessionId);
}

UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId)
.orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED));

// 락 획득 후 race 재확인 (다른 트랜잭션이 동일 sessionId로 먼저 INSERT했을 가능성)
Optional<FuelTransaction> raced = transactionRepository.findById(sessionId);
if (raced.isPresent()) {
return idempotentReturnFromStudy(raced.get(), userId, sessionId);
}

UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(studyMinutes);

// amount=0이면 transaction을 만들지 않는다 — pending만 갱신.
// fuel_transactions의 chk_fuel_tx_amount_positive 제약으로 amount=0 INSERT 불가능하기도 하고,
// 거래 내역(GET /api/fuel/transactions)에 0연료 노이즈 row 노출을 피하기 위함.
// 대신 sessionId 유일성은 호출자(TimerSessionService)가 보장해야 한다. (위 Javadoc 참조)
if (result.amount() > 0) {
FuelTransaction tx = FuelTransaction.of(
sessionId, userId, TransactionType.CHARGE,
result.amount(), FuelReason.STUDY_SESSION,
sessionId, fuel.getCurrentFuel());
transactionRepository.save(tx);
}

log.info("[Fuel] 공부 세션 충전 | userId={}, studyMinutes={}, amount={}, " +
"pendingMinutes={}, balanceAfter={}, sessionId={}",
userId, studyMinutes, result.amount(),
result.newPendingMinutes(), fuel.getCurrentFuel(), sessionId);
return new FuelChargeFromStudyResult(
result.amount(), result.newPendingMinutes(), fuel.getCurrentFuel());
}
Comment on lines +157 to +197

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

amount=0일 때 멱등성 보장이 누락됨

result.amount() == 0인 경우(예: 기존 pendingMinutes가 0이고 studyMinutes가 30 미만) 트랜잭션이 저장되지 않습니다. 동일한 sessionId로 재호출 시 transactionRepository.findById(sessionId)가 빈 결과를 반환하여 pendingMinutes가 중복 누적될 수 있습니다.

현재는 TimerSessionServiceidempotencyKey 기반 검사가 상위 계층에서 보호하고 있지만, 이 메서드의 Javadoc에 명시된 멱등성 계약과 불일치합니다. 향후 직접 호출 시 데이터 불일치가 발생할 수 있습니다.

🛡️ amount=0일 때도 멱등성 마커 트랜잭션 저장 제안
         if (result.amount() > 0) {
             FuelTransaction tx = FuelTransaction.of(
                     sessionId, userId, TransactionType.CHARGE,
                     result.amount(), FuelReason.STUDY_SESSION,
                     sessionId, fuel.getCurrentFuel());
             transactionRepository.save(tx);
+        } else {
+            // amount=0이더라도 멱등성 보장을 위해 마커 트랜잭션 저장
+            FuelTransaction tx = FuelTransaction.of(
+                    sessionId, userId, TransactionType.CHARGE,
+                    0, FuelReason.STUDY_SESSION,
+                    sessionId, fuel.getCurrentFuel());
+            transactionRepository.save(tx);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Transactional
public FuelChargeFromStudyResult chargeFromStudy(
Long userId, int studyMinutes, String sessionId) {
if (studyMinutes <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE);
Optional<FuelTransaction> existing = transactionRepository.findById(sessionId);
if (existing.isPresent()) {
return idempotentReturnFromStudy(existing.get(), userId, sessionId);
}
UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId)
.orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED));
// 락 획득 후 race 재확인 (다른 트랜잭션이 동일 sessionId로 먼저 INSERT했을 가능성)
Optional<FuelTransaction> raced = transactionRepository.findById(sessionId);
if (raced.isPresent()) {
return idempotentReturnFromStudy(raced.get(), userId, sessionId);
}
UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(studyMinutes);
if (result.amount() > 0) {
FuelTransaction tx = FuelTransaction.of(
sessionId, userId, TransactionType.CHARGE,
result.amount(), FuelReason.STUDY_SESSION,
sessionId, fuel.getCurrentFuel());
transactionRepository.save(tx);
}
log.info("[Fuel] 공부 세션 충전 | userId={}, studyMinutes={}, amount={}, " +
"pendingMinutes={}, balanceAfter={}, sessionId={}",
userId, studyMinutes, result.amount(),
result.newPendingMinutes(), fuel.getCurrentFuel(), sessionId);
return new FuelChargeFromStudyResult(
result.amount(), result.newPendingMinutes(), fuel.getCurrentFuel());
}
`@Transactional`
public FuelChargeFromStudyResult chargeFromStudy(
Long userId, int studyMinutes, String sessionId) {
if (studyMinutes <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE);
Optional<FuelTransaction> existing = transactionRepository.findById(sessionId);
if (existing.isPresent()) {
return idempotentReturnFromStudy(existing.get(), userId, sessionId);
}
UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId)
.orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED));
// 락 획득 후 race 재확인 (다른 트랜잭션이 동일 sessionId로 먼저 INSERT했을 가능성)
Optional<FuelTransaction> raced = transactionRepository.findById(sessionId);
if (raced.isPresent()) {
return idempotentReturnFromStudy(raced.get(), userId, sessionId);
}
UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(studyMinutes);
if (result.amount() > 0) {
FuelTransaction tx = FuelTransaction.of(
sessionId, userId, TransactionType.CHARGE,
result.amount(), FuelReason.STUDY_SESSION,
sessionId, fuel.getCurrentFuel());
transactionRepository.save(tx);
} else {
// amount=0이더라도 멱등성 보장을 위해 마커 트랜잭션 저장
FuelTransaction tx = FuelTransaction.of(
sessionId, userId, TransactionType.CHARGE,
0, FuelReason.STUDY_SESSION,
sessionId, fuel.getCurrentFuel());
transactionRepository.save(tx);
}
log.info("[Fuel] 공부 세션 충전 | userId={}, studyMinutes={}, amount={}, " +
"pendingMinutes={}, balanceAfter={}, sessionId={}",
userId, studyMinutes, result.amount(),
result.newPendingMinutes(), fuel.getCurrentFuel(), sessionId);
return new FuelChargeFromStudyResult(
result.amount(), result.newPendingMinutes(), fuel.getCurrentFuel());
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java`
around lines 146 - 182, The method chargeFromStudy in FuelService currently
skips persisting a FuelTransaction when result.amount() == 0, breaking the
idempotency contract; always persist an idempotency marker transaction (e.g., a
FuelTransaction with amount 0 and TransactionType.CHARGE /
FuelReason.STUDY_SESSION and the same sessionId) after computing result so
subsequent calls to transactionRepository.findById(sessionId) will return it;
update the code path in chargeFromStudy to save this marker (and keep existing
behavior when amount>0), then return the same FuelChargeFromStudyResult as
before.


private FuelChargeFromStudyResult idempotentReturnFromStudy(
FuelTransaction tx, Long userId, String sessionId) {
if (!tx.getUserId().equals(userId)) {
throw new CustomException(ErrorCode.INVALID_INPUT_VALUE);
}
UserFuel fuel = userFuelRepository.findByUserId(userId)
.orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED));
log.info("[Fuel] 공부 세션 idempotent skip | userId={}, sessionId={}", userId, sessionId);
return new FuelChargeFromStudyResult(
tx.getAmount(), fuel.getPendingMinutes(), fuel.getCurrentFuel());
}

/**
* 특정 sessionId에 대응하는 충전 transaction의 amount를 조회한다.
* Timer 도메인이 dedup 응답을 만들 때 사용 (transaction이 없으면 0).
*/
public int findChargedAmountBySessionId(String sessionId) {
return transactionRepository.findById(sessionId)
.map(FuelTransaction::getAmount)
.orElse(0);
}

@Transactional
public void initialize(Long userId) {
if (userFuelRepository.existsByUserId(userId)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import com.elipair.spacestudyship.common.exception.CustomException;
import com.elipair.spacestudyship.common.exception.ErrorCode;
import com.elipair.spacestudyship.study.fuel.constant.FuelReason;
import com.elipair.spacestudyship.study.fuel.dto.FuelChargeFromStudyResult;
import com.elipair.spacestudyship.study.fuel.service.FuelService;
import com.elipair.spacestudyship.study.timer.dto.*;
import com.elipair.spacestudyship.study.timer.entity.TimerSession;
Expand Down Expand Up @@ -61,7 +61,8 @@ public TimerSessionCreateResponse create(
if (existing.isPresent()) {
log.info("[Timer] idempotent skip | userId={}, key={}, sessionId={}",
userId, normalizedKey, existing.get().getId());
return buildResponse(existing.get(), existing.get().getDurationMinutes());
int existingFuelCharged = fuelService.findChargedAmountBySessionId(existing.get().getId());
return buildResponse(existing.get(), existingFuelCharged);
}
}

Expand All @@ -88,21 +89,26 @@ public TimerSessionCreateResponse create(
if (raced.isPresent()) {
log.info("[Timer] idempotent race resolved | userId={}, key={}",
userId, normalizedKey);
return buildResponse(raced.get(), raced.get().getDurationMinutes());
int racedFuelCharged = fuelService.findChargedAmountBySessionId(raced.get().getId());
return buildResponse(raced.get(), racedFuelCharged);
}
}
throw e;
}

int fuelCharged = request.durationMinutes();
fuelService.charge(userId, fuelCharged, FuelReason.STUDY_SESSION, sessionId, sessionId);
// 30분 = 1연료 환산. 잔여분은 user_fuel.pendingMinutes에 이월되어 다음 세션과 합산.
FuelChargeFromStudyResult fuelResult = fuelService.chargeFromStudy(
userId, request.durationMinutes(), sessionId);
int fuelCharged = fuelResult.amount();

// Todo actualMinutes는 실제 공부 분(durationMinutes)으로 누적 (연료 환산과 무관)
if (request.todoId() != null) {
todoService.addActualMinutes(userId, request.todoId(), fuelCharged);
todoService.addActualMinutes(userId, request.todoId(), request.durationMinutes());
}

log.info("[Timer] 세션 저장 | userId={}, sessionId={}, duration={}분, todoId={}",
userId, sessionId, fuelCharged, request.todoId());
log.info("[Timer] 세션 저장 | userId={}, sessionId={}, studyMinutes={}, fuelCharged={}, pendingMinutes={}, todoId={}",
userId, sessionId, request.durationMinutes(), fuelCharged,
fuelResult.newPendingMinutes(), request.todoId());
return buildResponse(session, fuelCharged);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,88 @@ void consume_insufficient_throws() {
.isInstanceOf(CustomException.class)
.extracting("errorCode").isEqualTo(ErrorCode.INSUFFICIENT_FUEL);
}

// ---------- chargeFromStudy (30분 = 1연료) ----------

@Test
@DisplayName("chargeFromStudy: 30분 정확히 → 1연료 충전, pending=0")
void chargeFromStudy_exactly30_charges1() {
UserFuel fuel = UserFuel.initialize(1L);

UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(30);

assertThat(result.amount()).isEqualTo(1);
assertThat(result.newPendingMinutes()).isZero();
assertThat(fuel.getCurrentFuel()).isEqualTo(1);
assertThat(fuel.getTotalCharged()).isEqualTo(1);
assertThat(fuel.getPendingMinutes()).isZero();
}

@Test
@DisplayName("chargeFromStudy: 25분 → 0연료, pending=25 누적 (transaction 없이 잔여분만 이월)")
void chargeFromStudy_under30_pendingOnly() {
UserFuel fuel = UserFuel.initialize(1L);

UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(25);

assertThat(result.amount()).isZero();
assertThat(result.newPendingMinutes()).isEqualTo(25);
assertThat(fuel.getCurrentFuel()).isZero();
assertThat(fuel.getTotalCharged()).isZero();
assertThat(fuel.getPendingMinutes()).isEqualTo(25);
}

@Test
@DisplayName("chargeFromStudy: 90분 → 3연료, pending=0")
void chargeFromStudy_multiple_3() {
UserFuel fuel = UserFuel.initialize(1L);

UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(90);

assertThat(result.amount()).isEqualTo(3);
assertThat(result.newPendingMinutes()).isZero();
assertThat(fuel.getCurrentFuel()).isEqualTo(3);
}

@Test
@DisplayName("chargeFromStudy: pending 25 + 20분 → 1연료, pending=15 (잔여분 이월 누적)")
void chargeFromStudy_pendingCarriedOver() {
UserFuel fuel = UserFuel.initialize(1L);
fuel.chargeFromStudy(25); // pending=25

UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(20);

assertThat(result.amount()).isEqualTo(1);
assertThat(result.newPendingMinutes()).isEqualTo(15);
assertThat(fuel.getCurrentFuel()).isEqualTo(1);
assertThat(fuel.getPendingMinutes()).isEqualTo(15);
}

@Test
@DisplayName("chargeFromStudy: pending 15 + 50분 → 2연료, pending=5")
void chargeFromStudy_pendingPlusLargeStudy() {
UserFuel fuel = UserFuel.initialize(1L);
fuel.chargeFromStudy(25);
fuel.chargeFromStudy(20); // 누적 currentFuel=1, pending=15

UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(50);

assertThat(result.amount()).isEqualTo(2);
assertThat(result.newPendingMinutes()).isEqualTo(5);
assertThat(fuel.getCurrentFuel()).isEqualTo(3); // 0+1+2
assertThat(fuel.getTotalCharged()).isEqualTo(3);
}

@Test
@DisplayName("chargeFromStudy: studyMinutes<=0이면 INVALID_INPUT_VALUE")
void chargeFromStudy_nonPositive_throws() {
UserFuel fuel = UserFuel.initialize(1L);

assertThatThrownBy(() -> fuel.chargeFromStudy(0))
.isInstanceOf(CustomException.class)
.extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE);
assertThatThrownBy(() -> fuel.chargeFromStudy(-5))
.isInstanceOf(CustomException.class)
.extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE);
}
}
Loading
Loading