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