feat(backend): Phase 3 MVP — Detection Model Registry 운영자 API

V034 detection_models / detection_model_versions 에 대한 CRUD 경량 API.
MVP 8 엔드포인트로 Phase 2 PoC 5 모델을 DRAFT→ACTIVE 승격 가능한 최소 범위.

엔드포인트 (/api/ai/detection-models):
- GET  /                                    — 카탈로그 목록
- GET  /{modelId}                           — 단건
- GET  /{modelId}/dependencies              — DAG 선행
- GET  /{modelId}/versions                  — 버전 목록
- GET  /{modelId}/versions/{versionId}      — 버전 상세
- POST /{modelId}/versions                  — DRAFT 생성 [@Auditable]
- POST /.../{versionId}/activate            — DRAFT→ACTIVE(role) [@Auditable]
- POST /.../{versionId}/archive             — ACTIVE/DRAFT→ARCHIVED [@Auditable]

구성 요소 (gc.mda.kcg.domain.ai):
- DetectionModel / DetectionModelVersion 엔티티
  · JSONB params 는 Hibernate @JdbcTypeCode(SqlTypes.JSON) + JsonNode
- DetectionModelRepository / DetectionModelVersionRepository
- DetectionModelService (READ only)
- DetectionModelVersionService (전이 화이트리스트 + uk_detection_model_primary 409)
- 4 DTO record (Response 2, Request 3)

권한: ai-operations:detection-models (V034 에서 이미 seed)
  · READ (전체), CREATE (POST /versions), UPDATE (activate/archive)
  · ADMIN 5 ops / OPERATOR READ+UPDATE / ANALYST·VIEWER READ

검증:
- mvn -q compile 성공 (warning 만, error 없음)
- mvn spring-boot:run 로컬 기동 성공 (포트 8081)
- admin 계정 쿠키 인증으로 8 엔드포인트 전수 smoke test:
  · GET /api/ai/detection-models → 5 모델 (dark/risk/gear/pair/transship) 반환
  · GET /{modelId} 단건 + /dependencies (0 rows) 정상
  · POST /versions (1.0.1-test DRAFT) → activate SHADOW → archive 전이 사이클

후속 PR:
- promote-primary (SHADOW/CHALLENGER→PRIMARY, 기존 PRIMARY 자동 archive)
- enable 토글, metrics / compare / runs 조회

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-20 13:46:31 +09:00
부모 0ee2651dd7
커밋 2e18960bf2
13개의 변경된 파일587개의 추가작업 그리고 0개의 파일을 삭제

파일 보기

