diff --git a/src/main/kotlin/com/depromeet/piki/admin/audit/AdminAuditAction.kt b/src/main/kotlin/com/depromeet/piki/admin/audit/AdminAuditAction.kt index 9fed5fd72..b7e883443 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/audit/AdminAuditAction.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/audit/AdminAuditAction.kt @@ -4,7 +4,7 @@ package com.depromeet.piki.admin.audit enum class AdminAuditAction { TEMPLATE_UPDATE, - // 추출 라우팅 정책(#9 디스패처) — 누가 어느 도메인을 어떤 정책으로 추가/삭제했는지. + // 도메인 접근 정책(#9 디스패처) — 누가 어느 도메인을 어떤 정책으로 추가/삭제했는지. EXTRACTION_POLICY_UPDATE, // 출처 몰 표시명(#766) — 누가 어느 도메인의 표시명을 추가/교체/삭제했는지. diff --git a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelController.kt b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelController.kt index 565e4805f..55af7e6b2 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionModelController.kt @@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.RequestParam // // 목록 하나로 끝난다(상세 화면 없음). 행이 경로 수(2개)로 고정이고 각 행이 값 하나뿐이라, 상세로 들어갈 것이 // 없다. 파괴적 액션인 해제도 목록에 두되 그 결과는 "extractor 기본 모델로 되돌아감"이라 되돌릴 수 있다 -// (라우팅 정책의 삭제가 곧 차단 해제였던 것과 달리 위험이 낮다). +// (접근 정책의 삭제가 곧 차단 해제인 것과 달리 위험이 낮다). // // Spring 의 Model 파라미터를 view 로 받는 이유: 이 화면의 도메인 용어가 "model"(LLM 모델)이라 폼 필드명과 // 이름이 겹친다. 폼 필드 쪽이 사용자 대면이므로 그 이름을 지키고 프레임워크 쪽을 비켰다. diff --git a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyController.kt b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyController.kt index fcc5c648b..9a6b27e8d 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyController.kt @@ -3,7 +3,7 @@ package com.depromeet.piki.admin.extraction import com.depromeet.piki.admin.access.AdminSession import com.depromeet.piki.admin.config.ClientIp import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled -import com.depromeet.piki.product.routing.ExtractionRoute +import com.depromeet.piki.product.routing.DomainAccess import io.swagger.v3.oas.annotations.Hidden import jakarta.servlet.http.HttpServletRequest import org.springframework.stereotype.Controller @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam -// 추출 라우팅 정책 관리 화면(#9 디스패처). 갈래별 3열 보드(목록·필터)와 상세(수정·삭제) 두 화면의 SSR — +// 도메인 접근 정책 관리 화면(#9 디스패처). 갈래별 보드(목록·필터)와 상세(수정·삭제) 두 화면의 SSR — // AdminTemplateController 의 목록 → 편집 진입과 같은 토대 (게이트는 슬랙-세션 #526, // actor 폴백은 AdminSession.actorName(request)). // 파괴적 액션(삭제)은 보드에 두지 않는다. 상세로 들어와 도메인·정책·사유를 확인한 뒤 실행한다 — @@ -29,31 +29,31 @@ class AdminExtractionPolicyController( // guide: 상세 화면이 "정책 종류 설명" 링크로 보낼 때 그 설명을 펼친 채 연다. @GetMapping fun board( - @RequestParam(required = false) route: String?, + @RequestParam(required = false) access: String?, @RequestParam(required = false) guide: String?, model: Model, - ): String = boardView(route, model, guideOpen = !guide.isNullOrBlank()) + ): String = boardView(access, model, guideOpen = !guide.isNullOrBlank()) - // 추가 폼은 헤드리스 허가를 켜지 않는다(save 의 기본값 = 거부). 허가는 "메일로 받아 원장에 남기는" 조작이라 + // 추가 폼은 허락 근거를 받지 않는다(save 의 기본값 = null). 허락은 "메일로 받아 원장에 남기는" 조작이라 // 근거 입력·현재 상태 확인이 있는 상세 화면의 몫이다 — 목록의 한 줄짜리 폼에서 지나가듯 켤 일이 아니다. - // 그래서 여기서 HEADLESS_FIRST 를 고르면 default-deny 가드에 걸려 "상세에서 허가를 켜라"는 안내가 뜬다. + // 그래서 여기서 ALLOWED 를 고르면 근거 필수 가드에 걸려 "상세에서 근거를 남겨라"는 안내가 뜬다. @PostMapping fun add( @RequestParam domain: String, - @RequestParam route: ExtractionRoute, + @RequestParam access: DomainAccess, @RequestParam(required = false) reason: String?, request: HttpServletRequest, model: Model, ): String = try { - adminExtractionPolicyService.save(domain, route, reason, actor = AdminSession.actorName(request), clientIp = ClientIp.of(request)) + adminExtractionPolicyService.save(domain, access, reason, actor = AdminSession.actorName(request), clientIp = ClientIp.of(request)) "redirect:/admin/extraction-policies?updated" } catch (e: IllegalArgumentException) { - // 정규화·길이 검증 실패 — 제출값(route 선택 포함)을 유지한 채 보드 화면에 에러를 표시한다(400 JSON 대신 SSR). + // 정규화·길이 검증 실패 — 제출값(access 선택 포함)을 유지한 채 보드 화면에 에러를 표시한다(400 JSON 대신 SSR). model.addAttribute("error", e.message) model.addAttribute("draftDomain", domain) model.addAttribute("draftReason", reason) - boardView(filter = null, model = model, selectedRoute = route.name) + boardView(filter = null, model = model, selectedAccess = access.name) } @GetMapping("/{domain}") @@ -68,18 +68,14 @@ class AdminExtractionPolicyController( "redirect:/admin/extraction-policies?missing" } - // 상세의 정책·사유·헤드리스 허가 수정. save 가 upsert 라 추가 폼과 같은 경로를 탄다 (도메인은 PK 라 상세에서 - // 바꾸지 않는다). 정책과 사유를 한 폼으로 받는 이유: 정책이 바뀌는 순간이 곧 근거가 새로 필요한 순간이라, 둘이 - // 따로 저장되면 "403 봇 차단" 사유가 SUPPORTED 행에 남는 식으로 근거가 정책과 어긋난다. 허가도 같은 폼에 둔다 — - // 허가와 정책이 따로 저장되면 "허가 없는 HEADLESS_FIRST" 같은 어긋난 중간 상태를 지나야 한다. - // - // headlessAllowed 는 체크박스라 끄면 파라미터 자체가 오지 않는다 — 없음을 곧 거부(false)로 읽는다(default-deny). + // 상세의 정책·사유·허락 근거 수정. save 가 upsert 라 추가 폼과 같은 경로를 탄다 (도메인은 PK 라 상세에서 + // 바꾸지 않는다). 셋을 한 폼으로 받는 이유: 정책이 바뀌는 순간이 곧 근거가 새로 필요한 순간이라, 따로 + // 저장되면 "403 봇 차단" 사유가 ALLOWED 행에 남는 식으로 근거가 정책과 어긋난다. @PostMapping("/{domain}") fun update( @PathVariable domain: String, - @RequestParam route: ExtractionRoute, + @RequestParam access: DomainAccess, @RequestParam(required = false) reason: String?, - @RequestParam(required = false) headlessAllowed: Boolean?, @RequestParam(required = false) permissionRef: String?, request: HttpServletRequest, model: Model, @@ -87,11 +83,10 @@ class AdminExtractionPolicyController( try { adminExtractionPolicyService.save( domain, - route, + access, reason, actor = AdminSession.actorName(request), clientIp = ClientIp.of(request), - headlessAllowed = headlessAllowed ?: false, permissionRef = permissionRef, ) "redirect:/admin/extraction-policies?updated" @@ -101,8 +96,7 @@ class AdminExtractionPolicyController( detailView( adminExtractionPolicyService.find(domain), model, - selectedRoute = route.name, - draftHeadlessAllowed = headlessAllowed ?: false, + selectedAccess = access.name, draftPermissionRef = permissionRef, ) } @@ -123,44 +117,43 @@ class AdminExtractionPolicyController( // 보드 화면의 모델 채우기 단일 지점 — 정상 목록과 두 에러 재표시 경로가 공유한다 // (한쪽만 갱신돼 에러 화면에서 모델이 비는 함정 방지). - // selectedRoute 기본값이 SUPPORTED 인 이유: 추가 폼의 기본 선택이 곧 오조작 시 저장되는 값이라, - // 라우팅을 바꾸지 않는 값(기록용)을 기본에 둔다. UNSUPPORTED 가 기본이면 실수 한 번이 등록 차단이 된다. + // selectedAccess 기본값이 BLOCKED 인 이유: 값이 둘뿐이라 "아무것도 안 바꾸는 값"이 없다. ALLOWED 를 기본에 + // 두면 도메인만 치고 엔터한 오조작이 허락으로 저장될 수 있으므로, 되돌리기 쉬운 쪽(행 삭제 = 즉시 등록 재개)을 + // 기본에 둔다. ALLOWED 는 근거 필수 가드가 한 번 더 막아 어느 쪽으로도 지나가듯 켜지지 않는다. // guideOpen 은 모델로 넘긴다. 템플릿의 th:attr 안에서는 요청 파라미터(param) 접근이 막혀 있다. private fun boardView( filter: String?, model: Model, - selectedRoute: String? = null, + selectedAccess: String? = null, guideOpen: Boolean = false, ): String { - model.addAttribute("board", adminExtractionPolicyService.board(parseRoute(filter))) - model.addAttribute("routes", ExtractionRoute.entries) - model.addAttribute("selectedRoute", selectedRoute ?: ExtractionRoute.SUPPORTED.name) + model.addAttribute("board", adminExtractionPolicyService.board(parseAccess(filter))) + model.addAttribute("accesses", DomainAccess.entries) + model.addAttribute("selectedAccess", selectedAccess ?: DomainAccess.BLOCKED.name) model.addAttribute("guideOpen", guideOpen) return "admin/extraction-policies" } - // knownRoute: 저장된 route 문자열이 이 바이너리의 enum 에 있는가. 없으면(신버전이 만든 값 → 구버전 롤백) + // knownAccess: 저장된 access 문자열이 이 바이너리의 enum 에 있는가. 없으면(신버전이 만든 값 → 구버전 롤백) // select 에 고를 항목이 없으므로 화면이 경고를 띄우고 운영자가 알려진 정책으로 교체하거나 삭제하게 한다. // - // 허가 입력값은 selectedRoute 와 같은 방식으로 컨트롤러가 확정해 넘긴다 — 에러 재표시에서는 방금 제출한 - // 값을, 평소에는 저장된 값을 보여준다(체크 해제는 draft 가 false 라 그대로 유지된다). + // 허락 근거도 selectedAccess 와 같은 방식으로 컨트롤러가 확정해 넘긴다 — 에러 재표시에서는 방금 제출한 + // 값을, 평소에는 저장된 값을 보여준다. private fun detailView( policy: ExtractionPolicyView, model: Model, - selectedRoute: String? = null, - draftHeadlessAllowed: Boolean? = null, + selectedAccess: String? = null, draftPermissionRef: String? = null, ): String { model.addAttribute("policy", policy) - model.addAttribute("routes", ExtractionRoute.entries) - model.addAttribute("selectedRoute", selectedRoute ?: policy.route) - model.addAttribute("knownRoute", ExtractionRoute.entries.any { it.name == policy.route }) - model.addAttribute("headlessAllowedChecked", draftHeadlessAllowed ?: policy.headlessAllowed) + model.addAttribute("accesses", DomainAccess.entries) + model.addAttribute("selectedAccess", selectedAccess ?: policy.access) + model.addAttribute("knownAccess", DomainAccess.entries.any { it.name == policy.access }) model.addAttribute("permissionRefValue", draftPermissionRef ?: policy.permissionRef) return "admin/extraction-policy-detail" } - // ?route= 는 tolerant 하게 읽는다 — 모르는 값이면 400 대신 "필터 없음(전체)"으로 떨군다. 옛 링크를 눌렀거나 + // ?access= 는 tolerant 하게 읽는다 — 모르는 값이면 400 대신 "필터 없음(전체)"으로 떨군다. 옛 링크를 눌렀거나 // URL 을 손으로 고쳤을 때 화면이 깨지는 것보다, 전체 보드를 보여주는 편이 백오피스에서 덜 위험하다. - private fun parseRoute(raw: String?): ExtractionRoute? = ExtractionRoute.entries.find { it.name == raw } + private fun parseAccess(raw: String?): DomainAccess? = DomainAccess.entries.find { it.name == raw } } diff --git a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyService.kt b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyService.kt index 16630444c..0bd5ab46a 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyService.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyService.kt @@ -3,41 +3,41 @@ package com.depromeet.piki.admin.extraction import com.depromeet.piki.admin.audit.AdminAuditAction import com.depromeet.piki.admin.audit.AdminAuditService import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled -import com.depromeet.piki.product.routing.DbExtractionRoutingPolicy -import com.depromeet.piki.product.routing.ExtractionPlatformPolicyEntity -import com.depromeet.piki.product.routing.ExtractionPlatformPolicyJpaRepository -import com.depromeet.piki.product.routing.ExtractionRoute +import com.depromeet.piki.product.routing.DbDomainAccessPolicy +import com.depromeet.piki.product.routing.DomainAccess +import com.depromeet.piki.product.routing.DomainAccessPolicyEntity +import com.depromeet.piki.product.routing.DomainAccessPolicyJpaRepository import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime -import java.util.Optional -// 백오피스 추출 라우팅 정책 관리(#9 디스패처). 배포 없이 도메인별 지원 표기(SUPPORTED)·차단(UNSUPPORTED)· -// 브라우저 직행(HEADLESS_FIRST)을 저장(upsert)·삭제한다. +// 백오피스 도메인 접근 정책 관리. 배포 없이 도메인별 허락(ALLOWED)·차단(BLOCKED)을 저장(upsert)·삭제한다. // 수정 시: 도메인 정규화·검증 → 저장/삭제 → 캐시 reload(커밋 후) → 감사 기록 (AdminTemplateService 패턴). +// +// 값이 둘뿐인 것은 축이 하나이기 때문이다 — "어떻게 가져오나"는 추출 체인이 관측으로 스스로 정하고, 여기서는 +// "요청을 보낼 것인가"와 "어디까지 허락됐나"만 답한다. 행이 없으면 기본 흐름이라, 정책은 예외만 담는다. @Service @ConditionalOnAdminEnabled class AdminExtractionPolicyService( - private val policyRepository: ExtractionPlatformPolicyJpaRepository, - private val routingPolicy: DbExtractionRoutingPolicy, + private val policyRepository: DomainAccessPolicyJpaRepository, + private val accessPolicy: DbDomainAccessPolicy, private val auditService: AdminAuditService, ) { - // 화면용 갈래별 보드. filter 를 주면 그 열만 남긴다(열 헤더 링크 = ?route=X). + // 화면용 갈래별 보드. filter 를 주면 그 열만 남긴다(열 헤더 링크 = ?access=X). // - // unknown(이 바이너리가 모르는 route 값)은 전체 보기에서만 싣는다. 열에서 아주 빼면 백오피스에서 보이지도 - // 지워지지도 않는 유령 행이 되지만, 필터를 건 화면에까지 끼워 넣으면 "그 갈래만 본다"는 약속이 깨진다 - // (모르는 값은 route 로 지목할 수 없어 어떤 필터에도 속하지 않는다). 전체 보기가 그 값을 만나는 자리다. + // unknown(이 바이너리가 모르는 access 값)은 전체 보기에서만 싣는다. 열에서 아주 빼면 백오피스에서 보이지도 + // 지워지지도 않는 유령 행이 되지만, 필터를 건 화면에까지 끼워 넣으면 "그 갈래만 본다"는 약속이 깨진다. @Transactional(readOnly = true) - fun board(filter: ExtractionRoute?): ExtractionPolicyBoard { - val byRoute = policyRepository.findAll().map { it.toView() }.groupBy { it.route } - val known = ExtractionRoute.entries.map { it.name }.toSet() - val shown: List = filter?.let { listOf(it) } ?: ExtractionRoute.entries - val unknown = byRoute.filterKeys { it !in known }.values.flatten().sortedBy { it.domain } + fun board(filter: DomainAccess?): ExtractionPolicyBoard { + val byAccess = policyRepository.findAll().map { it.toView() }.groupBy { it.access } + val known = DomainAccess.entries.map { it.name }.toSet() + val shown: List = filter?.let { listOf(it) } ?: DomainAccess.entries + val unknown = byAccess.filterKeys { it !in known }.values.flatten().sortedBy { it.domain } return ExtractionPolicyBoard( - columns = shown.map { ExtractionPolicyColumn(route = it, policies = byRoute[it.name].orEmpty().sortedBy { p -> p.domain }) }, + columns = shown.map { ExtractionPolicyColumn(access = it, policies = byAccess[it.name].orEmpty().sortedBy { p -> p.domain }) }, unknown = filter?.let { emptyList() } ?: unknown, filter = filter, ) @@ -50,51 +50,40 @@ class AdminExtractionPolicyService( .orElseThrow { IllegalArgumentException("정책이 없는 도메인입니다: $rawDomain") } .toView() - private fun ExtractionPlatformPolicyEntity.toView() = + private fun DomainAccessPolicyEntity.toView() = ExtractionPolicyView( domain = domain, - route = route, + access = access, reason = reason, - updatedAt = updatedAt, - headlessAllowed = headlessAllowed, permissionRef = permissionRef, - permissionGrantedAt = permissionGrantedAt, + updatedAt = updatedAt, ) // upsert — 같은 도메인이 있으면 정책을 교체한다. "삭제 후 재추가"로 수정하게 하면 그 사이 정책 공백 창이 생기고 - // (삭제 시점에 캐시가 즉시 갱신돼 기본 체인으로 열림), 중복 검사 후 저장의 check-then-act 레이스도 남는다 — + // (삭제 시점에 캐시가 즉시 갱신돼 기본 흐름으로 열림), 중복 검사 후 저장의 check-then-act 레이스도 남는다 — // 교체 의미로 두면 둘 다 사라진다. - // - // headlessAllowed·permissionRef 는 헤드리스 허가 원장이다. 정책·사유와 같은 폼으로 받는 이유도 같다 — - // 허가와 정책이 따로 저장되면 "허가 없는 HEADLESS_FIRST" 같은 어긋난 중간 상태를 지나야 하고, 그 창에서 - // 브라우저 직행이 열린다. 한 번의 저장으로 허가와 정책이 함께 확정된다. @Transactional fun save( rawDomain: String, - route: ExtractionRoute, + access: DomainAccess, reason: String?, actor: String, clientIp: String?, - headlessAllowed: Boolean = false, permissionRef: String? = null, ) { val domain = normalize(rawDomain) val trimmedReason = reason?.trim()?.ifBlank { null } val trimmedPermissionRef = permissionRef?.trim()?.ifBlank { null } validateLengths(domain, trimmedReason, trimmedPermissionRef) - validatePermission(route, headlessAllowed, trimmedPermissionRef) - // 이전 행을 읽어 두는 이유 둘 — 교체/추가 감사 문구와, 이미 켜져 있던 허가의 시각 보존(grantedAt). - val previous = policyRepository.findById(domain) - val replaced = previous.isPresent + validatePermission(access, trimmedPermissionRef) + val replaced = policyRepository.existsById(domain) try { policyRepository.saveAndFlush( - ExtractionPlatformPolicyEntity( + DomainAccessPolicyEntity( domain = domain, - route = route.name, + access = access.name, reason = trimmedReason, - headlessAllowed = headlessAllowed, permissionRef = trimmedPermissionRef, - permissionGrantedAt = grantedAt(headlessAllowed, previous), ), ) } catch (e: DataIntegrityViolationException) { @@ -105,7 +94,7 @@ class AdminExtractionPolicyService( auditService.record( actor, AdminAuditAction.EXTRACTION_POLICY_UPDATE, - "$domain → $route${if (headlessAllowed) " (헤드리스 허가)" else ""} ${if (replaced) "교체" else "추가"}", + "$domain → $access ${if (replaced) "교체" else "추가"}", clientIp, ) reloadAfterCommit() @@ -120,7 +109,7 @@ class AdminExtractionPolicyService( val domain = normalize(rawDomain) val entity = policyRepository.findById(domain).orElseThrow { IllegalArgumentException("정책이 없는 도메인입니다: $domain") } policyRepository.delete(entity) - auditService.record(actor, AdminAuditAction.EXTRACTION_POLICY_UPDATE, "$domain → ${entity.route} 삭제", clientIp) + auditService.record(actor, AdminAuditAction.EXTRACTION_POLICY_UPDATE, "$domain → ${entity.access} 삭제", clientIp) reloadAfterCommit() } @@ -145,43 +134,29 @@ class AdminExtractionPolicyService( ) { require(domain.length <= COLUMN_MAX_LENGTH) { "도메인은 ${COLUMN_MAX_LENGTH}자를 초과할 수 없습니다." } require((reason?.length ?: 0) <= COLUMN_MAX_LENGTH) { "사유는 ${COLUMN_MAX_LENGTH}자를 초과할 수 없습니다." } - require((permissionRef?.length ?: 0) <= COLUMN_MAX_LENGTH) { "허가 근거는 ${COLUMN_MAX_LENGTH}자를 초과할 수 없습니다." } + require((permissionRef?.length ?: 0) <= COLUMN_MAX_LENGTH) { "허락 근거는 ${COLUMN_MAX_LENGTH}자를 초과할 수 없습니다." } } - // 헤드리스 허가의 계약 검증(입력 경계). 백오피스 폼으로 멀쩡한 운영자가 정상 조작으로 닿는 자리라 커스텀 - // 도메인 예외가 아니라 이 화면의 기존 방식(IllegalArgumentException → 화면 에러 표시)을 따른다. - // 같은 default-deny 불변식이 엔티티 생성자에도 있다 — 여기는 사용자 문구로 안내하는 층이고, 저쪽은 어떤 - // 경로로 만들든 허가 없는 직행 행이 생기지 않게 하는 최후의 보루다(다층 방어). + // 허락의 계약 검증(입력 경계). 백오피스 폼으로 멀쩡한 운영자가 정상 조작으로 닿는 자리라 커스텀 도메인 + // 예외가 아니라 이 화면의 기존 방식(IllegalArgumentException → 화면 에러 표시)을 따른다. + // 같은 불변식이 엔티티 생성자에도 있다 — 여기는 사용자 문구로 안내하는 층이고, 저쪽은 어떤 경로로 만들든 + // 근거 없는 허락 행이 생기지 않게 하는 최후의 보루다(다층 방어). private fun validatePermission( - route: ExtractionRoute, - headlessAllowed: Boolean, + access: DomainAccess, permissionRef: String?, ) { - // 허가는 사람이 플랫폼에서 받아 오는 것이라, 켜는 순간 근거를 함께 남기게 강제한다. 근거 없는 허가가 - // 쌓이면 원장이 "왜 열려 있나"에 답하지 못해 원장 구실을 못 한다. - require(!headlessAllowed || !permissionRef.isNullOrBlank()) { - "헤드리스 허가를 켜려면 허가 근거를 함께 남겨 주세요 (예: 메일 스레드·담당자)." - } - require(headlessAllowed || route != ExtractionRoute.HEADLESS_FIRST) { - "허가받지 않은 도메인은 HEADLESS_FIRST 로 지정할 수 없습니다. 상세 화면에서 헤드리스 허가를 함께 켜 주세요." + // 허락은 사람이 플랫폼에서 받아 오는 것이라, 켜는 순간 근거를 함께 남기게 강제한다. 근거 없는 허락이 + // 쌓이면 원장이 "왜 열려 있나"에 답하지 못해 원장 구실을 못 한다 — 이 값은 적극적인 수단을 여는 값이라 특히 그렇다. + require(access != DomainAccess.ALLOWED || !permissionRef.isNullOrBlank()) { + "허락(ALLOWED)을 지정하려면 근거를 함께 남겨 주세요 (예: 메일 스레드·담당자)." } } - // 허가를 켠 시각. 이미 켜져 있던 행은 그 시각을 보존한다 — 사유 수정 같은 다른 편집이 허가 시각을 밀어내면 - // "언제부터 열려 있었나"가 사라진다. 새로 켜는 순간에만 지금으로 찍고, 끄면 비운다. - private fun grantedAt( - headlessAllowed: Boolean, - previous: Optional, - ): LocalDateTime? { - if (!headlessAllowed) return null - return previous.filter { it.headlessAllowed }.map { it.permissionGrantedAt }.orElseGet { LocalDateTime.now() } - } - // 캐시 갱신은 커밋 후로 미룬다 — 커밋 전 reload 면 이후 단계 롤백 시 캐시만 새 정책으로 남아 DB 와 어긋난다. private fun reloadAfterCommit() { TransactionSynchronizationManager.registerSynchronization( object : TransactionSynchronization { - override fun afterCommit() = routingPolicy.reload() + override fun afterCommit() = accessPolicy.reload() }, ) } @@ -193,18 +168,16 @@ class AdminExtractionPolicyService( data class ExtractionPolicyView( val domain: String, - val route: String, + val access: String, val reason: String?, - val updatedAt: LocalDateTime, - // 헤드리스 허가 원장 — 화면이 "이 도메인을 브라우저로 열어도 되는가"와 그 근거를 함께 보여준다. - val headlessAllowed: Boolean, + // 허락 근거 — 화면이 "왜 이 도메인이 열려 있나"를 답할 수 있게 한다. ALLOWED 행에는 반드시 있다. val permissionRef: String?, - val permissionGrantedAt: LocalDateTime?, + val updatedAt: LocalDateTime, ) -// 한 갈래(route)와 거기 속한 정책들. 열 헤더가 개수를 보여주므로 빈 열도 열 자체는 렌더된다. +// 한 갈래(access)와 거기 속한 정책들. 열 헤더가 개수를 보여주므로 빈 열도 열 자체는 렌더된다. data class ExtractionPolicyColumn( - val route: ExtractionRoute, + val access: DomainAccess, val policies: List, ) @@ -212,5 +185,5 @@ data class ExtractionPolicyColumn( data class ExtractionPolicyBoard( val columns: List, val unknown: List, - val filter: ExtractionRoute?, + val filter: DomainAccess?, ) diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt index 421a2fc43..c552d7467 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaProperties.kt @@ -19,11 +19,11 @@ import java.time.Duration // (그 item 은 위시에 담길 때 이미 한 번 깎였다). // // 기준은 "LLM 을 타는가" 가 아니라 **"외부에 돈이 나가는가"** 다. 등록 1건은 파싱이 파서로 풀려 LLM 을 안 타도 -// fetch 대역·residential proxy 요청(HEADLESS_FIRST 사이트)·헤드리스 렌더러 시간·이미지 저장·DB 행 영구 증가를 +// fetch 대역·추출 모듈의 시간·이미지 저장·DB 행 영구 증가를 // 소모한다. 그래서 경로별 차등 없이 균일하게 1 을 센다 — 애초에 등록 시점엔 파서로 풀릴지 LLM 으로 갈지 알 수 없고, // 사이트가 마크업을 바꾸면 어제 파서로 풀리던 링크가 오늘 LLM 을 탄다. // -// 실제 소비량에 맞춘 정밀 차감(LLM 을 탔는지·프록시 IP 를 몇 번 돌렸는지를 파싱 후에 세는 사후 정산)은 후속 과제다. +// 실제 소비량에 맞춘 정밀 차감(어느 경로를 몇 번 탔는지를 파싱 후에 세는 사후 정산)은 후속 과제다. // // 판정은 잔액 방식이다 — 남은 몫이 있으면 요청 크기와 무관하게 통과시키고, 넘긴 만큼은 다음 요청이 갚는다. // 그래서 창당 실제 소비는 한도가 아니라 (한도 + 1회 최대 요청량)까지 갈 수 있다(RedisItemQuotaStore 주석 참고). @@ -51,9 +51,9 @@ data class ItemQuotaProperties( // // 3000 은 계정 한도(30)를 꽉 채운 사용자 100명분이다. 파싱 워커가 maxPoolSize 8 · queueCapacity 0 이라 // 동시 처리는 최대 8건이고, 건당 소요를 파서 1~2초로 잡으면 이론 처리량이 시간당 14,000건을 넘는다 - // (실측이 아니라 timeout 상한에서 잡은 추정). 헤드리스·LLM 이 섞이면 건당 5~20초까지 늘어 이론 처리량이 + // (실측이 아니라 timeout 상한에서 잡은 추정). 무거운 경로(LLM 등)가 섞이면 건당 5~20초까지 늘어 이론 처리량이 // 시간당 1,400건까지 떨어지는데, 그 구간에서는 이 상한이 워커보다 느슨해 상한에 닿기 전에 PENDING 이 쌓인다. - // 거부가 아니라 대기라 장애는 아니지만, 화이트리스트 전환으로 파서 위주가 되는 것을 전제로 잡은 값이다. + // 거부가 아니라 대기라 장애는 아니지만, 대부분의 등록이 파서로 풀리는 것을 전제로 잡은 값이다. val capacityLimit: Int = 3_000, // 상한의 몇 %에서 경고를 남길지. **상한에 닿으면 이미 늦으므로 이 지점이 실질 방어선이다** — 여기서 // 손 쓸 시간을 벌기 위한 값이지, 도달 자체가 정상이라는 뜻이 아니다. diff --git a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettings.kt b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettings.kt index e510feec5..80d297314 100644 --- a/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettings.kt +++ b/src/main/kotlin/com/depromeet/piki/common/ratelimit/ItemQuotaSettings.kt @@ -5,7 +5,7 @@ import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component -// 지금 적용 중인 한도 값의 단일 조회 지점. 인터페이스/구현 분리는 ExtractionRoutingPolicy·ExtractionModelSettings 와 +// 지금 적용 중인 한도 값의 단일 조회 지점. 인터페이스/구현 분리는 DomainAccessPolicy·ExtractionModelSettings 와 // 같은 구조 — 소비자(한도 게이트)의 단위 테스트가 DB 없이 값을 대체할 수 있게 한다. interface ItemQuotaSettings { fun current(): ItemQuotaSnapshot diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt index 554655262..73d12f7aa 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemParsingMetrics.kt @@ -36,7 +36,7 @@ object ItemParsingMetrics { // 우리 구성으로 그 페이지를 못 읽음. 늘면 그 도메인의 허가 후보를 본다. const val REASON_UNREADABLE = "unreadable" - // 대상이 우리를 막음. 늘면 UNSUPPORTED 정책 후보를 본다. + // 대상이 우리를 막음. 늘면 BLOCKED 정책 후보를 본다. const val REASON_BLOCKED = "blocked" // 추출은 됐는데 값을 믿을 수 없음. 늘면 모델·프롬프트·검증 규칙을 본다. diff --git a/src/main/kotlin/com/depromeet/piki/product/domain/CanonicalLink.kt b/src/main/kotlin/com/depromeet/piki/product/domain/CanonicalLink.kt index a592e1555..74fb7b86e 100644 --- a/src/main/kotlin/com/depromeet/piki/product/domain/CanonicalLink.kt +++ b/src/main/kotlin/com/depromeet/piki/product/domain/CanonicalLink.kt @@ -59,7 +59,7 @@ class CanonicalLink private constructor( // fragment 는 여기서 별도 처리하지 않는다 — 재조립이 rawPath·rawQuery 만 쓰므로 구조적으로 탈락한다. // fragment 는 HTTP 요청에 실리지 않아 서버 렌더 몰에선 상품을 바꿀 수 없고(원리), prod 582건 중 의미 있는 - // fragment 는 0건(실측). 예외는 해시 라우팅 SPA 를 헤드리스로 렌더하는 경우뿐 — 그런 몰이 나타나면 + // fragment 는 0건(실측). 예외는 해시로 화면을 가르는 SPA 를 실제로 렌더해 읽는 경우뿐 — 그런 몰이 나타나면 // 몰별 예외로 보존 규칙을 더한다. fun of(link: ProductLink): CanonicalLink { val host = requireNotNull(link.normalizedHost()) { "host 없는 링크는 canonical 을 만들 수 없다" } diff --git a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt index de678c8c7..b240c834c 100644 --- a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt +++ b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt @@ -11,7 +11,7 @@ class ProductLink private constructor( fun safeLogString(): String = "${value.host ?: "?"}${value.rawPath ?: ""}" // host 가 주어진 도메인 목록의 항목과 같거나 그 서브도메인이면 true 인 도메인 단위 매칭의 단일 술어. - // 플랫폼 라우팅 정책 판정(ExtractionRoutingPolicy)이 쓰는 도메인 매칭 술어다 — + // 도메인 접근 정책 판정(DomainAccessPolicy)이 쓰는 도메인 매칭 술어다 — // 정규화 규칙(trailing dot 제거, lowercase, 부분 문자열이 아닌 도메인 단위)이 바뀔 때 사본들이 갈라지지 않게 // 도메인이 규칙의 주인을 맡는다. host 가 없으면(형식 이상은 parse 가 이미 처리) 어느 목록과도 매칭되지 않는다. // trailing dot(절대 도메인 표기, 예: "naver.com.")은 제거해 우회를 막는다. Kotlin lowercase() 는 locale 무관(invariant). diff --git a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkErrorCode.kt b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkErrorCode.kt index 0bad9dfac..789b85bb2 100644 --- a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLinkErrorCode.kt @@ -8,7 +8,7 @@ import com.depromeet.piki.common.exception.ErrorCode // 응답 detail·로그·OpenAPI 카탈로그는 message 로 파생된다. // // 3개 전부 공개 JSON API 도달이라 ErrorCodeRegistry 에 등록한다. 링크 등록 경계(ProductLink.of · -// ExtractionRoutingPolicy)가 위시 등록(POST /wishlists)·토너먼트 아이템 등록(POST /tournaments/{id}/items) +// DomainAccessPolicy)가 위시 등록(POST /wishlists)·토너먼트 아이템 등록(POST /tournaments/{id}/items) // 양쪽에서 GlobalExceptionHandler 를 거쳐 wire code 로 나간다(WishlistApiExamples·TournamentItemApiExamples 문서화). // // 빈 링크에는 code 를 배정하지 않는다 — 두 등록 DTO 가 @field:NotBlank 로 막아 컨트롤러 진입 전에 diff --git a/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccess.kt b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccess.kt new file mode 100644 index 000000000..d3d03ec44 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccess.kt @@ -0,0 +1,18 @@ +package com.depromeet.piki.product.routing + +// 도메인 하나를 어떻게 대하는가. 축은 이것 하나이고 값은 둘뿐이다 — 행이 없으면 "아직 판단하지 않음"이라 +// 기본 수단만 쓰는 흐름을 그대로 탄다. +// +// 옛 ExtractionRoute 가 세 질문(등록을 받나 / 어떻게 가져오나 / 실측했나)에 한 축으로 답하다 모순 조합을 +// 만들어냈던 것이 이 enum 을 값 둘로 좁힌 이유다. "어떻게 가져오나"는 추출 모듈이 스스로 정하고, +// 여기서는 "받을 것인가"와 "어디까지 허락됐나"만 답한다. +enum class DomainAccess { + // 플랫폼의 명시적 허락을 받았다. 등록을 받고, 추출 모듈이 적극적인 수단까지 쓸 수 있게 된다 — + // 그 수단이 무엇인지는 모듈이 정하며 이쪽은 알지 않는다(알면 모듈이 바뀔 때 이 서술이 조용히 낡는다). + // 허락은 사람이 받아 오는 것이라 근거(permissionRef) 없이는 켤 수 없다(입력 경계가 강제). + ALLOWED, + + // 얻을 수 없다고 판단한 도메인. 등록을 400 으로 거절하고, 이미 담긴 아이템의 재파싱도 요청을 보내지 않는다. + // 차단당했다는 사실을 확인한 곳에 매번 다시 두드리지 않는다는 뜻이라, 최적화이자 계약이다. + BLOCKED, +} diff --git a/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicy.kt b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicy.kt new file mode 100644 index 000000000..5f5677ba0 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicy.kt @@ -0,0 +1,99 @@ +package com.depromeet.piki.product.routing + +import com.depromeet.piki.product.domain.ProductLink +import com.depromeet.piki.product.domain.ProductLinkException +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +// 도메인별 접근 정책의 단일 결정 지점. "이 도메인을 어떻게 대하나"의 판정이 전부 여기로 모인다 — +// 등록 거절(BLOCKED)과 허락(ALLOWED) 둘 뿐이고, 정책 행이 없는 도메인은 기본 흐름을 그대로 탄다. +// +// 인터페이스/구현 분리는 알림의 NotificationTemplateProvider 와 같은 구조 — 소비자(등록 경계·원격 클라이언트)의 +// 단위 테스트가 DB 없이 정책을 대체할 수 있게 한다. +interface DomainAccessPolicy { + // 이 링크의 host 에 지정된 접근 정책. 행이 없으면 null — 등록을 받고, 기본 수단만 쓰는 흐름을 탄다. + fun accessOf(link: ProductLink): DomainAccess? + + // 이 host 에 적극적인 수단까지 써도 되는가 — 플랫폼에서 받은 명시적 허락의 판정. 기본은 거부다: + // 정책 행이 없으면(= 대부분의 도메인) false 다. 인터페이스 기본 구현을 false 로 두는 이유는 그 default-deny 를 + // 시그니처에 박기 위해서다 — 원장을 아는 구현만 true 를 낼 수 있고, 모르는 대체 구현은 거부로 수렴한다. + fun authorizedFor(link: ProductLink): Boolean = false + + // 이 host 에 요청을 보내도 되는가. BLOCKED 면 등록 경계에서 400 으로 거르고, 파싱 경로에서도 요청 자체를 + // 내보내지 않는다 — 차단당했다고 판단한 곳에 매번 다시 두드리지 않는다는 계약이다. + fun blocked(link: ProductLink): Boolean = accessOf(link) == DomainAccess.BLOCKED + + // 등록 입력 경계 전용 — 차단 도메인이면 등록을 거른다(400). parse(형식 불변식)와 분리된 정책 계약이라 + // 등록 경계가 진다: 차단이 풀리면 백오피스에서 행만 지운다. + fun verifyRegistrable(link: ProductLink) { + if (blocked(link)) throw ProductLinkException.unsupportedPlatform() + } +} + +// DB(domain_access_policies) 기반 구현. 백오피스가 배포 없이 정책을 바꾼다(알림의 DbNotificationTemplateProvider +// 와 같은 패턴): 판정은 잦으므로(등록·파싱마다) 매번 DB 를 치지 않고 메모리 캐시로 읽고, 백오피스 수정 +// (afterCommit)과 주기 재적재가 reload() 로 캐시를 갱신한다. 매칭 규칙(서브도메인 포함 도메인 단위, 정규형)은 +// ProductLink.matchesAnyDomain 단일 술어가 진다. +@Component +class DbDomainAccessPolicy( + private val policyRepository: DomainAccessPolicyJpaRepository, +) : DomainAccessPolicy { + private val log = LoggerFactory.getLogger(javaClass) + + // 불변 List 를 통째로 교체(@Volatile)한다 — reader(등록·파싱 스레드)는 항상 옛/새 전체 중 하나만 본다 + // (DbNotificationTemplateProvider 와 같은 이유: 2단계 clear+put 이면 그 사이 빈 캐시를 읽는다). + // 도메인 길이 내림차순 정렬 — 부모/서브도메인 정책이 겹치는 host 는 더 구체적인(긴) 도메인의 정책이 이긴다. + @Volatile + private var policies: List = emptyList() + + @PostConstruct + fun load() { + policies = + policyRepository + .findAll() + .mapNotNull { toMatched(it) } + .sortedByDescending { it.domain.length } + } + + // tolerant reader — 이 바이너리가 모르는 access 문자열의 행은 스킵하고(그 도메인은 기본 흐름) warn 만 남긴다. + // 엔티티를 @Enumerated 로 두면 모르는 값 한 행이 findAll 하이드레이션을 깨 @PostConstruct 부팅이 죽는다 — + // 값을 늘린 신버전에서 행을 만든 뒤 구버전으로 롤백하면(DB 는 forward-only) 롤백 자체가 차단되는 함정. + private fun toMatched(entity: DomainAccessPolicyEntity): Matched? { + val access = DomainAccess.entries.find { it.name == entity.access } + access ?: run { + log.warn("모르는 접근 정책을 스킵(해당 도메인은 기본 흐름): domain={} access={}", entity.domain, entity.access) + return null + } + return Matched(entity.domain, access) + } + + // 백오피스 수정 직후(afterCommit)와 주기 재적재가 함께 부른다. 주기 재적재는 다른 인스턴스에서 바뀐 정책을 + // 이 인스턴스가 따라잡는 유일한 경로다 — afterCommit reload 는 수정 요청을 받은 인스턴스의 캐시만 갱신하므로, + // blue-green 공존·수평 확장에서 stale 이 이 주기로 바운드된다. 재적재 실패(일시 DB 오류)는 예외 전파로 로그에 + // 남고 기존 캐시가 유지되며 다음 주기에 재시도된다. + @Scheduled(fixedDelay = RELOAD_INTERVAL_MS) + fun reload() = load() + + override fun accessOf(link: ProductLink): DomainAccess? = matched(link)?.access + + override fun authorizedFor(link: ProductLink): Boolean = accessOf(link) == DomainAccess.ALLOWED + + // 길이 내림차순 목록의 첫 매치 = 가장 구체적인 정책. domain 이 PK 라 같은 도메인 문자열의 중복은 없고, + // 길이가 같은 서로 다른 도메인을 한 host 가 동시에 suffix 로 가질 수 없어 동률도 없다. + private fun matched(link: ProductLink): Matched? = policies.firstOrNull { link.matchesAnyDomain(it.domains) } + + private class Matched( + val domain: String, + val access: DomainAccess, + ) { + // matchesAnyDomain 이 Collection 을 받으므로 미리 감싸 판정마다 리스트를 재생성하지 않는다. + val domains: List = listOf(domain) + } + + companion object { + // stale 상한(위 reload 주석). 정책 변경은 사람 손의 백오피스 조작이라 분 단위 전파면 충분하다. + private const val RELOAD_INTERVAL_MS = 300_000L + } +} diff --git a/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntity.kt b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntity.kt new file mode 100644 index 000000000..518dd7d26 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyEntity.kt @@ -0,0 +1,44 @@ +package com.depromeet.piki.product.routing + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime + +// 도메인별 접근 정책 행. domain(정규형: 소문자·trailing dot 없음)이 자연키(PK)라 같은 도메인 문자열의 정책은 +// 정확히 하나다 — 옛 스키마에서 route 와 허가 boolean 이 갈라져 "이렇게 가져와라 + 받지 마라" 같은 모순을 +// 저장할 수 있던 것을 축 하나로 합쳐 원천 차단한다. +@Entity +@Table(name = "domain_access_policies") +class DomainAccessPolicyEntity( + @Id + @Column(name = "domain", length = 255) + val domain: String, + // DomainAccess 의 이름 문자열. @Enumerated 로 두지 않는 이유: 구버전 바이너리가 모르는 값 한 행이 + // 하이드레이션조차 못 해 부팅(@PostConstruct findAll)이 죽는다 — 변환은 읽는 쪽이 tolerant 하게 진다. + @Column(name = "access", nullable = false, length = 16) + val access: String, + @Column(name = "reason", length = 255) + val reason: String? = null, + // 허락 근거(메일 스레드·수신일·담당자). 허락은 사람이 받아 오는 것이라 근거 없이 켜진 행은 되짚을 수 없다. + @Column(name = "permission_ref", length = 255) + val permissionRef: String? = null, +) { + init { + // 생성 경로(admin 서비스·테스트)의 불변식 — JPA no-arg 하이드레이션은 init 을 타지 않으므로 DB 의 기존 + // 행을 막지는 못한다. 정규화 자체는 경계(AdminDomainAccessService.normalize)가 책임지고, 여기는 새 생성 + // 경로가 정규화를 빠뜨리는 코드 버그를 잡는 층이다. + require(domain.isNotBlank()) { "domain 이 비어 있습니다." } + require(domain == domain.trim().trimEnd('.').lowercase()) { "domain 은 정규형(소문자·trailing dot 없음)이어야 합니다." } + // 허락은 근거가 있어야 허락이다. 근거 없는 ALLOWED 는 적극적인 수단을 여는 값이 아무 흔적 없이 켜진 상태라, + // 몇 달 뒤 "왜 켰지"를 되짚을 수 없고 지워도 되는지도 판단할 수 없다. + require(access != DomainAccess.ALLOWED.name || !permissionRef.isNullOrBlank()) { + "허락 근거(permissionRef) 없이 ALLOWED 정책을 만들 수 없습니다." + } + } + + // 행 수정은 upsert(같은 PK 로 새 인스턴스 save = 교체)라 새 인스턴스의 생성 시각이 곧 마지막 변경 시각이 된다. + @Column(name = "updated_at", nullable = false) + val updatedAt: LocalDateTime = LocalDateTime.now() +} diff --git a/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyJpaRepository.kt similarity index 50% rename from src/main/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyJpaRepository.kt rename to src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyJpaRepository.kt index b3fdfcc0b..305c47fac 100644 --- a/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/product/routing/DomainAccessPolicyJpaRepository.kt @@ -2,4 +2,4 @@ package com.depromeet.piki.product.routing import org.springframework.data.jpa.repository.JpaRepository -interface ExtractionPlatformPolicyJpaRepository : JpaRepository +interface DomainAccessPolicyJpaRepository : JpaRepository diff --git a/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyEntity.kt b/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyEntity.kt deleted file mode 100644 index cd6737a52..000000000 --- a/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionPlatformPolicyEntity.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.depromeet.piki.product.routing - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.Table -import java.time.LocalDateTime - -// 플랫폼(host)별 추출 라우팅 정책 행. domain(정규형: 소문자·trailing dot 없음)이 자연키(PK)라 같은 도메인 문자열의 -// 정책은 정확히 하나다. 백오피스가 배포 없이 추가·교체·삭제하고(NotificationTemplateEntity 와 같은 동적 설정 패턴), -// reason 은 운영 메모(왜 이 정책인가 — 실측 근거)다. -@Entity -@Table(name = "extraction_platform_policies") -class ExtractionPlatformPolicyEntity( - @Id - @Column(name = "domain", length = 255) - val domain: String, - // ExtractionRoute 의 이름 문자열. @Enumerated 로 두지 않는 이유: 구버전 바이너리가 모르는 route 행을 - // 하이드레이션조차 못 해 부팅(@PostConstruct findAll)이 죽는다 — enum 변환은 읽는 쪽 - // (DbExtractionRoutingPolicy)이 tolerant 하게 진다(모르는 route 는 스킵). - @Column(name = "route", nullable = false, length = 32) - val route: String, - @Column(name = "reason", length = 255) - val reason: String?, - // 헤드리스 허가 원장. 브라우저로 페이지를 여는 것은 플랫폼의 명시적 허가를 받은 도메인에만 허용한다 — - // 기본은 거부라 default 가 false 이고, 정책 행이 아예 없는 도메인도 거부다("행 없음 = 기본 = 불가"). - @Column(name = "headless_allowed", nullable = false) - val headlessAllowed: Boolean = false, - // 허가 근거(메일 스레드·수신일·담당자 등)와 허가를 켠 시각. 허가는 사람이 받아 오는 것이라 근거 없이 켜진 - // 행은 되짚을 수 없다 — 근거 필수는 입력 경계(AdminExtractionPolicyService)가 지고, 여기는 원장 자리만 둔다. - @Column(name = "permission_ref", length = 255) - val permissionRef: String? = null, - @Column(name = "permission_granted_at") - val permissionGrantedAt: LocalDateTime? = null, -) { - init { - // 생성 경로(admin 서비스·테스트)의 불변식 — JPA no-arg 하이드레이션은 init 을 타지 않으므로 DB 의 기존 행을 - // 막지는 못한다. 정규화 자체는 경계(AdminExtractionPolicyService.normalize)가 책임지고, 여기는 새 생성 - // 경로가 정규화를 빠뜨리는 코드 버그를 잡는 층이다. - require(domain.isNotBlank()) { "domain 이 비어 있습니다." } - require(domain == domain.trim().trimEnd('.').lowercase()) { "domain 은 정규형(소문자·trailing dot 없음)이어야 합니다." } - // default-deny 불변식 — 허가 없는 브라우저 직행 행은 만들 수 없다. 어느 경로가 만들든(백오피스·테스트· - // 미래의 배치) 같은 결과가 나오게 도메인이 자기방어한다. 정상 흐름에선 입력 경계가 먼저 사용자 문구로 - // 거르므로 여기 닿으면 그 경계가 검증을 빠뜨린 코드 버그다. - // route 를 문자열로 비교하는 이유는 필드가 tolerant 한 String 이기 때문(모르는 route 하이드레이션 보호). - require(route != ExtractionRoute.HEADLESS_FIRST.name || headlessAllowed) { - "허가(headlessAllowed) 없이 HEADLESS_FIRST 정책을 만들 수 없습니다." - } - } - - // 행 수정은 upsert(같은 PK 로 새 인스턴스 save = 교체)라 새 인스턴스의 생성 시각이 곧 마지막 변경 시각이 된다. - @Column(name = "updated_at", nullable = false) - val updatedAt: LocalDateTime = LocalDateTime.now() -} diff --git a/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionRoute.kt b/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionRoute.kt deleted file mode 100644 index a52e8f08d..000000000 --- a/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionRoute.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.depromeet.piki.product.routing - -// 플랫폼(host)별 추출 라우팅 정책의 종류. DB(extraction_platform_policies.route)에 문자열로 저장되고 -// 백오피스에서 배포 없이 지정·해제한다. 정책 행이 없는 도메인은 기본 추출 체인을 탄다 — enum 에 DEFAULT 를 -// 두지 않는 이유: "행 없음 = 기본" 규약이라 DEFAULT 행이 생길 수 없어야 하고, 소비처는 null(정책 없음)로 받는다. -// -// SUPPORTED 는 그 DEFAULT 와 다르다. DEFAULT 는 "행 없음"의 동어반복이지만 SUPPORTED 는 정보를 더한다 — -// "행 없음"이 "아직 안 봐서 모름"인 반면 SUPPORTED 는 "실측으로 잘 됨을 확인함"이다. 라우팅 동작이 같아도 -// 운영자가 아는 사실이 다르므로 값을 나눈다. -// -// 선언 순서는 백오피스 3열 뷰의 열 순서(느슨함 → 강함)로 쓴다. 매칭·판정은 이 순서에 기대지 않는다 -// (DbExtractionRoutingPolicy 는 도메인 길이 내림차순 최장 매치로 승자를 정한다). -enum class ExtractionRoute { - // 기본 추출 체인(정적 fetch → 구조화/LLM)으로 잘 된다고 실측 확인한 플랫폼. 라우팅 동작은 정책 행이 - // 없는 도메인과 같다 — 소비처(verifyRegistrable · HttpProductLinkExtractor)가 UNSUPPORTED · HEADLESS_FIRST - // 와의 정확한 값 비교만 하므로 이 값은 두 분기를 모두 빗나가 기본 체인을 탄다. 지정 목적은 판정이 아니라 - // 기록이다: 실측 근거(reason)를 남겨, 커버리지를 확인한 플랫폼과 아직 안 본 플랫폼을 백오피스에서 구분한다. - SUPPORTED, - - // 기본 체인(plain: 정적 HTTP)을 건너뛰고 처음부터 헤드리스 브라우저로 추출한다 — plain 이 항상 차단되는 - // 플랫폼에서 느린-실패(fetch 타임아웃 후 에스컬레이트) 낭비를 없앤다. HttpProductLinkExtractor 가 요청 - // 힌트(headlessFirst)로 extractor 에 실어 보내며, extractor 의 headless 스위치 - // (product.extract.headless.enabled)가 꺼진 환경에선 지정해도 효과가 없다. - HEADLESS_FIRST, - - // 등록 입력 경계에서 400 으로 거절한다 — fetch 로 상품 정보를 가져올 수 없는 플랫폼(봇 차단 등). - // 담아봐야 파싱이 무의미하게 실패하고 사용자에겐 틀린 안내("주소를 다시 확인")가 나가기 때문. - UNSUPPORTED, -} diff --git a/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionRoutingPolicy.kt b/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionRoutingPolicy.kt deleted file mode 100644 index b05aa3a1b..000000000 --- a/src/main/kotlin/com/depromeet/piki/product/routing/ExtractionRoutingPolicy.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.depromeet.piki.product.routing - -import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.product.domain.ProductLinkException -import jakarta.annotation.PostConstruct -import org.slf4j.LoggerFactory -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.stereotype.Component - -// 플랫폼(host)별 추출 라우팅의 단일 결정 지점(디스패처). "이 링크를 어떻게 다룰까"의 host 축 정책이 전부 여기로 -// 모인다 — 등록 경계의 미지원 거절(UNSUPPORTED)과 추출 체인의 브라우저 직행(HEADLESS_FIRST). -// 인터페이스/구현 분리는 알림의 NotificationTemplateProvider 와 같은 구조 — 소비자(등록 경계·원격 클라이언트)의 -// 단위 테스트가 DB 없이 정책을 대체할 수 있게 한다. -interface ExtractionRoutingPolicy { - // 이 링크의 host 에 지정된 정책. 정책 행이 없으면 null — 기본 추출 체인(구조화 → LLM, 차단 시 헤드리스 에스컬레이트). - fun routeOf(link: ProductLink): ExtractionRoute? - - // 이 링크의 host 를 헤드리스(브라우저)로 열어도 되는가 — 플랫폼에서 받은 명시적 허가의 판정. 기본은 거부다: - // 정책 행이 없으면(= 대부분의 도메인) false 이고, 행이 있어도 허가를 켜지 않았으면 false 다. - // 인터페이스 기본 구현을 false 로 두는 이유는 그 default-deny 를 시그니처에 박기 위해서다 — 허가 원장을 - // 아는 구현(DbExtractionRoutingPolicy)만 true 를 낼 수 있고, 원장을 모르는 대체 구현은 거부로 수렴한다. - fun headlessAllowedOf(link: ProductLink): Boolean = false - - // 등록 입력 경계 전용 — UNSUPPORTED 플랫폼이면 등록을 거른다(400). parse(형식 불변식)와 분리된 정책 계약이라 - // 등록 경계가 진다: 이미 저장된 미지원 URL 조회·redirect 추적은 막지 않고, 차단이 풀리면 백오피스에서 행만 지운다. - fun verifyRegistrable(link: ProductLink) { - if (routeOf(link) == ExtractionRoute.UNSUPPORTED) throw ProductLinkException.unsupportedPlatform() - } -} - -// DB(extraction_platform_policies) 기반 구현. 백오피스가 배포 없이 정책을 바꾼다(알림의 -// DbNotificationTemplateProvider 와 같은 패턴): 판정은 잦으므로(등록·파싱마다) 매번 DB 를 치지 않고 메모리 캐시로 -// 읽고, 백오피스 수정(afterCommit)과 주기 재적재가 reload() 로 캐시를 갱신한다. 매칭 규칙(서브도메인 포함 -// 도메인 단위, 정규형)은 ProductLink.matchesAnyDomain 단일 술어가 진다. -@Component -class DbExtractionRoutingPolicy( - private val policyRepository: ExtractionPlatformPolicyJpaRepository, -) : ExtractionRoutingPolicy { - private val log = LoggerFactory.getLogger(javaClass) - - // 불변 List 를 통째로 교체(@Volatile)한다 — reader(등록·파싱 스레드)는 항상 옛/새 전체 중 하나만 본다 - // (DbNotificationTemplateProvider 와 같은 이유: 2단계 clear+put 이면 그 사이 빈 캐시를 읽는다). - // 도메인 길이 내림차순 정렬 — 부모/서브도메인 정책이 겹치는 host(예: a-bly.com 차단 + m.a-bly.com 직행)는 - // 더 구체적인(긴) 도메인의 정책이 이긴다. enum 선언 순서 같은 암묵 규칙에 기대지 않는다. - @Volatile - private var policies: List = emptyList() - - @PostConstruct - fun load() { - policies = - policyRepository - .findAll() - .mapNotNull { toDomainPolicy(it) } - .sortedByDescending { it.domain.length } - } - - // tolerant reader — 이 바이너리가 모르는 route 문자열의 행은 스킵하고(기본 체인으로 판정) warn 만 남긴다. - // 엔티티를 @Enumerated 로 두면 모르는 값 한 행이 findAll 하이드레이션을 깨 @PostConstruct 부팅이 죽는다 — - // route 를 늘린 신버전에서 행을 만든 뒤 구버전으로 롤백하면(DB 는 forward-only) 롤백 자체가 차단되는 함정. - private fun toDomainPolicy(entity: ExtractionPlatformPolicyEntity): DomainPolicy? { - val route = ExtractionRoute.entries.find { it.name == entity.route } - route ?: run { - log.warn("모르는 추출 route 를 스킵(해당 도메인은 기본 체인): domain={} route={}", entity.domain, entity.route) - return null - } - return DomainPolicy(entity.domain, route, entity.headlessAllowed) - } - - // 백오피스 수정 직후(afterCommit)와 주기 재적재가 함께 부른다. 주기 재적재는 다른 인스턴스에서 바뀐 정책을 - // 이 인스턴스가 따라잡는 유일한 경로다 — afterCommit reload 는 수정 요청을 받은 인스턴스의 캐시만 갱신하므로, - // blue-green 공존·수평 확장에서 stale 이 이 주기로 바운드된다. 재적재 실패(일시 DB 오류)는 예외 전파로 로그에 - // 남고 기존 캐시가 유지되며 다음 주기에 재시도된다. - @Scheduled(fixedDelay = RELOAD_INTERVAL_MS) - fun reload() = load() - - override fun routeOf(link: ProductLink): ExtractionRoute? = matched(link)?.route - - // 허가는 매칭된 정책 행 하나가 답한다 — 행이 없으면(또는 모르는 route 라 캐시에서 스킵됐으면) false 로 떨어져 - // 기본 거부가 유지된다. route 와 같은 최장 매치를 쓰므로 한 host 에 대해 두 값이 서로 다른 행을 가리키지 않는다. - override fun headlessAllowedOf(link: ProductLink): Boolean = matched(link)?.headlessAllowed ?: false - - // 길이 내림차순 목록의 첫 매치 = 가장 구체적인 정책. domain 이 PK 라 같은 도메인 문자열의 중복은 없고, - // 길이가 같은 서로 다른 도메인을 한 host 가 동시에 suffix 로 가질 수 없어 동률도 없다. - private fun matched(link: ProductLink): DomainPolicy? = policies.firstOrNull { link.matchesAnyDomain(it.domains) } - - private class DomainPolicy( - val domain: String, - val route: ExtractionRoute, - val headlessAllowed: Boolean, - ) { - // matchesAnyDomain 이 Collection 을 받으므로 미리 감싸 판정마다 리스트를 재생성하지 않는다. - val domains: List = listOf(domain) - } - - companion object { - // stale 상한(위 reload 주석). 정책 변경은 사람 손의 백오피스 조작이라 분 단위 전파면 충분하다. - private const val RELOAD_INTERVAL_MS = 300_000L - } -} diff --git a/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt b/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt index 7950fbe21..edcaa9b43 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt @@ -14,7 +14,7 @@ enum class ExtractionFailureBucket { // 우리 구성으로 그 페이지를 못 읽었다(빈 셸·추출할 본문 없음). 늘면 도메인 허가 후보를 본다. UNREADABLE, - // 대상이 우리를 막았다. 늘면 UNSUPPORTED 정책 후보를 본다. + // 대상이 우리를 막았다. 늘면 BLOCKED 정책 후보를 본다. BLOCKED, // 추출은 됐는데 값을 믿을 수 없다. 늘면 모델·프롬프트·검증 규칙을 본다. diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelEntity.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelEntity.kt index d2b46ff33..92968b36b 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelEntity.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelEntity.kt @@ -11,7 +11,7 @@ import java.time.LocalDateTime @Entity @Table(name = "extraction_models") class ExtractionModelEntity( - // ExtractionTarget 의 이름 문자열. @Enumerated 로 두지 않는 이유는 ExtractionPlatformPolicyEntity.route 와 + // ExtractionTarget 의 이름 문자열. @Enumerated 로 두지 않는 이유는 DomainAccessPolicyEntity.access 와 // 같다 — 구버전 바이너리가 모르는 target 행 하나가 findAll 하이드레이션을 깨 부팅(@PostConstruct)을 죽인다. // enum 변환은 읽는 쪽(ExtractionModelSettings)이 tolerant 하게 진다. @Id diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelSettings.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelSettings.kt index 5ed4fb79c..26914b495 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelSettings.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelSettings.kt @@ -5,7 +5,7 @@ import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component -// 추출 경로별 LLM 모델 지정의 단일 조회 지점. 인터페이스/구현 분리는 ExtractionRoutingPolicy 와 같은 구조 — +// 추출 경로별 LLM 모델 지정의 단일 조회 지점. 인터페이스/구현 분리는 DomainAccessPolicy 와 같은 구조 — // 소비자(원격 클라이언트)의 단위 테스트가 DB 없이 지정값을 대체할 수 있게 한다. interface ExtractionModelSettings { // 지정된 모델. 행이 없으면 null 이고, 그 경우 호출자는 요청에 모델을 싣지 않아 extractor 기본값으로 동작한다. @@ -14,7 +14,7 @@ interface ExtractionModelSettings { // DB(extraction_models) 기반 구현. 백오피스가 배포 없이 모델을 바꾼다 — 판정은 잦으므로(파싱마다) 매번 DB 를 // 치지 않고 메모리 캐시로 읽고, 백오피스 수정(afterCommit)과 주기 재적재가 reload() 로 갱신한다 -// (DbExtractionRoutingPolicy 와 같은 패턴). +// (DbDomainAccessPolicy 와 같은 패턴). @Component class DbExtractionModelSettings( private val repository: ExtractionModelJpaRepository, @@ -50,7 +50,7 @@ class DbExtractionModelSettings( // 파싱이 죽지 않는다 (DB 는 forward-only 라 행이 남는다). // // 인터페이스에 두지 않는 이유는 reload 와 같다 — 파싱 경로가 쓰지 않는 관리 기능이라 admin 이 구현을 - // 직접 주입받는다 (AdminExtractionPolicyService 가 DbExtractionRoutingPolicy 를 직접 받는 것과 같다). + // 직접 주입받는다 (AdminExtractionPolicyService 가 DbDomainAccessPolicy 를 직접 받는 것과 같다). fun findAll(): Map = repository .findAll() @@ -65,7 +65,7 @@ class DbExtractionModelSettings( companion object { // stale 상한. 모델 변경은 사람 손의 백오피스 조작이라 분 단위 전파면 충분하다 - // (DbExtractionRoutingPolicy 와 같은 값·같은 이유). + // (DbDomainAccessPolicy 와 같은 값·같은 이유). private const val RELOAD_INTERVAL_MS = 300_000L } } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractor.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractor.kt index 33473ad00..dfe433d86 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractor.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractor.kt @@ -1,28 +1,26 @@ package com.depromeet.piki.product.service.remote import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.product.routing.ExtractionRoute -import com.depromeet.piki.product.routing.ExtractionRoutingPolicy +import com.depromeet.piki.product.domain.ProductLinkException +import com.depromeet.piki.product.routing.DomainAccessPolicy import com.depromeet.piki.product.service.ProductLinkExtractor import com.depromeet.piki.product.service.ProductSnapshot import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Component import org.springframework.web.client.RestClient -// 원격 추출 서비스(extractor)의 링크 추출 클라이언트. 계약은 extractor repo 의 docs/api-contract.md 가 -// single source 이고, 호출·3갈래 번역(2xx / 422+code / 그 외)·2xx 계약 위반 가드는 이미지 클라이언트 +// 원격 추출 서비스(extractor)의 링크 추출 클라이언트. 계약 정본은 TeamPiKi/infra 의 +// contracts/extraction-api.md 이고, 호출·3갈래 번역(2xx / 422+code / 그 외)·2xx 계약 위반 가드는 이미지 클라이언트 // (HttpImageSnapshotExtractor)와 공유하므로 RemoteExtractionContract 한 곳에 있다 — 여기는 링크 고유의 요청(URL)만 진다. // -// headlessFirst: 라우팅 정책(HEADLESS_FIRST, DB·백오피스)의 판정을 요청 힌트로 싣는다 — 정책의 단일 진실은 -// 이쪽(DB) 이고, 무상태인 extractor 는 요청 단위로만 받는다(계약 §2). extractor 의 headless 스위치가 꺼져 -// 있으면 저쪽에서 무시되므로 그 스위치는 여기서 게이트하지 않는다 — 능력을 가진 쪽(extractor) 한 곳에 둔다. -// 단, 허가(headlessAllowed)만큼은 이쪽이 게이트한다: headlessFirst 는 HEADLESS_FIRST 이면서 허가된 도메인에만 -// true 다. 허가 없이 route 만 HEADLESS_FIRST 인 행을 만나도 힌트를 싣지 않아 core 가 스스로 default-deny 를 지킨다. +// 차단(BLOCKED)은 여기서 끝난다 — 요청을 아예 내보내지 않으므로 extractor·renderer 는 그 도메인의 존재조차 +// 모른다. 등록 경계도 같은 판정을 하지만 그건 사용자에게 즉시 400 을 주기 위한 것이고, 이 검사는 이미 담긴 +// 아이템의 재파싱·새로고침까지 덮는 마지막 출구다. // -// headlessAllowed: "이 도메인을 브라우저로 열어도 되는가"의 허가 판정(플랫폼에서 받은 명시적 허가). 정책과 같은 -// 이유로 요청에 싣는다 — 허가 원장(extraction_platform_policies)은 이쪽 DB 에만 있고 extractor 는 무상태다. -// 기본은 거부라 정책 행이 없는 대부분의 도메인은 false 로 나간다. 브라우저를 열어도 되는지의 최종 판단 근거이며, -// headlessFirst("빠른 길" 힌트)도 이 값이 true 일 때만 켜진다. +// authorized: "이 대상이 플랫폼의 명시적 허락을 받았는가"의 판정. 원장(domain_access_policies)은 이쪽 DB 에만 +// 있고 extractor·renderer 는 무상태라, 요청 단위로 실어 보낸다. 기본은 거부라 정책 행이 없는 대부분의 도메인은 +// false 로 나간다. 이 값이 여는 것은 추출 모듈이 쓸 수 있는 수단의 범위이고, 그 수단이 무엇인지는 모듈이 +// 정한다 — 여기서 알면 저쪽 구현이 바뀔 때 이 서술만 조용히 낡는다. 기본 수단으로 가는 데는 허락이 필요 없다. // // model 도 같은 이유로 요청에 싣는다(#875). extractor 박스 하나를 여러 환경이 공유하므로 저쪽 환경변수로 // 모델을 잡으면 dev 실험이 prod 를 덮는다 — 요청 단위로 주면 환경마다 다른 이쪽 DB 가 그대로 경계가 된다. @@ -32,24 +30,24 @@ import org.springframework.web.client.RestClient @Component class HttpProductLinkExtractor( @Qualifier("remoteExtractionRestClient") private val restClient: RestClient, - private val routingPolicy: ExtractionRoutingPolicy, + private val accessPolicy: DomainAccessPolicy, private val modelSettings: ExtractionModelSettings, ) : ProductLinkExtractor { override fun extract(link: ProductLink): ProductSnapshot { - // 허가 게이트를 headlessFirst 에도 AND 한다 — 허가받지 않은 도메인엔 "브라우저 직행" 힌트조차 싣지 않는다. - // 허가 없이 HEADLESS_FIRST 로 남은 행(이 기능 이전 데이터·구버전이 만든 행)을 만나도 core 가 스스로 - // default-deny 를 지켜 (headlessFirst=true, headlessAllowed=false) 같은 모순 요청이 나가지 않는다 — - // extractor 가 먼저 배포되는 구간에서도 허가 없는 헤드리스가 열리지 않게 core 를 자기완결적으로 둔다. - // 허가가 없으면(대부분의 도메인) 단락 평가로 routeOf 스캔조차 건너뛴다. - val headlessAllowed = routingPolicy.headlessAllowedOf(link) + // 차단 도메인은 요청 자체를 내보내지 않는다. 등록 경계(verifyRegistrable)가 새 등록을 이미 막지만, + // 그것만으로는 차단 지정 이전에 담긴 아이템의 재파싱·새로고침이 그대로 나간다 — 여기가 extractor 로 + // 나가는 유일한 출구라, 이 한 곳을 막으면 어느 경로로 들어오든 두드리지 않는다. + // "차단당했다고 판단한 곳에 매번 다시 요청하지 않는다"는 계약이라 성능 판단이 아니다. + if (accessPolicy.blocked(link)) throw ProductLinkException.unsupportedPlatform() return RemoteExtractionContract.postForSnapshot( restClient = restClient, path = LINK_EXTRACTION_PATH, request = RemoteLinkExtractionRequest( url = link.value.toString(), - headlessFirst = headlessAllowed && routingPolicy.routeOf(link) == ExtractionRoute.HEADLESS_FIRST, - headlessAllowed = headlessAllowed, + // 허락 판정의 원장은 core 다. extractor·renderer 는 이 값만큼 수단을 열 뿐, + // 무엇이 허락됐는지 스스로 알지 않는다(무상태). + authorized = accessPolicy.authorizedFor(link), model = modelSettings.modelOf(ExtractionTarget.LINK), ), link = link, @@ -66,7 +64,6 @@ class HttpProductLinkExtractor( // model 이 null 이면 extractor 가 자기 기본 모델을 쓴다(계약 §2) — 지정이 없는 상태를 그대로 흘려보낸다. private data class RemoteLinkExtractionRequest( val url: String, - val headlessFirst: Boolean, - val headlessAllowed: Boolean, + val authorized: Boolean, val model: String?, ) diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt index d29c01875..1ad4cde55 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt @@ -41,7 +41,7 @@ enum class ProductExtractorErrorCode( ), // 대상이 우리를 막아 확정 실패. 우리 버그도 사용자 잘못도 아니라 따로 센다 — 늘면 그 도메인의 - // UNSUPPORTED 정책(백오피스) 후보가 된다. + // BLOCKED 정책(백오피스) 후보가 된다. BLOCKED_BY_TARGET( "EXTRACTOR-003", ErrorCategory.SERVER_ERROR, diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorException.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorException.kt index 9dee23ca1..83a6dc41a 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorException.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorException.kt @@ -31,7 +31,7 @@ class ProductExtractorException private constructor( fun permanentFailure(): ProductExtractorException = ProductExtractorException(ProductExtractorErrorCode.PERMANENT_FAILURE) // 원격이 422 로 답했고, 그 사유가 "대상이 우리를 막았다"인 경우(4xx 접근 거부·영구 upstream 거절). - // 재시도 무의미인 건 같고, 메트릭에서 blocked 로 따로 세어 정책(UNSUPPORTED) 판단의 입력이 된다. + // 재시도 무의미인 건 같고, 메트릭에서 blocked 로 따로 세어 정책(BLOCKED) 판단의 입력이 된다. fun blockedByTarget(): ProductExtractorException = ProductExtractorException(ProductExtractorErrorCode.BLOCKED_BY_TARGET) } } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt index 5adb583b6..4a1592c87 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt @@ -35,7 +35,7 @@ internal object RemoteExtractionContract { // 우리 구성으로 못 읽었다 — 도메인 허가 후보 신호. "EMPTY_SHELL" to { ProductSnapshotException.noExtractableContent() }, "NO_EXTRACTABLE_CONTENT" to { ProductSnapshotException.noExtractableContent() }, - // 대상이 우리를 막았다 — UNSUPPORTED 정책 후보. + // 대상이 우리를 막았다 — BLOCKED 정책 후보. "FETCH_CLIENT_ERROR" to { ProductExtractorException.blockedByTarget() }, "PERMANENT_UPSTREAM" to { ProductExtractorException.blockedByTarget() }, // 추출은 됐는데 값을 믿을 수 없다 — 모델·프롬프트·검증 규칙 소관. diff --git a/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformEntity.kt b/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformEntity.kt index df610eed0..2d03bee13 100644 --- a/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformEntity.kt +++ b/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformEntity.kt @@ -7,7 +7,7 @@ import jakarta.persistence.Table import java.time.LocalDateTime // 출처 커머스몰 표시명 행 (#766). domain(정규형: 소문자·trailing dot 없음)이 자연키(PK)라 같은 도메인 문자열의 -// 표시명은 정확히 하나다. 백오피스가 배포 없이 추가·교체·삭제하고(ExtractionPlatformPolicyEntity 와 같은 동적 설정 +// 표시명은 정확히 하나다. 백오피스가 배포 없이 추가·교체·삭제하고(DomainAccessPolicyEntity 와 같은 동적 설정 // 패턴), display_name 은 클라이언트 응답(sourcePlatform)에 그대로 나가는 사용자 대면 표기다. @Entity @Table(name = "source_platforms") diff --git a/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformResolver.kt b/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformResolver.kt index 95eb733f7..920c906d2 100644 --- a/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformResolver.kt +++ b/src/main/kotlin/com/depromeet/piki/product/source/SourcePlatformResolver.kt @@ -7,14 +7,14 @@ import org.springframework.stereotype.Component // 출처 커머스몰 표시명(sourcePlatform)의 단일 결정 지점 (#766). 응답 시점에 URL 에서 유도한다 — items 에 저장하지 // 않으므로 백오피스 수정·신규 등록이 과거 item 에도 즉시 소급된다. -// 인터페이스/구현 분리는 ExtractionRoutingPolicy 와 같은 구조 — 소비자의 단위 테스트가 DB 없이 대체할 수 있게 한다. +// 인터페이스/구현 분리는 DomainAccessPolicy 와 같은 구조 — 소비자의 단위 테스트가 DB 없이 대체할 수 있게 한다. interface SourcePlatformResolver { // 이 링크의 출처 몰 표시명. 백오피스 등록값(도메인 최장 일치)이 우선하고, 없으면 host 에서 유도한 임시값 // (SourcePlatformFallback). 링크가 없거나(이미지 등록 item) host 가 없으면 null — 유도할 재료 자체가 없다. fun resolve(link: ProductLink?): String? } -// DB(source_platforms) 기반 구현. 백오피스가 배포 없이 표시명을 바꾼다(DbExtractionRoutingPolicy 와 같은 패턴): +// DB(source_platforms) 기반 구현. 백오피스가 배포 없이 표시명을 바꾼다(DbDomainAccessPolicy 와 같은 패턴): // 판정은 잦으므로(위시 목록 항목마다) 매번 DB 를 치지 않고 메모리 캐시로 읽고, 백오피스 수정(afterCommit)과 // 주기 재적재가 reload() 로 캐시를 갱신한다. 매칭 규칙(서브도메인 포함 도메인 단위, 정규형)은 // ProductLink.matchesAnyDomain 단일 술어가 진다. @@ -37,7 +37,7 @@ class DbSourcePlatformResolver( } // 백오피스 수정 직후(afterCommit)와 주기 재적재가 함께 부른다. 주기 재적재는 다른 인스턴스에서 바뀐 등록을 - // 이 인스턴스가 따라잡는 유일한 경로다 (DbExtractionRoutingPolicy.reload 와 같은 이유·같은 stale 상한). + // 이 인스턴스가 따라잡는 유일한 경로다 (DbDomainAccessPolicy.reload 와 같은 이유·같은 stale 상한). @Scheduled(fixedDelay = RELOAD_INTERVAL_MS) fun reload() = load() diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt index 82936c19a..70c179b00 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentItemApiExamples.kt @@ -74,7 +74,7 @@ class TournamentItemApiExamples( ) add(ProductLinkException.invalidFormat(urlFormatCause), name = "유효하지 않은 URL 형식") add(ProductLinkException.unsupportedScheme(), name = "https 외 스킴") - add(ProductLinkException.unsupportedPlatform(), name = "지원하지 않는 쇼핑몰 (차단 목록은 백오피스 추출 라우팅 정책 기준)") + add(ProductLinkException.unsupportedPlatform(), name = "지원하지 않는 쇼핑몰 (차단 목록은 백오피스 도메인 접근 정책 기준)") add(TournamentException.tooManyTournamentItems(), name = "아이템 최대 32개 초과") unauthorized() add(TournamentException.forbiddenTournament(), name = "토너먼트 권한 없음") diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt index 59b29e8c1..6fbf157fe 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemService.kt @@ -9,7 +9,7 @@ import com.depromeet.piki.image.service.dto.PresignedRawUpload import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.product.routing.ExtractionRoutingPolicy +import com.depromeet.piki.product.routing.DomainAccessPolicy import com.depromeet.piki.tournament.repository.TournamentItemRepository import com.depromeet.piki.tournament.repository.TournamentRepository import com.depromeet.piki.tournament.repository.TournamentUserRepository @@ -20,7 +20,7 @@ import java.util.UUID @Service class TournamentItemService( private val tournamentItemPersistenceService: TournamentItemPersistenceService, - private val extractionRoutingPolicy: ExtractionRoutingPolicy, + private val accessPolicy: DomainAccessPolicy, private val imageStorage: ImageStorage, private val imagePresignService: ImagePresignService, private val tournamentRepository: TournamentRepository, @@ -53,8 +53,8 @@ class TournamentItemService( ): Long { val link = ProductLink.parse(url) // fetch 불가 플랫폼(봇 차단)은 담아봐야 파싱이 무의미하게 실패한다 — 등록 시점에 막아 빠르게 안내한다(400). - // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — ExtractionRoutingPolicy 참고. - extractionRoutingPolicy.verifyRegistrable(link) + // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — DomainAccessPolicy 참고. + accessPolicy.verifyRegistrable(link) // 권한·상태를 차감 전에 확인한다(이미지 경로와 같은 이유) — 참여자도 아닌 요청이 오너의 몫을 깎으면 안 된다. // persist 안에서 정원까지 포함해 최종 판정을 다시 하므로 여기 검증은 사전 확인이다. tournamentItemPersistenceService.verifyCanAddItems(userId, tournamentId) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt index 5a930f587..fac9b1173 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt @@ -48,7 +48,7 @@ class WishlistApiExamples( ) add(ProductLinkException.invalidFormat(urlFormatCause), name = "유효하지 않은 URL 형식") add(ProductLinkException.unsupportedScheme(), name = "https 외 스킴") - add(ProductLinkException.unsupportedPlatform(), name = "지원하지 않는 쇼핑몰 (차단 목록은 백오피스 추출 라우팅 정책 기준)") + add(ProductLinkException.unsupportedPlatform(), name = "지원하지 않는 쇼핑몰 (차단 목록은 백오피스 도메인 접근 정책 기준)") add(WishException.alreadyExists(), name = "이미 위시리스트에 등록된 상품 (공유 정체성 기준)") unauthorized() add(WishException.guestCannotUseWishlist(), name = "게스트의 위시리스트 이용 거부 (회원 전용)") diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt index 8e297743e..8839253b9 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -12,7 +12,7 @@ import com.depromeet.piki.item.repository.ItemRepository import com.depromeet.piki.item.repository.ItemSnapshotRepository import com.depromeet.piki.item.service.ItemDisplayService import com.depromeet.piki.product.domain.ProductLink -import com.depromeet.piki.product.routing.ExtractionRoutingPolicy +import com.depromeet.piki.product.routing.DomainAccessPolicy import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.service.UserService import com.depromeet.piki.wishlist.domain.WishCursor @@ -32,7 +32,7 @@ import java.util.UUID @Service class WishlistService( private val wishPersistenceService: WishPersistenceService, - private val extractionRoutingPolicy: ExtractionRoutingPolicy, + private val accessPolicy: DomainAccessPolicy, private val imageStorage: ImageStorage, private val imagePresignService: ImagePresignService, private val wishRepository: WishRepository, @@ -64,10 +64,10 @@ class WishlistService( requireMember(userId) val link = ProductLink.parse(rawUrl) // fetch 불가 플랫폼(봇 차단)은 담아봐야 파싱이 무의미하게 실패한다 — 등록 시점에 막아 빠르게 안내한다. - // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — ExtractionRoutingPolicy 참고. - extractionRoutingPolicy.verifyRegistrable(link) + // 미지원 목록은 DB 정책(백오피스에서 배포 없이 변경)이 진다 — DomainAccessPolicy 참고. + accessPolicy.verifyRegistrable(link) // 형식·플랫폼 검증(400)을 통과한 뒤에 차감한다 — 잘못된 URL 로 한도를 깎으면 사용자가 자기 실수로 몫을 잃는다. - // 파서로 풀려 LLM 을 안 타도 fetch·프록시·저장·DB 행은 그대로 소모되므로 경로와 무관하게 1 로 센다. + // 파서로 풀려 LLM 을 안 타도 fetch·추출 모듈 시간·저장·DB 행은 그대로 소모되므로 경로와 무관하게 1 로 센다. itemQuotaGuard.consume(userId, 1, WishErrorCode.ITEM_QUOTA_EXCEEDED) return wishPersistenceService.persist(userId, Item(link)) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1767e88da..c8d7e11c5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -169,8 +169,8 @@ s3: # 큐에 넣는 item 수다(이미지 5장 = 5). 값은 env 로 재정의해 배포 없이 조일 수 있다. # # "LLM 을 타는가" 가 아니라 "외부에 돈이 나가는가" 가 기준이다. 등록 1건은 파싱이 파서로 풀려 LLM 을 안 타더라도 -# fetch 대역, residential proxy 요청(HEADLESS_FIRST 사이트), 헤드리스 렌더러 시간, 이미지 저장, DB 행 영구 증가를 -# 소모한다. 그래서 LLM 여부로 차등하지 않고 등록 1건을 균일하게 센다 — 등록 시점엔 어느 경로로 풀릴지 알 수도 없다. +# fetch 대역, 추출 모듈의 시간, 이미지 저장, DB 행 영구 증가를 소모한다. 그래서 LLM 여부로 차등하지 않고 +# 등록 1건을 균일하게 센다 — 등록 시점엔 어느 경로로 풀릴지 알 수도 없다. # # 무엇이 차감 대상인지의 기준은 "새 파싱 작업이 큐에 들어가는가" 하나다. 그래서 새로고침은 파싱이 한 번 더 도므로 # 신규 등록과 같이 세고, 위시에 있는 item 을 토너먼트로 담는 것은 기존 item 을 참조만 하므로 세지 않는다. diff --git a/src/main/resources/db/migration/V20260816090000__replace_extraction_policies_with_domain_access.sql b/src/main/resources/db/migration/V20260816090000__replace_extraction_policies_with_domain_access.sql new file mode 100644 index 000000000..8997f2052 --- /dev/null +++ b/src/main/resources/db/migration/V20260816090000__replace_extraction_policies_with_domain_access.sql @@ -0,0 +1,28 @@ +-- 플랫폼 정책 테이블을 "허용/차단" 한 축으로 교체한다. +-- +-- 왜 이관하지 않고 새로 만드나: 옛 route 3값과 허가 boolean 은 +-- 서로 다른 질문(등록을 받나 / 어떻게 가져오나 / 확인했나 / 어디까지 허락됐나)에 한 축으로 답하고 있어 +-- 기계적 매핑이 성립하지 않는다. 같은 HEADLESS_FIRST 라도 kream 은 차단이고 store.kakao 는 차단이 아니라 +-- 갈 곳이 다르다. 그래서 값을 옮기지 않고 비운 채 시작하며, 판단이 선 도메인만 백오피스에서 다시 넣는다. +-- +-- 시드를 두지 않는 것도 같은 이유의 의도된 결정이다. 쿠팡·네이버 등이 당분간 실패로 흐르지만, 그 실패는 +-- "아직 판단하지 않았다"는 사실의 정직한 반영이다 — 자동 차단 감지(후속)나 메일 회신으로 판단이 서면 그때 +-- BLOCKED 로 넣는다. 반대로 근거 없이 미리 채우면 옛 테이블이 좀비 행을 갖게 된 경로를 그대로 반복한다. +-- +-- ALLOWED 는 "플랫폼의 명시적 허락을 받았다"는 뜻이고, 그 결과로 추출 모듈이 적극적인 수단까지 쓸 수 있게 +-- 된다. 허락은 사람이 받아 오는 것이라 근거(permission_ref) 없이는 켤 수 없다 — 입력 경계가 그걸 강제한다. +CREATE TABLE domain_access_policies ( + domain VARCHAR(255) NOT NULL, + -- ALLOWED | BLOCKED. 값 이름을 enum 이 아니라 문자열로 두는 이유는 옛 테이블과 같다 — 이 바이너리가 + -- 모르는 값 한 행이 findAll 하이드레이션을 깨 부팅을 죽이는 함정을 피하고, 읽는 쪽이 tolerant 하게 진다. + access VARCHAR(16) NOT NULL, + -- 왜 그렇게 판단했나. BLOCKED 면 무엇으로 막혔는지(403·앱 브릿지·로그인 필요), ALLOWED 면 어떤 허락인지. + reason VARCHAR(255) NULL, + -- 허락 근거(메일 스레드·수신일·담당자). ALLOWED 행에는 필수이며 입력 경계가 검증한다. + permission_ref VARCHAR(255) NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (domain) +); + +-- 옛 테이블은 이 마이그레이션에서 지우지 않는다. 코드가 참조를 끊은 배포가 한 바퀴 돈 뒤 별도 마이그레이션으로 +-- 제거한다 — blue-green 공존 구간에서 구버전 인스턴스가 아직 옛 테이블을 읽기 때문이다(단계 배포). diff --git a/src/main/resources/templates/admin/extraction-policies.html b/src/main/resources/templates/admin/extraction-policies.html index cffe6d40c..cb5ebf60b 100644 --- a/src/main/resources/templates/admin/extraction-policies.html +++ b/src/main/resources/templates/admin/extraction-policies.html @@ -4,7 +4,7 @@ - 추출 라우팅 정책 · PiKi 백오피스 + 도메인 접근 정책 · PiKi 백오피스