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:
부모
0ee2651dd7
커밋
2e18960bf2
@ -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 확인
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user