@ -0,0 +1,53 @@
package gc.mda.kcg.domain.ai;
import jakarta.persistence.*;
import lombok.*;
import java.time.OffsetDateTime;
/**
* detection_models 엔티티 (V034, Phase 1-1).
*
* prediction 탐지 모델 카탈로그 model_id PK. 런타임 파라미터는
* 별도 {@link DetectionModelVersion} 버전별로 보관.
*/
@Entity
@Table(name = "detection_models", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class DetectionModel {
@Id
@Column(name = "model_id", length = 64, nullable = false)
private String modelId;
@Column(name = "display_name", length = 200, nullable = false)
private String displayName;
@Column(name = "tier", nullable = false)
private Integer tier;
@Column(name = "category", length = 40)
private String category;
@Column(name = "description", columnDefinition = "text")
private String description;
@Column(name = "entry_module", length = 200, nullable = false)
private String entryModule;
@Column(name = "entry_callable", length = 100, nullable = false)
private String entryCallable;
@Column(name = "is_enabled", nullable = false)
private Boolean isEnabled;
@Column(name = "created_at", updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
}

파일 보기

@ -0,0 +1,119 @@
package gc.mda.kcg.domain.ai;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* Detection Model Registry 운영자 API (Phase 3 MVP).
*
* 경로: /api/ai/detection-models
*
* <h3>엔드포인트 (MVP 8종)</h3>
* <ul>
* <li>GET / 모델 카탈로그 목록 (tier, model_id 정렬)</li>
* <li>GET /{modelId} 단건 상세</li>
* <li>GET /{modelId}/dependencies DAG 선행 의존성 목록</li>
* <li>GET /{modelId}/versions 버전 목록 (최신순)</li>
* <li>GET /{modelId}/versions/{versionId} 버전 상세 (params 포함)</li>
* <li>POST /{modelId}/versions DRAFT 버전 생성</li>
* <li>POST /{modelId}/versions/{versionId}/activate DRAFT ACTIVE(role)</li>
* <li>POST /{modelId}/versions/{versionId}/archive ACTIVE/DRAFT ARCHIVED</li>
* </ul>
*
* <h3>범위 (후속 PR)</h3>
* - promote-primary (SHADOW/CHALLENGER PRIMARY), enable 토글,
* metrics / compare / runs 조회.
*
* <h3>권한</h3>
* 모든 READ: {@code ai-operations:detection-models} READ.
* 쓰기(create/activate/archive): 각각 CREATE/UPDATE 권한. ADMIN / OPERATOR 역할 보유.
* 모든 쓰기 액션은 {@code @Auditable} 감사로그 자동 기록.
*/
@RestController
@RequestMapping("/api/ai/detection-models")
@RequiredArgsConstructor
public class DetectionModelController {
private static final String RESOURCE = "ai-operations:detection-models";
private final DetectionModelService modelService;
private final DetectionModelVersionService versionService;
// 모델 카탈로그
@GetMapping
@RequirePermission(resource = RESOURCE, operation = "READ")
public List<DetectionModelResponse> list() {
return modelService.list().stream()
.map(DetectionModelResponse::from)
.toList();
}
@GetMapping("/{modelId}")
@RequirePermission(resource = RESOURCE, operation = "READ")
public DetectionModelResponse get(@PathVariable String modelId) {
return DetectionModelResponse.from(modelService.get(modelId));
}
@GetMapping("/{modelId}/dependencies")
@RequirePermission(resource = RESOURCE, operation = "READ")
public List<Map<String, String>> dependencies(@PathVariable String modelId) {
return modelService.dependencies(modelId);
}
// 버전 조회
@GetMapping("/{modelId}/versions")
@RequirePermission(resource = RESOURCE, operation = "READ")
public List<DetectionModelVersionResponse> listVersions(@PathVariable String modelId) {
return versionService.listByModel(modelId).stream()
.map(DetectionModelVersionResponse::from)
.toList();
}
@GetMapping("/{modelId}/versions/{versionId}")
@RequirePermission(resource = RESOURCE, operation = "READ")
public DetectionModelVersionResponse getVersion(
@PathVariable String modelId,
@PathVariable Long versionId
) {
return DetectionModelVersionResponse.from(
versionService.get(modelId, versionId));
}
// 버전 라이프사이클
@PostMapping("/{modelId}/versions")
@RequirePermission(resource = RESOURCE, operation = "CREATE")
public DetectionModelVersionResponse createVersion(
@PathVariable String modelId,
@Valid @RequestBody DetectionModelVersionCreateRequest req
) {
return DetectionModelVersionResponse.from(
versionService.create(modelId, req));
}
@PostMapping("/{modelId}/versions/{versionId}/activate")
@RequirePermission(resource = RESOURCE, operation = "UPDATE")
public DetectionModelVersionResponse activate(
@PathVariable String modelId,
@PathVariable Long versionId,
@Valid @RequestBody DetectionModelVersionActivateRequest req
) {
return DetectionModelVersionResponse.from(
versionService.activate(modelId, versionId, req.role()));
}
@PostMapping("/{modelId}/versions/{versionId}/archive")
@RequirePermission(resource = RESOURCE, operation = "UPDATE")
public DetectionModelVersionResponse archive(
@PathVariable String modelId,
@PathVariable Long versionId,
@RequestBody(required = false) DetectionModelVersionArchiveRequest req
) {
return DetectionModelVersionResponse.from(
versionService.archive(modelId, versionId, req));
}
}

파일 보기

@ -0,0 +1,19 @@
package gc.mda.kcg.domain.ai;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface DetectionModelRepository extends JpaRepository<DetectionModel, String> {
List<DetectionModel> findAllByOrderByTierAscModelIdAsc();
@Query(value = """
SELECT depends_on, input_key
FROM kcg.detection_model_dependencies
WHERE model_id = :modelId
ORDER BY depends_on, input_key
""", nativeQuery = true)
List<Object[]> findDependencies(String modelId);
}

파일 보기

@ -0,0 +1,24 @@
package gc.mda.kcg.domain.ai;
import java.time.OffsetDateTime;
public record DetectionModelResponse(
String modelId,
String displayName,
Integer tier,
String category,
String description,
String entryModule,
String entryCallable,
Boolean isEnabled,
OffsetDateTime createdAt,
OffsetDateTime updatedAt
) {
public static DetectionModelResponse from(DetectionModel m) {
return new DetectionModelResponse(
m.getModelId(), m.getDisplayName(), m.getTier(), m.getCategory(),
m.getDescription(), m.getEntryModule(), m.getEntryCallable(),
m.getIsEnabled(), m.getCreatedAt(), m.getUpdatedAt()
);
}
}

파일 보기

@ -0,0 +1,54 @@
package gc.mda.kcg.domain.ai;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Detection Model 카탈로그 조회 서비스 (Phase 3 MVP READ only).
*
* 카탈로그 수정(enable 토글, entry_module 변경 ) 후속 PR 이관.
*/
@Service
@RequiredArgsConstructor
public class DetectionModelService {
private final DetectionModelRepository repository;
@Transactional(readOnly = true)
public List<DetectionModel> list() {
return repository.findAllByOrderByTierAscModelIdAsc();
}
@Transactional(readOnly = true)
public DetectionModel get(String modelId) {
return repository.findById(modelId)
.orElseThrow(() -> new EntityNotFoundException(
"DETECTION_MODEL_NOT_FOUND: " + modelId));
}
/**
* model_id DAG 선행 의존성 목록.
* 반환: [{depends_on, input_key}, ...]
*/
@Transactional(readOnly = true)
public List<Map<String, String>> dependencies(String modelId) {
// 404 방어
if (!repository.existsById(modelId)) {
throw new EntityNotFoundException("DETECTION_MODEL_NOT_FOUND: " + modelId);
}
List<Map<String, String>> out = new ArrayList<>();
for (Object[] row : repository.findDependencies(modelId)) {
out.add(Map.of(
"dependsOn", (String) row[0],
"inputKey", (String) row[1]
));
}
return out;
}
}

파일 보기

@ -0,0 +1,81 @@
package gc.mda.kcg.domain.ai;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* detection_model_versions 엔티티 (V034).
*
* {@link DetectionModel} 파라미터 스냅샷 + 라이프사이클.
* DRAFT (activate) ACTIVE(role=PRIMARY/SHADOW/CHALLENGER) (archive) ARCHIVED.
* TESTING 상태는 후속 PR 에서 활성화.
*
* 불변식 (DB CHECK):
* - status = ACTIVE 이면 role NULL 아니어야 한다.
* - 같은 model_id PRIMARY × ACTIVE 최대 1건 (uk_detection_model_primary partial index).
*/
@Entity
@Table(name = "detection_model_versions", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class DetectionModelVersion {
public static final String STATUS_DRAFT = "DRAFT";
public static final String STATUS_TESTING = "TESTING";
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_ARCHIVED = "ARCHIVED";
public static final String ROLE_PRIMARY = "PRIMARY";
public static final String ROLE_SHADOW = "SHADOW";
public static final String ROLE_CHALLENGER = "CHALLENGER";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "model_id", length = 64, nullable = false)
private String modelId;
@Column(name = "version", length = 32, nullable = false)
private String version;
@Column(name = "status", length = 20, nullable = false)
private String status;
@Column(name = "role", length = 20)
private String role;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "params", columnDefinition = "jsonb", nullable = false)
private JsonNode params;
@Column(name = "notes", columnDefinition = "text")
private String notes;
@Column(name = "traffic_weight")
private Integer trafficWeight;
@Column(name = "parent_version_id")
private Long parentVersionId;
@Column(name = "created_by")
private UUID createdBy;
@Column(name = "created_at", updatable = false)
private OffsetDateTime createdAt;
@Column(name = "activated_at")
private OffsetDateTime activatedAt;
@Column(name = "archived_at")
private OffsetDateTime archivedAt;
}

파일 보기

@ -0,0 +1,10 @@
package gc.mda.kcg.domain.ai;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record DetectionModelVersionActivateRequest(
@NotBlank
@Pattern(regexp = "^(PRIMARY|SHADOW|CHALLENGER)$")
String role
) {}

파일 보기

@ -0,0 +1,7 @@
package gc.mda.kcg.domain.ai;
import jakarta.validation.constraints.Size;
public record DetectionModelVersionArchiveRequest(
@Size(max = 8000) String reason
) {}

파일 보기

@ -0,0 +1,13 @@
package gc.mda.kcg.domain.ai;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record DetectionModelVersionCreateRequest(
@NotBlank @Size(max = 32) String version,
@NotNull JsonNode params,
@Size(max = 8000) String notes,
Long parentVersionId
) {}

파일 보기

@ -0,0 +1,17 @@
package gc.mda.kcg.domain.ai;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface DetectionModelVersionRepository extends JpaRepository<DetectionModelVersion, Long> {
List<DetectionModelVersion> findAllByModelIdOrderByIdDesc(String modelId);
Optional<DetectionModelVersion> findByModelIdAndVersion(String modelId, String version);
/** ACTIVE × PRIMARY 중복 방지 — uk_detection_model_primary partial index 대응. */
Optional<DetectionModelVersion> findByModelIdAndStatusAndRole(
String modelId, String status, String role);
}

파일 보기

@ -0,0 +1,30 @@
package gc.mda.kcg.domain.ai;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.OffsetDateTime;
import java.util.UUID;
public record DetectionModelVersionResponse(
Long id,
String modelId,
String version,
String status,
String role,
JsonNode params,
String notes,
Integer trafficWeight,
Long parentVersionId,
UUID createdBy,
OffsetDateTime createdAt,
OffsetDateTime activatedAt,
OffsetDateTime archivedAt
) {
public static DetectionModelVersionResponse from(DetectionModelVersion v) {
return new DetectionModelVersionResponse(
v.getId(), v.getModelId(), v.getVersion(), v.getStatus(), v.getRole(),
v.getParams(), v.getNotes(), v.getTrafficWeight(), v.getParentVersionId(),
v.getCreatedBy(), v.getCreatedAt(), v.getActivatedAt(), v.getArchivedAt()
);
}
}

파일 보기

@ -0,0 +1,157 @@
package gc.mda.kcg.domain.ai;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.auth.AuthPrincipal;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Detection Model Version 라이프사이클 서비스 (Phase 3 MVP).
*
* 지원 전이:
* DRAFT ACTIVE(PRIMARY|SHADOW|CHALLENGER) via activate()
* DRAFT ARCHIVED via archive()
* ACTIVE(*) ARCHIVED via archive()
*
* 범위 (후속 PR):
* - promote-primary (SHADOW/CHALLENGER PRIMARY, 기존 PRIMARY 자동 archive)
* - enable 토글 (카탈로그 수정)
* - metrics / compare / runs 조회
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DetectionModelVersionService {
private static final String RESOURCE_TYPE = "DETECTION_MODEL_VERSION";
private final DetectionModelRepository modelRepository;
private final DetectionModelVersionRepository versionRepository;
@Transactional(readOnly = true)
public List<DetectionModelVersion> listByModel(String modelId) {
ensureModelExists(modelId);
return versionRepository.findAllByModelIdOrderByIdDesc(modelId);
}
@Transactional(readOnly = true)
public DetectionModelVersion get(String modelId, Long versionId) {
DetectionModelVersion v = versionRepository.findById(versionId)
.orElseThrow(() -> new EntityNotFoundException(
"DETECTION_MODEL_VERSION_NOT_FOUND: " + versionId));
if (!v.getModelId().equals(modelId)) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND,
"VERSION_MODEL_MISMATCH: version " + versionId + " does not belong to " + modelId);
}
return v;
}
@Auditable(action = "DETECTION_MODEL_VERSION_CREATE", resourceType = RESOURCE_TYPE)
@Transactional
public DetectionModelVersion create(String modelId, DetectionModelVersionCreateRequest req) {
ensureModelExists(modelId);
versionRepository.findByModelIdAndVersion(modelId, req.version()).ifPresent(v -> {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"VERSION_ALREADY_EXISTS: " + modelId + "@" + req.version());
});
OffsetDateTime now = OffsetDateTime.now();
DetectionModelVersion draft = DetectionModelVersion.builder()
.modelId(modelId)
.version(req.version())
.status(DetectionModelVersion.STATUS_DRAFT)
.role(null)
.params(req.params())
.notes(req.notes())
.trafficWeight(0)
.parentVersionId(req.parentVersionId())
.createdBy(principalUserId())
.createdAt(now)
.build();
try {
return versionRepository.save(draft);
} catch (DataIntegrityViolationException ex) {
// UNIQUE (model_id, version) race 409 반환
throw new ResponseStatusException(
HttpStatus.CONFLICT, "VERSION_CREATE_CONFLICT", ex);
}
}
@Auditable(action = "DETECTION_MODEL_VERSION_ACTIVATE", resourceType = RESOURCE_TYPE)
@Transactional
public DetectionModelVersion activate(String modelId, Long versionId, String role) {
DetectionModelVersion v = get(modelId, versionId);
if (!DetectionModelVersion.STATUS_DRAFT.equals(v.getStatus())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"INVALID_STATE_TRANSITION: status=" + v.getStatus() + " → ACTIVE(" + role + ") not allowed");
}
if (DetectionModelVersion.ROLE_PRIMARY.equals(role)) {
versionRepository.findByModelIdAndStatusAndRole(
modelId, DetectionModelVersion.STATUS_ACTIVE,
DetectionModelVersion.ROLE_PRIMARY
).ifPresent(existing -> {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"PRIMARY_ALREADY_ACTIVE: version_id=" + existing.getId()
+ ". Archive it first, or use /promote-primary (미구현, 후속 PR)");
});
}
OffsetDateTime now = OffsetDateTime.now();
v.setStatus(DetectionModelVersion.STATUS_ACTIVE);
v.setRole(role);
v.setActivatedAt(now);
try {
return versionRepository.save(v);
} catch (DataIntegrityViolationException ex) {
// uk_detection_model_primary partial index race 409 반환
throw new ResponseStatusException(
HttpStatus.CONFLICT, "ACTIVATE_CONFLICT", ex);
}
}
@Auditable(action = "DETECTION_MODEL_VERSION_ARCHIVE", resourceType = RESOURCE_TYPE)
@Transactional
public DetectionModelVersion archive(String modelId, Long versionId,
DetectionModelVersionArchiveRequest req) {
DetectionModelVersion v = get(modelId, versionId);
if (DetectionModelVersion.STATUS_ARCHIVED.equals(v.getStatus())) {
return v; // idempotent
}
OffsetDateTime now = OffsetDateTime.now();
v.setStatus(DetectionModelVersion.STATUS_ARCHIVED);
v.setRole(null); // ACTIVE role 해제
v.setArchivedAt(now);
if (req != null && req.reason() != null && !req.reason().isBlank()) {
String prefix = v.getNotes() == null ? "" : v.getNotes() + "\n";
v.setNotes(prefix + "[archived " + now + "] " + req.reason());
}
return versionRepository.save(v);
}
// ------------------------------------------------------------------
private void ensureModelExists(String modelId) {
if (!modelRepository.existsById(modelId)) {
throw new EntityNotFoundException("DETECTION_MODEL_NOT_FOUND: " + modelId);
}
}
private java.util.UUID principalUserId() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) {
return p.getUserId();
}
return null;
}
}

