diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModel.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModel.java new file mode 100644 index 0000000..36a4e5c --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModel.java @@ -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; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelController.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelController.java new file mode 100644 index 0000000..8a7bfef --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelController.java @@ -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 + * + *

엔드포인트 (MVP 8종)

+ * + * + *

범위 밖 (후속 PR)

+ * - promote-primary (SHADOW/CHALLENGER → PRIMARY), enable 토글, + * metrics / compare / runs 조회. + * + *

권한

+ * 모든 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 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> dependencies(@PathVariable String modelId) { + return modelService.dependencies(modelId); + } + + // ── 버전 조회 ──────────────────────────────────────────────────── + @GetMapping("/{modelId}/versions") + @RequirePermission(resource = RESOURCE, operation = "READ") + public List 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)); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelRepository.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelRepository.java new file mode 100644 index 0000000..58e70fd --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelRepository.java @@ -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 { + + List 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 findDependencies(String modelId); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelResponse.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelResponse.java new file mode 100644 index 0000000..6bedb91 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelResponse.java @@ -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() + ); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelService.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelService.java new file mode 100644 index 0000000..69f1ebb --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelService.java @@ -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 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> dependencies(String modelId) { + // 404 방어 + if (!repository.existsById(modelId)) { + throw new EntityNotFoundException("DETECTION_MODEL_NOT_FOUND: " + modelId); + } + List> out = new ArrayList<>(); + for (Object[] row : repository.findDependencies(modelId)) { + out.add(Map.of( + "dependsOn", (String) row[0], + "inputKey", (String) row[1] + )); + } + return out; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersion.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersion.java new file mode 100644 index 0000000..9de0a1b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersion.java @@ -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; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionActivateRequest.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionActivateRequest.java new file mode 100644 index 0000000..c921420 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionActivateRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionArchiveRequest.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionArchiveRequest.java new file mode 100644 index 0000000..42fcf11 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionArchiveRequest.java @@ -0,0 +1,7 @@ +package gc.mda.kcg.domain.ai; + +import jakarta.validation.constraints.Size; + +public record DetectionModelVersionArchiveRequest( + @Size(max = 8000) String reason +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionCreateRequest.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionCreateRequest.java new file mode 100644 index 0000000..9a8fdfc --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionCreateRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionRepository.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionRepository.java new file mode 100644 index 0000000..072aef1 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionRepository.java @@ -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 { + + List findAllByModelIdOrderByIdDesc(String modelId); + + Optional findByModelIdAndVersion(String modelId, String version); + + /** ACTIVE × PRIMARY 중복 방지 — uk_detection_model_primary partial index 대응. */ + Optional findByModelIdAndStatusAndRole( + String modelId, String status, String role); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionResponse.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionResponse.java new file mode 100644 index 0000000..0ae6d55 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionResponse.java @@ -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() + ); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionService.java b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionService.java new file mode 100644 index 0000000..210b2f0 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/ai/DetectionModelVersionService.java @@ -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 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; + } +} diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 62b8396..7963ade 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -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 확인