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 234a19a7..2e41606b 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 @@ -78,9 +78,9 @@ public static VerificationGuideFacts of(Challenge c, RoutineTemplate template) { } case "WAKE" -> { String t = clock(params.get("target_time")); - conditions.add((t != null ? t : "목표 시각") + " 전에 처음 휴대폰 잠금 해제하기"); + conditions.add((t != null ? t : "목표 시각") + "쯤 일어나 걷기(걸음 기록 기준)"); if (t != null) must.add(t); - fallback = freq + " " + (t != null ? t : "목표 시각") + " 전에 처음 휴대폰 잠금을 해제하면 자동 인증됩니다."; + fallback = freq + " " + (t != null ? t : "목표 시각") + "에 일어나 조금 걸으면 걸음 기록으로 자동 인증됩니다."; } case "SLEEP" -> { String bed = clock(params.get("bedtime_before")); 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 ae0ad947..130febf3 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,6 +11,10 @@ * @param syncMinIntervalSec 평상시 sync 최소 간격(초). 기기 스펙 기반 flushIntervalSec 보다 짧게 잡아 * 정상 주기 전송이 걸리지 않게 한다 * @param syncBacklogMinIntervalSec 복구 전송(backlog=true) 최소 간격(초). 밀린 구간을 나눠 올려야 해서 더 짧다 + * @param wakeGraceMinutes 기상 판정에서 목표 시각 뒤로 걸음을 더 기다리는 시간(분). 일어나 바로 걷지 않거나 + * 걸음 기록이 조금 늦게 잡혀도 인정한다. 기본 30 + * @param wakeMinSteps 기상으로 인정할 걸음 수(창 안 합계). 휴대폰을 집어 드는 정도의 오탐 걸음과 + * 실제로 일어나 움직인 것을 가르는 문턱이라 실기기 테스트로 찾는다. 기본 10 * @param signalRetentionDays 판정 원본을 유지할 일수. raw 는 현재 귀속일과 직전 유예 * 귀속일만 필요한 hot storage 이고, D일 신호는 D+2 00:00 KST 확정이 * 끝나면 목적이 끝난다. 정확히 이 일수를 남긴다 — 기본 3이면 @@ -58,7 +62,8 @@ public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayload Integer signalPartitionLookaheadDays, Integer gpsRetentionDays, Integer anomalyRetentionDays, Integer materializeCatchupDays, - Boolean requireActiveDevice, Boolean requireSignalOrigin) { + Boolean requireActiveDevice, Boolean requireSignalOrigin, + Integer wakeGraceMinutes, Integer wakeMinSteps) { private static final int DEFAULT_GEOFENCE_RADIUS_M = 200; private static final int DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576; @@ -69,6 +74,8 @@ public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayload private static final int DEFAULT_GPS_RETENTION_DAYS = 0; private static final int DEFAULT_ANOMALY_RETENTION_DAYS = 30; private static final int DEFAULT_MATERIALIZE_CATCHUP_DAYS = 7; + private static final int DEFAULT_WAKE_GRACE_MINUTES = 30; + private static final int DEFAULT_WAKE_MIN_STEPS = 10; public VerificationProperties { geofenceRadiusM = positiveOrDefault(geofenceRadiusM, DEFAULT_GEOFENCE_RADIUS_M); @@ -95,6 +102,9 @@ public record VerificationProperties(Integer geofenceRadiusM, Integer maxPayload // 관측으로 안전을 확인한 뒤 켜야 한다. requireActiveDevice = requireActiveDevice != null && requireActiveDevice; requireSignalOrigin = requireSignalOrigin != null && requireSignalOrigin; + // 0 은 「목표 시각까지만」이라는 유효한 값이다. + wakeGraceMinutes = nonNegativeOrDefault(wakeGraceMinutes, DEFAULT_WAKE_GRACE_MINUTES); + wakeMinSteps = positiveOrDefault(wakeMinSteps, DEFAULT_WAKE_MIN_STEPS); } private static int positiveOrDefault(Integer value, int fallback) { diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailureEvidence.java b/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailureEvidence.java index 243baec6..708fa6aa 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailureEvidence.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/domain/FailureEvidence.java @@ -27,7 +27,7 @@ public record FailureEvidence(String summary, * 숫자가 들어간다 — 기록하지 않는 타입은 기준값 없이 사유 코드만 남는다. */ private static final List EXPECTED_KEYS = List.of( - "goal", "goalKm", "goalMinutes", "beforeTime", "bedtimeBefore", "graceMinutes", + "goal", "goalKm", "goalMinutes", "beforeTime", "deadline", "bedtimeBefore", "graceMinutes", "unit", "metric", "mode"); /** 실제 측정값 키. 나머지(내부 이월 상태·중복 제거 키)는 설명에 쓰지 않는다. */ @@ -57,7 +57,7 @@ private static String summary(String reasonCode, Map ev) { case "INSUFFICIENT_STEPS" -> pair(ev, "걸음", "value", "걸음", "goal", "걸음"); case "INSUFFICIENT_USAGE" -> pair(ev, "사용", "usageMinutes", "분", "goalMinutes", "분"); case "USAGE_EXCEEDED" -> limit(ev, "사용", "usageMinutes", "goalMinutes"); - case "WOKE_UP_LATE" -> before(ev, "첫 잠금 해제", "firstUnlockAt", "beforeTime"); + case "WOKE_UP_LATE" -> wake(ev); case "SLEPT_LATE" -> before(ev, "취침", "bedtime", "bedtimeBefore"); case "INSUFFICIENT_SLEEP" -> pair(ev, "수면", "sleepHours", "시간", "goal", "시간"); case "PERMISSION_MISSING" -> "인증에 필요한 권한이 꺼져 있어 측정하지 못했어요."; @@ -98,7 +98,16 @@ private static String limit(Map ev, String label, String actualK return "%s %s분 / 상한 %s분".formatted(label, actual, goal); } - /** 「첫 잠금 해제 07:20 / 목표 07:00 이전」. */ + /** 「07:30까지 걸음 3걸음 / 목표 10걸음」 — 기상은 목표 시각 + 유예 안에 걸었는지로 본다. */ + private static String wake(Map ev) { + Object deadline = value(ev, "deadline"); + Object steps = value(ev, "value"); + Object goal = value(ev, "goal"); + if (deadline == null || steps == null || goal == null) return null; + return "%s까지 걸음 %s걸음 / 목표 %s걸음".formatted(deadline, steps, goal); + } + + /** 「취침 01:20 / 목표 00:00 이전」. */ private static String before(Map ev, String label, String actualKey, String goalKey) { Object actual = value(ev, actualKey); Object goal = value(ev, goalKey); diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/domain/WakeConfig.java b/src/main/java/com/ruleup/ruleup_backend/verification/domain/WakeConfig.java index a660b779..089b1152 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/domain/WakeConfig.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/domain/WakeConfig.java @@ -2,13 +2,27 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + /** - * 기상 인증 파라미터(§4.3). "beforeTime(예 07:00) 이전 첫 잠금해제"면 성공. - * 창은 [하루 시작, beforeTime] — beforeTime에 창이 닫힘(자정 아님, §2.11). + * 기상 인증 파라미터(§4.3). 「하루 시작부터 beforeTime + graceMinutes 까지 minSteps 걸음 이상」이면 성공. + * + *

예전에는 하루 첫 잠금 해제 시각으로 기상을 봤다. 잠금 해제는 누워서 알람만 끄고 다시 자도 + * 잡히고, 반대로 일어나서 휴대폰을 안 보면 실패한다 — 「일어났다」가 아니라 「휴대폰을 봤다」를 + * 쟀다. 2026-10-15 부터 일어나서 움직인 흔적(걸음)으로 본다. + * + * @param beforeTime 목표 기상 시각 "HH:mm"(루틴의 target_time) + * @param graceMinutes 목표 시각 뒤로 걸음을 더 기다리는 시간(분). 일어나 바로 걷지 않거나 걸음 기록이 + * 조금 늦게 잡혀도 인정한다 + * @param minSteps 창 안에서 필요한 걸음 수 + * @param trustedOrigins 걸음 기록을 인정하는 출처(비면 게이트 미적용) */ @JsonIgnoreProperties(ignoreUnknown = true) public record WakeConfig( - String beforeTime, // "HH:mm" + String beforeTime, + int graceMinutes, + int minSteps, + List trustedOrigins, Polarity polarity, // ACHIEVEMENT int maxSignalLagHours ) {} diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java index 7c86507a..8b75bf66 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java @@ -66,7 +66,7 @@ public EvaluationOutcome evaluate(DayContext ctx) { if (cfg.exerciseType() != null && r.exerciseType() != null && !cfg.exerciseType().equalsIgnoreCase(r.exerciseType())) continue; // 운동종류 불일치 제외 - String gate = gateReason(r, cfg); + String gate = gateReason(r, cfg.rejectManualRecording(), cfg.trustedOrigins()); if (gate != null) { anyRejected = true; HealthOrigin o = r.origin(); @@ -105,8 +105,8 @@ public EvaluationOutcome evaluate(DayContext ctx) { return EvaluationOutcome.pending(ev, windowClose(ctx)); } - /** 구간을 가진 측정값 1건. */ - private record Measured(Instant from, Instant to, double value) {} + /** 구간을 가진 측정값 1건. 기상 판정도 같은 방식으로 겹침을 걷어낸다. */ + record Measured(Instant from, Instant to, double value) {} /** * 겹치는 구간을 걷어낸 합계. @@ -115,7 +115,7 @@ private record Measured(Instant from, Instant to, double value) {} * 비례해 더한다. 값이 구간에 고르게 분포한다고 보는 근사지만, 겹친 만큼을 통째로 두 번 세거나 * 통째로 버리는 것보다 실제에 가깝다. Health Connect 는 같은 활동을 기기별로 따로 기록한다. */ - private double dedupedSum(List readings) { + static double dedupedSum(List readings) { if (readings.isEmpty()) return 0; List sorted = new ArrayList<>(readings); sorted.sort(Comparator.comparing(Measured::from).thenComparing(Measured::to)); @@ -134,12 +134,11 @@ private double dedupedSum(List readings) { return sum; } - /** 게이트 통과 여부. 통과면 null, 막히면 사유 문자열. */ - private String gateReason(HealthReading r, HealthConfig cfg) { + /** 게이트 통과 여부. 통과면 null, 막히면 사유 문자열. 기상 판정의 걸음 기록에도 같은 게이트를 쓴다. */ + static String gateReason(HealthReading r, boolean rejectManualRecording, List trusted) { HealthOrigin o = r.origin(); if (o == null) return "NO_ORIGIN"; // §6.3 메타데이터 누락 - if (cfg.rejectManualRecording() && "MANUAL".equalsIgnoreCase(o.recordingMethod())) return "MANUAL"; - List trusted = cfg.trustedOrigins(); + if (rejectManualRecording && "MANUAL".equalsIgnoreCase(o.recordingMethod())) return "MANUAL"; if (trusted != null && !trusted.isEmpty()) { boolean ok = o.dataOrigin() != null && trusted.contains(o.dataOrigin()); if (!ok) return "UNTRUSTED_ORIGIN"; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java index 30f16284..d7ebcd0c 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java @@ -1,29 +1,33 @@ package com.ruleup.ruleup_backend.verification.evaluator; +import com.ruleup.ruleup_backend.verification.domain.HealthMetric; import com.ruleup.ruleup_backend.verification.domain.VerificationMethod; import com.ruleup.ruleup_backend.verification.domain.WakeConfig; -import com.ruleup.ruleup_backend.verification.signal.ScreenEvent; +import com.ruleup.ruleup_backend.verification.signal.HealthReading; import com.ruleup.ruleup_backend.verification.signal.SignalType; import com.ruleup.ruleup_backend.verification.signal.SyncSignal; import org.springframework.stereotype.Component; import java.time.Instant; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.*; /** - * WAKE(기상) 평가기 — 도달형(§2.6, §2.11). - * - 신호: WAKE·SCREEN_TIME 의 screenEvents(UNLOCK/SCREEN_ON) 중 당일 첫 발생 = firstUnlockAt. - * 두 타입을 모두 읽는다 — 기상만 보내는 기기는 WAKE 로, 앱 사용과 함께 보내는 기기는 - * SCREEN_TIME 에 실어 보낸다. 한쪽만 읽으면 기기에 따라 기상이 영영 안 잡힌다. - * - 창: [하루 시작, beforeTime]. beforeTime에 창이 닫힌다(자정 아님). - * - 판정: firstUnlockAt ≤ beforeTime → SUCCESS / 창 닫힘·미발생 → FAILED(WOKE_UP_LATE) / 그 외 → PENDING. - * - 그날 원본 전부에서 가장 이른 잠금해제를 매번 다시 고른다 — 이월 상태가 없어 도착 순서와 무관하다. + * WAKE(기상) 평가기 — 도달형(§2.6, §2.11). 일어나서 걸었는가로 본다. + * - 신호: HEALTH 의 STEPS 기록(Health Connect StepsRecord). 구간(startTime~endTime)이 있는 기록만 쓴다 — + * 「언제 걸었는지」가 판정의 전부라, 구간 없는 하루 누적값은 근거가 되지 못한다. + * - 창: [하루 시작, beforeTime + graceMinutes]. 창에 걸친 기록은 겹친 비율만큼만 센다. + * - 판정: 창 안 걸음 ≥ minSteps → SUCCESS / 창 닫힘·미달 → FAILED(WOKE_UP_LATE) / 그 외 → PENDING. + * - 출처 게이트는 움직임(HEALTH)과 같다 — 손입력·신뢰 밖 출처는 뺀다. 폰과 워치가 같은 걸음을 각자 + * 기록하면 겹친 구간은 한 번만 센다. + * - 그날 원본 전부를 매번 다시 읽는다 — 이월 상태가 없어 도착 순서와 무관하다. */ @Component public class WakeEvaluator implements MethodEvaluator { + private static final DateTimeFormatter HHMM = DateTimeFormatter.ofPattern("HH:mm"); + @Override public VerificationMethod method() { return VerificationMethod.WAKE; } @@ -34,47 +38,60 @@ public EvaluationOutcome evaluate(DayContext ctx) { return EvaluationOutcome.pending(null, null); // 잘못된 config는 무시(PENDING 유지) } - Instant windowCloses = TimeWindows.atTime(ctx.targetDate(), cfg.beforeTime(), ctx.zone()); - Instant dayStart = TimeWindows.startOfDay(ctx.targetDate(), ctx.zone()); - Window window = new Window(dayStart, windowCloses); + Instant target = TimeWindows.atTime(ctx.targetDate(), cfg.beforeTime(), ctx.zone()); + Instant windowCloses = target.plusSeconds(60L * Math.max(cfg.graceMinutes(), 0)); + Window window = new Window(TimeWindows.startOfDay(ctx.targetDate(), ctx.zone()), windowCloses); - // 그날 원본 중 창 안에서 가장 이른 잠금해제 - Instant firstUnlock = earliestUnlockInWindow(ctx.signals(), window); + Set rejected = new LinkedHashSet<>(); + double steps = stepsInWindow(ctx.signals(), window, cfg, rejected); Map evidence = new HashMap<>(); evidence.put("beforeTime", cfg.beforeTime()); - if (firstUnlock != null) evidence.put("firstUnlockAt", firstUnlock.toString()); + evidence.put("graceMinutes", cfg.graceMinutes()); + evidence.put("deadline", LocalTime.ofInstant(windowCloses, ctx.zone()).format(HHMM)); + evidence.put("metric", HealthMetric.STEPS.name()); + evidence.put("unit", "count"); + evidence.put("value", Math.round(steps)); + evidence.put("goal", cfg.minSteps()); + if (!rejected.isEmpty()) evidence.put("rejectedOrigins", new ArrayList<>(rejected)); - EvaluationOutcome outcome; - if (firstUnlock != null) { - outcome = EvaluationOutcome.success(evidence, windowCloses); // 창 내 기상 → 즉시 성공 - } else if (window.isClosed(ctx.now())) { - outcome = EvaluationOutcome.violated("WOKE_UP_LATE", evidence, windowCloses); // 창 닫힘·미발생 - } else { - outcome = EvaluationOutcome.pending(evidence, windowCloses); // 아직 대기 + if (cfg.minSteps() > 0 && steps >= cfg.minSteps()) { + return EvaluationOutcome.success(evidence, windowCloses); // 창 안에서 움직였다 → 즉시 성공 } - return outcome; - } - - /** 화면 이벤트를 실어 오는 입력 타입. 기상 전용(WAKE)과 앱 사용과 함께 오는 것(SCREEN_TIME) 둘 다다. */ - private boolean carriesScreenEvents(String type) { - return SignalType.WAKE.name().equals(type) || SignalType.SCREEN_TIME.name().equals(type); + if (window.isClosed(ctx.now())) { + return EvaluationOutcome.violated("WOKE_UP_LATE", evidence, windowCloses); + } + return EvaluationOutcome.pending(evidence, windowCloses); } - private Instant earliestUnlockInWindow(List signals, Window window) { - if (signals == null) return null; - Instant earliest = null; + /** 창 안 걸음 합 — 출처 게이트를 통과한 구간 기록만, 창에 겹친 비율만큼, 겹친 기록은 한 번만. */ + private double stepsInWindow(List signals, Window window, WakeConfig cfg, Set rejected) { + if (signals == null) return 0; + List clipped = new ArrayList<>(); + Set seenRecords = new HashSet<>(); for (SyncSignal s : signals) { - if (!carriesScreenEvents(s.type()) || s.screenEvents() == null) continue; - for (ScreenEvent e : s.screenEvents()) { - // 기상 판정은 "당일 첫 잠금 해제"다(인증 정책 §1.1). 화면만 켜지는 일은 알림 확인으로도 - // 흔해서, SCREEN_ON 을 기상으로 세면 자는 사람이 깬 것으로 잡힌다. - if (!"UNLOCK".equalsIgnoreCase(e.event())) continue; - Instant at = TimeWindows.parseInstant(e.at()); - if (at == null || !window.contains(at)) continue; - if (earliest == null || at.isBefore(earliest)) earliest = at; + if (!SignalType.HEALTH.name().equals(s.type()) || s.readings() == null) continue; + for (HealthReading r : s.readings()) { + if (r == null || !HealthMetric.STEPS.name().equalsIgnoreCase(r.metric())) continue; + String gate = HealthEvaluator.gateReason(r, true, cfg.trustedOrigins()); + if (gate != null) { + String origin = (r.origin() != null && r.origin().dataOrigin() != null) ? r.origin().dataOrigin() : "?"; + rejected.add(origin + ":" + gate); + continue; + } + Instant from = TimeWindows.parseInstant(r.startTime()); + Instant to = TimeWindows.parseInstant(r.endTime()); + if (from == null || to == null || !to.isAfter(from) || r.value() == null) continue; + if (r.recordId() != null && !seenRecords.add(r.recordId())) continue; // 재전송은 한 번만 + + Instant start = from.isBefore(window.start()) ? window.start() : from; + Instant end = to.isAfter(window.end()) ? window.end() : to; + if (!end.isAfter(start)) continue; // 창 밖 기록 + double share = (double) (end.getEpochSecond() - start.getEpochSecond()) + / (to.getEpochSecond() - from.getEpochSecond()); + clipped.add(new HealthEvaluator.Measured(start, end, r.value().doubleValue() * share)); } } - return earliest; + return HealthEvaluator.dedupedSum(clipped); } } diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/AnomalyEventRecorder.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/AnomalyEventRecorder.java index 99af72c4..28926cc2 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/AnomalyEventRecorder.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/AnomalyEventRecorder.java @@ -124,7 +124,7 @@ private static String signalTypeOf(VerificationMethod method) { case GPS_PRESENCE, GPS_DISTANCE -> "LOCATION"; case HEALTH -> "HEALTH"; case SCREEN_TIME -> "SCREEN_TIME"; - case WAKE -> "WAKE"; + case WAKE -> "HEALTH"; case SLEEP -> "SLEEP"; default -> "UNKNOWN"; }; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/MethodSignalTypes.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/MethodSignalTypes.java index dbb58a78..a4aef480 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/MethodSignalTypes.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/MethodSignalTypes.java @@ -26,7 +26,7 @@ public static Set of(VerificationMethod method) { Set.of("GEOFENCE", "GEOFENCE_TRANSITION", "LOCATION", "RUNNING_SESSION"); case HEALTH -> Set.of("HEALTH"); case SCREEN_TIME -> Set.of("SCREEN_TIME", "USAGE", "APP_USAGE"); - case WAKE -> Set.of("SCREEN_TIME", "WAKE", "UNLOCK", "USAGE"); + case WAKE -> Set.of("HEALTH"); // 기상은 일어나 걸은 걸음으로 본다(2026-10-15) case SLEEP -> Set.of("SLEEP"); default -> Set.of(); }; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalConsentGate.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalConsentGate.java index 5bae7ac4..0b0ca20c 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalConsentGate.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalConsentGate.java @@ -57,7 +57,7 @@ public record Decision(List accepted, List rejectedTypes, List consentRequired) {} /** - * 이 판정 방식이 요구하는 개별 동의 — 없으면 {@code null}(앱 사용·기상은 대상이 아니다). + * 이 판정 방식이 요구하는 개별 동의 — 없으면 {@code null}(앱 사용은 대상이 아니다. 기상은 걸음을 읽으므로 건강정보 동의가 필요하다). * *

신호가 한 건도 오지 않아도 물을 수 있어야 한다. 동의가 없으면 그 방은 앞으로도 * 자동 인증이 불가능한 상태이고, 그건 권한이 꺼진 것과 같은 사건이다 — 신호가 실려 온 diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalExclusionRecorder.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalExclusionRecorder.java index b1e10ca1..2cf8e873 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalExclusionRecorder.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/SignalExclusionRecorder.java @@ -150,7 +150,7 @@ private static String signalTypeOf(VerificationMethod method) { case GPS_PRESENCE, GPS_DISTANCE -> "LOCATION"; case HEALTH -> "HEALTH"; case SCREEN_TIME -> "SCREEN_TIME"; - case WAKE -> "WAKE"; + case WAKE -> "HEALTH"; case SLEEP -> "SLEEP"; default -> "UNKNOWN"; }; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java index e8cf80b0..be3fde4b 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java @@ -66,7 +66,9 @@ public VerificationConfig build(Challenge challenge) { switch (tag) { case "WAKE" -> wake = new WakeConfig( - timeParam(params, "target_time", "07:00"), Polarity.ACHIEVEMENT, 1); + timeParam(params, "target_time", "07:00"), + properties.wakeGraceMinutes(), properties.wakeMinSteps(), + DEFAULT_TRUSTED_ORIGINS, Polarity.ACHIEVEMENT, 1); case "SCREEN_TIME_MIN" -> screenTime = new ScreenTimeConfig( ScreenTimeMode.MIN, Polarity.ACHIEVEMENT, packages(params), intParam(params, "duration_min", 30), timeWindow(params), 1); @@ -181,7 +183,9 @@ private String heuristicTag(Object snapObj, Map params) { if (src == null) return "SELF_CHECK"; return switch (src) { case GEOFENCE -> "GPS_PRESENCE"; - case GPS, ACTIVITY, HC_RECORD -> "HEALTH"; // v2: 움직임은 Health Connect + // 기상도 걸음(Health Connect)으로 본다 — 목표 시각이 있으면 기상이다. + case GPS, ACTIVITY, HC_RECORD -> + (params != null && params.containsKey("target_time")) ? "WAKE" : "HEALTH"; case SLEEP -> "SLEEP"; case USAGE -> (params != null && params.containsKey("target_time")) ? "WAKE" : "SCREEN_TIME_MIN"; default -> "SELF_CHECK"; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java index 1669bf87..497d3877 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java @@ -196,7 +196,8 @@ private String windowLabel(VerificationConfig config) { case GPS_PRESENCE -> (config.gps() != null) ? config.gps().timeWindow() : null; case SCREEN_TIME -> (config.screenTime() != null) ? config.screenTime().timeWindow() : null; case WAKE -> (config.wake() != null && config.wake().beforeTime() != null) - ? "~" + config.wake().beforeTime() : null; + ? "~" + java.time.LocalTime.parse(config.wake().beforeTime()) + .plusMinutes(config.wake().graceMinutes()) : null; case SLEEP -> (config.sleep() != null && config.sleep().bedtimeBefore() != null) ? "~" + config.sleep().bedtimeBefore() : null; default -> null; diff --git a/src/main/resources/db/migration/V13__wake_by_steps.sql b/src/main/resources/db/migration/V13__wake_by_steps.sql new file mode 100644 index 00000000..6775f53a --- /dev/null +++ b/src/main/resources/db/migration/V13__wake_by_steps.sql @@ -0,0 +1,31 @@ +-- ====================================================================== +-- 기상 루틴(1501~1507)을 첫 잠금 해제가 아니라 걸음으로 판정한다 +-- +-- 잠금 해제는 누워서 알람만 끄고 다시 자도 잡히고, 일어나서 휴대폰을 안 보면 실패한다. +-- 「하루 시작 ~ 목표 시각 + 유예」 안에 걸음이 잡히면 일어난 것으로 본다(유예·걸음 수는 서버 설정). +-- +-- 앱은 챌린지의 requiredPermissions 로 Health Connect 수집 지표를 정한다 — READ_STEPS 가 있어야 +-- 걸음 기록을 보낸다. 그래서 템플릿뿐 아니라 이미 만든 방의 스냅샷도 같이 바꾼다. 바꾸지 않으면 +-- 그 방은 판정기만 걸음을 기다리고 앱은 걸음을 보내지 않아 매일 무신호로 실패한다. +-- ====================================================================== + +UPDATE `RoutineTemplate` + SET `description` = '목표 시각쯤 일어나 걸은 걸음 기록으로 기상을 확인해요.', + `rationale` = '목표 시각 + 유예 안의 걸음으로 기상을 판정' + WHERE `id` BETWEEN 1501 AND 1507; + +UPDATE `RoutineVerification` + SET `autoVerificationType` = 'HEALTH_CONNECT', + `autoSignalSource` = 'HC_RECORD', + `autoRequiredPermissions` = JSON_ARRAY('android.permission.health.READ_STEPS') + WHERE `templateId` BETWEEN 1501 AND 1507; + +-- 자동 인증으로 만든 방만 — 직접 인증으로 바꾼 방은 신호를 쓰지 않는다. +UPDATE `challenges` + SET `verification_config` = JSON_SET(`verification_config`, + '$.verificationType', 'HEALTH_CONNECT', + '$.signalSource', 'HC_RECORD', + '$.requiredPermissions', JSON_ARRAY('android.permission.health.READ_STEPS')), + `verification_guide` = NULL + WHERE `template_id` BETWEEN 1501 AND 1507 + AND JSON_UNQUOTE(JSON_EXTRACT(`verification_config`, '$.selectedMethod')) = 'AUTO'; diff --git a/src/main/resources/prompts/verification-guide.txt b/src/main/resources/prompts/verification-guide.txt index 52334792..f7f06d81 100644 --- a/src/main/resources/prompts/verification-guide.txt +++ b/src/main/resources/prompts/verification-guide.txt @@ -26,7 +26,7 @@ description: 챌린지가 만들어진 뒤, 그 방의 인증 조건(서버가 인증하라는 뜻으로 "직접 인증해 주세요."로 끝낸다. 5. **길이.** 한두 문장, 80자 이내. 존댓말("~됩니다", "~해 주세요"). 이모지·따옴표·마크다운 금지. 6. 판정 방식의 내부 용어(Health Connect, 지오펜스, 잠금 해제 이벤트 등)는 쓰지 않는다. - 단, 수면은 "수면 기록으로"라고 써도 된다. + 단, 수면은 "수면 기록으로", 기상은 "걸음 기록으로"라고 써도 된다. ## 예시 @@ -36,8 +36,8 @@ description: 챌린지가 만들어진 뒤, 그 방의 인증 조건(서버가 → `{"guide":"스터디 카페 장소를 등록한 뒤 주 5회 그곳에 2시간 이상 머물면 자동 인증됩니다."}` - 앱 줄이기 / 루틴 "유튜브 줄이기" / 매일 / 고른 앱을 하루 1시간 이하로 쓰기 → `{"guide":"매일 유튜브를 1시간 이하로 보면 자동 인증됩니다."}` -- 기상 / 매일 / 오전 7시 전에 처음 휴대폰 잠금 해제하기 - → `{"guide":"매일 오전 7시 전에 휴대폰 잠금을 처음 해제하면 자동 인증됩니다."}` +- 기상 / 매일 / 오전 7시쯤 일어나 걷기(걸음 기록 기준) + → `{"guide":"매일 오전 7시에 일어나 조금 걸으면 걸음 기록으로 자동 인증됩니다."}` - 수면 / 매일 / 밤 11시 전에 잠들기(수면 기록 기준) → `{"guide":"매일 밤 11시 전에 잠들면 수면 기록으로 자동 인증됩니다."}` - 수동 / 제목 "매일 코딩 문제 2개 풀기" / 매일 diff --git a/src/test/java/com/ruleup/ruleup_backend/routine/RoutineCatalogSeedIT.java b/src/test/java/com/ruleup/ruleup_backend/routine/RoutineCatalogSeedIT.java index 2bdc3ce5..23a270cb 100644 --- a/src/test/java/com/ruleup/ruleup_backend/routine/RoutineCatalogSeedIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/routine/RoutineCatalogSeedIT.java @@ -54,7 +54,7 @@ class RoutineCatalogSeedIT { "GPS_PRESENCE", Set.of("ACCESS_FINE_LOCATION", "ACCESS_BACKGROUND_LOCATION"), "SCREEN_TIME_MAX", Set.of("PACKAGE_USAGE_STATS"), "SCREEN_TIME_MIN", Set.of("PACKAGE_USAGE_STATS"), - "WAKE", Set.of("PACKAGE_USAGE_STATS"), + "WAKE", Set.of("android.permission.health.READ_STEPS"), "HEALTH", Set.of("android.permission.health.READ_STEPS", "android.permission.health.READ_DISTANCE"), "SLEEP", Set.of("android.permission.health.READ_SLEEP")); diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/AndroidWireSignalTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/AndroidWireSignalTest.java index fd899f6e..c5743557 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/AndroidWireSignalTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/AndroidWireSignalTest.java @@ -98,7 +98,7 @@ void sleepSessionsAreEvaluated() { List.of()); EvaluationOutcome outcome = new SleepEvaluator( new com.ruleup.ruleup_backend.verification.config.VerificationProperties( - null, null, null, null, null, null, null, null, null, false, false)) + null, null, null, null, null, null, null, null, null, false, false, null, null)) .evaluate(new DayContext(LocalDate.of(2026, 9, 17), KST, morning, config, List.of(wire.withReceivedAt(morning)), List.of(), List.of(), "member")); diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java index c916f7ef..951d8c37 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java @@ -23,7 +23,7 @@ * 평가기가 문서에 적힌 판정 기준을 실제로 지키는지 (인증 정책 §1.1 · 테크 스펙 §5-1 판정 유형). * *

여기 모인 네 가지는 전부 "문서에는 있는데 코드에 없던" 게이트다. 없으면 조용히 틀린 판정이 나간다 — - * 스쳐 지나간 편의점이 위반이 되고, 조작된 위치가 인증을 통과하고, 알림 확인이 기상으로 잡힌다. + * 스쳐 지나간 편의점이 위반이 되고, 조작된 위치가 인증을 통과하고, 누워서 알람만 끈 것이 기상으로 잡힌다. */ class EvaluatorAccuracyTest { @@ -34,13 +34,13 @@ class EvaluatorAccuracyTest { /** 게이트 강화를 끈 기본 설정. 나머지 값은 record 가 기본값으로 채운다. */ private static com.ruleup.ruleup_backend.verification.config.VerificationProperties lenientProperties() { return new com.ruleup.ruleup_backend.verification.config.VerificationProperties( - null, null, null, null, null, null, null, null, null, false, false); + null, null, null, null, null, null, null, null, null, false, false, null, null); } /** 출처 없는 기록을 거부하는 설정. */ private static com.ruleup.ruleup_backend.verification.config.VerificationProperties strictOriginProperties() { return new com.ruleup.ruleup_backend.verification.config.VerificationProperties( - null, null, null, null, null, null, null, null, null, false, true); + null, null, null, null, null, null, null, null, null, false, true, null, null); } private static Instant at(int hour, int minute) { @@ -66,7 +66,8 @@ private VerificationConfig gpsConfig(int dwellMinutes, int graceMinutes, int acc private VerificationConfig wakeConfig(String beforeTime) { return new VerificationConfig(ScheduleType.FIXED_DAYS, null, MethodCombine.AND, List.of(VerificationMethod.WAKE), null, null, null, - new WakeConfig(beforeTime, Polarity.ACHIEVEMENT, 1), null, List.of()); + new WakeConfig(beforeTime, 30, 10, List.of("com.sec.android.app.shealth"), + Polarity.ACHIEVEMENT, 1), null, List.of()); } private VerificationConfig sleepConfig(BigDecimal minHours) { @@ -99,6 +100,15 @@ private static SyncSignal screen(String event, Instant when) { List.of(new ScreenEvent(event, when.toString())), null, null); } + private static final HealthOrigin WATCH = new HealthOrigin("com.sec.android.app.shealth", "AUTO", "WATCH"); + + private static SyncSignal steps(double count, Instant start, Instant end, HealthOrigin origin) { + HealthReading reading = new HealthReading(null, "STEPS", BigDecimal.valueOf(count), "count", + start.toString(), end.toString(), null, origin); + return new SyncSignal("HEALTH", null, end.toString(), null, null, null, List.of(reading), + null, null, null, TARGET.toString(), null, null, null, null); + } + private static SyncSignal sleep(Instant start, Instant end, HealthOrigin origin) { return new SyncSignal("SLEEP", null, end.toString(), null, null, null, null, null, null, null, null, null, null, @@ -190,27 +200,70 @@ void mockPointsAreExcluded() { } @Nested - @DisplayName("기상 — 잠금 해제만 인정") - class WakeUnlockOnly { + @DisplayName("기상 — 목표 시각 + 30분 안의 걸음") + class WakeBySteps { private final WakeEvaluator evaluator = new WakeEvaluator(); @Test - @DisplayName("잠금 해제는 기상으로 인정된다") - void unlockCounts() { + @DisplayName("목표 시각 뒤 유예 안에 걸으면 기상으로 인정된다") + void stepsInsideWindowCount() { EvaluationOutcome outcome = evaluator.evaluate(ctx( - wakeConfig("07:00"), List.of(screen("UNLOCK", at(6, 30))), List.of(), at(8, 0))); + wakeConfig("07:00"), List.of(steps(40, at(7, 10), at(7, 15), WATCH)), List.of(), at(7, 20))); assertThat(outcome.status()).isEqualTo(VerificationStatus.SUCCESS); } @Test - @DisplayName("화면만 켜진 것은 기상이 아니다 — 알림 확인으로도 켜진다") - void screenOnAloneIsNotWakingUp() { + @DisplayName("잠금 해제만으로는 기상이 아니다 — 누워서 알람만 꺼도 잡힌다") + void unlockAloneIsNotWakingUp() { EvaluationOutcome outcome = evaluator.evaluate(ctx( - wakeConfig("07:00"), List.of(screen("SCREEN_ON", at(6, 30))), List.of(), at(8, 0))); + wakeConfig("07:00"), List.of(screen("UNLOCK", at(6, 30))), List.of(), at(8, 0))); assertThat(outcome.status()).isNotEqualTo(VerificationStatus.SUCCESS); + assertThat(outcome.failureReason()).isEqualTo("WOKE_UP_LATE"); + } + + @Test + @DisplayName("유예가 지난 뒤의 걸음은 늦잠이다") + void stepsAfterDeadlineAreLate() { + EvaluationOutcome outcome = evaluator.evaluate(ctx( + wakeConfig("07:00"), List.of(steps(500, at(8, 0), at(8, 10), WATCH)), List.of(), at(9, 0))); + + assertThat(outcome.status()).isNotEqualTo(VerificationStatus.SUCCESS); + assertThat(outcome.failureReason()).isEqualTo("WOKE_UP_LATE"); + assertThat(outcome.evidence()).containsEntry("value", 0L).containsEntry("deadline", "07:30"); + } + + @Test + @DisplayName("마감에 걸친 기록은 창 안에 든 비율만큼만 센다") + void straddlingRecordIsProrated() { + // 07:20~07:40 의 16걸음 중 마감(07:30) 전은 절반, 8걸음 — 기준 10걸음에 못 미친다. + EvaluationOutcome outcome = evaluator.evaluate(ctx( + wakeConfig("07:00"), List.of(steps(16, at(7, 20), at(7, 40), WATCH)), List.of(), at(9, 0))); + + assertThat(outcome.status()).isNotEqualTo(VerificationStatus.SUCCESS); + assertThat(outcome.evidence()).containsEntry("value", 8L); + } + + @Test + @DisplayName("손으로 입력한 걸음은 기상 근거가 아니다") + void manualStepsAreExcluded() { + HealthOrigin handWritten = new HealthOrigin("com.sec.android.app.shealth", "MANUAL", "PHONE"); + EvaluationOutcome outcome = evaluator.evaluate(ctx( + wakeConfig("07:00"), List.of(steps(100, at(7, 0), at(7, 5), handWritten)), List.of(), at(8, 0))); + + assertThat(outcome.status()).isNotEqualTo(VerificationStatus.SUCCESS); + } + + @Test + @DisplayName("창이 열려 있고 아직 걸음이 없으면 기다린다") + void waitsWhileWindowOpen() { + EvaluationOutcome outcome = evaluator.evaluate(ctx( + wakeConfig("07:00"), List.of(), List.of(), at(7, 10))); + + assertThat(outcome.status()).isEqualTo(VerificationStatus.PENDING); + assertThat(outcome.failureReason()).isNull(); } } diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/GpsRetentionCoherenceTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/GpsRetentionCoherenceTest.java index a60263b4..5ccc1cd4 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/GpsRetentionCoherenceTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/GpsRetentionCoherenceTest.java @@ -30,7 +30,7 @@ class GpsRetentionCoherenceTest { /** 지정하지 않은 값은 record 가 기본값으로 채운다. */ private VerificationProperties properties(Integer gpsRetentionDays, Integer signalRetentionDays) { return new VerificationProperties(null, null, null, null, - signalRetentionDays, null, gpsRetentionDays, null, null, false, false); + signalRetentionDays, null, gpsRetentionDays, null, null, false, false, null, null); } @Test diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/SleepUnionTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/SleepUnionTest.java index 6baa713f..0887951f 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/SleepUnionTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/SleepUnionTest.java @@ -34,7 +34,7 @@ class SleepUnionTest { private final SleepEvaluator evaluator = new SleepEvaluator( new com.ruleup.ruleup_backend.verification.config.VerificationProperties( - null, null, null, null, null, null, null, null, null, false, false)); + null, null, null, null, null, null, null, null, null, false, false, null, null)); private static Instant at(int day, int hour, int minute) { return LocalDate.of(2026, 9, day).atTime(hour, minute).atZone(KST).toInstant(); diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAndroidWireIT.java b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAndroidWireIT.java index c37ccbcf..a7a507f3 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAndroidWireIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAndroidWireIT.java @@ -194,17 +194,24 @@ void healthWithAndroidShape() throws Exception { } @Test - @DisplayName("기상 — 앱 형식 firstUnlock(epoch millis)으로 인증된다") + @DisplayName("기상 — 앱 형식(epoch millis) 걸음 기록으로 인증된다") void wakeWithAndroidShape() throws Exception { Member me = member(uniq("wire-wake")); - UUID ch = insertAutoChallenge(me.id(), "WAKE", "USAGE", "{\"target_time\":\"07:00\"}"); + UUID ch = insertAutoChallenge(me.id(), "WAKE", "HC_RECORD", "{\"target_time\":\"07:00\"}"); UUID memberId = insertReadyMember(ch, me.id(), null, null); + Map reading = new LinkedHashMap<>(); + reading.put("recordId", "hc-wake-1"); + reading.put("value", 24.0); + reading.put("startTime", todayAt(7, 3).toEpochMilli()); + reading.put("endTime", todayAt(7, 6).toEpochMilli()); + reading.put("recordingMethod", "AUTO"); + reading.put("originPackage", "com.sec.android.app.shealth"); Map signal = new LinkedHashMap<>(); - signal.put("type", "WAKE"); - signal.put("firstUnlock", todayAt(6, 30).toEpochMilli()); - signal.put("firstScreenOn", todayAt(6, 28).toEpochMilli()); - signal.put("deviceSecure", true); + signal.put("type", "HEALTH"); + signal.put("date", java.time.LocalDate.now(KST).toString()); + signal.put("metric", "STEPS"); + signal.put("readings", List.of(reading)); sync(me.token(), List.of(signal)); diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSignalContractIT.java b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSignalContractIT.java index e35c63d6..f7c249ea 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSignalContractIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationSignalContractIT.java @@ -112,17 +112,27 @@ private static Map healthSignal(String recordId, double value, class InputTypes { @Test - @DisplayName("[P1] WAKE 로 올라온 잠금해제가 기상 판정에 쓰인다") - void wakeSignalDrivesWakeVerification() throws Exception { + @DisplayName("[P1] 기상은 목표 시각 + 유예 안의 걸음으로 판정된다") + void stepsDriveWakeVerification() throws Exception { Member me = member(uniq("contract-wake")); - UUID challenge = insertAutoChallenge(me.id(), "WAKE", "USAGE", "{\"target_time\":\"07:00\"}"); + UUID challenge = insertAutoChallenge(me.id(), "WAKE", "HC_RECORD", "{\"target_time\":\"07:00\"}"); + UUID memberId = insertReadyMember(challenge, me.id(), null, null); + + syncOk(me.token(), List.of(healthSignal("wake-steps-1", 30, todayAt(7, 5), todayAt(7, 10)))); + + assertThat(statusToday(memberId)).isEqualTo("SUCCESS"); + } + + @Test + @DisplayName("[P1] 잠금 해제만으로는 기상으로 인정하지 않는다") + void unlockAloneDoesNotVerifyWake() throws Exception { + Member me = member(uniq("contract-wake-unlock")); + UUID challenge = insertAutoChallenge(me.id(), "WAKE", "HC_RECORD", "{\"target_time\":\"07:00\"}"); UUID memberId = insertReadyMember(challenge, me.id(), null, null); syncOk(me.token(), List.of(wakeSignal("UNLOCK", todayAt(6, 30)))); - assertThat(statusToday(memberId)) - .as("SCREEN_TIME 만 읽으면 기상만 보내는 기기는 영영 인증되지 않는다") - .isEqualTo("SUCCESS"); + assertThat(statusToday(memberId)).isNotEqualTo("SUCCESS"); } @Test