파일 보기

@ -4,6 +4,9 @@
## [Unreleased]
### 추가
- **Phase 3 MVP — Detection Model Registry 운영자 API (백엔드)**`gc.mda.kcg.domain.ai` 패키지 신설, `ai-operations:detection-models` 권한 기반 8 엔드포인트: GET `/api/ai/detection-models` (카탈로그 목록, tier 정렬) / GET `/{modelId}` / GET `/{modelId}/dependencies` (DAG 선행) / GET `/{modelId}/versions` / GET `/{modelId}/versions/{versionId}` / POST `/{modelId}/versions` (DRAFT 생성, @Auditable `DETECTION_MODEL_VERSION_CREATE`) / POST `/versions/{versionId}/activate` (DRAFT → ACTIVE(role=PRIMARY·SHADOW·CHALLENGER), @Auditable `DETECTION_MODEL_VERSION_ACTIVATE`) / POST `/versions/{versionId}/archive` (ACTIVE/DRAFT → ARCHIVED, @Auditable `DETECTION_MODEL_VERSION_ARCHIVE`, idempotent). `DetectionModel`·`DetectionModelVersion` 엔티티(JSONB params 는 Hibernate `@JdbcTypeCode(SqlTypes.JSON)` 기반 `JsonNode` 매핑), `DetectionModelRepository`·`DetectionModelVersionRepository`, `DetectionModelService`(READ 전용)·`DetectionModelVersionService`(create/activate/archive, 전이 화이트리스트 + `uk_detection_model_primary` 중복 방지 409 응답), Request/Response record 4종. 로컬 `mvn spring-boot:run` 기동 성공 + admin 계정 쿠키 인증으로 8 엔드포인트 전수 smoke test 통과(5 모델 조회 / DRAFT 생성 → activate SHADOW → archive 전이 사이클 검증). **범위 밖 후속 PR**: promote-primary(SHADOW/CHALLENGER→PRIMARY) / enable 토글 / metrics · compare · runs 조회
### 수정
- **`stats_aggregator.aggregate_hourly` hour 경계 silent 누락 버그** — prediction 5분 interval 이지만 한 사이클 평균 소요가 13분이라 사이클이 hour 경계를 넘나드는 경우(예: 12:55 시작 → 13:08 완료)가 흔함. 이 사이클 내 생성된 이벤트(occurred_at=12:57)가 stats_aggregate_hourly 호출 시점(now_kst=13:08)을 기준으로 **현재 hour=13:00 만** UPSERT 되어 12:00 hour 는 이전 사이클 snapshot 으로 stale 유지되는 silent drop. 실제 운영에서 `2026-04-20 12:50 CRITICAL GEAR_IDENTITY_COLLISION` 이벤트가 `prediction_stats_hourly.by_category` 에서 누락된 것을 Phase 1-2 의 snapshot `C1 drift` 섹션이 포착. 수정: `aggregate_hourly()` 가 호출될 때마다 **현재 + 이전 hour 를 모두 UPSERT** (UPSERT idempotent). `_aggregate_one_hour()` 로 단일 hour 집계를 분리하고 `aggregate_hourly()` 가 previous→current 순서로 호출. target_hour 지정 케이스에서도 ±1h 재집계. 반환값은 현재 hour 만 (하위 호환). 3 유닛테스트 (경계 호출 / 반환값 / 일 경계) 통과, 운영 수동 재집계로 2026-04-20 12:00 slot 에 GEAR_IDENTITY_COLLISION: 1 복구 + `C1 drift` 0 확인