Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -46,9 +46,6 @@ public record AlarmInfoPreviewResponse(
LocalDate secondUpcomingDay,

@Schema(description = "그 다음 텀의 알람 요일", example = "목요일")
String secondUpcomingDayOfWeek,

@Schema(description = "회원의 남은 알람 끄기 횟수(회원당 매주 2회 부여, 매주 월요일 초기화)", example = "1")
Long remainingOffCount
String secondUpcomingDayOfWeek

) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package akuma.whiplash.domains.alarm.application.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

@Builder
Comment on lines +4 to +6
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

단일 필드 record에서 @builder는 과합니다 + 최소값 스키마 명시 제안

  • 단일 필드 record에서는 @Builder 없이도 가독성과 생성 용이성이 충분합니다. 팀 컨벤션에 특별한 이유가 없다면 제거를 권장합니다.
  • remainingOffCount는 음수가 되지 않으므로 스키마에 최소값을 명시하면 API 문서 품질이 좋아집니다.

예시 diff:

-import lombok.Builder;
+// import 제거

-@Builder
 @Schema(description = "남은 알람 끄기 횟수 응답")
 public record AlarmRemainingOffCountResponse(
     @Schema(
         description = "회원의 남은 알람 끄기 횟수(회원당 매주 2회 부여, 매주 월요일 초기화)",
-        example = "1"
+        example = "1",
+        minimum = "0"
     )
     int remainingOffCount
 ) {}

Also applies to: 9-13

🤖 Prompt for AI Agents
In
src/main/java/akuma/whiplash/domains/alarm/application/dto/response/AlarmRemainingOffCountResponse.java
lines 4 to 6 and 9 to 13, remove the @Builder annotation from the single-field
record to simplify the code and improve readability. Additionally, add a minimum
value constraint to the remainingOffCount field to ensure it cannot be negative,
enhancing the API schema documentation quality.

