Skip to content

타이머 세션 도메인 구현 : feat : 연료 환율 30분=1연료 + 잔여분 이월(pendingMinutes) — 프론트 게…#39

Merged
EM-H20 merged 2 commits into
mainfrom
20260422_#25_타이머_세션_도메인_구현
May 26, 2026

Hidden character warning

The head ref may contain hidden characters: "20260422_#25_\ud0c0\uc774\uba38_\uc138\uc158_\ub3c4\uba54\uc778_\uad6c\ud604"
Merged

타이머 세션 도메인 구현 : feat : 연료 환율 30분=1연료 + 잔여분 이월(pendingMinutes) — 프론트 게…#39
EM-H20 merged 2 commits into
mainfrom
20260422_#25_타이머_세션_도메인_구현

Conversation

@EM-H20

@EM-H20 EM-H20 commented May 26, 2026

Copy link
Copy Markdown
Contributor

…스트 모드 정책 미러링, UserFuel.chargeFromStudy + FuelService.chargeFromStudy 추가, TimerSessionService 호출부 변경 #25

✨ 변경 사항


✅ 테스트


  • 수동 테스트 완료
  • 테스트 코드 완료

Summary by CodeRabbit

Release Notes

  • New Features

    • 공부 시간 기반 연료 충전 시스템 추가: 30분당 1연료 환산, 30분 미만 잔여분은 다음 세션에 자동 누적
    • 중복 충전 방지 메커니즘으로 안정적인 세션 처리
  • Documentation

    • 연료 충전 규칙 및 pendingMinutes 필드 설명 명확화
    • API 응답 예시 및 환산 로직 규칙 업데이트
  • Tests

    • 다양한 공부 시간 시나리오에 대한 테스트 케이스 확대
    • Idempotency 및 race 조건 처리 검증 강화

Review Change Stack

…스트 모드 정책 미러링, UserFuel.chargeFromStudy + FuelService.chargeFromStudy 추가, TimerSessionService 호출부 변경 #25
@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@EM-H20, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 15 minutes and 49 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 33f184bb-7dea-4490-af22-31a2d84ff383

📥 Commits

Reviewing files that changed from the base of the PR and between a67fefe and ef94f92.

📒 Files selected for processing (1)
  • SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java

Walkthrough

이 PR은 공부 세션 완료 시 30분 단위로 연료를 환산하고 30분 미만의 잔여분을 누적하는 새로운 충전 메커니즘을 추가합니다. 엔티티 메서드, 서비스 로직, 통합 흐름, 전체 테스트 및 API 문서를 일관되게 갱신합니다.

Changes

공부 세션 기반 연료 충전

Layer / File(s) Summary
연료 충전 결과 계약 및 엔티티 상수
SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelChargeFromStudyResult.java, SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java
FuelChargeFromStudyResult 레코드는 충전량(amount), 새 잔여분(newPendingMinutes), 현재 잔량(currentFuel)을 담습니다. UserFuelMINUTES_PER_FUEL = 30 상수가 정의됩니다.
UserFuel 공부 세션 충전 메서드
SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java
chargeFromStudy(int) 메서드는 기존 pendingMinutesstudyMinutes를 더해 30분 단위로 연료를 계산하고, 0 초과 시에만 상태를 갱신하며 결과를 반환합니다.
FuelService 세션 기반 충전 메서드
SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java
chargeFromStudy(userId, studyMinutes, sessionId)는 sessionId 기반 멱등성 검증, 행 잠금, race 재확인 후 조건부 트랜잭션 저장을 수행합니다. findChargedAmountBySessionId(sessionId)는 해당 sessionId의 충전액을 조회하거나 0을 반환합니다.
TimerSessionService 연료 충전 통합
SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java
idempotent skip, race resolved, 신규 세션 처리 분기에서 chargeFromStudy 또는 findChargedAmountBySessionId를 호출해 실제 환산된 연료를 얻고 pending을 누적합니다.
UserFuel 충전 메서드 테스트
SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java
정확한 환산(30분→1, 90분→3), pending-only 케이스, carry-over 시나리오, 입력 유효성 검증을 확인합니다.
FuelService 메서드 테스트
SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java
chargeFromStudy의 멱등성, 조건부 저장, 입력 검증, 상태 예외와 findChargedAmountBySessionId의 조회 로직을 검증합니다.
TimerSessionService 통합 테스트
SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java
정확한 환산, 30분 미만 케이스, idempotency dedup, race 복구 시나리오에서 FuelService 호출, pending 누적, 조건부 거래 생성, Todo 누적을 검증합니다.
컨트롤러 테스트 값 갱신
SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java
실제 환산된 연료 값(90분→3, 60분→2)으로 mock 반환값과 응답 검증 기대값을 수정합니다.
API 문서 갱신
docs/api-specs/03_timer.md, docs/api-specs/04_fuel.md
30분 단위 환산 규칙, pendingMinutes 이월, 조건부 거래 생성, 서버 재검증, todo actualMinutes 독립 누적을 명시합니다. pendingMinutes 필드 설명을 누적 공부 분 잔여 및 이월 규칙으로 갱신합니다.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • SpaceStudyShip/SpaceStudyShip-BE#38: TimerSessionService#create 메서드의 연료 충전 및 멱등성 처리 로직을 새로운 FuelService.chargeFromStudy(...) 기반으로 변경하므로 직접적으로 교차 검증 필요합니다.
  • SpaceStudyShip/SpaceStudyShip-BE#35: UserFuel, FuelService 핵심 클래스와 타이머/세션 흐름을 확장하여 새로운 공부 세션 기반 충전 메커니즘 및 대응 DTO와 테스트를 추가합니다.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 주요 변경사항(연료 환율 30분=1연료 + 잔여분 이월)을 명확하게 설명하고 있으며, raw_summary의 모든 파일 변경사항과 일치합니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 20260422_#25_타이머_세션_도메인_구현

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In
`@SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fda3e58d-6b3e-41d2-9a17-290f6a63e2cb

📥 Commits

Reviewing files that changed from the base of the PR and between 3687639 and a67fefe.

📒 Files selected for processing (10)
  • SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelChargeFromStudyResult.java
  • SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java
  • SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java
  • SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java
  • SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java
  • SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java
  • SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java
  • SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java
  • docs/api-specs/03_timer.md
  • docs/api-specs/04_fuel.md

Comment on lines +146 to +182
@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());
}

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.

@EM-H20 EM-H20 merged commit a0b4561 into main May 26, 2026
1 check passed
@github-actions

Copy link
Copy Markdown

🗑️ Preview 환경 삭제 완료!

항목
컨테이너 spacestudyship-pr-39
상태 삭제됨

다시 배포하려면: @suh-lab server build


🤖 이 댓글은 PR/Issue Preview 시스템에 의해 자동 생성되었습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant