From 98163c64b911f41b3556c35dd05630608a44f810 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Wed, 14 Oct 2026 09:58:06 +0900 Subject: [PATCH 01/58] =?UTF-8?q?fix:=20=EB=8B=B9=EC=9D=BC=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=ED=95=9C=20=EC=88=98=EB=8F=99=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=8F=84=20=EC=9D=B4=ED=8B=80=20=EB=92=A4=20=E3=80=8C=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A1=9C=20=ED=99=95=EC=A0=95=EB=90=90=EC=96=B4?= =?UTF-8?q?=EC=9A=94=E3=80=8D=20=EC=95=8C=EB=A6=BC=EC=9D=84=20=EB=B0=9B?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERIFICATION_RESULT 의 멱등 키가 verification_id 하나라, 수동 체크 성공(MANUAL_SUCCESS)을 당일 취소한 행이 D+2 에 실패로 굳어도 CONFIRMED_FAILURE 가 앞선 성공 알림의 UNIQUE 에 막혀 조용히 사라졌다. 키를 (verification_id, variant) 로 바꾼다. 같은 변형의 재발행은 그대로 한 번이다. 옛 키와 새 키가 겹치지 않아 마이그레이션은 없다. Co-Authored-By: Claude Opus 5.5 --- .../notification/domain/NotificationType.java | 11 ++++-- .../service/VerificationFinalizeService.java | 4 +-- .../service/VerificationManualService.java | 3 +- .../NotificationRegistryTest.java | 4 ++- .../VerificationResultNotificationIT.java | 36 +++++++++++++++++++ 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/notification/domain/NotificationType.java b/src/main/java/com/ruleup/ruleup_backend/notification/domain/NotificationType.java index 2dd3a8ae..cbd1c81b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/notification/domain/NotificationType.java +++ b/src/main/java/com/ruleup/ruleup_backend/notification/domain/NotificationType.java @@ -177,9 +177,14 @@ public enum NotificationType { * *

딥링크는 {@code ruleup://challenge/{id}} (단수) 다 — 2026-09-19 Android 와 합의한 계약값이다 * (QA NOTI-15). 다른 챌린지 그룹 타입의 {@code challenges/} 는 아직 정합 전이다. + * + *

멱등 키는 {@code (verification_id, variant)} 다. 한 판정이 결과를 바꿀 수 있어서다 — 수동 체크 + * (MANUAL_SUCCESS)를 당일 취소하면 같은 행이 이틀 뒤 CONFIRMED_FAILURE 로 굳는다. 키가 + * {@code verification_id} 하나였을 때는 실패 확정이 앞선 성공 알림의 UNIQUE 에 막혀 조용히 사라졌다. + * 같은 변형의 재발행(재 sync·취소 후 재체크·배치 재실행)은 그대로 한 번이다. */ VERIFICATION_RESULT(NotificationToggleGroup.CHALLENGE, "ruleup://challenge/{challenge_id}", - new String[]{VERIFICATION_ID}), + new String[]{VERIFICATION_ID, VARIANT}), /** * 실패 예정 진입 — 「이의제기가 필요해요」(QA NOTI-16). 탭하면 이의를 낼 수 있는 화면으로 간다. @@ -193,8 +198,8 @@ public enum NotificationType { *

그래서 {@code date}(귀속일) 가 파라미터에 들어간다. 멱등 키는 {@code verification_id} 그대로다 — * 키에 날짜를 더하면 같은 건에 두 번 적재될 길이 열린다. * - *

{@link #VERIFICATION_RESULT} 의 변형으로 두지 않는다. 멱등 키가 {@code verification_id} 하나라 - * 같은 건에 실패 예정 알림이 먼저 적재되면 이틀 뒤 「실패로 확정됐어요」가 UNIQUE 에 막혀 사라진다. + *

{@link #VERIFICATION_RESULT} 의 변형으로 두지 않는다. 타입이 다르면 앱이 목적지를 타입으로 가를 수 + * 있고, 처음 이렇게 나눈 이유였던 멱등 키 충돌(실패 예정이 이틀 뒤 「실패로 확정됐어요」를 막음)도 따로 막힌다. */ VERIFICATION_FAIL_EXPECTED(NotificationToggleGroup.CHALLENGE, "ruleup://me/calendar/{date}", new String[]{VERIFICATION_ID}), diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationFinalizeService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationFinalizeService.java index 2a25a4a1..240d579d 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationFinalizeService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationFinalizeService.java @@ -509,8 +509,8 @@ private boolean finalizeOne(VerificationDaily daily, Instant now) { // 판정 결과 고지 — 성공·실패 둘 다. 확정 시각이 귀속일 이틀 뒤 00:00 이라 그때 유저는 // 앱을 보고 있지 않다. 알림함이 「그날이 어떻게 끝났는지」를 확인할 유일한 자리다. - // 판정 하나에 확정은 하나뿐이라 verification_id 가 곧 멱등 키이고, 배치가 재실행돼도 - // 두 번 적재되지 않는다. + // 판정 하나에 확정은 하나뿐이라 (verification_id, variant) 멱등 키로 배치가 재실행돼도 + // 두 번 적재되지 않는다. 변형이 키에 들어가 있어 당일 취소된 수동 체크의 성공 알림에 막히지 않는다. if (member != null) { notificationPublisher.publish(NotificationEvent.forChallenge(member.getUserId(), NotificationType.VERIFICATION_RESULT, diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationManualService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationManualService.java index 14d7108a..195bef79 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationManualService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationManualService.java @@ -131,7 +131,8 @@ public ManualVerificationResponse submit(UUID userId, UUID challengeId, ManualVe // 수동 체크 성공도 판정 결과다. 확정 배치는 미확정 건만 집어가고 즉시 확정된 건은 // finalizeOne 초입의 isTerminal() 에서 걸러지므로, 여기서 고지하지 않으면 성공한 날은 - // 알림이 영영 없다. verification_id 가 멱등 키라 취소 후 다시 체크해도 한 번만 적재된다. + // 알림이 영영 없다. (verification_id, variant) 가 멱등 키라 취소 후 다시 체크해도 한 번만 적재되고, + // 취소한 채 하루가 지나면 이틀 뒤 「실패로 확정됐어요」는 따로 쌓인다. // 수동 체크는 당일만 되므로 늘 「오늘」이다 — 오늘 해야 할 게 이것 하나였다면 「모두 완료」가 대신한다. if (!completionNotice.afterSuccess(userId, daily.getTargetDate()).replacesIndividual()) { notificationPublisher.publish(NotificationEvent.forChallenge(userId, diff --git a/src/test/java/com/ruleup/ruleup_backend/notification/NotificationRegistryTest.java b/src/test/java/com/ruleup/ruleup_backend/notification/NotificationRegistryTest.java index 5592b27b..33ea0420 100644 --- a/src/test/java/com/ruleup/ruleup_backend/notification/NotificationRegistryTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/notification/NotificationRegistryTest.java @@ -306,8 +306,9 @@ void shape() { @Test @DisplayName("타입별 식별자 구성이 스펙 표와 일치한다") void perTypeIdentifiers() { + // 변형이 키에 들어간다 — 당일 취소된 수동 체크의 성공 알림이 이틀 뒤 실패 확정을 막지 않게. assertThat(NotificationType.VERIFICATION_RESULT.dedupKey(user, params())) - .endsWith(":v-12"); + .endsWith(":v-12:CONFIRMED_FAILURE"); assertThat(NotificationType.CHALLENGE_LIFECYCLE.dedupKey(user, params())) .endsWith(":c-301:STARTED"); assertThat(NotificationType.WATCHER_INVITATION_EXPIRED.dedupKey(user, params())) @@ -430,6 +431,7 @@ private static Map params() { p.put(NotificationParams.CAMPAIGN_ID, "cp-1"); p.put(NotificationParams.DATE, "2026-09-08"); p.put(NotificationParams.SLOT, "MORNING"); + p.put(NotificationParams.VARIANT, "CONFIRMED_FAILURE"); return p; } } diff --git a/src/test/java/com/ruleup/ruleup_backend/notification/VerificationResultNotificationIT.java b/src/test/java/com/ruleup/ruleup_backend/notification/VerificationResultNotificationIT.java index 5dc6ebac..4807d53e 100644 --- a/src/test/java/com/ruleup/ruleup_backend/notification/VerificationResultNotificationIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/notification/VerificationResultNotificationIT.java @@ -47,6 +47,8 @@ class VerificationResultNotificationIT extends VerificationApiSupport { @Autowired NotificationRepository notificationRepository; @Autowired VerificationManualService manualService; @Autowired com.ruleup.ruleup_backend.verification.service.FailExpectedNoticeJob failExpectedJob; + @Autowired com.ruleup.ruleup_backend.verification.service.VerificationFinalizeService finalizeService; + @Autowired TestcontainersConfiguration.MutableClock clock; private MockMvc mvc; @@ -55,6 +57,11 @@ void setUp() { mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); } + @org.junit.jupiter.api.AfterEach + void resetClock() { + clock.reset(); + } + @Override protected MockMvc mvc() { return mvc; } @Override protected JdbcTemplate jdbc() { return jdbcTemplate; } @@ -127,6 +134,35 @@ void manualSuccessNotifies() throws Exception { } } + // ===================================================================== + @Nested + @DisplayName("결과가 바뀐 판정") + class ChangedOutcome { + + @Test + @DisplayName("수동 체크를 당일 취소하면 이틀 뒤 실패 확정 알림이 앞선 성공 알림에 막히지 않는다") + void canceledManualCheckStillGetsConfirmedFailure() throws Exception { + Member me = member(uniq("vrc")); + UUID challengeId = insertChallenge(me.id(), "EXERCISE", "ACTIVE", "GROUP"); + insertActiveMembership(challengeId, me.id(), "OWNER"); + anotherPendingRoutine(me); + + String verificationId = manualService.submit(me.id(), challengeId, + new ManualVerificationRequest(null, null)).verificationId(); + manualService.cancel(me.id(), UUID.fromString(verificationId)); + manualService.submit(me.id(), challengeId, new ManualVerificationRequest(null, null)); + manualService.cancel(me.id(), UUID.fromString(verificationId)); + assertThat(resultsOf(me.id())).as("취소 후 다시 체크해도 성공 알림은 한 번").hasSize(1); + + clock.advance(java.time.Duration.ofDays(2).plusMinutes(1)); + finalizeService.finalizeDue(); + + assertThat(resultsOf(me.id())).extracting(Notification::getTitle).containsExactly( + "[테스트 챌린지] 인증이 실패로 확정됐어요", + "[테스트 챌린지] 인증이 완료됐어요"); + } + } + /** * 오늘 아직 체크하지 않은 수동 방 하나를 더 둔다. 오늘 해야 할 루틴이 하나뿐이면 「모두 완료」가 개별 성공 * 알림을 대신하므로(DailyRoutineNoticeIT), 개별 알림을 보는 시험은 남은 루틴이 있는 상태에서 본다. From 3b47ce9dbe23c1c85251be176e76f22825249d12 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Wed, 14 Oct 2026 09:58:06 +0900 Subject: [PATCH 02/58] =?UTF-8?q?fix:=20=EC=A2=85=EB=A3=8C=EA=B0=80=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=EB=90=98=EB=A9=B4=20=EB=AA=A8=EB=8D=94?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=ED=81=90=20=ED=8F=B4=EB=A7=81?= =?UTF-8?q?=EC=9D=84=20=EB=A9=88=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포 때마다 옛 태스크가 SqsClient 가 닫힌 뒤에도 5초 폴러를 돌려 moderation_receive_failed (Connection pool shut down · RejectedExecutionException) ERROR 를 남겼다. ContextClosedEvent 는 빈 파괴보다 먼저 오므로 그때 수신을 멈추고, 그 순간 이미 시작된 수신의 실패는 로그로 남기지 않는다. UserModerationQueue 도 같은 문제라 함께 고친다. Co-Authored-By: Claude Opus 5.5 --- .../moderation/ChallengeModerationQueue.java | 17 ++++++++++++++++- .../moderation/UserModerationQueue.java | 15 +++++++++++++-- .../ChallengeModerationQueueTest.java | 15 +++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueue.java b/src/main/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueue.java index b8c4836f..a47d1618 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueue.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueue.java @@ -6,6 +6,8 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import software.amazon.awssdk.services.sqs.SqsClient; @@ -27,6 +29,7 @@ public record MessageBody(UUID challengeId, List targets) {} private final boolean local; private final ChallengeModerationStore store; private final ChallengeModerationService service; + private volatile boolean stopping; public ChallengeModerationQueue(@Qualifier("challengeModerationSqsClient") ObjectProvider sqs, @Value("${app.moderation.queue.url:}") String queueUrl, @@ -60,9 +63,19 @@ public boolean configured() { return local || (!queueUrl.isEmpty() && sqs.getIfAvailable() != null); } + /** + * 종료가 시작되면 새 수신을 멈춘다. 컨텍스트 종료 이벤트는 빈 파괴보다 먼저 온다 — SqsClient 가 + * 닫힌 뒤에도 5초 폴러가 돌아 배포 때마다 옛 태스크가 「Connection pool shut down」· + * RejectedExecutionException 을 ERROR 로 남겼다. 받아 둔 메시지는 가시성 만료 후 새 태스크가 받는다. + */ + @EventListener(ContextClosedEvent.class) + public void stop() { + stopping = true; + } + @Scheduled(fixedDelayString = "${app.moderation.queue.poll-delay-ms:5000}") public void poll() { - if (local || queueUrl.isEmpty()) return; + if (local || queueUrl.isEmpty() || stopping) return; try { for (Message message : sqs.getObject().receiveMessage(ReceiveMessageRequest.builder() .queueUrl(queueUrl).maxNumberOfMessages(1).waitTimeSeconds(1).visibilityTimeout(120) @@ -70,6 +83,8 @@ public void poll() { handle(message); } } catch (RuntimeException failure) { + // 종료 신호와 같은 순간에 시작된 수신은 닫히는 클라이언트에 걸릴 수 있다 — 장애가 아니다. + if (stopping) return; // 클래스 이름만 남기면 enqueue 때처럼 원인이 가려진다 — 메시지까지 남긴다. log.error("moderation_receive_failed error={}", failure.toString()); } diff --git a/src/main/java/com/ruleup/ruleup_backend/moderation/UserModerationQueue.java b/src/main/java/com/ruleup/ruleup_backend/moderation/UserModerationQueue.java index 00f0fb3b..0fd619de 100644 --- a/src/main/java/com/ruleup/ruleup_backend/moderation/UserModerationQueue.java +++ b/src/main/java/com/ruleup/ruleup_backend/moderation/UserModerationQueue.java @@ -6,6 +6,8 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; import org.springframework.jdbc.core.JdbcTemplate; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; @@ -27,6 +29,7 @@ public class UserModerationQueue { private final String url; private final boolean local; private final ObjectMapper json = new ObjectMapper(); + private volatile boolean stopping; public UserModerationQueue(JdbcTemplate jdbc, UserModerationService moderation, @Qualifier("challengeModerationSqsClient") ObjectProvider clients, @@ -64,9 +67,15 @@ else if (!url.isBlank()) { } } + /** 종료가 시작되면 새 수신을 멈춘다 — 닫힌 SqsClient 로 폴링하지 않게(ChallengeModerationQueue#stop 과 같은 이유). */ + @EventListener(ContextClosedEvent.class) + public void stop() { + stopping = true; + } + @Scheduled(fixedDelayString = "${app.moderation.poll-ms:5000}") public void poll() { - if (local || url.isBlank()) return; + if (local || url.isBlank() || stopping) return; try { SqsClient sqs = clients.getObject(); for (var message : sqs.receiveMessage(b -> b.queueUrl(url).maxNumberOfMessages(1) @@ -76,7 +85,9 @@ public void poll() { // Pending on provider failure remains eligible for tomorrow's sweep. sqs.deleteMessage(b -> b.queueUrl(url).receiptHandle(message.receiptHandle())); } - } catch (Exception failure) { log.warn("user_moderation_consume_failed error={}", failure.getClass().getSimpleName()); } + } catch (Exception failure) { + if (!stopping) log.warn("user_moderation_consume_failed error={}", failure.getClass().getSimpleName()); + } } public record MessageBody(UUID userId, List targets) {} diff --git a/src/test/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueueTest.java b/src/test/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueueTest.java index 0d5cea33..1f3fd183 100644 --- a/src/test/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueueTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/challenge/moderation/ChallengeModerationQueueTest.java @@ -50,4 +50,19 @@ ChallengeModerationQueue queue() { queue().handle(message); verify(sqs).deleteMessage(any(DeleteMessageRequest.class)); } + @Test void stopsPollingOnceShutdownBegins() { + // 배포 때 옛 태스크는 SqsClient 가 닫힌 뒤에도 폴러가 돌아 ERROR 를 남겼다 — 종료 신호 뒤엔 수신하지 않는다. + var queue=queue(); + queue.stop(); + queue.poll(); + verifyNoInteractions(sqs); + } + @Test void receiveFailureDuringShutdownIsSwallowed() { + var queue=queue(); + when(sqs.receiveMessage(any(ReceiveMessageRequest.class))).thenAnswer(inv -> { + queue.stop(); + throw new IllegalStateException("Connection pool shut down"); + }); + assertThatCode(queue::poll).doesNotThrowAnyException(); + } } From 0d51d1d592dfd9122c760fb013ab7242f70be965 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Wed, 14 Oct 2026 09:58:06 +0900 Subject: [PATCH 03/58] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=97=90=20MALLOC=5FARENA=5FMAX=3D2=20?= =?UTF-8?q?=E2=80=94=20stg=20=ED=95=98=EB=93=9C=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC=2080%=20=EA=B2=BD=EB=B3=B4=20=EB=B0=98=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stg(1GB 태스크) JVM RSS ~800MB 중 힙은 256MB 였고 glibc malloc 아레나가 ~200MB 를 차지해 (ECS Exec 로 /proc/1/smaps 실측) RSS 가 천천히 차올라 ECS-06-80 이 하루쯤마다 다시 울렸다. 태스크 메모리는 늘리지 않고 아레나 수를 묶는다. Co-Authored-By: Claude Opus 5.5 --- Dockerfile.deploy | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile.deploy b/Dockerfile.deploy index 33f648d6..3af8cdab 100644 --- a/Dockerfile.deploy +++ b/Dockerfile.deploy @@ -14,5 +14,9 @@ ARG ADOT_JAVA_AGENT_VERSION=v2.31.0 ADD https://github.com/aws-observability/aws-otel-java-instrumentation/releases/download/${ADOT_JAVA_AGENT_VERSION}/aws-opentelemetry-agent.jar /app/otel/aws-opentelemetry-agent.jar COPY --from=build /src/build/libs/*.jar app.jar COPY infra/monitoring/tracing/entrypoint.sh /app/entrypoint.sh +# glibc 는 스레드마다 64MB 단위 malloc 아레나를 늘려 가며 돌려주지 않는다. stg(1GB 태스크)에서 JVM RSS 800MB 중 +# 힙은 256MB 였고 아레나가 ~200MB 를 차지해, 하루쯤마다 하드 메모리 80% 경보(ECS-06-80)를 다시 울렸다(2026-10-14 smaps 실측). +# 아레나 수를 묶으면 네이티브 할당이 몇 개 아레나를 같이 쓰게 되어 RSS 가 천천히 차오르지 않는다. +ENV MALLOC_ARENA_MAX=2 EXPOSE 8080 ENTRYPOINT ["sh","/app/entrypoint.sh"] From 43f8e5c1272f7a76dabf1840b96f8fe3e578595f Mon Sep 17 00:00:00 2001 From: selee1012 Date: Thu, 15 Oct 2026 10:14:33 +0900 Subject: [PATCH 04/58] =?UTF-8?q?feat:=20=EC=9E=A5=EC=86=8C=20=ED=94=BC?= =?UTF-8?q?=ED=95=98=EA=B8=B0(GPS=5FAVOID)=20=EC=9C=A0=ED=98=95=EC=9D=84?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「등록한 장소에 머물지 않았다」는 판정이 성립하지 않아 유형째 뺀다. 카탈로그 1101~1110 을 지우고(V12), 회피 판정 분기·스침 유예 설정· ENTERED_AVOID_ZONE 문구·인증 안내 예시를 걷어낸다. 이미 만든 방은 지우지 않고 직접 인증으로 바꾼다 — 템플릿만 없어진 채 자동으로 두면 휴리스틱이 GEOFENCE 를 「장소 방문」으로 읽어 정반대로 판정한다. Co-Authored-By: Claude Opus 5.5 --- .../guide/VerificationGuideFacts.java | 5 - .../guide/VerificationGuideService.java | 2 +- .../config/VerificationProperties.java | 7 +- .../verification/domain/FailExpectation.java | 2 +- .../verification/domain/FailureEvidence.java | 1 - .../verification/domain/GpsConfig.java | 8 +- .../verification/domain/GpsPresence.java | 4 - .../evaluator/GpsPresenceEvaluator.java | 68 +------------ .../service/VerificationConfigFactory.java | 28 ++---- .../service/VerificationSetupService.java | 2 +- .../db/migration/V12__remove_place_avoid.sql | 32 ++++++ .../resources/prompts/verification-guide.txt | 2 - .../ruleup_backend/me/MyPageContractIT.java | 23 +++-- .../VerificationResultNotificationIT.java | 15 ++- .../routine/RoutineCatalogSeedIT.java | 6 +- .../verification/AndroidWireSignalTest.java | 2 +- .../verification/EvaluatorAccuracyTest.java | 99 +++---------------- .../GpsRetentionCoherenceTest.java | 2 +- .../verification/SleepUnionTest.java | 2 +- .../VerificationAndroidWireIT.java | 17 ---- .../verification/VerificationApiSupport.java | 2 +- .../VerificationCrossChallengeIT.java | 25 ----- 22 files changed, 88 insertions(+), 266 deletions(-) delete mode 100644 src/main/java/com/ruleup/ruleup_backend/verification/domain/GpsPresence.java create mode 100644 src/main/resources/db/migration/V12__remove_place_avoid.sql diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideFacts.java b/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideFacts.java index b786def2..234a19a7 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideFacts.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideFacts.java @@ -59,11 +59,6 @@ public static VerificationGuideFacts of(Challenge c, RoutineTemplate template) { fallback = "장소를 등록한 뒤 " + freq + " 그곳에 " + (dur != null ? dur + " 이상 " : "") + "머물면 자동 인증됩니다."; } - case "GPS_AVOID" -> { - conditions.add("등록한 장소에 머물지 않기(잠깐 스쳐 지나가는 것은 괜찮다)"); - fallback = "피하고 싶은 장소를 등록하고 그곳에 머물지 않으면 자동 인증됩니다. " - + "잠깐 스쳐 지나가는 것은 괜찮습니다."; - } case "HEALTH" -> { String steps = steps(params.get("steps")); String km = km(params.get("distance_km")); diff --git a/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideService.java b/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideService.java index b1831aca..af28e2b1 100644 --- a/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideService.java +++ b/src/main/java/com/ruleup/ruleup_backend/challenge/guide/VerificationGuideService.java @@ -119,7 +119,7 @@ static boolean acceptable(VerificationGuideFacts f, String guide) { for (String must : f.mustContain()) { if (!guide.contains(must)) return false; // 판정 기준 값이 바뀌거나 빠졌다 } - // 「매일」은 장소 피하기처럼 문장에서 자연스럽게 빠질 수 있다. 「주 3회」는 빠지면 조건이 바뀐다 + // 「매일」은 문장에서 자연스럽게 빠질 수 있다. 「주 3회」는 빠지면 조건이 바뀐다 if (!"매일".equals(f.frequency()) && !guide.contains(f.frequency())) return false; return f.auto() ? guide.contains("자동 인증") : guide.contains("직접 인증"); } diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/config/VerificationProperties.java b/src/main/java/com/ruleup/ruleup_backend/verification/config/VerificationProperties.java index c2fde29b..ae0ad947 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/config/VerificationProperties.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/config/VerificationProperties.java @@ -11,9 +11,6 @@ * @param syncMinIntervalSec 평상시 sync 최소 간격(초). 기기 스펙 기반 flushIntervalSec 보다 짧게 잡아 * 정상 주기 전송이 걸리지 않게 한다 * @param syncBacklogMinIntervalSec 복구 전송(backlog=true) 최소 간격(초). 밀린 구간을 나눠 올려야 해서 더 짧다 - * @param avoidGraceMinutes 장소 피하기의 "스침" 허용 시간(분). 이 시간 안에 나오면 위반이 아니다 — - * 금지 장소 앞을 지나가기만 해도 지오펜스는 ENTER 를 쏘기 때문이다. - * 실기기 테스트로 조절할 값이라 코드 상수로 두지 않는다 * @param signalRetentionDays 판정 원본을 유지할 일수. raw 는 현재 귀속일과 직전 유예 * 귀속일만 필요한 hot storage 이고, D일 신호는 D+2 00:00 KST 확정이 * 끝나면 목적이 끝난다. 정확히 이 일수를 남긴다 — 기본 3이면 @@ -57,7 +54,7 @@ @ConfigurationProperties(prefix = "app.verification") public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayloadBytes, Integer syncMinIntervalSec, Integer syncBacklogMinIntervalSec, - Integer avoidGraceMinutes, Integer signalRetentionDays, + Integer signalRetentionDays, Integer signalPartitionLookaheadDays, Integer gpsRetentionDays, Integer anomalyRetentionDays, Integer materializeCatchupDays, @@ -67,7 +64,6 @@ public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayload private static final int DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576; private static final int DEFAULT_SYNC_MIN_INTERVAL_SEC = 300; private static final int DEFAULT_SYNC_BACKLOG_MIN_INTERVAL_SEC = 10; - private static final int DEFAULT_AVOID_GRACE_MINUTES = 5; private static final int DEFAULT_SIGNAL_RETENTION_DAYS = 3; private static final int DEFAULT_SIGNAL_PARTITION_LOOKAHEAD_DAYS = 10; private static final int DEFAULT_GPS_RETENTION_DAYS = 0; @@ -80,7 +76,6 @@ public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayload // 0 은 "제한 없음"으로 인정한다(테스트·부하 시나리오). syncMinIntervalSec = nonNegativeOrDefault(syncMinIntervalSec, DEFAULT_SYNC_MIN_INTERVAL_SEC); syncBacklogMinIntervalSec = nonNegativeOrDefault(syncBacklogMinIntervalSec, DEFAULT_SYNC_BACKLOG_MIN_INTERVAL_SEC); - avoidGraceMinutes = nonNegativeOrDefault(avoidGraceMinutes, DEFAULT_AVOID_GRACE_MINUTES); signalRetentionDays = positiveOrDefault(signalRetentionDays, DEFAULT_SIGNAL_RETENTION_DAYS); signalPartitionLookaheadDays = positiveOrDefault( signalPartitionLookaheadDays, DEFAULT_SIGNAL_PARTITION_LOOKAHEAD_DAYS); diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailExpectation.java b/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailExpectation.java index ba298aef..9fd98280 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailExpectation.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailExpectation.java @@ -10,7 +10,7 @@ * *

저장 상태가 아니라 계산 상태다. 두 경우에 실패 예정이 된다. *