@Schema(description = "남은 알람 끄기 횟수 응답")
public record AlarmRemainingOffCountResponse(
@Schema(
description = "회원의 남은 알람 끄기 횟수(회원당 매주 2회 부여, 매주 월요일 초기화)",
example = "1"
)
int remainingOffCount
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import akuma.whiplash.domains.alarm.application.dto.request.AlarmRegisterRequest;
import akuma.whiplash.domains.alarm.application.dto.response.AlarmInfoPreviewResponse;
import akuma.whiplash.domains.alarm.application.dto.response.AlarmOffResultResponse;
import akuma.whiplash.domains.alarm.application.dto.response.AlarmRemainingOffCountResponse;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

UseCase에서 DTO 매핑으로 역할 분리 제안

현재 구조도 무방하지만, 도메인 레이어 의존성 축소를 위해 도메인 서비스는 원시 타입을 반환하고(예: long), UseCase에서 DTO로 감싸는 방식을 권장합니다.

도메인 서비스 시그니처를 long getRemainingOffCount(Long memberId)로 바꾼 경우, 본 메서드는 다음처럼 매핑합니다:

-    public AlarmRemainingOffCountResponse getRemainingOffCount(Long memberId) {
-        return alarmQueryService.getRemainingOffCount(memberId);
-    }
+    public AlarmRemainingOffCountResponse getRemainingOffCount(Long memberId) {
+        long count = alarmQueryService.getRemainingOffCount(memberId);
+        return new AlarmRemainingOffCountResponse(count);
+        // 또는 Lombok Builder 사용 시:
+        // return AlarmRemainingOffCountResponse.builder().remainingOffCount(count).build();
+    }

참고: 팀 컨벤션에 따라 본 메서드에 @Transactional(readOnly = true)를 부여하는 것도 고려할 수 있습니다(읽기 전용 쿼리).

Also applies to: 47-49


🏁 Script executed:

#!/bin/bash
echo "== AlarmUseCase.java 스니펫 확인 =="
rg "class AlarmUseCase" -A30 -B10 src/main/java/akuma/whiplash/domains/alarm/application/usecase/AlarmUseCase.java

echo "== getRemainingOffCount 메서드 정의 및 호출 확인 =="
rg "getRemainingOffCount" -tjava -A5 -B5 src

Length of output: 6619


도메인 서비스는 원시 타입 반환, UseCase에서 DTO 매핑으로 책임 분리

현재 AlarmQueryServiceAlarmRemainingOffCountResponse DTO를 직접 반환하며 도메인 계층이 애플리케이션 레이어 DTO에 의존하고 있습니다. 다음과 같이 리팩토링을 권장합니다.

• AlarmQueryService 인터페이스와 구현체에서 DTO 대신 long 반환
• AlarmUseCase#getRemainingOffCount에서 반환된 longAlarmRemainingOffCountResponse로 매핑
• 필요 시 읽기 전용 메서드에 @Transactional(readOnly = true) 추가 검토

수정 대상:

  • src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmQueryService.java
  • src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmQueryServiceImpl.java
  • src/main/java/akuma/whiplash/domains/alarm/application/usecase/AlarmUseCase.java
// AlarmQueryService.java
-public interface AlarmQueryService {
-    AlarmRemainingOffCountResponse getRemainingOffCount(Long memberId);
-}
+public interface AlarmQueryService {
+    long getRemainingOffCount(Long memberId);
+}
// AlarmQueryServiceImpl.java
-@Override
-public AlarmRemainingOffCountResponse getRemainingOffCount(Long memberId) {
-    // ... 계산 로직
-    return new AlarmRemainingOffCountResponse(count);
-}
+@Override
+public long getRemainingOffCount(Long memberId) {
+    // ... 계산 로직
+    return count;
+}
// AlarmUseCase.java
-public AlarmRemainingOffCountResponse getRemainingOffCount(Long memberId) {
-    return alarmQueryService.getRemainingOffCount(memberId);
-}
+@Transactional(readOnly = true)
+public AlarmRemainingOffCountResponse getRemainingOffCount(Long memberId) {
+    long count = alarmQueryService.getRemainingOffCount(memberId);
+    return new AlarmRemainingOffCountResponse(count);
+}

이렇게 하면 도메인 레이어의 의존성을 줄이고, UseCase가 DTO 매핑을 책임지도록 분리할 수 있습니다.

📝 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
import akuma.whiplash.domains.alarm.application.dto.response.AlarmRemainingOffCountResponse;
public interface AlarmQueryService {
long getRemainingOffCount(Long memberId);
}
Suggested change
import akuma.whiplash.domains.alarm.application.dto.response.AlarmRemainingOffCountResponse;
@Override
public long getRemainingOffCount(Long memberId) {
// ... 계산 로직
return count;
}
Suggested change
import akuma.whiplash.domains.alarm.application.dto.response.AlarmRemainingOffCountResponse;
@Transactional(readOnly = true)
public AlarmRemainingOffCountResponse getRemainingOffCount(Long memberId) {
long count = alarmQueryService.getRemainingOffCount(memberId);
return new AlarmRemainingOffCountResponse(count);
}
🤖 Prompt for AI Agents
In
src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmQueryService.java
and AlarmQueryServiceImpl.java around line 7, refactor the method that currently
returns AlarmRemainingOffCountResponse DTO to instead return a primitive long
value. Then, in
src/main/java/akuma/whiplash/domains/alarm/application/usecase/AlarmUseCase.java,
update the getRemainingOffCount method to call the domain service returning long
and map this long value to an AlarmRemainingOffCountResponse DTO before
returning it. Also consider adding @Transactional(readOnly = true) to the
UseCase method if it is a read-only operation. This separates domain logic from
DTO mapping and reduces domain layer dependencies on application layer DTOs.

import akuma.whiplash.domains.alarm.application.dto.response.CreateAlarmOccurrenceResponse;
import akuma.whiplash.domains.alarm.domain.service.AlarmCommandService;
import akuma.whiplash.domains.alarm.domain.service.AlarmQueryService;
Expand Down Expand Up @@ -42,4 +43,8 @@ public void checkinAlarm(Long memberId, Long alarmId, AlarmCheckinRequest reques
public List<AlarmInfoPreviewResponse> getAlarms(Long memberId) {
return alarmQueryService.getAlarms(memberId);
}

public AlarmRemainingOffCountResponse getWeeklyRemainingOffCount(Long memberId) {
return alarmQueryService.getWeeklyRemainingOffCount(memberId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public class AlarmCommandServiceImpl implements AlarmCommandService {
private String sheetRange;

private static final double CHECKIN_RADIUS_METERS = 100.0;
private static final int WEEKLY_OFF_LIMIT = 2;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

WEEKLY_OFF_LIMIT 상수의 단일 출처화 제안

한 곳(예: domain 상수 클래스 또는 설정값)으로 상수 출처를 통일하세요. 커맨드/쿼리 양쪽에서 동일 상수를 참조하면 변경 비용과 불일치 리스크를 줄일 수 있습니다.

예시(새 상수 클래스 추가):

// src/main/java/akuma/whiplash/domains/alarm/domain/constant/AlarmLimits.java
package akuma.whiplash.domains.alarm.domain.constant;

public final class AlarmLimits {
    private AlarmLimits() {}
    public static final int WEEKLY_OFF_LIMIT = 2;
}

이 파일 적용:

-    private static final int WEEKLY_OFF_LIMIT = 2;
+    // TODO: 상수 단일 출처화 완료 후 로컬 상수 제거 예정
+    // private static final int WEEKLY_OFF_LIMIT = 2;

그리고 사용부:

import static akuma.whiplash.domains.alarm.domain.constant.AlarmLimits.WEEKLY_OFF_LIMIT;
🤖 Prompt for AI Agents
In
src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceImpl.java
at line 70, the WEEKLY_OFF_LIMIT constant should be centralized to avoid
duplication and inconsistency. Move the WEEKLY_OFF_LIMIT constant to a new
dedicated constants class, for example, AlarmLimits in the domain.constant
package. Then, update this file and any other usage locations to import and
reference WEEKLY_OFF_LIMIT from that new constants class.


@Override
public void createAlarm(AlarmRegisterRequest request, Long memberId) {
Expand Down Expand Up @@ -118,7 +119,7 @@ public AlarmOffResultResponse alarmOff(Long memberId, Long alarmId, LocalDateTim
);

// 5. 제한 초과 시 예외 발생
if (weeklyOffCount >= 2) {
if (weeklyOffCount >= WEEKLY_OFF_LIMIT) {
throw ApplicationException.from(ALARM_OFF_LIMIT_EXCEEDED);
}
Comment on lines 121 to 124
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

주간 OFF 제한 체크의 경쟁 조건(race condition) 가능성

동일 회원에 대한 동시 요청 두 건이 같은 주 내에 거의 동시에 들어오면, 둘 다 weeklyOffCount를 낮게 읽고 제한을 통과해 초과 허용될 수 있습니다. 단일 트랜잭션이어도 별 트랜잭션 간에는 상호 가시성이 없어 방지되지 않습니다.

권장 대응(택1 또는 병행):

  • DB 잠금 기반 직렬화: 알람 끄기 처리 시작 시 회원 레코드(또는 회원-주 단위 집계 레코드)에 PESSIMISTIC_WRITE 락을 걸어 체크/기록을 직렬화.
  • 사후 검증 롤백: 저장 직후 재집계하여 한도를 초과하면 예외를 던져 전체 트랜잭션 롤백. 단, 격리수준/잠금 없이는 동시 커밋 간 틈이 남습니다. 잠금과 병행 권장.

예시(Repository + 사용부):

// MemberRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select m from MemberEntity m where m.id = :id")
Optional<MemberEntity> findByIdForUpdate(@Param("id") Long id);
// AlarmCommandServiceImpl.alarmOff(...)
MemberEntity lockedMember = memberRepository.findByIdForUpdate(memberId)
    .orElseThrow(() -> ApplicationException.from(MemberErrorCode.MEMBER_NOT_FOUND));
// 이후 weeklyOffCount 조회/검증/저장 수행

또는 회원-주 키를 가지는 별도 테이블(예: member_weekly_off_counter)에 unique(member_id, week_start) + 남은 횟수 컬럼을 두고, 행 잠금으로 원자적 감소를 수행하는 방식도 견고합니다.

검증 요청: 동시성 테스트(동일 memberId로 병렬 호출 3회)에서 초과가 허용되지 않는지 확인 부탁드립니다.


아래 스크립트로 동시성 제약(락/트랜잭션) 적용 여부를 확인해 주세요:


🏁 Script executed:

#!/bin/bash
# AlarmCommandServiceImpl 내 weeklyOffCount 사용 위치 확인
rg "weeklyOffCount" -n src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceImpl.java

# 알람 끄기 메서드 시그니처 및 트랜잭션 어노테이션 확인
rg -n -A2 "class AlarmCommandServiceImpl" src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceImpl.java
rg -n "@Transactional" src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceImpl.java

# MemberRepository 에서 PESSIMISTIC_WRITE 락 메서드 정의 여부 확인
rg -n "interface MemberRepository" -A5 src/main/java/akuma/whiplash/domains
rg -n "@Lock" src/main/java/akuma/whiplash/domains

Length of output: 1373


경쟁 조건 방지를 위해 PESSIMISTIC_WRITE 락 또는 원자적 감소 로직 적용 필요

AlarmCommandServiceImpl.java (115–124행)에서 트랜잭션 기본 격리 수준(READ_COMMITTED) 하에 weeklyOffCount 조회 → 검증 → 기록이 이루어지므로, 동일 회원에 대해 병렬 요청 시 둘 다 낮은 카운트를 읽고 한도를 초과 허용할 수 있습니다. 현재 MemberRepository에는 락 메서드가 없습니다.

수정 제안 (택1 또는 병행):

  • MemberRepository에 PESSIMISTIC_WRITE 락 메서드 추가
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select m from MemberEntity m where m.id = :id")
    Optional<MemberEntity> findByIdForUpdate(@Param("id") Long id);
  • AlarmCommandServiceImpl.alarmOff(...) 시작부에서 위 메서드로 회원 조회 후 한도 체크·저장 직렬화
    MemberEntity lockedMember = memberRepository.findByIdForUpdate(memberId)
        .orElseThrow(() -> ApplicationException.from(MemberErrorCode.MEMBER_NOT_FOUND));
    // 이후 weeklyOffCount 조회/검증/로그 저장

또는

  • member_weekly_off_counter 엔티티(또는 테이블)에 unique(member_id, week_start) + 남은횟수 컬럼을 두고
    UPDATE … SET remaining_count = remaining_count - 1 WHERE member_id = :id AND week_start = :start AND remaining_count > 0
    처럼 원자적 감소 방식 적용

동일 memberId로 병렬 3회 호출 시 한도가 절대 초과되지 않는지 확인하는 동시성 테스트도 병행해 주세요.

🤖 Prompt for AI Agents
In
src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmCommandServiceImpl.java
around lines 115 to 124, the current logic reads and checks weeklyOffCount
without locking, causing race conditions under concurrent requests. To fix this,
add a PESSIMISTIC_WRITE lock method in MemberRepository to fetch the member
entity with a database lock, then use this locked entity in alarmOff(...) to
perform the count check and update atomically. Alternatively, implement an
atomic decrement query on a dedicated member_weekly_off_counter table with a
unique constraint on member_id and week_start to ensure the limit is never
exceeded. Also, add concurrency tests to verify the fix.


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import akuma.whiplash.domains.alarm.application.dto.etc.OccurrencePushInfo;
import akuma.whiplash.domains.alarm.application.dto.response.AlarmInfoPreviewResponse;
import akuma.whiplash.domains.alarm.application.dto.response.AlarmRemainingOffCountResponse;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

🛠️ Refactor suggestion

Domain 서비스가 Application DTO에 의존하고 있습니다 — 계층 분리 권장

domain.service 인터페이스가 application.dto.response를 직접 반환하면 계층 역참조가 발생합니다. 도메인 서비스는 원시 타입(예: long) 또는 도메인 VO를 반환하고, UseCase에서 DTO로 매핑하는 구조가 바람직합니다.

아래처럼 반환 타입을 단순화하면 계층 결합을 낮출 수 있습니다.

-import akuma.whiplash.domains.alarm.application.dto.response.AlarmRemainingOffCountResponse;
+// 애플리케이션 DTO 의존 제거

 ...
-    AlarmRemainingOffCountResponse getRemainingOffCount(Long memberId);
+    long getRemainingOffCount(Long memberId);

추가로, 구현체(AlarmQueryServiceImpl)와 UseCase에서 해당 변경에 맞춰 매핑을 적용해 주세요.

Also applies to: 12-12

🤖 Prompt for AI Agents
In
src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmQueryService.java
at line 5 and also around line 12, the domain service interface is directly
returning an Application DTO, causing unwanted layer coupling. To fix this,
change the return type in the domain service interface to a primitive type like
long or a domain value object instead of the DTO. Then, update the
implementation class AlarmQueryServiceImpl and the UseCase layer to handle the
mapping from the domain return type to the Application DTO accordingly, ensuring
proper separation of concerns between layers.

import java.time.LocalDateTime;
import java.util.List;

public interface AlarmQueryService {
List<AlarmInfoPreviewResponse> getAlarms(Long memberId);
List<OccurrencePushInfo> getPreNotificationTargets(LocalDateTime startInclusive, LocalDateTime endInclusive);
AlarmRemainingOffCountResponse getWeeklyRemainingOffCount(Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import akuma.whiplash.domains.alarm.application.dto.etc.OccurrencePushInfo;
import akuma.whiplash.domains.alarm.application.dto.response.AlarmInfoPreviewResponse;
import akuma.whiplash.domains.alarm.application.dto.response.AlarmRemainingOffCountResponse;
import akuma.whiplash.domains.alarm.domain.constant.DeactivateType;
import akuma.whiplash.domains.alarm.domain.constant.Weekday;
import akuma.whiplash.domains.alarm.persistence.entity.AlarmEntity;
Expand Down Expand Up @@ -39,6 +40,8 @@ public class AlarmQueryServiceImpl implements AlarmQueryService {
private final AlarmOffLogRepository alarmOffLogRepository;
private final MemberRepository memberRepository;

private static final int WEEKLY_OFF_LIMIT = 2;

@Transactional
@Override
public List<AlarmInfoPreviewResponse> getAlarms(Long memberId) {
Expand Down Expand Up @@ -85,6 +88,28 @@ public List<OccurrencePushInfo> getPreNotificationTargets(LocalDateTime startInc
));
}

@Override
public AlarmRemainingOffCountResponse getWeeklyRemainingOffCount(Long memberId) {
memberRepository
.findById(memberId)
.orElseThrow(() -> ApplicationException.from(MemberErrorCode.MEMBER_NOT_FOUND));

LocalDate today = LocalDate.now();
LocalDate monday = today.with(DayOfWeek.MONDAY);
LocalDateTime weekStart = monday.atStartOfDay();
LocalDateTime now = LocalDateTime.now();

Comment on lines +97 to +101
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

now/주차 기준 개선: Clock 주입 및 Monday 계산 명시화 권장

  • 테스트 가능성과 타임존 일관성을 위해 Clock 주입 사용을 권장합니다.
  • today.with(DayOfWeek.MONDAY) 대신 TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)로 의도를 명시하면 가독성이 좋아집니다.

적용 예시(diff는 현재 범위 내 변경만 포함):

-        LocalDate today = LocalDate.now();
-        LocalDate monday = today.with(DayOfWeek.MONDAY);
-        LocalDateTime weekStart = monday.atStartOfDay();
-        LocalDateTime now = LocalDateTime.now();
+        LocalDate today = LocalDate.now(clock);
+        LocalDate monday = today.with(java.time.temporal.TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
+        LocalDateTime weekStart = monday.atStartOfDay();
+        LocalDateTime now = LocalDateTime.now(clock);

범위 밖 변경(참고):

// 필드 주입
private final java.time.Clock clock;

// 구성 클래스 등지에서 Bean 제공 (예: KST 고정이 필요하다면)
@Bean
public Clock clock() {
  return Clock.system(ZoneId.of("Asia/Seoul"));
}
🤖 Prompt for AI Agents
In
src/main/java/akuma/whiplash/domains/alarm/domain/service/AlarmQueryServiceImpl.java
around lines 95 to 99, replace the direct calls to LocalDate.now() and
LocalDateTime.now() with calls that use an injected Clock instance to improve
testability and timezone consistency. Also, change the calculation of Monday
from today.with(DayOfWeek.MONDAY) to use
TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY) for clearer intent. Inject
the Clock as a final field and use it in the date/time calls accordingly.

long offCount = alarmOffLogRepository.countByMemberIdAndCreatedAtBetween(
memberId, weekStart, now
);

int count = (int) Math.max(0, WEEKLY_OFF_LIMIT - offCount);

return AlarmRemainingOffCountResponse.builder()
.remainingOffCount(count)
.build();
}

private AlarmInfoPreviewResponse buildPreviewResponse(AlarmEntity alarm, LocalDate today, Long memberId) {
// 1. 가장 최근 OFF 또는 CHECKIN 이력 조회
Optional<AlarmOccurrenceEntity> recentOccurrenceOpt =
Expand Down Expand Up @@ -133,9 +158,6 @@ private AlarmInfoPreviewResponse buildPreviewResponse(AlarmEntity alarm, LocalDa
final LocalDate resolvedFirstUpcomingDate = isCurrentDeactivated ? secondDate : firstDate;
final LocalDate resolvedSecondUpcomingDate = isCurrentDeactivated ? thirdDate : secondDate;

// 5. 회원의 이번 주 남은 알람 끄기 횟수 계산
long remainingOffCount = calculateRemainingOffCount(memberId);

return AlarmInfoPreviewResponse.builder()
.alarmId(alarm.getId())
.alarmPurpose(alarm.getAlarmPurpose())
Expand All @@ -153,25 +175,8 @@ private AlarmInfoPreviewResponse buildPreviewResponse(AlarmEntity alarm, LocalDa
.firstUpcomingDayOfWeek(DateUtil.getKoreanDayOfWeek(resolvedFirstUpcomingDate))
.secondUpcomingDay(resolvedSecondUpcomingDate)
.secondUpcomingDayOfWeek(DateUtil.getKoreanDayOfWeek(resolvedSecondUpcomingDate))
.remainingOffCount(remainingOffCount)
.build();
}

/**
* 이번 주 월요일부터 현재까지의 OFF 로그를 기반으로 남은 끄기 횟수 계산
*/
private long calculateRemainingOffCount(Long memberId) {
LocalDate today = LocalDate.now();
LocalDate monday = today.with(DayOfWeek.MONDAY);
LocalDateTime weekStart = monday.atStartOfDay();
LocalDateTime now = LocalDateTime.now();

long offCount = alarmOffLogRepository.countByMemberIdAndCreatedAtBetween(
memberId, weekStart, now
);

return Math.max(0, 2 - offCount);
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import akuma.whiplash.domains.alarm.application.dto.request.AlarmRegisterRequest;
import akuma.whiplash.domains.alarm.application.dto.response.AlarmInfoPreviewResponse;
import akuma.whiplash.domains.alarm.application.dto.response.AlarmOffResultResponse;
import akuma.whiplash.domains.alarm.application.dto.response.AlarmRemainingOffCountResponse;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

신규 엔드포인트 설계 적절 — 오류 코드 문서화 및 테스트 보강 제안

구현은 명확하고 일관적입니다. 두 가지 보강점을 제안합니다.

  • PERMISSION_DENIED 문서화 여부 확인: 다른 엔드포인트 일부는 authErrorCodes = {PERMISSION_DENIED}를 명시합니다. 현재 getAlarms와 동일하게 생략되어 있는데, 전역 예외 매핑 정책에 따른 의도인지 확인 부탁드립니다.
  • 테스트 추가: /api/alarms/off-count에 대한 MVC/통합 테스트(성공 케이스, 회원 없음, 인증 실패 등) 추가를 권장합니다.

원하시면 해당 엔드포인트용 Spring MVC 테스트 템플릿을 생성해 드리겠습니다.

Also applies to: 110-118


🏁 Script executed:

#!/bin/bash
# Display the controller definition and imports
rg -n "class AlarmController" -A15 -B15 src/main/java/akuma/whiplash/domains/alarm/presentation/AlarmController.java

# Show the new /off-count endpoint snippet
rg -n "off-count" -A5 -B5 src/main/java/akuma/whiplash/domains/alarm/presentation/AlarmController.java

# Show the existing getAlarms endpoint for comparison
rg -n "getAlarms" -A5 -B5 src/main/java/akuma/whiplash/domains/alarm/presentation/AlarmController.java

# Find occurrences of PERMISSION_DENIED across the codebase
rg -n "PERMISSION_DENIED"

# Check for authErrorCodes usage in controllers
rg -n "authErrorCodes" -A3 -B3 src/main/java/akuma/whiplash/domains

Length of output: 8965


🏁 Script executed:

#!/bin/bash
# Search for any controller tests referencing the /off-count endpoint
rg -n "/off-count" -g "src/test/java/**/*.java"

# Search for tests referencing getRemainingOffCount method
rg -n "getRemainingOffCount" -g "src/test/java/**/*.java"

# Search for tests referencing AlarmController directly
rg -n "AlarmController" -g "src/test/java/**/*.java"

Length of output: 158


/api/alarms/off-count 엔드포인트: PERMISSION_DENIED 생략은 의도된 패턴, 테스트 추가 필요

getAlarms와 동일하게 자신의 리소스만 조회하기 때문에 authErrorCodes = {PERMISSION_DENIED}를 명시하지 않은 것이 일관된 설계입니다. 별도 변경은 필요하지 않습니다.

다만, 현재 src/test/java 경로에 해당 엔드포인트를 검증하는 Spring MVC/통합 테스트가 존재하지 않습니다. 아래 시나리오를 커버하는 테스트를 추가해 주세요:

  • 정상 조회 (인증된 회원의 off-count 반환)
  • 인증 실패 (무효 토큰 또는 비인증)
  • MEMBER_NOT_FOUND (인증 토큰은 유효하나 회원이 DB에 없는 경우)

필요하시면 Spring MVC 테스트 템플릿을 제공해 드리겠습니다.

🤖 Prompt for AI Agents
In src/main/java/akuma/whiplash/domains/alarm/presentation/AlarmController.java
at line 13, no code change is needed for the /api/alarms/off-count endpoint
regarding permission handling, but you must add Spring MVC or integration tests
under src/test/java to cover this endpoint. Implement tests for successful
retrieval by an authenticated user, authentication failure due to invalid or
missing token, and the case where the token is valid but the member is not found
in the database.

import akuma.whiplash.domains.alarm.application.dto.response.CreateAlarmOccurrenceResponse;
import akuma.whiplash.domains.alarm.application.usecase.AlarmUseCase;
import akuma.whiplash.domains.auth.application.dto.etc.MemberContext;
Expand Down Expand Up @@ -105,4 +106,14 @@ public ApplicationResponse<List<AlarmInfoPreviewResponse>> getAlarms(@Authentica
List<AlarmInfoPreviewResponse> alarms = alarmUseCase.getAlarms(memberContext.memberId());
return ApplicationResponse.onSuccess(alarms);
}

@CustomErrorCodes(memberErrorCodes = {MEMBER_NOT_FOUND})
@Operation(summary = "남은 알람 끄기 횟수 조회", description = "회원의 이번 주 남은 알람 끄기 횟수를 조회합니다.")
@GetMapping("/off-count")
public ApplicationResponse<AlarmRemainingOffCountResponse> getWeeklyRemainingOffCount(
@AuthenticationPrincipal MemberContext memberContext
) {
AlarmRemainingOffCountResponse response = alarmUseCase.getWeeklyRemainingOffCount(memberContext.memberId());
return ApplicationResponse.onSuccess(response);
}
Comment on lines +110 to +118
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

❓ Verification inconclusive

신규 엔드포인트 설계는 명확 — 테스트 커버리지 추가 필요

계약, 에러코드, 문서화 모두 일관적입니다. 다음 시나리오의 MVC/통합 테스트를 추가해 주세요:

  • 정상 조회(인증된 사용자의 off-count 반환)
  • 인증 실패(무효 토큰/비인증)
  • MEMBER_NOT_FOUND(유효 토큰이나 회원 미존재)

검증용 스크립트:

원하시면 MockMvc 기반 테스트 템플릿을 제공합니다.


🏁 Script executed:

#!/bin/bash
# /off-count 엔드포인트 테스트 존재 여부 점검
rg -n "/api/alarms/off-count" -g "src/test/java/**/*.java" -A2 -B2 || true

Length of output: 73


테스트 추가 필요: /api/alarms/off-count 엔드포인트
현재 이 엔드포인트에 대한 테스트가 없습니다. 아래 시나리오를 MockMvc 기반 MVC/통합 테스트로 보강해 주세요:

  • 정상 조회
    • 인증된 사용자의 off-count 반환 검증
  • 인증 실패
    • 무효 토큰 또는 비인증 요청 시 401 응답 확인
  • MEMBER_NOT_FOUND
    • 유효 토큰이지만 회원이 존재하지 않을 때 정의된 에러 코드 및 메시지 검증

추가 대상 예시 위치:

  • src/test/java/.../alarm/presentation/AlarmControllerTest.java
  • 또는 통합 테스트 패키지 내 적절한 클래스

원하시면 MockMvc 테스트 템플릿을 제공합니다.

🤖 Prompt for AI Agents
In src/main/java/akuma/whiplash/domains/alarm/presentation/AlarmController.java
around lines 110 to 118, the /api/alarms/off-count endpoint lacks tests. Add
MockMvc-based MVC/integration tests covering: successful retrieval of off-count
for authenticated users, 401 responses for invalid or unauthenticated requests,
and handling of MEMBER_NOT_FOUND error with proper error code and message. Place
these tests in src/test/java/.../alarm/presentation/AlarmControllerTest.java or
an appropriate integration test class.

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
public record RegisterFcmTokenRequest(

@Schema(description = "FCM 토큰", example = "djkhsa01whjas")
@NotBlank
@NotBlank(message = "FCM 토큰을 입력해주세요.")
String fcmToken
Comment on lines 9 to 11
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

국제화/일관성: 하드코딩 메시지 대신 메시지 코드 사용을 고려해주세요.

팀에서 MessageSource(예: messages.properties)을 사용한다면, 직접 문자열 대신 메시지 코드를 사용하는 편이 일관성과 다국어 확장성에 유리합니다.

아래처럼 변경을 제안드립니다:

-    @NotBlank(message = "FCM 토큰을 입력해주세요.")
+    @NotBlank(message = "{validation.fcmToken.notBlank}")

(messages.properties 예시)

validation.fcmToken.notBlank=FCM 토큰을 입력해주세요.

원하시면 messages 파일 생성/추가까지 포함한 패치도 드리겠습니다.

📝 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
@Schema(description = "FCM 토큰", example = "djkhsa01whjas")
@NotBlank
@NotBlank(message = "FCM 토큰을 입력해주세요.")
String fcmToken
@Schema(description = "FCM 토큰", example = "djkhsa01whjas")
@NotBlank(message = "{validation.fcmToken.notBlank}")
String fcmToken
🤖 Prompt for AI Agents
In
src/main/java/akuma/whiplash/domains/auth/application/dto/request/RegisterFcmTokenRequest.java
around lines 9 to 11, the @NotBlank annotation uses a hardcoded validation
message string. To improve internationalization and consistency, replace the
hardcoded message with a message code key that refers to an entry in
messages.properties (e.g., "validation.fcmToken.notBlank"). Update the
annotation to use the message code instead of the direct string, and add the
corresponding key-value pair in the messages.properties file.

) {}